authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-16 00:35:58-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-16 00:55:31-08:00
log51dcb547edf34240a509ffb80f7486d382fd9445
tree5689ffad2dd4a0f72d49d274bceb04b95072c4ce
parent228862ac564e8753e56286ee9936ce8352570768

Refactor ChatView into memoized subcomponents

- Extract header, error, approvals, and timeline into memoized components - Memoize `ChatMarkdown`, pickers, and handlers to reduce avoidable re-renders - Reduce running-time ticker frequency from 250ms to 1000ms

2 files changed, 432 insertions(+), 314 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+4-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { memo } from "react";
1import type { Components } from "react-markdown";2import type { Components } from "react-markdown";
2import ReactMarkdown from "react-markdown";3import ReactMarkdown from "react-markdown";
3import rehypeHighlight from "rehype-highlight";4import rehypeHighlight from "rehype-highlight";
...@@ -13,7 +14,7 @@ const markdownComponents: Components = {...@@ -13,7 +14,7 @@ const markdownComponents: Components = {
13 },14 },
14};15};
1516
16export default function ChatMarkdown({ text }: ChatMarkdownProps) {17function ChatMarkdown({ text }: ChatMarkdownProps) {
17 return (18 return (
18 <div className="chat-markdown text-sm leading-relaxed text-foreground/80">19 <div className="chat-markdown text-sm leading-relaxed text-foreground/80">
19 <ReactMarkdown20 <ReactMarkdown
...@@ -26,3 +27,5 @@ export default function ChatMarkdown({ text }: ChatMarkdownProps) {...@@ -26,3 +27,5 @@ export default function ChatMarkdown({ text }: ChatMarkdownProps) {
26 </div>27 </div>
27 );28 );
28}29}
30
31export default memo(ChatMarkdown);
apps/web/src/components/ChatView.tsx+428-313
...@@ -2,6 +2,7 @@ import {...@@ -2,6 +2,7 @@ import {
2 EDITORS,2 EDITORS,
3 type EditorId,3 type EditorId,
4 ModelSlug,4 ModelSlug,
5 type NativeApi,
5 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,6 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
6 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,7 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
7 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,8 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
...@@ -15,6 +16,8 @@ import {...@@ -15,6 +16,8 @@ import {
15 type FormEvent,16 type FormEvent,
16 Fragment,17 Fragment,
17 type KeyboardEvent,18 type KeyboardEvent,
19 memo,
20 type RefObject,
18 useCallback,21 useCallback,
19 useEffect,22 useEffect,
20 useLayoutEffect,23 useLayoutEffect,
...@@ -40,6 +43,7 @@ import {...@@ -40,6 +43,7 @@ import {
40 derivePendingApprovals,43 derivePendingApprovals,
41 derivePhase,44 derivePhase,
42 deriveTimelineEntries,45 deriveTimelineEntries,
46 type PendingApproval,
43 deriveWorkLogEntries,47 deriveWorkLogEntries,
44 formatDuration,48 formatDuration,
45 formatElapsed,49 formatElapsed,
...@@ -47,7 +51,7 @@ import {...@@ -47,7 +51,7 @@ import {
47} from "../session-logic";51} from "../session-logic";
48import { isScrollContainerNearBottom } from "../chat-scroll";52import { isScrollContainerNearBottom } from "../chat-scroll";
49import { useStore } from "../store";53import { useStore } from "../store";
50import type { ChatImageAttachment } from "../types";54import type { ChatImageAttachment, Thread } from "../types";
51import BranchToolbar from "./BranchToolbar";55import BranchToolbar from "./BranchToolbar";
52import GitActionsControl from "./GitActionsControl";56import GitActionsControl from "./GitActionsControl";
53import {57import {
...@@ -497,7 +501,7 @@ export default function ChatView() {...@@ -497,7 +501,7 @@ export default function ChatView() {
497 if (phase !== "running") return;501 if (phase !== "running") return;
498 const timer = window.setInterval(() => {502 const timer = window.setInterval(() => {
499 setNowTick(Date.now());503 setNowTick(Date.now());
500 }, 250);504 }, 1000);
501 return () => {505 return () => {
502 window.clearInterval(timer);506 window.clearInterval(timer);
503 };507 };
...@@ -889,28 +893,31 @@ export default function ChatView() {...@@ -889,28 +893,31 @@ export default function ChatView() {
889 });893 });
890 };894 };
891895
892 const onRespondToApproval = async (requestId: string, decision: ProviderApprovalDecision) => {896 const onRespondToApproval = useCallback(
893 if (!api || !activeThread?.session) return;897 async (requestId: string, decision: ProviderApprovalDecision) => {
898 if (!api || !activeThread?.session) return;
894899
895 setRespondingRequestIds((existing) =>900 setRespondingRequestIds((existing) =>
896 existing.includes(requestId) ? existing : [...existing, requestId],901 existing.includes(requestId) ? existing : [...existing, requestId],
897 );902 );
898 try {903 try {
899 await api.providers.respondToRequest({904 await api.providers.respondToRequest({
900 sessionId: activeThread.session.sessionId,905 sessionId: activeThread.session.sessionId,
901 requestId,906 requestId,
902 decision,907 decision,
903 });908 });
904 } catch (err) {909 } catch (err) {
905 dispatch({910 dispatch({
906 type: "SET_ERROR",911 type: "SET_ERROR",
907 threadId: activeThread.id,912 threadId: activeThread.id,
908 error: err instanceof Error ? err.message : "Failed to submit approval decision.",913 error: err instanceof Error ? err.message : "Failed to submit approval decision.",
909 });914 });
910 } finally {915 } finally {
911 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));916 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
912 }917 }
913 };918 },
919 [activeThread?.id, activeThread?.session, api, dispatch],
920 );
914921
915 const onModelSelect = useCallback(922 const onModelSelect = useCallback(
916 (model: ModelSlug) => {923 (model: ModelSlug) => {
...@@ -945,6 +952,18 @@ export default function ChatView() {...@@ -945,6 +952,18 @@ export default function ChatView() {
945 void onSend(e as unknown as FormEvent);952 void onSend(e as unknown as FormEvent);
946 }953 }
947 };954 };
955 const onToggleWorkGroup = useCallback((groupId: string) => {
956 setExpandedWorkGroups((existing) => ({
957 ...existing,
958 [groupId]: !existing[groupId],
959 }));
960 }, []);
961 const onExpandTimelineImage = useCallback((image: ExpandedImagePreview) => {
962 setExpandedImage(image);
963 }, []);
964 const onToggleDiff = useCallback(() => {
965 dispatch({ type: "TOGGLE_DIFF" });
966 }, [dispatch]);
948967
949 // Empty state: no active thread968 // Empty state: no active thread
950 if (!activeThread) {969 if (!activeThread) {
...@@ -970,101 +989,24 @@ export default function ChatView() {...@@ -970,101 +989,24 @@ export default function ChatView() {
970 <header989 <header
971 className={`flex items-center justify-between border-b border-border px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`}990 className={`flex items-center justify-between border-b border-border px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`}
972 >991 >
973 <div className="flex items-center gap-3">992 <ChatHeader
974 <h2 className="text-sm font-medium text-foreground">{activeThread.title}</h2>993 activeThreadTitle={activeThread.title}
975 {activeProject && <Badge variant="outline">{activeProject.name}</Badge>}994 activeProjectName={activeProject?.name}
976 </div>995 keybindings={keybindings}
977 <div className="flex items-center gap-3">996 api={api}
978 {/* Open in editor */}997 gitCwd={gitCwd}
979 {activeProject && <OpenInPicker keybindings={keybindings} />}998 diffOpen={state.diffOpen}
980 {/* Git actions */}999 onToggleDiff={onToggleDiff}
981 {activeProject && <GitActionsControl api={api} gitCwd={gitCwd} />}1000 />
982
983 {/* Diff toggle */}
984 <Button
985 size="xs"
986 variant="ghost"
987 className={cn(
988 "text-muted-foreground/70 hover:text-foreground/80",
989 state.diffOpen && "bg-accent text-accent-foreground",
990 )}
991 onClick={() => dispatch({ type: "TOGGLE_DIFF" })}
992 >
993 Diff
994 </Button>
995 </div>
996 </header>1001 </header>
9971002
998 {/* Error banner */}1003 {/* Error banner */}
999 {activeThread.error && (1004 <ThreadErrorBanner error={activeThread.error} />
1000 <div className="pt-3 mx-auto max-w-3xl">1005 <PendingApprovalsPanel
1001 <Alert variant="error">1006 pendingApprovals={pendingApprovals}
1002 <CircleAlertIcon />1007 respondingRequestIds={respondingRequestIds}
1003 <AlertDescription className="line-clamp-3" title={activeThread.error}>1008 onRespondToApproval={onRespondToApproval}
1004 {activeThread.error}1009 />
1005 </AlertDescription>
1006 </Alert>
1007 </div>
1008 )}
1009
1010 {pendingApprovals.length > 0 && (
1011 <div className="pt-3 mx-auto max-w-3xl space-y-2">
1012 {pendingApprovals.map((approval) => {
1013 const isResponding = respondingRequestIds.includes(approval.requestId);
1014
1015 return (
1016 <Alert variant="warning" key={approval.requestId}>
1017 <InfoIcon />
1018 <AlertTitle className="text-xs">
1019 {approval.requestKind === "command"
1020 ? "Command approval requested"
1021 : "File-change approval requested"}
1022 </AlertTitle>
1023 <AlertDescription
1024 className="truncate block font-mono text-[11px]"
1025 title={approval.detail}
1026 >
1027 {approval.detail}
1028 </AlertDescription>
1029 <AlertAction className="col-start-2! -col-end-1! mt-1.5 sm:row-start-auto sm:row-end-auto">
1030 <Button
1031 size="xs"
1032 variant="default"
1033 disabled={isResponding}
1034 onClick={() => void onRespondToApproval(approval.requestId, "accept")}
1035 >
1036 Approve once
1037 </Button>
1038 <Button
1039 size="xs"
1040 variant="outline"
1041 disabled={isResponding}
1042 onClick={() => void onRespondToApproval(approval.requestId, "acceptForSession")}
1043 >
1044 Always allow this session
1045 </Button>
1046 <Button
1047 size="xs"
1048 variant="destructive-outline"
1049 disabled={isResponding}
1050 onClick={() => void onRespondToApproval(approval.requestId, "decline")}
1051 >
1052 Decline
1053 </Button>
1054 <Button
1055 size="xs"
1056 variant="ghost"
1057 disabled={isResponding}
1058 onClick={() => void onRespondToApproval(approval.requestId, "cancel")}
1059 >
1060 Cancel turn
1061 </Button>
1062 </AlertAction>
1063 </Alert>
1064 );
1065 })}
1066 </div>
1067 )}
10681010
1069 {/* Messages */}1011 {/* Messages */}
1070 <div1012 <div
...@@ -1072,198 +1014,19 @@ export default function ChatView() {...@@ -1072,198 +1014,19 @@ export default function ChatView() {
1072 className="min-h-0 flex-1 overflow-y-auto px-5 py-4"1014 className="min-h-0 flex-1 overflow-y-auto px-5 py-4"
1073 onScroll={onMessagesScroll}1015 onScroll={onMessagesScroll}
1074 >1016 >
1075 {activeThread.messages.length === 0 && !isWorking ? (1017 <MessagesTimeline
1076 <div className="flex h-full items-center justify-center">1018 activeThread={activeThread}
1077 <p className="text-sm text-muted-foreground/30">1019 isWorking={isWorking}
1078 Send a message to start the conversation.1020 timelineEntries={timelineEntries}
1079 </p>1021 completionDividerBeforeEntryId={completionDividerBeforeEntryId}
1080 </div>1022 completionSummary={completionSummary}
1081 ) : (1023 assistantCompletionByItemId={assistantCompletionByItemId}
1082 <div className="mx-auto max-w-3xl space-y-4">1024 nowIso={nowIso}
1083 {timelineEntries.map((timelineEntry, index) => {1025 expandedWorkGroups={expandedWorkGroups}
1084 if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") {1026 onToggleWorkGroup={onToggleWorkGroup}
1085 return null;1027 onImageExpand={onExpandTimelineImage}
1086 }1028 messagesEndRef={messagesEndRef}
10871029 />
1088 const showCompletionDivider =
1089 timelineEntry.kind === "message" &&
1090 timelineEntry.message.role === "assistant" &&
1091 completionDividerBeforeEntryId === timelineEntry.id;
1092
1093 if (timelineEntry.kind === "work") {
1094 const groupedEntries = [timelineEntry.entry];
1095 let cursor = index + 1;
1096 while (cursor < timelineEntries.length) {
1097 const nextEntry = timelineEntries[cursor];
1098 if (!nextEntry || nextEntry.kind !== "work") break;
1099 groupedEntries.push(nextEntry.entry);
1100 cursor += 1;
1101 }
1102
1103 const groupId = timelineEntry.id;
1104 const isExpanded = expandedWorkGroups[groupId] ?? false;
1105 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
1106 const visibleEntries =
1107 hasOverflow && !isExpanded
1108 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
1109 : groupedEntries;
1110 const hiddenCount = groupedEntries.length - visibleEntries.length;
1111 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
1112 const groupLabel = onlyToolEntries
1113 ? groupedEntries.length === 1
1114 ? "Tool call"
1115 : `Tool calls (${groupedEntries.length})`
1116 : groupedEntries.length === 1
1117 ? "Work event"
1118 : `Work log (${groupedEntries.length})`;
1119
1120 return (
1121 <Fragment key={timelineEntry.id}>
1122 <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">
1123 <div className="mb-1.5 flex items-center justify-between gap-3">
1124 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
1125 {groupLabel}
1126 </p>
1127 {hasOverflow && (
1128 <button
1129 type="button"
1130 className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"
1131 onClick={() =>
1132 setExpandedWorkGroups((existing) => ({
1133 ...existing,
1134 [groupId]: !existing[groupId],
1135 }))
1136 }
1137 >
1138 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
1139 </button>
1140 )}
1141 </div>
1142 <div className="space-y-1">
1143 {visibleEntries.map((workEntry) => (
1144 <div
1145 key={`work-row:${workEntry.id}`}
1146 className="flex items-start gap-2 py-0.5"
1147 >
1148 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
1149 <p
1150 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}
1151 >
1152 {workEntry.detail ? (
1153 <>
1154 {workEntry.label}
1155 <span
1156 className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60"
1157 title={workEntry.detail}
1158 >
1159 {workEntry.detail}
1160 </span>
1161 </>
1162 ) : (
1163 workEntry.label
1164 )}
1165 </p>
1166 </div>
1167 ))}
1168 </div>
1169 </div>
1170 </Fragment>
1171 );
1172 }
1173
1174 if (timelineEntry.message.role === "user") {
1175 const userImages = timelineEntry.message.attachments ?? [];
1176 return (
1177 <Fragment key={timelineEntry.id}>
1178 <div className="flex justify-end">
1179 <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
1180 {userImages.length > 0 && (
1181 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
1182 {userImages.map((image) => (
1183 <div
1184 key={image.id}
1185 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
1186 >
1187 {image.previewUrl ? (
1188 <img
1189 src={image.previewUrl}
1190 alt={image.name}
1191 className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
1192 onClick={() =>
1193 setExpandedImage({ src: image.previewUrl!, name: image.name })
1194 }
1195 />
1196 ) : (
1197 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">
1198 {image.name}
1199 </div>
1200 )}
1201 </div>
1202 ))}
1203 </div>
1204 )}
1205 {timelineEntry.message.text && (
1206 <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground">
1207 {timelineEntry.message.text}
1208 </pre>
1209 )}
1210 <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30">
1211 {formatTimestamp(timelineEntry.message.createdAt)}
1212 </p>
1213 </div>
1214 </div>
1215 </Fragment>
1216 );
1217 }
1218
1219 return (
1220 <Fragment key={timelineEntry.id}>
1221 {showCompletionDivider && (
1222 <div className="my-3 flex items-center gap-3">
1223 <span className="h-px flex-1 bg-border" />
1224 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
1225 {completionSummary ? `Response • ${completionSummary}` : "Response"}
1226 </span>
1227 <span className="h-px flex-1 bg-border" />
1228 </div>
1229 )}
1230 <div className="px-1 py-0.5">
1231 <ChatMarkdown
1232 text={
1233 timelineEntry.message.text ||
1234 (timelineEntry.message.streaming ? "" : "(empty response)")
1235 }
1236 />
1237 <p className="mt-1.5 text-[10px] text-muted-foreground/30">
1238 {formatMessageMeta(
1239 timelineEntry.message.createdAt,
1240 timelineEntry.message.streaming
1241 ? formatElapsed(timelineEntry.message.createdAt, nowIso)
1242 : formatElapsed(
1243 timelineEntry.message.createdAt,
1244 assistantCompletionByItemId.get(timelineEntry.message.id),
1245 ),
1246 )}
1247 </p>
1248 </div>
1249 </Fragment>
1250 );
1251 })}
1252 {isWorking && (
1253 <div className="flex items-center gap-2 py-0.5 pl-1.5">
1254 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
1255 <div className="flex items-center pt-1">
1256 <span className="inline-flex items-center gap-[3px]">
1257 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
1258 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
1259 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
1260 </span>
1261 </div>
1262 </div>
1263 )}
1264 <div ref={messagesEndRef} />
1265 </div>
1266 )}
1267 </div>1030 </div>
12681031
1269 {/* Input bar */}1032 {/* Input bar */}
...@@ -1519,7 +1282,355 @@ export default function ChatView() {...@@ -1519,7 +1282,355 @@ export default function ChatView() {
1519 );1282 );
1520}1283}
15211284
1522function ModelPicker(props: { model: ModelSlug; onModelChange: (model: ModelSlug) => void }) {1285interface ChatHeaderProps {
1286 activeThreadTitle: string;
1287 activeProjectName: string | undefined;
1288 keybindings: ResolvedKeybindingsConfig;
1289 api: NativeApi | undefined;
1290 gitCwd: string | null;
1291 diffOpen: boolean;
1292 onToggleDiff: () => void;
1293}
1294
1295const ChatHeader = memo(function ChatHeader({
1296 activeThreadTitle,
1297 activeProjectName,
1298 keybindings,
1299 api,
1300 gitCwd,
1301 diffOpen,
1302 onToggleDiff,
1303}: ChatHeaderProps) {
1304 return (
1305 <>
1306 <div className="flex items-center gap-3">
1307 <h2 className="text-sm font-medium text-foreground">{activeThreadTitle}</h2>
1308 {activeProjectName && <Badge variant="outline">{activeProjectName}</Badge>}
1309 </div>
1310 <div className="flex items-center gap-3">
1311 {activeProjectName && <OpenInPicker keybindings={keybindings} />}
1312 {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />}
1313 <Button
1314 size="xs"
1315 variant="ghost"
1316 className={cn(
1317 "text-muted-foreground/70 hover:text-foreground/80",
1318 diffOpen && "bg-accent text-accent-foreground",
1319 )}
1320 onClick={onToggleDiff}
1321 >
1322 Diff
1323 </Button>
1324 </div>
1325 </>
1326 );
1327});
1328
1329const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: string | null }) {
1330 if (!error) return null;
1331 return (
1332 <div className="pt-3 mx-auto max-w-3xl">
1333 <Alert variant="error">
1334 <CircleAlertIcon />
1335 <AlertDescription className="line-clamp-3" title={error}>
1336 {error}
1337 </AlertDescription>
1338 </Alert>
1339 </div>
1340 );
1341});
1342
1343interface PendingApprovalsPanelProps {
1344 pendingApprovals: PendingApproval[];
1345 respondingRequestIds: string[];
1346 onRespondToApproval: (requestId: string, decision: ProviderApprovalDecision) => Promise<void>;
1347}
1348
1349const PendingApprovalsPanel = memo(function PendingApprovalsPanel({
1350 pendingApprovals,
1351 respondingRequestIds,
1352 onRespondToApproval,
1353}: PendingApprovalsPanelProps) {
1354 if (pendingApprovals.length === 0) return null;
1355 return (
1356 <div className="pt-3 mx-auto max-w-3xl space-y-2">
1357 {pendingApprovals.map((approval) => {
1358 const isResponding = respondingRequestIds.includes(approval.requestId);
1359
1360 return (
1361 <Alert variant="warning" key={approval.requestId}>
1362 <InfoIcon />
1363 <AlertTitle className="text-xs">
1364 {approval.requestKind === "command"
1365 ? "Command approval requested"
1366 : "File-change approval requested"}
1367 </AlertTitle>
1368 <AlertDescription
1369 className="truncate block font-mono text-[11px]"
1370 title={approval.detail}
1371 >
1372 {approval.detail}
1373 </AlertDescription>
1374 <AlertAction className="col-start-2! -col-end-1! mt-1.5 sm:row-start-auto sm:row-end-auto">
1375 <Button
1376 size="xs"
1377 variant="default"
1378 disabled={isResponding}
1379 onClick={() => void onRespondToApproval(approval.requestId, "accept")}
1380 >
1381 Approve once
1382 </Button>
1383 <Button
1384 size="xs"
1385 variant="outline"
1386 disabled={isResponding}
1387 onClick={() => void onRespondToApproval(approval.requestId, "acceptForSession")}
1388 >
1389 Always allow this session
1390 </Button>
1391 <Button
1392 size="xs"
1393 variant="destructive-outline"
1394 disabled={isResponding}
1395 onClick={() => void onRespondToApproval(approval.requestId, "decline")}
1396 >
1397 Decline
1398 </Button>
1399 <Button
1400 size="xs"
1401 variant="ghost"
1402 disabled={isResponding}
1403 onClick={() => void onRespondToApproval(approval.requestId, "cancel")}
1404 >
1405 Cancel turn
1406 </Button>
1407 </AlertAction>
1408 </Alert>
1409 );
1410 })}
1411 </div>
1412 );
1413});
1414
1415interface MessagesTimelineProps {
1416 activeThread: Thread;
1417 isWorking: boolean;
1418 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
1419 completionDividerBeforeEntryId: string | null;
1420 completionSummary: string | null;
1421 assistantCompletionByItemId: Map<string, string>;
1422 nowIso: string;
1423 expandedWorkGroups: Record<string, boolean>;
1424 onToggleWorkGroup: (groupId: string) => void;
1425 onImageExpand: (image: ExpandedImagePreview) => void;
1426 messagesEndRef: RefObject<HTMLDivElement | null>;
1427}
1428
1429const MessagesTimeline = memo(function MessagesTimeline({
1430 activeThread,
1431 isWorking,
1432 timelineEntries,
1433 completionDividerBeforeEntryId,
1434 completionSummary,
1435 assistantCompletionByItemId,
1436 nowIso,
1437 expandedWorkGroups,
1438 onToggleWorkGroup,
1439 onImageExpand,
1440 messagesEndRef,
1441}: MessagesTimelineProps) {
1442 if (activeThread.messages.length === 0 && !isWorking) {
1443 return (
1444 <div className="flex h-full items-center justify-center">
1445 <p className="text-sm text-muted-foreground/30">Send a message to start the conversation.</p>
1446 </div>
1447 );
1448 }
1449
1450 return (
1451 <div className="mx-auto max-w-3xl space-y-4">
1452 {timelineEntries.map((timelineEntry, index) => {
1453 if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") {
1454 return null;
1455 }
1456
1457 const showCompletionDivider =
1458 timelineEntry.kind === "message" &&
1459 timelineEntry.message.role === "assistant" &&
1460 completionDividerBeforeEntryId === timelineEntry.id;
1461
1462 if (timelineEntry.kind === "work") {
1463 const groupedEntries = [timelineEntry.entry];
1464 let cursor = index + 1;
1465 while (cursor < timelineEntries.length) {
1466 const nextEntry = timelineEntries[cursor];
1467 if (!nextEntry || nextEntry.kind !== "work") break;
1468 groupedEntries.push(nextEntry.entry);
1469 cursor += 1;
1470 }
1471
1472 const groupId = timelineEntry.id;
1473 const isExpanded = expandedWorkGroups[groupId] ?? false;
1474 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
1475 const visibleEntries =
1476 hasOverflow && !isExpanded
1477 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
1478 : groupedEntries;
1479 const hiddenCount = groupedEntries.length - visibleEntries.length;
1480 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
1481 const groupLabel = onlyToolEntries
1482 ? groupedEntries.length === 1
1483 ? "Tool call"
1484 : `Tool calls (${groupedEntries.length})`
1485 : groupedEntries.length === 1
1486 ? "Work event"
1487 : `Work log (${groupedEntries.length})`;
1488
1489 return (
1490 <Fragment key={timelineEntry.id}>
1491 <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">
1492 <div className="mb-1.5 flex items-center justify-between gap-3">
1493 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
1494 {groupLabel}
1495 </p>
1496 {hasOverflow && (
1497 <button
1498 type="button"
1499 className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"
1500 onClick={() => onToggleWorkGroup(groupId)}
1501 >
1502 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
1503 </button>
1504 )}
1505 </div>
1506 <div className="space-y-1">
1507 {visibleEntries.map((workEntry) => (
1508 <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5">
1509 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
1510 <p
1511 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}
1512 >
1513 {workEntry.detail ? (
1514 <>
1515 {workEntry.label}
1516 <span
1517 className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60"
1518 title={workEntry.detail}
1519 >
1520 {workEntry.detail}
1521 </span>
1522 </>
1523 ) : (
1524 workEntry.label
1525 )}
1526 </p>
1527 </div>
1528 ))}
1529 </div>
1530 </div>
1531 </Fragment>
1532 );
1533 }
1534
1535 if (timelineEntry.message.role === "user") {
1536 const userImages = timelineEntry.message.attachments ?? [];
1537 return (
1538 <Fragment key={timelineEntry.id}>
1539 <div className="flex justify-end">
1540 <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
1541 {userImages.length > 0 && (
1542 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
1543 {userImages.map((image) => (
1544 <div
1545 key={image.id}
1546 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
1547 >
1548 {image.previewUrl ? (
1549 <img
1550 src={image.previewUrl}
1551 alt={image.name}
1552 className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
1553 onClick={() =>
1554 onImageExpand({ src: image.previewUrl!, name: image.name })
1555 }
1556 />
1557 ) : (
1558 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">
1559 {image.name}
1560 </div>
1561 )}
1562 </div>
1563 ))}
1564 </div>
1565 )}
1566 {timelineEntry.message.text && (
1567 <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground">
1568 {timelineEntry.message.text}
1569 </pre>
1570 )}
1571 <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30">
1572 {formatTimestamp(timelineEntry.message.createdAt)}
1573 </p>
1574 </div>
1575 </div>
1576 </Fragment>
1577 );
1578 }
1579
1580 return (
1581 <Fragment key={timelineEntry.id}>
1582 {showCompletionDivider && (
1583 <div className="my-3 flex items-center gap-3">
1584 <span className="h-px flex-1 bg-border" />
1585 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
1586 {completionSummary ? `Response • ${completionSummary}` : "Response"}
1587 </span>
1588 <span className="h-px flex-1 bg-border" />
1589 </div>
1590 )}
1591 <div className="px-1 py-0.5">
1592 <ChatMarkdown
1593 text={
1594 timelineEntry.message.text ||
1595 (timelineEntry.message.streaming ? "" : "(empty response)")
1596 }
1597 />
1598 <p className="mt-1.5 text-[10px] text-muted-foreground/30">
1599 {formatMessageMeta(
1600 timelineEntry.message.createdAt,
1601 timelineEntry.message.streaming
1602 ? formatElapsed(timelineEntry.message.createdAt, nowIso)
1603 : formatElapsed(
1604 timelineEntry.message.createdAt,
1605 assistantCompletionByItemId.get(timelineEntry.message.id),
1606 ),
1607 )}
1608 </p>
1609 </div>
1610 </Fragment>
1611 );
1612 })}
1613 {isWorking && (
1614 <div className="flex items-center gap-2 py-0.5 pl-1.5">
1615 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
1616 <div className="flex items-center pt-1">
1617 <span className="inline-flex items-center gap-[3px]">
1618 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
1619 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
1620 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
1621 </span>
1622 </div>
1623 </div>
1624 )}
1625 <div ref={messagesEndRef} />
1626 </div>
1627 );
1628});
1629
1630const ModelPicker = memo(function ModelPicker(props: {
1631 model: ModelSlug;
1632 onModelChange: (model: ModelSlug) => void;
1633}) {
1523 return (1634 return (
1524 <Select1635 <Select
1525 items={MODEL_OPTIONS.map((option) => ({ label: option.name, value: option.slug }))}1636 items={MODEL_OPTIONS.map((option) => ({ label: option.name, value: option.slug }))}
...@@ -1538,9 +1649,9 @@ function ModelPicker(props: { model: ModelSlug; onModelChange: (model: ModelSlug...@@ -1538,9 +1649,9 @@ function ModelPicker(props: { model: ModelSlug; onModelChange: (model: ModelSlug
1538 </SelectPopup>1649 </SelectPopup>
1539 </Select>1650 </Select>
1540 );1651 );
1541}1652});
15421653
1543function ReasoningEffortPicker(props: {1654const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: {
1544 effort: ReasoningEffort;1655 effort: ReasoningEffort;
1545 onEffortChange: (effort: ReasoningEffort) => void;1656 onEffortChange: (effort: ReasoningEffort) => void;
1546}) {1657}) {
...@@ -1562,9 +1673,13 @@ function ReasoningEffortPicker(props: {...@@ -1562,9 +1673,13 @@ function ReasoningEffortPicker(props: {
1562 </SelectPopup>1673 </SelectPopup>
1563 </Select>1674 </Select>
1564 );1675 );
1565}1676});
15661677
1567function OpenInPicker({ keybindings }: { keybindings: ResolvedKeybindingsConfig }) {1678const OpenInPicker = memo(function OpenInPicker({
1679 keybindings,
1680}: {
1681 keybindings: ResolvedKeybindingsConfig;
1682}) {
1568 const [lastEditor, setLastEditor] = useState<EditorId>(() => {1683 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
1569 const stored = localStorage.getItem(LAST_EDITOR_KEY);1684 const stored = localStorage.getItem(LAST_EDITOR_KEY);
1570 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;1685 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;
...@@ -1648,4 +1763,4 @@ function OpenInPicker({ keybindings }: { keybindings: ResolvedKeybindingsConfig...@@ -1648,4 +1763,4 @@ function OpenInPicker({ keybindings }: { keybindings: ResolvedKeybindingsConfig
1648 </Menu>1763 </Menu>
1649 </Group>1764 </Group>
1650 );1765 );
1651}1766});