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