Skip to content
Open
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
36 changes: 32 additions & 4 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,13 @@ import {
deriveTimelineEntries,
deriveActiveWorkStartedAt,
deriveActivePlanState,
deriveUserInputWaitIntervals,
findLatestProposedPlan,
deriveWorkLogEntries,
hasToolActivityForTurn,
isLatestTurnSettled,
formatElapsed,
formatTurnWorkElapsedExcludingUserInputWait,
} from "../session-logic";
import { isScrollContainerNearBottom } from "../chat-scroll";
import {
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<SendPhase, "idle">) => {
setSendStartedAt((current) => current ?? new Date().toISOString());
Expand Down Expand Up @@ -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}
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
Loading