authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-28 22:01:07-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 22:36:35-08:00
log545a9193e2add9995bb03ccec39538e5315349b6
tree5359d154ae8a67c758e94aa7ff1c3bea710cf378
parentfa0205cba3f2a8dffccee2c0348cb334a7bb6b6f

Persist per-thread composer drafts and reuse project draft threads

- add a zustand-backed composer draft store with localStorage persistence for prompt, cursor, model/effort, and image attachments - wire ChatView to draft state, restore/clear drafts correctly, and warn when an attachment cannot be persisted - track per-project draft thread IDs so Sidebar reuses hidden draft threads and excludes empty drafts from thread lists

7 files changed, 1084 insertions(+), 56 deletions(-)

apps/web/package.json+2-1
...@@ -27,7 +27,8 @@...@@ -27,7 +27,8 @@
27 "react-dom": "^19.0.0",27 "react-dom": "^19.0.0",
28 "react-markdown": "^10.1.0",28 "react-markdown": "^10.1.0",
29 "remark-gfm": "^4.0.1",29 "remark-gfm": "^4.0.1",
30 "tailwind-merge": "^3.4.0"30 "tailwind-merge": "^3.4.0",
31 "zustand": "^5.0.11"
31 },32 },
32 "devDependencies": {33 "devDependencies": {
33 "@effect/language-service": "catalog:",34 "@effect/language-service": "catalog:",
apps/web/src/components/ChatView.tsx+226-49
...@@ -53,7 +53,6 @@ import {...@@ -53,7 +53,6 @@ import {
53 DEFAULT_THREAD_TERMINAL_ID,53 DEFAULT_THREAD_TERMINAL_ID,
54 MAX_THREAD_TERMINAL_COUNT,54 MAX_THREAD_TERMINAL_COUNT,
55 type ChatMessage,55 type ChatMessage,
56 type ChatImageAttachment,
57 type TurnDiffSummary,56 type TurnDiffSummary,
58} from "../types";57} from "../types";
59import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";58import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";
...@@ -111,6 +110,14 @@ import { SidebarTrigger } from "./ui/sidebar";...@@ -111,6 +110,14 @@ import { SidebarTrigger } from "./ui/sidebar";
111import { newCommandId, newMessageId } from "~/lib/utils";110import { newCommandId, newMessageId } from "~/lib/utils";
112import { readNativeApi } from "~/nativeApi";111import { readNativeApi } from "~/nativeApi";
113import { getAppModelOptions, useAppSettings } from "../appSettings";112import { getAppModelOptions, useAppSettings } from "../appSettings";
113import {
114 type ComposerImageAttachment,
115 type PersistedComposerImageAttachment,
116 readPersistedComposerDraftAttachments,
117 useComposerDraftStore,
118 useComposerThreadDraft,
119} from "../composerDraftStore";
120import { clearProjectDraftThreadById } from "../projectDraftThreads";
114import { clamp } from "effect/Number";121import { clamp } from "effect/Number";
115122
116function formatMessageMeta(createdAt: string, duration: string | null): string {123function formatMessageMeta(createdAt: string, duration: string | null): string {
...@@ -130,6 +137,8 @@ const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;...@@ -130,6 +137,8 @@ const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
130const SCRIPT_TERMINAL_COLS = 120;137const SCRIPT_TERMINAL_COLS = 120;
131const SCRIPT_TERMINAL_ROWS = 30;138const SCRIPT_TERMINAL_ROWS = 30;
132const WORKTREE_BRANCH_PREFIX = "t3code";139const WORKTREE_BRANCH_PREFIX = "t3code";
140const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
141const ENABLE_DRAFT_PERSIST_DEBUG_LOGS = import.meta.env.DEV;
133142
134function readLastInvokedScriptByProjectFromStorage(): Record<string, string> {143function readLastInvokedScriptByProjectFromStorage(): Record<string, string> {
135 const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY);144 const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY);
...@@ -156,11 +165,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {...@@ -156,11 +165,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
156 return "text-muted-foreground/40";165 return "text-muted-foreground/40";
157}166}
158167
159interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> {
160 previewUrl: string;
161 file: File;
162}
163
164interface ExpandedImageItem {168interface ExpandedImageItem {
165 src: string;169 src: string;
166 name: string;170 name: string;
...@@ -239,6 +243,25 @@ function buildTemporaryWorktreeBranchName(): string {...@@ -239,6 +243,25 @@ function buildTemporaryWorktreeBranchName(): string {
239 return `${WORKTREE_BRANCH_PREFIX}/${token}`;243 return `${WORKTREE_BRANCH_PREFIX}/${token}`;
240}244}
241245
246function readComposerDraftStorageChars(): number | null {
247 if (typeof window === "undefined") {
248 return null;
249 }
250 try {
251 const raw = window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY);
252 return raw?.length ?? 0;
253 } catch {
254 return null;
255 }
256}
257
258function debugDraftPersist(message: string, payload: Record<string, unknown>): void {
259 if (!ENABLE_DRAFT_PERSIST_DEBUG_LOGS) {
260 return;
261 }
262 console.debug(`[draft-persist] ${message}`, payload);
263}
264
242const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {265const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
243 pathValue: string;266 pathValue: string;
244 kind: "file" | "directory";267 kind: "file" | "directory";
...@@ -372,23 +395,38 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -372,23 +395,38 @@ export default function ChatView({ threadId }: ChatViewProps) {
372 const { resolvedTheme } = useTheme();395 const { resolvedTheme } = useTheme();
373 const queryClient = useQueryClient();396 const queryClient = useQueryClient();
374 const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient }));397 const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient }));
375 const [prompt, setPrompt] = useState("");398 const composerDraft = useComposerThreadDraft(threadId);
399 const prompt = composerDraft.prompt;
400 const composerImages = composerDraft.images;
401 const composerCursor = composerDraft.cursor;
402 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
403 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
404 const setComposerDraftCursor = useComposerDraftStore((store) => store.setCursor);
405 const setComposerDraftModel = useComposerDraftStore((store) => store.setModel);
406 const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort);
407 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);
408 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
409 const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);
410 const clearComposerDraftPersistedAttachments = useComposerDraftStore(
411 (store) => store.clearPersistedAttachments,
412 );
413 const syncComposerDraftPersistedAttachments = useComposerDraftStore(
414 (store) => store.syncPersistedAttachments,
415 );
416 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
376 const promptRef = useRef(prompt);417 const promptRef = useRef(prompt);
377 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
378 const [isDragOverComposer, setIsDragOverComposer] = useState(false);418 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
379 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);419 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
380 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);420 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
381 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");421 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
382 const [isConnecting, _setIsConnecting] = useState(false);422 const [isConnecting, _setIsConnecting] = useState(false);
383 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);423 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
384 const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
385 const [envMode, setEnvMode] = useState<"local" | "worktree">("local");424 const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
386 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);425 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
387 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);426 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
388 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});427 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
389 const [nowTick, setNowTick] = useState(() => Date.now());428 const [nowTick, setNowTick] = useState(() => Date.now());
390 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);429 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
391 const [composerCursor, setComposerCursor] = useState(0);
392 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);430 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
393 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState<431 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState<
394 Record<string, string>432 Record<string, string>
...@@ -402,6 +440,37 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -402,6 +440,37 @@ export default function ChatView({ threadId }: ChatViewProps) {
402 const dragDepthRef = useRef(0);440 const dragDepthRef = useRef(0);
403 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});441 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
404442
443 const setPrompt = useCallback(
444 (nextPrompt: string) => {
445 setComposerDraftPrompt(threadId, nextPrompt);
446 },
447 [setComposerDraftPrompt, threadId],
448 );
449 const setComposerCursor = useCallback(
450 (nextCursor: number) => {
451 setComposerDraftCursor(threadId, nextCursor);
452 },
453 [setComposerDraftCursor, threadId],
454 );
455 const addComposerImage = useCallback(
456 (image: ComposerImageAttachment) => {
457 addComposerDraftImage(threadId, image);
458 },
459 [addComposerDraftImage, threadId],
460 );
461 const addComposerImagesToDraft = useCallback(
462 (images: ComposerImageAttachment[]) => {
463 addComposerDraftImages(threadId, images);
464 },
465 [addComposerDraftImages, threadId],
466 );
467 const removeComposerImageFromDraft = useCallback(
468 (imageId: string) => {
469 removeComposerDraftImage(threadId, imageId);
470 },
471 [removeComposerDraftImage, threadId],
472 );
473
405 const activeThread = state.threads.find((t) => t.id === threadId);474 const activeThread = state.threads.find((t) => t.id === threadId);
406 const diffSearch = useMemo(475 const diffSearch = useMemo(
407 () => parseDiffRouteSearch(rawSearch as Record<string, unknown>),476 () => parseDiffRouteSearch(rawSearch as Record<string, unknown>),
...@@ -435,8 +504,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -435,8 +504,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
435 ]);504 ]);
436505
437 const selectedModel = resolveModelSlug(506 const selectedModel = resolveModelSlug(
438 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,507 composerDraft.model ?? activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
439 );508 );
509 const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING;
440 const modelOptions = useMemo(510 const modelOptions = useMemo(
441 () => getAppModelOptions(settings.customCodexModels, selectedModel),511 () => getAppModelOptions(settings.customCodexModels, selectedModel),
442 [selectedModel, settings.customCodexModels],512 [selectedModel, settings.customCodexModels],
...@@ -645,6 +715,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -645,6 +715,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
645 null,715 null,
646 [composerHighlightedItemId, composerMenuItems],716 [composerHighlightedItemId, composerMenuItems],
647 );717 );
718 const nonPersistedComposerImageIdSet = useMemo(
719 () => new Set(nonPersistedComposerImageIds),
720 [nonPersistedComposerImageIds],
721 );
648 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;722 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;
649 const threadTerminalRuntimeEnv = useMemo(() => {723 const threadTerminalRuntimeEnv = useMemo(() => {
650 if (!activeProject?.cwd) return {};724 if (!activeProject?.cwd) return {};
...@@ -693,12 +767,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -693,12 +767,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
693 const hasReachedTerminalLimit =767 const hasReachedTerminalLimit =
694 (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT;768 (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT;
695769
696 const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => {
697 for (const image of images) {
698 if (!image.previewUrl) continue;
699 URL.revokeObjectURL(image.previewUrl);
700 }
701 }, []);
702 const focusComposer = useCallback(() => {770 const focusComposer = useCallback(() => {
703 const textarea = textareaRef.current;771 const textarea = textareaRef.current;
704 if (!textarea) return;772 if (!textarea) return;
...@@ -1088,26 +1156,115 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1088,26 +1156,115 @@ export default function ChatView({ threadId }: ChatViewProps) {
1088 }, [activeThread?.id, activeThread?.messages]);1156 }, [activeThread?.id, activeThread?.messages]);
10891157
1090 useEffect(() => {1158 useEffect(() => {
1091 setComposerImages((existing) => {1159 promptRef.current = prompt;
1092 revokePreviewUrls(existing);1160 }, [prompt]);
1093 return [];1161
1094 });1162 useEffect(() => {
1095 setOptimisticUserMessages([]);1163 setOptimisticUserMessages([]);
1096 setPrompt("");
1097 promptRef.current = "";
1098 setSendPhase("idle");1164 setSendPhase("idle");
1099 setComposerCursor(0);
1100 setComposerHighlightedItemId(null);1165 setComposerHighlightedItemId(null);
1101 dragDepthRef.current = 0;1166 dragDepthRef.current = 0;
1102 setIsDragOverComposer(false);1167 setIsDragOverComposer(false);
1103 setExpandedImage(null);1168 setExpandedImage(null);
1104 }, [activeThread?.id, revokePreviewUrls]);1169 }, [threadId]);
11051170
1106 useEffect(() => {1171 useEffect(() => {
1172 let cancelled = false;
1173 void (async () => {
1174 if (composerImages.length === 0) {
1175 clearComposerDraftPersistedAttachments(threadId);
1176 return;
1177 }
1178 try {
1179 const currentPersistedAttachments =
1180 useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? [];
1181 const existingPersistedById = new Map(
1182 currentPersistedAttachments.map((attachment) => [attachment.id, attachment]),
1183 );
1184 const stagedAttachmentById = new Map<string, PersistedComposerImageAttachment>();
1185 const serializationFailedImageIds: string[] = [];
1186 await Promise.all(
1187 composerImages.map(async (image) => {
1188 try {
1189 const dataUrl = await readFileAsDataUrl(image.file);
1190 stagedAttachmentById.set(image.id, {
1191 id: image.id,
1192 name: image.name,
1193 mimeType: image.mimeType,
1194 sizeBytes: image.sizeBytes,
1195 dataUrl,
1196 });
1197 } catch {
1198 const existingPersisted = existingPersistedById.get(image.id);
1199 if (existingPersisted) {
1200 stagedAttachmentById.set(image.id, existingPersisted);
1201 } else {
1202 serializationFailedImageIds.push(image.id);
1203 }
1204 }
1205 }),
1206 );
1207 const serialized = Array.from(stagedAttachmentById.values());
1208 if (cancelled) {
1209 return;
1210 }
1211 // Stage attachments in persisted draft state first so persist middleware can write them.
1212 const stagedAttachmentIds = serialized.map((attachment) => attachment.id);
1213 debugDraftPersist("stage", {
1214 threadId,
1215 attachmentCount: serialized.length,
1216 serializationFailedCount: serializationFailedImageIds.length,
1217 serializationFailedImageIds,
1218 attachmentMeta: serialized.map((attachment) => ({
1219 id: attachment.id,
1220 name: attachment.name,
1221 sizeBytes: attachment.sizeBytes,
1222 dataUrlChars: attachment.dataUrl.length,
1223 })),
1224 storageCharsBefore: readComposerDraftStorageChars(),
1225 });
1226 syncComposerDraftPersistedAttachments(
1227 threadId,
1228 serialized,
1229 );
1230 debugDraftPersist("reconcile-request", {
1231 threadId,
1232 stagedCount: stagedAttachmentIds.length,
1233 serializationFailedCount: serializationFailedImageIds.length,
1234 storageCharsAfter: readComposerDraftStorageChars(),
1235 });
1236 } catch (error) {
1237 const currentImageIds = new Set(composerImages.map((image) => image.id));
1238 const fallbackPersistedAttachments = readPersistedComposerDraftAttachments(threadId);
1239 const fallbackPersistedIds = fallbackPersistedAttachments
1240 .map((attachment) => attachment.id)
1241 .filter((id) => currentImageIds.has(id));
1242 const fallbackPersistedIdSet = new Set(fallbackPersistedIds);
1243 const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) =>
1244 fallbackPersistedIdSet.has(attachment.id),
1245 );
1246 debugDraftPersist("error", {
1247 threadId,
1248 error: error instanceof Error ? error.message : String(error),
1249 imageIds: composerImages.map((image) => image.id),
1250 fallbackPersistedCount: fallbackPersistedIds.length,
1251 storageChars: readComposerDraftStorageChars(),
1252 });
1253 if (cancelled) {
1254 return;
1255 }
1256 syncComposerDraftPersistedAttachments(threadId, fallbackAttachments);
1257 }
1258 })();
1107 return () => {1259 return () => {
1108 revokePreviewUrls(composerImagesRef.current);1260 cancelled = true;
1109 };1261 };
1110 }, [revokePreviewUrls]);1262 }, [
1263 clearComposerDraftPersistedAttachments,
1264 composerImages,
1265 syncComposerDraftPersistedAttachments,
1266 threadId,
1267 ]);
11111268
1112 const closeExpandedImage = useCallback(() => {1269 const closeExpandedImage = useCallback(() => {
1113 setExpandedImage(null);1270 setExpandedImage(null);
...@@ -1310,7 +1467,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1310,7 +1467,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
1310 const addComposerImages = (files: File[]) => {1467 const addComposerImages = (files: File[]) => {
1311 if (!activeThreadId || files.length === 0) return;1468 if (!activeThreadId || files.length === 0) return;
13121469
1313 const nextImages = [...composerImagesRef.current];1470 const nextImages: ComposerImageAttachment[] = [];
1471 let nextImageCount = composerImagesRef.current.length;
1314 let error: string | null = null;1472 let error: string | null = null;
1315 for (const file of files) {1473 for (const file of files) {
1316 if (!file.type.startsWith("image/")) {1474 if (!file.type.startsWith("image/")) {
...@@ -1321,7 +1479,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1321,7 +1479,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1321 error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`;1479 error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`;
1322 continue;1480 continue;
1323 }1481 }
1324 if (nextImages.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {1482 if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
1325 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`;1483 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`;
1326 break;1484 break;
1327 }1485 }
...@@ -1336,20 +1494,19 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1336,20 +1494,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
1336 previewUrl,1494 previewUrl,
1337 file,1495 file,
1338 });1496 });
1497 nextImageCount += 1;
1339 }1498 }
13401499
1341 setComposerImages(nextImages);1500 if (nextImages.length === 1 && nextImages[0]) {
1501 addComposerImage(nextImages[0]);
1502 } else if (nextImages.length > 1) {
1503 addComposerImagesToDraft(nextImages);
1504 }
1342 setThreadError(activeThreadId, error);1505 setThreadError(activeThreadId, error);
1343 };1506 };
13441507
1345 const removeComposerImage = (imageId: string) => {1508 const removeComposerImage = (imageId: string) => {
1346 setComposerImages((existing) => {1509 removeComposerImageFromDraft(imageId);
1347 const match = existing.find((image) => image.id === imageId);
1348 if (match?.previewUrl) {
1349 URL.revokeObjectURL(match.previewUrl);
1350 }
1351 return existing.filter((image) => image.id !== imageId);
1352 });
1353 };1510 };
13541511
1355 const onComposerPaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {1512 const onComposerPaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
...@@ -1516,9 +1673,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1516,9 +1673,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
15161673
1517 setThreadError(threadIdForSend, null);1674 setThreadError(threadIdForSend, null);
1518 promptRef.current = "";1675 promptRef.current = "";
1519 setPrompt("");1676 clearComposerDraftContent(threadIdForSend);
1520 setComposerImages([]);
1521 setComposerCursor(0);
1522 setComposerHighlightedItemId(null);1677 setComposerHighlightedItemId(null);
15231678
1524 let attemptedTurnStart = false;1679 let attemptedTurnStart = false;
...@@ -1596,6 +1751,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1596,6 +1751,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
1596 sandboxMode,1751 sandboxMode,
1597 createdAt: messageCreatedAt,1752 createdAt: messageCreatedAt,
1598 });1753 });
1754 if (isFirstMessage) {
1755 clearProjectDraftThreadById(activeProject.id, threadIdForSend);
1756 }
1599 } catch (err) {1757 } catch (err) {
1600 if (1758 if (
1601 !attemptedTurnStart &&1759 !attemptedTurnStart &&
...@@ -1607,7 +1765,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1607,7 +1765,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1607 );1765 );
1608 promptRef.current = trimmed;1766 promptRef.current = trimmed;
1609 setPrompt(trimmed);1767 setPrompt(trimmed);
1610 setComposerImages(composerImagesSnapshot);1768 addComposerImagesToDraft(composerImagesSnapshot);
1611 setComposerCursor(trimmed.length);1769 setComposerCursor(trimmed.length);
1612 }1770 }
1613 setThreadError(1771 setThreadError(
...@@ -1663,26 +1821,27 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1663,26 +1821,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
16631821
1664 const onModelSelect = useCallback(1822 const onModelSelect = useCallback(
1665 (model: string) => {1823 (model: string) => {
1824 const normalizedModel = resolveModelSlug(model);
1825 setComposerDraftModel(threadId, normalizedModel);
1666 const api = readNativeApi();1826 const api = readNativeApi();
1667 if (!activeThread) return;1827 if (api && activeThread) {
1668 if (api) {
1669 void api.orchestration.dispatchCommand({1828 void api.orchestration.dispatchCommand({
1670 type: "thread.meta.update",1829 type: "thread.meta.update",
1671 commandId: newCommandId(),1830 commandId: newCommandId(),
1672 threadId: activeThread.id,1831 threadId: activeThread.id,
1673 model: resolveModelSlug(model),1832 model: normalizedModel,
1674 });1833 });
1675 }1834 }
1676 scheduleComposerFocus();1835 scheduleComposerFocus();
1677 },1836 },
1678 [activeThread, scheduleComposerFocus],1837 [activeThread, scheduleComposerFocus, setComposerDraftModel, threadId],
1679 );1838 );
1680 const onEffortSelect = useCallback(1839 const onEffortSelect = useCallback(
1681 (effort: ReasoningEffort) => {1840 (effort: ReasoningEffort) => {
1682 setSelectedEffort(effort);1841 setComposerDraftEffort(threadId, effort);
1683 scheduleComposerFocus();1842 scheduleComposerFocus();
1684 },1843 },
1685 [scheduleComposerFocus],1844 [scheduleComposerFocus, setComposerDraftEffort, threadId],
1686 );1845 );
1687 const onEnvModeChange = useCallback(1846 const onEnvModeChange = useCallback(
1688 (mode: "local" | "worktree") => {1847 (mode: "local" | "worktree") => {
...@@ -1705,7 +1864,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1705,7 +1864,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1705 setComposerCursor(next.cursor);1864 setComposerCursor(next.cursor);
1706 });1865 });
1707 },1866 },
1708 [],1867 [setComposerCursor, setPrompt],
1709 );1868 );
17101869
1711 const onSelectComposerItem = useCallback(1870 const onSelectComposerItem = useCallback(
...@@ -1748,7 +1907,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1748,7 +1907,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1748 promptRef.current = nextPrompt;1907 promptRef.current = nextPrompt;
1749 setPrompt(nextPrompt);1908 setPrompt(nextPrompt);
1750 setComposerCursor(nextCursor);1909 setComposerCursor(nextCursor);
1751 }, []);1910 }, [setComposerCursor, setPrompt]);
17521911
1753 const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {1912 const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
1754 if (composerMenuOpen && composerMenuItems.length > 0) {1913 if (composerMenuOpen && composerMenuItems.length > 0) {
...@@ -1960,6 +2119,24 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1960,6 +2119,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
1960 {image.name}2119 {image.name}
1961 </div>2120 </div>
1962 )}2121 )}
2122 {nonPersistedComposerImageIdSet.has(image.id) && (
2123 <Tooltip>
2124 <TooltipTrigger
2125 render={
2126 <span
2127 role="img"
2128 aria-label="Draft attachment may not persist"
2129 className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600"
2130 >
2131 <CircleAlertIcon className="size-3" />
2132 </span>
2133 }
2134 />
2135 <TooltipPopup side="top" className="max-w-64 whitespace-normal leading-tight">
2136 Draft attachment could not be saved locally and may be lost on navigation.
2137 </TooltipPopup>
2138 </Tooltip>
2139 )}
1963 <Button2140 <Button
1964 variant="ghost"2141 variant="ghost"
1965 size="icon-xs"2142 size="icon-xs"
apps/web/src/components/Sidebar.tsx+72-6
...@@ -20,6 +20,13 @@ import { derivePendingApprovals } from "../session-logic";...@@ -20,6 +20,13 @@ import { derivePendingApprovals } from "../session-logic";
20import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";20import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
21import { serverConfigQueryOptions } from "../lib/serverReactQuery";21import { serverConfigQueryOptions } from "../lib/serverReactQuery";
22import { readNativeApi } from "../nativeApi";22import { readNativeApi } from "../nativeApi";
23import { useComposerDraftStore } from "../composerDraftStore";
24import {
25 clearProjectDraftThreadById,
26 clearProjectDraftThreadId,
27 readProjectDraftThreadId,
28 writeProjectDraftThreadId,
29} from "../projectDraftThreads";
23import { toastManager } from "./ui/toast";30import { toastManager } from "./ui/toast";
24import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";31import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
25import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";32import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
...@@ -74,6 +81,10 @@ interface PrStatusIndicator {...@@ -74,6 +81,10 @@ interface PrStatusIndicator {
7481
75type ThreadPr = GitStatusResult["pr"];82type ThreadPr = GitStatusResult["pr"];
7683
84function shouldShowThreadInSidebar(thread: Thread): boolean {
85 return thread.messages.some((message) => message.role === "user");
86}
87
77function hasUnseenCompletion(thread: Thread): boolean {88function hasUnseenCompletion(thread: Thread): boolean {
78 if (!thread.latestTurn?.completedAt) return false;89 if (!thread.latestTurn?.completedAt) return false;
79 const completedAt = Date.parse(thread.latestTurn.completedAt);90 const completedAt = Date.parse(thread.latestTurn.completedAt);
...@@ -228,6 +239,7 @@ function ProjectFavicon({ cwd }: { cwd: string }) {...@@ -228,6 +239,7 @@ function ProjectFavicon({ cwd }: { cwd: string }) {
228239
229export default function Sidebar() {240export default function Sidebar() {
230 const { state, dispatch } = useStore();241 const { state, dispatch } = useStore();
242 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);
231 const navigate = useNavigate();243 const navigate = useNavigate();
232 const { settings: appSettings } = useAppSettings();244 const { settings: appSettings } = useAppSettings();
233 const routeThreadId = useParams({245 const routeThreadId = useParams({
...@@ -336,6 +348,42 @@ export default function Sidebar() {...@@ -336,6 +348,42 @@ export default function Sidebar() {
336 ): Promise<void> => {348 ): Promise<void> => {
337 const api = readNativeApi();349 const api = readNativeApi();
338 if (!api) return Promise.resolve();350 if (!api) return Promise.resolve();
351 const storedDraftThreadId = readProjectDraftThreadId(projectId);
352 const storedDraftThread = storedDraftThreadId
353 ? state.threads.find((thread) => thread.id === storedDraftThreadId)
354 : undefined;
355 const reusableStoredDraftThreadId =
356 storedDraftThread &&
357 storedDraftThread.projectId === projectId &&
358 !shouldShowThreadInSidebar(storedDraftThread)
359 ? storedDraftThread.id
360 : null;
361 if (reusableStoredDraftThreadId) {
362 if (routeThreadId === reusableStoredDraftThreadId) {
363 return Promise.resolve();
364 }
365 return navigate({
366 to: "/$threadId",
367 params: { threadId: reusableStoredDraftThreadId },
368 });
369 }
370 if (storedDraftThreadId) {
371 clearProjectDraftThreadId(projectId);
372 }
373
374 const activeThread = routeThreadId
375 ? state.threads.find((thread) => thread.id === routeThreadId)
376 : undefined;
377 const reusableDraftThreadId =
378 activeThread &&
379 activeThread.projectId === projectId &&
380 !shouldShowThreadInSidebar(activeThread)
381 ? activeThread.id
382 : null;
383 if (reusableDraftThreadId) {
384 writeProjectDraftThreadId(projectId, reusableDraftThreadId);
385 return Promise.resolve();
386 }
339 const threadId = newThreadId();387 const threadId = newThreadId();
340 const createdAt = new Date().toISOString();388 const createdAt = new Date().toISOString();
341 const model =389 const model =
...@@ -352,6 +400,7 @@ export default function Sidebar() {...@@ -352,6 +400,7 @@ export default function Sidebar() {
352 worktreePath: options?.worktreePath ?? null,400 worktreePath: options?.worktreePath ?? null,
353 createdAt,401 createdAt,
354 });402 });
403 writeProjectDraftThreadId(projectId, threadId);
355404
356 // Ensure route guards can see the new thread before navigating.405 // Ensure route guards can see the new thread before navigating.
357 try {406 try {
...@@ -367,7 +416,7 @@ export default function Sidebar() {...@@ -367,7 +416,7 @@ export default function Sidebar() {
367 });416 });
368 })();417 })();
369 },418 },
370 [dispatch, navigate, state.projects],419 [dispatch, navigate, routeThreadId, state.projects, state.threads],
371 );420 );
372421
373 const focusMostRecentThreadForProject = useCallback(422 const focusMostRecentThreadForProject = useCallback(
...@@ -517,6 +566,8 @@ export default function Sidebar() {...@@ -517,6 +566,8 @@ export default function Sidebar() {
517 commandId: newCommandId(),566 commandId: newCommandId(),
518 threadId,567 threadId,
519 });568 });
569 clearComposerDraftForThread(threadId);
570 clearProjectDraftThreadById(thread.projectId, thread.id);
520 if (shouldNavigateToFallback) {571 if (shouldNavigateToFallback) {
521 if (fallbackThreadId) {572 if (fallbackThreadId) {
522 void navigate({573 void navigate({
...@@ -556,6 +607,7 @@ export default function Sidebar() {...@@ -556,6 +607,7 @@ export default function Sidebar() {
556 },607 },
557 [608 [
558 appSettings.confirmThreadDelete,609 appSettings.confirmThreadDelete,
610 clearComposerDraftForThread,
559 dispatch,611 dispatch,
560 navigate,612 navigate,
561 removeWorktreeMutation,613 removeWorktreeMutation,
...@@ -576,7 +628,9 @@ export default function Sidebar() {...@@ -576,7 +628,9 @@ export default function Sidebar() {
576 if (!project) return;628 if (!project) return;
577629
578 const projectThreads = state.threads.filter((thread) => thread.projectId === projectId);630 const projectThreads = state.threads.filter((thread) => thread.projectId === projectId);
579 if (projectThreads.length > 0) {631 const visibleThreads = projectThreads.filter(shouldShowThreadInSidebar);
632 const draftThreads = projectThreads.filter((thread) => !shouldShowThreadInSidebar(thread));
633 if (visibleThreads.length > 0) {
580 toastManager.add({634 toastManager.add({
581 type: "warning",635 type: "warning",
582 title: "Project is not empty",636 title: "Project is not empty",
...@@ -591,6 +645,15 @@ export default function Sidebar() {...@@ -591,6 +645,15 @@ export default function Sidebar() {
591 if (!confirmed) return;645 if (!confirmed) return;
592646
593 try {647 try {
648 for (const draftThread of draftThreads) {
649 await api.orchestration.dispatchCommand({
650 type: "thread.delete",
651 commandId: newCommandId(),
652 threadId: draftThread.id,
653 });
654 clearComposerDraftForThread(draftThread.id);
655 }
656 clearProjectDraftThreadId(projectId);
594 await api.orchestration.dispatchCommand({657 await api.orchestration.dispatchCommand({
595 type: "project.delete",658 type: "project.delete",
596 commandId: newCommandId(),659 commandId: newCommandId(),
...@@ -606,7 +669,7 @@ export default function Sidebar() {...@@ -606,7 +669,7 @@ export default function Sidebar() {
606 });669 });
607 }670 }
608 },671 },
609 [state.projects, state.threads],672 [clearComposerDraftForThread, state.projects, state.threads],
610 );673 );
611674
612 useEffect(() => {675 useEffect(() => {
...@@ -693,8 +756,9 @@ export default function Sidebar() {...@@ -693,8 +756,9 @@ export default function Sidebar() {
693 <SidebarGroup className="px-2 py-2">756 <SidebarGroup className="px-2 py-2">
694 <SidebarMenu>757 <SidebarMenu>
695 {state.projects.map((project) => {758 {state.projects.map((project) => {
696 const threads = state.threads759 const visibleThreads = state.threads
697 .filter((thread) => thread.projectId === project.id)760 .filter((thread) => thread.projectId === project.id)
761 .filter(shouldShowThreadInSidebar)
698 .toSorted((a, b) => {762 .toSorted((a, b) => {
699 const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();763 const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
700 if (byDate !== 0) return byDate;764 if (byDate !== 0) return byDate;
...@@ -736,12 +800,14 @@ export default function Sidebar() {...@@ -736,12 +800,14 @@ export default function Sidebar() {
736 <span className="flex-1 truncate text-xs font-medium text-foreground/90">800 <span className="flex-1 truncate text-xs font-medium text-foreground/90">
737 {project.name}801 {project.name}
738 </span>802 </span>
739 <span className="text-[10px] text-muted-foreground/60">{threads.length}</span>803 <span className="text-[10px] text-muted-foreground/60">
804 {visibleThreads.length}
805 </span>
740 </CollapsibleTrigger>806 </CollapsibleTrigger>
741807
742 <CollapsibleContent>808 <CollapsibleContent>
743 <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0">809 <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0">
744 {threads.map((thread) => {810 {visibleThreads.map((thread) => {
745 const isActive = routeThreadId === thread.id;811 const isActive = routeThreadId === thread.id;
746 const threadStatus = threadStatusPill(812 const threadStatus = threadStatusPill(
747 thread,813 thread,
apps/web/src/composerDraftStore.ts created+687
...@@ -0,0 +1,687 @@
1import {
2 DEFAULT_REASONING,
3 REASONING_OPTIONS,
4 ThreadId,
5 normalizeModelSlug,
6 type ReasoningEffort,
7} from "@t3tools/contracts";
8import type { ChatImageAttachment } from "./types";
9import { create } from "zustand";
10import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
11
12const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
13
14const SAFE_LOCAL_STORAGE: StateStorage = {
15 getItem: (key) => {
16 if (typeof window === "undefined") {
17 return null;
18 }
19 try {
20 return window.localStorage.getItem(key);
21 } catch {
22 return null;
23 }
24 },
25 setItem: (key, value) => {
26 if (typeof window === "undefined") {
27 return;
28 }
29 try {
30 window.localStorage.setItem(key, value);
31 } catch {
32 // Swallow quota/storage errors so draft state stays usable in-memory.
33 }
34 },
35 removeItem: (key) => {
36 if (typeof window === "undefined") {
37 return;
38 }
39 try {
40 window.localStorage.removeItem(key);
41 } catch {
42 // Best-effort cleanup.
43 }
44 },
45};
46
47export interface PersistedComposerImageAttachment {
48 id: string;
49 name: string;
50 mimeType: string;
51 sizeBytes: number;
52 dataUrl: string;
53}
54
55export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> {
56 previewUrl: string;
57 file: File;
58}
59
60interface PersistedComposerThreadDraftState {
61 prompt: string;
62 cursor: number;
63 attachments: PersistedComposerImageAttachment[];
64 model?: string | null;
65 effort?: ReasoningEffort | null;
66}
67
68interface PersistedComposerDraftStoreState {
69 draftsByThreadId: Record<ThreadId, PersistedComposerThreadDraftState>;
70}
71
72export interface ComposerThreadDraftState {
73 prompt: string;
74 cursor: number;
75 images: ComposerImageAttachment[];
76 nonPersistedImageIds: string[];
77 persistedAttachments: PersistedComposerImageAttachment[];
78 model: string | null;
79 effort: ReasoningEffort | null;
80}
81
82interface ComposerDraftStoreState {
83 draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>;
84 setPrompt: (threadId: ThreadId, prompt: string) => void;
85 setCursor: (threadId: ThreadId, cursor: number) => void;
86 setModel: (threadId: ThreadId, model: string | null | undefined) => void;
87 setEffort: (threadId: ThreadId, effort: ReasoningEffort | null | undefined) => void;
88 addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void;
89 addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void;
90 removeImage: (threadId: ThreadId, imageId: string) => void;
91 clearPersistedAttachments: (threadId: ThreadId) => void;
92 syncPersistedAttachments: (threadId: ThreadId, attachments: PersistedComposerImageAttachment[]) => void;
93 clearComposerContent: (threadId: ThreadId) => void;
94 clearThreadDraft: (threadId: ThreadId) => void;
95}
96
97const EMPTY_IMAGES: ComposerImageAttachment[] = [];
98const EMPTY_IDS: string[] = [];
99const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = [];
100const EMPTY_THREAD_DRAFT: ComposerThreadDraftState = {
101 prompt: "",
102 cursor: 0,
103 images: EMPTY_IMAGES,
104 nonPersistedImageIds: EMPTY_IDS,
105 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,
106 model: null,
107 effort: null,
108};
109
110const REASONING_EFFORT_VALUES = new Set<ReasoningEffort>(REASONING_OPTIONS);
111
112function createEmptyThreadDraft(): ComposerThreadDraftState {
113 return {
114 prompt: "",
115 cursor: 0,
116 images: [],
117 nonPersistedImageIds: [],
118 persistedAttachments: [],
119 model: null,
120 effort: null,
121 };
122}
123
124function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
125 return (
126 draft.prompt.length === 0 &&
127 draft.images.length === 0 &&
128 draft.persistedAttachments.length === 0 &&
129 draft.model === null &&
130 draft.effort === null
131 );
132}
133
134function revokeObjectPreviewUrl(previewUrl: string): void {
135 if (typeof URL === "undefined") {
136 return;
137 }
138 if (!previewUrl.startsWith("blob:")) {
139 return;
140 }
141 URL.revokeObjectURL(previewUrl);
142}
143
144function normalizePersistedAttachment(value: unknown): PersistedComposerImageAttachment | null {
145 if (!value || typeof value !== "object") {
146 return null;
147 }
148 const candidate = value as Record<string, unknown>;
149 const id = candidate.id;
150 const name = candidate.name;
151 const mimeType = candidate.mimeType;
152 const sizeBytes = candidate.sizeBytes;
153 const dataUrl = candidate.dataUrl;
154 if (
155 typeof id !== "string" ||
156 typeof name !== "string" ||
157 typeof mimeType !== "string" ||
158 typeof sizeBytes !== "number" ||
159 !Number.isFinite(sizeBytes) ||
160 typeof dataUrl !== "string" ||
161 id.length === 0 ||
162 dataUrl.length === 0
163 ) {
164 return null;
165 }
166 return {
167 id,
168 name,
169 mimeType,
170 sizeBytes,
171 dataUrl,
172 };
173}
174
175function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState {
176 if (!value || typeof value !== "object") {
177 return { draftsByThreadId: {} };
178 }
179 const candidate = value as Record<string, unknown>;
180 const rawDraftMap = candidate.draftsByThreadId;
181 if (!rawDraftMap || typeof rawDraftMap !== "object") {
182 return { draftsByThreadId: {} };
183 }
184 const nextDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {};
185 for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record<string, unknown>)) {
186 if (typeof threadId !== "string" || threadId.length === 0) {
187 continue;
188 }
189 if (!draftValue || typeof draftValue !== "object") {
190 continue;
191 }
192 const draftCandidate = draftValue as Record<string, unknown>;
193 const prompt = typeof draftCandidate.prompt === "string" ? draftCandidate.prompt : "";
194 const cursor =
195 typeof draftCandidate.cursor === "number" && Number.isFinite(draftCandidate.cursor)
196 ? Math.max(0, Math.floor(draftCandidate.cursor))
197 : 0;
198 const attachments = Array.isArray(draftCandidate.attachments)
199 ? draftCandidate.attachments.flatMap((entry) => {
200 const normalized = normalizePersistedAttachment(entry);
201 return normalized ? [normalized] : [];
202 })
203 : [];
204 const model =
205 typeof draftCandidate.model === "string" ? normalizeModelSlug(draftCandidate.model) : null;
206 const effortCandidate =
207 typeof draftCandidate.effort === "string" ? draftCandidate.effort : null;
208 const effort =
209 effortCandidate && REASONING_EFFORT_VALUES.has(effortCandidate as ReasoningEffort)
210 ? (effortCandidate as ReasoningEffort)
211 : null;
212 if (prompt.length === 0 && attachments.length === 0 && !model && !effort) {
213 continue;
214 }
215 nextDraftsByThreadId[threadId as ThreadId] = {
216 prompt,
217 cursor,
218 attachments,
219 ...(model ? { model } : {}),
220 ...(effort ? { effort } : {}),
221 };
222 }
223 return { draftsByThreadId: nextDraftsByThreadId };
224}
225
226function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null {
227 const [rawHeader, payload] = dataUrl.split(",", 2);
228 const header = rawHeader ?? "";
229 if (!payload) {
230 return null;
231 }
232 try {
233 const isBase64 = header.includes(";base64");
234 if (!isBase64) {
235 const decodedText = decodeURIComponent(payload);
236 const inferredMimeType =
237 header.startsWith("data:") && header.includes(";")
238 ? header.slice("data:".length, header.indexOf(";"))
239 : mimeType;
240 return new File([decodedText], name, { type: inferredMimeType || mimeType });
241 }
242 const binary = atob(payload);
243 const bytes = new Uint8Array(binary.length);
244 for (let index = 0; index < binary.length; index += 1) {
245 bytes[index] = binary.charCodeAt(index);
246 }
247 return new File([bytes], name, { type: mimeType });
248 } catch {
249 return null;
250 }
251}
252
253function hydrateImagesFromPersisted(
254 attachments: PersistedComposerImageAttachment[],
255): ComposerImageAttachment[] {
256 return attachments.flatMap((attachment) => {
257 const file = fileFromDataUrl(attachment.dataUrl, attachment.name, attachment.mimeType);
258 if (!file) {
259 return [];
260 }
261 return [
262 {
263 type: "image" as const,
264 id: attachment.id,
265 name: attachment.name,
266 mimeType: attachment.mimeType,
267 sizeBytes: attachment.sizeBytes,
268 previewUrl: attachment.dataUrl,
269 file,
270 } satisfies ComposerImageAttachment,
271 ];
272 });
273}
274
275function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreState {
276 if (typeof window === "undefined") {
277 return { draftsByThreadId: {} };
278 }
279 try {
280 const raw = window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY);
281 if (!raw) {
282 return { draftsByThreadId: {} };
283 }
284 const parsed = JSON.parse(raw) as unknown;
285 if (parsed && typeof parsed === "object" && "state" in parsed) {
286 return normalizePersistedComposerDraftState((parsed as { state?: unknown }).state);
287 }
288 return normalizePersistedComposerDraftState(parsed);
289 } catch {
290 return { draftsByThreadId: {} };
291 }
292}
293
294function toHydratedThreadDraft(
295 persistedDraft: PersistedComposerThreadDraftState,
296): ComposerThreadDraftState {
297 return {
298 prompt: persistedDraft.prompt,
299 cursor: persistedDraft.cursor,
300 images: hydrateImagesFromPersisted(persistedDraft.attachments),
301 nonPersistedImageIds: [],
302 persistedAttachments: persistedDraft.attachments,
303 model: persistedDraft.model ?? null,
304 effort: persistedDraft.effort ?? null,
305 };
306}
307
308export const useComposerDraftStore = create<ComposerDraftStoreState>()(
309 persist(
310 (set, get) => ({
311 draftsByThreadId: {},
312 setPrompt: (threadId, prompt) => {
313 if (threadId.length === 0) {
314 return;
315 }
316 set((state) => {
317 const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();
318 const cursor = Math.min(existing.cursor, prompt.length);
319 const nextDraft: ComposerThreadDraftState = {
320 ...existing,
321 prompt,
322 cursor,
323 };
324 const nextDraftsByThreadId = { ...state.draftsByThreadId };
325 if (shouldRemoveDraft(nextDraft)) {
326 delete nextDraftsByThreadId[threadId];
327 } else {
328 nextDraftsByThreadId[threadId] = nextDraft;
329 }
330 return { draftsByThreadId: nextDraftsByThreadId };
331 });
332 },
333 setCursor: (threadId, cursor) => {
334 if (threadId.length === 0) {
335 return;
336 }
337 const nextCursor = Number.isFinite(cursor) ? Math.max(0, Math.floor(cursor)) : 0;
338 set((state) => {
339 const existing = state.draftsByThreadId[threadId];
340 if (!existing && nextCursor === 0) {
341 return state;
342 }
343 const base = existing ?? createEmptyThreadDraft();
344 const boundedCursor = Math.min(nextCursor, base.prompt.length);
345 const nextDraft: ComposerThreadDraftState = {
346 ...base,
347 cursor: boundedCursor,
348 };
349 const nextDraftsByThreadId = { ...state.draftsByThreadId };
350 if (shouldRemoveDraft(nextDraft)) {
351 delete nextDraftsByThreadId[threadId];
352 } else {
353 nextDraftsByThreadId[threadId] = nextDraft;
354 }
355 return { draftsByThreadId: nextDraftsByThreadId };
356 });
357 },
358 setModel: (threadId, model) => {
359 if (threadId.length === 0) {
360 return;
361 }
362 const normalizedModel = normalizeModelSlug(model) ?? null;
363 set((state) => {
364 const existing = state.draftsByThreadId[threadId];
365 if (!existing && normalizedModel === null) {
366 return state;
367 }
368 const base = existing ?? createEmptyThreadDraft();
369 if (base.model === normalizedModel) {
370 return state;
371 }
372 const nextDraft: ComposerThreadDraftState = {
373 ...base,
374 model: normalizedModel,
375 };
376 const nextDraftsByThreadId = { ...state.draftsByThreadId };
377 if (shouldRemoveDraft(nextDraft)) {
378 delete nextDraftsByThreadId[threadId];
379 } else {
380 nextDraftsByThreadId[threadId] = nextDraft;
381 }
382 return { draftsByThreadId: nextDraftsByThreadId };
383 });
384 },
385 setEffort: (threadId, effort) => {
386 if (threadId.length === 0) {
387 return;
388 }
389 const nextEffort =
390 effort &&
391 REASONING_EFFORT_VALUES.has(effort) &&
392 effort !== DEFAULT_REASONING
393 ? effort
394 : null;
395 set((state) => {
396 const existing = state.draftsByThreadId[threadId];
397 if (!existing && nextEffort === null) {
398 return state;
399 }
400 const base = existing ?? createEmptyThreadDraft();
401 if (base.effort === nextEffort) {
402 return state;
403 }
404 const nextDraft: ComposerThreadDraftState = {
405 ...base,
406 effort: nextEffort,
407 };
408 const nextDraftsByThreadId = { ...state.draftsByThreadId };
409 if (shouldRemoveDraft(nextDraft)) {
410 delete nextDraftsByThreadId[threadId];
411 } else {
412 nextDraftsByThreadId[threadId] = nextDraft;
413 }
414 return { draftsByThreadId: nextDraftsByThreadId };
415 });
416 },
417 addImage: (threadId, image) => {
418 if (threadId.length === 0) {
419 return;
420 }
421 get().addImages(threadId, [image]);
422 },
423 addImages: (threadId, images) => {
424 if (threadId.length === 0 || images.length === 0) {
425 return;
426 }
427 set((state) => {
428 const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();
429 const existingIds = new Set(existing.images.map((image) => image.id));
430 const dedupedIncoming: ComposerImageAttachment[] = [];
431 for (const image of images) {
432 if (existingIds.has(image.id)) {
433 revokeObjectPreviewUrl(image.previewUrl);
434 continue;
435 }
436 dedupedIncoming.push(image);
437 existingIds.add(image.id);
438 }
439 if (dedupedIncoming.length === 0) {
440 return state;
441 }
442 return {
443 draftsByThreadId: {
444 ...state.draftsByThreadId,
445 [threadId]: {
446 ...existing,
447 images: [...existing.images, ...dedupedIncoming],
448 },
449 },
450 };
451 });
452 },
453 removeImage: (threadId, imageId) => {
454 if (threadId.length === 0) {
455 return;
456 }
457 const existing = get().draftsByThreadId[threadId];
458 if (!existing) {
459 return;
460 }
461 const removedImage = existing.images.find((image) => image.id === imageId);
462 if (removedImage) {
463 revokeObjectPreviewUrl(removedImage.previewUrl);
464 }
465 set((state) => {
466 const current = state.draftsByThreadId[threadId];
467 if (!current) {
468 return state;
469 }
470 const nextDraft: ComposerThreadDraftState = {
471 ...current,
472 images: current.images.filter((image) => image.id !== imageId),
473 nonPersistedImageIds: current.nonPersistedImageIds.filter((id) => id !== imageId),
474 persistedAttachments: current.persistedAttachments.filter(
475 (attachment) => attachment.id !== imageId,
476 ),
477 };
478 const nextDraftsByThreadId = { ...state.draftsByThreadId };
479 if (shouldRemoveDraft(nextDraft)) {
480 delete nextDraftsByThreadId[threadId];
481 } else {
482 nextDraftsByThreadId[threadId] = nextDraft;
483 }
484 return { draftsByThreadId: nextDraftsByThreadId };
485 });
486 },
487 clearPersistedAttachments: (threadId) => {
488 if (threadId.length === 0) {
489 return;
490 }
491 set((state) => {
492 const current = state.draftsByThreadId[threadId];
493 if (!current) {
494 return state;
495 }
496 const nextDraft: ComposerThreadDraftState = {
497 ...current,
498 persistedAttachments: [],
499 nonPersistedImageIds: [],
500 };
501 const nextDraftsByThreadId = { ...state.draftsByThreadId };
502 if (shouldRemoveDraft(nextDraft)) {
503 delete nextDraftsByThreadId[threadId];
504 } else {
505 nextDraftsByThreadId[threadId] = nextDraft;
506 }
507 return { draftsByThreadId: nextDraftsByThreadId };
508 });
509 },
510 syncPersistedAttachments: (threadId, attachments) => {
511 if (threadId.length === 0) {
512 return;
513 }
514 const attachmentIdSet = new Set(attachments.map((attachment) => attachment.id));
515 set((state) => {
516 const current = state.draftsByThreadId[threadId];
517 if (!current) {
518 return state;
519 }
520 const nextDraft: ComposerThreadDraftState = {
521 ...current,
522 // Stage attempted attachments so persist middleware can try writing them.
523 persistedAttachments: attachments,
524 nonPersistedImageIds: current.nonPersistedImageIds.filter((id) =>
525 !attachmentIdSet.has(id),
526 ),
527 };
528 const nextDraftsByThreadId = { ...state.draftsByThreadId };
529 if (shouldRemoveDraft(nextDraft)) {
530 delete nextDraftsByThreadId[threadId];
531 } else {
532 nextDraftsByThreadId[threadId] = nextDraft;
533 }
534 return { draftsByThreadId: nextDraftsByThreadId };
535 });
536 Promise.resolve().then(() => {
537 const persistedIdSet = new Set(readPersistedComposerDraftAttachmentIds(threadId));
538 set((state) => {
539 const current = state.draftsByThreadId[threadId];
540 if (!current) {
541 return state;
542 }
543 const imageIdSet = new Set(current.images.map((image) => image.id));
544 const persistedAttachments = attachments.filter(
545 (attachment) => imageIdSet.has(attachment.id) && persistedIdSet.has(attachment.id),
546 );
547 const nonPersistedImageIds = current.images
548 .map((image) => image.id)
549 .filter((imageId) => !persistedIdSet.has(imageId));
550 const nextDraft: ComposerThreadDraftState = {
551 ...current,
552 persistedAttachments,
553 nonPersistedImageIds,
554 };
555 const nextDraftsByThreadId = { ...state.draftsByThreadId };
556 if (shouldRemoveDraft(nextDraft)) {
557 delete nextDraftsByThreadId[threadId];
558 } else {
559 nextDraftsByThreadId[threadId] = nextDraft;
560 }
561 return { draftsByThreadId: nextDraftsByThreadId };
562 });
563 });
564 },
565 clearComposerContent: (threadId) => {
566 if (threadId.length === 0) {
567 return;
568 }
569 const existing = get().draftsByThreadId[threadId];
570 if (existing) {
571 for (const image of existing.images) {
572 revokeObjectPreviewUrl(image.previewUrl);
573 }
574 }
575 set((state) => {
576 const current = state.draftsByThreadId[threadId];
577 if (!current) {
578 return state;
579 }
580 const nextDraft: ComposerThreadDraftState = {
581 ...current,
582 prompt: "",
583 cursor: 0,
584 images: [],
585 nonPersistedImageIds: [],
586 persistedAttachments: [],
587 };
588 const nextDraftsByThreadId = { ...state.draftsByThreadId };
589 if (shouldRemoveDraft(nextDraft)) {
590 delete nextDraftsByThreadId[threadId];
591 } else {
592 nextDraftsByThreadId[threadId] = nextDraft;
593 }
594 return { draftsByThreadId: nextDraftsByThreadId };
595 });
596 },
597 clearThreadDraft: (threadId) => {
598 if (threadId.length === 0) {
599 return;
600 }
601 const existing = get().draftsByThreadId[threadId];
602 if (existing) {
603 for (const image of existing.images) {
604 revokeObjectPreviewUrl(image.previewUrl);
605 }
606 }
607 set((state) => {
608 if (state.draftsByThreadId[threadId] === undefined) {
609 return state;
610 }
611 const { [threadId]: _removed, ...rest } = state.draftsByThreadId;
612 return { draftsByThreadId: rest };
613 });
614 },
615 }),
616 {
617 name: COMPOSER_DRAFT_STORAGE_KEY,
618 version: 1,
619 storage: createJSONStorage(() => SAFE_LOCAL_STORAGE),
620 partialize: (state) => {
621 const persistedDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {};
622 for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) {
623 if (typeof threadId !== "string" || threadId.length === 0) {
624 continue;
625 }
626 if (
627 draft.prompt.length === 0 &&
628 draft.persistedAttachments.length === 0 &&
629 draft.model === null &&
630 draft.effort === null
631 ) {
632 continue;
633 }
634 const persistedDraft: PersistedComposerThreadDraftState = {
635 prompt: draft.prompt,
636 cursor: Math.min(Math.max(0, draft.cursor), draft.prompt.length),
637 attachments: draft.persistedAttachments,
638 };
639 if (draft.model) {
640 persistedDraft.model = draft.model;
641 }
642 if (draft.effort) {
643 persistedDraft.effort = draft.effort;
644 }
645 persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft;
646 }
647 return {
648 draftsByThreadId: persistedDraftsByThreadId,
649 };
650 },
651 merge: (persistedState, currentState) => {
652 const normalizedPersisted = normalizePersistedComposerDraftState(persistedState);
653 const draftsByThreadId = Object.fromEntries(
654 Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [
655 threadId,
656 toHydratedThreadDraft(draft),
657 ]),
658 );
659 return {
660 ...currentState,
661 draftsByThreadId,
662 };
663 },
664 },
665 ),
666);
667
668export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState {
669 return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT);
670}
671
672export function readPersistedComposerDraftAttachmentIds(threadId: ThreadId): string[] {
673 if (threadId.length === 0) {
674 return [];
675 }
676 return readPersistedComposerDraftAttachments(threadId).map((attachment) => attachment.id);
677}
678
679export function readPersistedComposerDraftAttachments(
680 threadId: ThreadId,
681): PersistedComposerImageAttachment[] {
682 if (threadId.length === 0) {
683 return [];
684 }
685 const persisted = readPersistedDraftStateFromLocalStorage();
686 return persisted.draftsByThreadId[threadId]?.attachments ?? [];
687}
apps/web/src/projectDraftThreads.ts created+91
...@@ -0,0 +1,91 @@
1import { ProjectId, ThreadId } from "@t3tools/contracts";
2
3const PROJECT_DRAFT_THREADS_STORAGE_KEY = "t3code:project-draft-threads:v1";
4
5type ProjectDraftThreadMap = Record<ProjectId, ThreadId>;
6
7function readAllProjectDraftThreads(): ProjectDraftThreadMap {
8 if (typeof window === "undefined") {
9 return {};
10 }
11 try {
12 const raw = window.localStorage.getItem(PROJECT_DRAFT_THREADS_STORAGE_KEY);
13 if (!raw) {
14 return {};
15 }
16 const parsed = JSON.parse(raw) as unknown;
17 if (!parsed || typeof parsed !== "object") {
18 return {};
19 }
20 const next: ProjectDraftThreadMap = {};
21 for (const [projectId, threadId] of Object.entries(parsed as Record<string, unknown>)) {
22 if (typeof projectId !== "string" || typeof threadId !== "string") {
23 continue;
24 }
25 if (projectId.length === 0 || threadId.length === 0) {
26 continue;
27 }
28 next[projectId as ProjectId] = threadId as ThreadId;
29 }
30 return next;
31 } catch {
32 return {};
33 }
34}
35
36function persistAllProjectDraftThreads(next: ProjectDraftThreadMap): void {
37 if (typeof window === "undefined") {
38 return;
39 }
40 try {
41 const entries = Object.entries(next);
42 if (entries.length === 0) {
43 window.localStorage.removeItem(PROJECT_DRAFT_THREADS_STORAGE_KEY);
44 return;
45 }
46 window.localStorage.setItem(PROJECT_DRAFT_THREADS_STORAGE_KEY, JSON.stringify(next));
47 } catch {
48 // Best-effort persistence only.
49 }
50}
51
52export function readProjectDraftThreadId(projectId: ProjectId): ThreadId | null {
53 if (projectId.length === 0) {
54 return null;
55 }
56 const all = readAllProjectDraftThreads();
57 return all[projectId] ?? null;
58}
59
60export function writeProjectDraftThreadId(projectId: ProjectId, threadId: ThreadId): void {
61 if (projectId.length === 0 || threadId.length === 0) {
62 return;
63 }
64 const all = readAllProjectDraftThreads();
65 all[projectId] = threadId;
66 persistAllProjectDraftThreads(all);
67}
68
69export function clearProjectDraftThreadId(projectId: ProjectId): void {
70 if (projectId.length === 0) {
71 return;
72 }
73 const all = readAllProjectDraftThreads();
74 if (all[projectId] === undefined) {
75 return;
76 }
77 const { [projectId]: _removed, ...rest } = all;
78 persistAllProjectDraftThreads(rest);
79}
80
81export function clearProjectDraftThreadById(projectId: ProjectId, threadId: ThreadId): void {
82 if (projectId.length === 0 || threadId.length === 0) {
83 return;
84 }
85 const all = readAllProjectDraftThreads();
86 if (all[projectId] !== threadId) {
87 return;
88 }
89 const { [projectId]: _removed, ...rest } = all;
90 persistAllProjectDraftThreads(rest);
91}
bun.lock+3
...@@ -79,6 +79,7 @@...@@ -79,6 +79,7 @@
79 "react-markdown": "^10.1.0",79 "react-markdown": "^10.1.0",
80 "remark-gfm": "^4.0.1",80 "remark-gfm": "^4.0.1",
81 "tailwind-merge": "^3.4.0",81 "tailwind-merge": "^3.4.0",
82 "zustand": "^5.0.11",
82 },83 },
83 "devDependencies": {84 "devDependencies": {
84 "@effect/language-service": "catalog:",85 "@effect/language-service": "catalog:",
...@@ -1417,6 +1418,8 @@...@@ -1417,6 +1418,8 @@
14171418
1418 "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],1419 "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
14191420
1421 "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="],
1422
1420 "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],1423 "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
14211424
1422 "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],1425 "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
packages/contracts/src/model.ts+3
...@@ -11,8 +11,11 @@ export type ModelSlug = string;...@@ -11,8 +11,11 @@ export type ModelSlug = string;
11export const DEFAULT_MODEL = "gpt-5.3-codex";11export const DEFAULT_MODEL = "gpt-5.3-codex";
1212
13export const MODEL_SLUG_ALIASES: Record<string, BuiltInModelSlug> = {13export const MODEL_SLUG_ALIASES: Record<string, BuiltInModelSlug> = {
14 "gpt-5": "gpt-5.3-codex",
15 "gpt-5-codex": "gpt-5.3-codex",
14 "5.3": "gpt-5.3-codex",16 "5.3": "gpt-5.3-codex",
15 "gpt-5.3": "gpt-5.3-codex",17 "gpt-5.3": "gpt-5.3-codex",
18 "gpt-5-codex-spark": "gpt-5.3-codex-spark",
16 "5.3-spark": "gpt-5.3-codex-spark",19 "5.3-spark": "gpt-5.3-codex-spark",
17 "gpt-5.3-spark": "gpt-5.3-codex-spark",20 "gpt-5.3-spark": "gpt-5.3-codex-spark",
18};21};