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 5942fc7292..56dd2e3087 100644 --- a/apps/desktop/src/features/chat/transcript/shared.tsx +++ b/apps/desktop/src/features/chat/transcript/shared.tsx @@ -714,6 +714,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) ? (
@@ -726,7 +730,7 @@ export const ThinkingRow = memo(function ThinkingRow({
) : null; } - const text = thinkingText(message); + const { text } = presentation.current; return (