import { lazy, Suspense, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { Button, Layout, Main, SessionSidebar, type SessionSidebarGroup, type SessionSidebarGroupId, type SessionSidebarProps, type SessionSidebarRepo, Sidebar, type SidebarRow, ThemeProvider, toast, Toaster, TooltipProvider, useLayoutLeft, useLayoutRight, useLocalStorage, useMedia, } from "@ace/ui"; import { IconHash, IconPlus } from "@ace/ui/icons"; import type { Hello, HostRequest, Listing, MoveTarget, People, Project } from "@ace/host/protocol"; import { chosen, deployed, remember } from "./address"; import { Channel, ChannelDetails, type ChannelDraft } from "./channel"; import { Dashboard } from "./dashboard"; import { Github, GithubSidebar } from "./github"; import { useGithubRefresh } from "./github-cache"; import { desktop, here, titlebar } from "./desktop"; import { host } from "./host"; import { Rename } from "./layout/rename"; import { Navigation, type Page, WindowControls } from "./navigation"; import { EmptyProjects, OpenProject } from "./open-project"; import { type AppProject, channelProject, checkoutId, label, projectId, projects, } from "./projects"; import { Settings } from "./settings"; import { SetupProject } from "./setup-project"; import { UpdateNotice } from "./updates"; const Annotations = lazy(() => import("./annotations")); // Older versions saved an inline GitHub item per project; Ace now opens items on GitHub. localStorage.removeItem(`ace:github-targets:${host.url}`); /** * On a phone the channel list is a drawer over the channel: open while no channel is chosen, and * out of the way once one is. */ function Drawers({ page, channel }: { page: string; channel?: string }) { const left = useLayoutLeft(); const { setOpen: setRightOpen } = useLayoutRight(); const phone = useMedia("(width < 40rem)"); const { setOpen } = left; useEffect(() => { if (phone) setOpen(page === "channels" && !channel); }, [phone, page, channel, setOpen]); useEffect(() => { if (page !== "channels" || !channel) setRightOpen(false); }, [page, channel, setRightOpen]); return null; } function ChannelsSidebar(props: SessionSidebarProps) { const left = useLayoutLeft(); const right = useLayoutRight(); const phone = useMedia("(width < 40rem)"); return ( { props.onSelect?.(item); if (phone) left.setOpen(false); right.setOpen(true); }} /> ); } function Disconnected() { if (desktop) return <>Disconnected from Ace Helper. Open Settings → This Mac to start it.; if (!deployed) return <>Disconnected from your host. Reconnecting…; return ( <> Can't reach {chosen}. Check that Tailscale is on.{" "} ); } /** Channels idle this long fold into the Inactive group until their transcript grows again. */ const INACTIVE_AFTER = 6 * 60 * 60_000; const ALL_PROJECTS = "all"; const RECENT_CHANNELS = 10; function row(channel: Listing, user: string, host: string): SidebarRow { return { uid: channel.id, kind: "session", name: channel.name, summary: channel.summary, createdAt: Math.floor(channel.created / 1000), lifecycle: channel.state === "archived" ? "archived" : "live", private: false, mine: channel.owner === user, member: true, capabilities: { rename: channel.owner === user && channel.state !== "offline", archive: channel.host === host, delete: channel.host === host, move: channel.owner === user && channel.host === host && channel.state !== "archived", }, connection: channel.state === "running" ? "connected" : channel.state === "offline" ? "offline" : "idle", agent: channel.state === "running" && channel.busy ? "thinking" : "idle", unreadCount: 0, mentionCount: 0, lastActivityAt: Math.floor((channel.active || channel.created) / 1000), online: [], }; } /** Teammates' archived channels stay folded until someone opens them. */ const folded = ( collapsed: Partial>, id: SessionSidebarGroupId, ) => collapsed[id] ?? id.endsWith(":archived"); /** * A teammate is one participant on every host, so their machines share one section. Their idle * and archived channels stay under their name; only they can archive or delete them. */ function projectGroups( channels: Listing[], hello: Hello, people: People, now: number, collapsed: Partial>, ): SessionSidebarGroup[] { const mine: SidebarRow[] = []; const inactive: SidebarRow[] = []; const archived: SidebarRow[] = []; const team = new Map(); for (const channel of channels) { const next = row(channel, hello.user, hello.host); const stored = channel.state === "archived"; if (channel.owner === hello.user) { const idle = !channel.busy && now - (channel.active || channel.created) > INACTIVE_AFTER; (stored ? archived : idle ? inactive : mine).push(next); continue; } let owned = team.get(channel.owner); if (!owned) { owned = { name: people[channel.owner] || channel.owner, rows: [], archived: [] }; team.set(channel.owner, owned); } (stored ? owned.archived : owned.rows).push(next); } return [ { id: "mine", label: "Channels", rows: mine, collapsed: collapsed.mine }, ...[...team].sort(([, a], [, b]) => a.name.localeCompare(b.name)).flatMap(( [owner, { name, rows, archived }], ): SessionSidebarGroup[] => { const id: SessionSidebarGroupId = `team:${owner}`; const stored: SessionSidebarGroupId = `team:${owner}:archived`; return [ { id, label: name, rows, collapsed: folded(collapsed, id) }, { id: stored, label: `${name} · Archived`, rows: archived, collapsed: folded(collapsed, stored), }, ]; }), { id: "inactive", label: "Inactive", rows: inactive, collapsed: collapsed.inactive }, { id: "archived", label: "Archived", rows: archived, collapsed: collapsed.archived }, ]; } export function App() { const channels = useSyncExternalStore(host.subscribe, () => host.channels); const opened = useSyncExternalStore(host.subscribe, () => host.projects); const status = useSyncExternalStore(host.subscribe, () => host.status); const people = useSyncExternalStore(host.subscribe, () => host.people); const [hello, setHello] = useState({ user: "", host: "" }); const [page, setPage] = useLocalStorage("ace:page", "dashboard"); const [project, setProject] = useLocalStorage("ace:project-id", undefined); const [all, setAll] = useLocalStorage("ace:all-projects", false); const [selected, setSelected] = useLocalStorage>( "ace:project-channels", {}, ); const [adding, setAdding] = useState(false); /** A project to clone here; `start` continues into channel creation with its initial text. */ const [setup, setSetup] = useState<{ project: AppProject & { repo: string }; start?: { text?: string; onCreated?: () => void }; }>(); const [settings, setSettings] = useState(false); const [draft, setDraft] = useState(); const [renaming, setRenaming] = useState<{ id: string; name: string }>(); const [left, setLeft] = useLocalStorage("panel:left", true); const [width, setWidth] = useLocalStorage("panel:left:width", 200); const [collapsed, setCollapsed] = useState>({ pinned: false, mine: false, team: false, inactive: true, archived: true, }); const [now, setNow] = useState(Date.now); const picking = useRef(false); const creating = useRef(false); useEffect(() => { const open = () => setSettings("updates"); window.addEventListener("ace:updates", open); if (location.hash === "#updates") { history.replaceState(null, "", location.pathname + location.search); open(); } return () => window.removeEventListener("ace:updates", open); }, []); useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 60_000); return () => clearInterval(timer); }, []); useEffect(() => { if (status !== "open") return; host.request({ op: "hello" }).then(setHello, () => {}); }, [status]); const available = useMemo(() => projects(opened, channels, hello.host), [ opened, channels, hello.host, ]); useGithubRefresh(available); const allProjects = all && page === "channels"; const listings = new Map(channels.map((channel) => [channel.id, channel])); const allSelected = listings.get(selected[ALL_PROJECTS]); const byId = useMemo(() => { const values = new Map(); for (const value of available) { // Earlier versions saved checkout IDs for project and channel selections. for (const checkout of value.checkouts) values.set(checkout, value); values.set(value.id, value); } return values; }, [available]); const current = (project && byId.get(project)) || available[0]; const visible = current ? channels.filter((channel) => channelProject(channel) === current.id) : []; // The saved checkout ID names the selection an earlier version made among merged checkouts. const channel = allProjects ? allSelected : current && [ current.id, project, ...current.checkouts, ].map((id) => id ? listings.get(selected[id]) : undefined).find((value) => value && channelProject(value) === current.id ); const connected = status === "open" && !!hello.host; const local = current?.host === hello.host; // GitHub projects can be cloned here; other folders exist only on the hosts that hold them. const creatable = local || !!current?.repo; const repos: SessionSidebarRepo[] = [ { id: ALL_PROJECTS, name: "All Projects", org: "", kind: "all", }, ...available.map((value) => ({ id: value.id, name: label(value, hello.host), org: value.repo?.split("/")[0] || "", })), ]; let groups = projectGroups(visible, hello, people, now, collapsed); if (allProjects) { const byProject = new Map(); for (const value of channels) { if (value.state === "archived") continue; const id = channelProject(value); const rows = byProject.get(id); if (rows) rows.push(value); else byProject.set(id, [value]); } groups = available.map((value) => { const id: SessionSidebarGroupId = `project:${value.id}`; const rows = (byProject.get(value.id) || []).sort((a, b) => (b.active || b.created) - (a.active || a.created) || b.created - a.created || b.id.localeCompare(a.id) ).slice(0, RECENT_CHANNELS).map((value) => row(value, hello.user, hello.host)); return { id, label: label(value, hello.host), rows, sort: "none", collapsed: collapsed[id], onNewSession: connected && (value.host === hello.host || value.repo) ? () => void create(undefined, undefined, value) : undefined, }; }); } function chooseProject(id: string) { setAll(id === ALL_PROJECTS); if (id === ALL_PROJECTS) return setPage("channels"); setProject(id); } function select(value: Pick) { const id = channelProject(value); setProject(id); if (!allProjects) setAll(false); setSelected((previous) => ({ ...previous, [id]: value.id, ...(allProjects ? { [ALL_PROJECTS]: value.id } : {}), })); setPage("channels"); } async function open(path: string) { const value = await host.request({ op: "project-open", path }); chooseProject(projectId(hello.host, value.path, value.repo)); } async function choose() { if (!connected || picking.current) return; if (!desktop) return setAdding(true); picking.current = true; try { const path = await desktop.project(); if (path) await open(path); } catch (error) { toast.error("Could not open project", { description: (error as Error).message }); } finally { picking.current = false; } } function startSetup(project: AppProject, start?: { text?: string; onCreated?: () => void }) { if (project.repo) setSetup({ project: { ...project, repo: project.repo }, start }); } /** Channels only start on this host; setup may clone here first, never on the other host. */ async function finishSetup(path: string) { if (!setup) return; const value = await host.request({ op: "project-clone", repo: setup.project.repo, path, }); const id = projectId(hello.host, value.path, value.repo); setSetup(undefined); if (!setup.start) return chooseProject(id); const target: AppProject = { ...value, id, host: hello.host, hosts: new Set([hello.host]), checkouts: new Set([checkoutId(hello.host, value.path)]), }; await create(setup.start.text, setup.start.onCreated, target); } async function create( text?: string, onCreated?: () => void, target = current, ): Promise { if (!target || !connected || creating.current) return; if (target.host !== hello.host) return startSetup(target, { text, onCreated }); creating.current = true; try { const value = await host.request>({ op: "create", project: target.path, }); if (text) { try { await host.channel(value.id, { op: "ask", author: hello.user, text }); } catch (error) { setDraft({ channel: value.id, text }); toast.error("Could not invoke agent", { description: (error as Error).message, action: { label: "Settings", onClick: () => setSettings(true) }, }); } } onCreated?.(); select({ ...value, host: hello.host, root: target.path, repo: target.repo }); } catch (error) { toast.error("Could not start channel", { description: (error as Error).message, action: { label: "Settings", onClick: () => setSettings(true) }, }); } finally { creating.current = false; } } async function change(request: HostRequest) { try { await host.request(request); } catch (error) { toast.error("Could not update channel", { description: (error as Error).message }); } } async function archiveInactive(project: string) { try { const count = await host.request({ op: "archive-inactive", project }); toast( count ? `Archived ${count} inactive channel${count === 1 ? "" : "s"}` : "No inactive channels", ); } catch (error) { toast.error("Could not archive channels", { description: (error as Error).message }); } } function moveTargets(item: SidebarRow) { return host.request({ op: "move-targets", channel: item.uid }); } async function move(item: SidebarRow, target: MoveTarget) { const id = toast.loading(`Moving ${item.name} to ${target.label}…`); try { await host.request({ op: "move", channel: item.uid, target: target.target }); toast.success(`Moved ${item.name} to ${target.label}`, { id }); } catch (error) { toast.error("Could not move channel", { id, description: (error as Error).message }); } } async function rename(name: string) { if (!renaming) return; await host.channel(renaming.id, { op: "rename", author: hello.user, name: name.trim() }); setRenaming(undefined); } return ( setSettings(true)} /> } > void choose()} onSettings={() => setSettings(true)} onPage={setPage} connected={connected} /> {page !== "dashboard" && current && ( {page === "channels" ? ( chooseProject(repo.id)} onAddRepo={connected ? () => void choose() : undefined} onSelect={(item) => { const value = listings.get(item.uid); if (value) select(value); }} onNewSession={!allProjects && creatable && connected ? () => void create() : undefined} onRename={connected ? (item) => setRenaming({ id: item.uid, name: item.name }) : undefined} onMoveTargets={connected && !deployed ? moveTargets : undefined} onMove={connected && !deployed ? move : undefined} onToggleGroup={(id) => setCollapsed((value) => ({ ...value, [id]: !folded(value, id) }))} onArchive={(allProjects || local) && connected ? (item) => void change({ op: "archive", channel: item.uid, archived: item.lifecycle !== "archived", }) : undefined} onArchiveInactive={!allProjects && local && connected ? () => void archiveInactive(current.path) : undefined} onDelete={!allProjects && local && connected ? (item) => void change({ op: "delete", channel: item.uid }) : undefined} empty={

{allProjects ? "No recent channels across your projects." : "No channels in this project yet."}

} /> ) : ( void choose()} /> )}
)}
{deployed && status === "connecting" && !channels.length && (

Connecting to{" "} {chosen}… If your browser asks to reach devices on your local network, allow it.

)} {status === "closed" && (

)} {!current && !connected ? null : !current ? void choose()} disabled={!connected} /> : page === "dashboard" ? ( startSetup(current) : undefined} onProject={chooseProject} onOpen={() => void choose()} onChannel={select} onCreate={create} /> ) : page === "issues" || page === "prs" ? ( ) : channel ? ( setDraft(undefined)} onSettings={() => setSettings(true)} /> ) : (

{allProjects ? "Choose a channel from any project" : `Choose a channel in ${current.name}`}

{allProjects && (

Use + beside a project to start a new channel.

)} {!allProjects && !local && (

{current.name} is checked out on {[...current.hosts].join(", ")}.{" "} {current.repo ? `Set it up on ${here} to start channels here.` : "Open a checkout on this host to start channels here."}

)} {!allProjects && creatable && (
{!local && ( )}
)}
)}
{page === "channels" && channel && }
{adding && setAdding(false)} />} {setup && ( setSetup(undefined)} /> )} {renaming && ( setRenaming(undefined)} /> )} {settings && ( setSettings(false)} /> )} {desktop && window.dispatchEvent(new Event("ace:updates"))} />} {desktop && ( )}
); }