diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f34c2a56ede..4b15b8090b0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -70,11 +70,6 @@ import { formatWorkspaceRelativePath } from "../../filePathDisplay"; // --------------------------------------------------------------------------- interface TimelineRowSharedState { - activeTurnInProgress: boolean; - activeTurnId: TurnId | null | undefined; - isWorking: boolean; - isRevertingCheckpoint: boolean; - completionSummary: string | null; timestampFormat: TimestampFormat; routeThreadKey: string; markdownCwd: string | undefined; @@ -86,7 +81,16 @@ interface TimelineRowSharedState { onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; } +interface TimelineRowActivityState { + activeTurnInProgress: boolean; + activeTurnId: TurnId | null; + isWorking: boolean; + isRevertingCheckpoint: boolean; + completionSummary: string | null; +} + const TimelineRowCtx = createContext(null!); +const TimelineRowActivityCtx = createContext(null!); const TIMELINE_LIST_HEADER =
; const TIMELINE_LIST_FOOTER =
; @@ -191,15 +195,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }; }, [listRef, onIsAtEndChange, rows.length]); - // Memoised context value — only changes on state transitions, NOT on - // every streaming chunk. Callbacks from ChatView are useCallback-stable. const sharedState = useMemo( () => ({ - activeTurnInProgress, - activeTurnId: activeTurnId ?? null, - isWorking, - isRevertingCheckpoint, - completionSummary, timestampFormat, routeThreadKey, markdownCwd, @@ -211,11 +208,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onOpenTurnDiff, }), [ - activeTurnInProgress, - activeTurnId, - isWorking, - isRevertingCheckpoint, - completionSummary, timestampFormat, routeThreadKey, markdownCwd, @@ -227,6 +219,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onOpenTurnDiff, ], ); + const activityState = useMemo( + () => ({ + activeTurnInProgress, + activeTurnId: activeTurnId ?? null, + isWorking, + isRevertingCheckpoint, + completionSummary, + }), + [activeTurnInProgress, activeTurnId, completionSummary, isRevertingCheckpoint, isWorking], + ); // Stable renderItem — no closure deps. Row components read shared state // from TimelineRowCtx, which propagates through LegendList's memo. @@ -251,21 +253,23 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return ( - - ref={listRef} - data={rows} - keyExtractor={keyExtractor} - renderItem={renderItem} - estimatedItemSize={90} - initialScrollAtEnd - maintainScrollAtEnd - maintainScrollAtEndThreshold={0.1} - maintainVisibleContentPosition - onScroll={handleScroll} - className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5" - ListHeaderComponent={TIMELINE_LIST_HEADER} - ListFooterComponent={TIMELINE_LIST_FOOTER} - /> + + + ref={listRef} + data={rows} + keyExtractor={keyExtractor} + renderItem={renderItem} + estimatedItemSize={90} + initialScrollAtEnd + maintainScrollAtEnd + maintainScrollAtEndThreshold={0.1} + maintainVisibleContentPosition + onScroll={handleScroll} + className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5" + ListHeaderComponent={TIMELINE_LIST_HEADER} + ListFooterComponent={TIMELINE_LIST_FOOTER} + /> + ); }); @@ -283,9 +287,7 @@ type TimelineMessage = Extract["message"]; type TimelineWorkEntry = Extract["groupedEntries"][number]; type TimelineRow = MessagesTimelineRow; -function TimelineRowContent({ row }: { row: TimelineRow }) { - const ctx = use(TimelineRowCtx); - +const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { return (
- {row.kind === "work" && } - - {row.kind === "message" && - row.message.role === "user" && - (() => { - const userImages = row.message.attachments ?? []; - const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); - const terminalContexts = displayedUserMessage.contexts; - const canRevertAgentWork = typeof row.revertTurnCount === "number"; - return ( -
-
- {userImages.length > 0 && ( -
- {userImages.map( - (image: NonNullable[number]) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} -
- ), - )} + {row.kind === "work" ? : null} + {row.kind === "message" && row.message.role === "user" ? : null} + {row.kind === "message" && row.message.role === "assistant" ? ( + + ) : null} + {row.kind === "proposed-plan" ? : null} + {row.kind === "working" ? : null} +
+ ); +}); + +function UserTimelineRow({ row }: { row: Extract }) { + const ctx = use(TimelineRowCtx); + const userImages = row.message.attachments ?? []; + const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); + const terminalContexts = displayedUserMessage.contexts; + const canRevertAgentWork = typeof row.revertTurnCount === "number"; + + return ( +
+
+ {userImages.length > 0 && ( +
+ {userImages.map((image: NonNullable[number]) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name}
)} - {(displayedUserMessage.visibleText.trim().length > 0 || - terminalContexts.length > 0) && ( - - )} -
-
- {displayedUserMessage.copyText && ( - - )} - {canRevertAgentWork && ( - - )} -
-

- {formatTimestamp(row.message.createdAt, ctx.timestampFormat)} -

-
-
- ); - })()} - - {row.kind === "message" && - row.message.role === "assistant" && - (() => { - const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); - const assistantTurnStillInProgress = - ctx.activeTurnInProgress && - ctx.activeTurnId !== null && - ctx.activeTurnId !== undefined && - row.message.turnId === ctx.activeTurnId; - const assistantCopyState = resolveAssistantMessageCopyState({ - text: row.message.text ?? null, - showCopyButton: row.showAssistantCopyButton, - streaming: row.message.streaming || assistantTurnStillInProgress, - }); - return ( - <> - {row.showCompletionDivider && ( -
- - - {ctx.completionSummary ? `Response • ${ctx.completionSummary}` : "Response"} - - -
- )} -
- - -
-

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

- {assistantCopyState.visible ? ( -
- -
- ) : null} -
-
- - ); - })()} - - {row.kind === "proposed-plan" && ( -
- + )} + {(displayedUserMessage.visibleText.trim().length > 0 || terminalContexts.length > 0) && ( + + )} +
+
+ {displayedUserMessage.copyText && ( + + )} + {canRevertAgentWork && } +
+

