| author | |
| committer | |
| log | 545a9193e2add9995bb03ccec39538e5315349b6 |
| tree | 5359d154ae8a67c758e94aa7ff1c3bea710cf378 |
| parent | fa0205cba3f2a8dffccee2c0348cb334a7bb6b6f |
- 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 lists7 files changed, 1084 insertions(+), 56 deletions(-)
apps/web/package.json+2-1| ... | ... | @@ -27,7 +27,8 @@ |
| 27 | 27 | "react-dom": "^19.0.0", |
| 28 | 28 | "react-markdown": "^10.1.0", |
| 29 | 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 | 33 | "devDependencies": { |
| 33 | 34 | "@effect/language-service": "catalog:", |
apps/web/src/components/ChatView.tsx+226-49| ... | ... | @@ -53,7 +53,6 @@ import { |
| 53 | 53 | DEFAULT_THREAD_TERMINAL_ID, |
| 54 | 54 | MAX_THREAD_TERMINAL_COUNT, |
| 55 | 55 | type ChatMessage, |
| 56 | type ChatImageAttachment, | |
| 57 | 56 | type TurnDiffSummary, |
| 58 | 57 | } from "../types"; |
| 59 | 58 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; |
| ... | ... | @@ -111,6 +110,14 @@ import { SidebarTrigger } from "./ui/sidebar"; |
| 111 | 110 | import { newCommandId, newMessageId } from "~/lib/utils"; |
| 112 | 111 | import { readNativeApi } from "~/nativeApi"; |
| 113 | 112 | import { getAppModelOptions, useAppSettings } from "../appSettings"; |
| 113 | import { | |
| 114 | type ComposerImageAttachment, | |
| 115 | type PersistedComposerImageAttachment, | |
| 116 | readPersistedComposerDraftAttachments, | |
| 117 | useComposerDraftStore, | |
| 118 | useComposerThreadDraft, | |
| 119 | } from "../composerDraftStore"; | |
| 120 | import { clearProjectDraftThreadById } from "../projectDraftThreads"; | |
| 114 | 121 | import { clamp } from "effect/Number"; |
| 115 | 122 | |
| 116 | 123 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| ... | ... | @@ -130,6 +137,8 @@ const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; |
| 130 | 137 | const SCRIPT_TERMINAL_COLS = 120; |
| 131 | 138 | const SCRIPT_TERMINAL_ROWS = 30; |
| 132 | 139 | const WORKTREE_BRANCH_PREFIX = "t3code"; |
| 140 | const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; | |
| 141 | const ENABLE_DRAFT_PERSIST_DEBUG_LOGS = import.meta.env.DEV; | |
| 133 | 142 | |
| 134 | 143 | function readLastInvokedScriptByProjectFromStorage(): Record<string, string> { |
| 135 | 144 | const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); |
| ... | ... | @@ -156,11 +165,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 156 | 165 | return "text-muted-foreground/40"; |
| 157 | 166 | } |
| 158 | 167 | |
| 159 | interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> { | |
| 160 | previewUrl: string; | |
| 161 | file: File; | |
| 162 | } | |
| 163 | ||
| 164 | 168 | interface ExpandedImageItem { |
| 165 | 169 | src: string; |
| 166 | 170 | name: string; |
| ... | ... | @@ -239,6 +243,25 @@ function buildTemporaryWorktreeBranchName(): string { |
| 239 | 243 | return `${WORKTREE_BRANCH_PREFIX}/${token}`; |
| 240 | 244 | } |
| 241 | 245 | |
| 246 | function 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 | ||
| 258 | function 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 | ||
| 242 | 265 | const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { |
| 243 | 266 | pathValue: string; |
| 244 | 267 | kind: "file" | "directory"; |
| ... | ... | @@ -372,23 +395,38 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 372 | 395 | const { resolvedTheme } = useTheme(); |
| 373 | 396 | const queryClient = useQueryClient(); |
| 374 | 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 | 417 | const promptRef = useRef(prompt); |
| 377 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); | |
| 378 | 418 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| 379 | 419 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| 380 | 420 | const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); |
| 381 | 421 | const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); |
| 382 | 422 | const [isConnecting, _setIsConnecting] = useState(false); |
| 383 | 423 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| 384 | const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); | |
| 385 | 424 | const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); |
| 386 | 425 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); |
| 387 | 426 | const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]); |
| 388 | 427 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); |
| 389 | 428 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 390 | 429 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 391 | const [composerCursor, setComposerCursor] = useState(0); | |
| 392 | 430 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); |
| 393 | 431 | const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< |
| 394 | 432 | Record<string, string> |
| ... | ... | @@ -402,6 +440,37 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 402 | 440 | const dragDepthRef = useRef(0); |
| 403 | 441 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| 404 | 442 | |
| 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 | 474 | const activeThread = state.threads.find((t) => t.id === threadId); |
| 406 | 475 | const diffSearch = useMemo( |
| 407 | 476 | () => parseDiffRouteSearch(rawSearch as Record<string, unknown>), |
| ... | ... | @@ -435,8 +504,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 435 | 504 | ]); |
| 436 | 505 | |
| 437 | 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 | 510 | const modelOptions = useMemo( |
| 441 | 511 | () => getAppModelOptions(settings.customCodexModels, selectedModel), |
| 442 | 512 | [selectedModel, settings.customCodexModels], |
| ... | ... | @@ -645,6 +715,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 645 | 715 | null, |
| 646 | 716 | [composerHighlightedItemId, composerMenuItems], |
| 647 | 717 | ); |
| 718 | const nonPersistedComposerImageIdSet = useMemo( | |
| 719 | () => new Set(nonPersistedComposerImageIds), | |
| 720 | [nonPersistedComposerImageIds], | |
| 721 | ); | |
| 648 | 722 | const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; |
| 649 | 723 | const threadTerminalRuntimeEnv = useMemo(() => { |
| 650 | 724 | if (!activeProject?.cwd) return {}; |
| ... | ... | @@ -693,12 +767,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 693 | 767 | const hasReachedTerminalLimit = |
| 694 | 768 | (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT; |
| 695 | 769 | |
| 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 | 770 | const focusComposer = useCallback(() => { |
| 703 | 771 | const textarea = textareaRef.current; |
| 704 | 772 | if (!textarea) return; |
| ... | ... | @@ -1088,26 +1156,115 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1088 | 1156 | }, [activeThread?.id, activeThread?.messages]); |
| 1089 | 1157 | |
| 1090 | 1158 | useEffect(() => { |
| 1091 | setComposerImages((existing) => { | |
| 1092 | revokePreviewUrls(existing); | |
| 1093 | return []; | |
| 1094 | }); | |
| 1159 | promptRef.current = prompt; | |
| 1160 | }, [prompt]); | |
| 1161 | ||
| 1162 | useEffect(() => { | |
| 1095 | 1163 | setOptimisticUserMessages([]); |
| 1096 | setPrompt(""); | |
| 1097 | promptRef.current = ""; | |
| 1098 | 1164 | setSendPhase("idle"); |
| 1099 | setComposerCursor(0); | |
| 1100 | 1165 | setComposerHighlightedItemId(null); |
| 1101 | 1166 | dragDepthRef.current = 0; |
| 1102 | 1167 | setIsDragOverComposer(false); |
| 1103 | 1168 | setExpandedImage(null); |
| 1104 | }, [activeThread?.id, revokePreviewUrls]); | |
| 1169 | }, [threadId]); | |
| 1105 | 1170 | |
| 1106 | 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 | 1259 | return () => { |
| 1108 | revokePreviewUrls(composerImagesRef.current); | |
| 1260 | cancelled = true; | |
| 1109 | 1261 | }; |
| 1110 | }, [revokePreviewUrls]); | |
| 1262 | }, [ | |
| 1263 | clearComposerDraftPersistedAttachments, | |
| 1264 | composerImages, | |
| 1265 | syncComposerDraftPersistedAttachments, | |
| 1266 | threadId, | |
| 1267 | ]); | |
| 1111 | 1268 | |
| 1112 | 1269 | const closeExpandedImage = useCallback(() => { |
| 1113 | 1270 | setExpandedImage(null); |
| ... | ... | @@ -1310,7 +1467,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1310 | 1467 | const addComposerImages = (files: File[]) => { |
| 1311 | 1468 | if (!activeThreadId || files.length === 0) return; |
| 1312 | 1469 | |
| 1313 | const nextImages = [...composerImagesRef.current]; | |
| 1470 | const nextImages: ComposerImageAttachment[] = []; | |
| 1471 | let nextImageCount = composerImagesRef.current.length; | |
| 1314 | 1472 | let error: string | null = null; |
| 1315 | 1473 | for (const file of files) { |
| 1316 | 1474 | if (!file.type.startsWith("image/")) { |
| ... | ... | @@ -1321,7 +1479,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1321 | 1479 | error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; |
| 1322 | 1480 | continue; |
| 1323 | 1481 | } |
| 1324 | if (nextImages.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { | |
| 1482 | if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { | |
| 1325 | 1483 | error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; |
| 1326 | 1484 | break; |
| 1327 | 1485 | } |
| ... | ... | @@ -1336,20 +1494,19 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1336 | 1494 | previewUrl, |
| 1337 | 1495 | file, |
| 1338 | 1496 | }); |
| 1497 | nextImageCount += 1; | |
| 1339 | 1498 | } |
| 1340 | 1499 | |
| 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 | 1505 | setThreadError(activeThreadId, error); |
| 1343 | 1506 | }; |
| 1344 | 1507 | |
| 1345 | 1508 | 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); | |
| 1353 | 1510 | }; |
| 1354 | 1511 | |
| 1355 | 1512 | const onComposerPaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => { |
| ... | ... | @@ -1516,9 +1673,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1516 | 1673 | |
| 1517 | 1674 | setThreadError(threadIdForSend, null); |
| 1518 | 1675 | promptRef.current = ""; |
| 1519 | setPrompt(""); | |
| 1520 | setComposerImages([]); | |
| 1521 | setComposerCursor(0); | |
| 1676 | clearComposerDraftContent(threadIdForSend); | |
| 1522 | 1677 | setComposerHighlightedItemId(null); |
| 1523 | 1678 | |
| 1524 | 1679 | let attemptedTurnStart = false; |
| ... | ... | @@ -1596,6 +1751,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1596 | 1751 | sandboxMode, |
| 1597 | 1752 | createdAt: messageCreatedAt, |
| 1598 | 1753 | }); |
| 1754 | if (isFirstMessage) { | |
| 1755 | clearProjectDraftThreadById(activeProject.id, threadIdForSend); | |
| 1756 | } | |
| 1599 | 1757 | } catch (err) { |
| 1600 | 1758 | if ( |
| 1601 | 1759 | !attemptedTurnStart && |
| ... | ... | @@ -1607,7 +1765,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1607 | 1765 | ); |
| 1608 | 1766 | promptRef.current = trimmed; |
| 1609 | 1767 | setPrompt(trimmed); |
| 1610 | setComposerImages(composerImagesSnapshot); | |
| 1768 | addComposerImagesToDraft(composerImagesSnapshot); | |
| 1611 | 1769 | setComposerCursor(trimmed.length); |
| 1612 | 1770 | } |
| 1613 | 1771 | setThreadError( |
| ... | ... | @@ -1663,26 +1821,27 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1663 | 1821 | |
| 1664 | 1822 | const onModelSelect = useCallback( |
| 1665 | 1823 | (model: string) => { |
| 1824 | const normalizedModel = resolveModelSlug(model); | |
| 1825 | setComposerDraftModel(threadId, normalizedModel); | |
| 1666 | 1826 | const api = readNativeApi(); |
| 1667 | if (!activeThread) return; | |
| 1668 | if (api) { | |
| 1827 | if (api && activeThread) { | |
| 1669 | 1828 | void api.orchestration.dispatchCommand({ |
| 1670 | 1829 | type: "thread.meta.update", |
| 1671 | 1830 | commandId: newCommandId(), |
| 1672 | 1831 | threadId: activeThread.id, |
| 1673 | model: resolveModelSlug(model), | |
| 1832 | model: normalizedModel, | |
| 1674 | 1833 | }); |
| 1675 | 1834 | } |
| 1676 | 1835 | scheduleComposerFocus(); |
| 1677 | 1836 | }, |
| 1678 | [activeThread, scheduleComposerFocus], | |
| 1837 | [activeThread, scheduleComposerFocus, setComposerDraftModel, threadId], | |
| 1679 | 1838 | ); |
| 1680 | 1839 | const onEffortSelect = useCallback( |
| 1681 | 1840 | (effort: ReasoningEffort) => { |
| 1682 | setSelectedEffort(effort); | |
| 1841 | setComposerDraftEffort(threadId, effort); | |
| 1683 | 1842 | scheduleComposerFocus(); |
| 1684 | 1843 | }, |
| 1685 | [scheduleComposerFocus], | |
| 1844 | [scheduleComposerFocus, setComposerDraftEffort, threadId], | |
| 1686 | 1845 | ); |
| 1687 | 1846 | const onEnvModeChange = useCallback( |
| 1688 | 1847 | (mode: "local" | "worktree") => { |
| ... | ... | @@ -1705,7 +1864,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1705 | 1864 | setComposerCursor(next.cursor); |
| 1706 | 1865 | }); |
| 1707 | 1866 | }, |
| 1708 | [], | |
| 1867 | [setComposerCursor, setPrompt], | |
| 1709 | 1868 | ); |
| 1710 | 1869 | |
| 1711 | 1870 | const onSelectComposerItem = useCallback( |
| ... | ... | @@ -1748,7 +1907,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1748 | 1907 | promptRef.current = nextPrompt; |
| 1749 | 1908 | setPrompt(nextPrompt); |
| 1750 | 1909 | setComposerCursor(nextCursor); |
| 1751 | }, []); | |
| 1910 | }, [setComposerCursor, setPrompt]); | |
| 1752 | 1911 | |
| 1753 | 1912 | const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { |
| 1754 | 1913 | if (composerMenuOpen && composerMenuItems.length > 0) { |
| ... | ... | @@ -1960,6 +2119,24 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1960 | 2119 | {image.name} |
| 1961 | 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 | 2140 | <Button |
| 1964 | 2141 | variant="ghost" |
| 1965 | 2142 | size="icon-xs" |
apps/web/src/components/Sidebar.tsx+72-6| ... | ... | @@ -20,6 +20,13 @@ import { derivePendingApprovals } from "../session-logic"; |
| 20 | 20 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; |
| 21 | 21 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 22 | 22 | import { readNativeApi } from "../nativeApi"; |
| 23 | import { useComposerDraftStore } from "../composerDraftStore"; | |
| 24 | import { | |
| 25 | clearProjectDraftThreadById, | |
| 26 | clearProjectDraftThreadId, | |
| 27 | readProjectDraftThreadId, | |
| 28 | writeProjectDraftThreadId, | |
| 29 | } from "../projectDraftThreads"; | |
| 23 | 30 | import { toastManager } from "./ui/toast"; |
| 24 | 31 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; |
| 25 | 32 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| ... | ... | @@ -74,6 +81,10 @@ interface PrStatusIndicator { |
| 74 | 81 | |
| 75 | 82 | type ThreadPr = GitStatusResult["pr"]; |
| 76 | 83 | |
| 84 | function shouldShowThreadInSidebar(thread: Thread): boolean { | |
| 85 | return thread.messages.some((message) => message.role === "user"); | |
| 86 | } | |
| 87 | ||
| 77 | 88 | function hasUnseenCompletion(thread: Thread): boolean { |
| 78 | 89 | if (!thread.latestTurn?.completedAt) return false; |
| 79 | 90 | const completedAt = Date.parse(thread.latestTurn.completedAt); |
| ... | ... | @@ -228,6 +239,7 @@ function ProjectFavicon({ cwd }: { cwd: string }) { |
| 228 | 239 | |
| 229 | 240 | export default function Sidebar() { |
| 230 | 241 | const { state, dispatch } = useStore(); |
| 242 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); | |
| 231 | 243 | const navigate = useNavigate(); |
| 232 | 244 | const { settings: appSettings } = useAppSettings(); |
| 233 | 245 | const routeThreadId = useParams({ |
| ... | ... | @@ -336,6 +348,42 @@ export default function Sidebar() { |
| 336 | 348 | ): Promise<void> => { |
| 337 | 349 | const api = readNativeApi(); |
| 338 | 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 | 387 | const threadId = newThreadId(); |
| 340 | 388 | const createdAt = new Date().toISOString(); |
| 341 | 389 | const model = |
| ... | ... | @@ -352,6 +400,7 @@ export default function Sidebar() { |
| 352 | 400 | worktreePath: options?.worktreePath ?? null, |
| 353 | 401 | createdAt, |
| 354 | 402 | }); |
| 403 | writeProjectDraftThreadId(projectId, threadId); | |
| 355 | 404 | |
| 356 | 405 | // Ensure route guards can see the new thread before navigating. |
| 357 | 406 | try { |
| ... | ... | @@ -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 | ); |
| 372 | 421 | |
| 373 | 422 | const focusMostRecentThreadForProject = useCallback( |
| ... | ... | @@ -517,6 +566,8 @@ export default function Sidebar() { |
| 517 | 566 | commandId: newCommandId(), |
| 518 | 567 | threadId, |
| 519 | 568 | }); |
| 569 | clearComposerDraftForThread(threadId); | |
| 570 | clearProjectDraftThreadById(thread.projectId, thread.id); | |
| 520 | 571 | if (shouldNavigateToFallback) { |
| 521 | 572 | if (fallbackThreadId) { |
| 522 | 573 | void navigate({ |
| ... | ... | @@ -556,6 +607,7 @@ export default function Sidebar() { |
| 556 | 607 | }, |
| 557 | 608 | [ |
| 558 | 609 | appSettings.confirmThreadDelete, |
| 610 | clearComposerDraftForThread, | |
| 559 | 611 | dispatch, |
| 560 | 612 | navigate, |
| 561 | 613 | removeWorktreeMutation, |
| ... | ... | @@ -576,7 +628,9 @@ export default function Sidebar() { |
| 576 | 628 | if (!project) return; |
| 577 | 629 | |
| 578 | 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 | 634 | toastManager.add({ |
| 581 | 635 | type: "warning", |
| 582 | 636 | title: "Project is not empty", |
| ... | ... | @@ -591,6 +645,15 @@ export default function Sidebar() { |
| 591 | 645 | if (!confirmed) return; |
| 592 | 646 | |
| 593 | 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 | 657 | await api.orchestration.dispatchCommand({ |
| 595 | 658 | type: "project.delete", |
| 596 | 659 | commandId: newCommandId(), |
| ... | ... | @@ -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 | ); |
| 611 | 674 | |
| 612 | 675 | useEffect(() => { |
| ... | ... | @@ -693,8 +756,9 @@ export default function Sidebar() { |
| 693 | 756 | <SidebarGroup className="px-2 py-2"> |
| 694 | 757 | <SidebarMenu> |
| 695 | 758 | {state.projects.map((project) => { |
| 696 | const threads = state.threads | |
| 759 | const visibleThreads = state.threads | |
| 697 | 760 | .filter((thread) => thread.projectId === project.id) |
| 761 | .filter(shouldShowThreadInSidebar) | |
| 698 | 762 | .toSorted((a, b) => { |
| 699 | 763 | const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); |
| 700 | 764 | if (byDate !== 0) return byDate; |
| ... | ... | @@ -736,12 +800,14 @@ export default function Sidebar() { |
| 736 | 800 | <span className="flex-1 truncate text-xs font-medium text-foreground/90"> |
| 737 | 801 | {project.name} |
| 738 | 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 | 806 | </CollapsibleTrigger> |
| 741 | 807 | |
| 742 | 808 | <CollapsibleContent> |
| 743 | 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 | 811 | const isActive = routeThreadId === thread.id; |
| 746 | 812 | const threadStatus = threadStatusPill( |
| 747 | 813 | thread, |
apps/web/src/composerDraftStore.ts created+687| ... | ... | @@ -0,0 +1,687 @@ |
| 1 | import { | |
| 2 | DEFAULT_REASONING, | |
| 3 | REASONING_OPTIONS, | |
| 4 | ThreadId, | |
| 5 | normalizeModelSlug, | |
| 6 | type ReasoningEffort, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import type { ChatImageAttachment } from "./types"; | |
| 9 | import { create } from "zustand"; | |
| 10 | import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; | |
| 11 | ||
| 12 | const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; | |
| 13 | ||
| 14 | const 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 | ||
| 47 | export interface PersistedComposerImageAttachment { | |
| 48 | id: string; | |
| 49 | name: string; | |
| 50 | mimeType: string; | |
| 51 | sizeBytes: number; | |
| 52 | dataUrl: string; | |
| 53 | } | |
| 54 | ||
| 55 | export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> { | |
| 56 | previewUrl: string; | |
| 57 | file: File; | |
| 58 | } | |
| 59 | ||
| 60 | interface PersistedComposerThreadDraftState { | |
| 61 | prompt: string; | |
| 62 | cursor: number; | |
| 63 | attachments: PersistedComposerImageAttachment[]; | |
| 64 | model?: string | null; | |
| 65 | effort?: ReasoningEffort | null; | |
| 66 | } | |
| 67 | ||
| 68 | interface PersistedComposerDraftStoreState { | |
| 69 | draftsByThreadId: Record<ThreadId, PersistedComposerThreadDraftState>; | |
| 70 | } | |
| 71 | ||
| 72 | export 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 | ||
| 82 | interface 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 | ||
| 97 | const EMPTY_IMAGES: ComposerImageAttachment[] = []; | |
| 98 | const EMPTY_IDS: string[] = []; | |
| 99 | const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; | |
| 100 | const 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 | ||
| 110 | const REASONING_EFFORT_VALUES = new Set<ReasoningEffort>(REASONING_OPTIONS); | |
| 111 | ||
| 112 | function createEmptyThreadDraft(): ComposerThreadDraftState { | |
| 113 | return { | |
| 114 | prompt: "", | |
| 115 | cursor: 0, | |
| 116 | images: [], | |
| 117 | nonPersistedImageIds: [], | |
| 118 | persistedAttachments: [], | |
| 119 | model: null, | |
| 120 | effort: null, | |
| 121 | }; | |
| 122 | } | |
| 123 | ||
| 124 | function 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 | ||
| 134 | function 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 | ||
| 144 | function 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 | ||
| 175 | function 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 | ||
| 226 | function 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 | ||
| 253 | function 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 | ||
| 275 | function 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 | ||
| 294 | function 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 | ||
| 308 | export 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 | ||
| 668 | export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState { | |
| 669 | return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT); | |
| 670 | } | |
| 671 | ||
| 672 | export function readPersistedComposerDraftAttachmentIds(threadId: ThreadId): string[] { | |
| 673 | if (threadId.length === 0) { | |
| 674 | return []; | |
| 675 | } | |
| 676 | return readPersistedComposerDraftAttachments(threadId).map((attachment) => attachment.id); | |
| 677 | } | |
| 678 | ||
| 679 | export 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 @@ |
| 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | const PROJECT_DRAFT_THREADS_STORAGE_KEY = "t3code:project-draft-threads:v1"; | |
| 4 | ||
| 5 | type ProjectDraftThreadMap = Record<ProjectId, ThreadId>; | |
| 6 | ||
| 7 | function 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 | ||
| 36 | function 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 | ||
| 52 | export 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 | ||
| 60 | export 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 | ||
| 69 | export 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 | ||
| 81 | export 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 | 79 | "react-markdown": "^10.1.0", |
| 80 | 80 | "remark-gfm": "^4.0.1", |
| 81 | 81 | "tailwind-merge": "^3.4.0", |
| 82 | "zustand": "^5.0.11", | |
| 82 | 83 | }, |
| 83 | 84 | "devDependencies": { |
| 84 | 85 | "@effect/language-service": "catalog:", |
| ... | ... | @@ -1417,6 +1418,8 @@ |
| 1417 | 1418 | |
| 1418 | 1419 | "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], |
| 1419 | 1420 | |
| 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 | 1423 | "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], |
| 1421 | 1424 | |
| 1422 | 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 | 11 | export const DEFAULT_MODEL = "gpt-5.3-codex"; |
| 12 | 12 | |
| 13 | 13 | export const MODEL_SLUG_ALIASES: Record<string, BuiltInModelSlug> = { |
| 14 | "gpt-5": "gpt-5.3-codex", | |
| 15 | "gpt-5-codex": "gpt-5.3-codex", | |
| 14 | 16 | "5.3": "gpt-5.3-codex", |
| 15 | 17 | "gpt-5.3": "gpt-5.3-codex", |
| 18 | "gpt-5-codex-spark": "gpt-5.3-codex-spark", | |
| 16 | 19 | "5.3-spark": "gpt-5.3-codex-spark", |
| 17 | 20 | "gpt-5.3-spark": "gpt-5.3-codex-spark", |
| 18 | 21 | }; |