Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
1 change: 0 additions & 1 deletion apps/server/src/codexAppServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,6 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [
const CODEX_DEFAULT_MODEL = "gpt-5.3-codex";
const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark";
const CODEX_SPARK_DISABLED_PLAN_TYPES = new Set<CodexPlanType>(["free", "go", "plus"]);

function asObject(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object") {
return undefined;
Expand Down
28 changes: 24 additions & 4 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
hasToolActivityForTurn,
isLatestTurnSettled,
formatElapsed,
formatTurnWorkElapsedExcludingUserInputWait,
} from "../session-logic";
import { isScrollContainerNearBottom } from "../chat-scroll";
import {
Expand Down Expand Up @@ -568,7 +569,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
const phase = derivePhase(activeThread?.session ?? null);
const isSendBusy = sendPhase !== "idle";
const isPreparingWorktree = sendPhase === "preparing-worktree";
const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
const nowIso = new Date(nowTick).toISOString();
const activeWorkStartedAt = deriveActiveWorkStartedAt(
activeLatestTurn,
Expand All @@ -593,6 +593,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
[threadActivities],
);
const activePendingUserInput = pendingUserInputs[0] ?? null;
const isWaitingForUserInput = activePendingUserInput !== null;
const isWorking =
(phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint) &&
!isWaitingForUserInput;
const activePendingDraftAnswers = useMemo(
() =>
activePendingUserInput
Expand Down Expand Up @@ -862,14 +866,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
if (!activeLatestTurn.completedAt) return null;
if (!latestTurnHasToolActivity) return null;

const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt);
const elapsed = formatTurnWorkElapsedExcludingUserInputWait(
activeLatestTurn.startedAt,
activeLatestTurn.completedAt,
threadActivities,
);
return elapsed ? `Worked for ${elapsed}` : null;
}, [
activeLatestTurn?.completedAt,
activeLatestTurn?.startedAt,
latestTurnHasToolActivity,
latestTurnSettled,
threadActivities,
]);
const workingForLabel = useMemo(() => {
if (!isWorking) return null;
if (!activeWorkStartedAt) return null;
return (
formatTurnWorkElapsedExcludingUserInputWait(activeWorkStartedAt, nowIso, threadActivities) ??
formatElapsed(activeWorkStartedAt, nowIso)
);
}, [activeWorkStartedAt, isWorking, nowIso, threadActivities]);
const completionDividerBeforeEntryId = useMemo(() => {
if (!latestTurnSettled) return null;
if (!activeLatestTurn?.startedAt) return null;
Expand Down Expand Up @@ -1900,14 +1917,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
: "local";

useEffect(() => {
if (phase !== "running") return;
if (phase !== "running" || isWaitingForUserInput) return;
const timer = window.setInterval(() => {
setNowTick(Date.now());
}, 1000);
return () => {
window.clearInterval(timer);
};
}, [phase]);
}, [isWaitingForUserInput, phase]);

