diff --git a/crates/daemon/assets/index.html b/crates/daemon/assets/index.html index d105de95..fd8f3de8 100644 --- a/crates/daemon/assets/index.html +++ b/crates/daemon/assets/index.html @@ -11389,12 +11389,6 @@
} } -/** - * Rebuild the pane DOM from the shared tree. - * - * Cheap to call: the expensive things (xterm instances, transcript panes) are - * cached per session and only re-parented here, never recreated. - */ function paneHeadButton(glyph, tip, onClick) { const btn = document.createElement("button"); btn.type = "button"; @@ -11480,7 +11474,83 @@ return btn; } +/** + * Everything the grid renders out of shared state, flattened to a string. + * + * `renderSessions` runs on every `session/state` delta — a background + * worker's status tick, a token count, a spinner frame — and each rebuild + * re-parents the focused pane's `#viewStack`. Comparing this against the + * last render lets the session-list path skip the rebuild when nothing the + * grid actually draws has moved. + */ +function paneGridSignature() { + if (!splitLayoutActive()) return "single"; + const parts = [`f:${state.focusedPaneId}`, state.paneZoom ? "zoom" : "-"]; + const walk = (node) => { + if (!node) { + parts.push("nil"); + return; + } + if (node.kind !== "leaf") { + parts.push(`split:${node.direction}:${node.ratio_percent || 50}`); + walk(node.first); + walk(node.second); + return; + } + const service = node.service_name + ? state.services.find((item) => item.name === node.service_name) + : null; + const session = node.session_id + ? state.sessions.find((s) => s.id === node.session_id) + : null; + parts.push( + `leaf:${node.id}:${node.session_id || ""}:${node.service_name || ""}`, + // Pane title + which surface the mirror mounts are the only + // session-derived things a pane draws. + session + ? `${sessionViewTitle(session)}|${preferredViewModeForSession(session)}` + : "-", + service + ? `${service.routing || ""}|${service.harness || ""}|${service.paused ? "paused" : "-"}` + : "-", + ); + }; + walk(state.layout.tree); + return parts.join(""); +} + +let paneGridRenderedSignature = null; + +/** + * Rebuild the grid, keeping the caret where the user left it. + * + * The rebuild moves `#viewStack` — and with it the focused xterm's helper + * textarea — into a freshly built pane element. Detaching the node that holds + * `document.activeElement` blurs it, and re-attaching does not bring focus + * back, so without this the caret silently dies mid-keystroke every time + * anything repaints the grid. Restoring synchronously means no keystroke can + * land in the gap, and it leaves later deliberate focus moves (a pane click + * that selects another session) with the last word. + */ function renderPaneGrid() { + const typing = captureTypingFocus(); + try { + renderPaneGridNow(); + // Only a render that actually completed may satisfy a later skip. + paneGridRenderedSignature = paneGridSignature(); + } finally { + restoreTypingFocus(typing); + } +} + +/** + * Rebuild the pane DOM from the shared tree. + * + * The expensive things (xterm instances, transcript panes) are cached per + * session and only re-parented here, never recreated — but re-parenting is + * not free either. Go through `renderPaneGrid`, never straight to this. + */ +function renderPaneGridNow() { if (!splitLayoutActive()) { // Single-session mode — the pre-split layout, verbatim. Any mirrored // terminal hosts go home so the normal show/hide logic owns them again. @@ -12021,7 +12091,14 @@ paneGridRefreshQueued = true; requestAnimationFrame(() => { paneGridRefreshQueued = false; - if (splitLayoutActive()) renderPaneGrid(); + if (!splitLayoutActive()) return; + // A busy fleet pushes `session/state` several times a second, and this + // path fires on every one of them. Rebuilding for a status tick that + // changes nothing the grid draws churns the terminal re-parent, refit and + // caret restore for no reason — and an IME composition does not survive + // that. Only rebuild when a pane's own content moved. + if (paneGridSignature() === paneGridRenderedSignature) return; + renderPaneGrid(); }); } diff --git a/specs/0184-a-repaint-never-takes-the-caret.md b/specs/0184-a-repaint-never-takes-the-caret.md new file mode 100644 index 00000000..2a6a4da5 --- /dev/null +++ b/specs/0184-a-repaint-never-takes-the-caret.md @@ -0,0 +1,86 @@ +# 0184-a-repaint-never-takes-the-caret + +Status: accepted +Date: 2026-08-02 +Area: webui +Scope: A client repainting itself because of fleet activity must never move the user's typing caret, and must not repaint at all when nothing it draws has changed. + +## Decision + +Repainting is a consequence of *other people's* activity. Typing is the user's. +The two must not collide. + +Two rules, and a client that renders shared state must honor both: + +1. **A repaint restores the caret it displaces.** If a repaint relocates the + DOM subtree that holds the focused element, it puts focus back before + yielding — synchronously, in the same task, so no keystroke can land in the + gap. This covers whichever surface the user was typing into: composer, + playbook editor, or terminal. A terminal needs its own terminal API to be + refocused; focusing the underlying textarea alone does not restore keyboard + and IME state. + +2. **A repaint driven by ambient state is conditional on that state mattering.** + Before rebuilding, a client compares what it is about to draw against what it + drew last time and skips the rebuild when they agree. Deliberate user actions + — splitting, closing, moving focus, resizing — repaint unconditionally. + +Rule 1 without rule 2 is not enough. Restoring focus is not free: it interrupts +an in-progress IME composition, which means a repaint arriving mid-syllable +still eats input for anyone typing a composed script. Rule 2 is what keeps the +repaint from happening in the first place. + +## Reason + +Fleet activity is continuous and unrelated to what the user is doing. A single +busy session pushes state several times a second — status transitions, token +counts, spinner frames — and every one of them reaches every connected client. + +A layout that mounts one live surface and re-parents it into whichever pane +holds focus makes that traffic dangerous: moving a node out of the document +blurs whatever inside it had focus, and putting it back does not return focus. +So a background worker's status tick silently kills the caret of a user typing +in a completely different pane. The symptom is not read as a bug in the client — +it is read as the app being broken, because the user is mid-sentence and their +keystrokes stop arriving with nothing on screen to explain why. + +The cost of the rules is a comparison per repaint and one focus snapshot. That +is much cheaper than the repaint being skipped, and far cheaper than the +re-parent, refit and reflow the skip avoids. + +## Consequences + +- Any state a pane draws must be part of what the conditional repaint compares. + Adding a session-derived detail to a pane without adding it there produces a + pane that silently stops updating — the failure mode this rule trades for. +- Content that arrives asynchronously (a transcript that finishes loading, a + terminal library that finishes downloading) repaints by asking for one + directly, not by waiting for ambient traffic to trigger it. +- Focus stays per-client and is never written to or read from the daemon; this + rule is about not *losing* focus, and does not weaken + [0118-split-layout-is-shared-daemon-state](0118-split-layout-is-shared-daemon-state.md). +- Restoration must not outlive the repaint. Re-asserting the old caret on a + later frame would fight deliberate focus moves — clicking a pane to select + another session must still end with focus in that pane. + +## Non-Goals + +- Eliminating re-parenting. One live surface moved between panes is what lets + the composer, editor and widget machinery stay singletons; this rule makes + that design safe rather than replacing it. +- Preserving selection, scroll position, or hover across a repaint. Only the + typing caret is protected. +- Applying the conditional repaint to user-initiated layout changes, which must + always be immediate. + +## Examples + +- A user is typing a prompt in the left pane while an agent in the right pane + streams output. The caret stays in the left pane; no keystroke is dropped. +- A background session the user cannot even see finishes its turn. The session + list updates; the panes are not rebuilt, because no pane's title or surface + changed. +- That same session is showing in a pane and its title changes as it finishes. + The pane is rebuilt, and a user typing in another pane keeps the caret. +- A user clicks an unfocused pane while typing in the focused one. Focus ends in + the clicked pane, not back where it started.