Skip to content

feat(desktop): name a project when creating it, and move tasks between projects - #5392

Merged
me2seeks merged 24 commits into
apache:mainfrom
MoonOld:feat/desktop-move-session-between-projects
Sep 21, 2026
Merged

me2seeks merged 24 commits into
apache:mainfrom
MoonOld:feat/desktop-move-session-between-projects

Conversation

@MoonOld

@MoonOld MoonOld commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #5389.

Desktop users can create a named, directory-backed project and move existing tasks between projects using either the task menu or drag and drop. Moving into a project changes the task's working directory to that project's preferred directory; removing its project association preserves its current directory and conversation history.

  • New project is available from the workspace picker, the Projects heading, and Settings. The name is supplied when the selected or newly created folder is registered.
  • Tasks can be dropped onto project rows, including empty projects, or onto the visible Ungrouped bucket. The Move to project menu provides a keyboard alternative.
  • The BrowserWindow capture-phase drop guard now lets the private session payload reach designated project targets. Previously, component drag tests passed while the window guard swallowed real desktop drops. Unrelated file/text drops remain blocked; attachment and queue drop routes remain available.
  • Project-creation dialog state belongs to Task Entry. Its command bridge preserves the typed name, and opening the dialog does not rerender the shell frame. Settings uses mutually exclusive project dialogs.

Projects remain directory-backed. Project-less default-directory behavior and Work Board scope are separate from this change.

Verification

Validated after merging current main and resolving the generated architecture/inventory conflicts:

  • Library build and desktop main build: pass.
  • Desktop typecheck (preload, main, renderer, stories): pass.
  • Repository lint and format checks: pass.
  • Renderer architecture check against origin/main with --strict-base, AppShell hook scope, Astryx inventory, Windows test inventory, and ASF headers: pass.
  • Storybook build and complete render smoke: pass (377 stories, 406 theme renders). The existing session-switch render budget reproduced the CI failure before the fix and passes unchanged afterward. Project menus now receive only the catalog through memoized row props, instead of subscribing to all rail selection updates.
  • 75 targeted tests pass, covering project registration, named creation, Task Entry render scope/name forwarding, session move success/detach/refusal, project drops, window drop filtering, and affected overlay/session action boundaries.
ℹ tests 75
ℹ suites 9
ℹ pass 75
ℹ fail 0

Real-window verification on macOS: reproduced the original no-op drag in the development app; after the drop-guard fix and restart, the user confirmed that manually dragging the task between project folders works. The existing conversation remained visible. This manual confirmation preceded the main sync and Task Entry ownership cleanup; the final combined tree was verified with the checks above.

Not run: the complete Electron e2e suite, a full release build, or a new model turn after relocation. No screenshot/video attachment is included.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Maka authored the initial project-creation and move implementation. Codex diagnosed and fixed the window-level drag interception, added regression coverage, resolved main conflicts, and corrected feature ownership/name forwarding under human direction. Commits retain their respective Generated-by trailers.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

The rail's row menu can only flag, rename, or archive a task; there is no way
to re-file an existing task under a different project, even though the Runtime
Host already supports it: `session.workspace.relocate` takes a project target
and `SessionManager.relocateSessionWorkspace` has a `projectId`-only branch.

Expose that as a "Move to project" submenu on the session row. It lists the
projects that can receive the task, plus an exit that detaches it from every
project (the Host's `host_path` target, seeded from the task's current cwd, so
the task stays where it is).

- main: `DesktopRuntimeHostClient.relocateSessionWorkspace` and a
  `sessions:moveToProject` handler that maps `null` to a `host_path`.
- preload: the `sessions.moveToProject` bridge method.
- renderer: a `moveSessionToProject` row action that names the Host's refusal
  code instead of a generic failure.
- ui: the submenu, built from the rail's project list.

Refs apache#5389.

Generated-by: Maka
The picker's add action was "Add project": it opened the folder picker and
registered whatever folder was chosen under that folder's name, leaving the
user to rename it afterwards from the Projects list.

Turn it into "New project". A project is directory-backed, so the dialog
collects the one thing the folder picker cannot — a name — and the directory
step follows it. The project is registered under that name, so the catalog
never holds the folder-derived placeholder the user would have had to fix.

- main: `projects.add` takes an optional `name` and applies it with the
  catalog's rename before selecting, so callers read back the typed name.
