feat(desktop): name a project when creating it, and move tasks between projects - #5392
Conversation
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
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
|
Found and fixed a real bug while trying to capture evidence. Symptom. With Cause. Astryx's Fix ( |
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
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
|
Ready for review — no longer a draft, merged up with @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
Where I would look first
Generated artifacts — Verification so far: |
me2seeks
left a comment
There was a problem hiding this comment.
Review: request changes
基于 commit eb23ac92de931da083ef0971dd27eab1c762f90a 检查了 Runtime Host、Desktop IPC、renderer 分组及拖拽路径。当前发现以下真实问题。
[P1] 移出项目在并发下可能覆盖更新
位置:
移出项目时,Desktop 先读取当前 cwd,再调用 relocateSessionWorkspace。后者会重新读取最新 revision,但仍提交第一次读取到的旧 cwd。
并发时可能发生:
- Desktop 读取 cwd=A、revision=1。
- 另一个入口把会话移动到 cwd=B、revision=2。
- Desktop 重新读取到 revision=2,却提交旧 cwd=A。
- Host 认为 revision=2 是最新,于是接受请求,把会话移回 A 并清除项目归属。
这绕过了原本应提供保护的 revision CAS;如果 cwd 变化,还可能触发 backend/shell 的重建。
建议把“保留当前 cwd 的移出项目”变成 Host 内部的原子语义,或让提交携带第一次读取时的 revision,使并发变化返回冲突。请补充对应的并发测试。
[P1] 远程 Host 分组被错误当成未归属项目
位置:
- app-shell.tsx#L2352-L2383
- session-navigation-groups.ts#L27-L57
- session-history-list.tsx#L553-L583
- session-history-list.tsx#L725-L732
远程/environment 会话按 Runtime Host 分组,而不是按项目分组。但所有分组都会获得 session drop handler。对远程 Host 分组拖放时,代码使用 props.project?.id ?? null,因此会把该操作解释成 projectId = null,即移出项目。
同时,整个 rail 使用 local Host 的项目列表,远程会话也可能显示不属于其所在 Host 的项目目标。
建议明确区分 project、ungrouped、runtime-host 分组;只有前两者允许项目拖放;项目菜单也必须按会话所属 Host 提供,或对不支持该操作的 Host 会话隐藏。请增加 remote/environment 会话与本地项目列表的覆盖测试。
[P2] 拖拽目标和菜单的可用性规则不一致
位置:
菜单会过滤不可用或已归档项目,但拖拽仍会把这些项目行设置成 drop target。用户会看到可以拖放,实际提交后再由 Host 拒绝。建议让拖拽目标复用与菜单相同的 eligibility 判断。
另外,这项功能确实不是单纯的 UI 标记:进入项目时 Host 可能同时持久化 projectId 和 cwd,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
d03a14f to
dc37e9c
Compare
|
Thanks @me2seeks — all three are real, and both P1s are addressed in two new commits ( [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 [P1] Runtime Host groups acting as drop targets — fixed. Groups now declare what they are ( [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 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 ( Verification after the change: typecheck (all workspaces), lint, format, |
…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
|
Rebased the drop gating onto #5527 rather than beside it ( 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:
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. 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
|
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. |



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.
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:
origin/mainwith--strict-base, AppShell hook scope, Astryx inventory, Windows test inventory, and ASF headers: pass.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
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-bytrailers.Checklist
Does this PR entail a change in behavior?