import { useCallback, useEffect, useReducer, useRef, useState } from "react"; import { ContentSwapLayer } from "@chopin/editor/content-swap"; import { childDocumentPath, documentPath, documentsPath, parseChildDocumentPath, parseDocumentPath, parseResearchWorkspacePath, researchWorkspacePath, } from "@chopin/protocol/document-url"; import * as Api from "./api"; import { readChannelRecovery, readDocumentRecovery, rememberChannel } from "./channel-recovery"; import { documentRouteIdentity, transitionDocumentRoute } from "./document-route-swap"; import { newestDocument } from "./document-actions"; import { motionContract } from "./motion-contract"; import { motionImmediately } from "./motion-input"; import { NavigationShell, useNavigationDocument } from "./navigation-shell"; import type { ComponentType, ReactNode } from "react"; import type { DocumentRouteIdentity, DocumentRouteIdentitySource, DocumentRouteSwap as DocumentRouteSwapState, } from "./document-route-swap"; export type HostedWorkspaceProps = { room: string; handle: string; label: string; slug: string; updatedAt: string; descriptionRevision: number; description?: string; repository: Api.Repository; canEdit: boolean; canManage: boolean; archivedAt?: string; agent?: boolean; userId: string; }; export type HostedRoute = | DocumentRouteIdentitySource | { page: "repositories" } | { page: "repository"; owner: string; repository: string } | { page: "missing" }; type DocumentRoute = Exclude; type ChannelSource = DocumentRouteIdentitySource; type DocumentSource = DocumentRoute; type DocumentRouteRequest = { immediately: boolean; key: DocumentRouteIdentity; source: DocumentSource; }; type DocumentRouteResolution = { canonicalPath: string; channel: Api.Channel }; type DocumentRouteLayer = DocumentRouteSwapState< DocumentRouteRequest, DocumentRouteResolution >["current"]; function keyedDocumentRoute( route: DocumentRoute, immediately: boolean, ): DocumentRouteRequest { return { immediately, key: documentRouteIdentity(route), source: route, }; } function decoded(value: string): string | undefined { try { return decodeURIComponent(value); } catch { return undefined; } } export function hostedRoute(pathname: string): HostedRoute { if (pathname === "/" || pathname === "") return { page: "repositories" }; let research = parseResearchWorkspacePath(pathname); if (research) return { page: "research", ...research }; let child = parseChildDocumentPath(pathname); if (child) return { page: "child", ...child }; let document = parseDocumentPath(pathname); if (document?.slug) { return { page: "document", owner: document.owner, repository: document.repository, slug: document.slug, }; } if (document) return { page: "repository", ...document }; let repository = /^\/repositories\/([^/]+)\/([^/]+)\/?$/.exec(pathname); if (repository) { let owner = decoded(repository[1]!); let name = decoded(repository[2]!); if (owner && name) return { page: "repository", owner, repository: name }; } let channel = /^\/channels\/([0-9a-f-]{36})\/?$/i.exec(pathname); if (channel) return { page: "channel", id: channel[1]!.toLowerCase() }; return { page: "missing" }; } export function retryableChannelFailure(error: unknown): boolean { return !(error instanceof Api.ApiError) || error.status === 408 || error.status === 429 || error.status >= 500; } export function validatedChildPath( child: Api.ChannelDetail, parent: Api.ChannelDetail, ): string { let sameRepository = child.repository.id === parent.repository.id && child.channel.repositoryId === child.repository.id && parent.channel.repositoryId === parent.repository.id; if ( !child.channel.parentChannelId || child.channel.parentChannelId !== parent.channel.id || parent.channel.parentChannelId || !sameRepository || !parent.channel.slug || !child.channel.slug ) { throw new Api.ApiError("Child document not found", 404); } return childDocumentPath( child.repository.owner, child.repository.name, parent.channel.slug, child.channel.slug, ); } type DocumentReaders = { channel: (id: string, signal?: AbortSignal) => Promise; document: ( owner: string, repository: string, slug: string, signal?: AbortSignal, ) => Promise; }; export async function prepareDocumentLoad( address: | { id: string } | { owner: string; repository: string; slug: string; parentSlug?: string; }, signal: AbortSignal, readers: DocumentReaders = Api, ): Promise<{ detail: Api.ChannelDetail; pathname: string }> { if ("id" in address) { let detail = await readers.channel(address.id, signal); if (!detail.channel.parentChannelId) { return { detail, pathname: documentPath( detail.repository.owner, detail.repository.name, detail.channel.slug, ), }; } let parent = await readers.channel(detail.channel.parentChannelId, signal); return { detail, pathname: validatedChildPath(detail, parent) }; } if (address.parentSlug) { let [detail, parent] = await Promise.all([ readers.document(address.owner, address.repository, address.slug, signal), readers.document(address.owner, address.repository, address.parentSlug, signal), ]); return { detail, pathname: validatedChildPath(detail, parent) }; } let detail = await readers.document( address.owner, address.repository, address.slug, signal, ); if (detail.channel.parentChannelId) { let parent = await readers.channel(detail.channel.parentChannelId, signal); return { detail, pathname: validatedChildPath(detail, parent) }; } return { detail, pathname: documentPath( detail.repository.owner, detail.repository.name, detail.channel.slug, ), }; } function Loading({ label = "Loading" }: { label?: string }) { return (
{label}
); } function repositoryHref(repository: { owner: string; name: string }): string { return documentsPath(repository.owner, repository.name); } function canonicalize(pathname: string): void { if (location.pathname === pathname) return; history.replaceState(null, "", `${pathname}${location.search}${location.hash}`); } function Failure( { channel, error, onRetry, repository, }: { channel?: { title?: string; slug?: string }; error: unknown; onRetry?: () => void; repository?: Pick; }, ) { let message = error instanceof Error ? error.message : "Something went wrong"; return (

Cannot open Chopin

{message}

{channel && (
{channel.title &&

{channel.title}

} {channel.slug && (

{channel.slug}

)} {repository && (

{repository.fullName}

)}
)}
{onRetry && ( )} {repository && ( View {repository.fullName} channels )} Back to repositories
); } export function HostedLogin() { let href = githubLoginHref(location.pathname, location.search, location.hash); return (

chopin

Plan together, in the context of the code.

A shared document, a visible conversation, and an agent that can read the repository without owning the decision.

Open your workspace

Sign in with GitHub to choose a repository and its planning channels.

Continue with GitHub
); } export function githubLoginHref(pathname: string, search = "", hash = ""): string { let parameters = new URLSearchParams({ return_to: `${pathname}${search}${hash}` }); return `/auth/github?${parameters}`; } function ChannelWorkspace( { agent, onReady, source, user }: { agent: boolean; onReady?: (key: DocumentRouteIdentity, resolution?: DocumentRouteResolution) => void; source: ChannelSource; user: Api.User; }, ) { type LoadedWorkspace = | { kind: "document"; detail: Api.ChannelDetail; Workspace: ComponentType; } | { kind: "research"; detail: Api.ChannelDetail; Workspace: ComponentType; workspaceId: string; }; let [loaded, setLoaded] = useState(); let [error, setError] = useState(); let [retry, setRetry] = useState(0); let { channel: navigationChannel } = useNavigationDocument(); let routeKey = documentRouteIdentity(source); let recovery; switch (source.page) { case "channel": recovery = readChannelRecovery(user.id, source.id); break; case "child": recovery = readDocumentRecovery( user.id, source.owner, source.repository, source.childSlug, ); break; case "document": case "research": recovery = readDocumentRecovery( user.id, source.owner, source.repository, source.slug, ); break; } useEffect(() => { let active = true; let controller = new AbortController(); setLoaded(undefined); setError(undefined); let prepared = source.page === "research" ? Api.document( source.owner, source.repository, source.slug, controller.signal, ).then(detail => ({ canonicalPath: researchWorkspacePath( detail.repository.owner, detail.repository.name, detail.channel.slug, source.workspaceId, ), detail, })) : prepareDocumentLoad( source.page === "channel" ? { id: source.id } : { owner: source.owner, parentSlug: source.page === "child" ? source.parentSlug : undefined, repository: source.repository, slug: source.page === "child" ? source.childSlug : source.slug, }, controller.signal, ).then(({ detail, pathname }) => ({ canonicalPath: pathname, detail })); prepared = prepared.then(resolved => { if (active) { rememberChannel(user.id, resolved.detail.channel, resolved.detail.repository); } return resolved; }); let workspace; switch (source.page) { case "channel": case "child": case "document": workspace = import("./room-workspace").then(module => ({ kind: "document" as const, Workspace: module.RoomWorkspace, })); break; case "research": workspace = import("./research-workspace").then(module => ({ kind: "research" as const, Workspace: module.ResearchWorkspace, workspaceId: source.workspaceId, })); break; } Promise.all([prepared, workspace]).then(([resolved, selected]) => { if (active) { setLoaded({ detail: resolved.detail, ...selected } as LoadedWorkspace); onReady?.(routeKey, { canonicalPath: resolved.canonicalPath, channel: resolved.detail.channel, }); } }, reason => { if (active) { setError(reason); onReady?.(routeKey); } }); return () => { active = false; controller.abort(); }; }, [onReady, retry, routeKey, source, user.id]); if (error) { let requestedRepository; let requestedSlug; switch (source.page) { case "channel": break; case "child": requestedRepository = { fullName: `${source.owner}/${source.repository}`, name: source.repository, owner: source.owner, }; requestedSlug = source.childSlug; break; case "document": case "research": requestedRepository = { fullName: `${source.owner}/${source.repository}`, name: source.repository, owner: source.owner, }; requestedSlug = source.slug; break; } return ( { setError(undefined); setRetry(value => value + 1); } : undefined} repository={recovery?.repository ?? requestedRepository} /> ); } if (!loaded) return ; let { detail } = loaded; let channel = navigationChannel?.id === detail.channel.id ? newestDocument(detail.channel, navigationChannel) : detail.channel; let props: HostedWorkspaceProps = { agent, archivedAt: channel.archivedAt, canEdit: !channel.archivedAt && (detail.canEdit || detail.canManage), canManage: detail.canManage, description: channel.description, descriptionRevision: channel.descriptionRevision, handle: user.login, label: channel.title, slug: channel.slug, updatedAt: channel.updatedAt, repository: detail.repository, room: detail.channel.id, userId: user.id, }; if (loaded.kind === "research") { let Research = loaded.Workspace; return ; } let Document = loaded.Workspace; return ; } function ActiveChannelWorkspace( { agent, source, user }: { agent: boolean; source: ChannelSource; user: Api.User }, ) { let { onDocumentLoaded } = useNavigationDocument(); let ready = useCallback((key: DocumentRouteIdentity, resolution?: DocumentRouteResolution) => { if (!resolution) return; canonicalize(resolution.canonicalPath); void onDocumentLoaded(resolution.channel, key); }, [onDocumentLoaded]); return ; } function DocumentRouteSwap( { agent, route, user }: { agent: boolean; route: DocumentRoute; user: Api.User }, ) { let requested = keyedDocumentRoute(route, motionImmediately()); let [state, dispatch] = useReducer( transitionDocumentRoute, { current: requested }, ); let presentedRequest = state.pending ?? state.current; if (requested.key !== presentedRequest.key) { dispatch({ route: requested, type: "requested" }); } let layers = [state.previous, state.current, state.pending].filter( (layer): layer is DocumentRouteLayer => layer !== undefined, ); let motion = motionContract("content-swap"); let ready = useCallback(( key: DocumentRouteIdentity, resolution?: DocumentRouteResolution, ) => { dispatch({ key, resolution, type: "ready" }); }, []); let { onDocumentLoaded } = useNavigationDocument(); let published = useRef(undefined); useEffect(() => { let resolution = state.current.resolution; if (!resolution || published.current === resolution) return; published.current = resolution; canonicalize(resolution.canonicalPath); void onDocumentLoaded(resolution.channel, state.current.key); }, [onDocumentLoaded, state.current.key, state.current.resolution]); return (
{layers.map(layer => ( dispatch({ key: layer.key, type: "closed" }) : undefined} > ))}
); } export function HostedApp( { agent, user }: { agent: boolean; user: Api.User }, ) { let [route, setRoute] = useState(() => hostedRoute(location.pathname)); let navigate = useCallback((destination: string, options: { replace?: boolean } = {}) => { let target = new URL(destination, location.href); if (target.origin !== location.origin) { location.assign(target.href); return; } let next = `${target.pathname}${target.search}${target.hash}`; let current = `${location.pathname}${location.search}${location.hash}`; if (next === current) return; if (options.replace) history.replaceState(null, "", next); else history.pushState(null, "", next); setRoute(hostedRoute(target.pathname)); }, []); useEffect(() => { let changed = () => setRoute(hostedRoute(location.pathname)); window.addEventListener("popstate", changed); return () => window.removeEventListener("popstate", changed); }, []); if (route.page === "missing") { return ; } let workspace: ReactNode; switch (route.page) { case "repositories": case "repository": break; case "document": case "child": case "channel": workspace = ; break; case "research": workspace = ( ); break; } return ( {workspace} ); } export { Failure as HostedFailure, Loading as HostedLoading };