From c40c779018f6681ebb9e5b89a01657b15be9b98b Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Wed, 29 Jul 2026 21:44:26 -0700 Subject: [PATCH] feat(chat): show elapsed time for delegated agents --- .../components/thread/ChatPane/ChatPane.tsx | 99 +-------------- .../thread/ChatPane/ChatTurnElapsed.tsx | 102 ++++++++++++++++ .../parts/items/SubAgentOverlay.test.tsx | 88 +++++++++++++- .../ChatPane/parts/items/SubAgentOverlay.tsx | 113 ++++++++++++++---- src/renderer/locales/de/messages.po | 4 +- src/renderer/locales/en/messages.po | 4 +- src/renderer/locales/es/messages.po | 4 +- src/renderer/locales/fr/messages.po | 4 +- src/renderer/locales/ja/messages.po | 4 +- src/renderer/locales/ko/messages.po | 4 +- src/renderer/locales/pl/messages.po | 4 +- src/renderer/locales/pt-BR/messages.po | 4 +- src/renderer/locales/ru/messages.po | 4 +- src/renderer/locales/tr/messages.po | 4 +- src/renderer/locales/uk/messages.po | 4 +- src/renderer/locales/vi/messages.po | 4 +- src/renderer/locales/zh-CN/messages.po | 4 +- .../state/slices/runtimeEventReducer.ts | 14 ++- .../state/slices/runtimeEventSlice.test.ts | 24 ++++ .../state/slices/runtimeEventSlice.ts | 3 + 20 files changed, 346 insertions(+), 149 deletions(-) create mode 100644 src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx diff --git a/src/renderer/components/thread/ChatPane/ChatPane.tsx b/src/renderer/components/thread/ChatPane/ChatPane.tsx index 73aa0d591..696dd7cb5 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 000000000..689b3b43d --- /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 bb09f8df1..d3a0334ce 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 886d3b080..867a72154 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 8277eca4a..b4990ca8d 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 9208ad7db..6cd8edc95 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 569e0ab5c..a4d45f564 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 55aa3b66c..25835df14 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 c62caf95c..1a98479cf 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 e8615d3fd..be12ebaf7 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 769aa1fa6..6129879c5 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 d37dcaa95..788d2d524 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 c413c7766..3973c8ae5 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 55f69124b..bb8d098d0 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 ce654ec02..99b9b0a28 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 568b0e956..777084860 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 8362ac858..a8bb4a790 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 142cdef41..8633ac431 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 d0b3846df..6eb674e94 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 472331c20..cfe59aa91 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.). */