From 28c01ea1cede5bf012b2bdceb1dd34bbd0bfde3e Mon Sep 17 00:00:00 2001 From: Patrick Wang Date: Sat, 7 Nov 2026 21:21:25 +0800 Subject: [PATCH 01/30] fix(terminal): preserve mouse encoding when replaying sessions (#9004) Restore SGR and pixel mouse encoding alongside tracking modes so Copilot CLI scrolling survives switching conversations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/terminal-worker.md | 2 +- .../src/terminal/terminal-serializer.ts | 20 ++++---- packages/server/src/terminal/terminal.test.ts | 46 +++++++++++++++++++ 3 files changed, 59 insertions(+), 9 deletions(-) diff --git a/docs/terminal-worker.md b/docs/terminal-worker.md index 77aa9325b7..d83dea9957 100644 --- a/docs/terminal-worker.md +++ b/docs/terminal-worker.md @@ -15,7 +15,7 @@ supervisor (paseo.pid) ──► daemon worker ──► launcher ──► term - The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again). - Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon. -The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut. +The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. It also restores SGR mouse encoding: the addon preserves mouse tracking but omits its encoding, so switching away and back otherwise sends legacy wheel reports that Copilot CLI cannot use. These missing modes require xterm's internal services because its public modes API omits them. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut. Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon. diff --git a/packages/server/src/terminal/terminal-serializer.ts b/packages/server/src/terminal/terminal-serializer.ts index 5f79064948..2b36dcbe7b 100644 --- a/packages/server/src/terminal/terminal-serializer.ts +++ b/packages/server/src/terminal/terminal-serializer.ts @@ -16,13 +16,13 @@ export function createTerminalSerializer( // absolutely; an x==cols cursor must keep its pending wrap for the next byte. const position = buffer.cursorX < terminal.cols ? `\x1b[${buffer.cursorY + 1};${buffer.cursorX + 1}H` : ""; - return Buffer.from(data + position + serializeCursorPresentation(terminal), "utf8"); + return Buffer.from(data + position + serializeAdditionalModes(terminal), "utf8"); }; } -// Stable addon 0.14 serializes buffer/modes but omits DECSCUSR and cursor visibility. -// xterm 6 keeps the program-selected presentation in coreService, not public options. -function serializeCursorPresentation(terminal: TerminalType): string { +// Addon 0.14 omits mouse encoding, DECSCUSR and cursor visibility. xterm 6 +// retains these in internal services rather than the public modes/options. +function serializeAdditionalModes(terminal: TerminalType): string { const core = ( terminal as unknown as { _core: { @@ -30,13 +30,17 @@ function serializeCursorPresentation(terminal: TerminalType): string { isCursorHidden: boolean; decPrivateModes: { cursorStyle?: "block" | "underline" | "bar"; cursorBlink?: boolean }; }; + mouseStateService: { activeEncoding: string }; }; } - )._core.coreService; - const visibility = `\x1b[?25${core.isCursorHidden ? "l" : "h"}`; - const cursorStyle = core.decPrivateModes.cursorStyle; + )._core; + let mouseEncoding = ""; + if (core.mouseStateService.activeEncoding === "SGR") mouseEncoding = "\x1b[?1006h"; + else if (core.mouseStateService.activeEncoding === "SGR_PIXELS") mouseEncoding = "\x1b[?1016h"; + const visibility = `${mouseEncoding}\x1b[?25${core.coreService.isCursorHidden ? "l" : "h"}`; + const cursorStyle = core.coreService.decPrivateModes.cursorStyle; // Unset presentation belongs to each renderer's configured defaults. if (cursorStyle === undefined) return visibility; const style = { block: 2, underline: 4, bar: 6 }[cursorStyle]; - return `${visibility}\x1b[${style - (core.decPrivateModes.cursorBlink ? 1 : 0)} q`; + return `${visibility}\x1b[${style - (core.coreService.decPrivateModes.cursorBlink ? 1 : 0)} q`; } diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 3d1fcca545..d36e86d732 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -1702,6 +1702,52 @@ it.each([false, true])( }, ); +it.each([ + { name: "SGR", modes: "\x1b[?1006h", wheel: "\x1b[<64;10;5M" }, + { name: "SGR pixels", modes: "\x1b[?1016h", wheel: "\x1b[<64;80;90M" }, + { name: "disabled SGR", modes: "\x1b[?1006h\x1b[?1006l", wheel: "\x1b[M`*%" }, +])("preserves $name wheel reports across repeated terminal replays", async (input) => { + const source = new xterm.Terminal({ rows: 10, cols: 80, allowProposedApi: true }); + const receiver = new xterm.Terminal({ rows: 10, cols: 80, allowProposedApi: true }); + const serialize = createTerminalSerializer(source); + const wheel = { + button: 4, + action: 0, + col: 10, + row: 5, + x: 80, + y: 90, + ctrl: false, + alt: false, + shift: false, + }; + // Mouse encoding is not exposed by xterm's public modes API. + const mouse = ( + receiver as unknown as { + _core: { + mouseStateService: { + encodeMouseEvent(event: typeof wheel): string; + }; + }; + } + )._core.mouseStateService; + try { + await new Promise((resolve) => + source.write("\x1b[?1049h\x1b[?1002h" + input.modes + "TRANSCRIPT", resolve), + ); + for (let replay = 0; replay < 2; replay++) { + receiver.reset(); + await new Promise((resolve) => receiver.write(serialize(), resolve)); + expect(receiver.buffer.active.type).toBe("alternate"); + expect(receiver.modes.mouseTrackingMode).toBe("drag"); + expect(mouse.encodeMouseEvent(wheel)).toBe(input.wheel); + } + } finally { + receiver.dispose(); + source.dispose(); + } +}); + it.each([ { name: "unset", presentation: "", style: undefined, visible: true }, { From a48bc3aa6f7742a86d2b18cd947f757b24c0df56 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:03:47 +0000 Subject: [PATCH 02/30] Keep concurrent Codex discovery from failing during startup (#9008) * Serialize Codex app-server startup by state directory * Keep Codex session constructor unchanged * Type Codex startup failures and inject process launch port * fix(codex): retain startup diagnostics emitted after exit Classify failed initialization after stdio closes so the bounded SQLite retry sees the complete stderr. Keep running-session exit handling immediate. The regression delivers the SQLite diagnostic between exit and close. * test(codex): inject history test process through the public dependency The history fixture still replaced the old private spawn method, so provider launch preparation tried to find a real CLI in CI. Inject the same process interface as other provider tests and use an invalid command as a guard against accidental real launches. --------- Co-authored-by: Michael Wu Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Ivan Malison <1246619+colonelpanic8@users.noreply.github.com> --- .../codex-app-server-agent.local.e2e.test.ts | 29 +- ...odex-app-server-agent.process-exit.test.ts | 7 + ...codex-app-server-agent.spawn-error.test.ts | 13 +- .../providers/codex-app-server-agent.test.ts | 130 ++++--- .../agent/providers/codex-app-server-agent.ts | 194 ++++++---- .../codex/app-server-startup.test.ts | 347 ++++++++++++++++++ .../providers/codex/app-server-startup.ts | 189 ++++++++++ .../codex/app-server-transport.test.ts | 50 ++- .../providers/codex/app-server-transport.ts | 55 ++- .../codex/test-utils/fake-app-server.ts | 4 + .../providers/codex/thread-history.test.ts | 16 +- 11 files changed, 903 insertions(+), 131 deletions(-) create mode 100644 packages/server/src/server/agent/providers/codex/app-server-startup.test.ts create mode 100644 packages/server/src/server/agent/providers/codex/app-server-startup.ts diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts index dc7f920993..ea0c959e5c 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts @@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; import { createServer } from "node:http"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { once } from "node:events"; import { AgentManager } from "../agent-manager.js"; @@ -217,6 +217,33 @@ function waitForEvent(params: { } describe("Codex app-server provider (local e2e)", () => { + test.runIf(isCodexInstalled())( + "concurrent import scans initialize successfully in a fresh shared Codex home", + async () => { + const artifacts = path.resolve(".artifacts"); + mkdirSync(artifacts, { recursive: true }); + const root = mkdtempSync(path.join(artifacts, "codex-concurrent-startup-")); + try { + for (let round = 0; round < 4; round++) { + const codexHome = path.join(root, String(round)); + mkdirSync(codexHome); + const provider = new CodexAppServerAgentClient(createTestLogger(), { + env: { CODEX_HOME: codexHome }, + }); + const results = await Promise.allSettled( + Array.from({ length: 24 }, () => provider.listImportableSessions({ limit: 1 })), + ); + expect(results).toEqual( + Array.from({ length: 24 }, () => ({ status: "fulfilled", value: [] })), + ); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + 60_000, + ); + test.runIf(isCodexInstalled())( "reloads a persisted idle thread repeatedly without overlapping writers", async () => { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts index 9cfeca60d3..e528621338 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts @@ -587,6 +587,10 @@ test("concurrent session APIs share one reconnect after an idle provider exit", createFakeCodexAppServer(), ]; let spawnCount = 0; + let markReconnectStarted!: () => void; + const reconnectStarted = new Promise((resolve) => { + markReconnectStarted = resolve; + }); let releaseReconnect: (() => void) | undefined; const reconnectGate = new Promise((resolve) => { releaseReconnect = resolve; @@ -602,6 +606,7 @@ test("concurrent session APIs share one reconnect after an idle provider exit", } spawnCount += 1; if (spawnCount > 1) { + markReconnectStarted(); await reconnectGate; } return appServer.child; @@ -614,12 +619,14 @@ test("concurrent session APIs share one reconnect after an idle provider exit", const runtimeInfo = session.getRuntimeInfo(); const turnStart = session.startTurn("continue after reconnect"); + await reconnectStarted; const reconnectSpawnCount = spawnCount - 1; releaseReconnect?.(); await expect(runtimeInfo).resolves.toMatchObject({ provider: "codex" }); await expect(turnStart).resolves.toMatchObject({ turnId: expect.any(String) }); expect(reconnectSpawnCount).toBe(1); + expect(spawnCount).toBe(2); } finally { releaseReconnect?.(); await session.close(); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts index bff992e2ff..8cb8649e26 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts @@ -35,15 +35,14 @@ describe("CodexAppServerAgentClient spawn error handling", () => { }); test("fetchCatalog settles when the refresh deadline aborts an unanswered initialize", async () => { - const client = new CodexAppServerAgentClient(logger, { - command: { - mode: "replace", - argv: ["/nonexistent/codex-binary-that-does-not-exist"], - }, - }); const child = createCodexAppServerChildProcess(); const kill = vi.spyOn(child, "kill"); - Object.assign(client, { spawnAppServer: async () => child }); + const client = new CodexAppServerAgentClient(logger, undefined, { + appServerProcess: { + resolvePrefix: async () => ({ command: "codex", args: [] }), + spawn: () => child, + }, + }); await expect( runProviderRefreshWithDeadline({ diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 40935c2668..4d19e42e78 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -87,6 +87,7 @@ describe("Codex executable discovery", () => { import { CodexAppServerClient } from "./codex/app-server-transport.js"; import { + createCodexAppServerChildProcess, createFakeCodexAppServer, fakeCodexThreadHistory, type FakeCodexAppServer, @@ -185,18 +186,24 @@ function createProviderWithFakeAppServer( ): CodexAppServerAgentClient { const provider = new CodexAppServerAgentClient(createTestLogger(), options?.runtimeSettings, { customProvider: options?.customProvider, + appServerProcess: createFakeCodexAppServerProcess(() => appServer.child), }); const internals = castInternals<{ goalsEnabledPromise: Promise | null; autoReviewEnabledPromise: Promise | null; - spawnAppServer: () => Promise; }>(provider); internals.goalsEnabledPromise = Promise.resolve(false); internals.autoReviewEnabledPromise = Promise.resolve(false); - internals.spawnAppServer = async () => appServer.child; return provider; } +function createFakeCodexAppServerProcess(spawn: () => ChildProcessWithoutNullStreams) { + return { + resolvePrefix: async () => ({ command: "codex", args: [] }), + spawn: () => spawn(), + }; +} + async function startPublicSteeringSession( appServer: FakeCodexAppServer, resolveSlashCommandInvocation?: (prompt: AgentPromptInput) => Promise<{ @@ -626,24 +633,25 @@ function codexAgentItem(text: string) { // app-server serving `threads` from thread/read. function createReopenableCodexAgent(threads: Record) { const appServers: FakeCodexAppServer[] = []; - const provider = new CodexAppServerAgentClient(createTestLogger()); + const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, { + appServerProcess: createFakeCodexAppServerProcess(() => { + const appServer = createFakeCodexAppServer({ + "thread/read": (params) => { + const { threadId } = params as { threadId: string }; + return { thread: { id: threadId, turns: threads[threadId] ?? [] } }; + }, + }); + appServers.push(appServer); + return appServer.child; + }), + }); const internals = castInternals<{ goalsEnabledPromise: Promise | null; autoReviewEnabledPromise: Promise | null; - spawnAppServer: () => Promise; }>(provider); internals.goalsEnabledPromise = Promise.resolve(false); internals.autoReviewEnabledPromise = Promise.resolve(false); - internals.spawnAppServer = async () => { - const appServer = createFakeCodexAppServer({ - "thread/read": (params) => { - const { threadId } = params as { threadId: string }; - return { thread: { id: threadId, turns: threads[threadId] ?? [] } }; - }, - }); - appServers.push(appServer); - return appServer.child; - }; + // The fake app-server stands in for the Codex CLI, which test machines may not have. const client: AgentClient = { provider: "codex", @@ -2373,10 +2381,9 @@ describe("Codex app-server provider", () => { return { thread: { id: "native-thread-id" } }; }, }); - const provider = new CodexAppServerAgentClient(createTestLogger()); - castInternals<{ spawnAppServer: () => Promise }>( - provider, - ).spawnAppServer = async () => appServer.child; + const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, { + appServerProcess: createFakeCodexAppServerProcess(() => appServer.child), + }); await provider.unarchiveNativeSession({ provider: "codex", @@ -2398,10 +2405,9 @@ describe("Codex app-server provider", () => { return { thread: { id: "native-thread-id" } }; }, }); - const provider = new CodexAppServerAgentClient(createTestLogger()); - castInternals<{ spawnAppServer: () => Promise }>( - provider, - ).spawnAppServer = async () => appServer.child; + const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, { + appServerProcess: createFakeCodexAppServerProcess(() => appServer.child), + }); await provider.archiveNativeSession({ provider: "codex", @@ -2423,10 +2429,9 @@ describe("Codex app-server provider", () => { return { thread: { id: "persisted-thread-id" } }; }, }); - const provider = new CodexAppServerAgentClient(createTestLogger()); - castInternals<{ spawnAppServer: () => Promise }>( - provider, - ).spawnAppServer = async () => appServer.child; + const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, { + appServerProcess: createFakeCodexAppServerProcess(() => appServer.child), + }); await provider.unarchiveNativeSession({ provider: "codex", @@ -2455,10 +2460,9 @@ describe("Codex app-server provider", () => { return { thread: { id: "active-thread-id", turns: [] } }; }, }); - const provider = new CodexAppServerAgentClient(createTestLogger()); - castInternals<{ spawnAppServer: () => Promise }>( - provider, - ).spawnAppServer = async () => appServer.child; + const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, { + appServerProcess: createFakeCodexAppServerProcess(() => appServer.child), + }); await provider.unarchiveNativeSession({ provider: "codex", @@ -2488,10 +2492,9 @@ describe("Codex app-server provider", () => { return Promise.reject(new Error("thread not found")); }, }); - const provider = new CodexAppServerAgentClient(createTestLogger()); - castInternals<{ spawnAppServer: () => Promise }>( - provider, - ).spawnAppServer = async () => appServer.child; + const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, { + appServerProcess: createFakeCodexAppServerProcess(() => appServer.child), + }); await expect( provider.unarchiveNativeSession({ @@ -7530,6 +7533,52 @@ describe("Codex app-server provider", () => { }); describe("Codex importable sessions", () => { + test("serializes concurrent app-server startups while allowing all scans to complete", async () => { + const firstEntered = deferred(); + const releaseFirst = deferred(); + let spawned = 0; + let activeInitializations = 0; + let maxActiveInitializations = 0; + let disposed = 0; + const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, { + appServerProcess: createFakeCodexAppServerProcess(() => { + spawned++; + return createCodexAppServerChildProcess(); + }), + _createCodexClient: () => ({ + request: async (method) => { + if (method === "initialize") { + activeInitializations++; + maxActiveInitializations = Math.max(maxActiveInitializations, activeInitializations); + if (spawned === 1) { + firstEntered.resolve(); + await releaseFirst.promise; + } + activeInitializations--; + return {}; + } + if (method === "thread/list") return { data: [] }; + throw new Error(`Unexpected Codex method: ${method}`); + }, + notify: () => {}, + dispose: async () => { + disposed++; + }, + }), + }); + + const scans = Array.from({ length: 24 }, () => provider.listImportableSessions({ limit: 1 })); + await firstEntered.promise; + expect(spawned).toBe(1); + releaseFirst.resolve(); + const results = await Promise.all(scans); + + expect(results).toEqual(Array.from({ length: 24 }, () => [])); + expect(spawned).toBe(24); + expect(maxActiveInitializations).toBe(1); + expect(disposed).toBe(24); + }); + const CODEX_THREAD_PAGE_CAP = 100; // Codex answers thread/list with at most 100 rows per response whatever limit @@ -7792,19 +7841,8 @@ describe("Codex importable sessions", () => { const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, { _createCodexClient: () => fakeClient, + appServerProcess: createFakeCodexAppServerProcess(createCodexAppServerChildProcess), }); - castInternals<{ spawnAppServer: () => Promise }>( - provider, - ).spawnAppServer = async () => { - const child = new EventEmitter() as ChildProcessWithoutNullStreams; - child.exitCode = 0; - child.signalCode = null; - child.stdin = new PassThrough(); - child.stdout = new PassThrough(); - child.stderr = new PassThrough(); - child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"]; - return child; - }; const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" }); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 2f4ba468ba..b79cc26a2b 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -75,7 +75,7 @@ import { probeExecutable, } from "../../../executable-resolution/executable-resolution.js"; import { createPathEquivalenceMatcher } from "../../../utils/path.js"; -import { spawnProcess } from "../../../utils/spawn.js"; +import { spawnProcess, type SpawnProcessOptions } from "../../../utils/spawn.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; import { buildCodexFeatures, @@ -94,6 +94,7 @@ import { type CodexThreadRollbackResponse, type CodexAppServerTraceContext, } from "./codex/app-server-transport.js"; +import { codexAppServerStartup } from "./codex/app-server-startup.js"; import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js"; import { type CodexHistoryRow, @@ -291,14 +292,31 @@ const CODEX_MODES: AgentMode[] = [ const DEFAULT_CODEX_MODE_ID = "auto"; interface CodexAppServerClientLike { - request(method: string, params?: unknown): Promise; + request(method: string, params?: unknown, timeoutMs?: number): Promise; forkThread?(params: CodexThreadForkParams): Promise; rollbackThread?(params: CodexThreadRollbackParams): Promise; notify(method: string, params?: unknown): void; dispose(): Promise; } +interface CodexAppServerLaunchPrefix { + command: string; + args: string[]; +} + +interface CodexAppServerProcessSpawnInput extends CodexAppServerLaunchPrefix { + options: SpawnProcessOptions; +} + +interface CodexAppServerProcessPort { + resolvePrefix(options: { + runtimeSettings?: ProviderRuntimeSettings; + }): Promise; + spawn(input: CodexAppServerProcessSpawnInput): ChildProcess; +} + interface CodexAppServerAgentDeps { + appServerProcess?: CodexAppServerProcessPort; environment?: Record; workspaceGitService?: Pick; customProvider?: { @@ -610,10 +628,9 @@ export async function findDefaultCodexBinary(): Promise { return await findCodexMicrosoftStoreBinary(); } -async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSettings): Promise<{ - command: string; - args: string[]; -}> { +async function resolveCodexLaunchPrefix( + runtimeSettings?: ProviderRuntimeSettings, +): Promise { const launch = await resolveCodexLaunch(runtimeSettings); const availability = await checkCodexLaunchAvailable(launch); if (!availability.available) { @@ -628,6 +645,11 @@ async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSetting }; } +const defaultCodexAppServerProcess: CodexAppServerProcessPort = { + resolvePrefix: ({ runtimeSettings }) => resolveCodexLaunchPrefix(runtimeSettings), + spawn: ({ command, args, options }) => spawnProcess(command, args, options), +}; + async function resolveCodexLaunch( runtimeSettings?: ProviderRuntimeSettings, ): Promise { @@ -3369,6 +3391,14 @@ export function buildCodexAppServerEnv( }); } +function codexStartupStateDirKey(environment: NodeJS.ProcessEnv): string { + const home = + process.platform === "win32" + ? environment.USERPROFILE || os.homedir() + : environment.HOME || os.homedir(); + return path.resolve(environment.CODEX_HOME || path.join(home, ".codex")); +} + function buildCodexAppServerInitializeParams(): { clientInfo: { name: string; title: string; version: string }; capabilities: { experimentalApi: true; mcpServerOpenaiFormElicitation: true }; @@ -3566,6 +3596,7 @@ export class CodexAppServerAgentSession implements AgentSession { private unpairedCompactionItemCompletions = 0; private connectionState: "disconnected" | "history-ready" | "connected" = "disconnected"; private connectionPromise: Promise | null = null; + private readonly startupAbort = new AbortController(); private closed = false; private collaborationModes: Array<{ name: string; @@ -3683,8 +3714,15 @@ export class CodexAppServerAgentSession implements AgentSession { this.connectionState = "history-ready"; return; } - const child = await this.spawnAppServer(); - const client = new CodexAppServerClient(child, this.logger, () => this.traceContext()); + const { client, initialized } = await codexAppServerStartup.start({ + stateDirKey: codexStartupStateDirKey(this.harnessEnvironment), + spawn: this.spawnAppServer, + createClient: (child) => + new CodexAppServerClient(child, this.logger, () => this.traceContext()), + initializeParams: buildCodexAppServerInitializeParams(), + logger: this.logger, + signal: this.startupAbort.signal, + }); if (this.closed) { await client.dispose(); throw this.createClosedError(); @@ -3697,11 +3735,9 @@ export class CodexAppServerAgentSession implements AgentSession { this.registerRequestHandlers(); try { - const initialized = toObjectRecord( - await client.request("initialize", buildCodexAppServerInitializeParams()), + this.threadRollbackAvailable = codexServerHasThreadRollback( + toObjectRecord(initialized)?.userAgent, ); - this.threadRollbackAvailable = codexServerHasThreadRollback(initialized?.userAgent); - client.notify("initialized", {}); this.speedModels = CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? []; @@ -4029,11 +4065,16 @@ export class CodexAppServerAgentSession implements AgentSession { } private async readArchivedHistory(): Promise { - const child = await this.spawnAppServer(); - const client = new CodexAppServerClient(child, this.logger, () => this.traceContext()); + const { client } = await codexAppServerStartup.start({ + stateDirKey: codexStartupStateDirKey(this.harnessEnvironment), + spawn: this.spawnAppServer, + createClient: (child) => + new CodexAppServerClient(child, this.logger, () => this.traceContext()), + initializeParams: buildCodexAppServerInitializeParams(), + logger: this.logger, + signal: this.startupAbort.signal, + }); try { - await client.request("initialize", buildCodexAppServerInitializeParams()); - client.notify("initialized", {}); return await this.readThreadHistory(client); } finally { await client.dispose(); @@ -5296,6 +5337,7 @@ export class CodexAppServerAgentSession implements AgentSession { async close(): Promise { this.closed = true; + this.startupAbort.abort(this.createClosedError()); this.background.stop(); this.clearPendingPermissions(); this.pendingToolCallDetails.clear(); @@ -7534,31 +7576,55 @@ export class CodexAppServerAgentClient implements AgentClient { } } - private async spawnAppServer( - launchEnv?: Record, - options?: { goalsEnabled?: boolean; agentId?: string; environment?: Record }, - ): Promise { - const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings); + private async prepareAppServerSpawn(options: { + environment: Record; + goalsEnabled?: boolean; + agentId?: string; + }): Promise<() => ChildProcessWithoutNullStreams> { + const processPort = this.deps.appServerProcess ?? defaultCodexAppServerProcess; + const launchPrefix = await processPort.resolvePrefix({ runtimeSettings: this.runtimeSettings }); const args = [...launchPrefix.args, "app-server"]; - if (options?.goalsEnabled) { + if (options.goalsEnabled) { args.push("--enable", "goals"); } - this.logger.trace( - { - agentId: options?.agentId, - provider: CODEX_PROVIDER, - launchPrefix, - goalsEnabled: options?.goalsEnabled === true, - }, - "provider.codex.spawn", - ); - const child = spawnProcess(launchPrefix.command, args, { - detached: process.platform !== "win32", - stdio: ["pipe", "pipe", "pipe"], - baseEnv: options?.environment ?? buildCodexAppServerEnv(this.runtimeSettings, launchEnv), + return () => { + this.logger.trace( + { + agentId: options.agentId, + provider: CODEX_PROVIDER, + launchPrefix, + goalsEnabled: options.goalsEnabled === true, + }, + "provider.codex.spawn", + ); + const child = processPort.spawn({ + command: launchPrefix.command, + args, + options: { + detached: process.platform !== "win32", + stdio: ["pipe", "pipe", "pipe"], + baseEnv: options.environment, + }, + }); + assertChildWithPipes(child); + return child; + }; + } + + private async startAppServer( + options: { signal?: AbortSignal } = {}, + ): Promise { + const environment = buildCodexAppServerEnv(this.runtimeSettings); + const spawn = await this.prepareAppServerSpawn({ environment }); + const { client } = await codexAppServerStartup.start({ + stateDirKey: codexStartupStateDirKey(environment), + spawn, + createClient: (child) => new CodexAppServerClient(child, this.logger), + initializeParams: buildCodexAppServerInitializeParams(), + logger: this.logger, + signal: options.signal, }); - assertChildWithPipes(child); - return child; + return client; } async createSession( @@ -7577,16 +7643,16 @@ export class CodexAppServerAgentClient implements AgentClient { const goalsEnabled = await this.resolveGoalsEnabled(); const autoReviewEnabled = await this.resolveAutoReviewEnabled(); const environment = buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env); + const spawn = await this.prepareAppServerSpawn({ + environment, + goalsEnabled, + agentId: launchContext?.agentId, + }); const session = new CodexAppServerAgentSession( sessionConfig, null, this.logger, - () => - this.spawnAppServer(launchContext?.env, { - goalsEnabled, - agentId: launchContext?.agentId, - environment, - }), + async () => spawn(), { ...this.sessionDeps(launchContext?.env), environment }, options?.persistSession === false, goalsEnabled, @@ -7614,16 +7680,16 @@ export class CodexAppServerAgentClient implements AgentClient { const goalsEnabled = await this.resolveGoalsEnabled(); const autoReviewEnabled = await this.resolveAutoReviewEnabled(); const environment = buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env); + const spawn = await this.prepareAppServerSpawn({ + environment, + goalsEnabled, + agentId: launchContext?.agentId, + }); const session = new CodexAppServerAgentSession( merged, handle, this.logger, - () => - this.spawnAppServer(launchContext?.env, { - goalsEnabled, - agentId: launchContext?.agentId, - environment, - }), + async () => spawn(), { ...this.sessionDeps(launchContext?.env), environment }, false, goalsEnabled, @@ -7638,15 +7704,19 @@ export class CodexAppServerAgentClient implements AgentClient { async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { - const child = await this.spawnAppServer(); - const client = - this.deps._createCodexClient?.(child, this.logger, () => ({})) ?? - new CodexAppServerClient(child, this.logger); + const environment = buildCodexAppServerEnv(this.runtimeSettings); + const spawn = await this.prepareAppServerSpawn({ environment }); + const { client } = await codexAppServerStartup.start({ + stateDirKey: codexStartupStateDirKey(environment), + spawn, + createClient: (child) => + this.deps._createCodexClient?.(child, this.logger, () => ({})) ?? + new CodexAppServerClient(child, this.logger), + initializeParams: buildCodexAppServerInitializeParams(), + logger: this.logger, + }); try { - await client.request("initialize", buildCodexAppServerInitializeParams()); - client.notify("initialized", {}); - const limit = options?.limit ?? 20; const scanLimit = Math.min(options?.scanLimit ?? limit, 500); // thread/list returns the cheap `cwd` field. Fetch a wider window when @@ -7749,15 +7819,10 @@ export class CodexAppServerAgentClient implements AgentClient { try { await runProviderRefreshActivity(context, "app-server.start", async () => { - const child = await this.spawnAppServer(); - client = new CodexAppServerClient(child, this.logger); + client = await this.startAppServer({ signal: context?.signal }); if (context?.signal.aborted) await dispose(); }); if (!client) throw new Error("Codex app-server did not start"); - await runProviderRefreshActivity(context, "initialize", () => - client!.request("initialize", buildCodexAppServerInitializeParams()), - ); - client.notify("initialized", {}); const rawResponse = await runProviderRefreshActivity(context, "model/list", () => client!.request("model/list", {}), @@ -7801,12 +7866,9 @@ export class CodexAppServerAgentClient implements AgentClient { const threadId = handle.nativeHandle ?? handle.sessionId; if (!threadId) return; - const child = await this.spawnAppServer(); - const client = new CodexAppServerClient(child, this.logger); + const client = await this.startAppServer(); try { - await client.request("initialize", buildCodexAppServerInitializeParams()); - client.notify("initialized", {}); if (state === "archive") { await client.request("thread/archive", { threadId }); return; diff --git a/packages/server/src/server/agent/providers/codex/app-server-startup.test.ts b/packages/server/src/server/agent/providers/codex/app-server-startup.test.ts new file mode 100644 index 0000000000..9ff257343b --- /dev/null +++ b/packages/server/src/server/agent/providers/codex/app-server-startup.test.ts @@ -0,0 +1,347 @@ +import { describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import { createCodexAppServerChildProcess } from "./test-utils/fake-app-server.js"; +import { CodexAppServerStartupCoordinator } from "./app-server-startup.js"; +import { CodexAppServerExitError } from "./app-server-transport.js"; + +function sqliteStartupError(): CodexAppServerExitError { + return new CodexAppServerExitError({ + exitCode: 1, + exitSignal: null, + stderr: "failed to initialize sqlite state runtime", + }); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe("Codex app-server startup coordinator", () => { + test("returns initialize metadata so sessions retain server capabilities", async () => { + const coordinator = new CodexAppServerStartupCoordinator(); + const client = { + request: async () => ({ userAgent: "codex/0.164.0" }), + notify: () => {}, + dispose: async () => {}, + }; + const started = await coordinator.start({ + stateDirKey: "/codex/metadata", + spawn: createCodexAppServerChildProcess, + createClient: () => client, + initializeParams: {}, + logger: createTestLogger(), + }); + expect(started).toEqual({ client, initialized: { userAgent: "codex/0.164.0" } }); + }); + + test("serializes a burst through initialize but retains all initialized clients", async () => { + const coordinator = new CodexAppServerStartupCoordinator(); + const firstEntered = deferred(); + const releaseFirst = deferred(); + let spawned = 0; + let activeInitializations = 0; + let maxActiveInitializations = 0; + let disposed = 0; + + const starts = Array.from({ length: 24 }, () => + coordinator.start({ + stateDirKey: "/codex/shared", + spawn: () => { + spawned++; + return createCodexAppServerChildProcess(); + }, + createClient: () => ({ + request: async (method: string) => { + expect(method).toBe("initialize"); + activeInitializations++; + maxActiveInitializations = Math.max(maxActiveInitializations, activeInitializations); + if (spawned === 1) { + firstEntered.resolve(); + await releaseFirst.promise; + } + activeInitializations--; + return {}; + }, + notify: vi.fn(), + dispose: async () => { + disposed++; + }, + }), + initializeParams: {}, + logger: createTestLogger(), + }), + ); + + await firstEntered.promise; + expect(spawned).toBe(1); + releaseFirst.resolve(); + const clients = await Promise.all(starts); + + expect(clients).toHaveLength(24); + expect(spawned).toBe(24); + expect(maxActiveInitializations).toBe(1); + expect(disposed).toBe(0); + }); + + test("allows different Codex homes to initialize concurrently", async () => { + const coordinator = new CodexAppServerStartupCoordinator(); + const bothEntered = deferred(); + const release = deferred(); + let entered = 0; + const start = (stateDirKey: string) => + coordinator.start({ + stateDirKey, + spawn: createCodexAppServerChildProcess, + createClient: () => ({ + request: async () => { + entered++; + if (entered === 2) bothEntered.resolve(); + await release.promise; + return {}; + }, + notify: vi.fn(), + dispose: vi.fn(async () => {}), + }), + initializeParams: {}, + logger: createTestLogger(), + }); + + const first = start("/codex/one"); + const second = start("/codex/two"); + await bothEntered.promise; + expect(entered).toBe(2); + release.resolve(); + await Promise.all([first, second]); + }); + + test("retries only SQLite startup failures after disposing the failed child", async () => { + const coordinator = new CodexAppServerStartupCoordinator(); + const events: string[] = []; + let attempt = 0; + const client = await coordinator.start({ + stateDirKey: "/codex/shared", + spawn: () => { + attempt++; + events.push(`spawn ${attempt}`); + return createCodexAppServerChildProcess(); + }, + createClient: () => { + const number = attempt; + return { + request: async () => { + events.push(`initialize ${number}`); + if (number === 1) throw sqliteStartupError(); + return {}; + }, + notify: () => events.push(`initialized ${number}`), + dispose: async () => { + events.push(`dispose ${number}`); + }, + }; + }, + initializeParams: {}, + logger: createTestLogger(), + }); + + expect(client.client).toEqual(expect.objectContaining({ request: expect.any(Function) })); + expect(events).toEqual([ + "spawn 1", + "initialize 1", + "dispose 1", + "spawn 2", + "initialize 2", + "initialized 2", + ]); + }); + + test("does not retry unrelated startup failures", async () => { + const coordinator = new CodexAppServerStartupCoordinator(); + let spawned = 0; + let disposed = 0; + await expect( + coordinator.start({ + stateDirKey: "/codex/shared", + spawn: () => { + spawned++; + return createCodexAppServerChildProcess(); + }, + createClient: () => ({ + request: async () => { + throw new Error("database is locked while reading unrelated configuration"); + }, + notify: vi.fn(), + dispose: async () => { + disposed++; + }, + }), + initializeParams: {}, + logger: createTestLogger(), + }), + ).rejects.toThrow("database is locked while reading unrelated configuration"); + expect(spawned).toBe(1); + expect(disposed).toBe(1); + }); + + test("bounds persistent SQLite failures and leaves the lane usable", async () => { + const coordinator = new CodexAppServerStartupCoordinator(); + let spawned = 0; + let disposed = 0; + let locked = true; + const start = () => + coordinator.start({ + stateDirKey: "/codex/shared", + spawn: () => { + spawned++; + return createCodexAppServerChildProcess(); + }, + createClient: () => ({ + request: async () => { + if (locked) throw sqliteStartupError(); + return {}; + }, + notify: vi.fn(), + dispose: async () => { + disposed++; + }, + }), + initializeParams: {}, + logger: createTestLogger(), + }); + + await expect(start()).rejects.toThrow("failed to initialize sqlite state runtime"); + expect(spawned).toBe(3); + expect(disposed).toBe(3); + locked = false; + await expect(start()).resolves.toEqual( + expect.objectContaining({ + client: expect.objectContaining({ request: expect.any(Function) }), + }), + ); + expect(spawned).toBe(4); + expect(disposed).toBe(3); + }); + + test("a cancelled waiter never spawns or overtakes the active startup", async () => { + const coordinator = new CodexAppServerStartupCoordinator(); + const firstEntered = deferred(); + const releaseFirst = deferred(); + const events: string[] = []; + const first = coordinator.start({ + stateDirKey: "/codex/shared", + spawn: () => { + events.push("spawn first"); + return createCodexAppServerChildProcess(); + }, + createClient: () => ({ + request: async () => { + firstEntered.resolve(); + await releaseFirst.promise; + return {}; + }, + notify: vi.fn(), + dispose: vi.fn(async () => {}), + }), + initializeParams: {}, + logger: createTestLogger(), + }); + await firstEntered.promise; + + const controller = new AbortController(); + const queued = coordinator.start({ + stateDirKey: "/codex/shared", + spawn: () => { + events.push("spawn cancelled"); + return createCodexAppServerChildProcess(); + }, + createClient: () => ({ + request: async () => ({}), + notify: vi.fn(), + dispose: vi.fn(async () => {}), + }), + initializeParams: {}, + logger: createTestLogger(), + signal: controller.signal, + }); + controller.abort(new Error("cancelled")); + await expect(queued).rejects.toThrow("cancelled"); + const next = coordinator.start({ + stateDirKey: "/codex/shared", + spawn: () => { + events.push("spawn next"); + return createCodexAppServerChildProcess(); + }, + createClient: () => ({ + request: async () => ({}), + notify: vi.fn(), + dispose: vi.fn(async () => {}), + }), + initializeParams: {}, + logger: createTestLogger(), + }); + + expect(events).toEqual(["spawn first"]); + releaseFirst.resolve(); + await Promise.all([first, next]); + expect(events).toEqual(["spawn first", "spawn next"]); + }); + + test("a timed-out initialize disposes its child and releases the next waiter", async () => { + vi.useFakeTimers(); + try { + const coordinator = new CodexAppServerStartupCoordinator({ + attemptTimeoutMs: 10, + queueTimeoutMs: 100, + }); + const firstEntered = deferred(); + const events: string[] = []; + const first = coordinator.start({ + stateDirKey: "/codex/shared", + spawn: createCodexAppServerChildProcess, + createClient: () => ({ + request: () => { + firstEntered.resolve(); + return new Promise(() => {}); + }, + notify: vi.fn(), + dispose: async () => { + events.push("dispose first"); + }, + }), + initializeParams: {}, + logger: createTestLogger(), + }); + await firstEntered.promise; + const next = coordinator.start({ + stateDirKey: "/codex/shared", + spawn: () => { + events.push("spawn next"); + return createCodexAppServerChildProcess(); + }, + createClient: () => ({ + request: async () => ({}), + notify: vi.fn(), + dispose: vi.fn(async () => {}), + }), + initializeParams: {}, + logger: createTestLogger(), + }); + + const firstResult = expect(first).rejects.toThrow("Timed out initializing Codex app-server"); + await vi.advanceTimersByTimeAsync(10); + await firstResult; + await expect(next).resolves.toEqual( + expect.objectContaining({ + client: expect.objectContaining({ request: expect.any(Function) }), + }), + ); + expect(events).toEqual(["dispose first", "spawn next"]); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/codex/app-server-startup.ts b/packages/server/src/server/agent/providers/codex/app-server-startup.ts new file mode 100644 index 0000000000..9453e9ffb9 --- /dev/null +++ b/packages/server/src/server/agent/providers/codex/app-server-startup.ts @@ -0,0 +1,189 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import type { Logger } from "pino"; + +import { terminateWithTreeKill } from "../../../../utils/tree-kill.js"; +import { CodexAppServerExitError } from "./app-server-transport.js"; + +const DEFAULT_QUEUE_TIMEOUT_MS = 60_000; +const DEFAULT_ATTEMPT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_ATTEMPTS = 3; + +interface InitializableAppServerClient { + request(method: string, params?: unknown, timeoutMs?: number): Promise; + notify(method: string, params?: unknown): void; + dispose(): Promise; +} + +interface StartupOptions { + stateDirKey: string; + spawn: () => ChildProcessWithoutNullStreams | Promise; + createClient: (child: ChildProcessWithoutNullStreams) => TClient; + initializeParams: unknown; + logger: Pick; + signal?: AbortSignal; +} + +interface StartedAppServer { + client: TClient; + initialized: unknown; +} + +interface CoordinatorOptions { + queueTimeoutMs?: number; + attemptTimeoutMs?: number; + maxAttempts?: number; +} + +async function waitForSignal(operation: Promise, signal: AbortSignal): Promise { + signal.throwIfAborted(); + let onAbort: (() => void) | undefined; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + return await Promise.race([operation, aborted]); + } finally { + if (onAbort) signal.removeEventListener("abort", onAbort); + } +} + +async function disposeUnownedChild(child: ChildProcessWithoutNullStreams): Promise { + const result = await terminateWithTreeKill(child, { + gracefulTimeoutMs: 2_000, + forceTimeoutMs: 1_000, + }); + if (result === "kill-timeout") { + throw new Error("Codex app-server did not report exit after SIGKILL"); + } +} + +export class CodexAppServerStartupCoordinator { + private readonly tails = new Map>(); + private readonly queueTimeoutMs: number; + private readonly attemptTimeoutMs: number; + private readonly maxAttempts: number; + + constructor(options: CoordinatorOptions = {}) { + this.queueTimeoutMs = options.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS; + this.attemptTimeoutMs = options.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS; + this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; + } + + async start( + options: StartupOptions, + ): Promise> { + options.signal?.throwIfAborted(); + const previous = this.tails.get(options.stateDirKey) ?? Promise.resolve(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + // An aborted waiter must not let later waiters pass the current owner. + const tail = previous.then(() => gate); + this.tails.set(options.stateDirKey, tail); + void tail.then( + () => this.tails.get(options.stateDirKey) === tail && this.tails.delete(options.stateDirKey), + ); + + const queueAbort = new AbortController(); + const onAbort = () => queueAbort.abort(options.signal?.reason); + options.signal?.addEventListener("abort", onAbort, { once: true }); + const queueTimer = setTimeout( + () => queueAbort.abort(new Error("Timed out waiting to start Codex app-server")), + this.queueTimeoutMs, + ); + try { + await waitForSignal(previous, queueAbort.signal); + clearTimeout(queueTimer); + options.signal?.throwIfAborted(); + for (let attempt = 1; attempt <= this.maxAttempts; attempt++) { + try { + return await this.startAttempt(options); + } catch (error) { + const isRetryableStartupFailure = + error instanceof CodexAppServerExitError && + error.reason === "sqlite_state_initialization"; + if ( + options.signal?.aborted || + attempt === this.maxAttempts || + !isRetryableStartupFailure + ) { + throw error; + } + options.logger.warn( + { err: error, attempt, maxAttempts: this.maxAttempts }, + "Codex app-server SQLite startup failed; retrying with a fresh process", + ); + } + } + throw new Error("Codex app-server startup attempts exhausted"); + } finally { + clearTimeout(queueTimer); + options.signal?.removeEventListener("abort", onAbort); + release(); + } + } + + private async startAttempt( + options: StartupOptions, + ): Promise> { + const attemptAbort = new AbortController(); + const onAbort = () => attemptAbort.abort(options.signal?.reason); + options.signal?.addEventListener("abort", onAbort, { once: true }); + const attemptTimer = setTimeout( + () => attemptAbort.abort(new Error("Timed out initializing Codex app-server")), + this.attemptTimeoutMs, + ); + let child: ChildProcessWithoutNullStreams | undefined; + let client: TClient | undefined; + try { + attemptAbort.signal.throwIfAborted(); + // Production hands in a synchronous spawn closure. Resolve the binary + // before entering the lane, then create the child while holding it. + const spawnPromise = Promise.resolve(options.spawn()); + try { + child = await waitForSignal(spawnPromise, attemptAbort.signal); + } catch (error) { + if (attemptAbort.signal.aborted) { + // A future asynchronous spawner may finish after cancellation. + void spawnPromise + .then((lateChild) => disposeUnownedChild(lateChild)) + .catch((cleanupError) => + options.logger.warn({ err: cleanupError }, "Failed to clean up late Codex spawn"), + ); + } + throw error; + } + attemptAbort.signal.throwIfAborted(); + client = options.createClient(child); + const initialized = await waitForSignal( + client.request("initialize", options.initializeParams, this.attemptTimeoutMs), + attemptAbort.signal, + ); + attemptAbort.signal.throwIfAborted(); + client.notify("initialized", {}); + return { client, initialized }; + } catch (error) { + try { + if (client) { + await client.dispose(); + } else if (child) { + await disposeUnownedChild(child); + } + } catch (cleanupError) { + options.logger.warn( + { err: cleanupError, startupError: error }, + "Failed to clean up Codex app-server after startup failure", + ); + throw new Error("Codex app-server startup cleanup failed", { cause: cleanupError }); + } + throw error; + } finally { + clearTimeout(attemptTimer); + options.signal?.removeEventListener("abort", onAbort); + } + } +} + +export const codexAppServerStartup = new CodexAppServerStartupCoordinator(); diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts index 84b82b431b..859409ec5b 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts @@ -5,9 +5,57 @@ import { createCodexAppServerChildProcess, createFakeCodexAppServer, } from "./test-utils/fake-app-server.js"; -import { CodexAppServerClient } from "./app-server-transport.js"; +import { CodexAppServerClient, CodexAppServerExitError } from "./app-server-transport.js"; describe("Codex app-server transport", () => { + test("classifies startup diagnostics delivered after the child exits", async () => { + const child = createCodexAppServerChildProcess(); + const client = new CodexAppServerClient(child, createTestLogger()); + const request = client.request("initialize", {}); + const result = expect(request).rejects.toMatchObject({ + name: "CodexAppServerExitError", + reason: "sqlite_state_initialization", + stderr: "failed to initialize sqlite state runtime", + }); + child.exitCode = 1; + child.emit("exit", 1, null); + child.stderr.write("failed to initialize sqlite state runtime"); + child.emit("close", 1, null); + try { + await result; + } finally { + child.stdout.end(); + child.stderr.end(); + child.stdin.end(); + } + }); + + test.each([ + { + stderr: "Error: failed to initialize sqlite state runtime under /isolated/.codex", + reason: "sqlite_state_initialization", + }, + { stderr: "database is locked while loading configuration", reason: "other" }, + ] as const)("classifies a failed startup from child stderr as $reason", async (entry) => { + const child = createCodexAppServerChildProcess(); + const client = new CodexAppServerClient(child, createTestLogger()); + const request = client.request("initialize", {}); + child.stderr.write(entry.stderr); + child.exitCode = 1; + child.emit("exit", 1, null); + + await expect(request).rejects.toMatchObject({ + name: "CodexAppServerExitError", + reason: entry.reason, + exitCode: 1, + exitSignal: null, + stderr: entry.stderr, + } satisfies Partial); + child.stdout.end(); + child.stderr.end(); + child.stdin.end(); + }); + test("ignores non-JSON stdout lines without dropping pending requests", async () => { const child = createCodexAppServerChildProcess(); const client = new CodexAppServerClient(child, createTestLogger()); diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.ts index 5a218e2edb..04793e011a 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.ts @@ -64,6 +64,39 @@ export class CodexAppServerRpcError extends Error { } } +interface CodexAppServerExitDetails { + exitCode: number | null; + exitSignal: NodeJS.Signals | null; + stderr: string; +} + +export class CodexAppServerExitError extends Error { + readonly reason: "sqlite_state_initialization" | "other"; + readonly exitCode: number | null; + readonly exitSignal: NodeJS.Signals | null; + readonly stderr: string; + + constructor(details: CodexAppServerExitDetails) { + const message = + details.exitCode === 0 && !details.exitSignal + ? "Codex app-server exited" + : `Codex app-server exited with code ${details.exitCode ?? "null"} and signal ${details.exitSignal ?? "null"}`; + super(`${message}\n${details.stderr}`.trim()); + this.name = "CodexAppServerExitError"; + this.exitCode = details.exitCode; + this.exitSignal = details.exitSignal; + this.stderr = details.stderr; + // Codex reports this pre-initialize failure only on stderr, not as a + // JSON-RPC error. Classify it here so callers branch on a typed reason. + this.reason = + details.exitCode === 1 && + !details.exitSignal && + details.stderr.includes("failed to initialize sqlite state runtime") + ? "sqlite_state_initialization" + : "other"; + } +} + type RequestHandler = (params: unknown, requestId: number) => unknown; type NotificationHandler = (method: string, params: unknown) => void; type UnexpectedTerminationHandler = (error: Error) => void; @@ -234,14 +267,24 @@ export class CodexAppServerClient { this.handleUnexpectedTermination(err); }); + const reportExit = (code: number | null, signal: NodeJS.Signals | null) => { + this.handleUnexpectedTermination( + new CodexAppServerExitError({ + exitCode: code, + exitSignal: signal, + stderr: this.stderrBuffer, + }), + ); + }; child.on("exit", (code, signal) => { - const message = - code === 0 && !signal - ? "Codex app-server exited" - : `Codex app-server exited with code ${code ?? "null"} and signal ${signal ?? "null"}`; - const error = new Error(`${message}\n${this.stderrBuffer}`.trim()); - this.handleUnexpectedTermination(error); + // Startup retries need complete diagnostics. Node drains the child's + // stdio before close, but exit can precede the final stderr data. + const initializing = Array.from(this.pending.values()).some( + (request) => request.method === "initialize", + ); + if (!initializing) reportExit(code, signal); }); + child.on("close", reportExit); } setUnexpectedTerminationHandler(handler: UnexpectedTerminationHandler): void { diff --git a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts index 106c734753..195d924654 100644 --- a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts +++ b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts @@ -146,6 +146,10 @@ export function createCodexAppServerChildProcess(): CodexAppServerChildProcess { exitCode: null, signalCode: null, }) as CodexAppServerChildProcess; + // A real ChildProcess closes its stdio after exit, then emits close. + child.once("exit", (code, signal) => { + queueMicrotask(() => child.emit("close", code, signal)); + }); child.kill = ((signal?: NodeJS.Signals | number) => { queueMicrotask(() => child.emit("exit", null, signal ?? null)); return true; diff --git a/packages/server/src/server/agent/providers/codex/thread-history.test.ts b/packages/server/src/server/agent/providers/codex/thread-history.test.ts index 8d3d8fce3d..3feb737aa8 100644 --- a/packages/server/src/server/agent/providers/codex/thread-history.test.ts +++ b/packages/server/src/server/agent/providers/codex/thread-history.test.ts @@ -1,4 +1,3 @@ -import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; @@ -27,15 +26,24 @@ async function openCodexThread( const appServer = createFakeCodexAppServer( fakeCodexThreadHistory({ "saved-thread": thread }, options), ); - const provider = new CodexAppServerAgentClient(createTestLogger()); + const provider = new CodexAppServerAgentClient( + createTestLogger(), + { + command: { mode: "replace", argv: ["codex-must-not-run-in-this-test"] }, + }, + { + appServerProcess: { + resolvePrefix: async () => ({ command: "codex", args: [] }), + spawn: () => appServer.child, + }, + }, + ); const internals = provider as unknown as { goalsEnabledPromise: Promise | null; autoReviewEnabledPromise: Promise | null; - spawnAppServer: () => Promise; }; internals.goalsEnabledPromise = Promise.resolve(false); internals.autoReviewEnabledPromise = Promise.resolve(false); - internals.spawnAppServer = async () => appServer.child; const session = await provider.resumeSession({ sessionId: "saved-thread" }); return { appServer, session }; } From b7b38412a0bf40da1e91d30d937c863f0d5964c1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 7 Nov 2026 21:32:14 +0700 Subject: [PATCH 03/30] Keep turn footer timing within the available chat width (#9007) * fix(app): keep turn footer timing within the chat width Let visible timing text determine its height within the available chat width instead of sizing an absolute overlay by character count. Keep actions anchored when duration and timestamp labels swap. * fix(app): keep footer timestamp hover target stable * fix(app): reserve both wrapped timing label heights --- .../app/e2e/browser/agent-turn-end.spec.ts | 78 +++++++++++++++++ packages/app/src/agent-stream/turn-footer.tsx | 4 +- packages/app/src/components/message.tsx | 83 +++++++++++++------ 3 files changed, 138 insertions(+), 27 deletions(-) diff --git a/packages/app/e2e/browser/agent-turn-end.spec.ts b/packages/app/e2e/browser/agent-turn-end.spec.ts index 523297729c..82e6457555 100644 --- a/packages/app/e2e/browser/agent-turn-end.spec.ts +++ b/packages/app/e2e/browser/agent-turn-end.spec.ts @@ -198,3 +198,81 @@ test.describe("Turn end", () => { expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input); }); }); + +async function expectTimingInsideFooter(page: Page): Promise { + const label = page.getByRole("button", { name: /^Worked for/ }).last(); + const paintedLines = await label.getByTestId("turn-footer-timing-label").evaluate((element) => { + const labelBounds = element.parentElement!.getBoundingClientRect(); + const rowBounds = element.closest('[data-testid="turn-footer"]')!.getBoundingClientRect(); + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + const lines: boolean[] = []; + while (walker.nextNode()) { + const range = document.createRange(); + range.selectNodeContents(walker.currentNode); + for (const rect of range.getClientRects()) { + lines.push( + rect.left >= Math.max(rowBounds.left, labelBounds.left) - 0.5 && + rect.right <= Math.min(rowBounds.right, labelBounds.right) + 0.5 && + rect.top >= Math.max(rowBounds.top, labelBounds.top) - 0.5 && + rect.bottom <= Math.min(rowBounds.bottom, labelBounds.bottom) + 0.5, + ); + } + } + return lines; + }); + expect(paintedLines.length).toBeGreaterThan(0); + expect(paintedLines.every(Boolean)).toBe(true); +} + +async function expectFooterWithinWidth(page: Page, width: number): Promise { + const footer = page.getByTestId("turn-footer").last(); + await footer.evaluate((element, availableWidth) => { + element.style.width = `${availableWidth}px`; + }, width); + const label = page.getByRole("button", { name: /^Worked for/ }).last(); + await expect(label).toBeVisible(); + await expectTimingInsideFooter(page); + const controls = footer.getByRole("button", { name: /Copy turn|Fork chat/ }); + const before = await controls.evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + return { x: rect.x, y: rect.y }; + }), + ); + const footerBefore = await footer.boundingBox(); + const labelBefore = await label.boundingBox(); + await label.hover({ position: { x: labelBefore!.width - 2, y: labelBefore!.height - 2 } }); + await expect(label.getByTestId("turn-footer-timing-label")).toContainText(/\d:\d\d/); + expect(await label.boundingBox()).toMatchObject({ + x: labelBefore!.x, + width: labelBefore!.width, + height: labelBefore!.height, + }); + expect( + await controls.evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + return { x: rect.x, y: rect.y }; + }), + ), + ).toEqual(before); + expect(await footer.boundingBox()).toMatchObject({ + x: footerBefore!.x, + y: footerBefore!.y, + width: footerBefore!.width, + }); + await expectTimingInsideFooter(page); + await page.mouse.move(0, 0); + await expect(label.getByTestId("turn-footer-timing-label")).toContainText("Worked for"); +} + +test("footer timing fits the chat column and swapping labels keeps controls in place", async ({ + page, + agent, +}) => { + await openAgentRoute(page, agent); + await expectFooterWithinWidth(page, 140); + await expectFooterWithinWidth(page, 150); + await expectFooterWithinWidth(page, 300); + await expectFooterWithinWidth(page, 880); +}); diff --git a/packages/app/src/agent-stream/turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx index 08fceb4005..6699fb800b 100644 --- a/packages/app/src/agent-stream/turn-footer.tsx +++ b/packages/app/src/agent-stream/turn-footer.tsx @@ -236,8 +236,8 @@ const stylesheet = StyleSheet.create((theme) => ({ turnFooterSlot: { flexDirection: "row", alignItems: "center", - alignSelf: "flex-start", - height: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING, + alignSelf: "stretch", + minHeight: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING, paddingBottom: TURN_FOOTER_BOTTOM_SPACING, }, turnFooterContent: { diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index e73dd4171f..2bc96f6b97 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -557,6 +557,15 @@ interface AssistantTurnFooterProps { const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({ container: { + flex: 1, + minWidth: 0, + minHeight: 24, + flexDirection: "row", + alignItems: "flex-start", + gap: theme.spacing[2], + }, + actions: { + height: 24, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], @@ -568,21 +577,25 @@ const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({ marginTop: 0, marginLeft: -theme.spacing[1], }, - labelWrapper: { - position: "relative", + labelButton: { + flex: 1, + minHeight: 24, + flexDirection: "row", + alignItems: "center", + minWidth: 0, }, - labelSizer: { + label: { + width: "100%", + flexShrink: 0, color: theme.colors.foregroundMuted, fontSize: STREAM_METADATA_FONT_SIZE, + }, + overlappingLabel: { + marginLeft: "-100%", + }, + hiddenLabel: { opacity: 0, }, - labelOverlay: { - position: "absolute", - top: 0, - left: 0, - color: theme.colors.foregroundMuted, - fontSize: STREAM_METADATA_FONT_SIZE, - }, })); const TIMESTAMP_REVEAL_MS = 3000; @@ -649,31 +662,51 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ return ( - {getContent ? ( - + {getContent || canFork ? ( + + {getContent ? ( + + ) : null} + {canFork ? : null} + ) : null} - {canFork ? : null} {primaryLabel ? ( - - {/* Sizer reserves space for whichever label is longer so the - container width is stable across hover transitions. */} - - {primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel} + {/* Both labels participate in layout at the available width. Overlapping their + columns reserves the taller wrapped height without measuring text or characters. */} + + {primaryLabel} + + {canSwap ? ( + + {timestampLabel} - - {showTimestamp ? timestampLabel : primaryLabel} - - + ) : null} ) : null} From ef7de3d066256901396e1f47d9e309d569589b2c Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:01:32 +0000 Subject: [PATCH 04/30] test(server): control recursive watcher reconciliation timing (#9010) Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../internal/native-recursive.test.ts | 162 ++++++++++-------- 1 file changed, 91 insertions(+), 71 deletions(-) diff --git a/packages/server/src/server/file-observer/internal/native-recursive.test.ts b/packages/server/src/server/file-observer/internal/native-recursive.test.ts index 12ab1b18f3..8511e26bbf 100644 --- a/packages/server/src/server/file-observer/internal/native-recursive.test.ts +++ b/packages/server/src/server/file-observer/internal/native-recursive.test.ts @@ -60,6 +60,7 @@ test.each(["existing", "new"])( await Promise.all(removed.map((path) => rm(path))); vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] }); for (const path of removed) notifications.emit("change", "change", path); + // The file-only event uses the eight-second optional audit debounce. await vi.advanceTimersByTimeAsync(8_000); vi.useRealTimers(); await expect @@ -309,79 +310,98 @@ test("a classification shed onto the scoped audit still recovers the file", asyn // re-link `directory` itself back into its parent -- walkSubtree only // descends through that link, so without the re-link a later ancestor // removal can no longer reach anything below the reconciled directory. -test("a recursive-scope reconcile re-links its directory so an ancestor removal still finds nested files", async () => { - const root = await mkdtemp(join(tmpdir(), "native-relink-")); - const paths = createObserverPaths(process.platform); - const deepDirectory = join(root, "a", "b", "c"); - const deepFile = join(deepDirectory, "deep.txt"); - await mkdir(deepDirectory, { recursive: true }); - await writeFile(deepFile, "content"); - const events: FileChange[] = []; - const notifications = new EventEmitter(); - const observer = createFileObserver(); - let active = true; - const backend = createNativeRecursiveBackend( - { - root, - metrics: observer.getDiagnostics(), - isActive: () => active, - isIgnored: () => false, - isPathInside: paths.isInside, - queueEvent: (type, path) => events.push({ type, path }), - fail: (error) => { - throw error; - }, - }, - paths, - (_root, listener) => { - notifications.on("change", listener); - return { - close: () => notifications.removeAllListeners(), - on: (event, onError) => notifications.on(event, onError), - }; - }, - ); - const settled = () => - expect - .poll( - () => { - const diagnostics = backend.getDiagnostics(); - return ( - diagnostics.pendingReconciliationWorkCount === 0 && !diagnostics.reconciliationInFlight - ); +test.each([ + { notification: "ancestor", deletedPath: ["a"] }, + { notification: "file", deletedPath: ["a", "b", "c", "deep.txt"] }, +])( + "a recursive-scope reconcile preserves nested deletions announced by $notification", + async ({ deletedPath }) => { + const root = await mkdtemp(join(tmpdir(), "native-relink-")); + const paths = createObserverPaths(process.platform); + const deepDirectory = join(root, "a", "b", "c"); + const deepFile = join(deepDirectory, "deep.txt"); + await mkdir(deepDirectory, { recursive: true }); + await writeFile(deepFile, "content"); + const events: FileChange[] = []; + const notifications = new EventEmitter(); + const observer = createFileObserver(); + let active = true; + const backend = createNativeRecursiveBackend( + { + root, + metrics: observer.getDiagnostics(), + isActive: () => active, + isIgnored: () => false, + isPathInside: paths.isInside, + queueEvent: (type, path) => events.push({ type, path }), + fail: (error) => { + throw error; }, - { timeout: 10_000 }, - ) - .toBe(true); - try { - await backend.start(); // Tracks a, a/b, a/b/c, and deep.txt. - - // A rename event landing on the already-known directory a/b produces a - // local scope on its parent "a" (rebuilding a's DirectoryEntry from a - // fresh shallow scan) and, once classify()'s real stat() confirms a/b is - // still a directory, a recursive scope on a/b itself -- the same "known - // directory changes in place" shape the directory-churn benchmark - // exercises via atomic replace. - notifications.emit("change", "rename", join(root, "a", "b")); - await settled(); - - // Remove the ancestor while the native event names only a deleted file. - // The scoped scan must report its missing directory as well as that file. - await rm(join(root, "a"), { recursive: true, force: true }); - notifications.emit("change", "rename", deepFile); - await settled(); - - expect(events.filter((event) => event.type === "delete").map((event) => event.path)).toContain( - deepFile, + }, + paths, + (_root, listener) => { + notifications.on("change", listener); + return { + close: () => notifications.removeAllListeners(), + on: (event, onError) => notifications.on(event, onError), + }; + }, ); - expect(events).toContainEqual({ type: "delete", path: deepDirectory }); - } finally { - active = false; - await backend.close(); - await observer.close(); - await rm(root, { recursive: true, force: true }); - } -}, 15_000); + const settled = async () => { + // Classification uses real filesystem I/O and can request another audit. + // Drain it before advancing the debounce clock so no later classification + // leaves a fresh timer behind. + await expect + .poll(() => backend.getDiagnostics().pendingClassificationCount, { timeout: 10_000 }) + .toBe(0); + // The file-only event uses the eight-second optional audit debounce. + await vi.advanceTimersByTimeAsync(8_000); + await expect + .poll( + () => { + const diagnostics = backend.getDiagnostics(); + return ( + diagnostics.pendingReconciliationWorkCount === 0 && + !diagnostics.reconciliationInFlight + ); + }, + { timeout: 10_000 }, + ) + .toBe(true); + }; + try { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] }); + await backend.start(); // Tracks a, a/b, a/b/c, and deep.txt. + + // A rename event landing on the already-known directory a/b produces a + // local scope on its parent "a" (rebuilding a's DirectoryEntry from a + // fresh shallow scan) and, once classify()'s real stat() confirms a/b is + // still a directory, a recursive scope on a/b itself -- the same "known + // directory changes in place" shape the directory-churn benchmark + // exercises via atomic replace. + notifications.emit("change", "rename", join(root, "a", "b")); + await settled(); + + // An ancestor notification must walk the re-linked subtree. A file-only + // notification must also recover the missing directory from its scoped scan. + await rm(join(root, "a"), { recursive: true, force: true }); + notifications.emit("change", "rename", join(root, ...deletedPath)); + await settled(); + + expect( + events.filter((event) => event.type === "delete").map((event) => event.path), + ).toContain(deepFile); + expect(events).toContainEqual({ type: "delete", path: deepDirectory }); + } finally { + vi.useRealTimers(); + active = false; + await backend.close(); + await observer.close(); + await rm(root, { recursive: true, force: true }); + } + }, + 15_000, +); test("a full native inventory audit reports a removed directory", async () => { const root = await mkdtemp(join(tmpdir(), "native-full-audit-")); From c6f0c24bf64d0429ac75688156cd485d7b79126e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 7 Nov 2026 23:00:13 +0700 Subject: [PATCH 05/30] Make a folded turn's summary line its own chat row (#9009) * fix(app): make a folded turn's summary line its own chat row The summary line of a collapsed turn was drawn inside its prompt's row, so the row gap landed below the summary and the prompt touched it. Turn collapse now emits a `turn_summary` row between the host and the reply, and one spacing rule covers every prompt: 16 above whatever answers it (reply, summary row or tool row). The summary row spaces like the work rows it stands for, 4 above the reply and flush under a tool host. This removes the per-row summary lookup, the host revision tracking and the fragments in the view. A fold reuses a turn's previous summary row object while its work and open state are unchanged, so mounted rows do not re-render. * fix(app): keep the turn summary row at its host's timeline position The chat outline resolves the active prompt from the timeline position of the row under the reading line. The summary row had none, so the outline lost its mark while the row sat there. The row now carries its host's position and is replaced when that position lands. * fix(app): keep the reader on a folding turn's host, not its summary row When an older page completes the oldest loaded turn and it folds, the reading position moves from the hidden rows to the turn's host row, as on main, so the prompt and its summary row both land in view. The pagination helper now finds the summary line in the row under the prompt. --- .../app/e2e/browser/turn-collapse.spec.ts | 21 +++ .../support/helpers/timeline-pagination.ts | 6 +- .../app/src/agent-stream/presentation.test.ts | 34 +++-- packages/app/src/agent-stream/presentation.ts | 5 +- packages/app/src/agent-stream/spacing.test.ts | 36 +++++ packages/app/src/agent-stream/spacing.ts | 23 ++-- .../agent-stream/turn-collapse/model.test.ts | 115 +++++++++------- .../src/agent-stream/turn-collapse/model.ts | 126 +++++++++--------- .../turn-collapse/summary-line.tsx | 14 +- packages/app/src/agent-stream/view.tsx | 58 ++------ .../src/agent-stream/web-virtualization.ts | 1 + .../app/src/plugins/timeline/projection.ts | 1 + .../app/src/runtime/replica-cache/index.ts | 3 + packages/app/src/types/stream.ts | 23 +++- 14 files changed, 279 insertions(+), 187 deletions(-) diff --git a/packages/app/e2e/browser/turn-collapse.spec.ts b/packages/app/e2e/browser/turn-collapse.spec.ts index bcfca2f763..37d7de28ab 100644 --- a/packages/app/e2e/browser/turn-collapse.spec.ts +++ b/packages/app/e2e/browser/turn-collapse.spec.ts @@ -63,6 +63,26 @@ async function rowsBetweenLatestSummaryAndReply( }, replyTestId); } +/** The latest turn's summary line is a chat row of its own, sitting apart from its prompt. */ +async function expectSummaryLineSitsApartFromPrompt(page: Page) { + const summaryRow = () => + page.evaluate(() => { + const latest = (testId: string) => + [...document.querySelectorAll(`[data-testid="${testId}"]`)].at(-1)!; + const summary = latest("turn-summary"); + const row = summary.closest("[data-message-id]")!; + return { + sharesRowWithPrompt: row.querySelector("[data-testid=user-message]") !== null, + gapBelowPrompt: Math.round( + summary.getBoundingClientRect().top - + latest("user-message").getBoundingClientRect().bottom, + ), + }; + }); + await expect.poll(summaryRow).toMatchObject({ sharesRowWithPrompt: false }); + expect((await summaryRow()).gapBelowPrompt).toBeGreaterThan(0); +} + async function groupedToolsInsideLatestTurn(page: Page): Promise { return page.evaluate(() => { const rows = [...document.querySelectorAll("[data-message-id]")]; @@ -208,6 +228,7 @@ export default function contribute(client) { /\(end of synthetic stream\)$/, ); await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); + await expectSummaryLineSitsApartFromPrompt(page); await summary.click(); await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0); diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts index f54fd86812..ed69897e4e 100644 --- a/packages/app/e2e/support/helpers/timeline-pagination.ts +++ b/packages/app/e2e/support/helpers/timeline-pagination.ts @@ -575,8 +575,10 @@ export async function expectFoldedTurnAtReadingPosition(page: Page, prompt: stri const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt }); await expect(promptRow).toBeVisible({ timeout: 15_000 }); - // The host row carries the folded turn's summary line. - await expect(promptRow.getByTestId("turn-summary")).toBeVisible(); + // The folded turn's summary line is the row under its prompt. + const hostId = await promptRow.getAttribute("data-history-row-id"); + const summaryRow = timeline.locator(`[data-history-row-id="turn-summary:${hostId}"]`); + await expect(summaryRow.getByTestId("turn-summary")).toBeVisible(); await expect .poll( async () => { diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index 9f8213d716..48a8ab757b 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -608,8 +608,11 @@ it("folds work before a plugin-rendered reply and leaves the reply visible", () display: "collapseTurns", isTurnActive: false, }); - expect(result.tail.map((item) => item.id)).toEqual(["prompt", "probe-plugin/reply/0"]); - expect([...result.turns.turnsByHostId.keys()]).toEqual(["prompt"]); + expect(result.tail.map((item) => item.id)).toEqual([ + "prompt", + "turn-summary:prompt", + "probe-plugin/reply/0", + ]); expect(pluginData(result.tail)).toEqual([{ text: "$$x^2$$", phase: "complete" }]); }); @@ -636,12 +639,14 @@ it("keeps streaming replies native, then folds work without losing manual expans const completed = { ...input, tail: [...work, reply], head: [], isTurnActive: false }; expect(present(completed).tail.map((item) => item.id)).toEqual([ "prompt", + "turn-summary:prompt", "probe-plugin/reply/0", ]); const expandedTurnHostIds = new Set(["prompt"]); const opened = present({ ...completed, expandedTurnHostIds }); expect(opened.tail.map((item) => item.id)).toEqual([ "prompt", + "turn-summary:prompt", "tool-one", "probe-plugin/reply/0", ]); @@ -649,6 +654,7 @@ it("keeps streaming replies native, then folds work without losing manual expans expect(present({ ...completed, expandedTurnHostIds }).tail).toEqual(opened.tail); expect(present(completed).tail.map((item) => item.id)).toEqual([ "prompt", + "turn-summary:prompt", "probe-plugin/reply/0", ]); }); @@ -675,7 +681,7 @@ it.each(["full", "collapseToolCalls"] as const)( "tool-one", "probe-plugin/reply/0", ]); - expect(result.turns.turnsByHostId.size).toBe(0); + expect(result.turns.summaryRowsByHostId.size).toBe(0); }, ); @@ -697,7 +703,13 @@ it("opening a turn reveals individual calls rather than another tool group", () isTurnActive: false, expandedTurnHostIds: new Set(["prompt"]), }); - expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id)); + expect(result.tail.map((item) => item.id)).toEqual([ + "prompt", + "turn-summary:prompt", + "tool-one", + "tool-two", + "reply", + ]); expect(result.groupsByHostId.size).toBe(0); expect(result.readingRowReplacements.get("tool-two")).toBeUndefined(); }); @@ -739,7 +751,7 @@ it.each(["collapseToolCalls", "collapseTurns"] as const)( { kind: "assistant_message", id: "reply", text: "Done", timestamp }, ], }); - // Both the original partial group and its replacement resolve directly to the turn. + // Both the original partial group and its replacement resolve directly to the turn's host. expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt"); expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt"); const opened = present({ ...input, tail: calls, display: "full" }); @@ -788,7 +800,7 @@ it("does not reopen the previous turn while a new run waits for its first row", isTurnActive: true, activeTurnId: "new", }); - expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]); + expect(result.tail.map((item) => item.id)).toEqual(["prompt", "turn-summary:prompt", "reply"]); }); // A sent prompt counts as activity before the daemon opens its turn, so the only known turn is @@ -810,7 +822,7 @@ it("keeps the previous turn folded while a sent prompt waits for its turn to ope isTurnActive: true, activeTurnId: null, }); - expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]); + expect(result.tail.map((item) => item.id)).toEqual(["prompt", "turn-summary:prompt", "reply"]); }); it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => { @@ -867,7 +879,13 @@ it.each([false, true])( const closed = present(input); expect(closed.tail.some((item) => item.id === "thought")).toBe(false); const opened = present({ ...input, expandedTurnHostIds: new Set(["prompt"]) }); - expect(opened.tail).toEqual(tail); + expect(opened.tail.map((item) => item.id)).toEqual([ + "prompt", + "turn-summary:prompt", + "thought", + "tool-one", + "reply", + ]); expect(opened.turns.nestedItemIds.has("thought")).toBe(true); expect(opened.groupsByHostId.size).toBe(0); }, diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts index 9a5ff6469e..8e197aed23 100644 --- a/packages/app/src/agent-stream/presentation.ts +++ b/packages/app/src/agent-stream/presentation.ts @@ -50,7 +50,7 @@ const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({ isLatestTurnOpen: false, expandedHostIds: new Set(), getGroupedCalls: () => undefined, - previousTurnsByHostId: new Map(), + previousSummaryRowsByHostId: new Map(), }); /** Source messages reach plugins before display projection. */ @@ -135,7 +135,8 @@ export function createStreamPresentation() { isLatestTurnOpen, expandedHostIds: input.expandedTurnHostIds, getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls, - previousTurnsByHostId: collapsed?.turnsByHostId ?? NO_COLLAPSED_TURNS.turnsByHostId, + previousSummaryRowsByHostId: + collapsed?.summaryRowsByHostId ?? NO_COLLAPSED_TURNS.summaryRowsByHostId, }); collapsedSource = projection.tail; collapsedIsLatestTurnOpen = isLatestTurnOpen; diff --git a/packages/app/src/agent-stream/spacing.test.ts b/packages/app/src/agent-stream/spacing.test.ts index 07e9f185ff..3f84f0df5e 100644 --- a/packages/app/src/agent-stream/spacing.test.ts +++ b/packages/app/src/agent-stream/spacing.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { getAssistantMessageSpacing, getGapBetweenStreamItems } from "./spacing"; +import { summarizeToolCalls } from "@/tool-calls/detail-level/overview/model"; import type { StreamItem } from "@/types/stream"; const message: StreamItem = { kind: "assistant_message", @@ -7,6 +8,27 @@ const message: StreamItem = { text: "First\n\nSecond", timestamp: new Date(0), }; +const prompt: StreamItem = { + kind: "user_message", + id: "p", + text: "Fix it", + timestamp: new Date(0), +}; +const thought: StreamItem = { + kind: "thought", + id: "t", + text: "Reading", + timestamp: new Date(0), + status: "ready", +}; +const summary: StreamItem = { + kind: "turn_summary", + id: "turn-summary:p", + hostId: "p", + expanded: false, + timestamp: new Date(0), + tools: summarizeToolCalls([]), +}; describe("whole-message spacing", () => { it("compacts only the gap to its turn footer", () => { expect(getAssistantMessageSpacing({ item: message })).toBe("default"); @@ -17,4 +39,18 @@ describe("whole-message spacing", () => { it("keeps separate messages separated", () => { expect(getGapBetweenStreamItems(message, { ...message, id: "next" })).toBeGreaterThan(0); }); + it("sets a prompt the same distance above whatever answers it", () => { + const belowPrompt = getGapBetweenStreamItems(prompt, thought); + expect(belowPrompt).toBeGreaterThan(0); + expect(getGapBetweenStreamItems(prompt, message)).toBe(belowPrompt); + expect(getGapBetweenStreamItems(prompt, summary)).toBe(belowPrompt); + }); + it("spaces a turn's summary row like the work rows it stands for", () => { + expect(getGapBetweenStreamItems(summary, message)).toBe( + getGapBetweenStreamItems(thought, message), + ); + expect(getGapBetweenStreamItems(thought, summary)).toBe( + getGapBetweenStreamItems(thought, thought), + ); + }); }); diff --git a/packages/app/src/agent-stream/spacing.ts b/packages/app/src/agent-stream/spacing.ts index 55761f71f3..3caaa9f21e 100644 --- a/packages/app/src/agent-stream/spacing.ts +++ b/packages/app/src/agent-stream/spacing.ts @@ -11,8 +11,12 @@ export function getAssistantMessageSpacing(params: { } const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message"; -const isToolSequenceItem = (item?: StreamItem | null) => - item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list"; +/** A row of the agent's work, including the summary row that stands in for a folded turn's. */ +const isWorkItem = (item?: StreamItem | null) => + item?.kind === "tool_call" || + item?.kind === "thought" || + item?.kind === "todo_list" || + item?.kind === "turn_summary"; export function getGapBetweenStreamItems( item: StreamItem | null, @@ -25,19 +29,16 @@ export function getGapBetweenStreamItems( if (isUserMessageItem(item) && isUserMessageItem(belowItem)) { return SPACING[1]; } - if (item.kind === "user_message" && belowItem.kind === "assistant_message") { - return 0; - } - if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) { - return 0; - } - if (item.kind === "user_message" && isToolSequenceItem(belowItem)) { + if (isUserMessageItem(item)) { return SPACING[4]; } - if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) { + if (isWorkItem(item) && isWorkItem(belowItem)) { + return 0; + } + if (item.kind === "assistant_message" && isWorkItem(belowItem)) { return SPACING[1]; } - if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") { + if (isWorkItem(item) && belowItem.kind === "assistant_message") { return SPACING[1]; } return SPACING[4]; diff --git a/packages/app/src/agent-stream/turn-collapse/model.test.ts b/packages/app/src/agent-stream/turn-collapse/model.test.ts index 4fab619628..9c48f5695a 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.test.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.test.ts @@ -56,7 +56,7 @@ function collapse(tail: StreamItem[], options: Partial = {}) isLatestTurnOpen: false, expandedHostIds: new Set(), getGroupedCalls: () => undefined, - previousTurnsByHostId: new Map(), + previousSummaryRowsByHostId: new Map(), ...options, }); } @@ -73,17 +73,29 @@ describe("collapseTurns", () => { pluginReply("final", "right"), ]; const result = collapse(items); - expect(ids(result.tail)).toEqual(["prompt", "math/final/left", "math/final/right"]); + expect(ids(result.tail)).toEqual([ + "prompt", + "turn-summary:prompt", + "math/final/left", + "math/final/right", + ]); expect(result.hostIdByFoldedMessageId.get("acknowledgment")).toBe("prompt"); expect(result.hostIdByFoldedMessageId.has("final")).toBe(false); const expanded = collapse(items, { expandedHostIds: new Set(["prompt"]) }); - expect(expanded.tail).toEqual(items); + expect(ids(expanded.tail)).toEqual([ + "prompt", + "turn-summary:prompt", + "acknowledgment", + "call", + "math/final/left", + "math/final/right", + ]); expect([...expanded.nestedItemIds]).toEqual(["acknowledgment", "call"]); }); it("folds intermediate plugin replies even without tool calls", () => { const result = collapse([user("prompt"), pluginReply("intermediate"), pluginReply("final")]); - expect(ids(result.tail)).toEqual(["prompt", "math/final/0"]); + expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "math/final/0"]); expect(result.hostIdByFoldedMessageId.get("intermediate")).toBe("prompt"); }); @@ -93,7 +105,7 @@ describe("collapseTurns", () => { const card = { ...pluginReply("card"), sourceKind }; const items = [user("prompt"), shell("call"), card]; expect(collapse(items).tail).toEqual(items); - expect(collapse(items).turnsByHostId.size).toBe(0); + expect(collapse(items).summaryRowsByHostId.size).toBe(0); }, ); @@ -102,38 +114,41 @@ describe("collapseTurns", () => { expect(collapse(items, { isLatestTurnOpen: true }).tail).toEqual(items); }); - // A history row renders again only when its item changes, so a prompt that is already retained - // when its turn closes needs its row revised to show the summary line. - it("reports the hosts whose summary line appeared, changed or went away", () => { - const open = collapse([user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2")], { + // A mounted row renders again only when its item changes, so a turn whose work and open state + // are unchanged keeps its summary row object across folds of the same history. + it("keeps a summary row until its turn's work or open state changes", () => { + const first = collapse([user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2")], { isLatestTurnOpen: true, }); - expect([...open.updatedHostIds]).toEqual(["u1"]); const closed = collapse( [user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")], - { - previousTurnsByHostId: open.turnsByHostId, - }, + { previousSummaryRowsByHostId: first.summaryRowsByHostId }, ); - expect([...closed.updatedHostIds]).toEqual(["u2"]); - const unchanged = collapse( - [user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")], - { - previousTurnsByHostId: closed.turnsByHostId, - }, - ); - expect(unchanged.updatedHostIds.size).toBe(0); + expect(closed.summaryRowsByHostId.get("u1")).toBe(first.summaryRowsByHostId.get("u1")); const moreWork = collapse( [user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), shell("c3"), reply("r2")], - { previousTurnsByHostId: unchanged.turnsByHostId }, + { previousSummaryRowsByHostId: closed.summaryRowsByHostId }, ); - expect([...moreWork.updatedHostIds]).toEqual(["u2"]); - const rewound = collapse([user("u1"), shell("c1"), reply("r1")], { - previousTurnsByHostId: moreWork.turnsByHostId, + expect(moreWork.summaryRowsByHostId.get("u1")).toBe(closed.summaryRowsByHostId.get("u1")); + expect(moreWork.summaryRowsByHostId.get("u2")).not.toBe(closed.summaryRowsByHostId.get("u2")); + const opened = collapse(moreWork.tail, { + previousSummaryRowsByHostId: moreWork.summaryRowsByHostId, + expandedHostIds: new Set(["u1"]), }); - expect([...rewound.updatedHostIds]).toEqual(["u2"]); + expect(opened.summaryRowsByHostId.get("u1")).not.toBe(moreWork.summaryRowsByHostId.get("u1")); }); - it("maps hidden rows only while their turn is closed", () => { + it("stands at its host's timeline position, taking a new row when that position lands", () => { + const live = collapse([user("u1"), shell("c1"), reply("r1")]); + expect(live.summaryRowsByHostId.get("u1")).not.toHaveProperty("timelineCursor"); + const cursor = { epoch: "epoch-1", seq: 7 }; + const placed = [{ ...user("u1"), timelineCursor: cursor }, shell("c1"), reply("r1")]; + const canonical = collapse(placed, { previousSummaryRowsByHostId: live.summaryRowsByHostId }); + expect(canonical.summaryRowsByHostId.get("u1")).not.toBe(live.summaryRowsByHostId.get("u1")); + expect(canonical.summaryRowsByHostId.get("u1")?.timelineCursor).toEqual(cursor); + const again = collapse(placed, { previousSummaryRowsByHostId: canonical.summaryRowsByHostId }); + expect(again.summaryRowsByHostId.get("u1")).toBe(canonical.summaryRowsByHostId.get("u1")); + }); + it("maps hidden rows to their turn's host only while the turn is closed", () => { const result = collapse([user("u1"), shell("c1"), reply("final")]); expect(result.hostIdByHiddenRowId.get("c1")).toBe("u1"); const expanded = collapse([user("u1"), shell("c1"), reply("final")], { @@ -168,15 +183,15 @@ describe("collapseTurns", () => { reply("done"), ].map((item) => Object.assign(item, { turnId: "same-run" })); const result = collapse(items); - expect(ids(result.tail)).toEqual(["u1", "finished", "compaction", "done"]); - expect(result.turnsByHostId.get("u1")?.summary.tools).toMatchObject({ + expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "finished", "compaction", "done"]); + expect(result.summaryRowsByHostId.get("u1")?.tools).toMatchObject({ commandCount: 1, otherToolCount: 0, paseoActions: { "Created agent": 1 }, }); }); - it("folds a finished turn's work into its user message and keeps the reply", () => { + it("folds a finished turn's work into a summary row under its user message and keeps the reply", () => { const result = collapse([ user("u1"), thought("t1"), @@ -186,14 +201,16 @@ describe("collapseTurns", () => { reply("final"), ]); - expect(ids(result.tail)).toEqual(["u1", "final"]); - expect(result.turnsByHostId.get("u1")).toEqual({ + expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "final"]); + expect(result.tail[1]).toEqual({ + kind: "turn_summary", + id: "turn-summary:u1", hostId: "u1", + timestamp: at, expanded: false, - summary: { - tools: expect.objectContaining({ commandCount: 2 }), - }, + tools: expect.objectContaining({ commandCount: 2 }), }); + expect(result.summaryRowsByHostId.get("u1")).toBe(result.tail[1]); expect(result.hostIdByFoldedMessageId.get("early")).toBe("u1"); }); @@ -202,9 +219,9 @@ describe("collapseTurns", () => { expandedHostIds: new Set(["u1"]), }); - expect(ids(result.tail)).toEqual(["u1", "t1", "c1", "final"]); + expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "t1", "c1", "final"]); expect([...result.nestedItemIds]).toEqual(["t1", "c1"]); - expect(result.turnsByHostId.get("u1")?.expanded).toBe(true); + expect(result.summaryRowsByHostId.get("u1")?.expanded).toBe(true); }); it("leaves the latest turn alone while it is still producing output", () => { @@ -212,8 +229,8 @@ describe("collapseTurns", () => { const result = collapse(tail, { isLatestTurnOpen: true }); - expect(ids(result.tail)).toEqual(["u1", "r1", "u2", "c2", "r2"]); - expect([...result.turnsByHostId.keys()]).toEqual(["u1"]); + expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "u2", "c2", "r2"]); + expect([...result.summaryRowsByHostId.keys()]).toEqual(["u1"]); }); it("keeps rows after the reply, and turns with no work before the reply, as they are", () => { @@ -226,8 +243,8 @@ describe("collapseTurns", () => { reply("r2"), ]); - expect(ids(result.tail)).toEqual(["u1", "r1", "after", "u2", "r2"]); - expect([...result.turnsByHostId.keys()]).toEqual(["u1"]); + expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "after", "u2", "r2"]); + expect([...result.summaryRowsByHostId.keys()]).toEqual(["u1"]); }); it("does not fold a turn that only thought before its reply", () => { @@ -236,7 +253,7 @@ describe("collapseTurns", () => { const result = collapse(tail); expect(result.tail).toBe(tail); - expect(result.turnsByHostId.size).toBe(0); + expect(result.summaryRowsByHostId.size).toBe(0); }); it("does not fold a turn without a reply or whose prompt is above the loaded history", () => { @@ -245,7 +262,7 @@ describe("collapseTurns", () => { const result = collapse(tail); expect(result.tail).toBe(tail); - expect(result.turnsByHostId.size).toBe(0); + expect(result.summaryRowsByHostId.size).toBe(0); }); it("keeps a completed response folded when a promptless canonical turn starts", () => { @@ -254,15 +271,15 @@ describe("collapseTurns", () => { ); const next = { ...shell("heartbeat"), turnId: "second" }; const result = collapse([...first, next], { isLatestTurnOpen: true }); - expect(ids(result.tail)).toEqual(["u1", "r1", "heartbeat"]); - expect(result.turnsByHostId.has("u1")).toBe(true); + expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "heartbeat"]); + expect(result.tail[1]).toMatchObject({ kind: "turn_summary", turnId: "first" }); }); it("keeps mid-turn messages visible when work is folded", () => { const tail = [user("prompt"), shell("work"), user("steer"), shell("more"), reply("done")]; for (const item of tail) item.turnId = "same-turn"; const result = collapse(tail); - expect(ids(result.tail)).toEqual(["prompt", "steer", "done"]); + expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "steer", "done"]); expect(result.hostIdByFoldedMessageId.has("steer")).toBe(false); }); @@ -272,7 +289,7 @@ describe("collapseTurns", () => { expandedHostIds: new Set(["prompt"]), getGroupedCalls: (id) => (id === "one" ? calls : undefined), }); - expect(ids(result.tail)).toEqual(["prompt", "one", "two", "done"]); + expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "one", "two", "done"]); expect([...result.nestedItemIds]).toEqual(["one", "two"]); }); @@ -282,8 +299,6 @@ describe("collapseTurns", () => { getGroupedCalls: (itemId) => (itemId === "c1" ? group : undefined), }); - expect(result.turnsByHostId.get("u1")?.summary).toMatchObject({ - tools: { commandCount: 3 }, - }); + expect(result.summaryRowsByHostId.get("u1")?.tools).toMatchObject({ commandCount: 3 }); }); }); diff --git a/packages/app/src/agent-stream/turn-collapse/model.ts b/packages/app/src/agent-stream/turn-collapse/model.ts index 0821c74401..fe931afed5 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.ts @@ -3,35 +3,25 @@ import { getStreamItemMessageId, type StreamItem, type ThoughtItem, + type TimelinePosition, type ToolCallItem, + type TurnSummaryItem, } from "@/types/stream"; import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model"; -export interface TurnSummary { - tools: OverviewSummary; -} - -export interface CollapsedTurn { - /** The input or first loaded row that carries the turn's summary line. */ - hostId: string; - expanded: boolean; - summary: TurnSummary; -} - export interface TurnCollapse { tail: StreamItem[]; - turnsByHostId: ReadonlyMap; /** Rows of expanded turns, which render inside the turn's indent rail. */ nestedItemIds: ReadonlySet; - /** The turn a folded message belongs to, for surfaces that must reveal it. */ + /** The host of the turn a folded message belongs to, for surfaces that must reveal it. */ hostIdByFoldedMessageId: ReadonlyMap; /** Mounted rows replaced by a closed turn's host, for preserving the reading position. */ hostIdByHiddenRowId: ReadonlyMap; /** - * Hosts whose summary line appeared, changed or went away since `previousTurnsByHostId`. A - * history row renders again only when its item changes, so these rows are revised. + * The summary rows by host. A mounted row renders again only when its item changes, so a fold + * keeps the previous row object for a turn whose work and open state are unchanged. */ - updatedHostIds: ReadonlySet; + summaryRowsByHostId: ReadonlyMap; } export interface CollapseTurnsInput { @@ -41,39 +31,39 @@ export interface CollapseTurnsInput { expandedHostIds: ReadonlySet; /** The calls a tool-call group row stands for; undefined for a single call. */ getGroupedCalls: (itemId: string) => readonly (ToolCallItem | ThoughtItem)[] | undefined; - /** The previous fold of the same history, the baseline for `updatedHostIds`. */ - previousTurnsByHostId: ReadonlyMap; + /** The previous fold of the same history, whose unchanged summary rows are reused. */ + previousSummaryRowsByHostId: ReadonlyMap; } -const EMPTY_TURNS: ReadonlyMap = new Map(); const EMPTY_IDS: ReadonlySet = new Set(); +const EMPTY_ROWS: ReadonlyMap = new Map(); const EMPTY_HOSTS: ReadonlyMap = new Map(); function summarizeTurn( items: readonly StreamItem[], getGroupedCalls: CollapseTurnsInput["getGroupedCalls"], -): TurnSummary { +): OverviewSummary { const calls: (ToolCallItem | ThoughtItem)[] = []; for (const item of items) { if ((item.kind === "tool_call" || item.kind === "thought") && !isTurnTrigger(item)) calls.push(...(getGroupedCalls(item.id) ?? [item])); } - return { tools: summarizeToolCalls(calls) }; + return summarizeToolCalls(calls); } -function hasWork(summary: TurnSummary): boolean { - const { paseoActions, thoughtCount: _thoughtCount, ...counts } = summary.tools; +function hasWork(tools: OverviewSummary): boolean { + const { paseoActions, thoughtCount: _thoughtCount, ...counts } = tools; return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0); } -function isSameTurn(a: CollapsedTurn, b: CollapsedTurn): boolean { - const { paseoActions: actionsA, ...countsA } = a.summary.tools; - const { paseoActions: actionsB, ...countsB } = b.summary.tools; - return ( - a.expanded === b.expanded && - hasSameCounts(countsA, countsB) && - hasSameCounts(actionsA, actionsB) - ); +function hasSameWork(a: OverviewSummary, b: OverviewSummary): boolean { + const { paseoActions: actionsA, ...countsA } = a; + const { paseoActions: actionsB, ...countsB } = b; + return hasSameCounts(countsA, countsB) && hasSameCounts(actionsA, actionsB); +} + +function hasSamePosition(a: TimelinePosition | undefined, b: TimelinePosition | undefined) { + return a === b || (a !== undefined && b !== undefined && a.epoch === b.epoch && a.seq === b.seq); } function hasSameCounts(a: Record, b: Record): boolean { @@ -81,19 +71,32 @@ function hasSameCounts(a: Record, b: Record): bo return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]); } -function findUpdatedHosts( - previous: ReadonlyMap, - next: ReadonlyMap, -): ReadonlySet { - const updated = new Set(); - for (const [hostId, turn] of next) { - const before = previous.get(hostId); - if (!before || !isSameTurn(before, turn)) updated.add(hostId); +function summaryRow( + host: StreamItem, + expanded: boolean, + tools: OverviewSummary, + previous: TurnSummaryItem | undefined, +): TurnSummaryItem { + if ( + previous && + previous.expanded === expanded && + hasSameWork(previous.tools, tools) && + hasSamePosition(previous.timelineCursor, host.timelineCursor) + ) { + return previous; } - for (const hostId of previous.keys()) { - if (!next.has(hostId)) updated.add(hostId); - } - return updated.size === 0 ? EMPTY_IDS : updated; + return { + kind: "turn_summary", + id: `turn-summary:${host.id}`, + // The row stands at its host's place in the timeline, so the reading position over it + // still resolves to the turn's prompt. + ...(host.timelineCursor !== undefined ? { timelineCursor: host.timelineCursor } : {}), + ...(host.turnId !== undefined ? { turnId: host.turnId } : {}), + timestamp: host.timestamp, + hostId: host.id, + expanded, + tools, + }; } function staysVisibleWhenCollapsed(item: StreamItem): boolean { @@ -124,15 +127,15 @@ function findLastReplyIndex(tail: StreamItem[], start: number, end: number): num } /** - * Folds each answered turn into its user message: everything the agent did before its last - * message collapses into one summary line, and that message stays in the chat. A turn folds - * only once it is complete, its user message is loaded, and it sent messages or called tools - * before its reply. Reasoning is revealed only when the turn opens. + * Folds each answered turn behind a summary row under its user message: everything the agent + * did before its last message collapses into that row, and the last message stays in the chat. + * A turn folds only once it is complete, its user message is loaded, and it sent messages or + * called tools before its reply. Reasoning is revealed only when the turn opens. */ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { const { tail } = input; const output: StreamItem[] = []; - const turnsByHostId = new Map(); + const summaryRowsByHostId = new Map(); const nestedItemIds = new Set(); const hostIdByFoldedMessageId = new Map(); const hostIdByHiddenRowId = new Map(); @@ -152,13 +155,19 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { return; } const folded = tail.slice(runStart, replyIndex); - const summary = summarizeTurn(folded, input.getGroupedCalls); - if (!hasWork(summary) && !folded.some(isAssistantReply)) { + const tools = summarizeTurn(folded, input.getGroupedCalls); + if (!hasWork(tools) && !folded.some(isAssistantReply)) { output.push(...tail.slice(runStart, runEnd)); return; } const expanded = input.expandedHostIds.has(host.id); - turnsByHostId.set(host.id, { hostId: host.id, expanded, summary }); + const summary = summaryRow( + host, + expanded, + tools, + input.previousSummaryRowsByHostId.get(host.id), + ); + summaryRowsByHostId.set(host.id, summary); const revealed = expanded ? folded.flatMap((item) => item.kind === "tool_call" || item.kind === "thought" @@ -173,6 +182,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { else hostIdByHiddenRowId.set(item.id, host.id); } output.push( + summary, ...(expanded ? revealed : folded.filter((item) => staysVisibleWhenCollapsed(item))), ); output.push(...tail.slice(replyIndex, runEnd)); @@ -190,26 +200,20 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { } flushRun(tail.length); - const updatedHostIds = findUpdatedHosts(input.previousTurnsByHostId, turnsByHostId); - if (turnsByHostId.size === 0) { + if (summaryRowsByHostId.size === 0) { return { tail, - turnsByHostId: EMPTY_TURNS, nestedItemIds: EMPTY_IDS, hostIdByFoldedMessageId: EMPTY_HOSTS, hostIdByHiddenRowId: EMPTY_HOSTS, - updatedHostIds, + summaryRowsByHostId: EMPTY_ROWS, }; } return { - tail: - output.length === tail.length && output.every((item, index) => item === tail[index]) - ? tail - : output, - turnsByHostId, + tail: output, nestedItemIds, hostIdByFoldedMessageId, hostIdByHiddenRowId, - updatedHostIds, + summaryRowsByHostId, }; } diff --git a/packages/app/src/agent-stream/turn-collapse/summary-line.tsx b/packages/app/src/agent-stream/turn-collapse/summary-line.tsx index 22660b4606..289a6f748f 100644 --- a/packages/app/src/agent-stream/turn-collapse/summary-line.tsx +++ b/packages/app/src/agent-stream/turn-collapse/summary-line.tsx @@ -4,25 +4,25 @@ import { Summary } from "lucide-react-native"; import { useDisclosureHold } from "@/agent-stream/disclosure"; import { ExpandableBadge } from "@/components/message"; import { useOverviewSummaryLabel } from "@/tool-calls/detail-level/overview/label"; -import type { CollapsedTurn } from "./model"; +import type { TurnSummaryItem } from "@/types/stream"; /** * The row a collapsed turn leaves under its user message, styled like a tool-call group. * Pressing it opens the turn's rows beneath it. */ export const TurnSummaryLine = memo(function TurnSummaryLine({ - turn, + item, onExpandedChange, }: { - turn: CollapsedTurn; + item: TurnSummaryItem; onExpandedChange: (hostId: string, expanded: boolean) => void; }) { const { t } = useTranslation(); - const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details"); + const label = useOverviewSummaryLabel(item.tools) || t("turnSummary.details"); const hold = useDisclosureHold(); const toggle = useCallback( - () => hold(() => onExpandedChange(turn.hostId, !turn.expanded)), - [hold, onExpandedChange, turn.expanded, turn.hostId], + () => hold(() => onExpandedChange(item.hostId, !item.expanded)), + [hold, onExpandedChange, item.expanded, item.hostId], ); return ( @@ -30,7 +30,7 @@ export const TurnSummaryLine = memo(function TurnSummaryLine({ testID="turn-summary" label={label} icon={Summary} - isExpanded={turn.expanded} + isExpanded={item.expanded} onToggle={toggle} borderlessWhenExpanded /> diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index ab9d3b8258..6b039ba74e 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -689,26 +689,23 @@ const AgentStreamViewComponent = forwardRef - presentation.turns.turnsByHostId.get(hostId), - ); + // Read through a stable event so a collapse rerun does not change the renderer identity; + // nested rows are revised through `historyRowRevision`. const getTurnIndent = useStableEvent((layoutItem: StreamLayoutItem): TurnIndent => { const { nestedItemIds } = presentation.turns; - if (nestedItemIds.has(layoutItem.item.id)) { + const { item } = layoutItem; + if (nestedItemIds.has(item.id)) { const below = layoutItem.belowItem; return below && nestedItemIds.has(below.id) ? "nested" : "lastNested"; } - return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none"; + return item.kind === "turn_summary" && item.expanded ? "openedHost" : "none"; }); const setToolCallGroupExpanded = toolCallExpansion.setGroupExpanded; const renderUserMessageItem = useCallback( (layoutItem: StreamLayoutItem, item: Extract) => { - const collapsedTurn = getCollapsedTurn(item.id); - const message = ( + return ( ); - if (!collapsedTurn) { - return message; - } - return ( - <> - {message} - - - ); }, - [ - context.capabilities, - agentId, - client, - getCollapsedTurn, - pendingClientMessageIds, - resolvedServerId, - setTurnExpanded, - ], + [context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId], ); const renderAssistantMessageItem = useCallback( @@ -931,17 +911,10 @@ const AgentStreamViewComponent = forwardRef - {renderToolCallItem(layoutItem, item)} - {getCollapsedTurn(item.id) ? ( - - ) : null} - - ); + return renderToolCallItem(layoutItem, item); + + case "turn_summary": + return ; case "notification": return ; @@ -972,7 +945,6 @@ const AgentStreamViewComponent = forwardRef ({ contentById: { - has: (id: string) => - presentation.historyGroupUpdatesByHostId.has(id) || updatedTurnHostIds.has(id), + has: (id: string) => presentation.historyGroupUpdatesByHostId.has(id), }, displayStateById: { // An opened lone call also marks its future group open; that row stays unrevised // until a group actually forms there. has: (id: string) => (expandedToolCallGroups.has(id) && getToolCallGroup(id) !== undefined) || - expandedTurnHostIds.has(id) || nestedItemIds.has(id), }, globalDisplayState: isMobile, @@ -1171,11 +1141,9 @@ const AgentStreamViewComponent = forwardRef Date: Sat, 7 Nov 2026 23:12:08 +0700 Subject: [PATCH 06/30] Fill short collapsed chat histories (#8358) * fix(app): fill short collapsed chat histories Collapsed whole turns can leave the initial viewport short even when older history exists. Continue paging through measured pages before revealing a fresh chat, while preserving visible history and the reading anchor. * fix(app): settle padded and stalled chat history Measure the native history edge using the same content area as viewport filling, and bound child history requests so retry can start after a stalled fetch. * fix(app): adapt viewport filling to the current timeline * fix(app): resume history filling after switching tabs --- docs/timeline-sync.md | 4 +- .../app/e2e/browser/turn-collapse.spec.ts | 73 ++- .../history-start-pagination.test.ts | 469 +++++++++++------- .../agent-stream/history-start-pagination.ts | 237 ++++----- .../native-history-start-geometry.ts | 12 + .../app/src/agent-stream/strategy-native.tsx | 212 ++++---- .../strategy-web.browser.test.tsx | 2 +- .../src/agent-stream/strategy-web.test.tsx | 4 +- .../app/src/agent-stream/strategy-web.tsx | 147 +++--- packages/app/src/agent-stream/strategy.ts | 4 +- .../use-history-start-pagination.ts | 33 ++ packages/app/src/agent-stream/view.tsx | 12 + .../use-load-older-agent-history.test.ts | 6 +- .../src/hooks/use-load-older-agent-history.ts | 12 +- packages/app/src/panels/agent-panel.tsx | 15 + .../src/panels/provider-subagent-panel.tsx | 29 +- .../app/src/subagents/provider-store.test.ts | 93 +++- packages/app/src/subagents/provider-store.ts | 29 ++ 18 files changed, 885 insertions(+), 508 deletions(-) create mode 100644 packages/app/src/agent-stream/native-history-start-geometry.ts create mode 100644 packages/app/src/agent-stream/use-history-start-pagination.ts diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index e12b4a495a..cef349dbfd 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -69,8 +69,8 @@ Source coverage still includes interleaved tool updates. Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received agent messages each start a fallback turn. This deterministic fallback is shared by paging -and presentation; it cannot recover historical batching or steering. Older history remains -user-driven by scrolling upward. +and presentation; it cannot recover historical batching or steering. An underfilled viewport +loads older history until it fills or history is exhausted. Further history is user-driven. A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling. A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active diff --git a/packages/app/e2e/browser/turn-collapse.spec.ts b/packages/app/e2e/browser/turn-collapse.spec.ts index 37d7de28ab..1ae180fb8e 100644 --- a/packages/app/e2e/browser/turn-collapse.spec.ts +++ b/packages/app/e2e/browser/turn-collapse.spec.ts @@ -491,34 +491,87 @@ async function seedWholeTurnHistory() { return agent; } -test("loads complete long turns and preserves expansion while revealing older history", async ({ - page, -}) => { +async function expectInitialFillIsConcealed(page: Page) { + await expect(page.getByTestId("agent-initial-fill")).toBeVisible(); + await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "0"); +} + +async function expectFilledChatAtBottom(page: Page) { + await expect(page.getByTestId("agent-initial-fill")).toBeHidden(); + const chat = page.getByTestId("agent-chat-scroll"); + await expect(chat).toHaveCSS("opacity", "1"); + await expect + .poll(() => + chat.evaluate((element) => ({ + hasOverflow: element.scrollHeight > element.clientHeight, + atBottom: Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) <= 2, + })), + ) + .toEqual({ hasOverflow: true, atBottom: true }); +} + +test("fills collapsed history before revealing a fresh chat", async ({ page }) => { test.setTimeout(120_000); + await page.setViewportSize({ width: 1280, height: 1000 }); await useChatDisplay(page, "collapseTurns"); const agent = await seedWholeTurnHistory(); try { const history = await holdOlderHistoryPages(page, agent); await openAgentRoute(page, agent); + await history.expectRequestedPages(1); + await expectInitialFillIsConcealed(page); + history.releasePage(1); + await history.expectRequestedPages(2); + await expectInitialFillIsConcealed(page); + history.releasePage(2); + await expectFilledChatAtBottom(page); + await page.setViewportSize({ width: 1280, height: 8000 }); + await history.expectRequestedPages(3); + await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "1"); + await expect(page.getByTestId("agent-initial-fill")).toBeHidden(); + history.releasePage(3); + await history.expectSettledWithRequestedPages(3); + history.expectNoRepeatedEntries(); + } finally { + await agent.cleanup(); + } +}); + +test("loads complete long turns and preserves expansion while revealing older history", async ({ + page, +}) => { + test.setTimeout(120_000); + await page.setViewportSize({ width: 1280, height: 1000 }); + await useChatDisplay(page, "collapseTurns"); + const agent = await seedWholeTurnHistory(); + try { + const history = await holdOlderHistoryPages(page, agent); + await openAgentRoute(page, agent); + await history.expectRequestedPages(1); + history.releasePage(1); + await history.expectRequestedPages(2); + history.releasePage(2); + await expectFilledChatAtBottom(page); await expectComposerVisible(page); const latest = page .getByTestId("turn-summary") .getByRole("button", { name: "Ran 120 commands", exact: true }); await expect(latest).toBeVisible(); - await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); - await latest.click(); - await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120); - await userScrollsTimelineToHistoryStart(page); - await history.expectRequestedPages(1); - history.releasePage(1); await expect( page .getByTestId("turn-summary") .getByRole("button", { name: "Ran 60 commands", exact: true }), ).toBeVisible(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); + await latest.click(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120); + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(3); + history.releasePage(3); + await page.getByTestId("scroll-to-bottom-button").click(); await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120); history.expectNoRepeatedEntries(); - await latest.scrollIntoViewIfNeeded(); + await scrollTimelineRowIntoView(page, latest); await latest.click(); await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); } finally { diff --git a/packages/app/src/agent-stream/history-start-pagination.test.ts b/packages/app/src/agent-stream/history-start-pagination.test.ts index 9663d87e23..4867bcce2e 100644 --- a/packages/app/src/agent-stream/history-start-pagination.test.ts +++ b/packages/app/src/agent-stream/history-start-pagination.test.ts @@ -1,197 +1,310 @@ -import { describe, expect, it } from "vitest"; +import { nativeHistoryStartGeometry } from "./native-history-start-geometry"; +import { describe, expect, it, vi } from "vitest"; import { - abandonHistoryStartPaginationRequest, - createHistoryStartPaginationState, - evaluateHistoryStartPagination, - isHistoryStartLoadingOperation, - rearmHistoryStartPagination, - settleHistoryStartPagination, + createHistoryStartPagination, type HistoryStartPaginationInput, } from "./history-start-pagination"; -function createArmedHistoryStartPaginationState() { - return rearmHistoryStartPagination(createHistoryStartPaginationState()); -} - -const visibleHistoryStart: HistoryStartPaginationInput = { +const initial: HistoryStartPaginationInput = { distanceFromHistoryStart: 0, + isUnderfilled: true, hasOlderHistory: true, isLoadingOlderHistory: false, isReady: true, - progressKey: "epoch-1:20", + progressKey: "20", }; +function harness(initiallyVisible = false) { + let geometry = { ...initial }; + const requests: Array<(value: boolean) => void> = []; + let settles = 0; + const pagination = createHistoryStartPagination({ + initiallyVisible, + read: () => geometry, + load: () => new Promise((resolve) => requests.push(resolve)), + changed: () => {}, + scheduleSettle: () => { + settles++; + }, + }); + return { + pagination, + requests, + get settles() { + return settles; + }, + set(patch: Partial) { + geometry = { ...geometry, ...patch }; + pagination.update(); + }, + }; +} + describe("history start pagination", () => { - it("treats locally revealed history as progress before remote pagination", () => { - const first = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const localReveal = evaluateHistoryStartPagination(first.state, { - ...visibleHistoryStart, - progressKey: "epoch-1:20:local-60", - }); - - expect(first.shouldLoad).toBe(true); - expect(localReveal.state).toEqual({ - status: "settling", - loadedProgressKey: "epoch-1:20:local-60", - }); + it("waits for a delayed request and its committed page before settling", async () => { + const h = harness(); + h.pagination.update(); + h.set({ progressKey: "10" }); + expect(h.settles).toBe(0); + h.requests[0](true); + await Promise.resolve(); + expect(h.settles).toBe(1); + expect(h.pagination.state.visible).toBe(false); + h.set({ isUnderfilled: false }); + h.pagination.settle(); + expect(h.pagination.state).toEqual({ status: "dormant", visible: true }); + h.pagination.dispose(); }); - it("waits for user scroll intent before loading", () => { - const dormant = createHistoryStartPaginationState(); - const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart); - - expect(evaluated).toEqual({ state: { status: "dormant" }, shouldLoad: false }); + it("waits for the committed page after a request completes", async () => { + const h = harness(); + h.pagination.update(); + h.requests[0](true); + await Promise.resolve(); + expect(h.settles).toBe(0); + h.set({ progressKey: "10" }); + expect(h.settles).toBe(1); + h.pagination.dispose(); }); - - it("waits for anchored page geometry before authorizing another page", () => { - const first = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const inFlight = evaluateHistoryStartPagination(first.state, { - ...visibleHistoryStart, - isLoadingOlderHistory: true, - }); - const pageApplied = evaluateHistoryStartPagination(inFlight.state, { - ...visibleHistoryStart, - isLoadingOlderHistory: true, - progressKey: "epoch-1:10", - }); - - expect([first.shouldLoad, inFlight.shouldLoad, pageApplied.shouldLoad]).toEqual([ - true, - false, - false, - ]); - expect(pageApplied.state).toEqual({ status: "settling", loadedProgressKey: "epoch-1:10" }); + it("continues short pages and reveals on exhaustion", async () => { + const h = harness(); + h.pagination.update(); + h.set({ progressKey: "10" }); + h.requests[0](true); + await Promise.resolve(); + h.pagination.settle(); + expect(h.requests).toHaveLength(2); + h.set({ hasOlderHistory: false }); + h.requests[1](true); + await Promise.resolve(); + expect(h.pagination.state).toEqual({ status: "dormant", visible: true }); + h.pagination.dispose(); }); - - it("loads one page each time anchored geometry leaves and returns to history start", () => { - const first = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const pageApplied = evaluateHistoryStartPagination(first.state, { - ...visibleHistoryStart, - progressKey: "epoch-1:10", - }); - const settledAway = settleHistoryStartPagination(pageApplied.state, { - ...visibleHistoryStart, - distanceFromHistoryStart: 300, - progressKey: "epoch-1:10", - }); - const returned = evaluateHistoryStartPagination(settledAway.state, { - ...visibleHistoryStart, - progressKey: "epoch-1:10", - }); - - expect([ - first.shouldLoad, - pageApplied.shouldLoad, - settledAway.shouldLoad, - returned.shouldLoad, - ]).toEqual([true, false, false, true]); + it("releases a failed or stalled request and allows retry", async () => { + vi.useFakeTimers(); + const h = harness(); + h.pagination.update(); + vi.advanceTimersByTime(15_000); + expect(h.pagination.state).toEqual({ status: "failed", visible: true }); + h.pagination.rearm(); + expect(h.requests).toHaveLength(2); + h.requests[1](false); + await Promise.resolve(); + expect(h.pagination.state.status).toBe("failed"); + h.pagination.dispose(); + vi.useRealTimers(); }); - - it("continues the same loading operation when anchored geometry remains at history start", () => { - const first = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const pageApplied = evaluateHistoryStartPagination(first.state, { - ...visibleHistoryStart, - progressKey: "epoch-1:10", - }); - const continued = settleHistoryStartPagination(pageApplied.state, { - ...visibleHistoryStart, - progressKey: "epoch-1:10", - }); - - expect(continued.shouldLoad).toBe(true); - expect(isHistoryStartLoadingOperation(first.state)).toBe(true); - expect(isHistoryStartLoadingOperation(pageApplied.state)).toBe(true); - expect(isHistoryStartLoadingOperation(continued.state)).toBe(true); + it("keeps visible history visible while filling", () => { + const h = harness(true); + h.pagination.update(); + expect(h.pagination.state.visible).toBe(true); + h.pagination.dispose(); }); - - it("latches a request that finishes without cursor progress", () => { - const first = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const inFlight = evaluateHistoryStartPagination(first.state, { - ...visibleHistoryStart, - isLoadingOlderHistory: true, - }); - const finished = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart); - const duplicate = evaluateHistoryStartPagination(finished.state, visibleHistoryStart); - const away = evaluateHistoryStartPagination(duplicate.state, { - ...visibleHistoryStart, - distanceFromHistoryStart: 300, - }); - - expect(finished.state).toEqual({ status: "latched" }); - expect([finished.shouldLoad, duplicate.shouldLoad, away.shouldLoad]).toEqual([ - false, - false, - false, - ]); - expect(away.state).toEqual({ status: "ready" }); + it("does not load a full viewport until user intent and respects inactive views", () => { + const h = harness(); + h.set({ isUnderfilled: false }); + expect(h.requests).toHaveLength(0); + h.set({ isReady: false }); + h.pagination.rearm(); + expect(h.requests).toHaveLength(0); + h.set({ isReady: true }); + expect(h.requests).toHaveLength(1); + h.pagination.dispose(); }); - - it("allows another user attempt after a request finishes without progress", () => { - const first = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const inFlight = evaluateHistoryStartPagination(first.state, { - ...visibleHistoryStart, - isLoadingOlderHistory: true, - }); - const failed = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart); - const retried = evaluateHistoryStartPagination( - rearmHistoryStartPagination(failed.state), - visibleHistoryStart, - ); - - expect(failed.state).toEqual({ status: "latched" }); - expect(retried.shouldLoad).toBe(true); - }); - - it("latches an attempt that becomes invalid before entering flight", () => { - const requested = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - - expect(abandonHistoryStartPaginationRequest(requested.state, "epoch-1:20")).toEqual({ - status: "latched", - }); - }); - - it("does not mistake repeated edge observations for a finished request", () => { - const first = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const repeated = evaluateHistoryStartPagination(first.state, visibleHistoryStart); - - expect(repeated.state).toBe(first.state); - expect(repeated.shouldLoad).toBe(false); - }); - - it("waits while history loading is unavailable or already active", () => { - const state = createArmedHistoryStartPaginationState(); - - expect([ - evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isReady: false }).shouldLoad, - evaluateHistoryStartPagination(state, { ...visibleHistoryStart, hasOlderHistory: false }) - .shouldLoad, - evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isLoadingOlderHistory: true }) - .shouldLoad, - evaluateHistoryStartPagination(state, { ...visibleHistoryStart, progressKey: null }) - .shouldLoad, - ]).toEqual([false, false, false, false]); + it("preserves the reading position after an anchored page moves away from the start", async () => { + const h = harness(true); + h.set({ isUnderfilled: false }); + h.pagination.rearm(); + h.set({ progressKey: "10", distanceFromHistoryStart: 300 }); + h.requests[0](true); + await Promise.resolve(); + h.pagination.settle(); + expect(h.requests).toHaveLength(1); + h.set({ distanceFromHistoryStart: 0 }); + expect(h.requests).toHaveLength(2); + h.pagination.dispose(); }); }); + +it("ignores a timed-out response after the reader retries", async () => { + vi.useFakeTimers(); + const h = harness(); + h.pagination.update(); + vi.advanceTimersByTime(15_000); + h.pagination.rearm(); + h.requests[0](true); + await Promise.resolve(); + expect(h.pagination.state.status).toBe("loading"); + expect(h.settles).toBe(0); + h.requests[1](false); + await Promise.resolve(); + expect(h.pagination.state.status).toBe("failed"); + h.pagination.dispose(); + vi.useRealTimers(); +}); + +it("does not repeat a successful page that made no progress", async () => { + vi.useFakeTimers(); + const h = harness(); + h.pagination.update(); + h.requests[0](true); + await Promise.resolve(); + h.pagination.update(); + expect(h.requests).toHaveLength(1); + vi.advanceTimersByTime(15_000); + expect(h.pagination.state).toEqual({ status: "failed", visible: true }); + h.pagination.dispose(); + vi.useRealTimers(); +}); + +it("waits until the cursor and active history are available", () => { + const h = harness(); + h.set({ isReady: false, progressKey: null }); + h.set({ isReady: true, isLoadingOlderHistory: true }); + h.set({ isLoadingOlderHistory: false }); + expect(h.requests).toHaveLength(0); + h.set({ progressKey: "20" }); + expect(h.requests).toHaveLength(1); + h.pagination.dispose(); +}); + +it("reveals immediately on exhausted history without offering retry", () => { + const h = harness(); + h.set({ hasOlderHistory: false }); + expect(h.requests).toHaveLength(0); + expect(h.pagination.state).toEqual({ status: "dormant", visible: true }); + h.pagination.dispose(); +}); + +it("keeps a settled page pending while its view is inactive", async () => { + const h = harness(); + h.pagination.update(); + h.set({ progressKey: "10" }); + h.requests[0](true); + await Promise.resolve(); + h.set({ isReady: false }); + h.pagination.settle(); + expect(h.pagination.state.status).toBe("settling"); + expect(h.requests).toHaveLength(1); + h.set({ isReady: true, isUnderfilled: false }); + h.pagination.settle(); + expect(h.pagination.state).toEqual({ status: "dormant", visible: true }); + h.pagination.dispose(); +}); + +it("keeps cached history visible after synchronization and subsequent pagination", async () => { + const h = harness(); + h.pagination.reveal(); + h.pagination.update(); + h.set({ progressKey: "10" }); + h.requests[0](true); + await Promise.resolve(); + h.pagination.settle(); + expect(h.pagination.state.visible).toBe(true); + expect(h.requests).toHaveLength(2); + h.pagination.dispose(); +}); + +it.each([true, false])( + "handles native padding when short history has older=%s", + (hasOlderHistory) => { + const geometry = nativeHistoryStartGeometry({ + contentHeight: 825, + viewportHeight: 700, + offsetY: 125, + topOverlayInset: 123, + }); + let requests = 0; + const pagination = createHistoryStartPagination({ + initiallyVisible: false, + read: () => ({ ...initial, ...geometry, hasOlderHistory }), + load: () => { + requests++; + return new Promise(() => {}); + }, + changed: () => {}, + scheduleSettle: () => {}, + }); + pagination.update(); + expect(requests).toBe(hasOlderHistory ? 1 : 0); + expect(pagination.state.visible).toBe(!hasOlderHistory); + pagination.dispose(); + }, +); + +it("uses the forward native list's top edge to request older history", () => { + let offsetY = 1300; + let requests = 0; + const pagination = createHistoryStartPagination({ + initiallyVisible: true, + read: () => ({ + ...initial, + ...nativeHistoryStartGeometry({ + contentHeight: 2000, + viewportHeight: 700, + offsetY, + topOverlayInset: 123, + }), + }), + load: () => { + requests++; + return new Promise(() => {}); + }, + changed: () => {}, + scheduleSettle: () => {}, + }); + pagination.rearm(); + expect(requests).toBe(0); + offsetY = 155; + pagination.update(); + expect(requests).toBe(1); + pagination.dispose(); +}); + +it.each([null, { ...initial, isReady: false }])( + "resumes a completed page after its view stays inactive beyond the deadline (%j)", + async (inactive) => { + vi.useFakeTimers(); + let geometry: HistoryStartPaginationInput | null = { ...initial }; + let complete!: (applied: boolean) => void; + let requests = 0; + const pagination = createHistoryStartPagination({ + initiallyVisible: false, + read: () => geometry, + load: () => { + requests++; + return new Promise((resolve) => { + complete = resolve; + }); + }, + changed: () => {}, + scheduleSettle: () => {}, + }); + try { + pagination.update(); + geometry = inactive; + pagination.update(); + complete(true); + await Promise.resolve(); + vi.advanceTimersByTime(30_000); + expect(pagination.state.status).toBe("loading"); + geometry = { ...initial, progressKey: "10" }; + pagination.update(); + expect(pagination.state.status).toBe("settling"); + geometry = inactive; + pagination.update(); + pagination.settle(); + vi.advanceTimersByTime(30_000); + expect(pagination.state.status).toBe("settling"); + geometry = { ...initial, progressKey: "10" }; + pagination.update(); + pagination.settle(); + expect(requests).toBe(2); + expect(pagination.state.status).toBe("loading"); + vi.advanceTimersByTime(15_000); + expect(pagination.state.status).toBe("failed"); + } finally { + pagination.dispose(); + vi.useRealTimers(); + } + }, +); diff --git a/packages/app/src/agent-stream/history-start-pagination.ts b/packages/app/src/agent-stream/history-start-pagination.ts index 46914ae3e1..9931d4d469 100644 --- a/packages/app/src/agent-stream/history-start-pagination.ts +++ b/packages/app/src/agent-stream/history-start-pagination.ts @@ -1,138 +1,149 @@ export const HISTORY_START_THRESHOLD_PX = 96; -export type HistoryStartPaginationState = - | { status: "dormant" } - | { status: "ready" } - | { status: "loading"; requestedProgressKey: string; requestObserved: boolean } - | { status: "settling"; loadedProgressKey: string } - | { status: "latched" }; - export interface HistoryStartPaginationInput { distanceFromHistoryStart: number; + isUnderfilled: boolean; hasOlderHistory: boolean; isLoadingOlderHistory: boolean; isReady: boolean; progressKey: string | null; } -export interface HistoryStartPaginationTransition { - state: HistoryStartPaginationState; - shouldLoad: boolean; -} - -export function createHistoryStartPaginationState(): HistoryStartPaginationState { - return { status: "dormant" }; +export interface HistoryStartPaginationState { + status: "dormant" | "ready" | "loading" | "settling" | "failed"; + visible: boolean; } export function isHistoryStartLoadingOperation(state: HistoryStartPaginationState): boolean { return state.status === "loading" || state.status === "settling"; } -export function rearmHistoryStartPagination( - state: HistoryStartPaginationState, -): HistoryStartPaginationState { - return state.status === "dormant" || state.status === "latched" ? { status: "ready" } : state; -} +/** Owns one pagination operation, including the initial viewport reveal. */ +export function createHistoryStartPagination(input: { + initiallyVisible: boolean; + read: () => HistoryStartPaginationInput | null; + load: () => Promise; + changed: (state: HistoryStartPaginationState) => void; + scheduleSettle: () => void; +}) { + let state: HistoryStartPaginationState = { status: "dormant", visible: input.initiallyVisible }; + let requestedKey: string | null = null; + let completed = false; + let fill = false; + let generation = 0; + let timeout: ReturnType | undefined; -export function abandonHistoryStartPaginationRequest( - state: HistoryStartPaginationState, - requestedProgressKey: string, -): HistoryStartPaginationState { - if ( - state.status !== "loading" || - state.requestObserved || - state.requestedProgressKey !== requestedProgressKey - ) { - return state; - } - return { status: "latched" }; -} + const publish = (status: HistoryStartPaginationState["status"], visible = state.visible) => { + if (status === state.status && visible === state.visible) return; + state = { status, visible }; + input.changed(state); + }; + const clear = () => { + generation++; + clearTimeout(timeout); + timeout = undefined; + }; + const finish = (status: HistoryStartPaginationState["status"]) => { + clear(); + publish(status, true); + }; + const fail = () => finish("failed"); -export function evaluateHistoryStartPagination( - state: HistoryStartPaginationState, - input: HistoryStartPaginationInput, -): HistoryStartPaginationTransition { - if (state.status === "dormant") { - return { state, shouldLoad: false }; - } - if (state.status === "loading") { - if (input.progressKey !== null && input.progressKey !== state.requestedProgressKey) { - return { - state: { status: "settling", loadedProgressKey: input.progressKey }, - shouldLoad: false, - }; + const watchProgress = (geometry: HistoryStartPaginationInput | null) => { + if (completed && !geometry?.isReady) { + clearTimeout(timeout); + timeout = undefined; + } else if (timeout === undefined) { + const request = generation; + timeout = setTimeout(() => { + timeout = undefined; + // A completed request waits for its retained view to become measurable again. + // Unfinished requests still time out even while the view is inactive. + if (request === generation && (!completed || input.read()?.isReady)) fail(); + }, 15_000); } - if (input.isLoadingOlderHistory && !state.requestObserved) { - return { state: { ...state, requestObserved: true }, shouldLoad: false }; - } - if (!input.isLoadingOlderHistory && !input.hasOlderHistory) { - return { state: { status: "latched" }, shouldLoad: false }; - } - if ( - state.requestObserved && - !input.isLoadingOlderHistory && - input.progressKey === state.requestedProgressKey - ) { - return { state: { status: "latched" }, shouldLoad: false }; - } - return { state, shouldLoad: false }; - } + }; - if (state.status === "settling") { - return { state, shouldLoad: false }; - } - - const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX; - if (!isAtHistoryStart) { - return state.status === "ready" - ? { state, shouldLoad: false } - : { state: { status: "ready" }, shouldLoad: false }; - } - if ( - state.status === "latched" || - !input.isReady || - !input.hasOlderHistory || - input.isLoadingOlderHistory || - input.progressKey === null - ) { - return { state, shouldLoad: false }; - } + const update = () => { + const geometry = input.read(); + if (isHistoryStartLoadingOperation(state)) watchProgress(geometry); + if (!geometry) return; + if (state.status === "loading") { + if (!completed) return; + if (geometry.progressKey !== null && geometry.progressKey !== requestedKey) { + publish("settling"); + input.scheduleSettle(); + } else if (!geometry.hasOlderHistory) { + finish("dormant"); + } + return; + } + if (state.status === "settling" || state.status === "failed" || !geometry.isReady) return; + const atStart = geometry.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX; + if (state.status === "dormant" && !geometry.isUnderfilled) { + publish("dormant", true); + return; + } + if (!atStart || (!geometry.isUnderfilled && state.status !== "ready")) return; + if (!geometry.hasOlderHistory) { + finish("dormant"); + return; + } + if (geometry.isLoadingOlderHistory || geometry.progressKey === null) return; + fill = geometry.isUnderfilled; + requestedKey = geometry.progressKey; + completed = false; + publish("loading"); + const request = ++generation; + watchProgress(geometry); + void input.load().then( + (applied) => { + if (request !== generation) return undefined; + if (!applied) { + fail(); + return undefined; + } + completed = true; + update(); + return undefined; + }, + () => { + if (request === generation) fail(); + }, + ); + }; return { - state: { - status: "loading", - requestedProgressKey: input.progressKey, - requestObserved: false, + get state() { + return state; }, - shouldLoad: true, - }; -} - -export function settleHistoryStartPagination( - state: HistoryStartPaginationState, - input: HistoryStartPaginationInput, -): HistoryStartPaginationTransition { - if (state.status !== "settling") { - return { state, shouldLoad: false }; - } - const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX; - if ( - !isAtHistoryStart || - !input.isReady || - !input.hasOlderHistory || - input.isLoadingOlderHistory || - input.progressKey === null - ) { - return { - state: isAtHistoryStart ? { status: "latched" } : { status: "ready" }, - shouldLoad: false, - }; - } - return { - state: { - status: "loading", - requestedProgressKey: input.progressKey, - requestObserved: false, + update, + settle() { + if (state.status !== "settling") return; + const geometry = input.read(); + if (!geometry?.isReady) return; + clear(); + if (fill && !geometry.isUnderfilled) { + publish("dormant", true); + } else if (geometry.distanceFromHistoryStart > HISTORY_START_THRESHOLD_PX) { + publish("ready", true); + } else if (!geometry.hasOlderHistory) { + publish("dormant", true); + } else { + publish("ready"); + update(); + } }, - shouldLoad: true, + rearm() { + if (state.status === "dormant" || state.status === "failed") publish("ready", true); + update(); + }, + reveal() { + publish(state.status, true); + }, + reset(visible: boolean) { + clear(); + publish("dormant", visible); + }, + dispose: clear, }; } diff --git a/packages/app/src/agent-stream/native-history-start-geometry.ts b/packages/app/src/agent-stream/native-history-start-geometry.ts new file mode 100644 index 0000000000..6df65f48a1 --- /dev/null +++ b/packages/app/src/agent-stream/native-history-start-geometry.ts @@ -0,0 +1,12 @@ +export function nativeHistoryStartGeometry(input: { + contentHeight: number; + viewportHeight: number; + offsetY: number; + topOverlayInset: number; +}) { + const contentHeight = input.contentHeight - 32 - input.topOverlayInset; + return { + distanceFromHistoryStart: Math.max(0, input.offsetY - input.topOverlayInset - 32), + isUnderfilled: contentHeight < input.viewportHeight, + }; +} diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 3288e2be25..ef855902d6 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -25,6 +25,9 @@ import { } from "@legendapp/list/react-native"; import { withUnistyles } from "react-native-unistyles"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { Button } from "@/components/ui/button"; +import { useRetainedPanelActive } from "@/components/retained-panel"; +import { useTranslation } from "react-i18next"; import type { StreamItem } from "@/types/stream"; import type { Theme } from "@/styles/theme"; import { useStableEvent } from "@/hooks/use-stable-event"; @@ -38,15 +41,11 @@ import { createNewRowEstimates } from "./new-row-estimates"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; import { createStreamStrategy, isNearBottomForStreamRenderStrategy } from "./strategy"; import { - abandonHistoryStartPaginationRequest, - createHistoryStartPaginationState, - evaluateHistoryStartPagination, isHistoryStartLoadingOperation, - rearmHistoryStartPagination, - settleHistoryStartPagination, type HistoryStartPaginationInput, - type HistoryStartPaginationTransition, } from "./history-start-pagination"; +import { useHistoryStartPagination } from "./use-history-start-pagination"; +import { nativeHistoryStartGeometry } from "./native-history-start-geometry"; import { useStreamTopOverlayInset } from "./top-overlay"; import { createHistoryStartSettleScheduler, @@ -108,6 +107,16 @@ function distanceFromEnd(input: { }): number { return Math.max(0, input.contentHeight - input.viewportHeight - input.offsetY); } +const viewportStyle: ViewStyle = { flex: 1 }; +const initialFillStyle: ViewStyle = { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + alignItems: "center", + justifyContent: "center", +}; function keyExtractor(item: { id: string }): string { return item.id; @@ -125,6 +134,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat viewportRef, routeBottomAnchorRequest, isAuthoritativeHistoryReady, + isInitialHistoryLoading, + revealInitialHistory, onNearBottomChange, onNearHistoryStart, isLoadingOlderHistory, @@ -135,6 +146,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat baseListContentContainerStyle, strategy, } = props; + const isActive = useRetainedPanelActive(); + const { t } = useTranslation(); const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers; const listRef = useRef(null); // LegendList places the rows once, behind a hidden reveal, when the list mounts. @@ -157,10 +170,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const isReleasedAtEndRef = useRef(false); const programmaticScrollEventBudgetRef = useRef(0); const historyStartReadyRef = useRef(false); - const [historyStartPaginationState, setHistoryStartPaginationState] = useState( - createHistoryStartPaginationState, - ); - const historyStartPaginationStateRef = useRef(historyStartPaginationState); const historyStartSettleSchedulerRef = useRef(null); const historyItems = useMemo(() => { @@ -191,48 +200,36 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat metrics.viewportMeasuredForKey === metrics.containerKey && metrics.contentMeasuredForKey === metrics.containerKey; return { - distanceFromHistoryStart: metrics.offsetY, + ...nativeHistoryStartGeometry({ + contentHeight: metrics.contentHeight, + viewportHeight: metrics.viewportHeight, + offsetY: metrics.offsetY, + topOverlayInset, + }), hasOlderHistory, isLoadingOlderHistory, - isReady: historyStartReadyRef.current && hasMeasuredViewport, + isReady: + isActive && + isAuthoritativeHistoryReady && + historyStartReadyRef.current && + hasMeasuredViewport, progressKey: olderHistoryProgressKey, }; }); - const applyHistoryStartPaginationTransition = useStableEvent( - (transition: HistoryStartPaginationTransition) => { - const previousState = historyStartPaginationStateRef.current; - historyStartPaginationStateRef.current = transition.state; - if (transition.state !== previousState) { - setHistoryStartPaginationState(transition.state); - } - if (transition.shouldLoad) { - const requestedProgressKey = olderHistoryProgressKey; - if (requestedProgressKey === null) { - return; - } - void (async () => { - const started = await onNearHistoryStart(); - if (started === true) { - return; - } - applyHistoryStartPaginationTransition({ - state: abandonHistoryStartPaginationRequest( - historyStartPaginationStateRef.current, - requestedProgressKey, - ), - shouldLoad: false, - }); - })(); - } - }, + const { state: historyStartPaginationState, controller: historyStartPagination } = + useHistoryStartPagination({ + initiallyVisible: !isInitialHistoryLoading, + read: getHistoryStartPaginationInput, + load: async () => onNearHistoryStart(), + scheduleSettle: () => scheduleHistoryStartSettle(), + }); + const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update()); + const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose()); + const isHistoryStartSettling = useStableEvent( + () => historyStartPagination.state.status === "settling", ); - const evaluateHistoryStart = useStableEvent(() => { - const transition = evaluateHistoryStartPagination( - historyStartPaginationStateRef.current, - getHistoryStartPaginationInput(), - ); - applyHistoryStartPaginationTransition(transition); - }); + const finishInitialFill = useStableEvent(() => historyStartPagination.reveal()); + const retryHistoryStart = useStableEvent(() => historyStartPagination.rearm()); const scheduleHistoryStartSettle = useStableEvent(() => { let scheduler = historyStartSettleSchedulerRef.current; if (!scheduler) { @@ -240,15 +237,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat settleFrames: HISTORY_START_SETTLE_FRAMES, requestFrame: requestAnimationFrame, cancelFrame: cancelAnimationFrame, - isSettling: () => historyStartPaginationStateRef.current.status === "settling", + isSettling: () => isHistoryStartSettling(), isLoading: () => getHistoryStartPaginationInput().isLoadingOlderHistory, - onSettle: () => { - const transition = settleHistoryStartPagination( - historyStartPaginationStateRef.current, - getHistoryStartPaginationInput(), - ); - applyHistoryStartPaginationTransition(transition); - }, + onSettle: () => historyStartPagination.settle(), }); historyStartSettleSchedulerRef.current = scheduler; } @@ -390,10 +381,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat return () => { cancelAnimationFrame(frame); clearPendingUserScrollEnd(); + disposeHistoryStart(); historyStartSettleSchedulerRef.current?.cancel(); historyStartSettleSchedulerRef.current = null; }; - }, [clearPendingUserScrollEnd, evaluateHistoryStart]); + }, [clearPendingUserScrollEnd, disposeHistoryStart, evaluateHistoryStart]); useEffect(() => { const handle: StreamViewportHandle = { @@ -506,6 +498,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }); const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent) => { + finishInitialFill(); clearPendingUserScrollEnd(); isUserScrollActiveRef.current = true; scrollKeyboardDismiss.onScrollBeginDrag(event); @@ -514,14 +507,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat // no row LegendList may anchor a history page on. The gesture's end decides following. disclosureHold.cancel(); bottomAnchorController.beginUserScroll(); - const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current); - if (rearmed !== historyStartPaginationStateRef.current) { - historyStartPaginationStateRef.current = rearmed; - setHistoryStartPaginationState(rearmed); - evaluateHistoryStart(); - } + historyStartPagination.rearm(); }); + useEffect(() => { + if (revealInitialHistory) finishInitialFill(); + }, [finishInitialFill, revealInitialHistory]); + // A gesture ends at the end when the finger lifts at or past it, or when its momentum stops there. // A release past the end bounces back to it on iOS, and lines that land during the bounce scroll // in as they do while following. Momentum stopping short of the end, or output that arrived below @@ -598,7 +590,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat publishNearBottom(); } evaluateHistoryStart(); - if (historyStartPaginationStateRef.current.status === "settling") { + if (isHistoryStartSettling()) { scheduleHistoryStartSettle(); } }); @@ -611,19 +603,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat contentMeasuredForKey: "native-virtualized", }; bottomAnchorController.handleContentSizeChange(); + evaluateHistoryStart(); }); useEffect(() => { evaluateHistoryStart(); - if (historyStartPaginationStateRef.current.status === "settling") { + if (isHistoryStartSettling()) { scheduleHistoryStartSettle(); } }, [ evaluateHistoryStart, hasOlderHistory, + isActive, + isAuthoritativeHistoryReady, isLoadingOlderHistory, olderHistoryProgressKey, scheduleHistoryStartSettle, + isHistoryStartSettling, ]); // LegendList re-renders a mounted row only when its item or `extraData` changes, not when @@ -658,51 +654,65 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState); return ( - {isLoadingOperation ? ( + {isLoadingOperation && ( - ) : null} + )} + {!isLoadingOperation && + historyStartPaginationState.status === "failed" && + hasOlderHistory && ( + + )} ); - }, [historyStartPaginationState]); + }, [historyStartPaginationState, hasOlderHistory, retryHistoryStart, t]); return ( - - ref={listRef} - data={rows} - renderItem={renderItem} - extraData={renderHistoryMountedRow} - keyExtractor={keyExtractor} - testID="agent-chat-scroll" - nativeID="agent-chat-scroll-native-virtualized" - ListHeaderComponent={historyStartContent} - ListFooterComponent={liveAuxiliaryContent ?? undefined} - contentContainerStyle={listContentContainerStyle} - automaticallyAdjustContentInsets={false} - automaticallyAdjustsScrollIndicatorInsets={false} - contentInsetAdjustmentBehavior="never" - style={listStyle} - onLayout={handleListLayout} - onScroll={handleScroll} - onScrollBeginDrag={handleScrollBeginDrag} - onScrollEndDrag={handleScrollEndDrag} - onMomentumScrollBegin={handleMomentumScrollBegin} - onMomentumScrollEnd={handleMomentumScrollEnd} - scrollEventThrottle={16} - onContentSizeChange={handleContentSizeChange} - onItemSizeChanged={handleItemSizeChanged} - onReady={handleListPlaced} - maintainVisibleContentPosition={maintainVisibleContentPosition} - renderScrollComponent={renderScrollComponent} - recycleItems={false} - alignItemsAtEnd - initialScrollAtEnd - drawDistance={NATIVE_STREAM_DRAW_DISTANCE} - getEstimatedItemSize={getEstimatedItemSize} - scrollEnabled={scrollEnabled} - showsVerticalScrollIndicator - /> + + + ref={listRef} + data={rows} + renderItem={renderItem} + extraData={renderHistoryMountedRow} + keyExtractor={keyExtractor} + testID="agent-chat-scroll" + nativeID="agent-chat-scroll-native-virtualized" + ListHeaderComponent={historyStartContent} + ListFooterComponent={liveAuxiliaryContent ?? undefined} + contentContainerStyle={listContentContainerStyle} + automaticallyAdjustContentInsets={false} + automaticallyAdjustsScrollIndicatorInsets={false} + contentInsetAdjustmentBehavior="never" + style={[listStyle, { opacity: historyStartPaginationState.visible ? 1 : 0 }]} + onLayout={handleListLayout} + onScroll={handleScroll} + onScrollBeginDrag={handleScrollBeginDrag} + onScrollEndDrag={handleScrollEndDrag} + onMomentumScrollBegin={handleMomentumScrollBegin} + onMomentumScrollEnd={handleMomentumScrollEnd} + scrollEventThrottle={16} + onContentSizeChange={handleContentSizeChange} + onItemSizeChanged={handleItemSizeChanged} + onReady={handleListPlaced} + maintainVisibleContentPosition={maintainVisibleContentPosition} + renderScrollComponent={renderScrollComponent} + recycleItems={false} + alignItemsAtEnd + initialScrollAtEnd + drawDistance={NATIVE_STREAM_DRAW_DISTANCE} + getEstimatedItemSize={getEstimatedItemSize} + scrollEnabled={scrollEnabled} + showsVerticalScrollIndicator + /> + {!historyStartPaginationState.visible ? ( + + + + ) : null} + ); } diff --git a/packages/app/src/agent-stream/strategy-web.browser.test.tsx b/packages/app/src/agent-stream/strategy-web.browser.test.tsx index 28fbd1e2a2..24e8db7e1a 100644 --- a/packages/app/src/agent-stream/strategy-web.browser.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.browser.test.tsx @@ -67,7 +67,7 @@ function mountTimeline(historyMounted: StreamItem[]) { routeBottomAnchorRequest: null, isAuthoritativeHistoryReady: true, onNearBottomChange: () => {}, - onNearHistoryStart: () => true, + onNearHistoryStart: async () => true, isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 5ad0be03c2..3601c8b0b2 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -152,7 +152,7 @@ describe("createWebStreamStrategy", () => { routeBottomAnchorRequest: null, isAuthoritativeHistoryReady: true, onNearBottomChange: () => {}, - onNearHistoryStart: () => true, + onNearHistoryStart: async () => true, isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, @@ -274,7 +274,7 @@ describe("createWebStreamStrategy", () => { routeBottomAnchorRequest: null, isAuthoritativeHistoryReady: true, onNearBottomChange: () => {}, - onNearHistoryStart: () => true, + onNearHistoryStart: async () => true, isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index b511ac72dd..6552b6fb77 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -20,6 +20,7 @@ import { withUnistyles } from "react-native-unistyles"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useStableEvent } from "@/hooks/use-stable-event"; +import { useTranslation } from "react-i18next"; import { getStreamItemMessageId, type StreamItem } from "@/types/stream"; import type { Theme } from "@/styles/theme"; import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; @@ -30,15 +31,10 @@ import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./ import { useRevisedHistoryRows } from "./history-row-revision"; import { createStreamStrategy } from "./strategy"; import { - abandonHistoryStartPaginationRequest, - createHistoryStartPaginationState, - evaluateHistoryStartPagination, isHistoryStartLoadingOperation, - rearmHistoryStartPagination, - settleHistoryStartPagination, type HistoryStartPaginationInput, - type HistoryStartPaginationTransition, } from "./history-start-pagination"; +import { useHistoryStartPagination } from "./use-history-start-pagination"; import { createHistoryStartSettleScheduler, type HistoryStartSettleScheduler, @@ -77,6 +73,14 @@ const historyStartSlotStyle: CSSProperties = { }; const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" }; +const initialFillStyle: CSSProperties = { + position: "absolute", + inset: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + pointerEvents: "none", +}; const streamRowStyle: CSSProperties = { display: "flex", @@ -298,6 +302,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool viewportRef, routeBottomAnchorRequest, isAuthoritativeHistoryReady, + isInitialHistoryLoading, + revealInitialHistory, onNearBottomChange, onReadingPositionChange, onNearHistoryStart, @@ -318,6 +324,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool [historyMounted, historyVirtualized, inputSegments], ); const isActive = useRetainedPanelActive(); + const { t } = useTranslation(); + const rowsHostRef = useRef(null); + const auxiliaryRef = useRef(null); const isActiveRef = useRef(isActive); const scrollContainerRef = useRef(null); const contentRef = useRef(null); @@ -351,10 +360,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const pendingAutoScrollFrameRef = useRef(null); const pendingAutoScrollTimeoutRef = useRef(null); const historyStartReadyRef = useRef(false); - const [historyStartPaginationState, setHistoryStartPaginationState] = useState( - createHistoryStartPaginationState, - ); - const historyStartPaginationStateRef = useRef(historyStartPaginationState); const readingAnchor = useMemo(createReadingAnchor, []); const measuredRowHeights = useRef(new Map()); const historyStartSettleSchedulerRef = useRef(null); @@ -481,60 +486,36 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool !followOutputRef.current || isScrollContainerNearBottom(scrollContainer); return { distanceFromHistoryStart: scrollContainer.scrollTop, + isUnderfilled: + (rowsHostRef.current?.getBoundingClientRect().height ?? 0) + + (auxiliaryRef.current?.getBoundingClientRect().height ?? 0) < + scrollContainer.clientHeight, hasOlderHistory, isLoadingOlderHistory, // An explicit jump owns the landing position. Revealing another local // page here changes the virtual index before the target has mounted. - isReady: historyStartReadyRef.current && bottomAnchorSettled && !isJumpSettling(), + isReady: + historyStartReadyRef.current && + isAuthoritativeHistoryReady && + bottomAnchorSettled && + !isJumpSettling(), progressKey: olderHistoryProgressKey, }; }); - const applyHistoryStartPaginationTransition = useStableEvent( - (transition: HistoryStartPaginationTransition) => { - const previousState = historyStartPaginationStateRef.current; - historyStartPaginationStateRef.current = transition.state; - if (transition.state !== previousState) { - setHistoryStartPaginationState(transition.state); - } - if (!transition.shouldLoad || olderHistoryProgressKey === null) { - return; - } - const requestedProgressKey = olderHistoryProgressKey; - void (async () => { - const started = await onNearHistoryStart(); - if (started === true) { - return; - } - applyHistoryStartPaginationTransition({ - state: abandonHistoryStartPaginationRequest( - historyStartPaginationStateRef.current, - requestedProgressKey, - ), - shouldLoad: false, - }); - })(); - }, + const { state: historyStartPaginationState, controller: historyStartPagination } = + useHistoryStartPagination({ + initiallyVisible: !isInitialHistoryLoading, + read: getHistoryStartPaginationInput, + load: async () => onNearHistoryStart(), + scheduleSettle: () => scheduleHistoryStartPrependSettle(), + }); + const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update()); + const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose()); + const isHistoryStartSettling = useStableEvent( + () => historyStartPagination.state.status === "settling", ); - const evaluateHistoryStart = useStableEvent(() => { - const input = getHistoryStartPaginationInput(); - if (!input) { - return; - } - const transition = evaluateHistoryStartPagination( - historyStartPaginationStateRef.current, - input, - ); - applyHistoryStartPaginationTransition(transition); - }); - const rearmHistoryStartFromUserIntent = useStableEvent(() => { - const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current); - if (rearmed === historyStartPaginationStateRef.current) { - return; - } - historyStartPaginationStateRef.current = rearmed; - setHistoryStartPaginationState(rearmed); - evaluateHistoryStart(); - }); + const finishInitialFill = useStableEvent(() => historyStartPagination.reveal()); + const rearmHistoryStartFromUserIntent = useStableEvent(() => historyStartPagination.rearm()); const scheduleHistoryStartPrependSettle = useStableEvent(() => { let scheduler = historyStartSettleSchedulerRef.current; if (!scheduler) { @@ -542,22 +523,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool settleFrames: HISTORY_START_SETTLE_FRAMES, requestFrame: (callback) => window.requestAnimationFrame(callback), cancelFrame: (frame) => window.cancelAnimationFrame(frame), - isSettling: () => historyStartPaginationStateRef.current.status === "settling", + isSettling: () => isHistoryStartSettling(), isLoading: () => { const input = getHistoryStartPaginationInput(); return !input || input.isLoadingOlderHistory; }, - onSettle: () => { - const input = getHistoryStartPaginationInput(); - if (!input) { - return; - } - const transition = settleHistoryStartPagination( - historyStartPaginationStateRef.current, - input, - ); - applyHistoryStartPaginationTransition(transition); - }, + onSettle: () => historyStartPagination.settle(), }); historyStartSettleSchedulerRef.current = scheduler; } @@ -794,6 +765,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ]); const stopFollowingOutputFromUserIntent = useStableEvent(() => { + finishInitialFill(); cancelPendingStickToBottom(); if (followOutputRef.current) { setFollowOutput(false); @@ -801,6 +773,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool rearmHistoryStartFromUserIntent(); }); + useEffect(() => { + if (revealInitialHistory) finishInitialFill(); + }, [finishInitialFill, revealInitialHistory]); + const handleDomScroll = useCallback(() => { const scrollContainer = scrollContainerRef.current; if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) { @@ -846,10 +822,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return () => { window.cancelAnimationFrame(frame); historyStartReadyRef.current = false; + disposeHistoryStart(); historyStartSettleSchedulerRef.current?.cancel(); historyStartSettleSchedulerRef.current = null; }; - }, [evaluateHistoryStart]); + }, [disposeHistoryStart, evaluateHistoryStart]); useLayoutEffect(() => { if (!isActiveRef.current || !isActivationReady) { @@ -938,16 +915,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool updateScrollMetrics(); } evaluateHistoryStart(); - if (historyStartPaginationStateRef.current.status === "settling") { + if (isHistoryStartSettling()) { scheduleHistoryStartPrependSettle(); } }, [ evaluateHistoryStart, hasOlderHistory, isActive, + isAuthoritativeHistoryReady, isLoadingOlderHistory, olderHistoryProgressKey, scheduleHistoryStartPrependSettle, + isHistoryStartSettling, segments.historyMounted.length, segments.historyVirtualized.length, segments.liveHead.length, @@ -986,7 +965,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool lastObservedViewportGeometryRef.current = nextGeometry; } reconcileReadingPosition(); - if (historyStartPaginationStateRef.current.status === "settling") { + if (isHistoryStartSettling()) { scheduleHistoryStartPrependSettle(); } updateScrollMetrics(); @@ -1020,6 +999,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool isActive, reportReadingPosition, scheduleHistoryStartPrependSettle, + isHistoryStartSettling, forceStickToBottom, updateScrollMetrics, measureVirtualizedRows, @@ -1241,8 +1221,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool // Detached reading has one owner: reconcileReadingPosition. overflowAnchor: followOutput ? "auto" : "none", scrollbarWidth: overlayScrollbarEnabled ? "none" : undefined, + opacity: historyStartPaginationState.visible ? 1 : 0, }; - }, [followOutput, isMobileBreakpoint, scrollEnabled]); + }, [followOutput, historyStartPaginationState.visible, isMobileBreakpoint, scrollEnabled]); const viewportStyle = useMemo( (): CSSProperties => ({ position: "relative", @@ -1319,14 +1300,21 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState); return (
- {isLoadingOperation ? ( + {isLoadingOperation && (
- ) : null} + )} + {!isLoadingOperation && + historyStartPaginationState.status === "failed" && + hasOlderHistory && ( + + )}
); - }, [historyStartPaginationState]); + }, [historyStartPaginationState, hasOlderHistory, rearmHistoryStartFromUserIntent, t]); const shouldRenderEmpty = !boundary.hasMountedHistory && !boundary.hasVirtualizedHistory && @@ -1346,7 +1334,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool >
{historyStartSlot} -
+
{[ ...virtualRows.map((virtualRow) => { @@ -1374,10 +1362,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ...mountedRows, ]}
- {liveAuxiliary} +
{liveAuxiliary}
{shouldRenderEmpty ? listEmptyComponent : null}
+ {!historyStartPaginationState.visible ? ( +
+ +
+ ) : null} {scrollEnabled && !isMobileBreakpoint ? ( ; routeBottomAnchorRequest: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady: boolean; + isInitialHistoryLoading?: boolean; + revealInitialHistory?: boolean; onNearBottomChange: (value: boolean) => void; // The history row under the top of the viewport, for surfaces that mark where the reader // is in the transcript. Only the web viewport measures it today. onReadingPositionChange?: (rowId: string | null) => void; - onNearHistoryStart: () => boolean | Promise; + onNearHistoryStart: () => Promise; isLoadingOlderHistory: boolean; hasOlderHistory: boolean; olderHistoryProgressKey: string | null; diff --git a/packages/app/src/agent-stream/use-history-start-pagination.ts b/packages/app/src/agent-stream/use-history-start-pagination.ts new file mode 100644 index 0000000000..1fb6168857 --- /dev/null +++ b/packages/app/src/agent-stream/use-history-start-pagination.ts @@ -0,0 +1,33 @@ +import { useRef, useState } from "react"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { + createHistoryStartPagination, + type HistoryStartPaginationInput, + type HistoryStartPaginationState, +} from "./history-start-pagination"; + +export function useHistoryStartPagination(input: { + initiallyVisible: boolean; + read: () => HistoryStartPaginationInput | null; + load: () => Promise; + scheduleSettle: () => void; +}) { + const read = useStableEvent(input.read); + const load = useStableEvent(input.load); + const scheduleSettle = useStableEvent(input.scheduleSettle); + const [state, setState] = useState(() => ({ + status: "dormant", + visible: input.initiallyVisible, + })); + const controller = useRef | null>(null); + if (!controller.current) { + controller.current = createHistoryStartPagination({ + initiallyVisible: input.initiallyVisible, + read, + load, + scheduleSettle, + changed: setState, + }); + } + return { state, controller: controller.current }; +} diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 6b039ba74e..ef22abfa48 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -248,6 +248,8 @@ export interface AgentStreamViewProps { turnPresentation: TurnPresentation; routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady?: boolean; + isInitialHistoryLoading?: boolean; + revealInitialHistory?: boolean; /** Tail space required by a transparent overlay rendered at the bottom edge. */ bottomOverlayTailClearance?: number; /** Bottom offset required for controls floating above that overlay. */ @@ -345,6 +347,8 @@ const AgentStreamViewComponent = forwardRef { expect(client.calls).toEqual([]); expect(inFlight.values).toEqual([true]); - expect(started).toBe(true); + expect(started).toBe(false); }); it("requests the page before the current start cursor and clears in-flight on success", async () => { @@ -153,6 +153,7 @@ describe("loadOlderAgentHistory", () => { limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", wholeTurns: true, + timeout: 10_000, }, }, ]); @@ -169,7 +170,7 @@ describe("loadOlderAgentHistory", () => { const toast = createToast(); const logger = createLogger(); - await loadOlderAgentHistory(agentId, { + const applied = await loadOlderAgentHistory(agentId, { client, cursor: someCursor, hasOlder: true, @@ -179,6 +180,7 @@ describe("loadOlderAgentHistory", () => { logger, }); + expect(applied).toBe(false); expect(client.calls).toHaveLength(1); expect(toast.shown).toEqual([ { diff --git a/packages/app/src/hooks/use-load-older-agent-history.ts b/packages/app/src/hooks/use-load-older-agent-history.ts index 2bc3f5c9df..a3887dd7b2 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.ts @@ -18,6 +18,7 @@ export interface LoadOlderAgentHistoryClient { cursor: { epoch: string; seq: number }; limit: number; projection: "projected"; + timeout: number; }, ) => Promise; } @@ -44,7 +45,7 @@ export async function loadOlderAgentHistory( const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger, failedMessage } = deps; if (isLoadingOlder) { - return true; + return false; } if (!client || !cursor || !hasOlder) { return false; @@ -52,16 +53,17 @@ export async function loadOlderAgentHistory( setInFlight(true); try { - await client.fetchAgentTimeline( - agentId, - planTimelineOlderFetch({ epoch: cursor.epoch, seq: cursor.startSeq }), - ); + await client.fetchAgentTimeline(agentId, { + ...planTimelineOlderFetch({ epoch: cursor.epoch, seq: cursor.startSeq }), + timeout: 10_000, + }); } catch (error) { (logger ?? console).warn("[Timeline] failed to load older agent history", agentId, error); toast?.show(failedMessage ?? i18n.t("loadOlderHistory.failed"), { durationMs: 2200, testID: "agent-load-older-history-toast", }); + return false; } finally { setInFlight(false); } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index c610ec2410..f85ae6b96d 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1012,6 +1012,7 @@ function ChatAgentContent({ effectiveAgent={effectiveAgent} routeBottomAnchorRequest={routeBottomAnchorRequest} hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory} + hasCachedHistory={replicaTimelineStatus === "painted"} toastApi={toastApi} toast={toastState} dismiss={dismissToast} @@ -1043,6 +1044,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ effectiveAgent, routeBottomAnchorRequest, hasAppliedAuthoritativeHistory, + hasCachedHistory, toastApi, toast, dismiss, @@ -1071,6 +1073,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ effectiveAgent: AgentScreenAgent | null; routeBottomAnchorRequest: RouteBottomAnchorRequest; hasAppliedAuthoritativeHistory: boolean; + hasCachedHistory: boolean; toastApi: ToastApi; toast: ToastState | null; dismiss: () => void; @@ -1198,6 +1201,12 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ draft={draft} routeBottomAnchorRequest={routeBottomAnchorRequest} hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory} + isInitialHistoryLoading={ + routeBottomAnchorRequest?.reason === "initial-entry" && + !hasCachedHistory && + !showHistorySyncError + } + revealInitialHistory={hasCachedHistory || showHistorySyncError} hasActiveComposer={hasActiveComposer} toast={toastApi} onOpenWorkspaceFile={onOpenWorkspaceFile} @@ -1359,6 +1368,8 @@ const AgentStreamSection = memo(function AgentStreamSection({ draft, routeBottomAnchorRequest, hasAppliedAuthoritativeHistory, + isInitialHistoryLoading, + revealInitialHistory, hasActiveComposer, toast, onOpenWorkspaceFile, @@ -1370,6 +1381,8 @@ const AgentStreamSection = memo(function AgentStreamSection({ draft: DraftConversation | null; routeBottomAnchorRequest: RouteBottomAnchorRequest; hasAppliedAuthoritativeHistory: boolean; + isInitialHistoryLoading: boolean; + revealInitialHistory: boolean; hasActiveComposer: boolean; toast: ReturnType["api"]; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; @@ -1460,6 +1473,8 @@ const AgentStreamSection = memo(function AgentStreamSection({ pendingPermissions={pendingPermissions} routeBottomAnchorRequest={routeBottomAnchorRequest} isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory} + isInitialHistoryLoading={isInitialHistoryLoading} + revealInitialHistory={revealInitialHistory} bottomOverlayTailClearance={bottomOverlayTailClearance} bottomOverlayControlClearance={bottomOverlayControlClearance} toast={toast} diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index 377d5331c8..039595b6a4 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -26,6 +26,7 @@ import { providerSubagentLifecycleStatus, refreshProviderSubagents, observeProviderSubagentTimeline, + loadOlderProviderSubagentTimeline, useProviderSubagentStore, } from "@/subagents/provider-store"; import { useTranslation } from "react-i18next"; @@ -177,26 +178,24 @@ function ProviderSubagentPanel() { }); }, [client, supported, serverId, target.parentAgentId, target.subagentId]); - const loadOlder = useCallback((): boolean => { + const loadOlder = useCallback(async (): Promise => { if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) { return false; } - const firstSeq = timeline.cursor?.startSeq ?? null; - if (firstSeq === null) return false; setIsLoadingOlder(true); - void client - .fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, { - direction: "before", - cursor: { epoch: timeline.epoch, seq: firstSeq }, + try { + return await loadOlderProviderSubagentTimeline({ + client, + serverId, + parentAgentId: target.parentAgentId, + subagentId: target.subagentId, limit: TIMELINE_FETCH_PAGE_SIZE, - }) - .then((payload) => { - useProviderSubagentStore.getState().replaceTimeline(serverId, payload); - return undefined; - }) - .catch(() => undefined) - .finally(() => setIsLoadingOlder(false)); - return true; + }); + } catch { + return false; + } finally { + setIsLoadingOlder(false); + } }, [ client, isLoadingOlder, diff --git a/packages/app/src/subagents/provider-store.test.ts b/packages/app/src/subagents/provider-store.test.ts index 01d6880d5b..a28e9b00fd 100644 --- a/packages/app/src/subagents/provider-store.test.ts +++ b/packages/app/src/subagents/provider-store.test.ts @@ -1,7 +1,9 @@ +import { createHistoryStartPagination } from "@/agent-stream/history-start-pagination"; import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; -import { afterEach, describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages"; import { + loadOlderProviderSubagentTimeline, observeProviderSubagentTimeline, providerSubagentKey, refreshProviderSubagents, @@ -565,6 +567,95 @@ describe("projected child history", () => { .getState() .replaceTimeline(SERVER_ID, response(value, seqStart, seqEnd, direction)); } + test("older child history completes only after the delayed page is applied", async () => { + const tail = { ...response("B", 2, 2), hasOlder: true }; + useProviderSubagentStore.getState().replaceTimeline(SERVER_ID, tail); + let deliver!: (payload: Payload) => void; + const client = { + fetchProviderSubagentTimeline: () => + new Promise((resolve) => { + deliver = resolve; + }), + }; + let completed = false; + const request = loadOlderProviderSubagentTimeline({ + client, + serverId: SERVER_ID, + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + limit: 100, + }).then((result) => { + completed = true; + return result; + }); + await Promise.resolve(); + expect(completed).toBe(false); + deliver({ ...response("A", 1, 1), direction: "before" }); + expect(await request).toBe(true); + expect(current().cursor?.startSeq).toBe(1); + }); + test("bounds an older child request so a timeout permits another attempt", async () => { + vi.useFakeTimers(); + try { + useProviderSubagentStore + .getState() + .replaceTimeline(SERVER_ID, { ...response("B", 2, 2), hasOlder: true }); + const timeouts: Array = []; + const client = { + fetchProviderSubagentTimeline: ( + _parent: string, + _child: string, + options: { timeout?: number }, + ) => { + timeouts.push(options.timeout); + return new Promise((_resolve, reject) => + setTimeout(() => reject(new Error("timeout")), options.timeout ?? 60_000), + ); + }, + }; + let loading = false; + const pagination = createHistoryStartPagination({ + initiallyVisible: true, + read: () => ({ + distanceFromHistoryStart: 0, + isUnderfilled: true, + hasOlderHistory: true, + isLoadingOlderHistory: loading, + isReady: true, + progressKey: "e:2", + }), + load: async () => { + loading = true; + try { + return await loadOlderProviderSubagentTimeline({ + client, + serverId: SERVER_ID, + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + limit: 100, + }); + } catch { + return false; + } finally { + loading = false; + } + }, + changed: () => {}, + scheduleSettle: () => {}, + }); + pagination.update(); + expect(timeouts).toEqual([10_000]); + await vi.advanceTimersByTimeAsync(10_000); + expect(pagination.state.status).toBe("failed"); + pagination.rearm(); + expect(timeouts).toEqual([10_000, 10_000]); + expect(pagination.state.status).toBe("loading"); + pagination.dispose(); + await vi.advanceTimersByTimeAsync(10_000); + } finally { + vi.useRealTimers(); + } + }); test("replaces overlapping fetched text and continues live streaming", () => { page("AB", 1, 2); stream(3, "C"); diff --git a/packages/app/src/subagents/provider-store.ts b/packages/app/src/subagents/provider-store.ts index fd44dae8d2..48010aa0be 100644 --- a/packages/app/src/subagents/provider-store.ts +++ b/packages/app/src/subagents/provider-store.ts @@ -355,6 +355,35 @@ export const useProviderSubagentStore = create((set) => ( })); /** Owns child history bootstrap and recovery while a pane observes the child. */ +export async function loadOlderProviderSubagentTimeline({ + client, + serverId, + parentAgentId, + subagentId, + limit, +}: { + client: Pick; + serverId: string; + parentAgentId: string; + subagentId: string; + limit: number; +}): Promise { + const timeline = useProviderSubagentStore + .getState() + .timelines.get(providerSubagentKey(serverId, parentAgentId, subagentId)); + const seq = timeline?.cursor?.startSeq; + if (!timeline?.hasOlder || !timeline.epoch || seq == null) return false; + const payload = await client.fetchProviderSubagentTimeline(parentAgentId, subagentId, { + direction: "before", + cursor: { epoch: timeline.epoch, seq }, + limit, + timeout: 10_000, + }); + if (payload.error) return false; + useProviderSubagentStore.getState().replaceTimeline(serverId, payload); + return true; +} + export function observeProviderSubagentTimeline({ client, serverId, From ab8e1e61ff7c0bbad08c95e15a09f3ec42333410 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:18:07 +0000 Subject: [PATCH 07/30] fix(app): keep the composer ready after clicking Send (#9015) Pointer submission blurs the editor before sending and clears retained focus intent. Restore focus through the existing default send action for mouse-driven layouts, while keeping touch keyboard dismissal intact. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/app/e2e/browser/composer-focus.spec.ts | 14 +++++++++++++- packages/app/src/composer/input/input.tsx | 3 +++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/app/e2e/browser/composer-focus.spec.ts b/packages/app/e2e/browser/composer-focus.spec.ts index 4f2610b8db..61d2a5a35e 100644 --- a/packages/app/e2e/browser/composer-focus.spec.ts +++ b/packages/app/e2e/browser/composer-focus.spec.ts @@ -19,6 +19,7 @@ import { expectWorkspaceHeaderVisible, resizeComposerViewport, submitMessage, + submitMessageWithButton, typeIntoFocusedComposer, } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; @@ -146,6 +147,14 @@ test("submitting a message leaves the composer ready for the next message", asyn await openAgentRoute(page, agent); await expectComposerVisible(page); + await submitMessageWithButton(page, "Pointer submission"); + await expectComposerDraft(page, ""); + await expectComposerFocused(page); + await typeIntoFocusedComposer(page, "Ready after Send"); + await expectComposerDraft(page, "Ready after Send"); + await agent.client.waitForFinish(agent.agentId, 15_000); + await expectComposerFocused(page); + await submitMessage(page, "First message"); await expectComposerFocused(page); @@ -173,7 +182,7 @@ test("submitting a message leaves the composer ready for the next message", asyn "Investigate: [this FR](https://example.com/issues/39150) next. Thanks", ); await agent.client.waitForFinish(agent.agentId, 15_000); - await expect(page.getByRole("button", { name: "Copy turn", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Copy turn", exact: true }).last()).toBeVisible(); await connection.drop(); await connection.waitForBlockedConnection(); await pasteRichTextIntoComposer({ @@ -313,6 +322,9 @@ test.describe("touch screen wide enough for the desktop layout", () => { // Autofocus makes its first attempt on the next animation frame. await waitForAnimationFrames(page, 4); await expectComposerNotFocused(page); + await submitMessageWithButton(page, "Touch submission"); + await expectComposerDraft(page, ""); + await expectComposerNotFocused(page); } finally { await agent.cleanup(); } diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index fc82a73e6d..5cb380c34b 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1791,6 +1791,9 @@ export const MessageInput = forwardRef( handleSendMessage, handleQueueMessage, }); + if (hasFinePointer()) { + textInputRef.current?.focus(); + } }, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]); const handleAlternateSendAction = useCallback(() => { From 8a14d7b2c36f716005358f26901de36130ab1992 Mon Sep 17 00:00:00 2001 From: Brandon Elliott Date: Sat, 7 Nov 2026 10:54:27 -0600 Subject: [PATCH 08/30] Collect idle Claude runtimes reopened without a turn (#9019) Reopening a chat lists its commands, which resumes the agent and starts a Claude process with no prompt. Claude only sends init with a turn, so the runtime's background-work state stayed unknown and idle cleanup vetoed it on every sweep: the process stayed resident until the daemon restarted. A process that has not been sent a prompt and has not reported init has run no turn and owns no background work, so it can stop. Older CLIs that ran a turn without a roster are still kept. Fixes #9017 Co-authored-by: Brandon Elliott <923787+Brandon168@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../claude/agent.background-subagent.test.ts | 51 +++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 1 + .../agent/providers/claude/runtime-safety.ts | 12 +++++ 3 files changed, 64 insertions(+) diff --git a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts index 5c70f0a1fe..432071741f 100644 --- a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts @@ -806,6 +806,57 @@ test("emits inventory only when relevant observations change it", () => { ).toEqual([{ id: "m", kind: "shell", description: null, canStop: true }]); }); +test("Claude runtime safety releases a process that has not been sent a turn", () => { + const safety = new ClaudeRuntimeSafety(); + // A resumed process that only answered control requests never sends init. + expect(safety.canStopRuntime()).toBe(true); + safety.notePromptSent(); + expect(safety.canStopRuntime()).toBe(false); + safety.observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" } as SDKMessage); + expect(safety.canStopRuntime()).toBe(true); + safety.reset(); + expect(safety.canStopRuntime()).toBe(true); + // A CLI without a roster that ran a turn may own background work. + safety.observe({ type: "system", subtype: "init", claude_code_version: "2.1.220" } as SDKMessage); + expect(safety.canStopRuntime()).toBe(false); + safety.retire(); + expect(safety.canStopRuntime()).toBe(false); +}); + +test("Claude session started by a control request without a turn can stop its runtime", async () => { + let launched = 0; + const query = { + next: () => new Promise>(() => {}), + return: async () => ({ done: true, value: undefined }), + close: () => {}, + interrupt: async () => {}, + setPermissionMode: async () => {}, + setModel: async () => {}, + supportedModels: async () => [], + supportedCommands: async () => [], + [Symbol.asyncIterator]() { + return this; + }, + } as unknown as Query; + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => "/test/claude/bin", + queryFactory: ({ prompt }) => { + if (typeof prompt === "string") throw new Error("Expected streaming input"); + launched++; + return query; + }, + }).createSession({ provider: "claude", cwd: process.cwd() }); + try { + // Reopening a chat lists its commands, which launches Claude with no prompt. + await session.listCommands!(); + expect(launched).toBe(1); + expect(await session.canStopRuntime!()).toBe(true); + } finally { + await session.close(); + } +}); + test("Claude session runtime safety observes pumped background work and autonomous replies", async () => { let wake: (() => void) | undefined; const channel = { closed: false }; diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index b4bd7e27db..7f72fefbb2 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2591,6 +2591,7 @@ class ClaudeAgentSession implements AgentSession { this.activeForegroundQuery = this.query; this.activeForegroundInput = this.input; this.startQueryPump(); + this.runtimeSafety.notePromptSent(); this.input.push(sdkMessage); setTimeout(() => { if (this.activeForegroundTurnId === turnId) { diff --git a/packages/server/src/server/agent/providers/claude/runtime-safety.ts b/packages/server/src/server/agent/providers/claude/runtime-safety.ts index a21bf908ea..8688e93f45 100644 --- a/packages/server/src/server/agent/providers/claude/runtime-safety.ts +++ b/packages/server/src/server/agent/providers/claude/runtime-safety.ts @@ -6,6 +6,10 @@ import { toBackgroundWorkInputs, type ClaudeBackgroundTask } from "./background- /** Process-local background ownership. Completion delivery can follow an empty roster. */ export class ClaudeRuntimeSafety { private inventoryState: "unknown" | "supported" | "retired" = "unknown"; + // Background work starts inside a turn, and init only arrives with a turn. A process that + // has neither been sent a prompt nor reported init (a resumed chat that only answered + // control requests) owns no background work. + private turnSeen = false; private liveTasks: readonly ClaudeBackgroundTask[] = []; private published: AgentBackgroundWorkInput[] | undefined; private readonly awaitingNotifications = new Set(); @@ -15,6 +19,7 @@ export class ClaudeRuntimeSafety { reset(): void { this.inventoryState = "unknown"; + this.turnSeen = false; this.liveTasks = []; this.published = undefined; this.awaitingNotifications.clear(); @@ -28,6 +33,11 @@ export class ClaudeRuntimeSafety { this.inventoryState = "retired"; } + /** Called before a prompt is written to this process. */ + notePromptSent(): void { + this.turnSeen = true; + } + /** Returns a full replacement only when the visible inventory changes. */ observe(message: SDKMessage): AgentBackgroundWorkInput[] | undefined { if (this.inventoryState === "retired") return undefined; @@ -93,6 +103,7 @@ export class ClaudeRuntimeSafety { ); } if (message.subtype === "init") { + this.turnSeen = true; const version = parseClaudeCodeVersion(message.claude_code_version ?? ""); if ( version && @@ -115,6 +126,7 @@ export class ClaudeRuntimeSafety { } canStopRuntime(): boolean { + if (this.inventoryState === "unknown" && !this.turnSeen) return true; return ( this.inventoryState === "supported" && this.liveTasks.length === 0 && From c908eddce67fd5767326f4d2c66c08a1a667e7ad Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:52:53 +0000 Subject: [PATCH 09/30] Fill controlled form fields through browser tools (#9022) * fix(desktop): fill controlled form fields before submission Write text controls through native DOM setters so page-level value tracking sees the input event as a change. Cover state updates, submission, replacement and clearing through the real browser tool journey. * refactor(desktop): select native field setters with explicit branches --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../desktop/e2e/browser-fill.electron.mjs | 108 ++++++++++++++++++ packages/desktop/e2e/browser-tabs.e2e.mjs | 8 ++ .../browser-automation/snapshot-engine.ts | 11 +- 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 packages/desktop/e2e/browser-fill.electron.mjs diff --git a/packages/desktop/e2e/browser-fill.electron.mjs b/packages/desktop/e2e/browser-fill.electron.mjs new file mode 100644 index 0000000000..78ce935012 --- /dev/null +++ b/packages/desktop/e2e/browser-fill.electron.mjs @@ -0,0 +1,108 @@ +import { build } from "esbuild"; +import { fileURLToPath } from "node:url"; +import { expect } from "@playwright/test"; + +export async function buildFillFixture() { + const { outputFiles } = await build({ + stdin: { + resolveDir: fileURLToPath(new URL("../../app", import.meta.url)), + contents: ` + import { createElement as e, useState } from 'react'; + import { createRoot } from 'react-dom/client'; + function Form() { + const [title, setTitle] = useState(''); + const [notes, setNotes] = useState(''); + const [submitted, setSubmitted] = useState(null); + return e('form', { onSubmit: ev => { + ev.preventDefault(); setSubmitted({ title, notes }); + } }, + e('input', { 'aria-label': 'React title', value: title, + onChange: ev => setTitle(ev.target.value) }), + e('textarea', { 'aria-label': 'React notes', value: notes, + onChange: ev => setNotes(ev.target.value) }), + e('p', null, 'React state: ' + JSON.stringify({ title, notes })), + e('button', { type: 'submit' }, 'Submit'), + e('p', null, 'Submitted: ' + JSON.stringify(submitted))); + } + createRoot(document.getElementById('root')).render(e(Form)); + document.getElementById('plain').addEventListener('input', ev => { + document.getElementById('plain-state').textContent = 'Plain state: ' + ev.target.value; + }); + `, + }, + bundle: true, + write: false, + format: "iife", + platform: "browser", + define: { "process.env.NODE_ENV": '"production"' }, + }); + return `React fill fixture
+

+
+ `; +} + +async function findRef(call, browserId, role, name) { + const { snapshot } = await call("browser_snapshot", { browserId }); + const line = snapshot.split("\n").find((candidate) => candidate.includes(`${role} "${name}"`)); + const ref = line?.match(/\[ref=([^\]]+)\]/)?.[1]; + expect(ref, `Snapshot must expose ${role} ${name}: ${snapshot}`).toBeTruthy(); + return ref; +} + +async function fillField(call, browserId, name, value) { + const ref = await findRef(call, browserId, "textbox", name); + await call("browser_fill", { browserId, ref, value }); +} + +async function submitForm(call, browserId) { + const ref = await findRef(call, browserId, "button", "Submit"); + await call("browser_click", { browserId, ref }); +} + +async function expectPageText(call, browserId, text) { + await expect + .poll(async () => { + const result = await call("browser_evaluate", { + browserId, + function: "() => document.body.innerText", + }); + return JSON.parse(result.resultJson); + }) + .toContain(text); +} + +export async function verifyReactFormFill(call, targetUrl) { + const { browserId } = await call("browser_new_tab", { + url: new URL("/react-fill", targetUrl).href, + }); + try { + await call("browser_wait", { browserId, text: "React state:", timeoutMs: 15_000 }); + await fillField(call, browserId, "Plain input", "plain value"); + await expectPageText(call, browserId, "Plain state: plain value"); + await fillField(call, browserId, "Editable text", "editable value"); + await expectPageText(call, browserId, "editable value"); + await fillField(call, browserId, "React title", "Hello from browser_fill"); + await fillField(call, browserId, "React notes", "First line\nSecond line"); + await expectPageText( + call, + browserId, + 'React state: {"title":"Hello from browser_fill","notes":"First line\\nSecond line"}', + ); + await submitForm(call, browserId); + await expectPageText( + call, + browserId, + 'Submitted: {"title":"Hello from browser_fill","notes":"First line\\nSecond line"}', + ); + await fillField(call, browserId, "React title", "replacement"); + await fillField(call, browserId, "React notes", ""); + await submitForm(call, browserId); + await expectPageText(call, browserId, 'Submitted: {"title":"replacement","notes":""}'); + await fillField(call, browserId, "React title", ""); + await submitForm(call, browserId); + await expectPageText(call, browserId, 'Submitted: {"title":"","notes":""}'); + } finally { + await call("browser_close_tab", { browserId }); + } +} diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 93e6bd61d1..668d884b80 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -17,6 +17,7 @@ import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs"; import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs"; import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs"; import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs"; +import { buildFillFixture, verifyReactFormFill } from "./browser-fill.electron.mjs"; import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs"; import { seedPluginLinks, runPluginLinksRegression } from "./plugin-links.electron.mjs"; @@ -216,7 +217,13 @@ async function settleDesktopStartup(page) { const CSP_PAGE_PATH = "/csp-no-eval"; async function startTargetPage() { + const fillFixture = await buildFillFixture(); const server = createServer((request, response) => { + if (request.url === "/react-fill") { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end(fillFixture); + return; + } if (request.url === CSP_PAGE_PATH) { response.writeHead(200, { "content-type": "text/html; charset=utf-8", @@ -782,6 +789,7 @@ async function runRegression({ document.body.appendChild(overlayRoot); }); + await verifyReactFormFill((name, args) => callBrowserTool(client, name, args), targetUrl); await verifyEvaluateOnPageWithoutUnsafeEval(client, targetUrl); const created = await callBrowserTool(client, "browser_new_tab", { url: targetUrl }); diff --git a/packages/desktop/src/features/browser-automation/snapshot-engine.ts b/packages/desktop/src/features/browser-automation/snapshot-engine.ts index 2ee1a68a5e..e6fc74a3c9 100644 --- a/packages/desktop/src/features/browser-automation/snapshot-engine.ts +++ b/packages/desktop/src/features/browser-automation/snapshot-engine.ts @@ -316,7 +316,16 @@ function buildFillScript(metadata: BrowserRefMetadata, value: string): string { element.focus(); const nextValue = ${JSON.stringify(value)}; if ('value' in element) { - element.value = nextValue; + const view = element.ownerDocument.defaultView; + let prototype = null; + if (element.tagName === 'INPUT') { + prototype = view.HTMLInputElement.prototype; + } else if (element.tagName === 'TEXTAREA') { + prototype = view.HTMLTextAreaElement.prototype; + } + // Bypass instance setters so framework value trackers observe the input event as a change. + if (prototype) Object.getOwnPropertyDescriptor(prototype, 'value').set.call(element, nextValue); + else element.value = nextValue; element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); return { ok: true }; From 063b402d7c291e03aef83301f4863c7831514a2f Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:19:31 +0000 Subject: [PATCH 10/30] test(cli): align worker upgrade checks with protocol 6 (#9033) The upgrade journey still expected protocol 5 after the worker moved to 6, stopping every main CLI run before adoption coverage. Keep obsolete versions 0 through 4 and exercise newer-worker rejection with version 7. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../cli/tests/46-daemon-terminal-worker-upgrade.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/tests/46-daemon-terminal-worker-upgrade.test.ts b/packages/cli/tests/46-daemon-terminal-worker-upgrade.test.ts index 9090456406..cb272cf54a 100644 --- a/packages/cli/tests/46-daemon-terminal-worker-upgrade.test.ts +++ b/packages/cli/tests/46-daemon-terminal-worker-upgrade.test.ts @@ -49,7 +49,7 @@ const paseoHome = join(root, "home"); const cliHome = join(root, "os-home"); const workDir = join(root, "work"); for (const dir of [paseoHome, cliHome, workDir]) mkdirSync(dir, { recursive: true }); -const currentProtocolVersion = 5; +const currentProtocolVersion = 6; const zsh = ["/bin/zsh", "/usr/bin/zsh"].find((candidate) => existsSync(candidate)) ?? null; interface Installation { @@ -456,8 +456,8 @@ try { }); console.log(`Competing listener owns released port ${releasedPort}`); - // There is no supported previous wire at protocol 5. Future previous-version classification - // is covered by terminal-worker-protocol.test.ts; do not simulate an unreleased drain adapter. + // Protocols 0–4 have no draining adapter. Protocol 5 is now the supported previous wire; + // its classification is covered by terminal-worker-protocol.test.ts. for (const protocolVersion of [0, 1, 2, 3, 4]) { console.log(`Test 4: obsolete protocol ${protocolVersion} is closed before serving terminals`); const obsolete = await leaveWorkerWithProtocol( From b7a308ca005fbd3e5fd5b3c936afcc9dda9f4a1b Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:59:08 +0000 Subject: [PATCH 11/30] fix(relay): stop handshake retries when the channel closes (#9025) Cancel handshake work at the channel close transition instead of waiting for a socket acknowledgment from an unreachable peer. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/relay/src/handshake-lifetime.test.ts | 60 +++++++++++++++++++ packages/relay/src/secure-channel.ts | 6 +- 2 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 packages/relay/src/handshake-lifetime.test.ts diff --git a/packages/relay/src/handshake-lifetime.test.ts b/packages/relay/src/handshake-lifetime.test.ts new file mode 100644 index 0000000000..ea99005916 --- /dev/null +++ b/packages/relay/src/handshake-lifetime.test.ts @@ -0,0 +1,60 @@ +import { expect, it, vi } from "vitest"; +import { exportPublicKey, generateKeyPair } from "./crypto.js"; +import type { Transport } from "./encrypted-channel.js"; +import { openClientSecureChannel } from "./secure-channel.js"; + +/** A socket whose peer never acknowledges the close handshake. */ +class UnresponsiveTransport implements Transport { + onmessage: Transport["onmessage"] = null; + onclose: Transport["onclose"] = null; + onerror: Transport["onerror"] = null; + sends = 0; + closing = false; + + send(): void { + this.sends += 1; + if (this.closing) throw new Error("WebSocket not open (readyState=2)"); + } + + close(): void { + this.closing = true; + } +} + +it.each(["client close", "handshake rejection"] as const)( + "stops handshake retries after %s without a socket acknowledgment", + async (trigger) => { + vi.useFakeTimers(); + const transport = new UnresponsiveTransport(); + const errors: Error[] = []; + let closed = 0; + try { + const channel = await openClientSecureChannel(transport, { + daemonPublicKeyB64: exportPublicKey(generateKeyPair().publicKey), + minimumMode: "protected", + events: { onerror: (error) => errors.push(error) }, + }); + channel.onClose(() => closed++); + + // The handshake still retries while the connection is wanted. + await vi.advanceTimersByTimeAsync(2_000); + expect(transport.sends).toBe(3); + if (trigger === "client close") { + channel.close(); + } else { + transport.onmessage?.({ data: JSON.stringify({ type: "e2ee_ready" }), isBinary: false }); + } + await vi.advanceTimersByTimeAsync(5_000); + + expect(transport.sends).toBe(3); + expect(errors).toHaveLength(trigger === "client close" ? 0 : 1); + expect(closed).toBe(1); + // A late socket acknowledgment does not close the channel a second time. + transport.onclose?.(1000, "Normal closure"); + expect(closed).toBe(1); + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + } + }, +); diff --git a/packages/relay/src/secure-channel.ts b/packages/relay/src/secure-channel.ts index 760dcc49d0..49cbce1a53 100644 --- a/packages/relay/src/secure-channel.ts +++ b/packages/relay/src/secure-channel.ts @@ -344,8 +344,7 @@ class ClientSecureChannel implements SecureChannel { private fail(code: number, reason: string): void { if (this.state === "closed") return; - this.state = "closed"; - this.handshake = null; + this.notifyClosed(); try { this.transport.close(code, reason); } catch { @@ -364,13 +363,12 @@ class ClientSecureChannel implements SecureChannel { } close(code = 1000, reason = "Normal closure"): void { + this.notifyClosed(); if (this.inner) { this.inner.close(code, reason); } else { this.transport.close(code, reason); } - this.state = "closed"; - this.handshake = null; } isOpen(): boolean { From 249c35891b44256edb946156299bcd09fc413033 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:02:59 +0000 Subject: [PATCH 12/30] fix(app): load History after a browser reload (#9029) Consume host connection statuses in History instead of a version-only dependency that the React Compiler removes. Keep saved sessions reachable when the host connects after the screen mounts. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../app/e2e/browser/sessions-search.spec.ts | 11 ++++++++ packages/app/src/hooks/use-agent-history.ts | 25 ++++++++++--------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/app/e2e/browser/sessions-search.spec.ts b/packages/app/e2e/browser/sessions-search.spec.ts index 7f54617304..6cf8024e35 100644 --- a/packages/app/e2e/browser/sessions-search.spec.ts +++ b/packages/app/e2e/browser/sessions-search.spec.ts @@ -60,6 +60,15 @@ async function verifyChronologicalFiltering(page: Page): Promise { }); } +async function verifyHistorySurvivesReload(page: Page): Promise { + await test.step("reloads History without visiting a workspace to reconnect", async () => { + await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]); + await page.reload(); + await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]); + await expect(page.getByTestId("sessions-host-errors")).toBeHidden(); + }); +} + async function verifyRecencyBeforeMatchStrength(page: Page): Promise { await test.step("keeps newer partial matches before older stronger matches", async () => { await search(page, `${NONCE} bill`); @@ -151,6 +160,8 @@ test.describe("History search", () => { await verifyChronologicalFiltering(page); + await verifyHistorySurvivesReload(page); + await verifyRecencyBeforeMatchStrength(page); await verifyExactAndTypoHighlights(page); diff --git a/packages/app/src/hooks/use-agent-history.ts b/packages/app/src/hooks/use-agent-history.ts index 96b0395e76..efd17c74b3 100644 --- a/packages/app/src/hooks/use-agent-history.ts +++ b/packages/app/src/hooks/use-agent-history.ts @@ -4,10 +4,14 @@ import type { FetchAgentHistoryPageInfo, } from "@getpaseo/client/internal/daemon-client"; import { useInfiniteQuery } from "@tanstack/react-query"; -import { useCallback, useMemo, useSyncExternalStore } from "react"; +import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; -import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime"; +import { + getHostRuntimeStore, + useHostRuntimeConnectionStatuses, + useHosts, +} from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { buildAgentDirectoryState } from "@/utils/agent-directory-sync"; import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key"; @@ -222,32 +226,29 @@ export function useAgentHistory(options: { const { t } = useTranslation(); const daemons = useHosts(); const runtime = getHostRuntimeStore(); - const runtimeVersion = useSyncExternalStore( - (onStoreChange) => runtime.subscribeAll(onStoreChange), - () => runtime.getVersion(), - () => runtime.getVersion(), - ); const serverId = useMemo(() => { const value = options.serverId; return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; }, [options.serverId]); const enabled = options.enabled ?? true; + const serverIds = useMemo( + () => (serverId ? [serverId] : daemons.map((daemon) => daemon.serverId)), + [daemons, serverId], + ); + const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds); // A host the user asked about splits two ways: one this fetch can reach, and // one whose sessions will be missing from the answer. Both come out of here, // because dropping the unreachable ones silently is what lets the list — and // worse, "No sessions match" — overstate what was actually searched. const { targetHosts, unreachableHosts } = useMemo(() => { - void runtimeVersion; const serverLabelById = new Map(daemons.map((daemon) => [daemon.serverId, daemon.label])); - const serverIds = serverId ? [serverId] : daemons.map((daemon) => daemon.serverId); const hosts: AgentHistoryHost[] = []; const unreachable: AgentHistoryHostError[] = []; for (const targetServerId of serverIds) { - const snapshot = runtime.getSnapshot(targetServerId); const client = runtime.getClient(targetServerId); const serverName = serverLabelById.get(targetServerId) ?? targetServerId; - if (!client || !isHostRuntimeConnected(snapshot)) { + if (!client || connectionStatuses.get(targetServerId) !== "online") { unreachable.push({ serverId: targetServerId, serverName }); continue; } @@ -255,7 +256,7 @@ export function useAgentHistory(options: { } return { targetHosts: hosts, unreachableHosts: unreachable }; - }, [daemons, runtime, runtimeVersion, serverId]); + }, [daemons, runtime, connectionStatuses, serverIds]); const targetServerIds = useMemo(() => targetHosts.map((host) => host.serverId), [targetHosts]); // One gate, checked before the field is offered: a fleet where any host // predates search has no search, rather than a list that silently omits that From 19848899d00de15c3af183e39649e8de3c7b7388 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:19:18 +0000 Subject: [PATCH 13/30] Keep host identity and error details in desktop logs (#9038) * fix(app): preserve host error details in desktop logs Bind host context through the existing client logger and emit structured text so desktop console capture retains the error record, including for adopted probe clients. * fix(app): retain discovered host identity and nested error causes --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/app/src/runtime/host-logger.test.ts | 72 +++++++++++++++++++ packages/app/src/runtime/host-logger.ts | 36 ++++++++++ packages/app/src/runtime/host-runtime.ts | 3 + .../src/utils/test-daemon-connection.test.ts | 40 +++++++++++ .../app/src/utils/test-daemon-connection.ts | 9 ++- 5 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/runtime/host-logger.test.ts create mode 100644 packages/app/src/runtime/host-logger.ts diff --git a/packages/app/src/runtime/host-logger.test.ts b/packages/app/src/runtime/host-logger.test.ts new file mode 100644 index 0000000000..fa53ca7668 --- /dev/null +++ b/packages/app/src/runtime/host-logger.test.ts @@ -0,0 +1,72 @@ +import { expect, it } from "vitest"; +import { createHostLogger } from "./host-logger"; + +it("preserves the original transport cause and tolerates a cyclic cause chain", () => { + const lines: string[] = []; + const capture = (...values: unknown[]) => lines.push(values.map(String).join(" ")); + const logger = createHostLogger(() => "test-host", { + log: capture, + warn: capture, + error: capture, + }); + const transport = new Error("socket closed"); + const wrapper = new Error("Subscription failed", { cause: transport }); + Object.defineProperty(transport, "cause", { value: wrapper }); + logger.error({ err: wrapper }, "subscription_error"); + const record = JSON.parse(lines[0]!.slice("subscription_error ".length)); + expect(record.err.cause.message).toBe("socket closed"); + expect(record.err.cause.stack).toBe(transport.stack); + expect(record.err.cause.cause).toBe("[Circular]"); +}); + +it("retains host and error details in the desktop's text-only console capture", () => { + const lines: { level: string; text: string }[] = []; + const capture = + (level: string) => + (...values: unknown[]) => { + lines.push({ level, text: values.map(String).join(" ") }); + }; + const logger = createHostLogger(() => "offline-host", { + log: capture("info"), + warn: capture("warn"), + error: capture("error"), + }); + logger.warn( + { err: { name: "Error", message: "malformed ready envelope" } }, + "relay_e2ee_channel_error", + ); + expect(lines).toEqual([ + { + level: "warn", + text: 'relay_e2ee_channel_error {"serverId":"offline-host","err":{"name":"Error","message":"malformed ready envelope"}}', + }, + ]); +}); + +it("keeps native Error messages and severity without failing on circular diagnostics", () => { + const lines: { level: string; text: string }[] = []; + const capture = + (level: string) => + (...values: unknown[]) => { + lines.push({ level, text: values.map(String).join(" ") }); + }; + const logger = createHostLogger(() => "test-host", { + log: capture("info"), + warn: capture("warn"), + error: capture("error"), + }); + const record: { err: Error; self?: object } = { err: new Error("connection lost") }; + record.self = record; + logger.error(record, "Connection failed"); + expect(lines[0]?.level).toBe("error"); + expect(lines[0]?.text).toContain('"message":"connection lost"'); + expect(lines[0]?.text).toContain('"serverId":"test-host"'); + expect(lines[0]?.text).toContain('"[Circular]"'); + logger.info({ status: "offline" }, "Host status"); + expect(lines[1]).toEqual({ + level: "info", + text: 'Host status {"serverId":"test-host","status":"offline"}', + }); + logger.debug({ status: "offline" }, "Host status"); + expect(lines).toHaveLength(2); +}); diff --git a/packages/app/src/runtime/host-logger.ts b/packages/app/src/runtime/host-logger.ts new file mode 100644 index 0000000000..3950038600 --- /dev/null +++ b/packages/app/src/runtime/host-logger.ts @@ -0,0 +1,36 @@ +import type { Logger } from "@getpaseo/client/internal/daemon-client"; + +export function createHostLogger( + readServerId: () => string | undefined, + output: Pick = console, +): Logger { + const format = (record: object, message?: string): string => { + const seen = new WeakSet(); + const details = JSON.stringify( + { serverId: readServerId(), ...record }, + (_key, value: unknown) => { + if (value && typeof value === "object") { + if (seen.has(value)) return "[Circular]"; + seen.add(value); + if (value instanceof Error) { + return { + ...value, + name: value.name, + message: value.message, + stack: value.stack, + cause: value.cause, + }; + } + } + return value; + }, + ); + return message ? `${message} ${details}` : details; + }; + return { + debug: () => {}, + info: (record, message) => output.log(format(record, message)), + warn: (record, message) => output.warn(format(record, message)), + error: (record, message) => output.error(format(record, message)), + }; +} diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index b6d10dc8c7..394c61d4bf 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1,5 +1,6 @@ import { createHostPluginClientBundleStore } from "@/plugins/client-bundles"; import { watchdog } from "@/watchdog"; +import { createHostLogger } from "./host-logger"; import { createHostChannelTransport } from "@getpaseo/client/internal/hosts"; import type { OwnedSubscription } from "@getpaseo/client"; import type { HostDirectory } from "@getpaseo/protocol/hosts"; @@ -589,6 +590,7 @@ function createDefaultDeps( runtimeGeneration, capabilities: appCapabilities, trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }), + logger: createHostLogger(() => host.serverId), providerSnapshots: "wire", createPluginClientBundleStore: createHostPluginClientBundleStore, ...(host.password ? { password: host.password } : {}), @@ -669,6 +671,7 @@ function createDefaultDeps( null, ), reconnect: { enabled: false }, + logger: createHostLogger(() => host.serverId), appVersion: resolveAppVersion() ?? undefined, createPluginClientBundleStore: createHostPluginClientBundleStore, }); diff --git a/packages/app/src/utils/test-daemon-connection.test.ts b/packages/app/src/utils/test-daemon-connection.test.ts index 29382b5c89..88f36c6d0f 100644 --- a/packages/app/src/utils/test-daemon-connection.test.ts +++ b/packages/app/src/utils/test-daemon-connection.test.ts @@ -2,9 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client"; import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client"; import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection"; +import { createHostLogger } from "@/runtime/host-logger"; class FakeDaemonClient implements DaemonProbeClient { readonly lastError: string | null; + private connected = false; constructor( private readonly probe: FakeDaemonProbe, @@ -17,9 +19,11 @@ class FakeDaemonClient implements DaemonProbeClient { if (this.probe.nextConnectError) { throw this.probe.nextConnectError; } + this.connected = true; } getLastServerInfoMessage() { + if (!this.connected) return null; return { serverId: "srv_probe_test", hostname: "probe-host", @@ -27,11 +31,13 @@ class FakeDaemonClient implements DaemonProbeClient { } async close(): Promise { + this.connected = false; this.probe.closedClients.push(this); } } class FakeDaemonProbe { + diagnosticLines: string[] = []; createdClients: FakeDaemonClient[] = []; closedClients: FakeDaemonClient[] = []; clientIdsRequested = 0; @@ -39,6 +45,12 @@ class FakeDaemonProbe { nextLastError: string | null = null; readonly deps: DaemonConnectionDependencies = { + createLogger: (readServerId) => { + const capture = (...values: unknown[]) => { + this.diagnosticLines.push(values.map(String).join(" ")); + }; + return createHostLogger(readServerId, { log: capture, warn: capture, error: capture }); + }, getClientId: async () => { this.clientIdsRequested += 1; return "cid_shared_probe_test"; @@ -76,6 +88,34 @@ describe("test-daemon-connection connectToDaemon", () => { probe = new FakeDaemonProbe(); }); + it.each([undefined, "srv_probe_test"])( + "logs learned host identity on an adopted probe initially given %s", + async (serverId) => { + const { connectToDaemon } = await import("./test-daemon-connection"); + const result = await connectToDaemon( + { id: "direct:test", type: "directTcp", endpoint: "localhost:6767" }, + { serverId }, + probe.deps, + ); + try { + result.client.config.logger!.error({ err: new Error("socket closed") }, "connection_error"); + const record = JSON.parse(probe.diagnosticLines[0]!.slice("connection_error ".length)); + expect(record.serverId).toBe("srv_probe_test"); + expect(record.err.message).toBe("socket closed"); + await result.client.close(); + expect(result.client.getLastServerInfoMessage()).toBeNull(); + result.client.config.logger!.error({ err: new Error("disconnected") }, "connection_error"); + const closedRecord = JSON.parse( + probe.diagnosticLines[1]!.slice("connection_error ".length), + ); + expect(closedRecord.serverId).toBe("srv_probe_test"); + expect(closedRecord.err.message).toBe("disconnected"); + } finally { + await result.client.close(); + } + }, + ); + it.each([ { id: "direct:lan:6767", type: "directTcp" as const, endpoint: "lan:6767" }, { diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index 1d1605af62..96400b4878 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -7,6 +7,7 @@ import { import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client"; import type { HostConnection } from "@/types/host-connection"; import { createAppWebSocketFactory } from "@/runtime/websocket-factory"; +import { createHostLogger } from "@/runtime/host-logger"; import { getOrCreateClientId } from "./client-id"; import { resolveAppVersion } from "./app-version"; import { @@ -29,6 +30,7 @@ export interface DaemonProbeClient { } export interface DaemonConnectionDependencies { + createLogger: typeof createHostLogger; getClientId(): Promise; resolveAppVersion(): string | null; createDesktopTransportFactory(): DaemonClientConfig["transportFactory"] | null; @@ -37,6 +39,7 @@ export interface DaemonConnectionDependencies } const defaultDaemonConnectionDependencies: DaemonConnectionDependencies = { + createLogger: createHostLogger, getClientId: getOrCreateClientId, resolveAppVersion, createDesktopTransportFactory: createDesktopDaemonTransportFactory, @@ -317,5 +320,9 @@ export async function connectToDaemon( deps: DaemonConnectionDependencies = defaultDaemonConnectionDependencies, ): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> { const config = await buildClientConfig(connection, options?.serverId, options, deps); - return connectAndProbe(config, resolveTimeout(connection, options), deps); + let serverId = options?.serverId; + config.logger = deps.createLogger(() => serverId); + const result = await connectAndProbe(config, resolveTimeout(connection, options), deps); + serverId = result.serverId; + return result; } From d92495487fcb93f72eaa3f40250e51e276daa589 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:28:12 +0000 Subject: [PATCH 14/30] fix(website): enable the language menu after hydration (#9046) The server-rendered trigger accepted clicks before its handler existed. Tie its enabled state to hydration and cover delayed scripts so an early click cannot silently disappear. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/website/e2e/i18n.spec.ts | 30 +++++++++++++++++++ .../src/components/language-selector.tsx | 4 ++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/website/e2e/i18n.spec.ts b/packages/website/e2e/i18n.spec.ts index 02d17e92cb..79fa1fa2bb 100644 --- a/packages/website/e2e/i18n.spec.ts +++ b/packages/website/e2e/i18n.spec.ts @@ -9,6 +9,23 @@ async function chooseLanguage(page: Page, language: string) { await page.getByRole("menuitemradio", { name: language }).click(); } +async function withPageScriptsHeld(page: Page, run: (release: () => void) => Promise) { + let releaseScripts!: () => void; + const scriptsReleased = new Promise((resolve) => { + releaseScripts = resolve; + }); + await page.route("**/*.js", async (route) => { + await scriptsReleased; + await route.continue(); + }); + try { + await run(releaseScripts); + } finally { + releaseScripts(); + await page.unrouteAll({ behavior: "wait" }); + } +} + function describeAlternates(links: Element[]): string[] { return links.map((link) => `${link.getAttribute("hreflang")} ${link.getAttribute("href")}`); } @@ -114,6 +131,7 @@ test("shows the language as an abbreviation and lists every language by name", a test("switches language from the keyboard", async ({ page }) => { await page.goto("/"); + await expect(languageButton(page)).toBeEnabled(); await languageButton(page).focus(); await page.keyboard.press("Enter"); await expect(page.getByRole("menuitemradio", { name: "English" })).toBeFocused(); @@ -183,3 +201,15 @@ test("shows the changelog in Chinese", async ({ page }) => { await expect(page.getByRole("heading", { name: "新增" }).first()).toBeVisible(); await expect(page.getByRole("note")).toHaveCount(0); }); + +test("waits for scripts before enabling the language menu", async ({ page }) => { + await withPageScriptsHeld(page, async (releaseScripts) => { + await page.goto("/docs/cli#running-agents", { waitUntil: "commit" }); + await expect(languageButton(page)).toBeVisible(); + await expect(languageButton(page)).toBeDisabled(); + + releaseScripts(); + await languageButton(page).click(); + await expect(page.getByRole("menuitemradio", { name: "English" })).toBeVisible(); + }); +}); diff --git a/packages/website/src/components/language-selector.tsx b/packages/website/src/components/language-selector.tsx index 9fb219fad5..601663f534 100644 --- a/packages/website/src/components/language-selector.tsx +++ b/packages/website/src/components/language-selector.tsx @@ -1,4 +1,4 @@ -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useHydrated, useLocation, useNavigate } from "@tanstack/react-router"; import { AnimatePresence, motion, type Transition } from "framer-motion"; import { Check, ChevronDown, Languages } from "lucide-react"; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; @@ -33,6 +33,7 @@ type OpenFocus = "current" | "first" | "last"; * the menu button pattern: arrows move between languages, Escape and outside presses close it. */ export function LanguageSelector() { + const hydrated = useHydrated(); const { t } = useTranslation(); const locale = useLocale(); const location = useLocation(); @@ -125,6 +126,7 @@ export function LanguageSelector() {