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) {
479479 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
480480 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
481481 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
482 const optimisticUserMessagesRef = useRef(optimisticUserMessages);
483 optimisticUserMessagesRef.current = optimisticUserMessages;
482484 const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<
483485 Record<ThreadId, string | null>
484486 >({});
......@@ -634,6 +636,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
634636 useEffect(() => {
635637 return () => {
636638 clearAttachmentPreviewHandoffs();
639 for (const message of optimisticUserMessagesRef.current) {
640 revokeUserMessagePreviewUrls(message);
641 }
637642 };
638643 }, [clearAttachmentPreviewHandoffs]);
639644 const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {
......@@ -1542,6 +1547,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
15421547
15431548 if (!previous && current) {
15441549 terminalOpenByThreadRef.current[activeThreadId] = current;
1550 setTerminalFocusRequestId((value) => value + 1);
15451551 return;
15461552 } else if (previous && !current) {
15471553 terminalOpenByThreadRef.current[activeThreadId] = current;
apps/web/src/components/Sidebar.tsx+3
......@@ -241,6 +241,7 @@ export default function Sidebar() {
241241 );
242242 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
243243 const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
244 const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
244245 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
245246 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
246247 const clearProjectDraftThreadId = useComposerDraftStore(
......@@ -578,6 +579,7 @@ export default function Sidebar() {
578579 });
579580 clearComposerDraftForThread(threadId);
580581 clearProjectDraftThreadById(thread.projectId, thread.id);
582 clearTerminalState(threadId);
581583 if (shouldNavigateToFallback) {
582584 if (fallbackThreadId) {
583585 void navigate({
......@@ -619,6 +621,7 @@ export default function Sidebar() {
619621 appSettings.confirmThreadDelete,
620622 clearComposerDraftForThread,
621623 clearProjectDraftThreadById,
624 clearTerminalState,
622625 markThreadUnread,
623626 navigate,
624627 projects,
apps/web/src/routes/__root.tsx+9
......@@ -143,11 +143,20 @@ function EventRouter() {
143143 let syncing = false;
144144 let pending = false;
145145
146 const removeOrphanedTerminalStates =
147 useTerminalStateStore.getState().removeOrphanedTerminalStates;
148
146149 const flushSnapshotSync = async (): Promise<void> => {
147150 const snapshot = await api.orchestration.getSnapshot();
148151 if (disposed) return;
149152 latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
150153 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);
151160 if (pending) {
152161 pending = false;
153162 await flushSnapshotSync();
apps/web/src/terminalStateStore.ts+13
......@@ -461,6 +461,7 @@ interface TerminalStateStoreState {
461461 hasRunningSubprocess: boolean,
462462 ) => void;
463463 clearTerminalState: (threadId: ThreadId) => void;
464 removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void;
464465}
465466
466467export const useTerminalStateStore = create<TerminalStateStoreState>()(
......@@ -505,6 +506,18 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
505506 ),
506507 clearTerminalState: (threadId) =>
507508 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 }),
508521 };
509522 },
510523 {