From d0c80782582518164f85cc2aa2e1a93c3f1d64d8 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Thu, 3 Sep 2026 03:53:45 +0800 Subject: [PATCH] refactor(desktop): share the feature services context and pass bridge namespaces through Add application/contracts/feature-services.tsx with createServicesContext, and derive every feature slice's ServicesProvider/useServices pair from it instead of restating the same createContext boilerplate nine times; every exported name, type, and error message is unchanged. Where a Desktop adapter's port is a structural subset of one bridge namespace, hand the namespace through (sessions, newTasks, scheduledTasks, shellRuns, todo, attachments) instead of restating each method. Blocks that rename, guard, filter, or translate stay hand-written: the adapter tests drive Proxy-based bridge recorders without own keys, so a spread would copy nothing there, and passing the object keeps late binding everywhere. Generated-by: Claude Code --- apps/desktop/src/renderer/README.md | 6 +- .../contracts/feature-services.tsx | 56 +++++++++++++++++++ .../connection-settings/services-context.tsx | 22 +++----- .../features/goals/services-context.tsx | 19 ++----- .../features/module-hub/services-context.tsx | 19 ++----- .../services-context.tsx | 19 ++----- .../services-context.tsx | 20 ++----- .../session-navigation/services-context.tsx | 22 ++------ .../session-settings/services-context.tsx | 19 ++----- .../features/task-entry/services-context.tsx | 19 ++----- .../features/workbar/services-context.tsx | 21 ++----- .../desktop/create-module-hub-services.ts | 17 +----- .../create-session-navigation-services.ts | 15 +---- .../desktop/create-task-entry-services.ts | 8 +-- .../desktop/create-workbar-services.ts | 23 +------- docs/astryx-surface-file-inventory.md | 3 +- docs/astryx-surface-file-inventory.paths | 1 + 17 files changed, 109 insertions(+), 200 deletions(-) create mode 100644 apps/desktop/src/renderer/application/contracts/feature-services.tsx diff --git a/apps/desktop/src/renderer/README.md b/apps/desktop/src/renderer/README.md index bbc8191a27..5d6f1d77c5 100644 --- a/apps/desktop/src/renderer/README.md +++ b/apps/desktop/src/renderer/README.md @@ -64,7 +64,11 @@ application -> shared contracts + injected ports preload, main, or `platform/desktop`. Consumers use its public `index` entry; `testing` is test/Storybook-only. - `platform/desktop/` is the outer adapter zone for the preload bridge. It - implements narrow inward-facing ports rather than exporting the whole bridge; + implements narrow inward-facing ports rather than exporting the whole bridge: + where a port is a structural subset of one bridge namespace the adapter hands + that namespace through as-is (`sessions: bridge.sessions`) instead of + restating each method, and hand-writes the blocks that rename, guard, or + translate; composition and adapters consume application public entries, not deep implementation modules. Adapters may own bridge and browser-environment access, but never React UI/hooks/class lifecycle, Electron/Node imports, or diff --git a/apps/desktop/src/renderer/application/contracts/feature-services.tsx b/apps/desktop/src/renderer/application/contracts/feature-services.tsx new file mode 100644 index 0000000000..387661f1f0 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/feature-services.tsx @@ -0,0 +1,56 @@ +/* + * 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 ReactElement, type ReactNode } from 'react'; + +export interface ServicesContext { + /** Mounts one services object for the feature's subtree. */ + readonly Provider: (props: { + readonly services: S; + readonly children?: ReactNode; + }) => ReactElement; + /** Reads the mounted services; throws when the Provider is missing. */ + readonly useServices: () => S; +} + +/** + * One services context per feature slice. + * + * A slice declares its inward-facing services type in `ports.ts` and gets the + * Provider/hook pair from here instead of restating twenty lines of + * `createContext` boilerplate. `providerName` is the Provider's displayName + * and the name in the error a consumer sees when nothing is mounted, so the + * message stays `ServicesProvider is missing` for every slice. + */ +export function createServicesContext(providerName: string): ServicesContext { + const Context = createContext(null); + function Provider(props: { + readonly services: S; + readonly children?: ReactNode; + }): ReactElement { + return {props.children}; + } + Provider.displayName = providerName; + function useServices(): S { + const services = useContext(Context); + if (!services) throw new Error(`${providerName} is missing`); + return services; + } + return { Provider, useServices }; +} diff --git a/apps/desktop/src/renderer/features/connection-settings/services-context.tsx b/apps/desktop/src/renderer/features/connection-settings/services-context.tsx index 695fae2dbd..a6b809b5fe 100644 --- a/apps/desktop/src/renderer/features/connection-settings/services-context.tsx +++ b/apps/desktop/src/renderer/features/connection-settings/services-context.tsx @@ -17,26 +17,18 @@ * under the License. */ -import { createContext, useContext, useSyncExternalStore, type ReactNode } from 'react'; +import { useSyncExternalStore, type ReactNode } from 'react'; +import { createServicesContext } from '../../application/contracts/feature-services.js'; import type { ApiKeyOnboardingBridge, ConnectionSettingsServices } from './ports.js'; -const ConnectionSettingsServicesContext = createContext(null); +const { Provider, useServices } = createServicesContext( + 'ConnectionSettingsServicesProvider', +); -export function ConnectionSettingsServicesProvider(props: { - readonly services: ConnectionSettingsServices; - readonly children?: ReactNode; -}) { - return ( - - {props.children} - - ); -} +export const ConnectionSettingsServicesProvider = Provider; export function useConnectionSettingsServices(): ConnectionSettingsServices { - const services = useContext(ConnectionSettingsServicesContext); - if (!services) throw new Error('ConnectionSettingsServicesProvider is missing'); - return services; + return useServices(); } export function ConnectionSettingsServicesConsumer(props: { diff --git a/apps/desktop/src/renderer/features/goals/services-context.tsx b/apps/desktop/src/renderer/features/goals/services-context.tsx index 65efb5120a..5715960165 100644 --- a/apps/desktop/src/renderer/features/goals/services-context.tsx +++ b/apps/desktop/src/renderer/features/goals/services-context.tsx @@ -17,24 +17,13 @@ * under the License. */ -import { createContext, useContext, type ReactNode } from 'react'; +import { createServicesContext } from '../../application/contracts/feature-services.js'; import type { GoalServices } from './ports.js'; -const GoalServicesContext = createContext(null); +const { Provider, useServices } = createServicesContext('GoalServicesProvider'); -export function GoalServicesProvider(props: { - services: GoalServices; - children?: ReactNode; -}) { - return ( - - {props.children} - - ); -} +export const GoalServicesProvider = Provider; export function useGoalServices(): GoalServices { - const services = useContext(GoalServicesContext); - if (!services) throw new Error('GoalServicesProvider is missing'); - return services; + return useServices(); } diff --git a/apps/desktop/src/renderer/features/module-hub/services-context.tsx b/apps/desktop/src/renderer/features/module-hub/services-context.tsx index 09125f6f69..4887afa5f9 100644 --- a/apps/desktop/src/renderer/features/module-hub/services-context.tsx +++ b/apps/desktop/src/renderer/features/module-hub/services-context.tsx @@ -17,24 +17,13 @@ * under the License. */ -import { createContext, useContext, type ReactNode } from 'react'; +import { createServicesContext } from '../../application/contracts/feature-services.js'; import type { ModuleHubServices } from './ports.js'; -const ModuleHubServicesContext = createContext(null); +const { Provider, useServices } = createServicesContext('ModuleHubServicesProvider'); -export function ModuleHubServicesProvider(props: { - services: ModuleHubServices; - children?: ReactNode; -}) { - return ( - - {props.children} - - ); -} +export const ModuleHubServicesProvider = Provider; export function useModuleHubServices(): ModuleHubServices { - const services = useContext(ModuleHubServicesContext); - if (!services) throw new Error('ModuleHubServicesProvider is missing'); - return services; + return useServices(); } diff --git a/apps/desktop/src/renderer/features/runtime-host-management/services-context.tsx b/apps/desktop/src/renderer/features/runtime-host-management/services-context.tsx index 5bb2660de3..4c02b8e727 100644 --- a/apps/desktop/src/renderer/features/runtime-host-management/services-context.tsx +++ b/apps/desktop/src/renderer/features/runtime-host-management/services-context.tsx @@ -17,24 +17,13 @@ * under the License. */ -import { createContext, useContext, type ReactNode } from 'react'; +import { createServicesContext } from '../../application/contracts/feature-services.js'; import type { RuntimeHostManagementServices } from './ports.js'; -const RuntimeHostManagementServicesContext = createContext(null); +const { Provider, useServices } = createServicesContext('RuntimeHostManagementServicesProvider'); -export function RuntimeHostManagementServicesProvider(props: { - readonly services: RuntimeHostManagementServices; - readonly children?: ReactNode; -}) { - return ( - - {props.children} - - ); -} +export const RuntimeHostManagementServicesProvider = Provider; export function useRuntimeHostManagementServices(): RuntimeHostManagementServices { - const services = useContext(RuntimeHostManagementServicesContext); - if (!services) throw new Error('RuntimeHostManagementServicesProvider is missing'); - return services; + return useServices(); } diff --git a/apps/desktop/src/renderer/features/session-collaboration/services-context.tsx b/apps/desktop/src/renderer/features/session-collaboration/services-context.tsx index 9d6e556b5c..f6fc8a99bb 100644 --- a/apps/desktop/src/renderer/features/session-collaboration/services-context.tsx +++ b/apps/desktop/src/renderer/features/session-collaboration/services-context.tsx @@ -17,25 +17,13 @@ * under the License. */ -import { createContext, useContext, type ReactNode } from 'react'; +import { createServicesContext } from '../../application/contracts/feature-services.js'; import type { SessionCollaborationServices } from './ports.js'; -const SessionCollaborationServicesContext = - createContext(null); +const { Provider, useServices } = createServicesContext('SessionCollaborationServicesProvider'); -export function SessionCollaborationServicesProvider(props: { - readonly services: SessionCollaborationServices; - readonly children?: ReactNode; -}) { - return ( - - {props.children} - - ); -} +export const SessionCollaborationServicesProvider = Provider; export function useSessionCollaborationServices(): SessionCollaborationServices { - const services = useContext(SessionCollaborationServicesContext); - if (!services) throw new Error('SessionCollaborationServicesProvider is missing'); - return services; + return useServices(); } diff --git a/apps/desktop/src/renderer/features/session-navigation/services-context.tsx b/apps/desktop/src/renderer/features/session-navigation/services-context.tsx index 72cbcc6108..cd4a114f2e 100644 --- a/apps/desktop/src/renderer/features/session-navigation/services-context.tsx +++ b/apps/desktop/src/renderer/features/session-navigation/services-context.tsx @@ -17,27 +17,13 @@ * under the License. */ -import { createContext, useContext, type ReactNode } from 'react'; +import { createServicesContext } from '../../application/contracts/feature-services.js'; import type { SessionNavigationServices } from './ports.js'; -const SessionNavigationServicesContext = - createContext(null); +const { Provider, useServices } = createServicesContext('SessionNavigationServicesProvider'); -export function SessionNavigationServicesProvider(props: { - services: SessionNavigationServices; - children?: ReactNode; -}) { - return ( - - {props.children} - - ); -} +export const SessionNavigationServicesProvider = Provider; export function useSessionNavigationServices(): SessionNavigationServices { - const services = useContext(SessionNavigationServicesContext); - if (!services) { - throw new Error('SessionNavigationServicesProvider is missing'); - } - return services; + return useServices(); } diff --git a/apps/desktop/src/renderer/features/session-settings/services-context.tsx b/apps/desktop/src/renderer/features/session-settings/services-context.tsx index 6ac7e6d8f3..49fe4c043c 100644 --- a/apps/desktop/src/renderer/features/session-settings/services-context.tsx +++ b/apps/desktop/src/renderer/features/session-settings/services-context.tsx @@ -17,24 +17,13 @@ * under the License. */ -import { createContext, useContext, type ReactNode } from 'react'; +import { createServicesContext } from '../../application/contracts/feature-services.js'; import type { SessionSettingsServices } from './ports.js'; -const SessionSettingsServicesContext = createContext(null); +const { Provider, useServices } = createServicesContext('SessionSettingsServicesProvider'); -export function SessionSettingsServicesProvider(props: { - readonly services: SessionSettingsServices; - readonly children?: ReactNode; -}) { - return ( - - {props.children} - - ); -} +export const SessionSettingsServicesProvider = Provider; export function useSessionSettingsServices(): SessionSettingsServices { - const services = useContext(SessionSettingsServicesContext); - if (!services) throw new Error('SessionSettingsServicesProvider is missing'); - return services; + return useServices(); } diff --git a/apps/desktop/src/renderer/features/task-entry/services-context.tsx b/apps/desktop/src/renderer/features/task-entry/services-context.tsx index a09cdf8579..9fc13ea1cb 100644 --- a/apps/desktop/src/renderer/features/task-entry/services-context.tsx +++ b/apps/desktop/src/renderer/features/task-entry/services-context.tsx @@ -17,24 +17,13 @@ * under the License. */ -import { createContext, useContext, type ReactNode } from 'react'; +import { createServicesContext } from '../../application/contracts/feature-services.js'; import type { TaskEntryServices } from './ports.js'; -const TaskEntryServicesContext = createContext(null); +const { Provider, useServices } = createServicesContext('TaskEntryServicesProvider'); -export function TaskEntryServicesProvider(props: { - services: TaskEntryServices; - children?: ReactNode; -}) { - return ( - - {props.children} - - ); -} +export const TaskEntryServicesProvider = Provider; export function useTaskEntryServices(): TaskEntryServices { - const services = useContext(TaskEntryServicesContext); - if (!services) throw new Error('TaskEntryServicesProvider is missing'); - return services; + return useServices(); } diff --git a/apps/desktop/src/renderer/features/workbar/services-context.tsx b/apps/desktop/src/renderer/features/workbar/services-context.tsx index cd57b9d66b..27829e255c 100644 --- a/apps/desktop/src/renderer/features/workbar/services-context.tsx +++ b/apps/desktop/src/renderer/features/workbar/services-context.tsx @@ -17,26 +17,13 @@ * under the License. */ -import { createContext, useContext, type ReactNode } from 'react'; +import { createServicesContext } from '../../application/contracts/feature-services.js'; import type { WorkbarServices } from './ports.js'; -const WorkbarServicesContext = createContext(null); +const { Provider, useServices } = createServicesContext('WorkbarServicesProvider'); -export function WorkbarServicesProvider(props: { - services: WorkbarServices; - children?: ReactNode; -}) { - return ( - - {props.children} - - ); -} +export const WorkbarServicesProvider = Provider; export function useWorkbarServices(): WorkbarServices { - const services = useContext(WorkbarServicesContext); - if (!services) { - throw new Error('WorkbarServicesProvider is missing'); - } - return services; + return useServices(); } diff --git a/apps/desktop/src/renderer/platform/desktop/create-module-hub-services.ts b/apps/desktop/src/renderer/platform/desktop/create-module-hub-services.ts index 4c4880ce4c..c5f5a90e5d 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-module-hub-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-module-hub-services.ts @@ -84,22 +84,7 @@ export function createDesktopModuleHubServices( delete: (skillRef, host) => bridge.skills.delete(skillRef, host), open: (skillId, target, host) => bridge.skills.open(skillId, target, host), }, - scheduledTasks: { - list: (host) => bridge.scheduledTasks.list(host), - create: (input, host) => bridge.scheduledTasks.create(input, host), - update: (id, patch, host) => - bridge.scheduledTasks.update(id, patch, host), - setEnabled: (id, enabled, host) => - bridge.scheduledTasks.setEnabled(id, enabled, host), - triggerNow: (id, host) => bridge.scheduledTasks.triggerNow(id, host), - snooze: (id, host) => bridge.scheduledTasks.snooze(id, host), - clearRunHistory: (id, host) => - bridge.scheduledTasks.clearRunHistory(id, host), - delete: (id, host) => bridge.scheduledTasks.delete(id, host), - subscribeChanges: (handler) => - bridge.scheduledTasks.subscribeChanges(handler), - subscribeDue: (handler) => bridge.scheduledTasks.subscribeDue(handler), - }, + scheduledTasks: bridge.scheduledTasks, clientSettings: { supported: clientSettingsSupported, async getKeepSystemAwake() { diff --git a/apps/desktop/src/renderer/platform/desktop/create-session-navigation-services.ts b/apps/desktop/src/renderer/platform/desktop/create-session-navigation-services.ts index 241c0cd656..09af1227c2 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-session-navigation-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-session-navigation-services.ts @@ -27,19 +27,6 @@ export function createDesktopSessionNavigationServices( bridge: DesktopSessionNavigationBridge = window.maka, ): SessionNavigationServices { return { - sessions: { - list: () => bridge.sessions.list(), - setFlagged: (sessionId, flagged, options) => - bridge.sessions.setFlagged(sessionId, flagged, options), - archive: (sessionId, options) => - bridge.sessions.archive(sessionId, options), - unarchive: (sessionId, options) => - bridge.sessions.unarchive(sessionId, options), - rename: (sessionId, name, options) => - bridge.sessions.rename(sessionId, name, options), - remove: (sessionId, options) => - bridge.sessions.remove(sessionId, options), - previewRemoval: (sessionId) => bridge.sessions.previewRemoval(sessionId), - }, + sessions: bridge.sessions, }; } diff --git a/apps/desktop/src/renderer/platform/desktop/create-task-entry-services.ts b/apps/desktop/src/renderer/platform/desktop/create-task-entry-services.ts index f34d256f5e..d3d3b7fddc 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-task-entry-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-task-entry-services.ts @@ -27,12 +27,6 @@ export function createDesktopTaskEntryServices( bridge: DesktopTaskEntryBridge = window.maka, ): TaskEntryServices { return { - catalog: { - getCatalog: () => bridge.newTasks.getCatalog(), - subscribeChanges: (handler) => bridge.newTasks.subscribeChanges(handler), - addProject: (host) => bridge.newTasks.addProject(host), - relinkProject: (host, projectId) => - bridge.newTasks.relinkProject(host, projectId), - }, + catalog: bridge.newTasks, }; } diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index 5b53b29c91..23480b7124 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -54,19 +54,8 @@ export function createDesktopWorkbarServices( subscribeSessionEvents: (sessionId, handler) => bridge.sessions.subscribeEvents(sessionId, handler), }, - terminal: { - start: (sessionId) => bridge.shellRuns.start(sessionId), - stop: (input) => bridge.shellRuns.stop(input), - attach: (input) => bridge.shellRuns.attach(input), - detach: (input) => bridge.shellRuns.detach(input), - write: (input) => bridge.shellRuns.write(input), - subscribePtyData: (handler) => bridge.shellRuns.subscribePtyData(handler), - subscribeResync: (handler) => bridge.shellRuns.subscribeResync(handler), - }, - todo: { - read: (sessionId) => bridge.todo.read(sessionId), - subscribeChanges: (handler) => bridge.todo.subscribeChanges(handler), - }, + terminal: bridge.shellRuns, + todo: bridge.todo, browser: { setActiveSession: (sessionId) => bridge.browser.setActiveSession(sessionId), setViewport: (input) => bridge.browser.setViewport(input), @@ -103,13 +92,7 @@ export function createDesktopWorkbarServices( subscribeUsageChanges: (sessionId, handler) => bridge.inspector.subscribeUsageChanges(sessionId, handler), }, - attachments: { - readBytes: (sessionId, artifactId) => - bridge.attachments.readBytes(sessionId, artifactId), - pickFiles: () => bridge.attachments.pickFiles(), - previewApproval: (approvalId) => - bridge.attachments.previewApproval(approvalId), - }, + attachments: bridge.attachments, sideChat: { listSessions: () => bridge.sessions.list(), listTurns: (sessionId) => bridge.sessions.listTurns(sessionId), diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 14992542f9..2c1d211cb1 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:** 244 files — blocker 0, reimplementation 0, polish 1, aligned 243. +**Totals:** 245 files — blocker 0, reimplementation 0, polish 1, aligned 244. ## Exclusions (explicit) @@ -33,6 +33,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/app-shell-overlays.tsx` | shell-chrome-or-panel | Spinner | aligned — uses Astryx (Spinner) | aligned | | `apps/desktop/src/renderer/app-shell.tsx` | shell-chrome-or-panel | AppShell, Button | aligned — uses Astryx (AppShell, Button) | aligned | | `apps/desktop/src/renderer/app.tsx` | other | Theme | aligned — uses Astryx (Theme) | aligned | +| `apps/desktop/src/renderer/application/contracts/feature-services.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/cascade-layers.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `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 | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index e256ca9acb..40063d9469 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -4,6 +4,7 @@ apps/desktop/src/renderer/app-shell-detail-panel.tsx apps/desktop/src/renderer/app-shell-overlays.tsx apps/desktop/src/renderer/app-shell.tsx apps/desktop/src/renderer/app.tsx +apps/desktop/src/renderer/application/contracts/feature-services.tsx apps/desktop/src/renderer/cascade-layers.css apps/desktop/src/renderer/chat-composer-region.tsx apps/desktop/src/renderer/chat-message-surface.tsx