import { 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, 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 { GithubLinks, type GithubTarget } from "./github-link"; import { desktop, 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 { projectId, projects } from "./projects"; import { Settings } from "./settings"; import { UpdateNotice } from "./updates"; /** * 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.{" "} ); } function row(channel: Listing, user: 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: true, }, connection: channel.state === "running" ? "connected" : channel.state === "offline" ? "offline" : "idle", agent: "idle", unreadCount: 0, mentionCount: 0, lastActivityAt: Math.floor(channel.created / 1000), online: [], }; } 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 [hello, setHello] = useState({ user: "", host: "" }); const [page, setPage] = useLocalStorage("ace:page", "dashboard"); const [project, setProject] = useLocalStorage("ace:project-id", undefined); const [selected, setSelected] = useLocalStorage>( "ace:project-channels", {}, ); const [adding, setAdding] = useState(false); const [settings, setSettings] = useState(false); const [draft, setDraft] = useState(); const [renaming, setRenaming] = useState<{ id: string; name: string }>(); const [github, setGithub] = useLocalStorage< Record>> >(`ace:github-targets:${host.url}`, {}); 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, archived: true, }); 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(() => { 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 current = available.find((value) => value.id === project) || available[0]; const visible = channels.filter((channel) => channel.host === current?.host && channel.project === current.path ); const channel = current ? visible.find((value) => value.id === selected[current.id]) : undefined; const connected = status === "open" && !!hello.host; const local = current?.host === hello.host; const repos: SessionSidebarRepo[] = available.map((value) => ({ id: value.id, name: value.host === hello.host ? value.name : `${value.name} · ${value.host}`, org: "", })); const groups: SessionSidebarGroup[] = [ { id: "mine", label: "Channels", rows: visible.filter((value) => value.state !== "archived" && value.owner === hello.user).map( (value) => row(value, hello.user), ), collapsed: collapsed.mine, }, { id: "team", label: "Team", rows: visible.filter((value) => value.state !== "archived" && value.owner !== hello.user).map( (value) => row(value, hello.user), ), collapsed: collapsed.team, }, { id: "archived", label: "Archived", rows: visible.filter((value) => value.state === "archived").map((value) => row(value, hello.user) ), collapsed: collapsed.archived, }, ]; function select(value: Pick) { const id = projectId(value.host, value.project); setProject(id); setSelected((previous) => ({ ...previous, [id]: value.id })); setPage("channels"); } async function open(path: string) { const value = await host.request({ op: "project-open", path }); setProject(projectId(hello.host, value.path)); } 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; } } async function create(text?: string, onCreated?: () => void): Promise { if (!current || !local || !connected || creating.current) return; creating.current = true; try { const value = await host.request>({ op: "create", project: current.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 }); } 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 rename(name: string) { if (!renaming) return; await host.channel(renaming.id, { op: "rename", author: hello.user, name: name.trim() }); setRenaming(undefined); } function setTarget(kind: "issues" | "prs", target?: GithubTarget) { if (!current) return; setGithub((value) => ({ ...value, [current.id]: { ...value[current.id], [kind]: target }, })); } function openGithub(target: GithubTarget) { setTarget(target.kind, target); setPage(target.kind); } return ( {current && } setSettings(true)} /> } > void choose()} onSettings={() => setSettings(true)} onPage={setPage} connected={connected} /> {page !== "dashboard" && current && ( {page === "channels" ? ( setProject(repo.id)} onAddRepo={connected ? () => void choose() : undefined} onSelect={(item) => { const value = visible.find((value) => value.id === item.uid); if (value) select(value); }} onNewSession={local && connected ? () => void create() : undefined} onRename={connected ? (item) => setRenaming({ id: item.uid, name: item.name }) : undefined} onToggleGroup={(id) => setCollapsed((value) => ({ ...value, [id]: !value[id] }))} onArchive={local && connected ? (item) => void change({ op: "archive", channel: item.uid, archived: item.lifecycle !== "archived", }) : undefined} onDelete={local && connected ? (item) => void change({ op: "delete", channel: item.uid }) : undefined} empty={

No channels in this project yet.

} /> ) : ( void choose()} onTarget={(target) => setTarget(page, target)} /> )}
)}
{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" ? ( void choose()} onChannel={select} onCreate={create} /> ) : page === "issues" || page === "prs" ? ( setTarget(page, target)} /> ) : channel ? ( setDraft(undefined)} onSettings={() => setSettings(true)} /> ) : (

Choose a channel in {current.name}

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