You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/22 22:38:46 UTC
(maka) branch feat/runtime-host-rust updated: feat(desktop): name projects and move tasks through scoped workspace admission
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/feat/runtime-host-rust by this push:
new 329bccbcc2 feat(desktop): name projects and move tasks through scoped workspace admission
329bccbcc2 is described below
commit 329bccbcc2b9ec685ccf2723bf563ebc12606e42
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Sep 23 06:38:40 2026 +0800
feat(desktop): name projects and move tasks through scoped workspace admission
Generated-by: Codex
---
apps/desktop/renderer-architecture.json | 2 +-
.../main/__tests__/main-window-drop-guard.test.ts | 93 ++++++++++++
.../src/main/__tests__/overlays-boundary.test.ts | 5 +-
.../__tests__/project-management-service.test.ts | 37 +++++
.../runtime-host-client-operations.test.ts | 36 +++++
.../runtime-host-session-catalog-ipc-main.test.ts | 35 +++++
...ession-navigation-row-actions-revisions.test.ts | 1 +
.../session-navigation-session-purge.test.ts | 1 +
.../main/__tests__/task-entry-controller.test.ts | 32 +++-
apps/desktop/src/main/app-ipc-main.ts | 14 +-
apps/desktop/src/main/main-window-drop-guard.ts | 36 +++++
apps/desktop/src/main/main-window.ts | 20 +--
.../desktop/src/main/project-management-service.ts | 20 ++-
apps/desktop/src/main/runtime-host-boot.ts | 3 +-
apps/desktop/src/main/runtime-host-client.ts | 19 +++
.../main/runtime-host-session-catalog-ipc-main.ts | 46 ++++++
apps/desktop/src/preload/bridge-contract.d.ts | 14 +-
apps/desktop/src/preload/preload.ts | 13 +-
apps/desktop/src/renderer/app-shell.tsx | 6 +-
.../overlays/controller/use-overlays-controller.ts | 3 +-
.../features/overlays/model/overlays-projection.ts | 2 +-
.../controller/session-row-actions.ts | 28 +++-
.../model/session-navigation-groups.ts | 16 +-
.../renderer/features/session-navigation/ports.ts | 7 +
.../features/session-navigation/testing.ts | 1 +
.../ui/session-navigation-provider.tsx | 64 ++++++++
.../controller/use-task-entry-controller.ts | 51 +++++--
.../src/renderer/features/task-entry/ports.ts | 2 +-
.../features/task-entry/ui/task-entry-host.tsx | 13 +-
.../features/task-entry/ui/task-entry-provider.tsx | 4 +
.../src/renderer/locales/settings-projects-copy.ts | 4 +-
apps/desktop/src/renderer/locales/shell-copy.ts | 28 ++++
.../renderer/remote-project-directory-dialog.tsx | 3 +-
.../renderer/settings/projects-settings-page.tsx | 44 ++++--
apps/desktop/src/renderer/styles/sidebar.css | 13 ++
apps/desktop/stories/app-shell.stories.tsx | 29 ++++
docs/astryx-surface-file-inventory.md | 5 +-
docs/astryx-surface-file-inventory.paths | 1 +
packages/ui/src/conversation-copy.ts | 30 +++-
packages/ui/src/index.ts | 2 +
packages/ui/src/new-project-dialog.tsx | 102 +++++++++++++
packages/ui/src/session-history-list.tsx | 169 ++++++++++++++++++++-
packages/ui/src/session-rail-context.tsx | 28 ++--
packages/ui/src/workspace-picker.tsx | 36 ++++-
packages/ui/stories/session-list-panel.stories.tsx | 30 ++++
packages/ui/stories/session-rail-harness.tsx | 3 +
46 files changed, 1049 insertions(+), 102 deletions(-)
diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index 57779f378f..c107472512 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -811,7 +811,7 @@
"react": 1
},
"importSpecifiers": 100,
- "nonTriviaTokens": 13582
+ "nonTriviaTokens": 13580
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 3,
diff --git a/apps/desktop/src/main/__tests__/main-window-drop-guard.test.ts b/apps/desktop/src/main/__tests__/main-window-drop-guard.test.ts
new file mode 100644
index 0000000000..dba1a9046f
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/main-window-drop-guard.test.ts
@@ -0,0 +1,93 @@
+/*
+ * 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 assert from 'node:assert/strict';
+import test from 'node:test';
+import { runInNewContext } from 'node:vm';
+import { MAIN_WINDOW_DROP_GUARD_SCRIPT } from '../main-window-drop-guard.js';
+
+class DropTarget {
+ constructor(private readonly selector: string) {}
+ closest(selector: string) { return selector === this.selector ? this : null; }
+}
+
+function dispatchDropGuard(type: string, target: unknown, types: string[]) {
+ const listeners = new Map<string, (event: unknown) => void>();
+ runInNewContext(MAIN_WINDOW_DROP_GUARD_SCRIPT, {
+ Element: DropTarget,
+ window: {
+ addEventListener(type: string, listener: (event: unknown) => void, capture: boolean) {
+ assert.equal(capture, true);
+ listeners.set(type, listener);
+ },
+ },
+ });
+ let prevented = false;
+ let stopped = false;
+ listeners.get(type)!({
+ target,
+ dataTransfer: { types },
+ preventDefault() { prevented = true; },
+ stopPropagation() { stopped = true; },
+ });
+ return { prevented, stopped };
+}
+
+for (const type of ['dragover', 'drop']) {
+ test(`${type}: session payload reaches a project target through the window guard`, () => {
+ const project = new DropTarget('[data-maka-session-drop-target="true"]');
+ for (const target of [project, { parentElement: project }]) {
+ assert.deepEqual(dispatchDropGuard(type, target, ['application/x-maka-session']), {
+ prevented: false, stopped: false,
+ });
+ }
+ });
+
+ test(`${type}: files and foreign text remain blocked on project targets`, () => {
+ const project = new DropTarget('[data-maka-session-drop-target="true"]');
+ for (const types of [['Files'], ['text/plain'], []]) {
+ assert.deepEqual(dispatchDropGuard(type, project, types), {
+ prevented: true, stopped: true,
+ });
+ }
+ });
+
+ test(`${type}: session drops outside a move target remain blocked`, () => {
+ for (const target of [new DropTarget('body'), null]) {
+ assert.deepEqual(dispatchDropGuard(type, target, ['application/x-maka-session']), {
+ prevented: true, stopped: true,
+ });
+ }
+ });
+
+ test(`${type}: attachment and queue drop routes remain available`, () => {
+ assert.deepEqual(dispatchDropGuard(type,
+ new DropTarget('[data-maka-file-drop-target="true"]'), ['Files']), {
+ prevented: false, stopped: false,
+ });
+ assert.deepEqual(dispatchDropGuard(type,
+ new DropTarget('[data-maka-queue-drop-target="true"]'), ['application/x-maka-queue-entry']), {
+ prevented: false, stopped: false,
+ });
+ assert.deepEqual(dispatchDropGuard(type,
+ new DropTarget('[data-maka-queue-drop-target="true"]'), ['application/x-maka-session']), {
+ prevented: true, stopped: true,
+ });
+ });
+}
diff --git a/apps/desktop/src/main/__tests__/overlays-boundary.test.ts b/apps/desktop/src/main/__tests__/overlays-boundary.test.ts
index b381d3e7c7..b51951d40a 100644
--- a/apps/desktop/src/main/__tests__/overlays-boundary.test.ts
+++ b/apps/desktop/src/main/__tests__/overlays-boundary.test.ts
@@ -209,7 +209,6 @@ describe('Overlays feature boundary', () => {
}
}
assert.deepEqual(imports.sort(), [
- 'src/renderer/app-shell-command-actions.ts: Command',
'src/renderer/app-shell-overlays.tsx: *',
'src/renderer/app-shell-overlays.tsx: OverlaysShellProjection',
'src/renderer/app-shell.tsx: *',
@@ -261,7 +260,7 @@ describe('Overlays feature boundary', () => {
'OverlaysRoot',
'OverlaysConsumer',
'KeyboardHelpModal',
- 'CommandPalette',
+ 'SessionCommandPalette',
'SearchModalHost',
]) {
const count = jsxMounts(source, path, name, isFeatureEntry);
@@ -271,10 +270,10 @@ describe('Overlays feature boundary', () => {
}
}
assert.deepEqual(mounts.sort(), [
- 'src/renderer/app-shell-overlays.tsx: CommandPalette',
'src/renderer/app-shell-overlays.tsx: KeyboardHelpModal',
'src/renderer/app-shell-overlays.tsx: OverlaysConsumer',
'src/renderer/app-shell-overlays.tsx: SearchModalHost',
+ 'src/renderer/app-shell-overlays.tsx: SessionCommandPalette',
'src/renderer/app-shell.tsx: OverlaysRoot',
]);
});
diff --git a/apps/desktop/src/main/__tests__/project-management-service.test.ts b/apps/desktop/src/main/__tests__/project-management-service.test.ts
index fef7a9cb70..be0c2c3aed 100644
--- a/apps/desktop/src/main/__tests__/project-management-service.test.ts
+++ b/apps/desktop/src/main/__tests__/project-management-service.test.ts
@@ -166,6 +166,43 @@ test('can register a draft Project without changing the Host selection', async (
assert.equal(selected, false);
});
+test('local and Host directory registration return the requested name and surface rename failure', async () => {
+ for (const remote of [false, true]) {
+ const calls: string[] = [];
+ let failRename = false;
+ const project = { id: 'project', name: 'folder', locations: [], available: true, preferredPath: '/workspace' };
+ const register = async () => { calls.push('register'); return project; };
+ const service = createProjectManagementService({
+ capabilities: remote ? REMOTE_CAPABILITIES : LOCAL_CAPABILITIES,
+ catalog: { list: unexpected, register, relink: unexpected, archive: unexpected, restore: unexpected,
+ rename: async (id, name) => {
+ assert.equal(id, 'project');
+ calls.push(`rename:${name}`);
+ if (failRename) throw new Error('Rename unavailable');
+ return { ...project, name };
+ },
+ },
+ directoryCatalog: { listDirectoryRoots: unexpected, listDirectories: unexpected, registerDirectory: register },
+ chooseDirectory: async () => '/workspace',
+ selection: { currentSelection: unexpected, setSelection: () => { calls.push('select'); } },
+ });
+ const create = () => remote
+ ? service.registerDirectory({ rootId: 'home', segments: [], name: ' Named project ' })
+ : service.add({ name: ' Named project ' }).then(result => { assert.ok(result.ok); return result.project; });
+ assert.equal((await create()).name, 'Named project');
+ assert.deepEqual(calls, remote ? ['register', 'rename:Named project'] : ['register', 'rename:Named project', 'select']);
+ calls.length = 0;
+ failRename = true;
+ await assert.rejects(create(), /Rename unavailable/);
+ assert.deepEqual(calls, ['register', 'rename:Named project']);
+ if (remote) {
+ calls.length = 0;
+ await assert.rejects(service.registerDirectory({ rootId: 'home', segments: [], name: 1 }), /Invalid project name/);
+ assert.deepEqual(calls, []);
+ }
+ }
+});
+
test('rejects malformed Project identities before catalog access', async () => {
const service = createProjectManagementService({
capabilities: LOCAL_CAPABILITIES,
diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts
index 6388e2d693..ed6cbe7994 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts
@@ -851,6 +851,42 @@ interface RecordedRequest {
input: unknown;
}
+test('a relocate conflict is reported, not replayed with a stale directory', async () => {
+ const { client, requests } = clientWithResponses([
+ {
+ kind: 'session',
+ session: session('session-1', 1, {
+ workspace: { target: { kind: 'host_path', path: '/old' }, hostCwd: '/old' },
+ }),
+ },
+ { kind: 'revision_conflict', expectedRevision: 1, actualRevision: 2 },
+ ]);
+
+ const current = await client.getSession('session-1');
+ assert.ok(current);
+ await assert.rejects(
+ () =>
+ client.relocateSessionWorkspace('session-1', current.revision, {
+ kind: 'host_path',
+ path: current.workspace.hostCwd,
+ }),
+ /kept changing during relocate/,
+ );
+
+ // One attempt, at the revision the directory was read from. A replay would
+ // commit `/old` under revision 2 — moving the Session back to a directory a
+ // concurrent writer had already left.
+ assert.deepEqual(
+ requests.map(({ operation }) => operation),
+ ['session.catalog.query', 'session.workspace.relocate'],
+ );
+ assert.deepEqual(requests[1]?.input, {
+ sessionId: 'session-1',
+ expectedRevision: 1,
+ workspace: { kind: 'host_path', path: '/old' },
+ });
+});
+
function clientWithResponses(responses: unknown[]): {
client: DesktopRuntimeHostClient;
requests: RecordedRequest[];
diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts
index ae79de80a7..51322958be 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts
@@ -19,6 +19,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
+import { DesktopRuntimeHostClientError } from '../runtime-host-client.js';
import type { IpcMain } from 'electron';
import type { SessionCatalogProjection, SessionCreateInput } from '@maka/runtime-host/protocol';
import {
@@ -102,6 +103,40 @@ test('session creation forwards a plugin executor without a model target', async
);
});
+test('moves only the requested Session and keeps detach cwd paired with its revision', async () => {
+ const ipc = ipcHarness();
+ const deps = createDeps([]);
+ const moves: unknown[] = [];
+ const changed: unknown[] = [];
+ let current: SessionCatalogProjection | null = projection({ revision: 7 });
+ let conflict = false;
+ deps.client.getSession = async () => current;
+ deps.client.relocateSessionWorkspace = async (id, revision, workspace) => {
+ moves.push({ id, revision, workspace });
+ if (conflict) throw new DesktopRuntimeHostClientError('revision_conflict', 'Concurrent move');
+ assert.ok(current);
+ current = projection({ revision: revision + 1, workspace: { target: workspace,
+ hostCwd: workspace.kind === 'host_path' ? workspace.path : '/destination' } });
+ return current;
+ };
+ deps.emitSessionsChanged = (...args) => { changed.push(args); };
+ registerRuntimeHostSessionCatalogIpc(deps, ipc as unknown as IpcMain);
+ assert.equal((await ipc.invoke('sessions:moveToProject', 'session-1', 'project-2') as { ok: boolean }).ok, true);
+ assert.equal((await ipc.invoke('sessions:moveToProject', 'session-1', null) as { ok: boolean }).ok, true);
+ conflict = true;
+ assert.deepEqual(await ipc.invoke('sessions:moveToProject', 'session-1', null), { ok: false, code: 'operation_conflict' });
+ assert.deepEqual(moves, [
+ { id: 'session-1', revision: 7, workspace: { kind: 'project', projectId: 'project-2' } },
+ { id: 'session-1', revision: 8, workspace: { kind: 'host_path', path: '/destination' } },
+ { id: 'session-1', revision: 9, workspace: { kind: 'host_path', path: '/destination' } },
+ ]);
+ assert.deepEqual(changed, [['updated', 'session-1'], ['updated', 'session-1']]);
+ await assert.rejects(ipc.invoke('sessions:moveToProject', 'session-1', {}), /Invalid project/);
+ current = null;
+ assert.deepEqual(await ipc.invoke('sessions:moveToProject', 'session-1', null), { ok: false, code: 'not_found' });
+ assert.equal(moves.length, 3);
+});
+
type IpcHandler = Parameters<Pick<IpcMain, 'handle'>['handle']>[1];
function ipcHarness() {
diff --git a/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts b/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts
index 456c5d49a6..74ee3edd09 100644
--- a/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts
+++ b/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts
@@ -51,6 +51,7 @@ function createService(
const { disposition = 'removed', archivedSubtaskCount = 0, preview = {} } = opts;
return {
list: async () => [],
+ moveToProject: async () => ({ ok: true as const }),
setFlagged: async (id: string, value: boolean, options: { revisionFamily: true }) => {
calls.push(`flag:${id}:${value}:${options.revisionFamily}`);
},
diff --git a/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts b/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts
index 1d4832653f..d8c8f2cfd0 100644
--- a/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts
+++ b/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts
@@ -125,6 +125,7 @@ function installService(
if (options.rejectPreviewIds?.includes(id)) throw new Error(`preview-unavailable:${id}`);
return options.previewSubtasks?.[id] ?? 0;
},
+ moveToProject: async () => ({ ok: true as const }),
};
}
diff --git a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts
index 92dd69ccec..4ae51febb8 100644
--- a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts
+++ b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts
@@ -316,8 +316,8 @@ describe('useTaskEntryController', () => {
await act(async () => renderController(root, services));
await act(async () => {
- controller().selectors.workspacePicker.groups[0]?.onAdd?.();
- controller().selectors.workspacePicker.groups[0]?.onAdd?.();
+ controller().selectors.workspacePicker.groups[0]?.onAdd?.('New project');
+ controller().selectors.workspacePicker.groups[0]?.onAdd?.('New project');
});
assert.equal(addCalls, 1);
assert.equal(controller().selectors.workspacePicker.pending, true);
@@ -327,6 +327,32 @@ describe('useTaskEntryController', () => {
assert.equal(controller().selectors.workspacePicker.pending, false);
});
+ it('keeps a named project bound to its original Host while the catalog changes', async () => {
+ const { root } = installReactRenderer();
+ let current = readyHost({ hostId: 'host-a' });
+ const calls: unknown[] = [];
+ const services = createFakeTaskEntryServices({ catalog: {
+ ...createFakeTaskEntryServices().catalog,
+ getCatalog: async () => catalog(current),
+ addProject: async (host, name) => { calls.push({ host, name }); return { ok: false, reason: 'cancelled' }; },
+ } });
+ await act(async () => renderController(root, services));
+ const originalGroup = controller().selectors.workspacePicker.groups[0]!.id;
+ await act(async () => controller().commands.openNewProject());
+ assert.equal(controller().host.newProjectDialog?.disabled, false);
+ current = readyHost({ hostId: 'host-b' });
+ await act(async () => controller().commands.refresh());
+ assert.notEqual(controller().selectors.workspacePicker.groups[0]!.id, originalGroup);
+ assert.equal(controller().host.newProjectDialog?.disabled, true);
+ await act(async () => controller().host.newProjectDialog?.submit('Preserved name'));
+ assert.deepEqual(calls, []);
+ current = readyHost({ hostId: 'host-a' });
+ await act(async () => controller().commands.refresh());
+ assert.equal(controller().host.newProjectDialog?.disabled, false);
+ await act(async () => controller().host.newProjectDialog?.submit('Preserved name'));
+ assert.deepEqual(calls, [{ host: { profileId: 'local', hostId: 'host-a' }, name: 'Preserved name' }]);
+ });
+
it('deduplicates relink requests and selects the returned Project before refreshing', async () => {
const { root } = installReactRenderer();
const relinked = deferred<{
@@ -725,7 +751,7 @@ describe('useTaskEntryController', () => {
await act(async () => renderController(root, services, errors));
await act(async () => {
- controller().selectors.workspacePicker.groups[0]?.onAdd?.();
+ controller().selectors.workspacePicker.groups[0]?.onAdd?.('New project');
await Promise.resolve();
});
assert.equal(controller().selectors.workspacePicker.pending, true);
diff --git a/apps/desktop/src/main/app-ipc-main.ts b/apps/desktop/src/main/app-ipc-main.ts
index e4d1e0309f..68491ef347 100644
--- a/apps/desktop/src/main/app-ipc-main.ts
+++ b/apps/desktop/src/main/app-ipc-main.ts
@@ -133,8 +133,11 @@ export function registerAppIpc(
handleReconnectableRead(targetIpc, 'projects:getSnapshot', () =>
deps.projectManagement.getSnapshot(),
);
- targetIpc.handle('projects:add', (_event, options?: { select?: unknown }) =>
- deps.projectManagement.add({ select: options?.select !== false }));
+ targetIpc.handle('projects:add', (_event, options?: { select?: unknown; name?: unknown }) =>
+ deps.projectManagement.add({
+ select: options?.select !== false,
+ ...(options?.name === undefined ? {} : { name: requireProjectName(options.name) }),
+ }));
handleReconnectableRead(targetIpc, 'projects:directoryRoots', () =>
deps.projectManagement.directoryRoots());
handleReconnectableRead(targetIpc, 'projects:listDirectory', (_event, input: unknown) =>
@@ -218,3 +221,10 @@ function isAppUpdateInstallRequest(input: unknown): input is AppUpdateInstallReq
'allowInterruptActiveTasks' in input &&
typeof input.allowInterruptActiveTasks === 'boolean';
}
+
+function requireProjectName(value: unknown): string {
+ if (typeof value !== 'string' || value.length > 200 || /[\u0000-\u001f\u007f]/u.test(value)) {
+ throw new TypeError('Invalid project name.');
+ }
+ return value;
+}
diff --git a/apps/desktop/src/main/main-window-drop-guard.ts b/apps/desktop/src/main/main-window-drop-guard.ts
new file mode 100644
index 0000000000..c6078c68f2
--- /dev/null
+++ b/apps/desktop/src/main/main-window-drop-guard.ts
@@ -0,0 +1,36 @@
+/*
+ * 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.
+ */
+
+/** Installed in the renderer before any application drop handlers run. */
+export const MAIN_WINDOW_DROP_GUARD_SCRIPT = `
+(() => {
+ const block = (e) => {
+ const target = e.target instanceof Element ? e.target : e.target?.parentElement;
+ if (target?.closest('[data-maka-file-drop-target="true"]')) return;
+ if (target?.closest('[data-maka-queue-drop-target="true"]')
+ && e.dataTransfer?.types.includes('application/x-maka-queue-entry')) return;
+ if (target?.closest('[data-maka-session-drop-target="true"]')
+ && e.dataTransfer?.types.includes('application/x-maka-session')) return;
+ e.preventDefault();
+ e.stopPropagation();
+ };
+ window.addEventListener('dragover', block, true);
+ window.addEventListener('drop', block, true);
+})();
+`;
diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts
index 23b231dbb1..a41b64e566 100644
--- a/apps/desktop/src/main/main-window.ts
+++ b/apps/desktop/src/main/main-window.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import { MAIN_WINDOW_DROP_GUARD_SCRIPT } from './main-window-drop-guard.js';
import { app, BrowserWindow, dialog, nativeTheme, screen, shell, type View, webFrameMain } from 'electron';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
@@ -493,22 +494,11 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main
// BrowserWindow tries to navigate to its `file://` URL; the `will-navigate`
// handler above stops the navigation, but the visual flash + dropEffect
// ambiguity is still confusing. Suppressing dragover/drop at the document
- // level keeps the chat surface immutable to accidental drops.
+ // level blocks accidental drops while designated application targets remain usable.
mainWindow.webContents.on('did-finish-load', () => {
- mainWindow?.webContents.executeJavaScript(`
- (() => {
- const block = (e) => {
- const target = e.target instanceof Element ? e.target : e.target?.parentElement;
- if (target?.closest('[data-maka-file-drop-target="true"]')) return;
- if (target?.closest('[data-maka-queue-drop-target="true"]')
- && e.dataTransfer?.types.includes('application/x-maka-queue-entry')) return;
- e.preventDefault();
- e.stopPropagation();
- };
- window.addEventListener('dragover', block, true);
- window.addEventListener('drop', block, true);
- })();
- `).catch(() => { /* renderer may not be ready; ignore */ });
+ mainWindow?.webContents.executeJavaScript(MAIN_WINDOW_DROP_GUARD_SCRIPT).catch(() => {
+ /* renderer may not be ready; ignore */
+ });
});
// Restore maximized state after construction (BrowserWindow constructor
diff --git a/apps/desktop/src/main/project-management-service.ts b/apps/desktop/src/main/project-management-service.ts
index 9b5effd22f..9c6521891f 100644
--- a/apps/desktop/src/main/project-management-service.ts
+++ b/apps/desktop/src/main/project-management-service.ts
@@ -36,7 +36,8 @@ type SelectedDirectoryActionResult =
export interface ProjectManagementService {
current(): Promise<CurrentProjectSelection>;
getSnapshot(): Promise<DesktopProjectSnapshot>;
- add(options?: { select?: boolean }): Promise<SelectedDirectoryActionResult>;
+ /** Register the chosen directory and apply its name before selecting it. */
+ add(options?: { select?: boolean; name?: string }): Promise<SelectedDirectoryActionResult>;
select(
projectId: unknown,
): Promise<{ project: ProjectRecord | null; path: string }>;
@@ -121,7 +122,13 @@ export function createProjectManagementService(deps: {
requireLocalDirectoryActions(deps);
const path = await deps.chooseDirectory();
if (!path) return { ok: false, reason: 'cancelled' };
- const project = await deps.catalog.register(path);
+ const registered = await deps.catalog.register(path);
+ // Rename before selecting, so the value the caller reads back (and the
+ // one the selection records) is the name the user typed, not the folder
+ // basename it was registered under. A blank name is not a name: it leaves
+ // the folder-derived one alone rather than failing the whole add.
+ const name = options?.name?.trim();
+ const project = name ? await deps.catalog.rename(registered.id, name) : registered;
const selected = requireSelectableProject(project);
if (options?.select !== false) {
deps.selection.setSelection(selected.id, selected.preferredPath);
@@ -176,12 +183,17 @@ export function createProjectManagementService(deps: {
);
},
- registerDirectory(input) {
+ async registerDirectory(input) {
const directory = requireDirectoryInput(input);
- return requireHostDirectoryActions(deps).registerDirectory(
+ const name = (input as { name?: unknown }).name;
+ if (name !== undefined && (typeof name !== 'string' || !name.trim() || name.length > 200 || /[\u0000-\u001f\u007f]/u.test(name))) {
+ throw new TypeError('Invalid project name.');
+ }
+ const registered = await requireHostDirectoryActions(deps).registerDirectory(
directory.rootId,
directory.segments,
);
+ return typeof name === 'string' ? deps.catalog.rename(registered.id, name.trim()) : registered;
},
async pathFor(projectId) {
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index 194998bac7..40df620968 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -1477,7 +1477,8 @@ function registerHostClientIpc(
chooseDirectory: async () => {
const result = await mainWindowController.showOpenDialog({
title: projectPickerTitle(await desktopLocale.resolve()),
- properties: ["openDirectory"],
+ // Enable the native New Folder action on macOS.
+ properties: ["openDirectory", "createDirectory"],
});
return result.canceled ? undefined : result.filePaths[0];
},
diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts
index 36239bf68e..e6cbbe171a 100644
--- a/apps/desktop/src/main/runtime-host-client.ts
+++ b/apps/desktop/src/main/runtime-host-client.ts
@@ -138,6 +138,7 @@ import {
type TurnMessageSubmitInput,
type TurnMessageSubmitResult,
type WorkspaceProjection,
+ type WorkspaceTarget,
} from "@maka/runtime-host/protocol";
const decodeStoredMessage = (value: unknown): StoredMessage =>
@@ -986,6 +987,24 @@ export class DesktopRuntimeHostClient {
);
}
+ /**
+ * Do not retry: the target cwd belongs to the supplied revision.
+ * Replaying it against a newer revision could undo a concurrent move.
+ */
+ async relocateSessionWorkspace(
+ sessionId: string,
+ expectedRevision: number,
+ workspace: WorkspaceTarget,
+ ): Promise<SessionCatalogProjection> {
+ const result = await this.request("session.workspace.relocate", {
+ sessionId,
+ expectedRevision,
+ workspace,
+ });
+ if (result.kind === "committed") return result.session;
+ throw revisionConflict("relocate", sessionId);
+ }
+
async setSessionReadMarker(
sessionId: string,
readThroughMessageId: string,
diff --git a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts
index 93b5c53383..9788d3195b 100644
--- a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts
@@ -60,6 +60,7 @@ type RuntimeHostSessionCatalogClient = Pick<
| 'listSessions'
| 'getSession'
| 'previewSessionRemoval'
+ | 'relocateSessionWorkspace'
| 'removeSession'
| 'setSessionLifecycle'
| 'updateSessionConfiguration'
@@ -258,6 +259,51 @@ export function registerRuntimeHostSessionCatalogIpc(
// Read-only: how many subtasks the delete would archive, for the confirm.
return deps.client.previewSessionRemoval(sessionId);
});
+ ipcMain.handle(
+ 'sessions:moveToProject',
+ async (_event, sessionId: string, projectId: unknown) => {
+ if (projectId !== null && (typeof projectId !== 'string' || projectId.length === 0)) {
+ throw new Error('Invalid project id');
+ }
+ return moveSessionToProject(deps, sessionId, projectId);
+ },
+ );
+}
+
+/**
+ * Move one Session, not its revision family. Detach preserves the cwd from
+ * the same revision used for admission; a conflict is reported without retry.
+ */
+async function moveSessionToProject(
+ deps: RuntimeHostSessionCatalogIpcDeps,
+ sessionId: string,
+ projectId: string | null,
+): Promise<DesktopSessionUpdateResult<DesktopHostSessionSummary>> {
+ let session: SessionCatalogProjection;
+ try {
+ const current = await deps.client.getSession(sessionId);
+ if (!current) {
+ throw new DesktopRuntimeHostClientError(
+ 'session_not_found',
+ `No such Session: ${sessionId}`,
+ );
+ }
+ const workspace: WorkspaceTarget =
+ projectId === null
+ ? { kind: 'host_path', path: current.workspace.hostCwd }
+ : { kind: 'project', projectId };
+ session = await deps.client.relocateSessionWorkspace(
+ sessionId,
+ current.revision,
+ workspace,
+ );
+ } catch (error) {
+ const code = updateFailureCode(error);
+ if (code) return { ok: false, code };
+ throw error;
+ }
+ deps.emitSessionsChanged('updated', sessionId);
+ return { ok: true, session: toDesktopHostSessionSummary(session) };
}
/**
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index e32b9ae613..d9156c9d5f 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -974,7 +974,7 @@ export interface MakaBridge {
searchExecutors(host: DesktopNewTaskHostRef, query: { query: string }): Promise<import('@maka-agent/plugin-sdk/host').ExecutorChoices>;
getCatalog(): Promise<DesktopNewTaskCatalog>;
subscribeChanges(handler: () => void): () => void;
- addProject(host: DesktopNewTaskHostRef): Promise<
+ addProject(host: DesktopNewTaskHostRef, name?: string): Promise<
{ ok: true; project: ProjectRecord } | { ok: false; reason: 'cancelled' }
>;
relinkProject(host: DesktopNewTaskHostRef, projectId: string): Promise<
@@ -1280,6 +1280,8 @@ export interface MakaBridge {
setFlagged(sessionId: string, isFlagged: boolean, options?: { revisionFamily?: boolean }): Promise<void>;
rename(sessionId: string, name: string, options?: { revisionFamily?: boolean }): Promise<void>;
setSandboxMode(sessionId: string, mode: SandboxMode): Promise<DesktopSessionUpdateResult<DesktopSessionSummary>>;
+ /** Move one idle Session; null clears its project without changing its cwd. */
+ moveToProject(sessionId: string, projectId: string | null): Promise<DesktopSessionUpdateResult<DesktopSessionSummary>>;
setApprovalPolicy(sessionId: string, policy: import('@maka/core/execution-permissions').ApprovalPolicy): Promise<DesktopSessionUpdateResult<DesktopSessionSummary>>;
setExecutionPolicy(sessionId: string, policy: import('@maka/core/execution-permissions').ExecutionPolicy): Promise<DesktopSessionUpdateResult<DesktopSessionSummary>>;
/**
@@ -1389,7 +1391,13 @@ export interface MakaBridge {
subscribeChanges(handler: () => void, sessionId?: string, host?: DesktopRuntimeHostRef): () => void;
getLocalSnapshot(): Promise<DesktopProjectSnapshot>;
subscribeLocalChanges(handler: () => void): () => void;
- add(host?: DesktopRuntimeHostRef): Promise<
+ /**
+ * Register a directory the user picks as a project, optionally naming it in
+ * the same step. The folder picker still decides the directory; `name` is
+ * what the New project dialog collected, applied before the call returns so
+ * the caller never sees the folder-derived placeholder.
+ */
+ add(host?: DesktopRuntimeHostRef, options?: { readonly name?: string }): Promise<
{ ok: true; project: ProjectRecord; path: string } | { ok: false; reason: 'cancelled' }
>;
getDirectoryRoots(host: DesktopRuntimeHostRef): Promise<readonly DesktopProjectDirectoryRoot[]>;
@@ -1398,7 +1406,7 @@ export interface MakaBridge {
host: DesktopRuntimeHostRef,
): Promise<readonly DesktopProjectDirectoryEntry[]>;
registerDirectory(
- input: { readonly rootId: string; readonly segments: readonly string[] },
+ input: { readonly rootId: string; readonly segments: readonly string[]; readonly name?: string },
host: DesktopRuntimeHostRef,
): Promise<ProjectRecord>;
select(
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index c74e969332..0a0e50caf0 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -1790,11 +1790,11 @@ const makaBridge = {
for (const unsubscribe of unsubscribes) unsubscribe();
};
},
- async addProject(host: DesktopNewTaskHostRef) {
+ async addProject(host: DesktopNewTaskHostRef, name?: string) {
const result = await invokeWhenReady(
'projects:add',
await runtimeHostScope(host),
- { select: false },
+ { select: false, name },
) as
| { ok: true; project: ProjectRecord; path: string }
| { ok: false; reason: 'cancelled' };
@@ -2339,6 +2339,9 @@ const makaBridge = {
setSandboxMode(sessionId: string, mode: SandboxMode): Promise<DesktopSessionUpdateResult<DesktopSessionSummary>> {
return invokeSessionUpdate('sessions:setSandboxMode', sessionId, mode);
},
+ moveToProject(sessionId: string, projectId: string | null): Promise<DesktopSessionUpdateResult<DesktopSessionSummary>> {
+ return invokeSessionUpdate('sessions:moveToProject', sessionId, projectId);
+ },
setApprovalPolicy(sessionId: string, policy: import('@maka/core/execution-permissions').ApprovalPolicy): Promise<DesktopSessionUpdateResult<DesktopSessionSummary>> {
return invokeSessionUpdate('sessions:setApprovalPolicy', sessionId, policy);
},
@@ -2714,10 +2717,10 @@ const makaBridge = {
if (runtimeHostMetadataFor(scope)?.profileKind === 'local') handler();
});
},
- add(host?: DesktopRuntimeHostRef): Promise<
+ add(host?: DesktopRuntimeHostRef, options?: { name?: string }): Promise<
{ ok: true; project: ProjectRecord; path: string } | { ok: false; reason: 'cancelled' }
> {
- return invokeSelectedRuntimeHost(host, 'projects:add');
+ return invokeSelectedRuntimeHost(host, 'projects:add', options);
},
getDirectoryRoots(host: DesktopRuntimeHostRef) {
return invokeSelectedRuntimeHost(host, 'projects:directoryRoots');
@@ -2729,7 +2732,7 @@ const makaBridge = {
return invokeSelectedRuntimeHost(host, 'projects:listDirectory', input);
},
registerDirectory(
- input: { readonly rootId: string; readonly segments: readonly string[] },
+ input: { readonly rootId: string; readonly segments: readonly string[]; readonly name?: string },
host: DesktopRuntimeHostRef,
) {
return invokeSelectedRuntimeHost(host, 'projects:registerDirectory', input);
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index 5c0dd1f5e7..8bb61623f8 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -2273,6 +2273,7 @@ function AppShellContent({
onSelect: openWorkHub,
} : undefined}
projectActions={projectRowActions}
+ onNewProject={taskEntry.selectors.canAddProject ? taskEntry.commands.openNewProject : undefined}
>
{SESSION_RAIL}
</SessionNavigationProvider>
@@ -2622,10 +2623,7 @@ function AppShellContent({
}
showOnboardingHero={showOnboardingHero}
onboardingState={onboardingState}
- onOpenSettings={(section) => {
- if (section) openSettingsSection(section);
- else openSettings();
- }}
+ onOpenSettings={openSettingsSection}
onOpenConnectionDetail={openConnectionDetail}
onAddProvider={openProviderCreate}
onBrowseProviders={openProviderCatalog}
diff --git a/apps/desktop/src/renderer/features/overlays/controller/use-overlays-controller.ts b/apps/desktop/src/renderer/features/overlays/controller/use-overlays-controller.ts
index 6770e66927..0a19c2fcc8 100644
--- a/apps/desktop/src/renderer/features/overlays/controller/use-overlays-controller.ts
+++ b/apps/desktop/src/renderer/features/overlays/controller/use-overlays-controller.ts
@@ -91,7 +91,8 @@ export function useOverlaysController(): OverlaysController {
cancelSearchThread: (requestId) => services.search.cancelThread(requestId),
setSearchScrollTarget,
openSettings: () => openSettingsWith({ kind: 'settings' }),
- openSettingsSection: (section) => openSettingsWith({ kind: 'section', section }),
+ openSettingsSection: (section) =>
+ openSettingsWith(section ? { kind: 'section', section } : { kind: 'settings' }),
openProjectSettings: (profileId) => openSettingsWith({ kind: 'project', profileId }),
openProviderCatalog: () => openSettingsWith({ kind: 'provider-catalog' }),
openConnectionDetail: (slug) => openSettingsWith({ kind: 'connection-detail', slug }),
diff --git a/apps/desktop/src/renderer/features/overlays/model/overlays-projection.ts b/apps/desktop/src/renderer/features/overlays/model/overlays-projection.ts
index 8ef508d712..57d12f575f 100644
--- a/apps/desktop/src/renderer/features/overlays/model/overlays-projection.ts
+++ b/apps/desktop/src/renderer/features/overlays/model/overlays-projection.ts
@@ -35,7 +35,7 @@ export interface OverlaysCommands {
cancelSearchThread(requestId: string): Promise<void>;
setSearchScrollTarget(target: SearchScrollTarget | null): void;
openSettings(): void;
- openSettingsSection(section: SettingsSection): void;
+ openSettingsSection(section?: SettingsSection): void;
openProjectSettings(profileId: string): void;
openProviderCatalog(): void;
openConnectionDetail(slug: string): void;
diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts b/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts
index e0c4ca4f8c..cdbefc32c4 100644
--- a/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts
+++ b/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts
@@ -79,6 +79,11 @@ export interface SessionNavigationRowActions {
archiveSession(sessionId: string): Promise<void>;
unarchiveSession(sessionId: string): Promise<void>;
renameSession(sessionId: string, name: string): Promise<void>;
+ /**
+ * Re-files a task under another project, or out of every project (`null`).
+ * Not a revision-family action: one working directory moves.
+ */
+ moveSessionToProject(sessionId: string, projectId: string | null): Promise<void>;
deleteSession(sessionId: string): Promise<void>;
purgeSessions(sessionIds: readonly string[]): Promise<SessionPurgeOutcome>;
/** Sweeps and reports — the rail's own wording. */
@@ -109,7 +114,7 @@ export function createSessionNavigationRowActions(deps: {
async function runSessionRowAction(
sessionId: string,
- actionId: 'flag' | 'archive' | 'rename' | 'delete',
+ actionId: 'flag' | 'archive' | 'rename' | 'delete' | 'move',
errorTitle: string,
action: () => Promise<void>,
): Promise<void> {
@@ -460,11 +465,32 @@ export function createSessionNavigationRowActions(deps: {
);
}
+ /**
+ * Re-file one task into another project, or out of every project.
+ *
+ * The refusals worth explaining — a running Turn, a project that is gone —
+ * arrive as an outcome rather than a thrown error, so the toast can name the
+ * reason instead of falling back to the generic line.
+ */
+ async function moveSessionToProject(sessionId: string, projectId: string | null) {
+ return runSessionRowAction(sessionId, 'move', copy.moveFailedTitle, async () => {
+ const outcome = await service.moveToProject(sessionId, projectId);
+ if (!outcome.ok) {
+ toastApi.error(copy.moveFailedTitle, copy.moveFailures[outcome.code], undefined, {
+ sessionId,
+ });
+ return;
+ }
+ await refreshSessions();
+ });
+ }
+
return {
flagSession,
archiveSession,
unarchiveSession,
renameSession,
+ moveSessionToProject,
deleteSession,
purgeSessions,
archiveSelected,
diff --git a/apps/desktop/src/renderer/features/session-navigation/model/session-navigation-groups.ts b/apps/desktop/src/renderer/features/session-navigation/model/session-navigation-groups.ts
index 139526a858..9cb6740313 100644
--- a/apps/desktop/src/renderer/features/session-navigation/model/session-navigation-groups.ts
+++ b/apps/desktop/src/renderer/features/session-navigation/model/session-navigation-groups.ts
@@ -28,6 +28,16 @@ import { runtimeHostProjectKey } from '../../../application/contracts/runtime-ho
const UNGROUPED_KEY = '__ungrouped__';
+/** The rail row a Project scope is drawn as. */
+export function projectGroupId(scopeKey: string): string {
+ return `project:${scopeKey}`;
+}
+
+/** The rail row a Host's project-less Sessions are drawn as. */
+export function ungroupedGroupId(hostId: string): string {
+ return `${UNGROUPED_KEY}:${hostId}`;
+}
+
function scopedProjectLabel(projectName: string, profileName: string): string {
return `${projectName} · ${profileName}`;
}
@@ -71,7 +81,7 @@ export function deriveSessionNavigationGroups(
const groups = projectScopes.map((scope): SessionHistoryGroup => {
const key = scope.key;
return {
- id: `project:${key}`,
+ id: projectGroupId(key),
label: scopedProjectLabel(scope.project.name, scope.profileName),
sessions: sessionsByProject.get(key) ?? [],
// The UI treats this id as an opaque action target. Scope it here so
@@ -89,7 +99,7 @@ export function deriveSessionNavigationGroups(
.split(/[/\\]/)
.at(-1);
groups.push({
- id: `project:${key}`,
+ id: projectGroupId(key),
label: scopedProjectLabel(
pathName || first.projectId || 'Project',
first.profileName,
@@ -101,7 +111,7 @@ export function deriveSessionNavigationGroups(
for (const [hostId, ungrouped] of ungroupedByHost) {
const profileName = ungrouped[0]!.profileName;
groups.push({
- id: `${UNGROUPED_KEY}:${hostId}`,
+ id: ungroupedGroupId(hostId),
label: scopedProjectLabel(
getShellRemainingCopy(locale).projects.ungrouped,
profileName,
diff --git a/apps/desktop/src/renderer/features/session-navigation/ports.ts b/apps/desktop/src/renderer/features/session-navigation/ports.ts
index 2883877b0d..efd0f06168 100644
--- a/apps/desktop/src/renderer/features/session-navigation/ports.ts
+++ b/apps/desktop/src/renderer/features/session-navigation/ports.ts
@@ -21,6 +21,7 @@ import type { RefObject } from 'react';
import type { SessionSummary } from '@maka/core/session';
import type { ProjectRecord } from '@maka/core/project';
import type { RuntimeHostProfileKind } from '@maka/runtime-host/profile-kind';
+import type { DesktopSessionUpdateFailureCode } from '../../../shared/desktop-session-projection.js';
export type SessionNavigationRemoveDisposition = 'removed' | 'restored';
@@ -105,8 +106,14 @@ export interface SessionNavigationSessionService {
* estimating from the catalog projection.
*/
previewRemoval(sessionId: string): Promise<number>;
+ /** Move to a project, or detach (`null`); expected refusals retain their Host code. */
+ moveToProject(sessionId: string, projectId: string | null): Promise<SessionMoveOutcome>;
}
+export type SessionMoveOutcome =
+ | { readonly ok: true }
+ | { readonly ok: false; readonly code: DesktopSessionUpdateFailureCode };
+
export interface SessionNavigationServices {
readonly sessions: SessionNavigationSessionService;
}
diff --git a/apps/desktop/src/renderer/features/session-navigation/testing.ts b/apps/desktop/src/renderer/features/session-navigation/testing.ts
index 58b1695602..31e02b423f 100644
--- a/apps/desktop/src/renderer/features/session-navigation/testing.ts
+++ b/apps/desktop/src/renderer/features/session-navigation/testing.ts
@@ -69,6 +69,7 @@ export function createFakeSessionNavigationServices(
rename: async () => undefined,
remove: async () => ({ disposition: 'removed', archivedSubtaskCount: 0 }),
previewRemoval: async () => 0,
+ moveToProject: async () => ({ ok: true }),
},
...overrides,
};
diff --git a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx
index af800785c9..021b2174dc 100644
--- a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx
+++ b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx
@@ -18,6 +18,7 @@
*/
import {
+ useCallback,
useLayoutEffect,
useMemo,
useRef,
@@ -30,6 +31,7 @@ import {
type NavSelection,
type ProjectRowActions,
type SessionRailChrome,
+ type SessionMoveTarget,
type SessionRailData,
type SessionRowActions,
} from '@maka/ui';
@@ -45,6 +47,7 @@ import { selectSessions, useSessionCatalogController } from '../../../applicatio
import { useExternalStoreSelector } from '../../../application/contracts/session-catalog/use-external-store-selector.js';
import { deriveStaleSessionIds } from '../../../application/contracts/session-catalog/stale-sessions.js';
import { sessionRailLayoutStore } from '../model/session-rail-layout-store.js';
+import { projectGroupId, ungroupedGroupId } from '../model/session-navigation-groups.js';
import type {
SessionNavigationPorts,
SessionNavigationProjectScope,
@@ -64,6 +67,11 @@ export interface SessionNavigationChromeInput {
onNew(): void;
onExitWorkHub(): void;
onSelectSession(sessionId: string): void;
+ /**
+ * Create a project from the rail's +. Absent when no host can make one, and
+ * the heading then carries no + at all.
+ */
+ onNewProject?: () => void;
}
export interface SessionNavigationProviderProps extends SessionNavigationChromeInput {
@@ -126,6 +134,9 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps)
onRename: (sessionId, name) => {
void controller.commands.renameSession(sessionId, name);
},
+ onMoveToProject: (sessionId, projectId) => {
+ void controller.commands.moveSessionToProject(sessionId, projectId);
+ },
// No `onDelete`: the rail cannot delete. `deleteSession` is still a
// command, reached from Settings › 已归档任务, where the task has already
// been archived once.
@@ -133,6 +144,53 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps)
[controller.commands],
);
+ // The rail draws a row for every Host's Projects, so a task may only be moved
+ // among its own Host's — and only into a project that can receive one. Both
+ // answers come from the same scopes, so the rows that carry the drop marker and
+ // the destinations a task is offered cannot disagree.
+ const moveDropGroupKeys = useMemo(() => {
+ const keys = new Set<string>();
+ for (const scope of props.projectScopes) {
+ if (scope.project.available && scope.project.archivedAt === undefined) {
+ keys.add(projectGroupId(scope.key));
+ }
+ }
+ for (const session of rail.sessions) {
+ if (!session.projectId) keys.add(ungroupedGroupId(session.runtimeHostId));
+ }
+ return keys;
+ }, [props.projectScopes, rail.sessions]);
+
+ const moveTargetsBySession = useMemo(() => new Map<string, readonly SessionMoveTarget[]>(rail.sessions.map((session) => {
+ if (session.shared || session.isArchived || controller.selectors.worktreeSessionIds.has(session.id)) {
+ return [session.id, []] as const;
+ }
+ const targets: SessionMoveTarget[] = props.projectScopes
+ .filter(
+ (scope) =>
+ scope.hostId === session.runtimeHostId &&
+ scope.project.id !== session.projectId &&
+ scope.project.available &&
+ scope.project.archivedAt === undefined,
+ )
+ .map((scope) => ({
+ groupKey: projectGroupId(scope.key),
+ projectId: scope.project.id,
+ name: scope.project.name,
+ }));
+ if (session.projectId) {
+ // The one row that means "leave every project". Its name is the rail's
+ // to say, so none is given here.
+ targets.push({
+ groupKey: ungroupedGroupId(session.runtimeHostId),
+ projectId: null,
+ });
+ }
+ return [session.id, targets] as const;
+ })), [props.projectScopes, rail.sessions, controller.selectors.worktreeSessionIds]);
+ const moveTargets = useCallback((sessionId: string): readonly SessionMoveTarget[] =>
+ moveTargetsBySession.get(sessionId) ?? [], [moveTargetsBySession]);
+
// Project row mutations are commands too, and they arrive from a different
// feature entirely. Read through a ref for the same reason as the ports: what
// the rail needs is that they can be CALLED, and their identity says nothing
@@ -197,6 +255,9 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps)
rowActions,
projectActions,
relinkableProjectIds,
+ moveDropGroupKeys,
+ moveTargets,
+ onNewProject: props.onNewProject,
}),
[
controller.layout.viewMode,
@@ -205,6 +266,9 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps)
controller.selectors.sessionProjectName,
controller.selectors.worktreeSessionIds,
props.onSelectSession,
+ props.onNewProject,
+ moveDropGroupKeys,
+ moveTargets,
projectActions,
relinkableProjectIds,
rail,
diff --git a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts
index 81a2d77eb1..508ad106b1 100644
--- a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts
+++ b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts
@@ -90,6 +90,7 @@ export interface TaskEntryControllerCommands {
restoreProject(projectKey: string): Promise<void>;
relinkProject(projectKey: string): Promise<void>;
addProject(): void;
+ openNewProject(): void;
chooseProjectForProfile(profileId: string): Promise<void>;
resolveWorkBoardTarget(item: WorkBoardItem): WorkBoardStartTargetResult;
prepareWorkBoardDraft(target: TaskEntryTarget, draft: string): string | undefined;
@@ -108,13 +109,19 @@ const EMPTY_CATALOG: TaskEntryCatalog = {
type DirectoryHandoff = TaskEntryHostRef & {
readonly name: string;
+ /** The name typed in the New project dialog, applied once a folder is picked. */
+ readonly projectName?: string;
};
-function directoryHandoffForHost(host: ReadyTaskEntryHost): DirectoryHandoff {
+function directoryHandoffForHost(
+ host: ReadyTaskEntryHost,
+ projectName?: string,
+): DirectoryHandoff {
return {
profileId: host.profile.id,
hostId: host.hostId,
name: host.profile.name,
+ ...(projectName ? { projectName } : {}),
};
}
@@ -154,6 +161,7 @@ export function useTaskEntryController(
const [refreshing, setRefreshing] = useState(true);
const [error, setError] = useState<string>();
const [directoryHost, setDirectoryHost] = useState<DirectoryHandoff>();
+ const [newProjectHost, setNewProjectHost] = useState<TaskEntryHostRef>();
const directoryOpenerRef = useRef<HTMLElement | null>(null);
const committedCatalogRef = useRef<TaskEntryCatalog>(EMPTY_CATALOG);
const refreshRequestSequenceRef = useRef(0);
@@ -283,12 +291,12 @@ export function useTaskEntryController(
}
}, [copy.projectUpdateFailedFallback, copy.projectUpdateFailedTitle, locale, refresh, reportError]);
- const addProjectForHost = useCallback(async (host: ReadyTaskEntryHost): Promise<void> => {
+ const addProjectForHost = useCallback(async (host: ReadyTaskEntryHost, name?: string): Promise<void> => {
if (projectMutationPendingRef.current) return;
if (host.capabilities.chooseHostDirectory) {
directoryOpenerRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
- setDirectoryHost(directoryHandoffForHost(host));
+ setDirectoryHost(directoryHandoffForHost(host, name));
return;
}
if (!host.capabilities.chooseClientDirectory) return;
@@ -297,10 +305,13 @@ export function useTaskEntryController(
try {
let result: TaskEntryProjectMutationResult;
try {
- result = await service.addProject({
- profileId: host.profile.id,
- hostId: host.hostId,
- });
+ result = await service.addProject(
+ {
+ profileId: host.profile.id,
+ hostId: host.hostId,
+ },
+ name,
+ );
} catch (cause) {
reportError({
title: copy.selectDirectoryFailedTitle,
@@ -425,7 +436,7 @@ export function useTaskEntryController(
: {}),
branch: selectedProjectId === null ? null : selectedBranch,
pending: pending || (refreshing && catalog.hosts.length === 0),
- selectedGroupId: selectedProfileId,
+ selectedGroupId: selectedHost ? JSON.stringify([selectedHost.profile.id, selectedHost.hostId]) : undefined,
groups: catalog.hosts.map((host) => {
if (!isReadyTaskEntryHost(host)) {
return {
@@ -442,13 +453,13 @@ export function useTaskEntryController(
? selectedProjectId
: host.selectedProjectId;
return {
- id: host.profile.id,
+ id: JSON.stringify([host.profile.id, host.hostId]),
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) }
+ ? { onAdd: (name: string) => void addProjectForHost(host, name) }
: {}),
...(host.capabilities.chooseClientDirectory
? { onRelink: (projectId: string) => void relinkProject(host, projectId) }
@@ -597,6 +608,21 @@ export function useTaskEntryController(
: undefined,
[selectedHost],
);
+
+ const openNewProject = useCallback(() => {
+ if (selectedHost) setNewProjectHost({ profileId: selectedHost.profile.id, hostId: selectedHost.hostId });
+ }, [selectedHost]);
+ const newProjectDialog = useMemo(() => {
+ if (!newProjectHost) return undefined;
+ const target = catalog.hosts.find((host): host is ReadyTaskEntryHost =>
+ isReadyTaskEntryHost(host) && host.profile.id === newProjectHost.profileId && host.hostId === newProjectHost.hostId);
+ const available = target && (target.capabilities.chooseClientDirectory || target.capabilities.chooseHostDirectory);
+ return {
+ disabled: !available,
+ close: () => setNewProjectHost(undefined),
+ submit: (name: string) => { if (available) void addProjectForHost(target, name); },
+ };
+ }, [newProjectHost, catalog.hosts, addProjectForHost]);
return useMemo(() => ({
host: {
...(directoryHost
@@ -605,9 +631,11 @@ export function useTaskEntryController(
profileId: directoryHost.profileId,
hostId: directoryHost.hostId,
name: directoryHost.name,
+ ...(directoryHost.projectName ? { projectName: directoryHost.projectName } : {}),
},
}
: {}),
+ newProjectDialog,
directoryOpener: directoryOpenerRef.current,
closeDirectoryPicker,
acceptRegisteredProject,
@@ -648,6 +676,7 @@ export function useTaskEntryController(
if (host) await relinkProject(host, scope.project.id);
},
addProject: addSelectedProject,
+ openNewProject,
chooseProjectForProfile,
resolveWorkBoardTarget,
prepareWorkBoardDraft,
@@ -672,6 +701,8 @@ export function useTaskEntryController(
}), [
acceptRegisteredProject,
addSelectedProject,
+ newProjectDialog,
+ openNewProject,
catalog.defaultProfileId,
catalog.hosts.length,
chooseProjectForProfile,
diff --git a/apps/desktop/src/renderer/features/task-entry/ports.ts b/apps/desktop/src/renderer/features/task-entry/ports.ts
index 8e73826aa9..c54ff314eb 100644
--- a/apps/desktop/src/renderer/features/task-entry/ports.ts
+++ b/apps/desktop/src/renderer/features/task-entry/ports.ts
@@ -103,7 +103,7 @@ export type TaskEntryProjectMutationResult =
export interface TaskEntryCatalogService {
getCatalog(): Promise<TaskEntryCatalog>;
subscribeChanges(handler: () => void): TaskEntryUnsubscribe;
- addProject(host: TaskEntryHostRef): Promise<TaskEntryProjectMutationResult>;
+ addProject(host: TaskEntryHostRef, name?: string): Promise<TaskEntryProjectMutationResult>;
relinkProject(
host: TaskEntryHostRef,
projectId: string,
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
index 60275c8fab..2c020bd4aa 100644
--- 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
@@ -17,13 +17,15 @@
* under the License.
*/
+import { NewProjectDialog } from '@maka/ui';
import type { ProjectRecord } from '@maka/core/project';
import { RemoteProjectDirectoryDialog } from '../../../remote-project-directory-dialog.js';
import type { TaskEntryHostRef } from '../ports.js';
import { useTaskEntryHostModel } from './task-entry-provider.js';
export interface TaskEntryHostModel {
- directoryHost?: TaskEntryHostRef & { readonly name?: string };
+ newProjectDialog?: { disabled: boolean; close(): void; submit(name: string): void };
+ directoryHost?: TaskEntryHostRef & { readonly name?: string; readonly projectName?: string };
directoryOpener?: HTMLElement | null;
closeDirectoryPicker(): void;
acceptRegisteredProject(
@@ -38,6 +40,7 @@ export function TaskEntryHost() {
export function TaskEntryHostView({ model }: { model: TaskEntryHostModel }) {
return (
+ <>
<RemoteProjectDirectoryDialog
host={model.directoryHost}
returnFocusTo={model.directoryOpener}
@@ -46,5 +49,13 @@ export function TaskEntryHostView({ model }: { model: TaskEntryHostModel }) {
void model.acceptRegisteredProject(project, host);
}}
/>
+ {model.newProjectDialog ? (
+ <NewProjectDialog
+ isDisabled={model.newProjectDialog.disabled}
+ onOpenChange={(open) => { if (!open) model.newProjectDialog?.close(); }}
+ onSubmit={model.newProjectDialog.submit}
+ />
+ ) : null}
+ </>
);
}
diff --git a/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx b/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx
index 3115f42aa7..c1d15b932e 100644
--- a/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx
+++ b/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx
@@ -72,6 +72,7 @@ const EMPTY_CONTROLLER: TaskEntryController = {
async restoreProject() {},
async relinkProject() {},
addProject() {},
+ openNewProject() {},
async chooseProjectForProfile() {},
resolveWorkBoardTarget: (
_item: Parameters<TaskEntryControllerCommands['resolveWorkBoardTarget']>[0],
@@ -123,6 +124,7 @@ function createTaskEntryOwner(): TaskEntryOwner & {
relinkProject: (projectKey: string) =>
current.commands.relinkProject(projectKey),
addProject: () => current.commands.addProject(),
+ openNewProject: () => current.commands.openNewProject(),
chooseProjectForProfile: (profileId: string) =>
current.commands.chooseProjectForProfile(profileId),
resolveWorkBoardTarget: (
@@ -230,9 +232,11 @@ const selectHost = (controller: TaskEntryController): TaskEntryHostModel => cont
function sameHost(previous: TaskEntryHostModel, next: TaskEntryHostModel): boolean {
return (
+ previous.newProjectDialog === next.newProjectDialog &&
previous.directoryHost?.profileId === next.directoryHost?.profileId &&
previous.directoryHost?.hostId === next.directoryHost?.hostId &&
previous.directoryHost?.name === next.directoryHost?.name &&
+ previous.directoryHost?.projectName === next.directoryHost?.projectName &&
previous.directoryOpener === next.directoryOpener &&
previous.closeDirectoryPicker === next.closeDirectoryPicker &&
previous.acceptRegisteredProject === next.acceptRegisteredProject
diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts
index 78ee694394..1de08f3fe8 100644
--- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts
@@ -640,7 +640,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
// that only mentions the default would leave the user guessing what
// happens before they set one.
sectionHelp: '新任务默认打开此项目;未设置时沿用上次使用的项目。任何任务都能在输入框旁临时切换。',
- addProject: '添加项目',
+ addProject: '新建项目',
defaultBadge: '默认',
setDefault: '设为默认',
setDefaultTitle: '新任务默认打开这个项目',
@@ -1334,7 +1334,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
section: 'Workspace',
sectionHelp:
'New tasks open in the default project; without one, they reuse the project you last used. You can switch any task to a different project next to the input box.',
- addProject: 'Add project',
+ addProject: 'New project',
defaultBadge: 'Default',
setDefault: 'Set as default',
setDefaultTitle: 'Open new tasks in this project',
diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts
index 023946cf19..840af5c44e 100644
--- a/apps/desktop/src/renderer/locales/shell-copy.ts
+++ b/apps/desktop/src/renderer/locales/shell-copy.ts
@@ -266,6 +266,13 @@ type ShellCopy = {
unarchiveFailedTitle: string;
renameFailedTitle: string;
deleteFailedTitle: string;
+ /** Toast title when a task cannot be re-filed into another project. */
+ moveFailedTitle: string;
+ /** Why a re-file was refused, keyed by the Host's failure code. */
+ moveFailures: Record<
+ 'session_busy' | 'operation_conflict' | 'operation_unavailable' | 'not_found',
+ string
+ >;
currentConversation: string;
deleteTitle(name: string): string;
deleteDescription: string;
@@ -876,6 +883,13 @@ const SHELL_COPY_BY_LOCALE = {
unarchiveFailedTitle: '恢复任务失败',
renameFailedTitle: '重命名任务失败',
deleteFailedTitle: '删除任务失败',
+ moveFailedTitle: '移动任务失败',
+ moveFailures: {
+ session_busy: '任务正在运行,结束后再移动。',
+ operation_conflict: '该项目当前不可用,无法移入。',
+ operation_unavailable: '当前无法移动这个任务。',
+ not_found: '找不到该项目或任务。',
+ },
currentConversation: '当前任务',
deleteTitle: (name: string) => `删除 "${name}"`,
deleteDescription: '任务和全部消息会从磁盘上永久移除。该操作不可撤销。',
@@ -1379,6 +1393,13 @@ const SHELL_COPY_BY_LOCALE = {
unarchiveFailedTitle: '恢復任務失敗',
renameFailedTitle: '重新命名任務失敗',
deleteFailedTitle: '刪除任務失敗',
+ moveFailedTitle: '移動任務失敗',
+ moveFailures: {
+ session_busy: '任務正在執行,結束後再移動。',
+ operation_conflict: '該專案目前無法使用,無法移入。',
+ operation_unavailable: '目前無法移動這個任務。',
+ not_found: '找不到該專案或任務。',
+ },
currentConversation: '目前任務',
deleteTitle: (name: string) => `刪除 "${name}"`,
deleteDescription: '任務和全部訊息會從磁碟上永久移除。該操作不可撤銷。',
@@ -1884,6 +1905,13 @@ const SHELL_COPY_BY_LOCALE = {
unarchiveFailedTitle: 'Could not restore task',
renameFailedTitle: 'Could not rename task',
deleteFailedTitle: 'Could not delete task',
+ moveFailedTitle: 'Could not move task',
+ moveFailures: {
+ session_busy: 'A task is running. Wait for it to finish before moving this one.',
+ operation_conflict: 'That project is unavailable right now, so the task cannot move into it.',
+ operation_unavailable: 'This task cannot be moved right now.',
+ not_found: 'That project or task could not be found.',
+ },
currentConversation: 'Current task',
deleteTitle: (name: string) => `Delete "${name}"`,
deleteDescription:
diff --git a/apps/desktop/src/renderer/remote-project-directory-dialog.tsx b/apps/desktop/src/renderer/remote-project-directory-dialog.tsx
index 2209482e0f..ca167556bc 100644
--- a/apps/desktop/src/renderer/remote-project-directory-dialog.tsx
+++ b/apps/desktop/src/renderer/remote-project-directory-dialog.tsx
@@ -35,7 +35,7 @@ import type {
} from '../preload/bridge-contract.js';
import { getShellCopy } from './locales/shell-copy.js';
-type DirectoryHost = DesktopRuntimeHostRef & { readonly name?: string };
+type DirectoryHost = DesktopRuntimeHostRef & { readonly name?: string; readonly projectName?: string };
type DirectoryLoad =
| { readonly kind: 'initial'; readonly host: DirectoryHost }
| {
@@ -165,6 +165,7 @@ export function RemoteProjectDirectoryDialog(props: {
const project = await window.maka.projects.registerDirectory({
rootId: root.id,
segments,
+ name: host.projectName,
}, host);
if (request.current !== sequence) return;
props.onRegistered(project, host);
diff --git a/apps/desktop/src/renderer/settings/projects-settings-page.tsx b/apps/desktop/src/renderer/settings/projects-settings-page.tsx
index aa90925347..66ecc12b69 100644
--- a/apps/desktop/src/renderer/settings/projects-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/projects-settings-page.tsx
@@ -27,6 +27,7 @@ import {
Button,
EmptyState,
MoreMenu,
+ NewProjectDialog,
TextInput,
useMountedRef,
useToast,
@@ -92,7 +93,7 @@ export function ProjectsSettingsPage(props: {
const [homePath, setHomePath] = useState<string | undefined>(undefined);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [draftName, setDraftName] = useState('');
- const [directoryPickerOpen, setDirectoryPickerOpen] = useState(false);
+ const [projectDialog, setProjectDialog] = useState<{ kind: 'new' } | { kind: 'directory'; name: string } | null>(null);
const directoryPickerTriggerRef = useRef<HTMLButtonElement>(null);
const reloadGeneration = useRef(0);
@@ -106,7 +107,19 @@ export function ProjectsSettingsPage(props: {
}
}, [host, mountedRef, props.runtimeHostTargetVerified]);
+ async function addNamedProject(name: string) {
+ if (!host || !props.runtimeHostTargetVerified) return;
+ if (capabilities.chooseHostDirectory) {
+ setProjectDialog({ kind: 'directory', name });
+ return;
+ }
+ await runRowAction('add', async () => {
+ await window.maka.projects.add(host, { name });
+ }, copy.actionFailed);
+ }
+
useEffect(() => {
+ setProjectDialog(null);
if (!host || !props.runtimeHostTargetVerified) {
reloadGeneration.current += 1;
setProjects([]);
@@ -268,15 +281,9 @@ export function ProjectsSettingsPage(props: {
variant="secondary"
size="sm"
label={copy.addProject}
- clickAction={capabilities.chooseHostDirectory
- ? () => {
- if (props.runtimeHostTargetVerified) setDirectoryPickerOpen(true);
- }
- : async () => {
- if (!props.runtimeHostTargetVerified) return;
- const result = await window.maka.projects.add(host);
- if (result.ok) await reload();
- }}
+ clickAction={() => {
+ if (props.runtimeHostTargetVerified) setProjectDialog({ kind: 'new' });
+ }}
/>
) : undefined}
>
@@ -496,14 +503,25 @@ export function ProjectsSettingsPage(props: {
)}
</SettingsSection>
<RemoteProjectDirectoryDialog
- host={directoryPickerOpen && props.runtimeHostTargetVerified ? host : undefined}
+ host={projectDialog?.kind === 'directory' && props.runtimeHostTargetVerified && host
+ ? { ...host, projectName: projectDialog.name } : undefined}
returnFocusTo={directoryPickerTriggerRef.current}
- onClose={() => setDirectoryPickerOpen(false)}
+ onClose={() => setProjectDialog(null)}
onRegistered={() => {
- setDirectoryPickerOpen(false);
+ setProjectDialog(null);
void reload();
}}
/>
+ {projectDialog?.kind === 'new' && props.runtimeHostTargetVerified ? (
+ <NewProjectDialog
+ onOpenChange={(open) => {
+ if (!open) setProjectDialog(null);
+ }}
+ onSubmit={(name) => {
+ void addNamedProject(name);
+ }}
+ />
+ ) : null}
</RuntimeHostInteractionBoundary>
</SettingsPage>
);
diff --git a/apps/desktop/src/renderer/styles/sidebar.css b/apps/desktop/src/renderer/styles/sidebar.css
index 031e11b3ea..635c55a959 100644
--- a/apps/desktop/src/renderer/styles/sidebar.css
+++ b/apps/desktop/src/renderer/styles/sidebar.css
@@ -208,6 +208,19 @@
-webkit-app-region: no-drag;
}
+/*
+ * A project row receiving a dragged task. Drawn on the row's own box rather
+ * than on the nested SideNavItem so the whole target — the heading and any
+ * expanded children — reads as one zone. An inset outline, not a border or
+ * padding: it costs no layout, so nothing under the pointer shifts as the ring
+ * appears.
+ */
+.maka-project-row[data-drop-target="true"] {
+ border-radius: var(--radius-control);
+ outline: 2px solid var(--accent-solid);
+ outline-offset: -2px;
+}
+
/*
* Grouping switch (session-list-panel.tsx): the last row of the sticky top
* region, above the scrolling history it governs.
diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx
index 3f15132c05..83b3414ecc 100644
--- a/apps/desktop/stories/app-shell.stories.tsx
+++ b/apps/desktop/stories/app-shell.stories.tsx
@@ -1386,6 +1386,35 @@ export const NewChatComposerEmptyLocalHost: Story = {
),
};
+const namedProject = fn();
+const namedProjectSend = fn();
+// Real path: 新任务 → 项目选择器 → 新建项目。名称表单必须在 composer form 之外。
+export const NewChatComposerNamedProject: Story = {
+ render: () => <ComposedShell session={null} chat={{ messages: [] }} composer={{
+ onSend: namedProjectSend,
+ workspacePicker: { groups: [{ id: 'local-host', label: 'This device', projects: [], onAdd: namedProject }] },
+ }} />,
+ play: async ({ canvasElement }) => {
+ namedProject.mockClear(); namedProjectSend.mockClear();
+ const page = within(canvasElement.ownerDocument.body);
+ await userEvent.click(page.getByRole('button', { name: /选择项目/ }));
+ await userEvent.click(page.getByRole('menuitem', { name: '新建项目' }));
+ const dialog = await page.findByRole('dialog', { name: '新建项目' });
+ const input = within(dialog).getByRole('textbox', { name: '项目名称' });
+ await waitFor(() => expect(input).toHaveFocus());
+ expect(dialog.parentElement?.closest('form')).toBeNull();
+ expect(within(dialog).getByRole('button', { name: '选择文件夹' })).toBeDisabled();
+ await userEvent.type(input, ' Named project ');
+ await userEvent.keyboard('{Enter}');
+ await waitFor(() => expect(namedProject).toHaveBeenCalledOnce());
+ expect(namedProject).toHaveBeenCalledWith('Named project');
+ expect(namedProjectSend).not.toHaveBeenCalled();
+ await userEvent.click(page.getByRole('button', { name: /选择项目/ }));
+ await userEvent.click(page.getByRole('menuitem', { name: '新建项目' }));
+ await waitFor(() => expect(page.getByRole('textbox', { name: '项目名称' })).toHaveFocus());
+ },
+};
+
// Real path: 新任务 → 切换项目 → 项目 picker 处于 pending(切换中)。
// Production passes `pending: projectPickerPending` while a project switch is
// in flight; the trigger locks with a spinner and every menu row disables,
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index e782d5b92b..afb752b259 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.2` (195 component exports).
Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
-**Totals:** 282 files — blocker 4, reimplementation 0, polish 4, aligned 274.
+**Totals:** 283 files — blocker 4, reimplementation 0, polish 4, aligned 275.
## Exclusions (explicit)
@@ -276,6 +276,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `packages/ui/src/model-wheel-picker.tsx` | ui-composition | Button | aligned — uses Astryx (Button) | aligned |
| `packages/ui/src/module-hub-selector.tsx` | ui-composition | Tab, TabList | aligned — uses Astryx (Tab, TabList) | aligned |
| `packages/ui/src/module-pages.tsx` | ui-composition | EmptyState, Spinner | aligned — uses Astryx (EmptyState, Spinner) | aligned |
+| `packages/ui/src/new-project-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, TextInput | aligned — uses Astryx (Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, TextInput) | aligned |
| `packages/ui/src/permission-mode-menu.tsx` | ui-composition | DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuDivider, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, Selector, SelectorOption | aligned — uses Astryx (DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuDivider, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, Selector, SelectorOption) | aligned |
| `packages/ui/src/permissions-prompt.tsx` | ui-composition | Button | raw `<input` (API Use-the-System); raw `<select` (API Use-the-System) | blocker |
| `packages/ui/src/platform-shortcut-text.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
@@ -291,7 +292,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `packages/ui/src/scheduled-task-panel.tsx` | module-hub | Button, Divider, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuItem, EmptyState, List, ListItem, SegmentedControl, SegmentedControlItem, Selector, StatusDot, Text, TextInput, Toolbar | aligned — uses Astryx (Button, Divider, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuItem, EmptyState, List, ListItem) | aligned |
| `packages/ui/src/search-modal.tsx` | dialog-overlay | CommandPalette, CommandPaletteFooter, CommandPaletteInput | aligned — uses Astryx (CommandPalette, CommandPaletteFooter, CommandPaletteInput) | aligned |
| `packages/ui/src/session-context-layer.tsx` | shell-chrome-or-panel | BreadcrumbItem, Breadcrumbs, ButtonGroup, Icon, IconButton, LayoutHeader, MoreMenu, OverflowList, StatusDot, Text, Token, Tooltip | aligned — uses Astryx (BreadcrumbItem, Breadcrumbs, ButtonGroup, Icon, IconButton, LayoutHeader, MoreMenu, OverflowList) | aligned |
-| `packages/ui/src/session-history-list.tsx` | shell-chrome-or-panel | Badge, MoreMenu, SideNavItem, SideNavSection, StatusDot, VStack | aligned — uses Astryx (Badge, MoreMenu, SideNavItem, SideNavSection, StatusDot, VStack) | aligned |
+| `packages/ui/src/session-history-list.tsx` | shell-chrome-or-panel | Badge, Button, MoreMenu, SideNavItem, SideNavSection, StatusDot, VStack | aligned — uses Astryx (Badge, Button, MoreMenu, SideNavItem, SideNavSection, StatusDot, VStack) | aligned |
| `packages/ui/src/session-list-panel.tsx` | shell-chrome-or-panel | SegmentedControl, SegmentedControlItem, SideNav | aligned — uses Astryx (SegmentedControl, SegmentedControlItem, SideNav) | aligned |
| `packages/ui/src/session-rail-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `packages/ui/src/session-rename-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, TextInput | aligned — uses Astryx (Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, TextInput) | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index b89e835684..4a06f36a3c 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -246,6 +246,7 @@ packages/ui/src/model-picker.tsx
packages/ui/src/model-wheel-picker.tsx
packages/ui/src/module-hub-selector.tsx
packages/ui/src/module-pages.tsx
+packages/ui/src/new-project-dialog.tsx
packages/ui/src/permission-mode-menu.tsx
packages/ui/src/permissions-prompt.tsx
packages/ui/src/platform-shortcut-text.tsx
diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts
index 7115d8ab2d..6a728e09da 100644
--- a/packages/ui/src/conversation-copy.ts
+++ b/packages/ui/src/conversation-copy.ts
@@ -242,7 +242,16 @@ export interface ConversationCopy {
workspace: {
choose: string;
current: string;
- addProject: string;
+ /** The picker's create action; the dialog that follows names the project. */
+ newProject: string;
+ /** Header of the New project dialog. */
+ newProjectTitle: string;
+ /** One line under the header: name first, folder next. */
+ newProjectDescription: string;
+ newProjectUnavailable: string;
+ newProjectNameLabel: string;
+ /** The dialog's submit, which opens the folder picker. */
+ newProjectSubmit: string;
manageProjects: string;
noProject: string;
relink: string;
@@ -388,6 +397,10 @@ export interface ConversationCopy {
archive: string;
unarchive: string;
delete: string;
+ /** Row-menu submenu that re-files one task into another project. */
+ moveToProject: string;
+ /** The submenu's clearing choice: leave the task with no project. */
+ moveToNoProject: string;
pinned: string;
/** Time-sort unpinned section title (SideNavSection). */
recent: string;
@@ -498,7 +511,8 @@ const CONVERSATION_COPY = {
forms: { keyboardHint: '1–9 选择 · ↑↓ 切换 · Enter 确认 · Esc 取消', requester: (name) => `由 ${name} 请求`, requesterWithSource: (name, source) => `由 ${name} 请求 · ${source}`, required: '必填', optional: '选填', include: (label) => `提供:${label}`, enabled: (label) => `启用:${label}`, enterValue: '输入内容', enterNumber: '输入数字', constraintSeparator: ';', lengthConstraint: (minimum, maximum) => minimum === undefined ? `最多 ${maximum} 个字符` : maximum === undefined ? `至少 ${minimum} 个字符` : `长度 ${minimum}–${maxim [...]
mentions: { noFiles: '未找到文件', noSkills: '暂无技能', noCommandsOrSkills: '没有匹配的命令或技能', filesAriaLabel: '工作区文件', skillsAriaLabel: '技能', commandsAndSkillsAriaLabel: '命令和技能', commandsGroup: '命令', skillsGroup: 'Skills', loading: '加载中…' },
workspace: {
- choose: '选择项目', current: '当前项目', addProject: '添加项目', manageProjects: '管理项目', noProject: '无项目', relink: '重新定位', unavailable: '不可用',
+ choose: '选择项目', current: '当前项目', newProject: '新建项目', newProjectTitle: '新建项目', newProjectDescription: '先给项目起个名字,再选择它所在的文件夹。', newProjectNameLabel: '项目名称', newProjectSubmit: '选择文件夹', manageProjects: '管理项目', noProject: '无项目', relink: '重新定位', unavailable: '不可用',
+ newProjectUnavailable: '目标主机暂不可用;恢复后可继续创建。',
chooseTitle: (branch) => branch ? `选择项目 · ${branch}` : '选择项目',
chooseAriaLabel: (label, branch) => branch ? `选择项目:${label},当前分支 ${branch}` : `选择项目:${label}`,
},
@@ -595,7 +609,7 @@ const CONVERSATION_COPY = {
sessions: {
status: { active: '可继续', running: '进行中', waiting_for_user: '等你确认', blocked: '需要处理', aborted: '已中止' },
blockedReason: { NO_REAL_CONNECTION: '等待配置可用模型连接', auth: '需要重新登录', permission_required: '等待权限确认', tool_failed: '工具调用失败', unknown: '运行中断,可重试' },
- listAriaLabel: '任务列表', showMore: '显示更多', showMoreAriaLabel: (count) => `显示 ${count} 条更多任务`, renameAriaLabel: '重命名任务', renameProjectTitle: '重命名项目', renameSubmit: '保存', respondingAriaLabel: '正在响应', respondingTitle: '任务正在流式响应中', staleTitle: '此任务使用的模型连接已不可用,发送时会切换到默认连接', staleAriaLabel: '任务已过期', stale: '已过期', unreadAriaLabel: '未读消息', actionsAriaLabel: (name) => `${name} 任务操作`, pin: '置顶', unpin: '取消置顶', rename: '重命名', archive: '归档', unarchive: '取消归档', delete: '删除', pinned: '置顶', recent: [...]
+ listAriaLabel: '任务列表', showMore: '显示更多', showMoreAriaLabel: (count) => `显示 ${count} 条更多任务`, renameAriaLabel: '重命名任务', renameProjectTitle: '重命名项目', renameSubmit: '保存', respondingAriaLabel: '正在响应', respondingTitle: '任务正在流式响应中', staleTitle: '此任务使用的模型连接已不可用,发送时会切换到默认连接', staleAriaLabel: '任务已过期', stale: '已过期', unreadAriaLabel: '未读消息', actionsAriaLabel: (name) => `${name} 任务操作`, pin: '置顶', unpin: '取消置顶', rename: '重命名', archive: '归档', unarchive: '取消归档', delete: '删除', moveToProject: '移动到项目 [...]
},
},
'zh-TW': {
@@ -677,7 +691,8 @@ const CONVERSATION_COPY = {
forms: { keyboardHint: '1–9 選擇 · ↑↓ 切換 · Enter 確認 · Esc 取消', requester: (name) => `由 ${name} 請求`, requesterWithSource: (name, source) => `由 ${name} 請求 · ${source}`, required: '必填', optional: '選填', include: (label) => `提供:${label}`, enabled: (label) => `啟用:${label}`, enterValue: '輸入內容', enterNumber: '輸入數字', constraintSeparator: ';', lengthConstraint: (minimum, maximum) => minimum === undefined ? `最多 ${maximum} 個字元` : maximum === undefined ? `至少 ${minimum} 個字元` : `長度 ${minimum}–${maxim [...]
mentions: { noFiles: '未找到檔案', noSkills: '暫無技能', noCommandsOrSkills: '沒有符合的命令或技能', filesAriaLabel: '工作區檔案', skillsAriaLabel: '技能', commandsAndSkillsAriaLabel: '命令和技能', commandsGroup: '命令', skillsGroup: 'Skills', loading: '載入中…' },
workspace: {
- choose: '選擇專案', current: '目前專案', addProject: '新增專案', manageProjects: '管理專案', noProject: '無專案', relink: '重新定位', unavailable: '不可用',
+ choose: '選擇專案', current: '目前專案', newProject: '新增專案', newProjectTitle: '新增專案', newProjectDescription: '先為專案命名,再選擇它所在的資料夾。', newProjectNameLabel: '專案名稱', newProjectSubmit: '選擇資料夾', manageProjects: '管理專案', noProject: '無專案', relink: '重新定位', unavailable: '不可用',
+ newProjectUnavailable: '目標主機暫不可用;恢復後可繼續建立。',
chooseTitle: (branch) => branch ? `選擇專案 · ${branch}` : '選擇專案',
chooseAriaLabel: (label, branch) => branch ? `選擇專案:${label},目前分支 ${branch}` : `選擇專案:${label}`,
},
@@ -774,7 +789,7 @@ const CONVERSATION_COPY = {
sessions: {
status: { active: '可繼續', running: '進行中', waiting_for_user: '等你確認', blocked: '需要處理', aborted: '已中止' },
blockedReason: { NO_REAL_CONNECTION: '等待設定可用模型連線', auth: '需要重新登入', permission_required: '等待權限確認', tool_failed: '工具呼叫失敗', unknown: '執行中斷,可重試' },
- listAriaLabel: '任務列表', showMore: '顯示更多', showMoreAriaLabel: (count) => `顯示 ${count} 條更多工`, renameAriaLabel: '重新命名任務', renameProjectTitle: '重新命名專案', renameSubmit: '儲存', respondingAriaLabel: '正在響應', respondingTitle: '任務正在流式響應中', staleTitle: '此任務使用的模型連線已不可用,傳送時會切換到預設連線', staleAriaLabel: '任務已過期', stale: '已過期', unreadAriaLabel: '未讀訊息', actionsAriaLabel: (name) => `${name} 任務操作`, pin: '置頂', unpin: '取消置頂', rename: '重新命名', archive: '歸檔', unarchive: '取消歸檔', delete: '刪除', pinned: '置頂', recen [...]
+ listAriaLabel: '任務列表', showMore: '顯示更多', showMoreAriaLabel: (count) => `顯示 ${count} 條更多工`, renameAriaLabel: '重新命名任務', renameProjectTitle: '重新命名專案', renameSubmit: '儲存', respondingAriaLabel: '正在響應', respondingTitle: '任務正在流式響應中', staleTitle: '此任務使用的模型連線已不可用,傳送時會切換到預設連線', staleAriaLabel: '任務已過期', stale: '已過期', unreadAriaLabel: '未讀訊息', actionsAriaLabel: (name) => `${name} 任務操作`, pin: '置頂', unpin: '取消置頂', rename: '重新命名', archive: '歸檔', unarchive: '取消歸檔', delete: '刪除', moveToProject: '移動到 [...]
},
},
en: {
@@ -853,7 +868,8 @@ const CONVERSATION_COPY = {
forms: { keyboardHint: '1–9 select · ↑↓ navigate · Enter confirm · Esc cancel', requester: (name) => `Requested by ${name}`, requesterWithSource: (name, source) => `Requested by ${name} · ${source}`, required: 'Required', optional: 'Optional', include: (label) => `Provide ${label}`, enabled: (label) => `Enable ${label}`, enterValue: 'Enter a value', enterNumber: 'Enter a number', constraintSeparator: ' · ', lengthConstraint: (minimum, maximum) => minimum === undefined ? `At most ${ma [...]
mentions: { noFiles: 'No files found', noSkills: 'No skills available', noCommandsOrSkills: 'No matching commands or skills', filesAriaLabel: 'Workspace files', skillsAriaLabel: 'Skills', commandsAndSkillsAriaLabel: 'Commands and skills', commandsGroup: 'Commands', skillsGroup: 'Skills', loading: 'Loading…' },
workspace: {
- choose: 'Choose project', current: 'Current project', addProject: 'Add project', manageProjects: 'Manage projects', noProject: 'No project', relink: 'Relink', unavailable: 'Unavailable',
+ choose: 'Choose project', current: 'Current project', newProject: 'New project', newProjectTitle: 'New project', newProjectDescription: 'Name the project, then choose the folder it lives in.', newProjectNameLabel: 'Project name', newProjectSubmit: 'Choose folder', manageProjects: 'Manage projects', noProject: 'No project', relink: 'Relink', unavailable: 'Unavailable',
+ newProjectUnavailable: 'The selected host is unavailable. You can continue when it returns.',
chooseTitle: (branch) => branch ? `Choose project · ${branch}` : 'Choose project',
chooseAriaLabel: (label, branch) => branch ? `Choose project: ${label}, current branch ${branch}` : `Choose project: ${label}`,
},
@@ -950,7 +966,7 @@ const CONVERSATION_COPY = {
sessions: {
status: { active: 'Ready', running: 'Running', waiting_for_user: 'Waiting for you', blocked: 'Needs attention', aborted: 'Stopped' },
blockedReason: { NO_REAL_CONNECTION: 'Waiting for an available model connection', auth: 'Sign in again', permission_required: 'Waiting for permission', tool_failed: 'Tool call failed', unknown: 'Run interrupted; retry available' },
- listAriaLabel: 'Task list', showMore: 'Show more', showMoreAriaLabel: (count) => `Show ${count} more tasks`, renameAriaLabel: 'Rename task', renameProjectTitle: 'Rename project', renameSubmit: 'Save', respondingAriaLabel: 'Responding', respondingTitle: 'This task is streaming a response', staleTitle: 'This task\'s model connection is unavailable; sending will switch to the default connection', staleAriaLabel: 'Stale task', stale: 'Stale', unreadAriaLabel: 'Unread messages', actions [...]
+ listAriaLabel: 'Task list', showMore: 'Show more', showMoreAriaLabel: (count) => `Show ${count} more tasks`, renameAriaLabel: 'Rename task', renameProjectTitle: 'Rename project', renameSubmit: 'Save', respondingAriaLabel: 'Responding', respondingTitle: 'This task is streaming a response', staleTitle: 'This task\'s model connection is unavailable; sending will switch to the default connection', staleAriaLabel: 'Stale task', stale: 'Stale', unreadAriaLabel: 'Unread messages', actions [...]
},
},
} satisfies UiCatalog<ConversationCopy>;
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 36f6ccecae..0ef3012ae8 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -33,6 +33,7 @@ export type {
SessionHistoryGroup,
SessionRowActions,
} from './session-history-list.js';
+export type { SessionMoveTarget } from './session-rail-context.js';
export * from './session-status-presentation.js';
export * from './composer-helpers.js';
export * from './conversation-copy.js';
@@ -58,6 +59,7 @@ export * from './transcript-projection.js';
export * from './use-transcript-projection.js';
export * from './model-picker.js';
export * from './model-wheel-picker.js';
+export * from './new-project-dialog.js';
export * from './interaction-queue.js';
export * from './user-question-prompt.js';
export * from './user-question-prompt-state.js';
diff --git a/packages/ui/src/new-project-dialog.tsx b/packages/ui/src/new-project-dialog.tsx
new file mode 100644
index 0000000000..8ba5bd8513
--- /dev/null
+++ b/packages/ui/src/new-project-dialog.tsx
@@ -0,0 +1,102 @@
+/*
+ * 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, type FormEvent } from 'react';
+import { Button, HStack, TextInput } from '@astryxdesign/core';
+import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
+import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
+import { getConversationCopy } from './conversation-copy.js';
+import { useUiLocale } from './locale-context.js';
+
+/** Collect a name before the caller opens its directory picker. */
+export function NewProjectDialog(props: {
+ isDisabled?: boolean;
+ onOpenChange(open: boolean): void;
+ /** The typed name, trimmed and non-empty; the caller opens the folder picker. */
+ onSubmit(name: string): void;
+}) {
+ const copy = getConversationCopy(useUiLocale()).workspace;
+ const [name, setName] = useState('');
+ const inputRef = useRef<HTMLInputElement>(null);
+
+ useEffect(() => {
+ const input = inputRef.current;
+ if (!input) return;
+ const focusAndSelect = () => {
+ input.focus({ preventScroll: true });
+ input.select();
+ };
+ focusAndSelect();
+ const frame = window.requestAnimationFrame(() => {
+ // Closing a menu and opening a native dialog both manage focus. If either
+ // handoff wins after this effect, take ownership back once it has settled.
+ if (document.activeElement !== input) focusAndSelect();
+ });
+ return () => window.cancelAnimationFrame(frame);
+ }, []);
+
+ const trimmed = name.trim();
+
+ function submit(event: FormEvent<HTMLFormElement>) {
+ event.preventDefault();
+ if (!trimmed || props.isDisabled) return;
+ props.onOpenChange(false);
+ props.onSubmit(trimmed);
+ }
+
+ return (
+ <Dialog isOpen onOpenChange={props.onOpenChange} purpose="form" width={440}>
+ <Layout
+ header={
+ <DialogHeader title={copy.newProjectTitle} onOpenChange={props.onOpenChange} />
+ }
+ footer={
+ <LayoutFooter>
+ <HStack gap={2} hAlign="end">
+ <Button
+ variant="primary"
+ type="submit"
+ form="maka-new-project-form"
+ isDisabled={!trimmed || props.isDisabled}
+ label={copy.newProjectSubmit}
+ />
+ </HStack>
+ </LayoutFooter>
+ }
+ content={
+ <LayoutContent>
+ <form id="maka-new-project-form" onSubmit={submit}>
+ <TextInput
+ ref={inputRef}
+ label={copy.newProjectNameLabel}
+ description={props.isDisabled ? copy.newProjectUnavailable : copy.newProjectDescription}
+ value={name}
+ // A project name is a label, not a document; the same 80 the
+ // titlebar's session field takes.
+ onChange={(value) => setName(value.slice(0, 80))}
+ hasAutoFocus
+ width="100%"
+ />
+ </form>
+ </LayoutContent>
+ }
+ />
+ </Dialog>
+ );
+}
diff --git a/packages/ui/src/session-history-list.tsx b/packages/ui/src/session-history-list.tsx
index ed737d8d42..94ed4ff0d2 100644
--- a/packages/ui/src/session-history-list.tsx
+++ b/packages/ui/src/session-history-list.tsx
@@ -44,11 +44,13 @@ import {
Pin,
PinOff,
Plug,
+ Plus,
SquarePen,
} from './icons.js';
import { RelativeTime } from './relative-time.js';
import { formatAbsoluteTimestamp } from '@maka/core/relative-time';
import { Badge } from '@astryxdesign/core/Badge';
+import { Button } from '@astryxdesign/core';
import { useHoverCard, type HoverCardReturn } from '@astryxdesign/core/HoverCard';
import { MoreMenu } from '@astryxdesign/core/MoreMenu';
import {
@@ -61,6 +63,7 @@ import { describeBlockedReason, presentSessionStatus } from './session-status-pr
import { dotForStatus } from './status-vocabulary.js';
import { SessionRenameDialog, type SessionRenameTarget } from './session-rename-dialog.js';
import {
+ type SessionMoveTarget,
type SessionRailData,
useSessionRailData,
useSessionRailSelection,
@@ -72,7 +75,16 @@ import { getConversationCopy } from './conversation-copy.js';
import { getSessionHoverCardCopy } from './session-hover-card-copy.js';
import { deriveTitlebarProjectName } from './titlebar-session-identity.js';
-type SessionRowActionId = 'flag' | 'archive' | 'rename';
+type SessionRowActionId = 'flag' | 'archive' | 'rename' | 'move';
+
+// Distinguish task moves from external file and text drops.
+const SESSION_DRAG_MIME = 'application/x-maka-session';
+
+const EMPTY_MOVE_TARGETS: readonly SessionMoveTarget[] = [];
+
+// Protected dragover hides payload data. Track the window's single active drag
+// without rerendering its source during dragstart.
+let draggingSessionId: string | null = null;
type ProjectRowActionId = 'new' | 'relink' | 'rename' | 'archive' | 'restore';
type SessionHistoryGroupVariant = 'conversation' | 'project';
@@ -121,6 +133,12 @@ export interface SessionRowActions {
onArchive(sessionId: string): void | Promise<void>;
onUnarchive(sessionId: string): void | Promise<void>;
onRename(sessionId: string, name: string): void | Promise<void>;
+ /**
+ * Re-file ONE task under another project (`projectId`), or out of every
+ * project (`null`). Optional: a shell without project authority omits it and
+ * the row menu then hides the whole "Move to project" submenu.
+ */
+ onMoveToProject?(sessionId: string, projectId: string | null): void | Promise<void>;
}
export interface ProjectRowActions {
@@ -399,6 +417,7 @@ function SessionListGroups(props: {
const rail = useSessionRailData();
const locale = useUiLocale();
const copy = getConversationCopy(locale).sessions;
+ const workspaceCopy = getConversationCopy(locale).workspace;
const [renameTarget, setRenameTarget] = useState<SessionRenameTarget | null>(null);
/**
* The control the rename was started from, so focus can go back to it.
@@ -501,6 +520,7 @@ function SessionListGroups(props: {
}
meta={rail.sessionMeta?.(session)}
sessionBadge={rail.sessionBadge}
+ moveTargets={rail.moveTargets?.(session.id) ?? EMPTY_MOVE_TARGETS}
onSelectSession={rail.onSelectSession}
actions={(session as SessionSummary & { readonly shared?: true }).shared
? undefined
@@ -534,6 +554,8 @@ function SessionListGroups(props: {
const actions = project && rail.relinkableProjectIds?.has(project.id)
? rail.projectActions
: projectActionsWithoutRelink;
+ const canDrop = (rail.moveDropGroupKeys?.has(group.key) ?? false)
+ && rail.rowActions?.onMoveToProject !== undefined;
return (
<ProjectNavRow
key={group.key}
@@ -543,6 +565,21 @@ function SessionListGroups(props: {
sessions={sessions}
streamingSessionIds={rail.streamingSessionIds}
projectActions={actions}
+ onDropSession={
+ canDrop
+ ? (sessionId, projectId) => {
+ void rail.rowActions?.onMoveToProject?.(sessionId, projectId);
+ }
+ : undefined
+ }
+ moveTargetForSession={
+ canDrop
+ ? (sessionId) =>
+ rail
+ .moveTargets?.(sessionId)
+ .find((target) => target.groupKey === group.key)
+ : undefined
+ }
onStartRename={(opener) => {
if (project) {
startRename({ kind: 'project', id: project.id, name: project.name }, opener);
@@ -566,8 +603,27 @@ function SessionListGroups(props: {
{pinnedGroup.sessions.map((session) => renderSessionRow(session))}
</SideNavSection>
)}
- {(activeGroups.length > 0 || archivedGroups.length > 0) && (
- <SideNavSection title={copy.projects} className="maka-session-group">
+ {(activeGroups.length > 0 || archivedGroups.length > 0 || rail.onNewProject !== undefined) && (
+ <SideNavSection
+ title={copy.projects}
+ className="maka-session-group"
+ endContent={
+ rail.onNewProject ? (
+ // The + ChatGPT's sidebar puts on 项目. Always visible in the
+ // project view — including when the list is empty, which is
+ // exactly when someone needs it.
+ <Button
+ variant="ghost"
+ size="sm"
+ isIconOnly
+ label={workspaceCopy.newProject}
+ tooltip={workspaceCopy.newProject}
+ icon={<Plus size={ICON_SIZE.control} aria-hidden="true" />}
+ onClick={() => rail.onNewProject?.()}
+ />
+ ) : undefined
+ }
+ >
{activeGroups.map((group) => renderProjectGroup(group))}
{archivedGroups.length > 0 && (
<SideNavItem
@@ -621,6 +677,18 @@ function ProjectNavRow(props: {
streamingSessionIds?: ReadonlySet<string>;
projectActions?: ProjectRowActions;
onStartRename(opener: HTMLElement | null): void;
+ /**
+ * Re-file a task dragged from the rail under this project. `null` for the
+ * ungrouped bucket, the one drop that means "leave every project". Absent
+ * when the shell cannot move tasks, and the row is then not a drop target.
+ */
+ onDropSession?(sessionId: string, projectId: string | null): void;
+ /**
+ * Where this Session may be moved *on this row*, or undefined when this row
+ * is not one of its destinations. `onDropSession` says the row can receive a
+ * task at all; this says whether it can receive *this* one.
+ */
+ moveTargetForSession?(sessionId: string): SessionMoveTarget | undefined;
renderSession(session: SessionSummary): ReactNode;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -643,8 +711,34 @@ function ProjectNavRow(props: {
const hasSessions = props.sessions.length > 0;
const hasActions = props.project !== undefined && props.projectActions !== undefined;
const hasMeta = (props.project !== undefined && !props.project.available) || hasActions;
+ const [isDropTarget, setIsDropTarget] = useState(false);
return (
- <div ref={containerRef} data-project-id={props.groupKey} className="maka-project-row">
+ <div
+ ref={containerRef}
+ data-project-id={props.groupKey}
+ data-maka-session-drop-target={props.onDropSession ? 'true' : undefined}
+ className="maka-project-row"
+ data-drop-target={isDropTarget ? 'true' : undefined}
+ onDragOver={(event) => {
+ // Check this task's destinations, not just whether the project accepts any task.
+ if (!event.dataTransfer.types.includes(SESSION_DRAG_MIME) || !props.moveTargetForSession || draggingSessionId === null) return;
+ if (!props.moveTargetForSession(draggingSessionId)) return;
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'move';
+ setIsDropTarget(true);
+ }}
+ onDragLeave={() => setIsDropTarget(false)}
+ onDrop={(event) => {
+ setIsDropTarget(false);
+ const sessionId = draggingSessionId;
+ draggingSessionId = null;
+ if (!sessionId || event.dataTransfer.getData(SESSION_DRAG_MIME) !== sessionId || !props.moveTargetForSession) return;
+ const target = props.moveTargetForSession(sessionId);
+ if (!target) return;
+ event.preventDefault();
+ props.onDropSession?.(sessionId, target.projectId);
+ }}
+ >
<SideNavItem
key="navigation"
label={props.label}
@@ -733,6 +827,7 @@ const SessionNavRow = memo(function SessionNavRow(props: {
projectName?: string;
meta?: string;
sessionBadge?: SessionRailData['sessionBadge'];
+ moveTargets: readonly SessionMoveTarget[];
onSelectSession(sessionId: string): void;
actions?: SessionRowActions;
onStartRename(target: SessionRenameTarget, opener: HTMLElement | null): void;
@@ -781,12 +876,45 @@ const SessionNavRow = memo(function SessionNavRow(props: {
.filter((entry): entry is string => Boolean(entry))
.join(' · ');
+ // A row can be dragged onto a project to re-file it, but only when the shell
+ // offers that move and the row stands alone: with several rows picked, which
+ // one the pointer grabbed is not something the drag itself says, and moving
+ // the whole set is not what a drop on one project would mean.
+ const canDrag =
+ props.actions?.onMoveToProject !== undefined &&
+ props.moveTargets.length > 0 &&
+ !(props.picked && props.bulkCount > 1);
+
+ // Chromium does not start an HTML5 drag from a `<button>`, and every row here
+ // IS one — the pointer is on the button, so the wrapper's own `draggable` is
+ // never reached and the gesture degrades to dragging the button's selected
+ // text. Mark the button itself: the drag then begins on the element under the
+ // pointer, and `dragstart` bubbles to this row's handler from there.
+ useEffect(() => {
+ const button = containerRef.current?.querySelector('button.astryx-side-nav-item');
+ if (!button) return undefined;
+ if (!canDrag) return undefined;
+ button.setAttribute('draggable', 'true');
+ return () => button.removeAttribute('draggable');
+ }, [canDrag]);
+
return (
<div
ref={containerRef}
className="maka-session-row"
data-maka-contract="session-row"
data-session-id={props.session.id}
+ draggable={canDrag ? true : undefined}
+ onDragStart={
+ canDrag
+ ? (event) => {
+ draggingSessionId = props.session.id;
+ event.dataTransfer.setData(SESSION_DRAG_MIME, props.session.id);
+ event.dataTransfer.effectAllowed = 'move';
+ }
+ : undefined
+ }
+ onDragEnd={canDrag ? () => { draggingSessionId = null; } : undefined}
data-stale={props.stale ? 'true' : undefined}
data-worktree={props.worktree ? 'true' : undefined}
data-picked={props.picked ? 'true' : undefined}
@@ -889,6 +1017,7 @@ const SessionNavRow = memo(function SessionNavRow(props: {
<SessionItemActions
session={props.session}
actions={props.actions}
+ moveTargets={props.moveTargets}
bulkCount={props.bulkCount}
bulkAllPinned={props.bulkAllPinned}
selectionCommands={props.selectionCommands}
@@ -1248,6 +1377,7 @@ function ProjectItemActions(props: {
function SessionItemActions(props: {
session: SessionSummary;
actions: SessionRowActions;
+ moveTargets: readonly SessionMoveTarget[];
bulkCount: number;
bulkAllPinned: boolean;
selectionCommands?: SessionRailSelectionCommands;
@@ -1269,6 +1399,9 @@ function SessionItemActions(props: {
const mountedRef = useMountedRef();
const pendingActionRef = useRef<SessionRowActionId | null>(null);
const actions = props.actions;
+ // A menu needs the project catalog, not activeId or the rest of the rail.
+ // Subscribing here to rail context bypasses the memoized row on every
+ // session switch and rewrites all of the menu triggers' anchor styles.
useEffect(
() => () => {
@@ -1277,6 +1410,22 @@ function SessionItemActions(props: {
[],
);
+ // Where this task may go, asked of the shell rather than derived here: the
+ // rail holds no project list beyond the rows it draws, and one Host's
+ // projects are not another's. The row that leaves every project is offered
+ // only while the task is in one.
+ const moveTargets = useMemo(() => {
+ const currentProjectId = props.session.projectId ?? null;
+ return props.moveTargets
+ .filter((target) => target.projectId !== currentProjectId)
+ .filter((target) => target.projectId !== null || currentProjectId !== null)
+ .map((target) => ({
+ label: target.projectId === null ? copy.moveToNoProject : (target.name ?? ''),
+ onClick: () =>
+ runRowAction('move', () => actions.onMoveToProject?.(props.session.id, target.projectId)),
+ }));
+ }, [actions, copy.moveToNoProject, props.moveTargets, props.session.id, props.session.projectId]);
+
function runRowAction(actionId: SessionRowActionId, action: () => void | Promise<void>) {
if (pendingActionRef.current) return;
pendingActionRef.current = actionId;
@@ -1367,6 +1516,18 @@ function SessionItemActions(props: {
: actions.onArchive(props.session.id),
),
},
+ // No submenu to build when the shell has no project authority or
+ // there is nowhere to move the task to. `moveTargets` already
+ // holds the projects plus, when the task has one, the exit.
+ ...(actions.onMoveToProject && moveTargets.length > 0
+ ? [
+ {
+ label: copy.moveToProject,
+ icon: FolderOpen,
+ items: moveTargets,
+ },
+ ]
+ : []),
]
}
/>
diff --git a/packages/ui/src/session-rail-context.tsx b/packages/ui/src/session-rail-context.tsx
index 994782ba5a..1800102fa4 100644
--- a/packages/ui/src/session-rail-context.tsx
+++ b/packages/ui/src/session-rail-context.tsx
@@ -29,17 +29,17 @@ import type {
export type SessionViewMode = 'conversation' | 'project';
-/**
- * What the rail's rows are made of.
- *
- * One declaration, read by the list and by every row under it. It used to be
- * the same eleven props redeclared at each of `SessionListPanel`,
- * `SessionHistoryList` and `SessionListGroups`, threaded by hand and kept
- * identity-stable by hand, because the state lived above the whole shell and
- * had no other way down (#4109). Read from here it has one producer, so its
- * identity is the producer's business alone: hold this value still and the
- * ~1,000 fibers below do not render.
- */
+/** One place a Session may be moved to, and the row that offers it. */
+export interface SessionMoveTarget {
+ /** The rail row's `data-project-id`. */
+ readonly groupKey: string;
+ /** Host-local project ID, not a scoped rail key; null detaches. */
+ readonly projectId: string | null;
+ /** Omitted for detach, whose label is localized by the rail. */
+ readonly name?: string;
+}
+
+/** Session projection shared by all rows, independently of rail chrome. */
export interface SessionRailData {
sessions: readonly SessionSummary[];
activeId?: string;
@@ -56,6 +56,12 @@ export interface SessionRailData {
onSelectSession(sessionId: string): void;
rowActions?: SessionRowActions;
projectActions?: ProjectRowActions;
+ /** Candidate rows marked for the window's capture-phase drop guard. */
+ moveDropGroupKeys?: ReadonlySet<string>;
+ /** Current permitted destinations, restricted to the Session's Host. */
+ moveTargets?(sessionId: string): readonly SessionMoveTarget[];
+ /** Omitted when the selected Host cannot create projects. */
+ onNewProject?: () => void;
/** Opaque Project ids whose Host can choose a replacement client directory. */
relinkableProjectIds?: ReadonlySet<string>;
}
diff --git a/packages/ui/src/workspace-picker.tsx b/packages/ui/src/workspace-picker.tsx
index 9533837e0a..26a7675e47 100644
--- a/packages/ui/src/workspace-picker.tsx
+++ b/packages/ui/src/workspace-picker.tsx
@@ -18,12 +18,16 @@
*/
import type { ProjectRecord } from '@maka/core/project';
+import { useState } from 'react';
+import { createPortal } from 'react-dom';
import { DropdownMenu, DropdownMenuItem } from '@astryxdesign/core/DropdownMenu';
import { ICON_SIZE, AlertTriangle, Check, FolderOpen, Network, Plus, RefreshCcw, Settings, X } from './icons.js';
import { useUiLocale } from './locale-context.js';
import { getConversationCopy } from './conversation-copy.js';
+import { NewProjectDialog } from './new-project-dialog.js';
export interface WorkspacePickerGroup {
+ /** Stable within a mutation target; must change when the target changes. */
id: string;
label: string;
status?: string;
@@ -31,7 +35,12 @@ export interface WorkspacePickerGroup {
projects: readonly ProjectRecord[];
selectedProjectId?: string | null;
onSelectProject?(projectId: string): void;
- onAdd?(): void;
+ /**
+ * Create a project on this host. The picker collects the name first (see
+ * `NewProjectDialog`) and passes it here; the handler then asks for the
+ * directory and registers the project under that name.
+ */
+ onAdd?(name: string): void;
onManage?(): void;
onRelink?(projectId: string): void;
onSelectNoProject?(): void;
@@ -52,8 +61,15 @@ export function WorkspacePicker({ workspacePicker: picker }: {
}) {
const copy = getConversationCopy(useUiLocale()).workspace;
const locked = picker.pending === true;
+ // Which host's New project dialog is open. The picker names the project, then
+ // the caller's `onAdd` opens the folder picker — so the name is collected
+ // before the directory step, not after it.
+ const [newProjectGroupId, setNewProjectGroupId] = useState<string | null>(null);
+ const newProjectGroup = picker.groups.find((group) => group.id === newProjectGroupId);
+ const canCreate = !locked && newProjectGroup?.onAdd !== undefined && !newProjectGroup.disabled;
return (
+ <>
<DropdownMenu
placement="above"
hasChevron={false}
@@ -132,9 +148,9 @@ export function WorkspacePicker({ workspacePicker: picker }: {
{group.onAdd ? (
<DropdownMenuItem
icon={<Plus size={ICON_SIZE.meta} aria-hidden="true" />}
- label={copy.addProject}
+ label={copy.newProject}
isDisabled={locked || group.disabled}
- onClick={group.onAdd}
+ onClick={() => setNewProjectGroupId(group.id)}
/>
) : null}
{group.onManage ? (
@@ -172,5 +188,19 @@ export function WorkspacePicker({ workspacePicker: picker }: {
</div>
) : null}
</DropdownMenu>
+ {newProjectGroupId !== null ? (
+ // Astryx Dialog does not portal itself. Keep its form outside the composer form.
+ createPortal(
+ <NewProjectDialog
+ isDisabled={!canCreate}
+ onOpenChange={(open) => {
+ if (!open) setNewProjectGroupId(null);
+ }}
+ onSubmit={(name) => { if (canCreate) newProjectGroup?.onAdd?.(name); }}
+ />,
+ document.body,
+ )
+ ) : null}
+ </>
);
}
diff --git a/packages/ui/stories/session-list-panel.stories.tsx b/packages/ui/stories/session-list-panel.stories.tsx
index f5723384f6..55de928bbb 100644
--- a/packages/ui/stories/session-list-panel.stories.tsx
+++ b/packages/ui/stories/session-list-panel.stories.tsx
@@ -26,6 +26,36 @@ import { SessionRail, type SessionRailStoryProps } from './session-rail-harness.
const NOW = Date.now();
+function ProjectMoveFixture() {
+ const [projectId, setProjectId] = useState<string | null>('source');
+ const session = makeSession({ id: 'move-task', name: 'Move task', projectId });
+ const projects = [makeProject({ id: 'source', name: 'Source' }), makeProject({ id: 'destination', name: 'Destination' })];
+ return <StoryFrame><SessionRail {...panelProps({ sessions: [session], viewMode: 'project' })}
+ groups={projects.map(project => ({ id: project.id, label: project.name, project, sessions: project.id === projectId ? [session] : [] }))}
+ rowActions={{ ...rowActions, onMoveToProject: (_id, target) => setProjectId(target) }}
+ moveDropGroupKeys={new Set(projects.map(project => project.id))}
+ moveTargets={() => projects.filter(project => project.id !== projectId).map(project => ({ groupKey: project.id, projectId: project.id, name: project.name }))}
+ /></StoryFrame>;
+}
+
+// Real path: a task's row menu → Move to project. Host-scoped destinations enter through the rail contract.
+export const ProjectMoveMenu: Story = {
+ render: () => <ProjectMoveFixture />,
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ page.getByRole('button', { name: 'Move task' }).focus();
+ await userEvent.click(page.getByRole('button', { name: 'Move task 任务操作' }));
+ await userEvent.hover(page.getByRole('menuitem', { name: '移动到项目' }));
+ const destination = await page.findByRole('menuitem', { name: 'Destination' });
+ await userEvent.click(destination);
+ await waitFor(() => expect(canvasElement.querySelector('[data-project-id="destination"] [data-session-id="move-task"]')).not.toBeNull());
+ page.getByRole('button', { name: 'Move task' }).focus();
+ await userEvent.click(page.getByRole('button', { name: 'Move task 任务操作' }));
+ await userEvent.hover(page.getByRole('menuitem', { name: '移动到项目' }));
+ await expect(await page.findByRole('menuitem', { name: 'Source' })).toBeVisible();
+ },
+};
+
// Fidelity convention (#1433): every story below names the real app path
// that reaches it. See apps/desktop/stories/FIDELITY.md.
diff --git a/packages/ui/stories/session-rail-harness.tsx b/packages/ui/stories/session-rail-harness.tsx
index 05dece17f0..5a4e64fb8b 100644
--- a/packages/ui/stories/session-rail-harness.tsx
+++ b/packages/ui/stories/session-rail-harness.tsx
@@ -64,6 +64,9 @@ export function SessionRail(props: SessionRailStoryProps) {
onSelectSession: props.onSelectSession ?? (() => undefined),
rowActions: props.rowActions,
projectActions: props.projectActions,
+ moveTargets: props.moveTargets,
+ moveDropGroupKeys: props.moveDropGroupKeys,
+ onNewProject: props.onNewProject,
};
const chrome: SessionRailChrome = {
collapsed: props.collapsed ?? false,
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org