Skip to content

feat(spike): web client for the durable host (P2) - #429

Merged
bubbleptr merged 1 commit into
feat/durable-multiview-tuifrom
feat/durable-multiview-web
Oct 2, 2026
Merged

bubbleptr merged 1 commit into
feat/durable-multiview-tuifrom
feat/durable-multiview-web

Conversation

@bubbleptr

@bubbleptr bubbleptr commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

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 through RemoteDurable, side by side with the TUI. Run it with bun run web on 127.0.0.1:5199.
    • SideNav: the conversation list and a connection status dot.
    • ChatLayout: the transcript, with Markdown, tool calls and an interrupted token 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.
    • End panel: the live task tree, the queue and notices.
    • A Banner shows while the host is lost; the composer is disabled until it comes back.
  • The token travels in the URL fragment, so it never reaches the dev server. The host's ready line now includes the full web link.
  • presentation/chat.ts: browser-safe derivations from ConversationView to 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.json uses Bundler resolution. Astryx's .d.ts files re-export with extensionless relative paths, and under NodeNext its types degrade. The spike's typecheck script checks both configs. The Vite config pins one react copy, as Pace's renderer config does.
  • Fix: a restarted gateway now titles main in the conversation list, as the commit listener already did while the host was running. Before this, main lost its title after a restart. Covered by a test.

Test plan

  • bun run typecheck
  • bun run lint
  • bun run test: 167 files, 1970 tests (spike excluded)
  • bun run build
  • Spike bun run typecheck (Node and web configs) and bun 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.
  • Screenshots checked: empty, streaming (status, task panel, stop button), a queued follow-up, both turns answered, host lost (banner, status dot, disabled composer), and kill -9 mid-stream then restart (interrupted partial followed by the full answer).
  • Playwright E2E for the app not run (no app change)

Checklist

  • Linked issue or a short reason this has none
  • Title follows Conventional Commits
  • bun run typecheck, bun run test, and bun run build are green
  • ADR added or updated if an architectural boundary or product term changed (none changed)
  • CONTEXT.md updated if vocabulary changed (none changed)
  • New or changed shared/ui/ components are registered on /design (none; the spike page uses Astryx directly and lives outside apps/desktop)
  • Branch is feat/; this is not a push to main

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.
@bubbleptr
bubbleptr added this pull request to stack #428 October 2, 2026 10:24
@bubbleptr
bubbleptr marked this pull request as ready for review October 2, 2026 11:39

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 4 potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment on lines +104 to +115
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);

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.

Comment on lines +214 to +215
isStopShown={busy}
onStop={() => void remote.controller.abort()}

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.

🔴 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.

Suggested change
isStopShown={busy}
onStop={() => void remote.controller.abort()}
isStopShown={busy && value.trim() === ""}
onStop={() => void remote.controller.abort()}

Devin Review


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

Comment on lines +243 to +247
isDisabled={value.trim() === ""}
onClick={() => {
void remote.controller.submit(value.trim(), "followUp");
setValue("");
}}

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.

🟡 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.

Devin Review


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

Comment on lines +196 to +197
case "compaction":
return <ChatSystemMessage variant="divider">Earlier context summarized</ChatSystemMessage>;

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.

🟡 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.

Devin Review


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

@bubbleptr
bubbleptr merged commit 7bfcc16 into main Oct 2, 2026
2 checks passed
@bubbleptr
bubbleptr deleted the feat/durable-multiview-web branch October 2, 2026 14:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant