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 @@
1import { memo } from "react";
12import type { Components } from "react-markdown";
23import ReactMarkdown from "react-markdown";
34import rehypeHighlight from "rehype-highlight";
......@@ -13,7 +14,7 @@ const markdownComponents: Components = {
1314 },
1415};
1516
16export default function ChatMarkdown({ text }: ChatMarkdownProps) {
17function ChatMarkdown({ text }: ChatMarkdownProps) {
1718 return (
1819 <div className="chat-markdown text-sm leading-relaxed text-foreground/80">
1920 <ReactMarkdown
......@@ -26,3 +27,5 @@ export default function ChatMarkdown({ text }: ChatMarkdownProps) {
2627 </div>
2728 );
2829}
30
31export default memo(ChatMarkdown);
apps/web/src/components/ChatView.tsx+428-313
......@@ -2,6 +2,7 @@ import {
22 EDITORS,
33 type EditorId,
44 ModelSlug,
5 type NativeApi,
56 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
67 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
78 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
......@@ -15,6 +16,8 @@ import {
1516 type FormEvent,
1617 Fragment,
1718 type KeyboardEvent,
19 memo,
20 type RefObject,
1821 useCallback,
1922 useEffect,
2023 useLayoutEffect,
......@@ -40,6 +43,7 @@ import {
4043 derivePendingApprovals,
4144 derivePhase,
4245 deriveTimelineEntries,
46 type PendingApproval,
4347 deriveWorkLogEntries,
4448 formatDuration,
4549 formatElapsed,
......@@ -47,7 +51,7 @@ import {
4751} from "../session-logic";
4852import { isScrollContainerNearBottom } from "../chat-scroll";
4953import { useStore } from "../store";
50import type { ChatImageAttachment } from "../types";
54import type { ChatImageAttachment, Thread } from "../types";
5155import BranchToolbar from "./BranchToolbar";
5256import GitActionsControl from "./GitActionsControl";
5357import {
......@@ -497,7 +501,7 @@ export default function ChatView() {
497501 if (phase !== "running") return;
498502 const timer = window.setInterval(() => {
499503 setNowTick(Date.now());
500 }, 250);
504 }, 1000);
501505 return () => {
502506 window.clearInterval(timer);
503507 };
......@@ -889,28 +893,31 @@ export default function ChatView() {
889893 });
890894 };
891895
892 const onRespondToApproval = async (requestId: string, decision: ProviderApprovalDecision) => {
893 if (!api || !activeThread?.session) return;
896 const onRespondToApproval = useCallback(
897 async (requestId: string, decision: ProviderApprovalDecision) => {
898 if (!api || !activeThread?.session) return;
894899
895 setRespondingRequestIds((existing) =>
896 existing.includes(requestId) ? existing : [...existing, requestId],
897 );
898 try {
899 await api.providers.respondToRequest({
900 sessionId: activeThread.session.sessionId,
901 requestId,
902 decision,
903 });
904 } catch (err) {
905 dispatch({
906 type: "SET_ERROR",
907 threadId: activeThread.id,
908 error: err instanceof Error ? err.message : "Failed to submit approval decision.",
909 });
910 } finally {
911 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
912 }
913 };
900 setRespondingRequestIds((existing) =>
901 existing.includes(requestId) ? existing : [...existing, requestId],
902 );
903 try {
904 await api.providers.respondToRequest({
905 sessionId: activeThread.session.sessionId,
906 requestId,
907 decision,
908 });
909 } catch (err) {
910 dispatch({
911 type: "SET_ERROR",
912 threadId: activeThread.id,
913 error: err instanceof Error ? err.message : "Failed to submit approval decision.",
914 });
915 } finally {
916 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
917 }
918 },
919 [activeThread?.id, activeThread?.session, api, dispatch],
920 );
914921
915922 const onModelSelect = useCallback(
916923 (model: ModelSlug) => {
......@@ -945,6 +952,18 @@ export default function ChatView() {
945952 void onSend(e as unknown as FormEvent);
946953 }
947954 };
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
949968 // Empty state: no active thread
950969 if (!activeThread) {
......@@ -970,101 +989,24 @@ export default function ChatView() {
970989 <header
971990 className={`flex items-center justify-between border-b border-border px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`}
972991 >
973 <div className="flex items-center gap-3">
974 <h2 className="text-sm font-medium text-foreground">{activeThread.title}</h2>
975 {activeProject && <Badge variant="outline">{activeProject.name}</Badge>}
976 </div>
977 <div className="flex items-center gap-3">
978 {/* Open in editor */}
979 {activeProject && <OpenInPicker keybindings={keybindings} />}
980 {/* Git actions */}
981 {activeProject && <GitActionsControl api={api} gitCwd={gitCwd} />}
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>
992 <ChatHeader
993 activeThreadTitle={activeThread.title}
994 activeProjectName={activeProject?.name}
995 keybindings={keybindings}
996 api={api}
997 gitCwd={gitCwd}
998 diffOpen={state.diffOpen}
999 onToggleDiff={onToggleDiff}
1000 />
9961001 </header>
9971002
9981003 {/* Error banner */}
999 {activeThread.error && (
1000 <div className="pt-3 mx-auto max-w-3xl">
1001 <Alert variant="error">
1002 <CircleAlertIcon />
1003 <AlertDescription className="line-clamp-3" title={activeThread.error}>
1004 {activeThread.error}
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 )}
1004 <ThreadErrorBanner error={activeThread.error} />
1005 <PendingApprovalsPanel
1006 pendingApprovals={pendingApprovals}
1007 respondingRequestIds={respondingRequestIds}
1008 onRespondToApproval={onRespondToApproval}
1009 />
10681010
10691011 {/* Messages */}
10701012 <div
......@@ -1072,198 +1014,19 @@ export default function ChatView() {
10721014 className="min-h-0 flex-1 overflow-y-auto px-5 py-4"
10731015 onScroll={onMessagesScroll}
10741016 >
1075 {activeThread.messages.length === 0 && !isWorking ? (
1076 <div className="flex h-full items-center justify-center">
1077 <p className="text-sm text-muted-foreground/30">
1078 Send a message to start the conversation.
1079 </p>
1080 </div>
1081 ) : (
1082 <div className="mx-auto max-w-3xl space-y-4">
1083 {timelineEntries.map((timelineEntry, index) => {
1084 if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") {
1085 return null;
1086 }
1087
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 )}
1017 <MessagesTimeline
1018 activeThread={activeThread}
1019 isWorking={isWorking}
1020 timelineEntries={timelineEntries}
1021 completionDividerBeforeEntryId={completionDividerBeforeEntryId}
1022 completionSummary={completionSummary}
1023 assistantCompletionByItemId={assistantCompletionByItemId}
1024 nowIso={nowIso}
1025 expandedWorkGroups={expandedWorkGroups}
1026 onToggleWorkGroup={onToggleWorkGroup}
1027 onImageExpand={onExpandTimelineImage}
1028 messagesEndRef={messagesEndRef}
1029 />
12671030 </div>
12681031
12691032 {/* Input bar */}
......@@ -1519,7 +1282,355 @@ export default function ChatView() {
15191282 );
15201283}
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}) {
15231634 return (
15241635 <Select
15251636 items={MODEL_OPTIONS.map((option) => ({ label: option.name, value: option.slug }))}
......@@ -1538,9 +1649,9 @@ function ModelPicker(props: { model: ModelSlug; onModelChange: (model: ModelSlug
15381649 </SelectPopup>
15391650 </Select>
15401651 );
1541}
1652});
15421653
1543function ReasoningEffortPicker(props: {
1654const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: {
15441655 effort: ReasoningEffort;
15451656 onEffortChange: (effort: ReasoningEffort) => void;
15461657}) {
......@@ -1562,9 +1673,13 @@ function ReasoningEffortPicker(props: {
15621673 </SelectPopup>
15631674 </Select>
15641675 );
1565}
1676});
15661677
1567function OpenInPicker({ keybindings }: { keybindings: ResolvedKeybindingsConfig }) {
1678const OpenInPicker = memo(function OpenInPicker({
1679 keybindings,
1680}: {
1681 keybindings: ResolvedKeybindingsConfig;
1682}) {
15681683 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
15691684 const stored = localStorage.getItem(LAST_EDITOR_KEY);
15701685 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;
......@@ -1648,4 +1763,4 @@ function OpenInPicker({ keybindings }: { keybindings: ResolvedKeybindingsConfig
16481763 </Menu>
16491764 </Group>
16501765 );
1651}
1766});