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