Skip to content

feat: multi-directory workspaces#16

Merged
proofmancer merged 1 commit into
mainfrom
feat/multi-directory
May 24, 2026
Merged

feat: multi-directory workspaces#16
proofmancer merged 1 commit into
mainfrom
feat/multi-directory

Conversation

@proofmancer
Copy link
Copy Markdown
Owner

@proofmancer proofmancer commented May 24, 2026

Closes #2.

Summary

  • New workspace_directories table (id, workspace_id, path, label, created_at). A workspace can now have many directories instead of just one.
  • New agents.directory_id (nullable FK, ON DELETE SET NULL). Binds an agent to one specific directory. Null means "use the workspace's first directory at run time".
  • Backfill on boot: any pre-existing workspace gets a workspace_directories row mirrored from its legacy workspaces.cwd, labeled "default". Idempotent. Existing agents keep null directory_id and resolve to that same default at run time, so behavior is unchanged.
  • workspaces.cwd kept for back-compat as the canonical "default directory" path.
  • New src/lib/directories.ts with listDirectories(workspaceId), resolveAgentCwd(agent, workspace), and ensureDefaultDirectory(workspaceId, cwd) (called from the workspace POST handler so freshly created workspaces are uniform from day one).
  • New routes: GET / POST /api/workspaces/[id]/directories and DELETE /api/workspaces/[id]/directories/[dirId]. Delete refuses to remove the last directory.
  • Agent POST accepts optional directoryId.
  • Run route resolves cwd via resolveAgentCwd and uses it for the spawn cwd AND for writeAgentSkillConfig. Shared MEMORY.md now reads from the workspace's default cwd via the new memoryCwd opt in claude.ts, so memory stays workspace-scoped instead of per-directory.
  • UI: new "Directories" section on the workspace page (above Shared memory). Lists each directory with label + path, lets you add and remove, refuses to remove the last one. The header no longer hard-codes a single cwd, just shows the count.
  • UI: CreateAgentForm gains a Directory dropdown when the workspace has more than one directory. With one directory, the dropdown is hidden and the agent uses the default automatically.
  • UI: AgentPanel shows a chip with the agent's effective directory (amber when explicitly bound, neutral when defaulted).

Test plan

  • Existing workspaces still load and existing agents still run in the same cwd as before (backfill works).
  • Open a workspace, add a second directory, create an agent and pick it, run the agent. Confirm the run cwd is the picked path (claude pwd or any file-touching prompt).
  • Shared memory at the default directory still applies to an agent running in a non-default directory.
  • Delete a non-default directory while an agent is bound to it: agent's directory_id becomes null, next run falls back to the default.
  • Try to delete the last directory of a workspace: refused with a 400.

Summary by CodeRabbit

  • New Features
    • Added workspace directory management—users can now create, list, and delete working directories within a workspace
    • Agents can be assigned to specific directories for isolated execution environments
    • Directory selection is now available when creating new agents
    • Agent panels display the assigned directory with a "(default)" label for unbound agents
    • Shared memory now respects directory boundaries while maintaining workspace-level consistency

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 24, 2026

Warning

Review limit reached

@proofmancer, we couldn't start this review because you've used your available PR reviews for now.

Your plan includes 1 review of capacity. Refill in 38 minutes and 45 seconds.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more review capacity refills, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than trial, open-source, and free plans. In all cases, review capacity refills continuously over time.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 972aab1b-f2c9-471b-a354-15cee4c193dc

📥 Commits

Reviewing files that changed from the base of the PR and between 8317149 and 438c551.

📒 Files selected for processing (16)
  • src/app/api/agents/[id]/run/route.ts
  • src/app/api/agents/route.ts
  • src/app/api/workspaces/[id]/directories/[dirId]/route.ts
  • src/app/api/workspaces/[id]/directories/route.ts
  • src/app/api/workspaces/route.ts
  • src/app/layout.tsx
  • src/app/page.tsx
  • src/app/workspace/[id]/page.tsx
  • src/components/AgentPanel.tsx
  • src/components/CreateAgentForm.tsx
  • src/components/CreateWorkspaceForm.tsx
  • src/components/WorkspaceDirectories.tsx
  • src/lib/claude.ts
  • src/lib/db.ts
  • src/lib/directories.ts
  • src/lib/schema.ts
