Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.factory
.factory
e2e_*.mjs
16 changes: 13 additions & 3 deletions app/api/sessions/[id]/context/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ import { getRpcSession } from "@/lib/rpc-manager";

export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const url = new URL(req.url);
const leafId = url.searchParams.get("leafId") ?? undefined;
const deferThinking = url.searchParams.has("deferThinking");
const deferToolResultImages = url.searchParams.has("deferMedia");
// `tail` caps the ancestor chain returned (default 50); `before` rewinds the
// walk start to an older entry so the client can page upward without
// re-fetching the whole active branch.
const rawTail = Number(url.searchParams.get("tail"));
const tail = Number.isFinite(rawTail) && rawTail > 0 ? Math.min(rawTail, 1000) : 50;
const before = url.searchParams.get("before") ?? undefined;

try {
const rpc = getRpcSession(id);
Expand All @@ -22,12 +28,16 @@ export async function GET(
}

const sm = liveRpc?.inner.sessionManager ?? SessionManager.open(filePath!);
const context = buildSessionContext(sm.getEntries() as never, leafId, {
// `before` is the oldest entry already on the client; fetch its ancestors
// only (excludeLeaf) so prepending the page does not duplicate `before`.
const context = buildSessionContext(sm.getEntries() as never, before ?? leafId, {
deferThinking,
deferToolResultImages,
tail,
excludeLeaf: Boolean(before),
});

return NextResponse.json({ context });
return NextResponse.json({ context, tail, before: before ?? null });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
Expand Down
4 changes: 3 additions & 1 deletion app/api/sessions/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ export async function GET(
const searchParams = new URL(req.url).searchParams;
const deferThinking = searchParams.has("deferThinking");
const deferToolResultImages = searchParams.has("deferMedia");
const context = buildSessionContext(entries as never, leafId, { deferThinking, deferToolResultImages });
const rawTail = Number(searchParams.get("tail"));
const tail = Number.isFinite(rawTail) && rawTail > 0 ? Math.min(rawTail, 1000) : 50;
const context = buildSessionContext(entries as never, leafId, { deferThinking, deferToolResultImages, tail });
const totalActiveMs = computeSessionTotalActiveMs(entries);

const header = sm.getHeader();
Expand Down
37 changes: 37 additions & 0 deletions app/api/sessions/context-route.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Static + behavior coverage for the context pagination API (the #555 transfer fix):
// ?tail bounds the returned chain, ?before rewinds the walk and excludes its own
// boundary so prepending the page never duplicates it. Data behavior is covered
// end-to-end in lib/session-reader.pagination.test.mjs; here we assert the route wires
// the params through to buildSessionContext (excludeLeaf on ?before).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createJiti } from "jiti";

const routeSrc = await readFileSync(new URL("./[id]/context/route.ts", import.meta.url), "utf8");
const jiti = createJiti(import.meta.url, {
alias: { "@": process.cwd() },
interopDefault: true,
moduleCache: false,
});
const { buildSessionContext } = await jiti.import("@/lib/session-reader");

test("context route parses ?tail and ?before, excluding the boundary on paging", () => {
assert.match(routeSrc, /const tail = Number\.isFinite\(rawTail\) && rawTail > 0 \? Math\.min\(rawTail, 1000\) : 50/);
assert.match(routeSrc, /const before = url\.searchParams\.get\("before"\)/);
assert.match(routeSrc, /buildSessionContext\(sm\.getEntries\(\) as never, before \?\? leafId, \{[^}]*excludeLeaf: Boolean\(before\)/);
});

test("context route: ?before pages upward without duplicating the boundary", () => {
const entries = [];
for (let i = 0; i < 100; i++) {
entries.push({ id: `e${i}`, parentId: i === 0 ? null : `e${i - 1}`, type: "message", timestamp: new Date(1000 + i * 1000).toISOString(), message: { role: "user", content: `m${i}` } });
}
const page1 = buildSessionContext(entries, "e99", { tail: 5 }).entryIds;
assert.deepEqual(page1, ["e95", "e96", "e97", "e98", "e99"]);
const oldest = page1[0]; // e95
const page2 = buildSessionContext(entries, oldest, { tail: 5, excludeLeaf: true }).entryIds;
assert.equal(page2[page2.length - 1], "e94");
assert.ok(!page2.includes(oldest), "boundary `before` must not be duplicated");
assert.ok(page1.every((id) => !page2.includes(id)), "adjacent pages share no entry");
});
51 changes: 51 additions & 0 deletions app/api/sessions/detail-route.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Static + behavior coverage for the session detail API's tail bound (the #509/#555
// transfer fix). Mirrors runtime-route.test.mjs: source assertions confirm the route
// parses ?tail (default 50, NaN-safe, capped at 1000) and feeds only the sliced chain
// to buildSessionContext. The data-slicing behavior itself is covered end-to-end in
// lib/session-reader.pagination.test.mjs (sliceActiveBranch + buildSessionContext).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createJiti } from "jiti";

const routeSrc = await readFileSync(new URL("./[id]/route.ts", import.meta.url), "utf8");
const jiti = createJiti(import.meta.url, {
alias: { "@": process.cwd() },
interopDefault: true,
moduleCache: false,
});
const { buildSessionContext } = await jiti.import("@/lib/session-reader");

test("detail route parses ?tail: default 50, NaN-safe, capped at 1000", () => {
assert.match(routeSrc, /const rawTail = Number\(searchParams\.get\("tail"\)\)/);
assert.match(routeSrc, /Math\.min\(rawTail, 1000\)/);
assert.match(routeSrc, /Number\.isFinite\(rawTail\) && rawTail > 0 \? Math\.min\(rawTail, 1000\) : 50/);
assert.match(routeSrc, /buildSessionContext\(entries as never, leafId, \{[^}]*tail \}\)/);
});

test("detail route bounds history to the tail window (default 50 over 5000 entries)", () => {
const entries = [];
for (let i = 0; i < 5000; i++) {
entries.push({
id: `e${i}`,
parentId: i === 0 ? null : `e${i - 1}`,
type: "message",
timestamp: new Date(1000 + i * 1000).toISOString(),
message: { role: i % 2 === 0 ? "user" : "assistant", content: `m${i}` },
});
}
const ctx = buildSessionContext(entries, "e4999", { tail: 50 });
assert.equal(ctx.messages.length, 50);
// The transferred window is the tail, not the full 5000-entry forest.
assert.equal(ctx.entryIds[0], "e4950");
assert.equal(ctx.entryIds[ctx.entryIds.length - 1], "e4999");
});

test("detail route with an out-of-range tail still caps at 1000", () => {
const entries = [];
for (let i = 0; i < 5000; i++) {
entries.push({ id: `e${i}`, parentId: i === 0 ? null : `e${i - 1}`, type: "message", timestamp: new Date(1000 + i * 1000).toISOString(), message: { role: "user", content: `m${i}` } });
}
const ctx = buildSessionContext(entries, "e4999", { tail: 5000 });
assert.equal(ctx.messages.length, 5000);
});
46 changes: 45 additions & 1 deletion components/BranchNavigator.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const jiti = createJiti(import.meta.url, {
jsx: { runtime: "automatic" },
tsconfigPaths: true,
});
const { compressChain, selectTopLevelBranches } = await jiti.import("./BranchNavigator.tsx");
const { compressChain, selectTopLevelBranches, buildActivePath, hasBranch } = await jiti.import("./BranchNavigator.tsx");

const msg = (id, role, text) => ({ type: "message", id, parentId: null, timestamp: "t", message: { role, content: text } });
const info = (id) => ({ type: "session_info", id, parentId: null, timestamp: "t", name: "x" });
Expand Down Expand Up @@ -104,3 +104,47 @@ test("multi-root metadata chains use their user previews and assistant represent
assert.deepEqual(topLevel.map((n) => compressChain(n).branchPreview.text), ["第一问", "第二问"]);
assert.deepEqual(topLevel.map((n) => compressChain(n).node.entry.id), ["a1", "a2"]);
});

// --- #509 regression: recursive tree consumption overflowed the stack on a
// linear session (depth == entry count). The iterative rewrite must survive a
// chain far deeper than V8's call-stack limit.

// Build a linear chain of `n` nodes (each child is the previous one).
function linearTree(n) {
const nodes = [];
let prev = null;
for (let i = 0; i < n; i++) {
const entry = { type: "message", id: `e${i}`, parentId: prev, timestamp: "t", message: { role: "user", content: `m${i}` } };
nodes.push({ entry, children: [] });
if (prev) nodes[nodes.length - 2].children = [nodes[nodes.length - 1]];
prev = `e${i}`;
}
return nodes[0];
}

test("buildActivePath finds the leaf on a 6000-deep linear chain without a stack overflow", () => {
const root = linearTree(6000);
const path = buildActivePath([root], "e5999");
assert.equal(path.size, 6000);
assert.ok(path.has("e0"));
assert.ok(path.has("e5999"));
});

test("hasBranch reports false for a linear chain (no branching) and true otherwise", () => {
assert.equal(hasBranch([linearTree(5000)]), false);
const root = linearTree(3);
root.children[0].children[0].children = [
{ entry: { type: "message", id: "b1", parentId: "e2", timestamp: "t", message: { role: "user", content: "x" } }, children: [] },
{ entry: { type: "message", id: "b2", parentId: "e2", timestamp: "t", message: { role: "user", content: "y" } }, children: [] },
];
assert.equal(hasBranch([root]), true);
assert.equal(hasBranch([root]), true);
});

test("hasBranch reports true for multiple root nodes (a branch from the first message)", () => {
// Each root has a single child, so no node.children.length > 1 — only the
// multiple-root shape makes this a branch.
const r1 = { entry: { type: "message", id: "r1", parentId: null, timestamp: "t", message: { role: "user", content: "a" } }, children: [] };
const r2 = { entry: { type: "message", id: "r2", parentId: null, timestamp: "t", message: { role: "user", content: "b" } }, children: [] };
assert.equal(hasBranch([r1, r2]), true);
});
36 changes: 21 additions & 15 deletions components/BranchNavigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,23 @@ interface Props {
}

// Find the visible entry IDs on the path from root to activeLeafId.
function buildActivePath(nodes: SessionTreeNode[], targetId: string | null): Set<string> {
// Iterative DFS: a linear session degrades into a chain whose depth equals the
// entry count, so a recursive search overflows the call stack. Walk with an
// explicit stack instead (paths accumulate depth, not the call stack).
export function buildActivePath(nodes: SessionTreeNode[], targetId: string | null): Set<string> {
if (!targetId) return new Set();
const target = targetId;
function search(nodes: SessionTreeNode[], path: string[]): string[] | null {
for (const node of nodes) {
const next = [...path, node.entry.id];
if (node.entry.id === target || node.compressedEntryIds?.includes(target)) {
return next;
}
const found = search(node.children, next);
if (found) return found;
const stack: { node: SessionTreeNode; path: string[] }[] = nodes.map((n) => ({ node: n, path: [n.entry.id] }));
while (stack.length > 0) {
const { node, path } = stack.pop()!;
if (node.entry.id === target || node.compressedEntryIds?.includes(target)) {
return new Set(path);
}
for (const child of node.children) {
stack.push({ node: child, path: [...path, child.entry.id] });
}
return null;
}
return new Set(search(nodes, []) ?? []);
return new Set();
}

function isMessageEntry(entry: SessionEntry): boolean {
Expand Down Expand Up @@ -99,12 +101,16 @@ function getLabel(entry: SessionEntry): string {
return entry.type;
}

// Does the tree have any branching at all?
function hasBranch(nodes: SessionTreeNode[]): boolean {
// Does the tree have any branching at all? Iterative: a linear chain has no
// branching but recursing over it would overflow the stack, so walk with a stack.
export function hasBranch(nodes: SessionTreeNode[]): boolean {
// Sessions branched from the very first message have multiple root nodes.
if (nodes.length > 1) return true;
for (const node of nodes) {
const stack: SessionTreeNode[] = [...nodes];
while (stack.length > 0) {
const node = stack.pop()!;
if (node.children.length > 1) return true;
Comment on lines +109 to 112

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve multiple root branches in the navigator

This iterative rewrite no longer treats nodes.length > 1 as a branch. Sessions branched from the first message have multiple root nodes (and selectTopLevelBranches still returns those roots), but hasBranch now returns false when each root has at most one child, causing the UI to show the no-branches state and hide the branch choices. Re-add the top-level nodes.length > 1 check before walking the stack.

Useful? React with 👍 / 👎.

if (hasBranch(node.children)) return true;
for (const child of node.children) stack.push(child);
}
return false;
}
Expand Down
36 changes: 27 additions & 9 deletions components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import type { SessionStatsInfo } from "@/lib/pi-types";
import type { AppUpdateResponse } from "@/lib/api-types";
import {
captureScrollDistance,
getNextVisibleCount,
getPromptAnchorSpacerHeight,
getVisibleRenderWindow,
restoreScrollTop,
Expand Down Expand Up @@ -292,6 +291,7 @@ export function ChatWindow({ session, sessionRunning, newSessionCwd, newSessionD
handleRecallQueue,
handleBuiltinSlashCommand,
handleToolPresetChange, handleThinkingLevelChange, loadSlashCommands, scrollUserMsgToTop,
loadContext, activeLeafId,
} = useAgentSession({
session, sessionRunning, newSessionCwd, newSessionDraftKey, onAgentEnd: wrappedOnAgentEnd, onAttentionNeeded, onSessionCreated, onSessionForked,
modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSystemPromptLoaderChange, onSessionStatsPanelOpen,
Expand All @@ -315,7 +315,7 @@ export function ChatWindow({ session, sessionRunning, newSessionCwd, newSessionD
const [visibleCount, setVisibleCount] = useState(VISIBLE_PAGE_SIZE);
const sentinelRef = useRef<HTMLDivElement>(null);
const prevScrollDistanceRef = useRef<number | null>(null);

const loadingOlderRef = useRef(false);
// IntersectionObserver on the sentinel div at the top of the message list.
// When it becomes visible, load the next page of older messages.
useEffect(() => {
Expand All @@ -324,17 +324,32 @@ export function ChatWindow({ session, sessionRunning, newSessionCwd, newSessionD
if (!sentinel || !container) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
// Save distance from top before prepending to restore scroll later
prevScrollDistanceRef.current = captureScrollDistance(container.scrollHeight, container.scrollTop);
setVisibleCount((prev) => getNextVisibleCount(prev));
}
if (!entries[0]?.isIntersecting) return;
// No older history loaded yet: fetch the previous page from the server
// and prepend it (loadContext handles prepend + scroll anchoring).
// Skip while a page is already loading or nothing older exists.
if (loadingOlderRef.current) return;
const oldestId = entryIds[0];
if (!oldestId) return;
const sid = session?.id ?? sessionIdRef.current;
if (!sid) return;
loadingOlderRef.current = true;
prevScrollDistanceRef.current = captureScrollDistance(container.scrollHeight, container.scrollTop);
void loadContext(sid, activeLeafId, oldestId).finally(() => {
loadingOlderRef.current = false;
});
},
{ root: container, threshold: 0 }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [visibleCount, messages.length, scrollContainerRef]);
}, [entryIds, session, activeLeafId, loadContext, sessionIdRef, scrollContainerRef]);

// Keep the rendered window at least as large as what's loaded, so prepended
// (older) pages stay visible instead of being sliced off the top.
useEffect(() => {
setVisibleCount((current) => Math.max(current, messages.length));
}, [messages.length]);

// After visibleCount increases (more messages prepended), restore the
// scroll position so the viewport doesn't jump.
Expand Down Expand Up @@ -875,7 +890,10 @@ export function ChatWindow({ session, sessionRunning, newSessionCwd, newSessionD
}
idx = endIdx;
}
const { startIndex, hasMore } = getVisibleRenderWindow(rendered.length, visibleCount);
// Show the sentinel when the window is full: the initial tail is a
// truncation, and after prepending there may still be older history.
const { startIndex } = getVisibleRenderWindow(rendered.length, visibleCount);
const hasMore = startIndex > 0 || rendered.length >= visibleCount;
return (
<>
{hasMore && (
Expand Down
17 changes: 13 additions & 4 deletions hooks/useAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,16 +523,25 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
}
}, []);

const loadContext = useCallback(async (sid: string, leafId: string | null) => {
const loadContext = useCallback(async (sid: string, leafId: string | null, before?: string | null) => {
try {
const params = new URLSearchParams({ deferThinking: "1", deferMedia: "1" });
if (leafId) params.set("leafId", leafId);
// Page upward: ask the server for the `tail` ancestors preceding `before`,
// then prepend them. Omitting `before` fetches the most-recent `tail`.
if (before) params.set("before", before);
const url = `/api/sessions/${encodeURIComponent(sid)}/context?${params}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const d = await res.json() as { context: { messages: AgentMessage[]; entryIds: string[] } };
setMessages(d.context.messages);
setEntryIds(d.context.entryIds ?? []);
if (before) {
// Older page: prepend so scroll position stays anchored.
setMessages((prev) => [...d.context.messages, ...prev]);
setEntryIds((prev) => [...d.context.entryIds, ...prev]);
Comment on lines +537 to +540

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reveal prepended history pages

When before is set, this prepends the older page but ChatWindow still renders only getVisibleRenderWindow(rendered.length, visibleCount), with visibleCount left at the initial page size. For a long session, the prepended entries land before startIndex and are immediately sliced away, so scrolling up never exposes history older than the default tail (and an initial exactly-50-entry tail does not render the sentinel at all). The pagination path needs to expand/track the visible window or otherwise render the newly loaded page.

Useful? React with 👍 / 👎.

} else {
setMessages(d.context.messages);
setEntryIds(d.context.entryIds ?? []);
}
} catch (e) {
console.error("Failed to load context:", e);
}
Expand Down Expand Up @@ -1939,7 +1948,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
handleCompact, handleSteer, handleFollowUp, handlePromptWithStreamingBehavior, handleAbortCompaction,
handleRecallQueue,
handleBuiltinSlashCommand,
handleToolPresetChange, handleThinkingLevelChange, loadTools, loadSlashCommands, setActiveLeafId, setData, setMessages,
handleToolPresetChange, handleThinkingLevelChange, loadTools, loadSlashCommands, setActiveLeafId, setData, setMessages, loadContext,
scrollToBottom, scrollUserMsgToTop,
dispatch, setAgentRunning, setForkingEntryId,
bashRunning, pendingBash,
Expand Down
Loading