Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions .doctor/dupe-ignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ MAGIC:ghost
MAGIC:secondary
MAGIC:accent
MAGIC:default
MAGIC:link

# Config key — only 2 files, not worth extracting
MAGIC:sttModel
Expand Down
19 changes: 8 additions & 11 deletions src-tauri/src/agent/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ fn process_recording(app_handle: &AppHandle, context: RecordingContext) -> Resul

// Auto-prune if size-based retention is configured
if let Ok(Some(retention)) = db.get_config("transcriptRetention") {
if let Some(max_bytes) = parse_retention_bytes(&retention) {
if let Some(max_bytes) = crate::storage::transcripts::parse_retention_bytes(&retention) {
match db.prune_transcripts(max_bytes) {
Ok(n) if n > 0 => log::info!("Pruned {n} old transcripts (retention: {retention})"),
Err(e) => log::error!("Failed to prune transcripts: {e}"),
Expand Down Expand Up @@ -485,15 +485,12 @@ fn hot_swap_llm(app_handle: &AppHandle, model_id: &str) -> Result<(), AppError>
}

fn emit_state(app_handle: &AppHandle, state: &str) -> Result<(), tauri::Error> {
app_handle.emit("agent-state-changed", serde_json::json!({ "state": state }))
}

fn parse_retention_bytes(value: &str) -> Option<i64> {
match value {
"100mb" => Some(104_857_600),
"500mb" => Some(524_288_000),
"1gb" => Some(1_073_741_824),
"5gb" => Some(5_368_709_120),
_ => None,
// Surface the widget while a recording is active (even when hidden), then
// restore the user's configured visibility once we settle back to idle.
match state {
"recording" => crate::window::widget::show(app_handle),
"idle" => crate::window::widget::restore_visibility(app_handle),
_ => {}
}
app_handle.emit("agent-state-changed", serde_json::json!({ "state": state }))
}
15 changes: 12 additions & 3 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ pub fn run() {

if onboarding_done {
if let Some(widget) = app.get_webview_window("widget") {
let (position, size) = {
let (position, size, visible) = {
let db = state.db.lock().unwrap();
let pos = db
.get_config("widgetPosition")
Expand All @@ -188,11 +188,20 @@ pub fn run() {
.ok()
.flatten()
.unwrap_or_else(|| "medium".to_string());
(pos, sz)
let vis = db
.get_config("widgetVisible")
.ok()
.flatten()
.map_or(true, |v| v == "true");
(pos, sz, vis)
};
let _ =
window::widget::apply_position_and_size(&widget, &position, &size);
let _ = widget.show();
// Respect the user's hide preference at startup. When hidden,
// the pipeline still surfaces the widget during recording.
if visible {
let _ = widget.show();
}
}

// Load STT model in background — but only if the user is on
Expand Down
12 changes: 12 additions & 0 deletions src-tauri/src/storage/transcripts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,15 @@ pub trait TranscriptStore {
fn get_db_size(&self) -> Result<i64>;
fn prune_transcripts(&self, max_bytes: i64) -> Result<i64>;
}

/// Map a size-based retention setting (e.g. `"500mb"`) to its byte budget.
/// Returns `None` for unbounded / unrecognized values.
pub fn parse_retention_bytes(value: &str) -> Option<i64> {
match value {
"100mb" => Some(104_857_600),
"500mb" => Some(524_288_000),
"1gb" => Some(1_073_741_824),
"5gb" => Some(5_368_709_120),
_ => None,
}
}
68 changes: 67 additions & 1 deletion src-tauri/src/window/widget.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,71 @@
use crate::error::AppError;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
use tauri::{LogicalSize, Manager, PhysicalPosition, WebviewWindow};
use crate::state::AppState;
use crate::storage::config::ConfigStore;
use tauri::{AppHandle, LogicalSize, Manager, PhysicalPosition, WebviewWindow};

/// Force the widget visible (used when a recording starts), WITHOUT stealing
/// keyboard focus from the user's active app. On macOS the widget is an
/// `NSPanel`; a plain `show()` calls `makeKeyAndOrderFront`, which would steal
/// focus and break paste injection — so we order it front "regardless" instead.
///
/// NSPanel/AppKit calls must happen on the main thread. The pipeline drives
/// this from the hotkey listener thread, so we hop to the main thread first —
/// calling AppKit off-main crashes the process.
pub fn show(app_handle: &AppHandle) {
let app = app_handle.clone();
let _ = app_handle.run_on_main_thread(move || {
#[cfg(target_os = "macos")]
{
use tauri_nspanel::ManagerExt;
if let Ok(panel) = app.get_webview_panel("widget") {
if !panel.is_visible() {
panel.order_front_regardless();
}
return;
}
}

if let Some(widget) = app.get_webview_window("widget") {
if !widget.is_visible().unwrap_or(false) {
let _ = widget.show();
}
}
});
}

/// Restore the widget to the user's configured visibility. Called when the
/// pipeline returns to idle, so a hidden widget that we surfaced for recording
/// gets hidden again. The DB read is thread-safe; the AppKit hide is dispatched
/// to the main thread (see [`show`]).
pub fn restore_visibility(app_handle: &AppHandle) {
let state = app_handle.state::<AppState>();
let visible = state
.db
.lock()
.ok()
.and_then(|db| db.get_config("widgetVisible").ok().flatten())
.map_or(true, |v| v == "true");

if visible {
return;
}

let app = app_handle.clone();
let _ = app_handle.run_on_main_thread(move || {
#[cfg(target_os = "macos")]
{
use tauri_nspanel::ManagerExt;
if let Ok(panel) = app.get_webview_panel("widget") {
panel.order_out(None);
return;
}
}

if let Some(widget) = app.get_webview_window("widget") {
let _ = widget.hide();
}
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

pub fn apply_position_and_size(
widget: &WebviewWindow,
Expand Down
6 changes: 4 additions & 2 deletions src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@
"minHeight": 500,
"center": true,
"resizable": true,
"decorations": false,
"transparent": true,
"decorations": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"transparent": false,
"visible": true,
"url": "index.html"
},
Expand Down
14 changes: 10 additions & 4 deletions src/dashboard/dashboard-page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState } from "react";
import PageHeader from "../shared/components/page-header";
import PeriodSelector from "../shared/components/period-selector";
import statPeriods from "./activity/stat-periods";
import periodToDays from "./activity/period-to-days";
Expand All @@ -11,13 +12,16 @@ export default function DashboardPage() {

return (
<div>
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
<PageHeader
title="Dashboard"
description="Your dictation activity at a glance."
/>

<StatsRow />

<div className="mb-8">
<div className="mb-10">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Activity</h2>
<h2 className="text-[15px] font-semibold tracking-[-0.01em]">Activity</h2>
<PeriodSelector
periods={statPeriods}
active={activePeriod}
Expand All @@ -28,7 +32,9 @@ export default function DashboardPage() {
</div>

<div>
<h2 className="text-lg font-semibold mb-4">Recent Transcripts</h2>
<h2 className="text-[15px] font-semibold tracking-[-0.01em] mb-4">
Recent Transcripts
</h2>
<RecentTranscripts />
</div>
</div>
Expand Down
19 changes: 19 additions & 0 deletions src/dashboard/stats/stat-cards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Type, Gauge, Mic, Clock, type LucideIcon } from "lucide-react";
import formatNumber from "../../shared/lib/utils/format-number";
import formatMinutes from "../../shared/lib/utils/format-minutes";
import type { TranscriptStats } from "../../shared/types/transcript";

export interface StatCard {
label: string;
icon: LucideIcon;
value: (stats: TranscriptStats) => string;
}

const statCards: StatCard[] = [
{ label: "words total", icon: Type, value: (s) => formatNumber(s.totalWords) },
{ label: "w/sec avg", icon: Gauge, value: (s) => s.avgWordsPerSecond.toFixed(1) },
{ label: "transcriptions", icon: Mic, value: (s) => formatNumber(s.totalTranscripts) },
{ label: "time saved", icon: Clock, value: (s) => formatMinutes(s.timeSavedMinutes) },
];

export default statCards;
8 changes: 0 additions & 8 deletions src/dashboard/stats/stat-placeholders.ts

This file was deleted.

28 changes: 10 additions & 18 deletions src/dashboard/stats/stats-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ import { useEffect, useState } from "react";
import Card from "../../shared/components/card";
import { getTranscriptStats } from "../../shared/lib/tauri-commands";
import type { TranscriptStats } from "../../shared/types/transcript";
import formatNumber from "../../shared/lib/utils/format-number";
import formatMinutes from "../../shared/lib/utils/format-minutes";
import statPlaceholders from "./stat-placeholders";
import statCards from "./stat-cards";

export default function StatsRow() {
const [stats, setStats] = useState<TranscriptStats | null>(null);
Expand All @@ -13,23 +11,17 @@ export default function StatsRow() {
getTranscriptStats().then(setStats).catch(console.error);
}, []);

const items = stats
? [
{ value: formatNumber(stats.totalWords), label: "words total" },
{ value: `${stats.avgWordsPerSecond.toFixed(1)}`, label: "w/sec avg" },
{ value: formatNumber(stats.totalTranscripts), label: "transcriptions" },
{ value: formatMinutes(stats.timeSavedMinutes), label: "time saved" },
]
: statPlaceholders;

return (
<div className="grid grid-cols-4 gap-4 mb-8">
{items.map((stat) => (
<Card key={stat.label} className="px-5 py-4">
<div className="text-2xl font-bold text-text-primary">
{stat.value}
<div className="grid grid-cols-4 gap-4 mb-10">
{statCards.map(({ label, icon: Icon, value }) => (
<Card key={label} className="p-5">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-accent-subtle text-accent mb-3.5">
<Icon size={18} strokeWidth={2} />
</div>
<div className="text-[28px] leading-none font-bold tabular-nums tracking-[-0.02em] text-text-primary">
{stats ? value(stats) : "—"}
</div>
<div className="text-xs text-text-secondary mt-1">{stat.label}</div>
<div className="text-xs text-text-secondary mt-1.5">{label}</div>
</Card>
))}
</div>
Expand Down
Loading
Loading