import { useEffect, useState } from "react"; import * as Api from "./api"; import { readChannelRecovery, rememberChannel } from "./channel-recovery"; import { RepositoryPicker } from "./repository-picker"; import { clearRepositoryCache } from "./repository-cache"; import type { ComponentType, FormEvent, ReactNode } from "react"; export type HostedWorkspaceProps = { room: string; handle: string; label: string; repository: Api.Repository; canEdit: boolean; agent?: boolean; userId: string; }; export type HostedRoute = | { page: "repositories" } | { page: "repository"; owner: string; repository: string } | { page: "channel"; id: string } | { page: "missing" }; 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 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; } function Frame( { children, currentRepository, openRepositoryPicker = false, user, }: { children: ReactNode; currentRepository?: Api.Repository | { owner: string; name: string; fullName: string }; openRepositoryPicker?: boolean; user: Api.User; }, ) { async function signOut() { await Api.logout(); clearRepositoryCache(user.id); location.assign("/"); } return (
chopin
{children}
); } function Loading({ label = "Loading" }: { label?: string }) { return (
{label}
); } function repositoryHref(repository: { owner: string; name: string }): string { return `/repositories/${encodeURIComponent(repository.owner)}/${ encodeURIComponent(repository.name) }`; } function Failure( { channel, error, onRetry, repository, }: { channel?: { id: string; title?: 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.id}

{repository && (

{repository.fullName}

)}
)}
{onRetry && ( )} {repository && ( View {repository.fullName} channels )} Back to repositories
); } export function HostedLogin() { 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
); } function RepositoryHome({ user }: { user: Api.User }) { return (

Choose a repository from the menu to see its planning channels.

); } function RepositoryChannels( { owner, repository, user }: { owner: string; repository: string; user: Api.User }, ) { let [page, setPage] = useState(); let [error, setError] = useState(); let [title, setTitle] = useState(""); let [creating, setCreating] = useState(false); let [loadingMore, setLoadingMore] = useState(false); useEffect(() => { let active = true; Api.channels(owner, repository).then(value => { if (active) setPage(value); }, reason => { if (active) setError(reason); }); return () => { active = false; }; }, [owner, repository]); async function create(event: FormEvent) { event.preventDefault(); if (!title.trim() || creating) return; setCreating(true); try { let result = await Api.createChannel(owner, repository, title.trim()); rememberChannel(user.id, result.channel, result.repository); location.assign(`/channels/${result.channel.id}`); } catch (reason) { setError(reason); setCreating(false); } } async function more() { if (!page?.nextCursor || loadingMore) return; setLoadingMore(true); try { let next = await Api.channels(owner, repository, page.nextCursor); setPage({ ...next, channels: [...page.channels, ...next.channels] }); } catch (reason) { setError(reason); } finally { setLoadingMore(false); } } if (error) return ; return (

Planning channels

One shared plan and conversation per channel.

{page?.canEdit && (
setTitle(event.target.value)} placeholder="Plan the next release" value={title} />
)} {!page ?

Loading channels...

: page.channels.length === 0 ? (

No planning channels yet

{page.canEdit ? "Create the first one above." : "A repository editor can create one."}

) : ( )} {page?.nextCursor && ( )}
); } function ChannelWorkspace( { agent, id, user }: { agent: boolean; id: string; user: Api.User; }, ) { let [loaded, setLoaded] = useState<{ detail: Api.ChannelDetail; Workspace: ComponentType; }>(); let [error, setError] = useState(); let [retry, setRetry] = useState(0); let recovery = readChannelRecovery(user.id, id); useEffect(() => { let active = true; let detail = Api.channel(id).then(value => { if (active) rememberChannel(user.id, value.channel, value.repository); return value; }); Promise.all([detail, import("./room-workspace")]).then(([detail, module]) => { if (active) setLoaded({ detail, Workspace: module.RoomWorkspace }); }, reason => { if (active) setError(reason); }); return () => { active = false; }; }, [id, retry]); if (error) { return ( { setError(undefined); setRetry(value => value + 1); } : undefined} repository={recovery?.repository} /> ); } if (!loaded) return ; let { detail, Workspace } = loaded; return ( ); } export function HostedApp( { agent, user }: { agent: boolean; user: Api.User }, ) { let route = hostedRoute(location.pathname); switch (route.page) { case "repositories": return ; case "repository": return ; case "channel": return ; case "missing": return ; } } export { Failure as HostedFailure, Loading as HostedLoading };