Skip to content
Closed
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
4 changes: 3 additions & 1 deletion apps/desktop/electron/main/ipc/agent-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/components/SessionPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export const SessionPane = memo(function SessionPane({
aria-hidden={visible ? undefined : true}
inert={visible ? undefined : true}
>
<TranscriptDisclosureProvider sessionId={sessionId}>
<TranscriptDisclosureProvider sessionId={sessionId} visible={visible}>
<ChatTranscript
sessionId={sessionId}
messages={transcript.messages}
Expand Down
21 changes: 13 additions & 8 deletions apps/desktop/src/features/chat/transcript/ActivityGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { SubagentStopButton } from "./SubagentStopButton";
import { activityTimingInputs, cachedVisibleActivityItems } from "../../../lib/transcript-activity-summary";
import { reuseReferences } from "../../../lib/transcript-summary";
import { ActivityItems } from "./ActivityItems";
import { DisclosureScope, disclosureKey } from "./disclosure";
import { DisclosureScope, disclosureKey, useTranscriptVisibility } from "./disclosure";
import { ProcessActivityGroup } from "./ProcessActivityGroup";
import {
memo,
Expand Down Expand Up @@ -203,6 +203,7 @@ export const ActivityGroup = memo(function ActivityGroup({
turnDelegationStatuses,
turnDelegationTimings,
}: ActivityGroupProps) {
const visible = useTranscriptVisibility();
const showThinking = useAppStore((state) => resolveShowThinking(state.settings));
const hideThinking = !showThinking;
const displayMode = useAppStore((state) => resolveTranscriptDisplayMode(state.settings));
Expand Down Expand Up @@ -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 = <ActivityItems
items={items}
Expand Down Expand Up @@ -412,19 +413,21 @@ export const ActivityGroup = memo(function ActivityGroup({

/** Keep the running turn visible when no more specific runtime phase is known. */
export function WorkingIndicator({ startedAt }: { startedAt?: number } = {}) {
const visible = useTranscriptVisibility();
const { t } = useTranslation();
const [elapsed, setElapsed] = useState(0);
const startedAtRef = useRef(startedAt ?? Date.now());

useEffect(() => {
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 (
<div
Expand All @@ -449,17 +452,19 @@ export function WorkingIndicator({ startedAt }: { startedAt?: number } = {}) {
}

export function RunActivityIndicator({ activity }: { activity: AgentActivity }) {
const visible = useTranscriptVisibility();
const { t } = useTranslation();
const [now, setNow] = useState(Date.now);
const retryErrorDetailsId = useId();
const retryReasonRef = useRef<HTMLSpanElement | null>(null);
const retryPlateRef = useRef<HTMLSpanElement | null>(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)),
Expand All @@ -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;
Expand Down Expand Up @@ -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}`;
Expand Down
16 changes: 13 additions & 3 deletions apps/desktop/src/features/chat/transcript/AssistantTurnParts.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<div
className={`message-bubble assistant-turn-fragment${streaming ? " streaming" : ""}${showCursor ? " smooth-cursor" : ""}`}
Expand Down
23 changes: 13 additions & 10 deletions apps/desktop/src/features/chat/transcript/ToolRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { useChatFileMenu } from "../../../hooks/use-chat-file-menu";
import { ContextMenu } from "../../../components/ContextMenu";
import { useFollowScroll } from "../../../hooks/use-follow-scroll";
import { getToolPreviewTarget } from "../../../lib/chat-links";
import { disclosureKey } from "./disclosure";
import { disclosureKey, useTranscriptVisibility } from "./disclosure";
import {
formatToolDuration,
getToolAction,
Expand Down Expand Up @@ -256,7 +256,7 @@ function HostToolRow({
nestedReport: boolean | undefined;
blocks: ReturnType<typeof buildToolPresentation>;
} | 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,
Expand Down Expand Up @@ -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`
Expand All @@ -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<HTMLElement>(".tool-row-content");
Expand All @@ -374,7 +374,7 @@ function HostToolRow({
el.scrollTop = el.scrollHeight;
}
}
}, [status, open, message, disclosure.bodyRef]);
}, [status, open, message, disclosure.bodyRef, disclosure.workVisible]);


const statusTone =
Expand Down Expand Up @@ -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,
Expand All @@ -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 (
<DisclosureAnchorContext.Provider value={disclosureAnchorNotifier}>
Expand All @@ -710,7 +713,7 @@ function SubagentRunFollow({
onScroll={scrollable ? handleScroll : undefined}
>
<div ref={contentRef}>
{items.map((item) =>
{displayedItems.current.map((item) =>
item.kind === "tool" ? (
reviewChangeFromMessage(item.message) ? (
<ReviewChangeCard
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/features/chat/transcript/TurnProcess.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,11 @@ export function TurnProcess({
const [now, setNow] = useState(Date.now);
const { startedAt, endedAt } = useMemo(() => 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<DisclosureScopeValue>(
() => ({ claim: disclosure.claim, visible: disclosure.open }),
[disclosure.claim, disclosure.open],
Expand Down
21 changes: 19 additions & 2 deletions apps/desktop/src/features/chat/transcript/disclosure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class DisclosureChoices {
}

const ChoicesContext = createContext<DisclosureChoices | null>(null);
const PaneVisibilityContext = createContext(true);

/*
* A level's own visibility is a second gate beside the stored choice, so a
Expand All @@ -56,11 +57,24 @@ const ChoicesContext = createContext<DisclosureChoices | null>(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 <ChoicesContext.Provider value={choices}>{children}</ChoicesContext.Provider>;
return <ChoicesContext.Provider value={choices}>
<PaneVisibilityContext.Provider value={parentVisible && visible}>{children}</PaneVisibilityContext.Provider>
</ChoicesContext.Provider>;
}

/** 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[]) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -517,8 +517,16 @@ export function useTranscriptScroll({
[messages, compactions],
);
const deferredProjection = useDeferredValue(projection);
const revealedProjection = useRef<typeof projection | null>(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
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/features/chat/transcript/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) ? (
<div className="tool-row thinking thinking-compact" role="status">
Expand All @@ -726,7 +730,7 @@ export const ThinkingRow = memo(function ThinkingRow({
</div>
) : null;
}
const text = thinkingText(message);
const { text } = presentation.current;
return (
<div className={`tool-row thinking ${open ? "open" : ""}`}>
<button
Expand Down
Loading
Loading