diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9f625762c..d7f47167b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -53,11 +53,13 @@ import { deriveTimelineEntries, deriveActiveWorkStartedAt, deriveActivePlanState, + deriveUserInputWaitIntervals, findLatestProposedPlan, deriveWorkLogEntries, hasToolActivityForTurn, isLatestTurnSettled, formatElapsed, + formatTurnWorkElapsedExcludingUserInputWait, } from "../session-logic"; import { isScrollContainerNearBottom } from "../chat-scroll"; import { @@ -568,7 +570,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, @@ -592,7 +593,15 @@ export default function ChatView({ threadId }: ChatViewProps) { () => derivePendingUserInputs(threadActivities), [threadActivities], ); + const userInputWaitIntervals = useMemo( + () => deriveUserInputWaitIntervals(threadActivities), + [threadActivities], + ); const activePendingUserInput = pendingUserInputs[0] ?? null; + const isWaitingForUserInput = activePendingUserInput !== null; + const isWorking = + (phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint) && + !isWaitingForUserInput; const activePendingDraftAnswers = useMemo( () => activePendingUserInput @@ -862,14 +871,30 @@ 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, + userInputWaitIntervals, + ); return elapsed ? `Worked for ${elapsed}` : null; }, [ activeLatestTurn?.completedAt, activeLatestTurn?.startedAt, latestTurnHasToolActivity, latestTurnSettled, + userInputWaitIntervals, ]); + const workingForLabel = useMemo(() => { + if (!isWorking) return null; + if (!activeWorkStartedAt) return null; + return ( + formatTurnWorkElapsedExcludingUserInputWait( + activeWorkStartedAt, + nowIso, + userInputWaitIntervals, + ) ?? formatElapsed(activeWorkStartedAt, nowIso) + ); + }, [activeWorkStartedAt, isWorking, nowIso, userInputWaitIntervals]); const completionDividerBeforeEntryId = useMemo(() => { if (!latestTurnSettled) return null; if (!activeLatestTurn?.startedAt) return null; @@ -1900,14 +1925,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) => { setSendStartedAt((current) => current ?? new Date().toISOString()); @@ -3278,6 +3303,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} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e30801041..d22a5c653 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -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; @@ -67,6 +70,9 @@ interface MessagesTimelineProps { export const MessagesTimeline = memo(function MessagesTimeline({ hasMessages, isWorking, + isWaitingForUserInput, + waitingStartedAt, + workingForLabel, activeTurnInProgress, activeTurnStartedAt, scrollContainer, @@ -113,7 +119,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return () => { observer.disconnect(); }; - }, [hasMessages, isWorking]); + }, [hasMessages, isWorking, isWaitingForUserInput]); const rows = useMemo(() => { const nextRows: TimelineRow[] = []; @@ -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); @@ -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; @@ -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, @@ -518,17 +537,32 @@ export const MessagesTimeline = memo(function MessagesTimeline({ - {row.createdAt - ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}` - : "Working..."} + {workingForLabel + ? `Working for ${workingForLabel}` + : row.createdAt + ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}` + : "Working..."} + + + + )} + + {row.kind === "waiting" && ( +
+
+ + + + + Waiting for you
)} ); - if (!hasMessages && !isWorking) { + if (!hasMessages && !isWorking && !isWaitingForUserInput) { return (

@@ -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)); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 74ba3a814..fc5ec1748 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -8,8 +8,10 @@ import { derivePendingApprovals, derivePendingUserInputs, deriveTimelineEntries, + deriveUserInputWaitIntervals, deriveWorkLogEntries, findLatestProposedPlan, + formatTurnWorkElapsedExcludingUserInputWait, hasToolActivityForTurn, isLatestTurnSettled, } from "./session-logic"; @@ -222,6 +224,198 @@ 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, + deriveUserInputWaitIntervals(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, + deriveUserInputWaitIntervals(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, + deriveUserInputWaitIntervals(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, + deriveUserInputWaitIntervals(activities), + ), + ).toBe("1m 20s"); + }); + + it("sorts intervals and preserves open waits for live timers", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "wait-requested-late", + createdAt: "2026-02-23T00:00:20.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { requestId: "req-seq-1" }, + sequence: 2, + }), + makeActivity({ + id: "wait-resolved-late", + createdAt: "2026-02-23T00:00:30.000Z", + kind: "user-input.resolved", + summary: "User input submitted", + tone: "info", + payload: { requestId: "req-seq-1" }, + sequence: 3, + }), + makeActivity({ + id: "wait-requested-early", + createdAt: "2026-02-23T00:00:10.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { requestId: "req-seq-2" }, + sequence: 1, + }), + makeActivity({ + id: "wait-requested-open", + createdAt: "2026-02-23T00:00:40.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { requestId: "req-open-2" }, + sequence: 4, + }), + ]; + + expect(deriveUserInputWaitIntervals(activities)).toEqual([ + { startMs: Date.parse("2026-02-23T00:00:10.000Z"), endMs: null }, + { + startMs: Date.parse("2026-02-23T00:00:20.000Z"), + endMs: Date.parse("2026-02-23T00:00:30.000Z"), + }, + { startMs: Date.parse("2026-02-23T00:00:40.000Z"), endMs: null }, + ]); + }); +}); + describe("deriveActivePlanState", () => { it("returns the latest plan update for the active turn", () => { const activities: OrchestrationThreadActivity[] = [ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index e389f10e2..8e81b8268 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -56,6 +56,11 @@ export interface PendingUserInput { questions: ReadonlyArray; } +export interface UserInputWaitInterval { + startMs: number; + endMs: number | null; +} + export interface ActivePlanState { createdAt: string; turnId: TurnId | null; @@ -116,6 +121,126 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str return formatDuration(endedAt - startedAt); } +function payloadFromActivity( + activity: OrchestrationThreadActivity, +): Record | null { + return activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; +} + +function requestIdFromPayload(payload: Record | null): ApprovalRequestId | null { + return payload && typeof payload.requestId === "string" + ? ApprovalRequestId.makeUnsafe(payload.requestId) + : null; +} + +export function deriveUserInputWaitIntervals( + activities: ReadonlyArray, +): UserInputWaitInterval[] { + const ordered = [...activities].toSorted(compareActivitiesByOrder); + const openWaitStartByRequestId = new Map(); + const waitIntervals: UserInputWaitInterval[] = []; + + for (const activity of ordered) { + if (activity.kind !== "user-input.requested" && activity.kind !== "user-input.resolved") { + continue; + } + + const payload = payloadFromActivity(activity); + const requestId = requestIdFromPayload(payload); + if (!requestId) { + continue; + } + + const activityMs = Date.parse(activity.createdAt); + if (Number.isNaN(activityMs)) { + if (activity.kind === "user-input.resolved") { + openWaitStartByRequestId.delete(requestId); + } + continue; + } + + if (activity.kind === "user-input.requested") { + if (!openWaitStartByRequestId.has(requestId)) { + openWaitStartByRequestId.set(requestId, activityMs); + } + continue; + } + + const waitStartMs = openWaitStartByRequestId.get(requestId); + openWaitStartByRequestId.delete(requestId); + if (waitStartMs === undefined) { + continue; + } + + if (activityMs <= waitStartMs) { + continue; + } + + waitIntervals.push({ startMs: waitStartMs, endMs: activityMs }); + } + + for (const waitStartMs of openWaitStartByRequestId.values()) { + waitIntervals.push({ startMs: waitStartMs, endMs: null }); + } + + return waitIntervals.toSorted( + (left, right) => + left.startMs - right.startMs || + (left.endMs ?? Number.POSITIVE_INFINITY) - (right.endMs ?? Number.POSITIVE_INFINITY), + ); +} + +export function formatTurnWorkElapsedExcludingUserInputWait( + startIso: string, + endIso: string, + waitIntervals: ReadonlyArray, +): string | null { + const startMs = Date.parse(startIso); + const endMs = Date.parse(endIso); + if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs < startMs) { + return null; + } + + let waitingMs = 0; + let currentInterval: { startMs: number; endMs: number } | null = null; + + for (const interval of waitIntervals) { + const waitEndMs = interval.endMs ?? endMs; + const overlapStartMs = Math.max(startMs, interval.startMs); + const overlapEndMs = Math.min(endMs, waitEndMs); + if (overlapEndMs <= overlapStartMs) { + continue; + } + + const overlappingInterval = { + startMs: overlapStartMs, + endMs: overlapEndMs, + }; + + if (!currentInterval) { + currentInterval = overlappingInterval; + continue; + } + + if (overlappingInterval.startMs <= currentInterval.endMs) { + currentInterval.endMs = Math.max(currentInterval.endMs, overlappingInterval.endMs); + continue; + } + + waitingMs += currentInterval.endMs - currentInterval.startMs; + currentInterval = overlappingInterval; + } + + if (currentInterval) { + waitingMs += currentInterval.endMs - currentInterval.startMs; + } + + const effectiveMs = Math.max(0, endMs - startMs - waitingMs); + return formatDuration(effectiveMs); +} + type LatestTurnTiming = Pick; type SessionActivityState = Pick; @@ -163,14 +288,8 @@ export function derivePendingApprovals( const ordered = [...activities].toSorted(compareActivitiesByOrder); for (const activity of ordered) { - const payload = - activity.payload && typeof activity.payload === "object" - ? (activity.payload as Record) - : null; - const requestId = - payload && typeof payload.requestId === "string" - ? ApprovalRequestId.makeUnsafe(payload.requestId) - : null; + const payload = payloadFromActivity(activity); + const requestId = requestIdFromPayload(payload); const requestKind = payload && (payload.requestKind === "command" || @@ -268,14 +387,8 @@ export function derivePendingUserInputs( const ordered = [...activities].toSorted(compareActivitiesByOrder); for (const activity of ordered) { - const payload = - activity.payload && typeof activity.payload === "object" - ? (activity.payload as Record) - : null; - const requestId = - payload && typeof payload.requestId === "string" - ? ApprovalRequestId.makeUnsafe(payload.requestId) - : null; + const payload = payloadFromActivity(activity); + const requestId = requestIdFromPayload(payload); if (activity.kind === "user-input.requested" && requestId) { const questions = parseUserInputQuestions(payload);