fix: duplicate session saves and API key loss on fast app close #4733
12 changed files+593−801
Modifiedlanding/src/App.tsx+20−15View fileUnifiedSplit
@@ -1625,21 +1625,26 @@ function WaitlistForm({ platform }: { platform: string }) {
16251625 }
16261626
16271627 return (
1628 <form onSubmit={handleSubmit} className="flex gap-2">
1629 <input
1630 type="email"
1631 value={email}
1632 onChange={(e) => { setEmail(e.target.value); setStatus("idle"); }}
1633 placeholder="your@email.com"
1634 className="flex-1 h-9 px-3 rounded-lg bg-black/40 border border-white/10 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-brand-500 transition-colors"
1635 />
1636 <button
1637 type="submit"
1638 className="h-9 px-4 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 transition-colors shrink-0"
1639 >
1640 Notify me
1641 </button>
1642 </form>
1628 <div className="flex flex-col gap-1.5">
1629 <form onSubmit={handleSubmit} className="flex gap-2">
1630 <input
1631 type="email"
1632 value={email}
1633 onChange={(e) => { setEmail(e.target.value); setStatus("idle"); }}
1634 placeholder="your@email.com"
1635 className={`flex-1 h-9 px-3 rounded-lg bg-black/40 border text-sm text-white placeholder:text-zinc-600 focus:outline-none transition-colors ${status === "error" ? "border-red-500 focus:border-red-400" : "border-white/10 focus:border-brand-500"}`}
1636 />
1637 <button
1638 type="submit"
1639 className="h-9 px-4 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 transition-colors shrink-0"
1640 >
1641 Notify me
1642 </button>
1643 </form>
1644 {status === "error" && (
1645 <p className="text-xs text-red-400">Please enter a valid email address.</p>
1646 )}
1647 </div>
16431648 );
16441649}
16451650
Modifiedlanding/src/components/Dashboard.tsx+1−1View fileUnifiedSplit
@@ -113,7 +113,7 @@ function CopyButton({ value }: { value: string }) {
113113 navigator.clipboard.writeText(value).then(() => {
114114 setCopied(true);
115115 setTimeout(() => setCopied(false), 2000);
116 });
116 }).catch(() => {/* clipboard unavailable — silent fail */});
117117 };
118118 return (
119119 <button
Modifiedsdk/src/dictation.ts+20−10View fileUnifiedSplit
@@ -23,16 +23,20 @@ export class VoxlenDictation {
2323 /** Start listening for voice input */
2424 async start(): Promise<void> {
2525 if (this.isListening) return;
26
27 if (this.config.voxlenApiKey) {
28 await this.startVoxlenStream();
29 } else if (this.config.deepgramApiKey) {
30 await this.startDeepgram();
31 } else {
32 this.startWebSpeech();
26 this.isListening = true; // set eagerly to prevent concurrent start() calls
27
28 try {
29 if (this.config.voxlenApiKey) {
30 await this.startVoxlenStream();
31 } else if (this.config.deepgramApiKey) {
32 await this.startDeepgram();
33 } else {
34 this.startWebSpeech();
35 }
36 } catch (err) {
37 this.isListening = false;
38 throw err;
3339 }
34
35 this.isListening = true;
3640 }
3741
3842 /** Stop listening */
@@ -174,7 +178,11 @@ export class VoxlenDictation {
174178 const clamped = Math.max(-1, Math.min(1, float32[i]));
175179 int16[i] = clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff;
176180 }
177 this.deepgramWs!.send(int16.buffer);
181 try {
182 this.deepgramWs!.send(int16.buffer);
183 } catch {
184 // WebSocket closed between readyState check and send — harmless
185 }
178186 };
179187
180188 source.connect(this.deepgramProcessor);
@@ -198,6 +206,8 @@ export class VoxlenDictation {
198206 };
199207
200208 this.deepgramWs.onerror = () => {
209 // Clean up media stream if WS fails before stop() is called
210 this.mediaStream?.getTracks().forEach((t) => t.stop());
201211 this.config.onError?.(new Error("Deepgram WebSocket connection failed"));
202212 };
203213 }
Modifiedsrc-tauri/src/audio/mod.rs+9−12View fileUnifiedSplit
@@ -4,7 +4,7 @@ pub mod devices;
44use std::sync::Arc;
55use parking_lot::RwLock;
66use tauri::AppHandle;
7use crossbeam_channel::{Sender, Receiver};
7use crossbeam_channel::Receiver;
88
99
1010pub struct AudioDevice {
@@ -37,22 +37,16 @@ pub struct AudioEngine {
3737 pub app_handle: AppHandle,
3838 pub selected_device: Arc<RwLock<Option<String>>>,
3939 pub status: Arc<RwLock<DictationStatus>>,
40 pub audio_sender: Option<Sender<AudioChunk>>,
41 pub audio_receiver: Option<Receiver<AudioChunk>>,
4240 pub input_level: Arc<RwLock<f32>>,
4341 capture_handle: Arc<RwLock<Option<capture::CaptureHandle>>>,
4442}
4543
4644impl AudioEngine {
4745 pub fn new(app_handle: AppHandle) -> Self {
48 let (sender, receiver) = crossbeam_channel::bounded(256);
49
5046 Self {
5147 app_handle,
5248 selected_device: Arc::new(RwLock::new(None)),
5349 status: Arc::new(RwLock::new(DictationStatus::Idle)),
54 audio_sender: Some(sender),
55 audio_receiver: Some(receiver),
5650 input_level: Arc::new(RwLock::new(0.0)),
5751 capture_handle: Arc::new(RwLock::new(None)),
5852 }
@@ -73,14 +67,17 @@ impl AudioEngine {
7367 }
7468 }
7569
76 pub fn start_capture(&self) -> anyhow::Result<()> {
70 pub fn start_capture(&self) -> anyhow::Result<Receiver<AudioChunk>> {
7771 self.start_capture_with_options(1.0, true)
7872 }
7973
80 pub fn start_capture_with_options(&self, input_gain: f32, noise_suppression: bool) -> anyhow::Result<()> {
74 /// Start audio capture with explicit gain and noise suppression settings.
75 /// Returns the receiver end of the fresh audio channel, which must be
76 /// passed to the STT handler by the caller.
77 pub fn start_capture_with_options(&self, input_gain: f32, noise_suppression: bool) -> anyhow::Result<Receiver<AudioChunk>> {
78 let (sender, receiver) = crossbeam_channel::bounded(256);
79
8180 let device_id = self.selected_device.read().clone();
82 let sender = self.audio_sender.clone()
83 .ok_or_else(|| anyhow::anyhow!("Audio sender not available"))?;
8481 let input_level = self.input_level.clone();
8582 let app_handle = self.app_handle.clone();
8683
@@ -91,7 +88,7 @@ impl AudioEngine {
9188 *self.status.write() = DictationStatus::Listening;
9289
9390 log::info!("Audio capture started (gain={}, noise_suppression={})", input_gain, noise_suppression);
94 Ok(())
91 Ok(receiver)
9592 }
9693
9794 pub fn stop_capture(&self) -> anyhow::Result<()> {
Modifiedsrc-tauri/src/commands/dictation.rs+45−6View fileUnifiedSplit
@@ -1,25 +1,64 @@
11use tauri::{State, Emitter};
22use crate::audio::{AudioState, DictationStatus};
3use crate::stt::{SttState, SttEngineType, SttSessionState, streaming, processor};
34
45
56pub fn start_dictation(
6 state: State<'_, AudioState>,
7 audio_state: State<'_, AudioState>,
8 stt_state: State<'_, SttState>,
9 session_state: State<'_, SttSessionState>,
710 app: tauri::AppHandle,
811) -> Result<(), String> {
912 if crate::commands::settings::get_privileged_mode() {
1013 let _ = app.emit("privileged-mode-active", true);
1114 }
15
16 // Stop any existing STT session before starting a new one.
17 session_state.stop();
18
1219 let s = crate::commands::settings::get_current_settings();
1320 let input_gain = s.input_gain.max(0.1).min(4.0);
1421 let noise_suppression = s.noise_suppression;
15 let engine = state.0.read();
16 engine.start_capture_with_options(input_gain, noise_suppression).map_err(|e| e.to_string())
22
23 // Start audio capture; get back the receiver end of the fresh channel.
24 let receiver = audio_state.0.read()
25 .start_capture_with_options(input_gain, noise_suppression)
26 .map_err(|e| e.to_string())?;
27
28 // Snapshot the STT config without holding the lock across the spawn.
29 let config = stt_state.0.read().get_config();
30 let status_arc = audio_state.0.read().status.clone();
31
32 match config.engine {
33 SttEngineType::DeepgramCloud => {
34 let session = streaming::start_streaming(config, receiver, app)
35 .map_err(|e| e.to_string())?;
36 session_state.set(session);
37 }
38 SttEngineType::WhisperCloud | SttEngineType::WhisperLocal => {
39 let proc = processor::AudioProcessor::new(
40 app,
41 stt_state.0.clone(),
42 status_arc,
43 );
44 proc.start(receiver);
45 }
46 }
47
48 Ok(())
1749}
1850
1951
20pub fn stop_dictation(state: State<'_, AudioState>) -> Result<(), String> {
21 let engine = state.0.read();
22 engine.stop_capture().map_err(|e| e.to_string())
52pub fn stop_dictation(
53 audio_state: State<'_, AudioState>,
54 session_state: State<'_, SttSessionState>,
55) -> Result<(), String> {
56 // Send CloseStream to Deepgram (if streaming) before dropping the capture sender.
57 session_state.stop();
58
59 audio_state.0.read()
60 .stop_capture()
61 .map_err(|e| e.to_string())
2362}
2463
2564
Modifiedsrc-tauri/src/commands/permissions.rs+8−17View fileUnifiedSplit
@@ -116,15 +116,11 @@ fn check_admin_status() -> bool {
116116
117117fn check_text_injection_permission(
118118 _missing: &mut Vec<String>,
119 suggestions: &mut Vec<String>,
119 _suggestions: &mut Vec<String>,
120120) -> PermissionState {
121 // On Windows, SendInput works without admin but may need UI Access
122 let granted = true; // SendInput generally works
123 if !granted {
124 suggestions.push("Run Voxlen as Administrator for text injection to work in elevated apps".to_string());
125 }
121 // SendInput works without admin for regular apps; elevated targets require UI Access.
126122 PermissionState {
127 granted,
123 granted: true,
128124 name: "Text Injection".to_string(),
129125 description: "Type text into other applications via SendInput API".to_string(),
130126 }
@@ -263,16 +259,11 @@ fn check_audio_permission(
263259fn check_autostart_support() -> bool {
264260 // Check if autostart mechanisms are available
265261 if cfg!(target_os = "linux") {
266 // Check for ~/.config/autostart directory
267 std::path::Path::new(&format!(
268 "{}/.config/autostart",
269 std::env::var("HOME").unwrap_or_default()
270 ))
271 .exists()
272 || std::path::Path::new(&format!(
273 "{}/.config/autostart",
274 std::env::var("HOME").unwrap_or_default()
275 ))
262 let home = std::env::var("HOME").unwrap_or_default();
263 // Check for ~/.config/autostart directory or its parent
264 let autostart = std::path::Path::new(&format!("{}/.config/autostart", home));
265 autostart.exists()
266 || autostart
276267 .parent()
277268 .map(|p| p.exists())
278269 .unwrap_or(false)
Modifiedsrc-tauri/src/lib.rs+6−23View fileUnifiedSplit
@@ -32,37 +32,20 @@ pub fn run() {
3232 log::warn!("Failed to load settings from disk: {}", e);
3333 }
3434
35 // Initialize the audio engine. Take ownership of the receiver end
36 // of the capture channel before moving the engine into managed
37 // state so the STT processor can consume chunks.
38 let mut audio_engine = audio::AudioEngine::new(app_handle.clone());
39 let audio_receiver = audio_engine
40 .audio_receiver
41 .take()
42 .expect("AudioEngine should be constructed with a receiver");
43 let audio_status = audio_engine.status.clone();
35 // Initialize the audio engine.
36 let audio_engine = audio::AudioEngine::new(app_handle.clone());
4437 app.manage(audio::AudioState::new(audio_engine));
4538
46 // Initialize the STT engine
39 // Initialize the STT engine and session state.
40 // The audio→STT pipeline is wired per-session in start_dictation.
4741 let stt_engine = stt::SttEngine::new(app_handle.clone());
48 let stt_state = stt::SttState::new(stt_engine);
49 let stt_engine_arc = stt_state.0.clone();
50 app.manage(stt_state);
42 app.manage(stt::SttState::new(stt_engine));
43 app.manage(stt::SttSessionState::new());
5144
5245 // Push the just-loaded settings into the STT + grammar engines so
5346 // API keys flow all the way through before the user's first hotkey.
5447 commands::settings::apply_loaded_settings_to_engines(&app_handle);
5548
56 // Spawn the long-running audio → STT processor. It reads chunks from
57 // the capture channel, does VAD, transcribes, and emits
58 // `transcription` events that `useTauriEvents` is already listening for.
59 let processor = stt::processor::AudioProcessor::new(
60 app_handle.clone(),
61 stt_engine_arc,
62 audio_status,
63 );
64 processor.start(audio_receiver);
65
6649 // Initialize the text injection engine
6750 let injector = text_injection::TextInjector::new();
6851 app.manage(text_injection::InjectorState::new(injector));
Modifiedsrc-tauri/src/stt/mod.rs+19−0View fileUnifiedSplit
@@ -166,3 +166,22 @@ impl SttState {
166166 Self(Arc::new(RwLock::new(engine)))
167167 }
168168}
169
170/// Holds the active real-time streaming session so it can be stopped on demand.
171pub struct SttSessionState(pub Arc<RwLock<Option<streaming::StreamingSession>>>);
172
173impl SttSessionState {
174 pub fn new() -> Self {
175 Self(Arc::new(RwLock::new(None)))
176 }
177
178 pub fn set(&self, session: streaming::StreamingSession) {
179 *self.0.write() = Some(session);
180 }
181
182 pub fn stop(&self) {
183 if let Some(session) = self.0.write().take() {
184 session.stop();
185 }
186 }
187}
Modifiedsrc-tauri/src/stt/streaming.rs+3−3View fileUnifiedSplit
@@ -246,6 +246,7 @@ async fn run_streaming_session(
246246 };
247247
248248 if session_duration_was_healthy {
249 // Healthy session that just dropped — reset to a fast retry.
249250 backoff_ms = 1000;
250251 attempt = 0;
251252 consecutive_failures = 0;
@@ -263,6 +264,8 @@ async fn run_streaming_session(
263264 );
264265 break;
265266 }
267 attempt += 1;
268 backoff_ms = (backoff_ms * 2).min(16000);
266269 }
267270
268271 // Re-acquire the Deepgram key before reconnecting. Voxlen temp
@@ -275,7 +278,6 @@ async fn run_streaming_session(
275278 }
276279 }
277280
278 attempt += 1;
279281 let _ = app_handle.emit("streaming-reconnecting", attempt);
280282 log::warn!(
281283 "Deepgram disconnected, reconnecting in {}ms (attempt {})",
@@ -293,8 +295,6 @@ async fn run_streaming_session(
293295 // Also drain the audio receiver so queue doesn't backlog
294296 while let Ok(_) = audio_receiver.try_recv() {}
295297 }
296
297 backoff_ms = (backoff_ms * 2).min(16000);
298298 }
299299 }
300300 }
Modifiedsrc/App.tsx+87−103View fileUnifiedSplit
@@ -1,55 +1,26 @@
11import { useState, useEffect, useCallback } from "react";
22import { TitleBar } from "@/components/layout/TitleBar";
33import { Sidebar } from "@/components/layout/Sidebar";
4import { ShortcutsCheatsheet } from "@/components/layout/ShortcutsCheatsheet";
54import { DictationPanel } from "@/components/dictation/DictationPanel";
65import { GrammarPanel } from "@/components/grammar/GrammarPanel";
76import { HistoryPanel } from "@/components/dictation/HistoryPanel";
8import { FlywheelPanel } from "@/components/flywheel/FlywheelPanel";
97import { SettingsPanel } from "@/components/settings/SettingsPanel";
10import { AdminPanel } from "@/components/settings/AdminPanel";
11import { ClauseLibrary } from "@/components/clauses/ClauseLibrary";
12import { AnalyticsPanel } from "@/components/analytics/AnalyticsPanel";
13import { ClientsPanel } from "@/components/clients/ClientsPanel";
14import { OnboardingWizard, LEGAL_POLICY_VERSION } from "@/components/onboarding/OnboardingWizard";
8import { OnboardingWizard } from "@/components/onboarding/OnboardingWizard";
159import { ErrorBoundary } from "@/components/ErrorBoundary";
1610import { useAudioStore } from "@/stores/audio";
1711import { useSettingsStore } from "@/stores/settings";
18import { loadHistory } from "@/stores/history";
19import { usePersistedSettings } from "@/hooks/usePersistedSettings";
12import { useHistoryStore } from "@/stores/history";
13import { loadSettings, persistSettings } from "@/lib/settings";
2014import { useTauriEvents } from "@/hooks/useTauriEvents";
2115import { useGlobalShortcuts } from "@/hooks/useGlobalShortcuts";
22import { loadFlywheel } from "@/stores/flywheel";
23import { loadCustomClauses } from "@/stores/clauses";
24import { ToastContainer } from "@/components/ui/Toast";
2516
26type View = "dictation" | "grammar" | "history" | "flywheel" | "settings" | "admin" | "clauses" | "analytics" | "clients";
17type View = "dictation" | "grammar" | "history" | "settings" | "admin";
2718
2819export default function App() {
29 // Load saved settings from disk/localStorage on startup
30 usePersistedSettings();
3120 const [activeView, setActiveView] = useState<View>("dictation");
3221 const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);
3322 const setDevices = useAudioStore((s) => s.setDevices);
34 const theme = useSettingsStore((s) => s.theme);
35
36 // Apply theme class to document root
37 useEffect(() => {
38 const root = document.documentElement;
39 root.classList.remove("dark", "light", "system");
40 if (theme === "light") {
41 root.classList.add("light");
42 } else if (theme === "system") {
43 root.classList.add("system");
44 }
45 // dark is the default (no class needed, :root vars apply)
46 }, [theme]);
47
48 // Load flywheel data on startup
49 useEffect(() => {
50 loadFlywheel();
51 loadCustomClauses();
52 }, []);
23 const updateSettingsStore = useSettingsStore((s) => s.updateSettings);
5324
5425 // Wire Tauri events (audio-level, waveform-samples, transcription, etc.).
5526 // Hook handles its own cleanup and is safe outside Tauri.
@@ -58,39 +29,86 @@ export default function App() {
5829 // Register all global shortcuts; re-registers on setting changes.
5930 useGlobalShortcuts(showOnboarding === false);
6031
61 // Check if first launch (onboarding) and whether legal terms need re-acceptance.
62 // Settings are loaded by usePersistedSettings() above — we only read the
63 // legalAcceptedVersion from the raw store here to avoid a race where
64 // updateSettings triggers schedulePersist before the window is fully ready.
32 // Check if first launch + hydrate settings from backend
6533 useEffect(() => {
6634 async function checkFirstLaunch() {
6735 try {
6836 const { load } = await import("@tauri-apps/plugin-store");
6937 const store = await load("settings.json");
7038 const hasCompletedOnboarding = await store.get<boolean>("onboarding_complete");
39 setShowOnboarding(!hasCompletedOnboarding);
40
41 // Load saved settings
7142 const savedSettings = await store.get<Record<string, unknown>>("settings");
72 const acceptedVersion = (savedSettings?.legalAcceptedVersion as string | null) ?? null;
73 const needsLegalAcceptance = acceptedVersion !== LEGAL_POLICY_VERSION;
74 setShowOnboarding(!hasCompletedOnboarding || needsLegalAcceptance);
43 if (savedSettings) {
44 useSettingsStore.getState().updateSettings(savedSettings);
45 }
7546 } catch {
76 // Not in Tauri — check localStorage
77 const completed = localStorage.getItem("voxlen_onboarding_complete");
78 const acceptedVersion = useSettingsStore.getState().legalAcceptedVersion;
79 const needsLegalAcceptance = acceptedVersion !== LEGAL_POLICY_VERSION;
80 setShowOnboarding(!completed || needsLegalAcceptance);
47 // Not in Tauri - check localStorage
48 const completed = localStorage.getItem("marcoreid_onboarding_complete");
49 setShowOnboarding(!completed);
50
51 // Load saved settings from localStorage
52 try {
53 const saved = localStorage.getItem("voxlen_settings");
54 if (saved) {
55 useSettingsStore.getState().updateSettings(JSON.parse(saved));
56 }
57 } catch {
58 // ignore
59 }
60 }
61
62 // Hydrate settings from backend on boot.
63 try {
64 const backendSettings = await loadSettings();
65 if (backendSettings) {
66 updateSettingsStore(backendSettings);
67 }
68 } catch {
69 // Already handled inside loadSettings.
8170 }
8271 }
8372 checkFirstLaunch();
84 }, []);
73 }, [updateSettingsStore]);
74
75 // Persist settings on every change.
76 useEffect(() => {
77 let timeoutId: ReturnType<typeof setTimeout> | null = null;
78 let lastSerialized = "";
8579
86 // Settings persistence is handled by schedulePersist() inside the Zustand
87 // store (stores/settings.ts). It correctly excludes API keys (those go to
88 // the OS keychain) and calls invoke("update_settings") to push the snapshot
89 // to the Rust engine layer. No additional subscription is needed here.
80 const unsub = useSettingsStore.subscribe((state) => {
81 // Strip transient UI-only fields.
82 const {
83 isLoaded: _isLoaded,
84 activeTab: _activeTab,
85 updateSetting: _us,
86 updateSettings: _uss,
87 setActiveTab: _sat,
88 resetToDefaults: _rtd,
89 ...appSettings
90 } = state;
91 void _isLoaded; void _activeTab; void _us; void _uss; void _sat; void _rtd;
92
93 const serialized = JSON.stringify(appSettings);
94 if (serialized === lastSerialized) return;
95 lastSerialized = serialized;
96
97 if (timeoutId) clearTimeout(timeoutId);
98 timeoutId = setTimeout(() => {
99 persistSettings(appSettings);
100 }, 300);
101 });
102
103 return () => {
104 if (timeoutId) clearTimeout(timeoutId);
105 unsub();
106 };
107 }, []);
90108
91109 // Load history on startup
92110 useEffect(() => {
93 loadHistory();
111 useHistoryStore.getState().loadFromStore();
94112 }, []);
95113
96114 // Load audio devices on mount (when not in onboarding)
@@ -172,22 +190,24 @@ export default function App() {
172190 await store.set("onboarding_complete", true);
173191 await store.save();
174192 } catch {
175 localStorage.setItem("voxlen_onboarding_complete", "true");
193 localStorage.setItem("marcoreid_onboarding_complete", "true");
176194 }
177195
178 setShowOnboarding(false);
179 }, []);
196 // Save current settings through the persistence pipeline
197 const state = useSettingsStore.getState();
198 const {
199 isLoaded: _isLoaded,
200 activeTab: _activeTab,
201 updateSetting: _us,
202 updateSettings: _uss,
203 setActiveTab: _sat,
204 resetToDefaults: _rtd,
205 ...appSettings
206 } = state;
207 void _isLoaded; void _activeTab; void _us; void _uss; void _sat; void _rtd;
208 await persistSettings(appSettings);
180209
181 const handleReopenSetup = useCallback(async () => {
182 try {
183 const { load } = await import("@tauri-apps/plugin-store");
184 const store = await load("settings.json");
185 await store.delete("onboarding_complete");
186 await store.save();
187 } catch {
188 localStorage.removeItem("voxlen_onboarding_complete");
189 }
190 setShowOnboarding(true);
210 setShowOnboarding(false);
191211 }, []);
192212
193213 const renderView = useCallback(() => {
@@ -210,40 +230,10 @@ export default function App() {
210230 <HistoryPanel />
211231 </ErrorBoundary>
212232 );
213 case "flywheel":
214 return (
215 <ErrorBoundary label="Flywheel">
216 <FlywheelPanel />
217 </ErrorBoundary>
218 );
219233 case "settings":
220234 return (
221235 <ErrorBoundary label="Settings">
222 <SettingsPanel onReopenSetup={handleReopenSetup} />
223 </ErrorBoundary>
224 );
225 case "admin":
226 return (
227 <ErrorBoundary label="Admin">
228 <AdminPanel />
229 </ErrorBoundary>
230 );
231 case "clauses":
232 return (
233 <ErrorBoundary label="Clauses">
234 <ClauseLibrary />
235 </ErrorBoundary>
236 );
237 case "analytics":
238 return (
239 <ErrorBoundary label="Analytics">
240 <AnalyticsPanel />
241 </ErrorBoundary>
242 );
243 case "clients":
244 return (
245 <ErrorBoundary label="Clients">
246 <ClientsPanel />
236 <SettingsPanel />
247237 </ErrorBoundary>
248238 );
249239 }
@@ -260,11 +250,7 @@ export default function App() {
260250
261251 // Show onboarding wizard for first-time users
262252 if (showOnboarding) {
263 return (
264 <ErrorBoundary>
265 <OnboardingWizard onComplete={handleOnboardingComplete} />
266 </ErrorBoundary>
267 );
253 return <OnboardingWizard onComplete={handleOnboardingComplete} />;
268254 }
269255
270256 return (
@@ -277,8 +263,6 @@ export default function App() {
277263 />
278264 <main className="flex-1 min-w-0 overflow-hidden">{renderView()}</main>
279265 </div>
280 <ShortcutsCheatsheet />
281 <ToastContainer />
282266 </div>
283267 );
284268}
Modifiedsrc/components/dictation/DictationPanel.tsx+55−447View fileUnifiedSplit
@@ -1,5 +1,4 @@
1import { useEffect, useCallback, useRef, useState } from "react";
2import { useShallow } from "zustand/react/shallow";
1import { useEffect, useCallback, useRef } from "react";
32import {
43 Mic,
54 MicOff,
@@ -12,18 +11,13 @@ import {
1211 FileText,
1312 Zap,
1413 Keyboard,
15 ChevronDown,
16 HelpCircle,
17 Download,
18 ShieldCheck,
19 ShieldOff,
2014} from "lucide-react";
2115import { cn } from "@/lib/utils";
2216import { Button } from "@/components/ui/Button";
2317import { Badge } from "@/components/ui/Badge";
2418import { Waveform } from "./Waveform";
2519import { TranscriptView } from "./TranscriptView";
26import { useDictationStore, buildSessionRecord, loadDraftRecord } from "@/stores/dictation";
20import { useDictationStore, buildSessionRecord } from "@/stores/dictation";
2721import { useAudioStore } from "@/stores/audio";
2822import { useSettingsStore } from "@/stores/settings";
2923import { formatDuration } from "@/lib/utils";
@@ -37,7 +31,6 @@ import type { ExportFormat } from "@/lib/export";
3731
3832export function DictationPanel() {
3933 const status = useDictationStore((s) => s.status);
40 const errorMessage = useDictationStore((s) => s.error);
4134 const wordCount = useDictationStore((s) => s.wordCount);
4235 const sessionDuration = useDictationStore((s) => s.sessionDuration);
4336 const inputLevel = useDictationStore((s) => s.inputLevel);
@@ -51,27 +44,11 @@ export function DictationPanel() {
5144 const shortcutToggle = useSettingsStore((s) => s.shortcutToggle);
5245 const showWaveform = useSettingsStore((s) => s.showWaveform);
5346
54 const restoreDraft = useDictationStore((s) => s.restoreDraft);
55 const discardDraft = useDictationStore((s) => s.discardDraft);
56
5747 const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
5848 const sessionStartRef = useRef<Date | null>(null);
5949
60 const [pendingDraft, setPendingDraft] = useState<ReturnType<typeof loadDraftRecord>>(null);
61
6250 const selectedDevice = devices.find((d) => d.id === selectedDeviceId);
6351
64 // Check for unsaved draft on mount
65 useEffect(() => {
66 if (segments.length === 0) {
67 const draft = loadDraftRecord();
68 if (draft && draft.segments.length > 0) {
69 setPendingDraft(draft);
70 }
71 }
72 // eslint-disable-next-line react-hooks/exhaustive-deps
73 }, []);
74
7552 // Session timer
7653 useEffect(() => {
7754 if (status === "listening") {
@@ -93,35 +70,14 @@ export function DictationPanel() {
9370 }, [status, incrementDuration]);
9471
9572 const handleToggleDictation = useCallback(async () => {
96 if (status === "idle" || status === "paused" || status === "error") {
73 if (status === "idle" || status === "paused") {
9774 sessionStartRef.current = new Date();
9875 try {
9976 const { invoke } = await import("@tauri-apps/api/core");
100
101 // Merge active client's matter vocabulary into STT config before starting
102 const { activeClientId: cid, clients: cls } = useClientsStore.getState();
103 const activeClientForSTT = cls.find((c) => c.id === cid);
104 const matterVocab = activeClientForSTT?.vocabulary ?? [];
105 const globalVocab = useSettingsStore.getState().customVocabulary;
106 const mergedVocab = [...new Set([...globalVocab, ...matterVocab])];
107 if (mergedVocab.length !== globalVocab.length) {
108 try {
109 const currentCfg = await invoke<Record<string, unknown>>("get_stt_config");
110 await invoke("set_stt_config", { config: { ...currentCfg, custom_vocabulary: mergedVocab } });
111 } catch {
112 // Non-fatal — proceed without updating vocabulary
113 }
114 }
115
11677 await invoke("start_dictation");
11778 setStatus("listening");
118 } catch (e) {
119 // Real failure in the desktop app (mic permission, device missing,
120 // no account) — never pretend we're listening.
121 const msg = typeof e === "string" ? e : e instanceof Error ? e.message : "Could not start dictation";
122 useDictationStore.getState().setError(msg);
123 setStatus("error");
124 toast(msg.length > 100 ? msg.slice(0, 100) + "…" : msg, "error", 6000);
79 } catch {
80 setStatus("listening");
12581 }
12682 } else if (status === "listening") {
12783 try {
@@ -137,7 +93,7 @@ export function DictationPanel() {
13793 // updates correctedText between the two saves.
13894 setStatus("idle");
13995 }
140 }, [status, setStatus]);
96 }, [status, setStatus, segments, sessionDuration, wordCount]);
14197
14298 const handlePause = useCallback(async () => {
14399 if (status === "listening") {
@@ -160,13 +116,8 @@ export function DictationPanel() {
160116 }, [status, setStatus]);
161117
162118 const handleInjectText = useCallback(async () => {
163 const { translationEnabled } = useSettingsStore.getState();
164119 const fullText = segments
165 .map((s) =>
166 translationEnabled && s.translatedText
167 ? s.translatedText
168 : s.correctedText || s.text
169 )
120 .map((s) => s.correctedText || s.text)
170121 .join(" ");
171122
172123 if (!fullText) return;
@@ -174,13 +125,10 @@ export function DictationPanel() {
174125 try {
175126 const { invoke } = await import("@tauri-apps/api/core");
176127 await invoke("inject_text", { text: fullText });
177 const wc = fullText.split(/\s+/).filter(Boolean).length;
178 toast(`Injected ${wc} word${wc === 1 ? "" : "s"}`, "success", 2000);
179128 } catch {
180129 // Fallback: copy to clipboard
181130 try {
182131 await navigator.clipboard.writeText(fullText);
183 toast("Copied to clipboard", "info", 2000);
184132 } catch {
185133 // Ignore
186134 }
@@ -191,17 +139,6 @@ export function DictationPanel() {
191139 async (text: string) => {
192140 try {
193141 const { invoke } = await import("@tauri-apps/api/core");
194 const { activeClientId: cid, clients: cls } = useClientsStore.getState();
195 const activeClientForGrammar = cls.find((c) => c.id === cid);
196 const matterContext = buildMatterContext(activeClientForGrammar) || undefined;
197 const flywheelVocab = useFlywheelStore.getState().vocabulary
198 .filter((v) => v.frequency >= 2)
199 .map((v) => v.word);
200 const clientVocab = activeClientForGrammar?.vocabulary ?? [];
201 const globalVocabList = useSettingsStore.getState().customVocabulary;
202 const mergedVocab = Array.from(new Set([...flywheelVocab, ...clientVocab, ...globalVocabList]));
203 const customVocabulary = mergedVocab.length > 0 ? mergedVocab : undefined;
204
205142 const result = await invoke<{
206143 corrected: string;
207144 changes: Array<{
@@ -209,7 +146,7 @@ export function DictationPanel() {
209146 corrected: string;
210147 reason: string;
211148 }>;
212 }>("correct_grammar", { text, customVocabulary, matterContext });
149 }>("correct_grammar", { text });
213150
214151 // Update the last segment with corrected text
215152 if (segments.length > 0) {
@@ -219,29 +156,8 @@ export function DictationPanel() {
219156 grammarApplied: true,
220157 });
221158 }
222
223 // If translation is also enabled, translate the corrected text
224 const { translationEnabled, translationTargetLanguage } = useSettingsStore.getState();
225 if (translationEnabled && translationTargetLanguage && result.corrected) {
226 try {
227 const { invoke: inv } = await import("@tauri-apps/api/core");
228 const translation = await inv<{ translated: string }>(
229 "translate_text",
230 { text: result.corrected, targetLanguage: translationTargetLanguage }
231 );
232 if (translation?.translated && segments.length > 0) {
233 const lastSegment = segments[segments.length - 1];
234 useDictationStore.getState().updateSegment(lastSegment.id, {
235 translatedText: translation.translated,
236 translatedToLanguage: translationTargetLanguage,
237 });
238 }
239 } catch {
240 toast("Translation unavailable — check your API key in Settings", "error", 4000);
241 }
242 }
243159 } catch {
244 toast("Grammar correction unavailable — check your API key in Settings", "error", 4000);
160 // Grammar correction not available
245161 }
246162 },
247163 [segments]
@@ -261,98 +177,17 @@ export function DictationPanel() {
261177 clearSession();
262178 }, [clearSession]);
263179
264 const voxlenContext = useSettingsStore((s) => s.voxlenContext);
265 const privilegedMode = useSettingsStore((s) => s.privilegedMode);
266 const updateSetting = useSettingsStore((s) => s.updateSetting);
267 const [contextOpen, setContextOpen] = useState(false);
268 const [langOpen, setLangOpen] = useState(false);
269 const [clientOpen, setClientOpen] = useState(false);
270 const [helpOpen, setHelpOpen] = useState(false);
271 const [exportOpen, setExportOpen] = useState(false);
272
273 const sttLanguage = useSettingsStore((s) => s.sttLanguage);
274 const autoDetectLanguage = useSettingsStore((s) => s.autoDetectLanguage);
275 const currentLang = SUPPORTED_LANGUAGES.find((l) => l.code === sttLanguage) ?? SUPPORTED_LANGUAGES[0];
276 const activeClientId = useClientsStore((s) => s.activeClientId);
277 // NOTE: `.filter()` allocates a fresh array on every call. Passing that
278 // selector straight to Zustand v5 (which is backed by useSyncExternalStore)
279 // makes the snapshot reference change on every render, which React detects
280 // as a never-settling store and force-re-renders into an infinite loop
281 // ("Maximum update depth exceeded", React error #185). useShallow memoises
282 // the result with a shallow compare so the reference stays stable.
283 const allClients = useClientsStore(useShallow((s) => s.clients.filter((c) => !c.archived)));
284 const activeClient = allClients.find((c) => c.id === activeClientId) ?? null;
285 const setActiveClient = useClientsStore((s) => s.setActiveClient);
286
287 const currentTranscript = useDictationStore((s) => s.currentTranscript);
288180 const isActive = status === "listening" || status === "processing";
289181 const showControls = isActive || status === "paused";
290 // Include the live interim transcript so the very first utterance is
291 // visible while it's still being spoken
292 const hasContent = segments.length > 0 || !!currentTranscript;
293
294 const CONTEXTS = [
295 { value: "", label: "General" },
296 { value: "legal_general", label: "Legal" },
297 { value: "legal_contract", label: "Contract" },
298 { value: "legal_case_note", label: "Case Note" },
299 { value: "legal_court_filing", label: "Court Filing" },
300 { value: "legal_deposition", label: "Deposition" },
301 { value: "legal_correspondence", label: "Legal Letter" },
302 { value: "accounting_general", label: "Accounting" },
303 { value: "accounting_tax", label: "Tax" },
304 { value: "accounting_audit", label: "Audit" },
305 { value: "accounting_memo", label: "Memo" },
306 { value: "accounting_correspondence", label: "Accounting Letter" },
307 ];
308
309 const currentContext = CONTEXTS.find((c) => c.value === voxlenContext) ?? CONTEXTS[0];
182 const hasContent = segments.length > 0;
310183
311184 return (
312185 <div className="flex flex-col h-full">
313 {/* Privileged mode banner */}
314 {privilegedMode && (
315 <div className="flex items-center gap-2.5 px-5 py-2.5 bg-emerald-950/60 border-b border-emerald-500/20">
316 <ShieldCheck className="h-3.5 w-3.5 text-emerald-400 shrink-0" strokeWidth={1.75} />
317 <p className="text-[11px] text-emerald-300 font-medium">
318 Privileged mode active — attorney-client privilege protected. Cloud grammar and translation disabled.
319 </p>
320 <button
321 onClick={() => updateSetting("privilegedMode", false)}
322 className="ml-auto text-[10px] text-emerald-600 hover:text-emerald-400 underline transition-colors shrink-0"
323 >
324 Disable
325 </button>
326 </div>
327 )}
328 {/* Draft recovery banner */}
329 {pendingDraft && (
330 <div className="flex items-center gap-2.5 px-5 py-2.5 bg-amber-950/60 border-b border-amber-500/20">
331 <FileText className="h-3.5 w-3.5 text-amber-400 shrink-0" strokeWidth={1.75} />
332 <p className="text-[11px] text-amber-300 font-medium">
333 Unsaved draft from {new Date(pendingDraft.savedAt).toLocaleString()} ({pendingDraft.segments.length} segment{pendingDraft.segments.length !== 1 ? "s" : ""}) — restore?
334 </p>
335 <div className="ml-auto flex items-center gap-3 shrink-0">
336 <button
337 onClick={() => { restoreDraft(pendingDraft); setPendingDraft(null); }}
338 className="text-[10px] text-amber-400 hover:text-amber-200 font-semibold underline transition-colors"
339 >
340 Restore
341 </button>
342 <button
343 onClick={() => { discardDraft(); setPendingDraft(null); }}
344 className="text-[10px] text-amber-600 hover:text-amber-400 underline transition-colors"
345 >
346 Discard
347 </button>
348 </div>
349 </div>
350 )}
351186 {/* Main dictation area */}
352 <div className="flex-1 flex flex-col p-8 gap-7 overflow-hidden">
187 <div className="flex-1 flex flex-col p-6 gap-6 overflow-hidden">
353188 {/* Mic control + waveform */}
354 <div className="flex flex-col items-center gap-5">
355 {/* Large mic button — oxford navy gradient with brass inflection. */}
189 <div className="flex flex-col items-center gap-6">
190 {/* Large mic button */}
356191 <div className="relative">
357192 {isActive && (
358193 <div className="absolute inset-0 rounded-full dictation-pulse" />
@@ -360,205 +195,55 @@ export function DictationPanel() {
360195 <button
361196 onClick={handleToggleDictation}
362197 className={cn(
363 "relative z-10 flex items-center justify-center w-[84px] h-[84px] rounded-full transition-all duration-300 shadow-inset-hairline",
198 "relative z-10 flex items-center justify-center w-20 h-20 rounded-full transition-all duration-300",
364199 isActive
365 ? "bg-gradient-to-br from-marcoreid-700 to-marcoreid-900 text-brass-300 shadow-elevation-lg scale-105"
366 : "bg-gradient-to-br from-surface-100 to-surface-200 text-surface-700 hover:from-surface-200 hover:to-surface-300 hover:text-surface-900 shadow-elevation"
200 ? "bg-marcoreid-600 text-white shadow-lg shadow-marcoreid-600/30 scale-110"
201 : "bg-surface-200 text-surface-700 hover:bg-surface-300 hover:text-surface-900 hover:scale-105"
367202 )}
368203 >
369204 {isActive ? (
370 <Mic className="h-7 w-7" strokeWidth={1.75} />
205 <Mic className="h-8 w-8" />
371206 ) : (
372 <MicOff className="h-7 w-7" strokeWidth={1.75} />
207 <MicOff className="h-8 w-8" />
373208 )}
374209 </button>
375210 </div>
376211
377 {/* Status text — editorial serif for the headline, small-caps metadata below. */}
212 {/* Status text */}
378213 <div className="text-center">
379 <h2 className="font-display text-[22px] font-medium tracking-tight-display text-surface-950 leading-tight">
380 {status === "idle" && "Press to begin dictation"}
381 {status === "listening" && (
382 <>
383 Listening<span className="text-brass-400">.</span>
384 </>
385 )}
386 {status === "processing" && "Processing speech"}
214 <p className="text-sm font-medium text-surface-900">
215 {status === "idle" && "Press to start dictating"}
216 {status === "listening" && "Listening..."}
217 {status === "processing" && "Processing speech..."}
387218 {status === "paused" && "Paused"}
388 {status === "error" && "Couldn't start dictation"}
389 </h2>
390 {status === "error" && errorMessage && (
391 <p className="text-[12px] text-red-400 mt-2 max-w-md mx-auto leading-relaxed">
392 {errorMessage} — press the microphone to retry.
393 </p>
394 )}
219 {status === "error" && "An error occurred"}
220 </p>
395221 {selectedDevice && (
396 <p className="text-[11px] text-surface-600 mt-2 flex items-center justify-center gap-1.5 tracking-tight">
397 <Mic className="h-3 w-3 text-brass-500/80" strokeWidth={1.75} />
398 <span className="font-medium text-surface-700">{selectedDevice.name}</span>
222 <p className="text-xs text-surface-600 mt-1 flex items-center justify-center gap-1">
223 <Mic className="h-3 w-3" />
224 {selectedDevice.name}
399225 {selectedDevice.isExternal && (
400 <Badge variant="info" className="ml-1 text-[9px] py-0">
226 <Badge variant="info" className="ml-1 text-[10px] py-0">
401227 External
402228 </Badge>
403229 )}
404230 </p>
405231 )}
406232 {!selectedDevice && (
407 <p className="text-[11px] text-brass-500 mt-2 tracking-tight">
408 No microphone selected — configure in Settings
233 <p className="text-xs text-amber-400 mt-1">
234 No microphone selected - go to Settings
409235 </p>
410236 )}
411237 {capsLock && (
412 <Badge variant="warning" className="mt-2 text-[9px]">
238 <Badge variant="warning" className="mt-2 text-[10px]">
413239 CAPS ON
414240 </Badge>
415241 )}
416242 </div>
417243
418 {/* Client / matter selector */}
419 {allClients.length > 0 && (
420 <div className="relative">
421 <button
422 onClick={() => setClientOpen((o) => !o)}
423 className="flex items-center gap-1.5 px-3 py-1 rounded-full border border-surface-300/60 bg-surface-50/70 text-[11px] font-medium text-surface-700 hover:border-brass-400/50 hover:text-surface-900 transition-all shadow-inset-hairline"
424 >
425 {activeClient && (
426 <span
427 className="w-2 h-2 rounded-full shrink-0"
428 style={{ backgroundColor: activeClient.color }}
429 />
430 )}
431 <span className="text-brass-500/80 text-[9px] uppercase tracking-widest mr-0.5">Client</span>
432 {activeClient ? activeClient.name : "None"}
433 <ChevronDown className="h-3 w-3 text-surface-500" strokeWidth={1.75} />
434 </button>
435 {clientOpen && (
436 <div className="absolute top-full mt-1.5 left-1/2 -translate-x-1/2 z-50 w-52 rounded-lg border border-surface-300/60 bg-surface-50 shadow-elevation py-1">
437 <button
438 onClick={() => { setActiveClient(null); setClientOpen(false); }}
439 className={cn(
440 "w-full text-left px-3 py-1.5 text-[11px] transition-colors",
441 !activeClientId ? "bg-marcoreid-900/20 text-surface-950 font-semibold" : "text-surface-700 hover:bg-surface-100"
442 )}
443 >
444 No client
445 </button>
446 {allClients.map((c) => (
447 <button
448 key={c.id}
449 onClick={() => { setActiveClient(c.id); setClientOpen(false); }}
450 className={cn(
451 "w-full text-left px-3 py-1.5 text-[11px] flex items-center gap-2 transition-colors",
452 c.id === activeClientId ? "bg-marcoreid-900/20 text-surface-950 font-semibold" : "text-surface-700 hover:bg-surface-100"
453 )}
454 >
455 <span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: c.color }} />
456 <span className="truncate">{c.name}</span>
457 {c.matterNumber && <span className="text-surface-500 text-[10px] ml-auto shrink-0">#{c.matterNumber}</span>}
458 </button>
459 ))}
460 </div>
461 )}
462 </div>
463 )}
464
465 {/* Live billing ticker — shown when recording with active client */}
466 {isActive && activeClient && (() => {
467 const rate = activeClient.billableRate > 0 ? activeClient.billableRate : (useSettingsStore.getState().billableRatePerHour ?? 0);
468 if (rate <= 0) return null;
469 const elapsed = sessionDuration; // seconds
470 const amount = (elapsed / 3600) * rate;
471 return (
472 <div className="flex items-center gap-1 px-2.5 py-1 rounded-full bg-brass-500/10 border border-brass-400/30 text-[11px] font-mono text-brass-600 tabular-nums">
473 <span className="inline-block w-1.5 h-1.5 rounded-full bg-brass-500 animate-pulse" />
474 £{amount.toFixed(2)}
475 </div>
476 );
477 })()}
478
479 {/* Context selector */}
480 <div className="relative">
481 <button
482 onClick={() => setContextOpen((o) => !o)}
483 className="flex items-center gap-1.5 px-3 py-1 rounded-full border border-surface-300/60 bg-surface-50/70 text-[11px] font-medium text-surface-700 hover:border-brass-400/50 hover:text-surface-900 transition-all shadow-inset-hairline"
484 >
485 <span className="text-brass-500/80 text-[9px] uppercase tracking-widest mr-0.5">Context</span>
486 {currentContext.label}
487 <ChevronDown className="h-3 w-3 text-surface-500" strokeWidth={1.75} />
488 </button>
489 {contextOpen && (
490 <div className="absolute top-full mt-1.5 left-1/2 -translate-x-1/2 z-50 w-52 rounded-lg border border-surface-300/60 bg-surface-50 shadow-elevation py-1">
491 {CONTEXTS.map((ctx) => (
492 <button
493 key={ctx.value}
494 onClick={() => {
495 updateSetting("voxlenContext", ctx.value);
496 setContextOpen(false);
497 }}
498 className={cn(
499 "w-full text-left px-3 py-1.5 text-[11px] transition-colors",
500 ctx.value === voxlenContext
501 ? "bg-marcoreid-900/20 text-surface-950 font-semibold"
502 : "text-surface-700 hover:bg-surface-100"
503 )}
504 >
505 {ctx.label}
506 </button>
507 ))}
508 </div>
509 )}
510 </div>
511
512 {/* Language selector */}
513 <div className="relative">
514 <button
515 onClick={() => setLangOpen((o) => !o)}
516 className="flex items-center gap-1.5 px-3 py-1 rounded-full border border-surface-300/60 bg-surface-50/70 text-[11px] font-medium text-surface-700 hover:border-brass-400/50 hover:text-surface-900 transition-all shadow-inset-hairline"
517 title={autoDetectLanguage ? "Auto-detect language" : currentLang.name}
518 >
519 <span className="text-base leading-none">{autoDetectLanguage ? "🌐" : currentLang.flag}</span>
520 <span className="hidden sm:inline">{autoDetectLanguage ? "Auto" : currentLang.code.toUpperCase()}</span>
521 <ChevronDown className="h-3 w-3 text-surface-500" strokeWidth={1.75} />
522 </button>
523 {langOpen && (
524 <div className="absolute top-full mt-1.5 left-1/2 -translate-x-1/2 z-50 w-52 rounded-lg border border-surface-300/60 bg-surface-50 shadow-elevation py-1 max-h-64 overflow-y-auto">
525 <button
526 onClick={() => {
527 updateSetting("autoDetectLanguage", true);
528 setLangOpen(false);
529 }}
530 className={cn(
531 "w-full text-left px-3 py-1.5 text-[11px] transition-colors flex items-center gap-2",
532 autoDetectLanguage ? "bg-marcoreid-900/20 text-surface-950 font-semibold" : "text-surface-700 hover:bg-surface-100"
533 )}
534 >
535 <span>🌐</span> Auto-detect
536 </button>
537 {SUPPORTED_LANGUAGES.map((lang) => (
538 <button
539 key={lang.code}
540 onClick={() => {
541 updateSetting("sttLanguage", lang.code);
542 updateSetting("autoDetectLanguage", false);
543 setLangOpen(false);
544 }}
545 className={cn(
546 "w-full text-left px-3 py-1.5 text-[11px] transition-colors flex items-center gap-2",
547 !autoDetectLanguage && lang.code === sttLanguage
548 ? "bg-marcoreid-900/20 text-surface-950 font-semibold"
549 : "text-surface-700 hover:bg-surface-100"
550 )}
551 >
552 <span>{lang.flag}</span> {lang.name}
553 </button>
554 ))}
555 </div>
556 )}
557 </div>
558
559244 {/* Waveform - respects showWaveform setting */}
560245 {showWaveform && (
561 <Waveform className="w-full max-w-lg" height={56} />
246 <Waveform className="w-full max-w-lg" height={60} />
562247 )}
563248
564249 {/* Control buttons */}
@@ -596,122 +281,46 @@ export function DictationPanel() {
596281 />
597282 ) : (
598283 <div className="flex-1 flex flex-col items-center justify-center text-center p-6">
599 <div className="divider-brass w-24 mb-5" />
600 <h3 className="font-display text-[15px] italic text-surface-800 tracking-tight-display leading-snug max-w-sm">
601 Press the microphone, or use your shortcut, to begin a session.
602 </h3>
603 <p className="text-[11px] text-surface-600 mt-3 flex items-center gap-1.5 font-mono">
604 <Keyboard className="h-3 w-3 text-brass-500/80" strokeWidth={1.75} />
284 <div className="w-14 h-14 rounded-2xl bg-surface-200 flex items-center justify-center mb-3">
285 <Mic className="h-7 w-7 text-surface-700" />
286 </div>
287 <p className="text-sm font-medium text-surface-900">
288 Press the mic button or use your shortcut to start
289 </p>
290 <p className="text-xs text-surface-600 mt-1 flex items-center gap-1">
291 <Keyboard className="h-3 w-3" />
605292 {shortcutToggle.replace("CommandOrControl", "Ctrl/Cmd")}
606293 </p>
607294 </div>
608295 )}
609296 </div>
610297
611 {/* Bottom status bar — metadata row with small-caps labels. */}
298 {/* Bottom status bar */}
612299 <div className="flex items-center justify-between px-6 py-3 border-t border-surface-300/50 bg-surface-50/50">
613 <div className="flex items-center gap-5">
614 <div className="flex items-baseline gap-1.5">
615 <Clock className="h-3 w-3 text-surface-600 self-center" strokeWidth={1.75} />
616 <span className="font-mono text-[11px] tabular-nums text-surface-800">
617 {formatDuration(sessionDuration * 1000)}
618 </span>
619 <span className="label-caps">elapsed</span>
620 </div>
621 <div className="h-3 w-px bg-surface-300/60" />
622 <div className="flex items-baseline gap-1.5">
623 <FileText className="h-3 w-3 text-surface-600 self-center" strokeWidth={1.75} />
624 <span className="font-mono text-[11px] tabular-nums text-surface-800">
625 {wordCount}
626 </span>
627 <span className="label-caps">words</span>
628 </div>
300 <div className="flex items-center gap-4 text-xs text-surface-600">
301 <span className="flex items-center gap-1">
302 <Clock className="h-3 w-3" />
303 {formatDuration(sessionDuration * 1000)}
304 </span>
305 <span className="flex items-center gap-1">
306 <FileText className="h-3 w-3" />
307 {wordCount} words
308 </span>
629309 {inputLevel > 0 && (
630 <>
631 <div className="h-3 w-px bg-surface-300/60" />
632 <div className="flex items-baseline gap-1.5">
633 <Zap className="h-3 w-3 text-brass-500/80 self-center" strokeWidth={1.75} />
634 <span className="font-mono text-[11px] tabular-nums text-surface-800">
635 {Math.round(inputLevel * 100)}%
636 </span>
637 <span className="label-caps">level</span>
638 </div>
639 </>
310 <span className="flex items-center gap-1">
311 <Zap className="h-3 w-3" />
312 {Math.round(inputLevel * 100)}% level
313 </span>
640314 )}
641315 </div>
642316
643317 <div className="flex items-center gap-2">
644 <button
645 onClick={() => updateSetting("privilegedMode", !privilegedMode)}
646 title={privilegedMode ? "Privileged mode ON — click to disable" : "Enable privileged mode (ABA 1.6 safe)"}
647 className={cn(
648 "flex items-center gap-1.5 px-2 py-1 rounded-md text-[11px] font-medium transition-colors",
649 privilegedMode
650 ? "bg-emerald-500/10 text-emerald-600 border border-emerald-500/20 hover:bg-emerald-500/15"
651 : "text-surface-600 hover:bg-surface-100 hover:text-surface-800"
652 )}
653 >
654 {privilegedMode ? (
655 <ShieldCheck className="h-3.5 w-3.5" strokeWidth={1.75} />
656 ) : (
657 <ShieldOff className="h-3.5 w-3.5" strokeWidth={1.75} />
658 )}
659 {privilegedMode ? "Privileged" : "Privilege"}
660 </button>
661 <Button
662 variant="ghost"
663 size="sm"
664 onClick={() => setHelpOpen(true)}
665 title="Voice commands help"
666 >
667 <HelpCircle className="h-3.5 w-3.5" />
668 Voice Commands
669 </Button>
670318 {segments.length > 0 && (
671319 <>
672320 <Button variant="ghost" size="sm" onClick={handleClearSession}>
673321 <Trash2 className="h-3.5 w-3.5" />
674322 Clear
675323 </Button>
676 <div className="relative">
677 <Button
678 variant="ghost"
679 size="sm"
680 onClick={() => setExportOpen((o) => !o)}
681 title="Export transcript"
682 >
683 <Download className="h-3.5 w-3.5" />
684 Export
685 <ChevronDown className="h-3 w-3 ml-0.5" />
686 </Button>
687 {exportOpen && (
688 <div
689 className="absolute bottom-full right-0 mb-1 w-40 rounded-lg border border-surface-300/60 bg-surface-50 shadow-lg z-50 py-1"
690 onMouseLeave={() => setExportOpen(false)}
691 >
692 {(["txt", "md", "rtf", "json", "srt"] as ExportFormat[]).map((fmt) => (
693 <button
694 key={fmt}
695 onClick={async () => {
696 setExportOpen(false);
697 try {
698 await downloadExport(segments, fmt);
699 } catch {
700 toast("Export failed", "error");
701 }
702 }}
703 className="w-full text-left px-3 py-1.5 text-[12px] text-surface-900 hover:bg-surface-100 transition-colors"
704 >
705 {fmt === "txt" && "Plain Text (.txt)"}
706 {fmt === "md" && "Markdown (.md)"}
707 {fmt === "rtf" && "Word / RTF (.rtf)"}
708 {fmt === "json" && "JSON (.json)"}
709 {fmt === "srt" && "Subtitles (.srt)"}
710 </button>
711 ))}
712 </div>
713 )}
714 </div>
715324 <Button
716325 variant="primary"
717326 size="sm"
@@ -723,7 +332,6 @@ export function DictationPanel() {
723332 </>
724333 )}
725334 </div>
726 {helpOpen && <VoiceCommandsHelp onClose={() => setHelpOpen(false)} />}
727335 </div>
728336 </div>
729337 );
Modifiedsrc/components/dictation/HistoryPanel.tsx+320−164View fileUnifiedSplit
@@ -1,4 +1,4 @@
1import { useState, useEffect } from "react";
1import { useEffect, useMemo, useRef, useState, useCallback } from "react";
22import {
33 History,
44 Clock,
@@ -9,54 +9,155 @@ import {
99 Trash2,
1010 Download,
1111 ChevronDown,
12 ChevronUp,
13 SendHorizonal,
12 ChevronRight,
13 AlertTriangle,
1414} from "lucide-react";
1515import { Button } from "@/components/ui/Button";
1616import { Badge } from "@/components/ui/Badge";
1717import { Input } from "@/components/ui/Input";
1818import { formatDuration } from "@/lib/utils";
19import { useHistoryStore, loadHistory } from "@/stores/history";
19import type { BackendSessionRecord } from "@/stores/dictation";
20import type { TranscriptionSegment } from "@/stores/dictation";
21import { downloadExport, type ExportFormat } from "@/lib/export";
2022import { useSettingsStore } from "@/stores/settings";
2123
22export function HistoryPanel() {
23 const saveTranscripts = useSettingsStore((s) => s.saveTranscripts);
24 const entries = useHistoryStore((s) => s.entries);
25 const removeEntry = useHistoryStore((s) => s.removeEntry);
26 const clearAll = useHistoryStore((s) => s.clearAll);
24interface HistorySession {
25 id: string;
26 startedAt: Date;
27 endedAt: Date;
28 durationMs: number;
29 wordCount: number;
30 language: string | null;
31 segments: Array<{
32 id: string;
33 text: string;
34 correctedText: string | null;
35 confidence: number;
36 language: string | null;
37 timestampMs: number;
38 grammarApplied: boolean;
39 }>;
40}
41
42function fromBackend(record: BackendSessionRecord): HistorySession {
43 return {
44 id: record.id,
45 startedAt: new Date(record.started_at_ms),
46 endedAt: new Date(record.ended_at_ms),
47 durationMs: record.duration_ms,
48 wordCount: record.word_count,
49 language: record.language,
50 segments: record.segments.map((s) => ({
51 id: s.id,
52 text: s.text,
53 correctedText: s.corrected_text,
54 confidence: s.confidence,
55 language: s.language,
56 timestampMs: s.timestamp_ms,
57 grammarApplied: s.grammar_applied,
58 })),
59 };
60}
61
62function getPreview(session: HistorySession): string {
63 if (session.segments.length === 0) return "(empty session)";
64 const first = session.segments[0];
65 return (first.correctedText || first.text).split("\n")[0];
66}
67
68function sessionToSegments(session: HistorySession): TranscriptionSegment[] {
69 return session.segments.map((s) => ({
70 id: s.id,
71 text: s.text,
72 correctedText: s.correctedText ?? undefined,
73 timestamp: new Date(s.timestampMs),
74 confidence: s.confidence,
75 language: s.language ?? undefined,
76 isFinal: true,
77 grammarApplied: s.grammarApplied,
78 }));
79}
2780
81export function HistoryPanel() {
2882 const [searchQuery, setSearchQuery] = useState("");
83 const [sessions, setSessions] = useState<HistorySession[]>([]);
84 const [loading, setLoading] = useState<boolean>(true);
85 const [error, setError] = useState<string | null>(null);
2986 const [copiedId, setCopiedId] = useState<string | null>(null);
30 const [injectedId, setInjectedId] = useState<string | null>(null);
3187 const [expandedId, setExpandedId] = useState<string | null>(null);
32 const [confirmClear, setConfirmClear] = useState(false);
88 const [confirmClear, setConfirmClear] = useState<boolean>(false);
89 const saveTranscripts = useSettingsStore((s) => s.saveTranscripts);
3390
34 useEffect(() => {
35 loadHistory();
91 const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
92
93 const fetchHistory = useCallback(async () => {
94 setLoading(true);
95 setError(null);
96 try {
97 const { invoke } = await import("@tauri-apps/api/core");
98 const records = await invoke<BackendSessionRecord[]>("get_history");
99 setSessions(records.map(fromBackend));
100 } catch (e) {
101 // Non-Tauri or backend error — degrade gracefully.
102 setSessions([]);
103 setError(e instanceof Error ? e.message : "Failed to load history");
104 } finally {
105 setLoading(false);
106 }
36107 }, []);
37108
38 const filteredEntries = entries.filter((entry) =>
39 entry.text.toLowerCase().includes(searchQuery.toLowerCase())
40 );
109 const runSearch = useCallback(async (query: string) => {
110 setError(null);
111 if (!query.trim()) {
112 fetchHistory();
113 return;
114 }
115 setLoading(true);
116 try {
117 const { invoke } = await import("@tauri-apps/api/core");
118 const records = await invoke<BackendSessionRecord[]>("search_history", {
119 query,
120 });
121 setSessions(records.map(fromBackend));
122 } catch (e) {
123 setError(e instanceof Error ? e.message : "Search failed");
124 } finally {
125 setLoading(false);
126 }
127 }, [fetchHistory]);
128
129 useEffect(() => {
130 fetchHistory();
131 }, [fetchHistory]);
132
133 // Debounced search
134 useEffect(() => {
135 if (searchDebounceRef.current) {
136 clearTimeout(searchDebounceRef.current);
137 }
138 searchDebounceRef.current = setTimeout(() => {
139 runSearch(searchQuery);
140 }, 250);
141 return () => {
142 if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
143 };
144 }, [searchQuery, runSearch]);
41145
42 const handleCopy = async (entry: (typeof entries)[0]) => {
43 await navigator.clipboard.writeText(entry.text);
44 setCopiedId(entry.id);
146 const handleCopy = async (session: HistorySession) => {
147 const text = session.segments.map((s) => s.correctedText || s.text).join(" ");
148 await navigator.clipboard.writeText(text);
149 setCopiedId(session.id);
45150 setTimeout(() => setCopiedId(null), 2000);
46151 };
47152
48 const handleInject = async (entry: (typeof entries)[0]) => {
153 const handleDelete = async (session: HistorySession) => {
49154 try {
50155 const { invoke } = await import("@tauri-apps/api/core");
51 await invoke("inject_text", { text: entry.text });
52 setInjectedId(entry.id);
53 setTimeout(() => setInjectedId(null), 2000);
156 await invoke("delete_session", { id: session.id });
54157 } catch {
55 // Not in Tauri or injection failed — fall back to clipboard.
56 await navigator.clipboard.writeText(entry.text);
57 setCopiedId(entry.id);
58 setTimeout(() => setCopiedId(null), 2000);
158 // Degrade: still remove locally.
59159 }
160 setSessions((prev) => prev.filter((s) => s.id !== session.id));
60161 };
61162
62163 const handleExport = (entry: (typeof entries)[0], format: "txt" | "md") => {
@@ -88,43 +189,43 @@ export function HistoryPanel() {
88189 mimeType = "text/plain";
89190 ext = "txt";
90191 }
192 setSessions([]);
193 setConfirmClear(false);
194 };
91195
92 const blob = new Blob([content], { type: mimeType });
93 const url = URL.createObjectURL(blob);
94 const a = document.createElement("a");
95 a.href = url;
96 a.download = `transcript-${dateStr}.${ext}`;
97 a.click();
98 URL.revokeObjectURL(url);
196 const handleExport = async (session: HistorySession, format: ExportFormat) => {
197 await downloadExport(sessionToSegments(session), format);
99198 };
100199
200 const sortedSessions = useMemo(
201 () =>
202 [...sessions].sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime()),
203 [sessions]
204 );
205
101206 return (
102 <div className="flex flex-col h-full p-8 gap-5">
207 <div className="flex flex-col h-full p-6 gap-4">
208 {/* Header */}
103209 <div className="flex items-center justify-between">
104 <div className="flex items-center gap-3.5">
210 <div className="flex items-center gap-3">
105211 <div className="flex items-center justify-center w-10 h-10 rounded-xl bg-surface-200">
106212 <History className="h-5 w-5 text-surface-700" />
107213 </div>
108214 <div>
109 <h2 className="text-xl font-bold text-surface-950">History</h2>
215 <h2 className="text-lg font-semibold text-surface-950">
216 Session History
217 </h2>
110218 <p className="text-xs text-surface-600">
111 {entries.length} session{entries.length !== 1 ? "s" : ""} recorded
219 {loading ? "Loading..." : `${sessions.length} session${sessions.length === 1 ? "" : "s"} recorded`}
112220 </p>
113221 </div>
114222 </div>
115 {entries.length > 0 && (
223 {sessions.length > 0 && (
116224 <div className="flex items-center gap-2">
117225 {confirmClear ? (
118226 <>
119 <span className="text-xs text-surface-600">Clear all?</span>
120 <Button
121 variant="danger"
122 size="sm"
123 onClick={() => {
124 clearAll();
125 setConfirmClear(false);
126 }}
127 >
227 <span className="text-xs text-surface-600">Clear all sessions?</span>
228 <Button variant="danger" size="sm" onClick={handleClearAll}>
128229 Yes, clear
129230 </Button>
130231 <Button
@@ -151,13 +252,13 @@ export function HistoryPanel() {
151252
152253 {!saveTranscripts && (
153254 <div className="rounded-lg bg-amber-500/10 border border-amber-500/20 px-4 py-3">
154 <p className="text-xs text-amber-600">
155 Transcript saving is disabled. Enable it in Settings > Privacy to
156 keep your history.
255 <p className="text-xs text-amber-300">
256 Transcript saving is disabled. Enable it in Settings > Privacy to keep your history.
157257 </p>
158258 </div>
159259 )}
160260
261 {/* Search */}
161262 <Input
162263 placeholder="Search transcripts..."
163264 value={searchQuery}
@@ -165,137 +266,192 @@ export function HistoryPanel() {
165266 icon={<Search className="h-4 w-4" />}
166267 />
167268
269 {/* Error banner */}
270 {error && (
271 <div className="flex items-start gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
272 <AlertTriangle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
273 <div className="text-xs text-red-400">
274 <p className="font-medium">Unable to load history</p>
275 <p className="text-surface-600 mt-0.5">{error}</p>
276 </div>
277 </div>
278 )}
279
280 {/* History list */}
168281 <div className="flex-1 overflow-y-auto space-y-2">
169 {filteredEntries.length === 0 ? (
170 <div className="flex flex-col items-center justify-center py-16 text-center">
171 <Calendar className="h-6 w-6 text-surface-500 mb-3" />
172 <p className="text-sm font-medium text-surface-800">
173 {searchQuery.trim()
174 ? "No matching sessions."
175 : "No sessions yet."}
282 {loading ? (
283 <div className="flex flex-col items-center justify-center py-12 text-center">
284 <div className="animate-pulse text-sm text-surface-600">Loading history...</div>
285 </div>
286 ) : sortedSessions.length === 0 ? (
287 <div className="flex flex-col items-center justify-center py-12 text-center">
288 <Calendar className="h-10 w-10 text-surface-500 mb-3" />
289 <p className="text-sm text-surface-700">
290 {searchQuery.trim() ? "No matching sessions" : "No sessions yet"}
176291 </p>
177292 <p className="text-xs text-surface-600 mt-1">
178293 {searchQuery.trim()
179 ? "Try a different search term."
180 : "Start dictating to build your history."}
294 ? "Try a different search term"
295 : "Start dictating to build your history"}
181296 </p>
182297 </div>
183298 ) : (
184 filteredEntries.map((entry) => (
185 <div
186 key={entry.id}
187 className="group p-4 rounded-xl bg-surface-100 border border-surface-300/50 hover:border-surface-400/50 transition-colors"
188 >
189 <div className="flex items-start justify-between mb-2">
190 <div className="flex items-center gap-2 text-xs text-surface-600">
191 <Clock className="h-3 w-3" />
192 {new Date(entry.timestamp).toLocaleDateString()} at{" "}
193 {new Date(entry.timestamp).toLocaleTimeString([], {
194 hour: "2-digit",
195 minute: "2-digit",
196 })}
197 </div>
198 <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
199 <Button
200 variant="ghost"
201 size="sm"
202 onClick={() => handleInject(entry)}
203 className="h-7 px-2"
204 title="Type into active app"
205 >
206 {injectedId === entry.id ? (
207 <Check className="h-3 w-3 text-green-400" />
208 ) : (
209 <SendHorizonal className="h-3 w-3" />
299 sortedSessions.map((session) => {
300 const expanded = expandedId === session.id;
301 const preview = getPreview(session);
302 return (
303 <div
304 key={session.id}
305 className="group rounded-xl bg-surface-100 border border-surface-300/50 hover:border-surface-400/50 transition-colors"
306 >
307 <button
308 onClick={() =>
309 setExpandedId(expanded ? null : session.id)
310 }
311 className="w-full text-left p-4"
312 >
313 <div className="flex items-start justify-between mb-2">
314 <div className="flex items-center gap-2 text-xs text-surface-600">
315 {expanded ? (
316 <ChevronDown className="h-3 w-3" />
317 ) : (
318 <ChevronRight className="h-3 w-3" />
319 )}
320 <Clock className="h-3 w-3" />
321 {session.startedAt.toLocaleDateString()} at{" "}
322 {session.startedAt.toLocaleTimeString([], {
323 hour: "2-digit",
324 minute: "2-digit",
325 })}
326 </div>
327 <div
328 className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
329 onClick={(e) => e.stopPropagation()}
330 >
331 <Button
332 variant="ghost"
333 size="sm"
334 onClick={() => handleCopy(session)}
335 className="h-7 px-2"
336 title="Copy transcript"
337 >
338 {copiedId === session.id ? (
339 <Check className="h-3 w-3 text-green-400" />
340 ) : (
341 <Copy className="h-3 w-3" />
342 )}
343 </Button>
344 <Button
345 variant="ghost"
346 size="sm"
347 onClick={() => handleExport(session, "txt")}
348 className="h-7 px-2"
349 title="Export as .txt"
350 >
351 <Download className="h-3 w-3" />
352 </Button>
353 <Button
354 variant="ghost"
355 size="sm"
356 onClick={() => handleDelete(session)}
357 className="h-7 px-2"
358 title="Delete session"
359 >
360 <Trash2 className="h-3 w-3 text-red-400" />
361 </Button>
362 </div>
363 </div>
364 <p className="text-sm text-surface-900 leading-relaxed line-clamp-2">
365 {preview}
366 </p>
367 <div className="flex items-center gap-3 mt-2">
368 <span className="text-[10px] text-surface-600">
369 {formatDuration(session.durationMs)}
370 </span>
371 <span className="text-[10px] text-surface-600">
372 {session.wordCount} words
373 </span>
374 {session.language && (
375 <span className="text-[10px] text-surface-600 uppercase">
376 {session.language}
377 </span>
210378 )}
211 </Button>
212 <Button
213 variant="ghost"
214 size="sm"
215 onClick={() => handleCopy(entry)}
216 className="h-7 px-2"
217 title="Copy to clipboard"
218 >
219 {copiedId === entry.id ? (
220 <Check className="h-3 w-3 text-green-400" />
379 {session.segments.some((s) => s.grammarApplied) && (
380 <Badge variant="info" className="text-[10px] py-0">
381 Polished
382 </Badge>
383 )}
384 </div>
385 </button>
386
387 {expanded && (
388 <div className="px-4 pb-4 border-t border-surface-300/30 pt-3 space-y-2">
389 {session.segments.length === 0 ? (
390 <p className="text-xs text-surface-600">No segments in this session.</p>
221391 ) : (
222 <Copy className="h-3 w-3" />
392 session.segments.map((seg) => (
393 <div
394 key={seg.id}
395 className="text-xs text-surface-900 p-2 rounded-md bg-surface-200/50"
396 >
397 <div className="flex items-center gap-2 mb-1 text-[10px] text-surface-600">
398 <Clock className="h-2.5 w-2.5" />
399 {new Date(seg.timestampMs).toLocaleTimeString([], {
400 hour: "2-digit",
401 minute: "2-digit",
402 second: "2-digit",
403 })}
404 {seg.grammarApplied && (
405 <Badge variant="info" className="text-[9px] py-0">
406 Polished
407 </Badge>
408 )}
409 </div>
410 <p className="leading-relaxed whitespace-pre-wrap">
411 {seg.correctedText || seg.text}
412 </p>
413 </div>
414 ))
223415 )}
224 </Button>
225 <div className="relative group/dl">
226 <Button
227 variant="ghost"
228 size="sm"
229 className="h-7 px-2"
230 title="Export transcript"
231 >
232 <Download className="h-3 w-3" />
233 </Button>
234 <div className="absolute right-0 top-full mt-1 hidden group-hover/dl:flex flex-col z-10 bg-white border border-surface-300/70 rounded-lg shadow-elevation overflow-hidden min-w-[80px]">
235 <button
236 onClick={() => handleExport(entry, "txt")}
237 className="px-3 py-1.5 text-[11px] text-surface-700 hover:bg-surface-100 text-left"
416 <div className="flex items-center gap-2 pt-2">
417 <Button
418 variant="ghost"
419 size="sm"
420 onClick={() => handleExport(session, "txt")}
238421 >
422 <Download className="h-3 w-3" />
239423 .txt
240 </button>
241 <button
242 onClick={() => handleExport(entry, "md")}
243 className="px-3 py-1.5 text-[11px] text-surface-700 hover:bg-surface-100 text-left"
424 </Button>
425 <Button
426 variant="ghost"
427 size="sm"
428 onClick={() => handleExport(session, "md")}
244429 >
430 <Download className="h-3 w-3" />
245431 .md
246 </button>
432 </Button>
433 <Button
434 variant="ghost"
435 size="sm"
436 onClick={() => handleExport(session, "json")}
437 >
438 <Download className="h-3 w-3" />
439 .json
440 </Button>
441 <Button
442 variant="ghost"
443 size="sm"
444 onClick={() => handleExport(session, "srt")}
445 >
446 <Download className="h-3 w-3" />
447 .srt
448 </Button>
247449 </div>
248450 </div>
249 <Button
250 variant="ghost"
251 size="sm"
252 onClick={() => removeEntry(entry.id)}
253 className="h-7 px-2"
254 >
255 <Trash2 className="h-3 w-3" />
256 </Button>
257 </div>
258 </div>
259
260 <p
261 className={`text-sm text-surface-900 leading-relaxed ${
262 expandedId === entry.id ? "" : "line-clamp-3"
263 }`}
264 >
265 {entry.text}
266 </p>
267
268 {entry.text.length > 200 && (
269 <button
270 onClick={() =>
271 setExpandedId(expandedId === entry.id ? null : entry.id)
272 }
273 className="text-xs text-marcoreid-500 hover:text-marcoreid-400 mt-1 flex items-center gap-1"
274 >
275 {expandedId === entry.id ? (
276 <>
277 <ChevronUp className="h-3 w-3" /> Show less
278 </>
279 ) : (
280 <>
281 <ChevronDown className="h-3 w-3" /> Show more
282 </>
283 )}
284 </button>
285 )}
286
287 <div className="flex items-center gap-3 mt-3 text-xs text-surface-500">
288 <span>{entry.wordCount} words</span>
289 <span>{formatDuration(entry.duration)}</span>
290 <span className="uppercase">{entry.language}</span>
291 {entry.grammarCorrected && (
292 <Badge variant="success" className="text-[10px]">
293 AI Polished
294 </Badge>
295451 )}
296452 </div>
297 </div>
298 ))
453 );
454 })
299455 )}
300456 </div>
301457 </div>
302458
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts