irongit

Open source voice dictation for your whole desktop. Local whisper.cpp transcription, global hotkeys, optional LLM enhancement via Claude Code, Gemini CLI, Codex, or any API. Wayland-first.

mutterbox/src/App.tsx
127 lines4.1 KBJavaScript
1import { useCallback, useEffect, useState } from "react";
2import { listen } from "@tauri-apps/api/event";
3import Overlay from "./Overlay";
4import GeneralPage from "./pages/General";
5import ModelsPage from "./pages/Models";
6import EnhancePage from "./pages/Enhance";
7import { getSettings, setSettings as persistSettings, toggleDictation } from "./api";
8import type { RecordingState, Settings, TranscriptPayload } from "./types";
9
10type Page = "general" | "models" | "enhance";
11
12const NAV: { id: Page; label: string }[] = [
13 { id: "general", label: "general" },
14 { id: "models", label: "models" },
15 { id: "enhance", label: "enhance" },
16];
17
18export default function App() {
19 if (window.location.hash === "#overlay") {
20 return <Overlay />;
21 }
22 return <SettingsApp />;
23}
24
25function SettingsApp() {
26 const [page, setPage] = useState<Page>("general");
27 const [settings, setSettingsState] = useState<Settings | null>(null);
28 const [saveError, setSaveError] = useState<string | null>(null);
29 const [recState, setRecState] = useState<RecordingState>("idle");
30 const [lastTranscript, setLastTranscript] = useState<TranscriptPayload | null>(null);
31 const [pipelineError, setPipelineError] = useState<string | null>(null);
32
33 useEffect(() => {
34 getSettings().then(setSettingsState).catch(console.error);
35 const subs = [
36 listen<{ state: RecordingState }>("recording-state", (e) =>
37 setRecState(e.payload.state),
38 ),
39 listen<TranscriptPayload>("transcript", (e) => {
40 setLastTranscript(e.payload);
41 setPipelineError(null);
42 }),
43 listen<{ message: string }>("pipeline-error", (e) =>
44 setPipelineError(e.payload.message),
45 ),
46 // fired when the tray switches profiles behind the UI's back
47 listen("settings-changed", () => {
48 getSettings().then(setSettingsState).catch(console.error);
49 }),
50 ];
51 return () => {
52 subs.forEach((s) => s.then((fn) => fn()));
53 };
54 }, []);
55
56 const update = useCallback((next: Settings) => {
57 setSettingsState(next);
58 setSaveError(null);
59 persistSettings(next).catch((e) => setSaveError(String(e)));
60 }, []);
61
62 return (
63 <div className="shell">
64 <aside className="sidebar">
65 <div className="wordmark">
66 <span className="wordmark-glyph" aria-hidden>
67 ((•))
68 </span>
69 <h1>mutterbox</h1>
70 <p className="tagline">speak anywhere, type nowhere</p>
71 </div>
72 <nav>
73 {NAV.map((item) => (
74 <button
75 key={item.id}
76 className={page === item.id ? "nav-item active" : "nav-item"}
77 onClick={() => setPage(item.id)}
78 >
79 <span className="nav-marker">{page === item.id ? "▸" : " "}</span>
80 {item.label}
81 </button>
82 ))}
83 </nav>
84 <div className="sidebar-foot">
85 <button
86 className={`dictate-btn state-${recState}`}
87 onClick={() => toggleDictation()}
88 >
89 {recState === "recording"
90 ? "■ stop"
91 : recState === "processing"
92 ? "… working"
93 : "● dictate"}
94 </button>
95 <p className="hint">
96 {settings ? `hotkey ${settings.hotkey}` : ""}
97 </p>
98 </div>
99 </aside>
100
101 <main className="content">
102 {saveError && <div className="banner error">save failed: {saveError}</div>}
103 {pipelineError && (
104 <div className="banner error">
105 {pipelineError}
106 <button className="banner-dismiss" onClick={() => setPipelineError(null)}>
107 ✕
108 </button>
109 </div>
110 )}
111 {settings === null ? (
112 <p className="hint">loading…</p>
113 ) : page === "general" ? (
114 <GeneralPage
115 settings={settings}
116 onChange={update}
117 lastTranscript={lastTranscript}
118 />
119 ) : page === "models" ? (
120 <ModelsPage settings={settings} onChange={update} />
121 ) : (
122 <EnhancePage settings={settings} onChange={update} />
123 )}
124 </main>
125 </div>
126 );
127}