diff --git a/src/renderer/components/thread/ChatPane/ChatTurnElapsed.test.tsx b/src/renderer/components/thread/ChatPane/ChatTurnElapsed.test.tsx new file mode 100644 index 00000000..a125866d --- /dev/null +++ b/src/renderer/components/thread/ChatPane/ChatTurnElapsed.test.tsx @@ -0,0 +1,17 @@ +import { I18nProvider } from "@lingui/react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { i18n } from "@/renderer/i18n/i18n"; +import { ChatTurnElapsedFooter } from "./ChatTurnElapsed"; + +describe("ChatTurnElapsedFooter", () => { + it("renders a completed duration in the same commit", () => { + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain("Worked for 3m 34s"); + }); +}); diff --git a/src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx b/src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx index 689b3b43..e2462595 100644 --- a/src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx +++ b/src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx @@ -28,17 +28,33 @@ export function ChatTurnElapsedFooter({ ); } +function WorkingFor({ turn, isPaused }: { turn: TurnTiming; isPaused: boolean }) { + if (turn.endedAt !== null) { + return ; + } + return ; +} + +function WorkedFor({ startedAt, endedAt }: { startedAt: number; endedAt: number }) { + const { t } = useLingui(); + const elapsedSeconds = Math.max(0, Math.floor((endedAt - startedAt) / 1000)); + const elapsed = formatElapsed(elapsedSeconds); + const text = elapsedSeconds < 1 ? "" : t`Worked for ${elapsed}`; + return ( + + {text} + + ); +} + /** - * 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. + * Self-ticking elapsed-time label. 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 while chat is streaming. */ -function WorkingFor({ turn, isPaused }: { turn: TurnTiming; isPaused: boolean }) { +function LiveWorkingFor({ startedAt, isPaused }: { startedAt: number; isPaused: boolean }) { const { t } = useLingui(); const textRef = useRef(null); const pauseStateRef = useRef<{ accumulatedPauseMs: number; pausedSinceMs: number | null }>({ @@ -48,25 +64,17 @@ function WorkingFor({ turn, isPaused }: { turn: TurnTiming; isPaused: boolean }) useEffect(() => { pauseStateRef.current = { accumulatedPauseMs: 0, pausedSinceMs: null }; - }, [turn.startedAt, turn.endedAt]); + }, [startedAt]); 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 elapsedMs = now - 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}`; @@ -90,12 +98,11 @@ function WorkingFor({ turn, isPaused }: { turn: TurnTiming; isPaused: boolean }) pauseStateRef.current.pausedSinceMs = null; } update(); - if (turn.endedAt !== null) return; const id = setInterval(update, 1000); return () => clearInterval(id); - }, [turn.startedAt, turn.endedAt, isPaused, t]); + }, [startedAt, isPaused, t]); - const isThinking = !isPaused && turn.endedAt === null; + const isThinking = !isPaused; useShimmerRef(textRef, isThinking); const className = isThinking ? "poracode-thinking-text" : "text-muted"; return ; diff --git a/src/renderer/state/appStore.test.ts b/src/renderer/state/appStore.test.ts index 44c9128b..79c89de6 100644 --- a/src/renderer/state/appStore.test.ts +++ b/src/renderer/state/appStore.test.ts @@ -1189,6 +1189,57 @@ describe("appStore runtime config sync", () => { }); }); + it("does not reopen a completed GUI turn for a trailing goal update", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-01T12:00:00.000Z")); + const project = useAppStore.getState().addProject({ + kind: "windows", + path: "C:\\repo", + }); + const thread = useAppStore.getState().createThread({ + projectId: project.id, + agentKind: "codex", + config: { model: "m" }, + prompt: "a", + presentationMode: "gui", + }); + useAppStore.getState().applyRuntimeEvent(thread.id, { + type: "turn.started", + threadId: thread.id, + turnId: "turn-1", + }); + useAppStore.getState().applyRuntimeEvent(thread.id, { + type: "item.started", + threadId: thread.id, + itemId: "assistant-1", + itemType: "assistant_message", + }); + + vi.setSystemTime(new Date("2026-05-01T12:03:34.000Z")); + useAppStore.getState().updateThreadRuntime(thread.id, { + status: "idle", + attention: "none", + canResumeWithConfig: true, + }); + const completedTurns = useAppStore.getState().runtimeCompletedTurnsByThread[thread.id]; + + useAppStore.getState().applyRuntimeEvent(thread.id, { + type: "item.started", + threadId: thread.id, + itemId: "goal-1", + itemType: "goal", + payload: { entries: [{ id: "1", title: "Done", status: "completed" }] }, + }); + + expect(useAppStore.getState().threads[0]).toMatchObject({ + status: "idle", + attention: "none", + activeTurnStartedAt: undefined, + lastTurnEndedAt: "2026-05-01T12:03:34.000Z", + }); + expect(useAppStore.getState().runtimeCompletedTurnsByThread[thread.id]).toBe(completedTurns); + }); + it("does not add sub-second completed turns", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-01T12:00:00.000Z")); diff --git a/src/renderer/state/slices/runtimeEventReducer.ts b/src/renderer/state/slices/runtimeEventReducer.ts index 8633ac43..c4856dc3 100644 --- a/src/renderer/state/slices/runtimeEventReducer.ts +++ b/src/renderer/state/slices/runtimeEventReducer.ts @@ -149,11 +149,22 @@ function isLiveAssistantActivity( event: RuntimeEvent, ): boolean { if (event.type === "item.started") { - return event.itemType !== "user_message" && event.itemType !== "error"; + return ( + event.itemType !== "user_message" && + event.itemType !== "error" && + event.itemType !== "plan" && + event.itemType !== "goal" + ); } if (event.type !== "item.updated" && event.type !== "content.delta") return false; const item = state.runtimeItemsByIdByThread[threadId]?.[event.itemId]; - return item !== undefined && item.state !== "completed" && item.type !== "user_message"; + return ( + item !== undefined && + item.state !== "completed" && + item.type !== "user_message" && + item.type !== "plan" && + item.type !== "goal" + ); } function parseTurnMs(iso: string | undefined): number | null {