diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 5b1b54c1f7..dbd69ab46e 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -3434,7 +3434,9 @@ "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], - "dependencyPaths": {} + "dependencyPaths": { + "../application/contracts/settings-presentation/runtime-host-settings-target.js": 1 + } }, "src/renderer/settings/settings-status-badge.ts": { "bridgePaths": {}, diff --git a/apps/desktop/src/main/__tests__/module-hub-daily-review-controller.test.ts b/apps/desktop/src/main/__tests__/module-hub-daily-review-controller.test.ts index 218525a58e..2d57bf065c 100644 --- a/apps/desktop/src/main/__tests__/module-hub-daily-review-controller.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-daily-review-controller.test.ts @@ -85,6 +85,131 @@ test('stable page bridge retries rather than exposing a stale default-Host read' assert.deepEqual(reads, ['host-a', 'host-b']); }); +test('page bridge keeps the last successful today snapshot through a failed refresh', async () => { + let fail = false; + let reads = 0; + const services = createFakeModuleHubServices({ + dailyReview: dailyReviewService(async () => { + reads += 1; + if (fail) throw new Error('offline'); + return { ok: true, data: summary(reads) }; + }), + }); + const bridge = createDailyReviewBridge(services, 'en'); + assert.equal(bridge.readCachedDay?.(0, 1), undefined); + const first = await bridge.fetchDay(0, 1); + assert.equal(bridge.readCachedDay?.(0, 1), first); + assert.equal(bridge.readCachedDay?.(-1, 1), undefined); + assert.equal(bridge.readCachedDay?.(0, 7), undefined); + await bridge.fetchDay(-1, 1); + assert.equal(bridge.readCachedDay?.(0, 1), first); + fail = true; + await assert.rejects(bridge.fetchDay(0, 1), /offline/); + assert.equal(bridge.readCachedDay?.(0, 1), first); + fail = false; + const fresh = await bridge.fetchDay(0, 1); + assert.equal(bridge.readCachedDay?.(0, 1), fresh); + assert.equal(reads, 4); +}); + +test('cancelled and superseded reads cannot overwrite a newer cached summary', async () => { + const reads = Array.from({ length: 3 }, () => deferred<{ ok: true; data: DailyReviewSummary }>()); + let index = 0; + const bridge = createDailyReviewBridge(createFakeModuleHubServices({ + dailyReview: dailyReviewService(async () => reads[index++]!.promise), + }), 'en'); + const oldRead = bridge.fetchDay(0, 1); + const newRead = bridge.fetchDay(0, 1); + reads[1]!.resolve({ ok: true, data: summary(8) }); + const fresh = await newRead; + reads[0]!.resolve({ ok: true, data: summary(1) }); + await oldRead; + assert.equal(bridge.readCachedDay?.(0, 1), fresh); + + const request = new AbortController(); + const cancelled = bridge.fetchDay(0, 1, request.signal); + await Promise.resolve(); + request.abort(); + reads[2]!.resolve({ ok: true, data: summary(99) }); + await assert.rejects(cancelled, { name: 'AbortError' }); + assert.equal(bridge.readCachedDay?.(0, 1), fresh); +}); + +test('a new local day cannot reuse yesterday as today or cache an overnight read', async (t) => { + let now = new Date(2026, 8, 18, 23, 59).getTime(); + t.mock.method(Date, 'now', () => now); + const pending = deferred<{ ok: true; data: DailyReviewSummary }>(); + let reads = 0; + const bridge = createDailyReviewBridge(createFakeModuleHubServices({ + dailyReview: dailyReviewService(async () => { + reads += 1; + return reads === 1 ? { ok: true, data: summary() } : pending.promise; + }), + }), 'en'); + await bridge.fetchDay(0, 1); + assert.ok(bridge.readCachedDay?.(0, 1)); + const overnight = bridge.fetchDay(0, 1); + now = new Date(2026, 8, 19, 0, 1).getTime(); + assert.equal(bridge.readCachedDay?.(0, 1), undefined); + pending.resolve({ ok: true, data: summary(99) }); + await overnight; + assert.equal(bridge.readCachedDay?.(0, 1), undefined); +}); + +test('the controller invalidates cached and pending reads on Host changes and disposes its subscription', async () => { + const { root } = installReactRenderer(); + const hostA = { profileId: 'a', hostId: 'a' }; + const hostB = { profileId: 'b', hostId: 'b' }; + let currentHost = hostA; + let listener: Parameters[0] | undefined; + const pending = deferred<{ ok: true; data: DailyReviewSummary }>(); + let reads = 0; + const services = createFakeModuleHubServices({ + runtimeHosts: { + getDefault: async () => currentHost, + subscribeChanges: (handler) => { + listener = handler; + return () => { listener = undefined; }; + }, + }, + dailyReview: dailyReviewService(async () => { + reads += 1; + return reads === 2 ? pending.promise : { ok: true, data: summary(reads) }; + }), + }); + let controller: DailyReviewController | undefined; + function Probe() { + controller = useDailyReviewController({ + services, uiLocale: 'en', + toastApi: { success: () => undefined, error: () => undefined }, + appendComposerText: () => undefined, + captureActiveComposerClaim: () => undefined, + isDailyReviewSurfaceActive: () => false, + }); + return null; + } + await act(async () => root.render(createElement(Probe))); + const bridge = controller!.bridge; + await bridge.fetchDay(0, 1); + await act(async () => root.render(createElement(Probe))); + assert.equal(controller!.bridge, bridge); + assert.ok(bridge.readCachedDay?.(0, 1)); + const loading = bridge.fetchDay(0, 1); + await Promise.resolve(); + currentHost = hostB; + assert.ok(listener); + listener({ ...hostB, readiness: 'ready', isDefault: true }); + assert.equal(bridge.readCachedDay?.(0, 1), undefined); + pending.resolve({ ok: true, data: summary(99) }); + await loading; + assert.equal(bridge.readCachedDay?.(0, 1), undefined); + const fresh = await bridge.fetchDay(0, 1); + assert.equal(bridge.readCachedDay?.(0, 1), fresh); + await act(async () => root.unmount()); + assert.equal(listener, undefined); + assert.equal(bridge.readCachedDay?.(0, 1), undefined); +}); + test('today paste captures its composer claim before reading and drops a late result', async () => { const { root } = installReactRenderer(); const pendingDay = deferred<{ ok: true; data: DailyReviewSummary }>(); diff --git a/apps/desktop/src/main/__tests__/settings-resource-state.test.ts b/apps/desktop/src/main/__tests__/settings-resource-state.test.ts index 22f8bb6dec..1b69ea3176 100644 --- a/apps/desktop/src/main/__tests__/settings-resource-state.test.ts +++ b/apps/desktop/src/main/__tests__/settings-resource-state.test.ts @@ -23,6 +23,11 @@ import { createDefaultSettings, type RuntimeHostAppSettings, } from '@maka/core/settings'; +import type { + CapabilitySnapshotCollection, + PermissionSnapshot, +} from '@maka/core/capabilities'; +import type { HealthSnapshot } from '@maka/core/health'; import { beginSettingsResourceLoad, completeSettingsResourceLoad, @@ -202,7 +207,7 @@ describe('Settings snapshot cache', () => { assert.equal(settingsSnapshotCacheFor(secondBridge).readClient(), undefined); }); - it('isolates settings and connections by selected Runtime Host key', () => { + it('isolates every Runtime Host snapshot by selected Host key', () => { const cache = createSettingsSnapshotCache(); const localSettings = runtimeHostSettings(); const remoteSettings = { @@ -213,15 +218,30 @@ describe('Settings snapshot cache', () => { }, }; const localConnections = { connections: [], defaultSlug: 'local-default' }; + const localHealth: HealthSnapshot = { + checkedAt: 1, + signals: [], + summary: { ok: 0, info: 0, warning: 0, error: 0, unknown: 0 }, + }; + const localPermissionCenter = { + permissions: { checkedAt: 1, platform: 'darwin', permissions: {} } as PermissionSnapshot, + capabilities: { checkedAt: 1, capabilities: [] } as CapabilitySnapshotCollection, + }; cache.commitRuntimeHostSettingsRead(LOCAL_KEY, localSettings); cache.commitRuntimeHostSettingsRead(REMOTE_KEY, remoteSettings); cache.commitRuntimeHostConnectionsRead(LOCAL_KEY, localConnections); + cache.commitRuntimeHostHealthRead(LOCAL_KEY, localHealth); + cache.commitRuntimeHostPermissionCenterRead(LOCAL_KEY, localPermissionCenter); assert.equal(cache.readRuntimeHostSettings(LOCAL_KEY), localSettings); assert.equal(cache.readRuntimeHostSettings(REMOTE_KEY), remoteSettings); assert.equal(cache.readRuntimeHostConnections(LOCAL_KEY), localConnections); assert.equal(cache.readRuntimeHostConnections(REMOTE_KEY), undefined); + assert.equal(cache.readRuntimeHostHealth(LOCAL_KEY), localHealth); + assert.equal(cache.readRuntimeHostHealth(REMOTE_KEY), undefined); + assert.equal(cache.readRuntimeHostPermissionCenter(LOCAL_KEY), localPermissionCenter); + assert.equal(cache.readRuntimeHostPermissionCenter(REMOTE_KEY), undefined); }); it('prunes snapshots when a profile reconnects with a new host id', () => { @@ -231,6 +251,15 @@ describe('Settings snapshot cache', () => { connections: [], defaultSlug: null, }); + cache.commitRuntimeHostHealthRead(LOCAL_KEY, { + checkedAt: 1, + signals: [], + summary: { ok: 0, info: 0, warning: 0, error: 0, unknown: 0 }, + }); + cache.commitRuntimeHostPermissionCenterRead(LOCAL_KEY, { + permissions: { checkedAt: 1, platform: 'darwin', permissions: {} } as PermissionSnapshot, + capabilities: { checkedAt: 1, capabilities: [] }, + }); cache.commitRuntimeHostCatalogRead(catalog([ { @@ -244,6 +273,8 @@ describe('Settings snapshot cache', () => { assert.equal(cache.readRuntimeHostSettings(LOCAL_KEY), undefined); assert.equal(cache.readRuntimeHostConnections(LOCAL_KEY), undefined); + assert.equal(cache.readRuntimeHostHealth(LOCAL_KEY), undefined); + assert.equal(cache.readRuntimeHostPermissionCenter(LOCAL_KEY), undefined); }); it('stores settings and connection reads independently', () => { diff --git a/apps/desktop/src/renderer/application/contracts/settings-presentation/runtime-host-settings-target.tsx b/apps/desktop/src/renderer/application/contracts/settings-presentation/runtime-host-settings-target.tsx index 28ca53082b..70aa3d2e40 100644 --- a/apps/desktop/src/renderer/application/contracts/settings-presentation/runtime-host-settings-target.tsx +++ b/apps/desktop/src/renderer/application/contracts/settings-presentation/runtime-host-settings-target.tsx @@ -28,6 +28,10 @@ import { import { useToast } from "@maka/ui"; export interface SettingsHostTarget { readonly profileId: string; readonly hostId: string; } +export function runtimeHostSettingsKey(host: SettingsHostTarget): string { + return `${host.profileId}:${host.hostId}`; +} + interface RuntimeHostSettingsTargetValue { readonly host: SettingsHostTarget; /** diff --git a/apps/desktop/src/renderer/features/module-hub/README.md b/apps/desktop/src/renderer/features/module-hub/README.md index c6b56e0ecc..37d79a7bf5 100644 --- a/apps/desktop/src/renderer/features/module-hub/README.md +++ b/apps/desktop/src/renderer/features/module-hub/README.md @@ -81,6 +81,9 @@ reach it only through `testing.ts`. - The Daily Review page bridge is stable for one services/locale pair. Page feedback is live-surface fenced, while Command Palette commands remain usable off-page. +- That bridge retains the last successful today snapshot across leaf mounts; + every mount still revalidates. Host lifecycle events and local-day rollover + invalidate it. Cancelled, superseded, and pre-invalidation reads cannot commit. - Daily Review paste captures the active Composer before its first await and validates the Session, navigation owner, and Composer handle before append and feedback. diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-daily-review-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-daily-review-controller.ts index 64b68812c9..95e4686868 100644 --- a/apps/desktop/src/renderer/features/module-hub/controller/use-daily-review-controller.ts +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-daily-review-controller.ts @@ -17,7 +17,8 @@ * under the License. */ -import { useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; +import { localDayBoundsAt } from '@maka/core/daily-review'; import type { DailyReviewArchive, DailyReviewArchiveSummary, @@ -67,7 +68,8 @@ export interface ActiveComposerClaim { /** Structural equivalent of the UI bridge; kept here so @maka/ui stays leaf-only. */ export interface DailyReviewBridge { - fetchDay(offsetDays: number, daySpan?: number): Promise; + readCachedDay?(offsetDays: number, daySpan?: number): DailyReviewSummary | undefined; + fetchDay(offsetDays: number, daySpan?: number, signal?: AbortSignal): Promise; runOnce?(input: { range: DailyReviewRange; offsetDays?: number; @@ -147,11 +149,30 @@ async function readCurrentDefaultHost( export function createDailyReviewBridge( services: ModuleHubServices, locale: UiLocale, -): DailyReviewBridge { +): DailyReviewBridge & { invalidateCache(): void } { const copy = getShellRemainingCopy(locale).dailyReview; + // Every leaf mount starts on today. Keep only that snapshot, rather than + // retaining an unbounded history of date/range selections in the renderer. + let cachedToday: { dayStart: number; summary: DailyReviewSummary } | undefined; + let generation = 0; + let latestTodayRead = 0; return { - async fetchDay(offsetDays: number, daySpan?: number) { - return readCurrentDefaultHost(services, async (host) => { + invalidateCache() { + cachedToday = undefined; + generation += 1; + }, + readCachedDay(offsetDays, daySpan = 1) { + if (offsetDays !== 0 || daySpan !== 1) return undefined; + if (cachedToday?.dayStart !== localDayBoundsAt(Date.now(), 0).fromMs) return undefined; + return cachedToday.summary; + }, + async fetchDay(offsetDays: number, daySpan = 1, signal?: AbortSignal) { + const cachesToday = offsetDays === 0 && daySpan === 1; + const read = cachesToday ? ++latestTodayRead : 0; + const readGeneration = generation; + const dayStart = localDayBoundsAt(Date.now(), 0).fromMs; + const summary = await readCurrentDefaultHost(services, async (host) => { + signal?.throwIfAborted(); const result = await services.dailyReview.day( offsetDays, daySpan, @@ -160,6 +181,12 @@ export function createDailyReviewBridge( if (!result.ok) throw new Error(result.error.message); return result.data; }); + signal?.throwIfAborted(); + if ( + cachesToday && read === latestTodayRead && readGeneration === generation + && dayStart === localDayBoundsAt(Date.now(), 0).fromMs + ) cachedToday = { dayStart, summary }; + return summary; }, runOnce(input) { return services.dailyReview.runOnce(input); @@ -186,6 +213,13 @@ export function useDailyReviewController( () => createDailyReviewBridge(input.services, input.uiLocale), [input.services, input.uiLocale], ); + useEffect(() => { + const unsubscribe = input.services.runtimeHosts.subscribeChanges(() => bridge.invalidateCache()); + return () => { + unsubscribe(); + bridge.invalidateCache(); + }; + }, [bridge, input.services.runtimeHosts]); return useMemo(() => { const copy = getShellCopy(input.uiLocale).commandActions; diff --git a/apps/desktop/src/renderer/settings/health-center-page.tsx b/apps/desktop/src/renderer/settings/health-center-page.tsx index f52158751f..53f2784536 100644 --- a/apps/desktop/src/renderer/settings/health-center-page.tsx +++ b/apps/desktop/src/renderer/settings/health-center-page.tsx @@ -35,7 +35,7 @@ import { settingsActionErrorMessage } from './settings-error-copy'; import { SettingsPage, SettingsRow, SettingsSection } from './settings-section'; import { SettingsSkeletonStack } from './settings-skeleton'; import { dotForStatus } from '@maka/ui'; -import { useRuntimeHostSettingsTarget } from './runtime-host-settings-target.js'; +import { runtimeHostSettingsKey, useRuntimeHostSettingsTarget } from './runtime-host-settings-target.js'; import { SettingsStatusSummaryFilter, type SettingsStatusSummaryOption, @@ -56,12 +56,17 @@ import { * Read-only boundary: no test buttons, no repair flows. Test/repair entries * will be wired in PR-HC-2 once typed actions are exposed. */ -export function HealthCenterPage() { +export function HealthCenterPage(props: { + initialSnapshot?: HealthSnapshot; + onSnapshot(key: string, snapshot: HealthSnapshot): void; +}) { const host = useRuntimeHostSettingsTarget(); const locale = useUiLocale(); const copy = getHealthCenterCopy(locale); - const [snapshot, setSnapshot] = useState(null); - const [loading, setLoading] = useState(true); + const [snapshot, setSnapshot] = useState( + () => props.initialSnapshot ?? null, + ); + const [loading, setLoading] = useState(snapshot === null); const [error, setError] = useState(null); const [refreshTick, setRefreshTick] = useState(0); const [signalFilter, setSignalFilter] = useState(null); @@ -74,6 +79,7 @@ export function HealthCenterPage() { .getSnapshot(host) .then((next) => { if (cancelled) return; + props.onSnapshot(runtimeHostSettingsKey(host), next); setSnapshot(next); setLoading(false); }) @@ -85,7 +91,7 @@ export function HealthCenterPage() { return () => { cancelled = true; }; - }, [host, locale, refreshTick]); + }, [host, locale, props.onSnapshot, refreshTick]); useEffect(() => { if (!snapshot) return; @@ -95,17 +101,18 @@ export function HealthCenterPage() { }); }, [snapshot]); - if (loading) { + if (loading && !snapshot) { return ( ); } - if (error || !snapshot) { + if (!snapshot) { return ( setRefreshTick((tick) => tick + 1)} label={copy.readAgain} />} /> @@ -130,6 +137,15 @@ export function HealthCenterPage() { return ( + {error ? ( + setRefreshTick((tick) => tick + 1)} label={copy.readAgain} />} + /> + ) : null} setRefreshTick((tick) => tick + 1)} + isLoading={loading} label={copy.refresh} /> diff --git a/apps/desktop/src/renderer/settings/permission-center-page.tsx b/apps/desktop/src/renderer/settings/permission-center-page.tsx index 10fb401926..e851a79b71 100644 --- a/apps/desktop/src/renderer/settings/permission-center-page.tsx +++ b/apps/desktop/src/renderer/settings/permission-center-page.tsx @@ -61,11 +61,13 @@ import { getPermissionCenterCopy, type PermissionCenterCopy } from '../locales/p import { botStatusReasonCopy } from '../locales/settings-bot-copy'; import { settingsActionErrorMessage } from './settings-error-copy'; import { + runtimeHostSettingsKey, useRuntimeHostSettingsErrorReporter, useRuntimeHostSettingsTarget, } from './runtime-host-settings-target.js'; import { dotForStatus } from '@maka/ui'; import { SettingsSkeletonStack } from './settings-skeleton'; +import type { PermissionCenterSnapshot } from './settings-snapshot-cache.js'; import { useActionGuard } from './use-action-guard'; import { SettingsStatusSummaryFilter, @@ -100,13 +102,20 @@ const OS_PERMISSION_ICONS: Record> = type PermissionStatusFilter = 'granted' | 'pending' | 'denied' | 'other'; -export function PermissionCenterPage() { +export function PermissionCenterPage(props: { + initialSnapshot?: PermissionCenterSnapshot; + onSnapshot(key: string, snapshot: PermissionCenterSnapshot): void; +}) { const host = useRuntimeHostSettingsTarget(); const locale = useUiLocale(); const copy = getPermissionCenterCopy(locale); - const [permissions, setPermissions] = useState(null); - const [capabilities, setCapabilities] = useState(null); - const [loading, setLoading] = useState(true); + const [permissions, setPermissions] = useState( + () => props.initialSnapshot?.permissions ?? null, + ); + const [capabilities, setCapabilities] = useState( + () => props.initialSnapshot?.capabilities ?? null, + ); + const [loading, setLoading] = useState(permissions === null || capabilities === null); const [error, setError] = useState(null); const [refreshTick, setRefreshTick] = useState(0); const [pendingPermAction, setPendingPermAction] = useState(null); @@ -125,6 +134,10 @@ export function PermissionCenterPage() { ]) .then(([perm, caps]) => { if (cancelled) return; + props.onSnapshot(runtimeHostSettingsKey(host), { + permissions: perm, + capabilities: caps, + }); setPermissions(perm); setCapabilities(caps); setLoading(false); @@ -137,7 +150,7 @@ export function PermissionCenterPage() { return () => { cancelled = true; }; - }, [host, locale, refreshTick]); + }, [host, locale, props.onSnapshot, refreshTick]); useEffect(() => { const refreshAfterSystemSettings = () => { @@ -203,13 +216,13 @@ export function PermissionCenterPage() { } } - if (loading) { + if (loading && (!permissions || !capabilities)) { return ( ); } - if (error || !permissions || !capabilities) { + if (!permissions || !capabilities) { return ( + {error ? ( + setRefreshTick((tick) => tick + 1)} label={copy.readAgain} /> + )} + /> + ) : null} {/* The page opened with a SectionHeader whose title was the page title VERBATIM (权限与能力 twice, ~40px apart) and whose subtitle restated the 系统权限 section's own help line below it. Worse, the refresh @@ -260,6 +284,7 @@ export function PermissionCenterPage() { variant="secondary" size="sm" onClick={() => setRefreshTick((tick) => tick + 1)} + isLoading={loading} label={copy.detectAgain} /> diff --git a/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts b/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts index b06a09041e..2ad790af29 100644 --- a/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts +++ b/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts @@ -18,19 +18,27 @@ */ import type { AppSettings, RuntimeHostAppSettings } from '@maka/core/settings'; +import type { ProjectedLlmConnection } from '@maka/core/llm-connections'; import type { - ProjectedLlmConnection, -} from '@maka/core/llm-connections'; -import type { - DesktopRuntimeHostProfileSnapshot, - DesktopRuntimeHostRef, -} from '../../preload/bridge-contract.js'; + CapabilitySnapshotCollection, + PermissionSnapshot, +} from '@maka/core/capabilities'; +import type { HealthSnapshot } from '@maka/core/health'; +import type { DesktopRuntimeHostProfileSnapshot } from '../../preload/bridge-contract.js'; +import { runtimeHostSettingsKey } from '../application/contracts/settings-presentation/runtime-host-settings-target.js'; + +export { runtimeHostSettingsKey }; export interface RuntimeHostConnectionsSnapshot { readonly connections: ProjectedLlmConnection[]; readonly defaultSlug: string | null; } +export interface PermissionCenterSnapshot { + readonly permissions: PermissionSnapshot; + readonly capabilities: CapabilitySnapshotCollection; +} + export interface SettingsSnapshotCache { readClient(): AppSettings | undefined; commitClientRead(snapshot: AppSettings): void; @@ -46,14 +54,19 @@ export interface SettingsSnapshotCache { key: string, snapshot: RuntimeHostConnectionsSnapshot, ): void; -} -export function runtimeHostSettingsKey(host: DesktopRuntimeHostRef): string { - return `${host.profileId}:${host.hostId}`; + readRuntimeHostHealth(key: string): HealthSnapshot | undefined; + commitRuntimeHostHealthRead(key: string, snapshot: HealthSnapshot): void; + + readRuntimeHostPermissionCenter(key: string): PermissionCenterSnapshot | undefined; + commitRuntimeHostPermissionCenterRead( + key: string, + snapshot: PermissionCenterSnapshot, + ): void; } /** - * Renderer-memory cache for masked read snapshots. It deliberately has no + * Renderer-memory cache for successful Settings reads. It deliberately has no * method that accepts a settings mutation response: update responses may * reveal the just-submitted secret, while subsequent GETs are masked again. */ @@ -62,6 +75,8 @@ export function createSettingsSnapshotCache(): SettingsSnapshotCache { let runtimeHostCatalog: DesktopRuntimeHostProfileSnapshot | undefined; const runtimeHostSettings = new Map(); const runtimeHostConnections = new Map(); + const runtimeHostHealth = new Map(); + const runtimeHostPermissionCenter = new Map(); return { readClient: () => client, @@ -84,6 +99,12 @@ export function createSettingsSnapshotCache(): SettingsSnapshotCache { for (const key of runtimeHostConnections.keys()) { if (!currentHostKeys.has(key)) runtimeHostConnections.delete(key); } + for (const key of runtimeHostHealth.keys()) { + if (!currentHostKeys.has(key)) runtimeHostHealth.delete(key); + } + for (const key of runtimeHostPermissionCenter.keys()) { + if (!currentHostKeys.has(key)) runtimeHostPermissionCenter.delete(key); + } }, readRuntimeHostSettings: (key) => runtimeHostSettings.get(key), commitRuntimeHostSettingsRead: (key, snapshot) => { @@ -93,6 +114,14 @@ export function createSettingsSnapshotCache(): SettingsSnapshotCache { commitRuntimeHostConnectionsRead: (key, snapshot) => { runtimeHostConnections.set(key, snapshot); }, + readRuntimeHostHealth: (key) => runtimeHostHealth.get(key), + commitRuntimeHostHealthRead: (key, snapshot) => { + runtimeHostHealth.set(key, snapshot); + }, + readRuntimeHostPermissionCenter: (key) => runtimeHostPermissionCenter.get(key), + commitRuntimeHostPermissionCenterRead: (key, snapshot) => { + runtimeHostPermissionCenter.set(key, snapshot); + }, }; } diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx index bd7cc9199a..24f6175712 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -64,6 +64,7 @@ import type { DesktopSessionSummary, } from '../../preload/bridge-contract.js'; import type { UiLocalePreference } from '@maka/core/ui-locale'; +import type { HealthSnapshot } from '@maka/core/health'; import { createDefaultSettings, DEFAULT_APP_ICON } from '@maka/core/settings'; import { Banner, @@ -131,6 +132,7 @@ import { import { runtimeHostSettingsKey, settingsSnapshotCacheFor, + type PermissionCenterSnapshot, type RuntimeHostConnectionsSnapshot, type SettingsSnapshotCache, } from './settings-snapshot-cache.js'; @@ -1079,6 +1081,14 @@ function SettingsSurfaceContent( archivedTasks={props.archivedTasks} onTaskImported={props.onTaskImported} onRemoteHostAdded={props.onRemoteHostAdded} + healthSnapshot={selectedRuntimeHostKey + ? snapshotCache.readRuntimeHostHealth(selectedRuntimeHostKey) + : undefined} + permissionCenterSnapshot={selectedRuntimeHostKey + ? snapshotCache.readRuntimeHostPermissionCenter(selectedRuntimeHostKey) + : undefined} + onHealthSnapshot={snapshotCache.commitRuntimeHostHealthRead} + onPermissionCenterSnapshot={snapshotCache.commitRuntimeHostPermissionCenterRead} openProviderCatalog={providerCatalogRequested} initialConnectionSlug={props.initialConnectionSlug} initialCreateProviderType={createProviderRequest} @@ -1141,6 +1151,10 @@ function SettingsPageBody(props: { archivedTasks: ArchivedTasksBridge; onTaskImported(session: DesktopSessionSummary): void; onRemoteHostAdded(profileId: string): void; + healthSnapshot?: HealthSnapshot; + permissionCenterSnapshot?: PermissionCenterSnapshot; + onHealthSnapshot: Parameters[0]['onSnapshot']; + onPermissionCenterSnapshot: Parameters[0]['onSnapshot']; openProviderCatalog?: boolean; initialConnectionSlug?: string; initialCreateProviderType?: ProviderType; @@ -1269,9 +1283,19 @@ function SettingsPageBody(props: { /> ); case 'permissions': - return ; + return ( + + ); case 'health': - return ; + return ( + + ); case 'memory': // PR-SETTINGS-REVIEW-0 (WAWQAQ msg `886f6406`): the merged // memory-review page was too dense; 记忆 is its own page again. diff --git a/apps/desktop/src/renderer/styles/daily-review.css b/apps/desktop/src/renderer/styles/daily-review.css index 614f5a7750..377894d584 100644 --- a/apps/desktop/src/renderer/styles/daily-review.css +++ b/apps/desktop/src/renderer/styles/daily-review.css @@ -24,7 +24,6 @@ .maka-daily-review-report, .maka-daily-review-content { min-width: 0; - animation: maka-daily-review-enter var(--duration-base) var(--ease-out-strong) both; } .maka-daily-review-content { @@ -39,23 +38,6 @@ gap: var(--space-5); } -@keyframes maka-daily-review-enter { - from { - opacity: 0.9; - } - - to { - opacity: 1; - } -} - -@media (prefers-reduced-motion: reduce) { - .maka-daily-review-report, - .maka-daily-review-content { - animation: none; - } -} - .maka-daily-review-range-label { min-width: 9rem; text-align: center; diff --git a/apps/desktop/stories/module-hubs.stories.tsx b/apps/desktop/stories/module-hubs.stories.tsx index bbcc32835d..65091970d6 100644 --- a/apps/desktop/stories/module-hubs.stories.tsx +++ b/apps/desktop/stories/module-hubs.stories.tsx @@ -18,6 +18,7 @@ */ import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, waitFor } from 'storybook/test'; import type { DailyReviewArchive, DailyReviewSummary } from '@maka/core/daily-review'; import type { ScheduledTask, ScheduledTaskRun } from '@maka/core/scheduled-task'; import type { McpConfigFile, McpServerStatus } from '@maka/core/mcp'; @@ -27,6 +28,7 @@ import { DailyReviewPage, getSharedUiCopy, ModuleHubSelector, + type NavSelection, SkillsPage, type ManagedSkillUpdatePreview, type SkillEntry, @@ -45,6 +47,7 @@ import { import { createFakeModuleHubHostModel, createFakeModuleHubServices, + type ModuleHubServices, } from '../src/renderer/features/module-hub/testing'; import { AppShellDetailPanel } from '../src/renderer/app-shell-detail-panel'; import { McpPage } from '../src/renderer/mcp-page'; @@ -808,9 +811,15 @@ function ModuleHubHostSurface(props: { | { section: 'extensions'; module: 'skills' | 'mcp' } | { section: 'automations'; module: 'scheduled-tasks' | 'daily-review' }; }) { - const base = createFakeModuleHubHostModel(props.selection); + const [selection, setSelection] = useState(props.selection); + const base = createFakeModuleHubHostModel(selection); const model = { ...base, + selectModule: (next: NavSelection) => { + if (next.section === 'extensions' || next.section === 'automations') { + setSelection(next); + } + }, skills: { ...base.skills, skills: INSTALLED_SKILLS, @@ -827,9 +836,9 @@ function ModuleHubHostSurface(props: { }, }, }; - const agentsView = props.selection.section === 'extensions' - ? props.selection.module - : props.selection.module === 'daily-review' + const agentsView = selection.section === 'extensions' + ? selection.module + : selection.module === 'daily-review' ? 'daily-review' : 'cron'; return ( @@ -839,7 +848,13 @@ function ModuleHubHostSurface(props: { ); } -function ProductionModuleHubHostSurface() { +function ProductionModuleHubHostSurface(props: { + initialSelection?: ComponentProps['selection']; + dailyReviewDay?: ModuleHubServices['dailyReview']['day']; +}) { + const [selection, setSelection] = useState( + props.initialSelection ?? { section: 'extensions', module: 'skills' }, + ); const [commandPort] = useState(createModuleHubCommandPort); const [services] = useState(() => { const defaults = createFakeModuleHubServices(); @@ -849,14 +864,21 @@ function ProductionModuleHubHostSurface() { list: async () => INSTALLED_SKILLS, listBundledCatalog: async () => BUNDLED_SKILLS, }, + scheduledTasks: { ...defaults.scheduledTasks, list: async () => CONFIGURED_TASKS }, + dailyReview: { + ...defaults.dailyReview, + day: props.dailyReviewDay ?? defaults.dailyReview.day, + }, }); }); return ( - + = []; +const delayedDailyReviewDay: ModuleHubServices['dailyReview']['day'] = () => + new Promise((resolve, reject) => pendingDailyReviewReads.push({ resolve, reject })); + +async function finishDailyReviewRead(fail = false) { + await waitFor(() => expect(pendingDailyReviewReads.length).toBeGreaterThan(0)); + for (const read of pendingDailyReviewReads.splice(0)) { + if (fail) read.reject(new Error('Snapshot request timeout')); + else read.resolve({ ok: true, data: DAILY_REVIEW_SUMMARY }); + } +} + +/** + * Starts on Scheduled Tasks to observe the first switch; `play` leaves Daily + * Review visible for the visual catalog. With autoplay disabled, the initial + * Scheduled Tasks page is intentional. + */ +// Real path: sidebar → 定时任务 → 每日回顾. export const HostAutomationsDailyReview: Story = { + beforeEach: () => { pendingDailyReviewReads = []; }, render: () => ( - ), + play: async ({ canvasElement }) => { + const dailyReview = await waitForStoryButton( + canvasElement, + (candidate) => candidate.textContent?.includes('每日回顾') === true, + ); + let lazyFallbacks = 0; + let skeletons = 0; + let dailyReviewEntryAnimations = 0; + const mutationObserver = new MutationObserver((records) => { + for (const record of records) { + for (const node of record.addedNodes) { + if (!(node instanceof Element)) continue; + if (node.matches('[data-daily-review-loading]')) skeletons += 1; + skeletons += node.querySelectorAll('[data-daily-review-loading]').length; + const fallbacks = [ + ...(node.matches('.maka-lazy-fallback') ? [node] : []), + ...node.querySelectorAll('.maka-lazy-fallback'), + ]; + for (const fallback of fallbacks) { + // Ignore Scheduled Tasks even if its chunk settles after play + // starts. The target also covers an added fallback removed again + // before this observer callback runs. + if ( + fallback.closest('[data-module="daily-review"]') + || (record.target instanceof Element + && record.target.closest('[data-module="daily-review"]')) + ) lazyFallbacks += 1; + } + } + } + }); + const onAnimationStart = (event: Event) => { + if ( + event instanceof AnimationEvent + && event.animationName === 'maka-daily-review-enter' + ) { + dailyReviewEntryAnimations += 1; + } + }; + mutationObserver.observe(canvasElement, { childList: true, subtree: true }); + canvasElement.addEventListener('animationstart', onAnimationStart, true); + try { + dailyReview.click(); + const loading = await waitForStorySelector(canvasElement, '[data-daily-review-loading]'); + // A cold read has one stable loading state until the real service seam + // resolves. It must survive an actual paint, not a microtask-only mock. + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + expect(loading).toBeVisible(); + expect(canvasElement.querySelector('.maka-daily-review-content')).toBeNull(); + expect(skeletons).toBe(1); + await finishDailyReviewRead(); + const content = await waitForStorySelector( + canvasElement, + '.maka-daily-review-content', + ); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + expect(canvasElement.querySelector('[data-daily-review-loading]')).toBeNull(); + expect(getComputedStyle(content).animationName).toBe('none'); + + const scheduledTasks = await waitForStoryButton(canvasElement, + (candidate) => candidate.textContent?.includes('定时任务') === true); + scheduledTasks.click(); + await waitForStorySelector(canvasElement, '[data-module="scheduled-tasks"]'); + const revisit = await waitForStoryButton(canvasElement, + (candidate) => candidate.textContent?.includes('每日回顾') === true); + revisit.click(); + await waitForStorySelector(canvasElement, '[data-module="daily-review"]'); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + expect(skeletons).toBe(1); + const cached = await waitForStorySelector(canvasElement, '.maka-daily-review-content'); + expect(cached).toBeVisible(); + expect(cached).toHaveAttribute('data-refreshing', 'true'); + expect(canvasElement.querySelector('[data-daily-review-loading]')).toBeNull(); + expect(skeletons).toBe(1); + await finishDailyReviewRead(true); + await waitFor(() => expect(cached).toHaveAttribute('aria-busy', 'false')); + expect(cached).toBeVisible(); + const retry = await waitForStoryButton(canvasElement, + (candidate) => candidate.textContent?.trim() === '重试'); + retry.click(); + await finishDailyReviewRead(); + await waitFor(() => expect(cached).toHaveAttribute('aria-busy', 'false')); + expect(skeletons).toBe(1); + expect(lazyFallbacks).toBe(0); + expect(dailyReviewEntryAnimations).toBe(0); + expect(getComputedStyle(cached).animationName).toBe('none'); + } finally { + mutationObserver.disconnect(); + canvasElement.removeEventListener('animationstart', onAnimationStart, true); + } + }, }; // Real path: sidebar → 扩展 → 技能, with several installed Skills. @@ -1447,7 +1583,8 @@ export const ScheduledDailyReviewGenerationFailed: Story = { play: async ({ canvasElement }) => { const generate = await waitForStoryButton( canvasElement, - (candidate) => candidate.textContent?.includes('生成分析') === true, + (candidate) => + candidate.textContent?.includes('生成分析') === true && !candidate.disabled, ); generate.click(); await waitForStoryText(canvasElement, '生成失败'); diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 034bf29df8..ddb290f59a 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -97,6 +97,8 @@ import { withScopedMakaBridge } from '../maka-bridge'; import { getDailyReviewSettingsCopy } from '../../src/renderer/locales/settings-daily-review-copy'; import { getExternalSessionImportCopy } from '../../src/renderer/locales/external-session-import-copy'; import { getUsageSettingsCopy } from '../../src/renderer/locales/settings-usage-copy'; +import { getHealthCenterCopy } from '../../src/renderer/locales/settings-health-copy'; +import { getPermissionCenterCopy } from '../../src/renderer/locales/permission-center-copy'; /** * Read from the copy table, not typed out again. This selector matched a @@ -772,6 +774,15 @@ function seedGeneralSnapshotCache(cache: SettingsSnapshotCache): void { }); } +function seedSettingsPageTransitionSnapshotCache(cache: SettingsSnapshotCache): void { + seedGeneralSnapshotCache(cache); + cache.commitRuntimeHostHealthRead(STORY_RUNTIME_HOST_KEY, healthSnapshot); + cache.commitRuntimeHostPermissionCenterRead(STORY_RUNTIME_HOST_KEY, { + permissions: permissionSnapshot, + capabilities: capabilitySnapshot, + }); +} + function seedCopilotGenerationSnapshotCache(cache: SettingsSnapshotCache): void { seedGeneralSnapshotCache(cache); cache.commitRuntimeHostConnectionsRead(STORY_RUNTIME_HOST_KEY, { @@ -1003,6 +1014,85 @@ const makaBridge = { const withSettingsBridge = withScopedMakaBridge(makaBridge); +const SETTINGS_PAGE_TRANSITION_DELAY_MS = 80; +const waitForSettingsPageTransitionRead = () => + new Promise((resolve) => { + globalThis.setTimeout(resolve, SETTINGS_PAGE_TRANSITION_DELAY_MS); + }); +const settingsPageTransitionBridge = { + ...makaBridge, + health: { + getSnapshot: async () => { + await waitForSettingsPageTransitionRead(); + return healthSnapshot; + }, + }, + permissions: { + ...makaBridge.permissions, + getSnapshot: async () => { + await waitForSettingsPageTransitionRead(); + return permissionSnapshot; + }, + }, + capabilities: { + getSnapshot: async () => { + await waitForSettingsPageTransitionRead(); + return capabilitySnapshot; + }, + }, +} satisfies Record; +const withSettingsPageTransitionBridge = withScopedMakaBridge(settingsPageTransitionBridge); + +// Hold each read until play has observed the cached page and its refresh +// control. This covers an indefinitely pending request without timer races. +function controlledSettingsRead() { + let pending: Array<{ resolve(value: T): void; reject(error: Error): void }> = []; + return { + getSnapshot: () => new Promise((resolve, reject) => pending.push({ resolve, reject })), + resolve(snapshot: T) { + const reads = pending.splice(0); + expect(reads.length).toBeGreaterThan(0); + for (const read of reads) read.resolve(snapshot); + }, + reject() { + const reads = pending.splice(0); + expect(reads.length).toBeGreaterThan(0); + for (const read of reads) read.reject(new Error('Snapshot request timeout')); + }, + reset() { pending = []; }, + }; +} + +const healthRefreshRead = controlledSettingsRead(); +const permissionRefreshRead = controlledSettingsRead(); +const capabilityRefreshRead = controlledSettingsRead(); +const withSettingsRefreshFailureBridge = withScopedMakaBridge({ + ...makaBridge, + health: { getSnapshot: healthRefreshRead.getSnapshot }, + permissions: { ...makaBridge.permissions, getSnapshot: permissionRefreshRead.getSnapshot }, + capabilities: { getSnapshot: capabilityRefreshRead.getSnapshot }, +} satisfies Record); + +function observeSettingsSkeletons(canvasElement: HTMLElement) { + let inserted = 0; + const inspect = (records: MutationRecord[]) => { + for (const record of records) { + for (const node of record.addedNodes) { + if (!(node instanceof Element)) continue; + if (node.matches('.settingsSkeletonStack')) inserted += 1; + inserted += node.querySelectorAll('.settingsSkeletonStack').length; + } + } + }; + const observer = new MutationObserver(inspect); + observer.observe(canvasElement, { childList: true, subtree: true }); + return () => { + inspect(observer.takeRecords()); + observer.disconnect(); + expect(inserted).toBe(0); + }; +} + let typographyStoryDefaultSlug: string | null = 'zai-live'; let typographyStorySelectedPetId: string | null = 'storybook.typography-pet'; @@ -3339,14 +3429,38 @@ export const PermissionCenterDiagnosticsExpanded: Story = { } }, }; +/** + * Starts on About so play can observe navigation into the cached Permissions + * and Health pages. Play leaves Health visible for the visual catalog; + * disabling autoplay intentionally shows the initial About page. + */ // Real path: 设置 → 健康 (also reachable from the topbar health action), with probes // reporting. export const HealthCenter: Story = { - decorators: [withSettingsBridge], - render: () => , + decorators: [withSettingsPageTransitionBridge], + render: () => ( + + ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const errorFilter = await canvas.findByRole('button', { name: /^仅显示错误健康信号/ }); + // Both Host-scoped snapshots are already known when navigation starts. + // A delayed revalidation makes the old full-page loading placeholder + // observable instead of depending on IPC timing or a paint screenshot. + await canvas.findByRole('heading', { name: '支持' }); + const assertNoSkeletons = observeSettingsSkeletons(canvasElement); + try { + await userEvent.click(canvas.getByRole('button', { name: /^权限与能力$/ })); + await canvas.findByRole('button', { name: /^仅显示已授权权限/ }); + await userEvent.click(canvas.getByRole('button', { name: /^健康$/ })); + await canvas.findByRole('button', { name: /^仅显示错误健康信号/ }); + } finally { + assertNoSkeletons(); + } + + const errorFilter = canvas.getByRole('button', { name: /^仅显示错误健康信号/ }); expect(errorFilter).toHaveAttribute('aria-pressed', 'false'); await userEvent.click(errorFilter); await waitFor(() => { @@ -3363,6 +3477,118 @@ export const HealthCenter: Story = { }); }, }; + +// Real path: 设置 → 健康, revisit a cached snapshot while the Host read fails; +// retry successfully, then leave a subsequent failed refresh visible. +export const HealthCenterRefreshFailed: Story = { + decorators: [withSettingsRefreshFailureBridge], + beforeEach: () => { healthRefreshRead.reset(); }, + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const copy = getHealthCenterCopy('zh-CN'); + await canvas.findByRole('heading', { name: '支持' }); + const assertNoSkeletons = observeSettingsSkeletons(canvasElement); + try { + await userEvent.click(canvas.getByRole('button', { name: /^健康$/ })); + const refresh = await canvas.findByRole('button', { name: copy.refresh }); + await waitFor(() => expect(refresh).toHaveAttribute('aria-busy', 'true')); + expect(canvas.getByText('Z.AI Live')).toBeVisible(); + healthRefreshRead.reject(); + await canvas.findByText(copy.readFailed); + expect(canvas.getByRole('alert')).toHaveTextContent(copy.readFailed); + expect(canvas.getByText('Z.AI Live')).toBeVisible(); + expect(refresh).not.toHaveAttribute('aria-busy', 'true'); + + await userEvent.click(canvas.getByRole('button', { name: copy.readAgain })); + await waitFor(() => expect(refresh).toHaveAttribute('aria-busy', 'true')); + expect(canvas.queryByText(copy.readFailed)).not.toBeInTheDocument(); + expect(canvas.getByText('Z.AI Live')).toBeVisible(); + healthRefreshRead.resolve({ + ...healthSnapshot, + signals: healthSnapshot.signals.map((signal) => ( + signal.label === 'Z.AI Live' ? { ...signal, label: 'Z.AI Updated' } : signal + )), + }); + await canvas.findByText('Z.AI Updated'); + expect(refresh).not.toHaveAttribute('aria-busy', 'true'); + + await userEvent.click(refresh); + await waitFor(() => expect(refresh).toHaveAttribute('aria-busy', 'true')); + healthRefreshRead.reject(); + await canvas.findByText(copy.readFailed); + expect(canvas.getByRole('alert')).toHaveTextContent(copy.readFailed); + expect(canvas.getByText('Z.AI Updated')).toBeVisible(); + expect(refresh).not.toHaveAttribute('aria-busy', 'true'); + } finally { + assertNoSkeletons(); + } + }, +}; + +// Real path: 设置 → 权限与能力, revisit cached permissions/capabilities; a +// permission read fails, retry succeeds, then the capability read fails. +export const PermissionCenterRefreshFailed: Story = { + decorators: [withSettingsRefreshFailureBridge], + beforeEach: () => { + permissionRefreshRead.reset(); + capabilityRefreshRead.reset(); + }, + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const copy = getPermissionCenterCopy('zh-CN'); + await canvas.findByRole('heading', { name: '支持' }); + const assertNoSkeletons = observeSettingsSkeletons(canvasElement); + try { + await userEvent.click(canvas.getByRole('button', { name: /^权限与能力$/ })); + const refresh = await canvas.findByRole('button', { name: copy.detectAgain }); + const grantedFilter = (count: number) => canvas.getByRole('button', { + name: copy.summaryFilterAria(copy.granted, count, false), + }); + await waitFor(() => expect(refresh).toHaveAttribute('aria-busy', 'true')); + expect(grantedFilter(2)).toBeVisible(); + expect(canvas.getByRole('group', { name: copy.capabilityListAria })).toBeInTheDocument(); + permissionRefreshRead.reject(); + capabilityRefreshRead.resolve(capabilitySnapshot); + await canvas.findByText(copy.readFailed); + expect(grantedFilter(2)).toBeVisible(); + expect(canvas.getByRole('group', { name: copy.capabilityListAria })).toBeInTheDocument(); + expect(refresh).not.toHaveAttribute('aria-busy', 'true'); + + await userEvent.click(canvas.getByRole('button', { name: copy.readAgain })); + await waitFor(() => expect(refresh).toHaveAttribute('aria-busy', 'true')); + expect(canvas.queryByText(copy.readFailed)).not.toBeInTheDocument(); + expect(grantedFilter(2)).toBeVisible(); + permissionRefreshRead.resolve({ + ...permissionSnapshot, + permissions: { + ...permissionSnapshot.permissions, + screen_recording: { ...permissionSnapshot.permissions.screen_recording, status: 'granted' }, + }, + }); + capabilityRefreshRead.resolve(capabilitySnapshot); + await waitFor(() => expect(grantedFilter(3)).toBeVisible()); + expect(refresh).not.toHaveAttribute('aria-busy', 'true'); + + await userEvent.click(refresh); + await waitFor(() => expect(refresh).toHaveAttribute('aria-busy', 'true')); + permissionRefreshRead.resolve(permissionSnapshot); + capabilityRefreshRead.reject(); + await canvas.findByText(copy.readFailed); + // A partially successful read must not replace the last complete pair. + expect(grantedFilter(3)).toBeVisible(); + expect(canvas.getByRole('group', { name: copy.capabilityListAria })).toBeInTheDocument(); + expect(refresh).not.toHaveAttribute('aria-busy', 'true'); + } finally { + assertNoSkeletons(); + } + }, +}; // Real path: 设置 → 关于 (also reachable from 反馈 in the topbar). export const About: Story = { decorators: [withSettingsBridge], diff --git a/packages/ui/src/daily-review-panel.tsx b/packages/ui/src/daily-review-panel.tsx index 45df6a744d..6c5bfeafcf 100644 --- a/packages/ui/src/daily-review-panel.tsx +++ b/packages/ui/src/daily-review-panel.tsx @@ -94,8 +94,11 @@ export function DailyReviewPanel(props: { const [activityState, dispatchActivity] = useReducer( dailyReviewActivityReducer, - { range: 1, offsetDays: 0 }, - createDailyReviewActivityState, + { range: 1, offsetDays: 0 } satisfies DailyReviewScope, + (scope) => createDailyReviewActivityState( + scope, + props.bridge.readCachedDay?.(scope.offsetDays, scope.range), + ), ); const [reloadToken, setReloadToken] = useState(0); const [archiveState, setArchiveState] = useState({ status: 'loading' }); @@ -124,9 +127,10 @@ export function DailyReviewPanel(props: { useEffect(() => { let cancelled = false; + const request = new AbortController(); const requestedScope = { range, offsetDays }; dispatchActivity({ type: 'selected', scope: requestedScope }); - bridgeRef.current.fetchDay(offsetDays, range).then((next) => { + bridgeRef.current.fetchDay(offsetDays, range, request.signal).then((next) => { if (cancelled) return; dispatchActivity({ type: 'resolved', scope: requestedScope, summary: next }); }).catch((nextError: unknown) => { @@ -139,6 +143,7 @@ export function DailyReviewPanel(props: { }); return () => { cancelled = true; + request.abort(); }; }, [locale, offsetDays, range, reloadToken]); @@ -335,7 +340,7 @@ export function DailyReviewPanel(props: { ) : null} {!displayedSummary && loading ? ( - + diff --git a/packages/ui/src/daily-review-view-state.ts b/packages/ui/src/daily-review-view-state.ts index 4ce41ecbd7..83359ffde9 100644 --- a/packages/ui/src/daily-review-view-state.ts +++ b/packages/ui/src/daily-review-view-state.ts @@ -43,10 +43,13 @@ export type DailyReviewActivityAction = | { readonly type: 'resolved'; readonly scope: DailyReviewScope; readonly summary: DailyReviewSummary } | { readonly type: 'rejected'; readonly scope: DailyReviewScope; readonly error: string }; -export function createDailyReviewActivityState(scope: DailyReviewScope): DailyReviewActivityState { +export function createDailyReviewActivityState( + scope: DailyReviewScope, + summary?: DailyReviewSummary, +): DailyReviewActivityState { return { selection: scope, - resolvedView: null, + resolvedView: summary ? { scope, scopeKey: scopeKey(scope), summary } : null, pendingScopeKey: scopeKey(scope), error: null, }; diff --git a/packages/ui/src/module-pages.tsx b/packages/ui/src/module-pages.tsx index 88c6bd3cae..307c0ab6e8 100644 --- a/packages/ui/src/module-pages.tsx +++ b/packages/ui/src/module-pages.tsx @@ -26,6 +26,7 @@ import { ModulePage } from './primitives/module-page.js'; import { useUiLocale } from './locale-context.js'; import { getSharedUiCopy } from './shared-ui-copy.js'; import { getSkillsCopy } from './skills-copy.js'; +import { DailyReviewPanel } from './daily-review-panel.js'; import type { ModuleHubHeader } from './module-hub-selector.js'; import type { BundledSkillCatalogEntry, @@ -41,7 +42,6 @@ import type { } from './module-panel-types.js'; const SkillsModuleMain = lazy(() => import('./skills-panel.js').then((module) => ({ default: module.SkillsModuleMain }))); -const DailyReviewPanel = lazy(() => import('./daily-review-panel.js').then((module) => ({ default: module.DailyReviewPanel }))); const ScheduledTaskPanel = lazy(() => import('./scheduled-task-panel.js').then((module) => ({ default: module.ScheduledTaskPanel }))); /** Skills renders its own labelled region inside the lazy chunk, so its fallback must too. */ @@ -140,9 +140,7 @@ export function DailyReviewPage(props: { // The page header lives INSIDE the panel: its primary action (生成分析 / // 查看分析) rides the panel's run state, exactly like 定时任务's 新建. // The bridge-less fallback keeps its own static header below. - (}> - - ) + () ) : ( // The disconnected state keeps the module switch: it is the only // in-page way back to 定时任务, and a page you cannot leave is a worse diff --git a/packages/ui/src/module-panel-types.ts b/packages/ui/src/module-panel-types.ts index bc37af4c66..e8e9a06293 100644 --- a/packages/ui/src/module-panel-types.ts +++ b/packages/ui/src/module-panel-types.ts @@ -178,7 +178,9 @@ export type ScheduledTaskDeliveryMethod = ScheduledTaskDelivery['channel'] | 'ag * (e.g. a desktop notification renderer). */ export interface DailyReviewBridge { - fetchDay(offsetDays: number, daySpan?: number): Promise; + /** A controller-owned last-good snapshot; reads still revalidate on mount. */ + readCachedDay?(offsetDays: number, daySpan?: number): DailyReviewSummary | undefined; + fetchDay(offsetDays: number, daySpan?: number, signal?: AbortSignal): Promise; /** * PR-DAILY-REVIEW-FULL-0 — optional pipeline methods. Renderer checks * for presence before exposing the matching UI. When undefined, the diff --git a/packages/ui/src/shared-ui-copy.ts b/packages/ui/src/shared-ui-copy.ts index cc6fc00034..dbedea2ca4 100644 --- a/packages/ui/src/shared-ui-copy.ts +++ b/packages/ui/src/shared-ui-copy.ts @@ -87,7 +87,6 @@ export interface SharedUiCopy { automations: string; loadingAutomations: string; dailyReview: string; - loadingDailyReview: string; dailyReviewDescription: string; dailyReviewDisconnectedTitle: string; dailyReviewDisconnectedBody: string; @@ -183,7 +182,6 @@ const SHARED_UI_COPY = { automations: '定时任务', loadingAutomations: '正在加载定时任务…', dailyReview: '每日回顾', - loadingDailyReview: '正在加载每日回顾…', dailyReviewDescription: '自动汇总本机任务,生成摘要、遗漏提醒与深度分析;可在设置中开启定时执行。', dailyReviewDisconnectedTitle: '等待连接每日回顾数据', dailyReviewDisconnectedBody: '桌面端数据桥当前未连接。', @@ -262,7 +260,6 @@ const SHARED_UI_COPY = { automations: '定時任務', loadingAutomations: '正在載入定時任務…', dailyReview: '每日回顧', - loadingDailyReview: '正在載入每日回顧…', dailyReviewDescription: '自動彙總本機任務,生成摘要、遺漏提醒與深度分析;可在設定中開啟定時執行。', dailyReviewDisconnectedTitle: '等待連線每日回顧資料', dailyReviewDisconnectedBody: '桌面端資料橋目前未連線。', @@ -341,7 +338,6 @@ const SHARED_UI_COPY = { automations: 'Scheduled tasks', loadingAutomations: 'Loading scheduled tasks…', dailyReview: 'Daily review', - loadingDailyReview: 'Loading daily review…', dailyReviewDescription: 'Summarize local tasks into highlights, missed items, and deeper analysis. Scheduled runs can be enabled in Settings.', dailyReviewDisconnectedTitle: 'Waiting for daily review data', dailyReviewDisconnectedBody: 'The desktop data bridge is not connected.',