- {navSelection.section === 'extensions' && navSelection.module === 'skills' ? (
-
refreshSkills()}
- onRefreshManagedSkillSources={() => refreshManagedSkillSources()}
- onOpenSkill={projectCapabilities.viewClientPath
- ? (skillId) => openSkill(skillId)
- : undefined}
- onUseSkill={useSkillInChat}
- onOpenSkillsFolder={projectCapabilities.viewClientPath
- ? () => openSkillsFolder()
- : undefined}
- managedSkillSources={managedSkillSources}
- onImportManagedSkillSource={projectCapabilities.viewClientPath
- ? () => importManagedSkillSource()
- : undefined}
- onInstallManagedSkill={(sourceId) => installManagedSkill(sourceId)}
- bundledSkillCatalog={bundledSkillCatalog}
- onRefreshBundledSkillCatalog={() => refreshBundledSkillCatalog()}
- onInstallBundledSkill={(id) => installBundledSkill(id)}
- onPreviewManagedSkillUpdate={(skillId) => previewManagedSkillUpdate(skillId)}
- onUpdateManagedSkill={(skillId, options) => updateManagedSkill(skillId, options)}
- onSetSkillEnabled={(skillId, enabled) => setSkillEnabled(skillId, enabled)}
- onSetSkillPinned={(skillRef, pinned) => setSkillPinned(skillRef, pinned)}
- onDeleteSkill={(skillRef) => deleteSkill(skillRef)}
- />
- ) : navSelection.section === 'extensions' && navSelection.module === 'mcp' ? (
-
- ) : navSelection.section === 'automations' && navSelection.module === 'scheduled-tasks' ? (
- setScheduledTaskCreateRequestNonce(0)}
- keepSystemAwake={
- keepSystemAwakeController.supported
- ? keepSystemAwakeController.keepSystemAwake
- : undefined
- }
- onKeepSystemAwakeChange={
- keepSystemAwakeController.supported
- ? keepSystemAwakeController.setKeepSystemAwake
- : undefined
- }
- onRefresh={() =>
- refreshScheduledTasks({
- shouldShowError: isScheduledTasksSurfaceActive,
- })
- }
- onCreate={(input) => createScheduledTask(input)}
- onUpdate={(id, patch) => updateScheduledTask(id, patch)}
- onToggle={(id, enabled) => toggleScheduledTask(id, enabled)}
- onTriggerNow={(id) => triggerScheduledTaskNow(id)}
- onSnooze={(id) => snoozeScheduledTask(id)}
- onClearRunHistory={(id) => clearScheduledTaskRunHistory(id)}
- onDelete={(id) => deleteScheduledTask(id)}
- />
- ) : navSelection.section === 'automations' && navSelection.module === 'daily-review' ? (
- copyDailyReviewMarkdown(input, { shouldShowFeedback: isDailyReviewSurfaceActive })}
- onAppendMarkdown={appendDailyReviewMarkdown}
- onSaveMarkdown={(input) => saveDailyReviewMarkdown(input, { shouldShowFeedback: isDailyReviewSurfaceActive })}
- />
- ) : null}
+
{workHubEnabled && workHubActive && navSelection.section === 'sessions' ? (
+
+# Module Hub feature slice
+
+`module-hub` owns the Desktop renderer behavior behind Extensions and
+Automations:
+
+- installed, managed-source, and bundled-catalog Skills projections and
+ mutations;
+- Scheduled Tasks projection, mutations, due/change subscriptions, and the
+ create-dialog request nonce;
+- the keep-system-awake client setting shown by Scheduled Tasks;
+- Daily Review page bridge, page actions, and Command Palette commands;
+- selection and header composition for Skills, MCP, Scheduled Tasks, and Daily
+ Review.
+
+`AppShell` still owns top-level `NavSelection`, module-memory persistence,
+Session/Project navigation, and the Composer. Those capabilities cross the
+boundary only as intents. The feature exposes a read-only Scheduled Tasks
+projection to the Session rail and a revision number that invalidates the
+Composer's separate Runtime-owned invocable-Skills projection; neither makes
+the Shell an owner of Module Hub data.
+
+## Dependency direction
+
+Production consumers import only `features/module-hub/index`. Tests and
+Storybook may import `features/module-hub/testing`. The feature imports core/UI
+types and renderer-neutral locale/formatting helpers, but it must not import
+AppShell, preload, or main-process modules and must not access `window.maka` or
+`navigator`.
+
+All environment I/O is represented by `ModuleHubServices` and mapped once by
+`platform/desktop/create-module-hub-services.ts`. The adapter is also where an
+older preload is converted into an unsupported keep-awake capability.
+
+MCP is the explicit exception to I/O ownership in this slice. `McpPage` keeps
+its existing page-owned controller and direct Desktop bridge. `ModuleHubHost`
+only selects and mounts that leaf; moving MCP internals is a separate change.
+
+## Lifecycle invariants
+
+- The three Skills projections and Scheduled Tasks each have independent
+ generation fences.
+- Host-scoped reads re-check the current default Runtime Host before committing.
+ Late reads, mutation feedback, and diagnostics from an old Host are dropped.
+- Initial Skills and Scheduled Tasks refresh remains deferred to the first
+ animation frame. A ready default-Host change refreshes both clusters.
+- Scheduled Task change and due subscriptions are disposed with the controller;
+ due notifications retain the navigation action.
+- Mutation feedback is live-surface fenced. Confirmation continuations are
+ also abandoned after leaving Scheduled Tasks.
+- Keep-awake reads, external updates, and writes share a generation so a slow
+ completion cannot overwrite newer confirmed settings; failed writes still
+ reject for the panel's optimistic revert.
+- The Daily Review page bridge is stable for one services/locale pair. Page
+ feedback is live-surface fenced, while Command Palette commands remain usable
+ off-page.
+- Daily Review paste captures the active Composer before its first await and
+ validates the Session, navigation owner, and Composer handle before append and
+ feedback.
+- Opening Scheduled Task creation selects the page and increments the request
+ nonce; the page acknowledgement resets it to zero.
+
+There is intentionally no feature-level reducer or store: these projections and
+commands have real lifecycle ownership, while navigation persistence remains a
+Shell concern.
diff --git a/apps/desktop/src/renderer/features/module-hub/controller/default-runtime-host.ts b/apps/desktop/src/renderer/features/module-hub/controller/default-runtime-host.ts
new file mode 100644
index 0000000000..a5af5bde95
--- /dev/null
+++ b/apps/desktop/src/renderer/features/module-hub/controller/default-runtime-host.ts
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type {
+ ModuleHubRuntimeHostRef,
+ ModuleHubRuntimeHostsService,
+} from '../ports.js';
+
+export type ModuleHubDiagnosticTarget = { readonly profileId: string };
+
+class ModuleHubDefaultRuntimeHostOperationError extends Error {
+ readonly diagnosticTarget: ModuleHubDiagnosticTarget;
+ readonly host: ModuleHubRuntimeHostRef;
+
+ constructor(
+ cause: unknown,
+ host: ModuleHubRuntimeHostRef,
+ diagnosticTarget: ModuleHubDiagnosticTarget,
+ ) {
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
+ this.name = cause instanceof Error ? cause.name : 'Error';
+ this.host = host;
+ this.diagnosticTarget = diagnosticTarget;
+ }
+}
+
+export async function runOnDefaultRuntimeHost(
+ runtimeHosts: ModuleHubRuntimeHostsService,
+ operation: (host: ModuleHubRuntimeHostRef) => Promise,
+): Promise<{
+ readonly value: T;
+ readonly host: ModuleHubRuntimeHostRef;
+ readonly diagnosticTarget: ModuleHubDiagnosticTarget;
+}> {
+ const host = await runtimeHosts.getDefault();
+ const diagnosticTarget = { profileId: host.profileId };
+ try {
+ return { value: await operation(host), host, diagnosticTarget };
+ } catch (error) {
+ throw new ModuleHubDefaultRuntimeHostOperationError(
+ error,
+ host,
+ diagnosticTarget,
+ );
+ }
+}
+
+export async function isDefaultRuntimeHostCurrent(
+ runtimeHosts: ModuleHubRuntimeHostsService,
+ host: ModuleHubRuntimeHostRef,
+): Promise {
+ try {
+ const currentHost = await runtimeHosts.getDefault();
+ return (
+ currentHost.profileId === host.profileId &&
+ currentHost.hostId === host.hostId
+ );
+ } catch {
+ return false;
+ }
+}
+
+export async function runIfDefaultRuntimeHostCurrent(
+ runtimeHosts: ModuleHubRuntimeHostsService,
+ host: ModuleHubRuntimeHostRef,
+ operation: () => unknown | Promise,
+): Promise {
+ if (!(await isDefaultRuntimeHostCurrent(runtimeHosts, host))) return false;
+ await operation();
+ return true;
+}
+
+export function defaultRuntimeHostDiagnosticTarget(
+ error: unknown,
+): ModuleHubDiagnosticTarget | undefined {
+ return error instanceof ModuleHubDefaultRuntimeHostOperationError
+ ? error.diagnosticTarget
+ : undefined;
+}
+
+export function defaultRuntimeHostOperationHost(
+ error: unknown,
+): ModuleHubRuntimeHostRef | undefined {
+ return error instanceof ModuleHubDefaultRuntimeHostOperationError
+ ? error.host
+ : undefined;
+}
diff --git a/apps/desktop/src/renderer/features/module-hub/controller/module-hub-lifecycle.ts b/apps/desktop/src/renderer/features/module-hub/controller/module-hub-lifecycle.ts
new file mode 100644
index 0000000000..fecb5b4075
--- /dev/null
+++ b/apps/desktop/src/renderer/features/module-hub/controller/module-hub-lifecycle.ts
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { ModuleHubRuntimeHostsService } from '../ports.js';
+
+export interface ModuleHubLifecycleScheduler {
+ requestFrame(callback: FrameRequestCallback): number;
+ cancelFrame(handle: number): void;
+}
+
+const browserScheduler: ModuleHubLifecycleScheduler = {
+ requestFrame: (callback) => requestAnimationFrame(callback),
+ cancelFrame: (handle) => cancelAnimationFrame(handle),
+};
+
+/** Owns Module Hub startup deferral and default Runtime Host invalidation. */
+export function startModuleHubLifecycle(input: {
+ runtimeHosts: ModuleHubRuntimeHostsService;
+ refreshProjectSkills(): void;
+ refreshScheduledTasks(): void;
+ scheduler?: ModuleHubLifecycleScheduler;
+}): () => void {
+ const scheduler = input.scheduler ?? browserScheduler;
+ const refreshAll = () => {
+ input.refreshProjectSkills();
+ input.refreshScheduledTasks();
+ };
+ const startupFrame = scheduler.requestFrame(refreshAll);
+ const unsubscribeRuntimeHosts = input.runtimeHosts.subscribeChanges((event) => {
+ if (event.readiness !== 'ready' || !event.isDefault) return;
+ refreshAll();
+ });
+ return () => {
+ scheduler.cancelFrame(startupFrame);
+ unsubscribeRuntimeHosts();
+ };
+}
diff --git a/apps/desktop/src/renderer/features/module-hub/controller/module-hub-route.ts b/apps/desktop/src/renderer/features/module-hub/controller/module-hub-route.ts
new file mode 100644
index 0000000000..156a0e139b
--- /dev/null
+++ b/apps/desktop/src/renderer/features/module-hub/controller/module-hub-route.ts
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { NavSelection } from '@maka/ui';
+
+export type ModuleHubHostRoute =
+ | 'skills'
+ | 'mcp'
+ | 'scheduled-tasks'
+ | 'daily-review'
+ | null;
+
+export function resolveModuleHubHostRoute(
+ selection: NavSelection,
+): ModuleHubHostRoute {
+ if (selection.section === 'extensions') return selection.module;
+ if (selection.section === 'automations') return selection.module;
+ return null;
+}
diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-daily-review-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-daily-review-controller.ts
new file mode 100644
index 0000000000..64b68812c9
--- /dev/null
+++ b/apps/desktop/src/renderer/features/module-hub/controller/use-daily-review-controller.ts
@@ -0,0 +1,448 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useMemo, useRef } from 'react';
+import type {
+ DailyReviewArchive,
+ DailyReviewArchiveSummary,
+ DailyReviewRange,
+ DailyReviewSummary,
+} from '@maka/core/daily-review';
+import type { UiLocale } from '@maka/core/ui-locale';
+import {
+ formatDailyReviewMarkdown,
+ type DailyReviewMarkdownActionInput,
+ useMountedRef,
+} from '@maka/ui';
+import {
+ dailyReviewActionErrorMessage,
+ dailyReviewExportDefaultName,
+} from '../../../daily-review-actions.js';
+import { getShellCopy } from '../../../locales/shell-copy.js';
+import { getShellRemainingCopy } from '../../../locales/shell-remaining-copy.js';
+import type { ModuleHubRuntimeHostRef, ModuleHubServices } from '../ports.js';
+import {
+ defaultRuntimeHostDiagnosticTarget,
+ defaultRuntimeHostOperationHost,
+ isDefaultRuntimeHostCurrent,
+ runOnDefaultRuntimeHost,
+} from './default-runtime-host.js';
+
+type DailyReviewFeedbackOptions = {
+ readonly shouldShowFeedback?: () => boolean;
+};
+
+export interface ModuleHubToastApi {
+ success(title: string, description?: string): void;
+ error(
+ title: string,
+ description?: string,
+ diagnosticDetails?: string,
+ diagnosticTarget?: { sessionId: string } | { profileId: string },
+ ): void;
+}
+
+export interface ActiveComposerClaim {
+ /** True only while the same Session, navigation owner and composer still own this claim. */
+ isCurrent(): boolean;
+ /** Appends to the composer that was captured with the claim. */
+ append(text: string): void;
+}
+
+/** Structural equivalent of the UI bridge; kept here so @maka/ui stays leaf-only. */
+export interface DailyReviewBridge {
+ fetchDay(offsetDays: number, daySpan?: number): Promise;
+ runOnce?(input: {
+ range: DailyReviewRange;
+ offsetDays?: number;
+ }): Promise<{ archiveId: string }>;
+ listArchives?(): Promise;
+ getArchive?(archiveId: string): Promise;
+}
+
+export interface DailyReviewController {
+ readonly bridge: DailyReviewBridge;
+ copyMarkdown(
+ input: DailyReviewMarkdownActionInput,
+ options?: DailyReviewFeedbackOptions,
+ ): Promise;
+ appendMarkdown(input: DailyReviewMarkdownActionInput): void;
+ saveMarkdown(
+ input: DailyReviewMarkdownActionInput,
+ options?: DailyReviewFeedbackOptions,
+ ): Promise;
+ copyToday(): Promise;
+ pasteToday(): Promise;
+ saveToday(): Promise;
+}
+
+export interface UseDailyReviewControllerInput {
+ readonly services: ModuleHubServices;
+ readonly uiLocale: UiLocale;
+ readonly toastApi: ModuleHubToastApi;
+ readonly appendComposerText: (text: string) => void;
+ readonly captureActiveComposerClaim: () => ActiveComposerClaim | undefined;
+ readonly isDailyReviewSurfaceActive: () => boolean;
+}
+
+class StaleDailyReviewHostError extends Error {
+ constructor() {
+ super('The default Runtime Host changed while loading Daily Review');
+ this.name = 'StaleDailyReviewHostError';
+ }
+}
+
+async function operationFailureIsCurrent(
+ services: ModuleHubServices,
+ error: unknown,
+): Promise {
+ if (error instanceof StaleDailyReviewHostError) return false;
+ const host = defaultRuntimeHostOperationHost(error);
+ return host ? isDefaultRuntimeHostCurrent(services.runtimeHosts, host) : true;
+}
+
+/**
+ * Runs a Daily Review read against the default Host and refuses to expose a
+ * result after that Host changes. One retry absorbs the normal profile-switch
+ * race without making the page bridge identity depend on Host state.
+ */
+async function readCurrentDefaultHost(
+ services: ModuleHubServices,
+ operation: (host: ModuleHubRuntimeHostRef) => Promise,
+): Promise {
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ try {
+ const result = await runOnDefaultRuntimeHost(
+ services.runtimeHosts,
+ operation,
+ );
+ if (
+ await isDefaultRuntimeHostCurrent(services.runtimeHosts, result.host)
+ ) {
+ return result.value;
+ }
+ } catch (error) {
+ if (await operationFailureIsCurrent(services, error)) throw error;
+ }
+ }
+ throw new StaleDailyReviewHostError();
+}
+
+export function createDailyReviewBridge(
+ services: ModuleHubServices,
+ locale: UiLocale,
+): DailyReviewBridge {
+ const copy = getShellRemainingCopy(locale).dailyReview;
+ return {
+ async fetchDay(offsetDays: number, daySpan?: number) {
+ return readCurrentDefaultHost(services, async (host) => {
+ const result = await services.dailyReview.day(
+ offsetDays,
+ daySpan,
+ host,
+ );
+ if (!result.ok) throw new Error(result.error.message);
+ return result.data;
+ });
+ },
+ runOnce(input) {
+ return services.dailyReview.runOnce(input);
+ },
+ listArchives() {
+ return services.dailyReview.listArchives();
+ },
+ async getArchive(archiveId: string) {
+ const archive = await services.dailyReview.getArchive(archiveId);
+ if (!archive) throw new Error(copy.archiveMissing);
+ return archive;
+ },
+ };
+}
+
+export function useDailyReviewController(
+ input: UseDailyReviewControllerInput,
+): DailyReviewController {
+ const mountedRef = useMountedRef();
+ const inputRef = useRef(input);
+ inputRef.current = input;
+
+ const bridge = useMemo(
+ () => createDailyReviewBridge(input.services, input.uiLocale),
+ [input.services, input.uiLocale],
+ );
+
+ return useMemo(() => {
+ const copy = getShellCopy(input.uiLocale).commandActions;
+ const showIfMounted = (predicate: () => boolean = () => true) =>
+ mountedRef.current && predicate();
+ const shouldReportOperationFailure = async (
+ error: unknown,
+ predicate: () => boolean = () => true,
+ ) => {
+ if (!showIfMounted(predicate)) return false;
+ if (!(await operationFailureIsCurrent(input.services, error)))
+ return false;
+ return showIfMounted(predicate);
+ };
+
+ async function copyMarkdown(
+ markdownInput: DailyReviewMarkdownActionInput,
+ options: DailyReviewFeedbackOptions = {},
+ ) {
+ const shouldShowFeedback = options.shouldShowFeedback ?? (() => true);
+ const shouldShowPageFeedback = () =>
+ inputRef.current.isDailyReviewSurfaceActive() && shouldShowFeedback();
+ try {
+ await input.services.clipboard.writeText(markdownInput.markdown);
+ if (showIfMounted(shouldShowPageFeedback)) {
+ inputRef.current.toastApi.success(
+ copy.reviewCopied(markdownInput.label),
+ copy.reviewSummary(
+ markdownInput.totals.sessionCount,
+ markdownInput.totals.requestCount,
+ ),
+ );
+ }
+ } catch (error) {
+ if (showIfMounted(shouldShowPageFeedback)) {
+ inputRef.current.toastApi.error(
+ copy.copyFailedTitle,
+ dailyReviewActionErrorMessage(
+ error,
+ copy.clipboardDenied,
+ input.uiLocale,
+ ),
+ );
+ }
+ }
+ }
+
+ function appendMarkdown(markdownInput: DailyReviewMarkdownActionInput) {
+ inputRef.current.appendComposerText(markdownInput.markdown);
+ if (showIfMounted(inputRef.current.isDailyReviewSurfaceActive)) {
+ inputRef.current.toastApi.success(
+ copy.reviewPasted(markdownInput.label),
+ copy.reviewSummary(
+ markdownInput.totals.sessionCount,
+ markdownInput.totals.requestCount,
+ ),
+ );
+ }
+ }
+
+ async function persistMarkdown(
+ markdownInput: DailyReviewMarkdownActionInput,
+ shouldShowFeedback: () => boolean,
+ ) {
+ try {
+ const result = await input.services.dailyReview.saveMarkdownToFile({
+ markdown: markdownInput.markdown,
+ defaultName: dailyReviewExportDefaultName({
+ range: markdownInput.range,
+ day: markdownInput.day,
+ }),
+ });
+ if (!showIfMounted(shouldShowFeedback)) return;
+ if (result.ok) {
+ inputRef.current.toastApi.success(
+ copy.reviewSaved(markdownInput.label),
+ copy.reviewSummary(
+ markdownInput.totals.sessionCount,
+ markdownInput.totals.requestCount,
+ ),
+ );
+ } else if (result.reason === 'invalid_input') {
+ inputRef.current.toastApi.error(
+ copy.saveFailedTitle,
+ copy.invalidExport,
+ );
+ } else if (result.reason === 'write_failed') {
+ inputRef.current.toastApi.error(
+ copy.saveFailedTitle,
+ copy.writeFailed,
+ );
+ }
+ // A canceled save dialog deliberately has no feedback.
+ } catch (error) {
+ if (showIfMounted(shouldShowFeedback)) {
+ inputRef.current.toastApi.error(
+ copy.saveFailedTitle,
+ dailyReviewActionErrorMessage(
+ error,
+ copy.reviewSaveFallback,
+ input.uiLocale,
+ ),
+ );
+ }
+ }
+ }
+
+ async function saveMarkdown(
+ markdownInput: DailyReviewMarkdownActionInput,
+ options: DailyReviewFeedbackOptions = {},
+ ) {
+ const shouldShowFeedback = options.shouldShowFeedback ?? (() => true);
+ await persistMarkdown(
+ markdownInput,
+ () =>
+ inputRef.current.isDailyReviewSurfaceActive() && shouldShowFeedback(),
+ );
+ }
+
+ async function readToday() {
+ return readCurrentDefaultHost(input.services, async (host) => {
+ const result = await input.services.dailyReview.day(0, 1, host);
+ if (!result.ok) throw new Error(result.error.message);
+ return result.data;
+ });
+ }
+
+ async function copyToday() {
+ let summary;
+ try {
+ summary = await readToday();
+ } catch (error) {
+ if (await shouldReportOperationFailure(error)) {
+ inputRef.current.toastApi.error(
+ copy.copyFailedTitle,
+ dailyReviewActionErrorMessage(
+ error,
+ copy.reviewCopyFallback,
+ input.uiLocale,
+ ),
+ undefined,
+ defaultRuntimeHostDiagnosticTarget(error),
+ );
+ }
+ return;
+ }
+
+ try {
+ const markdown = formatDailyReviewMarkdown(
+ summary,
+ copy.today,
+ input.uiLocale,
+ );
+ await input.services.clipboard.writeText(markdown);
+ if (showIfMounted()) {
+ inputRef.current.toastApi.success(
+ copy.reviewCopiedTitle,
+ copy.reviewSummary(
+ summary.totals.sessionCount,
+ summary.totals.requestCount,
+ ),
+ );
+ }
+ } catch (error) {
+ if (showIfMounted()) {
+ inputRef.current.toastApi.error(
+ copy.copyFailedTitle,
+ dailyReviewActionErrorMessage(
+ error,
+ copy.clipboardDenied,
+ input.uiLocale,
+ ),
+ );
+ }
+ }
+ }
+
+ async function pasteToday() {
+ // Capture before the first await. The claim owns both the validity check
+ // and append target, so a Session/nav/composer switch cannot redirect a
+ // late Daily Review result into the new owner.
+ const claim = inputRef.current.captureActiveComposerClaim();
+ if (!claim) return;
+ try {
+ const summary = await readToday();
+ if (!claim.isCurrent() || !showIfMounted()) return;
+ claim.append(
+ formatDailyReviewMarkdown(summary, copy.today, input.uiLocale),
+ );
+ if (!claim.isCurrent() || !showIfMounted()) return;
+ inputRef.current.toastApi.success(
+ copy.reviewPastedTitle,
+ copy.reviewSummary(
+ summary.totals.sessionCount,
+ summary.totals.requestCount,
+ ),
+ );
+ } catch (error) {
+ if (await shouldReportOperationFailure(error, claim.isCurrent)) {
+ inputRef.current.toastApi.error(
+ copy.pasteFailedTitle,
+ dailyReviewActionErrorMessage(
+ error,
+ copy.reviewUnavailable,
+ input.uiLocale,
+ ),
+ undefined,
+ defaultRuntimeHostDiagnosticTarget(error),
+ );
+ }
+ }
+ }
+
+ async function saveToday() {
+ let summary;
+ try {
+ summary = await readToday();
+ } catch (error) {
+ if (await shouldReportOperationFailure(error)) {
+ inputRef.current.toastApi.error(
+ copy.saveFailedTitle,
+ dailyReviewActionErrorMessage(
+ error,
+ copy.reviewUnavailable,
+ input.uiLocale,
+ ),
+ undefined,
+ defaultRuntimeHostDiagnosticTarget(error),
+ );
+ }
+ return;
+ }
+ const markdown = formatDailyReviewMarkdown(
+ summary,
+ copy.today,
+ input.uiLocale,
+ );
+ await persistMarkdown(
+ {
+ day: summary.day,
+ range: 1,
+ totals: summary.totals,
+ markdown,
+ label: copy.today,
+ },
+ () => true,
+ );
+ }
+
+ return {
+ bridge,
+ copyMarkdown,
+ appendMarkdown,
+ saveMarkdown,
+ copyToday,
+ pasteToday,
+ saveToday,
+ };
+ }, [bridge, input.services, input.uiLocale, mountedRef]);
+}
diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-keep-system-awake-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-keep-system-awake-controller.ts
new file mode 100644
index 0000000000..035179a030
--- /dev/null
+++ b/apps/desktop/src/renderer/features/module-hub/controller/use-keep-system-awake-controller.ts
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useMountedRef } from '@maka/ui';
+import type { ModuleHubServices } from '../ports.js';
+
+export interface KeepSystemAwakeController {
+ /** False for a non-Electron surface or older preload without this bridge. */
+ readonly supported: boolean;
+ /** Last confirmed persisted value; undefined until the first read settles. */
+ readonly keepSystemAwake: boolean | undefined;
+ /** Rejects on persistence failure so the Scheduled Tasks panel can revert. */
+ setKeepSystemAwake(next: boolean): Promise;
+}
+
+/**
+ * Owns the Desktop client setting surfaced by Scheduled Tasks. The UI owns its
+ * short-lived optimistic checkbox; this hook owns the confirmed snapshot and
+ * makes rejected writes observable so that UI can revert.
+ */
+export function useKeepSystemAwakeController(
+ services: ModuleHubServices,
+): KeepSystemAwakeController {
+ const settings = services.clientSettings;
+ const supported = settings.supported;
+ const [keepSystemAwake, setSnapshot] = useState();
+ const mountedRef = useMountedRef();
+ // Reads, writes and external-change refreshes share a generation. A slow
+ // older completion cannot overwrite the newest confirmed client setting.
+ const generationRef = useRef(0);
+
+ const refresh = useCallback(async () => {
+ if (!supported) return;
+ const generation = ++generationRef.current;
+ try {
+ const next = await settings.getKeepSystemAwake();
+ if (mountedRef.current && generation === generationRef.current) {
+ setSnapshot(next);
+ }
+ } catch {
+ // `false` is the persisted default and the only safe fallback. Preserve
+ // a snapshot that was already confirmed by an earlier successful read.
+ if (mountedRef.current && generation === generationRef.current) {
+ setSnapshot((previous) => previous ?? false);
+ }
+ }
+ }, [mountedRef, settings, supported]);
+
+ useEffect(() => {
+ if (!supported) return;
+ void refresh();
+ const dispose = settings.subscribeChanges(() => {
+ void refresh();
+ });
+ return () => {
+ generationRef.current += 1;
+ dispose();
+ };
+ }, [refresh, settings, supported]);
+
+ const setKeepSystemAwake = useCallback(
+ async (next: boolean) => {
+ if (!supported) throw new Error('Keep-system-awake settings are unavailable');
+ const generation = ++generationRef.current;
+ // Do not catch: the Scheduled Tasks panel needs the rejection to revert
+ // its optimistic checkbox and surface the existing localized toast.
+ const confirmed = await settings.setKeepSystemAwake(next);
+ if (mountedRef.current && generation === generationRef.current) {
+ setSnapshot(confirmed);
+ }
+ },
+ [mountedRef, settings, supported],
+ );
+
+ return { supported, keepSystemAwake, setKeepSystemAwake };
+}
diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts
new file mode 100644
index 0000000000..8ebfce96b5
--- /dev/null
+++ b/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useEffect, useMemo, useRef } from 'react';
+import type { ScheduledTask } from '@maka/core/scheduled-task';
+import type { NavSelection } from '@maka/ui';
+import { useToast, useUiLocale } from '@maka/ui';
+import { useModuleHubServices } from '../services-context.js';
+import { startModuleHubLifecycle } from './module-hub-lifecycle.js';
+import {
+ useDailyReviewController,
+ type ActiveComposerClaim,
+ type DailyReviewController,
+} from './use-daily-review-controller.js';
+import {
+ useKeepSystemAwakeController,
+ type KeepSystemAwakeController,
+} from './use-keep-system-awake-controller.js';
+import {
+ useScheduledTasksController,
+ type ScheduledTasksController,
+} from './use-scheduled-tasks-controller.js';
+import {
+ useSkillsController,
+ type SkillsHostModel,
+} from './use-skills-controller.js';
+
+export interface ModuleHubHostModel {
+ readonly selection: NavSelection;
+ readonly selectModule: (selection: NavSelection) => void;
+ readonly skills: SkillsHostModel;
+ readonly scheduledTasks: ScheduledTasksController;
+ readonly keepSystemAwake: KeepSystemAwakeController;
+ readonly dailyReview: DailyReviewController;
+ readonly openSession: (sessionId: string) => void;
+}
+
+export interface ModuleHubController {
+ readonly host: ModuleHubHostModel;
+ readonly commands: {
+ refreshProjectSkills(): Promise;
+ openScheduledTaskCreate(): void;
+ copyTodayDailyReview(): Promise;
+ pasteTodayDailyReview(): Promise;
+ saveTodayDailyReview(): Promise;
+ };
+ readonly selectors: {
+ readonly scheduledTasks: readonly ScheduledTask[];
+ /** Invalidates the composer's Runtime-owned invocable Skills projection. */
+ readonly skillCatalogRevision: number;
+ };
+}
+
+export interface UseModuleHubControllerInput {
+ readonly selection: NavSelection;
+ readonly selectModule: (selection: NavSelection) => void;
+ readonly openSkillsFolder?: () => void | Promise;
+ readonly useSkillInChat: (skillId: string, skillName: string) => void;
+ readonly openSession: (sessionId: string) => void;
+ readonly appendComposerText: (text: string) => void;
+ readonly captureActiveComposerClaim: () => ActiveComposerClaim | undefined;
+}
+
+/** Public ownership boundary for every Module Hub surface except the MCP leaf. */
+export function useModuleHubController(
+ input: UseModuleHubControllerInput,
+): ModuleHubController {
+ const services = useModuleHubServices();
+ const uiLocale = useUiLocale();
+ const toastApi = useToast();
+ const isSkillsActive =
+ input.selection.section === 'extensions' &&
+ input.selection.module === 'skills';
+ const skills = useSkillsController({
+ uiLocale,
+ active: isSkillsActive,
+ toastApi,
+ useSkillInChat: input.useSkillInChat,
+ openSkillsFolder: input.openSkillsFolder,
+ });
+ const scheduledTasks = useScheduledTasksController({
+ uiLocale,
+ toastApi,
+ selection: input.selection,
+ selectModule: input.selectModule,
+ });
+ const keepSystemAwake = useKeepSystemAwakeController(services);
+ const selectionRef = useRef(input.selection);
+ selectionRef.current = input.selection;
+ const dailyReview = useDailyReviewController({
+ services,
+ uiLocale,
+ toastApi,
+ appendComposerText: input.appendComposerText,
+ captureActiveComposerClaim: input.captureActiveComposerClaim,
+ isDailyReviewSurfaceActive: () =>
+ selectionRef.current.section === 'automations' &&
+ selectionRef.current.module === 'daily-review',
+ });
+
+ const refreshProjectSkillsRef = useRef(skills.refreshProjectSkills);
+ const refreshScheduledTasksRef = useRef(scheduledTasks.refresh);
+ refreshProjectSkillsRef.current = skills.refreshProjectSkills;
+ refreshScheduledTasksRef.current = scheduledTasks.refresh;
+
+ useEffect(() => {
+ return startModuleHubLifecycle({
+ runtimeHosts: services.runtimeHosts,
+ refreshProjectSkills: () => void refreshProjectSkillsRef.current(),
+ refreshScheduledTasks: () => void refreshScheduledTasksRef.current(),
+ });
+ }, [services.runtimeHosts]);
+
+ return useMemo(
+ () => ({
+ host: {
+ selection: input.selection,
+ selectModule: input.selectModule,
+ skills: skills.host,
+ scheduledTasks,
+ keepSystemAwake,
+ dailyReview,
+ openSession: input.openSession,
+ },
+ commands: {
+ refreshProjectSkills: skills.refreshProjectSkills,
+ openScheduledTaskCreate: scheduledTasks.openCreate,
+ copyTodayDailyReview: dailyReview.copyToday,
+ pasteTodayDailyReview: dailyReview.pasteToday,
+ saveTodayDailyReview: dailyReview.saveToday,
+ },
+ selectors: {
+ scheduledTasks: scheduledTasks.scheduledTasks,
+ skillCatalogRevision: skills.revision,
+ },
+ }),
+ [
+ dailyReview,
+ input.openSession,
+ input.selectModule,
+ input.selection,
+ keepSystemAwake,
+ scheduledTasks,
+ skills.host,
+ skills.refreshProjectSkills,
+ skills.revision,
+ ],
+ );
+}
diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-scheduled-tasks-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-scheduled-tasks-controller.ts
new file mode 100644
index 0000000000..7c3c71fbb8
--- /dev/null
+++ b/apps/desktop/src/renderer/features/module-hub/controller/use-scheduled-tasks-controller.ts
@@ -0,0 +1,331 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useEffect, useRef, useState } from 'react';
+import type {
+ ScheduledTask,
+ UpdateScheduledTaskInput,
+} from '@maka/core/scheduled-task';
+import type { UiLocale } from '@maka/core/ui-locale';
+import type { NavSelection, ToastApi } from '@maka/ui';
+import { useMountedRef } from '@maka/ui';
+import { localizedShellErrorMessage } from '../../../locales/shell-copy.js';
+import { getShellRemainingCopy } from '../../../locales/shell-remaining-copy.js';
+import type { ScheduledTaskCreateInput } from '../ports.js';
+import { useModuleHubServices } from '../services-context.js';
+import {
+ defaultRuntimeHostDiagnosticTarget,
+ defaultRuntimeHostOperationHost,
+ isDefaultRuntimeHostCurrent,
+ runIfDefaultRuntimeHostCurrent,
+ runOnDefaultRuntimeHost,
+} from './default-runtime-host.js';
+
+export interface ScheduledTasksController {
+ readonly scheduledTasks: ScheduledTask[];
+ readonly createRequestNonce: number;
+ openCreate(): void;
+ handleCreateRequest(): void;
+ refresh(options?: { shouldShowError?: () => boolean }): Promise;
+ refreshSurface(): Promise;
+ create(input: ScheduledTaskCreateInput): Promise;
+ update(id: string, patch: UpdateScheduledTaskInput): Promise;
+ toggle(id: string, enabled: boolean): Promise;
+ triggerNow(id: string): Promise;
+ snooze(id: string): Promise;
+ clearRunHistory(id: string): Promise;
+ delete(id: string): Promise;
+}
+
+export type ScheduledTasksToastApi = Pick<
+ ToastApi,
+ 'confirm' | 'error' | 'success' | 'toast'
+>;
+
+export function useScheduledTasksController(options: {
+ uiLocale: UiLocale;
+ toastApi: ScheduledTasksToastApi;
+ selection: NavSelection;
+ selectModule(selection: NavSelection): void;
+}): ScheduledTasksController {
+ const services = useModuleHubServices();
+ const uiLocale = options.uiLocale;
+ const toastApi = options.toastApi;
+ const mountedRef = useMountedRef();
+ const copy = getShellRemainingCopy(uiLocale).scheduledTaskActions;
+ const notificationsCopy = getShellRemainingCopy(uiLocale).notifications;
+ const [scheduledTasks, setScheduledTasks] = useState([]);
+ const [createRequestNonce, setCreateRequestNonce] = useState(0);
+ const refreshGenerationRef = useRef(0);
+ const scheduledTasksRef = useRef(scheduledTasks);
+ const selectionRef = useRef(options.selection);
+ const selectModuleRef = useRef(options.selectModule);
+ const toastApiRef = useRef(toastApi);
+ const notificationsCopyRef = useRef(notificationsCopy);
+ const refreshRef = useRef<
+ (options?: { shouldShowError?: () => boolean }) => Promise
+ >(async () => {});
+ scheduledTasksRef.current = scheduledTasks;
+ selectionRef.current = options.selection;
+ selectModuleRef.current = options.selectModule;
+ toastApiRef.current = toastApi;
+ notificationsCopyRef.current = notificationsCopy;
+
+ const isSurfaceActive = () =>
+ mountedRef.current &&
+ selectionRef.current.section === 'automations' &&
+ selectionRef.current.module === 'scheduled-tasks';
+
+ async function refresh(
+ refreshOptions: { shouldShowError?: () => boolean } = {},
+ ): Promise {
+ const generation = ++refreshGenerationRef.current;
+ try {
+ const next = await runOnDefaultRuntimeHost(
+ services.runtimeHosts,
+ (host) => services.scheduledTasks.list(host),
+ );
+ await runIfDefaultRuntimeHostCurrent(
+ services.runtimeHosts,
+ next.host,
+ () => {
+ if (
+ mountedRef.current &&
+ generation === refreshGenerationRef.current
+ ) {
+ setScheduledTasks(next.value);
+ }
+ },
+ );
+ } catch (error) {
+ if (!mountedRef.current || generation !== refreshGenerationRef.current)
+ return;
+ const operationHost = defaultRuntimeHostOperationHost(error);
+ const hostIsCurrent = operationHost
+ ? await isDefaultRuntimeHostCurrent(
+ services.runtimeHosts,
+ operationHost,
+ )
+ : true;
+ if (
+ !mountedRef.current ||
+ generation !== refreshGenerationRef.current ||
+ !hostIsCurrent
+ )
+ return;
+ if (refreshOptions.shouldShowError?.() ?? true) {
+ toastApi.error(
+ copy.refreshFailed,
+ localizedShellErrorMessage(error, copy.refreshFallback, uiLocale),
+ undefined,
+ defaultRuntimeHostDiagnosticTarget(error),
+ );
+ }
+ }
+ }
+ refreshRef.current = refresh;
+
+ async function runMutation(mutation: {
+ run: Parameters[1];
+ successTitle?: string;
+ successDetail?: string;
+ errorTitle: string;
+ errorFallback: string;
+ errorMessage?: (error: unknown) => string | undefined;
+ }): Promise {
+ try {
+ const result = await runOnDefaultRuntimeHost(
+ services.runtimeHosts,
+ mutation.run,
+ );
+ if (!mountedRef.current) return false;
+ const hostIsCurrent = await isDefaultRuntimeHostCurrent(
+ services.runtimeHosts,
+ result.host,
+ );
+ if (!mountedRef.current || !hostIsCurrent) return false;
+ await refreshRef.current({ shouldShowError: isSurfaceActive });
+ const hostIsStillCurrent = await isDefaultRuntimeHostCurrent(
+ services.runtimeHosts,
+ result.host,
+ );
+ if (!mountedRef.current || !hostIsStillCurrent) return false;
+ if (mountedRef.current && mutation.successTitle && isSurfaceActive()) {
+ toastApi.success(mutation.successTitle, mutation.successDetail);
+ }
+ return true;
+ } catch (error) {
+ if (!mountedRef.current) return false;
+ const operationHost = defaultRuntimeHostOperationHost(error);
+ const hostIsCurrent = operationHost
+ ? await isDefaultRuntimeHostCurrent(
+ services.runtimeHosts,
+ operationHost,
+ )
+ : true;
+ if (!mountedRef.current || !hostIsCurrent) return false;
+ if (isSurfaceActive()) {
+ toastApi.error(
+ mutation.errorTitle,
+ mutation.errorMessage?.(error) ??
+ localizedShellErrorMessage(error, mutation.errorFallback, uiLocale),
+ undefined,
+ defaultRuntimeHostDiagnosticTarget(error),
+ );
+ }
+ return false;
+ }
+ }
+
+ useEffect(() => {
+ const unsubscribeChanges = services.scheduledTasks.subscribeChanges(() => {
+ void refreshRef.current();
+ });
+ const unsubscribeDue = services.scheduledTasks.subscribeDue((task) => {
+ void refreshRef.current();
+ const currentCopy = notificationsCopyRef.current;
+ toastApiRef.current.toast({
+ title: currentCopy.scheduledTask,
+ description: task.title,
+ variant: 'info',
+ duration: 8000,
+ action: {
+ label: currentCopy.viewScheduledTasks,
+ onClick: () =>
+ selectModuleRef.current({
+ section: 'automations',
+ module: 'scheduled-tasks',
+ }),
+ },
+ });
+ });
+ return () => {
+ unsubscribeChanges();
+ unsubscribeDue();
+ };
+ }, [services.scheduledTasks]);
+
+ return {
+ scheduledTasks,
+ createRequestNonce,
+ openCreate() {
+ selectModuleRef.current({
+ section: 'automations',
+ module: 'scheduled-tasks',
+ });
+ setCreateRequestNonce((current) => current + 1);
+ },
+ handleCreateRequest() {
+ setCreateRequestNonce(0);
+ },
+ refresh,
+ refreshSurface() {
+ return refresh({ shouldShowError: isSurfaceActive });
+ },
+ create(input) {
+ return runMutation({
+ run: (host) => services.scheduledTasks.create(input, host),
+ successTitle: copy.created,
+ successDetail: input.title,
+ errorTitle: copy.createFailed,
+ errorFallback: copy.createFallback,
+ errorMessage: (error) =>
+ errorText(error).includes('SCHEDULED_TASK_INCOGNITO_ACTIVE')
+ ? copy.createIncognitoBlocked
+ : undefined,
+ });
+ },
+ update(id, patch) {
+ return runMutation({
+ run: (host) => services.scheduledTasks.update(id, patch, host),
+ successTitle: copy.saved,
+ successDetail: patch.title,
+ errorTitle: copy.saveFailed,
+ errorFallback: copy.saveFallback,
+ });
+ },
+ async toggle(id, enabled) {
+ await runMutation({
+ run: (host) => services.scheduledTasks.setEnabled(id, enabled, host),
+ successTitle: enabled ? copy.enabled : copy.paused,
+ errorTitle: copy.updateFailed,
+ errorFallback: copy.updateFallback,
+ });
+ },
+ async triggerNow(id) {
+ const task = scheduledTasksRef.current.find((entry) => entry.id === id);
+ await runMutation({
+ run: (host) => services.scheduledTasks.triggerNow(id, host),
+ successTitle: copy.triggered,
+ successDetail: task?.title,
+ errorTitle: copy.triggerFailed,
+ errorFallback: copy.triggerFallback,
+ });
+ },
+ async snooze(id) {
+ const task = scheduledTasksRef.current.find((entry) => entry.id === id);
+ await runMutation({
+ run: (host) => services.scheduledTasks.snooze(id, host),
+ successTitle: copy.snoozed,
+ successDetail: task?.title,
+ errorTitle: copy.snoozeFailed,
+ errorFallback: copy.snoozeFallback,
+ });
+ },
+ async clearRunHistory(id) {
+ const task = scheduledTasksRef.current.find((entry) => entry.id === id);
+ const confirmed = await toastApi.confirm({
+ title: copy.clearTitle(task?.title ?? copy.task),
+ description: copy.clearDescription,
+ confirmLabel: copy.clear,
+ cancelLabel: copy.cancel,
+ destructive: true,
+ });
+ if (!confirmed || !isSurfaceActive()) return;
+ await runMutation({
+ run: (host) => services.scheduledTasks.clearRunHistory(id, host),
+ successTitle: copy.cleared,
+ successDetail: task?.title,
+ errorTitle: copy.clearFailed,
+ errorFallback: copy.clearFallback,
+ });
+ },
+ async delete(id) {
+ const task = scheduledTasksRef.current.find((entry) => entry.id === id);
+ const confirmed = await toastApi.confirm({
+ title: copy.deleteTitle(task?.title ?? copy.task),
+ description: copy.deleteDescription,
+ confirmLabel: copy.delete,
+ cancelLabel: copy.cancel,
+ destructive: true,
+ });
+ if (!confirmed || !isSurfaceActive()) return;
+ await runMutation({
+ run: (host) => services.scheduledTasks.delete(id, host),
+ successTitle: copy.deleted,
+ errorTitle: copy.deleteFailed,
+ errorFallback: copy.deleteFallback,
+ });
+ },
+ };
+}
+
+function errorText(error: unknown): string {
+ return error instanceof Error ? error.message : String(error ?? '');
+}
diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts
new file mode 100644
index 0000000000..3871f35609
--- /dev/null
+++ b/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts
@@ -0,0 +1,835 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type { UiLocale } from "@maka/core/ui-locale";
+import type {
+ BundledSkillCatalogEntry,
+ ManagedSkillSourceEntry,
+ ManagedSkillUpdatePreview,
+ SkillEntry,
+ ToastApi,
+} from "@maka/ui";
+import {
+ getShellCopy,
+ localizedShellErrorMessage,
+} from "../../../locales/shell-copy.js";
+import type { ModuleHubRuntimeHostRef } from "../ports.js";
+import { useModuleHubServices } from "../services-context.js";
+import {
+ defaultRuntimeHostDiagnosticTarget,
+ defaultRuntimeHostOperationHost,
+ isDefaultRuntimeHostCurrent,
+ runIfDefaultRuntimeHostCurrent,
+ runOnDefaultRuntimeHost,
+} from "./default-runtime-host.js";
+
+type SkillsToastApi = Pick;
+
+type RefreshOptions = {
+ shouldShowError?: () => boolean;
+};
+
+export interface SkillsHostModel {
+ skills: SkillEntry[];
+ managedSkillSources: ManagedSkillSourceEntry[];
+ bundledSkillCatalog: BundledSkillCatalogEntry[];
+ onRefreshSkills(): Promise;
+ onOpenSkill?: (skillId: string) => Promise;
+ onUseSkill(skillId: string, skillName: string): void;
+ onOpenSkillsFolder?: () => void | Promise;
+ onRefreshManagedSkillSources(): Promise;
+ onImportManagedSkillSource?: () => Promise;
+ onInstallManagedSkill(sourceId: string): Promise;
+ onRefreshBundledSkillCatalog(): Promise;
+ onInstallBundledSkill(id: string): Promise;
+ onPreviewManagedSkillUpdate(
+ skillId: string,
+ ): Promise;
+ onUpdateManagedSkill(
+ skillId: string,
+ options?: {
+ force?: boolean;
+ expectedCurrentSha256?: string;
+ expectedSourceSha256?: string;
+ },
+ ): Promise;
+ onSetSkillEnabled(skillId: string, enabled: boolean): Promise;
+ onSetSkillPinned(skillRef: string, pinned: boolean): Promise;
+ onDeleteSkill(skillRef: string): Promise;
+}
+
+export interface SkillsController {
+ host: SkillsHostModel;
+ /** Changes only when the current default Host's installed-Skills projection commits. */
+ readonly revision: number;
+ /** Refreshes every project-scoped Skills projection after a project change. */
+ refreshProjectSkills(): Promise;
+}
+
+export interface UseSkillsControllerInput {
+ uiLocale: UiLocale;
+ active: boolean;
+ toastApi: SkillsToastApi;
+ useSkillInChat(skillId: string, skillName: string): void;
+ openSkillsFolder?: () => void | Promise;
+}
+
+type SkillsProjection =
+ "skills" | "managedSkillSources" | "bundledSkillCatalog";
+
+/** Owns the three Skills projections, their Host fences, and every Skills mutation. */
+export function useSkillsController(
+ input: UseSkillsControllerInput,
+): SkillsController {
+ const services = useModuleHubServices();
+ const [skills, setSkills] = useState([]);
+ const [revision, setRevision] = useState(0);
+ const [managedSkillSources, setManagedSkillSources] = useState<
+ ManagedSkillSourceEntry[]
+ >([]);
+ const [bundledSkillCatalog, setBundledSkillCatalog] = useState<
+ BundledSkillCatalogEntry[]
+ >([]);
+ const generationsRef = useRef>({
+ skills: 0,
+ managedSkillSources: 0,
+ bundledSkillCatalog: 0,
+ });
+ const mountedRef = useRef(true);
+ const inputRef = useRef(input);
+ inputRef.current = input;
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ generationsRef.current.skills += 1;
+ generationsRef.current.managedSkillSources += 1;
+ generationsRef.current.bundledSkillCatalog += 1;
+ };
+ }, []);
+
+ const isSkillsSurfaceActive = useCallback(
+ () => mountedRef.current && inputRef.current.active,
+ [],
+ );
+
+ const isOperationHostCurrent = useCallback(
+ async (error: unknown): Promise => {
+ if (!mountedRef.current) return false;
+ const host = defaultRuntimeHostOperationHost(error);
+ if (!host) return mountedRef.current;
+ const current = await isDefaultRuntimeHostCurrent(
+ services.runtimeHosts,
+ host,
+ );
+ return mountedRef.current && current;
+ },
+ [services.runtimeHosts],
+ );
+
+ const shouldReportRefreshError = useCallback(
+ async (options: RefreshOptions, error: unknown): Promise => {
+ const shouldShowError = options.shouldShowError;
+ if (shouldShowError && !shouldShowError()) return false;
+ if (!(await isOperationHostCurrent(error))) return false;
+ return shouldShowError?.() ?? true;
+ },
+ [isOperationHostCurrent],
+ );
+
+ const reportRuntimeHostError = useCallback(
+ (title: string, fallback: string, error: unknown): void => {
+ const current = inputRef.current;
+ current.toastApi.error(
+ title,
+ localizedShellErrorMessage(error, fallback, current.uiLocale),
+ undefined,
+ defaultRuntimeHostDiagnosticTarget(error),
+ );
+ },
+ [],
+ );
+
+ const refreshSkills = useCallback(
+ async (options: RefreshOptions = {}): Promise => {
+ const generation = ++generationsRef.current.skills;
+ const copy = getShellCopy(inputRef.current.uiLocale).skillActions;
+ try {
+ const next = await runOnDefaultRuntimeHost(
+ services.runtimeHosts,
+ (host) => services.skills.list(host),
+ );
+ await runIfDefaultRuntimeHostCurrent(
+ services.runtimeHosts,
+ next.host,
+ () => {
+ if (
+ mountedRef.current &&
+ generation === generationsRef.current.skills
+ ) {
+ setSkills(next.value);
+ setRevision((current) => current + 1);
+ }
+ },
+ );
+ } catch (error) {
+ if (!mountedRef.current || generation !== generationsRef.current.skills)
+ return;
+ const shouldReport = await shouldReportRefreshError(options, error);
+ if (
+ mountedRef.current &&
+ generation === generationsRef.current.skills &&
+ shouldReport
+ ) {
+ reportRuntimeHostError(
+ copy.refreshSkillsFailedTitle,
+ copy.refreshSkillsFallback,
+ error,
+ );
+ }
+ }
+ },
+ [
+ isOperationHostCurrent,
+ reportRuntimeHostError,
+ services.runtimeHosts,
+ services.skills,
+ shouldReportRefreshError,
+ ],
+ );
+
+ const refreshManagedSkillSources = useCallback(
+ async (options: RefreshOptions = {}): Promise => {
+ const generation = ++generationsRef.current.managedSkillSources;
+ const copy = getShellCopy(inputRef.current.uiLocale).skillActions;
+ try {
+ const next = await runOnDefaultRuntimeHost(
+ services.runtimeHosts,
+ (host) => services.skills.listManagedSources(host),
+ );
+ await runIfDefaultRuntimeHostCurrent(
+ services.runtimeHosts,
+ next.host,
+ () => {
+ if (
+ mountedRef.current &&
+ generation === generationsRef.current.managedSkillSources
+ ) {
+ setManagedSkillSources(next.value);
+ }
+ },
+ );
+ } catch (error) {
+ if (
+ !mountedRef.current ||
+ generation !== generationsRef.current.managedSkillSources
+ ) {
+ return;
+ }
+ const shouldReport = await shouldReportRefreshError(options, error);
+ if (
+ mountedRef.current &&
+ generation === generationsRef.current.managedSkillSources &&
+ shouldReport
+ ) {
+ reportRuntimeHostError(
+ copy.refreshSourcesFailedTitle,
+ copy.refreshSourcesFallback,
+ error,
+ );
+ }
+ }
+ },
+ [
+ isOperationHostCurrent,
+ reportRuntimeHostError,
+ services.runtimeHosts,
+ services.skills,
+ shouldReportRefreshError,
+ ],
+ );
+
+ const refreshBundledSkillCatalog = useCallback(
+ async (options: RefreshOptions = {}): Promise => {
+ const generation = ++generationsRef.current.bundledSkillCatalog;
+ const copy = getShellCopy(inputRef.current.uiLocale).skillActions;
+ try {
+ const next = await runOnDefaultRuntimeHost(
+ services.runtimeHosts,
+ (host) => services.skills.listBundledCatalog(host),
+ );
+ await runIfDefaultRuntimeHostCurrent(
+ services.runtimeHosts,
+ next.host,
+ () => {
+ if (
+ mountedRef.current &&
+ generation === generationsRef.current.bundledSkillCatalog
+ ) {
+ setBundledSkillCatalog(next.value);
+ }
+ },
+ );
+ } catch (error) {
+ if (
+ !mountedRef.current ||
+ generation !== generationsRef.current.bundledSkillCatalog
+ ) {
+ return;
+ }
+ const shouldReport = await shouldReportRefreshError(options, error);
+ if (
+ mountedRef.current &&
+ generation === generationsRef.current.bundledSkillCatalog &&
+ shouldReport
+ ) {
+ reportRuntimeHostError(
+ copy.refreshBundledFailedTitle,
+ copy.refreshBundledFallback,
+ error,
+ );
+ }
+ }
+ },
+ [
+ isOperationHostCurrent,
+ reportRuntimeHostError,
+ services.runtimeHosts,
+ services.skills,
+ shouldReportRefreshError,
+ ],
+ );
+
+ const shouldReportMutation = useCallback(
+ async (host: ModuleHubRuntimeHostRef): Promise => {
+ if (!isSkillsSurfaceActive()) return false;
+ let activeAfterHostCheck = false;
+ const current = await runIfDefaultRuntimeHostCurrent(
+ services.runtimeHosts,
+ host,
+ () => {
+ activeAfterHostCheck = isSkillsSurfaceActive();
+ },
+ );
+ return current && activeAfterHostCheck;
+ },
+ [isSkillsSurfaceActive, services.runtimeHosts],
+ );
+
+ const shouldReportOperationError = useCallback(
+ async (error: unknown): Promise => {
+ if (!isSkillsSurfaceActive()) return false;
+ if (!(await isOperationHostCurrent(error))) return false;
+ return isSkillsSurfaceActive();
+ },
+ [isOperationHostCurrent, isSkillsSurfaceActive],
+ );
+
+ const importManagedSkillSource = useCallback(async (): Promise => {
+ const copy = getShellCopy(inputRef.current.uiLocale).skillActions;
+ try {
+ const next = await runOnDefaultRuntimeHost(
+ services.runtimeHosts,
+ (host) => services.skills.importManagedSource(host),
+ );
+ if (!next.value.ok) {
+ if (
+ next.value.reason !== "cancelled" &&
+ (await shouldReportMutation(next.host))
+ ) {
+ inputRef.current.toastApi.error(
+ copy.importSourceFailedTitle,
+ copy.sourceFailures[next.value.reason],
+ undefined,
+ next.diagnosticTarget,
+ );
+ }
+ return;
+ }
+ await refreshManagedSkillSources({
+ shouldShowError: isSkillsSurfaceActive,
+ });
+ if (await shouldReportMutation(next.host)) {
+ inputRef.current.toastApi.success(
+ copy.importedSourceTitle,
+ next.value.source.name,
+ );
+ }
+ } catch (error) {
+ if (await shouldReportOperationError(error)) {
+ reportRuntimeHostError(
+ copy.importSourceFailedTitle,
+ copy.importSourceFallback,
+ error,
+ );
+ }
+ }
+ }, [
+ isSkillsSurfaceActive,
+ refreshManagedSkillSources,
+ reportRuntimeHostError,
+ services.runtimeHosts,
+ services.skills,
+ shouldReportOperationError,
+ shouldReportMutation,
+ ]);
+
+ const installManagedSkill = useCallback(
+ async (sourceId: string): Promise => {
+ const copy = getShellCopy(inputRef.current.uiLocale).skillActions;
+ try {
+ const next = await runOnDefaultRuntimeHost(
+ services.runtimeHosts,
+ (host) => services.skills.installManaged(sourceId, host),
+ );
+ if (!next.value.ok) {
+ if (await shouldReportMutation(next.host)) {
+ inputRef.current.toastApi.error(
+ copy.installFailedTitle,
+ copy.installFailures[next.value.reason],
+ undefined,
+ next.diagnosticTarget,
+ );
+ }
+ return;
+ }
+ await refreshSkills({ shouldShowError: isSkillsSurfaceActive });
+ await refreshManagedSkillSources({
+ shouldShowError: isSkillsSurfaceActive,
+ });
+ if (await shouldReportMutation(next.host)) {
+ inputRef.current.toastApi.success(
+ copy.installedTitle,
+ copy.installedDescription(next.value.skill.id),
+ );
+ }
+ } catch (error) {
+ if (await shouldReportOperationError(error)) {
+ reportRuntimeHostError(
+ copy.installFailedTitle,
+ copy.installFallback,
+ error,
+ );
+ }
+ }
+ },
+ [
+ isSkillsSurfaceActive,
+ refreshManagedSkillSources,
+ refreshSkills,
+ reportRuntimeHostError,
+ services.runtimeHosts,
+ services.skills,
+ shouldReportOperationError,
+ shouldReportMutation,
+ ],
+ );
+
+ const installBundledSkill = useCallback(
+ async (id: string): Promise => {
+ const copy = getShellCopy(inputRef.current.uiLocale).skillActions;
+ try {
+ const next = await runOnDefaultRuntimeHost(
+ services.runtimeHosts,
+ (host) => services.skills.installBundled(id, host),
+ );
+ if (!next.value.ok) {
+ if (await shouldReportMutation(next.host)) {
+ inputRef.current.toastApi.error(
+ copy.installBundledFailedTitle,
+ copy.installFailures[next.value.reason],
+ undefined,
+ next.diagnosticTarget,
+ );
+ }
+ return;
+ }
+ await refreshSkills({ shouldShowError: isSkillsSurfaceActive });
+ await refreshBundledSkillCatalog({
+ shouldShowError: isSkillsSurfaceActive,
+ });
+ if (await shouldReportMutation(next.host)) {
+ inputRef.current.toastApi.success(
+ copy.installedBundledTitle,
+ copy.installedDescription(next.value.skill.id),
+ );
+ }
+ } catch (error) {
+ if (await shouldReportOperationError(error)) {
+ reportRuntimeHostError(
+ copy.installBundledFailedTitle,
+ copy.installBundledFallback,
+ error,
+ );
+ }
+ }
+ },
+ [
+ isSkillsSurfaceActive,
+ refreshBundledSkillCatalog,
+ refreshSkills,
+ reportRuntimeHostError,
+ services.runtimeHosts,
+ services.skills,
+ shouldReportOperationError,
+ shouldReportMutation,
+ ],
+ );
+
+ const previewManagedSkillUpdate = useCallback(
+ async (skillId: string): Promise => {
+ const copy = getShellCopy(inputRef.current.uiLocale).skillActions;
+ try {
+ const next = await runOnDefaultRuntimeHost(
+ services.runtimeHosts,
+ (host) => services.skills.previewUpdate(skillId, host),
+ );
+ if (!next.value.ok) {
+ if (await shouldReportMutation(next.host)) {
+ inputRef.current.toastApi.error(
+ copy.previewFailedTitle,
+ copy.previewFailures[next.value.reason],
+ undefined,
+ next.diagnosticTarget,
+ );
+ }
+ return null;
+ }
+ return (await shouldReportMutation(next.host))
+ ? next.value.preview
+ : null;
+ } catch (error) {
+ if (await shouldReportOperationError(error)) {
+ reportRuntimeHostError(
+ copy.previewFailedTitle,
+ copy.previewFallback,
+ error,
+ );
+ }
+ return null;
+ }
+ },
+ [
+ isSkillsSurfaceActive,
+ reportRuntimeHostError,
+ services.runtimeHosts,
+ services.skills,
+ shouldReportOperationError,
+ shouldReportMutation,
+ ],
+ );
+
+ const updateManagedSkill = useCallback(
+ async (
+ skillId: string,
+ options: {
+ force?: boolean;
+ expectedCurrentSha256?: string;
+ expectedSourceSha256?: string;
+ } = {},
+ ): Promise => {
+ const copy = getShellCopy(inputRef.current.uiLocale).skillActions;
+ try {
+ const next = await runOnDefaultRuntimeHost(
+ services.runtimeHosts,
+ (host) => services.skills.updateManaged(skillId, options, host),
+ );
+ if (!next.value.ok) {
+ if (await shouldReportMutation(next.host)) {
+ inputRef.current.toastApi.error(
+ copy.updateFailedTitle,
+ copy.updateFailures[next.value.reason],
+ undefined,
+ next.diagnosticTarget,
+ );
+ }
+ return false;
+ }
+ await refreshSkills({ shouldShowError: isSkillsSurfaceActive });
+ if (await shouldReportMutation(next.host)) {
+ inputRef.current.toastApi.success(
+ options.force ? copy.forceUpdatedTitle : copy.updatedTitle,
+ copy.updatedDescription(next.value.skill.id),
+ );
+ }
+ return true;
+ } catch (error) {
+ if (await shouldReportOperationError(error)) {
+ reportRuntimeHostError(
+ copy.updateFailedTitle,
+ copy.updateFallback,
+ error,
+ );
+ }
+ return false;
+ }
+ },
+ [
+ isSkillsSurfaceActive,
+ refreshSkills,
+ reportRuntimeHostError,
+ services.runtimeHosts,
+ services.skills,
+ shouldReportOperationError,
+ shouldReportMutation,
+ ],
+ );
+
+ const setSkillEnabled = useCallback(
+ async (skillId: string, enabled: boolean): Promise => {
+ const copy = getShellCopy(inputRef.current.uiLocale).skillActions;
+ try {
+ const next = await runOnDefaultRuntimeHost(
+ services.runtimeHosts,
+ (host) => services.skills.setEnabled(skillId, enabled, host),
+ );
+ if (!next.value.ok) {
+ if (await shouldReportMutation(next.host)) {
+ inputRef.current.toastApi.error(
+ copy.toggleFailedTitle,
+ copy.runtimeFailures[next.value.reason],
+ undefined,
+ next.diagnosticTarget,
+ );
+ }
+ return;
+ }
+ await refreshSkills({ shouldShowError: isSkillsSurfaceActive });
+ if (await shouldReportMutation(next.host)) {
+ inputRef.current.toastApi.success(
+ enabled ? copy.enabledTitle : copy.disabledTitle,
+ copy.runtimeDescription(next.value.skill.name),
+ );
+ }
+ } catch (error) {
+ if (await shouldReportOperationError(error)) {
+ reportRuntimeHostError(
+ copy.toggleFailedTitle,
+ copy.toggleFallback,
+ error,
+ );
+ }
+ }
+ },
+ [
+ isSkillsSurfaceActive,
+ refreshSkills,
+ reportRuntimeHostError,
+ services.runtimeHosts,
+ services.skills,
+ shouldReportOperationError,
+ shouldReportMutation,
+ ],
+ );
+
+ const setSkillPinned = useCallback(
+ async (skillRef: string, pinned: boolean): Promise => {
+ const copy = getShellCopy(inputRef.current.uiLocale).skillActions;
+ try {
+ const next = await runOnDefaultRuntimeHost(
+ services.runtimeHosts,
+ (host) => services.skills.setPinned(skillRef, pinned, host),
+ );
+ if (!next.value.ok) {
+ if (await shouldReportMutation(next.host)) {
+ inputRef.current.toastApi.error(
+ copy.toggleFailedTitle,
+ copy.runtimeFailures[next.value.reason],
+ undefined,
+ next.diagnosticTarget,
+ );
+ }
+ return;
+ }
+ await refreshSkills({ shouldShowError: isSkillsSurfaceActive });
+ if (await shouldReportMutation(next.host)) {
+ inputRef.current.toastApi.success(
+ pinned ? copy.pinnedTitle : copy.unpinnedTitle,
+ next.value.skill.name,
+ );
+ }
+ } catch (error) {
+ if (await shouldReportOperationError(error)) {
+ reportRuntimeHostError(
+ copy.toggleFailedTitle,
+ copy.toggleFallback,
+ error,
+ );
+ }
+ }
+ },
+ [
+ isSkillsSurfaceActive,
+ refreshSkills,
+ reportRuntimeHostError,
+ services.runtimeHosts,
+ services.skills,
+ shouldReportOperationError,
+ shouldReportMutation,
+ ],
+ );
+
+ const deleteSkill = useCallback(
+ async (skillRef: string): Promise => {
+ const copy = getShellCopy(inputRef.current.uiLocale).skillActions;
+ try {
+ const next = await runOnDefaultRuntimeHost(
+ services.runtimeHosts,
+ (host) => services.skills.delete(skillRef, host),
+ );
+ if (!next.value.ok) {
+ if (await shouldReportMutation(next.host)) {
+ inputRef.current.toastApi.error(
+ copy.deleteFailedTitle,
+ copy.deleteFailures[next.value.reason],
+ undefined,
+ next.diagnosticTarget,
+ );
+ }
+ return;
+ }
+ await refreshSkills({ shouldShowError: isSkillsSurfaceActive });
+ await refreshBundledSkillCatalog({
+ shouldShowError: isSkillsSurfaceActive,
+ });
+ if (await shouldReportMutation(next.host)) {
+ const displayId = skillRef.slice(skillRef.lastIndexOf(":") + 1);
+ inputRef.current.toastApi.success(
+ copy.deletedTitle,
+ copy.deletedDescription(displayId),
+ );
+ }
+ } catch (error) {
+ if (await shouldReportOperationError(error)) {
+ reportRuntimeHostError(
+ copy.deleteFailedTitle,
+ copy.deleteFallback,
+ error,
+ );
+ }
+ }
+ },
+ [
+ isSkillsSurfaceActive,
+ refreshBundledSkillCatalog,
+ refreshSkills,
+ reportRuntimeHostError,
+ services.runtimeHosts,
+ services.skills,
+ shouldReportOperationError,
+ shouldReportMutation,
+ ],
+ );
+
+ const openSkill = useCallback(
+ async (skillId: string): Promise => {
+ const copy = getShellCopy(inputRef.current.uiLocale).skillActions;
+ try {
+ const next = await runOnDefaultRuntimeHost(
+ services.runtimeHosts,
+ (host) => services.skills.open(skillId, "file", host),
+ );
+ if (!next.value.ok && (await shouldReportMutation(next.host))) {
+ inputRef.current.toastApi.error(
+ copy.openFailedTitle,
+ copy.openFailures[next.value.reason],
+ undefined,
+ next.diagnosticTarget,
+ );
+ }
+ } catch (error) {
+ if (await shouldReportOperationError(error)) {
+ reportRuntimeHostError(
+ copy.openFailedTitle,
+ copy.openFallback,
+ error,
+ );
+ }
+ }
+ },
+ [
+ isSkillsSurfaceActive,
+ reportRuntimeHostError,
+ services.runtimeHosts,
+ services.skills,
+ shouldReportOperationError,
+ shouldReportMutation,
+ ],
+ );
+
+ const refreshProjectSkills = useCallback(async (): Promise => {
+ await Promise.all([
+ refreshSkills(),
+ refreshManagedSkillSources(),
+ refreshBundledSkillCatalog(),
+ ]);
+ }, [refreshBundledSkillCatalog, refreshManagedSkillSources, refreshSkills]);
+
+ const host = useMemo(
+ () => ({
+ skills,
+ managedSkillSources,
+ bundledSkillCatalog,
+ onRefreshSkills: refreshSkills,
+ onUseSkill: input.useSkillInChat,
+ ...(input.openSkillsFolder
+ ? {
+ onOpenSkill: openSkill,
+ onOpenSkillsFolder: input.openSkillsFolder,
+ onImportManagedSkillSource: importManagedSkillSource,
+ }
+ : {}),
+ onRefreshManagedSkillSources: refreshManagedSkillSources,
+ onInstallManagedSkill: installManagedSkill,
+ onRefreshBundledSkillCatalog: refreshBundledSkillCatalog,
+ onInstallBundledSkill: installBundledSkill,
+ onPreviewManagedSkillUpdate: previewManagedSkillUpdate,
+ onUpdateManagedSkill: updateManagedSkill,
+ onSetSkillEnabled: setSkillEnabled,
+ onSetSkillPinned: setSkillPinned,
+ onDeleteSkill: deleteSkill,
+ }),
+ [
+ bundledSkillCatalog,
+ deleteSkill,
+ importManagedSkillSource,
+ input.openSkillsFolder,
+ input.useSkillInChat,
+ installBundledSkill,
+ installManagedSkill,
+ managedSkillSources,
+ openSkill,
+ previewManagedSkillUpdate,
+ refreshBundledSkillCatalog,
+ refreshManagedSkillSources,
+ refreshSkills,
+ setSkillEnabled,
+ setSkillPinned,
+ skills,
+ updateManagedSkill,
+ ],
+ );
+
+ return useMemo(
+ () => ({ host, revision, refreshProjectSkills }),
+ [host, refreshProjectSkills, revision],
+ );
+}
diff --git a/apps/desktop/src/renderer/features/module-hub/index.ts b/apps/desktop/src/renderer/features/module-hub/index.ts
new file mode 100644
index 0000000000..a99b752aff
--- /dev/null
+++ b/apps/desktop/src/renderer/features/module-hub/index.ts
@@ -0,0 +1,26 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export { useModuleHubController } from './controller/use-module-hub-controller.js';
+export { ModuleHubServicesProvider } from './services-context.js';
+export type {
+ ModuleHubClipboardService,
+ ModuleHubServices,
+} from './ports.js';
+export { ModuleHubHost } from './ui/module-hub-host.js';
diff --git a/apps/desktop/src/renderer/features/module-hub/ports.ts b/apps/desktop/src/renderer/features/module-hub/ports.ts
new file mode 100644
index 0000000000..7abb633628
--- /dev/null
+++ b/apps/desktop/src/renderer/features/module-hub/ports.ts
@@ -0,0 +1,246 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type {
+ DailyReviewArchive,
+ DailyReviewArchiveSummary,
+ DailyReviewRange,
+ DailyReviewSummary,
+} from '@maka/core/daily-review';
+import type { Result } from '@maka/core/result';
+import type {
+ CreateScheduledTaskInput,
+ ScheduledTask,
+ UpdateScheduledTaskInput,
+} from '@maka/core/scheduled-task';
+import type {
+ BundledSkillCatalogEntry,
+ ManagedSkillSourceEntry,
+ ManagedSkillUpdatePreview,
+ SkillEntry,
+} from '@maka/ui';
+
+export type ModuleHubUnsubscribe = () => void;
+
+export interface ModuleHubRuntimeHostRef {
+ readonly profileId: string;
+ readonly hostId: string;
+}
+
+export interface ModuleHubRuntimeHostChangedEvent {
+ readonly profileId: string;
+ readonly readiness: 'connecting' | 'ready' | 'reconnecting' | 'unavailable';
+ readonly hostId?: string;
+ readonly isDefault: boolean;
+ readonly removed?: boolean;
+}
+
+export interface ModuleHubRuntimeHostsService {
+ getDefault(): Promise;
+ subscribeChanges(
+ handler: (event: ModuleHubRuntimeHostChangedEvent) => void,
+ ): ModuleHubUnsubscribe;
+}
+
+export type InstallSkillResult =
+ | { ok: true; skill: SkillEntry }
+ | {
+ ok: false;
+ reason: 'not_found' | 'already_exists' | 'blocked_path' | 'write_failed';
+ };
+
+export type ImportManagedSkillSourceResult =
+ | { ok: true; source: ManagedSkillSourceEntry }
+ | {
+ ok: false;
+ reason:
+ | 'cancelled'
+ | 'invalid_skill'
+ | 'already_exists'
+ | 'blocked_path'
+ | 'write_failed';
+ };
+
+export type PreviewManagedSkillUpdateResult =
+ | { ok: true; preview: ManagedSkillUpdatePreview }
+ | {
+ ok: false;
+ reason:
+ | 'not_managed'
+ | 'source_missing'
+ | 'metadata_error'
+ | 'blocked_path'
+ | 'read_failed';
+ };
+
+export type UpdateManagedSkillResult =
+ | { ok: true; skill: SkillEntry }
+ | {
+ ok: false;
+ reason:
+ | 'not_managed'
+ | 'source_missing'
+ | 'local_modified'
+ | 'metadata_error'
+ | 'blocked_path'
+ | 'write_failed';
+ };
+
+export type ChangeSkillRuntimeStateResult =
+ | { ok: true; skill: SkillEntry }
+ | {
+ ok: false;
+ reason: 'not_found' | 'blocked_path' | 'state_error' | 'write_failed';
+ };
+
+export type DeleteSkillResult =
+ | { ok: true }
+ | {
+ ok: false;
+ reason: 'not_found' | 'blocked_path' | 'blocked_scope' | 'delete_failed';
+ };
+
+export type OpenSkillResult =
+ | { ok: true; target: 'file' | 'directory' }
+ | {
+ ok: false;
+ reason:
+ | 'invalid_id'
+ | 'missing'
+ | 'blocked_path'
+ | 'not_file'
+ | 'not_directory'
+ | 'open_failed';
+ };
+
+export interface ModuleHubSkillsService {
+ list(host: ModuleHubRuntimeHostRef): Promise;
+ listManagedSources(host: ModuleHubRuntimeHostRef): Promise;
+ listBundledCatalog(host: ModuleHubRuntimeHostRef): Promise;
+ importManagedSource(host: ModuleHubRuntimeHostRef): Promise;
+ installManaged(sourceId: string, host: ModuleHubRuntimeHostRef): Promise;
+ installBundled(id: string, host: ModuleHubRuntimeHostRef): Promise;
+ previewUpdate(
+ skillId: string,
+ host: ModuleHubRuntimeHostRef,
+ ): Promise;
+ updateManaged(
+ skillId: string,
+ options: {
+ force?: boolean;
+ expectedCurrentSha256?: string;
+ expectedSourceSha256?: string;
+ },
+ host: ModuleHubRuntimeHostRef,
+ ): Promise;
+ setEnabled(
+ skillId: string,
+ enabled: boolean,
+ host: ModuleHubRuntimeHostRef,
+ ): Promise;
+ setPinned(
+ skillRef: string,
+ pinned: boolean,
+ host: ModuleHubRuntimeHostRef,
+ ): Promise;
+ delete(skillRef: string, host: ModuleHubRuntimeHostRef): Promise;
+ open(
+ skillId: string,
+ target: 'file' | 'directory',
+ host: ModuleHubRuntimeHostRef,
+ ): Promise;
+}
+
+export type ScheduledTaskCreateInput = Omit;
+
+export interface ModuleHubScheduledTasksService {
+ list(host: ModuleHubRuntimeHostRef): Promise;
+ create(
+ input: ScheduledTaskCreateInput,
+ host: ModuleHubRuntimeHostRef,
+ ): Promise;
+ update(
+ id: string,
+ patch: UpdateScheduledTaskInput,
+ host: ModuleHubRuntimeHostRef,
+ ): Promise;
+ setEnabled(
+ id: string,
+ enabled: boolean,
+ host: ModuleHubRuntimeHostRef,
+ ): Promise;
+ triggerNow(id: string, host: ModuleHubRuntimeHostRef): Promise;
+ snooze(id: string, host: ModuleHubRuntimeHostRef): Promise;
+ clearRunHistory(id: string, host: ModuleHubRuntimeHostRef): Promise;
+ delete(id: string, host: ModuleHubRuntimeHostRef): Promise;
+ subscribeChanges(
+ handler: (event: {
+ type: 'scheduled_tasks_changed';
+ reason: string;
+ taskId?: string;
+ ts: number;
+ }) => void,
+ ): ModuleHubUnsubscribe;
+ subscribeDue(
+ handler: (task: Pick) => void,
+ ): ModuleHubUnsubscribe;
+}
+
+export interface ModuleHubClientSettingsService {
+ readonly supported: boolean;
+ getKeepSystemAwake(): Promise;
+ setKeepSystemAwake(next: boolean): Promise;
+ subscribeChanges(handler: () => void): ModuleHubUnsubscribe;
+}
+
+export interface ModuleHubDailyReviewService {
+ day(
+ offsetDays: number,
+ daySpan: number | undefined,
+ host: ModuleHubRuntimeHostRef,
+ ): Promise>;
+ runOnce(input: {
+ range: DailyReviewRange;
+ offsetDays?: number;
+ modelKey?: string;
+ }): Promise<{ archiveId: string }>;
+ listArchives(): Promise;
+ getArchive(archiveId: string): Promise;
+ saveMarkdownToFile(input: {
+ markdown: string;
+ defaultName: string;
+ }): Promise<
+ | { ok: true; path: string }
+ | { ok: false; reason: 'canceled' | 'write_failed' | 'invalid_input' }
+ >;
+}
+
+export interface ModuleHubClipboardService {
+ writeText(text: string): Promise;
+}
+
+/** Environment capabilities owned by the Module Hub feature slice. */
+export interface ModuleHubServices {
+ runtimeHosts: ModuleHubRuntimeHostsService;
+ skills: ModuleHubSkillsService;
+ scheduledTasks: ModuleHubScheduledTasksService;
+ clientSettings: ModuleHubClientSettingsService;
+ dailyReview: ModuleHubDailyReviewService;
+ clipboard: ModuleHubClipboardService;
+}
diff --git a/apps/desktop/src/renderer/features/module-hub/services-context.tsx b/apps/desktop/src/renderer/features/module-hub/services-context.tsx
new file mode 100644
index 0000000000..09125f6f69
--- /dev/null
+++ b/apps/desktop/src/renderer/features/module-hub/services-context.tsx
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { createContext, useContext, type ReactNode } from 'react';
+import type { ModuleHubServices } from './ports.js';
+
+const ModuleHubServicesContext = createContext(null);
+
+export function ModuleHubServicesProvider(props: {
+ services: ModuleHubServices;
+ children?: ReactNode;
+}) {
+ return (
+
+ {props.children}
+
+ );
+}
+
+export function useModuleHubServices(): ModuleHubServices {
+ const services = useContext(ModuleHubServicesContext);
+ if (!services) throw new Error('ModuleHubServicesProvider is missing');
+ return services;
+}
diff --git a/apps/desktop/src/renderer/features/module-hub/testing.ts b/apps/desktop/src/renderer/features/module-hub/testing.ts
new file mode 100644
index 0000000000..ca504b3ce7
--- /dev/null
+++ b/apps/desktop/src/renderer/features/module-hub/testing.ts
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { ModuleHubServices } from "./ports.js";
+import type { NavSelection } from "@maka/ui";
+import type { ModuleHubHostModel } from "./controller/use-module-hub-controller.js";
+
+export { ModuleHubServicesProvider } from "./services-context.js";
+export type { ModuleHubServices } from "./ports.js";
+export { startModuleHubLifecycle } from "./controller/module-hub-lifecycle.js";
+export { resolveModuleHubHostRoute } from "./controller/module-hub-route.js";
+export type { ModuleHubHostModel } from "./controller/use-module-hub-controller.js";
+export {
+ createDailyReviewBridge,
+ useDailyReviewController,
+ type DailyReviewController,
+} from "./controller/use-daily-review-controller.js";
+export {
+ useKeepSystemAwakeController,
+ type KeepSystemAwakeController,
+} from "./controller/use-keep-system-awake-controller.js";
+export {
+ useScheduledTasksController,
+ type ScheduledTasksController,
+ type ScheduledTasksToastApi,
+} from "./controller/use-scheduled-tasks-controller.js";
+export type {
+ ModuleHubRuntimeHostChangedEvent,
+ ModuleHubRuntimeHostRef,
+} from "./ports.js";
+export {
+ useSkillsController,
+ type SkillsController,
+ type UseSkillsControllerInput,
+} from "./controller/use-skills-controller.js";
+
+const noopSubscription = (): (() => void) => () => undefined;
+const notConfigured = (operation: string): never => {
+ throw new Error(`Fake ${operation} is not configured`);
+};
+
+/** Environment-free Host model for route composition tests and Storybook. */
+export function createFakeModuleHubHostModel(
+ selection: NavSelection,
+ overrides: Partial = {},
+): ModuleHubHostModel {
+ return {
+ selection,
+ selectModule: () => undefined,
+ skills: {
+ skills: [],
+ managedSkillSources: [],
+ bundledSkillCatalog: [],
+ onRefreshSkills: async () => undefined,
+ onUseSkill: () => undefined,
+ onRefreshManagedSkillSources: async () => undefined,
+ onImportManagedSkillSource: async () => undefined,
+ onInstallManagedSkill: async () => undefined,
+ onRefreshBundledSkillCatalog: async () => undefined,
+ onInstallBundledSkill: async () => undefined,
+ onPreviewManagedSkillUpdate: async () => null,
+ onUpdateManagedSkill: async () => false,
+ onSetSkillEnabled: async () => undefined,
+ onSetSkillPinned: async () => undefined,
+ onDeleteSkill: async () => undefined,
+ },
+ scheduledTasks: {
+ scheduledTasks: [],
+ createRequestNonce: 0,
+ openCreate: () => undefined,
+ handleCreateRequest: () => undefined,
+ refresh: async () => undefined,
+ refreshSurface: async () => undefined,
+ create: async () => false,
+ update: async () => false,
+ toggle: async () => undefined,
+ triggerNow: async () => undefined,
+ snooze: async () => undefined,
+ clearRunHistory: async () => undefined,
+ delete: async () => undefined,
+ },
+ keepSystemAwake: {
+ supported: false,
+ keepSystemAwake: undefined,
+ setKeepSystemAwake: async () => undefined,
+ },
+ dailyReview: {
+ bridge: {
+ fetchDay: async () => notConfigured("dailyReview.fetchDay"),
+ },
+ copyMarkdown: async () => undefined,
+ appendMarkdown: () => undefined,
+ saveMarkdown: async () => undefined,
+ copyToday: async () => undefined,
+ pasteToday: async () => undefined,
+ saveToday: async () => undefined,
+ },
+ openSession: () => undefined,
+ ...overrides,
+ };
+}
+
+/** Environment-free Module Hub defaults for focused tests and Storybook. */
+export function createFakeModuleHubServices(
+ overrides: Partial = {},
+): ModuleHubServices {
+ return {
+ runtimeHosts: {
+ getDefault: async () => ({ profileId: "local", hostId: "local" }),
+ subscribeChanges: noopSubscription,
+ },
+ skills: {
+ list: async () => [],
+ listManagedSources: async () => [],
+ listBundledCatalog: async () => [],
+ importManagedSource: async () =>
+ notConfigured("skills.importManagedSource"),
+ installManaged: async () => notConfigured("skills.installManaged"),
+ installBundled: async () => notConfigured("skills.installBundled"),
+ previewUpdate: async () => notConfigured("skills.previewUpdate"),
+ updateManaged: async () => notConfigured("skills.updateManaged"),
+ setEnabled: async () => notConfigured("skills.setEnabled"),
+ setPinned: async () => notConfigured("skills.setPinned"),
+ delete: async () => notConfigured("skills.delete"),
+ open: async () => notConfigured("skills.open"),
+ },
+ scheduledTasks: {
+ list: async () => [],
+ create: async () => notConfigured("scheduledTasks.create"),
+ update: async () => notConfigured("scheduledTasks.update"),
+ setEnabled: async () => notConfigured("scheduledTasks.setEnabled"),
+ triggerNow: async () => notConfigured("scheduledTasks.triggerNow"),
+ snooze: async () => notConfigured("scheduledTasks.snooze"),
+ clearRunHistory: async () =>
+ notConfigured("scheduledTasks.clearRunHistory"),
+ delete: async () => notConfigured("scheduledTasks.delete"),
+ subscribeChanges: noopSubscription,
+ subscribeDue: noopSubscription,
+ },
+ clientSettings: {
+ supported: true,
+ getKeepSystemAwake: async () => false,
+ setKeepSystemAwake: async (next) => next,
+ subscribeChanges: noopSubscription,
+ },
+ dailyReview: {
+ day: async () => notConfigured("dailyReview.day"),
+ runOnce: async () => notConfigured("dailyReview.runOnce"),
+ listArchives: async () => [],
+ getArchive: async () => null,
+ saveMarkdownToFile: async () =>
+ notConfigured("dailyReview.saveMarkdownToFile"),
+ },
+ clipboard: {
+ writeText: async () => undefined,
+ },
+ ...overrides,
+ };
+}
diff --git a/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx b/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx
new file mode 100644
index 0000000000..3b437d3a4f
--- /dev/null
+++ b/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+ DailyReviewPage,
+ ModuleHubSelector,
+ ScheduledTasksPage,
+ SkillsPage,
+ getSharedUiCopy,
+ useUiLocale,
+ type ModuleHubHeader,
+} from '@maka/ui';
+import { McpPage } from '../../../mcp-page.js';
+import type { ModuleHubHostModel } from '../controller/use-module-hub-controller.js';
+import { resolveModuleHubHostRoute } from '../controller/module-hub-route.js';
+
+/** Selects and mounts exactly one Module Hub leaf for the Shell selection. */
+export function ModuleHubHost(props: { model: ModuleHubHostModel }) {
+ const { model } = props;
+ const copy = getSharedUiCopy(useUiLocale()).moduleHubs;
+ const selection = model.selection;
+ const route = resolveModuleHubHostRoute(selection);
+
+ if (route === 'skills' || route === 'mcp') {
+ const header: ModuleHubHeader = {
+ title: copy.extensions.title,
+ subtitle: copy.extensions.description,
+ badge: (
+
+ model.selectModule({ section: 'extensions', module })
+ }
+ />
+ ),
+ };
+ if (route === 'mcp') {
+ // Explicit leaf-owner exception: MCP keeps its existing page-owned
+ // controller and direct bridge; Module Hub only selects and mounts it.
+ return ;
+ }
+ return (
+
+ );
+ }
+
+ if (route === 'scheduled-tasks' || route === 'daily-review') {
+ const header: ModuleHubHeader = {
+ title: copy.automations.title,
+ subtitle: copy.automations.description,
+ badge: (
+
+ model.selectModule({ section: 'automations', module })
+ }
+ />
+ ),
+ };
+ if (route === 'scheduled-tasks') {
+ const keepAwake = model.keepSystemAwake;
+ const tasks = model.scheduledTasks;
+ return (
+
+ );
+ }
+ const dailyReview = model.dailyReview;
+ return (
+
+ );
+ }
+
+ return null;
+}
diff --git a/apps/desktop/src/renderer/main.tsx b/apps/desktop/src/renderer/main.tsx
index c7f267ed94..c39f931b27 100644
--- a/apps/desktop/src/renderer/main.tsx
+++ b/apps/desktop/src/renderer/main.tsx
@@ -28,6 +28,8 @@ import { WorkbarServicesProvider } from './features/workbar';
import { createDesktopWorkbarServices } from './platform/desktop/create-workbar-services';
import { GoalServicesProvider } from './features/goals';
import { createDesktopGoalServices } from './platform/desktop/create-goal-services';
+import { ModuleHubServicesProvider } from './features/module-hub';
+import { createDesktopModuleHubServices } from './platform/desktop/create-module-hub-services';
const ONBOARDING_SNAPSHOT_RETRY_DELAY_MS = 150;
const ONBOARDING_SNAPSHOT_TIMEOUT_MS = 2_500;
@@ -36,6 +38,7 @@ syncUiLocaleDocument(readSystemUiLocale());
applyCachedThemeBeforeMount();
const workbarServices = createDesktopWorkbarServices();
const goalServices = createDesktopGoalServices();
+const moduleHubServices = createDesktopModuleHubServices();
/**
* Prefetch the onboarding snapshot BEFORE mounting React. The preload
@@ -68,10 +71,12 @@ async function prefetchOnboardingSnapshot(): Promise
void prefetchOnboardingSnapshot().then((initialOnboardingSnapshot) => {
createRoot(document.getElementById('root')!).render(
-
-
-
-
- ,
+
+
+
+
+
+
+ ,
);
});
diff --git a/apps/desktop/src/renderer/platform/desktop/create-module-hub-services.ts b/apps/desktop/src/renderer/platform/desktop/create-module-hub-services.ts
new file mode 100644
index 0000000000..4c4880ce4c
--- /dev/null
+++ b/apps/desktop/src/renderer/platform/desktop/create-module-hub-services.ts
@@ -0,0 +1,154 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { MakaBridge } from '../../../preload/bridge-contract.js';
+import type {
+ ModuleHubClipboardService,
+ ModuleHubServices,
+} from '../../features/module-hub/index.js';
+
+type DesktopModuleHubSettingsBridge = Partial<
+ Pick
+>;
+
+export type DesktopModuleHubBridge = Pick<
+ MakaBridge,
+ 'dailyReview' | 'runtimeHostProfiles' | 'scheduledTasks' | 'skills'
+> & {
+ /** Optional at runtime so a renderer can coexist with an older preload. */
+ readonly settings?: DesktopModuleHubSettingsBridge;
+};
+
+export interface DesktopModuleHubServiceDependencies {
+ readonly clipboard?: ModuleHubClipboardService;
+}
+
+/** The only Desktop-to-Module-Hub adapter. */
+export function createDesktopModuleHubServices(
+ bridge: DesktopModuleHubBridge = window.maka,
+ dependencies: DesktopModuleHubServiceDependencies = {},
+): ModuleHubServices {
+ const getClientSettings = bridge.settings?.getClient;
+ const updateClientSettings = bridge.settings?.updateClient;
+ const subscribeClientSettings = bridge.settings?.subscribeClientChanged;
+ const clientSettingsSupported =
+ typeof getClientSettings === 'function' &&
+ typeof updateClientSettings === 'function';
+
+ return {
+ runtimeHosts: {
+ getDefault: () => bridge.runtimeHostProfiles.getDefaultHost(),
+ subscribeChanges: (handler) =>
+ bridge.runtimeHostProfiles.subscribeChanges((event) =>
+ handler({
+ profileId: event.profileId,
+ readiness: event.readiness,
+ hostId: event.hostId,
+ isDefault: event.isDefault,
+ removed: event.removed,
+ }),
+ ),
+ },
+ skills: {
+ list: (host) => bridge.skills.list(host),
+ listManagedSources: (host) => bridge.skills.sources.list(host),
+ listBundledCatalog: (host) => bridge.skills.catalog.list(host),
+ importManagedSource: (host) => bridge.skills.sources.importLocalFile(host),
+ installManaged: (sourceId, host) =>
+ bridge.skills.installManaged(sourceId, host),
+ installBundled: (id, host) => bridge.skills.catalog.install(id, host),
+ previewUpdate: (skillId, host) =>
+ bridge.skills.previewUpdate(skillId, host),
+ updateManaged: (skillId, options, host) =>
+ bridge.skills.updateManaged(skillId, options, host),
+ setEnabled: (skillId, enabled, host) =>
+ bridge.skills.setEnabled(skillId, enabled, host),
+ setPinned: (skillRef, pinned, host) =>
+ bridge.skills.setPinned(skillRef, pinned, host),
+ delete: (skillRef, host) => bridge.skills.delete(skillRef, host),
+ open: (skillId, target, host) => bridge.skills.open(skillId, target, host),
+ },
+ scheduledTasks: {
+ list: (host) => bridge.scheduledTasks.list(host),
+ create: (input, host) => bridge.scheduledTasks.create(input, host),
+ update: (id, patch, host) =>
+ bridge.scheduledTasks.update(id, patch, host),
+ setEnabled: (id, enabled, host) =>
+ bridge.scheduledTasks.setEnabled(id, enabled, host),
+ triggerNow: (id, host) => bridge.scheduledTasks.triggerNow(id, host),
+ snooze: (id, host) => bridge.scheduledTasks.snooze(id, host),
+ clearRunHistory: (id, host) =>
+ bridge.scheduledTasks.clearRunHistory(id, host),
+ delete: (id, host) => bridge.scheduledTasks.delete(id, host),
+ subscribeChanges: (handler) =>
+ bridge.scheduledTasks.subscribeChanges(handler),
+ subscribeDue: (handler) => bridge.scheduledTasks.subscribeDue(handler),
+ },
+ clientSettings: {
+ supported: clientSettingsSupported,
+ async getKeepSystemAwake() {
+ if (!getClientSettings) {
+ throw new Error('Client settings are unavailable');
+ }
+ const settings = await getClientSettings.call(bridge.settings);
+ return settings.system.keepSystemAwake;
+ },
+ async setKeepSystemAwake(next) {
+ if (!updateClientSettings) {
+ throw new Error('Client settings are unavailable');
+ }
+ const result = await updateClientSettings.call(bridge.settings, {
+ system: { keepSystemAwake: next },
+ });
+ return result.settings.system.keepSystemAwake;
+ },
+ subscribeChanges(handler) {
+ if (!subscribeClientSettings) return () => undefined;
+ return subscribeClientSettings.call(bridge.settings, handler);
+ },
+ },
+ dailyReview: {
+ day: (offsetDays, daySpan, host) =>
+ bridge.dailyReview.day(offsetDays, daySpan, host),
+ runOnce: (input) => {
+ const runOnce = bridge.dailyReview.runOnce;
+ if (!runOnce) throw new Error('Daily Review run is unavailable');
+ return runOnce(input);
+ },
+ listArchives: () => {
+ const listArchives = bridge.dailyReview.listArchives;
+ if (!listArchives) throw new Error('Daily Review history is unavailable');
+ return listArchives();
+ },
+ getArchive: (archiveId) => {
+ const getArchive = bridge.dailyReview.getArchive;
+ if (!getArchive) throw new Error('Daily Review history is unavailable');
+ return getArchive(archiveId);
+ },
+ saveMarkdownToFile: (input) =>
+ bridge.dailyReview.saveMarkdownToFile(input),
+ },
+ clipboard: {
+ writeText(text) {
+ const clipboard = dependencies.clipboard ?? navigator.clipboard;
+ return clipboard.writeText(text);
+ },
+ },
+ };
+}
diff --git a/apps/desktop/src/renderer/use-composer-mentions.ts b/apps/desktop/src/renderer/use-composer-mentions.ts
index 2d31f9505c..ea95b268d9 100644
--- a/apps/desktop/src/renderer/use-composer-mentions.ts
+++ b/apps/desktop/src/renderer/use-composer-mentions.ts
@@ -19,7 +19,6 @@
import { useCallback, useEffect, useState } from 'react';
import type { ChatDefaultPermissionMode } from '@maka/core/settings';
-import type { SkillEntry } from '@maka/ui';
import type { InvocableSkillEntry } from '@maka/runtime/skill-invocation';
import type { DesktopNewTaskTarget } from '../preload/bridge-contract.js';
@@ -56,7 +55,8 @@ function invocableSkillListsEqual(
* identities across renders.
*/
export function useComposerMentions(options: {
- skills: readonly SkillEntry[];
+ /** Invalidates Runtime's invocable projection after installed Skills settle. */
+ skillCatalogRevision: number;
sessionId?: string;
projectPath?: string;
newSessionModel?: { llmConnectionSlug: string; model: string };
@@ -72,7 +72,7 @@ export function useComposerMentions(options: {
const {
projectPath,
sessionId,
- skills,
+ skillCatalogRevision,
newSessionModel,
newSessionCollaborationMode,
newSessionPermissionMode,
@@ -203,7 +203,7 @@ export function useComposerMentions(options: {
}, [
projectPath,
sessionId,
- skills,
+ skillCatalogRevision,
newSessionModel?.llmConnectionSlug,
newSessionModel?.model,
newSessionCollaborationMode,
diff --git a/apps/desktop/src/renderer/use-keep-system-awake.ts b/apps/desktop/src/renderer/use-keep-system-awake.ts
deleted file mode 100644
index 2223574835..0000000000
--- a/apps/desktop/src/renderer/use-keep-system-awake.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-import { useCallback, useEffect, useState } from 'react';
-import { useMountedRef } from '@maka/ui';
-
-/**
- * Reads + writes the 保持系统唤醒 (`settings.system.keepSystemAwake`) toggle
- * that surfaces on the 定时任务 page. This is a Desktop preference, so it
- * remains available when the selected Runtime Host is offline.
- *
- * `supported` gates the whole capability on bridge presence: when the
- * preload bridge is absent (older main, or a non-Electron host), the caller
- * hides the row entirely rather than rendering a dead control. The
- * optimistic-update / revert-on-error / toast lifecycle lives in the panel;
- * this hook only owns the persisted snapshot and the write that rejects on
- * failure so the panel can revert.
- */
-export interface KeepSystemAwakeController {
- /** Whether the settings bridge exposing this toggle exists. */
- supported: boolean;
- /** Last-known persisted value. Undefined until the initial read settles. */
- keepSystemAwake: boolean | undefined;
- /**
- * Persist a new value. Resolves once the store confirms the write (and
- * updates the local snapshot); rejects on failure so the caller can revert
- * its optimistic UI.
- */
- setKeepSystemAwake(next: boolean): Promise;
-}
-
-export function useKeepSystemAwake(): KeepSystemAwakeController {
- // Gate on the bridge actually exposing both calls at runtime. `window.maka`
- // is typed as always-present, so a truthiness check trips TS2774; a
- // `typeof … === 'function'` probe is the honest runtime guard for a
- // non-Electron host or an older preload that predates this capability.
- const supported =
- typeof window.maka?.settings?.getClient === 'function' &&
- typeof window.maka?.settings?.updateClient === 'function';
- const [keepSystemAwake, setSnapshot] = useState();
- const mountedRef = useMountedRef();
-
- const refresh = useCallback(async () => {
- if (!supported) return;
- try {
- const settings = await window.maka.settings.getClient();
- if (mountedRef.current) setSnapshot(settings.system.keepSystemAwake);
- } catch {
- // The persisted default is false. Falling back to that known-safe value
- // after an initial failure keeps the control recoverable; later failures
- // must not overwrite a value that was already confirmed.
- if (mountedRef.current) setSnapshot((previous) => previous ?? false);
- }
- }, [supported, mountedRef]);
-
- useEffect(() => {
- void refresh();
- if (!supported) return;
- // Keep the snapshot honest when settings.json is edited out of band.
- return window.maka.settings.subscribeClientChanged(() => {
- void refresh();
- });
- }, [supported, refresh]);
-
- const setKeepSystemAwake = useCallback(
- async (next: boolean) => {
- const result = await window.maka.settings.updateClient({ system: { keepSystemAwake: next } });
- if (mountedRef.current) setSnapshot(result.settings.system.keepSystemAwake);
- },
- [mountedRef],
- );
-
- return { supported, keepSystemAwake, setKeepSystemAwake };
-}
diff --git a/apps/desktop/src/renderer/use-module-data.ts b/apps/desktop/src/renderer/use-module-data.ts
deleted file mode 100644
index 121c794790..0000000000
--- a/apps/desktop/src/renderer/use-module-data.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-import { useRef, useState } from 'react';
-import type { ScheduledTask } from '@maka/core/scheduled-task';
-import type { UiLocale } from '@maka/core/ui-locale';
-import type { BundledSkillCatalogEntry, ManagedSkillSourceEntry, SkillEntry } from '@maka/ui';
-import {
- createAppShellScheduledTaskActions,
- type AppShellScheduledTaskActions,
-} from './app-shell-scheduled-task-actions';
-import { createAppShellSkillActions, type AppShellSkillActions } from './app-shell-skill-actions';
-
-type ToastApi = {
- success(title: string, description?: string): void;
- error(
- title: string,
- description?: string,
- diagnosticDetails?: string,
- diagnosticTarget?: { profileId: string },
- ): void;
- confirm(options: {
- title: string;
- description: string;
- confirmLabel: string;
- cancelLabel: string;
- destructive?: boolean;
- }): Promise;
-};
-
-/**
- * Owns the two sidebar-module data clusters — installed/managed/bundled
- * skills and scheduled tasks — together with their refresh + mutation
- * helpers. The
- * surface-active predicates are injected so the mutation helpers only
- * surface error toasts while their module is the foreground view, exactly
- * as before. Pure move: every returned action keeps its prior identity
- * semantics (recreated each render alongside the shell) and the task
- * getter reads the latest values on each call.
- */
-export function useAppShellModuleData(options: {
- uiLocale: UiLocale;
- isSkillsSurfaceActive: () => boolean;
- isScheduledTasksSurfaceActive: () => boolean;
- toastApi: ToastApi;
-}): AppShellScheduledTaskActions & AppShellSkillActions & {
- skills: SkillEntry[];
- managedSkillSources: ManagedSkillSourceEntry[];
- bundledSkillCatalog: BundledSkillCatalogEntry[];
- scheduledTasks: ScheduledTask[];
-} {
- const {
- uiLocale,
- isSkillsSurfaceActive,
- isScheduledTasksSurfaceActive,
- toastApi,
- } = options;
- const [skills, setSkills] = useState([]);
- const [managedSkillSources, setManagedSkillSources] = useState([]);
- const [bundledSkillCatalog, setBundledSkillCatalog] = useState([]);
- const [scheduledTasks, setScheduledTasks] = useState([]);
- const refreshGenerationsRef = useRef({
- skills: 0,
- managedSkillSources: 0,
- bundledSkillCatalog: 0,
- scheduledTasks: 0,
- });
-
- const scheduledTaskActions = createAppShellScheduledTaskActions({
- uiLocale,
- getScheduledTasks: () => scheduledTasks,
- isScheduledTasksSurfaceActive,
- refreshGenerationsRef,
- setScheduledTasks,
- toastApi,
- });
-
- const skillActions = createAppShellSkillActions({
- uiLocale,
- isSkillsSurfaceActive,
- refreshGenerationsRef,
- setSkills,
- setManagedSkillSources,
- setBundledSkillCatalog,
- toastApi,
- });
-
- return {
- skills,
- managedSkillSources,
- bundledSkillCatalog,
- scheduledTasks,
- ...scheduledTaskActions,
- ...skillActions,
- };
-}
diff --git a/apps/desktop/stories/module-hubs.stories.tsx b/apps/desktop/stories/module-hubs.stories.tsx
index 67ff23885d..1fdd2db49b 100644
--- a/apps/desktop/stories/module-hubs.stories.tsx
+++ b/apps/desktop/stories/module-hubs.stories.tsx
@@ -35,6 +35,8 @@ import {
} from '@maka/ui';
import { type ComponentProps, type ReactNode, useState } from 'react';
import { WorkbarTitlebarActions } from '../src/renderer/features/workbar';
+import { ModuleHubHost } from '../src/renderer/features/module-hub/index';
+import { createFakeModuleHubHostModel } from '../src/renderer/features/module-hub/testing';
import { AppShellDetailPanel } from '../src/renderer/app-shell-detail-panel';
import { McpPage } from '../src/renderer/mcp-page';
import { withScopedMakaBridge } from './maka-bridge';
@@ -669,7 +671,11 @@ function ExtensionsMcpSurface() {
);
}
-function ScheduledTasksSurface(props: { tasks?: ScheduledTask[] }) {
+function ScheduledTasksSurface(props: {
+ tasks?: ScheduledTask[];
+ keepSystemAwake?: boolean;
+ onKeepSystemAwakeChange?: (next: boolean) => Promise;
+}) {
const copy = getSharedUiCopy(useUiLocale()).moduleHubs.automations;
return (
@@ -680,8 +686,10 @@ function ScheduledTasksSurface(props: { tasks?: ScheduledTask[] }) {
badge: {}} />,
}}
tasks={props.tasks ?? []}
- keepSystemAwake={false}
- onKeepSystemAwakeChange={async () => {}}
+ keepSystemAwake={props.keepSystemAwake ?? false}
+ onKeepSystemAwakeChange={
+ props.onKeepSystemAwakeChange ?? (async () => {})
+ }
onRefresh={noop}
onCreate={noop}
onUpdate={noop}
@@ -719,6 +727,42 @@ function ScheduledDailyReviewSurface(
);
}
+function ModuleHubHostSurface(props: {
+ selection:
+ | { section: 'extensions'; module: 'skills' | 'mcp' }
+ | { section: 'automations'; module: 'scheduled-tasks' | 'daily-review' };
+}) {
+ const base = createFakeModuleHubHostModel(props.selection);
+ const model = {
+ ...base,
+ skills: {
+ ...base.skills,
+ skills: INSTALLED_SKILLS,
+ bundledSkillCatalog: BUNDLED_SKILLS,
+ },
+ scheduledTasks: {
+ ...base.scheduledTasks,
+ scheduledTasks: CONFIGURED_TASKS,
+ },
+ dailyReview: {
+ ...base.dailyReview,
+ bridge: {
+ fetchDay: async () => DAILY_REVIEW_SUMMARY,
+ },
+ },
+ };
+ const agentsView = props.selection.section === 'extensions'
+ ? props.selection.module
+ : props.selection.module === 'daily-review'
+ ? 'daily-review'
+ : 'cron';
+ return (
+
+
+
+ );
+}
+
async function waitForStoryButton(
canvasElement: HTMLElement,
predicate: (button: HTMLButtonElement) => boolean,
@@ -756,6 +800,40 @@ export const ExtensionsSkillsEmpty: Story = {
render: () => ,
};
+// Feature-slice composition coverage: the production Host, not a direct leaf.
+export const HostExtensionsSkills: Story = {
+ render: () => (
+
+ ),
+};
+
+export const HostExtensionsMcp: Story = {
+ decorators: [withEmptyMcpBridge],
+ render: () => (
+
+ ),
+};
+
+export const HostAutomationsScheduledTasks: Story = {
+ render: () => (
+
+ ),
+};
+
+export const HostAutomationsDailyReview: Story = {
+ render: () => (
+
+ ),
+};
+
// Real path: sidebar → 扩展 → 技能, with several installed Skills.
export const ExtensionsSkillsInstalled: Story = {
render: () => ,
@@ -945,6 +1023,54 @@ export const ScheduledTasksConfigured: Story = {
render: () => ,
};
+// A newer external settings read wins over a slow local write in the Module
+// Hub controller. The checkbox must return to the persisted prop when that
+// pending write settles, even when the Boolean prop itself never changed.
+export const ScheduledTasksKeepAwakeExternalWins: Story = {
+ render: () => (
+ {
+ await new Promise((resolve) => globalThis.setTimeout(resolve, 80));
+ }}
+ />
+ ),
+ play: async ({ canvasElement }) => {
+ const settings = await waitForStoryButton(
+ canvasElement,
+ (candidate) => candidate.getAttribute('aria-label') === '定时任务页面设置',
+ );
+ settings.click();
+ const body = canvasElement.ownerDocument.body;
+ const checkbox = await waitForStorySelector(
+ body,
+ '[role="menuitemcheckbox"]',
+ );
+ if (checkbox.getAttribute('aria-checked') !== 'true') {
+ throw new Error('Keep-awake story did not start from persisted true');
+ }
+ checkbox.click();
+ for (let attempt = 0; attempt < 50; attempt += 1) {
+ const current = body.querySelector('[role="menuitemcheckbox"]');
+ if (current?.getAttribute('aria-checked') === 'false') break;
+ await new Promise((resolve) => globalThis.setTimeout(resolve, 10));
+ if (attempt === 49) throw new Error('Keep-awake optimistic value did not render');
+ }
+ await new Promise((resolve) => globalThis.setTimeout(resolve, 100));
+ let persisted = body.querySelector('[role="menuitemcheckbox"]');
+ if (!persisted) {
+ settings.click();
+ persisted = await waitForStorySelector(
+ body,
+ '[role="menuitemcheckbox"]',
+ );
+ }
+ if (persisted.getAttribute('aria-checked') !== 'true') {
+ throw new Error('Keep-awake checkbox diverged from the persisted setting');
+ }
+ },
+};
+
// Real path: sidebar → 定时任务 → 定时任务 → click a task row, which opens the
// inspector where every per-task control now lives. Wide only: below 1024px the
// page drops the inspector rather than squeeze two columns into one.
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 55dc8f7c78..000849b7cd 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -5,7 +5,7 @@ Each row is one on-disk product surface file. Regenerated inventory must stay in
Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
-**Totals:** 207 files — blocker 0, polish 1, aligned 206.
+**Totals:** 209 files — blocker 0, polish 1, aligned 208.
## Exclusions (explicit)
@@ -41,6 +41,8 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/features/goals/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, Text, TextInput, VStack | aligned — uses Astryx (Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, Text, TextInput) | aligned |
| `apps/desktop/src/renderer/features/goals/ui/goal-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `apps/desktop/src/renderer/features/module-hub/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/workbar/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx` | shell-chrome-or-panel | Badge, Banner, Button, EmptyState | aligned — uses Astryx (Badge, Banner, Button, EmptyState) | aligned |
| `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx` | shell-chrome-or-panel | Banner, Button, Spinner | aligned — uses Astryx (Banner, Button, Spinner) | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index 5f902815a8..6ae464a287 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -13,6 +13,8 @@ apps/desktop/src/renderer/error-boundary.tsx
apps/desktop/src/renderer/features/goals/services-context.tsx
apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx
apps/desktop/src/renderer/features/goals/ui/goal-host.tsx
+apps/desktop/src/renderer/features/module-hub/services-context.tsx
+apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx
apps/desktop/src/renderer/features/workbar/services-context.tsx
apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx
apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx
diff --git a/packages/ui/src/scheduled-task-panel.tsx b/packages/ui/src/scheduled-task-panel.tsx
index e23babe84a..fc041986b9 100644
--- a/packages/ui/src/scheduled-task-panel.tsx
+++ b/packages/ui/src/scheduled-task-panel.tsx
@@ -190,11 +190,14 @@ export function ScheduledTaskPanel(props: {
// Re-sync the switch to the persisted snapshot when it changes (external
// edit, relaunch), unless a local write is mid-flight — the optimistic
- // value wins until the write settles.
+ // value wins until the write settles. Pending is also a dependency: an
+ // external refresh can deliberately retain the same persisted Boolean and
+ // supersede a slow write, so the prop itself may not change when the write
+ // finishes.
useEffect(() => {
- if (keepSystemAwakePendingRef.current) return;
+ if (keepSystemAwakePending) return;
if (props.keepSystemAwake !== undefined) setKeepSystemAwakeChecked(props.keepSystemAwake);
- }, [props.keepSystemAwake]);
+ }, [keepSystemAwakePending, props.keepSystemAwake]);
useEffect(() => {
if (!props.createRequestNonce) return;
diff --git a/packages/ui/src/session-list-panel.tsx b/packages/ui/src/session-list-panel.tsx
index 8c9fb8cb12..fee2ea05ec 100644
--- a/packages/ui/src/session-list-panel.tsx
+++ b/packages/ui/src/session-list-panel.tsx
@@ -60,7 +60,7 @@ export function SessionListPanel(props: {
selection: NavSelection;
sessions: SessionSummary[];
activeId?: string;
- scheduledTasks?: ScheduledTask[];
+ scheduledTasks?: readonly ScheduledTask[];
streamingSessionIds?: Set;
staleSessionIds?: Set;
groups?: ReadonlyArray;
diff --git a/packages/ui/src/session-sidebar-nav.tsx b/packages/ui/src/session-sidebar-nav.tsx
index 4df03e1be0..e7d3b71ff1 100644
--- a/packages/ui/src/session-sidebar-nav.tsx
+++ b/packages/ui/src/session-sidebar-nav.tsx
@@ -29,7 +29,7 @@ import { Tooltip } from '@astryxdesign/core/Tooltip';
export function SessionSidebarNav(props: {
selection: NavSelection;
- scheduledTasks?: ScheduledTask[];
+ scheduledTasks?: readonly ScheduledTask[];
moduleMemory?: NavModuleMemory;
onSelect(selection: NavSelection): void;
onNew(): void;