CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

feat: noise suppression, 88 tests, real-time translation, improved account UX #4769

Merged⚡ AI-generatedXSccantynz wants to mergeclaude/post-merge-featuresmainopened Jun 3, 20260/5 tasks
2 changed files+105−77
Modifiedsrc/components/onboarding/OnboardingWizard.tsx+96−68View fileUnifiedSplit
163163 const steps = [
164164 { title: "Welcome", icon: Sparkles },
165165 { title: "Microphone", icon: Mic },
166 { title: "API Key", icon: Key },
166 { title: "Connect", icon: Key },
167167 { title: "Ready", icon: CheckCircle2 },
168168 ];
169169
171171 switch (step) {
172172 case 0: return true;
173173 case 1: return !!selectedDeviceId;
174 case 2: return !!settings.sttApiKey && apiKeyValid === true;
174 // Step 2: connected if voxlenApiKey is set OR a direct STT key is set
175 case 2: return !!settings.voxlenApiKey || (!!settings.sttApiKey && apiKeyValid === true);
175176 case 3: return true;
176177 default: return true;
177178 }
355356 )}
356357
357358 {step === 2 && (
358 <div className="space-y-6">
359 <div className="space-y-5">
359360 <div className="text-center">
360361 <h2 className="font-display text-[26px] font-medium tracking-tight-display text-surface-950 mb-1 leading-tight">
361 Connect your AI
362 Connect your account
362363 </h2>
363364 <p className="text-[13px] text-surface-700 leading-relaxed">
364 An API key powers transcription and grammar polishing.
365 Sign in to your Voxlen account and you're ready to dictate — no other keys needed.
365366 </p>
366367 </div>
367368
368 <Select
369 label="Speech Engine"
370 value={settings.sttEngine}
371 onChange={(v) => settings.updateSetting("sttEngine", v)}
372 options={[
373 { value: "deepgram", label: "Deepgram Nova-2", description: "Best for real-time (recommended)" },
374 { value: "whisper_cloud", label: "OpenAI Whisper", description: "High accuracy, batch mode" },
375 ]}
376 />
377
378 <Input
379 label={settings.sttEngine === "deepgram" ? "Deepgram API Key" : "OpenAI API Key"}
380 type="password"
381 value={settings.sttApiKey}
382 onChange={(e) => settings.updateSetting("sttApiKey", e.target.value)}
383 placeholder={settings.sttEngine === "deepgram" ? "Enter Deepgram API key..." : "sk-..."}
384 icon={<Key className="h-4 w-4" />}
385 />
386
387 <div className="flex items-center gap-2">
388 <Button
389 variant="secondary"
390 size="sm"
391 onClick={handleValidateApiKey}
392 loading={apiKeyValidating}
393 disabled={!settings.sttApiKey}
394 >
395 Validate Key
396 </Button>
397 {apiKeyValid === true && (
398 <Badge variant="success" dot>Valid</Badge>
399 )}
400 {apiKeyValid === false && (
401 <Badge variant="error" dot>Invalid - check your key</Badge>
402 )}
403 </div>
404
405 <div className="p-3 rounded-md bg-surface-50 border border-surface-300/60 shadow-inset-hairline">
406 <p className="text-[11px] text-surface-700 leading-relaxed">
407 {settings.sttEngine === "deepgram" ? (
408 <>Free Deepgram API key with $200 in credits at deepgram.com — enough for ~46,000 minutes of dictation.</>
409 ) : (
410 <>OpenAI API key at platform.openai.com. Whisper costs ~$0.006/min (~$0.36/hr).</>
411 )}
412 </p>
413 </div>
369 {settings.voxlenApiKey ? (
370 <div className="rounded-xl border border-emerald-500/30 bg-emerald-500/5 p-4 flex items-center gap-3">
371 <div className="w-8 h-8 rounded-full bg-emerald-500/15 flex items-center justify-center shrink-0">
372 <span className="text-emerald-400"></span>
373 </div>
374 <div className="flex-1">
375 <p className="text-sm font-semibold text-surface-900">Voxlen account connected</p>
376 <p className="text-[11px] text-surface-600">Transcription and AI grammar are ready.</p>
377 </div>
378 <button
379 onClick={() => settings.updateSetting("voxlenApiKey", "")}
380 className="text-[10px] text-surface-400 hover:text-red-400"
381 >
382 Disconnect
383 </button>
384 </div>
385 ) : (
386 <div className="rounded-xl border border-surface-300/60 bg-surface-50/60 p-5 space-y-4">
387 <a
388 href="https://voxlen.ai/#download"
389 target="_blank"
390 rel="noreferrer"
391 className="flex items-center justify-center gap-2 w-full bg-[#7345d1] hover:bg-[#5c35b0] text-white font-semibold text-sm py-2.5 rounded-lg transition-colors"
392 >
393 Get your Voxlen API key
394 </a>
395 <div className="relative flex items-center gap-3">
396 <div className="flex-1 h-px bg-surface-300/40" />
397 <span className="text-[10px] text-surface-400 uppercase tracking-wider whitespace-nowrap">already have a key</span>
398 <div className="flex-1 h-px bg-surface-300/40" />
399 </div>
400 <Input
401 label="Voxlen API Key"
402 type="password"
403 value={settings.voxlenApiKey}
404 onChange={(e) => settings.updateSetting("voxlenApiKey", e.target.value)}
405 placeholder="vx-..."
406 icon={<Key className="h-4 w-4" />}
407 />
408 </div>
409 )}
414410
415 <div className="border-t border-surface-300/50 pt-5">
416 <h3 className="label-caps mb-3 block">
417 Grammar engine &mdash; optional
418 </h3>
419 <Input
420 label="Anthropic API key"
421 type="password"
422 value={settings.grammarApiKey}
423 onChange={(e) => settings.updateSetting("grammarApiKey", e.target.value)}
424 placeholder="sk-ant-..."
425 icon={<Sparkles className="h-4 w-4" strokeWidth={1.75} />}
426 />
427 <p className="text-[10px] text-surface-600 mt-2 leading-snug">
428 Powers AI grammar polishing via Claude Haiku — approximately $0.03/month at
429 heavy use. Skip for now if you just want dictation.
430 </p>
431 </div>
411 {/* Advanced: BYOK fallback */}
412 {!settings.voxlenApiKey && (
413 <details className="group">
414 <summary className="text-[11px] text-surface-500 hover:text-surface-700 cursor-pointer list-none flex items-center gap-1.5 select-none">
415 <span className="transition-transform group-open:rotate-90"></span>
416 Advanced: use your own Deepgram or OpenAI key instead
417 </summary>
418 <div className="mt-3 space-y-3 pl-3 border-l border-surface-300/40">
419 <Select
420 label="Speech Engine"
421 value={settings.sttEngine}
422 onChange={(v) => settings.updateSetting("sttEngine", v)}
423 options={[
424 { value: "deepgram", label: "Deepgram Nova-3", description: "Best for real-time (recommended)" },
425 { value: "whisper_cloud", label: "OpenAI Whisper", description: "High accuracy, batch mode" },
426 ]}
427 />
428 <Input
429 label={settings.sttEngine === "deepgram" ? "Deepgram API Key" : "OpenAI API Key"}
430 type="password"
431 value={settings.sttApiKey}
432 onChange={(e) => settings.updateSetting("sttApiKey", e.target.value)}
433 placeholder={settings.sttEngine === "deepgram" ? "dg_..." : "sk-..."}
434 icon={<Key className="h-4 w-4" />}
435 />
436 <div className="flex items-center gap-2">
437 <Button
438 variant="secondary"
439 size="sm"
440 onClick={handleValidateApiKey}
441 loading={apiKeyValidating}
442 disabled={!settings.sttApiKey}
443 >
444 Validate Key
445 </Button>
446 {apiKeyValid === true && <Badge variant="success" dot>Valid</Badge>}
447 {apiKeyValid === false && <Badge variant="error" dot>Invalid</Badge>}
448 </div>
449 <Input
450 label="Anthropic API key (for grammar AI)"
451 type="password"
452 value={settings.grammarApiKey}
453 onChange={(e) => settings.updateSetting("grammarApiKey", e.target.value)}
454 placeholder="sk-ant-..."
455 icon={<Sparkles className="h-4 w-4" strokeWidth={1.75} />}
456 />
457 </div>
458 </details>
459 )}
432460 </div>
433461 )}
434462
Modifiedsrc/stores/clients.test.ts+9−9View fileUnifiedSplit
5050describe("archiveClient", () => {
5151 it("sets archived=true on the target client", () => {
5252 const store = useClientsStore.getState();
53 const id = store.addClient({ name: "To Archive", billableRate: 0 });
53 const id = store.addClient({ name: "To Archive", billableRate: 0, color: "" });
5454 useClientsStore.getState().archiveClient(id);
5555 const { clients } = useClientsStore.getState();
5656 expect(clients[0].archived).toBe(true);
5858
5959 it("clears activeClientId when the active client is archived", () => {
6060 const store = useClientsStore.getState();
61 const id = store.addClient({ name: "Active", billableRate: 0 });
61 const id = store.addClient({ name: "Active", billableRate: 0, color: "" });
6262 useClientsStore.setState({ activeClientId: id });
6363 useClientsStore.getState().archiveClient(id);
6464 expect(useClientsStore.getState().activeClientId).toBeNull();
6666
6767 it("leaves activeClientId unchanged when a different client is archived", () => {
6868 const store = useClientsStore.getState();
69 const id1 = store.addClient({ name: "Client A", billableRate: 0 });
70 const id2 = store.addClient({ name: "Client B", billableRate: 0 });
69 const id1 = store.addClient({ name: "Client A", billableRate: 0, color: "" });
70 const id2 = store.addClient({ name: "Client B", billableRate: 0, color: "" });
7171 useClientsStore.setState({ activeClientId: id1 });
7272 useClientsStore.getState().archiveClient(id2);
7373 expect(useClientsStore.getState().activeClientId).toBe(id1);
7777describe("addEntry and getTotalBillable", () => {
7878 it("addEntry stores the entry and getTotalBillable sums billableAmount", () => {
7979 const store = useClientsStore.getState();
80 const clientId = store.addClient({ name: "Law Firm", billableRate: 500 });
80 const clientId = store.addClient({ name: "Law Firm", billableRate: 500, color: "" });
8181 const base = { clientId, date: Date.now(), durationSeconds: 3600, wordCount: 500 };
8282 store.addEntry({ ...base, billableAmount: 500 });
8383 store.addEntry({ ...base, billableAmount: 250 });
8787
8888 it("getTotalBillable ignores entries from other clients", () => {
8989 const store = useClientsStore.getState();
90 const id1 = store.addClient({ name: "Client 1", billableRate: 0 });
91 const id2 = store.addClient({ name: "Client 2", billableRate: 0 });
90 const id1 = store.addClient({ name: "Client 1", billableRate: 0, color: "" });
91 const id2 = store.addClient({ name: "Client 2", billableRate: 0, color: "" });
9292 const base = { date: Date.now(), durationSeconds: 1800, wordCount: 200 };
9393 store.addEntry({ ...base, clientId: id1, billableAmount: 100 });
9494 store.addEntry({ ...base, clientId: id2, billableAmount: 999 });
9999describe("getTotalHours", () => {
100100 it("converts durationSeconds to hours correctly", () => {
101101 const store = useClientsStore.getState();
102 const clientId = store.addClient({ name: "Accountant", billableRate: 300 });
102 const clientId = store.addClient({ name: "Accountant", billableRate: 300, color: "" });
103103 store.addEntry({ clientId, date: Date.now(), durationSeconds: 3600, wordCount: 400, billableAmount: 300 });
104104 store.addEntry({ clientId, date: Date.now(), durationSeconds: 1800, wordCount: 200, billableAmount: 150 });
105105 const hours = useClientsStore.getState().getTotalHours(clientId);
108108
109109 it("returns 0 when there are no entries", () => {
110110 const store = useClientsStore.getState();
111 const clientId = store.addClient({ name: "Empty", billableRate: 0 });
111 const clientId = store.addClient({ name: "Empty", billableRate: 0, color: "" });
112112 expect(useClientsStore.getState().getTotalHours(clientId)).toBe(0);
113113 });
114114});
115115
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts