agent: code-atomiser-fix — trash view + restore button in HistoryPage (Rev-2 UI)
Adds a Live | Trash toggle to the HistoryPage header. The Trash tab
lists soft-deleted transcripts via the new list_trashed_transcripts
Tauri command and offers per-row Restore via restore_transcript.
Permanently-delete-from-trash is intentionally deferred — the 30-day
startup purge (TRANSCRIPT_TRASH_RETENTION_DAYS in src-tauri/src/lib.rs)
handles hard-removal until that UI lands. The retention policy is
surfaced in the panel header: Trashed items are kept for 30 days,
then permanently deleted on next app start.
Design notes:
- View toggle is a tablist of two pill buttons (Live | Trash); the
aria-selected attribute reflects the active mode.
- Switching to Trash triggers an effect that loads the list once.
Switching back to Live discards the trash data so stale rows
don't reappear on toggle.
- Live-mode header controls (Starred, Tag all untagged, Clear All)
are hidden in Trash mode and replaced with a Refresh button.
- Restore drops the row from the in-memory trash list rather than
splicing into history; the live store reloads from SQLite on its
own initialisation path, so we avoid drifting the in-memory shape
from the canonical source.
- The created timestamp is shown rather than the deletion
timestamp; deleted_at is not currently in the TranscriptDto and
expanding the DTO is out of scope for this fix.
- Audio files may already have been removed by delete_transcript's
best-effort filesystem cleanup, so restored text + metadata may
surface without playable audio (documented in the storage-layer
restore_transcript contract).
TODO(test): no Svelte component test framework wired in the repo
(vitest not installed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,79 @@
|
|||||||
let showStarredOnly = $state(false);
|
let showStarredOnly = $state(false);
|
||||||
let activeTagFilter = $state(null); // null = all; string = tag value
|
let activeTagFilter = $state(null); // null = all; string = tag value
|
||||||
let expandedId = $state(null);
|
let expandedId = $state(null);
|
||||||
|
|
||||||
|
// View toggle: "live" shows current transcripts (default), "trash"
|
||||||
|
// shows soft-deleted transcripts. Backed by `list_trashed_transcripts`
|
||||||
|
// and `restore_transcript` Tauri commands. Permanent-delete-from-trash
|
||||||
|
// is intentionally deferred — the 30-day startup purge
|
||||||
|
// (TRANSCRIPT_TRASH_RETENTION_DAYS in src-tauri/src/lib.rs) handles
|
||||||
|
// hard-removal until the UI for it lands.
|
||||||
|
let viewMode: "live" | "trash" = $state("live");
|
||||||
|
let trashItems = $state([]);
|
||||||
|
let trashLoading = $state(false);
|
||||||
|
let trashError = $state("");
|
||||||
|
let restoringId = $state(null);
|
||||||
|
|
||||||
|
async function loadTrash() {
|
||||||
|
trashLoading = true;
|
||||||
|
trashError = "";
|
||||||
|
try {
|
||||||
|
trashItems = await invoke("list_trashed_transcripts", { limit: 500, offset: 0 });
|
||||||
|
} catch (err) {
|
||||||
|
trashError = typeof err === "string" ? err : String(err);
|
||||||
|
toasts.error("Failed to load Trash", trashError);
|
||||||
|
} finally {
|
||||||
|
trashLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreFromTrash(id) {
|
||||||
|
if (restoringId) return;
|
||||||
|
restoringId = id;
|
||||||
|
try {
|
||||||
|
await invoke("restore_transcript", { id: String(id) });
|
||||||
|
// Drop the row from the trash list. The live list will pick the
|
||||||
|
// restored row up on next refresh; we deliberately do not splice
|
||||||
|
// it into `history` directly here, because that store is loaded
|
||||||
|
// by the page-store init path and inserting a partial DTO would
|
||||||
|
// drift the in-memory shape from the SQLite source of truth.
|
||||||
|
trashItems = trashItems.filter((t) => t.id !== id);
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(
|
||||||
|
"Restore failed",
|
||||||
|
typeof err === "string" ? err : String(err),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
restoringId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formats an ISO timestamp for display in the Trash list. We surface
|
||||||
|
// the transcript's `createdAt` rather than the deletion timestamp
|
||||||
|
// because `deleted_at` is not currently in the TranscriptDto shape;
|
||||||
|
// adding it is out of scope for this fix. Falls back to the raw
|
||||||
|
// string if Date can't parse it so we never display "NaN".
|
||||||
|
function formatTrashTimestamp(iso) {
|
||||||
|
if (!iso) return "";
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switching to trash view loads the list once. Switching back to live
|
||||||
|
// discards the trash data so a stale list doesn't reappear on toggle.
|
||||||
|
$effect(() => {
|
||||||
|
if (viewMode === "trash") {
|
||||||
|
void loadTrash();
|
||||||
|
} else {
|
||||||
|
trashItems = [];
|
||||||
|
trashError = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
// Phase 9 bulk selection. Selection state is frontend-only and resets
|
// Phase 9 bulk selection. Selection state is frontend-only and resets
|
||||||
// on page refresh by design.
|
// on page refresh by design.
|
||||||
let selected = $state(new Set());
|
let selected = $state(new Set());
|
||||||
@@ -622,35 +695,68 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center gap-4 px-7 pt-6 pb-4">
|
<div class="flex items-center gap-4 px-7 pt-6 pb-4">
|
||||||
<h2 class="font-display text-[26px] italic text-text">History</h2>
|
<h2 class="font-display text-[26px] italic text-text">History</h2>
|
||||||
<span class="text-[12px] text-text-secondary mt-1">{history.length} saved</span>
|
<span class="text-[12px] text-text-secondary mt-1">
|
||||||
<div class="flex-1"></div>
|
{viewMode === "trash" ? `${trashItems.length} in Trash` : `${history.length} saved`}
|
||||||
<button
|
</span>
|
||||||
class="inline-flex items-center gap-1.5 btn-md rounded-lg
|
<div
|
||||||
{showStarredOnly ? 'text-accent bg-accent/10' : 'text-text-tertiary hover:text-text-secondary hover:bg-hover'}"
|
class="inline-flex items-center rounded-lg border border-border-subtle p-0.5 bg-bg-elevated"
|
||||||
onclick={() => (showStarredOnly = !showStarredOnly)}
|
role="tablist"
|
||||||
aria-pressed={showStarredOnly}
|
aria-label="History view"
|
||||||
title={showStarredOnly ? "Showing starred only (click to show all)" : "Show starred only"}
|
|
||||||
>
|
>
|
||||||
<Star size={14} aria-hidden="true" />
|
|
||||||
<span class="text-[12px]">Starred</span>
|
|
||||||
</button>
|
|
||||||
{#if history.some((i) => !i.llmTags || i.llmTags.length === 0)}
|
|
||||||
<button
|
<button
|
||||||
class="btn-md rounded-lg text-text-tertiary hover:text-text hover:bg-hover disabled:opacity-50 inline-flex items-center gap-1.5"
|
class="text-[12px] px-2.5 py-1 rounded-md
|
||||||
onclick={tagAllUntagged}
|
{viewMode === 'live' ? 'bg-bg-card text-text' : 'text-text-secondary hover:text-text'}"
|
||||||
disabled={bulkTagging}
|
role="tab"
|
||||||
title="Run AI content tagging across every untagged transcript"
|
aria-selected={viewMode === "live"}
|
||||||
|
onclick={() => (viewMode = "live")}
|
||||||
|
>Live</button>
|
||||||
|
<button
|
||||||
|
class="text-[12px] px-2.5 py-1 rounded-md
|
||||||
|
{viewMode === 'trash' ? 'bg-bg-card text-text' : 'text-text-secondary hover:text-text'}"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={viewMode === "trash"}
|
||||||
|
onclick={() => (viewMode = "trash")}
|
||||||
|
>Trash</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
{#if viewMode === "live"}
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-1.5 btn-md rounded-lg
|
||||||
|
{showStarredOnly ? 'text-accent bg-accent/10' : 'text-text-tertiary hover:text-text-secondary hover:bg-hover'}"
|
||||||
|
onclick={() => (showStarredOnly = !showStarredOnly)}
|
||||||
|
aria-pressed={showStarredOnly}
|
||||||
|
title={showStarredOnly ? "Showing starred only (click to show all)" : "Show starred only"}
|
||||||
>
|
>
|
||||||
<Tag size={13} aria-hidden="true" />
|
<Star size={14} aria-hidden="true" />
|
||||||
<span class="text-[12px]">{bulkTagging ? `Tagging ${bulkTaggingProgress}` : "Tag all untagged"}</span>
|
<span class="text-[12px]">Starred</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{#if history.some((i) => !i.llmTags || i.llmTags.length === 0)}
|
||||||
{#if history.length > 0}
|
<button
|
||||||
|
class="btn-md rounded-lg text-text-tertiary hover:text-text hover:bg-hover disabled:opacity-50 inline-flex items-center gap-1.5"
|
||||||
|
onclick={tagAllUntagged}
|
||||||
|
disabled={bulkTagging}
|
||||||
|
title="Run AI content tagging across every untagged transcript"
|
||||||
|
>
|
||||||
|
<Tag size={13} aria-hidden="true" />
|
||||||
|
<span class="text-[12px]">{bulkTagging ? `Tagging ${bulkTaggingProgress}` : "Tag all untagged"}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if history.length > 0}
|
||||||
|
<button
|
||||||
|
class="btn-md rounded-lg text-text-tertiary hover:text-danger hover:bg-hover"
|
||||||
|
onclick={openClearAllModal}
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
<button
|
<button
|
||||||
class="btn-md rounded-lg text-text-tertiary hover:text-danger hover:bg-hover"
|
class="btn-md rounded-lg text-text-tertiary hover:text-text hover:bg-hover disabled:opacity-50"
|
||||||
onclick={openClearAllModal}
|
onclick={loadTrash}
|
||||||
|
disabled={trashLoading}
|
||||||
|
title="Reload Trash"
|
||||||
>
|
>
|
||||||
Clear All
|
<span class="text-[12px]">{trashLoading ? "Refreshing…" : "Refresh"}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -726,6 +832,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if viewMode === "live"}
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="px-7 pb-3">
|
<div class="px-7 pb-3">
|
||||||
<Card tone="subtle">
|
<Card tone="subtle">
|
||||||
@@ -1059,4 +1166,60 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Trash view. Lists soft-deleted transcripts (deleted_at IS NOT
|
||||||
|
NULL) with a Restore button per row. Permanently-delete-from-
|
||||||
|
trash is intentionally deferred — the 30-day startup purge
|
||||||
|
handles hard-removal until the UI lands. Audio files may
|
||||||
|
already have been removed by delete_transcript's best-effort
|
||||||
|
filesystem cleanup, so restored text + metadata may surface
|
||||||
|
without playable audio. -->
|
||||||
|
<div class="px-7 pb-3">
|
||||||
|
<div class="text-[12px] text-text-secondary italic">
|
||||||
|
Trashed items are kept for 30 days, then permanently deleted on next app start.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 px-7 pb-4 min-h-0">
|
||||||
|
<Card classes="h-full flex flex-col overflow-hidden">
|
||||||
|
{#if trashLoading && trashItems.length === 0}
|
||||||
|
<EmptyState
|
||||||
|
icon={Clock}
|
||||||
|
message="Loading Trash…"
|
||||||
|
/>
|
||||||
|
{:else if trashError && trashItems.length === 0}
|
||||||
|
<EmptyState
|
||||||
|
icon={Clock}
|
||||||
|
message={`Failed to load Trash: ${trashError}`}
|
||||||
|
/>
|
||||||
|
{:else if trashItems.length === 0}
|
||||||
|
<EmptyState
|
||||||
|
icon={Clock}
|
||||||
|
message="Trash is empty"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
{#each trashItems as item (item.id)}
|
||||||
|
<div class="flex items-center gap-3 px-4 py-3 border-b border-border-subtle bg-bg-card">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-[13px] text-text truncate">
|
||||||
|
{item.title || (item.text ? item.text.slice(0, 80) : "Untitled")}
|
||||||
|
</div>
|
||||||
|
<div class="text-[12px] text-text-tertiary mt-0.5">
|
||||||
|
Created {formatTrashTimestamp(item.createdAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="text-[12px] px-3 py-1.5 rounded-lg bg-accent/10 border border-accent/30 text-accent
|
||||||
|
hover:bg-accent/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
onclick={() => restoreFromTrash(item.id)}
|
||||||
|
disabled={restoringId === item.id}
|
||||||
|
title="Restore this transcript to the Live list"
|
||||||
|
>{restoringId === item.id ? "Restoring…" : "Restore"}</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user