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:
@@ -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