diff --git a/apps/desktop/e2e/sidebar-workspaces.spec.ts b/apps/desktop/e2e/sidebar-workspaces.spec.ts index 41339b60..00f2e854 100644 --- a/apps/desktop/e2e/sidebar-workspaces.spec.ts +++ b/apps/desktop/e2e/sidebar-workspaces.spec.ts @@ -51,6 +51,42 @@ test("each project has notes, and a second workspace groups the sidebar's projec } }); +test("Move to new workspace makes the workspace and moves the project in one step; Escape makes nothing", async () => { + const { home, repo } = seedHome(); + const project = path.basename(repo); + const { app, page } = await launch(home); + try { + await expect.poll(() => fs.existsSync(path.join(home, "notes", "Personal", project, ".modex-project.json"))).toBe(true); + const field = page.getByRole("textbox", { name: "New workspace name" }); + + // Escape leaves everything as it was. + await tid(page, "project-menu").click(); + await tid(page, "project-new-workspace").click(); + await expect(field).toBeFocused(); + await field.fill("Nowhere"); + await page.keyboard.press("Escape"); + await expect(field).toHaveCount(0); + expect(fs.existsSync(path.join(home, "notes", "Nowhere"))).toBe(false); + await expect(tid(page, "workspace-group")).toHaveCount(0); + + // With only the default workspace, the project can still go to a new one. + await tid(page, "project-menu").click(); + await tid(page, "project-new-workspace").click(); + await field.fill("Clients"); + await page.keyboard.press("Enter"); + await expect(tid(page, "workspace-group")).toHaveCount(1); + await expect(tid(page, "workspace-group")).toHaveAccessibleName("Clients"); + await expect(tid(page, "workspace-group").locator('[data-testid="project"]')).toHaveCount(1); + await expect(field).toHaveCount(0); + expect(fs.existsSync(path.join(home, "notes", "Clients", project, ".modex-project.json"))).toBe(true); + expect(fs.existsSync(path.join(home, "notes", "Personal", project))).toBe(false); + } finally { + await app.close(); + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(repo, { recursive: true, force: true }); + } +}); + test("Open notes consumes its focus across polls and remounts, and accepts a fresh request", async () => { const { home, repo } = seedHome(); const project = path.basename(repo); diff --git a/apps/desktop/src/main/commands.ts b/apps/desktop/src/main/commands.ts index 84d98bc5..124593f6 100644 --- a/apps/desktop/src/main/commands.ts +++ b/apps/desktop/src/main/commands.ts @@ -68,6 +68,7 @@ export function registerCommands(handle: RegisterCommand, { handle("notes:workspaces", () => store.space.workspaces()); handle("project:notes", ({ projectId }) => { const project = store.project(projectId); if (!project) throw new Error("This project is no longer in Modex."); return store.space.linkProject(project); }); handle("project:workspace", ({ projectId, workspaceId }) => { const project = store.project(projectId); if (!project) throw new Error("This project is no longer in Modex."); store.space.moveNodes([store.space.linkProject(project).id], workspaceId); return store.space.workspaces(); }); + handle("project:new-workspace", ({ projectId, name }) => { const project = store.project(projectId); if (!project) throw new Error("This project is no longer in Modex."); return store.space.moveProjectToNewWorkspace(project, name); }); handle("notes:reveal", () => { throw new Error("Reveal in Finder is available in the desktop app."); }); handle("notes:open", () => { throw new Error("Opening files is available in the desktop app."); }); handle("notes:import", () => { throw new Error("Importing files is available in the desktop app."); }); diff --git a/apps/desktop/src/main/engine/notes-store.ts b/apps/desktop/src/main/engine/notes-store.ts index 87b77d27..050eb69a 100644 --- a/apps/desktop/src/main/engine/notes-store.ts +++ b/apps/desktop/src/main/engine/notes-store.ts @@ -452,6 +452,19 @@ export class NotesStore implements SpacePagesStore { return created; } + /** A new workspace with this Modex project's notes in it, in one step. If the move fails, the new, empty workspace goes again. */ + moveProjectToNewWorkspace(project: LinkedProject, name: string): NotesWorkspaceSummary[] { + if (!name.trim()) throw new Error("Name the workspace."); + const notes = this.linkProject(project); + const workspace = this.createWorkspace(name); + try { this.moveNodes([notes.id], workspace.id); } + catch (err) { + try { this.deleteEmpty(workspace.id); } catch { /* the move's own error is the one to report */ } + throw err; + } + return this.workspaces(); + } + /** A removed Modex project leaves its notes where they are, ready to link again. */ detachProject(modexProjectId: string): void { const node = this.projectFor(modexProjectId); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index a1e81dbe..847b946a 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -753,6 +753,7 @@ handle("notes:delete", ({ id }) => { store.space.deleteEmpty(id); return store.s handle("notes:workspaces", () => store.space.workspaces()); handle("project:notes", ({ projectId }) => { const project = store.project(projectId); if (!project) throw new Error("This project is no longer in Modex."); return store.space.linkProject(project); }); handle("project:workspace", ({ projectId, workspaceId }) => { const project = store.project(projectId); if (!project) throw new Error("This project is no longer in Modex."); store.space.moveNodes([store.space.linkProject(project).id], workspaceId); return store.space.workspaces(); }); +handle("project:new-workspace", ({ projectId, name }) => { const project = store.project(projectId); if (!project) throw new Error("This project is no longer in Modex."); return store.space.moveProjectToNewWorkspace(project, name); }); handle("notes:reveal", ({ id }) => shell.showItemInFolder(store.space.pathOf(id)), { localOnly: true }); handle("notes:open", async ({ id }) => { const error = await shell.openPath(store.space.pathOf(id)); if (error) throw new Error(error); }, { localOnly: true }); handle("notes:import", async ({ targetId, paths }) => { diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index e76497dc..c4df9218 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -538,6 +538,7 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE }, [projectKey, surface, spaceEnabled]); const openNotes = (projectId: string) => { setOpenProject({ projectId, nonce: Date.now() }); setSpaceOpened(true); setSurface("space"); }; const moveToWorkspace = (projectId: string, workspaceId: string) => void act(async () => { setWorkspaces(await bridge.invoke("project:workspace", { projectId, workspaceId })); }); + const moveToNewWorkspace = (projectId: string, name: string) => void act(async () => { setWorkspaces(await bridge.invoke("project:new-workspace", { projectId, name })); }); const act = async (fn: () => Promise, { retry = true }: { retry?: boolean } = {}): Promise => { try { if (nativeHoldRef.current || nativeLoading || nativeRecoveryError) throw new Error("Wait for draft recovery to finish."); @@ -928,7 +929,7 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE onRemoveProject={removeProject} unsent={unsent} agents={activeAgents} - {...(spaceEnabled ? { workspaces, onOpenNotes: openNotes, onMoveToWorkspace: moveToWorkspace } : {})} + {...(spaceEnabled ? { workspaces, onOpenNotes: openNotes, onMoveToWorkspace: moveToWorkspace, onMoveToNewWorkspace: moveToNewWorkspace } : {})} /> )}
diff --git a/apps/desktop/src/renderer/components/Sidebar.tsx b/apps/desktop/src/renderer/components/Sidebar.tsx index de37b50f..dcf90be3 100644 --- a/apps/desktop/src/renderer/components/Sidebar.tsx +++ b/apps/desktop/src/renderer/components/Sidebar.tsx @@ -31,12 +31,16 @@ interface Props { /** Opens the project's notes in Space. */ onOpenNotes?: (projectId: string) => void; onMoveToWorkspace?: (projectId: string, workspaceId: string) => void; + /** Makes a notes workspace with this name and moves the project into it. */ + onMoveToNewWorkspace?: (projectId: string, name: string) => void; } /** Threads shown per project before "Show more". */ export const THREADS_PER_PROJECT = 5; -export function Sidebar({ state, selected, onSelect, draftProjectId, onAddProject, onNewChat, onNewThread, onDeleteThread, onArchiveThread, onRemoveProject, unsent, agents = [], workspaces, onOpenNotes, onMoveToWorkspace }: Props) { +export function Sidebar({ state, selected, onSelect, draftProjectId, onAddProject, onNewChat, onNewThread, onDeleteThread, onArchiveThread, onRemoveProject, unsent, agents = [], workspaces, onOpenNotes, onMoveToWorkspace, onMoveToNewWorkspace }: Props) { + /** The project whose "Move to new workspace" name field is open, and what is typed in it. */ + const [naming, setNaming] = useState<{ projectId: string; value: string } | null>(null); const [workspaceCollapsed, setWorkspaceCollapsed] = useState>({}); const [collapsed, setCollapsed] = useState>({}); const [expanded, setExpanded] = useState>({}); @@ -152,10 +156,38 @@ export function Sidebar({ state, selected, onSelect, draftProjectId, onAddProjec {onOpenNotes && onOpenNotes(p.id)}>Open notes} {grouped && onMoveToWorkspace && ordered.filter(w => w.id !== homeOf(p.id)?.id).map(w => onMoveToWorkspace(p.id, w.id)}>Move to {w.name})} + {onMoveToNewWorkspace && setNaming({ projectId: p.id, value: "" })}>Move to new workspace…} onRemoveProject(p.id)}>Remove project + {naming?.projectId === p.id && ( +
+ + {/* Enter makes the workspace and moves the project; Escape or leaving the field makes nothing. */} + setNaming({ projectId: p.id, value: e.target.value })} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + const name = naming.value.trim(); + setNaming(null); + if (name) onMoveToNewWorkspace?.(p.id, name); + } else if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + setNaming(null); + } + }} + onBlur={() => setNaming(null)} + /> +
+ )} {!isCollapsed && (
    {all.length === 0 &&
  • No threads
  • } diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index a802c0ae..c8a1efea 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -385,6 +385,7 @@ body[data-streamer-mode="true"] ::search-text { color: transparent; background-c .sidebar-search:focus-within { border-color: var(--accent); } .sidebar-search input { flex: 1; min-width: 0; border: 0; outline: none; background: transparent; color: var(--text-1); font-size: var(--text-md); } .sidebar-search input::placeholder { color: var(--text-placeholder); } +.sidebar-search.workspace-name { margin: 2px 19px 4px 24px; } .side-row { position: relative; display: flex; align-items: center; height: var(--row-h); flex: none; margin: 0 8px 1px; border-radius: var(--radius-row); color: var(--text-2); font-size: var(--text-md); } .side-row:hover { background: var(--bg-row-hover); } diff --git a/apps/desktop/src/shared/types.ts b/apps/desktop/src/shared/types.ts index f48f8c11..9bbd745d 100644 --- a/apps/desktop/src/shared/types.ts +++ b/apps/desktop/src/shared/types.ts @@ -559,6 +559,8 @@ export interface BridgeCommands extends NativeDraftCommands, ImageCommands, Plug "project:notes": { req: { projectId: string }; res: import("./notes.js").NotesProject }; /** Moves a Modex project's notes into another workspace. */ "project:workspace": { req: { projectId: string; workspaceId: string }; res: { id: string; name: string; order: number; isDefault: boolean; modexProjectIds: string[] }[] }; + /** Makes a notes workspace and moves the project's notes into it, as one action. */ + "project:new-workspace": { req: { projectId: string; name: string }; res: { id: string; name: string; order: number; isDefault: boolean; modexProjectIds: string[] }[] }; "notes:open": { req: { id: string }; res: void }; /** Copies files into a project or folder; without paths, a picker opens. */ "notes:import": { req: { targetId: string; paths?: string[] }; res: { imported: number; errors: string[] } }; diff --git a/apps/desktop/test/notes-projects.test.ts b/apps/desktop/test/notes-projects.test.ts index bad803d1..a81395eb 100644 --- a/apps/desktop/test/notes-projects.test.ts +++ b/apps/desktop/test/notes-projects.test.ts @@ -66,6 +66,28 @@ test("the workspace summary says which Modex projects each workspace holds", () assert.equal(notes.projectFor("p1")?.rel, "Clients/site", "a project moved between workspaces keeps its link"); }); +test("a project moves into a new workspace in one step, and a failed move leaves no empty workspace", () => { + const home = tmpdir("modex-notes-projects-"); + const store = new Store(home); + const repo = gitRepo(); + const project = store.addProject(repo); + const summaries = store.space.moveProjectToNewWorkspace(project, "Clients"); + assert.deepEqual(summaries.map(w => w.name).sort(), ["Clients", "Personal"]); + assert.deepEqual(summaries.find(w => w.name === "Clients")!.modexProjectIds, [project.id]); + assert.equal(marker(home, "Clients", path.basename(repo)).modexProjectId, project.id); + assert.equal(fs.existsSync(path.join(home, "notes", "Personal", path.basename(repo))), false); + + const other = store.addProject(gitRepo()); + const move = store.space.moveNodes; + store.space.moveNodes = () => { throw new Error("disk full"); }; + try { assert.throws(() => store.space.moveProjectToNewWorkspace(other, "Archive"), /disk full/); } + finally { store.space.moveNodes = move; } + assert.deepEqual(store.space.workspaces().map(w => w.name).sort(), ["Clients", "Personal"], "the workspace made for a failed move is gone again"); + assert.equal(fs.existsSync(path.join(home, "notes", "Archive")), false); + assert.throws(() => store.space.moveProjectToNewWorkspace(other, " "), /Name the workspace/); + assert.equal(store.space.workspaces().length, 2); +}); + test("a project folder copied in Finder becomes a project of its own instead of making the notes unreadable", () => { const home = tmpdir("modex-notes-copies-"); const notes = new NotesStore(path.join(home, "notes")); diff --git a/apps/store-desktop/src/main/demo.ts b/apps/store-desktop/src/main/demo.ts index f540df4d..be08d9ea 100644 --- a/apps/store-desktop/src/main/demo.ts +++ b/apps/store-desktop/src/main/demo.ts @@ -107,6 +107,7 @@ export class StoreDemo { "notes:workspaces": () => [], "project:notes": () => { throw new Error("Space is unavailable in the review demo."); }, "project:workspace": () => { throw new Error("Space is unavailable in the review demo."); }, + "project:new-workspace": () => { throw new Error("Space is unavailable in the review demo."); }, "notes:open": unavailable, "notes:import": unavailable, "notes:file:read": unavailable, diff --git a/docs/notes.md b/docs/notes.md index 3d94ed1e..5d571b60 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -97,6 +97,10 @@ Each Modex project has a notes project, made in the default workspace when the p its code folder. Removing the project from Modex keeps the notes and unlinks them; adding the same folder again links them back. Moving a project's notes to another workspace regroups it in the chat sidebar. +A project's menu in the chat sidebar also has **Move to new workspace…**: name it, press Enter, +and Modex makes the workspace and moves the project's notes into it in one step +(`project:new-workspace`). It works while there is only the default workspace. Escape or leaving +the field makes nothing, and if the move fails, the new workspace is removed again. ## History and revisions diff --git a/docs/status.md b/docs/status.md index 9b9880e0..2410028d 100644 --- a/docs/status.md +++ b/docs/status.md @@ -144,6 +144,9 @@ and [landing verification](reviews/2026-10-11-landing-clarity.md). unknown stays in view, and every Codex web search read as unknown: codex-cli 0.163.0 gives a search no status or error field. Its completion is its only report, so a completed search now reads as done and folds like other finished work (#277). +- **A project can move to a new workspace from the chat sidebar.** Its menu has **Move to new + workspace…**, which names a workspace and moves the project into it in one step, even while there + is only the default workspace. Before, a new workspace had to be made in Space first. ## In flight