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
7 changes: 4 additions & 3 deletions apps/desktop/src/components/SessionPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,17 @@ export const SessionPane = memo(function SessionPane({
onLoadOlder={() => loadTranscriptPage(sessionId, "before")}
searchTarget={searchTarget}
readingWindow={transcript.historical}
liveTail={transcript.liveTail}
onRestoreReading={restoreReading}
hasMoreAfter={transcript.hasMoreAfter}
onLoadNewer={() => loadTranscriptPage(sessionId, "after")}
onReturnToLatest={() => returnToLatest(sessionId)}
navigationLoading={transcript.loading}
isRunning={isRunning}
pendingPermission={transcript.historical ? undefined : pendingPermission}
pendingPermission={transcript.liveTail ? pendingPermission : undefined}
queuedPermissions={queuedPermissions}
askPending={transcript.historical ? false : askPending}
planningState={transcript.historical ? undefined : planningState}
askPending={transcript.liveTail ? askPending : false}
planningState={transcript.liveTail ? planningState : undefined}
paneVisible={visible}
/>
</TranscriptDisclosureProvider>
Expand Down
10 changes: 7 additions & 3 deletions apps/desktop/src/features/chat/transcript/ChatTranscript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ type ChatTranscriptProps = {
paneVisible?: boolean;
searchTarget?: TranscriptSearchTarget | null;
readingWindow?: boolean;
/** The displayed range includes this session's canonical live tail. */
liveTail?: boolean;
onRestoreReading?: RestoreTranscriptReading;
hasMoreAfter?: boolean;
onLoadNewer?: () => Promise<void>;
Expand Down Expand Up @@ -89,6 +91,7 @@ function TranscriptBody({
paneVisible = true,
searchTarget = null,
readingWindow = false,
liveTail = !readingWindow,
onRestoreReading,
hasMoreAfter = false,
onLoadNewer,
Expand All @@ -99,7 +102,7 @@ function TranscriptBody({
const openTranscriptMenu = useTranscriptMenu();
const { copyText, selectText } = useChatTextActions();
const showToast = useAppStore((state) => state.showToast);
const transcriptRunning = isRunning && !readingWindow;
const transcriptRunning = isRunning && liveTail;
const latestTurnResult = useAppStore((state) =>
sessionId ? state.latestTurnResults[sessionId] : undefined,
);
Expand Down Expand Up @@ -148,7 +151,8 @@ function TranscriptBody({
compactions,
hasMoreBefore,
onLoadOlder,
isRunning: transcriptRunning,
// Rejoining live output is not a new turn: keep the reader's scroll owner.
isRunning: isRunning && !readingWindow,
pendingPermission,
askPending,
approvalPending,
Expand Down Expand Up @@ -315,7 +319,7 @@ function TranscriptBody({
{t("chat.loadLaterMessages")}
</button>
) : null}
{!readingWindow ? (
{liveTail ? (
<TurnOutcomeCard
messages={messages}
result={latestTurnResult}
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/hooks/use-transcript-view.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { EMPTY_TRANSCRIPT, transcriptViewMessages } from "../lib/transcript-reading";
import { EMPTY_TRANSCRIPT, isTranscriptAtLiveTail, transcriptViewMessages } from "../lib/transcript-reading";
import { useAppStore } from "../stores/app-store";

/** Main transcript and subagent details consume the same renderer reading range. */
Expand All @@ -19,6 +19,7 @@ export function useTranscriptView(sessionId: string) {
focus: view?.focus ?? null,
parentMessage: view?.parentMessage,
historical: Boolean(view?.focus || view?.restored),
liveTail: isTranscriptAtLiveTail(live, messages, view),
loading: Boolean(view?.loading),
};
}
13 changes: 13 additions & 0 deletions apps/desktop/src/lib/transcript-reading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ export const TRANSCRIPT_PAGE_SIZE = 100;
export const TRANSCRIPT_SEARCH_PAGE_SIZE = 60;
export const TRANSCRIPT_CONTENT_LIMIT = 64 * 1024;

/** Reading ownership is independent of whether its range reaches live output. */
export function isTranscriptAtLiveTail(
live: readonly UiMessage[],
messages: readonly UiMessage[],
view?: TranscriptView,
): boolean {
if (view?.focus || view?.hasMoreAfter) return false;
// The canonical surface must show first-event waits even before it has rows.
if (!view) return true;
const tailId = live.at(-1)?.id;
return Boolean(tailId) && messages.at(-1)?.id === tailId;
}

export function transcriptViewFromSession(
session: SessionDetail,
focus: TranscriptSearchTarget | null = null,
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/test/permission-inline.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ test("asktool card is a stepwise, non-expiring composer question surface", () =>
assert.match(composerSource, /headAsk\(s\.pendingAsks/);
assert.doesNotMatch(transcriptSource, /AskToolCard/);
// Each retained pane subscribes to its own session's ask queue (ADR 0137).
assert.match(sessionPaneSource, /askPending=\{transcript\.historical \? false : askPending\}/);
assert.match(sessionPaneSource, /askPending=\{transcript\.liveTail \? askPending : false\}/);
assert.match(sessionPaneSource, /headAsk\(state\.pendingAsks, sessionId\)/);
assert.match(storeSource, /event\.type === "asktool_request"/);
assert.match(askCardSource, /current\.multiSelect/);
Expand Down Expand Up @@ -243,7 +243,7 @@ test("permission approval is an inline transcript card, never a global dialog",
assert.match(chatSurfaceSource, /headPermission\(state\.pendingPermissions/);
assert.match(
sessionPaneSource,
/pendingPermission=\{transcript\.historical \? undefined : pendingPermission\}/,
/pendingPermission=\{transcript\.liveTail \? pendingPermission : undefined\}/,
);
assert.match(
sessionPaneSource,
Expand Down
32 changes: 31 additions & 1 deletion apps/desktop/test/transcript-reading.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url));
const { createTranscriptReadingRuntime } = await import(
"../src/stores/runtime/transcript-reading-runtime.ts"
);
const { transcriptViewMessages, transcriptViewFromSession } = await import("../src/lib/transcript-reading.ts");
const { isTranscriptAtLiveTail, transcriptViewMessages, transcriptViewFromSession } = await import("../src/lib/transcript-reading.ts");
const { getSessionMessageSnapshot } = await import("../src/lib/session-transcript-updates.ts");
const { mergeLiveSessionMessages, upsertLiveSessionMessage, projectMessageEnd, removeLiveSessionMessage } = await import("../src/lib/session-transcript.ts");
const { buildTranscriptEntries } = await import("../src/lib/assistant-turns.ts");
Expand Down Expand Up @@ -73,6 +73,36 @@ function reader(read, overrides = {}) {
};
}

test("a canonical empty transcript can present its first-event wait, while empty reading ranges cannot", () => {
assert.equal(isTranscriptAtLiveTail([], []), true);
const view = { ...transcriptViewFromSession(page([], { hasMoreAfter: false }).session), restored: true };
assert.equal(isTranscriptAtLiveTail([], transcriptViewMessages([], view), view), false);
assert.equal(isTranscriptAtLiveTail([message("tail")], [], view), false);
const disconnected = { ...view, messages: [message("old")] };
assert.equal(isTranscriptAtLiveTail([], transcriptViewMessages([], disconnected), disconnected), false);
const unidentified = [message("")];
assert.equal(isTranscriptAtLiveTail(unidentified, unidentified, view), false);
});

test("live-tail identity survives a restored navigation parent's object copy", () => {
const live = [message("tail", "live")];
const copied = { ...live[0] };
const view = { ...transcriptViewFromSession(page([copied], { hasMoreAfter: false }).session),
restored: true, parentMessage: copied };
const visible = transcriptViewMessages(live, view);
assert.notEqual(visible.at(-1), live.at(-1), "exercise the real navigation-parent projection");
assert.equal(isTranscriptAtLiveTail(live, visible, view), true);
assert.equal(isTranscriptAtLiveTail(live, [{ ...copied }], view), true, "copied rows keep their wire identity");
});

test("unread gaps, explicit search and different tail identities remain historical", () => {
const live = [message("tail")];
const view = transcriptViewFromSession(page(live, { hasMoreAfter: false }).session);
assert.equal(isTranscriptAtLiveTail(live, live, { ...view, hasMoreAfter: true }), false);
assert.equal(isTranscriptAtLiveTail(live, live, { ...view, focus: { ...target("tail"), requestId: 1 } }), false);
assert.equal(isTranscriptAtLiveTail(live, [message("other")], view), false);
});

test("a search jump keeps complete focused text without changing live/model input", async () => {
const calls = [];
const content = `${"prefix ".repeat(15_000)}needle`;
Expand Down
8 changes: 8 additions & 0 deletions docs/spec/04-ux/09-interaction-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,14 @@ may be retained while exactly one workspace supplies the visible shell context.
- Background sessions continue without adding progress chrome to the visible
session or moving focus. Reduced motion therefore has no progress-card
transitions to preserve.
- A restored reading range that has reached the canonical live tail presents
the session's running phase, elapsed time, and interaction waits normally.
Keeping its reading bookmark does not mean the turn has finished. Rejoining
live output preserves the viewport rather than acting like a new turn.
Explicit search, unread later pages, and disjoint snapshots remain historical.
Reading ranges require a non-empty canonical message identity, compared by
message id across history object copies. The ordinary canonical surface still
shows the first-event wait before its first message arrives.

### 2.5 Turn outcome closure

Expand Down
21 changes: 21 additions & 0 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -2104,6 +2104,27 @@ identify the platform validation still needed.
- **Milestone**: M2
- **Status**: Source-level regression covered; full visual scenario Draft

#### E2E-TRANSCRIPT-restored-live-tail-status

- **Preconditions**: An isolated renderer with two retained session panes, a
running turn with tool output, and the real Composer and SessionPane.
- **Steps**: Restore a bookmark during selection revalidation; append live
output; switch away and reveal the running pane; inspect an unread history
range and an explicit search at the tail; settle and abort the turn.
- **Expected**: The restored live tail keeps a running process header while
Stop is available. Content updates and pane reveal do not falsely show
Finished. The restored range and scroll offset remain owned by the reader.
Another session, unread pages, and explicit search do not inherit live
status. Terminal completion and abort show Finished and Stopped respectively.
An empty restored range stays historical, an ordinary empty canonical range
shows the initial wait, and a restored navigation-parent copy with the same
message id keeps the running presentation.
- **Automation**: `node scripts/e2e-transcript-reading-position.mjs`
(`transcriptLiveTailProbe`).
- **Specs linked**: `04-ux/09-interaction-patterns.md` §2.4
- **Acceptance**: C (session switching and running status)
- **Status**: Automated isolated Electron regression

#### E2E-207: Open large and long sessions through bounded transcript windows

- **Preconditions**: A session contains a message with at least 50 MB of text
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 @@ -51,7 +51,8 @@ app.whenReady().then(async () => {
const scrolling = await window.webContents.executeJavaScript("globalThis.transcriptReadingPositionProbe()");
const session = scrolling.ok ? await window.webContents.executeJavaScript("globalThis.sessionReadingPositionProbe()") : null;
const continuation = session?.ok ? await window.webContents.executeJavaScript("globalThis.transcriptContinuationProbe()") : null;
const result = { ok: scrolling.ok && session?.ok && continuation?.ok, scrolling, session, continuation };
const liveTail = continuation?.ok ? await window.webContents.executeJavaScript("globalThis.transcriptLiveTailProbe()") : null;
const result = { ok: scrolling.ok && session?.ok && continuation?.ok && liveTail?.ok, scrolling, session, continuation, liveTail };
console.log("READING_POSITION_PROBE " + JSON.stringify(result));
app.exit(result.ok ? 0 : 1);
} catch (error) { console.error(error); app.exit(1); }
Expand All @@ -70,7 +71,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 ?? result.continuation?.error ?? output.slice(-4000));
assert.equal(code, 0, result.scrolling?.error ?? result.session?.error ?? result.continuation?.error ?? result.liveTail?.error ?? output.slice(-4000));
assert.equal(result.ok, true);
} finally {
await rm(temp, { recursive: true, force: true });
Expand Down
Loading
Loading