diff --git a/src/renderer/components/thread/ChatPane/ChatPane.tsx b/src/renderer/components/thread/ChatPane/ChatPane.tsx
index 73aa0d59..696dd7cb 100644
--- a/src/renderer/components/thread/ChatPane/ChatPane.tsx
+++ b/src/renderer/components/thread/ChatPane/ChatPane.tsx
@@ -1,13 +1,10 @@
import { useEffect, useMemo, useRef, useState } from "react";
-import { Surface } from "@heroui/react";
-import { Trans, useLingui } from "@lingui/react/macro";
+import { Trans } from "@lingui/react/macro";
import { useShallow } from "zustand/react/shallow";
import { isThreadTurnActive, type ProjectLocation, type Thread } from "@/shared/contracts";
import { resolveGrokSessionDir } from "@/shared/grokSessionMedia";
import { isHomeProjectId } from "@/shared/homeScope";
-import { chatMessageSurfaceClass } from "./parts/items/chatMessageSurface";
import { readBridge } from "@/renderer/bridge";
-import { useShimmerRef } from "@/renderer/thinkingAnimator";
import { useScrollFade } from "@/renderer/hooks/useScrollFade";
import { useThreadHasBackgroundActivity } from "@/renderer/hooks/uiSelectors";
import { useAppStore } from "@/renderer/state/appStore";
@@ -24,7 +21,6 @@ import {
import { useFileEditorStore } from "@/renderer/state/fileEditorStore";
import { useProjectRootNames } from "@/renderer/state/projectRootNamesStore";
import { useProjectTreeStore } from "@/renderer/state/projectTreeStore";
-import { formatElapsed } from "@/renderer/utils/formatTime";
import {
buildFileEditorContext,
openFileInEditor,
@@ -34,6 +30,7 @@ import { showSubAgentPanel } from "@/renderer/actions/panelActions";
import { ChatFindBar, type ScrollToIndex } from "@/renderer/components/find/ChatFindBar";
import { ChatPaneActionsContext, type ChatPaneActions } from "./chatPaneActionsContext";
import { ChatScrollControls, type ChatScrollControlsHandle } from "./ChatScrollControls";
+import { ChatTurnElapsedFooter, type TurnTiming } from "./ChatTurnElapsed";
import { selectVisibleThreadTimelineEntries, type ChatTimelineEntry } from "./chatPaneSelectors";
import { shouldMarkUserScrollIntentFromPointerTarget } from "./chatScrollGeometry";
import { normalizeChatProjectPath } from "./chatPathUtils";
@@ -369,7 +366,7 @@ export function ChatPane(props: ChatPaneProps) {
}
footer={
showTailLoader && tailTurn ? (
-
+
) : null
}
onWheelCapture={(event) => {
@@ -457,11 +454,6 @@ export function ChatPane(props: ChatPaneProps) {
);
}
-interface TurnTiming {
- startedAt: number;
- endedAt: number | null;
-}
-
interface CompletedTurnTiming extends TurnTiming {
anchorItemId: string | null;
endedAt: number;
@@ -514,91 +506,6 @@ function selectMostRecentDisplayableCompletedTurn(
return null;
}
-function ChatTailLoader({ turn, isPaused }: { turn: TurnTiming; isPaused: boolean }) {
- return (
-
- );
-}
-
-/**
- * Self-ticking elapsed-time label. While `turn.endedAt` is null, ticks every
- * second as "Working for N"; once set, freezes as "Worked for N". When
- * `isPaused` is true (e.g. the runtime is blocked on a user-input prompt) the
- * counter freezes at its current value and the paused interval is excluded
- * from the elapsed total once it resumes. Mutates `textContent` directly via
- * a ref instead of calling `setState` so the per-second tick produces zero
- * React commits — important while the rest of the chat is potentially
- * streaming.
- */
-function WorkingFor({ turn, isPaused }: { turn: TurnTiming; isPaused: boolean }) {
- const { t } = useLingui();
- const textRef = useRef(null);
- const pauseStateRef = useRef<{ accumulatedPauseMs: number; pausedSinceMs: number | null }>({
- accumulatedPauseMs: 0,
- pausedSinceMs: null,
- });
-
- useEffect(() => {
- pauseStateRef.current = { accumulatedPauseMs: 0, pausedSinceMs: null };
- }, [turn.startedAt, turn.endedAt]);
-
- useEffect(() => {
- const update = () => {
- const node = textRef.current;
- if (!node) return;
- if (turn.endedAt !== null) {
- const elapsedSeconds = Math.max(0, Math.floor((turn.endedAt - turn.startedAt) / 1000));
- const elapsed = formatElapsed(elapsedSeconds);
- const text = elapsedSeconds < 1 ? "" : t`Worked for ${elapsed}`;
- node.textContent = text;
- node.dataset.poracodeShimmerText = text;
- return;
- }
- const pauseState = pauseStateRef.current;
- const now = Date.now();
- const currentPauseMs =
- pauseState.pausedSinceMs !== null ? Math.max(0, now - pauseState.pausedSinceMs) : 0;
- const elapsedMs = now - turn.startedAt - pauseState.accumulatedPauseMs - currentPauseMs;
- const elapsedSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
- const elapsed = formatElapsed(elapsedSeconds);
- const text = elapsedSeconds < 1 ? "" : t`Working for ${elapsed}`;
- node.textContent = text;
- node.dataset.poracodeShimmerText = text;
- };
-
- if (isPaused) {
- if (pauseStateRef.current.pausedSinceMs === null) {
- pauseStateRef.current.pausedSinceMs = Date.now();
- }
- update();
- return;
- }
-
- if (pauseStateRef.current.pausedSinceMs !== null) {
- pauseStateRef.current.accumulatedPauseMs += Math.max(
- 0,
- Date.now() - pauseStateRef.current.pausedSinceMs,
- );
- pauseStateRef.current.pausedSinceMs = null;
- }
- update();
- if (turn.endedAt !== null) return;
- const id = setInterval(update, 1000);
- return () => clearInterval(id);
- }, [turn.startedAt, turn.endedAt, isPaused, t]);
-
- const isThinking = !isPaused && turn.endedAt === null;
- useShimmerRef(textRef, isThinking);
- const className = isThinking ? "poracode-thinking-text" : "text-muted";
- return ;
-}
-
function isScrollNavigationKey(key: string): boolean {
return (
key === "ArrowUp" ||
diff --git a/src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx b/src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
new file mode 100644
index 00000000..689b3b43
--- /dev/null
+++ b/src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
@@ -0,0 +1,102 @@
+import { useEffect, useRef } from "react";
+import { Surface } from "@heroui/react";
+import { useLingui } from "@lingui/react/macro";
+import { useShimmerRef } from "@/renderer/thinkingAnimator";
+import { formatElapsed } from "@/renderer/utils/formatTime";
+import { chatMessageSurfaceClass } from "./parts/items/chatMessageSurface";
+
+export interface TurnTiming {
+ startedAt: number;
+ endedAt: number | null;
+}
+
+export function ChatTurnElapsedFooter({
+ turn,
+ isPaused = false,
+}: {
+ turn: TurnTiming;
+ isPaused?: boolean;
+}) {
+ return (
+
+ );
+}
+
+/**
+ * Self-ticking elapsed-time label. While `turn.endedAt` is null, ticks every
+ * second as "Working for N"; once set, freezes as "Worked for N". When
+ * `isPaused` is true (e.g. the runtime is blocked on a user-input prompt) the
+ * counter freezes at its current value and the paused interval is excluded
+ * from the elapsed total once it resumes. Mutates `textContent` directly via
+ * a ref instead of calling `setState` so the per-second tick produces zero
+ * React commits — important while the rest of the chat is potentially
+ * streaming.
+ */
+function WorkingFor({ turn, isPaused }: { turn: TurnTiming; isPaused: boolean }) {
+ const { t } = useLingui();
+ const textRef = useRef(null);
+ const pauseStateRef = useRef<{ accumulatedPauseMs: number; pausedSinceMs: number | null }>({
+ accumulatedPauseMs: 0,
+ pausedSinceMs: null,
+ });
+
+ useEffect(() => {
+ pauseStateRef.current = { accumulatedPauseMs: 0, pausedSinceMs: null };
+ }, [turn.startedAt, turn.endedAt]);
+
+ useEffect(() => {
+ const update = () => {
+ const node = textRef.current;
+ if (!node) return;
+ if (turn.endedAt !== null) {
+ const elapsedSeconds = Math.max(0, Math.floor((turn.endedAt - turn.startedAt) / 1000));
+ const elapsed = formatElapsed(elapsedSeconds);
+ const text = elapsedSeconds < 1 ? "" : t`Worked for ${elapsed}`;
+ node.textContent = text;
+ node.dataset.poracodeShimmerText = text;
+ return;
+ }
+ const pauseState = pauseStateRef.current;
+ const now = Date.now();
+ const currentPauseMs =
+ pauseState.pausedSinceMs !== null ? Math.max(0, now - pauseState.pausedSinceMs) : 0;
+ const elapsedMs = now - turn.startedAt - pauseState.accumulatedPauseMs - currentPauseMs;
+ const elapsedSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
+ const elapsed = formatElapsed(elapsedSeconds);
+ const text = elapsedSeconds < 1 ? "" : t`Working for ${elapsed}`;
+ node.textContent = text;
+ node.dataset.poracodeShimmerText = text;
+ };
+
+ if (isPaused) {
+ if (pauseStateRef.current.pausedSinceMs === null) {
+ pauseStateRef.current.pausedSinceMs = Date.now();
+ }
+ update();
+ return;
+ }
+
+ if (pauseStateRef.current.pausedSinceMs !== null) {
+ pauseStateRef.current.accumulatedPauseMs += Math.max(
+ 0,
+ Date.now() - pauseStateRef.current.pausedSinceMs,
+ );
+ pauseStateRef.current.pausedSinceMs = null;
+ }
+ update();
+ if (turn.endedAt !== null) return;
+ const id = setInterval(update, 1000);
+ return () => clearInterval(id);
+ }, [turn.startedAt, turn.endedAt, isPaused, t]);
+
+ const isThinking = !isPaused && turn.endedAt === null;
+ useShimmerRef(textRef, isThinking);
+ const className = isThinking ? "poracode-thinking-text" : "text-muted";
+ return ;
+}
diff --git a/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.test.tsx b/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.test.tsx
index bb09f8df..d3a0334c 100644
--- a/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.test.tsx
+++ b/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.test.tsx
@@ -1,4 +1,4 @@
-import { screen, waitFor, within } from "@testing-library/react";
+import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ProjectLocation, ToolCallPayload } from "@/shared/contracts";
import { AppProvider } from "@/renderer/components/ui/provider";
@@ -15,6 +15,7 @@ const mockBridge = {
subagentUnsubscribe:
vi.fn<(payload: { threadId: string; parentItemId: string }) => Promise>(),
};
+const mockScrollToEnd = vi.hoisted(() => vi.fn<() => Promise>());
type MockLegendProps = {
data: readonly ChatTimelineEntry[];
@@ -26,6 +27,8 @@ type MockLegendProps = {
className?: string;
contentContainerClassName?: string;
contentContainerStyle?: React.CSSProperties;
+ style?: React.CSSProperties;
+ onWheelCapture?: React.WheelEventHandler;
onLoad?: () => void;
};
@@ -44,11 +47,17 @@ vi.mock("@legendapp/list/react", async () => {
sizes: new Map(),
listen: () => () => undefined,
}),
- scrollToEnd: () => Promise.resolve(),
+ scrollToEnd: mockScrollToEnd,
}));
React.useLayoutEffect(() => onLoadRef.current?.(), []);
return (
-
+
{
beforeEach(() => {
mockBridge.subagentSubscribe.mockReset().mockResolvedValue({ history: [] });
mockBridge.subagentUnsubscribe.mockReset().mockResolvedValue(undefined);
+ mockScrollToEnd.mockReset().mockResolvedValue(undefined);
useAppStore.setState({
runtimeItemIdsByThread: {},
runtimeItemsByIdByThread: {},
@@ -135,6 +145,78 @@ describe("SubAgentContent", () => {
expect(icons[1]).toHaveClass("size-3.5");
});
+ it("reuses the main chat fade, sticky-bottom controls, and live elapsed footer", async () => {
+ const threadId = "thread-1";
+ const parentItem: RuntimeChatItem = {
+ ...makeSubAgentItem("parent-1"),
+ startedAt: Date.now() - 70_000,
+ };
+
+ useAppStore.setState({
+ runtimeItemIdsByThread: { [threadId]: [parentItem.id] },
+ runtimeItemsByIdByThread: {
+ [threadId]: {
+ [parentItem.id]: parentItem,
+ },
+ },
+ runtimeStructuralVersionByThread: { [threadId]: 1 },
+ });
+
+ const view = render(
+
,
+ );
+
+ expect(await screen.findByText("Working for 1m 10s")).toBeInTheDocument();
+ await waitFor(() => expect(mockScrollToEnd).toHaveBeenCalled());
+ const scroller = view.container.querySelector
(
+ '[data-poracode-chat-scroller="true"]',
+ );
+ expect(scroller).not.toBeNull();
+ expect(scroller?.style.maskImage).toContain("var(--top-fade-size");
+ const scrollButton = screen.getByRole("button", { name: "Scroll to bottom" });
+ expect(scrollButton).toHaveClass("opacity-0");
+
+ Object.defineProperties(scroller!, {
+ clientHeight: { configurable: true, value: 200 },
+ scrollHeight: { configurable: true, value: 1_000 },
+ });
+ fireEvent.wheel(scroller!, { deltaY: -10 });
+ scroller!.scrollTop = 100;
+ fireEvent.scroll(scroller!);
+
+ await waitFor(() => expect(scrollButton).toHaveClass("opacity-80"));
+ mockScrollToEnd.mockClear();
+ fireEvent.click(scrollButton);
+ await waitFor(() => expect(mockScrollToEnd).toHaveBeenCalled());
+ });
+
+ it("shows the frozen subagent duration after completion", async () => {
+ const threadId = "thread-1";
+ const startedAt = Date.now() - 75_000;
+ const runningParent = makeSubAgentItem("parent-1");
+ const parentItem: RuntimeChatItem = {
+ ...runningParent,
+ state: "completed",
+ startedAt,
+ completedAt: startedAt + 75_000,
+ payload: { ...(runningParent.payload as ToolCallPayload), status: "success" },
+ };
+
+ useAppStore.setState({
+ runtimeItemIdsByThread: { [threadId]: [parentItem.id] },
+ runtimeItemsByIdByThread: {
+ [threadId]: {
+ [parentItem.id]: parentItem,
+ },
+ },
+ runtimeStructuralVersionByThread: { [threadId]: 1 },
+ });
+
+ render();
+
+ expect(await screen.findByText("Worked for 1m 15s")).toBeInTheDocument();
+ });
+
it("splits a Crossagent name and model selection into a two-line route header", () => {
const threadId = "thread-1";
const parentItem: RuntimeChatItem = {
diff --git a/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx b/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx
index 886d3b08..867a7215 100644
--- a/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx
+++ b/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx
@@ -4,15 +4,21 @@ import { Bot, X } from "lucide-react";
import type { ProjectLocation, ToolCallPayload } from "@/shared/contracts";
import { PixelLoader } from "@/renderer/components/common/PixelLoader";
import { readBridge } from "@/renderer/bridge";
+import { useScrollFade } from "@/renderer/hooks/useScrollFade";
import { useAppStore } from "@/renderer/state/appStore";
import { useSharedSettings } from "@/renderer/state/sharedSettingsStore";
-import { getRuntimeItemPayload } from "@/renderer/state/slices/runtimeEventSlice";
+import {
+ getRuntimeItemPayload,
+ type RuntimeChatItem,
+} from "@/renderer/state/slices/runtimeEventSlice";
import { guiChatFontCssVars } from "../../chatFontVars";
import {
getChildTimelineEntriesStoreSelector,
getRuntimeItemStoreSelector,
type ChatTimelineEntry,
} from "../../chatPaneSelectors";
+import { ChatScrollControls, type ChatScrollControlsHandle } from "../../ChatScrollControls";
+import { ChatTurnElapsedFooter, type TurnTiming } from "../../ChatTurnElapsed";
import { MessageList } from "../MessageList";
import { buildSubAgentProgressParts } from "./subAgentProgressMeta";
import { deriveToolDisplay, isCrossagentTool, isWorkflowTool } from "./toolDisplay";
@@ -133,6 +139,7 @@ export function SubAgentContent({
isRunning,
}
: null;
+ const turn = resolveSubAgentTurnTiming(item, payload, isRunning);
const renderWorkflow = !!(workflow && workflow.manifestPath);
return (
@@ -155,8 +162,10 @@ export function SubAgentContent({
) : (
@@ -319,42 +328,102 @@ function Shell({
function ChildList({
threadId,
+ parentItemId,
entries,
stickToBottom,
+ turn,
workflow,
workflowProgress,
}: {
threadId: string;
+ parentItemId: string;
entries: readonly ChatTimelineEntry[];
stickToBottom: boolean;
+ turn: TurnTiming | null;
workflow: WorkflowInfo | null;
workflowProgress: WorkflowOverlayProgress | null;
}) {
+ const contentRef = useRef(null);
+ const scrollControlsRef = useRef(null);
+ const virtualScrollToBottomRef = useRef<(() => void) | null>(null);
+ const { setScrollContainer, scrollRef, scrollFadeStyle } = useScrollFade({
+ contentRef,
+ });
+
return (
- : null
- }
- emptyContent={
- workflow ? (
-
- ) : (
-
- Working…
-
- )
- }
- />
+
+
scrollControlsRef.current?.onContentHeightChange()}
+ onVirtualizerLayoutChange={() => scrollControlsRef.current?.beginVirtualizerLayoutChange()}
+ onLiveVirtualizerLayoutChange={() =>
+ scrollControlsRef.current?.beginLiveVirtualizerLayoutChange()
+ }
+ registerVirtualScrollToBottom={(handler) => {
+ virtualScrollToBottomRef.current = handler;
+ }}
+ scrollClassName="h-full min-h-0 overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable]"
+ scrollStyle={scrollFadeStyle}
+ contentClassName="poracode-subagent-list-content min-h-full px-3 pt-3"
+ header={
+ workflow ? (
+
+ ) : null
+ }
+ footer={turn ? : null}
+ emptyContent={
+ workflow ? (
+
+ ) : (
+
+ Working…
+
+ )
+ }
+ onWheelCapture={(event) => {
+ if (event.deltaY >= 0) return;
+ scrollControlsRef.current?.markUserScrollIntent();
+ scrollControlsRef.current?.disableStickToBottom();
+ }}
+ />
+ undefined}
+ />
+
);
}
+function resolveSubAgentTurnTiming(
+ item: RuntimeChatItem,
+ payload: ToolCallPayload | undefined,
+ isRunning: boolean,
+): TurnTiming | null {
+ if (isRunning) {
+ return item.startedAt === undefined ? null : { startedAt: item.startedAt, endedAt: null };
+ }
+ const durationMs =
+ item.startedAt !== undefined && item.completedAt !== undefined
+ ? item.completedAt - item.startedAt
+ : payload?.progress?.durationMs;
+ return durationMs === undefined ? null : { startedAt: 0, endedAt: Math.max(0, durationMs) };
+}
+
function WorkflowOverlayHeader({
workflow,
progress,
diff --git a/src/renderer/locales/de/messages.po b/src/renderer/locales/de/messages.po
index 8277eca4..b4990ca8 100644
--- a/src/renderer/locales/de/messages.po
+++ b/src/renderer/locales/de/messages.po
@@ -11793,7 +11793,7 @@ msgstr "Arbeit"
msgid "Work in the current checkout"
msgstr "Im aktuellen Checkout arbeiten"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
#: src/renderer/components/thread/ChatPane/parts/MessageList.tsx
msgid "Worked for {elapsed}"
msgstr "Gearbeitet für {elapsed}"
@@ -11836,7 +11836,7 @@ msgstr "Workflows"
msgid "Working"
msgstr "Arbeiten"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
msgid "Working for {elapsed}"
msgstr "Arbeitet seit {elapsed}"
diff --git a/src/renderer/locales/en/messages.po b/src/renderer/locales/en/messages.po
index 9208ad7d..6cd8edc9 100644
--- a/src/renderer/locales/en/messages.po
+++ b/src/renderer/locales/en/messages.po
@@ -11793,7 +11793,7 @@ msgstr "Work"
msgid "Work in the current checkout"
msgstr "Work in the current checkout"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
#: src/renderer/components/thread/ChatPane/parts/MessageList.tsx
msgid "Worked for {elapsed}"
msgstr "Worked for {elapsed}"
@@ -11836,7 +11836,7 @@ msgstr "Workflows"
msgid "Working"
msgstr "Working"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
msgid "Working for {elapsed}"
msgstr "Working for {elapsed}"
diff --git a/src/renderer/locales/es/messages.po b/src/renderer/locales/es/messages.po
index 569e0ab5..a4d45f56 100644
--- a/src/renderer/locales/es/messages.po
+++ b/src/renderer/locales/es/messages.po
@@ -11793,7 +11793,7 @@ msgstr "Trabajo"
msgid "Work in the current checkout"
msgstr "Trabajar en el checkout actual"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
#: src/renderer/components/thread/ChatPane/parts/MessageList.tsx
msgid "Worked for {elapsed}"
msgstr "Trabajó durante {elapsed}"
@@ -11836,7 +11836,7 @@ msgstr "Flujos de trabajo"
msgid "Working"
msgstr "Trabajando"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
msgid "Working for {elapsed}"
msgstr "Trabajando durante {elapsed}"
diff --git a/src/renderer/locales/fr/messages.po b/src/renderer/locales/fr/messages.po
index 55aa3b66..25835df1 100644
--- a/src/renderer/locales/fr/messages.po
+++ b/src/renderer/locales/fr/messages.po
@@ -11792,7 +11792,7 @@ msgstr "Travail"
msgid "Work in the current checkout"
msgstr "Travailler dans la copie de travail actuelle"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
#: src/renderer/components/thread/ChatPane/parts/MessageList.tsx
msgid "Worked for {elapsed}"
msgstr "A travaillé pendant {elapsed}"
@@ -11835,7 +11835,7 @@ msgstr "Flux de travail"
msgid "Working"
msgstr "En cours"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
msgid "Working for {elapsed}"
msgstr "En cours depuis {elapsed}"
diff --git a/src/renderer/locales/ja/messages.po b/src/renderer/locales/ja/messages.po
index c62caf95..1a98479c 100644
--- a/src/renderer/locales/ja/messages.po
+++ b/src/renderer/locales/ja/messages.po
@@ -11791,7 +11791,7 @@ msgstr "作業"
msgid "Work in the current checkout"
msgstr "現在のチェックアウトでの作業"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
#: src/renderer/components/thread/ChatPane/parts/MessageList.tsx
msgid "Worked for {elapsed}"
msgstr "{elapsed}稼働しました"
@@ -11834,7 +11834,7 @@ msgstr "ワークフロー"
msgid "Working"
msgstr "実行中"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
msgid "Working for {elapsed}"
msgstr "{elapsed}稼働中"
diff --git a/src/renderer/locales/ko/messages.po b/src/renderer/locales/ko/messages.po
index e8615d3f..be12ebaf 100644
--- a/src/renderer/locales/ko/messages.po
+++ b/src/renderer/locales/ko/messages.po
@@ -11793,7 +11793,7 @@ msgstr "작업"
msgid "Work in the current checkout"
msgstr "현재 체크아웃에서 작업"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
#: src/renderer/components/thread/ChatPane/parts/MessageList.tsx
msgid "Worked for {elapsed}"
msgstr "{elapsed} 동안 작업함"
@@ -11836,7 +11836,7 @@ msgstr "워크플로"
msgid "Working"
msgstr "작업 중"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
msgid "Working for {elapsed}"
msgstr "{elapsed} 동안 작업 중"
diff --git a/src/renderer/locales/pl/messages.po b/src/renderer/locales/pl/messages.po
index 769aa1fa..6129879c 100644
--- a/src/renderer/locales/pl/messages.po
+++ b/src/renderer/locales/pl/messages.po
@@ -11793,7 +11793,7 @@ msgstr "Praca"
msgid "Work in the current checkout"
msgstr "Pracuj w bieżącym checkoucie"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
#: src/renderer/components/thread/ChatPane/parts/MessageList.tsx
msgid "Worked for {elapsed}"
msgstr "Pracował przez {elapsed}"
@@ -11836,7 +11836,7 @@ msgstr "Przepływy pracy"
msgid "Working"
msgstr "Pracuje"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
msgid "Working for {elapsed}"
msgstr "Praca przez {elapsed}"
diff --git a/src/renderer/locales/pt-BR/messages.po b/src/renderer/locales/pt-BR/messages.po
index d37dcaa9..788d2d52 100644
--- a/src/renderer/locales/pt-BR/messages.po
+++ b/src/renderer/locales/pt-BR/messages.po
@@ -11793,7 +11793,7 @@ msgstr "Trabalho"
msgid "Work in the current checkout"
msgstr "Trabalhar no checkout atual"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
#: src/renderer/components/thread/ChatPane/parts/MessageList.tsx
msgid "Worked for {elapsed}"
msgstr "Trabalhou por {elapsed}"
@@ -11836,7 +11836,7 @@ msgstr "Fluxos de trabalho"
msgid "Working"
msgstr "Trabalhando"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
msgid "Working for {elapsed}"
msgstr "Trabalhando por {elapsed}"
diff --git a/src/renderer/locales/ru/messages.po b/src/renderer/locales/ru/messages.po
index c413c776..3973c8ae 100644
--- a/src/renderer/locales/ru/messages.po
+++ b/src/renderer/locales/ru/messages.po
@@ -11793,7 +11793,7 @@ msgstr "Работа"
msgid "Work in the current checkout"
msgstr "Работать в текущем checkout"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
#: src/renderer/components/thread/ChatPane/parts/MessageList.tsx
msgid "Worked for {elapsed}"
msgstr "Работал {elapsed}"
@@ -11836,7 +11836,7 @@ msgstr "Рабочие процессы"
msgid "Working"
msgstr "Работает"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
msgid "Working for {elapsed}"
msgstr "Работает {elapsed}"
diff --git a/src/renderer/locales/tr/messages.po b/src/renderer/locales/tr/messages.po
index 55f69124..bb8d098d 100644
--- a/src/renderer/locales/tr/messages.po
+++ b/src/renderer/locales/tr/messages.po
@@ -11793,7 +11793,7 @@ msgstr "İş"
msgid "Work in the current checkout"
msgstr "Mevcut çalışma kopyasında çalışın"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
#: src/renderer/components/thread/ChatPane/parts/MessageList.tsx
msgid "Worked for {elapsed}"
msgstr "{elapsed} boyunca çalıştı"
@@ -11836,7 +11836,7 @@ msgstr "İş akışları"
msgid "Working"
msgstr "Çalışıyor"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
msgid "Working for {elapsed}"
msgstr "{elapsed} boyunca çalışıyor"
diff --git a/src/renderer/locales/uk/messages.po b/src/renderer/locales/uk/messages.po
index ce654ec0..99b9b0a2 100644
--- a/src/renderer/locales/uk/messages.po
+++ b/src/renderer/locales/uk/messages.po
@@ -11793,7 +11793,7 @@ msgstr "Робота"
msgid "Work in the current checkout"
msgstr "Працювати в поточному checkout"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
#: src/renderer/components/thread/ChatPane/parts/MessageList.tsx
msgid "Worked for {elapsed}"
msgstr "Працював {elapsed}"
@@ -11836,7 +11836,7 @@ msgstr "Робочі процеси"
msgid "Working"
msgstr "Працює"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
msgid "Working for {elapsed}"
msgstr "Працює {elapsed}"
diff --git a/src/renderer/locales/vi/messages.po b/src/renderer/locales/vi/messages.po
index 568b0e95..77708486 100644
--- a/src/renderer/locales/vi/messages.po
+++ b/src/renderer/locales/vi/messages.po
@@ -11793,7 +11793,7 @@ msgstr "công việc"
msgid "Work in the current checkout"
msgstr "Làm việc trong checkout hiện tại"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
#: src/renderer/components/thread/ChatPane/parts/MessageList.tsx
msgid "Worked for {elapsed}"
msgstr "Đã làm việc trong {elapsed}"
@@ -11836,7 +11836,7 @@ msgstr "Quy trình làm việc"
msgid "Working"
msgstr "Đang làm việc"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
msgid "Working for {elapsed}"
msgstr "Đang làm việc trong {elapsed}"
diff --git a/src/renderer/locales/zh-CN/messages.po b/src/renderer/locales/zh-CN/messages.po
index 8362ac85..a8bb4a79 100644
--- a/src/renderer/locales/zh-CN/messages.po
+++ b/src/renderer/locales/zh-CN/messages.po
@@ -11792,7 +11792,7 @@ msgstr "工作"
msgid "Work in the current checkout"
msgstr "在当前检出中工作"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
#: src/renderer/components/thread/ChatPane/parts/MessageList.tsx
msgid "Worked for {elapsed}"
msgstr "已工作 {elapsed}"
@@ -11835,7 +11835,7 @@ msgstr "工作流程"
msgid "Working"
msgstr "工作中"
-#: src/renderer/components/thread/ChatPane/ChatPane.tsx
+#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
msgid "Working for {elapsed}"
msgstr "已工作 {elapsed}"
diff --git a/src/renderer/state/slices/runtimeEventReducer.ts b/src/renderer/state/slices/runtimeEventReducer.ts
index 142cdef4..8633ac43 100644
--- a/src/renderer/state/slices/runtimeEventReducer.ts
+++ b/src/renderer/state/slices/runtimeEventReducer.ts
@@ -1,4 +1,5 @@
-import type { RuntimeEvent, ThreadContextUsage } from "@/shared/contracts";
+import type { RuntimeEvent, ThreadContextUsage, ToolCallPayload } from "@/shared/contracts";
+import { isDelegatedAgentTool } from "@/shared/toolCallClassification";
import type { AppStoreState } from "./shared";
import {
type CompletedTurnRecord,
@@ -243,6 +244,9 @@ function applyRuntimeEventToRuntimeState(
id: event.itemId,
type: event.itemType,
state: "started",
+ ...(isDelegatedAgentTool(event.payload as ToolCallPayload | undefined)
+ ? { startedAt: Date.now() }
+ : {}),
payload: event.payload,
streams: {},
observedLive: true,
@@ -264,10 +268,15 @@ function applyRuntimeEventToRuntimeState(
const items = state.runtimeItemsByIdByThread[threadId];
const prev = items?.[event.itemId];
if (!prev || !items) return {};
+ const payload = mergePayload(prev.payload, event.payload);
const next: RuntimeChatItem = {
...prev,
state: prev.state === "completed" ? "completed" : "updated",
- payload: mergePayload(prev.payload, event.payload),
+ ...(prev.startedAt === undefined &&
+ isDelegatedAgentTool(payload as ToolCallPayload | undefined)
+ ? { startedAt: Date.now() }
+ : {}),
+ payload,
};
return {
runtimeItemsByIdByThread: {
@@ -284,6 +293,7 @@ function applyRuntimeEventToRuntimeState(
const next: RuntimeChatItem = {
...prev,
state: "completed",
+ ...(prev.startedAt !== undefined ? { completedAt: Date.now() } : {}),
payload:
event.payload !== undefined ? mergePayload(prev.payload, event.payload) : prev.payload,
};
diff --git a/src/renderer/state/slices/runtimeEventSlice.test.ts b/src/renderer/state/slices/runtimeEventSlice.test.ts
index d0b3846d..6eb674e9 100644
--- a/src/renderer/state/slices/runtimeEventSlice.test.ts
+++ b/src/renderer/state/slices/runtimeEventSlice.test.ts
@@ -50,6 +50,30 @@ describe("runtimeEventSlice.applyRuntimeEvent", () => {
});
});
+ it("records a local completion timestamp without replacing the start timestamp", () => {
+ const startedBefore = Date.now();
+ apply("t1", {
+ type: "item.started",
+ threadId: "t1",
+ itemId: "i1",
+ itemType: "tool_call",
+ payload: { name: "spawnAgent", status: "running", isSubAgent: true },
+ });
+ const startedAt = store.getState().runtimeItemsByIdByThread["t1"]?.["i1"]?.startedAt;
+ expect(startedAt).toBeGreaterThanOrEqual(startedBefore);
+
+ apply("t1", {
+ type: "item.completed",
+ threadId: "t1",
+ itemId: "i1",
+ payload: { status: "success" },
+ });
+
+ const item = store.getState().runtimeItemsByIdByThread["t1"]?.["i1"];
+ expect(item?.startedAt).toBe(startedAt);
+ expect(item?.completedAt).toBeGreaterThanOrEqual(startedAt ?? 0);
+ });
+
it("is idempotent for repeated item.started with the same id", () => {
apply("t1", {
type: "item.started",
diff --git a/src/renderer/state/slices/runtimeEventSlice.ts b/src/renderer/state/slices/runtimeEventSlice.ts
index 472331c2..cfe59aa9 100644
--- a/src/renderer/state/slices/runtimeEventSlice.ts
+++ b/src/renderer/state/slices/runtimeEventSlice.ts
@@ -44,6 +44,9 @@ export interface RuntimeChatItem {
type: CanonicalItemType;
/** "started" / "updated" land on items that haven't ended yet; "completed" → final. */
state: "started" | "updated" | "completed";
+ /** Local wall-clock timing for delegated-agent parents. Not persisted; provider duration is the reload fallback. */
+ startedAt?: number;
+ completedAt?: number;
/** Last payload object reported via `item.started` or `item.updated`. */
payload?: unknown;
/** Streamed content buckets (markdown text, command output, etc.). */