- preload/bridge: `projects.add` and `newTasks.addProject` forward the name.
- ui: a `NewProjectDialog` the workspace picker opens before its add action.
  For a remote Host, whose directory browser has no name field, the name rides
  the handoff and is applied when the folder comes back.

Refs apache#5389.

Generated-by: Maka
@github-actions github-actions Bot added the effort/L Under 1000 readable lines label Sep 16, 2026
The row menu could re-file a task, but the gesture users reach for first is a
drag. Make a session row a drag source and a project row a drop target, and give
the ungrouped bucket the one drop that means "leave every project".

The payload is a private MIME type carrying the Session id, so a project row can
tell our task drag from a file or text the OS drags in and a drop anywhere else
is not hijacked. A row only drags when the shell can move tasks and the row
stands alone — with several picked, which one the pointer grabbed is not
something the drag says. The menu item stays, so the gesture has a keyboard
path.

Settings → Projects gets the same New project dialog the composer picker now
opens, so naming is not a composer-only privilege.

Generated-by: Maka
The workspace picker lives inside the composer's <form>, and Astryx's Dialog is
a native <dialog> that does not portal itself. Mounted in place, the picker's
New project form therefore landed INSIDE the composer form, where a submit can
be taken for the composer's and the window navigates to the dev URL (the
`/?` a bare GET submit leaves behind).

Portal the dialog to document.body so it sits outside every other form, and pin
the property that matters with a test: a submit is handled by the dialog and
never reaches the browser.

Opened from Settings this changes nothing observable — the native <dialog>
already renders in the top layer — so the portal is scoped to the picker, the
one caller that nests it.

Generated-by: Maka
@MoonOld

MoonOld commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Found and fixed a real bug while trying to capture evidence.

Symptom. With npm run dev, opening New project from the composer navigated the window to http://localhost:5173/?.

Cause. Astryx's Dialog is a native <dialog> and does not portal itself, while the workspace picker renders inside the composer's <form> (packages/ui/src/composer.tsx). So the New project dialog — which owns its own <form> — was mounted inside the composer form. A submit there could be taken for the composer's, and the bare GET submit left the ? on the dev URL. It is the only dialog in the app opened from inside a form, which is why the same pattern is fine in Settings.

Fix (9e74020b): portal the dialog to document.body so it sits outside every other form, and pin the property that matters with a test — a submit is handled by the dialog and never reaches the browser. Opened from Settings this changes nothing observable, since the native <dialog> already renders in the top layer; the portal is scoped to the picker, the one caller that nests it.

The picker asked for `openDirectory` alone, so on macOS its sheet had no New
Folder button: a project could only point at a folder that already existed.
Creating a project therefore required the user to go make somewhere to put it
first, which is the opposite of what "New project" says.

Add `createDirectory`, the macOS property that puts that button in the sheet.
Ignored on platforms whose directory picker already offers one.

Generated-by: Maka
Creating a project was reachable only from inside the project menu, and from
Settings. On the first screen — where a new workspace is actually decided —
the control named the project you were in and nothing said you could make
another one.

Draw a + next to it, the shape the project picker already reads as: the button
names the workspace, the + makes a new one. It opens the same dialog the menu
item does, and is hidden when no host can create a project, so it is never a
control that would fail.

Generated-by: Maka
The + belongs on the rail's 项目 heading, not on the composer's project
control: that control only renders for a new task, so with a Session open — the
state most of the app spends its time in — there was nothing to put it next to.
The heading is always there.

Move it, and revert the composer button. The dialog moves with it: it is owned
by the shell now, rendered outside the composer's form, and both the rail's +
and the picker's menu item open the same one. The heading draws the + whenever
a host can create a project, including when the project list is empty — which is
exactly when someone needs it.

Generated-by: Maka
An empty project is a heading with nothing under it, so the heading was the
whole target and the eye aimed past it into the next row — the drop either
missed or landed on a neighbour. And the only witness a drop target accepted was
the MIME type in `dataTransfer`, which is the one thing about the gesture that
lives outside the list.

Fix both. The list now tells its project rows when one of its tasks is in the
air, an empty project opens the space its first child would take and lets that
space be part of the target, and a drop is accepted on either witness — the
list's own state or the type travelling with the drag.

Generated-by: Maka
The last change set state in `dragstart` so an empty project could open a drop
area, and that re-render is what a Chromium drag does not survive: the gesture
is dropped on the floor and nothing accepts it afterwards.

Track the dragged id in module state instead. At most one HTML5 drag exists per
window, it needs no render, and it makes the drop target's witness stronger
rather than weaker — the MIME type is now the fallback, not the only evidence.
The drop area goes with the state; the empty project's heading is the target
again.

