diff --git a/.scratch/durable-multiview/HANDOFF.md b/.scratch/durable-multiview/HANDOFF.md index 23b6d467..c19acb32 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–P3 完成(见 §10–§13),P4 拆成两段:P4a 宿主侧演示扩展与协议已完成(§14),下一步 P4b(三端界面与剧本验证)。PR 栈:P0 [#426](https://github.com/bubbleptr/pace/pull/426) ← P1 [#427](https://github.com/bubbleptr/pace/pull/427) ← P2 [#429](https://github.com/bubbleptr/pace/pull/429) ← P3 [#430](https://github.com/bubbleptr/pace/pull/430) ← P4a `feat/durable-multiview-demo`。前置的 Pi 1.0 升级 [PR #425](https://github.com/bubbleptr/pace/pull/425) 已合入,分支 `feat/durable-multiview-spike` 从 `origin/main` 开出。 +- 状态:P0–P4 完成(见 §10–§15);P5 结论文档另列栈顶 PR。PR 栈:P0 [#426](https://github.com/bubbleptr/pace/pull/426) ← P1 [#427](https://github.com/bubbleptr/pace/pull/427) ← P2 [#429](https://github.com/bubbleptr/pace/pull/429) ← P3 [#430](https://github.com/bubbleptr/pace/pull/430) ← P4a [#431](https://github.com/bubbleptr/pace/pull/431) ← P4b `feat/durable-multiview-demo-ui`。前置的 Pi 1.0 升级 [PR #425](https://github.com/bubbleptr/pace/pull/425) 已合入。 - 关联:[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) - 目的:把上一个会话里已核实的事实、已定的决策和待决问题交给新会话,避免重查。 @@ -306,3 +306,60 @@ P4 拆成两个 PR:P4a 是宿主扩展、网关和客户端协议,三端界 - **`ConversationView.docs` 只有 4 个内置文档**,扩展文档要单独 `watchDoc`。`watchDoc` 不会创建文档,文档不存在时返回 `undefined`;网关先发 `null`,再等创建它的那次提交(`document` 或 fork 时的 `document.copy` 变更)到来后挂上 watch。 - **钩子不能提交,所以「谁在等审批」不是持久状态。** 决定是持久的(文档 + memo),等待只在宿主内存里;宿主重启后钩子重跑,会重新登记。这一点对 Pace 有影响:待审批列表要么走 spike 这样的旁路流,要么改成 §8.1 的 b)方案,用工具加任务把审批变成持久状态。 - `TaskQuery` 没有按 owner 过滤的字段,只能按 `conversationId` + `kind` 扫,再按 `owner` 过滤。 + +## 15. P4b 完成(2026-10-02):三端界面与完整剧本 + +### 15.1 落地的内容 + +- Web 与 Pace 共用审批横幅、待办、用量、思考块、fork 对话框;fork 在指定答案处分支并去掉 `rollback`。审批记录使用各端名字,TUI 支持 `/approve`、`/deny`,晚到操作会显示先前的决定。 +- 窄屏将会话导航放入抽屉、live state 放入对话框、控制项放入菜单;390px 下聊天输入仍可用,无横向溢出。Pace 根容器声明 Astryx 表面背景,避免透明窗口下正文落到黑底。 +- 失联时保留未发送草稿,禁用 Follow-up、审批、fork 和模型/压缩等写操作;恢复后仍从宿主快照续接。 +- 演示模型支持中途 steer、压缩与可调提醒时间。宿主可加载工具模块的临时副本,验收不改仓库中的演示工具。 +- `scripts/demo-scenario.mjs` / `bun run verify:demo` 驱动独立的宿主进程、Pace Electron dev 页面、Chromium 网页和 PTY TUI,保存截图及终端屏幕。数据目录、profile 和端口均独立;生成临时 Vite 配置并校验 CDP 页面的 URL,避免误连已有 Pace。 + +### 15.2 本轮验收结果 + +2026-10-02 实际运行输出: + +```text +bun run typecheck exit 0(仓库根) +bun run lint exit 0(仓库根) +bun run test 168 files / 1975 tests passed(仓库根) +bun run build Exited with code 0(仓库根) +bun run typecheck exit 0(spike) +bun run test 12 files / 46 tests passed(spike) +bun run verify:demo DONE; exit 0(spike) +``` + +本机证据在 `output/durable-multiview-p4b-20261002/`(gitignored,不提交截图): + +- `02-subagents-*`:三个子会话同时调查。 +- `03-host-lost-*`、`03-recovered-*`:SIGKILL 后三端断线、重启恢复且三个子会话 ID 不变。 +- `05-pace-subagent-*`:Pace 直接驱动子会话,并在工具运行时 steer。 +- `06-approval-and-queue-*`:三端审批、TUI steer、Web follow-up;Pace 先批准,TUI 再操作得到 Already approved by pace。 +- `07-rollback-running-*`、`08-reminder-*`:三个区域并行,一个失败、另两个 abort 补偿;Esc 后 background 提醒仍到达。 +- `09-fork-*`、`10-compacted-*`:Web fork;Pace 手动压缩,Web 和 TUI 同步看到 summary marker。TUI 的 marker 位于保留的近期消息之前,使用 Home 滚到顶部断言后再 End 返回底部。 +- `11-reloaded-*`、`12-usage-thinking-*`、`12-narrow-web.png`、`12-phone-*.png`:热替换改变下一次工具输出,用量、思考块、900px 状态弹窗、390px 手机聊天及导航。截图已经人工查看。 + +完整三端剧本使用确定性 `--faux-demo`,没有把它称作真模型验收。历史真模型冒烟仍以 §10、§14 为准。复跑: + +```sh +cd spikes/durable-multiview +bun run verify:demo ../../output/durable-multiview-rerun +``` + +### 15.3 本轮修复与边界 + +- TUI 连接时已存在审批,另一端先决定后,本端原先无法解释晚到 `/approve`;先失败测试,再从初始快照缓存审批。 +- 监听单文件 inode 会在编辑器连续原子保存后失效;先以真实宿主进程和两次 rename 保存复现,再改为监听父目录并过滤文件名。`test/host-reload.test.ts` 验证表格→纯文本的实际工具输出。 +- 两项浏览器回归保护手机宽度和断线草稿;Web 测试显式分配端口,避免 Vite 的 `port: 0` 回退默认端口造成并行碰撞。 +- 验收脚本原先因固定端口误连旧 CDP,真实运行复现后修正;脚本本身是验收工具,不再为脚本字面量另加单测。背景颜色属于样式,使用截图验收。 +- `failFast` 只中止仍在运行的兄弟任务。这里的恢复流量是演示自定义 abort 逻辑,不表示 Durable 会撤销已完成副作用。 +- 尚未生产化:手机控件尚未按 44px 触控目标全面处理。P3 的旧 `verify:three` 使用固定端口,已有 dev 实例时可能冲突;本次完整验收使用独立端口的 `verify:demo`。 + +### 15.4 PR #432 检视修复(2026-10-02) + +- 演示模型原先对所有压缩请求返回固定的“已尝试回滚”,即使只完成调查。现改为摘录实际工具结果并去重;拒绝记录不会被改写成执行记录,二次压缩也保留事实。测试检查回滚前、批准后、拒绝后及连续压缩的摘要和模型上下文。 +- Fork 和 interrupted 标记移到 assistant 消息级 metadata,不再依赖正文气泡。纯思考、纯工具条目可从真实持久 ID 创建分支;流式和中断条目仍没有 Fork,纯思考中断也有标记。浏览器回归覆盖实际分支创建与中断行为。 +- 上述行为先观察到失败测试再修复。根 typecheck、lint、1975 项测试及 build 通过;完整三端剧本再次通过,本机证据在 `output/durable-multiview-pr432-fixes-20261002/`。无正文消息的桌面与 390px 截图在 `output/durable-multiview-review-fork-20261002/`,已人工检查。 +- 复验期间旧 `web.test.ts` 出现页面未加载导致 textbox 超时;单独 Web 测试和加入临时诊断后的完整 51 项测试均通过。共享 Vite 缓存的假设尚未复现同样故障,未据此修改运行配置,也未增加超时;这是尚未定位的测试启动不稳定性。移除全部诊断后,最后一次原始 `bun run test` 为 12 文件 / 51 项全部通过(15.10s),spike typecheck 也通过。 diff --git a/apps/desktop/src/dev/durable-spike/durable-spike-page.tsx b/apps/desktop/src/dev/durable-spike/durable-spike-page.tsx index 09c94c2b..6add6e21 100644 --- a/apps/desktop/src/dev/durable-spike/durable-spike-page.tsx +++ b/apps/desktop/src/dev/durable-spike/durable-spike-page.tsx @@ -9,8 +9,8 @@ const transport = relayTransport(backendRelay({ invoke, onBackendEvent }), label /** * Dev-only third client of the Durable multiview spike (spikes/durable-multiview): * the web client's workbench, reached through Pace's backend instead of a - * browser WebSocket. Start the host first (`bun run host` in the spike). + * browser WebSocket. Start the host first (`bun run host` or `bun run demo` in the spike). */ export function DurableSpikePage() { - return ; + return ; } diff --git a/spikes/durable-multiview/host/demo/faux-script.ts b/spikes/durable-multiview/host/demo/faux-script.ts index e6315823..50093fcc 100644 --- a/spikes/durable-multiview/host/demo/faux-script.ts +++ b/spikes/durable-multiview/host/demo/faux-script.ts @@ -2,7 +2,7 @@ // conversation and its parallel subagents arrive interleaved, so every answer is decided from // the request itself: the tools it offers and the messages since the newest user input. import type { Message } from "@earendil-works/pi-ai"; -import { fauxAssistantMessage, type FauxResponseFactory, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux"; +import { fauxAssistantMessage, type FauxResponseFactory, fauxText, fauxThinking, fauxToolCall } from "@earendil-works/pi-ai/providers/faux"; import { AREAS } from "./oncall.ts"; const INVESTIGATION: readonly string[] = Object.values(AREAS).map((area) => area.tool); @@ -24,7 +24,20 @@ function offeredTools(messages: readonly Message[]): string[] { return [...tools]; } +function summarizeResults(messages: readonly Message[]): string { + const input = textOf(messages.findLast((message) => message.role === "user")); + const history = /\n([\s\S]*)\n<\/conversation>/.exec(input)?.[1] ?? ""; + // Keep the serialized result markers so another compaction can retain these same facts. + const results = history.match(/(?:^|\n)\[Tool result\]: [\s\S]*?(?=\n\n\[(?:User|Assistant(?: thinking| tool calls)?|Tool result)\]:|\n<\/summary>|$)/g) ?? []; + const excerpts = [...new Set(results.map((result) => result.trim()))]; + return excerpts.length === 0 + ? "No tool results were recorded in the summarized history." + : `Recorded tool results:\n\n${excerpts.join("\n\n")}`; +} + const PLAN = ["Search the deploy logs", "Compare error metrics", "Review the commits in v2.3"]; +/** Inputs that start something of their own; anything else arriving mid-run is a steer. */ +const COMMANDS = [/^Reminder:/, /postmortem/i, /roll ?back/i, /check the (logs|metrics|commits) again/i, /check again in \d+ seconds?/i]; export function oncallScript({ reminderSeconds = 60 }: { reminderSeconds?: number } = {}): FauxResponseFactory { return (context, _options, state) => { @@ -33,10 +46,13 @@ export function oncallScript({ reminderSeconds = 60 }: { reminderSeconds?: numbe fauxToolCall(name, args, { id: `call-${state.callCount}-${index}` }); const calls = (...blocks: ReturnType[]) => fauxAssistantMessage(blocks, { stopReason: "toolUse" }); const tools = offeredTools(messages); + // A summarization request offers no tools; its input quotes the selected history prefix. + if (tools.length === 0) return fauxAssistantMessage(summarizeResults(messages)); const userAt = messages.findLastIndex((message) => message.role === "user"); const input = textOf(messages[userAt]); // A changed section, such as the plan after update_plan, lands as a system message after the results. - const last = messages.slice(userAt + 1).findLast((message) => message.role !== "system"); + const spoken = (message: Message) => message.role !== "system"; + let last = messages.slice(userAt + 1).findLast(spoken); // A subagent: one investigation tool, called once, then reported on. const own = tools.length === 1 && INVESTIGATION.includes(tools[0]!) ? tools[0]! : undefined; @@ -46,14 +62,22 @@ export function oncallScript({ reminderSeconds = 60 }: { reminderSeconds?: numbe return fauxAssistantMessage(`Found in ${own}: ${first}`); } + const command = COMMANDS.some((pattern) => pattern.test(input)); + // A steer is placed after the tool round it interrupted: keep following that round. + const before = messages.slice(0, userAt).findLast(spoken); + if (last === undefined && !command && before?.role === "toolResult") last = before; + if (last?.role === "toolResult") { if (last.isError) return fauxAssistantMessage(`${last.toolName} did not run: ${textOf(last)} Standing by.`); switch (last.toolName) { case "update_plan": if (messages.some((message) => message.role === "toolResult" && message.toolName === "subagent")) { - return fauxAssistantMessage( - "The pg driver bump in a1b2c3d cut the pool from 100 to 20; eu-west exhausted it at 09:14 and errors went to 14.8%. I recommend rolling back to v2.2.", - ); + return fauxAssistantMessage([ + fauxThinking("Logs show pool exhaustion in eu-west, metrics show saturated connections, and a1b2c3d changed the pool default. Same cause."), + fauxText( + "The pg driver bump in a1b2c3d cut the pool from 100 to 20; eu-west exhausted it at 09:14 and errors went to 14.8%. I recommend rolling back to v2.2.", + ), + ]); } return calls( call("subagent", { area: "logs", task: "find the errors around the v2.3 deploy in the logs" }, 0), @@ -76,6 +100,8 @@ export function oncallScript({ reminderSeconds = 60 }: { reminderSeconds?: numbe return fauxAssistantMessage([fauxText("Postmortem: v2.3 shipped a pg driver bump that shrank the connection pool; eu-west saturated first.")]); } if (/roll ?back/i.test(input)) return calls(call("rollback", { version: "v2.2", reason: "pg driver pool regression in v2.3" })); + const again = /check the (logs|metrics|commits) again/i.exec(input); + if (again !== null) return calls(call("subagent", { area: again[1]!.toLowerCase(), task: `check the ${again[1]} again` })); const delay = /check again in (\d+) seconds?/i.exec(input); if (delay !== null) return calls(call("schedule_check", { seconds: Number(delay[1]), note: "re-check the error rate" })); if (tools.includes("update_plan") && !messages.some((message) => message.role === "toolResult" && message.toolName === "update_plan")) { diff --git a/spikes/durable-multiview/host/demo/index.ts b/spikes/durable-multiview/host/demo/index.ts index 2f7bad88..2302891c 100644 --- a/spikes/durable-multiview/host/demo/index.ts +++ b/spikes/durable-multiview/host/demo/index.ts @@ -1,6 +1,6 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; -import { createRegistry, type Extension, type Registry } from "@earendil-works/pi-durable"; +import { createRegistry, type Extension, type HarnessSettings, type Registry } from "@earendil-works/pi-durable"; import type { OpenHostOptions } from "../host.ts"; import { ApprovalBoard } from "./approvals.ts"; import { AREAS, createOncall } from "./oncall.ts"; @@ -23,6 +23,8 @@ export interface Demo { readonly hostOptions: Pick; /** Reinstall the investigation tools from their module; returns the module's load count. */ reload(): Promise; + /** `base` with a short verbatim tail, so a manual compaction of a demo-sized transcript has something to summarize. */ + settings(base?: HarnessSettings): HarnessSettings; } const defaultModule = fileURLToPath(new URL("./investigation.ts", import.meta.url)); @@ -59,5 +61,12 @@ export async function createDemo(options: DemoOptions = {}): Promise { registry.install(await load()); return loads; }, + settings(base = {}) { + // Descriptors, not a spread: pi's settings are getters that read the settings file at each use. + return Object.defineProperties({}, { + ...Object.getOwnPropertyDescriptors(base), + compaction: { enumerable: true, get: () => ({ ...base.compaction, keepRecentTokens: 400 }) }, + }); + }, }; } diff --git a/spikes/durable-multiview/host/main.ts b/spikes/durable-multiview/host/main.ts index b43983d7..7836cb40 100644 --- a/spikes/durable-multiview/host/main.ts +++ b/spikes/durable-multiview/host/main.ts @@ -7,7 +7,7 @@ // node host/main.ts --faux-demo # the same demo driven by a scripted model, for tests and rehearsals // node host/main.ts --faux "scripted answer" [--faux-tps 40] # no real model, for tests import { watch } from "node:fs"; -import { join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { parseArgs } from "node:util"; import { createModels } from "@earendil-works/pi-ai/models"; import { type FauxResponseStep, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux"; @@ -30,6 +30,7 @@ const { values } = parseArgs({ "pace-ms": { type: "string" }, "step-ms": { type: "string" }, "reminder-seconds": { type: "string" }, + "investigation-module": { type: "string" }, }, }); @@ -41,6 +42,7 @@ const demo: Demo | undefined = ? await createDemo({ ...(values["pace-ms"] === undefined ? {} : { paceMs: Number(values["pace-ms"]) }), ...(values["step-ms"] === undefined ? {} : { stepMs: Number(values["step-ms"]) }), + ...(values["investigation-module"] === undefined ? {} : { investigationModule: resolve(values["investigation-module"]) }), }) : undefined; const common = { @@ -59,7 +61,13 @@ function fauxOptions(responses: () => FauxResponseStep): OpenHostOptions { faux.setResponses(Array.from({ length: 1000 }, responses)); const model = faux.getModel(); const summary = { provider: model.provider, modelId: model.id, name: model.name, contextWindow: model.contextWindow }; - return { ...common, models, modelSummaries: () => [summary], initialModel: { provider: model.provider, modelId: model.id } }; + return { + ...common, + models, + modelSummaries: () => [summary], + initialModel: { provider: model.provider, modelId: model.id }, + ...(demo === undefined ? {} : { settings: demo.settings() }), + }; } async function piOptions(): Promise { @@ -71,7 +79,7 @@ async function piOptions(): Promise { ...common, models: modelRuntime, modelSummaries: () => modelSummaries(modelRuntime), - settings: createHarnessSettings(settingsManager), + settings: demo === undefined ? createHarnessSettings(settingsManager) : demo.settings(createHarnessSettings(settingsManager)), ...(initialModel === undefined ? {} : { initialModel }), }; } @@ -92,7 +100,9 @@ console.log(JSON.stringify({ event: "ready", url: host.url, token: host.token, d if (demo !== undefined) { // Editors save in bursts; reload once the burst settles. let timer: NodeJS.Timeout | undefined; - const watcher = watch(demo.investigationModule, () => { + // Watch the directory because an editor's atomic save replaces the file's inode. + const watcher = watch(dirname(demo.investigationModule), (_event, filename) => { + if (filename !== null && filename !== basename(demo.investigationModule)) return; clearTimeout(timer); timer = setTimeout(() => { demo.reload().then( diff --git a/spikes/durable-multiview/package.json b/spikes/durable-multiview/package.json index 8c909eaf..d06ef6c5 100644 --- a/spikes/durable-multiview/package.json +++ b/spikes/durable-multiview/package.json @@ -13,7 +13,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p web/tsconfig.json", "tui": "node tui/main.ts", "web": "vite --config web/vite.config.ts", - "verify:three": "node scripts/three-clients.mjs" + "verify:three": "node scripts/three-clients.mjs", + "verify:demo": "node scripts/demo-scenario.mjs" }, "dependencies": { "@astryxdesign/core": "^0.3.0", diff --git a/spikes/durable-multiview/presentation/chat.ts b/spikes/durable-multiview/presentation/chat.ts index ee8173d6..63c5b5f3 100644 --- a/spikes/durable-multiview/presentation/chat.ts +++ b/spikes/durable-multiview/presentation/chat.ts @@ -10,7 +10,10 @@ import type { TaskGraph, TaskGraphNode, TaskId, + UsageState, } from "@earendil-works/pi-durable"; +import type { PendingApproval, PlanItem, PlanState } from "../protocol/demo.ts"; +import type { DurableView } from "../protocol/view.ts"; export interface ToolCallView { readonly callId: string; @@ -29,6 +32,8 @@ export type ChatItem = readonly kind: "assistant"; readonly id: string; readonly text: string; + /** The answer's thinking blocks, when the model showed any. */ + readonly thinking?: string; /** Absent while streaming; `aborted` for a partial an interrupted attempt left behind. */ readonly stopReason?: string; readonly streaming: boolean; @@ -37,7 +42,14 @@ export type ChatItem = | { 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 Block = { + readonly type: string; + readonly text?: string; + readonly thinking?: 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 { @@ -45,6 +57,11 @@ function textOf(content: Message["content"]): string { return content.flatMap((block) => (block.type === "text" && block.text !== undefined ? [block.text] : [])).join(""); } +function thinkingOf(content: Message["content"]): string { + if (typeof content === "string") return ""; + return content.flatMap((block) => (block.type === "thinking" && block.thinking !== undefined ? [block.thinking] : [])).join("\n\n"); +} + function targetOf(args: unknown): string | undefined { if (args === null || typeof args !== "object") return undefined; const values = Object.values(args as Record); @@ -80,10 +97,12 @@ export function chatItems(view: ConversationView): ChatItem[] { 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 })); + const thinking = thinkingOf(message.content); items.push({ kind: "assistant", id, text: textOf(message.content), + ...(thinking === "" ? {} : { thinking }), ...(streaming ? {} : { stopReason: message.stopReason ?? "stop" }), streaming, tools, @@ -166,6 +185,37 @@ function describeTask(node: TaskGraphNode): string { return `${node.kind} #${node.id}: ${status}${flags.length > 0 ? ` [${flags.join(", ")}]` : ""}${owned}`; } +/** The on-call demo's plan for the shown conversation. */ +export function planItems(docs: DurableView["docs"]): readonly PlanItem[] { + return (docs["demo.plan"] as PlanState | null | undefined)?.items ?? []; +} + +export interface UsageRow { + /** `provider/model` of model responses, or a tool name. */ + readonly key: string; + readonly input: number; + readonly output: number; + readonly cost: number; +} + +/** Spend of the shown conversation, as `pi.usage` totals it; failed and aborted attempts count. */ +export function usageRows(view: ConversationView): UsageRow[] { + const usage = view.docs["pi.usage"] as UsageState | undefined; + if (usage === undefined) return []; + return [...Object.entries(usage.models), ...Object.entries(usage.tools)].map(([key, value]) => ({ + key, + input: value.input, + output: value.output, + cost: value.cost.total, + })); +} + +/** An approval prompt that says which conversation is waiting. */ +export function approvalText(approval: PendingApproval, conversations: DurableView["conversations"]): string { + const where = conversations.find((summary) => summary.id === approval.conversationId)?.label ?? `conversation ${approval.conversationId}`; + return `${where}: ${approval.summary}`; +} + /** 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); diff --git a/spikes/durable-multiview/scripts/demo-scenario.mjs b/spikes/durable-multiview/scripts/demo-scenario.mjs new file mode 100644 index 00000000..9a361454 --- /dev/null +++ b/spikes/durable-multiview/scripts/demo-scenario.mjs @@ -0,0 +1,320 @@ +// P4 proof: the on-call scenario (HANDOFF §6) on one scripted-model host, walked through Pace's +// dev page, the web page and the TUI at once. One kill -9 and one hot reload on the way. +// A node client watches alongside to check what the screens cannot show. Isolated like +// three-clients.mjs: its own ports, Pace profile, data and agent dirs, and a copy of the +// investigation module to edit. +// +// node scripts/demo-scenario.mjs [evidence-dir] # default: a new temp dir, printed at the end +import { execFileSync, spawn } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { createServer } from "node:net"; +import { join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; +import { spawn as spawnPty } from "@lydell/node-pty"; +import { chromium } from "@playwright/test"; +import xterm from "@xterm/headless"; +import { connectRemoteDurable } from "../protocol/remote-durable.ts"; + +const spike = fileURLToPath(new URL("..", import.meta.url)); +const repo = resolve(spike, "../.."); +const T = mkdtempSync(join(tmpdir(), "dmv-demo-")); +const hostDir = join(T, "agent/experimental/durable-multiview/default"); +mkdirSync(join(T, "data"), { recursive: true }); +writeFileSync(join(T, "data/preflight-status.json"), JSON.stringify({ completedAt: "2026-10-02T00:00:00.000Z" })); +// The host must import the copy with the spike's node_modules in reach, so it lives in the spike. +const moduleDir = join(spike, ".tmp", `scenario-${process.pid}`); +mkdirSync(moduleDir, { recursive: true }); +const investigation = join(moduleDir, "investigation.ts"); +copyFileSync(join(spike, "host/demo/investigation.ts"), investigation); +async function freePort() { + const server = createServer(); + await new Promise((resolve, reject) => server.once("error", reject).listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + return port; +} +const [PORT, cdpPort, pacePort, webPort] = await Promise.all(Array.from({ length: 4 }, freePort)); +const paceConfig = join(moduleDir, "electron.vite.config.mjs"); +writeFileSync(paceConfig, `import config from ${JSON.stringify(join(repo, "apps/desktop/electron.vite.config.ts"))}; +config.renderer.server.port = ${pacePort}; +config.renderer.cacheDir = ${JSON.stringify(join(T, "vite-cache"))}; +export default config; +`); +const out = resolve(process.argv[2] ?? mkdtempSync(join(tmpdir(), "dmv-demo-evidence-"))); +mkdirSync(out, { recursive: true }); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const children = []; +let paceLog = ""; +let screenAtFailure; +let pagesAtFailure; +let observer; +let browser; + +console.log(`Evidence: ${out}`); + +async function readyLine(child, predicate, label) { + const lines = createInterface({ input: child.stdout }); + const timeout = setTimeout(() => lines.close(), 30_000); + try { + for await (const line of lines) if (predicate(line)) return line; + throw new Error(`${label} did not become ready (exit ${child.exitCode})`); + } finally { + clearTimeout(timeout); + lines.close(); + } +} + +async function startHost() { + const args = ["host/main.ts", "--faux-demo", "--data-dir", hostDir, "--cwd", T, "--port", String(PORT), "--lock-stale-ms", "2000"]; + args.push("--faux-tps", "60", "--pace-ms", "300", "--step-ms", "1500", "--reminder-seconds", "8", "--investigation-module", investigation); + const host = spawn(process.execPath, args, { cwd: spike, stdio: ["ignore", "pipe", "inherit"] }); + children.push(host); + const ready = JSON.parse(await readyLine(host, (line) => line.startsWith("{") && JSON.parse(line).event === "ready", "host")); + return { host, ready }; +} +async function until(what, predicate, timeout = 30000) { + const deadline = Date.now() + timeout; + while (!(await predicate())) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await sleep(200); + } + console.log(" ok:", what); +} +const step = (title) => console.log(`\n${title}`); + +try { + let { host, ready } = await startHost(); + observer = await connectRemoteDurable({ url: ready.url, token: ready.token, clientName: "observer", reconnectDelayMs: { min: 100, max: 500 } }); + await observer.controller.toggleTasks(); + const seen = () => observer.view.current(); + const subagents = () => seen().conversations.filter((summary) => summary.label.startsWith("subagent")); + + const pace = spawn(join(repo, "node_modules/.bin/electron-vite"), ["dev", "--config", paceConfig, "--remoteDebuggingPort", String(cdpPort), "--", `--user-data-dir=${join(T, "profile")}`], { + cwd: join(repo, "apps/desktop"), + env: { ...process.env, PACE_DATA_DIR: join(T, "data"), PI_CODING_AGENT_DIR: join(T, "agent"), PACE_DURABLE_SPIKE_URL: ready.url, PACE_E2E: "1" }, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + children.push(pace); + pace.stdout.on("data", (d) => (paceLog += d)); + pace.stderr.on("data", (d) => (paceLog += d)); + let cdp; + await until("Pace CDP", async () => { + if (pace.exitCode !== null) throw new Error(`Pace exited before CDP was ready (${pace.exitCode})`); + try { cdp = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`); return true; } catch { return false; } + }, 90000); + let p; + await until("Pace window", async () => { p = cdp.contexts().flatMap((c) => c.pages()).find((page) => page.url().startsWith(`http://127.0.0.1:${pacePort}`)); return p !== undefined; }); + await p.waitForLoadState("domcontentloaded"); + await p.setViewportSize({ width: 1400, height: 860 }).catch(() => {}); + await p.evaluate(() => { window.location.hash = "#/durable-spike"; }); + await until("Pace page connected", async () => (await p.getByText("Durable host").count()) > 0, 30000); + + const webServer = spawn(process.execPath, [join(repo, "node_modules/vite/bin/vite.js"), "--config", "web/vite.config.ts", "--port", String(webPort), "--strictPort"], { cwd: spike, stdio: ["ignore", "pipe", "inherit"] }); + children.push(webServer); + await readyLine(webServer, (line) => line.includes(`:${webPort}/`), "web server"); + browser = await chromium.launch(); + const w = await browser.newPage({ viewport: { width: 1400, height: 860 } }); + const webUrl = new URL(ready.web); + webUrl.port = String(webPort); + await w.goto(webUrl.href); + pagesAtFailure = { pace: p, web: w }; + await until("web connected", async () => (await w.getByText("Durable host").count()) > 0); + + const cols = 140, rows = 44; + const term = new xterm.Terminal({ cols, rows, allowProposedApi: true }); + mkdirSync(join(T, "tui-agent"), { recursive: true }); + const tui = spawnPty(process.execPath, ["tui/main.ts", "--url", ready.url, "--token", ready.token], { cols, rows, cwd: spike, env: { ...process.env, PI_CODING_AGENT_DIR: join(T, "tui-agent"), TERM: "xterm-256color" } }); + children.push({ kill: () => tui.kill() }); + tui.onData((d) => term.write(d)); + const screen = () => Array.from({ length: rows }, (_, i) => term.buffer.active.getLine(i)?.translateToString(true).trimEnd() ?? "").join("\n"); + // Long lines wrap, so compare with the line breaks folded into spaces. + const tuiShows = (text) => screen().replace(/\s+/g, " ").includes(text); + const tuiType = async (text) => { for (const ch of text) { tui.write(ch); await sleep(4); } tui.write("\r"); }; + screenAtFailure = screen; + await until("TUI footer", () => tuiShows("faux/faux-1")); + + const shoot = async (name, pages = { pace: p, web: w }) => { + for (const [client, page] of Object.entries(pages)) await page.screenshot({ path: join(out, `${name}-${client}.png`) }); + writeFileSync(join(out, `${name}-tui.txt`), screen()); + }; + const shows = (page, text) => async () => (await page.getByText(text).count()) > 0; + const ask = async (page, text) => { await page.getByRole("textbox").first().fill(text); await page.getByRole("textbox").first().press("Enter"); }; + + step("1. The web asks; the plan shows everywhere"); + await ask(w, "v2.3 release failed, find out why"); + await until("Pace shows the plan", shows(p, "Search the deploy logs")); + await until("TUI shows the plan", () => tuiShows("Plan") && tuiShows("Search the deploy logs")); + + step("2. Three subagents in parallel"); + await until("three subagents run their tools", () => { + const children = new Set(subagents().map((summary) => summary.id)); + return Object.values(seen().tasks?.tasks ?? {}).filter((node) => node.kind === "pi.tool" && children.has(node.conversationId)).length === 3; + }); + const before = subagents().map((summary) => summary.id); + await shoot("02-subagents"); + + step("3. kill -9 the host mid-investigation, then restart it"); + host.kill("SIGKILL"); + await until("Pace shows the lost host", shows(p, "Host connection lost")); + await until("web shows the lost host", shows(w, "Host connection lost")); + await until("TUI shows the lost host", () => tuiShows("reconnecting")); + await shoot("03-host-lost"); + ({ host } = await startHost()); + await until("Pace shows the recommendation", shows(p, "I recommend rolling back to v2.2"), 60000); + await until("TUI shows the recommendation", () => tuiShows("recommend rolling back"), 30000); + if (subagents().map((summary) => summary.id).join() !== before.join()) throw new Error(`subagents changed: ${before} -> ${subagents().map((s) => s.id)}`); + console.log(" ok: the same three subagents after the restart"); + await shoot("03-recovered"); + + step("5. Pace talks to a subagent directly"); + await p.getByText(/^subagent \d+ · logs/).click(); + await until("Pace shows the logs subagent", shows(p, "Found in search_logs")); + await ask(p, "also look at ap-south"); + const logsId = subagents().find((summary) => summary.title.startsWith("logs:")).id; + await until("the logs subagent is running a tool", () => Object.values(seen().tasks?.tasks ?? {}).some((node) => node.kind === "pi.tool" && node.conversationId === logsId)); + await ask(p, "focus on the connection pool limit"); + await until("Pace steers the running subagent", shows(p, "focus on the connection pool limit")); + await until("the subagent answers Pace", async () => (await p.getByText("Found in search_logs").count()) >= 2); + await shoot("05-pace-subagent", { pace: p }); + await p.getByText(/^main/).first().click(); + await until("Pace is back on main", shows(p, "I recommend rolling back to v2.2")); + + step("6. A rollback asks every client for approval"); + await ask(w, "roll back to v2.2"); + await until("Pace asks", shows(p, "Approve rollback?")); + await until("web asks", shows(w, "Approve rollback?")); + await until("TUI asks", () => tuiShows("Approve rollback?")); + + step("4. While it waits: the TUI steers, the web queues a follow-up"); + await tuiType("keep eu-west drained until the fix ships"); + await w.getByRole("textbox").first().fill("then draft the status page update"); + await w.getByRole("button", { name: "Follow-up" }).click(); + await until("Pace shows both queued", async () => (await shows(p, "keep eu-west drained until the fix ships")()) && (await shows(p, "then draft the status page update")())); + await until("TUI shows both queued", () => tuiShows("[steer] keep eu-west") && tuiShows("[followUp] then draft")); + await shoot("06-approval-and-queue"); + + step("6. Pace approves first; the TUI's later /approve is told"); + await p.getByRole("button", { name: "Approve", exact: true }).click(); + await tuiType("/approve"); + await until("TUI hears it was already decided", () => tuiShows("Already approved by pace")); + await until("the banners are gone", async () => (await p.getByText("Approve rollback?").count()) === 0 && (await w.getByText("Approve rollback?").count()) === 0); + + step("7. The rollback: one region fails, the others are compensated"); + await until("region tasks in the task graph", () => Object.values(seen().tasks?.tasks ?? {}).some((node) => node.kind === "demo.region-rollback")); + await until("healthy regions are switching while eu-west is checking", () => { + const run = Object.values(seen().docs["demo.rollout"]?.runs ?? {})[0] ?? {}; + return run["us-east"] === "switching to v2.2" && run["ap-south"] === "switching to v2.2"; + }); + await shoot("07-rollback-running"); + await until("the rollout settles", () => { + const run = Object.values(seen().docs["demo.rollout"]?.runs ?? {})[0] ?? {}; + return run["eu-west"] === "failed: health check" && run["us-east"] === "compensated: traffic restored" && run["ap-south"] === "compensated: traffic restored"; + }); + + step("8. Esc, and the scheduled check still arrives"); + await until("Pace shows the check scheduled", shows(p, "Check scheduled"), 30000); + tui.write("\u001b"); + await until("the reminder arrives as a follow-up", shows(p, /^Reminder: verify the error rate/), 30000); + await until("and is answered", shows(p, "back to baseline"), 30000); + await shoot("08-reminder"); + + step("9. The web forks at the recommendation without the rollback tool"); + const answer = w.locator("*", { has: w.getByText("I recommend rolling back to v2.2") }).filter({ has: w.getByRole("button", { name: "Fork" }) }).last(); + await answer.getByRole("button", { name: "Fork" }).click(); + await w.getByRole("dialog").getByRole("button", { name: "Fork", exact: true }).click(); + await until("the web shows the fork's postmortem", shows(w, "Postmortem:"), 30000); + await until("Pace lists the fork", shows(p, /^fork \d+/)); + await shoot("09-fork"); + + step("10. Pace compacts main; every client sees it"); + await w.getByText(/^main/).first().click(); + await p.getByRole("button", { name: "Compact" }).click(); + await until("Pace shows the summary marker", shows(p, "Earlier context summarized"), 30000); + await until("web shows the same summary marker", shows(w, "Earlier context summarized")); + // Compaction precedes the retained recent turns, outside the bottom viewport. + tui.write("\u001b[H"); + await until("TUI shows compaction", () => tuiShows("[compaction]")); + // The host tells the client that asked. + await until("Pace hears the compaction completed", shows(p, "Compaction completed."), 30000); + await shoot("10-compacted"); + tui.write("\u001b[F"); + + step("11. Edit the investigation tools; the host reloads them"); + writeFileSync(investigation, readFileSync(investigation, "utf8").replace(`const FORMAT = "plain"`, `const FORMAT = "table"`)); + await until("the host reports the reload", () => seen().notices.some((notice) => /Reloaded the investigation tools \(load 2\)/.test(notice.message))); + await ask(p, "check the metrics again"); + await until("a fourth subagent ran", () => subagents().length === 4); + const fresh = subagents().at(-1); + await until("its output is a table now", async () => { + await observer.controller.switchConversation(fresh.id); + return seen().conversation.entries.some((entry) => entry.kind === "pi.tool-result" && JSON.stringify(entry.model).includes("| metric | change | where |")); + }); + await p.getByText(new RegExp(`^subagent ${fresh.id} `)).click(); + await sleep(500); + await shoot("11-reloaded", { pace: p }); + + step("12. Usage, the thinking block, and the narrow layout"); + await p.getByText(/^main/).first().click(); + await until("Pace shows usage", shows(p, "Usage")); + await p.getByText("Thinking", { exact: true }).first().click(); + await until("the thinking block opens", shows(p, "Same cause.")); + await shoot("12-usage-thinking", { pace: p }); + await w.setViewportSize({ width: 900, height: 860 }); + await until("the narrow web hides the panel", async () => (await w.getByRole("button", { name: "Live state" }).count()) > 0); + await w.getByRole("button", { name: "Live state" }).click(); + await until("the live state opens in a dialog", async () => (await w.getByRole("dialog").getByText("Tasks").count()) > 0); + // Let the dialog finish fading in. + await sleep(600); + await w.screenshot({ path: join(out, "12-narrow-web.png") }); + await w.keyboard.press("Escape"); + await w.setViewportSize({ width: 390, height: 844 }); + await until("phone chat keeps a usable input", async () => (await w.getByRole("textbox").boundingBox())?.width > 300); + if (await w.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)) throw new Error("phone layout overflows horizontally"); + await w.screenshot({ path: join(out, "12-phone-web.png") }); + await w.getByRole("button", { name: "Conversations", exact: true }).click(); + await until("phone navigation opens", async () => (await w.getByRole("dialog").getByText(/^main/).count()) > 0); + await w.screenshot({ path: join(out, "12-phone-navigation-web.png") }); + + console.log(`\nDONE; evidence in ${out}`); +} catch (error) { + console.error("FAILED:", error.message); + for (const [name, page] of Object.entries(pagesAtFailure ?? {})) { + await page.screenshot({ path: join(out, `failure-${name}.png`) }).catch(() => {}); + writeFileSync(join(out, `failure-${name}.txt`), await page.locator("body").innerText().catch(() => "page closed")); + } + try { + writeFileSync(join(out, "failure-tui.txt"), screenAtFailure?.() ?? ""); + } catch {} + console.error(paceLog.slice(-3000)); + process.exitCode = 1; +} finally { + observer?.close(); + await browser?.close(); + for (const child of children) { + try { + if (child.pid) process.kill(-child.pid, "SIGTERM"); + else child.kill(); + } catch { + try { + child.kill(); + } catch {} + } + } + // electron-vite starts Electron outside its process group; end only the one on this run's profile. + const profile = join(T, "profile"); + for (const line of execFileSync("ps", ["-axo", "pid=,command="], { encoding: "utf8" }).split("\n")) { + const [pid, ...command] = line.trim().split(/\s+/); + if (pid && command.join(" ").includes(`--user-data-dir=${profile}`)) { + try { + process.kill(Number(pid), "SIGTERM"); + } catch {} + } + } + await sleep(1000); + rmSync(T, { recursive: true, force: true }); + rmSync(moduleDir, { recursive: true, force: true }); + process.exit(); +} diff --git a/spikes/durable-multiview/test/demo.test.ts b/spikes/durable-multiview/test/demo.test.ts index fee63474..48a12d48 100644 --- a/spikes/durable-multiview/test/demo.test.ts +++ b/spikes/durable-multiview/test/demo.test.ts @@ -18,6 +18,8 @@ const lastText = (view: DurableView): string | undefined => transcript(view.conv const subagents = (view: DurableView) => view.conversations.filter((summary) => summary.label.startsWith("subagent")); const rollout = (view: DurableView) => view.docs["demo.rollout"] as RolloutState | null | undefined; const plan = (view: DurableView) => view.docs["demo.plan"] as PlanState | null | undefined; +const queueItemsOf = (view: DurableView) => ((view.conversation.docs["pi.inbox"] ?? { items: [] }) as { items: { mode: string }[] }).items; +const summaryOf = (view: DurableView) => JSON.stringify(view.conversation.entries.findLast((entry) => entry.kind === "pi.compaction")?.model); describe("on-call demo", () => { it("plans, then delegates to three subagents in parallel; each child is a listed conversation", async () => { @@ -134,6 +136,63 @@ describe("on-call demo", () => { expect(transcript(view.conversation).at(-2)).toEqual({ role: "user", text: "write the postmortem" }); }); + it("takes a steer sent while the rollback waits without losing its place", async () => { + const { host } = await startDemoHost(defer); + const web = await connectTo(defer, host, host.token, "web"); + const tui = await connectTo(defer, host, host.token, "tui"); + await web.controller.submit("roll back to v2.2", "followUp"); + await waitForView(tui.view, (view) => view.approvals.length === 1); + await tui.controller.submit("keep eu-west drained until the fix ships", "steer"); + await waitForView(web.view, (view) => queueItemsOf(view).some((item) => item.mode === "steer")); + await web.controller.approve(web.view.current().approvals[0]!, true); + + await waitForView(web.view, (view) => idle(view) && /check scheduled/i.test(lastText(view) ?? "")); + expect(transcript(web.view.current().conversation).map((line) => line.text)).toContain("keep eu-west drained until the fix ships"); + }); + + it("compacts the investigation without inventing a rollback; every client sees the summary marker", async () => { + const { host } = await startDemoHost(defer); + const pace = await connectTo(defer, host, host.token, "pace"); + const web = await connectTo(defer, host, host.token, "web"); + await pace.controller.submit("v2.3 release failed, find out why", "followUp"); + await waitForView(pace.view, (view) => idle(view) && /recommend rolling back/i.test(lastText(view) ?? "")); + + await pace.controller.compact(undefined); + await waitForView(web.view, (view) => view.conversation.entries.some((entry) => entry.kind === "pi.compaction")); + await waitForView(pace.view, (view) => view.notices.some((notice) => /compaction completed/i.test(notice.message))); + expect(summaryOf(web.view.current())).not.toContain("a rollback was attempted"); + // This first cut leaves the investigation results in the verbatim tail. + const main = (await host.harness.conversation(pace.view.current().conversation.conversation.id, context))!; + const { messages } = await main.context(context); + expect(JSON.stringify(messages)).not.toContain("a rollback was attempted"); + expect(JSON.stringify(messages)).toContain("pg driver 8.11 -> 9.0 (pool default 100 -> 20)"); + }); + + it.each([true, false])("preserves the actual rollback outcome across repeated compaction (approved: %s)", async (approved) => { + const { host } = await startDemoHost(defer); + const client = await connectTo(defer, host, host.token, "web"); + await client.controller.submit("v2.3 release failed, find out why", "followUp"); + await waitForView(client.view, (view) => idle(view) && /recommend rolling back/i.test(lastText(view) ?? "")); + await client.controller.submit("roll back to v2.2", "followUp"); + await waitForView(client.view, (view) => view.approvals.length === 1); + await client.controller.approve(client.view.current().approvals[0]!, approved); + await waitForView(client.view, (view) => idle(view) && (approved ? /check scheduled/i : /did not run/i).test(lastText(view) ?? "")); + + for (let pass = 0; pass < 2; pass++) { + // Keep a full user message after the outcome so it lies before the compaction's verbatim tail. + await client.controller.submit(`write the postmortem. ${"Preserve the incident timeline and the available evidence. ".repeat(40)}`, "followUp"); + await waitForView(client.view, (view) => idle(view) && /Postmortem:/i.test(lastText(view) ?? "")); + const previous = client.view.current().conversation.entries.findLast((entry) => entry.kind === "pi.compaction")?.id; + await client.controller.compact(undefined); + await waitForView(client.view, (view) => view.conversation.entries.findLast((entry) => entry.kind === "pi.compaction")?.id !== previous); + + const summary = summaryOf(client.view.current()); + expect(summary).toContain("pg driver 8.11 -> 9.0 (pool default 100 -> 20)"); + expect(summary).toContain(approved ? "Rollback to v2.2 failed; drained regions restored." : "Tool call blocked: Rollback denied by web."); + if (!approved) expect(summary).not.toMatch(/rollback was attempted|Rollback to v2.2 failed/); + } + }); + it("hot-reloads the investigation tools; the next call uses the new code", async () => { const dir = join(spikeDir, ".tmp", `reload-${process.pid}-${Date.now()}`); await mkdir(dir, { recursive: true }); @@ -146,10 +205,9 @@ describe("on-call demo", () => { await writeFile(module, (await readFile(module, "utf8")).replace(`const FORMAT = "plain"`, `const FORMAT = "table"`)); expect(await demo.reload()).toBe(2); - await client.controller.submit("v2.3 release failed, find out why", "followUp"); - await waitForView(client.view, (view) => idle(view) && /recommend rolling back/i.test(lastText(view) ?? "")); - const logs = subagents(client.view.current()).find((summary) => summary.title?.startsWith("logs"))!; - const child = await host.harness.conversation(logs.id, context); + await client.controller.submit("check the metrics again", "followUp"); + await waitForView(client.view, (view) => idle(view) && subagents(view).length === 1); + const child = await host.harness.conversation(subagents(client.view.current())[0]!.id, context); const { messages } = await child!.context(context); const output = messages.flatMap((message) => (message.role === "toolResult" ? message.content : [])); expect(output.map((block) => (block.type === "text" ? block.text : "")).join("")).toMatch(/^\| /m); diff --git a/spikes/durable-multiview/test/host-reload.test.ts b/spikes/durable-multiview/test/host-reload.test.ts new file mode 100644 index 00000000..0b2582eb --- /dev/null +++ b/spikes/durable-multiview/test/host-reload.test.ts @@ -0,0 +1,71 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; +import { connectRemoteDurable } from "../protocol/remote-durable.ts"; +import { isBusy } from "../protocol/transcript.ts"; +import { tempDir, useCleanups, waitForView } from "./support.ts"; + +const defer = useCleanups(); +const spikeDir = fileURLToPath(new URL("..", import.meta.url)); + +it("reloads the investigation tools after consecutive atomic editor saves", async () => { + const data = await tempDir(); + defer(data.remove); + await mkdir(join(spikeDir, ".tmp"), { recursive: true }); + const modules = await mkdtemp(join(spikeDir, ".tmp", "host-reload-")); + defer(() => rm(modules, { recursive: true, force: true })); + const module = join(modules, "investigation.ts"); + const original = await readFile(join(spikeDir, "host", "demo", "investigation.ts"), "utf8"); + await writeFile(module, original); + const child = spawn( + process.execPath, + [ + "host/main.ts", "--faux-demo", "--data-dir", data.path, "--cwd", data.path, + "--port", "0", "--faux-tps", "1000", "--pace-ms", "0", "--investigation-module", module, + ], + { cwd: spikeDir, stdio: ["ignore", "pipe", "inherit"] }, + ); + defer(async () => { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = once(child, "exit"); + child.kill("SIGTERM"); + await exited; + }); + let address: { url: string; token: string } | undefined; + for await (const line of createInterface({ input: child.stdout! })) { + const event = JSON.parse(line) as { event: string; url: string; token: string }; + if (event.event !== "ready") continue; + address = event; + break; + } + if (address === undefined) throw new Error("host exited before it was ready"); + const client = await connectRemoteDurable(address); + defer(() => client.close()); + const root = client.view.current().conversation.conversation.id; + + for (const format of ["table", "plain"]) { + const notices = client.view.current().notices.length; + await writeFile(`${module}.tmp`, original.replace(`const FORMAT = "plain"`, `const FORMAT = "${format}"`)); + await rename(`${module}.tmp`, module); + await waitForView( + client.view, + (view) => view.notices.slice(notices).some((notice) => /Reloaded the investigation tools/.test(notice.message)), + 5000, + ); + await client.controller.switchConversation(root); + const count = client.view.current().conversations.filter((summary) => summary.label.startsWith("subagent")).length; + await client.controller.submit("check the metrics again", "followUp"); + await waitForView( + client.view, + (view) => !isBusy(view.conversation) && view.conversations.filter((summary) => summary.label.startsWith("subagent")).length === count + 1, + ); + const subagent = client.view.current().conversations.filter((summary) => summary.label.startsWith("subagent")).at(-1)!; + await client.controller.switchConversation(subagent.id); + const output = client.view.current().conversation.entries.filter((entry) => entry.kind === "pi.tool-result"); + expect(JSON.stringify(output).includes("| metric | change | where |")).toBe(format === "table"); + } +}); diff --git a/spikes/durable-multiview/test/presentation.test.ts b/spikes/durable-multiview/test/presentation.test.ts index 24e85907..d5704d7e 100644 --- a/spikes/durable-multiview/test/presentation.test.ts +++ b/spikes/durable-multiview/test/presentation.test.ts @@ -1,6 +1,6 @@ -import type { ConversationView, EntryRecord, TaskGraph } from "@earendil-works/pi-durable"; +import type { ConversationId, ConversationView, EntryRecord, TaskGraph } from "@earendil-works/pi-durable"; import { describe, expect, it } from "vitest"; -import { chatItems, queueItems, statusText, taskRows } from "../presentation/chat.ts"; +import { approvalText, chatItems, planItems, queueItems, statusText, taskRows, usageRows } from "../presentation/chat.ts"; let nextId = 1; const entry = (kind: string, message: unknown): EntryRecord => @@ -95,3 +95,37 @@ describe("taskRows", () => { ]); }); }); + +describe("demo presentation", () => { + it("keeps an answer's thinking apart from its text, also while streaming", () => { + const thinking = (value: string) => ({ type: "thinking", thinking: value }); + expect(chatItems(view([assistant([thinking("pool sizes?"), text("rollback")])])).at(-1)).toMatchObject({ text: "rollback", thinking: "pool sizes?" }); + expect(chatItems(view([assistant([text("plain")])])).at(-1)).not.toHaveProperty("thinking"); + const live = { run: {}, generation: { attempt: 1, message: { role: "assistant", content: [thinking("che")] } } }; + expect(chatItems(view([], live)).at(-1)).toMatchObject({ text: "", thinking: "che", streaming: true }); + }); + + it("reads the plan, empty until the agent writes one", () => { + expect(planItems({})).toEqual([]); + expect(planItems({ "demo.plan": null })).toEqual([]); + expect(planItems({ "demo.plan": { items: [{ text: "Search the deploy logs", status: "done" }] } })).toEqual([ + { text: "Search the deploy logs", status: "done" }, + ]); + }); + + it("sums usage per model and tool", () => { + const usage = (input: number, output: number, cost: number) => ({ input, output, cacheRead: 0, cacheWrite: 0, totalTokens: input + output, cost: { total: cost } }); + const conversation = { ...view([]), docs: { "pi.usage": { models: { "faux/faux-1": usage(120, 30, 0.002) }, tools: { search_logs: usage(0, 0, 0) } } } }; + expect(usageRows(conversation as unknown as ConversationView)).toEqual([ + { key: "faux/faux-1", input: 120, output: 30, cost: 0.002 }, + { key: "search_logs", input: 0, output: 0, cost: 0 }, + ]); + expect(usageRows(view([]))).toEqual([]); + }); + + it("says where an approval is waiting", () => { + const approval = { id: "76", conversationId: 4 as ConversationId, tool: "rollback", summary: "Roll back to v2.2: pool regression", requestedAt: 0 }; + expect(approvalText(approval, [{ id: 4 as ConversationId, label: "fork 4" }])).toBe("fork 4: Roll back to v2.2: pool regression"); + expect(approvalText(approval, [])).toBe("conversation 4: Roll back to v2.2: pool regression"); + }); +}); diff --git a/spikes/durable-multiview/test/support.ts b/spikes/durable-multiview/test/support.ts index 5d93061d..138689af 100644 --- a/spikes/durable-multiview/test/support.ts +++ b/spikes/durable-multiview/test/support.ts @@ -82,6 +82,7 @@ export async function startDemoHost( initialModel: { provider: model.provider, modelId: model.id }, port, ...demo.hostOptions, + settings: demo.settings(), }); defer(() => host.close()); return { host, demo }; diff --git a/spikes/durable-multiview/test/tui.test.ts b/spikes/durable-multiview/test/tui.test.ts index f779dc87..f1016151 100644 --- a/spikes/durable-multiview/test/tui.test.ts +++ b/spikes/durable-multiview/test/tui.test.ts @@ -1,6 +1,6 @@ import { expect, it } from "vitest"; import { transcript } from "../protocol/transcript.ts"; -import { connectTo, startFauxHost, useCleanups, waitForView } from "./support.ts"; +import { connectTo, startDemoHost, startFauxHost, useCleanups, waitForView } from "./support.ts"; import { startTui } from "./tui-harness.ts"; const defer = useCleanups(); @@ -24,3 +24,36 @@ it("drives the host from the TUI and shows the answer, then the lost host", asyn await tui.until(() => tui.exited() !== undefined, "exit", 5000).catch(() => {}); expect(tui.exited()).toBe(0); }); + +it("shows the plan and a pending approval, and decides it with /approve", async () => { + const { host } = await startDemoHost(defer); + const web = await connectTo(defer, host, host.token, "web"); + const tui = await startTui(defer, host.url, host.token); + await tui.until((text) => text.includes("faux/faux-1"), "the footer"); + + await web.controller.submit("v2.3 release failed, find out why", "followUp"); + await tui.until((text) => text.includes("[x] Review the commits in v2.3"), "the finished plan"); + await web.controller.submit("roll back to v2.2", "followUp"); + await tui.until((text) => text.includes("Approve rollback? main: Roll back to v2.2"), "the approval"); + tui.pty.write("/approve"); + tui.pty.write("\r"); + + await waitForView(web.view, (view) => view.approvals.length === 0); + await tui.until((text) => text.includes("You approved the rollback."), "the decision"); +}); + +it("remembers an approval shown on connection when another client decides first", async () => { + const { host } = await startDemoHost(defer); + const web = await connectTo(defer, host, host.token, "web"); + await web.controller.submit("roll back to v2.2", "followUp"); + await waitForView(web.view, (view) => view.approvals.length === 1); + const approval = web.view.current().approvals[0]!; + const tui = await startTui(defer, host.url, host.token); + await tui.until((text) => text.includes("Approve rollback?"), "the approval already pending on connection"); + + await web.controller.approve(approval, false); + await waitForView(web.view, (view) => view.approvals.length === 0); + tui.pty.write("/approve\r"); + + await tui.until((text) => text.includes("Already denied by web."), "the earlier decision", 5000); +}); diff --git a/spikes/durable-multiview/test/web-ui.test.ts b/spikes/durable-multiview/test/web-ui.test.ts new file mode 100644 index 00000000..f69a22e9 --- /dev/null +++ b/spikes/durable-multiview/test/web-ui.test.ts @@ -0,0 +1,117 @@ +import { fileURLToPath } from "node:url"; +import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; +import { createModels } from "@earendil-works/pi-ai/models"; +import { fauxAssistantMessage, fauxProvider, fauxThinking, fauxToolCall } from "@earendil-works/pi-ai/providers/faux"; +import { AssistantEntry } from "@earendil-works/pi-durable"; +import { chromium } from "@playwright/test"; +import { createServer } from "vite"; +import { expect, it } from "vitest"; +import { openHost, type OpenedHost } from "../host/host.ts"; +import { chatItems } from "../presentation/chat.ts"; +import { isBusy, transcript } from "../protocol/transcript.ts"; +import { connectTo, freePort, startFauxHost, tempDir, useCleanups, waitForView } from "./support.ts"; + +const defer = useCleanups(); +const webConfig = fileURLToPath(new URL("../web/vite.config.ts", import.meta.url)); + +async function openPage(host: OpenedHost, width: number) { + const vite = await createServer({ configFile: webConfig, server: { port: await freePort() }, logLevel: "error" }); + await vite.listen(); + defer(() => vite.close()); + const browser = await chromium.launch(); + defer(() => browser.close()); + const page = await browser.newPage({ viewport: { width, height: 844 } }); + await page.goto(`${vite.resolvedUrls!.local[0]}#token=${encodeURIComponent(host.token)}&url=${encodeURIComponent(host.url)}`); + await page.getByRole("textbox").waitFor(); + return page; +} + +it("keeps the chat usable on a phone with navigation and live state still reachable", async () => { + const host = await startFauxHost(defer); + const page = await openPage(host, 390); + + const composer = page.getByRole("textbox"); + expect((await composer.boundingBox())!.width).toBeGreaterThan(300); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + + await page.getByRole("button", { name: "Conversations", exact: true }).click(); + await page.getByRole("button", { name: "main", exact: true }).click(); + await expect.poll(() => page.getByRole("dialog").count()).toBe(0); + await page.getByRole("button", { name: "Live state", exact: true }).click(); + await page.getByRole("dialog").getByText("No live tasks").waitFor(); + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "Controls", exact: true }).click(); + await page.getByRole("menuitem", { name: "Compact", exact: true }).waitFor(); +}); + +it("preserves an unsent follow-up and disables write controls while reconnecting", async () => { + const host = await startFauxHost(defer, { answers: ["An answer to fork."] }); + const page = await openPage(host, 1280); + const composer = page.getByRole("textbox"); + await composer.fill("hello"); + await composer.press("Enter"); + await page.getByRole("button", { name: "Fork", exact: true }).waitFor(); + await composer.fill("keep this draft"); + + await host.close(); + await page.getByText("Host connection lost", { exact: true }).waitFor(); + expect(await page.getByRole("button", { name: "Follow-up", exact: true }).isDisabled()).toBe(true); + expect(await page.getByRole("button", { name: "Compact", exact: true }).isDisabled()).toBe(true); + expect(await page.getByRole("button", { name: "Fork", exact: true }).isDisabled()).toBe(true); + expect(await composer.textContent()).toBe("keep this draft"); +}); + +it.each([ + ["thinking-only", fauxAssistantMessage(fauxThinking("Compare the release evidence."))], + ["tool-only", fauxAssistantMessage(fauxToolCall("search_logs", { release: "v2.3" }), { stopReason: "toolUse" })], +])("forks a persisted %s answer from its message action", async (_, message) => { + const host = await startFauxHost(defer, { answers: ["The fork continued."] }); + const root = await host.harness.root(BACKGROUND_CONTEXT); + const source = await root.commit((tx) => tx.appendEntry(AssistantEntry, root.id, { model: [message] }), BACKGROUND_CONTEXT); + const observer = await connectTo(defer, host); + const page = await openPage(host, 1280); + + const fork = page.getByRole("button", { name: "Fork", exact: true }); + await expect.poll(() => fork.count()).toBe(1); + await fork.click(); + await page.getByRole("textbox", { name: "First message", exact: true }).fill("Continue from this evidence."); + await page.getByRole("dialog").getByRole("button", { name: "Fork", exact: true }).click(); + + await waitForView(observer.view, (view) => view.conversations.some((conversation) => conversation.label.startsWith("fork "))); + const branch = observer.view.current().conversations.find((conversation) => conversation.label.startsWith("fork "))!; + await observer.controller.switchConversation(branch.id); + await waitForView(observer.view, (view) => !isBusy(view.conversation) && transcript(view.conversation).at(-1)?.text === "The fork continued."); + const entries = observer.view.current().conversation.entries; + expect(entries.some((entry) => entry.id === source.id)).toBe(true); + expect(transcript(observer.view.current().conversation).at(-2)).toMatchObject({ role: "user", text: "Continue from this evidence." }); +}); + +it("shows interruption without a text bubble and never offers a fork for streaming or aborted thinking", async () => { + const directory = await tempDir(); + defer(directory.remove); + const faux = fauxProvider({ tokensPerSecond: 20, tokenSize: { min: 1, max: 1 } }); + faux.setResponses([fauxAssistantMessage(fauxThinking("Compare the release evidence. ".repeat(100)))]); + const models = createModels(); + models.setProvider(faux.provider); + const model = faux.getModel(); + const host = await openHost({ + dataDir: directory.path, + cwd: directory.path, + models, + modelSummaries: () => [], + initialModel: { provider: model.provider, modelId: model.id }, + port: 0, + }); + defer(() => host.close()); + const observer = await connectTo(defer, host); + const page = await openPage(host, 390); + await page.getByRole("textbox").fill("Investigate the release."); + await page.getByRole("textbox").press("Enter"); + await page.getByText("Thinking…", { exact: true }).waitFor(); + expect(await page.getByRole("button", { name: "Fork", exact: true }).count()).toBe(0); + + await observer.controller.abort(); + await waitForView(observer.view, (view) => chatItems(view.conversation).some((item) => item.kind === "assistant" && item.stopReason === "aborted")); + await expect.poll(() => page.getByText("interrupted", { exact: true }).count()).toBe(1); + expect(await page.getByRole("button", { name: "Fork", exact: true }).count()).toBe(0); +}); diff --git a/spikes/durable-multiview/test/web.test.ts b/spikes/durable-multiview/test/web.test.ts index 97b35f2b..861366cb 100644 --- a/spikes/durable-multiview/test/web.test.ts +++ b/spikes/durable-multiview/test/web.test.ts @@ -3,7 +3,7 @@ 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 { connectTo, freePort, startFauxHost, useCleanups, waitForView } from "./support.ts"; import { startTui } from "./tui-harness.ts"; const defer = useCleanups(); @@ -13,7 +13,7 @@ it("shows the same conversation in the web page and the TUI, driven from either" 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" }); + const vite = await createServer({ configFile: webConfig, server: { port: await freePort() }, logLevel: "error" }); await vite.listen(); defer(() => vite.close()); const browser = await chromium.launch(); diff --git a/spikes/durable-multiview/tui/main.ts b/spikes/durable-multiview/tui/main.ts index 2abf3682..ce97b3aa 100644 --- a/spikes/durable-multiview/tui/main.ts +++ b/spikes/durable-multiview/tui/main.ts @@ -9,7 +9,7 @@ import { hostAddress } from "../cli/host-address.ts"; import { connectRemoteDurable } from "../protocol/remote-durable.ts"; import { runDurableTui } from "./tui.ts"; -const remote = await connectRemoteDurable(await hostAddress(process.argv.slice(2))); +const remote = await connectRemoteDurable({ ...(await hostAddress(process.argv.slice(2))), clientName: "tui" }); // As in pi's durable TUI, the task panel starts open; /tasks hides it. await remote.controller.toggleTasks(); try { diff --git a/spikes/durable-multiview/tui/tui.ts b/spikes/durable-multiview/tui/tui.ts index fcd09a5e..691f3f35 100644 --- a/spikes/durable-multiview/tui/tui.ts +++ b/spikes/durable-multiview/tui/tui.ts @@ -1,6 +1,7 @@ // Ported from pi's packages/coding-agent/src/experimental/durable/tui.ts at // earendil-works/pi@7fbbd5f (MIT, Earendil Works). Kept in upstream's formatting so -// a diff against it shows only the port: imports, and the host connection notice. +// a diff against it shows only the port: imports, the host connection notice, and +// the on-call demo's plan and approvals (`/approve`, `/deny`). import type { AssistantMessage, ToolResultMessage, Usage, UserMessage } from "@earendil-works/pi-ai"; import { AssistantMessageComponent, @@ -47,6 +48,8 @@ import { TuiAltScreen, VStack, } from "@earendil-works/pi-tui"; +import { approvalText, planItems } from "../presentation/chat.ts"; +import type { PendingApproval, PlanItem } from "../protocol/demo.ts"; import type { DurableController, DurableView, DurableViewSource } from "../protocol/view.ts"; import { createAllToolRenderers, @@ -173,6 +176,7 @@ class DurableTui { static readonly #renderers: Record = createAllToolRenderers(); readonly #ui: TuiAltScreen; readonly #chat = new Container(); + readonly #plan = new Container(); readonly #tasks = new Container(); readonly #queue = new Container(); readonly #notices = new Container(); @@ -236,6 +240,7 @@ class DurableTui { const transcript = new ScrollView(content, { follow: "end", primary: true, overscroll: "chain" }); this.#transcript = transcript; const dock = new VStack([ + { component: this.#plan, shrink: 1, minSize: 0 }, { component: this.#tasks, shrink: 1, minSize: 0 }, { component: this.#queue, shrink: 1, minSize: 0 }, { component: this.#notices, shrink: 1, minSize: 0 }, @@ -244,6 +249,7 @@ class DurableTui { ]); for (const component of [ this.#chat, + this.#plan, this.#tasks, this.#queue, this.#notices, @@ -318,6 +324,7 @@ class DurableTui { ); } } + this.#syncPlan(planItems(view.docs)); this.#syncTasks(view.tasks); this.#syncQueue((view.conversation.docs["pi.inbox"] ?? { items: [] }) as InboxState); this.#syncNotices(view); @@ -329,6 +336,16 @@ class DurableTui { this.#rebuilt = false; } + #syncPlan(items: readonly PlanItem[]): void { + this.#plan.clear(); + if (items.length === 0) return; + this.#plan.addChild(new TruncatedText(theme.fg("accent", "Plan"), 1, 0)); + for (const item of items) { + const mark = item.status === "done" ? "[x]" : item.status === "doing" ? "[~]" : "[ ]"; + this.#plan.addChild(new TruncatedText(theme.fg("muted", ` ${mark} ${item.text}`), 1, 0)); + } + } + #syncTasks(graph: TaskGraph | undefined): void { this.#tasks.clear(); if (graph === undefined) return; @@ -369,6 +386,10 @@ class DurableTui { } else if (view.connection === "closed") { this.#notices.addChild(new TruncatedText(theme.fg("error", "Disconnected from the host."), 1, 0)); } + for (const approval of view.approvals) { + const text = `Approve ${approval.tool}? ${approvalText(approval, view.conversations)} (/approve or /deny)`; + this.#notices.addChild(new TruncatedText(theme.fg("warning", text), 1, 0)); + } for (const item of view.notices.slice(-4)) { const color = item.level === "error" ? "error" : item.level === "warning" ? "warning" : "muted"; this.#notices.addChild(new TruncatedText(theme.fg(color, item.message), 1, 0)); @@ -650,6 +671,12 @@ export async function runDurableTui( if (trimmed === "/model") return selectModel(); if (trimmed === "/tasks") return void controller.toggleTasks(); if (trimmed === "/agents") return selectConversation(); + if (trimmed === "/approve" || trimmed === "/deny") { + // One typed after another client decided still goes to the host, which says who did. + const approval = source.current().approvals[0] ?? lastApproval; + if (approval !== undefined) void controller.approve(approval, trimmed === "/approve"); + return; + } if (trimmed === "/compact" || trimmed.startsWith("/compact ")) { const instructions = trimmed.slice("/compact".length).trim(); return void controller.compact(instructions || undefined); @@ -669,7 +696,11 @@ export async function runDurableTui( showError: (message) => console.error(message), onChanged: () => view.ui.requestRender(), }); - const unsubscribe = source.subscribe(() => view.apply(source.current())); + let lastApproval: PendingApproval | undefined = source.current().approvals[0]; + const unsubscribe = source.subscribe(() => { + lastApproval = source.current().approvals[0] ?? lastApproval; + view.apply(source.current()); + }); view.start(); themes.applyFromSettings(); view.apply(source.current()); diff --git a/spikes/durable-multiview/web/App.tsx b/spikes/durable-multiview/web/App.tsx index 36bf2467..bf6ab9e2 100644 --- a/spikes/durable-multiview/web/App.tsx +++ b/spikes/durable-multiview/web/App.tsx @@ -9,25 +9,35 @@ import { ChatSystemMessage, ChatToolCalls, } from "@astryxdesign/core/Chat"; +import { Collapsible } from "@astryxdesign/core/Collapsible"; +import { Dialog, DialogHeader } from "@astryxdesign/core/Dialog"; import { DropdownMenu } from "@astryxdesign/core/DropdownMenu"; import { EmptyState } from "@astryxdesign/core/EmptyState"; -import { Layout, LayoutContent, LayoutPanel, VStack } from "@astryxdesign/core/Layout"; +import { useMediaQuery } from "@astryxdesign/core/hooks"; +import { HStack, Layout, LayoutContent, LayoutFooter, LayoutPanel, VStack } from "@astryxdesign/core/Layout"; import { List, ListItem } from "@astryxdesign/core/List"; import { Markdown } from "@astryxdesign/core/Markdown"; +import { MobileNav } from "@astryxdesign/core/MobileNav"; import { SideNav, SideNavHeading, SideNavItem, SideNavSection } from "@astryxdesign/core/SideNav"; import { StatusDot } from "@astryxdesign/core/StatusDot"; import { Text } from "@astryxdesign/core/Text"; +import { TextInput } from "@astryxdesign/core/TextInput"; 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 { approvalText, type ChatItem, chatItems, planItems, queueItems, statusText, taskRows, usageRows } from "../presentation/chat.ts"; import type { RemoteDurable, RemoteDurableOptions } 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 page: CSSProperties = { + height: "100dvh", + width: "100%", + backgroundColor: "var(--color-background-body)", + color: "var(--color-text-primary)", +}; const chatColumn: CSSProperties = { flex: 1, minHeight: 0 }; export function App() { @@ -51,7 +61,7 @@ export function Centered({ children }: { children: ReactNode }) { } function Connected({ address }: { address: { url: string; token: string } }) { - return ; + return ; } /** Connects with `options` and shows the workbench, or why it cannot; shared by this page and Pace's dev page. */ @@ -80,24 +90,36 @@ function Workbench({ remote }: { remote: RemoteDurable }) { const view = useDurableView(remote); const items = useMemo(() => chatItems(view.conversation), [view.conversation]); const busy = isBusy(view.conversation); + // Below this the chat column would be squeezed; the live state moves into a dialog. + const narrow = useMediaQuery("(max-width: 1023px)"); + const [navOpen, setNavOpen] = useState(false); + const [panelOpen, setPanelOpen] = useState(false); + const [forkAt, setForkAt] = useState(); return ( } + start={narrow ? undefined : } content={ + {narrow ? ( + +