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:
2026-05-13 18:04:39 +01:00
parent f7af7b07bb
commit 87e6248774

View File

@@ -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,8 +695,31 @@
<!-- 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">
{viewMode === "trash" ? `${trashItems.length} in Trash` : `${history.length} saved`}
</span>
<div
class="inline-flex items-center rounded-lg border border-border-subtle p-0.5 bg-bg-elevated"
role="tablist"
aria-label="History view"
>
<button
class="text-[12px] px-2.5 py-1 rounded-md
{viewMode === 'live' ? 'bg-bg-card text-text' : 'text-text-secondary hover:text-text'}"
role="tab"
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> <div class="flex-1"></div>
{#if viewMode === "live"}
<button <button
class="inline-flex items-center gap-1.5 btn-md rounded-lg 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'}" {showStarredOnly ? 'text-accent bg-accent/10' : 'text-text-tertiary hover:text-text-secondary hover:bg-hover'}"
@@ -653,6 +749,16 @@
Clear All Clear All
</button> </button>
{/if} {/if}
{:else}
<button
class="btn-md rounded-lg text-text-tertiary hover:text-text hover:bg-hover disabled:opacity-50"
onclick={loadTrash}
disabled={trashLoading}
title="Reload Trash"
>
<span class="text-[12px]">{trashLoading ? "Refreshing…" : "Refresh"}</span>
</button>
{/if}
</div> </div>
<!-- Type-the-word DELETE modal for clearAll. Replaces the 4-second <!-- Type-the-word DELETE modal for clearAll. Replaces the 4-second
@@ -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>