feat(app): add chat outline navigation (#2792)
* feat(app): add chat outline navigation Long agent sessions need prompt navigation without coupling viewport movement to disposable server windows. Keep fetched timeline pages in client state so repeated jumps stay local, and settle prompt jumps against virtualized row measurement changes. * fix(app): preserve chat outline timeline coverage * fix(app): reconcile outline window submissions * fix(app): harden outline timeline navigation
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<PromptJumpRequestTracker> {
|
||||
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<void>;
|
||||
|
||||
@@ -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<void> {
|
||||
await expect(chatOutlineRail(page)).toBeVisible();
|
||||
await expect(chatOutlineRail(page).getByRole("tab")).toHaveCount(count);
|
||||
}
|
||||
|
||||
export async function expectNoChatOutline(page: Page): Promise<void> {
|
||||
await expect(chatOutlineRail(page)).toBeHidden();
|
||||
}
|
||||
|
||||
export async function hoverChatOutlinePrompt(page: Page, position: number): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await pointAtChatOutlineRowEdge(page, position);
|
||||
await page.mouse.down();
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
export async function splitCurrentPanelRight(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Split pane right" }).first().click();
|
||||
}
|
||||
|
||||
export async function disableChatOutlineFromAppearance(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await chatOutlinePrompt(page, position).focus();
|
||||
}
|
||||
|
||||
export async function pressEnterOnFocusedPrompt(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
|
||||
export async function expectChatOutlinePreview(page: Page, preview: string): Promise<void> {
|
||||
await expect(page.getByTestId("chat-outline-preview")).toHaveText(preview);
|
||||
}
|
||||
|
||||
export async function expectNoChatOutlinePreview(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("chat-outline-preview")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectNoChatOutlinePreviewWhileCrossingToSidebar(page: Page): Promise<void> {
|
||||
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<void>((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<void> {
|
||||
await expect(chatOutlinePrompt(page, position)).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
}
|
||||
|
||||
export async function expectChatOutlineAlignedWithActiveTabGlyph(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
await expect(chatOutlineRail(page).getByRole("tab", { selected: true })).toHaveCount(1);
|
||||
}
|
||||
|
||||
export async function expectActiveChatOutlinePrompt(page: Page, position: number): Promise<void> {
|
||||
await expect(chatOutlineRail(page).getByRole("tab", { selected: true })).toHaveAccessibleName(
|
||||
new RegExp(`^${position} of `),
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectLiveTurnPromptAboveFoldAndActive(
|
||||
page: Page,
|
||||
prompt: string,
|
||||
position: number,
|
||||
): Promise<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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<void>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createChatOutlineHoverIntent } from "./hover-intent";
|
||||
|
||||
function createFakeScheduler() {
|
||||
const callbacks = new Map<number, () => 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<number | null> = [];
|
||||
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<number | null> = [];
|
||||
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<number | null> = [];
|
||||
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<number | null> = [];
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<number | null>(null);
|
||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(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 (
|
||||
<View style={styles.panelMeasure} pointerEvents="box-none" onLayout={onLayout}>
|
||||
{isPanelNarrow ? null : (
|
||||
<View
|
||||
style={styles.rail}
|
||||
role="tablist"
|
||||
testID="chat-outline-rail"
|
||||
onPointerEnter={handlePointerEnterRail}
|
||||
onPointerMove={handlePointerMoveRail}
|
||||
onPointerLeave={handlePointerLeaveRail}
|
||||
>
|
||||
{prompts.map((prompt, index) => (
|
||||
<ChatOutlineTick
|
||||
key={prompt.seq}
|
||||
index={index}
|
||||
seq={prompt.seq}
|
||||
preview={prompt.preview}
|
||||
label={`${index + 1} of ${prompts.length}: ${prompt.preview}`}
|
||||
isActive={prompt.seq === activeSeq}
|
||||
hasAttention={index === attentionIndex}
|
||||
magnification={
|
||||
prefersReducedMotion || attentionIndex === null
|
||||
? 0
|
||||
: promptTickMagnification(index - attentionIndex)
|
||||
}
|
||||
onHover={handlePointerEnterTick}
|
||||
onFocusChange={handleFocusChange}
|
||||
onJumpToPrompt={onJumpToPrompt}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
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 (
|
||||
<View style={styles.slot} onPointerEnter={handlePointerEnter}>
|
||||
<Pressable
|
||||
style={styles.target}
|
||||
onPress={handlePress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
accessibilityRole="tab"
|
||||
aria-selected={isActive}
|
||||
accessibilityLabel={label}
|
||||
testID={`chat-outline-tick-${seq}`}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.pill,
|
||||
isActive && styles.pillActive,
|
||||
hasAttention && styles.pillAttention,
|
||||
inlineUnistylesStyle({ width: pillWidth, height: pillHeight }),
|
||||
]}
|
||||
/>
|
||||
</Pressable>
|
||||
{hasAttention ? (
|
||||
<View style={styles.preview} pointerEvents="none" aria-hidden testID="chat-outline-preview">
|
||||
<Text style={styles.previewText} numberOfLines={2}>
|
||||
{preview}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((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<StreamViewportHandle>();
|
||||
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<StreamViewportHandle>();
|
||||
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<StreamViewportHandle>();
|
||||
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());
|
||||
});
|
||||
});
|
||||
@@ -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<StreamViewportHandle | null>;
|
||||
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<AgentTimelinePromptIndexPayload | null>(null);
|
||||
const [pendingJump, setPendingJump] = useState<PendingPromptJump | null>(null);
|
||||
const [activePrompt] = useState(createActivePromptPublisher);
|
||||
const readingRowIdRef = useRef<string | null>(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 };
|
||||
}
|
||||
@@ -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<number, () => 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<string, number>();
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<StreamViewportHandle>();
|
||||
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;
|
||||
|
||||
@@ -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<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
|
||||
): 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<HTMLElement>("[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) => (
|
||||
<div key={item.id} data-history-row-id={item.id} style={mountedHistoryRowStyle}>
|
||||
<div key={item.id} data-history-row-id={item.id} style={streamRowStyle}>
|
||||
{renderHistoryMountedRow(item, index, segments.historyMounted)}
|
||||
</div>
|
||||
));
|
||||
@@ -769,7 +822,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const liveHeadRows = useMemo(() => {
|
||||
void liveHeadRowRevision;
|
||||
return segments.liveHead.map((item, index) => (
|
||||
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
|
||||
<div key={item.id} data-history-row-id={item.id} style={streamRowStyle}>
|
||||
{renderLiveHeadRow(item, index, segments.liveHead)}
|
||||
</div>
|
||||
));
|
||||
}, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]);
|
||||
const liveAuxiliary = useMemo(() => {
|
||||
|
||||
@@ -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<boolean>;
|
||||
isLoadingOlderHistory: boolean;
|
||||
hasOlderHistory: boolean;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
export async function returnToTimelineTail(input: {
|
||||
fetchTail: () => Promise<unknown>;
|
||||
scrollToBottom: () => void;
|
||||
onError: () => void;
|
||||
logError?: (message: string, error: unknown) => void;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await input.fetchTail();
|
||||
input.scrollToBottom();
|
||||
} catch (error) {
|
||||
(input.logError ?? console.warn)("Failed to return to the live timeline tail", error);
|
||||
input.onError();
|
||||
}
|
||||
}
|
||||
@@ -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<HTMLElement | null>;
|
||||
rowVirtualizer: Virtualizer<HTMLElement, Element>;
|
||||
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<HTMLElement>(
|
||||
`[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<HTMLElement>(
|
||||
`[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,
|
||||
};
|
||||
}
|
||||
@@ -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 = (
|
||||
<StreamItemWrapper gapBelow={input.layoutItem.gapBelow}>{input.content}</StreamItemWrapper>
|
||||
<StreamItemWrapper itemId={input.layoutItem.item.id} gapBelow={input.layoutItem.gapBelow}>
|
||||
{input.content}
|
||||
</StreamItemWrapper>
|
||||
);
|
||||
|
||||
if (input.layoutItem.frameOrder === "footer-then-content") {
|
||||
@@ -287,6 +294,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const { t } = useTranslation();
|
||||
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
|
||||
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
|
||||
const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled);
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const pendingClientMessageIds = useMemo(
|
||||
() => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)),
|
||||
@@ -324,6 +332,16 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
(state) =>
|
||||
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<AgentStreamViewHandle, AgentStreamVi
|
||||
streamRenderStrategy,
|
||||
],
|
||||
);
|
||||
const handleTimelineHistoryLoadError = useCallback(() => {
|
||||
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<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
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<AgentStreamViewHandle, AgentStreamVi
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
onNearBottomChange: setIsNearBottom,
|
||||
onReadingPositionChange: chatOutline.reportReadingPosition,
|
||||
onNearHistoryStart: loadOlder,
|
||||
isLoadingOlderHistory: isLoadingOlder,
|
||||
hasOlderHistory: hasOlder,
|
||||
@@ -957,7 +1001,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
|
||||
})}
|
||||
</MessageOuterSpacingProvider>
|
||||
{!isNearBottom && (
|
||||
<ChatOutlineRail
|
||||
prompts={chatOutline.prompts}
|
||||
activePrompt={chatOutline.activePrompt}
|
||||
onJumpToPrompt={chatOutline.jumpToPrompt}
|
||||
/>
|
||||
{(!isNearBottom || isTimelineDetached) && (
|
||||
<View style={stylesheet.scrollToBottomContainer} pointerEvents="box-none">
|
||||
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
|
||||
<Pressable
|
||||
@@ -1559,6 +1608,7 @@ const permissionStyles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
interface StreamItemWrapperProps {
|
||||
itemId: string;
|
||||
gapBelow: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
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<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) {
|
||||
@@ -235,9 +248,7 @@ function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
|
||||
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)
|
||||
|
||||
@@ -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: "الافتراضي للنظام",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "システムデフォルト",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Система по умолчанию",
|
||||
|
||||
@@ -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: "系统默认",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.appearance.chatOutline.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.appearance.chatOutline.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
accessibilityLabel={t("settings.appearance.chatOutline.title")}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<ChatOutlineRow
|
||||
value={settings.chatOutlineEnabled}
|
||||
onChange={handleChatOutlineChange}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</SettingsSection>
|
||||
<SettingsSection title={t("settings.appearance.fonts.title")}>
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, MessageSubmissionRecord[]>;
|
||||
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
|
||||
agentTimelineHasOlder: Map<string, boolean>;
|
||||
agentTimelineHasNewer: Map<string, boolean>;
|
||||
agentTimelineOlderFetchInFlight: Map<string, boolean>;
|
||||
historySyncGeneration: number;
|
||||
agentHistorySyncGeneration: Map<string, number>;
|
||||
@@ -556,6 +564,10 @@ interface SessionStoreActions {
|
||||
serverId: string,
|
||||
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
|
||||
) => void;
|
||||
setAgentTimelineHasNewer: (
|
||||
serverId: string,
|
||||
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
|
||||
) => void;
|
||||
setAgentTimelineOlderFetchInFlight: (
|
||||
serverId: string,
|
||||
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
|
||||
@@ -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<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
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<SessionStore>()(
|
||||
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<SessionStore>()(
|
||||
agentStreamHead: nextHead,
|
||||
agentTimelineCursor: nextCursor,
|
||||
agentTimelineHasOlder: nextHasOlder,
|
||||
agentTimelineHasNewer: nextHasNewer,
|
||||
agentAuthoritativeHistoryApplied: nextAuthoritative,
|
||||
agentHistorySyncGeneration: nextSyncGeneration,
|
||||
messageSubmissions,
|
||||
|
||||
@@ -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<StreamItem, { kind: "assistant_message" }> => {
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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<ProcessAgentStreamEventsInput, "events">;
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<AgentTimelinePromptIndexPayload> {
|
||||
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 } = {},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof FetchAgentResponseMessage
|
||||
export type FetchAgentTimelineResponseMessage = z.infer<
|
||||
typeof FetchAgentTimelineResponseMessageSchema
|
||||
>;
|
||||
export type AgentTimelineListPromptsResponseMessage = z.infer<
|
||||
typeof AgentTimelineListPromptsResponseMessageSchema
|
||||
>;
|
||||
export type AgentForkContextResponseMessage = z.infer<typeof AgentForkContextResponseMessageSchema>;
|
||||
export type CancelAgentResponseMessage = z.infer<typeof CancelAgentResponseMessageSchema>;
|
||||
export type SendAgentMessageResponseMessage = z.infer<typeof SendAgentMessageResponseMessageSchema>;
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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<SessionInboundMessage, { type: "agent.timeline.list_prompts.request" }>,
|
||||
source?: object,
|
||||
): Promise<void> {
|
||||
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<SessionInboundMessage, { type: "agent.provider_subagents.list.request" }>,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user