Skip to content

Commit 68eca8d

Browse files
committed
refactor(desktop): move Module Hub controller below AppShell
Generated-by: OpenAI Codex
1 parent aaedc4c commit 68eca8d

16 files changed

Lines changed: 642 additions & 61 deletions

apps/desktop/renderer-architecture.json

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,15 @@
325325
"legacyPlatformImports": [
326326
"src/renderer/platform/desktop/create-workbar-services.ts -> src/renderer/session-message-settlement"
327327
],
328+
"controllerOwners": [
329+
{
330+
"implementation": "src/renderer/features/module-hub/controller/use-module-hub-controller.ts",
331+
"symbol": "useModuleHubController",
332+
"owner": "src/renderer/features/module-hub/ui/module-hub-provider.tsx",
333+
"ownerSymbol": "ModuleHubProvider",
334+
"count": 1
335+
}
336+
],
328337
"hookTransitions": [],
329338
"legacyAppShell": {
330339
"files": {
@@ -926,11 +935,10 @@
926935
"useGoalController": 1,
927936
"useKeyboardHelp": 1,
928937
"useLayoutEffect": 2,
929-
"useModuleHubController": 1,
930938
"useNewTaskChoice": 1,
931939
"useOnboardingSnapshot": 1,
932940
"usePlanModeState": 1,
933-
"useRef": 25,
941+
"useRef": 24,
934942
"useSessionCollaborationDialog": 1,
935943
"useSessionEventHealthPolling": 1,
936944
"useSessionNavigationReads": 1,
@@ -1062,8 +1070,8 @@
10621070
"@maka/ui/icons": 1,
10631071
"react": 1
10641072
},
1065-
"importSpecifiers": 187,
1066-
"nonTriviaTokens": 15912
1073+
"importSpecifiers": 186,
1074+
"nonTriviaTokens": 15907
10671075
},
10681076
"src/renderer/use-app-shell-composer-quotes.ts": {
10691077
"importDeclarations": 3,

apps/desktop/src/main/__tests__/module-hub-boundary.test.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe('Module Hub feature boundary', () => {
6666
});
6767

6868
it('is consumed outside the feature only through index or testing', () => {
69-
const allowed = /\/features\/module-hub\/(?:index|testing)(?:\.js)?$/;
69+
const allowed = /\/features\/module-hub\/(?:index|stories|testing)(?:\.js)?$/;
7070
const violations: string[] = [];
7171
for (const root of [join(desktopRoot, 'src'), join(desktopRoot, 'stories')]) {
7272
for (const path of sourceFiles(root)) {
@@ -92,6 +92,7 @@ describe('Module Hub feature boundary', () => {
9292
const productionEntry = readFileSync(join(featureRoot, 'index.ts'), 'utf8');
9393
assert.equal(productionEntry.includes('createFakeModuleHub'), false);
9494
assert.equal(productionEntry.includes("from './testing"), false);
95+
assert.equal(productionEntry.includes('useModuleHubController'), false);
9596
});
9697

9798
it('keeps module data, pages, nonce, bridges, and subscriptions out of AppShell', () => {
@@ -116,8 +117,38 @@ describe('Module Hub feature boundary', () => {
116117
]) {
117118
assert.equal(appShell.includes(forbidden), false, forbidden);
118119
}
119-
assert.equal(appShell.includes('useModuleHubController({'), true);
120-
assert.equal(appShell.includes('<ModuleHubHost model={moduleHub.host} />'), true);
120+
for (const forbidden of [
121+
'useModuleHubController(',
122+
'moduleHub.commands',
123+
'moduleHub.selectors',
124+
'moduleHub.host',
125+
'<ModuleHubHost model=',
126+
]) {
127+
assert.equal(appShell.includes(forbidden), false, forbidden);
128+
}
129+
assert.equal(appShell.includes('<ModuleHub.ModuleHubProvider'), true);
130+
assert.equal(
131+
appShell.includes('<ModuleHub.ModuleHubScheduledTasksBoundary>'),
132+
true,
133+
);
134+
assert.equal(
135+
appShell.includes('<ModuleHub.ModuleHubSkillCatalogRevisionBoundary>'),
136+
true,
137+
);
138+
assert.equal(appShell.includes('<ModuleHub.ModuleHubHost />'), true);
139+
140+
const provider = readFileSync(
141+
join(featureRoot, 'ui', 'module-hub-provider.tsx'),
142+
'utf8',
143+
);
144+
assert.equal(
145+
provider.includes('const controller = useModuleHubController(input);'),
146+
true,
147+
);
148+
assert.equal(
149+
provider.includes('commandPort.connect(controller.commands)'),
150+
true,
151+
);
121152

122153
const effects = readFileSync(
123154
join(desktopRoot, 'src', 'renderer', 'app-shell-effects.ts'),
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import assert from 'node:assert/strict';
21+
import { afterEach, test } from 'node:test';
22+
import { act, createElement, Fragment } from 'react';
23+
import type { ScheduledTask } from '@maka/core/scheduled-task';
24+
import { LocaleProvider, ToastProvider } from '@maka/ui';
25+
import {
26+
createModuleHubCommandPort,
27+
ModuleHubProvider,
28+
ModuleHubScheduledTasksBoundary,
29+
ModuleHubSkillCatalogRevisionBoundary,
30+
ModuleHubServicesProvider,
31+
createFakeModuleHubServices,
32+
type ModuleHubCommands,
33+
type ModuleHubServices,
34+
useModuleHubController,
35+
} from '../../renderer/features/module-hub/testing.js';
36+
import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
37+
38+
function task(id: string): ScheduledTask {
39+
return {
40+
id,
41+
title: id,
42+
intent: { kind: 'text', body: 'run' },
43+
schedule: { kind: 'once', runAt: 1 },
44+
effect: { kind: 'notify', channel: 'local' },
45+
status: 'active',
46+
nextFireAt: 1,
47+
lastFireAt: null,
48+
fireCount: 0,
49+
maxFires: null,
50+
expiresAt: null,
51+
createdBy: { kind: 'user' },
52+
createdAt: 1,
53+
updatedAt: 1,
54+
runs: [],
55+
lastError: null,
56+
};
57+
}
58+
59+
afterEach(() => cleanupFakeDom());
60+
61+
function scheduledTasksHarness(): {
62+
services: ModuleHubServices;
63+
emit(tasks: ScheduledTask[]): void;
64+
} {
65+
const defaults = createFakeModuleHubServices();
66+
let handler: (() => void) | undefined;
67+
let scheduledTasks: ScheduledTask[] = [];
68+
return {
69+
services: createFakeModuleHubServices({
70+
scheduledTasks: {
71+
...defaults.scheduledTasks,
72+
list: async () => scheduledTasks,
73+
subscribeChanges(next) {
74+
handler = () =>
75+
next({
76+
type: 'scheduled_tasks_changed',
77+
reason: 'test',
78+
ts: 1,
79+
});
80+
return () => {
81+
handler = undefined;
82+
};
83+
},
84+
},
85+
}),
86+
emit(tasks) {
87+
scheduledTasks = tasks;
88+
assert.ok(handler);
89+
handler();
90+
},
91+
};
92+
}
93+
94+
test('controller scoping removes shell-wide work from Module Hub updates', async () => {
95+
const { root } = installReactRenderer();
96+
const legacy = scheduledTasksHarness();
97+
const scoped = scheduledTasksHarness();
98+
const commandPort = createModuleHubCommandPort();
99+
const renders = {
100+
legacyShell: 0,
101+
legacyUnrelated: 0,
102+
legacyReader: 0,
103+
scopedShell: 0,
104+
scopedUnrelated: 0,
105+
scopedReader: 0,
106+
scopedSkillReader: 0,
107+
};
108+
let legacyObservedTasks: readonly ScheduledTask[] = [];
109+
let scopedObservedTasks: readonly ScheduledTask[] = [];
110+
let scopedObservedSkillRevision = -1;
111+
const controllerInput = {
112+
selection: { section: 'sessions' } as const,
113+
selectModule: () => undefined,
114+
useSkillInChat: () => undefined,
115+
openSession: () => undefined,
116+
appendComposerText: () => undefined,
117+
captureActiveComposerClaim: () => undefined,
118+
};
119+
120+
function LegacyUnrelatedProbe() {
121+
renders.legacyUnrelated += 1;
122+
return null;
123+
}
124+
125+
function ScopedUnrelatedProbe() {
126+
renders.scopedUnrelated += 1;
127+
return null;
128+
}
129+
130+
function LegacyScheduledTasksProbe(props: {
131+
scheduledTasks: readonly ScheduledTask[];
132+
}) {
133+
renders.legacyReader += 1;
134+
legacyObservedTasks = props.scheduledTasks;
135+
return null;
136+
}
137+
138+
function ScopedScheduledTasksProbe(props: {
139+
scheduledTasks?: readonly ScheduledTask[];
140+
}) {
141+
renders.scopedReader += 1;
142+
scopedObservedTasks = props.scheduledTasks ?? [];
143+
return null;
144+
}
145+
146+
function ScopedSkillCatalogProbe(props: { skillCatalogRevision?: number }) {
147+
renders.scopedSkillReader += 1;
148+
scopedObservedSkillRevision = props.skillCatalogRevision ?? -1;
149+
return null;
150+
}
151+
152+
function LegacyShellReplica() {
153+
renders.legacyShell += 1;
154+
const controller = useModuleHubController(controllerInput);
155+
return createElement(
156+
Fragment,
157+
null,
158+
createElement(LegacyUnrelatedProbe),
159+
createElement(LegacyScheduledTasksProbe, {
160+
scheduledTasks: controller.selectors.scheduledTasks,
161+
}),
162+
);
163+
}
164+
165+
function ScopedShellReplica() {
166+
renders.scopedShell += 1;
167+
return createElement(
168+
ModuleHubProvider,
169+
{
170+
...controllerInput,
171+
commandPort,
172+
},
173+
createElement(
174+
Fragment,
175+
null,
176+
createElement(ScopedUnrelatedProbe),
177+
createElement(
178+
ModuleHubScheduledTasksBoundary,
179+
null,
180+
createElement(ScopedScheduledTasksProbe),
181+
),
182+
createElement(
183+
ModuleHubSkillCatalogRevisionBoundary,
184+
null,
185+
createElement(ScopedSkillCatalogProbe),
186+
),
187+
),
188+
);
189+
}
190+
191+
await act(async () => {
192+
const app = createElement(
193+
Fragment,
194+
null,
195+
createElement(
196+
ModuleHubServicesProvider,
197+
{ services: legacy.services },
198+
createElement(LegacyShellReplica),
199+
),
200+
createElement(
201+
ModuleHubServicesProvider,
202+
{ services: scoped.services },
203+
createElement(ScopedShellReplica),
204+
),
205+
);
206+
root.render(
207+
createElement(LocaleProvider, {
208+
locale: 'en',
209+
children: createElement(ToastProvider, { children: app }),
210+
}),
211+
);
212+
await new Promise<void>((resolve) => setImmediate(resolve));
213+
});
214+
const baseline = { ...renders };
215+
216+
await act(async () => {
217+
legacy.emit([task('legacy-task')]);
218+
await new Promise<void>((resolve) => setImmediate(resolve));
219+
});
220+
assert.deepEqual(renders, {
221+
legacyShell: baseline.legacyShell + 1,
222+
legacyUnrelated: baseline.legacyUnrelated + 1,
223+
legacyReader: baseline.legacyReader + 1,
224+
scopedShell: baseline.scopedShell,
225+
scopedUnrelated: baseline.scopedUnrelated,
226+
scopedReader: baseline.scopedReader,
227+
scopedSkillReader: baseline.scopedSkillReader,
228+
});
229+
assert.deepEqual(
230+
legacyObservedTasks.map(({ id }) => id),
231+
['legacy-task'],
232+
);
233+
234+
const afterLegacy = { ...renders };
235+
await act(async () => {
236+
scoped.emit([task('scoped-task')]);
237+
await new Promise<void>((resolve) => setImmediate(resolve));
238+
});
239+
assert.deepEqual(renders, {
240+
legacyShell: afterLegacy.legacyShell,
241+
legacyUnrelated: afterLegacy.legacyUnrelated,
242+
legacyReader: afterLegacy.legacyReader,
243+
scopedShell: afterLegacy.scopedShell,
244+
scopedUnrelated: afterLegacy.scopedUnrelated,
245+
scopedReader: afterLegacy.scopedReader + 1,
246+
scopedSkillReader: afterLegacy.scopedSkillReader,
247+
});
248+
assert.deepEqual(
249+
scopedObservedTasks.map(({ id }) => id),
250+
['scoped-task'],
251+
);
252+
253+
const afterScheduledTasks = { ...renders };
254+
const previousSkillRevision = scopedObservedSkillRevision;
255+
await act(async () => {
256+
await commandPort.refreshProjectSkills();
257+
});
258+
assert.deepEqual(renders, {
259+
legacyShell: afterScheduledTasks.legacyShell,
260+
legacyUnrelated: afterScheduledTasks.legacyUnrelated,
261+
legacyReader: afterScheduledTasks.legacyReader,
262+
scopedShell: afterScheduledTasks.scopedShell,
263+
scopedUnrelated: afterScheduledTasks.scopedUnrelated,
264+
scopedReader: afterScheduledTasks.scopedReader,
265+
scopedSkillReader: afterScheduledTasks.scopedSkillReader + 1,
266+
});
267+
assert.equal(scopedObservedSkillRevision, previousSkillRevision + 1);
268+
});
269+
270+
test('command port keeps the newest controller through stale cleanup', async () => {
271+
const calls: string[] = [];
272+
const commands = (name: string): ModuleHubCommands => ({
273+
refreshProjectSkills: async () => {
274+
calls.push(`${name}:refresh`);
275+
},
276+
openScheduledTaskCreate: () => calls.push(`${name}:create`),
277+
copyTodayDailyReview: async () => {
278+
calls.push(`${name}:copy`);
279+
},
280+
pasteTodayDailyReview: async () => {
281+
calls.push(`${name}:paste`);
282+
},
283+
saveTodayDailyReview: async () => {
284+
calls.push(`${name}:save`);
285+
},
286+
});
287+
const port = createModuleHubCommandPort();
288+
const disconnectFirst = port.connect(commands('first'));
289+
const disconnectSecond = port.connect(commands('second'));
290+
291+
disconnectFirst();
292+
await port.refreshProjectSkills();
293+
port.openScheduledTaskCreate();
294+
assert.deepEqual(calls, ['second:refresh', 'second:create']);
295+
296+
disconnectSecond();
297+
await port.copyTodayDailyReview();
298+
assert.deepEqual(calls, ['second:refresh', 'second:create']);
299+
});

0 commit comments

Comments
 (0)