pi-mono/apps/p2/plugins/sessions/web.tsx
Mario Zechner 20d86c3657 feat(p2): Chord v2 cleanup pass
p2 rewritten by the Chord v2 authoring rules (decision "App authoring rules confirmed
by the p2 audit"), in four parts merged together:
- core: session lifecycle (relocate, clone, fork, archive) and search rebuild are
  persisted jobs on a new server store; Sessions v6 with client-chosen ids and visible
  operations; Retry answers with the worker's real outcome; workers push branch,
  subagent and status state to the server; roots take the method's ctx.
- services: models, envs, ports, login, mcp, logs, sharing and voice follow external
  state (self.follow, followNative) instead of copy loops; methods answer after their
  state commits; presence from host.references.
- chat: chat mode, artifacts and prompt resources follow Durable and native state;
  shell commands are Chord jobs (shellId is the requestId); status pushed before
  send and stop answer; race-free draft claim.
- ui: optimistic updates on web (useOptimistic) and terminal (TuiScope.optimistic,
  extended in Chord with a reducer and a pending flag), pending states for long work,
  visible rollback, load waterfalls removed; merged with the one composer and
  immediate first send (ccff9876e) and the folder + button (5a31a6d5f).
docs/optimistic-ui.md describes the pattern.
2026-11-05 18:22:13 +00:00

377 lines
14 KiB
TypeScript

// The mobile drawer's component tree, with catalog/status/subagent/search handles supplying its data, and the owner's
// session actions on its rows.
import { Access, BACKGROUND_CONTEXT, contribute, defineFacet, type HeldIn, liveIn, whenLive } from "@p2/app/v1";
import {
ChevronIcon,
closeLayers,
conversationRoute,
Drawer,
MoreIcon,
navigate,
openLayer,
rememberMains,
type SessionSummary,
Slot,
Slots,
toggleSidebar,
useComposerState,
useConnected,
useHost,
useNavigation,
useNewSessionState,
useObject,
useOptimisticCall,
useOwner,
useReadable,
useRoute,
useSheet,
} from "@p2/app/v1/web";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { directoryPickerLayer } from "../envs/contract.web.tsx";
import { Search, SessionReads } from "../search/contract.ts";
import { SessionStatus } from "../status/contract.ts";
import { Subagents } from "../subagents/contract.ts";
import { SessionTree } from "../tree/contract.ts";
import { BranchSheet } from "./BranchSheet.tsx";
import { type Operation, type Session, Sessions } from "./contract.ts";
import { SessionSheet } from "./SessionSheet.tsx";
const subscribeVisibility = (listener: () => void) => {
document.addEventListener("visibilitychange", listener);
return () => document.removeEventListener("visibilitychange", listener);
};
/** Whether the page is shown (not a background tab or a hidden app). */
const usePageVisible = () => useSyncExternalStore(subscribeVisibility, () => document.visibilityState === "visible");
/**
* The footer's owner entries, in an explicit order, behind one "More" button whose menu opens upwards: one row of
* buttons no longer fits them. The entries stay mounted while the menu is closed, because each renders the sheet it
* opens (through a portal, so the hidden menu does not hide it).
*/
function FooterMenu() {
const [open, setOpen] = useState(false);
const menu = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const outside = (event: PointerEvent) => {
if (!menu.current?.contains(event.target as Node)) setOpen(false);
};
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", outside);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointerdown", outside);
document.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<div className="foot-menu" ref={menu}>
<button
type="button"
className={`foot-button ${open ? "on" : ""}`}
aria-haspopup="true"
aria-expanded={open}
onClick={() => setOpen(!open)}
>
More
<span className="foot-menu-chevron">
<ChevronIcon size={14} />
</span>
</button>
{/* An entry's click opens its sheet, then closes the menu. */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents lint/a11y/noStaticElementInteractions: the entries are buttons; this only notices their clicks. */}
<div className="foot-menu-list" hidden={!open} onClick={() => setOpen(false)}>
<Slot name="sidebar-foot:providers" />
<Slot name="sidebar-foot:mcp" />
<Slot name="sidebar-foot:envs" />
<Slot name="sidebar-foot:devices" />
<Slot name="sidebar-foot:ports" />
<Slot name="sidebar-foot:voice" />
<Slot name="sidebar-foot:logs" />
</div>
</div>
);
}
const footer = <FooterMenu />;
/** What a row says about a lifecycle operation (`Sessions.operations`), by kind: running, and failed. */
const OPERATION_LABELS: Record<Operation["kind"], { running: string; failed: string }> = {
relocate: { running: "Moving…", failed: "Move failed" },
clone: { running: "Cloning…", failed: "Clone failed" },
fork: { running: "Forking…", failed: "Fork failed" },
archive: { running: "Archiving…", failed: "Archive failed" },
};
/**
* The operation row `session` shows: a failed one of it first (with Dismiss), else one running on it, else a copy
* into it whose files are still being copied (its worker waits for them).
*/
function operationOf(
operations: readonly Operation[],
session: string,
dismiss: (operation: string) => void,
): SessionSummary["operation"] {
const failed = operations.find((operation) => operation.phase === "failed" && operation.session === session);
if (failed?.phase === "failed")
return { label: OPERATION_LABELS[failed.kind].failed, error: failed.error, dismiss: () => dismiss(failed.id) };
const running = operations.find((operation) => operation.session === session);
if (running) return { label: OPERATION_LABELS[running.kind].running };
const copying = operations.find(
(operation) => operation.phase === "running" && operation.target === session && operation.copying,
);
return copying ? { label: "Copying files…" } : undefined;
}
/** A change to one session's row while its call is in flight. */
type RowGuess = { readonly id: string; readonly change: Partial<Session> };
const applyRow = (rows: Session[] | undefined, { id, change }: RowGuess): Session[] | undefined =>
rows?.map((row) => (row.id === id ? { ...row, ...change } : row));
/**
* The drawer, and the owner's session actions on its rows: rename, pin, archive and restore (the Session sheet is
* rendered here, opened from the top bar's button), switching and renaming `/tree` branches. Each action shows its
* effect on the rows at once (`useOptimisticCall`); a refused one rolls back and the toast says why.
*/
function Sidebar() {
const connected = useConnected();
const route = useRoute();
const nav = useNavigation();
const service = useObject(Sessions, {});
const catalog = liveIn(service, "owner");
const access = useObject(Access, {});
const guest = useReadable(liveIn(access, "guest")?.grant);
// As the server last said (kept across starts): unread marks and search hold without waiting for Access.
const owner = useOwner();
const status = liveIn(useObject(SessionStatus, {}), "reader");
const agents = liveIn(useObject(Subagents, {}), "reader");
const { fail } = useComposerState();
const [list, edit] = useOptimisticCall(useReadable(liveIn(service, "owner", "guest")?.sessions), applyRow, fail);
const statuses = useReadable(status?.statuses);
const children = useReadable(agents?.children);
useEffect(() => {
if (list) rememberMains(list);
}, [list]);
// Unread answers are the owner's (any device); guests see none and mark nothing.
const marks = useObject(SessionReads, owner ? {} : undefined);
const answers = useReadable(marks?.answers);
const reads = useReadable(marks?.reads);
// Dismissing a failed operation removes its line at once.
const [operations, dismissing] = useOptimisticCall(
useReadable(catalog?.operations) ?? [],
(list, operation: string) => list.filter((each) => each.id !== operation),
fail,
);
const dismiss = useCallback(
(operation: string) => {
if (catalog) dismissing(operation, () => catalog.dismissOperation({ operation }, BACKGROUND_CONTEXT));
},
[catalog, dismissing],
);
const sessions = useMemo(
() =>
(list ?? []).map((session) => {
const busy = statuses?.[session.id]?.busy ?? [];
return {
...session,
env: session.env ?? null,
worker: {
busy,
error: statuses?.[session.id]?.error,
unread: (answers?.[session.id] ?? 0) > (reads?.[session.id] ?? 0),
},
subagents: children?.[session.id] ?? [],
operation: operationOf(operations, session.id, dismiss),
};
}),
[list, statuses, children, answers, reads, operations, dismiss],
);
// The open session's main conversation, shown on a visible page, is read through its latest answer.
const visible = usePageVisible();
const viewed = list?.find((session) => session.id === route.session);
const shown = visible && route.conversation === viewed?.main ? route.session : undefined;
const latest = shown ? (answers?.[shown] ?? 0) : 0;
const read = shown ? (reads?.[shown] ?? 0) : 0;
useEffect(() => {
if (shown && marks && latest > read)
void marks.markRead({ session: shown, through: latest }, BACKGROUND_CONTEXT).catch(() => {});
}, [marks, shown, latest, read]);
const search = liveIn(useObject(Search, owner ? {} : undefined), "owner");
// A rebuild shows as running at once; it is long work (a job), so the sheet shows it running until it is done.
const [rebuilds, reindex] = useOptimisticCall(
useReadable(search?.rebuilds) ?? [],
(list, session: string) => [
...list.filter((each) => each.session !== session),
{ session, phase: "running" as const },
],
fail,
);
const onSearch = useCallback(
(query: string, archived: boolean) => search!.search({ query, archived }, BACKGROUND_CONTEXT),
[search],
);
// A session opens at its main conversation; `/s/<id>` would open conversation 1, which redirects there.
const onOpen = useCallback(
(session: string, conversation?: number) =>
navigate({ session, conversation: conversation ?? list?.find((each) => each.id === session)?.main ?? 1 }),
[list],
);
// The restored row is back at once; the session opens once the server has it (an archived one would bounce).
const onRestore = useCallback(
(id: string, conversation?: number) => {
if (!catalog) return;
edit({ id, change: { archivedAt: null } }, async () => {
await catalog.restore({ id }, BACKGROUND_CONTEXT);
onOpen(id, conversation);
});
},
[catalog, edit, onOpen],
);
// A branch's switch or rename: a reference of its own (the change also reaches the session row).
const host = useHost();
const withTree = useCallback(
(session: string, action: (tree: HeldIn<typeof SessionTree, "owner">) => Promise<unknown>) => {
const held = host.hold(SessionTree, { session }, BACKGROUND_CONTEXT);
return whenLive(held)
.then(() => {
const tree = liveIn(held, "owner");
if (!tree) throw new Error("Only the owner changes branches");
return action(tree);
})
.finally(() => held.release());
},
[host],
);
// The row marks the chosen branch at once; the view follows once the session's main conversation moved.
const onBranch = useCallback(
(session: string, conversation: number) =>
edit({ id: session, change: { main: conversation } }, async () => {
await withTree(session, (tree) => tree.select({ conversation }, BACKGROUND_CONTEXT));
navigate({ session, conversation });
}),
[withTree, edit],
);
const [renaming, setRenaming] = useState<{ session: string; conversation: number }>();
const onRenameBranch = useCallback(
(session: string, conversation: number) => setRenaming({ session, conversation }),
[],
);
const renamed = renaming && list?.find((each) => each.id === renaming.session);
const branch = renamed?.branches.find((each) => each.conversation === renaming?.conversation);
const renameBranch = (session: Session, conversation: number, label: string | null) =>
edit(
{
id: session.id,
change: {
branches: session.branches.map((each) =>
each.conversation === conversation ? { ...each, label } : each,
),
},
},
() => withTree(session.id, (tree) => tree.rename({ conversation, label }, BACKGROUND_CONTEXT)),
);
const sessionSheet = useSheet("session");
const { setChoice, setLocation } = useNewSessionState();
const onNew = useCallback(() => {
setChoice(undefined);
navigate({});
setTimeout(() => document.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus(), 50);
}, [setChoice]);
// A folder's "+": the new session starts in that folder's location.
const onNewIn = useCallback(
(env: string | null, cwd: string) => {
setLocation({ env, cwd });
onNew();
},
[setLocation, onNew],
);
return (
<>
<Drawer
sessions={sessions}
guest={guest}
activeId={route.session}
conversationId={route.conversation}
connected={connected}
onOpen={onOpen}
onBranch={catalog ? onBranch : undefined}
onRenameBranch={catalog ? onRenameBranch : undefined}
onNew={onNew}
onNewIn={owner ? onNewIn : undefined}
onCollapse={toggleSidebar}
onSearch={search ? onSearch : undefined}
onRestore={onRestore}
footer={footer}
onProviders={() => openLayer({ kind: "sheet", name: "providers" }, { fromDrawer: true })}
onEnvs={() => openLayer({ kind: "sheet", name: "envs" }, { fromDrawer: true })}
onOwnerLink={() => openLayer({ kind: "sheet", name: "devices" }, { fromDrawer: true })}
/>
{renamed && branch && (
<BranchSheet
title={renamed.title}
conversation={branch.conversation}
label={branch.label}
onSave={(label) => renameBranch(renamed, branch.conversation, label)}
onClose={() => setRenaming(undefined)}
/>
)}
{sessionSheet && catalog && viewed && route.conversation === viewed.main && (
<SessionSheet
session={viewed}
onClose={closeLayers}
onRename={(title) =>
edit({ id: viewed.id, change: { title } }, () =>
catalog.rename({ id: viewed.id, title }, BACKGROUND_CONTEXT),
)
}
onPin={() =>
edit({ id: viewed.id, change: { pinned: !viewed.pinned } }, () =>
catalog.pin({ id: viewed.id, pinned: !viewed.pinned }, BACKGROUND_CONTEXT),
)
}
onShare={() => openLayer({ kind: "sheet", name: "share" })}
onCwd={() => openLayer(directoryPickerLayer(viewed.env ?? null, viewed.cwd))}
reindex={rebuilds.find((each) => each.session === viewed.id)}
onReindex={
search &&
(() => reindex(viewed.id, () => search.rebuildIndex({ session: viewed.id }, BACKGROUND_CONTEXT)))
}
onArchive={() => {
closeLayers();
void nav.replace(conversationRoute(route, undefined));
edit({ id: viewed.id, change: { archivedAt: Date.now() } }, () =>
catalog.archive({ id: viewed.id }, BACKGROUND_CONTEXT),
);
}}
/>
)}
</>
);
}
/** The top bar's button that opens the Session sheet (the drawer renders the sheet, where the rows are). */
function SessionOptions() {
const route = useRoute();
const rows = useReadable(useObject(Sessions, {}, { variant: "owner" })?.sessions);
const session = rows?.find((row) => row.id === route.session);
if (!session || route.conversation !== session.main) return null;
return (
<button
type="button"
className="icon-button"
aria-label="Session options"
onClick={() => openLayer({ kind: "sheet", name: "session" })}
>
<MoreIcon />
</button>
);
}
export default defineFacet((host) => {
contribute(host, "p2.web-sessions", (self) => {
self.contribute(Slots, { sidebar: Sidebar, "session-options": SessionOptions });
});
});