From ee097fb7d55cd640ddae6305074d474fdef53da9 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Wed, 2 Sep 2026 01:02:56 +0800 Subject: [PATCH 1/2] refactor(desktop): move Task Entry controller below AppShell Generated-by: OpenAI Codex --- apps/desktop/renderer-architecture.json | 12 +- .../__tests__/task-entry-boundary.test.ts | 48 ++- .../task-entry-provider-scope.test.ts | 189 ++++++++++++ apps/desktop/src/renderer/app-shell.tsx | 64 ++-- .../renderer/features/task-entry/README.md | 9 +- .../controller/use-task-entry-controller.ts | 7 +- .../src/renderer/features/task-entry/index.ts | 5 +- .../src/renderer/features/task-entry/ports.ts | 6 + .../renderer/features/task-entry/testing.ts | 6 + .../task-entry/ui/task-entry-host.tsx | 7 +- .../task-entry/ui/task-entry-provider.tsx | 289 ++++++++++++++++++ docs/astryx-surface-file-inventory.paths | 1 + scripts/check-app-shell-hooks.mjs | 1 - 13 files changed, 598 insertions(+), 46 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/task-entry-provider-scope.test.ts create mode 100644 apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 06a7759a64..2a9041eb16 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -294,6 +294,13 @@ "owner": "src/renderer/features/module-hub/ui/module-hub-provider.tsx", "ownerSymbol": "ModuleHubProvider", "count": 1 + }, + { + "implementation": "src/renderer/features/task-entry/controller/use-task-entry-controller.ts", + "symbol": "useTaskEntryController", + "owner": "src/renderer/features/task-entry/ui/task-entry-provider.tsx", + "ownerSymbol": "TaskEntryProvider", + "count": 1 } ], "legacyAppShell": { @@ -792,7 +799,6 @@ "useStableActions": 6, "useState": 17, "useSystemUiLocale": 1, - "useTaskEntryController": 1, "useTaskSubmissionReadiness": 1, "useToast": 1, "useTurnActionRegistry": 1, @@ -895,8 +901,8 @@ "@maka/ui/icons": 1, "react": 1 }, - "importSpecifiers": 147, - "nonTriviaTokens": 15588 + "importSpecifiers": 146, + "nonTriviaTokens": 15583 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, diff --git a/apps/desktop/src/main/__tests__/task-entry-boundary.test.ts b/apps/desktop/src/main/__tests__/task-entry-boundary.test.ts index 028d1b55d8..8b879d13b4 100644 --- a/apps/desktop/src/main/__tests__/task-entry-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/task-entry-boundary.test.ts @@ -83,6 +83,39 @@ describe('Task Entry feature boundary', () => { const productionEntry = readFileSync(join(featureRoot, 'index.ts'), 'utf8'); assert.equal(productionEntry.includes('createFakeTaskEntryServices'), false); assert.equal(productionEntry.includes("from './testing"), false); + assert.equal(productionEntry.includes('useTaskEntryController'), false); + assert.equal(productionEntry.includes('TaskEntryProvider,'), false); + assert.equal(productionEntry.includes('useTaskEntryOwnership'), false); + }); + + it('keeps the controller owned by TaskEntryProvider and out of renderer roots', () => { + const controllerOwner = join(featureRoot, 'ui', 'task-entry-provider.tsx'); + const consumers: string[] = []; + for (const path of sourceFiles(join(desktopRoot, 'src', 'renderer'))) { + if (!/\.tsx?$/.test(path) || path.endsWith('use-task-entry-controller.ts')) continue; + const source = readFileSync(path, 'utf8'); + if (/\buseTaskEntryController\s*\(/.test(source)) { + consumers.push(relative(desktopRoot, path)); + } + } + assert.deepEqual(consumers, [relative(desktopRoot, controllerOwner)]); + }); + + it('keeps the controller module behind TaskEntryProvider and the testing entry', () => { + const importers: string[] = []; + for (const path of sourceFiles(featureRoot)) { + if (!/\.tsx?$/.test(path)) continue; + const source = readFileSync(path, 'utf8'); + for (const match of source.matchAll(/from\s+['"]([^'"]+)['"]/g)) { + if (match[1]?.includes('controller/use-task-entry-controller')) { + importers.push(relative(desktopRoot, path)); + } + } + } + assert.deepEqual(importers.sort(), [ + 'src/renderer/features/task-entry/testing.ts', + 'src/renderer/features/task-entry/ui/task-entry-provider.tsx', + ]); }); it('keeps Task Entry catalog, picker, and directory handoff ownership out of AppShell', () => { @@ -96,10 +129,21 @@ describe('Task Entry feature boundary', () => { 'newTaskDraftKey(', 'RemoteProjectDirectoryDialog', 'const workspacePicker: WorkspacePickerModel', + 'useTaskEntryController', + 'useTaskEntryShellProjection', + 'taskEntry.host', + 'taskEntry.owner', + ''), true); + for (const required of [ + '', + '', + ]) { + assert.equal(appShell.includes(required), true, required); + } }); }); diff --git a/apps/desktop/src/main/__tests__/task-entry-provider-scope.test.ts b/apps/desktop/src/main/__tests__/task-entry-provider-scope.test.ts new file mode 100644 index 0000000000..a26743c806 --- /dev/null +++ b/apps/desktop/src/main/__tests__/task-entry-provider-scope.test.ts @@ -0,0 +1,189 @@ +/* + * 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 { strict as assert } from 'node:assert'; +import { afterEach, describe, it } from 'node:test'; +import { act, createElement, Fragment } from 'react'; +import { LocaleProvider, ToastProvider } from '@maka/ui'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { + createFakeTaskEntryServices, + TaskEntryRoot, + TaskEntryServicesProvider, + TaskEntryWorkspacePickerConsumer, + useTaskEntryHostModel, + type TaskEntryCatalog, + type TaskEntryHost, + type TaskEntryShellProjection, + type TaskEntryServices, +} from '../../renderer/features/task-entry/testing.js'; + +let shellRenders = 0; +let frameRenders = 0; +let workspaceRenders = 0; +let hostRenders = 0; +let latestTaskEntry: TaskEntryShellProjection | undefined; +let latestDirectoryHostId: string | undefined; +let latestWorkspaceGroupCount = 0; + +function project(id: string) { + return { + id, + name: id, + locations: [{ path: `/tmp/${id}`, isWorktree: false }], + available: true, + preferredPath: `/tmp/${id}`, + }; +} + +function remoteHost(): Extract { + return { + profile: { id: 'remote', name: 'Remote', kind: 'remote' }, + hostId: 'host-remote', + readiness: 'ready', + state: 'available', + projects: [project('project-a')], + capabilities: { + chooseClientDirectory: false, + chooseHostDirectory: true, + selectNoProject: false, + }, + selectedProjectId: 'project-a', + chatDefaults: { permissionMode: 'ask', thinkingLevel: 'high' }, + }; +} + +function catalog(): TaskEntryCatalog { + return { defaultProfileId: 'remote', hosts: [remoteHost()] }; +} + +function WorkspaceProbe() { + return createElement(TaskEntryWorkspacePickerConsumer, { + manageProjects() {}, + children: (workspacePicker) => { + workspaceRenders += 1; + latestWorkspaceGroupCount = workspacePicker.groups.length; + return null; + }, + }); +} + +function HostProbe() { + const host = useTaskEntryHostModel(); + hostRenders += 1; + latestDirectoryHostId = host.directoryHost?.hostId; + return null; +} + +function FrameProbe() { + frameRenders += 1; + return createElement(Fragment, null, createElement(WorkspaceProbe), createElement(HostProbe)); +} + +function ShellProbe() { + return createElement(TaskEntryRoot, { + children: (taskEntry) => { + shellRenders += 1; + latestTaskEntry = taskEntry; + return createElement(FrameProbe); + }, + }); +} + +function renderProvider( + root: ReturnType['root'], + services: TaskEntryServices, +) { + root.render( + createElement(LocaleProvider, { + locale: 'en', + children: createElement( + ToastProvider, + null, + createElement( + TaskEntryServicesProvider, + { services }, + createElement(ShellProbe), + ), + ), + }), + ); +} + +afterEach(() => { + shellRenders = 0; + frameRenders = 0; + workspaceRenders = 0; + hostRenders = 0; + latestTaskEntry = undefined; + latestDirectoryHostId = undefined; + latestWorkspaceGroupCount = 0; + cleanupFakeDom(); +}); + +describe('TaskEntryProvider render scope', () => { + it('keeps a controller-only directory handoff below the shell frame', async () => { + const { root } = installReactRenderer(); + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => catalog(), + }, + }); + + await act(async () => renderProvider(root, services)); + assert.equal(latestTaskEntry?.selectors.target?.hostId, 'host-remote'); + assert.equal(latestWorkspaceGroupCount, 1); + + const shellBefore = shellRenders; + const frameBefore = frameRenders; + const workspaceBefore = workspaceRenders; + const hostBefore = hostRenders; + await act(async () => latestTaskEntry?.commands.addProject()); + + assert.equal(latestDirectoryHostId, 'host-remote'); + assert.equal(shellRenders, shellBefore); + assert.equal(frameRenders, frameBefore); + assert.equal(workspaceRenders, workspaceBefore); + assert.equal(hostRenders, hostBefore + 1); + + await act(async () => root.unmount()); + }); + + it('retains the shell projection across an equivalent catalog refresh', async () => { + const { root } = installReactRenderer(); + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => catalog(), + }, + }); + + await act(async () => renderProvider(root, services)); + const shellBefore = shellRenders; + const frameBefore = frameRenders; + await act(async () => latestTaskEntry?.commands.refresh()); + + assert.equal(shellRenders, shellBefore); + assert.equal(frameRenders, frameBefore); + assert.equal(latestTaskEntry?.selectors.target?.projectId, 'project-a'); + + await act(async () => root.unmount()); + }); +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index f6473dd7e7..23367a057a 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -99,7 +99,7 @@ import { type SessionNavigationPorts, type SessionNavigationRowActions, } from './features/session-navigation'; -import { TaskEntryHost, useTaskEntryController } from './features/task-entry'; +import * as TaskEntry from './features/task-entry'; import { useNewTaskChoice } from './use-new-task-choice'; import { SessionCollaborationDialog } from './session-collaboration-dialog'; import * as SessionCollaboration from './features/session-collaboration'; @@ -257,6 +257,10 @@ type AppShellProps = { initialOnboardingSnapshot?: OnboardingSnapshot | null; }; +type TaskEntryShellProjection = Parameters< + ComponentProps['children'] +>[0]; + export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = {}) { const [uiLocalePreference, setUiLocalePreference] = useState('auto'); const [uiLocaleOverride, setUiLocaleOverride] = useState(null); @@ -287,13 +291,18 @@ export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = { - + + {(taskEntry) => ( + + )} + @@ -314,12 +323,14 @@ const SESSION_RAIL = ; function AppShellContent({ initialOnboardingSnapshot = null, + taskEntry, uiLocale, uiLocaleOverride, setUiLocaleOverride, setUiLocalePreference, }: { initialOnboardingSnapshot?: OnboardingSnapshot | null; + taskEntry: TaskEntryShellProjection; uiLocale: UiLocale; uiLocaleOverride: UiLocale | null; setUiLocaleOverride: Dispatch>; @@ -384,21 +395,8 @@ function AppShellContent({ } = useSettingsModal(); const onboarding = useOnboardingSnapshot(initialOnboardingSnapshot); - const reportTaskEntryError = useCallback< - Parameters[0]['reportError'] - >( - ({ title, description, profileId }) => { - toastApi.error(title, description, undefined, { profileId }); - }, - [toastApi], - ); - const taskEntry = useTaskEntryController({ - reportError: reportTaskEntryError, - manageProjects: openProjectSettings, - }); - // Named on its own because the rail depends on it: `taskEntry.commands` is a - // fresh object every render, so depending on the bag rather than the command - // would rebuild the rail's Project rows on every AppShell commit (#4109). + // The owner bridge keeps commands stable while TaskEntryProvider swaps the + // current feature-owned implementation below the shell. const { selectLocalProject } = taskEntry.commands; const currentNewTaskDraftKey = taskEntry.selectors.draftKey; // Staged files and quotes do NOT take the target-scoped key: they belong to @@ -1448,7 +1446,6 @@ function AppShellContent({ // Where a NEW chat starts. Built unconditionally and handed to the composer, // which renders it only while no session owns it — the project is fixed once // the first message creates one, so there is nothing to pick after that. - const workspacePicker = taskEntry.selectors.workspacePicker; const taskReadinessWorkspace = activeSession?.cwd ?? taskEntry.selectors.projectPath; const taskReadinessRequest = { ...resolveTaskReadinessModelTarget(activeSession, activeSessionSendOutcome, newChatModel), @@ -2626,9 +2623,12 @@ function AppShellContent({ : 'im_hub'; return ( - // Goal state and Module Hub ownership both live below the shell. Composer - // mentions still wrap the frame so one projection serves every composer, - // including side-chat panels, without rebuilding the frame on catalog moves. + // Feature controllers live below the shell. Task Entry publishes a stable + // shell projection plus reader-local Host/Workspace Picker projections; + // Goal state and Module Hub ownership likewise wake only their narrow + // readers. Composer mentions still wrap the frame so one projection serves + // every composer, including side-chat panels, without rebuilding the frame + // on catalog moves. ) : ( - + {(workspacePicker) => ( + + /> + )} + )} } @@ -3229,7 +3233,7 @@ function AppShellContent({ /> )} - + ; } -export function TaskEntryHost({ model }: { model: TaskEntryHostModel }) { +export function TaskEntryHost() { + return ; +} + +export function TaskEntryHostView({ model }: { model: TaskEntryHostModel }) { return ( void; + +interface TaskEntryOwner { + getState(): TaskEntryController; + subscribe(listener: Listener): () => void; + readonly commands: TaskEntryControllerCommands; +} + +export interface TaskEntryShellProjection { + readonly commands: TaskEntryControllerCommands; + readonly selectors: Omit; +} + +interface TaskEntryProviderProps { + readonly owner: TaskEntryOwner; + readonly children?: ReactNode; +} + +export interface TaskEntryRootProps { + readonly children: (taskEntry: TaskEntryShellProjection) => ReactNode; +} + +const EMPTY_WORKSPACE_PICKER: WorkspacePickerModel = { + pending: true, + groups: [], +}; + +const EMPTY_CONTROLLER: TaskEntryController = { + host: { + closeDirectoryPicker() {}, + async acceptRegisteredProject() {}, + }, + commands: { + async refresh() {}, + selectLocalProject: () => false, + addProject() {}, + async chooseProjectForProfile() {}, + }, + selectors: { + draftKey: taskEntryDraftKey(undefined), + defaultProfileId: 'local', + usesDefaultHost: true, + workspacePicker: EMPTY_WORKSPACE_PICKER, + canAddProject: false, + }, +}; + +const TaskEntryOwnerContext = createContext(null); + +function ignoreManageProjects(): void {} + +function createTaskEntryOwner(): TaskEntryOwner & { + publish(controller: TaskEntryController): void; +} { + let current = EMPTY_CONTROLLER; + const listeners = new Set(); + const owner = { + getState: () => current, + subscribe(listener: Listener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); + }, + commands: { + refresh: () => current.commands.refresh(), + selectLocalProject: (projectId: string) => + current.commands.selectLocalProject(projectId), + addProject: () => current.commands.addProject(), + chooseProjectForProfile: (profileId: string) => + current.commands.chooseProjectForProfile(profileId), + }, + publish(controller: TaskEntryController): void { + if (current === controller) return; + current = controller; + for (const listener of [...listeners]) listener(); + }, + }; + return owner; +} + +function useTaskEntrySelection( + owner: TaskEntryOwner, + select: (controller: TaskEntryController) => T, + isEqual: (previous: T, next: T) => boolean = Object.is, +): T { + const getSnapshot = useMemo(() => { + let cachedController: TaskEntryController | undefined; + let cachedSelection: T | undefined; + return (): T => { + const controller = owner.getState(); + if (controller === cachedController) return cachedSelection as T; + const next = select(controller); + if (cachedController === undefined || !isEqual(cachedSelection as T, next)) { + cachedSelection = next; + } + cachedController = controller; + return cachedSelection as T; + }; + }, [isEqual, owner, select]); + return useSyncExternalStore(owner.subscribe, getSnapshot, getSnapshot); +} + +function sameTarget( + previous: TaskEntryControllerSelectors['target'], + next: TaskEntryControllerSelectors['target'], +): boolean { + return previous === next || Boolean( + previous && + next && + previous.profileId === next.profileId && + previous.hostId === next.hostId && + previous.projectId === next.projectId, + ); +} + +function sameSelectedHost( + previous: TaskEntryControllerSelectors['selectedHost'], + next: TaskEntryControllerSelectors['selectedHost'], +): boolean { + return previous === next || Boolean( + previous && + next && + previous.profileId === next.profileId && + previous.hostId === next.hostId && + previous.name === next.name && + previous.kind === next.kind && + previous.chatDefaults.permissionMode === next.chatDefaults.permissionMode && + previous.chatDefaults.thinkingLevel === next.chatDefaults.thinkingLevel, + ); +} + +const selectShellSelectors = ( + controller: TaskEntryController, +): Omit => { + const { workspacePicker: _workspacePicker, ...selectors } = controller.selectors; + return selectors; +}; + +function sameShellSelectors( + previous: Omit, + next: Omit, +): boolean { + return ( + sameTarget(previous.target, next.target) && + previous.draftKey === next.draftKey && + previous.projectPath === next.projectPath && + sameSelectedHost(previous.selectedHost, next.selectedHost) && + previous.selectedProfileId === next.selectedProfileId && + previous.defaultProfileId === next.defaultProfileId && + previous.usesDefaultHost === next.usesDefaultHost && + previous.canAddProject === next.canAddProject + ); +} + +const selectWorkspacePicker = (controller: TaskEntryController): WorkspacePickerModel => + controller.selectors.workspacePicker; +const selectHost = (controller: TaskEntryController): TaskEntryHostModel => controller.host; + +function sameHost(previous: TaskEntryHostModel, next: TaskEntryHostModel): boolean { + return ( + previous.directoryHost?.profileId === next.directoryHost?.profileId && + previous.directoryHost?.hostId === next.directoryHost?.hostId && + previous.directoryHost?.name === next.directoryHost?.name && + previous.directoryOpener === next.directoryOpener && + previous.closeDirectoryPicker === next.closeDirectoryPicker && + previous.acceptRegisteredProject === next.acceptRegisteredProject + ); +} + +/** + * Creates the stable bridge AppShell reads without owning the Task Entry controller. + * Controller-only updates keep the same shell projection identity and therefore + * stop at the provider or the matching leaf reader. + */ +function useTaskEntryOwnership(): TaskEntryShellProjection & { readonly owner: TaskEntryOwner } { + const owner = useMemo(createTaskEntryOwner, []); + const selectors = useTaskEntrySelection(owner, selectShellSelectors, sameShellSelectors); + return useMemo( + () => ({ owner, commands: owner.commands, selectors }), + [owner, selectors], + ); +} + +/** Owns Task Entry catalog/selection lifecycle below AppShell. */ +export function TaskEntryProvider({ + owner: ownerInput, + children, +}: TaskEntryProviderProps) { + const owner = ownerInput as ReturnType; + const toastApi = useToast(); + const reportError = useCallback( + ({ title, description, profileId }: TaskEntryError) => { + toastApi.error(title, description, undefined, { profileId }); + }, + [toastApi], + ); + const controller = useTaskEntryController({ reportError, manageProjects: ignoreManageProjects }); + useLayoutEffect(() => owner.publish(controller), [controller, owner]); + + return ( + + {children} + + ); +} + +/** Mounts the controller owner and hands only its stable shell projection outward. */ +export function TaskEntryRoot({ children }: TaskEntryRootProps) { + const ownership = useTaskEntryOwnership(); + const taskEntry = useMemo( + () => ({ commands: ownership.commands, selectors: ownership.selectors }), + [ownership.commands, ownership.selectors], + ); + return ( + + {children(taskEntry)} + + ); +} + +function useTaskEntryOwner(): TaskEntryOwner { + const owner = useContext(TaskEntryOwnerContext); + if (!owner) throw new Error('TaskEntryProvider is missing'); + return owner; +} + +export function TaskEntryWorkspacePickerConsumer({ + manageProjects, + children, +}: { + readonly manageProjects: (profileId: string) => void; + readonly children: (workspacePicker: WorkspacePickerModel) => ReactNode; +}) { + const owner = useTaskEntryOwner(); + const controllerPicker = useTaskEntrySelection(owner, selectWorkspacePicker); + const workspacePicker = useMemo( + () => ({ + ...controllerPicker, + groups: controllerPicker.groups.map((group) => + group.onManage + ? { ...group, onManage: () => manageProjects(group.id) } + : group), + }), + [controllerPicker, manageProjects], + ); + return children(workspacePicker); +} + +export function useTaskEntryHostModel(): TaskEntryHostModel { + return useTaskEntrySelection(useTaskEntryOwner(), selectHost, sameHost); +} diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 557f6c2ef9..acc43c996b 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -43,6 +43,7 @@ apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-prov apps/desktop/src/renderer/features/session-settings/services-context.tsx apps/desktop/src/renderer/features/task-entry/services-context.tsx apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx +apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx apps/desktop/src/renderer/features/usage/services-context.tsx apps/desktop/src/renderer/features/usage/ui/metric-card.tsx apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index bc56032921..4ec98cf595 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -148,7 +148,6 @@ export const ALLOWED = { useShellSearch: 1, useStableActions: 6, useState: 15, - useTaskEntryController: 1, useTaskSubmissionReadiness: 1, useToast: 1, // The last of the three `useKeyedPendingRegistry` call sites this entry From 0151abc72a48bedfd6d7fe8e8bc37c54e975f0a3 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Sat, 5 Sep 2026 00:50:46 +0800 Subject: [PATCH 2/2] refactor(desktop): make TaskEntryRoot the registered Task Entry owner The controllerOwners guard from #4315 requires the component AppShell mounts to own the controller call, so TaskEntryRoot now calls useTaskEntryController itself and memoizes the render prop on the shell projection, which keeps the frame bail-out the inner provider used to provide. The public entry exports the projection type so AppShell no longer derives it from component props, and the boundary assertions the guard now proves are dropped. --- apps/desktop/renderer-architecture.json | 4 +- .../__tests__/task-entry-boundary.test.ts | 30 ----------- .../task-entry-provider-scope.test.ts | 2 +- apps/desktop/src/renderer/app-shell.tsx | 8 +-- .../renderer/features/task-entry/README.md | 2 +- .../src/renderer/features/task-entry/index.ts | 1 + .../task-entry/ui/task-entry-provider.tsx | 50 ++++++++----------- docs/astryx-surface-file-inventory.md | 3 +- 8 files changed, 30 insertions(+), 70 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 2a9041eb16..35a874454d 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -299,7 +299,7 @@ "implementation": "src/renderer/features/task-entry/controller/use-task-entry-controller.ts", "symbol": "useTaskEntryController", "owner": "src/renderer/features/task-entry/ui/task-entry-provider.tsx", - "ownerSymbol": "TaskEntryProvider", + "ownerSymbol": "TaskEntryRoot", "count": 1 } ], @@ -902,7 +902,7 @@ "react": 1 }, "importSpecifiers": 146, - "nonTriviaTokens": 15583 + "nonTriviaTokens": 15568 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, diff --git a/apps/desktop/src/main/__tests__/task-entry-boundary.test.ts b/apps/desktop/src/main/__tests__/task-entry-boundary.test.ts index 8b879d13b4..d112ad86c5 100644 --- a/apps/desktop/src/main/__tests__/task-entry-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/task-entry-boundary.test.ts @@ -83,40 +83,10 @@ describe('Task Entry feature boundary', () => { const productionEntry = readFileSync(join(featureRoot, 'index.ts'), 'utf8'); assert.equal(productionEntry.includes('createFakeTaskEntryServices'), false); assert.equal(productionEntry.includes("from './testing"), false); - assert.equal(productionEntry.includes('useTaskEntryController'), false); - assert.equal(productionEntry.includes('TaskEntryProvider,'), false); assert.equal(productionEntry.includes('useTaskEntryOwnership'), false); }); - it('keeps the controller owned by TaskEntryProvider and out of renderer roots', () => { - const controllerOwner = join(featureRoot, 'ui', 'task-entry-provider.tsx'); - const consumers: string[] = []; - for (const path of sourceFiles(join(desktopRoot, 'src', 'renderer'))) { - if (!/\.tsx?$/.test(path) || path.endsWith('use-task-entry-controller.ts')) continue; - const source = readFileSync(path, 'utf8'); - if (/\buseTaskEntryController\s*\(/.test(source)) { - consumers.push(relative(desktopRoot, path)); - } - } - assert.deepEqual(consumers, [relative(desktopRoot, controllerOwner)]); - }); - it('keeps the controller module behind TaskEntryProvider and the testing entry', () => { - const importers: string[] = []; - for (const path of sourceFiles(featureRoot)) { - if (!/\.tsx?$/.test(path)) continue; - const source = readFileSync(path, 'utf8'); - for (const match of source.matchAll(/from\s+['"]([^'"]+)['"]/g)) { - if (match[1]?.includes('controller/use-task-entry-controller')) { - importers.push(relative(desktopRoot, path)); - } - } - } - assert.deepEqual(importers.sort(), [ - 'src/renderer/features/task-entry/testing.ts', - 'src/renderer/features/task-entry/ui/task-entry-provider.tsx', - ]); - }); it('keeps Task Entry catalog, picker, and directory handoff ownership out of AppShell', () => { const appShell = readFileSync( diff --git a/apps/desktop/src/main/__tests__/task-entry-provider-scope.test.ts b/apps/desktop/src/main/__tests__/task-entry-provider-scope.test.ts index a26743c806..4619ae77ea 100644 --- a/apps/desktop/src/main/__tests__/task-entry-provider-scope.test.ts +++ b/apps/desktop/src/main/__tests__/task-entry-provider-scope.test.ts @@ -137,7 +137,7 @@ afterEach(() => { cleanupFakeDom(); }); -describe('TaskEntryProvider render scope', () => { +describe('TaskEntryRoot render scope', () => { it('keeps a controller-only directory handoff below the shell frame', async () => { const { root } = installReactRenderer(); const services = createFakeTaskEntryServices({ diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 23367a057a..a3cd6e7e68 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -25,7 +25,6 @@ import { useRef, useState, type CSSProperties, - type ComponentProps, type Dispatch, type SetStateAction, } from 'react'; @@ -100,6 +99,7 @@ import { type SessionNavigationRowActions, } from './features/session-navigation'; import * as TaskEntry from './features/task-entry'; +import type { TaskEntryShellProjection } from './features/task-entry'; import { useNewTaskChoice } from './use-new-task-choice'; import { SessionCollaborationDialog } from './session-collaboration-dialog'; import * as SessionCollaboration from './features/session-collaboration'; @@ -257,10 +257,6 @@ type AppShellProps = { initialOnboardingSnapshot?: OnboardingSnapshot | null; }; -type TaskEntryShellProjection = Parameters< - ComponentProps['children'] ->[0]; - export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = {}) { const [uiLocalePreference, setUiLocalePreference] = useState('auto'); const [uiLocaleOverride, setUiLocaleOverride] = useState(null); @@ -395,7 +391,7 @@ function AppShellContent({ } = useSettingsModal(); const onboarding = useOnboardingSnapshot(initialOnboardingSnapshot); - // The owner bridge keeps commands stable while TaskEntryProvider swaps the + // The owner bridge keeps commands stable while TaskEntryRoot swaps the // current feature-owned implementation below the shell. const { selectLocalProject } = taskEntry.commands; const currentNewTaskDraftKey = taskEntry.selectors.draftKey; diff --git a/apps/desktop/src/renderer/features/task-entry/README.md b/apps/desktop/src/renderer/features/task-entry/README.md index 3369a29de8..7895872f59 100644 --- a/apps/desktop/src/renderer/features/task-entry/README.md +++ b/apps/desktop/src/renderer/features/task-entry/README.md @@ -28,7 +28,7 @@ add/relink plus remote-directory handoff lifecycles. - Consumers import production APIs from `features/task-entry`. - Tests and stories may additionally import `features/task-entry/testing`. -- `TaskEntryRoot` / `TaskEntryProvider` are the only production owners of the +- `TaskEntryRoot` is the only production owner of the controller hook. Renderer roots receive a stable semantic projection rather than catalog lifecycle state or the controller itself. - Task Entry may use shared renderer copy, shared project UI, core/runtime-host diff --git a/apps/desktop/src/renderer/features/task-entry/index.ts b/apps/desktop/src/renderer/features/task-entry/index.ts index e9c169012e..daacd288f0 100644 --- a/apps/desktop/src/renderer/features/task-entry/index.ts +++ b/apps/desktop/src/renderer/features/task-entry/index.ts @@ -22,5 +22,6 @@ export { TaskEntryRoot, TaskEntryWorkspacePickerConsumer, } from './ui/task-entry-provider.js'; +export type { TaskEntryShellProjection } from './ui/task-entry-provider.js'; export { TaskEntryServicesProvider } from './services-context.js'; export type { TaskEntryServices } from './ports.js'; diff --git a/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx b/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx index 0266e938cf..659153ffb1 100644 --- a/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx +++ b/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx @@ -50,11 +50,6 @@ export interface TaskEntryShellProjection { readonly selectors: Omit; } -interface TaskEntryProviderProps { - readonly owner: TaskEntryOwner; - readonly children?: ReactNode; -} - export interface TaskEntryRootProps { readonly children: (taskEntry: TaskEntryShellProjection) => ReactNode; } @@ -206,9 +201,9 @@ function sameHost(previous: TaskEntryHostModel, next: TaskEntryHostModel): boole } /** - * Creates the stable bridge AppShell reads without owning the Task Entry controller. - * Controller-only updates keep the same shell projection identity and therefore - * stop at the provider or the matching leaf reader. + * Creates the stable bridge AppShell reads. Controller-only updates keep the + * same shell projection identity and therefore stop at the owner or the + * matching leaf reader. */ function useTaskEntryOwnership(): TaskEntryShellProjection & { readonly owner: TaskEntryOwner } { const owner = useMemo(createTaskEntryOwner, []); @@ -219,12 +214,19 @@ function useTaskEntryOwnership(): TaskEntryShellProjection & { readonly owner: T ); } -/** Owns Task Entry catalog/selection lifecycle below AppShell. */ -export function TaskEntryProvider({ - owner: ownerInput, - children, -}: TaskEntryProviderProps) { - const owner = ownerInput as ReturnType; +/** + * Owns the Task Entry controller below AppShell and hands only its stable + * shell projection outward. + * + * The render prop is memoized on that projection, so a controller-only update + * re-renders this one fiber and reuses the frame element it built last time; + * React bails out of the frame, and only the Host and Workspace Picker readers + * whose selection changed wake through the owner store. The shell's own reads + * arrive as `taskEntry`, whose identity moves only on a semantic change. + */ +export function TaskEntryRoot({ children }: TaskEntryRootProps) { + const ownership = useTaskEntryOwnership(); + const owner = ownership.owner as ReturnType; const toastApi = useToast(); const reportError = useCallback( ({ title, description, profileId }: TaskEntryError) => { @@ -234,31 +236,21 @@ export function TaskEntryProvider({ ); const controller = useTaskEntryController({ reportError, manageProjects: ignoreManageProjects }); useLayoutEffect(() => owner.publish(controller), [controller, owner]); - - return ( - - {children} - - ); -} - -/** Mounts the controller owner and hands only its stable shell projection outward. */ -export function TaskEntryRoot({ children }: TaskEntryRootProps) { - const ownership = useTaskEntryOwnership(); const taskEntry = useMemo( () => ({ commands: ownership.commands, selectors: ownership.selectors }), [ownership.commands, ownership.selectors], ); + const frame = useMemo(() => children(taskEntry), [children, taskEntry]); return ( - - {children(taskEntry)} - + + {frame} + ); } function useTaskEntryOwner(): TaskEntryOwner { const owner = useContext(TaskEntryOwnerContext); - if (!owner) throw new Error('TaskEntryProvider is missing'); + if (!owner) throw new Error('TaskEntryRoot is missing'); return owner; } diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 274904ffde..8c8043eebe 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 247 files — blocker 0, reimplementation 0, polish 1, aligned 246. +**Totals:** 248 files — blocker 0, reimplementation 0, polish 1, aligned 247. ## Exclusions (explicit) @@ -72,6 +72,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/session-settings/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/task-entry/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/usage/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/usage/ui/metric-card.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx` | other | Banner, Button, SegmentedControl, SegmentedControlItem, Selector, Switch, Tab, TabList, TextInput, Tooltip | aligned — uses Astryx (Banner, Button, SegmentedControl, SegmentedControlItem, Selector, Switch, Tab, TabList) | aligned |