From a678d200dbb2d164fe5a65669e4f9ba99c6da09b Mon Sep 17 00:00:00 2001 From: shabhui <264076396+shabhui@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:26:34 +0800 Subject: [PATCH 1/4] fix(desktop): reduce retained transcript work and settle Stop Suspend background transcript clocks and projections without losing mounted reading state. Cache immutable turn timing and keep Stop scoped to actual backend settlement before recovering drafts or history. --- apps/desktop/electron/main/ipc/agent-ipc.ts | 4 +- apps/desktop/src/components/SessionPane.tsx | 2 +- .../chat/transcript/ActivityGroup.tsx | 21 +- .../chat/transcript/AssistantTurnParts.tsx | 16 +- .../src/features/chat/transcript/ToolRow.tsx | 23 +- .../features/chat/transcript/TurnProcess.tsx | 4 +- .../features/chat/transcript/disclosure.tsx | 21 +- .../transcript/hooks/useTranscriptScroll.ts | 14 +- .../src/features/chat/transcript/shared.tsx | 6 +- apps/desktop/src/hooks/use-follow-scroll.ts | 36 ++- apps/desktop/src/lib/turn-process.ts | 59 ++-- .../src/stores/runtime/stop-session.ts | 174 ++++++++++++ .../src/stores/slices/transcript-slice.ts | 145 +--------- apps/desktop/test/native-pi-sessions.test.mjs | 10 +- .../test/session-message-input.test.mjs | 33 +++ .../test/session-stop-settlement.test.mjs | 251 ++++++++++++++++++ .../desktop/test/subagent-transcript.test.mjs | 6 +- apps/desktop/test/turn-process.test.mjs | 41 +++ packages/agent-runtime/src/runtime.test.ts | 74 +++++- packages/agent-runtime/src/runtime.ts | 23 +- scripts/e2e-transcript-render.mjs | 19 +- scripts/e2e/transcript-hidden-pane.tsx | 191 +++++++++++++ scripts/e2e/transcript-render.tsx | 3 + 23 files changed, 929 insertions(+), 247 deletions(-) create mode 100644 apps/desktop/src/stores/runtime/stop-session.ts create mode 100644 apps/desktop/test/session-stop-settlement.test.mjs create mode 100644 scripts/e2e/transcript-hidden-pane.tsx diff --git a/apps/desktop/electron/main/ipc/agent-ipc.ts b/apps/desktop/electron/main/ipc/agent-ipc.ts index 39dfb7106b..42460d679f 100644 --- a/apps/desktop/electron/main/ipc/agent-ipc.ts +++ b/apps/desktop/electron/main/ipc/agent-ipc.ts @@ -649,7 +649,9 @@ export function registerAgentIpc({ const abortedTurnId = activeTurns.get(req.sessionId); if (req.turnId && abortedTurnId !== req.turnId) return { ok: false, aborted: false }; logger.app("session", "info", "prompt aborted", { sessionId: req.sessionId }); - agentHostBridge?.markAborting(req.sessionId); + // A duplicate Stop after settlement has no turn whose finalizer can clear + // this marker. Do not carry that cancellation into the next prompt. + if (abortedTurnId) agentHostBridge?.markAborting(req.sessionId); // Lock the abort reason before the first await: the cancel RPC can take a // while, and a terminal event arriving in that window must not settle the // turn as completed. diff --git a/apps/desktop/src/components/SessionPane.tsx b/apps/desktop/src/components/SessionPane.tsx index c40194fbb0..c9b72e0d3a 100644 --- a/apps/desktop/src/components/SessionPane.tsx +++ b/apps/desktop/src/components/SessionPane.tsx @@ -71,7 +71,7 @@ export const SessionPane = memo(function SessionPane({ aria-hidden={visible ? undefined : true} inert={visible ? undefined : true} > - + resolveShowThinking(state.settings)); const hideThinking = !showThinking; const displayMode = useAppStore((state) => resolveTranscriptDisplayMode(state.settings)); @@ -311,11 +312,11 @@ export const ActivityGroup = memo(function ActivityGroup({ useEffect(() => { if (wasActiveRef.current && !live) setFinishedAt(Date.now()); wasActiveRef.current = live; - if (!live) return; + if (!live || !visible) return; setNow(Date.now()); const id = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(id); - }, [live]); + }, [live, visible]); const renderedItems = { - startedAtRef.current = startedAt ?? Date.now(); + if (!visible) return; + if (startedAt !== undefined) startedAtRef.current = startedAt; const updateElapsed = () => { setElapsed(Math.floor((Date.now() - startedAtRef.current) / 1000)); }; updateElapsed(); const timer = window.setInterval(updateElapsed, 1000); return () => window.clearInterval(timer); - }, [startedAt]); + }, [startedAt, visible]); return (
(null); useEffect(() => { + if (!visible) return; setNow(Date.now()); const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer); - }, [activity.since]); + }, [activity.since, visible]); const elapsed = formatToolDuration( Math.max(0, Math.floor((now - activity.since) / 1000)), @@ -476,7 +481,7 @@ export function RunActivityIndicator({ activity }: { activity: AgentActivity }) // anchored 8px above the trigger, so its own bottom edge starts that room - // and let the remainder scroll. useEffect(() => { - if (!retryError) return; + if (!retryError || !visible) return; const reason = retryReasonRef.current; const plate = retryPlateRef.current; if (!reason || !plate) return; @@ -525,7 +530,7 @@ export function RunActivityIndicator({ activity }: { activity: AgentActivity }) scroller?.removeEventListener("scroll", measure); observer?.disconnect(); }; - }, [retryError]); + }, [retryError, visible]); const retryErrorSummary = retryError ? (() => { const key = `errors.${retryError.code}`; diff --git a/apps/desktop/src/features/chat/transcript/AssistantTurnParts.tsx b/apps/desktop/src/features/chat/transcript/AssistantTurnParts.tsx index 89f607f9c0..9760fcb310 100644 --- a/apps/desktop/src/features/chat/transcript/AssistantTurnParts.tsx +++ b/apps/desktop/src/features/chat/transcript/AssistantTurnParts.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { memo, useRef } from "react"; import type { AgentActivity, UiMessage } from "@pi-desktop/shared"; import type { AssistantTurnPart } from "../../../lib/assistant-turns"; import type { SubagentOutcome, SubagentTiming } from "../../../lib/subagent-topology"; @@ -8,18 +8,28 @@ import { useSmoothText } from "../../../hooks/useSmoothText"; import { ActivityGroup } from "./ActivityGroup"; import { AssistantErrorMessage } from "./shared"; import { useRenderBlocks, type RenderBlock } from "./render-blocks"; +import { useTranscriptVisibility } from "./disclosure"; /** Message bubble that optionally applies smooth text release. */ const SmoothMessageBubble = memo(function SmoothMessageBubble({ message, streaming }: { message: UiMessage; streaming: boolean; }) { + const visible = useTranscriptVisibility(); const smoothStreaming = useAppStore((s) => s.settings?.smoothStreaming !== false); const prefersReducedMotion = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; const enabled = smoothStreaming && !prefersReducedMotion; - const displayContent = useSmoothText(message.content || "", streaming, enabled); - const showCursor = streaming && enabled && Boolean(displayContent); + const source = useRef(message.content || ""); + const lastDisplay = useRef(source.current); + const wasVisible = useRef(visible); + const revealed = visible && !wasVisible.current; + if (visible) source.current = message.content || ""; + const smoothContent = useSmoothText(source.current, streaming, enabled && visible && !revealed); + if (visible) lastDisplay.current = smoothContent; + wasVisible.current = visible; + const displayContent = lastDisplay.current; + const showCursor = streaming && enabled && visible && Boolean(displayContent); return (
; } | null>(null); - if (variant !== "topology" && open && hasDetails && disclosure.parentVisible && + if (variant !== "topology" && open && hasDetails && disclosure.workVisible && (presentation.current?.message !== message || presentation.current?.nestedReport !== nestedReport)) { presentation.current = { message, @@ -350,11 +350,11 @@ function HostToolRow({ : ""; useEffect(() => { - if (outcome !== "running") return; + if (outcome !== "running" || !disclosure.workVisible) return; setNow(Date.now()); const id = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(id); - }, [outcome]); + }, [outcome, disclosure.workVisible]); // Auto-scroll the nested `.tool-row-content` containers to their bottom // while the tool is still running. These elements have `max-height: 260px` @@ -363,7 +363,7 @@ function HostToolRow({ // when the container is already near the bottom so a manual scroll-up by // the user is not overridden. useLayoutEffect(() => { - if (status !== "running" || !open) return; + if (status !== "running" || !open || !disclosure.workVisible) return; const body = disclosure.bodyRef.current; if (!body) return; const containers = body.querySelectorAll(".tool-row-content"); @@ -374,7 +374,7 @@ function HostToolRow({ el.scrollTop = el.scrollHeight; } } - }, [status, open, message, disclosure.bodyRef]); + }, [status, open, message, disclosure.bodyRef, disclosure.workVisible]); const statusTone = @@ -678,6 +678,9 @@ function SubagentRunFollow({ scrollable?: boolean; }) { const { t } = useTranslation(); + const visible = useTranscriptVisibility(); + const displayedItems = useRef(items); + if (visible) displayedItems.current = items; const { scrollRef, contentRef, @@ -686,12 +689,12 @@ function SubagentRunFollow({ jumpToLatest, scheduleFollowScroll, disclosureAnchorNotifier, - } = useFollowScroll(); + } = useFollowScroll(visible && scrollable); useLayoutEffect(() => { - if (!scrollable) return; + if (!scrollable || !visible) return; scheduleFollowScroll(); - }, [items, scheduleFollowScroll, scrollable]); + }, [items, scheduleFollowScroll, scrollable, visible]); return ( @@ -710,7 +713,7 @@ function SubagentRunFollow({ onScroll={scrollable ? handleScroll : undefined} >
- {items.map((item) => + {displayedItems.current.map((item) => item.kind === "tool" ? ( reviewChangeFromMessage(item.message) ? ( turnProcessTiming(turnParts), [turnParts]); useEffect(() => { - if (!isActive) return; + if (!isActive || !disclosure.workVisible) return; setNow(Date.now()); const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer); - }, [isActive]); + }, [isActive, disclosure.workVisible]); const scope = useMemo( () => ({ claim: disclosure.claim, visible: disclosure.open }), [disclosure.claim, disclosure.open], diff --git a/apps/desktop/src/features/chat/transcript/disclosure.tsx b/apps/desktop/src/features/chat/transcript/disclosure.tsx index 8f30f50c00..00b1e940ee 100644 --- a/apps/desktop/src/features/chat/transcript/disclosure.tsx +++ b/apps/desktop/src/features/chat/transcript/disclosure.tsx @@ -46,6 +46,7 @@ class DisclosureChoices { } const ChoicesContext = createContext(null); +const PaneVisibilityContext = createContext(true); /* * A level's own visibility is a second gate beside the stored choice, so a @@ -56,11 +57,24 @@ const ChoicesContext = createContext(null); export type { DisclosureScopeValue } from "./disclosure-scope"; /** Explicit choices survive pane eviction, but never persist to the host. */ -export function TranscriptDisclosureProvider({ children, sessionId }: { children: ReactNode; sessionId?: string }) { +export function TranscriptDisclosureProvider({ children, sessionId, visible = true }: { + children: ReactNode; + sessionId?: string; + visible?: boolean; +}) { + const parentVisible = useContext(PaneVisibilityContext); const [choices] = useState(() => new DisclosureChoices( sessionId ? sessionReadingState(sessionId).disclosures : undefined, )); - return {children}; + return + {children} + ; +} + +/** Mounted transcript descendants suspend work while their pane or scope is hidden. */ +export function useTranscriptVisibility() { + const paneVisible = useContext(PaneVisibilityContext); + return useContext(ScopeContext).visible && paneVisible; } export function disclosureKey(kind: string, ...ids: string[]) { @@ -97,6 +111,7 @@ export function useAutomaticDisclosure( : automaticOpen ); const scope = useContext(ScopeContext); + const paneVisible = useContext(PaneVisibilityContext); // A level below a collapsed parent renders nothing, whatever its own choice // says. The choice is left untouched so reopening the parent restores it. const open = requestedOpen && scope.visible; @@ -151,6 +166,8 @@ export function useAutomaticDisclosure( // Kept for callers that read the parent's visibility directly; the scope // itself already folded it into `open`. parentVisible: scope.visible, + // Pane suspension is independent from the stored disclosure/open state. + workVisible: scope.visible && paneVisible, // Pointer selection and keyboard interaction establish ownership before // a streaming update can apply an automatic close. bodyEvents: { onPointerDownCapture: claim, onFocusCapture: claim }, diff --git a/apps/desktop/src/features/chat/transcript/hooks/useTranscriptScroll.ts b/apps/desktop/src/features/chat/transcript/hooks/useTranscriptScroll.ts index 4d9d838ca1..21d4f4c0ae 100644 --- a/apps/desktop/src/features/chat/transcript/hooks/useTranscriptScroll.ts +++ b/apps/desktop/src/features/chat/transcript/hooks/useTranscriptScroll.ts @@ -500,12 +500,12 @@ export function useTranscriptScroll({ useEffect(() => { const content = contentRef.current; const scroller = scrollRef.current; - if (!content || !scroller || typeof ResizeObserver === "undefined") return; + if (!paneVisible || !content || !scroller || typeof ResizeObserver === "undefined") return; const ro = new ResizeObserver(followScrollNow); ro.observe(content, { box: "border-box" }); ro.observe(scroller, { box: "border-box" }); return () => ro.disconnect(); - }, [followScrollNow]); + }, [followScrollNow, paneVisible]); // Defer one immutable projection, never messages and compaction boundaries // independently. First paint, reading, and pane reveal use the current source. @@ -517,8 +517,16 @@ export function useTranscriptScroll({ [messages, compactions], ); const deferredProjection = useDeferredValue(projection); + const revealedProjection = useRef(null); + if (paneRevealed) revealedProjection.current = projection; + if (deferredProjection === revealedProjection.current || deferredProjection === projection) { + revealedProjection.current = null; + } + // A reveal uses the latest source immediately. Layout commits must not + // rewind to the pre-reveal deferred snapshot before it catches up. const renderedProjection = - readingWindow || firstCommit || paneRevealed ? projection : deferredProjection; + readingWindow || firstCommit || paneRevealed || revealedProjection.current + ? projection : deferredProjection; const { entries, visible, history: allHistoryEntries } = renderedProjection; const tailEntry = entries.at(-1); // Published for `reachTop`, which is declared above this projection but only diff --git a/apps/desktop/src/features/chat/transcript/shared.tsx b/apps/desktop/src/features/chat/transcript/shared.tsx index 3a075dd8b6..a81681afa8 100644 --- a/apps/desktop/src/features/chat/transcript/shared.tsx +++ b/apps/desktop/src/features/chat/transcript/shared.tsx @@ -697,6 +697,10 @@ export const ThinkingRow = memo(function ThinkingRow({ // reasoning rows and excerpts. The stored text is never removed, so turning // the preference back on restores the row. const showThinking = useAppStore((state) => resolveShowThinking(state.settings)); + const presentation = useRef<{ message: UiMessage; text: string } | null>(null); + if (!presentation.current || (disclosure.workVisible && presentation.current.message !== message)) { + presentation.current = { message, text: thinkingText(message) }; + } if (!showThinking) { return isThinkingActive(message, streaming) ? (
@@ -709,7 +713,7 @@ export const ThinkingRow = memo(function ThinkingRow({
) : null; } - const text = thinkingText(message); + const { text } = presentation.current; return (