fix(mothership): catch draft restore errors instead of crashing /home (#4433)

* 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.

* 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.
This commit is contained in:
Waleed
2026-05-04 12:13:30 -07:00
committed by GitHub
parent 57dc745bab
commit 3af6c25b97
@@ -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
@@ -148,7 +151,8 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(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<HTMLDivElement>(null)
const plusMenuRef = useRef<PlusMenuHandle>(null)
@@ -189,14 +193,17 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(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) => ({
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) {
restoredContexts = draft.contexts
}
if (draft.fileAttachments?.length) {
restoredFiles = draft.fileAttachments.map((a) => ({
id: a.id,
name: a.filename,
size: a.size,
@@ -205,13 +212,22 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
key: a.key,
uploading: false,
}))
)
}
if (typeof draft.text === 'string' && draft.text.length > 0) {
caretText = draft.text
}
} catch (err) {
logger.error('Failed to read draft, clearing', { err })
useMothershipDraftsStore.getState().clearDraft(draftScopeKey)
return
}
if (draft.text) {
if (restoredContexts) contextManagement.setSelectedContexts(restoredContexts)
if (restoredFiles) files.restoreAttachedFiles(restoredFiles)
if (caretText !== null) {
const textarea = textareaRef.current
if (textarea) {
textarea.focus()
textarea.setSelectionRange(draft.text.length, draft.text.length)
textarea.setSelectionRange(caretText.length, caretText.length)
}
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps -- intentional mount-only restore