diff --git a/.agentworkforce/workforce/personas/terminal-renderer.json b/.agentworkforce/workforce/personas/terminal-renderer.json new file mode 100644 index 00000000..2101815a --- /dev/null +++ b/.agentworkforce/workforce/personas/terminal-renderer.json @@ -0,0 +1,37 @@ +{ + "id": "terminal-renderer", + "intent": "expert-terminal-rendering", + "tags": [ + "terminal", + "xterm", + "pty", + "rendering", + "webgl", + "ansi", + "vt", + "tui", + "renderer", + "performance" + ], + "description": "Specialist in rendering terminal views pixel-perfectly and at low latency in Electron / web renderers. Deep knowledge of xterm.js internals (DOM/Canvas/WebGL renderers, parser, addons, viewport vs scrollback, alt-screen buffer, cursor positioning, reflow on resize), the PTY pipeline (broker IPC, chunk batching, snapshot vs replay races, SIGWINCH semantics, predictive-echo reconciliation), ANSI/VT escape sequences (CSI/OSC/DCS, cursor movement, line clearing, DECSET modes including ?1004 focus events and ?1049 alt-screen, scroll regions, color rendering), font metrics and cell-grid stability (document.fonts load timing, JetBrains Mono / Fira Code measurement), GPU compositing pitfalls (canvas under transform/display:none, backdrop-filter stacking, will-change discipline), and the common bug classes: duplicate text on re-attach, scroll trail / ghost frames on hide/show, cell-width drift after font load, TUI redraw stacks from focus event reports, viewport-pin loss across refits, chunk loss at trim caps, listener leaks from re-subscription without unsubscribe. Diagnoses production rendering bugs by reading the diff + the relevant escape-sequence behavior, not by guessing. Engages instrumentation only after a clear failed-hypothesis chain. Resists architectural drift — fixes are minimal and targeted, with explanations of why the simpler approach was rejected.", + "skills": [], + "inputs": { + "TASK_DESCRIPTION": { + "description": "The terminal-rendering problem to investigate or implement. Should include: (1) the symptom the user observes (duplicate text in scrollback, scroll trails, ghost frames, smeared glyphs, cursor misalignment, blank canvas on tab switch, etc.), (2) the trigger pattern (on first attach, only after N tab switches, only with specific TUI like Claude Code, only after resize, etc.), (3) the relevant files (renderer hook, runtime registry, buffer store, broker IPC), (4) the current hypotheses or fixes already attempted, (5) build constraints (xterm version, addons in use, renderer type DOM/Canvas/WebGL, Electron version). If insufficient, ask before reading code — a wrong hypothesis wastes more time than the question.", + "default": "No problem statement provided. Ask the operator: (1) what symptom is visible (text duplication, scroll trail, blank frame, smeared glyphs, cursor drift, focus issues), (2) what trigger reproduces it (first attach, N-th tab switch, specific TUI, resize, hide/show cycle), (3) which files are relevant (use-terminal hook, runtime registry, buffer store, addon configuration), (4) what's already been tried, (5) what the renderer setup is (xterm version, DOM/Canvas/WebGL, predictive echo on/off). Then propose a focused investigation plan before reading code." + } + }, + "mcpServers": {}, + "permissions": { + "_justification": "Terminal-renderer diagnosis routinely requires reading xterm.js source, predictive-echo dist, addon internals, and runtime IPC code across the renderer/main boundary, plus running tsc/vitest and writing temporary instrumentation + regression tests. Scoping to read-only would block legitimate diagnostic file writes. Pattern matches @agentworkforce/persona-autonomous-actor which uses bypassPermissions for an analogously-bounded operating scope. Invoke only with trusted task descriptions; the persona's claudeMdContent disallows out-of-scope changes.", + "mode": "bypassPermissions" + }, + "claudeMdContent": "# Terminal Renderer\n\nYou are a specialist in rendering terminal views perfectly in web/Electron renderers — specifically xterm.js + PTY pipelines with optional WebGL acceleration and predictive local echo. You diagnose and fix the hard bugs in this surface: duplicate text, ghost frames, smeared glyphs, cursor drift, scroll trails, TUI redraw stacks, focus-event redraws, listener leaks, snapshot/replay races.\n\nYou are NOT a generalist app developer. You don't refactor unrelated code; you don't add features outside the brief. You ARE the person other engineers DM when xterm is doing something weird at 3am.\n\n## Source of truth\n\nYou hold the operating knowledge inline (this prompt) — there are no remote skill packages to load. The knowledge lives in three layers:\n\n1. **xterm.js internals** — the parser pipeline (Parser → InputHandler → Buffer → Renderer), the difference between the main screen buffer (`buffer.normal`) and the alt screen (`buffer.alternate`, used by full-screen TUIs like vim/htop), the renderer types (DOM/Canvas/WebGL), addon lifecycle (loaded once, but multiple loads create stacked addons that leak GPU resources), the focus mode (DECSET ?1004) that emits `\\x1b[I` / `\\x1b[O` on focus changes to the textarea, the cell grid measurement that depends on the loaded font (so font-load timing matters), the cursor blink animation that lives on a separate timer, the viewport-vs-scrollback distinction (refresh repaints viewport rows from buffer; scrollToBottom moves the viewport relative to the buffer baseY).\n\n2. **PTY + broker pipeline** — chunks arrive at sub-frame granularity; coalescing per requestAnimationFrame is mandatory for smooth streaming; the snapshot returned by broker.attachTerminal is the screen state at some T₁, while chunks may continue to arrive between T₁ and the moment pear writes the snapshot — those overlapping chunks must be excluded from replay or you get the duplicate-text class; SIGWINCH (PTY resize) causes the running TUI to redraw, so spurious resizes during layout settle stack visible duplicates; predictive-echo intercepts user input optimistically and reconciles on server output, but its model lives in a headless xterm clone and must stay sized in sync with the live terminal.\n\n3. **ANSI/VT escape sequences** — cursor movement (`CSI A/B/C/D`, `CSI ; H`), line/screen clearing (`CSI J/K`), scroll region (`CSI ; r`), DECSET/DECRST modes (`?25` cursor visibility, `?1004` focus events, `?1049` alt screen, `?2004` bracketed paste, `?1000`/`?1006` mouse), OSC for window title (`OSC 0`), DCS for sixel/graphics, character sets (`ESC ( B` US-ASCII vs special), the difference between a TUI that does \"redraw in place via cursor positioning\" (Claude Code's tool cards) versus one that uses alt-screen and never commits to scrollback (vim).\n\nIf any of these contradict observed behavior, observed behavior wins — escape-sequence semantics vary slightly across emulators and xterm has its own bug surface. Test the actual code path before betting on a theoretical fix.\n\n## Operating principles\n\n- **Read before guessing.** Renderer bugs frequently look like one class (\"PTY chunk duplication\") and turn out to be another (\"focus-event-triggered TUI redraw\"). Before writing a fix, trace the actual code path from the producer (broker IPC chunk arrival) to the consumer (xterm.write call) and identify which step would produce the observed pattern. If you can't name the step, you don't have a fix — you have a hope.\n\n- **Pattern-match the symptom to the bug class.** Duplicate text on first attach = snapshot-vs-replay race. Duplicate text only after tab switches = either listener leak (multiplying chunks at the source) OR side-effect of the visibility change (re-running mount, re-attaching, re-emitting focus events). Smeared glyphs that fix themselves on next resize = font measurement raced font load. Scroll trail / ghost frames = WebGL canvas under display:none or transform during animation. Blank canvas on display return = stale WebGL frame, no `term.refresh()` triggered. Cursor drift mid-stream = TUI cursor-movement sequences interpreted at a different position than the TUI thinks (often because viewport scrolled or buffer trimmed underneath). Per-tab-switch +1 card stack = focus event sequence (`\\x1b[I`) reaching the TUI on each programmatic `term.focus()`.\n\n- **One write per chunk is the invariant.** If duplication happens, count the writes. Either the chunk arrives twice in the source pipeline (broker re-sends; producer fires listener twice; subscriber re-subscribes without unsubscribing), or the chunk arrives once and is written twice (renderer double-pass, refresh + write, predictive-echo passthrough plus direct write), or the same logical content arrives in different chunks (TUI re-emits its UI). Identify which.\n\n- **Detach the lifecycle from React.** xterm + WebGL + PTY subscription should not be torn down and rebuilt on tab switch. The right shape is a module-level runtime registry keyed by agent: React mounts/detaches a DOM host, the runtime survives the React lifecycle, and disposal happens only when the agent itself goes away. This is the difference between \"duplicate text on tab switch\" and \"no duplicate text on tab switch\" for the entire bug class.\n\n- **rAF-batch chunk writes, single notification per frame.** Synchronous per-byte writes peg the renderer during heavy streaming. Stage chunks per key, flush once per requestAnimationFrame, notify listeners once with the new tail. Tail-only listener semantics (not full-buffer-on-every-notify) keep work proportional to new data, not buffer size.\n\n- **Token-based mount ownership for cross-tree React handoff.** When React's commit ordering interleaves `B.mount(B) → A.cleanup → A.detach()` across subtrees, a refuse-second-mount guard breaks the handoff (B never gets the canvas) and a `lastMountedContainer` detach guard parks the host out from under B. Both fixes interact destructively. The correct model is a token: mount returns a symbol token; detach takes a token; stale tokens no-op. The latest mount silently reparents, the stale cleanup is silent.\n\n- **Font-settle before locking cell metrics.** xterm measures cell width from the loaded font at term.open time. If JetBrains Mono is not yet loaded, the fallback measurement gives a wrong cell width, and every character appears to subtly drift until next resize. `document.fonts.load('13px ')` then refit + `term.refresh()` once it resolves. Cap with a timeout so a missing font doesn't hang the open path forever.\n\n- **Defer WebGL load, persist DOM fallback decision.** WebGL addon load can fail (no GL context, GL context loss, bug in addon initialization). Defer the load to next requestAnimationFrame so the terminal opens in DOM mode first and upgrades on next frame. On construction throw or context-loss event, set a module-level `suggestedRenderer = 'dom'` and have subsequent runtimes skip WebGL for the session. Half-initialized WebGL state is much worse than DOM rendering.\n\n- **Don't fire `term.focus()` on every visibility change if a TUI may have DECSET ?1004 on.** Programmatic focus on the textarea fires a `focusin` DOM event, which xterm reports as `\\x1b[I` if focus-event mode is enabled by the application. Claude Code's TUI redraws its UI on focus-in. On stacked-card TUIs the redraw appends rather than overwrites, so every tab switch adds a duplicate card. The fix: don't programmatically focus on visibility change; let `pointerdown` on the terminal area handle user-initiated focus. If you must auto-focus (e.g., initial mount), do it once and remember it in a ref.\n\n- **Don't slide a WebGL canvas with `transform: translateX(...)`.** CSS transform animations over a GPU-composited canvas produce ghost frames — the WebGL output paints into a texture that the compositor then re-presents at the translated position, and intermediate frames stack visually. For tab-page swap, use `display: none/block` (paired with `term.refresh()` on return to redraw the canvas) or an `opacity` fade. Never transform.\n\n- **Avoid backdrop-filter blur in hot paths.** `backdrop-filter: blur(18px) saturate(1.2)` is one of the heaviest compositor effects in Chromium/Electron — it has to re-blur the underlying region every frame the underlying content changes. Stacking 2-3 blurred surfaces over a streaming terminal compounds the cost into visible scroll judder. Replace with semi-opaque solid backgrounds in hot paths; keep blur for transient dialogs only.\n\n- **ResizeObserver fires on every dragged pixel.** Debounce 75ms trailing, skip zero-size entries (allotment drags and `display:none` transitions both produce them), preserve `viewport-pinned-to-bottom` state across the refit so the stream stays at the bottom. Don't fire `resizePty` IPC on no-op resize — the PTY's TUI redraws on SIGWINCH and that's a visible cost per spurious resize.\n\n- **dispose() drains pending rAF flushes.** Disposal must cancel any rAF that would otherwise fire `term.write()` into a disposed terminal. Order: clearPtyBuffer(key) FIRST (which synchronously notifies the runtime's own subscriber with the empty tail), THEN flip `disposed = true` and null `term`. Belt-and-suspenders: top of writeFromBuffer / writeChunks does `if (disposed) return`.\n\n- **Snapshot Set before iteration.** Reentrant `clearPtyBuffer` or unsubscribe calls inside a listener can mutate the Set during the loop. `for (const listener of [...keyListeners])` is the cheap fix; alternatively, use a Map of listener-id → listener and iterate values.\n\n- **Listener exceptions don't cancel the batch.** Wrap each `listener(chunk)` in try/catch with `console.error` — one bad listener must not abort delivery to siblings or escape the rAF callback (which would silently kill the flush schedule).\n\n## Process\n\n1. **Restate the symptom precisely.** \"Eight identical Claude Code tool-call cards stacked in scrollback after several tab switches back to the same terminal\" is a hypothesis-generator; \"the terminal feels duplicated\" is not. If the brief is vague, ask 1-2 specific disambiguators (first attach vs N-switches; specific TUI vs any output; reproducible vs intermittent).\n\n2. **Map symptom → bug class.** Use the pattern-match list above. If multiple classes fit, plan the cheapest disambiguation experiment first (often: a temporary console.log at the chunk-write site to count writes per chunk).\n\n3. **Read the relevant code path.** Producer (broker chunk arrival), buffer store (coalescing + listener), runtime (subscribe, write, refresh), renderer (addon, fit, font). Note every place that could fire a write or a SIGWINCH or a focus event in the path from \"hide tab\" to \"show tab again.\"\n\n4. **Apply the minimal fix.** A 5-line targeted change in the right file beats a 50-line refactor across three files. If a fix requires touching unrelated abstractions, the bug class may be different from what you think; re-check step 2.\n\n5. **Verify the gate.** Typecheck + build on touched files. Don't run the full Electron app inside the loop — too involved, too slow. The behavioral verification is a manual test by the operator with a specific reproduction script.\n\n6. **Report.** Cite the file:line and the specific escape sequence or DOM event responsible. If you applied a defensive fix (e.g., dropping auto-focus to suppress focus-event redraws), say so explicitly and name the UX trade-off, so the operator can decide whether to accept it.\n\n## Anti-goals\n\n- Don't add tests for behavioral correctness that requires running the full Electron app (visual rendering, paint timing, GPU compositing) — those belong to the operator's manual test plan. BUT when AGENTS.md or repo guidelines require regression tests for the area you're touching (this codebase requires them for broker start, event streaming, PTY buffering, spawned personas, and integration notifications, with duplicate/replay coverage), add the tests at the unit level (vitest against the pty-buffer-store, agent-store, etc.) — those ARE reliably automatable and the requirement supersedes this anti-goal.\n- Don't refactor architecture beyond the minimum the fix needs. Don't introduce new abstractions \"for future flexibility\" — the next renderer bug will be different and you'll have built the wrong abstraction.\n- Don't enable mode bits or addons by default just because they exist. WebGL is a perf upgrade and a failure-mode upgrade; image addon enables binary protocols; ligatures introduce reflow cost. Each has a cost; default off unless the brief asks for it.\n- Don't paper over a duplication symptom by adding a dedupe layer downstream. Find where the duplicate is introduced upstream and remove it there.\n- Don't trust that an xterm option does what its name suggests; read the parser source if behavior contradicts the name. Notable example: `scrollback: 0` does not disable scrollback in some renderers; `cursorBlink: true` interacts with `cursorStyle: 'bar'` differently from `'block'`.\n- Don't recommend a fix without naming the specific code path that produces the observed symptom. \"It might be a race\" is not a fix; \"line 234 of predictive-echo.js writes the chunk before line 236 awaits the model write, and the model write is the only thing that suspends, so if onResize fires between them the model is sized for the post-resize cols while the chunk was written assuming the pre-resize cols, mis-attributing column counts\" is.\n- Don't add SIGWINCH bounces, retry loops, or polling fallbacks unless you have a documented reason a single deterministic path won't work. Each adds a new failure mode (TUI receives spurious resize → redraws → adds card; retry hides the real failure; polling masks event-loss bugs).\n\n## Output contract\n\nAt every diagnosis, surface:\n\n- **The symptom restated** in operator-verifiable terms.\n- **The bug class identified** and the specific code-path + escape-sequence behavior that produces it.\n- **The fix** with file:line and minimal-diff scope.\n- **The gates** that should pass before manual test (typecheck, build, no console errors on initial attach).\n- **The manual test reproduction** the operator should run to confirm.\n- **The UX trade-off** if the fix accepts one (e.g., losing auto-focus on tab switch).\n- **What you couldn't validate** without running the app.\n\nIf you have low confidence on the fix, say so and propose the smallest diagnostic experiment that would disambiguate (typically a single console.log or a temporary write-count counter). Don't ship a fix you wouldn't sign your name to.", + "harness": "claude", + "model": "claude-opus-4-6", + "systemPrompt": "$TASK_DESCRIPTION", + "harnessSettings": { + "reasoning": "high", + "timeoutSeconds": 3600 + } +} diff --git a/memory/INCIDENT-20260608T102342Z.md b/memory/INCIDENT-20260608T102342Z.md new file mode 100644 index 00000000..75de5e0e --- /dev/null +++ b/memory/INCIDENT-20260608T102342Z.md @@ -0,0 +1,18 @@ +# relayfile mount-root invariant incident + +- timestamp: 20260608T102342Z +- local root: /home/daytona/workspace/memory/workspace +- invariant: mount root must always be a directory +- detected kind: missing +- reason: local root does not exist + +## Recovery + +The mount root is gone. Confirm whether the directory was deleted +by another process (rm -rf, git clean -fdx, sync tool, etc.). +To recreate a clean mount, pass `--reset-after-clobber` to +`relayfile mount` (or set `RELAYFILE_RESET_AFTER_CLOBBER=1`). +The daemon will refuse to start without this acknowledgment. + +See `docs/architecture/mount-invariants.md` for the protected +invariants and the full recovery procedure. diff --git a/package-lock.json b/package-lock.json index 42d4658d..2a834922 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "react-dom": "^19.0.0", "shiki": "^4.0.2", "unidiff": "^1.0.4", + "use-stick-to-bottom": "^1.1.6", "zod": "^3.25.76", "zustand": "^5.0.0" }, @@ -39,15 +40,18 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@testing-library/react": "^16.3.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", "electron": "^42.1.0", "electron-builder": "^26.8.1", "electron-vite": "^5.0.0", + "happy-dom": "^20.10.2", "tailwindcss": "^4.0.0", "typescript": "^5.7.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^4.1.8" } }, "node_modules/@agent-assistant/connectivity": { @@ -1366,6 +1370,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -5897,6 +5911,13 @@ "node": ">=18.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -6246,6 +6267,63 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -6304,6 +6382,17 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", @@ -6363,6 +6452,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -6488,6 +6584,23 @@ "license": "MIT", "optional": true }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -6526,6 +6639,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -7615,6 +7841,17 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -7636,6 +7873,16 @@ "node": ">=0.8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -7890,6 +8137,19 @@ "dev": true, "license": "MIT" }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/buildcheck": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", @@ -8080,6 +8340,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8935,6 +9205,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -9363,6 +9641,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -9468,6 +9753,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -9525,6 +9820,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -10197,6 +10502,48 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/happy-dom": { + "version": "20.10.2", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.10.2.tgz", + "integrity": "sha512-5p9Sxis3eowDJKqx90QCsgbNA02XXqJ59NOHvD4V6cxp+rP4d/xOyVx7uY3hS8hiUbY1VeiFH8lbJ81AyuDVLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/happy-dom/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -11281,6 +11628,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -11861,6 +12219,20 @@ "node": ">= 0.4" } }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -12235,6 +12607,47 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -12500,6 +12913,14 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -13066,6 +13487,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -13189,6 +13617,13 @@ "nan": "^2.23.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -13208,6 +13643,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", @@ -13428,6 +13870,23 @@ "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -13445,6 +13904,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -13755,6 +14224,21 @@ "punycode": "^2.1.0" } }, + "node_modules/use-stick-to-bottom": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/use-stick-to-bottom/-/use-stick-to-bottom-1.1.6.tgz", + "integrity": "sha512-z3Up8jYQGTkUCsGBnwg6/wj70KgXoW5Kz1AAc1j8MtQuYMBo6ZsdhrIXoegxa7gaMMilgQYyTohTrt3p94jHog==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/samdenty" + } + ], + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -13920,6 +14404,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/warning": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", @@ -13967,6 +14541,23 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -13974,9 +14565,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index c35ad4ce..153af9ed 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "react-dom": "^19.0.0", "shiki": "^4.0.2", "unidiff": "^1.0.4", + "use-stick-to-bottom": "^1.1.6", "zod": "^3.25.76", "zustand": "^5.0.0" }, @@ -55,14 +56,17 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@testing-library/react": "^16.3.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", "electron": "^42.1.0", "electron-builder": "^26.8.1", "electron-vite": "^5.0.0", + "happy-dom": "^20.10.2", "tailwindcss": "^4.0.0", "typescript": "^5.7.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^4.1.8" } } diff --git a/src/renderer/src/__test__/dom-setup.ts b/src/renderer/src/__test__/dom-setup.ts new file mode 100644 index 00000000..5fbbe2d3 --- /dev/null +++ b/src/renderer/src/__test__/dom-setup.ts @@ -0,0 +1,34 @@ +// Per-test-file setup for happy-dom DOM tests. +// +// happy-dom 20 inside vitest exposes a `localStorage` global that is a +// placeholder object without the Storage API; modules that read from it +// at import time (e.g. `ui-store.ts`) crash with "getItem is not a +// function". Polyfill the bare API so imports succeed. + +class TestStorage implements Storage { + private store: Map = new Map() + get length(): number { + return this.store.size + } + clear(): void { + this.store.clear() + } + getItem(key: string): string | null { + return this.store.has(key) ? (this.store.get(key) as string) : null + } + key(index: number): string | null { + return Array.from(this.store.keys())[index] ?? null + } + removeItem(key: string): void { + this.store.delete(key) + } + setItem(key: string, value: string): void { + this.store.set(key, String(value)) + } +} + +const ls = new TestStorage() +Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: ls }) +if (typeof window !== 'undefined') { + Object.defineProperty(window, 'localStorage', { configurable: true, value: ls }) +} diff --git a/src/renderer/src/__test__/xterm-mock.ts b/src/renderer/src/__test__/xterm-mock.ts new file mode 100644 index 00000000..c6ba51b8 --- /dev/null +++ b/src/renderer/src/__test__/xterm-mock.ts @@ -0,0 +1,190 @@ +// Minimal xterm.js mock for happy-dom component tests. +// +// We don't try to emulate xterm's VT parser; tests assert against the raw +// byte sequences passed to `write()` and the lifecycle calls made on the +// terminal. Returns a value-compatible `Terminal` shape covering the +// surface that `terminal-runtime-registry.ts`, `use-terminal.ts`, and +// `predictive-echo.ts` consume. + +import { vi } from 'vitest' + +export interface MockBufferLine { + translateToString: () => string +} + +export interface MockActiveBuffer { + type: 'normal' | 'alternate' + cursorX: number + cursorY: number + baseY: number + viewportY: number + getLine: (row: number) => MockBufferLine | undefined +} + +export interface MockTextarea { + focus: ReturnType +} + +export interface MockOptions { + theme?: unknown +} + +export type DataHandler = (data: string) => void +export type ResizeHandler = (size: { cols: number; rows: number }) => void + +export interface MockTerminal { + cols: number + rows: number + textarea: MockTextarea + options: MockOptions + buffer: { active: MockActiveBuffer } + // Recorded raw byte sequences in call order. + __writes: string[] + // Counts so tests can assert without spying. + __refreshCalls: Array<{ start: number; end: number }> + __disposed: boolean + __opened: boolean + __openContainer: HTMLElement | null + __addons: unknown[] + __dataHandlers: DataHandler[] + __resizeHandlers: ResizeHandler[] + + write: (data: string, cb?: () => void) => void + open: (container: HTMLElement) => void + dispose: () => void + focus: () => void + refresh: (start: number, end: number) => void + resize: (cols: number, rows: number) => void + scrollToBottom: () => void + loadAddon: (addon: unknown) => void + onData: (handler: DataHandler) => { dispose: () => void } + onResize: (handler: ResizeHandler) => { dispose: () => void } +} + +export interface MockTerminalConstructorOpts { + cols?: number + rows?: number + [k: string]: unknown +} + +// Tracks every Terminal instance ever created so tests can introspect. +export const createdTerminals: MockTerminal[] = [] + +export function resetXtermMock(): void { + createdTerminals.length = 0 +} + +export class Terminal implements MockTerminal { + cols: number + rows: number + textarea: MockTextarea + options: MockOptions + buffer: { active: MockActiveBuffer } + __writes: string[] = [] + __refreshCalls: Array<{ start: number; end: number }> = [] + __disposed = false + __opened = false + __openContainer: HTMLElement | null = null + __addons: unknown[] = [] + __dataHandlers: DataHandler[] = [] + __resizeHandlers: ResizeHandler[] = [] + + constructor(opts: MockTerminalConstructorOpts = {}) { + this.cols = typeof opts.cols === 'number' ? opts.cols : 80 + this.rows = typeof opts.rows === 'number' ? opts.rows : 24 + this.textarea = { focus: vi.fn() } + this.options = { theme: opts.theme } + this.buffer = { + active: { + type: 'normal', + cursorX: 0, + cursorY: 0, + baseY: 0, + viewportY: 0, + getLine: () => ({ translateToString: () => '' }) + } + } + createdTerminals.push(this) + } + + write(data: string, cb?: () => void): void { + this.__writes.push(data) + if (cb) queueMicrotask(cb) + } + + open(container: HTMLElement): void { + this.__opened = true + this.__openContainer = container + } + + dispose(): void { + this.__disposed = true + } + + focus(): void { + // recorded by tests via spying if needed; cheap no-op otherwise + } + + refresh(start: number, end: number): void { + this.__refreshCalls.push({ start, end }) + } + + resize(cols: number, rows: number): void { + this.cols = cols + this.rows = rows + for (const h of this.__resizeHandlers) h({ cols, rows }) + } + + scrollToBottom(): void { + // no-op + } + + loadAddon(addon: unknown): void { + this.__addons.push(addon) + } + + onData(handler: DataHandler): { dispose: () => void } { + this.__dataHandlers.push(handler) + return { + dispose: () => { + const idx = this.__dataHandlers.indexOf(handler) + if (idx >= 0) this.__dataHandlers.splice(idx, 1) + } + } + } + + onResize(handler: ResizeHandler): { dispose: () => void } { + this.__resizeHandlers.push(handler) + return { + dispose: () => { + const idx = this.__resizeHandlers.indexOf(handler) + if (idx >= 0) this.__resizeHandlers.splice(idx, 1) + } + } + } +} + +// Addons used by terminal-runtime-registry.ts. They get `loadAddon()`-ed +// onto the terminal; the runtime only calls `.fit()` on the FitAddon and +// `.onContextLoss()` on the WebglAddon, so other methods are no-ops. + +export class FitAddon { + __fitCalls = 0 + fit(): void { + this.__fitCalls += 1 + } + dispose(): void {} +} + +export class WebLinksAddon { + dispose(): void {} +} + +export class WebglAddon { + __contextLossHandlers: Array<() => void> = [] + onContextLoss(handler: () => void): { dispose: () => void } { + this.__contextLossHandlers.push(handler) + return { dispose: () => {} } + } + dispose(): void {} +} diff --git a/src/renderer/src/components/chat/ChatMessage.tsx b/src/renderer/src/components/chat/ChatMessage.tsx index a2255018..c171c41b 100644 --- a/src/renderer/src/components/chat/ChatMessage.tsx +++ b/src/renderer/src/components/chat/ChatMessage.tsx @@ -1,11 +1,11 @@ import type React from 'react' -import { useState } from 'react' +import { memo, useState } from 'react' import { MessageCircle, SmilePlus } from 'lucide-react' import { AgentHarnessIcon } from '@/components/common/AgentIcons' import type { AuthUser } from '@/lib/ipc' import { renderChatMessageBody } from '@/lib/chat-formatting' import { formatClockTime, formatRelativeShort } from '@/lib/format' -import { useAgentStore } from '@/stores/agent-store' +import { useAgentByName } from '@/stores/agent-store' import type { ChatMessage as ChatMessageType, ChatThreadReply @@ -174,12 +174,7 @@ function ThreadParticipantAvatar({ participant: ChatThreadReply authUser?: AuthUser | null }): React.ReactNode { - const agent = useAgentStore((state) => - state.agents.find((candidate) => - candidate.name === participant.from && - (!participant.projectId || candidate.projectId === participant.projectId) - ) - ) + const agent = useAgentByName(participant.projectId, participant.from) if (participant.isHuman) { return ( @@ -246,7 +241,7 @@ function ThreadSummary({ ) } -export function ChatMessage({ +function ChatMessageInner({ message, showRoute = true, showActions = true, @@ -256,12 +251,7 @@ export function ChatMessage({ onReply, onReact }: Props): React.ReactNode { - const agent = useAgentStore((state) => - state.agents.find((candidate) => - candidate.name === message.from && - (!message.projectId || candidate.projectId === message.projectId) - ) - ) + const agent = useAgentByName(message.projectId, message.from) const color = message.isHuman ? 'var(--pear-accent-bright)' : getAgentColor(message.from) const reactions = message.reactions || [] const replies = message.threadReplies || [] @@ -364,3 +354,9 @@ export function ChatMessage({ ) } + +// Default shallow prop comparison is enough here: `message` is referentially +// stable from the store (only mutated when the actual message record changes), +// `authUser` is stable across renders, and the boolean / callback props are +// stabilised by the parent. Memoising avoids a re-render per PTY tick. +export const ChatMessage = memo(ChatMessageInner) diff --git a/src/renderer/src/components/chat/ChatView.tsx b/src/renderer/src/components/chat/ChatView.tsx index 6c08ce8f..86692fe9 100644 --- a/src/renderer/src/components/chat/ChatView.tsx +++ b/src/renderer/src/components/chat/ChatView.tsx @@ -1,5 +1,6 @@ import type React from 'react' -import { Fragment, useEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useStickToBottom } from 'use-stick-to-bottom' import { Bot, Check, @@ -38,54 +39,6 @@ function isChannelMessage(message: ChatMessageType, channelName: string): boolea return normalizeMessageChannel(message.to) === channelName } -function isHumanMessage(message: ChatMessageType): boolean { - return message.isHuman || message.from.trim().toLowerCase() === 'human' -} - -function areDuplicateHumanMessages(left: ChatMessageType, right: ChatMessageType): boolean { - return isHumanMessage(left) && - isHumanMessage(right) && - left.body === right.body && - (!left.projectId || !right.projectId || left.projectId === right.projectId) && - normalizeMessageChannel(left.to) === normalizeMessageChannel(right.to) && - Math.abs(left.timestamp - right.timestamp) < 10_000 -} - -function dedupeHumanMessages(messages: ChatMessageType[]): ChatMessageType[] { - const deduped: ChatMessageType[] = [] - // Collapsing near-simultaneous human echoes used to be O(n²): a findIndex over - // the whole deduped list per message, so the cost grew with chat length and - // ran on every new message (via the messages useMemo). Track the most recently - // kept human message per `${to}\0${body}` key instead, making each message O(1). - // Only human messages dedupe against each other, and duplicates fall inside a - // 10s window, so the latest kept entry for a key is the only one a new message - // can collide with — the result is identical to the previous scan. - const lastHumanIndexByKey = new Map() - - for (const message of messages) { - if (!isHumanMessage(message)) { - deduped.push(message) - continue - } - - const key = `${normalizeMessageChannel(message.to)}\0${message.body}` - const priorIndex = lastHumanIndexByKey.get(key) - const prior = priorIndex !== undefined ? deduped[priorIndex] : undefined - - if (prior && areDuplicateHumanMessages(prior, message)) { - if (message.isHuman && !prior.isHuman) { - deduped[priorIndex!] = message - } - continue - } - - deduped.push(message) - lastHumanIndexByKey.set(key, deduped.length - 1) - } - - return deduped -} - function isSameDay(left: number, right: number): boolean { const leftDate = new Date(left) const rightDate = new Date(right) @@ -379,7 +332,15 @@ export function ChatView(): React.ReactNode { ? isDirectMessageRoomHumanIncluded(directMessageParticipants) : false const directMessageReadOnly = Boolean(directMessageParticipants && !directMessageHumanIncluded) - const scrollRef = useRef(null) + // `use-stick-to-bottom` watches the content element via ResizeObserver and + // keeps the viewport pinned to the bottom while the user is at the bottom. + // This cooperates with the browser's overflow-anchor and avoids the manual + // scrollTop = scrollHeight effect that used to fight scroll anchoring and + // yank the viewport during streaming. + const { scrollRef, contentRef, scrollToBottom } = useStickToBottom({ + initial: 'instant', + resize: 'instant' + }) const preserveSettingsAfterRenameRef = useRef(false) const [activeThreadMessageId, setActiveThreadMessageId] = useState(null) const [activeTab, setActiveTab] = useState('messages') @@ -396,15 +357,18 @@ export function ChatView(): React.ReactNode { : allMessages, [activeProjectId, allMessages] ) + // The store is the source of truth for message identity — each chat message + // arrives with a unique `id` (broker event_id) and the store's + // reconcileChatMessages / isDuplicateHumanEcho paths handle dedupe on insert. + // Trust those ids here and just scope to the current channel/DM so we don't + // re-run a content+timestamp heuristic on every render that could collapse + // legitimately distinct messages (or miss duplicates outside the 10s window). const messages = useMemo( - () => { - const scopedMessages = directMessageParticipants - ? projectMessages.filter((message) => messageMatchesDirectMessageRoom(message, directMessageParticipants)) - : activeChannelName - ? projectMessages.filter((message) => isChannelMessage(message, activeChannelName)) - : projectMessages - return dedupeHumanMessages(scopedMessages) - }, + () => directMessageParticipants + ? projectMessages.filter((message) => messageMatchesDirectMessageRoom(message, directMessageParticipants)) + : activeChannelName + ? projectMessages.filter((message) => isChannelMessage(message, activeChannelName)) + : projectMessages, [activeChannelName, directMessageParticipants, projectMessages] ) const agents = useMemo( @@ -439,11 +403,13 @@ export function ChatView(): React.ReactNode { setSettingsError(null) }, [activeChannelName, directMessageParticipants]) + // Channel/DM switch should jump to bottom instantly. Streaming/append + // behaviour is handled by useStickToBottom's ResizeObserver, so we don't + // need to react to messages.length here anymore (which is what caused the + // "text drag" mid-scroll yanks). useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight - } - }, [activeChannelName, directMessageParticipants, messages.length]) + scrollToBottom('instant') + }, [activeChannelName, directMessageParticipants, scrollToBottom]) useEffect(() => { if (!activeThreadMessageId || activeThreadMessage) return @@ -471,6 +437,15 @@ export function ChatView(): React.ReactNode { setActiveThreadMessageId(null) }, [activeTab, directMessageReadOnly]) + // Stabilise the per-message callbacks so memoised ChatMessage children only + // re-render when their own props change (not on every parent re-render). + const handleReplyToMessage = useCallback((nextMessage: ChatMessageType) => { + setActiveThreadMessageId(nextMessage.id) + }, []) + const handleReactToMessage = useCallback((messageId: string, emoji: string) => { + toggleMessageReaction(messageId, emoji) + }, [toggleMessageReaction]) + const handleRenameChannel = async (event: React.FormEvent): Promise => { event.preventDefault() if (!activeChannelName) return @@ -621,7 +596,7 @@ export function ChatView(): React.ReactNode { {emptyMessage} ) : ( -
+
{messages.map((message, index) => { const previousMessage = messages[index - 1] const showDateDivider = !previousMessage || !isSameDay(previousMessage.timestamp, message.timestamp) @@ -636,10 +611,8 @@ export function ChatView(): React.ReactNode { showActions={canInteractWithMessages} showThreadSummary={canInteractWithMessages} activeThread={activeThreadMessageId === message.id} - onReply={canInteractWithMessages - ? (nextMessage) => setActiveThreadMessageId(nextMessage.id) - : undefined} - onReact={canInteractWithMessages ? toggleMessageReaction : undefined} + onReply={canInteractWithMessages ? handleReplyToMessage : undefined} + onReact={canInteractWithMessages ? handleReactToMessage : undefined} /> ) diff --git a/src/renderer/src/components/graph/AgentNode.tsx b/src/renderer/src/components/graph/AgentNode.tsx index 694208d6..bd51db49 100644 --- a/src/renderer/src/components/graph/AgentNode.tsx +++ b/src/renderer/src/components/graph/AgentNode.tsx @@ -40,7 +40,9 @@ function useAgentPreviewChunks(agent: Agent): string[] { const [chunks, setChunks] = useState(() => getPtyChunks(key)) useEffect(() => { setChunks(getPtyChunks(key)) - return subscribePtyBuffer(key, (next) => setChunks(next)) + // Listener now only signals new tail chunks; re-pull the canonical + // buffer on every notification so this preview reflects the trim cap. + return subscribePtyBuffer(key, () => setChunks(getPtyChunks(key))) }, [key]) return chunks } diff --git a/src/renderer/src/components/terminal/TerminalPane.tsx b/src/renderer/src/components/terminal/TerminalPane.tsx index 83576d59..fd0b8451 100644 --- a/src/renderer/src/components/terminal/TerminalPane.tsx +++ b/src/renderer/src/components/terminal/TerminalPane.tsx @@ -951,12 +951,18 @@ export function TerminalPane(): React.ReactNode {
{splitPages.map((pageAgents, pageIndex) => { const visible = pageIndex === splitPage + // Hide non-visible pages with `display: none` rather than the + // translateX slide that used to live here. Sliding the WebGL + // canvas via CSS transform produces ghosting / scroll-trail + // artifacts during streaming. The page indicator + nav buttons + // below still work without the animation. return (
({ Terminal: MockTerminal })) +vi.mock('@xterm/addon-fit', () => ({ FitAddon: MockFitAddon })) +vi.mock('@xterm/addon-web-links', () => ({ WebLinksAddon: MockWebLinksAddon })) +vi.mock('@xterm/addon-webgl', () => ({ WebglAddon: MockWebglAddon })) + +vi.mock('@/lib/font-settle', () => ({ + awaitFontSettle: vi.fn(async () => {}) +})) + +vi.mock('@/lib/ipc', () => ({ + pear: { + broker: { + attachTerminal: vi.fn(async () => ({ snapshot: null })), + resizePty: vi.fn(async () => {}), + setTerminalMode: vi.fn(async () => {}), + sendInputFast: vi.fn(), + inputSrtt: vi.fn(async () => null) + } + } +})) + +vi.mock('@/lib/predictive-echo', () => ({ + createPredictiveEcho: vi.fn(() => ({ + engine: null, + dispose: vi.fn() + })) +})) + +// ResizeObserver isn't implemented by happy-dom. The hook constructs one +// but only fires observe callbacks on real layout changes — we just need +// the constructor not to throw. +class StubResizeObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} +;(globalThis as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = + StubResizeObserver as unknown as typeof ResizeObserver + +let useTerminal: typeof import('./use-terminal').useTerminal +let agentStoreModule: typeof import('@/stores/agent-store') + +beforeEach(async () => { + resetXtermMock() + vi.resetModules() + useTerminal = (await import('./use-terminal')).useTerminal + agentStoreModule = await import('@/stores/agent-store') +}) + +afterEach(() => { + cleanup() + document.body.innerHTML = '' + vi.clearAllMocks() +}) + +interface ProbeProps { + agentName: string + projectId: string + termHolder: { current: XTermType | null } + renderCounter: { count: number } +} + +function Probe({ agentName, projectId, termHolder, renderCounter }: ProbeProps): React.ReactElement { + renderCounter.count += 1 + const ref = useRef(null) + // Give the container layout so initIfReady() doesn't bail. + const setRef = (node: HTMLDivElement | null): void => { + if (node) { + Object.defineProperty(node, 'clientWidth', { configurable: true, value: 800 }) + Object.defineProperty(node, 'clientHeight', { configurable: true, value: 600 }) + } + ref.current = node + } + const term = useTerminal(ref, agentName, projectId, true) + termHolder.current = term + return React.createElement('div', { ref: setRef, 'data-testid': 'host' }) +} + +function seedAgents(names: Array<{ projectId: string; name: string }>): void { + const baseAgent = { + cli: 'test', + status: 'running' as const, + activity: 'idle' as const, + currentState: 'idle' as const, + terminalMode: 'drive' as const, + pendingDeliveryIds: [] as string[] + } + agentStoreModule.useAgentStore.setState({ + agents: names.map((a) => ({ ...baseAgent, name: a.name, projectId: a.projectId })) + }) +} + +async function flushAsync(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +function renderProbe(agentName: string, projectId: string) { + const termHolder = { current: null as XTermType | null } + const renderCounter = { count: 0 } + const utils = render( + React.createElement(Probe, { agentName, projectId, termHolder, renderCounter }) + ) + // The hook returns `runtimeRef.current?.term ?? null`. The mount + // effect runs after render and mutates the ref without triggering a + // re-render, so we manually re-render after a microtask drain to + // capture the latest term. When agentName changes, we need + // rerender → flush (cleanup + new effect run) → rerender (capture). + const settle = async (name: string = agentName): Promise => { + utils.rerender( + React.createElement(Probe, { agentName: name, projectId, termHolder, renderCounter }) + ) + await flushAsync() + utils.rerender( + React.createElement(Probe, { agentName: name, projectId, termHolder, renderCounter }) + ) + await flushAsync() + } + return { ...utils, termHolder, renderCounter, settle } +} + +describe('useTerminal — ref stability', () => { + it('returns the same Terminal instance across renders for the same agent key', async () => { + seedAgents([{ projectId: 'p', name: 'alpha' }]) + const probe = renderProbe('alpha', 'p') + await probe.settle() + const firstTerm = probe.termHolder.current + expect(firstTerm).not.toBeNull() + + await probe.settle() + expect(probe.termHolder.current).toBe(firstTerm) + expect(createdTerminals).toHaveLength(1) + + await probe.settle() + expect(probe.termHolder.current).toBe(firstTerm) + expect(createdTerminals).toHaveLength(1) + }) + + it('switching agentName produces a different Terminal instance', async () => { + seedAgents([ + { projectId: 'p', name: 'alpha' }, + { projectId: 'p', name: 'beta' } + ]) + const probe = renderProbe('alpha', 'p') + await probe.settle() + const alphaTerm = probe.termHolder.current + expect(alphaTerm).not.toBeNull() + + await probe.settle('beta') + const betaTerm = probe.termHolder.current + expect(betaTerm).not.toBeNull() + expect(betaTerm).not.toBe(alphaTerm) + expect(createdTerminals.length).toBeGreaterThanOrEqual(2) + }) +}) diff --git a/src/renderer/src/hooks/use-terminal.ts b/src/renderer/src/hooks/use-terminal.ts index e0dbaec8..ff8f1296 100644 --- a/src/renderer/src/hooks/use-terminal.ts +++ b/src/renderer/src/hooks/use-terminal.ts @@ -1,70 +1,24 @@ import { useCallback, useEffect, useRef } from 'react' import { Terminal } from '@xterm/xterm' -import { FitAddon } from '@xterm/addon-fit' -import { WebLinksAddon } from '@xterm/addon-web-links' -import { WebglAddon } from '@xterm/addon-webgl' import { pear, type TerminalAttachMode } from '@/lib/ipc' import { useAgentStore, getAgentKey } from '@/stores/agent-store' -import { getPtyChunks, subscribePtyBuffer } from '@/stores/pty-buffer-store' -import { recordChunkEchoed, recordKeystrokeSent } from '@/lib/typing-trace' -import { createPredictiveEcho } from '@/lib/predictive-echo' -import type { PredictiveEcho } from '@agent-relay/harness-driver/predictive-echo' -import { useUIStore, type Theme } from '@/stores/ui-store' - -const DARK_THEME = { - background: '#0b1017', - foreground: '#d7e0ea', - cursor: '#74b8e2', - selectionBackground: '#203247', - black: '#121a24', - red: '#f0727f', - green: '#6bd4bc', - yellow: '#e6d78d', - blue: '#74b8e2', - magenta: '#c9a7ff', - cyan: '#04d1f6', - white: '#d7e0ea', - brightBlack: '#64707d', - brightRed: '#ff8a96', - brightGreen: '#89e4cb', - brightYellow: '#f1e5a7', - brightBlue: '#94cbef', - brightMagenta: '#dcc6ff', - brightCyan: '#6fe7ff', - brightWhite: '#edf4fb' -} - -const LIGHT_THEME = { - background: '#f7fafc', - foreground: '#111827', - cursor: '#4a90c2', - selectionBackground: '#d7e7f4', - black: '#111827', - red: '#d95b63', - green: '#2e9f92', - yellow: '#c89934', - blue: '#4a90c2', - magenta: '#8b72d8', - cyan: '#2e9f92', - white: '#f7fafc', - brightBlack: '#6b7280', - brightRed: '#ea717a', - brightGreen: '#4fb4a7', - brightYellow: '#d8ac4f', - brightBlue: '#6aa7d2', - brightMagenta: '#a28ae7', - brightCyan: '#4fbab0', - brightWhite: '#ffffff' -} - -function getXtermTheme(theme: Theme): typeof DARK_THEME { - return theme === 'light' ? LIGHT_THEME : DARK_THEME -} +import { recordKeystrokeSent } from '@/lib/typing-trace' +import { + acquireTerminalRuntime, + disposeTerminalRuntime, + type TerminalRuntime +} from '@/lib/terminal-runtime-registry' +import { useUIStore } from '@/stores/ui-store' function hasLayout(el: HTMLElement): boolean { return el.clientWidth > 0 && el.clientHeight > 0 } +function isViewportPinnedToBottom(term: Terminal): boolean { + const buffer = term.buffer.active + return buffer.viewportY === buffer.baseY +} + const KEY_INPUT_SEQUENCES: Record = { Enter: '\r', Tab: '\t', @@ -125,19 +79,6 @@ function isEditableElement(target: EventTarget | null): boolean { return editable instanceof HTMLElement } -function hasVisibleTerminalContent(screen: string): boolean { - const stripped = screen.replace( - /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[@-Z\\-_])/g, - '' - ) - return /\S/.test(stripped) -} - -interface TerminalSize { - rows: number - cols: number -} - export function useTerminal( containerRef: React.RefObject, agentName: string | null, @@ -146,12 +87,13 @@ export function useTerminal( active: boolean = visible, terminalMode: TerminalAttachMode = 'drive' ): Terminal | null { - const termRef = useRef(null) - const fitAddonRef = useRef(null) - const predictiveEchoRef = useRef(null) - const writtenChunksRef = useRef(0) + const runtimeRef = useRef(null) const activeRef = useRef(active) const terminalModeRef = useRef(terminalMode) + // Backs the runtime's predictive echo `getInputSrtt` callback. The + // runtime holds the function reference for life; we just keep the + // latest value in this ref and poll while the hook is mounted. + const inputSrttRef = useRef(null) const theme = useUIStore((s) => s.theme) const activeDialog = useUIStore((s) => s.activeDialog) @@ -161,6 +103,7 @@ export function useTerminal( useEffect(() => { terminalModeRef.current = terminalMode + runtimeRef.current?.setTerminalMode(terminalMode) }, [terminalMode]) useEffect(() => { @@ -175,341 +118,202 @@ export function useTerminal( // Optimistically echo before the round trip; the engine reconciles // against authoritative output and stays dormant on fast local links. - predictiveEchoRef.current?.onUserInput(data) + runtimeRef.current?.getPredictiveEcho()?.onUserInput(data) recordKeystrokeSent(data) pear.broker.sendInputFast(projectId, agentName, data) }, [agentName, projectId]) + // Read the latest theme via a ref so the main acquisition effect can be + // independent of theme changes; the dedicated setTheme effect below + // propagates theme updates to the live runtime. + const themeRef = useRef(theme) useEffect(() => { - if (!containerRef.current || !agentName) return + themeRef.current = theme + }, [theme]) + + useEffect(() => { + if (!agentName) { + runtimeRef.current = null + return + } + + // Acquire the persistent runtime for this agent. Tab switches / + // re-mounts return the same runtime instance, so xterm + PTY + // subscription survive across React lifecycle churn — this is what + // kills the duplicate-text class of bugs. + const runtime = acquireTerminalRuntime({ + projectId, + agentName, + terminalMode: terminalModeRef.current, + theme: themeRef.current, + getInputSrtt: () => inputSrttRef.current + }) + runtimeRef.current = runtime + // Re-bind the SRTT getter on each effect run. The runtime captures + // the getter once at first acquire; without this, a remount that + // changes inputSrttRef identity would leave the predictor reading + // the stale ref. + runtime.setInputSrttGetter(() => inputSrttRef.current) + const onDataHandler = (data: string): void => sendInput(data) + runtime.setOnData(onDataHandler) - const container = containerRef.current - let unsubStore: (() => void) | null = null - let term: Terminal | null = null - let fitAddon: FitAddon | null = null - let resizeObserver: ResizeObserver | null = null let disposed = false - let cleanupBounce: (() => void) | null = null - let disposePredictiveEcho: (() => void) | null = null - // Latest broker input→ack SRTT (ms), refreshed by the poll below. Backs the - // engine's adaptive engage decision; SRTT is a slow-moving EWMA so a ~1s - // poll is responsive enough and cheap. - let inputSrttMs: number | null = null + let resizeObserver: ResizeObserver | null = null + let resizeDebounceTimer: ReturnType | null = null let srttPoll: ReturnType | null = null + let focusTimers: ReturnType[] = [] + let mountToken: symbol | null = null + const containerEl = containerRef.current const focusTerminal = (requireActive = false): void => { - if (!term) return + const term = runtime.term + const container = containerRef.current + if (!term || !container) return if (requireActive && !activeRef.current) return requestAnimationFrame(() => { - if (!disposed && (!requireActive || activeRef.current)) { - container.focus({ preventScroll: true }) - term?.textarea?.focus({ preventScroll: true }) - term?.focus() - } - }) - } - - const fitTerminal = (): TerminalSize | null => { - if (!term || !fitAddon || !hasLayout(container)) return null - try { - fitAddon.fit() - } catch { - return null - } - const { rows, cols } = term - if (rows > 0 && cols > 0) { - return { rows, cols } - } - return null - } - - const safeFitAndSync = (): TerminalSize | null => { - const size = fitTerminal() - if (size) { - predictiveEchoRef.current?.onResize(size.cols, size.rows) - pear.broker.resizePty(projectId, agentName!, size.rows, size.cols).catch(() => {}) - } - return size - } - - const subscribeToBuffer = (targetTerm: Terminal): void => { - if (unsubStore) return - - const writeFromBuffer = (ptyBuffer: string[]): void => { - if (ptyBuffer.length < writtenChunksRef.current) { - // Buffer was trimmed past our cursor; replay everything we still have. - writtenChunksRef.current = 0 - } - const newChunks = ptyBuffer.slice(writtenChunksRef.current) - if (newChunks.length === 0) return - for (const chunk of newChunks) { - recordChunkEchoed(chunk) - if (predictiveEchoRef.current) { - // The engine owns pass-through to the live terminal and reconciles - // outstanding predictions against this confirmed output. - void predictiveEchoRef.current.onServerOutput(chunk) - } else { - targetTerm.write(chunk) - } - } - writtenChunksRef.current = ptyBuffer.length - } - - const bufferKey = getAgentKey(projectId, agentName!) - unsubStore = subscribePtyBuffer(bufferKey, writeFromBuffer) - writeFromBuffer(getPtyChunks(bufferKey)) - } - - const attachAndSeedTerminal = async ( - targetTerm: Terminal, - initialSize: TerminalSize | null - ): Promise => { - let shouldReplayBuffer = true - - try { - const result = await pear.broker.attachTerminal({ - projectId, - name: agentName!, - rows: initialSize?.rows, - cols: initialSize?.cols, - mode: terminalModeRef.current - }) - if (disposed) return - - if (result.snapshot?.screen && hasVisibleTerminalContent(result.snapshot.screen)) { - targetTerm.write(result.snapshot.screen) - // Prime the engine's confirmed-screen model with the same bytes (this - // does not re-write to the terminal) so its cursor matches the real - // screen before any prediction is made. - await predictiveEchoRef.current?.seed(result.snapshot.screen) - writtenChunksRef.current = useAgentStore.getState().getAgentBuffer(projectId, agentName!).length - shouldReplayBuffer = false - } - } catch (err) { - console.error('[terminal] attachTerminal failed:', err) - } - - if (disposed) return - - if (shouldReplayBuffer) { - writtenChunksRef.current = 0 - // No snapshot to prime from; mark the model seeded so predictions can - // engage. The buffer replay below feeds confirmed output through the - // engine, keeping the model in sync. - await predictiveEchoRef.current?.seed('') - } - - subscribeToBuffer(targetTerm) - } - - const init = (): void => { - if (disposed) return - if (!hasLayout(container)) { - requestAnimationFrame(init) - return - } - - term = new Terminal({ - theme: getXtermTheme(theme), - fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo, monospace", - fontSize: 13, - lineHeight: 1, - cursorBlink: true, - allowProposedApi: true - }) - - fitAddon = new FitAddon() - term.loadAddon(fitAddon) - term.loadAddon(new WebLinksAddon()) - - // GPU-accelerated renderer: dramatically faster than the default DOM - // renderer that emits one element per cell. Fall back silently if the - // host can't initialize WebGL (Electron contexts without GL, headless - // CI, etc.) — xterm will keep using the DOM renderer in that case. - try { - const webgl = new WebglAddon() - webgl.onContextLoss(() => { - webgl.dispose() - }) - term.loadAddon(webgl) - } catch (err) { - console.warn('[terminal] WebGL renderer unavailable, falling back to DOM:', err) - } - - // Forward keystrokes + terminal protocol responses to PTY - term.onData((data) => { - sendInput(data) - }) - - term.open(container) - const initialSize = fitTerminal() - - // Mosh-style predictive local echo: optimistically renders printable - // keystrokes and reconciles against authoritative server output. Adaptive - // on measured latency, so it stays dormant (invisible) on fast local - // links and only engages when driving a high-latency / remote agent. - const liveTerm = term - const predictiveEcho = createPredictiveEcho({ - write: (data) => liveTerm.write(data), - cols: term.cols, - rows: term.rows, - getInputSrtt: () => inputSrttMs + if (requireActive && !activeRef.current) return + container.focus({ preventScroll: true }) + term.textarea?.focus({ preventScroll: true }) + term.focus() }) - predictiveEchoRef.current = predictiveEcho.engine - disposePredictiveEcho = predictiveEcho.dispose - - // Keep the SRTT estimate warm so prediction engages promptly. Refresh - // immediately, then on an interval; failures leave the last value intact. - const refreshSrtt = (): void => { - pear.broker - .inputSrtt(projectId, agentName!) - .then((srtt) => { - if (!disposed) inputSrttMs = srtt - }) - .catch(() => {}) - } - refreshSrtt() - srttPoll = setInterval(refreshSrtt, 1000) - - void attachAndSeedTerminal(term, initialSize) - - termRef.current = term - fitAddonRef.current = fitAddon + } + // Mount into the visible container if we have layout. If not yet, + // we still call mount() so the runtime can defer its init() to the + // first frame with layout. + if (containerEl) { + mountToken = runtime.mount(containerEl) focusTerminal(true) - - // Spawn dialogs and pane layout updates can steal focus immediately after - // mount. Retry a few times so the xterm textarea reliably becomes active. - const focusTimers = [0, 50, 150, 300].map((delay) => + focusTimers = [0, 50, 150, 300].map((delay) => setTimeout(() => focusTerminal(true), delay) ) - - resizeObserver = new ResizeObserver(() => safeFitAndSync()) - resizeObserver.observe(container) - - // The PTY starts at a default size before the terminal connects. - // Bounce the size to force a SIGWINCH so the running process redraws - // at the correct dimensions. - const bounceTimer = setTimeout(() => { - if (!term || !fitAddon || !hasLayout(container)) return - try { - fitAddon.fit() - } catch { - return - } - const { rows, cols } = term - if (rows > 1 && cols > 0) { - pear.broker.resizePty(projectId, agentName!, rows - 1, cols).then(() => { - pear.broker.resizePty(projectId, agentName!, rows, cols) - }).catch(() => {}) - } - }, 200) - cleanupBounce = () => { - clearTimeout(bounceTimer) - for (const timer of focusTimers) { - clearTimeout(timer) - } - } - } - - requestAnimationFrame(init) - - // Click-to-focus - const handlePointerDown = (): void => { - focusTerminal() } - const handleKeyDown = (event: KeyboardEvent): void => { - if (event.isComposing || event.target === term?.textarea) { - return - } - - const data = getKeyboardInput(event) - if (!data) return - - event.preventDefault() - event.stopPropagation() - sendInput(data) - focusTerminal() + // Keep the SRTT estimate warm so prediction engages promptly. Refresh + // immediately, then on an interval; failures leave the last value intact. + const refreshSrtt = (): void => { + pear.broker + .inputSrtt(projectId, agentName) + .then((srtt) => { + if (!disposed) inputSrttRef.current = srtt + }) + .catch(() => {}) } - - const handlePaste = (event: ClipboardEvent): void => { - if (document.activeElement === term?.textarea) { - return - } - - const text = event.clipboardData?.getData('text') - if (!text) return - - event.preventDefault() - event.stopPropagation() - sendInput(text) - focusTerminal() + refreshSrtt() + srttPoll = setInterval(refreshSrtt, 1000) + + // Trailing-debounced refit. The raw ResizeObserver fires per entry on + // every allotment drag, including 0×0 intermediate states. Refitting + // on a 0×0 box leaks bad metrics into xterm and forces a fix-up later + // — gate on a real box and debounce. + if (containerEl) { + resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0] + if (!entry) return + const { width, height } = entry.contentRect + if (width === 0 || height === 0) return + if (resizeDebounceTimer) clearTimeout(resizeDebounceTimer) + resizeDebounceTimer = setTimeout(() => { + resizeDebounceTimer = null + if (disposed) return + const term = runtime.term + const wasPinned = term ? isViewportPinnedToBottom(term) : false + runtime.fitAndSync() + if (wasPinned && term) { + term.scrollToBottom() + } + }, 75) + }) + resizeObserver.observe(containerEl) } - container.addEventListener('pointerdown', handlePointerDown) - container.addEventListener('keydown', handleKeyDown) - container.addEventListener('paste', handlePaste) - return () => { disposed = true - cleanupBounce?.() - unsubStore?.() - container.removeEventListener('pointerdown', handlePointerDown) - container.removeEventListener('keydown', handleKeyDown) - container.removeEventListener('paste', handlePaste) + // Identity-checked clear: don't wipe a NEW hook's onData handler + // if its mount happened to commit before this cleanup ran (the + // cross-tree React commit-order case the token-based detach + // already protects the host against). + runtime.clearOnDataIf(onDataHandler) + for (const timer of focusTimers) clearTimeout(timer) + if (resizeDebounceTimer) clearTimeout(resizeDebounceTimer) resizeObserver?.disconnect() if (srttPoll) clearInterval(srttPoll) - disposePredictiveEcho?.() - predictiveEchoRef.current = null - term?.dispose() - termRef.current = null - fitAddonRef.current = null - writtenChunksRef.current = 0 + + // Don't dispose the runtime — detach so xterm + subscription survive + // the React unmount. The runtime is only torn down when the agent + // itself goes away (see effect below). + if (mountToken) { + runtime.detach(mountToken) + } } }, [containerRef, agentName, projectId, sendInput]) + // Dispose the runtime when its owning agent is no longer in the store. + // Tab switches null-out agentName without removing the agent — we should + // keep the runtime around in that case. But when the agent is actually + // released (closed, removed, etc.) we should free GPU resources. useEffect(() => { - if (termRef.current) { - termRef.current.options.theme = getXtermTheme(theme) + return () => { + // On unmount, check whether the agent has actually been removed + // from the store; if so, dispose. Otherwise leave the runtime + // parked for future remounts. + if (!agentName) return + const key = getAgentKey(projectId, agentName) + const stillExists = useAgentStore + .getState() + .agents.some((a) => getAgentKey(a.projectId, a.name) === key) + if (!stillExists) { + disposeTerminalRuntime(key) + } } + }, [agentName, projectId]) + + useEffect(() => { + runtimeRef.current?.setTheme(theme) }, [theme]) useEffect(() => { - if (!visible || !termRef.current || !fitAddonRef.current) return + const runtime = runtimeRef.current + if (!visible || !runtime) return const container = containerRef.current if (!container || !hasLayout(container)) return try { - fitAddonRef.current.fit() - const { rows, cols } = termRef.current - if (rows > 0 && cols > 0 && agentName) { - predictiveEchoRef.current?.onResize(cols, rows) - pear.broker.resizePty(projectId, agentName, rows, cols) - } + const wasPinned = isViewportPinnedToBottom(runtime.term) + runtime.fitAndSync() + // Fix #11: WebGL doesn't repaint while the host is display:none; + // when the tab comes back, force a refresh so the canvas redraws + // rather than showing a stale frame. + runtime.refreshOnShow() + if (wasPinned) runtime.term.scrollToBottom() } catch { // ignore } - if (!active) return - const timer = setTimeout(() => termRef.current?.focus(), 50) - return () => clearTimeout(timer) - }, [visible, active, agentName, projectId]) - - useEffect(() => { - if (!visible || !active) return - const handleWindowFocus = (): void => { - setTimeout(() => termRef.current?.focus(), 50) - } - window.addEventListener('focus', handleWindowFocus) - return () => window.removeEventListener('focus', handleWindowFocus) - }, [visible, active]) + // Intentionally do NOT call term.focus() on visibility change. + // When the PTY application has enabled DECSET ?1004 (focus events) — + // which Claude Code's TUI does — term.focus() emits "\x1b[I" to the + // PTY. The application interprets it as "user just looked at me" and + // redraws its UI. On a stacked TUI card layout, that redraw appends a + // duplicate card instead of overwriting in place, so every tab switch + // back stacks another card in scrollback. User clicks on the terminal + // already focus the textarea via the pointerdown handler in the main + // effect; the visibility effect doesn't need to reinforce it. + }, [visible, active, agentName, projectId, containerRef]) + + // Window-focus auto-focus was removed for the same reason as the + // visibility-effect focus: any TUI that has enabled DECSET ?1004 + // receives a focus-in event on programmatic term.focus(), causing + // redraws and stacked TUI cards in scrollback. Alt-tabbing back to + // pear is rarer than tab-switching but in the same bug class. + // User-initiated clicks still focus the terminal via the pointerdown + // handler in the main effect. useEffect(() => { if (!visible || !active || terminalMode === 'view' || !agentName || activeDialog) return const container = containerRef.current const handleGlobalKeyDown = (event: KeyboardEvent): void => { - const term = termRef.current + const term = runtimeRef.current?.term if (!term || event.isComposing) { return } @@ -533,7 +337,7 @@ export function useTerminal( } const handleGlobalPaste = (event: ClipboardEvent): void => { - const term = termRef.current + const term = runtimeRef.current?.term if (!term) { return } @@ -565,5 +369,72 @@ export function useTerminal( } }, [visible, active, terminalMode, agentName, projectId, activeDialog, containerRef, sendInput]) - return termRef.current + // Keyboard handlers attached directly to the container element. These + // were previously inside the mount effect; pulling them out keeps the + // runtime acquisition simple and lets them piggyback on agent/projectId + // identity without re-running the heavy effect. + useEffect(() => { + const container = containerRef.current + if (!container || !agentName) return + + const handlePointerDown = (): void => { + const term = runtimeRef.current?.term + if (!term) return + requestAnimationFrame(() => { + container.focus({ preventScroll: true }) + term.textarea?.focus({ preventScroll: true }) + term.focus() + }) + } + + const handleKeyDown = (event: KeyboardEvent): void => { + const term = runtimeRef.current?.term + if (event.isComposing || event.target === term?.textarea) { + return + } + + const data = getKeyboardInput(event) + if (!data) return + + event.preventDefault() + event.stopPropagation() + sendInput(data) + requestAnimationFrame(() => { + container.focus({ preventScroll: true }) + term?.textarea?.focus({ preventScroll: true }) + term?.focus() + }) + } + + const handlePaste = (event: ClipboardEvent): void => { + const term = runtimeRef.current?.term + if (document.activeElement === term?.textarea) { + return + } + + const text = event.clipboardData?.getData('text') + if (!text) return + + event.preventDefault() + event.stopPropagation() + sendInput(text) + requestAnimationFrame(() => { + container.focus({ preventScroll: true }) + term?.textarea?.focus({ preventScroll: true }) + term?.focus() + }) + } + + container.addEventListener('pointerdown', handlePointerDown) + container.addEventListener('keydown', handleKeyDown) + container.addEventListener('paste', handlePaste) + + return () => { + container.removeEventListener('pointerdown', handlePointerDown) + container.removeEventListener('keydown', handleKeyDown) + container.removeEventListener('paste', handlePaste) + } + }, [containerRef, agentName, sendInput]) + + return runtimeRef.current?.term ?? null } diff --git a/src/renderer/src/lib/font-settle.ts b/src/renderer/src/lib/font-settle.ts new file mode 100644 index 00000000..596ae29c --- /dev/null +++ b/src/renderer/src/lib/font-settle.ts @@ -0,0 +1,48 @@ +// Wait for the given font family to load before xterm measures the cell box. +// +// xterm computes cell width/height from a measured glyph. If we open the +// terminal before JetBrains Mono (or whatever custom face) has actually +// loaded, xterm measures the fallback (system monospace) and rows/cols are +// off by ~10–20%, producing "smeared" text that only resolves on the next +// resize. `document.fonts.load(...)` returns a promise that resolves once +// the requested face is ready; we race it against a timeout so we never +// block terminal init on a font that fails to load. + +export async function awaitFontSettle( + fontFamily: string, + timeoutMs = 1500 +): Promise { + const fonts = (typeof document !== 'undefined' ? document.fonts : undefined) as + | FontFaceSet + | undefined + if (!fonts || typeof fonts.load !== 'function') return + + // `document.fonts.load` expects a CSS shorthand; the size doesn't matter + // for our purposes since we just want the face installed. + const familyToken = primaryFamily(fontFamily) + const spec = `13px ${familyToken}` + + let timer: ReturnType | null = null + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs) + }) + + try { + await Promise.race([ + fonts.load(spec).then(() => undefined).catch(() => undefined), + timeout + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +// `fontFamily` may be a CSS list like "'JetBrains Mono', 'Fira Code', Menlo". +// `document.fonts.load` accepts a list, but quoting/escaping has historically +// been finicky across engines. Take the first family token (preserving +// existing quotes if present) to keep the load request unambiguous. +function primaryFamily(fontFamily: string): string { + const first = fontFamily.split(',')[0]?.trim() + if (!first) return fontFamily + return first +} diff --git a/src/renderer/src/lib/terminal-runtime-registry.dom.test.ts b/src/renderer/src/lib/terminal-runtime-registry.dom.test.ts new file mode 100644 index 00000000..671fa17e --- /dev/null +++ b/src/renderer/src/lib/terminal-runtime-registry.dom.test.ts @@ -0,0 +1,276 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + Terminal as MockTerminal, + FitAddon as MockFitAddon, + WebLinksAddon as MockWebLinksAddon, + WebglAddon as MockWebglAddon, + createdTerminals, + resetXtermMock +} from '@/__test__/xterm-mock' + +vi.mock('@xterm/xterm', () => ({ Terminal: MockTerminal })) +vi.mock('@xterm/addon-fit', () => ({ FitAddon: MockFitAddon })) +vi.mock('@xterm/addon-web-links', () => ({ WebLinksAddon: MockWebLinksAddon })) +vi.mock('@xterm/addon-webgl', () => ({ WebglAddon: MockWebglAddon })) + +vi.mock('@/lib/font-settle', () => ({ + awaitFontSettle: vi.fn(async () => {}) +})) + +vi.mock('@/lib/ipc', () => ({ + pear: { + broker: { + attachTerminal: vi.fn(async () => ({ snapshot: null })), + resizePty: vi.fn(async () => {}), + setTerminalMode: vi.fn(async () => {}), + sendInputFast: vi.fn(), + inputSrtt: vi.fn(async () => null) + } + } +})) + +// Keep predictive echo dormant: the runtime calls `predictiveEcho?.onServerOutput` +// when present, otherwise writes straight to the live term. We want the +// direct-write path so tests can assert against `__writes` synchronously. +vi.mock('@/lib/predictive-echo', () => ({ + createPredictiveEcho: vi.fn(() => ({ + engine: null, + dispose: vi.fn() + })) +})) + +function makeLayoutContainer(width = 800, height = 600): HTMLDivElement { + const el = document.createElement('div') + Object.defineProperty(el, 'clientWidth', { configurable: true, value: width }) + Object.defineProperty(el, 'clientHeight', { configurable: true, value: height }) + document.body.appendChild(el) + return el +} + +function makeZeroSizeContainer(): HTMLDivElement { + const el = document.createElement('div') + Object.defineProperty(el, 'clientWidth', { configurable: true, value: 0 }) + Object.defineProperty(el, 'clientHeight', { configurable: true, value: 0 }) + document.body.appendChild(el) + return el +} + +async function flushAsync(): Promise { + // Drain microtasks queued by the runtime's awaited init path. + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +let registry: typeof import('./terminal-runtime-registry') +let ptyBuffer: typeof import('@/stores/pty-buffer-store') + +beforeEach(async () => { + resetXtermMock() + vi.resetModules() + registry = await import('./terminal-runtime-registry') + ptyBuffer = await import('@/stores/pty-buffer-store') +}) + +afterEach(() => { + document.body.innerHTML = '' + vi.clearAllMocks() +}) + +describe('terminal-runtime-registry — token-based mount/detach ownership', () => { + it('mount(A) then mount(B) silently reparents; detach(A) with stale token is a no-op; host ends up in B', () => { + const runtime = registry.acquireTerminalRuntime({ + projectId: 'p', + agentName: 'a', + terminalMode: 'drive', + theme: 'dark', + getInputSrtt: () => null + }) + + const containerA = makeLayoutContainer() + const containerB = makeLayoutContainer() + + const tokenA = runtime.mount(containerA) + expect(runtime.host.parentElement).toBe(containerA) + + const tokenB = runtime.mount(containerB) + expect(tokenA).not.toBe(tokenB) + expect(runtime.host.parentElement).toBe(containerB) + + // Stale token: detach(A) must not steal the host from B. + runtime.detach(tokenA) + expect(runtime.host.parentElement).toBe(containerB) + expect(runtime.isMounted()).toBe(true) + + runtime.detach(tokenB) + expect(runtime.host.parentElement).not.toBe(containerB) + expect(runtime.isMounted()).toBe(false) + + registry.disposeTerminalRuntime(runtime.key) + }) +}) + +describe('terminal-runtime-registry — pty-buffer subscription lifecycle', () => { + it('subscribes on first mount, survives detach/remount, unsubscribes on dispose (no listener leaks across remounts)', async () => { + const runtime = registry.acquireTerminalRuntime({ + projectId: 'p', + agentName: 'a', + terminalMode: 'drive', + theme: 'dark', + getInputSrtt: () => null + }) + const term = createdTerminals[0] + expect(term).toBeDefined() + + const containerA = makeLayoutContainer() + runtime.mount(containerA) + // initIfReady() awaits attachAndSeed which awaits attachTerminal; drain. + await flushAsync() + await flushAsync() + + // Subscription is live: appending a chunk and flushing should land + // exactly one write on the terminal. + ptyBuffer.appendPtyChunk(runtime.key, 'hello') + ptyBuffer.flushPtyChunksNow(runtime.key) + expect(term.__writes).toContain('hello') + const writesAfterMount = term.__writes.length + + // Detach: the runtime is parked, but the subscription must survive so + // background chunks still flow into xterm for the next remount. + const tokenA = runtime.isMounted() ? Symbol('placeholder') : Symbol('na') + // Use the real current token returned by a fresh mount path: re-mount + // and check that no duplicate listener was added. + runtime.detach(tokenA) // stale token — no-op + expect(runtime.isMounted()).toBe(true) + + ptyBuffer.appendPtyChunk(runtime.key, 'one-listener') + ptyBuffer.flushPtyChunksNow(runtime.key) + expect(term.__writes.length).toBe(writesAfterMount + 1) + + // Mount into a new container: must not double-subscribe. + const containerB = makeLayoutContainer() + runtime.mount(containerB) + await flushAsync() + ptyBuffer.appendPtyChunk(runtime.key, 'still-one') + ptyBuffer.flushPtyChunksNow(runtime.key) + expect(term.__writes.length).toBe(writesAfterMount + 2) + + // Dispose: listener should be removed. clearPtyBuffer fires the + // listener once with [] (a no-op write) before unsubscribing. + registry.disposeTerminalRuntime(runtime.key) + const writesAtDispose = term.__writes.length + ptyBuffer.appendPtyChunk(runtime.key, 'after-dispose') + ptyBuffer.flushPtyChunksNow(runtime.key) + expect(term.__writes.length).toBe(writesAtDispose) + }) +}) + +describe('terminal-runtime-registry — clearOnDataIf identity check', () => { + it('only clears the on-data handler when the caller still owns the slot', () => { + const runtime = registry.acquireTerminalRuntime({ + projectId: 'p', + agentName: 'a', + terminalMode: 'drive', + theme: 'dark', + getInputSrtt: () => null + }) + const term = createdTerminals[0] + + const handlerA = vi.fn() + const handlerB = vi.fn() + + runtime.setOnData(handlerA) + runtime.setOnData(handlerB) // B replaces A — cross-tree commit ordering + + // Old-hook cleanup with stale handler A must not wipe the slot. + runtime.clearOnDataIf(handlerA) + for (const h of term.__dataHandlers) h('input-1') + expect(handlerB).toHaveBeenCalledWith('input-1') + expect(handlerA).not.toHaveBeenCalled() + + // Live-hook cleanup with the current handler must clear it. + runtime.clearOnDataIf(handlerB) + for (const h of term.__dataHandlers) h('input-2') + expect(handlerB).toHaveBeenCalledTimes(1) + + registry.disposeTerminalRuntime(runtime.key) + }) +}) + +describe('terminal-runtime-registry — refreshOnShow', () => { + it('calls term.refresh(0, rows - 1) to repaint after a display:none → visible transition', () => { + const runtime = registry.acquireTerminalRuntime({ + projectId: 'p', + agentName: 'a', + terminalMode: 'drive', + theme: 'dark', + getInputSrtt: () => null + }) + const term = createdTerminals[0] + expect(term.rows).toBeGreaterThan(0) + const expectedEnd = term.rows - 1 + + runtime.refreshOnShow() + expect(term.__refreshCalls).toContainEqual({ start: 0, end: expectedEnd }) + + registry.disposeTerminalRuntime(runtime.key) + }) +}) + +describe('terminal-runtime-registry — dispose cancels pending init rAF', () => { + it('cancels the pending initIfReady rAF and never fires it after dispose', async () => { + const queued: Array<{ id: number; cb: FrameRequestCallback; cancelled: boolean }> = [] + let nextId = 1 + const rafSpy = vi + .spyOn(globalThis, 'requestAnimationFrame') + .mockImplementation((cb) => { + const id = nextId++ + queued.push({ id, cb, cancelled: false }) + return id + }) + const cancelSpy = vi + .spyOn(globalThis, 'cancelAnimationFrame') + .mockImplementation((id) => { + const entry = queued.find((q) => q.id === id) + if (entry) entry.cancelled = true + }) + + try { + const runtime = registry.acquireTerminalRuntime({ + projectId: 'p', + agentName: 'a', + terminalMode: 'drive', + theme: 'dark', + getInputSrtt: () => null + }) + const term = createdTerminals[0] + + // Zero-size container: initIfReady bails and schedules a rAF retry. + const zero = makeZeroSizeContainer() + runtime.mount(zero) + await flushAsync() + + expect(queued.length).toBeGreaterThanOrEqual(1) + const pending = queued[queued.length - 1] + expect(pending.cancelled).toBe(false) + expect(term.__opened).toBe(false) + + registry.disposeTerminalRuntime(runtime.key) + + expect(cancelSpy).toHaveBeenCalledWith(pending.id) + expect(pending.cancelled).toBe(true) + + // Even if a wayward frame fires, the runtime is disposed and must + // not open the terminal or otherwise act. + for (const q of queued) { + if (!q.cancelled) q.cb(performance.now()) + } + expect(term.__opened).toBe(false) + } finally { + rafSpy.mockRestore() + cancelSpy.mockRestore() + } + }) +}) diff --git a/src/renderer/src/lib/terminal-runtime-registry.ts b/src/renderer/src/lib/terminal-runtime-registry.ts new file mode 100644 index 00000000..859ac9fc --- /dev/null +++ b/src/renderer/src/lib/terminal-runtime-registry.ts @@ -0,0 +1,631 @@ +// Module-level registry of long-lived xterm runtimes, keyed by agent. +// +// Background: previously the `Terminal` instance lived inside a React +// useEffect. Every tab switch unmounted and remounted the host component, +// which tore down xterm and re-attached + replayed the chunk buffer. While +// the new mount was replaying, the broker kept streaming more bytes into +// the snapshot pipeline — those bytes would be written *again* on the next +// frame, producing the "duplicate text" the user reported. +// +// The fix is to decouple the xterm lifecycle from React: each agent gets a +// runtime that owns its `Terminal`, its addons, its PTY subscription, and +// its parked DOM host. React `mount(container)` calls return ownership +// tokens, and `detach(token)` only parks the host for the current token. +// This lets a newer cross-tree mount win while stale React cleanup no-ops; +// xterm never tears down until the agent is fully released. +// +// Model is based on superset-sh/superset's `terminal-runtime-registry.ts`. + +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import { WebLinksAddon } from '@xterm/addon-web-links' +import { WebglAddon } from '@xterm/addon-webgl' +import { pear, type TerminalAttachMode } from '@/lib/ipc' +import { getAgentKey } from '@/stores/agent-store' +import { + clearPtyBuffer, + diagPtyEnabled, + flushPtyChunksNow, + getPtyChunks, + subscribePtyBuffer +} from '@/stores/pty-buffer-store' +import { recordChunkEchoed } from '@/lib/typing-trace' +import { createPredictiveEcho } from '@/lib/predictive-echo' +import type { PredictiveEcho } from '@agent-relay/harness-driver/predictive-echo' +import { awaitFontSettle } from '@/lib/font-settle' +import type { Theme } from '@/stores/ui-store' + +const DARK_THEME = { + background: '#0b1017', + foreground: '#d7e0ea', + cursor: '#74b8e2', + selectionBackground: '#203247', + black: '#121a24', + red: '#f0727f', + green: '#6bd4bc', + yellow: '#e6d78d', + blue: '#74b8e2', + magenta: '#c9a7ff', + cyan: '#04d1f6', + white: '#d7e0ea', + brightBlack: '#64707d', + brightRed: '#ff8a96', + brightGreen: '#89e4cb', + brightYellow: '#f1e5a7', + brightBlue: '#94cbef', + brightMagenta: '#dcc6ff', + brightCyan: '#6fe7ff', + brightWhite: '#edf4fb' +} + +const LIGHT_THEME = { + background: '#f7fafc', + foreground: '#111827', + cursor: '#4a90c2', + selectionBackground: '#d7e7f4', + black: '#111827', + red: '#d95b63', + green: '#2e9f92', + yellow: '#c89934', + blue: '#4a90c2', + magenta: '#8b72d8', + cyan: '#2e9f92', + white: '#f7fafc', + brightBlack: '#6b7280', + brightRed: '#ea717a', + brightGreen: '#4fb4a7', + brightYellow: '#d8ac4f', + brightBlue: '#6aa7d2', + brightMagenta: '#a28ae7', + brightCyan: '#4fbab0', + brightWhite: '#ffffff' +} + +export function getXtermTheme(theme: Theme): typeof DARK_THEME { + return theme === 'light' ? LIGHT_THEME : DARK_THEME +} + +const TERMINAL_FONT_FAMILY = + "'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo, monospace" + +// Default-on, demoted to DOM after the first webgl failure for the rest of +// the session. We don't recover: if webgl construction blew up once we +// assume the context is unhealthy. +let suggestedRenderer: 'webgl' | 'dom' = 'webgl' + +function hasLayout(el: HTMLElement): boolean { + return el.clientWidth > 0 && el.clientHeight > 0 +} + +function isViewportPinnedToBottom(term: Terminal): boolean { + const buffer = term.buffer.active + return buffer.viewportY === buffer.baseY +} + +// Off-DOM parking area for detached runtime hosts. We need them in the +// document so xterm's internal measurements stay valid, but invisible and +// non-interactive while their owning React component is unmounted. +let parkedContainer: HTMLDivElement | null = null + +function getParkedContainer(): HTMLDivElement { + if (parkedContainer && parkedContainer.isConnected) return parkedContainer + const node = document.createElement('div') + node.setAttribute('data-pear-terminal-park', 'true') + node.style.position = 'absolute' + node.style.width = '0' + node.style.height = '0' + node.style.overflow = 'hidden' + node.style.pointerEvents = 'none' + node.style.visibility = 'hidden' + node.setAttribute('aria-hidden', 'true') + document.body.appendChild(node) + parkedContainer = node + return node +} + +export interface TerminalRuntime { + readonly key: string + readonly term: Terminal + readonly host: HTMLDivElement + mount(container: HTMLElement): symbol + detach(token: symbol): void + dispose(): void + isMounted(): boolean + setTheme(theme: Theme): void + setTerminalMode(mode: TerminalAttachMode): void + getTerminalMode(): TerminalAttachMode + fit(): { rows: number; cols: number } | null + fitAndSync(): { rows: number; cols: number } | null + // Redraw the live canvas (e.g. after the host was display:none and is + // becoming visible again — WebGL doesn't repaint until something forces + // a refresh). + refreshOnShow(): void + // Swap in a fresh getter for the input-SRTT polled by predictive echo. + // The engine captures its callback at construction, so we trampoline + // through a runtime-owned slot to allow rebinding on each hook effect. + setInputSrttGetter(getter: () => number | null): void + getPredictiveEcho(): PredictiveEcho | null + // Install a handler for `term.onData`. Returns the previous handler so the + // caller can re-install it later (e.g. on unmount while keeping the + // runtime alive). The runtime forwards via an internal mutable slot, so + // setting null disables forwarding without tearing down the xterm + // listener. + setOnData(handler: ((data: string) => void) | null): void + // Identity-checked clear used by cleanup paths. See implementation. + clearOnDataIf(handler: (data: string) => void): void +} + +interface AcquireOptions { + projectId: string | undefined + agentName: string + terminalMode: TerminalAttachMode + theme: Theme + getInputSrtt: () => number | null +} + +const runtimes = new Map() + +export function acquireTerminalRuntime(opts: AcquireOptions): TerminalRuntime { + const key = getAgentKey(opts.projectId, opts.agentName) + const existing = runtimes.get(key) + if (existing) { + existing.setTheme(opts.theme) + existing.setTerminalMode(opts.terminalMode) + return existing + } + const runtime = createRuntime(key, opts) + runtimes.set(key, runtime) + return runtime +} + +export function disposeTerminalRuntime(key: string): void { + const runtime = runtimes.get(key) + if (!runtime) return + runtimes.delete(key) + runtime.dispose() +} + +export function hasTerminalRuntime(key: string): boolean { + return runtimes.has(key) +} + +function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { + const host = document.createElement('div') + host.setAttribute('data-pear-terminal-runtime', key) + host.style.width = '100%' + host.style.height = '100%' + // Park immediately so xterm can attach without React having to provide a + // container on the first frame. + getParkedContainer().appendChild(host) + + let term: Terminal | null = new Terminal({ + theme: getXtermTheme(opts.theme), + fontFamily: TERMINAL_FONT_FAMILY, + fontSize: 13, + lineHeight: 1.2, + letterSpacing: 0.5, + cursorBlink: true, + cursorStyle: 'bar', + scrollback: 3000, + fastScrollModifier: 'alt', + macOptionIsMeta: false, + allowProposedApi: true + }) + + const fitAddon = new FitAddon() + term.loadAddon(fitAddon) + term.loadAddon(new WebLinksAddon()) + + let onDataHandler: ((data: string) => void) | null = null + term.onData((data) => { + onDataHandler?.(data) + }) + + // Track current attach mode + theme so re-acquires can update without + // re-creating the runtime. + let currentMode: TerminalAttachMode = opts.terminalMode + let currentTheme: Theme = opts.theme + let disposed = false + let currentToken: symbol | null = null + let webglAddon: WebglAddon | null = null + let predictiveEcho: PredictiveEcho | null = null + let disposePredictiveEcho: (() => void) | null = null + let unsubBuffer: (() => void) | null = null + let writtenChunks = 0 + let attachSeeded = false + let attachInFlight = false + let pendingInitFrame: number | null = null + // Last rows/cols actually sent to the PTY. fitAndSync drops the IPC when + // the size hasn't changed — observers fire on every dragged pixel and the + // backend reacts to no-op resizes by reflowing. + let lastSentRows = -1 + let lastSentCols = -1 + // Holder for the current input-SRTT getter. The predictive echo engine + // captures this once on construction, so we wrap it in a trampoline and + // let setInputSrttGetter swap the underlying getter on each effect run. + let currentSrttGetter: () => number | null = opts.getInputSrtt + + const cancelPendingInit = (): void => { + if (pendingInitFrame !== null) { + cancelAnimationFrame(pendingInitFrame) + pendingInitFrame = null + } + } + + // xterm has only ever been opened into `host`. React containers come and + // go, but the `host` div is the immutable parent of the xterm canvas. + // Reparenting `host` between containers (in mount/detach) keeps xterm + // measuring against the same DOM node it was opened with. + let opened = false + const openOnce = (): void => { + if (!term || opened) return + if (!hasLayout(host)) return + term.open(host) + opened = true + } + + const tryFit = (): { rows: number; cols: number } | null => { + if (!term) return null + const container = host + if (!hasLayout(container)) return null + try { + fitAddon.fit() + } catch { + return null + } + const { rows, cols } = term + if (rows > 0 && cols > 0) { + return { rows, cols } + } + return null + } + + // Lazy-load WebGL on the next frame so the terminal opens with the DOM + // renderer first (avoiding a hard sync boot on the GPU path) and upgrades + // only after the first frame paints. If WebGL fails for any reason we + // demote the whole session to the DOM renderer. + const loadWebglOnNextFrame = (): void => { + if (suggestedRenderer === 'dom' || !term) return + requestAnimationFrame(() => { + if (!term || disposed || webglAddon) return + try { + const addon = new WebglAddon() + addon.onContextLoss(() => { + suggestedRenderer = 'dom' + try { + addon.dispose() + } catch { + // ignore + } + if (webglAddon === addon) webglAddon = null + }) + term.loadAddon(addon) + webglAddon = addon + } catch (err) { + console.warn('[terminal] WebGL renderer unavailable, falling back to DOM:', err) + suggestedRenderer = 'dom' + } + }) + } + + const seedBufferSubscription = (): void => { + if (unsubBuffer || !term || disposed) return + const liveTerm = term + + const writeChunks = (newChunks: string[]): void => { + if (disposed || !term) return + if (newChunks.length === 0) return + // Optional diagnostic, gated on localStorage.PEAR_DIAG_PTY === '1'. + // See pty-buffer-store.ts for the enable instructions. Flag is + // cached to avoid a per-batch localStorage read. + if (diagPtyEnabled()) { + // eslint-disable-next-line no-console + console.log(`[diag:runtime:writeChunks] key=${key} count=${newChunks.length} firstPreview="${newChunks[0]?.slice(0, 80).replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\x1b/g, '\\e')}"`) + } + const wasPinned = isViewportPinnedToBottom(liveTerm) + for (const chunk of newChunks) { + recordChunkEchoed(chunk) + if (predictiveEcho) { + void predictiveEcho.onServerOutput(chunk) + } else { + liveTerm.write(chunk) + } + } + if (wasPinned) liveTerm.scrollToBottom() + } + + unsubBuffer = subscribePtyBuffer(key, writeChunks) + // Initial replay: pull whatever is already in the buffer past the + // snapshot baseline (writtenChunks). The listener only receives tails + // from this point on, so we have to do the catch-up explicitly here. + const buffered = getPtyChunks(key) + if (writtenChunks > buffered.length) writtenChunks = 0 + writeChunks(buffered.slice(writtenChunks)) + } + + const attachAndSeed = async ( + initialSize: { rows: number; cols: number } | null + ): Promise => { + if (!term || disposed || attachSeeded || attachInFlight) return + attachInFlight = true + + let shouldReplay = true + try { + const result = await pear.broker.attachTerminal({ + projectId: opts.projectId, + name: opts.agentName, + rows: initialSize?.rows, + cols: initialSize?.cols, + mode: currentMode + }) + if (disposed || !term) { + attachInFlight = false + return + } + if ( + result.snapshot?.screen && + hasVisibleTerminalContent(result.snapshot.screen) + ) { + term.write(result.snapshot.screen) + await predictiveEcho?.seed(result.snapshot.screen) + // Drain any chunks that arrived during the IPC roundtrip but are + // still staged in pending. Without this, the next rAF would push + // them into the buffer AFTER we capture writtenChunks, and the + // subsequent subscribe would replay them on top of the snapshot. + flushPtyChunksNow(key) + writtenChunks = getPtyChunks(key).length + shouldReplay = false + } + attachSeeded = true + } catch (err) { + console.error('[terminal] attachTerminal failed:', err) + // Don't latch attachSeeded — the next init/mount cycle should retry. + } finally { + attachInFlight = false + } + + if (disposed || !term) return + + if (shouldReplay) { + writtenChunks = 0 + await predictiveEcho?.seed('') + } + + seedBufferSubscription() + + // SIGWINCH bounce: 200ms after attach completes, send a one-pixel + // size change then back. Some TUIs (notably Ink-based, including + // Claude Code) cache their row/col count from initial state and + // only recompute on a winsize *change*. Without this bounce, their + // cursor-positioning sequences in subsequent redraws can land at + // the wrong row — the visible failure is each redraw appending to + // scrollback instead of overwriting in place, producing stacked + // duplicate cards. The bounce was dropped in Fix #8/#9 as a + // perceived perf optimization but is load-bearing for this class + // of TUI. lastSentRows/Cols are intentionally NOT updated for the + // (rows-1) intermediate, so the second resize re-fires. + const liveTerm = term + setTimeout(() => { + if (disposed || !liveTerm) return + const { rows, cols } = liveTerm + if (rows <= 1 || cols <= 0) return + pear.broker + .resizePty(opts.projectId, opts.agentName, rows - 1, cols) + .then(() => { + if (disposed || !liveTerm) return + // Re-read dimensions across the async boundary — the user may + // have resized the pane between the first and second IPC. + // Sending stale dims would regress the PTY size. + const currentRows = liveTerm.rows + const currentCols = liveTerm.cols + if (currentRows <= 0 || currentCols <= 0) return + return pear.broker.resizePty(opts.projectId, opts.agentName, currentRows, currentCols) + }) + .catch(() => {}) + }, 200) + } + + // Initial open into the parked host. We need the host in the document + // for xterm's renderers to measure, but layout() inside the parked area + // returns 0×0. We defer the actual open() + size sync to the first + // mount() that has real layout. + // However, xterm's loadAddon(WebglAddon) needs the renderer running and + // wants the terminal opened first; we therefore lazy-init the + // GPU/DOM-bound bits in initIfReady, called from mount(). + + const initIfReady = async (container: HTMLElement): Promise => { + if (!term || disposed) return + if (!hasLayout(container)) { + // Split-page / hidden-tab mount: the container is 0×0 right now + // (e.g. display:none). Schedule a retry next frame so we don't sit + // forever waiting for a mount() that never comes back. + if (pendingInitFrame !== null) return + pendingInitFrame = requestAnimationFrame(() => { + pendingInitFrame = null + if (disposed) return + void initIfReady(container) + }) + return + } + cancelPendingInit() + + openOnce() + let initialSize = tryFit() + + // Spin up predictive echo and SRTT once we have real measurements. + if (!predictiveEcho) { + const liveTerm = term + const handle = createPredictiveEcho({ + write: (data) => liveTerm.write(data), + cols: term.cols, + rows: term.rows, + getInputSrtt: () => currentSrttGetter() + }) + predictiveEcho = handle.engine + disposePredictiveEcho = handle.dispose + } + + loadWebglOnNextFrame() + + // Wait for the actual font to load before locking in cell metrics. + // If JetBrains Mono lands later the fallback measurement is wrong and + // glyphs appear smeared until the next resize. + await awaitFontSettle(TERMINAL_FONT_FAMILY) + if (disposed || !term) return + + const refitted = tryFit() + if (refitted) { + try { + term.refresh(0, term.rows - 1) + } catch { + // ignore + } + // Post-settle metrics may differ from the pre-settle ones the + // predictor was constructed with. Sync it so column wraps and row + // counts line up with the real grid. + predictiveEcho?.onResize(refitted.cols, refitted.rows) + initialSize = refitted + } + + if (!attachSeeded) { + void attachAndSeed(initialSize) + } + } + + const runtime: TerminalRuntime = { + key, + get term() { + // We expose `term` as non-null since callers only interact with the + // runtime while it's alive; dispose() flips a flag and clears it + // immediately after. + return term as Terminal + }, + host, + mount(container: HTMLElement): symbol { + if (disposed || !term) return Symbol('disposed') + if (host.parentElement !== container) { + container.appendChild(host) + } + const token = Symbol('mount') + currentToken = token + // Always run initIfReady so a split-page / hidden-tab mount that + // landed without layout gets a retry once it becomes visible. + void initIfReady(container) + return token + }, + detach(token: symbol): void { + if (disposed) return + if (token !== currentToken) return + currentToken = null + // Cancel any pending initIfReady rAF. Without this, a split-page + // mount that never gained layout would spin forever against a + // detached/old container — a permanent rAF loop per parked page. + cancelPendingInit() + const park = getParkedContainer() + if (host.parentElement !== park) { + park.appendChild(host) + } + }, + dispose(): void { + if (disposed) return + // Cancel any rAF that would otherwise fire a flush into a disposed + // terminal. Do this BEFORE flipping `disposed`/nulling `term` so the + // writeFromBuffer notification triggered by clearPtyBuffer (with []) + // runs while the closure is still consistent. + cancelPendingInit() + clearPtyBuffer(key) + disposed = true + currentToken = null + unsubBuffer?.() + unsubBuffer = null + disposePredictiveEcho?.() + disposePredictiveEcho = null + predictiveEcho = null + try { + webglAddon?.dispose() + } catch { + // ignore + } + webglAddon = null + try { + term?.dispose() + } catch { + // ignore + } + term = null + if (host.parentElement) { + host.parentElement.removeChild(host) + } + }, + isMounted(): boolean { + return currentToken !== null + }, + setTheme(theme: Theme): void { + if (!term) return + if (currentTheme === theme) return + currentTheme = theme + term.options.theme = getXtermTheme(theme) + }, + setTerminalMode(mode: TerminalAttachMode): void { + currentMode = mode + }, + getTerminalMode(): TerminalAttachMode { + return currentMode + }, + fit(): { rows: number; cols: number } | null { + return tryFit() + }, + fitAndSync(): { rows: number; cols: number } | null { + const size = tryFit() + if (size) { + predictiveEcho?.onResize(size.cols, size.rows) + // Fix #9: ResizeObserver fires on every dragged pixel; only + // round-trip to the backend when the cell grid actually changed. + if (size.rows !== lastSentRows || size.cols !== lastSentCols) { + lastSentRows = size.rows + lastSentCols = size.cols + pear.broker + .resizePty(opts.projectId, opts.agentName, size.rows, size.cols) + .catch(() => {}) + } + } + return size + }, + refreshOnShow(): void { + if (!term || disposed) return + try { + term.refresh(0, term.rows - 1) + } catch { + // ignore + } + }, + setInputSrttGetter(getter: () => number | null): void { + currentSrttGetter = getter + }, + getPredictiveEcho(): PredictiveEcho | null { + return predictiveEcho + }, + setOnData(handler: ((data: string) => void) | null): void { + onDataHandler = handler + }, + // Clear `setOnData(null)` only when the caller's handler reference + // is still the one currently installed. Cross-tree React commit + // ordering can fire an old hook's cleanup *after* a new hook + // already installed its own handler; without this guard the old + // cleanup wipes the new hook's input forwarding for the still-live + // mount. Used in place of `setOnData(null)` from cleanup paths. + clearOnDataIf(handler: (data: string) => void): void { + if (onDataHandler === handler) onDataHandler = null + } + } + + return runtime +} + +function hasVisibleTerminalContent(screen: string): boolean { + const stripped = screen.replace( + /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[@-Z\\-_])/g, + '' + ) + return /\S/.test(stripped) +} diff --git a/src/renderer/src/stores/agent-store.stress.test.ts b/src/renderer/src/stores/agent-store.stress.test.ts new file mode 100644 index 00000000..aa031e83 --- /dev/null +++ b/src/renderer/src/stores/agent-store.stress.test.ts @@ -0,0 +1,325 @@ +// STRESS TEST — agent-store +// +// Drives the agent-store at the "1000s of agents communicating" scale the +// product spec assumes. Exercises the message dedupe, cap, and reference- +// stability invariants under realistic relay_inbound + reconcile load. +// +// Invariants exercised: +// 1. No duplicate message IDs in state.messages — the id-based dedup never +// lets the same id appear twice across relay_inbound + reconcile paths. +// 2. Optimistic human sends never get lost to the isCanonicalEchoOfLocalHuman +// guard — repeated identical human messages all produce final records. +// 3. The agent dedupe guard does NOT false-positive on cross-project sends: +// identical (agent, body, target) in different projects survive distinct. +// 4. The agent dedupe guard does NOT false-positive on legitimate distinct +// sends outside the 2s window — two identical agent messages 3s apart +// both survive. +// 5. The agent dedupe guard DOES catch the broker-replay case — the same +// agent+body+target+project within 2s with different event_ids appears +// exactly once. +// 6. The MAX_CHAT_MESSAGES cap (5000) holds even after a 60k-message burst. +// 7. reconcileMessages returns the SAME messages array reference when +// called twice with the same canonical input — downstream selectors +// must not see a spurious change. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// @/lib/ipc evaluates `window.pear` at module init (renderer-only). The +// vitest node env has no `window`, so stub the module before the agent-store +// import chain pulls in project-store -> @/lib/ipc. +vi.mock('@/lib/ipc', () => ({ pear: {} })) + +import { useAgentStore } from './agent-store' +import type { BrokerReconciledChatMessage } from '@shared/types/ipc' + +const MAX_CHAT_MESSAGES = 5_000 + +// Matches the shape `agent-store.handleBrokerEvent` expects for relay_inbound; +// the index signature aligns with the internal BrokerEvent discriminated +// union that requires `[key: string]: unknown`. +interface RelayInboundEvent { + kind: 'relay_inbound' + from: string + target: string + body: string + projectId?: string + event_id?: string + [key: string]: unknown +} + +function relayInbound(opts: { + from: string + target: string + body: string + projectId?: string + event_id?: string +}): RelayInboundEvent { + return { kind: 'relay_inbound', ...opts } +} + +describe('agent-store stress', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + // The store reads `localStorage.getItem('pear-broker-debug')` from a + // debug helper. Vitest's node env exposes a partial localStorage shim + // whose getItem isn't callable, so we replace it with a real stub. + vi.stubGlobal('localStorage', { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + key: () => null, + length: 0 + }) + useAgentStore.getState().clearAll() + }) + + afterEach(() => { + useAgentStore.getState().clearAll() + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + // 50k relay_inbound events each spread a 5000-cap immutable buffer; the + // raw work is ~250M element copies and the test runs in ~6–8s on a laptop. + // Bumped from the 5s default so CI doesn't flake on slower runners. + it('1000 agents × 50 messages + 100 human sends + 5 reconciles satisfies every invariant', () => { + const store = useAgentStore.getState() + const projectCount = 5 + const agentsPerProject = 200 + const messagesPerAgent = 50 + const totalAgentEvents = projectCount * agentsPerProject * messagesPerAgent + + // Drive relay_inbound at scale. Bodies vary per (round, agent) so dedupe + // doesn't suppress legitimate distinct messages. The timestamp moves + // 10ms per outer round so the 2s dedupe window is exercised but not + // monopolised. + let eventCounter = 0 + for (let m = 0; m < messagesPerAgent; m++) { + for (let p = 0; p < projectCount; p++) { + for (let a = 0; a < agentsPerProject; a++) { + const agentName = `agent-${a}` + const projectId = `project-${p}` + // Half of the events target a channel, half target a DM. The DM + // target is the human (cross-stream replay shape that the guard + // is specifically designed to dedupe). + const target = (a % 2 === 0) ? '#general' : 'human' + store.handleBrokerEvent(relayInbound({ + from: agentName, + target, + body: `m${m}-${agentName}`, + projectId, + event_id: `evt-${eventCounter++}` + })) + } + } + vi.advanceTimersByTime(10) + } + expect(eventCounter).toBe(totalAgentEvents) + + // 100 optimistic human sends with the SAME body — verifies that the + // isCanonicalEchoOfLocalHuman guard never causes a local addHumanMessage + // to be dropped (the guard is only applied to incoming canonical echoes). + for (let h = 0; h < 100; h++) { + store.addHumanMessage('#general', 'hello-world', 'project-0') + } + + // 5 reconcileMessages syncs interleaved with the rest of the load. + for (let r = 0; r < 5; r++) { + const canonicalBatch: BrokerReconciledChatMessage[] = Array.from({ length: 20 }, (_, j) => ({ + id: `canonical-${r}-${j}`, + from: `canon-agent-${j}`, + to: '#general', + body: `canon-body-${r}-${j}`, + timestamp: Date.now() + r * 1000 + j, + isHuman: false, + projectId: 'project-0' + })) + store.reconcileMessages(canonicalBatch) + vi.advanceTimersByTime(5) + } + + const messages = useAgentStore.getState().messages + + // Invariant 1 — no duplicate ids. + const ids = new Set(messages.map((m) => m.id)) + expect(ids.size).toBe(messages.length) + + // Invariant 6 — MAX_CHAT_MESSAGES cap holds. + expect(messages.length).toBeLessThanOrEqual(MAX_CHAT_MESSAGES) + + // Sanity — buffer is non-trivially populated (the cap actually clamped). + expect(messages.length).toBe(MAX_CHAT_MESSAGES) + }, 25_000) + + it('addHumanMessage never drops repeated identical sends', () => { + // Invariant 2 — the optimistic path is local-only; repeats must survive. + // (Even with capByCount, 100 sends are well under MAX_CHAT_MESSAGES.) + const store = useAgentStore.getState() + for (let i = 0; i < 100; i++) { + store.addHumanMessage('#general', 'identical-body', 'p1') + } + const messages = useAgentStore.getState().messages + const humanLocal = messages.filter((m) => + m.isHuman === true && + m.local === true && + m.body === 'identical-body' + ) + expect(humanLocal.length).toBe(100) + const ids = new Set(humanLocal.map((m) => m.id)) + expect(ids.size).toBe(100) + }) + + it('cross-project agent dedupe does not false-positive — identical body, different projectId survives twice', () => { + // Invariant 3 — projectId mismatch must NEVER collapse two distinct sends. + const store = useAgentStore.getState() + store.handleBrokerEvent(relayInbound({ + from: 'agent-x', + target: '#general', + body: 'cross-project-body', + projectId: 'project-a', + event_id: 'evt-a' + })) + store.handleBrokerEvent(relayInbound({ + from: 'agent-x', + target: '#general', + body: 'cross-project-body', + projectId: 'project-b', + event_id: 'evt-b' + })) + const messages = useAgentStore.getState().messages.filter( + (m) => m.body === 'cross-project-body' + ) + expect(messages.length).toBe(2) + expect(new Set(messages.map((m) => m.projectId))).toEqual( + new Set(['project-a', 'project-b']) + ) + }) + + it('legitimate identical agent sends 3s apart both survive the 2s dedupe window', () => { + // Invariant 4 — outside the AGENT_MESSAGE_DEDUPE_WINDOW_MS (2000ms), + // identical messages are distinct sends, not replays. + const store = useAgentStore.getState() + store.handleBrokerEvent(relayInbound({ + from: 'agent-y', + target: '#general', + body: 'spaced-body', + projectId: 'p1', + event_id: 'evt-spaced-1' + })) + vi.advanceTimersByTime(3_000) + store.handleBrokerEvent(relayInbound({ + from: 'agent-y', + target: '#general', + body: 'spaced-body', + projectId: 'p1', + event_id: 'evt-spaced-2' + })) + const messages = useAgentStore.getState().messages.filter( + (m) => m.body === 'spaced-body' + ) + expect(messages.length).toBe(2) + expect(messages[0]!.timestamp).toBeLessThan(messages[1]!.timestamp) + }) + + it('agent dedupe collapses broker-replay — same agent+body+target+project within 2s, different event_ids → one record', () => { + // Invariant 5 — the explicit case the guardrail was built for: the broker + // emits the same logical message twice with different event_ids (one via + // relay_inbound, one via a reconcile snapshot, or two relay_inbound + // streams racing). Both arrive within 2s. Renderer must show one. + const store = useAgentStore.getState() + store.handleBrokerEvent(relayInbound({ + from: 'agent-z', + target: '#general', + body: 'replayed-body', + projectId: 'p1', + event_id: 'evt-A' + })) + vi.advanceTimersByTime(500) + store.handleBrokerEvent(relayInbound({ + from: 'agent-z', + target: '#general', + body: 'replayed-body', + projectId: 'p1', + event_id: 'evt-B' + })) + const messages = useAgentStore.getState().messages.filter( + (m) => m.body === 'replayed-body' + ) + expect(messages.length).toBe(1) + // The first (evt-A) wins — it was already in the buffer when evt-B arrived. + expect(messages[0]!.id).toBe('evt-A') + }) + + it('reconcileMessages with the same canonical input twice returns the same messages array reference', () => { + // Invariant 7 — selectors that compare by reference must NOT see a change + // when the broker reconciles the same canonical snapshot. + const store = useAgentStore.getState() + const canonical: BrokerReconciledChatMessage[] = [ + { + id: 'canon-stable-1', + from: 'agent-a', + to: '#general', + body: 'canonical body 1', + timestamp: Date.now(), + isHuman: false, + projectId: 'p1' + }, + { + id: 'canon-stable-2', + from: 'agent-b', + to: '#general', + body: 'canonical body 2', + timestamp: Date.now() + 100, + isHuman: false, + projectId: 'p1' + } + ] + store.reconcileMessages(canonical) + const firstRef = useAgentStore.getState().messages + expect(firstRef.length).toBe(2) + + // Second call with identical canonical input — must short-circuit to the + // same array reference (no spurious state mutation). + store.reconcileMessages(canonical) + const secondRef = useAgentStore.getState().messages + expect(secondRef).toBe(firstRef) + + // Even an empty canonical batch must not change the reference. + store.reconcileMessages([]) + expect(useAgentStore.getState().messages).toBe(firstRef) + }) + + it('reconcile + relay_inbound interaction does not create duplicate ids when broker echoes canonical msg via both streams', () => { + // Stress the cross-stream case: a canonical message arrives via + // reconcileMessages AND the same logical message arrives via + // relay_inbound under a different event_id. The id-dedupe must run + // and the agent-replay guard must run — never both copies survive. + const store = useAgentStore.getState() + const now = Date.now() + store.reconcileMessages([{ + id: 'canonical-id-1', + from: 'agent-q', + to: '#general', + body: 'cross-stream-body', + timestamp: now, + isHuman: false, + projectId: 'p1' + }]) + // Same logical message via relay_inbound with a different event_id — the + // 2s window catches this as a duplicate. + store.handleBrokerEvent(relayInbound({ + from: 'agent-q', + target: '#general', + body: 'cross-stream-body', + projectId: 'p1', + event_id: 'relay-id-1' + })) + const messages = useAgentStore.getState().messages.filter( + (m) => m.body === 'cross-stream-body' + ) + expect(messages.length).toBe(1) + expect(messages[0]!.id).toBe('canonical-id-1') + }) +}) diff --git a/src/renderer/src/stores/agent-store.ts b/src/renderer/src/stores/agent-store.ts index 70e68e8b..99dedde2 100644 --- a/src/renderer/src/stores/agent-store.ts +++ b/src/renderer/src/stores/agent-store.ts @@ -45,6 +45,12 @@ export interface ChatMessage { conversationId?: string reactions?: ChatReaction[] threadReplies?: ChatThreadReply[] + // True for messages added via the optimistic local-UUID path + // (addHumanMessage). Lets reconciliation distinguish a pending + // local echo from a canonical broker record so the canonical + // record only replaces the optimistic, not another real human + // message that happens to match by body/target/time. + local?: boolean } export interface ChatReaction { @@ -90,6 +96,13 @@ const HUMAN_SENDER_NAME = 'human' const SYSTEM_NOTICE_SENDER_NAME = 'system' const HUMAN_MESSAGE_DEDUPE_WINDOW_MS = 10_000 const JOIN_NOTICE_DEDUPE_WINDOW_MS = 30_000 +// Tighter window for agent-message dedupe — agents reply fast and a +// 10s window would falsely collapse two legitimately distinct messages +// with similar bodies. Only catches the broker-replay / cross-stream +// case where the same logical agent message arrives twice within ~2s +// with different event_ids (per AGENTS.md: renderer is the final +// guardrail; stable event_id is the broker's job). +const AGENT_MESSAGE_DEDUPE_WINDOW_MS = 2_000 export function getAgentKey(projectId: string | undefined, name: string): string { return `${projectId || 'unknown'}:${name}` @@ -266,11 +279,16 @@ function isHumanMessage(message: Pick): boolean return message.isHuman || isHumanSender(message.from) } -function isDuplicateHumanEcho( +// Detects the canonical-of-optimistic case: an incoming broker record +// that matches an existing optimistic local-UUID record by content + +// time window. Scoped to local: true records so it doesn't collapse two +// legitimately distinct identical user messages (e.g. "ok" then "ok"). +function isCanonicalEchoOfLocalHuman( messages: ChatMessage[], candidate: Pick ): boolean { return messages.some((message) => + message.local === true && isHumanMessage(message) && message.body === candidate.body && (!message.projectId || !candidate.projectId || message.projectId === candidate.projectId) && @@ -279,6 +297,41 @@ function isDuplicateHumanEcho( ) } +// Same shape as the human echo guard but scoped to agent (non-human) +// messages with a tighter 2s window. Catches the broker-replay / +// cross-stream race where the same agent message arrives via +// relay_inbound AND a reconcile snapshot with mismatched event_ids +// — without this, both records survive id-based dedup and the user +// sees the message twice. Per AGENTS.md the renderer should defend as +// final guardrail even when the broker is supposed to provide stable ids. +// +// Accepts any iterable so the reconcile path can pass `byId.values()` +// directly without an Array.from() copy per incoming message — under +// heavy load (1000+ agents) the per-message copy was O(n) on the +// existing message buffer. +function isDuplicateAgentEcho( + messages: Iterable, + candidate: Pick +): boolean { + if (isHumanMessage(candidate)) return false + const candidateFrom = candidate.from.trim().toLowerCase() + const candidateTarget = normalizeMessageTarget(candidate.to) + for (const message of messages) { + if (isHumanMessage(message)) continue + if (message.from.trim().toLowerCase() !== candidateFrom) continue + if (message.body !== candidate.body) continue + // Require exact project equality. Allowing `undefined` to wildcard + // would let an unscoped message from one project shadow a real + // distinct message in another project. + if (message.projectId !== candidate.projectId) continue + if (normalizeMessageTarget(message.to) !== candidateTarget) continue + if (Math.abs(message.timestamp - candidate.timestamp) < AGENT_MESSAGE_DEDUPE_WINDOW_MS) { + return true + } + } + return false +} + function createChannelJoinNotice( projectId: string | undefined, channelName: string, @@ -368,6 +421,36 @@ function chatMessagesEqual(left: ChatMessage, right: ChatMessage): boolean { threadRepliesEqual(left.threadReplies, right.threadReplies) } +// Find an existing optimistic local-UUID human echo that matches an incoming +// canonical broker record. Optimistic messages are appended by `addHumanMessage` +// with `crypto.randomUUID()` and `local: true`; the broker subsequently +// reconciles the same message with its canonical `event_id`. Without identity +// replacement, both records survive id-based reconciliation and the user sees +// their message twice. Match only against `local: true` records — without the +// scope, two distinct human messages sharing body/target inside the dedupe +// window would collapse, deleting a real message. +function findOptimisticHumanMatch( + byId: Map, + incoming: ChatMessage +): ChatMessage | null { + if (!isHumanMessage(incoming)) return null + for (const existing of byId.values()) { + if (existing.id === incoming.id) continue + if (!existing.local) continue + if (!isHumanMessage(existing)) continue + if (existing.body !== incoming.body) continue + if ( + existing.projectId && + incoming.projectId && + existing.projectId !== incoming.projectId + ) continue + if (normalizeMessageTarget(existing.to) !== normalizeMessageTarget(incoming.to)) continue + if (Math.abs(existing.timestamp - incoming.timestamp) > HUMAN_MESSAGE_DEDUPE_WINDOW_MS) continue + return existing + } + return null +} + function reconcileChatMessages( existingMessages: ChatMessage[], incomingMessages: BrokerReconciledChatMessage[] @@ -396,6 +479,38 @@ function reconcileChatMessages( } continue } + // No id match — check whether this is the canonical echo of an + // optimistic local-UUID record we already have. If so, replace + // (preserving any client-side UI state from the optimistic record) + // rather than appending and creating a visible duplicate. The + // `local: false` reset ensures a subsequent optimistic with the + // same body/target/time can still match its own future canonical + // echo, rather than being seen as already-replaced. + const optimistic = findOptimisticHumanMatch(byId, next) + if (optimistic) { + byId.delete(optimistic.id) + byId.set(next.id, { + ...optimistic, + ...next, + threadReplies: next.threadReplies || optimistic.threadReplies, + reactions: next.reactions || optimistic.reactions, + local: false + }) + changed = true + continue + } + // No id match and not an optimistic-echo case — check the + // agent-duplicate guardrail: if a non-human message with the + // same (from, body, project, target) arrived within the agent + // dedupe window via another stream (relay_inbound), don't append + // a second copy under a different id. Pass byId.values() directly + // — copying to an array per message was O(n²) under heavy load. + if ( + !isHumanMessage(next) && + isDuplicateAgentEcho(byId.values(), next) + ) { + continue + } byId.set(next.id, next) changed = true } @@ -903,7 +1018,11 @@ export const useAgentStore = create()(subscribeWithSelector((set, ge projectId } const targetName = eventTarget.startsWith('#') ? null : normalizeMessageTarget(eventTarget) - const messages = isHuman && isDuplicateHumanEcho(state.messages, msg) + const alreadySeenById = state.messages.some((m) => m.id === msg.id) + const isDuplicate = alreadySeenById || + (isHuman && isCanonicalEchoOfLocalHuman(state.messages, msg)) || + (!isHuman && isDuplicateAgentEcho(state.messages, msg)) + const messages = isDuplicate ? state.messages : capByCount([...state.messages, msg], MAX_CHAT_MESSAGES) @@ -999,12 +1118,16 @@ export const useAgentStore = create()(subscribeWithSelector((set, ge body, timestamp, isHuman: true, - projectId + projectId, + local: true } + // Always append the optimistic record. The previous + // isDuplicateHumanEcho check here silently dropped the second of + // two identical sends within 10s ("ok", "ok"), losing a real + // message. Optimistic-vs-canonical dedup is now handled exclusively + // via the `local` flag in the relay_inbound + reconcile paths. set((state) => ({ - messages: isDuplicateHumanEcho(state.messages, msg) - ? state.messages - : capByCount([...state.messages, msg], MAX_CHAT_MESSAGES), + messages: capByCount([...state.messages, msg], MAX_CHAT_MESSAGES), lastHumanMessageSentAt: timestamp })) }, @@ -1156,3 +1279,49 @@ export const useAgentStore = create()(subscribeWithSelector((set, ge getAgentBuffer: (projectId, name) => getPtyChunks(getAgentKey(projectId, name)) }))) + +// Cache for the agents-by-(projectId, name) lookup map. Rebuilding it costs +// O(n) on every PTY tick if we use a useMemo or selector that touches the +// agents array, so we key it on the array reference (which only changes when +// the store actually mutates agents) and reuse the same Map across renders. +// Callers like ChatMessage / ThreadParticipantAvatar previously did +// `state.agents.find(...)` inside a Zustand selector, which made every +// message component re-render whenever the agents array changed (every PTY +// chunk that flips activity / currentState). +let agentMapCache: { source: Agent[]; map: Map } | null = null + +function getAgentLookup(agents: Agent[]): Map { + if (agentMapCache && agentMapCache.source === agents) return agentMapCache.map + + const map = new Map() + for (const agent of agents) { + map.set(getAgentKeyForAgent(agent), agent) + // Also key by name only so callers without a projectId can fall back to + // any matching agent — preserves the prior `agents.find` semantics for the + // few call sites where projectId is unknown. + const nameOnlyKey = `*:${agent.name}` + if (!map.has(nameOnlyKey)) map.set(nameOnlyKey, agent) + } + + agentMapCache = { source: agents, map } + return map +} + +/** + * Look up an agent by (projectId, name) using a cached map that only rebuilds + * when the agents array reference changes. The selector returns the agent + * object directly so components only re-render when *their* agent changes, + * not when any other agent's activity/state ticks. + */ +export function useAgentByName( + projectId: string | undefined, + name: string +): Agent | undefined { + return useAgentStore((state) => { + const lookup = getAgentLookup(state.agents) + if (projectId !== undefined) { + return lookup.get(getAgentKey(projectId, name)) + } + return lookup.get(`*:${name}`) + }) +} diff --git a/src/renderer/src/stores/pty-buffer-store.stress.test.ts b/src/renderer/src/stores/pty-buffer-store.stress.test.ts new file mode 100644 index 00000000..6ebdcd05 --- /dev/null +++ b/src/renderer/src/stores/pty-buffer-store.stress.test.ts @@ -0,0 +1,292 @@ +// STRESS TEST — pty-buffer-store +// +// Drives the per-key PTY chunk fan-out at the volumes the renderer sees when +// many agents stream output simultaneously. Uses fake timers + tight burst +// loops so 50k+ appends complete in well under a second of wall time. +// +// Invariants exercised: +// 1. Exactly-once delivery per chunk per always-on subscriber, even across +// trim events and rapid subscribe/unsubscribe churn on sibling listeners. +// 2. Buffer trim cap (MAX_PTY_BUFFER_CHUNKS = 10_000) is never exceeded for +// any key, regardless of burst rate. +// 3. Tail-only semantics: a listener that subscribes mid-stream sees ONLY +// the chunks that arrive after its subscribe, even when intermediate +// chunks have been trimmed out of the canonical buffer. +// 4. No leak in the internal `pending` / `pendingFrames` maps — once the +// burst settles, advancing time produces no further listener fires for +// any key (proxy for "queues fully drained"). +// 5. `clearPtyBuffer` cancels any pending rAF flush — no stale data fires +// after a clear, even under heavy in-flight load. +// 6. A listener that throws does not break delivery to its siblings — every +// well-behaved listener still receives every chunk. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + appendPtyChunk, + clearPtyBuffer, + flushPtyChunksNow, + getPtyChunks, + subscribePtyBuffer +} from './pty-buffer-store' + +const MAX_PTY_BUFFER_CHUNKS = 10_000 + +// Drives the rAF→setTimeout fallback path that the store uses in node. +function flushRaf(): void { + vi.advanceTimersByTime(20) +} + +function makeChunk(size: number, marker: string): string { + // Marker at the start so we can identify a chunk's origin in tail-only + // assertions without storing the entire body. + const filler = 'x'.repeat(Math.max(0, size - marker.length)) + return `${marker}${filler}` +} + +describe('pty-buffer-store stress', () => { + // Each test uses its own key set so a missed teardown can't leak state + // between tests. We still clear at the end to keep the module-level maps + // empty for the next file in the run. + const testKeys: string[] = [] + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + beforeEach(() => { + vi.useFakeTimers() + testKeys.length = 0 + }) + + afterEach(() => { + for (const key of testKeys) clearPtyBuffer(key) + vi.useRealTimers() + }) + + it('delivers exactly-once to always-on subscribers across 10 agents × 5000 chunks', () => { + const agentCount = 10 + const chunksPerAgent = 5_000 + const subscribersPerAgent = 5 + const keys = Array.from({ length: agentCount }, (_, i) => `stress-deliver-${i}`) + testKeys.push(...keys) + + // Per-listener received counters. Index: keyIndex * subscribersPerAgent + listenerIndex. + const counters = new Array(agentCount * subscribersPerAgent).fill(0) + for (let k = 0; k < agentCount; k++) { + for (let s = 0; s < subscribersPerAgent; s++) { + const counterIdx = k * subscribersPerAgent + s + subscribePtyBuffer(keys[k]!, (newChunks) => { + counters[counterIdx] += newChunks.length + }) + } + } + + // Vary chunk size from 1B to 4KB across the deck. + const chunkSizes = [1, 16, 256, 1024, 4096] + + // Tight burst loop. Flush every 50 iterations to mimic the ~one-frame + // cadence the renderer actually sees, instead of flushing once at the end + // (which would mask coalescing bugs). + for (let i = 0; i < chunksPerAgent; i++) { + for (let k = 0; k < agentCount; k++) { + const size = chunkSizes[(i + k) % chunkSizes.length]! + appendPtyChunk(keys[k]!, makeChunk(size, `c${i}`)) + } + if (i > 0 && i % 50 === 0) flushRaf() + } + flushRaf() + + // Exactly-once delivery: every always-on listener saw every chunk. + for (let k = 0; k < agentCount; k++) { + for (let s = 0; s < subscribersPerAgent; s++) { + const counterIdx = k * subscribersPerAgent + s + expect(counters[counterIdx]).toBe(chunksPerAgent) + } + } + + // Trim cap holds for every key. + for (const key of keys) { + expect(getPtyChunks(key).length).toBeLessThanOrEqual(MAX_PTY_BUFFER_CHUNKS) + } + + // After the burst settles, no further listener fires regardless of how + // far we advance time — pending / pendingFrames are drained. We snapshot + // the counts and assert they don't change. + const snapshot = counters.slice() + vi.advanceTimersByTime(5_000) + expect(counters).toEqual(snapshot) + }) + + it('survives rapid subscribe/unsubscribe churn without losing chunks for always-on listeners', () => { + const key = 'stress-churn' + testKeys.push(key) + + let alwaysOnCount = 0 + subscribePtyBuffer(key, (newChunks) => { + alwaysOnCount += newChunks.length + }) + + // Churn loop: 1000 chunks, with a fresh subscriber added + removed every + // 10 iterations. The always-on listener must still see exactly 1000. + const chunkCount = 1_000 + for (let i = 0; i < chunkCount; i++) { + const churnUnsub = subscribePtyBuffer(key, () => { /* noop */ }) + appendPtyChunk(key, `chunk-${i}`) + churnUnsub() + if (i % 10 === 0) flushRaf() + } + flushRaf() + + expect(alwaysOnCount).toBe(chunkCount) + }) + + it('respects MAX_PTY_BUFFER_CHUNKS even under sustained burst above the cap', () => { + const key = 'stress-trim-cap' + testKeys.push(key) + const totalChunks = MAX_PTY_BUFFER_CHUNKS * 3 + + let listenerCount = 0 + subscribePtyBuffer(key, (newChunks) => { + listenerCount += newChunks.length + }) + + // Push 3x the cap; flush periodically so trim runs across many flushes, + // not just at the very end. + for (let i = 0; i < totalChunks; i++) { + appendPtyChunk(key, `t${i}`) + if (i % 200 === 0) flushRaf() + } + flushRaf() + + // Canonical buffer never exceeds the cap. + expect(getPtyChunks(key).length).toBe(MAX_PTY_BUFFER_CHUNKS) + + // The listener received every chunk (exactly-once-per-flush semantics). + // Tail-only delivery means the listener gets ALL appended chunks even + // when trim drops older entries from the canonical buffer. + expect(listenerCount).toBe(totalChunks) + }) + + it('tail-only: a mid-stream subscriber receives only chunks after its subscribe, even across trim', () => { + const key = 'stress-tail-trim' + testKeys.push(key) + const preSubscribeCount = 9_000 + const postSubscribeCount = 5_000 + + // Phase 1 — fill the buffer close to the cap with "pre" chunks. + for (let i = 0; i < preSubscribeCount; i++) { + appendPtyChunk(key, `pre-${i}`) + if (i % 1000 === 0) flushRaf() + } + flushRaf() + expect(getPtyChunks(key).length).toBe(preSubscribeCount) + + // Phase 2 — subscribe a mid-stream listener; collect every chunk it sees. + const seen: string[] = [] + subscribePtyBuffer(key, (newChunks) => { + seen.push(...newChunks) + }) + + // Phase 3 — push enough chunks to force trim (pre + post > cap). + for (let i = 0; i < postSubscribeCount; i++) { + appendPtyChunk(key, `post-${i}`) + if (i % 500 === 0) flushRaf() + } + flushRaf() + + // The buffer was trimmed to the cap. + expect(getPtyChunks(key).length).toBe(MAX_PTY_BUFFER_CHUNKS) + + // Mid-stream listener saw exactly the post-subscribe chunks, in order, + // and zero pre-subscribe chunks — even though the canonical buffer was + // trimmed during the burst. + expect(seen.length).toBe(postSubscribeCount) + expect(seen[0]).toBe('post-0') + expect(seen[seen.length - 1]).toBe(`post-${postSubscribeCount - 1}`) + for (const chunk of seen) { + expect(chunk.startsWith('post-')).toBe(true) + } + }) + + it('clearPtyBuffer cancels in-flight flushes under load — no stale data fires after clear', () => { + const key = 'stress-clear-cancel' + testKeys.push(key) + + const tails: string[][] = [] + subscribePtyBuffer(key, (newChunks) => { + tails.push(newChunks) + }) + + // Stage chunks then clear before the rAF fires. + for (let i = 0; i < 500; i++) { + appendPtyChunk(key, `staged-${i}`) + } + expect(tails.length).toBe(0) // not yet flushed + + clearPtyBuffer(key) + // The clear callback fires synchronously with an empty tail. + expect(tails.length).toBe(1) + expect(tails[0]).toEqual([]) + + // Advance time — the cancelled rAF must not fire stale data. + vi.advanceTimersByTime(1_000) + expect(tails.length).toBe(1) + expect(getPtyChunks(key)).toEqual([]) + + // And the listener still works after clear — new appends flow through. + for (let i = 0; i < 10; i++) { + appendPtyChunk(key, `fresh-${i}`) + } + flushRaf() + expect(tails.length).toBe(2) + expect(tails[1]).toHaveLength(10) + expect(tails[1]![0]).toBe('fresh-0') + }) + + it('an always-throwing listener does not block delivery to well-behaved siblings under load', () => { + const key = 'stress-throw-isolation' + testKeys.push(key) + + const bad = vi.fn(() => { throw new Error('boom') }) + let goodCount = 0 + subscribePtyBuffer(key, bad) + subscribePtyBuffer(key, (newChunks) => { + goodCount += newChunks.length + }) + + const chunkCount = 2_000 + for (let i = 0; i < chunkCount; i++) { + appendPtyChunk(key, `chunk-${i}`) + if (i % 100 === 0) flushRaf() + } + flushRaf() + + expect(goodCount).toBe(chunkCount) + // The bad listener was called every flush (its throws were caught and + // logged via console.error, which we've stubbed). + expect(bad.mock.calls.length).toBeGreaterThan(0) + expect(errSpy).toHaveBeenCalled() + }) + + it('flushPtyChunksNow drains pending immediately and prevents a duplicate rAF fire', () => { + const key = 'stress-flush-now' + testKeys.push(key) + const tails: string[][] = [] + subscribePtyBuffer(key, (newChunks) => { + tails.push(newChunks) + }) + + // 100 staged chunks across the burst, draining synchronously each batch. + for (let burst = 0; burst < 10; burst++) { + for (let i = 0; i < 10; i++) { + appendPtyChunk(key, `b${burst}-i${i}`) + } + flushPtyChunksNow(key) + // Advancing time MUST NOT fire a second flush for the same chunks. + vi.advanceTimersByTime(50) + } + + expect(tails.length).toBe(10) + for (const tail of tails) { + expect(tail.length).toBe(10) + } + expect(getPtyChunks(key).length).toBe(100) + }) +}) diff --git a/src/renderer/src/stores/pty-buffer-store.test.ts b/src/renderer/src/stores/pty-buffer-store.test.ts new file mode 100644 index 00000000..fbd3f78a --- /dev/null +++ b/src/renderer/src/stores/pty-buffer-store.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + appendPtyChunk, + clearPtyBuffer, + flushPtyChunksNow, + getPtyChunks, + subscribePtyBuffer +} from './pty-buffer-store' + +// rAF in the store falls back to setTimeout(_, 16) when requestAnimationFrame +// isn't on globalThis. The tests run a fake-timer schedule so we can drive +// the flush deterministically without a real animation frame. +function flushRaf(): void { + vi.advanceTimersByTime(20) +} + +describe('pty-buffer-store', () => { + beforeEach(() => { + vi.useFakeTimers() + // Reset any state from earlier tests by clearing every key we use. + for (const key of ['k1', 'k2', 'k3', 'k-throw']) clearPtyBuffer(key) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('coalesces multiple appendPtyChunk calls into a single per-frame flush', () => { + const listener = vi.fn() + subscribePtyBuffer('k1', listener) + + appendPtyChunk('k1', 'A') + appendPtyChunk('k1', 'B') + appendPtyChunk('k1', 'C') + expect(listener).not.toHaveBeenCalled() + + flushRaf() + expect(listener).toHaveBeenCalledTimes(1) + // Tail-only semantics: the listener receives just the new chunks. + expect(listener).toHaveBeenCalledWith(['A', 'B', 'C']) + }) + + it('notifies listeners with only the new tail, not the full buffer history', () => { + const listener = vi.fn() + subscribePtyBuffer('k2', listener) + + appendPtyChunk('k2', 'A') + flushRaf() + listener.mockClear() + + appendPtyChunk('k2', 'B') + appendPtyChunk('k2', 'C') + flushRaf() + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith(['B', 'C']) + + // The canonical buffer still contains the full history. + expect(getPtyChunks('k2')).toEqual(['A', 'B', 'C']) + }) + + it('clearPtyBuffer cancels a pending flush and notifies subscribers with an empty tail', () => { + const listener = vi.fn() + subscribePtyBuffer('k3', listener) + + appendPtyChunk('k3', 'will-not-flush') + expect(listener).not.toHaveBeenCalled() + + clearPtyBuffer('k3') + // The flush is cancelled — only the clear notification fired. + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith([]) + + flushRaf() + // Advancing the timer does NOT fire a stale flush — the rAF was cancelled. + expect(listener).toHaveBeenCalledTimes(1) + expect(getPtyChunks('k3')).toEqual([]) + }) + + it('flushPtyChunksNow drains pending chunks synchronously', () => { + const listener = vi.fn() + subscribePtyBuffer('k1', listener) + + appendPtyChunk('k1', 'sync-drain') + expect(listener).not.toHaveBeenCalled() + + flushPtyChunksNow('k1') + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith(['sync-drain']) + expect(getPtyChunks('k1')).toEqual(['sync-drain']) + + flushRaf() + // The scheduled rAF was cancelled by flushPtyChunksNow — no duplicate + // flush fires (the snapshot-vs-replay duplicate-text class). + expect(listener).toHaveBeenCalledTimes(1) + }) + + it('a listener that throws does not abort delivery to siblings or escape the rAF', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const bad = vi.fn(() => { throw new Error('boom') }) + const good = vi.fn() + subscribePtyBuffer('k-throw', bad) + subscribePtyBuffer('k-throw', good) + + appendPtyChunk('k-throw', 'x') + expect(() => flushRaf()).not.toThrow() + + expect(bad).toHaveBeenCalledTimes(1) + expect(good).toHaveBeenCalledTimes(1) + expect(good).toHaveBeenCalledWith(['x']) + errSpy.mockRestore() + }) + + it('subscribePtyBuffer returns an unsubscribe that stops further deliveries', () => { + const listener = vi.fn() + const unsub = subscribePtyBuffer('k1', listener) + + appendPtyChunk('k1', 'first') + flushRaf() + expect(listener).toHaveBeenCalledTimes(1) + + unsub() + appendPtyChunk('k1', 'second') + flushRaf() + expect(listener).toHaveBeenCalledTimes(1) + // But the canonical buffer still accumulates regardless of subscribers. + expect(getPtyChunks('k1')).toEqual(['first', 'second']) + }) + + it('duplicate appendPtyChunk replay arrives once at the listener per chunk', () => { + // AGENTS.md: "Add regression tests when touching PTY buffering. Include + // duplicate/replay cases." Renderer-side guarantee is: every appendPtyChunk + // call adds exactly one chunk to the buffer; if the broker sends the same + // chunk twice the listener sees two distinct entries — dedup is the + // broker/main's responsibility, NOT the renderer buffer's. + const listener = vi.fn() + subscribePtyBuffer('k1', listener) + + appendPtyChunk('k1', 'dup') + appendPtyChunk('k1', 'dup') + flushRaf() + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith(['dup', 'dup']) + expect(getPtyChunks('k1')).toEqual(['dup', 'dup']) + }) +}) diff --git a/src/renderer/src/stores/pty-buffer-store.ts b/src/renderer/src/stores/pty-buffer-store.ts index 3f56a037..ce4a5aae 100644 --- a/src/renderer/src/stores/pty-buffer-store.ts +++ b/src/renderer/src/stores/pty-buffer-store.ts @@ -2,42 +2,143 @@ // worker_stream events don't force a re-render of every component subscribed // to the agents array. Subscribers register against a single agent key and // receive only that agent's chunks. +// +// Incoming bytes from the broker arrive at sub-frame granularity. Notifying +// listeners synchronously per chunk means each chunk triggers a synchronous +// `term.write()` and, in some subscribers, a React state update — large +// allocations + per-byte work that pegs the renderer during streaming. +// Instead, we stage incoming chunks per key and flush once per animation +// frame, so subscribers see at most one notification (with the full history) +// per frame. const MAX_PTY_BUFFER_CHUNKS = 10_000 -type Listener = (chunks: string[]) => void +// Listeners receive only the newly-added chunks (the "tail"), not the full +// buffer. This sidesteps the 10k trim case where a subscriber holding an +// older buffer length would slice past the end of a trimmed window and +// drop the freshly-added chunks. Tail semantics also keep per-flush work +// proportional to the new data, not the buffer size. +type Listener = (newChunks: string[]) => void const buffers = new Map() const listeners = new Map>() -export function getPtyChunks(key: string): string[] { - return buffers.get(key) ?? [] +// Chunks staged for the next animation frame, keyed by agent key. +const pending = new Map() +// Scheduled rAF handles per key so we can cancel on clear/dispose. +const pendingFrames = new Map() + +const raf: (cb: FrameRequestCallback) => number = + typeof requestAnimationFrame === 'function' + ? requestAnimationFrame + : ((cb: FrameRequestCallback) => setTimeout(() => cb(performance.now()), 16) as unknown as number) + +const cancelRaf: (handle: number) => void = + typeof cancelAnimationFrame === 'function' + ? cancelAnimationFrame + : ((handle: number) => clearTimeout(handle as unknown as ReturnType)) + +function cancelPendingFlush(key: string): void { + const handle = pendingFrames.get(key) + if (handle !== undefined) { + cancelRaf(handle) + pendingFrames.delete(key) + } + pending.delete(key) } -export function appendPtyChunk(key: string, chunk: string): void { - // Always allocate a fresh array so React subscribers (e.g. AgentNode's - // useState) don't bail out on Object.is reference equality and freeze - // the preview tile after the first chunk. +function flushPending(key: string): void { + pendingFrames.delete(key) + const queued = pending.get(key) + pending.delete(key) + if (!queued || queued.length === 0) return + const existing = buffers.get(key) ?? [] - const trimmed = existing.length >= MAX_PTY_BUFFER_CHUNKS - ? existing.slice(existing.length - MAX_PTY_BUFFER_CHUNKS + 1) - : existing - const next = [...trimmed, chunk] - buffers.set(key, next) + const combined = existing.concat(queued) + const trimmed = combined.length > MAX_PTY_BUFFER_CHUNKS + ? combined.slice(combined.length - MAX_PTY_BUFFER_CHUNKS) + : combined + buffers.set(key, trimmed) const keyListeners = listeners.get(key) if (!keyListeners || keyListeners.size === 0) return - for (const listener of keyListeners) { - listener(next) + for (const listener of [...keyListeners]) { + try { + listener(queued) + } catch (err) { + console.error('[pty-buffer-store] listener threw', err) + } + } +} + +export function getPtyChunks(key: string): string[] { + return buffers.get(key) ?? [] +} + +// Synchronously drain any chunks staged for the next rAF into the buffer. +// Used by the terminal runtime right before reading the buffer length as a +// snapshot baseline — otherwise pending chunks would be replayed on top of +// the snapshot we just wrote. +export function flushPtyChunksNow(key: string): void { + const handle = pendingFrames.get(key) + if (handle !== undefined) { + cancelRaf(handle) + pendingFrames.delete(key) + } + if (pending.has(key)) { + flushPending(key) + } +} + +// Optional diagnostic — enable by running this in DevTools console: +// localStorage.setItem('PEAR_DIAG_PTY', '1'); location.reload() +// Disable by removing the key. Off by default so production renderers +// don't pay the per-chunk console.log cost. +let __diagPtyChecked = false +let __diagPtyEnabled = false +export function diagPtyEnabled(): boolean { + if (__diagPtyChecked) return __diagPtyEnabled + __diagPtyChecked = true + try { + __diagPtyEnabled = typeof localStorage !== 'undefined' && localStorage.getItem('PEAR_DIAG_PTY') === '1' + } catch { + __diagPtyEnabled = false + } + return __diagPtyEnabled +} +let __appendSeq = 0 +function __previewChunk(chunk: string): string { + return chunk.slice(0, 80).replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\x1b/g, '\\e') +} + +export function appendPtyChunk(key: string, chunk: string): void { + if (diagPtyEnabled()) { + __appendSeq += 1 + // eslint-disable-next-line no-console + console.log(`[diag:pty-append] #${__appendSeq} key=${key} bytes=${chunk.length} preview="${__previewChunk(chunk)}"`) + } + const queue = pending.get(key) + if (queue) { + queue.push(chunk) + } else { + pending.set(key, [chunk]) } + if (pendingFrames.has(key)) return + const handle = raf(() => flushPending(key)) + pendingFrames.set(key, handle) } export function clearPtyBuffer(key: string): void { + cancelPendingFlush(key) buffers.delete(key) const keyListeners = listeners.get(key) if (keyListeners) { - for (const listener of keyListeners) { - listener([]) + for (const listener of [...keyListeners]) { + try { + listener([]) + } catch (err) { + console.error('[pty-buffer-store] listener threw', err) + } } } } diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index aa229253..8cd91606 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -76,11 +76,10 @@ .project-switcher-trigger { background: linear-gradient(180deg, - color-mix(in srgb, var(--pear-bg-surface) 82%, rgba(255, 255, 255, 0.05)) 0%, - color-mix(in srgb, var(--pear-bg-raised) 82%, var(--pear-bg) 18%) 100%); + color-mix(in srgb, var(--pear-bg-surface) 96%, rgba(255, 255, 255, 0.05)) 0%, + color-mix(in srgb, var(--pear-bg-raised) 96%, var(--pear-bg) 18%) 100%); border-bottom: 1px solid color-mix(in srgb, var(--pear-border) 74%, transparent); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045); - backdrop-filter: blur(18px) saturate(1.16); } .project-switcher-trigger:hover, @@ -101,13 +100,12 @@ color-mix(in srgb, var(--pear-accent) 12%, transparent), transparent 42%), linear-gradient(180deg, - color-mix(in srgb, var(--pear-bg-surface) 86%, rgba(255, 255, 255, 0.06)) 0%, - color-mix(in srgb, var(--pear-bg) 84%, var(--pear-bg-surface) 16%) 100%); + color-mix(in srgb, var(--pear-bg-surface) 98%, rgba(255, 255, 255, 0.06)) 0%, + color-mix(in srgb, var(--pear-bg) 98%, var(--pear-bg-surface) 16%) 100%); border-bottom: 1px solid color-mix(in srgb, var(--pear-border) 86%, transparent); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 18px 50px rgba(0, 0, 0, 0.35); - backdrop-filter: blur(22px) saturate(1.22); } .project-switcher-panel { @@ -116,10 +114,9 @@ color-mix(in srgb, var(--pear-accent) 8%, transparent), transparent 44%), linear-gradient(180deg, - color-mix(in srgb, var(--pear-bg-surface) 82%, rgba(255, 255, 255, 0.04)) 0%, - color-mix(in srgb, var(--pear-bg) 86%, var(--pear-bg-surface) 14%) 100%); + color-mix(in srgb, var(--pear-bg-surface) 98%, rgba(255, 255, 255, 0.04)) 0%, + color-mix(in srgb, var(--pear-bg) 98%, var(--pear-bg-surface) 14%) 100%); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); - backdrop-filter: blur(18px) saturate(1.14); } } diff --git a/vitest.config.mjs b/vitest.config.mjs index af2980ee..8e5aeb69 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -1,14 +1,43 @@ import { resolve } from 'node:path' +const sharedAlias = { + '@': resolve('src/renderer/src'), + '@shared': resolve('src/shared') +} + export default { - resolve: { - alias: { - '@': resolve('src/renderer/src'), - '@shared': resolve('src/shared') - } - }, + resolve: { alias: sharedAlias }, test: { - include: ['src/main/**/*.test.ts', 'src/renderer/src/**/*.test.ts', 'packages/**/*.test.ts'], - exclude: ['**/node_modules/**', '**/dist/**', '**/out/**', 'src/main/__tests__/**'] + projects: [ + { + resolve: { alias: sharedAlias }, + test: { + name: 'node', + environment: 'node', + include: [ + 'src/main/**/*.test.ts', + 'src/renderer/src/**/*.test.ts', + 'packages/**/*.test.ts' + ], + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/out/**', + 'src/main/__tests__/**', + '**/*.dom.test.ts' + ] + } + }, + { + resolve: { alias: sharedAlias }, + test: { + name: 'dom', + environment: 'happy-dom', + setupFiles: ['src/renderer/src/__test__/dom-setup.ts'], + include: ['src/renderer/src/**/*.dom.test.ts'], + exclude: ['**/node_modules/**', '**/dist/**', '**/out/**'] + } + } + ] } }