Compare commits
3 Commits
62e795174c
...
6881aa800a
| 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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
============================================================ */
|
||||
: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-elevated: #1A1713;
|
||||
--color-bg-card: #211D18;
|
||||
@@ -207,25 +217,28 @@
|
||||
--color-accent-subtle: color-mix(in srgb, var(--blue-700) 12%, transparent);
|
||||
--color-accent-glow: color-mix(in srgb, var(--blue-700) 25%, transparent);
|
||||
|
||||
/* Four-tier role tokens. Signal stays bright (the role's identity);
|
||||
ink lightens for text legibility on dark; border is a middle value;
|
||||
bg is a hand-tuned dark tint, not a color-mix derivation. */
|
||||
--color-info-signal: #6EA8FF;
|
||||
/* Four-tier role tokens. Signal carries the role's identity
|
||||
(Tiger Flame / Banana Cream / Sea Green / Steel Azure under
|
||||
Phase 4l); ink lightens for text legibility on dark; border is
|
||||
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-border: #4D8DFF;
|
||||
--color-info-bg: #101A2A;
|
||||
|
||||
--color-danger-signal: #FF5A52;
|
||||
--color-danger-signal: #F26430;
|
||||
--color-danger-ink: #FF9B96;
|
||||
--color-danger-border: #FF5A52;
|
||||
--color-danger-bg: #2A1412;
|
||||
|
||||
--color-success-signal: #00FF56;
|
||||
--color-success-signal: #519872;
|
||||
--color-success-ink: #7DFFA8;
|
||||
--color-success-border: #00D94A;
|
||||
--color-success-bg: #0D2415;
|
||||
|
||||
--color-caution-signal: #FFCD00;
|
||||
--color-caution-signal: #FCEC52;
|
||||
--color-caution-ink: #FFE066;
|
||||
--color-caution-border: #FFCD00;
|
||||
--color-caution-bg: #29210A;
|
||||
@@ -419,25 +432,28 @@
|
||||
--color-accent-subtle: color-mix(in srgb, var(--blue-700) 12%, transparent);
|
||||
--color-accent-glow: color-mix(in srgb, var(--blue-700) 25%, transparent);
|
||||
|
||||
/* Four-tier role tokens, light mode. Bg values hand-picked as
|
||||
clean pale tints, not muddy color-mix products. */
|
||||
--color-info-signal: #000AFF;
|
||||
--color-info-ink: #0033A0;
|
||||
/* Four-tier role tokens, light mode. Phase 4l — Wren palette
|
||||
revision 2026/05/15. Signal = FILL (vivid), ink = TEXT-safe
|
||||
darker sibling that passes 4.5:1 on cream. Saturation/lightness
|
||||
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-bg: #EEF3FF;
|
||||
|
||||
--color-danger-signal: #FF0700;
|
||||
--color-danger-ink: #B3261E;
|
||||
--color-danger-signal: #D55121;
|
||||
--color-danger-ink: #C54B1F;
|
||||
--color-danger-border: #E13A32;
|
||||
--color-danger-bg: #FFF0EF;
|
||||
|
||||
--color-success-signal: #00D94A;
|
||||
--color-success-ink: #006B2D;
|
||||
--color-success-signal: #21D575;
|
||||
--color-success-ink: #148348;
|
||||
--color-success-border: #159947;
|
||||
--color-success-bg: #EAF8EE;
|
||||
|
||||
--color-caution-signal: #FFCD00;
|
||||
--color-caution-ink: #7A5D00;
|
||||
--color-caution-signal: #D5A021;
|
||||
--color-caution-ink: #7E7414;
|
||||
--color-caution-border: #D9A900;
|
||||
--color-caution-bg: #FFF7D6;
|
||||
|
||||
|
||||
@@ -28,10 +28,12 @@
|
||||
import LumotiaNotice from "$lib/ui/LumotiaNotice.svelte";
|
||||
// v0.3 Phase 5f — quietware History layout.
|
||||
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 { formatTime, formatDuration, formatTimestamp } from "$lib/utils/time.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 COLLAPSED_ROW_MIN_HEIGHT = 54;
|
||||
@@ -298,6 +300,95 @@
|
||||
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(() => {
|
||||
return filtered.map((item) => {
|
||||
const text = compactPreviewText(item);
|
||||
@@ -721,14 +812,19 @@
|
||||
<!-- =================================================================
|
||||
v0.3 Phase 5f — quietware History layout. Calm local archive.
|
||||
|
||||
Header "History" + count
|
||||
Primary surface search + view tabs + list / empty state
|
||||
Action dock contextual per item
|
||||
Mono footer saved count · storage · local only
|
||||
Header "History" + Live / Trash tabs with counts
|
||||
Primary surface search + date-grouped list / empty state
|
||||
Row icon · title · 2-line preview · meta-line
|
||||
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),
|
||||
trashItems, viewMode, searchQuery, filtered (derived),
|
||||
compactPreviewText, copyItem, removeItem, openViewer,
|
||||
copyItem, exportMarkdown, removeItem, openViewer,
|
||||
restoreFromTrash. Bulk actions, audio playback, tag chips
|
||||
and ClearAll modal stay accessible via the v0.2 fallback —
|
||||
the quietware view focuses on the core archive flow.
|
||||
@@ -775,9 +871,11 @@
|
||||
</div>
|
||||
{/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="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" />
|
||||
<input
|
||||
type="search"
|
||||
@@ -791,7 +889,11 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
{#if viewMode === "live"}
|
||||
{#if filtered.length === 0}
|
||||
@@ -805,28 +907,61 @@
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border-subtle">
|
||||
{#each filtered as item (item.id)}
|
||||
<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">
|
||||
{#if item.source === "file"}
|
||||
<FileText size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<Mic size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-[13px] font-medium text-text truncate">
|
||||
{item.title || item.preview?.slice(0, 80) || "Untitled"}
|
||||
</p>
|
||||
<p class="text-[12px] text-text-secondary mt-0.5 line-clamp-2">{compactPreviewText(item)}</p>
|
||||
<p class="text-[11px] text-text-tertiary mt-1 font-mono">{formatTimestamp(item.savedAt || item.timestamp)}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<LumotiaButton size="sm" variant="tertiary" onclick={() => openViewer(item)}>Open</LumotiaButton>
|
||||
<LumotiaButton size="sm" variant="tertiary" onclick={() => copyItem(item)}>Copy</LumotiaButton>
|
||||
<LumotiaButton size="sm" variant="tertiary" onclick={() => removeItem(item)}>Delete</LumotiaButton>
|
||||
</div>
|
||||
<ul class="pb-2">
|
||||
{#each liveGroups as group (group.key)}
|
||||
<li
|
||||
class="px-6 pt-5 pb-2 text-[11px] uppercase tracking-wider text-text-tertiary font-mono"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{group.label}
|
||||
</li>
|
||||
<li>
|
||||
<ul class="divide-y divide-border-subtle border-y border-border-subtle">
|
||||
{#each group.items as item (item.id)}
|
||||
{@const previewText = quietRowPreview(item)}
|
||||
{@const sourceLabel = quietRowSourceLabel(item)}
|
||||
<li class="group/row flex items-start gap-3 px-6 py-3 hover:bg-hover transition-colors">
|
||||
<div class="mt-0.5 text-text-tertiary shrink-0">
|
||||
{#if sourceLabel === "File"}
|
||||
<FileText size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<Mic size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
</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>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -844,21 +979,31 @@
|
||||
{:else}
|
||||
<ul class="divide-y divide-border-subtle">
|
||||
{#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">
|
||||
<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 class="flex-1 min-w-0">
|
||||
<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 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 class="flex items-center gap-1 shrink-0">
|
||||
<LumotiaButton
|
||||
size="sm"
|
||||
variant={restoringId === item.id ? "secondary" : "primary"}
|
||||
variant={restoringId === item.id ? "secondary" : "tertiary"}
|
||||
disabled={restoringId === item.id}
|
||||
onclick={() => restoreFromTrash(item.id)}
|
||||
>{restoringId === item.id ? "Restoring…" : "Restore"}</LumotiaButton>
|
||||
|
||||
Reference in New Issue
Block a user