+ {formatTimestamp(row.message.createdAt, ctx.timestampFormat)} +

- )} +
+
+ ); +} - {row.kind === "working" && ( -
-
- - - - - - - {row.createdAt ? ( - <> - Working for - - ) : ( - "Working..." - )} - -
+function RevertUserMessageButton({ messageId }: { messageId: MessageId }) { + const ctx = use(TimelineRowCtx); + const activity = use(TimelineRowActivityCtx); + + return ( + + ); +} + +function AssistantTimelineRow({ row }: { row: Extract }) { + const ctx = use(TimelineRowCtx); + const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); + + return ( + <> + {row.showCompletionDivider && } +
+ + +
+

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

+
- )} +
+ + ); +} + +function AssistantCompletionDivider() { + const activity = use(TimelineRowActivityCtx); + + return ( +
+ + + {activity.completionSummary ? `Response • ${activity.completionSummary}` : "Response"} + + +
+ ); +} + +function AssistantCopyButton({ row }: { row: Extract }) { + const activity = use(TimelineRowActivityCtx); + const assistantTurnStillInProgress = + activity.activeTurnInProgress && + activity.activeTurnId !== null && + row.message.turnId === activity.activeTurnId; + const assistantCopyState = resolveAssistantMessageCopyState({ + text: row.message.text ?? null, + showCopyButton: row.showAssistantCopyButton, + streaming: row.message.streaming || assistantTurnStillInProgress, + }); + + if (!assistantCopyState.visible) { + return null; + } + + return ( +
+ +
+ ); +} + +function ProposedPlanTimelineRow({ + row, +}: { + row: Extract; +}) { + const ctx = use(TimelineRowCtx); + + return ( +
+ +
+ ); +} + +function WorkingTimelineRow({ row }: { row: Extract }) { + return ( +
+
+ + + + + + + {row.createdAt ? ( + <> + Working for + + ) : ( + "Working..." + )} + +
); }