authorgravatar for cursoragent@cursor.comCursor Agent <cursoragent@cursor.com> 2026-03-02 20:22:06+00:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 13:13:19-08:00
log32fe80d1647ea9fb466fe5fab67b74fea78f011b
tree28562e51f579392a7960571431efca89630d3f0b
parent6e93d42023a61f638d7a83c50083a18040f136c1

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 <juliusmarminge@users.noreply.github.com>

4 files changed, 31 insertions(+), 0 deletions(-)

apps/web/src/components/ChatView.tsx+6
...@@ -479,6 +479,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -479,6 +479,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
479 const [isDragOverComposer, setIsDragOverComposer] = useState(false);479 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
480 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);480 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
481 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);481 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
482 const optimisticUserMessagesRef = useRef(optimisticUserMessages);
483 optimisticUserMessagesRef.current = optimisticUserMessages;
482 const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<484 const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<
483 Record<ThreadId, string | null>485 Record<ThreadId, string | null>
484 >({});486 >({});
...@@ -634,6 +636,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -634,6 +636,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
634 useEffect(() => {636 useEffect(() => {
635 return () => {637 return () => {
636 clearAttachmentPreviewHandoffs();638 clearAttachmentPreviewHandoffs();
639 for (const message of optimisticUserMessagesRef.current) {
640 revokeUserMessagePreviewUrls(message);
641 }
637 };642 };
638 }, [clearAttachmentPreviewHandoffs]);643 }, [clearAttachmentPreviewHandoffs]);
639 const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {644 const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {
...@@ -1542,6 +1547,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1542,6 +1547,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
15421547
1543 if (!previous && current) {1548 if (!previous && current) {
1544 terminalOpenByThreadRef.current[activeThreadId] = current;1549 terminalOpenByThreadRef.current[activeThreadId] = current;
1550 setTerminalFocusRequestId((value) => value + 1);
1545 return;1551 return;
1546 } else if (previous && !current) {1552 } else if (previous && !current) {
1547 terminalOpenByThreadRef.current[activeThreadId] = current;1553 terminalOpenByThreadRef.current[activeThreadId] = current;
apps/web/src/components/Sidebar.tsx+3
...@@ -241,6 +241,7 @@ export default function Sidebar() {...@@ -241,6 +241,7 @@ export default function Sidebar() {
241 );241 );
242 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);242 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
243 const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);243 const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
244 const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
244 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);245 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
245 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);246 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
246 const clearProjectDraftThreadId = useComposerDraftStore(247 const clearProjectDraftThreadId = useComposerDraftStore(
...@@ -578,6 +579,7 @@ export default function Sidebar() {...@@ -578,6 +579,7 @@ export default function Sidebar() {
578 });579 });
579 clearComposerDraftForThread(threadId);580 clearComposerDraftForThread(threadId);
580 clearProjectDraftThreadById(thread.projectId, thread.id);581 clearProjectDraftThreadById(thread.projectId, thread.id);
582 clearTerminalState(threadId);
581 if (shouldNavigateToFallback) {583 if (shouldNavigateToFallback) {
582 if (fallbackThreadId) {584 if (fallbackThreadId) {
583 void navigate({585 void navigate({
...@@ -619,6 +621,7 @@ export default function Sidebar() {...@@ -619,6 +621,7 @@ export default function Sidebar() {
619 appSettings.confirmThreadDelete,621 appSettings.confirmThreadDelete,
620 clearComposerDraftForThread,622 clearComposerDraftForThread,
621 clearProjectDraftThreadById,623 clearProjectDraftThreadById,
624 clearTerminalState,
622 markThreadUnread,625 markThreadUnread,
623 navigate,626 navigate,
624 projects,627 projects,
apps/web/src/routes/__root.tsx+9
...@@ -143,11 +143,20 @@ function EventRouter() {...@@ -143,11 +143,20 @@ function EventRouter() {
143 let syncing = false;143 let syncing = false;
144 let pending = false;144 let pending = false;
145145
146 const removeOrphanedTerminalStates =
147 useTerminalStateStore.getState().removeOrphanedTerminalStates;
148
146 const flushSnapshotSync = async (): Promise<void> => {149 const flushSnapshotSync = async (): Promise<void> => {
147 const snapshot = await api.orchestration.getSnapshot();150 const snapshot = await api.orchestration.getSnapshot();
148 if (disposed) return;151 if (disposed) return;
149 latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);152 latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
150 syncServerReadModel(snapshot);153 syncServerReadModel(snapshot);
154 const activeThreadIds = new Set(
155 snapshot.threads
156 .filter((t) => t.deletedAt === null)
157 .map((t) => t.id),
158 );
159 removeOrphanedTerminalStates(activeThreadIds);
151 if (pending) {160 if (pending) {
152 pending = false;161 pending = false;
153 await flushSnapshotSync();162 await flushSnapshotSync();
apps/web/src/terminalStateStore.ts+13
...@@ -461,6 +461,7 @@ interface TerminalStateStoreState {...@@ -461,6 +461,7 @@ interface TerminalStateStoreState {
461 hasRunningSubprocess: boolean,461 hasRunningSubprocess: boolean,
462 ) => void;462 ) => void;
463 clearTerminalState: (threadId: ThreadId) => void;463 clearTerminalState: (threadId: ThreadId) => void;
464 removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void;
464}465}
465466
466export const useTerminalStateStore = create<TerminalStateStoreState>()(467export const useTerminalStateStore = create<TerminalStateStoreState>()(
...@@ -505,6 +506,18 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -505,6 +506,18 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
505 ),506 ),
506 clearTerminalState: (threadId) =>507 clearTerminalState: (threadId) =>
507 updateTerminal(threadId, () => createDefaultThreadTerminalState()),508 updateTerminal(threadId, () => createDefaultThreadTerminalState()),
509 removeOrphanedTerminalStates: (activeThreadIds) =>
510 set((state) => {
511 const orphanedIds = Object.keys(state.terminalStateByThreadId).filter(
512 (id) => !activeThreadIds.has(id as ThreadId),
513 );
514 if (orphanedIds.length === 0) return state;
515 const next = { ...state.terminalStateByThreadId };
516 for (const id of orphanedIds) {
517 delete next[id as ThreadId];
518 }
519 return { terminalStateByThreadId: next };
520 }),
508 };521 };
509 },522 },
510 {523 {