From 96340b4d48b6bf6092b584c329023548d21bd0c1 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Tue, 8 Sep 2026 00:45:54 +0800 Subject: [PATCH] refactor(desktop): move the overlay surfaces below AppShell Give the keyboard help, the Command Palette, the Search modal and the Settings modal one owner outside the shell. `features/overlays` holds their open state, the Settings request with its sub-surfaces, the Search scroll target and the global shortcuts; `OverlaysRoot` is the registered owner of `useOverlaysController` and hands the shell frame the overlays through a render prop, so `AppShellContent` calls none of the four hooks. The legacy overlay layer reads what to show through `OverlaysConsumer` and keeps only the lazy Settings modal and the palette's command list, which are shell concerns: its props drop from 34 to 17. The thread search, the remembered Settings section and the focus settle before Settings opens reach the feature through one Desktop adapter. `AppShellContent` loses four hooks (40 to 36 in the gate inventory) and five legacy files leave the renderer root. --- apps/desktop/renderer-architecture.json | 130 +------- .../main/__tests__/overlays-boundary.test.ts | 295 ++++++++++++++++++ .../src/main/__tests__/overlays-model.test.ts | 121 +++++++ .../__tests__/overlays-provider-scope.test.ts | 201 ++++++++++++ .../overlays-services-adapter.test.ts | 84 +++++ .../src/renderer/app-shell-command-actions.ts | 2 +- .../src/renderer/app-shell-e2e-fixture.ts | 6 +- .../src/renderer/app-shell-overlays.tsx | 152 ++++----- apps/desktop/src/renderer/app-shell.tsx | 88 ++---- .../src/renderer/command-palette-commands.ts | 2 +- .../composition/desktop-feature-services.tsx | 9 +- .../src/renderer/features/overlays/README.md | 49 +++ .../controller/use-overlays-controller.ts | 124 ++++++++ .../src/renderer/features/overlays/index.ts | 28 ++ .../overlays/model/command.ts} | 6 +- .../overlays/model/overlays-projection.ts | 61 ++++ .../overlays/model/search-scroll-target.ts | 38 +++ .../overlays/model/settings-surface.ts | 107 +++++++ .../src/renderer/features/overlays/ports.ts | 50 +++ .../features/overlays/services-context.tsx | 29 ++ .../src/renderer/features/overlays/testing.ts | 46 +++ .../overlays/ui}/command-palette.tsx | 97 ++---- .../overlays/ui/keyboard-help-modal.tsx | 107 +++++++ .../features/overlays/ui/overlays-context.tsx | 37 +++ .../features/overlays/ui/overlays-root.tsx | 39 +++ .../overlays/ui/search-modal-host.tsx | 49 +++ apps/desktop/src/renderer/keyboard-help.tsx | 143 --------- .../desktop/create-overlays-services.ts | 59 ++++ .../src/renderer/use-settings-modal.ts | 131 -------- apps/desktop/src/renderer/use-shell-search.ts | 73 ----- .../stories/command-search.stories.tsx | 39 ++- docs/astryx-surface-file-inventory.md | 10 +- docs/astryx-surface-file-inventory.paths | 8 +- scripts/check-app-shell-hooks.mjs | 4 - 34 files changed, 1713 insertions(+), 711 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/overlays-boundary.test.ts create mode 100644 apps/desktop/src/main/__tests__/overlays-model.test.ts create mode 100644 apps/desktop/src/main/__tests__/overlays-provider-scope.test.ts create mode 100644 apps/desktop/src/main/__tests__/overlays-services-adapter.test.ts create mode 100644 apps/desktop/src/renderer/features/overlays/README.md create mode 100644 apps/desktop/src/renderer/features/overlays/controller/use-overlays-controller.ts create mode 100644 apps/desktop/src/renderer/features/overlays/index.ts rename apps/desktop/src/renderer/{command-palette-types.ts => features/overlays/model/command.ts} (86%) create mode 100644 apps/desktop/src/renderer/features/overlays/model/overlays-projection.ts create mode 100644 apps/desktop/src/renderer/features/overlays/model/search-scroll-target.ts create mode 100644 apps/desktop/src/renderer/features/overlays/model/settings-surface.ts create mode 100644 apps/desktop/src/renderer/features/overlays/ports.ts create mode 100644 apps/desktop/src/renderer/features/overlays/services-context.tsx create mode 100644 apps/desktop/src/renderer/features/overlays/testing.ts rename apps/desktop/src/renderer/{ => features/overlays/ui}/command-palette.tsx (69%) create mode 100644 apps/desktop/src/renderer/features/overlays/ui/keyboard-help-modal.tsx create mode 100644 apps/desktop/src/renderer/features/overlays/ui/overlays-context.tsx create mode 100644 apps/desktop/src/renderer/features/overlays/ui/overlays-root.tsx create mode 100644 apps/desktop/src/renderer/features/overlays/ui/search-modal-host.tsx delete mode 100644 apps/desktop/src/renderer/keyboard-help.tsx create mode 100644 apps/desktop/src/renderer/platform/desktop/create-overlays-services.ts delete mode 100644 apps/desktop/src/renderer/use-settings-modal.ts delete mode 100644 apps/desktop/src/renderer/use-shell-search.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 3ef2c9e555..8397193db0 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -34,8 +34,6 @@ "src/renderer/chat-message-surface.tsx", "src/renderer/chat-recovery-notice.tsx", "src/renderer/command-palette-commands.ts", - "src/renderer/command-palette-types.ts", - "src/renderer/command-palette.tsx", "src/renderer/composer-attachments.ts", "src/renderer/composer-defaults.ts", "src/renderer/composer-mentions.tsx", @@ -53,7 +51,6 @@ "src/renderer/error-boundary.tsx", "src/renderer/follow-up-submit-routing.ts", "src/renderer/interrupted-resume.ts", - "src/renderer/keyboard-help.tsx", "src/renderer/live-content-seed.ts", "src/renderer/live-turn-reconciler.tsx", "src/renderer/live-turn-snapshot.ts", @@ -229,14 +226,12 @@ "src/renderer/use-new-task-choice.ts", "src/renderer/use-onboarding-snapshot.ts", "src/renderer/use-project-context.ts", - "src/renderer/use-settings-modal.ts", "src/renderer/use-shell-appearance.ts", "src/renderer/use-shell-chat-model.ts", "src/renderer/use-shell-connections.ts", "src/renderer/use-shell-live-turn.ts", "src/renderer/use-shell-memory-pill.ts", "src/renderer/use-shell-resume.ts", - "src/renderer/use-shell-search.ts", "src/renderer/use-stable-actions.ts", "src/renderer/use-system-ui-locale.ts", "src/renderer/use-task-submission-readiness.ts", @@ -295,6 +290,13 @@ "ownerSymbol": "ModuleHubProvider", "count": 1 }, + { + "implementation": "src/renderer/features/overlays/controller/use-overlays-controller.ts", + "symbol": "useOverlaysController", + "owner": "src/renderer/features/overlays/ui/overlays-root.tsx", + "ownerSymbol": "OverlaysRoot", + "count": 1 + }, { "implementation": "src/renderer/features/task-entry/controller/use-task-entry-controller.ts", "symbol": "useTaskEntryController", @@ -447,7 +449,7 @@ "./theme": 1 }, "importSpecifiers": 1, - "nonTriviaTokens": 642 + "nonTriviaTokens": 637 }, "src/renderer/app-shell-effects.ts": { "importDeclarations": 11, @@ -503,7 +505,7 @@ "nonTriviaTokens": 3816 }, "src/renderer/app-shell-overlays.tsx": { - "importDeclarations": 7, + "importDeclarations": 5, "bridgePaths": {}, "environmentCapabilities": { "window": 2, @@ -522,8 +524,7 @@ "actionFactories": [], "dependencyPaths": { "./app-shell-command-actions": 1, - "./command-palette": 1, - "./keyboard-help": 1, + "./features/overlays/index.js": 1, "./locales/shell-remaining-copy.js": 1, "./settings/settings-modal": 1, "@astryxdesign/core/hooks": 1, @@ -531,8 +532,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 11, - "nonTriviaTokens": 977 + "importSpecifiers": 8, + "nonTriviaTokens": 881 }, "src/renderer/app-shell-project-actions.ts": { "importDeclarations": 4, @@ -710,7 +711,7 @@ "nonTriviaTokens": 1395 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 71, + "importDeclarations": 67, "bridgePaths": { "window.maka.attachments": 1, "window.maka.attachments.readBytes": 1, @@ -752,10 +753,8 @@ "useAppShellSessionUiReads": 1, "useAppShellSessionWorkspace": 1, "useAppShellTurnPresentation": 1, - "useCommandPalette": 1, "useComposerAttachments": 1, "useEffect": 7, - "useKeyboardHelp": 1, "useLayoutEffect": 2, "useNewTaskChoice": 1, "useOnboardingSnapshot": 1, @@ -765,7 +764,6 @@ "useSessionEventHealthPolling": 1, "useSessionNavigationReads": 1, "useSessionSettingIntent": 1, - "useSettingsModal": 1, "useShellAppearance": 1, "useShellChatModel": 1, "useShellConnections": 3, @@ -773,7 +771,6 @@ "useShellMemoryPill": 1, "useShellResume": 1, "useShellRunUpdates": 1, - "useShellSearch": 1, "useStableActions": 6, "useState": 14, "useSystemUiLocale": 1, @@ -803,7 +800,6 @@ "./app-shell-turn-view-model": 1, "./chat-composer-region": 1, "./chat-message-surface": 1, - "./command-palette": 1, "./composer-defaults": 1, "./composer-mentions": 1, "./custom-pet-companion": 1, @@ -817,6 +813,7 @@ "./features/conversation/index.js": 1, "./features/goals": 1, "./features/module-hub": 1, + "./features/overlays/index.js": 1, "./features/session-collaboration": 1, "./features/session-navigation": 1, "./features/session-settings": 1, @@ -824,7 +821,6 @@ "./features/workbar": 1, "./features/workhub": 1, "./follow-up-submit-routing": 1, - "./keyboard-help": 1, "./live-content-seed": 1, "./live-turn-reconciler": 1, "./locales/conversation-copy": 1, @@ -849,14 +845,12 @@ "./use-new-task-choice": 1, "./use-onboarding-snapshot": 1, "./use-project-context": 1, - "./use-settings-modal": 1, "./use-shell-appearance": 1, "./use-shell-chat-model": 1, "./use-shell-connections": 1, "./use-shell-live-turn": 1, "./use-shell-memory-pill": 1, "./use-shell-resume": 1, - "./use-shell-search": 1, "./use-stable-actions": 1, "./use-system-ui-locale": 1, "./use-task-submission-readiness": 1, @@ -872,8 +866,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 109, - "nonTriviaTokens": 13746 + "importSpecifiers": 105, + "nonTriviaTokens": 13666 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, @@ -1163,41 +1157,6 @@ "@maka/ui/icons": 1 } }, - "src/renderer/command-palette-types.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {} - }, - "src/renderer/command-palette.tsx": { - "bridgePaths": {}, - "environmentCapabilities": { - "window.cancelAnimationFrame": 1, - "window.requestAnimationFrame": 1 - }, - "hookCalls": { - "useEffect": 1, - "useHotkeys": 1, - "useRef": 1, - "useState": 1, - "useUiLocale": 1 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./locales/shell-copy": 1, - "@astryxdesign/core/EmptyState": 1, - "@astryxdesign/core/hooks": 1, - "@astryxdesign/core/Kbd": 1, - "@maka/ui": 1, - "@maka/ui/icons": 1, - "react": 1 - } - }, "src/renderer/composer-attachments.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -1404,29 +1363,6 @@ "actionFactories": [], "dependencyPaths": {} }, - "src/renderer/keyboard-help.tsx": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": { - "useHotkeys": 1, - "useState": 1, - "useUiLocale": 1 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./locales/shell-copy": 1, - "@astryxdesign/core/Dialog": 1, - "@astryxdesign/core/Heading": 1, - "@astryxdesign/core/hooks": 1, - "@astryxdesign/core/Kbd": 1, - "@astryxdesign/core/Layout": 1, - "@maka/ui": 1, - "@maka/ui/icons": 1, - "react": 1 - } - }, "src/renderer/live-content-seed.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -4263,23 +4199,6 @@ "react": 1 } }, - "src/renderer/use-settings-modal.ts": { - "bridgePaths": {}, - "environmentCapabilities": { - "document.activeElement": 1, - "document.activeElement.blur": 1 - }, - "hookCalls": { - "useState": 5 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./browser-storage": 1, - "react": 1 - } - }, "src/renderer/use-shell-appearance.ts": { "bridgePaths": { "window.maka.e2eFixture.getState": 1, @@ -4390,21 +4309,6 @@ "react": 1 } }, - "src/renderer/use-shell-search.ts": { - "bridgePaths": { - "window.maka.search.thread": 1 - }, - "environmentCapabilities": {}, - "hookCalls": { - "useState": 2 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "react": 1 - } - }, "src/renderer/use-stable-actions.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -4784,7 +4688,7 @@ }, { "capability": "commands-and-overlays", - "targetZone": "application/overlays", + "targetZone": "features/overlays", "legacyPaths": [ "src/renderer/app-shell-command-actions.ts", "src/renderer/app-shell-overlays.tsx" diff --git a/apps/desktop/src/main/__tests__/overlays-boundary.test.ts b/apps/desktop/src/main/__tests__/overlays-boundary.test.ts new file mode 100644 index 0000000000..b381d3e7c7 --- /dev/null +++ b/apps/desktop/src/main/__tests__/overlays-boundary.test.ts @@ -0,0 +1,295 @@ +/* + * 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, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { parse } from '@babel/parser'; +// The architecture checker is an executable JavaScript module by design. +// @ts-expect-error It does not publish a declaration file. +import { analyzeRendererSource } from '../../../scripts/check-renderer-architecture.mjs'; + +const desktopRoot = resolve(fileURLToPath(new URL('../../../', import.meta.url))); +const rendererRoot = join(desktopRoot, 'src', 'renderer'); +const featureRoot = join(rendererRoot, 'features', 'overlays'); +const sourceCache = new Map(); +const analysisCache = new Map>(); + +function sourceOf(path: string): string { + const cached = sourceCache.get(path); + if (cached !== undefined) return cached; + const source = readFileSync(path, 'utf8'); + sourceCache.set(path, source); + return source; +} + +function analysisOf(path: string): ReturnType { + const cached = analysisCache.get(path); + if (cached) return cached; + const analysis = analyzeRendererSource(sourceOf(path), path); + analysisCache.set(path, analysis); + return analysis; +} + +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)$/.test(entry.name) ? [path] : []; + }); +} + +function relativeSource(path: string): string { + return relative(desktopRoot, path).replace(/\\/g, '/'); +} + +function productionRendererSources(): string[] { + return sourceFiles(rendererRoot).filter( + (path) => + !path.replace(/\\/g, '/').includes('/__tests__/') && !path.endsWith(join('', 'testing.ts')), + ); +} + +function parseModule(source: string, file: string) { + return parse(source, { + createImportExpressions: true, + sourceType: 'module', + sourceFilename: file, + plugins: ['typescript', 'jsx'], + }); +} + +function visit(value: unknown, onNode: (node: Record) => void): void { + if (!value || typeof value !== 'object') return; + if (Array.isArray(value)) { + for (const child of value) visit(child, onNode); + return; + } + const node = value as Record; + onNode(node); + for (const [key, child] of Object.entries(node)) { + if (key !== 'loc' && key !== 'start' && key !== 'end') visit(child, onNode); + } +} + +/** Every JSX mount of `exportedName` bound from a module matching `matches`. */ +function jsxMounts( + source: string, + file: string, + exportedName: string, + matches: (dependency: string) => boolean, +): number { + const locals = new Set(); + const namespaces = new Set(); + let mounts = 0; + visit(parseModule(source, file).program, (node) => { + if (node.type === 'ImportDeclaration') { + const dependency = String((node.source as { value?: unknown }).value ?? ''); + if (!matches(dependency)) return; + for (const specifier of (node.specifiers as Array>) ?? []) { + const local = (specifier.local as { name?: string }).name ?? ''; + if (specifier.type === 'ImportNamespaceSpecifier') namespaces.add(local); + const imported = specifier.imported as { name?: string; value?: string } | undefined; + if ( + specifier.type === 'ImportSpecifier' && + (imported?.name === exportedName || imported?.value === exportedName) + ) { + locals.add(local); + } + } + } + if (node.type === 'JSXOpeningElement') { + const name = node.name as Record; + if (name.type === 'JSXIdentifier' && locals.has(String(name.name))) mounts += 1; + if (name.type === 'JSXMemberExpression') { + const object = name.object as { name?: string }; + const property = name.property as { name?: string }; + if (namespaces.has(String(object.name)) && property.name === exportedName) mounts += 1; + } + } + }); + return mounts; +} + +/** Every binding a module takes from modules matching `matches`. */ +function moduleBindings( + source: string, + file: string, + matches: (dependency: string) => boolean, +): string[] { + const bindings: string[] = []; + const importedName = (value: { type: string; name?: string; value?: string }) => + value.type === 'Identifier' ? String(value.name) : String(value.value); + visit(parseModule(source, file).program, (node) => { + const dependency = String((node.source as { value?: unknown } | undefined)?.value ?? ''); + if (node.type === 'ImportDeclaration' && matches(dependency)) { + for (const specifier of (node.specifiers as Array>) ?? []) { + if (specifier.type === 'ImportSpecifier') { + bindings.push(importedName(specifier.imported as Parameters[0])); + } else if (specifier.type === 'ImportDefaultSpecifier') { + bindings.push('default'); + } else { + bindings.push('*'); + } + } + } + if ( + (node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') && + matches(dependency) + ) { + bindings.push('export:*'); + } + if (node.type === 'ImportExpression' && matches(dependency)) bindings.push('dynamic:*'); + }); + return bindings; +} + +const isFeatureEntry = (dependency: string) => dependency.includes('features/overlays'); +const isDesktopAdapter = (dependency: string) => + dependency.includes('platform/desktop/create-overlays-services'); + +describe('Overlays feature boundary', () => { + test('keeps the services hook exclusively owned by the controller', () => { + const owners: string[] = []; + for (const path of productionRendererSources()) { + const calls = analysisOf(path).hookCalls.useOverlaysServices ?? 0; + for (let index = 0; index < calls; index += 1) owners.push(relativeSource(path)); + } + assert.deepEqual(owners, [ + 'src/renderer/features/overlays/controller/use-overlays-controller.ts', + ]); + }); + + test('keeps Desktop globals and shell/process dependencies out of the feature', () => { + const violations: string[] = []; + for (const path of sourceFiles(featureRoot)) { + if (path.endsWith('testing.ts')) continue; + const analysis = analysisOf(path); + for (const capability of Object.keys(analysis.bridgePaths)) { + violations.push(`${relativeSource(path)}: ${capability}`); + } + for (const dependency of analysis.dependencies as string[]) { + if ( + dependency.includes('app-shell') || + dependency.includes('/preload/') || + dependency.includes('/main/') || + dependency.includes('/settings/') || + dependency.includes('browser-storage') + ) { + violations.push(`${relativeSource(path)}: ${dependency}`); + } + } + } + assert.deepEqual(violations, []); + }); + + test('pins the production entry surface to the shell, the overlay layer, and composition', () => { + const imports: string[] = []; + for (const path of productionRendererSources()) { + for (const binding of moduleBindings(sourceOf(path), path, isFeatureEntry)) { + imports.push(`${relativeSource(path)}: ${binding}`); + } + } + assert.deepEqual(imports.sort(), [ + 'src/renderer/app-shell-command-actions.ts: Command', + 'src/renderer/app-shell-overlays.tsx: *', + 'src/renderer/app-shell-overlays.tsx: OverlaysShellProjection', + 'src/renderer/app-shell.tsx: *', + 'src/renderer/app-shell.tsx: OverlaysShellProjection', + 'src/renderer/command-palette-commands.ts: Command', + 'src/renderer/composition/desktop-feature-services.tsx: OverlaysServicesProvider', + 'src/renderer/platform/desktop/create-overlays-services.ts: OverlaysServices', + ]); + }); + + test('keeps the Desktop adapter exclusively owned by feature-services composition', () => { + const bindings: string[] = []; + for (const path of productionRendererSources()) { + for (const binding of moduleBindings(sourceOf(path), path, isDesktopAdapter)) { + bindings.push(`${relativeSource(path)}: ${binding}`); + } + } + assert.deepEqual(bindings, [ + 'src/renderer/composition/desktop-feature-services.tsx: createDesktopOverlaysServices', + ]); + }); + + test('keeps the controller, the models, and the fakes out of the production entry', () => { + const productionEntry = sourceOf(join(featureRoot, 'index.ts')); + for (const name of ['useOverlaysController', 'createFakeOverlaysServices', "from './testing"]) { + assert.equal(productionEntry.includes(name), false, name); + } + assert.equal(productionEntry.includes("from './controller/"), false); + }); + + test('keeps the raw search capability out of every production renderer module', () => { + const violations: string[] = []; + for (const path of productionRendererSources()) { + const analysis = analysisOf(path); + for (const capability of ['window.maka.search.thread', 'window.maka.search.*']) { + if ((analysis.bridgePaths[capability] ?? 0) > 0) { + violations.push(`${relativeSource(path)}: ${capability}`); + } + } + } + assert.deepEqual(violations, []); + }); + + test('mounts one OverlaysRoot in the shell and the overlay UI only in the overlay layer', () => { + const mounts: string[] = []; + for (const path of productionRendererSources()) { + const source = sourceOf(path); + for (const name of [ + 'OverlaysRoot', + 'OverlaysConsumer', + 'KeyboardHelpModal', + 'CommandPalette', + 'SearchModalHost', + ]) { + const count = jsxMounts(source, path, name, isFeatureEntry); + for (let index = 0; index < count; index += 1) { + mounts.push(`${relativeSource(path)}: ${name}`); + } + } + } + assert.deepEqual(mounts.sort(), [ + 'src/renderer/app-shell-overlays.tsx: CommandPalette', + 'src/renderer/app-shell-overlays.tsx: KeyboardHelpModal', + 'src/renderer/app-shell-overlays.tsx: OverlaysConsumer', + 'src/renderer/app-shell-overlays.tsx: SearchModalHost', + 'src/renderer/app-shell.tsx: OverlaysRoot', + ]); + }); + + test('leaves the shell with no overlay hook of its own', () => { + const shell = analysisOf(join(rendererRoot, 'app-shell.tsx')); + for (const hook of [ + 'useOverlaysController', + 'useOverlays', + 'useKeyboardHelp', + 'useCommandPalette', + 'useShellSearch', + 'useSettingsModal', + ]) { + assert.equal(shell.hookCalls[hook] ?? 0, 0, hook); + } + }); +}); diff --git a/apps/desktop/src/main/__tests__/overlays-model.test.ts b/apps/desktop/src/main/__tests__/overlays-model.test.ts new file mode 100644 index 0000000000..ba155407a0 --- /dev/null +++ b/apps/desktop/src/main/__tests__/overlays-model.test.ts @@ -0,0 +1,121 @@ +/* + * 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 { describe, test } from 'node:test'; +import { + CLOSED_SETTINGS_SURFACE, + closeSettingsSurface, + consumeSearchScrollTarget, + openSettingsSurface, + settingsIntentSection, + withSettingsProfileId, + type SettingsSurface, +} from '../../renderer/features/overlays/testing.js'; + +const remembered: SettingsSurface = { + ...CLOSED_SETTINGS_SURFACE, + request: { section: 'general', profileId: 'profile-1' }, +}; + +describe('Settings surface model', () => { + test('a plain open keeps the remembered request and resets every sub-surface', () => { + const detail = openSettingsSurface(remembered, { kind: 'connection-detail', slug: 'acme' }); + assert.equal(detail.connectionDetailSlug, 'acme'); + const reopened = openSettingsSurface(detail, { kind: 'settings' }); + assert.deepEqual(reopened, { + open: true, + request: { section: 'models', profileId: 'profile-1' }, + providerCatalogOpen: false, + connectionDetailSlug: undefined, + createProviderType: undefined, + }); + }); + + test('a section open merges the section into the request', () => { + const next = openSettingsSurface(remembered, { kind: 'section', section: 'projects' }); + assert.deepEqual(next.request, { section: 'projects', profileId: 'profile-1' }); + assert.equal(next.open, true); + }); + + test('a project open replaces the whole request so a stale profile cannot leak', () => { + const next = openSettingsSurface(remembered, { kind: 'project', profileId: 'profile-2' }); + assert.deepEqual(next.request, { section: 'projects', profileId: 'profile-2' }); + }); + + test('the models openers land on models and raise only their own sub-surface', () => { + const catalog = openSettingsSurface(remembered, { kind: 'provider-catalog' }); + assert.equal(catalog.request.section, 'models'); + assert.equal(catalog.providerCatalogOpen, true); + assert.equal(catalog.connectionDetailSlug, undefined); + assert.equal(catalog.createProviderType, undefined); + + const detail = openSettingsSurface(catalog, { kind: 'connection-detail', slug: 'acme' }); + assert.equal(detail.providerCatalogOpen, false); + assert.equal(detail.connectionDetailSlug, 'acme'); + + const create = openSettingsSurface(detail, { kind: 'provider-create', providerType: 'openai' }); + assert.equal(create.connectionDetailSlug, undefined); + assert.equal(create.createProviderType, 'openai'); + }); + + test('the persisted section is the one the intent lands on', () => { + assert.equal(settingsIntentSection({ kind: 'settings' }), undefined); + assert.equal(settingsIntentSection({ kind: 'section', section: 'general' }), 'general'); + assert.equal(settingsIntentSection({ kind: 'project', profileId: 'p' }), 'projects'); + assert.equal(settingsIntentSection({ kind: 'provider-catalog' }), 'models'); + assert.equal(settingsIntentSection({ kind: 'connection-detail', slug: 's' }), 'models'); + assert.equal( + settingsIntentSection({ kind: 'provider-create', providerType: 'openai' }), + 'models', + ); + }); + + test('closing drops the open flag and the catalog, keeps the rest for the next open', () => { + const detail = openSettingsSurface(remembered, { kind: 'connection-detail', slug: 'acme' }); + const closed = closeSettingsSurface({ ...detail, providerCatalogOpen: true }); + assert.equal(closed.open, false); + assert.equal(closed.providerCatalogOpen, false); + assert.equal(closed.connectionDetailSlug, 'acme'); + assert.deepEqual(closed.request, detail.request); + assert.equal(closeSettingsSurface(CLOSED_SETTINGS_SURFACE), CLOSED_SETTINGS_SURFACE); + }); + + test('a profile change is a no-op when the profile is already set', () => { + assert.equal(withSettingsProfileId(remembered, 'profile-1'), remembered); + const next = withSettingsProfileId(remembered, 'profile-2'); + assert.deepEqual(next.request, { section: 'general', profileId: 'profile-2' }); + assert.equal(withSettingsProfileId(next, undefined).request.profileId, undefined); + }); +}); + +describe('Search scroll target model', () => { + const target = { sessionId: 's1', turnId: 't1', nonce: 7 }; + + test('consumes the matching nonce once', () => { + const handled = consumeSearchScrollTarget(target, 7); + assert.deepEqual(handled, { ...target, handled: true }); + assert.equal(consumeSearchScrollTarget(handled, 7), handled); + }); + + test('ignores a stale nonce and an absent target', () => { + assert.equal(consumeSearchScrollTarget(target, 6), target); + assert.equal(consumeSearchScrollTarget(null, 7), null); + }); +}); diff --git a/apps/desktop/src/main/__tests__/overlays-provider-scope.test.ts b/apps/desktop/src/main/__tests__/overlays-provider-scope.test.ts new file mode 100644 index 0000000000..6b547b1659 --- /dev/null +++ b/apps/desktop/src/main/__tests__/overlays-provider-scope.test.ts @@ -0,0 +1,201 @@ +/* + * 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, describe, test } from 'node:test'; +import { act, createElement } from 'react'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { + createFakeOverlaysServices, + OverlaysConsumer, + OverlaysRoot, + OverlaysServicesProvider, + type OverlaysShellProjection, +} from '../../renderer/features/overlays/testing.js'; + +let frameRenders = 0; +let layerRenders = 0; +let latest: OverlaysShellProjection | undefined; +let latestLayer: OverlaysShellProjection | undefined; + +function ShellFrame(props: { readonly overlays: OverlaysShellProjection }) { + frameRenders += 1; + latest = props.overlays; + return null; +} + +function OverlayLayerProbe() { + return createElement(OverlaysConsumer, { + children: (overlays) => { + layerRenders += 1; + latestLayer = overlays; + return null; + }, + }); +} + +function renderRoot( + root: ReturnType['root'], + services = createFakeOverlaysServices(), +) { + root.render( + createElement( + OverlaysServicesProvider, + { services }, + createElement(OverlaysRoot, { + children: (overlays) => + createElement( + 'div', + null, + createElement(ShellFrame, { overlays }), + createElement(OverlayLayerProbe), + ), + }), + ), + ); +} + +afterEach(() => { + frameRenders = 0; + layerRenders = 0; + latest = undefined; + latestLayer = undefined; + cleanupFakeDom(); +}); + +describe('OverlaysRoot', () => { + test('hands the shell frame closed overlays and stable commands', async () => { + const { root } = installReactRenderer(); + await act(async () => renderRoot(root)); + assert.ok(latest); + assert.equal(latest.selectors.anyModalOpen, false); + assert.equal(latest.selectors.settings.open, false); + assert.equal(latest.selectors.searchScrollTarget, null); + assert.equal(latestLayer, latest); + const commands = latest.commands; + + await act(async () => commands.openHelp()); + assert.equal(latest?.selectors.helpOpen, true); + assert.equal(latest?.selectors.anyModalOpen, true); + assert.equal(latest?.commands, commands); + assert.equal(frameRenders, 2); + assert.equal(layerRenders, 2); + + await act(async () => commands.closeHelp()); + assert.equal(latest?.selectors.anyModalOpen, false); + await act(async () => commands.openPalette()); + assert.equal(latest?.selectors.paletteOpen, true); + await act(async () => commands.closePalette()); + await act(async () => commands.openSearch()); + assert.equal(latest?.selectors.searchOpen, true); + assert.equal(latest?.selectors.anyModalOpen, true); + await act(async () => commands.closeSearch()); + assert.equal(latest?.selectors.anyModalOpen, false); + assert.equal(latest?.commands, commands); + await act(async () => root.unmount()); + }); + + test('opens Settings through the persisted section and settles focus only when closed', async () => { + const { root } = installReactRenderer(); + const persisted: string[] = []; + let blurred = 0; + const services = createFakeOverlaysServices({ + settingsSection: { persist: (section) => { persisted.push(section); } }, + focus: { blurActiveElement: () => { blurred += 1; } }, + }); + await act(async () => renderRoot(root, services)); + const commands = latest!.commands; + + await act(async () => commands.openSettingsSection('models')); + assert.equal(latest?.selectors.settings.open, true); + assert.deepEqual(latest?.selectors.settings.request, { section: 'models' }); + assert.deepEqual(persisted, ['models']); + assert.equal(blurred, 1); + + await act(async () => commands.openProviderCatalog()); + assert.equal(latest?.selectors.settings.providerCatalogOpen, true); + assert.deepEqual(persisted, ['models', 'models']); + assert.equal(blurred, 1, 'an already open Settings does not move focus again'); + + await act(async () => commands.closeSettings()); + assert.equal(latest?.selectors.settings.open, false); + assert.equal(latest?.selectors.settings.providerCatalogOpen, false); + + await act(async () => commands.openProjectSettings('profile-2')); + assert.deepEqual(latest?.selectors.settings.request, { + section: 'projects', + profileId: 'profile-2', + }); + assert.equal(blurred, 2); + + await act(async () => commands.setSettingsProfileId('profile-2')); + assert.equal(frameRenders, 5, 'an unchanged profile re-renders nothing'); + await act(async () => commands.setSettingsProfileId(undefined)); + assert.equal(latest?.selectors.settings.request.profileId, undefined); + await act(async () => root.unmount()); + }); + + test('records the search scroll target and marks it handled once', async () => { + const { root } = installReactRenderer(); + await act(async () => renderRoot(root)); + const commands = latest!.commands; + + await act(async () => + commands.setSearchScrollTarget({ sessionId: 's1', turnId: 't1', nonce: 3 })); + assert.deepEqual(latest?.selectors.searchScrollTarget, { + sessionId: 's1', + turnId: 't1', + nonce: 3, + }); + const rendersBeforeStaleNonce = frameRenders; + await act(async () => commands.consumeSearchScrollTarget(2)); + assert.equal(frameRenders, rendersBeforeStaleNonce); + await act(async () => commands.consumeSearchScrollTarget(3)); + assert.equal(latest?.selectors.searchScrollTarget?.handled, true); + await act(async () => root.unmount()); + }); + + test('routes the thread search to the mounted service', async () => { + const { root } = installReactRenderer(); + const queries: string[] = []; + const services = createFakeOverlaysServices({ + search: { + thread: async (request) => { + queries.push(request.query); + return []; + }, + }, + }); + await act(async () => renderRoot(root, services)); + await latest!.commands.searchThread({ query: 'deploy' } as Parameters< + OverlaysShellProjection['commands']['searchThread'] + >[0]); + assert.deepEqual(queries, ['deploy']); + await act(async () => root.unmount()); + }); + + test('throws for an overlay reader mounted outside OverlaysRoot', () => { + const { root } = installReactRenderer(); + assert.throws( + () => act(() => root.render(createElement(OverlayLayerProbe))), + { message: 'OverlaysRoot is missing' }, + ); + assert.equal(layerRenders, 0); + }); +}); diff --git a/apps/desktop/src/main/__tests__/overlays-services-adapter.test.ts b/apps/desktop/src/main/__tests__/overlays-services-adapter.test.ts new file mode 100644 index 0000000000..f7b08c562d --- /dev/null +++ b/apps/desktop/src/main/__tests__/overlays-services-adapter.test.ts @@ -0,0 +1,84 @@ +/* + * 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 { + createDesktopOverlaysServices, + SETTINGS_SECTION_STORAGE_KEY, + type DesktopOverlaysBridge, + type DesktopOverlaysEnvironment, +} from '../../renderer/platform/desktop/create-overlays-services.js'; + +function recordingEnvironment() { + const writes: Array<[string, string]> = []; + let blurred = 0; + const environment: DesktopOverlaysEnvironment & { activeElement: unknown } = { + activeElement: { blur: () => { blurred += 1; } }, + storage: { + setItem(key: string, value: string) { + writes.push([key, value]); + }, + }, + get document() { + return { activeElement: this.activeElement as Element | null }; + }, + }; + return { environment, writes, blurred: () => blurred }; +} + +test('the Desktop adapter hands the search namespace through and owns the browser edges', async () => { + const calls: string[] = []; + const search = { + thread: async (request: { query: string }) => { + calls.push(`thread:${request.query}`); + return []; + }, + }; + const bridge = { search } as unknown as DesktopOverlaysBridge; + const { environment, writes, blurred } = recordingEnvironment(); + + const services = createDesktopOverlaysServices(bridge, environment); + + assert.equal(services.search, bridge.search); + await services.search.thread({ query: 'plan' } as Parameters[0]); + services.settingsSection.persist('models'); + services.focus.blurActiveElement(); + + assert.deepEqual(calls, ['thread:plan']); + assert.deepEqual(writes, [[SETTINGS_SECTION_STORAGE_KEY, 'models']]); + assert.equal(SETTINGS_SECTION_STORAGE_KEY, 'maka-settings-section-v1'); + assert.equal(blurred(), 1); + assert.deepEqual(Object.keys(services).sort(), ['focus', 'search', 'settingsSection']); +}); + +test('the adapter tolerates an unavailable store and a missing active element', () => { + const bridge = { search: { thread: async () => [] } } as unknown as DesktopOverlaysBridge; + const services = createDesktopOverlaysServices(bridge, { + storage: { + setItem() { + throw new Error('storage disabled'); + }, + }, + document: { activeElement: null }, + }); + + assert.doesNotThrow(() => services.settingsSection.persist('general')); + assert.doesNotThrow(() => services.focus.blurActiveElement()); +}); diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index 6e91cca05c..322b42c777 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -34,7 +34,7 @@ import { buildCommandList, buildSessionCommands, } from "./command-palette-commands.js"; -import type { Command } from "./command-palette-types.js"; +import type { Command } from './features/overlays/index.js'; import { renderConversationMarkdown } from "./conversation-markdown.js"; import { commandPaletteActionErrorMessage, diff --git a/apps/desktop/src/renderer/app-shell-e2e-fixture.ts b/apps/desktop/src/renderer/app-shell-e2e-fixture.ts index af7eabc496..9e91d1d199 100644 --- a/apps/desktop/src/renderer/app-shell-e2e-fixture.ts +++ b/apps/desktop/src/renderer/app-shell-e2e-fixture.ts @@ -33,7 +33,7 @@ export function createAppShellE2eFixtureActions(options: { refreshSessions: () => Promise; setActiveId: (sessionId: string | undefined) => void; setNavSelection: Dispatch>; - setSearchModalOpen: Dispatch>; + openSearchModal(): void; setSessionListCollapsed(collapsed: boolean): void; workbar: { rightCollapsed: boolean; @@ -51,7 +51,7 @@ export function createAppShellE2eFixtureActions(options: { refreshSessions, setActiveId, setNavSelection, - setSearchModalOpen, + openSearchModal, setSessionListCollapsed, workbar, setThemePref, @@ -141,7 +141,7 @@ export function createAppShellE2eFixtureActions(options: { // shell is on screen deterministically. Real users never reach this branch // (e2eFixture.getState returns null without MAKA_E2E_FIXTURE). if (state.searchModalOpen) { - setSearchModalOpen(true); + openSearchModal(); } if (state.sidebarSection === 'automations') { setNavSelection({ section: 'automations', module: 'scheduled-tasks' }); diff --git a/apps/desktop/src/renderer/app-shell-overlays.tsx b/apps/desktop/src/renderer/app-shell-overlays.tsx index d675b618e1..3bc12c1d14 100644 --- a/apps/desktop/src/renderer/app-shell-overlays.tsx +++ b/apps/desktop/src/renderer/app-shell-overlays.tsx @@ -18,15 +18,14 @@ */ import { lazy, Suspense, useLayoutEffect, useRef } from 'react'; -import type { ChatDefaultPermissionMode, SettingsSection, ThemePalette, ThemePreference } from '@maka/core/settings'; -import type { ProviderType } from '@maka/core/llm-connections'; +import type { ThemePalette, ThemePreference } from '@maka/core/settings'; import type { DesktopSessionSummary } from '../preload/bridge-contract.js'; import type { UiLocalePreference } from '@maka/core/ui-locale'; import { Spinner } from '@astryxdesign/core/Spinner'; import { useHotkeys } from '@astryxdesign/core/hooks'; -import { SearchModal, useUiLocale } from '@maka/ui'; -import { KeyboardHelpModal } from './keyboard-help'; -import { CommandPalette } from './command-palette'; +import { useUiLocale } from '@maka/ui'; +import * as Overlays from './features/overlays/index.js'; +import type { OverlaysShellProjection } from './features/overlays/index.js'; import { useAppShellCommands, type AppShellCommandListOptions } from './app-shell-command-actions'; import type { ArchivedTasksBridge } from './settings/tasks-settings-page'; import type { UiLocaleUpdateGate } from './settings/ui-locale-update-gate'; @@ -42,8 +41,6 @@ const SettingsModal = lazy(async () => { return import('./settings/settings-modal'); }); -type SearchModalProps = Parameters[0]; - function SettingsModalFallback() { const copy = getShellRemainingCopy(useUiLocale()).overlays; return ( @@ -61,8 +58,14 @@ function SettingsModalFallback() { ); } -export function AppShellOverlays(props: { - settingsOpen: boolean; +/** + * What the overlay layer still needs from the shell: the Settings modal's + * inputs and the actions that leave an overlay for a shell surface. Which + * overlay is showing, and what Settings was asked to show, come from the + * overlays feature. + */ +export interface AppShellOverlaysProps { + /** The shell's close, which also re-reads what Settings may have changed. */ closeSettings(): void; themePref: ThemePreference; setThemePref(themePref: ThemePreference): void; @@ -78,57 +81,38 @@ export function AppShellOverlays(props: { * can disagree the moment anything else writes the setting. */ refreshChatDefaults(): void; - settingsRequest: { readonly section?: SettingsSection; readonly profileId?: string }; - settingsProviderCatalogOpen: boolean; - settingsConnectionDetailSlug: string | undefined; - settingsCreateProviderType: ProviderType | undefined; onOpenDailyReview(): void; - onOpenKeyboardHelp(): void; onOpenSettingsSession(sessionId: string): void; archivedTasks: ArchivedTasksBridge; - helpOpen: boolean; - closeHelp(): void; - searchModalOpen: boolean; - closeSearchModal(): void; - searchModalDeps: SearchModalProps['deps']; - searchModalOnNavigate: NonNullable; - paletteOpen: boolean; - closePalette(): void; commandOptions: AppShellCommandListOptions; onExternalSessionImported(session: DesktopSessionSummary): void; onRemoteHostAdded(profileId: string): void; onSelectedRuntimeHostProfileIdChange(profileId: string | undefined): void; -}) { - const { - closeHelp, - closePalette, - closeSearchModal, - closeSettings, - commandOptions, - helpOpen, - paletteOpen, - searchModalDeps, - searchModalOnNavigate, - searchModalOpen, - settingsOpen, - settingsRequest, - settingsProviderCatalogOpen, - settingsConnectionDetailSlug, - settingsCreateProviderType, - setThemePalette, - setThemePref, - setUiLocalePreference, - uiLocaleUpdateGate, - setUserLabel, - refreshChatDefaults, - themePalette, - themePref, - onExternalSessionImported, - } = props; + /** + * Opens a Session from a Search result. The shell hands over its stable + * opener: the modal lists this callback in an effect's dependencies. + */ + onNavigateToSession(sessionId: string, turnId?: string, sequence?: number): void; +} - const closeSettingsRef = useRef(closeSettings); +export function AppShellOverlays(props: AppShellOverlaysProps) { + return ( + + {(overlays) => } + + ); +} + +function OverlayLayer({ + overlays, + ...props +}: AppShellOverlaysProps & { readonly overlays: OverlaysShellProjection }) { + const { commands, selectors } = overlays; + const { settings } = selectors; + + const closeSettingsRef = useRef(props.closeSettings); useLayoutEffect(() => { - closeSettingsRef.current = closeSettings; + closeSettingsRef.current = props.closeSettings; }); // The overlay boundary, rather than the lazy Settings chunk, owns Escape. @@ -136,7 +120,7 @@ export function AppShellOverlays(props: { // surface and the resolved Settings surface. Keep the listener stable while // Settings is open, but read the latest shell callback after every commit. useLayoutEffect(() => { - if (!settingsOpen) return; + if (!settings.open) return; function onKeyDown(event: globalThis.KeyboardEvent) { if ( @@ -154,67 +138,51 @@ export function AppShellOverlays(props: { window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [settingsOpen]); + }, [settings.open]); // #1045: base commands freeze per open/close; session rows stay live on // visibleSessions/activeId. run() closures read latest options via ref. - const commands = useAppShellCommands(paletteOpen, commandOptions); + const paletteCommands = useAppShellCommands(selectors.paletteOpen, props.commandOptions); useHotkeys([ { keys: 'mod+shift+d', allowInInputs: true, - onPress: () => void commands.find((command) => command.id === 'diag:copy-diagnostics')?.run(), + onPress: () => + void paletteCommands.find((command) => command.id === 'diag:copy-diagnostics')?.run(), }, ]); + return ( <> - {settingsOpen && ( + {settings.open && ( }> refreshChatDefaults()} - request={settingsRequest} - openProviderCatalog={settingsProviderCatalogOpen} - initialConnectionSlug={settingsConnectionDetailSlug} - initialCreateProviderType={settingsCreateProviderType} + onClose={props.closeSettings} + themePref={props.themePref} + onThemeChange={props.setThemePref} + themePalette={props.themePalette} + onThemePaletteChange={props.setThemePalette} + onUiLocalePreferenceChange={props.setUiLocalePreference} + uiLocaleUpdateGate={props.uiLocaleUpdateGate} + onUserLabelChange={props.setUserLabel} + onDefaultPermissionModeChange={() => props.refreshChatDefaults()} + request={settings.request} + openProviderCatalog={settings.providerCatalogOpen} + initialConnectionSlug={settings.connectionDetailSlug} + initialCreateProviderType={settings.createProviderType} onOpenDailyReview={props.onOpenDailyReview} - onOpenKeyboardHelp={props.onOpenKeyboardHelp} + onOpenKeyboardHelp={commands.openHelp} onOpenSession={props.onOpenSettingsSession} archivedTasks={props.archivedTasks} - onTaskImported={onExternalSessionImported} + onTaskImported={props.onExternalSessionImported} onRemoteHostAdded={props.onRemoteHostAdded} onSelectedRuntimeHostProfileIdChange={props.onSelectedRuntimeHostProfileIdChange} /> )} - { - if (!open) closeHelp(); - }} - /> - { - if (!open) closeSearchModal(); - }} - deps={searchModalDeps} - onNavigateToSession={searchModalOnNavigate} - /> - { - if (!open) closePalette(); - }} - commands={commands} - /> + + + ); } diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 135de12c0f..99cb26467c 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -67,8 +67,6 @@ import { } from '@maka/ui'; import type { ConnectionEvent } from '@maka/core/connections'; import { Button } from '@astryxdesign/core/Button'; -import { useKeyboardHelp } from './keyboard-help'; -import { useCommandPalette } from './command-palette'; import { ChatMessageSurface } from './chat-message-surface'; import { useTaskSubmissionReadiness } from './use-task-submission-readiness'; import * as Conversation from './features/conversation'; @@ -96,6 +94,8 @@ import { } from './features/session-navigation'; import * as TaskEntry from './features/task-entry'; import type { TaskEntryShellProjection } from './features/task-entry'; +import * as Overlays from './features/overlays/index.js'; +import type { OverlaysShellProjection } from './features/overlays/index.js'; import { useNewTaskChoice } from './use-new-task-choice'; import { SessionCollaborationDialog } from './session-collaboration-dialog'; import * as SessionCollaboration from './features/session-collaboration'; @@ -134,7 +134,6 @@ import { getShellRemainingCopy } from './locales/shell-remaining-copy.js'; import { getDesktopConversationCopy } from './locales/conversation-copy'; import { ErrorBoundary } from './error-boundary'; import { useShellAppearance } from './use-shell-appearance'; -import { useShellSearch } from './use-shell-search'; import { useSessionSettingIntent } from './features/session-settings'; import { deriveStaleSessionIds } from './stale-sessions'; import { pendingSessionView } from './pending-session-view'; @@ -201,7 +200,6 @@ import { useShellChatModel } from './use-shell-chat-model'; import { useShellLiveTurn } from './use-shell-live-turn'; import { useShellResume } from './use-shell-resume'; -import { useSettingsModal } from './use-settings-modal'; import { useSystemUiLocale } from './use-system-ui-locale'; import { isSessionWorkspaceUnavailableError, @@ -268,9 +266,13 @@ export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = { {(taskEntry) => ( - + + {(overlays) => ( + + )} + )} @@ -295,6 +297,7 @@ const SESSION_RAIL = ; function AppShellContent({ initialOnboardingSnapshot = null, taskEntry, + overlays, uiLocale, uiLocaleOverride, setUiLocaleOverride, @@ -302,6 +305,7 @@ function AppShellContent({ }: { initialOnboardingSnapshot?: OnboardingSnapshot | null; taskEntry: TaskEntryShellProjection; + overlays: OverlaysShellProjection; uiLocale: UiLocale; uiLocaleOverride: UiLocale | null; setUiLocaleOverride: Dispatch>; @@ -350,21 +354,21 @@ function AppShellContent({ }, []); const { - settingsOpen, - settingsRequest, - settingsProviderCatalogOpen, - settingsConnectionDetailSlug, - settingsCreateProviderType, - setSettingsOpen, - setSettingsProviderCatalogOpen, - setSettingsProfileId, + openHelp, + closePalette, + openSearch, + setSearchScrollTarget, + consumeSearchScrollTarget, openSettings, openSettingsSection, openProjectSettings, openProviderCatalog, openConnectionDetail, openProviderCreate, - } = useSettingsModal(); + setSettingsProfileId, + } = overlays.commands; + const { searchScrollTarget } = overlays.selectors; + const settingsOpen = overlays.selectors.settings.open; const onboarding = useOnboardingSnapshot(initialOnboardingSnapshot); // The owner bridge keeps commands stable while TaskEntryRoot swaps the @@ -613,8 +617,6 @@ function AppShellContent({ // recent workspace history so the home view is populated before the async // `app:info` round-trip completes on mount. const persistedComposerDefaults = loadComposerDefaults(); - const [helpOpen, closeHelp, openHelp] = useKeyboardHelp(); - const [paletteOpen, openPalette, closePalette] = useCommandPalette(); const composerRef = useRef(null); const openComposerModelPicker = useCallback(() => { composerRef.current?.openModelPicker(); @@ -940,26 +942,6 @@ function AppShellContent({ [], ); - /* PR-FE-BUG-HUNT-0 (kenji bug-hunt 2026-06-24): SearchModal + - CommandPalette callbacks used to be inline arrows in JSX, so - their identity churned on every App re-render. SearchModal's - debounce effect lists `searchThread` in its dep array; during a - turn stream `App` re-renders many times per second and the - 180ms timeout was torn down + restarted on every render, so it - never reached its `setTimeout` fire — search was effectively - dead while a stream was active. Same root cause for the palette - selection effect that resets keyboard highlight on every deps - change. Stable refs + memos keep the timers alive. */ - const { - searchModalOpen, - setSearchModalOpen, - searchScrollTarget, - setSearchScrollTarget, - consumeSearchScrollTarget, - closeSearchModal, - searchModalDeps, - searchModalOnNavigate, - } = useShellSearch({ openSessionInChatRef }); /** 技能页 使用: jump to the chat view and seed the composer with a skill * invocation. Same human-in-the-loop rule as maka://compose — we never * auto-send; the user finishes the sentence and presses Enter. @@ -980,10 +962,10 @@ function AppShellContent({ ); const openWorkHub = useCallback(() => { if (!workHubEnabledRef.current) return; - setSettingsOpen(false); + overlays.commands.closeSettings(); setNavSelection({ section: 'sessions' }); setWorkHubActive(true); - }, [setNavSelection]); + }, [overlays.commands, setNavSelection]); // Transient placeholder while the real SessionSummary loads, so the composer // does not flash a value the session never had. @@ -1350,7 +1332,7 @@ function AppShellContent({ newSessionPermissionMode: newTaskPermissionMode, }; - const hasModalOpen = helpOpen || paletteOpen || searchModalOpen || sharedSessionDialog.isOpen; + const hasModalOpen = overlays.selectors.anyModalOpen || sharedSessionDialog.isOpen; const shellObscured = hasModalOpen || settingsOpen; const contextCompactionPresentation = useMemo( () => @@ -1469,7 +1451,7 @@ function AppShellContent({ refreshSessions, setActiveId, setNavSelection, - setSearchModalOpen, + openSearchModal: openSearch, setSessionListCollapsed: sessionRailLayoutStore.setCollapsed, workbar: { rightCollapsed: selectors.rightCollapsed, @@ -2161,8 +2143,7 @@ function AppShellContent({ } function closeSettings() { - setSettingsOpen(false); - setSettingsProviderCatalogOpen(false); + overlays.commands.closeSettings(); // PR110c: re-pull onboarding snapshot when the user closes the // Settings modal — they may have just configured a default // connection or supplied a credential. Existing connections / @@ -2247,7 +2228,7 @@ function AppShellContent({ messages, newTaskProfileId: taskEntry.selectors.selectedProfileId, settingsOpen, - settingsProfileId: settingsRequest.profileId, + settingsProfileId: overlays.selectors.settings.request.profileId, sessions, themePref, visibleSessions, @@ -2382,7 +2363,7 @@ function AppShellContent({ sessionSideNavHandleRef.current?.getCollapseState()?.toggle()} - onOpenSearchModal={() => setSearchModalOpen(true)} + onOpenSearchModal={openSearch} /> {/* Only a session has an identity to state. The other views name themselves in the nav column they are selected from, and the @@ -2888,7 +2869,6 @@ function AppShellContent({ /> { void taskEntry.commands.refresh().catch(() => undefined); }} - settingsRequest={settingsRequest} - settingsProviderCatalogOpen={settingsProviderCatalogOpen} - settingsConnectionDetailSlug={settingsConnectionDetailSlug} - settingsCreateProviderType={settingsCreateProviderType} onOpenDailyReview={() => { closeSettings(); setNavSelection({ section: 'automations', module: 'daily-review' }); }} - onOpenKeyboardHelp={openHelp} onOpenSettingsSession={(sessionId) => { closeSettings(); openSessionInChat(sessionId); }} archivedTasks={archivedTasksBridge} - helpOpen={helpOpen} - closeHelp={closeHelp} - searchModalOpen={searchModalOpen} - closeSearchModal={closeSearchModal} - searchModalDeps={searchModalDeps} - searchModalOnNavigate={searchModalOnNavigate} - paletteOpen={paletteOpen} - closePalette={closePalette} commandOptions={commandOptions} + onNavigateToSession={openSessionInChat} onExternalSessionImported={(session) => { closeSettings(); openSessionInChat(session.id); diff --git a/apps/desktop/src/renderer/command-palette-commands.ts b/apps/desktop/src/renderer/command-palette-commands.ts index b9a020bec0..96ae2ca7db 100644 --- a/apps/desktop/src/renderer/command-palette-commands.ts +++ b/apps/desktop/src/renderer/command-palette-commands.ts @@ -54,7 +54,7 @@ import type { UiLocale } from '@maka/core/ui-locale'; import type { NavSelection } from '@maka/ui'; import { getShellCopy } from './locales/shell-copy.js'; import { SETTINGS_NAV } from './settings/settings-nav.js'; -import type { Command } from './command-palette-types.js'; +import type { Command } from './features/overlays/index.js'; /** * Helper composing the palette's base command list (everything except the diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index dc6a528e09..3e966da349 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -32,6 +32,7 @@ import { SessionNavigationServicesProvider } from '../features/session-navigatio import { SessionSettingsServicesProvider } from '../features/session-settings'; import { TaskEntryServicesProvider } from '../features/task-entry'; import { WorkbarServicesProvider } from '../features/workbar'; +import { OverlaysServicesProvider } from '../features/overlays/index.js'; import { createDesktopAppUpdateServices } from '../platform/desktop/create-app-update-services'; import { createDesktopGoalServices } from '../platform/desktop/create-goal-services'; import { createDesktopConnectionSettingsServices } from '../platform/desktop/create-connection-settings-services'; @@ -42,6 +43,7 @@ import { createDesktopSessionNavigationServices } from '../platform/desktop/crea import { createDesktopSessionSettingsServices } from '../platform/desktop/create-session-settings-services'; import { createDesktopTaskEntryServices } from '../platform/desktop/create-task-entry-services'; import { createDesktopWorkbarServices } from '../platform/desktop/create-workbar-services'; +import { createDesktopOverlaysServices } from '../platform/desktop/create-overlays-services'; export function createDesktopFeatureServices() { return { @@ -51,6 +53,7 @@ export function createDesktopFeatureServices() { connectionSettings: createDesktopConnectionSettingsServices(), goal: createDesktopGoalServices(), moduleHub: createDesktopModuleHubServices(), + overlays: createDesktopOverlaysServices(), runtimeHostManagement: createDesktopRuntimeHostManagementServices(), sessionCollaboration: createDesktopSessionCollaborationServices(), sessionNavigation: createDesktopSessionNavigationServices(), @@ -76,7 +79,11 @@ export function DesktopFeatureServicesProvider(props: { - {props.children} + + + {props.children} + + diff --git a/apps/desktop/src/renderer/features/overlays/README.md b/apps/desktop/src/renderer/features/overlays/README.md new file mode 100644 index 0000000000..a71c9875f3 --- /dev/null +++ b/apps/desktop/src/renderer/features/overlays/README.md @@ -0,0 +1,49 @@ + + +# Overlays feature + +This slice owns the shell's overlay surfaces: the keyboard help, the Command +Palette, the Search modal with its transcript scroll target, and the Settings +modal with what it was asked to show. The global shortcuts that open them live +here too. + +## Ownership + +- `OverlaysRoot` is the only production owner of `useOverlaysController`. It + hands the shell frame the overlays through a render prop, so `AppShell` + calls no overlay hook. +- `platform/desktop/create-overlays-services.ts` is the only adapter from the + Desktop bridge and browser environment into this feature (thread search, + the remembered Settings section, settling focus before Settings opens), and + Desktop feature-services composition is its only production importer. +- The keyboard help, the Command Palette rows, and the Search modal render + from this slice and read the controller directly. The Settings modal stays + with the legacy settings code; `app-shell-overlays.tsx` reads what to show + through `OverlaysConsumer` and mounts it. +- The palette's command list stays a shell concern: the shell builds the rows + from its own actions and passes them to `CommandPalette`. + +## Model + +`model/settings-surface.ts` is the Settings surface as data: every opener is +an intent, `openSettingsSurface` applies it, and the section an intent lands +on is what the adapter persists. `model/search-scroll-target.ts` records the +turn a Search result asked the transcript to scroll to and marks it handled +once. diff --git a/apps/desktop/src/renderer/features/overlays/controller/use-overlays-controller.ts b/apps/desktop/src/renderer/features/overlays/controller/use-overlays-controller.ts new file mode 100644 index 0000000000..fb6a5780f8 --- /dev/null +++ b/apps/desktop/src/renderer/features/overlays/controller/use-overlays-controller.ts @@ -0,0 +1,124 @@ +/* + * 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, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useHotkeys } from '@astryxdesign/core/hooks'; +import type { + OverlaysCommands, + OverlaysSelectors, + OverlaysShellProjection, +} from '../model/overlays-projection.js'; +import { + consumeSearchScrollTarget, + type SearchScrollTarget, +} from '../model/search-scroll-target.js'; +import { + CLOSED_SETTINGS_SURFACE, + closeSettingsSurface, + openSettingsSurface, + settingsIntentSection, + withSettingsProfileId, + type SettingsSurfaceIntent, +} from '../model/settings-surface.js'; +import { useOverlaysServices } from '../services-context.js'; + +export type OverlaysController = OverlaysShellProjection; + +/** + * Owns the shell's overlay surfaces: the keyboard help, the Command Palette, + * the Search modal and its scroll target, and the Settings modal with what it + * was asked to show. The global shortcuts live here too, so opening any of + * them re-renders this owner and whatever reads it, never the shell's inputs. + * + * Bare `?` keeps typing itself into inputs: `useHotkeys` skips typing surfaces + * by default, and only the modified combos opt back in. `mod+k` opts in + * because the palette's point is being reachable mid-sentence. + */ +export function useOverlaysController(): OverlaysController { + const services = useOverlaysServices(); + const [helpOpen, setHelpOpen] = useState(false); + const [paletteOpen, setPaletteOpen] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); + const [searchScrollTarget, setSearchScrollTarget] = useState(null); + const [settings, setSettings] = useState(CLOSED_SETTINGS_SURFACE); + + useHotkeys([ + { keys: 'mod+/', allowInInputs: true, onPress: () => setHelpOpen((previous) => !previous) }, + { keys: 'mod+?', allowInInputs: true, onPress: () => setHelpOpen((previous) => !previous) }, + { keys: '?', onPress: () => setHelpOpen(true) }, + { keys: 'mod+k', allowInInputs: true, onPress: () => setPaletteOpen((previous) => !previous) }, + ]); + + // The openers are stable for the palette's memoized command pipeline; they + // read whether Settings is already showing through the latest committed + // value, since only a closed-to-open transition may move focus. + const settingsOpenRef = useRef(settings.open); + useLayoutEffect(() => { + settingsOpenRef.current = settings.open; + }); + const openSettingsWith = useCallback( + (intent: SettingsSurfaceIntent) => { + const section = settingsIntentSection(intent); + if (section) services.settingsSection.persist(section); + if (!settingsOpenRef.current) services.focus.blurActiveElement(); + setSettings((current) => openSettingsSurface(current, intent)); + }, + [services], + ); + + const commands = useMemo( + () => ({ + openHelp: () => setHelpOpen(true), + closeHelp: () => setHelpOpen(false), + openPalette: () => setPaletteOpen(true), + closePalette: () => setPaletteOpen(false), + openSearch: () => setSearchOpen(true), + closeSearch: () => setSearchOpen(false), + searchThread: (request) => services.search.thread(request), + setSearchScrollTarget, + consumeSearchScrollTarget: (nonce) => + setSearchScrollTarget((current) => consumeSearchScrollTarget(current, nonce)), + openSettings: () => openSettingsWith({ kind: 'settings' }), + openSettingsSection: (section) => openSettingsWith({ kind: 'section', section }), + openProjectSettings: (profileId) => openSettingsWith({ kind: 'project', profileId }), + openProviderCatalog: () => openSettingsWith({ kind: 'provider-catalog' }), + openConnectionDetail: (slug) => openSettingsWith({ kind: 'connection-detail', slug }), + openProviderCreate: (providerType) => + openSettingsWith({ kind: 'provider-create', providerType }), + setSettingsProfileId: (profileId) => + setSettings((current) => withSettingsProfileId(current, profileId)), + closeSettings: () => setSettings(closeSettingsSurface), + }), + [openSettingsWith, services], + ); + + const selectors = useMemo( + () => ({ + helpOpen, + paletteOpen, + searchOpen, + anyModalOpen: helpOpen || paletteOpen || searchOpen, + settings, + searchScrollTarget, + }), + [helpOpen, paletteOpen, searchOpen, settings, searchScrollTarget], + ); + + return useMemo(() => ({ commands, selectors }), [commands, selectors]); +} diff --git a/apps/desktop/src/renderer/features/overlays/index.ts b/apps/desktop/src/renderer/features/overlays/index.ts new file mode 100644 index 0000000000..d0ca3716e7 --- /dev/null +++ b/apps/desktop/src/renderer/features/overlays/index.ts @@ -0,0 +1,28 @@ +/* + * 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 { OverlaysServicesProvider } from './services-context.js'; +export { OverlaysRoot } from './ui/overlays-root.js'; +export { OverlaysConsumer } from './ui/overlays-context.js'; +export { KeyboardHelpModal } from './ui/keyboard-help-modal.js'; +export { CommandPalette } from './ui/command-palette.js'; +export { SearchModalHost } from './ui/search-modal-host.js'; +export type { OverlaysServices } from './ports.js'; +export type { Command } from './model/command.js'; +export type { OverlaysShellProjection } from './model/overlays-projection.js'; diff --git a/apps/desktop/src/renderer/command-palette-types.ts b/apps/desktop/src/renderer/features/overlays/model/command.ts similarity index 86% rename from apps/desktop/src/renderer/command-palette-types.ts rename to apps/desktop/src/renderer/features/overlays/model/command.ts index 53bc0e13a5..b47682fbdb 100644 --- a/apps/desktop/src/renderer/command-palette-types.ts +++ b/apps/desktop/src/renderer/features/overlays/model/command.ts @@ -18,9 +18,9 @@ */ /** - * Shared types for the Command Palette. Pulled out of - * `command-palette.tsx` so non-JSX consumers can import them under the - * main-process tsconfig that does not compile JSX. + * The Command Palette's row contract. The palette renders rows; the shell and + * its command builders decide what the rows do, so the type is the only thing + * the two share. */ import type { LucideIcon } from '@maka/ui/icons'; diff --git a/apps/desktop/src/renderer/features/overlays/model/overlays-projection.ts b/apps/desktop/src/renderer/features/overlays/model/overlays-projection.ts new file mode 100644 index 0000000000..ad58e3bc54 --- /dev/null +++ b/apps/desktop/src/renderer/features/overlays/model/overlays-projection.ts @@ -0,0 +1,61 @@ +/* + * 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 { ProviderType } from '@maka/core/llm-connections'; +import type { SettingsSection } from '@maka/core/settings'; +import type { OverlaySearchThread } from '../ports.js'; +import type { SearchScrollTarget } from './search-scroll-target.js'; +import type { SettingsSurface } from './settings-surface.js'; + +/** What the shell and the overlay layer may ask the overlays to do. */ +export interface OverlaysCommands { + openHelp(): void; + closeHelp(): void; + openPalette(): void; + closePalette(): void; + openSearch(): void; + closeSearch(): void; + searchThread: OverlaySearchThread; + setSearchScrollTarget(target: SearchScrollTarget | null): void; + consumeSearchScrollTarget(nonce: number): void; + openSettings(): void; + openSettingsSection(section: SettingsSection): void; + openProjectSettings(profileId: string): void; + openProviderCatalog(): void; + openConnectionDetail(slug: string): void; + openProviderCreate(providerType: ProviderType): void; + setSettingsProfileId(profileId: string | undefined): void; + closeSettings(): void; +} + +/** What is showing. */ +export interface OverlaysSelectors { + readonly helpOpen: boolean; + readonly paletteOpen: boolean; + readonly searchOpen: boolean; + /** Help, palette, or search: the modals that make the shell inert. */ + readonly anyModalOpen: boolean; + readonly settings: SettingsSurface; + readonly searchScrollTarget: SearchScrollTarget | null; +} + +export interface OverlaysShellProjection { + readonly commands: OverlaysCommands; + readonly selectors: OverlaysSelectors; +} diff --git a/apps/desktop/src/renderer/features/overlays/model/search-scroll-target.ts b/apps/desktop/src/renderer/features/overlays/model/search-scroll-target.ts new file mode 100644 index 0000000000..ef10a6eb35 --- /dev/null +++ b/apps/desktop/src/renderer/features/overlays/model/search-scroll-target.ts @@ -0,0 +1,38 @@ +/* + * 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. + */ + +/** + * The turn a Search result asked the transcript to scroll to. The nonce tells + * two picks of the same turn apart; `handled` records that the transcript + * already scrolled, so a re-render does not scroll again. + */ +export interface SearchScrollTarget { + readonly sessionId: string; + readonly turnId: string; + readonly sequence?: number; + readonly nonce: number; + readonly handled?: boolean; +} + +export function consumeSearchScrollTarget( + current: SearchScrollTarget | null, + nonce: number, +): SearchScrollTarget | null { + return current?.nonce === nonce && !current.handled ? { ...current, handled: true } : current; +} diff --git a/apps/desktop/src/renderer/features/overlays/model/settings-surface.ts b/apps/desktop/src/renderer/features/overlays/model/settings-surface.ts new file mode 100644 index 0000000000..96139658b3 --- /dev/null +++ b/apps/desktop/src/renderer/features/overlays/model/settings-surface.ts @@ -0,0 +1,107 @@ +/* + * 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 { ProviderType } from '@maka/core/llm-connections'; +import type { SettingsSection } from '@maka/core/settings'; + +export interface SettingsNavigationRequest { + readonly section?: SettingsSection; + readonly profileId?: string; +} + +/** What the Settings modal is asked to show, and whether it is showing. */ +export interface SettingsSurface { + readonly open: boolean; + readonly request: SettingsNavigationRequest; + readonly providerCatalogOpen: boolean; + readonly connectionDetailSlug: string | undefined; + readonly createProviderType: ProviderType | undefined; +} + +/** Every way the shell opens Settings, as data. */ +export type SettingsSurfaceIntent = + | { readonly kind: 'settings' } + | { readonly kind: 'section'; readonly section: SettingsSection } + | { readonly kind: 'project'; readonly profileId: string } + | { readonly kind: 'provider-catalog' } + | { readonly kind: 'connection-detail'; readonly slug: string } + | { readonly kind: 'provider-create'; readonly providerType: ProviderType }; + +export const CLOSED_SETTINGS_SURFACE: SettingsSurface = { + open: false, + request: {}, + providerCatalogOpen: false, + connectionDetailSlug: undefined, + createProviderType: undefined, +}; + +/** The section an intent lands on; `undefined` keeps the remembered one. */ +export function settingsIntentSection(intent: SettingsSurfaceIntent): SettingsSection | undefined { + switch (intent.kind) { + case 'section': + return intent.section; + case 'project': + return 'projects'; + case 'provider-catalog': + case 'connection-detail': + case 'provider-create': + return 'models'; + default: + return undefined; + } +} + +/** + * Opens Settings on an intent. Every opener resets the three sub-surfaces and + * then raises its own; a project intent replaces the whole request so a stale + * profile from an earlier open cannot leak into the Projects page. + */ +export function openSettingsSurface( + current: SettingsSurface, + intent: SettingsSurfaceIntent, +): SettingsSurface { + const section = settingsIntentSection(intent); + const request: SettingsNavigationRequest = + intent.kind === 'project' + ? { section: 'projects', profileId: intent.profileId } + : section + ? { ...current.request, section } + : current.request; + return { + open: true, + request, + providerCatalogOpen: intent.kind === 'provider-catalog', + connectionDetailSlug: intent.kind === 'connection-detail' ? intent.slug : undefined, + createProviderType: intent.kind === 'provider-create' ? intent.providerType : undefined, + }; +} + +/** Closes Settings and its catalog; the other sub-surfaces reset on the next open. */ +export function closeSettingsSurface(current: SettingsSurface): SettingsSurface { + if (!current.open && !current.providerCatalogOpen) return current; + return { ...current, open: false, providerCatalogOpen: false }; +} + +export function withSettingsProfileId( + current: SettingsSurface, + profileId: string | undefined, +): SettingsSurface { + if (current.request.profileId === profileId) return current; + return { ...current, request: { ...current.request, profileId } }; +} diff --git a/apps/desktop/src/renderer/features/overlays/ports.ts b/apps/desktop/src/renderer/features/overlays/ports.ts new file mode 100644 index 0000000000..379b512b2d --- /dev/null +++ b/apps/desktop/src/renderer/features/overlays/ports.ts @@ -0,0 +1,50 @@ +/* + * 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 { SettingsSection } from '@maka/core/settings'; +import type { SearchModal } from '@maka/ui'; + +/** The thread search the Search modal runs; the type is the modal's own. */ +export type OverlaySearchThread = NonNullable< + Parameters[0]['deps'] +>['searchThread']; + +/** The minimum environment capabilities the overlays need. */ +export interface OverlaySearchService { + thread: OverlaySearchThread; +} + +export interface OverlaySettingsSectionStore { + /** Remembers the Settings section an opener landed on, for the next open. */ + persist(section: SettingsSection): void; +} + +export interface OverlayFocusService { + /** + * Settles blur-owned edits before Settings obscures the shell: macOS menu + * commands open Settings without moving DOM focus first. + */ + blurActiveElement(): void; +} + +export interface OverlaysServices { + search: OverlaySearchService; + settingsSection: OverlaySettingsSectionStore; + focus: OverlayFocusService; +} diff --git a/apps/desktop/src/renderer/features/overlays/services-context.tsx b/apps/desktop/src/renderer/features/overlays/services-context.tsx new file mode 100644 index 0000000000..8f2d557d2d --- /dev/null +++ b/apps/desktop/src/renderer/features/overlays/services-context.tsx @@ -0,0 +1,29 @@ +/* + * 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 { createServicesContext } from '../../application/contracts/feature-services.js'; +import type { OverlaysServices } from './ports.js'; + +const { Provider, useServices } = createServicesContext('OverlaysServicesProvider'); + +export const OverlaysServicesProvider = Provider; + +export function useOverlaysServices(): OverlaysServices { + return useServices(); +} diff --git a/apps/desktop/src/renderer/features/overlays/testing.ts b/apps/desktop/src/renderer/features/overlays/testing.ts new file mode 100644 index 0000000000..f75d03dc64 --- /dev/null +++ b/apps/desktop/src/renderer/features/overlays/testing.ts @@ -0,0 +1,46 @@ +/* + * 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 { OverlaysServices } from './ports.js'; + +export { OverlaysServicesProvider } from './services-context.js'; +export { OverlaysRoot } from './ui/overlays-root.js'; +export { OverlaysConsumer } from './ui/overlays-context.js'; +export { + CLOSED_SETTINGS_SURFACE, + closeSettingsSurface, + openSettingsSurface, + settingsIntentSection, + withSettingsProfileId, + type SettingsSurface, +} from './model/settings-surface.js'; +export { consumeSearchScrollTarget } from './model/search-scroll-target.js'; +export type { OverlaysShellProjection } from './model/overlays-projection.js'; +export type { OverlaysServices } from './ports.js'; + +export function createFakeOverlaysServices( + overrides: Partial = {}, +): OverlaysServices { + return { + search: { thread: async () => [] }, + settingsSection: { persist: () => undefined }, + focus: { blurActiveElement: () => undefined }, + ...overrides, + }; +} diff --git a/apps/desktop/src/renderer/command-palette.tsx b/apps/desktop/src/renderer/features/overlays/ui/command-palette.tsx similarity index 69% rename from apps/desktop/src/renderer/command-palette.tsx rename to apps/desktop/src/renderer/features/overlays/ui/command-palette.tsx index 9a342b161a..9727b03b04 100644 --- a/apps/desktop/src/renderer/command-palette.tsx +++ b/apps/desktop/src/renderer/features/overlays/ui/command-palette.tsx @@ -17,19 +17,13 @@ * under the License. */ -// apps/desktop/src/renderer/command-palette.tsx -// -// ⌘K (Ctrl+K off macOS) command palette. Combines static actions (new chat, theme -// switch, open settings, open keyboard help) with the live session list so -// the user can fuzzy-search across both. Astryx owns the dialog, input, -// listbox, keyboard navigation, focus, and dismissal. +// ⌘K (Ctrl+K off macOS) command palette. Renders the rows the shell built +// (static actions plus the live session list) so the user can fuzzy-search +// across both. Astryx owns the dialog, input, listbox, keyboard navigation, +// focus, and dismissal; the overlays controller owns whether it is open. -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - ICON_SIZE, - ChevronRight, - CornerDownLeft, -} from '@maka/ui/icons'; +import { useEffect, useMemo, useRef } from 'react'; +import { ICON_SIZE, ChevronRight, CornerDownLeft } from '@maka/ui/icons'; import { CommandPalette as AstryxCommandPalette, CommandPaletteFooter, @@ -41,42 +35,15 @@ import { useUiLocale, } from '@maka/ui'; import { Kbd } from '@astryxdesign/core/Kbd'; -import { useHotkeys } from '@astryxdesign/core/hooks'; import { EmptyState } from '@astryxdesign/core/EmptyState'; -import type { Command, CommandKind } from './command-palette-types'; -import { getShellCopy } from './locales/shell-copy'; -export type { Command } from './command-palette-types'; - -// `Command` / `CommandKind` types live in `./command-palette-types` -// (extracted so non-JSX consumers can import them under the main -// tsconfig). Re-exported via the explicit `export { ... }` above. - -export function useCommandPalette(): [boolean, () => void, () => void] { - const [open, setOpen] = useState(false); - - // `allowInInputs` because the palette's whole point is being reachable - // mid-sentence in the composer — the hook skips typing surfaces by default, - // which would have made ⌘K dead exactly where it is used most. - useHotkeys([ - { - keys: 'mod+k', - allowInInputs: true, - onPress: () => setOpen((prev) => !prev), - }, - ]); - - // Stable open/close identities: callers feed these into memoized callback - // chains (the palette's command pipeline), so fresh closures per render - // would churn every memo downstream for no state change. - const openPalette = useCallback(() => setOpen(true), []); - const closePalette = useCallback(() => setOpen(false), []); - return [open, openPalette, closePalette]; -} +import { getShellCopy } from '../../../locales/shell-copy.js'; +import type { Command } from '../model/command.js'; +import { useOverlays } from './overlays-context.js'; function fuzzy(query: string, text: string): boolean { // Cheap subsequence match: every char of query (lowercase) must appear in // order somewhere inside text (lowercase). Good enough for a palette with -// <100 commands; we can swap in a real fuzzy matcher later. + // <100 commands; we can swap in a real fuzzy matcher later. if (!query) return true; let i = 0; const q = query.toLowerCase(); @@ -87,11 +54,9 @@ function fuzzy(query: string, text: string): boolean { return i === q.length; } -export function CommandPalette(props: { - commands: Command[]; - isOpen: boolean; - onOpenChange(isOpen: boolean): void; -}) { +export function CommandPalette(props: { readonly commands: Command[] }) { + const { commands: overlayCommands, selectors } = useOverlays(); + const isOpen = selectors.paletteOpen; const locale = useUiLocale(); const copy = getShellCopy(locale).commandPalette; const astryxOverrides = useMemo( @@ -113,14 +78,11 @@ export function CommandPalette(props: { })), [props.commands], ); - const itemById = useMemo( - () => new Map(items.map((item) => [item.id, item])), - [items], - ); + const itemById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items]); const pendingCommandRef = useRef(null); useEffect(() => { - if (props.isOpen) return; + if (isOpen) return; const command = pendingCommandRef.current; pendingCommandRef.current = null; if (!command) return; @@ -128,7 +90,7 @@ export function CommandPalette(props: { void Promise.resolve(command.run()).catch(() => undefined); }); return () => window.cancelAnimationFrame(frame); - }, [props.isOpen]); + }, [isOpen]); const searchSource = useMemo>( () => ({ bootstrap: () => items, @@ -147,9 +109,7 @@ export function CommandPalette(props: { ) { return true; } - return command.keywords?.some((keyword) => - fuzzy(normalized, keyword), - ) ?? false; + return command.keywords?.some((keyword) => fuzzy(normalized, keyword)) ?? false; }); }, }), @@ -160,25 +120,22 @@ export function CommandPalette(props: { const command = itemById.get(commandId)?.auxiliaryData?.command; if (!command || pendingCommandRef.current) return; pendingCommandRef.current = command; - props.onOpenChange(false); + overlayCommands.closePalette(); } return ( { + if (!open) overlayCommands.closePalette(); + }} searchSource={searchSource} label={copy.label} width={584} maxHeight="min(620px, 68vh)" - input={( - - )} - emptySearchText={( + input={} + emptySearchText={ /* Filter empty (DESIGN.md §10 tier 1): no clear action here — the palette input itself is the exit from a no-match search. */ - )} + } emptyBootstrapText={copy.emptyDescription} onValueChange={commit} renderItem={(item) => { @@ -217,7 +174,7 @@ export function CommandPalette(props: { ); }} - footer={( + footer={ @@ -233,7 +190,7 @@ export function CommandPalette(props: { {copy.closeHint} - )} + } /> ); diff --git a/apps/desktop/src/renderer/features/overlays/ui/keyboard-help-modal.tsx b/apps/desktop/src/renderer/features/overlays/ui/keyboard-help-modal.tsx new file mode 100644 index 0000000000..f66e838cc7 --- /dev/null +++ b/apps/desktop/src/renderer/features/overlays/ui/keyboard-help-modal.tsx @@ -0,0 +1,107 @@ +/* + * 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. + */ + +// Discoverable keyboard cheat sheet. Opened by `?` (when no input is focused) +// or `⌘/` (`Ctrl+/` off macOS) through the overlays controller. Lists every +// shortcut the renderer reacts to so users don't need to scrape the README. +// Astryx Dialog owns focus trapping, Esc, and focus restoration. + +import { ICON_SIZE, Keyboard } from '@maka/ui/icons'; +import { useUiLocale } from '@maka/ui'; +import { Heading } from '@astryxdesign/core/Heading'; +import { Kbd } from '@astryxdesign/core/Kbd'; +import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; +import { Layout, LayoutContent } from '@astryxdesign/core/Layout'; +import { getShellCopy } from '../../../locales/shell-copy.js'; +import { useOverlays } from './overlays-context.js'; + +const ASTRYX_KEY_TOKENS: Readonly> = { + '⌘': 'mod', + '↑': 'up', + '↓': 'down', + '←': 'left', + '→': 'right', + esc: 'escape', +}; + +function toAstryxKeyToken(key: string): string { + const normalized = key.toLowerCase(); + return ASTRYX_KEY_TOKENS[key] ?? ASTRYX_KEY_TOKENS[normalized] ?? normalized; +} + +export function KeyboardHelpModal() { + const { commands, selectors } = useOverlays(); + const locale = useUiLocale(); + const copy = getShellCopy(locale).keyboardHelp; + + return ( + { + if (!open) commands.closeHelp(); + }} + className="maka-help-modal" + width={560} + maxHeight="calc(100dvh - 96px)" + purpose="info" + > + + ); +} diff --git a/apps/desktop/src/renderer/features/overlays/ui/overlays-context.tsx b/apps/desktop/src/renderer/features/overlays/ui/overlays-context.tsx new file mode 100644 index 0000000000..b35e06845c --- /dev/null +++ b/apps/desktop/src/renderer/features/overlays/ui/overlays-context.tsx @@ -0,0 +1,37 @@ +/* + * 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 { OverlaysShellProjection } from '../model/overlays-projection.js'; + +export const OverlaysContext = createContext(null); + +/** Reads the overlays; a mount outside `OverlaysRoot` is a bug, not a quiet idle. */ +export function useOverlays(): OverlaysShellProjection { + const overlays = useContext(OverlaysContext); + if (!overlays) throw new Error('OverlaysRoot is missing'); + return overlays; +} + +/** The overlay layer's read of the overlays, as a component so it owns no hook of its own. */ +export function OverlaysConsumer(props: { + readonly children: (overlays: OverlaysShellProjection) => ReactNode; +}): ReactNode { + return props.children(useOverlays()); +} diff --git a/apps/desktop/src/renderer/features/overlays/ui/overlays-root.tsx b/apps/desktop/src/renderer/features/overlays/ui/overlays-root.tsx new file mode 100644 index 0000000000..9dedcf96e8 --- /dev/null +++ b/apps/desktop/src/renderer/features/overlays/ui/overlays-root.tsx @@ -0,0 +1,39 @@ +/* + * 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, type ReactNode } from 'react'; +import { useOverlaysController } from '../controller/use-overlays-controller.js'; +import type { OverlaysShellProjection } from '../model/overlays-projection.js'; +import { OverlaysContext } from './overlays-context.js'; + +export interface OverlaysRootProps { + /** + * The shell frame, built once per overlays change. The frame reads the + * overlays from the argument rather than a hook, so the shell body owns no + * overlay state and the hook gate sees none. + */ + readonly children: (overlays: OverlaysShellProjection) => ReactNode; +} + +/** The only production owner of `useOverlaysController`. */ +export function OverlaysRoot({ children }: OverlaysRootProps) { + const overlays = useOverlaysController(); + const frame = useMemo(() => children(overlays), [children, overlays]); + return {frame}; +} diff --git a/apps/desktop/src/renderer/features/overlays/ui/search-modal-host.tsx b/apps/desktop/src/renderer/features/overlays/ui/search-modal-host.tsx new file mode 100644 index 0000000000..ef691102f7 --- /dev/null +++ b/apps/desktop/src/renderer/features/overlays/ui/search-modal-host.tsx @@ -0,0 +1,49 @@ +/* + * 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 } from 'react'; +import { SearchModal } from '@maka/ui'; +import { useOverlays } from './overlays-context.js'; + +/** + * The Search modal, wired to the overlays: open state and the thread search + * come from the controller, and only the navigation into a Session stays a + * shell action. Astryx restores the opener for ordinary closes. + * + * `deps` keeps one identity for the life of the controller. The modal's + * debounce effect lists `searchThread` in its dependencies, and a fresh + * identity per render tore the timer down before it fired while a turn was + * streaming, which made search dead exactly then. + */ +export function SearchModalHost(props: { + readonly onNavigateToSession: (sessionId: string, turnId?: string, sequence?: number) => void; +}) { + const { commands, selectors } = useOverlays(); + const deps = useMemo(() => ({ searchThread: commands.searchThread }), [commands]); + return ( + { + if (!open) commands.closeSearch(); + }} + deps={deps} + onNavigateToSession={props.onNavigateToSession} + /> + ); +} diff --git a/apps/desktop/src/renderer/keyboard-help.tsx b/apps/desktop/src/renderer/keyboard-help.tsx deleted file mode 100644 index fab861cdaa..0000000000 --- a/apps/desktop/src/renderer/keyboard-help.tsx +++ /dev/null @@ -1,143 +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. - */ - -// apps/desktop/src/renderer/keyboard-help.tsx -// -// Discoverable keyboard cheat sheet. Modal triggered by `?` (when no input is -// focused) or `⌘/` (`Ctrl+/` off macOS). Lists every shortcut the renderer reacts to so -// users don't need to scrape the README. Astryx Dialog owns focus trapping, -// Esc, and focus restoration. - -import { useState } from 'react'; -import { ICON_SIZE, Keyboard } from '@maka/ui/icons'; -import { useUiLocale } from '@maka/ui'; -import { Heading } from '@astryxdesign/core/Heading'; -import { Kbd } from '@astryxdesign/core/Kbd'; -import { useHotkeys } from '@astryxdesign/core/hooks'; -import { - Dialog, - DialogHeader, -} from '@astryxdesign/core/Dialog'; -import { Layout, LayoutContent } from '@astryxdesign/core/Layout'; -import { getShellCopy } from './locales/shell-copy'; - -const ASTRYX_KEY_TOKENS: Readonly> = { - '⌘': 'mod', - '↑': 'up', - '↓': 'down', - '←': 'left', - '→': 'right', - esc: 'escape', -}; - -function toAstryxKeyToken(key: string): string { - const normalized = key.toLowerCase(); - return ASTRYX_KEY_TOKENS[key] ?? ASTRYX_KEY_TOKENS[normalized] ?? normalized; -} - -/** - * Manages the global key listener that opens and closes the help modal. - * Returned tuple gives callers the current open state and an imperative - * close function for the rendered modal. - */ -/** - * Manages the global key listener that opens and closes the help modal. - * - * PR-UX-POLISH-1 commit 4 (WAWQAQ msg `e0dbad11` + kenji msg - * `2844f64f`): the `openHelp` third tuple element added in commit - * 2 is RETAINED — the Command Palette `查看快捷键` entry uses it - * to open the modal without dispatching synthetic KeyboardEvent's. - * The sidebar chip that originally needed it is removed; the - * Command Palette is the new caller. - */ -export function useKeyboardHelp(): [boolean, () => void, () => void] { - const [open, setOpen] = useState(false); - - // The hand-written "is the user typing?" guard is gone: skipping - // input/textarea/select/contenteditable is what useHotkeys does by default, - // and it is the only reason the bare `?` entry needs no guard of its own — - // `?` must still type itself into the composer. The two modified combos opt - // back in, matching the old code, which reached them before its guard. - useHotkeys([ - { keys: 'mod+/', allowInInputs: true, onPress: () => setOpen((prev) => !prev) }, - { keys: 'mod+?', allowInInputs: true, onPress: () => setOpen((prev) => !prev) }, - { keys: '?', onPress: () => setOpen(true) }, - ]); - - return [open, () => setOpen(false), () => setOpen(true)]; -} - -export function KeyboardHelpModal(props: { - isOpen: boolean; - onOpenChange(isOpen: boolean): void; -}) { - const locale = useUiLocale(); - const copy = getShellCopy(locale).keyboardHelp; - - return ( - - - ); -} diff --git a/apps/desktop/src/renderer/platform/desktop/create-overlays-services.ts b/apps/desktop/src/renderer/platform/desktop/create-overlays-services.ts new file mode 100644 index 0000000000..0c687a95b0 --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-overlays-services.ts @@ -0,0 +1,59 @@ +/* + * 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 { OverlaysServices } from '../../features/overlays/index.js'; + +export type DesktopOverlaysBridge = Pick; + +/** The browser capabilities the overlays reach through this adapter. */ +export interface DesktopOverlaysEnvironment { + readonly storage: Pick; + readonly document: Pick; +} + +/** Read back by `settings/settings-nav.ts` when the Settings modal opens. */ +export const SETTINGS_SECTION_STORAGE_KEY = 'maka-settings-section-v1'; + +/** The only Desktop-to-overlays adapter. */ +export function createDesktopOverlaysServices( + bridge: DesktopOverlaysBridge = window.maka, + environment: DesktopOverlaysEnvironment = { storage: window.localStorage, document }, +): OverlaysServices { + return { + search: bridge.search, + settingsSection: { + persist(section) { + try { + environment.storage.setItem(SETTINGS_SECTION_STORAGE_KEY, section); + } catch { + // Storage may be unavailable in restricted or test renderer contexts. + } + }, + }, + focus: { + blurActiveElement() { + const element = environment.document.activeElement; + if (element && typeof (element as HTMLElement).blur === 'function') { + (element as HTMLElement).blur(); + } + }, + }, + }; +} diff --git a/apps/desktop/src/renderer/use-settings-modal.ts b/apps/desktop/src/renderer/use-settings-modal.ts deleted file mode 100644 index 98ee7a0c18..0000000000 --- a/apps/desktop/src/renderer/use-settings-modal.ts +++ /dev/null @@ -1,131 +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, useState } from 'react'; -import type { ProviderType } from '@maka/core/llm-connections'; -import type { SettingsSection } from '@maka/core/settings'; -import { safeLocalStorageSet } from './browser-storage'; - -interface SettingsNavigationRequest { - readonly section?: SettingsSection; - readonly profileId?: string; -} - -/** - * Owns the Settings modal surface state (issue #1043): the open flag, the - * requested section, and the provider-catalog sub-open flag, plus the openers - * that persist the section to localStorage. - * - * `closeSettings` stays in AppShell: on close it re-pulls the onboarding - * snapshot, the memory-visibility flag, and the default permission mode - - * cross-slice orchestration that belongs to the shell, not the modal. - */ -export function useSettingsModal() { - const [settingsOpen, setSettingsOpen] = useState(false); - const [settingsRequest, setSettingsRequest] = useState({}); - const [settingsProviderCatalogOpen, setSettingsProviderCatalogOpen] = useState(false); - const [settingsConnectionDetailSlug, setSettingsConnectionDetailSlug] = useState(undefined); - const [settingsCreateProviderType, setSettingsCreateProviderType] = useState(undefined); - - const setSettingsProfileId = useCallback((profileId: string | undefined) => { - setSettingsRequest((current) => - current.profileId === profileId ? current : { ...current, profileId }, - ); - }, []); - - function showSettings() { - // macOS menu commands do not move DOM focus before opening Settings. - // Settle blur-owned edits before the obscured shell unmounts them. - if (!settingsOpen && document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - setSettingsOpen(true); - } - - function openSettings() { - setSettingsProviderCatalogOpen(false); - setSettingsConnectionDetailSlug(undefined); - setSettingsCreateProviderType(undefined); - showSettings(); - } - - function openSettingsSection(section: SettingsSection) { - safeLocalStorageSet('maka-settings-section-v1', section); - setSettingsRequest((current) => ({ ...current, section })); - setSettingsProviderCatalogOpen(false); - setSettingsConnectionDetailSlug(undefined); - setSettingsCreateProviderType(undefined); - showSettings(); - } - - function openProjectSettings(profileId: string) { - safeLocalStorageSet('maka-settings-section-v1', 'projects'); - setSettingsRequest({ section: 'projects', profileId }); - setSettingsProviderCatalogOpen(false); - setSettingsConnectionDetailSlug(undefined); - setSettingsCreateProviderType(undefined); - showSettings(); - } - - function openProviderCatalog() { - safeLocalStorageSet('maka-settings-section-v1', 'models'); - setSettingsRequest((current) => ({ ...current, section: 'models' })); - setSettingsProviderCatalogOpen(true); - setSettingsConnectionDetailSlug(undefined); - setSettingsCreateProviderType(undefined); - showSettings(); - } - - /** Open Settings → 模型 with a specific connection's detail sheet expanded. */ - function openConnectionDetail(slug: string) { - safeLocalStorageSet('maka-settings-section-v1', 'models'); - setSettingsRequest((current) => ({ ...current, section: 'models' })); - setSettingsProviderCatalogOpen(false); - setSettingsConnectionDetailSlug(slug); - setSettingsCreateProviderType(undefined); - showSettings(); - } - - /** Open Settings → 模型 with the create-connection dialog for this provider expanded. */ - function openProviderCreate(providerType: ProviderType) { - safeLocalStorageSet('maka-settings-section-v1', 'models'); - setSettingsRequest((current) => ({ ...current, section: 'models' })); - setSettingsProviderCatalogOpen(false); - setSettingsConnectionDetailSlug(undefined); - setSettingsCreateProviderType(providerType); - showSettings(); - } - - return { - settingsOpen, - settingsRequest, - settingsProviderCatalogOpen, - settingsConnectionDetailSlug, - settingsCreateProviderType, - setSettingsOpen, - setSettingsProviderCatalogOpen, - setSettingsProfileId, - openSettings, - openSettingsSection, - openProjectSettings, - openProviderCatalog, - openConnectionDetail, - openProviderCreate, - }; -} diff --git a/apps/desktop/src/renderer/use-shell-search.ts b/apps/desktop/src/renderer/use-shell-search.ts deleted file mode 100644 index 5cff78c2a7..0000000000 --- a/apps/desktop/src/renderer/use-shell-search.ts +++ /dev/null @@ -1,73 +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, useMemo, useState } from 'react'; - -type OpenSessionInChat = (sessionId: string, turnId?: string, sequence?: number) => void; - -/** - * Owns the search-modal slice (issue #1043): the open flag, the scroll-target - * anchor handed to ChatView, the close handler, and the stable search-thread - * dep + navigate callback. Astryx restores the opener for ordinary closes. - * - * `openSessionInChatRef` is AppShell's stable ref so the navigate callback - * stays memoized across renders while always calling the latest opener. - */ -export function useShellSearch({ openSessionInChatRef }: { openSessionInChatRef: { current: OpenSessionInChat } }) { - const [searchModalOpen, setSearchModalOpen] = useState(false); - const [searchScrollTarget, setSearchScrollTarget] = useState<{ - sessionId: string; - turnId: string; - sequence?: number; - nonce: number; - handled?: boolean; - } | null>(null); - - const consumeSearchScrollTarget = useCallback((nonce: number) => { - setSearchScrollTarget((current) => - current?.nonce === nonce && !current.handled - ? { ...current, handled: true } - : current, - ); - }, []); - - function closeSearchModal() { - setSearchModalOpen(false); - } - - const searchModalDeps = useMemo( - () => ({ searchThread: (request: Parameters[0]) => window.maka.search.thread(request) }), - [], - ); - - const searchModalOnNavigate = useCallback((sessionId: string, turnId?: string, sequence?: number) => { - openSessionInChatRef.current(sessionId, turnId, sequence); - }, [openSessionInChatRef]); - - return { - searchModalOpen, - setSearchModalOpen, - searchScrollTarget, - setSearchScrollTarget, - consumeSearchScrollTarget, - closeSearchModal, - searchModalDeps, - searchModalOnNavigate, - }; -} diff --git a/apps/desktop/stories/command-search.stories.tsx b/apps/desktop/stories/command-search.stories.tsx index 03d79a56a6..2365b8ac69 100644 --- a/apps/desktop/stories/command-search.stories.tsx +++ b/apps/desktop/stories/command-search.stories.tsx @@ -29,8 +29,13 @@ import { Settings, Sparkles, } from '@maka/ui/icons'; -import { CommandPalette } from '../src/renderer/command-palette'; -import type { Command } from '../src/renderer/command-palette-types'; +import { + CommandPalette, + OverlaysRoot, + OverlaysServicesProvider, + type Command, +} from '../src/renderer/features/overlays/index.js'; +import { createFakeOverlaysServices } from '../src/renderer/features/overlays/testing.js'; // Fidelity convention (#1433): every story below names the real app path // that reaches it. See apps/desktop/stories/FIDELITY.md. @@ -145,12 +150,19 @@ function searchModalDeps(response: SearchResponse): SearchModalDeps { }; } -function CommandPaletteFrame(props: { commands: Command[] }) { - const [isOpen, setIsOpen] = useState(false); +const storyOverlayServices = createFakeOverlaysServices(); + +/** Opens the palette the way the shell does: through the overlays owner. */ +function OpenPaletteOnMount(props: { openPalette(): void }) { + const { openPalette } = props; useEffect(() => { - const frame = window.requestAnimationFrame(() => setIsOpen(true)); + const frame = window.requestAnimationFrame(openPalette); return () => window.cancelAnimationFrame(frame); - }, []); + }, [openPalette]); + return null; +} + +function CommandPaletteFrame(props: { commands: Command[] }) { return (
- + + + {(overlays) => ( + <> + + + + )} + +
); } diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 8b1f6e9da8..482a671dca 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 266 files — blocker 0, reimplementation 0, polish 2, aligned 264. +**Totals:** 270 files — blocker 0, reimplementation 0, polish 2, aligned 268. ## Exclusions (explicit) @@ -38,7 +38,6 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/chat-composer-region.tsx` | shell-chrome-or-panel | Banner, Button | aligned — uses Astryx (Banner, Button) | aligned | | `apps/desktop/src/renderer/chat-message-surface.tsx` | shell-chrome-or-panel | Skeleton | aligned — uses Astryx (Skeleton) | aligned | | `apps/desktop/src/renderer/chat-recovery-notice.tsx` | shell-chrome-or-panel | Banner, Button | aligned — uses Astryx (Banner, Button) | aligned | -| `apps/desktop/src/renderer/command-palette.tsx` | dialog-overlay | CommandPalette, CommandPaletteFooter, CommandPaletteInput, EmptyState, Kbd | aligned — uses Astryx (CommandPalette, CommandPaletteFooter, CommandPaletteInput, EmptyState, Kbd) | aligned | | `apps/desktop/src/renderer/composer-mentions.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/composition/desktop-feature-services.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/composition/legacy-desktop-region.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | @@ -58,6 +57,12 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `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/module-hub/ui/module-hub-provider.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/overlays/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/overlays/ui/command-palette.tsx` | dialog-overlay | CommandPalette, CommandPaletteFooter, CommandPaletteInput, EmptyState, Kbd | aligned — uses Astryx (CommandPalette, CommandPaletteFooter, CommandPaletteInput, EmptyState, Kbd) | aligned | +| `apps/desktop/src/renderer/features/overlays/ui/keyboard-help-modal.tsx` | dialog-overlay | Dialog, DialogHeader, Heading, Kbd, Layout, LayoutContent | aligned — uses Astryx (Dialog, DialogHeader, Heading, Kbd, Layout, LayoutContent) | aligned | +| `apps/desktop/src/renderer/features/overlays/ui/overlays-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/overlays/ui/overlays-root.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/overlays/ui/search-modal-host.tsx` | dialog-overlay | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/runtime-host-management/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/runtime-host-management/ui/peer-mesh-peer-id-button.tsx` | other | Button | aligned — uses Astryx (Button) | aligned | | `apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-add-computer-menu.tsx` | other | DropdownMenu, DropdownMenuItem | aligned — uses Astryx (DropdownMenu, DropdownMenuItem) | aligned | @@ -109,7 +114,6 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx` | other | Button, IconButton | aligned — uses Astryx (Button, IconButton) | aligned | | `apps/desktop/src/renderer/features/workhub/ui/workhub-surface-switch.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/workhub/ui/workhub-work-identity.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | -| `apps/desktop/src/renderer/keyboard-help.tsx` | dialog-overlay | Dialog, DialogHeader, Heading, Kbd, Layout, LayoutContent | aligned — uses Astryx (Dialog, DialogHeader, Heading, Kbd, Layout, LayoutContent) | aligned | | `apps/desktop/src/renderer/live-turn-reconciler.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/maka-tokens.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/mcp-brand-marks.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index ebbb76bd9c..f34aa2a111 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -9,7 +9,6 @@ apps/desktop/src/renderer/cascade-layers.css apps/desktop/src/renderer/chat-composer-region.tsx apps/desktop/src/renderer/chat-message-surface.tsx apps/desktop/src/renderer/chat-recovery-notice.tsx -apps/desktop/src/renderer/command-palette.tsx apps/desktop/src/renderer/composer-mentions.tsx apps/desktop/src/renderer/composition/desktop-feature-services.tsx apps/desktop/src/renderer/composition/legacy-desktop-region.tsx @@ -29,6 +28,12 @@ apps/desktop/src/renderer/features/goals/ui/goal-provider.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/module-hub/ui/module-hub-provider.tsx +apps/desktop/src/renderer/features/overlays/services-context.tsx +apps/desktop/src/renderer/features/overlays/ui/command-palette.tsx +apps/desktop/src/renderer/features/overlays/ui/keyboard-help-modal.tsx +apps/desktop/src/renderer/features/overlays/ui/overlays-context.tsx +apps/desktop/src/renderer/features/overlays/ui/overlays-root.tsx +apps/desktop/src/renderer/features/overlays/ui/search-modal-host.tsx apps/desktop/src/renderer/features/runtime-host-management/services-context.tsx apps/desktop/src/renderer/features/runtime-host-management/ui/peer-mesh-peer-id-button.tsx apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-add-computer-menu.tsx @@ -80,7 +85,6 @@ apps/desktop/src/renderer/features/workhub/ui/workhub-navigation-rail.tsx apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx apps/desktop/src/renderer/features/workhub/ui/workhub-surface-switch.tsx apps/desktop/src/renderer/features/workhub/ui/workhub-work-identity.tsx -apps/desktop/src/renderer/keyboard-help.tsx apps/desktop/src/renderer/live-turn-reconciler.tsx apps/desktop/src/renderer/maka-tokens.css apps/desktop/src/renderer/mcp-brand-marks.tsx diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index fdafad4144..1490432f88 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -116,10 +116,8 @@ export const ALLOWED = { useAppShellSessionUiReads: 1, useAppShellSessionWorkspace: 1, useAppShellTurnPresentation: 1, - useCommandPalette: 1, useComposerAttachments: 1, useEffect: 7, - useKeyboardHelp: 1, useLayoutEffect: 2, useNewTaskChoice: 1, useOnboardingSnapshot: 1, @@ -137,7 +135,6 @@ export const ALLOWED = { useSessionNavigationReads: 1, useSessionCollaborationDialog: 1, useSessionSettingIntent: 1, - useSettingsModal: 1, useShellAppearance: 1, useShellChatModel: 1, useShellConnections: 3, @@ -145,7 +142,6 @@ export const ALLOWED = { useShellMemoryPill: 1, useShellResume: 1, useShellRunUpdates: 1, - useShellSearch: 1, useStableActions: 6, useState: 12, useTaskSubmissionReadiness: 1,