Skip to content
Merged
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
14 changes: 13 additions & 1 deletion apps/desktop/src/features/chat/transcript/ChatTranscript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { TranscriptHistory, TranscriptTail } from "./AssistantTurn";
import { collectLiveDelegationEntries } from "../../../lib/live-delegation-entries";
import { reuseReadonlyMap } from "../../../lib/assistant-turns";
import { useTranscriptScroll } from "./hooks/useTranscriptScroll";
import { useTranscriptContinuation } from "../../../hooks/use-transcript-continuation";
import type { TranscriptSearchTarget } from "../../../lib/transcript-reading";
import type { RestoreTranscriptReading } from "../../../hooks/use-transcript-reading-position";
import { TranscriptSearchContext } from "../../../lib/transcript-search-context";
Expand Down Expand Up @@ -158,6 +159,17 @@ function TranscriptBody({
onRestoreReading,
});

const continuationCursor = useAppStore((state) =>
sessionId ? state.transcriptViews[sessionId]?.messageEnd : undefined,
);
const automaticContinuation = readingWindow && !searchTarget;
useTranscriptContinuation({
scrollRef, contentRef, messages, onLoadNewer,
enabled: automaticContinuation && hasMoreAfter && paneVisible && !veilCovering,
cursor: continuationCursor,
loading: navigationLoading,
});