📝 Walkthrough

Walkthrough

This PR implements multi-directory workspace support, allowing users to define multiple working directories per workspace and bind each agent to a specific directory. The feature includes schema extensions, database migration with backfill for existing workspaces, directory CRUD endpoints, agent-directory binding through forms, and runtime cwd resolution with separate workspace memory handling.

Changes

Multi-Directory Workspace Feature

Layer / File(s) Summary
Schema and database infrastructure
src/lib/schema.ts, src/lib/db.ts
workspace_directories table definition with workspace foreign key, agents table extended with nullable directoryId column. Database migration creates table schema, performs backward-compatible column addition via ALTER TABLE, and backfills default directory rows for pre-existing workspaces using legacy workspace.cwd values.
Directory resolution helpers
src/lib/directories.ts
Three new exported functions: listDirectories(workspaceId) fetches workspace directory rows oldest-first, resolveAgentCwd(agent, workspace) determines effective cwd with fallback chain (agent-bound directory → workspace first directory → workspace cwd), ensureDefaultDirectory(workspaceId, cwd) creates initial default directory row for new workspaces.
Workspace directory API endpoints
src/app/api/workspaces/[id]/directories/route.ts, src/app/api/workspaces/[id]/directories/[dirId]/route.ts, src/app/api/workspaces/route.ts
GET /api/workspaces/{id}/directories lists directories, POST validates and creates new directory with path and optional label, DELETE /api/workspaces/{id}/directories/{dirId} removes directory with enforcement that at least one remains. Workspace creation calls ensureDefaultDirectory to initialize default directory row.
Agent-directory binding in forms and API
src/app/api/agents/route.ts, src/components/CreateAgentForm.tsx
POST /api/agents accepts optional directoryId and includes it in agent insert. CreateAgentForm receives directories prop, maintains directoryId state, conditionally renders directory select when multiple directories exist, sends directoryId in creation payload, and resets field after successful create.
Agent execution with directory and memory awareness
src/lib/claude.ts, src/app/api/agents/[id]/run/route.ts
AgentRunOptions gains optional memoryCwd field to decouple agent execution directory from workspace memory lookup. Agent run route resolves effective cwd via resolveAgentCwd, writes agent skills config in resolved directory, passes cwd to runAgent for execution while setting memoryCwd to workspace default cwd for shared memory consistency.
Workspace page and UI integration
src/app/workspace/[id]/page.tsx, src/components/WorkspaceDirectories.tsx, src/components/AgentPanel.tsx
Workspace page preloads listDirectories(workspace.id) and passes directories to WorkspaceDirectories for management, AgentPanel for display, and CreateAgentForm for selection. New WorkspaceDirectories component renders directory list with add/remove forms, confirmation prompts, and error handling; refreshes page after mutations. AgentPanel displays agent's resolved directory chip with visual distinction for default vs. bound selection.

Sequence Diagram

