Compare commits
3 Commits
62e795174c
...
feat/v0.3-
| Author | SHA1 | Date | |
|---|---|---|---|
| 6881aa800a | |||
| baab7b3c12 | |||
| 3810fdda43 |
@@ -83,6 +83,42 @@ Display sleep is intentionally NOT blocked — the user is dictating, not watchi
|
|||||||
|
|
||||||
**Workaround:** N/A. Existing path works as before.
|
**Workaround:** N/A. Existing path works as before.
|
||||||
|
|
||||||
|
## LLM
|
||||||
|
|
||||||
|
### KI-07 — `llama-cpp-2 v0.1.146` panics on Qwen3.5+ Fused Gated Delta Net (FGDN) tensors
|
||||||
|
|
||||||
|
**Status:** Loading any of the four configured model variants (`Qwen3_5_2B_Q4`, `Qwen3_5_4B_Q4`, `Qwen3_5_9B_Q4`, `Qwen3_6_27B_Q4`) at startup aborts the whole process with a GGML assert inside the bundled `llama.cpp`:
|
||||||
|
|
||||||
|
```
|
||||||
|
llama-cpp-sys-2-0.1.146/llama.cpp/src/llama-context.cpp:487:
|
||||||
|
GGML_ASSERT(strncmp(n->name, LLAMA_TENSOR_NAME_FGDN_AR "-", prefix_len) == 0) failed
|
||||||
|
```
|
||||||
|
|
||||||
|
The assert fires during `llama_context` construction while resolving Fused Gated Delta Net support, which is the recurrent / Mamba-style attention mechanism Qwen3.5+ uses. `llama-cpp-2 v0.1.146` bundles a `llama.cpp` snapshot whose FGDN tensor-name prefix check rejects tensors as named in current Qwen3.5 GGUFs. Because the assert is in C++ via `ggml`, the panic kills the parent Tauri process — Tauri's crash handler then attaches GDB, which is what surfaces in the dev log as a stack trace ending in `tao::event_loop` rather than as a Rust panic.
|
||||||
|
|
||||||
|
All four enum variants share the same architecture family, so picking a different size does not work around it.
|
||||||
|
|
||||||
|
**Source:**
|
||||||
|
- Dep pin: [`crates/llm/Cargo.toml:24`](crates/llm/Cargo.toml#L24) (`llama-cpp-2 = "0.1.146"`)
|
||||||
|
- Model enum: [`crates/llm/src/model_manager.rs:15`](crates/llm/src/model_manager.rs#L15) (the four Qwen3.5+ variants)
|
||||||
|
- Frontend autoload trigger: [`src/lib/pages/DictationPage.svelte:274`](src/lib/pages/DictationPage.svelte#L274) (`ensureLlmModelLoaded` → `invoke("load_llm_model", …)`)
|
||||||
|
- Backend command: [`src-tauri/src/commands/llm.rs:94`](src-tauri/src/commands/llm.rs#L94) (`load_llm_model`)
|
||||||
|
- Upstream assert: `llama-cpp-sys-2-0.1.146/llama.cpp/src/llama-context.cpp:487`
|
||||||
|
|
||||||
|
**Impact:** App will not start in dev (and presumably release) on any machine that has a Qwen3.5+ `.gguf` present and `settings.aiTier !== "off"`. There is no UI to recover — the crash precedes the WebView paint, so the user cannot reach Settings to disable AI features or swap the model. Affects every code path that exercises the LLM (transcript cleanup, task extraction, micro-step decomposition).
|
||||||
|
|
||||||
|
**Workarounds (in order of cost):**
|
||||||
|
1. **Park the model file** so `check_llm_model` returns `downloaded: false` and autoload silently bails:
|
||||||
|
```
|
||||||
|
mv ~/.local/share/lumotia/models/llm/Qwen3.5-4B-Q4_K_M.gguf{,.crash-parked}
|
||||||
|
```
|
||||||
|
Non-LLM features (dictation, transcript history, file imports) work normally. Reverse with `mv …{.crash-parked,}`.
|
||||||
|
2. **Set `aiTier = "off"`** in `lumotia_preferences` before launch (also bypasses the autoload guard).
|
||||||
|
3. **Bump `llama-cpp-2`** to a release that includes the upstream `llama.cpp` FGDN fix. Track upstream PRs at <https://github.com/utilityai/llama-cpp-rs/releases>; verify the bundled `llama.cpp` SHA covers Gated Delta Net tensor-name handling before adopting.
|
||||||
|
4. **Add a non-FGDN model variant** to `LlmModelId` (e.g. Llama 3.2 3B Q4 — well-supported in 0.1.146 — or Qwen2.5 3B) and ship a Settings UI to switch to it. Larger change because the enum, the on-disk URL/size constants, and the Settings model picker all need entries.
|
||||||
|
|
||||||
|
**Resolution (deferred):** Option 3 is the real fix and matches the spirit of `crates/llm` (Qwen3.5+ explicitly listed in the crate description). Option 4 is the right fallback if upstream is slow — having one non-FGDN model on the enum prevents the dev-blocker class of crash entirely.
|
||||||
|
|
||||||
## How to add an entry
|
## How to add an entry
|
||||||
|
|
||||||
When shipping a partial implementation or known limitation, add a `KI-NN` entry here with the four standard fields: **Status**, **Source** (file:line), **Impact**, **Workaround**. Link from the affected module's doc comment back to this file by ID.
|
When shipping a partial implementation or known limitation, add a `KI-NN` entry here with the four standard fields: **Status**, **Source** (file:line), **Impact**, **Workaround**. Link from the affected module's doc comment back to this file by ID.
|
||||||
|
|||||||
@@ -186,6 +186,16 @@
|
|||||||
Quietware — dark mode default. Warm brown-charcoal atmosphere.
|
Quietware — dark mode default. Warm brown-charcoal atmosphere.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
:root[data-design="quietware"] {
|
:root[data-design="quietware"] {
|
||||||
|
/* Phase 4l — Wren palette revision, 2026/05/15. Role signals
|
||||||
|
swapped to Tiger Flame / Banana Cream / Sea Green / Steel Azure
|
||||||
|
(dark) and the FILL/TEXT pair set (light). Coffee Bean bg
|
||||||
|
candidate #1F0812 was tested live and rejected — its R/G/B
|
||||||
|
channels (31/8/18) sit on a purple-leaning hue, reading as
|
||||||
|
aubergine in the running window, not the labelled warm brown.
|
||||||
|
Bg + grain held at the prior values pending a true coffee-brown
|
||||||
|
candidate. Steel Azure contrast on dark fails AA (see notice on
|
||||||
|
--color-info-signal below) regardless of bg choice — retune of
|
||||||
|
the info ink/signal split is owed separately. */
|
||||||
--color-bg: #12100E;
|
--color-bg: #12100E;
|
||||||
--color-bg-elevated: #1A1713;
|
--color-bg-elevated: #1A1713;
|
||||||
--color-bg-card: #211D18;
|
--color-bg-card: #211D18;
|
||||||
@@ -207,25 +217,28 @@
|
|||||||
--color-accent-subtle: color-mix(in srgb, var(--blue-700) 12%, transparent);
|
--color-accent-subtle: color-mix(in srgb, var(--blue-700) 12%, transparent);
|
||||||
--color-accent-glow: color-mix(in srgb, var(--blue-700) 25%, transparent);
|
--color-accent-glow: color-mix(in srgb, var(--blue-700) 25%, transparent);
|
||||||
|
|
||||||
/* Four-tier role tokens. Signal stays bright (the role's identity);
|
/* Four-tier role tokens. Signal carries the role's identity
|
||||||
ink lightens for text legibility on dark; border is a middle value;
|
(Tiger Flame / Banana Cream / Sea Green / Steel Azure under
|
||||||
bg is a hand-tuned dark tint, not a color-mix derivation. */
|
Phase 4l); ink lightens for text legibility on dark; border is
|
||||||
--color-info-signal: #6EA8FF;
|
a middle value; bg is a hand-tuned dark tint, not a color-mix.
|
||||||
|
Borders + bg tints retained from Phase 4j — they go with the
|
||||||
|
hue family rather than the specific shade. */
|
||||||
|
--color-info-signal: #064789;
|
||||||
--color-info-ink: #9CC6FF;
|
--color-info-ink: #9CC6FF;
|
||||||
--color-info-border: #4D8DFF;
|
--color-info-border: #4D8DFF;
|
||||||
--color-info-bg: #101A2A;
|
--color-info-bg: #101A2A;
|
||||||
|
|
||||||
--color-danger-signal: #FF5A52;
|
--color-danger-signal: #F26430;
|
||||||
--color-danger-ink: #FF9B96;
|
--color-danger-ink: #FF9B96;
|
||||||
--color-danger-border: #FF5A52;
|
--color-danger-border: #FF5A52;
|
||||||
--color-danger-bg: #2A1412;
|
--color-danger-bg: #2A1412;
|
||||||
|
|
||||||
--color-success-signal: #00FF56;
|
--color-success-signal: #519872;
|
||||||
--color-success-ink: #7DFFA8;
|
--color-success-ink: #7DFFA8;
|
||||||
--color-success-border: #00D94A;
|
--color-success-border: #00D94A;
|
||||||
--color-success-bg: #0D2415;
|
--color-success-bg: #0D2415;
|
||||||
|
|
||||||
--color-caution-signal: #FFCD00;
|
--color-caution-signal: #FCEC52;
|
||||||
--color-caution-ink: #FFE066;
|
--color-caution-ink: #FFE066;
|
||||||
--color-caution-border: #FFCD00;
|
--color-caution-border: #FFCD00;
|
||||||
--color-caution-bg: #29210A;
|
--color-caution-bg: #29210A;
|
||||||
@@ -419,25 +432,28 @@
|
|||||||
--color-accent-subtle: color-mix(in srgb, var(--blue-700) 12%, transparent);
|
--color-accent-subtle: color-mix(in srgb, var(--blue-700) 12%, transparent);
|
||||||
--color-accent-glow: color-mix(in srgb, var(--blue-700) 25%, transparent);
|
--color-accent-glow: color-mix(in srgb, var(--blue-700) 25%, transparent);
|
||||||
|
|
||||||
/* Four-tier role tokens, light mode. Bg values hand-picked as
|
/* Four-tier role tokens, light mode. Phase 4l — Wren palette
|
||||||
clean pale tints, not muddy color-mix products. */
|
revision 2026/05/15. Signal = FILL (vivid), ink = TEXT-safe
|
||||||
--color-info-signal: #000AFF;
|
darker sibling that passes 4.5:1 on cream. Saturation/lightness
|
||||||
--color-info-ink: #0033A0;
|
targeted on the locked Banana Cream anchor #D5A021 (S 73%, L 48%).
|
||||||
|
Borders + bg tints unchanged — they go with the hue family. */
|
||||||
|
--color-info-signal: #217AD5;
|
||||||
|
--color-info-ink: #1F73C9;
|
||||||
--color-info-border: #3A6BFF;
|
--color-info-border: #3A6BFF;
|
||||||
--color-info-bg: #EEF3FF;
|
--color-info-bg: #EEF3FF;
|
||||||
|
|
||||||
--color-danger-signal: #FF0700;
|
--color-danger-signal: #D55121;
|
||||||
--color-danger-ink: #B3261E;
|
--color-danger-ink: #C54B1F;
|
||||||
--color-danger-border: #E13A32;
|
--color-danger-border: #E13A32;
|
||||||
--color-danger-bg: #FFF0EF;
|
--color-danger-bg: #FFF0EF;
|
||||||
|
|
||||||
--color-success-signal: #00D94A;
|
--color-success-signal: #21D575;
|
||||||
--color-success-ink: #006B2D;
|
--color-success-ink: #148348;
|
||||||
--color-success-border: #159947;
|
--color-success-border: #159947;
|
||||||
--color-success-bg: #EAF8EE;
|
--color-success-bg: #EAF8EE;
|
||||||
|
|
||||||
--color-caution-signal: #FFCD00;
|
--color-caution-signal: #D5A021;
|
||||||
--color-caution-ink: #7A5D00;
|
--color-caution-ink: #7E7414;
|
||||||
--color-caution-border: #D9A900;
|
--color-caution-border: #D9A900;
|
||||||
--color-caution-bg: #FFF7D6;
|
--color-caution-bg: #FFF7D6;
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,12 @@
|
|||||||
import LumotiaNotice from "$lib/ui/LumotiaNotice.svelte";
|
import LumotiaNotice from "$lib/ui/LumotiaNotice.svelte";
|
||||||
// v0.3 Phase 5f — quietware History layout.
|
// v0.3 Phase 5f — quietware History layout.
|
||||||
import LumotiaPageSkeleton from "$lib/ui/LumotiaPageSkeleton.svelte";
|
import LumotiaPageSkeleton from "$lib/ui/LumotiaPageSkeleton.svelte";
|
||||||
|
import LumotiaMenu from "$lib/ui/LumotiaMenu.svelte";
|
||||||
|
import LumotiaIconButton from "$lib/ui/LumotiaIconButton.svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { formatTime, formatDuration, formatTimestamp } from "$lib/utils/time.js";
|
import { formatTime, formatDuration, formatTimestamp } from "$lib/utils/time.js";
|
||||||
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
|
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
|
||||||
import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Star, Tag } from 'lucide-svelte';
|
import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Star, Tag, MoreHorizontal, Copy, Trash2, FileDown } from 'lucide-svelte';
|
||||||
|
|
||||||
const prefs = getPreferences();
|
const prefs = getPreferences();
|
||||||
const COLLAPSED_ROW_MIN_HEIGHT = 54;
|
const COLLAPSED_ROW_MIN_HEIGHT = 54;
|
||||||
@@ -298,6 +300,95 @@
|
|||||||
return item.title?.trim() || "Untitled";
|
return item.title?.trim() || "Untitled";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.3 Phase 5f polish — quietware row helpers. Kept separate from
|
||||||
|
// compactPreviewText so the v0.2 fallback's row-height measurement
|
||||||
|
// (which reads compactPreviews) is untouched.
|
||||||
|
|
||||||
|
/** Two-line transcript snippet for the quietware row sub-line. Falls
|
||||||
|
* through item.text → item.preview → empty string ("No preview…"
|
||||||
|
* rendering is the template's job, not this helper's). Stripped of
|
||||||
|
* leading whitespace so previews don't start mid-pause. */
|
||||||
|
function quietRowPreview(item) {
|
||||||
|
const raw = (item?.text || item?.preview || "").replace(/\s+/g, " ").trim();
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Source label for the quietware row meta-line. "Dictation" for
|
||||||
|
* mic-captured items, "File" for imports. Mirrors the icon column
|
||||||
|
* but lives in the meta-line so the row scans without colour cues. */
|
||||||
|
function quietRowSourceLabel(item) {
|
||||||
|
const s = (item?.source || "").toLowerCase();
|
||||||
|
return s.includes("file") ? "File" : "Dictation";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Just the HH:MM portion of a saved-at timestamp. The date sits in
|
||||||
|
* the group header above the row, so repeating it here would waste
|
||||||
|
* hierarchy. Uses en-GB 24h to match formatTimestamp. */
|
||||||
|
function quietRowTime(iso) {
|
||||||
|
if (!iso) return "";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "";
|
||||||
|
return d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calendar-day label for date-group headers. Formula:
|
||||||
|
* same local-calendar day as now → "Today"
|
||||||
|
* previous local-calendar day → "Yesterday"
|
||||||
|
* same local-calendar year → "D MMM" (e.g. "12 May")
|
||||||
|
* older → "D MMM YYYY" (e.g. "12 May 2025")
|
||||||
|
* Day boundaries are strictly local midnight, not 24-hour windows. */
|
||||||
|
function dateGroupLabel(iso) {
|
||||||
|
if (!iso) return "Undated";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "Undated";
|
||||||
|
const now = new Date();
|
||||||
|
const sameDay = (a, b) =>
|
||||||
|
a.getFullYear() === b.getFullYear() &&
|
||||||
|
a.getMonth() === b.getMonth() &&
|
||||||
|
a.getDate() === b.getDate();
|
||||||
|
if (sameDay(d, now)) return "Today";
|
||||||
|
const y = new Date(now);
|
||||||
|
y.setDate(now.getDate() - 1);
|
||||||
|
if (sameDay(d, y)) return "Yesterday";
|
||||||
|
const fmt = d.toLocaleDateString("en-GB", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: d.getFullYear() === now.getFullYear() ? undefined : "numeric",
|
||||||
|
});
|
||||||
|
return fmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable bucket key for grouping. Local-calendar YYYY-MM-DD so the
|
||||||
|
* groups break at midnight, not on UTC date math. Items with no
|
||||||
|
* savedAt fall into an "undated" bucket that surfaces last. */
|
||||||
|
function dateGroupKey(iso) {
|
||||||
|
if (!iso) return "undated";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "undated";
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = (d.getMonth() + 1).toString().padStart(2, "0");
|
||||||
|
const day = d.getDate().toString().padStart(2, "0");
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walk `filtered` in order, breaking at calendar-day boundaries.
|
||||||
|
* Preserves the underlying sort so newest-first stays newest-first;
|
||||||
|
* groups appear in the order their first item appears. */
|
||||||
|
let liveGroups = $derived.by(() => {
|
||||||
|
const out = [];
|
||||||
|
let current = null;
|
||||||
|
for (const item of filtered) {
|
||||||
|
const iso = item.savedAt || item.timestamp;
|
||||||
|
const key = dateGroupKey(iso);
|
||||||
|
if (!current || current.key !== key) {
|
||||||
|
current = { key, label: dateGroupLabel(iso), items: [] };
|
||||||
|
out.push(current);
|
||||||
|
}
|
||||||
|
current.items.push(item);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
let compactPreviews = $derived.by(() => {
|
let compactPreviews = $derived.by(() => {
|
||||||
return filtered.map((item) => {
|
return filtered.map((item) => {
|
||||||
const text = compactPreviewText(item);
|
const text = compactPreviewText(item);
|
||||||
@@ -721,14 +812,19 @@
|
|||||||
<!-- =================================================================
|
<!-- =================================================================
|
||||||
v0.3 Phase 5f — quietware History layout. Calm local archive.
|
v0.3 Phase 5f — quietware History layout. Calm local archive.
|
||||||
|
|
||||||
Header "History" + count
|
Header "History" + Live / Trash tabs with counts
|
||||||
Primary surface search + view tabs + list / empty state
|
Primary surface search + date-grouped list / empty state
|
||||||
Action dock contextual per item
|
Row icon · title · 2-line preview · meta-line
|
||||||
Mono footer saved count · storage · local only
|
Open (visible) + ⋯ overflow (Copy / Export
|
||||||
|
/ Move to Trash). Trash rows show Restore
|
||||||
|
visible; permanent-delete UI is deferred
|
||||||
|
(the 30-day purge handles hard-removal,
|
||||||
|
see TRANSCRIPT_TRASH_RETENTION_DAYS).
|
||||||
|
Mono footer saved count · trash count · local only
|
||||||
|
|
||||||
Reuses every existing state and handler: history (store),
|
Reuses every existing state and handler: history (store),
|
||||||
trashItems, viewMode, searchQuery, filtered (derived),
|
trashItems, viewMode, searchQuery, filtered (derived),
|
||||||
compactPreviewText, copyItem, removeItem, openViewer,
|
copyItem, exportMarkdown, removeItem, openViewer,
|
||||||
restoreFromTrash. Bulk actions, audio playback, tag chips
|
restoreFromTrash. Bulk actions, audio playback, tag chips
|
||||||
and ClearAll modal stay accessible via the v0.2 fallback —
|
and ClearAll modal stay accessible via the v0.2 fallback —
|
||||||
the quietware view focuses on the core archive flow.
|
the quietware view focuses on the core archive flow.
|
||||||
@@ -775,9 +871,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Search input, mounted to the top of the surface. -->
|
<!-- Search input. No max-width: spans the same content gutter
|
||||||
|
as the rows below so the right edge aligns with where the
|
||||||
|
row actions column begins. Intentional, not arbitrary. -->
|
||||||
<div class="px-6 pt-5 pb-3 border-b border-border-subtle">
|
<div class="px-6 pt-5 pb-3 border-b border-border-subtle">
|
||||||
<div class="relative max-w-2xl">
|
<div class="relative">
|
||||||
<Search size={14} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary pointer-events-none" aria-hidden="true" />
|
<Search size={14} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary pointer-events-none" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
@@ -791,7 +889,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Archive list. Reuses the existing derived `filtered` for
|
<!-- Archive list. Reuses the existing derived `filtered` for
|
||||||
live mode and `trashItems` for trash mode. -->
|
live mode and `trashItems` for trash mode. Live mode
|
||||||
|
renders date-bucketed groups (Today / Yesterday / D MMM)
|
||||||
|
so the surface scans as an archive rather than a flat
|
||||||
|
table; trash stays ungrouped because the relevant axis
|
||||||
|
there is days-until-purge, not save date. -->
|
||||||
<div class="flex-1 overflow-auto min-h-0">
|
<div class="flex-1 overflow-auto min-h-0">
|
||||||
{#if viewMode === "live"}
|
{#if viewMode === "live"}
|
||||||
{#if filtered.length === 0}
|
{#if filtered.length === 0}
|
||||||
@@ -805,28 +907,61 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="divide-y divide-border-subtle">
|
<ul class="pb-2">
|
||||||
{#each filtered as item (item.id)}
|
{#each liveGroups as group (group.key)}
|
||||||
<li class="flex items-start gap-3 px-6 py-3 hover:bg-hover transition-colors">
|
<li
|
||||||
<div class="mt-0.5 text-text-tertiary shrink-0">
|
class="px-6 pt-5 pb-2 text-[11px] uppercase tracking-wider text-text-tertiary font-mono"
|
||||||
{#if item.source === "file"}
|
aria-hidden="true"
|
||||||
<FileText size={16} aria-hidden="true" />
|
>
|
||||||
{:else}
|
{group.label}
|
||||||
<Mic size={16} aria-hidden="true" />
|
</li>
|
||||||
{/if}
|
<li>
|
||||||
</div>
|
<ul class="divide-y divide-border-subtle border-y border-border-subtle">
|
||||||
<div class="flex-1 min-w-0">
|
{#each group.items as item (item.id)}
|
||||||
<p class="text-[13px] font-medium text-text truncate">
|
{@const previewText = quietRowPreview(item)}
|
||||||
{item.title || item.preview?.slice(0, 80) || "Untitled"}
|
{@const sourceLabel = quietRowSourceLabel(item)}
|
||||||
</p>
|
<li class="group/row flex items-start gap-3 px-6 py-3 hover:bg-hover transition-colors">
|
||||||
<p class="text-[12px] text-text-secondary mt-0.5 line-clamp-2">{compactPreviewText(item)}</p>
|
<div class="mt-0.5 text-text-tertiary shrink-0">
|
||||||
<p class="text-[11px] text-text-tertiary mt-1 font-mono">{formatTimestamp(item.savedAt || item.timestamp)}</p>
|
{#if sourceLabel === "File"}
|
||||||
</div>
|
<FileText size={16} aria-hidden="true" />
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
{:else}
|
||||||
<LumotiaButton size="sm" variant="tertiary" onclick={() => openViewer(item)}>Open</LumotiaButton>
|
<Mic size={16} aria-hidden="true" />
|
||||||
<LumotiaButton size="sm" variant="tertiary" onclick={() => copyItem(item)}>Copy</LumotiaButton>
|
{/if}
|
||||||
<LumotiaButton size="sm" variant="tertiary" onclick={() => removeItem(item)}>Delete</LumotiaButton>
|
</div>
|
||||||
</div>
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-[13px] font-medium text-text truncate">
|
||||||
|
{item.title?.trim() || "Untitled"}
|
||||||
|
</p>
|
||||||
|
<p class="text-[12px] text-text-secondary mt-0.5 line-clamp-2">
|
||||||
|
{previewText || "No preview available"}
|
||||||
|
</p>
|
||||||
|
<p class="text-[11px] text-text-tertiary mt-1 font-mono">
|
||||||
|
{quietRowTime(item.savedAt || item.timestamp)}
|
||||||
|
{#if item.duration}
|
||||||
|
· {formatDuration(item.duration)}
|
||||||
|
{/if}
|
||||||
|
· {sourceLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
|
<LumotiaButton size="sm" variant="tertiary" onclick={() => openViewer(item)}>Open</LumotiaButton>
|
||||||
|
<LumotiaMenu
|
||||||
|
align="end"
|
||||||
|
items={[
|
||||||
|
{ value: "copy", label: "Copy transcript", icon: Copy, onSelect: () => copyItem(item) },
|
||||||
|
{ value: "export", label: "Export markdown", icon: FileDown, onSelect: () => exportMarkdown(item) },
|
||||||
|
{ kind: "separator" as const },
|
||||||
|
{ value: "delete", label: "Move to Trash", icon: Trash2, destructive: true, onSelect: () => removeItem(item) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{#snippet trigger()}
|
||||||
|
<LumotiaIconButton icon={MoreHorizontal} label="More actions" size="sm" />
|
||||||
|
{/snippet}
|
||||||
|
</LumotiaMenu>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -844,21 +979,31 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<ul class="divide-y divide-border-subtle">
|
<ul class="divide-y divide-border-subtle">
|
||||||
{#each trashItems as item (item.id)}
|
{#each trashItems as item (item.id)}
|
||||||
|
{@const previewText = quietRowPreview(item)}
|
||||||
|
{@const sourceLabel = quietRowSourceLabel(item)}
|
||||||
<li class="flex items-start gap-3 px-6 py-3 hover:bg-hover transition-colors">
|
<li class="flex items-start gap-3 px-6 py-3 hover:bg-hover transition-colors">
|
||||||
<div class="mt-0.5 text-text-tertiary shrink-0">
|
<div class="mt-0.5 text-text-tertiary shrink-0">
|
||||||
<FileText size={16} aria-hidden="true" />
|
{#if sourceLabel === "File"}
|
||||||
|
<FileText size={16} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<Mic size={16} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-[13px] font-medium text-text truncate">
|
<p class="text-[13px] font-medium text-text truncate">
|
||||||
{item.title || item.preview?.slice(0, 80) || "Untitled"}
|
{item.title?.trim() || "Untitled"}
|
||||||
|
</p>
|
||||||
|
<p class="text-[12px] text-text-secondary mt-0.5 line-clamp-2">
|
||||||
|
{previewText || "No preview available"}
|
||||||
|
</p>
|
||||||
|
<p class="text-[11px] text-text-tertiary mt-1 font-mono">
|
||||||
|
Deleted {formatTrashTimestamp(item.deleted_at || item.createdAt)} · Auto-purges after 30 days
|
||||||
</p>
|
</p>
|
||||||
<p class="text-[12px] text-text-secondary mt-0.5 line-clamp-1">{item.preview || ""}</p>
|
|
||||||
<p class="text-[11px] text-text-tertiary mt-1 font-mono">Deleted {formatTrashTimestamp(item.deleted_at)}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
<LumotiaButton
|
<LumotiaButton
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={restoringId === item.id ? "secondary" : "primary"}
|
variant={restoringId === item.id ? "secondary" : "tertiary"}
|
||||||
disabled={restoringId === item.id}
|
disabled={restoringId === item.id}
|
||||||
onclick={() => restoreFromTrash(item.id)}
|
onclick={() => restoreFromTrash(item.id)}
|
||||||
>{restoringId === item.id ? "Restoring…" : "Restore"}</LumotiaButton>
|
>{restoringId === item.id ? "Restoring…" : "Restore"}</LumotiaButton>
|
||||||
|
|||||||
Reference in New Issue
Block a user