pi-mono/apps/p2/plugins/sessions/SessionOptions.tsx
David Brailovsky e885255061 feat(p2): /tree, branches of the main conversation as Durable forks
Each branch is a top-level conversation forked from another at an entry;
the session-scoped p2.main document lists them and names the current one.
Earlier branches are read-only. The main agent's per-agent state
(subagents, artifacts, voice and chat mode, pending move) stays on
conversation 1 for every branch.

- tree plugin: SessionTree (tree, navigate, select); the worker reports
  main and branches to the catalog (Sessions v4, catalog schema 4)
- TUI: /tree and double Escape (pi's default "tree"), a tree selector
  adapted from coding-agent
- web: Edit on user messages, Branch from here on answers, branch rows
  under the session in the sidebar
- main-agent checks use the branch list instead of conversation 1
- search indexes each branch's own entries

Design: apps/p2/docs/tree.md
2026-11-04 13:40:36 +01:00

65 lines
2.2 KiB
TypeScript

// Native catalog operations beneath the copied mobile Session sheet.
import { BACKGROUND_CONTEXT, liveIn, whenLive } from "@p2/app/v1";
import {
closeLayers,
conversationRoute,
MoreIcon,
openLayer,
useComposerState,
useHost,
useNavigation,
useObject,
useReadable,
useRoute,
useSheet,
} from "@p2/app/v1/web";
import { directoryPickerLayer } from "../envs/contract.web.tsx";
import { Sessions } from "./contract.ts";
import { SessionSheet } from "./SessionSheet.tsx";
export function SessionOptions() {
const host = useHost(),
nav = useNavigation(),
route = useRoute();
const service = useObject(Sessions, {}, { variant: "owner" }),
rows = useReadable(service?.sessions);
const session = rows?.find((row) => row.id === route.session),
sessionSheet = useSheet("session");
const { setError } = useComposerState();
const perform = (operation: Promise<unknown> | undefined) =>
void operation?.catch((error) => setError(error instanceof Error ? error.message : String(error)));
if (!service || !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>
{sessionSheet && (
<SessionSheet
session={session}
onClose={closeLayers}
onRename={(title) => perform(service?.rename({ id: session.id, title }, BACKGROUND_CONTEXT))}
onPin={() => perform(service?.pin({ id: session.id, pinned: !session.pinned }, BACKGROUND_CONTEXT))}
onShare={() => openLayer({ kind: "sheet", name: "share" })}
onCwd={() => openLayer(directoryPickerLayer(session.env ?? null, session.cwd))}
onArchive={() => {
closeLayers();
void nav.replace(conversationRoute(route, undefined));
// Leaving the session unmounts this view and releases `service`, which cancels its calls: the
// archive holds a reference of its own until it is done.
const archiving = host.hold(Sessions, {}, BACKGROUND_CONTEXT);
perform(
whenLive(archiving)
.then(() => liveIn(archiving, "owner")?.archive({ id: session.id }, BACKGROUND_CONTEXT))
.finally(() => archiving.release()),
);
}}
/>
)}
</>
);
}