-
+
{workHubEnabled && workHubActive && navSelection.section === 'sessions' ? (
workHubCoordinationSessionId ? (
-
+
+
);
}
diff --git a/apps/desktop/src/renderer/composer-mentions.tsx b/apps/desktop/src/renderer/composer-mentions.tsx
index 4a4eeb62fc..4b0b331b36 100644
--- a/apps/desktop/src/renderer/composer-mentions.tsx
+++ b/apps/desktop/src/renderer/composer-mentions.tsx
@@ -56,7 +56,7 @@ export interface ComposerMentions {
/** Which backend surface the popups should describe. */
export interface ComposerMentionsSurface {
- /** Invalidates Runtime's invocable projection after installed Skills settle. */
+ /** Handed over by the Module Hub boundary to invalidate Runtime's projection. */
skillCatalogRevision: number;
sessionId?: string;
projectPath?: string;
@@ -66,6 +66,12 @@ export interface ComposerMentionsSurface {
newTaskTarget?: DesktopNewTaskTarget;
}
+/** The surface AppShell assembles; the Module Hub boundary supplies the revision. */
+export type ComposerMentionsSurfaceInput = Omit<
+ ComposerMentionsSurface,
+ 'skillCatalogRevision'
+>;
+
/**
* Owns the composer mention popup wiring so app-shell.tsx keeps no inline
* `window.maka` state (app-shell-composer-attachment-owner-contract). Derives
@@ -296,6 +302,22 @@ export function ComposerMentionsProvider({
return
{children};
}
+/**
+ * How the provider mounts under the Skill catalog revision the Module Hub
+ * boundary hands over: the shell passes the surface it assembled, the boundary
+ * supplies the revision and the frame it already built, and the provider's
+ * `skillCatalogRevision` stays a required, compiler-checked prop.
+ */
+export function renderComposerMentionsProvider(
+ surface: ComposerMentionsSurfaceInput,
+): (skillCatalogRevision: number, children: ReactNode) => ReactNode {
+ return (skillCatalogRevision, children) => (
+
+ {children}
+
+ );
+}
+
export function useComposerMentionsContext(): ComposerMentions | undefined {
return useContext(ComposerMentionsContext);
}
diff --git a/apps/desktop/src/renderer/features/module-hub/README.md b/apps/desktop/src/renderer/features/module-hub/README.md
index f93eb29dfc..98e5e15896 100644
--- a/apps/desktop/src/renderer/features/module-hub/README.md
+++ b/apps/desktop/src/renderer/features/module-hub/README.md
@@ -31,12 +31,15 @@ Automations:
- selection and header composition for Skills, MCP, Scheduled Tasks, and Daily
Review.
-`AppShell` still owns top-level `NavSelection`, module-memory persistence,
-Session/Project navigation, and the Composer. Those capabilities cross the
-boundary only as intents. The feature exposes a read-only Scheduled Tasks
-projection to the Session rail and a revision number that invalidates the
-Composer's separate Runtime-owned invocable-Skills projection; neither makes
-the Shell an owner of Module Hub data.
+`ModuleHubProvider` owns the controller below `AppShell`. `AppShell` still owns
+top-level `NavSelection`, module-memory persistence, Session/Project
+navigation, and the Composer. Those capabilities cross the boundary only as
+intents. Commands return through a stable imperative port because the Shell
+calls them only from event handlers. Two typed boundaries hand the read-only
+Scheduled Tasks projection to the Session rail and the Skill catalog revision
+to Composer mentions through a `render` prop at their actual readers, so the
+readers' props stay required and the compiler checks the wiring; Module Hub
+updates therefore do not subscribe or re-render `AppShell`.
## Dependency direction
@@ -54,6 +57,12 @@ MCP is the explicit exception to I/O ownership in this slice. `McpPage` keeps
its existing page-owned controller and direct Desktop bridge. `ModuleHubHost`
only selects and mounts that leaf; moving MCP internals is a separate change.
+The production entry deliberately does not export `useModuleHubController`.
+The renderer architecture policy records its implementation and
+`ModuleHubProvider` as the unique production owner. Moving, duplicating, or
+re-exporting that controller fails the central architecture check; tests may
+reach it only through `testing.ts`.
+
## Lifecycle invariants
- The three Skills projections and Scheduled Tasks each have independent
@@ -77,6 +86,11 @@ only selects and mounts that leaf; moving MCP internals is a separate change.
feedback.
- Opening Scheduled Task creation selects the page and increments the request
nonce; the page acknowledgement resets it to zero.
+- The Provider remains mounted across all routes. Navigation may hide every
+ Module Hub page, but it must not tear down default-Host refresh, Scheduled
+ Task subscriptions, Daily Review command state, or the create nonce.
+- The command port connects and disconnects in a layout effect. A stale
+ Provider cleanup cannot detach a newer controller target.
There is intentionally no feature-level reducer or store: these projections and
commands have real lifecycle ownership, while navigation persistence remains a
diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts
index 8ebfce96b5..209c325382 100644
--- a/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts
+++ b/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts
@@ -51,15 +51,17 @@ export interface ModuleHubHostModel {
readonly openSession: (sessionId: string) => void;
}
+export interface ModuleHubCommands {
+ refreshProjectSkills(): Promise
;
+ openScheduledTaskCreate(): void;
+ copyTodayDailyReview(): Promise;
+ pasteTodayDailyReview(): Promise;
+ saveTodayDailyReview(): Promise;
+}
+
export interface ModuleHubController {
readonly host: ModuleHubHostModel;
- readonly commands: {
- refreshProjectSkills(): Promise;
- openScheduledTaskCreate(): void;
- copyTodayDailyReview(): Promise;
- pasteTodayDailyReview(): Promise;
- saveTodayDailyReview(): Promise;
- };
+ readonly commands: ModuleHubCommands;
readonly selectors: {
readonly scheduledTasks: readonly ScheduledTask[];
/** Invalidates the composer's Runtime-owned invocable Skills projection. */
diff --git a/apps/desktop/src/renderer/features/module-hub/index.ts b/apps/desktop/src/renderer/features/module-hub/index.ts
index a99b752aff..f9d5c9f2cb 100644
--- a/apps/desktop/src/renderer/features/module-hub/index.ts
+++ b/apps/desktop/src/renderer/features/module-hub/index.ts
@@ -17,10 +17,15 @@
* under the License.
*/
-export { useModuleHubController } from './controller/use-module-hub-controller.js';
export { ModuleHubServicesProvider } from './services-context.js';
export type {
ModuleHubClipboardService,
ModuleHubServices,
} from './ports.js';
-export { ModuleHubHost } from './ui/module-hub-host.js';
+export { ModuleHubHost, ModuleHubHostView } from './ui/module-hub-host.js';
+export {
+ createModuleHubCommandPort,
+ ModuleHubProvider,
+ ModuleHubScheduledTasksBoundary,
+ ModuleHubSkillCatalogRevisionBoundary,
+} from './ui/module-hub-provider.js';
diff --git a/apps/desktop/src/renderer/features/module-hub/testing.ts b/apps/desktop/src/renderer/features/module-hub/testing.ts
index ca504b3ce7..8dbc0a4223 100644
--- a/apps/desktop/src/renderer/features/module-hub/testing.ts
+++ b/apps/desktop/src/renderer/features/module-hub/testing.ts
@@ -23,9 +23,19 @@ import type { ModuleHubHostModel } from "./controller/use-module-hub-controller.
export { ModuleHubServicesProvider } from "./services-context.js";
export type { ModuleHubServices } from "./ports.js";
+export {
+ createModuleHubCommandPort,
+ ModuleHubProvider,
+ ModuleHubScheduledTasksBoundary,
+ ModuleHubSkillCatalogRevisionBoundary,
+ type ModuleHubCommands,
+} from "./ui/module-hub-provider.js";
export { startModuleHubLifecycle } from "./controller/module-hub-lifecycle.js";
export { resolveModuleHubHostRoute } from "./controller/module-hub-route.js";
-export type { ModuleHubHostModel } from "./controller/use-module-hub-controller.js";
+export {
+ useModuleHubController,
+ type ModuleHubHostModel,
+} from "./controller/use-module-hub-controller.js";
export {
createDailyReviewBridge,
useDailyReviewController,
diff --git a/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx b/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx
index 3b437d3a4f..7792351cd1 100644
--- a/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx
+++ b/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx
@@ -29,10 +29,15 @@ import {
import { McpPage } from '../../../mcp-page.js';
import type { ModuleHubHostModel } from '../controller/use-module-hub-controller.js';
import { resolveModuleHubHostRoute } from '../controller/module-hub-route.js';
+import { useModuleHubHostModel } from './module-hub-provider.js';
/** Selects and mounts exactly one Module Hub leaf for the Shell selection. */
-export function ModuleHubHost(props: { model: ModuleHubHostModel }) {
- const { model } = props;
+export function ModuleHubHost() {
+ return ;
+}
+
+/** Environment-free view seam for focused tests and Storybook. */
+export function ModuleHubHostView({ model }: { model: ModuleHubHostModel }) {
const copy = getSharedUiCopy(useUiLocale()).moduleHubs;
const selection = model.selection;
const route = resolveModuleHubHostRoute(selection);
diff --git a/apps/desktop/src/renderer/features/module-hub/ui/module-hub-provider.tsx b/apps/desktop/src/renderer/features/module-hub/ui/module-hub-provider.tsx
new file mode 100644
index 0000000000..3abc28c3fb
--- /dev/null
+++ b/apps/desktop/src/renderer/features/module-hub/ui/module-hub-provider.tsx
@@ -0,0 +1,159 @@
+/*
+ * 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,
+ useLayoutEffect,
+ type ReactNode,
+} from 'react';
+import type { ScheduledTask } from '@maka/core/scheduled-task';
+import {
+ useModuleHubController,
+ type ModuleHubCommands,
+ type ModuleHubHostModel,
+ type UseModuleHubControllerInput,
+} from '../controller/use-module-hub-controller.js';
+
+const ModuleHubHostContext = createContext(null);
+const ModuleHubScheduledTasksContext = createContext<
+ readonly ScheduledTask[] | null
+>(null);
+const ModuleHubSkillCatalogRevisionContext = createContext(null);
+
+export interface ModuleHubCommandPort extends ModuleHubCommands {
+ connect(target: ModuleHubCommands): () => void;
+}
+
+export interface ModuleHubProviderProps extends UseModuleHubControllerInput {
+ readonly commandPort: ModuleHubCommandPort;
+ readonly children?: ReactNode;
+}
+
+/**
+ * The stable command surface AppShell holds while the controller below it is
+ * replaced.
+ *
+ * `connect` runs from a layout effect keyed on `controller.commands`, and that
+ * object is rebuilt whenever the controller's `useMemo` input changes — which
+ * includes `openSession`, a fresh arrow on every AppShell render. So the effect
+ * re-runs on every shell render: cleanup, then connect. The identity check in
+ * the cleanup is what keeps that churn harmless — a stale cleanup must never
+ * detach a newer target that connected after it.
+ */
+export function createModuleHubCommandPort(): ModuleHubCommandPort {
+ let target: ModuleHubCommands | null = null;
+ return {
+ connect(next) {
+ target = next;
+ return () => {
+ if (target === next) target = null;
+ };
+ },
+ refreshProjectSkills: () =>
+ target?.refreshProjectSkills() ?? Promise.resolve(),
+ openScheduledTaskCreate: () => target?.openScheduledTaskCreate(),
+ copyTodayDailyReview: () =>
+ target?.copyTodayDailyReview() ?? Promise.resolve(),
+ pasteTodayDailyReview: () =>
+ target?.pasteTodayDailyReview() ?? Promise.resolve(),
+ saveTodayDailyReview: () =>
+ target?.saveTodayDailyReview() ?? Promise.resolve(),
+ };
+}
+
+/**
+ * Owns the Module Hub controller below AppShell.
+ *
+ * Module Hub updates re-render this provider and the three narrow readers
+ * below it. `children` is the element AppShell already built, so React can
+ * bail out of the rest of the frame instead of widening feature state back to
+ * the shell root.
+ */
+export function ModuleHubProvider({
+ commandPort,
+ children,
+ ...input
+}: ModuleHubProviderProps) {
+ const controller = useModuleHubController(input);
+
+ useLayoutEffect(
+ () => commandPort.connect(controller.commands),
+ [commandPort, controller.commands],
+ );
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+
+export function useModuleHubHostModel(): ModuleHubHostModel {
+ const model = useContext(ModuleHubHostContext);
+ if (!model) throw new Error('ModuleHubProvider is missing');
+ return model;
+}
+
+/**
+ * Hands the rail's read-only Scheduled Tasks projection to its reader.
+ *
+ * `render` receives the projection together with the element AppShell already
+ * built, so the reader's prop stays required and typed at the call site, and a
+ * Scheduled Tasks change re-renders only this boundary and the element
+ * `render` returns — the children it forwards keep their identity.
+ */
+export function ModuleHubScheduledTasksBoundary(props: {
+ readonly render: (
+ scheduledTasks: readonly ScheduledTask[],
+ children: ReactNode,
+ ) => ReactNode;
+ readonly children?: ReactNode;
+}): ReactNode {
+ const scheduledTasks = useContext(ModuleHubScheduledTasksContext);
+ if (!scheduledTasks) throw new Error('ModuleHubProvider is missing');
+ return props.render(scheduledTasks, props.children);
+}
+
+/** Hands the Skill catalog revision to Composer mentions without waking AppShell. */
+export function ModuleHubSkillCatalogRevisionBoundary(props: {
+ readonly render: (
+ skillCatalogRevision: number,
+ children: ReactNode,
+ ) => ReactNode;
+ readonly children?: ReactNode;
+}): ReactNode {
+ const skillCatalogRevision = useContext(
+ ModuleHubSkillCatalogRevisionContext,
+ );
+ if (skillCatalogRevision === null) {
+ throw new Error('ModuleHubProvider is missing');
+ }
+ return props.render(skillCatalogRevision, props.children);
+}
+
+export type { ModuleHubCommands } from '../controller/use-module-hub-controller.js';
diff --git a/apps/desktop/stories/module-hubs.stories.tsx b/apps/desktop/stories/module-hubs.stories.tsx
index 482dae04ce..762c0bef7d 100644
--- a/apps/desktop/stories/module-hubs.stories.tsx
+++ b/apps/desktop/stories/module-hubs.stories.tsx
@@ -35,8 +35,17 @@ import {
} from '@maka/ui';
import { type ComponentProps, type ReactNode, useState } from 'react';
import { WorkbarTitlebarActions } from '../src/renderer/features/workbar';
-import { ModuleHubHost } from '../src/renderer/features/module-hub/index';
-import { createFakeModuleHubHostModel } from '../src/renderer/features/module-hub/testing';
+import {
+ createModuleHubCommandPort,
+ ModuleHubHost,
+ ModuleHubHostView,
+ ModuleHubProvider,
+ ModuleHubServicesProvider,
+} from '../src/renderer/features/module-hub';
+import {
+ createFakeModuleHubHostModel,
+ createFakeModuleHubServices,
+} from '../src/renderer/features/module-hub/testing';
import { AppShellDetailPanel } from '../src/renderer/app-shell-detail-panel';
import { McpPage } from '../src/renderer/mcp-page';
import { withScopedMakaBridge } from './maka-bridge';
@@ -812,7 +821,38 @@ function ModuleHubHostSurface(props: {
: 'cron';
return (
-
+
+
+ );
+}
+
+function ProductionModuleHubHostSurface() {
+ const [commandPort] = useState(createModuleHubCommandPort);
+ const [services] = useState(() => {
+ const defaults = createFakeModuleHubServices();
+ return createFakeModuleHubServices({
+ skills: {
+ ...defaults.skills,
+ list: async () => INSTALLED_SKILLS,
+ listBundledCatalog: async () => BUNDLED_SKILLS,
+ },
+ });
+ });
+ return (
+
+
+ undefined}
+ commandPort={commandPort}
+ >
+
+
+
);
}
@@ -854,15 +894,12 @@ export const ExtensionsSkillsEmpty: Story = {
render: () => ,
};
-// Feature-slice composition coverage: the production Host, not a direct leaf.
+// Full production composition: public Provider → Context → public Host.
export const HostExtensionsSkills: Story = {
- render: () => (
-
- ),
+ render: () => ,
};
+// Focused view seams keep the other route variants deterministic.
export const HostExtensionsMcp: Story = {
decorators: [withEmptyMcpBridge],
render: () => (
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 14992542f9..24bec0574e 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)
@@ -49,6 +49,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/features/goals/ui/goal-provider.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/module-hub/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `apps/desktop/src/renderer/features/module-hub/ui/module-hub-provider.tsx` | other | 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 |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index e256ca9acb..b067e57bd8 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -20,6 +20,7 @@ apps/desktop/src/renderer/features/goals/ui/goal-host.tsx
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/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
diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs
index a7d81339ce..bc56032921 100644
--- a/scripts/check-app-shell-hooks.mjs
+++ b/scripts/check-app-shell-hooks.mjs
@@ -121,7 +121,6 @@ export const ALLOWED = {
useEffect: 14,
useKeyboardHelp: 1,
useLayoutEffect: 2,
- useModuleHubController: 1,
useNewTaskChoice: 1,
useOnboardingSnapshot: 1,
usePlanModeState: 1,