const beginSendPhase = useCallback((nextPhase: Exclude<SendPhase, "idle">) => {
setSendStartedAt((current) => current ?? new Date().toISOString());
Expand Down Expand Up @@ -3278,6 +3295,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
key={activeThread.id}
hasMessages={timelineEntries.length > 0}
isWorking={isWorking}
isWaitingForUserInput={isWaitingForUserInput}
waitingStartedAt={activePendingUserInput?.createdAt ?? null}
workingForLabel={workingForLabel}
activeTurnInProgress={isWorking || !latestTurnSettled}
activeTurnStartedAt={activeWorkStartedAt}
scrollContainer={messagesScrollElement}
Expand Down
53 changes: 44 additions & 9 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;
interface MessagesTimelineProps {
hasMessages: boolean;
isWorking: boolean;
isWaitingForUserInput: boolean;
waitingStartedAt: string | null;
workingForLabel: string | null;
activeTurnInProgress: boolean;
activeTurnStartedAt: string | null;
scrollContainer: HTMLDivElement | null;
Expand All @@ -67,6 +70,9 @@ interface MessagesTimelineProps {
export const MessagesTimeline = memo(function MessagesTimeline({
hasMessages,
isWorking,
isWaitingForUserInput,
waitingStartedAt,
workingForLabel,
activeTurnInProgress,
activeTurnStartedAt,
scrollContainer,
Expand Down Expand Up @@ -113,7 +119,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
return () => {
observer.disconnect();
};
}, [hasMessages, isWorking]);
}, [hasMessages, isWorking, isWaitingForUserInput]);

const rows = useMemo<TimelineRow[]>(() => {
const nextRows: TimelineRow[] = [];
Expand Down Expand Up @@ -175,10 +181,23 @@ export const MessagesTimeline = memo(function MessagesTimeline({
id: "working-indicator-row",
createdAt: activeTurnStartedAt,
});
} else if (isWaitingForUserInput) {
nextRows.push({
kind: "waiting",
id: "waiting-indicator-row",
createdAt: waitingStartedAt ?? activeTurnStartedAt,
});
}

return nextRows;
}, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]);
}, [
timelineEntries,
completionDividerBeforeEntryId,
isWorking,
activeTurnStartedAt,
isWaitingForUserInput,
waitingStartedAt,
]);

const firstUnvirtualizedRowIndex = useMemo(() => {
const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0);
Expand All @@ -189,7 +208,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
let firstCurrentTurnRowIndex = -1;
if (!Number.isNaN(turnStartedAtMs)) {
firstCurrentTurnRowIndex = rows.findIndex((row) => {
if (row.kind === "working") return true;
if (row.kind === "working" || row.kind === "waiting") return true;
if (!row.createdAt) return false;
const rowCreatedAtMs = Date.parse(row.createdAt);
return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs;
Expand Down Expand Up @@ -233,7 +252,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
if (!row) return 96;
if (row.kind === "work") return 112;
if (row.kind === "proposed-plan") return estimateTimelineProposedPlanHeight(row.proposedPlan);
if (row.kind === "working") return 40;
if (row.kind === "working" || row.kind === "waiting") return 40;
return estimateTimelineMessageHeight(row.message, { timelineWidthPx });
},
measureElement: measureVirtualElement,
Expand Down Expand Up @@ -518,17 +537,32 @@ export const MessagesTimeline = memo(function MessagesTimeline({
<span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
</span>
<span>
{row.createdAt
? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}`
: "Working..."}
{workingForLabel
? `Working for ${workingForLabel}`
: row.createdAt
? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}`
: "Working..."}
</span>
</div>
</div>
)}

{row.kind === "waiting" && (
<div className="py-0.5 pl-1.5">
<div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70">
<span className="inline-flex items-center gap-[3px]">
<span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
<span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
<span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
</span>
<span>Waiting for you</span>
</div>
</div>
)}
</div>
);

if (!hasMessages && !isWorking) {
if (!hasMessages && !isWorking && !isWaitingForUserInput) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground/30">
Expand Down Expand Up @@ -597,7 +631,8 @@ type TimelineRow =
createdAt: string;
proposedPlan: TimelineProposedPlan;
}
| { kind: "working"; id: string; createdAt: string | null };
| { kind: "working"; id: string; createdAt: string | null }
| { kind: "waiting"; id: string; createdAt: string | null };

function estimateTimelineProposedPlanHeight(proposedPlan: TimelineProposedPlan): number {
const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72));
Expand Down
121 changes: 121 additions & 0 deletions apps/web/src/session-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
deriveTimelineEntries,
deriveWorkLogEntries,
findLatestProposedPlan,
formatTurnWorkElapsedExcludingUserInputWait,
hasToolActivityForTurn,
isLatestTurnSettled,
} from "./session-logic";
Expand Down Expand Up @@ -222,6 +223,126 @@ describe("derivePendingUserInputs", () => {
});
});