Generated-by: Maka
Chromium does not initiate an HTML5 drag from a `<button>`, and every rail row
IS one: the pointer is on the button, so the wrapper's `draggable` is never
reached and the gesture silently degrades to dragging the button's selected
text. That is why a ghost appeared while nothing ever accepted the drop — our
`dragstart` never ran, so neither the drag id nor the MIME type existed at the
target.

Mark the row's own button as the drag source in an effect, and unmark it when
the row stops being draggable. `dragstart` then fires where the pointer is and
bubbles to the row's handler.

Generated-by: Maka
The entry broke three recorded contracts, which is what CI stopped on.

- The shell's hook scope counts `useState` in AppShellContent, and the dialog's
  open flag was one more. It moves to a `NewProjectDialogHost` that owns its own
  state and exposes `open()` through a handle, so the shell only holds a ref.
- Renderer architecture: the ledger and the Astryx surface inventory are
  regenerated for the two new files and the `Button` the rail's + introduced.
- Feature code must not touch the Desktop bridge: naming a project on a remote
  Host went through `window.maka` directly. It now goes through the task-entry
  catalog service (`renameProject`), like every other project mutation.

Generated-by: Maka
@github-actions github-actions Bot added effort/XL Under 2500 readable lines and removed effort/L Under 1000 readable lines labels Sep 17, 2026
Allow the private session payload only on project drop targets so real desktop drags reach the rail. Preserve file and queue drop handling, and cover the capture-phase guard with regression tests.

Generated-by: Codex
Regenerate renderer architecture and Astryx inventory for the combined source tree.

Generated-by: Codex
Own the New project dialog in the existing feature host, forward names through its command bridge, and keep Settings dialogs mutually exclusive. Forward optional settings-section intent to its feature owner so AppShell remains composition-only. Cover dialog render scope and named project forwarding.

Generated-by: Codex
Pass the project catalog through memoized rows instead of subscribing each row menu to the entire rail context. This prevents session switches from rewriting all 32 menu anchors. The existing two-row render budget is unchanged; the complete Storybook smoke now passes (377 stories, 406 renders).

Generated-by: Codex
…sion-between-projects

# Conflicts:
#	apps/desktop/renderer-architecture.json
#	docs/astryx-surface-file-inventory.md
@MoonOld

MoonOld commented Sep 20, 2026

Copy link
Copy Markdown
Contributor Author

Ready for review — no longer a draft, merged up with main (no conflicts), and CI is running on the merge commit.

@Astro-Han @M4n5ter — you two have most recently worked on the surfaces this touches (the rail, the shell, and the workspace picker), so you are the reviewers I would ask for. GitHub will not let me request you formally from a fork, hence the @.

What it does

  • New project — creating one now names it: a dialog runs before the folder picker and the project is registered under that name. Entry points are the + on the rail's 项目 heading and Settings → Projects. The macOS picker gets createDirectory, so a new folder can be made in place.
  • Move a task between projects — a "Move to project" item on the session row, plus drag and drop onto a project row (empty projects and the ungrouped bucket included). Built on session.workspace.relocate: moving into a project also moves the task's cwd to that project's preferred directory; detaching keeps the cwd.

Where I would look first

  1. apps/desktop/src/main/main-window-drop-guard.ts (+ main-window.ts) — session drags now pass the window's file-drop guard on designated project targets. This is the change with a window-wide blast radius.
  2. packages/ui/src/session-history-list.tsx — the drag source is the row's own <button> (Chromium will not start a drag from a button), the module-level drag id, and how project rows read it.
  3. apps/desktop/src/main/project-management-service.tsprojects.add({ name }) applies the name through the catalog's rename before selecting.

Generated artifactsrenderer-architecture.json and docs/astryx-surface-file-inventory.* are regenerated for the merge, and the compatible-change declaration is re-pinned to epoch 166 in the merge commit.

Verification so far: test and package CI jobs passed on the previous head; locally, typecheck, lint, format, check:app-shell-hooks, check:renderer-architecture, locale hygiene, the Astryx surface inventory, and the affected desktop/ui suites pass.

@me2seeks me2seeks left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: request changes

基于 commit eb23ac92de931da083ef0971dd27eab1c762f90a 检查了 Runtime Host、Desktop IPC、renderer 分组及拖拽路径。当前发现以下真实问题。

[P1] 移出项目在并发下可能覆盖更新

位置:

移出项目时,Desktop 先读取当前 cwd,再调用 relocateSessionWorkspace。后者会重新读取最新 revision,但仍提交第一次读取到的旧 cwd。

并发时可能发生:

  1. Desktop 读取 cwd=A、revision=1。
  2. 另一个入口把会话移动到 cwd=B、revision=2。
  3. Desktop 重新读取到 revision=2,却提交旧 cwd=A。
  4. Host 认为 revision=2 是最新,于是接受请求,把会话移回 A 并清除项目归属。

这绕过了原本应提供保护的 revision CAS;如果 cwd 变化,还可能触发 backend/shell 的重建。

建议把“保留当前 cwd 的移出项目”变成 Host 内部的原子语义,或让提交携带第一次读取时的 revision,使并发变化返回冲突。请补充对应的并发测试。

[P1] 远程 Host 分组被错误当成未归属项目

位置:

远程/environment 会话按 Runtime Host 分组,而不是按项目分组。但所有分组都会获得 session drop handler。对远程 Host 分组拖放时,代码使用 props.project?.id ?? null,因此会把该操作解释成 projectId = null,即移出项目。

同时,整个 rail 使用 local Host 的项目列表,远程会话也可能显示不属于其所在 Host 的项目目标。

建议明确区分 projectungroupedruntime-host 分组;只有前两者允许项目拖放;项目菜单也必须按会话所属 Host 提供,或对不支持该操作的 Host 会话隐藏。请增加 remote/environment 会话与本地项目列表的覆盖测试。

[P2] 拖拽目标和菜单的可用性规则不一致

位置:

菜单会过滤不可用或已归档项目,但拖拽仍会把这些项目行设置成 drop target。用户会看到可以拖放,实际提交后再由 Host 拒绝。建议让拖拽目标复用与菜单相同的 eligibility 判断。

另外,这项功能确实不是单纯的 UI 标记:进入项目时 Host 可能同时持久化 projectIdcwd,cwd 变化还会影响运行时 backend/shell。因此上述并发和 Host scope 问题需要在合并前处理。

Detaching a task from every project needs the task's own cwd as the target,
and that directory is only meaningful paired with the revision it was read at.
The client read a fresh revision on every attempt and re-sent the first
directory with it, so a concurrent move was silently undone: the Host saw the
latest revision, accepted, and put the task back in the directory it had left —
bypassing the compare-and-set that exists to stop exactly this.

The revision is now read once, in the handler, and carried into a single
attempt. A conflict is reported to the row action, which names it, instead of
being replayed.

Generated-by: Maka
…pointer

Every group in the rail was a drop target, and a drop read the group's project
id — absent for groups that are merely "these rows share a Runtime Host", so
aiming at another Host's heading meant "leave every project". Unavailable and
archived projects were targets too, which the menu had already filtered out.

Groups now say what they are (project / ungrouped / host), only the first two
accept a drop, and a project has to be one the Host would accept — the same rule
the menu applies. The move affordances also follow the Session's Host: the rail
holds the local Host's project list, so a Session running elsewhere is neither
draggable nor offered the menu.

Generated-by: Maka
@MoonOld
MoonOld force-pushed the feat/desktop-move-session-between-projects branch from d03a14f to dc37e9c Compare September 20, 2026 06:33
@MoonOld

MoonOld commented Sep 20, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @me2seeks — all three are real, and both P1s are addressed in two new commits (d4bd2dc5e, dc37e9cf3).

[P1] detach racing the compare-and-set — fixed. The revision is now read once, in the IPC handler, and carried into a single attempt: the client no longer re-reads a fresh revision while holding the directory the caller read. A concurrent change therefore comes back as revision_conflict and is reported to the row action, instead of being replayed with a stale directory.
Regression test: a relocate conflict is reported, not replayed with a stale directory — asserts exactly one session.workspace.relocate request, at the revision the directory was read from.

[P1] Runtime Host groups acting as drop targets — fixed. Groups now declare what they are (kind: 'project' | 'ungrouped' | 'host' on the exported SessionHistoryGroup), and only project and ungrouped accept a drop; a host group is a heading. Worth noting the tests caught a second bug in the same area: SessionHistoryList was projecting groups into {key,label,sessions,project} and dropping kind, so the gate would have disabled drops everywhere.
Tests: a Runtime Host group is a heading, not a drop target.

