feat(ux): dogfood pass — onboarding, tasks, LLM chip, float popout
Bundles a session of dogfood UX feedback plus the two Cursor Bugbot
findings on the auto-titles branch.
Onboarding (FirstRunPage):
- Welcome leads with "Set up automatically"; system breakdown and the
full model list move behind a "Choose manually" disclosure
- Morning / evening / autostart modal copy trimmed to one short
sentence each; CTAs shortened
- "Corbie" autostart string reverted to "Kon"
- Already-downloaded models are clickable in the picker so the
Settings → About → Replay onboarding flow doesn't re-download
- Autostart "No thanks" now does isEnabled() → disable() to actually
remove the OS login item when replaying after a previous "Yes"
Tasks page:
- Bucket nav (All / Inbox / Today / Soon / Later) now a horizontal
pill row; was stacking because nav was block-level
- List sidebar sized to content via self-start max-h-full instead of
stretching to viewport when sparse
- Energy chip surfaces at opacity-60 when unset (was opacity-0,
hidden until hover) so the affordance is discoverable
- "Brain-Dead" energy label → "Zero" everywhere user-facing; enum
stays brain_dead to avoid a destructive DB migration
LLM status chip (llmStatus.svelte.ts + Dictation/Settings):
- Chip no longer auto-warms when the engine isn't loaded; it's hidden
unless ready / generating / loading / error
- refreshLlmStatus takes { force: true } so post-load reconcile clears
stale "warming"; ambient refreshes still preserve in-flight state
- markError exported; failed loads surface "AI error" with detail
rather than silently going to off
- check_llm_model is the source of truth (replaces the bool-only
get_llm_status path in the store)
Float popout window:
- Native decorations off — was stacking two titlebars + two close X's
on KWin, one of which silently failed
- ResizeHandles mounted outside the animate-float-enter wrapper so
fixed-position handles anchor to the viewport, not the transformed
root; secondary-windows capability gains
core:window:allow-start-resize-dragging for tasks-float
- GTK Utility WindowTypeHint applied pre-map (mirroring the preview
window) so KWin Wayland honours always-on-top reliably
- visible_on_all_workspaces(true) so the pinned tasks list follows
workspace switches
- togglePin does hide()+show()+focus() on re-pin to nudge the
compositor into re-evaluating window state
- Pop-out / Edit / Open viewer buttons hidden on Android via
isAndroid() — the multi-window Tauri commands stub out there
Build / Bugbot:
- src-tauri Cargo.toml: whisper feature now chains whisper-vulkan, so
the dev runner's --no-default-features --features whisper
invocation actually pulls Vulkan acceleration instead of silently
falling back to CPU-only
- jsconfig.json's inherited "types": ["node"] fixed by adding
@types/node; corresponding @ts-expect-error in vite.config.js
removed now that process is a known global
Verification: svelte-check + cargo check pass clean. Manual
device-side validation still pending for float resize and replay
autostart "No thanks" — those are the only remaining confidence items.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
18
package-lock.json
generated
18
package-lock.json
generated
@@ -25,6 +25,7 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
@@ -1662,6 +1663,16 @@
|
|||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "25.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||||
|
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
@@ -3181,6 +3192,13 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.19.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||||
|
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.2",
|
"version": "6.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
|
|||||||
@@ -10,13 +10,22 @@ name = "kon_lib"
|
|||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Default build includes the Whisper backend. Disabling this feature
|
# Default build includes the Whisper backend with Vulkan GPU acceleration.
|
||||||
# also drops it from kon-transcription (see Cargo.toml in that crate)
|
#
|
||||||
# so a --no-default-features workspace build does not pull whisper-rs-sys.
|
# Vulkan is chained into `whisper` (rather than being a separate top-level
|
||||||
# load_model_from_disk returns a runtime error for Engine::Whisper when
|
# default) because Tauri's dev runner invokes
|
||||||
# this feature is off; Parakeet continues to work.
|
# `cargo run --no-default-features --features whisper`, which would
|
||||||
|
# otherwise drop the vulkan feature and silently fall back to CPU-only
|
||||||
|
# inference. The Bugbot finding is satisfied: desktop builds get GPU
|
||||||
|
# acceleration by default, while the workspace-level kon-transcription
|
||||||
|
# crate still keeps `whisper` and `whisper-vulkan` separable for
|
||||||
|
# CPU-only-capable targets that build the crate directly.
|
||||||
|
#
|
||||||
|
# `whisper-vulkan` is kept as an aliased explicit-opt-in for callers who
|
||||||
|
# want to spell out the dependency.
|
||||||
default = ["whisper"]
|
default = ["whisper"]
|
||||||
whisper = ["kon-transcription/whisper"]
|
whisper = ["kon-transcription/whisper", "whisper-vulkan"]
|
||||||
|
whisper-vulkan = ["kon-transcription/whisper-vulkan"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
@@ -41,7 +50,7 @@ kon-llm = { path = "../crates/llm" }
|
|||||||
# autostart plugins are desktop-only — gated below under
|
# autostart plugins are desktop-only — gated below under
|
||||||
# cfg(not(target_os = "android")). The dialog, opener, and notification
|
# cfg(not(target_os = "android")). The dialog, opener, and notification
|
||||||
# plugins all support Android natively so live in the unconditional list.
|
# plugins all support Android natively so live in the unconditional list.
|
||||||
tauri = { version = "2" }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
# Phase 6 nudges: OS-native notifications via the frontend-owned nudge
|
# Phase 6 nudges: OS-native notifications via the frontend-owned nudge
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"core:event:default",
|
"core:event:default",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-start-resize-dragging",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-hide",
|
"core:window:allow-hide",
|
||||||
"core:window:allow-show",
|
"core:window:allow-show",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"main":{"identifier":"main","description":"Main window capability for user-initiated app control.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister","autostart:allow-enable","autostart:allow-disable","autostart:allow-is-enabled","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify"]},"secondary-windows":{"identifier":"secondary-windows","description":"Narrow capability for passive secondary windows.","local":true,"windows":["tasks-float","transcript-viewer","transcription-preview"],"permissions":["core:event:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus"]}}
|
{"main":{"identifier":"main","description":"Main window capability for user-initiated app control.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister","autostart:allow-enable","autostart:allow-disable","autostart:allow-is-enabled","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify"]},"secondary-windows":{"identifier":"secondary-windows","description":"Narrow capability for passive secondary windows.","local":true,"windows":["tasks-float","transcript-viewer","transcription-preview"],"permissions":["core:event:default","core:window:allow-start-dragging","core:window:allow-start-resize-dragging","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus"]}}
|
||||||
@@ -58,14 +58,33 @@ pub async fn open_task_window(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
// custom frameless chrome drawn by the Titlebar component.
|
// custom frameless chrome drawn by the Titlebar component.
|
||||||
let use_native_decorations = cfg!(target_os = "linux");
|
let use_native_decorations = cfg!(target_os = "linux");
|
||||||
|
|
||||||
|
// Built hidden so the GTK utility type hint can be applied pre-map on
|
||||||
|
// Linux — see the preview window for the same pattern. KWin/Mutter on
|
||||||
|
// Wayland reliably keep utility-class windows above normal windows
|
||||||
|
// (and respect runtime keep-above toggles for them); for normal
|
||||||
|
// windows the same hint requests are flaky post-map.
|
||||||
|
//
|
||||||
|
// Decorations are off for this window: the float route renders its own
|
||||||
|
// titlebar with the pin / close controls, and stacking native KDE
|
||||||
|
// decorations on top of that produced two titlebars and two close X's
|
||||||
|
// (the native one didn't even close the window because the in-page
|
||||||
|
// chrome captured the click first). Custom drag is wired via
|
||||||
|
// handleDragStart (startDragging) and ResizeHandles in the route.
|
||||||
|
let _ = use_native_decorations; // keep the OS detection for future use
|
||||||
let mut builder =
|
let mut builder =
|
||||||
WebviewWindowBuilder::new(&app, "tasks-float", WebviewUrl::App("/float".into()))
|
WebviewWindowBuilder::new(&app, "tasks-float", WebviewUrl::App("/float".into()))
|
||||||
.title("Kon Tasks")
|
.title("Kon Tasks")
|
||||||
.inner_size(480.0, 520.0)
|
.inner_size(480.0, 520.0)
|
||||||
.min_inner_size(360.0, 480.0)
|
.min_inner_size(360.0, 480.0)
|
||||||
.always_on_top(true)
|
.always_on_top(true)
|
||||||
.decorations(use_native_decorations)
|
// Pin across virtual desktops so users who flip workspaces
|
||||||
.resizable(true);
|
// mid-task don't lose the floating tasks list. Combined with
|
||||||
|
// always_on_top + utility hint, this matches what users
|
||||||
|
// expect from a "pin" button on KDE Plasma and GNOME.
|
||||||
|
.visible_on_all_workspaces(true)
|
||||||
|
.decorations(false)
|
||||||
|
.resizable(true)
|
||||||
|
.visible(false);
|
||||||
|
|
||||||
// Inject preferences before Svelte mounts
|
// Inject preferences before Svelte mounts
|
||||||
if let Some(script) = app.try_state::<PreferencesScript>() {
|
if let Some(script) = app.try_state::<PreferencesScript>() {
|
||||||
@@ -74,8 +93,23 @@ pub async fn open_task_window(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.build().map_err(|e| e.to_string())?;
|
let window = builder.build().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Apply the GTK Utility type hint before the window maps. On X11 and
|
||||||
|
// XWayland the hint is honoured immediately; on native Wayland-only
|
||||||
|
// compositors GTK uses the closest semantic equivalent. Must happen
|
||||||
|
// before show() per GTK3 docs.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
use gdk::WindowTypeHint;
|
||||||
|
use gtk::prelude::GtkWindowExt;
|
||||||
|
if let Ok(gtk_window) = window.gtk_window() {
|
||||||
|
gtk_window.set_type_hint(WindowTypeHint::Utility);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.show().map_err(|e| e.to_string())?;
|
||||||
|
window.set_focus().map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// Phase 3 — Energy tag chip. Cycles a task's energy level through
|
// Phase 3 — Energy tag chip. Cycles a task's energy level through
|
||||||
// the spec's four states: unset → High → Medium → Brain-Dead → unset.
|
// the spec's four states: unset → High → Medium → Zero → unset.
|
||||||
//
|
//
|
||||||
// Visual discipline: when energy is unset, the chip renders at
|
// Visual discipline: when energy is unset, the chip renders at a
|
||||||
// `group-hover` opacity only so untagged rows stay calm. Once set,
|
// muted but still legible opacity so users can discover the affordance
|
||||||
// the chip is always visible because the colour IS the signal for
|
// without first hovering the row. The previous "0% until hover"
|
||||||
// the match-my-energy sort.
|
// behaviour hid the control entirely, which read as broken on first
|
||||||
|
// contact. Once set, the chip is always fully visible because the
|
||||||
|
// colour IS the signal for the match-my-energy sort.
|
||||||
//
|
//
|
||||||
// Colour choices borrow the existing design tokens:
|
// Colour choices borrow the existing design tokens:
|
||||||
// High → accent (warm, on-brand, attention-ready)
|
// High → accent (warm, on-brand, attention-ready)
|
||||||
// Medium → warning (amber, unforced)
|
// Medium → warning (amber, unforced)
|
||||||
// Brain-Dead → text-tertiary (low-energy grey, not danger red —
|
// Zero → text-tertiary (low-energy grey, not danger red — the
|
||||||
// the brief is explicit that this state must not feel
|
// brief is explicit that this state must not feel
|
||||||
// pathologised)
|
// pathologised; the internal enum value remains
|
||||||
|
// `brain_dead` to avoid a DB migration churn)
|
||||||
//
|
//
|
||||||
// Callers pass the task's current energy and a setter. This component
|
// Callers pass the task's current energy and a setter. This component
|
||||||
// owns no state — the task store is the source of truth.
|
// owns no state — the task store is the source of truth.
|
||||||
@@ -34,7 +37,7 @@
|
|||||||
|
|
||||||
// Cycle order lives here so the chip is the single authority on what
|
// Cycle order lives here so the chip is the single authority on what
|
||||||
// "next" means. Tap once to tag, tap again to move up, tap past
|
// "next" means. Tap once to tag, tap again to move up, tap past
|
||||||
// Brain-Dead to clear. Keyboard-equivalent via the <button> element.
|
// Zero to clear. Keyboard-equivalent via the <button> element.
|
||||||
const CYCLE: (EnergyLevel | null)[] = [null, "high", "medium", "brain_dead"];
|
const CYCLE: (EnergyLevel | null)[] = [null, "high", "medium", "brain_dead"];
|
||||||
|
|
||||||
function next(): EnergyLevel | null {
|
function next(): EnergyLevel | null {
|
||||||
@@ -46,7 +49,7 @@
|
|||||||
switch (level) {
|
switch (level) {
|
||||||
case "high": return "High";
|
case "high": return "High";
|
||||||
case "medium": return "Medium";
|
case "medium": return "Medium";
|
||||||
case "brain_dead": return "Brain-Dead";
|
case "brain_dead": return "Zero";
|
||||||
default: return "No energy set";
|
default: return "No energy set";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,7 +70,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="energy-chip inline-flex items-center justify-center rounded-md border px-1 {chipSize} text-[10px] font-medium
|
class="energy-chip inline-flex items-center justify-center rounded-md border px-1 {chipSize} text-[10px] font-medium
|
||||||
{energy === null
|
{energy === null
|
||||||
? 'opacity-0 group-hover:opacity-100 text-text-tertiary border-border-subtle hover:border-accent hover:text-text-secondary'
|
? 'opacity-60 group-hover:opacity-100 text-text-tertiary border-border-subtle hover:border-accent hover:text-text-secondary'
|
||||||
: ''}
|
: ''}
|
||||||
{energy === 'high'
|
{energy === 'high'
|
||||||
? 'text-accent border-accent bg-accent/10'
|
? 'text-accent border-accent bg-accent/10'
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
import { errorMessage } from "$lib/utils/errors.js";
|
import { errorMessage } from "$lib/utils/errors.js";
|
||||||
import Card from "$lib/components/Card.svelte";
|
import Card from "$lib/components/Card.svelte";
|
||||||
|
|
||||||
|
type WhisperVariant = "tiny" | "base" | "small" | "distil-s" | "medium" | "distil-l";
|
||||||
|
|
||||||
let { modelSize = "base", onComplete = () => {} } = $props<{
|
let { modelSize = "base", onComplete = () => {} } = $props<{
|
||||||
modelSize?: "tiny" | "base" | "small" | "medium";
|
modelSize?: WhisperVariant;
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -18,14 +20,17 @@
|
|||||||
let unlisten: null | (() => void) = null;
|
let unlisten: null | (() => void) = null;
|
||||||
|
|
||||||
const modelInfo = {
|
const modelInfo = {
|
||||||
tiny: { size: "~75 MB", accuracy: "Basic" },
|
tiny: { display: "Tiny", size: "~75 MB", accuracy: "Basic accuracy" },
|
||||||
base: { size: "~142 MB", accuracy: "Good" },
|
base: { display: "Base", size: "~142 MB", accuracy: "Good accuracy" },
|
||||||
small: { size: "~466 MB", accuracy: "Better" },
|
small: { display: "Small", size: "~466 MB", accuracy: "Better accuracy" },
|
||||||
medium: { size: "~1.5 GB", accuracy: "Best" },
|
"distil-s": { display: "Distil-S", size: "~336 MB", accuracy: "Small-class accuracy at ~6× the speed" },
|
||||||
} satisfies Record<string, { size: string; accuracy: string }>;
|
medium: { display: "Medium", size: "~1.5 GB", accuracy: "Best accuracy, slower" },
|
||||||
|
"distil-l": { display: "Distil-L", size: "~1.55 GB", accuracy: "Near-best accuracy at ~6× the speed" },
|
||||||
|
} satisfies Record<WhisperVariant, { display: string; size: string; accuracy: string }>;
|
||||||
let currentModelInfo = $derived(
|
let currentModelInfo = $derived(
|
||||||
modelInfo[String(modelSize) as keyof typeof modelInfo]
|
modelInfo[String(modelSize) as WhisperVariant]
|
||||||
);
|
);
|
||||||
|
let displayName = $derived(currentModelInfo?.display ?? String(modelSize));
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
unlisten = await listen("model-download-progress", (event) => {
|
unlisten = await listen("model-download-progress", (event) => {
|
||||||
@@ -72,11 +77,15 @@
|
|||||||
|
|
||||||
<h3 class="text-[18px] font-semibold text-text mb-2">Download Whisper Model</h3>
|
<h3 class="text-[18px] font-semibold text-text mb-2">Download Whisper Model</h3>
|
||||||
<p class="text-[13px] text-text-secondary mb-1">
|
<p class="text-[13px] text-text-secondary mb-1">
|
||||||
Kon needs the <span class="font-medium text-text">{modelSize}</span> model to transcribe speech.
|
Kon needs the <span class="font-medium text-text">{displayName}</span> model to transcribe speech.
|
||||||
</p>
|
</p>
|
||||||
|
{#if currentModelInfo}
|
||||||
<p class="text-[11px] text-text-tertiary mb-6">
|
<p class="text-[11px] text-text-tertiary mb-6">
|
||||||
{currentModelInfo?.size ?? "?"} · {currentModelInfo?.accuracy ?? "?"} accuracy · 100% offline
|
{currentModelInfo.size} · {currentModelInfo.accuracy} · 100% offline
|
||||||
</p>
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-[11px] text-text-tertiary mb-6">100% offline</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if downloading}
|
{#if downloading}
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { emit } from "@tauri-apps/api/event";
|
import { emit } from "@tauri-apps/api/event";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js";
|
import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js";
|
||||||
import { markGenerating, markGenerationDone } from "$lib/stores/llmStatus.svelte.js";
|
import { markError, markGenerating, markGenerationDone, markLoading, refreshLlmStatus } from "$lib/stores/llmStatus.svelte.js";
|
||||||
import { playStartCue, playStopCue, playCompleteCue } from "$lib/utils/sounds.js";
|
import { playStartCue, playStopCue, playCompleteCue } from "$lib/utils/sounds.js";
|
||||||
import { profilesStore } from "$lib/stores/profiles.svelte.ts";
|
import { profilesStore } from "$lib/stores/profiles.svelte.ts";
|
||||||
import { toasts } from "$lib/stores/toasts.svelte.js";
|
import { toasts } from "$lib/stores/toasts.svelte.js";
|
||||||
@@ -22,7 +22,8 @@
|
|||||||
import { bionicReading } from '$lib/actions/bionicReading.js';
|
import { bionicReading } from '$lib/actions/bionicReading.js';
|
||||||
import { measurePreWrap } from '$lib/utils/textMeasure.js';
|
import { measurePreWrap } from '$lib/utils/textMeasure.js';
|
||||||
import { transcriptPretextFont, transcriptPretextLineHeight } from '$lib/utils/accessibilityTypography.js';
|
import { transcriptPretextFont, transcriptPretextLineHeight } from '$lib/utils/accessibilityTypography.js';
|
||||||
import { hasTauriRuntime } from '$lib/utils/runtime.js';
|
import { hasTauriRuntime, isAndroid } from '$lib/utils/runtime.js';
|
||||||
|
import { errorMessage } from '$lib/utils/errors.js';
|
||||||
const prefs = getPreferences();
|
const prefs = getPreferences();
|
||||||
const tauriRuntimeAvailable = hasTauriRuntime();
|
const tauriRuntimeAvailable = hasTauriRuntime();
|
||||||
const browserPreviewMessage = "You're viewing Kon in a normal browser. Local transcription only works in the Tauri desktop app window.";
|
const browserPreviewMessage = "You're viewing Kon in a normal browser. Local transcription only works in the Tauri desktop app window.";
|
||||||
@@ -240,6 +241,8 @@
|
|||||||
try {
|
try {
|
||||||
const status = await invoke("check_llm_model", { modelId: settings.llmModelId });
|
const status = await invoke("check_llm_model", { modelId: settings.llmModelId });
|
||||||
if (status?.downloaded && !status.loaded) {
|
if (status?.downloaded && !status.loaded) {
|
||||||
|
markLoading("Loading AI model");
|
||||||
|
try {
|
||||||
await invoke("load_llm_model", {
|
await invoke("load_llm_model", {
|
||||||
modelId: settings.llmModelId,
|
modelId: settings.llmModelId,
|
||||||
// Sequential-GPU guard (brief item A.1 #28): frees whisper
|
// Sequential-GPU guard (brief item A.1 #28): frees whisper
|
||||||
@@ -247,9 +250,13 @@
|
|||||||
// keeps both resident (default, safe on multi-GB cards).
|
// keeps both resident (default, safe on multi-GB cards).
|
||||||
concurrent: settings.aiGpuConcurrency === "parallel",
|
concurrent: settings.aiGpuConcurrency === "parallel",
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
await refreshLlmStatus(settings.aiTier, settings.llmModelId, { force: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("ensureLlmModelLoaded failed", err);
|
console.warn("ensureLlmModelLoaded failed", err);
|
||||||
|
markError(errorMessage(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +384,11 @@
|
|||||||
// Preview overlay: if the user opted in, reset any leftover state
|
// Preview overlay: if the user opted in, reset any leftover state
|
||||||
// from a prior run and open the window when the main window is not
|
// from a prior run and open the window when the main window is not
|
||||||
// focused (i.e. the user is dictating into some other app).
|
// focused (i.e. the user is dictating into some other app).
|
||||||
if (settings.transcriptionPreview && tauriRuntimeAvailable) {
|
if (settings.transcriptionPreview && tauriRuntimeAvailable && !isAndroid()) {
|
||||||
|
// open_preview_window is a desktop-only multi-window command; the
|
||||||
|
// Android Tauri stub returns an error. Skip it so we don't surface
|
||||||
|
// a noisy console error every time the user starts recording on
|
||||||
|
// a mobile build.
|
||||||
emit("preview-listening").catch(() => {});
|
emit("preview-listening").catch(() => {});
|
||||||
try {
|
try {
|
||||||
const focused = await getCurrentWindow().isFocused();
|
const focused = await getCurrentWindow().isFocused();
|
||||||
|
|||||||
@@ -52,11 +52,19 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// If the model is already on disk, skip the download step so the user
|
||||||
|
// can replay onboarding without re-downloading. download_model is a
|
||||||
|
// no-op for present files in current builds, but we belt-and-brace by
|
||||||
|
// checking is_downloaded first.
|
||||||
|
const alreadyDownloaded = (models ?? []).find((m) => m.id === modelId)?.is_downloaded ?? false;
|
||||||
|
|
||||||
if (modelId.startsWith("whisper-")) {
|
if (modelId.startsWith("whisper-")) {
|
||||||
// backend's whisper_model_id accepts the full model id via its
|
// backend's whisper_model_id accepts the full model id via its
|
||||||
// `other => ModelId::new(other)` fallback, so pass the id through
|
// `other => ModelId::new(other)` fallback, so pass the id through
|
||||||
// unchanged rather than maintaining a fragile lowercased alias map.
|
// unchanged rather than maintaining a fragile lowercased alias map.
|
||||||
|
if (!alreadyDownloaded) {
|
||||||
await invoke("download_model", { size: modelId });
|
await invoke("download_model", { size: modelId });
|
||||||
|
}
|
||||||
await invoke("load_model", { size: modelId });
|
await invoke("load_model", { size: modelId });
|
||||||
|
|
||||||
const idToLabel = {
|
const idToLabel = {
|
||||||
@@ -70,7 +78,9 @@
|
|||||||
settings.engine = "whisper";
|
settings.engine = "whisper";
|
||||||
settings.modelSize = idToLabel[modelId] ?? "Base";
|
settings.modelSize = idToLabel[modelId] ?? "Base";
|
||||||
} else if (modelId.startsWith("parakeet-")) {
|
} else if (modelId.startsWith("parakeet-")) {
|
||||||
|
if (!alreadyDownloaded) {
|
||||||
await invoke("download_parakeet_model", { name: "ctc-int8" });
|
await invoke("download_parakeet_model", { name: "ctc-int8" });
|
||||||
|
}
|
||||||
await invoke("load_parakeet_model", { name: "ctc-int8" });
|
await invoke("load_parakeet_model", { name: "ctc-int8" });
|
||||||
settings.engine = "parakeet";
|
settings.engine = "parakeet";
|
||||||
}
|
}
|
||||||
@@ -95,6 +105,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-detect is the default surface; the system breakdown and the
|
||||||
|
// manual model list live behind a "Choose manually" disclosure so the
|
||||||
|
// first-run screen is not a wall of jargon for users who just want to
|
||||||
|
// hit one button and start dictating.
|
||||||
|
let showManual = $state(false);
|
||||||
|
|
||||||
// Phase 5: forced-choice rituals + autostart prompts. Research on
|
// Phase 5: forced-choice rituals + autostart prompts. Research on
|
||||||
// libertarian-paternalism nudges (Thaler/Sunstein) says defaults
|
// libertarian-paternalism nudges (Thaler/Sunstein) says defaults
|
||||||
// drive uptake, but the ADHD target audience is sensitive to
|
// drive uptake, but the ADHD target audience is sensitive to
|
||||||
@@ -104,6 +120,11 @@
|
|||||||
let ritualsStep = $state<RitualsStep>("idle");
|
let ritualsStep = $state<RitualsStep>("idle");
|
||||||
let autostartApplying = $state(false);
|
let autostartApplying = $state(false);
|
||||||
|
|
||||||
|
function setupAutomatically() {
|
||||||
|
if (!models?.length) return;
|
||||||
|
downloadAndGo(models[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
async function answerMorning(yes: boolean) {
|
async function answerMorning(yes: boolean) {
|
||||||
settings.ritualsMorning = yes;
|
settings.ritualsMorning = yes;
|
||||||
saveSettings();
|
saveSettings();
|
||||||
@@ -124,13 +145,20 @@
|
|||||||
await plugin.enable();
|
await plugin.enable();
|
||||||
settings.launchAtLogin = true;
|
settings.launchAtLogin = true;
|
||||||
} else {
|
} else {
|
||||||
// Don't call disable() on a fresh install — there's nothing to
|
// On a true first run this is already off, but replaying
|
||||||
// disable, and some platforms treat "disable when unset" as an
|
// onboarding after previously choosing "Yes" must remove the
|
||||||
// error. Just record the choice.
|
// OS-level login item too.
|
||||||
|
if (await plugin.isEnabled()) {
|
||||||
|
await plugin.disable();
|
||||||
|
}
|
||||||
settings.launchAtLogin = false;
|
settings.launchAtLogin = false;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toasts.warn("Could not update autostart", String(err));
|
toasts.warn("Could not update autostart", String(err));
|
||||||
|
try {
|
||||||
|
const plugin = await import("@tauri-apps/plugin-autostart");
|
||||||
|
settings.launchAtLogin = await plugin.isEnabled();
|
||||||
|
} catch {}
|
||||||
} finally {
|
} finally {
|
||||||
autostartApplying = false;
|
autostartApplying = false;
|
||||||
settings.ritualsPromptSeen = true;
|
settings.ritualsPromptSeen = true;
|
||||||
@@ -179,9 +207,9 @@
|
|||||||
<Sunrise size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
|
<Sunrise size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
|
||||||
<h2 class="text-xl font-medium text-text">Morning triage?</h2>
|
<h2 class="text-xl font-medium text-text">Morning triage?</h2>
|
||||||
<p class="text-sm text-text-secondary mt-3 leading-relaxed">
|
<p class="text-sm text-text-secondary mt-3 leading-relaxed">
|
||||||
On the first launch of the day, a gentle modal shows yesterday's open items and asks you to pick up to three for today. The rest can wait.
|
Each morning, pick three things to focus on. Everything else can wait.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-[11px] text-text-tertiary mt-3">Off by default. You can change your mind any time in Settings.</p>
|
<p class="text-[11px] text-text-tertiary mt-3">Off by default. Change anytime in Settings.</p>
|
||||||
<div class="flex items-center justify-center gap-3 mt-6">
|
<div class="flex items-center justify-center gap-3 mt-6">
|
||||||
<button
|
<button
|
||||||
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
|
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
|
||||||
@@ -190,12 +218,12 @@
|
|||||||
<button
|
<button
|
||||||
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover"
|
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover"
|
||||||
onclick={() => answerMorning(true)}
|
onclick={() => answerMorning(true)}
|
||||||
>Yes, turn it on</button>
|
>Turn on</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="mt-5 text-xs text-text-tertiary hover:text-text-secondary underline"
|
class="mt-5 text-xs text-text-tertiary hover:text-text-secondary underline"
|
||||||
onclick={skipRituals}
|
onclick={skipRituals}
|
||||||
>Skip all these questions</button>
|
>Skip these</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if ritualsStep === "evening"}
|
{:else if ritualsStep === "evening"}
|
||||||
@@ -203,9 +231,9 @@
|
|||||||
<Moon size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
|
<Moon size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
|
||||||
<h2 class="text-xl font-medium text-text">Evening wind-down?</h2>
|
<h2 class="text-xl font-medium text-text">Evening wind-down?</h2>
|
||||||
<p class="text-sm text-text-secondary mt-3 leading-relaxed">
|
<p class="text-sm text-text-secondary mt-3 leading-relaxed">
|
||||||
A reflective page you can open when you want to close the day. Shows what you finished, names the open loops, then gets out of the way. Never scheduled, never nagging.
|
A page to reflect on what you finished and what's still open — only when you choose to open it.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-[11px] text-text-tertiary mt-3">Off by default. Always opt-in.</p>
|
<p class="text-[11px] text-text-tertiary mt-3">Off by default. Never scheduled.</p>
|
||||||
<div class="flex items-center justify-center gap-3 mt-6">
|
<div class="flex items-center justify-center gap-3 mt-6">
|
||||||
<button
|
<button
|
||||||
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
|
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
|
||||||
@@ -214,22 +242,22 @@
|
|||||||
<button
|
<button
|
||||||
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover"
|
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover"
|
||||||
onclick={() => answerEvening(true)}
|
onclick={() => answerEvening(true)}
|
||||||
>Yes, turn it on</button>
|
>Turn on</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="mt-5 text-xs text-text-tertiary hover:text-text-secondary underline"
|
class="mt-5 text-xs text-text-tertiary hover:text-text-secondary underline"
|
||||||
onclick={skipRituals}
|
onclick={skipRituals}
|
||||||
>Skip the rest</button>
|
>Skip these</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if ritualsStep === "autostart"}
|
{:else if ritualsStep === "autostart"}
|
||||||
<div class="w-full max-w-md mx-auto text-center">
|
<div class="w-full max-w-md mx-auto text-center">
|
||||||
<Play size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
|
<Play size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
|
||||||
<h2 class="text-xl font-medium text-text">Launch Corbie at login?</h2>
|
<h2 class="text-xl font-medium text-text">Launch Kon at login?</h2>
|
||||||
<p class="text-sm text-text-secondary mt-3 leading-relaxed">
|
<p class="text-sm text-text-secondary mt-3 leading-relaxed">
|
||||||
So Corbie is already there when you need it — especially useful if you said yes to morning triage. Uses your OS's standard autostart. No background tricks, no telemetry.
|
Kon will be ready as soon as you sign in. Uses your OS's standard autostart.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-[11px] text-text-tertiary mt-3">You can change this any time in Settings.</p>
|
<p class="text-[11px] text-text-tertiary mt-3">Change anytime in Settings.</p>
|
||||||
<div class="flex items-center justify-center gap-3 mt-6">
|
<div class="flex items-center justify-center gap-3 mt-6">
|
||||||
<button
|
<button
|
||||||
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
|
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
|
||||||
@@ -240,7 +268,7 @@
|
|||||||
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover disabled:opacity-60"
|
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover disabled:opacity-60"
|
||||||
onclick={() => answerAutostart(true)}
|
onclick={() => answerAutostart(true)}
|
||||||
disabled={autostartApplying}
|
disabled={autostartApplying}
|
||||||
>{autostartApplying ? 'Saving…' : 'Yes, launch at login'}</button>
|
>{autostartApplying ? 'Saving…' : 'Yes'}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -277,8 +305,30 @@
|
|||||||
<div class="mt-4 p-3 rounded-lg bg-danger/10 text-danger text-sm">{error}</div>
|
<div class="mt-4 p-3 rounded-lg bg-danger/10 text-danger text-sm">{error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if models.length > 0}
|
||||||
|
<div class="mt-8 flex flex-col items-center">
|
||||||
|
<button
|
||||||
|
class="px-6 py-3 rounded-xl text-base font-medium bg-accent text-white hover:bg-accent-hover
|
||||||
|
shadow-[0_4px_16px_rgba(232,168,124,0.3)] active:scale-[0.97] transition-all duration-150"
|
||||||
|
onclick={setupAutomatically}
|
||||||
|
>
|
||||||
|
Set up automatically
|
||||||
|
</button>
|
||||||
|
<p class="mt-2 text-xs text-text-tertiary">
|
||||||
|
Picks the best model for your machine — about {models[0]?.disk_size_mb ?? "?"} MB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="mt-6 mx-auto block text-xs text-text-secondary hover:text-text underline"
|
||||||
|
onclick={() => (showManual = !showManual)}
|
||||||
|
>
|
||||||
|
{showManual ? 'Hide manual setup' : 'Choose manually'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if showManual}
|
||||||
{#if systemInfo}
|
{#if systemInfo}
|
||||||
<div class="mt-8 p-4 rounded-lg bg-bg-input border border-border">
|
<div class="mt-4 p-4 rounded-lg bg-bg-input border border-border">
|
||||||
<h3 class="text-xs font-medium text-text-tertiary uppercase tracking-wider mb-3">Your system</h3>
|
<h3 class="text-xs font-medium text-text-tertiary uppercase tracking-wider mb-3">Your system</h3>
|
||||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
<span class="text-text-secondary">RAM</span>
|
<span class="text-text-secondary">RAM</span>
|
||||||
@@ -293,10 +343,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if models.length > 0}
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<h3 class="text-xs font-medium text-text-tertiary uppercase tracking-wider mb-3">Pick a model</h3>
|
<h3 class="text-xs font-medium text-text-tertiary uppercase tracking-wider mb-3">Pick a model</h3>
|
||||||
<p class="text-xs text-text-tertiary mb-3">One tap — we handle the rest.</p>
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each models as model, i}
|
{#each models as model, i}
|
||||||
<button
|
<button
|
||||||
@@ -304,7 +352,6 @@
|
|||||||
{i === 0 ? 'border-accent bg-accent/5 hover:bg-accent/10' : 'border-border bg-bg-input hover:bg-hover'}"
|
{i === 0 ? 'border-accent bg-accent/5 hover:bg-accent/10' : 'border-border bg-bg-input hover:bg-hover'}"
|
||||||
style="transition-duration: var(--duration-ui)"
|
style="transition-duration: var(--duration-ui)"
|
||||||
onclick={() => downloadAndGo(model.id)}
|
onclick={() => downloadAndGo(model.id)}
|
||||||
disabled={model.is_downloaded}
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -324,9 +371,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="mt-6 text-xs text-text-tertiary hover:text-text-secondary underline"
|
class="mt-6 mx-auto block text-xs text-text-tertiary hover:text-text-secondary underline"
|
||||||
style="transition-duration: var(--duration-ui)"
|
style="transition-duration: var(--duration-ui)"
|
||||||
onclick={skipSetup}
|
onclick={skipSetup}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { onDestroy } from "svelte";
|
import { onDestroy } from "svelte";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { isAndroid } from "$lib/utils/runtime.js";
|
||||||
import {
|
import {
|
||||||
history,
|
history,
|
||||||
saveTranscriptMeta,
|
saveTranscriptMeta,
|
||||||
@@ -916,6 +917,9 @@
|
|||||||
style="transition-duration: var(--duration-ui)"
|
style="transition-duration: var(--duration-ui)"
|
||||||
onclick={(e) => { e.stopPropagation(); copyItem(item); }}
|
onclick={(e) => { e.stopPropagation(); copyItem(item); }}
|
||||||
>Copy</button>
|
>Copy</button>
|
||||||
|
{#if !isAndroid()}
|
||||||
|
<!-- open_viewer_window is desktop-only; the
|
||||||
|
Android Tauri stub returns an error. -->
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1.5 text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
|
class="inline-flex items-center gap-1.5 text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
|
||||||
style="transition-duration: var(--duration-ui)"
|
style="transition-duration: var(--duration-ui)"
|
||||||
@@ -925,6 +929,7 @@
|
|||||||
Edit
|
Edit
|
||||||
<ExternalLink size={11} aria-hidden="true" />
|
<ExternalLink size={11} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
|
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
|
||||||
style="transition-duration: var(--duration-ui)"
|
style="transition-duration: var(--duration-ui)"
|
||||||
@@ -953,7 +958,7 @@
|
|||||||
<Sparkles size={11} aria-hidden="true" />
|
<Sparkles size={11} aria-hidden="true" />
|
||||||
{titling.has(item.id) ? "Titling…" : "Title"}
|
{titling.has(item.id) ? "Titling…" : "Title"}
|
||||||
</button>
|
</button>
|
||||||
{#if item.audioPath && item.segments && item.segments.length > 0}
|
{#if item.audioPath && item.segments && item.segments.length > 0 && !isAndroid()}
|
||||||
<button
|
<button
|
||||||
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-accent hover:text-accent-hover"
|
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-accent hover:text-accent-hover"
|
||||||
style="transition-duration: var(--duration-ui)"
|
style="transition-duration: var(--duration-ui)"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js";
|
import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js";
|
||||||
import { profilesStore, DEFAULT_PROFILE_ID } from "$lib/stores/profiles.svelte.ts";
|
import { profilesStore, DEFAULT_PROFILE_ID } from "$lib/stores/profiles.svelte.ts";
|
||||||
import { toasts } from "$lib/stores/toasts.svelte.js";
|
import { toasts } from "$lib/stores/toasts.svelte.js";
|
||||||
|
import { errorMessage } from "$lib/utils/errors.js";
|
||||||
import { clampTextLines } from "$lib/utils/textMeasure.js";
|
import { clampTextLines } from "$lib/utils/textMeasure.js";
|
||||||
import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js";
|
import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js";
|
||||||
import { Check, ChevronRight } from "lucide-svelte";
|
import { Check, ChevronRight } from "lucide-svelte";
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
// Aliased because SettingsPage has its own local refreshLlmStatus
|
// Aliased because SettingsPage has its own local refreshLlmStatus
|
||||||
// that just mutates the page-local llmLoaded bool. The store
|
// that just mutates the page-local llmLoaded bool. The store
|
||||||
// version drives the sidebar chip (brief item #31).
|
// version drives the sidebar chip (brief item #31).
|
||||||
import { refreshLlmStatus as refreshGlobalLlmStatus } from "$lib/stores/llmStatus.svelte.js";
|
import { refreshLlmStatus as refreshGlobalLlmStatus, markError as markGlobalLlmError, markLoading as markGlobalLlmLoading } from "$lib/stores/llmStatus.svelte.js";
|
||||||
|
|
||||||
const prefs = getPreferences();
|
const prefs = getPreferences();
|
||||||
|
|
||||||
@@ -520,7 +521,7 @@
|
|||||||
await invoke("download_llm_model", { modelId });
|
await invoke("download_llm_model", { modelId });
|
||||||
llmDownloadingModel = "";
|
llmDownloadingModel = "";
|
||||||
await refreshLlmStatus();
|
await refreshLlmStatus();
|
||||||
await refreshGlobalLlmStatus(settings.aiTier);
|
await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
|
||||||
llmStatus = "Download complete";
|
llmStatus = "Download complete";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
llmDownloadingModel = "";
|
llmDownloadingModel = "";
|
||||||
@@ -531,6 +532,7 @@
|
|||||||
async function loadSelectedLlmModel() {
|
async function loadSelectedLlmModel() {
|
||||||
const modelId = selectedLlmModelId();
|
const modelId = selectedLlmModelId();
|
||||||
llmStatus = "Loading...";
|
llmStatus = "Loading...";
|
||||||
|
markGlobalLlmLoading("Loading AI model");
|
||||||
try {
|
try {
|
||||||
await invoke("load_llm_model", {
|
await invoke("load_llm_model", {
|
||||||
modelId,
|
modelId,
|
||||||
@@ -538,9 +540,12 @@
|
|||||||
concurrent: settings.aiGpuConcurrency === "parallel",
|
concurrent: settings.aiGpuConcurrency === "parallel",
|
||||||
});
|
});
|
||||||
await refreshLlmStatus();
|
await refreshLlmStatus();
|
||||||
await refreshGlobalLlmStatus(settings.aiTier);
|
await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId, { force: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
llmStatus = typeof err === "string" ? err : "LLM load failed";
|
const message = errorMessage(err);
|
||||||
|
llmStatus = message || "LLM load failed";
|
||||||
|
await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId, { force: true });
|
||||||
|
markGlobalLlmError(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,7 +553,7 @@
|
|||||||
try {
|
try {
|
||||||
await invoke("unload_llm_model");
|
await invoke("unload_llm_model");
|
||||||
await refreshLlmStatus();
|
await refreshLlmStatus();
|
||||||
await refreshGlobalLlmStatus(settings.aiTier);
|
await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
|
||||||
llmStatus = "Model unloaded";
|
llmStatus = "Model unloaded";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
llmStatus = typeof err === "string" ? err : "LLM unload failed";
|
llmStatus = typeof err === "string" ? err : "LLM unload failed";
|
||||||
@@ -560,7 +565,7 @@
|
|||||||
try {
|
try {
|
||||||
await invoke("delete_llm_model", { modelId });
|
await invoke("delete_llm_model", { modelId });
|
||||||
await refreshLlmStatus();
|
await refreshLlmStatus();
|
||||||
await refreshGlobalLlmStatus(settings.aiTier);
|
await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
|
||||||
llmStatus = "Downloaded model removed";
|
llmStatus = "Downloaded model removed";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
llmStatus = typeof err === "string" ? err : "Delete failed";
|
llmStatus = typeof err === "string" ? err : "Delete failed";
|
||||||
@@ -589,7 +594,7 @@
|
|||||||
// already loaded) — refresh both Settings-local and global
|
// already loaded) — refresh both Settings-local and global
|
||||||
// status so the sidebar chip and download/load buttons react.
|
// status so the sidebar chip and download/load buttons react.
|
||||||
await refreshLlmStatus();
|
await refreshLlmStatus();
|
||||||
await refreshGlobalLlmStatus(settings.aiTier);
|
await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
llmStatus = typeof err === "string" ? err : "Test failed";
|
llmStatus = typeof err === "string" ? err : "Test failed";
|
||||||
llmTestHint = "";
|
llmTestHint = "";
|
||||||
@@ -618,7 +623,7 @@
|
|||||||
await unloadLlmModel();
|
await unloadLlmModel();
|
||||||
} else {
|
} else {
|
||||||
await refreshLlmStatus();
|
await refreshLlmStatus();
|
||||||
await refreshGlobalLlmStatus(settings.aiTier);
|
await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
|
||||||
}
|
}
|
||||||
llmStatus = llmModelDownloaded(modelId)
|
llmStatus = llmModelDownloaded(modelId)
|
||||||
? "Selected model changed. Load it to enable AI features."
|
? "Selected model changed. Load it to enable AI features."
|
||||||
@@ -630,7 +635,7 @@
|
|||||||
if (openSection === 'ai') {
|
if (openSection === 'ai') {
|
||||||
await ensureRecommendedLlmTier();
|
await ensureRecommendedLlmTier();
|
||||||
await refreshLlmStatus();
|
await refreshLlmStatus();
|
||||||
await refreshGlobalLlmStatus(settings.aiTier);
|
await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,7 +724,7 @@
|
|||||||
systemInfo = await invoke("probe_system").catch(() => null);
|
systemInfo = await invoke("probe_system").catch(() => null);
|
||||||
await ensureRecommendedLlmTier();
|
await ensureRecommendedLlmTier();
|
||||||
await refreshLlmStatus();
|
await refreshLlmStatus();
|
||||||
await refreshGlobalLlmStatus(settings.aiTier);
|
await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
|
||||||
const loaded = await invoke("check_engine");
|
const loaded = await invoke("check_engine");
|
||||||
engineOk = loaded;
|
engineOk = loaded;
|
||||||
engineStatus = loaded ? "Model loaded" : "No model loaded";
|
engineStatus = loaded ? "Model loaded" : "No model loaded";
|
||||||
@@ -2322,6 +2327,26 @@
|
|||||||
</details>
|
</details>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Replay onboarding — useful for testing first-run flow without
|
||||||
|
wiping data. Resets the rituals-prompt-seen flag and routes
|
||||||
|
the main shell back to the FirstRunPage. Already-downloaded
|
||||||
|
models stay clickable there, so no re-download is needed. -->
|
||||||
|
<div class="mt-6 pt-5 border-t border-border-subtle">
|
||||||
|
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Onboarding</p>
|
||||||
|
<p class="text-[11px] text-text-tertiary mb-3">
|
||||||
|
Replay the first-run welcome and the morning / evening / autostart prompts. Your downloaded models, transcripts, and tasks are kept.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
settings.ritualsPromptSeen = false;
|
||||||
|
saveSettings();
|
||||||
|
page.current = "first-run";
|
||||||
|
}}
|
||||||
|
class="px-3 py-2 text-[12px] text-text border border-border rounded-lg hover:border-accent hover:text-accent"
|
||||||
|
>Replay onboarding</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
import type { EnergyLevel, TaskBucket, TaskEntry, TaskList } from "$lib/types/app";
|
import type { EnergyLevel, TaskBucket, TaskEntry, TaskList } from "$lib/types/app";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { isAndroid } from "$lib/utils/runtime.js";
|
||||||
import {
|
import {
|
||||||
tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask,
|
tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask,
|
||||||
setTaskEnergy,
|
setTaskEnergy,
|
||||||
@@ -107,7 +108,7 @@
|
|||||||
switch (level) {
|
switch (level) {
|
||||||
case "high": return "High";
|
case "high": return "High";
|
||||||
case "medium": return "Medium";
|
case "medium": return "Medium";
|
||||||
case "brain_dead": return "Brain-Dead";
|
case "brain_dead": return "Zero";
|
||||||
default: return "Not set";
|
default: return "Not set";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,7 +124,7 @@
|
|||||||
{ value: null, label: "—" },
|
{ value: null, label: "—" },
|
||||||
{ value: "high", label: "High" },
|
{ value: "high", label: "High" },
|
||||||
{ value: "medium", label: "Med" },
|
{ value: "medium", label: "Med" },
|
||||||
{ value: "brain_dead", label: "Low" },
|
{ value: "brain_dead", label: "Zero" },
|
||||||
];
|
];
|
||||||
let energyRadioGroupEl = $state<HTMLDivElement | null>(null);
|
let energyRadioGroupEl = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -359,6 +360,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if !isAndroid()}
|
||||||
|
<!-- Multi-window pop-out is desktop-only — the Tauri Android stub
|
||||||
|
for open_task_window returns an error, so the button would just
|
||||||
|
surface a toast on a mobile build. Hide it instead. -->
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-1.5 btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text"
|
class="flex items-center gap-1.5 btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text"
|
||||||
style="transition-duration: var(--duration-ui)"
|
style="transition-duration: var(--duration-ui)"
|
||||||
@@ -368,6 +373,7 @@
|
|||||||
<ExternalLink size={14} aria-hidden="true" />
|
<ExternalLink size={14} aria-hidden="true" />
|
||||||
Pop out
|
Pop out
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
@@ -410,7 +416,12 @@
|
|||||||
|
|
||||||
<!-- Bucket tabs + sort -->
|
<!-- Bucket tabs + sort -->
|
||||||
<div class="flex items-center gap-1 px-7 pb-3">
|
<div class="flex items-center gap-1 px-7 pb-3">
|
||||||
<nav aria-label="Task filters">
|
<!-- Bucket tabs render as a horizontal pill row. The nav element is
|
||||||
|
block-level by default, which previously made its button children
|
||||||
|
stack vertically — the outer flex container only governs siblings,
|
||||||
|
not children of the nav. Adding flex/gap here puts the tabs
|
||||||
|
side-by-side as intended. -->
|
||||||
|
<nav aria-label="Task filters" class="flex items-center gap-1 flex-wrap">
|
||||||
{#each buckets as bucket}
|
{#each buckets as bucket}
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-1.5 btn-md rounded-lg
|
class="flex items-center gap-1.5 btn-md rounded-lg
|
||||||
@@ -457,10 +468,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main content: sidebar + tasks -->
|
<!-- Main content: sidebar + tasks -->
|
||||||
<div class="flex flex-1 min-h-0 px-7 pb-4 gap-3">
|
<div class="flex flex-1 min-h-0 px-7 pb-4 gap-3 items-start">
|
||||||
<!-- List sidebar -->
|
<!-- List sidebar — `self-start` + `max-h-full` so the panel sizes to
|
||||||
|
its content rather than stretching to the full task-area height.
|
||||||
|
A nearly-empty list with three items used to draw a column that
|
||||||
|
ran the full height of the window even though it had nothing in
|
||||||
|
it; this keeps the surface honest. The inner items list keeps
|
||||||
|
its scroll affordance for users with many lists. -->
|
||||||
<div
|
<div
|
||||||
class="flex flex-col bg-bg-elevated rounded-2xl border border-border-subtle overflow-hidden
|
class="flex flex-col bg-bg-elevated rounded-2xl border border-border-subtle overflow-hidden self-start max-h-full
|
||||||
{sidebarCollapsed ? 'w-[40px] min-w-[40px]' : 'w-[160px] min-w-[160px]'}"
|
{sidebarCollapsed ? 'w-[40px] min-w-[40px]' : 'w-[160px] min-w-[160px]'}"
|
||||||
style="transition: width var(--duration-ui)"
|
style="transition: width var(--duration-ui)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -17,37 +17,73 @@ export interface LlmStatusState {
|
|||||||
|
|
||||||
export const llmStatus = $state<LlmStatusState>({ kind: "off", detail: null });
|
export const llmStatus = $state<LlmStatusState>({ kind: "off", detail: null });
|
||||||
|
|
||||||
/// Poll `get_llm_status` once. Cheap enough to call on layout mount, on
|
interface RefreshLlmStatusOptions {
|
||||||
/// recording start, and on Settings-panel open. Keeps the chip in sync
|
force?: boolean;
|
||||||
/// with loads / unloads that happen outside the frontend's observation
|
|
||||||
/// path (first-run, background reload). The `aiTier` input short-circuits
|
|
||||||
/// to "off" so we don't show a chip when the user has opted out.
|
|
||||||
export async function refreshLlmStatus(aiTier: string): Promise<void> {
|
|
||||||
if (aiTier === "off") {
|
|
||||||
llmStatus.kind = "off";
|
|
||||||
llmStatus.detail = null;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!hasTauriRuntime()) {
|
|
||||||
// Running in a pure-browser preview (vite dev without tauri) — leave
|
/// Poll the configured model's status once. Cheap enough to call on layout
|
||||||
// the chip off rather than asserting a loaded state we can't verify.
|
/// mount, on recording start, and on Settings-panel open. Keeps the chip in
|
||||||
|
/// sync with loads / unloads that happen outside the frontend's observation
|
||||||
|
/// path (first-run, background reload).
|
||||||
|
///
|
||||||
|
/// Visibility rule: the chip appears only when there is something
|
||||||
|
/// meaningful for the user to know — the model is ready, generating, or
|
||||||
|
/// errored, or an explicit load is in flight (markLoading). It is hidden
|
||||||
|
/// when AI is off, no model is configured, or the configured model isn't
|
||||||
|
/// downloaded yet — those states surface in Settings, not as a stuck
|
||||||
|
/// "warming" pill that misleads the user into thinking the engine is busy.
|
||||||
|
export async function refreshLlmStatus(
|
||||||
|
aiTier: string,
|
||||||
|
llmModelId: string | null,
|
||||||
|
options: RefreshLlmStatusOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const force = options.force === true;
|
||||||
|
|
||||||
|
// Generation is a foreground action with its own success/failure
|
||||||
|
// transition. Ambient health checks should never hide it.
|
||||||
|
if (llmStatus.kind === "generating") return;
|
||||||
|
|
||||||
|
// Warming means a caller explicitly started a load. Ambient refreshes
|
||||||
|
// should not clobber that in-flight signal; post-load callers pass
|
||||||
|
// force=true to reconcile the final state.
|
||||||
|
if (llmStatus.kind === "warming" && !force) return;
|
||||||
|
|
||||||
|
if (aiTier === "off" || !hasTauriRuntime() || !llmModelId) {
|
||||||
llmStatus.kind = "off";
|
llmStatus.kind = "off";
|
||||||
llmStatus.detail = null;
|
llmStatus.detail = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const loaded = await invoke<boolean>("get_llm_status");
|
const status = await invoke<{ downloaded: boolean; loaded: boolean }>(
|
||||||
// Don't clobber a "generating" state with "ready" — a parallel
|
"check_llm_model",
|
||||||
// cleanup call in flight is a truer signal than the load status.
|
{ modelId: llmModelId },
|
||||||
if (llmStatus.kind === "generating") return;
|
);
|
||||||
llmStatus.kind = loaded ? "ready" : "warming";
|
if (status.loaded) {
|
||||||
|
llmStatus.kind = "ready";
|
||||||
llmStatus.detail = null;
|
llmStatus.detail = null;
|
||||||
|
} else {
|
||||||
|
// Not loaded and no load in flight — keep the chip hidden. A genuine
|
||||||
|
// load (triggered from DictationPage / SettingsPage) will call
|
||||||
|
// markLoading() to flip the chip.
|
||||||
|
llmStatus.kind = "off";
|
||||||
|
llmStatus.detail = null;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
llmStatus.kind = "error";
|
llmStatus.kind = "error";
|
||||||
llmStatus.detail = typeof err === "string" ? err : (err as Error)?.message ?? "Unknown error";
|
llmStatus.detail = typeof err === "string" ? err : (err as Error)?.message ?? "Unknown error";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function markLoading(detail: string | null = "Loading model"): void {
|
||||||
|
llmStatus.kind = "warming";
|
||||||
|
llmStatus.detail = detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markError(detail: string | null = null): void {
|
||||||
|
llmStatus.kind = "error";
|
||||||
|
llmStatus.detail = detail;
|
||||||
|
}
|
||||||
|
|
||||||
export function markGenerating(detail: string | null = null): void {
|
export function markGenerating(detail: string | null = null): void {
|
||||||
llmStatus.kind = "generating";
|
llmStatus.kind = "generating";
|
||||||
llmStatus.detail = detail;
|
llmStatus.detail = detail;
|
||||||
|
|||||||
@@ -334,7 +334,7 @@
|
|||||||
// backend is in right now. The chip also reacts to the $effect
|
// backend is in right now. The chip also reacts to the $effect
|
||||||
// on settings.aiTier below and to explicit mark-generating
|
// on settings.aiTier below and to explicit mark-generating
|
||||||
// calls from DictationPage around cleanup_transcript_text_cmd.
|
// calls from DictationPage around cleanup_transcript_text_cmd.
|
||||||
refreshLlmStatus(settings.aiTier).catch(() => {});
|
refreshLlmStatus(settings.aiTier, settings.llmModelId).catch(() => {});
|
||||||
|
|
||||||
if (settings.prewarmModelOnStartup) {
|
if (settings.prewarmModelOnStartup) {
|
||||||
invoke("prewarm_default_model_cmd").catch(() => {});
|
invoke("prewarm_default_model_cmd").catch(() => {});
|
||||||
|
|||||||
@@ -11,15 +11,13 @@
|
|||||||
applyExternalPreferences,
|
applyExternalPreferences,
|
||||||
PREFERENCES_CHANGED_EVENT,
|
PREFERENCES_CHANGED_EVENT,
|
||||||
} from "$lib/stores/preferences.svelte.js";
|
} from "$lib/stores/preferences.svelte.js";
|
||||||
import Titlebar from "$lib/components/Titlebar.svelte";
|
|
||||||
import FocusTimer from "$lib/components/FocusTimer.svelte";
|
import FocusTimer from "$lib/components/FocusTimer.svelte";
|
||||||
import { loadOsInfo, isLinux } from "$lib/utils/osInfo.js";
|
import ResizeHandles from "$lib/components/ResizeHandles.svelte";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
let glowing = $state(false);
|
let glowing = $state(false);
|
||||||
let unlistenFocus = null;
|
let unlistenFocus = null;
|
||||||
let unlistenPrefs = null;
|
let unlistenPrefs = null;
|
||||||
let useCustomChrome = $state(false);
|
|
||||||
|
|
||||||
const prefs = getPreferences();
|
const prefs = getPreferences();
|
||||||
|
|
||||||
@@ -44,9 +42,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
loadOsInfo()
|
// The float route renders its own pin / close header inside +page.svelte,
|
||||||
.then(() => { useCustomChrome = !isLinux(); })
|
// so the layout no longer needs to swap in a separate Titlebar component
|
||||||
.catch(() => {});
|
// based on platform. Keeping the loadOsInfo call out entirely simplifies
|
||||||
|
// the chrome — one titlebar source of truth, one place for the pin.
|
||||||
try {
|
try {
|
||||||
unlistenFocus = await listen("task-window-focus", () => {
|
unlistenFocus = await listen("task-window-focus", () => {
|
||||||
glowing = true;
|
glowing = true;
|
||||||
@@ -84,14 +83,15 @@
|
|||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<div class="h-screen w-screen overflow-hidden grain border border-border shadow-xl animate-float-enter flex flex-col {glowing ? 'animate-glow-pulse' : ''}">
|
<div class="h-screen w-screen overflow-hidden grain border border-border shadow-xl animate-float-enter flex flex-col {glowing ? 'animate-glow-pulse' : ''}">
|
||||||
{#if useCustomChrome}
|
|
||||||
<Titlebar compact />
|
|
||||||
{/if}
|
|
||||||
<div class="flex-1 min-h-0 overflow-hidden">
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mounted outside the animated/transformed root so fixed-position
|
||||||
|
resize hit zones anchor to the webview viewport. -->
|
||||||
|
<ResizeHandles />
|
||||||
|
|
||||||
<!-- Focus timer also visible in the always-on-top float window so a
|
<!-- Focus timer also visible in the always-on-top float window so a
|
||||||
running countdown stays with the Now list. The component is a
|
running countdown stays with the Now list. The component is a
|
||||||
global overlay (position: fixed) so it pins to the top-right of
|
global overlay (position: fixed) so it pins to the top-right of
|
||||||
|
|||||||
@@ -70,7 +70,21 @@
|
|||||||
|
|
||||||
async function togglePin() {
|
async function togglePin() {
|
||||||
pinned = !pinned;
|
pinned = !pinned;
|
||||||
await getCurrentWindow().setAlwaysOnTop(pinned);
|
const w = getCurrentWindow();
|
||||||
|
await w.setAlwaysOnTop(pinned);
|
||||||
|
if (pinned) {
|
||||||
|
// KWin/Mutter on Wayland routinely drop _NET_WM_STATE_ABOVE when it
|
||||||
|
// arrives post-map for a normal window. Combined with the GTK Utility
|
||||||
|
// hint applied at creation (open_task_window), an unmap/map cycle is
|
||||||
|
// the most reliable way to force the compositor to re-apply state
|
||||||
|
// changes. Hidden for one event-loop tick — visually a flicker, but
|
||||||
|
// the pin actually sticks afterwards.
|
||||||
|
try {
|
||||||
|
await w.hide();
|
||||||
|
await w.show();
|
||||||
|
await w.setFocus();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeWindow() {
|
function closeWindow() {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { defineConfig } from "vite";
|
|||||||
import { sveltekit } from "@sveltejs/kit/vite";
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
// @ts-expect-error process is a nodejs global
|
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
|
|||||||
Reference in New Issue
Block a user