diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 4570aa0895..9915c7e896 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": "TaskEntryRoot", + "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": 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 028d1b55d8..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,8 +83,11 @@ 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('useTaskEntryOwnership'), false); }); + + it('keeps Task Entry catalog, picker, and directory handoff ownership out of AppShell', () => { const appShell = readFileSync( join(desktopRoot, 'src', 'renderer', 'app-shell.tsx'), @@ -96,10 +99,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..4619ae77ea --- /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('TaskEntryRoot 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..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'; @@ -99,7 +98,8 @@ import { type SessionNavigationPorts, type SessionNavigationRowActions, } from './features/session-navigation'; -import { TaskEntryHost, useTaskEntryController } from './features/task-entry'; +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'; @@ -287,13 +287,18 @@ export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = { - + + {(taskEntry) => ( + + )} + @@ -314,12 +319,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 +391,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 TaskEntryRoot 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 +1442,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 +2619,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 +3229,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; +} + +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. 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, []); + const selectors = useTaskEntrySelection(owner, selectShellSelectors, sameShellSelectors); + return useMemo( + () => ({ owner, commands: owner.commands, selectors }), + [owner, selectors], + ); +} + +/** + * 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) => { + toastApi.error(title, description, undefined, { profileId }); + }, + [toastApi], + ); + const controller = useTaskEntryController({ reportError, manageProjects: ignoreManageProjects }); + useLayoutEffect(() => owner.publish(controller), [controller, owner]); + const taskEntry = useMemo( + () => ({ commands: ownership.commands, selectors: ownership.selectors }), + [ownership.commands, ownership.selectors], + ); + const frame = useMemo(() => children(taskEntry), [children, taskEntry]); + return ( + + {frame} + + ); +} + +function useTaskEntryOwner(): TaskEntryOwner { + const owner = useContext(TaskEntryOwnerContext); + if (!owner) throw new Error('TaskEntryRoot 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.md b/docs/astryx-surface-file-inventory.md index 6a8def3d1f..865709bd82 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:** 249 files — blocker 0, reimplementation 0, polish 1, aligned 248. +**Totals:** 250 files — blocker 0, reimplementation 0, polish 1, aligned 249. ## 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 | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index a00a5e0e85..275defce51 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