From 401bd3a4d8f2477b0187ddb8760a57f410fcfb13 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:56:08 -0700 Subject: [PATCH 1/4] fix(local-mail): drive empty-list copy off mirror row count, not filter heuristic selectedLabel defaults to 'INBOX', so the old isFiltered derived was almost always true and a freshly-synced (empty) mirror showed "No messages match / try a different label" instead of "No messages mirrored / run sync --full". Replace the isFiltered prop with mirrorEmpty (status rows.messages === 0), the real signal for an empty mirror; the SearchX/Inbox icons and both copy strings follow it. --- .../ui/src/lib/components/MessageList.svelte | 18 +++++++++--------- apps/local-mail/ui/src/routes/+page.svelte | 7 +++++-- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/apps/local-mail/ui/src/lib/components/MessageList.svelte b/apps/local-mail/ui/src/lib/components/MessageList.svelte index a27593244e..28ab64457b 100644 --- a/apps/local-mail/ui/src/lib/components/MessageList.svelte +++ b/apps/local-mail/ui/src/lib/components/MessageList.svelte @@ -22,7 +22,7 @@ selectedId, loading, error, - isFiltered, + mirrorEmpty, onSelect, }: { messages: MessageSummary[]; @@ -30,7 +30,7 @@ selectedId: string | null; loading: boolean; error: string | null; - isFiltered: boolean; + mirrorEmpty: boolean; onSelect: (id: string) => void; } = $props(); @@ -63,19 +63,19 @@ {:else if messages.length === 0} - {#if isFiltered} - - {:else} + {#if mirrorEmpty} + {:else} + {/if} - {isFiltered ? 'No messages match' : 'No messages mirrored'} + {mirrorEmpty ? 'No messages mirrored' : 'No messages match'} - {isFiltered - ? 'Try a different label or search term.' - : 'Run local-mail sync --full to populate the mirror.'} + {mirrorEmpty + ? 'Run local-mail sync --full to populate the mirror.' + : 'Try a different label or search term.'} {:else} diff --git a/apps/local-mail/ui/src/routes/+page.svelte b/apps/local-mail/ui/src/routes/+page.svelte index 8ecc6caece..df4e74aa91 100644 --- a/apps/local-mail/ui/src/routes/+page.svelte +++ b/apps/local-mail/ui/src/routes/+page.svelte @@ -49,7 +49,10 @@ const labelList = $derived(labels.data?.labels ?? []); const messageList = $derived(messages.data?.messages ?? []); - const isFiltered = $derived(search.trim().length > 0 || selectedLabel !== null); + // True when the mirror holds no messages at all (nothing synced yet), as + // opposed to this label/search view simply matching none. Drives which empty + // state the list shows: "run sync" vs "no match". + const mirrorEmpty = $derived((status.data?.rows.messages ?? 0) === 0); const syncError = $derived( sync.error?.message ?? sync.data?.failure?.message ?? null, ); @@ -90,7 +93,7 @@ {selectedId} loading={messages.isPending} error={messages.error?.message ?? null} - {isFiltered} + {mirrorEmpty} onSelect={(id) => (selectedId = id)} /> From 191cb49d8eb00ed954585190b1cbe27cdb93f884 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:56:08 -0700 Subject: [PATCH 2/4] refactor(local-mail): drop rt/sess re-bind in up.ts via arrow handleApi The re-bind existed to re-narrow runtime/session inside handleApi, but the real cause was hoisting: a function declaration is checked against the widened type because it could be called before the guard runs. Making handleApi a const arrow (defined after the guard) inherits the const narrowing directly, so the two aliases and their inaccurate "all closures lose narrowing" comment are gone. Behavior unchanged. --- apps/local-mail/src/up.ts | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/apps/local-mail/src/up.ts b/apps/local-mail/src/up.ts index 3be2c38474..4b16ddd775 100644 --- a/apps/local-mail/src/up.ts +++ b/apps/local-mail/src/up.ts @@ -158,13 +158,11 @@ export async function runUp(): Promise { return 1; } - // Re-bind the non-null values: TS drops the post-guard narrowing of the - // destructured `runtime`/`session` bindings inside the fetch/loop/SIGINT - // closures below, so capture them here where the narrowing holds. - const rt = runtime; - const sess = session; - const { db } = sess.deps; - const readOnly = rt.config.readOnly; + // `runtime`/`session` are `const`, so their post-guard non-null narrowing + // flows into the closures below (`handleApi` is an arrow, not a hoisted + // declaration, so it inherits the narrowing rather than the widened type). + const { db } = session.deps; + const readOnly = runtime.config.readOnly; // The valid-bearer set. Dev pre-seeds the fixed proxy token; prod fills it // only through the bootstrap exchange. @@ -174,7 +172,7 @@ export async function runUp(): Promise { const devToken = process.env.LOCAL_MAIL_TOKEN; if (!devToken) { lock.release(); - sess.close(); + session.close(); console.error( 'LOCAL_MAIL_DEV=1 requires LOCAL_MAIL_TOKEN so the Vite proxy can authenticate.', ); @@ -196,7 +194,7 @@ export async function runUp(): Promise { return header.slice('Bearer '.length); } - async function handleApi(req: Request, url: URL): Promise { + const handleApi = async (req: Request, url: URL): Promise => { const { pathname } = url; // The one unauthenticated mutation: exchange the bootstrap for a bearer. @@ -226,7 +224,7 @@ export async function runUp(): Promise { } if (pathname === '/api/status' && req.method === 'GET') { - const status = await readMailStatus(rt); + const status = await readMailStatus(runtime); return json({ accountEmail: status.accountEmail, connected: status.connected, @@ -264,7 +262,7 @@ export async function runUp(): Promise { if (pathname === '/api/sync' && req.method === 'POST') { const outcome = await gate(() => - syncMailbox(sess.deps, { forceFull: false }), + syncMailbox(session.deps, { forceFull: false }), ); return json(outcome); } @@ -282,7 +280,7 @@ export async function runUp(): Promise { ); } const { data, error } = await resolveAndModifyMessageLabels({ - deps: sess.deps, + deps: session.deps, ids: body.ids, addLabels: body.addLabels ?? [], removeLabels: body.removeLabels ?? [], @@ -293,7 +291,7 @@ export async function runUp(): Promise { } return json({ error: 'Not found.' }, 404); - } + }; const server = Bun.serve({ hostname: '127.0.0.1', @@ -317,7 +315,7 @@ export async function runUp(): Promise { // The background sync loop, serialized through the same gate as POST /api/sync. (async () => { while (!controller.signal.aborted) { - await gate(() => syncMailbox(sess.deps, { forceFull: false })).catch( + await gate(() => syncMailbox(session.deps, { forceFull: false })).catch( (cause) => console.error(`[sync] loop pass failed: ${cause}`), ); if (controller.signal.aborted) break; @@ -348,7 +346,7 @@ export async function runUp(): Promise { process.on('SIGINT', () => { controller.abort(); server.stop(); - sess.close(); + session.close(); lock.release(); resolve(); }); From 2bdc70b7cf05982d77b618aef80cdec7b72faa3f Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:59:07 -0700 Subject: [PATCH 3/4] refactor(local-mail): drop dead reset $effect and lastOutcome mirror in MessageDetail The parent wraps in {#key selectedId}, so the whole component (mutation included) remounts on every selection: id is constant within an instance, making the "reset on id change" $effect dead. And lastOutcome was just a copy of modify.data. Delete both; the strip now reads modify.data directly. No behavior change (the three-branch strip and the toast are untouched); whether the strip should exist at all is left as an open UX call. --- .../src/lib/components/MessageDetail.svelte | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/apps/local-mail/ui/src/lib/components/MessageDetail.svelte b/apps/local-mail/ui/src/lib/components/MessageDetail.svelte index 283fe603d1..7a7b9ee005 100644 --- a/apps/local-mail/ui/src/lib/components/MessageDetail.svelte +++ b/apps/local-mail/ui/src/lib/components/MessageDetail.svelte @@ -18,7 +18,7 @@ import { toast } from 'svelte-sonner'; import { api } from '$lib/api'; import { fullDate, labelDisplayName } from '$lib/format'; - import type { MailLabel, ModifyMessageLabelsOutcome } from '$lib/types'; + import type { MailLabel } from '$lib/types'; let { id, @@ -38,13 +38,11 @@ })); let lastVerb = $state(null); - let lastOutcome = $state(null); const modify = createMutation(() => ({ mutationFn: (input: { addLabels?: string[]; removeLabels?: string[] }) => api.modify({ ids: id ? [id] : [], ...input }), onSuccess: (outcome) => { - lastOutcome = outcome; const failed = outcome.results.filter((r) => r.error).length; const ok = outcome.results.length - failed; if (outcome.aborted) { @@ -92,13 +90,6 @@ if (present) run(`Removed ${name}`, { removeLabels: [labelId] }); else run(`Added ${name}`, { addLabels: [labelId] }); } - - // Reset the inline outcome strip when a different message opens. - $effect(() => { - id; - lastOutcome = null; - lastVerb = null; - }); {#snippet actionButton( @@ -235,19 +226,19 @@ - {#if lastOutcome} - {@const result = lastOutcome.results[0]} + {#if modify.data} + {@const result = modify.data.results[0]}
- {#if lastOutcome.aborted} + {#if modify.data.aborted} - Aborted: {lastOutcome.aborted.message} + Aborted: {modify.data.aborted.message} {:else if result?.error} {lastVerb} failed: {result.error.message} From 0c830d216b999da0635146d3999ab5a1418e843f Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:54:08 -0700 Subject: [PATCH 4/4] refactor(local-mail): cut the MessageDetail outcome strip, keep the toast The strip was a persistent second surface for the same per-action feedback the toast already carries, and because a fold usually succeeds its common state was a redundant "Mirror updated" echoing the visibly-changed label chips. This is the append-log surface ADR-0029 (Whispering's dictation-feedback projection) declined to build: let the effect be the receipt (the row leaves the list, the chips update) and keep a transient toast only for what the effect does not show (the folded:false sync-lag note and errors). Also drops the now-unused CheckIcon and a pre-existing dead Separator import. --- .../src/lib/components/MessageDetail.svelte | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/apps/local-mail/ui/src/lib/components/MessageDetail.svelte b/apps/local-mail/ui/src/lib/components/MessageDetail.svelte index 7a7b9ee005..8496911bca 100644 --- a/apps/local-mail/ui/src/lib/components/MessageDetail.svelte +++ b/apps/local-mail/ui/src/lib/components/MessageDetail.svelte @@ -4,10 +4,8 @@ import * as DropdownMenu from '@epicenter/ui/dropdown-menu'; import * as Empty from '@epicenter/ui/empty'; import { Loading } from '@epicenter/ui/loading'; - import { Separator } from '@epicenter/ui/separator'; import ArchiveIcon from '@lucide/svelte/icons/archive'; import ArchiveRestoreIcon from '@lucide/svelte/icons/archive-restore'; - import CheckIcon from '@lucide/svelte/icons/check'; import MailOpenIcon from '@lucide/svelte/icons/mail-open'; import MailIcon from '@lucide/svelte/icons/mail'; import MousePointerClickIcon from '@lucide/svelte/icons/mouse-pointer-click'; @@ -225,33 +223,6 @@
- - {#if modify.data} - {@const result = modify.data.results[0]} -
- {#if modify.data.aborted} - - Aborted: {modify.data.aborted.message} - {:else if result?.error} - - {lastVerb} failed: {result.error.message} - {:else if result && !result.folded} - - {lastVerb}. Gmail accepted it; the mirror catches up on the next sync (folded: false). - {:else} - - {lastVerb}. Mirror updated from Gmail's response. - {/if} -
- {/if} -
{#if detail.bodyText}