From 8c36d24f8c8df74eda829a5754fd3badf9c8719e Mon Sep 17 00:00:00 2001 From: chihumyum Date: Mon, 24 Aug 2026 22:35:01 +0800 Subject: [PATCH 1/2] refactor(desktop): extract Task Entry feature slice Move new-task Host and Project selection, catalog lifecycle, workspace picker projection, and remote-directory handoff behind a narrow renderer feature boundary. Generation-fence imperative catalog consumers and cover add and relink mutation lifecycles. Generated-by: Codex --- .../__tests__/task-entry-boundary.test.ts | 105 ++++ .../__tests__/task-entry-controller.test.ts | 385 ++++++++++++++ .../main/__tests__/task-entry-model.test.ts | 156 ++++++ .../task-entry-services-adapter.test.ts | 77 +++ apps/desktop/src/renderer/app-shell.tsx | 181 ++----- .../renderer/features/task-entry/README.md | 66 +++ .../controller/use-task-entry-controller.ts | 470 ++++++++++++++++++ .../src/renderer/features/task-entry/index.ts | 26 + .../task-entry/model/task-entry-selection.ts | 76 +++ .../src/renderer/features/task-entry/ports.ts | 98 ++++ .../features/task-entry/services-context.tsx | 40 ++ .../renderer/features/task-entry/testing.ts | 52 ++ .../task-entry/ui/task-entry-host.tsx | 45 ++ apps/desktop/src/renderer/main.tsx | 19 +- .../desktop/create-task-entry-services.ts | 38 ++ .../src/renderer/use-new-task-target.ts | 299 ----------- docs/astryx-surface-file-inventory.md | 4 +- docs/astryx-surface-file-inventory.paths | 2 + 18 files changed, 1696 insertions(+), 443 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/task-entry-boundary.test.ts create mode 100644 apps/desktop/src/main/__tests__/task-entry-controller.test.ts create mode 100644 apps/desktop/src/main/__tests__/task-entry-model.test.ts create mode 100644 apps/desktop/src/main/__tests__/task-entry-services-adapter.test.ts create mode 100644 apps/desktop/src/renderer/features/task-entry/README.md create mode 100644 apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts create mode 100644 apps/desktop/src/renderer/features/task-entry/index.ts create mode 100644 apps/desktop/src/renderer/features/task-entry/model/task-entry-selection.ts create mode 100644 apps/desktop/src/renderer/features/task-entry/ports.ts create mode 100644 apps/desktop/src/renderer/features/task-entry/services-context.tsx create mode 100644 apps/desktop/src/renderer/features/task-entry/testing.ts create mode 100644 apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx create mode 100644 apps/desktop/src/renderer/platform/desktop/create-task-entry-services.ts delete mode 100644 apps/desktop/src/renderer/use-new-task-target.ts diff --git a/apps/desktop/src/main/__tests__/task-entry-boundary.test.ts b/apps/desktop/src/main/__tests__/task-entry-boundary.test.ts new file mode 100644 index 0000000000..028d1b55d8 --- /dev/null +++ b/apps/desktop/src/main/__tests__/task-entry-boundary.test.ts @@ -0,0 +1,105 @@ +/* + * 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 { readdirSync, readFileSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const desktopRoot = resolve(fileURLToPath(new URL('../../../', import.meta.url))); +const featureRoot = join(desktopRoot, 'src', 'renderer', 'features', 'task-entry'); + +function sourceFiles(root: string): string[] { + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const path = join(root, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + return /\.(?:ts|tsx|md)$/.test(entry.name) ? [path] : []; + }); +} + +describe('Task Entry feature boundary', () => { + it('contains no Desktop global bridge or shell/process imports', () => { + const violations: string[] = []; + for (const path of sourceFiles(featureRoot)) { + const source = readFileSync(path, 'utf8'); + const name = relative(desktopRoot, path); + if (source.includes('window.maka')) violations.push(`${name}: Desktop global`); + for (const match of source.matchAll(/from\s+['"]([^'"]+)['"]/g)) { + const imported = match[1] ?? ''; + if ( + imported.includes('app-shell') || + imported.includes('/preload/') || + imported.includes('/main/') + ) { + violations.push(`${name}: ${imported}`); + } + } + } + assert.deepEqual(violations, []); + }); + + it('is consumed outside the feature only through public entries', () => { + const allowed = /\/features\/task-entry\/(?:index|testing)(?:\.js)?$/; + const violations: string[] = []; + for (const root of [join(desktopRoot, 'src'), join(desktopRoot, 'stories')]) { + for (const path of sourceFiles(root)) { + if (path.startsWith(featureRoot)) continue; + const source = readFileSync(path, 'utf8'); + for (const match of source.matchAll( + /from\s+['"]([^'"]*features\/task-entry[^'"]*)['"]/g, + )) { + const imported = match[1] ?? ''; + const normalized = imported.replace(/\\/g, '/'); + const explicitEntry = normalized.endsWith('/features/task-entry') + ? `${normalized}/index` + : normalized; + if (!allowed.test(explicitEntry)) { + violations.push(`${relative(desktopRoot, path)}: ${imported}`); + } + } + } + } + assert.deepEqual(violations, []); + }); + + it('keeps fake services out of the production entry', () => { + const productionEntry = readFileSync(join(featureRoot, 'index.ts'), 'utf8'); + assert.equal(productionEntry.includes('createFakeTaskEntryServices'), false); + assert.equal(productionEntry.includes("from './testing"), false); + }); + + it('keeps Task Entry catalog, picker, and directory handoff ownership out of AppShell', () => { + const appShell = readFileSync( + join(desktopRoot, 'src', 'renderer', 'app-shell.tsx'), + 'utf8', + ); + for (const forbidden of [ + 'useNewTaskTarget', + 'newTask.catalog', + 'newTaskDraftKey(', + 'RemoteProjectDirectoryDialog', + 'const workspacePicker: WorkspacePickerModel', + ]) { + assert.equal(appShell.includes(forbidden), false, forbidden); + } + assert.equal(appShell.includes('const taskEntry = useTaskEntryController({'), true); + assert.equal(appShell.includes(''), true); + }); +}); diff --git a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts new file mode 100644 index 0000000000..7e44cf45c1 --- /dev/null +++ b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts @@ -0,0 +1,385 @@ +/* + * 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 } from 'react'; +import { LocaleProvider } from '@maka/ui'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { + createFakeTaskEntryServices, + TaskEntryServicesProvider, + useTaskEntryController, + type TaskEntryCatalog, + type TaskEntryController, + type TaskEntryHost, + type TaskEntryServices, +} from '../../renderer/features/task-entry/testing.js'; + +function project(id: string) { + return { + id, + name: id, + locations: [{ path: `/tmp/${id}`, isWorktree: false }], + available: true, + preferredPath: `/tmp/${id}`, + }; +} + +function readyHost(input: { + hostId?: string; + projects?: ReturnType[]; + selectedProjectId?: string | null; + chooseClientDirectory?: boolean; + chooseHostDirectory?: boolean; + selectNoProject?: boolean; +} = {}): Extract { + return { + profile: { id: 'local', name: 'Local', kind: 'local' }, + hostId: input.hostId ?? 'host-local', + readiness: 'ready', + state: 'available', + projects: input.projects ?? [project('project-a')], + capabilities: { + chooseClientDirectory: input.chooseClientDirectory ?? true, + chooseHostDirectory: input.chooseHostDirectory ?? false, + selectNoProject: input.selectNoProject ?? false, + }, + selectedProjectId: input.selectedProjectId ?? 'project-a', + chatDefaults: { permissionMode: 'ask', thinkingLevel: 'high' }, + branch: 'main', + }; +} + +function catalog(host: TaskEntryHost = readyHost()): TaskEntryCatalog { + return { defaultProfileId: 'local', hosts: [host] }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((accept, decline) => { + resolve = accept; + reject = decline; + }); + return { promise, resolve, reject }; +} + +let latestController: TaskEntryController | undefined; + +function ControllerProbe(props: { reportError(error: unknown): void }) { + latestController = useTaskEntryController({ reportError: props.reportError }); + return null; +} + +function controller(): TaskEntryController { + assert.ok(latestController); + return latestController; +} + +function renderController( + root: ReturnType['root'], + services: TaskEntryServices, + errors: unknown[] = [], +) { + root.render( + createElement(LocaleProvider, { + locale: 'en', + children: createElement( + TaskEntryServicesProvider, + { services }, + createElement(ControllerProbe, { + reportError: (error: unknown) => errors.push(error), + }), + ), + }), + ); +} + +afterEach(() => { + latestController = undefined; + cleanupFakeDom(); +}); + +describe('useTaskEntryController', () => { + it('projects the canonical target, draft identity, Host defaults, and Workspace Picker', async () => { + const { root } = installReactRenderer(); + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => catalog(), + }, + }); + + await act(async () => renderController(root, services)); + + assert.deepEqual(controller().selectors.target, { + profileId: 'local', + hostId: 'host-local', + projectId: 'project-a', + }); + assert.equal(controller().selectors.projectPath, '/tmp/project-a'); + assert.equal(controller().selectors.selectedHost?.chatDefaults.thinkingLevel, 'high'); + assert.equal(controller().selectors.usesDefaultHost, true); + assert.equal(controller().selectors.workspacePicker.label, 'project-a'); + assert.equal(controller().selectors.workspacePicker.branch, 'main'); + assert.equal(controller().selectors.workspacePicker.groups[0]?.selectedProjectId, 'project-a'); + assert.match(controller().selectors.draftKey, /host-local.*project-a/); + }); + + it('commits only the latest catalog refresh and releases its subscription', async () => { + const { root } = installReactRenderer(); + const first = deferred(); + const second = deferred(); + let reads = 0; + let emit: (() => void) | undefined; + let disposed = 0; + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: () => (++reads === 1 ? first.promise : second.promise), + subscribeChanges: (handler) => { + emit = handler; + return () => { + disposed += 1; + }; + }, + }, + }); + + await act(async () => renderController(root, services)); + await act(async () => emit?.()); + await act(async () => second.resolve(catalog(readyHost({ hostId: 'new-generation' })))); + assert.equal(controller().selectors.target?.hostId, 'new-generation'); + + await act(async () => first.resolve(catalog(readyHost({ hostId: 'stale-generation' })))); + assert.equal(controller().selectors.target?.hostId, 'new-generation'); + + await act(async () => root.unmount()); + assert.equal(disposed, 1); + }); + + it('deduplicates add requests and selects the returned Project before refreshing', async () => { + const { root } = installReactRenderer(); + const added = deferred<{ + ok: true; + project: ReturnType; + }>(); + let addCalls = 0; + let reads = 0; + const initialHost = readyHost(); + const refreshedHost = readyHost({ + projects: [project('project-a'), project('project-b')], + selectedProjectId: 'project-a', + }); + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => catalog(++reads === 1 ? initialHost : refreshedHost), + addProject: () => { + addCalls += 1; + return added.promise; + }, + }, + }); + + await act(async () => renderController(root, services)); + await act(async () => { + controller().selectors.workspacePicker.groups[0]?.onAdd?.(); + controller().selectors.workspacePicker.groups[0]?.onAdd?.(); + }); + assert.equal(addCalls, 1); + assert.equal(controller().selectors.workspacePicker.pending, true); + + await act(async () => added.resolve({ ok: true, project: project('project-b') })); + assert.equal(controller().selectors.target?.projectId, 'project-b'); + assert.equal(controller().selectors.workspacePicker.pending, false); + }); + + it('deduplicates relink requests and selects the returned Project before refreshing', async () => { + const { root } = installReactRenderer(); + const relinked = deferred<{ + ok: true; + project: ReturnType; + }>(); + const relinkCalls: Array<{ + host: { profileId: string; hostId: string }; + projectId: string; + }> = []; + let reads = 0; + const initialHost = readyHost(); + const refreshedHost = readyHost({ + projects: [project('project-a'), project('project-b')], + selectedProjectId: 'project-a', + }); + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => catalog(++reads === 1 ? initialHost : refreshedHost), + relinkProject: (host, projectId) => { + relinkCalls.push({ host, projectId }); + return relinked.promise; + }, + }, + }); + + await act(async () => renderController(root, services)); + await act(async () => { + controller().selectors.workspacePicker.groups[0]?.onRelink?.('project-a'); + controller().selectors.workspacePicker.groups[0]?.onRelink?.('project-a'); + }); + assert.deepEqual(relinkCalls, [{ + host: { profileId: 'local', hostId: 'host-local' }, + projectId: 'project-a', + }]); + assert.equal(controller().selectors.workspacePicker.pending, true); + + await act(async () => relinked.resolve({ ok: true, project: project('project-b') })); + assert.equal(controller().selectors.target?.projectId, 'project-b'); + assert.equal(controller().selectors.workspacePicker.pending, false); + }); + + it('fences remote directory registration by Host generation', async () => { + const { root } = installReactRenderer(); + let reads = 0; + const remote = { + ...readyHost({ chooseClientDirectory: false, chooseHostDirectory: true }), + profile: { + id: 'remote', + name: 'Remote', + kind: 'remote' as const, + }, + hostId: 'remote-generation', + }; + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => { + reads += 1; + return { + defaultProfileId: 'remote', + hosts: [remote], + }; + }, + }, + }); + + await act(async () => renderController(root, services)); + await act(async () => controller().commands.addProject()); + assert.equal(controller().host.directoryHost?.hostId, 'remote-generation'); + + await act(async () => controller().host.acceptRegisteredProject( + project('wrong'), + { profileId: 'remote', hostId: 'old-generation' }, + )); + assert.equal(controller().host.directoryHost?.hostId, 'remote-generation'); + + await act(async () => controller().host.acceptRegisteredProject( + project('project-b'), + { profileId: 'remote', hostId: 'remote-generation' }, + )); + assert.equal(controller().host.directoryHost, undefined); + assert.equal(reads, 2); + }); + + it('closes a remote directory handoff when the Host generation changes', async () => { + const { root } = installReactRenderer(); + let reads = 0; + const remoteHost = (hostId: string) => ({ + ...readyHost({ chooseClientDirectory: false, chooseHostDirectory: true }), + profile: { + id: 'remote', + name: 'Remote', + kind: 'remote' as const, + }, + hostId, + }); + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => ({ + defaultProfileId: 'remote', + hosts: [remoteHost(++reads === 1 ? 'generation-a' : 'generation-b')], + }), + }, + }); + + await act(async () => renderController(root, services)); + await act(async () => controller().commands.addProject()); + assert.equal(controller().host.directoryHost?.hostId, 'generation-a'); + + await act(async () => controller().commands.refresh()); + assert.equal(controller().selectors.target?.hostId, 'generation-b'); + assert.equal(controller().host.directoryHost, undefined); + + await act(async () => controller().host.acceptRegisteredProject( + project('stale-project'), + { profileId: 'remote', hostId: 'generation-a' }, + )); + assert.equal(reads, 2); + }); + + it('opens a newly added remote Host from the committed catalog generation', async () => { + const { root } = installReactRenderer(); + const stale = deferred(); + const current = deferred(); + let reads = 0; + const remoteHost = (hostId: string) => ({ + ...readyHost({ chooseClientDirectory: false, chooseHostDirectory: true }), + profile: { + id: 'remote', + name: 'Remote', + kind: 'remote' as const, + }, + hostId, + }); + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => { + reads += 1; + if (reads === 1) { + return { defaultProfileId: 'remote', hosts: [remoteHost('initial')] }; + } + return reads === 2 ? stale.promise : current.promise; + }, + }, + }); + + await act(async () => renderController(root, services)); + let choose!: Promise; + let refresh!: Promise; + await act(async () => { + choose = controller().commands.chooseProjectForProfile('remote'); + refresh = controller().commands.refresh(); + }); + await act(async () => current.resolve({ + defaultProfileId: 'remote', + hosts: [remoteHost('current-generation')], + })); + await act(async () => stale.resolve({ + defaultProfileId: 'remote', + hosts: [remoteHost('stale-generation')], + })); + await act(async () => Promise.all([choose, refresh])); + + assert.equal(controller().selectors.target?.hostId, 'current-generation'); + assert.equal(controller().host.directoryHost?.hostId, 'current-generation'); + }); +}); diff --git a/apps/desktop/src/main/__tests__/task-entry-model.test.ts b/apps/desktop/src/main/__tests__/task-entry-model.test.ts new file mode 100644 index 0000000000..f1fe25285e --- /dev/null +++ b/apps/desktop/src/main/__tests__/task-entry-model.test.ts @@ -0,0 +1,156 @@ +/* + * 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 { describe, it } from 'node:test'; +import { UNRESOLVED_NEW_TASK_DRAFT_KEY } from '../../renderer/new-task-reload-intent.js'; +import { + resolveProjectSelection, + selectAvailableProfile, + taskEntryDraftKey, + type TaskEntryHost, +} from '../../renderer/features/task-entry/testing.js'; + +function project( + id: string, + options: { available?: boolean; archivedAt?: number; aliases?: string[] } = {}, +) { + return { + id, + ...(options.aliases ? { aliases: options.aliases } : {}), + name: id, + locations: [{ path: `/tmp/${id}`, isWorktree: false }], + available: options.available ?? true, + preferredPath: `/tmp/${id}`, + ...(options.archivedAt === undefined ? {} : { archivedAt: options.archivedAt }), + }; +} + +function readyHost(input: { + id: string; + kind?: 'local' | 'remote'; + selectedProjectId?: string | null; + defaultProjectId?: string; + selectNoProject?: boolean; + projects?: ReturnType[]; +}): Extract { + const profile = input.kind === 'remote' + ? { + id: input.id, + name: input.id, + kind: 'remote' as const, + } + : { id: 'local' as const, name: 'Local' as const, kind: 'local' as const }; + return { + profile, + hostId: `host-${input.id}`, + readiness: 'ready', + state: 'available', + projects: input.projects ?? [], + capabilities: { + chooseClientDirectory: true, + chooseHostDirectory: false, + selectNoProject: input.selectNoProject ?? false, + }, + selectedProjectId: input.selectedProjectId, + ...(input.defaultProjectId ? { defaultProjectId: input.defaultProjectId } : {}), + chatDefaults: { permissionMode: 'ask' }, + }; +} + +describe('Task Entry model', () => { + it('keeps an available profile then falls back through default and first available', () => { + const local = readyHost({ id: 'local' }); + const remote = readyHost({ id: 'remote', kind: 'remote' }); + const unavailable = { + profile: { + id: 'offline', + name: 'Offline', + kind: 'remote' as const, + }, + readiness: 'unavailable' as const, + }; + + assert.equal( + selectAvailableProfile( + { defaultProfileId: 'local', hosts: [local, remote] }, + 'remote', + ), + 'remote', + ); + assert.equal( + selectAvailableProfile( + { defaultProfileId: 'local', hosts: [remote, local] }, + 'missing', + ), + 'local', + ); + assert.equal( + selectAvailableProfile( + { defaultProfileId: 'missing', hosts: [unavailable, remote] }, + undefined, + ), + 'remote', + ); + assert.equal( + selectAvailableProfile( + { defaultProfileId: 'offline', hosts: [unavailable] }, + undefined, + ), + 'offline', + ); + }); + + it('rejects unavailable requests and preserves the Host project fallback order', () => { + const host = readyHost({ + id: 'local', + projects: [ + project('canonical', { aliases: ['legacy'] }), + project('default'), + project('missing', { available: false }), + project('archived', { archivedAt: 1 }), + ], + defaultProjectId: 'default', + selectedProjectId: 'canonical', + selectNoProject: true, + }); + + assert.equal(resolveProjectSelection(host, 'legacy'), 'canonical'); + assert.equal(resolveProjectSelection(host, 'missing'), 'default'); + assert.equal(resolveProjectSelection(host, 'archived'), 'default'); + assert.equal(resolveProjectSelection(host, null), null); + + const withoutDefault = { ...host, defaultProjectId: undefined }; + assert.equal(resolveProjectSelection(withoutDefault, 'unknown'), 'canonical'); + const withoutSelected = { ...withoutDefault, selectedProjectId: undefined }; + assert.equal(resolveProjectSelection(withoutSelected, 'unknown'), null); + }); + + it('uses Host generation and canonical Project identity in the draft key', () => { + assert.equal(taskEntryDraftKey(undefined), UNRESOLVED_NEW_TASK_DRAFT_KEY); + assert.notEqual( + taskEntryDraftKey({ profileId: 'remote', hostId: 'generation-a', projectId: 'p' }), + taskEntryDraftKey({ profileId: 'remote', hostId: 'generation-b', projectId: 'p' }), + ); + assert.notEqual( + taskEntryDraftKey({ profileId: 'remote', hostId: 'generation-a', projectId: 'p' }), + taskEntryDraftKey({ profileId: 'remote', hostId: 'generation-a', projectId: null }), + ); + }); +}); diff --git a/apps/desktop/src/main/__tests__/task-entry-services-adapter.test.ts b/apps/desktop/src/main/__tests__/task-entry-services-adapter.test.ts new file mode 100644 index 0000000000..8ccadd0cc4 --- /dev/null +++ b/apps/desktop/src/main/__tests__/task-entry-services-adapter.test.ts @@ -0,0 +1,77 @@ +/* + * 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 { describe, it } from 'node:test'; +import type { MakaBridge } from '../../preload/bridge-contract.js'; +import { createDesktopTaskEntryServices } from '../../renderer/platform/desktop/create-task-entry-services.js'; + +describe('createDesktopTaskEntryServices', () => { + it('maps only the Task Entry catalog and Project selection operations', async () => { + const calls: Array<{ name: string; args: unknown[] }> = []; + let changeHandler: (() => void) | undefined; + let changes = 0; + let disposed = 0; + const catalog = { defaultProfileId: 'local', hosts: [] }; + const cancelled = { ok: false as const, reason: 'cancelled' as const }; + const bridge = { + newTasks: { + getCatalog: async () => { + calls.push({ name: 'getCatalog', args: [] }); + return catalog; + }, + subscribeChanges: (handler: () => void) => { + calls.push({ name: 'subscribeChanges', args: [] }); + changeHandler = handler; + return () => { + disposed += 1; + }; + }, + addProject: async (...args: unknown[]) => { + calls.push({ name: 'addProject', args }); + return cancelled; + }, + relinkProject: async (...args: unknown[]) => { + calls.push({ name: 'relinkProject', args }); + return cancelled; + }, + }, + } as unknown as Pick; + const services = createDesktopTaskEntryServices(bridge); + const host = { profileId: 'remote', hostId: 'host-1' }; + + assert.equal(await services.catalog.getCatalog(), catalog); + const unsubscribe = services.catalog.subscribeChanges(() => { + changes += 1; + }); + changeHandler?.(); + await services.catalog.addProject(host); + await services.catalog.relinkProject(host, 'project-1'); + unsubscribe(); + + assert.deepEqual(calls, [ + { name: 'getCatalog', args: [] }, + { name: 'subscribeChanges', args: [] }, + { name: 'addProject', args: [host] }, + { name: 'relinkProject', args: [host, 'project-1'] }, + ]); + assert.equal(changes, 1); + assert.equal(disposed, 1); + }); +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 30ed340d22..563a6e777a 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -62,12 +62,10 @@ import { type SessionViewMode, TitlebarSessionIdentity, type TurnFooterActionMeta, - type WorkspacePickerModel, useToast, activeInteractionFor, deriveTitlebarProjectName, enqueueInteraction, - getConversationCopy, reconcileInteractions, } from '@maka/ui'; import type { ConnectionEvent } from '@maka/core/connections'; @@ -94,7 +92,11 @@ import { } from './features/workbar'; import { GoalHost, useGoalController } from './features/goals'; import { ModuleHubHost, useModuleHubController } from './features/module-hub'; -import { UNRESOLVED_NEW_TASK_DRAFT_KEY } from './new-task-reload-intent'; +import { + TaskEntryHost, + useTaskEntryController, + type TaskEntryError, +} from './features/task-entry'; import { useNewTaskChoice } from './use-new-task-choice'; import { NEW_TASK_PENDING_KEY } from './pending-items'; import { parseDesktopSlashCommand } from './desktop-slash-command'; @@ -216,7 +218,6 @@ import { useComposerMentions } from './use-composer-mentions'; import { useAppShellSessionWorkspace } from './use-app-shell-session-workspace'; import { useShellMemoryPill } from './use-shell-memory-pill'; import { useShellConnections } from './use-shell-connections'; -import { useNewTaskTarget } from './use-new-task-target'; import { useShellChatModel } from './use-shell-chat-model'; import { useShellLiveTurn } from './use-shell-live-turn'; import { useShellLayout } from './use-shell-layout'; @@ -239,7 +240,6 @@ function rebaseWorkspaceFileReferences( } import { useSettingsModal } from './use-settings-modal'; -import { RemoteProjectDirectoryDialog } from './remote-project-directory-dialog'; import { useSystemUiLocale } from './use-system-ui-locale'; import { isSessionWorkspaceUnavailableError, @@ -254,16 +254,6 @@ type ComposerImportOwner = { newTaskDraftKey?: string; }; -function newTaskDraftKey(target: { - profileId: string; - hostId: string; - projectId: string | null; -} | undefined): string { - return target - ? JSON.stringify(['new-task', target.profileId, target.hostId, target.projectId]) - : UNRESOLVED_NEW_TASK_DRAFT_KEY; -} - /** * Grace period before the committed-history fallback force-settles an * assistant stream slot when the primary post-commit signal is missed. @@ -385,8 +375,16 @@ function AppShellContent({ }, []); const onboarding = useOnboardingSnapshot(initialOnboardingSnapshot); - const newTask = useNewTaskTarget({ toastApi, uiLocale }); - const currentNewTaskDraftKey = newTaskDraftKey(newTask.target); + const reportTaskEntryError = useCallback( + ({ title, description, profileId }: TaskEntryError) => { + toastApi.error(title, description, undefined, { profileId }); + }, + [toastApi], + ); + const taskEntry = useTaskEntryController({ + reportError: reportTaskEntryError, + }); + const currentNewTaskDraftKey = taskEntry.selectors.draftKey; // Staged files and quotes do NOT take the target-scoped key: they belong to // the composer the user is looking at, and an in-flight send needs an owner // that cannot move under it. See NEW_TASK_PENDING_KEY. @@ -483,8 +481,11 @@ function AppShellContent({ uiLocale, sessionId: activeId, }); - const newTaskHost = newTask.selectedHost - ? { profileId: newTask.selectedHost.profile.id, hostId: newTask.selectedHost.hostId } + const newTaskHost = taskEntry.selectors.selectedHost + ? { + profileId: taskEntry.selectors.selectedHost.profileId, + hostId: taskEntry.selectors.selectedHost.hostId, + } : undefined; const newTaskConnections = useShellConnections({ toastApi, @@ -503,9 +504,7 @@ function AppShellContent({ }); const startupConnectionSnapshot = initialOnboardingSnapshot ?? onboarding.mountedSnapshotHandoff; - const newTaskUsesDefaultHost = - newTask.catalog.hosts.length === 0 || - newTask.selectedProfileId === newTask.catalog.defaultProfileId; + const newTaskUsesDefaultHost = taskEntry.selectors.usesDefaultHost; let newTaskConnectionSnapshot = newTaskConnections.snapshot; if (!newTaskConnections.hasSnapshot && newTaskUsesDefaultHost) { newTaskConnectionSnapshot = defaultHostConnections.hasSnapshot @@ -579,7 +578,6 @@ function AppShellContent({ setUiLocalePreference, }); const shellCopy = getShellCopy(uiLocale).app; - const projectActionsCopy = getShellCopy(uiLocale).projectActions; const desktopConversationCopy = getDesktopConversationCopy(uiLocale); /** * What this draft would start in: the user's choice for it if they made one, @@ -590,7 +588,9 @@ function AppShellContent({ * never written back to `chatDefaults` — the Settings surface owns that. */ const newTaskPermissionMode = - newTaskPermissionChoice ?? newTask.selectedHost?.chatDefaults.permissionMode ?? 'ask'; + newTaskPermissionChoice ?? + taskEntry.selectors.selectedHost?.chatDefaults.permissionMode ?? + 'ask'; const setNewTaskPermissionMode = setNewTaskPermissionChoice; useEffect(() => { if (!isAppUpdateInstallFailure(appUpdateStatus)) { @@ -778,13 +778,13 @@ function AppShellContent({ : undefined; const composerProfileId = activeId ? activeDesktopSession?.profileId - : newTask.selectedProfileId; + : taskEntry.selectors.selectedProfileId; const composerProfileName = activeId ? activeDesktopSession?.profileName - : newTask.selectedHost?.profile.name; + : taskEntry.selectors.selectedHost?.name; const modelSettingsOwnsComposerHost = composerProfileId !== undefined && - composerProfileId === newTask.catalog.defaultProfileId; + composerProfileId === taskEntry.selectors.defaultProfileId; const { chatModelChoices, activeConnection, @@ -814,7 +814,7 @@ function AppShellContent({ activeSession, persistedComposerDefaults, usePersistedComposerDefaults: modelSettingsOwnsComposerHost, - defaultThinkingLevel: newTask.selectedHost?.chatDefaults.thinkingLevel, + defaultThinkingLevel: taskEntry.selectors.selectedHost?.chatDefaults.thinkingLevel, openSettingsSection, }); const newChatProviderType = newChatModel @@ -1487,72 +1487,8 @@ 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 selectedNewTaskHost = newTask.catalog.hosts.find( - (host) => host.profile.id === newTask.selectedProfileId, - ); - const selectedNewTaskProject = newTask.currentProject?.name ?? - (newTask.selectedProjectId === null && newTask.selectedHost?.capabilities.selectNoProject - ? getConversationCopy(uiLocale).workspace.noProject - : undefined); - const selectedNewTaskBranch = - newTask.selectedHost && - newTask.selectedProjectId === newTask.selectedHost.selectedProjectId - ? newTask.selectedHost.branch - : undefined; - const newTaskCatalogNeedsRetry = Boolean(newTask.error) || newTask.catalog.hosts.some( - (host) => host.readiness === 'ready' && host.state === 'error', - ); - const workspacePicker: WorkspacePickerModel = { - label: selectedNewTaskProject ?? selectedNewTaskHost?.profile.name ?? - (newTask.error ? getShellCopy(uiLocale).projectActions.catalogUnavailable : undefined), - ...(selectedNewTaskHost?.profile.kind === 'remote' - ? { hostBadge: selectedNewTaskHost.profile.name } - : {}), - branch: newTask.selectedProjectId === null ? null : selectedNewTaskBranch, - pending: newTask.pending || (newTask.refreshing && newTask.catalog.hosts.length === 0), - selectedGroupId: newTask.selectedProfileId, - groups: newTask.catalog.hosts.map((host) => { - if (host.readiness !== 'ready' || host.state !== 'available') { - return { - id: host.profile.id, - label: host.profile.name, - status: host.readiness === 'ready' - ? host.message - : getShellCopy(uiLocale).projectActions.runtimeHostReadiness[host.readiness], - disabled: true, - projects: [], - }; - } - const selectedProjectId = host.profile.id === newTask.selectedProfileId - ? newTask.selectedProjectId - : host.selectedProjectId; - return { - id: host.profile.id, - label: host.profile.name, - projects: host.projects.filter((project) => project.archivedAt === undefined), - selectedProjectId, - onSelectProject: (projectId: string) => newTask.selectProject(host, projectId), - ...(host.capabilities.chooseClientDirectory || host.capabilities.chooseHostDirectory - ? { onAdd: () => void newTask.addProject(host) } - : {}), - ...(host.capabilities.chooseClientDirectory - ? { onRelink: (projectId: string) => void newTask.relinkProject(host, projectId) } - : {}), - ...(host.capabilities.selectNoProject - ? { onSelectNoProject: () => newTask.selectNoProject(host) } - : {}), - }; - }), - ...(newTaskCatalogNeedsRetry - ? { - retry: { - label: getShellCopy(uiLocale).projectActions.retryCatalog, - onClick: () => void newTask.refresh().catch(() => undefined), - }, - } - : {}), - }; - const taskReadinessWorkspace = activeSession?.cwd ?? newTask.projectPath; + const workspacePicker = taskEntry.selectors.workspacePicker; + const taskReadinessWorkspace = activeSession?.cwd ?? taskEntry.selectors.projectPath; const taskReadinessRequest = { ...resolveTaskReadinessModelTarget(activeSession, activeSessionSendOutcome, newChatModel), ...(taskReadinessWorkspace ? { cwd: taskReadinessWorkspace } : {}), @@ -1561,13 +1497,13 @@ function AppShellContent({ taskReadinessRequest, onboarding.snapshot, activeId, - activeId ? undefined : newTask.target, + activeId ? undefined : taskEntry.selectors.target, ); const taskReadinessNotice = deriveTaskReadinessNotice(taskReadiness.snapshot, uiLocale); const ignoreTaskReadinessModelTarget = activeSession !== undefined && activeSessionSendOutcome?.kind !== 'blocked'; const taskSubmissionHardBlocked = - (!activeId && !newTask.target) || + (!activeId && !taskEntry.selectors.target) || isTaskSubmissionHardBlocked(taskReadiness.snapshot, { ignoreModelTarget: ignoreTaskReadinessModelTarget, }); @@ -1585,7 +1521,7 @@ function AppShellContent({ composerRef, isShellSurfaceOwnerActive, openSessionInChat, - newTaskTarget: newTask.target, + newTaskTarget: taskEntry.selectors.target, sessionStartPendingRef, refreshOnboarding: onboarding.refresh, refreshSessions, @@ -1616,8 +1552,8 @@ function AppShellContent({ const { mentionSkills, mentionSkillsUnavailable, mentionSkillsLoading, searchMentionFiles } = useComposerMentions({ skillCatalogRevision: moduleHub.selectors.skillCatalogRevision, sessionId: activeId, - projectPath: activeId ? projectInfo?.projectPath : newTask.projectPath, - newTaskTarget: activeId ? undefined : newTask.target, + projectPath: activeId ? projectInfo?.projectPath : taskEntry.selectors.projectPath, + newTaskTarget: activeId ? undefined : taskEntry.selectors.target, newSessionModel: newChatModel, newSessionCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', // Refresh only; Desktop Main re-reads the authoritative default before @@ -1764,7 +1700,7 @@ function AppShellContent({ clearNewChatPermissionChoice: clearNewTaskPermissionChoice, newChatCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', newChatOrchestrationMode: newChatOrchestrationMode, - newTaskTarget: newTask.target, + newTaskTarget: taskEntry.selectors.target, }); const { handleTurnFooterAction } = useStableActions(createAppShellTurnActions, { @@ -2434,8 +2370,7 @@ function AppShellContent({ } async function createSessionInProject(projectId: string) { - if (!newTask.localHost) return; - newTask.selectProject(newTask.localHost, projectId); + if (!taskEntry.commands.selectLocalProject(projectId)) return; openNewTaskSurface(); } @@ -2528,7 +2463,8 @@ function AppShellContent({ toastApi.error(title, description, undefined, { sessionId }); } - const canStageComposerContext = activeId !== undefined || newTask.target !== undefined; + const canStageComposerContext = + activeId !== undefined || taskEntry.selectors.target !== undefined; const activeMessageLoadError = activeId ? messageLoadErrorBySession[activeId] : undefined; let activeTranscriptRange; @@ -2587,7 +2523,7 @@ function AppShellContent({ connections: defaultHostConnections.snapshot.connections, defaultConnection: defaultHostConnections.snapshot.defaultConnection, messages, - newTaskProfileId: newTask.selectedProfileId, + newTaskProfileId: taskEntry.selectors.selectedProfileId, settingsOpen, settingsProfileId: settingsDiagnosticProfileId, sessions, @@ -3095,12 +3031,8 @@ function AppShellContent({ taskReadinessNotice?.action === 'workspace_picker' ? activeSession ? openNewTaskSurface - : newTask.selectedHost && - (newTask.selectedHost.capabilities.chooseClientDirectory || - newTask.selectedHost.capabilities.chooseHostDirectory) - ? () => { - if (newTask.selectedHost) void newTask.addProject(newTask.selectedHost); - } + : taskEntry.selectors.canAddProject + ? taskEntry.commands.addProject : undefined : taskReadiness.refresh } @@ -3156,32 +3088,9 @@ function AppShellContent({ /> )} + - { - void newTask.acceptRegisteredProject(project, host).catch((error) => { - toastApi.error( - projectActionsCopy.projectUpdateFailedTitle, - localizedShellErrorMessage( - error, - projectActionsCopy.projectUpdateFailedFallback, - uiLocale, - ), - undefined, - { profileId: host.profileId }, - ); - }); - }} - /> - { - void newTask.refresh(); + void taskEntry.commands.refresh().catch(() => undefined); }} settingsRequestedSection={settingsRequestedSection} settingsProviderCatalogOpen={settingsProviderCatalogOpen} @@ -3225,7 +3134,7 @@ function AppShellContent({ onRemoteHostAdded={(profileId) => { closeSettings(); openNewTaskSurface(); - void newTask.chooseProjectForProfile(profileId).catch(() => undefined); + void taskEntry.commands.chooseProjectForProfile(profileId).catch(() => undefined); }} onSelectedRuntimeHostProfileIdChange={setSettingsDiagnosticProfileId} /> diff --git a/apps/desktop/src/renderer/features/task-entry/README.md b/apps/desktop/src/renderer/features/task-entry/README.md new file mode 100644 index 0000000000..d6d9b3551d --- /dev/null +++ b/apps/desktop/src/renderer/features/task-entry/README.md @@ -0,0 +1,66 @@ + + +# Task Entry / Workspace feature + +Task Entry owns the renderer state that chooses where a new task will run. It +loads the Runtime Host/Project catalog, preserves a selection per Host, derives +the new-task target and draft identity, projects the Workspace Picker, and owns +add/relink plus remote-directory handoff lifecycles. + +## Dependency direction + +- Consumers import production APIs from `features/task-entry`. +- Tests and stories may additionally import `features/task-entry/testing`. +- Task Entry may use shared renderer copy, shared project UI, core/runtime-host + types, and Maka UI. +- Task Entry must not import `AppShell`, preload, or the main process. +- Desktop catalog I/O enters through `TaskEntryServices`; feature code never + reads the Desktop global bridge directly. +- `AppShell` supplies explicit navigation/error intents and consumes only the + target, Host defaults, project path, draft identity, and Workspace Picker. + +## Lifecycle invariants + +- Catalog refreshes are generation-fenced. A stale response cannot overwrite a + newer Host/Project snapshot or drive an imperative directory handoff, + including after unmount or locale changes. +- The current Host remains selected while it is available. Otherwise selection + falls back to the available default Host, the first available Host, then the + default/first unavailable catalog row for honest loading/error presentation. +- Project selection is remembered per Host. Missing, archived, and unavailable + projects fall back through Host default, Host selected Project, then the + explicit no-Project capability. +- Add/relink mutations are single-flight. Cancellation stays silent; failures + preserve the selected target and retain the existing localized diagnostic. +- Remote directory registration is accepted only for the Host that opened the + picker. Closing or completing the picker restores focus to its opener. +- Draft identity is target-scoped. An unresolved catalog always uses the stable + unresolved new-task key, preserving reload and target-switch handoff behavior. + +## Non-ownership + +Session creation, first-send/task submission, Composer state, attachments, +readiness, Project Settings, and the shared remote-directory browser internals +remain separate. Task Entry supplies their target/workspace inputs but does not +own their lifecycles. + +The Desktop adapter is created once in the renderer composition root. Tests use +`createFakeTaskEntryServices` from `testing.ts`; production code must not import +that entry. diff --git a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts new file mode 100644 index 0000000000..4d00021ef6 --- /dev/null +++ b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts @@ -0,0 +1,470 @@ +/* + * 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 { findProjectByIdentity, type ProjectRecord } from '@maka/core/project'; +import { + getConversationCopy, + type WorkspacePickerModel, + useUiLocale, +} from '@maka/ui'; +import { + getShellCopy, + localizedShellErrorMessage, +} from '../../../locales/shell-copy.js'; +import { + isReadyTaskEntryHost, + resolveProjectSelection, + selectAvailableProfile, + taskEntryDraftKey, + type ReadyTaskEntryHost, +} from '../model/task-entry-selection.js'; +import type { + TaskEntryCatalog, + TaskEntryHostRef, + TaskEntryTarget, +} from '../ports.js'; +import { useTaskEntryServices } from '../services-context.js'; +import type { TaskEntryHostModel } from '../ui/task-entry-host.js'; + +export interface TaskEntryError { + readonly title: string; + readonly description?: string; + readonly profileId: string; +} + +export interface UseTaskEntryControllerInput { + reportError(error: TaskEntryError): void; +} + +export interface TaskEntryControllerSelectors { + readonly target?: TaskEntryTarget; + readonly draftKey: string; + readonly projectPath?: string; + readonly selectedHost?: { + readonly profileId: string; + readonly hostId: string; + readonly name: string; + readonly kind: 'local' | 'remote'; + readonly chatDefaults: ReadyTaskEntryHost['chatDefaults']; + }; + readonly selectedProfileId?: string; + readonly defaultProfileId: string; + readonly usesDefaultHost: boolean; + readonly workspacePicker: WorkspacePickerModel; + readonly canAddProject: boolean; +} + +export interface TaskEntryControllerCommands { + refresh(): Promise; + selectLocalProject(projectId: string): boolean; + addProject(): void; + chooseProjectForProfile(profileId: string): Promise; +} + +export interface TaskEntryController { + readonly host: TaskEntryHostModel; + readonly commands: TaskEntryControllerCommands; + readonly selectors: TaskEntryControllerSelectors; +} + +const EMPTY_CATALOG: TaskEntryCatalog = { + defaultProfileId: 'local', + hosts: [], +}; + +/** Owns Task Entry Host/Project state, catalog subscriptions, and workspace selection. */ +export function useTaskEntryController( + input: UseTaskEntryControllerInput, +): TaskEntryController { + const locale = useUiLocale(); + const copy = getShellCopy(locale).projectActions; + const conversationCopy = getConversationCopy(locale).workspace; + const reportError = input.reportError; + const { catalog: service } = useTaskEntryServices(); + const [catalog, setCatalog] = useState(EMPTY_CATALOG); + const [selectedProfileId, setSelectedProfileId] = useState(); + const [projectSelections, setProjectSelections] = useState( + () => new Map(), + ); + const [pending, setPending] = useState(false); + const [refreshing, setRefreshing] = useState(true); + const [error, setError] = useState(); + const [directoryHost, setDirectoryHost] = useState(); + const directoryOpenerRef = useRef(null); + const committedCatalogRef = useRef(EMPTY_CATALOG); + const refreshSequence = useRef(0); + const projectMutationPendingRef = useRef(false); + + const refresh = useCallback(async (): Promise => { + const sequence = ++refreshSequence.current; + setRefreshing(true); + try { + const next = await service.getCatalog(); + if (refreshSequence.current !== sequence) return committedCatalogRef.current; + committedCatalogRef.current = next; + setCatalog(next); + setError(undefined); + setSelectedProfileId((current) => selectAvailableProfile(next, current)); + setDirectoryHost((current) => { + if (!current) return current; + return next.hosts.find( + (host): host is ReadyTaskEntryHost => + host.profile.id === current.profile.id && + isReadyTaskEntryHost(host) && + host.hostId === current.hostId, + ); + }); + return next; + } catch (cause) { + if (refreshSequence.current === sequence) { + setError(localizedShellErrorMessage(cause, copy.catalogUnavailable, locale)); + } + throw cause; + } finally { + if (refreshSequence.current === sequence) setRefreshing(false); + } + }, [copy.catalogUnavailable, locale, service]); + + useEffect(() => { + const unsubscribe = service.subscribeChanges(() => { + void refresh().catch(() => undefined); + }); + void refresh().catch(() => undefined); + return () => { + refreshSequence.current += 1; + unsubscribe(); + }; + }, [refresh, service]); + + const selectedHost = catalog.hosts.find( + (host): host is ReadyTaskEntryHost => + host.profile.id === selectedProfileId && isReadyTaskEntryHost(host), + ); + const selectedProjectId = selectedHost + ? resolveProjectSelection(selectedHost, projectSelections.get(selectedHost.profile.id)) + : undefined; + const target = selectedHost && selectedProjectId !== undefined + ? { + profileId: selectedHost.profile.id, + hostId: selectedHost.hostId, + projectId: selectedProjectId, + } + : undefined; + const currentProject = selectedHost && typeof selectedProjectId === 'string' + ? findProjectByIdentity(selectedHost.projects, selectedProjectId) + : undefined; + const projectPath = currentProject?.preferredPath ?? + (selectedProjectId === null ? selectedHost?.projectPath : undefined); + const localHost = catalog.hosts.find( + (host): host is ReadyTaskEntryHost => + host.profile.kind === 'local' && isReadyTaskEntryHost(host), + ); + + const selectProject = useCallback((host: ReadyTaskEntryHost, projectId: string): void => { + const project = findProjectByIdentity(host.projects, projectId); + if (!project?.available || project.archivedAt !== undefined) return; + setSelectedProfileId(host.profile.id); + setProjectSelections((current) => + new Map(current).set(host.profile.id, project.id), + ); + }, []); + + const selectNoProject = useCallback((host: ReadyTaskEntryHost): void => { + if (!host.capabilities.selectNoProject) return; + setSelectedProfileId(host.profile.id); + setProjectSelections((current) => new Map(current).set(host.profile.id, null)); + }, []); + + const addProjectForHost = useCallback(async (host: ReadyTaskEntryHost): Promise => { + if (projectMutationPendingRef.current) return; + if (host.capabilities.chooseHostDirectory) { + directoryOpenerRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + setDirectoryHost(host); + return; + } + if (!host.capabilities.chooseClientDirectory) return; + projectMutationPendingRef.current = true; + setPending(true); + try { + const result = await service.addProject({ + profileId: host.profile.id, + hostId: host.hostId, + }); + if (!result.ok) return; + setSelectedProfileId(host.profile.id); + setProjectSelections((current) => + new Map(current).set(host.profile.id, result.project.id), + ); + await refresh(); + } catch (cause) { + reportError({ + title: copy.selectDirectoryFailedTitle, + description: localizedShellErrorMessage(cause, copy.readPathFailedFallback, locale), + profileId: host.profile.id, + }); + } finally { + projectMutationPendingRef.current = false; + setPending(false); + } + }, [copy.readPathFailedFallback, copy.selectDirectoryFailedTitle, locale, refresh, reportError, service]); + + const chooseProjectForProfile = useCallback(async (profileId: string): Promise => { + const next = await refresh(); + const host = next.hosts.find( + (candidate): candidate is ReadyTaskEntryHost => + candidate.profile.id === profileId && isReadyTaskEntryHost(candidate), + ); + if (!host) { + reportError({ + title: copy.catalogUnavailable, + profileId, + }); + return; + } + setSelectedProfileId(profileId); + if (host.capabilities.chooseHostDirectory) setDirectoryHost(host); + }, [copy.catalogUnavailable, refresh, reportError]); + + const acceptRegisteredProject = useCallback(async ( + project: ProjectRecord, + registeredHost: TaskEntryHostRef, + ): Promise => { + const host = directoryHost; + if ( + !host || + host.profile.id !== registeredHost.profileId || + host.hostId !== registeredHost.hostId + ) return; + setDirectoryHost(undefined); + setSelectedProfileId(host.profile.id); + setProjectSelections((current) => new Map(current).set(host.profile.id, project.id)); + try { + await refresh(); + } catch (cause) { + reportError({ + title: copy.projectUpdateFailedTitle, + description: localizedShellErrorMessage(cause, copy.projectUpdateFailedFallback, locale), + profileId: host.profile.id, + }); + } + }, [copy.projectUpdateFailedFallback, copy.projectUpdateFailedTitle, directoryHost, locale, refresh, reportError]); + + const relinkProject = useCallback(async ( + host: ReadyTaskEntryHost, + projectId: string, + ): Promise => { + if (!host.capabilities.chooseClientDirectory || projectMutationPendingRef.current) return; + projectMutationPendingRef.current = true; + setPending(true); + try { + const result = await service.relinkProject( + { profileId: host.profile.id, hostId: host.hostId }, + projectId, + ); + if (!result.ok) return; + setSelectedProfileId(host.profile.id); + setProjectSelections((current) => + new Map(current).set(host.profile.id, result.project.id), + ); + await refresh(); + } catch (cause) { + reportError({ + title: copy.selectDirectoryFailedTitle, + description: localizedShellErrorMessage(cause, copy.readPathFailedFallback, locale), + profileId: host.profile.id, + }); + } finally { + projectMutationPendingRef.current = false; + setPending(false); + } + }, [copy.readPathFailedFallback, copy.selectDirectoryFailedTitle, locale, refresh, reportError, service]); + + const workspacePicker = useMemo(() => { + const selectedCatalogHost = catalog.hosts.find( + (host) => host.profile.id === selectedProfileId, + ); + const selectedProject = currentProject?.name ?? + (selectedProjectId === null && selectedHost?.capabilities.selectNoProject + ? conversationCopy.noProject + : undefined); + const selectedBranch = selectedHost && + selectedProjectId === selectedHost.selectedProjectId + ? selectedHost.branch + : undefined; + const catalogNeedsRetry = Boolean(error) || catalog.hosts.some( + (host) => host.readiness === 'ready' && host.state === 'error', + ); + return { + label: selectedProject ?? selectedCatalogHost?.profile.name ?? + (error ? copy.catalogUnavailable : undefined), + ...(selectedCatalogHost?.profile.kind === 'remote' + ? { hostBadge: selectedCatalogHost.profile.name } + : {}), + branch: selectedProjectId === null ? null : selectedBranch, + pending: pending || (refreshing && catalog.hosts.length === 0), + selectedGroupId: selectedProfileId, + groups: catalog.hosts.map((host) => { + if (!isReadyTaskEntryHost(host)) { + return { + id: host.profile.id, + label: host.profile.name, + status: host.readiness === 'ready' + ? host.message + : copy.runtimeHostReadiness[host.readiness], + disabled: true, + projects: [], + }; + } + const groupSelectedProjectId = host.profile.id === selectedProfileId + ? selectedProjectId + : host.selectedProjectId; + return { + id: host.profile.id, + label: host.profile.name, + projects: host.projects.filter((project) => project.archivedAt === undefined), + selectedProjectId: groupSelectedProjectId, + onSelectProject: (projectId: string) => selectProject(host, projectId), + ...(host.capabilities.chooseClientDirectory || host.capabilities.chooseHostDirectory + ? { onAdd: () => void addProjectForHost(host) } + : {}), + ...(host.capabilities.chooseClientDirectory + ? { onRelink: (projectId: string) => void relinkProject(host, projectId) } + : {}), + ...(host.capabilities.selectNoProject + ? { onSelectNoProject: () => selectNoProject(host) } + : {}), + }; + }), + ...(catalogNeedsRetry + ? { + retry: { + label: copy.retryCatalog, + onClick: () => void refresh().catch(() => undefined), + }, + } + : {}), + }; + }, [ + addProjectForHost, + catalog, + conversationCopy.noProject, + copy.catalogUnavailable, + copy.retryCatalog, + copy.runtimeHostReadiness, + currentProject?.name, + error, + pending, + refreshing, + refresh, + relinkProject, + selectedHost, + selectedProfileId, + selectedProjectId, + selectNoProject, + selectProject, + ]); + + const selectLocalProject = useCallback((projectId: string): boolean => { + if (!localHost) return false; + selectProject(localHost, projectId); + return true; + }, [localHost, selectProject]); + const addSelectedProject = useCallback(() => { + if (selectedHost) void addProjectForHost(selectedHost); + }, [addProjectForHost, selectedHost]); + const refreshCatalog = useCallback(async (): Promise => { + await refresh(); + }, [refresh]); + const closeDirectoryPicker = useCallback(() => setDirectoryHost(undefined), []); + const selectedHostProjection = useMemo( + () => selectedHost + ? { + profileId: selectedHost.profile.id, + hostId: selectedHost.hostId, + name: selectedHost.profile.name, + kind: selectedHost.profile.kind, + chatDefaults: selectedHost.chatDefaults, + } + : undefined, + [selectedHost], + ); + + return useMemo(() => ({ + host: { + ...(directoryHost + ? { + directoryHost: { + profileId: directoryHost.profile.id, + hostId: directoryHost.hostId, + name: directoryHost.profile.name, + }, + } + : {}), + directoryOpener: directoryOpenerRef.current, + closeDirectoryPicker, + acceptRegisteredProject, + }, + commands: { + refresh: refreshCatalog, + selectLocalProject, + addProject: addSelectedProject, + chooseProjectForProfile, + }, + selectors: { + ...(target ? { target } : {}), + draftKey: taskEntryDraftKey(target), + ...(projectPath ? { projectPath } : {}), + ...(selectedHostProjection ? { selectedHost: selectedHostProjection } : {}), + ...(selectedProfileId ? { selectedProfileId } : {}), + defaultProfileId: catalog.defaultProfileId, + usesDefaultHost: + catalog.hosts.length === 0 || selectedProfileId === catalog.defaultProfileId, + workspacePicker, + canAddProject: Boolean( + selectedHost && + (selectedHost.capabilities.chooseClientDirectory || + selectedHost.capabilities.chooseHostDirectory), + ), + }, + }), [ + acceptRegisteredProject, + addSelectedProject, + catalog.defaultProfileId, + catalog.hosts.length, + chooseProjectForProfile, + closeDirectoryPicker, + directoryHost, + projectPath, + refreshCatalog, + selectLocalProject, + selectedHost, + selectedHostProjection, + selectedProfileId, + target, + workspacePicker, + ]); +} diff --git a/apps/desktop/src/renderer/features/task-entry/index.ts b/apps/desktop/src/renderer/features/task-entry/index.ts new file mode 100644 index 0000000000..0b75d7d89a --- /dev/null +++ b/apps/desktop/src/renderer/features/task-entry/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 { TaskEntryHost } from './ui/task-entry-host.js'; +export { TaskEntryServicesProvider } from './services-context.js'; +export { useTaskEntryController } from './controller/use-task-entry-controller.js'; +export type { + TaskEntryError, +} from './controller/use-task-entry-controller.js'; +export type { TaskEntryServices } from './ports.js'; diff --git a/apps/desktop/src/renderer/features/task-entry/model/task-entry-selection.ts b/apps/desktop/src/renderer/features/task-entry/model/task-entry-selection.ts new file mode 100644 index 0000000000..b273715bc1 --- /dev/null +++ b/apps/desktop/src/renderer/features/task-entry/model/task-entry-selection.ts @@ -0,0 +1,76 @@ +/* + * 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 { findProjectByIdentity } from '@maka/core/project'; +import { UNRESOLVED_NEW_TASK_DRAFT_KEY } from '../../../new-task-reload-intent.js'; +import type { + TaskEntryCatalog, + TaskEntryHost, + TaskEntryTarget, +} from '../ports.js'; + +export type ReadyTaskEntryHost = Extract< + TaskEntryHost, + { readonly readiness: 'ready'; readonly state: 'available' } +>; + +export function selectAvailableProfile( + catalog: TaskEntryCatalog, + current: string | undefined, +): string | undefined { + const available = catalog.hosts.filter(isReadyTaskEntryHost); + if (current && available.some((host) => host.profile.id === current)) return current; + if (available.some((host) => host.profile.id === catalog.defaultProfileId)) { + return catalog.defaultProfileId; + } + return available[0]?.profile.id ?? + catalog.hosts.find((host) => host.profile.id === catalog.defaultProfileId)?.profile.id ?? + catalog.hosts[0]?.profile.id; +} + +export function resolveProjectSelection( + host: ReadyTaskEntryHost, + requested: string | null | undefined, +): string | null | undefined { + if (requested === null && host.capabilities.selectNoProject) return null; + if (typeof requested === 'string') { + const project = findProjectByIdentity(host.projects, requested); + if (project?.available && project.archivedAt === undefined) return project.id; + } + if (host.defaultProjectId) { + const project = findProjectByIdentity(host.projects, host.defaultProjectId); + if (project?.available && project.archivedAt === undefined) return project.id; + } + if (host.selectedProjectId === null && host.capabilities.selectNoProject) return null; + if (typeof host.selectedProjectId === 'string') { + const project = findProjectByIdentity(host.projects, host.selectedProjectId); + if (project?.available && project.archivedAt === undefined) return project.id; + } + return host.capabilities.selectNoProject ? null : undefined; +} + +export function taskEntryDraftKey(target: TaskEntryTarget | undefined): string { + return target + ? JSON.stringify(['new-task', target.profileId, target.hostId, target.projectId]) + : UNRESOLVED_NEW_TASK_DRAFT_KEY; +} + +export function isReadyTaskEntryHost(host: TaskEntryHost): host is ReadyTaskEntryHost { + return host.readiness === 'ready' && host.state === 'available'; +} diff --git a/apps/desktop/src/renderer/features/task-entry/ports.ts b/apps/desktop/src/renderer/features/task-entry/ports.ts new file mode 100644 index 0000000000..16a52e6010 --- /dev/null +++ b/apps/desktop/src/renderer/features/task-entry/ports.ts @@ -0,0 +1,98 @@ +/* + * 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 { ProjectRecord } from '@maka/core/project'; +import type { ChatDefaultsSettings } from '@maka/core/settings'; + +export type TaskEntryUnsubscribe = () => void; + +export interface TaskEntryHostRef { + readonly profileId: string; + readonly hostId: string; +} + +export interface TaskEntryTarget extends TaskEntryHostRef { + readonly projectId: string | null; +} + +export interface TaskEntryProjectCapabilities { + readonly chooseClientDirectory: boolean; + readonly chooseHostDirectory: boolean; + readonly selectNoProject: boolean; +} + +export interface TaskEntryHostProfile { + readonly id: string; + readonly name: string; + readonly kind: 'local' | 'remote'; +} + +export type TaskEntryHost = + | { + readonly profile: TaskEntryHostProfile; + readonly hostId: string; + readonly readiness: 'ready'; + readonly state: 'available'; + readonly projects: readonly ProjectRecord[]; + readonly capabilities: TaskEntryProjectCapabilities; + readonly selectedProjectId: string | null | undefined; + readonly defaultProjectId?: string; + readonly chatDefaults: Pick< + ChatDefaultsSettings, + 'permissionMode' | 'thinkingLevel' + >; + readonly projectPath?: string; + readonly branch?: string; + } + | { + readonly profile: TaskEntryHostProfile; + readonly hostId: string; + readonly readiness: 'ready'; + readonly state: 'error'; + readonly message: string; + } + | { + readonly profile: TaskEntryHostProfile; + readonly readiness: 'connecting' | 'reconnecting' | 'unavailable'; + readonly message?: string; + }; + +export interface TaskEntryCatalog { + readonly defaultProfileId: string; + readonly hosts: readonly TaskEntryHost[]; +} + +export type TaskEntryProjectMutationResult = + | { readonly ok: true; readonly project: ProjectRecord } + | { readonly ok: false; readonly reason: 'cancelled' }; + +/** The minimum environment capability needed by Task Entry / Workspace. */ +export interface TaskEntryCatalogService { + getCatalog(): Promise; + subscribeChanges(handler: () => void): TaskEntryUnsubscribe; + addProject(host: TaskEntryHostRef): Promise; + relinkProject( + host: TaskEntryHostRef, + projectId: string, + ): Promise; +} + +export interface TaskEntryServices { + readonly catalog: TaskEntryCatalogService; +} diff --git a/apps/desktop/src/renderer/features/task-entry/services-context.tsx b/apps/desktop/src/renderer/features/task-entry/services-context.tsx new file mode 100644 index 0000000000..a09cdf8579 --- /dev/null +++ b/apps/desktop/src/renderer/features/task-entry/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 { TaskEntryServices } from './ports.js'; + +const TaskEntryServicesContext = createContext(null); + +export function TaskEntryServicesProvider(props: { + services: TaskEntryServices; + children?: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +export function useTaskEntryServices(): TaskEntryServices { + const services = useContext(TaskEntryServicesContext); + if (!services) throw new Error('TaskEntryServicesProvider is missing'); + return services; +} diff --git a/apps/desktop/src/renderer/features/task-entry/testing.ts b/apps/desktop/src/renderer/features/task-entry/testing.ts new file mode 100644 index 0000000000..cda19f74ee --- /dev/null +++ b/apps/desktop/src/renderer/features/task-entry/testing.ts @@ -0,0 +1,52 @@ +/* + * 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 { TaskEntryServices } from './ports.js'; + +export { TaskEntryServicesProvider } from './services-context.js'; +export { + useTaskEntryController, + type TaskEntryController, +} from './controller/use-task-entry-controller.js'; +export { + resolveProjectSelection, + selectAvailableProfile, + taskEntryDraftKey, +} from './model/task-entry-selection.js'; +export type { + TaskEntryCatalog, + TaskEntryHost, + TaskEntryServices, +} from './ports.js'; + +const noopSubscription = (): (() => void) => () => undefined; + +export function createFakeTaskEntryServices( + overrides: Partial = {}, +): TaskEntryServices { + return { + catalog: { + getCatalog: async () => ({ defaultProfileId: 'local', hosts: [] }), + subscribeChanges: noopSubscription, + addProject: async () => ({ ok: false, reason: 'cancelled' }), + relinkProject: async () => ({ ok: false, reason: 'cancelled' }), + }, + ...overrides, + }; +} diff --git a/apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx b/apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx new file mode 100644 index 0000000000..64f1ed7c15 --- /dev/null +++ b/apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx @@ -0,0 +1,45 @@ +/* + * 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 { ProjectRecord } from '@maka/core/project'; +import { RemoteProjectDirectoryDialog } from '../../../remote-project-directory-dialog.js'; +import type { TaskEntryHostRef } from '../ports.js'; + +export interface TaskEntryHostModel { + directoryHost?: TaskEntryHostRef & { readonly name?: string }; + directoryOpener?: HTMLElement | null; + closeDirectoryPicker(): void; + acceptRegisteredProject( + project: ProjectRecord, + host: TaskEntryHostRef, + ): Promise; +} + +export function TaskEntryHost({ model }: { model: TaskEntryHostModel }) { + return ( + { + void model.acceptRegisteredProject(project, host); + }} + /> + ); +} diff --git a/apps/desktop/src/renderer/main.tsx b/apps/desktop/src/renderer/main.tsx index c39f931b27..98ccd79dfe 100644 --- a/apps/desktop/src/renderer/main.tsx +++ b/apps/desktop/src/renderer/main.tsx @@ -30,6 +30,8 @@ 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'; +import { TaskEntryServicesProvider } from './features/task-entry'; +import { createDesktopTaskEntryServices } from './platform/desktop/create-task-entry-services'; const ONBOARDING_SNAPSHOT_RETRY_DELAY_MS = 150; const ONBOARDING_SNAPSHOT_TIMEOUT_MS = 2_500; @@ -39,6 +41,7 @@ applyCachedThemeBeforeMount(); const workbarServices = createDesktopWorkbarServices(); const goalServices = createDesktopGoalServices(); const moduleHubServices = createDesktopModuleHubServices(); +const taskEntryServices = createDesktopTaskEntryServices(); /** * Prefetch the onboarding snapshot BEFORE mounting React. The preload @@ -71,12 +74,14 @@ async function prefetchOnboardingSnapshot(): Promise void prefetchOnboardingSnapshot().then((initialOnboardingSnapshot) => { createRoot(document.getElementById('root')!).render( - - - - - - - , + + + + + + + + + , ); }); diff --git a/apps/desktop/src/renderer/platform/desktop/create-task-entry-services.ts b/apps/desktop/src/renderer/platform/desktop/create-task-entry-services.ts new file mode 100644 index 0000000000..f34d256f5e --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-task-entry-services.ts @@ -0,0 +1,38 @@ +/* + * 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 { TaskEntryServices } from '../../features/task-entry'; + +export type DesktopTaskEntryBridge = Pick; + +/** The only Desktop-to-Task Entry adapter. */ +export function createDesktopTaskEntryServices( + bridge: DesktopTaskEntryBridge = window.maka, +): TaskEntryServices { + return { + catalog: { + getCatalog: () => bridge.newTasks.getCatalog(), + subscribeChanges: (handler) => bridge.newTasks.subscribeChanges(handler), + addProject: (host) => bridge.newTasks.addProject(host), + relinkProject: (host, projectId) => + bridge.newTasks.relinkProject(host, projectId), + }, + }; +} diff --git a/apps/desktop/src/renderer/use-new-task-target.ts b/apps/desktop/src/renderer/use-new-task-target.ts deleted file mode 100644 index 7d82a0ca7a..0000000000 --- a/apps/desktop/src/renderer/use-new-task-target.ts +++ /dev/null @@ -1,299 +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 { useEffect, useRef, useState } from 'react'; -import { findProjectByIdentity, type ProjectRecord } from '@maka/core/project'; -import type { UiLocale } from '@maka/core/ui-locale'; -import type { - DesktopNewTaskCatalog, - DesktopNewTaskHost, - DesktopRuntimeHostRef, -} from '../preload/bridge-contract.js'; -import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js'; - -type ReadyHost = Extract< - DesktopNewTaskHost, - { readonly readiness: 'ready'; readonly state: 'available' } ->; - -type ToastApi = { - error( - title: string, - description?: string, - diagnosticDetails?: string, - diagnosticTarget?: { profileId: string }, - ): void; -}; - -export function useNewTaskTarget(options: { - uiLocale: UiLocale; - toastApi: ToastApi; -}) { - const copy = getShellCopy(options.uiLocale).projectActions; - const [catalog, setCatalog] = useState({ - defaultProfileId: 'local', - hosts: [], - }); - const [selectedProfileId, setSelectedProfileId] = useState(); - const [projectSelections, setProjectSelections] = useState( - () => new Map(), - ); - const [pending, setPending] = useState(false); - const [refreshing, setRefreshing] = useState(true); - const [error, setError] = useState(); - const [directoryHost, setDirectoryHost] = useState(); - const directoryOpenerRef = useRef(null); - const refreshSequence = useRef(0); - - async function refresh(): Promise { - const sequence = ++refreshSequence.current; - setRefreshing(true); - try { - const next = await window.maka.newTasks.getCatalog(); - if (refreshSequence.current !== sequence) return next; - setCatalog(next); - setError(undefined); - setSelectedProfileId((current) => selectAvailableProfile(next, current)); - return next; - } catch (cause) { - if (refreshSequence.current === sequence) { - setError(localizedShellErrorMessage( - cause, - copy.catalogUnavailable, - options.uiLocale, - )); - } - throw cause; - } finally { - if (refreshSequence.current === sequence) setRefreshing(false); - } - } - - useEffect(() => { - const unsubscribe = window.maka.newTasks.subscribeChanges(() => { - void refresh().catch(() => undefined); - }); - void refresh().catch(() => undefined); - return () => { - refreshSequence.current += 1; - unsubscribe(); - }; - }, [options.uiLocale]); - - const selectedHost = catalog.hosts.find( - (host): host is ReadyHost => - host.profile.id === selectedProfileId && - host.readiness === 'ready' && - host.state === 'available', - ); - const selectedProjectId = selectedHost - ? resolveProjectSelection(selectedHost, projectSelections.get(selectedHost.profile.id)) - : undefined; - const target = selectedHost && selectedProjectId !== undefined - ? { - profileId: selectedHost.profile.id, - hostId: selectedHost.hostId, - projectId: selectedProjectId, - } - : undefined; - const currentProject = selectedHost && typeof selectedProjectId === 'string' - ? findProjectByIdentity(selectedHost.projects, selectedProjectId) - : undefined; - const projectPath = currentProject?.preferredPath ?? - (selectedProjectId === null ? selectedHost?.projectPath : undefined); - const localHost = catalog.hosts.find( - (host): host is ReadyHost => - host.profile.kind === 'local' && - host.readiness === 'ready' && - host.state === 'available', - ); - - const selectProject = (host: ReadyHost, projectId: string): void => { - const project = findProjectByIdentity(host.projects, projectId); - if (!project?.available || project.archivedAt !== undefined) return; - setSelectedProfileId(host.profile.id); - setProjectSelections((current) => - new Map(current).set(host.profile.id, project.id), - ); - }; - - const selectNoProject = (host: ReadyHost): void => { - if (!host.capabilities.selectNoProject) return; - setSelectedProfileId(host.profile.id); - setProjectSelections((current) => new Map(current).set(host.profile.id, null)); - }; - - async function addProject(host: ReadyHost): Promise { - if (pending) return; - if (host.capabilities.chooseHostDirectory) { - directoryOpenerRef.current = - document.activeElement instanceof HTMLElement ? document.activeElement : null; - setDirectoryHost(host); - return; - } - if (!host.capabilities.chooseClientDirectory) return; - setPending(true); - try { - const result = await window.maka.newTasks.addProject({ - profileId: host.profile.id, - hostId: host.hostId, - }); - if (!result.ok) return; - setSelectedProfileId(host.profile.id); - setProjectSelections((current) => - new Map(current).set(host.profile.id, result.project.id), - ); - await refresh(); - } catch (error) { - options.toastApi.error( - copy.selectDirectoryFailedTitle, - localizedShellErrorMessage(error, copy.readPathFailedFallback, options.uiLocale), - undefined, - { profileId: host.profile.id }, - ); - } finally { - setPending(false); - } - } - - async function chooseProjectForProfile(profileId: string): Promise { - const next = await refresh(); - const host = next.hosts.find( - (candidate): candidate is ReadyHost => - candidate.profile.id === profileId && - candidate.readiness === 'ready' && - candidate.state === 'available', - ); - if (!host) { - options.toastApi.error( - copy.catalogUnavailable, - undefined, - undefined, - { profileId }, - ); - return; - } - setSelectedProfileId(profileId); - if (host.capabilities.chooseHostDirectory) setDirectoryHost(host); - } - - async function acceptRegisteredProject( - project: ProjectRecord, - registeredHost: DesktopRuntimeHostRef, - ): Promise { - const host = directoryHost; - if ( - !host || - host.profile.id !== registeredHost.profileId || - host.hostId !== registeredHost.hostId - ) return; - setDirectoryHost(undefined); - setSelectedProfileId(host.profile.id); - setProjectSelections((current) => new Map(current).set(host.profile.id, project.id)); - await refresh(); - } - - async function relinkProject(host: ReadyHost, projectId: string): Promise { - if (!host.capabilities.chooseClientDirectory || pending) return; - setPending(true); - try { - const result = await window.maka.newTasks.relinkProject( - { profileId: host.profile.id, hostId: host.hostId }, - projectId, - ); - if (!result.ok) return; - setSelectedProfileId(host.profile.id); - setProjectSelections((current) => - new Map(current).set(host.profile.id, result.project.id), - ); - await refresh(); - } catch (error) { - options.toastApi.error( - copy.selectDirectoryFailedTitle, - localizedShellErrorMessage(error, copy.readPathFailedFallback, options.uiLocale), - undefined, - { profileId: host.profile.id }, - ); - } finally { - setPending(false); - } - } - - return { - catalog, - selectedProfileId, - selectedHost, - selectedProjectId, - target, - currentProject, - projectPath, - localHost, - pending, - refreshing, - error, - directoryHost, - directoryOpener: directoryOpenerRef.current, - refresh, - selectProject, - selectNoProject, - addProject, - chooseProjectForProfile, - closeDirectoryPicker: () => setDirectoryHost(undefined), - acceptRegisteredProject, - relinkProject, - }; -} - -function selectAvailableProfile( - catalog: DesktopNewTaskCatalog, - current: string | undefined, -): string | undefined { - const available = catalog.hosts.filter( - (host): host is ReadyHost => - host.readiness === 'ready' && host.state === 'available', - ); - if (current && available.some((host) => host.profile.id === current)) return current; - if (available.some((host) => host.profile.id === catalog.defaultProfileId)) { - return catalog.defaultProfileId; - } - return available[0]?.profile.id ?? - catalog.hosts.find((host) => host.profile.id === catalog.defaultProfileId)?.profile.id ?? - catalog.hosts[0]?.profile.id; -} - -function resolveProjectSelection( - host: ReadyHost, - requested: string | null | undefined, -): string | null | undefined { - if (requested === null && host.capabilities.selectNoProject) return null; - if (typeof requested === 'string') { - const project = findProjectByIdentity(host.projects, requested); - if (project?.available && project.archivedAt === undefined) return project.id; - } - if (host.defaultProjectId) { - const project = findProjectByIdentity(host.projects, host.defaultProjectId); - if (project?.available && project.archivedAt === undefined) return project.id; - } - if (host.selectedProjectId === null && host.capabilities.selectNoProject) return null; - if (typeof host.selectedProjectId === 'string') { - const project = findProjectByIdentity(host.projects, host.selectedProjectId); - if (project?.available && project.archivedAt === undefined) return project.id; - } - return host.capabilities.selectNoProject ? null : undefined; -} diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 4707983699..236268e629 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:** 209 files — blocker 0, polish 1, aligned 208. +**Totals:** 213 files — blocker 0, polish 1, aligned 212. ## Exclusions (explicit) @@ -43,6 +43,8 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `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/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/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 1abf8f367b..c82206a95c 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -15,6 +15,8 @@ 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/task-entry/services-context.tsx +apps/desktop/src/renderer/features/task-entry/ui/task-entry-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 From 577e75aebcb3b5c3cd4007c293fd412b306d0b1a Mon Sep 17 00:00:00 2001 From: chihumyum Date: Tue, 25 Aug 2026 01:12:38 +0800 Subject: [PATCH 2/2] fix(desktop): await winning Task Entry refresh Keep imperative catalog reads attached to the latest refresh generation so remote Host onboarding cannot lose its directory handoff. Cover stale success and stale failure ordering against the winning refresh. Generated-by: Codex --- .../__tests__/task-entry-controller.test.ts | 147 +++++++++++++++--- .../controller/use-task-entry-controller.ts | 68 +++++--- 2 files changed, 168 insertions(+), 47 deletions(-) diff --git a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts index 7e44cf45c1..4edb88bc9b 100644 --- a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts +++ b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts @@ -67,6 +67,18 @@ function readyHost(input: { }; } +function readyRemoteHost(hostId: string): Extract { + return { + ...readyHost({ chooseClientDirectory: false, chooseHostDirectory: true }), + profile: { + id: 'remote', + name: 'Remote', + kind: 'remote', + }, + hostId, + }; +} + function catalog(host: TaskEntryHost = readyHost()): TaskEntryCatalog { return { defaultProfileId: 'local', hosts: [host] }; } @@ -301,21 +313,12 @@ describe('useTaskEntryController', () => { it('closes a remote directory handoff when the Host generation changes', async () => { const { root } = installReactRenderer(); let reads = 0; - const remoteHost = (hostId: string) => ({ - ...readyHost({ chooseClientDirectory: false, chooseHostDirectory: true }), - profile: { - id: 'remote', - name: 'Remote', - kind: 'remote' as const, - }, - hostId, - }); const services = createFakeTaskEntryServices({ catalog: { ...createFakeTaskEntryServices().catalog, getCatalog: async () => ({ defaultProfileId: 'remote', - hosts: [remoteHost(++reads === 1 ? 'generation-a' : 'generation-b')], + hosts: [readyRemoteHost(++reads === 1 ? 'generation-a' : 'generation-b')], }), }, }); @@ -340,22 +343,13 @@ describe('useTaskEntryController', () => { const stale = deferred(); const current = deferred(); let reads = 0; - const remoteHost = (hostId: string) => ({ - ...readyHost({ chooseClientDirectory: false, chooseHostDirectory: true }), - profile: { - id: 'remote', - name: 'Remote', - kind: 'remote' as const, - }, - hostId, - }); const services = createFakeTaskEntryServices({ catalog: { ...createFakeTaskEntryServices().catalog, getCatalog: async () => { reads += 1; if (reads === 1) { - return { defaultProfileId: 'remote', hosts: [remoteHost('initial')] }; + return { defaultProfileId: 'remote', hosts: [readyRemoteHost('initial')] }; } return reads === 2 ? stale.promise : current.promise; }, @@ -371,15 +365,124 @@ describe('useTaskEntryController', () => { }); await act(async () => current.resolve({ defaultProfileId: 'remote', - hosts: [remoteHost('current-generation')], + hosts: [readyRemoteHost('current-generation')], })); await act(async () => stale.resolve({ defaultProfileId: 'remote', - hosts: [remoteHost('stale-generation')], + hosts: [readyRemoteHost('stale-generation')], })); await act(async () => Promise.all([choose, refresh])); assert.equal(controller().selectors.target?.hostId, 'current-generation'); assert.equal(controller().host.directoryHost?.hostId, 'current-generation'); }); + + it('awaits the winning refresh when an onboarding catalog read settles stale first', async () => { + const { root } = installReactRenderer(); + const stale = deferred(); + const current = deferred(); + const errors: unknown[] = []; + let reads = 0; + let emit: (() => void) | undefined; + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => { + reads += 1; + if (reads === 1) return catalog(); + return reads === 2 ? stale.promise : current.promise; + }, + subscribeChanges: (handler) => { + emit = handler; + return () => undefined; + }, + }, + }); + + await act(async () => renderController(root, services, errors)); + let settled = false; + let chooseError: unknown; + let choose!: Promise; + await act(async () => { + choose = controller().commands.chooseProjectForProfile('remote') + .catch((error: unknown) => { + chooseError = error; + }) + .finally(() => { + settled = true; + }); + emit?.(); + }); + + await act(async () => stale.resolve({ + defaultProfileId: 'remote', + hosts: [readyRemoteHost('stale-generation')], + })); + assert.equal(settled, false); + assert.equal(errors.length, 0); + + await act(async () => current.resolve({ + defaultProfileId: 'remote', + hosts: [readyRemoteHost('current-generation')], + })); + await act(async () => choose); + + assert.equal(chooseError, undefined); + assert.equal(errors.length, 0); + assert.equal(controller().selectors.target?.hostId, 'current-generation'); + assert.equal(controller().host.directoryHost?.hostId, 'current-generation'); + }); + + it('awaits the winning refresh when an onboarding catalog read rejects stale first', async () => { + const { root } = installReactRenderer(); + const stale = deferred(); + const current = deferred(); + const errors: unknown[] = []; + let reads = 0; + let emit: (() => void) | undefined; + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => { + reads += 1; + if (reads === 1) return catalog(); + return reads === 2 ? stale.promise : current.promise; + }, + subscribeChanges: (handler) => { + emit = handler; + return () => undefined; + }, + }, + }); + + await act(async () => renderController(root, services, errors)); + let settled = false; + let chooseError: unknown; + let choose!: Promise; + await act(async () => { + choose = controller().commands.chooseProjectForProfile('remote') + .catch((error: unknown) => { + chooseError = error; + }) + .finally(() => { + settled = true; + }); + emit?.(); + }); + + await act(async () => stale.reject(new Error('stale catalog failed'))); + assert.equal(settled, false); + assert.equal(errors.length, 0); + + await act(async () => current.resolve({ + defaultProfileId: 'remote', + hosts: [readyRemoteHost('current-generation')], + })); + await act(async () => choose); + + assert.equal(chooseError, undefined); + assert.equal(errors.length, 0); + assert.equal(controller().selectors.target?.hostId, 'current-generation'); + assert.equal(controller().host.directoryHost?.hostId, 'current-generation'); + }); }); diff --git a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts index 4d00021ef6..19b47b3977 100644 --- a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts +++ b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts @@ -116,36 +116,54 @@ export function useTaskEntryController( const directoryOpenerRef = useRef(null); const committedCatalogRef = useRef(EMPTY_CATALOG); const refreshSequence = useRef(0); + // Imperative callers must follow a refresh that supersedes their own read; + // otherwise a one-shot onboarding handoff can act on the previous catalog. + const latestRefreshRef = useRef<{ + readonly sequence: number; + readonly promise: Promise; + } | undefined>(undefined); const projectMutationPendingRef = useRef(false); - const refresh = useCallback(async (): Promise => { + const refresh = useCallback((): Promise => { const sequence = ++refreshSequence.current; setRefreshing(true); - try { - const next = await service.getCatalog(); - if (refreshSequence.current !== sequence) return committedCatalogRef.current; - committedCatalogRef.current = next; - setCatalog(next); - setError(undefined); - setSelectedProfileId((current) => selectAvailableProfile(next, current)); - setDirectoryHost((current) => { - if (!current) return current; - return next.hosts.find( - (host): host is ReadyTaskEntryHost => - host.profile.id === current.profile.id && - isReadyTaskEntryHost(host) && - host.hostId === current.hostId, - ); - }); - return next; - } catch (cause) { - if (refreshSequence.current === sequence) { - setError(localizedShellErrorMessage(cause, copy.catalogUnavailable, locale)); + const promise = (async (): Promise => { + try { + const next = await service.getCatalog(); + if (refreshSequence.current !== sequence) { + const winner = latestRefreshRef.current; + return winner && winner.sequence > sequence + ? winner.promise + : committedCatalogRef.current; + } + committedCatalogRef.current = next; + setCatalog(next); + setError(undefined); + setSelectedProfileId((current) => selectAvailableProfile(next, current)); + setDirectoryHost((current) => { + if (!current) return current; + return next.hosts.find( + (host): host is ReadyTaskEntryHost => + host.profile.id === current.profile.id && + isReadyTaskEntryHost(host) && + host.hostId === current.hostId, + ); + }); + return next; + } catch (cause) { + if (refreshSequence.current !== sequence) { + const winner = latestRefreshRef.current; + if (winner && winner.sequence > sequence) return winner.promise; + } else { + setError(localizedShellErrorMessage(cause, copy.catalogUnavailable, locale)); + } + throw cause; + } finally { + if (refreshSequence.current === sequence) setRefreshing(false); } - throw cause; - } finally { - if (refreshSequence.current === sequence) setRefreshing(false); - } + })(); + latestRefreshRef.current = { sequence, promise }; + return promise; }, [copy.catalogUnavailable, locale, service]); useEffect(() => {