diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index 36b13eb..ee871b6 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -316,6 +316,7 @@ export default function TerminalView({ sessionId, active }: Props) { try { webglRef.current?.dispose(); } catch { /* may already be disposed */ } webglRef.current = null; term.dispose(); + termRef.current = null; }; }, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps @@ -398,6 +399,21 @@ export default function TerminalView({ sessionId, active }: Props) { setScrollActiveToBottom(handleScrollToBottom); }, [active, isAtBottom, handleScrollToBottom, setTerminalAtBottom, setScrollActiveToBottom]); + // On unmount, if this was the active terminal, clear the status-bar scroll + // state so it doesn't point at a disposed terminal. (Tab switches don't + // unmount — the deactivating terminal stays mounted but hidden — so this + // only fires when the active session is actually closed.) + const activeRef = useRef(active); + activeRef.current = active; + useEffect(() => { + return () => { + if (activeRef.current) { + setTerminalAtBottom(true); + setScrollActiveToBottom(() => {}); + } + }; + }, [setTerminalAtBottom, setScrollActiveToBottom]); + const writeSelection = useCallback((mode: "trimmed" | "raw") => { const term = termRef.current; if (!term) return; @@ -466,8 +482,8 @@ export default function TerminalView({ sessionId, active }: Props) { FitAddon measures the host element it's mounted into; padding there causes the grid to overhang and clip the rightmost column / bottom row. The host below fills this wrapper's content box with no padding. - Kept tight so the terminal claims as much area as possible; right side - leaves a little room beside the scrollbar. */} + Kept to a tight, even gutter so the terminal claims as much area as + possible while leaving a little breathing room beside the scrollbar. */}
(null); const workletRef = useRef(null); const chunksRef = useRef([]); + // Pin the transcript to the terminal that was active when recording STARTED. + // The hook is bound to the live active session, which can change mid-recording + // (the user switches tabs); without this the transcript would land in whatever + // tab is active at stop time. + const recordingSessionIdRef = useRef(sessionId); const appSettings = useAppState((s) => s.appSettings); const deviceId = appSettings?.default_microphone; @@ -22,6 +27,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s setState("recording"); setError(null); chunksRef.current = []; + recordingSessionIdRef.current = sessionId; try { const audioConstraints: MediaTrackConstraints = { @@ -57,7 +63,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s setError(msg); setState("error"); } - }, [state, deviceId]); + }, [state, deviceId, sessionId]); const stopRecording = useCallback(async () => { if (state !== "recording") return; @@ -102,7 +108,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s const text = await commands.transcribeAudio(audioData); if (text) { - await sendInput(sessionId, text); + await sendInput(recordingSessionIdRef.current, text); } setState("idle"); } catch (e) { @@ -112,7 +118,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s // Reset to idle after a brief delay so the UI shows the error setTimeout(() => setState("idle"), 3000); } - }, [state, sessionId, sendInput]); + }, [state, sendInput]); const cancelRecording = useCallback(async () => { workletRef.current?.disconnect();