| ... | ... | @@ -2,6 +2,7 @@ import { |
| 2 | 2 | EDITORS, |
| 3 | 3 | type EditorId, |
| 4 | 4 | ModelSlug, |
| 5 | type NativeApi, |
| 5 | 6 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 6 | 7 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 7 | 8 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| ... | ... | @@ -15,6 +16,8 @@ import { |
| 15 | 16 | type FormEvent, |
| 16 | 17 | Fragment, |
| 17 | 18 | type KeyboardEvent, |
| 19 | memo, |
| 20 | type RefObject, |
| 18 | 21 | useCallback, |
| 19 | 22 | useEffect, |
| 20 | 23 | useLayoutEffect, |
| ... | ... | @@ -40,6 +43,7 @@ import { |
| 40 | 43 | derivePendingApprovals, |
| 41 | 44 | derivePhase, |
| 42 | 45 | deriveTimelineEntries, |
| 46 | type PendingApproval, |
| 43 | 47 | deriveWorkLogEntries, |
| 44 | 48 | formatDuration, |
| 45 | 49 | formatElapsed, |
| ... | ... | @@ -47,7 +51,7 @@ import { |
| 47 | 51 | } from "../session-logic"; |
| 48 | 52 | import { isScrollContainerNearBottom } from "../chat-scroll"; |
| 49 | 53 | import { useStore } from "../store"; |
| 50 | | import type { ChatImageAttachment } from "../types"; |
| 54 | import type { ChatImageAttachment, Thread } from "../types"; |
| 51 | 55 | import BranchToolbar from "./BranchToolbar"; |
| 52 | 56 | import GitActionsControl from "./GitActionsControl"; |
| 53 | 57 | import { |
| ... | ... | @@ -497,7 +501,7 @@ export default function ChatView() { |
| 497 | 501 | if (phase !== "running") return; |
| 498 | 502 | const timer = window.setInterval(() => { |
| 499 | 503 | setNowTick(Date.now()); |
| 500 | | }, 250); |
| 504 | }, 1000); |
| 501 | 505 | return () => { |
| 502 | 506 | window.clearInterval(timer); |
| 503 | 507 | }; |
| ... | ... | @@ -889,28 +893,31 @@ export default function ChatView() { |
| 889 | 893 | }); |
| 890 | 894 | }; |
| 891 | 895 | |
| 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; |
| 894 | 899 | |
| 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 | ); |
| 914 | 921 | |
| 915 | 922 | const onModelSelect = useCallback( |
| 916 | 923 | (model: ModelSlug) => { |
| ... | ... | @@ -945,6 +952,18 @@ export default function ChatView() { |
| 945 | 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]); |
| 948 | 967 | |
| 949 | 968 | // Empty state: no active thread |
| 950 | 969 | if (!activeThread) { |
| ... | ... | @@ -970,101 +989,24 @@ export default function ChatView() { |
| 970 | 989 | <header |
| 971 | 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"> |
| 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 | /> |
| 996 | 1001 | </header> |
| 997 | 1002 | |
| 998 | 1003 | {/* 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 | /> |
| 1068 | 1010 | |
| 1069 | 1011 | {/* Messages */} |
| 1070 | 1012 | <div |
| ... | ... | @@ -1072,198 +1014,19 @@ export default function ChatView() { |
| 1072 | 1014 | className="min-h-0 flex-1 overflow-y-auto px-5 py-4" |
| 1073 | 1015 | onScroll={onMessagesScroll} |
| 1074 | 1016 | > |
| 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 | /> |
| 1267 | 1030 | </div> |
| 1268 | 1031 | |
| 1269 | 1032 | {/* Input bar */} |
| ... | ... | @@ -1519,7 +1282,355 @@ export default function ChatView() { |
| 1519 | 1282 | ); |
| 1520 | 1283 | } |
| 1521 | 1284 | |
| 1522 | | function ModelPicker(props: { model: ModelSlug; onModelChange: (model: ModelSlug) => void }) { |
| 1285 | interface 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 | |
| 1295 | const 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 | |
| 1329 | const 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 | |
| 1343 | interface PendingApprovalsPanelProps { |
| 1344 | pendingApprovals: PendingApproval[]; |
| 1345 | respondingRequestIds: string[]; |
| 1346 | onRespondToApproval: (requestId: string, decision: ProviderApprovalDecision) => Promise<void>; |
| 1347 | } |
| 1348 | |
| 1349 | const 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 | |
| 1415 | interface 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 | |
| 1429 | const 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 | |
| 1630 | const ModelPicker = memo(function ModelPicker(props: { |
| 1631 | model: ModelSlug; |
| 1632 | onModelChange: (model: ModelSlug) => void; |
| 1633 | }) { |
| 1523 | 1634 | return ( |
| 1524 | 1635 | <Select |
| 1525 | 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 | 1649 | </SelectPopup> |
| 1539 | 1650 | </Select> |
| 1540 | 1651 | ); |
| 1541 | | } |
| 1652 | }); |
| 1542 | 1653 | |
| 1543 | | function ReasoningEffortPicker(props: { |
| 1654 | const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: { |
| 1544 | 1655 | effort: ReasoningEffort; |
| 1545 | 1656 | onEffortChange: (effort: ReasoningEffort) => void; |
| 1546 | 1657 | }) { |
| ... | ... | @@ -1562,9 +1673,13 @@ function ReasoningEffortPicker(props: { |
| 1562 | 1673 | </SelectPopup> |
| 1563 | 1674 | </Select> |
| 1564 | 1675 | ); |
| 1565 | | } |
| 1676 | }); |
| 1566 | 1677 | |
| 1567 | | function OpenInPicker({ keybindings }: { keybindings: ResolvedKeybindingsConfig }) { |
| 1678 | const OpenInPicker = memo(function OpenInPicker({ |
| 1679 | keybindings, |
| 1680 | }: { |
| 1681 | keybindings: ResolvedKeybindingsConfig; |
| 1682 | }) { |
| 1568 | 1683 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { |
| 1569 | 1684 | const stored = localStorage.getItem(LAST_EDITOR_KEY); |
| 1570 | 1685 | return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; |
| ... | ... | @@ -1648,4 +1763,4 @@ function OpenInPicker({ keybindings }: { keybindings: ResolvedKeybindingsConfig |
| 1648 | 1763 | </Menu> |
| 1649 | 1764 | </Group> |
| 1650 | 1765 | ); |
| 1651 | | } |
| 1766 | }); |