From 12dce5b25492f5d24e8f4929f5eccde8c09c7bc1 Mon Sep 17 00:00:00 2001 From: liugddx Date: Sun, 4 Oct 2026 14:37:56 +0800 Subject: [PATCH 1/3] feat(desktop): notify about archived-task cleanup outside Settings --- apps/desktop/renderer-architecture.json | 6 +- .../archive-retention-notices.test.ts | 280 ++++++++++++++++++ apps/desktop/src/renderer/app-shell.tsx | 20 +- .../renderer/features/storage-usage/index.ts | 3 +- .../locales/archive-retention-copy.ts | 8 + .../storage-usage/model/retention-notices.ts | 183 ++++++++++++ .../renderer/features/storage-usage/ports.ts | 22 ++ .../features/storage-usage/testing.ts | 1 + .../ui/archive-retention-notices.tsx | 72 +++++ .../ui/archive-retention-section.tsx | 6 +- .../desktop/create-storage-usage-services.ts | 32 +- docs/astryx-surface-file-inventory.md | 1 + docs/astryx-surface-file-inventory.paths | 1 + 13 files changed, 619 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/archive-retention-notices.test.ts create mode 100644 apps/desktop/src/renderer/features/storage-usage/model/retention-notices.ts create mode 100644 apps/desktop/src/renderer/features/storage-usage/ui/archive-retention-notices.tsx diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index a03f96a4406..6c1d8bf7a1d 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -463,6 +463,9 @@ ] }, "src/renderer/features/storage-usage/index.ts": { + "appShell": [ + "ArchiveRetentionNotices" + ], "composition": [ "StorageUsageServicesProvider" ] @@ -696,6 +699,7 @@ "./features/session-collaboration": 1, "./features/session-navigation": 1, "./features/session-settings": 1, + "./features/storage-usage/index.js": 1, "./features/task-entry": 1, "./features/workbar": 1, "./features/workhub": 1, @@ -727,7 +731,7 @@ "react": 1 }, "importSpecifiers": 52, - "nonTriviaTokens": 7333 + "nonTriviaTokens": 7330 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, diff --git a/apps/desktop/src/main/__tests__/archive-retention-notices.test.ts b/apps/desktop/src/main/__tests__/archive-retention-notices.test.ts new file mode 100644 index 00000000000..2b0f36df224 --- /dev/null +++ b/apps/desktop/src/main/__tests__/archive-retention-notices.test.ts @@ -0,0 +1,280 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { act, createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; +import { ArchiveRetentionNotices, StorageUsageServicesProvider } from '../../renderer/features/storage-usage/index.js'; +import type { StorageRetentionQueryResult } from '@maka/runtime-host/protocol'; +import type { StorageUsageServices } from '../../renderer/features/storage-usage/index.js'; +import type { RetentionNoticeHost, RetentionNoticeState } from '../../renderer/features/storage-usage/index.js'; +import { + acknowledgeRetentionResults, + decodeRetentionNoticeState, + observeRetentionNotices, + RETENTION_NOTICE_COOLDOWN_MS, + RETENTION_NOTICE_POLL_MS, +} from '../../renderer/features/storage-usage/testing.js'; + +const HOST = { profileId: 'p1', hostId: 'h1', name: 'Laptop' }; +const RESULT: StorageRetentionQueryResult = { enabled: true, revision: 1, days: 30, enabledAt: 0, preview: { count: 0 } }; +const stops: Array<() => void> = []; +afterEach(() => { while (stops.length) stops.pop()?.(); }); +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +function rig(persisted = new Map()) { + let change = () => {}; + let timer: (() => void) | undefined; + let time = 1_000_000; + let visible = true; + let hosts: readonly RetentionNoticeHost[] = [HOST]; + let result = RESULT; + let load: (host: RetentionNoticeHost) => Promise = async () => result; + let reads = 0; + let unsubscribed = false; + const shown: Array<{ host: RetentionNoticeHost; kind: string; count?: number; dismissed: boolean }> = []; + const writes: RetentionNoticeState[] = []; + const services: StorageUsageServices & { notices: NonNullable } = { + loadUsage: async () => assert.fail('usage must not be scanned'), + loadSessionUsage: async () => assert.fail('per-task sizes must not be scanned'), + setRetention: async () => assert.fail('observing must never change retention'), + loadRetention: async (host) => { reads += 1; return load(host as RetentionNoticeHost); }, + notices: { + loadHosts: async () => hosts, + subscribeChanges: (handler) => { change = handler; return () => { unsubscribed = true; }; }, + isVisible: () => visible, + readSeen: (id) => persisted.get(id), + writeSeen: (id, state) => { persisted.set(id, state); writes.push(state); }, + }, + }; + const stop = observeRetentionNotices({ + services, + now: () => time, + notify: (host, notice) => { + const item = { host, kind: notice.kind, ...(notice.kind === 'deletion' ? { count: notice.deletion.count } : {}), dismissed: false }; + shown.push(item); + return () => { item.dismissed = true; }; + }, + schedule: (callback, delay) => { + assert.equal(delay, RETENTION_NOTICE_POLL_MS); + timer = callback; + return () => { if (timer === callback) timer = undefined; }; + }, + }); + stops.push(stop); + return { + services, shown, writes, persisted, stop, + setResult: (next: StorageRetentionQueryResult) => { result = next; }, + setLoad: (next: typeof load) => { load = next; }, + setHosts: (next: typeof hosts) => { hosts = next; change(); }, + setVisible: (next: boolean) => { visible = next; change(); }, + advance: (ms: number) => { time += ms; }, + poll: async () => { const callback = timer; assert.ok(callback); timer = undefined; callback(); await settle(); }, + get reads() { return reads; }, + get stopped() { return unsubscribed && timer === undefined; }, + }; +} + +test('announces the latest cleanup once per Host, including after an observer restart', async () => { + const r = rig(); + r.setResult({ ...RESULT, lastDeletion: { at: 100, count: 8, bytes: 4096 } }); + await settle(); + assert.deepEqual(r.shown.map((n) => [n.host.hostId, n.kind, n.count]), [['h1', 'deletion', 8]]); + await r.poll(); + assert.equal(r.shown.length, 1); + assert.equal(r.writes.length, 1); + r.stop(); + const restarted = rig(r.persisted); + restarted.setResult({ ...RESULT, lastDeletion: { at: 100, count: 8 } }); + await settle(); + assert.equal(restarted.shown.length, 0); +}); + +test('coalesces successive cleanup batches without acknowledging unseen results', async () => { + const r = rig(); + r.setResult({ ...RESULT, lastDeletion: { at: 100, count: 8 } }); + await settle(); + r.setResult({ ...RESULT, lastDeletion: { at: 200, count: 3 } }); + r.advance(RETENTION_NOTICE_POLL_MS); + await r.poll(); + assert.equal(r.shown.length, 1); + assert.equal(r.persisted.get('h1')?.deletionAt, 100); + r.advance(RETENTION_NOTICE_COOLDOWN_MS); + await r.poll(); + assert.deepEqual(r.shown.map((n) => n.count), [8, 3]); + assert.equal(r.persisted.get('h1')?.deletionAt, 200); +}); + +test('warnings bypass cleanup cooldown, deduplicate, and disappear when the Host clears them', async () => { + const r = rig(); + r.setResult({ ...RESULT, lastDeletion: { at: 100, count: 1 } }); + await settle(); + const hold = { since: 100, detectedAt: 200, until: 300 }; + r.setResult({ ...RESULT, hold }); + await r.poll(); + await r.poll(); + assert.deepEqual(r.shown.map((n) => n.kind), ['deletion', 'hold']); + r.setResult({ ...RESULT, hold: { ...hold, detectedAt: 250, until: 350 } }); + await r.poll(); + assert.equal(r.shown[1]!.dismissed, true); + assert.equal(r.shown[2]!.dismissed, false); + r.setResult(RESULT); + await r.poll(); + assert.equal(r.shown[2]!.dismissed, true); +}); + +test('reports backward-clock pauses and clears the warning when retention is disabled', async () => { + const r = rig(); + const lastSweep = { at: 50, deleted: 0, skippedBusy: 0, needsReview: 0, failed: 0, paused: true as const }; + r.setResult({ ...RESULT, lastSweep }); + await settle(); + assert.deepEqual(r.shown.map((n) => n.kind), ['paused']); + r.setResult({ ...RESULT, enabled: false, lastSweep }); + await r.poll(); + assert.equal(r.shown[0]!.dismissed, true); + assert.equal(r.shown.length, 1); +}); + +test('Settings acknowledges displayed results even when the observer has cached an older result', async () => { + const r = rig(); + r.setResult({ ...RESULT, lastDeletion: { at: 100, count: 1 } }); + await settle(); + const newer = { ...RESULT, lastDeletion: { at: 200, count: 4 }, hold: { since: 100, detectedAt: 300, until: 400 } }; + acknowledgeRetentionResults(r.services, HOST, newer); + r.setResult(newer); + r.advance(RETENTION_NOTICE_COOLDOWN_MS); + await r.poll(); + assert.equal(r.shown.length, 1); +}); + +test('does not read or acknowledge results while hidden and catches up on visibility change', async () => { + const r = rig(); + r.setVisible(false); + r.setResult({ ...RESULT, lastDeletion: { at: 100, count: 2 } }); + await settle(); + assert.equal(r.reads, 0); + assert.equal(r.writes.length, 0); + await r.poll(); + assert.equal(r.reads, 0); + r.setVisible(true); + await settle(); + assert.equal(r.shown.length, 1); +}); + +test('a disconnected Host does not hide results from another Host, and duplicate profiles are read once', async () => { + const r = rig(); + const second = { profileId: 'p2', hostId: 'h2', name: 'Server' }; + r.setHosts([HOST, second, { ...second, profileId: 'alias' }]); + r.setLoad(async (host) => { + if (host.hostId === 'h1') throw new Error('disconnected'); + return { ...RESULT, lastDeletion: { at: 100, count: 1 } }; + }); + await settle(); + assert.deepEqual(r.shown.map((n) => n.host), [second]); + assert.equal(r.reads, 2); +}); + +test('removing a Host during an in-flight query discards its result and retires its warning', async () => { + const r = rig(); + r.setResult({ ...RESULT, hold: { since: 100, detectedAt: 200, until: 300 } }); + await settle(); + let finish!: (result: StorageRetentionQueryResult) => void; + r.setLoad(() => new Promise((resolve) => { finish = resolve; })); + const pending = r.poll(); + await settle(); + r.setHosts([]); + finish({ ...RESULT, lastDeletion: { at: 500, count: 8 } }); + await pending; + await settle(); + assert.equal(r.shown.length, 1); + assert.equal(r.shown[0]!.dismissed, true); + assert.equal(r.persisted.get('h1')?.deletionAt, undefined); +}); + +test('disposing during discovery prevents late notices and leaves no subscription or timer', async () => { + const r = rig(); + r.setResult({ ...RESULT, lastDeletion: { at: 100, count: 8 } }); + r.stop(); + await settle(); + assert.equal(r.shown.length, 0); + assert.equal(r.writes.length, 0); + assert.equal(r.stopped, true); +}); + +test('corrupt persisted state cannot suppress cleanup indefinitely', () => { + for (const value of [null, [], 123, { deletionAt: -1, notifiedAt: Infinity, warning: 5 }]) { + assert.deepEqual(decodeRetentionNoticeState(value), {}); + } + assert.deepEqual(decodeRetentionNoticeState({ deletionAt: 0, notifiedAt: 3, warning: 'hold:1' }), { deletionAt: 0, notifiedAt: 3, warning: 'hold:1' }); +}); + + +test('same-name Hosts keep separate notices and open their own archived tasks in all locales', async () => { + const previous = { + document: globalThis.document, + window: globalThis.window, + HTMLElement: globalThis.HTMLElement, + getComputedStyle: globalThis.getComputedStyle, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT, + }; + try { + for (const locale of ['en', 'zh-CN', 'zh-TW'] as const) { + const { document, window } = parseHTML('
'); + Object.assign(window, { matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} }) }); + Object.assign(globalThis, { + document, window, HTMLElement: window.HTMLElement, + getComputedStyle: () => ({ color: 'currentColor' }), IS_REACT_ACT_ENVIRONMENT: true, + }); + const r = rig(); + r.stop(); + r.setHosts([HOST, { ...HOST, profileId: 'p2', hostId: 'h2' }]); + r.setResult({ ...RESULT, lastDeletion: { at: 100, count: 3, bytes: 2048 } }); + const opened: string[] = []; + const container = document.getElementById('root') as unknown as HTMLElement; + const root = createRoot(container); + try { + await act(async () => { + root.render(createElement(LocaleProvider, { locale, children: createElement(AstryxLocaleProvider, { + children: createElement(ToastProvider, { children: createElement(StorageUsageServicesProvider, { + services: r.services, + children: createElement(ArchiveRetentionNotices, { navigation: { + setSettingsProfileId: (id) => { opened.push(id); }, + openSettingsSection: (section) => { opened.push(section); }, + } }), + }) }), + }) })); + await settle(); + }); + assert.ok(document.body.textContent?.includes('Laptop')); + assert.ok(document.body.textContent?.includes('3')); + const actions = Array.from(document.querySelectorAll('button')).filter((button) => /View archived tasks|查看已归档任务|檢視已歸檔任務/.test(button.textContent ?? '')); + assert.equal(actions.length, 2, document.body.textContent ?? 'missing notice'); + for (const action of actions) await act(async () => { action.click(); }); + assert.deepEqual(opened, ['p1', 'archived-tasks', 'p2', 'archived-tasks']); + } finally { + await act(async () => root.unmount()); + } + } + } finally { + Object.assign(globalThis, previous); + } +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 5f6cb763482..1e2bb2459e2 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -20,6 +20,7 @@ import { WorkHubControlOverlay, WorkHubDock, WorkHubMainNavigation } from './features/workhub'; import { WorkHubEnablementWatch } from './application/contracts/workhub-workspace/workhub-enablement.js'; import { RuntimeHostHandoffOverlay } from './features/runtime-host-management/index.js'; +import { ArchiveRetentionNotices } from './features/storage-usage/index.js'; import { useCallback, useLayoutEffect, @@ -251,7 +252,7 @@ function AppShellContent({ recordSessionChange, sessionCatalogController, commitSession, - activeCatalogSession, + activeCatalogSession: activeSession, activeHostSession, sharedSessionActive, ownerActiveId, @@ -288,8 +289,8 @@ function AppShellContent({ // that cannot move under it. See NEW_TASK_PENDING_KEY. const attachmentDraftKey = activeId ?? NEW_TASK_PENDING_KEY; const directoryHostId = activeId - ? (activeCatalogSession?.profileKind === 'local' - ? activeCatalogSession.runtimeHostId + ? (activeSession?.profileKind === 'local' + ? activeSession.runtimeHostId : undefined) : (taskEntry.selectors.selectedHost?.kind === 'local' ? taskEntry.selectors.target?.hostId @@ -355,9 +356,7 @@ function AppShellContent({ const activeConnectionSnapshot = workHubActive || activeId ? sessionHostConnections.snapshot : newTaskConnectionSnapshot; - const connections = activeConnectionSnapshot.connections; - const defaultConnection = activeConnectionSnapshot.defaultConnection; - const connectionModelChoices = activeConnectionSnapshot.chatModelChoices; + const { connections, defaultConnection, chatModelChoices: connectionModelChoices } = activeConnectionSnapshot; const refreshConnections = activeId ? sessionHostConnections.refreshConnections : newTaskConnections.refreshConnections; @@ -423,7 +422,6 @@ function AppShellContent({ // Named Composer edits; the editor handle stays with Conversation. const composerEditing = queueSurface.editing; const rendererMountedRef = useRef(true); - const activeSession = activeCatalogSession; const { setPermissionMode, setSessionModel, setSessionThinkingLevel, setSessionExecutor } = sessionSettingIntent.commands; const modelConfigurationOverlay = sessionSettingIntent.overlay.modelConfiguration; const activeSessionForModelControls = activeSession @@ -810,20 +808,17 @@ function AppShellContent({ const { revisionNavigation, activeParentSession, - layout: railLayout, + layout: { collapsed: sessionListCollapsed, width: sessionListWidth }, } = useSessionNavigationReads({ catalog: sessionCatalogController, activeSessionId: activeId, }); - const sessionListCollapsed = railLayout.collapsed; - const sessionListWidth = railLayout.width; const sessionSideNavHandleRef = sessionRailLayoutStore.collapseHandleRef; const titlebarParentSession = useMemo(() => { if (!activeParentSession) return undefined; - const parentId = activeParentSession.id; return { name: activeParentSession.name, - onOpen: () => openSessionInChatRef.current(parentId), + onOpen: () => openSessionInChatRef.current(activeParentSession.id), }; }, [activeParentSession]); const archivedTasksBridge = useMemo( @@ -1213,6 +1208,7 @@ function AppShellContent({ sessionListWidth, })} > + void defaultHostConnections.refreshConnections()} /> diff --git a/apps/desktop/src/renderer/features/storage-usage/index.ts b/apps/desktop/src/renderer/features/storage-usage/index.ts index 757562520b7..43e97382c2e 100644 --- a/apps/desktop/src/renderer/features/storage-usage/index.ts +++ b/apps/desktop/src/renderer/features/storage-usage/index.ts @@ -21,7 +21,8 @@ // from this barrel and render these surfaces; they hold no storage state. export { ArchiveRetentionSection } from './ui/archive-retention-section.js'; +export { ArchiveRetentionNotices } from './ui/archive-retention-notices.js'; export { StorageUsageSection } from './ui/storage-usage-section.js'; export { TaskStorageSize } from './ui/task-storage-size.js'; export { StorageUsageServicesProvider } from './services-context.js'; -export type { StorageUsageHostTarget, StorageUsageServices } from './ports.js'; +export type { RetentionNoticeHost, RetentionNoticeState, StorageUsageHostTarget, StorageUsageServices } from './ports.js'; diff --git a/apps/desktop/src/renderer/features/storage-usage/locales/archive-retention-copy.ts b/apps/desktop/src/renderer/features/storage-usage/locales/archive-retention-copy.ts index fdd5dba2fac..f43ab19a756 100644 --- a/apps/desktop/src/renderer/features/storage-usage/locales/archive-retention-copy.ts +++ b/apps/desktop/src/renderer/features/storage-usage/locales/archive-retention-copy.ts @@ -20,6 +20,8 @@ import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; export interface ArchiveRetentionCopy { + noticeTitle(hostName: string): string; + readonly viewArchivedTasks: string; readonly title: string; /** What the setting covers, when its clock starts, what it keeps, and that it is final. */ readonly help: string; @@ -55,6 +57,8 @@ export interface ArchiveRetentionCopy { const COPY_BY_LOCALE = { 'zh-CN': { + noticeTitle: (hostName: string) => `「${hostName}」的自动清理`, + viewArchivedTasks: '查看已归档任务', title: '自动清理', help: '开启后,此运行时主机会删除归档超过所选天数的任务。适用于所有已归档任务,无论是手动还是自动归档的。计时从开启或修改天数时开始,因此不会立即删除积压的任务。已置顶的任务会保留。删除后无法恢复。', enable: '自动删除已归档任务', @@ -91,6 +95,8 @@ const COPY_BY_LOCALE = { cancel: '取消', }, 'zh-TW': { + noticeTitle: (hostName: string) => `「${hostName}」的自動清理`, + viewArchivedTasks: '檢視已歸檔任務', title: '自動清理', help: '開啟後,此執行階段主機會刪除歸檔超過所選天數的任務。適用於所有已歸檔任務,無論是手動或自動歸檔的。計時從開啟或修改天數時開始,因此不會立即刪除積壓的任務。已置頂的任務會保留。刪除後無法復原。', enable: '自動刪除已歸檔任務', @@ -127,6 +133,8 @@ const COPY_BY_LOCALE = { cancel: '取消', }, en: { + noticeTitle: (hostName: string) => `Automatic cleanup on “${hostName}”`, + viewArchivedTasks: 'View archived tasks', title: 'Automatic cleanup', help: 'When on, this Runtime Host deletes tasks that have been archived for longer than the period you choose. It applies to every archived task, whether you archived it or it was archived automatically. The clock starts when you turn it on or change the period, so no backlog is deleted at once. Pinned tasks are kept. Deletion is permanent.', enable: 'Delete archived tasks automatically', diff --git a/apps/desktop/src/renderer/features/storage-usage/model/retention-notices.ts b/apps/desktop/src/renderer/features/storage-usage/model/retention-notices.ts new file mode 100644 index 00000000000..9f36556713f --- /dev/null +++ b/apps/desktop/src/renderer/features/storage-usage/model/retention-notices.ts @@ -0,0 +1,183 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { StorageRetentionQueryResult } from '@maka/runtime-host/protocol'; +import type { RetentionNoticeHost, RetentionNoticeState, StorageUsageHostTarget, StorageUsageServices } from '../ports.js'; + +export const RETENTION_NOTICE_POLL_MS = 60_000; +export const RETENTION_NOTICE_COOLDOWN_MS = 15 * 60_000; + +export type RetentionNotice = + | { readonly kind: 'deletion'; readonly deletion: NonNullable } + | { readonly kind: 'hold'; readonly hold: NonNullable } + | { readonly kind: 'paused' }; + +export function decodeRetentionNoticeState(value: unknown): RetentionNoticeState { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const record = value as Record; + const time = (v: unknown): v is number => Number.isSafeInteger(v) && (v as number) >= 0; + return { + ...(time(record.deletionAt) ? { deletionAt: record.deletionAt } : {}), + ...(time(record.notifiedAt) ? { notifiedAt: record.notifiedAt } : {}), + ...(typeof record.warning === 'string' ? { warning: record.warning } : {}), + }; +} + +function warningKey(retention: StorageRetentionQueryResult): string | undefined { + if (!retention.enabled) return undefined; + if (retention.hold) return `hold:${retention.revision}:${retention.hold.detectedAt}:${retention.hold.until}`; + return retention.lastSweep?.paused ? `paused:${retention.revision}:${retention.lastSweep.at}` : undefined; +} + +/** A result actually presented in Settings need not be announced again. */ +export function acknowledgeRetentionResults( + services: StorageUsageServices, + host: StorageUsageHostTarget, + retention: StorageRetentionQueryResult, +): void { + const notices = services.notices; + if (!notices) return; + const previous = decodeRetentionNoticeState(notices.readSeen(host.hostId)); + const warning = warningKey(retention); + const deletionAt = Math.max(previous.deletionAt ?? -1, retention.lastDeletion?.at ?? -1); + const next = { + ...previous, + ...(deletionAt >= 0 ? { deletionAt } : {}), + ...(warning ? { warning } : {}), + }; + if (next.deletionAt !== previous.deletionAt || next.warning !== previous.warning) notices.writeSeen(host.hostId, next); +} + +/** + * Poll only connected Hosts while this window can present a notice. Host time + * identifies results; Client time only throttles successive deletion batches. + * Warnings are never throttled behind a deletion reminder. + */ +export function observeRetentionNotices(input: { + readonly services: Pick & { + readonly notices: NonNullable; + }; + readonly notify: (host: RetentionNoticeHost, notice: RetentionNotice) => (() => void) | void; + readonly now?: () => number; + readonly schedule?: (callback: () => void, delay: number) => () => void; +}): () => void { + const { services } = input; + const now = input.now ?? Date.now; + const schedule = input.schedule ?? ((callback, delay) => { + const timer = setTimeout(callback, delay); + return () => clearTimeout(timer); + }); + const seen = new Map(); + const warnings = new Map void }>(); + let closed = false; + let version = 0; + let running = false; + let requested = false; + let cancelTimer: (() => void) | undefined; + + async function refresh(): Promise { + if (closed) return; + cancelTimer?.(); + cancelTimer = undefined; + if (running) { + requested = true; + return; + } + running = true; + const started = version; + try { + if (!services.notices.isVisible()) return; + const hosts = await services.notices.loadHosts(); + if (closed || started !== version) return; + const present = new Set(hosts.map((host) => host.hostId)); + for (const [hostId, active] of warnings) { + if (!present.has(hostId)) { + active.dismiss(); + warnings.delete(hostId); + } + } + const visited = new Set(); + for (const host of hosts) { + if (closed || started !== version || !services.notices.isVisible()) return; + if (visited.has(host.hostId)) continue; + visited.add(host.hostId); + let retention: StorageRetentionQueryResult; + try { + retention = await services.loadRetention(host); + } catch { + // A disconnected Host must not prevent notices from other Hosts. + continue; + } + if (closed || started !== version || !services.notices.isVisible()) return; + const persisted = decodeRetentionNoticeState(services.notices.readSeen(host.hostId)); + const cached = seen.get(host.hostId); + const deletionAt = Math.max(cached?.deletionAt ?? -1, persisted.deletionAt ?? -1); + let state: RetentionNoticeState = { ...persisted, ...cached, ...(deletionAt >= 0 ? { deletionAt } : {}) }; + const publish = (notice: RetentionNotice, next: RetentionNoticeState) => { + const dismiss = input.notify(host, notice); + if (notice.kind !== 'deletion' && next.warning && dismiss) warnings.set(host.hostId, { key: next.warning, dismiss }); + state = next; + seen.set(host.hostId, state); + services.notices.writeSeen(host.hostId, state); + }; + const warning = warningKey(retention); + const active = warnings.get(host.hostId); + if (active && active.key !== warning) { + active.dismiss(); + warnings.delete(host.hostId); + } + if (warning && warning === persisted.warning) state = { ...state, warning }; + if (warning && warning !== state.warning) { + publish(retention.hold ? { kind: 'hold', hold: retention.hold } : { kind: 'paused' }, { ...state, warning }); + } + const deletion = retention.lastDeletion; + const time = now(); + const cooled = state.notifiedAt === undefined || time < state.notifiedAt || time - state.notifiedAt >= RETENTION_NOTICE_COOLDOWN_MS; + if (deletion && deletion.count > 0 && deletion.at > (state.deletionAt ?? -1) && cooled) { + publish({ kind: 'deletion', deletion }, { ...state, deletionAt: deletion.at, notifiedAt: time }); + } + } + } catch { + // Profile discovery can be temporarily unavailable during Host handoff. + } finally { + running = false; + if (!closed) { + if (requested) { + requested = false; + void refresh(); + } else { + cancelTimer = schedule(() => { void refresh(); }, RETENTION_NOTICE_POLL_MS); + } + } + } + } + + const unsubscribe = services.notices.subscribeChanges(() => { + version += 1; + void refresh(); + }); + void refresh(); + return () => { + closed = true; + cancelTimer?.(); + unsubscribe(); + for (const active of warnings.values()) active.dismiss(); + warnings.clear(); + }; +} diff --git a/apps/desktop/src/renderer/features/storage-usage/ports.ts b/apps/desktop/src/renderer/features/storage-usage/ports.ts index 2b697249143..8bca8d81307 100644 --- a/apps/desktop/src/renderer/features/storage-usage/ports.ts +++ b/apps/desktop/src/renderer/features/storage-usage/ports.ts @@ -37,6 +37,8 @@ export interface StorageUsageHostTarget { * deletes data itself; a Host with retention enabled does that on its own. */ export interface StorageUsageServices { + /** Available in the application shell; isolated Settings fixtures need no observer. */ + readonly notices?: RetentionNoticeServices; /** One Runtime Host's State Root footprint. */ loadUsage(host: StorageUsageHostTarget): Promise; /** @@ -54,3 +56,23 @@ export interface StorageUsageServices { input: StorageRetentionSetInput, ): Promise; } + +export interface RetentionNoticeHost extends StorageUsageHostTarget { + readonly name: string; +} + +export interface RetentionNoticeState { + readonly deletionAt?: number; + readonly warning?: string; + /** Client time, used only to limit reminders during a long cleanup backlog. */ + readonly notifiedAt?: number; +} + +export interface RetentionNoticeServices { + /** Already-connected Hosts only; observing never starts or enables a Host. */ + loadHosts(): Promise; + subscribeChanges(handler: () => void): () => void; + isVisible(): boolean; + readSeen(hostId: string): unknown; + writeSeen(hostId: string, state: RetentionNoticeState): void; +} diff --git a/apps/desktop/src/renderer/features/storage-usage/testing.ts b/apps/desktop/src/renderer/features/storage-usage/testing.ts index 684f176ba70..b70a8403161 100644 --- a/apps/desktop/src/renderer/features/storage-usage/testing.ts +++ b/apps/desktop/src/renderer/features/storage-usage/testing.ts @@ -29,3 +29,4 @@ export { } from './model/session-storage-loader.js'; export { applyArchiveRetentionChange, archiveRetentionConfirm } from './model/archive-retention.js'; export { getArchiveRetentionCopy } from './locales/archive-retention-copy.js'; +export { acknowledgeRetentionResults, observeRetentionNotices, decodeRetentionNoticeState, RETENTION_NOTICE_POLL_MS, RETENTION_NOTICE_COOLDOWN_MS } from './model/retention-notices.js'; diff --git a/apps/desktop/src/renderer/features/storage-usage/ui/archive-retention-notices.tsx b/apps/desktop/src/renderer/features/storage-usage/ui/archive-retention-notices.tsx new file mode 100644 index 00000000000..8f70d1be48c --- /dev/null +++ b/apps/desktop/src/renderer/features/storage-usage/ui/archive-retention-notices.tsx @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useEffectEvent } from 'react'; +import { ARCHIVE_RETENTION_DAY_MS } from '@maka/core/archive-retention'; +import { formatAbsoluteTimestamp } from '@maka/core/relative-time'; +import type { SettingsSection } from '@maka/core/settings'; +import { formatBytes, useToast, useUiLocale } from '@maka/ui'; +import { getArchiveRetentionCopy } from '../locales/archive-retention-copy.js'; +import { observeRetentionNotices, type RetentionNotice } from '../model/retention-notices.js'; +import type { RetentionNoticeHost } from '../ports.js'; +import { useOptionalStorageUsageServices } from '../services-context.js'; + +/** Shell observer: independent of whether Storage settings is open. */ +export function ArchiveRetentionNotices(props: { + readonly navigation: { + setSettingsProfileId(profileId: string): void; + openSettingsSection(section: SettingsSection): void; + }; +}) { + const services = useOptionalStorageUsageServices(); + const toast = useToast(); + const locale = useUiLocale(); + const copy = getArchiveRetentionCopy(locale); + const notify = useEffectEvent((host: RetentionNoticeHost, notice: RetentionNotice) => { + const description = notice.kind === 'deletion' + ? copy.lastCleanup( + notice.deletion.count, + notice.deletion.bytes === undefined ? undefined : formatBytes(notice.deletion.bytes), + formatAbsoluteTimestamp(notice.deletion.at, locale), + ) + : notice.kind === 'hold' + ? copy.held( + formatAbsoluteTimestamp(notice.hold.until, locale), + Math.max(1, Math.round((notice.hold.detectedAt - notice.hold.since) / ARCHIVE_RETENTION_DAY_MS)), + ) + : copy.paused; + const id = toast.toast({ + title: copy.noticeTitle(host.name), + diagnosticTarget: { profileId: host.profileId }, + description, + variant: notice.kind === 'deletion' ? 'info' : 'warning', + duration: notice.kind === 'deletion' ? 12_000 : 0, + action: { label: copy.viewArchivedTasks, onClick: () => { + props.navigation.setSettingsProfileId(host.profileId); + props.navigation.openSettingsSection('archived-tasks'); + } }, + }); + return () => toast.dismiss(id); + }); + useEffect(() => { + if (!services?.notices) return; + return observeRetentionNotices({ services: { ...services, notices: services.notices }, notify }); + }, [services]); + return null; +} diff --git a/apps/desktop/src/renderer/features/storage-usage/ui/archive-retention-section.tsx b/apps/desktop/src/renderer/features/storage-usage/ui/archive-retention-section.tsx index b392735fc7c..343b7f82c7a 100644 --- a/apps/desktop/src/renderer/features/storage-usage/ui/archive-retention-section.tsx +++ b/apps/desktop/src/renderer/features/storage-usage/ui/archive-retention-section.tsx @@ -42,6 +42,7 @@ import { applyArchiveRetentionChange, archiveRetentionConfirm, } from '../model/archive-retention.js'; +import { acknowledgeRetentionResults } from '../model/retention-notices.js'; import { useOptionalStorageUsageServices } from '../services-context.js'; type RetentionState = @@ -79,7 +80,10 @@ export function ArchiveRetentionSection() { let current = true; services.loadRetention(host).then( (retention) => { - if (current) setScoped({ hostKey, state: { status: 'ready', retention } }); + if (current) { + setScoped({ hostKey, state: { status: 'ready', retention } }); + acknowledgeRetentionResults(services, host, retention); + } }, () => { if (current) setScoped({ hostKey, state: { status: 'failed' } }); diff --git a/apps/desktop/src/renderer/platform/desktop/create-storage-usage-services.ts b/apps/desktop/src/renderer/platform/desktop/create-storage-usage-services.ts index 356632da4dd..2a8087fb2fe 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-storage-usage-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-storage-usage-services.ts @@ -19,14 +19,44 @@ import type { MakaBridge } from '../../../preload/bridge-contract.js'; import type { StorageUsageServices } from '../../features/storage-usage'; +import { safeLocalStorageGet, safeLocalStorageSet } from './browser-storage.js'; -export type DesktopStorageUsageBridge = Pick; +export type DesktopStorageUsageBridge = Pick; /** Binds the storage usage feature to the Desktop bridge. */ export function createDesktopStorageUsageServices( bridge: DesktopStorageUsageBridge = window.maka, ): StorageUsageServices { return { + notices: { + async loadHosts() { + const snapshot = await bridge.runtimeHostProfiles.getSnapshot(); + return snapshot.entries.flatMap((entry) => entry.enabled && entry.readiness === 'ready' && entry.hostId + ? [{ profileId: entry.profile.id, hostId: entry.hostId, name: entry.profile.name }] + : []); + }, + subscribeChanges(handler) { + const unsubscribe = bridge.runtimeHostProfiles.subscribeChanges(handler); + document.addEventListener('visibilitychange', handler); + window.addEventListener('focus', handler); + window.addEventListener('blur', handler); + return () => { + unsubscribe(); + document.removeEventListener('visibilitychange', handler); + window.removeEventListener('focus', handler); + window.removeEventListener('blur', handler); + }; + }, + isVisible: () => document.visibilityState !== 'hidden' && document.hasFocus(), + readSeen(hostId) { + try { + return JSON.parse(safeLocalStorageGet(`maka-retention-notices-v1:${encodeURIComponent(hostId)}`) ?? 'null'); + } catch { + return undefined; + } + }, + writeSeen: (hostId, state) => safeLocalStorageSet(`maka-retention-notices-v1:${encodeURIComponent(hostId)}`, JSON.stringify(state)), + }, loadUsage: (host) => bridge.storage.usage(host), // No host argument: each task is measured by the Host that holds it, and // the bridge routes by the projected id for exactly that reason. diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index eada105c503..615727801d9 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -125,6 +125,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/session-settings/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/session-settings/ui/session-settings-provider.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/storage-usage/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/storage-usage/ui/archive-retention-notices.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/storage-usage/ui/archive-retention-section.tsx` | other | Banner, Selector, Switch, Text | aligned — uses Astryx (Banner, Selector, Switch, Text) | aligned | | `apps/desktop/src/renderer/features/storage-usage/ui/storage-usage-section.tsx` | other | Banner, Button | aligned — uses Astryx (Banner, Button) | aligned | | `apps/desktop/src/renderer/features/storage-usage/ui/task-storage-size.tsx` | other | Text | aligned — uses Astryx (Text) | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 56f909b21a6..ea0a82ee5ac 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -95,6 +95,7 @@ apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-prov apps/desktop/src/renderer/features/session-settings/services-context.tsx apps/desktop/src/renderer/features/session-settings/ui/session-settings-provider.tsx apps/desktop/src/renderer/features/storage-usage/services-context.tsx +apps/desktop/src/renderer/features/storage-usage/ui/archive-retention-notices.tsx apps/desktop/src/renderer/features/storage-usage/ui/archive-retention-section.tsx apps/desktop/src/renderer/features/storage-usage/ui/storage-usage-section.tsx apps/desktop/src/renderer/features/storage-usage/ui/task-storage-size.tsx From fa886a86640278c8fd75dabaf060dab61a1139a6 Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 5 Oct 2026 21:41:50 +0800 Subject: [PATCH 2/3] fix(desktop): address retention notice review feedback --- .../archive-retention-notices.test.ts | 94 +++++++++++++++++++ .../renderer/features/storage-usage/index.ts | 1 + .../storage-usage/model/retention-notices.ts | 35 ++++++- .../renderer/features/storage-usage/ports.ts | 2 + .../desktop/create-storage-usage-services.ts | 31 ++++-- 5 files changed, 149 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/main/__tests__/archive-retention-notices.test.ts b/apps/desktop/src/main/__tests__/archive-retention-notices.test.ts index 2b0f36df224..33daba6bd07 100644 --- a/apps/desktop/src/main/__tests__/archive-retention-notices.test.ts +++ b/apps/desktop/src/main/__tests__/archive-retention-notices.test.ts @@ -27,6 +27,7 @@ import { ArchiveRetentionNotices, StorageUsageServicesProvider } from '../../ren import type { StorageRetentionQueryResult } from '@maka/runtime-host/protocol'; import type { StorageUsageServices } from '../../renderer/features/storage-usage/index.js'; import type { RetentionNoticeHost, RetentionNoticeState } from '../../renderer/features/storage-usage/index.js'; +import { createDesktopStorageUsageServices } from '../../renderer/platform/desktop/create-storage-usage-services.js'; import { acknowledgeRetentionResults, decodeRetentionNoticeState, @@ -166,6 +167,42 @@ test('Settings acknowledges displayed results even when the observer has cached assert.equal(r.shown.length, 1); }); +test('Settings acknowledgement withdraws an already-visible warning without treating its first announcement as acknowledgement', async () => { + const r = rig(); + const held = { ...RESULT, hold: { since: 100, detectedAt: 200, until: 300 } }; + r.setResult(held); + await settle(); + await r.poll(); + assert.equal(r.shown.length, 1); + assert.equal(r.shown[0]!.dismissed, false); + acknowledgeRetentionResults(r.services, HOST, held); + await r.poll(); + assert.equal(r.shown[0]!.dismissed, true); + assert.equal(r.shown.length, 1); +}); + +test('disabled Hosts back off across focus changes and are checked again after fifteen minutes', async () => { + const r = rig(); + r.setResult({ ...RESULT, enabled: false }); + await settle(); + for (let minute = 1; minute < 15; minute += 1) { + r.advance(RETENTION_NOTICE_POLL_MS); + await r.poll(); + r.setVisible(false); + await settle(); + r.setVisible(true); + await settle(); + } + assert.equal(r.reads, 1); + r.setResult({ ...RESULT, hold: { since: 100, detectedAt: 200, until: 300 } }); + r.advance(RETENTION_NOTICE_POLL_MS); + await r.poll(); + assert.equal(r.reads, 2); + assert.equal(r.shown[0]!.kind, 'hold'); + await r.poll(); + assert.equal(r.reads, 3); +}); + test('does not read or acknowledge results while hidden and catches up on visibility change', async () => { const r = rig(); r.setVisible(false); @@ -227,6 +264,63 @@ test('corrupt persisted state cannot suppress cleanup indefinitely', () => { assert.deepEqual(decodeRetentionNoticeState({ deletionAt: 0, notifiedAt: 3, warning: 'hold:1' }), { deletionAt: 0, notifiedAt: 3, warning: 'hold:1' }); }); +test('Desktop events ignore blur, refresh on acknowledgement, and unsubscribe cleanly', () => { + const keys = ['document', 'window', 'localStorage'] as const; + const descriptors = keys.map((key) => Object.getOwnPropertyDescriptor(globalThis, key)); + const document = Object.assign(new EventTarget(), { visibilityState: 'visible', hasFocus: () => true }); + const window = new EventTarget(); + const stored = new Map(); + const localStorage = { getItem: (key: string) => stored.get(key) ?? null, setItem: (key: string, value: string) => { stored.set(key, value); } }; + let changes = 0; + let unsubscribed = false; + let stop = () => {}; + try { + for (const [key, value] of Object.entries({ document, window, localStorage })) { + Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); + } + const notices = createDesktopStorageUsageServices({ + storage: { + usage: async () => assert.fail('no storage query expected'), + sessionUsage: async () => assert.fail('no storage query expected'), + retention: async () => assert.fail('no storage query expected'), + setRetention: async () => assert.fail('no policy change expected'), + }, + runtimeHostProfiles: { + getSnapshot: async () => assert.fail('no discovery expected'), + subscribeChanges: () => () => { unsubscribed = true; }, + }, + }).notices!; + stop = notices.subscribeChanges(() => { changes += 1; }); + window.dispatchEvent(new Event('blur')); + assert.equal(changes, 0); + window.dispatchEvent(new Event('focus')); + document.dispatchEvent(new Event('visibilitychange')); + assert.equal(changes, 2); + const announced = { warning: 'hold:1' }; + notices.writeSeen('h1', announced); + assert.equal(changes, 2); + const acknowledged = { ...announced, acknowledgedWarning: announced.warning }; + notices.writeSeen('h1', acknowledged); + assert.equal(changes, 3); + assert.deepEqual(notices.readSeen('h1'), acknowledged); + notices.writeSeen('h1', acknowledged); + assert.equal(changes, 3); + stop(); + assert.equal(unsubscribed, true); + window.dispatchEvent(new Event('focus')); + document.dispatchEvent(new Event('visibilitychange')); + notices.writeSeen('h1', { warning: 'hold:2', acknowledgedWarning: 'hold:2' }); + assert.equal(changes, 3); + } finally { + stop(); + keys.forEach((key, index) => { + const descriptor = descriptors[index]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + }); + } +}); + test('same-name Hosts keep separate notices and open their own archived tasks in all locales', async () => { const previous = { diff --git a/apps/desktop/src/renderer/features/storage-usage/index.ts b/apps/desktop/src/renderer/features/storage-usage/index.ts index 43e97382c2e..d589e0138a4 100644 --- a/apps/desktop/src/renderer/features/storage-usage/index.ts +++ b/apps/desktop/src/renderer/features/storage-usage/index.ts @@ -22,6 +22,7 @@ export { ArchiveRetentionSection } from './ui/archive-retention-section.js'; export { ArchiveRetentionNotices } from './ui/archive-retention-notices.js'; +export { decodeRetentionNoticeState } from './model/retention-notices.js'; export { StorageUsageSection } from './ui/storage-usage-section.js'; export { TaskStorageSize } from './ui/task-storage-size.js'; export { StorageUsageServicesProvider } from './services-context.js'; diff --git a/apps/desktop/src/renderer/features/storage-usage/model/retention-notices.ts b/apps/desktop/src/renderer/features/storage-usage/model/retention-notices.ts index 9f36556713f..a78e21b0eed 100644 --- a/apps/desktop/src/renderer/features/storage-usage/model/retention-notices.ts +++ b/apps/desktop/src/renderer/features/storage-usage/model/retention-notices.ts @@ -22,6 +22,7 @@ import type { RetentionNoticeHost, RetentionNoticeState, StorageUsageHostTarget, export const RETENTION_NOTICE_POLL_MS = 60_000; export const RETENTION_NOTICE_COOLDOWN_MS = 15 * 60_000; +const DISABLED_POLL_MS = 15 * 60_000; export type RetentionNotice = | { readonly kind: 'deletion'; readonly deletion: NonNullable } @@ -36,6 +37,9 @@ export function decodeRetentionNoticeState(value: unknown): RetentionNoticeState ...(time(record.deletionAt) ? { deletionAt: record.deletionAt } : {}), ...(time(record.notifiedAt) ? { notifiedAt: record.notifiedAt } : {}), ...(typeof record.warning === 'string' ? { warning: record.warning } : {}), + ...(typeof record.acknowledgedWarning === 'string' + ? { acknowledgedWarning: record.acknowledgedWarning } + : {}), }; } @@ -59,9 +63,10 @@ export function acknowledgeRetentionResults( const next = { ...previous, ...(deletionAt >= 0 ? { deletionAt } : {}), - ...(warning ? { warning } : {}), + ...(warning ? { warning, acknowledgedWarning: warning } : {}), }; - if (next.deletionAt !== previous.deletionAt || next.warning !== previous.warning) notices.writeSeen(host.hostId, next); + if (next.deletionAt !== previous.deletionAt || next.warning !== previous.warning || + next.acknowledgedWarning !== previous.acknowledgedWarning) notices.writeSeen(host.hostId, next); } /** @@ -84,6 +89,7 @@ export function observeRetentionNotices(input: { return () => clearTimeout(timer); }); const seen = new Map(); + const disabledReadAt = new Map(); const warnings = new Map void }>(); let closed = false; let version = 0; @@ -106,6 +112,9 @@ export function observeRetentionNotices(input: { const hosts = await services.notices.loadHosts(); if (closed || started !== version) return; const present = new Set(hosts.map((host) => host.hostId)); + for (const hostId of disabledReadAt.keys()) { + if (!present.has(hostId)) disabledReadAt.delete(hostId); + } for (const [hostId, active] of warnings) { if (!present.has(hostId)) { active.dismiss(); @@ -117,6 +126,17 @@ export function observeRetentionNotices(input: { if (closed || started !== version || !services.notices.isVisible()) return; if (visited.has(host.hostId)) continue; visited.add(host.hostId); + const acknowledged = decodeRetentionNoticeState(services.notices.readSeen(host.hostId)); + const previousWarning = warnings.get(host.hostId); + if (previousWarning && previousWarning.key === acknowledged.acknowledgedWarning) { + previousWarning.dismiss(); + warnings.delete(host.hostId); + } + const disabledAt = disabledReadAt.get(host.hostId); + const elapsed = disabledAt === undefined ? undefined : now() - disabledAt; + // Focus changes do not turn the default-disabled policy into a full + // candidate scan every minute. A Client clock rollback forces a read. + if (elapsed !== undefined && elapsed >= 0 && elapsed < DISABLED_POLL_MS) continue; let retention: StorageRetentionQueryResult; try { retention = await services.loadRetention(host); @@ -125,10 +145,17 @@ export function observeRetentionNotices(input: { continue; } if (closed || started !== version || !services.notices.isVisible()) return; + if (retention.enabled) disabledReadAt.delete(host.hostId); + else disabledReadAt.set(host.hostId, now()); const persisted = decodeRetentionNoticeState(services.notices.readSeen(host.hostId)); const cached = seen.get(host.hostId); const deletionAt = Math.max(cached?.deletionAt ?? -1, persisted.deletionAt ?? -1); - let state: RetentionNoticeState = { ...persisted, ...cached, ...(deletionAt >= 0 ? { deletionAt } : {}) }; + let state: RetentionNoticeState = { + ...persisted, + ...cached, + ...(deletionAt >= 0 ? { deletionAt } : {}), + ...(persisted.acknowledgedWarning ? { acknowledgedWarning: persisted.acknowledgedWarning } : {}), + }; const publish = (notice: RetentionNotice, next: RetentionNoticeState) => { const dismiss = input.notify(host, notice); if (notice.kind !== 'deletion' && next.warning && dismiss) warnings.set(host.hostId, { key: next.warning, dismiss }); @@ -138,7 +165,7 @@ export function observeRetentionNotices(input: { }; const warning = warningKey(retention); const active = warnings.get(host.hostId); - if (active && active.key !== warning) { + if (active && (active.key !== warning || active.key === persisted.acknowledgedWarning)) { active.dismiss(); warnings.delete(host.hostId); } diff --git a/apps/desktop/src/renderer/features/storage-usage/ports.ts b/apps/desktop/src/renderer/features/storage-usage/ports.ts index 8bca8d81307..b06d36e91a9 100644 --- a/apps/desktop/src/renderer/features/storage-usage/ports.ts +++ b/apps/desktop/src/renderer/features/storage-usage/ports.ts @@ -64,6 +64,8 @@ export interface RetentionNoticeHost extends StorageUsageHostTarget { export interface RetentionNoticeState { readonly deletionAt?: number; readonly warning?: string; + /** Unlike announcement, viewing the warning in Settings retires its toast. */ + readonly acknowledgedWarning?: string; /** Client time, used only to limit reminders during a long cleanup backlog. */ readonly notifiedAt?: number; } diff --git a/apps/desktop/src/renderer/platform/desktop/create-storage-usage-services.ts b/apps/desktop/src/renderer/platform/desktop/create-storage-usage-services.ts index 2a8087fb2fe..eba0a7a537a 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-storage-usage-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-storage-usage-services.ts @@ -18,15 +18,26 @@ */ import type { MakaBridge } from '../../../preload/bridge-contract.js'; -import type { StorageUsageServices } from '../../features/storage-usage'; +import { decodeRetentionNoticeState, type StorageUsageServices } from '../../features/storage-usage/index.js'; import { safeLocalStorageGet, safeLocalStorageSet } from './browser-storage.js'; -export type DesktopStorageUsageBridge = Pick; +export type DesktopStorageUsageBridge = Pick & { + readonly runtimeHostProfiles: Pick; +}; /** Binds the storage usage feature to the Desktop bridge. */ export function createDesktopStorageUsageServices( bridge: DesktopStorageUsageBridge = window.maka, ): StorageUsageServices { + const changeListeners = new Set<() => void>(); + const seenKey = (hostId: string) => `maka-retention-notices-v1:${encodeURIComponent(hostId)}`; + const readSeen = (hostId: string): unknown => { + try { + return JSON.parse(safeLocalStorageGet(seenKey(hostId)) ?? 'null'); + } catch { + return undefined; + } + }; return { notices: { async loadHosts() { @@ -36,26 +47,26 @@ export function createDesktopStorageUsageServices( : []); }, subscribeChanges(handler) { + changeListeners.add(handler); const unsubscribe = bridge.runtimeHostProfiles.subscribeChanges(handler); document.addEventListener('visibilitychange', handler); window.addEventListener('focus', handler); - window.addEventListener('blur', handler); return () => { + changeListeners.delete(handler); unsubscribe(); document.removeEventListener('visibilitychange', handler); window.removeEventListener('focus', handler); - window.removeEventListener('blur', handler); }; }, isVisible: () => document.visibilityState !== 'hidden' && document.hasFocus(), - readSeen(hostId) { - try { - return JSON.parse(safeLocalStorageGet(`maka-retention-notices-v1:${encodeURIComponent(hostId)}`) ?? 'null'); - } catch { - return undefined; + readSeen, + writeSeen(hostId, state) { + const previous = decodeRetentionNoticeState(readSeen(hostId)); + safeLocalStorageSet(seenKey(hostId), JSON.stringify(state)); + if (state.acknowledgedWarning && state.acknowledgedWarning !== previous.acknowledgedWarning) { + for (const listener of changeListeners) listener(); } }, - writeSeen: (hostId, state) => safeLocalStorageSet(`maka-retention-notices-v1:${encodeURIComponent(hostId)}`, JSON.stringify(state)), }, loadUsage: (host) => bridge.storage.usage(host), // No host argument: each task is measured by the Host that holds it, and From 8d05fd7b35ef3773309d9eb80f96896a4fdeda86 Mon Sep 17 00:00:00 2001 From: liugddx Date: Tue, 6 Oct 2026 12:55:19 +0800 Subject: [PATCH 3/3] docs: regenerate the Astryx surface inventory for retention notices Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/astryx-surface-file-inventory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 615727801d9..3fa17e25700 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.3` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 330 files — blocker 0, reimplementation 0, polish 4, aligned 326. +**Totals:** 331 files — blocker 0, reimplementation 0, polish 4, aligned 327. ## Exclusions (explicit)