sequenceDiagram
  participant User
  participant WorkspacePage
  participant DirectoriesAPI
  participant AgentAPI
  participant AgentRunAPI
  
  User->>WorkspacePage: Load workspace
  WorkspacePage->>DirectoriesAPI: GET /api/workspaces/{id}/directories
  DirectoriesAPI-->>WorkspacePage: [directories]
  WorkspacePage->>WorkspacePage: Preload directories
  
  User->>WorkspacePage: Add new directory
  WorkspacePage->>DirectoriesAPI: POST /api/workspaces/{id}/directories
  DirectoriesAPI-->>WorkspacePage: {directory}
  WorkspacePage->>WorkspacePage: Refresh directory list
  
  User->>WorkspacePage: Create agent (choose directory)
  WorkspacePage->>AgentAPI: POST /api/agents {directoryId}
  AgentAPI-->>WorkspacePage: {agent}
  WorkspacePage->>WorkspacePage: Show agent with directory
  
  User->>WorkspacePage: Run agent
  WorkspacePage->>AgentRunAPI: POST /api/agents/{id}/run
  AgentRunAPI->>AgentRunAPI: resolveAgentCwd(agent)
  AgentRunAPI->>AgentRunAPI: runAgent(cwd, memoryCwd)
  AgentRunAPI-->>WorkspacePage: streaming output
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 Hop, hop, hooray! The workspace now blooms in many directions—
Each agent picks its perfect path, through directories of connections.
Memory stays shared, though cwd paths roam free,
A garden of workspaces, wild as can be! 🌿✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: multi-directory workspaces' clearly and concisely summarizes the main change—adding multi-directory support to workspaces.
Linked Issues check ✅ Passed All coding requirements from issue #2 are met: workspace schema supports multiple directories [#2], agents can select directories [#2], UI allows add/remove/dropdown [#2], and run uses selected directory as cwd [#2].
Out of Scope Changes check ✅ Passed All changes are directly aligned with issue #2 scope. No cross-directory shared-state changes are included, keeping the PR focused on the specified objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/multi-directory

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/app/api/workspaces/route.ts (1)

29-37: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Create workspace and default directory in one transaction.

If ensureDefaultDirectory fails after insert, the API can return an error while the workspace row already exists, leaving partial state and making client retries unsafe.

Suggested direction
-  const [row] = await db
-    .insert(schema.workspaces)
-    .values({ name, cwd })
-    .returning()
-  await ensureDefaultDirectory(row.id, cwd)
+  const row = await db.transaction(async (tx) => {
+    const [created] = await tx
+      .insert(schema.workspaces)
+      .values({ name, cwd })
+      .returning()
+    // ideally accept tx in ensureDefaultDirectory to keep this atomic
+    await ensureDefaultDirectory(created.id, cwd)
+    return created
+  })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/workspaces/route.ts` around lines 29 - 37, The workspace insert
followed by ensureDefaultDirectory can leave partial state if
ensureDefaultDirectory fails; wrap both the insert into schema.workspaces and
the subsequent ensureDefaultDirectory call in a single DB transaction so they
either both commit or both roll back. Use the DB transaction API around the
block that calls db.insert(...).values({ name, cwd }).returning() and then calls
ensureDefaultDirectory(row.id, cwd) (or change ensureDefaultDirectory to accept
a transaction/context and call its transactional variant), and only return
NextResponse.json({ workspace: row }, { status: 201 }) after the transaction
completes successfully.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/api/agents/route.ts`:
- Around line 47-54: directoryId is being saved without ensuring it belongs to
the same workspace (workspaceId), enabling cross-workspace associations; before
calling db.insert on schema.agents, if directoryId is non-null query the
directories table (e.g. via db.select().from(schema.directories).where({ id:
directoryId, workspaceId })) and if no row is returned reject the request
(throw/return 400) so only directories that match workspaceId are allowed, then
proceed with the existing db.insert into schema.agents; reference symbols:
directoryId, workspaceId, db, schema.agents, schema.directories, and the insert
call.

In `@src/app/api/workspaces/`[id]/directories/[dirId]/route.ts:
- Around line 22-43: The current check (listDirectories) and the delete
(db.delete on schema.workspaceDirectories) are separate and race-prone; make the
last-directory enforcement atomic by moving the validation and delete into a
single database transaction: inside a transaction (using your DB client's
transaction API) select the count of workspaceDirectories for workspaceId with a
row-level lock (or run a COUNT(*) WHERE workspaceId = id FOR UPDATE),
abort/rollback and return 400 if count <= 1, otherwise perform the delete WHERE
id = dirId AND workspaceId = id and commit; update the route handler to use this
transactional flow instead of listDirectories followed by db.delete to prevent
concurrent deletions from removing the last directory.

