diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 08a3a2155be7c78797a4072115d7974b6d488c2f..5fbc63ceaa62bbfe7bb3cd5f781a24f03bb877e6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1824,6 +1824,8 @@ export default function ChatView({ threadId }: ChatViewProps) { 0} isWorking={isWorking} + activeTurnInProgress={!latestTurnSettled} + activeTurnStartedAt={activeLatestTurn?.startedAt ?? null} scrollContainerRef={messagesScrollRef} timelineEntries={timelineEntries} completionDividerBeforeEntryId={completionDividerBeforeEntryId} @@ -2351,6 +2353,8 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri interface MessagesTimelineProps { hasMessages: boolean; isWorking: boolean; + activeTurnInProgress: boolean; + activeTurnStartedAt: string | null; scrollContainerRef: React.RefObject; timelineEntries: ReturnType; completionDividerBeforeEntryId: string | null; @@ -2374,19 +2378,23 @@ type TimelineRow = | { kind: "work"; id: string; + createdAt: string; groupedEntries: TimelineWorkEntry[]; } | { kind: "message"; id: string; + createdAt: string; message: TimelineMessage; showCompletionDivider: boolean; } - | { kind: "working"; id: string }; + | { kind: "working"; id: string; createdAt: string | null }; const MessagesTimeline = memo(function MessagesTimeline({ hasMessages, isWorking, + activeTurnInProgress, + activeTurnStartedAt, scrollContainerRef, timelineEntries, completionDividerBeforeEntryId, @@ -2423,6 +2431,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ nextRows.push({ kind: "work", id: timelineEntry.id, + createdAt: timelineEntry.createdAt, groupedEntries, }); index = cursor - 1; @@ -2432,6 +2441,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ nextRows.push({ kind: "message", id: timelineEntry.id, + createdAt: timelineEntry.createdAt, message: timelineEntry.message, showCompletionDivider: timelineEntry.message.role === "assistant" && @@ -2440,14 +2450,59 @@ const MessagesTimeline = memo(function MessagesTimeline({ } if (isWorking) { - nextRows.push({ kind: "working", id: "working-indicator-row" }); + nextRows.push({ + kind: "working", + id: "working-indicator-row", + createdAt: activeTurnStartedAt, + }); } return nextRows; - }, [timelineEntries, completionDividerBeforeEntryId, isWorking]); + }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]); + + const firstUnvirtualizedRowIndex = useMemo(() => { + if (!activeTurnInProgress) return rows.length; + + const turnStartedAtMs = + typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN; + let firstCurrentTurnRowIndex = -1; + if (!Number.isNaN(turnStartedAtMs)) { + firstCurrentTurnRowIndex = rows.findIndex((row) => { + if (row.kind === "working") return true; + if (!row.createdAt) return false; + const rowCreatedAtMs = Date.parse(row.createdAt); + return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs; + }); + } + + if (firstCurrentTurnRowIndex < 0) { + firstCurrentTurnRowIndex = rows.findIndex( + (row) => row.kind === "message" && row.message.streaming, + ); + } + + if (firstCurrentTurnRowIndex < 0) { + return rows.length; + } + + for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) { + const previousRow = rows[index]; + if (!previousRow || previousRow.kind !== "message") continue; + if (previousRow.message.role === "user") { + return index; + } + if (previousRow.message.role === "assistant" && !previousRow.message.streaming) { + break; + } + } + + return firstCurrentTurnRowIndex; + }, [activeTurnInProgress, activeTurnStartedAt, rows]); + + const virtualizedRowCount = Math.max(0, Math.min(rows.length, firstUnvirtualizedRowIndex)); const rowVirtualizer = useVirtualizer({ - count: rows.length, + count: virtualizedRowCount, getScrollElement: () => scrollContainerRef.current, estimateSize: (index: number) => { const row = rows[index]; @@ -2461,303 +2516,299 @@ const MessagesTimeline = memo(function MessagesTimeline({ }); const virtualRows = rowVirtualizer.getVirtualItems(); + const nonVirtualizedRows = rows.slice(virtualizedRowCount); - if (!hasMessages && !isWorking) { - return ( -
-

- Send a message to start the conversation. -

-
- ); - } + const renderRowContent = (row: TimelineRow) => ( +
+ {row.kind === "work" && + (() => { + const groupId = row.id; + const groupedEntries = row.groupedEntries; + const isExpanded = expandedWorkGroups[groupId] ?? false; + const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + const visibleEntries = + hasOverflow && !isExpanded + ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) + : groupedEntries; + const hiddenCount = groupedEntries.length - visibleEntries.length; + const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); + const groupLabel = onlyToolEntries + ? groupedEntries.length === 1 + ? "Tool call" + : `Tool calls (${groupedEntries.length})` + : groupedEntries.length === 1 + ? "Work event" + : `Work log (${groupedEntries.length})`; - return ( -
- {virtualRows.map((virtualRow: VirtualItem) => { - const row = rows[virtualRow.index]; - if (!row) return null; + return ( +
+
+

+ {groupLabel} +

+ {hasOverflow && ( + + )} +
+
+ {visibleEntries.map((workEntry) => ( +
+ +

+ {workEntry.detail ? ( + <> + {workEntry.label} + + {workEntry.detail} + + + ) : ( + workEntry.label + )} +

+
+ ))} +
+
+ ); + })()} - return ( -
-
- {row.kind === "work" && - (() => { - const groupId = row.id; - const groupedEntries = row.groupedEntries; - const isExpanded = expandedWorkGroups[groupId] ?? false; - const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; - const visibleEntries = - hasOverflow && !isExpanded - ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) - : groupedEntries; - const hiddenCount = groupedEntries.length - visibleEntries.length; - const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); - const groupLabel = onlyToolEntries - ? groupedEntries.length === 1 - ? "Tool call" - : `Tool calls (${groupedEntries.length})` - : groupedEntries.length === 1 - ? "Work event" - : `Work log (${groupedEntries.length})`; + {row.kind === "message" && + row.message.role === "user" && + (() => { + const userImages = row.message.attachments ?? []; + const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); + return ( +
+
+ {userImages.length > 0 && ( +
+ {userImages.map((image: NonNullable[number]) => ( +
+ {image.previewUrl ? ( + {image.name} onImageExpand({ src: image.previewUrl!, name: image.name })} + /> + ) : ( +
+ {image.name} +
+ )} +
+ ))} +
+ )} + {row.message.text && ( +
+                    {row.message.text}
+                  
+ )} +
+
+ {row.message.text && } + {canRevertAgentWork && ( + + )} +
+

+ {formatTimestamp(row.message.createdAt)} +

+
+
+
+ ); + })()} + {row.kind === "message" && + row.message.role === "assistant" && + (() => { + const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); + return ( + <> + {row.showCompletionDivider && ( +
+ + + {completionSummary ? `Response • ${completionSummary}` : "Response"} + + +
+ )} +
+ + {(() => { + const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); + if (!turnSummary) return null; + const checkpointFiles = turnSummary.files; + if (checkpointFiles.length === 0) return null; + const summaryStat = checkpointFiles.reduce( + (acc, file) => { + if ( + typeof file.additions !== "number" || + typeof file.deletions !== "number" + ) { + return acc; + } + return { + additions: acc.additions + file.additions, + deletions: acc.deletions + file.deletions, + }; + }, + { additions: 0, deletions: 0 }, + ); + const changedFileCountLabel = String(checkpointFiles.length); return ( -
-
+
+

- {groupLabel} + Changed files ({changedFileCountLabel}) + {(summaryStat.additions > 0 || summaryStat.deletions > 0) && ( + <> + • + +{summaryStat.additions} + / + -{summaryStat.deletions} + + )}

- {hasOverflow && ( + +
+
+ {checkpointFiles.map((file) => ( - )} -
-
- {visibleEntries.map((workEntry) => ( -
- -

- {workEntry.detail ? ( + {(() => { + const stat = + typeof file.additions === "number" && + typeof file.deletions === "number" + ? { + additions: file.additions, + deletions: file.deletions, + } + : null; + if (!stat) { + return file.path; + } + return ( <> - {workEntry.label} - - {workEntry.detail} - + {file.path} + ( + +{stat.additions} + / + -{stat.deletions} + ) - ) : ( - workEntry.label - )} -

-
+ ); + })()} + ))}
); })()} +

+ {formatMessageMeta( + row.message.createdAt, + row.message.streaming + ? formatElapsed(row.message.createdAt, nowIso) + : formatElapsed(row.message.createdAt, row.message.completedAt), + )} +

+
+ + ); + })()} - {row.kind === "message" && - row.message.role === "user" && - (() => { - const userImages = row.message.attachments ?? []; - const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); - return ( -
-
- {userImages.length > 0 && ( -
- {userImages.map( - (image: NonNullable[number]) => ( -
- {image.previewUrl ? ( - {image.name} - onImageExpand({ src: image.previewUrl!, name: image.name }) - } - /> - ) : ( -
- {image.name} -
- )} -
- ), - )} -
- )} - {row.message.text && ( -
-                            {row.message.text}
-                          
- )} -
-
- {row.message.text && } - {canRevertAgentWork && ( - - )} -
-

- {formatTimestamp(row.message.createdAt)} -

-
-
-
- ); - })()} - - {row.kind === "message" && - row.message.role === "assistant" && - (() => { - const messageText = - row.message.text || (row.message.streaming ? "" : "(empty response)"); - return ( - <> - {row.showCompletionDivider && ( -
- - - {completionSummary ? `Response • ${completionSummary}` : "Response"} - - -
- )} -
- - {(() => { - const turnSummary = turnDiffSummaryByAssistantMessageId.get( - row.message.id, - ); - if (!turnSummary) return null; - const checkpointFiles = turnSummary.files; - if (checkpointFiles.length === 0) return null; - const summaryStat = checkpointFiles.reduce( - (acc, file) => { - if ( - typeof file.additions !== "number" || - typeof file.deletions !== "number" - ) { - return acc; - } - return { - additions: acc.additions + file.additions, - deletions: acc.deletions + file.deletions, - }; - }, - { additions: 0, deletions: 0 }, - ); - const changedFileCountLabel = String(checkpointFiles.length); - return ( -
-
-

- Changed files ({changedFileCountLabel}) - {(summaryStat.additions > 0 || summaryStat.deletions > 0) && ( - <> - • - +{summaryStat.additions} - / - - -{summaryStat.deletions} - - - )} -

- -
-
- {checkpointFiles.map((file) => ( - - ))} -
-
- ); - })()} -

- {formatMessageMeta( - row.message.createdAt, - row.message.streaming - ? formatElapsed(row.message.createdAt, nowIso) - : formatElapsed(row.message.createdAt, row.message.completedAt), - )} -

-
- - ); - })()} - - {row.kind === "working" && ( -
- -
- - - - - -
-
- )} -
+ {row.kind === "working" && ( +
+ +
+ + + + +
- ); - })} +
+ )} +
+ ); + + if (!hasMessages && !isWorking) { + return ( +
+

+ Send a message to start the conversation. +

+
+ ); + } + + return ( +
+ {virtualizedRowCount > 0 && ( +
+ {virtualRows.map((virtualRow: VirtualItem) => { + const row = rows[virtualRow.index]; + if (!row) return null; + + return ( +
+ {renderRowContent(row)} +
+ ); + })} +
+ )} + + {nonVirtualizedRows.map((row) => ( +
{renderRowContent(row)}
+ ))}
); });