| 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,7 +27,8 @@ |
| 27 | "react-dom": "^19.0.0", | 27 | "react-dom": "^19.0.0", |
| 28 | "react-markdown": "^10.1.0", | 28 | "react-markdown": "^10.1.0", |
| 29 | "remark-gfm": "^4.0.1", | 29 | "remark-gfm": "^4.0.1", |
| 30 | "tailwind-merge": "^3.4.0" | 30 | "tailwind-merge": "^3.4.0", |
| 31 | "zustand": "^5.0.11" | ||
| 31 | }, | 32 | }, |
| 32 | "devDependencies": { | 33 | "devDependencies": { |
| 33 | "@effect/language-service": "catalog:", | 34 | "@effect/language-service": "catalog:", |
apps/web/src/components/ChatView.tsx+226-49| ... | @@ -53,7 +53,6 @@ import { | ... | @@ -53,7 +53,6 @@ import { |
| 53 | DEFAULT_THREAD_TERMINAL_ID, | 53 | DEFAULT_THREAD_TERMINAL_ID, |
| 54 | MAX_THREAD_TERMINAL_COUNT, | 54 | MAX_THREAD_TERMINAL_COUNT, |
| 55 | type ChatMessage, | 55 | type ChatMessage, |
| 56 | type ChatImageAttachment, | ||
| 57 | type TurnDiffSummary, | 56 | type TurnDiffSummary, |
| 58 | } from "../types"; | 57 | } from "../types"; |
| 59 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; | 58 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; |
| ... | @@ -111,6 +110,14 @@ import { SidebarTrigger } from "./ui/sidebar"; | ... | @@ -111,6 +110,14 @@ import { SidebarTrigger } from "./ui/sidebar"; |
| 111 | import { newCommandId, newMessageId } from "~/lib/utils"; | 110 | import { newCommandId, newMessageId } from "~/lib/utils"; |
| 112 | import { readNativeApi } from "~/nativeApi"; | 111 | import { readNativeApi } from "~/nativeApi"; |
| 113 | import { getAppModelOptions, useAppSettings } from "../appSettings"; | 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 | import { clamp } from "effect/Number"; | 121 | import { clamp } from "effect/Number"; |
| 115 | 122 | ||
| 116 | function formatMessageMeta(createdAt: string, duration: string | null): string { | 123 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| ... | @@ -130,6 +137,8 @@ const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; | ... | @@ -130,6 +137,8 @@ const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; |
| 130 | const SCRIPT_TERMINAL_COLS = 120; | 137 | const SCRIPT_TERMINAL_COLS = 120; |
| 131 | const SCRIPT_TERMINAL_ROWS = 30; | 138 | const SCRIPT_TERMINAL_ROWS = 30; |
| 132 | const WORKTREE_BRANCH_PREFIX = "t3code"; | 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 | function readLastInvokedScriptByProjectFromStorage(): Record<string, string> { | 143 | function readLastInvokedScriptByProjectFromStorage(): Record<string, string> { |
| 135 | const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); | 144 | const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); |
| ... | @@ -156,11 +165,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | ... | @@ -156,11 +165,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 156 | return "text-muted-foreground/40"; | 165 | return "text-muted-foreground/40"; |
| 157 | } | 166 | } |
| 158 | 167 | ||
| 159 | interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> { | ||
| 160 | previewUrl: string; | ||
| 161 | file: File; | ||
| 162 | } | ||
| 163 | |||
| 164 | interface ExpandedImageItem { | 168 | interface ExpandedImageItem { |
| 165 | src: string; | 169 | src: string; |
| 166 | name: string; | 170 | name: string; |
| ... | @@ -239,6 +243,25 @@ function buildTemporaryWorktreeBranchName(): string { | ... | @@ -239,6 +243,25 @@ function buildTemporaryWorktreeBranchName(): string { |
| 239 | return `${WORKTREE_BRANCH_PREFIX}/${token}`; | 243 | return `${WORKTREE_BRANCH_PREFIX}/${token}`; |
| 240 | } | 244 | } |
| 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 | const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { | 265 | const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { |
| 243 | pathValue: string; | 266 | pathValue: string; |
| 244 | kind: "file" | "directory"; | 267 | kind: "file" | "directory"; |
| ... | @@ -372,23 +395,38 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -372,23 +395,38 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 372 | const { resolvedTheme } = useTheme(); | 395 | const { resolvedTheme } = useTheme(); |
| 373 | const queryClient = useQueryClient(); | 396 | const queryClient = useQueryClient(); |
| 374 | const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); | 397 | const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); |
| 375 | const [prompt, setPrompt] = useState(""); | 398 | const composerDraft = useComposerThreadDraft(threadId); |
| 399 | const prompt = composerDraft.prompt; | ||
| 400 | const composerImages = composerDraft.images; | ||
| 401 | const composerCursor = composerDraft.cursor; | ||
| 402 | const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; | ||
| 403 | const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); | ||
| 404 | const setComposerDraftCursor = useComposerDraftStore((store) => store.setCursor); | ||
| 405 | const setComposerDraftModel = useComposerDraftStore((store) => store.setModel); | ||
| 406 | const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort); | ||
| 407 | const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); | ||
| 408 | const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); | ||
| 409 | const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); | ||
| 410 | const clearComposerDraftPersistedAttachments = useComposerDraftStore( | ||
| 411 | (store) => store.clearPersistedAttachments, | ||
| 412 | ); | ||
| 413 | const syncComposerDraftPersistedAttachments = useComposerDraftStore( | ||
| 414 | (store) => store.syncPersistedAttachments, | ||
| 415 | ); | ||
| 416 | const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); | ||
| 376 | const promptRef = useRef(prompt); | 417 | const promptRef = useRef(prompt); |
| 377 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); | ||
| 378 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); | 418 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| 379 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); | 419 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| 380 | const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); | 420 | const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); |
| 381 | const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); | 421 | const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); |
| 382 | const [isConnecting, _setIsConnecting] = useState(false); | 422 | const [isConnecting, _setIsConnecting] = useState(false); |
| 383 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); | 423 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| 384 | const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); | ||
| 385 | const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); | 424 | const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); |
| 386 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); | 425 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); |
| 387 | const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]); | 426 | const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]); |
| 388 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); | 427 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); |
| 389 | const [nowTick, setNowTick] = useState(() => Date.now()); | 428 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 390 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); | 429 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 391 | const [composerCursor, setComposerCursor] = useState(0); | ||
| 392 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); | 430 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); |
| 393 | const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< | 431 | const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< |
| 394 | Record<string, string> | 432 | Record<string, string> |
| ... | @@ -402,6 +440,37 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -402,6 +440,37 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 402 | const dragDepthRef = useRef(0); | 440 | const dragDepthRef = useRef(0); |
| 403 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); | 441 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| 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 | const activeThread = state.threads.find((t) => t.id === threadId); | 474 | const activeThread = state.threads.find((t) => t.id === threadId); |
| 406 | const diffSearch = useMemo( | 475 | const diffSearch = useMemo( |
| 407 | () => parseDiffRouteSearch(rawSearch as Record<string, unknown>), | 476 | () => parseDiffRouteSearch(rawSearch as Record<string, unknown>), |
| ... | @@ -435,8 +504,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -435,8 +504,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 435 | ]); | 504 | ]); |
| 436 | 505 | ||
| 437 | const selectedModel = resolveModelSlug( | 506 | const selectedModel = resolveModelSlug( |
| 438 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, | 507 | composerDraft.model ?? activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, |
| 439 | ); | 508 | ); |
| 509 | const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING; | ||
| 440 | const modelOptions = useMemo( | 510 | const modelOptions = useMemo( |
| 441 | () => getAppModelOptions(settings.customCodexModels, selectedModel), | 511 | () => getAppModelOptions(settings.customCodexModels, selectedModel), |
| 442 | [selectedModel, settings.customCodexModels], | 512 | [selectedModel, settings.customCodexModels], |
| ... | @@ -645,6 +715,10 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -645,6 +715,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 645 | null, | 715 | null, |
| 646 | [composerHighlightedItemId, composerMenuItems], | 716 | [composerHighlightedItemId, composerMenuItems], |
| 647 | ); | 717 | ); |
| 718 | const nonPersistedComposerImageIdSet = useMemo( | ||
| 719 | () => new Set(nonPersistedComposerImageIds), | ||
| 720 | [nonPersistedComposerImageIds], | ||
| 721 | ); | ||
| 648 | const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; | 722 | const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; |
| 649 | const threadTerminalRuntimeEnv = useMemo(() => { | 723 | const threadTerminalRuntimeEnv = useMemo(() => { |
| 650 | if (!activeProject?.cwd) return {}; | 724 | if (!activeProject?.cwd) return {}; |
| ... | @@ -693,12 +767,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -693,12 +767,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 693 | const hasReachedTerminalLimit = | 767 | const hasReachedTerminalLimit = |
| 694 | (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT; | 768 | (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT; |
| 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 | const focusComposer = useCallback(() => { | 770 | const focusComposer = useCallback(() => { |
| 703 | const textarea = textareaRef.current; | 771 | const textarea = textareaRef.current; |
| 704 | if (!textarea) return; | 772 | if (!textarea) return; |
| ... | @@ -1088,26 +1156,115 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1088,26 +1156,115 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1088 | }, [activeThread?.id, activeThread?.messages]); | 1156 | }, [activeThread?.id, activeThread?.messages]); |
| 1089 | 1157 | ||
| 1090 | useEffect(() => { | 1158 | useEffect(() => { |
| 1091 | setComposerImages((existing) => { | 1159 | promptRef.current = prompt; |
| 1092 | revokePreviewUrls(existing); | 1160 | }, [prompt]); |
| 1093 | return []; | 1161 | |
| 1094 | }); | 1162 | useEffect(() => { |
| 1095 | setOptimisticUserMessages([]); | 1163 | setOptimisticUserMessages([]); |
| 1096 | setPrompt(""); | ||
| 1097 | promptRef.current = ""; | ||
| 1098 | setSendPhase("idle"); | 1164 | setSendPhase("idle"); |
| 1099 | setComposerCursor(0); | ||
| 1100 | setComposerHighlightedItemId(null); | 1165 | setComposerHighlightedItemId(null); |
| 1101 | dragDepthRef.current = 0; | 1166 | dragDepthRef.current = 0; |
| 1102 | setIsDragOverComposer(false); | 1167 | setIsDragOverComposer(false); |
| 1103 | setExpandedImage(null); | 1168 | setExpandedImage(null); |
| 1104 | }, [activeThread?.id, revokePreviewUrls]); | 1169 | }, [threadId]); |
| 1105 | 1170 | ||
| 1106 | useEffect(() => { | 1171 | useEffect(() => { |
| 1172 | let cancelled = false; | ||
| 1173 | void (async () => { | ||
| 1174 | if (composerImages.length === 0) { | ||
| 1175 | clearComposerDraftPersistedAttachments(threadId); | ||
| 1176 | return; | ||
| 1177 | } | ||
| 1178 | try { | ||
| 1179 | const currentPersistedAttachments = | ||
| 1180 | useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? []; | ||
| 1181 | const existingPersistedById = new Map( | ||
| 1182 | currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), | ||
| 1183 | ); | ||
| 1184 | const stagedAttachmentById = new Map<string, PersistedComposerImageAttachment>(); | ||
| 1185 | const serializationFailedImageIds: string[] = []; | ||
| 1186 | await Promise.all( | ||
| 1187 | composerImages.map(async (image) => { | ||
| 1188 | try { | ||
| 1189 | const dataUrl = await readFileAsDataUrl(image.file); | ||
| 1190 | stagedAttachmentById.set(image.id, { | ||
| 1191 | id: image.id, | ||
| 1192 | name: image.name, | ||
| 1193 | mimeType: image.mimeType, | ||
| 1194 | sizeBytes: image.sizeBytes, | ||
| 1195 | dataUrl, | ||
| 1196 | }); | ||
| 1197 | } catch { | ||
| 1198 | const existingPersisted = existingPersistedById.get(image.id); | ||
| 1199 | if (existingPersisted) { | ||
| 1200 | stagedAttachmentById.set(image.id, existingPersisted); | ||
| 1201 | } else { | ||
| 1202 | serializationFailedImageIds.push(image.id); | ||
| 1203 | } | ||
| 1204 | } | ||
| 1205 | }), | ||
| 1206 | ); | ||
| 1207 | const serialized = Array.from(stagedAttachmentById.values()); | ||
| 1208 | if (cancelled) { | ||
| 1209 | return; | ||
| 1210 | } | ||
| 1211 | // Stage attachments in persisted draft state first so persist middleware can write them. | ||
| 1212 | const stagedAttachmentIds = serialized.map((attachment) => attachment.id); | ||
| 1213 | debugDraftPersist("stage", { | ||
| 1214 | threadId, | ||
| 1215 | attachmentCount: serialized.length, | ||
| 1216 | serializationFailedCount: serializationFailedImageIds.length, | ||
| 1217 | serializationFailedImageIds, | ||
| 1218 | attachmentMeta: serialized.map((attachment) => ({ | ||
| 1219 | id: attachment.id, | ||
| 1220 | name: attachment.name, | ||
| 1221 | sizeBytes: attachment.sizeBytes, | ||
| 1222 | dataUrlChars: attachment.dataUrl.length, | ||
| 1223 | })), | ||
| 1224 | storageCharsBefore: readComposerDraftStorageChars(), | ||
| 1225 | }); | ||
| 1226 | syncComposerDraftPersistedAttachments( | ||
| 1227 | threadId, | ||
| 1228 | serialized, | ||
| 1229 | ); | ||
| 1230 | debugDraftPersist("reconcile-request", { | ||
| 1231 | threadId, | ||
| 1232 | stagedCount: stagedAttachmentIds.length, | ||
| 1233 | serializationFailedCount: serializationFailedImageIds.length, | ||
| 1234 | storageCharsAfter: readComposerDraftStorageChars(), | ||
| 1235 | }); | ||
| 1236 | } catch (error) { | ||
| 1237 | const currentImageIds = new Set(composerImages.map((image) => image.id)); | ||
| 1238 | const fallbackPersistedAttachments = readPersistedComposerDraftAttachments(threadId); | ||
| 1239 | const fallbackPersistedIds = fallbackPersistedAttachments | ||
| 1240 | .map((attachment) => attachment.id) | ||
| 1241 | .filter((id) => currentImageIds.has(id)); | ||
| 1242 | const fallbackPersistedIdSet = new Set(fallbackPersistedIds); | ||
| 1243 | const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) => | ||
| 1244 | fallbackPersistedIdSet.has(attachment.id), | ||
| 1245 | ); | ||
| 1246 | debugDraftPersist("error", { | ||
| 1247 | threadId, | ||
| 1248 | error: error instanceof Error ? error.message : String(error), | ||
| 1249 | imageIds: composerImages.map((image) => image.id), | ||
| 1250 | fallbackPersistedCount: fallbackPersistedIds.length, | ||
| 1251 | storageChars: readComposerDraftStorageChars(), | ||
| 1252 | }); | ||
| 1253 | if (cancelled) { | ||
| 1254 | return; | ||
| 1255 | } | ||
| 1256 | syncComposerDraftPersistedAttachments(threadId, fallbackAttachments); | ||
| 1257 | } | ||
| 1258 | })(); | ||
| 1107 | return () => { | 1259 | return () => { |
| 1108 | revokePreviewUrls(composerImagesRef.current); | 1260 | cancelled = true; |
| 1109 | }; | 1261 | }; |
| 1110 | }, [revokePreviewUrls]); | 1262 | }, [ |
| 1263 | clearComposerDraftPersistedAttachments, | ||
| 1264 | composerImages, | ||
| 1265 | syncComposerDraftPersistedAttachments, | ||
| 1266 | threadId, | ||
| 1267 | ]); | ||
| 1111 | 1268 | ||
| 1112 | const closeExpandedImage = useCallback(() => { | 1269 | const closeExpandedImage = useCallback(() => { |
| 1113 | setExpandedImage(null); | 1270 | setExpandedImage(null); |
| ... | @@ -1310,7 +1467,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1310,7 +1467,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1310 | const addComposerImages = (files: File[]) => { | 1467 | const addComposerImages = (files: File[]) => { |
| 1311 | if (!activeThreadId || files.length === 0) return; | 1468 | if (!activeThreadId || files.length === 0) return; |
| 1312 | 1469 | ||
| 1313 | const nextImages = [...composerImagesRef.current]; | 1470 | const nextImages: ComposerImageAttachment[] = []; |
| 1471 | let nextImageCount = composerImagesRef.current.length; | ||
| 1314 | let error: string | null = null; | 1472 | let error: string | null = null; |
| 1315 | for (const file of files) { | 1473 | for (const file of files) { |
| 1316 | if (!file.type.startsWith("image/")) { | 1474 | if (!file.type.startsWith("image/")) { |
| ... | @@ -1321,7 +1479,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1321,7 +1479,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1321 | error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; | 1479 | error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; |
| 1322 | continue; | 1480 | continue; |
| 1323 | } | 1481 | } |
| 1324 | if (nextImages.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { | 1482 | if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { |
| 1325 | error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; | 1483 | error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; |
| 1326 | break; | 1484 | break; |
| 1327 | } | 1485 | } |
| ... | @@ -1336,20 +1494,19 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1336,20 +1494,19 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1336 | previewUrl, | 1494 | previewUrl, |
| 1337 | file, | 1495 | file, |
| 1338 | }); | 1496 | }); |
| 1497 | nextImageCount += 1; | ||
| 1339 | } | 1498 | } |
| 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 | setThreadError(activeThreadId, error); | 1505 | setThreadError(activeThreadId, error); |
| 1343 | }; | 1506 | }; |
| 1344 | 1507 | ||
| 1345 | const removeComposerImage = (imageId: string) => { | 1508 | const removeComposerImage = (imageId: string) => { |
| 1346 | setComposerImages((existing) => { | 1509 | removeComposerImageFromDraft(imageId); |
| 1347 | const match = existing.find((image) => image.id === imageId); | ||
| 1348 | if (match?.previewUrl) { | ||
| 1349 | URL.revokeObjectURL(match.previewUrl); | ||
| 1350 | } | ||
| 1351 | return existing.filter((image) => image.id !== imageId); | ||
| 1352 | }); | ||
| 1353 | }; | 1510 | }; |
| 1354 | 1511 | ||
| 1355 | const onComposerPaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => { | 1512 | const onComposerPaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => { |
| ... | @@ -1516,9 +1673,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1516,9 +1673,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1516 | 1673 | ||
| 1517 | setThreadError(threadIdForSend, null); | 1674 | setThreadError(threadIdForSend, null); |
| 1518 | promptRef.current = ""; | 1675 | promptRef.current = ""; |
| 1519 | setPrompt(""); | 1676 | clearComposerDraftContent(threadIdForSend); |
| 1520 | setComposerImages([]); | ||
| 1521 | setComposerCursor(0); | ||
| 1522 | setComposerHighlightedItemId(null); | 1677 | setComposerHighlightedItemId(null); |
| 1523 | 1678 | ||
| 1524 | let attemptedTurnStart = false; | 1679 | let attemptedTurnStart = false; |
| ... | @@ -1596,6 +1751,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1596,6 +1751,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1596 | sandboxMode, | 1751 | sandboxMode, |
| 1597 | createdAt: messageCreatedAt, | 1752 | createdAt: messageCreatedAt, |
| 1598 | }); | 1753 | }); |
| 1754 | if (isFirstMessage) { | ||
| 1755 | clearProjectDraftThreadById(activeProject.id, threadIdForSend); | ||
| 1756 | } | ||
| 1599 | } catch (err) { | 1757 | } catch (err) { |
| 1600 | if ( | 1758 | if ( |
| 1601 | !attemptedTurnStart && | 1759 | !attemptedTurnStart && |
| ... | @@ -1607,7 +1765,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1607,7 +1765,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1607 | ); | 1765 | ); |
| 1608 | promptRef.current = trimmed; | 1766 | promptRef.current = trimmed; |
| 1609 | setPrompt(trimmed); | 1767 | setPrompt(trimmed); |
| 1610 | setComposerImages(composerImagesSnapshot); | 1768 | addComposerImagesToDraft(composerImagesSnapshot); |
| 1611 | setComposerCursor(trimmed.length); | 1769 | setComposerCursor(trimmed.length); |
| 1612 | } | 1770 | } |
| 1613 | setThreadError( | 1771 | setThreadError( |
| ... | @@ -1663,26 +1821,27 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1663,26 +1821,27 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1663 | 1821 | ||
| 1664 | const onModelSelect = useCallback( | 1822 | const onModelSelect = useCallback( |
| 1665 | (model: string) => { | 1823 | (model: string) => { |
| 1824 | const normalizedModel = resolveModelSlug(model); | ||
| 1825 | setComposerDraftModel(threadId, normalizedModel); | ||
| 1666 | const api = readNativeApi(); | 1826 | const api = readNativeApi(); |
| 1667 | if (!activeThread) return; | 1827 | if (api && activeThread) { |
| 1668 | if (api) { | ||
| 1669 | void api.orchestration.dispatchCommand({ | 1828 | void api.orchestration.dispatchCommand({ |
| 1670 | type: "thread.meta.update", | 1829 | type: "thread.meta.update", |
| 1671 | commandId: newCommandId(), | 1830 | commandId: newCommandId(), |
| 1672 | threadId: activeThread.id, | 1831 | threadId: activeThread.id, |
| 1673 | model: resolveModelSlug(model), | 1832 | model: normalizedModel, |
| 1674 | }); | 1833 | }); |
| 1675 | } | 1834 | } |
| 1676 | scheduleComposerFocus(); | 1835 | scheduleComposerFocus(); |
| 1677 | }, | 1836 | }, |
| 1678 | [activeThread, scheduleComposerFocus], | 1837 | [activeThread, scheduleComposerFocus, setComposerDraftModel, threadId], |
| 1679 | ); | 1838 | ); |
| 1680 | const onEffortSelect = useCallback( | 1839 | const onEffortSelect = useCallback( |
| 1681 | (effort: ReasoningEffort) => { | 1840 | (effort: ReasoningEffort) => { |
| 1682 | setSelectedEffort(effort); | 1841 | setComposerDraftEffort(threadId, effort); |
| 1683 | scheduleComposerFocus(); | 1842 | scheduleComposerFocus(); |
| 1684 | }, | 1843 | }, |
| 1685 | [scheduleComposerFocus], | 1844 | [scheduleComposerFocus, setComposerDraftEffort, threadId], |
| 1686 | ); | 1845 | ); |
| 1687 | const onEnvModeChange = useCallback( | 1846 | const onEnvModeChange = useCallback( |
| 1688 | (mode: "local" | "worktree") => { | 1847 | (mode: "local" | "worktree") => { |
| ... | @@ -1705,7 +1864,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1705,7 +1864,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1705 | setComposerCursor(next.cursor); | 1864 | setComposerCursor(next.cursor); |
| 1706 | }); | 1865 | }); |
| 1707 | }, | 1866 | }, |
| 1708 | [], | 1867 | [setComposerCursor, setPrompt], |
| 1709 | ); | 1868 | ); |
| 1710 | 1869 | ||
| 1711 | const onSelectComposerItem = useCallback( | 1870 | const onSelectComposerItem = useCallback( |
| ... | @@ -1748,7 +1907,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1748,7 +1907,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1748 | promptRef.current = nextPrompt; | 1907 | promptRef.current = nextPrompt; |
| 1749 | setPrompt(nextPrompt); | 1908 | setPrompt(nextPrompt); |
| 1750 | setComposerCursor(nextCursor); | 1909 | setComposerCursor(nextCursor); |
| 1751 | }, []); | 1910 | }, [setComposerCursor, setPrompt]); |
| 1752 | 1911 | ||
| 1753 | const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { | 1912 | const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { |
| 1754 | if (composerMenuOpen && composerMenuItems.length > 0) { | 1913 | if (composerMenuOpen && composerMenuItems.length > 0) { |
| ... | @@ -1960,6 +2119,24 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1960,6 +2119,24 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1960 | {image.name} | 2119 | {image.name} |
| 1961 | </div> | 2120 | </div> |
| 1962 | )} | 2121 | )} |
| 2122 | {nonPersistedComposerImageIdSet.has(image.id) && ( | ||
| 2123 | <Tooltip> | ||
| 2124 | <TooltipTrigger | ||
| 2125 | render={ | ||
| 2126 | <span | ||
| 2127 | role="img" | ||
| 2128 | aria-label="Draft attachment may not persist" | ||
| 2129 | className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600" | ||
| 2130 | > | ||
| 2131 | <CircleAlertIcon className="size-3" /> | ||
| 2132 | </span> | ||
| 2133 | } | ||
| 2134 | /> | ||
| 2135 | <TooltipPopup side="top" className="max-w-64 whitespace-normal leading-tight"> | ||
| 2136 | Draft attachment could not be saved locally and may be lost on navigation. | ||
| 2137 | </TooltipPopup> | ||
| 2138 | </Tooltip> | ||
| 2139 | )} | ||
| 1963 | <Button | 2140 | <Button |
| 1964 | variant="ghost" | 2141 | variant="ghost" |
| 1965 | size="icon-xs" | 2142 | size="icon-xs" |
apps/web/src/components/Sidebar.tsx+72-6| ... | @@ -20,6 +20,13 @@ import { derivePendingApprovals } from "../session-logic"; | ... | @@ -20,6 +20,13 @@ import { derivePendingApprovals } from "../session-logic"; |
| 20 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; | 20 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; |
| 21 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | 21 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 22 | import { readNativeApi } from "../nativeApi"; | 22 | import { readNativeApi } from "../nativeApi"; |
| 23 | import { useComposerDraftStore } from "../composerDraftStore"; | ||
| 24 | import { | ||
| 25 | clearProjectDraftThreadById, | ||
| 26 | clearProjectDraftThreadId, | ||
| 27 | readProjectDraftThreadId, | ||
| 28 | writeProjectDraftThreadId, | ||
| 29 | } from "../projectDraftThreads"; | ||
| 23 | import { toastManager } from "./ui/toast"; | 30 | import { toastManager } from "./ui/toast"; |
| 24 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; | 31 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; |
| 25 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 32 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| ... | @@ -74,6 +81,10 @@ interface PrStatusIndicator { | ... | @@ -74,6 +81,10 @@ interface PrStatusIndicator { |
| 74 | 81 | ||
| 75 | type ThreadPr = GitStatusResult["pr"]; | 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 | function hasUnseenCompletion(thread: Thread): boolean { | 88 | function hasUnseenCompletion(thread: Thread): boolean { |
| 78 | if (!thread.latestTurn?.completedAt) return false; | 89 | if (!thread.latestTurn?.completedAt) return false; |
| 79 | const completedAt = Date.parse(thread.latestTurn.completedAt); | 90 | const completedAt = Date.parse(thread.latestTurn.completedAt); |
| ... | @@ -228,6 +239,7 @@ function ProjectFavicon({ cwd }: { cwd: string }) { | ... | @@ -228,6 +239,7 @@ function ProjectFavicon({ cwd }: { cwd: string }) { |
| 228 | 239 | ||
| 229 | export default function Sidebar() { | 240 | export default function Sidebar() { |
| 230 | const { state, dispatch } = useStore(); | 241 | const { state, dispatch } = useStore(); |
| 242 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); | ||
| 231 | const navigate = useNavigate(); | 243 | const navigate = useNavigate(); |
| 232 | const { settings: appSettings } = useAppSettings(); | 244 | const { settings: appSettings } = useAppSettings(); |
| 233 | const routeThreadId = useParams({ | 245 | const routeThreadId = useParams({ |
| ... | @@ -336,6 +348,42 @@ export default function Sidebar() { | ... | @@ -336,6 +348,42 @@ export default function Sidebar() { |
| 336 | ): Promise<void> => { | 348 | ): Promise<void> => { |
| 337 | const api = readNativeApi(); | 349 | const api = readNativeApi(); |
| 338 | if (!api) return Promise.resolve(); | 350 | if (!api) return Promise.resolve(); |
| 351 | const storedDraftThreadId = readProjectDraftThreadId(projectId); | ||
| 352 | const storedDraftThread = storedDraftThreadId | ||
| 353 | ? state.threads.find((thread) => thread.id === storedDraftThreadId) | ||
| 354 | : undefined; | ||
| 355 | const reusableStoredDraftThreadId = | ||
| 356 | storedDraftThread && | ||
| 357 | storedDraftThread.projectId === projectId && | ||
| 358 | !shouldShowThreadInSidebar(storedDraftThread) | ||
| 359 | ? storedDraftThread.id | ||
| 360 | : null; | ||
| 361 | if (reusableStoredDraftThreadId) { | ||
| 362 | if (routeThreadId === reusableStoredDraftThreadId) { | ||
| 363 | return Promise.resolve(); | ||
| 364 | } | ||
| 365 | return navigate({ | ||
| 366 | to: "/$threadId", | ||
| 367 | params: { threadId: reusableStoredDraftThreadId }, | ||
| 368 | }); | ||
| 369 | } | ||
| 370 | if (storedDraftThreadId) { | ||
| 371 | clearProjectDraftThreadId(projectId); | ||
| 372 | } | ||
| 373 | |||
| 374 | const activeThread = routeThreadId | ||
| 375 | ? state.threads.find((thread) => thread.id === routeThreadId) | ||
| 376 | : undefined; | ||
| 377 | const reusableDraftThreadId = | ||
| 378 | activeThread && | ||
| 379 | activeThread.projectId === projectId && | ||
| 380 | !shouldShowThreadInSidebar(activeThread) | ||
| 381 | ? activeThread.id | ||
| 382 | : null; | ||
| 383 | if (reusableDraftThreadId) { | ||
| 384 | writeProjectDraftThreadId(projectId, reusableDraftThreadId); | ||
| 385 | return Promise.resolve(); | ||
| 386 | } | ||
| 339 | const threadId = newThreadId(); | 387 | const threadId = newThreadId(); |
| 340 | const createdAt = new Date().toISOString(); | 388 | const createdAt = new Date().toISOString(); |
| 341 | const model = | 389 | const model = |
| ... | @@ -352,6 +400,7 @@ export default function Sidebar() { | ... | @@ -352,6 +400,7 @@ export default function Sidebar() { |
| 352 | worktreePath: options?.worktreePath ?? null, | 400 | worktreePath: options?.worktreePath ?? null, |
| 353 | createdAt, | 401 | createdAt, |
| 354 | }); | 402 | }); |
| 403 | writeProjectDraftThreadId(projectId, threadId); | ||
| 355 | 404 | ||
| 356 | // Ensure route guards can see the new thread before navigating. | 405 | // Ensure route guards can see the new thread before navigating. |
| 357 | try { | 406 | try { |
| ... | @@ -367,7 +416,7 @@ export default function Sidebar() { | ... | @@ -367,7 +416,7 @@ export default function Sidebar() { |
| 367 | }); | 416 | }); |
| 368 | })(); | 417 | })(); |
| 369 | }, | 418 | }, |
| 370 | [dispatch, navigate, state.projects], | 419 | [dispatch, navigate, routeThreadId, state.projects, state.threads], |
| 371 | ); | 420 | ); |
| 372 | 421 | ||
| 373 | const focusMostRecentThreadForProject = useCallback( | 422 | const focusMostRecentThreadForProject = useCallback( |
| ... | @@ -517,6 +566,8 @@ export default function Sidebar() { | ... | @@ -517,6 +566,8 @@ export default function Sidebar() { |
| 517 | commandId: newCommandId(), | 566 | commandId: newCommandId(), |
| 518 | threadId, | 567 | threadId, |
| 519 | }); | 568 | }); |
| 569 | clearComposerDraftForThread(threadId); | ||
| 570 | clearProjectDraftThreadById(thread.projectId, thread.id); | ||
| 520 | if (shouldNavigateToFallback) { | 571 | if (shouldNavigateToFallback) { |
| 521 | if (fallbackThreadId) { | 572 | if (fallbackThreadId) { |
| 522 | void navigate({ | 573 | void navigate({ |
| ... | @@ -556,6 +607,7 @@ export default function Sidebar() { | ... | @@ -556,6 +607,7 @@ export default function Sidebar() { |
| 556 | }, | 607 | }, |
| 557 | [ | 608 | [ |
| 558 | appSettings.confirmThreadDelete, | 609 | appSettings.confirmThreadDelete, |
| 610 | clearComposerDraftForThread, | ||
| 559 | dispatch, | 611 | dispatch, |
| 560 | navigate, | 612 | navigate, |
| 561 | removeWorktreeMutation, | 613 | removeWorktreeMutation, |
| ... | @@ -576,7 +628,9 @@ export default function Sidebar() { | ... | @@ -576,7 +628,9 @@ export default function Sidebar() { |
| 576 | if (!project) return; | 628 | if (!project) return; |
| 577 | 629 | ||
| 578 | const projectThreads = state.threads.filter((thread) => thread.projectId === projectId); | 630 | const projectThreads = state.threads.filter((thread) => thread.projectId === projectId); |
| 579 | if (projectThreads.length > 0) { | 631 | const visibleThreads = projectThreads.filter(shouldShowThreadInSidebar); |
| 632 | const draftThreads = projectThreads.filter((thread) => !shouldShowThreadInSidebar(thread)); | ||
| 633 | if (visibleThreads.length > 0) { | ||
| 580 | toastManager.add({ | 634 | toastManager.add({ |
| 581 | type: "warning", | 635 | type: "warning", |
| 582 | title: "Project is not empty", | 636 | title: "Project is not empty", |
| ... | @@ -591,6 +645,15 @@ export default function Sidebar() { | ... | @@ -591,6 +645,15 @@ export default function Sidebar() { |
| 591 | if (!confirmed) return; | 645 | if (!confirmed) return; |
| 592 | 646 | ||
| 593 | try { | 647 | try { |
| 648 | for (const draftThread of draftThreads) { | ||
| 649 | await api.orchestration.dispatchCommand({ | ||
| 650 | type: "thread.delete", | ||
| 651 | commandId: newCommandId(), | ||
| 652 | threadId: draftThread.id, | ||
| 653 | }); | ||
| 654 | clearComposerDraftForThread(draftThread.id); | ||
| 655 | } | ||
| 656 | clearProjectDraftThreadId(projectId); | ||
| 594 | await api.orchestration.dispatchCommand({ | 657 | await api.orchestration.dispatchCommand({ |
| 595 | type: "project.delete", | 658 | type: "project.delete", |
| 596 | commandId: newCommandId(), | 659 | commandId: newCommandId(), |
| ... | @@ -606,7 +669,7 @@ export default function Sidebar() { | ... | @@ -606,7 +669,7 @@ export default function Sidebar() { |
| 606 | }); | 669 | }); |
| 607 | } | 670 | } |
| 608 | }, | 671 | }, |
| 609 | [state.projects, state.threads], | 672 | [clearComposerDraftForThread, state.projects, state.threads], |
| 610 | ); | 673 | ); |
| 611 | 674 | ||
| 612 | useEffect(() => { | 675 | useEffect(() => { |
| ... | @@ -693,8 +756,9 @@ export default function Sidebar() { | ... | @@ -693,8 +756,9 @@ export default function Sidebar() { |
| 693 | <SidebarGroup className="px-2 py-2"> | 756 | <SidebarGroup className="px-2 py-2"> |
| 694 | <SidebarMenu> | 757 | <SidebarMenu> |
| 695 | {state.projects.map((project) => { | 758 | {state.projects.map((project) => { |
| 696 | const threads = state.threads | 759 | const visibleThreads = state.threads |
| 697 | .filter((thread) => thread.projectId === project.id) | 760 | .filter((thread) => thread.projectId === project.id) |
| 761 | .filter(shouldShowThreadInSidebar) | ||
| 698 | .toSorted((a, b) => { | 762 | .toSorted((a, b) => { |
| 699 | const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); | 763 | const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); |
| 700 | if (byDate !== 0) return byDate; | 764 | if (byDate !== 0) return byDate; |
| ... | @@ -736,12 +800,14 @@ export default function Sidebar() { | ... | @@ -736,12 +800,14 @@ export default function Sidebar() { |
| 736 | <span className="flex-1 truncate text-xs font-medium text-foreground/90"> | 800 | <span className="flex-1 truncate text-xs font-medium text-foreground/90"> |
| 737 | {project.name} | 801 | {project.name} |
| 738 | </span> | 802 | </span> |
| 739 | <span className="text-[10px] text-muted-foreground/60">{threads.length}</span> | 803 | <span className="text-[10px] text-muted-foreground/60"> |
| 804 | {visibleThreads.length} | ||
| 805 | </span> | ||
| 740 | </CollapsibleTrigger> | 806 | </CollapsibleTrigger> |
| 741 | 807 | ||
| 742 | <CollapsibleContent> | 808 | <CollapsibleContent> |
| 743 | <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0"> | 809 | <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0"> |
| 744 | {threads.map((thread) => { | 810 | {visibleThreads.map((thread) => { |
| 745 | const isActive = routeThreadId === thread.id; | 811 | const isActive = routeThreadId === thread.id; |
| 746 | const threadStatus = threadStatusPill( | 812 | const threadStatus = threadStatusPill( |
| 747 | thread, | 813 | thread, |
apps/web/src/composerDraftStore.ts created+687| ... | @@ -0,0 +1,687 @@ | ||
| 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,6 +79,7 @@ |
| 79 | "react-markdown": "^10.1.0", | 79 | "react-markdown": "^10.1.0", |
| 80 | "remark-gfm": "^4.0.1", | 80 | "remark-gfm": "^4.0.1", |
| 81 | "tailwind-merge": "^3.4.0", | 81 | "tailwind-merge": "^3.4.0", |
| 82 | "zustand": "^5.0.11", | ||
| 82 | }, | 83 | }, |
| 83 | "devDependencies": { | 84 | "devDependencies": { |
| 84 | "@effect/language-service": "catalog:", | 85 | "@effect/language-service": "catalog:", |
| ... | @@ -1417,6 +1418,8 @@ | ... | @@ -1417,6 +1418,8 @@ |
| 1417 | 1418 | ||
| 1418 | "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], | 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 | "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], | 1423 | "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], |
| 1421 | 1424 | ||
| 1422 | "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], | 1425 | "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], |
packages/contracts/src/model.ts+3| ... | @@ -11,8 +11,11 @@ export type ModelSlug = string; | ... | @@ -11,8 +11,11 @@ export type ModelSlug = string; |
| 11 | export const DEFAULT_MODEL = "gpt-5.3-codex"; | 11 | export const DEFAULT_MODEL = "gpt-5.3-codex"; |
| 12 | 12 | ||
| 13 | export const MODEL_SLUG_ALIASES: Record<string, BuiltInModelSlug> = { | 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 | "5.3": "gpt-5.3-codex", | 16 | "5.3": "gpt-5.3-codex", |
| 15 | "gpt-5.3": "gpt-5.3-codex", | 17 | "gpt-5.3": "gpt-5.3-codex", |
| 18 | "gpt-5-codex-spark": "gpt-5.3-codex-spark", | ||
| 16 | "5.3-spark": "gpt-5.3-codex-spark", | 19 | "5.3-spark": "gpt-5.3-codex-spark", |
| 17 | "gpt-5.3-spark": "gpt-5.3-codex-spark", | 20 | "gpt-5.3-spark": "gpt-5.3-codex-spark", |
| 18 | }; | 21 | }; |