describe("formatTurnWorkElapsedExcludingUserInputWait", () => {
it("subtracts a fully resolved wait interval from total elapsed", () => {
const startIso = "2026-02-23T00:00:00.000Z";
const endIso = "2026-02-23T00:01:00.000Z";
const activities: OrchestrationThreadActivity[] = [
makeActivity({
id: "wait-requested",
createdAt: "2026-02-23T00:00:10.000Z",
kind: "user-input.requested",
summary: "User input requested",
tone: "info",
payload: { requestId: "req-wait-1" },
}),
makeActivity({
id: "wait-resolved",
createdAt: "2026-02-23T00:00:40.000Z",
kind: "user-input.resolved",
summary: "User input submitted",
tone: "info",
payload: { requestId: "req-wait-1" },
}),
];

expect(formatTurnWorkElapsedExcludingUserInputWait(startIso, endIso, activities)).toBe("30s");
});

it("subtracts an open wait interval up to endIso (freezes elapsed at request time)", () => {
const startIso = "2026-02-23T00:00:00.000Z";
const endIso = "2026-02-23T00:01:00.000Z";
const activities: OrchestrationThreadActivity[] = [
makeActivity({
id: "wait-requested-open",
createdAt: "2026-02-23T00:00:10.000Z",
kind: "user-input.requested",
summary: "User input requested",
tone: "info",
payload: { requestId: "req-open-1" },
}),
];

expect(formatTurnWorkElapsedExcludingUserInputWait(startIso, endIso, activities)).toBe("10s");
});

it("ignores malformed payloads / missing requestIds safely", () => {
const startIso = "2026-02-23T00:00:00.000Z";
const endIso = "2026-02-23T00:01:00.000Z";
const activities: OrchestrationThreadActivity[] = [
makeActivity({
id: "wait-requested-missing",
createdAt: "2026-02-23T00:00:10.000Z",
kind: "user-input.requested",
summary: "User input requested",
tone: "info",
payload: {},
}),
makeActivity({
id: "wait-resolved-missing",
createdAt: "2026-02-23T00:00:20.000Z",
kind: "user-input.resolved",
summary: "User input submitted",
tone: "info",
payload: {},
}),
makeActivity({
id: "wait-requested-non-string",
createdAt: "2026-02-23T00:00:30.000Z",
kind: "user-input.requested",
summary: "User input requested",
tone: "info",
payload: { requestId: 123 },
}),
];

expect(formatTurnWorkElapsedExcludingUserInputWait(startIso, endIso, activities)).toBe("1m");
});

it("handles multiple wait intervals (sum of overlaps)", () => {
const startIso = "2026-02-23T00:00:00.000Z";
const endIso = "2026-02-23T00:02:00.000Z";
const activities: OrchestrationThreadActivity[] = [
makeActivity({
id: "wait-1-requested",
createdAt: "2026-02-23T00:00:10.000Z",
kind: "user-input.requested",
summary: "User input requested",
tone: "info",
payload: { requestId: "req-multi-1" },
}),
makeActivity({
id: "wait-1-resolved",
createdAt: "2026-02-23T00:00:20.000Z",
kind: "user-input.resolved",
summary: "User input submitted",
tone: "info",
payload: { requestId: "req-multi-1" },
}),
makeActivity({
id: "wait-2-requested",
createdAt: "2026-02-23T00:01:00.000Z",
kind: "user-input.requested",
summary: "User input requested",
tone: "info",
payload: { requestId: "req-multi-2" },
}),
makeActivity({
id: "wait-2-resolved",
createdAt: "2026-02-23T00:01:30.000Z",
kind: "user-input.resolved",
summary: "User input submitted",
tone: "info",
payload: { requestId: "req-multi-2" },
}),
];

expect(formatTurnWorkElapsedExcludingUserInputWait(startIso, endIso, activities)).toBe(
"1m 20s",
);
});
});

describe("deriveActivePlanState", () => {
it("returns the latest plan update for the active turn", () => {
const activities: OrchestrationThreadActivity[] = [
Expand Down
Loading