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 @@
2727 "react-dom": "^19.0.0",
2828 "react-markdown": "^10.1.0",
2929 "remark-gfm": "^4.0.1",
30 "tailwind-merge": "^3.4.0"
30 "tailwind-merge": "^3.4.0",
31 "zustand": "^5.0.11"
3132 },
3233 "devDependencies": {
3334 "@effect/language-service": "catalog:",
apps/web/src/components/ChatView.tsx+226-49
......@@ -53,7 +53,6 @@ import {
5353 DEFAULT_THREAD_TERMINAL_ID,
5454 MAX_THREAD_TERMINAL_COUNT,
5555 type ChatMessage,
56 type ChatImageAttachment,
5756 type TurnDiffSummary,
5857} from "../types";
5958import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";
......@@ -111,6 +110,14 @@ import { SidebarTrigger } from "./ui/sidebar";
111110import { newCommandId, newMessageId } from "~/lib/utils";
112111import { readNativeApi } from "~/nativeApi";
113112import { getAppModelOptions, useAppSettings } from "../appSettings";
113import {
114 type ComposerImageAttachment,
115 type PersistedComposerImageAttachment,
116 readPersistedComposerDraftAttachments,
117 useComposerDraftStore,
118 useComposerThreadDraft,
119} from "../composerDraftStore";
120import { clearProjectDraftThreadById } from "../projectDraftThreads";
114121import { clamp } from "effect/Number";
115122
116123function formatMessageMeta(createdAt: string, duration: string | null): string {
......@@ -130,6 +137,8 @@ const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
130137const SCRIPT_TERMINAL_COLS = 120;
131138const SCRIPT_TERMINAL_ROWS = 30;
132139const WORKTREE_BRANCH_PREFIX = "t3code";
140const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
141const ENABLE_DRAFT_PERSIST_DEBUG_LOGS = import.meta.env.DEV;
133142
134143function readLastInvokedScriptByProjectFromStorage(): Record<string, string> {
135144 const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY);
......@@ -156,11 +165,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
156165 return "text-muted-foreground/40";
157166}
158167
159interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> {
160 previewUrl: string;
161 file: File;
162}
163
164168interface ExpandedImageItem {
165169 src: string;
166170 name: string;
......@@ -239,6 +243,25 @@ function buildTemporaryWorktreeBranchName(): string {
239243 return `${WORKTREE_BRANCH_PREFIX}/${token}`;
240244}
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
242265const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
243266 pathValue: string;
244267 kind: "file" | "directory";
......@@ -372,23 +395,38 @@ export default function ChatView({ threadId }: ChatViewProps) {
372395 const { resolvedTheme } = useTheme();
373396 const queryClient = useQueryClient();
374397 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);
376417 const promptRef = useRef(prompt);
377 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
378418 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
379419 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
380420 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
381421 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
382422 const [isConnecting, _setIsConnecting] = useState(false);
383423 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
384 const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
385424 const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
386425 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
387426 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
388427 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
389428 const [nowTick, setNowTick] = useState(() => Date.now());
390429 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
391 const [composerCursor, setComposerCursor] = useState(0);
392430 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
393431 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState<
394432 Record<string, string>
......@@ -402,6 +440,37 @@ export default function ChatView({ threadId }: ChatViewProps) {
402440 const dragDepthRef = useRef(0);
403441 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
405474 const activeThread = state.threads.find((t) => t.id === threadId);
406475 const diffSearch = useMemo(
407476 () => parseDiffRouteSearch(rawSearch as Record<string, unknown>),
......@@ -435,8 +504,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
435504 ]);
436505
437506 const selectedModel = resolveModelSlug(
438 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
507 composerDraft.model ?? activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
439508 );
509 const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING;
440510 const modelOptions = useMemo(
441511 () => getAppModelOptions(settings.customCodexModels, selectedModel),
442512 [selectedModel, settings.customCodexModels],
......@@ -645,6 +715,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
645715 null,
646716 [composerHighlightedItemId, composerMenuItems],
647717 );
718 const nonPersistedComposerImageIdSet = useMemo(
719 () => new Set(nonPersistedComposerImageIds),
720 [nonPersistedComposerImageIds],
721 );
648722 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;
649723 const threadTerminalRuntimeEnv = useMemo(() => {
650724 if (!activeProject?.cwd) return {};
......@@ -693,12 +767,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
693767 const hasReachedTerminalLimit =
694768 (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 }, []);
702770 const focusComposer = useCallback(() => {
703771 const textarea = textareaRef.current;
704772 if (!textarea) return;
......@@ -1088,26 +1156,115 @@ export default function ChatView({ threadId }: ChatViewProps) {
10881156 }, [activeThread?.id, activeThread?.messages]);
10891157
10901158 useEffect(() => {
1091 setComposerImages((existing) => {
1092 revokePreviewUrls(existing);
1093 return [];
1094 });
1159 promptRef.current = prompt;
1160 }, [prompt]);
1161
1162 useEffect(() => {
10951163 setOptimisticUserMessages([]);
1096 setPrompt("");
1097 promptRef.current = "";
10981164 setSendPhase("idle");
1099 setComposerCursor(0);
11001165 setComposerHighlightedItemId(null);
11011166 dragDepthRef.current = 0;
11021167 setIsDragOverComposer(false);
11031168 setExpandedImage(null);
1104 }, [activeThread?.id, revokePreviewUrls]);
1169 }, [threadId]);
11051170
11061171 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 })();
11071259 return () => {
1108 revokePreviewUrls(composerImagesRef.current);
1260 cancelled = true;
11091261 };
1110 }, [revokePreviewUrls]);
1262 }, [
1263 clearComposerDraftPersistedAttachments,
1264 composerImages,
1265 syncComposerDraftPersistedAttachments,
1266 threadId,
1267 ]);
11111268
11121269 const closeExpandedImage = useCallback(() => {
11131270 setExpandedImage(null);
......@@ -1310,7 +1467,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
13101467 const addComposerImages = (files: File[]) => {
13111468 if (!activeThreadId || files.length === 0) return;
13121469
1313 const nextImages = [...composerImagesRef.current];
1470 const nextImages: ComposerImageAttachment[] = [];
1471 let nextImageCount = composerImagesRef.current.length;
13141472 let error: string | null = null;
13151473 for (const file of files) {
13161474 if (!file.type.startsWith("image/")) {
......@@ -1321,7 +1479,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
13211479 error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`;
13221480 continue;
13231481 }
1324 if (nextImages.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
1482 if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
13251483 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`;
13261484 break;
13271485 }
......@@ -1336,20 +1494,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
13361494 previewUrl,
13371495 file,
13381496 });
1497 nextImageCount += 1;
13391498 }
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 }
13421505 setThreadError(activeThreadId, error);
13431506 };
13441507
13451508 const removeComposerImage = (imageId: string) => {
1346 setComposerImages((existing) => {
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 });
1509 removeComposerImageFromDraft(imageId);
13531510 };
13541511
13551512 const onComposerPaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
......@@ -1516,9 +1673,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
15161673
15171674 setThreadError(threadIdForSend, null);
15181675 promptRef.current = "";
1519 setPrompt("");
1520 setComposerImages([]);
1521 setComposerCursor(0);
1676 clearComposerDraftContent(threadIdForSend);
15221677 setComposerHighlightedItemId(null);
15231678
15241679 let attemptedTurnStart = false;
......@@ -1596,6 +1751,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
15961751 sandboxMode,
15971752 createdAt: messageCreatedAt,
15981753 });
1754 if (isFirstMessage) {
1755 clearProjectDraftThreadById(activeProject.id, threadIdForSend);
1756 }
15991757 } catch (err) {
16001758 if (
16011759 !attemptedTurnStart &&
......@@ -1607,7 +1765,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
16071765 );
16081766 promptRef.current = trimmed;
16091767 setPrompt(trimmed);
1610 setComposerImages(composerImagesSnapshot);
1768 addComposerImagesToDraft(composerImagesSnapshot);
16111769 setComposerCursor(trimmed.length);
16121770 }
16131771 setThreadError(
......@@ -1663,26 +1821,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
16631821
16641822 const onModelSelect = useCallback(
16651823 (model: string) => {
1824 const normalizedModel = resolveModelSlug(model);
1825 setComposerDraftModel(threadId, normalizedModel);
16661826 const api = readNativeApi();
1667 if (!activeThread) return;
1668 if (api) {
1827 if (api && activeThread) {
16691828 void api.orchestration.dispatchCommand({
16701829 type: "thread.meta.update",
16711830 commandId: newCommandId(),
16721831 threadId: activeThread.id,
1673 model: resolveModelSlug(model),
1832 model: normalizedModel,
16741833 });
16751834 }
16761835 scheduleComposerFocus();
16771836 },
1678 [activeThread, scheduleComposerFocus],
1837 [activeThread, scheduleComposerFocus, setComposerDraftModel, threadId],
16791838 );
16801839 const onEffortSelect = useCallback(
16811840 (effort: ReasoningEffort) => {
1682 setSelectedEffort(effort);
1841 setComposerDraftEffort(threadId, effort);
16831842 scheduleComposerFocus();
16841843 },
1685 [scheduleComposerFocus],
1844 [scheduleComposerFocus, setComposerDraftEffort, threadId],
16861845 );
16871846 const onEnvModeChange = useCallback(
16881847 (mode: "local" | "worktree") => {
......@@ -1705,7 +1864,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
17051864 setComposerCursor(next.cursor);
17061865 });
17071866 },
1708 [],
1867 [setComposerCursor, setPrompt],
17091868 );
17101869
17111870 const onSelectComposerItem = useCallback(
......@@ -1748,7 +1907,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
17481907 promptRef.current = nextPrompt;
17491908 setPrompt(nextPrompt);
17501909 setComposerCursor(nextCursor);
1751 }, []);
1910 }, [setComposerCursor, setPrompt]);
17521911
17531912 const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
17541913 if (composerMenuOpen && composerMenuItems.length > 0) {
......@@ -1960,6 +2119,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
19602119 {image.name}
19612120 </div>
19622121 )}
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 )}
19632140 <Button
19642141 variant="ghost"
19652142 size="icon-xs"
apps/web/src/components/Sidebar.tsx+72-6
......@@ -20,6 +20,13 @@ import { derivePendingApprovals } from "../session-logic";
2020import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
2121import { serverConfigQueryOptions } from "../lib/serverReactQuery";
2222import { readNativeApi } from "../nativeApi";
23import { useComposerDraftStore } from "../composerDraftStore";
24import {
25 clearProjectDraftThreadById,
26 clearProjectDraftThreadId,
27 readProjectDraftThreadId,
28 writeProjectDraftThreadId,
29} from "../projectDraftThreads";
2330import { toastManager } from "./ui/toast";
2431import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
2532import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
......@@ -74,6 +81,10 @@ interface PrStatusIndicator {
7481
7582type ThreadPr = GitStatusResult["pr"];
7683
84function shouldShowThreadInSidebar(thread: Thread): boolean {
85 return thread.messages.some((message) => message.role === "user");
86}
87
7788function hasUnseenCompletion(thread: Thread): boolean {
7889 if (!thread.latestTurn?.completedAt) return false;
7990 const completedAt = Date.parse(thread.latestTurn.completedAt);
......@@ -228,6 +239,7 @@ function ProjectFavicon({ cwd }: { cwd: string }) {
228239
229240export default function Sidebar() {
230241 const { state, dispatch } = useStore();
242 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);
231243 const navigate = useNavigate();
232244 const { settings: appSettings } = useAppSettings();
233245 const routeThreadId = useParams({
......@@ -336,6 +348,42 @@ export default function Sidebar() {
336348 ): Promise<void> => {
337349 const api = readNativeApi();
338350 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 }
339387 const threadId = newThreadId();
340388 const createdAt = new Date().toISOString();
341389 const model =
......@@ -352,6 +400,7 @@ export default function Sidebar() {
352400 worktreePath: options?.worktreePath ?? null,
353401 createdAt,
354402 });
403 writeProjectDraftThreadId(projectId, threadId);
355404
356405 // Ensure route guards can see the new thread before navigating.
357406 try {
......@@ -367,7 +416,7 @@ export default function Sidebar() {
367416 });
368417 })();
369418 },
370 [dispatch, navigate, state.projects],
419 [dispatch, navigate, routeThreadId, state.projects, state.threads],
371420 );
372421
373422 const focusMostRecentThreadForProject = useCallback(
......@@ -517,6 +566,8 @@ export default function Sidebar() {
517566 commandId: newCommandId(),
518567 threadId,
519568 });
569 clearComposerDraftForThread(threadId);
570 clearProjectDraftThreadById(thread.projectId, thread.id);
520571 if (shouldNavigateToFallback) {
521572 if (fallbackThreadId) {
522573 void navigate({
......@@ -556,6 +607,7 @@ export default function Sidebar() {
556607 },
557608 [
558609 appSettings.confirmThreadDelete,
610 clearComposerDraftForThread,
559611 dispatch,
560612 navigate,
561613 removeWorktreeMutation,
......@@ -576,7 +628,9 @@ export default function Sidebar() {
576628 if (!project) return;
577629
578630 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) {
580634 toastManager.add({
581635 type: "warning",
582636 title: "Project is not empty",
......@@ -591,6 +645,15 @@ export default function Sidebar() {
591645 if (!confirmed) return;
592646
593647 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);
594657 await api.orchestration.dispatchCommand({
595658 type: "project.delete",
596659 commandId: newCommandId(),
......@@ -606,7 +669,7 @@ export default function Sidebar() {
606669 });
607670 }
608671 },
609 [state.projects, state.threads],
672 [clearComposerDraftForThread, state.projects, state.threads],
610673 );
611674
612675 useEffect(() => {
......@@ -693,8 +756,9 @@ export default function Sidebar() {
693756 <SidebarGroup className="px-2 py-2">
694757 <SidebarMenu>
695758 {state.projects.map((project) => {
696 const threads = state.threads
759 const visibleThreads = state.threads
697760 .filter((thread) => thread.projectId === project.id)
761 .filter(shouldShowThreadInSidebar)
698762 .toSorted((a, b) => {
699763 const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
700764 if (byDate !== 0) return byDate;
......@@ -736,12 +800,14 @@ export default function Sidebar() {
736800 <span className="flex-1 truncate text-xs font-medium text-foreground/90">
737801 {project.name}
738802 </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>
740806 </CollapsibleTrigger>
741807
742808 <CollapsibleContent>
743809 <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) => {
745811 const isActive = routeThreadId === thread.id;
746812 const threadStatus = threadStatusPill(
747813 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 @@
7979 "react-markdown": "^10.1.0",
8080 "remark-gfm": "^4.0.1",
8181 "tailwind-merge": "^3.4.0",
82 "zustand": "^5.0.11",
8283 },
8384 "devDependencies": {
8485 "@effect/language-service": "catalog:",
......@@ -1417,6 +1418,8 @@
14171418
14181419 "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
14201423 "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
14211424
14221425 "@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;
1111export const DEFAULT_MODEL = "gpt-5.3-codex";
1212
1313export const MODEL_SLUG_ALIASES: Record<string, BuiltInModelSlug> = {
14 "gpt-5": "gpt-5.3-codex",
15 "gpt-5-codex": "gpt-5.3-codex",
1416 "5.3": "gpt-5.3-codex",
1517 "gpt-5.3": "gpt-5.3-codex",
18 "gpt-5-codex-spark": "gpt-5.3-codex-spark",
1619 "5.3-spark": "gpt-5.3-codex-spark",
1720 "gpt-5.3-spark": "gpt-5.3-codex-spark",
1821};