From 553ac53643b2cc861b94044798e67cadbb28c98e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 2 Aug 2026 14:48:23 +0200 Subject: [PATCH] feat(app): add chat outline navigation (#2792) * feat(app): add chat outline navigation Long agent sessions need prompt navigation without coupling viewport movement to disposable server windows. Keep fetched timeline pages in client state so repeated jumps stay local, and settle prompt jumps against virtualized row measurement changes. * fix(app): preserve chat outline timeline coverage * fix(app): reconcile outline window submissions * fix(app): harden outline timeline navigation --- packages/app/e2e/browser/chat-outline.spec.ts | 241 +++++++++ .../support/helpers/agent-timeline-gate.ts | 35 ++ .../app/e2e/support/helpers/chat-outline.ts | 203 ++++++++ .../support/helpers/timeline-pagination.ts | 59 +++ .../chat-outline/hover-intent.test.ts | 125 +++++ .../agent-stream/chat-outline/hover-intent.ts | 82 ++++ .../agent-stream/chat-outline/model.test.ts | 73 +++ .../src/agent-stream/chat-outline/model.ts | 88 ++++ .../src/agent-stream/chat-outline/rail.tsx | 13 + .../agent-stream/chat-outline/rail.web.tsx | 255 ++++++++++ .../chat-outline/use-chat-outline.test.tsx | 177 +++++++ .../chat-outline/use-chat-outline.ts | 178 +++++++ .../agent-stream/prompt-jump-settle.test.ts | 193 ++++++++ .../src/agent-stream/prompt-jump-settle.ts | 111 +++++ .../src/agent-stream/strategy-web.test.tsx | 44 ++ .../app/src/agent-stream/strategy-web.tsx | 77 ++- packages/app/src/agent-stream/strategy.ts | 4 + .../timeline-tail-navigation.test.ts | 23 + .../agent-stream/timeline-tail-navigation.ts | 14 + .../agent-stream/use-scroll-to-message.web.ts | 126 +++++ packages/app/src/agent-stream/view.tsx | 58 ++- packages/app/src/contexts/session-context.tsx | 18 + .../src/hooks/use-settings/storage.test.ts | 20 + .../app/src/hooks/use-settings/storage.ts | 17 +- packages/app/src/i18n/resources/ar.ts | 5 + packages/app/src/i18n/resources/en.ts | 5 + packages/app/src/i18n/resources/es.ts | 5 + packages/app/src/i18n/resources/fr.ts | 5 + packages/app/src/i18n/resources/ja.ts | 5 + packages/app/src/i18n/resources/pt-BR.ts | 5 + packages/app/src/i18n/resources/ru.ts | 5 + packages/app/src/i18n/resources/zh-CN.ts | 5 + .../src/runtime/replica-cache/index.test.ts | 1 + .../appearance/appearance-section.tsx | 37 ++ packages/app/src/stores/session-store.test.ts | 8 +- packages/app/src/stores/session-store.ts | 34 ++ .../timeline/session-stream-reducers.test.ts | 283 +++++++++++ .../src/timeline/session-stream-reducers.ts | 461 +++++++++++++++--- .../src/timeline/timeline-sync-plan.test.ts | 14 + .../app/src/timeline/timeline-sync-plan.ts | 11 + packages/client/src/daemon-client.test.ts | 45 ++ packages/client/src/daemon-client.ts | 34 ++ .../src/messages.stream-parsing.test.ts | 28 ++ packages/protocol/src/messages.ts | 33 ++ .../server/agent/agent-timeline-store.test.ts | 54 ++ .../agent/timeline-prompt-index.test.ts | 54 ++ .../src/server/agent/timeline-prompt-index.ts | 42 ++ packages/server/src/server/session.ts | 54 ++ .../server/src/server/websocket-server.ts | 2 + 49 files changed, 3385 insertions(+), 84 deletions(-) create mode 100644 packages/app/e2e/browser/chat-outline.spec.ts create mode 100644 packages/app/e2e/support/helpers/chat-outline.ts create mode 100644 packages/app/src/agent-stream/chat-outline/hover-intent.test.ts create mode 100644 packages/app/src/agent-stream/chat-outline/hover-intent.ts create mode 100644 packages/app/src/agent-stream/chat-outline/model.test.ts create mode 100644 packages/app/src/agent-stream/chat-outline/model.ts create mode 100644 packages/app/src/agent-stream/chat-outline/rail.tsx create mode 100644 packages/app/src/agent-stream/chat-outline/rail.web.tsx create mode 100644 packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx create mode 100644 packages/app/src/agent-stream/chat-outline/use-chat-outline.ts create mode 100644 packages/app/src/agent-stream/prompt-jump-settle.test.ts create mode 100644 packages/app/src/agent-stream/prompt-jump-settle.ts create mode 100644 packages/app/src/agent-stream/timeline-tail-navigation.test.ts create mode 100644 packages/app/src/agent-stream/timeline-tail-navigation.ts create mode 100644 packages/app/src/agent-stream/use-scroll-to-message.web.ts create mode 100644 packages/server/src/server/agent/timeline-prompt-index.test.ts create mode 100644 packages/server/src/server/agent/timeline-prompt-index.ts diff --git a/packages/app/e2e/browser/chat-outline.spec.ts b/packages/app/e2e/browser/chat-outline.spec.ts new file mode 100644 index 000000000..55e299782 --- /dev/null +++ b/packages/app/e2e/browser/chat-outline.spec.ts @@ -0,0 +1,241 @@ +import { expect, test } from "../support/fixtures"; +import { trackPromptJumpRequests } from "../support/helpers/agent-timeline-gate"; +import { + clickChatOutlineRowEdge, + disableChatOutlineFromAppearance, + expectActiveChatOutlinePrompt, + expectChatOutlinePreview, + expectChatOutlinePrompts, + expectChatOutlineAlignedWithActiveTabGlyph, + expectChatOutlinePromptToRemainBare, + expectLiveTurnPromptAboveFoldAndActive, + expectNoChatOutlinePreviewWhileCrossingToSidebar, + expectNoChatOutline, + expectNoChatOutlinePreview, + expectOneActiveChatOutlinePrompt, + focusChatOutlinePrompt, + hoverChatOutlinePrompt, + chatOutlineRail, + movePointerOffChatOutline, + pointAtChatOutlineRowEdge, + pressEnterOnFocusedPrompt, + splitCurrentPanelRight, +} from "../support/helpers/chat-outline"; +import { + expectTimelineAtMaximumScrollWithPromptVisible, + expectTimelinePromptNotMounted, + expectTimelinePromptLandedBelowTop, + expectTimelinePromptVisible, + holdOlderHistoryPages, + openAgentTimeline, + scrollThroughOlderHistoryPages, + scrollTimelineToNewestLoadedEdge, + scrollTimelineToOldestLoadedEdge, + seedLongMockAgentTimeline, + type LongTimelineAgent, +} from "../support/helpers/timeline-pagination"; + +const WIDE_VIEWPORT = { width: 1280, height: 900 }; +const LOADED_TURNS = 16; + +test.describe("desktop chat outline", () => { + test("indexes unloaded prompts and jumps with one bounded merged page", async ({ page }) => { + test.setTimeout(120_000); + const agent = await seedLongMockAgentTimeline({ turns: 80 }); + try { + const jumps = await trackPromptJumpRequests(page, agent.agentId); + await page.setViewportSize(WIDE_VIEWPORT); + await openAgentTimeline(page, agent); + + const rail = chatOutlineRail(page); + await expectChatOutlinePrompts(page, 80); + await expectTimelinePromptNotMounted(page, agent.oldestPrompt); + + await rail.getByRole("tab").nth(39).click(); + await expectTimelinePromptLandedBelowTop(page, agent.prompts[39]); + await expect.poll(() => jumps.requests()).toHaveLength(1); + expect(jumps.requests()[0]).toMatchObject({ limit: 40, mergeWindow: true }); + + await page.getByTestId("scroll-to-bottom-button").click(); + await expectTimelinePromptVisible(page, agent.newestPrompt); + + const requestsBeforeLoadedJump = jumps.requests().length; + await rail.getByRole("tab").last().click(); + await expectTimelinePromptVisible(page, agent.newestPrompt); + expect(jumps.requests()).toHaveLength(requestsBeforeLoadedJump); + + await rail.getByRole("tab").first().click(); + await expectTimelinePromptVisible(page, agent.oldestPrompt); + await agent.client.sendAgentMessage(agent.agentId, "live prompt while viewing old history"); + await agent.client.waitForFinish(agent.agentId, 15_000); + await expectTimelinePromptVisible(page, agent.oldestPrompt); + } finally { + await agent.cleanup(); + } + }); + + test.describe("with the whole conversation loaded", () => { + let agent: LongTimelineAgent; + + test.beforeAll(async () => { + test.setTimeout(120_000); + agent = await seedLongMockAgentTimeline({ turns: LOADED_TURNS }); + }); + + test.afterAll(async () => { + await agent.cleanup(); + }); + + test.beforeEach(async ({ page }) => { + await page.setViewportSize(WIDE_VIEWPORT); + await openAgentTimeline(page, agent); + await expectChatOutlinePrompts(page, LOADED_TURNS); + }); + + test("previews the prompt under the pointer, anywhere across its row", async ({ page }) => { + await hoverChatOutlinePrompt(page, 2); + await expectChatOutlinePreview(page, agent.prompts[1]); + + await pointAtChatOutlineRowEdge(page, 7); + await expectChatOutlinePreview(page, agent.prompts[6]); + + await movePointerOffChatOutline(page); + await expectNoChatOutlinePreview(page); + }); + + test("does not preview while crossing the rail toward the sidebar", async ({ page }) => { + await expectNoChatOutlinePreviewWhileCrossingToSidebar(page); + }); + + test("closes the preview when the pointer leaves after a jump", async ({ page }) => { + await clickChatOutlineRowEdge(page, 4); + await movePointerOffChatOutline(page); + + await expectNoChatOutlinePreview(page); + }); + + test("previews and jumps to the focused prompt from the keyboard", async ({ page }) => { + await focusChatOutlinePrompt(page, 1); + await expectChatOutlinePreview(page, agent.prompts[0]); + + await pressEnterOnFocusedPrompt(page); + await expectTimelinePromptLandedBelowTop(page, agent.oldestPrompt); + await expectActiveChatOutlinePrompt(page, 1); + }); + + test("moves the active mark as the reader jumps and scrolls", async ({ page }) => { + await expectOneActiveChatOutlinePrompt(page); + + await clickChatOutlineRowEdge(page, 4); + await expectTimelinePromptVisible(page, agent.prompts[3]); + await expectActiveChatOutlinePrompt(page, 4); + + await scrollTimelineToOldestLoadedEdge(page); + await expectActiveChatOutlinePrompt(page, 1); + }); + + test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => { + await clickChatOutlineRowEdge(page, 4); + + await expectChatOutlinePromptToRemainBare(page, 4); + }); + + test("aligns the ticks with the active tab glyph rail", async ({ page }) => { + await expectChatOutlineAlignedWithActiveTabGlyph(page); + }); + + test("clamps the newest prompt at maximum scroll while keeping it visible", async ({ + page, + }) => { + await clickChatOutlineRowEdge(page, LOADED_TURNS); + + await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.newestPrompt); + }); + + test("keeps the current prompt active throughout a long live assistant turn", async ({ + page, + }) => { + const prompt = "chat outline live turn reading position"; + await agent.client.sendAgentMessage(agent.agentId, prompt); + try { + await expectLiveTurnPromptAboveFoldAndActive(page, prompt, LOADED_TURNS + 1); + } finally { + await agent.client.waitForFinish(agent.agentId, 15_000); + } + }); + }); + + test.describe("with virtualized loaded history", () => { + let agent: LongTimelineAgent; + + test.beforeAll(async () => { + test.setTimeout(120_000); + agent = await seedLongMockAgentTimeline({ turns: 80 }); + }); + + test.afterAll(async () => { + await agent.cleanup(); + }); + + test.beforeEach(async ({ page }) => { + await page.setViewportSize(WIDE_VIEWPORT); + }); + + test("pins a loaded prompt inside the virtualized block without fetching", async ({ page }) => { + const history = await holdOlderHistoryPages(page, agent); + await openAgentTimeline(page, agent); + await expectChatOutlinePrompts(page, 80); + await scrollThroughOlderHistoryPages(page, 2, history); + await scrollTimelineToNewestLoadedEdge(page); + const requestsBeforeJump = history.requestCount(); + + await clickChatOutlineRowEdge(page, 30); + await expectTimelinePromptLandedBelowTop(page, agent.prompts[29]); + + expect(history.requestCount()).toBe(requestsBeforeJump); + }); + + test("pins a newer loaded prompt when jumping downward from old history", async ({ page }) => { + const history = await holdOlderHistoryPages(page, agent); + await openAgentTimeline(page, agent); + await expectChatOutlinePrompts(page, 80); + await scrollThroughOlderHistoryPages(page, 2, history); + await scrollTimelineToNewestLoadedEdge(page); + + await clickChatOutlineRowEdge(page, 30); + await expectTimelinePromptLandedBelowTop(page, agent.prompts[29]); + + await clickChatOutlineRowEdge(page, 60); + + await expectTimelinePromptLandedBelowTop(page, agent.prompts[59]); + }); + }); + + test("hides the rail when a split makes its panel narrow", async ({ page }) => { + const agent = await seedLongMockAgentTimeline({ turns: 2 }); + try { + await page.setViewportSize(WIDE_VIEWPORT); + await openAgentTimeline(page, agent); + + await splitCurrentPanelRight(page); + + await expectNoChatOutline(page); + } finally { + await agent.cleanup(); + } + }); + + test("can be disabled from Appearance settings", async ({ page }) => { + const agent = await seedLongMockAgentTimeline({ turns: 2 }); + try { + await page.setViewportSize(WIDE_VIEWPORT); + await openAgentTimeline(page, agent); + + await disableChatOutlineFromAppearance(page); + + await expectNoChatOutline(page); + } finally { + await agent.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/support/helpers/agent-timeline-gate.ts b/packages/app/e2e/support/helpers/agent-timeline-gate.ts index 332c5d241..ae5b883aa 100644 --- a/packages/app/e2e/support/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/support/helpers/agent-timeline-gate.ts @@ -27,6 +27,41 @@ export interface DaemonHydrationGate { release(): void; } +export interface PromptJumpRequestTracker { + requests(): Array<{ cursorSeq: number | null; limit: number | null; mergeWindow: boolean }>; +} + +export async function trackPromptJumpRequests( + page: Page, + agentId: string, +): Promise { + const seen: Array<{ cursorSeq: number | null; limit: number | null; mergeWindow: boolean }> = []; + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => { + const sessionMessage = getSessionMessage(message); + if ( + sessionMessage?.type === "fetch_agent_timeline_request" && + sessionMessage.agentId === agentId && + sessionMessage.direction === "before" && + sessionMessage.mergeWindow === true + ) { + const cursor = sessionMessage.cursor as { seq?: unknown } | undefined; + seen.push({ + cursorSeq: typeof cursor?.seq === "number" ? cursor.seq : null, + limit: typeof sessionMessage.limit === "number" ? sessionMessage.limit : null, + mergeWindow: true, + }); + } + server.send(message); + }); + server.onMessage((message) => ws.send(message)); + }); + return { + requests: () => [...seen], + }; +} + export interface BootstrapTimelineGate extends AgentTimelineResponseGate { releaseCatchUp(): void; waitForDelayedCatchUp(): Promise; diff --git a/packages/app/e2e/support/helpers/chat-outline.ts b/packages/app/e2e/support/helpers/chat-outline.ts new file mode 100644 index 000000000..a70189654 --- /dev/null +++ b/packages/app/e2e/support/helpers/chat-outline.ts @@ -0,0 +1,203 @@ +import { expect, type Locator, type Page } from "@playwright/test"; +import { openSettings } from "./app"; +import { openSettingsSection } from "./settings"; + +export function chatOutlineRail(page: Page): Locator { + return page.getByTestId("chat-outline-rail"); +} + +function chatOutlinePrompt(page: Page, position: number): Locator { + return chatOutlineRail(page) + .getByRole("tab") + .nth(position - 1); +} + +export async function expectChatOutlinePrompts(page: Page, count: number): Promise { + await expect(chatOutlineRail(page)).toBeVisible(); + await expect(chatOutlineRail(page).getByRole("tab")).toHaveCount(count); +} + +export async function expectNoChatOutline(page: Page): Promise { + await expect(chatOutlineRail(page)).toBeHidden(); +} + +export async function hoverChatOutlinePrompt(page: Page, position: number): Promise { + await chatOutlinePrompt(page, position).hover(); +} + +/** + * The trailing edge of a prompt's row, far from its left-anchored pill. Acquiring a prompt + * there is what makes the rail usable when a long conversation squeezes the pills thin. + */ +export async function pointAtChatOutlineRowEdge(page: Page, position: number): Promise { + const row = await promptRowBox(page, position); + await page.mouse.move(row.x + row.width - 1, row.y + row.height / 2); +} + +export async function clickChatOutlineRowEdge(page: Page, position: number): Promise { + await pointAtChatOutlineRowEdge(page, position); + await page.mouse.down(); + await page.mouse.up(); +} + +export async function splitCurrentPanelRight(page: Page): Promise { + await page.getByRole("button", { name: "Split pane right" }).first().click(); +} + +export async function disableChatOutlineFromAppearance(page: Page): Promise { + const timelineUrl = page.url(); + await openSettings(page); + await openSettingsSection(page, "appearance"); + await page.getByRole("switch", { name: "Chat outline" }).click(); + await page.goto(timelineUrl); +} + +/** Parks the pointer in the middle of the transcript, clear of the rail. */ +export async function movePointerOffChatOutline(page: Page): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const box = await requireBoundingBox(timeline); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); +} + +export async function focusChatOutlinePrompt(page: Page, position: number): Promise { + await chatOutlinePrompt(page, position).focus(); +} + +export async function pressEnterOnFocusedPrompt(page: Page): Promise { + await page.keyboard.press("Enter"); +} + +export async function expectChatOutlinePreview(page: Page, preview: string): Promise { + await expect(page.getByTestId("chat-outline-preview")).toHaveText(preview); +} + +export async function expectNoChatOutlinePreview(page: Page): Promise { + await expect(page.getByTestId("chat-outline-preview")).toHaveCount(0); +} + +export async function expectNoChatOutlinePreviewWhileCrossingToSidebar(page: Page): Promise { + const railBox = await requireBoundingBox(chatOutlineRail(page)); + await page.mouse.move(railBox.x + railBox.width + 2, railBox.y + railBox.height / 2); + await page.evaluate(() => { + document.body.dataset.chatOutlinePreviewObserved = String( + document.querySelector('[data-testid="chat-outline-preview"]') !== null, + ); + const observer = new MutationObserver(() => { + if (document.querySelector('[data-testid="chat-outline-preview"]')) { + document.body.dataset.chatOutlinePreviewObserved = "true"; + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + Object.assign(window, { __chatOutlinePreviewObserver: observer }); + }); + + for (let step = 0; step <= 10; step += 1) { + await page.mouse.move( + railBox.x + railBox.width - 1 - (step * railBox.width) / 10, + railBox.y + railBox.height / 2, + ); + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); + } + await page.mouse.move(railBox.x - 2, railBox.y + railBox.height / 2); + + const previewAppeared = await page.evaluate(() => { + const observer = Reflect.get(window, "__chatOutlinePreviewObserver") as + | MutationObserver + | undefined; + observer?.disconnect(); + Reflect.deleteProperty(window, "__chatOutlinePreviewObserver"); + const observed = document.body.dataset.chatOutlinePreviewObserved === "true"; + delete document.body.dataset.chatOutlinePreviewObserved; + return observed; + }); + expect(previewAppeared).toBe(false); +} + +export async function expectChatOutlinePromptToRemainBare( + page: Page, + position: number, +): Promise { + await expect(chatOutlinePrompt(page, position)).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); +} + +export async function expectChatOutlineAlignedWithActiveTabGlyph(page: Page): Promise { + const outlinePill = chatOutlinePrompt(page, 1).locator("div").first(); + const activeTabGlyph = page + .getByTestId("workspace-tabs-row") + .filter({ visible: true }) + .locator('[role="button"][aria-selected="true"]') + .locator("svg") + .first(); + + await expect + .poll(async () => { + const [pillBox, glyphBox] = await Promise.all([ + requireBoundingBox(outlinePill), + requireBoundingBox(activeTabGlyph), + ]); + return Math.abs(pillBox.x - glyphBox.x); + }) + .toBeLessThanOrEqual(1); +} + +/** Exactly one prompt is marked, without saying which — the reader always has a "you are here". */ +export async function expectOneActiveChatOutlinePrompt(page: Page): Promise { + await expect(chatOutlineRail(page).getByRole("tab", { selected: true })).toHaveCount(1); +} + +export async function expectActiveChatOutlinePrompt(page: Page, position: number): Promise { + await expect(chatOutlineRail(page).getByRole("tab", { selected: true })).toHaveAccessibleName( + new RegExp(`^${position} of `), + ); +} + +export async function expectLiveTurnPromptAboveFoldAndActive( + page: Page, + prompt: string, + position: number, +): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const promptRow = timeline.getByTestId("user-message").filter({ hasText: prompt }); + const activeTick = chatOutlineRail(page).getByRole("tab", { selected: true }); + await expect + .poll( + async () => { + const [timelineBox, promptBox, activeLabel] = await Promise.all([ + timeline.boundingBox(), + promptRow.boundingBox(), + activeTick.getAttribute("aria-label"), + ]); + return Boolean( + timelineBox && + promptBox && + promptBox.y + promptBox.height < timelineBox.y && + activeLabel?.startsWith(`${position} of `), + ); + }, + { timeout: 15_000 }, + ) + .toBe(true); +} + +async function promptRowBox( + page: Page, + position: number, +): Promise<{ x: number; y: number; width: number; height: number }> { + return requireBoundingBox(chatOutlinePrompt(page, position)); +} + +async function requireBoundingBox( + locator: Locator, +): Promise<{ x: number; y: number; width: number; height: number }> { + await expect(locator).toBeVisible(); + const box = await locator.boundingBox(); + if (!box) { + throw new Error("Expected the chat outline element to have a layout box"); + } + return box; +} diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts index 8f1d301e1..8acdcac3b 100644 --- a/packages/app/e2e/support/helpers/timeline-pagination.ts +++ b/packages/app/e2e/support/helpers/timeline-pagination.ts @@ -17,6 +17,8 @@ interface LongTimelineAgentOptions { } export interface LongTimelineAgent extends MockAgentWorkspace { + /** Every seeded prompt, oldest first, so a test can name the nth turn. */ + prompts: string[]; firstOlderPagePrompt: string; initialTailAnchorPrompt: string; initialTailOldestPrompt: string; @@ -60,6 +62,7 @@ interface OlderHistoryPages { expectRepeatedEntries(): void; expectOwnedTextRendered(text: string): Promise; releasePage(pageNumber: number): void; + requestCount(): number; } function promptForTurn(index: number): string { @@ -82,6 +85,7 @@ export async function seedLongMockAgentTimeline( return { ...agent, + prompts: Array.from({ length: options.turns }, (_unused, index) => promptForTurn(index)), firstOlderPagePrompt: promptForTurn(Math.max(0, options.turns - 40)), initialTailAnchorPrompt: promptForTurn(Math.max(0, options.turns - 5)), initialTailOldestPrompt: promptForTurn(Math.max(0, options.turns - 20)), @@ -148,6 +152,25 @@ export async function rememberTimelinePromptPosition( return { prompt, top: box.y }; } +export async function expectTimelinePromptLandedBelowTop( + page: Page, + prompt: string, +): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt }); + await expect(promptRow).toBeVisible(); + await expect + .poll(async () => { + const [timelineBox, promptBox] = await Promise.all([ + timeline.boundingBox(), + promptRow.boundingBox(), + ]); + if (!timelineBox || !promptBox) return Number.POSITIVE_INFINITY; + return Math.abs(promptBox.y - timelineBox.y - 8); + }) + .toBeLessThanOrEqual(2); +} + export async function expectTimelinePromptPositionPreserved( page: Page, before: TimelinePromptPositionSnapshot, @@ -163,6 +186,39 @@ export async function expectTimelinePromptPositionPreserved( .toBeLessThanOrEqual(2); } +export async function expectTimelineAtMaximumScrollWithPromptVisible( + page: Page, + prompt: string, +): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const item = timeline.getByText(prompt, { exact: true }); + await expect + .poll(async () => { + const [metrics, timelineBox, promptBox] = await Promise.all([ + timeline.evaluate((element) => { + if (!(element instanceof HTMLElement)) { + throw new Error("Agent chat scroll element is not an HTMLElement"); + } + return { + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + scrollTop: element.scrollTop, + }; + }), + timeline.boundingBox(), + item.boundingBox(), + ]); + if (!timelineBox || !promptBox) return false; + const isAtMaximumScroll = + Math.abs(metrics.scrollTop + metrics.clientHeight - metrics.scrollHeight) <= 2; + const isPromptFullyVisible = + promptBox.y >= timelineBox.y && + promptBox.y + promptBox.height <= timelineBox.y + timelineBox.height; + return isAtMaximumScroll && isPromptFullyVisible; + }) + .toBe(true); +} + export async function openAgentTimeline( page: Page, agent: MockAgentWorkspace, @@ -299,6 +355,9 @@ export async function holdOlderHistoryPages( releasePage(pageNumber) { gate.releasePage(pageNumber); }, + requestCount() { + return gate.getRequestCount(); + }, }; } diff --git a/packages/app/src/agent-stream/chat-outline/hover-intent.test.ts b/packages/app/src/agent-stream/chat-outline/hover-intent.test.ts new file mode 100644 index 000000000..3595e2a83 --- /dev/null +++ b/packages/app/src/agent-stream/chat-outline/hover-intent.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { createChatOutlineHoverIntent } from "./hover-intent"; + +function createFakeScheduler() { + const callbacks = new Map void>(); + const delays: number[] = []; + let nextId = 1; + return { + schedule(callback: () => void, delayMs: number) { + const id = nextId++; + callbacks.set(id, callback); + delays.push(delayMs); + return id; + }, + cancel(id: number) { + callbacks.delete(id); + }, + runPending() { + for (const [id, callback] of callbacks) { + callbacks.delete(id); + callback(); + } + }, + scheduleCount() { + return delays.length; + }, + delays() { + return delays; + }, + }; +} + +describe("chat outline hover intent", () => { + it("counts movement across the rail toward one initial activation", () => { + const scheduler = createFakeScheduler(); + const activations: Array = []; + const intent = createChatOutlineHoverIntent({ + activate: (index) => activations.push(index), + schedule: scheduler.schedule, + cancel: scheduler.cancel, + }); + + intent.enter({ x: 20, y: 10 }); + intent.pointAt(1); + intent.move({ x: 20, y: 30 }); + intent.pointAt(2); + intent.move({ x: 20, y: 50 }); + intent.pointAt(3); + scheduler.runPending(); + + expect({ + activations, + scheduleCount: scheduler.scheduleCount(), + delays: scheduler.delays(), + }).toEqual({ + activations: [3], + scheduleCount: 1, + delays: [150], + }); + }); + + it("does not activate while the pointer is crossing horizontally through the rail", () => { + const scheduler = createFakeScheduler(); + const activations: Array = []; + const intent = createChatOutlineHoverIntent({ + activate: (index) => activations.push(index), + schedule: scheduler.schedule, + cancel: scheduler.cancel, + }); + + intent.enter({ x: 36, y: 20 }); + intent.pointAt(1); + intent.move({ x: 30, y: 20 }); + intent.move({ x: 24, y: 20 }); + intent.move({ x: 18, y: 20 }); + intent.leave(); + intent.enter({ x: 0, y: 20 }); + intent.pointAt(2); + intent.move({ x: 6, y: 20 }); + intent.move({ x: 12, y: 20 }); + intent.leave(); + scheduler.runPending(); + + expect({ activations, scheduleCount: scheduler.scheduleCount() }).toEqual({ + activations: [null, null], + scheduleCount: 7, + }); + }); + + it("moves immediately between ticks after the rail activates", () => { + const scheduler = createFakeScheduler(); + const activations: Array = []; + const intent = createChatOutlineHoverIntent({ + activate: (index) => activations.push(index), + schedule: scheduler.schedule, + cancel: scheduler.cancel, + }); + + intent.pointAt(1); + scheduler.runPending(); + intent.pointAt(5); + + expect(activations).toEqual([1, 5]); + }); + + it("resets activation when the pointer leaves the rail", () => { + const scheduler = createFakeScheduler(); + const activations: Array = []; + const intent = createChatOutlineHoverIntent({ + activate: (index) => activations.push(index), + schedule: scheduler.schedule, + cancel: scheduler.cancel, + }); + + intent.pointAt(1); + scheduler.runPending(); + intent.leave(); + intent.pointAt(2); + + expect({ activations, scheduleCount: scheduler.scheduleCount() }).toEqual({ + activations: [1, null], + scheduleCount: 2, + }); + }); +}); diff --git a/packages/app/src/agent-stream/chat-outline/hover-intent.ts b/packages/app/src/agent-stream/chat-outline/hover-intent.ts new file mode 100644 index 000000000..0b03690a4 --- /dev/null +++ b/packages/app/src/agent-stream/chat-outline/hover-intent.ts @@ -0,0 +1,82 @@ +export interface ChatOutlineHoverIntent { + enter(point: PointerPoint): void; + pointAt(index: number): void; + move(point: PointerPoint): void; + leave(): void; + dispose(): void; +} + +interface PointerPoint { + x: number; + y: number; +} + +const INITIAL_ACTIVATION_DELAY_MS = 150; +const HORIZONTAL_TRANSIT_DISTANCE_PX = 4; + +export function createChatOutlineHoverIntent(input: { + activate: (index: number | null) => void; + schedule: (callback: () => void, delayMs: number) => number; + cancel: (timerId: number) => void; +}): ChatOutlineHoverIntent { + let timerId: number | null = null; + let candidateIndex: number | null = null; + let motionAnchor: PointerPoint | null = null; + let active = false; + + function cancelTimer(): void { + if (timerId === null) return; + input.cancel(timerId); + timerId = null; + } + + function scheduleActivation(): void { + if (candidateIndex === null) return; + cancelTimer(); + timerId = input.schedule(() => { + timerId = null; + active = true; + if (candidateIndex !== null) input.activate(candidateIndex); + }, INITIAL_ACTIVATION_DELAY_MS); + } + + return { + enter(point) { + motionAnchor = point; + }, + pointAt(index) { + candidateIndex = index; + if (active) { + input.activate(index); + return; + } + if (timerId !== null) return; + scheduleActivation(); + }, + move(point) { + if (active || motionAnchor === null) return; + const deltaX = point.x - motionAnchor.x; + const deltaY = point.y - motionAnchor.y; + if (Math.abs(deltaY) >= Math.abs(deltaX)) { + motionAnchor = point; + return; + } + if (Math.abs(deltaX) < HORIZONTAL_TRANSIT_DISTANCE_PX) return; + motionAnchor = point; + scheduleActivation(); + }, + leave() { + cancelTimer(); + candidateIndex = null; + motionAnchor = null; + active = false; + input.activate(null); + }, + dispose() { + cancelTimer(); + candidateIndex = null; + motionAnchor = null; + active = false; + }, + }; +} diff --git a/packages/app/src/agent-stream/chat-outline/model.test.ts b/packages/app/src/agent-stream/chat-outline/model.test.ts new file mode 100644 index 000000000..81811c7d7 --- /dev/null +++ b/packages/app/src/agent-stream/chat-outline/model.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + createActivePromptPublisher, + shouldAcceptPromptIndexEpoch, + promptTickMagnification, + resolveActivePromptSeq, + OUTLINE_MAGNIFY_RADIUS, + type ChatOutlinePrompt, +} from "./model"; + +describe("chat outline prompt index epoch", () => { + it("accepts only the authoritative timeline epoch", () => { + expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-2")).toBe(true); + expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-1")).toBe(false); + expect(shouldAcceptPromptIndexEpoch(null, "epoch-1")).toBe(true); + }); +}); + +function prompt(seq: number): ChatOutlinePrompt { + return { seq, timestamp: new Date(seq).toISOString(), preview: `prompt ${seq}` }; +} + +describe("promptTickMagnification", () => { + it("peaks under the pointer and decays to nothing at the radius", () => { + expect(promptTickMagnification(0)).toBe(1); + expect(promptTickMagnification(OUTLINE_MAGNIFY_RADIUS)).toBe(0); + expect(promptTickMagnification(OUTLINE_MAGNIFY_RADIUS + 10)).toBe(0); + }); + + it("falls off monotonically and symmetrically around the pointer", () => { + const above = [0, 1, 2, 3].map((distance) => promptTickMagnification(distance)); + const below = [0, -1, -2, -3].map((distance) => promptTickMagnification(distance)); + + expect(above).toEqual(below); + expect(above).toEqual([...above].sort((left, right) => right - left)); + }); +}); + +describe("resolveActivePromptSeq", () => { + const prompts = [prompt(2), prompt(9), prompt(20)]; + + it("marks the prompt whose turn the reading position sits in", () => { + expect(resolveActivePromptSeq(prompts, 9)).toBe(9); + expect(resolveActivePromptSeq(prompts, 14)).toBe(9); + expect(resolveActivePromptSeq(prompts, 20)).toBe(20); + expect(resolveActivePromptSeq(prompts, 99)).toBe(20); + }); + + it("marks nothing above the first prompt or without a reading position", () => { + expect(resolveActivePromptSeq(prompts, 1)).toBeNull(); + expect(resolveActivePromptSeq(prompts, null)).toBeNull(); + expect(resolveActivePromptSeq([], 42)).toBeNull(); + }); +}); + +describe("createActivePromptPublisher", () => { + it("notifies subscribers only when the active prompt changes", () => { + const publisher = createActivePromptPublisher(); + let notifications = 0; + const unsubscribe = publisher.subscribe(() => { + notifications += 1; + }); + + publisher.publish(9); + publisher.publish(9); + publisher.publish(20); + unsubscribe(); + publisher.publish(null); + + expect(notifications).toBe(2); + expect(publisher.getActiveSeq()).toBeNull(); + }); +}); diff --git a/packages/app/src/agent-stream/chat-outline/model.ts b/packages/app/src/agent-stream/chat-outline/model.ts new file mode 100644 index 000000000..3d71d52b5 --- /dev/null +++ b/packages/app/src/agent-stream/chat-outline/model.ts @@ -0,0 +1,88 @@ +import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/daemon-client"; + +export type ChatOutlinePrompt = AgentTimelinePromptIndexPayload["prompts"][number]; + +export function shouldAcceptPromptIndexEpoch( + timelineEpoch: string | null, + indexEpoch: string, +): boolean { + return timelineEpoch === null || timelineEpoch === indexEpoch; +} + +/** + * Slots further than this from the pointer keep their resting size, so a long rail + * magnifies a local band instead of swelling the whole column. + */ +export const OUTLINE_MAGNIFY_RADIUS = 3; + +/** + * Dock-style falloff: 1 under the pointer, easing to 0 at the radius. The raised cosine + * has no corner at either end, so sweeping the rail reads as one bulge travelling with + * the pointer rather than a band switching on and off. + */ +export function promptTickMagnification(slotDistance: number): number { + const distance = Math.abs(slotDistance); + if (!Number.isFinite(distance) || distance >= OUTLINE_MAGNIFY_RADIUS) { + return 0; + } + return (1 + Math.cos((Math.PI * distance) / OUTLINE_MAGNIFY_RADIUS)) / 2; +} + +/** + * The prompt whose turn the reader is inside: the last indexed prompt at or before the + * timeline position under the top of the viewport. It reads the complete daemon index, + * so a prompt outside the loaded window still lights up while its turn is on screen. + */ +export function resolveActivePromptSeq( + prompts: readonly ChatOutlinePrompt[], + anchorSeq: number | null, +): number | null { + if (anchorSeq === null) { + return null; + } + let activeSeq: number | null = null; + for (const prompt of prompts) { + if (prompt.seq > anchorSeq) { + break; + } + activeSeq = prompt.seq; + } + return activeSeq; +} + +export interface ActivePromptSource { + subscribe: (listener: () => void) => () => void; + getActiveSeq: () => number | null; +} + +export interface ActivePromptPublisher extends ActivePromptSource { + publish: (seq: number | null) => void; +} + +/** + * The transcript reports its reading position on every scroll frame, far more often than + * it re-renders. Keeping the active prompt outside React lets the rail subscribe to it + * without dragging the transcript through a render on each frame. + */ +export function createActivePromptPublisher(): ActivePromptPublisher { + const listeners = new Set<() => void>(); + let activeSeq: number | null = null; + return { + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + getActiveSeq: () => activeSeq, + publish(seq) { + if (seq === activeSeq) { + return; + } + activeSeq = seq; + for (const listener of listeners) { + listener(); + } + }, + }; +} diff --git a/packages/app/src/agent-stream/chat-outline/rail.tsx b/packages/app/src/agent-stream/chat-outline/rail.tsx new file mode 100644 index 000000000..0736ee21a --- /dev/null +++ b/packages/app/src/agent-stream/chat-outline/rail.tsx @@ -0,0 +1,13 @@ +import type { ActivePromptSource, ChatOutlinePrompt } from "./model"; + +export interface ChatOutlineRailProps { + prompts: ChatOutlinePrompt[]; + activePrompt: ActivePromptSource; + onJumpToPrompt: (seq: number) => void; +} + +// The outline is a wide-layout pointer affordance. Native navigates the transcript by +// scrolling, so there is no rail to render. +export function ChatOutlineRail(_props: ChatOutlineRailProps): null { + return null; +} diff --git a/packages/app/src/agent-stream/chat-outline/rail.web.tsx b/packages/app/src/agent-stream/chat-outline/rail.web.tsx new file mode 100644 index 000000000..99c7298bc --- /dev/null +++ b/packages/app/src/agent-stream/chat-outline/rail.web.tsx @@ -0,0 +1,255 @@ +import { memo, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { Pressable, Text, View, type PointerEvent as RNPointerEvent } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useReducedMotion } from "react-native-reanimated"; +import { useContainerWidthBelow } from "@/hooks/use-container-width"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; +import { createChatOutlineHoverIntent } from "./hover-intent"; +import { promptTickMagnification } from "./model"; +import type { ChatOutlineRailProps } from "./rail"; + +// Hover tracking lives on the rail and the slots, never on the Pressable inside them: +// magnifying a slot must not move the box the pointer is resting on. See docs/hover.md. +const RAIL_WIDTH = 36; +const SLOT_HEIGHT = 8; +const MIN_PANEL_WIDTH = 818; +const RESTING_PILL_HEIGHT = 2; +const MAGNIFIED_PILL_HEIGHT = 4; +const RESTING_PILL_WIDTH = 10; +const ACTIVE_PILL_WIDTH = 18; +const MAGNIFIED_PILL_WIDTH = 26; +const PREVIEW_WIDTH = 260; +const PREVIEW_HEIGHT = 48; +const PREVIEW_GAP = 4; + +export const ChatOutlineRail = memo(function ChatOutlineRail({ + prompts, + activePrompt, + onJumpToPrompt, +}: ChatOutlineRailProps) { + const [hoveredIndex, setHoveredIndex] = useState(null); + const [focusedIndex, setFocusedIndex] = useState(null); + const activeSeq = useSyncExternalStore(activePrompt.subscribe, activePrompt.getActiveSeq); + const prefersReducedMotion = useReducedMotion(); + const { onLayout, isBelow: isPanelNarrow } = useContainerWidthBelow(MIN_PANEL_WIDTH); + + const hoverIntent = useMemo( + () => + createChatOutlineHoverIntent({ + activate: setHoveredIndex, + schedule: (callback, delayMs) => window.setTimeout(callback, delayMs), + cancel: (timerId) => window.clearTimeout(timerId), + }), + [], + ); + const handlePointerEnterTick = useCallback( + (index: number) => hoverIntent.pointAt(index), + [hoverIntent], + ); + const handlePointerEnterRail = useCallback( + (event: RNPointerEvent) => { + hoverIntent.enter({ x: event.nativeEvent.clientX, y: event.nativeEvent.clientY }); + }, + [hoverIntent], + ); + const handlePointerMoveRail = useCallback( + (event: RNPointerEvent) => { + hoverIntent.move({ x: event.nativeEvent.clientX, y: event.nativeEvent.clientY }); + }, + [hoverIntent], + ); + const handlePointerLeaveRail = useCallback(() => hoverIntent.leave(), [hoverIntent]); + useEffect(() => () => hoverIntent.dispose(), [hoverIntent]); + useEffect(() => { + if (isPanelNarrow) hoverIntent.leave(); + }, [hoverIntent, isPanelNarrow]); + const handleFocusChange = useCallback((index: number, focused: boolean) => { + setFocusedIndex((current) => { + if (focused) return index; + return current === index ? null : current; + }); + }, []); + + // The pointer owns the magnified band while it is on the rail; keyboard focus drives the + // same band and the same preview once the pointer leaves. + const attentionIndex = hoveredIndex ?? focusedIndex; + + if (prompts.length < 2) return null; + + return ( + + {isPanelNarrow ? null : ( + + {prompts.map((prompt, index) => ( + + ))} + + )} + + ); +}); + +interface ChatOutlineTickProps { + index: number; + seq: number; + preview: string; + label: string; + isActive: boolean; + hasAttention: boolean; + magnification: number; + onHover: (index: number) => void; + onFocusChange: (index: number, focused: boolean) => void; + onJumpToPrompt: (seq: number) => void; +} + +const ChatOutlineTick = memo(function ChatOutlineTick({ + index, + seq, + preview, + label, + isActive, + hasAttention, + magnification, + onHover, + onFocusChange, + onJumpToPrompt, +}: ChatOutlineTickProps) { + const handlePress = useCallback(() => { + onJumpToPrompt(seq); + onFocusChange(index, false); + }, [index, onFocusChange, onJumpToPrompt, seq]); + const handlePointerEnter = useCallback(() => onHover(index), [index, onHover]); + const handleFocus = useCallback(() => onFocusChange(index, true), [index, onFocusChange]); + const handleBlur = useCallback(() => onFocusChange(index, false), [index, onFocusChange]); + + // The pill grows inside a slot that never moves, so magnification can never pull the hit + // target out from under the pointer. + const restingWidth = isActive ? ACTIVE_PILL_WIDTH : RESTING_PILL_WIDTH; + const pillWidth = restingWidth + magnification * (MAGNIFIED_PILL_WIDTH - restingWidth); + const pillHeight = + RESTING_PILL_HEIGHT + magnification * (MAGNIFIED_PILL_HEIGHT - RESTING_PILL_HEIGHT); + + return ( + + + + + {hasAttention ? ( + + + {preview} + + + ) : null} + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + panelMeasure: { + position: "absolute", + inset: 0, + }, + rail: { + position: "absolute", + left: theme.spacing[2], + top: "10%", + bottom: "10%", + width: RAIL_WIDTH, + alignItems: "center", + justifyContent: "center", + zIndex: 2, + }, + // Slots tile the rail with no gaps, so every pixel of the column belongs to a prompt even + // when a long conversation squeezes them well below their resting height. + slot: { + width: RAIL_WIDTH, + flexBasis: SLOT_HEIGHT, + flexShrink: 1, + alignItems: "center", + justifyContent: "center", + }, + // Pills share one left rail and grow rightward, so magnification reads as a bulge moving + // with the pointer instead of every tick breathing about its own centre. + target: { + width: "100%", + height: "100%", + alignItems: "flex-start", + justifyContent: "center", + paddingLeft: theme.spacing[1], + borderRadius: theme.borderRadius.base, + }, + // At rest the pills use low-emphasis chrome so the column stays readable peripherally + // without competing with the transcript. Attention is the only foreground state. + pill: { + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.borderAccent, + transitionProperty: "width, height, background-color", + transitionDuration: "140ms", + transitionTimingFunction: "ease-out", + }, + pillActive: { + backgroundColor: theme.colors.foregroundExtraMuted, + }, + pillAttention: { + backgroundColor: theme.colors.foreground, + }, + preview: { + position: "absolute", + left: RAIL_WIDTH + PREVIEW_GAP, + top: "50%", + marginTop: -PREVIEW_HEIGHT / 2, + width: PREVIEW_WIDTH, + height: PREVIEW_HEIGHT, + justifyContent: "center", + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface2, + ...theme.shadow.md, + }, + previewText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx new file mode 100644 index 000000000..28d836137 --- /dev/null +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx @@ -0,0 +1,177 @@ +// @vitest-environment jsdom + +import { act, renderHook, waitFor } from "@testing-library/react"; +import { createRef } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { StreamViewportHandle } from "../strategy"; +import { useChatOutline } from "./use-chat-outline"; + +interface RefreshMessage { + type: "agent_stream"; + payload: { + agentId: string; + event: { type: "timeline"; item: { type: "user_message" } }; + }; +} + +const runtime = vi.hoisted(() => ({ + listAgentTimelinePrompts: vi.fn(), + fetchAgentTimeline: vi.fn(), + on: vi.fn<(event: string, listener: (message: RefreshMessage) => void) => () => void>( + () => () => undefined, + ), +})); + +vi.mock("@/constants/platform", () => ({ isWeb: true })); +vi.mock("@/runtime/host-runtime", () => ({ + getHostRuntimeStore: () => ({ + getClient: () => runtime, + fetchAgentTimeline: runtime.fetchAgentTimeline, + }), +})); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +describe("useChatOutline", () => { + beforeEach(() => { + runtime.listAgentTimelinePrompts.mockReset(); + runtime.fetchAgentTimeline.mockReset(); + runtime.on.mockClear(); + }); + + it("drops a late prompt index after the authoritative timeline epoch changes", async () => { + const first = deferred<{ epoch: string; prompts: [] }>(); + const second = deferred<{ + epoch: string; + prompts: Array<{ seq: number; timestamp: string; preview: string }>; + }>(); + runtime.listAgentTimelinePrompts + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const viewportRef = createRef(); + const { result, rerender } = renderHook( + ({ timelineEpoch }) => + useChatOutline({ + agentId: "agent-1", + serverId: "server-1", + timelineEpoch, + tail: [], + head: [], + enabled: true, + viewportRef, + onJumpError: vi.fn(), + }), + { initialProps: { timelineEpoch: "epoch-1" } }, + ); + await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(1)); + + rerender({ timelineEpoch: "epoch-2" }); + await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(2)); + await act(async () => first.resolve({ epoch: "epoch-1", prompts: [] })); + expect(result.current.prompts).toEqual([]); + + await act(async () => + second.resolve({ + epoch: "epoch-2", + prompts: [{ seq: 2, timestamp: new Date(2).toISOString(), preview: "current prompt" }], + }), + ); + await waitFor(() => { + expect(result.current.prompts).toHaveLength(1); + }); + expect(result.current.prompts[0]?.seq).toBe(2); + }); + + it("keeps the newest prompt index response within one epoch", async () => { + const older = deferred<{ + epoch: string; + prompts: Array<{ seq: number; timestamp: string; preview: string }>; + }>(); + const newer = deferred<{ + epoch: string; + prompts: Array<{ seq: number; timestamp: string; preview: string }>; + }>(); + runtime.listAgentTimelinePrompts + .mockResolvedValueOnce({ epoch: "epoch-1", prompts: [] }) + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise); + const viewportRef = createRef(); + const { result } = renderHook(() => + useChatOutline({ + agentId: "agent-1", + serverId: "server-1", + timelineEpoch: "epoch-1", + tail: [], + head: [], + enabled: true, + viewportRef, + onJumpError: vi.fn(), + }), + ); + await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(1)); + const refresh = runtime.on.mock.calls[0]?.[1]; + await act(async () => { + refresh?.({ + type: "agent_stream", + payload: { + agentId: "agent-1", + event: { type: "timeline", item: { type: "user_message" } }, + }, + }); + refresh?.({ + type: "agent_stream", + payload: { + agentId: "agent-1", + event: { type: "timeline", item: { type: "user_message" } }, + }, + }); + }); + await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(3)); + await act(async () => + newer.resolve({ + epoch: "epoch-1", + prompts: [{ seq: 3, timestamp: new Date(3).toISOString(), preview: "newer" }], + }), + ); + await act(async () => + older.resolve({ + epoch: "epoch-1", + prompts: [{ seq: 2, timestamp: new Date(2).toISOString(), preview: "older" }], + }), + ); + + expect(result.current.prompts.map((prompt) => prompt.seq)).toEqual([3]); + }); + + it("reports a failed unloaded prompt jump", async () => { + runtime.listAgentTimelinePrompts.mockResolvedValue({ + epoch: "epoch-1", + prompts: [{ seq: 1, timestamp: new Date(1).toISOString(), preview: "prompt" }], + }); + runtime.fetchAgentTimeline.mockRejectedValue(new Error("disconnected")); + const onJumpError = vi.fn(); + const viewportRef = createRef(); + const { result } = renderHook(() => + useChatOutline({ + agentId: "agent-1", + serverId: "server-1", + timelineEpoch: "epoch-1", + tail: [], + head: [], + enabled: true, + viewportRef, + onJumpError, + }), + ); + await waitFor(() => expect(result.current.prompts).toHaveLength(1)); + await act(async () => result.current.jumpToPrompt(1)); + + await waitFor(() => expect(onJumpError).toHaveBeenCalledOnce()); + }); +}); diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts new file mode 100644 index 000000000..96f780e7c --- /dev/null +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts @@ -0,0 +1,178 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/daemon-client"; +import { isWeb } from "@/constants/platform"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { planTimelinePromptJump } from "@/timeline/timeline-sync-plan"; +import type { StreamItem } from "@/types/stream"; +import type { StreamViewportHandle } from "../strategy"; +import { + createActivePromptPublisher, + resolveActivePromptSeq, + shouldAcceptPromptIndexEpoch, + type ActivePromptSource, + type ChatOutlinePrompt, +} from "./model"; + +const NO_PROMPTS: ChatOutlinePrompt[] = []; +const NO_STREAM_ITEMS: StreamItem[] = []; + +interface PendingPromptJump { + requestId: number; + seq: number; + fetchSettled: boolean; + hasScrolled: boolean; +} + +export interface UseChatOutlineInput { + agentId: string; + serverId: string; + timelineEpoch: string | null; + tail: StreamItem[]; + head: StreamItem[] | undefined; + enabled: boolean; + viewportRef: RefObject; + onJumpError: () => void; +} + +export interface ChatOutline { + prompts: ChatOutlinePrompt[]; + activePrompt: ActivePromptSource; + jumpToPrompt: (seq: number) => void; + reportReadingPosition: (rowId: string | null) => void; +} + +export function useChatOutline({ + agentId, + serverId, + timelineEpoch, + tail, + head, + enabled, + viewportRef, + onJumpError, +}: UseChatOutlineInput): ChatOutline { + const [index, setIndex] = useState(null); + const [pendingJump, setPendingJump] = useState(null); + const [activePrompt] = useState(createActivePromptPublisher); + const readingRowIdRef = useRef(null); + const nextJumpRequestIdRef = useRef(0); + const nextIndexRequestIdRef = useRef(0); + const loadedItems = useMemo(() => [...tail, ...(head ?? NO_STREAM_ITEMS)], [head, tail]); + const prompts = enabled ? (index?.prompts ?? NO_PROMPTS) : NO_PROMPTS; + + useEffect(() => { + if (!isWeb || !enabled) { + setIndex(null); + return; + } + setIndex(null); + const client = getHostRuntimeStore().getClient(serverId); + if (!client) return; + let active = true; + const refresh = () => { + const requestId = ++nextIndexRequestIdRef.current; + void client + .listAgentTimelinePrompts(agentId) + .then((payload) => { + if ( + active && + requestId === nextIndexRequestIdRef.current && + shouldAcceptPromptIndexEpoch(timelineEpoch, payload.epoch) + ) { + setIndex(payload); + } + return undefined; + }) + .catch(() => undefined); + }; + refresh(); + const unsubscribe = client.on("agent_stream", (message) => { + if ( + message.type === "agent_stream" && + message.payload.agentId === agentId && + message.payload.event.type === "timeline" && + message.payload.event.item.type === "user_message" + ) { + refresh(); + } + }); + return () => { + active = false; + unsubscribe(); + }; + }, [agentId, enabled, serverId, timelineEpoch]); + + // The transcript names the row it is showing; the outline turns that into a prompt using the + // complete index, so unloaded rows never have to exist in the DOM to be marked. + const publishActivePrompt = useStableEvent(() => { + const rowId = readingRowIdRef.current; + const anchorSeq = + rowId === null + ? null + : (loadedItems.find((item) => item.id === rowId)?.timelineCursor?.seq ?? null); + activePrompt.publish(resolveActivePromptSeq(prompts, anchorSeq)); + }); + + const reportReadingPosition = useStableEvent((rowId: string | null) => { + readingRowIdRef.current = rowId; + publishActivePrompt(); + }); + + useEffect(() => { + nextJumpRequestIdRef.current += 1; + setPendingJump(null); + readingRowIdRef.current = null; + activePrompt.publish(null); + }, [activePrompt, agentId, timelineEpoch]); + + // The transcript reports its reading position long before the index arrives, and a reader + // who never scrolls would otherwise sit on an unmarked rail. + useEffect(() => { + publishActivePrompt(); + }, [loadedItems, prompts, publishActivePrompt]); + + useEffect(() => { + if (pendingJump === null) return; + const target = loadedItems.find((item) => item.timelineCursor?.seq === pendingJump.seq); + if (target && !pendingJump.hasScrolled) { + viewportRef.current?.scrollToMessage?.(target.id); + setPendingJump((current) => { + if (current?.requestId !== pendingJump.requestId) return current; + return { ...current, hasScrolled: true }; + }); + return; + } + if (pendingJump.fetchSettled) setPendingJump(null); + }, [loadedItems, pendingJump, viewportRef]); + + const jumpToPrompt = useCallback( + (seq: number) => { + nextJumpRequestIdRef.current += 1; + setPendingJump(null); + const loaded = loadedItems.find((item) => item.timelineCursor?.seq === seq); + if (loaded) { + viewportRef.current?.scrollToMessage?.(loaded.id); + return; + } + if (!index) return; + const requestId = nextJumpRequestIdRef.current; + setPendingJump({ requestId, seq, fetchSettled: false, hasScrolled: false }); + void getHostRuntimeStore() + .fetchAgentTimeline(serverId, agentId, planTimelinePromptJump({ epoch: index.epoch, seq })) + .catch((error: unknown) => { + console.warn("Failed to load a Chat outline window", error); + onJumpError(); + }) + .finally(() => { + setPendingJump((current) => { + if (current?.requestId !== requestId) return current; + return { ...current, fetchSettled: true }; + }); + }); + }, + [agentId, index, loadedItems, onJumpError, serverId, viewportRef], + ); + + return { prompts, activePrompt, jumpToPrompt, reportReadingPosition }; +} diff --git a/packages/app/src/agent-stream/prompt-jump-settle.test.ts b/packages/app/src/agent-stream/prompt-jump-settle.test.ts new file mode 100644 index 000000000..322d52882 --- /dev/null +++ b/packages/app/src/agent-stream/prompt-jump-settle.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; +import { + createPromptJumpSettleController, + type PromptJumpSettleViewport, +} from "./prompt-jump-settle"; + +interface FakeFrameScheduler { + requestFrame(callback: () => void): number; + cancelFrame(frameId: number): void; + runNextFrame(): void; + pendingFrames(): number; +} + +interface FakeViewport extends PromptJumpSettleViewport { + emitUserIntent(): void; + setTargetTop(itemId: string, top: number | null): void; + scrollWrites(): number[]; + requestedTargets(): string[]; +} + +function createFakeFrameScheduler(): FakeFrameScheduler { + const frames = new Map void>(); + let nextFrameId = 1; + return { + requestFrame(callback) { + const frameId = nextFrameId; + nextFrameId += 1; + frames.set(frameId, callback); + return frameId; + }, + cancelFrame(frameId) { + frames.delete(frameId); + }, + runNextFrame() { + const next = frames.entries().next().value; + if (!next) { + throw new Error("Expected a pending prompt jump frame"); + } + const [frameId, callback] = next; + frames.delete(frameId); + callback(); + }, + pendingFrames() { + return frames.size; + }, + }; +} + +function createFakeViewport(input?: { + clientHeight?: number; + scrollHeight?: number; + scrollTop?: number; +}): FakeViewport { + const targets = new Map(); + const requestedTargets: string[] = []; + const scrollWrites: number[] = []; + const userIntentListeners = new Set<() => void>(); + const clientHeight = input?.clientHeight ?? 100; + const scrollHeight = input?.scrollHeight ?? 1_000; + let scrollTop = input?.scrollTop ?? 100; + return { + findTargetTop(itemId) { + requestedTargets.push(itemId); + return targets.get(itemId) ?? null; + }, + getContainerTop() { + return 0; + }, + getScrollMetrics() { + return { clientHeight, scrollHeight, scrollTop }; + }, + setScrollTop(nextScrollTop) { + const previousScrollTop = scrollTop; + const maxScrollTop = scrollHeight - clientHeight; + scrollTop = Math.max(0, Math.min(nextScrollTop, maxScrollTop)); + const appliedDelta = scrollTop - previousScrollTop; + for (const [itemId, top] of targets) { + targets.set(itemId, top - appliedDelta); + } + scrollWrites.push(scrollTop); + }, + subscribeToUserIntent(listener) { + userIntentListeners.add(listener); + return () => userIntentListeners.delete(listener); + }, + emitUserIntent() { + for (const listener of userIntentListeners) listener(); + }, + setTargetTop(itemId, top) { + if (top === null) { + targets.delete(itemId); + } else { + targets.set(itemId, top); + } + }, + scrollWrites() { + return scrollWrites; + }, + requestedTargets() { + return requestedTargets; + }, + }; +} + +function createController(viewport: FakeViewport, frames: FakeFrameScheduler) { + return createPromptJumpSettleController({ + viewport, + requestFrame: frames.requestFrame, + cancelFrame: frames.cancelFrame, + }); +} + +describe("prompt jump settle", () => { + it("re-pins the target when geometry shifts between frames", () => { + const frames = createFakeFrameScheduler(); + const viewport = createFakeViewport(); + const controller = createController(viewport, frames); + viewport.setTargetTop("target", 48); + + controller.start("target"); + frames.runNextFrame(); + viewport.setTargetTop("target", 28); + frames.runNextFrame(); + + expect(viewport.scrollWrites()).toEqual([140, 160]); + }); + + it("succeeds after two consecutive in-position frames", () => { + const frames = createFakeFrameScheduler(); + const viewport = createFakeViewport(); + const controller = createController(viewport, frames); + viewport.setTargetTop("target", 8); + + controller.start("target"); + frames.runNextFrame(); + frames.runNextFrame(); + + expect(controller.isActive()).toBe(false); + }); + + it("succeeds when the target remains below the top at maximum scroll", () => { + const frames = createFakeFrameScheduler(); + const viewport = createFakeViewport({ clientHeight: 100, scrollHeight: 220, scrollTop: 100 }); + const controller = createController(viewport, frames); + viewport.setTargetTop("target", 50); + + controller.start("target"); + frames.runNextFrame(); + + expect({ active: controller.isActive(), writes: viewport.scrollWrites() }).toEqual({ + active: false, + writes: [120], + }); + }); + + it("aborts on user intent", () => { + const frames = createFakeFrameScheduler(); + const viewport = createFakeViewport(); + const controller = createController(viewport, frames); + + controller.start("target"); + viewport.emitUserIntent(); + + expect({ active: controller.isActive(), frames: frames.pendingFrames() }).toEqual({ + active: false, + frames: 0, + }); + }); + + it("expires after its frame budget", () => { + const frames = createFakeFrameScheduler(); + const viewport = createFakeViewport(); + const controller = createController(viewport, frames); + + controller.start("missing"); + for (let frame = 0; frame < 24; frame += 1) frames.runNextFrame(); + + expect(controller.isActive()).toBe(false); + }); + + it("cancels the previous target when a newer jump starts", () => { + const frames = createFakeFrameScheduler(); + const viewport = createFakeViewport(); + const controller = createController(viewport, frames); + viewport.setTargetTop("new", 8); + + controller.start("old"); + controller.start("new"); + frames.runNextFrame(); + + expect(viewport.requestedTargets()).toEqual(["new"]); + }); +}); diff --git a/packages/app/src/agent-stream/prompt-jump-settle.ts b/packages/app/src/agent-stream/prompt-jump-settle.ts new file mode 100644 index 000000000..1ef5e1f7e --- /dev/null +++ b/packages/app/src/agent-stream/prompt-jump-settle.ts @@ -0,0 +1,111 @@ +interface PromptJumpScrollMetrics { + clientHeight: number; + scrollHeight: number; + scrollTop: number; +} + +export interface PromptJumpSettleViewport { + findTargetTop(itemId: string): number | null; + getContainerTop(): number; + getScrollMetrics(): PromptJumpScrollMetrics; + setScrollTop(scrollTop: number): void; + subscribeToUserIntent(listener: () => void): () => void; +} + +export interface PromptJumpSettleController { + start(itemId: string): void; + cancel(): void; + isActive(): boolean; +} + +const POSITION_TOLERANCE_PX = 1; +const REQUIRED_STABLE_FRAMES = 2; +const FRAME_BUDGET = 24; +export const PROMPT_JUMP_TOP_INSET_PX = 8; + +function distanceFromLandingPosition(targetTop: number, containerTop: number): number { + return targetTop - containerTop - PROMPT_JUMP_TOP_INSET_PX; +} + +export function createPromptJumpSettleController(input: { + viewport: PromptJumpSettleViewport; + requestFrame: (callback: () => void) => number; + cancelFrame: (frameId: number) => void; +}): PromptJumpSettleController { + let activeItemId: string | null = null; + let frameId: number | null = null; + let sampledFrames = 0; + let stableFrames = 0; + let unsubscribeFromUserIntent: (() => void) | null = null; + + function finish(): void { + if (frameId !== null) input.cancelFrame(frameId); + unsubscribeFromUserIntent?.(); + activeItemId = null; + frameId = null; + sampledFrames = 0; + stableFrames = 0; + unsubscribeFromUserIntent = null; + } + + function scheduleNextFrame(): void { + frameId = input.requestFrame(tick); + } + + function tick(): void { + frameId = null; + const itemId = activeItemId; + if (itemId === null) return; + + sampledFrames += 1; + const targetTop = input.viewport.findTargetTop(itemId); + if (targetTop !== null) { + const delta = distanceFromLandingPosition(targetTop, input.viewport.getContainerTop()); + if (Math.abs(delta) <= POSITION_TOLERANCE_PX) { + stableFrames += 1; + if (stableFrames >= REQUIRED_STABLE_FRAMES) { + finish(); + return; + } + } else { + stableFrames = 0; + const before = input.viewport.getScrollMetrics(); + input.viewport.setScrollTop(before.scrollTop + delta); + const after = input.viewport.getScrollMetrics(); + const adjustedTargetTop = input.viewport.findTargetTop(itemId); + let remainingDelta = 0; + if (adjustedTargetTop !== null) { + remainingDelta = distanceFromLandingPosition( + adjustedTargetTop, + input.viewport.getContainerTop(), + ); + } + const maxScrollTop = Math.max(0, after.scrollHeight - after.clientHeight); + const isClampedAtBottom = after.scrollTop >= maxScrollTop - POSITION_TOLERANCE_PX; + if (remainingDelta > POSITION_TOLERANCE_PX && isClampedAtBottom) { + finish(); + return; + } + } + } + + if (sampledFrames >= FRAME_BUDGET) { + finish(); + return; + } + scheduleNextFrame(); + } + + return { + start(itemId) { + finish(); + activeItemId = itemId; + unsubscribeFromUserIntent = input.viewport.subscribeToUserIntent(finish); + scheduleNextFrame(); + }, + cancel: finish, + isActive() { + return activeItemId !== null; + }, + }; +} diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index c9035fc68..dd2f48271 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -201,6 +201,50 @@ describe("createWebStreamStrategy", () => { expect(renderLiveHeadRow).toHaveBeenCalledTimes(2); }); + it("reports the live-head row as the reading position", () => { + const strategy = createWebStreamStrategy({ isMobileBreakpoint: false }); + const viewportRef = React.createRef(); + const onReadingPositionChange = vi.fn(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render( + strategy.render({ + agentId: "agent", + segments: { + historyVirtualized: [], + historyMounted: [userMessage(1)], + liveHead: [userMessage(2)], + }, + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: true, + hasLiveHead: true, + }, + renderers: createRenderers(vi.fn()), + listEmptyComponent: null, + viewportRef, + routeBottomAnchorRequest: null, + isAuthoritativeHistoryReady: true, + onNearBottomChange: vi.fn(), + onReadingPositionChange, + onNearHistoryStart: vi.fn().mockReturnValue(true), + isLoadingOlderHistory: false, + hasOlderHistory: false, + olderHistoryProgressKey: null, + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + }), + ); + }); + + expect(onReadingPositionChange).toHaveBeenLastCalledWith("message-2"); + }); + it("keeps bottom anchoring through subpixel browser rounding", () => { const scrollTo = vi.fn(); HTMLElement.prototype.scrollTo = scrollTo; diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 5faf2112d..971d2bc5b 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -1,5 +1,4 @@ import React, { - Fragment, type CSSProperties, useCallback, useEffect, @@ -30,6 +29,7 @@ import { createHistoryStartSettleScheduler, type HistoryStartSettleScheduler, } from "./history-start-settle-scheduler"; +import { useScrollToMessage } from "./use-scroll-to-message.web"; interface CreateWebStreamStrategyInput { isMobileBreakpoint: boolean; @@ -49,6 +49,12 @@ const BOTTOM_OVERSCROLL_TOLERANCE_PX = 2; const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64; const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1; const HISTORY_START_SETTLE_FRAMES = 2; +const HISTORY_START_SLOT_HEIGHT_PX = 32; +const CONTENT_PADDING_TOP_PX = 16; +const VIRTUALIZER_SCROLL_MARGIN_PX = HISTORY_START_SLOT_HEIGHT_PX + CONTENT_PADDING_TOP_PX; +// A row has to clear this much of the viewport top before the next one takes over as the +// reading position, so a row resting exactly on the edge does not flip back and forth. +const READING_POSITION_OFFSET_PX = 8; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const foregroundMutedColorMapping = (theme: Theme) => ({ @@ -68,11 +74,11 @@ const historyStartSlotStyle: CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center", - height: 32, + height: HISTORY_START_SLOT_HEIGHT_PX, flexShrink: 0, }; -const mountedHistoryRowStyle: CSSProperties = { +const streamRowStyle: CSSProperties = { display: "flex", flexDirection: "column", width: "100%", @@ -131,7 +137,6 @@ function getScrollContainerDistanceFromBottom( function isScrollContainerOverscrolledPastBottom( scrollContainer: Pick, ): boolean { - // Browser zoom can leave scrollTop fractional while the height metrics remain integer-valued. return getScrollContainerDistanceFromBottom(scrollContainer) < -BOTTOM_OVERSCROLL_TOLERANCE_PX; } @@ -146,6 +151,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool routeBottomAnchorRequest, isAuthoritativeHistoryReady, onNearBottomChange, + onReadingPositionChange, onNearHistoryStart, isLoadingOlderHistory, hasOlderHistory, @@ -206,6 +212,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return row ? estimateStreamItemHeight(row) : 120; }, measureElement: measureVirtualElement, + scrollMargin: VIRTUALIZER_SCROLL_MARGIN_PX, useAnimationFrameWithResizeObserver: true, overscan: 8, }); @@ -467,6 +474,33 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scheduleStickToBottom(); }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]); + // Rows are laid out in DOM order, virtualized block first, so the first row whose bottom + // clears the reading line is the one the reader is looking at. + const reportReadingPosition = useStableEvent(() => { + if (!onReadingPositionChange) { + return; + } + const scrollContainer = scrollContainerRef.current; + const contentNode = contentRef.current; + if (!scrollContainer || !contentNode) { + onReadingPositionChange(null); + return; + } + const readingLine = scrollContainer.getBoundingClientRect().top + READING_POSITION_OFFSET_PX; + let readingRowId: string | null = null; + for (const element of contentNode.querySelectorAll("[data-history-row-id]")) { + const rowId = element.dataset.historyRowId; + if (!rowId) { + continue; + } + readingRowId = rowId; + if (element.getBoundingClientRect().bottom > readingLine) { + break; + } + } + onReadingPositionChange(readingRowId); + }); + const updateScrollMetrics = useCallback(() => { const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { @@ -474,7 +508,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return; } syncNearBottom(scrollContainer, onNearBottomChange); - }, [onNearBottomChange]); + reportReadingPosition(); + }, [onNearBottomChange, reportReadingPosition]); + + const { isJumpSettling, scrollToMessage } = useScrollToMessage({ + scrollContainerRef, + rowVirtualizer, + historyVirtualized: segments.historyVirtualized, + cancelPendingStickToBottom, + setFollowOutput, + onNearBottomChange, + }); const handleDomScroll = useCallback(() => { const scrollContainer = scrollContainerRef.current; @@ -495,7 +539,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } pendingUserScrollUpIntentRef.current = false; rearmHistoryStartFromUserIntent(); - } else if (!followOutputRef.current && isAtBottom && scrolledDown) { + } else if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) { setFollowOutput(true); pendingUserScrollUpIntentRef.current = false; } else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) { @@ -512,6 +556,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }, [ cancelPendingStickToBottom, evaluateHistoryStart, + isJumpSettling, rearmHistoryStartFromUserIntent, updateScrollMetrics, ]); @@ -709,6 +754,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } scheduleStickToBottom(); }, + scrollToMessage, }; viewportRef.current = handle; return () => { @@ -717,14 +763,20 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } cancelPendingStickToBottom(); }; - }, [cancelPendingStickToBottom, forceStickToBottom, scheduleStickToBottom, viewportRef]); + }, [ + cancelPendingStickToBottom, + forceStickToBottom, + scheduleStickToBottom, + scrollToMessage, + viewportRef, + ]); const contentContainerStyle = useMemo((): CSSProperties => { return { display: "flex", flexDirection: "column", minHeight: "100%", - paddingTop: 16, + paddingTop: CONTENT_PADDING_TOP_PX, paddingBottom: 16, paddingLeft: isMobileBreakpoint ? 8 : 16, paddingRight: isMobileBreakpoint ? 8 : 16, @@ -737,6 +789,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool minHeight: 0, overflowX: "hidden", overflowY: scrollEnabled ? "auto" : "hidden", + overflowAnchor: "none", overscrollBehaviorY: "contain", }; }, [scrollEnabled]); @@ -755,13 +808,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool display: "flex", flexDirection: "column", width: "100%", - transform: `translateY(${start}px)`, + transform: `translateY(${start - VIRTUALIZER_SCROLL_MARGIN_PX}px)`, }), [], ); const mountedHistoryRows = useMemo(() => { return segments.historyMounted.map((item, index) => ( -
+
{renderHistoryMountedRow(item, index, segments.historyMounted)}
)); @@ -769,7 +822,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const liveHeadRows = useMemo(() => { void liveHeadRowRevision; return segments.liveHead.map((item, index) => ( - {renderLiveHeadRow(item, index, segments.liveHead)} +
+ {renderLiveHeadRow(item, index, segments.liveHead)} +
)); }, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]); const liveAuxiliary = useMemo(() => { diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 15836f623..4077bb025 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -42,6 +42,7 @@ export interface StreamEdgeSlotProps { export interface StreamViewportHandle { scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void; prepareForViewportChange: () => void; + scrollToMessage?: (itemId: string) => void; } export interface StreamSegmentRenderers { @@ -69,6 +70,9 @@ export interface StreamRenderInput { routeBottomAnchorRequest: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady: boolean; onNearBottomChange: (value: boolean) => void; + // The history row under the top of the viewport, for surfaces that mark where the reader + // is in the transcript. Only the web viewport measures it today. + onReadingPositionChange?: (rowId: string | null) => void; onNearHistoryStart: () => boolean | Promise; isLoadingOlderHistory: boolean; hasOlderHistory: boolean; diff --git a/packages/app/src/agent-stream/timeline-tail-navigation.test.ts b/packages/app/src/agent-stream/timeline-tail-navigation.test.ts new file mode 100644 index 000000000..f905fc0bb --- /dev/null +++ b/packages/app/src/agent-stream/timeline-tail-navigation.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from "vitest"; +import { returnToTimelineTail } from "./timeline-tail-navigation"; + +describe("returnToTimelineTail", () => { + it("reports a failed fetch without scrolling or rejecting", async () => { + const scrollToBottom = vi.fn(); + const onError = vi.fn(); + const logError = vi.fn(); + + await expect( + returnToTimelineTail({ + fetchTail: () => Promise.reject(new Error("disconnected")), + scrollToBottom, + onError, + logError, + }).then(() => ({ + scrolled: scrollToBottom.mock.calls.length, + errors: onError.mock.calls.length, + warnings: logError.mock.calls.length, + })), + ).resolves.toEqual({ scrolled: 0, errors: 1, warnings: 1 }); + }); +}); diff --git a/packages/app/src/agent-stream/timeline-tail-navigation.ts b/packages/app/src/agent-stream/timeline-tail-navigation.ts new file mode 100644 index 000000000..99a7c7d7e --- /dev/null +++ b/packages/app/src/agent-stream/timeline-tail-navigation.ts @@ -0,0 +1,14 @@ +export async function returnToTimelineTail(input: { + fetchTail: () => Promise; + scrollToBottom: () => void; + onError: () => void; + logError?: (message: string, error: unknown) => void; +}): Promise { + try { + await input.fetchTail(); + input.scrollToBottom(); + } catch (error) { + (input.logError ?? console.warn)("Failed to return to the live timeline tail", error); + input.onError(); + } +} diff --git a/packages/app/src/agent-stream/use-scroll-to-message.web.ts b/packages/app/src/agent-stream/use-scroll-to-message.web.ts new file mode 100644 index 000000000..aec7719ff --- /dev/null +++ b/packages/app/src/agent-stream/use-scroll-to-message.web.ts @@ -0,0 +1,126 @@ +import type React from "react"; +import { useCallback, useEffect, useMemo } from "react"; +import type { Virtualizer } from "@tanstack/react-virtual"; +import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle"; + +interface UseScrollToMessageInput { + scrollContainerRef: React.RefObject; + rowVirtualizer: Virtualizer; + historyVirtualized: readonly { id: string }[]; + cancelPendingStickToBottom: () => void; + setFollowOutput: (value: boolean) => boolean; + onNearBottomChange: (value: boolean) => void; +} + +const SCROLL_AFFECTING_KEYS = new Set([ + "ArrowDown", + "ArrowUp", + "End", + "Home", + "PageDown", + "PageUp", + " ", +]); + +export function useScrollToMessage({ + scrollContainerRef, + rowVirtualizer, + historyVirtualized, + cancelPendingStickToBottom, + setFollowOutput, + onNearBottomChange, +}: UseScrollToMessageInput) { + const settleController = useMemo( + () => + createPromptJumpSettleController({ + viewport: { + findTargetTop(itemId) { + const container = scrollContainerRef.current; + const target = container?.querySelector( + `[data-history-row-id="${CSS.escape(itemId)}"]`, + ); + return target?.getBoundingClientRect().top ?? null; + }, + getContainerTop() { + return scrollContainerRef.current?.getBoundingClientRect().top ?? 0; + }, + getScrollMetrics() { + const container = scrollContainerRef.current; + return { + clientHeight: container?.clientHeight ?? 0, + scrollHeight: container?.scrollHeight ?? 0, + scrollTop: container?.scrollTop ?? 0, + }; + }, + setScrollTop(scrollTop) { + const container = scrollContainerRef.current; + if (container) container.scrollTop = scrollTop; + }, + subscribeToUserIntent(listener) { + const container = scrollContainerRef.current; + if (!container) return () => undefined; + const handleKeyDown = (event: KeyboardEvent) => { + if (SCROLL_AFFECTING_KEYS.has(event.key)) listener(); + }; + container.addEventListener("wheel", listener, { passive: true }); + container.addEventListener("touchstart", listener, { passive: true }); + container.addEventListener("keydown", handleKeyDown, { passive: true }); + return () => { + container.removeEventListener("wheel", listener); + container.removeEventListener("touchstart", listener); + container.removeEventListener("keydown", handleKeyDown); + }; + }, + }, + requestFrame: (callback) => window.requestAnimationFrame(callback), + cancelFrame: (frameId) => window.cancelAnimationFrame(frameId), + }), + [scrollContainerRef], + ); + + useEffect(() => () => settleController.cancel(), [settleController]); + + const scrollToMessage = useCallback( + (itemId: string) => { + const container = scrollContainerRef.current; + if (!container) return; + cancelPendingStickToBottom(); + setFollowOutput(false); + + const mounted = container.querySelector( + `[data-history-row-id="${CSS.escape(itemId)}"]`, + ); + if (mounted) { + const delta = + mounted.getBoundingClientRect().top - + container.getBoundingClientRect().top - + PROMPT_JUMP_TOP_INSET_PX; + container.scrollTop += delta; + settleController.start(itemId); + onNearBottomChange(false); + return; + } + + const index = historyVirtualized.findIndex((item) => item.id === itemId); + if (index >= 0) { + rowVirtualizer.scrollToIndex(index, { align: "start" }); + settleController.start(itemId); + onNearBottomChange(false); + } + }, + [ + cancelPendingStickToBottom, + historyVirtualized, + onNearBottomChange, + rowVirtualizer, + scrollContainerRef, + settleController, + setFollowOutput, + ], + ); + + return { + isJumpSettling: settleController.isActive, + scrollToMessage, + }; +} diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 8454b1b93..980925dc2 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -54,6 +54,7 @@ import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history"; import { useSettings } from "@/hooks/use-settings"; import type { ToastApi } from "@/components/toast-host"; +import { returnToTimelineTail } from "./timeline-tail-navigation"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { ToolCallDetailsContent } from "@/components/tool-call-details"; import { QuestionFormCard } from "@/components/question-form-card"; @@ -66,6 +67,10 @@ import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/vi import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model"; import { resolveStreamRenderStrategy } from "./strategy-resolver"; import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy"; +import { ChatOutlineRail } from "@/agent-stream/chat-outline/rail"; +import { useChatOutline } from "@/agent-stream/chat-outline/use-chat-outline"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { CompletedTurnFooterRow, TurnFooter, @@ -155,7 +160,9 @@ function renderStreamItemWithTurnFooter(input: { /> ) : null; const content = ( - {input.content} + + {input.content} + ); if (input.layoutItem.frameOrder === "footer-then-content") { @@ -287,6 +294,7 @@ const AgentStreamViewComponent = forwardRef settings.autoExpandReasoning); const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel); + const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled); const viewportRef = useRef(null); const pendingClientMessageIds = useMemo( () => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)), @@ -324,6 +332,16 @@ const AgentStreamViewComponent = forwardRef state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContextCursor === true, ); + const supportsChatOutline = useSessionStore( + (state) => + state.sessions[resolvedServerId]?.serverInfo?.features?.agentTimelinePromptIndex === true, + ); + const timelineEpoch = useSessionStore( + (state) => state.sessions[resolvedServerId]?.agentTimelineCursor.get(agentId)?.epoch ?? null, + ); + const isTimelineDetached = useSessionStore( + (state) => state.sessions[resolvedServerId]?.agentTimelineHasNewer.get(agentId) === true, + ); const workspaceRoot = context.cwd?.trim() || ""; const { requestDirectoryListing } = useFileExplorerActions({ @@ -516,6 +534,20 @@ const AgentStreamViewComponent = forwardRef { + toast?.error(t("agentStream.historyLoadFailed")); + }, [t, toast]); + const chatOutline = useChatOutline({ + agentId, + serverId: resolvedServerId, + timelineEpoch, + tail: effectiveStreamItems, + head: effectiveStreamHead, + enabled: supportsChatOutline && chatOutlineEnabled, + viewportRef, + onJumpError: handleTimelineHistoryLoadError, + }); + useImperativeHandle( ref, () => ({ @@ -530,8 +562,19 @@ const AgentStreamViewComponent = forwardRef { - viewportRef.current?.scrollToBottom("jump-to-bottom"); - }, []); + if (!isTimelineDetached) { + viewportRef.current?.scrollToBottom("jump-to-bottom"); + return; + } + void returnToTimelineTail({ + fetchTail: () => + getHostRuntimeStore().fetchAgentTimeline(resolvedServerId, agentId, { + ...planTimelineTailFetch(), + }), + scrollToBottom: () => viewportRef.current?.scrollToBottom("jump-to-bottom"), + onError: handleTimelineHistoryLoadError, + }); + }, [agentId, handleTimelineHistoryLoadError, isTimelineDetached, resolvedServerId]); const setInlineDetailsExpanded = useCallback( (itemId: string, expanded: boolean) => { @@ -947,6 +990,7 @@ const AgentStreamViewComponent = forwardRef - {!isNearBottom && ( + + {(!isNearBottom || isTimelineDetached) && ( ({ })); interface StreamItemWrapperProps { + itemId: string; gapBelow: number; children: ReactNode; } diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 22e90575a..afbf13790 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -352,6 +352,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const clearAgentTurnLiveness = useSessionStore((state) => state.clearAgentTurnLiveness); const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead); const setAgentTimelineCursor = useSessionStore((state) => state.setAgentTimelineCursor); + const setAgentTimelineHasNewer = useSessionStore((state) => state.setAgentTimelineHasNewer); const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration); const markAgentHistorySynchronized = useSessionStore( @@ -626,6 +627,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider acknowledgedClientMessageIds: result.acknowledgedClientMessageIds, }); } + if (payload.direction !== "before") { + setAgentTimelineHasNewer(serverId, (current) => { + const next = new Map(current); + next.set(agentId, payload.hasNewer); + return next; + }); + } markAgentHistorySynchronized(serverId, agentId); } else { applyAgentTimelineResponseState(serverId, agentId, { @@ -633,6 +641,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider head: result.head, range: result.cursorChanged ? (result.cursor ?? null) : (currentCursor ?? null), older: result.older, + newer: + payload.direction === "before" + ? timeline.status === "synced" && timeline.newer === "available" + : payload.hasNewer, synchronized: shouldMarkAuthoritativeHistoryApplied, acknowledgedClientMessageIds: result.acknowledgedClientMessageIds, }); @@ -659,6 +671,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider recoverTimelineGap, serverId, setAgentStreamState, + setAgentTimelineHasNewer, setInitializingAgents, ], ); @@ -729,6 +742,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider useEffect( () => getHostRuntimeStore().subscribeAgentStoppedRunning(serverId, (agentId) => { + const session = useSessionStore.getState().sessions[serverId]; + const timeline = selectAgentTimelineState(session, agentId); + if (timeline.status === "synced" && timeline.newer === "available") { + return; + } viewedTimelineSyncRef.current?.reconcileAgent(agentId); }), [serverId], diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index a864afc76..a6fcd2e09 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -70,6 +70,26 @@ describe("loadAppSettingsFromStorage", () => { expect(result.workspaceTitleSource).toBe("title"); }); + it("enables the chat outline by default", async () => { + const deps = makeDeps(); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.chatOutlineEnabled).toBe(true); + }); + + it("loads a disabled chat outline preference", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ chatOutlineEnabled: false }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.chatOutlineEnabled).toBe(false); + }); + it("loads configured terminal scrollback lines from app settings", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 6afe34966..94288f44b 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -43,6 +43,7 @@ export interface AppSettings { workspaceTitleSource: WorkspaceTitleSource; autoExpandReasoning: boolean; toolCallDetailLevel: ToolCallDetailLevel; + chatOutlineEnabled: boolean; vimKeybindings: boolean; } @@ -67,6 +68,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { workspaceTitleSource: "title", autoExpandReasoning: false, toolCallDetailLevel: "detailed", + chatOutlineEnabled: true, vimKeybindings: false, }; @@ -188,6 +190,17 @@ function parseToolCallDetailLevel(stored: StoredAppSettings): ToolCallDetailLeve return null; } +function pickBooleanAppSettings(stored: StoredAppSettings): Partial { + const result: Partial = {}; + if (typeof stored.vimKeybindings === "boolean") { + result.vimKeybindings = stored.vimKeybindings; + } + if (typeof stored.chatOutlineEnabled === "boolean") { + result.chatOutlineEnabled = stored.chatOutlineEnabled; + } + return result; +} + function pickAppSettings(stored: StoredAppSettings): Partial { const result: Partial = {}; if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) { @@ -235,9 +248,7 @@ function pickAppSettings(stored: StoredAppSettings): Partial { if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) { result.syntaxTheme = stored.syntaxTheme; } - if (typeof stored.vimKeybindings === "boolean") { - result.vimKeybindings = stored.vimKeybindings; - } + Object.assign(result, pickBooleanAppSettings(stored)); if ( typeof stored.workspaceTitleSource === "string" && VALID_WORKSPACE_TITLE_SOURCES.has(stored.workspaceTitleSource) diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 5a8b00b7f..ab490bb62 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -190,6 +190,7 @@ export const ar: TranslationResources = { agentStream: { empty: "ابدأ الدردشة مع هذا الوكيل...", scrollToBottom: "قم بالتمرير إلى الأسفل", + historyLoadFailed: "تعذر تحميل سجل الوكيل", permission: { plan: "يخطط", required: "الإذن مطلوب", @@ -1781,6 +1782,10 @@ export const ar: TranslationResources = { detailLevel: { title: "مستوى التفاصيل", }, + chatOutline: { + title: "مخطط المحادثة", + description: "عرض مخطط للتنقل بين المطالبات", + }, fonts: { title: "الخطوط", systemDefault: "الافتراضي للنظام", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index f15b986c7..52c088bb6 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -188,6 +188,7 @@ export const en = { agentStream: { empty: "Start chatting with this agent...", scrollToBottom: "Scroll to bottom", + historyLoadFailed: "Couldn't load agent history", permission: { plan: "Plan", required: "Permission Required", @@ -1791,6 +1792,10 @@ export const en = { detailLevel: { title: "Detail level", }, + chatOutline: { + title: "Chat outline", + description: "Show an outline for jumping between prompts", + }, fonts: { title: "Fonts", systemDefault: "System default", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index d55caf813..cd60e1a04 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -190,6 +190,7 @@ export const es: TranslationResources = { agentStream: { empty: "Comience a chatear con este agente...", scrollToBottom: "Desplazarse hacia abajo", + historyLoadFailed: "No se pudo cargar el historial del agente", permission: { plan: "Plan", required: "Permiso requerido", @@ -1828,6 +1829,10 @@ export const es: TranslationResources = { detailLevel: { title: "Nivel de detalle", }, + chatOutline: { + title: "Esquema del chat", + description: "Muestra un esquema para saltar entre instrucciones", + }, fonts: { title: "Fuentes", systemDefault: "Valor predeterminado del sistema", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index e10ac745f..2d5061cdb 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -192,6 +192,7 @@ export const fr: TranslationResources = { agentStream: { empty: "Commencez à discuter avec cet agent...", scrollToBottom: "Faire défiler vers le bas", + historyLoadFailed: "Impossible de charger l’historique de l’agent", permission: { plan: "Plan", required: "Autorisation requise", @@ -1831,6 +1832,10 @@ export const fr: TranslationResources = { detailLevel: { title: "Niveau de détail", }, + chatOutline: { + title: "Plan de la discussion", + description: "Afficher un plan pour passer d’une requête à l’autre", + }, fonts: { title: "Polices", systemDefault: "Valeur par défaut du système", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 91ee63dbf..253870983 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -190,6 +190,7 @@ export const ja: TranslationResources = { agentStream: { empty: "このエージェントとチャットを始めましょう...", scrollToBottom: "下にスクロール", + historyLoadFailed: "エージェントの履歴を読み込めませんでした", permission: { plan: "プラン", required: "権限が必要です", @@ -1796,6 +1797,10 @@ export const ja: TranslationResources = { detailLevel: { title: "詳細レベル", }, + chatOutline: { + title: "チャットのアウトライン", + description: "プロンプト間を移動するためのアウトラインを表示します", + }, fonts: { title: "フォント", systemDefault: "システムデフォルト", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 0aca91731..2a5ffe091 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -190,6 +190,7 @@ export const ptBR: TranslationResources = { agentStream: { empty: "Comece a conversar com este agente...", scrollToBottom: "Rolar para o fim", + historyLoadFailed: "Não foi possível carregar o histórico do agente", permission: { plan: "Plano", required: "Permissão necessária", @@ -1812,6 +1813,10 @@ export const ptBR: TranslationResources = { detailLevel: { title: "Nível de detalhe", }, + chatOutline: { + title: "Estrutura do chat", + description: "Mostrar uma estrutura para navegar entre prompts", + }, fonts: { title: "Fontes", systemDefault: "Sistema padrão", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 7615a707a..7198f09c8 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -190,6 +190,7 @@ export const ru: TranslationResources = { agentStream: { empty: "Начните общаться с этим агентом...", scrollToBottom: "Прокрутить вниз", + historyLoadFailed: "Не удалось загрузить историю агента", permission: { plan: "План", required: "Требуется разрешение", @@ -1817,6 +1818,10 @@ export const ru: TranslationResources = { detailLevel: { title: "Уровень детализации", }, + chatOutline: { + title: "Структура чата", + description: "Показывать структуру для перехода между запросами", + }, fonts: { title: "Шрифты", systemDefault: "Система по умолчанию", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 1e9684b74..f3660487e 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -190,6 +190,7 @@ export const zhCN: TranslationResources = { agentStream: { empty: "开始和这个 Agent 对话...", scrollToBottom: "滚动到底部", + historyLoadFailed: "无法加载智能体历史记录", permission: { plan: "Plan", required: "需要权限", @@ -1760,6 +1761,10 @@ export const zhCN: TranslationResources = { detailLevel: { title: "详细程度", }, + chatOutline: { + title: "聊天大纲", + description: "显示用于在提示词之间跳转的大纲", + }, fonts: { title: "字体", systemDefault: "系统默认", diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index c9b259290..c634ff250 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -189,6 +189,7 @@ describe("ReplicaCache", () => { expect(session.agentAuthoritativeHistoryApplied).toEqual(new Map()); expect(session.agentTimelineCursor).toEqual(new Map()); expect(session.agentTimelineHasOlder).toEqual(new Map()); + expect(session.agentTimelineHasNewer).toEqual(new Map()); expect(session.agentHistorySyncGeneration).toEqual(new Map()); expect(selectAgentTimelineState(session, "agent-1")).toEqual({ status: "painted", diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index 3f8a41fe0..fe1013d63 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -212,6 +212,30 @@ function AutoExpandReasoningRow({ value, onChange }: AutoExpandReasoningRowProps ); } +interface ChatOutlineRowProps { + value: boolean; + onChange: (value: boolean) => void; +} + +function ChatOutlineRow({ value, onChange }: ChatOutlineRowProps) { + const { t } = useTranslation(); + return ( + + + {t("settings.appearance.chatOutline.title")} + + {t("settings.appearance.chatOutline.description")} + + + + + ); +} + const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [ "detailed", "overview", @@ -503,6 +527,13 @@ export function AppearanceSection() { [updateSettings], ); + const handleChatOutlineChange = useCallback( + (chatOutlineEnabled: boolean) => { + void updateSettings({ chatOutlineEnabled }); + }, + [updateSettings], + ); + const commitUiFontFamily = useCallback( (value: string) => { const sanitized = sanitizeFontFamily(value); @@ -593,6 +624,12 @@ export function AppearanceSection() { value={settings.toolCallDetailLevel} onChange={handleToolCallDetailLevelChange} /> + {!isNative ? ( + + ) : null} diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 4243c7b02..a784c8543 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -97,6 +97,7 @@ describe("agent timeline state", () => { head: [], range: { epoch: "epoch-1", startSeq: 51, endSeq: 100 }, older: "available", + newer: false, synchronized: true, acknowledgedClientMessageIds: [], }); @@ -108,6 +109,7 @@ describe("agent timeline state", () => { items, range: { epoch: "epoch-1", startSeq: 51, endSeq: 100 }, older: "available", + newer: "none", }); }); @@ -118,13 +120,14 @@ describe("agent timeline state", () => { head: [], range: null, older: "none", + newer: false, synchronized: true, acknowledgedClientMessageIds: [], }); expect( selectAgentTimelineState(useSessionStore.getState().sessions["test-server"], "agent-1"), - ).toEqual({ status: "synced", items: [], range: null, older: "none" }); + ).toEqual({ status: "synced", items: [], range: null, older: "none", newer: "none" }); }); it("preserves older availability when an applied response leaves it unchanged", () => { @@ -135,6 +138,7 @@ describe("agent timeline state", () => { head: [], range: { epoch: "epoch-1", startSeq: 200, endSeq: 240 }, older: "available", + newer: false, synchronized: true, acknowledgedClientMessageIds: [], }); @@ -144,6 +148,7 @@ describe("agent timeline state", () => { head: [], range: { epoch: "epoch-1", startSeq: 200, endSeq: 240 }, older: "unchanged", + newer: false, synchronized: false, acknowledgedClientMessageIds: [], }); @@ -155,6 +160,7 @@ describe("agent timeline state", () => { items: [], range: { epoch: "epoch-1", startSeq: 200, endSeq: 240 }, older: "available", + newer: "none", }); }); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 2b4a0ad1b..544507dfd 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -336,6 +336,11 @@ export interface AgentTimelineCursorState { epoch: string; startSeq: number; endSeq: number; + retainedRanges?: Array<{ + startSeq: number; + endSeq: number; + hasOlder?: boolean; + }>; } export interface SessionReplicaTimeline { @@ -358,6 +363,7 @@ export type AgentTimelineState = items: StreamItem[]; range: AgentTimelineCursorState | null; older: "available" | "none"; + newer: "available" | "none"; }; export function selectAgentTimelineState( @@ -372,6 +378,7 @@ export function selectAgentTimelineState( items, range: session.agentTimelineCursor.get(agentId) ?? null, older: session.agentTimelineHasOlder.get(agentId) === true ? "available" : "none", + newer: session.agentTimelineHasNewer.get(agentId) === true ? "available" : "none", }; } return items.length > 0 ? { status: "painted", items } : { status: "cold" }; @@ -423,6 +430,7 @@ export interface SessionState { messageSubmissions: Map; agentTimelineCursor: Map; agentTimelineHasOlder: Map; + agentTimelineHasNewer: Map; agentTimelineOlderFetchInFlight: Map; historySyncGeneration: number; agentHistorySyncGeneration: Map; @@ -556,6 +564,10 @@ interface SessionStoreActions { serverId: string, state: Map | ((prev: Map) => Map), ) => void; + setAgentTimelineHasNewer: ( + serverId: string, + state: Map | ((prev: Map) => Map), + ) => void; setAgentTimelineOlderFetchInFlight: ( serverId: string, state: Map | ((prev: Map) => Map), @@ -575,6 +587,7 @@ interface SessionStoreActions { head: StreamItem[]; range: AgentTimelineCursorState | null; older: "available" | "none" | "unchanged"; + newer: boolean; synchronized: boolean; acknowledgedClientMessageIds: string[]; }, @@ -683,6 +696,7 @@ function createInitialSessionState( messageSubmissions: new Map(), agentTimelineCursor: new Map(), agentTimelineHasOlder: new Map(), + agentTimelineHasNewer: new Map(), agentTimelineOlderFetchInFlight: new Map(), historySyncGeneration: 0, agentHistorySyncGeneration: new Map(), @@ -1432,6 +1446,23 @@ export const useSessionStore = create()( }); }, + setAgentTimelineHasNewer: (serverId, state) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) return prev; + const nextState = + typeof state === "function" ? state(session.agentTimelineHasNewer) : state; + if (session.agentTimelineHasNewer === nextState) return prev; + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, agentTimelineHasNewer: nextState }, + }, + }; + }); + }, + setAgentTimelineOlderFetchInFlight: (serverId, state) => { set((prev) => { const session = prev.sessions[serverId]; @@ -1548,6 +1579,8 @@ export const useSessionStore = create()( if (state.older !== "unchanged") { nextHasOlder.set(agentId, state.older === "available"); } + const nextHasNewer = new Map(session.agentTimelineHasNewer); + nextHasNewer.set(agentId, state.newer); const nextAuthoritative = new Map(session.agentAuthoritativeHistoryApplied); const nextSyncGeneration = new Map(session.agentHistorySyncGeneration); const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; @@ -1579,6 +1612,7 @@ export const useSessionStore = create()( agentStreamHead: nextHead, agentTimelineCursor: nextCursor, agentTimelineHasOlder: nextHasOlder, + agentTimelineHasNewer: nextHasNewer, agentAuthoritativeHistoryApplied: nextAuthoritative, agentHistorySyncGeneration: nextSyncGeneration, messageSubmissions, diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 7d7a2aae2..78fea2cbf 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -197,6 +197,24 @@ describe("deriveAgentStreamTurnLiveness", () => { }); }); +describe("detached timeline windows", () => { + it("does not apply or catch up live events while viewing an older window", () => { + const currentTail = [makeAssistantItem("older window")]; + const result = processAgentStreamEvents({ + events: [makeStreamReducerEvent(makeAssistantTimelineEvent("new live output"), 101)], + currentTail, + currentHead: [], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 }, + hasAuthoritativeBaseline: true, + isDetached: true, + }); + + expect(result.tail).toBe(currentTail); + expect(result.changedTail).toBe(false); + expect(result.sideEffects).toEqual([]); + }); +}); + function getAssistantTexts(items: StreamItem[]): string[] { return items .filter((item): item is Extract => { @@ -2658,6 +2676,271 @@ describe("processTimelineResponse", () => { expect(new Set(assistants.map((item) => item.id)).size).toBe(2); }); + it("retains the current timeline while merging a disjoint prompt-jump window", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [ + { + kind: "user_message", + id: "newest", + text: "newest", + timestamp: new Date(1000), + timelineCursor: { epoch: "epoch-1", seq: 100 }, + }, + makeSubmittedUserMessage("pending submission", "pending-submission"), + ], + currentCursor: { epoch: "epoch-1", startSeq: 80, endSeq: 100 }, + sendingClientMessageIds: ["pending-submission"], + payload: { + ...baseTimelineInput.payload, + direction: "before", + mergeWindow: true, + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 40 }, + hasOlder: false, + hasNewer: true, + entries: [makeTimelineEntry(1, "oldest", "user_message")], + }, + }); + + expect(getUserTexts(result.tail)).toEqual(["oldest", "newest", "pending submission"]); + expect(result.cursor).toEqual({ + epoch: "epoch-1", + startSeq: 80, + endSeq: 100, + retainedRanges: [{ startSeq: 1, endSeq: 40, hasOlder: false }], + }); + expect(result.older).toBe("none"); + expect(result.sideEffects).not.toContainEqual(expect.objectContaining({ type: "catch_up" })); + }); + + it("fills the gap between a retained prompt window and the contiguous tail", () => { + const currentTail: StreamItem[] = [ + { + kind: "user_message", + id: "oldest", + text: "oldest", + timestamp: new Date(1000), + timelineCursor: { epoch: "epoch-1", seq: 1 }, + }, + { + kind: "user_message", + id: "newest", + text: "newest", + timestamp: new Date(100_000), + timelineCursor: { epoch: "epoch-1", seq: 100 }, + }, + ]; + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail, + currentCursor: { + epoch: "epoch-1", + startSeq: 80, + endSeq: 100, + retainedRanges: [{ startSeq: 1, endSeq: 40, hasOlder: false }], + }, + payload: { + ...baseTimelineInput.payload, + direction: "before", + epoch: "epoch-1", + startCursor: { seq: 40 }, + endCursor: { seq: 79 }, + hasOlder: true, + entries: [makeTimelineEntry(50, "bridge", "user_message")], + }, + }); + + expect(getUserTexts(result.tail)).toEqual(["oldest", "bridge", "newest"]); + expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 100 }); + expect(result.older).toBe("none"); + }); + + it.each(["tail", "head"] as const)( + "reconciles a pending submission from the %s included in a prompt-jump window", + (lane) => { + const clientMessageId = "pending-submission"; + const pending = makeSubmittedUserMessage("local pending prompt", clientMessageId); + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [ + { + kind: "user_message", + id: "newest", + text: "newest", + timestamp: new Date(100_000), + timelineCursor: { epoch: "epoch-1", seq: 100 }, + }, + ...(lane === "tail" ? [pending] : []), + ], + currentHead: lane === "head" ? [pending] : [], + currentCursor: { epoch: "epoch-1", startSeq: 80, endSeq: 100 }, + sendingClientMessageIds: [clientMessageId], + payload: { + ...baseTimelineInput.payload, + direction: "before", + mergeWindow: true, + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 40 }, + hasOlder: false, + hasNewer: true, + entries: [ + { + ...makeTimelineEntry(1, "canonical pending prompt", "user_message"), + item: { + type: "user_message", + text: "canonical pending prompt", + messageId: "canonical-message", + clientMessageId, + }, + }, + ], + }, + }); + + expect({ + userMessages: result.tail + .filter((item) => item.kind === "user_message") + .map(({ text, messageId, timelineCursor }) => ({ text, messageId, timelineCursor })), + head: result.head, + acknowledgedClientMessageIds: result.acknowledgedClientMessageIds, + }).toEqual({ + userMessages: [ + { + text: "local pending prompt", + messageId: "canonical-message", + timelineCursor: { epoch: "epoch-1", seq: 1 }, + }, + { text: "newest", messageId: undefined, timelineCursor: { epoch: "epoch-1", seq: 100 } }, + ], + head: [], + acknowledgedClientMessageIds: [clientMessageId], + }); + }, + ); + + it("does not duplicate a projected tool row that spans a prompt-jump window", () => { + const currentTail = hydrateStreamState( + [ + { + event: makeToolCallTimelineEvent("call-1"), + timestamp: new Date(500), + timelineCursor: { epoch: "epoch-1", seq: 500 }, + }, + ], + { source: "canonical" }, + ); + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail, + currentCursor: { epoch: "epoch-1", startSeq: 400, endSeq: 500 }, + payload: { + ...baseTimelineInput.payload, + direction: "before", + projection: "projected", + mergeWindow: true, + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 40 }, + hasOlder: false, + hasNewer: true, + entries: [ + { + ...makeToolCallTimelineEntry(1, "call-1", "completed", { + type: "read", + filePath: "/tmp/example.ts", + }), + seqEnd: 500, + sourceSeqRanges: [ + { startSeq: 1, endSeq: 1 }, + { startSeq: 500, endSeq: 500 }, + ], + collapsed: ["tool_lifecycle"], + }, + ], + }, + }); + + expect( + getAgentToolCalls(result.tail).map((item) => ({ + callId: item.payload.data.callId, + status: item.payload.data.status, + timelineCursor: item.timelineCursor, + })), + ).toEqual([ + { + callId: "call-1", + status: "completed", + timelineCursor: { epoch: "epoch-1", seq: 500 }, + }, + ]); + }); + + it("does not duplicate a projected assistant row that spans a prompt-jump window", () => { + const timestamp = new Date(500); + const currentTail: StreamItem[] = [ + { + ...makeAssistantItem("projected response", "loaded-assistant"), + messageId: "assistant-message", + timestamp, + timelineCursor: { epoch: "epoch-1", seq: 500 }, + }, + ]; + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail, + currentCursor: { epoch: "epoch-1", startSeq: 400, endSeq: 500 }, + payload: { + ...baseTimelineInput.payload, + direction: "before", + projection: "projected", + mergeWindow: true, + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 40 }, + hasOlder: false, + hasNewer: true, + entries: [ + { + ...makeTimelineEntry(1, "projected response"), + item: { + type: "assistant_message", + text: "projected response", + messageId: "assistant-message", + }, + timestamp: timestamp.toISOString(), + seqEnd: 500, + sourceSeqRanges: [ + { startSeq: 1, endSeq: 1 }, + { startSeq: 500, endSeq: 500 }, + ], + }, + ], + }, + }); + + expect( + result.tail + .filter((item) => item.kind === "assistant_message") + .map(({ id, text, messageId, timelineCursor }) => ({ + id, + text, + messageId, + timelineCursor, + })), + ).toEqual([ + { + id: "loaded-assistant", + text: "projected response", + messageId: "assistant-message", + timelineCursor: { epoch: "epoch-1", seq: 500 }, + }, + ]); + }); + it("drops a stale before page anchored before a resume-tail replacement", () => { const currentTail: StreamItem[] = [ { diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index ec256f54f..158a8ead8 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -19,10 +19,17 @@ const AGENT_STREAM_REDUCER_FLUSH_DELAY_MS = 16 * 3; // Shared cursor type // --------------------------------------------------------------------------- +export interface TimelineLoadedRange { + startSeq: number; + endSeq: number; + hasOlder?: boolean; +} + export interface TimelineCursor { epoch: string; startSeq: number; endSeq: number; + retainedRanges?: TimelineLoadedRange[]; } // --------------------------------------------------------------------------- @@ -60,6 +67,59 @@ interface TimelineSeqRange { endSeq: number; } +function mergeTimelineCoverage( + current: TimelineCursor, + added: TimelineLoadedRange, +): TimelineCursor { + const ranges = [ + { startSeq: current.startSeq, endSeq: current.endSeq }, + ...(current.retainedRanges ?? []), + added, + ].sort((left, right) => left.startSeq - right.startSeq || left.endSeq - right.endSeq); + const merged: TimelineLoadedRange[] = []; + for (const range of ranges) { + const previous = merged.at(-1); + if (!previous || range.startSeq > previous.endSeq + 1) { + merged.push({ ...range }); + continue; + } + previous.endSeq = Math.max(previous.endSeq, range.endSeq); + } + const contiguousIndex = merged.findIndex( + (range) => range.startSeq <= current.endSeq && range.endSeq >= current.endSeq, + ); + const contiguous = merged[contiguousIndex]; + if (!contiguous) return current; + const retainedRanges = merged.filter((_, index) => index !== contiguousIndex); + return { + epoch: current.epoch, + startSeq: contiguous.startSeq, + endSeq: contiguous.endSeq, + ...(retainedRanges.length > 0 ? { retainedRanges } : {}), + }; +} + +function timelineCursorEquals(left: TimelineCursor, right: TimelineCursor): boolean { + if ( + left.epoch !== right.epoch || + left.startSeq !== right.startSeq || + left.endSeq !== right.endSeq + ) { + return false; + } + const leftRanges = left.retainedRanges ?? []; + const rightRanges = right.retainedRanges ?? []; + return ( + leftRanges.length === rightRanges.length && + leftRanges.every( + (range, index) => + range.startSeq === rightRanges[index]?.startSeq && + range.endSeq === rightRanges[index]?.endSeq && + range.hasOlder === rightRanges[index]?.hasOlder, + ) + ); +} + interface TimelineResponseEntry { seqStart: number; seqEnd: number; @@ -84,6 +144,7 @@ export interface ProcessTimelineResponseInput { error: string | null; hasNewer: boolean; hasOlder: boolean; + mergeWindow?: boolean; }; currentTail: StreamItem[]; currentHead: StreamItem[]; @@ -126,6 +187,92 @@ interface TimelinePathResult { acknowledgedClientMessageIds: string[]; } +function matchesProjectedRow(existing: StreamItem, incoming: StreamItem): boolean { + if (isAgentToolCallItem(existing) && isAgentToolCallItem(incoming)) { + return existing.payload.data.callId === incoming.payload.data.callId; + } + if (existing.kind === "assistant_message" && incoming.kind === "assistant_message") { + return ( + existing.messageId === incoming.messageId && + existing.text === incoming.text && + existing.timestamp.getTime() === incoming.timestamp.getTime() + ); + } + return ( + existing.kind === "thought" && + incoming.kind === "thought" && + existing.text === incoming.text && + existing.timestamp.getTime() === incoming.timestamp.getTime() + ); +} + +function reconcilePromptWindowItems(input: { + hydrated: StreamItem[]; + tail: StreamItem[]; + head: StreamItem[]; +}): { + page: StreamItem[]; + tail: StreamItem[]; + head: StreamItem[]; + acknowledgedClientMessageIds: string[]; +} { + let tail = input.tail; + let head = input.head; + const page: StreamItem[] = []; + const acknowledgedClientMessageIds: string[] = []; + for (const item of input.hydrated) { + if (item.kind !== "user_message") { + const tailIndex = tail.findIndex((candidate) => matchesProjectedRow(candidate, item)); + const headIndex = + tailIndex < 0 ? head.findIndex((candidate) => matchesProjectedRow(candidate, item)) : -1; + const lane = tailIndex >= 0 ? tail : head; + const index = tailIndex >= 0 ? tailIndex : headIndex; + const existing = lane[index]; + if (index >= 0 && existing) { + const next = [...lane]; + next[index] = + isAgentToolCallItem(existing) && isAgentToolCallItem(item) + ? mergeAgentToolCallItem( + existing, + item.payload.data, + item.timestamp, + item.timelineCursor, + ) + : { ...item, id: existing.id }; + if (tailIndex >= 0) tail = next; + else head = next; + continue; + } + page.push(item); + continue; + } + const reconciled = upsertUserMessageAcrossStream({ + tail, + head, + message: item, + insert: "none", + presentation: "existing", + }); + const location = reconciled.location; + if (!location?.matched) { + page.push(item); + continue; + } + page.push(location.message); + if (item.clientMessageId !== undefined) { + acknowledgedClientMessageIds.push(item.clientMessageId); + } + tail = reconciled.tail; + head = reconciled.head; + if (location.lane === "tail") { + tail = [...tail.slice(0, location.index), ...tail.slice(location.index + 1)]; + } else { + head = [...head.slice(0, location.index), ...head.slice(location.index + 1)]; + } + } + return { page, tail, head, acknowledgedClientMessageIds }; +} + function classifySessionTimelineSeq({ cursor, epoch, @@ -216,6 +363,108 @@ function deriveResumeTailPolicy(input: { return { kind: "replace", preserveContinuity: true }; } +function shouldPreserveReplacementContinuity(input: { + isResumeReplacement: boolean; + resumePolicy: ResumeTailPolicy; + currentEpoch: string | undefined; + responseEpoch: string; + reset: boolean; +}): boolean { + if (input.isResumeReplacement && input.resumePolicy.kind === "replace") { + return input.resumePolicy.preserveContinuity; + } + return input.currentEpoch === input.responseEpoch || !input.reset; +} + +function mergeTimelineWindow(args: { + timelineUnits: TimelineUnit[]; + payload: ProcessTimelineResponseInput["payload"]; + currentTail: StreamItem[]; + currentHead: StreamItem[]; + currentCursor: TimelineCursor | undefined; + toHydratedEvents: (units: TimelineUnit[]) => Array<{ + event: AgentStreamEventPayload; + timestamp: Date; + timelineCursor: { epoch: string; seq: number }; + }>; +}): TimelinePathResult { + const { timelineUnits, payload, currentTail, currentHead, currentCursor, toHydratedEvents } = + args; + if (!payload.startCursor || !payload.endCursor || currentCursor?.epoch !== payload.epoch) { + return { + tail: currentTail, + head: currentHead, + cursor: currentCursor, + cursorChanged: false, + older: "unchanged", + sideEffects: [], + acknowledgedClientMessageIds: [], + }; + } + + const startSeq = payload.startCursor.seq; + const endSeq = payload.endCursor.seq; + const projected = reconcileOverlappingProjectedStreamItems({ + tail: currentTail, + head: currentHead, + units: timelineUnits, + epoch: payload.epoch, + currentEndSeq: currentCursor.endSeq, + }); + const retainedTail = projected.tail.filter((item) => { + const cursor = item.timelineCursor; + return cursor?.epoch !== payload.epoch || cursor.seq < startSeq || cursor.seq > endSeq; + }); + const retainedHead = projected.head; + const reservedItemIds = new Set( + [...retainedTail, ...retainedHead].flatMap((item) => + item.kind === "assistant_message" && item.blockGroupId + ? [item.id, item.blockGroupId] + : [item.id], + ), + ); + const hydrated = hydrateStreamState( + toHydratedEvents(timelineUnits.filter((unit) => !projected.reconciledUnits.has(unit))), + { source: "canonical", reservedItemIds }, + ); + const reconciled = reconcilePromptWindowItems({ + hydrated, + tail: retainedTail, + head: retainedHead, + }); + const tail = [...reconciled.tail, ...reconciled.page] + .map((item, order) => ({ item, order })) + .sort((left, right) => { + const leftSeq = left.item.timelineCursor?.seq ?? Number.POSITIVE_INFINITY; + const rightSeq = right.item.timelineCursor?.seq ?? Number.POSITIVE_INFINITY; + return leftSeq - rightSeq || left.order - right.order; + }) + .map(({ item }) => item); + const cursor = mergeTimelineCoverage(currentCursor, { + startSeq, + endSeq, + hasOlder: payload.hasOlder, + }); + const earliestLoadedSeq = Math.min( + currentCursor.startSeq, + ...(currentCursor.retainedRanges ?? []).map((range) => range.startSeq), + ); + let older: TimelinePathResult["older"] = "unchanged"; + if (startSeq <= earliestLoadedSeq) { + older = payload.hasOlder ? "available" : "none"; + } + + return { + tail, + head: reconciled.head, + cursor, + cursorChanged: !timelineCursorEquals(currentCursor, cursor), + older, + sideEffects: [], + acknowledgedClientMessageIds: reconciled.acknowledgedClientMessageIds, + }; +} + function shouldResolveTimelineInit({ hasActiveInitDeferred, hasNewer, @@ -387,7 +636,11 @@ function acceptOlderTimelineUnits(args: { return { acceptedUnits: timelineUnits, - cursor: { ...currentCursor, startSeq: responseStartSeq }, + cursor: mergeTimelineCoverage(currentCursor, { + startSeq: responseStartSeq, + endSeq: responseEndSeq, + hasOlder: payload.hasOlder, + }), gapCursor: null, }; } @@ -460,6 +713,28 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea return [...olderTail.slice(0, -1), mergedAssistant, ...currentTail.slice(1)]; } +function mergeOlderTimelinePage(input: { + page: StreamItem[]; + currentTail: StreamItem[]; + epoch: string; + startSeq: number; + endSeq: number; +}): StreamItem[] { + const retainedBefore: StreamItem[] = []; + const currentAtOrAfterPage: StreamItem[] = []; + for (const item of input.currentTail) { + const cursor = item.timelineCursor; + if (cursor?.epoch !== input.epoch) { + currentAtOrAfterPage.push(item); + } else if (cursor.seq < input.startSeq) { + retainedBefore.push(item); + } else if (cursor.seq > input.endSeq) { + currentAtOrAfterPage.push(item); + } + } + return [...retainedBefore, ...mergePrependedCanonicalTail(input.page, currentAtOrAfterPage)]; +} + function replaceLiveAssistantWithProjectedText(params: { head: StreamItem[]; event: AgentStreamEventPayload; @@ -808,6 +1083,80 @@ function selectEntriesOwnedByTimelinePage( ); } +function resolveOlderTimelineAvailability(input: { + payload: ProcessTimelineResponseInput["payload"]; + cursor: TimelineCursor | null | undefined; + currentCursor: TimelineCursor | undefined; +}): TimelinePathResult["older"] { + const { payload, cursor, currentCursor } = input; + if ( + payload.direction !== "before" || + !cursor || + !currentCursor || + cursor.startSeq === currentCursor.startSeq + ) { + return "unchanged"; + } + const connectedRetainedRange = currentCursor.retainedRanges?.find( + (range) => range.startSeq === cursor.startSeq, + ); + return (connectedRetainedRange?.hasOlder ?? payload.hasOlder) ? "available" : "none"; +} + +function applyAcceptedTimelinePage(input: { + acceptedUnits: TimelineUnit[]; + payload: ProcessTimelineResponseInput["payload"]; + currentTail: StreamItem[]; + currentHead: StreamItem[]; + currentCursor: TimelineCursor | undefined; +}): { + tail: StreamItem[]; + head: StreamItem[]; + acknowledgedClientMessageIds: string[]; +} { + const { acceptedUnits, payload, currentTail, currentHead, currentCursor } = input; + if (acceptedUnits.length === 0) { + return { tail: currentTail, head: currentHead, acknowledgedClientMessageIds: [] }; + } + if (payload.direction !== "before") { + return applyAcceptedForwardTimelineUnits({ + units: acceptedUnits, + epoch: payload.epoch, + currentTail, + currentHead, + currentEndSeq: currentCursor?.endSeq, + }); + } + const olderTail = hydrateStreamState( + acceptedUnits.map(({ event, timestamp, seqEnd }) => ({ + event, + timestamp, + timelineCursor: { epoch: payload.epoch, seq: seqEnd }, + })), + { + source: "canonical", + reservedItemIds: new Set( + currentTail.flatMap((item) => + item.kind === "assistant_message" && item.blockGroupId + ? [item.id, item.blockGroupId] + : [item.id], + ), + ), + }, + ); + return { + tail: mergeOlderTimelinePage({ + page: olderTail, + currentTail, + epoch: payload.epoch, + startSeq: payload.startCursor?.seq ?? acceptedUnits[0]?.seq ?? 0, + endSeq: payload.endCursor?.seq ?? acceptedUnits.at(-1)?.seqEnd ?? 0, + }), + head: currentHead, + acknowledgedClientMessageIds: [], + }; +} + function applyTimelineIncrementalPath(args: { timelineUnits: TimelineUnit[]; payload: ProcessTimelineResponseInput["payload"]; @@ -816,23 +1165,19 @@ function applyTimelineIncrementalPath(args: { currentCursor: TimelineCursor | undefined; }): TimelinePathResult { const { timelineUnits, payload, currentTail, currentHead, currentCursor } = args; - let nextTail = currentTail; - let nextHead = currentHead; let nextCursor: TimelineCursor | null | undefined = currentCursor; let cursorChanged = false; - let older: TimelinePathResult["older"] = "unchanged"; const sideEffects: TimelineReducerSideEffect[] = []; - let acknowledgedClientMessageIds: string[] = []; if (timelineUnits.length === 0 && payload.direction !== "before") { return { - tail: nextTail, - head: nextHead, + tail: currentTail, + head: currentHead, cursor: nextCursor, cursorChanged, - older, + older: "unchanged", sideEffects, - acknowledgedClientMessageIds, + acknowledgedClientMessageIds: [], }; } @@ -849,56 +1194,15 @@ function applyTimelineIncrementalPath(args: { currentCursor, }); - if ( - payload.direction === "before" && - cursor && - currentCursor && - cursor.startSeq !== currentCursor.startSeq - ) { - older = payload.hasOlder ? "available" : "none"; - } + const applied = applyAcceptedTimelinePage({ + acceptedUnits, + payload, + currentTail, + currentHead, + currentCursor, + }); - if (acceptedUnits.length > 0) { - if (payload.direction === "before") { - const olderTail = hydrateStreamState( - acceptedUnits.map(({ event, timestamp, seqEnd }) => ({ - event, - timestamp, - timelineCursor: { epoch: payload.epoch, seq: seqEnd }, - })), - { - source: "canonical", - reservedItemIds: new Set( - currentTail.flatMap((item) => - item.kind === "assistant_message" && item.blockGroupId - ? [item.id, item.blockGroupId] - : [item.id], - ), - ), - }, - ); - nextTail = mergePrependedCanonicalTail(olderTail, currentTail); - } else { - const applied = applyAcceptedForwardTimelineUnits({ - units: acceptedUnits, - epoch: payload.epoch, - currentTail: nextTail, - currentHead: nextHead, - currentEndSeq: currentCursor?.endSeq, - }); - nextTail = applied.tail; - nextHead = applied.head; - acknowledgedClientMessageIds = applied.acknowledgedClientMessageIds; - } - } - - if ( - cursor && - (!currentCursor || - currentCursor.epoch !== cursor.epoch || - currentCursor.startSeq !== cursor.startSeq || - currentCursor.endSeq !== cursor.endSeq) - ) { + if (cursor && (!currentCursor || !timelineCursorEquals(currentCursor, cursor))) { nextCursor = cursor; cursorChanged = true; } @@ -908,13 +1212,13 @@ function applyTimelineIncrementalPath(args: { } return { - tail: nextTail, - head: nextHead, + tail: applied.tail, + head: applied.head, cursor: nextCursor, cursorChanged, - older, + older: resolveOlderTimelineAvailability({ payload, cursor, currentCursor }), sideEffects, - acknowledgedClientMessageIds, + acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds, }; } @@ -1021,6 +1325,15 @@ export function processTimelineResponse( currentHead, }).filter((clientMessageId) => sendingClientMessageIds.includes(clientMessageId)), }; + } else if (payload.mergeWindow === true) { + timelineResult = mergeTimelineWindow({ + timelineUnits, + payload, + currentTail, + currentHead, + currentCursor, + toHydratedEvents, + }); } else if (replace || resumeTailPolicy.kind === "replace") { const isResumeReplacement = resumeTailPolicy.kind === "replace"; timelineResult = applyTimelineReplacePath({ @@ -1032,9 +1345,13 @@ export function processTimelineResponse( currentTail, currentHead, sendingClientMessageIds, - preserveContinuity: isResumeReplacement - ? resumeTailPolicy.preserveContinuity - : currentCursor?.epoch === payload.epoch || !payload.reset, + preserveContinuity: shouldPreserveReplacementContinuity({ + isResumeReplacement, + resumePolicy: resumeTailPolicy, + currentEpoch: currentCursor?.epoch, + responseEpoch: payload.epoch, + reset: payload.reset, + }), toHydratedEvents, }); } else { @@ -1143,6 +1460,7 @@ export interface ProcessAgentStreamEventsInput { currentHead: StreamItem[]; currentCursor: TimelineCursor | undefined; hasAuthoritativeBaseline?: boolean; + isDetached?: boolean; } export type AgentStreamReducerSnapshot = Omit; @@ -1331,6 +1649,18 @@ export function processAgentStreamEvent( export function processAgentStreamEvents( input: ProcessAgentStreamEventsInput, ): ProcessAgentStreamEventOutput { + if (input.isDetached) { + return { + tail: input.currentTail, + head: input.currentHead, + changedTail: false, + changedHead: false, + cursor: input.currentCursor ?? null, + cursorChanged: false, + acknowledgedClientMessageIds: [], + sideEffects: [], + }; + } let tail = input.currentTail; let head = input.currentHead; let cursor = input.currentCursor; @@ -1513,6 +1843,7 @@ export function createSessionAgentStreamReducerQueue( currentHead: session?.agentStreamHead.get(agentId) ?? [], currentCursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined, hasAuthoritativeBaseline: timeline.status === "synced", + isDetached: timeline.status === "synced" && timeline.newer === "available", }; }, commit: (agentId, result, events) => { diff --git a/packages/app/src/timeline/timeline-sync-plan.test.ts b/packages/app/src/timeline/timeline-sync-plan.test.ts index 5d75754e6..99a987a48 100644 --- a/packages/app/src/timeline/timeline-sync-plan.test.ts +++ b/packages/app/src/timeline/timeline-sync-plan.test.ts @@ -4,6 +4,7 @@ import { isTimelineCatchUpComplete, isTimelineResumeSnapshotAuthoritative, planTimelineOlderFetch, + planTimelinePromptJump, planTimelineTailFetch, } from "./timeline-sync-plan"; @@ -29,6 +30,19 @@ describe("timeline sync planning", () => { }); }); + test("an unloaded prompt jump merges a window with half a page newer than the target", () => { + expect(planTimelinePromptJump({ epoch: "epoch-1", seq: 42 })).toEqual({ + direction: "before", + cursor: { + epoch: "epoch-1", + seq: 42 + Math.floor(TIMELINE_FETCH_PAGE_SIZE / 2) + 1, + }, + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "projected", + mergeWindow: true, + }); + }); + test("catch-up finishes when the daemon reports no newer rows", () => { expect(isTimelineCatchUpComplete({ direction: "after", hasNewer: false, error: null })).toBe( true, diff --git a/packages/app/src/timeline/timeline-sync-plan.ts b/packages/app/src/timeline/timeline-sync-plan.ts index 95b7e859b..800cbc007 100644 --- a/packages/app/src/timeline/timeline-sync-plan.ts +++ b/packages/app/src/timeline/timeline-sync-plan.ts @@ -60,6 +60,17 @@ export function planTimelineOlderFetch(cursor: TimelineSyncCursor) { } as const; } +export function planTimelinePromptJump(target: TimelineSyncCursor) { + const newerRows = Math.floor(TIMELINE_FETCH_PAGE_SIZE / 2); + return { + direction: "before", + cursor: { epoch: target.epoch, seq: target.seq + newerRows + 1 }, + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "projected", + mergeWindow: true, + } as const; +} + export function isTimelineCatchUpComplete(input: { direction: "tail" | "before" | "after"; hasNewer: boolean; diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index b1d6bfcf9..ee7c68422 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1081,6 +1081,51 @@ test("honors explicit fetchAgentTimeline timeout below the session RPC default", await expect(responsePromise).rejects.toThrow("Timeout waiting for message (2000ms)"); }); +test("lists the full agent prompt index", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const responsePromise = client.listAgentTimelinePrompts("agent-1", { + requestId: "req-prompts-1", + }); + + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "agent.timeline.list_prompts.request", + requestId: "req-prompts-1", + agentId: "agent-1", + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "agent.timeline.list_prompts.response", + payload: { + requestId: "req-prompts-1", + agentId: "agent-1", + epoch: "epoch-1", + prompts: [{ seq: 1, timestamp: "2026-01-01T00:00:00.000Z", preview: "First prompt" }], + error: null, + }, + }), + ); + + await expect(responsePromise).resolves.toMatchObject({ + epoch: "epoch-1", + prompts: [{ seq: 1, preview: "First prompt" }], + }); +}); + test("honors explicit fetchAgents timeout below the session RPC default", async () => { useHeartbeatClock(); const logger = createMockLogger(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index ead49fa54..1d5d1ea2c 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -562,10 +562,16 @@ export interface FetchAgentTimelineOptions { cursor?: FetchAgentTimelineCursor; limit?: number; projection?: FetchAgentTimelineProjection; + mergeWindow?: boolean; requestId?: string; timeout?: number; } +export type AgentTimelinePromptIndexPayload = Extract< + SessionOutboundMessage, + { type: "agent.timeline.list_prompts.response" } +>["payload"]; + export type ProviderSubagentListPayload = Extract< SessionOutboundMessage, { type: "agent.provider_subagents.list.response" } @@ -2738,6 +2744,7 @@ export class DaemonClient { ...(options.cursor ? { cursor: options.cursor } : {}), ...(typeof options.limit === "number" ? { limit: options.limit } : {}), ...(options.projection ? { projection: options.projection } : {}), + ...(options.mergeWindow === true ? { mergeWindow: true } : {}), }); const payload = await this.sendRequest({ @@ -2763,6 +2770,33 @@ export class DaemonClient { return payload; } + async listAgentTimelinePrompts( + agentId: string, + options: { requestId?: string; timeout?: number } = {}, + ): Promise { + const requestId = this.createRequestId(options.requestId); + const message = SessionInboundMessageSchema.parse({ + type: "agent.timeline.list_prompts.request", + agentId, + requestId, + }); + const payload = await this.sendRequest({ + requestId, + message, + timeout: options.timeout, + options: { skipQueue: true }, + select: (response) => + response.type === "agent.timeline.list_prompts.response" && + response.payload.requestId === requestId + ? response.payload + : null, + }); + if (payload.error) { + throw new Error(payload.error); + } + return payload; + } + async listProviderSubagents( parentAgentId: string, options: { requestId?: string; timeout?: number } = {}, diff --git a/packages/protocol/src/messages.stream-parsing.test.ts b/packages/protocol/src/messages.stream-parsing.test.ts index d4ea77d20..9bf33ba01 100644 --- a/packages/protocol/src/messages.stream-parsing.test.ts +++ b/packages/protocol/src/messages.stream-parsing.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + AgentTimelineListPromptsResponseMessageSchema, AgentStreamMessageSchema, FetchAgentTimelineResponseMessageSchema, SessionInboundMessageSchema, @@ -9,6 +10,33 @@ import { } from "./messages.js"; describe("shared messages stream parsing", () => { + it("parses a full timeline prompt index response", () => { + const parsed = AgentTimelineListPromptsResponseMessageSchema.parse({ + type: "agent.timeline.list_prompts.response", + payload: { + requestId: "req-prompts-1", + agentId: "agent_live", + epoch: "epoch-1", + prompts: [ + { + seq: 42, + timestamp: "2026-02-08T20:10:00.000Z", + preview: "Review the pagination behavior", + }, + ], + error: null, + }, + }); + + expect(parsed.payload.prompts).toEqual([ + { + seq: 42, + timestamp: "2026-02-08T20:10:00.000Z", + preview: "Review the pagination behavior", + }, + ]); + }); + it("parses representative fetch_agent_timeline_response payload", () => { const parsed = FetchAgentTimelineResponseMessageSchema.parse({ type: "fetch_agent_timeline_response", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index bc7e7a2b2..1da7b7914 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1409,6 +1409,14 @@ export const FetchAgentTimelineRequestMessageSchema = z.object({ limit: z.number().int().nonnegative().optional(), // Default should be projected for app timeline loading. projection: z.enum(["projected", "canonical"]).optional(), + // Allow the client to merge this bounded page outside its contiguous loaded range. + mergeWindow: z.boolean().optional(), +}); + +export const AgentTimelineListPromptsRequestMessageSchema = z.object({ + type: z.literal("agent.timeline.list_prompts.request"), + agentId: z.string(), + requestId: z.string(), }); export const ProviderSubagentListRequestMessageSchema = z.object({ @@ -2545,6 +2553,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ RestartServerRequestMessageSchema, DaemonUpdateRequestMessageSchema, FetchAgentTimelineRequestMessageSchema, + AgentTimelineListPromptsRequestMessageSchema, ProviderSubagentListRequestMessageSchema, ProviderSubagentTimelineRequestMessageSchema, SetAgentTimelineSubscriptionRequestMessageSchema, @@ -2832,6 +2841,8 @@ export const ServerInfoStatusPayloadSchema = z "terminal-restore-modes": z.boolean().optional(), // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. rewind: z.boolean().optional(), + // COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X. + agentTimelinePromptIndex: z.boolean().optional(), // COMPAT(checkoutRefresh): added in v0.1.86, remove gate after 2026-11-29. checkoutRefresh: z.boolean().optional(), // COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97 @@ -3608,11 +3619,29 @@ export const FetchAgentTimelineResponseMessageSchema = z.object({ endCursor: AgentTimelineCursorSchema.nullable(), hasOlder: z.boolean(), hasNewer: z.boolean(), + mergeWindow: z.boolean().optional(), entries: z.array(AgentTimelineEntryPayloadSchema), error: z.string().nullable(), }), }); +export const AgentTimelineListPromptsResponseMessageSchema = z.object({ + type: z.literal("agent.timeline.list_prompts.response"), + payload: z.object({ + requestId: z.string(), + agentId: z.string(), + epoch: z.string(), + prompts: z.array( + z.object({ + seq: z.number().int().nonnegative(), + timestamp: z.string(), + preview: z.string(), + }), + ), + error: z.string().nullable(), + }), +}); + export const ProviderSubagentDescriptorPayloadSchema = z.object({ id: z.string(), parentAgentId: z.string(), @@ -5308,6 +5337,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ArchiveWorkspaceResponseMessageSchema, FetchAgentResponseMessageSchema, FetchAgentTimelineResponseMessageSchema, + AgentTimelineListPromptsResponseMessageSchema, ProviderSubagentListResponseMessageSchema, ProviderSubagentTimelineResponseMessageSchema, ProviderSubagentUpdateMessageSchema, @@ -5512,6 +5542,9 @@ export type FetchAgentResponseMessage = z.infer; +export type AgentTimelineListPromptsResponseMessage = z.infer< + typeof AgentTimelineListPromptsResponseMessageSchema +>; export type AgentForkContextResponseMessage = z.infer; export type CancelAgentResponseMessage = z.infer; export type SendAgentMessageResponseMessage = z.infer; diff --git a/packages/server/src/server/agent/agent-timeline-store.test.ts b/packages/server/src/server/agent/agent-timeline-store.test.ts index b033a856e..2f801e027 100644 --- a/packages/server/src/server/agent/agent-timeline-store.test.ts +++ b/packages/server/src/server/agent/agent-timeline-store.test.ts @@ -2,6 +2,60 @@ import { describe, expect, it } from "vitest"; import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js"; describe("InMemoryAgentTimelineStore", () => { + it("clamps an overshooting before cursor into the bounded tail window", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("agent-1", { + epoch: "epoch-1", + nextSeq: 8, + rows: [ + { + seq: 5, + timestamp: "2026-01-01T00:00:00.000Z", + item: { type: "assistant_message", text: "five" }, + }, + { + seq: 6, + timestamp: "2026-01-01T00:00:01.000Z", + item: { type: "assistant_message", text: "six" }, + }, + { + seq: 7, + timestamp: "2026-01-01T00:00:02.000Z", + item: { type: "assistant_message", text: "seven" }, + }, + ], + }); + + const result = store.fetch("agent-1", { + direction: "before", + cursor: { epoch: "epoch-1", seq: 100 }, + limit: 2, + }); + + expect(result).toEqual({ + epoch: "epoch-1", + direction: "before", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 5, maxSeq: 7, nextSeq: 8 }, + hasOlder: true, + hasNewer: false, + rows: [ + { + seq: 6, + timestamp: "2026-01-01T00:00:01.000Z", + item: { type: "assistant_message", text: "six" }, + }, + { + seq: 7, + timestamp: "2026-01-01T00:00:02.000Z", + item: { type: "assistant_message", text: "seven" }, + }, + ], + }); + }); + it("returns a bounded reset window when an after cursor is behind retained history", () => { const store = new InMemoryAgentTimelineStore(); store.initialize("agent-1", { diff --git a/packages/server/src/server/agent/timeline-prompt-index.test.ts b/packages/server/src/server/agent/timeline-prompt-index.test.ts new file mode 100644 index 000000000..e03860026 --- /dev/null +++ b/packages/server/src/server/agent/timeline-prompt-index.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import type { AgentTimelineRow } from "./agent-timeline-store-types.js"; +import { buildTimelinePromptIndex } from "./timeline-prompt-index.js"; + +describe("buildTimelinePromptIndex", () => { + it("indexes every canonical user prompt with a stable timeline position", () => { + const rows: AgentTimelineRow[] = [ + { + seq: 3, + timestamp: "2026-01-01T00:00:00.000Z", + item: { type: "user_message", text: " First\n\n prompt " }, + }, + { + seq: 4, + timestamp: "2026-01-01T00:00:01.000Z", + item: { type: "assistant_message", text: "response" }, + }, + { + seq: 8, + timestamp: "2026-01-01T00:00:02.000Z", + item: { type: "user_message", text: "Second prompt" }, + }, + ]; + + expect(buildTimelinePromptIndex("epoch-1", rows)).toEqual({ + epoch: "epoch-1", + prompts: [ + { seq: 3, timestamp: "2026-01-01T00:00:00.000Z", preview: "First prompt" }, + { seq: 8, timestamp: "2026-01-01T00:00:02.000Z", preview: "Second prompt" }, + ], + }); + }); + + it("bounds previews without indexing assistant rows", () => { + const rows: AgentTimelineRow[] = [ + { + seq: 1, + timestamp: "2026-01-01T00:00:00.000Z", + item: { type: "user_message", text: "x".repeat(200) }, + }, + { + seq: 2, + timestamp: "2026-01-01T00:00:01.000Z", + item: { type: "assistant_message", text: "ignored" }, + }, + ]; + + const result = buildTimelinePromptIndex("epoch-1", rows); + + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0]?.preview).toHaveLength(120); + expect(result.prompts[0]?.preview.endsWith("…")).toBe(true); + }); +}); diff --git a/packages/server/src/server/agent/timeline-prompt-index.ts b/packages/server/src/server/agent/timeline-prompt-index.ts new file mode 100644 index 000000000..58f6ff28d --- /dev/null +++ b/packages/server/src/server/agent/timeline-prompt-index.ts @@ -0,0 +1,42 @@ +import type { AgentTimelineRow } from "./agent-timeline-store-types.js"; + +const PROMPT_PREVIEW_MAX_LENGTH = 120; + +export interface TimelinePromptIndexEntry { + seq: number; + timestamp: string; + preview: string; +} + +export interface TimelinePromptIndex { + epoch: string; + prompts: TimelinePromptIndexEntry[]; +} + +function promptPreview(text: string): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + if (collapsed.length <= PROMPT_PREVIEW_MAX_LENGTH) { + return collapsed; + } + return `${collapsed.slice(0, PROMPT_PREVIEW_MAX_LENGTH - 1)}…`; +} + +export function buildTimelinePromptIndex( + epoch: string, + rows: readonly AgentTimelineRow[], +): TimelinePromptIndex { + return { + epoch, + prompts: rows.flatMap((row) => + row.item.type === "user_message" + ? [ + { + seq: row.seq, + timestamp: row.timestamp, + preview: promptPreview(row.item.text), + }, + ] + : [], + ), + }; +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 432d1ff76..609d718c8 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -68,6 +68,7 @@ import { } from "./lifecycle-reasons.js"; import { AgentManager, AgentRunCancellationError } from "./agent/agent-manager.js"; +import { buildTimelinePromptIndex } from "./agent/timeline-prompt-index.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import type { AgentManagerEvent, @@ -1904,6 +1905,8 @@ export class Session { switch (msg.type) { case "fetch_agent_timeline_request": return this.handleFetchAgentTimelineRequest(msg, source); + case "agent.timeline.list_prompts.request": + return this.handleAgentTimelineListPromptsRequest(msg, source); case "agent.provider_subagents.list.request": return this.handleProviderSubagentListRequest(msg); case "agent.provider_subagents.timeline.get.request": @@ -6264,6 +6267,7 @@ export class Session { endCursor, hasOlder: selectedTimeline.hasOlder, hasNewer: selectedTimeline.hasNewer, + ...(msg.mergeWindow === true ? { mergeWindow: true } : {}), entries: selectedTimeline.entries.map((entry) => ({ provider: snapshot.provider, item: entry.item, @@ -6307,6 +6311,7 @@ export class Session { endCursor: null, hasOlder: false, hasNewer: false, + ...(msg.mergeWindow === true ? { mergeWindow: true } : {}), entries: [], error: error instanceof Error ? error.message : String(error), }, @@ -6316,6 +6321,55 @@ export class Session { } } + private async handleAgentTimelineListPromptsRequest( + msg: Extract, + source?: object, + ): Promise { + try { + await ensureAgentLoaded(msg.agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); + const rows = await this.agentManager.getTimelineRows(msg.agentId); + const timeline = this.agentManager.fetchTimeline(msg.agentId, { + direction: "tail", + limit: 1, + }); + const index = buildTimelinePromptIndex(timeline.epoch, rows); + this.emitForSource( + { + type: "agent.timeline.list_prompts.response", + payload: { + requestId: msg.requestId, + agentId: msg.agentId, + ...index, + error: null, + }, + }, + source, + ); + } catch (error) { + this.sessionLogger.error( + { err: error, agentId: msg.agentId }, + "Failed to handle agent.timeline.list_prompts.request", + ); + this.emitForSource( + { + type: "agent.timeline.list_prompts.response", + payload: { + requestId: msg.requestId, + agentId: msg.agentId, + epoch: "", + prompts: [], + error: error instanceof Error ? error.message : String(error), + }, + }, + source, + ); + } + } + private async handleProviderSubagentListRequest( msg: Extract, ): Promise { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 5991101fd..c1980c3d2 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1533,6 +1533,8 @@ export class VoiceAssistantWebSocketServer { "terminal-restore-modes": true, // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. rewind: true, + // COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X. + agentTimelinePromptIndex: true, // COMPAT(checkoutRefresh): added in v0.1.86, remove gate after 2026-11-29. checkoutRefresh: true, // COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97