From d3f86e4037c5e008f8817a44393e7e9431d62dda Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 16:00:15 +0000 Subject: [PATCH 01/80] Add a manual unsigned build workflow Builds macOS (Apple Silicon) dmg, Linux deb and AppImage, and a Windows NSIS installer without release secrets, and uploads them as run artifacts. Runs on workflow_dispatch and on pull requests that change the workflow. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014cSY6H6y99xe99WMeG5eNE --- .github/workflows/build.yml | 77 +++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..8dffb63f38 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,77 @@ +name: Build + +# Unsigned test builds for every desktop platform, downloadable from the run's +# artifacts. Release signing, notarization and the updater feed stay in +# release.yml, so this needs no secrets. +on: + workflow_dispatch: + pull_request: + paths: + - .github/workflows/build.yml + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: macOS (Apple Silicon) + os: macos-latest + artifact: monocode-macos-arm64 + bundles: app,dmg + paths: | + target/release/bundle/dmg/*.dmg + - name: Linux + os: ubuntu-22.04 + artifact: monocode-linux-x64 + bundles: deb,appimage + paths: | + target/release/bundle/deb/*.deb + target/release/bundle/appimage/*.AppImage + - name: Windows + os: windows-latest + artifact: monocode-windows-x64 + bundles: nsis + paths: | + target/release/bundle/nsis/*.exe + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - uses: dtolnay/rust-toolchain@stable + + - name: Install Linux system dependencies + if: runner.os == 'Linux' + run: bash scripts/install-linux-deps-debian.sh + + - run: npm ci + + # Updater artifacts need the release signing key; test builds skip them. + - name: Build + shell: bash + run: | + set -euo pipefail + echo '{"bundle":{"createUpdaterArtifacts":false}}' > "$RUNNER_TEMP/tauri.build.conf.json" + npx tauri build --bundles "${{ matrix.bundles }}" --config "$RUNNER_TEMP/tauri.build.conf.json" + if [[ "$RUNNER_OS" == "Linux" ]]; then bash scripts/repack-appimage.sh; fi + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: ${{ matrix.paths }} + if-no-files-found: error From 278daa0b370de5ae7e4db589307e286d1999da7c Mon Sep 17 00:00:00 2001 From: Nick <60738984+hardbeat920@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:09:51 +0100 Subject: [PATCH 02/80] Fix tab drag pane drops and fast-release reordering - Require a deliberate pull below the tab bar before a dragged tab targets a pane, so sideways drags that sag no longer drop onto panes - Keep a fast drag's drop when WebKit reports no buttons on the last moves before pointerup, using a short grace timer instead of cancelling immediately - Add browser specs and a fixture covering slow and fast drags, back-to-back drags, re-renders during drag, and pane placement --- src/app/shell/TitleBar.tsx | 32 ++++-- src/shared/hooks/useAnimatedReorder.test.ts | 24 +++++ src/shared/hooks/useAnimatedReorder.ts | 22 +++- tests/browser/title-tab-reorder.html | 11 ++ tests/browser/title-tab-reorder.spec.ts | 106 ++++++++++++++++++++ tests/browser/title-tab-reorder.tsx | 60 +++++++++++ 6 files changed, 245 insertions(+), 10 deletions(-) create mode 100644 tests/browser/title-tab-reorder.html create mode 100644 tests/browser/title-tab-reorder.spec.ts create mode 100644 tests/browser/title-tab-reorder.tsx diff --git a/src/app/shell/TitleBar.tsx b/src/app/shell/TitleBar.tsx index 77a783a803..cd44a8d01e 100644 --- a/src/app/shell/TitleBar.tsx +++ b/src/app/shell/TitleBar.tsx @@ -88,6 +88,9 @@ export type Tab = { previewFileId?: string; }; +/** How far below the tab bar a dragged tab must go before it targets a pane. */ +const PANE_DROP_PULL = 32; + type Props = { tabs: Tab[]; activeId: string; @@ -646,12 +649,24 @@ function TitleBarComponent({ }: Props) { const tabIds = tabs.map((tab) => tab.id); const { displayed, setTabNode, finishMotion } = useTabCloseMotion(tabs); + const tabStripRef = useRef(null); + const paneDropArmed = useRef(false); const externalTabDrop = useMemo | undefined>( - () => - onPlaceOnPane + () => { + // A sideways drag sags, so only a deliberate pull below the tab bar + // targets a pane. Once armed it stays armed until the pointer is back + // over the tabs. + const pulledBelowTabs = (event: PointerEvent) => { + const bottom = tabStripRef.current?.getBoundingClientRect().bottom ?? 0; + if (event.clientY <= bottom) paneDropArmed.current = false; + else if (event.clientY >= bottom + PANE_DROP_PULL) + paneDropArmed.current = true; + return paneDropArmed.current; + }; + return onPlaceOnPane ? { onMove: (tabId, event) => { - if (tabId === activeId) { + if (tabId === activeId || !pulledBelowTabs(event)) { setExternalPaneDrop(null); return false; } @@ -664,21 +679,24 @@ function TitleBarComponent({ return over != null; }, onDrop: (tabId, event) => { - if (tabId === activeId) return false; + if (tabId === activeId || !pulledBelowTabs(event)) return false; const over = paneDropFromPoint(event.clientX, event.clientY); if (!over) return false; onPlaceOnPane(tabId, over.id, over.edge); return true; }, - onEnd: () => setExternalPaneDrop(null), + onEnd: () => { + paneDropArmed.current = false; + setExternalPaneDrop(null); + }, } - : undefined, + : undefined; + }, [activeId, onPlaceOnPane], ); const sortable = useAnimatedReorder(tabIds, onReorder, "x", externalTabDrop); const paneToTabDrop = useExternalTitleTabDrop(); const lockOverscroll = useLockOverscroll(); - const tabStripRef = useRef(null); const setTabStripRef = useCallback( (el: HTMLDivElement | null) => { tabStripRef.current = el; diff --git a/src/shared/hooks/useAnimatedReorder.test.ts b/src/shared/hooks/useAnimatedReorder.test.ts index 14e18e6ac1..830ae05058 100644 --- a/src/shared/hooks/useAnimatedReorder.test.ts +++ b/src/shared/hooks/useAnimatedReorder.test.ts @@ -176,6 +176,30 @@ describe("workspace tab gestures", () => { expect(onReorder).not.toHaveBeenCalled(); }); + it("still drops a fast drag whose last moves report no button just before pointerup", () => { + const { press, onReorder } = setup(); + press(); + pointer("pointermove", 180); + // WebKit reads the live button state, so moves queued behind a quick + // release arrive with no buttons ahead of the pointerup. + browser.dispatchEvent( + Object.assign(new Event("pointermove"), { + clientX: 250, + clientY: 250, + pointerId: 1, + pointerType: "mouse", + buttons: 0, + }), + ); + pointer("pointerup", 250); + vi.runAllTimers(); + + expect(onReorder).toHaveBeenCalledExactlyOnceWith( + ["changes", "explorer", "sessions"], + "sessions", + ); + }); + it("previews and reorders vertical project rows", () => { const { press, tabs, onReorder } = setup(false, "y"); press(); diff --git a/src/shared/hooks/useAnimatedReorder.ts b/src/shared/hooks/useAnimatedReorder.ts index 5d2b2a9cc2..2d6e5fc451 100644 --- a/src/shared/hooks/useAnimatedReorder.ts +++ b/src/shared/hooks/useAnimatedReorder.ts @@ -18,6 +18,9 @@ export type ReorderExternalDrop = { onEnd?: (id: T) => void; }; +/** How long a button-less move waits for a late pointerup before cancelling. */ +const LOST_POINTERUP_GRACE = 150; + /** Direct manipulation for a row or column of equal-sized items. */ export function useAnimatedReorder( ids: T[], @@ -94,6 +97,7 @@ export function useAnimatedReorder( let pointerPosition = startPosition; let overExternalTarget = false; let finishTimer: ReturnType | undefined; + let lostPointerUpTimer: ReturnType | undefined; function releasePointer() { for (const element of tabs) delete element.dataset.reordering; @@ -116,6 +120,7 @@ export function useAnimatedReorder( window.removeEventListener("blur", onCancel); window.removeEventListener("scroll", onScroll, true); window.clearTimeout(finishTimer); + window.clearTimeout(lostPointerUpTimer); window.cancelAnimationFrame(frame); for (const element of tabs) { element.style.removeProperty("transition"); @@ -182,14 +187,23 @@ export function useAnimatedReorder( function onMove(ev: globalThis.PointerEvent) { if (ev.pointerId !== pointerId || settling) return; // The button is already up but its pointerup never reached us: drop - // the drag instead of leaving the item stuck to the cursor. + // the drag instead of leaving the item stuck to the cursor. WebKit + // reads the live button state, so a quick release also reports no + // buttons on the moves queued ahead of its pointerup; give that + // pointerup a moment to arrive before giving up. if ( ev.type === "pointermove" && ev.pointerType === "mouse" && ev.buttons === 0 ) { - stop(false); - return; + if (!lostPointerUpTimer) + lostPointerUpTimer = setTimeout( + () => stop(false), + LOST_POINTERUP_GRACE, + ); + } else if (ev.type === "pointermove") { + window.clearTimeout(lostPointerUpTimer); + lostPointerUpTimer = undefined; } pointerPosition = ev[coordinate]; if (!active) { @@ -220,6 +234,8 @@ export function useAnimatedReorder( function stop(commit: boolean, event?: globalThis.PointerEvent) { if (settling) return; + window.clearTimeout(lostPointerUpTimer); + lostPointerUpTimer = undefined; if (!active) { reset(); return; diff --git a/tests/browser/title-tab-reorder.html b/tests/browser/title-tab-reorder.html new file mode 100644 index 0000000000..19faa4d1e2 --- /dev/null +++ b/tests/browser/title-tab-reorder.html @@ -0,0 +1,11 @@ + + + + + Title tab reorder fixture + + +
+ + + diff --git a/tests/browser/title-tab-reorder.spec.ts b/tests/browser/title-tab-reorder.spec.ts new file mode 100644 index 0000000000..a02a3d40d6 --- /dev/null +++ b/tests/browser/title-tab-reorder.spec.ts @@ -0,0 +1,106 @@ +import { expect, test, type Page } from "@playwright/test"; + +type Fixture = Window & { + order: string[]; + placed: string[]; + startTicking: () => void; +}; + +const order = (page: Page) => + page.evaluate(() => (window as unknown as Fixture).order.join("")); + +const placed = (page: Page) => + page.evaluate(() => (window as unknown as Fixture).placed); + +async function center(page: Page, id: string) { + const box = await page.locator(`[data-title-tab-id="${id}"]`).boundingBox(); + if (!box) throw new Error(`no tab ${id}`); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +} + +async function drag( + page: Page, + fromId: string, + toId: string, + { steps = 12, settle = 400, sag = 0 } = {}, +) { + const from = await center(page, fromId); + const to = await center(page, toId); + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move(to.x, to.y + sag, { steps }); + await page.mouse.up(); + if (settle) await page.waitForTimeout(settle); +} + +test.beforeEach(async ({ page }) => { + await page.goto("/tests/browser/title-tab-reorder.html"); + await expect(page.locator("[data-title-tab-id]")).toHaveCount(5); +}); + +test("slow drag reorders", async ({ page }) => { + await drag(page, "a", "c", { steps: 30 }); + expect(await order(page)).toBe("bcade"); +}); + +test("fast flick reorders", async ({ page }) => { + await drag(page, "a", "d", { steps: 2 }); + expect(await order(page)).toBe("bcdae"); +}); + +test("back-to-back drags both land", async ({ page }) => { + await drag(page, "a", "c", { settle: 0 }); + await drag(page, "e", "a", { settle: 0 }); + await page.waitForTimeout(400); + expect(await order(page)).toBe("ebcad"); +}); + +test("drag while the title bar re-renders", async ({ page }) => { + await page.evaluate(() => (window as unknown as Fixture).startTicking()); + await drag(page, "a", "c", { steps: 30 }); + expect(await order(page)).toBe("bcade"); +}); + +test("an inactive tab that sags below the title bar still reorders", async ({ + page, +}) => { + await drag(page, "b", "d", { steps: 20, sag: 25 }); + expect(await order(page)).toBe("acdbe"); + expect(await placed(page)).toEqual([]); +}); + +test("pulling an inactive tab well below the title bar places it on a pane", async ({ + page, +}) => { + await drag(page, "b", "d", { steps: 20, sag: 120 }); + expect(await order(page)).toBe("abcde"); + expect(await placed(page)).toEqual(["b"]); +}); + +test("a fast release whose last moves report no button still drops", async ({ + page, +}) => { + const from = await center(page, "a"); + const to = await center(page, "d"); + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move(to.x, to.y, { steps: 2 }); + // macOS WebKit stamps queued moves with the live button state, so a quick + // release delivers them button-less just ahead of the pointerup. + await page.evaluate( + ({ x, y }) => + window.dispatchEvent( + new PointerEvent("pointermove", { + clientX: x, + clientY: y, + pointerId: 1, + pointerType: "mouse", + buttons: 0, + }), + ), + to, + ); + await page.mouse.up(); + await page.waitForTimeout(400); + expect(await order(page)).toBe("bcdae"); +}); diff --git a/tests/browser/title-tab-reorder.tsx b/tests/browser/title-tab-reorder.tsx new file mode 100644 index 0000000000..787443b52f --- /dev/null +++ b/tests/browser/title-tab-reorder.tsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { TitleBar, type Tab } from "../../src/app/shell/TitleBar"; +import "../../src/styles/index.css"; + +const tab = (id: string): Tab => ({ + id, + project: "agent-terminal", + title: `Tab ${id}`, + more: [], + sessionCount: 1, + harnesses: ["claude"], + busyHarnesses: [], + files: [], +}); + +function Fixture() { + const [ids, setIds] = useState(["a", "b", "c", "d", "e"]); + const [activeId, setActiveId] = useState("a"); + const [tick, setTick] = useState(0); + const [ticking, setTicking] = useState(false); + useEffect(() => { + Object.assign(window, { + order: ids, + activeId, + startTicking: () => setTicking(true), + }); + }, [ids, activeId]); + // Like App: busy agents re-render the title bar with fresh tab objects. + useEffect(() => { + if (!ticking) return; + const timer = setInterval(() => setTick((t) => t + 1), 50); + return () => clearInterval(timer); + }, [ticking]); + const tabs = ids.map((id) => ({ + ...tab(id), + busyHarnesses: tick % 2 ? (["claude"] as Tab["harnesses"]) : [], + })); + return ( + <> + {}} + onSelect={setActiveId} + onClose={(id) => setIds((list) => list.filter((x) => x !== id))} + onCloseMany={() => {}} + onReorder={(next) => setIds(next)} + onPlaceOnPane={(id) => { + (window as unknown as { placed: string[] }).placed.push(id); + }} + /> +
+ + ); +} + +Object.assign(window, { placed: [] }); +createRoot(document.getElementById("root")!).render(); From 08c1fdcf0b7bb8f36bc09ec34d2286c0bff1f70f Mon Sep 17 00:00:00 2001 From: Nick <60738984+hardbeat920@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:01:28 +0100 Subject: [PATCH 03/80] Stabilize Linux title tab reorder test - Mock native window IPC and exercise Linux title bar controls - Use a wider viewport and responsive fixture sizing --- tests/browser/title-tab-reorder.html | 2 +- tests/browser/title-tab-reorder.spec.ts | 7 +++++++ tests/browser/title-tab-reorder.tsx | 12 ++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/browser/title-tab-reorder.html b/tests/browser/title-tab-reorder.html index 19faa4d1e2..1bb1c97477 100644 --- a/tests/browser/title-tab-reorder.html +++ b/tests/browser/title-tab-reorder.html @@ -5,7 +5,7 @@ Title tab reorder fixture -
+
diff --git a/tests/browser/title-tab-reorder.spec.ts b/tests/browser/title-tab-reorder.spec.ts index a02a3d40d6..171e887f60 100644 --- a/tests/browser/title-tab-reorder.spec.ts +++ b/tests/browser/title-tab-reorder.spec.ts @@ -1,5 +1,8 @@ import { expect, test, type Page } from "@playwright/test"; +// Leave room for all five tabs beside Linux's title and window controls. +test.use({ viewport: { width: 1440, height: 800 } }); + type Fixture = Window & { order: string[]; placed: string[]; @@ -34,6 +37,10 @@ async function drag( } test.beforeEach(async ({ page }) => { + // Exercise the Linux CI window-controls path even when running on macOS. + await page.addInitScript(() => { + Object.defineProperty(navigator, "platform", { value: "Linux x86_64" }); + }); await page.goto("/tests/browser/title-tab-reorder.html"); await expect(page.locator("[data-title-tab-id]")).toHaveCount(5); }); diff --git a/tests/browser/title-tab-reorder.tsx b/tests/browser/title-tab-reorder.tsx index 787443b52f..48191726a5 100644 --- a/tests/browser/title-tab-reorder.tsx +++ b/tests/browser/title-tab-reorder.tsx @@ -1,8 +1,20 @@ import { useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; +import { mockIPC, mockWindows } from "@tauri-apps/api/mocks"; import { TitleBar, type Tab } from "../../src/app/shell/TitleBar"; import "../../src/styles/index.css"; +// Keep the real title bar and window controls, replacing only native IPC. +mockWindows("main"); +mockIPC( + (command) => { + if (command === "plugin:window|is_maximized") return false; + if (command === "plugin:window|set_title") return; + throw new Error(`Unexpected command: ${command}`); + }, + { shouldMockEvents: true }, +); + const tab = (id: string): Tab => ({ id, project: "agent-terminal", From 642d330d8390c93d015f84ed57e1c95ec43d25b4 Mon Sep 17 00:00:00 2001 From: Nick <60738984+hardbeat920@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:39:52 +0100 Subject: [PATCH 04/80] Stream chat replies as they arrive with cadence-timed fades - Show streamed text on arrival by default; the paced word-at-a-time reveal is now opt-in via localStorage["monocode.pacedReveal"] = "1". - Fade length follows the average gap between bursts (120-400ms). - Follow bottom growth with a velocity spring that feeds forward the stream's speed, so updates glide instead of jolting. - Respect entrance animation settings and hold follow motion during an ambiguous wheel gesture. - Don't scroll a nested code block's wheel input out of the transcript follow state. - Clip the transcript content so in-flight follow offsets don't affect scrollHeight. --- .../hooks/useBottomChatMotion.test.ts | 39 +++- .../sessions/hooks/useBottomChatMotion.ts | 193 +++++++++++++++--- src/features/sessions/ui/AgentMarkdown.tsx | 59 ++++-- .../ui/AgentTranscript.inlineWork.test.ts | 4 +- src/features/sessions/ui/AgentTranscript.tsx | 21 +- .../sessions/ui/AgentTranscriptPacing.test.ts | 23 ++- src/features/sessions/ui/wordFade.test.ts | 124 ++++++++++- src/features/sessions/ui/wordFade.tsx | 131 +++++++++++- src/styles/index.css | 6 +- 9 files changed, 531 insertions(+), 69 deletions(-) diff --git a/src/features/sessions/hooks/useBottomChatMotion.test.ts b/src/features/sessions/hooks/useBottomChatMotion.test.ts index 051618d878..0e30de3e87 100644 --- a/src/features/sessions/hooks/useBottomChatMotion.test.ts +++ b/src/features/sessions/hooks/useBottomChatMotion.test.ts @@ -3,7 +3,11 @@ import { act, createElement, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Block } from "../model/session"; -import { useBottomChatMotion } from "./useBottomChatMotion"; +import { + followSpring, + stepFollowSpring, + useBottomChatMotion, +} from "./useBottomChatMotion"; let container: HTMLDivElement; let root: Root; @@ -464,3 +468,36 @@ describe("bottom chat motion", () => { ); }); }); + +describe("follow spring", () => { + it("settles a single jump without overshooting", () => { + const spring = followSpring(); + let offset = stepFollowSpring(spring, 60, 60, 1); + let previous = 60; + for (let frame = 0; frame < 40; frame++) { + expect(offset).toBeLessThanOrEqual(previous); + expect(offset).toBeGreaterThanOrEqual(0); + previous = offset; + offset = stepFollowSpring(spring, offset, 0, 1); + } + expect(offset).toBe(0); + }); + + it("glides at the stream's speed instead of jolting at each update", () => { + const spring = followSpring(); + let offset = 0; + const travelled: number[] = []; + // 12px every other frame: an update lands, then a frame with none. + for (let frame = 0; frame < 120; frame++) { + const grew = frame % 2 === 0 ? 12 : 0; + const before = offset + grew; + offset = stepFollowSpring(spring, before, grew, 1, 1); + if (frame >= 80) travelled.push(before - offset); + } + // Settled into a steady trail behind the end, moving every frame. + expect(offset).toBeGreaterThan(0); + expect(offset).toBeLessThanOrEqual(32); + expect(Math.min(...travelled)).toBeGreaterThan(3); + expect(Math.max(...travelled)).toBeLessThan(9); + }); +}); diff --git a/src/features/sessions/hooks/useBottomChatMotion.ts b/src/features/sessions/hooks/useBottomChatMotion.ts index cc3bc2935b..71399eafb4 100644 --- a/src/features/sessions/hooks/useBottomChatMotion.ts +++ b/src/features/sessions/hooks/useBottomChatMotion.ts @@ -1,8 +1,77 @@ import { useCallback, useLayoutEffect, useRef, type RefObject } from "react"; import type { Block } from "../model/session"; +import { innerScrollerTakes } from "../../../shared/hooks/useLockOverscroll"; const FOLLOW_TIME_MS = 85; const SETTLED_PX = 0.25; +/* + * Following streamed growth is a velocity spring with a feed-forward term + * Transcript updates land in bursts; easing each one out on its own moves the + * reply in jolts. The spring instead learns how fast the reply is growing and + * travels at that speed, so a steady stream reads as one continuous glide. + * Units are px per 60fps frame. + */ +/** Velocity kept from one frame to the next (higher glides longer). */ +const SPRING_DAMPING = 0.7; +/** Pull toward the end (higher is snappier). */ +const SPRING_STIFFNESS = 0.05; +/** Inertia (higher is slower to start and stop). */ +const SPRING_MASS = 1.25; +const SPRING_FRAME_MS = 1000 / 60; +/** Rate the growth estimate follows the observed growth. */ +const SPRING_GROWTH_EMA = 0.12; +/** + * While the reply grows, trail the end by up to this much rather than hug a + * moving edge. The transcript's bottom padding covers it. + */ +const SPRING_MAX_LEAD_PX = 32; +/** Keep the spring's state this long after it lands, so a pause in the stream resumes at speed. */ +const SPRING_SETTLE_GRACE_MS = 500; +/** Longest stretch of time one growth sample is spread over. */ +const SPRING_MAX_SAMPLE_FRAMES = 60; + +export type FollowSpring = { velocity: number; growth: number }; + +export function followSpring(): FollowSpring { + return { velocity: 0, growth: 0 }; +} + +/** + * Advance the spring over `frames` 60fps frames and return the distance still + * left to travel. `offset` is how far the content trails the end, `grew` how + * much the end moved over the last `sampleFrames`. Never overshoots the end. + */ +export function stepFollowSpring( + spring: FollowSpring, + offset: number, + grew: number, + frames: number, + sampleFrames = frames, +): number { + if (grew < -1) spring.growth = 0; + else { + const observed = Math.max(0, grew) / Math.max(0.25, sampleFrames); + spring.growth += SPRING_GROWTH_EMA * (observed - spring.growth); + } + const lead = Math.min(spring.growth * 9, SPRING_MAX_LEAD_PX); + let velocity = spring.velocity; + while (frames > 0 && offset > 0) { + const h = Math.min(1, frames); + frames -= h; + const diff = Math.max(0, offset - lead); + velocity += + h * + ((SPRING_DAMPING * velocity + SPRING_STIFFNESS * diff) / SPRING_MASS - + velocity); + offset = Math.max(0, offset - (velocity + spring.growth) * h); + } + spring.velocity = velocity; + return offset; +} + +function springIdle(spring: FollowSpring): boolean { + return spring.velocity < 0.05 && spring.growth < 0.05; +} /** * How long growth keeps easing after a reply ends. Its last words are still * revealed at pace, and the turn's footer settles, after `busy` clears. @@ -23,9 +92,21 @@ export function useBottomChatMotion( firstSend: boolean, animateGrowth = true, historicalBlockIds?: ReadonlySet, + /** Animate new messages in, as the chat layout does. */ + animateEntrances = true, + /** + * Until this time a wheel gesture of unknown direction may be scrolling + * off the main thread; pinning would snap it back to the end. + */ + holdUntil?: RefObject, ) { - const latest = useRef({ blocks, firstSend, historicalBlockIds }); - latest.current = { blocks, firstSend, historicalBlockIds }; + const latest = useRef({ + blocks, + firstSend, + historicalBlockIds, + animateEntrances, + }); + latest.current = { blocks, firstSend, historicalBlockIds, animateEntrances }; const update = useRef<(() => void) | null>(null); const introduced = useRef(false); const growthUntil = useRef(animateGrowth ? Infinity : 0); @@ -73,6 +154,11 @@ export function useBottomChatMotion( let offset = 0; let frame = 0; let lastFrame = 0; + let spring = followSpring(); + // Growth since the spring last stepped, the feed-forward's sample. + let grown = 0; + let lastStep = 0; + let settledAt: number | null = null; // Content coordinates exclude native scrolling and movement of the pane. // Only a layout change may contribute to the eased message motion. @@ -86,8 +172,19 @@ export function useBottomChatMotion( if (frame) cancelAnimationFrame(frame); frame = 0; offset = 0; + spring = followSpring(); + grown = 0; + settledAt = null; if (content.style.transform) content.style.removeProperty("transform"); }; + // Landed: stop drawing frames, but remember how fast the reply was going. + const settle = (now: number) => { + const kept = spring; + reset(); + spring = kept; + settledAt = now; + lastStep = now; + }; const tick = (now: number) => { frame = 0; if (!scroller.isConnected) { @@ -100,9 +197,27 @@ export function useBottomChatMotion( } const elapsed = Math.min(64, Math.max(0, now - lastFrame)); lastFrame = now; - offset *= Math.exp(-elapsed / FOLLOW_TIME_MS); + // Growth rides the spring. Shrinking (a fold closing at the end) has + // nothing to track and just eases out. + if (offset > 0) { + offset = stepFollowSpring( + spring, + offset, + grown, + elapsed / SPRING_FRAME_MS, + Math.min( + SPRING_MAX_SAMPLE_FRAMES, + Math.max(0, now - lastStep) / SPRING_FRAME_MS, + ), + ); + } else { + offset *= Math.exp(-elapsed / FOLLOW_TIME_MS); + } + grown = 0; + lastStep = now; if (Math.abs(offset) < SETTLED_PX) { - reset(); + if (offset > 0 || !springIdle(spring)) settle(now); + else reset(); return; } content.style.transform = `translateY(${offset}px)`; @@ -110,7 +225,31 @@ export function useBottomChatMotion( }; const measure = () => { if (!scroller.isConnected) return; + // Markdown can reflow several times between transcript updates. Only + // inspect message IDs when the blocks change, not on every resize. + let sentPrompt = false; + if ( + latest.current.blocks !== scannedBlocks || + latest.current.blocks.length !== scannedCount + ) { + scannedBlocks = latest.current.blocks; + scannedCount = scannedBlocks.length; + for (const block of scannedBlocks) { + if (seen.has(block.id)) continue; + seen.add(block.id); + if ( + (block.role === "user" || block.role === "assistant") && + !block.draft && + !latest.current.historicalBlockIds?.has(block.id) + ) { + entering.add(block.id); + if (block.role === "user") sentPrompt = true; + } + } + } + const { animateEntrances } = latest.current; const following = stickToBottom.current; + if (following && performance.now() < (holdUntil?.current ?? 0)) return; if (!following) { pause(); } else { @@ -134,41 +273,38 @@ export function useBottomChatMotion( wasFollowing = true; const animated = performance.now() < growthUntil.current; - if (resized || reducedMotion?.matches || !animated) { + // Without entrances the transcript brings a sent prompt in itself. + if ( + resized || + reducedMotion?.matches || + !animated || + (!animateEntrances && sentPrompt) + ) { reset(); } else { const limit = Math.max(0, height * 0.75); offset = Math.max(-limit, Math.min(limit, offset + shift)); + grown += shift; if (Math.abs(offset) >= SETTLED_PX) { content.style.transform = `translateY(${offset}px)`; if (!frame) { - lastFrame = performance.now(); + const now = performance.now(); + if ( + settledAt === null || + now - settledAt > SPRING_SETTLE_GRACE_MS + ) { + spring = followSpring(); + lastStep = now; + } + settledAt = null; + lastFrame = now; frame = requestAnimationFrame(tick); } } } } - // Markdown can reflow several times between transcript updates. Only - // inspect message IDs when the blocks change, not on every resize. - if ( - latest.current.blocks !== scannedBlocks || - latest.current.blocks.length !== scannedCount - ) { - scannedBlocks = latest.current.blocks; - scannedCount = scannedBlocks.length; - for (const block of scannedBlocks) { - if (seen.has(block.id)) continue; - seen.add(block.id); - if ( - (block.role === "user" || block.role === "assistant") && - !block.draft && - !latest.current.historicalBlockIds?.has(block.id) - ) - entering.add(block.id); - } - } - if (!following || reducedMotion?.matches) { + if (!following || reducedMotion?.matches || !animateEntrances) { entering.clear(); return; } @@ -219,7 +355,8 @@ export function useBottomChatMotion( if (visibleTop !== null) scroller.scrollTop = Math.max(0, visibleTop); }; const onWheel = (event: WheelEvent) => { - if (event.deltaY < 0) pause(); + // A nested code block scrolling up leaves the reader at the end. + if (event.deltaY < 0 && !innerScrollerTakes(scroller, event)) pause(); }; const onScroll = () => { if (!stickToBottom.current && offset) pause(); @@ -246,7 +383,7 @@ export function useBottomChatMotion( reducedMotion?.removeEventListener?.("change", stop); stop(); }; - }, [scroller, enabled, stickToBottom]); + }, [scroller, enabled, stickToBottom, holdUntil]); // Run after the transcript's pin/restore effects, before this commit paints. useLayoutEffect(() => update.current?.()); diff --git a/src/features/sessions/ui/AgentMarkdown.tsx b/src/features/sessions/ui/AgentMarkdown.tsx index 7828e5b20e..a8ee08637a 100644 --- a/src/features/sessions/ui/AgentMarkdown.tsx +++ b/src/features/sessions/ui/AgentMarkdown.tsx @@ -11,6 +11,7 @@ import { useRef, useState, type ComponentProps, + type CSSProperties, type MouseEvent as ReactMouseEvent, type ReactNode, } from "react"; @@ -48,7 +49,15 @@ import { isNoteImagePath } from "../../notes"; import { IS_MAC, IS_WIN } from "../../../platform/tauri/platform"; import { InboxMedia } from "../../inbox/ui/InboxMedia"; import { rehypeHardBreaks } from "./hardBreaks"; -import { rehypeWordFade, usePacedText, useWordFading } from "./wordFade"; +import { + pacedRevealPreferred, + rehypeWordFade, + useCadenceFade, + usePacedText, + useStreamedText, + useWordFading, + WORD_FADE_MS, +} from "./wordFade"; import { isFenceBlock, parseStreamingMarkdown } from "./streamingMarkdown"; import { HighlightedCodeBlock } from "./HighlightedCodeBlock"; @@ -554,8 +563,19 @@ export const AgentMarkdown = memo(function AgentMarkdown({ [cwd], ); const remoteMedia = !!allowRemoteMedia; - const paced = usePacedText(text, !!streaming, revealOnMount); - const fading = useWordFading(!!streaming || paced.revealing); + // Read once: switching reveal hooks while mounted would break hook order. + const [pacedReveal] = useState(pacedRevealPreferred); + const useReveal = pacedReveal ? usePacedText : useStreamedText; + const paced = useReveal(text, !!streaming, revealOnMount); + const cadence = useCadenceFade(paced.text); + const fadeMs = pacedReveal ? WORD_FADE_MS : cadence.durationMs; + const fading = useWordFading(!!streaming || paced.revealing, fadeMs); + const fadeStyle = pacedReveal + ? undefined + : ({ + "--word-fade-ms": `${fadeMs}ms`, + "--word-fade-ease": cadence.easing, + } as CSSProperties); // Spans stay while words are fading so a word already on screen keeps its // element. Dropping one mid-fade would remount it and fade it again. Once // the fade is over they come off, or a finished reply would keep a span per @@ -617,21 +637,24 @@ export const AgentMarkdown = memo(function AgentMarkdown({ <> - - {paced.text} - + {/* Streamdown takes no style; a contents box passes the fade timing down. */} +
+ + {paced.text} + +
{fileMenu ? ( ({ copyMessage: vi.fn().mockResolvedValue(undefined), @@ -55,6 +55,7 @@ beforeEach(() => { }); afterEach(() => { + localStorage.removeItem(PACED_REVEAL_KEY); act(() => root.unmount()); container.remove(); resultStyles.remove(); @@ -191,6 +192,7 @@ it("shows sessions beside the footer actions only for the turn that launched the }); it("waits for the final reply's reveal, then shows its artifact before the actions", () => { + localStorage.setItem(PACED_REVEAL_KEY, "1"); const user: Block = { id: "user", role: "user", diff --git a/src/features/sessions/ui/AgentTranscript.tsx b/src/features/sessions/ui/AgentTranscript.tsx index ada8c26618..f3d9aac9df 100644 --- a/src/features/sessions/ui/AgentTranscript.tsx +++ b/src/features/sessions/ui/AgentTranscript.tsx @@ -1001,12 +1001,14 @@ function AgentTranscriptComponent({ refreshChatMotion.current = useBottomChatMotion( scrollerEl, - visible && bottomAligned, + visible, stickToBottom, blocks, - !!busy && userTurnCount(blocks, managed) === 1, + bottomAligned && !!busy && userTurnCount(blocks, managed) === 1, !!busy, historicalBlockIds, + bottomAligned, + wheelHold, ); return ( @@ -1604,13 +1606,16 @@ function TranscriptContent({ bottomAligned: boolean; children: ReactNode; }) { + // The clip keeps the follow motion's in-flight offset out of scrollHeight. if (!bottomAligned) { return ( -
- {children} +
+
+ {children} +
); } @@ -5118,7 +5123,7 @@ function pinToBottom(el: HTMLElement | null) { /** Keep the live turn's min-height in lockstep with the visible transcript. */ function syncTranscriptViewport(el: HTMLElement | null) { if (!el || el.clientHeight <= 0) return; - const inner = el.firstElementChild as HTMLElement | null; + const inner = el.querySelector("[data-transcript-content]"); const pad = inner ? Number.parseFloat(getComputedStyle(inner).paddingBottom) || 0 : 0; diff --git a/src/features/sessions/ui/AgentTranscriptPacing.test.ts b/src/features/sessions/ui/AgentTranscriptPacing.test.ts index 8c95e4a3f2..61cb538a96 100644 --- a/src/features/sessions/ui/AgentTranscriptPacing.test.ts +++ b/src/features/sessions/ui/AgentTranscriptPacing.test.ts @@ -4,7 +4,7 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { Block } from "../model/session"; import { AgentTranscript } from "./AgentTranscript"; -import { WORD_FADE_MS } from "./wordFade"; +import { PACED_REVEAL_KEY, WORD_FADE_MS } from "./wordFade"; let container: HTMLDivElement; let root: Root; @@ -27,12 +27,15 @@ beforeEach(() => { disconnect() {} }, ); + // These cover the opt-in paced reveal unless a test says otherwise. + localStorage.setItem(PACED_REVEAL_KEY, "1"); container = document.createElement("div"); document.body.append(container); root = createRoot(container); }); afterEach(() => { + localStorage.removeItem(PACED_REVEAL_KEY); act(() => root.unmount()); container.remove(); vi.unstubAllGlobals(); @@ -217,3 +220,21 @@ it("shows output received in a hidden tab immediately when switching to it", () expect(answerText()).toBe(reply); expect(container.querySelector(".word-fading")).toBeNull(); }); + +it("shows a new reply at once and fades it in without the paced reveal", () => { + localStorage.removeItem(PACED_REVEAL_KEY); + render([prompt]); + render( + [ + prompt, + { id: "answer", role: "assistant", text: reply, streaming: false }, + ], + false, + ); + expect(answerText()).toBe(reply); + expect(container.querySelector(".word-fading")).not.toBeNull(); + + act(() => vi.advanceTimersByTime(400)); + expect(answerText()).toBe(reply); + expect(container.querySelector(".word-fading")).toBeNull(); +}); diff --git a/src/features/sessions/ui/wordFade.test.ts b/src/features/sessions/ui/wordFade.test.ts index 8ea0b83c45..e32f243625 100644 --- a/src/features/sessions/ui/wordFade.test.ts +++ b/src/features/sessions/ui/wordFade.test.ts @@ -3,7 +3,13 @@ import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AgentMarkdown } from "./AgentMarkdown"; -import { revealEnd, WORD_FADE_MS } from "./wordFade"; +import { + cadenceFadeMs, + nextCadence, + PACED_REVEAL_KEY, + revealEnd, + WORD_FADE_MS, +} from "./wordFade"; describe("revealEnd", () => { it("stops at the end of the word the reveal has reached", () => { @@ -22,11 +28,25 @@ describe("revealEnd", () => { }); }); +describe("cadence fade", () => { + it("fades over three gaps of the stream, within bounds", () => { + expect(cadenceFadeMs(100)).toBe(300); + expect(cadenceFadeMs(10)).toBe(120); + expect(cadenceFadeMs(1000)).toBe(400); + }); + + it("follows the gaps between bursts without one stall taking over", () => { + expect(nextCadence(160, 60)).toBeCloseTo(130); + expect(nextCadence(160, 60_000)).toBeCloseTo(412); + }); +}); + describe("paced streaming", () => { let container: HTMLDivElement; let root: Root; beforeEach(() => { + localStorage.setItem(PACED_REVEAL_KEY, "1"); vi.useFakeTimers({ toFake: [ "setTimeout", @@ -43,6 +63,7 @@ describe("paced streaming", () => { }); afterEach(() => { + localStorage.removeItem(PACED_REVEAL_KEY); act(() => root.unmount()); container.remove(); vi.unstubAllGlobals(); @@ -183,3 +204,104 @@ describe("paced streaming", () => { expect(word("now")).toBeDefined(); }); }); + +describe("streamed reveal", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.useFakeTimers({ + toFake: [ + "setTimeout", + "clearTimeout", + "requestAnimationFrame", + "cancelAnimationFrame", + "performance", + ], + }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + function render(text: string, streaming: boolean) { + act(() => root.render(createElement(AgentMarkdown, { text, streaming }))); + } + + function shown() { + return container.textContent ?? ""; + } + + function fadeMs() { + const box = container.querySelector(".agent-markdown") + ?.parentElement; + return Number.parseFloat(box?.style.getPropertyValue("--word-fade-ms") ?? ""); + } + + it("shows a burst as soon as it arrives, fading its words in", () => { + render("", true); + render("I will review the diff ", true); + expect(shown()).toBe("I will review the diff"); + expect(container.querySelector(".agent-markdown.word-fading")).not.toBeNull(); + expect(container.querySelectorAll("[data-word-fade]")).toHaveLength(5); + }); + + it("holds back a word still being written until the stream pauses on it", () => { + render("", true); + render("Hello wor", true); + expect(shown()).toBe("Hello"); + act(() => vi.advanceTimersByTime(200)); + expect(shown()).toBe("Hello wor"); + render("Hello world and mo", true); + expect(shown()).toBe("Hello world and"); + }); + + it("shortens the fade for a fast stream and lengthens it for a slow one", () => { + render("", true); + let text = ""; + for (let index = 0; index < 20; index++) { + text += `word${index} `; + render(text, true); + act(() => vi.advanceTimersByTime(16)); + } + expect(fadeMs()).toBe(120); + + for (let index = 0; index < 20; index++) { + text += `slow${index} `; + render(text, true); + act(() => vi.advanceTimersByTime(300)); + } + expect(fadeMs()).toBe(400); + }); + + it("stops fading once the last burst has faded in", () => { + render("", true); + render("Done reviewing.", true); + render("Done reviewing.", false); + expect(shown()).toBe("Done reviewing."); + act(() => vi.advanceTimersByTime(400)); + expect(container.querySelector(".word-fading")).toBeNull(); + expect(container.querySelector("[data-word-fade]")).toBeNull(); + }); + + it("shows output already streaming when it opens whole and still", () => { + act(() => + root.render( + createElement(AgentMarkdown, { + text: "Half way through a wor", + streaming: true, + revealOnMount: false, + }), + ), + ); + expect(shown()).toBe("Half way through a wor"); + }); +}); diff --git a/src/features/sessions/ui/wordFade.tsx b/src/features/sessions/ui/wordFade.tsx index f7f2f18489..aa8300e4af 100644 --- a/src/features/sessions/ui/wordFade.tsx +++ b/src/features/sessions/ui/wordFade.tsx @@ -2,16 +2,88 @@ import type { Element, ElementContent, Root } from "hast"; import { useEffect, useReducer, useRef, useState } from "react"; /* - * Streaming prose, paced. Tokens land in uneven bursts; read straight off the - * wire, whole clauses pop in at once and then nothing for a beat. Instead the - * text is let out a word at a time at a steady rate that closes on whatever - * has arrived, and each word fades in as it is let out (the fade itself is - * `.word-fading [data-word-fade]` in index.css), so the reply grows with a - * soft leading edge rather than a ragged one. + * Streaming prose. Tokens land in uneven bursts. By default each burst goes + * on screen as it arrives, so layout is always the real reply, and its words + * fade in over a time that follows the stream cadance + * veil): a fast stream gets short fades, a slow one long fades, and either + * way consecutive bursts overlap into one rolling edge instead of popping in + * clause by clause. The fade itself is `.word-fading [data-word-fade]` in + * index.css. + * + * The older paced reveal, which lets text out a word at a time at a steady + * rate, stays available behind `localStorage["monocode.pacedReveal"] = "1"`. */ -/** How long one word takes to fade in; matches `word-fade-in` in index.css. */ +/** How long one word takes to fade in while paced. */ export const WORD_FADE_MS = 320; + +/** Opt back into the paced word-at-a-time reveal. */ +export const PACED_REVEAL_KEY = "monocode.pacedReveal"; + +/** + * Fade length tracks an average of the gaps between bursts: three gaps long, + * within these bounds. + */ +const CADENCE_FADE_MIN_MS = 120; +const CADENCE_FADE_MAX_MS = 400; +/** The gap average a fresh stream starts from. */ +const CADENCE_SEED_MS = 160; +/** A stall longer than this counts as this long, so one pause can't drag every fade out. */ +const CADENCE_GAP_CAP_MS = 1000; + +const CADENCE_FADE_EASE = "cubic-bezier(0.33, 0.53, 0.67, 1)"; + +export function pacedRevealPreferred(): boolean { + try { + return localStorage.getItem(PACED_REVEAL_KEY) === "1"; + } catch { + return false; + } +} + +/** The next gap average once a burst lands `gapMs` after the one before. */ +export function nextCadence(averageMs: number, gapMs: number): number { + return averageMs * 0.7 + Math.min(gapMs, CADENCE_GAP_CAP_MS) * 0.3; +} + +/** How long a burst's words fade, given the current gap average. */ +export function cadenceFadeMs(averageMs: number): number { + return Math.min( + CADENCE_FADE_MAX_MS, + Math.max(CADENCE_FADE_MIN_MS, averageMs * 3), + ); +} + +/** + * The fade length and curve for words appearing in `text` now. Each change + * that adds to the text is one burst; the gap since the previous one feeds + * the average. A render that repeats the same text (strict mode, an unrelated + * parent update) leaves it alone. + */ +export function useCadenceFade(text: string): { + durationMs: number; + easing: string; +} { + const state = useRef({ + text, + average: CADENCE_SEED_MS, + lastBurst: null as number | null, + }); + const current = state.current; + if (text !== current.text) { + if (text.length > current.text.length) { + const now = performance.now(); + if (current.lastBurst !== null) + current.average = nextCadence(current.average, now - current.lastBurst); + current.lastBurst = now; + } + current.text = text; + } + return { + durationMs: Math.round(cadenceFadeMs(current.average)), + easing: CADENCE_FADE_EASE, + }; +} /** * The slowest the reveal goes, in characters a second, so the tail of a * finished reply never crawls out. @@ -51,6 +123,42 @@ function isSpace(code: number): boolean { return code === 32 || code === 10 || code === 9 || code === 13; } +/** + * Show streamed output as it arrives, holding back only a word still being + * written until it is whole or the stream pauses on it. `revealing` stays on + * for the first render of new output so it fades in, including a reply that + * completed before its first paint; callers opening existing output opt out. + */ +export function useStreamedText( + text: string, + streaming: boolean, + revealOnMount = streaming, +): { text: string; revealing: boolean } { + const [mountReveal, setMountReveal] = useState(revealOnMount); + // Output already there when it opens is shown whole, half-written word too. + const [heldText, setHeldText] = useState( + revealOnMount ? null : text, + ); + const end = + heldText === text ? text.length : revealEnd(text, text.length, streaming); + const behind = end < text.length; + + useEffect(() => { + if (mountReveal) setMountReveal(false); + }, [mountReveal]); + + useEffect(() => { + if (!behind) return; + const timer = window.setTimeout(() => setHeldText(text), REVEAL_HOLD_MS); + return () => window.clearTimeout(timer); + }, [behind, text]); + + return { + text: behind ? text.slice(0, end) : text, + revealing: behind || mountReveal, + }; +} + /** * Pace new output, including its first chunk and a reply that completed before * its first paint. Callers opening existing output opt out of the mount reveal. @@ -124,15 +232,20 @@ export function usePacedText( * — an animation replays whenever its element is hidden and shown again, and * a finished reply folded away and reopened must not fade in all over again. */ -export function useWordFading(active: boolean): boolean { +export function useWordFading(active: boolean, fadeMs = WORD_FADE_MS): boolean { const [lingering, setLingering] = useState(false); + const lastFadeMs = useRef(fadeMs); + lastFadeMs.current = fadeMs; useEffect(() => { if (active) { setLingering(true); return; } - const timer = window.setTimeout(() => setLingering(false), WORD_FADE_MS); + const timer = window.setTimeout( + () => setLingering(false), + lastFadeMs.current, + ); return () => window.clearTimeout(timer); }, [active]); diff --git a/src/styles/index.css b/src/styles/index.css index 6346af6913..3c2e1ab359 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -3143,7 +3143,7 @@ html.theme-light .mono-pill { } /* - * A streamed word fading in as the paced reveal lets it out (wordFade.tsx). + * A streamed word fading in as it is shown (wordFade.tsx). * Opacity only: a word that moved would jostle the line it is set on. The * fade plays as a word's span is added, and words already on screen keep * theirs while the reply is coming in. The spans come off once the fade @@ -3158,8 +3158,10 @@ html.theme-light .mono-pill { } } +/* Unpaced replies set the timing from the stream's cadence (AgentMarkdown). */ .agent-markdown.word-fading [data-word-fade]:not([data-word-settled]) { - animation: word-fade-in 320ms ease-out both; + animation: word-fade-in var(--word-fade-ms, 320ms) + var(--word-fade-ease, ease-out) both; } @keyframes zen-thinking-pulse { From 49b4e5231d8a9c271d5a401f6074e1caa6e282e1 Mon Sep 17 00:00:00 2001 From: Nick <60738984+hardbeat920@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:53:37 +0100 Subject: [PATCH 05/80] Defer video source release to survive StrictMode replay - Defer clearing the video src in the effect cleanup so a StrictMode replay no longer aborts an in-flight load - Cancel the pending release timer when the effect runs again - Update the video tests: use fake timers for the unmount case and assert the source is not reloaded on replay --- src/features/files/ui/BinaryFileView.test.ts | 17 +++++++++-------- src/features/files/ui/BinaryFileView.tsx | 13 ++++++++++--- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/features/files/ui/BinaryFileView.test.ts b/src/features/files/ui/BinaryFileView.test.ts index 0768f29243..0055810d30 100644 --- a/src/features/files/ui/BinaryFileView.test.ts +++ b/src/features/files/ui/BinaryFileView.test.ts @@ -190,23 +190,24 @@ describe("BinaryFileView video playback", () => { const video = container.querySelector("video")!; const pause = vi.spyOn(video, "pause"); const load = vi.spyOn(video, "load"); + vi.useFakeTimers(); act(() => root.render(null)); expect(pause).toHaveBeenCalledOnce(); + act(() => vi.runAllTimers()); + vi.useRealTimers(); expect(video.hasAttribute("src")).toBe(false); expect(load).toHaveBeenCalledOnce(); expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:image-preview"); }); - it("restarts loading the restored source after StrictMode replays its effects", async () => { - const loadedSources: Array = []; - vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(function ( - this: HTMLMediaElement, - ) { - loadedSources.push(this.getAttribute("src")); - }); + it("keeps the source loading when StrictMode replays its effects", async () => { + const load = vi + .spyOn(HTMLMediaElement.prototype, "load") + .mockImplementation(() => {}); await renderViewer("/repo/clip.mp4", true, true); + await act(() => new Promise((resolve) => setTimeout(resolve))); expect(container.querySelector("video")?.src).toBe("blob:image-preview"); - expect(loadedSources).toEqual([null, "blob:image-preview"]); + expect(load).not.toHaveBeenCalled(); }); it("shows file read errors and allows retry", async () => { diff --git a/src/features/files/ui/BinaryFileView.tsx b/src/features/files/ui/BinaryFileView.tsx index b2566c52f0..c14df9f280 100644 --- a/src/features/files/ui/BinaryFileView.tsx +++ b/src/features/files/ui/BinaryFileView.tsx @@ -212,9 +212,13 @@ function VideoView({ document.removeEventListener("visibilitychange", pauseWhenHidden); }, [visible, failed]); + const releaseSource = useRef(0); useEffect(() => { const video = videoRef.current; - // Restore the source and restart loading if React replays effects after cleanup. + // A StrictMode replay runs this again straight after the cleanup. Keep the + // load in flight: aborting and restarting it in one task can leave Linux + // WebKit's media pipeline stalled without metadata or an error. + window.clearTimeout(releaseSource.current); if (video && !video.hasAttribute("src")) { video.src = url; video.load(); @@ -222,8 +226,11 @@ function VideoView({ return () => { if (!video) return; video.pause(); - video.removeAttribute("src"); - video.load(); + // Free the decoder once the element is really gone. + releaseSource.current = window.setTimeout(() => { + video.removeAttribute("src"); + video.load(); + }); }; }, [url, failed]); From 6a5b8d139201fa3079af3ab12170b4a3628dab58 Mon Sep 17 00:00:00 2001 From: Nick <60738984+hardbeat920@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:04:53 +0100 Subject: [PATCH 06/80] Avoid Cursor login flow during model discovery - Add coverage for ACP, CLI, and bundled catalog fallbacks - Preserve existing credentials without triggering browser authentication --- .../providers/cursor/cursorCatalog.test.ts | 174 ++++++++++++++++++ .../harness/providers/cursor/cursorCatalog.ts | 6 +- 2 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 src/integrations/harness/providers/cursor/cursorCatalog.test.ts diff --git a/src/integrations/harness/providers/cursor/cursorCatalog.test.ts b/src/integrations/harness/providers/cursor/cursorCatalog.test.ts new file mode 100644 index 0000000000..66e57ff656 --- /dev/null +++ b/src/integrations/harness/providers/cursor/cursorCatalog.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { JsonRpcMessage } from "../../core/acp"; + +const mocks = vi.hoisted(() => ({ + execChild: vi.fn<(...args: unknown[]) => Promise>(), + killChild: vi.fn(async () => undefined), + resolveCursorBinary: vi.fn(async () => ({ path: "/fake/cursor-agent" })), + setHarnessModels: vi.fn(), + spawnChild: vi.fn<(...args: unknown[]) => Promise>( + async () => undefined, + ), + unwatchChild: vi.fn(), +})); + +const sent: JsonRpcMessage[] = []; +let onLine: ((line: string) => void) | undefined; +let responses: Record>; + +vi.mock("../../../../platform/tauri/fs", () => ({ + homeDir: async () => "/home/test", +})); +vi.mock("../../../../features/sessions/model/models", () => ({ + setHarnessModels: mocks.setHarnessModels, +})); +vi.mock("../../core/child", () => ({ + ...mocks, + watchChild: (_id: string, line: (value: string) => void) => { + onLine = line; + }, + writeChild: async (_id: string, line: string) => { + const request = JSON.parse(line) as JsonRpcMessage; + sent.push(request); + const response = responses[request.method!]; + if (!response) throw new Error(`Unexpected request: ${request.method}`); + onLine!(JSON.stringify({ jsonrpc: "2.0", id: request.id, ...response })); + }, +})); + +import { discoverCursorModels, refreshCursorCatalog } from "./cursorCatalog"; + +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + sent.length = 0; + onLine = undefined; + responses = { + initialize: { + result: { authMethods: [{ id: "cursor_login", name: "Cursor Login" }] }, + }, + "cursor/list_available_models": { + result: { + models: [{ value: "composer-test", name: "Composer Test" }], + }, + }, + }; + mocks.execChild.mockReset().mockResolvedValue(""); + vi.spyOn(console, "debug").mockImplementation(() => undefined); +}); + +afterEach(() => { + expect(vi.getTimerCount()).toBe(0); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("Cursor catalog discovery", () => { + it("refreshes models using existing CLI credentials without requesting browser sign-in", async () => { + await refreshCursorCatalog(); + + expect(mocks.setHarnessModels).toHaveBeenCalledWith("cursor", [ + { + id: "cursor:composer-test", + harness: "cursor", + name: "Composer Test", + nativeId: "composer-test", + settings: undefined, + }, + ]); + expect(sent.map((request) => request.method)).toEqual([ + "initialize", + "cursor/list_available_models", + ]); + expect(mocks.execChild).not.toHaveBeenCalled(); + expect(mocks.spawnChild).toHaveBeenCalledWith( + expect.any(String), + "/fake/cursor-agent", + ["acp"], + "/home/test", + undefined, + "cursor", + ); + const probeId = mocks.spawnChild.mock.calls[0][0]; + expect(mocks.unwatchChild).toHaveBeenCalledWith(probeId); + expect(mocks.killChild).toHaveBeenCalledWith(probeId); + }); + + it("still discovers models from session setup without an authentication request", async () => { + responses["cursor/list_available_models"] = { result: { models: [] } }; + responses["session/new"] = { + result: { + models: { + availableModels: [{ modelId: "gpt-test", name: "GPT Test" }], + }, + }, + }; + + expect(await discoverCursorModels("/workspace")).toEqual([ + { + id: "cursor:gpt-test", + harness: "cursor", + name: "GPT Test", + nativeId: "gpt-test", + }, + ]); + expect(sent.map((request) => request.method)).toEqual([ + "initialize", + "cursor/list_available_models", + "session/new", + ]); + expect(sent.at(-1)?.params).toEqual({ cwd: "/workspace", mcpServers: [] }); + }); + + it("falls back to CLI models when ACP requires login without starting a login flow", async () => { + responses["cursor/list_available_models"] = { + error: { code: -32000, message: "Authentication required" }, + }; + mocks.execChild.mockResolvedValue( + "gpt-test-low - GPT Test Low\ngpt-test-high - GPT Test High\n", + ); + + expect(await discoverCursorModels("/workspace")).toMatchObject([ + { + id: "cursor:gpt-test", + name: "GPT Test", + nativeId: "gpt-test", + settings: [ + { + id: "reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "high", label: "High" }, + ], + }, + ], + }, + ]); + expect(sent.map((request) => request.method)).toEqual([ + "initialize", + "cursor/list_available_models", + ]); + expect(mocks.execChild).toHaveBeenCalledWith( + "/fake/cursor-agent", + ["--list-models"], + "/workspace", + "cursor", + ); + expect(mocks.killChild).toHaveBeenCalled(); + }); + + it("keeps the bundled catalog when both discovery paths require sign-in", async () => { + responses["cursor/list_available_models"] = { + error: { code: -32000, message: "Authentication required" }, + }; + mocks.execChild.mockRejectedValue(new Error("Not authenticated")); + + await refreshCursorCatalog(); + + expect(mocks.setHarnessModels).not.toHaveBeenCalled(); + expect(sent.some((request) => request.method === "authenticate")).toBe( + false, + ); + expect(mocks.killChild).toHaveBeenCalled(); + }); +}); diff --git a/src/integrations/harness/providers/cursor/cursorCatalog.ts b/src/integrations/harness/providers/cursor/cursorCatalog.ts index 91ee4e21fa..911a624df6 100644 --- a/src/integrations/harness/providers/cursor/cursorCatalog.ts +++ b/src/integrations/harness/providers/cursor/cursorCatalog.ts @@ -88,9 +88,9 @@ async function discoverViaAcp(workingDirectory?: string): Promise }, REQUEST_TIMEOUT_MS, ); - await acp - .request("authenticate", { methodId: "cursor_login" }, REQUEST_TIMEOUT_MS) - .catch(() => undefined); + // ACP reads existing CLI credentials at startup. `cursor_login` can + // launch a browser, so a catalog refresh must never request it. Missing + // credentials fall back to CLI discovery and the bundled model list. const listed = await acp.request( "cursor/list_available_models", {}, From efecfc9541812271d8047946745b4962581c8c6d Mon Sep 17 00:00:00 2001 From: Nick <60738984+hardbeat920@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:48:26 +0100 Subject: [PATCH 07/80] Show Mono-launched sessions in the sessions sidebar - Opening a launched session slides it over the Mono's session list, with Back, Open in workspace, and Hide controls - Sessions a Mono launches no longer open as workspace tabs unless placed beside an open pane - Quick launch takes an openTab flag so a session can start without a tab - PanelStack resets scroll on its clip box so an offscreen focus can't fight the slide Co-Authored-By: Claude Haiku 5.5 --- src/app/App.tsx | 115 ++++++- src/app/model/quickLaunchSession.test.ts | 58 ++-- src/app/model/quickLaunchSession.ts | 6 +- .../monos/ui/MonoSessionsPanel.test.ts | 51 ++++ src/features/monos/ui/MonoSessionsPanel.tsx | 288 ++++++++++++------ src/features/monos/ui/PanelStack.tsx | 10 +- 6 files changed, 400 insertions(+), 128 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index ad6eea4a56..ec91fb66de 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1145,6 +1145,20 @@ function Workspace({ useState(null); const [monoSessions, setMonoSessions] = useState(null); + // A launched session shown in the sessions sidebar. It belongs to the list + // it was opened from, so reopening or closing that list leaves it. + const [monoShownSession, setMonoShownSession] = useState<{ + from: MonoActivitySelection; + sessionId: string; + } | null>(null); + // Whether the shown session, rather than the Mono's chat, holds focus. + const [monoShownSessionFocused, setMonoShownSessionFocused] = useState(false); + const shownMonoSessionId = + monoViewId && monoSessions && monoShownSession?.from === monoSessions + ? monoShownSession.sessionId + : undefined; + const shownMonoSessionIdRef = useRef(shownMonoSessionId); + shownMonoSessionIdRef.current = shownMonoSessionId; const onShowMonoActivity = useCallback( (sessionId: string, turnId: string, blocks: Block[]) => { setMonoDetailsOpen(false); @@ -1478,6 +1492,8 @@ function Workspace({ const foregroundSurfaceRef = useRef<{ workspaceVisible: boolean; standaloneSessionId?: string; + /** A session shown beside the Mono's chat. */ + sideSessionId?: string; }>({ workspaceVisible: true }); foregroundSurfaceRef.current = { workspaceVisible: @@ -1495,6 +1511,14 @@ function Workspace({ !settingsOpen ? (monoViewId ?? undefined) : undefined, + sideSessionId: + !inboxViewOpen && + !searchViewOpen && + !notesViewOpen && + !automationsViewOpen && + !settingsOpen + ? shownMonoSessionId + : undefined, }; const notesViewOpenRef = useRef(notesViewOpen); notesViewOpenRef.current = notesViewOpen; @@ -1593,6 +1617,7 @@ function Workspace({ // Standalone conversations live outside the workspace tab tree. return ( foregroundSurfaceRef.current.standaloneSessionId === sessionId || + foregroundSurfaceRef.current.sideSessionId === sessionId || (foregroundSurfaceRef.current.workspaceVisible && !!tab && (leafIds(tab.layout).includes(sessionId) || @@ -2066,7 +2091,9 @@ function Workspace({ const activeSessionId = inboxViewOpen ? inboxAskPortal?.sessionId - : (monoViewSession?.id ?? active?.id); + : shownMonoSessionId && monoShownSessionFocused + ? shownMonoSessionId + : (monoViewSession?.id ?? active?.id); const activeSessionIdRef = useRef(activeSessionId); activeSessionIdRef.current = activeSessionId; @@ -2195,6 +2222,7 @@ function Workspace({ monoViewId, inboxViewOpen, inboxAskPortal?.sessionId, + shownMonoSessionId, searchViewOpen, notesViewOpen, automationsViewOpen, @@ -2691,11 +2719,7 @@ function Workspace({ setActiveTabId(tab.id); setComposerFocused(true); return session.id; - }, [ - appendTab, - sessionDefaults?.runtimeMode, - sidebarCwd, - ]); + }, [appendTab, sessionDefaults?.runtimeMode, sidebarCwd]); const onSelectRemoteSession = useCallback( (project: string, remoteSessionId: string) => { @@ -4021,6 +4045,13 @@ function Workspace({ const onFocusPane = useCallback( (paneId: string) => { if (paneId === monoViewIdRef.current) { + setMonoShownSessionFocused(false); + setProjectTerminalFocused(false); + setComposerFocused(true); + return; + } + if (paneId === shownMonoSessionIdRef.current) { + setMonoShownSessionFocused(true); setProjectTerminalFocused(false); setComposerFocused(true); return; @@ -4839,6 +4870,20 @@ function Workspace({ [ensureOpenSession, closeMonoView, setSidebarTab, onSelectHistorySession], ); + // Show a launched session in the Mono's sidebar, where it can be followed, + // answered or stopped without leaving the chat. + const onShowMonoLaunchedSession = useCallback( + async (from: MonoActivitySelection, sessionId: string) => { + const session = await ensureOpenSession(sessionId); + if (!session) throw new Error("This session is no longer available."); + setMonoShownSession({ from, sessionId }); + setMonoShownSessionFocused(true); + setProjectTerminalFocused(false); + setComposerFocused(true); + }, + [ensureOpenSession], + ); + const openReminderSession = useCallback( async (sessionId: string) => { const session = await ensureOpenSession(sessionId); @@ -8473,6 +8518,7 @@ function Workspace({ deliveryId: string, placement?: AppSessionPlacement, onSettled?: (outcome: ControlOutcome) => void, + openTab?: boolean, ) => acceptQuickLaunch( launch, @@ -8543,6 +8589,7 @@ function Workspace({ flushSync(() => onSaveDraft(id, prompt, attachments, requestId)), }, placement, + openTab, ), [appendTab, submitSession, onSaveDraft], ); @@ -10696,11 +10743,14 @@ function Workspace({ ) : undefined; try { + // A Mono's sessions open from its chat, not as workspace + // tabs, unless it places one beside an open pane. await launchQuickSessionRef.current( launch, id, placement, onSettled, + !isMonoSession(source.id) || !!placement, ); rememberLaunch(); onSettled?.accept(); @@ -12427,6 +12477,11 @@ function Workspace({ windowControls={monoCovers && !IS_MAC ? : undefined} /> ) : null; + const monoShownSessionPane = shownMonoSessionId + ? sessions.find((session) => session.id === shownMonoSessionId) + : undefined; + const monoShownSessionActive = + !!monoShownSessionPane && monoShownSessionFocused; const monoSessionsPanel = monoViewMono && selectedMonoSessions ? ( + monoSessions + ? onShowMonoLaunchedSession(monoSessions, sessionId) + : undefined + } + shown={ + monoShownSessionPane + ? { + sessionId: monoShownSessionPane.id, + title: sessionDisplayTitle( + monoShownSessionPane.title, + monoShownSessionPane.harness, + ), + pane: ( +
- {error ? ( -

- {error} -

- ) : null} -
+ ); } + +function LaunchedSessionPage({ + title, + onBack, + onOpenInWorkspace, + onClose, + children, +}: { + title: string; + onBack: () => void; + onOpenInWorkspace: () => void | Promise; + onClose: () => void; + children: ReactNode; +}) { + const [error, setError] = useState(); + return ( + <> + + { + setError(undefined); + void Promise.resolve(onOpenInWorkspace()).catch((cause) => + setError( + cause instanceof Error + ? cause.message + : "Could not open this session.", + ), + ); + }} + > + + + + + + + {error ? ( +

+ {error} +

+ ) : null} +
+ {children} +
+ + ); +} diff --git a/src/features/monos/ui/PanelStack.tsx b/src/features/monos/ui/PanelStack.tsx index cf3f820b92..8ab2450946 100644 --- a/src/features/monos/ui/PanelStack.tsx +++ b/src/features/monos/ui/PanelStack.tsx @@ -74,7 +74,15 @@ export function PanelStack({ const content = new Map(pages.map((page) => [page.key, page.node])); const open = layers.filter((layer) => !layer.leaving); return ( -
+
{ + event.currentTarget.scrollLeft = 0; + event.currentTarget.scrollTop = 0; + }} + > 0}>{children} {layers.map((layer) => { const index = open.findIndex((entry) => entry.key === layer.key); From b67db228efe6e9ee2d76f461ce1fb41a553ed21e Mon Sep 17 00:00:00 2001 From: Nick <60738984+hardbeat920@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:26:33 +0100 Subject: [PATCH 08/80] Add model controls beneath the Mono composer input - Mono composer can show an optional `controls` row under the input, with attach on the left and the model picker and effort pills on the right - ModelPicker and ModelControlPills gain a quiet `text` variant for that row - SessionPane passes model controls into the Mono composer - Composer guards against a missing measure element and only refits on real width changes --- src/features/monos/ui/MonoComposer.tsx | 227 ++++++++++++++--------- src/features/sessions/ui/ModelPicker.tsx | 98 ++++++---- src/features/sessions/ui/SessionPane.tsx | 30 +++ 3 files changed, 237 insertions(+), 118 deletions(-) diff --git a/src/features/monos/ui/MonoComposer.tsx b/src/features/monos/ui/MonoComposer.tsx index 52be27876a..374a62ad2e 100644 --- a/src/features/monos/ui/MonoComposer.tsx +++ b/src/features/monos/ui/MonoComposer.tsx @@ -1,4 +1,10 @@ -import { useCallback, useLayoutEffect, useRef, useState } from "react"; +import { + useCallback, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; import { isImeComposition } from "../../../shared/lib/keyboard"; import { ArrowUp, Plus } from "../../../shared/ui/icons"; import { AttachmentChip } from "../../sessions/ui/AttachmentChip"; @@ -44,13 +50,20 @@ type Props = { attachments: Attachment[], ) => boolean | void | Promise; onFocus?: () => void; + /** + * Sits under the input on the right, like the model and its effort, with + * attach across from it. Without any, attach and send share the field's + * line, as in the floating chat. + */ + controls?: ReactNode; }; /** * A messaging app's input for the Mono: attach, type, send, whether or not * it is mid-reply; Escape stops a reply. Files dropped anywhere on the chat - * attach here. The model, permissions and checkout - * live in the details panel instead. + * attach here. Attach sits under the input, with any `controls` (the model + * and its effort) across from it, or beside the field when there are none; + * permissions and checkout live in the details panel. */ export function MonoComposer({ sessionId, @@ -62,6 +75,7 @@ export function MonoComposer({ onDraftChange, onSubmit, onFocus, + controls, }: Props) { const [text, setText] = useState(() => getComposerDraft(sessionId) ?? ""); const [attachments, setAttachments] = useState([]); @@ -84,24 +98,27 @@ export function MonoComposer({ !submitting && pendingReads === 0 && (text.trim().length > 0 || attachments.length > 0); - // Attachments sit above the field like a second line of text, so the field - // moves above the buttons for them too. - const stacked = multiline || attachments.length > 0; + const inline = !controls; + // Attachments sit above the field like a second line of text, so an inline + // field moves above the buttons for them too. + const stacked = inline && (multiline || attachments.length > 0); useLayoutEffect(() => { const el = field.current; + if (!el) return; const measure = inlineMeasure.current; - if (!el || !measure) return; const fit = () => { - if (!measure.clientWidth) return; - // Always measure at the inline width. Expanding the field above the - // buttons must not make it collapse again just because it is wider. - const style = getComputedStyle(measure); - const singleLineHeight = - Number.parseFloat(style.lineHeight) + - Number.parseFloat(style.paddingTop) + - Number.parseFloat(style.paddingBottom); - setMultiline(measure.scrollHeight > singleLineHeight + 1); + if (!el.clientWidth) return; + if (measure?.clientWidth) { + // Always measure at the inline width. Expanding the field above the + // buttons must not make it collapse again just because it is wider. + const style = getComputedStyle(measure); + const singleLineHeight = + Number.parseFloat(style.lineHeight) + + Number.parseFloat(style.paddingTop) + + Number.parseFloat(style.paddingBottom); + setMultiline(measure.scrollHeight > singleLineHeight + 1); + } // Holds the input's height while measuring, as sessions do, so the // transcript above never grows for a moment and loses its bottom pin. resizeComposer(el, MAX_HEIGHT); @@ -110,9 +127,16 @@ export function MonoComposer({ fit(); // An empty field stays one line at any width, so once measured, resizing // the panes beside it need not measure it every frame. - if (!text && measure.clientWidth) return; - const observer = new ResizeObserver(fit); - observer.observe(measure); + const sized = measure ?? el; + if (!text && sized.clientWidth) return; + let width = sized.clientWidth; + const observer = new ResizeObserver(() => { + // Only a new width can rewrap the text; growing taller is our own doing. + if (sized.clientWidth === width) return; + width = sized.clientWidth; + fit(); + }); + observer.observe(sized); return () => observer.disconnect(); }, [text, multiline, stacked]); @@ -241,6 +265,63 @@ export function MonoComposer({ } else clear(result); }; + const attachButton = (className: string, label?: string) => ( + + ); + const input = (className: string) => ( +