| ... | @@ -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"; |
| 48 | import { isScrollContainerNearBottom } from "../chat-scroll"; | 52 | import { isScrollContainerNearBottom } from "../chat-scroll"; |
| 49 | import { useStore } from "../store"; | 53 | import { useStore } from "../store"; |
| 50 | import type { ChatImageAttachment } from "../types"; | 54 | import type { ChatImageAttachment, Thread } from "../types"; |
| 51 | import BranchToolbar from "./BranchToolbar"; | 55 | import BranchToolbar from "./BranchToolbar"; |
| 52 | import GitActionsControl from "./GitActionsControl"; | 56 | import GitActionsControl from "./GitActionsControl"; |
| 53 | import { | 57 | import { |
| ... | @@ -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 | }; |
| 891 | | 895 | |
| 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; |
| 894 | | 899 | |
| 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 | ); |
| 914 | | 921 | |
| 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]); |
| 948 | | 967 | |
| 949 | // Empty state: no active thread | 968 | // 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 | <header | 989 | <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> |
| 997 | | 1002 | |
| 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 | )} | | |
| 1068 | | 1010 | |
| 1069 | {/* Messages */} | 1011 | {/* Messages */} |
| 1070 | <div | 1012 | <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} |
| 1087 | | 1029 | /> |
| 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> |
| 1268 | | 1031 | |
| 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 | } |
| 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 | return ( | 1634 | return ( |
| 1524 | <Select | 1635 | <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 | }); |
| 1542 | | 1653 | |
| 1543 | function ReasoningEffortPicker(props: { | 1654 | const 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 | }); |
| 1566 | | 1677 | |
| 1567 | function OpenInPicker({ keybindings }: { keybindings: ResolvedKeybindingsConfig }) { | 1678 | const 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 | }); |