Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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(
<I18nProvider i18n={i18n}>
<ChatTurnElapsedFooter turn={{ startedAt: 1_000, endedAt: 215_000 }} />
</I18nProvider>,
);

expect(markup).toContain("Worked for 3m 34s");
});
});
51 changes: 29 additions & 22 deletions src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,33 @@ export function ChatTurnElapsedFooter({
);
}

function WorkingFor({ turn, isPaused }: { turn: TurnTiming; isPaused: boolean }) {
if (turn.endedAt !== null) {
return <WorkedFor startedAt={turn.startedAt} endedAt={turn.endedAt} />;
}
return <LiveWorkingFor startedAt={turn.startedAt} isPaused={isPaused} />;
}

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 (
<span className="text-muted" aria-live="polite">
{text}
</span>
);
}

/**
* 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<HTMLSpanElement>(null);
const pauseStateRef = useRef<{ accumulatedPauseMs: number; pausedSinceMs: number | null }>({
Expand All @@ -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}`;
Expand All @@ -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 <span ref={textRef} className={className} aria-live="polite" />;
Expand Down
51 changes: 51 additions & 0 deletions src/renderer/state/appStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
15 changes: 13 additions & 2 deletions src/renderer/state/slices/runtimeEventReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down