From 93395839eb45885d1a20ea619c1bcdb813ced8aa Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 4 May 2026 11:41:42 -0700 Subject: [PATCH 1/2] fix(mothership): catch draft restore errors instead of crashing /home Wrap the mount-time draft restore in try/catch with clearDraft on throw, and coerce text to a string in the useState initializer. A corrupt entry in mothership-drafts:v1 localStorage previously took down the entire workspace via the error boundary. --- .../home/components/user-input/user-input.tsx | 53 ++++++++++--------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 6a83a037c1b..0824eb1bc49 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -148,7 +148,8 @@ export const UserInput = forwardRef(function Us const [value, setValue] = useState(() => { if (defaultValue) return defaultValue if (!draftScopeKey) return '' - return useMothershipDraftsStore.getState().drafts[draftScopeKey]?.text ?? '' + const text = useMothershipDraftsStore.getState().drafts[draftScopeKey]?.text + return typeof text === 'string' ? text : '' }) const overlayRef = useRef(null) const plusMenuRef = useRef(null) @@ -189,30 +190,34 @@ export const UserInput = forwardRef(function Us useEffect(() => { if (hasRestoredDraftRef.current || !draftScopeKey) return hasRestoredDraftRef.current = true - const draft = useMothershipDraftsStore.getState().drafts[draftScopeKey] - if (!draft) return - if (draft.contexts?.length) { - contextManagement.setSelectedContexts(draft.contexts) - } - if (draft.fileAttachments?.length) { - files.restoreAttachedFiles( - draft.fileAttachments.map((a) => ({ - id: a.id, - name: a.filename, - size: a.size, - type: a.media_type, - path: a.path ?? '', - key: a.key, - uploading: false, - })) - ) - } - if (draft.text) { - const textarea = textareaRef.current - if (textarea) { - textarea.focus() - textarea.setSelectionRange(draft.text.length, draft.text.length) + try { + const draft = useMothershipDraftsStore.getState().drafts[draftScopeKey] + if (!draft) return + if (draft.contexts?.length) { + contextManagement.setSelectedContexts(draft.contexts) + } + if (draft.fileAttachments?.length) { + files.restoreAttachedFiles( + draft.fileAttachments.map((a) => ({ + id: a.id, + name: a.filename, + size: a.size, + type: a.media_type, + path: a.path ?? '', + key: a.key, + uploading: false, + })) + ) + } + if (typeof draft.text === 'string' && draft.text.length > 0) { + const textarea = textareaRef.current + if (textarea) { + textarea.focus() + textarea.setSelectionRange(draft.text.length, draft.text.length) + } } + } catch { + useMothershipDraftsStore.getState().clearDraft(draftScopeKey) } }, []) // eslint-disable-line react-hooks/exhaustive-deps -- intentional mount-only restore From b375d995bf5de96ad232b371340f5008e724e297 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 4 May 2026 11:47:28 -0700 Subject: [PATCH 2/2] fix(mothership): defer state writes and log restore failures Build the restored state in locals first and only apply on success so a partial throw can't leave stale contexts in the UI with the draft already cleared. Switch the empty catch to logger.error so corrupt-draft incidents surface in production logs. --- .../home/components/user-input/user-input.tsx | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 0824eb1bc49..a60c8ebff23 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -11,6 +11,7 @@ import { useRef, useState, } from 'react' +import { createLogger } from '@sim/logger' import { Paperclip } from 'lucide-react' import { useParams } from 'next/navigation' import { Button, Tooltip } from '@/components/emcn' @@ -58,6 +59,8 @@ import type { ChatContext } from '@/stores/panel' export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types' +const logger = createLogger('UserInput') + function getCaretAnchor( textarea: HTMLTextAreaElement, caretPos: number @@ -190,34 +193,42 @@ export const UserInput = forwardRef(function Us useEffect(() => { if (hasRestoredDraftRef.current || !draftScopeKey) return hasRestoredDraftRef.current = true + let restoredContexts: ChatContext[] | null = null + let restoredFiles: AttachedFile[] | null = null + let caretText: string | null = null try { const draft = useMothershipDraftsStore.getState().drafts[draftScopeKey] if (!draft) return if (draft.contexts?.length) { - contextManagement.setSelectedContexts(draft.contexts) + restoredContexts = draft.contexts } if (draft.fileAttachments?.length) { - files.restoreAttachedFiles( - draft.fileAttachments.map((a) => ({ - id: a.id, - name: a.filename, - size: a.size, - type: a.media_type, - path: a.path ?? '', - key: a.key, - uploading: false, - })) - ) + restoredFiles = draft.fileAttachments.map((a) => ({ + id: a.id, + name: a.filename, + size: a.size, + type: a.media_type, + path: a.path ?? '', + key: a.key, + uploading: false, + })) } if (typeof draft.text === 'string' && draft.text.length > 0) { - const textarea = textareaRef.current - if (textarea) { - textarea.focus() - textarea.setSelectionRange(draft.text.length, draft.text.length) - } + caretText = draft.text } - } catch { + } catch (err) { + logger.error('Failed to read draft, clearing', { err }) useMothershipDraftsStore.getState().clearDraft(draftScopeKey) + return + } + if (restoredContexts) contextManagement.setSelectedContexts(restoredContexts) + if (restoredFiles) files.restoreAttachedFiles(restoredFiles) + if (caretText !== null) { + const textarea = textareaRef.current + if (textarea) { + textarea.focus() + textarea.setSelectionRange(caretText.length, caretText.length) + } } }, []) // eslint-disable-line react-hooks/exhaustive-deps -- intentional mount-only restore