From f7a3e609af7520a048bd1a0474c3340b5e175e3d Mon Sep 17 00:00:00 2001
From: Rod Boev <106971+rodboev@users.noreply.github.com>
Date: Thu, 29 Oct 2026 22:20:14 -0500
Subject: [PATCH 01/61] feat(web): unify files, sources, and deletion review
Files and archived accounts need the same navigation and review controls
as Messages. Keep source schedules visible when they are configured
outside the settings catalog.
Reviewing a Gmail deletion should keep the selected messages in view.
Cancellation still needs confirmation and must reach the daemon as soon
as the user confirms it.
---
web/src/App.svelte | 3 +
.../lib/components/common/SidePanel.svelte | 55 ++
.../lib/components/common/StatusChip.svelte | 11 +
web/src/lib/components/common/run-state.ts | 11 +
.../deletions/DeletionReview.svelte | 162 ++++
.../deletions/DeletionReview.test.ts | 82 ++
.../deletions/DeletionsWorkspace.svelte | 789 +++++++-----------
.../deletions/DeletionsWorkspace.test.ts | 570 ++-----------
.../lib/components/explore/ContextBar.svelte | 8 +-
...nBar.svelte => ExploreSelectionBar.svelte} | 2 +-
...ar.test.ts => ExploreSelectionBar.test.ts} | 46 +-
.../lib/components/explore/FilterMenu.svelte | 5 +-
.../lib/components/files/FileFilters.svelte | 38 +
.../lib/components/files/FileViewer.svelte | 6 +-
.../lib/components/files/FileViewer.test.ts | 20 +-
.../components/files/FilesWorkspace.svelte | 172 ++--
.../components/files/FilesWorkspace.test.ts | 126 +--
web/src/lib/components/files/filter-chips.ts | 14 +
.../kata/KataFileIssueAction.svelte | 33 +-
.../kata/KataFileIssueAction.test.ts | 32 +-
.../settings/SettingsWorkspace.svelte | 16 +-
web/src/lib/components/shell/AppShell.svelte | 64 +-
web/src/lib/components/shell/AppShell.test.ts | 39 +-
.../lib/components/shell/AppSidebar.svelte | 6 +-
.../shell/EverythingWorkspace.svelte | 4 +-
web/src/lib/components/shell/navigation.ts | 5 +-
.../shell/shell-data.svelte.test.ts | 21 +
.../lib/components/shell/shell-data.svelte.ts | 12 +-
.../lib/components/sources/CommandLine.svelte | 33 +
.../components/sources/ContactPanel.svelte | 121 +++
.../lib/components/sources/SourcePanel.svelte | 351 ++++++++
.../sources/SourcesWorkspace.svelte | 451 +++++-----
.../sources/SourcesWorkspace.test.ts | 342 +++++---
.../components/tasks/AddToTaskDialog.svelte | 10 +-
web/src/lib/search/visual-ready.svelte.ts | 17 +
web/src/lib/settings/draft.svelte.ts | 134 +++
web/src/lib/sources/contacts.ts | 51 ++
web/src/lib/sources/labels.ts | 86 +-
web/tests/archive-management.spec.ts | 64 +-
web/tests/files-viewer.spec.ts | 8 +-
40 files changed, 2368 insertions(+), 1652 deletions(-)
create mode 100644 web/src/lib/components/common/SidePanel.svelte
create mode 100644 web/src/lib/components/common/StatusChip.svelte
create mode 100644 web/src/lib/components/common/run-state.ts
create mode 100644 web/src/lib/components/deletions/DeletionReview.svelte
create mode 100644 web/src/lib/components/deletions/DeletionReview.test.ts
rename web/src/lib/components/explore/{SelectionBar.svelte => ExploreSelectionBar.svelte} (99%)
rename web/src/lib/components/explore/{SelectionBar.test.ts => ExploreSelectionBar.test.ts} (89%)
create mode 100644 web/src/lib/components/files/FileFilters.svelte
create mode 100644 web/src/lib/components/files/filter-chips.ts
create mode 100644 web/src/lib/components/sources/CommandLine.svelte
create mode 100644 web/src/lib/components/sources/ContactPanel.svelte
create mode 100644 web/src/lib/components/sources/SourcePanel.svelte
create mode 100644 web/src/lib/search/visual-ready.svelte.ts
create mode 100644 web/src/lib/settings/draft.svelte.ts
create mode 100644 web/src/lib/sources/contacts.ts
diff --git a/web/src/App.svelte b/web/src/App.svelte
index cbb8326c5d..2792490d29 100644
--- a/web/src/App.svelte
+++ b/web/src/App.svelte
@@ -8,6 +8,7 @@
import { receiveGoogleContactsCallback } from './lib/settings/google-authorization';
import { createSessionController, type SessionController } from './lib/api/session.svelte';
import { provideKataReadiness } from './lib/kata/kata-ready.svelte';
+ import { provideVisualReadiness } from './lib/search/visual-ready.svelte';
import { provideMediaReadiness } from './lib/search/media-ready.svelte';
import Login from './lib/components/auth/Login.svelte';
import SettingsWorkspace from './lib/components/settings/SettingsWorkspace.svelte';
@@ -33,6 +34,8 @@
provideKataReadiness(session.client);
// svelte-ignore state_referenced_locally
provideMediaReadiness(session.client);
+ // svelte-ignore state_referenced_locally
+ provideVisualReadiness(session.client);
let oauthCallback = $state(false);
let pathname = $state(window.location.pathname);
const messageID = $derived(Number(/^\/messages\/([1-9]\d*)\/?$/.exec(pathname)?.[1]) || undefined);
diff --git a/web/src/lib/components/common/SidePanel.svelte b/web/src/lib/components/common/SidePanel.svelte
new file mode 100644
index 0000000000..8875563213
--- /dev/null
+++ b/web/src/lib/components/common/SidePanel.svelte
@@ -0,0 +1,55 @@
+
+
+
+
+
+ {#snippet header()}
+ {title}
+
+
+
+ {/snippet}
+ {@render children()}
+
+
+
+
diff --git a/web/src/lib/components/common/StatusChip.svelte b/web/src/lib/components/common/StatusChip.svelte
new file mode 100644
index 0000000000..29ec166d33
--- /dev/null
+++ b/web/src/lib/components/common/StatusChip.svelte
@@ -0,0 +1,11 @@
+
+
+{entry.label}
diff --git a/web/src/lib/components/common/run-state.ts b/web/src/lib/components/common/run-state.ts
new file mode 100644
index 0000000000..f72e3ce7b7
--- /dev/null
+++ b/web/src/lib/components/common/run-state.ts
@@ -0,0 +1,11 @@
+import type { ChipTone } from '@kenn-io/kit-ui';
+
+export type RunState = 'running' | 'succeeded' | 'partial' | 'failed';
+
+/** The one run-state vocabulary: label and tone for every job, sync, and run. */
+export const RUN_STATES: Record = {
+ running: { label: 'Running', tone: 'info' },
+ succeeded: { label: 'Succeeded', tone: 'success' },
+ partial: { label: 'Partial', tone: 'warning' },
+ failed: { label: 'Failed', tone: 'danger' },
+};
diff --git a/web/src/lib/components/deletions/DeletionReview.svelte b/web/src/lib/components/deletions/DeletionReview.svelte
new file mode 100644
index 0000000000..b218a1353d
--- /dev/null
+++ b/web/src/lib/components/deletions/DeletionReview.svelte
@@ -0,0 +1,162 @@
+
+
+
+
diff --git a/web/src/lib/components/deletions/DeletionReview.test.ts b/web/src/lib/components/deletions/DeletionReview.test.ts
new file mode 100644
index 0000000000..2db2ba7945
--- /dev/null
+++ b/web/src/lib/components/deletions/DeletionReview.test.ts
@@ -0,0 +1,82 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { createAPIClient } from '../../api/client';
+import type { ExploreSelection } from '../../api/generated/models';
+import DeletionReview from './DeletionReview.svelte';
+
+const selection: ExploreSelection = {
+ mode: 'explicit', predicate: { presentation: 'table' }, row_keys: ['source:1:message:m1'],
+ cache_revision: 'cache-1', search_provenance: {}
+};
+
+function preflight(overrides: Record = {}) {
+ return {
+ count: 3, deletable_count: 2, estimated_bytes: 3 * 1024, cache_revision: 'cache-1', search_provenance: {},
+ unavailable_actions: [], operation_token: 'token-1', expires_at: '2026-07-19T10:05:00Z', ...overrides
+ };
+}
+
+interface Seen { method: string; path: string; body?: Record }
+
+function api(review = preflight(), seen: Seen[] = []) {
+ return createAPIClient(vi.fn(async (input) => {
+ const request = input instanceof Request ? input : new Request(input);
+ const path = new URL(request.url).pathname;
+ const body = request.method === 'POST' ? await request.clone().json() : undefined;
+ seen.push({ method: request.method, path, body });
+ if (path.endsWith('/explore/preflight')) return Response.json(review);
+ if (path === '/api/v1/deletions' && request.method === 'POST') {
+ return body?.dry_run
+ ? Response.json({ dry_run: true, message_count: 2, matched_count: 3 })
+ : Response.json({ dry_run: false, id: 'batch-9', message_count: 2, matched_count: 3 }, { status: 201 });
+ }
+ if (request.method === 'DELETE') return Response.json({ id: 'batch-9', status: 'cancelled' });
+ return Response.json({}, { status: 404 });
+ }));
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+ document.body.replaceChildren();
+});
+
+describe('DeletionReview', () => {
+ it('shows counts and a Stage button with no confirmation modal on top', async () => {
+ render(DeletionReview, { client: api(), selection, onclose: vi.fn() });
+ expect(await screen.findByText('3 messages · 3 KB')).toBeDefined();
+ expect(screen.getByText("1 can't be deleted from Gmail and stay.")).toBeDefined();
+ expect(screen.getAllByRole('dialog')).toHaveLength(1);
+ expect(screen.getByRole('button', { name: 'Stage 2 messages' })).toBeDefined();
+ expect(screen.queryByText(/manifest|execute|Microsoft Graph/i)).toBeNull();
+ });
+
+ it('stages the reviewed selection and closes after daemon acknowledgement', async () => {
+ const seen: Seen[] = [];
+ const onStaged = vi.fn(), onclose = vi.fn();
+ render(DeletionReview, { client: api(preflight(), seen), selection, onclose, onStaged });
+ await fireEvent.click(await screen.findByRole('button', { name: 'Stage 2 messages' }));
+ await waitFor(() => expect(onStaged).toHaveBeenCalledWith('batch-9'));
+ expect(onclose).toHaveBeenCalledOnce();
+ expect(seen.find(entry => entry.path === '/api/v1/deletions')?.body).toMatchObject({ selection, operation_token: 'token-1', dry_run: false });
+ });
+
+ it('runs a dry run from the menu without staging anything', async () => {
+ const seen: Seen[] = [];
+ render(DeletionReview, { client: api(preflight(), seen), selection, onclose: vi.fn() });
+ await screen.findByText('3 messages · 3 KB');
+ await fireEvent.click(screen.getByRole('button', { name: 'Dry run' }));
+ expect(await screen.findByText('Dry run: 2 messages would be staged.')).toBeDefined();
+ expect(seen.filter((entry) => entry.method === 'POST' && entry.body?.dry_run === false)).toEqual([]);
+ });
+
+ it('disables staging and says why when nothing qualifies', async () => {
+ const review = preflight({
+ deletable_count: 0,
+ unavailable_actions: [{ action: 'stage_deletion', reason: 'no_deletable_messages' }]
+ });
+ render(DeletionReview, { client: api(review), selection, onclose: vi.fn() });
+ const stage = await screen.findByRole('button', { name: 'Stage 0 messages' });
+ expect((stage as HTMLButtonElement).disabled).toBe(true);
+ });
+});
diff --git a/web/src/lib/components/deletions/DeletionsWorkspace.svelte b/web/src/lib/components/deletions/DeletionsWorkspace.svelte
index bc9e8cd01d..488b31a9b5 100644
--- a/web/src/lib/components/deletions/DeletionsWorkspace.svelte
+++ b/web/src/lib/components/deletions/DeletionsWorkspace.svelte
@@ -2,17 +2,17 @@
import type { ChipTone } from '@kenn-io/kit-ui';
import { sentenceCase } from '../../explore/labels';
- const MANIFEST_STATUS: Readonly> = {
- pending: { label: 'Pending', tone: 'info' },
- in_progress: { label: 'In progress', tone: 'info' },
- completed: { label: 'Completed', tone: 'success' },
+ const BATCH_STATUS: Readonly> = {
+ pending: { label: 'Staged', tone: 'info' },
+ in_progress: { label: 'Deleting', tone: 'info' },
+ completed: { label: 'Deleted', tone: 'success' },
failed: { label: 'Failed', tone: 'danger' },
cancelled: { label: 'Cancelled', tone: 'muted' },
};
- function manifestStatusChip(status: string): { label: string; tone: ChipTone } {
- return Object.hasOwn(MANIFEST_STATUS, status)
- ? MANIFEST_STATUS[status]!
+ function batchStatus(status: string): { label: string; tone: ChipTone } {
+ return Object.hasOwn(BATCH_STATUS, status)
+ ? BATCH_STATUS[status]!
: { label: sentenceCase(status), tone: 'neutral' };
}
@@ -22,585 +22,356 @@
cancelDeletion as generatedCancelDeletion,
getDeletion as generatedGetDeletion,
listDeletions as generatedListDeletions,
- stageDeletion as generatedStageDeletion,
} from '../../api/generated/api/api';
- import { preflightExploreSelection as generatedPreflightExploreSelection } from '../../api/generated/exploration/exploration';
- import {
- Button,
- Card,
- Chip,
- EmptyState,
- Modal,
- Table,
- TableHeaderCell,
- appShortcuts,
- } from '@kenn-io/kit-ui';
- import { onDestroy, onMount, tick } from 'svelte';
+ import { Button, Checkbox, Chip, CopyButton, Table, TableHeaderCell } from '@kenn-io/kit-ui';
+ import { onDestroy, onMount } from 'svelte';
import type { APIClient } from '../../api/client';
- import { preflightReasonLabel } from '../../explore/labels';
- import { formatBytes, formatDateTime, formatRelativeTime } from '../../util/format';
- import PageHeader from '../shell/PageHeader.svelte';
import type {
DeletionManifestDetail as GeneratedDeletionManifestDetail,
DeletionManifestSummary as GeneratedDeletionManifestSummary,
- ExplorePreflightResponse as GeneratedExplorePreflightResponse,
- ExploreSelection as GeneratedExploreSelection,
- StageDeletionResponse as GeneratedStageDeletionResponse,
} from '../../api/generated/models';
- type ExploreSelection = GeneratedExploreSelection;
- type Preflight = GeneratedExplorePreflightResponse;
- type ManifestSummary = GeneratedDeletionManifestSummary;
- type ManifestDetail = GeneratedDeletionManifestDetail;
- type StageDeletionResponse = GeneratedStageDeletionResponse;
- let {
- client,
- selection = undefined,
- reviewOnMount = false,
- onReviewStarted = () => undefined,
- now = () => new Date(),
- }: {
- client: APIClient;
- selection?: ExploreSelection;
- reviewOnMount?: boolean;
- onReviewStarted?: () => void;
- now?: () => Date;
- } = $props();
- let manifests = $state([]);
- let detail = $state();
- let manifestsElement = $state();
- let reviewed = $state();
- let reviewedFingerprint = '';
+ import { formatBytes, formatDateTime } from '../../util/format';
+ import { EmptyState } from '@kenn-io/kit-ui';
+ import SidePanel from '../common/SidePanel.svelte';
+ import Dialog from '../common/Dialog.svelte';
+ import PageHeader from '../shell/PageHeader.svelte';
+ import CommandLine from '../sources/CommandLine.svelte';
+
+ type Batch = GeneratedDeletionManifestSummary;
+ type Detail = GeneratedDeletionManifestDetail;
+ const DELETE_COMMAND = 'msgvault delete-staged';
+ // Account lookups share a small worker pool across the batch history.
+ const ACCOUNT_LOOKUPS = 4;
+
+ let { client, onChanged = () => undefined }: { client: APIClient; onChanged?: () => void } = $props();
+
+ let batches = $state([]);
+ let accounts = $state>({});
+ let selectedID = $state();
+ let detail = $state();
+ let showCancelled = $state(false);
let loading = $state(true);
- let pending = $state(false);
let error = $state('');
- let preview = $state();
- let confirmStage = $state<'explicit' | 'all_matching'>();
- let confirmCancel = $state();
- let listController: AbortController | undefined;
- onMount(() => {
- void loadManifests();
- if (reviewOnMount && selection) {
- onReviewStarted();
- void reviewSelection(true);
- }
- const releaseScope = appShortcuts.pushScope('deletions');
- const unregister = [
- appShortcuts.register(
- 'd',
- () => {
- if (selection?.mode === 'explicit') void reviewSelection(true);
- },
- { scope: 'deletions', description: 'Stage selected messages for deletion' },
- ),
- appShortcuts.register(
- 'shift+d',
- () => {
- if (selection?.mode === 'all_matching') void reviewSelection(true);
- },
- { scope: 'deletions', description: 'Stage all matching messages for deletion' },
- ),
- ];
- return () => {
- for (const remove of unregister.reverse()) remove();
- releaseScope();
- };
- });
- onDestroy(() => listController?.abort());
- function fingerprint(value: ExploreSelection | undefined): string {
- return value ? JSON.stringify(value) : '';
- }
- function reviewedIsCurrent(): boolean {
- if (!reviewed || reviewedFingerprint !== fingerprint(selection)) {
- reviewed = undefined;
- preview = undefined;
- error = 'The selection changed. Review it again before continuing.';
- return false;
- }
- return true;
- }
- async function loadManifests(): Promise {
- listController?.abort();
- const controller = new AbortController();
- listController = controller;
+ let confirmCancel = $state();
+ let cancelling = $state(false);
+ let controller: AbortController | undefined;
+
+ const cancelledCount = $derived(batches.filter((batch) => batch.status === 'cancelled').length);
+ const rows = $derived(
+ batches.filter(
+ (batch) => showCancelled || batch.status !== 'cancelled',
+ ),
+ );
+
+ onMount(() => void load());
+ onDestroy(() => controller?.abort());
+
+ async function load(): Promise {
+ controller?.abort();
+ loading = true;
+ const current = (controller = new AbortController());
try {
const { data, error: responseError } = await generatedListDeletions(undefined, {
...client,
- signal: controller.signal,
+ signal: current.signal,
});
- if (!data) throw new Error(messageFor(responseError, 'Unable to load deletion manifests.'));
- manifests = data.manifests ?? [];
+ if (!data) throw new Error(messageFor(responseError, "Can't load staged deletions."));
+ if (current.signal.aborted) return;
+ batches = data.manifests ?? [];
error = '';
+ void loadAccounts(batches, current.signal);
} catch (cause) {
- if (!controller.signal.aborted)
- error = cause instanceof Error ? cause.message : 'Unable to load deletion manifests.';
- } finally {
- if (listController === controller) {
- listController = undefined;
- loading = false;
- }
- }
- }
- async function reviewSelection(openConfirmation = false): Promise {
- if (!selection || pending) return;
- pending = true;
- error = '';
- preview = undefined;
- const candidate = selection;
- const candidateFingerprint = fingerprint(candidate);
- try {
- const { data, error: responseError } = await generatedPreflightExploreSelection({ selection: candidate }, client);
- if (!data) throw new Error(messageFor(responseError, 'Unable to review this selection.'));
- if (typeof data.deletable_count !== 'number') {
- throw new Error('Deletion review requires daemon API schema 2.18.0 or newer. Upgrade the daemon and review again.');
- }
- if (candidateFingerprint !== fingerprint(selection)) {
- throw new Error('The selection changed while it was being reviewed. Review it again.');
- }
- reviewed = data;
- reviewedFingerprint = candidateFingerprint;
- if (openConfirmation && !unavailableReason('stage_deletion')) confirmStage = candidate.mode;
- } catch (cause) {
- error = cause instanceof Error ? cause.message : 'Unable to review this selection.';
+ if (!current.signal.aborted) error = cause instanceof Error ? cause.message : "Can't load staged deletions.";
} finally {
- pending = false;
+ if (controller === current) loading = false;
}
}
- function unavailableReason(action: string): string | undefined {
- return reviewed?.unavailable_actions.find((item) => item.action === action)?.reason;
- }
- async function dryRun(): Promise {
- if (!selection || !reviewedIsCurrent()) return;
- pending = true;
- error = '';
- const dryRunSelection = selection;
- const dryRunFingerprint = fingerprint(dryRunSelection);
- try {
- const { data, error: responseError } = await generatedStageDeletion(
- {
- selection: dryRunSelection,
- operation_token: reviewed!.operation_token,
- dry_run: true,
- },
- client,
- );
- if (!data) throw new Error(messageFor(responseError, 'Unable to run the deletion preview.'));
- if (dryRunFingerprint !== fingerprint(selection)) {
- throw new Error('The selection changed while it was being reviewed. Review it again.');
+
+ async function loadAccounts(list: Batch[], signal: AbortSignal): Promise {
+ const found: Record = {};
+ let index = 0;
+ await Promise.all(Array.from({ length: Math.min(ACCOUNT_LOOKUPS, list.length) }, async () => {
+ while (index < list.length && !signal.aborted) {
+ const batch = list[index++];
+ try {
+ const { data } = await generatedGetDeletion({ id: batch.id }, { ...client, signal });
+ if (data?.account) found[batch.id] = data.account;
+ } catch { /* A row still opens when its account lookup fails. */ }
}
- preview = data;
- } catch (cause) {
- preview = undefined;
- error = cause instanceof Error ? cause.message : 'Unable to run the deletion preview.';
- } finally {
- pending = false;
- }
+ }));
+ if (!signal.aborted) accounts = found;
}
- async function stage(): Promise {
- if (!selection || !reviewedIsCurrent()) return;
- pending = true;
- error = '';
- const stagedSelection = selection;
- try {
- const {
- data,
- error: responseError,
- response,
- } = await generatedStageDeletion(
- {
- selection: stagedSelection,
- operation_token: reviewed!.operation_token,
- description:
- stagedSelection.mode === 'all_matching' ? 'reviewed matching selection' : 'reviewed explicit selection',
- dry_run: false,
- },
- client,
- );
- if (!data || response.status !== 201)
- throw new Error(messageFor(responseError, 'Unable to stage this deletion.'));
- reviewed = undefined;
- preview = data;
- confirmStage = undefined;
- await loadManifests();
- } catch (cause) {
- preview = undefined;
- error = cause instanceof Error ? cause.message : 'Unable to stage this deletion.';
- } finally {
- pending = false;
+
+ async function open(batch: Batch): Promise {
+ if (selectedID === batch.id) {
+ selectedID = detail = undefined;
+ return;
}
- }
- async function closeDetail(): Promise {
- const id = detail?.id;
+ selectedID = batch.id;
detail = undefined;
- await tick();
- const rows = manifestsElement?.querySelectorAll('tr[data-manifest-id]') ?? [];
- const row = Array.from(rows).find((candidate) => candidate.dataset['manifestId'] === id);
- row?.querySelector('.inspect-action')?.focus();
- }
- async function inspect(manifest: ManifestSummary): Promise {
- pending = true;
- error = '';
try {
- const { data, error: responseError } = await generatedGetDeletion({ id: manifest.id }, client);
- if (!data) throw new Error(messageFor(responseError, 'Unable to inspect this deletion manifest.'));
- detail = data;
+ const { data, error: responseError } = await generatedGetDeletion({ id: batch.id }, { ...client, signal: controller?.signal });
+ if (!data) throw new Error(messageFor(responseError, "Can't open this batch."));
+ if (selectedID === batch.id) detail = data;
} catch (cause) {
- error = cause instanceof Error ? cause.message : 'Unable to inspect this deletion manifest.';
- } finally {
- pending = false;
+ if (selectedID !== batch.id || controller?.signal.aborted) return;
+ error = cause instanceof Error ? cause.message : "Can't open this batch.";
+ selectedID = undefined;
}
}
+
+ function close(): void {
+ selectedID = detail = undefined;
+ }
+
async function cancelManifest(): Promise {
- if (!confirmCancel) return;
- const target = confirmCancel;
- pending = true;
- error = '';
+ if (!confirmCancel || cancelling) return;
+ const batch = confirmCancel;
+ cancelling = true;
try {
- const { data, error: responseError } = await generatedCancelDeletion({ id: target.id }, client);
- if (!data) throw new Error(messageFor(responseError, 'Unable to cancel this deletion manifest.'));
- manifests = manifests.map((manifest) =>
- manifest.id === data.id ? { ...manifest, status: data.status } : manifest,
- );
+ const { data, error: responseError } = await generatedCancelDeletion({ id: batch.id }, client);
+ if (!data) throw new Error(messageFor(responseError, "Can't cancel the batch."));
+ batches = batches.map((item) => item.id === data.id ? { ...item, status: data.status } : item);
if (detail?.id === data.id) detail = { ...detail, status: data.status };
confirmCancel = undefined;
+ onChanged();
} catch (cause) {
- error = cause instanceof Error ? cause.message : 'Unable to cancel this deletion manifest.';
- } finally {
- pending = false;
- }
- }
- function stageCounts(value: StageDeletionResponse): { matched: number; staged: number; skipped: number } {
- const staged = value.message_count;
- const skipped = value.skipped_count ?? Math.max((value.matched_count ?? staged) - staged, 0);
- const matched = value.matched_count ?? staged + skipped;
- return { matched, staged, skipped };
- }
- function resultSummary(value: StageDeletionResponse): string {
- const { matched, staged, skipped } = stageCounts(value);
- const prefix = value.dry_run ? 'Dry run' : 'Staged';
- const account = value.account ? ` in ${value.account}` : '';
- const batch = !value.dry_run && value.id ? ` · Batch ID: ${value.id}` : '';
- return `${prefix}: Matched: ${matched.toLocaleString()} · Staged: ${staged.toLocaleString()} · Skipped: ${skipped.toLocaleString()}${account}${batch}`;
- }
- function partialWarning(value: StageDeletionResponse): string {
- const { staged, skipped } = stageCounts(value);
- if (value.dry_run) {
- return `Partial staging: only the deletable Gmail and Microsoft Graph mail subset (${staged.toLocaleString()}) will be staged; ${skipped.toLocaleString()} unsupported ${skipped === 1 ? 'match will be' : 'matches will be'} skipped.`;
- }
- return `Partial staging: only the deletable Gmail and Microsoft Graph mail subset (${staged.toLocaleString()}) was staged; ${skipped.toLocaleString()} unsupported ${skipped === 1 ? 'match was' : 'matches were'} skipped.`;
- }
- function selectionExclusions(): string {
- const count = selection?.exclusions?.length ?? 0;
- return selection?.mode === 'all_matching' && count > 0
- ? ` After ${count.toLocaleString()} ${count === 1 ? 'exclusion' : 'exclusions'}.`
- : '';
- }
- function confirmationDescription(): string {
- const counts = preview?.dry_run
- ? resultSummary(preview)
- : `Matched: ${reviewed!.count.toLocaleString()} · Will stage: ${reviewed!.deletable_count.toLocaleString()} · Will skip: ${(reviewed!.count - reviewed!.deletable_count).toLocaleString()}`;
- return `${counts}.${selectionExclusions()} Only deletable Gmail and Microsoft Graph mail messages will be staged. This creates a staged manifest; it does not execute deletion.`;
+ error = cause instanceof Error ? cause.message : "Can't cancel the batch.";
+ } finally { cancelling = false; }
}
+
function messageFor(value: unknown, fallback: string): string {
return typeof value === 'object' && value !== null && 'message' in value && typeof value.message === 'string'
? value.message
: fallback;
}
+ function count(value: number): string {
+ return `${value.toLocaleString()} ${value === 1 ? 'message' : 'messages'}`;
+ }
+ const cancellable = (status: string): boolean => status === 'pending' || status === 'in_progress';
-
-
- {#snippet descriptionContent()}Deletions you've staged. Nothing is deleted until you run msgvault delete-staged.{/snippet}
+
+
+ {#snippet actions()}
+ {#if cancelledCount > 0}
+
+ {/if}
+ {/snippet}
- {#if error}{error}
{/if}
+ {#if error}{error} void load()} />
{/if}
-
- {#if !selection}
-
- {:else}
-
-
-
- {#if reviewed}
- {@const stageReason = unavailableReason('stage_deletion')}
-
- {reviewed.count.toLocaleString()}
- {reviewed.count === 1 ? 'item' : 'items'} · {formatBytes(reviewed.estimated_bytes)}
-
- {reviewed.deletable_count.toLocaleString()} can be staged · {(reviewed.count - reviewed.deletable_count).toLocaleString()} will be skipped.{selectionExclusions()}
-
- Review expires
- {#if reviewed.search_deletion_scope === 'active'}
- Semantic search covers active messages only.
- {/if}
- {#if stageReason}
- {preflightReasonLabel('stage_deletion', stageReason)}
- {/if}
-
-
- void dryRun()} />
- {
- if (reviewedIsCurrent()) confirmStage = selection?.mode;
- }}
- />
-
- {/if}
- {#if preview}
- {resultSummary(preview)}
- {#if stageCounts(preview).skipped > 0}{partialWarning(preview)}
{/if}
- {/if}
-
-
- {/if}
-
-
- {#if loading}Loading deletion manifests…
- {:else if manifests.length === 0}
-
+ {#if loading}
+ Loading…
+ {:else if rows.length === 0}
+
{:else}
-
-
- {#snippet header()}
-
-
-
-
-
-
- {/snippet}
- {#each manifests as manifest (manifest.id)}
- {@const chip = manifestStatusChip(manifest.status)}
-
- {manifest.id} |
- {manifest.description} |
- {manifest.message_count.toLocaleString()} {manifest.message_count === 1 ? 'item' : 'items'} |
- {chip.label} |
- |
-
- void inspect(manifest)}
- />
- {#if manifest.status === 'pending' || manifest.status === 'in_progress'}
- {
- confirmCancel = manifest;
- }}
- />
- {/if}
- |
-
- {/each}
-
- {#if detail}
- {@const detailChip = manifestStatusChip(detail.status)}
-
-
-
- {/if}
-
+
+ {#snippet header()}
+
+
+
+
+ {/snippet}
+ {#each rows as batch (batch.id)}
+ {@const chip = batchStatus(batch.status)}
+ void open(batch)}>
+ |
+ {
+ event.stopPropagation();
+ void open(batch);
+ }}>{formatDateTime(batch.created_at)}
+ |
+ {count(batch.message_count)} |
+ {accounts[batch.id] ?? ''} |
+ {chip.label} |
+
+ {/each}
+
{/if}
+
+
-{#if confirmStage && reviewed}
- {
- confirmStage = undefined;
- }}
+ surface="soft"
+ label="Cancel batch"
+ onclick={() => confirmCancel = batches.find((batch) => batch.id === selectedID)}
+ />
+{/snippet}
+
+{#if selectedID && detail}
+ {@const chip = batchStatus(detail.status)}
+
- {confirmationDescription()}
- {#snippet footer()}
- {
- confirmStage = undefined;
- }}
- />
- void stage()}
- />
- {/snippet}
-
+
+
+ {chip.label}
+ {detail.account || (detail.summary?.accounts ?? []).join(', ')}
+
+
+ {count(detail.message_count)}
+ {#if detail.summary}
+
+ {formatBytes(detail.summary.total_size_bytes)}{#if detail.summary.date_range?.[0]} · {detail.summary.date_range[0].slice(0, 10)} to {detail.summary.date_range[1]?.slice(0, 10)}{/if}
+
+ {#if detail.summary.top_senders?.length}
+
+ {#each detail.summary.top_senders as sender (sender.sender)}
+ - {sender.sender}{sender.count.toLocaleString()}
+ {/each}
+
+ {/if}
+ {/if}
+
+ {#if detail.execution}
+
+ {detail.execution.succeeded.toLocaleString()} deleted · {detail.execution.failed.toLocaleString()} failed
+
+ {/if}
+
+ Batch ID
+
+ {detail.id}
+
+
+
+
+
{/if}
{#if confirmCancel}
- {
- confirmCancel = undefined;
- }}
- >
- Cancel {confirmCancel.id}? Completed and failed manifests cannot be cancelled.
- {#snippet footer()}
- {
- confirmCancel = undefined;
- }}
- />
- void cancelManifest()}
- />
- {/snippet}
-
+
{/if}
diff --git a/web/src/lib/components/deletions/DeletionsWorkspace.test.ts b/web/src/lib/components/deletions/DeletionsWorkspace.test.ts
index 867e55367c..016057ba1a 100644
--- a/web/src/lib/components/deletions/DeletionsWorkspace.test.ts
+++ b/web/src/lib/components/deletions/DeletionsWorkspace.test.ts
@@ -1,513 +1,119 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { appShortcuts, initShortcuts } from '@kenn-io/kit-ui';
import { createAPIClient } from '../../api/client';
-import { formatDateTime } from '../../util/format';
-import type { ExploreSelection as GeneratedExploreSelection } from '../../api/generated/models';
import DeletionsWorkspace from './DeletionsWorkspace.svelte';
-type ExploreSelection = GeneratedExploreSelection;
-
-const explicit: ExploreSelection = {
- mode: 'explicit',
- predicate: { presentation: 'table' },
- row_keys: ['source:1:message:m1'],
- cache_revision: 'cache-1',
- search_provenance: {},
-};
-
-const matching: ExploreSelection = {
- mode: 'all_matching',
- predicate: { filters: [{ dimension: 'source', values: ['1'] }], presentation: 'table' },
- exclusions: ['source:1:message:m2'],
- cache_revision: 'cache-1',
- search_provenance: {},
-};
+function batch(id: string, status: string, count: number, created = '2026-07-19T10:00:00Z') {
+ return { id, status, created_at: created, created_by: 'api', description: 'reviewed explicit selection', message_count: count };
+}
-function preflight(overrides: Record = {}) {
+function detail(id: string, status: string, overrides: Record = {}) {
return {
- count: 1,
- deletable_count: 1,
- estimated_bytes: 120,
- cache_revision: 'cache-1',
- search_provenance: {},
- unavailable_actions: [],
- operation_token: 'operation-1',
- expires_at: '2026-07-19T10:05:00Z',
- ...overrides,
+ ...batch(id, status, 2), account: 'archive@example.com',
+ summary: {
+ accounts: ['archive@example.com'], date_range: ['2026-07-01T00:00:00Z', '2026-07-09T00:00:00Z'],
+ message_count: 2, total_size_bytes: 4096, top_senders: [{ sender: 'news@example.com', count: 2 }]
+ },
+ ...overrides
};
}
-function listResponse() {
- return {
- manifests: [
- {
- id: 'batch-1',
- status: 'pending',
- created_at: '2026-07-19T10:00:00Z',
- created_by: 'api',
- description: 'reviewed selection',
- message_count: 1,
- },
- ],
- };
+interface Seen { method: string; path: string }
+
+function api(batches: unknown[], seen: Seen[] = [], details: Record = {}) {
+ return createAPIClient(vi.fn(async (input) => {
+ const request = input instanceof Request ? input : new Request(input);
+ const path = new URL(request.url).pathname;
+ seen.push({ method: request.method, path });
+ if (path === '/api/v1/deletions') return Response.json({ manifests: batches });
+ const id = path.split('/').at(-1)!;
+ if (request.method === 'DELETE') return Response.json({ id, status: 'cancelled' });
+ return Response.json(details[id] ?? detail(id, 'pending'));
+ }));
}
-afterEach(() => document.body.replaceChildren());
+afterEach(() => {
+ vi.useRealTimers();
+ document.body.replaceChildren();
+});
describe('DeletionsWorkspace', () => {
- it('explains how to start when nothing is selected', async () => {
- render(DeletionsWorkspace, {
- client: createAPIClient(vi.fn(async () => Response.json({ manifests: [] }))),
- });
- expect(screen.getByText('Nothing selected for deletion')).toBeDefined();
- expect(screen.getByText('Select items in Messages, then choose Review for deletion…')).toBeDefined();
- expect(screen.queryByRole('button', { name: 'Review selection' })).toBeNull();
- expect(await screen.findByText('No staged deletions')).toBeDefined();
- expect(screen.getByText('msgvault delete-staged').tagName).toBe('CODE');
- });
-
- it('summarizes a review with size, relative expiry, and only the staging reason', async () => {
- const fetchFn = vi.fn(async (input) => {
- const request = input instanceof Request ? input : new Request(input);
- if (new URL(request.url).pathname.endsWith('/explore/preflight'))
- return Response.json(
- preflight({
- count: 2,
- deletable_count: 2,
- estimated_bytes: 3 * 1024,
- expires_at: '2026-07-19T12:00:00Z',
- unavailable_actions: [
- { action: 'stage_deletion', reason: 'selection_contains_items_that_cannot_be_deleted_from_source' },
- { action: 'export', reason: 'browser_export_requires_single_message' },
- { action: 'open_in_source', reason: 'trusted_source_link_unavailable' },
- ],
- }),
- );
- return Response.json({ manifests: [] });
- });
- render(DeletionsWorkspace, {
- client: createAPIClient(fetchFn),
- selection: explicit,
- now: () => new Date('2026-07-19T10:00:00Z'),
- });
- await fireEvent.click(await screen.findByRole('button', { name: 'Review selection' }));
-
- expect(await screen.findByText('2 items · 3 KB')).toBeDefined();
- expect(screen.getByText('in 2 hours').closest('span')?.textContent).toBe('Review expires in 2 hours');
- const reason = screen.getByText('None of the selected items can be deleted from their source.');
- expect(reason.getAttribute('title')).toBe('selection_contains_items_that_cannot_be_deleted_from_source');
- expect(screen.queryByText(/Export works for one message/)).toBeNull();
- expect(screen.queryByText(/provide links to open/)).toBeNull();
- expect((screen.getByRole('button', { name: 'Stage deletion…' }) as HTMLButtonElement).disabled).toBe(true);
- });
-
- it('lists manifests with status chips and closes the detail back to its Inspect button', async () => {
- render(DeletionsWorkspace, {
- client: createAPIClient(
- vi.fn(async (input) => {
- const request = input instanceof Request ? input : new Request(input);
- if (new URL(request.url).pathname.endsWith('/batch-1'))
- return Response.json({ ...listResponse().manifests[0], account: 'archive@example.com' });
- return Response.json(listResponse());
- }),
- ),
- });
- const table = await screen.findByRole('table', { name: 'Deletion manifests' });
- expect(within(table).getAllByRole('columnheader').map((cell) => cell.textContent?.trim())).toEqual([
- 'ID',
- 'Description',
- 'Items',
- 'Status',
- 'Created',
- 'Actions',
- ]);
- expect(within(table).getByText('Pending')).toBeDefined();
- expect(within(table).getByTitle(formatDateTime('2026-07-19T10:00:00Z', 'long'))).toBeDefined();
- expect(within(table).queryByTitle('2026-07-19T10:00:00Z')).toBeNull();
- expect(within(table).getByRole('button', { name: 'Cancel batch-1' }).className).not.toContain('kit-button--danger');
- await fireEvent.click(within(table).getByRole('button', { name: 'Inspect batch-1' }));
+ it('lists staged batches by date, count, account, and status, and ends with the delete command', async () => {
+ render(DeletionsWorkspace, { client: api([batch('b1', 'pending', 2)]) });
+ expect(await screen.findByText('2 messages')).toBeDefined();
expect(await screen.findByText('archive@example.com')).toBeDefined();
- await fireEvent.click(screen.getByRole('button', { name: 'Close manifest detail' }));
- expect(screen.queryByText('archive@example.com')).toBeNull();
- await waitFor(() =>
- expect(document.activeElement).toBe(within(table).getByRole('button', { name: 'Inspect batch-1' })),
- );
+ expect(screen.getByText('Staged')).toBeDefined();
+ expect(screen.queryByText('b1')).toBeNull();
+ expect(screen.getByText('msgvault delete-staged')).toBeDefined();
+ expect(screen.queryByText(/manifest/i)).toBeNull();
});
- it('requires the deletable-count contract before offering staging', async () => {
- const fetchFn = vi.fn(async (input) => {
- const request = input instanceof Request ? input : new Request(input);
- if (new URL(request.url).pathname.endsWith('/explore/preflight')) {
- return Response.json(preflight({ deletable_count: undefined }));
- }
- return Response.json({ manifests: [] });
- });
- render(DeletionsWorkspace, { client: createAPIClient(fetchFn), selection: explicit, reviewOnMount: true });
-
- expect((await screen.findByRole('alert')).textContent).toContain('Upgrade the daemon and review again.');
- expect(screen.queryByRole('button', { name: 'Stage deletion…' })).toBeNull();
- expect(screen.queryByRole('dialog')).toBeNull();
+ it('says nothing is staged in one line and still shows the command', async () => {
+ render(DeletionsWorkspace, { client: api([]) });
+ expect(await screen.findByText('Nothing staged')).toBeDefined();
+ expect(screen.getByText('msgvault delete-staged')).toBeDefined();
});
- it('preflights, dry-runs, and explicitly confirms an exact selection before staging', async () => {
- const requests: Request[] = [];
- let deletionPosts = 0;
- const fetchFn = vi.fn(async (input) => {
- const request = input instanceof Request ? input : new Request(input);
- requests.push(request);
- const path = new URL(request.url).pathname;
- if (path.endsWith('/explore/preflight')) return Response.json(preflight());
- if (request.method === 'POST') {
- deletionPosts += 1;
- return deletionPosts === 1
- ? Response.json({ dry_run: true, matched_count: 1, message_count: 1, skipped_count: 0, account: 'archive@example.com', sample_gmail_ids: ['m1'] })
- : Response.json(
- { dry_run: false, matched_count: 1, message_count: 1, skipped_count: 0, account: 'archive@example.com', id: 'batch-2', status: 'pending' },
- { status: 201 },
- );
- }
- return Response.json(listResponse());
- });
- render(DeletionsWorkspace, { client: createAPIClient(fetchFn), selection: explicit });
-
- await screen.findByText('batch-1');
- await fireEvent.click(screen.getByRole('button', { name: 'Review selection' }));
- expect(await screen.findByText('1 item · 120 B')).toBeDefined();
- await fireEvent.click(screen.getByRole('button', { name: 'Dry run' }));
- expect(await screen.findByText(/Dry run: Matched: 1 · Staged: 1 · Skipped: 0 in archive@example.com/)).toBeDefined();
- expect(screen.queryByRole('alert')).toBeNull();
-
- await fireEvent.click(screen.getByRole('button', { name: 'Stage deletion…' }));
- expect(screen.getByRole('dialog', { name: 'Confirm selected deletion' })).toBeDefined();
- expect(deletionPosts).toBe(1);
- await fireEvent.click(screen.getByRole('button', { name: 'Confirm stage deletion' }));
- await waitFor(() => expect(deletionPosts).toBe(2));
- expect(await screen.findByText(/Batch ID: batch-2/)).toBeDefined();
-
- const preflightBody = await requests
- .find((request) => new URL(request.url).pathname.endsWith('/explore/preflight'))!
- .clone()
- .json();
- expect(preflightBody).toEqual({ selection: explicit });
- const stageBody = await requests
- .filter((request) => request.method === 'POST')
- .at(-1)!
- .clone()
- .json();
- expect(stageBody).toMatchObject({ selection: explicit, operation_token: 'operation-1', dry_run: false });
+ it('keeps cancelled batches behind a filter', async () => {
+ render(DeletionsWorkspace, { client: api([batch('b1', 'pending', 2), batch('b2', 'cancelled', 5)]) });
+ await screen.findByText('2 messages');
+ expect(screen.queryByText('5 messages')).toBeNull();
+ await fireEvent.click(screen.getByRole('checkbox', { name: 'Show cancelled (1)' }));
+ expect(screen.getByText('5 messages')).toBeDefined();
+ expect(screen.getByText('Cancelled')).toBeDefined();
});
- it('shows mixed dry-run and staged counts with a partial-staging warning', async () => {
- const requests: Request[] = [];
- let deletionPosts = 0;
- const mixed = { dry_run: true, matched_count: 3, message_count: 2, skipped_count: 1, account: 'archive@example.com' };
- const fetchFn = vi.fn(async (input) => {
- const request = input instanceof Request ? input : new Request(input);
- requests.push(request);
- const path = new URL(request.url).pathname;
- if (path.endsWith('/explore/preflight')) return Response.json(preflight({ count: 3, deletable_count: 2 }));
- if (request.method === 'POST') {
- deletionPosts += 1;
- return deletionPosts === 1
- ? Response.json(mixed)
- : Response.json({ ...mixed, dry_run: false, id: 'batch-2', status: 'pending' }, { status: 201 });
- }
- return Response.json({ manifests: [] });
- });
- render(DeletionsWorkspace, { client: createAPIClient(fetchFn), selection: explicit });
-
- await screen.findByText('No staged deletions');
- await fireEvent.click(screen.getByRole('button', { name: 'Review selection' }));
- await screen.findByText('3 items · 120 B');
- await fireEvent.click(screen.getByRole('button', { name: 'Dry run' }));
- expect(await screen.findByText(/Dry run: Matched: 3 · Staged: 2 · Skipped: 1 in archive@example.com/)).toBeDefined();
- expect(screen.getByRole('alert').textContent).toMatch(/Partial staging.*deletable Gmail and Microsoft Graph mail subset.*unsupported match will be skipped/);
-
- await fireEvent.click(screen.getByRole('button', { name: 'Stage deletion…' }));
- const dialog = screen.getByRole('dialog', { name: 'Confirm selected deletion' });
- expect(dialog.textContent).toMatch(/Dry run: Matched: 3 · Staged: 2 · Skipped: 1/);
- expect(dialog.textContent).toMatch(/Only deletable Gmail and Microsoft Graph mail messages will be staged/);
- expect(deletionPosts).toBe(1);
- await fireEvent.click(screen.getByRole('button', { name: 'Confirm stage deletion' }));
- await waitFor(() => expect(deletionPosts).toBe(2));
- expect(await screen.findByText(/Staged: Matched: 3 · Staged: 2 · Skipped: 1 in archive@example.com/)).toBeDefined();
- expect(screen.getByRole('alert').textContent).toMatch(/was staged.*unsupported match was skipped/);
- expect(requests.filter((request) => request.method === 'POST').length).toBeGreaterThanOrEqual(2);
+ it('opens a panel with the summary and a copyable ID, and the row closes it again', async () => {
+ render(DeletionsWorkspace, { client: api([batch('batch-123', 'pending', 2)]) });
+ const row = await screen.findByRole('button', { name: /^Open batch from/ });
+ await fireEvent.click(row);
+ expect(await screen.findByText('news@example.com')).toBeDefined();
+ expect(screen.getByText('batch-123')).toBeDefined();
+ expect(screen.getByRole('button', { name: 'Copy batch ID' })).toBeDefined();
+ expect(screen.getByRole('button', { name: 'Close' })).toBeDefined();
+ await fireEvent.click(row);
+ await waitFor(() => expect(screen.queryByText('news@example.com')).toBeNull());
});
- it('derives optional counts when the response omits one field at a time', async () => {
- let deletionPosts = 0;
- const fetchFn = vi.fn(async (input) => {
- const request = input instanceof Request ? input : new Request(input);
- const path = new URL(request.url).pathname;
- if (path.endsWith('/explore/preflight')) return Response.json(preflight({ count: 3, deletable_count: 2 }));
- if (request.method === 'POST') {
- deletionPosts += 1;
- return deletionPosts === 1
- ? Response.json({ dry_run: true, message_count: 2, skipped_count: 1 })
- : Response.json({ dry_run: true, matched_count: 3, message_count: 2 });
- }
- return Response.json({ manifests: [] });
- });
- render(DeletionsWorkspace, { client: createAPIClient(fetchFn), selection: explicit });
-
- await screen.findByText('No staged deletions');
- await fireEvent.click(screen.getByRole('button', { name: 'Review selection' }));
- await screen.findByText('3 items · 120 B');
- await fireEvent.click(screen.getByRole('button', { name: 'Dry run' }));
- expect(await screen.findByText(/Dry run: Matched: 3 · Staged: 2 · Skipped: 1/)).toBeDefined();
- await fireEvent.click(screen.getByRole('button', { name: 'Dry run' }));
- expect(await screen.findByText(/Dry run: Matched: 3 · Staged: 2 · Skipped: 1/)).toBeDefined();
- expect(screen.getByRole('alert')).toBeDefined();
+ it('waits for confirmation and immediately sends cancellation', async () => {
+ const seen: Seen[] = [], onChanged = vi.fn();
+ render(DeletionsWorkspace, { client: api([batch('b1', 'pending', 2)], seen), onChanged });
+ await fireEvent.click(await screen.findByRole('button', { name: /^Open batch from/ }));
+ await fireEvent.click(await screen.findByRole('button', { name: 'Cancel batch' }));
+ expect(seen.filter(entry => entry.method === 'DELETE')).toEqual([]);
+ const confirm = await screen.findByRole('dialog', { name: 'Cancel deletion batch?' });
+ await fireEvent.click(within(confirm).getByRole('button', { name: 'Cancel batch' }));
+ await waitFor(() => expect(seen.filter(entry => entry.method === 'DELETE')).toEqual([{ method: 'DELETE', path: '/api/v1/deletions/b1' }]));
+ await waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
+ await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Cancel deletion batch?' })).toBeNull());
+ expect(screen.queryByRole('button', { name: /^Open batch from/ })).toBeNull();
});
- it('discards a late dry-run response after the selection changes', async () => {
- let resolveDryRun!: (response: Response) => void;
- const pendingDryRun = new Promise((resolve) => {
- resolveDryRun = resolve;
- });
- let posts = 0;
- const fetchFn = vi.fn(async (input) => {
- const request = input instanceof Request ? input : new Request(input);
- const path = new URL(request.url).pathname;
- if (path.endsWith('/explore/preflight')) return Response.json(preflight());
- if (request.method === 'POST') {
- posts += 1;
- return pendingDryRun;
- }
- return Response.json({ manifests: [] });
- });
- const rendered = render(DeletionsWorkspace, { client: createAPIClient(fetchFn), selection: explicit });
-
- await screen.findByText('No staged deletions');
- await fireEvent.click(screen.getByRole('button', { name: 'Review selection' }));
- await screen.findByText('1 item · 120 B');
- await fireEvent.click(screen.getByRole('button', { name: 'Dry run' }));
- await waitFor(() => expect(posts).toBe(1));
- await rendered.rerender({ client: createAPIClient(fetchFn), selection: matching });
- resolveDryRun(Response.json({ dry_run: true, message_count: 1 }));
-
- expect(await screen.findByText('The selection changed while it was being reviewed. Review it again.')).toBeDefined();
- expect(screen.queryByText(/Dry run: Matched/)).toBeNull();
- rendered.unmount();
- });
-
- it('keeps a committed stage result and refreshes manifests after the selection changes', async () => {
- let resolveStage!: (response: Response) => void;
- const pendingStage = new Promise((resolve) => {
- resolveStage = resolve;
- });
- let listCalls = 0;
- let stagePosts = 0;
- const fetchFn = vi.fn(async (input) => {
- const request = input instanceof Request ? input : new Request(input);
- const path = new URL(request.url).pathname;
- if (path.endsWith('/explore/preflight')) return Response.json(preflight());
- if (request.method === 'POST') {
- stagePosts += 1;
- return pendingStage;
- }
- listCalls += 1;
- return listCalls === 1
- ? Response.json({ manifests: [] })
- : Response.json({
- manifests: [
- {
- id: 'batch-committed',
- status: 'pending',
- created_at: '2026-07-19T10:00:00Z',
- created_by: 'api',
- description: 'reviewed selection',
- message_count: 1,
- },
- ],
- });
- });
- const rendered = render(DeletionsWorkspace, { client: createAPIClient(fetchFn), selection: explicit });
-
- await screen.findByText('No staged deletions');
- await fireEvent.click(screen.getByRole('button', { name: 'Review selection' }));
- await screen.findByText('1 item · 120 B');
- await fireEvent.click(screen.getByRole('button', { name: 'Stage deletion…' }));
- await fireEvent.click(screen.getByRole('button', { name: 'Confirm stage deletion' }));
- await waitFor(() => expect(stagePosts).toBe(1));
- await rendered.rerender({ client: createAPIClient(fetchFn), selection: matching });
-
- resolveStage(
- Response.json(
- {
- dry_run: false,
- matched_count: 1,
- message_count: 1,
- skipped_count: 0,
- account: 'archive@example.com',
- id: 'batch-committed',
- status: 'pending',
- },
- { status: 201 },
- ),
- );
-
- expect(await screen.findByText(/Staged: Matched: 1 · Staged: 1 · Skipped: 0 in archive@example.com · Batch ID: batch-committed/)).toBeDefined();
- expect(await screen.findByText('batch-committed')).toBeDefined();
- expect(listCalls).toBe(2);
- rendered.unmount();
+ it('loads accounts beyond the first twenty batches with four requests at a time', async () => {
+ const batches = Array.from({ length: 25 }, (_, i) => batch(`batch-${i}`, 'pending', i + 1));
+ let active = 0, peak = 0;
+ const client = createAPIClient(async input => {
+ const path = new URL(input instanceof Request ? input.url : String(input)).pathname;
+ if (path === '/api/v1/deletions') return Response.json({ manifests: batches });
+ active++;
+ peak = Math.max(peak, active);
+ await new Promise(resolve => queueMicrotask(resolve));
+ active--;
+ const id = path.split('/').at(-1)!;
+ return Response.json(detail(id, 'pending', { account: `${id}@example.com` }));
+ });
+ render(DeletionsWorkspace, { client });
+ expect(await screen.findByText('batch-24@example.com')).toBeDefined();
+ expect(peak).toBeLessThanOrEqual(4);
});
- it('uses d/D shortcuts to preflight the matching selection and never acts before confirmation', async () => {
- const detach = initShortcuts();
- let staged = 0;
- const fetchFn = vi.fn(async (input) => {
- const request = input instanceof Request ? input : new Request(input);
- const path = new URL(request.url).pathname;
- if (path.endsWith('/explore/preflight')) return Response.json(preflight({ count: 8, deletable_count: 6 }));
- if (request.method === 'POST') {
- staged += 1;
- return Response.json({ dry_run: false, message_count: 8, id: 'batch-2', status: 'pending' }, { status: 201 });
- }
- return Response.json({ manifests: [] });
- });
- const rendered = render(DeletionsWorkspace, { client: createAPIClient(fetchFn), selection: matching });
- try {
- await screen.findByText('No staged deletions');
- await fireEvent.keyDown(window, { key: 'D', shiftKey: true });
- expect(await screen.findByRole('dialog', { name: 'Confirm matching deletion' })).toBeDefined();
- expect(screen.getByText(/Matched: 8 · Will stage: 6 · Will skip: 2.*After 1 exclusion/)).toBeDefined();
- expect(screen.getByText(/6 can be staged · 2 will be skipped/)).toBeDefined();
- expect(staged).toBe(0);
- await fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
- expect(staged).toBe(0);
- } finally {
- rendered.unmount();
- detach();
- }
- });
-
- it('owns deletion shortcuts instead of allowing the shell handler to consume them', async () => {
- const detach = initShortcuts();
- const shellHandler = vi.fn();
- const unregisterShell = appShortcuts.register('d', shellHandler);
- const fetchFn = vi.fn(async (input) => {
- const request = input instanceof Request ? input : new Request(input);
- if (new URL(request.url).pathname.endsWith('/explore/preflight')) return Response.json(preflight());
- return Response.json({ manifests: [] });
- });
- const rendered = render(DeletionsWorkspace, { client: createAPIClient(fetchFn), selection: explicit });
- try {
- await screen.findByText('No staged deletions');
- await fireEvent.keyDown(window, { key: 'd' });
- expect(await screen.findByRole('dialog', { name: 'Confirm selected deletion' })).toBeDefined();
- expect(shellHandler).not.toHaveBeenCalled();
- } finally {
- rendered.unmount();
- unregisterShell();
- detach();
- }
- });
-
- it('lists, inspects, and confirms cancellation while preserving lifecycle detail', async () => {
- const requests: Request[] = [];
- const fetchFn = vi.fn(async (input) => {
- const request = input instanceof Request ? input : new Request(input);
- requests.push(request);
- if (request.method === 'DELETE') return Response.json({ id: 'batch-1', status: 'cancelled' });
- if (new URL(request.url).pathname.endsWith('/batch-1'))
- return Response.json({
- id: 'batch-1',
- status: 'pending',
- created_at: '2026-07-19T10:00:00Z',
- created_by: 'api',
- description: 'reviewed selection',
- account: 'archive@example.com',
- message_count: 1,
- execution: null,
- summary: null,
- });
- return Response.json(listResponse());
- });
- render(DeletionsWorkspace, { client: createAPIClient(fetchFn) });
-
- await fireEvent.click(await screen.findByRole('button', { name: 'Inspect batch-1' }));
- expect(await screen.findByText('archive@example.com')).toBeDefined();
- await fireEvent.click(screen.getByRole('button', { name: 'Cancel batch-1' }));
- expect(screen.getByRole('dialog', { name: 'Cancel deletion manifest?' })).toBeDefined();
- expect(requests.some((request) => request.method === 'DELETE')).toBe(false);
- await fireEvent.click(screen.getByRole('button', { name: 'Confirm cancel manifest' }));
- await waitFor(() => expect(requests.some((request) => request.method === 'DELETE')).toBe(true));
- expect((await screen.findAllByText('Cancelled')).length).toBeGreaterThan(0);
- });
-
- it('discloses the active-only deletion scope reported by the preflight review', async () => {
- let scoped = false;
- const fetchFn = vi.fn(async (input) => {
- const request = input instanceof Request ? input : new Request(input);
- if (new URL(request.url).pathname.endsWith('/explore/preflight')) {
- return Response.json(preflight(scoped ? { search_deletion_scope: 'active' } : {}));
- }
- return Response.json({ manifests: [] });
- });
- render(DeletionsWorkspace, { client: createAPIClient(fetchFn), selection: explicit });
-
- await fireEvent.click(await screen.findByRole('button', { name: 'Review selection' }));
- await screen.findByText('1 item · 120 B');
- expect(screen.queryByText(/active messages only/)).toBeNull();
-
- scoped = true;
- await fireEvent.click(screen.getByRole('button', { name: 'Review selection' }));
- expect(await screen.findByText('Semantic search covers active messages only.')).toBeDefined();
- });
-
- it('shows server-supplied action reasons and disables staging', async () => {
- const fetchFn = vi.fn(async (input) => {
- const request = input instanceof Request ? input : new Request(input);
- if (new URL(request.url).pathname.endsWith('/explore/preflight'))
- return Response.json(
- preflight({
- unavailable_actions: [
- { action: 'stage_deletion', reason: 'selection_contains_items_that_cannot_be_deleted_from_source' },
- ],
- }),
- );
- return Response.json({ manifests: [] });
- });
- render(DeletionsWorkspace, { client: createAPIClient(fetchFn), selection: explicit });
-
- await fireEvent.click(await screen.findByRole('button', { name: 'Review selection' }));
- expect(await screen.findByText('None of the selected items can be deleted from their source.')).toBeDefined();
- expect((screen.getByRole('button', { name: 'Stage deletion…' }) as HTMLButtonElement).disabled).toBe(true);
- });
-
- it('clears stale result counts when dry-run and create requests fail', async () => {
- let deletionPosts = 0;
- const fetchFn = vi.fn(async (input) => {
- const request = input instanceof Request ? input : new Request(input);
- if (new URL(request.url).pathname.endsWith('/explore/preflight')) return Response.json(preflight());
- if (request.method === 'POST') {
- deletionPosts += 1;
- if (deletionPosts === 1 || deletionPosts === 3)
- return Response.json({ dry_run: true, message_count: 1 });
- return Response.json({ message: deletionPosts === 2 ? 'dry run failed' : 'create failed' }, { status: 500 });
- }
- return Response.json({ manifests: [] });
+ it('offers no cancel once a batch is deleted', async () => {
+ render(DeletionsWorkspace, {
+ client: api([batch('b1', 'completed', 2)], [], { b1: detail('b1', 'completed') })
});
- render(DeletionsWorkspace, { client: createAPIClient(fetchFn), selection: explicit });
-
- await screen.findByText('No staged deletions');
- await fireEvent.click(screen.getByRole('button', { name: 'Review selection' }));
- await screen.findByText('1 item · 120 B');
- await fireEvent.click(screen.getByRole('button', { name: 'Dry run' }));
- expect(await screen.findByText(/Dry run: Matched: 1/)).toBeDefined();
- await fireEvent.click(screen.getByRole('button', { name: 'Dry run' }));
- expect(await screen.findByText('dry run failed')).toBeDefined();
- expect(screen.queryByText(/Dry run: Matched: 1/)).toBeNull();
-
- await fireEvent.click(screen.getByRole('button', { name: 'Dry run' }));
- expect(await screen.findByText(/Dry run: Matched: 1/)).toBeDefined();
- await fireEvent.click(screen.getByRole('button', { name: 'Stage deletion…' }));
- await fireEvent.click(screen.getByRole('button', { name: 'Confirm stage deletion' }));
- expect(await screen.findByText('create failed')).toBeDefined();
- expect(screen.queryByText(/Dry run: Matched: 1/)).toBeNull();
+ await fireEvent.click(await screen.findByRole('button', { name: /^Open batch from/ }));
+ await screen.findByText('news@example.com');
+ expect(screen.queryByRole('button', { name: 'Cancel batch' })).toBeNull();
});
});
diff --git a/web/src/lib/components/explore/ContextBar.svelte b/web/src/lib/components/explore/ContextBar.svelte
index 3bd8bcea15..116e87a767 100644
--- a/web/src/lib/components/explore/ContextBar.svelte
+++ b/web/src/lib/components/explore/ContextBar.svelte
@@ -1,4 +1,5 @@
+
+
+
+
+
diff --git a/web/src/lib/components/files/FileViewer.svelte b/web/src/lib/components/files/FileViewer.svelte
index 017142c755..c1fb94e2c2 100644
--- a/web/src/lib/components/files/FileViewer.svelte
+++ b/web/src/lib/components/files/FileViewer.svelte
@@ -267,16 +267,16 @@
{#snippet footer()}