From 2caedfbced06e6d293e9ac2f0b2ae4f8690cb4b2 Mon Sep 17 00:00:00 2001 From: Kieran Zhang Date: Fri, 2 Oct 2026 18:24:34 +0800 Subject: [PATCH] feat(spike): web client for the durable host (P2) A Vite + React + Astryx page renders the host through RemoteDurable next to the TUI: conversation list, streaming transcript with tool calls and interrupted partials, composer with steer, follow-up and stop, and the live task tree and queue. View-to-UI derivations live in a browser-safe presentation module ported from the upstream TUI, for reuse by the Pace page. Also titles main when a restarted gateway lists conversations, as the commit listener already did while running. --- .scratch/durable-multiview/HANDOFF.md | 29 +- bun.lock | 10 + spikes/durable-multiview/host/gateway.ts | 6 +- spikes/durable-multiview/host/main.ts | 4 +- spikes/durable-multiview/package.json | 21 +- spikes/durable-multiview/presentation/chat.ts | 184 +++++++++++ spikes/durable-multiview/test/gateway.test.ts | 15 + .../test/presentation.test.ts | 97 ++++++ spikes/durable-multiview/test/support.ts | 8 +- spikes/durable-multiview/test/tui-harness.ts | 46 +++ spikes/durable-multiview/test/tui.test.ts | 40 +-- .../test/web-address.test.ts | 8 + spikes/durable-multiview/test/web.test.ts | 51 ++++ spikes/durable-multiview/tsconfig.json | 19 +- spikes/durable-multiview/web/App.tsx | 289 ++++++++++++++++++ spikes/durable-multiview/web/address.ts | 13 + spikes/durable-multiview/web/index.html | 12 + spikes/durable-multiview/web/main.tsx | 16 + spikes/durable-multiview/web/tsconfig.json | 10 + spikes/durable-multiview/web/use-remote.ts | 42 +++ spikes/durable-multiview/web/vite.config.ts | 23 ++ 21 files changed, 892 insertions(+), 51 deletions(-) create mode 100644 spikes/durable-multiview/presentation/chat.ts create mode 100644 spikes/durable-multiview/test/presentation.test.ts create mode 100644 spikes/durable-multiview/test/tui-harness.ts create mode 100644 spikes/durable-multiview/test/web-address.test.ts create mode 100644 spikes/durable-multiview/test/web.test.ts create mode 100644 spikes/durable-multiview/web/App.tsx create mode 100644 spikes/durable-multiview/web/address.ts create mode 100644 spikes/durable-multiview/web/index.html create mode 100644 spikes/durable-multiview/web/main.tsx create mode 100644 spikes/durable-multiview/web/tsconfig.json create mode 100644 spikes/durable-multiview/web/use-remote.ts create mode 100644 spikes/durable-multiview/web/vite.config.ts diff --git a/.scratch/durable-multiview/HANDOFF.md b/.scratch/durable-multiview/HANDOFF.md index 0400f5c6..a7e47759 100644 --- a/.scratch/durable-multiview/HANDOFF.md +++ b/.scratch/durable-multiview/HANDOFF.md @@ -1,7 +1,7 @@ # Handoff:Pi Durable 多端同屏 spike - 日期:2026-10-02 -- 状态:P0、P1 完成(见 §10、§11),下一步 P2(Web UI)。PR 栈:P0 [#426](https://github.com/bubbleptr/pace/pull/426) ← P1 `feat/durable-multiview-tui`。前置的 Pi 1.0 升级 [PR #425](https://github.com/bubbleptr/pace/pull/425) 已合入,分支 `feat/durable-multiview-spike` 从 `origin/main` 开出。 +- 状态:P0–P2 完成(见 §10–§12),下一步 P3(Pace dev 页面)。PR 栈:P0 [#426](https://github.com/bubbleptr/pace/pull/426) ← P1 [#427](https://github.com/bubbleptr/pace/pull/427) ← P2 `feat/durable-multiview-web`。前置的 Pi 1.0 升级 [PR #425](https://github.com/bubbleptr/pace/pull/425) 已合入,分支 `feat/durable-multiview-spike` 从 `origin/main` 开出。 - 关联:[docs/research/pi-durable-analysis.md](../../docs/research/pi-durable-analysis.md)(框架分析,用户的写作素材,只在本地工作区,不提交)、[ADR-0040](../../docs/adr/0040-root-session-process-isolation.md)、[ADR-0042](../../docs/adr/0042-pace-as-chord-presentation-host.md)、[ADR-0044](../../docs/adr/0044-single-writer-session-projection-in-renderer.md) - 目的:把上一个会话里已核实的事实、已定的决策和待决问题交给新会话,避免重查。 @@ -207,3 +207,30 @@ Pace 页面放在 `apps/desktop/src/pages/` 下的 dev-only 路由,backend 侧 - 用测试 API 直接建的 ownerless 会话 `pi.agent` 是空的(没有模型),提交会得到 `no_model`。子代理会话从父会话复制 agent 配置,不受影响。P4 造演示会话时要带上 `agent`。 - 崩溃的代价是重发一次请求:被打断那次的用量也计入 `pi.usage`(页脚从 `↑6 ↓29` 变成 `↑12 ↓58`)。剧本 §6 第 12 步讲用量时可以顺带提。 - `/agents` 现在只有 main,因为宿主还没装子代理扩展(P4)。协议层的会话切换已在测试里覆盖。 + +## 12. P2 进展(2026-10-02):Web UI + +### 12.1 落地的内容 + +- `presentation/chat.ts`:把 `ConversationView` 变成界面条目的纯函数,推导规则照搬上游 TUI,保证各端讲的是同一件事。包括 `chatItems`(消息、工具调用及其结果和运行中进度、子代理会话、aborted 片段、压缩、重置)、`statusText`、`queueItems`、`taskRows`(任务所有权树)。只用类型导入,浏览器可以直接用;**P3 的 Pace 页面可以复用它**。 +- `web/`:Vite + React + Astryx(neutral 主题,和 Pace 一致),`bun run web` 起在 `127.0.0.1:5199`。 + - 左侧 `SideNav`:会话列表和连接状态点。 + - 中间 `ChatLayout`:Enter 在空闲时是提问、忙碌时是 steer,和 TUI 一样;另有 Follow-up 按钮;忙碌时发送按钮变成停止;底部有模型下拉、思考档位、Compact。 + - 右侧面板:任务树、队列、通知。 + - 断线时顶部显示 `Banner`,输入框禁用。 + - aborted 条目带橙色 `interrupted` 标签。 +- token 放在 URL 的 `#fragment` 里,不会发给 dev server。宿主的 ready 行里多了一个 `web` 字段,就是完整链接。 +- `web/tsconfig.json` 用 `Bundler` 解析:Astryx 的 `.d.ts` 是不带扩展名的相对再导出,`NodeNext` 下类型会全部退化。spike 的 `typecheck` 脚本两份配置都会检查。 +- Vite 配置照 Pace 的做法固定一份 `react`(bun 在 `@astryxdesign/core` 下面嵌了第二份)。 + +### 12.2 验收 + +- `test/web.test.ts`:同时起 Vite dev server、Playwright Chromium 和 pty 里的 TUI,连同一个假模型宿主。网页提问,TUI 显示问题和答案;TUI 提问,网页显示问题和答案;观察器的转录是这四条;宿主关闭后网页显示重连提示;全程没有页面错误。又连跑了 3 次。 +- `test/presentation.test.ts`:8 条,覆盖展示层推导。 +- 截图核对:空页面、流式中(`Working...`、任务面板、停止按钮)、Follow-up 入队、两轮答完、宿主丢失(Banner、状态点变黄、输入框禁用),以及 `kill -9` 后重启(`interrupted` 标签加完整答案)。 + +### 12.3 新发现 + +- **根会话的 ID 是 1,不是 0。** 用 `ROOT_CONVERSATION_ID`,不要写死。 +- **修复一个上游带过来的不一致**:网关启动时扫描会话列表照搬了上游的写法,不给 main 标题,但运行中提交监听器会给 main 补上标题。结果宿主重启后,main 在列表里的标题就没了。现在启动时 main 也取第一条用户消息作为标题;有测试覆盖重启前后列表一致。 +- Astryx 的 `ChatComposer` 在 `isStopShown` 时 Enter 照样调用 `onSubmit`,只有按钮变成停止,所以 Enter 在忙碌时就是 steer,不需要单独的 steer 按钮。 diff --git a/bun.lock b/bun.lock index c106b95b..7887d7b9 100644 --- a/bun.lock +++ b/bun.lock @@ -94,19 +94,29 @@ "name": "@pace/durable-multiview-spike", "version": "0.0.0", "dependencies": { + "@astryxdesign/core": "^0.3.0", + "@astryxdesign/theme-neutral": "^0.3.0", "@earendil-works/chord": "1.0.0", "@earendil-works/pi-ai": "1.0.0", "@earendil-works/pi-coding-agent": "1.0.0", "@earendil-works/pi-durable": "1.0.0", "@earendil-works/pi-tui": "1.0.0", + "@stylexjs/stylex": "^0.19.0", "proper-lockfile": "4.1.2", + "react": "^19.2.7", + "react-dom": "^19.2.7", "undici": "8.10.2", "ws": "8.22.0", }, "devDependencies": { "@lydell/node-pty": "^1.2.0-beta.15", + "@playwright/test": "^1.61.1", "@types/proper-lockfile": "4.1.4", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", "@types/ws": "8.18.2", + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.2", }, }, }, diff --git a/spikes/durable-multiview/host/gateway.ts b/spikes/durable-multiview/host/gateway.ts index 267482f6..e45cc206 100644 --- a/spikes/durable-multiview/host/gateway.ts +++ b/spikes/durable-multiview/host/gateway.ts @@ -153,9 +153,11 @@ function titleOf(entry: EntryRecord | undefined): { title?: string } { return { title: text.replace(/\s+/g, " ").trim() }; } -/** A subagent's task: the oldest user message of its conversation. */ +/** + * The oldest user message of a conversation, a subagent's task. Unlike upstream, main gets + * one too: the commit listener titles it live, and a restarted host must list the same. + */ async function firstInput(harness: Harness, id: ConversationId): Promise<{ title?: string }> { - if (id === ROOT_CONVERSATION_ID) return {}; const conversation = (await harness.conversation(id, context))!; let first: EntryRecord | undefined; let cursor: Cursor | undefined; diff --git a/spikes/durable-multiview/host/main.ts b/spikes/durable-multiview/host/main.ts index 76a13a8a..b72d8981 100644 --- a/spikes/durable-multiview/host/main.ts +++ b/spikes/durable-multiview/host/main.ts @@ -60,7 +60,9 @@ async function piOptions(): Promise { const options = values.faux === undefined ? await piOptions() : fauxOptions(values.faux); const host = await openHost(options); const model = options.initialModel === undefined ? null : `${options.initialModel.provider}/${options.initialModel.modelId}`; -console.log(JSON.stringify({ event: "ready", url: host.url, token: host.token, dataDir, model })); +// The web client (`bun run web`) reads the host from the fragment, which never leaves the browser. +const web = `http://127.0.0.1:5199/#token=${encodeURIComponent(host.token)}&url=${encodeURIComponent(host.url)}`; +console.log(JSON.stringify({ event: "ready", url: host.url, token: host.token, dataDir, model, web })); let stopping = false; const stop = (): void => { diff --git a/spikes/durable-multiview/package.json b/spikes/durable-multiview/package.json index c774aab9..196efae0 100644 --- a/spikes/durable-multiview/package.json +++ b/spikes/durable-multiview/package.json @@ -8,22 +8,33 @@ "host": "node host/main.ts", "observe": "node cli/observe.ts", "test": "vitest run --config vitest.config.ts", - "typecheck": "tsc --noEmit -p tsconfig.json", - "tui": "node tui/main.ts" + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p web/tsconfig.json", + "tui": "node tui/main.ts", + "web": "vite --config web/vite.config.ts" }, "dependencies": { + "@astryxdesign/core": "^0.3.0", + "@astryxdesign/theme-neutral": "^0.3.0", "@earendil-works/chord": "1.0.0", "@earendil-works/pi-ai": "1.0.0", "@earendil-works/pi-coding-agent": "1.0.0", "@earendil-works/pi-durable": "1.0.0", + "@earendil-works/pi-tui": "1.0.0", + "@stylexjs/stylex": "^0.19.0", "proper-lockfile": "4.1.2", + "react": "^19.2.7", + "react-dom": "^19.2.7", "undici": "8.10.2", - "ws": "8.22.0", - "@earendil-works/pi-tui": "1.0.0" + "ws": "8.22.0" }, "devDependencies": { + "@lydell/node-pty": "^1.2.0-beta.15", + "@playwright/test": "^1.61.1", "@types/proper-lockfile": "4.1.4", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", "@types/ws": "8.18.2", - "@lydell/node-pty": "^1.2.0-beta.15" + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.2" } } diff --git a/spikes/durable-multiview/presentation/chat.ts b/spikes/durable-multiview/presentation/chat.ts new file mode 100644 index 00000000..ee8173d6 --- /dev/null +++ b/spikes/durable-multiview/presentation/chat.ts @@ -0,0 +1,184 @@ +// What a graphical client shows for a conversation view, as plain data. The +// derivations follow pi's durable TUI (packages/coding-agent/src/experimental/durable/tui.ts, +// MIT, Earendil Works) so every client tells the same story. Type-only imports keep it browser-safe. +import type { + ConversationId, + ConversationView, + EntryRecord, + InboxState, + LiveState, + TaskGraph, + TaskGraphNode, + TaskId, +} from "@earendil-works/pi-durable"; + +export interface ToolCallView { + readonly callId: string; + readonly name: string; + /** What the call acts on, from its arguments. */ + readonly target?: string; + readonly status: "pending" | "running" | "complete" | "error"; + readonly output?: string; + /** A subagent's conversation, from the call's running details. */ + readonly conversationId?: ConversationId; +} + +export type ChatItem = + | { readonly kind: "user"; readonly id: string; readonly text: string } + | { + readonly kind: "assistant"; + readonly id: string; + readonly text: string; + /** Absent while streaming; `aborted` for a partial an interrupted attempt left behind. */ + readonly stopReason?: string; + readonly streaming: boolean; + readonly tools: readonly ToolCallView[]; + } + | { readonly kind: "compaction"; readonly id: string; readonly summary: string } + | { readonly kind: "reset"; readonly id: string }; + +type Block = { readonly type: string; readonly text?: string; readonly id?: string; readonly name?: string; readonly arguments?: unknown }; +type Message = { readonly role: string; readonly content: string | readonly Block[]; readonly stopReason?: string; readonly toolCallId?: string; readonly isError?: boolean }; + +function textOf(content: Message["content"]): string { + if (typeof content === "string") return content; + return content.flatMap((block) => (block.type === "text" && block.text !== undefined ? [block.text] : [])).join(""); +} + +function targetOf(args: unknown): string | undefined { + if (args === null || typeof args !== "object") return undefined; + const values = Object.values(args as Record); + const first = values.find((value) => typeof value === "string") as string | undefined; + const target = first ?? (values.length === 0 ? undefined : JSON.stringify(args)); + return target === undefined || target.length <= 80 ? target : `${target.slice(0, 77)}...`; +} + +function callsOf(message: Message, status: ToolCallView["status"], output?: string): ToolCallView[] { + if (typeof message.content === "string") return []; + return message.content.flatMap((block) => { + if (block.type !== "toolCall" || block.id === undefined || block.name === undefined) return []; + const target = targetOf(block.arguments); + return [{ callId: block.id, name: block.name, status, ...(target === undefined ? {} : { target }), ...(output === undefined ? {} : { output }) }]; + }); +} + +const liveOf = (view: ConversationView): LiveState => (view.docs["pi.live"] ?? {}) as LiveState; + +export function chatItems(view: ConversationView): ChatItem[] { + const items: ChatItem[] = []; + // Where each call ID's newest card sits; provider call IDs may repeat across turns. + const calls = new Map(); + const updateCall = (callId: string, patch: Partial): void => { + const at = calls.get(callId); + if (at === undefined) return; + const item = items[at.item] as Extract; + const tools = item.tools.map((tool, index) => (index === at.tool ? { ...tool, ...patch } : tool)); + items[at.item] = { ...item, tools }; + }; + const addAssistant = (id: string, message: Message, streaming: boolean): void => { + // Only a tool-calling answer runs its calls; an aborted, failed, or truncated one never does. + const ran = streaming || message.stopReason === "toolUse"; + const tools = ran ? callsOf(message, "pending") : callsOf(message, "error", "Not run: the answer was interrupted."); + tools.forEach((tool, index) => calls.set(tool.callId, { item: items.length, tool: index })); + items.push({ + kind: "assistant", + id, + text: textOf(message.content), + ...(streaming ? {} : { stopReason: message.stopReason ?? "stop" }), + streaming, + tools, + }); + }; + + for (const entry of view.entries as readonly EntryRecord[]) { + const message = entry.model?.[0] as Message | undefined; + const id = String(entry.id); + if (entry.kind === "pi.user" && message?.role === "user") items.push({ kind: "user", id, text: textOf(message.content) }); + else if (entry.kind === "pi.assistant" && message?.role === "assistant") addAssistant(id, message, false); + else if (entry.kind === "pi.tool-result" && message?.role === "toolResult" && message.toolCallId !== undefined) { + updateCall(message.toolCallId, { status: message.isError ? "error" : "complete", output: textOf(message.content) }); + } else if (entry.kind === "pi.compaction") items.push({ kind: "compaction", id, summary: message === undefined ? "" : textOf(message.content) }); + else if (entry.kind === "pi.reset") items.push({ kind: "reset", id }); + } + + const live = liveOf(view); + for (const slot of live.tools ?? []) { + if (slot.status !== "running") continue; + const child = (slot.details as { conversationId?: ConversationId } | undefined)?.conversationId; + updateCall(slot.callId, { + status: "running", + ...(slot.output === undefined ? {} : { output: slot.output }), + ...(child === undefined ? {} : { conversationId: child }), + }); + } + const partial = live.generation?.message as Message | undefined; + if (partial !== undefined) addAssistant("live", partial, true); + return items; +} + +/** What the conversation is doing now, most specific first; empty when idle. */ +export function statusText(view: ConversationView): string { + const live = liveOf(view); + const generation = live.generation; + const compaction = live.compactions?.[0]; + const runningTool = live.tools?.find((slot) => slot.status === "running"); + if (generation?.retry !== undefined) return `Retrying (attempt ${generation.attempt + 1}): ${generation.retry.error}`; + if (generation?.deferred !== undefined) return "Waiting for deferred response..."; + if (compaction !== undefined) { + return compaction.retry ? `Retrying ${compaction.reason} compaction (attempt ${compaction.attempt + 1})...` : `Compacting (${compaction.reason})...`; + } + if (runningTool !== undefined) return `Running ${runningTool.name}...`; + if (live.run !== undefined) return "Working..."; + return ""; +} + +export interface QueueItem { + readonly id: number; + readonly mode: "steer" | "followUp" | "write"; + readonly text: string; +} + +export function queueItems(view: ConversationView): QueueItem[] { + const inbox = (view.docs["pi.inbox"] ?? { items: [] }) as InboxState; + return inbox.items.map((item) => ({ + id: item.id as unknown as number, + mode: item.mode, + text: item.mode === "write" ? `<${String(item.entry.kind)}>` : textOf(item.content as Message["content"]), + })); +} + +export interface TaskRow { + readonly id: TaskId; + readonly depth: number; + readonly label: string; +} + +function describeTask(node: TaskGraphNode): string { + const state = node.state; + const status = + state.status === "waiting" + ? `waiting on ${state.on.join(", ")}` + : state.status === "completing" + ? `completing (${state.outcome})` + : `${state.status} ${state.phase}`; + const flags = [node.background ? "background" : "", node.abortRequested ? "aborting" : ""].filter(Boolean); + const owned = node.conversations.length > 0 ? ` owns conversation ${node.conversations.join(", ")}` : ""; + return `${node.kind} #${node.id}: ${status}${flags.length > 0 ? ` [${flags.join(", ")}]` : ""}${owned}`; +} + +/** Live tasks as an indented tree: owned work under its owner, a subagent's work under the call that owns its conversation. */ +export function taskRows(graph: TaskGraph): TaskRow[] { + const nodes = Object.values(graph.tasks); + const owned = new Set(nodes.flatMap((node) => node.conversations)); + const children = (node: TaskGraphNode) => + nodes.filter( + (candidate) => candidate.owner === node.id || (candidate.owner === undefined && node.conversations.includes(candidate.conversationId)), + ); + const rows: TaskRow[] = []; + const visit = (node: TaskGraphNode, depth: number): void => { + rows.push({ id: node.id, depth, label: describeTask(node) }); + for (const child of children(node)) visit(child, depth + 1); + }; + for (const node of nodes) if (node.owner === undefined && !owned.has(node.conversationId)) visit(node, 0); + return rows; +} diff --git a/spikes/durable-multiview/test/gateway.test.ts b/spikes/durable-multiview/test/gateway.test.ts index ea5aaaad..90d5e37a 100644 --- a/spikes/durable-multiview/test/gateway.test.ts +++ b/spikes/durable-multiview/test/gateway.test.ts @@ -39,6 +39,21 @@ describe("gateway", () => { await expect(connectTo(defer, host, "not-the-token")).rejects.toThrow(/unauthorized/i); }); + it("lists the same conversation titles before and after the host restarts", async () => { + const first = await startFauxHost(defer, { answers: ["done"] }); + const before = await connectTo(defer, first); + await before.controller.submit("what broke v2.3?", "followUp"); + await waitForView(before.view, (view) => view.conversations[0]?.title === "what broke v2.3?"); + const dataDir = before.view.current().session.directory; + before.close(); + await first.close(); + + const second = await startFauxHost(defer, { dataDir }); + const after = await connectTo(defer, second); + expect(after.view.current().conversations).toEqual(before.view.current().conversations); + expect(after.view.current().conversations).toMatchObject([{ label: "main", title: "what broke v2.3?" }]); + }); + it("lists the conversations and opens the task graph on request", async () => { const host = await startFauxHost(defer); const client = await connectTo(defer, host); diff --git a/spikes/durable-multiview/test/presentation.test.ts b/spikes/durable-multiview/test/presentation.test.ts new file mode 100644 index 00000000..24e85907 --- /dev/null +++ b/spikes/durable-multiview/test/presentation.test.ts @@ -0,0 +1,97 @@ +import type { ConversationView, EntryRecord, TaskGraph } from "@earendil-works/pi-durable"; +import { describe, expect, it } from "vitest"; +import { chatItems, queueItems, statusText, taskRows } from "../presentation/chat.ts"; + +let nextId = 1; +const entry = (kind: string, message: unknown): EntryRecord => + ({ id: nextId++, conversationId: 0, kind, model: [message] }) as unknown as EntryRecord; +const user = (text: string) => entry("pi.user", { role: "user", content: text }); +const assistant = (content: unknown[], stopReason = "stop") => entry("pi.assistant", { role: "assistant", content, stopReason }); +const text = (value: string) => ({ type: "text", text: value }); +const call = (id: string, name: string, args: unknown) => ({ type: "toolCall", id, name, arguments: args }); +const result = (callId: string, name: string, output: string, isError = false) => + entry("pi.tool-result", { role: "toolResult", toolCallId: callId, toolName: name, content: [text(output)], isError }); +const view = (entries: EntryRecord[], live: object = {}, inbox: object[] = []): ConversationView => + ({ conversation: { id: 0 }, entries, docs: { "pi.live": live, "pi.inbox": { items: inbox } } }) as unknown as ConversationView; + +describe("chatItems", () => { + it("maps user and assistant entries, marking an interrupted partial", () => { + const items = chatItems(view([user("hi"), assistant([text("hel")], "aborted"), assistant([text("hello")])])); + expect(items.map((item) => [item.kind, "text" in item ? item.text : undefined, "stopReason" in item ? item.stopReason : undefined])).toEqual([ + ["user", "hi", undefined], + ["assistant", "hel", "aborted"], + ["assistant", "hello", "stop"], + ]); + }); + + it("appends the in-flight partial as a streaming assistant item", () => { + const items = chatItems(view([user("hi")], { run: {}, generation: { attempt: 1, message: { role: "assistant", content: [text("he")] } } })); + expect(items.at(-1)).toMatchObject({ kind: "assistant", text: "he", streaming: true }); + }); + + it("attaches tool results and live tool progress to the calls of their answer", () => { + const items = chatItems( + view( + [user("go"), assistant([call("c1", "logs", { service: "api" }), call("c2", "metrics", {})], "toolUse"), result("c1", "logs", "3 errors")], + { run: {}, tools: [{ callId: "c2", name: "metrics", status: "running", output: "fetching", details: { conversationId: 7 } }] }, + ), + ); + const answer = items.at(-1); + expect(answer).toMatchObject({ + kind: "assistant", + tools: [ + { callId: "c1", name: "logs", status: "complete", output: "3 errors" }, + { callId: "c2", name: "metrics", status: "running", output: "fetching", conversationId: 7 }, + ], + }); + }); + + it("marks the calls of an interrupted answer as not run", () => { + const items = chatItems(view([user("go"), assistant([call("c1", "logs", {})], "aborted")])); + expect(items.at(-1)).toMatchObject({ tools: [{ callId: "c1", status: "error", output: "Not run: the answer was interrupted." }] }); + }); + + it("shows compaction summaries and context resets", () => { + const compaction = entry("pi.compaction", { role: "user", content: "summary of earlier work" }); + const reset = { id: nextId++, conversationId: 0, kind: "pi.reset" } as unknown as EntryRecord; + expect(chatItems(view([compaction, reset])).map((item) => item.kind)).toEqual(["compaction", "reset"]); + }); +}); + +describe("statusText", () => { + it("names what the conversation is doing, most specific first", () => { + expect(statusText(view([]))).toBe(""); + expect(statusText(view([], { run: {} }))).toBe("Working..."); + expect(statusText(view([], { run: {}, tools: [{ callId: "c", name: "logs", status: "running" }] }))).toBe("Running logs..."); + expect(statusText(view([], { run: {}, generation: { attempt: 1, retry: { at: 0, error: "overloaded" } } }))).toBe( + "Retrying (attempt 2): overloaded", + ); + expect(statusText(view([], { compactions: [{ reason: "manual", attempt: 1 }] }))).toBe("Compacting (manual)..."); + }); +}); + +describe("queueItems", () => { + it("lists queued inputs with their mode", () => { + expect(queueItems(view([], {}, [{ id: 1, mode: "steer", content: "focus" }, { id: 2, mode: "followUp", content: [text("then this")] }]))).toEqual([ + { id: 1, mode: "steer", text: "focus" }, + { id: 2, mode: "followUp", text: "then this" }, + ]); + }); +}); + +describe("taskRows", () => { + it("nests owned tasks and conversation-owned tasks under their owner", () => { + const graph = { + tasks: { + "1": { id: 1, kind: "pi.generation", conversationId: 0, background: false, abortRequested: false, state: { status: "waiting", phase: "tools", on: [2], policy: "all" }, conversations: [] }, + "2": { id: 2, kind: "pi.tool", conversationId: 0, owner: 1, background: false, abortRequested: false, state: { status: "running", phase: "run" }, conversations: [5] }, + "3": { id: 3, kind: "pi.generation", conversationId: 5, background: false, abortRequested: true, state: { status: "running", phase: "request" }, conversations: [] }, + }, + } as unknown as TaskGraph; + expect(taskRows(graph)).toEqual([ + { id: 1, depth: 0, label: "pi.generation #1: waiting on 2" }, + { id: 2, depth: 1, label: "pi.tool #2: running run owns conversation 5" }, + { id: 3, depth: 2, label: "pi.generation #3: running request [aborting]" }, + ]); + }); +}); diff --git a/spikes/durable-multiview/test/support.ts b/spikes/durable-multiview/test/support.ts index fbb063bd..374436fe 100644 --- a/spikes/durable-multiview/test/support.ts +++ b/spikes/durable-multiview/test/support.ts @@ -21,9 +21,13 @@ export function useCleanups(): (cleanup: () => Promise | void) => void { /** An in-process host on a temporary store whose faux model gives `answers` in order. */ export async function startFauxHost( defer: (cleanup: () => Promise | void) => void, - { answers = [LONG_ANSWER], tokensPerSecond = 200 }: { answers?: string[]; tokensPerSecond?: number } = {}, + { + answers = [LONG_ANSWER], + tokensPerSecond = 200, + dataDir, + }: { answers?: string[]; tokensPerSecond?: number; /** Reopen an earlier host's store. */ dataDir?: string } = {}, ): Promise { - const dir = await tempDir(); + const dir = dataDir === undefined ? await tempDir() : { path: dataDir, remove: () => {} }; defer(dir.remove); const faux = fauxProvider({ tokensPerSecond, tokenSize: { min: 1, max: 1 } }); const models = createModels(); diff --git a/spikes/durable-multiview/test/tui-harness.ts b/spikes/durable-multiview/test/tui-harness.ts new file mode 100644 index 00000000..c66a0363 --- /dev/null +++ b/spikes/durable-multiview/test/tui-harness.ts @@ -0,0 +1,46 @@ +import { fileURLToPath } from "node:url"; +import { type IPty, spawn as spawnPty } from "@lydell/node-pty"; +import { tempDir } from "./support.ts"; + +const spikeDir = fileURLToPath(new URL("..", import.meta.url)); +// CSI, OSC, and the other escape sequences of a differential terminal render. +// eslint-disable-next-line no-control-regex +const ANSI = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]/g; + +export interface TuiProcess { + readonly pty: IPty; + /** Resolve once what the TUI printed so far, as plain text, satisfies `predicate`. */ + until(predicate: (text: string) => boolean, what: string, timeoutMs?: number): Promise; + exited(): number | undefined; +} + +/** The TUI in a real pseudo-terminal against a host, with an empty agent dir so no user settings leak in. */ +export async function startTui(defer: (cleanup: () => Promise | void) => void, url: string, token: string): Promise { + const agentDir = await tempDir(); + defer(agentDir.remove); + const pty = spawnPty(process.execPath, ["tui/main.ts", "--url", url, "--token", token], { + cols: 140, + rows: 40, + cwd: spikeDir, + env: { ...process.env, PI_CODING_AGENT_DIR: agentDir.path, TERM: "xterm-256color" }, + }); + let screen = ""; + let exited: number | undefined; + pty.onData((data) => (screen += data)); + pty.onExit(({ exitCode }) => (exited = exitCode)); + defer(() => { + if (exited === undefined) pty.kill(); + }); + return { + pty, + exited: () => exited, + async until(predicate, what, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs; + while (!predicate(screen.replace(ANSI, ""))) { + if (Date.now() > deadline) throw new Error(`TUI never showed ${what}; it printed:\n${screen.replace(ANSI, "").slice(-2000)}`); + if (exited !== undefined) throw new Error(`TUI exited with ${exited} before showing ${what}`); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + }, + }; +} diff --git a/spikes/durable-multiview/test/tui.test.ts b/spikes/durable-multiview/test/tui.test.ts index 14773140..f779dc87 100644 --- a/spikes/durable-multiview/test/tui.test.ts +++ b/spikes/durable-multiview/test/tui.test.ts @@ -1,48 +1,14 @@ -import { fileURLToPath } from "node:url"; -import { spawn as spawnPty } from "@lydell/node-pty"; import { expect, it } from "vitest"; import { transcript } from "../protocol/transcript.ts"; -import { connectTo, startFauxHost, tempDir, useCleanups, waitForView } from "./support.ts"; +import { connectTo, startFauxHost, useCleanups, waitForView } from "./support.ts"; +import { startTui } from "./tui-harness.ts"; -const spikeDir = fileURLToPath(new URL("..", import.meta.url)); const defer = useCleanups(); -// CSI, OSC, and the other escape sequences of a differential terminal render. -// eslint-disable-next-line no-control-regex -const ANSI = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]/g; - -/** The TUI in a real pseudo-terminal, with what it printed so far as plain text. */ -async function startTui(url: string, token: string) { - const agentDir = await tempDir(); - defer(agentDir.remove); - const pty = spawnPty(process.execPath, ["tui/main.ts", "--url", url, "--token", token], { - cols: 140, - rows: 40, - cwd: spikeDir, - // An empty agent dir: no user theme, keybindings, or settings leak in. - env: { ...process.env, PI_CODING_AGENT_DIR: agentDir.path, TERM: "xterm-256color" }, - }); - let screen = ""; - let exited: number | undefined; - pty.onData((data) => (screen += data)); - pty.onExit(({ exitCode }) => (exited = exitCode)); - defer(() => { - if (exited === undefined) pty.kill(); - }); - const until = async (predicate: (text: string) => boolean, what: string, timeoutMs = 15_000): Promise => { - const deadline = Date.now() + timeoutMs; - while (!predicate(screen.replace(ANSI, ""))) { - if (Date.now() > deadline) throw new Error(`TUI never showed ${what}; it printed:\n${screen.replace(ANSI, "").slice(-2000)}`); - if (exited !== undefined) throw new Error(`TUI exited with ${exited} before showing ${what}`); - await new Promise((resolve) => setTimeout(resolve, 50)); - } - }; - return { pty, until, exited: () => exited }; -} it("drives the host from the TUI and shows the answer, then the lost host", async () => { const host = await startFauxHost(defer, { answers: ["pong-from-the-host"] }); const observer = await connectTo(defer, host); - const tui = await startTui(host.url, host.token); + const tui = await startTui(defer, host.url, host.token); await tui.until((text) => text.includes("faux/faux-1"), "the footer"); tui.pty.write("ping-from-the-tui"); diff --git a/spikes/durable-multiview/test/web-address.test.ts b/spikes/durable-multiview/test/web-address.test.ts new file mode 100644 index 00000000..8e730438 --- /dev/null +++ b/spikes/durable-multiview/test/web-address.test.ts @@ -0,0 +1,8 @@ +import { expect, it } from "vitest"; +import { addressFromHash } from "../web/address.ts"; + +it("reads the host token and url from the fragment, defaulting the url", () => { + expect(addressFromHash("#token=abc")).toEqual({ url: "ws://127.0.0.1:7420", token: "abc" }); + expect(addressFromHash("#token=a%2Bb&url=ws%3A%2F%2F127.0.0.1%3A9000")).toEqual({ url: "ws://127.0.0.1:9000", token: "a+b" }); + expect(addressFromHash("")).toBeUndefined(); +}); diff --git a/spikes/durable-multiview/test/web.test.ts b/spikes/durable-multiview/test/web.test.ts new file mode 100644 index 00000000..97b35f2b --- /dev/null +++ b/spikes/durable-multiview/test/web.test.ts @@ -0,0 +1,51 @@ +import { fileURLToPath } from "node:url"; +import { chromium } from "@playwright/test"; +import { createServer } from "vite"; +import { expect, it } from "vitest"; +import { transcript } from "../protocol/transcript.ts"; +import { connectTo, startFauxHost, useCleanups, waitForView } from "./support.ts"; +import { startTui } from "./tui-harness.ts"; + +const defer = useCleanups(); +const webConfig = fileURLToPath(new URL("../web/vite.config.ts", import.meta.url)); + +it("shows the same conversation in the web page and the TUI, driven from either", async () => { + const host = await startFauxHost(defer, { answers: ["answer-for-the-web", "answer-for-the-tui"] }); + const observer = await connectTo(defer, host); + + const vite = await createServer({ configFile: webConfig, server: { port: 0 }, logLevel: "error" }); + await vite.listen(); + defer(() => vite.close()); + const browser = await chromium.launch(); + defer(() => browser.close()); + const page = await browser.newPage(); + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + await page.goto(`${vite.resolvedUrls!.local[0]}#token=${encodeURIComponent(host.token)}&url=${encodeURIComponent(host.url)}`); + + const tui = await startTui(defer, host.url, host.token); + await tui.until((text) => text.includes("faux/faux-1"), "the footer"); + + const composer = page.getByRole("textbox"); + await composer.fill("question-from-the-web"); + await composer.press("Enter"); + await tui.until((text) => text.includes("question-from-the-web") && text.includes("answer-for-the-web"), "the web's turn"); + await expect.poll(() => page.getByText("answer-for-the-web").count()).toBeGreaterThan(0); + + tui.pty.write("question-from-the-tui"); + tui.pty.write("\r"); + await expect.poll(() => page.getByText("answer-for-the-tui").count(), { timeout: 15_000 }).toBeGreaterThan(0); + await expect.poll(() => page.getByText("question-from-the-tui").count()).toBeGreaterThan(0); + + await waitForView(observer.view, (view) => transcript(view.conversation).length === 4); + expect(transcript(observer.view.current().conversation).map((line) => line.text)).toEqual([ + "question-from-the-web", + "answer-for-the-web", + "question-from-the-tui", + "answer-for-the-tui", + ]); + + await host.close(); + await expect.poll(() => page.getByText(/reconnecting/i).count(), { timeout: 10_000 }).toBeGreaterThan(0); + expect(pageErrors).toEqual([]); +}); diff --git a/spikes/durable-multiview/tsconfig.json b/spikes/durable-multiview/tsconfig.json index 59c1c3cb..b54f3fd2 100644 --- a/spikes/durable-multiview/tsconfig.json +++ b/spikes/durable-multiview/tsconfig.json @@ -1,7 +1,10 @@ { "compilerOptions": { "target": "ES2023", - "lib": ["ES2023", "DOM"], + "lib": [ + "ES2023", + "DOM" + ], "module": "NodeNext", "moduleResolution": "NodeNext", "allowImportingTsExtensions": true, @@ -10,7 +13,17 @@ "strict": true, "skipLibCheck": true, "noEmit": true, - "types": ["node"] + "types": [ + "node" + ] }, - "include": ["host", "protocol", "cli", "tui", "test", "vitest.config.ts"] + "include": [ + "host", + "protocol", + "presentation", + "cli", + "tui", + "test", + "vitest.config.ts" + ] } diff --git a/spikes/durable-multiview/web/App.tsx b/spikes/durable-multiview/web/App.tsx new file mode 100644 index 00000000..9a1d685f --- /dev/null +++ b/spikes/durable-multiview/web/App.tsx @@ -0,0 +1,289 @@ +import { Banner } from "@astryxdesign/core/Banner"; +import { Button } from "@astryxdesign/core/Button"; +import { + ChatComposer, + ChatLayout, + ChatMessage, + ChatMessageBubble, + ChatMessageList, + ChatSystemMessage, + ChatToolCalls, +} from "@astryxdesign/core/Chat"; +import { DropdownMenu } from "@astryxdesign/core/DropdownMenu"; +import { EmptyState } from "@astryxdesign/core/EmptyState"; +import { Layout, LayoutContent, LayoutPanel, VStack } from "@astryxdesign/core/Layout"; +import { List, ListItem } from "@astryxdesign/core/List"; +import { Markdown } from "@astryxdesign/core/Markdown"; +import { SideNav, SideNavHeading, SideNavItem, SideNavSection } from "@astryxdesign/core/SideNav"; +import { StatusDot } from "@astryxdesign/core/StatusDot"; +import { Text } from "@astryxdesign/core/Text"; +import { Token } from "@astryxdesign/core/Token"; +import type { AgentState } from "@earendil-works/pi-durable"; +import { type CSSProperties, type ReactNode, useMemo, useState } from "react"; +import { type ChatItem, chatItems, queueItems, statusText, taskRows } from "../presentation/chat.ts"; +import type { RemoteDurable } from "../protocol/remote-durable.ts"; +import { isBusy } from "../protocol/transcript.ts"; +import type { DurableView } from "../protocol/view.ts"; +import { addressFromHash } from "./address.ts"; +import { useDurableView, useRemoteDurable } from "./use-remote.ts"; + +const page: CSSProperties = { height: "100dvh", width: "100%" }; +const chatColumn: CSSProperties = { flex: 1, minHeight: 0 }; + +export function App() { + const address = useMemo(() => addressFromHash(window.location.hash), []); + if (address === undefined) { + return ( + + + + ); + } + return ; +} + +function Centered({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function Connected({ address }: { address: { url: string; token: string } }) { + const state = useRemoteDurable(address); + if (state.status === "connecting") { + return ( + + + + ); + } + if (state.status === "failed") { + return ( + + + + ); + } + return ; +} + +const agentOf = (view: DurableView): AgentState => (view.conversation.docs["pi.agent"] ?? {}) as AgentState; + +function Workbench({ remote }: { remote: RemoteDurable }) { + const view = useDurableView(remote); + const items = useMemo(() => chatItems(view.conversation), [view.conversation]); + const busy = isBusy(view.conversation); + return ( + + } + content={ + + + + } + emptyState={} + > + {items.length === 0 ? null : ( + + {items.map((item) => ( + + ))} + + )} + + + + } + end={ + + + + } + /> + + ); +} + +function ConnectionBanner({ view }: { view: DurableView }) { + if (view.connection === "reconnecting") { + return ( + + ); + } + if (view.connection === "closed") return ; + return null; +} + +function ConversationNav({ view, remote }: { view: DurableView; remote: RemoteDurable }) { + const shown = view.conversation.conversation.id; + const connection = + view.connection === "connected" ? ( + + ) : ( + + ); + return ( + } + footer={{view.session.cwd}} + > + + {[...view.conversations].map((summary) => ( + void remote.controller.switchConversation(summary.id)} + /> + ))} + + + ); +} + +function ChatRow({ item }: { item: ChatItem }) { + switch (item.kind) { + case "user": + return ( + + {item.text} + + ); + case "assistant": + return ( + + {item.text === "" ? null : ( + : undefined} + > + + {item.text} + + + )} + {item.tools.length === 0 ? null : ( + ({ + key: tool.callId, + name: tool.name, + status: tool.status, + ...(tool.conversationId !== undefined + ? { target: `subagent ${tool.conversationId}` } + : tool.target === undefined + ? {} + : { target: tool.target }), + ...(tool.status === "error" && tool.output !== undefined ? { errorMessage: tool.output } : {}), + ...(tool.status !== "error" && tool.output !== undefined + ? { resultDetail: {`\`\`\`\n${tool.output}\n\`\`\``} } + : {}), + }))} + /> + )} + + ); + case "compaction": + return Earlier context summarized; + case "reset": + return New context; + } +} + +function Composer({ view, remote, busy }: { view: DurableView; remote: RemoteDurable; busy: boolean }) { + const [value, setValue] = useState(""); + const agent = agentOf(view); + const model = agent.model === undefined ? "No model" : `${agent.model.provider}/${agent.model.modelId}`; + const status = statusText(view.conversation); + return ( + void remote.controller.submit(text, "steer")} + isStopShown={busy} + onStop={() => void remote.controller.abort()} + isDisabled={view.connection !== "connected"} + placeholder={busy ? "Steer the running turn…" : "Ask the agent…"} + headerContext={status === "" ? undefined : {status}} + footerActions={ + <> + ({ + label: `${candidate.provider}/${candidate.modelId}`, + onClick: () => void remote.controller.setModel({ provider: candidate.provider, modelId: candidate.modelId }), + }))} + /> +