fix(sync): show complete workspace and agent history (#2263)

Cached focused state was leaking into the sidebar before authoritative workspace hydration, while catch-up limits counted raw events instead of projected entries. Keep the focused cache fast, gate directory presentation on hydration, and preserve canonical cursors while paging projected history.
This commit is contained in:
Mohamed Boudra
2026-07-20 23:24:45 +08:00
committed by GitHub
parent 0c68b26a8b
commit 07d988488e
9 changed files with 331 additions and 43 deletions
@@ -572,6 +572,61 @@ describe("selectProjectedTimelinePage", () => {
expect(page.hasNewer).toBe(true);
});
test("after limit counts projected entries while preserving their canonical sequence coverage", () => {
const rows: AgentTimelineRow[] = [
...Array.from({ length: 200 }, (_, index) => ({
seq: index + 1,
timestamp: new Date(1000 + index).toISOString(),
item: { type: "assistant_message" as const, text: "x" },
})),
...Array.from({ length: 150 }, (_, index) => ({
seq: index + 201,
timestamp: new Date(2000 + index).toISOString(),
item: { type: "user_message" as const, text: `message ${index + 1}` },
})),
];
const page = selectProjectedTimelinePage({
rows,
direction: "after",
cursorSeq: 0,
limit: 100,
});
expect({
entryCount: page.entries.length,
firstEntry: page.entries[0],
lastEntry: page.entries.at(-1),
startSeq: page.startSeq,
endSeq: page.endSeq,
hasNewer: page.hasNewer,
}).toEqual({
entryCount: 100,
firstEntry: {
item: {
type: "assistant_message",
text: "x".repeat(200),
},
timestamp: new Date(1199).toISOString(),
seqStart: 1,
seqEnd: 200,
sourceSeqRanges: [{ startSeq: 1, endSeq: 200 }],
collapsed: ["assistant_merge"],
},
lastEntry: {
item: { type: "user_message", text: "message 99" },
timestamp: new Date(2098).toISOString(),
seqStart: 299,
seqEnd: 299,
sourceSeqRanges: [{ startSeq: 299, endSeq: 299 }],
collapsed: [],
},
startSeq: 1,
endSeq: 299,
hasNewer: true,
});
});
test("before page includes a wide tool whose earlier source range is before the cursor", () => {
const rows: AgentTimelineRow[] = [
toolRow(1, "running"),
@@ -376,6 +376,69 @@ function selectEntriesOverlappingSeqRange(input: {
);
}
function firstSourceSeqInRange(
entry: TimelineProjectionEntry,
startSeq: number,
endSeq: number,
): number | null {
for (const range of entry.sourceSeqRanges) {
const firstSeq = Math.max(range.startSeq, startSeq);
if (firstSeq <= Math.min(range.endSeq, endSeq)) return firstSeq;
}
return null;
}
interface ProjectedEntryCandidate {
entry: TimelineProjectionEntry;
index: number;
firstSourceSeq: number;
}
function selectProjectedEntriesAfter(input: {
entries: readonly TimelineProjectionEntry[];
rows: readonly AgentTimelineRow[];
startSeq: number;
maxSeq: number;
limit: number;
}): { entries: TimelineProjectionEntry[]; endSeq: number | null } {
const eligible = input.entries
.map((entry, index) => ({
entry,
index,
firstSourceSeq: firstSourceSeqInRange(entry, input.startSeq, input.maxSeq),
}))
.filter((candidate): candidate is ProjectedEntryCandidate => candidate.firstSourceSeq !== null)
.sort((left, right) => left.firstSourceSeq - right.firstSourceSeq || left.index - right.index);
const selected = input.limit === 0 ? eligible : eligible.slice(0, input.limit);
const selectedEntries = selected
.sort((left, right) => left.index - right.index)
.map((candidate) => candidate.entry);
if (selectedEntries.length === 0) return { entries: [], endSeq: null };
const selectedRanges = selectedEntries
.flatMap((entry) => entry.sourceSeqRanges)
.sort((left, right) => left.startSeq - right.startSeq || left.endSeq - right.endSeq);
// Wide projected entries can include future, discontiguous source ranges. The
// page cursor advances only through source rows covered without a gap.
let endSeq = input.startSeq - 1;
let rangeIndex = 0;
for (const row of input.rows) {
if (row.seq < input.startSeq) continue;
if (row.seq > input.maxSeq || row.seq !== endSeq + 1) break;
while (selectedRanges[rangeIndex] && selectedRanges[rangeIndex].endSeq < row.seq) {
rangeIndex += 1;
}
const range = selectedRanges[rangeIndex];
if (!range || row.seq < range.startSeq || row.seq > range.endSeq) break;
endSeq = row.seq;
}
return {
entries: selectedEntries,
endSeq: endSeq >= input.startSeq ? endSeq : null,
};
}
export function selectProjectedTimelinePage(input: {
rows: readonly AgentTimelineRow[];
bounds?: { minSeq: number; maxSeq: number };
@@ -446,7 +509,20 @@ export function selectProjectedTimelinePage(input: {
if (input.direction === "after") {
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
endSeq = limit === 0 ? bounds.maxSeq : Math.min(bounds.maxSeq, cursorSeq + limit);
const selected = selectProjectedEntriesAfter({
entries: projectedAll,
rows: input.rows,
startSeq,
maxSeq: bounds.maxSeq,
limit,
});
return {
entries: selected.entries,
startSeq: selected.endSeq === null ? null : startSeq,
endSeq: selected.endSeq,
hasOlder: startSeq > bounds.minSeq,
hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq,
};
} else {
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
endSeq = Math.min(bounds.maxSeq, cursorSeq - 1);
@@ -362,6 +362,75 @@ describe("daemon E2E - timeline window", () => {
}
});
test("projected after limit returns 100 projected entries with their canonical coverage", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Timeline Projected Page Limit Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 200; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: "x",
});
}
for (let seq = 201; seq <= 350; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: `message ${seq - 200}`,
});
}
const epoch = ctx.daemon.daemon.agentManager.fetchTimeline(agent.id, {
direction: "tail",
limit: 1,
}).epoch;
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: { epoch, seq: 0 },
limit: 100,
projection: "projected",
});
expect({
entryCount: timeline.entries.length,
firstEntry: timeline.entries[0],
lastEntry: timeline.entries.at(-1),
startCursor: timeline.startCursor,
endCursor: timeline.endCursor,
hasNewer: timeline.hasNewer,
}).toEqual({
entryCount: 100,
firstEntry: {
provider: "codex",
item: { type: "assistant_message", text: "x".repeat(200) },
timestamp: expect.any(String),
seqStart: 1,
seqEnd: 200,
sourceSeqRanges: [{ startSeq: 1, endSeq: 200 }],
collapsed: ["assistant_merge"],
},
lastEntry: {
provider: "codex",
item: { type: "user_message", text: "message 99" },
timestamp: expect.any(String),
seqStart: 299,
seqEnd: 299,
sourceSeqRanges: [{ startSeq: 299, endSeq: 299 }],
collapsed: [],
},
startCursor: { epoch, seq: 1 },
endCursor: { epoch, seq: 299 },
hasNewer: true,
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("projected empty after fetch preserves older history availability", async () => {
const cwd = tmpCwd();
try {
+17 -7
View File
@@ -5686,22 +5686,31 @@ export class Session {
private shouldUseFullTimelineForProjectedPage(input: {
timeline: AgentTimelineFetchResult;
pageLimit: number;
}): boolean {
const { timeline } = input;
if (timeline.reset || timeline.rows.length === 0 || !timeline.hasOlder) {
return false;
}
if (timeline.rows.length === 0) return false;
if (timeline.rows.some((row) => row.item.type === "tool_call")) return true;
const firstRow = timeline.rows[0];
if (
firstRow?.item.type === "assistant_message" ||
firstRow?.item.type === "reasoning" ||
firstRow?.item.type === "tool_call"
timeline.hasOlder &&
(firstRow?.item.type === "assistant_message" || firstRow?.item.type === "reasoning")
) {
return true;
}
return timeline.rows.some((row) => row.item.type === "tool_call");
const lastRow = timeline.rows.at(-1);
if (
timeline.hasNewer &&
(lastRow?.item.type === "assistant_message" || lastRow?.item.type === "reasoning")
) {
return true;
}
if (!timeline.hasNewer || input.pageLimit === 0) return false;
return projectTimelineRows({ rows: timeline.rows, mode: "projected" }).length < input.pageLimit;
}
private selectCanonicalTimelineProjection(input: {
@@ -5727,6 +5736,7 @@ export class Session {
}): AgentTimelineProjectionSelection {
const timeline = this.shouldUseFullTimelineForProjectedPage({
timeline: input.controlTimeline,
pageLimit: input.pageLimit,
})
? this.agentManager.fetchTimeline(input.agentId, { direction: "tail", limit: 0 })
: input.controlTimeline;