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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
21 changes: 21 additions & 0 deletions .stylelintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"rules": {
"at-rule-no-unknown": [
true,
{
"ignoreAtRules": [
"theme",
"apply",
"variant",
"custom-variant",
"utility",
"source",
"reference",
"config",
"plugin",
"tailwind"
]
}
]
}
}
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,
}
}
72 changes: 71 additions & 1 deletion src-tauri/src/window/widget.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,75 @@
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();
if let Err(e) = 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();
}
}
}) {
log::error!("Failed to dispatch widget show to main thread: {e}");
}
}

/// 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();
if let Err(e) = 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();
}
}) {
log::error!("Failed to dispatch widget hide to main thread: {e}");
}
}
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