Compare commits

..

6 Commits

Author SHA1 Message Date
jknapp 1716fb5c82 Merge pull request 'Fix xterm clipping; move mic + Jump to Current into status bar' (#7) from terminal-layout-statusbar into main
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m20s
Build App / build-windows (push) Successful in 4m39s
Build App / build-linux (push) Successful in 4m58s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 10s
Reviewed-on: #7
2026-06-29 15:23:35 +00:00
shadow-test da7b7b9bd5 Address review: pin STT transcript, clear stale scroll state
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m15s
Build App / build-windows (pull_request) Successful in 3m49s
Build App / build-linux (pull_request) Successful in 4m48s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Follow-up to PR review on terminal-layout-statusbar:

- [Major] Pin STT transcripts to the originating terminal. The single
  useSTT instance is bound to the live active session, which can change
  mid-recording. Capture the session id at recording start in a ref and
  inject the transcript there instead of the live sessionId, so text
  always lands in the terminal where recording began.
- [Minor] Clear the status-bar scroll state when the active terminal
  unmounts, and null out termRef on dispose, so scrollActiveToBottom
  can't point at a disposed terminal. Tab switches don't unmount, so
  this only fires when the active session is actually closed.
- [Nit] Fix the terminal padding comment to match the symmetric value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:33:27 -07:00
shadow-test 3d2d979197 Fix xterm clipping; move mic + Jump to Current into status bar
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m19s
Build App / build-linux (pull_request) Successful in 5m3s
Build App / build-windows (pull_request) Successful in 7m34s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Terminal layout fixes for the xterm pane:

- Stop the terminal grid from clipping its rightmost column / bottom
  row. The padding was on the element xterm mounts into, which the
  FitAddon measures; the grid overhang got clipped. Padding now lives on
  a wrapper and the xterm host fills it with no padding.
- Move the STT mic from a floating bottom-left overlay into the status
  bar (far right). A single useSTT instance bound to the active session
  now lives in App; Ctrl+Shift+M routes through the store.
- Move "Jump to Current" from a floating terminal overlay into the
  status bar. The active TerminalView surfaces its scroll state and
  scroll action via the store.
- Tighten terminal padding (was 8/12/48/16) now that nothing floats over
  it, so the terminal claims as much area as possible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:26:05 -07:00
jknapp de2752557d Merge pull request 'Fix Windows release upload: idempotent get-or-create + fail-loud' (#6) from feature/ux-improvements into main
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m39s
Build App / build-windows (push) Successful in 3m56s
Build App / build-linux (push) Successful in 5m51s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 13s
Reviewed-on: #6
2026-06-24 16:28:58 +00:00
jknapp 9221c25474 Merge branch 'main' into feature/ux-improvements
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m35s
Build App / build-windows (pull_request) Successful in 3m51s
Build App / build-linux (pull_request) Successful in 5m20s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
2026-06-24 16:28:45 +00:00
shadow-test 997e1ab3a9 Fix Windows release upload: idempotent get-or-create + fail-loud
Build App / compute-version (pull_request) Successful in 9s
Build App / build-macos (pull_request) Successful in 2m36s
Build App / build-windows (pull_request) Successful in 2m50s
Build App / build-linux (pull_request) Successful in 6m26s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
The cmd-batch upload step POSTed to /releases unconditionally. On a
re-run the v{VERSION}-win tag already exists, so Gitea returns 409, the
findstr id parse yields an empty RELEASE_ID, and uploads go to a
malformed .../releases//assets URL -- all silently swallowed by cmd and
`curl -s`, so the step reported success while attaching no assets.

Rewrite in PowerShell mirroring the macOS job: look the release up by
tag first and create only on 404, throw if the id can't be resolved,
delete same-named assets left over from partial runs before re-upload,
and fail loudly (ErrorActionPreference=Stop, curl.exe -fsS with retries,
$LASTEXITCODE check).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:22:55 -07:00
7 changed files with 189 additions and 62 deletions
+52 -10
View File
@@ -428,20 +428,62 @@ jobs:
- name: Upload to Gitea release - name: Upload to Gitea release
if: gitea.event_name == 'push' if: gitea.event_name == 'push'
shell: powershell
env: env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
COMMIT_SHA: ${{ gitea.sha }} COMMIT_SHA: ${{ gitea.sha }}
VERSION: ${{ needs.compute-version.outputs.version }}
run: | run: |
set "TAG=v${{ needs.compute-version.outputs.version }}-win" $ErrorActionPreference = "Stop"
echo Creating release %TAG%... $tag = "v$env:VERSION-win"
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (Windows)\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json $headers = @{ Authorization = "token $env:TOKEN" }
for /f "tokens=2 delims=:," %%a in ('findstr /c:"\"id\"" release.json') do set "RELEASE_ID=%%a" & goto :found $api = "$env:GITEA_URL/api/v1/repos/$env:REPO"
:found
echo Release ID: %RELEASE_ID% # Idempotent get-or-create. The old cmd-batch version swallowed
for %%f in (artifacts\*) do ( # curl errors and parsed the release id with findstr, so a 409 on
echo Uploading %%~nxf... # a pre-existing tag yielded an empty RELEASE_ID and uploads went to
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/octet-stream" --data-binary "@%%f" "%GITEA_URL%/api/v1/repos/%REPO%/releases/%RELEASE_ID%/assets?name=%%~nxf" # a malformed .../releases//assets URL while the step still reported
) # success. Look the release up by tag first; create only on 404.
try {
$release = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/tags/$tag"
Write-Host "Release $tag already exists, reusing"
} catch {
if ($_.Exception.Response.StatusCode.value__ -eq 404) {
Write-Host "Release $tag not found, creating"
$body = @{
tag_name = $tag
name = "Triple-C v$env:VERSION (Windows)"
body = "Automated build from commit $env:COMMIT_SHA"
} | ConvertTo-Json
$release = Invoke-RestMethod -Method Post -Headers $headers `
-ContentType "application/json" -Body $body -Uri "$api/releases"
} else {
throw
}
}
$releaseId = $release.id
if (-not $releaseId) { throw "Failed to resolve release id for $tag" }
Write-Host "Release ID: $releaseId"
# Upload each artifact. Delete any same-named asset left over from a
# partial prior run first, so the upload replaces rather than 409s.
$existing = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/$releaseId/assets"
foreach ($file in Get-ChildItem -File -Path artifacts\*) {
$name = $file.Name
$dupe = $existing | Where-Object { $_.name -eq $name }
if ($dupe) {
Write-Host "Deleting existing asset $name (id $($dupe.id))"
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$releaseId/assets/$($dupe.id)" | Out-Null
}
Write-Host "Uploading $name..."
$uploadUri = "$api/releases/$releaseId/assets?name=$([uri]::EscapeDataString($name))"
curl.exe -fsS --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 `
-X POST -H "Authorization: token $env:TOKEN" `
-H "Content-Type: application/octet-stream" `
--data-binary "@$($file.FullName)" $uploadUri
if ($LASTEXITCODE -ne 0) { throw "Upload of $name failed (curl exit $LASTEXITCODE)" }
}
create-tag: create-tag:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+14 -3
View File
@@ -10,6 +10,8 @@ import { useSettings } from "./hooks/useSettings";
import { useProjects } from "./hooks/useProjects"; import { useProjects } from "./hooks/useProjects";
import { useMcpServers } from "./hooks/useMcpServers"; import { useMcpServers } from "./hooks/useMcpServers";
import { useUpdates } from "./hooks/useUpdates"; import { useUpdates } from "./hooks/useUpdates";
import { useTerminal } from "./hooks/useTerminal";
import { useSTT } from "./hooks/useSTT";
import { useAppState } from "./store/appState"; import { useAppState } from "./store/appState";
import { reconcileProjectStatuses } from "./lib/tauri-commands"; import { reconcileProjectStatuses } from "./lib/tauri-commands";
@@ -19,11 +21,20 @@ export default function App() {
const { refresh } = useProjects(); const { refresh } = useProjects();
const { refresh: refreshMcp } = useMcpServers(); const { refresh: refreshMcp } = useMcpServers();
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates(); const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
const { sessions, activeSessionId, setProjects } = useAppState( const { sessions, activeSessionId, setProjects, setSttToggle } = useAppState(
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects })) useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects, setSttToggle: s.setSttToggle }))
); );
const [showInstallDialog, setShowInstallDialog] = useState(false); const [showInstallDialog, setShowInstallDialog] = useState(false);
// Single STT instance bound to the active session. The mic lives in the
// StatusBar; the terminal's Ctrl+Shift+M shortcut calls stt.toggle via the
// store (registered below).
const { sendInput } = useTerminal();
const stt = useSTT(activeSessionId ?? "", sendInput);
useEffect(() => {
setSttToggle(stt.toggle);
}, [stt.toggle, setSttToggle]);
// Initialize on mount // Initialize on mount
useEffect(() => { useEffect(() => {
loadSettings(); loadSettings();
@@ -82,7 +93,7 @@ export default function App() {
)} )}
</main> </main>
</div> </div>
<StatusBar /> <StatusBar stt={stt} />
{showInstallDialog && ( {showInstallDialog && (
<DockerInstallDialog onClose={() => setShowInstallDialog(false)} /> <DockerInstallDialog onClose={() => setShowInstallDialog(false)} />
)} )}
+40 -3
View File
@@ -1,9 +1,26 @@
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
import SttButton from "../terminal/SttButton";
import type { useSTT } from "../../hooks/useSTT";
export default function StatusBar() { interface Props {
const { projects, sessions, terminalHasSelection } = useAppState( stt: ReturnType<typeof useSTT>;
useShallow(s => ({ projects: s.projects, sessions: s.sessions, terminalHasSelection: s.terminalHasSelection })) }
export default function StatusBar({ stt }: Props) {
const {
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
terminalAtBottom, scrollActiveToBottom,
} = useAppState(
useShallow(s => ({
projects: s.projects,
sessions: s.sessions,
terminalHasSelection: s.terminalHasSelection,
activeSessionId: s.activeSessionId,
sttEnabled: s.appSettings?.stt?.enabled,
terminalAtBottom: s.terminalAtBottom,
scrollActiveToBottom: s.scrollActiveToBottom,
}))
); );
const running = projects.filter((p) => p.status === "running").length; const running = projects.filter((p) => p.status === "running").length;
@@ -28,6 +45,26 @@ export default function StatusBar() {
</span> </span>
</> </>
)} )}
{/* Right-aligned controls: Jump to Current + STT mic */}
<div className="ml-auto flex items-center gap-3 pl-2">
{activeSessionId && !terminalAtBottom && (
<button
onClick={() => scrollActiveToBottom()}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
title="Scroll the terminal to the latest output"
>
Jump to Current
</button>
)}
{sttEnabled && activeSessionId && (
<SttButton
state={stt.state}
error={stt.error}
onToggle={stt.toggle}
onCancel={stt.cancelRecording}
/>
)}
</div>
</div> </div>
); );
} }
+16 -18
View File
@@ -62,7 +62,15 @@ export default function SttButton({ state, error, onToggle, onCancel }: Props) {
}; };
return ( return (
<div className="absolute bottom-2 left-2 z-50 flex items-center gap-2"> <div className="flex items-center gap-1.5">
{state === "recording" && (
<span className="text-[#f85149] font-mono">{formatTime(elapsed)}</span>
)}
{state === "error" && error && (
<span className="text-[#f85149] max-w-[180px] truncate" title={error}>
{error}
</span>
)}
<div className="relative"> <div className="relative">
<button <button
onClick={handleClick} onClick={handleClick}
@@ -71,44 +79,34 @@ export default function SttButton({ state, error, onToggle, onCancel }: Props) {
onMouseEnter={() => setHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setHovered(false)}
disabled={state === "transcribing"} disabled={state === "transcribing"}
className={`w-8 h-8 rounded-full flex items-center justify-center transition-all cursor-pointer ${ className={`w-5 h-5 rounded-full flex items-center justify-center transition-all cursor-pointer ${
state === "recording" state === "recording"
? "bg-[#f85149] text-white shadow-lg animate-pulse" ? "bg-[#f85149] text-white animate-pulse"
: state === "transcribing" : state === "transcribing"
? "bg-[#1f2937] text-[#58a6ff] border border-[#30363d] opacity-80" ? "text-[#58a6ff] opacity-80"
: "bg-[#1f2937]/80 text-[#8b949e] border border-[#30363d] hover:text-[#e6edf3] hover:bg-[#2d3748]" : "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]"
}`} }`}
> >
{state === "transcribing" ? ( {state === "transcribing" ? (
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none"> <svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" opacity="0.25" /> <circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" opacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> <path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg> </svg>
) : ( ) : (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"> <svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" /> <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" /> <path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
</svg> </svg>
)} )}
</button> </button>
{hovered && state !== "recording" && ( {hovered && state !== "recording" && (
<div className="absolute bottom-full left-0 mb-1.5 px-2 py-1 text-[11px] leading-snug text-[#e6edf3] bg-[#21262d] border border-[#30363d] rounded shadow-lg whitespace-nowrap pointer-events-none"> <div className="absolute bottom-full right-0 mb-1.5 px-2 py-1 text-[11px] leading-snug text-[#e6edf3] bg-[#21262d] border border-[#30363d] rounded shadow-lg whitespace-nowrap pointer-events-none z-50">
{state === "transcribing" ? "Transcribing..." : ( {state === "transcribing" ? "Transcribing..." : (
<>Speech to text <kbd className="ml-1 px-1 py-0.5 text-[10px] bg-[#0d1117] border border-[#30363d] rounded font-mono">Ctrl+Shift+M</kbd></> <>Speech to text <kbd className="ml-1 px-1 py-0.5 text-[10px] bg-[#0d1117] border border-[#30363d] rounded font-mono">Ctrl+Shift+M</kbd></>
)} )}
</div> </div>
)} )}
</div> </div>
{state === "recording" && (
<span className="text-xs text-[#f85149] font-mono bg-[#1f2937] px-2 py-0.5 rounded border border-[#30363d]">
{formatTime(elapsed)}
</span>
)}
{state === "error" && error && (
<span className="text-xs text-[#f85149] bg-[#1f2937] px-2 py-0.5 rounded border border-[#30363d] max-w-[200px] truncate">
{error}
</span>
)}
</div> </div>
); );
} }
+42 -25
View File
@@ -7,8 +7,6 @@ import { openUrl } from "@tauri-apps/plugin-opener";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { useTerminal } from "../../hooks/useTerminal"; import { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
import { useSTT } from "../../hooks/useSTT";
import SttButton from "./SttButton";
import { awsSsoRefresh } from "../../lib/tauri-commands"; import { awsSsoRefresh } from "../../lib/tauri-commands";
import { UrlDetector } from "../../lib/urlDetector"; import { UrlDetector } from "../../lib/urlDetector";
import UrlToast from "./UrlToast"; import UrlToast from "./UrlToast";
@@ -29,10 +27,8 @@ export default function TerminalView({ sessionId, active }: Props) {
const detectorRef = useRef<UrlDetector | null>(null); const detectorRef = useRef<UrlDetector | null>(null);
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal(); const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection); const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
const sttEnabled = useAppState(s => s.appSettings?.stt?.enabled); const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
const stt = useSTT(sessionId, sendInput); const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
const sttToggleRef = useRef(stt.toggle);
sttToggleRef.current = stt.toggle;
const ssoBufferRef = useRef(""); const ssoBufferRef = useRef("");
const ssoTriggeredRef = useRef(false); const ssoTriggeredRef = useRef(false);
@@ -111,9 +107,10 @@ export default function TerminalView({ sessionId, active }: Props) {
} }
return false; // prevent xterm from processing this key return false; // prevent xterm from processing this key
} }
// Ctrl+Shift+M toggles speech-to-text recording // Ctrl+Shift+M toggles speech-to-text recording (mic lives in the status
// bar, bound to the active session; trigger it via the store).
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "M") { if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "M") {
sttToggleRef.current(); useAppState.getState().sttToggle();
return false; return false;
} }
return true; return true;
@@ -319,6 +316,7 @@ export default function TerminalView({ sessionId, active }: Props) {
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ } try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
webglRef.current = null; webglRef.current = null;
term.dispose(); term.dispose();
termRef.current = null;
}; };
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps }, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -393,6 +391,29 @@ export default function TerminalView({ sessionId, active }: Props) {
} }
}, []); }, []);
// Surface this terminal's scroll state to the status bar's "Jump to Current"
// control, but only while it's the active (visible) terminal.
useEffect(() => {
if (!active) return;
setTerminalAtBottom(isAtBottom);
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 writeSelection = useCallback((mode: "trimmed" | "raw") => {
const term = termRef.current; const term = termRef.current;
if (!term) return; if (!term) return;
@@ -457,23 +478,19 @@ export default function TerminalView({ sessionId, active }: Props) {
> >
{isAutoFollow ? "▼ Following" : "▽ Paused"} {isAutoFollow ? "▼ Following" : "▽ Paused"}
</button> </button>
{/* STT mic button - bottom left */} {/* Padding lives on this wrapper, NOT on the xterm host element. xterm's
{sttEnabled && <SttButton state={stt.state} error={stt.error} onToggle={stt.toggle} onCancel={stt.cancelRecording} />} FitAddon measures the host element it's mounted into; padding there
{/* Jump to Current - bottom right, when scrolled up */} causes the grid to overhang and clip the rightmost column / bottom
{!isAtBottom && ( row. The host below fills this wrapper's content box with no padding.
<button Kept to a tight, even gutter so the terminal claims as much area as
onClick={handleScrollToBottom} possible while leaving a little breathing room beside the scrollbar. */}
className="absolute bottom-4 right-4 z-50 px-3 py-1.5 rounded-md text-xs font-medium bg-[#1f2937] text-[#58a6ff] border border-[#30363d] shadow-lg hover:bg-[#2d3748] transition-colors cursor-pointer" <div className="w-full h-full" style={{ padding: "4px 8px 4px 8px" }}>
> <div
Jump to Current ref={containerRef}
</button> className="w-full h-full"
)} onContextMenu={handleContextMenu}
<div />
ref={containerRef} </div>
className="w-full h-full"
style={{ padding: "8px 12px 48px 16px" }}
onContextMenu={handleContextMenu}
/>
{contextMenu && ( {contextMenu && (
<TerminalContextMenu <TerminalContextMenu
x={contextMenu.x} x={contextMenu.x}
+9 -3
View File
@@ -13,6 +13,11 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
const streamRef = useRef<MediaStream | null>(null); const streamRef = useRef<MediaStream | null>(null);
const workletRef = useRef<AudioWorkletNode | null>(null); const workletRef = useRef<AudioWorkletNode | null>(null);
const chunksRef = useRef<Int16Array[]>([]); const chunksRef = useRef<Int16Array[]>([]);
// 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 appSettings = useAppState((s) => s.appSettings);
const deviceId = appSettings?.default_microphone; const deviceId = appSettings?.default_microphone;
@@ -22,6 +27,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
setState("recording"); setState("recording");
setError(null); setError(null);
chunksRef.current = []; chunksRef.current = [];
recordingSessionIdRef.current = sessionId;
try { try {
const audioConstraints: MediaTrackConstraints = { const audioConstraints: MediaTrackConstraints = {
@@ -57,7 +63,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
setError(msg); setError(msg);
setState("error"); setState("error");
} }
}, [state, deviceId]); }, [state, deviceId, sessionId]);
const stopRecording = useCallback(async () => { const stopRecording = useCallback(async () => {
if (state !== "recording") return; if (state !== "recording") return;
@@ -102,7 +108,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
const text = await commands.transcribeAudio(audioData); const text = await commands.transcribeAudio(audioData);
if (text) { if (text) {
await sendInput(sessionId, text); await sendInput(recordingSessionIdRef.current, text);
} }
setState("idle"); setState("idle");
} catch (e) { } 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 // Reset to idle after a brief delay so the UI shows the error
setTimeout(() => setState("idle"), 3000); setTimeout(() => setState("idle"), 3000);
} }
}, [state, sessionId, sendInput]); }, [state, sendInput]);
const cancelRecording = useCallback(async () => { const cancelRecording = useCallback(async () => {
workletRef.current?.disconnect(); workletRef.current?.disconnect();
+16
View File
@@ -44,6 +44,16 @@ interface AppState {
// UI state // UI state
terminalHasSelection: boolean; terminalHasSelection: boolean;
setTerminalHasSelection: (has: boolean) => void; setTerminalHasSelection: (has: boolean) => void;
// STT toggle for the active session, registered by App so the terminal's
// Ctrl+Shift+M shortcut can trigger the single status-bar mic instance.
sttToggle: () => void;
setSttToggle: (fn: () => void) => void;
// Active terminal scroll state, surfaced so the status bar can host the
// "Jump to Current" control. Only the active TerminalView writes these.
terminalAtBottom: boolean;
setTerminalAtBottom: (v: boolean) => void;
scrollActiveToBottom: () => void;
setScrollActiveToBottom: (fn: () => void) => void;
sidebarView: "projects" | "mcp" | "settings"; sidebarView: "projects" | "mcp" | "settings";
setSidebarView: (view: "projects" | "mcp" | "settings") => void; setSidebarView: (view: "projects" | "mcp" | "settings") => void;
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
@@ -125,6 +135,12 @@ export const useAppState = create<AppState>((set) => ({
// UI state // UI state
terminalHasSelection: false, terminalHasSelection: false,
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }), setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
sttToggle: () => {},
setSttToggle: (fn) => set({ sttToggle: fn }),
terminalAtBottom: true,
setTerminalAtBottom: (v) => set({ terminalAtBottom: v }),
scrollActiveToBottom: () => {},
setScrollActiveToBottom: (fn) => set({ scrollActiveToBottom: fn }),
sidebarView: "projects", sidebarView: "projects",
setSidebarView: (view) => set({ sidebarView: view }), setSidebarView: (view) => set({ sidebarView: view }),
sidebarCollapsed: loadSidebarCollapsed(), sidebarCollapsed: loadSidebarCollapsed(),