authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-01 11:54:23-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 22:36:35-08:00
log0b8dfee17beb957a8855f5c1382a110fc57de88a
tree4f066a866963be55b4988f18d27f8a03e1750dbb
parent6ab246676ebaee8ef895ab4067fb6457854b0a1c

Simplify composer draft persistence and remove debug instrumentation

- Remove ChatView draft persistence debug logging and storage size plumbing - Drop in-memory persisted draft cache; use JSON storage directly - Reconcile attachment IDs from localStorage and streamline image hydration

2 files changed, 26 insertions(+), 111 deletions(-)

apps/web/src/components/ChatView.tsx+2-42
...@@ -115,7 +115,6 @@ import {...@@ -115,7 +115,6 @@ import {
115 type ComposerImageAttachment,115 type ComposerImageAttachment,
116 type DraftThreadState,116 type DraftThreadState,
117 type PersistedComposerImageAttachment,117 type PersistedComposerImageAttachment,
118 readComposerDraftPersistStorageChars,
119 useComposerDraftStore,118 useComposerDraftStore,
120 useComposerThreadDraft,119 useComposerThreadDraft,
121} from "../composerDraftStore";120} from "../composerDraftStore";
...@@ -138,8 +137,6 @@ const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;...@@ -138,8 +137,6 @@ const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
138const SCRIPT_TERMINAL_COLS = 120;137const SCRIPT_TERMINAL_COLS = 120;
139const SCRIPT_TERMINAL_ROWS = 30;138const SCRIPT_TERMINAL_ROWS = 30;
140const WORKTREE_BRANCH_PREFIX = "t3code";139const WORKTREE_BRANCH_PREFIX = "t3code";
141const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
142const ENABLE_DRAFT_PERSIST_DEBUG_LOGS = import.meta.env.DEV;
143140
144function readLastInvokedScriptByProjectFromStorage(): Record<string, string> {141function readLastInvokedScriptByProjectFromStorage(): Record<string, string> {
145 const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY);142 const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY);
...@@ -294,13 +291,6 @@ function cloneComposerImageForRetry(image: ComposerImageAttachment): ComposerIma...@@ -294,13 +291,6 @@ function cloneComposerImageForRetry(image: ComposerImageAttachment): ComposerIma
294 }291 }
295}292}
296293
297function debugDraftPersist(message: string, payload: Record<string, unknown>): void {
298 if (!ENABLE_DRAFT_PERSIST_DEBUG_LOGS) {
299 return;
300 }
301 console.debug(`[draft-persist] ${message}`, payload);
302}
303
304const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {294const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
305 pathValue: string;295 pathValue: string;
306 kind: "file" | "directory";296 kind: "file" | "directory";
...@@ -1261,31 +1251,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1261,31 +1251,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
1261 return;1251 return;
1262 }1252 }
1263 // Stage attachments in persisted draft state first so persist middleware can write them.1253 // Stage attachments in persisted draft state first so persist middleware can write them.
1264 const stagedAttachmentIds = serialized.map((attachment) => attachment.id);1254 syncComposerDraftPersistedAttachments(threadId, serialized);
1265 debugDraftPersist("stage", {1255 } catch {
1266 threadId,
1267 attachmentCount: serialized.length,
1268 serializationFailedCount: serializationFailedImageIds.length,
1269 serializationFailedImageIds,
1270 attachmentMeta: serialized.map((attachment) => ({
1271 id: attachment.id,
1272 name: attachment.name,
1273 sizeBytes: attachment.sizeBytes,
1274 dataUrlChars: attachment.dataUrl.length,
1275 })),
1276 storageCharsBefore: readComposerDraftPersistStorageChars(),
1277 });
1278 syncComposerDraftPersistedAttachments(
1279 threadId,
1280 serialized,
1281 );
1282 debugDraftPersist("reconcile-request", {
1283 threadId,
1284 stagedCount: stagedAttachmentIds.length,
1285 serializationFailedCount: serializationFailedImageIds.length,
1286 storageCharsAfter: readComposerDraftPersistStorageChars(),
1287 });
1288 } catch (error) {
1289 const currentImageIds = new Set(composerImages.map((image) => image.id));1256 const currentImageIds = new Set(composerImages.map((image) => image.id));
1290 const fallbackPersistedAttachments =1257 const fallbackPersistedAttachments =
1291 useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? [];1258 useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? [];
...@@ -1296,13 +1263,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1296,13 +1263,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
1296 const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) =>1263 const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) =>
1297 fallbackPersistedIdSet.has(attachment.id),1264 fallbackPersistedIdSet.has(attachment.id),
1298 );1265 );
1299 debugDraftPersist("error", {
1300 threadId,
1301 error: error instanceof Error ? error.message : String(error),
1302 imageIds: composerImages.map((image) => image.id),
1303 fallbackPersistedCount: fallbackPersistedIds.length,
1304 storageChars: readComposerDraftPersistStorageChars(),
1305 });
1306 if (cancelled) {1266 if (cancelled) {
1307 return;1267 return;
1308 }1268 }
apps/web/src/composerDraftStore.ts+24-69
...@@ -116,9 +116,6 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = {...@@ -116,9 +116,6 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = {
116 projectDraftThreadIdByProjectId: {},116 projectDraftThreadIdByProjectId: {},
117};117};
118118
119let persistedDraftStateCache: PersistedComposerDraftStoreState = EMPTY_PERSISTED_DRAFT_STORE_STATE;
120let persistedDraftStorageChars: number | null = null;
121
122const EMPTY_IMAGES: ComposerImageAttachment[] = [];119const EMPTY_IMAGES: ComposerImageAttachment[] = [];
123const EMPTY_IDS: string[] = [];120const EMPTY_IDS: string[] = [];
124const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = [];121const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = [];
...@@ -337,17 +334,25 @@ function parsePersistedDraftStateRaw(raw: string | null): PersistedComposerDraft...@@ -337,17 +334,25 @@ function parsePersistedDraftStateRaw(raw: string | null): PersistedComposerDraft
337 }334 }
338}335}
339336
340function readPersistedAttachmentIdsFromCache(threadId: ThreadId): string[] {337function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] {
341 if (threadId.length === 0) {338 if (threadId.length === 0) {
342 return [];339 return [];
343 }340 }
344 return (persistedDraftStateCache.draftsByThreadId[threadId]?.attachments ?? []).map(341 try {
345 (attachment) => attachment.id,342 const raw = localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY);
346 );343 const persisted = parsePersistedDraftStateRaw(raw);
344 return (persisted.draftsByThreadId[threadId]?.attachments ?? []).map(
345 (attachment) => attachment.id,
346 );
347 } catch {
348 return [];
349 }
347}350}
348351
349function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null {352function hydreatePersistedComposerImageAttachment(
350 const [rawHeader, payload] = dataUrl.split(",", 2);353 attachment: PersistedComposerImageAttachment,
354): File | null {
355 const [rawHeader, payload] = attachment.dataUrl.split(",", 2);
351 const header = rawHeader ?? "";356 const header = rawHeader ?? "";
352 if (!payload) {357 if (!payload) {
353 return null;358 return null;
...@@ -359,15 +364,17 @@ function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File...@@ -359,15 +364,17 @@ function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File
359 const inferredMimeType =364 const inferredMimeType =
360 header.startsWith("data:") && header.includes(";")365 header.startsWith("data:") && header.includes(";")
361 ? header.slice("data:".length, header.indexOf(";"))366 ? header.slice("data:".length, header.indexOf(";"))
362 : mimeType;367 : attachment.mimeType;
363 return new File([decodedText], name, { type: inferredMimeType || mimeType });368 return new File([decodedText], attachment.name, {
369 type: inferredMimeType || attachment.mimeType,
370 });
364 }371 }
365 const binary = atob(payload);372 const binary = atob(payload);
366 const bytes = new Uint8Array(binary.length);373 const bytes = new Uint8Array(binary.length);
367 for (let index = 0; index < binary.length; index += 1) {374 for (let index = 0; index < binary.length; index += 1) {
368 bytes[index] = binary.charCodeAt(index);375 bytes[index] = binary.charCodeAt(index);
369 }376 }
370 return new File([bytes], name, { type: mimeType });377 return new File([bytes], attachment.name, { type: attachment.mimeType });
371 } catch {378 } catch {
372 return null;379 return null;
373 }380 }
...@@ -377,10 +384,9 @@ function hydrateImagesFromPersisted(...@@ -377,10 +384,9 @@ function hydrateImagesFromPersisted(
377 attachments: PersistedComposerImageAttachment[],384 attachments: PersistedComposerImageAttachment[],
378): ComposerImageAttachment[] {385): ComposerImageAttachment[] {
379 return attachments.flatMap((attachment) => {386 return attachments.flatMap((attachment) => {
380 const file = fileFromDataUrl(attachment.dataUrl, attachment.name, attachment.mimeType);387 const file = hydreatePersistedComposerImageAttachment(attachment);
381 if (!file) {388 if (!file) return [];
382 return [];389
383 }
384 return [390 return [
385 {391 {
386 type: "image" as const,392 type: "image" as const,
...@@ -409,52 +415,6 @@ function toHydratedThreadDraft(...@@ -409,52 +415,6 @@ function toHydratedThreadDraft(
409 };415 };
410}416}
411417
412const composerDraftStorage = createJSONStorage(() => ({
413 getItem: (name: string): string | null => {
414 if (typeof window === "undefined") {
415 persistedDraftStateCache = EMPTY_PERSISTED_DRAFT_STORE_STATE;
416 persistedDraftStorageChars = null;
417 return null;
418 }
419 try {
420 const raw = window.localStorage.getItem(name);
421 persistedDraftStateCache = parsePersistedDraftStateRaw(raw);
422 persistedDraftStorageChars = raw?.length ?? 0;
423 return raw;
424 } catch {
425 persistedDraftStateCache = EMPTY_PERSISTED_DRAFT_STORE_STATE;
426 persistedDraftStorageChars = null;
427 return null;
428 }
429 },
430 setItem: (name: string, value: string): void => {
431 if (typeof window === "undefined") {
432 return;
433 }
434 try {
435 window.localStorage.setItem(name, value);
436 persistedDraftStateCache = parsePersistedDraftStateRaw(value);
437 persistedDraftStorageChars = value.length;
438 } catch {
439 // Quota/storage errors are expected for large drafts. Keep last successful snapshot.
440 }
441 },
442 removeItem: (name: string): void => {
443 if (typeof window === "undefined") {
444 persistedDraftStateCache = EMPTY_PERSISTED_DRAFT_STORE_STATE;
445 persistedDraftStorageChars = null;
446 return;
447 }
448 try {
449 window.localStorage.removeItem(name);
450 } catch {
451 // Ignore remove failures; clear in-memory cache regardless.
452 }
453 persistedDraftStateCache = EMPTY_PERSISTED_DRAFT_STORE_STATE;
454 persistedDraftStorageChars = 0;
455 },
456}));
457
458export const useComposerDraftStore = create<ComposerDraftStoreState>()(418export const useComposerDraftStore = create<ComposerDraftStoreState>()(
459 persist(419 persist(
460 (set, get) => ({420 (set, get) => ({
...@@ -890,7 +850,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -890,7 +850,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
890 return { draftsByThreadId: nextDraftsByThreadId };850 return { draftsByThreadId: nextDraftsByThreadId };
891 });851 });
892 Promise.resolve().then(() => {852 Promise.resolve().then(() => {
893 const persistedIdSet = new Set(readPersistedAttachmentIdsFromCache(threadId));853 const persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadId));
894 set((state) => {854 set((state) => {
895 const current = state.draftsByThreadId[threadId];855 const current = state.draftsByThreadId[threadId];
896 if (!current) {856 if (!current) {
...@@ -983,7 +943,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -983,7 +943,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
983 {943 {
984 name: COMPOSER_DRAFT_STORAGE_KEY,944 name: COMPOSER_DRAFT_STORAGE_KEY,
985 version: 1,945 version: 1,
986 storage: composerDraftStorage,946 storage: createJSONStorage(() => localStorage),
987 partialize: (state) => {947 partialize: (state) => {
988 const persistedDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {};948 const persistedDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {};
989 for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) {949 for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) {
...@@ -1019,7 +979,6 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1019,7 +979,6 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1019 },979 },
1020 merge: (persistedState, currentState) => {980 merge: (persistedState, currentState) => {
1021 const normalizedPersisted = normalizePersistedComposerDraftState(persistedState);981 const normalizedPersisted = normalizePersistedComposerDraftState(persistedState);
1022 persistedDraftStateCache = normalizedPersisted;
1023 const draftsByThreadId = Object.fromEntries(982 const draftsByThreadId = Object.fromEntries(
1024 Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [983 Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [
1025 threadId,984 threadId,
...@@ -1040,7 +999,3 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1040,7 +999,3 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1040export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState {999export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState {
1041 return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT);1000 return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT);
1042}1001}
1043
1044export function readComposerDraftPersistStorageChars(): number | null {
1045 return persistedDraftStorageChars;
1046}