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 {
1717 type ProviderApprovalDecision,
1818 type ThreadId,
1919 type TurnId,
20 normalizeModelSlug,
2021 resolveModelSlug,
2122} from "@t3tools/contracts";
2223import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
......@@ -197,6 +198,7 @@ function buildLocalDraftThread(
197198 threadId: ThreadId,
198199 draftThread: DraftThreadState,
199200 fallbackModel: string,
201 error: string | null,
200202): Thread {
201203 return {
202204 id: threadId,
......@@ -218,7 +220,7 @@ function buildLocalDraftThread(
218220 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
219221 session: null,
220222 messages: [],
221 error: null,
223 error,
222224 createdAt: draftThread.createdAt,
223225 latestTurn: null,
224226 lastVisitedAt: draftThread.createdAt,
......@@ -229,6 +231,25 @@ function buildLocalDraftThread(
229231 };
230232}
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
232253type ComposerCommandItem =
233254 | {
234255 id: string;
......@@ -449,6 +470,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
449470 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
450471 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
451472 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
473 const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<
474 Record<ThreadId, string | null>
475 >({});
452476 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
453477 const [isConnecting, _setIsConnecting] = useState(false);
454478 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
......@@ -504,12 +528,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
504528
505529 const serverThread = state.threads.find((t) => t.id === threadId);
506530 const fallbackDraftProject = state.projects.find((project) => project.id === draftThread?.projectId);
531 const localDraftError = localDraftErrorsByThreadId[threadId] ?? null;
507532 const localDraftThread = useMemo(
508533 () =>
509534 draftThread
510 ? buildLocalDraftThread(threadId, draftThread, fallbackDraftProject?.model ?? DEFAULT_MODEL)
535 ? buildLocalDraftThread(
536 threadId,
537 draftThread,
538 fallbackDraftProject?.model ?? DEFAULT_MODEL,
539 localDraftError,
540 )
511541 : undefined,
512 [draftThread, fallbackDraftProject?.model, threadId],
542 [draftThread, fallbackDraftProject?.model, localDraftError, threadId],
513543 );
514544 const activeThread = serverThread ?? localDraftThread;
515545 const isServerThread = serverThread !== undefined;
......@@ -524,6 +554,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
524554 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
525555 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
527570 useEffect(() => {
528571 if (!activeThread?.id) return;
529572 if (!latestTurnSettled) return;
......@@ -545,9 +588,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
545588 dispatch,
546589 ]);
547590
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);
551593 const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING;
552594 const modelOptions = useMemo(
553595 () => getAppModelOptions(settings.customCodexModels, selectedModel),
......@@ -1184,7 +1226,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
11841226
11851227 useEffect(() => {
11861228 if (!activeThread?.id) {
1187 setOptimisticUserMessages([]);
1229 setOptimisticUserMessages((existing) => {
1230 for (const message of existing) {
1231 revokeUserMessagePreviewUrls(message);
1232 }
1233 return [];
1234 });
11881235 return;
11891236 }
11901237 if (activeThread.messages.length === 0) {
......@@ -1192,6 +1239,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
11921239 }
11931240 const serverIds = new Set(activeThread.messages.map((message) => message.id));
11941241 setOptimisticUserMessages((existing) => {
1242 const removed = existing.filter((message) => serverIds.has(message.id));
1243 for (const message of removed) {
1244 revokeUserMessagePreviewUrls(message);
1245 }
11951246 const next = existing.filter((message) => !serverIds.has(message.id));
11961247 return next.length === existing.length ? existing : next;
11971248 });
......@@ -1202,7 +1253,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
12021253 }, [prompt]);
12031254
12041255 useEffect(() => {
1205 setOptimisticUserMessages([]);
1256 setOptimisticUserMessages((existing) => {
1257 for (const message of existing) {
1258 revokeUserMessagePreviewUrls(message);
1259 }
1260 return [];
1261 });
12061262 setSendPhase("idle");
12071263 setComposerHighlightedItemId(null);
12081264 dragDepthRef.current = 0;
......@@ -1224,7 +1280,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
12241280 currentPersistedAttachments.map((attachment) => [attachment.id, attachment]),
12251281 );
12261282 const stagedAttachmentById = new Map<string, PersistedComposerImageAttachment>();
1227 const serializationFailedImageIds: string[] = [];
12281283 await Promise.all(
12291284 composerImages.map(async (image) => {
12301285 try {
......@@ -1240,8 +1295,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
12401295 const existingPersisted = existingPersistedById.get(image.id);
12411296 if (existingPersisted) {
12421297 stagedAttachmentById.set(image.id, existingPersisted);
1243 } else {
1244 serializationFailedImageIds.push(image.id);
12451298 }
12461299 }
12471300 }),
......@@ -1468,13 +1521,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
14681521 const setThreadError = useCallback(
14691522 (threadId: ThreadId | null, error: string | null) => {
14701523 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 };
14751540 });
14761541 },
1477 [dispatch],
1542 [dispatch, state.threads],
14781543 );
14791544
14801545 const addComposerImages = (files: File[]) => {
......@@ -1805,9 +1870,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
18051870 promptRef.current.length === 0 &&
18061871 composerImagesRef.current.length === 0
18071872 ) {
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 });
18111881 promptRef.current = trimmed;
18121882 setPrompt(trimmed);
18131883 addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry));
......@@ -1866,20 +1936,28 @@ export default function ChatView({ threadId }: ChatViewProps) {
18661936
18671937 const onModelSelect = useCallback(
18681938 (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);
18711942 const api = readNativeApi();
18721943 if (api && isServerThread && activeThread) {
18731944 void api.orchestration.dispatchCommand({
18741945 type: "thread.meta.update",
18751946 commandId: newCommandId(),
18761947 threadId: activeThread.id,
1877 model: normalizedModel,
1948 model: resolvedModel,
18781949 });
18791950 }
18801951 scheduleComposerFocus();
18811952 },
1882 [activeThread, isServerThread, scheduleComposerFocus, setComposerDraftModel, threadId],
1953 [
1954 activeThread,
1955 baseThreadModel,
1956 isServerThread,
1957 scheduleComposerFocus,
1958 setComposerDraftModel,
1959 threadId,
1960 ],
18831961 );
18841962 const onEffortSelect = useCallback(
18851963 (effort: ReasoningEffort) => {
apps/web/src/composerDraftStore.ts+11-3
......@@ -1,4 +1,5 @@
11import {
2 DEFAULT_MODEL,
23 DEFAULT_REASONING,
34 ProjectId,
45 REASONING_OPTIONS,
......@@ -119,7 +120,10 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = {
119120const EMPTY_IMAGES: ComposerImageAttachment[] = [];
120121const EMPTY_IDS: string[] = [];
121122const 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({
123127 prompt: "",
124128 cursor: 0,
125129 images: EMPTY_IMAGES,
......@@ -127,7 +131,7 @@ const EMPTY_THREAD_DRAFT: ComposerThreadDraftState = {
127131 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,
128132 model: null,
129133 effort: null,
130};
134}) as ComposerThreadDraftState;
131135
132136const REASONING_EFFORT_VALUES = new Set<ReasoningEffort>(REASONING_OPTIONS);
133137
......@@ -667,7 +671,11 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
667671 if (threadId.length === 0) {
668672 return;
669673 }
670 const normalizedModel = normalizeModelSlug(model) ?? null;
674 const normalizedModelCandidate = normalizeModelSlug(model);
675 const normalizedModel =
676 normalizedModelCandidate && normalizedModelCandidate !== DEFAULT_MODEL
677 ? normalizedModelCandidate
678 : null;
671679 set((state) => {
672680 const existing = state.draftsByThreadId[threadId];
673681 if (!existing && normalizedModel === null) {