From 32fe80d1647ea9fb466fe5fab67b74fea78f011b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Mar 2026 20:22:06 +0000 Subject: [PATCH] Fix terminal focus on toggle, clean up orphaned terminal state, and revoke optimistic message blob URLs on unmount - Add setTerminalFocusRequestId call when terminal opens via toggle so the terminal panel receives focus automatically - Call clearTerminalState when deleting threads in the Sidebar and add removeOrphanedTerminalStates to prune stale entries after read model sync - Track optimisticUserMessages via ref and revoke their blob URLs in the unmount cleanup effect to prevent memory leaks Co-authored-by: Julius Marminge --- apps/web/src/components/ChatView.tsx | 6 ++++++ apps/web/src/components/Sidebar.tsx | 3 +++ apps/web/src/routes/__root.tsx | 9 +++++++++ apps/web/src/terminalStateStore.ts | 13 +++++++++++++ 4 files changed, 31 insertions(+) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8d87198559bb19e3637df7013ba2f6dfbb31fd34..73178a2dcace15c314baa56f3558f03750c2937e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -479,6 +479,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); + const optimisticUserMessagesRef = useRef(optimisticUserMessages); + optimisticUserMessagesRef.current = optimisticUserMessages; const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< Record >({}); @@ -634,6 +636,9 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { return () => { clearAttachmentPreviewHandoffs(); + for (const message of optimisticUserMessagesRef.current) { + revokeUserMessagePreviewUrls(message); + } }; }, [clearAttachmentPreviewHandoffs]); const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { @@ -1542,6 +1547,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!previous && current) { terminalOpenByThreadRef.current[activeThreadId] = current; + setTerminalFocusRequestId((value) => value + 1); return; } else if (previous && !current) { terminalOpenByThreadRef.current[activeThreadId] = current; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d5921da8a9b8145272f1db209aadadc913445625..d32b604cf3159097d55dbb6f546ebd7e01a0b148 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -241,6 +241,7 @@ export default function Sidebar() { ); const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); + const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const clearProjectDraftThreadId = useComposerDraftStore( @@ -578,6 +579,7 @@ export default function Sidebar() { }); clearComposerDraftForThread(threadId); clearProjectDraftThreadById(thread.projectId, thread.id); + clearTerminalState(threadId); if (shouldNavigateToFallback) { if (fallbackThreadId) { void navigate({ @@ -619,6 +621,7 @@ export default function Sidebar() { appSettings.confirmThreadDelete, clearComposerDraftForThread, clearProjectDraftThreadById, + clearTerminalState, markThreadUnread, navigate, projects, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 219a22d84ab141a93715f780bf258d2492fab491..36f8abac80555a5eca3ee851985aa583e2a462c1 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -143,11 +143,20 @@ function EventRouter() { let syncing = false; let pending = false; + const removeOrphanedTerminalStates = + useTerminalStateStore.getState().removeOrphanedTerminalStates; + const flushSnapshotSync = async (): Promise => { const snapshot = await api.orchestration.getSnapshot(); if (disposed) return; latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); syncServerReadModel(snapshot); + const activeThreadIds = new Set( + snapshot.threads + .filter((t) => t.deletedAt === null) + .map((t) => t.id), + ); + removeOrphanedTerminalStates(activeThreadIds); if (pending) { pending = false; await flushSnapshotSync(); diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index 20f66910a5bd8f66f176a1880e1df4ee64f837fd..5f65dca3a873230697da36498fb2c5318ccc974f 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -461,6 +461,7 @@ interface TerminalStateStoreState { hasRunningSubprocess: boolean, ) => void; clearTerminalState: (threadId: ThreadId) => void; + removeOrphanedTerminalStates: (activeThreadIds: Set) => void; } export const useTerminalStateStore = create()( @@ -505,6 +506,18 @@ export const useTerminalStateStore = create()( ), clearTerminalState: (threadId) => updateTerminal(threadId, () => createDefaultThreadTerminalState()), + removeOrphanedTerminalStates: (activeThreadIds) => + set((state) => { + const orphanedIds = Object.keys(state.terminalStateByThreadId).filter( + (id) => !activeThreadIds.has(id as ThreadId), + ); + if (orphanedIds.length === 0) return state; + const next = { ...state.terminalStateByThreadId }; + for (const id of orphanedIds) { + delete next[id as ThreadId]; + } + return { terminalStateByThreadId: next }; + }), }; }, { -- 2.54.0