authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-01 16:09:46-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 22:36:35-08:00
logaced302b638608042c8a1d30b6fc91dc5661657a
treef9ea9b5323db371b7072c652341aff2a7a99a9a0
parent0b8dfee17beb957a8855f5c1382a110fc57de88a

Address PR review feedback for draft model/error/persistence handling

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 112 insertions(+), 26 deletions(-)

apps/web/src/components/ChatView.tsx+101-23
...@@ -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";
22import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";23import { 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}
231233
234function revokeBlobPreviewUrl(previewUrl: string | undefined): void {
235 if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) {
236 return;
237 }
238 URL.revokeObjectURL(previewUrl);
239}
240
241function 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
232type ComposerCommandItem =253type 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) {
504528
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 draftThread534 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);
526556
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 ]);
547590
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) {
11841226
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]);
12031254
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 );
14791544
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 === 01871 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) {
18661936
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) => {
apps/web/src/composerDraftStore.ts+11-3
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 DEFAULT_MODEL,
2 DEFAULT_REASONING,3 DEFAULT_REASONING,
3 ProjectId,4 ProjectId,
4 REASONING_OPTIONS,5 REASONING_OPTIONS,
...@@ -119,7 +120,10 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = {...@@ -119,7 +120,10 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = {
119const EMPTY_IMAGES: ComposerImageAttachment[] = [];120const EMPTY_IMAGES: ComposerImageAttachment[] = [];
120const EMPTY_IDS: string[] = [];121const EMPTY_IDS: string[] = [];
121const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = [];122const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = [];
122const EMPTY_THREAD_DRAFT: ComposerThreadDraftState = {123Object.freeze(EMPTY_IMAGES);
124Object.freeze(EMPTY_IDS);
125Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);
126const EMPTY_THREAD_DRAFT = Object.freeze({
123 prompt: "",127 prompt: "",
124 cursor: 0,128 cursor: 0,
125 images: EMPTY_IMAGES,129 images: EMPTY_IMAGES,
...@@ -127,7 +131,7 @@ const EMPTY_THREAD_DRAFT: ComposerThreadDraftState = {...@@ -127,7 +131,7 @@ const EMPTY_THREAD_DRAFT: ComposerThreadDraftState = {
127 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,131 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,
128 model: null,132 model: null,
129 effort: null,133 effort: null,
130};134}) as ComposerThreadDraftState;
131135
132const REASONING_EFFORT_VALUES = new Set<ReasoningEffort>(REASONING_OPTIONS);136const REASONING_EFFORT_VALUES = new Set<ReasoningEffort>(REASONING_OPTIONS);
133137
...@@ -667,7 +671,11 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -667,7 +671,11 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
667 if (threadId.length === 0) {671 if (threadId.length === 0) {
668 return;672 return;
669 }673 }
670 const normalizedModel = normalizeModelSlug(model) ?? null;674 const normalizedModelCandidate = normalizeModelSlug(model);
675 const normalizedModel =
676 normalizedModelCandidate && normalizedModelCandidate !== DEFAULT_MODEL
677 ? normalizedModelCandidate
678 : null;
671 set((state) => {679 set((state) => {
672 const existing = state.draftsByThreadId[threadId];680 const existing = state.draftsByThreadId[threadId];
673 if (!existing && normalizedModel === null) {681 if (!existing && normalizedModel === null) {