[P2] drop eligibility diverging from the menu — fixed. A project row is a target only when the project is available and unarchived — the same rule the menu applies — and the data-maka-session-drop-target marker (which the window drop guard keys on) follows it, so the guard blocks the rest.
Test: a project the Host would refuse is not a drop target.

Menu scope by Host. The rail holds one project list — the local Host's — so the move affordances now follow the Session: a Session running on another Runtime Host is neither draggable nor offered the menu, and the shell answers that per Session (canMoveSessionToProject). Test: a session on another Runtime Host offers no move affordance.

Verification after the change: typecheck (all workspaces), lint, format, check:app-shell-hooks, check:renderer-architecture, locale hygiene, Astryx surface inventory, and the affected suites. The generated ledgers are regenerated; all new tests pass.

@MoonOld

MoonOld commented Sep 20, 2026

Copy link
Copy Markdown
Contributor Author
12-drop-highlight 11-new-project-dialog 10-before

…sion-between-projects

# Conflicts:
#	apps/desktop/renderer-architecture.json
The merge brought main's epoch bump (166 -> 167), and a declaration has to state
the epoch the branch is at. Its reason still holds: the file only re-exports the
unchanged 4096-byte cwd limit.

Generated-by: Maka
…sion-between-projects

apache#5527 landed the same concern from the other side: project groups are now
scoped by Runtime Host at one level, and the rail draws a row per (host,
project). Drop gating is re-expressed on that model instead of beside it —
the shell answers `moveDropGroupKeys` (the static rows the window guard lets a
task through on) and `moveTargets(sessionId)` (where one task may go, in its own
Host's raw project ids), so the rows that carry the marker and the destinations
a task is offered cannot disagree. That replaces the `kind` field, the rail's
own project list, and the per-session host predicate this branch had added.
The merge brought main's epoch bump (167 -> 168). The reason still holds: the
file only re-exports the unchanged 4096-byte cwd limit.

Generated-by: Maka
@MoonOld

MoonOld commented Sep 20, 2026

Copy link
Copy Markdown
Contributor Author

Rebased the drop gating onto #5527 rather than beside it (1745b5909, plus a re-pin of the compatible-change declaration to the new epoch 168).

Since #5527 scopes project groups by Runtime Host at one level, the rail no longer holds a project list of its own, and the per-Session host predicate this branch had added would now be wrong in the other direction — it would hide the move from a task whose Host's projects the rail does draw. So all of it is replaced by two questions the shell answers from the same scopes:

  • moveDropGroupKeys — the rows that may receive a task at all. This is the static half, because the window's drop guard runs in the capture phase and reads the marker those rows carry in the DOM before React sees the event.
  • moveTargets(sessionId) — where one task may go, naming rows by their group key and projects by the owning Host's raw id (never the scoped key the rows carry for their own actions, which that Host has never seen).

The rows that carry the marker and the destinations a task is offered therefore come from one source and cannot disagree: another Host's projects, an unavailable project and an archived one are simply absent from the list, and the menu and the drop read the same one. session-project-grouping.ts follows main (kept for its worktree-id helper), and the group-id helpers (projectGroupId / ungroupedGroupId) are exported from the grouping module so the shell's answers and the rail's rows are named the same way.

The tests were rewritten for this shape — eight cases covering a project row, the row that leaves every project, an empty project, a row the shell does not offer, a row that is not this task's destination, a foreign drag, and a task with nowhere to go.

…sion-between-projects

Only the generated ledger and the Astryx surface inventory conflicted again; both
are regenerated (Astryx 0.6.2 now). The provider's rail projection is a local
value since apache#5527, so the move answers read `rail.sessions` rather than a prop.
The merge brought main's epoch bump (168 -> 169). The reason still holds: the
file only re-exports the unchanged 4096-byte cwd limit.

Generated-by: Maka
@me2seeks

Copy link
Copy Markdown
Contributor

Follow-up (P2): Settings → Projects still opens the remote directory picker directly for Host-directory targets such as WSL/SSH. That picker has no name field, so the newly registered project takes the folder basename, while the local and Task Entry paths preserve the name entered during creation. This does not block this PR because the project can still be created and renamed afterward.

@me2seeks me2seeks left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed current head 2da8a03. The remaining remote Settings naming inconsistency is recorded as a non-blocking follow-up comment; the PR behavior and validation are otherwise acceptable.

@me2seeks
me2seeks merged commit 1c18700 into apache:main Sep 21, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XL Under 2500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(desktop): create an empty project and reassign sessions between projects (drag and drop)

2 participants