diff --git a/apps/desktop/src/components/SessionPane.tsx b/apps/desktop/src/components/SessionPane.tsx index c40194fbb0..39428ad9d0 100644 --- a/apps/desktop/src/components/SessionPane.tsx +++ b/apps/desktop/src/components/SessionPane.tsx @@ -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} /> diff --git a/apps/desktop/src/features/chat/transcript/ChatTranscript.tsx b/apps/desktop/src/features/chat/transcript/ChatTranscript.tsx index 1012190411..ec0d8381be 100644 --- a/apps/desktop/src/features/chat/transcript/ChatTranscript.tsx +++ b/apps/desktop/src/features/chat/transcript/ChatTranscript.tsx @@ -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; @@ -89,6 +91,7 @@ function TranscriptBody({ paneVisible = true, searchTarget = null, readingWindow = false, + liveTail = !readingWindow, onRestoreReading, hasMoreAfter = false, onLoadNewer, @@ -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, ); @@ -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, @@ -315,7 +319,7 @@ function TranscriptBody({ {t("chat.loadLaterMessages")} ) : null} - {!readingWindow ? ( + {liveTail ? ( 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/); @@ -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, diff --git a/apps/desktop/test/transcript-reading.test.mjs b/apps/desktop/test/transcript-reading.test.mjs index e9c1cfc24a..bb9926e5f0 100644 --- a/apps/desktop/test/transcript-reading.test.mjs +++ b/apps/desktop/test/transcript-reading.test.mjs @@ -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"); @@ -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`; diff --git a/docs/spec/04-ux/09-interaction-patterns.md b/docs/spec/04-ux/09-interaction-patterns.md index 0cc6a2bb81..1ffd8965f9 100644 --- a/docs/spec/04-ux/09-interaction-patterns.md +++ b/docs/spec/04-ux/09-interaction-patterns.md @@ -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 diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 1e8b666fdc..8973c5ec4c 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -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 diff --git a/scripts/e2e-transcript-reading-position.mjs b/scripts/e2e-transcript-reading-position.mjs index 36abe47591..edafd6c81a 100644 --- a/scripts/e2e-transcript-reading-position.mjs +++ b/scripts/e2e-transcript-reading-position.mjs @@ -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); } @@ -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 }); diff --git a/scripts/e2e/transcript-live-tail.tsx b/scripts/e2e/transcript-live-tail.tsx new file mode 100644 index 0000000000..614e56fce2 --- /dev/null +++ b/scripts/e2e/transcript-live-tail.tsx @@ -0,0 +1,171 @@ +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { createInstance } from "i18next"; +import { I18nextProvider } from "react-i18next"; +import { en } from "@pi-desktop/i18n"; +import type { UiMessage } from "@pi-desktop/shared"; +import { SessionPane } from "../../apps/desktop/src/components/SessionPane"; +import { Composer } from "../../apps/desktop/src/components/Composer"; +import { useAppStore } from "../../apps/desktop/src/stores/app-store"; +import type { AppState } from "../../apps/desktop/src/stores/app-state"; + +const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); +function assert(condition: unknown, label: string): asserts condition { + if (!condition) throw new Error(label); +} +async function until(predicate: () => boolean, label: string) { + for (let index = 0; index < 240; index++) { + if (predicate()) return; + await frame(); + } + throw new Error(`Timed out: ${label}`); +} +function Surface() { + const active = useAppStore((state) => state.activeSessionId); + return <> +
+ {["live-tail", "other-tail"].map((id) => )} +
+ + ; +} + +declare global { var transcriptLiveTailProbe: () => Promise; } +globalThis.transcriptLiveTailProbe = async () => { + const i18n = createInstance(); + await i18n.init({ lng: "en", resources: { en: { translation: en } } }); + const initial = useAppStore.getState(); + const host = document.createElement("div"); + host.style.cssText = "position:fixed;inset:0;display:flex;flex-direction:column"; + document.body.append(host); + const root = createRoot(host); + const createdAt = new Date(Date.now() - 30_000).toISOString(); + const user: UiMessage = { id: "live-user", role: "user", + content: Array.from({ length: 30 }, (_, index) => `Inspect file ${index}.`).join("\n\n"), createdAt }; + const tool: UiMessage = { id: "live-tool", role: "tool", content: "file contents", toolName: "Read", + toolStatus: "success", toolCallId: "live-read", createdAt }; + const answer: UiMessage = { id: "live-answer", role: "assistant", content: "Still inspecting.", status: "streaming", createdAt }; + let messages = [user, tool, answer]; + const set = (patch: Partial) => flushSync(() => useAppStore.setState(patch)); + const label = () => host.querySelector('[data-session-pane="live-tail"] .tool-activity-label'); + const activeLabel = () => Boolean(label()?.classList.contains("running")); + const stop = () => host.querySelector('[aria-label="' + i18n.t("chat.stopGenerating") + '"]'); + try { + set({ + activeSessionId: "live-tail", selectingSessionId: "live-tail", messages, + retainedSessionIds: ["live-tail", "other-tail"], retainedTranscripts: { "other-tail": [] }, + sessions: ["live-tail", "other-tail"].map((id) => ({ id, title: id, mode: "agent", permissionMode: "ask", + thinkingLevel: "off", createdAt, updatedAt: createdAt, messageCount: messages.length })), + sessionHistory: {}, transcriptViews: {}, sessionCompactions: {}, pendingPlans: {}, + pendingPermissions: {}, pendingAsks: {}, planningStates: {}, agentStatuses: {}, + runningSessions: { "live-tail": true }, isRunning: true, queuedPrompts: {}, + providers: [], providerModels: {}, plugins: [], workspace: undefined, + settings: { defaultMode: "agent", theme: "light", enterToSend: true, onboardingDismissed: true, + ...initial.settings, smoothStreaming: false }, + }); + flushSync(() => root.render()); + await frame(); + assert(activeLabel(), "ordinary live process must be running"); + assert(stop(), "the running composer must offer Stop"); + set({ messages: [], selectingSessionId: undefined }); + await frame(); + assert(host.querySelector('[data-session-pane="live-tail"] [data-testid="working-indicator"]'), + "an empty canonical transcript still shows the initial model wait"); + set({ transcriptViews: { "live-tail": { + messages: [], messageStart: 0, hasMoreBefore: false, hasMoreAfter: false, + focus: null, restored: true, loading: null, + } } }); + await frame(); + assert(!host.querySelector('[data-session-pane="live-tail"] .transcript-runtime-status > *'), + "an empty restored range cannot prove it reaches a canonical live tail"); + set({ messages, transcriptViews: {}, selectingSessionId: "live-tail" }); + await frame(); + const bookmark = { pinned: false, messageId: user.id, offset: 0, scrollTop: 0, windowSize: 60 }; + // Public restore action used by a retained pane during selection revalidation. + await useAppStore.getState().restoreTranscriptReading("live-tail", bookmark, new AbortController().signal); + await frame(); + assert(useAppStore.getState().transcriptViews["live-tail"]?.restored, "exercise an actual restored view"); + assert(activeLabel(), "restored live tail must not say Finished while the composer can stop it"); + const duration = () => host.querySelector('[data-session-pane="live-tail"] .turn-duration')?.textContent; + const beforeTick = duration(); + await until(() => duration() !== beforeTick, "elapsed time keeps ticking without output deltas"); + set({ planningStates: { "live-tail": "planning" } }); + assert(host.querySelector('[data-session-pane="live-tail"] [data-testid="planning-indicator"]'), + "restored live tail keeps its planning feedback"); + set({ planningStates: {}, pendingPermissions: { "live-tail": [{ + sessionId: "live-tail", requestId: "permission", toolCallId: "permission-call", toolName: "Bash", + argsPreview: { command: "pwd" }, risk: "low", reason: "fixture", + }] } }); + assert(host.querySelector('[data-session-pane="live-tail"] .permission-card button'), + "restored live tail must not hide an actionable approval"); + assert(!host.querySelector('[data-session-pane="live-tail"] .transcript-runtime-status > *'), + "approval replaces generic running feedback"); + set({ pendingPermissions: {}, pendingAsks: { "live-tail": [{ + sessionId: "live-tail", requestId: "question", toolCallId: "question-call", + questions: [{ question: "Continue?", options: ["Yes", "No"] }], + }] } }); + assert(!host.querySelector('[data-session-pane="live-tail"] .transcript-runtime-status > *'), + "a question replaces generic running feedback in a restored live tail"); + set({ pendingAsks: {} }); + const reading = useAppStore.getState().transcriptViews["live-tail"]; + const scroll = host.querySelector('[data-session-pane="live-tail"] .thread-scroll')!; + assert(scroll.scrollHeight > scroll.clientHeight + 200, "exercise an overflowing transcript"); + scroll.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -500 })); + scroll.scrollTop = 160; + scroll.dispatchEvent(new Event("scroll", { bubbles: true })); + await frame(); + const scrollTop = scroll.scrollTop; + assert(scrollTop > 0 && scrollTop < scroll.scrollHeight - scroll.clientHeight, + "the reader must be away from both ends of the transcript"); + messages = [user, tool, { ...answer, content: "Output continues after restoration." }]; + set({ messages, selectingSessionId: undefined }); + await frame(); + assert(host.textContent?.includes("Output continues after restoration."), "canonical output must keep updating"); + assert(activeLabel(), "a content delta must not turn the header into Finished"); + assert(useAppStore.getState().transcriptViews["live-tail"] === reading && scroll.scrollTop === scrollTop, + "live status must preserve the restored range and reading offset"); + set({ activeSessionId: "other-tail", messages: [], retainedTranscripts: { "live-tail": messages, "other-tail": [] }, isRunning: false }); + await frame(); + assert(!host.querySelector('[data-session-pane="other-tail"] .turn-process.active'), "running state must not leak to another session"); + set({ activeSessionId: "live-tail", messages, isRunning: true }); + await frame(); + assert(activeLabel(), "revealing a retained running session must preserve its status"); + const view = useAppStore.getState().transcriptViews["live-tail"]!; + set({ transcriptViews: { "live-tail": { ...view, hasMoreAfter: true } } }); + await frame(); + assert(!activeLabel(), "a restored page with an unread gap remains historical"); + set({ transcriptViews: { "live-tail": { ...view, focus: { sessionId: "live-tail", messageId: user.id, query: "", requestId: 1 } } } }); + await frame(); + assert(!activeLabel(), "an explicit search at the tail must not claim a live process"); + set({ transcriptViews: { "live-tail": { ...view, messages: [ + { ...user, id: "disjoint-user" }, { ...tool, id: "disjoint-tool" }, + ] } } }); + await frame(); + assert(!activeLabel(), "a disjoint range must not claim the canonical live turn"); + const copiedTool = { ...tool }; + set({ messages: [tool], transcriptViews: { "live-tail": { + ...view, messages: [copiedTool], parentMessage: copiedTool, + } } }); + await frame(); + assert(activeLabel(), "a restored canonical message keeps its identity across history object copies"); + set({ messages }); + set({ transcriptViews: { "live-tail": view }, runningSessions: {}, isRunning: false }); + await frame(); + assert(label()?.textContent === i18n.t("chat.processCompleted") && !activeLabel(), "only settled run state shows Finished"); + set({ messages: [user, tool, { ...answer, status: "aborted" }] }); + await frame(); + assert(label()?.textContent === i18n.t("chat.processStopped"), "abort retains its stopped status"); + set({ messages: [user, tool, { ...answer, status: "complete", error: { + code: "UNKNOWN", message: "Fixture failure", retriable: false, + } }] }); + await frame(); + assert(label()?.textContent === i18n.t("chat.processFailed"), "failure retains its failed status"); + return { ok: true, restoredLiveStatus: true, retainedSessionStatus: true, historicalIsolation: true }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.stack : String(error), label: label()?.textContent, stop: Boolean(stop()) }; + } finally { + flushSync(() => root.unmount()); + host.remove(); + useAppStore.setState(initial); + } +}; diff --git a/scripts/e2e/transcript-reading-position.tsx b/scripts/e2e/transcript-reading-position.tsx index dfb4887abb..f7d704f2f8 100644 --- a/scripts/e2e/transcript-reading-position.tsx +++ b/scripts/e2e/transcript-reading-position.tsx @@ -1,5 +1,6 @@ import "./session-reading-position"; import "./transcript-continuation"; +import "./transcript-live-tail"; import { useCallback } from "react"; import { createRoot } from "react-dom/client"; import { flushSync } from "react-dom";