You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/08/27 17:59:41 UTC
(maka) branch main updated: refactor(desktop): extract Task Entry feature slice (#3723)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 8f9406fb5 refactor(desktop): extract Task Entry feature slice (#3723)
8f9406fb5 is described below
commit 8f9406fb5b661407d75c10b179dc818bc3bcf6a9
Author: yum <ya...@chihum.dev>
AuthorDate: Fri Aug 28 01:59:36 2026 +0800
refactor(desktop): extract Task Entry feature slice (#3723)
* 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
* 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
* fix(desktop): preserve Task Entry handoffs across refresh failures
Keep remote directory handoffs stable through transient reconnects and make catalog refresh drains fall back to their latest successful snapshot.
Generated-by: Codex
---
.../src/main/__tests__/task-entry-boundary.test.ts | 105 ++++
.../main/__tests__/task-entry-controller.test.ts | 683 +++++++++++++++++++++
.../src/main/__tests__/task-entry-model.test.ts | 156 +++++
.../__tests__/task-entry-services-adapter.test.ts | 77 +++
apps/desktop/src/renderer/app-shell.tsx | 181 ++----
.../src/renderer/features/task-entry/README.md | 66 ++
.../controller/use-task-entry-controller.ts | 561 +++++++++++++++++
.../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 ++
.../src/renderer/features/task-entry/testing.ts | 52 ++
.../features/task-entry/ui/task-entry-host.tsx | 45 ++
apps/desktop/src/renderer/main.tsx | 19 +-
.../platform/desktop/create-task-entry-services.ts | 38 ++
apps/desktop/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, 2085 insertions(+), 443 deletions(-)
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 000000000..028d1b55d
--- /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('<TaskEntryHost model={taskEntry.host} />'), 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 000000000..0988848d2
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts
@@ -0,0 +1,683 @@
+/*
+ * 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<typeof project>[];
+ selectedProjectId?: string | null;
+ chooseClientDirectory?: boolean;
+ chooseHostDirectory?: boolean;
+ selectNoProject?: boolean;
+} = {}): Extract<TaskEntryHost, { state: 'available' }> {
+ 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<TaskEntryHost, { state: 'available' }> {
+ return {
+ ...readyHost({ chooseClientDirectory: false, chooseHostDirectory: true }),
+ profile: {
+ id: 'remote',
+ name: 'Remote',
+ kind: 'remote',
+ },
+ hostId,
+ };
+}
+
+function reconnectingRemoteHost(): TaskEntryHost {
+ return {
+ profile: {
+ id: 'remote',
+ name: 'Remote',
+ kind: 'remote',
+ },
+ readiness: 'reconnecting',
+ message: 'Reconnecting',
+ };
+}
+
+function catalog(host: TaskEntryHost = readyHost()): TaskEntryCatalog {
+ return { defaultProfileId: 'local', hosts: [host] };
+}
+
+function deferred<T>() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise<T>((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<typeof installReactRenderer>['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('drains a queued catalog refresh and releases its subscription', async () => {
+ const { root } = installReactRenderer();
+ const first = deferred<TaskEntryCatalog>();
+ const second = deferred<TaskEntryCatalog>();
+ 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?.());
+ assert.equal(reads, 1);
+
+ await act(async () => first.resolve(catalog(readyHost({ hostId: 'stale-generation' }))));
+ assert.equal(reads, 2);
+ assert.equal(controller().selectors.target, undefined);
+
+ await act(async () => second.resolve(catalog(readyHost({ hostId: 'new-generation' }))));
+ assert.equal(controller().selectors.target?.hostId, 'new-generation');
+
+ await act(async () => root.unmount());
+ assert.equal(disposed, 1);
+ });
+
+ it('ignores an obsolete catalog read after the service lifecycle changes', async () => {
+ const { root } = installReactRenderer();
+ const obsolete = deferred<TaskEntryCatalog>();
+ const current = deferred<TaskEntryCatalog>();
+ const obsoleteServices = createFakeTaskEntryServices({
+ catalog: {
+ ...createFakeTaskEntryServices().catalog,
+ getCatalog: () => obsolete.promise,
+ },
+ });
+ const currentServices = createFakeTaskEntryServices({
+ catalog: {
+ ...createFakeTaskEntryServices().catalog,
+ getCatalog: () => current.promise,
+ },
+ });
+
+ await act(async () => renderController(root, obsoleteServices));
+ await act(async () => renderController(root, currentServices));
+ await act(async () => obsolete.resolve(catalog(readyHost({ hostId: 'obsolete' }))));
+
+ assert.equal(controller().selectors.target, undefined);
+ assert.equal(controller().selectors.workspacePicker.pending, true);
+
+ await act(async () => current.resolve(catalog(readyHost({ hostId: 'current' }))));
+ assert.equal(controller().selectors.target?.hostId, 'current');
+ assert.equal(controller().selectors.workspacePicker.pending, false);
+ });
+
+ it('deduplicates add requests and selects the returned Project before refreshing', async () => {
+ const { root } = installReactRenderer();
+ const added = deferred<{
+ ok: true;
+ project: ReturnType<typeof project>;
+ }>();
+ 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<typeof project>;
+ }>();
+ 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('keeps a remote directory handoff across reconnecting and closes it when the profile disappears', async () => {
+ const { root } = installReactRenderer();
+ const catalogs: TaskEntryCatalog[] = [
+ { defaultProfileId: 'remote', hosts: [readyRemoteHost('generation-a')] },
+ { defaultProfileId: 'remote', hosts: [reconnectingRemoteHost()] },
+ { defaultProfileId: 'remote', hosts: [readyRemoteHost('generation-a')] },
+ { defaultProfileId: 'local', hosts: [] },
+ ];
+ let reads = 0;
+ const services = createFakeTaskEntryServices({
+ catalog: {
+ ...createFakeTaskEntryServices().catalog,
+ getCatalog: async () => catalogs[reads++]!,
+ },
+ });
+
+ await act(async () => renderController(root, services));
+ await act(async () => controller().commands.addProject());
+ assert.deepEqual(controller().host.directoryHost, {
+ profileId: 'remote',
+ hostId: 'generation-a',
+ name: 'Remote',
+ });
+
+ await act(async () => controller().commands.refresh());
+ assert.equal(controller().host.directoryHost?.hostId, 'generation-a');
+ assert.equal(controller().selectors.target, undefined);
+
+ await act(async () => controller().commands.refresh());
+ assert.equal(controller().host.directoryHost?.hostId, 'generation-a');
+ assert.equal(controller().selectors.target?.hostId, 'generation-a');
+
+ await act(async () => controller().commands.refresh());
+ assert.equal(controller().host.directoryHost, undefined);
+ });
+
+ it('opens a newly added remote Host from the committed catalog generation', async () => {
+ const { root } = installReactRenderer();
+ const stale = deferred<TaskEntryCatalog>();
+ const current = deferred<TaskEntryCatalog>();
+ 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<void>;
+ let refresh!: Promise<void>;
+ await act(async () => {
+ choose = controller().commands.chooseProjectForProfile('remote');
+ refresh = controller().commands.refresh();
+ });
+ await act(async () => stale.resolve({
+ defaultProfileId: 'remote',
+ hosts: [readyRemoteHost('stale-generation')],
+ }));
+ assert.equal(controller().host.directoryHost, undefined);
+
+ await act(async () => current.resolve({
+ defaultProfileId: 'remote',
+ hosts: [readyRemoteHost('current-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<TaskEntryCatalog>();
+ const current = deferred<TaskEntryCatalog>();
+ 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<void>;
+ 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<TaskEntryCatalog>();
+ const current = deferred<TaskEntryCatalog>();
+ 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<void>;
+ 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');
+ });
+
+ it('falls back to a successful handoff catalog when the queued refresh fails', async () => {
+ const { root } = installReactRenderer();
+ const successful = deferred<TaskEntryCatalog>();
+ const failed = deferred<TaskEntryCatalog>();
+ 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 ? successful.promise : failed.promise;
+ },
+ subscribeChanges: (handler) => {
+ emit = handler;
+ return () => undefined;
+ },
+ },
+ });
+
+ await act(async () => renderController(root, services, errors));
+ let settled = false;
+ let chooseError: unknown;
+ let choose!: Promise<void>;
+ await act(async () => {
+ choose = controller().commands.chooseProjectForProfile('remote')
+ .catch((error: unknown) => {
+ chooseError = error;
+ })
+ .finally(() => {
+ settled = true;
+ });
+ emit?.();
+ });
+
+ await act(async () => successful.resolve({
+ defaultProfileId: 'remote',
+ hosts: [readyRemoteHost('successful-generation')],
+ }));
+ assert.equal(settled, false);
+ assert.equal(reads, 3);
+
+ await act(async () => failed.reject(new Error('queued catalog failed')));
+ await act(async () => choose);
+
+ assert.equal(chooseError, undefined);
+ assert.equal(errors.length, 0);
+ assert.equal(controller().selectors.target?.hostId, 'successful-generation');
+ assert.equal(controller().host.directoryHost?.hostId, 'successful-generation');
+ assert.ok(controller().selectors.workspacePicker.retry);
+ });
+
+ it('reports catalog unavailable once when every handoff refresh fails', async () => {
+ const { root } = installReactRenderer();
+ const errors: unknown[] = [];
+ let reads = 0;
+ const services = createFakeTaskEntryServices({
+ catalog: {
+ ...createFakeTaskEntryServices().catalog,
+ getCatalog: async () => {
+ reads += 1;
+ if (reads === 1) return catalog();
+ throw new Error('catalog unavailable');
+ },
+ },
+ });
+
+ await act(async () => renderController(root, services, errors));
+ await act(async () => controller().commands.chooseProjectForProfile('remote'));
+
+ assert.deepEqual(errors, [{
+ title: 'Runtime Hosts unavailable',
+ description: 'Runtime Hosts unavailable',
+ profileId: 'remote',
+ }]);
+ assert.equal(controller().host.directoryHost, undefined);
+ });
+
+ it('reports project update failure when an added Project cannot refresh', async () => {
+ const { root } = installReactRenderer();
+ const refreshed = deferred<TaskEntryCatalog>();
+ const errors: unknown[] = [];
+ let reads = 0;
+ const services = createFakeTaskEntryServices({
+ catalog: {
+ ...createFakeTaskEntryServices().catalog,
+ getCatalog: async () => {
+ reads += 1;
+ return reads === 1 ? catalog() : refreshed.promise;
+ },
+ addProject: async () => ({ ok: true, project: project('project-b') }),
+ },
+ });
+
+ await act(async () => renderController(root, services, errors));
+ await act(async () => {
+ controller().selectors.workspacePicker.groups[0]?.onAdd?.();
+ await Promise.resolve();
+ });
+ assert.equal(controller().selectors.workspacePicker.pending, true);
+
+ await act(async () => refreshed.reject(new Error('catalog refresh failed')));
+
+ assert.equal(controller().selectors.workspacePicker.pending, false);
+ assert.deepEqual(errors, [{
+ title: 'Could not update project',
+ description: 'The project could not be updated. Try again later.',
+ profileId: 'local',
+ }]);
+ });
+});
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 000000000..f1fe25285
--- /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<typeof project>[];
+}): Extract<TaskEntryHost, { state: 'available' }> {
+ 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 000000000..8ccadd0cc
--- /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<MakaBridge, 'newTasks'>;
+ 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 9b91c73ea..2c61228a4 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -60,12 +60,10 @@ import {
type NavSelection,
TitlebarSessionIdentity,
type TurnFooterActionMeta,
- type WorkspacePickerModel,
useToast,
activeInteractionFor,
deriveTitlebarProjectName,
enqueueInteraction,
- getConversationCopy,
reconcileInteractions,
} from '@maka/ui';
import type { ConnectionEvent } from '@maka/core/connections';
@@ -96,7 +94,11 @@ import {
SessionNavigationHost,
useSessionNavigationController,
} from './features/session-navigation';
-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';
@@ -207,7 +209,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 { useShellResume } from './use-shell-resume';
@@ -229,7 +230,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,
@@ -243,16 +243,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.
@@ -388,8 +378,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.
@@ -527,8 +525,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,
@@ -547,9 +548,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
@@ -626,7 +625,6 @@ function AppShellContent({
const shellCopy = getShellCopy(uiLocale).app;
const previousInterruptionCopy =
getShellRemainingCopy(uiLocale).previousMainProcessInterruption;
- 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,
@@ -637,7 +635,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 (!appearanceHydrated) return;
@@ -848,13 +848,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,
@@ -884,7 +884,7 @@ function AppShellContent({
activeSession,
persistedComposerDefaults,
usePersistedComposerDefaults: modelSettingsOwnsComposerHost,
- defaultThinkingLevel: newTask.selectedHost?.chatDefaults.thinkingLevel,
+ defaultThinkingLevel: taskEntry.selectors.selectedHost?.chatDefaults.thinkingLevel,
openSettingsSection,
});
const newChatProviderType = newChatModel
@@ -1525,72 +1525,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 } : {}),
@@ -1599,13 +1535,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,
});
@@ -1623,7 +1559,7 @@ function AppShellContent({
composerRef,
isShellSurfaceOwnerActive,
openSessionInChat,
- newTaskTarget: newTask.target,
+ newTaskTarget: taskEntry.selectors.target,
sessionStartPendingRef,
refreshOnboarding: onboarding.refresh,
refreshSessions,
@@ -1654,8 +1590,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
@@ -1848,7 +1784,7 @@ function AppShellContent({
clearNewChatPermissionChoice: clearNewTaskPermissionChoice,
newChatCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent',
newChatOrchestrationMode: newChatOrchestrationMode,
- newTaskTarget: newTask.target,
+ newTaskTarget: taskEntry.selectors.target,
});
const { handleTurnFooterAction } = useStableActions(createAppShellTurnActions, {
@@ -2526,8 +2462,7 @@ function AppShellContent({
}
async function createSessionInProject(projectId: string) {
- if (!newTask.localHost) return;
- newTask.selectProject(newTask.localHost, projectId);
+ if (!taskEntry.commands.selectLocalProject(projectId)) return;
openNewTaskSurface();
}
@@ -2620,7 +2555,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;
@@ -2679,7 +2615,7 @@ function AppShellContent({
connections: defaultHostConnections.snapshot.connections,
defaultConnection: defaultHostConnections.snapshot.defaultConnection,
messages,
- newTaskProfileId: newTask.selectedProfileId,
+ newTaskProfileId: taskEntry.selectors.selectedProfileId,
settingsOpen,
settingsProfileId: settingsDiagnosticProfileId,
sessions,
@@ -3177,12 +3113,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
}
@@ -3238,32 +3170,9 @@ function AppShellContent({
/>
)}
<GoalHost model={goals.host} />
+ <TaskEntryHost model={taskEntry.host} />
<RuntimeHostSshTerminalDialog />
- <RemoteProjectDirectoryDialog
- host={newTask.directoryHost ? {
- profileId: newTask.directoryHost.profile.id,
- hostId: newTask.directoryHost.hostId,
- name: newTask.directoryHost.profile.name,
- } : undefined}
- returnFocusTo={newTask.directoryOpener}
- onClose={newTask.closeDirectoryPicker}
- onRegistered={(project, host) => {
- void newTask.acceptRegisteredProject(project, host).catch((error) => {
- toastApi.error(
- projectActionsCopy.projectUpdateFailedTitle,
- localizedShellErrorMessage(
- error,
- projectActionsCopy.projectUpdateFailedFallback,
- uiLocale,
- ),
- undefined,
- { profileId: host.profileId },
- );
- });
- }}
- />
-
<AppShellOverlays
settingsOpen={settingsOpen}
closeSettings={closeSettings}
@@ -3275,7 +3184,7 @@ function AppShellContent({
uiLocaleUpdateGate={uiLocaleUpdateGate}
setUserLabel={setUserLabel}
refreshChatDefaults={() => {
- void newTask.refresh();
+ void taskEntry.commands.refresh().catch(() => undefined);
}}
settingsRequestedSection={settingsRequestedSection}
settingsProviderCatalogOpen={settingsProviderCatalogOpen}
@@ -3307,7 +3216,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 000000000..d6d9b3551
--- /dev/null
+++ b/apps/desktop/src/renderer/features/task-entry/README.md
@@ -0,0 +1,66 @@
+<!--
+ 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.
+-->
+
+# 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 000000000..d59a9577c
--- /dev/null
+++ b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts
@@ -0,0 +1,561 @@
+/*
+ * 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,
+ TaskEntryProjectMutationResult,
+ 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<void>;
+ selectLocalProject(projectId: string): boolean;
+ addProject(): void;
+ chooseProjectForProfile(profileId: string): Promise<void>;
+}
+
+export interface TaskEntryController {
+ readonly host: TaskEntryHostModel;
+ readonly commands: TaskEntryControllerCommands;
+ readonly selectors: TaskEntryControllerSelectors;
+}
+
+const EMPTY_CATALOG: TaskEntryCatalog = {
+ defaultProfileId: 'local',
+ hosts: [],
+};
+
+type DirectoryHandoff = TaskEntryHostRef & {
+ readonly name: string;
+};
+
+function directoryHandoffForHost(host: ReadyTaskEntryHost): DirectoryHandoff {
+ return {
+ profileId: host.profile.id,
+ hostId: host.hostId,
+ name: host.profile.name,
+ };
+}
+
+function reconcileDirectoryHandoff(
+ catalog: TaskEntryCatalog,
+ current: DirectoryHandoff | undefined,
+): DirectoryHandoff | undefined {
+ if (!current) return undefined;
+ const host = catalog.hosts.find((candidate) =>
+ candidate.profile.id === current.profileId
+ );
+ if (!host) return undefined;
+ if (host.readiness !== 'ready') {
+ return { ...current, name: host.profile.name };
+ }
+ return host.hostId === current.hostId
+ ? { ...current, name: host.profile.name }
+ : undefined;
+}
+
+/** 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<TaskEntryCatalog>(EMPTY_CATALOG);
+ const [selectedProfileId, setSelectedProfileId] = useState<string>();
+ const [projectSelections, setProjectSelections] = useState(
+ () => new Map<string, string | null>(),
+ );
+ const [pending, setPending] = useState(false);
+ const [refreshing, setRefreshing] = useState(true);
+ const [error, setError] = useState<string>();
+ const [directoryHost, setDirectoryHost] = useState<DirectoryHandoff>();
+ const directoryOpenerRef = useRef<HTMLElement | null>(null);
+ const committedCatalogRef = useRef<TaskEntryCatalog>(EMPTY_CATALOG);
+ const refreshRequestSequenceRef = useRef(0);
+ const refreshLifecycleRef = useRef(0);
+ const refreshRunRef = useRef<Promise<TaskEntryCatalog | undefined> | undefined>(undefined);
+ const projectMutationPendingRef = useRef(false);
+
+ const commitCatalog = useCallback((next: TaskEntryCatalog): void => {
+ committedCatalogRef.current = next;
+ setCatalog(next);
+ setSelectedProfileId((current) => selectAvailableProfile(next, current));
+ setDirectoryHost((current) => reconcileDirectoryHandoff(next, current));
+ }, []);
+
+ const refresh = useCallback((): Promise<TaskEntryCatalog | undefined> => {
+ refreshRequestSequenceRef.current += 1;
+ const running = refreshRunRef.current;
+ if (running) return running;
+
+ const lifecycle = refreshLifecycleRef.current;
+ setRefreshing(true);
+ const run = (async (): Promise<TaskEntryCatalog | undefined> => {
+ let latestSuccess: TaskEntryCatalog | undefined;
+ let latestFailure: unknown;
+
+ while (refreshLifecycleRef.current === lifecycle) {
+ const requestedSequence = refreshRequestSequenceRef.current;
+ try {
+ latestSuccess = await service.getCatalog();
+ latestFailure = undefined;
+ } catch (cause) {
+ latestFailure = cause;
+ }
+ if (refreshLifecycleRef.current !== lifecycle) {
+ return undefined;
+ }
+ if (requestedSequence === refreshRequestSequenceRef.current) break;
+ }
+
+ if (latestSuccess) {
+ commitCatalog(latestSuccess);
+ setError(latestFailure === undefined
+ ? undefined
+ : localizedShellErrorMessage(latestFailure, copy.catalogUnavailable, locale));
+ return latestSuccess;
+ }
+
+ setError(localizedShellErrorMessage(latestFailure, copy.catalogUnavailable, locale));
+ throw latestFailure;
+ })();
+ refreshRunRef.current = run;
+
+ const finish = (): void => {
+ if (
+ refreshRunRef.current === run &&
+ refreshLifecycleRef.current === lifecycle
+ ) {
+ refreshRunRef.current = undefined;
+ setRefreshing(false);
+ }
+ };
+ void run.then(finish, finish);
+ return run;
+ }, [commitCatalog, copy.catalogUnavailable, locale, service]);
+
+ useEffect(() => {
+ const unsubscribe = service.subscribeChanges(() => {
+ void refresh().catch(() => undefined);
+ });
+ void refresh().catch(() => undefined);
+ return () => {
+ refreshLifecycleRef.current += 1;
+ refreshRequestSequenceRef.current += 1;
+ refreshRunRef.current = undefined;
+ 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 refreshAfterProjectMutation = useCallback(async (profileId: string): Promise<void> => {
+ try {
+ await refresh();
+ } catch (cause) {
+ reportError({
+ title: copy.projectUpdateFailedTitle,
+ description: localizedShellErrorMessage(
+ cause,
+ copy.projectUpdateFailedFallback,
+ locale,
+ ),
+ profileId,
+ });
+ }
+ }, [copy.projectUpdateFailedFallback, copy.projectUpdateFailedTitle, locale, refresh, reportError]);
+
+ const addProjectForHost = useCallback(async (host: ReadyTaskEntryHost): Promise<void> => {
+ if (projectMutationPendingRef.current) return;
+ if (host.capabilities.chooseHostDirectory) {
+ directoryOpenerRef.current =
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ setDirectoryHost(directoryHandoffForHost(host));
+ return;
+ }
+ if (!host.capabilities.chooseClientDirectory) return;
+ projectMutationPendingRef.current = true;
+ setPending(true);
+ try {
+ let result: TaskEntryProjectMutationResult;
+ try {
+ result = await service.addProject({
+ profileId: host.profile.id,
+ hostId: host.hostId,
+ });
+ } catch (cause) {
+ reportError({
+ title: copy.selectDirectoryFailedTitle,
+ description: localizedShellErrorMessage(cause, copy.readPathFailedFallback, locale),
+ profileId: host.profile.id,
+ });
+ return;
+ }
+ if (!result.ok) return;
+ setSelectedProfileId(host.profile.id);
+ setProjectSelections((current) =>
+ new Map(current).set(host.profile.id, result.project.id),
+ );
+ await refreshAfterProjectMutation(host.profile.id);
+ } finally {
+ projectMutationPendingRef.current = false;
+ setPending(false);
+ }
+ }, [copy.readPathFailedFallback, copy.selectDirectoryFailedTitle, locale, refreshAfterProjectMutation, reportError, service]);
+
+ const chooseProjectForProfile = useCallback(async (profileId: string): Promise<void> => {
+ let next: TaskEntryCatalog | undefined;
+ try {
+ next = await refresh();
+ } catch (cause) {
+ reportError({
+ title: copy.catalogUnavailable,
+ description: localizedShellErrorMessage(cause, copy.catalogUnavailable, locale),
+ profileId,
+ });
+ return;
+ }
+ if (!next) return;
+ 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(directoryHandoffForHost(host));
+ }
+ }, [copy.catalogUnavailable, locale, refresh, reportError]);
+
+ const acceptRegisteredProject = useCallback(async (
+ project: ProjectRecord,
+ registeredHost: TaskEntryHostRef,
+ ): Promise<void> => {
+ const host = directoryHost;
+ if (
+ !host ||
+ host.profileId !== registeredHost.profileId ||
+ host.hostId !== registeredHost.hostId
+ ) return;
+ setDirectoryHost(undefined);
+ setSelectedProfileId(host.profileId);
+ setProjectSelections((current) => new Map(current).set(host.profileId, project.id));
+ await refreshAfterProjectMutation(host.profileId);
+ }, [directoryHost, refreshAfterProjectMutation]);
+
+ const relinkProject = useCallback(async (
+ host: ReadyTaskEntryHost,
+ projectId: string,
+ ): Promise<void> => {
+ if (!host.capabilities.chooseClientDirectory || projectMutationPendingRef.current) return;
+ projectMutationPendingRef.current = true;
+ setPending(true);
+ try {
+ let result: TaskEntryProjectMutationResult;
+ try {
+ result = await service.relinkProject(
+ { profileId: host.profile.id, hostId: host.hostId },
+ projectId,
+ );
+ } catch (cause) {
+ reportError({
+ title: copy.selectDirectoryFailedTitle,
+ description: localizedShellErrorMessage(cause, copy.readPathFailedFallback, locale),
+ profileId: host.profile.id,
+ });
+ return;
+ }
+ if (!result.ok) return;
+ setSelectedProfileId(host.profile.id);
+ setProjectSelections((current) =>
+ new Map(current).set(host.profile.id, result.project.id),
+ );
+ await refreshAfterProjectMutation(host.profile.id);
+ } finally {
+ projectMutationPendingRef.current = false;
+ setPending(false);
+ }
+ }, [copy.readPathFailedFallback, copy.selectDirectoryFailedTitle, locale, refreshAfterProjectMutation, reportError, service]);
+
+ const workspacePicker = useMemo<WorkspacePickerModel>(() => {
+ 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<void> => {
+ 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.profileId,
+ hostId: directoryHost.hostId,
+ name: directoryHost.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 000000000..0b75d7d89
--- /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 000000000..b273715bc
--- /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 000000000..16a52e601
--- /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<TaskEntryCatalog>;
+ subscribeChanges(handler: () => void): TaskEntryUnsubscribe;
+ addProject(host: TaskEntryHostRef): Promise<TaskEntryProjectMutationResult>;
+ relinkProject(
+ host: TaskEntryHostRef,
+ projectId: string,
+ ): Promise<TaskEntryProjectMutationResult>;
+}
+
+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 000000000..a09cdf857
--- /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<TaskEntryServices | null>(null);
+
+export function TaskEntryServicesProvider(props: {
+ services: TaskEntryServices;
+ children?: ReactNode;
+}) {
+ return (
+ <TaskEntryServicesContext.Provider value={props.services}>
+ {props.children}
+ </TaskEntryServicesContext.Provider>
+ );
+}
+
+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 000000000..cda19f74e
--- /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> = {},
+): 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 000000000..64f1ed7c1
--- /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<void>;
+}
+
+export function TaskEntryHost({ model }: { model: TaskEntryHostModel }) {
+ return (
+ <RemoteProjectDirectoryDialog
+ host={model.directoryHost}
+ returnFocusTo={model.directoryOpener}
+ onClose={model.closeDirectoryPicker}
+ onRegistered={(project, host) => {
+ void model.acceptRegisteredProject(project, host);
+ }}
+ />
+ );
+}
diff --git a/apps/desktop/src/renderer/main.tsx b/apps/desktop/src/renderer/main.tsx
index 63013c54e..4c676e3d3 100644
--- a/apps/desktop/src/renderer/main.tsx
+++ b/apps/desktop/src/renderer/main.tsx
@@ -32,6 +32,8 @@ import { ModuleHubServicesProvider } from './features/module-hub';
import { createDesktopModuleHubServices } from './platform/desktop/create-module-hub-services';
import { SessionNavigationServicesProvider } from './features/session-navigation';
import { createDesktopSessionNavigationServices } from './platform/desktop/create-session-navigation-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;
@@ -42,6 +44,7 @@ const workbarServices = createDesktopWorkbarServices();
const goalServices = createDesktopGoalServices();
const moduleHubServices = createDesktopModuleHubServices();
const sessionNavigationServices = createDesktopSessionNavigationServices();
+const taskEntryServices = createDesktopTaskEntryServices();
/**
* Prefetch the onboarding snapshot BEFORE mounting React. The preload
@@ -75,13 +78,15 @@ async function prefetchOnboardingSnapshot(): Promise<OnboardingSnapshot | null>
void prefetchOnboardingSnapshot().then((initialOnboardingSnapshot) => {
createRoot(document.getElementById('root')!).render(
<SessionNavigationServicesProvider services={sessionNavigationServices}>
- <ModuleHubServicesProvider services={moduleHubServices}>
- <GoalServicesProvider services={goalServices}>
- <WorkbarServicesProvider services={workbarServices}>
- <App initialOnboardingSnapshot={initialOnboardingSnapshot} />
- </WorkbarServicesProvider>
- </GoalServicesProvider>
- </ModuleHubServicesProvider>
+ <TaskEntryServicesProvider services={taskEntryServices}>
+ <ModuleHubServicesProvider services={moduleHubServices}>
+ <GoalServicesProvider services={goalServices}>
+ <WorkbarServicesProvider services={workbarServices}>
+ <App initialOnboardingSnapshot={initialOnboardingSnapshot} />
+ </WorkbarServicesProvider>
+ </GoalServicesProvider>
+ </ModuleHubServicesProvider>
+ </TaskEntryServicesProvider>
</SessionNavigationServicesProvider>,
);
});
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 000000000..f34d256f5
--- /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<MakaBridge, 'newTasks'>;
+
+/** 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 7d82a0ca7..000000000
--- 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<DesktopNewTaskCatalog>({
- defaultProfileId: 'local',
- hosts: [],
- });
- const [selectedProfileId, setSelectedProfileId] = useState<string>();
- const [projectSelections, setProjectSelections] = useState(
- () => new Map<string, string | null>(),
- );
- const [pending, setPending] = useState(false);
- const [refreshing, setRefreshing] = useState(true);
- const [error, setError] = useState<string>();
- const [directoryHost, setDirectoryHost] = useState<ReadyHost>();
- const directoryOpenerRef = useRef<HTMLElement | null>(null);
- const refreshSequence = useRef(0);
-
- async function refresh(): Promise<DesktopNewTaskCatalog> {
- 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<void> {
- 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<void> {
- 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<void> {
- 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<void> {
- 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 2c55693aa..5d8510fa4 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:** 215 files — blocker 0, polish 1, aligned 214.
+**Totals:** 217 files — blocker 0, polish 1, aligned 216.
## Exclusions (explicit)
@@ -45,6 +45,8 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `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/session-navigation/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-host.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `apps/desktop/src/renderer/features/task-entry/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/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 c99a8d9e8..bf618b944 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -17,6 +17,8 @@ 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/session-navigation/services-context.tsx
apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-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
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org