Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions apps/desktop/e2e/sidebar-workspaces.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."); });
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/main/engine/notes-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T,>(fn: () => Promise<T>, { retry = true }: { retry?: boolean } = {}): Promise<T | undefined> => {
try {
if (nativeHoldRef.current || nativeLoading || nativeRecoveryError) throw new Error("Wait for draft recovery to finish.");
Expand Down Expand Up @@ -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 } : {})}
/>
)}
<main className={`main${surface === "space" && spaceChatOpen ? " space-chat" : ""}`} data-testid={surface === "space" && spaceChatOpen ? "space-chat" : "main"}>
Expand Down
34 changes: 33 additions & 1 deletion apps/desktop/src/renderer/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, boolean>>({});
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
Expand Down Expand Up @@ -152,10 +156,38 @@ export function Sidebar({ state, selected, onSelect, draftProjectId, onAddProjec
<RowMenu label="Project actions" testId="project-menu">
{onOpenNotes && <MenuItem data-testid="project-notes" onClick={() => onOpenNotes(p.id)}>Open notes</MenuItem>}
{grouped && onMoveToWorkspace && ordered.filter(w => w.id !== homeOf(p.id)?.id).map(w => <MenuItem key={w.id} onClick={() => onMoveToWorkspace(p.id, w.id)}>Move to {w.name}</MenuItem>)}
{onMoveToNewWorkspace && <MenuItem data-testid="project-new-workspace" onClick={() => setNaming({ projectId: p.id, value: "" })}>Move to new workspace…</MenuItem>}
<MenuItem data-testid="project-remove" className="danger" onClick={() => onRemoveProject(p.id)}>Remove project</MenuItem>
</RowMenu>
</span>
</div>
{naming?.projectId === p.id && (
<div className="sidebar-search workspace-name">
<Icon name="grid" size={14} />
{/* Enter makes the workspace and moves the project; Escape or leaving the field makes nothing. */}
<SecretInput
autoFocus
aria-label="New workspace name"
placeholder="Workspace name"
value={naming.value}
spellCheck={false}
onChange={(e) => 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)}
/>
</div>
)}
{!isCollapsed && (
<ul className="threads">
{all.length === 0 && <li className="side-hint small">No threads</li>}
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] } };
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/test/notes-projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
1 change: 1 addition & 0 deletions apps/store-desktop/src/main/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading