Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
409 changes: 409 additions & 0 deletions .plans/06-multi-provider-transcription.md

Large diffs are not rendered by default.

258 changes: 61 additions & 197 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,29 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Settings as SettingsIcon } from "lucide-react";
import { SetupRail } from "./features/setup/SetupRail";
import { InlineSetup } from "./features/setup/InlineSetup";
import { RecorderPanel } from "./features/recorder/RecorderPanel";
import { SessionList } from "./features/sessions/SessionList";
import { TranscriptDrawer } from "./features/sessions/TranscriptDrawer";
import { SettingsSheet } from "./features/settings/SettingsSheet";
import { Archive, Trash2, X } from "lucide-react";
import {
SettingsSheet,
type SettingsSection,
} from "./features/settings/SettingsSheet";
import type { AppStatus, SessionSummary, Settings } from "./api/types";
import {
clearAllWavs,
deleteAllSessions,
getAppStatus,
getSettings,
listSessions,
selectSessionsFolder,
} from "./api/bridge";

export type SetupPanelKey =
| "gemini"
| "folder"
| "mic"
| "systemAudio"
| "github"
| null;

export function App() {
const [status, setStatus] = useState<AppStatus | null>(null);
const [settings, setSettings] = useState<Settings | null>(null);
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
null
);
const [openPanel, setOpenPanel] = useState<SetupPanelKey>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsSection, setSettingsSection] =
useState<SettingsSection>("setup");
const [error, setError] = useState<string | null>(null);

