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 {