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