const refreshStatus = useCallback(async () => {
Expand Down Expand Up @@ -69,40 +59,28 @@ export function App() {
refreshSessions();
}, [refreshStatus, refreshSettings, refreshSessions]);

// Progressive setup: if required items are missing, auto-open the first one.
// Folder is also auto-picked natively on first run when missing — matches
// the spec's "open native folder picker" behavior.
const folderAutoPickedRef = useRef(false);
const setupOpenedRef = useRef(false);
useEffect(() => {
if (!status) return;
if (
!folderAutoPickedRef.current &&
status.folder.state === "missing"
) {
folderAutoPickedRef.current = true;
selectSessionsFolder()
.then(async () => {
await refreshStatus();
await refreshSettings();
})
.catch(() => {
setOpenPanel("folder");
});
return;
if (!status.canRecord && !settingsOpen && !setupOpenedRef.current) {
setupOpenedRef.current = true;
setSettingsSection("setup");
setSettingsOpen(true);
}
if (openPanel !== null) return;
if (status.gemini.state !== "ready") {
setOpenPanel("gemini");
return;
}
if (status.folder.state !== "ready") {
setOpenPanel("folder");
}, [status, settingsOpen]);

useEffect(() => {
if (sessions.length === 0) {
setSelectedSessionId(null);
return;
}
if (status.mic.state === "denied" || status.mic.state === "missing") {
setOpenPanel("mic");
if (
!selectedSessionId ||
!sessions.some((session) => session.id === selectedSessionId)
) {
setSelectedSessionId(sessions[0]?.id ?? null);
}
}, [status, openPanel, refreshStatus, refreshSettings]);
}, [sessions, selectedSessionId]);

const selectedSession = useMemo(
() => sessions.find((s) => s.id === selectedSessionId) ?? null,
Expand All @@ -121,6 +99,11 @@ export function App() {
? "ready"
: "missing";

const openSettings = useCallback((section: SettingsSection = "setup") => {
setSettingsSection(section);
setSettingsOpen(true);
}, []);

const handleSessionCompleted = useCallback(
(session: SessionSummary) => {
setSessions((prev) => {
Expand All @@ -141,36 +124,6 @@ export function App() {
setSelectedSessionId((prev) => (prev === id ? null : prev));
}, []);

async function doDeleteAllSessions() {
if (sessions.length === 0) return;
const ok = window.confirm(
`Delete all ${sessions.length} sessions?\n\nRemoves every WAV, transcript, and metadata file. This cannot be undone.`
);
if (!ok) return;
try {
await deleteAllSessions();
setSessions([]);
setSelectedSessionId(null);
} catch (e) {
setError(String(e));
}
}

async function doClearAllWavs() {
const withWav = sessions.filter((s) => s.wavPath).length;
if (withWav === 0) return;
const ok = window.confirm(
`Clear WAV from ${withWav} session${withWav === 1 ? "" : "s"}?\n\nKeeps transcripts and metadata. Retranscription won't be possible after this.`
);
if (!ok) return;
try {
await clearAllWavs();
await refreshSessions();
} catch (e) {
setError(String(e));
}
}

return (
<div className="app">
<header className="app-top">
Expand All @@ -192,7 +145,7 @@ export function App() {
type="button"
className="btn btn-icon"
aria-label="Open settings"
onClick={() => setSettingsOpen(true)}
onClick={() => openSettings("setup")}
>
<SettingsIcon size={16} />
</button>
Expand All @@ -201,140 +154,45 @@ export function App() {

<main className="app-main">
<section className="app-main-left">
<div className="panel">
<h2 className="panel-title">Setup</h2>
<SetupRail
status={status}
openPanel={openPanel}
onToggle={(key) =>
setOpenPanel((prev) => (prev === key ? null : key))
}
/>
</div>

{openPanel && (
<InlineSetup
panel={openPanel}
status={status}
settings={settings}
onClose={() => setOpenPanel(null)}
onChanged={async () => {
await refreshStatus();
await refreshSettings();
}}
/>
)}
<RecorderPanel
status={status}
settings={settings}
onCompleted={handleSessionCompleted}
onSessionUpdated={handleSessionUpdated}
onRefreshStatus={refreshStatus}
onOpenSettings={openSettings}
/>

<div className="panel">
<h2 className="panel-title">Recorder</h2>
<RecorderPanel
status={status}
settings={settings}
onCompleted={handleSessionCompleted}
{selectedSession ? (
<TranscriptDrawer
session={selectedSession}
onSessionUpdated={handleSessionUpdated}
onRefreshStatus={refreshStatus}
/>
</div>

{selectedSession && (
<>
<div
className="transcript-scrim"
onClick={() => setSelectedSessionId(null)}
aria-hidden
/>
<div
className="panel transcript-drawer"
role="dialog"
aria-label={`Transcript for ${selectedSession.id}`}
>
<div className="inline-setup-title">
<h3 style={{ margin: 0, fontSize: 14 }}>
Transcript — {selectedSession.id}
</h3>
<button
type="button"
className="btn btn-icon transcript-drawer-close"
aria-label="Close transcript"
onClick={() => setSelectedSessionId(null)}
>
<X size={14} />
</button>
</div>
<TranscriptDrawer
session={selectedSession}
onSessionUpdated={handleSessionUpdated}
/>
) : (
<section className="transcript" aria-label="Transcript">
<div className="transcript__body">
<div className="empty">No sessions yet. Record to create one.</div>
</div>
</>
</section>
)}
</section>

<aside className="app-main-right">
<div
className="panel panel-list"
style={{ flex: 1, minHeight: 0 }}
>
<div className="panel-list-head">
<h3>Sessions</h3>
<div className="row" style={{ gap: 2 }}>
<span
className="muted"
style={{ fontSize: 12, marginRight: 4 }}
>
{sessions.length} total
</span>
<button
type="button"
className="btn btn-icon"
aria-label="Clear all WAVs"
title="Clear WAV for every session (keeps transcripts)"
disabled={sessions.every((s) => !s.wavPath)}
onClick={doClearAllWavs}
>
<Archive size={13} />
</button>
<button
type="button"
className="btn btn-icon btn-danger"
aria-label="Delete all sessions"
title="Delete all sessions (WAV + transcript + metadata)"
disabled={sessions.length === 0}
onClick={doDeleteAllSessions}
>
<Trash2 size={13} />
</button>
</div>
</div>
<SessionList
sessions={sessions}
selectedId={selectedSessionId}
onSelect={setSelectedSessionId}
onSessionUpdated={handleSessionUpdated}
onSessionRemoved={handleSessionRemoved}
githubSyncEnabled={settings?.githubSyncEnabled ?? false}
/>
</div>
</aside>
<div className="app-main-right">
<SessionList
sessions={sessions}
selectedId={selectedSessionId}
onSelect={setSelectedSessionId}
onSessionUpdated={handleSessionUpdated}
onSessionRemoved={handleSessionRemoved}
githubSyncEnabled={settings?.githubSyncEnabled ?? false}
/>
</div>
</main>

{error && (
<div
role="alert"
style={{
position: "fixed",
bottom: 16,
left: 16,
right: 16,
maxWidth: 520,
margin: "0 auto",
background: "var(--surface)",
border: "1px solid var(--error)",
color: "var(--error)",
padding: "10px 14px",
borderRadius: "var(--radius)",
fontSize: 13,
}}
className="app-toast app-toast--error"
>
<div className="row">
<span>{error}</span>
Expand All @@ -353,11 +211,17 @@ export function App() {
{settingsOpen && settings && (
<SettingsSheet
settings={settings}
status={status}
initialSection={settingsSection}
onClose={() => setSettingsOpen(false)}
onSaved={async (next) => {
setSettings(next);
await refreshStatus();
}}
onChanged={async () => {
await refreshStatus();
await refreshSettings();
}}
/>
)}
</div>
Expand Down
Loading