diff --git a/apps/desktop/e2e/module-hub.spec.ts b/apps/desktop/e2e/module-hub.spec.ts new file mode 100644 index 0000000000..462558387c --- /dev/null +++ b/apps/desktop/e2e/module-hub.spec.ts @@ -0,0 +1,52 @@ +/* + * 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 { expect, test } from './fixtures'; + +test('Module Hub switches all four leaves and opens scheduled creation once', async ({ + window: page, +}) => { + const expand = page.getByRole('button', { name: '展开侧边栏' }); + if (await expand.isVisible()) await expand.click(); + const sidebar = page.getByRole('navigation', { name: '任务列表' }); + + await sidebar.getByRole('button', { name: '扩展', exact: true }).click(); + await expect(page.locator('[data-module="skills"]')).toBeVisible(); + const extensions = page.getByRole('navigation', { name: /扩展内容/ }); + await extensions.getByRole('button', { name: 'MCP', exact: true }).click(); + await expect( + extensions.getByRole('button', { name: 'MCP', exact: true }), + ).toHaveAttribute('aria-current', 'page'); + + await sidebar.getByRole('button', { name: /定时任务/ }).click(); + await expect(page.locator('[data-module="scheduled-tasks"]')).toBeVisible(); + const automations = page.getByRole('navigation', { name: /定时任务内容/ }); + await automations.getByRole('button', { name: '每日回顾', exact: true }).click(); + await expect(page.locator('[data-module="daily-review"]')).toBeVisible(); + + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+k' : 'Control+k'); + const palette = page.getByRole('dialog', { name: '命令面板' }); + await expect(palette).toBeVisible(); + await palette.getByRole('option', { name: /新建定时任务/ }).click(); + + const createDialog = page.getByRole('dialog', { name: '新建定时任务' }); + await expect(createDialog).toBeVisible(); + await expect(createDialog).toHaveCount(1); + await expect(page.locator('[data-module="scheduled-tasks"]')).toBeVisible(); +}); diff --git a/apps/desktop/src/main/__tests__/module-hub-boundary.test.ts b/apps/desktop/src/main/__tests__/module-hub-boundary.test.ts new file mode 100644 index 0000000000..60b6b65d30 --- /dev/null +++ b/apps/desktop/src/main/__tests__/module-hub-boundary.test.ts @@ -0,0 +1,138 @@ +/* + * 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 { readdirSync, readFileSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const desktopRoot = resolve(fileURLToPath(new URL('../../../', import.meta.url))); +const featureRoot = join( + desktopRoot, + 'src', + 'renderer', + 'features', + 'module-hub', +); + +function sourceFiles(root: string): string[] { + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const path = join(root, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + return /\.(?:ts|tsx|md)$/.test(entry.name) ? [path] : []; + }); +} + +describe('Module Hub feature boundary', () => { + it('keeps Desktop globals and shell/process dependencies outside production feature code', () => { + const violations: string[] = []; + for (const path of sourceFiles(featureRoot)) { + if (path.endsWith(`${join('', 'testing.ts')}`)) continue; + const source = readFileSync(path, 'utf8'); + const name = relative(desktopRoot, path); + if (!path.endsWith('.md')) { + if (source.includes('window.maka')) violations.push(`${name}: window.maka`); + if (source.includes('navigator.')) violations.push(`${name}: navigator`); + } + for (const match of source.matchAll(/from\s+['"]([^'"]+)['"]/g)) { + const imported = match[1] ?? ''; + if ( + imported.includes('app-shell') || + imported.includes('/preload/') || + imported.includes('/main/') + ) { + violations.push(`${name}: ${imported}`); + } + } + } + assert.deepEqual(violations, []); + }); + + it('is consumed outside the feature only through index or testing', () => { + const allowed = /\/features\/module-hub\/(?:index|testing)(?:\.js)?$/; + const violations: string[] = []; + for (const root of [join(desktopRoot, 'src'), join(desktopRoot, 'stories')]) { + for (const path of sourceFiles(root)) { + if (path.startsWith(featureRoot)) continue; + const source = readFileSync(path, 'utf8'); + for (const match of source.matchAll( + /from\s+['"]([^'"]*features\/module-hub[^'"]*)['"]/g, + )) { + const imported = (match[1] ?? '').replace(/\\/g, '/'); + const explicitEntry = imported.endsWith('/features/module-hub') + ? `${imported}/index` + : imported; + if (!allowed.test(explicitEntry)) { + violations.push(`${relative(desktopRoot, path)}: ${imported}`); + } + } + } + } + assert.deepEqual(violations, []); + }); + + it('keeps fakes out of the production entry', () => { + const productionEntry = readFileSync(join(featureRoot, 'index.ts'), 'utf8'); + assert.equal(productionEntry.includes('createFakeModuleHub'), false); + assert.equal(productionEntry.includes("from './testing"), false); + }); + + it('keeps module data, pages, nonce, bridges, and subscriptions out of AppShell', () => { + const appShell = readFileSync( + join(desktopRoot, 'src', 'renderer', 'app-shell.tsx'), + 'utf8', + ); + for (const forbidden of [ + 'useAppShellModuleData', + 'useKeepSystemAwake', + 'createAppShellDailyReviewBridge', + 'createAppShellDailyReviewActions', + 'scheduledTaskCreateRequestNonce', + 'ModuleHubSelector', + ''), true); + + const effects = readFileSync( + join(desktopRoot, 'src', 'renderer', 'app-shell-effects.ts'), + 'utf8', + ); + assert.equal(effects.includes('window.maka.scheduledTasks'), false); + + const commands = readFileSync( + join(desktopRoot, 'src', 'renderer', 'app-shell-command-actions.ts'), + 'utf8', + ); + assert.equal(commands.includes('dailyReviewBridge'), false); + assert.equal(commands.includes('saveDailyReviewMarkdown'), false); + assert.equal(commands.includes('copyTodayDailyReview()'), true); + assert.equal(commands.includes('pasteTodayDailyReview()'), true); + assert.equal(commands.includes('saveTodayDailyReview()'), true); + }); +}); 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 new file mode 100644 index 0000000000..7fdb355eb5 --- /dev/null +++ b/apps/desktop/src/main/__tests__/module-hub-daily-review-controller.test.ts @@ -0,0 +1,310 @@ +/* + * 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 { act, createElement } from 'react'; +import type { DailyReviewSummary } from '@maka/core/daily-review'; +import { + createFakeModuleHubServices, + createDailyReviewBridge, + type DailyReviewController, + type ModuleHubServices, + useDailyReviewController, +} from '../../renderer/features/module-hub/testing.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function summary(sessionCount = 2): DailyReviewSummary { + return { + day: { fromMs: Date.UTC(2026, 7, 24), toMs: Date.UTC(2026, 7, 25) }, + totals: { + sessionCount, + requestCount: 7, + totalTokens: 1234, + costUsd: 0.25, + errorCount: 0, + }, + sessions: [], + topTools: [], + topModels: [], + }; +} + +function dailyReviewService( + day: ModuleHubServices['dailyReview']['day'], +): ModuleHubServices['dailyReview'] { + return { + day, + runOnce: async () => ({ archiveId: 'archive-1' }), + listArchives: async () => [], + getArchive: async () => null, + saveMarkdownToFile: async () => ({ ok: true, path: '/tmp/review.md' }), + }; +} + +test('stable page bridge retries rather than exposing a stale default-Host read', async () => { + const hostA = { profileId: 'profile-a', hostId: 'host-a' }; + const hostB = { profileId: 'profile-b', hostId: 'host-b' }; + let currentHost = hostA; + const reads: string[] = []; + const firstRead = deferred<{ ok: true; data: DailyReviewSummary }>(); + const services = createFakeModuleHubServices({ + runtimeHosts: { + getDefault: async () => currentHost, + subscribeChanges: () => () => undefined, + }, + dailyReview: dailyReviewService(async (_offset, _span, host) => { + reads.push(host.hostId); + if (host.hostId === hostA.hostId) return firstRead.promise; + return { ok: true, data: summary(9) }; + }), + }); + const bridge = createDailyReviewBridge(services, 'en'); + const pending = bridge.fetchDay(0, 1); + + currentHost = hostB; + firstRead.resolve({ ok: true, data: summary(1) }); + + assert.equal((await pending).totals.sessionCount, 9); + assert.deepEqual(reads, ['host-a', 'host-b']); +}); + +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 }>(); + const appended: string[] = []; + const successes: string[] = []; + let claimCurrent = true; + let claims = 0; + const services = createFakeModuleHubServices({ + dailyReview: dailyReviewService(async () => pendingDay.promise), + }); + let controller: DailyReviewController | undefined; + + function Probe() { + controller = useDailyReviewController({ + services, + uiLocale: 'en', + toastApi: { + success: (title) => successes.push(title), + error: () => undefined, + }, + appendComposerText: (text) => appended.push(text), + captureActiveComposerClaim: () => { + claims += 1; + return { + isCurrent: () => claimCurrent, + append: (text) => appended.push(text), + }; + }, + isDailyReviewSurfaceActive: () => true, + }); + return null; + } + + await act(async () => root.render(createElement(Probe))); + const bridgeBefore = controller?.bridge; + await act(async () => root.render(createElement(Probe))); + assert.equal(controller?.bridge, bridgeBefore); + + let paste!: Promise; + await act(async () => { + paste = controller!.pasteToday(); + await Promise.resolve(); + }); + assert.equal(claims, 1); + claimCurrent = false; + pendingDay.resolve({ ok: true, data: summary() }); + await act(async () => paste); + + assert.deepEqual(appended, []); + assert.deepEqual(successes, []); +}); + +test('today paste rechecks its composer claim after an async failure Host fence', async () => { + const { root } = installReactRenderer(); + const host = { profileId: 'profile-a', hostId: 'host-a' }; + const finalHostRecheck = deferred(); + const errors: string[] = []; + let claimCurrent = true; + let hostReads = 0; + const services = createFakeModuleHubServices({ + runtimeHosts: { + getDefault: async () => { + hostReads += 1; + return hostReads === 3 ? finalHostRecheck.promise : host; + }, + subscribeChanges: () => () => undefined, + }, + dailyReview: dailyReviewService(async () => { + throw new Error('offline'); + }), + }); + let controller: DailyReviewController | undefined; + + function Probe() { + controller = useDailyReviewController({ + services, + uiLocale: 'en', + toastApi: { + success: () => undefined, + error: (title) => errors.push(title), + }, + appendComposerText: () => undefined, + captureActiveComposerClaim: () => ({ + isCurrent: () => claimCurrent, + append: () => undefined, + }), + isDailyReviewSurfaceActive: () => false, + }); + return null; + } + + await act(async () => root.render(createElement(Probe))); + const paste = controller!.pasteToday(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.equal(hostReads, 3); + + claimCurrent = false; + finalHostRecheck.resolve(host); + await act(async () => paste); + + assert.deepEqual(errors, []); +}); + +test('page actions suppress late feedback after leaving Daily Review', async () => { + const { root } = installReactRenderer(); + const clipboard = deferred(); + const save = deferred< + | { ok: true; path: string } + | { ok: false; reason: 'canceled' | 'write_failed' | 'invalid_input' } + >(); + let active = true; + const successes: string[] = []; + const errors: string[] = []; + const services = createFakeModuleHubServices({ + dailyReview: { + ...dailyReviewService(async () => ({ ok: true, data: summary() })), + saveMarkdownToFile: async () => save.promise, + }, + clipboard: { writeText: async () => clipboard.promise }, + }); + let controller: DailyReviewController | undefined; + + function Probe() { + controller = useDailyReviewController({ + services, + uiLocale: 'en', + toastApi: { + success: (title) => successes.push(title), + error: (title) => errors.push(title), + }, + appendComposerText: () => undefined, + captureActiveComposerClaim: () => undefined, + isDailyReviewSurfaceActive: () => active, + }); + return null; + } + + await act(async () => root.render(createElement(Probe))); + const actionInput = { + day: summary().day, + range: 1 as const, + totals: summary().totals, + markdown: '# Review', + label: 'Today', + }; + // No caller predicate: the controller's live surface predicate is the + // ownership fence, even if a Host model snapshot was captured before leave. + const copyPromise = controller!.copyMarkdown(actionInput); + const savePromise = controller!.saveMarkdown(actionInput); + active = false; + clipboard.resolve(); + save.resolve({ ok: true, path: '/tmp/review.md' }); + await act(async () => Promise.all([copyPromise, savePromise])); + + assert.deepEqual(successes, []); + assert.deepEqual(errors, []); + + // Command Palette ownership is separate from the page surface: its public + // command still reports success while Daily Review is not selected. + await act(async () => controller!.saveToday()); + assert.deepEqual(successes, ['Today review saved']); +}); + +test('current default-Host Daily Review failures retain their diagnostic target', async () => { + const { root } = installReactRenderer(); + const errors: Array<{ title: string; profileId?: string }> = []; + const services = createFakeModuleHubServices({ + runtimeHosts: { + getDefault: async () => ({ + profileId: 'remote-profile', + hostId: 'remote-host', + }), + subscribeChanges: () => () => undefined, + }, + dailyReview: dailyReviewService(async () => { + throw new Error('offline'); + }), + }); + let controller: DailyReviewController | undefined; + + function Probe() { + controller = useDailyReviewController({ + services, + uiLocale: 'en', + toastApi: { + success: () => undefined, + error: (title, _description, _details, target) => + errors.push({ + title, + profileId: + target && 'profileId' in target ? target.profileId : undefined, + }), + }, + appendComposerText: () => undefined, + captureActiveComposerClaim: () => undefined, + isDailyReviewSurfaceActive: () => false, + }); + return null; + } + + await act(async () => root.render(createElement(Probe))); + await act(async () => controller!.copyToday()); + + assert.deepEqual(errors, [ + { title: 'Copy failed', profileId: 'remote-profile' }, + ]); +}); + +afterEach(() => cleanupFakeDom()); diff --git a/apps/desktop/src/main/__tests__/module-hub-host.test.ts b/apps/desktop/src/main/__tests__/module-hub-host.test.ts new file mode 100644 index 0000000000..7a523ff8aa --- /dev/null +++ b/apps/desktop/src/main/__tests__/module-hub-host.test.ts @@ -0,0 +1,63 @@ +/* + * 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 { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import type { NavSelection } from '@maka/ui'; +import { resolveModuleHubHostRoute } from '../../renderer/features/module-hub/testing.js'; + +test('Module Hub resolves all four leaf routes and no chat route', () => { + const cases: Array<[NavSelection, ReturnType]> = [ + [{ section: 'extensions', module: 'skills' }, 'skills'], + [{ section: 'extensions', module: 'mcp' }, 'mcp'], + [{ section: 'automations', module: 'scheduled-tasks' }, 'scheduled-tasks'], + [{ section: 'automations', module: 'daily-review' }, 'daily-review'], + [{ section: 'sessions' }, null], + ]; + for (const [selection, expected] of cases) { + assert.equal(resolveModuleHubHostRoute(selection), expected); + } +}); + +test('Host maps each route to one existing leaf and preserves the MCP exception', () => { + const desktopRoot = resolve( + fileURLToPath(new URL('../../../', import.meta.url)), + ); + const source = readFileSync( + resolve( + desktopRoot, + 'src/renderer/features/module-hub/ui/module-hub-host.tsx', + ), + 'utf8', + ); + for (const leaf of [ + '() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +test('falls back safely after the initial read fails and propagates write failures', async () => { + const { root } = installReactRenderer(); + const services = createFakeModuleHubServices({ + clientSettings: { + supported: true, + getKeepSystemAwake: async () => { + throw new Error('bad settings.json'); + }, + setKeepSystemAwake: async () => { + throw new Error('write failed'); + }, + subscribeChanges: () => () => undefined, + }, + }); + let controller: KeepSystemAwakeController | undefined; + + function Probe() { + controller = useKeepSystemAwakeController(services); + return null; + } + + await act(async () => root.render(createElement(Probe))); + assert.equal(controller?.keepSystemAwake, false); + await assert.rejects(() => controller!.setKeepSystemAwake(true), /write failed/); + assert.equal(controller?.keepSystemAwake, false); +}); + +test('external changes win over a slow write and the subscription is disposed', async () => { + const { root } = installReactRenderer(); + const write = deferred(); + let persisted = false; + let changed: (() => void) | undefined; + let disposed = 0; + const services = createFakeModuleHubServices({ + clientSettings: { + supported: true, + getKeepSystemAwake: async () => persisted, + setKeepSystemAwake: async () => write.promise, + subscribeChanges: (handler) => { + changed = handler; + return () => { + disposed += 1; + }; + }, + }, + }); + let controller: KeepSystemAwakeController | undefined; + + function Probe() { + controller = useKeepSystemAwakeController(services); + return null; + } + + await act(async () => root.render(createElement(Probe))); + assert.equal(controller?.keepSystemAwake, false); + const pendingWrite = controller!.setKeepSystemAwake(false); + persisted = true; + await act(async () => { + changed?.(); + await Promise.resolve(); + }); + assert.equal(controller?.keepSystemAwake, true); + + write.resolve(false); + await act(async () => pendingWrite); + assert.equal(controller?.keepSystemAwake, true); + + await act(async () => root.unmount()); + assert.equal(disposed, 1); +}); + +test('unsupported settings stay hidden and never probe an unavailable bridge', async () => { + const { root } = installReactRenderer(); + let reads = 0; + let subscriptions = 0; + const services = createFakeModuleHubServices({ + clientSettings: { + supported: false, + getKeepSystemAwake: async () => { + reads += 1; + return true; + }, + setKeepSystemAwake: async (next) => next, + subscribeChanges: () => { + subscriptions += 1; + return () => undefined; + }, + }, + }); + let controller: KeepSystemAwakeController | undefined; + + function Probe() { + controller = useKeepSystemAwakeController(services); + return null; + } + + await act(async () => root.render(createElement(Probe))); + assert.equal(controller?.supported, false); + assert.equal(controller?.keepSystemAwake, undefined); + assert.equal(reads, 0); + assert.equal(subscriptions, 0); + await assert.rejects(() => controller!.setKeepSystemAwake(true), /unavailable/); +}); + +test('a pending initial read cannot publish after controller disposal', async () => { + const { root } = installReactRenderer(); + const read = deferred(); + let disposed = 0; + const services = createFakeModuleHubServices({ + clientSettings: { + supported: true, + getKeepSystemAwake: async () => read.promise, + setKeepSystemAwake: async (next) => next, + subscribeChanges: () => () => { + disposed += 1; + }, + }, + }); + let controller: KeepSystemAwakeController | undefined; + + function Probe() { + controller = useKeepSystemAwakeController(services); + return null; + } + + await act(async () => root.render(createElement(Probe))); + const disposedSnapshot = controller; + assert.equal(disposedSnapshot?.keepSystemAwake, undefined); + await act(async () => root.unmount()); + read.resolve(true); + await act(async () => read.promise); + + assert.equal(disposed, 1); + assert.equal(disposedSnapshot?.keepSystemAwake, undefined); +}); + +afterEach(() => cleanupFakeDom()); diff --git a/apps/desktop/src/main/__tests__/module-hub-lifecycle.test.ts b/apps/desktop/src/main/__tests__/module-hub-lifecycle.test.ts new file mode 100644 index 0000000000..77ba9dfb7c --- /dev/null +++ b/apps/desktop/src/main/__tests__/module-hub-lifecycle.test.ts @@ -0,0 +1,88 @@ +/* + * 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 test from 'node:test'; +import { + startModuleHubLifecycle, + type ModuleHubRuntimeHostChangedEvent, +} from '../../renderer/features/module-hub/testing.js'; + +test('Module Hub owns deferred startup, default-Host refresh, and cleanup', () => { + let frame: FrameRequestCallback | undefined; + let hostChange: ((event: ModuleHubRuntimeHostChangedEvent) => void) | undefined; + const calls: string[] = []; + const cleanup = startModuleHubLifecycle({ + runtimeHosts: { + getDefault: async () => ({ profileId: 'local', hostId: 'local' }), + subscribeChanges(handler) { + hostChange = handler; + return () => calls.push('unsubscribe-hosts'); + }, + }, + refreshProjectSkills: () => calls.push('skills'), + refreshScheduledTasks: () => calls.push('tasks'), + scheduler: { + requestFrame(callback) { + frame = callback; + calls.push('request-frame'); + return 42; + }, + cancelFrame(handle) { + calls.push(`cancel-frame:${handle}`); + }, + }, + }); + + assert.deepEqual(calls, ['request-frame']); + frame?.(0); + assert.deepEqual(calls, ['request-frame', 'skills', 'tasks']); + + hostChange?.({ + profileId: 'remote', + readiness: 'reconnecting', + isDefault: true, + }); + hostChange?.({ + profileId: 'remote', + readiness: 'ready', + isDefault: false, + }); + assert.deepEqual(calls, ['request-frame', 'skills', 'tasks']); + + hostChange?.({ + profileId: 'remote', + hostId: 'remote-host', + readiness: 'ready', + isDefault: true, + }); + assert.deepEqual(calls, [ + 'request-frame', + 'skills', + 'tasks', + 'skills', + 'tasks', + ]); + + cleanup(); + assert.deepEqual(calls.slice(-2), [ + 'cancel-frame:42', + 'unsubscribe-hosts', + ]); +}); diff --git a/apps/desktop/src/main/__tests__/module-hub-scheduled-tasks-controller.test.ts b/apps/desktop/src/main/__tests__/module-hub-scheduled-tasks-controller.test.ts new file mode 100644 index 0000000000..ef8cf0d3cd --- /dev/null +++ b/apps/desktop/src/main/__tests__/module-hub-scheduled-tasks-controller.test.ts @@ -0,0 +1,494 @@ +/* + * 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 { act, createElement } from 'react'; +import type { ScheduledTask } from '@maka/core/scheduled-task'; +import type { NavSelection, ToastInput } from '@maka/ui'; +import { + createFakeModuleHubServices, + ModuleHubServicesProvider, + useScheduledTasksController, + type ModuleHubServices, + type ScheduledTasksController, + type ScheduledTasksToastApi, +} from '../../renderer/features/module-hub/testing.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((settle, fail) => { + resolve = settle; + reject = fail; + }); + return { promise, reject, resolve }; +} + +function task(id: string, title = id): ScheduledTask { + return { + id, + title, + intent: { kind: 'text', body: 'run' }, + schedule: { kind: 'once', runAt: 1 }, + effect: { kind: 'notify', channel: 'local' }, + status: 'active', + nextFireAt: 1, + lastFireAt: null, + fireCount: 0, + maxFires: null, + expiresAt: null, + createdBy: { kind: 'user' }, + createdAt: 1, + updatedAt: 1, + runs: [], + lastError: null, + }; +} + +type ToastRecord = + | { + kind: 'success' | 'error'; + title: string; + detail?: string; + profileId?: string; + } + | { kind: 'toast'; input: ToastInput }; + +function toastRecorder( + records: ToastRecord[], + confirm: () => Promise = async () => true, +): ScheduledTasksToastApi { + return { + success(title, detail) { + records.push({ kind: 'success', title, detail }); + return 'success'; + }, + error(title, detail, _diagnosticDetails, diagnosticTarget) { + records.push({ + kind: 'error', + title, + detail, + ...(diagnosticTarget && 'profileId' in diagnosticTarget + ? { profileId: diagnosticTarget.profileId } + : {}), + }); + return 'error'; + }, + toast(input) { + records.push({ kind: 'toast', input }); + return 'toast'; + }, + confirm, + }; +} + +let latest: ScheduledTasksController | undefined; + +function Probe(props: { + selection: NavSelection; + selectModule: (selection: NavSelection) => void; + toastApi: ScheduledTasksToastApi; +}) { + latest = useScheduledTasksController({ + uiLocale: 'en', + toastApi: props.toastApi, + selection: props.selection, + selectModule: props.selectModule, + }); + return null; +} + +function renderController( + root: ReturnType['root'], + services: ModuleHubServices, + props: Parameters[0], +) { + root.render( + createElement( + ModuleHubServicesProvider, + { services }, + createElement(Probe, props), + ), + ); +} + +function controller(): ScheduledTasksController { + assert.ok(latest); + return latest; +} + +const activeSelection: NavSelection = { + section: 'automations', + module: 'scheduled-tasks', +}; + +test('Scheduled Tasks read uses generation and current-default-Host fences', async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const hostA = { profileId: 'profile-a', hostId: 'host-a' }; + const hostB = { profileId: 'profile-b', hostId: 'host-b' }; + let currentHost = hostA; + const first = deferred(); + const oldHost = deferred(); + let reads = 0; + const defaults = createFakeModuleHubServices(); + const services = createFakeModuleHubServices({ + runtimeHosts: { + ...defaults.runtimeHosts, + getDefault: async () => currentHost, + }, + scheduledTasks: { + ...defaults.scheduledTasks, + list: async () => { + reads += 1; + if (reads === 1) return first.promise; + if (reads === 3) return oldHost.promise; + return [task(`current-${currentHost.hostId}`)]; + }, + }, + }); + await act(async () => + renderController(root, services, { + selection: activeSelection, + selectModule: () => undefined, + toastApi: toastRecorder(records), + }), + ); + + const stale = controller().refresh(); + await act(async () => controller().refresh()); + assert.deepEqual( + controller().scheduledTasks.map(({ id }) => id), + ['current-host-a'], + ); + await act(async () => { + first.resolve([task('same-host-stale')]); + await stale; + }); + assert.deepEqual( + controller().scheduledTasks.map(({ id }) => id), + ['current-host-a'], + ); + + const pendingOldHost = controller().refresh(); + currentHost = hostB; + await act(async () => { + oldHost.resolve([task('old-host')]); + await pendingOldHost; + }); + assert.deepEqual( + controller().scheduledTasks.map(({ id }) => id), + ['current-host-a'], + ); + assert.deepEqual(records, []); +}); + +test('stale Scheduled Task refresh errors do not outlive a newer generation', async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const host = { profileId: 'profile-a', hostId: 'host-a' }; + const staleHostRecheck = deferred(); + let hostReads = 0; + let reads = 0; + const defaults = createFakeModuleHubServices(); + const services = createFakeModuleHubServices({ + runtimeHosts: { + ...defaults.runtimeHosts, + getDefault: async () => { + hostReads += 1; + return hostReads === 2 ? staleHostRecheck.promise : host; + }, + }, + scheduledTasks: { + ...defaults.scheduledTasks, + list: async () => { + reads += 1; + if (reads === 1) throw new Error('stale refresh failed'); + return [task('fresh')]; + }, + }, + }); + await act(async () => + renderController(root, services, { + selection: activeSelection, + selectModule: () => undefined, + toastApi: toastRecorder(records), + }), + ); + + const staleRefresh = controller().refresh(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + assert.equal(hostReads, 2); + + await act(async () => controller().refresh()); + assert.deepEqual( + controller().scheduledTasks.map(({ id }) => id), + ['fresh'], + ); + + staleHostRecheck.resolve(host); + await act(async () => staleRefresh); + assert.deepEqual(records, []); +}); + +test('Scheduled Task mutations recheck their Host after the refresh', async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const hostA = { profileId: 'profile-a', hostId: 'host-a' }; + const hostB = { profileId: 'profile-b', hostId: 'host-b' }; + let currentHost = hostA; + let reads = 0; + const pendingRefresh = deferred(); + const defaults = createFakeModuleHubServices(); + const services = createFakeModuleHubServices({ + runtimeHosts: { + ...defaults.runtimeHosts, + getDefault: async () => currentHost, + }, + scheduledTasks: { + ...defaults.scheduledTasks, + create: async () => task('created-on-host-a'), + list: async () => { + reads += 1; + return pendingRefresh.promise; + }, + }, + }); + await act(async () => + renderController(root, services, { + selection: activeSelection, + selectModule: () => undefined, + toastApi: toastRecorder(records), + }), + ); + + const mutation = controller().create({ + title: 'Created on Host A', + intentBody: 'run', + schedule: { kind: 'once', runAt: 1 }, + effect: { kind: 'notify', channel: 'local' }, + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.equal(reads, 1); + + currentHost = hostB; + pendingRefresh.resolve([task('old-host-task')]); + let result = true; + await act(async () => { + result = await mutation; + }); + + assert.equal(result, false); + assert.deepEqual(controller().scheduledTasks, []); + assert.deepEqual(records, []); +}); + +test('mutations keep titles current, preserve refreshes, and fence confirm continuation', async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const calls: string[] = []; + const confirmResult = deferred(); + const defaults = createFakeModuleHubServices(); + let listed = [task('task-a', 'Latest title')]; + const services = createFakeModuleHubServices({ + scheduledTasks: { + ...defaults.scheduledTasks, + list: async () => { + calls.push('list'); + return listed; + }, + triggerNow: async (id) => { + calls.push(`trigger:${id}`); + return listed[0]!; + }, + delete: async (id) => { + calls.push(`delete:${id}`); + }, + create: async () => { + throw new Error('SCHEDULED_TASK_INCOGNITO_ACTIVE'); + }, + }, + }); + const activeProps = { + selection: activeSelection, + selectModule: () => undefined, + toastApi: toastRecorder(records, () => confirmResult.promise), + }; + await act(async () => renderController(root, services, activeProps)); + await act(async () => controller().refresh()); + await act(async () => controller().triggerNow('task-a')); + assert.deepEqual(calls, ['list', 'trigger:task-a', 'list']); + assert.ok( + records.some( + (record) => record.kind === 'success' && record.detail === 'Latest title', + ), + ); + + await act(async () => + controller().create({ + title: 'Blocked', + intentBody: 'run', + schedule: { kind: 'once', runAt: 1 }, + effect: { kind: 'notify', channel: 'local' }, + }), + ); + assert.ok( + records.some( + (record) => + record.kind === 'error' && + record.detail?.toLowerCase().includes('incognito'), + ), + ); + + const deletion = controller().delete('task-a'); + await act(async () => + renderController(root, services, { + ...activeProps, + selection: { section: 'extensions', module: 'skills' }, + }), + ); + confirmResult.resolve(true); + await act(async () => deletion); + assert.equal(calls.includes('delete:task-a'), false); + + const errorsBefore = records.filter(({ kind }) => kind === 'error').length; + services.scheduledTasks.list = async () => { + throw new Error('late refresh'); + }; + await act(async () => controller().refreshSurface()); + assert.equal( + records.filter(({ kind }) => kind === 'error').length, + errorsBefore, + ); +}); + +test('a destructive confirmation cannot continue after controller unmount', async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const confirmResult = deferred(); + const deleted: string[] = []; + const defaults = createFakeModuleHubServices(); + const services = createFakeModuleHubServices({ + scheduledTasks: { + ...defaults.scheduledTasks, + delete: async (id) => { + deleted.push(id); + }, + }, + }); + await act(async () => + renderController(root, services, { + selection: activeSelection, + selectModule: () => undefined, + toastApi: toastRecorder(records, () => confirmResult.promise), + }), + ); + + const deletion = controller().delete('task-a'); + await act(async () => root.unmount()); + confirmResult.resolve(true); + await act(async () => deletion); + + assert.deepEqual(deleted, []); + assert.deepEqual(records, []); +}); + +test('subscriptions refresh, due navigation action is live, disposers run, and nonce resets', async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const selections: NavSelection[] = []; + let changeHandler: + | Parameters[0] + | undefined; + let dueHandler: + ((task: Pick) => void) | undefined; + let disposals = 0; + let reads = 0; + const defaults = createFakeModuleHubServices(); + const services = createFakeModuleHubServices({ + scheduledTasks: { + ...defaults.scheduledTasks, + list: async () => { + reads += 1; + return []; + }, + subscribeChanges(handler) { + changeHandler = handler; + return () => { + disposals += 1; + }; + }, + subscribeDue(handler) { + dueHandler = handler; + return () => { + disposals += 1; + }; + }, + }, + }); + await act(async () => + renderController(root, services, { + selection: activeSelection, + selectModule: (selection) => selections.push(selection), + toastApi: toastRecorder(records), + }), + ); + + await act(async () => { + changeHandler?.({ + type: 'scheduled_tasks_changed', + reason: 'updated', + ts: 1, + }); + dueHandler?.({ id: 'due', title: 'Due task' }); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.equal(reads, 2); + const dueToast = records.find( + (record): record is Extract => + record.kind === 'toast', + ); + assert.equal(dueToast?.input.description, 'Due task'); + dueToast?.input.action?.onClick(); + assert.deepEqual(selections.at(-1), activeSelection); + + act(() => controller().openCreate()); + assert.equal(controller().createRequestNonce, 1); + assert.deepEqual(selections.at(-1), activeSelection); + act(() => controller().handleCreateRequest()); + assert.equal(controller().createRequestNonce, 0); + + act(() => root.unmount()); + assert.equal(disposals, 2); +}); + +afterEach(() => { + latest = undefined; + cleanupFakeDom(); +}); diff --git a/apps/desktop/src/main/__tests__/module-hub-services-adapter.test.ts b/apps/desktop/src/main/__tests__/module-hub-services-adapter.test.ts new file mode 100644 index 0000000000..4bf36c15f3 --- /dev/null +++ b/apps/desktop/src/main/__tests__/module-hub-services-adapter.test.ts @@ -0,0 +1,292 @@ +/* + * 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 { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import type { DesktopRuntimeHostProfileChangedEvent } from '../../preload/bridge-contract.js'; +import type { ModuleHubRuntimeHostRef } from '../../renderer/features/module-hub/testing.js'; +import { + createDesktopModuleHubServices, + type DesktopModuleHubBridge, +} from '../../renderer/platform/desktop/create-module-hub-services.js'; + +type Call = { name: string; args: unknown[] }; + +function methodRecorder(calls: Call[], prefix: string) { + return new Proxy( + {} as Record, + { + get: (target, property) => + Reflect.has(target, property) + ? Reflect.get(target, property) + : (...args: unknown[]) => { + calls.push({ name: `${prefix}.${String(property)}`, args }); + return Promise.resolve(undefined); + }, + }, + ); +} + +describe('createDesktopModuleHubServices', () => { + it('maps host-scoped Skills, Scheduled Tasks, Daily Review, and clipboard operations', async () => { + const calls: Call[] = []; + const host: ModuleHubRuntimeHostRef = { + profileId: 'remote-a', + hostId: 'host-a', + }; + const bridge = { + runtimeHostProfiles: { + getDefaultHost: async () => host, + subscribeChanges: () => () => undefined, + }, + skills: Object.assign(methodRecorder(calls, 'skills'), { + sources: methodRecorder(calls, 'skills.sources'), + catalog: methodRecorder(calls, 'skills.catalog'), + }), + scheduledTasks: methodRecorder(calls, 'scheduledTasks'), + dailyReview: methodRecorder(calls, 'dailyReview'), + } as unknown as DesktopModuleHubBridge; + const clipboard = { + async writeText(text: string) { + calls.push({ name: 'clipboard.writeText', args: [text] }); + }, + }; + const services = createDesktopModuleHubServices(bridge, { clipboard }); + + assert.deepEqual(await services.runtimeHosts.getDefault(), host); + await services.skills.list(host); + await services.skills.listManagedSources(host); + await services.skills.listBundledCatalog(host); + await services.skills.importManagedSource(host); + await services.skills.installManaged('managed', host); + await services.skills.installBundled('bundled', host); + await services.skills.previewUpdate('skill', host); + await services.skills.updateManaged('skill', { force: true }, host); + await services.skills.setEnabled('skill', true, host); + await services.skills.setPinned('user:skill', false, host); + await services.skills.delete('user:skill', host); + await services.skills.open('skill', 'directory', host); + + const createInput = { title: 'Task' } as Parameters< + typeof services.scheduledTasks.create + >[0]; + const updateInput = { title: 'Renamed' } as Parameters< + typeof services.scheduledTasks.update + >[1]; + await services.scheduledTasks.list(host); + await services.scheduledTasks.create(createInput, host); + await services.scheduledTasks.update('task', updateInput, host); + await services.scheduledTasks.setEnabled('task', true, host); + await services.scheduledTasks.triggerNow('task', host); + await services.scheduledTasks.snooze('task', host); + await services.scheduledTasks.clearRunHistory('task', host); + await services.scheduledTasks.delete('task', host); + + await services.dailyReview.day(0, 7, host); + await services.dailyReview.runOnce({ range: 7, offsetDays: -1 }); + await services.dailyReview.listArchives(); + await services.dailyReview.getArchive('archive'); + await services.dailyReview.saveMarkdownToFile({ + markdown: '# Review', + defaultName: 'review.md', + }); + await services.clipboard.writeText('review'); + + assert.deepEqual(calls, [ + { name: 'skills.list', args: [host] }, + { name: 'skills.sources.list', args: [host] }, + { name: 'skills.catalog.list', args: [host] }, + { name: 'skills.sources.importLocalFile', args: [host] }, + { name: 'skills.installManaged', args: ['managed', host] }, + { name: 'skills.catalog.install', args: ['bundled', host] }, + { name: 'skills.previewUpdate', args: ['skill', host] }, + { name: 'skills.updateManaged', args: ['skill', { force: true }, host] }, + { name: 'skills.setEnabled', args: ['skill', true, host] }, + { name: 'skills.setPinned', args: ['user:skill', false, host] }, + { name: 'skills.delete', args: ['user:skill', host] }, + { name: 'skills.open', args: ['skill', 'directory', host] }, + { name: 'scheduledTasks.list', args: [host] }, + { name: 'scheduledTasks.create', args: [createInput, host] }, + { name: 'scheduledTasks.update', args: ['task', updateInput, host] }, + { name: 'scheduledTasks.setEnabled', args: ['task', true, host] }, + { name: 'scheduledTasks.triggerNow', args: ['task', host] }, + { name: 'scheduledTasks.snooze', args: ['task', host] }, + { name: 'scheduledTasks.clearRunHistory', args: ['task', host] }, + { name: 'scheduledTasks.delete', args: ['task', host] }, + { name: 'dailyReview.day', args: [0, 7, host] }, + { name: 'dailyReview.runOnce', args: [{ range: 7, offsetDays: -1 }] }, + { name: 'dailyReview.listArchives', args: [] }, + { name: 'dailyReview.getArchive', args: ['archive'] }, + { + name: 'dailyReview.saveMarkdownToFile', + args: [{ markdown: '# Review', defaultName: 'review.md' }], + }, + { name: 'clipboard.writeText', args: ['review'] }, + ]); + }); + + it('forwards subscriptions, narrows Runtime Host events, and preserves disposers', () => { + let hostHandler: + | ((event: DesktopRuntimeHostProfileChangedEvent) => void) + | undefined; + let scheduledChangeHandler: ((event: never) => void) | undefined; + let scheduledDueHandler: ((task: never) => void) | undefined; + let disposed = 0; + const subscribe = (assign: (handler: (value: T) => void) => void) => + (handler: (value: T) => void) => { + assign(handler); + return () => { + disposed += 1; + }; + }; + const bridge = { + runtimeHostProfiles: { + getDefaultHost: async () => ({ profileId: 'local', hostId: 'local' }), + subscribeChanges: subscribe( + (handler) => { + hostHandler = handler; + }, + ), + }, + skills: Object.assign(methodRecorder([], 'skills'), { + sources: methodRecorder([], 'skills.sources'), + catalog: methodRecorder([], 'skills.catalog'), + }), + scheduledTasks: Object.assign(methodRecorder([], 'scheduledTasks'), { + subscribeChanges: subscribe((handler) => { + scheduledChangeHandler = handler; + }), + subscribeDue: subscribe((handler) => { + scheduledDueHandler = handler; + }), + }), + dailyReview: methodRecorder([], 'dailyReview'), + } as unknown as DesktopModuleHubBridge; + const services = createDesktopModuleHubServices(bridge, { + clipboard: { writeText: async () => undefined }, + }); + const hostEvents: unknown[] = []; + const taskEvents: unknown[] = []; + const dueEvents: unknown[] = []; + const unsubscribers = [ + services.runtimeHosts.subscribeChanges((event) => hostEvents.push(event)), + services.scheduledTasks.subscribeChanges((event) => taskEvents.push(event)), + services.scheduledTasks.subscribeDue((event) => dueEvents.push(event)), + ]; + const hostEvent: DesktopRuntimeHostProfileChangedEvent = { + epoch: '2', + profileId: 'remote-a', + profileName: 'Remote', + profileKind: 'remote', + readiness: 'ready', + hostId: 'host-a', + isDefault: true, + }; + const changeEvent = { + type: 'scheduled_tasks_changed' as const, + reason: 'updated', + taskId: 'task', + ts: 2, + }; + const dueEvent = { id: 'task', title: 'Task' }; + hostHandler?.(hostEvent); + scheduledChangeHandler?.(changeEvent as never); + scheduledDueHandler?.(dueEvent as never); + for (const unsubscribe of unsubscribers) unsubscribe(); + + assert.deepEqual(hostEvents, [ + { + profileId: 'remote-a', + readiness: 'ready', + hostId: 'host-a', + isDefault: true, + removed: undefined, + }, + ]); + assert.deepEqual(taskEvents, [changeEvent]); + assert.deepEqual(dueEvents, [dueEvent]); + assert.equal(disposed, 3); + }); + + it('maps keep-awake settings and safely gates an older preload', async () => { + let changed: (() => void) | undefined; + let disposed = 0; + const updates: unknown[] = []; + const base = { + runtimeHostProfiles: { + getDefaultHost: async () => ({ profileId: 'local', hostId: 'local' }), + subscribeChanges: () => () => undefined, + }, + skills: Object.assign(methodRecorder([], 'skills'), { + sources: methodRecorder([], 'skills.sources'), + catalog: methodRecorder([], 'skills.catalog'), + }), + scheduledTasks: methodRecorder([], 'scheduledTasks'), + dailyReview: methodRecorder([], 'dailyReview'), + }; + const services = createDesktopModuleHubServices( + { + ...base, + settings: { + getClient: async () => ({ + system: { keepSystemAwake: true }, + }), + updateClient: async (patch: unknown) => { + updates.push(patch); + return { settings: { system: { keepSystemAwake: false } } }; + }, + subscribeClientChanged(handler: () => void) { + changed = handler; + return () => { + disposed += 1; + }; + }, + }, + } as unknown as DesktopModuleHubBridge, + { clipboard: { writeText: async () => undefined } }, + ); + assert.equal(services.clientSettings.supported, true); + assert.equal(await services.clientSettings.getKeepSystemAwake(), true); + assert.equal(await services.clientSettings.setKeepSystemAwake(false), false); + let notifications = 0; + const unsubscribe = services.clientSettings.subscribeChanges(() => { + notifications += 1; + }); + changed?.(); + unsubscribe(); + assert.deepEqual(updates, [{ system: { keepSystemAwake: false } }]); + assert.equal(notifications, 1); + assert.equal(disposed, 1); + + const oldPreload = createDesktopModuleHubServices( + base as unknown as DesktopModuleHubBridge, + { clipboard: { writeText: async () => undefined } }, + ); + assert.equal(oldPreload.clientSettings.supported, false); + oldPreload.clientSettings.subscribeChanges(() => undefined)(); + await assert.rejects( + oldPreload.clientSettings.getKeepSystemAwake(), + /Client settings are unavailable/, + ); + await assert.rejects( + oldPreload.clientSettings.setKeepSystemAwake(true), + /Client settings are unavailable/, + ); + }); +}); diff --git a/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts b/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts new file mode 100644 index 0000000000..a6ee414d70 --- /dev/null +++ b/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts @@ -0,0 +1,474 @@ +/* + * 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 { act, createElement } from "react"; +import type { SkillEntry, ToastApi } from "@maka/ui"; +import { cleanupFakeDom, installReactRenderer } from "./fake-dom.js"; +import { + createFakeModuleHubServices, + ModuleHubServicesProvider, + useSkillsController, + type ModuleHubServices, + type SkillsController, + type UseSkillsControllerInput, +} from "../../renderer/features/module-hub/testing.js"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((settle, fail) => { + resolve = settle; + reject = fail; + }); + return { promise, reject, resolve }; +} + +function skill(id: string): SkillEntry { + return { + id, + name: id, + description: `${id} description`, + path: `/skills/${id}/SKILL.md`, + enabled: true, + runtimeStatus: "enabled", + }; +} + +type ToastRecord = { + kind: "success" | "error"; + title: string; + description?: string; + profileId?: string; +}; + +function toastRecorder( + records: ToastRecord[], +): Pick { + return { + success: (title, description) => { + records.push({ kind: "success", title, description }); + return "success-toast"; + }, + error: (title, description, _diagnosticDetails, diagnosticTarget) => { + records.push({ + kind: "error", + title, + description, + ...(diagnosticTarget && "profileId" in diagnosticTarget + ? { profileId: diagnosticTarget.profileId } + : {}), + }); + return "error-toast"; + }, + }; +} + +let latestController: SkillsController | undefined; + +function ControllerProbe(props: UseSkillsControllerInput) { + latestController = useSkillsController(props); + return null; +} + +function renderController( + root: ReturnType["root"], + services: ModuleHubServices, + input: UseSkillsControllerInput, +) { + root.render( + createElement( + ModuleHubServicesProvider, + { services }, + createElement(ControllerProbe, input), + ), + ); +} + +function controller(): SkillsController { + assert.ok(latestController); + return latestController; +} + +function input( + records: ToastRecord[], + overrides: Partial = {}, +): UseSkillsControllerInput { + return { + uiLocale: "en", + active: true, + toastApi: toastRecorder(records), + useSkillInChat: () => undefined, + ...overrides, + }; +} + +afterEach(() => { + latestController = undefined; + cleanupFakeDom(); +}); + +test("Skills projections have independent same-Host generation and default-Host fences", async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const hostA = { profileId: "profile-a", hostId: "host-a" }; + const hostB = { profileId: "profile-b", hostId: "host-b" }; + let defaultHost = hostA; + const staleSkills = deferred(); + let skillReads = 0; + const defaults = createFakeModuleHubServices(); + const services = createFakeModuleHubServices({ + runtimeHosts: { + ...defaults.runtimeHosts, + getDefault: async () => defaultHost, + }, + skills: { + ...defaults.skills, + list: async (host) => { + assert.equal(host, defaultHost); + skillReads += 1; + return skillReads === 1 + ? staleSkills.promise + : [skill(`new-${host.hostId}`)]; + }, + listManagedSources: async () => [ + { + id: "source-a", + name: "Source A", + description: "Source", + category: "效率工具", + sourceType: "local", + }, + ], + listBundledCatalog: async () => [ + { + id: "bundled-a", + name: "Bundled A", + description: "Bundled", + category: "效率工具", + declaredTools: [], + installed: false, + }, + ], + }, + }); + + await act(async () => renderController(root, services, input(records))); + const first = controller().host.onRefreshSkills(); + await act(async () => controller().host.onRefreshSkills()); + assert.deepEqual( + controller().host.skills.map(({ id }) => id), + ["new-host-a"], + ); + assert.equal(controller().revision, 1); + + await act(async () => { + staleSkills.resolve([skill("stale-host-a")]); + await first; + }); + assert.deepEqual( + controller().host.skills.map(({ id }) => id), + ["new-host-a"], + ); + assert.equal(controller().revision, 1); + + await act(async () => controller().refreshProjectSkills()); + assert.equal(controller().revision, 2); + assert.equal(controller().host.managedSkillSources[0]?.id, "source-a"); + assert.equal(controller().host.bundledSkillCatalog[0]?.id, "bundled-a"); + + const lateHostRead = deferred(); + services.skills.list = async () => lateHostRead.promise; + const pending = controller().host.onRefreshSkills(); + defaultHost = hostB; + await act(async () => { + lateHostRead.resolve([skill("late-host-a")]); + await pending; + }); + assert.deepEqual( + controller().host.skills.map(({ id }) => id), + ["new-host-a"], + ); + assert.equal(controller().revision, 2); + assert.equal(records.length, 0); +}); + +test("Skills mutations preserve refresh combinations and suppress inactive or cancelled feedback", async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const calls: string[] = []; + const defaults = createFakeModuleHubServices(); + const services = createFakeModuleHubServices({ + runtimeHosts: defaults.runtimeHosts, + skills: { + ...defaults.skills, + list: async () => { + calls.push("list"); + return []; + }, + listManagedSources: async () => { + calls.push("sources"); + return []; + }, + listBundledCatalog: async () => { + calls.push("catalog"); + return []; + }, + importManagedSource: async () => ({ ok: false, reason: "cancelled" }), + installManaged: async () => ({ ok: true, skill: skill("managed") }), + installBundled: async () => ({ ok: true, skill: skill("bundled") }), + updateManaged: async () => ({ ok: true, skill: skill("updated") }), + setEnabled: async (_id, enabled) => ({ + ok: true, + skill: { ...skill("enabled"), enabled }, + }), + setPinned: async (_id, pinned) => ({ + ok: true, + skill: { ...skill("pinned"), pinned }, + }), + delete: async () => ({ ok: true }), + }, + }); + const activeInput = input(records, { + openSkillsFolder: () => undefined, + }); + await act(async () => renderController(root, services, activeInput)); + const importManagedSkillSource = + controller().host.onImportManagedSkillSource; + assert.ok(importManagedSkillSource); + + await act(async () => importManagedSkillSource()); + assert.equal(records.length, 0); + + services.skills.importManagedSource = async () => ({ + ok: true, + source: { + id: "imported", + name: "Imported", + description: "Imported source", + category: "效率工具", + sourceType: "local", + }, + }); + await act(async () => importManagedSkillSource()); + assert.deepEqual(calls.splice(0), ["sources"]); + + await act(async () => controller().host.onInstallManagedSkill("source-a")); + assert.deepEqual(calls.splice(0), ["list", "sources"]); + assert.equal(records.at(-1)?.kind, "success"); + + await act(async () => controller().host.onInstallBundledSkill("bundled-a")); + assert.deepEqual(calls.splice(0), ["list", "catalog"]); + + await act(async () => { + assert.equal(await controller().host.onUpdateManagedSkill("managed"), true); + }); + assert.deepEqual(calls.splice(0), ["list"]); + + await act(async () => controller().host.onSetSkillEnabled("managed", false)); + assert.deepEqual(calls.splice(0), ["list"]); + + await act(async () => controller().host.onSetSkillPinned("managed", true)); + assert.deepEqual(calls.splice(0), ["list"]); + + await act(async () => + controller().host.onDeleteSkill("user:agents:bundled-a"), + ); + assert.deepEqual(calls.splice(0), ["list", "catalog"]); + assert.match(records.at(-1)?.description ?? "", /bundled-a/); + + const lateInstall = deferred>(); + services.skills.installManaged = async () => ({ + ok: true, + skill: await lateInstall.promise, + }); + const pending = controller().host.onInstallManagedSkill("late"); + await act(async () => + renderController(root, services, { ...activeInput, active: false }), + ); + const recordCount = records.length; + await act(async () => { + lateInstall.resolve(skill("late")); + await pending; + }); + assert.equal(records.length, recordCount); +}); + +test("Skills capabilities and stale mutation diagnostics are fenced", async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const hostA = { profileId: "profile-a", hostId: "host-a" }; + const hostB = { profileId: "profile-b", hostId: "host-b" }; + let defaultHost = hostA; + const openFailure = deferred(); + const used: string[] = []; + const opened: string[] = []; + const defaults = createFakeModuleHubServices(); + const services = createFakeModuleHubServices({ + runtimeHosts: { + ...defaults.runtimeHosts, + getDefault: async () => defaultHost, + }, + skills: { + ...defaults.skills, + open: async () => openFailure.promise, + }, + }); + + await act(async () => + renderController( + root, + services, + input(records, { + useSkillInChat: (id, name) => used.push(`${id}:${name}`), + }), + ), + ); + assert.equal(controller().host.onOpenSkill, undefined); + assert.equal(controller().host.onOpenSkillsFolder, undefined); + assert.equal(controller().host.onImportManagedSkillSource, undefined); + controller().host.onUseSkill("skill-a", "Skill A"); + assert.deepEqual(used, ["skill-a:Skill A"]); + + await act(async () => + renderController( + root, + services, + input(records, { + useSkillInChat: () => undefined, + openSkillsFolder: () => { + opened.push("folder"); + }, + }), + ), + ); + assert.equal(typeof controller().host.onOpenSkill, "function"); + assert.equal( + typeof controller().host.onImportManagedSkillSource, + "function", + ); + controller().host.onOpenSkillsFolder?.(); + assert.deepEqual(opened, ["folder"]); + + const pendingOpen = controller().host.onOpenSkill?.("skill-a"); + defaultHost = hostB; + await act(async () => { + openFailure.reject(new Error("old host offline")); + await pendingOpen; + }); + assert.equal(records.length, 0); + + services.skills.open = async () => ({ ok: false, reason: "missing" }); + await act(async () => controller().host.onOpenSkill?.("missing")); + assert.equal(records.length, 1); + assert.equal(records[0]?.kind, "error"); + assert.equal(records[0]?.profileId, "profile-b"); +}); + +test("Skills errors recheck the active surface after an async Host fence", async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const host = { profileId: "profile-a", hostId: "host-a" }; + const hostRecheck = deferred(); + let hostReads = 0; + const defaults = createFakeModuleHubServices(); + const services = createFakeModuleHubServices({ + runtimeHosts: { + ...defaults.runtimeHosts, + getDefault: async () => { + hostReads += 1; + return hostReads === 1 ? host : hostRecheck.promise; + }, + }, + skills: { + ...defaults.skills, + open: async () => { + throw new Error("open failed"); + }, + }, + }); + + const capability = { openSkillsFolder: () => undefined }; + await act(async () => + renderController(root, services, input(records, capability)), + ); + const pendingOpen = controller().host.onOpenSkill?.("skill-a"); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + assert.equal(hostReads, 2); + + await act(async () => + renderController( + root, + services, + input(records, { ...capability, active: false }), + ), + ); + hostRecheck.resolve(host); + await act(async () => pendingOpen); + assert.deepEqual(records, []); +}); + +test("stale Skills refresh errors do not outlive a newer successful generation", async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const host = { profileId: "profile-a", hostId: "host-a" }; + const staleHostRecheck = deferred(); + let hostReads = 0; + let skillReads = 0; + const defaults = createFakeModuleHubServices(); + const services = createFakeModuleHubServices({ + runtimeHosts: { + ...defaults.runtimeHosts, + getDefault: async () => { + hostReads += 1; + return hostReads === 2 ? staleHostRecheck.promise : host; + }, + }, + skills: { + ...defaults.skills, + list: async () => { + skillReads += 1; + if (skillReads === 1) throw new Error("stale refresh failed"); + return [skill("fresh")]; + }, + }, + }); + + await act(async () => renderController(root, services, input(records))); + const staleRefresh = controller().host.onRefreshSkills(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + assert.equal(hostReads, 2); + + await act(async () => controller().host.onRefreshSkills()); + assert.deepEqual( + controller().host.skills.map(({ id }) => id), + ["fresh"], + ); + + staleHostRecheck.resolve(host); + await act(async () => staleRefresh); + assert.deepEqual(records, []); +}); diff --git a/apps/desktop/src/main/__tests__/use-keep-system-awake.test.ts b/apps/desktop/src/main/__tests__/use-keep-system-awake.test.ts deleted file mode 100644 index 8c3e6e46e0..0000000000 --- a/apps/desktop/src/main/__tests__/use-keep-system-awake.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * 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 { act, createElement } from 'react'; -import { createDefaultSettings } from '@maka/core/settings'; -import { - useKeepSystemAwake, - type KeepSystemAwakeController, -} from '../../renderer/use-keep-system-awake.js'; -import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; - -test('keeps the Desktop preference available when no Runtime Host settings bridge is usable', async () => { - const { root } = installReactRenderer(); - let persisted = { - ...createDefaultSettings(), - system: { keepSystemAwake: true }, - }; - const updates: boolean[] = []; - (globalThis.window as unknown as { maka: unknown }).maka = { - settings: { - getClient: async () => persisted, - updateClient: async (patch: { system?: { keepSystemAwake?: boolean } }) => { - const keepSystemAwake = patch.system?.keepSystemAwake ?? persisted.system.keepSystemAwake; - updates.push(keepSystemAwake); - persisted = { ...persisted, system: { keepSystemAwake } }; - return { settings: persisted }; - }, - subscribeClientChanged: () => () => undefined, - }, - }; - - let current: KeepSystemAwakeController | undefined; - function Probe() { - current = useKeepSystemAwake(); - return null; - } - - await act(async () => { - root.render(createElement(Probe)); - }); - assert.equal(current?.keepSystemAwake, true); - - await act(async () => { - await current?.setKeepSystemAwake(false); - }); - assert.deepEqual(updates, [false]); - assert.equal(current?.keepSystemAwake, false); -}); - -afterEach(() => { - cleanupFakeDom(); - delete (globalThis as { window?: unknown }).window; -}); diff --git a/apps/desktop/src/main/__tests__/use-module-data.test.ts b/apps/desktop/src/main/__tests__/use-module-data.test.ts deleted file mode 100644 index 9c1f23b648..0000000000 --- a/apps/desktop/src/main/__tests__/use-module-data.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -/* - * 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 { mkdir, mkdtemp } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { afterEach, test } from 'node:test'; -import { act, createElement } from 'react'; -import { build } from 'esbuild'; -import type * as ModuleData from '../../renderer/use-module-data.js'; -import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; - -const REPO_ROOT = resolve(import.meta.dirname, '../../../../..'); - -function deferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((settle, fail) => { - resolve = settle; - reject = fail; - }); - return { promise, reject, resolve }; -} - -test('keeps stale same-Host module refreshes from changing state or reporting errors', async () => { - const moduleData = await importModuleData(); - const { root } = installReactRenderer(); - const host = { profileId: 'profile-a', hostId: 'host-a' }; - type Projection = 'skills' | 'managedSkillSources' | 'bundledSkillCatalog' | 'scheduledTasks'; - let activeRead: { - projection: Projection; - calls: number; - started: ReturnType>; - pending: ReturnType>>; - } | undefined; - const read = async (projection: Projection, operationHost: unknown) => { - assert.deepEqual(operationHost, host); - assert.equal(activeRead?.projection, projection); - if (!activeRead) throw new Error('No projection read is active'); - activeRead.calls += 1; - if (activeRead.calls === 1) { - activeRead.started.resolve(); - return activeRead.pending.promise; - } - return [{ id: `${projection}-new` }]; - }; - (globalThis.window as unknown as { maka: unknown }).maka = { - runtimeHostProfiles: { - getDefaultHost: async () => host, - }, - skills: { - list: (operationHost: unknown) => read('skills', operationHost), - sources: { - list: (operationHost: unknown) => read('managedSkillSources', operationHost), - }, - catalog: { - list: (operationHost: unknown) => read('bundledSkillCatalog', operationHost), - }, - }, - scheduledTasks: { - list: (operationHost: unknown) => read('scheduledTasks', operationHost), - }, - }; - let current: ReturnType | undefined; - let renderRevision = 0; - const errors: string[] = []; - - function Probe(_props: { revision: number }) { - current = moduleData.useAppShellModuleData({ - uiLocale: 'en', - isSkillsSurfaceActive: () => true, - isScheduledTasksSurfaceActive: () => true, - toastApi: { - success: () => {}, - error: (title) => errors.push(title), - confirm: async () => true, - }, - }); - return null; - } - - await act(async () => { - root.render(createElement(Probe, { revision: 0 })); - }); - - const projections: Array<{ - projection: Projection; - refresh(value: NonNullable): Promise; - values(value: NonNullable): readonly { id: string }[]; - }> = [ - { - projection: 'skills', - refresh: (value) => value.refreshSkills(), - values: (value) => value.skills, - }, - { - projection: 'managedSkillSources', - refresh: (value) => value.refreshManagedSkillSources(), - values: (value) => value.managedSkillSources, - }, - { - projection: 'bundledSkillCatalog', - refresh: (value) => value.refreshBundledSkillCatalog(), - values: (value) => value.bundledSkillCatalog, - }, - { - projection: 'scheduledTasks', - refresh: (value) => value.refreshScheduledTasks(), - values: (value) => value.scheduledTasks, - }, - ]; - - for (const { projection, refresh, values } of projections) { - const started = deferred(); - const pending = deferred>(); - activeRead = { projection, calls: 0, started, pending }; - const staleRefresh = refresh(current!); - await started.promise; - - await act(async () => { - root.render(createElement(Probe, { revision: ++renderRevision })); - }); - await act(async () => { - await refresh(current!); - }); - assert.deepEqual(values(current!).map(({ id }) => id), [`${projection}-new`]); - - await act(async () => { - pending.resolve([{ id: `${projection}-old` }]); - await staleRefresh; - }); - assert.deepEqual(values(current!).map(({ id }) => id), [`${projection}-new`]); - assert.equal(activeRead.calls, 2); - - const errorStarted = deferred(); - const pendingError = deferred>(); - activeRead = { - projection, - calls: 0, - started: errorStarted, - pending: pendingError, - }; - const staleErrorRefresh = refresh(current!); - await errorStarted.promise; - await act(async () => { - await refresh(current!); - }); - await act(async () => { - pendingError.reject(new Error(`${projection} stale failure`)); - await staleErrorRefresh; - }); - assert.deepEqual(errors, []); - assert.equal(activeRead.calls, 2); - } -}); - -afterEach(() => { - cleanupFakeDom(); -}); - -async function importModuleData(): Promise { - const outdir = await mkdtemp(resolve(REPO_ROOT, 'apps/desktop/dist/main/__tests__/module-data-')); - const outfile = resolve(outdir, 'use-module-data.mjs'); - await mkdir(dirname(outfile), { recursive: true }); - await build({ - entryPoints: [resolve(REPO_ROOT, 'apps/desktop/src/renderer/use-module-data.ts')], - outfile, - bundle: true, - platform: 'node', - format: 'esm', - target: 'node20', - external: ['react'], - logLevel: 'silent', - }); - return (await import(`${pathToFileURL(outfile).href}?t=${Date.now()}`)) as typeof ModuleData; -} diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index c2d3ae8b1d..bb42b79a18 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -18,17 +18,14 @@ */ import { useMemo, useRef } from "react"; -import type { DailyReviewSummary } from '@maka/core/daily-review'; import type { LlmConnection } from '@maka/core/llm-connections'; import type { PermissionMode } from '@maka/core/permission'; import type { SessionStartMode } from '@maka/core/explore-agent'; import type { SessionSummary, StoredMessage } from '@maka/core/session'; import type { SettingsSection, ThemePreference } from '@maka/core/settings'; import type { UiLocale } from '@maka/core/ui-locale'; -import { formatDailyReviewMarkdown } from "@maka/ui"; -import type { DailyReviewMarkdownActionInput, NavSelection } from "@maka/ui"; +import type { NavSelection } from "@maka/ui"; import type { DesktopManualDiagnosticTarget } from '../preload/diagnostics-contract.js'; -import type { DesktopRuntimeHostRef } from '../preload/bridge-contract.js'; import { defaultRuntimeHostDiagnosticTarget, runOnDefaultRuntimeHost, @@ -39,7 +36,6 @@ import { } from "./command-palette-commands.js"; import type { Command } from "./command-palette-types.js"; import { renderConversationMarkdown } from "./conversation-markdown.js"; -import { dailyReviewActionErrorMessage } from "./daily-review-actions.js"; import { commandPaletteActionErrorMessage, commandPaletteConnectionTestFailureMessage, @@ -66,18 +62,6 @@ type ComposerImportOwner = { type RefBox = { current: T }; -type ComposerAppendHandle = { - appendText(text: string): void; -}; - -type DailyReviewBridge = { - fetchDay( - offsetDays: number, - daySpan?: number, - host?: DesktopRuntimeHostRef, - ): Promise; -}; - export interface AppShellCommandListOptions { uiLocale: UiLocale; activeId: string | undefined; @@ -86,7 +70,6 @@ export interface AppShellCommandListOptions { clientPathsAccessible: boolean; connections: LlmConnection[]; defaultConnection: string | null; - dailyReviewBridge: DailyReviewBridge; messages: StoredMessage[]; newTaskProfileId: string | undefined; settingsOpen: boolean; @@ -95,13 +78,11 @@ export interface AppShellCommandListOptions { themePref: ThemePreference; visibleSessions: SessionSummary[]; captureComposerImportOwner: () => ComposerImportOwner; - composerRef: RefBox; createSession: () => void; openSideConversation: () => void; startModeSession: (mode: SessionStartMode) => Promise; - isComposerImportOwnerActive: (owner: ComposerImportOwner) => boolean; openHelp: () => void; - openScheduledTaskForm: () => void; + openScheduledTaskCreate: () => void; openProjectFolder: () => Promise; openSessionInChat: (sessionId: string) => void; openSettings: () => void; @@ -109,9 +90,9 @@ export interface AppShellCommandListOptions { openSkillsFolder: () => Promise; openWorkspaceFolder: () => Promise; refreshConnections: () => Promise; - saveDailyReviewMarkdown: ( - input: DailyReviewMarkdownActionInput, - ) => Promise; + copyTodayDailyReview: () => Promise; + pasteTodayDailyReview: () => Promise; + saveTodayDailyReview: () => Promise; setNavSelection: (selection: NavSelection) => void; setPermissionMode: (mode: PermissionMode) => Promise; setThemePref: (themePref: ThemePreference) => void; @@ -160,7 +141,7 @@ export function buildAppShellCommandList( const { startModeSession } = optionsRef.current; await startModeSession("deep_research"); }, - onStartScheduledTask: () => optionsRef.current.openScheduledTaskForm(), + onStartScheduledTask: () => optionsRef.current.openScheduledTaskCreate(), onOpenSettings: () => optionsRef.current.openSettings(), onOpenSettingsSection: (section) => optionsRef.current.openSettingsSection(section), @@ -339,145 +320,9 @@ export function buildAppShellCommandList( } : undefined, activePermissionMode: options.activePermissionMode, - onCopyTodayDailyReview: async () => { - const { dailyReviewBridge, toastApi } = optionsRef.current; - let summary: DailyReviewSummary; - try { - summary = ( - await runOnDefaultRuntimeHost((host) => - dailyReviewBridge.fetchDay(0, 1, host), - ) - ).value; - } catch (err) { - toastApi.error( - copy.copyFailedTitle, - dailyReviewActionErrorMessage( - err, - copy.reviewCopyFallback, - options.uiLocale, - ), - undefined, - defaultRuntimeHostDiagnosticTarget(err), - ); - return; - } - try { - const markdown = formatDailyReviewMarkdown( - summary, - copy.today, - options.uiLocale, - ); - await navigator.clipboard.writeText(markdown); - toastApi.success( - copy.reviewCopiedTitle, - copy.reviewSummary( - summary.totals.sessionCount, - summary.totals.requestCount, - ), - ); - } catch (err) { - toastApi.error( - copy.copyFailedTitle, - dailyReviewActionErrorMessage( - err, - copy.clipboardDenied, - options.uiLocale, - ), - ); - } - }, - onPasteTodayDailyReviewIntoComposer: async () => { - const { - captureComposerImportOwner, - composerRef, - dailyReviewBridge, - isComposerImportOwnerActive, - toastApi, - } = optionsRef.current; - const owner = captureComposerImportOwner(); - if (!owner.sessionId) return; - try { - const summary = ( - await runOnDefaultRuntimeHost((host) => - dailyReviewBridge.fetchDay(0, 1, host), - ) - ).value; - const markdown = formatDailyReviewMarkdown( - summary, - copy.today, - options.uiLocale, - ); - if (!isComposerImportOwnerActive(owner)) return; - composerRef.current?.appendText(markdown); - toastApi.success( - copy.reviewPastedTitle, - copy.reviewSummary( - summary.totals.sessionCount, - summary.totals.requestCount, - ), - ); - } catch (err) { - if (isComposerImportOwnerActive(owner)) { - toastApi.error( - copy.pasteFailedTitle, - dailyReviewActionErrorMessage( - err, - copy.reviewUnavailable, - options.uiLocale, - ), - undefined, - defaultRuntimeHostDiagnosticTarget(err), - ); - } - } - }, - onSaveTodayDailyReviewToFile: async () => { - const { dailyReviewBridge, saveDailyReviewMarkdown, toastApi } = - optionsRef.current; - let summary: DailyReviewSummary; - try { - summary = ( - await runOnDefaultRuntimeHost((host) => - dailyReviewBridge.fetchDay(0, 1, host), - ) - ).value; - } catch (err) { - toastApi.error( - copy.saveFailedTitle, - dailyReviewActionErrorMessage( - err, - copy.reviewUnavailable, - options.uiLocale, - ), - undefined, - defaultRuntimeHostDiagnosticTarget(err), - ); - return; - } - try { - const markdown = formatDailyReviewMarkdown( - summary, - copy.today, - options.uiLocale, - ); - await saveDailyReviewMarkdown({ - day: summary.day, - range: 1, - totals: summary.totals, - markdown, - label: copy.today, - }); - } catch (err) { - toastApi.error( - copy.saveFailedTitle, - dailyReviewActionErrorMessage( - err, - copy.reviewUnavailable, - options.uiLocale, - ), - ); - } - }, + onCopyTodayDailyReview: () => optionsRef.current.copyTodayDailyReview(), + onPasteTodayDailyReviewIntoComposer: () => optionsRef.current.pasteTodayDailyReview(), + onSaveTodayDailyReviewToFile: () => optionsRef.current.saveTodayDailyReview(), onCopyDiagnostics: async () => { const { captureComposerImportOwner, diff --git a/apps/desktop/src/renderer/app-shell-copy.ts b/apps/desktop/src/renderer/app-shell-copy.ts index 78da030fd6..2f15529095 100644 --- a/apps/desktop/src/renderer/app-shell-copy.ts +++ b/apps/desktop/src/renderer/app-shell-copy.ts @@ -77,10 +77,3 @@ function commandPaletteConnectionTestFailureFallback(result: ConnectionTestResul } return copy.unknown; } - -export function openSkillFailureCopy( - reason: 'invalid_id' | 'missing' | 'blocked_path' | 'not_file' | 'not_directory' | 'open_failed', - locale: UiLocale, -): string { - return getShellCopy(locale).skillActions.openFailures[reason]; -} diff --git a/apps/desktop/src/renderer/app-shell-daily-review-actions.ts b/apps/desktop/src/renderer/app-shell-daily-review-actions.ts deleted file mode 100644 index 27b81dc2be..0000000000 --- a/apps/desktop/src/renderer/app-shell-daily-review-actions.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* - * 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 { UiLocale } from '@maka/core/ui-locale'; -import type { DailyReviewMarkdownActionInput } from '@maka/ui'; -import { dailyReviewActionErrorMessage, dailyReviewExportDefaultName } from './daily-review-actions'; -import { getShellCopy } from './locales/shell-copy.js'; - -type ToastApi = { - success(title: string, description?: string): void; - error(title: string, description?: string): void; -}; - -type RefBox = { current: T }; - -type ComposerAppendHandle = { - appendText(text: string): void; -}; - -type DailyReviewFeedbackOptions = { - shouldShowFeedback?: () => boolean; -}; - -export interface AppShellDailyReviewActions { - copyDailyReviewMarkdown(input: DailyReviewMarkdownActionInput, options?: DailyReviewFeedbackOptions): Promise; - appendDailyReviewMarkdown(input: DailyReviewMarkdownActionInput): void; - saveDailyReviewMarkdown(input: DailyReviewMarkdownActionInput, options?: DailyReviewFeedbackOptions): Promise; -} - -export function createAppShellDailyReviewActions(deps: { - uiLocale: UiLocale; - composerRef: RefBox; - toastApi: ToastApi; -}): AppShellDailyReviewActions { - const { uiLocale, composerRef, toastApi } = deps; - const copy = getShellCopy(uiLocale).commandActions; - - async function copyDailyReviewMarkdown(input: DailyReviewMarkdownActionInput, options: DailyReviewFeedbackOptions = {}) { - const shouldShowFeedback = options.shouldShowFeedback ?? (() => true); - try { - await navigator.clipboard.writeText(input.markdown); - if (shouldShowFeedback()) { - toastApi.success( - copy.reviewCopied(input.label), - copy.reviewSummary(input.totals.sessionCount, input.totals.requestCount), - ); - } - } catch (error) { - if (shouldShowFeedback()) { - toastApi.error(copy.copyFailedTitle, dailyReviewActionErrorMessage(error, copy.clipboardDenied, uiLocale)); - } - } - } - - function appendDailyReviewMarkdown(input: DailyReviewMarkdownActionInput): void { - composerRef.current?.appendText(input.markdown); - toastApi.success( - copy.reviewPasted(input.label), - copy.reviewSummary(input.totals.sessionCount, input.totals.requestCount), - ); - } - - async function saveDailyReviewMarkdown(input: DailyReviewMarkdownActionInput, options: DailyReviewFeedbackOptions = {}) { - const shouldShowFeedback = options.shouldShowFeedback ?? (() => true); - try { - const result = await window.maka.dailyReview.saveMarkdownToFile({ - markdown: input.markdown, - defaultName: dailyReviewExportDefaultName({ range: input.range, day: input.day }), - }); - if (result.ok) { - if (shouldShowFeedback()) { - toastApi.success( - copy.reviewSaved(input.label), - copy.reviewSummary(input.totals.sessionCount, input.totals.requestCount), - ); - } - } else if (result.reason === 'canceled') { - // User dismissed the dialog, no toast. - } else if (result.reason === 'invalid_input') { - if (shouldShowFeedback()) toastApi.error(copy.saveFailedTitle, copy.invalidExport); - } else { - if (shouldShowFeedback()) toastApi.error(copy.saveFailedTitle, copy.writeFailed); - } - } catch (err) { - if (shouldShowFeedback()) { - toastApi.error(copy.saveFailedTitle, dailyReviewActionErrorMessage(err, copy.reviewSaveFallback, uiLocale)); - } - } - } - - return { - copyDailyReviewMarkdown, - appendDailyReviewMarkdown, - saveDailyReviewMarkdown, - }; -} diff --git a/apps/desktop/src/renderer/app-shell-daily-review-bridge.ts b/apps/desktop/src/renderer/app-shell-daily-review-bridge.ts deleted file mode 100644 index 7752a4f84f..0000000000 --- a/apps/desktop/src/renderer/app-shell-daily-review-bridge.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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 { DailyReviewRange } from '@maka/core/daily-review'; -import type { UiLocale } from '@maka/core/ui-locale'; -import type { DesktopRuntimeHostRef } from '../preload/bridge-contract.js'; -import { getShellRemainingCopy } from './locales/shell-remaining-copy.js'; - -export function createAppShellDailyReviewBridge(locale: UiLocale = 'zh') { - const copy = getShellRemainingCopy(locale).dailyReview; - return { - async fetchDay(offsetDays: number, daySpan?: number, host?: DesktopRuntimeHostRef) { - const result = await window.maka.dailyReview.day(offsetDays, daySpan, host); - if (!result.ok) throw new Error(result.error.message); - return result.data; - }, - runOnce(input: { range: DailyReviewRange; offsetDays?: number }) { - const runOnce = window.maka.dailyReview.runOnce; - if (!runOnce) throw new Error(copy.unavailable); - return runOnce(input); - }, - listArchives() { - const listArchives = window.maka.dailyReview.listArchives; - if (!listArchives) throw new Error(copy.historyUnavailable); - return listArchives(); - }, - async getArchive(archiveId: string) { - const getArchive = window.maka.dailyReview.getArchive; - if (!getArchive) throw new Error(copy.historyUnavailable); - const archive = await getArchive(archiveId); - if (!archive) throw new Error(copy.archiveMissing); - return archive; - }, - }; -} diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index ed37f4cafb..47282012f6 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -31,7 +31,6 @@ import { type ShellRunUpdate } from '@maka/core/events'; import type { LiveTurnProjection, NavSelection, SessionViewMode } from '@maka/ui'; import { messageReadErrorMessage } from './app-shell-copy'; import { getDesktopConversationCopy } from './locales/conversation-copy.js'; -import { getShellRemainingCopy } from './locales/shell-remaining-copy.js'; import { applyTheme, applyThemePalette } from './theme'; import { startTitlebarModalSync } from './titlebar-modal-sync'; import { safeLocalStorageSet } from './browser-storage'; @@ -196,26 +195,17 @@ export function useAppShellBootstrapSubscriptions(options: { refreshConnections: () => Promise; refreshMemoryActive: (failureContext?: 'load') => Promise; refreshMessages: (sessionId: string) => Promise; - refreshScheduledTasks: (options?: { shouldShowError?: () => boolean }) => Promise; refreshProjects: () => Promise; refreshShellSettings: () => Promise; - refreshSkills: (options?: { shouldShowError?: () => boolean }) => Promise; - refreshManagedSkillSources: (options?: { shouldShowError?: () => boolean }) => Promise; - refreshBundledSkillCatalog: (options?: { shouldShowError?: () => boolean }) => Promise; refreshSessions: () => Promise; rendererMountedRef: RefBox; setActiveId: (sessionId: string | undefined) => void; setMessages: (messages: StoredMessage[]) => void; - setNavSelection: (selection: NavSelection) => void; setSessionEventHealthBySession: SessionEventHealthUpdater; toastApi: ToastApi; }) { const runDeferredStartupRefreshes = useEffectEvent(() => { void options.refreshSessions(); - void options.refreshSkills(); - void options.refreshManagedSkillSources(); - void options.refreshBundledSkillCatalog(); - void options.refreshScheduledTasks(); void options.applyE2eFixture(); }); const handleConnectionSubscriptionEvent = useEffectEvent((event: ConnectionEvent) => { @@ -236,10 +226,6 @@ export function useAppShellBootstrapSubscriptions(options: { void options.refreshProjects(); void options.refreshConnections(); void options.refreshMemoryActive('load'); - void options.refreshSkills(); - void options.refreshManagedSkillSources(); - void options.refreshBundledSkillCatalog(); - void options.refreshScheduledTasks(); }); // PR-2088: the macOS application menu routes New Task / Settings / Keyboard // Shortcuts here through one channel. The renderer already owns these @@ -297,23 +283,6 @@ export function useAppShellBootstrapSubscriptions(options: { } }, ); - const handleScheduledTaskChange = useEffectEvent(() => { - void options.refreshScheduledTasks(); - }); - const handleScheduledTaskDue = useEffectEvent((task: { id: string; title: string }) => { - const copy = getShellRemainingCopy(options.uiLocale).notifications; - void options.refreshScheduledTasks(); - options.toastApi.toast({ - title: copy.scheduledTask, - description: task.title, - variant: 'info', - duration: 8000, - action: { - label: copy.viewScheduledTasks, - onClick: () => options.setNavSelection({ section: 'automations', module: 'scheduled-tasks' }), - }, - }); - }); // Both shortcuts fire while the composer has focus — they always did, and // that is the point of a global new-task / settings key — so both opt out of // the hook's default "stay silent while typing" rule. @@ -379,8 +348,6 @@ export function useAppShellBootstrapSubscriptions(options: { () => void options.refreshShellSettings(), ); const unsubscribeSessionChanges = window.maka.sessions.subscribeChanges(handleSessionChange); - const unsubscribeScheduledTaskChanges = window.maka.scheduledTasks.subscribeChanges(handleScheduledTaskChange); - const unsubscribeScheduledTaskDue = window.maka.scheduledTasks.subscribeDue(handleScheduledTaskDue); const unsubscribeWindowCommand = window.maka.appWindow.subscribeCommand(handleWindowCommand); markRendererMounted(); return () => { @@ -391,8 +358,6 @@ export function useAppShellBootstrapSubscriptions(options: { unsubscribeSettingsExternal(); unsubscribeClientSettings(); unsubscribeSessionChanges(); - unsubscribeScheduledTaskChanges(); - unsubscribeScheduledTaskDue(); unsubscribeWindowCommand(); }; }, []); diff --git a/apps/desktop/src/renderer/app-shell-open-skill-action.ts b/apps/desktop/src/renderer/app-shell-open-skill-action.ts deleted file mode 100644 index a5588f4f2f..0000000000 --- a/apps/desktop/src/renderer/app-shell-open-skill-action.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * 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 { UiLocale } from '@maka/core/ui-locale'; -import { openSkillFailureCopy } from './app-shell-copy'; -import { - defaultRuntimeHostDiagnosticTarget, - runOnDefaultRuntimeHost, -} from './default-runtime-host-operation.js'; -import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js'; - -type ToastApi = { - error(title: string, description?: string, diagnosticTarget?: { profileId: string }): void; -}; - -export function createOpenSkillAction(deps: { - uiLocale: UiLocale; - isSkillsSurfaceActive: () => boolean; - toastApi: ToastApi; -}): (skillId: string) => Promise { - const { uiLocale, isSkillsSurfaceActive, toastApi } = deps; - const copy = getShellCopy(uiLocale).skillActions; - - async function openSkill(skillId: string) { - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.skills.open(skillId, 'file', host), - ); - if (!result.ok) { - if (isSkillsSurfaceActive()) - toastApi.error( - copy.openFailedTitle, - openSkillFailureCopy(result.reason, uiLocale), - diagnosticTarget, - ); - } - } catch (error) { - if (isSkillsSurfaceActive()) { - toastApi.error( - copy.openFailedTitle, - localizedShellErrorMessage(error, copy.openFallback, uiLocale), - defaultRuntimeHostDiagnosticTarget(error), - ); - } - } - } - - return openSkill; -} diff --git a/apps/desktop/src/renderer/app-shell-scheduled-task-actions.ts b/apps/desktop/src/renderer/app-shell-scheduled-task-actions.ts deleted file mode 100644 index 54a35cc63b..0000000000 --- a/apps/desktop/src/renderer/app-shell-scheduled-task-actions.ts +++ /dev/null @@ -1,232 +0,0 @@ -/* - * 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 { Dispatch, SetStateAction } from "react"; -import type { CreateScheduledTaskInput, ScheduledTask, UpdateScheduledTaskInput } from '@maka/core/scheduled-task'; -import type { UiLocale } from '@maka/core/ui-locale'; -import { getShellRemainingCopy } from "./locales/shell-remaining-copy.js"; -import { localizedShellErrorMessage } from "./locales/shell-copy.js"; -import type { DesktopRuntimeHostRef } from '../preload/bridge-contract.js'; -import { - defaultRuntimeHostDiagnosticTarget, - runIfDefaultRuntimeHostCurrent, - runOnDefaultRuntimeHost, -} from './default-runtime-host-operation.js'; - -type ToastApi = { - success(title: string, description?: string): void; - error( - title: string, - description?: string, - diagnosticDetails?: string, - diagnosticTarget?: { profileId: string }, - ): void; - confirm(options: { - title: string; - description: string; - confirmLabel: string; - cancelLabel: string; - destructive?: boolean; - }): Promise; -}; - -type ScheduledTaskCreateInput = Omit; -type RefBox = { current: T }; - -export interface AppShellScheduledTaskActions { - refreshScheduledTasks(options?: { - shouldShowError?: () => boolean; - }): Promise; - createScheduledTask(input: ScheduledTaskCreateInput): Promise; - updateScheduledTask(id: string, patch: UpdateScheduledTaskInput): Promise; - toggleScheduledTask(id: string, enabled: boolean): Promise; - triggerScheduledTaskNow(id: string): Promise; - snoozeScheduledTask(id: string): Promise; - clearScheduledTaskRunHistory(id: string): Promise; - deleteScheduledTask(id: string): Promise; -} - -export function createAppShellScheduledTaskActions(deps: { - uiLocale: UiLocale; - getScheduledTasks: () => readonly ScheduledTask[]; - isScheduledTasksSurfaceActive: () => boolean; - refreshGenerationsRef: RefBox<{ scheduledTasks: number }>; - setScheduledTasks: Dispatch>; - toastApi: ToastApi; -}): AppShellScheduledTaskActions { - const { - uiLocale, - getScheduledTasks, - isScheduledTasksSurfaceActive, - refreshGenerationsRef, - setScheduledTasks, - toastApi, - } = deps; - const copy = getShellRemainingCopy(uiLocale).scheduledTaskActions; - - async function refreshScheduledTasks( - options: { shouldShowError?: () => boolean } = {}, - ) { - const generation = ++refreshGenerationsRef.current.scheduledTasks; - try { - const next = await runOnDefaultRuntimeHost((host) => - window.maka.scheduledTasks.list(host), - ); - await runIfDefaultRuntimeHostCurrent(next.host, () => { - if (generation === refreshGenerationsRef.current.scheduledTasks) { - setScheduledTasks(next.value); - } - }); - } catch (error) { - if (generation !== refreshGenerationsRef.current.scheduledTasks) return; - if (options.shouldShowError?.() ?? true) { - toastApi.error( - copy.refreshFailed, - localizedShellErrorMessage(error, copy.refreshFallback, uiLocale), - undefined, - defaultRuntimeHostDiagnosticTarget(error), - ); - } - } - } - - async function runScheduledTaskMutation(mutation: { - run: (host: DesktopRuntimeHostRef) => Promise; - successTitle?: string; - successDetail?: string; - errorTitle: string; - errorFallback: string; - errorMessage?: (error: unknown) => string | undefined; - }): Promise { - try { - await runOnDefaultRuntimeHost(mutation.run); - await refreshScheduledTasks({ - shouldShowError: isScheduledTasksSurfaceActive, - }); - if (mutation.successTitle && isScheduledTasksSurfaceActive()) { - toastApi.success(mutation.successTitle, mutation.successDetail); - } - return true; - } catch (error) { - if (isScheduledTasksSurfaceActive()) { - toastApi.error( - mutation.errorTitle, - mutation.errorMessage?.(error) ?? - localizedShellErrorMessage(error, mutation.errorFallback, uiLocale), - undefined, - defaultRuntimeHostDiagnosticTarget(error), - ); - } - return false; - } - } - - return { - refreshScheduledTasks, - createScheduledTask(input) { - return runScheduledTaskMutation({ - run: (host) => window.maka.scheduledTasks.create(input, host), - successTitle: copy.created, - successDetail: input.title, - errorTitle: copy.createFailed, - errorFallback: copy.createFallback, - errorMessage: (error) => - errorMessage(error).includes("SCHEDULED_TASK_INCOGNITO_ACTIVE") - ? copy.createIncognitoBlocked - : undefined, - }); - }, - updateScheduledTask(id, patch) { - return runScheduledTaskMutation({ - run: (host) => window.maka.scheduledTasks.update(id, patch, host), - successTitle: copy.saved, - successDetail: patch.title, - errorTitle: copy.saveFailed, - errorFallback: copy.saveFallback, - }); - }, - async toggleScheduledTask(id, enabled) { - await runScheduledTaskMutation({ - run: (host) => window.maka.scheduledTasks.setEnabled(id, enabled, host), - successTitle: enabled ? copy.enabled : copy.paused, - errorTitle: copy.updateFailed, - errorFallback: copy.updateFallback, - }); - }, - async triggerScheduledTaskNow(id) { - const task = getScheduledTasks().find((entry) => entry.id === id); - await runScheduledTaskMutation({ - run: (host) => window.maka.scheduledTasks.triggerNow(id, host), - successTitle: copy.triggered, - successDetail: task?.title, - errorTitle: copy.triggerFailed, - errorFallback: copy.triggerFallback, - }); - }, - async snoozeScheduledTask(id) { - const task = getScheduledTasks().find((entry) => entry.id === id); - await runScheduledTaskMutation({ - run: (host) => window.maka.scheduledTasks.snooze(id, host), - successTitle: copy.snoozed, - successDetail: task?.title, - errorTitle: copy.snoozeFailed, - errorFallback: copy.snoozeFallback, - }); - }, - async clearScheduledTaskRunHistory(id) { - const task = getScheduledTasks().find((entry) => entry.id === id); - const ok = await toastApi.confirm({ - title: copy.clearTitle(task?.title ?? copy.task), - description: copy.clearDescription, - confirmLabel: copy.clear, - cancelLabel: copy.cancel, - destructive: true, - }); - if (!ok) return; - await runScheduledTaskMutation({ - run: (host) => window.maka.scheduledTasks.clearRunHistory(id, host), - successTitle: copy.cleared, - successDetail: task?.title, - errorTitle: copy.clearFailed, - errorFallback: copy.clearFallback, - }); - }, - async deleteScheduledTask(id) { - const task = getScheduledTasks().find((entry) => entry.id === id); - const ok = await toastApi.confirm({ - title: copy.deleteTitle(task?.title ?? copy.task), - description: copy.deleteDescription, - confirmLabel: copy.delete, - cancelLabel: copy.cancel, - destructive: true, - }); - if (!ok) return; - await runScheduledTaskMutation({ - run: (host) => window.maka.scheduledTasks.delete(id, host), - successTitle: copy.deleted, - errorTitle: copy.deleteFailed, - errorFallback: copy.deleteFallback, - }); - }, - }; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error ?? ""); -} diff --git a/apps/desktop/src/renderer/app-shell-skill-actions.ts b/apps/desktop/src/renderer/app-shell-skill-actions.ts deleted file mode 100644 index 22be8179d5..0000000000 --- a/apps/desktop/src/renderer/app-shell-skill-actions.ts +++ /dev/null @@ -1,377 +0,0 @@ -/* - * 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 { Dispatch, SetStateAction } from 'react'; -import type { UiLocale } from '@maka/core/ui-locale'; -import type { - BundledSkillCatalogEntry, - ManagedSkillSourceEntry, - ManagedSkillUpdatePreview, - SkillEntry, -} from '@maka/ui'; -import { openSkillFailureCopy } from './app-shell-copy'; -import { createOpenSkillAction } from './app-shell-open-skill-action'; -import { - defaultRuntimeHostDiagnosticTarget, - runIfDefaultRuntimeHostCurrent, - runOnDefaultRuntimeHost, -} from './default-runtime-host-operation.js'; -import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js'; - -type ToastApi = { - success(title: string, description?: string): void; - error( - title: string, - description?: string, - diagnosticDetails?: string, - diagnosticTarget?: { profileId: string }, - ): void; -}; - -type RefBox = { current: T }; - -export interface AppShellSkillActions { - refreshSkills(options?: { shouldShowError?: () => boolean }): Promise; - refreshManagedSkillSources(options?: { shouldShowError?: () => boolean }): Promise; - refreshBundledSkillCatalog(options?: { shouldShowError?: () => boolean }): Promise; - importManagedSkillSource(): Promise; - installManagedSkill(sourceId: string): Promise; - installBundledSkill(id: string): Promise; - previewManagedSkillUpdate(skillId: string): Promise; - updateManagedSkill( - skillId: string, - options?: { - force?: boolean; - expectedCurrentSha256?: string; - expectedSourceSha256?: string; - }, - ): Promise; - setSkillEnabled(skillId: string, enabled: boolean): Promise; - setSkillPinned(skillRef: string, pinned: boolean): Promise; - deleteSkill(skillRef: string): Promise; - openSkill(skillId: string): Promise; -} - -export function createAppShellSkillActions(deps: { - uiLocale: UiLocale; - isSkillsSurfaceActive: () => boolean; - refreshGenerationsRef: RefBox<{ - skills: number; - managedSkillSources: number; - bundledSkillCatalog: number; - }>; - setSkills: Dispatch>; - setManagedSkillSources: Dispatch>; - setBundledSkillCatalog: Dispatch>; - toastApi: ToastApi; -}): AppShellSkillActions { - const { - uiLocale, - isSkillsSurfaceActive, - refreshGenerationsRef, - setBundledSkillCatalog, - setManagedSkillSources, - setSkills, - toastApi: baseToastApi, - } = deps; - const toastApi = { - success: (title: string, description?: string) => - baseToastApi.success(title, description), - error: (title: string, description?: string, diagnosticTarget?: { profileId: string }) => - baseToastApi.error(title, description, undefined, diagnosticTarget), - }; - const reportRuntimeHostError = (title: string, fallback: string, error: unknown) => - toastApi.error( - title, - localizedShellErrorMessage(error, fallback, uiLocale), - defaultRuntimeHostDiagnosticTarget(error), - ); - const copy = getShellCopy(uiLocale).skillActions; - const openSkill = createOpenSkillAction({ - uiLocale, - isSkillsSurfaceActive, - toastApi, - }); - - async function refreshSkills(options: { shouldShowError?: () => boolean } = {}) { - const generation = ++refreshGenerationsRef.current.skills; - try { - const next = await runOnDefaultRuntimeHost((host) => window.maka.skills.list(host)); - await runIfDefaultRuntimeHostCurrent(next.host, () => { - if (generation === refreshGenerationsRef.current.skills) setSkills(next.value); - }); - } catch (error) { - if (generation !== refreshGenerationsRef.current.skills) return; - if (options.shouldShowError?.() ?? true) { - reportRuntimeHostError(copy.refreshSkillsFailedTitle, copy.refreshSkillsFallback, error); - } - } - } - - async function refreshManagedSkillSources(options: { shouldShowError?: () => boolean } = {}) { - const generation = ++refreshGenerationsRef.current.managedSkillSources; - try { - const next = await runOnDefaultRuntimeHost((host) => window.maka.skills.sources.list(host)); - await runIfDefaultRuntimeHostCurrent(next.host, () => { - if (generation === refreshGenerationsRef.current.managedSkillSources) { - setManagedSkillSources(next.value); - } - }); - } catch (error) { - if (generation !== refreshGenerationsRef.current.managedSkillSources) return; - if (options.shouldShowError?.() ?? true) { - reportRuntimeHostError(copy.refreshSourcesFailedTitle, copy.refreshSourcesFallback, error); - } - } - } - - async function refreshBundledSkillCatalog(options: { shouldShowError?: () => boolean } = {}) { - const generation = ++refreshGenerationsRef.current.bundledSkillCatalog; - try { - const next = await runOnDefaultRuntimeHost((host) => window.maka.skills.catalog.list(host)); - await runIfDefaultRuntimeHostCurrent(next.host, () => { - if (generation === refreshGenerationsRef.current.bundledSkillCatalog) { - setBundledSkillCatalog(next.value); - } - }); - } catch (error) { - if (generation !== refreshGenerationsRef.current.bundledSkillCatalog) return; - if (options.shouldShowError?.() ?? true) { - reportRuntimeHostError(copy.refreshBundledFailedTitle, copy.refreshBundledFallback, error); - } - } - } - - async function installBundledSkill(id: string) { - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.skills.catalog.install(id, host), - ); - if (!result.ok) { - if (isSkillsSurfaceActive()) - toastApi.error( - copy.installBundledFailedTitle, - copy.installFailures[result.reason], - diagnosticTarget, - ); - return; - } - await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); - await refreshBundledSkillCatalog({ - shouldShowError: isSkillsSurfaceActive, - }); - if (isSkillsSurfaceActive()) - toastApi.success(copy.installedBundledTitle, copy.installedDescription(result.skill.id)); - } catch (error) { - if (isSkillsSurfaceActive()) { - reportRuntimeHostError(copy.installBundledFailedTitle, copy.installBundledFallback, error); - } - } - } - - async function importManagedSkillSource() { - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.skills.sources.importLocalFile(host), - ); - if (!result.ok) { - if (result.reason !== 'cancelled' && isSkillsSurfaceActive()) { - toastApi.error( - copy.importSourceFailedTitle, - copy.sourceFailures[result.reason], - diagnosticTarget, - ); - } - return; - } - await refreshManagedSkillSources({ - shouldShowError: isSkillsSurfaceActive, - }); - if (isSkillsSurfaceActive()) toastApi.success(copy.importedSourceTitle, result.source.name); - } catch (error) { - if (isSkillsSurfaceActive()) { - reportRuntimeHostError(copy.importSourceFailedTitle, copy.importSourceFallback, error); - } - } - } - - async function installManagedSkill(sourceId: string) { - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.skills.installManaged(sourceId, host), - ); - if (!result.ok) { - if (isSkillsSurfaceActive()) { - toastApi.error(copy.installFailedTitle, copy.installFailures[result.reason], diagnosticTarget); - } - return; - } - await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); - await refreshManagedSkillSources({ - shouldShowError: isSkillsSurfaceActive, - }); - if (isSkillsSurfaceActive()) toastApi.success(copy.installedTitle, copy.installedDescription(result.skill.id)); - } catch (error) { - if (isSkillsSurfaceActive()) { - reportRuntimeHostError(copy.installFailedTitle, copy.installFallback, error); - } - } - } - - async function previewManagedSkillUpdate(skillId: string): Promise { - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.skills.previewUpdate(skillId, host), - ); - if (!result.ok) { - if (isSkillsSurfaceActive()) { - toastApi.error(copy.previewFailedTitle, copy.previewFailures[result.reason], diagnosticTarget); - } - return null; - } - return result.preview; - } catch (error) { - if (isSkillsSurfaceActive()) { - reportRuntimeHostError(copy.previewFailedTitle, copy.previewFallback, error); - } - return null; - } - } - - async function updateManagedSkill( - skillId: string, - options: { - force?: boolean; - expectedCurrentSha256?: string; - expectedSourceSha256?: string; - } = {}, - ): Promise { - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.skills.updateManaged(skillId, options, host), - ); - if (!result.ok) { - if (isSkillsSurfaceActive()) { - toastApi.error(copy.updateFailedTitle, copy.updateFailures[result.reason], diagnosticTarget); - } - return false; - } - await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); - if (isSkillsSurfaceActive()) { - toastApi.success( - options.force ? copy.forceUpdatedTitle : copy.updatedTitle, - copy.updatedDescription(result.skill.id), - ); - } - return true; - } catch (error) { - if (isSkillsSurfaceActive()) { - reportRuntimeHostError(copy.updateFailedTitle, copy.updateFallback, error); - } - return false; - } - } - - async function setSkillEnabled(skillId: string, enabled: boolean) { - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.skills.setEnabled(skillId, enabled, host), - ); - if (!result.ok) { - if (isSkillsSurfaceActive()) { - toastApi.error(copy.toggleFailedTitle, copy.runtimeFailures[result.reason], diagnosticTarget); - } - return; - } - await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); - if (isSkillsSurfaceActive()) { - toastApi.success(enabled ? copy.enabledTitle : copy.disabledTitle, copy.runtimeDescription(result.skill.name)); - } - } catch (error) { - if (isSkillsSurfaceActive()) { - reportRuntimeHostError(copy.toggleFailedTitle, copy.toggleFallback, error); - } - } - } - - async function setSkillPinned(skillRef: string, pinned: boolean) { - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.skills.setPinned(skillRef, pinned, host), - ); - if (!result.ok) { - if (isSkillsSurfaceActive()) { - toastApi.error(copy.toggleFailedTitle, copy.runtimeFailures[result.reason], diagnosticTarget); - } - return; - } - await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); - if (isSkillsSurfaceActive()) { - toastApi.success(pinned ? copy.pinnedTitle : copy.unpinnedTitle, result.skill.name); - } - } catch (error) { - if (isSkillsSurfaceActive()) { - reportRuntimeHostError(copy.toggleFailedTitle, copy.toggleFallback, error); - } - } - } - - async function deleteSkill(skillRef: string) { - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.skills.delete(skillRef, host), - ); - if (!result.ok) { - if (isSkillsSurfaceActive()) { - toastApi.error(copy.deleteFailedTitle, copy.deleteFailures[result.reason], diagnosticTarget); - } - return; - } - await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); - // A deleted bundled skill must reappear as installable under 内置, so - // refresh the catalog's installed flags after removal. - await refreshBundledSkillCatalog({ - shouldShowError: isSkillsSurfaceActive, - }); - // The ref is scope-qualified (`user:agents:gh-cli`); the toast shows the - // bare skill id, which is what the row itself is labelled with. - const displayId = skillRef.slice(skillRef.lastIndexOf(':') + 1); - if (isSkillsSurfaceActive()) toastApi.success(copy.deletedTitle, copy.deletedDescription(displayId)); - } catch (error) { - if (isSkillsSurfaceActive()) { - reportRuntimeHostError(copy.deleteFailedTitle, copy.deleteFallback, error); - } - } - } - - return { - refreshSkills, - refreshManagedSkillSources, - refreshBundledSkillCatalog, - importManagedSkillSource, - installManagedSkill, - installBundledSkill, - previewManagedSkillUpdate, - updateManagedSkill, - setSkillEnabled, - setSkillPinned, - deleteSkill, - openSkill, - }; -} diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 29a97fb25b..e4567392e9 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -28,7 +28,6 @@ import { type Dispatch, type SetStateAction, } from 'react'; -import type { ScheduledTask } from '@maka/core/scheduled-task'; import type { ProjectRecord } from '@maka/core/project'; import type { FollowUpMode, @@ -46,8 +45,6 @@ import { resolveUiLocale } from '@maka/core/ui-locale'; import { slashCommandsForSurface } from '@maka/core/slash-command-catalog'; import { hasSettledInitialOnboarding } from '@maka/core/onboarding-milestone'; import { - ScheduledTasksPage, - DailyReviewPage, ChatSurfaceLayout, type ComposerHandle, type ComposerSendMetadata, @@ -56,14 +53,12 @@ import { MakaUriContext, AstryxLocaleProvider, LocaleProvider, - ModuleHubSelector, ToastProvider, type ToastDiagnosticTarget, type ToastErrorAction, type NavSelection, SessionListPanel, type SessionHistoryGroup, - SkillsPage, type SessionViewMode, TitlebarSessionIdentity, type TurnFooterActionMeta, @@ -73,7 +68,6 @@ import { deriveTitlebarProjectName, enqueueInteraction, getConversationCopy, - getSharedUiCopy, reconcileInteractions, } from '@maka/ui'; import type { ConnectionEvent } from '@maka/core/connections'; @@ -99,6 +93,7 @@ import { useWorkbarController, } from './features/workbar'; import { GoalHost, useGoalController } from './features/goals'; +import { ModuleHubHost, useModuleHubController } from './features/module-hub'; import { UNRESOLVED_NEW_TASK_DRAFT_KEY } from './new-task-reload-intent'; import { useNewTaskChoice } from './use-new-task-choice'; import { NEW_TASK_PENDING_KEY } from './pending-items'; @@ -113,7 +108,6 @@ import { PlanProposalCard, usePlanModeState, } from './plan-mode-panel'; -import { McpPage } from './mcp-page'; import { getOnboardingActivationCandidate, useOnboardingSnapshot } from './use-onboarding-snapshot'; import type { AppUpdateStatus, @@ -168,13 +162,10 @@ import { AppShellOverlays } from './app-shell-overlays'; import type { ArchivedTasksBridge } from './settings/tasks-settings-page'; import { CustomPetCompanion } from './custom-pet-companion'; import { derivePetActivityState } from './custom-pet-companion-model'; -import { createAppShellDailyReviewBridge } from './app-shell-daily-review-bridge'; import { defaultRuntimeHostDiagnosticTarget, runOnDefaultRuntimeHost, } from './default-runtime-host-operation.js'; -import { useAppShellModuleData } from './use-module-data'; -import { useKeepSystemAwake } from './use-keep-system-awake'; import { useAppShellProjectContext } from './use-project-context'; import { createAppShellSessionDisplayBatch, @@ -197,7 +188,6 @@ import { type TurnRevisionDraft, } from './app-shell-revision-actions'; import { createAppShellSessionStartActions } from './app-shell-session-start-actions'; -import { createAppShellDailyReviewActions } from './app-shell-daily-review-actions'; import { createAppShellSessionRowActions } from './app-shell-session-row-actions'; import { createAppShellSessionSettingsActions } from './app-shell-session-settings-actions'; import { createAppShellStopAction } from './app-shell-stop-action'; @@ -426,7 +416,6 @@ function AppShellContent({ // Plan toggle and one orchestration value, not one fused choice. const [newChatPlanModeActive, setNewChatPlanModeActive] = useState(false); const [newChatOrchestrationMode, setNewChatOrchestrationMode] = useState('default'); - const [scheduledTaskCreateRequestNonce, setScheduledTaskCreateRequestNonce] = useState(0); const [newTaskPermissionChoice, setNewTaskPermissionChoice, clearNewTaskPermissionChoice] = useNewTaskChoice(currentNewTaskDraftKey); const [historyLoadPendingSessionId, setHistoryLoadPendingSessionId] = useState(); @@ -690,29 +679,6 @@ function AppShellContent({ ); }); }, [updateReminder, shellCopy, toastApi, uiLocale]); - const moduleHubCopy = getSharedUiCopy(uiLocale).moduleHubs; - const extensionsHubHeader = { - title: moduleHubCopy.extensions.title, - subtitle: moduleHubCopy.extensions.description, - badge: ( - setNavSelection({ section: 'extensions', module })} - /> - ), - }; - const automationsHubHeader = { - title: moduleHubCopy.automations.title, - subtitle: moduleHubCopy.automations.description, - badge: ( - setNavSelection({ section: 'automations', module })} - /> - ), - }; // Persisted composer defaults seed the empty-state model, project path, and // recent workspace history so the home view is populated before the async // `app:info` round-trip completes on mount. @@ -769,19 +735,6 @@ function AppShellContent({ }), [sessions, onboarding.snapshot?.sessionSendOutcomes], ); - // PR-DAILY-REVIEW-MVP-0: bridge for the main Daily Review module. - // Memoized so the panel's `useEffect` cleanup keys - // off a stable reference instead of refetching on every render. - const dailyReviewBridge = useMemo(() => createAppShellDailyReviewBridge(uiLocale), [uiLocale]); - const { - appendDailyReviewMarkdown, - copyDailyReviewMarkdown, - saveDailyReviewMarkdown, - } = useStableActions(createAppShellDailyReviewActions, { - uiLocale, - composerRef, - toastApi, - }); const activeInteraction = activeInteractionFor(interactionBySession, activeId); const activeSandboxBoundary = activeInteraction?.type === 'sandbox_boundary_request' ? activeInteraction : undefined; @@ -1463,55 +1416,7 @@ function AppShellContent({ }, [activeId, activeStreamingLive, shellCopy.slashCommands, turnActive], ); - function isScheduledTasksSurfaceActive(): boolean { - return navSelectionRef.current.section === 'automations' && navSelectionRef.current.module === 'scheduled-tasks'; - } - - function isSkillsSurfaceActive(): boolean { - return navSelectionRef.current.section === 'extensions' && navSelectionRef.current.module === 'skills'; - } - - function isDailyReviewSurfaceActive(): boolean { - return navSelectionRef.current.section === 'automations' && navSelectionRef.current.module === 'daily-review'; - } - - const { - skills, - managedSkillSources, - bundledSkillCatalog, - scheduledTasks, - refreshScheduledTasks, - createScheduledTask, - updateScheduledTask, - toggleScheduledTask, - triggerScheduledTaskNow, - snoozeScheduledTask, - clearScheduledTaskRunHistory, - deleteScheduledTask, - refreshSkills, - refreshManagedSkillSources, - refreshBundledSkillCatalog, - importManagedSkillSource, - installManagedSkill, - installBundledSkill, - previewManagedSkillUpdate, - updateManagedSkill, - setSkillEnabled, - setSkillPinned, - deleteSkill, - openSkill, - } = useAppShellModuleData({ - uiLocale, - isSkillsSurfaceActive, - isScheduledTasksSurfaceActive, - toastApi, - }); - - // 保持系统唤醒 capability for the 定时任务 page: reads/writes - // settings.system.keepSystemAwake over the existing settings bridge. When - // the bridge is absent the panel hides the row (fail-soft). - const keepSystemAwakeController = useKeepSystemAwake(); - + const refreshProjectSkillsRef = useRef<() => Promise>(async () => {}); const { projectInfo, projects, @@ -1538,13 +1443,39 @@ function AppShellContent({ sessionProjectId: activeSession?.projectId, sessionProfileKind: activeDesktopSession?.profileKind, onProjectSelected: (ownerSessionId) => { - void refreshSkills(); - void refreshManagedSkillSources(); - void refreshBundledSkillCatalog(); + void refreshProjectSkillsRef.current(); if (ownerSessionId && activeIdRef.current === ownerSessionId) openNewTaskSurface(); }, toastApi, }); + const captureActiveComposerClaim = useCallback(() => { + const sessionId = activeIdRef.current; + const composer = composerRef.current; + if ( + !sessionId || + !composer || + navSelectionRef.current.section !== 'sessions' + ) { + return undefined; + } + return { + isCurrent: () => + activeIdRef.current === sessionId && + navSelectionRef.current.section === 'sessions' && + composerRef.current === composer, + append: (text: string) => composer.appendText(text), + }; + }, []); + const moduleHub = useModuleHubController({ + selection: navSelection, + selectModule: setNavSelection, + ...(projectCapabilities.viewClientPath ? { openSkillsFolder } : {}), + useSkillInChat, + openSession: (sessionId) => openSessionInChatRef.current(sessionId), + appendComposerText: (text) => composerRef.current?.appendText(text), + captureActiveComposerClaim, + }); + refreshProjectSkillsRef.current = moduleHub.commands.refreshProjectSkills; const workHubController = useMemo(() => createWorkHubController({ sessions: createDesktopWorkHubSessionPort({ sessions: window.maka.sessions, @@ -1683,7 +1614,7 @@ function AppShellContent({ // host-compatible projection; `@` uses workspace file search. Keep the // resolved project path as a refresh key for new-chat project changes. const { mentionSkills, mentionSkillsUnavailable, mentionSkillsLoading, searchMentionFiles } = useComposerMentions({ - skills, + skillCatalogRevision: moduleHub.selectors.skillCatalogRevision, sessionId: activeId, projectPath: activeId ? projectInfo?.projectPath : newTask.projectPath, newTaskTarget: activeId ? undefined : newTask.target, @@ -2307,17 +2238,12 @@ function AppShellContent({ refreshConnections: refreshConnectionProjections, refreshMemoryActive, refreshMessages, - refreshScheduledTasks, refreshProjects, refreshShellSettings, - refreshSkills, - refreshManagedSkillSources, - refreshBundledSkillCatalog, refreshSessions, rendererMountedRef, setActiveId, setMessages, - setNavSelection, setSessionEventHealthBySession, toastApi, }); @@ -2511,12 +2437,6 @@ function AppShellContent({ openNewTaskSurface(); } - function openScheduledTaskForm() { - setNavSelection({ section: 'automations', module: 'scheduled-tasks' }); - closePalette(); - setScheduledTaskCreateRequestNonce((nonce) => nonce + 1); - } - /** * PR-UI-RENDER-2 - single chokepoint for the Markdown internal-URI * router. Receives a typed `MakaUriDest` from the link override in @@ -2664,7 +2584,6 @@ function AppShellContent({ : projectCapabilities.viewClientPath, connections: defaultHostConnections.snapshot.connections, defaultConnection: defaultHostConnections.snapshot.defaultConnection, - dailyReviewBridge, messages, newTaskProfileId: newTask.selectedProfileId, settingsOpen, @@ -2673,12 +2592,13 @@ function AppShellContent({ themePref, visibleSessions, captureComposerImportOwner, - composerRef, createSession, startModeSession, - isComposerImportOwnerActive, openHelp, - openScheduledTaskForm, + openScheduledTaskCreate: () => { + closePalette(); + moduleHub.commands.openScheduledTaskCreate(); + }, openProjectFolder, openSessionInChat, openSideConversation: () => workbar.commands.openTool('side-chat'), @@ -2687,7 +2607,9 @@ function AppShellContent({ openSkillsFolder, openWorkspaceFolder, refreshConnections: defaultHostConnections.refreshConnections, - saveDailyReviewMarkdown, + copyTodayDailyReview: moduleHub.commands.copyTodayDailyReview, + pasteTodayDailyReview: moduleHub.commands.pasteTodayDailyReview, + saveTodayDailyReview: moduleHub.commands.saveTodayDailyReview, setNavSelection, setPermissionMode, setThemePref, @@ -2829,7 +2751,7 @@ function AppShellContent({ selection={navSelection} sessions={visibleSessions} activeId={workHubActive ? undefined : sidebarActiveId} - scheduledTasks={scheduledTasks} + scheduledTasks={moduleHub.selectors.scheduledTasks} streamingSessionIds={streamingSessionIds} staleSessionIds={staleSessionIds} viewMode={viewMode} @@ -2873,75 +2795,7 @@ function AppShellContent({
- {navSelection.section === 'extensions' && navSelection.module === 'skills' ? ( - refreshSkills()} - onRefreshManagedSkillSources={() => refreshManagedSkillSources()} - onOpenSkill={projectCapabilities.viewClientPath - ? (skillId) => openSkill(skillId) - : undefined} - onUseSkill={useSkillInChat} - onOpenSkillsFolder={projectCapabilities.viewClientPath - ? () => openSkillsFolder() - : undefined} - managedSkillSources={managedSkillSources} - onImportManagedSkillSource={projectCapabilities.viewClientPath - ? () => importManagedSkillSource() - : undefined} - onInstallManagedSkill={(sourceId) => installManagedSkill(sourceId)} - bundledSkillCatalog={bundledSkillCatalog} - onRefreshBundledSkillCatalog={() => refreshBundledSkillCatalog()} - onInstallBundledSkill={(id) => installBundledSkill(id)} - onPreviewManagedSkillUpdate={(skillId) => previewManagedSkillUpdate(skillId)} - onUpdateManagedSkill={(skillId, options) => updateManagedSkill(skillId, options)} - onSetSkillEnabled={(skillId, enabled) => setSkillEnabled(skillId, enabled)} - onSetSkillPinned={(skillRef, pinned) => setSkillPinned(skillRef, pinned)} - onDeleteSkill={(skillRef) => deleteSkill(skillRef)} - /> - ) : navSelection.section === 'extensions' && navSelection.module === 'mcp' ? ( - - ) : navSelection.section === 'automations' && navSelection.module === 'scheduled-tasks' ? ( - setScheduledTaskCreateRequestNonce(0)} - keepSystemAwake={ - keepSystemAwakeController.supported - ? keepSystemAwakeController.keepSystemAwake - : undefined - } - onKeepSystemAwakeChange={ - keepSystemAwakeController.supported - ? keepSystemAwakeController.setKeepSystemAwake - : undefined - } - onRefresh={() => - refreshScheduledTasks({ - shouldShowError: isScheduledTasksSurfaceActive, - }) - } - onCreate={(input) => createScheduledTask(input)} - onUpdate={(id, patch) => updateScheduledTask(id, patch)} - onToggle={(id, enabled) => toggleScheduledTask(id, enabled)} - onTriggerNow={(id) => triggerScheduledTaskNow(id)} - onSnooze={(id) => snoozeScheduledTask(id)} - onClearRunHistory={(id) => clearScheduledTaskRunHistory(id)} - onDelete={(id) => deleteScheduledTask(id)} - /> - ) : navSelection.section === 'automations' && navSelection.module === 'daily-review' ? ( - copyDailyReviewMarkdown(input, { shouldShowFeedback: isDailyReviewSurfaceActive })} - onAppendMarkdown={appendDailyReviewMarkdown} - onSaveMarkdown={(input) => saveDailyReviewMarkdown(input, { shouldShowFeedback: isDailyReviewSurfaceActive })} - /> - ) : null} + {workHubEnabled && workHubActive && navSelection.section === 'sessions' ? ( + +# Module Hub feature slice + +`module-hub` owns the Desktop renderer behavior behind Extensions and +Automations: + +- installed, managed-source, and bundled-catalog Skills projections and + mutations; +- Scheduled Tasks projection, mutations, due/change subscriptions, and the + create-dialog request nonce; +- the keep-system-awake client setting shown by Scheduled Tasks; +- Daily Review page bridge, page actions, and Command Palette commands; +- selection and header composition for Skills, MCP, Scheduled Tasks, and Daily + Review. + +`AppShell` still owns top-level `NavSelection`, module-memory persistence, +Session/Project navigation, and the Composer. Those capabilities cross the +boundary only as intents. The feature exposes a read-only Scheduled Tasks +projection to the Session rail and a revision number that invalidates the +Composer's separate Runtime-owned invocable-Skills projection; neither makes +the Shell an owner of Module Hub data. + +## Dependency direction + +Production consumers import only `features/module-hub/index`. Tests and +Storybook may import `features/module-hub/testing`. The feature imports core/UI +types and renderer-neutral locale/formatting helpers, but it must not import +AppShell, preload, or main-process modules and must not access `window.maka` or +`navigator`. + +All environment I/O is represented by `ModuleHubServices` and mapped once by +`platform/desktop/create-module-hub-services.ts`. The adapter is also where an +older preload is converted into an unsupported keep-awake capability. + +MCP is the explicit exception to I/O ownership in this slice. `McpPage` keeps +its existing page-owned controller and direct Desktop bridge. `ModuleHubHost` +only selects and mounts that leaf; moving MCP internals is a separate change. + +## Lifecycle invariants + +- The three Skills projections and Scheduled Tasks each have independent + generation fences. +- Host-scoped reads re-check the current default Runtime Host before committing. + Late reads, mutation feedback, and diagnostics from an old Host are dropped. +- Initial Skills and Scheduled Tasks refresh remains deferred to the first + animation frame. A ready default-Host change refreshes both clusters. +- Scheduled Task change and due subscriptions are disposed with the controller; + due notifications retain the navigation action. +- Mutation feedback is live-surface fenced. Confirmation continuations are + also abandoned after leaving Scheduled Tasks. +- Keep-awake reads, external updates, and writes share a generation so a slow + completion cannot overwrite newer confirmed settings; failed writes still + reject for the panel's optimistic revert. +- 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. +- Daily Review paste captures the active Composer before its first await and + validates the Session, navigation owner, and Composer handle before append and + feedback. +- Opening Scheduled Task creation selects the page and increments the request + nonce; the page acknowledgement resets it to zero. + +There is intentionally no feature-level reducer or store: these projections and +commands have real lifecycle ownership, while navigation persistence remains a +Shell concern. diff --git a/apps/desktop/src/renderer/features/module-hub/controller/default-runtime-host.ts b/apps/desktop/src/renderer/features/module-hub/controller/default-runtime-host.ts new file mode 100644 index 0000000000..a5af5bde95 --- /dev/null +++ b/apps/desktop/src/renderer/features/module-hub/controller/default-runtime-host.ts @@ -0,0 +1,103 @@ +/* + * 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 { + ModuleHubRuntimeHostRef, + ModuleHubRuntimeHostsService, +} from '../ports.js'; + +export type ModuleHubDiagnosticTarget = { readonly profileId: string }; + +class ModuleHubDefaultRuntimeHostOperationError extends Error { + readonly diagnosticTarget: ModuleHubDiagnosticTarget; + readonly host: ModuleHubRuntimeHostRef; + + constructor( + cause: unknown, + host: ModuleHubRuntimeHostRef, + diagnosticTarget: ModuleHubDiagnosticTarget, + ) { + super(cause instanceof Error ? cause.message : String(cause), { cause }); + this.name = cause instanceof Error ? cause.name : 'Error'; + this.host = host; + this.diagnosticTarget = diagnosticTarget; + } +} + +export async function runOnDefaultRuntimeHost( + runtimeHosts: ModuleHubRuntimeHostsService, + operation: (host: ModuleHubRuntimeHostRef) => Promise, +): Promise<{ + readonly value: T; + readonly host: ModuleHubRuntimeHostRef; + readonly diagnosticTarget: ModuleHubDiagnosticTarget; +}> { + const host = await runtimeHosts.getDefault(); + const diagnosticTarget = { profileId: host.profileId }; + try { + return { value: await operation(host), host, diagnosticTarget }; + } catch (error) { + throw new ModuleHubDefaultRuntimeHostOperationError( + error, + host, + diagnosticTarget, + ); + } +} + +export async function isDefaultRuntimeHostCurrent( + runtimeHosts: ModuleHubRuntimeHostsService, + host: ModuleHubRuntimeHostRef, +): Promise { + try { + const currentHost = await runtimeHosts.getDefault(); + return ( + currentHost.profileId === host.profileId && + currentHost.hostId === host.hostId + ); + } catch { + return false; + } +} + +export async function runIfDefaultRuntimeHostCurrent( + runtimeHosts: ModuleHubRuntimeHostsService, + host: ModuleHubRuntimeHostRef, + operation: () => unknown | Promise, +): Promise { + if (!(await isDefaultRuntimeHostCurrent(runtimeHosts, host))) return false; + await operation(); + return true; +} + +export function defaultRuntimeHostDiagnosticTarget( + error: unknown, +): ModuleHubDiagnosticTarget | undefined { + return error instanceof ModuleHubDefaultRuntimeHostOperationError + ? error.diagnosticTarget + : undefined; +} + +export function defaultRuntimeHostOperationHost( + error: unknown, +): ModuleHubRuntimeHostRef | undefined { + return error instanceof ModuleHubDefaultRuntimeHostOperationError + ? error.host + : undefined; +} diff --git a/apps/desktop/src/renderer/features/module-hub/controller/module-hub-lifecycle.ts b/apps/desktop/src/renderer/features/module-hub/controller/module-hub-lifecycle.ts new file mode 100644 index 0000000000..fecb5b4075 --- /dev/null +++ b/apps/desktop/src/renderer/features/module-hub/controller/module-hub-lifecycle.ts @@ -0,0 +1,53 @@ +/* + * 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 { ModuleHubRuntimeHostsService } from '../ports.js'; + +export interface ModuleHubLifecycleScheduler { + requestFrame(callback: FrameRequestCallback): number; + cancelFrame(handle: number): void; +} + +const browserScheduler: ModuleHubLifecycleScheduler = { + requestFrame: (callback) => requestAnimationFrame(callback), + cancelFrame: (handle) => cancelAnimationFrame(handle), +}; + +/** Owns Module Hub startup deferral and default Runtime Host invalidation. */ +export function startModuleHubLifecycle(input: { + runtimeHosts: ModuleHubRuntimeHostsService; + refreshProjectSkills(): void; + refreshScheduledTasks(): void; + scheduler?: ModuleHubLifecycleScheduler; +}): () => void { + const scheduler = input.scheduler ?? browserScheduler; + const refreshAll = () => { + input.refreshProjectSkills(); + input.refreshScheduledTasks(); + }; + const startupFrame = scheduler.requestFrame(refreshAll); + const unsubscribeRuntimeHosts = input.runtimeHosts.subscribeChanges((event) => { + if (event.readiness !== 'ready' || !event.isDefault) return; + refreshAll(); + }); + return () => { + scheduler.cancelFrame(startupFrame); + unsubscribeRuntimeHosts(); + }; +} diff --git a/apps/desktop/src/renderer/features/module-hub/controller/module-hub-route.ts b/apps/desktop/src/renderer/features/module-hub/controller/module-hub-route.ts new file mode 100644 index 0000000000..156a0e139b --- /dev/null +++ b/apps/desktop/src/renderer/features/module-hub/controller/module-hub-route.ts @@ -0,0 +1,35 @@ +/* + * 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 { NavSelection } from '@maka/ui'; + +export type ModuleHubHostRoute = + | 'skills' + | 'mcp' + | 'scheduled-tasks' + | 'daily-review' + | null; + +export function resolveModuleHubHostRoute( + selection: NavSelection, +): ModuleHubHostRoute { + if (selection.section === 'extensions') return selection.module; + if (selection.section === 'automations') return selection.module; + return null; +} 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 new file mode 100644 index 0000000000..64b68812c9 --- /dev/null +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-daily-review-controller.ts @@ -0,0 +1,448 @@ +/* + * 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 { useMemo, useRef } from 'react'; +import type { + DailyReviewArchive, + DailyReviewArchiveSummary, + DailyReviewRange, + DailyReviewSummary, +} from '@maka/core/daily-review'; +import type { UiLocale } from '@maka/core/ui-locale'; +import { + formatDailyReviewMarkdown, + type DailyReviewMarkdownActionInput, + useMountedRef, +} from '@maka/ui'; +import { + dailyReviewActionErrorMessage, + dailyReviewExportDefaultName, +} from '../../../daily-review-actions.js'; +import { getShellCopy } from '../../../locales/shell-copy.js'; +import { getShellRemainingCopy } from '../../../locales/shell-remaining-copy.js'; +import type { ModuleHubRuntimeHostRef, ModuleHubServices } from '../ports.js'; +import { + defaultRuntimeHostDiagnosticTarget, + defaultRuntimeHostOperationHost, + isDefaultRuntimeHostCurrent, + runOnDefaultRuntimeHost, +} from './default-runtime-host.js'; + +type DailyReviewFeedbackOptions = { + readonly shouldShowFeedback?: () => boolean; +}; + +export interface ModuleHubToastApi { + success(title: string, description?: string): void; + error( + title: string, + description?: string, + diagnosticDetails?: string, + diagnosticTarget?: { sessionId: string } | { profileId: string }, + ): void; +} + +export interface ActiveComposerClaim { + /** True only while the same Session, navigation owner and composer still own this claim. */ + isCurrent(): boolean; + /** Appends to the composer that was captured with the claim. */ + append(text: string): void; +} + +/** Structural equivalent of the UI bridge; kept here so @maka/ui stays leaf-only. */ +export interface DailyReviewBridge { + fetchDay(offsetDays: number, daySpan?: number): Promise; + runOnce?(input: { + range: DailyReviewRange; + offsetDays?: number; + }): Promise<{ archiveId: string }>; + listArchives?(): Promise; + getArchive?(archiveId: string): Promise; +} + +export interface DailyReviewController { + readonly bridge: DailyReviewBridge; + copyMarkdown( + input: DailyReviewMarkdownActionInput, + options?: DailyReviewFeedbackOptions, + ): Promise; + appendMarkdown(input: DailyReviewMarkdownActionInput): void; + saveMarkdown( + input: DailyReviewMarkdownActionInput, + options?: DailyReviewFeedbackOptions, + ): Promise; + copyToday(): Promise; + pasteToday(): Promise; + saveToday(): Promise; +} + +export interface UseDailyReviewControllerInput { + readonly services: ModuleHubServices; + readonly uiLocale: UiLocale; + readonly toastApi: ModuleHubToastApi; + readonly appendComposerText: (text: string) => void; + readonly captureActiveComposerClaim: () => ActiveComposerClaim | undefined; + readonly isDailyReviewSurfaceActive: () => boolean; +} + +class StaleDailyReviewHostError extends Error { + constructor() { + super('The default Runtime Host changed while loading Daily Review'); + this.name = 'StaleDailyReviewHostError'; + } +} + +async function operationFailureIsCurrent( + services: ModuleHubServices, + error: unknown, +): Promise { + if (error instanceof StaleDailyReviewHostError) return false; + const host = defaultRuntimeHostOperationHost(error); + return host ? isDefaultRuntimeHostCurrent(services.runtimeHosts, host) : true; +} + +/** + * Runs a Daily Review read against the default Host and refuses to expose a + * result after that Host changes. One retry absorbs the normal profile-switch + * race without making the page bridge identity depend on Host state. + */ +async function readCurrentDefaultHost( + services: ModuleHubServices, + operation: (host: ModuleHubRuntimeHostRef) => Promise, +): Promise { + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const result = await runOnDefaultRuntimeHost( + services.runtimeHosts, + operation, + ); + if ( + await isDefaultRuntimeHostCurrent(services.runtimeHosts, result.host) + ) { + return result.value; + } + } catch (error) { + if (await operationFailureIsCurrent(services, error)) throw error; + } + } + throw new StaleDailyReviewHostError(); +} + +export function createDailyReviewBridge( + services: ModuleHubServices, + locale: UiLocale, +): DailyReviewBridge { + const copy = getShellRemainingCopy(locale).dailyReview; + return { + async fetchDay(offsetDays: number, daySpan?: number) { + return readCurrentDefaultHost(services, async (host) => { + const result = await services.dailyReview.day( + offsetDays, + daySpan, + host, + ); + if (!result.ok) throw new Error(result.error.message); + return result.data; + }); + }, + runOnce(input) { + return services.dailyReview.runOnce(input); + }, + listArchives() { + return services.dailyReview.listArchives(); + }, + async getArchive(archiveId: string) { + const archive = await services.dailyReview.getArchive(archiveId); + if (!archive) throw new Error(copy.archiveMissing); + return archive; + }, + }; +} + +export function useDailyReviewController( + input: UseDailyReviewControllerInput, +): DailyReviewController { + const mountedRef = useMountedRef(); + const inputRef = useRef(input); + inputRef.current = input; + + const bridge = useMemo( + () => createDailyReviewBridge(input.services, input.uiLocale), + [input.services, input.uiLocale], + ); + + return useMemo(() => { + const copy = getShellCopy(input.uiLocale).commandActions; + const showIfMounted = (predicate: () => boolean = () => true) => + mountedRef.current && predicate(); + const shouldReportOperationFailure = async ( + error: unknown, + predicate: () => boolean = () => true, + ) => { + if (!showIfMounted(predicate)) return false; + if (!(await operationFailureIsCurrent(input.services, error))) + return false; + return showIfMounted(predicate); + }; + + async function copyMarkdown( + markdownInput: DailyReviewMarkdownActionInput, + options: DailyReviewFeedbackOptions = {}, + ) { + const shouldShowFeedback = options.shouldShowFeedback ?? (() => true); + const shouldShowPageFeedback = () => + inputRef.current.isDailyReviewSurfaceActive() && shouldShowFeedback(); + try { + await input.services.clipboard.writeText(markdownInput.markdown); + if (showIfMounted(shouldShowPageFeedback)) { + inputRef.current.toastApi.success( + copy.reviewCopied(markdownInput.label), + copy.reviewSummary( + markdownInput.totals.sessionCount, + markdownInput.totals.requestCount, + ), + ); + } + } catch (error) { + if (showIfMounted(shouldShowPageFeedback)) { + inputRef.current.toastApi.error( + copy.copyFailedTitle, + dailyReviewActionErrorMessage( + error, + copy.clipboardDenied, + input.uiLocale, + ), + ); + } + } + } + + function appendMarkdown(markdownInput: DailyReviewMarkdownActionInput) { + inputRef.current.appendComposerText(markdownInput.markdown); + if (showIfMounted(inputRef.current.isDailyReviewSurfaceActive)) { + inputRef.current.toastApi.success( + copy.reviewPasted(markdownInput.label), + copy.reviewSummary( + markdownInput.totals.sessionCount, + markdownInput.totals.requestCount, + ), + ); + } + } + + async function persistMarkdown( + markdownInput: DailyReviewMarkdownActionInput, + shouldShowFeedback: () => boolean, + ) { + try { + const result = await input.services.dailyReview.saveMarkdownToFile({ + markdown: markdownInput.markdown, + defaultName: dailyReviewExportDefaultName({ + range: markdownInput.range, + day: markdownInput.day, + }), + }); + if (!showIfMounted(shouldShowFeedback)) return; + if (result.ok) { + inputRef.current.toastApi.success( + copy.reviewSaved(markdownInput.label), + copy.reviewSummary( + markdownInput.totals.sessionCount, + markdownInput.totals.requestCount, + ), + ); + } else if (result.reason === 'invalid_input') { + inputRef.current.toastApi.error( + copy.saveFailedTitle, + copy.invalidExport, + ); + } else if (result.reason === 'write_failed') { + inputRef.current.toastApi.error( + copy.saveFailedTitle, + copy.writeFailed, + ); + } + // A canceled save dialog deliberately has no feedback. + } catch (error) { + if (showIfMounted(shouldShowFeedback)) { + inputRef.current.toastApi.error( + copy.saveFailedTitle, + dailyReviewActionErrorMessage( + error, + copy.reviewSaveFallback, + input.uiLocale, + ), + ); + } + } + } + + async function saveMarkdown( + markdownInput: DailyReviewMarkdownActionInput, + options: DailyReviewFeedbackOptions = {}, + ) { + const shouldShowFeedback = options.shouldShowFeedback ?? (() => true); + await persistMarkdown( + markdownInput, + () => + inputRef.current.isDailyReviewSurfaceActive() && shouldShowFeedback(), + ); + } + + async function readToday() { + return readCurrentDefaultHost(input.services, async (host) => { + const result = await input.services.dailyReview.day(0, 1, host); + if (!result.ok) throw new Error(result.error.message); + return result.data; + }); + } + + async function copyToday() { + let summary; + try { + summary = await readToday(); + } catch (error) { + if (await shouldReportOperationFailure(error)) { + inputRef.current.toastApi.error( + copy.copyFailedTitle, + dailyReviewActionErrorMessage( + error, + copy.reviewCopyFallback, + input.uiLocale, + ), + undefined, + defaultRuntimeHostDiagnosticTarget(error), + ); + } + return; + } + + try { + const markdown = formatDailyReviewMarkdown( + summary, + copy.today, + input.uiLocale, + ); + await input.services.clipboard.writeText(markdown); + if (showIfMounted()) { + inputRef.current.toastApi.success( + copy.reviewCopiedTitle, + copy.reviewSummary( + summary.totals.sessionCount, + summary.totals.requestCount, + ), + ); + } + } catch (error) { + if (showIfMounted()) { + inputRef.current.toastApi.error( + copy.copyFailedTitle, + dailyReviewActionErrorMessage( + error, + copy.clipboardDenied, + input.uiLocale, + ), + ); + } + } + } + + async function pasteToday() { + // Capture before the first await. The claim owns both the validity check + // and append target, so a Session/nav/composer switch cannot redirect a + // late Daily Review result into the new owner. + const claim = inputRef.current.captureActiveComposerClaim(); + if (!claim) return; + try { + const summary = await readToday(); + if (!claim.isCurrent() || !showIfMounted()) return; + claim.append( + formatDailyReviewMarkdown(summary, copy.today, input.uiLocale), + ); + if (!claim.isCurrent() || !showIfMounted()) return; + inputRef.current.toastApi.success( + copy.reviewPastedTitle, + copy.reviewSummary( + summary.totals.sessionCount, + summary.totals.requestCount, + ), + ); + } catch (error) { + if (await shouldReportOperationFailure(error, claim.isCurrent)) { + inputRef.current.toastApi.error( + copy.pasteFailedTitle, + dailyReviewActionErrorMessage( + error, + copy.reviewUnavailable, + input.uiLocale, + ), + undefined, + defaultRuntimeHostDiagnosticTarget(error), + ); + } + } + } + + async function saveToday() { + let summary; + try { + summary = await readToday(); + } catch (error) { + if (await shouldReportOperationFailure(error)) { + inputRef.current.toastApi.error( + copy.saveFailedTitle, + dailyReviewActionErrorMessage( + error, + copy.reviewUnavailable, + input.uiLocale, + ), + undefined, + defaultRuntimeHostDiagnosticTarget(error), + ); + } + return; + } + const markdown = formatDailyReviewMarkdown( + summary, + copy.today, + input.uiLocale, + ); + await persistMarkdown( + { + day: summary.day, + range: 1, + totals: summary.totals, + markdown, + label: copy.today, + }, + () => true, + ); + } + + return { + bridge, + copyMarkdown, + appendMarkdown, + saveMarkdown, + copyToday, + pasteToday, + saveToday, + }; + }, [bridge, input.services, input.uiLocale, mountedRef]); +} diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-keep-system-awake-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-keep-system-awake-controller.ts new file mode 100644 index 0000000000..035179a030 --- /dev/null +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-keep-system-awake-controller.ts @@ -0,0 +1,93 @@ +/* + * 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 { useCallback, useEffect, useRef, useState } from 'react'; +import { useMountedRef } from '@maka/ui'; +import type { ModuleHubServices } from '../ports.js'; + +export interface KeepSystemAwakeController { + /** False for a non-Electron surface or older preload without this bridge. */ + readonly supported: boolean; + /** Last confirmed persisted value; undefined until the first read settles. */ + readonly keepSystemAwake: boolean | undefined; + /** Rejects on persistence failure so the Scheduled Tasks panel can revert. */ + setKeepSystemAwake(next: boolean): Promise; +} + +/** + * Owns the Desktop client setting surfaced by Scheduled Tasks. The UI owns its + * short-lived optimistic checkbox; this hook owns the confirmed snapshot and + * makes rejected writes observable so that UI can revert. + */ +export function useKeepSystemAwakeController( + services: ModuleHubServices, +): KeepSystemAwakeController { + const settings = services.clientSettings; + const supported = settings.supported; + const [keepSystemAwake, setSnapshot] = useState(); + const mountedRef = useMountedRef(); + // Reads, writes and external-change refreshes share a generation. A slow + // older completion cannot overwrite the newest confirmed client setting. + const generationRef = useRef(0); + + const refresh = useCallback(async () => { + if (!supported) return; + const generation = ++generationRef.current; + try { + const next = await settings.getKeepSystemAwake(); + if (mountedRef.current && generation === generationRef.current) { + setSnapshot(next); + } + } catch { + // `false` is the persisted default and the only safe fallback. Preserve + // a snapshot that was already confirmed by an earlier successful read. + if (mountedRef.current && generation === generationRef.current) { + setSnapshot((previous) => previous ?? false); + } + } + }, [mountedRef, settings, supported]); + + useEffect(() => { + if (!supported) return; + void refresh(); + const dispose = settings.subscribeChanges(() => { + void refresh(); + }); + return () => { + generationRef.current += 1; + dispose(); + }; + }, [refresh, settings, supported]); + + const setKeepSystemAwake = useCallback( + async (next: boolean) => { + if (!supported) throw new Error('Keep-system-awake settings are unavailable'); + const generation = ++generationRef.current; + // Do not catch: the Scheduled Tasks panel needs the rejection to revert + // its optimistic checkbox and surface the existing localized toast. + const confirmed = await settings.setKeepSystemAwake(next); + if (mountedRef.current && generation === generationRef.current) { + setSnapshot(confirmed); + } + }, + [mountedRef, settings, supported], + ); + + return { supported, keepSystemAwake, setKeepSystemAwake }; +} diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts new file mode 100644 index 0000000000..8ebfce96b5 --- /dev/null +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts @@ -0,0 +1,165 @@ +/* + * 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, useMemo, useRef } from 'react'; +import type { ScheduledTask } from '@maka/core/scheduled-task'; +import type { NavSelection } from '@maka/ui'; +import { useToast, useUiLocale } from '@maka/ui'; +import { useModuleHubServices } from '../services-context.js'; +import { startModuleHubLifecycle } from './module-hub-lifecycle.js'; +import { + useDailyReviewController, + type ActiveComposerClaim, + type DailyReviewController, +} from './use-daily-review-controller.js'; +import { + useKeepSystemAwakeController, + type KeepSystemAwakeController, +} from './use-keep-system-awake-controller.js'; +import { + useScheduledTasksController, + type ScheduledTasksController, +} from './use-scheduled-tasks-controller.js'; +import { + useSkillsController, + type SkillsHostModel, +} from './use-skills-controller.js'; + +export interface ModuleHubHostModel { + readonly selection: NavSelection; + readonly selectModule: (selection: NavSelection) => void; + readonly skills: SkillsHostModel; + readonly scheduledTasks: ScheduledTasksController; + readonly keepSystemAwake: KeepSystemAwakeController; + readonly dailyReview: DailyReviewController; + readonly openSession: (sessionId: string) => void; +} + +export interface ModuleHubController { + readonly host: ModuleHubHostModel; + readonly commands: { + refreshProjectSkills(): Promise; + openScheduledTaskCreate(): void; + copyTodayDailyReview(): Promise; + pasteTodayDailyReview(): Promise; + saveTodayDailyReview(): Promise; + }; + readonly selectors: { + readonly scheduledTasks: readonly ScheduledTask[]; + /** Invalidates the composer's Runtime-owned invocable Skills projection. */ + readonly skillCatalogRevision: number; + }; +} + +export interface UseModuleHubControllerInput { + readonly selection: NavSelection; + readonly selectModule: (selection: NavSelection) => void; + readonly openSkillsFolder?: () => void | Promise; + readonly useSkillInChat: (skillId: string, skillName: string) => void; + readonly openSession: (sessionId: string) => void; + readonly appendComposerText: (text: string) => void; + readonly captureActiveComposerClaim: () => ActiveComposerClaim | undefined; +} + +/** Public ownership boundary for every Module Hub surface except the MCP leaf. */ +export function useModuleHubController( + input: UseModuleHubControllerInput, +): ModuleHubController { + const services = useModuleHubServices(); + const uiLocale = useUiLocale(); + const toastApi = useToast(); + const isSkillsActive = + input.selection.section === 'extensions' && + input.selection.module === 'skills'; + const skills = useSkillsController({ + uiLocale, + active: isSkillsActive, + toastApi, + useSkillInChat: input.useSkillInChat, + openSkillsFolder: input.openSkillsFolder, + }); + const scheduledTasks = useScheduledTasksController({ + uiLocale, + toastApi, + selection: input.selection, + selectModule: input.selectModule, + }); + const keepSystemAwake = useKeepSystemAwakeController(services); + const selectionRef = useRef(input.selection); + selectionRef.current = input.selection; + const dailyReview = useDailyReviewController({ + services, + uiLocale, + toastApi, + appendComposerText: input.appendComposerText, + captureActiveComposerClaim: input.captureActiveComposerClaim, + isDailyReviewSurfaceActive: () => + selectionRef.current.section === 'automations' && + selectionRef.current.module === 'daily-review', + }); + + const refreshProjectSkillsRef = useRef(skills.refreshProjectSkills); + const refreshScheduledTasksRef = useRef(scheduledTasks.refresh); + refreshProjectSkillsRef.current = skills.refreshProjectSkills; + refreshScheduledTasksRef.current = scheduledTasks.refresh; + + useEffect(() => { + return startModuleHubLifecycle({ + runtimeHosts: services.runtimeHosts, + refreshProjectSkills: () => void refreshProjectSkillsRef.current(), + refreshScheduledTasks: () => void refreshScheduledTasksRef.current(), + }); + }, [services.runtimeHosts]); + + return useMemo( + () => ({ + host: { + selection: input.selection, + selectModule: input.selectModule, + skills: skills.host, + scheduledTasks, + keepSystemAwake, + dailyReview, + openSession: input.openSession, + }, + commands: { + refreshProjectSkills: skills.refreshProjectSkills, + openScheduledTaskCreate: scheduledTasks.openCreate, + copyTodayDailyReview: dailyReview.copyToday, + pasteTodayDailyReview: dailyReview.pasteToday, + saveTodayDailyReview: dailyReview.saveToday, + }, + selectors: { + scheduledTasks: scheduledTasks.scheduledTasks, + skillCatalogRevision: skills.revision, + }, + }), + [ + dailyReview, + input.openSession, + input.selectModule, + input.selection, + keepSystemAwake, + scheduledTasks, + skills.host, + skills.refreshProjectSkills, + skills.revision, + ], + ); +} diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-scheduled-tasks-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-scheduled-tasks-controller.ts new file mode 100644 index 0000000000..7c3c71fbb8 --- /dev/null +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-scheduled-tasks-controller.ts @@ -0,0 +1,331 @@ +/* + * 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, useRef, useState } from 'react'; +import type { + ScheduledTask, + UpdateScheduledTaskInput, +} from '@maka/core/scheduled-task'; +import type { UiLocale } from '@maka/core/ui-locale'; +import type { NavSelection, ToastApi } from '@maka/ui'; +import { useMountedRef } from '@maka/ui'; +import { localizedShellErrorMessage } from '../../../locales/shell-copy.js'; +import { getShellRemainingCopy } from '../../../locales/shell-remaining-copy.js'; +import type { ScheduledTaskCreateInput } from '../ports.js'; +import { useModuleHubServices } from '../services-context.js'; +import { + defaultRuntimeHostDiagnosticTarget, + defaultRuntimeHostOperationHost, + isDefaultRuntimeHostCurrent, + runIfDefaultRuntimeHostCurrent, + runOnDefaultRuntimeHost, +} from './default-runtime-host.js'; + +export interface ScheduledTasksController { + readonly scheduledTasks: ScheduledTask[]; + readonly createRequestNonce: number; + openCreate(): void; + handleCreateRequest(): void; + refresh(options?: { shouldShowError?: () => boolean }): Promise; + refreshSurface(): Promise; + create(input: ScheduledTaskCreateInput): Promise; + update(id: string, patch: UpdateScheduledTaskInput): Promise; + toggle(id: string, enabled: boolean): Promise; + triggerNow(id: string): Promise; + snooze(id: string): Promise; + clearRunHistory(id: string): Promise; + delete(id: string): Promise; +} + +export type ScheduledTasksToastApi = Pick< + ToastApi, + 'confirm' | 'error' | 'success' | 'toast' +>; + +export function useScheduledTasksController(options: { + uiLocale: UiLocale; + toastApi: ScheduledTasksToastApi; + selection: NavSelection; + selectModule(selection: NavSelection): void; +}): ScheduledTasksController { + const services = useModuleHubServices(); + const uiLocale = options.uiLocale; + const toastApi = options.toastApi; + const mountedRef = useMountedRef(); + const copy = getShellRemainingCopy(uiLocale).scheduledTaskActions; + const notificationsCopy = getShellRemainingCopy(uiLocale).notifications; + const [scheduledTasks, setScheduledTasks] = useState([]); + const [createRequestNonce, setCreateRequestNonce] = useState(0); + const refreshGenerationRef = useRef(0); + const scheduledTasksRef = useRef(scheduledTasks); + const selectionRef = useRef(options.selection); + const selectModuleRef = useRef(options.selectModule); + const toastApiRef = useRef(toastApi); + const notificationsCopyRef = useRef(notificationsCopy); + const refreshRef = useRef< + (options?: { shouldShowError?: () => boolean }) => Promise + >(async () => {}); + scheduledTasksRef.current = scheduledTasks; + selectionRef.current = options.selection; + selectModuleRef.current = options.selectModule; + toastApiRef.current = toastApi; + notificationsCopyRef.current = notificationsCopy; + + const isSurfaceActive = () => + mountedRef.current && + selectionRef.current.section === 'automations' && + selectionRef.current.module === 'scheduled-tasks'; + + async function refresh( + refreshOptions: { shouldShowError?: () => boolean } = {}, + ): Promise { + const generation = ++refreshGenerationRef.current; + try { + const next = await runOnDefaultRuntimeHost( + services.runtimeHosts, + (host) => services.scheduledTasks.list(host), + ); + await runIfDefaultRuntimeHostCurrent( + services.runtimeHosts, + next.host, + () => { + if ( + mountedRef.current && + generation === refreshGenerationRef.current + ) { + setScheduledTasks(next.value); + } + }, + ); + } catch (error) { + if (!mountedRef.current || generation !== refreshGenerationRef.current) + return; + const operationHost = defaultRuntimeHostOperationHost(error); + const hostIsCurrent = operationHost + ? await isDefaultRuntimeHostCurrent( + services.runtimeHosts, + operationHost, + ) + : true; + if ( + !mountedRef.current || + generation !== refreshGenerationRef.current || + !hostIsCurrent + ) + return; + if (refreshOptions.shouldShowError?.() ?? true) { + toastApi.error( + copy.refreshFailed, + localizedShellErrorMessage(error, copy.refreshFallback, uiLocale), + undefined, + defaultRuntimeHostDiagnosticTarget(error), + ); + } + } + } + refreshRef.current = refresh; + + async function runMutation(mutation: { + run: Parameters[1]; + successTitle?: string; + successDetail?: string; + errorTitle: string; + errorFallback: string; + errorMessage?: (error: unknown) => string | undefined; + }): Promise { + try { + const result = await runOnDefaultRuntimeHost( + services.runtimeHosts, + mutation.run, + ); + if (!mountedRef.current) return false; + const hostIsCurrent = await isDefaultRuntimeHostCurrent( + services.runtimeHosts, + result.host, + ); + if (!mountedRef.current || !hostIsCurrent) return false; + await refreshRef.current({ shouldShowError: isSurfaceActive }); + const hostIsStillCurrent = await isDefaultRuntimeHostCurrent( + services.runtimeHosts, + result.host, + ); + if (!mountedRef.current || !hostIsStillCurrent) return false; + if (mountedRef.current && mutation.successTitle && isSurfaceActive()) { + toastApi.success(mutation.successTitle, mutation.successDetail); + } + return true; + } catch (error) { + if (!mountedRef.current) return false; + const operationHost = defaultRuntimeHostOperationHost(error); + const hostIsCurrent = operationHost + ? await isDefaultRuntimeHostCurrent( + services.runtimeHosts, + operationHost, + ) + : true; + if (!mountedRef.current || !hostIsCurrent) return false; + if (isSurfaceActive()) { + toastApi.error( + mutation.errorTitle, + mutation.errorMessage?.(error) ?? + localizedShellErrorMessage(error, mutation.errorFallback, uiLocale), + undefined, + defaultRuntimeHostDiagnosticTarget(error), + ); + } + return false; + } + } + + useEffect(() => { + const unsubscribeChanges = services.scheduledTasks.subscribeChanges(() => { + void refreshRef.current(); + }); + const unsubscribeDue = services.scheduledTasks.subscribeDue((task) => { + void refreshRef.current(); + const currentCopy = notificationsCopyRef.current; + toastApiRef.current.toast({ + title: currentCopy.scheduledTask, + description: task.title, + variant: 'info', + duration: 8000, + action: { + label: currentCopy.viewScheduledTasks, + onClick: () => + selectModuleRef.current({ + section: 'automations', + module: 'scheduled-tasks', + }), + }, + }); + }); + return () => { + unsubscribeChanges(); + unsubscribeDue(); + }; + }, [services.scheduledTasks]); + + return { + scheduledTasks, + createRequestNonce, + openCreate() { + selectModuleRef.current({ + section: 'automations', + module: 'scheduled-tasks', + }); + setCreateRequestNonce((current) => current + 1); + }, + handleCreateRequest() { + setCreateRequestNonce(0); + }, + refresh, + refreshSurface() { + return refresh({ shouldShowError: isSurfaceActive }); + }, + create(input) { + return runMutation({ + run: (host) => services.scheduledTasks.create(input, host), + successTitle: copy.created, + successDetail: input.title, + errorTitle: copy.createFailed, + errorFallback: copy.createFallback, + errorMessage: (error) => + errorText(error).includes('SCHEDULED_TASK_INCOGNITO_ACTIVE') + ? copy.createIncognitoBlocked + : undefined, + }); + }, + update(id, patch) { + return runMutation({ + run: (host) => services.scheduledTasks.update(id, patch, host), + successTitle: copy.saved, + successDetail: patch.title, + errorTitle: copy.saveFailed, + errorFallback: copy.saveFallback, + }); + }, + async toggle(id, enabled) { + await runMutation({ + run: (host) => services.scheduledTasks.setEnabled(id, enabled, host), + successTitle: enabled ? copy.enabled : copy.paused, + errorTitle: copy.updateFailed, + errorFallback: copy.updateFallback, + }); + }, + async triggerNow(id) { + const task = scheduledTasksRef.current.find((entry) => entry.id === id); + await runMutation({ + run: (host) => services.scheduledTasks.triggerNow(id, host), + successTitle: copy.triggered, + successDetail: task?.title, + errorTitle: copy.triggerFailed, + errorFallback: copy.triggerFallback, + }); + }, + async snooze(id) { + const task = scheduledTasksRef.current.find((entry) => entry.id === id); + await runMutation({ + run: (host) => services.scheduledTasks.snooze(id, host), + successTitle: copy.snoozed, + successDetail: task?.title, + errorTitle: copy.snoozeFailed, + errorFallback: copy.snoozeFallback, + }); + }, + async clearRunHistory(id) { + const task = scheduledTasksRef.current.find((entry) => entry.id === id); + const confirmed = await toastApi.confirm({ + title: copy.clearTitle(task?.title ?? copy.task), + description: copy.clearDescription, + confirmLabel: copy.clear, + cancelLabel: copy.cancel, + destructive: true, + }); + if (!confirmed || !isSurfaceActive()) return; + await runMutation({ + run: (host) => services.scheduledTasks.clearRunHistory(id, host), + successTitle: copy.cleared, + successDetail: task?.title, + errorTitle: copy.clearFailed, + errorFallback: copy.clearFallback, + }); + }, + async delete(id) { + const task = scheduledTasksRef.current.find((entry) => entry.id === id); + const confirmed = await toastApi.confirm({ + title: copy.deleteTitle(task?.title ?? copy.task), + description: copy.deleteDescription, + confirmLabel: copy.delete, + cancelLabel: copy.cancel, + destructive: true, + }); + if (!confirmed || !isSurfaceActive()) return; + await runMutation({ + run: (host) => services.scheduledTasks.delete(id, host), + successTitle: copy.deleted, + errorTitle: copy.deleteFailed, + errorFallback: copy.deleteFallback, + }); + }, + }; +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error ?? ''); +} diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts new file mode 100644 index 0000000000..3871f35609 --- /dev/null +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts @@ -0,0 +1,835 @@ +/* + * 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 { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { UiLocale } from "@maka/core/ui-locale"; +import type { + BundledSkillCatalogEntry, + ManagedSkillSourceEntry, + ManagedSkillUpdatePreview, + SkillEntry, + ToastApi, +} from "@maka/ui"; +import { + getShellCopy, + localizedShellErrorMessage, +} from "../../../locales/shell-copy.js"; +import type { ModuleHubRuntimeHostRef } from "../ports.js"; +import { useModuleHubServices } from "../services-context.js"; +import { + defaultRuntimeHostDiagnosticTarget, + defaultRuntimeHostOperationHost, + isDefaultRuntimeHostCurrent, + runIfDefaultRuntimeHostCurrent, + runOnDefaultRuntimeHost, +} from "./default-runtime-host.js"; + +type SkillsToastApi = Pick; + +type RefreshOptions = { + shouldShowError?: () => boolean; +}; + +export interface SkillsHostModel { + skills: SkillEntry[]; + managedSkillSources: ManagedSkillSourceEntry[]; + bundledSkillCatalog: BundledSkillCatalogEntry[]; + onRefreshSkills(): Promise; + onOpenSkill?: (skillId: string) => Promise; + onUseSkill(skillId: string, skillName: string): void; + onOpenSkillsFolder?: () => void | Promise; + onRefreshManagedSkillSources(): Promise; + onImportManagedSkillSource?: () => Promise; + onInstallManagedSkill(sourceId: string): Promise; + onRefreshBundledSkillCatalog(): Promise; + onInstallBundledSkill(id: string): Promise; + onPreviewManagedSkillUpdate( + skillId: string, + ): Promise; + onUpdateManagedSkill( + skillId: string, + options?: { + force?: boolean; + expectedCurrentSha256?: string; + expectedSourceSha256?: string; + }, + ): Promise; + onSetSkillEnabled(skillId: string, enabled: boolean): Promise; + onSetSkillPinned(skillRef: string, pinned: boolean): Promise; + onDeleteSkill(skillRef: string): Promise; +} + +export interface SkillsController { + host: SkillsHostModel; + /** Changes only when the current default Host's installed-Skills projection commits. */ + readonly revision: number; + /** Refreshes every project-scoped Skills projection after a project change. */ + refreshProjectSkills(): Promise; +} + +export interface UseSkillsControllerInput { + uiLocale: UiLocale; + active: boolean; + toastApi: SkillsToastApi; + useSkillInChat(skillId: string, skillName: string): void; + openSkillsFolder?: () => void | Promise; +} + +type SkillsProjection = + "skills" | "managedSkillSources" | "bundledSkillCatalog"; + +/** Owns the three Skills projections, their Host fences, and every Skills mutation. */ +export function useSkillsController( + input: UseSkillsControllerInput, +): SkillsController { + const services = useModuleHubServices(); + const [skills, setSkills] = useState([]); + const [revision, setRevision] = useState(0); + const [managedSkillSources, setManagedSkillSources] = useState< + ManagedSkillSourceEntry[] + >([]); + const [bundledSkillCatalog, setBundledSkillCatalog] = useState< + BundledSkillCatalogEntry[] + >([]); + const generationsRef = useRef>({ + skills: 0, + managedSkillSources: 0, + bundledSkillCatalog: 0, + }); + const mountedRef = useRef(true); + const inputRef = useRef(input); + inputRef.current = input; + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + generationsRef.current.skills += 1; + generationsRef.current.managedSkillSources += 1; + generationsRef.current.bundledSkillCatalog += 1; + }; + }, []); + + const isSkillsSurfaceActive = useCallback( + () => mountedRef.current && inputRef.current.active, + [], + ); + + const isOperationHostCurrent = useCallback( + async (error: unknown): Promise => { + if (!mountedRef.current) return false; + const host = defaultRuntimeHostOperationHost(error); + if (!host) return mountedRef.current; + const current = await isDefaultRuntimeHostCurrent( + services.runtimeHosts, + host, + ); + return mountedRef.current && current; + }, + [services.runtimeHosts], + ); + + const shouldReportRefreshError = useCallback( + async (options: RefreshOptions, error: unknown): Promise => { + const shouldShowError = options.shouldShowError; + if (shouldShowError && !shouldShowError()) return false; + if (!(await isOperationHostCurrent(error))) return false; + return shouldShowError?.() ?? true; + }, + [isOperationHostCurrent], + ); + + const reportRuntimeHostError = useCallback( + (title: string, fallback: string, error: unknown): void => { + const current = inputRef.current; + current.toastApi.error( + title, + localizedShellErrorMessage(error, fallback, current.uiLocale), + undefined, + defaultRuntimeHostDiagnosticTarget(error), + ); + }, + [], + ); + + const refreshSkills = useCallback( + async (options: RefreshOptions = {}): Promise => { + const generation = ++generationsRef.current.skills; + const copy = getShellCopy(inputRef.current.uiLocale).skillActions; + try { + const next = await runOnDefaultRuntimeHost( + services.runtimeHosts, + (host) => services.skills.list(host), + ); + await runIfDefaultRuntimeHostCurrent( + services.runtimeHosts, + next.host, + () => { + if ( + mountedRef.current && + generation === generationsRef.current.skills + ) { + setSkills(next.value); + setRevision((current) => current + 1); + } + }, + ); + } catch (error) { + if (!mountedRef.current || generation !== generationsRef.current.skills) + return; + const shouldReport = await shouldReportRefreshError(options, error); + if ( + mountedRef.current && + generation === generationsRef.current.skills && + shouldReport + ) { + reportRuntimeHostError( + copy.refreshSkillsFailedTitle, + copy.refreshSkillsFallback, + error, + ); + } + } + }, + [ + isOperationHostCurrent, + reportRuntimeHostError, + services.runtimeHosts, + services.skills, + shouldReportRefreshError, + ], + ); + + const refreshManagedSkillSources = useCallback( + async (options: RefreshOptions = {}): Promise => { + const generation = ++generationsRef.current.managedSkillSources; + const copy = getShellCopy(inputRef.current.uiLocale).skillActions; + try { + const next = await runOnDefaultRuntimeHost( + services.runtimeHosts, + (host) => services.skills.listManagedSources(host), + ); + await runIfDefaultRuntimeHostCurrent( + services.runtimeHosts, + next.host, + () => { + if ( + mountedRef.current && + generation === generationsRef.current.managedSkillSources + ) { + setManagedSkillSources(next.value); + } + }, + ); + } catch (error) { + if ( + !mountedRef.current || + generation !== generationsRef.current.managedSkillSources + ) { + return; + } + const shouldReport = await shouldReportRefreshError(options, error); + if ( + mountedRef.current && + generation === generationsRef.current.managedSkillSources && + shouldReport + ) { + reportRuntimeHostError( + copy.refreshSourcesFailedTitle, + copy.refreshSourcesFallback, + error, + ); + } + } + }, + [ + isOperationHostCurrent, + reportRuntimeHostError, + services.runtimeHosts, + services.skills, + shouldReportRefreshError, + ], + ); + + const refreshBundledSkillCatalog = useCallback( + async (options: RefreshOptions = {}): Promise => { + const generation = ++generationsRef.current.bundledSkillCatalog; + const copy = getShellCopy(inputRef.current.uiLocale).skillActions; + try { + const next = await runOnDefaultRuntimeHost( + services.runtimeHosts, + (host) => services.skills.listBundledCatalog(host), + ); + await runIfDefaultRuntimeHostCurrent( + services.runtimeHosts, + next.host, + () => { + if ( + mountedRef.current && + generation === generationsRef.current.bundledSkillCatalog + ) { + setBundledSkillCatalog(next.value); + } + }, + ); + } catch (error) { + if ( + !mountedRef.current || + generation !== generationsRef.current.bundledSkillCatalog + ) { + return; + } + const shouldReport = await shouldReportRefreshError(options, error); + if ( + mountedRef.current && + generation === generationsRef.current.bundledSkillCatalog && + shouldReport + ) { + reportRuntimeHostError( + copy.refreshBundledFailedTitle, + copy.refreshBundledFallback, + error, + ); + } + } + }, + [ + isOperationHostCurrent, + reportRuntimeHostError, + services.runtimeHosts, + services.skills, + shouldReportRefreshError, + ], + ); + + const shouldReportMutation = useCallback( + async (host: ModuleHubRuntimeHostRef): Promise => { + if (!isSkillsSurfaceActive()) return false; + let activeAfterHostCheck = false; + const current = await runIfDefaultRuntimeHostCurrent( + services.runtimeHosts, + host, + () => { + activeAfterHostCheck = isSkillsSurfaceActive(); + }, + ); + return current && activeAfterHostCheck; + }, + [isSkillsSurfaceActive, services.runtimeHosts], + ); + + const shouldReportOperationError = useCallback( + async (error: unknown): Promise => { + if (!isSkillsSurfaceActive()) return false; + if (!(await isOperationHostCurrent(error))) return false; + return isSkillsSurfaceActive(); + }, + [isOperationHostCurrent, isSkillsSurfaceActive], + ); + + const importManagedSkillSource = useCallback(async (): Promise => { + const copy = getShellCopy(inputRef.current.uiLocale).skillActions; + try { + const next = await runOnDefaultRuntimeHost( + services.runtimeHosts, + (host) => services.skills.importManagedSource(host), + ); + if (!next.value.ok) { + if ( + next.value.reason !== "cancelled" && + (await shouldReportMutation(next.host)) + ) { + inputRef.current.toastApi.error( + copy.importSourceFailedTitle, + copy.sourceFailures[next.value.reason], + undefined, + next.diagnosticTarget, + ); + } + return; + } + await refreshManagedSkillSources({ + shouldShowError: isSkillsSurfaceActive, + }); + if (await shouldReportMutation(next.host)) { + inputRef.current.toastApi.success( + copy.importedSourceTitle, + next.value.source.name, + ); + } + } catch (error) { + if (await shouldReportOperationError(error)) { + reportRuntimeHostError( + copy.importSourceFailedTitle, + copy.importSourceFallback, + error, + ); + } + } + }, [ + isSkillsSurfaceActive, + refreshManagedSkillSources, + reportRuntimeHostError, + services.runtimeHosts, + services.skills, + shouldReportOperationError, + shouldReportMutation, + ]); + + const installManagedSkill = useCallback( + async (sourceId: string): Promise => { + const copy = getShellCopy(inputRef.current.uiLocale).skillActions; + try { + const next = await runOnDefaultRuntimeHost( + services.runtimeHosts, + (host) => services.skills.installManaged(sourceId, host), + ); + if (!next.value.ok) { + if (await shouldReportMutation(next.host)) { + inputRef.current.toastApi.error( + copy.installFailedTitle, + copy.installFailures[next.value.reason], + undefined, + next.diagnosticTarget, + ); + } + return; + } + await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); + await refreshManagedSkillSources({ + shouldShowError: isSkillsSurfaceActive, + }); + if (await shouldReportMutation(next.host)) { + inputRef.current.toastApi.success( + copy.installedTitle, + copy.installedDescription(next.value.skill.id), + ); + } + } catch (error) { + if (await shouldReportOperationError(error)) { + reportRuntimeHostError( + copy.installFailedTitle, + copy.installFallback, + error, + ); + } + } + }, + [ + isSkillsSurfaceActive, + refreshManagedSkillSources, + refreshSkills, + reportRuntimeHostError, + services.runtimeHosts, + services.skills, + shouldReportOperationError, + shouldReportMutation, + ], + ); + + const installBundledSkill = useCallback( + async (id: string): Promise => { + const copy = getShellCopy(inputRef.current.uiLocale).skillActions; + try { + const next = await runOnDefaultRuntimeHost( + services.runtimeHosts, + (host) => services.skills.installBundled(id, host), + ); + if (!next.value.ok) { + if (await shouldReportMutation(next.host)) { + inputRef.current.toastApi.error( + copy.installBundledFailedTitle, + copy.installFailures[next.value.reason], + undefined, + next.diagnosticTarget, + ); + } + return; + } + await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); + await refreshBundledSkillCatalog({ + shouldShowError: isSkillsSurfaceActive, + }); + if (await shouldReportMutation(next.host)) { + inputRef.current.toastApi.success( + copy.installedBundledTitle, + copy.installedDescription(next.value.skill.id), + ); + } + } catch (error) { + if (await shouldReportOperationError(error)) { + reportRuntimeHostError( + copy.installBundledFailedTitle, + copy.installBundledFallback, + error, + ); + } + } + }, + [ + isSkillsSurfaceActive, + refreshBundledSkillCatalog, + refreshSkills, + reportRuntimeHostError, + services.runtimeHosts, + services.skills, + shouldReportOperationError, + shouldReportMutation, + ], + ); + + const previewManagedSkillUpdate = useCallback( + async (skillId: string): Promise => { + const copy = getShellCopy(inputRef.current.uiLocale).skillActions; + try { + const next = await runOnDefaultRuntimeHost( + services.runtimeHosts, + (host) => services.skills.previewUpdate(skillId, host), + ); + if (!next.value.ok) { + if (await shouldReportMutation(next.host)) { + inputRef.current.toastApi.error( + copy.previewFailedTitle, + copy.previewFailures[next.value.reason], + undefined, + next.diagnosticTarget, + ); + } + return null; + } + return (await shouldReportMutation(next.host)) + ? next.value.preview + : null; + } catch (error) { + if (await shouldReportOperationError(error)) { + reportRuntimeHostError( + copy.previewFailedTitle, + copy.previewFallback, + error, + ); + } + return null; + } + }, + [ + isSkillsSurfaceActive, + reportRuntimeHostError, + services.runtimeHosts, + services.skills, + shouldReportOperationError, + shouldReportMutation, + ], + ); + + const updateManagedSkill = useCallback( + async ( + skillId: string, + options: { + force?: boolean; + expectedCurrentSha256?: string; + expectedSourceSha256?: string; + } = {}, + ): Promise => { + const copy = getShellCopy(inputRef.current.uiLocale).skillActions; + try { + const next = await runOnDefaultRuntimeHost( + services.runtimeHosts, + (host) => services.skills.updateManaged(skillId, options, host), + ); + if (!next.value.ok) { + if (await shouldReportMutation(next.host)) { + inputRef.current.toastApi.error( + copy.updateFailedTitle, + copy.updateFailures[next.value.reason], + undefined, + next.diagnosticTarget, + ); + } + return false; + } + await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); + if (await shouldReportMutation(next.host)) { + inputRef.current.toastApi.success( + options.force ? copy.forceUpdatedTitle : copy.updatedTitle, + copy.updatedDescription(next.value.skill.id), + ); + } + return true; + } catch (error) { + if (await shouldReportOperationError(error)) { + reportRuntimeHostError( + copy.updateFailedTitle, + copy.updateFallback, + error, + ); + } + return false; + } + }, + [ + isSkillsSurfaceActive, + refreshSkills, + reportRuntimeHostError, + services.runtimeHosts, + services.skills, + shouldReportOperationError, + shouldReportMutation, + ], + ); + + const setSkillEnabled = useCallback( + async (skillId: string, enabled: boolean): Promise => { + const copy = getShellCopy(inputRef.current.uiLocale).skillActions; + try { + const next = await runOnDefaultRuntimeHost( + services.runtimeHosts, + (host) => services.skills.setEnabled(skillId, enabled, host), + ); + if (!next.value.ok) { + if (await shouldReportMutation(next.host)) { + inputRef.current.toastApi.error( + copy.toggleFailedTitle, + copy.runtimeFailures[next.value.reason], + undefined, + next.diagnosticTarget, + ); + } + return; + } + await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); + if (await shouldReportMutation(next.host)) { + inputRef.current.toastApi.success( + enabled ? copy.enabledTitle : copy.disabledTitle, + copy.runtimeDescription(next.value.skill.name), + ); + } + } catch (error) { + if (await shouldReportOperationError(error)) { + reportRuntimeHostError( + copy.toggleFailedTitle, + copy.toggleFallback, + error, + ); + } + } + }, + [ + isSkillsSurfaceActive, + refreshSkills, + reportRuntimeHostError, + services.runtimeHosts, + services.skills, + shouldReportOperationError, + shouldReportMutation, + ], + ); + + const setSkillPinned = useCallback( + async (skillRef: string, pinned: boolean): Promise => { + const copy = getShellCopy(inputRef.current.uiLocale).skillActions; + try { + const next = await runOnDefaultRuntimeHost( + services.runtimeHosts, + (host) => services.skills.setPinned(skillRef, pinned, host), + ); + if (!next.value.ok) { + if (await shouldReportMutation(next.host)) { + inputRef.current.toastApi.error( + copy.toggleFailedTitle, + copy.runtimeFailures[next.value.reason], + undefined, + next.diagnosticTarget, + ); + } + return; + } + await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); + if (await shouldReportMutation(next.host)) { + inputRef.current.toastApi.success( + pinned ? copy.pinnedTitle : copy.unpinnedTitle, + next.value.skill.name, + ); + } + } catch (error) { + if (await shouldReportOperationError(error)) { + reportRuntimeHostError( + copy.toggleFailedTitle, + copy.toggleFallback, + error, + ); + } + } + }, + [ + isSkillsSurfaceActive, + refreshSkills, + reportRuntimeHostError, + services.runtimeHosts, + services.skills, + shouldReportOperationError, + shouldReportMutation, + ], + ); + + const deleteSkill = useCallback( + async (skillRef: string): Promise => { + const copy = getShellCopy(inputRef.current.uiLocale).skillActions; + try { + const next = await runOnDefaultRuntimeHost( + services.runtimeHosts, + (host) => services.skills.delete(skillRef, host), + ); + if (!next.value.ok) { + if (await shouldReportMutation(next.host)) { + inputRef.current.toastApi.error( + copy.deleteFailedTitle, + copy.deleteFailures[next.value.reason], + undefined, + next.diagnosticTarget, + ); + } + return; + } + await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); + await refreshBundledSkillCatalog({ + shouldShowError: isSkillsSurfaceActive, + }); + if (await shouldReportMutation(next.host)) { + const displayId = skillRef.slice(skillRef.lastIndexOf(":") + 1); + inputRef.current.toastApi.success( + copy.deletedTitle, + copy.deletedDescription(displayId), + ); + } + } catch (error) { + if (await shouldReportOperationError(error)) { + reportRuntimeHostError( + copy.deleteFailedTitle, + copy.deleteFallback, + error, + ); + } + } + }, + [ + isSkillsSurfaceActive, + refreshBundledSkillCatalog, + refreshSkills, + reportRuntimeHostError, + services.runtimeHosts, + services.skills, + shouldReportOperationError, + shouldReportMutation, + ], + ); + + const openSkill = useCallback( + async (skillId: string): Promise => { + const copy = getShellCopy(inputRef.current.uiLocale).skillActions; + try { + const next = await runOnDefaultRuntimeHost( + services.runtimeHosts, + (host) => services.skills.open(skillId, "file", host), + ); + if (!next.value.ok && (await shouldReportMutation(next.host))) { + inputRef.current.toastApi.error( + copy.openFailedTitle, + copy.openFailures[next.value.reason], + undefined, + next.diagnosticTarget, + ); + } + } catch (error) { + if (await shouldReportOperationError(error)) { + reportRuntimeHostError( + copy.openFailedTitle, + copy.openFallback, + error, + ); + } + } + }, + [ + isSkillsSurfaceActive, + reportRuntimeHostError, + services.runtimeHosts, + services.skills, + shouldReportOperationError, + shouldReportMutation, + ], + ); + + const refreshProjectSkills = useCallback(async (): Promise => { + await Promise.all([ + refreshSkills(), + refreshManagedSkillSources(), + refreshBundledSkillCatalog(), + ]); + }, [refreshBundledSkillCatalog, refreshManagedSkillSources, refreshSkills]); + + const host = useMemo( + () => ({ + skills, + managedSkillSources, + bundledSkillCatalog, + onRefreshSkills: refreshSkills, + onUseSkill: input.useSkillInChat, + ...(input.openSkillsFolder + ? { + onOpenSkill: openSkill, + onOpenSkillsFolder: input.openSkillsFolder, + onImportManagedSkillSource: importManagedSkillSource, + } + : {}), + onRefreshManagedSkillSources: refreshManagedSkillSources, + onInstallManagedSkill: installManagedSkill, + onRefreshBundledSkillCatalog: refreshBundledSkillCatalog, + onInstallBundledSkill: installBundledSkill, + onPreviewManagedSkillUpdate: previewManagedSkillUpdate, + onUpdateManagedSkill: updateManagedSkill, + onSetSkillEnabled: setSkillEnabled, + onSetSkillPinned: setSkillPinned, + onDeleteSkill: deleteSkill, + }), + [ + bundledSkillCatalog, + deleteSkill, + importManagedSkillSource, + input.openSkillsFolder, + input.useSkillInChat, + installBundledSkill, + installManagedSkill, + managedSkillSources, + openSkill, + previewManagedSkillUpdate, + refreshBundledSkillCatalog, + refreshManagedSkillSources, + refreshSkills, + setSkillEnabled, + setSkillPinned, + skills, + updateManagedSkill, + ], + ); + + return useMemo( + () => ({ host, revision, refreshProjectSkills }), + [host, refreshProjectSkills, revision], + ); +} diff --git a/apps/desktop/src/renderer/features/module-hub/index.ts b/apps/desktop/src/renderer/features/module-hub/index.ts new file mode 100644 index 0000000000..a99b752aff --- /dev/null +++ b/apps/desktop/src/renderer/features/module-hub/index.ts @@ -0,0 +1,26 @@ +/* + * 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. + */ + +export { useModuleHubController } from './controller/use-module-hub-controller.js'; +export { ModuleHubServicesProvider } from './services-context.js'; +export type { + ModuleHubClipboardService, + ModuleHubServices, +} from './ports.js'; +export { ModuleHubHost } from './ui/module-hub-host.js'; diff --git a/apps/desktop/src/renderer/features/module-hub/ports.ts b/apps/desktop/src/renderer/features/module-hub/ports.ts new file mode 100644 index 0000000000..7abb633628 --- /dev/null +++ b/apps/desktop/src/renderer/features/module-hub/ports.ts @@ -0,0 +1,246 @@ +/* + * 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 { + DailyReviewArchive, + DailyReviewArchiveSummary, + DailyReviewRange, + DailyReviewSummary, +} from '@maka/core/daily-review'; +import type { Result } from '@maka/core/result'; +import type { + CreateScheduledTaskInput, + ScheduledTask, + UpdateScheduledTaskInput, +} from '@maka/core/scheduled-task'; +import type { + BundledSkillCatalogEntry, + ManagedSkillSourceEntry, + ManagedSkillUpdatePreview, + SkillEntry, +} from '@maka/ui'; + +export type ModuleHubUnsubscribe = () => void; + +export interface ModuleHubRuntimeHostRef { + readonly profileId: string; + readonly hostId: string; +} + +export interface ModuleHubRuntimeHostChangedEvent { + readonly profileId: string; + readonly readiness: 'connecting' | 'ready' | 'reconnecting' | 'unavailable'; + readonly hostId?: string; + readonly isDefault: boolean; + readonly removed?: boolean; +} + +export interface ModuleHubRuntimeHostsService { + getDefault(): Promise; + subscribeChanges( + handler: (event: ModuleHubRuntimeHostChangedEvent) => void, + ): ModuleHubUnsubscribe; +} + +export type InstallSkillResult = + | { ok: true; skill: SkillEntry } + | { + ok: false; + reason: 'not_found' | 'already_exists' | 'blocked_path' | 'write_failed'; + }; + +export type ImportManagedSkillSourceResult = + | { ok: true; source: ManagedSkillSourceEntry } + | { + ok: false; + reason: + | 'cancelled' + | 'invalid_skill' + | 'already_exists' + | 'blocked_path' + | 'write_failed'; + }; + +export type PreviewManagedSkillUpdateResult = + | { ok: true; preview: ManagedSkillUpdatePreview } + | { + ok: false; + reason: + | 'not_managed' + | 'source_missing' + | 'metadata_error' + | 'blocked_path' + | 'read_failed'; + }; + +export type UpdateManagedSkillResult = + | { ok: true; skill: SkillEntry } + | { + ok: false; + reason: + | 'not_managed' + | 'source_missing' + | 'local_modified' + | 'metadata_error' + | 'blocked_path' + | 'write_failed'; + }; + +export type ChangeSkillRuntimeStateResult = + | { ok: true; skill: SkillEntry } + | { + ok: false; + reason: 'not_found' | 'blocked_path' | 'state_error' | 'write_failed'; + }; + +export type DeleteSkillResult = + | { ok: true } + | { + ok: false; + reason: 'not_found' | 'blocked_path' | 'blocked_scope' | 'delete_failed'; + }; + +export type OpenSkillResult = + | { ok: true; target: 'file' | 'directory' } + | { + ok: false; + reason: + | 'invalid_id' + | 'missing' + | 'blocked_path' + | 'not_file' + | 'not_directory' + | 'open_failed'; + }; + +export interface ModuleHubSkillsService { + list(host: ModuleHubRuntimeHostRef): Promise; + listManagedSources(host: ModuleHubRuntimeHostRef): Promise; + listBundledCatalog(host: ModuleHubRuntimeHostRef): Promise; + importManagedSource(host: ModuleHubRuntimeHostRef): Promise; + installManaged(sourceId: string, host: ModuleHubRuntimeHostRef): Promise; + installBundled(id: string, host: ModuleHubRuntimeHostRef): Promise; + previewUpdate( + skillId: string, + host: ModuleHubRuntimeHostRef, + ): Promise; + updateManaged( + skillId: string, + options: { + force?: boolean; + expectedCurrentSha256?: string; + expectedSourceSha256?: string; + }, + host: ModuleHubRuntimeHostRef, + ): Promise; + setEnabled( + skillId: string, + enabled: boolean, + host: ModuleHubRuntimeHostRef, + ): Promise; + setPinned( + skillRef: string, + pinned: boolean, + host: ModuleHubRuntimeHostRef, + ): Promise; + delete(skillRef: string, host: ModuleHubRuntimeHostRef): Promise; + open( + skillId: string, + target: 'file' | 'directory', + host: ModuleHubRuntimeHostRef, + ): Promise; +} + +export type ScheduledTaskCreateInput = Omit; + +export interface ModuleHubScheduledTasksService { + list(host: ModuleHubRuntimeHostRef): Promise; + create( + input: ScheduledTaskCreateInput, + host: ModuleHubRuntimeHostRef, + ): Promise; + update( + id: string, + patch: UpdateScheduledTaskInput, + host: ModuleHubRuntimeHostRef, + ): Promise; + setEnabled( + id: string, + enabled: boolean, + host: ModuleHubRuntimeHostRef, + ): Promise; + triggerNow(id: string, host: ModuleHubRuntimeHostRef): Promise; + snooze(id: string, host: ModuleHubRuntimeHostRef): Promise; + clearRunHistory(id: string, host: ModuleHubRuntimeHostRef): Promise; + delete(id: string, host: ModuleHubRuntimeHostRef): Promise; + subscribeChanges( + handler: (event: { + type: 'scheduled_tasks_changed'; + reason: string; + taskId?: string; + ts: number; + }) => void, + ): ModuleHubUnsubscribe; + subscribeDue( + handler: (task: Pick) => void, + ): ModuleHubUnsubscribe; +} + +export interface ModuleHubClientSettingsService { + readonly supported: boolean; + getKeepSystemAwake(): Promise; + setKeepSystemAwake(next: boolean): Promise; + subscribeChanges(handler: () => void): ModuleHubUnsubscribe; +} + +export interface ModuleHubDailyReviewService { + day( + offsetDays: number, + daySpan: number | undefined, + host: ModuleHubRuntimeHostRef, + ): Promise>; + runOnce(input: { + range: DailyReviewRange; + offsetDays?: number; + modelKey?: string; + }): Promise<{ archiveId: string }>; + listArchives(): Promise; + getArchive(archiveId: string): Promise; + saveMarkdownToFile(input: { + markdown: string; + defaultName: string; + }): Promise< + | { ok: true; path: string } + | { ok: false; reason: 'canceled' | 'write_failed' | 'invalid_input' } + >; +} + +export interface ModuleHubClipboardService { + writeText(text: string): Promise; +} + +/** Environment capabilities owned by the Module Hub feature slice. */ +export interface ModuleHubServices { + runtimeHosts: ModuleHubRuntimeHostsService; + skills: ModuleHubSkillsService; + scheduledTasks: ModuleHubScheduledTasksService; + clientSettings: ModuleHubClientSettingsService; + dailyReview: ModuleHubDailyReviewService; + clipboard: ModuleHubClipboardService; +} diff --git a/apps/desktop/src/renderer/features/module-hub/services-context.tsx b/apps/desktop/src/renderer/features/module-hub/services-context.tsx new file mode 100644 index 0000000000..09125f6f69 --- /dev/null +++ b/apps/desktop/src/renderer/features/module-hub/services-context.tsx @@ -0,0 +1,40 @@ +/* + * 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 { createContext, useContext, type ReactNode } from 'react'; +import type { ModuleHubServices } from './ports.js'; + +const ModuleHubServicesContext = createContext(null); + +export function ModuleHubServicesProvider(props: { + services: ModuleHubServices; + children?: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +export function useModuleHubServices(): ModuleHubServices { + const services = useContext(ModuleHubServicesContext); + if (!services) throw new Error('ModuleHubServicesProvider is missing'); + return services; +} diff --git a/apps/desktop/src/renderer/features/module-hub/testing.ts b/apps/desktop/src/renderer/features/module-hub/testing.ts new file mode 100644 index 0000000000..ca504b3ce7 --- /dev/null +++ b/apps/desktop/src/renderer/features/module-hub/testing.ts @@ -0,0 +1,175 @@ +/* + * 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 { ModuleHubServices } from "./ports.js"; +import type { NavSelection } from "@maka/ui"; +import type { ModuleHubHostModel } from "./controller/use-module-hub-controller.js"; + +export { ModuleHubServicesProvider } from "./services-context.js"; +export type { ModuleHubServices } from "./ports.js"; +export { startModuleHubLifecycle } from "./controller/module-hub-lifecycle.js"; +export { resolveModuleHubHostRoute } from "./controller/module-hub-route.js"; +export type { ModuleHubHostModel } from "./controller/use-module-hub-controller.js"; +export { + createDailyReviewBridge, + useDailyReviewController, + type DailyReviewController, +} from "./controller/use-daily-review-controller.js"; +export { + useKeepSystemAwakeController, + type KeepSystemAwakeController, +} from "./controller/use-keep-system-awake-controller.js"; +export { + useScheduledTasksController, + type ScheduledTasksController, + type ScheduledTasksToastApi, +} from "./controller/use-scheduled-tasks-controller.js"; +export type { + ModuleHubRuntimeHostChangedEvent, + ModuleHubRuntimeHostRef, +} from "./ports.js"; +export { + useSkillsController, + type SkillsController, + type UseSkillsControllerInput, +} from "./controller/use-skills-controller.js"; + +const noopSubscription = (): (() => void) => () => undefined; +const notConfigured = (operation: string): never => { + throw new Error(`Fake ${operation} is not configured`); +}; + +/** Environment-free Host model for route composition tests and Storybook. */ +export function createFakeModuleHubHostModel( + selection: NavSelection, + overrides: Partial = {}, +): ModuleHubHostModel { + return { + selection, + selectModule: () => undefined, + skills: { + skills: [], + managedSkillSources: [], + bundledSkillCatalog: [], + onRefreshSkills: async () => undefined, + onUseSkill: () => undefined, + onRefreshManagedSkillSources: async () => undefined, + onImportManagedSkillSource: async () => undefined, + onInstallManagedSkill: async () => undefined, + onRefreshBundledSkillCatalog: async () => undefined, + onInstallBundledSkill: async () => undefined, + onPreviewManagedSkillUpdate: async () => null, + onUpdateManagedSkill: async () => false, + onSetSkillEnabled: async () => undefined, + onSetSkillPinned: async () => undefined, + onDeleteSkill: async () => undefined, + }, + scheduledTasks: { + scheduledTasks: [], + createRequestNonce: 0, + openCreate: () => undefined, + handleCreateRequest: () => undefined, + refresh: async () => undefined, + refreshSurface: async () => undefined, + create: async () => false, + update: async () => false, + toggle: async () => undefined, + triggerNow: async () => undefined, + snooze: async () => undefined, + clearRunHistory: async () => undefined, + delete: async () => undefined, + }, + keepSystemAwake: { + supported: false, + keepSystemAwake: undefined, + setKeepSystemAwake: async () => undefined, + }, + dailyReview: { + bridge: { + fetchDay: async () => notConfigured("dailyReview.fetchDay"), + }, + copyMarkdown: async () => undefined, + appendMarkdown: () => undefined, + saveMarkdown: async () => undefined, + copyToday: async () => undefined, + pasteToday: async () => undefined, + saveToday: async () => undefined, + }, + openSession: () => undefined, + ...overrides, + }; +} + +/** Environment-free Module Hub defaults for focused tests and Storybook. */ +export function createFakeModuleHubServices( + overrides: Partial = {}, +): ModuleHubServices { + return { + runtimeHosts: { + getDefault: async () => ({ profileId: "local", hostId: "local" }), + subscribeChanges: noopSubscription, + }, + skills: { + list: async () => [], + listManagedSources: async () => [], + listBundledCatalog: async () => [], + importManagedSource: async () => + notConfigured("skills.importManagedSource"), + installManaged: async () => notConfigured("skills.installManaged"), + installBundled: async () => notConfigured("skills.installBundled"), + previewUpdate: async () => notConfigured("skills.previewUpdate"), + updateManaged: async () => notConfigured("skills.updateManaged"), + setEnabled: async () => notConfigured("skills.setEnabled"), + setPinned: async () => notConfigured("skills.setPinned"), + delete: async () => notConfigured("skills.delete"), + open: async () => notConfigured("skills.open"), + }, + scheduledTasks: { + list: async () => [], + create: async () => notConfigured("scheduledTasks.create"), + update: async () => notConfigured("scheduledTasks.update"), + setEnabled: async () => notConfigured("scheduledTasks.setEnabled"), + triggerNow: async () => notConfigured("scheduledTasks.triggerNow"), + snooze: async () => notConfigured("scheduledTasks.snooze"), + clearRunHistory: async () => + notConfigured("scheduledTasks.clearRunHistory"), + delete: async () => notConfigured("scheduledTasks.delete"), + subscribeChanges: noopSubscription, + subscribeDue: noopSubscription, + }, + clientSettings: { + supported: true, + getKeepSystemAwake: async () => false, + setKeepSystemAwake: async (next) => next, + subscribeChanges: noopSubscription, + }, + dailyReview: { + day: async () => notConfigured("dailyReview.day"), + runOnce: async () => notConfigured("dailyReview.runOnce"), + listArchives: async () => [], + getArchive: async () => null, + saveMarkdownToFile: async () => + notConfigured("dailyReview.saveMarkdownToFile"), + }, + clipboard: { + writeText: async () => undefined, + }, + ...overrides, + }; +} diff --git a/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx b/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx new file mode 100644 index 0000000000..3b437d3a4f --- /dev/null +++ b/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx @@ -0,0 +1,122 @@ +/* + * 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 { + DailyReviewPage, + ModuleHubSelector, + ScheduledTasksPage, + SkillsPage, + getSharedUiCopy, + useUiLocale, + type ModuleHubHeader, +} from '@maka/ui'; +import { McpPage } from '../../../mcp-page.js'; +import type { ModuleHubHostModel } from '../controller/use-module-hub-controller.js'; +import { resolveModuleHubHostRoute } from '../controller/module-hub-route.js'; + +/** Selects and mounts exactly one Module Hub leaf for the Shell selection. */ +export function ModuleHubHost(props: { model: ModuleHubHostModel }) { + const { model } = props; + const copy = getSharedUiCopy(useUiLocale()).moduleHubs; + const selection = model.selection; + const route = resolveModuleHubHostRoute(selection); + + if (route === 'skills' || route === 'mcp') { + const header: ModuleHubHeader = { + title: copy.extensions.title, + subtitle: copy.extensions.description, + badge: ( + + model.selectModule({ section: 'extensions', module }) + } + /> + ), + }; + if (route === 'mcp') { + // Explicit leaf-owner exception: MCP keeps its existing page-owned + // controller and direct bridge; Module Hub only selects and mounts it. + return ; + } + return ( + + ); + } + + if (route === 'scheduled-tasks' || route === 'daily-review') { + const header: ModuleHubHeader = { + title: copy.automations.title, + subtitle: copy.automations.description, + badge: ( + + model.selectModule({ section: 'automations', module }) + } + /> + ), + }; + if (route === 'scheduled-tasks') { + const keepAwake = model.keepSystemAwake; + const tasks = model.scheduledTasks; + return ( + + ); + } + const dailyReview = model.dailyReview; + return ( + + ); + } + + return null; +} diff --git a/apps/desktop/src/renderer/main.tsx b/apps/desktop/src/renderer/main.tsx index c7f267ed94..c39f931b27 100644 --- a/apps/desktop/src/renderer/main.tsx +++ b/apps/desktop/src/renderer/main.tsx @@ -28,6 +28,8 @@ import { WorkbarServicesProvider } from './features/workbar'; import { createDesktopWorkbarServices } from './platform/desktop/create-workbar-services'; import { GoalServicesProvider } from './features/goals'; import { createDesktopGoalServices } from './platform/desktop/create-goal-services'; +import { ModuleHubServicesProvider } from './features/module-hub'; +import { createDesktopModuleHubServices } from './platform/desktop/create-module-hub-services'; const ONBOARDING_SNAPSHOT_RETRY_DELAY_MS = 150; const ONBOARDING_SNAPSHOT_TIMEOUT_MS = 2_500; @@ -36,6 +38,7 @@ syncUiLocaleDocument(readSystemUiLocale()); applyCachedThemeBeforeMount(); const workbarServices = createDesktopWorkbarServices(); const goalServices = createDesktopGoalServices(); +const moduleHubServices = createDesktopModuleHubServices(); /** * Prefetch the onboarding snapshot BEFORE mounting React. The preload @@ -68,10 +71,12 @@ async function prefetchOnboardingSnapshot(): Promise void prefetchOnboardingSnapshot().then((initialOnboardingSnapshot) => { createRoot(document.getElementById('root')!).render( - - - - - , + + + + + + + , ); }); diff --git a/apps/desktop/src/renderer/platform/desktop/create-module-hub-services.ts b/apps/desktop/src/renderer/platform/desktop/create-module-hub-services.ts new file mode 100644 index 0000000000..4c4880ce4c --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-module-hub-services.ts @@ -0,0 +1,154 @@ +/* + * 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 { MakaBridge } from '../../../preload/bridge-contract.js'; +import type { + ModuleHubClipboardService, + ModuleHubServices, +} from '../../features/module-hub/index.js'; + +type DesktopModuleHubSettingsBridge = Partial< + Pick +>; + +export type DesktopModuleHubBridge = Pick< + MakaBridge, + 'dailyReview' | 'runtimeHostProfiles' | 'scheduledTasks' | 'skills' +> & { + /** Optional at runtime so a renderer can coexist with an older preload. */ + readonly settings?: DesktopModuleHubSettingsBridge; +}; + +export interface DesktopModuleHubServiceDependencies { + readonly clipboard?: ModuleHubClipboardService; +} + +/** The only Desktop-to-Module-Hub adapter. */ +export function createDesktopModuleHubServices( + bridge: DesktopModuleHubBridge = window.maka, + dependencies: DesktopModuleHubServiceDependencies = {}, +): ModuleHubServices { + const getClientSettings = bridge.settings?.getClient; + const updateClientSettings = bridge.settings?.updateClient; + const subscribeClientSettings = bridge.settings?.subscribeClientChanged; + const clientSettingsSupported = + typeof getClientSettings === 'function' && + typeof updateClientSettings === 'function'; + + return { + runtimeHosts: { + getDefault: () => bridge.runtimeHostProfiles.getDefaultHost(), + subscribeChanges: (handler) => + bridge.runtimeHostProfiles.subscribeChanges((event) => + handler({ + profileId: event.profileId, + readiness: event.readiness, + hostId: event.hostId, + isDefault: event.isDefault, + removed: event.removed, + }), + ), + }, + skills: { + list: (host) => bridge.skills.list(host), + listManagedSources: (host) => bridge.skills.sources.list(host), + listBundledCatalog: (host) => bridge.skills.catalog.list(host), + importManagedSource: (host) => bridge.skills.sources.importLocalFile(host), + installManaged: (sourceId, host) => + bridge.skills.installManaged(sourceId, host), + installBundled: (id, host) => bridge.skills.catalog.install(id, host), + previewUpdate: (skillId, host) => + bridge.skills.previewUpdate(skillId, host), + updateManaged: (skillId, options, host) => + bridge.skills.updateManaged(skillId, options, host), + setEnabled: (skillId, enabled, host) => + bridge.skills.setEnabled(skillId, enabled, host), + setPinned: (skillRef, pinned, host) => + bridge.skills.setPinned(skillRef, pinned, host), + delete: (skillRef, host) => bridge.skills.delete(skillRef, host), + open: (skillId, target, host) => bridge.skills.open(skillId, target, host), + }, + scheduledTasks: { + list: (host) => bridge.scheduledTasks.list(host), + create: (input, host) => bridge.scheduledTasks.create(input, host), + update: (id, patch, host) => + bridge.scheduledTasks.update(id, patch, host), + setEnabled: (id, enabled, host) => + bridge.scheduledTasks.setEnabled(id, enabled, host), + triggerNow: (id, host) => bridge.scheduledTasks.triggerNow(id, host), + snooze: (id, host) => bridge.scheduledTasks.snooze(id, host), + clearRunHistory: (id, host) => + bridge.scheduledTasks.clearRunHistory(id, host), + delete: (id, host) => bridge.scheduledTasks.delete(id, host), + subscribeChanges: (handler) => + bridge.scheduledTasks.subscribeChanges(handler), + subscribeDue: (handler) => bridge.scheduledTasks.subscribeDue(handler), + }, + clientSettings: { + supported: clientSettingsSupported, + async getKeepSystemAwake() { + if (!getClientSettings) { + throw new Error('Client settings are unavailable'); + } + const settings = await getClientSettings.call(bridge.settings); + return settings.system.keepSystemAwake; + }, + async setKeepSystemAwake(next) { + if (!updateClientSettings) { + throw new Error('Client settings are unavailable'); + } + const result = await updateClientSettings.call(bridge.settings, { + system: { keepSystemAwake: next }, + }); + return result.settings.system.keepSystemAwake; + }, + subscribeChanges(handler) { + if (!subscribeClientSettings) return () => undefined; + return subscribeClientSettings.call(bridge.settings, handler); + }, + }, + dailyReview: { + day: (offsetDays, daySpan, host) => + bridge.dailyReview.day(offsetDays, daySpan, host), + runOnce: (input) => { + const runOnce = bridge.dailyReview.runOnce; + if (!runOnce) throw new Error('Daily Review run is unavailable'); + return runOnce(input); + }, + listArchives: () => { + const listArchives = bridge.dailyReview.listArchives; + if (!listArchives) throw new Error('Daily Review history is unavailable'); + return listArchives(); + }, + getArchive: (archiveId) => { + const getArchive = bridge.dailyReview.getArchive; + if (!getArchive) throw new Error('Daily Review history is unavailable'); + return getArchive(archiveId); + }, + saveMarkdownToFile: (input) => + bridge.dailyReview.saveMarkdownToFile(input), + }, + clipboard: { + writeText(text) { + const clipboard = dependencies.clipboard ?? navigator.clipboard; + return clipboard.writeText(text); + }, + }, + }; +} diff --git a/apps/desktop/src/renderer/use-composer-mentions.ts b/apps/desktop/src/renderer/use-composer-mentions.ts index 2d31f9505c..ea95b268d9 100644 --- a/apps/desktop/src/renderer/use-composer-mentions.ts +++ b/apps/desktop/src/renderer/use-composer-mentions.ts @@ -19,7 +19,6 @@ import { useCallback, useEffect, useState } from 'react'; import type { ChatDefaultPermissionMode } from '@maka/core/settings'; -import type { SkillEntry } from '@maka/ui'; import type { InvocableSkillEntry } from '@maka/runtime/skill-invocation'; import type { DesktopNewTaskTarget } from '../preload/bridge-contract.js'; @@ -56,7 +55,8 @@ function invocableSkillListsEqual( * identities across renders. */ export function useComposerMentions(options: { - skills: readonly SkillEntry[]; + /** Invalidates Runtime's invocable projection after installed Skills settle. */ + skillCatalogRevision: number; sessionId?: string; projectPath?: string; newSessionModel?: { llmConnectionSlug: string; model: string }; @@ -72,7 +72,7 @@ export function useComposerMentions(options: { const { projectPath, sessionId, - skills, + skillCatalogRevision, newSessionModel, newSessionCollaborationMode, newSessionPermissionMode, @@ -203,7 +203,7 @@ export function useComposerMentions(options: { }, [ projectPath, sessionId, - skills, + skillCatalogRevision, newSessionModel?.llmConnectionSlug, newSessionModel?.model, newSessionCollaborationMode, diff --git a/apps/desktop/src/renderer/use-keep-system-awake.ts b/apps/desktop/src/renderer/use-keep-system-awake.ts deleted file mode 100644 index 2223574835..0000000000 --- a/apps/desktop/src/renderer/use-keep-system-awake.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * 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 { useCallback, useEffect, useState } from 'react'; -import { useMountedRef } from '@maka/ui'; - -/** - * Reads + writes the 保持系统唤醒 (`settings.system.keepSystemAwake`) toggle - * that surfaces on the 定时任务 page. This is a Desktop preference, so it - * remains available when the selected Runtime Host is offline. - * - * `supported` gates the whole capability on bridge presence: when the - * preload bridge is absent (older main, or a non-Electron host), the caller - * hides the row entirely rather than rendering a dead control. The - * optimistic-update / revert-on-error / toast lifecycle lives in the panel; - * this hook only owns the persisted snapshot and the write that rejects on - * failure so the panel can revert. - */ -export interface KeepSystemAwakeController { - /** Whether the settings bridge exposing this toggle exists. */ - supported: boolean; - /** Last-known persisted value. Undefined until the initial read settles. */ - keepSystemAwake: boolean | undefined; - /** - * Persist a new value. Resolves once the store confirms the write (and - * updates the local snapshot); rejects on failure so the caller can revert - * its optimistic UI. - */ - setKeepSystemAwake(next: boolean): Promise; -} - -export function useKeepSystemAwake(): KeepSystemAwakeController { - // Gate on the bridge actually exposing both calls at runtime. `window.maka` - // is typed as always-present, so a truthiness check trips TS2774; a - // `typeof … === 'function'` probe is the honest runtime guard for a - // non-Electron host or an older preload that predates this capability. - const supported = - typeof window.maka?.settings?.getClient === 'function' && - typeof window.maka?.settings?.updateClient === 'function'; - const [keepSystemAwake, setSnapshot] = useState(); - const mountedRef = useMountedRef(); - - const refresh = useCallback(async () => { - if (!supported) return; - try { - const settings = await window.maka.settings.getClient(); - if (mountedRef.current) setSnapshot(settings.system.keepSystemAwake); - } catch { - // The persisted default is false. Falling back to that known-safe value - // after an initial failure keeps the control recoverable; later failures - // must not overwrite a value that was already confirmed. - if (mountedRef.current) setSnapshot((previous) => previous ?? false); - } - }, [supported, mountedRef]); - - useEffect(() => { - void refresh(); - if (!supported) return; - // Keep the snapshot honest when settings.json is edited out of band. - return window.maka.settings.subscribeClientChanged(() => { - void refresh(); - }); - }, [supported, refresh]); - - const setKeepSystemAwake = useCallback( - async (next: boolean) => { - const result = await window.maka.settings.updateClient({ system: { keepSystemAwake: next } }); - if (mountedRef.current) setSnapshot(result.settings.system.keepSystemAwake); - }, - [mountedRef], - ); - - return { supported, keepSystemAwake, setKeepSystemAwake }; -} diff --git a/apps/desktop/src/renderer/use-module-data.ts b/apps/desktop/src/renderer/use-module-data.ts deleted file mode 100644 index 121c794790..0000000000 --- a/apps/desktop/src/renderer/use-module-data.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* - * 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 { useRef, useState } from 'react'; -import type { ScheduledTask } from '@maka/core/scheduled-task'; -import type { UiLocale } from '@maka/core/ui-locale'; -import type { BundledSkillCatalogEntry, ManagedSkillSourceEntry, SkillEntry } from '@maka/ui'; -import { - createAppShellScheduledTaskActions, - type AppShellScheduledTaskActions, -} from './app-shell-scheduled-task-actions'; -import { createAppShellSkillActions, type AppShellSkillActions } from './app-shell-skill-actions'; - -type ToastApi = { - success(title: string, description?: string): void; - error( - title: string, - description?: string, - diagnosticDetails?: string, - diagnosticTarget?: { profileId: string }, - ): void; - confirm(options: { - title: string; - description: string; - confirmLabel: string; - cancelLabel: string; - destructive?: boolean; - }): Promise; -}; - -/** - * Owns the two sidebar-module data clusters — installed/managed/bundled - * skills and scheduled tasks — together with their refresh + mutation - * helpers. The - * surface-active predicates are injected so the mutation helpers only - * surface error toasts while their module is the foreground view, exactly - * as before. Pure move: every returned action keeps its prior identity - * semantics (recreated each render alongside the shell) and the task - * getter reads the latest values on each call. - */ -export function useAppShellModuleData(options: { - uiLocale: UiLocale; - isSkillsSurfaceActive: () => boolean; - isScheduledTasksSurfaceActive: () => boolean; - toastApi: ToastApi; -}): AppShellScheduledTaskActions & AppShellSkillActions & { - skills: SkillEntry[]; - managedSkillSources: ManagedSkillSourceEntry[]; - bundledSkillCatalog: BundledSkillCatalogEntry[]; - scheduledTasks: ScheduledTask[]; -} { - const { - uiLocale, - isSkillsSurfaceActive, - isScheduledTasksSurfaceActive, - toastApi, - } = options; - const [skills, setSkills] = useState([]); - const [managedSkillSources, setManagedSkillSources] = useState([]); - const [bundledSkillCatalog, setBundledSkillCatalog] = useState([]); - const [scheduledTasks, setScheduledTasks] = useState([]); - const refreshGenerationsRef = useRef({ - skills: 0, - managedSkillSources: 0, - bundledSkillCatalog: 0, - scheduledTasks: 0, - }); - - const scheduledTaskActions = createAppShellScheduledTaskActions({ - uiLocale, - getScheduledTasks: () => scheduledTasks, - isScheduledTasksSurfaceActive, - refreshGenerationsRef, - setScheduledTasks, - toastApi, - }); - - const skillActions = createAppShellSkillActions({ - uiLocale, - isSkillsSurfaceActive, - refreshGenerationsRef, - setSkills, - setManagedSkillSources, - setBundledSkillCatalog, - toastApi, - }); - - return { - skills, - managedSkillSources, - bundledSkillCatalog, - scheduledTasks, - ...scheduledTaskActions, - ...skillActions, - }; -} diff --git a/apps/desktop/stories/module-hubs.stories.tsx b/apps/desktop/stories/module-hubs.stories.tsx index 67ff23885d..1fdd2db49b 100644 --- a/apps/desktop/stories/module-hubs.stories.tsx +++ b/apps/desktop/stories/module-hubs.stories.tsx @@ -35,6 +35,8 @@ import { } from '@maka/ui'; import { type ComponentProps, type ReactNode, useState } from 'react'; import { WorkbarTitlebarActions } from '../src/renderer/features/workbar'; +import { ModuleHubHost } from '../src/renderer/features/module-hub/index'; +import { createFakeModuleHubHostModel } from '../src/renderer/features/module-hub/testing'; import { AppShellDetailPanel } from '../src/renderer/app-shell-detail-panel'; import { McpPage } from '../src/renderer/mcp-page'; import { withScopedMakaBridge } from './maka-bridge'; @@ -669,7 +671,11 @@ function ExtensionsMcpSurface() { ); } -function ScheduledTasksSurface(props: { tasks?: ScheduledTask[] }) { +function ScheduledTasksSurface(props: { + tasks?: ScheduledTask[]; + keepSystemAwake?: boolean; + onKeepSystemAwakeChange?: (next: boolean) => Promise; +}) { const copy = getSharedUiCopy(useUiLocale()).moduleHubs.automations; return ( @@ -680,8 +686,10 @@ function ScheduledTasksSurface(props: { tasks?: ScheduledTask[] }) { badge: {}} />, }} tasks={props.tasks ?? []} - keepSystemAwake={false} - onKeepSystemAwakeChange={async () => {}} + keepSystemAwake={props.keepSystemAwake ?? false} + onKeepSystemAwakeChange={ + props.onKeepSystemAwakeChange ?? (async () => {}) + } onRefresh={noop} onCreate={noop} onUpdate={noop} @@ -719,6 +727,42 @@ function ScheduledDailyReviewSurface( ); } +function ModuleHubHostSurface(props: { + selection: + | { section: 'extensions'; module: 'skills' | 'mcp' } + | { section: 'automations'; module: 'scheduled-tasks' | 'daily-review' }; +}) { + const base = createFakeModuleHubHostModel(props.selection); + const model = { + ...base, + skills: { + ...base.skills, + skills: INSTALLED_SKILLS, + bundledSkillCatalog: BUNDLED_SKILLS, + }, + scheduledTasks: { + ...base.scheduledTasks, + scheduledTasks: CONFIGURED_TASKS, + }, + dailyReview: { + ...base.dailyReview, + bridge: { + fetchDay: async () => DAILY_REVIEW_SUMMARY, + }, + }, + }; + const agentsView = props.selection.section === 'extensions' + ? props.selection.module + : props.selection.module === 'daily-review' + ? 'daily-review' + : 'cron'; + return ( + + + + ); +} + async function waitForStoryButton( canvasElement: HTMLElement, predicate: (button: HTMLButtonElement) => boolean, @@ -756,6 +800,40 @@ export const ExtensionsSkillsEmpty: Story = { render: () => , }; +// Feature-slice composition coverage: the production Host, not a direct leaf. +export const HostExtensionsSkills: Story = { + render: () => ( + + ), +}; + +export const HostExtensionsMcp: Story = { + decorators: [withEmptyMcpBridge], + render: () => ( + + ), +}; + +export const HostAutomationsScheduledTasks: Story = { + render: () => ( + + ), +}; + +export const HostAutomationsDailyReview: Story = { + render: () => ( + + ), +}; + // Real path: sidebar → 扩展 → 技能, with several installed Skills. export const ExtensionsSkillsInstalled: Story = { render: () => , @@ -945,6 +1023,54 @@ export const ScheduledTasksConfigured: Story = { render: () => , }; +// A newer external settings read wins over a slow local write in the Module +// Hub controller. The checkbox must return to the persisted prop when that +// pending write settles, even when the Boolean prop itself never changed. +export const ScheduledTasksKeepAwakeExternalWins: Story = { + render: () => ( + { + await new Promise((resolve) => globalThis.setTimeout(resolve, 80)); + }} + /> + ), + play: async ({ canvasElement }) => { + const settings = await waitForStoryButton( + canvasElement, + (candidate) => candidate.getAttribute('aria-label') === '定时任务页面设置', + ); + settings.click(); + const body = canvasElement.ownerDocument.body; + const checkbox = await waitForStorySelector( + body, + '[role="menuitemcheckbox"]', + ); + if (checkbox.getAttribute('aria-checked') !== 'true') { + throw new Error('Keep-awake story did not start from persisted true'); + } + checkbox.click(); + for (let attempt = 0; attempt < 50; attempt += 1) { + const current = body.querySelector('[role="menuitemcheckbox"]'); + if (current?.getAttribute('aria-checked') === 'false') break; + await new Promise((resolve) => globalThis.setTimeout(resolve, 10)); + if (attempt === 49) throw new Error('Keep-awake optimistic value did not render'); + } + await new Promise((resolve) => globalThis.setTimeout(resolve, 100)); + let persisted = body.querySelector('[role="menuitemcheckbox"]'); + if (!persisted) { + settings.click(); + persisted = await waitForStorySelector( + body, + '[role="menuitemcheckbox"]', + ); + } + if (persisted.getAttribute('aria-checked') !== 'true') { + throw new Error('Keep-awake checkbox diverged from the persisted setting'); + } + }, +}; + // Real path: sidebar → 定时任务 → 定时任务 → click a task row, which opens the // inspector where every per-task control now lives. Wide only: below 1024px the // page drops the inspector rather than squeeze two columns into one. diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 55dc8f7c78..000849b7cd 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -5,7 +5,7 @@ Each row is one on-disk product surface file. Regenerated inventory must stay in Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 207 files — blocker 0, polish 1, aligned 206. +**Totals:** 209 files — blocker 0, polish 1, aligned 208. ## Exclusions (explicit) @@ -41,6 +41,8 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/goals/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, Text, TextInput, VStack | aligned — uses Astryx (Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, Text, TextInput) | aligned | | `apps/desktop/src/renderer/features/goals/ui/goal-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/module-hub/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/workbar/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx` | shell-chrome-or-panel | Badge, Banner, Button, EmptyState | aligned — uses Astryx (Badge, Banner, Button, EmptyState) | aligned | | `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx` | shell-chrome-or-panel | Banner, Button, Spinner | aligned — uses Astryx (Banner, Button, Spinner) | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 5f902815a8..6ae464a287 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -13,6 +13,8 @@ apps/desktop/src/renderer/error-boundary.tsx apps/desktop/src/renderer/features/goals/services-context.tsx apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx apps/desktop/src/renderer/features/goals/ui/goal-host.tsx +apps/desktop/src/renderer/features/module-hub/services-context.tsx +apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx apps/desktop/src/renderer/features/workbar/services-context.tsx apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx diff --git a/packages/ui/src/scheduled-task-panel.tsx b/packages/ui/src/scheduled-task-panel.tsx index e23babe84a..fc041986b9 100644 --- a/packages/ui/src/scheduled-task-panel.tsx +++ b/packages/ui/src/scheduled-task-panel.tsx @@ -190,11 +190,14 @@ export function ScheduledTaskPanel(props: { // Re-sync the switch to the persisted snapshot when it changes (external // edit, relaunch), unless a local write is mid-flight — the optimistic - // value wins until the write settles. + // value wins until the write settles. Pending is also a dependency: an + // external refresh can deliberately retain the same persisted Boolean and + // supersede a slow write, so the prop itself may not change when the write + // finishes. useEffect(() => { - if (keepSystemAwakePendingRef.current) return; + if (keepSystemAwakePending) return; if (props.keepSystemAwake !== undefined) setKeepSystemAwakeChecked(props.keepSystemAwake); - }, [props.keepSystemAwake]); + }, [keepSystemAwakePending, props.keepSystemAwake]); useEffect(() => { if (!props.createRequestNonce) return; diff --git a/packages/ui/src/session-list-panel.tsx b/packages/ui/src/session-list-panel.tsx index 8c9fb8cb12..fee2ea05ec 100644 --- a/packages/ui/src/session-list-panel.tsx +++ b/packages/ui/src/session-list-panel.tsx @@ -60,7 +60,7 @@ export function SessionListPanel(props: { selection: NavSelection; sessions: SessionSummary[]; activeId?: string; - scheduledTasks?: ScheduledTask[]; + scheduledTasks?: readonly ScheduledTask[]; streamingSessionIds?: Set; staleSessionIds?: Set; groups?: ReadonlyArray; diff --git a/packages/ui/src/session-sidebar-nav.tsx b/packages/ui/src/session-sidebar-nav.tsx index 4df03e1be0..e7d3b71ff1 100644 --- a/packages/ui/src/session-sidebar-nav.tsx +++ b/packages/ui/src/session-sidebar-nav.tsx @@ -29,7 +29,7 @@ import { Tooltip } from '@astryxdesign/core/Tooltip'; export function SessionSidebarNav(props: { selection: NavSelection; - scheduledTasks?: ScheduledTask[]; + scheduledTasks?: readonly ScheduledTask[]; moduleMemory?: NavModuleMemory; onSelect(selection: NavSelection): void; onNew(): void;