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 activeTagFilter = $state(null); // null = all; string = tag value
|
||||
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
|
||||
// on page refresh by design.
|
||||
let selected = $state(new Set());
|
||||
@@ -622,35 +695,68 @@
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4 px-7 pt-6 pb-4">
|
||||
<h2 class="font-display text-[26px] italic text-text">History</h2>
|
||||
<span class="text-[12px] text-text-secondary mt-1">{history.length} saved</span>
|
||||
<div class="flex-1"></div>
|
||||
<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"}
|
||||
<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"
|
||||
>
|
||||
<Star size={14} aria-hidden="true" />
|
||||
<span class="text-[12px]">Starred</span>
|
||||
</button>
|
||||
{#if history.some((i) => !i.llmTags || i.llmTags.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"
|
||||
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>
|
||||
{#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" />
|
||||
<span class="text-[12px]">{bulkTagging ? `Tagging ${bulkTaggingProgress}` : "Tag all untagged"}</span>
|
||||
<Star size={14} aria-hidden="true" />
|
||||
<span class="text-[12px]">Starred</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if history.length > 0}
|
||||
{#if history.some((i) => !i.llmTags || i.llmTags.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
|
||||
class="btn-md rounded-lg text-text-tertiary hover:text-danger hover:bg-hover"
|
||||
onclick={openClearAllModal}
|
||||
class="btn-md rounded-lg text-text-tertiary hover:text-text hover:bg-hover disabled:opacity-50"
|
||||
onclick={loadTrash}
|
||||
disabled={trashLoading}
|
||||
title="Reload Trash"
|
||||
>
|
||||
Clear All
|
||||
<span class="text-[12px]">{trashLoading ? "Refreshing…" : "Refresh"}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -726,6 +832,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if viewMode === "live"}
|
||||
<!-- Search -->
|
||||
<div class="px-7 pb-3">
|
||||
<Card tone="subtle">
|
||||
@@ -1059,4 +1166,60 @@
|
||||
{/if}
|
||||
</Card>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user