| ... | @@ -17,6 +17,7 @@ import { | ... | @@ -17,6 +17,7 @@ import { |
| 17 | type ProviderApprovalDecision, | 17 | type ProviderApprovalDecision, |
| 18 | type ThreadId, | 18 | type ThreadId, |
| 19 | type TurnId, | 19 | type TurnId, |
| | 20 | normalizeModelSlug, |
| 20 | resolveModelSlug, | 21 | resolveModelSlug, |
| 21 | } from "@t3tools/contracts"; | 22 | } from "@t3tools/contracts"; |
| 22 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | 23 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| ... | @@ -197,6 +198,7 @@ function buildLocalDraftThread( | ... | @@ -197,6 +198,7 @@ function buildLocalDraftThread( |
| 197 | threadId: ThreadId, | 198 | threadId: ThreadId, |
| 198 | draftThread: DraftThreadState, | 199 | draftThread: DraftThreadState, |
| 199 | fallbackModel: string, | 200 | fallbackModel: string, |
| | 201 | error: string | null, |
| 200 | ): Thread { | 202 | ): Thread { |
| 201 | return { | 203 | return { |
| 202 | id: threadId, | 204 | id: threadId, |
| ... | @@ -218,7 +220,7 @@ function buildLocalDraftThread( | ... | @@ -218,7 +220,7 @@ function buildLocalDraftThread( |
| 218 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | 220 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, |
| 219 | session: null, | 221 | session: null, |
| 220 | messages: [], | 222 | messages: [], |
| 221 | error: null, | 223 | error, |
| 222 | createdAt: draftThread.createdAt, | 224 | createdAt: draftThread.createdAt, |
| 223 | latestTurn: null, | 225 | latestTurn: null, |
| 224 | lastVisitedAt: draftThread.createdAt, | 226 | lastVisitedAt: draftThread.createdAt, |
| ... | @@ -229,6 +231,25 @@ function buildLocalDraftThread( | ... | @@ -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 | type ComposerCommandItem = | 253 | type ComposerCommandItem = |
| 233 | | { | 254 | | { |
| 234 | id: string; | 255 | id: string; |
| ... | @@ -449,6 +470,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -449,6 +470,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 449 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); | 470 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| 450 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); | 471 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| 451 | const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); | 472 | const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); |
| | 473 | const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< |
| | 474 | Record<ThreadId, string | null> |
| | 475 | >({}); |
| 452 | const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); | 476 | const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); |
| 453 | const [isConnecting, _setIsConnecting] = useState(false); | 477 | const [isConnecting, _setIsConnecting] = useState(false); |
| 454 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); | 478 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| ... | @@ -504,12 +528,18 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -504,12 +528,18 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 504 | | 528 | |
| 505 | const serverThread = state.threads.find((t) => t.id === threadId); | 529 | const serverThread = state.threads.find((t) => t.id === threadId); |
| 506 | const fallbackDraftProject = state.projects.find((project) => project.id === draftThread?.projectId); | 530 | const fallbackDraftProject = state.projects.find((project) => project.id === draftThread?.projectId); |
| | 531 | const localDraftError = localDraftErrorsByThreadId[threadId] ?? null; |
| 507 | const localDraftThread = useMemo( | 532 | const localDraftThread = useMemo( |
| 508 | () => | 533 | () => |
| 509 | draftThread | 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 | : undefined, | 541 | : undefined, |
| 512 | [draftThread, fallbackDraftProject?.model, threadId], | 542 | [draftThread, fallbackDraftProject?.model, localDraftError, threadId], |
| 513 | ); | 543 | ); |
| 514 | const activeThread = serverThread ?? localDraftThread; | 544 | const activeThread = serverThread ?? localDraftThread; |
| 515 | const isServerThread = serverThread !== undefined; | 545 | const isServerThread = serverThread !== undefined; |
| ... | @@ -524,6 +554,19 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -524,6 +554,19 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 524 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); | 554 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| 525 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); | 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 | useEffect(() => { | 570 | useEffect(() => { |
| 528 | if (!activeThread?.id) return; | 571 | if (!activeThread?.id) return; |
| 529 | if (!latestTurnSettled) return; | 572 | if (!latestTurnSettled) return; |
| ... | @@ -545,9 +588,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -545,9 +588,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 545 | dispatch, | 588 | dispatch, |
| 546 | ]); | 589 | ]); |
| 547 | | 590 | |
| 548 | const selectedModel = resolveModelSlug( | 591 | const baseThreadModel = resolveModelSlug(activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL); |
| 549 | composerDraft.model ?? activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, | 592 | const selectedModel = resolveModelSlug(composerDraft.model ?? baseThreadModel); |
| 550 | ); | | |
| 551 | const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING; | 593 | const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING; |
| 552 | const modelOptions = useMemo( | 594 | const modelOptions = useMemo( |
| 553 | () => getAppModelOptions(settings.customCodexModels, selectedModel), | 595 | () => getAppModelOptions(settings.customCodexModels, selectedModel), |
| ... | @@ -1184,7 +1226,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1184,7 +1226,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1184 | | 1226 | |
| 1185 | useEffect(() => { | 1227 | useEffect(() => { |
| 1186 | if (!activeThread?.id) { | 1228 | if (!activeThread?.id) { |
| 1187 | setOptimisticUserMessages([]); | 1229 | setOptimisticUserMessages((existing) => { |
| | 1230 | for (const message of existing) { |
| | 1231 | revokeUserMessagePreviewUrls(message); |
| | 1232 | } |
| | 1233 | return []; |
| | 1234 | }); |
| 1188 | return; | 1235 | return; |
| 1189 | } | 1236 | } |
| 1190 | if (activeThread.messages.length === 0) { | 1237 | if (activeThread.messages.length === 0) { |
| ... | @@ -1192,6 +1239,10 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1192,6 +1239,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1192 | } | 1239 | } |
| 1193 | const serverIds = new Set(activeThread.messages.map((message) => message.id)); | 1240 | const serverIds = new Set(activeThread.messages.map((message) => message.id)); |
| 1194 | setOptimisticUserMessages((existing) => { | 1241 | setOptimisticUserMessages((existing) => { |
| | 1242 | const removed = existing.filter((message) => serverIds.has(message.id)); |
| | 1243 | for (const message of removed) { |
| | 1244 | revokeUserMessagePreviewUrls(message); |
| | 1245 | } |
| 1195 | const next = existing.filter((message) => !serverIds.has(message.id)); | 1246 | const next = existing.filter((message) => !serverIds.has(message.id)); |
| 1196 | return next.length === existing.length ? existing : next; | 1247 | return next.length === existing.length ? existing : next; |
| 1197 | }); | 1248 | }); |
| ... | @@ -1202,7 +1253,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1202,7 +1253,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1202 | }, [prompt]); | 1253 | }, [prompt]); |
| 1203 | | 1254 | |
| 1204 | useEffect(() => { | 1255 | useEffect(() => { |
| 1205 | setOptimisticUserMessages([]); | 1256 | setOptimisticUserMessages((existing) => { |
| | 1257 | for (const message of existing) { |
| | 1258 | revokeUserMessagePreviewUrls(message); |
| | 1259 | } |
| | 1260 | return []; |
| | 1261 | }); |
| 1206 | setSendPhase("idle"); | 1262 | setSendPhase("idle"); |
| 1207 | setComposerHighlightedItemId(null); | 1263 | setComposerHighlightedItemId(null); |
| 1208 | dragDepthRef.current = 0; | 1264 | dragDepthRef.current = 0; |
| ... | @@ -1224,7 +1280,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1224,7 +1280,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1224 | currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), | 1280 | currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), |
| 1225 | ); | 1281 | ); |
| 1226 | const stagedAttachmentById = new Map<string, PersistedComposerImageAttachment>(); | 1282 | const stagedAttachmentById = new Map<string, PersistedComposerImageAttachment>(); |
| 1227 | const serializationFailedImageIds: string[] = []; | | |
| 1228 | await Promise.all( | 1283 | await Promise.all( |
| 1229 | composerImages.map(async (image) => { | 1284 | composerImages.map(async (image) => { |
| 1230 | try { | 1285 | try { |
| ... | @@ -1240,8 +1295,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1240,8 +1295,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1240 | const existingPersisted = existingPersistedById.get(image.id); | 1295 | const existingPersisted = existingPersistedById.get(image.id); |
| 1241 | if (existingPersisted) { | 1296 | if (existingPersisted) { |
| 1242 | stagedAttachmentById.set(image.id, existingPersisted); | 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,13 +1521,25 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1468 | const setThreadError = useCallback( | 1521 | const setThreadError = useCallback( |
| 1469 | (threadId: ThreadId | null, error: string | null) => { | 1522 | (threadId: ThreadId | null, error: string | null) => { |
| 1470 | if (!threadId) return; | 1523 | if (!threadId) return; |
| 1471 | dispatch({ | 1524 | if (state.threads.some((thread) => thread.id === threadId)) { |
| 1472 | type: "SET_ERROR", | 1525 | dispatch({ |
| 1473 | threadId, | 1526 | type: "SET_ERROR", |
| 1474 | 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 | const addComposerImages = (files: File[]) => { | 1545 | const addComposerImages = (files: File[]) => { |
| ... | @@ -1805,9 +1870,14 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1805,9 +1870,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1805 | promptRef.current.length === 0 && | 1870 | promptRef.current.length === 0 && |
| 1806 | composerImagesRef.current.length === 0 | 1871 | composerImagesRef.current.length === 0 |
| 1807 | ) { | 1872 | ) { |
| 1808 | setOptimisticUserMessages((existing) => | 1873 | setOptimisticUserMessages((existing) => { |
| 1809 | existing.filter((message) => message.id !== messageIdForSend), | 1874 | const removed = existing.filter((message) => message.id === messageIdForSend); |
| 1810 | ); | 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 | promptRef.current = trimmed; | 1881 | promptRef.current = trimmed; |
| 1812 | setPrompt(trimmed); | 1882 | setPrompt(trimmed); |
| 1813 | addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); | 1883 | addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); |
| ... | @@ -1866,20 +1936,28 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1866,20 +1936,28 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1866 | | 1936 | |
| 1867 | const onModelSelect = useCallback( | 1937 | const onModelSelect = useCallback( |
| 1868 | (model: string) => { | 1938 | (model: string) => { |
| 1869 | const normalizedModel = resolveModelSlug(model); | 1939 | const normalizedModel = normalizeModelSlug(model); |
| 1870 | setComposerDraftModel(threadId, normalizedModel); | 1940 | const resolvedModel = normalizedModel ?? baseThreadModel; |
| | 1941 | setComposerDraftModel(threadId, resolvedModel === baseThreadModel ? null : resolvedModel); |
| 1871 | const api = readNativeApi(); | 1942 | const api = readNativeApi(); |
| 1872 | if (api && isServerThread && activeThread) { | 1943 | if (api && isServerThread && activeThread) { |
| 1873 | void api.orchestration.dispatchCommand({ | 1944 | void api.orchestration.dispatchCommand({ |
| 1874 | type: "thread.meta.update", | 1945 | type: "thread.meta.update", |
| 1875 | commandId: newCommandId(), | 1946 | commandId: newCommandId(), |
| 1876 | threadId: activeThread.id, | 1947 | threadId: activeThread.id, |
| 1877 | model: normalizedModel, | 1948 | model: resolvedModel, |
| 1878 | }); | 1949 | }); |
| 1879 | } | 1950 | } |
| 1880 | scheduleComposerFocus(); | 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 | const onEffortSelect = useCallback( | 1962 | const onEffortSelect = useCallback( |
| 1885 | (effort: ReasoningEffort) => { | 1963 | (effort: ReasoningEffort) => { |