Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion .scratch/durable-multiview/HANDOFF.md
Original file line number Diff line number Diff line change
@@ -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)
- 目的:把上一个会话里已核实的事实、已定的决策和待决问题交给新会话,避免重查。

Expand Down Expand Up @@ -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 按钮。
10 changes: 10 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions spikes/durable-multiview/host/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion spikes/durable-multiview/host/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ async function piOptions(): Promise<OpenHostOptions> {
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 => {
Expand Down
21 changes: 16 additions & 5 deletions spikes/durable-multiview/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
184 changes: 184 additions & 0 deletions spikes/durable-multiview/presentation/chat.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>);
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<string, { item: number; tool: number }>();
const updateCall = (callId: string, patch: Partial<ToolCallView>): void => {
const at = calls.get(callId);
if (at === undefined) return;
const item = items[at.item] as Extract<ChatItem, { kind: "assistant" }>;
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);
Comment on lines +104 to +115

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Streaming tools lose live progress

When a streaming answer calls a tool, chatItems adds its pending card after applying live tool updates. The running status, output and subagent ID disappear until the tool finishes.

Learn more

The live document contains running tool slots and the current partial assistant message. The map of tool cards is populated by addAssistant, but the partial assistant message is appended only after the running slots are applied. A new call that exists only in the partial cannot be found by updateCall; even when a committed call shares the ID, the later partial replaces the map entry with a pending card. The TUI rendering order handles the streaming message before running slots.

Example: A partial assistant message calls logs with ID c1, while pi.live.tools holds { callId: 'c1', status: 'running', output: 'fetching' }. The web card remains pending with no output; the TUI shows it running and displays fetching.

Recommended fix: Add the partial assistant with addAssistant before iterating live.tools, then patch the current call cards. Add a presentation test with a streaming tool call and a matching running slot.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

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;
}
15 changes: 15 additions & 0 deletions spikes/durable-multiview/test/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading