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..4edb88bc9b --- /dev/null +++ b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts @@ -0,0 +1,488 @@ +/* + * 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 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] }; +} + +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 services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => ({ + defaultProfileId: 'remote', + hosts: [readyRemoteHost(++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 services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => { + reads += 1; + if (reads === 1) { + return { defaultProfileId: 'remote', hosts: [readyRemoteHost('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: [readyRemoteHost('current-generation')], + })); + await act(async () => stale.resolve({ + defaultProfileId: 'remote', + 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/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..19b47b3977 --- /dev/null +++ b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts @@ -0,0 +1,488 @@ +/* + * 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); + // 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((): Promise => { + const sequence = ++refreshSequence.current; + setRefreshing(true); + 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); + } + })(); + latestRefreshRef.current = { sequence, promise }; + return promise; + }, [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