diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 5104ddced5e6..e16d09e2a370 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,3 +1,4 @@ +import { memo } from "react"; import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import rehypeHighlight from "rehype-highlight"; @@ -13,7 +14,7 @@ const markdownComponents: Components = { }, }; -export default function ChatMarkdown({ text }: ChatMarkdownProps) { +function ChatMarkdown({ text }: ChatMarkdownProps) { return (
); } + +export default memo(ChatMarkdown); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ccbbd10d7807..382d30b53664 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2,6 +2,7 @@ import { EDITORS, type EditorId, ModelSlug, + type NativeApi, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, @@ -15,6 +16,8 @@ import { type FormEvent, Fragment, type KeyboardEvent, + memo, + type RefObject, useCallback, useEffect, useLayoutEffect, @@ -40,6 +43,7 @@ import { derivePendingApprovals, derivePhase, deriveTimelineEntries, + type PendingApproval, deriveWorkLogEntries, formatDuration, formatElapsed, @@ -164,6 +168,7 @@ export default function ChatView() { const activeThread = state.threads.find((t) => t.id === state.activeThreadId); const activeThreadId = activeThread?.id ?? null; + const activeSessionId = activeThread?.session?.sessionId; const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); const selectedModel = resolveModelSlug( activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, @@ -497,7 +502,7 @@ export default function ChatView() { if (phase !== "running") return; const timer = window.setInterval(() => { setNowTick(Date.now()); - }, 250); + }, 1000); return () => { window.clearInterval(timer); }; @@ -889,28 +894,31 @@ export default function ChatView() { }); }; - const onRespondToApproval = async (requestId: string, decision: ProviderApprovalDecision) => { - if (!api || !activeThread?.session) return; + const onRespondToApproval = useCallback( + async (requestId: string, decision: ProviderApprovalDecision) => { + if (!api || !activeSessionId || !activeThreadId) return; - setRespondingRequestIds((existing) => - existing.includes(requestId) ? existing : [...existing, requestId], - ); - try { - await api.providers.respondToRequest({ - sessionId: activeThread.session.sessionId, - requestId, - decision, - }); - } catch (err) { - dispatch({ - type: "SET_ERROR", - threadId: activeThread.id, - error: err instanceof Error ? err.message : "Failed to submit approval decision.", - }); - } finally { - setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); - } - }; + setRespondingRequestIds((existing) => + existing.includes(requestId) ? existing : [...existing, requestId], + ); + try { + await api.providers.respondToRequest({ + sessionId: activeSessionId, + requestId, + decision, + }); + } catch (err) { + dispatch({ + type: "SET_ERROR", + threadId: activeThreadId, + error: err instanceof Error ? err.message : "Failed to submit approval decision.", + }); + } finally { + setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); + } + }, + [activeSessionId, activeThreadId, api, dispatch], + ); const onModelSelect = useCallback( (model: ModelSlug) => { @@ -945,6 +953,18 @@ export default function ChatView() { void onSend(e as unknown as FormEvent); } }; + const onToggleWorkGroup = useCallback((groupId: string) => { + setExpandedWorkGroups((existing) => ({ + ...existing, + [groupId]: !existing[groupId], + })); + }, []); + const onExpandTimelineImage = useCallback((image: ExpandedImagePreview) => { + setExpandedImage(image); + }, []); + const onToggleDiff = useCallback(() => { + dispatch({ type: "TOGGLE_DIFF" }); + }, [dispatch]); // Empty state: no active thread if (!activeThread) { @@ -970,101 +990,24 @@ export default function ChatView() {
-
-

{activeThread.title}

- {activeProject && {activeProject.name}} -
-
- {/* Open in editor */} - {activeProject && } - {/* Git actions */} - {activeProject && } - - {/* Diff toggle */} - -
+
{/* Error banner */} - {activeThread.error && ( -
- - - - {activeThread.error} - - -
- )} - - {pendingApprovals.length > 0 && ( -
- {pendingApprovals.map((approval) => { - const isResponding = respondingRequestIds.includes(approval.requestId); - - return ( - - - - {approval.requestKind === "command" - ? "Command approval requested" - : "File-change approval requested"} - - - {approval.detail} - - - - - - - - - ); - })} -
- )} + + {/* Messages */}
- {activeThread.messages.length === 0 && !isWorking ? ( -
-

- Send a message to start the conversation. -

-
- ) : ( -
- {timelineEntries.map((timelineEntry, index) => { - if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") { - return null; - } - - const showCompletionDivider = - timelineEntry.kind === "message" && - timelineEntry.message.role === "assistant" && - completionDividerBeforeEntryId === timelineEntry.id; - - if (timelineEntry.kind === "work") { - const groupedEntries = [timelineEntry.entry]; - let cursor = index + 1; - while (cursor < timelineEntries.length) { - const nextEntry = timelineEntries[cursor]; - if (!nextEntry || nextEntry.kind !== "work") break; - groupedEntries.push(nextEntry.entry); - cursor += 1; - } - - const groupId = timelineEntry.id; - 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 ( - -
-
-

- {groupLabel} -

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

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

-
- ))} -
-
-
- ); - } - - if (timelineEntry.message.role === "user") { - const userImages = timelineEntry.message.attachments ?? []; - return ( - -
-
- {userImages.length > 0 && ( -
- {userImages.map((image) => ( -
- {image.previewUrl ? ( - {image.name} - setExpandedImage({ src: image.previewUrl!, name: image.name }) - } - /> - ) : ( -
- {image.name} -
- )} -
- ))} -
- )} - {timelineEntry.message.text && ( -
-                            {timelineEntry.message.text}
-                          
- )} -

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

-
-
-
- ); - } - - return ( - - {showCompletionDivider && ( -
- - - {completionSummary ? `Response • ${completionSummary}` : "Response"} - - -
- )} -
- -

- {formatMessageMeta( - timelineEntry.message.createdAt, - timelineEntry.message.streaming - ? formatElapsed(timelineEntry.message.createdAt, nowIso) - : formatElapsed( - timelineEntry.message.createdAt, - assistantCompletionByItemId.get(timelineEntry.message.id), - ), - )} -

-
-
- ); - })} - {isWorking && ( -
- -
- - - - - -
-
- )} -
-
- )} + 0} + isWorking={isWorking} + timelineEntries={timelineEntries} + completionDividerBeforeEntryId={completionDividerBeforeEntryId} + completionSummary={completionSummary} + assistantCompletionByItemId={assistantCompletionByItemId} + nowIso={nowIso} + expandedWorkGroups={expandedWorkGroups} + onToggleWorkGroup={onToggleWorkGroup} + onImageExpand={onExpandTimelineImage} + messagesEndRef={messagesEndRef} + />
{/* Input bar */} @@ -1519,7 +1283,355 @@ export default function ChatView() { ); } -function ModelPicker(props: { model: ModelSlug; onModelChange: (model: ModelSlug) => void }) { +interface ChatHeaderProps { + activeThreadTitle: string; + activeProjectName: string | undefined; + keybindings: ResolvedKeybindingsConfig; + api: NativeApi | undefined; + gitCwd: string | null; + diffOpen: boolean; + onToggleDiff: () => void; +} + +const ChatHeader = memo(function ChatHeader({ + activeThreadTitle, + activeProjectName, + keybindings, + api, + gitCwd, + diffOpen, + onToggleDiff, +}: ChatHeaderProps) { + return ( + <> +
+

{activeThreadTitle}

+ {activeProjectName && {activeProjectName}} +
+
+ {activeProjectName && } + {activeProjectName && } + +
+ + ); +}); + +const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: string | null }) { + if (!error) return null; + return ( +
+ + + + {error} + + +
+ ); +}); + +interface PendingApprovalsPanelProps { + pendingApprovals: PendingApproval[]; + respondingRequestIds: string[]; + onRespondToApproval: (requestId: string, decision: ProviderApprovalDecision) => Promise; +} + +const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ + pendingApprovals, + respondingRequestIds, + onRespondToApproval, +}: PendingApprovalsPanelProps) { + if (pendingApprovals.length === 0) return null; + return ( +
+ {pendingApprovals.map((approval) => { + const isResponding = respondingRequestIds.includes(approval.requestId); + + return ( + + + + {approval.requestKind === "command" + ? "Command approval requested" + : "File-change approval requested"} + + + {approval.detail} + + + + + + + + + ); + })} +
+ ); +}); + +interface MessagesTimelineProps { + hasMessages: boolean; + isWorking: boolean; + timelineEntries: ReturnType; + completionDividerBeforeEntryId: string | null; + completionSummary: string | null; + assistantCompletionByItemId: Map; + nowIso: string; + expandedWorkGroups: Record; + onToggleWorkGroup: (groupId: string) => void; + onImageExpand: (image: ExpandedImagePreview) => void; + messagesEndRef: RefObject; +} + +const MessagesTimeline = memo(function MessagesTimeline({ + hasMessages, + isWorking, + timelineEntries, + completionDividerBeforeEntryId, + completionSummary, + assistantCompletionByItemId, + nowIso, + expandedWorkGroups, + onToggleWorkGroup, + onImageExpand, + messagesEndRef, +}: MessagesTimelineProps) { + if (!hasMessages && !isWorking) { + return ( +
+

Send a message to start the conversation.

+
+ ); + } + + return ( +
+ {timelineEntries.map((timelineEntry, index) => { + if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") { + return null; + } + + const showCompletionDivider = + timelineEntry.kind === "message" && + timelineEntry.message.role === "assistant" && + completionDividerBeforeEntryId === timelineEntry.id; + + if (timelineEntry.kind === "work") { + const groupedEntries = [timelineEntry.entry]; + let cursor = index + 1; + while (cursor < timelineEntries.length) { + const nextEntry = timelineEntries[cursor]; + if (!nextEntry || nextEntry.kind !== "work") break; + groupedEntries.push(nextEntry.entry); + cursor += 1; + } + + const groupId = timelineEntry.id; + 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 ( + +
+
+

+ {groupLabel} +

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

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

+
+ ))} +
+
+
+ ); + } + + if (timelineEntry.message.role === "user") { + const userImages = timelineEntry.message.attachments ?? []; + return ( + +
+
+ {userImages.length > 0 && ( +
+ {userImages.map((image) => ( +
+ {image.previewUrl ? ( + {image.name} + onImageExpand({ src: image.previewUrl!, name: image.name }) + } + /> + ) : ( +
+ {image.name} +
+ )} +
+ ))} +
+ )} + {timelineEntry.message.text && ( +
+                      {timelineEntry.message.text}
+                    
+ )} +

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

+
+
+
+ ); + } + + return ( + + {showCompletionDivider && ( +
+ + + {completionSummary ? `Response • ${completionSummary}` : "Response"} + + +
+ )} +
+ +

+ {formatMessageMeta( + timelineEntry.message.createdAt, + timelineEntry.message.streaming + ? formatElapsed(timelineEntry.message.createdAt, nowIso) + : formatElapsed( + timelineEntry.message.createdAt, + assistantCompletionByItemId.get(timelineEntry.message.id), + ), + )} +

+
+
+ ); + })} + {isWorking && ( +
+ +
+ + + + + +
+
+ )} +
+
+ ); +}); + +const ModelPicker = memo(function ModelPicker(props: { + model: ModelSlug; + onModelChange: (model: ModelSlug) => void; +}) { return ( ); -} +}); -function ReasoningEffortPicker(props: { +const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: { effort: ReasoningEffort; onEffortChange: (effort: ReasoningEffort) => void; }) { @@ -1562,9 +1674,13 @@ function ReasoningEffortPicker(props: { ); -} +}); -function OpenInPicker({ keybindings }: { keybindings: ResolvedKeybindingsConfig }) { +const OpenInPicker = memo(function OpenInPicker({ + keybindings, +}: { + keybindings: ResolvedKeybindingsConfig; +}) { const [lastEditor, setLastEditor] = useState(() => { const stored = localStorage.getItem(LAST_EDITOR_KEY); return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; @@ -1648,4 +1764,4 @@ function OpenInPicker({ keybindings }: { keybindings: ResolvedKeybindingsConfig ); -} +});