Repository navigation
feat(spike): web client for the durable host (P2) - #429
Conversation
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.
There was a problem hiding this comment.
Devin Review found 4 potential issues.
2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)
| 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); |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
| isStopShown={busy} | ||
| onStop={() => void remote.controller.abort()} |
There was a problem hiding this comment.
🔴 Typed steer prompts trigger stop instead
When a user types during a run, isStopShown still replaces the send button with Stop. Clicking it aborts the run instead of sending the steer prompt.
Learn more
Astryx's composer replaces its primary send action with Stop when isStopShown is true. Enter still submits a steer prompt, but the button does not. The desktop composer hides Stop as soon as the input contains text.
Example: An agent is working and the user types check the logs first. Enter steers it, but clicking the visible Stop button cancels the work and leaves the prompt unsent.
Recommended fix: Show Stop only when busy and the input is empty. Keep the send action when there is a typed prompt.
| isStopShown={busy} | |
| onStop={() => void remote.controller.abort()} | |
| isStopShown={busy && value.trim() === ""} | |
| onStop={() => void remote.controller.abort()} |
Was this helpful? React with 👍 or 👎 to provide feedback.
| isDisabled={value.trim() === ""} | ||
| onClick={() => { | ||
| void remote.controller.submit(value.trim(), "followUp"); | ||
| setValue(""); | ||
| }} |
There was a problem hiding this comment.
🟡 Disconnected follow-up discards the draft
If the host disconnects with a draft present, Follow-up remains enabled and clears it. The controller rejects the submit, so the draft is lost.
Learn more
The web view keeps its last conversation and the composer's local draft when the host connection drops. The custom Follow-up button is disabled only for an empty draft, even though remote.controller.submit rejects commands while disconnected. Its click handler clears the draft immediately regardless of the command result. The controller also turns the rejection into a notice, so the caller cannot use this promise to infer delivery without adjusting that contract.
Example: A user types summarize the logs, loses the host connection, then clicks Follow-up. The input clears and no follow-up reaches the host after reconnection.
Recommended fix: Disable Follow-up when view.connection !== 'connected' and avoid clearing text on a failed submission. Apply the same disconnected guard to the other control buttons if needed.
Was this helpful? React with 👍 or 👎 to provide feedback.
| case "compaction": | ||
| return <ChatSystemMessage variant="divider">Earlier context summarized</ChatSystemMessage>; |
There was a problem hiding this comment.
🟡 Compaction summary cannot be read
After compaction, ChatRow displays a generic divider and drops item.summary. The retained context is inaccessible in the web transcript, unlike the TUI.
Learn more
A compaction entry carries a summary of earlier conversation context. chatItems extracts that summary into a ChatItem, but this renderer replaces it with an unexpandable static divider. The TUI compaction component makes the same summary available when expanded.
Example: A completed compaction retains Release v2.3 failed because migration 42 timed out. The web transcript shows only Earlier context summarized; its user cannot inspect that retained fact.
Recommended fix: Render item.summary in an expandable area or a detail view attached to the divider so the compacted context remains inspectable.
Was this helpful? React with 👍 or 👎 to provide feedback.
No linked issue: third stage (P2) of the Pi Durable multi-client spike (
.scratch/durable-multiview/HANDOFF.md), stacked on #427. Spike-only; no Pace app code changes.Summary
web/: a Vite + React + Astryx page (neutral theme, as in Pace) that renders the host throughRemoteDurable, side by side with the TUI. Run it withbun run webon127.0.0.1:5199.SideNav: the conversation list and a connection status dot.ChatLayout: the transcript, with Markdown, tool calls and aninterruptedtoken on aborted partials. In the composer, Enter prompts when idle and steers when busy, as in the TUI. There is also a Follow-up button, a stop button while busy, and model, thinking-level and compact controls.Bannershows while the host is lost; the composer is disabled until it comes back.weblink.presentation/chat.ts: browser-safe derivations fromConversationViewto UI items, ported from the upstream TUI so every client tells the same story: chat items, status text, queue and task tree. The Pace page in P3 can reuse it.web/tsconfig.jsonusesBundlerresolution. Astryx's.d.tsfiles re-export with extensionless relative paths, and underNodeNextits types degrade. The spike'stypecheckscript checks both configs. The Vite config pins onereactcopy, as Pace's renderer config does.mainin the conversation list, as the commit listener already did while the host was running. Before this,mainlost its title after a restart. Covered by a test.Test plan
bun run typecheckbun run lintbun run test: 167 files, 1970 tests (spike excluded)bun run buildbun run typecheck(Node and web configs) andbun run test: 23 tests.web.test.ts: a Vite dev server, Playwright Chromium and the TUI in a pty connect to one faux host. The web prompt shows up in the TUI with its answer, and the TUI prompt shows up in the web with its answer. An observer's transcript is exactly those four lines. When the host goes away, the page shows the reconnect banner. There are no page errors. It passed 3 more consecutive runs.kill -9mid-stream then restart (interruptedpartial followed by the full answer).Checklist
bun run typecheck,bun run test, andbun run buildare greenCONTEXT.mdupdated if vocabulary changed (none changed)shared/ui/components are registered on/design(none; the spike page uses Astryx directly and lives outsideapps/desktop)feat/; this is not a push tomain