| ... | ... | @@ -17,6 +17,7 @@ import { |
| 17 | 17 | type ProviderApprovalDecision, |
| 18 | 18 | type ThreadId, |
| 19 | 19 | type TurnId, |
| 20 | normalizeModelSlug, |
| 20 | 21 | resolveModelSlug, |
| 21 | 22 | } from "@t3tools/contracts"; |
| 22 | 23 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| ... | ... | @@ -197,6 +198,7 @@ function buildLocalDraftThread( |
| 197 | 198 | threadId: ThreadId, |
| 198 | 199 | draftThread: DraftThreadState, |
| 199 | 200 | fallbackModel: string, |
| 201 | error: string | null, |
| 200 | 202 | ): Thread { |
| 201 | 203 | return { |
| 202 | 204 | id: threadId, |
| ... | ... | @@ -218,7 +220,7 @@ function buildLocalDraftThread( |
| 218 | 220 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, |
| 219 | 221 | session: null, |
| 220 | 222 | messages: [], |
| 221 | | error: null, |
| 223 | error, |
| 222 | 224 | createdAt: draftThread.createdAt, |
| 223 | 225 | latestTurn: null, |
| 224 | 226 | lastVisitedAt: draftThread.createdAt, |
| ... | ... | @@ -229,6 +231,25 @@ function buildLocalDraftThread( |
| 229 | 231 | }; |
| 230 | 232 | } |
| 231 | 233 | |
| 234 | function revokeBlobPreviewUrl(previewUrl: string | undefined): void { |
| 235 | if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { |
| 236 | return; |
| 237 | } |
| 238 | URL.revokeObjectURL(previewUrl); |
| 239 | } |
| 240 | |
| 241 | function revokeUserMessagePreviewUrls(message: ChatMessage): void { |
| 242 | if (message.role !== "user" || !message.attachments) { |
| 243 | return; |
| 244 | } |
| 245 | for (const attachment of message.attachments) { |
| 246 | if (attachment.type !== "image") { |
| 247 | continue; |
| 248 | } |
| 249 | revokeBlobPreviewUrl(attachment.previewUrl); |
| 250 | } |
| 251 | } |
| 252 | |
| 232 | 253 | type ComposerCommandItem = |
| 233 | 254 | | { |
| 234 | 255 | id: string; |
| ... | ... | @@ -449,6 +470,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 449 | 470 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| 450 | 471 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| 451 | 472 | const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); |
| 473 | const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< |
| 474 | Record<ThreadId, string | null> |
| 475 | >({}); |
| 452 | 476 | const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); |
| 453 | 477 | const [isConnecting, _setIsConnecting] = useState(false); |
| 454 | 478 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| ... | ... | @@ -504,12 +528,18 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 504 | 528 | |
| 505 | 529 | const serverThread = state.threads.find((t) => t.id === threadId); |
| 506 | 530 | const fallbackDraftProject = state.projects.find((project) => project.id === draftThread?.projectId); |
| 531 | const localDraftError = localDraftErrorsByThreadId[threadId] ?? null; |
| 507 | 532 | const localDraftThread = useMemo( |
| 508 | 533 | () => |
| 509 | 534 | draftThread |
| 510 | | ? buildLocalDraftThread(threadId, draftThread, fallbackDraftProject?.model ?? DEFAULT_MODEL) |
| 535 | ? buildLocalDraftThread( |
| 536 | threadId, |
| 537 | draftThread, |
| 538 | fallbackDraftProject?.model ?? DEFAULT_MODEL, |
| 539 | localDraftError, |
| 540 | ) |
| 511 | 541 | : undefined, |
| 512 | | [draftThread, fallbackDraftProject?.model, threadId], |
| 542 | [draftThread, fallbackDraftProject?.model, localDraftError, threadId], |
| 513 | 543 | ); |
| 514 | 544 | const activeThread = serverThread ?? localDraftThread; |
| 515 | 545 | const isServerThread = serverThread !== undefined; |
| ... | ... | @@ -524,6 +554,19 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 524 | 554 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| 525 | 555 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| 526 | 556 | |
| 557 | useEffect(() => { |
| 558 | if (!serverThread) { |
| 559 | return; |
| 560 | } |
| 561 | setLocalDraftErrorsByThreadId((existing) => { |
| 562 | if (!(threadId in existing)) { |
| 563 | return existing; |
| 564 | } |
| 565 | const { [threadId]: _removed, ...rest } = existing; |
| 566 | return rest as Record<ThreadId, string | null>; |
| 567 | }); |
| 568 | }, [serverThread, threadId]); |
| 569 | |
| 527 | 570 | useEffect(() => { |
| 528 | 571 | if (!activeThread?.id) return; |
| 529 | 572 | if (!latestTurnSettled) return; |
| ... | ... | @@ -545,9 +588,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 545 | 588 | dispatch, |
| 546 | 589 | ]); |
| 547 | 590 | |
| 548 | | const selectedModel = resolveModelSlug( |
| 549 | | composerDraft.model ?? activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, |
| 550 | | ); |
| 591 | const baseThreadModel = resolveModelSlug(activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL); |
| 592 | const selectedModel = resolveModelSlug(composerDraft.model ?? baseThreadModel); |
| 551 | 593 | const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING; |
| 552 | 594 | const modelOptions = useMemo( |
| 553 | 595 | () => getAppModelOptions(settings.customCodexModels, selectedModel), |
| ... | ... | @@ -1184,7 +1226,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1184 | 1226 | |
| 1185 | 1227 | useEffect(() => { |
| 1186 | 1228 | if (!activeThread?.id) { |
| 1187 | | setOptimisticUserMessages([]); |
| 1229 | setOptimisticUserMessages((existing) => { |
| 1230 | for (const message of existing) { |
| 1231 | revokeUserMessagePreviewUrls(message); |
| 1232 | } |
| 1233 | return []; |
| 1234 | }); |
| 1188 | 1235 | return; |
| 1189 | 1236 | } |
| 1190 | 1237 | if (activeThread.messages.length === 0) { |
| ... | ... | @@ -1192,6 +1239,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1192 | 1239 | } |
| 1193 | 1240 | const serverIds = new Set(activeThread.messages.map((message) => message.id)); |
| 1194 | 1241 | setOptimisticUserMessages((existing) => { |
| 1242 | const removed = existing.filter((message) => serverIds.has(message.id)); |
| 1243 | for (const message of removed) { |
| 1244 | revokeUserMessagePreviewUrls(message); |
| 1245 | } |
| 1195 | 1246 | const next = existing.filter((message) => !serverIds.has(message.id)); |
| 1196 | 1247 | return next.length === existing.length ? existing : next; |
| 1197 | 1248 | }); |
| ... | ... | @@ -1202,7 +1253,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1202 | 1253 | }, [prompt]); |
| 1203 | 1254 | |
| 1204 | 1255 | useEffect(() => { |
| 1205 | | setOptimisticUserMessages([]); |
| 1256 | setOptimisticUserMessages((existing) => { |
| 1257 | for (const message of existing) { |
| 1258 | revokeUserMessagePreviewUrls(message); |
| 1259 | } |
| 1260 | return []; |
| 1261 | }); |
| 1206 | 1262 | setSendPhase("idle"); |
| 1207 | 1263 | setComposerHighlightedItemId(null); |
| 1208 | 1264 | dragDepthRef.current = 0; |
| ... | ... | @@ -1224,7 +1280,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1224 | 1280 | currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), |
| 1225 | 1281 | ); |
| 1226 | 1282 | const stagedAttachmentById = new Map<string, PersistedComposerImageAttachment>(); |
| 1227 | | const serializationFailedImageIds: string[] = []; |
| 1228 | 1283 | await Promise.all( |
| 1229 | 1284 | composerImages.map(async (image) => { |
| 1230 | 1285 | try { |
| ... | ... | @@ -1240,8 +1295,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1240 | 1295 | const existingPersisted = existingPersistedById.get(image.id); |
| 1241 | 1296 | if (existingPersisted) { |
| 1242 | 1297 | stagedAttachmentById.set(image.id, existingPersisted); |
| 1243 | | } else { |
| 1244 | | serializationFailedImageIds.push(image.id); |
| 1245 | 1298 | } |
| 1246 | 1299 | } |
| 1247 | 1300 | }), |
| ... | ... | @@ -1468,13 +1521,25 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1468 | 1521 | const setThreadError = useCallback( |
| 1469 | 1522 | (threadId: ThreadId | null, error: string | null) => { |
| 1470 | 1523 | if (!threadId) return; |
| 1471 | | dispatch({ |
| 1472 | | type: "SET_ERROR", |
| 1473 | | threadId, |
| 1474 | | error, |
| 1524 | if (state.threads.some((thread) => thread.id === threadId)) { |
| 1525 | dispatch({ |
| 1526 | type: "SET_ERROR", |
| 1527 | threadId, |
| 1528 | error, |
| 1529 | }); |
| 1530 | return; |
| 1531 | } |
| 1532 | setLocalDraftErrorsByThreadId((existing) => { |
| 1533 | if ((existing[threadId] ?? null) === error) { |
| 1534 | return existing; |
| 1535 | } |
| 1536 | return { |
| 1537 | ...existing, |
| 1538 | [threadId]: error, |
| 1539 | }; |
| 1475 | 1540 | }); |
| 1476 | 1541 | }, |
| 1477 | | [dispatch], |
| 1542 | [dispatch, state.threads], |
| 1478 | 1543 | ); |
| 1479 | 1544 | |
| 1480 | 1545 | const addComposerImages = (files: File[]) => { |
| ... | ... | @@ -1805,9 +1870,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1805 | 1870 | promptRef.current.length === 0 && |
| 1806 | 1871 | composerImagesRef.current.length === 0 |
| 1807 | 1872 | ) { |
| 1808 | | setOptimisticUserMessages((existing) => |
| 1809 | | existing.filter((message) => message.id !== messageIdForSend), |
| 1810 | | ); |
| 1873 | setOptimisticUserMessages((existing) => { |
| 1874 | const removed = existing.filter((message) => message.id === messageIdForSend); |
| 1875 | for (const message of removed) { |
| 1876 | revokeUserMessagePreviewUrls(message); |
| 1877 | } |
| 1878 | const next = existing.filter((message) => message.id !== messageIdForSend); |
| 1879 | return next.length === existing.length ? existing : next; |
| 1880 | }); |
| 1811 | 1881 | promptRef.current = trimmed; |
| 1812 | 1882 | setPrompt(trimmed); |
| 1813 | 1883 | addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); |
| ... | ... | @@ -1866,20 +1936,28 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1866 | 1936 | |
| 1867 | 1937 | const onModelSelect = useCallback( |
| 1868 | 1938 | (model: string) => { |
| 1869 | | const normalizedModel = resolveModelSlug(model); |
| 1870 | | setComposerDraftModel(threadId, normalizedModel); |
| 1939 | const normalizedModel = normalizeModelSlug(model); |
| 1940 | const resolvedModel = normalizedModel ?? baseThreadModel; |
| 1941 | setComposerDraftModel(threadId, resolvedModel === baseThreadModel ? null : resolvedModel); |
| 1871 | 1942 | const api = readNativeApi(); |
| 1872 | 1943 | if (api && isServerThread && activeThread) { |
| 1873 | 1944 | void api.orchestration.dispatchCommand({ |
| 1874 | 1945 | type: "thread.meta.update", |
| 1875 | 1946 | commandId: newCommandId(), |
| 1876 | 1947 | threadId: activeThread.id, |
| 1877 | | model: normalizedModel, |
| 1948 | model: resolvedModel, |
| 1878 | 1949 | }); |
| 1879 | 1950 | } |
| 1880 | 1951 | scheduleComposerFocus(); |
| 1881 | 1952 | }, |
| 1882 | | [activeThread, isServerThread, scheduleComposerFocus, setComposerDraftModel, threadId], |
| 1953 | [ |
| 1954 | activeThread, |
| 1955 | baseThreadModel, |
| 1956 | isServerThread, |
| 1957 | scheduleComposerFocus, |
| 1958 | setComposerDraftModel, |
| 1959 | threadId, |
| 1960 | ], |
| 1883 | 1961 | ); |
| 1884 | 1962 | const onEffortSelect = useCallback( |
| 1885 | 1963 | (effort: ReasoningEffort) => { |