const specializedActivity = agentActivity;
const rawLiveDelegations = useMemo(
() => collectLiveDelegationEntries(historyEntries, tailEntry, transcriptRunning),
Expand Down Expand Up @@ -293,7 +305,7 @@ function TranscriptBody({
runtimeActivity={specializedActivity}
/>
) : null}
{hasMoreAfter ? (
{hasMoreAfter && !automaticContinuation ? (
<button
type="button"
className="transcript-load-later"
Expand Down
51 changes: 51 additions & 0 deletions apps/desktop/src/hooks/use-transcript-continuation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useEffect, useRef, type RefObject } from "react";
import type { UiMessage } from "@pi-desktop/shared";

/** Continue a restored reading range without turning it into a manual search page. */
export function useTranscriptContinuation({
scrollRef, contentRef, enabled, loading, messages, cursor, onLoadNewer,
}: {
scrollRef: RefObject<HTMLDivElement | null>;
contentRef: RefObject<HTMLDivElement | null>;
enabled: boolean;
loading: boolean;
messages: UiMessage[];
cursor?: number;
/** The reading service owns request coalescing, stale results and error reporting. */
onLoadNewer?: () => Promise<void>;
}) {
const attemptedRef = useRef<{ cursor?: number } | null>(null);
useEffect(() => {
const scroller = scrollRef.current;
const content = contentRef.current;
if (!enabled || loading || !onLoadNewer || !scroller || !content) return;
let requested = false;
const check = () => {
if (requested || scroller.clientHeight <= 0) return;
if (scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop > 240) return;
// A failed/empty page must not spin on loading, resize or queued scroll
// events. New reading input can retry; new rows can fill a short viewport.
if (attemptedRef.current?.cursor === cursor && attemptedRef.current !== null) return;
requested = true;
attemptedRef.current = { cursor };
void onLoadNewer();
};
const onScroll = () => check();
scroller.addEventListener("scroll", onScroll, { passive: true });
const onInput = () => {
attemptedRef.current = null;
check();
};
const inputEvents = ["wheel", "touchmove", "pointerdown", "keydown"] as const;
for (const event of inputEvents) scroller.addEventListener(event, onInput, { passive: true });
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => check());
observer?.observe(content);
observer?.observe(scroller);
check();
return () => {
scroller.removeEventListener("scroll", onScroll);
for (const event of inputEvents) scroller.removeEventListener(event, onInput);
observer?.disconnect();
};
}, [contentRef, cursor, enabled, loading, messages, onLoadNewer, scrollRef]);
}
16 changes: 12 additions & 4 deletions apps/desktop/src/lib/transcript-reading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,10 @@ function replaceReadingOverlap(
/** Live output remains authoritative outside an explicit historical search. */
export function transcriptViewMessages(live: UiMessage[], view?: TranscriptView): UiMessage[] {
if (!view) return live;
// Search deliberately holds its historical snapshot. Do not index or merge
// the live transcript when it cannot contribute anything to this view.
if (view.focus || view.restored) return withNavigationParent(view.messages, view.parentMessage);
// Search holds its selected snapshot. Restoration only stays isolated while
// unread physical pages separate it from the live tail.
if (view.focus || (view.restored && view.hasMoreAfter))
return withNavigationParent(view.messages, view.parentMessage);

const normalized = dedupeSessionMessages(live);
const source = getSessionMessageSnapshot(normalized);
Expand All @@ -121,8 +122,15 @@ export function transcriptViewMessages(live: UiMessage[], view?: TranscriptView)
const cached = projections.get(source.owner);
let messages = cached && replaceReadingOverlap(cached, source, view.parentMessage);
if (!messages) {
// The live cache can start before a short restored range. Joining at the
// first overlapping identity avoids prepending rows and moving the anchor;
// without overlap, do not invent continuity across an unknown gap.
const restoredStart = view.restored
? normalized.findIndex((message) => getSessionMessageSnapshot(view.messages).positions.has(message.id))
: 0;
const liveTail = restoredStart < 0 ? EMPTY_TRANSCRIPT : normalized.slice(restoredStart);
messages = withNavigationParent(
mergeLiveSessionMessages(view.messages, normalized).map((message) => {
mergeLiveSessionMessages(view.messages, liveTail).map((message) => {
// A stale reading snapshot must not outrank the canonical row, even
// when a previously streaming row has just completed.
const index = source.positions.get(message.id);
Expand Down
35 changes: 35 additions & 0 deletions apps/desktop/test/transcript-reading.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -576,3 +576,38 @@ test("selection revalidation cannot remove a restored range, but an explicit rew
r.set({ messages: [message("tail", "edited")] });
assert.equal(r.view(), undefined);
});

test("a restored range joins live output only after paging closes the gap", async () => {
const calls = [];
const canonical = [message("cached-before"), message("tail", "streaming")];
const r = reader(async (_id, options) => {
calls.push(options);
return calls.length === 1
? page([message("old")], { messageStart: 20, messageEnd: 80 })
: page([message("tail", "persisted")], {
messageStart: 80, messageEnd: 100, hasMoreAfter: false,
});
}, { messages: canonical, runningSessions: { s: true } });
await r.restoreTranscriptReading("s", bookmark, new AbortController().signal);
assert.deepEqual(r.visible().map((row) => row.id), ["old"], "do not skip unloaded history");
await r.loadTranscriptPage("s", "after");
assert.equal(r.view().hasMoreAfter, false);
assert.deepEqual(r.visible().map((row) => row.id), ["old", "tail"], "do not prepend cached rows on continuation");
assert.equal(r.visible().at(-1).content, "streaming");
r.set({ messages: [message("cached-before"), message("tail", "complete"), message("new")], runningSessions: {} });
assert.deepEqual(r.visible().map((row) => row.id), ["old", "tail", "new"]);
assert.equal(r.visible().find((row) => row.id === "tail").content, "complete");
assert.equal(r.get().messages.some((row) => row.id === "old"), false, "reading must not populate model input");
});

test("a restored range already at the tail uses canonical updates without adding older cached rows", () => {
const restored = { ...transcriptViewFromSession(page([message("tail", "persisted")], {
messageStart: 90, messageEnd: 100, hasMoreAfter: false,
}).session), restored: true };
const live = [message("before"), message("tail", "latest"), message("next")];
assert.deepEqual(transcriptViewMessages(live, restored).map((row) => [row.id, row.content]),
[["tail", "latest"], ["next", "next"]]);
const search = { ...restored, focus: { ...target("tail"), requestId: 1 } };
assert.deepEqual(transcriptViewMessages(live, search).map((row) => [row.id, row.content]),
[["tail", "persisted"]], "explicit search keeps its selected snapshot");
});
6 changes: 6 additions & 0 deletions docs/spec/04-ux/08-component-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ Outer frame that positions Topbar, Sidebar, MainChat, and WorkPanel. Owns resize
the message is already loaded but outside the mounted row window, first restore
the saved absolute scroll offset; the bounded window can then grow as the user
scrolls toward the anchor.
Restored ranges continue automatically near the visible lower boundary, in
bounded pages and without a Load later messages button. Appending a page must
preserve the viewport; hidden panes must not initiate continuation. Do not
merge a disjoint live tail across unread history. Once the range reaches the
tail, canonical updates and new live rows resume without prepending older
cached rows. Explicit search retains its separate manual paging behavior.
Explicit disclosure choices and the mounted-window budget survive eviction;
bookmarks do not persist across renderer/app restarts.
- A pane renders the store's live `messages` while its session is the active one
Expand Down
15 changes: 12 additions & 3 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -6150,9 +6150,10 @@ must keep splitting are covered by `markdown-blocks.test.mjs`.
- **Steps**: 1) Open sessions A, B, and C in that order, scrolling each one up a
measured amount. 2) Switch back to A, then B, and confirm each returns to its
own offset. 3) Open D and then E, so A and B fall outside the retained budget.
4) Return to A. 5) Return to E, then D, and confirm they are still warm. 6)
Repeat the whole cycle once more and watch for a stuck progress track, an error
toast, or a blank chat area.
4) Return to A and scroll to the lower boundary of the restored range without
clicking any load control. 5) Return to E, then D, and confirm they are still
warm. 6) Repeat the whole cycle once more and watch for a stuck progress track,
an error toast, or a blank chat area.
- **Expected**: Only the visible pane and the two most recently visited ones are
retained; visiting beyond that budget evicts the oldest pane. A warm return
paints the retained frame and offset immediately. Returning to an evicted
Expand All @@ -6167,6 +6168,14 @@ must keep splitting are covered by `markdown-blocks.test.mjs`.
dimmed at any point, hidden panes stay non-interactive and out of the
accessibility tree, and repeated cycling neither leaks a growing number of
mounted transcripts nor leaves a pane showing another session's rows.
A restored historical range has no Load later messages button: reaching its
lower boundary automatically reads bounded newer pages without moving the
visible anchor. Short ranges fill the viewport automatically; hidden panes do
not initiate reads. Pending reads coalesce, obsolete results cannot replace
a newer navigation, and failures do not cause automatic retry loops. After
continuation reaches the tail, live updates resume without adding older
cached rows above the reading position. Explicit search still uses manual
later-message paging.
- **Specs linked**: `04-ux/08-component-spec.md` §1.6 / §3.5 / §7,
`04-ux/09-interaction-patterns.md` §5, ADR 0130, ADR 0137
- **Acceptance**: C (switch sessions), Quality
Expand Down
5 changes: 3 additions & 2 deletions scripts/e2e-transcript-reading-position.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ app.whenReady().then(async () => {
await window.loadFile(path.join(__dirname, "index.html"));
const scrolling = await window.webContents.executeJavaScript("globalThis.transcriptReadingPositionProbe()");
const session = scrolling.ok ? await window.webContents.executeJavaScript("globalThis.sessionReadingPositionProbe()") : null;
const result = { ok: scrolling.ok && session?.ok, scrolling, session };
const continuation = session?.ok ? await window.webContents.executeJavaScript("globalThis.transcriptContinuationProbe()") : null;
const result = { ok: scrolling.ok && session?.ok && continuation?.ok, scrolling, session, continuation };
console.log("READING_POSITION_PROBE " + JSON.stringify(result));
app.exit(result.ok ? 0 : 1);
} catch (error) { console.error(error); app.exit(1); }
Expand All @@ -69,7 +70,7 @@ app.whenReady().then(async () => {
assert(line, `No probe result (exit=${code}): ${output.slice(-4000)}`);
const result = JSON.parse(line.slice("READING_POSITION_PROBE ".length));
console.log(JSON.stringify(result));
assert.equal(code, 0, result.scrolling?.error ?? result.session?.error ?? output.slice(-4000));
assert.equal(code, 0, result.scrolling?.error ?? result.session?.error ?? result.continuation?.error ?? output.slice(-4000));
assert.equal(result.ok, true);
} finally {
await rm(temp, { recursive: true, force: true });
Expand Down
37 changes: 37 additions & 0 deletions scripts/e2e/session-reading-position.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ globalThis.sessionReadingPositionProbe = async () => {
const bridge = window.piDesktop;
let firstARead = true;
const aroundReads: string[] = [];
const forwardReads: number[] = [];
const unexpected: string[] = [];
window.piDesktop = {
channels: IPC, platform: "win32", on: () => () => {},
Expand All @@ -61,6 +62,11 @@ globalThis.sessionReadingPositionProbe = async () => {
assert(index >= 0, "missing anchor");
start = Math.max(0, index - 20);
end = Math.min(messages.length, index + 40);
} else if ("messageBefore" in input && typeof input.messageBefore === "number") {
const limit = "messageLimit" in input ? Number(input.messageLimit) : 100;
end = Math.min(input.messageBefore, messages.length);
start = Math.max(0, end - limit);
forwardReads.push(start);
} else if (input.id === "real-A" && firstARead) {
firstARead = false;
start = 0;
Expand Down Expand Up @@ -126,6 +132,37 @@ globalThis.sessionReadingPositionProbe = async () => {
assert(host.querySelectorAll("[data-session-pane]").length === 3, "pane count changed");
assert(useAppStore.getState().messages[0]?.id === "real-m110", "restore changed canonical/live input");
const restoredOffset = offset(id);
assert(!scroller().querySelector(".transcript-load-later"), "ordinary restoration must not expose manual paging");
const readsBefore = forwardReads.length;
const continuation = useAppStore.getState().transcriptViews["real-A"];
assert(continuation?.hasMoreAfter, "restore must leave a real gap to exercise continuation");
const end = continuation.messageEnd!;
const boundaryId = `real-m${end - 1}`;
const boundary = row(boundaryId)!;
scroller().dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: 1000 }));
scroller().scrollTop = scroller().scrollHeight - scroller().clientHeight;
const boundaryOffset = boundary.getBoundingClientRect().top - scroller().getBoundingClientRect().top;
scroller().dispatchEvent(new Event("scroll", { bubbles: true }));
await until(() => useAppStore.getState().transcriptViews["real-A"]?.hasMoreAfter === false && Boolean(row("real-m139")),
"scrolling continues restored history without a button");
assert(forwardReads.length > readsBefore, "continuation must perform a physical page read");
const continuedOffset = row(boundaryId)!.getBoundingClientRect().top - scroller().getBoundingClientRect().top;
assert(Math.abs(continuedOffset - boundaryOffset) < 2,
`appending a continuation page moved the reading viewport: ${continuedOffset} vs ${boundaryOffset}`);
assert(row("real-m139"), "continuation did not reach the latest row");
assert(useAppStore.getState().messages[0]?.id === "real-m110", "continuation changed model input");
await useAppStore.getState().navigateTranscript({ sessionId: "real-A", messageId: id, query: "Message" });
await until(() => Boolean(scroller().querySelector(".transcript-load-later")), "explicit search keeps manual continuation");
const searchReads = forwardReads.length;
scroller().scrollTop = scroller().scrollHeight - scroller().clientHeight;
scroller().dispatchEvent(new Event("scroll", { bubbles: true }));
await frame();
await frame();
assert(forwardReads.length === searchReads, "search must not automatically load later pages");
const loadLater = scroller().querySelector<HTMLButtonElement>(".transcript-load-later")!;
flushSync(() => loadLater.click());
await until(() => useAppStore.getState().transcriptViews["real-A"]?.hasMoreAfter === false,
"explicit search can still load later pages manually");
const jump = host.querySelector<HTMLButtonElement>('[data-visible="true"] .jump-latest-btn')!;
flushSync(() => jump.click());
await until(() => !useAppStore.getState().transcriptViews["real-A"] &&
Expand Down
Loading
Loading