From b339c5e61deaa69160b6266e470e1e9a285af85d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 8 Mar 2026 12:54:08 +0700 Subject: [PATCH] Fix bottom anchoring and Claude wake routing --- packages/app/e2e/agent-bottom-anchor.spec.ts | 183 ++++ .../app/e2e/helpers/agent-bottom-anchor.ts | 273 ++++++ .../app/src/components/agent-input-area.tsx | 3 + .../agent-stream-render-strategy.test.ts | 43 + .../agent-stream-render-strategy.ts | 101 ++- .../app/src/components/agent-stream-view.tsx | 582 +++++++++---- .../agent-stream-web-virtualization.test.ts | 173 ++++ .../agent-stream-web-virtualization.ts | 94 ++ packages/app/src/components/message-input.tsx | 10 +- .../use-bottom-anchor-controller.test.ts | 578 +++++++++++++ .../use-bottom-anchor-controller.ts | 817 ++++++++++++++++++ packages/app/src/contexts/session-context.tsx | 10 + .../hooks/use-agent-initialization.test.ts | 6 + .../app/src/hooks/use-agent-initialization.ts | 6 +- .../use-agent-screen-state-machine.test.ts | 40 + .../hooks/use-agent-screen-state-machine.ts | 31 +- packages/app/src/runtime/host-runtime.test.ts | 62 ++ packages/app/src/runtime/host-runtime.ts | 13 +- .../agent/agent-ready-screen-bottom-anchor.ts | 37 + .../agent-ready-screen.bottom-anchor.test.ts | 70 ++ .../src/screens/agent/agent-ready-screen.tsx | 40 +- .../workspace/workspace-desktop-tabs-row.tsx | 198 +++-- .../screens/workspace/workspace-screen.tsx | 64 +- packages/app/src/stores/session-store.ts | 45 + ...agent.interrupt-restart-regression.test.ts | 357 +++++++- .../server/agent/providers/claude-agent.ts | 175 +++- ...de-autonomous-wake-simple.real.e2e.test.ts | 116 +++ 27 files changed, 3801 insertions(+), 326 deletions(-) create mode 100644 packages/app/e2e/agent-bottom-anchor.spec.ts create mode 100644 packages/app/e2e/helpers/agent-bottom-anchor.ts create mode 100644 packages/app/src/components/agent-stream-web-virtualization.test.ts create mode 100644 packages/app/src/components/agent-stream-web-virtualization.ts create mode 100644 packages/app/src/components/use-bottom-anchor-controller.test.ts create mode 100644 packages/app/src/components/use-bottom-anchor-controller.ts create mode 100644 packages/app/src/screens/agent/agent-ready-screen-bottom-anchor.ts create mode 100644 packages/app/src/screens/agent/agent-ready-screen.bottom-anchor.test.ts create mode 100644 packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts diff --git a/packages/app/e2e/agent-bottom-anchor.spec.ts b/packages/app/e2e/agent-bottom-anchor.spec.ts new file mode 100644 index 000000000..e122adb78 --- /dev/null +++ b/packages/app/e2e/agent-bottom-anchor.spec.ts @@ -0,0 +1,183 @@ +import { test, expect, type Page } from "./fixtures"; +import { createTempGitRepo } from "./helpers/workspace"; +import { + connectDaemonClient, + createReplyTurn, + expectDetachedFromBottom, + expectNearBottom, + getChatContainerKey, + readScrollMetrics, + scrollUpFromBottom, + seedBottomAnchorAgent, + waitForAgentReady, + waitForContentGrowth, +} from "./helpers/agent-bottom-anchor"; + +test.describe.configure({ timeout: 180000 }); + +async function openWorkspaceAgentTab(page: Page, agentId: string) { + const tab = page.getByTestId(`workspace-tab-agent_${agentId}`).first(); + await expect(tab).toBeVisible({ timeout: 30000 }); + await tab.click(); +} + +test("direct load and refresh land at the bottom for history-backed chats", async ({ + page, +}) => { + const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-direct-"); + const client = await connectDaemonClient(); + + try { + const agent = await seedBottomAnchorAgent({ + client, + cwd: repo.path, + title: `bottom-anchor-direct-${Date.now()}`, + turnCount: 4, + }); + + await page.goto(agent.url, { waitUntil: "domcontentloaded" }); + await openWorkspaceAgentTab(page, agent.id); + await waitForAgentReady(page, agent.expectedTailText); + await expectNearBottom(page); + + await page.reload({ waitUntil: "commit" }); + await openWorkspaceAgentTab(page, agent.id); + await waitForAgentReady(page, agent.expectedTailText); + await expectNearBottom(page); + } finally { + await client.close().catch(() => undefined); + await repo.cleanup(); + } +}); + +test("revisiting a loaded chat restores bottom anchoring", async ({ + page, +}) => { + const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-switch-"); + const client = await connectDaemonClient(); + + try { + const agent = await seedBottomAnchorAgent({ + client, + cwd: repo.path, + title: `bottom-anchor-switch-${Date.now()}`, + turnCount: 4, + }); + + await page.goto(agent.url, { waitUntil: "domcontentloaded" }); + await openWorkspaceAgentTab(page, agent.id); + await waitForAgentReady(page, agent.expectedTailText); + await expectNearBottom(page); + + await page.getByTestId("sidebar-new-agent").first().click(); + await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({ + timeout: 30000, + }); + + await openWorkspaceAgentTab(page, agent.id); + await waitForAgentReady(page, agent.expectedTailText); + await expectNearBottom(page); + } finally { + await client.close().catch(() => undefined); + await repo.cleanup(); + } +}); + +test("sticky mode stays pinned through composer growth and viewport resize, but detached mode does not fight streamed updates", async ({ + page, +}) => { + const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-sticky-"); + const client = await connectDaemonClient(); + + try { + const agent = await seedBottomAnchorAgent({ + client, + cwd: repo.path, + title: `bottom-anchor-sticky-${Date.now()}`, + turnCount: 10, + }); + + await page.setViewportSize({ width: 1320, height: 920 }); + await page.goto(agent.url, { waitUntil: "domcontentloaded" }); + await openWorkspaceAgentTab(page, agent.id); + await waitForAgentReady(page, agent.expectedTailText); + await expectNearBottom(page); + + const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); + await composer.click(); + for (let index = 0; index < 6; index += 1) { + await composer.pressSequentially(`composer growth line ${index + 1}`); + if (index < 5) { + await page.keyboard.press("Shift+Enter"); + } + } + await expectNearBottom(page); + await expect(page.getByTestId("scroll-to-bottom-button")).toHaveCount(0); + + await page.setViewportSize({ width: 820, height: 760 }); + await expectNearBottom(page); + + await scrollUpFromBottom(page, 720); + await expectDetachedFromBottom(page); + const beforeExternalUpdate = await readScrollMetrics(page); + + const externalTurn = createReplyTurn(`external-stream-${Date.now()}`); + await client.sendAgentMessage(agent.id, externalTurn.message); + await waitForContentGrowth(page, beforeExternalUpdate.contentHeight); + const finish = await client.waitForFinish(agent.id, 120000); + expect(finish.status).toBe("idle"); + await expectDetachedFromBottom(page); + } finally { + await client.close().catch(() => undefined); + await repo.cleanup(); + } +}); + +test("web partial virtualization keeps bottom anchoring stable across direct load, refresh, and resize", async ({ + page, +}) => { + await page.addInitScript(() => { + (window as typeof window & { + __PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: number; + __PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: number; + }).__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6; + (window as typeof window & { + __PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: number; + __PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: number; + }).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4; + }); + + const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-virtualized-"); + const client = await connectDaemonClient(); + + try { + const agent = await seedBottomAnchorAgent({ + client, + cwd: repo.path, + title: `bottom-anchor-virtualized-${Date.now()}`, + turnCount: 4, + }); + + await page.goto(agent.url, { waitUntil: "domcontentloaded" }); + await openWorkspaceAgentTab(page, agent.id); + await waitForAgentReady(page, agent.expectedTailText); + await expect + .poll(async () => await getChatContainerKey(page)) + .toBe("web-partial-virtualized"); + await expectNearBottom(page); + + await page.reload({ waitUntil: "commit" }); + await openWorkspaceAgentTab(page, agent.id); + await waitForAgentReady(page, agent.expectedTailText); + await expect + .poll(async () => await getChatContainerKey(page)) + .toBe("web-partial-virtualized"); + await expectNearBottom(page); + + await page.setViewportSize({ width: 780, height: 720 }); + await expectNearBottom(page); + } finally { + await client.close().catch(() => undefined); + await repo.cleanup(); + } +}); diff --git a/packages/app/e2e/helpers/agent-bottom-anchor.ts b/packages/app/e2e/helpers/agent-bottom-anchor.ts new file mode 100644 index 000000000..d32f46234 --- /dev/null +++ b/packages/app/e2e/helpers/agent-bottom-anchor.ts @@ -0,0 +1,273 @@ +import { expect, type Page } from "@playwright/test"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { randomUUID } from "node:crypto"; +import { + buildHostWorkspaceAgentRoute, + buildHostWorkspaceRoute, +} from "../../src/utils/host-routes"; + +const NEAR_BOTTOM_THRESHOLD_PX = 72; + +export type ScrollMetrics = { + offsetY: number; + contentHeight: number; + viewportHeight: number; + distanceFromBottom: number; +}; + +export type SeededAgent = { + id: string; + title: string; + expectedTailText: string; + url: string; + workspaceUrl: string; +}; + +export type DaemonClientInstance = { + connect(): Promise; + close(): Promise; + createAgent(options: { + provider: string; + model: string; + thinkingOptionId: string; + modeId: string; + cwd: string; + title: string; + initialPrompt: string; + }): Promise<{ id: string }>; + sendAgentMessage(agentId: string, text: string): Promise; + waitForFinish( + agentId: string, + timeout?: number + ): Promise<{ status: string }>; +}; + +function getDaemonWsUrl(): string { + const daemonPort = process.env.E2E_DAEMON_PORT; + if (!daemonPort) { + throw new Error("E2E_DAEMON_PORT is not set."); + } + return `ws://127.0.0.1:${daemonPort}/ws`; +} + +function getServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + return serverId; +} + +function buildReplyBlock(label: string, lineCount = 14): string { + return Array.from({ length: lineCount }, (_, index) => { + const line = (index + 1).toString().padStart(2, "0"); + return `${label} line ${line} anchor verification text keeps wrapping stable across resize and composer growth.`; + }).join("\n"); +} + +function buildProtocolMessage(label: string): string { + return [ + "For every message in this chat, reply with exactly the text after the final line `REPLY:`.", + "Do not add extra words, bullets, markdown fences, or tool calls.", + "REPLY:", + buildReplyBlock(label), + ].join("\n"); +} + +function buildReplyMessage(label: string): string { + return ["REPLY:", buildReplyBlock(label)].join("\n"); +} + +export function createReplyTurn(label: string): { + message: string; + expectedReply: string; +} { + return { + message: buildReplyMessage(label), + expectedReply: buildReplyBlock(label), + }; +} + +async function loadDaemonClientConstructor(): Promise DaemonClientInstance> { + const repoRoot = path.resolve(process.cwd(), "../.."); + const moduleUrl = pathToFileURL( + path.join(repoRoot, "packages/server/dist/server/server/exports.js") + ).href; + const mod = (await import(moduleUrl)) as { + DaemonClient: new (config: { + url: string; + clientId: string; + clientType: "cli"; + }) => DaemonClientInstance; + }; + return mod.DaemonClient; +} + +export async function connectDaemonClient(): Promise { + const DaemonClient = await loadDaemonClientConstructor(); + const client = new DaemonClient({ + url: getDaemonWsUrl(), + clientId: `app-e2e-${randomUUID()}`, + clientType: "cli", + }); + await client.connect(); + return client; +} + +export async function seedBottomAnchorAgent(input: { + client: DaemonClientInstance; + cwd: string; + title?: string; + turnCount?: number; +}): Promise { + const title = input.title ?? `bottom-anchor-${Date.now()}`; + const turnCount = Math.max(3, input.turnCount ?? 5); + const created = await input.client.createAgent({ + provider: "codex", + model: "gpt-5.1-codex-mini", + thinkingOptionId: "low", + modeId: "full-access", + cwd: input.cwd, + title, + initialPrompt: buildProtocolMessage(`${title}-turn-00`), + }); + const initialFinish = await input.client.waitForFinish(created.id, 120000); + if (initialFinish.status !== "idle") { + throw new Error( + `Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.` + ); + } + + let expectedTailText = buildReplyBlock(`${title}-turn-00`); + for (let index = 1; index < turnCount; index += 1) { + const label = `${title}-turn-${index.toString().padStart(2, "0")}`; + expectedTailText = buildReplyBlock(label); + await input.client.sendAgentMessage(created.id, buildReplyMessage(label)); + const finish = await input.client.waitForFinish(created.id, 120000); + if (finish.status !== "idle") { + throw new Error( + `Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.` + ); + } + } + + return { + id: created.id, + title, + expectedTailText, + url: buildHostWorkspaceAgentRoute(getServerId(), input.cwd, created.id), + workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd), + }; +} + +export async function readScrollMetrics(page: Page): Promise { + return page.getByTestId("agent-chat-scroll").evaluate((root: Element) => { + const rootElement = root as HTMLElement; + const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))]; + const scrollElement = + candidates.find( + (element) => + element instanceof HTMLElement && + element.scrollHeight - element.clientHeight > 1 + ) ?? rootElement; + + const offsetY = Math.max(0, scrollElement.scrollTop); + const contentHeight = Math.max(0, scrollElement.scrollHeight); + const viewportHeight = Math.max(0, scrollElement.clientHeight); + const distanceFromBottom = Math.max( + 0, + contentHeight - (offsetY + viewportHeight) + ); + + return { + offsetY, + contentHeight, + viewportHeight, + distanceFromBottom, + }; + }); +} + +export async function scrollUpFromBottom(page: Page, pixels: number): Promise { + await page.getByTestId("agent-chat-scroll").evaluate( + (root: Element, amount: number) => { + const rootElement = root as HTMLElement; + const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))]; + const scrollElement = + candidates.find( + (element) => + element instanceof HTMLElement && + element.scrollHeight - element.clientHeight > 1 + ) ?? rootElement; + + const bottomOffset = Math.max( + 0, + scrollElement.scrollHeight - scrollElement.clientHeight + ); + scrollElement.scrollTop = Math.max(0, bottomOffset - amount); + }, + pixels + ); +} + +export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise { + await expect(page.getByTestId("agent-chat-scroll")).toBeVisible({ timeout: 60000 }); + await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({ + timeout: 60000, + }); + await expect(page.getByTestId("agent-loading")).toHaveCount(0, { timeout: 60000 }); + if (expectedTailText) { + await expect + .poll(async () => { + const metrics = await readScrollMetrics(page); + return metrics.contentHeight; + }) + .toBeGreaterThan(0); + } +} + +export async function expectNearBottom(page: Page): Promise { + await expect + .poll(async () => { + const metrics = await readScrollMetrics(page); + return metrics.distanceFromBottom; + }) + .toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX); +} + +export async function expectDetachedFromBottom(page: Page): Promise { + await expect + .poll(async () => { + const metrics = await readScrollMetrics(page); + return metrics.distanceFromBottom; + }) + .toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX); +} + +export async function waitForContentGrowth( + page: Page, + previousContentHeight: number +): Promise { + await expect + .poll(async () => { + const metrics = await readScrollMetrics(page); + return metrics.contentHeight; + }) + .toBeGreaterThan(previousContentHeight); + return readScrollMetrics(page); +} + +export async function getChatContainerKey(page: Page): Promise { + return page + .getByTestId("agent-chat-scroll") + .evaluate((element) => { + const nativeId = (element as HTMLElement).id; + const prefix = "agent-chat-scroll-"; + return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null; + }); +} diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index c6735af3b..ccf6e80d3 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -66,6 +66,7 @@ interface AgentInputAreaProps { commandDraftConfig?: DraftCommandConfig /** Called when a message is about to be sent (any path: keyboard, dictation, queued). */ onMessageSent?: () => void + onComposerHeightChange?: (height: number) => void /** Controlled status controls rendered in input area (draft flows). */ statusControls?: DraftAgentStatusBarProps } @@ -85,6 +86,7 @@ export function AgentInputArea({ onAddImages, commandDraftConfig, onMessageSent, + onComposerHeightChange, statusControls, }: AgentInputAreaProps) { markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`) @@ -823,6 +825,7 @@ export function AgentInputArea({ setCursorIndex(selection.start) }} onFocusChange={setIsMessageInputFocused} + onHeightChange={onComposerHeightChange} /> diff --git a/packages/app/src/components/agent-stream-render-strategy.test.ts b/packages/app/src/components/agent-stream-render-strategy.test.ts index 1ef1fc867..d74d2aa49 100644 --- a/packages/app/src/components/agent-stream-render-strategy.test.ts +++ b/packages/app/src/components/agent-stream-render-strategy.test.ts @@ -9,6 +9,7 @@ import { isNearBottomForStreamRenderStrategy, orderHeadForStreamRenderStrategy, orderTailForStreamRenderStrategy, + resolveBottomAnchorTransportBehavior, resolveStreamRenderStrategy, } from "./agent-stream-render-strategy"; @@ -45,6 +46,10 @@ describe("resolveStreamRenderStrategy", () => { expect(strategy.getFlatListInverted()).toBe(false); expect(strategy.getOverlayScrollbarInverted()).toBe(false); expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(true); + expect(strategy.getBottomAnchorTransportBehavior()).toEqual({ + verificationDelayFrames: 0, + verificationRetryMode: "rescroll", + }); }); it("uses inverted_stream on native", () => { @@ -57,6 +62,44 @@ describe("resolveStreamRenderStrategy", () => { expect(strategy.getFlatListInverted()).toBe(true); expect(strategy.getOverlayScrollbarInverted()).toBe(true); expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(false); + expect(strategy.getBottomAnchorTransportBehavior()).toEqual({ + verificationDelayFrames: 2, + verificationRetryMode: "recheck", + }); + }); + + it("delays native verification while viewport settling is in flight", () => { + const strategy = resolveStreamRenderStrategy({ + platform: "ios", + isMobileBreakpoint: false, + }); + + expect( + resolveBottomAnchorTransportBehavior({ + strategy, + isViewportSettling: true, + }) + ).toEqual({ + verificationDelayFrames: 4, + verificationRetryMode: "recheck", + }); + }); + + it("does not inflate forward-stream verification delays during web resize", () => { + const strategy = resolveStreamRenderStrategy({ + platform: "web", + isMobileBreakpoint: false, + }); + + expect( + resolveBottomAnchorTransportBehavior({ + strategy, + isViewportSettling: true, + }) + ).toEqual({ + verificationDelayFrames: 0, + verificationRetryMode: "rescroll", + }); }); }); diff --git a/packages/app/src/components/agent-stream-render-strategy.ts b/packages/app/src/components/agent-stream-render-strategy.ts index 547fe3928..1fcd91756 100644 --- a/packages/app/src/components/agent-stream-render-strategy.ts +++ b/packages/app/src/components/agent-stream-render-strategy.ts @@ -11,6 +11,11 @@ export type MaintainVisibleContentPositionConfig = Readonly<{ autoscrollToTopThreshold: number; }>; +export type BottomAnchorTransportBehavior = Readonly<{ + verificationDelayFrames: number; + verificationRetryMode: "rescroll" | "recheck"; +}>; + export type StreamViewportMetrics = { contentHeight: number; viewportHeight: number; @@ -29,7 +34,7 @@ export type StreamEdgeSlotProps = { }; export type StreamRenderRefs = { - flatListRef: RefObject | null>; + flatListRef: RefObject | null>; scrollViewRef: RefObject; bottomAnchorRef: RefObject; }; @@ -58,6 +63,7 @@ export interface StreamRenderStrategy { getMaintainVisibleContentPosition: () => | MaintainVisibleContentPositionConfig | undefined; + getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior; getFlatListInverted: () => boolean; getOverlayScrollbarInverted: () => boolean; shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean; @@ -76,6 +82,8 @@ export interface StreamRenderStrategy { }) => void; } +const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4; + type StreamRenderStrategyConfig = { orderTailReverse: boolean; orderHeadReverse: boolean; @@ -84,6 +92,7 @@ type StreamRenderStrategyConfig = { flatListInverted: boolean; overlayScrollbarInverted: boolean; maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig; + bottomAnchorTransportBehavior: BottomAnchorTransportBehavior; disableParentScrollOnInlineDetailsExpansion: boolean; anchorBottomOnContentSizeChange: boolean; animateManualScrollToBottom: boolean; @@ -137,28 +146,26 @@ function scrollAnchorIntoView(params: { return true; } -function forceScrollContainerToBottom( - refs: StreamRenderRefs, - fallbackOffset: number -): void { - const resolveNode = ( - input: unknown - ): HTMLElement | null => { - if (!(input instanceof HTMLElement)) { - return null; - } - if (input.scrollHeight - input.clientHeight > 1) { - return input; - } - let node: HTMLElement | null = input.parentElement; - while (node) { - if (node.scrollHeight - node.clientHeight > 1) { - return node; - } - node = node.parentElement; - } +function resolvePotentialScrollNode(input: unknown): HTMLElement | null { + if (!(input instanceof HTMLElement)) { return null; - }; + } + if (input.scrollHeight - input.clientHeight > 1) { + return input; + } + let node: HTMLElement | null = input.parentElement; + while (node) { + if (node.scrollHeight - node.clientHeight > 1) { + return node; + } + node = node.parentElement; + } + return input; +} + +export function resolveWebScrollContainerNode( + refs: StreamRenderRefs +): HTMLElement | null { const scrollViewHandle = refs.scrollViewRef.current as | { @@ -185,18 +192,43 @@ function forceScrollContainerToBottom( let scrollNode: HTMLElement | null = null; for (const candidate of candidates) { - scrollNode = resolveNode(candidate); + scrollNode = resolvePotentialScrollNode(candidate); if (scrollNode) { break; } } if (!scrollNode && typeof document !== "undefined") { - scrollNode = resolveNode( + scrollNode = resolvePotentialScrollNode( document.querySelector("[data-testid='agent-chat-scroll']") ); } + return scrollNode; +} + +export function resolveBottomAnchorTransportBehavior(input: { + strategy: StreamRenderStrategy; + isViewportSettling: boolean; +}): BottomAnchorTransportBehavior { + const baseBehavior = input.strategy.getBottomAnchorTransportBehavior(); + if (!input.isViewportSettling || !input.strategy.getFlatListInverted()) { + return baseBehavior; + } + return { + verificationDelayFrames: Math.max( + baseBehavior.verificationDelayFrames, + NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES + ), + verificationRetryMode: "recheck", + }; +} + +function forceScrollContainerToBottom( + refs: StreamRenderRefs, + fallbackOffset: number +): void { + const scrollNode = resolveWebScrollContainerNode(refs); if (!scrollNode) { return; } @@ -267,6 +299,7 @@ function createStreamRenderStrategy( }; }, getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition, + getBottomAnchorTransportBehavior: () => config.bottomAnchorTransportBehavior, getFlatListInverted: () => config.flatListInverted, getOverlayScrollbarInverted: () => config.overlayScrollbarInverted, shouldDisableParentScrollOnInlineDetailsExpansion: () => @@ -289,6 +322,10 @@ function createInvertedStreamStrategy(): StreamRenderStrategy { flatListInverted: true, overlayScrollbarInverted: true, maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION, + bottomAnchorTransportBehavior: { + verificationDelayFrames: 2, + verificationRetryMode: "recheck", + }, disableParentScrollOnInlineDetailsExpansion: false, anchorBottomOnContentSizeChange: false, animateManualScrollToBottom: true, @@ -316,6 +353,10 @@ function createForwardStreamStrategy(): StreamRenderStrategy { flatListInverted: false, overlayScrollbarInverted: false, maintainVisibleContentPosition: undefined, + bottomAnchorTransportBehavior: { + verificationDelayFrames: 0, + verificationRetryMode: "rescroll", + }, disableParentScrollOnInlineDetailsExpansion: false, anchorBottomOnContentSizeChange: true, animateManualScrollToBottom: false, @@ -335,6 +376,14 @@ function createForwardStreamStrategy(): StreamRenderStrategy { 0, metrics.contentHeight - metrics.viewportHeight ); + if (refs.flatListRef.current) { + refs.flatListRef.current.scrollToEnd?.({ animated }); + refs.flatListRef.current.scrollToOffset?.({ + offset: bottomOffset, + animated, + }); + return; + } const usedAnchor = scrollAnchorIntoView({ refs, animated }); if (!usedAnchor) { refs.scrollViewRef.current?.scrollToEnd?.({ animated }); @@ -347,6 +396,10 @@ function createForwardStreamStrategy(): StreamRenderStrategy { forceScrollContainerToBottom(refs, bottomOffset); }, scrollToOffset: ({ refs, offset, animated }) => { + if (refs.flatListRef.current) { + refs.flatListRef.current.scrollToOffset?.({ offset, animated }); + return; + } refs.scrollViewRef.current?.scrollTo({ y: offset, animated }); }, }); diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index df7908374..77fc829ad 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -21,9 +21,9 @@ import { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, - InteractionManager, Platform, ActivityIndicator, + Keyboard, } from "react-native"; import Markdown from "react-native-markdown-display"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -75,9 +75,22 @@ import { isNearBottomForStreamRenderStrategy, orderHeadForStreamRenderStrategy, orderTailForStreamRenderStrategy, + resolveBottomAnchorTransportBehavior, + resolveWebScrollContainerNode, resolveStreamRenderStrategy, type StreamEdgeSlotProps, } from "./agent-stream-render-strategy"; +import { + getWebMountedRecentStreamItems, + getWebPartialVirtualizationThreshold, + splitWebVirtualizedHistory, + type IndexedStreamItem, +} from "./agent-stream-web-virtualization"; +import { + useBottomAnchorController, + type BottomAnchorLocalRequest, + type BottomAnchorRouteRequest, +} from "./use-bottom-anchor-controller"; import { createMarkdownStyles } from "@/styles/markdown-styles"; import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf"; @@ -109,7 +122,8 @@ function renderStreamEdgeComponent( } export interface AgentStreamViewHandle { - scrollToBottom(): void; + scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void; + prepareForViewportChange(): void; } export interface AgentStreamViewProps { @@ -118,6 +132,8 @@ export interface AgentStreamViewProps { agent: Agent; streamItems: StreamItem[]; pendingPermissions: Map; + routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; + isAuthoritativeHistoryReady?: boolean; } export const AgentStreamView = forwardRef(function AgentStreamView({ @@ -126,8 +142,10 @@ export const AgentStreamView = forwardRef>(null); + const flatListRef = useRef>(null); const scrollViewRef = useRef(null); const bottomAnchorRef = useRef(null); const { theme } = useUnistyles(); @@ -145,19 +163,23 @@ export const AgentStreamView = forwardRef(null); - const pendingAutoScrollAnimatedRef = useRef(false); const scrollOffsetYRef = useRef(0); + const programmaticScrollEventBudgetRef = useRef(0); const streamItemCountRef = useRef(0); + const previousStreamItemsRef = useRef(null); const streamViewportMetricsRef = useRef({ + containerKey: "unknown", contentHeight: 0, + viewportWidth: 0, viewportHeight: 0, + offsetY: 0, + viewportMeasuredForKey: null as string | null, + contentMeasuredForKey: null as string | null, }); const streamScrollbarMetrics = useWebDesktopScrollbarMetrics(); + const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false); + const nativeViewportSettlingFrameIdRef = useRef(null); const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState>(new Set()); const openFileExplorer = usePanelStore((state) => state.openFileExplorer); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); @@ -165,6 +187,38 @@ export const AgentStreamView = forwardRef ({ flatListRef, scrollViewRef, bottomAnchorRef }), [] ); + const clearNativeViewportSettling = useCallback(() => { + if (nativeViewportSettlingFrameIdRef.current !== null) { + cancelAnimationFrame(nativeViewportSettlingFrameIdRef.current); + nativeViewportSettlingFrameIdRef.current = null; + } + }, []); + const markNativeViewportSettling = useCallback(() => { + if (Platform.OS === "web" || !streamRenderStrategy.getFlatListInverted()) { + return; + } + clearNativeViewportSettling(); + setIsNativeViewportSettling(true); + let remainingFrames = 4; + const tick = () => { + if (remainingFrames <= 0) { + nativeViewportSettlingFrameIdRef.current = null; + setIsNativeViewportSettling(false); + return; + } + remainingFrames -= 1; + nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick); + }; + nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick); + }, [clearNativeViewportSettling, streamRenderStrategy]); + const bottomAnchorTransportBehavior = useMemo( + () => + resolveBottomAnchorTransportBehavior({ + strategy: streamRenderStrategy, + isViewportSettling: isNativeViewportSettling, + }), + [isNativeViewportSettling, streamRenderStrategy] + ); // Get serverId (fallback to agent's serverId if not provided) const resolvedServerId = serverId ?? agent.serverId ?? ""; @@ -194,12 +248,52 @@ export const AgentStreamView = forwardRef { - hasScrolledInitially.current = false; - hasAutoScrolledOnce.current = false; isNearBottomRef.current = true; - pendingAnchorRequestRef.current = false; + scrollOffsetYRef.current = 0; + programmaticScrollEventBudgetRef.current = 0; + previousStreamItemsRef.current = null; + streamViewportMetricsRef.current = { + containerKey: "unknown", + contentHeight: 0, + viewportWidth: 0, + viewportHeight: 0, + offsetY: 0, + viewportMeasuredForKey: null, + contentMeasuredForKey: null, + }; setExpandedInlineToolCallIds(new Set()); - }, [agentId]); + clearNativeViewportSettling(); + setIsNativeViewportSettling(false); + }, [agentId, clearNativeViewportSettling]); + + useEffect(() => { + if (Platform.OS === "web" || !streamRenderStrategy.getFlatListInverted()) { + return; + } + const keyboardEvents = [ + "keyboardWillShow", + "keyboardWillHide", + "keyboardDidShow", + "keyboardDidHide", + "keyboardWillChangeFrame", + "keyboardDidChangeFrame", + ] as const; + const subscriptions = keyboardEvents.map((eventName) => + Keyboard.addListener(eventName, () => { + markNativeViewportSettling(); + }) + ); + return () => { + for (const subscription of subscriptions) { + subscription.remove(); + } + clearNativeViewportSettling(); + }; + }, [ + clearNativeViewportSettling, + markNativeViewportSettling, + streamRenderStrategy, + ]); const handleInlinePathPress = useCallback( (target: InlinePathTarget) => { @@ -252,23 +346,112 @@ export const AgentStreamView = forwardRef { - pendingAnchorRequestRef.current = true; - }, []); + const flatListData = useMemo(() => { + return orderTailForStreamRenderStrategy({ + strategy: streamRenderStrategy, + streamItems, + }); + }, [streamItems, streamRenderStrategy]); + const indexedFlatListData = useMemo( + () => flatListData.map((item, index) => ({ item, index })), + [flatListData] + ); + const usesVirtualizedList = streamRenderStrategy.shouldUseVirtualizedList(); + const shouldUseWebPartialVirtualization = + Platform.OS === "web" && + !isMobile && + !usesVirtualizedList && + indexedFlatListData.length > getWebPartialVirtualizationThreshold(); + const currentContainerKey = usesVirtualizedList + ? "native-virtualized" + : shouldUseWebPartialVirtualization + ? "web-partial-virtualized" + : "scroll-view"; + + useEffect(() => { + const current = streamViewportMetricsRef.current; + if (current.containerKey === currentContainerKey) { + return; + } + streamViewportMetricsRef.current = { + containerKey: currentContainerKey, + contentHeight: 0, + viewportWidth: 0, + viewportHeight: 0, + offsetY: 0, + viewportMeasuredForKey: null, + contentMeasuredForKey: null, + }; + scrollOffsetYRef.current = 0; + }, [currentContainerKey]); + + const scrollToBottomInternal = useCallback( + ({ animated }: { animated: boolean }) => { + const targetOffset = streamRenderStrategy.getBottomOffset( + streamViewportMetricsRef.current + ); + programmaticScrollEventBudgetRef.current = 3; + streamRenderStrategy.scrollToBottom({ + refs: streamRenderRefs, + metrics: streamViewportMetricsRef.current, + animated, + }); + scrollOffsetYRef.current = targetOffset; + streamViewportMetricsRef.current = { + ...streamViewportMetricsRef.current, + offsetY: targetOffset, + }; + updateNearBottom(true); + }, + [updateNearBottom, streamRenderRefs, streamRenderStrategy] + ); + + const bottomAnchorController = useBottomAnchorController({ + agentId, + routeRequest: routeBottomAnchorRequest, + isAuthoritativeHistoryReady, + renderStrategy: + Platform.OS === "web" ? "forward-stream" : "inverted-stream", + transportBehavior: bottomAnchorTransportBehavior, + getMeasurementState: () => streamViewportMetricsRef.current, + isNearBottom: () => { + const metrics = streamViewportMetricsRef.current; + return isNearBottomForStreamRenderStrategy({ + strategy: streamRenderStrategy, + offsetY: metrics.offsetY, + threshold: Math.max(insets.bottom, 32), + contentHeight: metrics.contentHeight, + viewportHeight: metrics.viewportHeight, + }); + }, + scrollToBottom: (animated) => { + scrollToBottomInternal({ animated }); + }, + }); + + useEffect(() => { + if (previousStreamItemsRef.current === null) { + previousStreamItemsRef.current = streamItems; + return; + } + previousStreamItemsRef.current = streamItems; + bottomAnchorController.prepareForStickyContentChange(); + }, [bottomAnchorController, streamItems]); const handleScroll = useCallback( (event: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; const previousOffsetY = scrollOffsetYRef.current; - const previousContentHeight = streamViewportMetricsRef.current.contentHeight; scrollOffsetYRef.current = contentOffset.y; streamViewportMetricsRef.current = { contentHeight: Math.max(0, contentSize.height), + viewportWidth: Math.max(0, layoutMeasurement.width), viewportHeight: Math.max(0, layoutMeasurement.height), + containerKey: currentContainerKey, + offsetY: contentOffset.y, + viewportMeasuredForKey: currentContainerKey, + contentMeasuredForKey: currentContainerKey, }; - const offsetDelta = contentOffset.y - previousOffsetY; - const contentHeightDelta = - streamViewportMetricsRef.current.contentHeight - previousContentHeight; const threshold = Math.max(insets.bottom, 32); const nearBottom = isNearBottomForStreamRenderStrategy({ strategy: streamRenderStrategy, @@ -277,23 +460,24 @@ export const AgentStreamView = forwardRef 0; - if (shouldSuppressFalseNearBottom) { - updateNearBottom(true); + if ( + programmaticScrollEventBudgetRef.current > 0 && + isProgrammaticBottomEvent + ) { + programmaticScrollEventBudgetRef.current -= 1; } else { - updateNearBottom(nearBottom); - } - - const shouldClearPendingAnchor = - pendingAnchorBefore && !nearBottom && Math.abs(offsetDelta) > 1; - if (shouldClearPendingAnchor) { - pendingAnchorRequestRef.current = false; + programmaticScrollEventBudgetRef.current = 0; + bottomAnchorController.handleScrollNearBottomChange({ + nextIsNearBottom: nearBottom, + scrollDelta: contentOffset.y - previousOffsetY, + }); } if (showDesktopWebScrollbar) { @@ -305,162 +489,151 @@ export const AgentStreamView = forwardRef { + const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth; + const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight; + const viewportWidth = Math.max(0, event.nativeEvent.layout.width); + const viewportHeight = Math.max(0, event.nativeEvent.layout.height); + const viewportChanged = + (previousViewportWidth > 0 && previousViewportWidth !== viewportWidth) || + (previousViewportHeight > 0 && previousViewportHeight !== viewportHeight); streamViewportMetricsRef.current = { ...streamViewportMetricsRef.current, - viewportHeight: Math.max(0, event.nativeEvent.layout.height), + containerKey: currentContainerKey, + viewportWidth, + viewportHeight, + viewportMeasuredForKey: currentContainerKey, }; + if (viewportChanged) { + markNativeViewportSettling(); + } + bottomAnchorController.handleViewportMetricsChange({ + previousViewportWidth, + viewportWidth, + previousViewportHeight, + viewportHeight, + }); if (showDesktopWebScrollbar) { streamScrollbarMetrics.onLayout(event); } }, - [showDesktopWebScrollbar, streamScrollbarMetrics] - ); - - const scrollToBottomInternal = useCallback( - ({ animated }: { animated: boolean }) => { - const targetOffset = streamRenderStrategy.getBottomOffset( - streamViewportMetricsRef.current - ); - streamRenderStrategy.scrollToBottom({ - refs: streamRenderRefs, - metrics: streamViewportMetricsRef.current, - animated, - }); - scrollOffsetYRef.current = targetOffset; - updateNearBottom(true); - }, - [updateNearBottom, streamRenderRefs, streamRenderStrategy] + [ + showDesktopWebScrollbar, + streamScrollbarMetrics, + currentContainerKey, + markNativeViewportSettling, + bottomAnchorController, + ] ); useImperativeHandle(ref, () => ({ - scrollToBottom() { - requestAnchorToBottom(); + scrollToBottom(reason = "jump-to-bottom") { + bottomAnchorController.requestLocalAnchor({ agentId, reason }); }, - }), [requestAnchorToBottom]); + prepareForViewportChange() { + bottomAnchorController.prepareForStickyViewportChange(); + }, + }), [agentId, bottomAnchorController]); const handleContentSizeChange = useCallback( (width: number, height: number) => { - const previousMetrics = streamViewportMetricsRef.current; - const threshold = Math.max(insets.bottom, 32); - const wasNearBottom = isNearBottomForStreamRenderStrategy({ - strategy: streamRenderStrategy, - offsetY: scrollOffsetYRef.current, - threshold, - contentHeight: previousMetrics.contentHeight, - viewportHeight: previousMetrics.viewportHeight, - }); - + const previousContentHeight = streamViewportMetricsRef.current.contentHeight; + const nextContentHeight = Math.max(0, height); streamViewportMetricsRef.current = { - ...previousMetrics, - contentHeight: Math.max(0, height), + ...streamViewportMetricsRef.current, + containerKey: currentContainerKey, + contentHeight: nextContentHeight, + contentMeasuredForKey: currentContainerKey, }; - if (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) { - if (!hasAutoScrolledOnce.current) { - scrollToBottomInternal({ animated: false }); - hasAutoScrolledOnce.current = true; - hasScrolledInitially.current = true; - } else if ( - wasNearBottom || - isNearBottomRef.current || - pendingAnchorRequestRef.current - ) { - scrollToBottomInternal({ animated: false }); - } - } + bottomAnchorController.handleContentSizeChange({ + previousContentHeight, + contentHeight: nextContentHeight, + }); if (showDesktopWebScrollbar) { streamScrollbarMetrics.onContentSizeChange(width, height); } }, [ - insets.bottom, - scrollToBottomInternal, showDesktopWebScrollbar, - streamRenderStrategy, streamScrollbarMetrics, + currentContainerKey, + bottomAnchorController, ] ); - const scheduleAutoScroll = useCallback( - ({ animated }: { animated: boolean }) => { - pendingAutoScrollAnimatedRef.current = - pendingAutoScrollAnimatedRef.current || animated; - - if (pendingAutoScrollFrameRef.current !== null) { - return; - } - - pendingAutoScrollFrameRef.current = requestAnimationFrame(() => { - pendingAutoScrollFrameRef.current = null; - const shouldAnimate = pendingAutoScrollAnimatedRef.current; - pendingAutoScrollAnimatedRef.current = false; - scrollToBottomInternal({ animated: shouldAnimate }); - }); - }, - [scrollToBottomInternal] - ); - useEffect(() => { - return () => { - if (pendingAutoScrollFrameRef.current !== null) { - cancelAnimationFrame(pendingAutoScrollFrameRef.current); - pendingAutoScrollFrameRef.current = null; - } - pendingAutoScrollAnimatedRef.current = false; + if ( + Platform.OS !== "web" || + typeof ResizeObserver === "undefined" + ) { + return; + } + + const scrollNode = resolveWebScrollContainerNode(streamRenderRefs); + if (!scrollNode) { + return; + } + + const applyObservedMetrics = () => { + const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth; + const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight; + const previousContentHeight = streamViewportMetricsRef.current.contentHeight; + const viewportWidth = Math.max(0, scrollNode.clientWidth); + const viewportHeight = Math.max(0, scrollNode.clientHeight); + const contentHeight = Math.max(0, scrollNode.scrollHeight); + + streamViewportMetricsRef.current = { + ...streamViewportMetricsRef.current, + containerKey: currentContainerKey, + viewportWidth, + viewportHeight, + contentHeight, + viewportMeasuredForKey: currentContainerKey, + contentMeasuredForKey: currentContainerKey, + }; + + bottomAnchorController.handleViewportMetricsChange({ + previousViewportWidth, + viewportWidth, + previousViewportHeight, + viewportHeight, + }); + bottomAnchorController.handleContentSizeChange({ + previousContentHeight, + contentHeight, + }); }; - }, []); - useEffect(() => { - if (streamItems.length === 0) { - return; + applyObservedMetrics(); + const resizeObserver = new ResizeObserver(() => { + applyObservedMetrics(); + }); + resizeObserver.observe(scrollNode); + const contentNode = scrollNode.firstElementChild; + if (contentNode instanceof HTMLElement) { + resizeObserver.observe(contentNode); } - if (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) { - // Forward streams anchor from measurement updates in handleContentSizeChange. - return; - } - - if (!hasAutoScrolledOnce.current) { - const handle = InteractionManager.runAfterInteractions(() => { - scrollToBottomInternal({ animated: false }); - hasAutoScrolledOnce.current = true; - hasScrolledInitially.current = true; - }); - return () => handle.cancel(); - } - - if (!isNearBottomRef.current && !pendingAnchorRequestRef.current) { - return; - } - - const shouldAnimate = hasScrolledInitially.current; - scheduleAutoScroll({ animated: shouldAnimate }); - hasScrolledInitially.current = true; - }, [ - scheduleAutoScroll, - scrollToBottomInternal, - streamItems, - streamRenderStrategy, - ]); + return () => { + resizeObserver.disconnect(); + }; + }, [bottomAnchorController, currentContainerKey, streamRenderRefs]); function scrollToBottom() { - const animated = streamRenderStrategy.shouldAnimateManualScrollToBottom(); - scrollToBottomInternal({ animated }); - } - - const flatListData = useMemo(() => { - return orderTailForStreamRenderStrategy({ - strategy: streamRenderStrategy, - streamItems, + bottomAnchorController.requestLocalAnchor({ + agentId, + reason: "jump-to-bottom", }); - }, [streamItems, streamRenderStrategy]); + } const orderedStreamHead = useMemo(() => { return orderHeadForStreamRenderStrategy({ @@ -790,7 +963,35 @@ export const AgentStreamView = forwardRef { + if (!shouldUseWebPartialVirtualization) { + return null; + } + return splitWebVirtualizedHistory({ + entries: indexedFlatListData, + minMountedCount: getWebMountedRecentStreamItems(), + }); + }, [indexedFlatListData, shouldUseWebPartialVirtualization]); + + const renderIndexedStreamItem = useCallback( + (entry: IndexedStreamItem) => + renderStreamItem({ + item: entry.item, + index: entry.index, + separators: NOOP_SEPARATORS, + }), + [renderStreamItem] + ); + const renderWebVirtualizedStreamItem = useCallback( + ({ item }: ListRenderItemInfo) => { + const rendered = renderIndexedStreamItem(item); + if (!rendered) { + return null; + } + return {rendered}; + }, + [renderIndexedStreamItem] + ); const listEdgeSlotComponent = useMemo(() => { const hasPermissions = pendingPermissionItems.length > 0; @@ -929,22 +1130,59 @@ export const AgentStreamView = forwardRef { - if (flatListData.length === 0) { + if (indexedFlatListData.length === 0) { return null; } - return flatListData.map((item, index) => { - const rendered = renderStreamItem({ - item, - index, - separators: NOOP_SEPARATORS, - }); + return indexedFlatListData.map((entry) => { + const rendered = renderIndexedStreamItem(entry); if (!rendered) { return null; } - return {rendered}; + return {rendered}; }); - }, [flatListData, renderStreamItem]); + }, [indexedFlatListData, renderIndexedStreamItem]); + const webVirtualizedListHeader = useMemo(() => { + if (!shouldUseWebPartialVirtualization || !headerEdgeContent) { + return null; + } + return ( + + {headerEdgeContent} + + ); + }, [ + headerEdgeContent, + listEdgeSlotProps.ListHeaderComponentStyle, + shouldUseWebPartialVirtualization, + ]); + const webVirtualizedListFooter = useMemo(() => { + if (!shouldUseWebPartialVirtualization || !webVirtualizedHistoryWindow) { + return null; + } + + return ( + <> + {webVirtualizedHistoryWindow.mountedEntries.map((entry) => ( + + {renderIndexedStreamItem(entry)} + + ))} + {footerEdgeContent ? ( + + {footerEdgeContent} + + ) : null} + + + ); + }, [ + footerEdgeContent, + listEdgeSlotProps.ListFooterComponentStyle, + renderIndexedStreamItem, + shouldUseWebPartialVirtualization, + webVirtualizedHistoryWindow, + ]); return ( @@ -957,6 +1195,7 @@ export const AgentStreamView = forwardRef item.id} testID="agent-chat-scroll" + nativeID={`agent-chat-scroll-${currentContainerKey}`} {...listEdgeSlotProps} contentContainerStyle={listContentContainerStyle} style={stylesheet.list} @@ -975,10 +1214,34 @@ export const AgentStreamView = forwardRef + ) : shouldUseWebPartialVirtualization && webVirtualizedHistoryWindow ? ( + entry.item.id} + testID="agent-chat-scroll" + nativeID={`agent-chat-scroll-${currentContainerKey}`} + ListHeaderComponent={webVirtualizedListHeader} + ListFooterComponent={webVirtualizedListFooter} + contentContainerStyle={listContentContainerStyle} + style={stylesheet.list} + onLayout={handleListLayout} + onScroll={handleScroll} + scrollEventThrottle={16} + onContentSizeChange={handleContentSizeChange} + extraData={flatListExtraData} + initialNumToRender={12} + windowSize={10} + scrollEnabled={streamScrollEnabled} + showsVerticalScrollIndicator={!showDesktopWebScrollbar} + inverted={false} + /> ) : ( ({ item, index })); +} + +describe("findMountedWindowStart", () => { + it("keeps all items mounted when the chat is below the threshold", () => { + const items = [userMessage("u1", 1), assistantMessage("a1", 2)]; + + expect( + findMountedWindowStart({ + items, + minMountedCount: 50, + }) + ).toBe(0); + }); + + it("rewinds to the previous user boundary when the cutoff lands inside a turn", () => { + const items: StreamItem[] = []; + for (let index = 0; index < 30; index += 1) { + const seed = index * 3; + items.push(userMessage(`u${index}`, seed + 1)); + items.push(toolCall(`t${index}`, seed + 2)); + items.push(assistantMessage(`a${index}`, seed + 3)); + } + + expect( + findMountedWindowStart({ + items, + minMountedCount: 50, + }) + ).toBe(39); + }); +}); + +describe("splitWebVirtualizedHistory", () => { + it("splits older entries into the virtualized section and keeps the recent window mounted", () => { + const items: StreamItem[] = []; + for (let index = 0; index < 30; index += 1) { + const seed = index * 2; + items.push(userMessage(`u${index}`, seed + 1)); + items.push(assistantMessage(`a${index}`, seed + 2)); + } + + const window = splitWebVirtualizedHistory({ + entries: indexEntries(items), + minMountedCount: 50, + }); + + expect(window.virtualizedEntries).toHaveLength(10); + expect(window.virtualizedEntries[0]?.item.id).toBe("u0"); + expect(window.virtualizedEntries.at(-1)?.item.id).toBe("a4"); + expect(window.mountedEntries[0]?.item.id).toBe("u5"); + expect(window.mountedEntries).toHaveLength(50); + }); +}); + +describe("estimateStreamItemHeight", () => { + it("uses a larger estimate for user messages with image attachments", () => { + const item: StreamItem = { + kind: "user_message", + id: "u-image", + text: "image", + timestamp: createTimestamp(1), + images: [ + { + id: "att-1", + mimeType: "image/png", + storageType: "desktop-file", + storageKey: "/tmp/screenshot.png", + fileName: "screenshot.png", + byteSize: 1024, + createdAt: Date.now(), + }, + ], + }; + + expect(estimateStreamItemHeight(item)).toBe(220); + }); +}); + +describe("web virtualization test overrides", () => { + it("uses defaults unless explicit positive integer overrides are present", () => { + const globalWithOverrides = globalThis as typeof globalThis & { + __PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown; + __PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown; + }; + const previousThreshold = + globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD; + const previousMounted = + globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS; + + try { + delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD; + delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS; + expect(getWebPartialVirtualizationThreshold()).toBe( + DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD + ); + expect(getWebMountedRecentStreamItems()).toBe( + DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS + ); + + globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6; + globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4; + expect(getWebPartialVirtualizationThreshold()).toBe(6); + expect(getWebMountedRecentStreamItems()).toBe(4); + } finally { + if (previousThreshold === undefined) { + delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD; + } else { + globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = + previousThreshold; + } + if (previousMounted === undefined) { + delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS; + } else { + globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = + previousMounted; + } + } + }); +}); diff --git a/packages/app/src/components/agent-stream-web-virtualization.ts b/packages/app/src/components/agent-stream-web-virtualization.ts new file mode 100644 index 000000000..ed067c02d --- /dev/null +++ b/packages/app/src/components/agent-stream-web-virtualization.ts @@ -0,0 +1,94 @@ +import type { StreamItem } from "@/types/stream"; + +export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100; +export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50; + +type BottomAnchorE2ETestGlobals = typeof globalThis & { + __PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown; + __PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown; +}; + +function readPositiveIntegerOverride(value: unknown): number | null { + if (!Number.isFinite(value)) { + return null; + } + const normalized = Math.trunc(value as number); + return normalized > 0 ? normalized : null; +} + +export function getWebPartialVirtualizationThreshold(): number { + const override = readPositiveIntegerOverride( + (globalThis as BottomAnchorE2ETestGlobals) + .__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD + ); + return override ?? DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD; +} + +export function getWebMountedRecentStreamItems(): number { + const override = readPositiveIntegerOverride( + (globalThis as BottomAnchorE2ETestGlobals) + .__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS + ); + return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS; +} + +export type IndexedStreamItem = { + item: StreamItem; + index: number; +}; + +export type WebVirtualizedHistoryWindow = { + virtualizedEntries: IndexedStreamItem[]; + mountedEntries: IndexedStreamItem[]; +}; + +export function estimateStreamItemHeight(item: StreamItem): number { + switch (item.kind) { + case "user_message": + return item.images && item.images.length > 0 ? 220 : 96; + case "assistant_message": + return 220; + case "tool_call": + return 136; + case "thought": + return 112; + case "todo_list": + return 144; + case "activity_log": + return 88; + case "compaction": + return 72; + default: + return 120; + } +} + +export function findMountedWindowStart(input: { + items: StreamItem[]; + minMountedCount: number; +}): number { + const { items, minMountedCount } = input; + if (items.length <= minMountedCount) { + return 0; + } + + let startIndex = Math.max(items.length - minMountedCount, 0); + while (startIndex > 0 && items[startIndex]?.kind !== "user_message") { + startIndex -= 1; + } + return startIndex; +} + +export function splitWebVirtualizedHistory(input: { + entries: IndexedStreamItem[]; + minMountedCount: number; +}): WebVirtualizedHistoryWindow { + const startIndex = findMountedWindowStart({ + items: input.entries.map((entry) => entry.item), + minMountedCount: input.minMountedCount, + }); + return { + virtualizedEntries: input.entries.slice(0, startIndex), + mountedEntries: input.entries.slice(startIndex), + }; +} diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 6a52b2c1c..8881d2b53 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -84,6 +84,7 @@ export interface MessageInputProps { /** Reports cursor selection updates from the underlying input. */ onSelectionChange?: (selection: { start: number; end: number }) => void onFocusChange?: (focused: boolean) => void + onHeightChange?: (height: number) => void } export interface MessageInputRef { @@ -153,6 +154,7 @@ export const MessageInput = forwardRef(funct onKeyPress: onKeyPressCallback, onSelectionChange: onSelectionChangeCallback, onFocusChange, + onHeightChange, }, ref ) { @@ -473,7 +475,8 @@ export const MessageInput = forwardRef(funct onSubmit(payload) inputHeightRef.current = MIN_INPUT_HEIGHT setInputHeight(MIN_INPUT_HEIGHT) - }, [value, images, onSubmit, isAgentRunning]) + onHeightChange?.(MIN_INPUT_HEIGHT) + }, [value, images, onSubmit, isAgentRunning, onHeightChange]) const handleQueueMessage = useCallback(() => { if (!onQueue) return @@ -487,7 +490,8 @@ export const MessageInput = forwardRef(funct onChangeText('') inputHeightRef.current = MIN_INPUT_HEIGHT setInputHeight(MIN_INPUT_HEIGHT) - }, [value, images, onQueue, onChangeText]) + onHeightChange?.(MIN_INPUT_HEIGHT) + }, [value, images, onQueue, onChangeText, onHeightChange]) // Web input height measurement function isTextAreaLike(v: unknown): v is TextAreaHandle { @@ -587,6 +591,7 @@ export const MessageInput = forwardRef(funct if (Math.abs(inputHeightRef.current - bounded) >= 1) { inputHeightRef.current = bounded setInputHeight(bounded) + onHeightChange?.(bounded) return true } return false @@ -597,6 +602,7 @@ export const MessageInput = forwardRef(funct if (Math.abs(inputHeightRef.current - bounded) < 1) return inputHeightRef.current = bounded setInputHeight(bounded) + onHeightChange?.(bounded) } function handleContentSizeChange( diff --git a/packages/app/src/components/use-bottom-anchor-controller.test.ts b/packages/app/src/components/use-bottom-anchor-controller.test.ts new file mode 100644 index 000000000..75b897c75 --- /dev/null +++ b/packages/app/src/components/use-bottom-anchor-controller.test.ts @@ -0,0 +1,578 @@ +import { describe, expect, it, vi } from "vitest"; +import { + __private__, + deriveBottomAnchorBlockedReason, + type BottomAnchorMode, +} from "./use-bottom-anchor-controller"; +import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strategy"; + +type MeasurementState = ReturnType; + +function createMeasurementState( + overrides?: Partial<{ + containerKey: string; + viewportWidth: number; + viewportHeight: number; + contentHeight: number; + offsetY: number; + viewportMeasuredForKey: string | null; + contentMeasuredForKey: string | null; + }> +) { + return { + containerKey: "scroll-view", + viewportWidth: 0, + viewportHeight: 0, + contentHeight: 0, + offsetY: 0, + viewportMeasuredForKey: null, + contentMeasuredForKey: null, + ...overrides, + }; +} + +function createPendingRequest() { + return { + id: 1, + agentId: "agent-1", + reason: "initial-entry" as const, + requestKey: "route:agent-1", + }; +} + +function createFrameScheduler() { + let sequence = 0; + const tasks = new Map< + number, + { + cancelled: boolean; + remainingFrames: number; + callback: () => void; + kind: "attempt" | "verification"; + } + >(); + + return { + schedule(params: { + kind: "attempt" | "verification"; + callback: () => void; + delayFrames?: number; + }) { + const id = ++sequence; + tasks.set(id, { + cancelled: false, + remainingFrames: Math.max(0, params.delayFrames ?? 0), + callback: params.callback, + kind: params.kind, + }); + return id; + }, + cancel(handle: unknown) { + const task = tasks.get(handle as number); + if (task) { + task.cancelled = true; + } + }, + flushFrame() { + const due: Array<() => void> = []; + for (const [id, task] of Array.from(tasks.entries())) { + if (task.cancelled) { + tasks.delete(id); + continue; + } + if (task.remainingFrames > 0) { + task.remainingFrames -= 1; + continue; + } + tasks.delete(id); + due.push(task.callback); + } + for (const callback of due) { + callback(); + } + }, + flushAll(limit = 20) { + for (let index = 0; index < limit && tasks.size > 0; index += 1) { + this.flushFrame(); + } + }, + }; +} + +function createDriverHarness(input?: { + transportBehavior?: BottomAnchorTransportBehavior; + isNearBottom?: boolean; + measurementState?: MeasurementState; + authoritativeReady?: boolean; +}) { + const scheduler = createFrameScheduler(); + const measurementState = + input?.measurementState ?? + createMeasurementState({ + viewportWidth: 800, + viewportHeight: 480, + contentHeight: 1200, + viewportMeasuredForKey: "scroll-view", + contentMeasuredForKey: "scroll-view", + }); + const context = { + agentId: "agent-1", + authoritativeReady: input?.authoritativeReady ?? true, + renderStrategy: "forward-stream", + transportBehavior: + input?.transportBehavior ?? { + verificationDelayFrames: 0, + verificationRetryMode: "rescroll", + }, + measurementState, + nearBottom: input?.isNearBottom ?? true, + }; + const scrollToBottom = vi.fn(() => { + context.nearBottom = true; + context.measurementState.offsetY = 720; + }); + const modeChanges: BottomAnchorMode[] = []; + const warnings: Array<{ agentId: string; reason: string }> = []; + const driver = __private__.createBottomAnchorControllerDriver({ + getAgentId: () => context.agentId, + getIsAuthoritativeHistoryReady: () => context.authoritativeReady, + getRenderStrategy: () => context.renderStrategy, + getTransportBehavior: () => context.transportBehavior, + getMeasurementState: () => context.measurementState, + isNearBottom: () => context.nearBottom, + scrollToBottom, + onModeChange: (mode) => { + modeChanges.push(mode); + }, + log: () => {}, + warn: (details) => warnings.push(details), + scheduleFrame: (params) => scheduler.schedule(params), + cancelFrame: (handle) => scheduler.cancel(handle), + }); + + return { + context, + driver, + scheduler, + scrollToBottom, + modeChanges, + warnings, + }; +} + +describe("deriveBottomAnchorBlockedReason", () => { + it("keeps initial-entry pending until history is ready and geometry is measurable", () => { + const pendingRequest = createPendingRequest(); + + expect( + deriveBottomAnchorBlockedReason({ + pendingRequest, + isAuthoritativeHistoryReady: false, + measurementState: createMeasurementState(), + pendingVerificationRequestId: null, + }) + ).toBe("waiting_for_history_readiness"); + + expect( + deriveBottomAnchorBlockedReason({ + pendingRequest, + isAuthoritativeHistoryReady: true, + measurementState: createMeasurementState({ + viewportHeight: 480, + viewportMeasuredForKey: "scroll-view", + }), + pendingVerificationRequestId: null, + }) + ).toBe("waiting_for_measurable_content"); + + expect( + deriveBottomAnchorBlockedReason({ + pendingRequest, + isAuthoritativeHistoryReady: true, + measurementState: createMeasurementState({ + viewportHeight: 480, + contentHeight: 1200, + viewportMeasuredForKey: "scroll-view", + contentMeasuredForKey: "scroll-view", + }), + pendingVerificationRequestId: pendingRequest.id, + }) + ).toBe("waiting_for_post_layout_verification"); + }); +}); + +describe("bottom anchor controller driver", () => { + it("keeps initial-entry pending until authoritative history and current geometry exist", () => { + const harness = createDriverHarness({ + authoritativeReady: false, + measurementState: createMeasurementState(), + }); + + harness.driver.applyRouteRequest({ + agentId: "agent-1", + reason: "initial-entry", + requestKey: "route:agent-1:initial-entry", + }); + harness.scheduler.flushAll(); + + expect(harness.scrollToBottom).not.toHaveBeenCalled(); + expect(harness.driver.getSnapshot()).toMatchObject({ + mode: "sticky-bottom", + blockedReason: "waiting_for_history_readiness", + pendingRequest: { + reason: "initial-entry", + }, + }); + + harness.context.authoritativeReady = true; + harness.context.measurementState.viewportHeight = 480; + harness.context.measurementState.contentHeight = 1200; + harness.context.measurementState.viewportMeasuredForKey = "scroll-view"; + harness.context.measurementState.contentMeasuredForKey = "scroll-view"; + harness.context.nearBottom = true; + harness.driver.notifyAuthoritativeHistoryMaybeChanged(); + harness.driver.reevaluate(); + harness.scheduler.flushAll(); + + expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.driver.getSnapshot()).toMatchObject({ + blockedReason: null, + pendingRequest: null, + pendingVerification: null, + }); + }); + + it("suppresses sticky maintenance while detached", () => { + const harness = createDriverHarness(); + + harness.driver.detachByUser(); + harness.driver.handleContentSizeChange({ + previousContentHeight: 1200, + contentHeight: 1500, + }); + harness.driver.handleViewportMetricsChange({ + previousViewportWidth: 800, + viewportWidth: 640, + previousViewportHeight: 480, + viewportHeight: 420, + }); + harness.scheduler.flushAll(); + + expect(harness.driver.getSnapshot().mode).toBe("detached"); + expect(harness.scrollToBottom).not.toHaveBeenCalled(); + }); + + it("switches back to sticky-bottom for explicit jump-to-bottom", () => { + const harness = createDriverHarness({ + isNearBottom: false, + }); + + harness.driver.detachByUser(); + harness.driver.requestLocalAnchor({ + agentId: "agent-1", + reason: "jump-to-bottom", + }); + harness.scheduler.flushAll(); + + expect(harness.modeChanges).toContain("detached"); + expect(harness.modeChanges).toContain("sticky-bottom"); + expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom"); + }); + + it("schedules sticky maintenance on viewport and content growth", () => { + const harness = createDriverHarness(); + + harness.driver.handleViewportMetricsChange({ + previousViewportWidth: 800, + viewportWidth: 640, + previousViewportHeight: 480, + viewportHeight: 420, + }); + harness.scheduler.flushAll(); + + harness.driver.handleContentSizeChange({ + previousContentHeight: 1200, + contentHeight: 1600, + }); + harness.scheduler.flushAll(); + + expect(harness.scrollToBottom).toHaveBeenCalledTimes(2); + }); + + it("keeps a pending request blocked when stale container measurements arrive", () => { + const harness = createDriverHarness({ + measurementState: createMeasurementState({ + containerKey: "web-partial-virtualized", + viewportHeight: 420, + contentHeight: 1200, + viewportMeasuredForKey: "scroll-view", + contentMeasuredForKey: "scroll-view", + }), + }); + + harness.driver.applyRouteRequest({ + agentId: "agent-1", + reason: "resume", + requestKey: "route:agent-1:resume", + }); + harness.scheduler.flushAll(); + + expect(harness.scrollToBottom).not.toHaveBeenCalled(); + expect(harness.driver.getSnapshot()).toMatchObject({ + blockedReason: "waiting_for_measurable_viewport", + pendingRequest: { + reason: "resume", + }, + }); + + harness.context.measurementState.viewportMeasuredForKey = "web-partial-virtualized"; + harness.context.measurementState.contentMeasuredForKey = "web-partial-virtualized"; + harness.driver.reevaluate(); + harness.scheduler.flushAll(); + + expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.driver.getSnapshot().pendingRequest).toBeNull(); + }); + + it("uses delayed rechecks instead of repeated rescroll loops for native transport", () => { + const harness = createDriverHarness({ + transportBehavior: { + verificationDelayFrames: 2, + verificationRetryMode: "recheck", + }, + isNearBottom: false, + }); + harness.scrollToBottom.mockImplementation(() => { + harness.context.measurementState.offsetY = 0; + }); + + harness.driver.requestLocalAnchor({ + agentId: "agent-1", + reason: "jump-to-bottom", + }); + + harness.scheduler.flushFrame(); + expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + + harness.scheduler.flushFrame(); + harness.scheduler.flushFrame(); + expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + + harness.context.nearBottom = true; + harness.scheduler.flushAll(); + + expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.warnings).toEqual([]); + expect(harness.driver.getSnapshot().pendingRequest).toBeNull(); + }); + + it("keeps sticky-bottom during viewport growth until bottom is re-verified", () => { + const harness = createDriverHarness(); + harness.context.nearBottom = false; + harness.scrollToBottom.mockImplementation(() => { + harness.context.measurementState.offsetY = 720; + }); + + harness.driver.handleViewportMetricsChange({ + previousViewportWidth: 800, + viewportWidth: 800, + previousViewportHeight: 480, + viewportHeight: 420, + }); + harness.scheduler.flushAll(); + + expect(harness.scrollToBottom).toHaveBeenCalledTimes(4); + expect(harness.driver.getSnapshot()).toMatchObject({ + mode: "sticky-bottom", + pendingRequest: null, + pendingVerification: null, + }); + + harness.driver.handleScrollNearBottomChange({ + nextIsNearBottom: false, + scrollDelta: 0, + }); + + expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom"); + + harness.context.nearBottom = true; + harness.driver.handleScrollNearBottomChange({ + nextIsNearBottom: true, + scrollDelta: 0, + }); + harness.scheduler.flushAll(); + harness.driver.handleScrollNearBottomChange({ + nextIsNearBottom: false, + scrollDelta: 64, + }); + + expect(harness.driver.getSnapshot().mode).toBe("detached"); + }); + + it("keeps sticky-bottom during streaming growth until bottom is re-verified", () => { + const harness = createDriverHarness(); + harness.context.nearBottom = false; + harness.scrollToBottom.mockImplementation(() => { + harness.context.measurementState.offsetY = 900; + }); + + harness.driver.handleContentSizeChange({ + previousContentHeight: 1200, + contentHeight: 1400, + }); + harness.scheduler.flushAll(); + + expect(harness.scrollToBottom).toHaveBeenCalledTimes(4); + expect(harness.driver.getSnapshot()).toMatchObject({ + mode: "sticky-bottom", + pendingRequest: null, + pendingVerification: null, + }); + + harness.driver.handleScrollNearBottomChange({ + nextIsNearBottom: false, + scrollDelta: 0, + }); + + expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom"); + + harness.context.nearBottom = true; + harness.driver.handleScrollNearBottomChange({ + nextIsNearBottom: true, + scrollDelta: 0, + }); + harness.scheduler.flushAll(); + harness.driver.handleScrollNearBottomChange({ + nextIsNearBottom: false, + scrollDelta: 64, + }); + + expect(harness.driver.getSnapshot().mode).toBe("detached"); + }); +}); + +describe("controller helper predicates", () => { + it("rejects stale container measurements during post-scroll verification", () => { + expect( + __private__.deriveVerificationBlockedReason({ + isAuthoritativeHistoryReady: true, + measurementState: createMeasurementState({ + containerKey: "web-partial-virtualized", + viewportHeight: 420, + contentHeight: 1200, + viewportMeasuredForKey: "scroll-view", + contentMeasuredForKey: "scroll-view", + }), + }) + ).toBe("waiting_for_measurable_viewport"); + }); + + it("allows verification only after authoritative readiness and current geometry exist", () => { + expect( + __private__.deriveVerificationBlockedReason({ + isAuthoritativeHistoryReady: false, + measurementState: createMeasurementState({ + containerKey: "scroll-view", + viewportHeight: 420, + contentHeight: 1200, + viewportMeasuredForKey: "scroll-view", + contentMeasuredForKey: "scroll-view", + }), + }) + ).toBe("waiting_for_history_readiness"); + + expect( + __private__.deriveVerificationBlockedReason({ + isAuthoritativeHistoryReady: true, + measurementState: createMeasurementState({ + containerKey: "scroll-view", + viewportHeight: 420, + contentHeight: 1200, + viewportMeasuredForKey: "scroll-view", + contentMeasuredForKey: "scroll-view", + }), + }) + ).toBeNull(); + }); + + it("suppresses auto-anchor helpers while detached", () => { + const mode: BottomAnchorMode = "detached"; + + expect( + __private__.shouldRestickOnContentChange({ + mode, + previousContentHeight: 1000, + contentHeight: 1100, + }) + ).toBe(false); + expect( + __private__.shouldRestickOnViewportChange({ + mode, + previousViewportWidth: 800, + viewportWidth: 640, + previousViewportHeight: 400, + viewportHeight: 360, + }) + ).toBe(false); + }); + + it("does not detach from sticky while a restick request is still pending", () => { + expect( + __private__.shouldDetachFromScrollAway({ + mode: "sticky-bottom", + nextIsNearBottom: false, + scrollDelta: 0, + hasPendingRequest: true, + hasPendingVerification: false, + hasUnverifiedStickyMeasurementChange: false, + }) + ).toBe(false); + + expect( + __private__.shouldDetachFromScrollAway({ + mode: "sticky-bottom", + nextIsNearBottom: false, + scrollDelta: 0, + hasPendingRequest: false, + hasPendingVerification: true, + hasUnverifiedStickyMeasurementChange: false, + }) + ).toBe(false); + + expect( + __private__.shouldDetachFromScrollAway({ + mode: "sticky-bottom", + nextIsNearBottom: false, + scrollDelta: 0, + hasPendingRequest: false, + hasPendingVerification: false, + hasUnverifiedStickyMeasurementChange: true, + }) + ).toBe(false); + + expect( + __private__.shouldDetachFromScrollAway({ + mode: "sticky-bottom", + nextIsNearBottom: false, + scrollDelta: 0, + hasPendingRequest: false, + hasPendingVerification: false, + hasUnverifiedStickyMeasurementChange: false, + }) + ).toBe(true); + }); + + it("treats a large scroll delta as user detach even during an unverified sticky change", () => { + expect( + __private__.shouldDetachFromScrollAway({ + mode: "sticky-bottom", + nextIsNearBottom: false, + scrollDelta: 48, + hasPendingRequest: false, + hasPendingVerification: false, + hasUnverifiedStickyMeasurementChange: true, + }) + ).toBe(true); + }); +}); diff --git a/packages/app/src/components/use-bottom-anchor-controller.ts b/packages/app/src/components/use-bottom-anchor-controller.ts new file mode 100644 index 000000000..ca5cfa9ac --- /dev/null +++ b/packages/app/src/components/use-bottom-anchor-controller.ts @@ -0,0 +1,817 @@ +import { useEffect, useRef, useState } from "react"; +import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strategy"; + +export type BottomAnchorMode = "sticky-bottom" | "detached"; + +export type BottomAnchorRouteRequest = { + reason: "initial-entry" | "resume"; + agentId: string; + requestKey: string; +}; + +export type BottomAnchorLocalRequest = { + reason: "jump-to-bottom" | "message-sent"; + agentId: string; +}; + +export type BottomAnchorBlockedReason = + | "waiting_for_history_readiness" + | "waiting_for_measurable_viewport" + | "waiting_for_measurable_content" + | "waiting_for_post_layout_verification"; + +type BottomAnchorRequestReason = + | BottomAnchorRouteRequest["reason"] + | BottomAnchorLocalRequest["reason"]; + +type BottomAnchorRequest = { + id: number; + agentId: string; + reason: BottomAnchorRequestReason; + requestKey: string; +}; + +type ControllerMeasurementState = { + containerKey: string; + viewportWidth: number; + viewportHeight: number; + contentHeight: number; + offsetY: number; + viewportMeasuredForKey: string | null; + contentMeasuredForKey: string | null; +}; + +type AttemptContext = { + requestId: number | null; + retries: number; +}; + +type ScheduledFrameHandle = { + cancelled: boolean; + rafId: number | null; + remainingFrames: number; + callback: () => void; +}; + +type BottomAnchorEvent = + | "request_created" + | "attempt_started" + | "attempt_verified" + | "attempt_failed" + | "request_fulfilled" + | "request_cancelled" + | "detached_by_user" + | "blocked_reason_changed"; + +type BottomAnchorControllerDriver = { + destroy: () => void; + getSnapshot: () => { + mode: BottomAnchorMode; + pendingRequest: BottomAnchorRequest | null; + pendingVerification: AttemptContext | null; + blockedReason: BottomAnchorBlockedReason | null; + }; + resetForAgent: () => void; + applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void; + requestLocalAnchor: (request: BottomAnchorLocalRequest) => void; + detachByUser: () => void; + handleViewportMetricsChange: (params: { + previousViewportWidth: number; + viewportWidth: number; + previousViewportHeight: number; + viewportHeight: number; + }) => void; + handleContentSizeChange: (params: { + previousContentHeight: number; + contentHeight: number; + }) => void; + prepareForStickyViewportChange: () => void; + prepareForStickyContentChange: () => void; + handleScrollNearBottomChange: (params: { + nextIsNearBottom: boolean; + scrollDelta: number; + }) => void; + notifyAuthoritativeHistoryMaybeChanged: () => void; + reevaluate: (animated?: boolean) => void; +}; + +type CreateBottomAnchorControllerDriverInput = { + getAgentId: () => string; + getIsAuthoritativeHistoryReady: () => boolean; + getRenderStrategy: () => string; + getTransportBehavior: () => BottomAnchorTransportBehavior; + getMeasurementState: () => ControllerMeasurementState; + isNearBottom: () => boolean; + scrollToBottom: (animated: boolean) => void; + onModeChange: (mode: BottomAnchorMode) => void; + log: (event: BottomAnchorEvent, details: Record) => void; + warn: (details: { agentId: string; reason: BottomAnchorRequestReason }) => void; + scheduleFrame: (params: { + kind: "attempt" | "verification"; + callback: () => void; + delayFrames?: number; + }) => unknown; + cancelFrame: (handle: unknown) => void; +}; + +const MAX_VERIFICATION_RETRIES = 3; +const USER_SCROLL_AWAY_DELTA_PX = 24; +const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); + +function logBottomAnchorEvent( + event: BottomAnchorEvent, + details: Record +): void { + if (!IS_DEV) { + return; + } + console.debug("[BottomAnchor]", event, details); +} + +function scheduleAnimationFrameWithDelay(input: { + callback: () => void; + delayFrames?: number; +}): ScheduledFrameHandle { + const handle: ScheduledFrameHandle = { + cancelled: false, + rafId: null, + remainingFrames: Math.max(0, input.delayFrames ?? 0), + callback: input.callback, + }; + + const tick = () => { + if (handle.cancelled) { + return; + } + if (handle.remainingFrames > 0) { + handle.remainingFrames -= 1; + handle.rafId = requestAnimationFrame(tick); + return; + } + handle.rafId = null; + input.callback(); + }; + + handle.rafId = requestAnimationFrame(tick); + return handle; +} + +function cancelScheduledAnimationFrame(handle: unknown): void { + const scheduled = handle as ScheduledFrameHandle | null; + if (!scheduled) { + return; + } + scheduled.cancelled = true; + if (scheduled.rafId !== null) { + cancelAnimationFrame(scheduled.rafId); + scheduled.rafId = null; + } +} + +function deriveVerificationBlockedReason(input: { + isAuthoritativeHistoryReady: boolean; + measurementState: ControllerMeasurementState; +}): Exclude | null { + if (!input.isAuthoritativeHistoryReady) { + return "waiting_for_history_readiness"; + } + if ( + input.measurementState.viewportHeight <= 0 || + input.measurementState.viewportMeasuredForKey !== input.measurementState.containerKey + ) { + return "waiting_for_measurable_viewport"; + } + if ( + input.measurementState.contentHeight <= 0 || + input.measurementState.contentMeasuredForKey !== input.measurementState.containerKey + ) { + return "waiting_for_measurable_content"; + } + return null; +} + +export function deriveBottomAnchorBlockedReason(input: { + pendingRequest: BottomAnchorRequest | null; + isAuthoritativeHistoryReady: boolean; + measurementState: ControllerMeasurementState; + pendingVerificationRequestId: number | null; +}): BottomAnchorBlockedReason | null { + if (!input.pendingRequest) { + return null; + } + if (!input.isAuthoritativeHistoryReady) { + return "waiting_for_history_readiness"; + } + if ( + input.measurementState.viewportHeight <= 0 || + input.measurementState.viewportMeasuredForKey !== input.measurementState.containerKey + ) { + return "waiting_for_measurable_viewport"; + } + if ( + input.measurementState.contentHeight <= 0 || + input.measurementState.contentMeasuredForKey !== input.measurementState.containerKey + ) { + return "waiting_for_measurable_content"; + } + if (input.pendingVerificationRequestId === input.pendingRequest.id) { + return "waiting_for_post_layout_verification"; + } + return null; +} + +function deriveRetryDisposition(input: { + mode: BottomAnchorMode; + retries: number; + verificationRetryMode: BottomAnchorTransportBehavior["verificationRetryMode"]; +}): "retry-scroll" | "retry-verify" | "fail" { + if (input.mode !== "sticky-bottom" || input.retries >= MAX_VERIFICATION_RETRIES) { + return "fail"; + } + return input.verificationRetryMode === "recheck" + ? "retry-verify" + : "retry-scroll"; +} + +function createBottomAnchorControllerDriver( + input: CreateBottomAnchorControllerDriverInput +): BottomAnchorControllerDriver { + let requestSequence = 0; + let mode: BottomAnchorMode = "sticky-bottom"; + let pendingRequest: BottomAnchorRequest | null = null; + let pendingVerification: AttemptContext | null = null; + let blockedReason: BottomAnchorBlockedReason | null = null; + let attemptHandle: unknown = null; + let verificationHandle: unknown = null; + let lastRouteRequestKey: string | null = null; + let stickyMeasurementRevision = 0; + let lastVerifiedStickyMeasurementRevision = 0; + + const getLogContext = (extra?: Record) => { + const measurementState = input.getMeasurementState(); + return { + agentId: input.getAgentId(), + requestReason: pendingRequest?.reason ?? null, + authoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(), + contentHeight: measurementState.contentHeight, + viewportHeight: measurementState.viewportHeight, + offset: measurementState.offsetY, + renderStrategy: input.getRenderStrategy(), + blockedReason, + mode, + containerKey: measurementState.containerKey, + ...extra, + }; + }; + + const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => { + if (blockedReason === nextBlockedReason) { + return; + } + blockedReason = nextBlockedReason; + input.log( + "blocked_reason_changed", + getLogContext({ nextBlockedReason }) + ); + }; + + const setModeInternal = (nextMode: BottomAnchorMode) => { + if (mode === nextMode) { + return; + } + mode = nextMode; + input.onModeChange(nextMode); + if (nextMode === "detached") { + lastVerifiedStickyMeasurementRevision = stickyMeasurementRevision; + } + }; + + const markStickyMeasurementChanged = () => { + stickyMeasurementRevision += 1; + }; + + const markStickyMeasurementVerified = () => { + lastVerifiedStickyMeasurementRevision = stickyMeasurementRevision; + }; + + const cancelPendingAttempt = () => { + if (attemptHandle) { + input.cancelFrame(attemptHandle); + attemptHandle = null; + } + if (verificationHandle) { + input.cancelFrame(verificationHandle); + verificationHandle = null; + } + pendingVerification = null; + }; + + const cancelPendingRequest = (reason: string) => { + const currentRequest = pendingRequest; + if (!currentRequest) { + cancelPendingAttempt(); + setBlockedReason(null); + return; + } + input.log( + "request_cancelled", + getLogContext({ + cancelledRequestReason: currentRequest.reason, + cancelReason: reason, + }) + ); + pendingRequest = null; + cancelPendingAttempt(); + setBlockedReason(null); + }; + + const scheduleVerification = (attemptContext: AttemptContext) => { + if (verificationHandle) { + input.cancelFrame(verificationHandle); + } + verificationHandle = input.scheduleFrame({ + kind: "verification", + delayFrames: input.getTransportBehavior().verificationDelayFrames, + callback: () => { + verificationHandle = null; + const currentRequest = pendingRequest; + const isRequestAttempt = + currentRequest && attemptContext.requestId === currentRequest.id; + const measurementState = input.getMeasurementState(); + const verificationBlockedReason = deriveVerificationBlockedReason({ + isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(), + measurementState, + }); + + if (verificationBlockedReason) { + pendingVerification = attemptContext; + setBlockedReason(verificationBlockedReason); + return; + } + + const verifiedNearBottom = input.isNearBottom(); + + input.log( + "attempt_verified", + getLogContext({ + verifiedNearBottom, + retries: attemptContext.retries, + }) + ); + + if (verifiedNearBottom) { + pendingVerification = null; + markStickyMeasurementVerified(); + if (isRequestAttempt) { + input.log("request_fulfilled", getLogContext()); + pendingRequest = null; + } + setBlockedReason(null); + return; + } + + const retryDisposition = deriveRetryDisposition({ + mode, + retries: attemptContext.retries, + verificationRetryMode: input.getTransportBehavior().verificationRetryMode, + }); + + if (retryDisposition === "retry-verify") { + pendingVerification = { + requestId: attemptContext.requestId, + retries: attemptContext.retries + 1, + }; + setBlockedReason("waiting_for_post_layout_verification"); + scheduleVerification(pendingVerification); + return; + } + + if (retryDisposition === "retry-scroll") { + pendingVerification = { + requestId: attemptContext.requestId, + retries: attemptContext.retries + 1, + }; + evaluate(false); + return; + } + + input.log( + "attempt_failed", + getLogContext({ retries: attemptContext.retries }) + ); + pendingVerification = null; + if (isRequestAttempt && currentRequest) { + input.warn({ + agentId: input.getAgentId(), + reason: currentRequest.reason, + }); + } + setBlockedReason( + isRequestAttempt ? "waiting_for_post_layout_verification" : null + ); + }, + }); + }; + + const runAttempt = (animated: boolean) => { + const attemptContext: AttemptContext = { + requestId: pendingRequest?.id ?? null, + retries: pendingVerification?.retries ?? 0, + }; + pendingVerification = attemptContext; + input.log( + "attempt_started", + getLogContext({ animated, retries: attemptContext.retries }) + ); + input.scrollToBottom(animated); + scheduleVerification(attemptContext); + setBlockedReason( + deriveBottomAnchorBlockedReason({ + pendingRequest, + isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(), + measurementState: input.getMeasurementState(), + pendingVerificationRequestId: attemptContext.requestId, + }) + ); + }; + + const evaluate = (animated: boolean) => { + if (attemptHandle) { + return; + } + attemptHandle = input.scheduleFrame({ + kind: "attempt", + callback: () => { + attemptHandle = null; + const nextBlockedReason = deriveBottomAnchorBlockedReason({ + pendingRequest, + isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(), + measurementState: input.getMeasurementState(), + pendingVerificationRequestId: pendingVerification?.requestId ?? null, + }); + setBlockedReason(nextBlockedReason); + + const shouldAttemptForPendingRequest = + pendingRequest !== null && nextBlockedReason === null; + const shouldAttemptForStickyVerification = + mode === "sticky-bottom" && + pendingVerification !== null && + nextBlockedReason === null; + + if ( + !shouldAttemptForPendingRequest && + !shouldAttemptForStickyVerification + ) { + return; + } + + runAttempt(animated); + }, + }); + }; + + const createRequest = (request: BottomAnchorRouteRequest | BottomAnchorLocalRequest) => { + const existing = pendingRequest; + if (existing) { + input.log( + "request_cancelled", + getLogContext({ + cancelledRequestReason: existing.reason, + cancelReason: "replaced_by_new_request", + }) + ); + } + + cancelPendingAttempt(); + const nextRequest: BottomAnchorRequest = { + id: requestSequence + 1, + agentId: request.agentId, + reason: request.reason, + requestKey: + "requestKey" in request + ? request.requestKey + : `${request.agentId}:${request.reason}:${requestSequence + 1}`, + }; + requestSequence = nextRequest.id; + pendingRequest = nextRequest; + pendingVerification = null; + setModeInternal( + "requestKey" in request + ? "sticky-bottom" + : __private__.deriveModeForLocalRequest({ reason: request.reason }) + ); + input.log( + "request_created", + getLogContext({ requestReason: request.reason }) + ); + evaluate(request.reason === "jump-to-bottom"); + }; + + return { + destroy() { + cancelPendingAttempt(); + }, + getSnapshot() { + return { + mode, + pendingRequest, + pendingVerification, + blockedReason, + }; + }, + resetForAgent() { + lastRouteRequestKey = null; + pendingRequest = null; + blockedReason = null; + cancelPendingAttempt(); + stickyMeasurementRevision = 0; + lastVerifiedStickyMeasurementRevision = 0; + mode = "sticky-bottom"; + input.onModeChange("sticky-bottom"); + }, + applyRouteRequest(request) { + if (!request) { + return; + } + if (lastRouteRequestKey === request.requestKey) { + return; + } + lastRouteRequestKey = request.requestKey; + createRequest(request); + }, + requestLocalAnchor(request) { + createRequest(request); + }, + detachByUser() { + if (mode === "detached") { + return; + } + cancelPendingRequest("user_scrolled_away"); + setModeInternal("detached"); + input.log("detached_by_user", getLogContext()); + }, + handleViewportMetricsChange(params) { + if ( + params.previousViewportWidth !== params.viewportWidth || + params.previousViewportHeight !== params.viewportHeight + ) { + markStickyMeasurementChanged(); + } + const shouldRestick = __private__.shouldRestickOnViewportChange({ + mode, + previousViewportWidth: params.previousViewportWidth, + viewportWidth: params.viewportWidth, + previousViewportHeight: params.previousViewportHeight, + viewportHeight: params.viewportHeight, + }); + if (shouldRestick && !pendingRequest) { + pendingVerification = { requestId: null, retries: 0 }; + } + if (shouldRestick || pendingRequest) { + evaluate(false); + } + }, + handleContentSizeChange(params) { + if (params.previousContentHeight !== params.contentHeight) { + markStickyMeasurementChanged(); + } + const shouldRestick = __private__.shouldRestickOnContentChange({ + mode, + previousContentHeight: params.previousContentHeight, + contentHeight: params.contentHeight, + }); + if (shouldRestick && !pendingRequest) { + pendingVerification = { requestId: null, retries: 0 }; + } + if (shouldRestick || pendingRequest) { + evaluate(false); + } + }, + prepareForStickyViewportChange() { + if (mode !== "sticky-bottom") { + return; + } + markStickyMeasurementChanged(); + }, + prepareForStickyContentChange() { + if (mode !== "sticky-bottom") { + return; + } + markStickyMeasurementChanged(); + }, + handleScrollNearBottomChange(params) { + const { nextIsNearBottom, scrollDelta } = params; + if ( + nextIsNearBottom && + mode === "sticky-bottom" && + stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision + ) { + markStickyMeasurementVerified(); + } + const hasUnverifiedStickyMeasurementChange = + stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision; + if ( + __private__.shouldDetachFromScrollAway({ + mode, + nextIsNearBottom, + scrollDelta, + hasPendingRequest: pendingRequest !== null, + hasPendingVerification: pendingVerification !== null, + hasUnverifiedStickyMeasurementChange, + }) + ) { + this.detachByUser(); + return; + } + if ( + mode === "sticky-bottom" && + !nextIsNearBottom && + hasUnverifiedStickyMeasurementChange + ) { + if (!pendingRequest && !pendingVerification) { + pendingVerification = { requestId: null, retries: 0 }; + } + evaluate(false); + return; + } + if (nextIsNearBottom && pendingRequest) { + evaluate(false); + } + }, + notifyAuthoritativeHistoryMaybeChanged() { + if (!pendingVerification && !pendingRequest) { + return; + } + evaluate(false); + }, + reevaluate(animated = false) { + evaluate(animated); + }, + }; +} + +export const __private__ = { + createBottomAnchorControllerDriver, + deriveBottomAnchorBlockedReason, + deriveVerificationBlockedReason, + deriveRetryDisposition, + deriveModeForLocalRequest(input: { + reason: BottomAnchorLocalRequest["reason"]; + }): BottomAnchorMode { + return "sticky-bottom"; + }, + shouldRestickOnViewportChange(input: { + mode: BottomAnchorMode; + previousViewportWidth: number; + viewportWidth: number; + previousViewportHeight: number; + viewportHeight: number; + }): boolean { + return ( + input.mode === "sticky-bottom" && + ((input.previousViewportHeight > 0 && + input.viewportHeight > 0 && + input.previousViewportHeight !== input.viewportHeight) || + (input.previousViewportWidth > 0 && + input.viewportWidth > 0 && + input.previousViewportWidth !== input.viewportWidth)) + ); + }, + shouldRestickOnContentChange(input: { + mode: BottomAnchorMode; + previousContentHeight: number; + contentHeight: number; + }): boolean { + return ( + input.mode === "sticky-bottom" && + input.previousContentHeight > 0 && + input.contentHeight > input.previousContentHeight + ); + }, + shouldDetachFromScrollAway(input: { + mode: BottomAnchorMode; + nextIsNearBottom: boolean; + scrollDelta: number; + hasPendingRequest: boolean; + hasPendingVerification: boolean; + hasUnverifiedStickyMeasurementChange: boolean; + }): boolean { + const scrolledAwayIntentionally = + Math.abs(input.scrollDelta) >= USER_SCROLL_AWAY_DELTA_PX; + return ( + input.mode === "sticky-bottom" && + !input.nextIsNearBottom && + !input.hasPendingRequest && + !input.hasPendingVerification && + (!input.hasUnverifiedStickyMeasurementChange || scrolledAwayIntentionally) + ); + }, +}; + +export function useBottomAnchorController(input: { + agentId: string; + routeRequest: BottomAnchorRouteRequest | null; + isAuthoritativeHistoryReady: boolean; + renderStrategy: string; + transportBehavior: BottomAnchorTransportBehavior; + getMeasurementState: () => ControllerMeasurementState; + isNearBottom: () => boolean; + scrollToBottom: (animated: boolean) => void; +}) { + const [mode, setMode] = useState("sticky-bottom"); + const agentIdRef = useRef(input.agentId); + const readinessRef = useRef(input.isAuthoritativeHistoryReady); + const renderStrategyRef = useRef(input.renderStrategy); + const transportBehaviorRef = useRef(input.transportBehavior); + const getMeasurementStateRef = useRef(input.getMeasurementState); + const isNearBottomRef = useRef(input.isNearBottom); + const scrollToBottomRef = useRef(input.scrollToBottom); + const driverRef = useRef(null); + + agentIdRef.current = input.agentId; + readinessRef.current = input.isAuthoritativeHistoryReady; + renderStrategyRef.current = input.renderStrategy; + transportBehaviorRef.current = input.transportBehavior; + getMeasurementStateRef.current = input.getMeasurementState; + isNearBottomRef.current = input.isNearBottom; + scrollToBottomRef.current = input.scrollToBottom; + + if (!driverRef.current) { + driverRef.current = __private__.createBottomAnchorControllerDriver({ + getAgentId: () => agentIdRef.current, + getIsAuthoritativeHistoryReady: () => readinessRef.current, + getRenderStrategy: () => renderStrategyRef.current, + getTransportBehavior: () => transportBehaviorRef.current, + getMeasurementState: () => getMeasurementStateRef.current(), + isNearBottom: () => isNearBottomRef.current(), + scrollToBottom: (animated) => scrollToBottomRef.current(animated), + onModeChange: (nextMode) => setMode(nextMode), + log: (event, details) => logBottomAnchorEvent(event, details), + warn: (details) => { + console.warn("[BottomAnchor] request could not be fulfilled", details); + }, + scheduleFrame: ({ callback, delayFrames }) => + scheduleAnimationFrameWithDelay({ callback, delayFrames }), + cancelFrame: (handle) => cancelScheduledAnimationFrame(handle), + }); + } + + useEffect(() => { + driverRef.current?.resetForAgent(); + }, [input.agentId]); + + useEffect(() => { + driverRef.current?.applyRouteRequest(input.routeRequest); + }, [input.routeRequest]); + + useEffect(() => { + driverRef.current?.notifyAuthoritativeHistoryMaybeChanged(); + }, [input.isAuthoritativeHistoryReady]); + + useEffect(() => { + return () => { + driverRef.current?.destroy(); + driverRef.current = null; + }; + }, []); + + return { + mode, + requestLocalAnchor(request: BottomAnchorLocalRequest) { + driverRef.current?.requestLocalAnchor(request); + }, + detachByUser() { + driverRef.current?.detachByUser(); + }, + handleViewportLayout() {}, + handleViewportMetricsChange(params: { + previousViewportWidth: number; + viewportWidth: number; + previousViewportHeight: number; + viewportHeight: number; + }) { + driverRef.current?.handleViewportMetricsChange(params); + }, + handleContentSizeChange(params: { + previousContentHeight: number; + contentHeight: number; + }) { + driverRef.current?.handleContentSizeChange(params); + }, + prepareForStickyViewportChange() { + driverRef.current?.prepareForStickyViewportChange(); + }, + prepareForStickyContentChange() { + driverRef.current?.prepareForStickyContentChange(); + }, + handleScrollNearBottomChange(params: { + nextIsNearBottom: boolean; + scrollDelta: number; + }) { + driverRef.current?.handleScrollNearBottomChange(params); + }, + reevaluate(animated = false) { + driverRef.current?.reevaluate(animated); + }, + }; +} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 8a5ac7248..441448d73 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -240,6 +240,9 @@ function SessionProviderInternal({ const markAgentHistorySynchronized = useSessionStore( (state) => state.markAgentHistorySynchronized ); + const setAgentAuthoritativeHistoryApplied = useSessionStore( + (state) => state.setAgentAuthoritativeHistoryApplied + ); const setHasHydratedAgents = useSessionStore( (state) => state.setHasHydratedAgents ); @@ -719,6 +722,7 @@ function SessionProviderInternal({ next.delete(agentId); return next; }); + setAgentAuthoritativeHistoryApplied(serverId, agentId, false); return; } @@ -771,6 +775,8 @@ function SessionProviderInternal({ ) => { const agentId = payload.agentId; const initKey = getInitKey(serverId, agentId); + const shouldMarkAuthoritativeHistoryApplied = + payload.direction === "tail" || payload.direction === "after"; // Read current store state const session = useSessionStore.getState().sessions[serverId]; @@ -890,6 +896,9 @@ function SessionProviderInternal({ }); } + if (shouldMarkAuthoritativeHistoryApplied) { + setAgentAuthoritativeHistoryApplied(serverId, agentId, true); + } if (result.initResolution === "resolve") { resolveInitDeferred(initKey); } @@ -904,6 +913,7 @@ function SessionProviderInternal({ markAgentHistorySynchronized, requestCanonicalCatchUp, serverId, + setAgentAuthoritativeHistoryApplied, setAgentStreamTail, setAgentTimelineCursor, setInitializingAgents, diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index bf4ff38e6..443f920f9 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -64,4 +64,10 @@ describe("useAgentInitialization timeline request policy", () => { projection: "canonical", }); }); + + it("does not expose an RPC-success init fallback", () => { + expect( + "shouldResolveInitFromRpcSuccess" in __private__ + ).toBe(false); + }); }); diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index c0f07a9dd..e4a36292a 100644 --- a/packages/app/src/hooks/use-agent-initialization.ts +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -62,7 +62,7 @@ export function useAgentInitialization({ const cursor = session?.agentTimelineCursor.get(agentId); const initialTimelineLimit = resolveInitialTimelineLimit(); const hasAuthoritativeHistory = - (session?.agentHistorySyncGeneration.get(agentId) ?? -1) >= 0; + session?.agentAuthoritativeHistoryApplied.get(agentId) === true; const timelineRequest = deriveInitialTimelineRequest({ cursor: cursor ? { epoch: cursor.epoch, seq: cursor.endSeq } @@ -95,10 +95,6 @@ export function useAgentInitialization({ client .fetchAgentTimeline(agentId, timelineRequest) - .then(() => { - // No-op: hydration completion is handled by SessionContext - // when it processes fetch_agent_timeline_response. - }) .catch((error) => { setAgentInitializing(agentId, false); rejectInitDeferred( diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index 2de63aa10..99c688837 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -342,6 +342,46 @@ describe("deriveAgentScreenViewState", () => { expect(ready.sync.status).toBe("idle"); }); + it("keeps first route entry blocked until authoritative history is applied", () => { + const memory = createBaseMemory(); + const input: AgentScreenMachineInput = { + ...createBaseInput(), + agent: createAgent("agent-1"), + needsAuthoritativeSync: true, + isHistorySyncing: true, + hasHydratedHistoryBefore: false, + }; + + const result = deriveAgentScreenViewState({ input, memory }); + + expect(result.state).toEqual({ + tag: "boot", + reason: "loading", + source: "none", + }); + expect(result.memory.hasRenderedReady).toBe(false); + expect(result.memory.lastReadyAgent).toBeNull(); + }); + + it("still allows optimistic create flow to render before authoritative history arrives", () => { + const memory = createBaseMemory(); + const input: AgentScreenMachineInput = { + ...createBaseInput(), + agent: createAgentWithStatus({ id: "agent-1", status: "idle" }), + placeholderAgent: createAgent("agent-1"), + shouldUseOptimisticStream: true, + needsAuthoritativeSync: true, + isHistorySyncing: true, + hasHydratedHistoryBefore: false, + }; + + const result = deriveAgentScreenViewState({ input, memory }); + const ready = expectReadyState(result.state); + + expect(ready.source).toBe("optimistic"); + expect(ready.agent.status).toBe("running"); + }); + it("keeps optimistic flow non-blocking while transitioning to authoritative stream", () => { const initialMemory = createBaseMemory(); const optimisticInput: AgentScreenMachineInput = { diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index fac82e819..fcb318a4d 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -19,6 +19,16 @@ export interface AgentScreenMachineInput { hasHydratedHistoryBefore: boolean; } +function shouldBlockInitialAuthoritativeReadyState( + input: AgentScreenMachineInput +): boolean { + return ( + !input.shouldUseOptimisticStream && + !input.hasHydratedHistoryBefore && + (input.needsAuthoritativeSync || input.isHistorySyncing) + ); +} + export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error"; export interface AgentScreenMachineMemory { @@ -104,10 +114,7 @@ export function deriveAgentScreenViewState({ input.agent && useOptimisticCreateFlowAgent && input.placeholderAgent ? { ...input.agent, status: input.placeholderAgent.status } : input.agent ?? input.placeholderAgent; - if (candidateAgent) { - nextMemory.hasRenderedReady = true; - nextMemory.lastReadyAgent = candidateAgent; - } + const shouldBlockReadyState = shouldBlockInitialAuthoritativeReadyState(input); if (input.missingAgentState.kind === "not_found") { return { @@ -129,6 +136,22 @@ export function deriveAgentScreenViewState({ }; } + if (candidateAgent && shouldBlockReadyState) { + return { + state: { + tag: "boot", + reason: "loading", + source: "none", + }, + memory: nextMemory, + }; + } + + if (candidateAgent) { + nextMemory.hasRenderedReady = true; + nextMemory.lastReadyAgent = candidateAgent; + } + const displayAgent = candidateAgent ?? (nextMemory.hasRenderedReady ? nextMemory.lastReadyAgent : null); if (!displayAgent) { diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 4daf96b8c..c7286c8b3 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -978,6 +978,68 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().clearSession(host.serverId); }); + it("re-subscribes agent directory updates after reconnect", async () => { + const host = makeHost({ + serverId: "srv_resubscribe", + connections: [ + { + id: "direct:lan:6767", + type: "direct", + endpoint: "lan:6767", + }, + ], + }); + const fakeClient = new FakeDaemonClient(); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + measureLatency: async () => 5, + getClientId: async () => "cid_test_runtime", + }, + }); + + useSessionStore.getState().initializeSession( + host.serverId, + fakeClient as unknown as DaemonClient, + null as any + ); + store.syncHosts([host]); + + const initialTimeoutAt = Date.now() + 200; + while (fakeClient.fetchAgentsCalls.length < 1 && Date.now() < initialTimeoutAt) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + fakeClient.setConnectionState({ + status: "disconnected", + reason: "client_closed", + }); + fakeClient.setConnectionState({ status: "connected" }); + + const reconnectTimeoutAt = Date.now() + 200; + while (fakeClient.fetchAgentsCalls.length < 2 && Date.now() < reconnectTimeoutAt) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(fakeClient.fetchAgentsCalls).toEqual([ + { + filter: { includeArchived: true }, + sort: [{ key: "updated_at", direction: "desc" }], + subscribe: { subscriptionId: "app:srv_resubscribe" }, + page: { limit: 200 }, + }, + { + filter: { includeArchived: true }, + sort: [{ key: "updated_at", direction: "desc" }], + subscribe: { subscriptionId: "app:srv_resubscribe" }, + page: { limit: 200 }, + }, + ]); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + it("surfaces startup failures as error instead of leaving host idle", async () => { const host = makeHost({ connections: [ diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 403c7911f..055b619a5 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1043,16 +1043,19 @@ export class HostRuntimeStore { const snapshot = controller.getSnapshot(); const previousStatus = this.lastConnectionStatusByServer.get(serverId); this.lastConnectionStatusByServer.set(serverId, snapshot.connectionStatus); - if (snapshot.connectionStatus === "online" && previousStatus !== "online") { + const didTransitionOnline = + snapshot.connectionStatus === "online" && previousStatus !== "online"; + if (didTransitionOnline) { useSessionStore.getState().bumpHistorySyncGeneration(serverId); } // Runtime owns directory bootstrap policy, including reconnect and delayed // session initialization races. - if ( - snapshot.connectionStatus !== "online" || - snapshot.hasEverLoadedAgentDirectory - ) { + if (snapshot.connectionStatus !== "online") { + this.clearAgentDirectorySessionRetry(serverId); + return; + } + if (!didTransitionOnline && snapshot.hasEverLoadedAgentDirectory) { this.clearAgentDirectorySessionRetry(serverId); return; } diff --git a/packages/app/src/screens/agent/agent-ready-screen-bottom-anchor.ts b/packages/app/src/screens/agent/agent-ready-screen-bottom-anchor.ts new file mode 100644 index 000000000..393326158 --- /dev/null +++ b/packages/app/src/screens/agent/agent-ready-screen-bottom-anchor.ts @@ -0,0 +1,37 @@ +import type { BottomAnchorRouteRequest } from "@/components/use-bottom-anchor-controller"; + +export type RouteBottomAnchorIntent = { + routeKey: string; + reason: BottomAnchorRouteRequest["reason"]; +}; + +export function deriveRouteBottomAnchorIntent(input: { + cachedIntent: RouteBottomAnchorIntent | null; + routeKey: string | null; + hasAppliedAuthoritativeHistoryAtEntry: boolean; +}): RouteBottomAnchorIntent | null { + if (!input.routeKey) { + return null; + } + if (input.cachedIntent?.routeKey === input.routeKey) { + return input.cachedIntent; + } + return { + routeKey: input.routeKey, + reason: input.hasAppliedAuthoritativeHistoryAtEntry ? "resume" : "initial-entry", + }; +} + +export function deriveRouteBottomAnchorRequest(input: { + intent: RouteBottomAnchorIntent | null; + effectiveAgentId: string | null; +}): BottomAnchorRouteRequest | null { + if (!input.intent || !input.effectiveAgentId) { + return null; + } + return { + reason: input.intent.reason, + agentId: input.effectiveAgentId, + requestKey: `${input.intent.routeKey}:${input.intent.reason}`, + }; +} diff --git a/packages/app/src/screens/agent/agent-ready-screen.bottom-anchor.test.ts b/packages/app/src/screens/agent/agent-ready-screen.bottom-anchor.test.ts new file mode 100644 index 000000000..a59135cbd --- /dev/null +++ b/packages/app/src/screens/agent/agent-ready-screen.bottom-anchor.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + deriveRouteBottomAnchorIntent, + deriveRouteBottomAnchorRequest, +} from "./agent-ready-screen-bottom-anchor"; + +describe("agent-ready-screen bottom anchor intent", () => { + it("latches initial-entry on first route entry before authoritative history is applied", () => { + const intentAtEntry = deriveRouteBottomAnchorIntent({ + cachedIntent: null, + routeKey: "server-1:agent-1", + hasAppliedAuthoritativeHistoryAtEntry: false, + }); + + const intentAfterHistoryApplies = deriveRouteBottomAnchorIntent({ + cachedIntent: intentAtEntry, + routeKey: "server-1:agent-1", + hasAppliedAuthoritativeHistoryAtEntry: true, + }); + + expect(intentAfterHistoryApplies).toEqual({ + routeKey: "server-1:agent-1", + reason: "initial-entry", + }); + expect( + deriveRouteBottomAnchorRequest({ + intent: intentAfterHistoryApplies, + effectiveAgentId: "agent-1", + }) + ).toEqual({ + agentId: "agent-1", + reason: "initial-entry", + requestKey: "server-1:agent-1:initial-entry", + }); + }); + + it("creates resume requests when revisiting an already-hydrated route", () => { + const intent = deriveRouteBottomAnchorIntent({ + cachedIntent: null, + routeKey: "server-1:agent-2", + hasAppliedAuthoritativeHistoryAtEntry: true, + }); + + expect( + deriveRouteBottomAnchorRequest({ + intent, + effectiveAgentId: "agent-2", + }) + ).toEqual({ + agentId: "agent-2", + reason: "resume", + requestKey: "server-1:agent-2:resume", + }); + }); + + it("does not create a request until the effective agent exists", () => { + const intent = deriveRouteBottomAnchorIntent({ + cachedIntent: null, + routeKey: "server-1:agent-3", + hasAppliedAuthoritativeHistoryAtEntry: false, + }); + + expect( + deriveRouteBottomAnchorRequest({ + intent, + effectiveAgentId: null, + }) + ).toBeNull(); + }); +}); diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 1a18951c0..ac510dfaa 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -59,6 +59,10 @@ import { shouldClearAgentAttention } from "@/utils/agent-attention"; import type { DaemonClient } from "@server/client/daemon-client"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; import type { ExplorerCheckoutContext } from "@/stores/panel-store"; +import { + deriveRouteBottomAnchorIntent, + deriveRouteBottomAnchorRequest, +} from "./agent-ready-screen-bottom-anchor"; const EMPTY_STREAM_ITEMS: StreamItem[] = []; const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); @@ -390,12 +394,19 @@ function AgentScreenContent({ const historySyncGeneration = useSessionStore( (state) => state.sessions[serverId]?.historySyncGeneration ?? 0 ); + const hasAppliedAuthoritativeHistory = useSessionStore((state) => + resolvedAgentId + ? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get( + resolvedAgentId + ) === true + : false + ); const agentHistorySyncGeneration = useSessionStore((state) => resolvedAgentId ? state.sessions[serverId]?.agentHistorySyncGeneration?.get(resolvedAgentId) ?? -1 : -1 ); - const hasHydratedHistoryBefore = agentHistorySyncGeneration >= 0; + const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory; // Select raw pending permissions - filter with useMemo to avoid new Map on every render const allPendingPermissions = useSessionStore( @@ -432,6 +443,10 @@ function AgentScreenContent({ }); const reconnectToastArmedRef = useRef(false); const initAttemptTokenRef = useRef(0); + const routeBottomAnchorRequestRef = useRef<{ + routeKey: string; + reason: "initial-entry" | "resume"; + } | null>(null); const attentionClientRef = useRef(client); const attentionConnectedRef = useRef(isConnected); const setFocusedAgentId = useCallback( @@ -658,6 +673,20 @@ function AgentScreenContent({ }); const effectiveAgent = viewState.tag === "ready" ? viewState.agent : null; + const routeEntryKey = resolvedAgentId ? `${serverId}:${resolvedAgentId}` : null; + routeBottomAnchorRequestRef.current = deriveRouteBottomAnchorIntent({ + cachedIntent: routeBottomAnchorRequestRef.current, + routeKey: routeEntryKey, + hasAppliedAuthoritativeHistoryAtEntry: hasAppliedAuthoritativeHistory, + }); + const routeBottomAnchorRequest = useMemo( + () => + deriveRouteBottomAnchorRequest({ + intent: routeBottomAnchorRequestRef.current, + effectiveAgentId: effectiveAgent?.id ?? null, + }), + [effectiveAgent?.id] + ); useEffect(() => { if (!isPendingCreateForRoute || !pendingCreate) { return; @@ -914,6 +943,8 @@ function AgentScreenContent({ shouldUseOptimisticStream ? mergedStreamItems : streamItems } pendingPermissions={pendingPermissions} + routeBottomAnchorRequest={routeBottomAnchorRequest} + isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory} /> @@ -926,7 +957,12 @@ function AgentScreenContent({ autoFocus isSubmitLoading={showPendingCreateSubmitLoading} onAddImages={handleAddImagesCallback} - onMessageSent={() => streamViewRef.current?.scrollToBottom()} + onComposerHeightChange={() => + streamViewRef.current?.prepareForViewportChange() + } + onMessageSent={() => + streamViewRef.current?.scrollToBottom("message-sent") + } /> )} diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 23def4f96..36d270ac3 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -166,6 +166,10 @@ export function WorkspaceDesktopTabsRow({ const labelCharCap = layoutItem?.labelCharCap ?? tab.label.length; const renderedLabel = showLabel ? tab.label.slice(0, Math.max(1, labelCharCap)) : ""; const presentation = deriveWorkspaceTabPresentation({ tab, agent: tabAgent }); + const tooltipLabel = + tab.kind === "agent" && tab.titleState === "loading" + ? "Loading agent title" + : presentation.label; const contextMenuTestId = `workspace-tab-context-${tab.key}`; const isFirstTab = index === 0; @@ -174,107 +178,114 @@ export function WorkspaceDesktopTabsRow({ return ( - [ - styles.tab, - { - minWidth: resolvedTabWidth, - width: resolvedTabWidth, - maxWidth: resolvedTabWidth, - }, - isActive && styles.tabActive, - (hovered || pressed || isCloseHovered) && styles.tabHovered, - ]} - onHoverIn={() => { - setHoveredTabKey(tab.key); - }} - onHoverOut={() => { - setHoveredTabKey((current) => (current === tab.key ? null : current)); - }} - onPressIn={() => { - onNavigateTab(tab.tabId); - }} - onPress={() => { - onNavigateTab(tab.tabId); - }} - accessibilityLabel={ - tab.kind === "agent" && tab.titleState === "loading" - ? "Loading agent title" - : tab.label - } - > - + - - - - {showLabel ? ( - presentation.titleState === "loading" ? ( - - ) : ( - - {renderedLabel} - - ) - ) : null} - - - {shouldShowCloseButton ? ( - [ + styles.tab, + { + minWidth: resolvedTabWidth, + width: resolvedTabWidth, + maxWidth: resolvedTabWidth, + }, + isActive && styles.tabActive, + (hovered || pressed || isCloseHovered) && styles.tabHovered, + ]} onHoverIn={() => { setHoveredTabKey(tab.key); - setHoveredCloseTabKey(tab.key); }} onHoverOut={() => { setHoveredTabKey((current) => (current === tab.key ? null : current)); - setHoveredCloseTabKey((current) => (current === tab.key ? null : current)); }} - onPress={(event) => { - event.stopPropagation?.(); - void onCloseTab(tab.tabId); + onPressIn={() => { + onNavigateTab(tab.tabId); }} - style={({ hovered, pressed }) => [ - styles.tabCloseButton, - styles.tabCloseButtonShown, - (hovered || pressed) && styles.tabCloseButtonActive, - ]} + onPress={() => { + onNavigateTab(tab.tabId); + }} + accessibilityLabel={tooltipLabel} > - {isClosingTab ? ( - - ) : ( - - )} - - ) : null} - + + + + + {showLabel ? ( + presentation.titleState === "loading" ? ( + + ) : ( + + {renderedLabel} + + ) + ) : null} + + + {shouldShowCloseButton ? ( + { + setHoveredTabKey(tab.key); + setHoveredCloseTabKey(tab.key); + }} + onHoverOut={() => { + setHoveredTabKey((current) => (current === tab.key ? null : current)); + setHoveredCloseTabKey((current) => (current === tab.key ? null : current)); + }} + onPress={(event) => { + event.stopPropagation?.(); + void onCloseTab(tab.tabId); + }} + style={({ hovered, pressed }) => [ + styles.tabCloseButton, + styles.tabCloseButtonShown, + (hovered || pressed) && styles.tabCloseButtonActive, + ]} + > + {isClosingTab ? ( + + ) : ( + + )} + + ) : null} + + + + {tooltipLabel} + + {tab.kind === "agent" ? ( @@ -426,6 +437,9 @@ const styles = StyleSheet.create((theme) => ({ paddingRight: theme.spacing[2], paddingVertical: theme.spacing[1], }, + tabTooltipTrigger: { + flexShrink: 0, + }, tab: { paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 71b462896..b43066724 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -12,8 +12,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as Clipboard from "expo-clipboard"; import { ChevronDown, + Copy, Folder, GitBranch, + MoreVertical, PanelRight, Plus, SquareTerminal, @@ -24,6 +26,12 @@ import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; import { ScreenHeader } from "@/components/headers/screen-header"; import { Combobox } from "@/components/ui/combobox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, @@ -1006,6 +1014,20 @@ function WorkspaceScreenContent({ [sessionAgents, toast] ); + const handleCopyWorkspacePath = useCallback(async () => { + if (!normalizedWorkspaceId.startsWith("/")) { + toast.error("Workspace path not available"); + return; + } + + try { + await Clipboard.setStringAsync(normalizedWorkspaceId); + toast.copied("Workspace path"); + } catch { + toast.error("Copy failed"); + } + }, [normalizedWorkspaceId, toast]); + const handleBulkCloseTabs = useCallback( async (input: { tabsToClose: WorkspaceTabDescriptor[]; title: string; logLabel: string }) => { const { tabsToClose, title, logLabel } = input; @@ -1252,6 +1274,7 @@ function WorkspaceScreenContent({ if (target.kind === "agent") { return ( + + + + + + + } + disabled={!normalizedWorkspaceId.startsWith("/")} + onSelect={handleCopyWorkspacePath} + > + Copy workspace path + + + ({ headerRight: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[2], + gap: theme.spacing[1], }, - menuButton: { - padding: theme.spacing[3], + headerActionButton: { + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, }, newTabActions: { diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index c36622483..c07ab1dab 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -230,6 +230,7 @@ export interface SessionState { agentTimelineCursor: Map; historySyncGeneration: number; agentHistorySyncGeneration: Map; + agentAuthoritativeHistoryApplied: Map; // Initializing agents (used for UI loading state) initializingAgents: Map; @@ -295,6 +296,11 @@ interface SessionStoreActions { ) => void; bumpHistorySyncGeneration: (serverId: string) => void; markAgentHistorySynchronized: (serverId: string, agentId: string) => void; + setAgentAuthoritativeHistoryApplied: ( + serverId: string, + agentId: string, + applied: boolean + ) => void; // Initializing agents setInitializingAgents: (serverId: string, state: Map | ((prev: Map) => Map)) => void; @@ -391,6 +397,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio agentTimelineCursor: new Map(), historySyncGeneration: 0, agentHistorySyncGeneration: new Map(), + agentAuthoritativeHistoryApplied: new Map(), initializingAgents: new Map(), agents: new Map(), workspaces: new Map(), @@ -855,6 +862,44 @@ export const useSessionStore = create()( }); }, + setAgentAuthoritativeHistoryApplied: (serverId, agentId, applied) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) { + return prev; + } + + const previousApplied = + session.agentAuthoritativeHistoryApplied.get(agentId) ?? false; + if (previousApplied === applied) { + return prev; + } + + const nextApplied = new Map(session.agentAuthoritativeHistoryApplied); + if (applied) { + nextApplied.set(agentId, true); + } else { + nextApplied.delete(agentId); + } + + logSessionStoreUpdate("setAgentAuthoritativeHistoryApplied", serverId, { + agentId, + applied, + }); + + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { + ...session, + agentAuthoritativeHistoryApplied: nextApplied, + }, + }, + }; + }); + }, + // Initializing agents setInitializingAgents: (serverId, state) => { set((prev) => { diff --git a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts index 3d2e550fe..bf56c5cde 100644 --- a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts @@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { ClaudeAgentClient } from "./claude-agent.js"; -import type { AgentStreamEvent } from "../agent-sdk-types.js"; +import type { + AgentPersistenceHandle, + AgentStreamEvent, +} from "../agent-sdk-types.js"; type Deferred = { promise: Promise; @@ -297,7 +300,7 @@ describe("ClaudeAgentSession interrupt restart regression", () => { expect(sdkMocks.firstQuery).toBeTruthy(); expect(sdkMocks.secondQuery).toBeTruthy(); expect(sdkMocks.firstQuery).not.toBe(sdkMocks.secondQuery); - expect(sdkMocks.firstQuery?.interrupt).toHaveBeenCalledTimes(2); + expect(sdkMocks.firstQuery?.interrupt).toHaveBeenCalledTimes(1); expect(sdkMocks.secondQuery?.next).toHaveBeenCalled(); expect(secondAssistantText).toContain("NEW_TURN_RESPONSE"); expect(secondAssistantText).not.toContain("OLD_TURN_RESPONSE"); @@ -979,6 +982,146 @@ describe("ClaudeAgentSession interrupt restart regression", () => { await session.close(); }); + test("ignores unmatched resumed-session errors without starting an autonomous run", async () => { + const logger = createTestLogger(); + const keepQueryAlive = deferred(); + + sdkMocks.query.mockImplementation(() => { + let step = 0; + return { + next: vi.fn(async () => { + if (step === 0) { + step += 1; + return { + done: false, + value: { + type: "result", + subtype: "error_during_execution", + session_id: "new-session-after-missing-conversation", + errors: [ + "No conversation found with session ID: persisted-stale-session", + ], + num_turns: 0, + duration_ms: 0, + duration_api_ms: 0, + is_error: true, + stop_reason: null, + total_cost_usd: 0, + usage: buildUsage(), + }, + }; + } + await keepQueryAlive.promise; + return { done: true, value: undefined }; + }), + interrupt: vi.fn(async () => undefined), + return: vi.fn(async () => undefined), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), + supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), + } satisfies QueryMock; + }); + + const client = new ClaudeAgentClient({ logger }); + const handle: AgentPersistenceHandle = { + provider: "claude", + sessionId: "persisted-stale-session", + nativeHandle: "persisted-stale-session", + metadata: { + provider: "claude", + cwd: process.cwd(), + }, + }; + + const session = await client.resumeSession(handle, { cwd: process.cwd() }); + await new Promise((resolve) => setTimeout(resolve, 25)); + + const activeRuns = ( + session as unknown as { + runTracker: { listActiveRuns: (owner?: "foreground" | "autonomous") => unknown[] }; + } + ).runTracker.listActiveRuns(); + + expect(activeRuns).toHaveLength(0); + + keepQueryAlive.resolve(undefined); + await session.close(); + }); + + test("stops retrying live query pump when resumed Claude session no longer exists", async () => { + const logger = createTestLogger(); + let queryCreateCount = 0; + + sdkMocks.query.mockImplementation(() => { + queryCreateCount += 1; + let step = 0; + return { + next: vi.fn(async () => { + if (step === 0) { + step += 1; + return { + done: false, + value: { + type: "result", + subtype: "error_during_execution", + session_id: "new-session-after-missing-conversation", + errors: [ + "No conversation found with session ID: persisted-stale-session", + ], + num_turns: 0, + duration_ms: 0, + duration_api_ms: 0, + is_error: true, + stop_reason: null, + total_cost_usd: 0, + usage: buildUsage(), + }, + }; + } + return { done: true, value: undefined }; + }), + interrupt: vi.fn(async () => undefined), + return: vi.fn(async () => undefined), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), + supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), + } satisfies QueryMock; + }); + + const client = new ClaudeAgentClient({ logger }); + const handle: AgentPersistenceHandle = { + provider: "claude", + sessionId: "persisted-stale-session", + nativeHandle: "persisted-stale-session", + metadata: { + provider: "claude", + cwd: process.cwd(), + }, + }; + + const session = await client.resumeSession(handle, { cwd: process.cwd() }); + const liveIterator = ( + session as unknown as { + streamLiveEvents: () => AsyncGenerator; + } + ).streamLiveEvents(); + const liveNext = liveIterator.next(); + + await new Promise((resolve) => setTimeout(resolve, 25)); + + await new Promise((resolve) => setTimeout(resolve, 650)); + + expect(queryCreateCount).toBe(1); + expect(session.describePersistence()).toBeNull(); + + await session.close(); + await liveNext; + }); + test("does not emit live autonomous turn events for local_agent task_started during a foreground run", async () => { const logger = createTestLogger(); const keepQueryAlive = deferred(); @@ -1150,6 +1293,216 @@ describe("ClaudeAgentSession interrupt restart regression", () => { await session.close(); }); + test("does not let task_notification reservations steal a foreground terminal result", async () => { + const logger = createTestLogger(); + const keepQueryAlive = deferred(); + + sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { + const readPromptUuid = createPromptUuidReader(prompt); + let step = 0; + return { + next: vi.fn(async () => { + if (step === 0) { + step += 1; + return { + done: false, + value: { + type: "system", + subtype: "init", + session_id: "task-notification-foreground-session", + permissionMode: "default", + model: "opus", + }, + }; + } + if (step === 1) { + step += 1; + return { + done: false, + value: { + type: "system", + subtype: "task_notification", + task_id: "task-foreground-1", + tool_use_id: "toolu_foreground_1", + status: "completed", + output_file: "/tmp/task-foreground-1.txt", + summary: "Check Phase 1", + session_id: "task-notification-foreground-session", + uuid: "task-note-foreground-1", + }, + }; + } + if (step === 2) { + step += 1; + const promptUuid = (await readPromptUuid()) ?? "missing-prompt-uuid"; + return { + done: false, + value: { + type: "user", + message: { role: "user", content: "verify prompt" }, + parent_tool_use_id: null, + uuid: promptUuid, + session_id: "task-notification-foreground-session", + isReplay: true, + }, + }; + } + if (step === 3) { + step += 1; + return { + done: false, + value: { + type: "stream_event", + event: { + type: "message_start", + message: { + id: "foreground-tool-msg", + role: "assistant", + model: "opus", + usage: { input_tokens: 1, output_tokens: 0 }, + }, + }, + session_id: "task-notification-foreground-session", + parent_tool_use_id: null, + uuid: "foreground-message-start", + }, + }; + } + if (step === 4) { + step += 1; + return { + done: false, + value: { + type: "stream_event", + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "toolu_foreground_2", + name: "Agent", + input: {}, + caller: { type: "direct" }, + }, + }, + session_id: "task-notification-foreground-session", + parent_tool_use_id: null, + uuid: "foreground-tool-use-start", + }, + }; + } + if (step === 5) { + step += 1; + return { + done: false, + value: { + type: "assistant", + message: { + id: "foreground-final-msg", + content: "FOREGROUND_RESULT_STAYS_ATTACHED", + }, + session_id: "task-notification-foreground-session", + }, + }; + } + if (step === 6) { + step += 1; + return { + done: false, + value: { + type: "stream_event", + event: { + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { input_tokens: 1, output_tokens: 1 }, + }, + session_id: "task-notification-foreground-session", + parent_tool_use_id: null, + uuid: "foreground-message-delta", + }, + }; + } + if (step === 7) { + step += 1; + return { + done: false, + value: { + type: "stream_event", + event: { type: "message_stop" }, + session_id: "task-notification-foreground-session", + parent_tool_use_id: null, + uuid: "foreground-message-stop", + }, + }; + } + if (step === 8) { + step += 1; + return { + done: false, + value: { + type: "result", + subtype: "success", + usage: buildUsage(), + total_cost_usd: 0, + stop_reason: "end_turn", + session_id: "task-notification-foreground-session", + }, + }; + } + if (step === 9) { + await keepQueryAlive.promise; + return { done: true, value: undefined }; + } + return { done: true, value: undefined }; + }), + interrupt: vi.fn(async () => undefined), + return: vi.fn(async () => undefined), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), + supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), + } satisfies QueryMock; + }); + + const client = new ClaudeAgentClient({ logger }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + }); + + const foregroundEvents = await collectUntilTerminal(session.stream("verify prompt")); + const liveIterator = ( + session as unknown as { + streamLiveEvents: () => AsyncGenerator; + } + ).streamLiveEvents(); + const timedReader = createTimedIteratorReader({ iterator: liveIterator }); + const liveEvents: AgentStreamEvent[] = []; + + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + const next = await timedReader.nextWithTimeout(25); + if (next.done) { + break; + } + liveEvents.push(next.value); + } catch { + break; + } + } + + expect(collectAssistantText(foregroundEvents)).toContain( + "FOREGROUND_RESULT_STAYS_ATTACHED" + ); + expect(foregroundEvents.some((event) => event.type === "turn_completed")).toBe(true); + expect(liveEvents.some((event) => event.type === "turn_started")).toBe(false); + expect(liveEvents.some((event) => event.type === "turn_completed")).toBe(false); + + keepQueryAlive.resolve(undefined); + await session.close(); + }); + test("emits autonomous live events from SDK stream when Claude wakes itself", async () => { const logger = createTestLogger(); let queryCreateCount = 0; diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 37d626030..30a805885 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -74,6 +74,24 @@ import { } from "../provider-launch-config.js"; import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; +/* + * Routing invariant: + * While a foreground Claude turn is active, identifier-less assistant/stream/result + * events must stay attached to that foreground run unless we have explicit evidence + * that a distinct autonomous run has started. + * + * We previously allowed task_notification metadata to reserve autonomous ownership, + * then consumed that reservation on the next unbound chunk. In practice Claude often + * emits task_notification records and foreground tool-use stream chunks interleaved + * within the same turn. That let a foreground turn's terminal result get misrouted + * into an autonomous side run, which stranded the agent in "running" because the + * foreground stream never received turn_completed. + * + * The rule below is intentionally conservative: foreground turns get first claim on + * same-turn traffic, and autonomous wake reservations are only consumed once no + * foreground turn is active. This keeps task_notification advisory instead of letting + * it steal ownership from the active user turn. + */ const fsPromises = promises; const CLAUDE_SETTING_SOURCES: NonNullable = [ "user", @@ -102,8 +120,9 @@ type RoutingReason = | "parent_message_id" | "message_id" | "unbound_autonomous" - | "fallback" - | "metadata"; + | "reserved_autonomous" + | "metadata" + | "ignored_unmatched"; type EventIdentifiers = { taskId: string | null; @@ -114,6 +133,7 @@ type EventIdentifiers = { type RunRoute = { run: RunRecord | null; reason: RoutingReason; + dispatchWithoutRun?: boolean; }; type RunRecord = { @@ -2583,8 +2603,10 @@ class ClaudeAgentSession implements AgentSession { ): RunRoute { if (normalized.metadataOnly) { if ( - normalized.message.type === "user" && - isTaskNotificationUserContent(normalized.message.message?.content) + (normalized.message.type === "user" && + isTaskNotificationUserContent(normalized.message.message?.content)) || + (normalized.message.type === "system" && + normalized.message.subtype === "task_notification") ) { this.reserveAutonomousWake("task_notification"); } @@ -2603,13 +2625,6 @@ class ClaudeAgentSession implements AgentSession { return byIdentifiers; } - if (this.turnState === "autonomous") { - const activeAutonomousRun = this.runTracker.getLatestActiveRun("autonomous"); - if (activeAutonomousRun) { - return { run: activeAutonomousRun, reason: "unbound_autonomous" }; - } - } - const foregroundRun = this.activeForegroundTurn ? this.runTracker.getRun(this.activeForegroundTurn.runId) : null; @@ -2648,32 +2663,62 @@ class ClaudeAgentSession implements AgentSession { return { run: foregroundRun, reason: "foreground" }; } + if ( + this.pendingAutonomousWakeReservations > 0 && + !normalized.identifiers.taskId && + !normalized.identifiers.parentMessageId && + !normalized.identifiers.messageId + ) { + const reservedAutonomousRun = this.claimOrCreateAutonomousRun( + "reservation_unbound" + ); + return { + run: reservedAutonomousRun, + reason: "reserved_autonomous", + }; + } + if (!hasIdentifiers) { - if (this.pendingAutonomousWakeReservations > 0) { - const reservedAutonomousRun = this.claimOrCreateAutonomousRun( - "reservation_unbound" - ); - return { - run: reservedAutonomousRun, - reason: "unbound_autonomous", - }; - } const activeAutonomousRun = this.runTracker.getLatestActiveRun("autonomous"); if (activeAutonomousRun) { return { run: activeAutonomousRun, reason: "unbound_autonomous" }; } + if ( + !foregroundRun && + (normalized.message.type === "assistant" || + normalized.message.type === "stream_event" || + normalized.message.type === "result" || + normalized.message.type === "tool_progress") + ) { + const autonomousRun = this.claimOrCreateAutonomousRun("unbound_implicit"); + return { run: autonomousRun, reason: "unbound_autonomous" }; + } } if (this.pendingAutonomousWakeReservations > 0) { const reservedAutonomousRun = this.claimOrCreateAutonomousRun( "reservation_fallback" ); - return { run: reservedAutonomousRun, reason: "fallback" }; + return { run: reservedAutonomousRun, reason: "reserved_autonomous" }; } - const autonomousRun = this.createRun("autonomous", null); - this.emitRunEvent(autonomousRun, { type: "turn_started", provider: "claude" }); - return { run: autonomousRun, reason: "fallback" }; + this.logger.debug( + { + messageType: normalized.message.type, + hasIdentifiers, + taskId: normalized.identifiers.taskId, + parentMessageId: normalized.identifiers.parentMessageId, + messageId: normalized.identifiers.messageId, + turnState: this.turnState, + pendingAutonomousWakeReservations: this.pendingAutonomousWakeReservations, + }, + "Ignoring unmatched Claude SDK message without explicit run start signal" + ); + return { + run: null, + reason: "ignored_unmatched", + dispatchWithoutRun: false, + }; } private shouldPreferForegroundRun(input: { @@ -2746,10 +2791,14 @@ class ClaudeAgentSession implements AgentSession { if (!foregroundRun || foregroundRun.promptReplaySeen) { return; } - // System metadata (init/hook callbacks/etc.) can precede the first prompt - // replay for a legitimate foreground run. Treating that as churn strands - // one-shot helper runs in autonomous fallback. - if (message.type === "system") { + // Most system metadata (init/hook callbacks/etc.) can precede the first prompt + // replay for a legitimate foreground run. Treating all of it as churn strands + // one-shot helper runs. task_notification is the exception: it represents + // background agent activity and should suppress pre-replay foreground routing. + if ( + message.type === "system" && + message.subtype !== "task_notification" + ) { return; } this.preReplayMetadataSeen = true; @@ -2886,6 +2935,10 @@ class ClaudeAgentSession implements AgentSession { continue; } + if (await this.handleMissingResumedConversation(sdkMessage, q)) { + continue; + } + try { this.routeSdkMessageFromPump(sdkMessage); } catch (error) { @@ -2954,6 +3007,9 @@ class ClaudeAgentSession implements AgentSession { } if (!route.run) { + if (route.dispatchWithoutRun === false) { + return; + } this.dispatchMetadataEvents(events); return; } @@ -2963,6 +3019,45 @@ class ClaudeAgentSession implements AgentSession { } } + private async handleMissingResumedConversation( + message: SDKMessage, + query: Query + ): Promise { + const staleResumeError = this.readMissingResumedConversationError(message); + if (!staleResumeError) { + return false; + } + + this.logger.warn( + { + claudeSessionId: this.claudeSessionId, + error: staleResumeError, + }, + "Claude resumed session no longer exists; invalidating persisted session" + ); + + for (const run of this.runTracker.listActiveRuns()) { + this.failRun(run, staleResumeError); + } + this.transitionTurnStateFromActiveRuns("missing resumed conversation"); + this.input?.end(); + await this.awaitWithTimeout( + query.return?.(), + "query pump return on missing resumed conversation" + ); + if (this.query === query) { + this.query = null; + this.input = null; + } + this.claudeSessionId = null; + this.persistence = null; + this.persistedHistory = []; + this.historyPending = false; + this.cachedRuntimeInfo = null; + this.queryRestartNeeded = false; + return true; + } + private shouldSuppressReplayResultTerminal(input: { run: RunRecord | null; message: SDKMessage; @@ -3691,6 +3786,30 @@ class ClaudeAgentSession implements AgentSession { return threadStartedSessionId; } + private readMissingResumedConversationError(message: SDKMessage): string | null { + if (message.type !== "result" || message.subtype !== "error_during_execution") { + return null; + } + if (!this.claudeSessionId) { + return null; + } + const errors = + "errors" in message && Array.isArray(message.errors) ? message.errors : []; + for (const entry of errors) { + if (typeof entry !== "string") { + continue; + } + const match = entry.match(/^No conversation found with session ID:\s*(.+)$/); + if (!match) { + continue; + } + if (match[1]?.trim() === this.claudeSessionId) { + return entry.trim(); + } + } + return null; + } + private convertUsage(message: SDKResultMessage): AgentUsage | undefined { if (!message.usage) { return undefined; diff --git a/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts new file mode 100644 index 000000000..a58abedec --- /dev/null +++ b/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; + +import { ClaudeAgentClient } from "../agent/providers/claude-agent.js"; +import { isCommandAvailable } from "../agent/provider-launch-config.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { getFullAccessConfig } from "./agent-configs.js"; + +function tmpCwd(): string { + return mkdtempSync(path.join(tmpdir(), "daemon-real-claude-autonomous-simple-")); +} + +function compactText(value: string): string { + return value.replace(/\s+/g, "").toLowerCase(); +} + +describe("daemon E2E (real claude) - autonomous wake simple", () => { + test.runIf(isCommandAvailable("claude"))( + "hello + background sleep returns idle, then wakes once on completion", + async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-autonomous-simple-real" }, + }); + + const agent = await client.createAgent({ + cwd, + title: "claude-autonomous-simple-real", + ...getFullAccessConfig("claude"), + }); + + const autonomousWakeToken = `AUTONOMOUS_SIMPLE_${Date.now().toString(36)}`; + await client.sendMessage( + agent.id, + [ + "Hello.", + "Use the Bash tool with run_in_background.", + "Run exactly: sleep 5", + "Do not wait for the task result.", + "Reply immediately with exactly: SPAWNED", + `When the background task completes later, reply with exactly: ${autonomousWakeToken}`, + ].join(" ") + ); + + const firstFinish = await client.waitForFinish(agent.id, 240_000); + expect(firstFinish.status).toBe("idle"); + + const timelineAtIdle = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + const idleAssistantText = timelineAtIdle.entries + .filter( + (entry): entry is typeof entry & { + item: { type: "assistant_message"; text: string }; + } => entry.item.type === "assistant_message" + ) + .map((entry) => entry.item.text) + .join("\n"); + expect(compactText(idleAssistantText)).toContain("spawned"); + expect( + timelineAtIdle.entries.some( + (entry) => + entry.item.type === "tool_call" && + entry.item.name === "Bash" + ) + ).toBe(true); + + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000 + ); + + const autonomousFinish = await client.waitForFinish(agent.id, 120_000); + expect(autonomousFinish.status).toBe("idle"); + + const finalTimeline = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + const finalAssistantText = finalTimeline.entries + .filter( + (entry): entry is typeof entry & { + item: { type: "assistant_message"; text: string }; + } => entry.item.type === "assistant_message" + ) + .map((entry) => entry.item.text) + .join("\n"); + expect(compactText(finalAssistantText)).toContain( + autonomousWakeToken.toLowerCase() + ); + } finally { + await client.close().catch(() => undefined); + await daemon.close().catch(() => undefined); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 420_000 + ); +});