From 3121431a7e9c9a3a08c281946e6a0fdb7e9695f9 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 11:32:49 -0400 Subject: [PATCH 1/6] feat(ext/vault): vault-status indicator renderer (Plan C Phase 6) Renders sidebar-footer indicator with ahead/behind/pending state. Pure DOM; reuses shared/glyphs (four new status glyphs) and shared/relative-time. Status fetch happens in the wiring layer (Task 6.3). Co-Authored-By: Claude Opus 4.8 --- extension/src/shared/glyphs.ts | 4 +++ .../vault/__tests__/status-indicator.test.ts | 34 ++++++++++++++++++ extension/src/vault/vault-status.ts | 36 +++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 extension/src/vault/__tests__/status-indicator.test.ts create mode 100644 extension/src/vault/vault-status.ts diff --git a/extension/src/shared/glyphs.ts b/extension/src/shared/glyphs.ts index 1a4c6b9..4766e4c 100644 --- a/extension/src/shared/glyphs.ts +++ b/extension/src/shared/glyphs.ts @@ -19,6 +19,10 @@ export const GLYPH_LOCK = '⏻'; // sidebar lock nav export const GLYPH_NEXT = '▸'; // forward / next button (matches ▾/▸ disclosure family) export const GLYPH_COPY = '⎘'; // copy to clipboard export const GLYPH_SYNC = '⇅'; // sync / upload +export const GLYPH_SYNCED = '✓'; // vault status: in sync (no pending/ahead/behind) +export const GLYPH_AHEAD = '↑'; // vault status: local commits ahead of remote +export const GLYPH_BEHIND = '↓'; // vault status: remote commits not yet pulled +export const GLYPH_PENDING = '◌'; // vault status: items changed but not yet synced export const GLYPH_PREVIEW = '⊕'; // preview / expand export const GLYPH_VAULT_TAB = '⧉'; // U+29C9 pop-out to fullscreen vault tab diff --git a/extension/src/vault/__tests__/status-indicator.test.ts b/extension/src/vault/__tests__/status-indicator.test.ts new file mode 100644 index 0000000..d31c581 --- /dev/null +++ b/extension/src/vault/__tests__/status-indicator.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { renderStatusIndicator } from '../vault-status'; + +describe('vault status indicator', () => { + it('renders "in sync" when ahead/behind/pending all zero', () => { + const el = document.createElement('div'); + renderStatusIndicator(el, { ahead: 0, behind: 0, lastSyncAt: 1700000000, pendingItems: 0 }); + expect(el.textContent).toMatch(/in sync/i); + }); + + it('renders "N ahead" when ahead is non-zero', () => { + const el = document.createElement('div'); + renderStatusIndicator(el, { ahead: 3, behind: 0, lastSyncAt: 1700000000, pendingItems: 0 }); + expect(el.textContent).toMatch(/3 ahead/i); + }); + + it('renders "N behind" when behind is non-zero', () => { + const el = document.createElement('div'); + renderStatusIndicator(el, { ahead: 0, behind: 2, lastSyncAt: 1700000000, pendingItems: 0 }); + expect(el.textContent).toMatch(/2 behind/i); + }); + + it('renders "N pending" when pendingItems is non-zero', () => { + const el = document.createElement('div'); + renderStatusIndicator(el, { ahead: 0, behind: 0, lastSyncAt: 1700000000, pendingItems: 5 }); + expect(el.textContent).toMatch(/5 pending/i); + }); + + it('renders "never synced" when lastSyncAt is null', () => { + const el = document.createElement('div'); + renderStatusIndicator(el, { ahead: 0, behind: 0, lastSyncAt: null, pendingItems: 0 }); + expect(el.textContent).toMatch(/never synced/i); + }); +}); diff --git a/extension/src/vault/vault-status.ts b/extension/src/vault/vault-status.ts new file mode 100644 index 0000000..b55132f --- /dev/null +++ b/extension/src/vault/vault-status.ts @@ -0,0 +1,36 @@ +import { + GLYPH_SYNCED, + GLYPH_AHEAD, + GLYPH_BEHIND, + GLYPH_PENDING, +} from '../shared/glyphs'; +import { relativeTime } from '../shared/relative-time'; + +// Local shape for the sidebar-footer indicator. Mirrors the get_vault_status +// response data (ahead/behind/lastSyncAt/pendingItems). lastSyncAt is a unix +// timestamp in SECONDS, or null when the vault has never synced. +export interface VaultStatus { + ahead: number; + behind: number; + lastSyncAt: number | null; + pendingItems: number; +} + +export function renderStatusIndicator(el: HTMLElement, status: VaultStatus): void { + const ts = status.lastSyncAt !== null + ? `last sync ${relativeTime(status.lastSyncAt)}` + : 'never synced'; + + const parts: string[] = []; + if (status.pendingItems > 0) parts.push(`${GLYPH_PENDING} ${status.pendingItems} pending`); + if (status.ahead > 0) parts.push(`${GLYPH_AHEAD} ${status.ahead} ahead`); + if (status.behind > 0) parts.push(`${GLYPH_BEHIND} ${status.behind} behind`); + if (parts.length === 0) parts.push(`${GLYPH_SYNCED} in sync`); + + el.innerHTML = ` +
+
${parts.join(' · ')}
+
${ts}
+
+ `; +} From 61275574d4320aeb56b76b881b6a5beaa8cd9204 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 15:24:02 -0400 Subject: [PATCH 2/6] feat(ext/sw): get_vault_status handler + cached sync state (Plan C Phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returns cached ahead/behind/lastSyncAt from the GitHost plus a live count of active (non-trashed) manifest items. No network call — sync is user-initiated; the sync handler records lastSyncAt (unix seconds). ahead/behind stay 0 in the extension (writes go straight to the host, no local commit graph) and exist for parity with relicario status. Co-Authored-By: Claude Opus 4.8 --- .../__tests__/vault-status.test.ts | 53 +++++++++++++++++++ extension/src/service-worker/git-host.ts | 9 ++++ extension/src/service-worker/gitea.ts | 3 ++ extension/src/service-worker/github.ts | 3 ++ .../src/service-worker/router/popup-only.ts | 8 +-- extension/src/service-worker/vault.ts | 33 +++++++++++- 6 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 extension/src/service-worker/__tests__/vault-status.test.ts diff --git a/extension/src/service-worker/__tests__/vault-status.test.ts b/extension/src/service-worker/__tests__/vault-status.test.ts new file mode 100644 index 0000000..c9497d8 --- /dev/null +++ b/extension/src/service-worker/__tests__/vault-status.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { handleGetVaultStatus } from '../vault'; +import type { Manifest, ManifestEntry } from '../../shared/types'; + +// The handler only reads gitHost's three cache fields, so the test feeds a +// minimal object — the handler's Pick-typed param makes full GitHost mocking +// unnecessary. +const cache = (lastSyncAt: number | null, ahead = 0, behind = 0) => + ({ lastSyncAt, ahead, behind }); + +function manifestWith(activeCount: number, trashedCount = 0): Manifest { + const items: Record = {}; + for (let i = 0; i < activeCount; i++) { + items[`a${i}`] = { trashed_at: undefined } as ManifestEntry; + } + for (let i = 0; i < trashedCount; i++) { + items[`t${i}`] = { trashed_at: 1000 } as ManifestEntry; + } + return { items } as Manifest; +} + +describe('handleGetVaultStatus', () => { + it('returns zeros when never synced and no manifest', () => { + const resp = handleGetVaultStatus({ gitHost: cache(null), manifest: null }); + expect(resp).toEqual({ + ok: true, + data: { ahead: 0, behind: 0, lastSyncAt: null, pendingItems: 0 }, + }); + }); + + it('reflects cached sync state + active (non-trashed) item count', () => { + const resp = handleGetVaultStatus({ + gitHost: cache(1234567890, 3, 1), + manifest: manifestWith(5, 2), + }); + expect(resp.ok).toBe(true); + if (resp.ok) { + expect(resp.data).toEqual({ + ahead: 3, behind: 1, lastSyncAt: 1234567890, pendingItems: 5, + }); + } + }); + + it('returns vault_locked error when gitHost is null', () => { + expect(handleGetVaultStatus({ gitHost: null, manifest: null })) + .toEqual({ ok: false, error: 'vault_locked' }); + }); + + it('is synchronous — no network round-trip', () => { + const resp = handleGetVaultStatus({ gitHost: cache(0), manifest: null }); + expect(resp).not.toBeInstanceOf(Promise); + }); +}); diff --git a/extension/src/service-worker/git-host.ts b/extension/src/service-worker/git-host.ts index d817e81..45c6fa9 100644 --- a/extension/src/service-worker/git-host.ts +++ b/extension/src/service-worker/git-host.ts @@ -41,6 +41,15 @@ export interface GitHost { /// Delete a blob from the repo. Currently identical to deleteFile; /// kept distinct for symmetry with putBlob. deleteBlob(path: string, message: string): Promise; + + /// Cached sync metadata, populated by the `sync` handler — get_vault_status + /// reads these without any network call. lastSyncAt is unix SECONDS (or null + /// until the first sync). ahead/behind exist for parity with `relicario + /// status`; the extension writes straight to the host (no local commit + /// graph), so in practice they stay 0. + lastSyncAt: number | null; + ahead: number; + behind: number; } /// Pre-base64 byte size at which putBlob switches from Contents API to diff --git a/extension/src/service-worker/gitea.ts b/extension/src/service-worker/gitea.ts index e888bdd..d51a931 100644 --- a/extension/src/service-worker/gitea.ts +++ b/extension/src/service-worker/gitea.ts @@ -20,6 +20,9 @@ export class GiteaHost implements GitHost { private keysUrl: string; private branch: string = 'main'; private headers: Record; + lastSyncAt: number | null = null; + ahead = 0; + behind = 0; constructor(hostUrl: string, repoPath: string, apiToken: string) { // Remove trailing slash from hostUrl diff --git a/extension/src/service-worker/github.ts b/extension/src/service-worker/github.ts index e1a3d56..85bf198 100644 --- a/extension/src/service-worker/github.ts +++ b/extension/src/service-worker/github.ts @@ -17,6 +17,9 @@ export class GitHubHost implements GitHost { private commitsUrl: string; private branch: string = 'main'; private headers: Record; + lastSyncAt: number | null = null; + ahead = 0; + behind = 0; constructor(repoPath: string, apiToken: string) { this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`; diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 8df97e9..a069d01 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -130,6 +130,8 @@ export async function handle( const handle = session.getCurrent(); if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle); + // Record sync time (unix SECONDS) for the get_vault_status indicator. + state.gitHost.lastSyncAt = Math.floor(Date.now() / 1000); return { ok: true }; } @@ -634,9 +636,9 @@ export async function handle( case 'attach_vault': return vault.handleAttachVault(msg, state); - // get_vault_status lands in Phase 6 (Dev-C). - // Until each case lands, an unhandled popup message returns an explicit - // error rather than falling through with no return. + case 'get_vault_status': + return vault.handleGetVaultStatus(state); + default: return { ok: false, error: `unhandled popup message: ${(msg as { type: string }).type}` }; } diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index d12cb89..b189e18 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -7,7 +7,7 @@ import { createGitHost, uint8ArrayToBase64 } from './git-host'; import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings, VaultConfig } from '../shared/types'; import * as session from './session'; import * as devices from './devices'; -import type { AttachVaultResponse, CreateVaultResponse } from '../shared/messages'; +import type { AttachVaultResponse, CreateVaultResponse, GetVaultStatusResponse } from '../shared/messages'; import type { PopupState } from './router/popup-only'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -548,3 +548,34 @@ export function itemToManifestEntry(item: Item): ManifestEntry { function safeHostname(url: string): string | undefined { try { return new URL(url).hostname; } catch { return undefined; } } + +// --- Vault status (Plan C Phase 6) --- + +/** + * Return the cached vault status for the sidebar indicator. Reads cached sync + * metadata off the GitHost (populated by the `sync` handler) plus a live count + * of active (non-trashed) items from the in-memory manifest. Does NOT touch + * the network — sync is user-initiated (spec 2026-05-04, Phase 6). The + * Pick-typed gitHost param both avoids a circular import of the router's + * PopupState and structurally forbids a network call from here. + */ +export function handleGetVaultStatus( + state: { + gitHost: Pick | null; + manifest: Manifest | null; + }, +): GetVaultStatusResponse | { ok: false; error: string } { + if (!state.gitHost) return { ok: false, error: 'vault_locked' }; + const pendingItems = state.manifest + ? Object.values(state.manifest.items).filter((e) => e.trashed_at === undefined).length + : 0; + return { + ok: true, + data: { + ahead: state.gitHost.ahead, + behind: state.gitHost.behind, + lastSyncAt: state.gitHost.lastSyncAt, + pendingItems, + }, + }; +} From 5efc3a5491ded414b88972775038b43011f08370 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 18:09:08 -0400 Subject: [PATCH 3/6] =?UTF-8?q?test(ext/vault):=20handler=E2=86=92renderer?= =?UTF-8?q?=20status=20integration=20+=20indicator=20CSS=20(Plan=20C=20Pha?= =?UTF-8?q?se=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the 6.1↔6.2 contract: handleGetVaultStatus output feeds straight into renderStatusIndicator. Adds minimal self-contained .vault-status CSS. Stays out of vault-sidebar.ts — the footer wiring (Task 6.3) is Dev-B's boundary. Co-Authored-By: Claude Opus 4.8 --- .../__tests__/status-integration.test.ts | 65 +++++++++++++++++++ extension/src/vault/vault.css | 12 ++++ 2 files changed, 77 insertions(+) create mode 100644 extension/src/vault/__tests__/status-integration.test.ts diff --git a/extension/src/vault/__tests__/status-integration.test.ts b/extension/src/vault/__tests__/status-integration.test.ts new file mode 100644 index 0000000..197abb7 --- /dev/null +++ b/extension/src/vault/__tests__/status-integration.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { handleGetVaultStatus } from '../../service-worker/vault'; +import { renderStatusIndicator } from '../vault-status'; +import type { Manifest, ManifestEntry } from '../../shared/types'; + +// Integration seam: the get_vault_status SW handler (Phase 6 Task 6.1) produces +// the exact data shape the sidebar renderer (Task 6.2) consumes. This pins the +// contract between the two so a future change to either side can't silently +// drift the keys apart. It does NOT touch vault-sidebar.ts — the wiring layer +// (Task 6.3) is Dev-B's boundary and lands separately. + +const cache = (lastSyncAt: number | null, ahead = 0, behind = 0) => + ({ lastSyncAt, ahead, behind }); + +function manifestWith(activeCount: number, trashedCount = 0): Manifest { + const items: Record = {}; + for (let i = 0; i < activeCount; i++) { + items[`a${i}`] = { trashed_at: undefined } as ManifestEntry; + } + for (let i = 0; i < trashedCount; i++) { + items[`t${i}`] = { trashed_at: 1000 } as ManifestEntry; + } + return { items } as Manifest; +} + +describe('vault status: handler → renderer integration', () => { + it('renders "in sync" from a freshly-synced, no-pending handler response', () => { + const resp = handleGetVaultStatus({ gitHost: cache(1700000000), manifest: manifestWith(0) }); + expect(resp.ok).toBe(true); + if (!resp.ok) return; + const el = document.createElement('div'); + renderStatusIndicator(el, resp.data); + expect(el.textContent).toMatch(/in sync/i); + expect(el.textContent).toMatch(/last sync/i); + }); + + it('surfaces the handler\'s active-item count as "N pending" in the DOM', () => { + const resp = handleGetVaultStatus({ gitHost: cache(1700000000), manifest: manifestWith(7, 3) }); + expect(resp.ok).toBe(true); + if (!resp.ok) return; + expect(resp.data.pendingItems).toBe(7); + const el = document.createElement('div'); + renderStatusIndicator(el, resp.data); + expect(el.textContent).toMatch(/7 pending/i); + }); + + it('surfaces cached ahead/behind from the handler in the DOM', () => { + const resp = handleGetVaultStatus({ gitHost: cache(1700000000, 2, 1), manifest: manifestWith(0) }); + expect(resp.ok).toBe(true); + if (!resp.ok) return; + const el = document.createElement('div'); + renderStatusIndicator(el, resp.data); + expect(el.textContent).toMatch(/2 ahead/i); + expect(el.textContent).toMatch(/1 behind/i); + }); + + it('renders "never synced" when the handler reports a null lastSyncAt', () => { + const resp = handleGetVaultStatus({ gitHost: cache(null), manifest: manifestWith(0) }); + expect(resp.ok).toBe(true); + if (!resp.ok) return; + const el = document.createElement('div'); + renderStatusIndicator(el, resp.data); + expect(el.textContent).toMatch(/never synced/i); + }); +}); diff --git a/extension/src/vault/vault.css b/extension/src/vault/vault.css index 8e7ac7d..81bf140 100644 --- a/extension/src/vault/vault.css +++ b/extension/src/vault/vault.css @@ -2113,3 +2113,15 @@ textarea { .history-index-row__info { flex: 1; display: flex; flex-direction: column; } .history-index-row__title { color: var(--text); } .history-index-row__meta { font-size: 11px; } + +/* Sidebar-footer vault status indicator (Plan C Phase 6, vault-status.ts). + The footer slot + refresh button are wired by vault-sidebar.ts in Task 6.3. */ +.vault-status { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 11px; + line-height: 1.4; +} +.vault-status__state { color: var(--text-dim); } +.vault-status__ts { color: var(--text-muted); } From c662db2875f48b47b7b0bc6d2cb815c02288dee0 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 21:33:21 -0400 Subject: [PATCH 4/6] feat(ext/vault): wire vault-status into sidebar footer (Plan C Phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders the status indicator into #vault-status-slot on sidebar mount and on a manual ↻ button. No timer polling — get_vault_status returns cached state and sync is user-initiated. Closes the relicario status CLI/extension parity gap. Co-Authored-By: Claude Opus 4.8 --- extension/src/shared/glyphs.ts | 1 + .../__tests__/vault-sidebar-status.test.ts | 62 +++++++++++++++++++ extension/src/vault/vault-sidebar.ts | 22 ++++++- extension/src/vault/vault.css | 28 ++++++++- 4 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 extension/src/vault/__tests__/vault-sidebar-status.test.ts diff --git a/extension/src/shared/glyphs.ts b/extension/src/shared/glyphs.ts index 4766e4c..2b45f2e 100644 --- a/extension/src/shared/glyphs.ts +++ b/extension/src/shared/glyphs.ts @@ -19,6 +19,7 @@ export const GLYPH_LOCK = '⏻'; // sidebar lock nav export const GLYPH_NEXT = '▸'; // forward / next button (matches ▾/▸ disclosure family) export const GLYPH_COPY = '⎘'; // copy to clipboard export const GLYPH_SYNC = '⇅'; // sync / upload +export const GLYPH_REFRESH = '↻'; // manual refresh (vault status indicator); shares ↻ with GENERATE, distinct semantic export const GLYPH_SYNCED = '✓'; // vault status: in sync (no pending/ahead/behind) export const GLYPH_AHEAD = '↑'; // vault status: local commits ahead of remote export const GLYPH_BEHIND = '↓'; // vault status: remote commits not yet pulled diff --git a/extension/src/vault/__tests__/vault-sidebar-status.test.ts b/extension/src/vault/__tests__/vault-sidebar-status.test.ts new file mode 100644 index 0000000..bdc45f5 --- /dev/null +++ b/extension/src/vault/__tests__/vault-sidebar-status.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderSidebarShell, wireSidebar } from '../vault-sidebar'; +import type { VaultController } from '../vault-context'; + +const STATUS = { ahead: 0, behind: 0, lastSyncAt: 1700000000, pendingItems: 4 }; + +function makeCtx() { + return { + state: { searchQuery: '' }, + sendMessage: vi.fn(async (req: { type: string }) => + req.type === 'get_vault_status' + ? { ok: true, data: STATUS } + : { ok: true }), + render: vi.fn(), + renderPane: vi.fn(), + renderListPane: vi.fn(), + closeDrawer: vi.fn(), + openTypePanel: vi.fn(), + setHash: vi.fn(), + applyShellViewClass: vi.fn(), + } as unknown as VaultController; +} + +function statusCalls(ctx: VaultController): number { + return (ctx.sendMessage as ReturnType).mock.calls + .filter((c) => (c[0] as { type: string }).type === 'get_vault_status').length; +} + +describe('vault-sidebar status wiring', () => { + beforeEach(() => { document.body.innerHTML = renderSidebarShell(); }); + afterEach(() => { document.body.innerHTML = ''; }); + + it('fetches + renders the indicator on mount', async () => { + const ctx = makeCtx(); + wireSidebar(ctx); + await vi.waitFor(() => { + expect(document.getElementById('vault-status-slot')?.textContent).toMatch(/4 pending/i); + }); + expect(statusCalls(ctx)).toBe(1); + }); + + it('re-fetches on the manual refresh button', async () => { + const ctx = makeCtx(); + wireSidebar(ctx); + await vi.waitFor(() => expect(statusCalls(ctx)).toBe(1)); + document.getElementById('status-refresh-btn')?.dispatchEvent(new Event('click')); + await vi.waitFor(() => expect(statusCalls(ctx)).toBe(2)); + }); + + it('does NOT poll on a timer', async () => { + vi.useFakeTimers(); + try { + const ctx = makeCtx(); + wireSidebar(ctx); + await vi.advanceTimersByTimeAsync(60_000); + // Only the single mount fetch — no interval re-fetches. + expect(statusCalls(ctx)).toBe(1); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/extension/src/vault/vault-sidebar.ts b/extension/src/vault/vault-sidebar.ts index b7db7b7..3004467 100644 --- a/extension/src/vault/vault-sidebar.ts +++ b/extension/src/vault/vault-sidebar.ts @@ -2,12 +2,14 @@ // nav-button wiring, and the (now debounced) search input. Each function // receives the VaultController (`ctx`) and reaches sibling concerns through it; // pure helpers come from vault-context. Imports only from shared/ and -// vault-context — never from vault-shell or vault.ts. +// vault-context, plus the leaf renderer vault-status — never from vault-shell +// or vault.ts. import type { ItemType } from '../shared/types'; import { - GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_HISTORY, GLYPH_LOCK, + GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_HISTORY, GLYPH_LOCK, GLYPH_REFRESH, } from '../shared/glyphs'; +import { renderStatusIndicator, type VaultStatus } from './vault-status'; import { type VaultController, typeIcon, typeLabel, getFilteredEntries, } from './vault-context'; @@ -38,8 +40,8 @@ export function renderSidebarShell(): string { `; } @@ -109,6 +111,20 @@ export function wireSidebar(ctx: VaultController): void { ctx.renderListPane(); } }); + + // Vault status indicator — refresh on mount + on the manual button only. + // No timer polling: get_vault_status returns cached state and sync is + // user-initiated (spec 2026-05-04, Phase 6). + const refreshStatus = async (): Promise => { + const resp = await ctx.sendMessage({ type: 'get_vault_status' }); + if (!resp.ok) return; + const slot = document.getElementById('vault-status-slot'); + if (slot) renderStatusIndicator(slot, resp.data as VaultStatus); + }; + void refreshStatus(); + document.getElementById('status-refresh-btn')?.addEventListener('click', () => { + void refreshStatus(); + }); } function isEditableTarget(target: EventTarget | null): boolean { diff --git a/extension/src/vault/vault.css b/extension/src/vault/vault.css index 81bf140..ac3f975 100644 --- a/extension/src/vault/vault.css +++ b/extension/src/vault/vault.css @@ -2114,8 +2114,18 @@ textarea { .history-index-row__title { color: var(--text); } .history-index-row__meta { font-size: 11px; } -/* Sidebar-footer vault status indicator (Plan C Phase 6, vault-status.ts). - The footer slot + refresh button are wired by vault-sidebar.ts in Task 6.3. */ +/* Sidebar-footer vault status indicator (Plan C Phase 6, vault-status.ts + + vault-sidebar.ts). Indicator renders into #vault-status-slot; the ↻ button + triggers a manual refresh (no timer polling). */ +.vault-sidebar__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 12px; + border-top: 1px solid var(--border-subtle); +} +#vault-status-slot { flex: 1; min-width: 0; } .vault-status { display: flex; flex-direction: column; @@ -2125,3 +2135,17 @@ textarea { } .vault-status__state { color: var(--text-dim); } .vault-status__ts { color: var(--text-muted); } +.vault-status-refresh { + flex: none; + background: none; + border: none; + color: var(--text-dim); + cursor: pointer; + font-family: inherit; + font-size: 13px; + line-height: 1; + padding: 2px 6px; + border-radius: 4px; +} +.vault-status-refresh:hover { color: var(--text); background: var(--bg-input); } +.vault-status-refresh:focus-visible { outline: none; box-shadow: var(--focus-ring); } From f4b4cf3db7195a9d8d6428aeadf7407ee5b21e75 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 21:54:42 -0400 Subject: [PATCH 5/6] =?UTF-8?q?refactor(ext):=20simplify=20Phase=206=20?= =?UTF-8?q?=E2=80=94=20alias=20VaultStatus=20+=20reuse=20listItems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two simplify-pass cleanups: - vault-status.ts: VaultStatus is now an alias of GetVaultStatusResponse['data'] instead of a re-declared 4-field interface, so the renderer's input shape is single-sourced from the message contract and can't drift from the SW handler. - service-worker/vault.ts: handleGetVaultStatus counts active items via the existing listItems() helper rather than re-implementing the trashed_at filter. Co-Authored-By: Claude Opus 4.8 --- extension/src/service-worker/vault.ts | 4 +--- extension/src/vault/vault-status.ts | 15 ++++++--------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index b189e18..ad91ad3 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -566,9 +566,7 @@ export function handleGetVaultStatus( }, ): GetVaultStatusResponse | { ok: false; error: string } { if (!state.gitHost) return { ok: false, error: 'vault_locked' }; - const pendingItems = state.manifest - ? Object.values(state.manifest.items).filter((e) => e.trashed_at === undefined).length - : 0; + const pendingItems = state.manifest ? listItems(state.manifest).length : 0; return { ok: true, data: { diff --git a/extension/src/vault/vault-status.ts b/extension/src/vault/vault-status.ts index b55132f..d68a1b9 100644 --- a/extension/src/vault/vault-status.ts +++ b/extension/src/vault/vault-status.ts @@ -5,16 +5,13 @@ import { GLYPH_PENDING, } from '../shared/glyphs'; import { relativeTime } from '../shared/relative-time'; +import type { GetVaultStatusResponse } from '../shared/messages'; -// Local shape for the sidebar-footer indicator. Mirrors the get_vault_status -// response data (ahead/behind/lastSyncAt/pendingItems). lastSyncAt is a unix -// timestamp in SECONDS, or null when the vault has never synced. -export interface VaultStatus { - ahead: number; - behind: number; - lastSyncAt: number | null; - pendingItems: number; -} +// The indicator consumes exactly the get_vault_status response payload; alias +// it (rather than re-declaring the four fields) so the shape stays single- +// sourced and can't drift from the SW handler. lastSyncAt is a unix timestamp +// in SECONDS, or null when the vault has never synced. +export type VaultStatus = GetVaultStatusResponse['data']; export function renderStatusIndicator(el: HTMLElement, status: VaultStatus): void { const ts = status.lastSyncAt !== null From 675452a9efdf0e68570258abbff6a8774af650a2 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 22:11:02 -0400 Subject: [PATCH 6/6] fix(ext/sw): null gitHost on explicit lock (Plan C Phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The explicit lock message handler nulled state.manifest but left state.gitHost (now carrying the cached lastSyncAt) intact, so a lock then re-unlock within one service-worker lifetime surfaced a stale sync time. Null gitHost here too — symmetric with the session-expiry path (index.ts) and completing Plan C Phase 5's don't-leak-git-host-across-a-lock intent; unlock rebuilds it on demand. Co-Authored-By: Claude Opus 4.8 --- extension/src/service-worker/router/popup-only.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index a069d01..bbc6036 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -59,6 +59,9 @@ export async function handle( case 'lock': session.clearCurrent(); state.manifest = null; + // Don't leak the cached git-host (incl. lastSyncAt) across a lock — + // symmetric with the session-expiry path (index.ts); unlock rebuilds it. + state.gitHost = null; return { ok: true }; case 'list_items': {