In `@src/app/api/workspaces/`[id]/directories/route.ts:
- Around line 49-55: Update the validation in the POST handler in route.ts to
require an absolute path: after trimming the incoming body.path (variable path)
ensure it is absolute (use Node's path.isAbsolute or check leading '/' on POSIX)
and return a 400 JSON error if not; keep the existing empty check but replace
the generic error with a clear message like "path must be absolute" and ensure
subsequent logic (e.g., any use of path or label) only runs when the
absolute-path check passes.

In `@src/components/CreateAgentForm.tsx`:
- Around line 25-27: The component's directoryId state can become stale if the
available directory options change (e.g., the selected directory is removed);
update CreateAgentForm to reconcile directoryId whenever the directory options
list changes by validating the current directoryId against the new options and
setting it to '' (or null-equivalent) if it's no longer present; locate the
useState declaration for directoryId and its setter setDirectoryId as well as
any similar state usages later in the component (lines referenced around the
second occurrence) and add an effect (or inline check where options are derived)
to clear or replace directoryId when the options array changes.

In `@src/components/WorkspaceDirectories.tsx`:
- Around line 28-34: The add handler should early-return if an add is already in
progress to prevent duplicate requests: in the add function (async function
add(e: React.FormEvent)) check the busy flag at the top (if (busy) return)
before calling e.preventDefault() or performing validation, ensuring you still
call e.preventDefault() when appropriate and keep using setBusy(true) when
starting the operation; update references to busy and setBusy accordingly so
rapid or programmatic invocations are ignored.
- Around line 60-64: In the remove function, add a defensive busy guard at the
top (similar to add) to return early if busy is true to prevent races, and
update the confirmation prompt text to start with a capital letter (e.g.,
"Remove this directory? agents bound to it will fall back to the first
directory.") while keeping the existing fallback behavior and setBusy(true) call
intact; modify the remove function and reference the directories array, busy
state, setBusy, and the confirmation call.

---

Outside diff comments:
In `@src/app/api/workspaces/route.ts`:
- Around line 29-37: The workspace insert followed by ensureDefaultDirectory can
leave partial state if ensureDefaultDirectory fails; wrap both the insert into
schema.workspaces and the subsequent ensureDefaultDirectory call in a single DB
transaction so they either both commit or both roll back. Use the DB transaction
API around the block that calls db.insert(...).values({ name, cwd }).returning()
and then calls ensureDefaultDirectory(row.id, cwd) (or change
ensureDefaultDirectory to accept a transaction/context and call its
transactional variant), and only return NextResponse.json({ workspace: row }, {
status: 201 }) after the transaction completes successfully.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f96936f1-a8e3-4b12-8a76-66b0756c9708

📥 Commits

Reviewing files that changed from the base of the PR and between ffc3029 and 8317149.

📒 Files selected for processing (13)
  • src/app/api/agents/[id]/run/route.ts
  • src/app/api/agents/route.ts
  • src/app/api/workspaces/[id]/directories/[dirId]/route.ts
  • src/app/api/workspaces/[id]/directories/route.ts
  • src/app/api/workspaces/route.ts
  • src/app/workspace/[id]/page.tsx
  • src/components/AgentPanel.tsx
  • src/components/CreateAgentForm.tsx
  • src/components/WorkspaceDirectories.tsx
  • src/lib/claude.ts
  • src/lib/db.ts
  • src/lib/directories.ts
  • src/lib/schema.ts

Comment on lines +47 to 54
const directoryId =
typeof body.directoryId === 'string' && body.directoryId
? body.directoryId
: null
const [row] = await db
.insert(schema.agents)
.values({ workspaceId, name, systemPrompt, model, skills })
.values({ workspaceId, directoryId, name, systemPrompt, model, skills })
.returning()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Validate directoryId against the agent’s workspace before insert.

directoryId is persisted without verifying it belongs to workspaceId. That allows cross-workspace bindings and can produce unexpected FK failures.

Suggested fix
 import { NextResponse } from 'next/server'
 import { db, schema } from '`@/lib/db`'
-import { eq, desc } from 'drizzle-orm'
+import { and, eq, desc } from 'drizzle-orm'
@@
-  const directoryId =
-    typeof body.directoryId === 'string' && body.directoryId
-      ? body.directoryId
-      : null
+  const rawDirectoryId =
+    typeof body.directoryId === 'string' ? body.directoryId.trim() : ''
+  let directoryId: string | null = null
+  if (rawDirectoryId) {
+    const [dir] = await db
+      .select({ id: schema.workspaceDirectories.id })
+      .from(schema.workspaceDirectories)
+      .where(
+        and(
+          eq(schema.workspaceDirectories.id, rawDirectoryId),
+          eq(schema.workspaceDirectories.workspaceId, workspaceId),
+        ),
+      )
+    if (!dir) {
+      return NextResponse.json(
+        { error: 'invalid directoryId for workspace' },
+        { status: 400 },
+      )
+    }
+    directoryId = dir.id
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const directoryId =
typeof body.directoryId === 'string' && body.directoryId
? body.directoryId
: null
const [row] = await db
.insert(schema.agents)
.values({ workspaceId, name, systemPrompt, model, skills })
.values({ workspaceId, directoryId, name, systemPrompt, model, skills })
.returning()
const rawDirectoryId =
typeof body.directoryId === 'string' ? body.directoryId.trim() : ''
let directoryId: string | null = null
if (rawDirectoryId) {
const [dir] = await db
.select({ id: schema.workspaceDirectories.id })
.from(schema.workspaceDirectories)
.where(
and(
eq(schema.workspaceDirectories.id, rawDirectoryId),
eq(schema.workspaceDirectories.workspaceId, workspaceId),
),
)
if (!dir) {
return NextResponse.json(
{ error: 'invalid directoryId for workspace' },
{ status: 400 },
)
}
directoryId = dir.id
}
const [row] = await db
.insert(schema.agents)
.values({ workspaceId, directoryId, name, systemPrompt, model, skills })
.returning()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/agents/route.ts` around lines 47 - 54, directoryId is being saved
without ensuring it belongs to the same workspace (workspaceId), enabling
cross-workspace associations; before calling db.insert on schema.agents, if
directoryId is non-null query the directories table (e.g. via
db.select().from(schema.directories).where({ id: directoryId, workspaceId }))
and if no row is returned reject the request (throw/return 400) so only
directories that match workspaceId are allowed, then proceed with the existing
db.insert into schema.agents; reference symbols: directoryId, workspaceId, db,
schema.agents, schema.directories, and the insert call.

Comment on lines +22 to +43
const existing = await listDirectories(id)
if (!existing.find((d) => d.id === dirId)) {
return NextResponse.json(
{ error: 'directory not found in this workspace' },
{ status: 404 },
)
}
if (existing.length <= 1) {
return NextResponse.json(
{ error: 'cannot remove the last directory' },
{ status: 400 },
)
}
await db
.delete(schema.workspaceDirectories)
.where(
and(
eq(schema.workspaceDirectories.id, dirId),
eq(schema.workspaceDirectories.workspaceId, id),
),
)
return NextResponse.json({ ok: true })
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Make last-directory enforcement atomic.

The guard (existing.length <= 1) and the DELETE run in separate queries. Concurrent delete requests can both pass validation and remove all directories.

Suggested direction
 export async function DELETE(
@@
-  const existing = await listDirectories(id)
-  if (!existing.find((d) => d.id === dirId)) {
-    return NextResponse.json(
-      { error: 'directory not found in this workspace' },
-      { status: 404 },
-    )
-  }
-  if (existing.length <= 1) {
-    return NextResponse.json(
-      { error: 'cannot remove the last directory' },
-      { status: 400 },
-    )
-  }
-  await db
-    .delete(schema.workspaceDirectories)
-    .where(
-      and(
-        eq(schema.workspaceDirectories.id, dirId),
-        eq(schema.workspaceDirectories.workspaceId, id),
-      ),
-    )
+  const result = await db.transaction(async (tx) => {
+    const existing = await tx
+      .select({ id: schema.workspaceDirectories.id })
+      .from(schema.workspaceDirectories)
+      .where(eq(schema.workspaceDirectories.workspaceId, id))
+
+    if (!existing.find((d) => d.id === dirId)) return { status: 404 as const }
+    if (existing.length <= 1) return { status: 400 as const }
+
+    await tx
+      .delete(schema.workspaceDirectories)
+      .where(
+        and(
+          eq(schema.workspaceDirectories.id, dirId),
+          eq(schema.workspaceDirectories.workspaceId, id),
+        ),
+      )
+    return { status: 200 as const }
+  })
+
+  if (result.status === 404) {
+    return NextResponse.json(
+      { error: 'directory not found in this workspace' },
+      { status: 404 },
+    )
+  }
+  if (result.status === 400) {
+    return NextResponse.json(
+      { error: 'cannot remove the last directory' },
+      { status: 400 },
+    )
+  }
   return NextResponse.json({ ok: true })
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/workspaces/`[id]/directories/[dirId]/route.ts around lines 22 -
43, The current check (listDirectories) and the delete (db.delete on
schema.workspaceDirectories) are separate and race-prone; make the
last-directory enforcement atomic by moving the validation and delete into a
single database transaction: inside a transaction (using your DB client's
transaction API) select the count of workspaceDirectories for workspaceId with a
row-level lock (or run a COUNT(*) WHERE workspaceId = id FOR UPDATE),
abort/rollback and return 400 if count <= 1, otherwise perform the delete WHERE
id = dirId AND workspaceId = id and commit; update the route handler to use this
transactional flow instead of listDirectories followed by db.delete to prevent
concurrent deletions from removing the last directory.

Comment on lines +49 to +55
const path =
body && typeof body.path === 'string' ? body.path.trim() : ''
const label =
body && typeof body.label === 'string' ? body.label.trim() : ''
if (!path) {
return NextResponse.json({ error: 'path is required' }, { status: 400 })
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce absolute paths in directory creation.

POST currently accepts any non-empty string. That allows relative paths, which can resolve against process cwd unpredictably and break the workspace directory contract.

Suggested fix
+import path from 'node:path'
 import { NextResponse } from 'next/server'
 import { db, schema } from '`@/lib/db`'
 import { eq } from 'drizzle-orm'
 import { listDirectories } from '`@/lib/directories`'
@@
-  const path =
-    body && typeof body.path === 'string' ? body.path.trim() : ''
+  const directoryPath =
+    body && typeof body.path === 'string' ? body.path.trim() : ''
   const label =
     body && typeof body.label === 'string' ? body.label.trim() : ''
-  if (!path) {
+  if (!directoryPath) {
     return NextResponse.json({ error: 'path is required' }, { status: 400 })
   }
+  if (!path.isAbsolute(directoryPath)) {
+    return NextResponse.json(
+      { error: 'path must be absolute' },
+      { status: 400 },
+    )
+  }
   const [row] = await db
     .insert(schema.workspaceDirectories)
-    .values({ workspaceId: workspace.id, path, label })
+    .values({ workspaceId: workspace.id, path: directoryPath, label })
     .returning()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const path =
body && typeof body.path === 'string' ? body.path.trim() : ''
const label =
body && typeof body.label === 'string' ? body.label.trim() : ''
if (!path) {
return NextResponse.json({ error: 'path is required' }, { status: 400 })
}
import path from 'node:path'
import { NextResponse } from 'next/server'
import { db, schema } from '`@/lib/db`'
import { eq } from 'drizzle-orm'
import { listDirectories } from '`@/lib/directories`'
const directoryPath =
body && typeof body.path === 'string' ? body.path.trim() : ''
const label =
body && typeof body.label === 'string' ? body.label.trim() : ''
if (!directoryPath) {
return NextResponse.json({ error: 'path is required' }, { status: 400 })
}
if (!path.isAbsolute(directoryPath)) {
return NextResponse.json(
{ error: 'path must be absolute' },
{ status: 400 },
)
}
const [row] = await db
.insert(schema.workspaceDirectories)
.values({ workspaceId: workspace.id, path: directoryPath, label })
.returning()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/workspaces/`[id]/directories/route.ts around lines 49 - 55,
Update the validation in the POST handler in route.ts to require an absolute
path: after trimming the incoming body.path (variable path) ensure it is
absolute (use Node's path.isAbsolute or check leading '/' on POSIX) and return a
400 JSON error if not; keep the existing empty check but replace the generic
error with a clear message like "path must be absolute" and ensure subsequent
logic (e.g., any use of path or label) only runs when the absolute-path check
passes.

Comment on lines +25 to +27
// Empty string => "use the workspace's default directory" (null on
// the wire). Only meaningful when there is more than one directory.
const [directoryId, setDirectoryId] = useState('')
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reconcile directoryId when directory options change.

If the selected directory is removed, stale directoryId can remain in component state and still be posted.

Suggested fix
   const [directoryId, setDirectoryId] = useState('')
@@
+  useEffect(() => {
+    if (directoryId && !directories.some((d) => d.id === directoryId)) {
+      setDirectoryId('')
+    }
+  }, [directories, directoryId])

Also applies to: 139-157

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/CreateAgentForm.tsx` around lines 25 - 27, The component's
directoryId state can become stale if the available directory options change
(e.g., the selected directory is removed); update CreateAgentForm to reconcile
directoryId whenever the directory options list changes by validating the
current directoryId against the new options and setting it to '' (or
null-equivalent) if it's no longer present; locate the useState declaration for
directoryId and its setter setDirectoryId as well as any similar state usages
later in the component (lines referenced around the second occurrence) and add
an effect (or inline check where options are derived) to clear or replace
directoryId when the options array changes.

Comment on lines +28 to +34
async function add(e: React.FormEvent) {
e.preventDefault()
if (!path.trim()) {
setError('path is required')
return
}
setBusy(true)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add defensive busy check at function start.

While the submit button is disabled when busy, the function could theoretically be invoked programmatically or via rapid events before the state update takes effect. Adding a guard at the start prevents potential duplicate requests.

🛡️ Suggested defensive check
 async function add(e: React.FormEvent) {
   e.preventDefault()
+  if (busy) return
   if (!path.trim()) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function add(e: React.FormEvent) {
e.preventDefault()
if (!path.trim()) {
setError('path is required')
return
}
setBusy(true)
async function add(e: React.FormEvent) {
e.preventDefault()
if (busy) return
if (!path.trim()) {
setError('path is required')
return
}
setBusy(true)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/WorkspaceDirectories.tsx` around lines 28 - 34, The add
handler should early-return if an add is already in progress to prevent
duplicate requests: in the add function (async function add(e: React.FormEvent))
check the busy flag at the top (if (busy) return) before calling
e.preventDefault() or performing validation, ensuring you still call
e.preventDefault() when appropriate and keep using setBusy(true) when starting
the operation; update references to busy and setBusy accordingly so rapid or
programmatic invocations are ignored.

Comment on lines +60 to +64
async function remove(dirId: string) {
if (directories.length <= 1) return
if (!confirm('remove this directory? agents bound to it will fall back to the first directory.'))
return
setBusy(true)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix grammar and add defensive busy check.

Two minor issues:

  1. The confirmation message should start with a capital letter for proper grammar.
  2. Add a busy guard at the start to prevent potential race conditions (same as in add function).
📝 Suggested fixes
 async function remove(dirId: string) {
+  if (busy) return
   if (directories.length <= 1) return
-  if (!confirm('remove this directory? agents bound to it will fall back to the first directory.'))
+  if (!confirm('Remove this directory? Agents bound to it will fall back to the first directory.'))
     return
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/WorkspaceDirectories.tsx` around lines 60 - 64, In the remove
function, add a defensive busy guard at the top (similar to add) to return early
if busy is true to prevent races, and update the confirmation prompt text to
start with a capital letter (e.g., "Remove this directory? agents bound to it
will fall back to the first directory.") while keeping the existing fallback
behavior and setBusy(true) call intact; modify the remove function and reference
the directories array, busy state, setBusy, and the confirmation call.

@proofmancer proofmancer force-pushed the feat/multi-directory branch 2 times, most recently from 6de416a to 54968db Compare May 24, 2026 23:50
@proofmancer proofmancer force-pushed the feat/multi-directory branch from 54968db to 438c551 Compare May 24, 2026 23:54
@proofmancer proofmancer merged commit 614ad51 into main May 24, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v0.2: multi-directory workspaces (one project, many cwds)

1 participant