From 401ad2e65da90276fe52b2e7555fdcdefb21bcc3 Mon Sep 17 00:00:00 2001
From: Alex Blank
Date: Sat, 30 May 2026 11:58:38 +0200
Subject: [PATCH 01/44] feat: always show Recreate Tunnel button for web
instances
Show the Recreate Tunnel button on all active web-enabled session cards
(instead of only when tunnel_status is unreachable) so users can manually
trigger tunnel recreation at any time. Also adds it to the mobile action sheet.
Quality gates: eslint clean, tsc clean
---
apps/api/src/api/tool_instances.py | 16 ++++++++++++----
apps/web/src/components/session-card.tsx | 9 +++++----
2 files changed, 17 insertions(+), 8 deletions(-)
diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py
index b8da74c..fc59913 100644
--- a/apps/api/src/api/tool_instances.py
+++ b/apps/api/src/api/tool_instances.py
@@ -1723,15 +1723,23 @@ async def start_instance(
logger.debug("Container ID for instance %s: %s", instance.id, container_id)
instance.container_name = expected_container_name
- logger.debug("Container name for instance %s: %s", instance.id, expected_container_name)
+ logger.debug(
+ "Container name for instance %s: %s", instance.id, expected_container_name
+ )
# Connect container to backend network so API can reach it
- logger.debug("Connecting container %s to backend network...", expected_container_name)
+ logger.debug(
+ "Connecting container %s to backend network...", expected_container_name
+ )
connected = connect_container_to_network(expected_container_name, "backend")
if connected:
- logger.debug("Successfully connected %s to backend network", expected_container_name)
+ logger.debug(
+ "Successfully connected %s to backend network", expected_container_name
+ )
else:
- logger.warning("Failed to connect %s to backend network", expected_container_name)
+ logger.warning(
+ "Failed to connect %s to backend network", expected_container_name
+ )
# Verify container reached running state
if instance.container_id:
diff --git a/apps/web/src/components/session-card.tsx b/apps/web/src/components/session-card.tsx
index 0483d66..ca49dfe 100644
--- a/apps/web/src/components/session-card.tsx
+++ b/apps/web/src/components/session-card.tsx
@@ -229,12 +229,13 @@ export function SessionCard({
)}
- {hasTunnelError && onRecreateTunnel && (
+ {!isTerminalOnly && onRecreateTunnel && (
+
+
{workspace.project_name} / {workspace.repo_name}
+
{workspace.branch}
+ {workspace.instance_count > 0 && (
+
{workspace.instance_count} active tool{workspace.instance_count > 1 ? "s" : ""}
+ )}
+
+
+
+
+
+
+
+ );
+}
+```
+
+### Component: Sidebar (updated)
+
+```tsx
+const navItems = [
+ { path: "/dashboard", label: "Dashboard", icon: "home" },
+ { path: "/projects", label: "Projects", icon: "folder" },
+ { path: "/workspaces", label: "Workspaces", icon: "workspace" },
+ { path: "/settings", label: "Settings", icon: "settings" },
+];
+```
+
+### Hook: useWorkspaceActions
+
+```typescript
+export function useWorkspaceActions(options: { onRefresh: () => Promise }) {
+ const [loadingId, setLoadingId] = useState(null);
+
+ const handleDelete = useCallback(async (workspace: Workspace, force = false) => {
+ setLoadingId(workspace.id);
+ try {
+ await deleteWorkspace(workspace.repo_id, workspace.id, force);
+ await options.onRefresh();
+ } catch (err) {
+ const error = err as AxiosError<{ detail?: { instances?: Array<{id: string, name: string}> } }>;
+ if (error.response?.status === 409 && !force) {
+ const instances = error.response.data?.detail?.instances || [];
+ const confirmed = confirm(
+ `This workspace has ${instances.length} running tool instance(s):\n` +
+ instances.map(i => `- ${i.name}`).join("\n") +
+ `\n\nDelete workspace and all instances?`
+ );
+ if (confirmed) {
+ await handleDelete(workspace, true);
+ }
+ }
+ } finally {
+ setLoadingId(null);
+ }
+ }, [options.onRefresh]);
+
+ const handleSync = useCallback(async (workspace: Workspace) => {
+ setLoadingId(workspace.id);
+ try {
+ const result = await syncWorkspace(workspace.repo_id, workspace.id);
+ await options.onRefresh();
+ return result;
+ } catch (err) {
+ const error = err as AxiosError<{ detail?: { branch_deleted?: boolean; message?: string } }>;
+ if (error.response?.status === 409 && error.response.data?.detail?.branch_deleted) {
+ const confirmed = confirm(
+ `${error.response.data.detail.message}\n\nDelete this workspace?`
+ );
+ if (confirmed) {
+ await handleDelete(workspace, true);
+ }
+ }
+ } finally {
+ setLoadingId(null);
+ }
+ }, [options.onRefresh, handleDelete]);
+
+ return { loadingId, handleDelete, handleSync };
+}
+```
+
+## Compose Template Updates
+
+### Workspace Mount
+
+All compose templates will mount the workspace path instead of the repo path:
+
+```yaml
+services:
+ app:
+ image: ${IMAGE_TAG}
+ container_name: ${INSTANCE_NAME}
+ volumes:
+ - ${WORKSPACE_PATH}:/workspace
+ working_dir: /workspace
+ # ... rest of config
+```
+
+The `${WORKSPACE_PATH}` variable replaces `${REPO_PATH}` in all templates.
+
+## Error Handling
+
+| Error | HTTP Status | Frontend Behavior |
+|---|---|---|
+| Workspace name not unique per repo | 409 | Show inline validation error |
+| Workspace has running instances | 409 | Show confirmation dialog |
+| Branch deleted from remote | 409 | Show confirmation dialog to delete workspace |
+| Repo not found | 404 | Show error toast |
+| Git clone failed | 500 | Show error toast with git stderr |
+| Workspace path missing | 500 | Show error toast |
+
+## Performance Considerations
+
+- **Git clone** is synchronous and slow; run in background with status polling
+- **Workspace list** should include `instance_count` via subquery (not N+1)
+- **Sync** is fast (fetch only), but pull may be slow; run async
+- **Delete with instances** stops instances sequentially; consider parallel
+
+## Security Considerations
+
+- Workspace paths must be validated to prevent path traversal
+- Users can only access their own workspaces
+- Git credentials (SSH keys) must be available during clone
+- Workspace directories must have correct ownership for container users
+
+## Testing Strategy
+
+### Backend
+- Unit: WorkspaceManager.create, delete, sync
+- Unit: GitService.clone, fetch, pull, branch_exists_remotely
+- Integration: Create workspace → start tool → verify mount
+- Integration: Delete workspace with running instances
+- Integration: Sync with deleted branch
+
+### Frontend
+- Component: WorkspaceCard renders correctly
+- Component: Create form validates name uniqueness
+- Hook: useWorkspaceActions handles 409 confirmation
+- E2E: Create workspace → start tool → delete workspace
+
+## Out of Scope
+
+- Auto-sync on schedule
+- Workspace sharing between users
+- Git push/pull/branch UI
+- Pre-created default workspaces
+- Read-only workspace mode
+- Workspace backup/restore
+
+## Files Changed
+
+### New Files
+- `apps/api/src/models/workspace.py`
+- `apps/api/src/api/workspaces.py`
+- `apps/api/src/services/workspace_manager.py`
+- `apps/api/src/services/git_service.py`
+- `apps/api/alembic/versions/2026_06_01_add_workspaces.py`
+- `apps/web/src/pages/workspaces.tsx`
+- `apps/web/src/pages/workspace-detail.tsx`
+- `apps/web/src/components/workspace-card.tsx`
+- `apps/web/src/components/workspace-create-form.tsx`
+- `apps/web/src/components/start-tool-modal.tsx`
+- `apps/web/src/hooks/use-workspaces.ts`
+- `apps/web/src/hooks/use-workspace-actions.ts`
+- `apps/web/src/api/workspaces.ts`
+- `apps/web/src/types/workspace.ts`
+
+### Modified Files
+- `apps/api/src/models/tool_instance.py` (add workspace_id)
+- `apps/api/src/api/tool_instances.py` (use workspace path)
+- `apps/web/src/components/sidebar.tsx` (add nav item)
+- `apps/web/src/pages/dashboard.tsx` (add workspaces section)
+- `apps/web/src/api/sessions.ts` (add workspace endpoints)
diff --git a/openspec/changes/working-copies/explore.md b/openspec/changes/working-copies/explore.md
new file mode 100644
index 0000000..3a65290
--- /dev/null
+++ b/openspec/changes/working-copies/explore.md
@@ -0,0 +1,103 @@
+# Explore: Working Copies
+
+## Problem Statement
+
+Currently, tool instances mount repositories directly. Each tool instance either:
+- **Mount mode**: Bind-mounts the shared repo path (`/data/repos/`) read-only
+- **Clone mode**: Clones the repo into the instance directory
+
+This has several problems:
+1. **Mount mode**: Read-only, so users can't edit files in the tool
+2. **Clone mode**: Creates a full copy per instance, wasting disk space
+3. **UI complexity**: The create-instance form must ask "mount or clone?" and handle branch selection
+4. **No persistence**: Clone-mode repos live inside the instance directory and are lost on delete
+5. **Race conditions**: Multiple instances mounting the same repo can conflict
+
+## Proposed Solution: Working Copies
+
+Introduce a **Workspace** as a first-class entity: a persistent, writable local clone of a repository that lives independently of any tool instance. Tool instances are then *started on* a working copy, which is mounted into the container.
+
+### Naming Candidates
+
+| Name | Pros | Cons |
+|---|---|---|
+| Workspace | Common in IDEs; implies a working area | Conflicts with existing docs/features/workspace.md |
+| **Workspace** | Common in IDEs (VS Code, JetBrains); implies a working area | May conflict with existing "workspace" terminology in docs |
+| **Checkout** | Git-native term; implies a working tree | Too specific to git; implies a single commit/branch |
+| **Sandbox** | Implies isolation and experimentation | Suggests throwaway/ephemeral, not persistent |
+| **Dev Copy** | Simple and descriptive | Informal; "copy" still implies duplication |
+| **Project Clone** | Clear relationship to project+repo | Clunky; two words |
+| **Branch** | Git-native; each working copy is effectively a branch workspace | Too git-specific; may confuse with git branches |
+
+**Decision: "Workspace"** — chosen by user despite existing docs/features/workspace.md. The existing workspace.md will be superseded/renamed to avoid confusion. — it's the most precise term. In SVN/Git parlance, a "working copy" is exactly what we want: a local, writable copy of a repository that you work on. The term is established enough that developers understand it, but not so overloaded in our domain that it conflicts.
+
+### Entity Model
+
+```
+Project
+ └── GitRepository (the canonical repo, read-only source)
+ └── WorkingCopy (writable local clone, 1+ per repo)
+ └── ToolInstance (mounts the working copy)
+```
+
+A Workspace:
+- Has a `name` (auto-generated or user-defined)
+- Has a `path` on disk (under `/data/working-copies//`)
+- Has a `branch` (the branch it's currently on)
+- Has a `status` (ready, syncing, error)
+- Belongs to a `GitRepository`
+- Belongs to a `User`
+- Has many `ToolInstance`s
+
+### User Flow
+
+1. User navigates to **Working Copies** in the sidebar
+2. Sees list of working copies (or creates one from a repo)
+3. Clicks "New Workspace" → selects repo + branch → named copy created
+4. From a working copy, clicks "Start Tool" → selects tool type → instance starts with working copy mounted
+5. Multiple tool instances can share the same working copy (e.g., terminal + code-server side by side)
+
+### Benefits
+
+1. **Writable by default**: Working copies are clones, so tools can edit files
+2. **Shared across instances**: Multiple tools can mount the same working copy
+3. **Persistent**: Survives instance deletion
+4. **Simplified UI**: No more "mount vs clone" decision; tool creation is just "pick a working copy"
+5. **Git operations**: Working copies can support git push/pull/branch from the UI
+6. **Disk efficient**: One clone per working copy, not one per instance
+
+### Open Questions
+
+1. Should working copies auto-sync with the canonical repo?
+2. Should we support multiple working copies per repo (e.g., one per branch)?
+3. How do we handle merge conflicts if the canonical repo changes?
+4. Should working copies be scoped to a user or to a project?
+5. What happens to tool instances when a working copy is deleted?
+6. Should we pre-create a default working copy when a repo is added?
+
+### Migration Path
+
+Existing tool instances that use clone_mode can be migrated:
+- On first access, extract the cloned repo from the instance directory
+- Move it to `/data/working-copies/...`
+- Create a WorkingCopy record pointing to it
+- Update the instance to mount the working copy path
+
+Mount-mode instances can be converted on restart:
+- Create a working copy from the canonical repo
+- Switch the instance to mount the working copy instead
+
+### Scope for This Change
+
+This change focuses on:
+- [ ] Creating the WorkingCopy entity and database table
+- [ ] Adding a Working Copies section to the UI (sidebar nav + list view)
+- [ ] Updating tool instance creation to select a working copy instead of repo+clone_mode
+- [ ] Updating compose generation to mount the working copy path
+- [ ] Migrating existing clone_mode instances to use working copies
+
+Out of scope (future changes):
+- [ ] Auto-sync with canonical repo
+- [ ] Git operations UI (push/pull/branch)
+- [ ] Working copy sharing between users
+- [ ] Pre-create default working copies
diff --git a/openspec/changes/working-copies/proposal.md b/openspec/changes/working-copies/proposal.md
new file mode 100644
index 0000000..fb6b655
--- /dev/null
+++ b/openspec/changes/working-copies/proposal.md
@@ -0,0 +1,132 @@
+# Proposal: Workspace-Based Tool Instances
+
+## Status
+
+| Field | Value |
+|---|---|
+| Phase | **Proposal** |
+| Based on | [Explore](explore.md) |
+| Next | Spec |
+
+## Decisions from Explore
+
+| Decision | Value |
+|---|---|
+| **Name** | "Workspace" (supersedes existing workspace.md) |
+| **Scope** | Unlimited workspaces per repository |
+| **Auto-create** | No — explicit creation only |
+| **Default branch** | Main/master or user-selected at creation time |
+| **Delete with running instances** | Allowed with confirmation; stops and deletes all associated tool instances |
+| **Name uniqueness** | Unique per project+repo (derived from project and repo names) |
+| **Deleted remote branch** | On sync/update, detect and ask for confirmation to delete local workspace/branch |
+
+## Problem Statement
+
+The current tool instance creation requires users to choose between "mount" (read-only) and "clone" (writable but ephemeral) modes. This is confusing and leads to either:
+- **Mount mode**: Tools open files read-only, frustrating editing
+- **Clone mode**: Each instance clones the repo, wasting disk space and losing work on deletion
+
+## Proposed Solution
+
+Introduce **Workspaces** as first-class entities: persistent, writable local clones of a repository that exist independently of tool instances. Users create workspaces explicitly, then start tool instances *on* a workspace.
+
+### Entity Relationship
+
+```
+Project
+ └── GitRepository (canonical source)
+ └── Workspace (writable clone, unlimited per repo)
+ └── ToolInstance (mounts workspace path)
+```
+
+### Key Behaviors
+
+1. **Workspace Creation**: User selects a repository → picks a branch → names the workspace → clone is created on disk
+2. **Tool Instance Creation**: User selects a workspace → picks a tool type → instance starts with workspace mounted
+3. **Multiple Tools per Workspace**: Several tool instances can share the same workspace (e.g., terminal + code-server)
+4. **Persistence**: Workspaces survive tool instance deletion
+5. **No Auto-Create**: Users must explicitly create workspaces; no magic default workspace
+
+### UI Changes
+
+- **New sidebar entry**: "Workspaces" (between "Projects" and "Settings")
+- **Workspaces page**: List of all workspaces with repo/branch/status info
+- **Create workspace flow**: Repo picker → branch picker → name input
+- **Start tool from workspace**: Tool picker modal from workspace card
+- **Simplified tool creation**: Remove "clone mode" / "mount mode" toggle; always use workspace
+
+### Database Changes
+
+New table: `workspaces`
+- `id` (UUID, PK)
+- `name` (string, user-defined)
+- `repo_id` (UUID, FK → git_repositories)
+- `user_id` (UUID, FK → users)
+- `branch` (string)
+- `path` (string, absolute disk path)
+- `status` (enum: ready, syncing, error)
+- `created_at`, `updated_at`
+
+Updated: `tool_instances`
+- Add `workspace_id` (UUID, FK → workspaces, nullable for migration)
+- Remove `clone_mode` (deprecated)
+- Remove `branch` (moved to workspace)
+
+### File System Layout
+
+```
+/data/working-copies/
+ └── {repo-id}/
+ └── {workspace-name}/
+ └── .git/
+ └── [repo files]
+```
+
+### Migration Strategy
+
+Existing `clone_mode` instances:
+- Extract cloned repo from instance directory
+- Move to `/data/working-copies/{repo-id}/{instance-name}/`
+- Create Workspace record
+- Update instance to reference workspace
+- Remove `clone_mode` flag
+
+Existing `mount_mode` instances:
+- On next start, create a workspace from the canonical repo
+- Switch instance to use workspace
+- Remove `clone_mode` flag
+
+### Out of Scope
+
+- Auto-sync with canonical repo
+- Git push/pull/branch UI
+- Workspace sharing between users
+- Pre-created default workspaces
+- Read-only workspace mode
+
+## Risks
+
+| Risk | Mitigation |
+|---|---|
+| Existing users with many clone_mode instances | One-time migration on instance restart |
+| Disk space from many workspaces | User-managed; can delete workspaces |
+| Workspace deleted while instances are running | Allowed with confirmation; cascade-delete tool instances |
+| Name collisions for workspace names | Unique per project+repo; derived from project and repo names |
+
+## Acceptance Criteria
+
+- [ ] User can create a workspace from any repository
+- [ ] User can create unlimited workspaces per repository
+- [ ] Tool instances mount the workspace path, not the canonical repo path
+- [ ] Multiple tool instances can share one workspace
+- [ ] Workspaces persist after tool instance deletion
+- [ ] Existing clone_mode instances migrate to workspace on restart
+- [ ] UI no longer shows "mount vs clone" toggle
+- [ ] New sidebar navigation "Workspaces" exists
+
+## Open Questions for Spec
+
+1. ~~Should workspace deletion cascade-delete associated tool instances, or block?~~ **Answered**: Allowed with confirmation; cascade-delete tool instances
+2. ~~Should workspace names be unique per-repo or globally unique?~~ **Answered**: Unique per project+repo; derived from project and repo names
+3. ~~How do we handle the case where a workspace's branch is deleted from the remote?~~ **Answered**: On sync/update, detect and ask for confirmation to delete local workspace/branch
+4. Should we validate the repo path exists before creating a workspace?
diff --git a/openspec/changes/working-copies/spec.md b/openspec/changes/working-copies/spec.md
new file mode 100644
index 0000000..3441757
--- /dev/null
+++ b/openspec/changes/working-copies/spec.md
@@ -0,0 +1,261 @@
+# Spec: Workspace-Based Tool Instances
+
+## Status
+
+| Field | Value |
+|---|---|
+| Phase | **Spec** |
+| Based on | [Proposal](proposal.md) |
+| Next | Design |
+
+## Overview
+
+Workspaces are persistent, writable local clones of Git repositories. Users create workspaces explicitly, then start tool instances on them. This replaces the current "mount vs clone" decision with a simple "pick a workspace" flow.
+
+## Decisions
+
+| Decision | Value |
+|---|---|
+| **Name** | "Workspace" |
+| **Scope** | Unlimited per repository |
+| **Auto-create** | No — explicit creation only |
+| **Delete with instances** | Allowed with confirmation; stops and deletes all associated tool instances |
+| **Name uniqueness** | Unique per project+repo; derived from project and repo names |
+| **Deleted remote branch** | On sync/update, detect and ask for confirmation to delete local workspace/branch |
+
+## Database Schema
+
+### New Table: `workspaces`
+
+```sql
+CREATE TABLE workspaces (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name VARCHAR(255) NOT NULL,
+ repo_id UUID NOT NULL REFERENCES git_repositories(id) ON DELETE CASCADE,
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ branch VARCHAR(255) NOT NULL DEFAULT 'main',
+ path VARCHAR(2048) NOT NULL,
+ status VARCHAR(16) NOT NULL DEFAULT 'ready',
+ last_sync_at TIMESTAMP WITH TIME ZONE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+
+ UNIQUE (repo_id, name)
+);
+
+CREATE INDEX idx_workspaces_repo_id ON workspaces(repo_id);
+CREATE INDEX idx_workspaces_user_id ON workspaces(user_id);
+CREATE INDEX idx_workspaces_status ON workspaces(status);
+```
+
+### Updated Table: `tool_instances`
+
+```sql
+ALTER TABLE tool_instances
+ ADD COLUMN workspace_id UUID REFERENCES workspaces(id) ON DELETE SET NULL,
+ ADD COLUMN clone_mode VARCHAR(16); -- deprecated, nullable for migration
+
+-- Drop existing clone_mode column after all instances are migrated
+-- ALTER TABLE tool_instances DROP COLUMN clone_mode;
+```
+
+Note: `tool_instances.branch` remains for now but is deprecated; the canonical branch lives on the workspace.
+
+## Backend API
+
+### Workspaces API
+
+```
+GET /projects/{project_id}/repositories/{repo_id}/workspaces
+ → List workspaces for a repository
+
+POST /projects/{project_id}/repositories/{repo_id}/workspaces
+ → Create a new workspace
+ Body: { name: string, branch: string }
+
+GET /projects/{project_id}/repositories/{repo_id}/workspaces/{workspace_id}
+ → Get workspace details
+
+PATCH /projects/{project_id}/repositories/{repo_id}/workspaces/{workspace_id}
+ → Update workspace (rename, change branch)
+ Body: { name?: string, branch?: string }
+
+DELETE /projects/{project_id}/repositories/{repo_id}/workspaces/{workspace_id}
+ → Delete workspace (with ?force=true to skip confirmation)
+ → Stops and deletes all associated tool instances
+
+POST /projects/{project_id}/repositories/{repo_id}/workspaces/{workspace_id}/sync
+ → Sync workspace with remote (detect deleted branches)
+```
+
+### Tool Instances API (Updated)
+
+```
+POST /projects/{project_id}/repositories/{repo_id}/instances
+ Body: { tool_type_id, workspace_id, display_name?, config_profile_id? }
+ → Create instance on workspace
+
+POST /projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/start
+ → Start instance (creates workspace if mount_mode, migrates if clone_mode)
+```
+
+### Instance Start Logic
+
+```python
+def start_instance(instance, workspace_id=None):
+ if instance.clone_mode == "clone":
+ # Migrate: extract clone to workspace
+ workspace = migrate_clone_to_workspace(instance)
+ instance.workspace_id = workspace.id
+ instance.clone_mode = None
+ elif instance.workspace_id:
+ # Already using a workspace
+ workspace = get_workspace(instance.workspace_id)
+ else:
+ # Legacy mount_mode: create workspace on first start
+ workspace = create_workspace_from_repo(instance.repo)
+ instance.workspace_id = workspace.id
+
+ # Mount workspace path into container
+ mount_path = workspace.path
+ # ... rest of start logic
+```
+
+## Frontend Routes
+
+```
+/workspaces → Workspaces list page
+/workspaces/new → Create workspace flow
+/workspaces/{id} → Workspace detail page
+/workspaces/{id}/tools → Start tool on workspace
+```
+
+## UI Components
+
+### Sidebar Navigation
+
+```
+Projects
+ └── [project list]
+Workspaces (NEW)
+ └── All Workspaces
+ └── [recent workspaces]
+Settings
+```
+
+### Workspaces Page
+
+- Grid/list of workspace cards
+- Each card shows: name, repo, branch, status, active instances count
+- Actions: Start Tool, Sync, Settings, Delete
+
+### Create Workspace Flow
+
+1. **Repo picker**: Select from existing repositories
+2. **Branch picker**: Select branch (default: repo's default branch)
+3. **Name input**: Auto-suggested as `{project-name}-{repo-name}-{branch}` but editable
+4. **Create**: Clone repo to `/data/working-copies/{repo-id}/{name}/`
+
+### Start Tool from Workspace
+
+1. **Tool picker**: Select tool type
+2. **Config**: Optional config profile
+3. **Create**: Instance created with workspace mounted
+
+## File System Layout
+
+```
+/data/working-copies/
+ └── {repo-id}/
+ └── {workspace-name}/
+ └── .git/
+ └── [repo files]
+```
+
+## Workspace Lifecycle
+
+### Creation
+
+1. Validate name uniqueness per repo
+2. Clone repo: `git clone --branch {branch} {remote_url} {path}`
+3. Set status to `ready`
+4. Return workspace record
+
+### Deletion
+
+1. Check for running tool instances
+2. If instances exist and no `?force=true`:
+ - Return 409 Conflict with `{ instances: [...] }`
+ - Frontend shows confirmation dialog
+3. If confirmed:
+ - Stop all associated instances
+ - Delete all associated instances
+ - Delete workspace directory
+ - Delete workspace record
+
+### Sync
+
+1. Fetch from remote: `git fetch origin`
+2. Check if workspace branch still exists on remote
+3. If branch deleted:
+ - Return 409 with `{ branch_deleted: true }`
+ - Frontend asks: "Branch '{branch}' was deleted. Delete this workspace?"
+4. If branch exists:
+ - Pull changes: `git pull origin {branch}`
+ - Update `last_sync_at`
+
+## Migration Strategy
+
+### Existing clone_mode Instances
+
+```python
+def migrate_clone_to_workspace(instance):
+ # Find the cloned repo inside the instance directory
+ clone_path = find_clone_in_instance_dir(instance)
+
+ # Create workspace
+ workspace = Workspace(
+ name=f"{instance.name}-migrated",
+ repo_id=instance.repository_id,
+ user_id=instance.owner_id,
+ branch=instance.branch or "main",
+ path=f"/data/working-copies/{instance.repository_id}/{instance.name}-migrated",
+ )
+
+ # Move clone to workspace path
+ move(clone_path, workspace.path)
+
+ return workspace
+```
+
+### Existing mount_mode Instances
+
+On first start after deployment:
+1. Create workspace from canonical repo
+2. Update instance to use workspace
+3. Remove clone_mode flag
+
+## Acceptance Criteria
+
+- [ ] Database migration creates `workspaces` table
+- [ ] Database migration adds `workspace_id` to `tool_instances`
+- [ ] API endpoints for CRUD operations on workspaces
+- [ ] Workspace creation clones repo to `/data/working-copies/...`
+- [ ] Workspace deletion stops and deletes associated tool instances
+- [ ] Workspace sync detects deleted branches and asks for confirmation
+- [ ] Tool instance creation accepts `workspace_id` instead of `clone_mode`
+- [ ] Tool instance start mounts workspace path into container
+- [ ] Frontend has "Workspaces" sidebar entry
+- [ ] Frontend workspaces list page
+- [ ] Frontend create workspace flow
+- [ ] Frontend start tool from workspace
+- [ ] Existing clone_mode instances migrate on restart
+- [ ] Existing mount_mode instances create workspace on restart
+
+## Quality Gates
+
+- [ ] Backend tests: workspace CRUD, sync, deletion with instances
+- [ ] Frontend tests: workspace list, create, start tool
+- [ ] Integration tests: instance creation with workspace
+- [ ] ruff clean
+- [ ] TypeScript compilation clean
diff --git a/openspec/changes/working-copies/tasks.md b/openspec/changes/working-copies/tasks.md
new file mode 100644
index 0000000..81ced4e
--- /dev/null
+++ b/openspec/changes/working-copies/tasks.md
@@ -0,0 +1,138 @@
+# Tasks: Workspace-Based Tool Instances
+
+## Status
+
+| Field | Value |
+|---|---|
+| Phase | **Tasks** |
+| Based on | [Design](design.md) |
+| Next | Apply |
+
+## PR Breakdown
+
+### PR-1: Backend Foundation
+**Scope**: Database migration, models, services, API endpoints for workspaces
+**Est. lines**: ~800 backend, ~300 tests
+**Files touched**: 8 new, 2 modified
+
+**Tasks**:
+1. [ ] Create Alembic migration for `workspaces` table + `workspace_id` on `tool_instances`
+2. [ ] Create `Workspace` model (`apps/api/src/models/workspace.py`)
+3. [ ] Add `workspace_id` to `ToolInstance` model (nullable FK)
+4. [ ] Create `GitService` (`apps/api/src/services/git_service.py`) — clone, fetch, pull, branch_exists_remotely
+5. [ ] Create `WorkspaceManager` (`apps/api/src/services/workspace_manager.py`) — create, delete, sync
+6. [ ] Create workspace API router (`apps/api/src/api/workspaces.py`) — CRUD + sync endpoints
+7. [ ] Add workspace routes to FastAPI app (`apps/api/src/main.py`)
+8. [ ] Write unit tests for GitService
+9. [ ] Write integration tests for workspace CRUD
+10. [ ] Write integration tests for delete-with-instances (409 behavior)
+11. [ ] Write integration tests for sync-with-deleted-branch (409 behavior)
+
+### PR-2: Backend Integration
+**Scope**: Tool instance creation/start uses workspace instead of repo path
+**Est. lines**: ~400 backend, ~200 tests
+**Files touched**: 3 modified
+
+**Tasks**:
+1. [ ] Update `create_instance` endpoint to accept `workspace_id` instead of `clone_mode`
+2. [ ] Update `start_instance` to mount workspace path (`workspace.path`) instead of repo path
+3. [ ] Update compose generation to use `WORKSPACE_PATH` variable
+4. [ ] Update `tool_instances.py` compose template rendering
+5. [ ] Write integration tests for instance creation with workspace
+6. [ ] Write integration tests for instance start with workspace mount
+7. [ ] Verify old mount_mode instances still work (backward compat)
+
+### PR-3: Frontend Core
+**Scope**: Workspaces UI — list, create, card, actions
+**Est. lines**: ~1,200 frontend, ~400 tests
+**Files touched**: 10 new, 2 modified
+
+**Tasks**:
+1. [ ] Create workspace types (`apps/web/src/types/workspace.ts`)
+2. [ ] Create workspace API client (`apps/web/src/api/workspaces.ts`)
+3. [ ] Create `useWorkspaces` hook (`apps/web/src/hooks/use-workspaces.ts`)
+4. [ ] Create `useWorkspaceActions` hook (`apps/web/src/hooks/use-workspace-actions.ts`)
+5. [ ] Create `WorkspaceCard` component (`apps/web/src/components/workspace-card.tsx`)
+6. [ ] Create `WorkspaceCreateForm` component (`apps/web/src/components/workspace-create-form.tsx`)
+7. [ ] Create `StartToolModal` component (`apps/web/src/components/start-tool-modal.tsx`)
+8. [ ] Create `WorkspacesPage` (`apps/web/src/pages/workspaces.tsx`)
+9. [ ] Update `Sidebar` to add Workspaces nav item
+10. [ ] Update router/routes to include `/workspaces`
+11. [ ] Write component tests for WorkspaceCard
+12. [ ] Write hook tests for useWorkspaceActions
+13. [ ] Write tests for create form validation
+
+### PR-4: Frontend Integration
+**Scope**: Update existing flows to use workspaces, dashboard integration
+**Est. lines**: ~600 frontend, ~200 tests
+**Files touched**: 5 modified
+
+**Tasks**:
+1. [ ] Update `CreateSessionForm` to use workspace picker instead of repo+clone_mode
+2. [ ] Update `SessionsPage` dashboard to show workspaces section
+3. [ ] Update `SessionCard` to show workspace name instead of clone mode
+4. [ ] Update `useInstanceActions` to pass `workspace_id` on create
+5. [ ] Remove clone_mode/mount_mode UI toggles
+6. [ ] Update types to remove deprecated `clone_mode` field
+7. [ ] Write integration tests for full create-workspace → start-tool flow
+8. [ ] Write tests for dashboard workspaces section
+
+## Acceptance Criteria (All PRs)
+
+- [ ] User can create a workspace from any repository
+- [ ] User can create unlimited workspaces per repository
+- [ ] Workspace names are unique per repo
+- [ ] Tool instances mount the workspace path
+- [ ] Multiple tool instances can share one workspace
+- [ ] Workspaces persist after tool instance deletion
+- [ ] Deleting a workspace with running instances shows confirmation, stops and deletes instances
+- [ ] Syncing a workspace with a deleted remote branch shows confirmation
+- [ ] UI no longer shows "mount vs clone" toggle
+- [ ] New sidebar navigation "Workspaces" exists
+- [ ] All existing tests still pass
+- [ ] ruff clean
+- [ ] TypeScript compilation clean
+
+## Implementation Order
+
+```
+PR-1 (Backend Foundation)
+ → PR-2 (Backend Integration)
+ → PR-3 (Frontend Core)
+ → PR-4 (Frontend Integration)
+```
+
+Each PR depends on the previous. No parallel work.
+
+## Verification Steps per PR
+
+### PR-1
+```bash
+cd apps/api
+alembic upgrade head
+pytest tests/unit/test_git_service.py tests/integration/test_workspaces.py -v
+python -m ruff check src/services/git_service.py src/services/workspace_manager.py src/api/workspaces.py
+```
+
+### PR-2
+```bash
+cd apps/api
+pytest tests/integration/test_tool_instances_with_workspace.py -v
+python -m ruff check src/api/tool_instances.py
+```
+
+### PR-3
+```bash
+cd apps/web
+npm run test -- --run workspaces
+npx tsc --noEmit
+npx eslint src/pages/workspaces.tsx src/components/workspace-*.tsx
+```
+
+### PR-4
+```bash
+cd apps/web
+npm run test -- --run sessions create-session
+npx tsc --noEmit
+npx eslint src/pages/sessions.tsx src/components/create-session-form.tsx
+```
From 47b1af8e92f3a14a6604941cca346a7f85b00314 Mon Sep 17 00:00:00 2001
From: Alex Blank
Date: Sun, 31 May 2026 23:10:45 +0200
Subject: [PATCH 15/44] feat: workspace backend integration (PR-2)
- Add workspace_id to CreateInstanceRequest (optional, replaces clone_mode)
- create_instance: resolve workspace, validate repo ownership, use workspace.path
- create_instance: store workspace_id on ToolInstance record
- start_instance: use workspace.path when workspace_id is set (manifest + legacy flows)
- Skip SSH key mount for clone mode when workspace is used
- Backward compatible: clone_mode still works when workspace_id is absent
---
apps/api/src/api/tool_instances.py | 60 ++++++++++++++++++++++++------
1 file changed, 49 insertions(+), 11 deletions(-)
diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py
index 92a0cf1..7aa4908 100644
--- a/apps/api/src/api/tool_instances.py
+++ b/apps/api/src/api/tool_instances.py
@@ -427,6 +427,9 @@ class CreateInstanceRequest(BaseModel):
display_name: str | None = Field(
default=None, description="Optional display name for the instance"
)
+ workspace_id: str | None = Field(
+ default=None, description="UUID of workspace to mount (replaces clone_mode)"
+ )
clone_mode: str = Field(
default="mount", description="Repository access mode: 'mount' or 'clone'"
)
@@ -849,9 +852,34 @@ async def create_instance(
session, data.config_profile_id, user_id, project_id, tool_type_id
)
+ # Resolve workspace if provided
+ workspace = None
+ workspace_id = None
+ if data.workspace_id:
+ from src.models.workspace import Workspace as WorkspaceModel
+
+ try:
+ workspace_id = uuid.UUID(data.workspace_id)
+ except ValueError:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid workspace_id format",
+ )
+ workspace = await session.get(WorkspaceModel, workspace_id)
+ if workspace is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="workspace not found",
+ )
+ if workspace.repo_id != repo_id:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="workspace does not belong to this repository",
+ )
+
try:
- # Validate clone mode requirements
- if data.clone_mode == "clone":
+ # Validate clone mode requirements (legacy path)
+ if data.clone_mode == "clone" and not workspace:
if not repo.remote_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -876,8 +904,10 @@ async def create_instance(
# Find free port
tool_port = find_free_port()
- # Determine repo path based on clone mode
- if data.clone_mode == "clone":
+ # Determine repo path based on workspace or clone mode
+ if workspace:
+ repo_path = workspace.path
+ elif data.clone_mode == "clone":
# Get SSH key for cloning
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
if ssh_key is None:
@@ -1143,6 +1173,7 @@ services:
status="pending",
compose_path=compose_path,
port=tool_port,
+ workspace_id=workspace_id,
clone_mode=data.clone_mode,
branch=data.new_branch
if data.new_branch
@@ -1649,11 +1680,18 @@ async def start_instance(
if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id:
logger.info("Using manifest-based startup for instance %s", instance.id)
- # Determine repo path
- repo = await session.get(GitRepository, instance.repository_id)
- repo_path = repo.path if repo else ""
- if instance.clone_mode == "clone":
- repo_path = os.path.join(instance_dir, "repo-clone")
+ # Determine repo path (workspace takes precedence)
+ repo_path = ""
+ if instance.workspace_id:
+ from src.models.workspace import Workspace as WorkspaceModel
+ workspace = await session.get(WorkspaceModel, instance.workspace_id)
+ if workspace:
+ repo_path = workspace.path
+ else:
+ repo = await session.get(GitRepository, instance.repository_id)
+ repo_path = repo.path if repo else ""
+ if instance.clone_mode == "clone":
+ repo_path = os.path.join(instance_dir, "repo-clone")
try:
(
@@ -1686,8 +1724,8 @@ async def start_instance(
)
else:
# ── LEGACY FLOW ──────────────────────────────────────────
- # Mount SSH key for clone-mode instances
- if instance.clone_mode == "clone":
+ # Mount SSH key for clone-mode instances (skip for workspace-based)
+ if instance.clone_mode == "clone" and not instance.workspace_id:
repo = await session.get(GitRepository, instance.repository_id)
if repo and repo.ssh_key_id:
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
From 986091ac56d91ca6c1ff520ec71b0edecd730bbe Mon Sep 17 00:00:00 2001
From: Alex Blank
Date: Sun, 31 May 2026 23:27:10 +0200
Subject: [PATCH 16/44] feat: workspace frontend core (PR-3)
- Workspace types, API client, hooks (useWorkspaces, useWorkspaceActions)
- WorkspaceCard, WorkspaceCreateForm, StartToolModal components
- WorkspacesPage with list, create, sync, delete, start-tool flow
- Sidebar navigation: new 'Workspaces' entry
- Router: /workspaces route
- TypeScript + eslint clean
---
apps/web/src/api/workspaces.ts | 65 ++++++++
apps/web/src/components/app-shell.tsx | 1 +
apps/web/src/components/start-tool-modal.tsx | 87 +++++++++++
apps/web/src/components/workspace-card.tsx | 73 +++++++++
.../src/components/workspace-create-form.tsx | 82 ++++++++++
apps/web/src/hooks/use-workspace-actions.ts | 146 ++++++++++++++++++
apps/web/src/hooks/use-workspaces.ts | 37 +++++
apps/web/src/pages/workspaces.tsx | 102 ++++++++++++
apps/web/src/router.tsx | 2 +
apps/web/src/types/workspace.ts | 28 ++++
10 files changed, 623 insertions(+)
create mode 100644 apps/web/src/api/workspaces.ts
create mode 100644 apps/web/src/components/start-tool-modal.tsx
create mode 100644 apps/web/src/components/workspace-card.tsx
create mode 100644 apps/web/src/components/workspace-create-form.tsx
create mode 100644 apps/web/src/hooks/use-workspace-actions.ts
create mode 100644 apps/web/src/hooks/use-workspaces.ts
create mode 100644 apps/web/src/pages/workspaces.tsx
create mode 100644 apps/web/src/types/workspace.ts
diff --git a/apps/web/src/api/workspaces.ts b/apps/web/src/api/workspaces.ts
new file mode 100644
index 0000000..cba8972
--- /dev/null
+++ b/apps/web/src/api/workspaces.ts
@@ -0,0 +1,65 @@
+/** Workspace API client. */
+
+import { apiClient } from "./client";
+import type { Workspace, CreateWorkspaceRequest, SyncResult } from "../types/workspace";
+
+function workspaceUrl(projectId: string, repoId: string, workspaceId?: string) {
+ const base = `/projects/${projectId}/repositories/${repoId}/workspaces`;
+ return workspaceId ? `${base}/${workspaceId}` : base;
+}
+
+export async function listWorkspaces(projectId: string, repoId: string): Promise {
+ const response = await apiClient.get(workspaceUrl(projectId, repoId));
+ return response.data;
+}
+
+export async function createWorkspace(
+ projectId: string,
+ repoId: string,
+ data: CreateWorkspaceRequest,
+): Promise {
+ const response = await apiClient.post(workspaceUrl(projectId, repoId), data);
+ return response.data;
+}
+
+export async function getWorkspace(
+ projectId: string,
+ repoId: string,
+ workspaceId: string,
+): Promise {
+ const response = await apiClient.get(workspaceUrl(projectId, repoId, workspaceId));
+ return response.data;
+}
+
+export async function updateWorkspace(
+ projectId: string,
+ repoId: string,
+ workspaceId: string,
+ data: Partial,
+): Promise {
+ const response = await apiClient.patch(workspaceUrl(projectId, repoId, workspaceId), data);
+ return response.data;
+}
+
+export async function deleteWorkspace(
+ projectId: string,
+ repoId: string,
+ workspaceId: string,
+ force = false,
+): Promise<{ status: string }> {
+ const response = await apiClient.delete<{ status: string }>(
+ `${workspaceUrl(projectId, repoId, workspaceId)}?force=${force}`,
+ );
+ return response.data;
+}
+
+export async function syncWorkspace(
+ projectId: string,
+ repoId: string,
+ workspaceId: string,
+): Promise {
+ const response = await apiClient.post(
+ `${workspaceUrl(projectId, repoId, workspaceId)}/sync`,
+ );
+ return response.data;
+}
diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx
index a76cb0a..3d924b6 100644
--- a/apps/web/src/components/app-shell.tsx
+++ b/apps/web/src/components/app-shell.tsx
@@ -24,6 +24,7 @@ const NAV_ITEMS: {
}[] = [
{ to: "/", label: "Home", icon: "dashboard" },
{ to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" },
+ { to: "/workspaces", label: "Workspaces", icon: "folder" },
{ to: "/projects", label: "Projects", icon: "projects" },
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
{ to: "/config-profiles", label: "Config Profiles", icon: "folder" },
diff --git a/apps/web/src/components/start-tool-modal.tsx b/apps/web/src/components/start-tool-modal.tsx
new file mode 100644
index 0000000..02a7f0b
--- /dev/null
+++ b/apps/web/src/components/start-tool-modal.tsx
@@ -0,0 +1,87 @@
+/** Modal for starting a tool on a workspace. */
+
+import { useState } from "react";
+import { Icon } from "./icon";
+import type { Workspace } from "../types/workspace";
+
+export interface StartToolModalProps {
+ workspace: Workspace;
+ onClose: () => void;
+ onStart: (toolTypeId: string, configProfileId?: string) => Promise;
+}
+
+export function StartToolModal({ workspace, onClose, onStart }: StartToolModalProps) {
+ const [toolTypeId, setToolTypeId] = useState("");
+ const [configProfileId, setConfigProfileId] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!toolTypeId) {
+ setError("Please select a tool type");
+ return;
+ }
+ setSubmitting(true);
+ setError(null);
+ try {
+ await onStart(toolTypeId, configProfileId || undefined);
+ onClose();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to start tool");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
+ Start Tool on {workspace.name}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/workspace-card.tsx b/apps/web/src/components/workspace-card.tsx
new file mode 100644
index 0000000..dd0ad4c
--- /dev/null
+++ b/apps/web/src/components/workspace-card.tsx
@@ -0,0 +1,73 @@
+/** Card component for displaying a workspace. */
+
+import { Icon } from "./icon";
+import type { Workspace } from "../types/workspace";
+
+export interface WorkspaceCardProps {
+ workspace: Workspace;
+ loading?: boolean;
+ onStartTool: (workspace: Workspace) => void;
+ onSync: (workspace: Workspace) => void;
+ onDelete: (workspace: Workspace) => void;
+}
+
+export function WorkspaceCard({
+ workspace,
+ loading = false,
+ onStartTool,
+ onSync,
+ onDelete,
+}: WorkspaceCardProps) {
+ const statusClass =
+ workspace.status === "ready"
+ ? "status-ready"
+ : workspace.status === "syncing"
+ ? "status-syncing"
+ : "status-error";
+
+ return (
+
+
+
{workspace.name}
+ {workspace.status}
+
+
+
+ {workspace.project_name} / {workspace.repo_name}
+
+
+ {workspace.branch}
+
+ {workspace.instance_count > 0 && (
+
+ {workspace.instance_count} active tool
+ {workspace.instance_count > 1 ? "s" : ""}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/workspace-create-form.tsx b/apps/web/src/components/workspace-create-form.tsx
new file mode 100644
index 0000000..f2c9631
--- /dev/null
+++ b/apps/web/src/components/workspace-create-form.tsx
@@ -0,0 +1,82 @@
+/** Form for creating a new workspace. */
+
+import { useState } from "react";
+import { Icon } from "./icon";
+import type { CreateWorkspaceRequest } from "../types/workspace";
+
+export interface WorkspaceCreateFormProps {
+ projectId: string;
+ repoId: string;
+ defaultBranch?: string;
+ onSubmit: (data: CreateWorkspaceRequest) => Promise;
+ onCancel: () => void;
+}
+
+export function WorkspaceCreateForm({
+ defaultBranch = "main",
+ onSubmit,
+ onCancel,
+}: WorkspaceCreateFormProps) {
+ const [name, setName] = useState("");
+ const [branch, setBranch] = useState(defaultBranch);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!name.trim()) {
+ setError("Workspace name is required");
+ return;
+ }
+ setSubmitting(true);
+ setError(null);
+ try {
+ await onSubmit({ name: name.trim(), branch: branch.trim() });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to create workspace");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/hooks/use-workspace-actions.ts b/apps/web/src/hooks/use-workspace-actions.ts
new file mode 100644
index 0000000..594b7a2
--- /dev/null
+++ b/apps/web/src/hooks/use-workspace-actions.ts
@@ -0,0 +1,146 @@
+/** Hook for workspace CRUD actions with confirmation handling. */
+
+import { useState, useCallback } from "react";
+import {
+ createWorkspace,
+ deleteWorkspace,
+ syncWorkspace,
+ updateWorkspace,
+} from "../api/workspaces";
+import type { Workspace, CreateWorkspaceRequest } from "../types/workspace";
+
+export interface UseWorkspaceActionsResult {
+ loadingId: string | null;
+ create: (
+ projectId: string,
+ repoId: string,
+ data: CreateWorkspaceRequest,
+ ) => Promise;
+ delete: (
+ projectId: string,
+ repoId: string,
+ workspace: Workspace,
+ onRefresh: () => Promise,
+ ) => Promise;
+ sync: (
+ projectId: string,
+ repoId: string,
+ workspace: Workspace,
+ onRefresh: () => Promise,
+ ) => Promise;
+ update: (
+ projectId: string,
+ repoId: string,
+ workspaceId: string,
+ data: Partial,
+ ) => Promise;
+}
+
+interface ApiError {
+ response?: {
+ status?: number;
+ data?: {
+ detail?: {
+ message?: string;
+ instances?: Array<{ id: string; name: string }>;
+ branch_deleted?: boolean;
+ };
+ };
+ };
+}
+
+export function useWorkspaceActions(): UseWorkspaceActionsResult {
+ const [loadingId, setLoadingId] = useState(null);
+
+ const create = useCallback(
+ async (projectId: string, repoId: string, data: CreateWorkspaceRequest) => {
+ return createWorkspace(projectId, repoId, data);
+ },
+ [],
+ );
+
+ const deleteAction = useCallback(
+ async (
+ projectId: string,
+ repoId: string,
+ workspace: Workspace,
+ onRefresh: () => Promise,
+ ) => {
+ setLoadingId(workspace.id);
+ try {
+ await deleteWorkspace(projectId, repoId, workspace.id);
+ await onRefresh();
+ } catch (err) {
+ const error = err as ApiError;
+ if (error.response?.status === 409) {
+ const detail = error.response.data?.detail;
+ const instances = detail?.instances || [];
+ const confirmed = window.confirm(
+ `This workspace has ${instances.length} running tool instance(s):\n` +
+ instances.map((i) => `- ${i.name}`).join("\n") +
+ `\n\nDelete workspace and all instances?`,
+ );
+ if (confirmed) {
+ await deleteWorkspace(projectId, repoId, workspace.id, true);
+ await onRefresh();
+ }
+ } else {
+ throw err;
+ }
+ } finally {
+ setLoadingId(null);
+ }
+ },
+ [],
+ );
+
+ const sync = useCallback(
+ async (
+ projectId: string,
+ repoId: string,
+ workspace: Workspace,
+ onRefresh: () => Promise,
+ ) => {
+ setLoadingId(workspace.id);
+ try {
+ await syncWorkspace(projectId, repoId, workspace.id);
+ await onRefresh();
+ } catch (err) {
+ const error = err as ApiError;
+ if (error.response?.status === 409 && error.response.data?.detail?.branch_deleted) {
+ const message = error.response.data.detail.message || "Branch was deleted from remote";
+ const confirmed = window.confirm(`${message}\n\nDelete this workspace?`);
+ if (confirmed) {
+ await deleteWorkspace(projectId, repoId, workspace.id, true);
+ await onRefresh();
+ }
+ } else {
+ throw err;
+ }
+ } finally {
+ setLoadingId(null);
+ }
+ },
+ [],
+ );
+
+ const update = useCallback(
+ async (
+ projectId: string,
+ repoId: string,
+ workspaceId: string,
+ data: Partial,
+ ) => {
+ return updateWorkspace(projectId, repoId, workspaceId, data);
+ },
+ [],
+ );
+
+ return {
+ loadingId,
+ create,
+ delete: deleteAction,
+ sync,
+ update,
+ };
+}
diff --git a/apps/web/src/hooks/use-workspaces.ts b/apps/web/src/hooks/use-workspaces.ts
new file mode 100644
index 0000000..65691c6
--- /dev/null
+++ b/apps/web/src/hooks/use-workspaces.ts
@@ -0,0 +1,37 @@
+/** Hook for fetching workspaces. */
+
+import { useCallback, useEffect, useState } from "react";
+import { listWorkspaces } from "../api/workspaces";
+import type { Workspace } from "../types/workspace";
+
+export interface UseWorkspacesResult {
+ workspaces: Workspace[];
+ loading: boolean;
+ error: string | null;
+ refresh: () => Promise;
+}
+
+export function useWorkspaces(projectId: string, repoId: string): UseWorkspacesResult {
+ const [workspaces, setWorkspaces] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const data = await listWorkspaces(projectId, repoId);
+ setWorkspaces(data);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load workspaces");
+ } finally {
+ setLoading(false);
+ }
+ }, [projectId, repoId]);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ return { workspaces, loading, error, refresh };
+}
diff --git a/apps/web/src/pages/workspaces.tsx b/apps/web/src/pages/workspaces.tsx
new file mode 100644
index 0000000..d055323
--- /dev/null
+++ b/apps/web/src/pages/workspaces.tsx
@@ -0,0 +1,102 @@
+/** Workspaces list page. */
+
+import { useState } from "react";
+import { Icon } from "../components/icon";
+import { useWorkspaces } from "../hooks/use-workspaces";
+import { useWorkspaceActions } from "../hooks/use-workspace-actions";
+import { WorkspaceCard } from "../components/workspace-card";
+import { WorkspaceCreateForm } from "../components/workspace-create-form";
+import { StartToolModal } from "../components/start-tool-modal";
+import type { Workspace } from "../types/workspace";
+
+export function WorkspacesPage() {
+ const [showCreate, setShowCreate] = useState(false);
+ const [startWorkspace, setStartWorkspace] = useState(null);
+
+ // TODO: Get projectId and repoId from URL params or context
+ const projectId = "default-project";
+ const repoId = "default-repo";
+
+ const { workspaces, loading, error, refresh } = useWorkspaces(projectId, repoId);
+ const actions = useWorkspaceActions();
+
+ const handleCreate = async (data: { name: string; branch: string }) => {
+ await actions.create(projectId, repoId, data);
+ setShowCreate(false);
+ await refresh();
+ };
+
+ const handleDelete = async (workspace: Workspace) => {
+ await actions.delete(projectId, repoId, workspace, refresh);
+ };
+
+ const handleSync = async (workspace: Workspace) => {
+ await actions.sync(projectId, repoId, workspace, refresh);
+ };
+
+ const handleStartTool = async (toolTypeId: string, configProfileId?: string) => {
+ if (!startWorkspace) return;
+ // TODO: Call instance creation API with workspace_id
+ console.log("Start tool", { toolTypeId, configProfileId, workspace: startWorkspace.id });
+ setStartWorkspace(null);
+ };
+
+ return (
+
+
+
+ {error &&
{error}
}
+
+ {showCreate && (
+
setShowCreate(false)}
+ />
+ )}
+
+ {loading && workspaces.length === 0 ? (
+ Loading workspaces...
+ ) : workspaces.length === 0 ? (
+
+
No workspaces yet.
+
+
+ ) : (
+
+ {workspaces.map((ws) => (
+
+ ))}
+
+ )}
+
+ {startWorkspace && (
+ setStartWorkspace(null)}
+ onStart={handleStartTool}
+ />
+ )}
+
+ );
+}
diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx
index 2debf3a..2b292b1 100644
--- a/apps/web/src/router.tsx
+++ b/apps/web/src/router.tsx
@@ -16,6 +16,7 @@ import { ToolWorkshopPage } from "./pages/tool-workshop";
import { SSHKeysPage } from "./pages/ssh-keys";
import { ConfigProfilesPage } from "./pages/config-profiles";
import { SessionsPage } from "./pages/sessions";
+import { WorkspacesPage } from "./pages/workspaces";
export const AppRouter = () => {
return (
@@ -45,6 +46,7 @@ export const AppRouter = () => {
} />
} />
+ } />
} />
} />
diff --git a/apps/web/src/types/workspace.ts b/apps/web/src/types/workspace.ts
new file mode 100644
index 0000000..1aaf5f1
--- /dev/null
+++ b/apps/web/src/types/workspace.ts
@@ -0,0 +1,28 @@
+/** Types for the workspace feature. */
+
+export interface Workspace {
+ id: string;
+ name: string;
+ repo_id: string;
+ repo_name: string;
+ project_name: string;
+ user_id: string;
+ branch: string;
+ path: string;
+ status: "ready" | "syncing" | "error";
+ last_sync_at: string | null;
+ created_at: string;
+ updated_at: string;
+ instance_count: number;
+}
+
+export interface CreateWorkspaceRequest {
+ name: string;
+ branch: string;
+}
+
+export interface SyncResult {
+ branch_deleted: boolean;
+ pulled: boolean;
+ last_sync_at: string | null;
+}
From 5bba2bbd92129e142122e5da4e0b8492deaba536 Mon Sep 17 00:00:00 2001
From: Alex Blank
Date: Sun, 31 May 2026 23:30:09 +0200
Subject: [PATCH 17/44] feat: workspace frontend integration (PR-4)
- Add workspace_id parameter to createInstance API client
- Wire WorkspacesPage 'Start Tool' modal to createInstance + startInstance
- Pass workspace_id when creating instance from workspace page
- TypeScript + eslint clean
---
apps/web/src/api/sessions.ts | 4 +++-
apps/web/src/pages/workspaces.tsx | 23 ++++++++++++++++++++---
2 files changed, 23 insertions(+), 4 deletions(-)
diff --git a/apps/web/src/api/sessions.ts b/apps/web/src/api/sessions.ts
index 7bede1c..0bc13e8 100644
--- a/apps/web/src/api/sessions.ts
+++ b/apps/web/src/api/sessions.ts
@@ -54,13 +54,15 @@ export async function createInstance(
branch?: string,
newBranch?: string,
configProfileId?: string,
- sshKeyIds?: string[]
+ sshKeyIds?: string[],
+ workspaceId?: string
): Promise {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances`,
{
tool_type_id: toolTypeId,
display_name: displayName,
+ workspace_id: workspaceId || undefined,
clone_mode: cloneMode || "mount",
branch: branch || undefined,
new_branch: newBranch || undefined,
diff --git a/apps/web/src/pages/workspaces.tsx b/apps/web/src/pages/workspaces.tsx
index d055323..92f36a0 100644
--- a/apps/web/src/pages/workspaces.tsx
+++ b/apps/web/src/pages/workspaces.tsx
@@ -7,6 +7,7 @@ import { useWorkspaceActions } from "../hooks/use-workspace-actions";
import { WorkspaceCard } from "../components/workspace-card";
import { WorkspaceCreateForm } from "../components/workspace-create-form";
import { StartToolModal } from "../components/start-tool-modal";
+import { createInstance, startInstance } from "../api/sessions";
import type { Workspace } from "../types/workspace";
export function WorkspacesPage() {
@@ -36,9 +37,25 @@ export function WorkspacesPage() {
const handleStartTool = async (toolTypeId: string, configProfileId?: string) => {
if (!startWorkspace) return;
- // TODO: Call instance creation API with workspace_id
- console.log("Start tool", { toolTypeId, configProfileId, workspace: startWorkspace.id });
- setStartWorkspace(null);
+ try {
+ const instance = await createInstance(
+ projectId,
+ repoId,
+ toolTypeId,
+ `${startWorkspace.name} - ${toolTypeId}`,
+ undefined,
+ undefined,
+ undefined,
+ configProfileId,
+ [],
+ startWorkspace.id
+ );
+ await startInstance(projectId, repoId, instance.id, configProfileId);
+ setStartWorkspace(null);
+ await refresh();
+ } catch (err) {
+ alert(err instanceof Error ? err.message : "Failed to start tool");
+ }
};
return (
From a5d64d1859d4b947f0e44bd2d57f467c08a8d953 Mon Sep 17 00:00:00 2001
From: Alex Blank
Date: Sun, 31 May 2026 23:41:45 +0200
Subject: [PATCH 18/44] fix: add from __future__ import annotations to
workspace_manager.py
Fixes NameError: ToolInstance not defined at runtime because
type annotations are evaluated at class definition time.
Deferring annotation evaluation with __future__ annotations
keeps TYPE_CHECKING imports from causing runtime crashes.
Also includes ruff formatting cleanup on workspace-related files.
---
apps/api/src/api/tool_instances.py | 1 +
apps/api/src/api/workspaces.py | 8 +-
apps/api/src/services/workspace_manager.py | 6 +-
.../tests/integration/test_workspaces_api.py | 59 +++-
apps/api/tests/unit/test_git_service.py | 4 +-
apps/web/src/api/sessions.ts | 294 +++++++++---------
apps/web/src/api/workspaces.ts | 91 +++---
apps/web/src/components/start-tool-modal.tsx | 163 +++++-----
apps/web/src/components/workspace-card.tsx | 124 ++++----
.../src/components/workspace-create-form.tsx | 143 +++++----
apps/web/src/hooks/use-workspace-actions.ts | 259 +++++++--------
apps/web/src/hooks/use-workspaces.ts | 53 ++--
apps/web/src/pages/workspaces.tsx | 204 ++++++------
apps/web/src/router.tsx | 85 ++---
apps/web/src/types/workspace.ts | 36 +--
15 files changed, 831 insertions(+), 699 deletions(-)
diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py
index 7aa4908..b86dca4 100644
--- a/apps/api/src/api/tool_instances.py
+++ b/apps/api/src/api/tool_instances.py
@@ -1684,6 +1684,7 @@ async def start_instance(
repo_path = ""
if instance.workspace_id:
from src.models.workspace import Workspace as WorkspaceModel
+
workspace = await session.get(WorkspaceModel, instance.workspace_id)
if workspace:
repo_path = workspace.path
diff --git a/apps/api/src/api/workspaces.py b/apps/api/src/api/workspaces.py
index 70d5278..fa80dec 100644
--- a/apps/api/src/api/workspaces.py
+++ b/apps/api/src/api/workspaces.py
@@ -108,7 +108,9 @@ async def create_workspace(
"branch": workspace.branch,
"path": workspace.path,
"status": workspace.status,
- "created_at": workspace.created_at.isoformat() if workspace.created_at else None,
+ "created_at": workspace.created_at.isoformat()
+ if workspace.created_at
+ else None,
}
@@ -216,9 +218,7 @@ async def delete_workspace(
status_code=409,
detail={
"message": "Workspace has running tool instances",
- "instances": [
- {"id": str(i.id), "name": i.name} for i in exc.instances
- ],
+ "instances": [{"id": str(i.id), "name": i.name} for i in exc.instances],
},
) from exc
except Exception as exc:
diff --git a/apps/api/src/services/workspace_manager.py b/apps/api/src/services/workspace_manager.py
index fccf957..cc7d886 100644
--- a/apps/api/src/services/workspace_manager.py
+++ b/apps/api/src/services/workspace_manager.py
@@ -1,5 +1,7 @@
"""Workspace lifecycle management service."""
+from __future__ import annotations
+
import logging
import os
import shutil
@@ -170,6 +172,4 @@ class WorkspaceManager:
TODO(PR-2): Wire up to actual instance stop/delete logic.
For now, this is a placeholder.
"""
- logger.warning(
- "Placeholder: stopping and deleting instance %s", instance.id
- )
+ logger.warning("Placeholder: stopping and deleting instance %s", instance.id)
diff --git a/apps/api/tests/integration/test_workspaces_api.py b/apps/api/tests/integration/test_workspaces_api.py
index 6c211a9..cb7f4d5 100644
--- a/apps/api/tests/integration/test_workspaces_api.py
+++ b/apps/api/tests/integration/test_workspaces_api.py
@@ -60,7 +60,9 @@ def test_repo(db_session: AsyncSession, authenticated_client: TestClient):
class TestListWorkspaces:
"""Tests for GET /projects/{pid}/repositories/{rid}/workspaces."""
- def test_list_empty(self, authenticated_client: TestClient, test_repo: GitRepository):
+ def test_list_empty(
+ self, authenticated_client: TestClient, test_repo: GitRepository
+ ):
"""Returns empty list when no workspaces exist."""
response = authenticated_client.get(
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces"
@@ -69,7 +71,10 @@ class TestListWorkspaces:
assert response.json() == []
def test_list_with_workspaces(
- self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
+ self,
+ authenticated_client: TestClient,
+ db_session: AsyncSession,
+ test_repo: GitRepository,
):
"""Returns workspaces with instance counts."""
ws = Workspace(
@@ -99,7 +104,9 @@ class TestListWorkspaces:
class TestCreateWorkspace:
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces."""
- def test_create_success(self, authenticated_client: TestClient, test_repo: GitRepository):
+ def test_create_success(
+ self, authenticated_client: TestClient, test_repo: GitRepository
+ ):
"""Creates a workspace and clones the repo."""
mock_ws = Workspace(
id=uuid.uuid4(),
@@ -110,7 +117,9 @@ class TestCreateWorkspace:
path="/data/working-copies/test/feature-branch",
)
- with patch.object(WorkspaceManager, "create", return_value=mock_ws) as mock_create:
+ with patch.object(
+ WorkspaceManager, "create", return_value=mock_ws
+ ) as mock_create:
response = authenticated_client.post(
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
json={"name": "feature-branch", "branch": "feature"},
@@ -121,7 +130,9 @@ class TestCreateWorkspace:
assert data["branch"] == "feature"
mock_create.assert_called_once()
- def test_create_missing_name(self, authenticated_client: TestClient, test_repo: GitRepository):
+ def test_create_missing_name(
+ self, authenticated_client: TestClient, test_repo: GitRepository
+ ):
"""Returns 400 when name is missing."""
response = authenticated_client.post(
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
@@ -131,7 +142,10 @@ class TestCreateWorkspace:
assert "name" in response.json()["detail"]
def test_create_duplicate_name(
- self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
+ self,
+ authenticated_client: TestClient,
+ db_session: AsyncSession,
+ test_repo: GitRepository,
):
"""Returns 409 when workspace name already exists."""
ws = Workspace(
@@ -148,7 +162,9 @@ class TestCreateWorkspace:
asyncio.run(_commit())
- with patch.object(WorkspaceManager, "create", side_effect=Exception("duplicate")):
+ with patch.object(
+ WorkspaceManager, "create", side_effect=Exception("duplicate")
+ ):
response = authenticated_client.post(
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
json={"name": "dev", "branch": "main"},
@@ -160,7 +176,10 @@ class TestDeleteWorkspace:
"""Tests for DELETE /projects/{pid}/repositories/{rid}/workspaces/{wid}."""
def test_delete_without_instances(
- self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
+ self,
+ authenticated_client: TestClient,
+ db_session: AsyncSession,
+ test_repo: GitRepository,
):
"""Deletes workspace when no instances exist."""
ws = Workspace(
@@ -185,9 +204,14 @@ class TestDeleteWorkspace:
assert response.status_code == 200
assert response.json()["status"] == "deleted"
- @pytest.mark.skip(reason="Async fixture interaction with sync tests — endpoint logic verified manually")
+ @pytest.mark.skip(
+ reason="Async fixture interaction with sync tests — endpoint logic verified manually"
+ )
def test_delete_with_instances_no_force(
- self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
+ self,
+ authenticated_client: TestClient,
+ db_session: AsyncSession,
+ test_repo: GitRepository,
):
"""Returns 409 when workspace has instances and force=False."""
ws = Workspace(
@@ -239,7 +263,10 @@ class TestDeleteWorkspace:
assert len(detail["instances"]) == 1
def test_delete_with_instances_force(
- self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
+ self,
+ authenticated_client: TestClient,
+ db_session: AsyncSession,
+ test_repo: GitRepository,
):
"""Deletes workspace when force=True even with instances."""
ws = Workspace(
@@ -268,7 +295,10 @@ class TestSyncWorkspace:
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces/{wid}/sync."""
def test_sync_success(
- self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
+ self,
+ authenticated_client: TestClient,
+ db_session: AsyncSession,
+ test_repo: GitRepository,
):
"""Sync succeeds and updates last_sync_at."""
ws = Workspace(
@@ -298,7 +328,10 @@ class TestSyncWorkspace:
assert data["pulled"] is True
def test_sync_branch_deleted(
- self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
+ self,
+ authenticated_client: TestClient,
+ db_session: AsyncSession,
+ test_repo: GitRepository,
):
"""Returns 409 when branch was deleted from remote."""
ws = Workspace(
diff --git a/apps/api/tests/unit/test_git_service.py b/apps/api/tests/unit/test_git_service.py
index 444bc76..35fd263 100644
--- a/apps/api/tests/unit/test_git_service.py
+++ b/apps/api/tests/unit/test_git_service.py
@@ -21,7 +21,9 @@ class TestGitServiceClone:
with patch(
"asyncio.create_subprocess_exec", return_value=mock_proc
) as mock_exec:
- await GitService.clone("https://github.com/test/repo.git", "main", "/tmp/ws")
+ await GitService.clone(
+ "https://github.com/test/repo.git", "main", "/tmp/ws"
+ )
mock_exec.assert_called_once_with(
"git",
diff --git a/apps/web/src/api/sessions.ts b/apps/web/src/api/sessions.ts
index 0bc13e8..751bf6e 100644
--- a/apps/web/src/api/sessions.ts
+++ b/apps/web/src/api/sessions.ts
@@ -2,185 +2,199 @@ import { AxiosError } from "axios";
import { apiClient } from "./client";
export interface ToolInstance {
- id: string;
- name: string;
- display_name: string;
- tool_type_id: string;
- tool_type_name: string;
- tool_type_interfaces: string[];
- status: string;
- url: string | null;
- port: number | null;
- selected_config_profile_id: string | null;
- ssh_key_ids: string[];
- created_at: string;
+ id: string;
+ name: string;
+ display_name: string;
+ tool_type_id: string;
+ tool_type_name: string;
+ tool_type_interfaces: string[];
+ status: string;
+ url: string | null;
+ port: number | null;
+ selected_config_profile_id: string | null;
+ ssh_key_ids: string[];
+ created_at: string;
}
export interface Session {
- id: string;
- display_name: string;
- tool_type_name: string;
- tool_icon: string;
- tool_type_interfaces: string[];
- repository_name: string;
- repository_id: string;
- project_name: string;
- project_id: string;
- status: string;
- url: string | null;
- container_status?: string;
- probe_status?: string;
- clone_mode?: string;
- branch?: string | null;
- created_at?: string;
+ id: string;
+ display_name: string;
+ tool_type_name: string;
+ tool_icon: string;
+ tool_type_interfaces: string[];
+ repository_name: string;
+ repository_id: string;
+ project_name: string;
+ project_id: string;
+ status: string;
+ url: string | null;
+ container_status?: string;
+ probe_status?: string;
+ clone_mode?: string;
+ branch?: string | null;
+ created_at?: string;
}
export async function listInstances(
- projectId: string,
- repoId: string
+ projectId: string,
+ repoId: string,
): Promise {
- const response = await apiClient.get(
- `/projects/${projectId}/repositories/${repoId}/instances`
- );
- return response.data.instances;
+ const response = await apiClient.get(
+ `/projects/${projectId}/repositories/${repoId}/instances`,
+ );
+ return response.data.instances;
}
export async function createInstance(
- projectId: string,
- repoId: string,
- toolTypeId: string,
- displayName?: string,
- cloneMode?: string,
- branch?: string,
- newBranch?: string,
- configProfileId?: string,
- sshKeyIds?: string[],
- workspaceId?: string
+ projectId: string,
+ repoId: string,
+ toolTypeId: string,
+ displayName?: string,
+ cloneMode?: string,
+ branch?: string,
+ newBranch?: string,
+ configProfileId?: string,
+ sshKeyIds?: string[],
+ workspaceId?: string,
): Promise {
- const response = await apiClient.post(
- `/projects/${projectId}/repositories/${repoId}/instances`,
- {
- tool_type_id: toolTypeId,
- display_name: displayName,
- workspace_id: workspaceId || undefined,
- clone_mode: cloneMode || "mount",
- branch: branch || undefined,
- new_branch: newBranch || undefined,
- config_profile_id: configProfileId,
- ssh_key_ids: sshKeyIds || [],
- }
- );
- return response.data;
+ const response = await apiClient.post(
+ `/projects/${projectId}/repositories/${repoId}/instances`,
+ {
+ tool_type_id: toolTypeId,
+ display_name: displayName,
+ workspace_id: workspaceId || undefined,
+ clone_mode: cloneMode || "mount",
+ branch: branch || undefined,
+ new_branch: newBranch || undefined,
+ config_profile_id: configProfileId,
+ ssh_key_ids: sshKeyIds || [],
+ },
+ );
+ return response.data;
}
export async function startInstance(
- projectId: string,
- repoId: string,
- instanceId: string,
- configProfileId?: string,
- sshKeyIds?: string[],
- retries = 2
+ projectId: string,
+ repoId: string,
+ instanceId: string,
+ configProfileId?: string,
+ sshKeyIds?: string[],
+ retries = 2,
): Promise<{ status: string; url?: string }> {
- try {
- const response = await apiClient.post(
- `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
- { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }
- );
- return response.data;
- } catch (error) {
- // Retry on network errors (e.g. Docker creating network interfaces)
- const axiosError = error as AxiosError;
- if (retries > 0 && !axiosError.response) {
- await new Promise((r) => setTimeout(r, 1500));
- return startInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1);
- }
- throw error;
- }
+ try {
+ const response = await apiClient.post(
+ `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
+ { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] },
+ );
+ return response.data;
+ } catch (error) {
+ // Retry on network errors (e.g. Docker creating network interfaces)
+ const axiosError = error as AxiosError;
+ if (retries > 0 && !axiosError.response) {
+ await new Promise((r) => setTimeout(r, 1500));
+ return startInstance(
+ projectId,
+ repoId,
+ instanceId,
+ configProfileId,
+ sshKeyIds,
+ retries - 1,
+ );
+ }
+ throw error;
+ }
}
export async function stopInstance(
- projectId: string,
- repoId: string,
- instanceId: string
+ projectId: string,
+ repoId: string,
+ instanceId: string,
): Promise<{ status: string }> {
- const response = await apiClient.post(
- `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`
- );
- return response.data;
+ const response = await apiClient.post(
+ `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`,
+ );
+ return response.data;
}
export async function restartInstance(
- projectId: string,
- repoId: string,
- instanceId: string,
- configProfileId?: string,
- sshKeyIds?: string[],
- retries = 2
+ projectId: string,
+ repoId: string,
+ instanceId: string,
+ configProfileId?: string,
+ sshKeyIds?: string[],
+ retries = 2,
): Promise<{ status: string; url?: string }> {
- try {
- const response = await apiClient.post(
- `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
- { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }
- );
- return response.data;
- } catch (error) {
- // Retry on network errors (e.g. Docker creating network interfaces)
- const axiosError = error as AxiosError;
- if (retries > 0 && !axiosError.response) {
- await new Promise((r) => setTimeout(r, 1500));
- return restartInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1);
- }
- throw error;
- }
+ try {
+ const response = await apiClient.post(
+ `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
+ { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] },
+ );
+ return response.data;
+ } catch (error) {
+ // Retry on network errors (e.g. Docker creating network interfaces)
+ const axiosError = error as AxiosError;
+ if (retries > 0 && !axiosError.response) {
+ await new Promise((r) => setTimeout(r, 1500));
+ return restartInstance(
+ projectId,
+ repoId,
+ instanceId,
+ configProfileId,
+ sshKeyIds,
+ retries - 1,
+ );
+ }
+ throw error;
+ }
}
export async function deleteInstance(
- projectId: string,
- repoId: string,
- instanceId: string,
- force?: boolean
+ projectId: string,
+ repoId: string,
+ instanceId: string,
+ force?: boolean,
): Promise {
- await apiClient.delete(
- `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
- { params: { force } }
- );
+ await apiClient.delete(
+ `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
+ { params: { force } },
+ );
}
export async function getUserSessions(): Promise {
- const response = await apiClient.get("/users/me/sessions");
- return response.data.sessions;
+ const response = await apiClient.get("/users/me/sessions");
+ return response.data.sessions;
}
export interface InstanceHealth {
- healthy: boolean;
- container_status: string;
- container_health: string | null;
- container_exit_code: number | null;
- tunnel_status: string;
- tunnel_status_code: number | null;
- probe_status: string;
- last_probe_output: string | null;
- error: string | null;
+ healthy: boolean;
+ container_status: string;
+ container_health: string | null;
+ container_exit_code: number | null;
+ tunnel_status: string;
+ tunnel_status_code: number | null;
+ probe_status: string;
+ last_probe_output: string | null;
+ error: string | null;
}
export async function checkInstanceHealth(
- projectId: string,
- repoId: string,
- instanceId: string
+ projectId: string,
+ repoId: string,
+ instanceId: string,
): Promise {
- const response = await apiClient.get(
- `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
- );
- return response.data;
+ const response = await apiClient.get(
+ `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`,
+ );
+ return response.data;
}
export async function recreateInstanceTunnel(
- projectId: string,
- repoId: string,
- instanceId: string
+ projectId: string,
+ repoId: string,
+ instanceId: string,
): Promise<{ status: string; url?: string }> {
- const response = await apiClient.post(
- `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`
- );
- return response.data;
+ const response = await apiClient.post(
+ `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`,
+ );
+ return response.data;
}
diff --git a/apps/web/src/api/workspaces.ts b/apps/web/src/api/workspaces.ts
index cba8972..1632770 100644
--- a/apps/web/src/api/workspaces.ts
+++ b/apps/web/src/api/workspaces.ts
@@ -1,65 +1,82 @@
/** Workspace API client. */
import { apiClient } from "./client";
-import type { Workspace, CreateWorkspaceRequest, SyncResult } from "../types/workspace";
+import type {
+ Workspace,
+ CreateWorkspaceRequest,
+ SyncResult,
+} from "../types/workspace";
function workspaceUrl(projectId: string, repoId: string, workspaceId?: string) {
- const base = `/projects/${projectId}/repositories/${repoId}/workspaces`;
- return workspaceId ? `${base}/${workspaceId}` : base;
+ const base = `/projects/${projectId}/repositories/${repoId}/workspaces`;
+ return workspaceId ? `${base}/${workspaceId}` : base;
}
-export async function listWorkspaces(projectId: string, repoId: string): Promise {
- const response = await apiClient.get(workspaceUrl(projectId, repoId));
- return response.data;
+export async function listWorkspaces(
+ projectId: string,
+ repoId: string,
+): Promise {
+ const response = await apiClient.get(
+ workspaceUrl(projectId, repoId),
+ );
+ return response.data;
}
export async function createWorkspace(
- projectId: string,
- repoId: string,
- data: CreateWorkspaceRequest,
+ projectId: string,
+ repoId: string,
+ data: CreateWorkspaceRequest,
): Promise {
- const response = await apiClient.post(workspaceUrl(projectId, repoId), data);
- return response.data;
+ const response = await apiClient.post(
+ workspaceUrl(projectId, repoId),
+ data,
+ );
+ return response.data;
}
export async function getWorkspace(
- projectId: string,
- repoId: string,
- workspaceId: string,
+ projectId: string,
+ repoId: string,
+ workspaceId: string,
): Promise {
- const response = await apiClient.get(workspaceUrl(projectId, repoId, workspaceId));
- return response.data;
+ const response = await apiClient.get(
+ workspaceUrl(projectId, repoId, workspaceId),
+ );
+ return response.data;
}
export async function updateWorkspace(
- projectId: string,
- repoId: string,
- workspaceId: string,
- data: Partial,
+ projectId: string,
+ repoId: string,
+ workspaceId: string,
+ data: Partial,
): Promise {
- const response = await apiClient.patch(workspaceUrl(projectId, repoId, workspaceId), data);
- return response.data;
+ const response = await apiClient.patch(
+ workspaceUrl(projectId, repoId, workspaceId),
+ data,
+ );
+ return response.data;
}
export async function deleteWorkspace(
- projectId: string,
- repoId: string,
- workspaceId: string,
- force = false,
+ projectId: string,
+ repoId: string,
+ workspaceId: string,
+ force = false,
): Promise<{ status: string }> {
- const response = await apiClient.delete<{ status: string }>(
- `${workspaceUrl(projectId, repoId, workspaceId)}?force=${force}`,
- );
- return response.data;
+ const response = await apiClient.delete<{ status: string }>(
+ `${workspaceUrl(projectId, repoId, workspaceId)}?force=${force}`,
+ );
+ return response.data;
}
export async function syncWorkspace(
- projectId: string,
- repoId: string,
- workspaceId: string,
+ projectId: string,
+ repoId: string,
+ workspaceId: string,
): Promise {
- const response = await apiClient.post(
- `${workspaceUrl(projectId, repoId, workspaceId)}/sync`,
- );
- return response.data;
+ const response = await apiClient.post(
+ `${workspaceUrl(projectId, repoId, workspaceId)}/sync`,
+ );
+ return response.data;
}
diff --git a/apps/web/src/components/start-tool-modal.tsx b/apps/web/src/components/start-tool-modal.tsx
index 02a7f0b..67bcdd0 100644
--- a/apps/web/src/components/start-tool-modal.tsx
+++ b/apps/web/src/components/start-tool-modal.tsx
@@ -5,83 +5,96 @@ import { Icon } from "./icon";
import type { Workspace } from "../types/workspace";
export interface StartToolModalProps {
- workspace: Workspace;
- onClose: () => void;
- onStart: (toolTypeId: string, configProfileId?: string) => Promise;
+ workspace: Workspace;
+ onClose: () => void;
+ onStart: (toolTypeId: string, configProfileId?: string) => Promise;
}
-export function StartToolModal({ workspace, onClose, onStart }: StartToolModalProps) {
- const [toolTypeId, setToolTypeId] = useState("");
- const [configProfileId, setConfigProfileId] = useState("");
- const [submitting, setSubmitting] = useState(false);
- const [error, setError] = useState(null);
+export function StartToolModal({
+ workspace,
+ onClose,
+ onStart,
+}: StartToolModalProps) {
+ const [toolTypeId, setToolTypeId] = useState("");
+ const [configProfileId, setConfigProfileId] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- if (!toolTypeId) {
- setError("Please select a tool type");
- return;
- }
- setSubmitting(true);
- setError(null);
- try {
- await onStart(toolTypeId, configProfileId || undefined);
- onClose();
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to start tool");
- } finally {
- setSubmitting(false);
- }
- };
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!toolTypeId) {
+ setError("Please select a tool type");
+ return;
+ }
+ setSubmitting(true);
+ setError(null);
+ try {
+ await onStart(toolTypeId, configProfileId || undefined);
+ onClose();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to start tool");
+ } finally {
+ setSubmitting(false);
+ }
+ };
- return (
-
-
e.stopPropagation()}>
-
-
- Start Tool on {workspace.name}
-
-
-
-
-
-
- );
+ return (
+
+
e.stopPropagation()}>
+
+
+ Start Tool on {workspace.name}
+
+
+
+
+
+
+ );
}
diff --git a/apps/web/src/components/workspace-card.tsx b/apps/web/src/components/workspace-card.tsx
index dd0ad4c..d6e4e06 100644
--- a/apps/web/src/components/workspace-card.tsx
+++ b/apps/web/src/components/workspace-card.tsx
@@ -4,70 +4,72 @@ import { Icon } from "./icon";
import type { Workspace } from "../types/workspace";
export interface WorkspaceCardProps {
- workspace: Workspace;
- loading?: boolean;
- onStartTool: (workspace: Workspace) => void;
- onSync: (workspace: Workspace) => void;
- onDelete: (workspace: Workspace) => void;
+ workspace: Workspace;
+ loading?: boolean;
+ onStartTool: (workspace: Workspace) => void;
+ onSync: (workspace: Workspace) => void;
+ onDelete: (workspace: Workspace) => void;
}
export function WorkspaceCard({
- workspace,
- loading = false,
- onStartTool,
- onSync,
- onDelete,
+ workspace,
+ loading = false,
+ onStartTool,
+ onSync,
+ onDelete,
}: WorkspaceCardProps) {
- const statusClass =
- workspace.status === "ready"
- ? "status-ready"
- : workspace.status === "syncing"
- ? "status-syncing"
- : "status-error";
+ const statusClass =
+ workspace.status === "ready"
+ ? "status-ready"
+ : workspace.status === "syncing"
+ ? "status-syncing"
+ : "status-error";
- return (
-
-
-
{workspace.name}
- {workspace.status}
-
-
-
- {workspace.project_name} / {workspace.repo_name}
-
-
- {workspace.branch}
-
- {workspace.instance_count > 0 && (
-
- {workspace.instance_count} active tool
- {workspace.instance_count > 1 ? "s" : ""}
-
- )}
-
-
-
-
-
-
-
- );
+ return (
+
+
+
{workspace.name}
+
+ {workspace.status}
+
+
+
+
+ {workspace.project_name} / {workspace.repo_name}
+
+
+ {workspace.branch}
+
+ {workspace.instance_count > 0 && (
+
+ {workspace.instance_count} active tool
+ {workspace.instance_count > 1 ? "s" : ""}
+
+ )}
+
+
+
+
+
+
+
+ );
}
diff --git a/apps/web/src/components/workspace-create-form.tsx b/apps/web/src/components/workspace-create-form.tsx
index f2c9631..c907f3f 100644
--- a/apps/web/src/components/workspace-create-form.tsx
+++ b/apps/web/src/components/workspace-create-form.tsx
@@ -5,78 +5,85 @@ import { Icon } from "./icon";
import type { CreateWorkspaceRequest } from "../types/workspace";
export interface WorkspaceCreateFormProps {
- projectId: string;
- repoId: string;
- defaultBranch?: string;
- onSubmit: (data: CreateWorkspaceRequest) => Promise;
- onCancel: () => void;
+ projectId: string;
+ repoId: string;
+ defaultBranch?: string;
+ onSubmit: (data: CreateWorkspaceRequest) => Promise;
+ onCancel: () => void;
}
export function WorkspaceCreateForm({
- defaultBranch = "main",
- onSubmit,
- onCancel,
+ defaultBranch = "main",
+ onSubmit,
+ onCancel,
}: WorkspaceCreateFormProps) {
- const [name, setName] = useState("");
- const [branch, setBranch] = useState(defaultBranch);
- const [submitting, setSubmitting] = useState(false);
- const [error, setError] = useState(null);
+ const [name, setName] = useState("");
+ const [branch, setBranch] = useState(defaultBranch);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- if (!name.trim()) {
- setError("Workspace name is required");
- return;
- }
- setSubmitting(true);
- setError(null);
- try {
- await onSubmit({ name: name.trim(), branch: branch.trim() });
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to create workspace");
- } finally {
- setSubmitting(false);
- }
- };
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!name.trim()) {
+ setError("Workspace name is required");
+ return;
+ }
+ setSubmitting(true);
+ setError(null);
+ try {
+ await onSubmit({ name: name.trim(), branch: branch.trim() });
+ } catch (err) {
+ setError(
+ err instanceof Error ? err.message : "Failed to create workspace",
+ );
+ } finally {
+ setSubmitting(false);
+ }
+ };
- return (
-
- );
+ return (
+
+ );
}
diff --git a/apps/web/src/hooks/use-workspace-actions.ts b/apps/web/src/hooks/use-workspace-actions.ts
index 594b7a2..52b5d99 100644
--- a/apps/web/src/hooks/use-workspace-actions.ts
+++ b/apps/web/src/hooks/use-workspace-actions.ts
@@ -2,145 +2,152 @@
import { useState, useCallback } from "react";
import {
- createWorkspace,
- deleteWorkspace,
- syncWorkspace,
- updateWorkspace,
+ createWorkspace,
+ deleteWorkspace,
+ syncWorkspace,
+ updateWorkspace,
} from "../api/workspaces";
import type { Workspace, CreateWorkspaceRequest } from "../types/workspace";
export interface UseWorkspaceActionsResult {
- loadingId: string | null;
- create: (
- projectId: string,
- repoId: string,
- data: CreateWorkspaceRequest,
- ) => Promise;
- delete: (
- projectId: string,
- repoId: string,
- workspace: Workspace,
- onRefresh: () => Promise,
- ) => Promise;
- sync: (
- projectId: string,
- repoId: string,
- workspace: Workspace,
- onRefresh: () => Promise,
- ) => Promise;
- update: (
- projectId: string,
- repoId: string,
- workspaceId: string,
- data: Partial,
- ) => Promise;
+ loadingId: string | null;
+ create: (
+ projectId: string,
+ repoId: string,
+ data: CreateWorkspaceRequest,
+ ) => Promise;
+ delete: (
+ projectId: string,
+ repoId: string,
+ workspace: Workspace,
+ onRefresh: () => Promise,
+ ) => Promise;
+ sync: (
+ projectId: string,
+ repoId: string,
+ workspace: Workspace,
+ onRefresh: () => Promise,
+ ) => Promise;
+ update: (
+ projectId: string,
+ repoId: string,
+ workspaceId: string,
+ data: Partial,
+ ) => Promise;
}
interface ApiError {
- response?: {
- status?: number;
- data?: {
- detail?: {
- message?: string;
- instances?: Array<{ id: string; name: string }>;
- branch_deleted?: boolean;
- };
- };
- };
+ response?: {
+ status?: number;
+ data?: {
+ detail?: {
+ message?: string;
+ instances?: Array<{ id: string; name: string }>;
+ branch_deleted?: boolean;
+ };
+ };
+ };
}
export function useWorkspaceActions(): UseWorkspaceActionsResult {
- const [loadingId, setLoadingId] = useState(null);
+ const [loadingId, setLoadingId] = useState(null);
- const create = useCallback(
- async (projectId: string, repoId: string, data: CreateWorkspaceRequest) => {
- return createWorkspace(projectId, repoId, data);
- },
- [],
- );
+ const create = useCallback(
+ async (projectId: string, repoId: string, data: CreateWorkspaceRequest) => {
+ return createWorkspace(projectId, repoId, data);
+ },
+ [],
+ );
- const deleteAction = useCallback(
- async (
- projectId: string,
- repoId: string,
- workspace: Workspace,
- onRefresh: () => Promise,
- ) => {
- setLoadingId(workspace.id);
- try {
- await deleteWorkspace(projectId, repoId, workspace.id);
- await onRefresh();
- } catch (err) {
- const error = err as ApiError;
- if (error.response?.status === 409) {
- const detail = error.response.data?.detail;
- const instances = detail?.instances || [];
- const confirmed = window.confirm(
- `This workspace has ${instances.length} running tool instance(s):\n` +
- instances.map((i) => `- ${i.name}`).join("\n") +
- `\n\nDelete workspace and all instances?`,
- );
- if (confirmed) {
- await deleteWorkspace(projectId, repoId, workspace.id, true);
- await onRefresh();
- }
- } else {
- throw err;
- }
- } finally {
- setLoadingId(null);
- }
- },
- [],
- );
+ const deleteAction = useCallback(
+ async (
+ projectId: string,
+ repoId: string,
+ workspace: Workspace,
+ onRefresh: () => Promise,
+ ) => {
+ setLoadingId(workspace.id);
+ try {
+ await deleteWorkspace(projectId, repoId, workspace.id);
+ await onRefresh();
+ } catch (err) {
+ const error = err as ApiError;
+ if (error.response?.status === 409) {
+ const detail = error.response.data?.detail;
+ const instances = detail?.instances || [];
+ const confirmed = window.confirm(
+ `This workspace has ${instances.length} running tool instance(s):\n` +
+ instances.map((i) => `- ${i.name}`).join("\n") +
+ `\n\nDelete workspace and all instances?`,
+ );
+ if (confirmed) {
+ await deleteWorkspace(projectId, repoId, workspace.id, true);
+ await onRefresh();
+ }
+ } else {
+ throw err;
+ }
+ } finally {
+ setLoadingId(null);
+ }
+ },
+ [],
+ );
- const sync = useCallback(
- async (
- projectId: string,
- repoId: string,
- workspace: Workspace,
- onRefresh: () => Promise,
- ) => {
- setLoadingId(workspace.id);
- try {
- await syncWorkspace(projectId, repoId, workspace.id);
- await onRefresh();
- } catch (err) {
- const error = err as ApiError;
- if (error.response?.status === 409 && error.response.data?.detail?.branch_deleted) {
- const message = error.response.data.detail.message || "Branch was deleted from remote";
- const confirmed = window.confirm(`${message}\n\nDelete this workspace?`);
- if (confirmed) {
- await deleteWorkspace(projectId, repoId, workspace.id, true);
- await onRefresh();
- }
- } else {
- throw err;
- }
- } finally {
- setLoadingId(null);
- }
- },
- [],
- );
+ const sync = useCallback(
+ async (
+ projectId: string,
+ repoId: string,
+ workspace: Workspace,
+ onRefresh: () => Promise,
+ ) => {
+ setLoadingId(workspace.id);
+ try {
+ await syncWorkspace(projectId, repoId, workspace.id);
+ await onRefresh();
+ } catch (err) {
+ const error = err as ApiError;
+ if (
+ error.response?.status === 409 &&
+ error.response.data?.detail?.branch_deleted
+ ) {
+ const message =
+ error.response.data.detail.message ||
+ "Branch was deleted from remote";
+ const confirmed = window.confirm(
+ `${message}\n\nDelete this workspace?`,
+ );
+ if (confirmed) {
+ await deleteWorkspace(projectId, repoId, workspace.id, true);
+ await onRefresh();
+ }
+ } else {
+ throw err;
+ }
+ } finally {
+ setLoadingId(null);
+ }
+ },
+ [],
+ );
- const update = useCallback(
- async (
- projectId: string,
- repoId: string,
- workspaceId: string,
- data: Partial,
- ) => {
- return updateWorkspace(projectId, repoId, workspaceId, data);
- },
- [],
- );
+ const update = useCallback(
+ async (
+ projectId: string,
+ repoId: string,
+ workspaceId: string,
+ data: Partial,
+ ) => {
+ return updateWorkspace(projectId, repoId, workspaceId, data);
+ },
+ [],
+ );
- return {
- loadingId,
- create,
- delete: deleteAction,
- sync,
- update,
- };
+ return {
+ loadingId,
+ create,
+ delete: deleteAction,
+ sync,
+ update,
+ };
}
diff --git a/apps/web/src/hooks/use-workspaces.ts b/apps/web/src/hooks/use-workspaces.ts
index 65691c6..f27969f 100644
--- a/apps/web/src/hooks/use-workspaces.ts
+++ b/apps/web/src/hooks/use-workspaces.ts
@@ -5,33 +5,38 @@ import { listWorkspaces } from "../api/workspaces";
import type { Workspace } from "../types/workspace";
export interface UseWorkspacesResult {
- workspaces: Workspace[];
- loading: boolean;
- error: string | null;
- refresh: () => Promise;
+ workspaces: Workspace[];
+ loading: boolean;
+ error: string | null;
+ refresh: () => Promise;
}
-export function useWorkspaces(projectId: string, repoId: string): UseWorkspacesResult {
- const [workspaces, setWorkspaces] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
+export function useWorkspaces(
+ projectId: string,
+ repoId: string,
+): UseWorkspacesResult {
+ const [workspaces, setWorkspaces] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
- const refresh = useCallback(async () => {
- setLoading(true);
- setError(null);
- try {
- const data = await listWorkspaces(projectId, repoId);
- setWorkspaces(data);
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to load workspaces");
- } finally {
- setLoading(false);
- }
- }, [projectId, repoId]);
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const data = await listWorkspaces(projectId, repoId);
+ setWorkspaces(data);
+ } catch (err) {
+ setError(
+ err instanceof Error ? err.message : "Failed to load workspaces",
+ );
+ } finally {
+ setLoading(false);
+ }
+ }, [projectId, repoId]);
- useEffect(() => {
- refresh();
- }, [refresh]);
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
- return { workspaces, loading, error, refresh };
+ return { workspaces, loading, error, refresh };
}
diff --git a/apps/web/src/pages/workspaces.tsx b/apps/web/src/pages/workspaces.tsx
index 92f36a0..9a1f681 100644
--- a/apps/web/src/pages/workspaces.tsx
+++ b/apps/web/src/pages/workspaces.tsx
@@ -11,109 +11,125 @@ import { createInstance, startInstance } from "../api/sessions";
import type { Workspace } from "../types/workspace";
export function WorkspacesPage() {
- const [showCreate, setShowCreate] = useState(false);
- const [startWorkspace, setStartWorkspace] = useState(null);
+ const [showCreate, setShowCreate] = useState(false);
+ const [startWorkspace, setStartWorkspace] = useState(null);
- // TODO: Get projectId and repoId from URL params or context
- const projectId = "default-project";
- const repoId = "default-repo";
+ // TODO: Get projectId and repoId from URL params or context
+ const projectId = "default-project";
+ const repoId = "default-repo";
- const { workspaces, loading, error, refresh } = useWorkspaces(projectId, repoId);
- const actions = useWorkspaceActions();
+ const { workspaces, loading, error, refresh } = useWorkspaces(
+ projectId,
+ repoId,
+ );
+ const actions = useWorkspaceActions();
- const handleCreate = async (data: { name: string; branch: string }) => {
- await actions.create(projectId, repoId, data);
- setShowCreate(false);
- await refresh();
- };
+ const handleCreate = async (data: { name: string; branch: string }) => {
+ await actions.create(projectId, repoId, data);
+ setShowCreate(false);
+ await refresh();
+ };
- const handleDelete = async (workspace: Workspace) => {
- await actions.delete(projectId, repoId, workspace, refresh);
- };
+ const handleDelete = async (workspace: Workspace) => {
+ await actions.delete(projectId, repoId, workspace, refresh);
+ };
- const handleSync = async (workspace: Workspace) => {
- await actions.sync(projectId, repoId, workspace, refresh);
- };
+ const handleSync = async (workspace: Workspace) => {
+ await actions.sync(projectId, repoId, workspace, refresh);
+ };
- const handleStartTool = async (toolTypeId: string, configProfileId?: string) => {
- if (!startWorkspace) return;
- try {
- const instance = await createInstance(
- projectId,
- repoId,
- toolTypeId,
- `${startWorkspace.name} - ${toolTypeId}`,
- undefined,
- undefined,
- undefined,
- configProfileId,
- [],
- startWorkspace.id
- );
- await startInstance(projectId, repoId, instance.id, configProfileId);
- setStartWorkspace(null);
- await refresh();
- } catch (err) {
- alert(err instanceof Error ? err.message : "Failed to start tool");
- }
- };
+ const handleStartTool = async (
+ toolTypeId: string,
+ configProfileId?: string,
+ ) => {
+ if (!startWorkspace) return;
+ try {
+ const instance = await createInstance(
+ projectId,
+ repoId,
+ toolTypeId,
+ `${startWorkspace.name} - ${toolTypeId}`,
+ undefined,
+ undefined,
+ undefined,
+ configProfileId,
+ [],
+ startWorkspace.id,
+ );
+ await startInstance(projectId, repoId, instance.id, configProfileId);
+ setStartWorkspace(null);
+ await refresh();
+ } catch (err) {
+ alert(err instanceof Error ? err.message : "Failed to start tool");
+ }
+ };
- return (
-
-
+ return (
+
+
- {error &&
{error}
}
+ {error &&
{error}
}
- {showCreate && (
-
setShowCreate(false)}
- />
- )}
+ {showCreate && (
+ setShowCreate(false)}
+ />
+ )}
- {loading && workspaces.length === 0 ? (
- Loading workspaces...
- ) : workspaces.length === 0 ? (
-
-
No workspaces yet.
-
-
- ) : (
-
- {workspaces.map((ws) => (
-
- ))}
-
- )}
+ {loading && workspaces.length === 0 ? (
+ Loading workspaces...
+ ) : workspaces.length === 0 ? (
+
+
No workspaces yet.
+
+
+ ) : (
+
+ {workspaces.map((ws) => (
+
+ ))}
+
+ )}
- {startWorkspace && (
- setStartWorkspace(null)}
- onStart={handleStartTool}
- />
- )}
-
- );
+ {startWorkspace && (
+
setStartWorkspace(null)}
+ onStart={handleStartTool}
+ />
+ )}
+
+ );
}
diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx
index 2b292b1..2e60f54 100644
--- a/apps/web/src/router.tsx
+++ b/apps/web/src/router.tsx
@@ -19,39 +19,54 @@ import { SessionsPage } from "./pages/sessions";
import { WorkspacesPage } from "./pages/workspaces";
export const AppRouter = () => {
- return (
-
- } />
- } />
-
-
-
- }
- >
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- }>
- } />
- } />
- } />
- } />
-
- } />
- } />
- } />
- } />
-
- } />
- } />
-
- );
+ return (
+
+ } />
+ }
+ />
+
+
+
+ }
+ >
+ } />
+ } />
+ } />
+ }
+ />
+ }
+ />
+ }
+ />
+ } />
+ } />
+ }>
+ } />
+ } />
+ } />
+ } />
+
+ } />
+ } />
+ } />
+ }
+ />
+
+ } />
+ } />
+
+ );
};
diff --git a/apps/web/src/types/workspace.ts b/apps/web/src/types/workspace.ts
index 1aaf5f1..801d0bb 100644
--- a/apps/web/src/types/workspace.ts
+++ b/apps/web/src/types/workspace.ts
@@ -1,28 +1,28 @@
/** Types for the workspace feature. */
export interface Workspace {
- id: string;
- name: string;
- repo_id: string;
- repo_name: string;
- project_name: string;
- user_id: string;
- branch: string;
- path: string;
- status: "ready" | "syncing" | "error";
- last_sync_at: string | null;
- created_at: string;
- updated_at: string;
- instance_count: number;
+ id: string;
+ name: string;
+ repo_id: string;
+ repo_name: string;
+ project_name: string;
+ user_id: string;
+ branch: string;
+ path: string;
+ status: "ready" | "syncing" | "error";
+ last_sync_at: string | null;
+ created_at: string;
+ updated_at: string;
+ instance_count: number;
}
export interface CreateWorkspaceRequest {
- name: string;
- branch: string;
+ name: string;
+ branch: string;
}
export interface SyncResult {
- branch_deleted: boolean;
- pulled: boolean;
- last_sync_at: string | null;
+ branch_deleted: boolean;
+ pulled: boolean;
+ last_sync_at: string | null;
}
From b05de96569b67affad2956f0ce7a326ae6796e77 Mon Sep 17 00:00:00 2001
From: Alex Blank
Date: Mon, 1 Jun 2026 00:03:33 +0200
Subject: [PATCH 19/44] fix: add trailing slash to workspace API URLs
FastAPI auto-redirects /workspaces to /workspaces/ with 307.
Behind Traefik (HTTP internal), the 307 becomes http://,
triggering Mixed Content in the browser. Adding trailing
slashes avoids the redirect entirely.
---
apps/web/src/api/workspaces.ts | 2 +-
docker-compose.yml | 14 ++++++++++----
2 files changed, 11 insertions(+), 5 deletions(-)
diff --git a/apps/web/src/api/workspaces.ts b/apps/web/src/api/workspaces.ts
index 1632770..d72fc78 100644
--- a/apps/web/src/api/workspaces.ts
+++ b/apps/web/src/api/workspaces.ts
@@ -9,7 +9,7 @@ import type {
function workspaceUrl(projectId: string, repoId: string, workspaceId?: string) {
const base = `/projects/${projectId}/repositories/${repoId}/workspaces`;
- return workspaceId ? `${base}/${workspaceId}` : base;
+ return workspaceId ? `${base}/${workspaceId}/` : `${base}/`;
}
export async function listWorkspaces(
diff --git a/docker-compose.yml b/docker-compose.yml
index 67bfff3..62f963d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,4 +1,4 @@
-version: '3.8'
+version: "3.8"
services:
# PostgreSQL Database
@@ -14,7 +14,11 @@ services:
ports:
- "5432:5432"
healthcheck:
- test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-headquarter} -d ${POSTGRES_DB:-headquarter}"]
+ test:
+ [
+ "CMD-SHELL",
+ "pg_isready -U ${POSTGRES_USER:-headquarter} -d ${POSTGRES_DB:-headquarter}",
+ ]
interval: 10s
timeout: 5s
retries: 5
@@ -76,10 +80,12 @@ services:
context: ./apps/web
dockerfile: Dockerfile
args:
- VITE_API_BASE_URL: http://localhost:8000
+ VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:8000}
+ VITE_APP_URL: ${VITE_APP_URL:-http://localhost:3000}
container_name: hq-web
environment:
- VITE_API_BASE_URL: http://localhost:8000
+ # These are only for documentation; Vite bakes values at build time.
+ VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:8000}
ports:
- "3000:80"
depends_on:
From 59b125d8e26f8b902004c4e4481ddd871bde1672 Mon Sep 17 00:00:00 2001
From: Alex Blank
Date: Mon, 1 Jun 2026 00:20:19 +0200
Subject: [PATCH 20/44] fix: add top-level GET /workspaces endpoint and derive
project/repo from workspace data
- Add all_workspaces_router with GET /workspaces/ (no project/repo required)
- Include project_id in workspace responses
- Frontend: useWorkspaces() calls listAllWorkspaces when no args
- Frontend: WorkspacesPage uses top-level list, derives project/repo from workspace for mutations
- Fixes 422 from invalid UUID path params
---
apps/api/src/api/workspaces.py | 49 +++++++++++++++++++++++
apps/api/src/main.py | 3 +-
apps/web/src/api/workspaces.ts | 5 +++
apps/web/src/hooks/use-workspaces.ts | 11 ++++--
apps/web/src/pages/workspaces.tsx | 59 ++++++++++++++--------------
apps/web/src/types/workspace.ts | 1 +
6 files changed, 93 insertions(+), 35 deletions(-)
diff --git a/apps/api/src/api/workspaces.py b/apps/api/src/api/workspaces.py
index fa80dec..1c20c89 100644
--- a/apps/api/src/api/workspaces.py
+++ b/apps/api/src/api/workspaces.py
@@ -17,6 +17,54 @@ from src.services.workspace_manager import WorkspaceHasInstancesError, Workspace
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/projects/{project_id}/repositories/{repo_id}/workspaces")
+all_workspaces_router = APIRouter(prefix="/workspaces")
+
+
+@all_workspaces_router.get("/")
+async def list_all_workspaces(
+ user_id: uuid.UUID = Depends(get_current_user_id),
+ session: AsyncSession = Depends(get_db_session),
+) -> list[dict]:
+ """List all workspaces for the current user across all repos."""
+ instance_count = (
+ select(func.count(ToolInstance.id))
+ .where(ToolInstance.workspace_id == Workspace.id)
+ .correlate(Workspace)
+ .scalar_subquery()
+ )
+
+ result = await session.execute(
+ select(
+ Workspace,
+ GitRepository.name.label("repo_name"),
+ GitRepository.project_id,
+ instance_count.label("instance_count"),
+ )
+ .join(GitRepository, Workspace.repo_id == GitRepository.id)
+ .where(Workspace.user_id == user_id)
+ .order_by(Workspace.created_at.desc())
+ )
+ rows = result.all()
+
+ return [
+ {
+ "id": str(ws.id),
+ "name": ws.name,
+ "repo_id": str(ws.repo_id),
+ "repo_name": repo_name or "",
+ "project_id": str(project_id) if project_id else "",
+ "project_name": "", # Could join with Project if needed
+ "user_id": str(ws.user_id),
+ "branch": ws.branch,
+ "path": ws.path,
+ "status": ws.status,
+ "last_sync_at": ws.last_sync_at.isoformat() if ws.last_sync_at else None,
+ "created_at": ws.created_at.isoformat() if ws.created_at else None,
+ "updated_at": ws.updated_at.isoformat() if ws.updated_at else None,
+ "instance_count": count or 0,
+ }
+ for ws, repo_name, project_id, count in rows
+ ]
@router.get("/")
@@ -54,6 +102,7 @@ async def list_workspaces(
"name": ws.name,
"repo_id": str(ws.repo_id),
"repo_name": repo.name,
+ "project_id": str(repo.project_id) if repo.project_id else "",
"project_name": repo.project.name if repo.project else "",
"user_id": str(ws.user_id),
"branch": ws.branch,
diff --git a/apps/api/src/main.py b/apps/api/src/main.py
index c1e2b89..b10e897 100644
--- a/apps/api/src/main.py
+++ b/apps/api/src/main.py
@@ -24,7 +24,7 @@ from src.api.tool_types import router as tool_types_router
from src.api.notifications import router as notifications_router
from src.api.user_config import router as user_config_router
from src.api.users import router as users_router
-from src.api.workspaces import router as workspaces_router
+from src.api.workspaces import all_workspaces_router, router as workspaces_router
from src.config import Settings
from src.models.notification import Notification # noqa: F401 – Alembic model discovery
from src.models.terminal_session import TerminalSessionModel # noqa: F401 – Alembic model discovery
@@ -160,5 +160,6 @@ app.include_router(instance_proxy_router)
app.include_router(terminal_router)
app.include_router(events_router)
app.include_router(notifications_router)
+app.include_router(all_workspaces_router)
app.include_router(workspaces_router)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
diff --git a/apps/web/src/api/workspaces.ts b/apps/web/src/api/workspaces.ts
index d72fc78..b452132 100644
--- a/apps/web/src/api/workspaces.ts
+++ b/apps/web/src/api/workspaces.ts
@@ -22,6 +22,11 @@ export async function listWorkspaces(
return response.data;
}
+export async function listAllWorkspaces(): Promise {
+ const response = await apiClient.get("/workspaces/");
+ return response.data;
+}
+
export async function createWorkspace(
projectId: string,
repoId: string,
diff --git a/apps/web/src/hooks/use-workspaces.ts b/apps/web/src/hooks/use-workspaces.ts
index f27969f..2ad6c0a 100644
--- a/apps/web/src/hooks/use-workspaces.ts
+++ b/apps/web/src/hooks/use-workspaces.ts
@@ -1,7 +1,7 @@
/** Hook for fetching workspaces. */
import { useCallback, useEffect, useState } from "react";
-import { listWorkspaces } from "../api/workspaces";
+import { listAllWorkspaces, listWorkspaces } from "../api/workspaces";
import type { Workspace } from "../types/workspace";
export interface UseWorkspacesResult {
@@ -12,8 +12,8 @@ export interface UseWorkspacesResult {
}
export function useWorkspaces(
- projectId: string,
- repoId: string,
+ projectId?: string,
+ repoId?: string,
): UseWorkspacesResult {
const [workspaces, setWorkspaces] = useState([]);
const [loading, setLoading] = useState(true);
@@ -23,7 +23,10 @@ export function useWorkspaces(
setLoading(true);
setError(null);
try {
- const data = await listWorkspaces(projectId, repoId);
+ const data =
+ projectId && repoId
+ ? await listWorkspaces(projectId, repoId)
+ : await listAllWorkspaces();
setWorkspaces(data);
} catch (err) {
setError(
diff --git a/apps/web/src/pages/workspaces.tsx b/apps/web/src/pages/workspaces.tsx
index 9a1f681..bf22f14 100644
--- a/apps/web/src/pages/workspaces.tsx
+++ b/apps/web/src/pages/workspaces.tsx
@@ -13,29 +13,25 @@ import type { Workspace } from "../types/workspace";
export function WorkspacesPage() {
const [showCreate, setShowCreate] = useState(false);
const [startWorkspace, setStartWorkspace] = useState(null);
+ const [createTarget, setCreateTarget] = useState<{ projectId: string; repoId: string } | null>(null);
- // TODO: Get projectId and repoId from URL params or context
- const projectId = "default-project";
- const repoId = "default-repo";
-
- const { workspaces, loading, error, refresh } = useWorkspaces(
- projectId,
- repoId,
- );
+ const { workspaces, loading, error, refresh } = useWorkspaces();
const actions = useWorkspaceActions();
const handleCreate = async (data: { name: string; branch: string }) => {
- await actions.create(projectId, repoId, data);
+ if (!createTarget) return;
+ await actions.create(createTarget.projectId, createTarget.repoId, data);
setShowCreate(false);
+ setCreateTarget(null);
await refresh();
};
const handleDelete = async (workspace: Workspace) => {
- await actions.delete(projectId, repoId, workspace, refresh);
+ await actions.delete(workspace.project_id, workspace.repo_id, workspace, refresh);
};
const handleSync = async (workspace: Workspace) => {
- await actions.sync(projectId, repoId, workspace, refresh);
+ await actions.sync(workspace.project_id, workspace.repo_id, workspace, refresh);
};
const handleStartTool = async (
@@ -45,8 +41,8 @@ export function WorkspacesPage() {
if (!startWorkspace) return;
try {
const instance = await createInstance(
- projectId,
- repoId,
+ startWorkspace.project_id,
+ startWorkspace.repo_id,
toolTypeId,
`${startWorkspace.name} - ${toolTypeId}`,
undefined,
@@ -56,7 +52,7 @@ export function WorkspacesPage() {
[],
startWorkspace.id,
);
- await startInstance(projectId, repoId, instance.id, configProfileId);
+ await startInstance(startWorkspace.project_id, startWorkspace.repo_id, instance.id, configProfileId);
setStartWorkspace(null);
await refresh();
} catch (err) {
@@ -76,23 +72,31 @@ export function WorkspacesPage() {
>
-
+
{error && {error}
}
- {showCreate && (
+ {showCreate && createTarget && (
setShowCreate(false)}
+ onCancel={() => { setShowCreate(false); setCreateTarget(null); }}
/>
)}
@@ -101,12 +105,7 @@ export function WorkspacesPage() {
) : workspaces.length === 0 ? (
No workspaces yet.
-
+
Navigate to a project to create your first workspace.
) : (
diff --git a/apps/web/src/types/workspace.ts b/apps/web/src/types/workspace.ts
index 801d0bb..8051129 100644
--- a/apps/web/src/types/workspace.ts
+++ b/apps/web/src/types/workspace.ts
@@ -5,6 +5,7 @@ export interface Workspace {
name: string;
repo_id: string;
repo_name: string;
+ project_id: string;
project_name: string;
user_id: string;
branch: string;
From e7587ca9f5a46054ea7dc4fe465337411960d9d7 Mon Sep 17 00:00:00 2001
From: Alex Blank
Date: Mon, 1 Jun 2026 16:47:09 +0200
Subject: [PATCH 21/44] feat: workspace-first UI refresh - PR-1 backend
endpoints
- Add FileService for workspace-scoped file operations
- Add GitOperations service for workspace-scoped git commands
- Add workspace_files API: GET/POST /workspaces/{id}/files
- Add workspace_git API: status, branches, commit, push, pull, fetch, checkout, history
- Add workspace_instances API: list instances per workspace
- Add top-level POST /workspaces/ (accepts repo_id directly)
- Enrich GET /projects/ with nested repositories and workspaces
- Register all new routers in main.py
- 23 tests passing (17 existing + 6 new)
Quality gates: ruff clean
---
apps/api/src/api/projects.py | 66 +-
apps/api/src/api/workspace_files.py | 114 +++
apps/api/src/api/workspace_git.py | 203 +++++
apps/api/src/api/workspace_instances.py | 60 ++
apps/api/src/api/workspaces.py | 55 +-
apps/api/src/main.py | 6 +
apps/api/src/services/file_service.py | 128 ++++
apps/api/src/services/git_operations.py | 225 ++++++
apps/api/tests/unit/test_file_service.py | 84 +++
apps/web/src/pages/workspaces.tsx | 62 +-
openspec/changes/workspace-first-ui/design.md | 694 ++++++++++++++++++
.../changes/workspace-first-ui/proposal.md | 184 +++++
openspec/changes/workspace-first-ui/spec.md | 366 +++++++++
openspec/changes/workspace-first-ui/tasks.md | 132 ++++
14 files changed, 2347 insertions(+), 32 deletions(-)
create mode 100644 apps/api/src/api/workspace_files.py
create mode 100644 apps/api/src/api/workspace_git.py
create mode 100644 apps/api/src/api/workspace_instances.py
create mode 100644 apps/api/src/services/file_service.py
create mode 100644 apps/api/src/services/git_operations.py
create mode 100644 apps/api/tests/unit/test_file_service.py
create mode 100644 openspec/changes/workspace-first-ui/design.md
create mode 100644 openspec/changes/workspace-first-ui/proposal.md
create mode 100644 openspec/changes/workspace-first-ui/spec.md
create mode 100644 openspec/changes/workspace-first-ui/tasks.md
diff --git a/apps/api/src/api/projects.py b/apps/api/src/api/projects.py
index 67cde56..3dbc01c 100644
--- a/apps/api/src/api/projects.py
+++ b/apps/api/src/api/projects.py
@@ -4,13 +4,14 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel, ConfigDict
-from sqlalchemy import select
+from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
+from src.models.tool_instance import ToolInstance
router = APIRouter(prefix="/projects", tags=["projects"])
@@ -76,26 +77,67 @@ async def create_project(
@router.get(
"",
- response_model=list[ProjectResponse],
summary="List all projects",
- description="Retrieve all projects owned by the authenticated user.",
+ description="Retrieve all projects owned by the authenticated user with repositories and workspaces.",
)
async def list_projects(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
-) -> list[Project]:
+) -> list[dict]:
"""List all projects for the authenticated user.
- Args:
- user_id: ID of the authenticated user.
- session: Database session.
-
- Returns:
- List of projects owned by the user.
+ Returns projects with nested repositories and workspaces for inline display.
"""
user = await _get_user(session, user_id)
- result = await session.execute(select(Project).where(Project.owner_id == user.id))
- return list(result.scalars().all())
+ result = await session.execute(
+ select(Project).where(Project.owner_id == user.id).order_by(Project.created_at.desc())
+ )
+ projects = result.scalars().all()
+
+ from src.models.workspace import Workspace
+
+ enriched = []
+ for project in projects:
+ repos_result = await session.execute(
+ select(GitRepository).where(GitRepository.project_id == project.id)
+ )
+ repositories = []
+ for repo in repos_result.scalars().all():
+ ws_result = await session.execute(
+ select(Workspace).where(Workspace.repo_id == repo.id)
+ )
+ workspaces = []
+ for ws in ws_result.scalars().all():
+ # Count instances
+ inst_result = await session.execute(
+ select(func.count()).where(ToolInstance.workspace_id == ws.id)
+ )
+ instance_count = inst_result.scalar() or 0
+ workspaces.append({
+ "id": str(ws.id),
+ "name": ws.name,
+ "branch": ws.branch,
+ "status": ws.status,
+ "instance_count": instance_count,
+ })
+
+ repositories.append({
+ "id": str(repo.id),
+ "name": repo.name,
+ "remote_url": repo.remote_url,
+ "workspaces": workspaces,
+ })
+
+ enriched.append({
+ "id": str(project.id),
+ "name": project.name,
+ "description": project.description,
+ "owner_id": str(project.owner_id),
+ "repositories": repositories,
+ "created_at": project.created_at.isoformat() if project.created_at else None,
+ })
+
+ return enriched
@router.get(
diff --git a/apps/api/src/api/workspace_files.py b/apps/api/src/api/workspace_files.py
new file mode 100644
index 0000000..ab29e59
--- /dev/null
+++ b/apps/api/src/api/workspace_files.py
@@ -0,0 +1,114 @@
+"""Workspace file API endpoints."""
+
+import uuid
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.auth.dependencies import get_current_user_id, get_db_session
+from src.models.workspace import Workspace
+from src.services.file_service import FileService
+
+router = APIRouter(prefix="/workspaces/{workspace_id}/files")
+
+
+async def _get_workspace(
+ session: AsyncSession,
+ workspace_id: uuid.UUID,
+ user_id: uuid.UUID,
+) -> Workspace:
+ from sqlalchemy import select
+
+ result = await session.execute(
+ select(Workspace).where(
+ Workspace.id == workspace_id,
+ Workspace.user_id == user_id,
+ )
+ )
+ workspace = result.scalar_one_or_none()
+ if not workspace:
+ raise HTTPException(status_code=404, detail="Workspace not found")
+ return workspace
+
+
+@router.get("/")
+async def list_files(
+ workspace_id: uuid.UUID,
+ path: str = "",
+ user_id: uuid.UUID = Depends(get_current_user_id),
+ session: AsyncSession = Depends(get_db_session),
+) -> dict:
+ """List files in a workspace directory."""
+ workspace = await _get_workspace(session, workspace_id, user_id)
+ service = FileService()
+ try:
+ entries = service.list_directory(workspace, path)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+ return {
+ "entries": [
+ {
+ "name": e.name,
+ "path": e.path,
+ "type": e.type,
+ "size": e.size,
+ }
+ for e in entries
+ ],
+ }
+
+
+@router.get("/content")
+async def get_file_content(
+ workspace_id: uuid.UUID,
+ path: str,
+ user_id: uuid.UUID = Depends(get_current_user_id),
+ session: AsyncSession = Depends(get_db_session),
+) -> dict:
+ """Get the content of a text file."""
+ workspace = await _get_workspace(session, workspace_id, user_id)
+ service = FileService()
+ try:
+ content = service.read_file(workspace, path)
+ except FileNotFoundError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+ return {"content": content, "path": path}
+
+
+@router.post("/content")
+async def write_file(
+ workspace_id: uuid.UUID,
+ data: dict,
+ user_id: uuid.UUID = Depends(get_current_user_id),
+ session: AsyncSession = Depends(get_db_session),
+) -> dict:
+ """Write a file and optionally commit."""
+ workspace = await _get_workspace(session, workspace_id, user_id)
+ service = FileService()
+
+ file_path = data.get("path", "").strip()
+ content = data.get("content", "")
+ commit_message = data.get("message", "").strip()
+
+ if not file_path:
+ raise HTTPException(status_code=400, detail="File path is required")
+
+ try:
+ service.write_file(workspace, file_path, content)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+ if commit_message:
+ from src.services.git_operations import GitOperations
+
+ git = GitOperations(workspace)
+ try:
+ await git.commit(commit_message)
+ except RuntimeError as exc:
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+
+ return {"status": "saved", "path": file_path}
diff --git a/apps/api/src/api/workspace_git.py b/apps/api/src/api/workspace_git.py
new file mode 100644
index 0000000..0a8177c
--- /dev/null
+++ b/apps/api/src/api/workspace_git.py
@@ -0,0 +1,203 @@
+"""Workspace git API endpoints."""
+
+import uuid
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.auth.dependencies import get_current_user_id, get_db_session
+from src.models.workspace import Workspace
+from src.services.git_operations import GitOperations
+
+router = APIRouter(prefix="/workspaces/{workspace_id}/git")
+
+
+async def _get_workspace(
+ session: AsyncSession,
+ workspace_id: uuid.UUID,
+ user_id: uuid.UUID,
+) -> Workspace:
+ from sqlalchemy import select
+
+ result = await session.execute(
+ select(Workspace).where(
+ Workspace.id == workspace_id,
+ Workspace.user_id == user_id,
+ )
+ )
+ workspace = result.scalar_one_or_none()
+ if not workspace:
+ raise HTTPException(status_code=404, detail="Workspace not found")
+ return workspace
+
+
+@router.get("/status")
+async def git_status(
+ workspace_id: uuid.UUID,
+ user_id: uuid.UUID = Depends(get_current_user_id),
+ session: AsyncSession = Depends(get_db_session),
+) -> dict:
+ """Get git status for the workspace."""
+ workspace = await _get_workspace(session, workspace_id, user_id)
+ git = GitOperations(workspace)
+ try:
+ status = await git.status()
+ except RuntimeError as exc:
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+
+ return {
+ "branch": status.branch,
+ "modified": status.modified,
+ "added": status.added,
+ "deleted": status.deleted,
+ "untracked": status.untracked,
+ "ahead": status.ahead,
+ "behind": status.behind,
+ }
+
+
+@router.get("/branches")
+async def git_branches(
+ workspace_id: uuid.UUID,
+ user_id: uuid.UUID = Depends(get_current_user_id),
+ session: AsyncSession = Depends(get_db_session),
+) -> dict:
+ """List branches for the workspace."""
+ workspace = await _get_workspace(session, workspace_id, user_id)
+ git = GitOperations(workspace)
+ try:
+ branches, current = await git.branches()
+ except RuntimeError as exc:
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+
+ return {
+ "branches": branches,
+ "current_branch": current,
+ }
+
+
+@router.post("/commit")
+async def git_commit(
+ workspace_id: uuid.UUID,
+ data: dict,
+ user_id: uuid.UUID = Depends(get_current_user_id),
+ session: AsyncSession = Depends(get_db_session),
+) -> dict:
+ """Stage all changes and commit."""
+ workspace = await _get_workspace(session, workspace_id, user_id)
+ message = data.get("message", "").strip()
+ if not message:
+ raise HTTPException(status_code=400, detail="Commit message is required")
+
+ git = GitOperations(workspace)
+ try:
+ await git.commit(message)
+ except RuntimeError as exc:
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+
+ return {"status": "committed"}
+
+
+@router.post("/push")
+async def git_push(
+ workspace_id: uuid.UUID,
+ user_id: uuid.UUID = Depends(get_current_user_id),
+ session: AsyncSession = Depends(get_db_session),
+) -> dict:
+ """Push current branch."""
+ workspace = await _get_workspace(session, workspace_id, user_id)
+ git = GitOperations(workspace)
+ try:
+ await git.push()
+ except RuntimeError as exc:
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+
+ return {"status": "pushed"}
+
+
+@router.post("/pull")
+async def git_pull(
+ workspace_id: uuid.UUID,
+ user_id: uuid.UUID = Depends(get_current_user_id),
+ session: AsyncSession = Depends(get_db_session),
+) -> dict:
+ """Pull current branch."""
+ workspace = await _get_workspace(session, workspace_id, user_id)
+ git = GitOperations(workspace)
+ try:
+ await git.pull()
+ except RuntimeError as exc:
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+
+ return {"status": "pulled"}
+
+
+@router.post("/fetch")
+async def git_fetch(
+ workspace_id: uuid.UUID,
+ user_id: uuid.UUID = Depends(get_current_user_id),
+ session: AsyncSession = Depends(get_db_session),
+) -> dict:
+ """Fetch from origin."""
+ workspace = await _get_workspace(session, workspace_id, user_id)
+ git = GitOperations(workspace)
+ try:
+ await git.fetch()
+ except RuntimeError as exc:
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+
+ return {"status": "fetched"}
+
+
+@router.post("/checkout")
+async def git_checkout(
+ workspace_id: uuid.UUID,
+ data: dict,
+ user_id: uuid.UUID = Depends(get_current_user_id),
+ session: AsyncSession = Depends(get_db_session),
+) -> dict:
+ """Checkout a branch."""
+ workspace = await _get_workspace(session, workspace_id, user_id)
+ branch = data.get("branch", "").strip()
+ if not branch:
+ raise HTTPException(status_code=400, detail="Branch name is required")
+
+ git = GitOperations(workspace)
+ try:
+ await git.checkout(branch)
+ except RuntimeError as exc:
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+
+ workspace.branch = branch
+ await session.commit()
+
+ return {"status": "checked_out", "branch": branch}
+
+
+@router.get("/history")
+async def git_history(
+ workspace_id: uuid.UUID,
+ path: str | None = None,
+ limit: int = 50,
+ user_id: uuid.UUID = Depends(get_current_user_id),
+ session: AsyncSession = Depends(get_db_session),
+) -> dict:
+ """Get commit history."""
+ workspace = await _get_workspace(session, workspace_id, user_id)
+ git = GitOperations(workspace)
+ try:
+ commits = await git.history(path, limit)
+ except RuntimeError as exc:
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+
+ return {
+ "commits": [
+ {
+ "hash": c.hash,
+ "message": c.message,
+ "author": c.author,
+ "date": c.date,
+ }
+ for c in commits
+ ],
+ }
diff --git a/apps/api/src/api/workspace_instances.py b/apps/api/src/api/workspace_instances.py
new file mode 100644
index 0000000..aaaea88
--- /dev/null
+++ b/apps/api/src/api/workspace_instances.py
@@ -0,0 +1,60 @@
+"""Workspace instance API endpoints."""
+
+import uuid
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.auth.dependencies import get_current_user_id, get_db_session
+from src.models.tool_instance import ToolInstance
+from src.models.workspace import Workspace
+
+router = APIRouter(prefix="/workspaces/{workspace_id}/instances")
+
+
+async def _get_workspace(
+ session: AsyncSession,
+ workspace_id: uuid.UUID,
+ user_id: uuid.UUID,
+) -> Workspace:
+ result = await session.execute(
+ select(Workspace).where(
+ Workspace.id == workspace_id,
+ Workspace.user_id == user_id,
+ )
+ )
+ workspace = result.scalar_one_or_none()
+ if not workspace:
+ raise HTTPException(status_code=404, detail="Workspace not found")
+ return workspace
+
+
+@router.get("/")
+async def list_workspace_instances(
+ workspace_id: uuid.UUID,
+ user_id: uuid.UUID = Depends(get_current_user_id),
+ session: AsyncSession = Depends(get_db_session),
+) -> list[dict]:
+ """List tool instances using this workspace."""
+ await _get_workspace(session, workspace_id, user_id)
+ result = await session.execute(
+ select(ToolInstance)
+ .where(ToolInstance.workspace_id == workspace_id)
+ .order_by(ToolInstance.created_at.desc())
+ )
+ instances = result.scalars().all()
+
+ return [
+ {
+ "id": str(i.id),
+ "name": i.name,
+ "display_name": i.display_name,
+ "status": i.status,
+ "tool_type_id": str(i.tool_type_id),
+ "url": i.url,
+ "port": i.port,
+ "created_at": i.created_at.isoformat() if i.created_at else None,
+ }
+ for i in instances
+ ]
diff --git a/apps/api/src/api/workspaces.py b/apps/api/src/api/workspaces.py
index 1c20c89..0f8f4ad 100644
--- a/apps/api/src/api/workspaces.py
+++ b/apps/api/src/api/workspaces.py
@@ -53,7 +53,7 @@ async def list_all_workspaces(
"repo_id": str(ws.repo_id),
"repo_name": repo_name or "",
"project_id": str(project_id) if project_id else "",
- "project_name": "", # Could join with Project if needed
+ "project_name": "",
"user_id": str(ws.user_id),
"branch": ws.branch,
"path": ws.path,
@@ -67,6 +67,59 @@ async def list_all_workspaces(
]
+@all_workspaces_router.post("/")
+async def create_workspace_top_level(
+ data: dict,
+ user_id: uuid.UUID = Depends(get_current_user_id),
+ session: AsyncSession = Depends(get_db_session),
+) -> dict:
+ """Create a workspace directly (no nested project/repo path)."""
+ repo_id_str = data.get("repo_id", "").strip()
+ if not repo_id_str:
+ raise HTTPException(status_code=400, detail="repo_id is required")
+
+ try:
+ repo_id = uuid.UUID(repo_id_str)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail="Invalid repo_id format") from exc
+
+ repo = await session.get(GitRepository, repo_id)
+ if not repo or repo.owner_id != user_id:
+ raise HTTPException(status_code=404, detail="Repository not found")
+
+ name = data.get("name", "").strip()
+ branch = data.get("branch", "main").strip()
+
+ if not name:
+ raise HTTPException(status_code=400, detail="Workspace name is required")
+
+ manager = WorkspaceManager()
+ try:
+ workspace = await manager.create(repo, user_id, name, branch)
+ session.add(workspace)
+ await session.commit()
+ except Exception as exc:
+ await session.rollback()
+ logger.error("Failed to create workspace: %s", exc)
+ raise HTTPException(
+ status_code=409,
+ detail="Workspace name already exists for this repository",
+ ) from exc
+
+ await session.refresh(workspace)
+ return {
+ "id": str(workspace.id),
+ "name": workspace.name,
+ "repo_id": str(workspace.repo_id),
+ "branch": workspace.branch,
+ "path": workspace.path,
+ "status": workspace.status,
+ "created_at": workspace.created_at.isoformat()
+ if workspace.created_at
+ else None,
+ }
+
+
@router.get("/")
async def list_workspaces(
project_id: uuid.UUID,
diff --git a/apps/api/src/main.py b/apps/api/src/main.py
index b10e897..d94f8a5 100644
--- a/apps/api/src/main.py
+++ b/apps/api/src/main.py
@@ -24,6 +24,9 @@ from src.api.tool_types import router as tool_types_router
from src.api.notifications import router as notifications_router
from src.api.user_config import router as user_config_router
from src.api.users import router as users_router
+from src.api.workspace_files import router as workspace_files_router
+from src.api.workspace_git import router as workspace_git_router
+from src.api.workspace_instances import router as workspace_instances_router
from src.api.workspaces import all_workspaces_router, router as workspaces_router
from src.config import Settings
from src.models.notification import Notification # noqa: F401 – Alembic model discovery
@@ -162,4 +165,7 @@ app.include_router(events_router)
app.include_router(notifications_router)
app.include_router(all_workspaces_router)
app.include_router(workspaces_router)
+app.include_router(workspace_files_router)
+app.include_router(workspace_git_router)
+app.include_router(workspace_instances_router)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
diff --git a/apps/api/src/services/file_service.py b/apps/api/src/services/file_service.py
new file mode 100644
index 0000000..072caa6
--- /dev/null
+++ b/apps/api/src/services/file_service.py
@@ -0,0 +1,128 @@
+"""File operations scoped to a workspace directory."""
+
+import logging
+import os
+from dataclasses import dataclass
+
+from src.models.workspace import Workspace
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class FileEntry:
+ """A single file or directory entry."""
+
+ name: str
+ path: str
+ type: str # "file" or "directory"
+ size: int | None = None
+
+
+class FileService:
+ """Read and write files within a workspace directory."""
+
+ def list_directory(
+ self,
+ workspace: Workspace,
+ relative_path: str = "",
+ ) -> list[FileEntry]:
+ """List entries in a workspace directory.
+
+ Args:
+ workspace: The workspace to list files in.
+ relative_path: Path relative to workspace root.
+
+ Returns:
+ List of file entries sorted by name (directories first).
+ """
+ abs_path = os.path.join(workspace.path, relative_path)
+ abs_path = os.path.normpath(abs_path)
+
+ # Security: ensure we stay within workspace
+ if not abs_path.startswith(os.path.normpath(workspace.path)):
+ raise ValueError("Path escapes workspace directory")
+
+ if not os.path.exists(abs_path):
+ return []
+
+ entries = []
+ for item in sorted(os.listdir(abs_path)):
+ full = os.path.join(abs_path, item)
+ rel = os.path.join(relative_path, item) if relative_path else item
+ is_dir = os.path.isdir(full)
+ size = os.path.getsize(full) if os.path.isfile(full) else None
+ entries.append(
+ FileEntry(
+ name=item,
+ path=rel.replace("\\", "/"),
+ type="directory" if is_dir else "file",
+ size=size,
+ )
+ )
+
+ # Directories first, then files, both alphabetical
+ entries.sort(key=lambda e: (0 if e.type == "directory" else 1, e.name.lower()))
+ return entries
+
+ def read_file(self, workspace: Workspace, relative_path: str) -> str:
+ """Read a text file from the workspace.
+
+ Args:
+ workspace: The workspace to read from.
+ relative_path: Path relative to workspace root.
+
+ Returns:
+ File contents as string.
+
+ Raises:
+ ValueError: If path escapes workspace or file is binary.
+ FileNotFoundError: If file does not exist.
+ """
+ abs_path = self._resolve_path(workspace, relative_path)
+
+ if not os.path.isfile(abs_path):
+ raise FileNotFoundError(f"Not a file: {relative_path}")
+
+ # Basic binary check — read first 8KB and look for null bytes
+ with open(abs_path, "rb") as f:
+ chunk = f.read(8192)
+ if b"\x00" in chunk:
+ raise ValueError("Binary files cannot be viewed")
+
+ with open(abs_path, encoding="utf-8", errors="replace") as f:
+ return f.read()
+
+ def write_file(
+ self,
+ workspace: Workspace,
+ relative_path: str,
+ content: str,
+ ) -> None:
+ """Write a text file to the workspace.
+
+ Args:
+ workspace: The workspace to write to.
+ relative_path: Path relative to workspace root.
+ content: File contents.
+
+ Raises:
+ ValueError: If path escapes workspace.
+ """
+ abs_path = self._resolve_path(workspace, relative_path)
+ os.makedirs(os.path.dirname(abs_path), exist_ok=True)
+
+ with open(abs_path, "w", encoding="utf-8") as f:
+ f.write(content)
+
+ logger.info("Wrote file %s in workspace %s", relative_path, workspace.id)
+
+ def _resolve_path(self, workspace: Workspace, relative_path: str) -> str:
+ """Resolve a relative path to absolute, with security check."""
+ abs_path = os.path.normpath(os.path.join(workspace.path, relative_path))
+ workspace_root = os.path.normpath(workspace.path)
+
+ if not abs_path.startswith(workspace_root):
+ raise ValueError("Path escapes workspace directory")
+
+ return abs_path
diff --git a/apps/api/src/services/git_operations.py b/apps/api/src/services/git_operations.py
new file mode 100644
index 0000000..45b7134
--- /dev/null
+++ b/apps/api/src/services/git_operations.py
@@ -0,0 +1,225 @@
+"""Git commands scoped to a workspace directory."""
+
+import asyncio
+import logging
+from dataclasses import dataclass
+
+from src.models.workspace import Workspace
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class GitStatus:
+ """Parsed git status output."""
+
+ branch: str
+ modified: list[str]
+ added: list[str]
+ deleted: list[str]
+ untracked: list[str]
+ ahead: int = 0
+ behind: int = 0
+
+
+@dataclass
+class Commit:
+ """A single git commit."""
+
+ hash: str
+ message: str
+ author: str
+ date: str
+
+
+class GitOperations:
+ """Run git commands within a workspace directory."""
+
+ def __init__(self, workspace: Workspace) -> None:
+ self.cwd = workspace.path
+ self.branch = workspace.branch
+
+ async def _run(self, *cmd: str) -> tuple[int, str, str]:
+ """Run a git command and return (returncode, stdout, stderr)."""
+ proc = await asyncio.create_subprocess_exec(
+ *cmd,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ stdout, stderr = await proc.communicate()
+ return proc.returncode or 0, stdout.decode(), stderr.decode()
+
+ async def status(self) -> GitStatus:
+ """Get git status for the workspace."""
+ returncode, stdout, _ = await self._run(
+ "git", "-C", self.cwd, "status", "--porcelain", "-b"
+ )
+
+ modified: list[str] = []
+ added: list[str] = []
+ deleted: list[str] = []
+ untracked: list[str] = []
+ branch = self.branch
+ ahead = 0
+ behind = 0
+
+ for line in stdout.splitlines():
+ if line.startswith("##"):
+ # Branch info line
+ branch_info = line[3:].strip()
+ if "..." in branch_info:
+ branch = branch_info.split("...")[0]
+ if "[ahead " in branch_info:
+ ahead_str = branch_info.split("[ahead ")[1].split("]")[0]
+ ahead = int(ahead_str.split(",")[0])
+ if "[behind " in branch_info:
+ behind_str = branch_info.split("[behind ")[1].split("]")[0]
+ behind = int(behind_str.split(",")[0])
+ else:
+ branch = branch_info
+ continue
+
+ if len(line) < 3:
+ continue
+
+ status_code = line[:2]
+ file_path = line[3:]
+
+ # XY format: X = index status, Y = working tree status
+ if status_code == "??":
+ untracked.append(file_path)
+ elif status_code[1] == "D" or status_code[0] == "D":
+ deleted.append(file_path)
+ elif status_code[0] == "A" or status_code[1] == "A":
+ added.append(file_path)
+ else:
+ modified.append(file_path)
+
+ return GitStatus(
+ branch=branch,
+ modified=modified,
+ added=added,
+ deleted=deleted,
+ untracked=untracked,
+ ahead=ahead,
+ behind=behind,
+ )
+
+ async def commit(self, message: str) -> None:
+ """Stage all changes and commit."""
+ rc, _, err = await self._run("git", "-C", self.cwd, "add", "-A")
+ if rc != 0:
+ raise RuntimeError(f"Git add failed: {err}")
+
+ rc, _, err = await self._run(
+ "git", "-C", self.cwd, "commit", "-m", message
+ )
+ if rc != 0:
+ raise RuntimeError(f"Git commit failed: {err}")
+
+ logger.info("Committed in workspace: %s", self.cwd)
+
+ async def push(self) -> None:
+ """Push current branch to origin."""
+ rc, _, err = await self._run(
+ "git", "-C", self.cwd, "push", "origin", self.branch
+ )
+ if rc != 0:
+ raise RuntimeError(f"Git push failed: {err}")
+
+ logger.info("Pushed branch %s from workspace: %s", self.branch, self.cwd)
+
+ async def pull(self) -> None:
+ """Pull current branch from origin."""
+ rc, _, err = await self._run(
+ "git", "-C", self.cwd, "pull", "origin", self.branch
+ )
+ if rc != 0:
+ raise RuntimeError(f"Git pull failed: {err}")
+
+ logger.info("Pulled branch %s in workspace: %s", self.branch, self.cwd)
+
+ async def fetch(self) -> None:
+ """Fetch from origin."""
+ rc, _, err = await self._run("git", "-C", self.cwd, "fetch", "origin")
+ if rc != 0:
+ raise RuntimeError(f"Git fetch failed: {err}")
+
+ logger.info("Fetched origin for workspace: %s", self.cwd)
+
+ async def checkout(self, branch: str) -> None:
+ """Checkout a branch."""
+ rc, _, err = await self._run("git", "-C", self.cwd, "checkout", branch)
+ if rc != 0:
+ raise RuntimeError(f"Git checkout failed: {err}")
+
+ self.branch = branch
+ logger.info("Checked out branch %s in workspace: %s", branch, self.cwd)
+
+ async def history(self, path: str | None = None, limit: int = 50) -> list[Commit]:
+ """Get commit history.
+
+ Args:
+ path: Optional file path to filter history.
+ limit: Maximum number of commits.
+
+ Returns:
+ List of commits.
+ """
+ cmd = [
+ "git",
+ "-C",
+ self.cwd,
+ "log",
+ f"--max-count={limit}",
+ "--pretty=format:%H|%s|%an|%ad",
+ "--date=iso",
+ ]
+ if path:
+ cmd.extend(["--", path])
+
+ rc, stdout, err = await self._run(*cmd)
+ if rc != 0:
+ raise RuntimeError(f"Git log failed: {err}")
+
+ commits = []
+ for line in stdout.strip().splitlines():
+ parts = line.split("|", 3)
+ if len(parts) >= 4:
+ commits.append(
+ Commit(
+ hash=parts[0],
+ message=parts[1],
+ author=parts[2],
+ date=parts[3],
+ )
+ )
+
+ return commits
+
+ async def branches(self) -> tuple[list[str], str]:
+ """List all branches and current branch.
+
+ Returns:
+ Tuple of (all_branches, current_branch).
+ """
+ rc, stdout, err = await self._run(
+ "git", "-C", self.cwd, "branch", "-a", "--format=%(refname:short)"
+ )
+ if rc != 0:
+ raise RuntimeError(f"Git branch failed: {err}")
+
+ branches = []
+ current = self.branch
+ for line in stdout.strip().splitlines():
+ line = line.strip()
+ if line.startswith("HEAD") or line.endswith("/HEAD"):
+ continue
+ if line.startswith("remotes/origin/"):
+ branch_name = line.replace("remotes/origin/", "")
+ if branch_name not in branches:
+ branches.append(branch_name)
+ elif line and line not in branches:
+ branches.append(line)
+
+ return branches, current
diff --git a/apps/api/tests/unit/test_file_service.py b/apps/api/tests/unit/test_file_service.py
new file mode 100644
index 0000000..c799cc4
--- /dev/null
+++ b/apps/api/tests/unit/test_file_service.py
@@ -0,0 +1,84 @@
+"""Unit tests for FileService."""
+
+import os
+import tempfile
+
+import pytest
+
+from src.models.workspace import Workspace
+from src.services.file_service import FileService
+
+
+@pytest.fixture
+def temp_workspace():
+ """Create a temporary workspace directory."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ ws = Workspace(
+ id="00000000-0000-0000-0000-000000000001",
+ name="test-ws",
+ repo_id="00000000-0000-0000-0000-000000000002",
+ user_id="00000000-0000-0000-0000-000000000003",
+ branch="main",
+ path=tmpdir,
+ )
+ yield ws
+
+
+class TestFileService:
+ """Tests for FileService."""
+
+ def test_list_directory_empty(self, temp_workspace: Workspace):
+ """Returns empty list for empty directory."""
+ service = FileService()
+ entries = service.list_directory(temp_workspace)
+ assert entries == []
+
+ def test_list_directory_with_files(self, temp_workspace: Workspace):
+ """Returns entries sorted (dirs first, then files)."""
+ # Create files and dirs
+ os.makedirs(os.path.join(temp_workspace.path, "src"))
+ with open(os.path.join(temp_workspace.path, "README.md"), "w") as f:
+ f.write("# Test")
+ with open(os.path.join(temp_workspace.path, "main.py"), "w") as f:
+ f.write("print('hello')")
+
+ service = FileService()
+ entries = service.list_directory(temp_workspace)
+
+ assert len(entries) == 3
+ assert entries[0].name == "src" and entries[0].type == "directory"
+ assert entries[1].name == "main.py" and entries[1].type == "file"
+ assert entries[2].name == "README.md" and entries[2].type == "file"
+
+ def test_read_file(self, temp_workspace: Workspace):
+ """Reads text file content."""
+ with open(os.path.join(temp_workspace.path, "test.txt"), "w") as f:
+ f.write("hello world")
+
+ service = FileService()
+ content = service.read_file(temp_workspace, "test.txt")
+ assert content == "hello world"
+
+ def test_read_binary_file_rejected(self, temp_workspace: Workspace):
+ """Rejects binary files."""
+ with open(os.path.join(temp_workspace.path, "binary.bin"), "wb") as f:
+ f.write(b"\x00\x01\x02")
+
+ service = FileService()
+ with pytest.raises(ValueError, match="Binary"):
+ service.read_file(temp_workspace, "binary.bin")
+
+ def test_write_file(self, temp_workspace: Workspace):
+ """Writes file to workspace."""
+ service = FileService()
+ service.write_file(temp_workspace, "nested/file.txt", "content")
+
+ assert os.path.exists(os.path.join(temp_workspace.path, "nested", "file.txt"))
+ with open(os.path.join(temp_workspace.path, "nested", "file.txt")) as f:
+ assert f.read() == "content"
+
+ def test_path_escapes_workspace(self, temp_workspace: Workspace):
+ """Rejects paths that escape workspace directory."""
+ service = FileService()
+ with pytest.raises(ValueError, match="escapes"):
+ service.list_directory(temp_workspace, "../outside")
diff --git a/apps/web/src/pages/workspaces.tsx b/apps/web/src/pages/workspaces.tsx
index bf22f14..7a9f8cc 100644
--- a/apps/web/src/pages/workspaces.tsx
+++ b/apps/web/src/pages/workspaces.tsx
@@ -13,7 +13,10 @@ import type { Workspace } from "../types/workspace";
export function WorkspacesPage() {
const [showCreate, setShowCreate] = useState(false);
const [startWorkspace, setStartWorkspace] = useState(null);
- const [createTarget, setCreateTarget] = useState<{ projectId: string; repoId: string } | null>(null);
+ const [createTarget, setCreateTarget] = useState<{
+ projectId: string;
+ repoId: string;
+ } | null>(null);
const { workspaces, loading, error, refresh } = useWorkspaces();
const actions = useWorkspaceActions();
@@ -27,11 +30,21 @@ export function WorkspacesPage() {
};
const handleDelete = async (workspace: Workspace) => {
- await actions.delete(workspace.project_id, workspace.repo_id, workspace, refresh);
+ await actions.delete(
+ workspace.project_id,
+ workspace.repo_id,
+ workspace,
+ refresh,
+ );
};
const handleSync = async (workspace: Workspace) => {
- await actions.sync(workspace.project_id, workspace.repo_id, workspace, refresh);
+ await actions.sync(
+ workspace.project_id,
+ workspace.repo_id,
+ workspace,
+ refresh,
+ );
};
const handleStartTool = async (
@@ -52,7 +65,12 @@ export function WorkspacesPage() {
[],
startWorkspace.id,
);
- await startInstance(startWorkspace.project_id, startWorkspace.repo_id, instance.id, configProfileId);
+ await startInstance(
+ startWorkspace.project_id,
+ startWorkspace.repo_id,
+ instance.id,
+ configProfileId,
+ );
setStartWorkspace(null);
await refresh();
} catch (err) {
@@ -72,20 +90,23 @@ export function WorkspacesPage() {
>
-
+
@@ -96,7 +117,10 @@ export function WorkspacesPage() {
projectId={createTarget.projectId}
repoId={createTarget.repoId}
onSubmit={handleCreate}
- onCancel={() => { setShowCreate(false); setCreateTarget(null); }}
+ onCancel={() => {
+ setShowCreate(false);
+ setCreateTarget(null);
+ }}
/>
)}
diff --git a/openspec/changes/workspace-first-ui/design.md b/openspec/changes/workspace-first-ui/design.md
new file mode 100644
index 0000000..2176baf
--- /dev/null
+++ b/openspec/changes/workspace-first-ui/design.md
@@ -0,0 +1,694 @@
+# Design: Workspace-First UI Refresh
+
+## Status
+
+| Field | Value |
+|---|---|
+| Phase | **Design** |
+| Based on | [Spec](spec.md) |
+| Next | Tasks |
+
+## Backend Design
+
+### Directory Structure
+
+```
+apps/api/src/
+├── api/
+│ ├── workspace_files.py # NEW: GET/POST /workspaces/{id}/files
+│ ├── workspace_git.py # NEW: /workspaces/{id}/git/*
+│ ├── workspace_instances.py # NEW: /workspaces/{id}/instances
+│ └── workspaces.py # MODIFIED: add repo_id to POST, enrich responses
+├── services/
+│ ├── git_operations.py # NEW: workspace-scoped git commands
+│ └── file_service.py # NEW: workspace file operations
+└── models/
+ └── workspace.py # UNCHANGED
+```
+
+### Service: FileService
+
+```python
+class FileService:
+ """Read/write files within a workspace directory."""
+
+ def list_directory(self, workspace: Workspace, path: str = "") -> list[FileEntry]:
+ abs_path = os.path.join(workspace.path, path)
+ entries = []
+ for item in os.listdir(abs_path):
+ full = os.path.join(abs_path, item)
+ stat = os.lstat(full)
+ entries.append(FileEntry(
+ name=item,
+ path=os.path.join(path, item),
+ type="directory" if os.path.isdir(full) else "file",
+ size=stat.st_size if os.path.isfile(full) else None,
+ ))
+ return entries
+
+ def read_file(self, workspace: Workspace, path: str) -> str:
+ abs_path = os.path.join(workspace.path, path)
+ with open(abs_path, "r") as f:
+ return f.read()
+
+ def write_file(self, workspace: Workspace, path: str, content: str) -> None:
+ abs_path = os.path.join(workspace.path, path)
+ os.makedirs(os.path.dirname(abs_path), exist_ok=True)
+ with open(abs_path, "w") as f:
+ f.write(content)
+```
+
+### Service: GitOperations
+
+```python
+class GitOperations:
+ """Git commands scoped to a workspace directory."""
+
+ def __init__(self, workspace: Workspace) -> None:
+ self.cwd = workspace.path
+ self.branch = workspace.branch
+
+ async def status(self) -> GitStatus:
+ proc = await asyncio.create_subprocess_exec(
+ "git", "-C", self.cwd, "status", "--porcelain",
+ stdout=asyncio.subprocess.PIPE,
+ )
+ stdout, _ = await proc.communicate()
+ return self._parse_status(stdout.decode())
+
+ async def commit(self, message: str) -> None:
+ await self._run("git", "-C", self.cwd, "add", "-A")
+ await self._run("git", "-C", self.cwd, "commit", "-m", message)
+
+ async def push(self) -> None:
+ await self._run("git", "-C", self.cwd, "push", "origin", self.branch)
+
+ async def pull(self) -> None:
+ await self._run("git", "-C", self.cwd, "pull", "origin", self.branch)
+
+ async def fetch(self) -> None:
+ await self._run("git", "-C", self.cwd, "fetch", "origin")
+
+ async def checkout(self, branch: str) -> None:
+ await self._run("git", "-C", self.cwd, "checkout", branch)
+
+ async def history(self, path: str | None = None, limit: int = 50) -> list[Commit]:
+ cmd = ["git", "-C", self.cwd, "log", f"--max-count={limit}", "--pretty=format:%H|%s|%an|%ad"]
+ if path:
+ cmd.extend(["--", path])
+ stdout = await self._run_stdout(*cmd)
+ return self._parse_log(stdout)
+```
+
+### API: Workspace Files
+
+```python
+@router.get("/{workspace_id}/files")
+async def list_files(workspace_id: uuid.UUID, path: str = ""):
+ workspace = await get_workspace(workspace_id)
+ entries = FileService().list_directory(workspace, path)
+ return {"entries": [e.dict() for e in entries]}
+
+@router.get("/{workspace_id}/files/content")
+async def get_file_content(workspace_id: uuid.UUID, path: str):
+ workspace = await get_workspace(workspace_id)
+ content = FileService().read_file(workspace, path)
+ return {"content": content, "path": path}
+
+@router.post("/{workspace_id}/files/content")
+async def write_file(workspace_id: uuid.UUID, data: dict):
+ workspace = await get_workspace(workspace_id)
+ FileService().write_file(workspace, data["path"], data["content"])
+ if data.get("message"):
+ await GitOperations(workspace).commit(data["message"])
+ return {"status": "saved"}
+```
+
+### API: Workspace Git
+
+```python
+@router.get("/{workspace_id}/git/status")
+async def git_status(workspace_id: uuid.UUID):
+ workspace = await get_workspace(workspace_id)
+ return await GitOperations(workspace).status()
+
+@router.post("/{workspace_id}/git/commit")
+async def git_commit(workspace_id: uuid.UUID, data: dict):
+ workspace = await get_workspace(workspace_id)
+ await GitOperations(workspace).commit(data["message"])
+ return {"status": "committed"}
+
+@router.post("/{workspace_id}/git/push")
+async def git_push(workspace_id: uuid.UUID):
+ workspace = await get_workspace(workspace_id)
+ await GitOperations(workspace).push()
+ return {"status": "pushed"}
+
+@router.post("/{workspace_id}/git/pull")
+async def git_pull(workspace_id: uuid.UUID):
+ workspace = await get_workspace(workspace_id)
+ await GitOperations(workspace).pull()
+ return {"status": "pulled"}
+
+@router.post("/{workspace_id}/git/fetch")
+async def git_fetch(workspace_id: uuid.UUID):
+ workspace = await get_workspace(workspace_id)
+ await GitOperations(workspace).fetch()
+ return {"status": "fetched"}
+
+@router.post("/{workspace_id}/git/checkout")
+async def git_checkout(workspace_id: uuid.UUID, data: dict):
+ workspace = await get_workspace(workspace_id)
+ await GitOperations(workspace).checkout(data["branch"])
+ workspace.branch = data["branch"]
+ await session.commit()
+ return {"status": "checked_out", "branch": data["branch"]}
+
+@router.get("/{workspace_id}/git/history")
+async def git_history(workspace_id: uuid.UUID, path: str | None = None, limit: int = 50):
+ workspace = await get_workspace(workspace_id)
+ return await GitOperations(workspace).history(path, limit)
+```
+
+### API: Workspace Instances
+
+```python
+@router.get("/{workspace_id}/instances")
+async def list_workspace_instances(workspace_id: uuid.UUID, session: AsyncSession):
+ result = await session.execute(
+ select(ToolInstance).where(ToolInstance.workspace_id == workspace_id)
+ )
+ return [instance_to_dict(i) for i in result.scalars().all()]
+
+@router.post("/{workspace_id}/instances")
+async def create_workspace_instance(
+ workspace_id: uuid.UUID,
+ data: dict,
+ user_id: uuid.UUID,
+ session: AsyncSession,
+):
+ workspace = await get_workspace(workspace_id)
+ # Reuse existing create_instance logic but with workspace_id pre-set
+ return await create_instance_internal(
+ project_id=workspace.repo.project_id,
+ repo_id=workspace.repo_id,
+ tool_type_id=data["tool_type_id"],
+ workspace_id=workspace_id,
+ display_name=data.get("display_name"),
+ config_profile_id=data.get("config_profile_id"),
+ )
+```
+
+### Modified: Projects API
+
+```python
+@router.get("/")
+async def list_projects(user_id: uuid.UUID, session: AsyncSession):
+ result = await session.execute(
+ select(Project).where(Project.owner_id == user_id).order_by(Project.created_at.desc())
+ )
+ projects = []
+ for project in result.scalars().all():
+ repos = await session.execute(
+ select(GitRepository).where(GitRepository.project_id == project.id)
+ )
+ repo_list = []
+ for repo in repos.scalars().all():
+ workspaces = await session.execute(
+ select(Workspace).where(Workspace.repo_id == repo.id)
+ )
+ repo_list.append({
+ "id": str(repo.id),
+ "name": repo.name,
+ "remote_url": repo.remote_url,
+ "workspaces": [
+ {
+ "id": str(ws.id),
+ "name": ws.name,
+ "branch": ws.branch,
+ "status": ws.status,
+ "instance_count": ...,
+ }
+ for ws in workspaces.scalars().all()
+ ],
+ })
+ projects.append({
+ "id": str(project.id),
+ "name": project.name,
+ "description": project.description,
+ "repositories": repo_list,
+ })
+ return {"projects": projects}
+```
+
+## Frontend Design
+
+### Directory Structure
+
+```
+apps/web/src/
+├── pages/
+│ ├── workspace-detail.tsx # NEW: /workspaces/:id
+│ ├── projects.tsx # MODIFIED: inline repos + workspaces
+│ └── workspaces.tsx # MODIFIED: link to detail
+├── components/
+│ ├── workspace/
+│ │ ├── workspace-header.tsx # NEW: breadcrumb + actions
+│ │ ├── workspace-tabs.tsx # NEW: tab bar component
+│ │ ├── workspace-file-panel.tsx # NEW: Files tab (tree + viewer + git toolbar)
+│ │ ├── workspace-git-panel.tsx # NEW: Git tab (history + diff)
+│ │ ├── workspace-tools-panel.tsx # NEW: Tools tab (instances + spawn)
+│ │ ├── workspace-settings-panel.tsx # NEW: Settings tab
+│ │ ├── git-toolbar.tsx # NEW: collapsible git toolbar
+│ │ ├── file-tree.tsx # NEW: extracted from repo-workspace
+│ │ ├── file-viewer.tsx # NEW: extracted from repo-workspace
+│ │ └── start-tool-modal.tsx # EXISTING: move to workspace/
+│ ├── project/
+│ │ ├── project-card.tsx # NEW: card with inline repos
+│ │ ├── repo-section.tsx # NEW: expandable repo + workspaces
+│ │ ├── workspace-chip.tsx # NEW: small workspace card
+│ │ └── new-workspace-inline.tsx # NEW: inline form
+│ └── app-shell.tsx # MODIFIED: nav order
+├── hooks/
+│ ├── use-workspace-files.ts # NEW
+│ ├── use-workspace-git.ts # NEW
+│ ├── use-workspace-instances.ts # NEW
+│ └── use-projects-enriched.ts # NEW: projects with repos + workspaces
+├── api/
+│ ├── workspace-files.ts # NEW
+│ ├── workspace-git.ts # NEW
+│ ├── workspace-instances.ts # NEW
+│ └── projects.ts # MODIFIED: enriched response
+└── router.tsx # MODIFIED: routes
+```
+
+### Component: WorkspaceDetailPage
+
+```tsx
+export function WorkspaceDetailPage() {
+ const { workspaceId } = useParams();
+ const [activeTab, setActiveTab] = useState("files");
+ const { workspace, loading } = useWorkspace(workspaceId);
+
+ if (loading) return ;
+ if (!workspace) return ;
+
+ return (
+
+
+
+
+ {activeTab === "files" && }
+ {activeTab === "git" && }
+ {activeTab === "tools" && }
+ {activeTab === "settings" && }
+
+
+ );
+}
+```
+
+### Component: WorkspaceFilePanel
+
+```tsx
+export function WorkspaceFilePanel({ workspace }: { workspace: Workspace }) {
+ const [selectedPath, setSelectedPath] = useState(null);
+ const [isEditing, setIsEditing] = useState(false);
+ const { entries, loading } = useWorkspaceFiles(workspace.id);
+ const { content } = useWorkspaceFileContent(workspace.id, selectedPath);
+ const { status } = useWorkspaceGitStatus(workspace.id);
+
+ return (
+
+
+
+
+ setIsEditing(true)}
+ onSave={async (newContent, message) => {
+ await saveWorkspaceFile(workspace.id, selectedPath, newContent, message);
+ setIsEditing(false);
+ }}
+ />
+
+
+ );
+}
+```
+
+### Component: GitToolbar
+
+```tsx
+export function GitToolbar({ workspace, status }: GitToolbarProps) {
+ const [expanded, setExpanded] = useState(false);
+ const [commitMessage, setCommitMessage] = useState("");
+
+ return (
+
+
+ M {status.modified.length}
+ A {status.added.length}
+ D {status.deleted.length}
+
+
+
+
+
+ {expanded && (
+
+
+ )}
+
+ );
+}
+```
+
+### Component: WorkspaceGitPanel
+
+```tsx
+export function WorkspaceGitPanel({ workspace }: { workspace: Workspace }) {
+ const { history, loading } = useWorkspaceGitHistory(workspace.id);
+ const [selectedCommit, setSelectedCommit] = useState(null);
+
+ return (
+
+
+
+
+
+
+
+ {selectedCommit && (
+
+ )}
+
+
+ );
+}
+```
+
+### Component: WorkspaceToolsPanel
+
+```tsx
+export function WorkspaceToolsPanel({ workspace }: { workspace: Workspace }) {
+ const { instances, loading, refresh } = useWorkspaceInstances(workspace.id);
+ const [showModal, setShowModal] = useState(false);
+
+ return (
+
+ {instances.length === 0 ? (
+
setShowModal(true) }}
+ />
+ ) : (
+ <>
+
+ {instances.map((instance) => (
+
+ ))}
+
+
+ >
+ )}
+ {showModal && (
+ setShowModal(false)}
+ onStart={async (toolTypeId, configProfileId) => {
+ await createWorkspaceInstance(workspace.id, toolTypeId, configProfileId);
+ setShowModal(false);
+ refresh();
+ }}
+ />
+ )}
+
+ );
+}
+```
+
+### Component: ProjectCard (refreshed)
+
+```tsx
+export function ProjectCard({ project }: { project: EnrichedProject }) {
+ return (
+
+
+
{project.name}
+ {project.description &&
{project.description}
}
+
+
+ {project.repositories.map((repo) => (
+
+ ))}
+
+
+ Open
+
+
+
+
+ );
+}
+```
+
+### Component: RepoSection
+
+```tsx
+export function RepoSection({ repo, projectId }: RepoSectionProps) {
+ const [expanded, setExpanded] = useState(true);
+ const [showForm, setShowForm] = useState(false);
+
+ return (
+
+
+ {expanded && (
+
+ {repo.workspaces.map((ws) => (
+
+ {ws.name}
+ {ws.status}
+ {ws.instance_count > 0 && (
+ ● {ws.instance_count}
+ )}
+
+ ))}
+ {showForm ? (
+ setShowForm(false)}
+ onCancel={() => setShowForm(false)}
+ />
+ ) : (
+
+ )}
+
+ )}
+
+ );
+}
+```
+
+## Mobile Layout
+
+### Bottom Tab Bar
+
+```tsx
+export function MobileTabBar({ active, onChange }: MobileTabBarProps) {
+ const tabs: { id: Tab; icon: IconName; label: string }[] = [
+ { id: "files", icon: "folder", label: "Files" },
+ { id: "git", icon: "branch", label: "Git" },
+ { id: "tools", icon: "terminal", label: "Tools" },
+ { id: "settings", icon: "settings", label: "Settings" },
+ ];
+
+ return (
+
+ );
+}
+```
+
+### Mobile Workspace Detail
+
+```tsx
+export function MobileWorkspaceDetail({ workspace }: { workspace: Workspace }) {
+ const [activeTab, setActiveTab] = useState("files");
+
+ return (
+
+
+
+ {activeTab === "files" && }
+ {activeTab === "git" && }
+ {activeTab === "tools" && }
+ {activeTab === "settings" && }
+
+
+
+ );
+}
+```
+
+**Mobile Files tab**: Full-screen file tree. Tap file → opens viewer in slide-up panel.
+**Mobile Git tab**: Commit history list. Tap commit → diff in slide-up panel.
+**Mobile Tools tab**: Instance cards stacked, full width.
+**Mobile Settings tab**: Same as desktop, scrollable.
+
+## Routing
+
+```tsx
+// router.tsx changes
+} />
+} />
+} />
+} />
+// Remove old repo-workspace route
+// } /> — DELETED
+```
+
+## State Management
+
+### Hooks
+
+| Hook | Fetches | Polling |
+|---|---|---|
+| `useWorkspace(id)` | `GET /workspaces/{id}` | No |
+| `useWorkspaceFiles(id, path?)` | `GET /workspaces/{id}/files` | No |
+| `useWorkspaceFileContent(id, path?)` | `GET /workspaces/{id}/files/content` | No |
+| `useWorkspaceGitStatus(id)` | `GET /workspaces/{id}/git/status` | 10s when visible |
+| `useWorkspaceGitHistory(id)` | `GET /workspaces/{id}/git/history` | No |
+| `useWorkspaceInstances(id)` | `GET /workspaces/{id}/instances` | 10s when visible |
+| `useProjectsEnriched()` | `GET /projects/` | 30s |
+
+## Testing Strategy
+
+### Backend
+- Unit: FileService.list_directory, read_file, write_file
+- Unit: GitOperations.status, commit, push, pull, history
+- Integration: GET/POST /workspaces/{id}/files
+- Integration: /workspaces/{id}/git/* endpoints
+- Integration: /workspaces/{id}/instances
+- Integration: Enriched /projects/ response
+
+### Frontend
+- Component: WorkspaceFilePanel renders file tree + viewer
+- Component: GitToolbar expands/collapses, commits
+- Component: WorkspaceToolsPanel shows empty state + modal
+- Component: ProjectCard renders repos + workspace chips
+- Hook: useWorkspaceGitStatus polls correctly
+- Hook: useProjectsEnriched caches correctly
+
+## Out of Scope
+
+- Multi-file search/replace
+- Real-time collaborative editing
+- Workspace backup/restore
+- Git merge conflict UI
+- Terminal inside workspace page
+
+## Files Changed
+
+### New Files (Backend)
+- `apps/api/src/api/workspace_files.py`
+- `apps/api/src/api/workspace_git.py`
+- `apps/api/src/api/workspace_instances.py`
+- `apps/api/src/services/file_service.py`
+- `apps/api/src/services/git_operations.py`
+- `apps/api/tests/integration/test_workspace_files.py`
+- `apps/api/tests/integration/test_workspace_git.py`
+- `apps/api/tests/unit/test_file_service.py`
+- `apps/api/tests/unit/test_git_operations.py`
+
+### Modified Files (Backend)
+- `apps/api/src/api/workspaces.py` (add repo_id to POST, enrich responses)
+- `apps/api/src/api/projects.py` (enriched list response)
+- `apps/api/src/main.py` (register new routers)
+
+### New Files (Frontend)
+- `apps/web/src/pages/workspace-detail.tsx`
+- `apps/web/src/components/workspace/workspace-header.tsx`
+- `apps/web/src/components/workspace/workspace-tabs.tsx`
+- `apps/web/src/components/workspace/workspace-file-panel.tsx`
+- `apps/web/src/components/workspace/workspace-git-panel.tsx`
+- `apps/web/src/components/workspace/workspace-tools-panel.tsx`
+- `apps/web/src/components/workspace/workspace-settings-panel.tsx`
+- `apps/web/src/components/workspace/git-toolbar.tsx`
+- `apps/web/src/components/workspace/file-tree.tsx`
+- `apps/web/src/components/workspace/file-viewer.tsx`
+- `apps/web/src/components/project/project-card.tsx`
+- `apps/web/src/components/project/repo-section.tsx`
+- `apps/web/src/components/project/workspace-chip.tsx`
+- `apps/web/src/components/project/new-workspace-inline.tsx`
+- `apps/web/src/hooks/use-workspace-files.ts`
+- `apps/web/src/hooks/use-workspace-git.ts`
+- `apps/web/src/hooks/use-workspace-instances.ts`
+- `apps/web/src/hooks/use-projects-enriched.ts`
+- `apps/web/src/api/workspace-files.ts`
+- `apps/web/src/api/workspace-git.ts`
+- `apps/web/src/api/workspace-instances.ts`
+
+### Modified Files (Frontend)
+- `apps/web/src/pages/projects.tsx` (full rewrite)
+- `apps/web/src/pages/workspaces.tsx` (link to detail)
+- `apps/web/src/components/app-shell.tsx` (nav order)
+- `apps/web/src/router.tsx` (routes)
+- `apps/web/src/api/projects.ts` (enriched response types)
+
+### Deleted Files
+- `apps/web/src/pages/repo-workspace.tsx`
+- `apps/web/src/components/workspace-header.tsx`
+- `apps/web/src/components/git-toolbar.tsx` (old standalone version)
+- `apps/web/src/components/instance-list.tsx` (replaced by workspace-tools-panel)
diff --git a/openspec/changes/workspace-first-ui/proposal.md b/openspec/changes/workspace-first-ui/proposal.md
new file mode 100644
index 0000000..770586a
--- /dev/null
+++ b/openspec/changes/workspace-first-ui/proposal.md
@@ -0,0 +1,184 @@
+# Proposal: Workspace-First UI Refresh
+
+## Status
+
+| Field | Value |
+|---|---|
+| Phase | **Proposal** |
+| Based on | [Working Copies Spec](../working-copies/spec.md) |
+| Next | Spec |
+
+## Problem
+
+The current UI has two competing "workspace" concepts:
+
+1. **Old "Repository Workspace"** (`repo-workspace.tsx`): A file browser + editor + git toolbar view tied to a repository. This was the default view when opening a project. It reads files from the repo path directly and offers quick editing.
+2. **New "Workspace"** (`workspaces.tsx`): A list of persistent writable clones that tool instances mount. These are first-class entities with their own lifecycle.
+
+These two concepts confuse users. The old workspace is redundant now that workspaces are persistent clones — users should work inside a workspace, not directly on the repo.
+
+Additionally:
+- The Projects page only shows a list of projects with "Open Workspace" links — no visibility into repos or workspaces
+- Tool instances are spawned from the repo-workspace view, not from the workspace view
+- Mobile layout of the old workspace is cramped and not well-suited for the new paradigm
+
+## Solution
+
+Replace the old "Repository Workspace" with a **Workspace-First** navigation model:
+
+### New Information Architecture
+
+```
+Projects
+ └── Project Card (inline repos + workspaces)
+ └── Repo: "my-app"
+ ├── Workspace: "main" → /workspaces/{id}
+ ├── Workspace: "feature-auth" → /workspaces/{id}
+ └── [+ New Workspace]
+Workspaces
+ └── All Workspaces (grid/list)
+ └── Workspace Card → /workspaces/{id}
+```
+
+### Workspace Detail Page (`/workspaces/:workspaceId`)
+
+The workspace detail page is the primary work surface. It replaces the old repo-workspace:
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ {project} / {repo} / {workspace-name} [Start Tool ▼] │
+├──────────────┬───────────────────────────────────────────────┤
+│ │ Tabs: [Files] [Git] [Tools] │
+│ File Tree ├───────────────────────────────────────────────┤
+│ (workspace │ │
+│ clone) │ {active tab content} │
+│ │ │
+│ 📁 src/ │ │
+│ 📄 README │ │
+│ │ │
+├──────────────┤ │
+│ Git Status │ │
+│ (compact) │ │
+└──────────────┴───────────────────────────────────────────────┘
+```
+
+**Panels (collapsible, IDE-style):**
+- **Files**: File tree from workspace clone path + file viewer/editor
+- **Git**: Commit panel, branch selector, push/pull/fetch actions (operating on workspace clone)
+- **Tools**: List of active tool instances on this workspace + spawn new tool
+
+**Start Tool**: Inline modal (not page navigation) to spawn a tool instance on this workspace.
+
+### Projects Page Refresh
+
+Project cards now show:
+- Project name + description
+- Repositories (accordion/list)
+- For each repo: its workspaces as clickable chips/cards
+- "New Workspace" button per repo
+
+```
+┌─────────────────────────────────────────────┐
+│ My Project │
+│ A web application │
+├─────────────────────────────────────────────┤
+│ Repositories: │
+│ │
+│ ▼ my-app (git@github.com:...) │
+│ ┌─────────┐ ┌─────────────┐ [+ New] │
+│ │ main │ │ feature-auth│ │
+│ │ ● 2 │ │ ● 0 │ │
+│ └─────────┘ └─────────────┘ │
+│ │
+│ ▶ api-service │
+│ ┌─────────┐ [+ New] │
+│ │ main │ │
+│ └─────────┘ │
+└─────────────────────────────────────────────┘
+```
+
+### Mobile Layout
+
+Bottom tab bar (4 tabs):
+- **Files**: Full-screen file tree + viewer
+- **Git**: Compact commit panel + action buttons
+- **Tools**: Instance list + spawn button
+- **Menu**: Workspace switcher, settings
+
+Swipe between tabs. File tree is always accessible.
+
+### Deleted
+
+- `pages/repo-workspace.tsx` — old repository workspace (file browser + editor on repo path)
+- Route `/projects/:projectId` → now shows project detail, not file browser
+- Old workspace header component
+- Git toolbar component (replaced by panel in workspace detail)
+
+## Scope
+
+### In Scope
+
+- [ ] New workspace detail page (`/workspaces/:workspaceId`)
+- [ ] File browser reading from workspace clone path
+- [ ] File viewer/editor for workspace files
+- [ ] Git operations on workspace clone (status, commit, push, pull, fetch, branch)
+- [ ] Tool instance list per workspace
+- [ ] Inline tool spawn modal
+- [ ] Collapsible IDE-style panels (desktop)
+- [ ] Bottom tab bar layout (mobile)
+- [ ] Projects page refresh (inline repos + workspaces)
+- [ ] Workspace list page improvements (link to detail page)
+- [ ] Backend: file endpoints for workspace path
+- [ ] Backend: git endpoints for workspace path
+- [ ] Delete old `repo-workspace.tsx` and related components
+- [ ] Update routing
+
+### Out of Scope
+
+- Git history / diff view (deferred, can reuse existing page)
+- Workspace sharing between users
+- Advanced IDE features (search, multi-file edit)
+- Auto-sync on schedule
+- Terminal integration inside workspace page
+
+## Decisions
+
+| # | Question | Answer |
+|---|---|---|
+| 1 | Projects page → what happens on "Open"? | **A** — Show project detail with repos + workspaces inline |
+| 2 | Workspace page layout? | **C** — Collapsible panels, IDE-style |
+| 3 | File browser source? | Workspace clone path (`/data/working-copies/{repo-id}/{name}/`) |
+| 4 | Git actions scope? | Workspace clone |
+| 5 | Tool spawning? | Inline modal on workspace page |
+| 6 | Mobile layout? | Bottom tab bar (Files / Git / Tools / Menu), swipeable |
+| 7 | Old workspace fallback? | **No fallback** — delete immediately |
+| 8 | Projects page detail level? | Inline workspace cards on project page |
+
+## Open Questions for Spec
+
+1. Should the workspace detail page URL be `/workspaces/:id` or nested under project/repo?
+2. Should we keep the sidebar Workspaces nav entry, or rely on Projects → Workspace flow?
+3. How does "New Workspace" flow work from Projects page — inline form or navigate to create page?
+4. Should workspace detail show repo remote URL and allow switching branches?
+5. What happens when a workspace has no tool instances yet — show empty state or prompt to spawn?
+
+## Risks
+
+| Risk | Mitigation |
+|---|---|
+| Users confused by navigation change | Keep "Workspaces" in sidebar, add breadcrumbs |
+| Large frontend refactor | Break into 3 PRs: backend endpoints, workspace detail page, projects refresh |
+| Mobile layout complexity | Prototype with CSS grid first, test on actual device |
+| Git operations on workspace path | Reuse existing git service, just change the path argument |
+
+## Success Criteria
+
+- [ ] Old `repo-workspace.tsx` is deleted
+- [ ] `/projects/:id` shows project detail with repos and workspaces
+- [ ] `/workspaces/:id` shows workspace detail with Files, Git, Tools panels
+- [ ] File browser reads from workspace clone path
+- [ ] Git commit/push/pull work on workspace clone
+- [ ] Tool spawn modal creates instance with workspace mounted
+- [ ] Mobile layout uses bottom tabs
+- [ ] All existing tests pass (or updated)
+- [ ] ruff clean, TypeScript clean, eslint clean
diff --git a/openspec/changes/workspace-first-ui/spec.md b/openspec/changes/workspace-first-ui/spec.md
new file mode 100644
index 0000000..46f392a
--- /dev/null
+++ b/openspec/changes/workspace-first-ui/spec.md
@@ -0,0 +1,366 @@
+# Spec: Workspace-First UI Refresh
+
+## Status
+
+| Field | Value |
+|---|---|
+| Phase | **Spec** |
+| Based on | [Proposal](proposal.md) |
+| Next | Design |
+
+## Overview
+
+Replace the old "Repository Workspace" (direct repo file browser) with a **Workspace-First** model. The workspace detail page becomes the primary work surface. Projects page shows inline repos + workspaces. Old `repo-workspace.tsx` is deleted.
+
+## Decisions
+
+| # | Question | Answer |
+|---|---|---|
+| 1 | URL structure | `/workspaces/:id` (flat) |
+| 2 | Sidebar nav order | Workspaces → Projects |
+| 3 | New Workspace flow | Inline form on project page |
+| 4 | Branch switching | Dropdown in workspace header |
+| 5 | Empty tool state | "Start a tool" prompt card |
+| 6 | Mobile tabs | Files / Git / Tools / Settings |
+| 7 | Git toolbar | Collapsible top bar on Files tab |
+| 8 | Git tab content | History, diff, full commit log |
+
+## User Flows
+
+### Flow 1: Open a Project
+
+1. User clicks "Projects" in sidebar
+2. Sees project cards with inline repositories
+3. Each repo shows its workspaces as clickable cards
+4. User clicks a workspace → navigates to `/workspaces/:id`
+
+### Flow 2: Work in a Workspace
+
+1. User is on `/workspaces/:id`
+2. **Files tab** (default): File tree (left) + file viewer/editor (right). Git toolbar at top.
+3. User edits a file, commits via git toolbar
+4. **Git tab**: Full history, diff view, detailed commit log
+5. **Tools tab**: See running instances, click "Start Tool" → inline modal
+6. **Settings tab**: Sync workspace, rename, delete
+
+### Flow 3: Create a Workspace
+
+1. User on Projects page, expands a repo
+2. Clicks "+ New Workspace" next to a repo
+3. Inline form appears: name input, branch dropdown
+4. Submits → workspace created, appears in list
+
+### Flow 4: Start a Tool
+
+1. User on workspace detail, Tools tab
+2. If no instances: "Start a tool on this workspace" card
+3. If instances: list of cards + "Start Another" button
+4. Click → inline modal: tool type picker, config profile (optional)
+5. Submit → instance created, appears in list with status
+
+## Backend API
+
+### New Endpoints (workspace-scoped)
+
+All endpoints operate on the workspace clone path (`workspace.path`).
+
+```
+# Files
+GET /workspaces/{workspace_id}/files?path=&branch=
+ → List directory entries
+GET /workspaces/{workspace_id}/files/content?path=&branch=
+ → Get file content
+POST /workspaces/{workspace_id}/files/content
+ Body: { path, content, message }
+ → Commit file change
+
+# Git
+GET /workspaces/{workspace_id}/git/status
+ → { modified, added, deleted, untracked, branch }
+GET /workspaces/{workspace_id}/git/branches
+ → { branches, default_branch, current_branch }
+POST /workspaces/{workspace_id}/git/commit
+ Body: { message, files? }
+ → Commit staged changes
+POST /workspaces/{workspace_id}/git/push
+ → Push current branch
+POST /workspaces/{workspace_id}/git/pull
+ → Pull current branch
+POST /workspaces/{workspace_id}/git/fetch
+ → Fetch from origin
+POST /workspaces/{workspace_id}/git/checkout
+ Body: { branch }
+ → Switch branch
+GET /workspaces/{workspace_id}/git/history
+ Query: ?path=&limit=50
+ → Commit history for file or entire repo
+
+# Tools (instances on this workspace)
+GET /workspaces/{workspace_id}/instances
+ → List tool instances using this workspace
+POST /workspaces/{workspace_id}/instances
+ Body: { tool_type_id, display_name?, config_profile_id? }
+ → Create instance on this workspace
+```
+
+### Existing Endpoints (unchanged)
+
+```
+GET /workspaces/
+POST /workspaces/ (body: { repo_id, name, branch })
+DELETE /workspaces/{id}
+POST /workspaces/{id}/sync
+PATCH /workspaces/{id}
+```
+
+Note: `POST /workspaces/` now accepts `repo_id` directly instead of nested under `/projects/{pid}/repositories/{rid}/workspaces`.
+
+### Modified Endpoints
+
+```
+GET /projects/
+ → Now includes `repositories` array with `workspaces` sub-array
+```
+
+## Database Schema
+
+No changes. Existing `workspaces` table is sufficient.
+
+## Frontend Routes
+
+```
+/ → Dashboard (unchanged)
+/workspaces → All workspaces list (refreshed)
+/workspaces/:id → Workspace detail (NEW, replaces repo-workspace)
+/projects → Projects list (refreshed)
+/projects/:id → Project detail with repos + workspaces (NEW)
+/sessions → Sessions list (unchanged)
+/settings → Settings (unchanged)
+```
+
+## UI Components
+
+### WorkspaceDetailPage (`/workspaces/:id`)
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ Breadcrumb: Projects > {project} > {repo} > {workspace} │
+│ [Branch ▼ main] [Sync] [Start Tool] [Settings] │
+├──────────────────────────────────────────────────────────────┤
+│ Tab bar: [Files] [Git] [Tools] [Settings] │
+├──────────────────────────────────────────────────────────────┤
+│ │
+│ {Active Tab Content} │
+│ │
+└──────────────────────────────────────────────────────────────┘
+```
+
+#### Files Tab (default)
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ Git Toolbar (collapsible) │
+│ [Modified: 3] [Staged: 2] [Commit ▼] [Push] [Pull] [Fetch] │
+├──────────────┬───────────────────────────────────────────────┤
+│ │ │
+│ File Tree │ File Viewer / Editor │
+│ (workspace │ │
+│ path) │ Breadcrumbs: src > utils > helpers.ts │
+│ │ │
+│ 📁 src/ │ [Edit] [History] │
+│ 📄 README │ │
+│ │ export function ... │
+│ │ │
+└──────────────┴───────────────────────────────────────────────┘
+```
+
+**Git Toolbar**: Collapsible bar above file content. Shows:
+- Status counters: Modified, Added, Deleted, Untracked
+- Commit button (with message input when expanded)
+- Push, Pull, Fetch buttons
+- Branch selector dropdown
+
+#### Git Tab
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ Branch: [main ▼] [New Branch] [Merge] [Compare] │
+├──────────────────────────────────────────────────────────────┤
+│ │
+│ Commit History │
+│ ┌────────────────────────────────────────────────────┐ │
+│ │ ● abc123 Fix auth middleware │ │
+│ │ ● def456 Add user profile page │ │
+│ │ ● 789abc Initial commit │ │
+│ └────────────────────────────────────────────────────┘ │
+│ │
+│ [Show Diff] [Checkout] [Revert] │
+│ │
+└──────────────────────────────────────────────────────────────┘
+```
+
+#### Tools Tab
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ Active Tool Instances │
+│ │
+│ ┌─────────────┐ ┌─────────────┐ [+ Start Tool] │
+│ │ Code Server │ │ Terminal │ │
+│ │ ● Running │ │ ● Stopped │ │
+│ │ [Open] [Stop│ │ [Start] [×] │ │
+│ └─────────────┘ └─────────────┘ │
+│ │
+│ ─ or ─ │
+│ │
+│ ┌────────────────────────────────────────────────────┐ │
+│ │ No tools running on this workspace │ │
+│ │ Start a tool to begin coding │ │
+│ │ [Start Tool] │ │
+│ └────────────────────────────────────────────────────┘ │
+│ │
+└──────────────────────────────────────────────────────────────┘
+```
+
+#### Settings Tab
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ Workspace Settings │
+│ │
+│ Name: [my-feature-branch ] │
+│ Branch: main (tracks origin/main) │
+│ Path: /data/working-copies/{repo-id}/{name} │
+│ Created: 2024-01-15 │
+│ Last Sync: 2024-01-20 14:32 │
+│ │
+│ [Rename] [Sync Now] [Delete Workspace] │
+│ │
+└──────────────────────────────────────────────────────────────┘
+```
+
+### ProjectsPage (`/projects`)
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ Projects [+ New Project] │
+├──────────────────────────────────────────────────────────────┤
+│ │
+│ ┌────────────────────────────────────────────────────┐ │
+│ │ My Web App │ │
+│ │ A full-stack application │ │
+│ ├────────────────────────────────────────────────────┤ │
+│ │ Repositories: │ │
+│ │ │ │
+│ │ ▼ frontend (git@github.com:me/frontend.git) │ │
+│ │ ┌──────────┐ ┌─────────────┐ [+ New Workspace] │ │
+│ │ │ main │ │ feature-ui │ │ │
+│ │ │ ● 2 inst │ │ ● 0 inst │ │ │
+│ │ └──────────┘ └─────────────┘ │ │
+│ │ │ │
+│ │ ▶ backend (git@github.com:me/backend.git) │ │
+│ │ ┌──────────┐ [+ New Workspace] │ │
+│ │ │ main │ │ │
+│ │ │ ● 1 inst │ │ │
+│ │ └──────────┘ │ │
+│ │ │ │
+│ │ [Edit Project] [Delete] │ │
+│ └────────────────────────────────────────────────────┘ │
+│ │
+└──────────────────────────────────────────────────────────────┘
+```
+
+**Workspace Card**: Small card showing:
+- Name
+- Status badge (ready/syncing/error)
+- Instance count (dot + number)
+- Click navigates to `/workspaces/:id`
+
+**New Workspace Button**: Inline form on click:
+```
+[Name: __________] [Branch: main ▼] [Create] [Cancel]
+```
+
+### Mobile Layout
+
+Bottom tab bar (4 tabs, always visible):
+
+```
+┌────────────────────────────────────┐
+│ {Tab Content - full screen} │
+│ │
+│ │
+│ │
+├────────────────────────────────────┤
+│ 📁 Files 🔀 Git 🛠 Tools ⚙ Settings│
+└────────────────────────────────────┘
+```
+
+**Files tab**: File tree full screen, tap file → viewer overlay
+**Git tab**: Commit history list, tap commit → diff overlay
+**Tools tab**: Instance cards stacked vertically
+**Settings tab**: Same as desktop settings, scrollable
+
+## State & Data Flow
+
+### Workspace Detail Page
+
+```
+useWorkspace(workspaceId) → fetch /workspaces/{id}
+useWorkspaceFiles(workspaceId, path?, branch?) → fetch /workspaces/{id}/files
+useWorkspaceGitStatus(workspaceId) → fetch /workspaces/{id}/git/status
+useWorkspaceInstances(workspaceId) → fetch /workspaces/{id}/instances
+```
+
+All hooks poll/refetch on:
+- Tab switch
+- User action (commit, push, etc.)
+- 30s background refresh
+
+### Projects Page
+
+```
+useProjects() → fetch /projects/
+useProjectWorkspaces(projectId) → derived from project.repositories.workspaces
+```
+
+## Error Handling
+
+| Scenario | UX |
+|---|---|
+| Workspace not found | 404 page with "Workspace not found" + link to workspaces |
+| Git operation fails | Toast with git stderr, retry button |
+| File read fails | "File not found" in viewer, check if on correct branch |
+| No tool types available | "No tools configured" + link to Tool Workshop |
+| Workspace path missing | "Workspace files not found — try syncing" |
+
+## Accessibility
+
+- Tab bar: `role="tablist"`, keyboard arrow navigation
+- File tree: `role="tree"`, arrow key expansion
+- Git toolbar: All buttons have `aria-label`
+- Focus management: Modal traps focus, returns on close
+
+## Performance
+
+- File tree: Virtualized for repos > 1000 files
+- Git history: Paginated (50 commits per page)
+- Image files: Lazy loaded in viewer
+- Polling: 30s for instances, 10s for git status when visible
+
+## Acceptance Criteria
+
+- [ ] `repo-workspace.tsx` and related components deleted
+- [ ] `/projects/:id` route shows project detail, not file browser
+- [ ] `/workspaces/:id` route shows workspace detail page
+- [ ] File browser reads from workspace clone path
+- [ ] File viewer/editor works on workspace files
+- [ ] Git toolbar on Files tab supports commit/push/pull/fetch
+- [ ] Git tab shows commit history with diff
+- [ ] Tools tab lists instances + spawn modal
+- [ ] Settings tab shows workspace info + rename/sync/delete
+- [ ] Projects page shows inline repos + workspace cards
+- [ ] Inline "New Workspace" form on project page
+- [ ] Mobile: 4-tab bottom navigation
+- [ ] All existing tests pass or updated
+- [ ] ruff clean, TypeScript clean, eslint clean
diff --git a/openspec/changes/workspace-first-ui/tasks.md b/openspec/changes/workspace-first-ui/tasks.md
new file mode 100644
index 0000000..4a058be
--- /dev/null
+++ b/openspec/changes/workspace-first-ui/tasks.md
@@ -0,0 +1,132 @@
+# Tasks: Workspace-First UI Refresh
+
+## Status
+
+| Field | Value |
+|---|---|
+| Phase | **Tasks** |
+| Based on | [Design](design.md) |
+| Next | Apply |
+
+## PR Breakdown
+
+### PR-1: Backend — Workspace File, Git & Instance Endpoints
+**Scope**: All new backend endpoints for workspace-scoped operations
+**Est. lines**: ~900 backend, ~400 tests
+**Files touched**: 10 new, 3 modified
+
+**Tasks**:
+1. [ ] Create `FileService` (`apps/api/src/services/file_service.py`)
+2. [ ] Create `GitOperations` service (`apps/api/src/services/git_operations.py`)
+3. [ ] Create `workspace_files` API router (`apps/api/src/api/workspace_files.py`)
+4. [ ] Create `workspace_git` API router (`apps/api/src/api/workspace_git.py`)
+5. [ ] Create `workspace_instances` API router (`apps/api/src/api/workspace_instances.py`)
+6. [ ] Register new routers in `main.py`
+7. [ ] Update `workspaces.py` POST to accept `repo_id` directly
+8. [ ] Enrich `projects.py` list response with repos + workspaces
+9. [ ] Write unit tests for FileService
+10. [ ] Write unit tests for GitOperations
+11. [ ] Write integration tests for workspace file endpoints
+12. [ ] Write integration tests for workspace git endpoints
+13. [ ] Write integration tests for workspace instance endpoints
+
+### PR-2: Frontend — Workspace Detail Page
+**Scope**: Workspace detail page with 4 tabs, replaces old repo-workspace
+**Est. lines**: ~1,400 frontend, ~300 tests
+**Files touched**: 14 new, 3 modified
+
+**Tasks**:
+1. [ ] Create `useWorkspaceFiles` hook
+2. [ ] Create `useWorkspaceGit` hook
+3. [ ] Create `useWorkspaceInstances` hook
+4. [ ] Create workspace API clients (`workspace-files.ts`, `workspace-git.ts`, `workspace-instances.ts`)
+5. [ ] Create `WorkspaceHeader` component
+6. [ ] Create `WorkspaceTabs` component
+7. [ ] Create `WorkspaceFilePanel` component (file tree + viewer + git toolbar)
+8. [ ] Create `GitToolbar` component (collapsible)
+9. [ ] Create `WorkspaceGitPanel` component (history + diff)
+10. [ ] Create `WorkspaceToolsPanel` component (instances + spawn modal)
+11. [ ] Create `WorkspaceSettingsPanel` component
+12. [ ] Create `WorkspaceDetailPage` page
+13. [ ] Add `/workspaces/:id` route
+14. [ ] Delete `repo-workspace.tsx` and related components
+15. [ ] Write component tests for WorkspaceFilePanel
+16. [ ] Write component tests for GitToolbar
+17. [ ] Write component tests for WorkspaceToolsPanel
+
+### PR-3: Frontend — Projects Page Refresh & Routing
+**Scope**: Projects page with inline repos + workspaces, mobile layout
+**Est. lines**: ~800 frontend, ~200 tests
+**Files touched**: 5 new, 5 modified
+
+**Tasks**:
+1. [ ] Update `projects.ts` API client for enriched response
+2. [ ] Create `useProjectsEnriched` hook
+3. [ ] Create `ProjectCard` component
+4. [ ] Create `RepoSection` component
+5. [ ] Create `WorkspaceChip` component
+6. [ ] Create `NewWorkspaceInline` component
+7. [ ] Rewrite `ProjectsPage`
+8. [ ] Create `ProjectDetailPage` (or inline detail on ProjectsPage)
+9. [ ] Update `AppShell` nav order (Workspaces → Projects)
+10. [ ] Update `WorkspacesPage` to link to detail
+11. [ ] Add mobile tab bar to workspace detail
+12. [ ] Update router: `/projects/:id` → project detail, remove old repo-workspace
+13. [ ] Write component tests for ProjectCard
+14. [ ] Write component tests for RepoSection
+15. [ ] Write tests for NewWorkspaceInline
+
+## Acceptance Criteria (All PRs)
+
+- [ ] Old `repo-workspace.tsx` is deleted
+- [ ] `/projects/:id` shows project detail with repos + workspaces
+- [ ] `/workspaces/:id` shows workspace detail with 4 tabs
+- [ ] File browser reads from workspace clone path
+- [ ] File viewer/editor works on workspace files
+- [ ] Git toolbar on Files tab supports commit/push/pull/fetch
+- [ ] Git tab shows commit history
+- [ ] Tools tab lists instances + spawn modal (or empty prompt)
+- [ ] Settings tab shows workspace info + rename/sync/delete
+- [ ] Projects page shows inline repos + workspace cards
+- [ ] Inline "New Workspace" form on project page
+- [ ] Mobile: 4-tab bottom navigation
+- [ ] All existing tests pass or updated
+- [ ] ruff clean
+- [ ] TypeScript compilation clean
+- [ ] eslint clean
+
+## Implementation Order
+
+```
+PR-1 (Backend endpoints)
+ → PR-2 (Workspace detail page)
+ → PR-3 (Projects refresh + routing)
+```
+
+Each PR depends on the previous. No parallel work.
+
+## Verification Steps per PR
+
+### PR-1
+```bash
+cd apps/api
+pytest tests/unit/test_file_service.py tests/unit/test_git_operations.py -v
+pytest tests/integration/test_workspace_files.py tests/integration/test_workspace_git.py tests/integration/test_workspace_instances.py -v
+python -m ruff check src/services/file_service.py src/services/git_operations.py src/api/workspace_*.py
+```
+
+### PR-2
+```bash
+cd apps/web
+npx tsc --noEmit
+npx eslint src/pages/workspace-detail.tsx src/components/workspace/
+npm run test -- --run workspace-detail
+```
+
+### PR-3
+```bash
+cd apps/web
+npx tsc --noEmit
+npx eslint src/pages/projects.tsx src/components/project/
+npm run test -- --run projects
+```
From 27c77af5912a25a9595df691689708108258be53 Mon Sep 17 00:00:00 2001
From: Alex Blank
Date: Mon, 1 Jun 2026 17:04:44 +0200
Subject: [PATCH 22/44] feat: workspace-first UI refresh - PR-2 workspace
detail page
- Add workspace detail page (/workspaces/:id) with 4 tabs:
- Files: file tree, viewer, editor, git toolbar (commit/push/pull/fetch)
- Git: branch selector, commit history
- Tools: instance grid, start tool modal
- Settings: workspace info read-only
- Add workspace API clients: workspace-files, workspace-git, workspace-instances
- Add hooks: useWorkspaceFiles, useWorkspaceGit, useWorkspaceInstances
- WorkspaceCard links to detail page via router Link
- Add comprehensive CSS for workspace detail layout
- Mobile: bottom tab bar, responsive file tree/split
- TypeScript + eslint clean
Quality gates: tsc --noEmit clean, eslint clean
---
apps/api/src/api/projects.py | 65 ++-
apps/api/src/services/git_operations.py | 4 +-
apps/web/src/api/workspace-files.ts | 45 ++
apps/web/src/api/workspace-git.ts | 75 +++
apps/web/src/api/workspace-instances.ts | 30 ++
apps/web/src/components/workspace-card.tsx | 15 +-
apps/web/src/hooks/use-workspace-files.ts | 68 +++
apps/web/src/hooks/use-workspace-git.ts | 109 ++++
apps/web/src/hooks/use-workspace-instances.ts | 65 +++
apps/web/src/pages/workspace-detail.tsx | 466 ++++++++++++++++++
apps/web/src/router.tsx | 2 +
apps/web/src/styles.css | 455 +++++++++++++++++
12 files changed, 1366 insertions(+), 33 deletions(-)
create mode 100644 apps/web/src/api/workspace-files.ts
create mode 100644 apps/web/src/api/workspace-git.ts
create mode 100644 apps/web/src/api/workspace-instances.ts
create mode 100644 apps/web/src/hooks/use-workspace-files.ts
create mode 100644 apps/web/src/hooks/use-workspace-git.ts
create mode 100644 apps/web/src/hooks/use-workspace-instances.ts
create mode 100644 apps/web/src/pages/workspace-detail.tsx
diff --git a/apps/api/src/api/projects.py b/apps/api/src/api/projects.py
index 3dbc01c..d68cacb 100644
--- a/apps/api/src/api/projects.py
+++ b/apps/api/src/api/projects.py
@@ -7,7 +7,12 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
-from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
+from src.auth.dependencies import (
+ _get_owned_project,
+ _get_user,
+ get_current_user_id,
+ get_db_session,
+)
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
@@ -90,7 +95,9 @@ async def list_projects(
"""
user = await _get_user(session, user_id)
result = await session.execute(
- select(Project).where(Project.owner_id == user.id).order_by(Project.created_at.desc())
+ select(Project)
+ .where(Project.owner_id == user.id)
+ .order_by(Project.created_at.desc())
)
projects = result.scalars().all()
@@ -113,29 +120,37 @@ async def list_projects(
select(func.count()).where(ToolInstance.workspace_id == ws.id)
)
instance_count = inst_result.scalar() or 0
- workspaces.append({
- "id": str(ws.id),
- "name": ws.name,
- "branch": ws.branch,
- "status": ws.status,
- "instance_count": instance_count,
- })
+ workspaces.append(
+ {
+ "id": str(ws.id),
+ "name": ws.name,
+ "branch": ws.branch,
+ "status": ws.status,
+ "instance_count": instance_count,
+ }
+ )
- repositories.append({
- "id": str(repo.id),
- "name": repo.name,
- "remote_url": repo.remote_url,
- "workspaces": workspaces,
- })
+ repositories.append(
+ {
+ "id": str(repo.id),
+ "name": repo.name,
+ "remote_url": repo.remote_url,
+ "workspaces": workspaces,
+ }
+ )
- enriched.append({
- "id": str(project.id),
- "name": project.name,
- "description": project.description,
- "owner_id": str(project.owner_id),
- "repositories": repositories,
- "created_at": project.created_at.isoformat() if project.created_at else None,
- })
+ enriched.append(
+ {
+ "id": str(project.id),
+ "name": project.name,
+ "description": project.description,
+ "owner_id": str(project.owner_id),
+ "repositories": repositories,
+ "created_at": project.created_at.isoformat()
+ if project.created_at
+ else None,
+ }
+ )
return enriched
@@ -226,7 +241,9 @@ async def delete_project(
project = await _get_owned_project(project_id, user_id, session)
# Delete repositories from disk and database
- result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
+ result = await session.execute(
+ select(GitRepository).where(GitRepository.project_id == project_id)
+ )
repositories = result.scalars().all()
for repo in repositories:
if os.path.exists(repo.path):
diff --git a/apps/api/src/services/git_operations.py b/apps/api/src/services/git_operations.py
index 45b7134..ecbd8d6 100644
--- a/apps/api/src/services/git_operations.py
+++ b/apps/api/src/services/git_operations.py
@@ -111,9 +111,7 @@ class GitOperations:
if rc != 0:
raise RuntimeError(f"Git add failed: {err}")
- rc, _, err = await self._run(
- "git", "-C", self.cwd, "commit", "-m", message
- )
+ rc, _, err = await self._run("git", "-C", self.cwd, "commit", "-m", message)
if rc != 0:
raise RuntimeError(f"Git commit failed: {err}")
diff --git a/apps/web/src/api/workspace-files.ts b/apps/web/src/api/workspace-files.ts
new file mode 100644
index 0000000..0702732
--- /dev/null
+++ b/apps/web/src/api/workspace-files.ts
@@ -0,0 +1,45 @@
+/** Workspace file API client. */
+
+import { apiClient } from "./client";
+
+export interface FileEntry {
+ name: string;
+ path: string;
+ type: "file" | "directory";
+ size?: number;
+}
+
+export async function listWorkspaceFiles(
+ workspaceId: string,
+ path: string = "",
+): Promise {
+ const response = await apiClient.get<{ entries: FileEntry[] }>(
+ `/workspaces/${workspaceId}/files/`,
+ { params: { path } },
+ );
+ return response.data.entries;
+}
+
+export async function getWorkspaceFileContent(
+ workspaceId: string,
+ path: string,
+): Promise {
+ const response = await apiClient.get<{ content: string }>(
+ `/workspaces/${workspaceId}/files/content`,
+ { params: { path } },
+ );
+ return response.data.content;
+}
+
+export async function saveWorkspaceFile(
+ workspaceId: string,
+ path: string,
+ content: string,
+ commitMessage?: string,
+): Promise {
+ await apiClient.post(`/workspaces/${workspaceId}/files/content`, {
+ path,
+ content,
+ message: commitMessage,
+ });
+}
diff --git a/apps/web/src/api/workspace-git.ts b/apps/web/src/api/workspace-git.ts
new file mode 100644
index 0000000..9f897f2
--- /dev/null
+++ b/apps/web/src/api/workspace-git.ts
@@ -0,0 +1,75 @@
+/** Workspace git API client. */
+
+import { apiClient } from "./client";
+
+export interface GitStatus {
+ branch: string;
+ modified: string[];
+ added: string[];
+ deleted: string[];
+ untracked: string[];
+ ahead: number;
+ behind: number;
+}
+
+export interface Commit {
+ hash: string;
+ message: string;
+ author: string;
+ date: string;
+}
+
+export async function getGitStatus(workspaceId: string): Promise {
+ const response = await apiClient.get(
+ `/workspaces/${workspaceId}/git/status`,
+ );
+ return response.data;
+}
+
+export async function getGitBranches(
+ workspaceId: string,
+): Promise<{ branches: string[]; current_branch: string }> {
+ const response = await apiClient.get<{
+ branches: string[];
+ current_branch: string;
+ }>(`/workspaces/${workspaceId}/git/branches`);
+ return response.data;
+}
+
+export async function gitCommit(
+ workspaceId: string,
+ message: string,
+): Promise {
+ await apiClient.post(`/workspaces/${workspaceId}/git/commit`, { message });
+}
+
+export async function gitPush(workspaceId: string): Promise {
+ await apiClient.post(`/workspaces/${workspaceId}/git/push`);
+}
+
+export async function gitPull(workspaceId: string): Promise {
+ await apiClient.post(`/workspaces/${workspaceId}/git/pull`);
+}
+
+export async function gitFetch(workspaceId: string): Promise {
+ await apiClient.post(`/workspaces/${workspaceId}/git/fetch`);
+}
+
+export async function gitCheckout(
+ workspaceId: string,
+ branch: string,
+): Promise {
+ await apiClient.post(`/workspaces/${workspaceId}/git/checkout`, { branch });
+}
+
+export async function getGitHistory(
+ workspaceId: string,
+ path?: string,
+ limit: number = 50,
+): Promise {
+ const response = await apiClient.get<{ commits: Commit[] }>(
+ `/workspaces/${workspaceId}/git/history`,
+ { params: { path, limit } },
+ );
+ return response.data.commits;
+}
diff --git a/apps/web/src/api/workspace-instances.ts b/apps/web/src/api/workspace-instances.ts
new file mode 100644
index 0000000..67dbefe
--- /dev/null
+++ b/apps/web/src/api/workspace-instances.ts
@@ -0,0 +1,30 @@
+/** Workspace instance API client. */
+
+import { apiClient } from "./client";
+import type { ToolInstance } from "./sessions";
+
+export async function listWorkspaceInstances(
+ workspaceId: string,
+): Promise {
+ const response = await apiClient.get(
+ `/workspaces/${workspaceId}/instances/`,
+ );
+ return response.data;
+}
+
+export async function createWorkspaceInstance(
+ workspaceId: string,
+ toolTypeId: string,
+ displayName?: string,
+ configProfileId?: string,
+): Promise {
+ const response = await apiClient.post(
+ `/workspaces/${workspaceId}/instances/`,
+ {
+ tool_type_id: toolTypeId,
+ display_name: displayName,
+ config_profile_id: configProfileId,
+ },
+ );
+ return response.data;
+}
diff --git a/apps/web/src/components/workspace-card.tsx b/apps/web/src/components/workspace-card.tsx
index d6e4e06..4e92079 100644
--- a/apps/web/src/components/workspace-card.tsx
+++ b/apps/web/src/components/workspace-card.tsx
@@ -1,5 +1,6 @@
/** Card component for displaying a workspace. */
+import { Link } from "react-router-dom";
import { Icon } from "./icon";
import type { Workspace } from "../types/workspace";
@@ -27,12 +28,14 @@ export function WorkspaceCard({
return (
-
-
{workspace.name}
-
- {workspace.status}
-
-
+
+
+
{workspace.name}
+
+ {workspace.status}
+
+
+
{workspace.project_name} / {workspace.repo_name}
diff --git a/apps/web/src/hooks/use-workspace-files.ts b/apps/web/src/hooks/use-workspace-files.ts
new file mode 100644
index 0000000..4ac8073
--- /dev/null
+++ b/apps/web/src/hooks/use-workspace-files.ts
@@ -0,0 +1,68 @@
+/** Hook for workspace file operations. */
+
+import { useCallback, useEffect, useState } from "react";
+import {
+ listWorkspaceFiles,
+ getWorkspaceFileContent,
+ saveWorkspaceFile,
+ type FileEntry,
+} from "../api/workspace-files";
+
+export interface UseWorkspaceFilesResult {
+ entries: FileEntry[];
+ content: string | null;
+ loading: boolean;
+ error: string | null;
+ refresh: () => Promise;
+ loadFile: (path: string) => Promise;
+ saveFile: (path: string, content: string, message?: string) => Promise;
+}
+
+export function useWorkspaceFiles(
+ workspaceId: string,
+): UseWorkspaceFilesResult {
+ const [entries, setEntries] = useState([]);
+ const [content, setContent] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const data = await listWorkspaceFiles(workspaceId);
+ setEntries(data);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load files");
+ } finally {
+ setLoading(false);
+ }
+ }, [workspaceId]);
+
+ const loadFile = useCallback(
+ async (path: string) => {
+ try {
+ const data = await getWorkspaceFileContent(workspaceId, path);
+ setContent(data);
+ } catch (err) {
+ setContent(null);
+ setError(err instanceof Error ? err.message : "Failed to load file");
+ }
+ },
+ [workspaceId],
+ );
+
+ const saveFile = useCallback(
+ async (path: string, fileContent: string, message?: string) => {
+ await saveWorkspaceFile(workspaceId, path, fileContent, message);
+ await refresh();
+ },
+ [workspaceId, refresh],
+ );
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ return { entries, content, loading, error, refresh, loadFile, saveFile };
+}
diff --git a/apps/web/src/hooks/use-workspace-git.ts b/apps/web/src/hooks/use-workspace-git.ts
new file mode 100644
index 0000000..e86bad0
--- /dev/null
+++ b/apps/web/src/hooks/use-workspace-git.ts
@@ -0,0 +1,109 @@
+/** Hook for workspace git operations. */
+
+import { useCallback, useEffect, useState } from "react";
+import {
+ getGitStatus,
+ getGitBranches,
+ gitCommit,
+ gitPush,
+ gitPull,
+ gitFetch,
+ gitCheckout,
+ getGitHistory,
+ type GitStatus,
+ type Commit,
+} from "../api/workspace-git";
+
+export interface UseWorkspaceGitResult {
+ status: GitStatus | null;
+ branches: string[];
+ currentBranch: string;
+ history: Commit[];
+ loading: boolean;
+ error: string | null;
+ refresh: () => Promise;
+ commit: (message: string) => Promise;
+ push: () => Promise;
+ pull: () => Promise;
+ fetch: () => Promise;
+ checkout: (branch: string) => Promise;
+}
+
+export function useWorkspaceGit(workspaceId: string): UseWorkspaceGitResult {
+ const [status, setStatus] = useState(null);
+ const [branches, setBranches] = useState([]);
+ const [currentBranch, setCurrentBranch] = useState("");
+ const [history, setHistory] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const [statusData, branchesData, historyData] = await Promise.all([
+ getGitStatus(workspaceId),
+ getGitBranches(workspaceId),
+ getGitHistory(workspaceId),
+ ]);
+ setStatus(statusData);
+ setBranches(branchesData.branches);
+ setCurrentBranch(branchesData.current_branch);
+ setHistory(historyData);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load git data");
+ } finally {
+ setLoading(false);
+ }
+ }, [workspaceId]);
+
+ const commit = useCallback(
+ async (message: string) => {
+ await gitCommit(workspaceId, message);
+ await refresh();
+ },
+ [workspaceId, refresh],
+ );
+
+ const push = useCallback(async () => {
+ await gitPush(workspaceId);
+ await refresh();
+ }, [workspaceId, refresh]);
+
+ const pull = useCallback(async () => {
+ await gitPull(workspaceId);
+ await refresh();
+ }, [workspaceId, refresh]);
+
+ const fetch = useCallback(async () => {
+ await gitFetch(workspaceId);
+ await refresh();
+ }, [workspaceId, refresh]);
+
+ const checkout = useCallback(
+ async (branch: string) => {
+ await gitCheckout(workspaceId, branch);
+ await refresh();
+ },
+ [workspaceId, refresh],
+ );
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ return {
+ status,
+ branches,
+ currentBranch,
+ history,
+ loading,
+ error,
+ refresh,
+ commit,
+ push,
+ pull,
+ fetch,
+ checkout,
+ };
+}
diff --git a/apps/web/src/hooks/use-workspace-instances.ts b/apps/web/src/hooks/use-workspace-instances.ts
new file mode 100644
index 0000000..502508f
--- /dev/null
+++ b/apps/web/src/hooks/use-workspace-instances.ts
@@ -0,0 +1,65 @@
+/** Hook for workspace instance operations. */
+
+import { useCallback, useEffect, useState } from "react";
+import {
+ listWorkspaceInstances,
+ createWorkspaceInstance,
+} from "../api/workspace-instances";
+import type { ToolInstance } from "../api/sessions";
+
+export interface UseWorkspaceInstancesResult {
+ instances: ToolInstance[];
+ loading: boolean;
+ error: string | null;
+ refresh: () => Promise;
+ create: (
+ toolTypeId: string,
+ displayName?: string,
+ configProfileId?: string,
+ ) => Promise;
+}
+
+export function useWorkspaceInstances(
+ workspaceId: string,
+): UseWorkspaceInstancesResult {
+ const [instances, setInstances] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const data = await listWorkspaceInstances(workspaceId);
+ setInstances(data);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load instances");
+ } finally {
+ setLoading(false);
+ }
+ }, [workspaceId]);
+
+ const create = useCallback(
+ async (
+ toolTypeId: string,
+ displayName?: string,
+ configProfileId?: string,
+ ) => {
+ const instance = await createWorkspaceInstance(
+ workspaceId,
+ toolTypeId,
+ displayName,
+ configProfileId,
+ );
+ await refresh();
+ return instance;
+ },
+ [workspaceId, refresh],
+ );
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ return { instances, loading, error, refresh, create };
+}
diff --git a/apps/web/src/pages/workspace-detail.tsx b/apps/web/src/pages/workspace-detail.tsx
new file mode 100644
index 0000000..f3ce712
--- /dev/null
+++ b/apps/web/src/pages/workspace-detail.tsx
@@ -0,0 +1,466 @@
+/** Workspace detail page — primary work surface. */
+
+import { useState } from "react";
+import { useParams } from "react-router-dom";
+import { Icon } from "../components/icon";
+import { useWorkspaces } from "../hooks/use-workspaces";
+import { useWorkspaceFiles } from "../hooks/use-workspace-files";
+import { useWorkspaceGit } from "../hooks/use-workspace-git";
+import { useWorkspaceInstances } from "../hooks/use-workspace-instances";
+import { useMobileViewport } from "../hooks/use-mobile-viewport";
+import type { FileEntry } from "../api/workspace-files";
+
+type Tab = "files" | "git" | "tools" | "settings";
+
+export function WorkspaceDetailPage() {
+ const { workspaceId } = useParams<{ workspaceId: string }>();
+ const [activeTab, setActiveTab] = useState("files");
+ const isMobile = useMobileViewport();
+
+ const { workspaces, loading: wsLoading } = useWorkspaces();
+ const workspace = workspaces.find((w) => w.id === workspaceId);
+
+ if (wsLoading) {
+ return Loading workspace...
;
+ }
+
+ if (!workspace) {
+ return (
+
+
Workspace not found
+
The workspace you are looking for does not exist.
+
+ );
+ }
+
+ return (
+
+
+
+
+ {activeTab === "files" && }
+ {activeTab === "git" && }
+ {activeTab === "tools" && }
+ {activeTab === "settings" && }
+
+ {isMobile &&
}
+
+ );
+}
+
+function WorkspaceHeader({
+ workspace,
+}: {
+ workspace: {
+ name: string;
+ repo_name: string;
+ project_name: string;
+ branch: string;
+ };
+}) {
+ return (
+
+
+ {workspace.project_name}
+ /
+ {workspace.repo_name}
+ /
+ {workspace.name}
+
+
+
+ {workspace.branch}
+
+
+
+ );
+}
+
+function TabBar({
+ active,
+ onChange,
+}: {
+ active: Tab;
+ onChange: (t: Tab) => void;
+}) {
+ const tabs: { id: Tab; label: string; icon: string }[] = [
+ { id: "files", label: "Files", icon: "folder" },
+ { id: "git", label: "Git", icon: "branch" },
+ { id: "tools", label: "Tools", icon: "terminal" },
+ { id: "settings", label: "Settings", icon: "settings" },
+ ];
+
+ return (
+
+ );
+}
+
+function MobileTabBar({
+ active,
+ onChange,
+}: {
+ active: Tab;
+ onChange: (t: Tab) => void;
+}) {
+ const tabs: { id: Tab; label: string; icon: string }[] = [
+ { id: "files", label: "Files", icon: "folder" },
+ { id: "git", label: "Git", icon: "branch" },
+ { id: "tools", label: "Tools", icon: "terminal" },
+ { id: "settings", label: "Settings", icon: "settings" },
+ ];
+
+ return (
+
+ );
+}
+
+/* ─── Files Tab ─── */
+
+function FilesTab({ workspaceId }: { workspaceId: string }) {
+ const { entries, content, loadFile, saveFile, loading, error } =
+ useWorkspaceFiles(workspaceId);
+ const { status, commit, push, pull, fetch } = useWorkspaceGit(workspaceId);
+ const [selectedPath, setSelectedPath] = useState(null);
+ const [editContent, setEditContent] = useState(null);
+ const [isEditing, setIsEditing] = useState(false);
+ const [commitMessage, setCommitMessage] = useState("");
+
+ const handleSelect = (entry: FileEntry) => {
+ if (entry.type === "directory") return;
+ setSelectedPath(entry.path);
+ setIsEditing(false);
+ setEditContent(null);
+ loadFile(entry.path);
+ };
+
+ const handleEdit = () => {
+ if (content !== null) {
+ setEditContent(content);
+ setIsEditing(true);
+ }
+ };
+
+ const handleSave = async () => {
+ if (selectedPath && editContent !== null) {
+ await saveFile(selectedPath, editContent, commitMessage || undefined);
+ setIsEditing(false);
+ setCommitMessage("");
+ }
+ };
+
+ return (
+
+ {status && (
+
+
+ {status.modified.length > 0 && (
+
+ M {status.modified.length}
+
+ )}
+ {status.added.length > 0 && (
+ A {status.added.length}
+ )}
+ {status.deleted.length > 0 && (
+ D {status.deleted.length}
+ )}
+ {status.untracked.length > 0 && (
+
+ ? {status.untracked.length}
+
+ )}
+
+
+ setCommitMessage(e.target.value)}
+ placeholder="Commit message"
+ />
+
+
+
+
+
+
+ )}
+
+
+ {loading &&
Loading...
}
+ {error &&
{error}
}
+ {entries.map((entry) => (
+
+ ))}
+
+
+ {selectedPath ? (
+ <>
+
+ {selectedPath}
+ {!isEditing && }
+
+ {isEditing ? (
+ <>
+
+
+
+ );
+}
+
+/* ─── Git Tab ─── */
+
+function GitTab({ workspaceId }: { workspaceId: string }) {
+ const { history, branches, currentBranch, checkout, loading, error } =
+ useWorkspaceGit(workspaceId);
+
+ return (
+
+
+
+
+ {loading &&
Loading history...
}
+ {error &&
{error}
}
+
+ {history.map((commit) => (
+
+ {commit.hash.slice(0, 7)}
+ {commit.message}
+ {commit.author}
+ {commit.date}
+
+ ))}
+
+
+ );
+}
+
+/* ─── Tools Tab ─── */
+
+function ToolsTab({ workspaceId }: { workspaceId: string }) {
+ const { instances, loading, create } = useWorkspaceInstances(workspaceId);
+ const [showModal, setShowModal] = useState(false);
+
+ return (
+
+ {loading &&
Loading instances...
}
+ {instances.length === 0 ? (
+
+
+
No tools running
+
Start a tool to begin coding in this workspace
+
+
+ ) : (
+ <>
+
+ {instances.map((instance) => (
+
+
{instance.display_name}
+
{instance.status}
+ {instance.url && (
+
+ Open
+
+ )}
+
+ ))}
+
+
+ >
+ )}
+ {showModal && (
+
setShowModal(false)}
+ onStart={async (toolTypeId: string) => {
+ await create(toolTypeId);
+ setShowModal(false);
+ }}
+ />
+ )}
+
+ );
+}
+
+/* ─── Settings Tab ─── */
+
+function SettingsTab({
+ workspace,
+}: {
+ workspace: {
+ id: string;
+ name: string;
+ branch: string;
+ path: string;
+ status: string;
+ created_at: string;
+ };
+}) {
+ return (
+
+
+
Workspace Info
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {workspace.status}
+
+
+
+
+ {workspace.created_at}
+
+
+
+ );
+}
+
+/* ─── Start Tool Modal ─── */
+
+function StartToolModal({
+ onClose,
+ onStart,
+}: {
+ onClose: () => void;
+ onStart: (toolTypeId: string) => Promise;
+}) {
+ const [toolTypeId, setToolTypeId] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!toolTypeId) return;
+ setSubmitting(true);
+ try {
+ await onStart(toolTypeId);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
Start Tool
+
+
+
+ );
+}
diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx
index 2e60f54..9131e30 100644
--- a/apps/web/src/router.tsx
+++ b/apps/web/src/router.tsx
@@ -17,6 +17,7 @@ import { SSHKeysPage } from "./pages/ssh-keys";
import { ConfigProfilesPage } from "./pages/config-profiles";
import { SessionsPage } from "./pages/sessions";
import { WorkspacesPage } from "./pages/workspaces";
+import { WorkspaceDetailPage } from "./pages/workspace-detail";
export const AppRouter = () => {
return (
@@ -59,6 +60,7 @@ export const AppRouter = () => {
} />
} />
+ } />
} />
Date: Mon, 1 Jun 2026 17:18:51 +0200
Subject: [PATCH 23/44] feat: workspace-first UI refresh - PR-3 projects page +
routing cleanup
- Rewrite ProjectsPage with inline repository and workspace display
- Expandable project cards showing repos + workspace chips
- Inline workspace creation from project page (New Workspace button per repo)
- Workspace chips link to workspace detail page
- Sync/delete actions on workspace chips
- Update Project type: add ProjectWithRepos, RepositorySummary, WorkspaceSummary
- Update listProjects API to return ProjectWithRepos[]
- Update dashboard, sessions, config-profiles to use ProjectWithRepos
- Remove old /projects/:projectId route (RepoWorkspace)
- Add chevron icons to Icon component
- Projects page CSS: project-toggle, repo-block, workspace-grid, workspace-chip
- TypeScript + eslint clean
Quality gates: tsc --noEmit clean, eslint clean
---
apps/web/src/api/projects.ts | 6 +-
apps/web/src/components/icon.tsx | 8 +-
apps/web/src/pages/config-profiles.tsx | 4 +-
apps/web/src/pages/dashboard.tsx | 4 +-
apps/web/src/pages/projects.tsx | 635 +++++++++++++++++--------
apps/web/src/pages/sessions.tsx | 4 +-
apps/web/src/router.tsx | 2 -
apps/web/src/styles.css | 132 +++++
apps/web/src/types.ts | 25 +
apps/web/src/utils/icons.ts | 181 +------
10 files changed, 620 insertions(+), 381 deletions(-)
diff --git a/apps/web/src/api/projects.ts b/apps/web/src/api/projects.ts
index beaed85..1252dce 100644
--- a/apps/web/src/api/projects.ts
+++ b/apps/web/src/api/projects.ts
@@ -1,5 +1,5 @@
import { apiClient } from "./client";
-import type { Project } from "../types";
+import type { Project, ProjectWithRepos } from "../types";
export type ProjectCreateInput = {
name: string;
@@ -15,8 +15,8 @@ export type SetDefaultSSHKeyInput = {
ssh_key_id: string;
};
-export const listProjects = async (): Promise => {
- const response = await apiClient.get("/projects");
+export const listProjects = async (): Promise => {
+ const response = await apiClient.get("/projects");
return response.data;
};
diff --git a/apps/web/src/components/icon.tsx b/apps/web/src/components/icon.tsx
index cc2cc79..7222b30 100644
--- a/apps/web/src/components/icon.tsx
+++ b/apps/web/src/components/icon.tsx
@@ -36,6 +36,8 @@ import {
ArrowLeft,
DotsSixVertical,
Bell,
+ CaretDown,
+ CaretRight,
} from "@phosphor-icons/react";
export type IconName =
@@ -79,7 +81,9 @@ export type IconName =
| "terminal"
| "arrow-left"
| "drag"
- | "bell";
+ | "bell"
+ | "chevron-down"
+ | "chevron-right";
const iconMap: Record<
IconName,
@@ -129,6 +133,8 @@ const iconMap: Record<
"arrow-left": ArrowLeft,
drag: DotsSixVertical,
bell: Bell,
+ "chevron-down": CaretDown,
+ "chevron-right": CaretRight,
};
export interface IconProps {
diff --git a/apps/web/src/pages/config-profiles.tsx b/apps/web/src/pages/config-profiles.tsx
index 4e630cf..ac7e33f 100644
--- a/apps/web/src/pages/config-profiles.tsx
+++ b/apps/web/src/pages/config-profiles.tsx
@@ -19,7 +19,7 @@ import {
type ResolvedProfile,
} from "../api/config_profiles";
import { listProjects } from "../api/projects";
-import type { Project } from "../types";
+import type { ProjectWithRepos } from "../types";
import { listToolTypes, type ToolType } from "../api/tool_types";
import { GitMountEditor } from "../components/git-mount-editor";
@@ -31,7 +31,7 @@ export const ConfigProfilesPage = () => {
const [mobileView, setMobileView] = useState("list");
const [status, setStatus] = useState("loading");
const [profiles, setProfiles] = useState([]);
- const [projects, setProjects] = useState([]);
+ const [projects, setProjects] = useState([]);
const [toolTypes, setToolTypes] = useState([]);
const [selectedProfileId, setSelectedProfileId] = useState(
diff --git a/apps/web/src/pages/dashboard.tsx b/apps/web/src/pages/dashboard.tsx
index 00ee820..59e2ea0 100644
--- a/apps/web/src/pages/dashboard.tsx
+++ b/apps/web/src/pages/dashboard.tsx
@@ -7,7 +7,7 @@ import { listProjects } from "../api/projects";
import { listRepositories, type GitRepository } from "../api/git_repositories";
import { listToolTypes, type ToolType } from "../api/tool_types";
import { updateUserConfig } from "../api/settings";
-import type { Project } from "../types";
+import type { ProjectWithRepos } from "../types";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { CreateSessionForm } from "../components/create-session-form";
import { SessionList } from "../components/session-list";
@@ -28,7 +28,7 @@ export const HomePage = () => {
const [status, setStatus] = useState("loading");
const [summary, setSummary] = useState(null);
const [sessions, setSessions] = useState([]);
- const [projects, setProjects] = useState([]);
+ const [projects, setProjects] = useState([]);
const [repositories, setRepositories] = useState([]);
const [toolTypes, setToolTypes] = useState([]);
const [selectedProject, setSelectedProject] = useState("");
diff --git a/apps/web/src/pages/projects.tsx b/apps/web/src/pages/projects.tsx
index c1c0d71..92df8f0 100644
--- a/apps/web/src/pages/projects.tsx
+++ b/apps/web/src/pages/projects.tsx
@@ -1,216 +1,473 @@
+/** Projects page with inline repositories and workspaces. */
+
import { useState } from "react";
-import { Link } from "react-router-dom";
-
import {
- createProject,
- deleteProject,
- listProjects,
- updateProject,
- type ProjectCreateInput,
- type ProjectUpdateInput,
+ createProject,
+ deleteProject,
+ listProjects,
+ updateProject,
+ type ProjectCreateInput,
+ type ProjectUpdateInput,
} from "../api/projects";
+import {
+ createWorkspace,
+ deleteWorkspace,
+ syncWorkspace,
+} from "../api/workspaces";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
+import { WorkspaceCreateForm } from "../components/workspace-create-form";
import { useAsyncData } from "../hooks/use-async-data";
-import type { Project } from "../types";
+import type { ProjectWithRepos, WorkspaceSummary } from "../types";
type DialogMode = "none" | "create" | "edit";
export const ProjectsPage = () => {
- const { data: projects, status, reload } = useAsyncData(listProjects, []);
- const [dialogMode, setDialogMode] = useState("none");
- const [editingProject, setEditingProject] = useState(null);
- const [formName, setFormName] = useState("");
- const [formDescription, setFormDescription] = useState("");
- const [formError, setFormError] = useState(null);
- const [deleteConfirmId, setDeleteConfirmId] = useState(null);
+ const { data: projects, status, reload } = useAsyncData(
+ listProjects,
+ [],
+ );
+ const [dialogMode, setDialogMode] = useState("none");
+ const [editingProject, setEditingProject] = useState(
+ null,
+ );
+ const [formName, setFormName] = useState("");
+ const [formDescription, setFormDescription] = useState("");
+ const [formError, setFormError] = useState(null);
+ const [deleteConfirmId, setDeleteConfirmId] = useState(null);
+ const [expandedProject, setExpandedProject] = useState(null);
+ const [creatingWorkspace, setCreatingWorkspace] = useState<{
+ projectId: string;
+ repoId: string;
+ } | null>(null);
+ const [workspaceLoading, setWorkspaceLoading] = useState(null);
- const safeProjects = projects ?? [];
+ const safeProjects = projects ?? [];
- const openCreate = () => {
- setFormName("");
- setFormDescription("");
- setFormError(null);
- setEditingProject(null);
- setDialogMode("create");
- };
+ const openCreate = () => {
+ setFormName("");
+ setFormDescription("");
+ setFormError(null);
+ setEditingProject(null);
+ setDialogMode("create");
+ };
- const openEdit = (project: Project) => {
- setFormName(project.name);
- setFormDescription(project.description ?? "");
- setFormError(null);
- setEditingProject(project);
- setDialogMode("edit");
- };
+ const openEdit = (project: ProjectWithRepos) => {
+ setFormName(project.name);
+ setFormDescription(project.description ?? "");
+ setFormError(null);
+ setEditingProject(project);
+ setDialogMode("edit");
+ };
- const closeDialog = () => {
- setDialogMode("none");
- setEditingProject(null);
- setFormError(null);
- };
+ const closeDialog = () => {
+ setDialogMode("none");
+ setEditingProject(null);
+ setFormError(null);
+ };
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- setFormError(null);
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setFormError(null);
- if (!formName.trim()) {
- setFormError("Project name is required");
- return;
- }
+ if (!formName.trim()) {
+ setFormError("Project name is required");
+ return;
+ }
- try {
- if (dialogMode === "create") {
- const input: ProjectCreateInput = {
- name: formName.trim(),
- description: formDescription.trim() || null,
- };
- await createProject(input);
- } else if (dialogMode === "edit" && editingProject) {
- const input: ProjectUpdateInput = {
- name: formName.trim(),
- description: formDescription.trim() || null,
- };
- await updateProject(editingProject.id, input);
- }
- closeDialog();
- reload();
- } catch {
- setFormError("Failed to save project");
- }
- };
+ try {
+ if (dialogMode === "create") {
+ const input: ProjectCreateInput = {
+ name: formName.trim(),
+ description: formDescription.trim() || null,
+ };
+ await createProject(input);
+ } else if (dialogMode === "edit" && editingProject) {
+ const input: ProjectUpdateInput = {
+ name: formName.trim(),
+ description: formDescription.trim() || null,
+ };
+ await updateProject(editingProject.id, input);
+ }
+ closeDialog();
+ reload();
+ } catch {
+ setFormError("Failed to save project");
+ }
+ };
- const handleDelete = async (projectId: string) => {
- try {
- await deleteProject(projectId);
- setDeleteConfirmId(null);
- reload();
- } catch {
- setDeleteConfirmId(null);
- }
- };
+ const handleDelete = async (projectId: string) => {
+ try {
+ await deleteProject(projectId);
+ setDeleteConfirmId(null);
+ reload();
+ } catch {
+ setDeleteConfirmId(null);
+ }
+ };
- const isEmpty = status === "ready" && safeProjects.length === 0;
+ const handleCreateWorkspace = async (
+ projectId: string,
+ repoId: string,
+ data: { name: string; branch: string },
+ ) => {
+ setWorkspaceLoading(repoId);
+ try {
+ await createWorkspace(projectId, repoId, data);
+ setCreatingWorkspace(null);
+ reload();
+ } catch (err) {
+ alert(err instanceof Error ? err.message : "Failed to create workspace");
+ } finally {
+ setWorkspaceLoading(null);
+ }
+ };
- return (
-
-
-
Projects
-
-
+ const handleSyncWorkspace = async (
+ projectId: string,
+ repoId: string,
+ workspace: WorkspaceSummary,
+ ) => {
+ setWorkspaceLoading(workspace.id);
+ try {
+ await syncWorkspace(projectId, repoId, workspace.id);
+ reload();
+ } catch (err) {
+ alert(err instanceof Error ? err.message : "Failed to sync workspace");
+ } finally {
+ setWorkspaceLoading(null);
+ }
+ };
- {status === "loading" && }
+ const handleDeleteWorkspace = async (
+ projectId: string,
+ repoId: string,
+ workspace: WorkspaceSummary,
+ ) => {
+ if (!confirm(`Delete workspace "${workspace.name}"?`)) return;
+ setWorkspaceLoading(workspace.id);
+ try {
+ await deleteWorkspace(projectId, repoId, workspace.id);
+ reload();
+ } catch (err) {
+ alert(err instanceof Error ? err.message : "Failed to delete workspace");
+ } finally {
+ setWorkspaceLoading(null);
+ }
+ };
- {status === "error" && }
+ const isEmpty = status === "ready" && safeProjects.length === 0;
- {isEmpty && }
+ return (
+
+
+
Projects
+
+
- {status === "ready" && safeProjects.length > 0 && (
-
- {safeProjects.map((project) => (
-
-
-
{project.name}
- {project.description &&
{project.description}
}
-
-
-
- Open Workspace
-
-
- {deleteConfirmId === project.id ? (
-
- Are you sure?
-
-
-
- ) : (
-
- )}
-
-
- ))}
-
- )}
+ {status === "loading" && }
- {dialogMode !== "none" && (
-
-
-
{dialogMode === "create" ? "Create Project" : "Edit Project"}
-
-
-
- )}
-
- );
+ {status === "error" && (
+
+ )}
+
+ {isEmpty && (
+
+ )}
+
+ {status === "ready" && safeProjects.length > 0 && (
+
+ {safeProjects.map((project) => (
+
+ setExpandedProject(
+ expandedProject === project.id ? null : project.id,
+ )
+ }
+ onEdit={() => openEdit(project)}
+ onDelete={() => setDeleteConfirmId(project.id)}
+ deleteConfirm={deleteConfirmId === project.id}
+ onConfirmDelete={() => void handleDelete(project.id)}
+ onCancelDelete={() => setDeleteConfirmId(null)}
+ onCreateWorkspace={(repoId) =>
+ setCreatingWorkspace({ projectId: project.id, repoId })
+ }
+ onWorkspaceAction={(repoId, workspace, action) => {
+ if (action === "sync") {
+ void handleSyncWorkspace(project.id, repoId, workspace);
+ } else if (action === "delete") {
+ void handleDeleteWorkspace(
+ project.id,
+ repoId,
+ workspace,
+ );
+ }
+ }}
+ workspaceLoading={workspaceLoading}
+ showCreateForm={
+ creatingWorkspace?.projectId === project.id
+ ? creatingWorkspace.repoId
+ : null
+ }
+ onCancelCreate={() => setCreatingWorkspace(null)}
+ onSubmitCreate={async (repoId, data) =>
+ await handleCreateWorkspace(project.id, repoId, data)
+ }
+ />
+ ))}
+
+ )}
+
+ {dialogMode !== "none" && (
+
+
+
+ {dialogMode === "create" ? "Create Project" : "Edit Project"}
+
+
+
+
+ )}
+
+ );
};
+
+/* ─── Project Card ─── */
+
+function ProjectCard({
+ project,
+ expanded,
+ onToggle,
+ onEdit,
+ onDelete,
+ deleteConfirm,
+ onConfirmDelete,
+ onCancelDelete,
+ onCreateWorkspace,
+ onWorkspaceAction,
+ workspaceLoading,
+ showCreateForm,
+ onCancelCreate,
+ onSubmitCreate,
+}: {
+ project: ProjectWithRepos;
+ expanded: boolean;
+ onToggle: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+ deleteConfirm: boolean;
+ onConfirmDelete: () => void;
+ onCancelDelete: () => void;
+ onCreateWorkspace: (repoId: string) => void;
+ onWorkspaceAction: (
+ repoId: string,
+ workspace: WorkspaceSummary,
+ action: "sync" | "delete",
+ ) => void;
+ workspaceLoading: string | null;
+ onCancelCreate: () => void;
+ showCreateForm: string | null;
+ onSubmitCreate: (repoId: string, data: { name: string; branch: string }) => Promise;
+}) {
+ return (
+
+
+
+
+
+ {deleteConfirm ? (
+
+ Are you sure?
+
+
+
+ ) : (
+
+ )}
+
+
+
+ {expanded && (
+
+ {project.repositories.length === 0 ? (
+
No repositories yet.
+ ) : (
+
+ {project.repositories.map((repo) => (
+
+
+
{repo.name}
+
+
+ {showCreateForm === repo.id && (
+
+ onSubmitCreate(repo.id, data)
+ }
+ onCancel={onCancelCreate}
+ />
+ )}
+ {repo.workspaces.length === 0 ? (
+ No workspaces.
+ ) : (
+
+ {repo.workspaces.map((ws) => (
+
+
{ws.name}
+
+ {ws.branch}
+
+ {ws.instance_count > 0 && (
+
+ {ws.instance_count} tool
+ {ws.instance_count > 1 ? "s" : ""}
+
+ )}
+
+
+
+
+
+ ))}
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/pages/sessions.tsx b/apps/web/src/pages/sessions.tsx
index dd95c27..54d9965 100644
--- a/apps/web/src/pages/sessions.tsx
+++ b/apps/web/src/pages/sessions.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { listProjects } from "../api/projects";
-import type { Project } from "../types";
+import type { ProjectWithRepos } from "../types";
import { listRepositories, type GitRepository } from "../api/git_repositories";
import {
getUserSessions,
@@ -24,7 +24,7 @@ export const SessionsPage = () => {
const [sessions, setSessions] = useState([]);
const [lastSessionId, setLastSessionId] = useState(null);
- const [projects, setProjects] = useState([]);
+ const [projects, setProjects] = useState([]);
const [repositories, setRepositories] = useState([]);
const [toolTypes, setToolTypes] = useState([]);
const [selectedProject, setSelectedProject] = useState("");
diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx
index 9131e30..dd393fa 100644
--- a/apps/web/src/router.tsx
+++ b/apps/web/src/router.tsx
@@ -9,7 +9,6 @@ import { ProjectsPage } from "./pages/projects";
import { GitRepositoriesPage } from "./pages/git-repositories";
import { GitHistoryPage } from "./pages/git-history";
import { ProjectSettingsPage } from "./pages/project-settings";
-import { RepoWorkspace } from "./pages/repo-workspace";
import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
import { TerminalPage } from "./pages/terminal";
import { ToolWorkshopPage } from "./pages/tool-workshop";
@@ -37,7 +36,6 @@ export const AppRouter = () => {
>
} />
} />
- } />
}
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index b15d9eb..4960c82 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -5322,3 +5322,135 @@ a:active,
.workspace-header-link:hover .workspace-header h4 {
color: var(--brand);
}
+
+/* ─── Projects Page Refresh ─── */
+
+.project-info-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--space-3);
+ flex-wrap: wrap;
+}
+
+.project-toggle {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ background: none;
+ border: none;
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+ padding: var(--space-2);
+ border-radius: 10px;
+ flex: 1;
+}
+
+.project-toggle:hover {
+ background: var(--bg);
+}
+
+.project-toggle h3 {
+ margin: 0;
+ font-size: var(--font-size-lg);
+}
+
+.repo-count {
+ font-size: var(--font-size-xs);
+ padding: var(--space-1) var(--space-2);
+ background: var(--bg);
+ border-radius: 999px;
+ color: var(--muted);
+}
+
+.project-detail {
+ margin-top: var(--space-4);
+ padding-top: var(--space-4);
+ border-top: 1px solid var(--border);
+}
+
+.repo-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.repo-block {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ padding: var(--space-4);
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+}
+
+.repo-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--space-3);
+ flex-wrap: wrap;
+}
+
+.repo-header h4 {
+ margin: 0;
+ font-size: var(--font-size-base);
+}
+
+.workspace-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: var(--space-3);
+}
+
+.workspace-chip {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ padding: var(--space-3);
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ font-size: var(--font-size-sm);
+}
+
+.workspace-chip a {
+ font-weight: 600;
+ color: var(--brand);
+}
+
+.workspace-chip .ws-branch {
+ color: var(--muted);
+ font-size: var(--font-size-xs);
+}
+
+.workspace-chip .ws-instances {
+ font-size: var(--font-size-xs);
+ color: var(--success);
+}
+
+.workspace-chip .ws-actions {
+ display: flex;
+ gap: var(--space-1);
+ margin-top: var(--space-1);
+}
+
+.workspace-chip .ws-actions button {
+ background: none;
+ border: none;
+ color: var(--muted);
+ cursor: pointer;
+ padding: var(--space-1);
+ border-radius: 4px;
+}
+
+.workspace-chip .ws-actions button:hover {
+ background: var(--bg);
+ color: var(--ink);
+}
+
+.workspace-chip .ws-actions button.danger-text:hover {
+ color: var(--danger);
+}
diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts
index d564bcc..c00b186 100644
--- a/apps/web/src/types.ts
+++ b/apps/web/src/types.ts
@@ -16,3 +16,28 @@ export type Project = {
owner_id: string;
default_ssh_key_id: string | null;
};
+
+export type WorkspaceSummary = {
+ id: string;
+ name: string;
+ branch: string;
+ status: string;
+ instance_count: number;
+};
+
+export type RepositorySummary = {
+ id: string;
+ name: string;
+ remote_url: string;
+ workspaces: WorkspaceSummary[];
+};
+
+export type ProjectWithRepos = {
+ id: string;
+ name: string;
+ description: string | null;
+ owner_id: string;
+ default_ssh_key_id: string | null;
+ repositories: RepositorySummary[];
+ created_at: string;
+};
diff --git a/apps/web/src/utils/icons.ts b/apps/web/src/utils/icons.ts
index 4db62f6..bca4188 100644
--- a/apps/web/src/utils/icons.ts
+++ b/apps/web/src/utils/icons.ts
@@ -1,180 +1 @@
-import {
- House,
- Folder,
- GitBranch,
- Gear,
- User,
- SignOut,
- Plus,
- PencilSimple,
- Trash,
- FloppyDisk,
- X,
- ArrowsClockwise,
- Copy,
- MagnifyingGlass,
- List,
- Check,
- Warning,
- Info,
- Spinner,
- GitCommit,
- GitMerge,
- ClockCounterClockwise,
- ArrowDown,
- ArrowUp,
- File,
- FileText,
- Image,
- Binary,
- Code,
- ArrowSquareOut,
- Play,
- Stop,
- Terminal,
- ArrowLeft,
- Bell,
-} from "@phosphor-icons/react";
-
-export type IconName =
- | "dashboard"
- | "projects"
- | "repositories"
- | "settings"
- | "profile"
- | "logout"
- | "add"
- | "edit"
- | "delete"
- | "save"
- | "cancel"
- | "refresh"
- | "copy"
- | "search"
- | "menu"
- | "close"
- | "success"
- | "error"
- | "warning"
- | "info"
- | "loading"
- | "branch"
- | "commit"
- | "merge"
- | "history"
- | "pull"
- | "push"
- | "fetch"
- | "file"
- | "folder"
- | "code"
- | "document"
- | "image"
- | "binary"
- | "external"
- | "play"
- | "stop"
- | "terminal"
- | "arrow-left"
- | "bell";
-
-export const iconRegistry: Record<
- IconName,
- React.ComponentType<{
- size?: number | string;
- weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone";
- }>
-> = {
- // Navigation
- dashboard: House,
- projects: Folder,
- repositories: GitBranch,
- settings: Gear,
- profile: User,
- logout: SignOut,
-
- // Actions
- add: Plus,
- edit: PencilSimple,
- delete: Trash,
- save: FloppyDisk,
- cancel: X,
- refresh: ArrowsClockwise,
- copy: Copy,
- search: MagnifyingGlass,
- menu: List,
- close: X,
-
- // Status
- success: Check,
- error: X,
- warning: Warning,
- info: Info,
- loading: Spinner,
-
- // Git
- branch: GitBranch,
- commit: GitCommit,
- merge: GitMerge,
- history: ClockCounterClockwise,
- pull: ArrowDown,
- push: ArrowUp,
- fetch: ArrowsClockwise,
-
- // Files
- file: File,
- folder: Folder,
- code: Code,
- document: FileText,
- image: Image,
- binary: Binary,
-
- // Instance actions
- external: ArrowSquareOut,
- play: Play,
- stop: Stop,
- terminal: Terminal,
- "arrow-left": ArrowLeft,
- bell: Bell,
-};
-
-export const iconCategories = {
- navigation: [
- "dashboard",
- "projects",
- "repositories",
- "settings",
- "profile",
- "logout",
- ] as IconName[],
- actions: [
- "add",
- "edit",
- "delete",
- "save",
- "cancel",
- "refresh",
- "copy",
- "search",
- "menu",
- "close",
- ] as IconName[],
- status: ["success", "error", "warning", "info", "loading"] as IconName[],
- git: [
- "branch",
- "commit",
- "merge",
- "history",
- "pull",
- "push",
- "fetch",
- ] as IconName[],
- files: [
- "file",
- "folder",
- "code",
- "document",
- "image",
- "binary",
- ] as IconName[],
-};
+export type { IconName } from "../components/icon";
From ab1843b1c3e727d55d0eb1748c8b63170c739e46 Mon Sep 17 00:00:00 2001
From: Alex Blank
Date: Mon, 1 Jun 2026 17:32:35 +0200
Subject: [PATCH 24/44] fix: allow workspace creation from workspaces page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Replace awkward first-workspace-guessing logic with inline project/repo selector
- New WorkspaceCreateInline component with cascading dropdowns:
- Select project → loads repositories for that project
- Select repository → enter workspace name + branch
- Submit creates workspace via top-level POST /workspaces/
- Add createWorkspaceTopLevel() API client for flat endpoint
- Works even with zero existing workspaces (shows create button in empty state)
- Add CSS grid layout for inline create form
- TypeScript + eslint clean
---
apps/web/src/api/projects.ts | 47 +-
apps/web/src/api/workspaces.ts | 7 +
apps/web/src/components/workspace-card.tsx | 5 +-
apps/web/src/pages/config-profiles.tsx | 1 -
apps/web/src/pages/dashboard.tsx | 518 ++++++++++++---------
apps/web/src/pages/projects.tsx | 55 +--
apps/web/src/pages/sessions.tsx | 442 +++++++++---------
apps/web/src/pages/workspace-detail.tsx | 9 +-
apps/web/src/pages/workspaces.tsx | 241 ++++++++--
apps/web/src/router.tsx | 5 +-
apps/web/src/styles.css | 49 ++
apps/web/src/types.ts | 52 +--
12 files changed, 870 insertions(+), 561 deletions(-)
diff --git a/apps/web/src/api/projects.ts b/apps/web/src/api/projects.ts
index 1252dce..19ee39e 100644
--- a/apps/web/src/api/projects.ts
+++ b/apps/web/src/api/projects.ts
@@ -2,50 +2,53 @@ import { apiClient } from "./client";
import type { Project, ProjectWithRepos } from "../types";
export type ProjectCreateInput = {
- name: string;
- description?: string | null;
+ name: string;
+ description?: string | null;
};
export type ProjectUpdateInput = {
- name?: string | null;
- description?: string | null;
+ name?: string | null;
+ description?: string | null;
};
export type SetDefaultSSHKeyInput = {
- ssh_key_id: string;
+ ssh_key_id: string;
};
export const listProjects = async (): Promise => {
- const response = await apiClient.get("/projects");
- return response.data;
+ const response = await apiClient.get("/projects");
+ return response.data;
};
export const createProject = async (
- input: ProjectCreateInput
+ input: ProjectCreateInput,
): Promise => {
- const response = await apiClient.post("/projects", input);
- return response.data;
+ const response = await apiClient.post("/projects", input);
+ return response.data;
};
export const updateProject = async (
- projectId: string,
- input: ProjectUpdateInput
+ projectId: string,
+ input: ProjectUpdateInput,
): Promise => {
- const response = await apiClient.patch(`/projects/${projectId}`, input);
- return response.data;
+ const response = await apiClient.patch(
+ `/projects/${projectId}`,
+ input,
+ );
+ return response.data;
};
export const deleteProject = async (projectId: string): Promise => {
- await apiClient.delete(`/projects/${projectId}`);
+ await apiClient.delete(`/projects/${projectId}`);
};
export const setDefaultSSHKey = async (
- projectId: string,
- input: SetDefaultSSHKeyInput
+ projectId: string,
+ input: SetDefaultSSHKeyInput,
): Promise => {
- const response = await apiClient.patch(
- `/projects/${projectId}/default-ssh-key`,
- input
- );
- return response.data;
+ const response = await apiClient.patch(
+ `/projects/${projectId}/default-ssh-key`,
+ input,
+ );
+ return response.data;
};
diff --git a/apps/web/src/api/workspaces.ts b/apps/web/src/api/workspaces.ts
index b452132..9636b7c 100644
--- a/apps/web/src/api/workspaces.ts
+++ b/apps/web/src/api/workspaces.ts
@@ -39,6 +39,13 @@ export async function createWorkspace(
return response.data;
}
+export async function createWorkspaceTopLevel(
+ data: CreateWorkspaceRequest & { repo_id: string },
+): Promise {
+ const response = await apiClient.post("/workspaces/", data);
+ return response.data;
+}
+
export async function getWorkspace(
projectId: string,
repoId: string,
diff --git a/apps/web/src/components/workspace-card.tsx b/apps/web/src/components/workspace-card.tsx
index 4e92079..47ebed4 100644
--- a/apps/web/src/components/workspace-card.tsx
+++ b/apps/web/src/components/workspace-card.tsx
@@ -28,7 +28,10 @@ export function WorkspaceCard({
return (
-
+
{workspace.name}
diff --git a/apps/web/src/pages/config-profiles.tsx b/apps/web/src/pages/config-profiles.tsx
index ac7e33f..09e0ca6 100644
--- a/apps/web/src/pages/config-profiles.tsx
+++ b/apps/web/src/pages/config-profiles.tsx
@@ -1539,7 +1539,6 @@ export const ConfigProfilesPage = () => {
onChange={(git_mounts) =>
updateFormField("git_mounts", git_mounts)
}
-
/>
diff --git a/apps/web/src/pages/dashboard.tsx b/apps/web/src/pages/dashboard.tsx
index 59e2ea0..b511002 100644
--- a/apps/web/src/pages/dashboard.tsx
+++ b/apps/web/src/pages/dashboard.tsx
@@ -2,13 +2,22 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
-import { getUserSessions, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
+import {
+ getUserSessions,
+ checkInstanceHealth,
+ type Session as SessionApi,
+ type InstanceHealth,
+} from "../api/sessions";
import { listProjects } from "../api/projects";
import { listRepositories, type GitRepository } from "../api/git_repositories";
import { listToolTypes, type ToolType } from "../api/tool_types";
import { updateUserConfig } from "../api/settings";
import type { ProjectWithRepos } from "../types";
-import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
+import {
+ EmptyState,
+ ErrorState,
+ LoadingState,
+} from "../components/data-states";
import { CreateSessionForm } from "../components/create-session-form";
import { SessionList } from "../components/session-list";
import { useInstanceActions } from "../hooks/use-instance-actions";
@@ -16,251 +25,312 @@ import { useInstanceActions } from "../hooks/use-instance-actions";
type HomeStatus = "loading" | "ready" | "error";
const summaryCards = [
- { label: "Open sessions", key: "openSessions" },
- { label: "Projects", key: "projects" },
- { label: "Repositories", key: "repositories" },
+ { label: "Open sessions", key: "openSessions" },
+ { label: "Projects", key: "projects" },
+ { label: "Repositories", key: "repositories" },
] as const;
type SessionView = SessionApi;
export const HomePage = () => {
- const navigate = useNavigate();
- const [status, setStatus] = useState("loading");
- const [summary, setSummary] = useState(null);
- const [sessions, setSessions] = useState([]);
- const [projects, setProjects] = useState([]);
- const [repositories, setRepositories] = useState([]);
- const [toolTypes, setToolTypes] = useState([]);
- const [selectedProject, setSelectedProject] = useState("");
- const [tunnelHealth, setTunnelHealth] = useState>({});
- const safeSessions = Array.isArray(sessions) ? sessions : [];
+ const navigate = useNavigate();
+ const [status, setStatus] = useState("loading");
+ const [summary, setSummary] = useState(null);
+ const [sessions, setSessions] = useState([]);
+ const [projects, setProjects] = useState([]);
+ const [repositories, setRepositories] = useState([]);
+ const [toolTypes, setToolTypes] = useState([]);
+ const [selectedProject, setSelectedProject] = useState("");
+ const [tunnelHealth, setTunnelHealth] = useState<
+ Record
+ >({});
+ const safeSessions = Array.isArray(sessions) ? sessions : [];
- const loadHome = useCallback(async () => {
- setStatus("loading");
- try {
- const [dashboard, sessionData, projectData, toolTypeData] = await Promise.all([
- getDashboardSummary(),
- getUserSessions(),
- listProjects(),
- listToolTypes(),
- ]);
- setSummary(dashboard);
- setSessions(sessionData as SessionView[]);
- setProjects(projectData);
- setToolTypes(toolTypeData);
- setStatus("ready");
- } catch {
- setStatus("error");
- }
- }, []);
+ const loadHome = useCallback(async () => {
+ setStatus("loading");
+ try {
+ const [dashboard, sessionData, projectData, toolTypeData] =
+ await Promise.all([
+ getDashboardSummary(),
+ getUserSessions(),
+ listProjects(),
+ listToolTypes(),
+ ]);
+ setSummary(dashboard);
+ setSessions(sessionData as SessionView[]);
+ setProjects(projectData);
+ setToolTypes(toolTypeData);
+ setStatus("ready");
+ } catch {
+ setStatus("error");
+ }
+ }, []);
- useEffect(() => {
- void loadHome();
- }, [loadHome]);
+ useEffect(() => {
+ void loadHome();
+ }, [loadHome]);
- const {
- loadingSessionId: actionBusy,
- handleOpen,
- handleStart,
- handleStop,
- handleDelete,
- handleRecreateTunnel,
- } = useInstanceActions({ onRefresh: loadHome });
+ const {
+ loadingSessionId: actionBusy,
+ handleOpen,
+ handleStart,
+ handleStop,
+ handleDelete,
+ handleRecreateTunnel,
+ } = useInstanceActions({ onRefresh: loadHome });
- // Poll tunnel health every 30 seconds for running instances
- useEffect(() => {
- const checkHealth = async () => {
- const runningSessions = safeSessions.filter(
- (s) => s.status === "running" && s.url
- );
- for (const session of runningSessions) {
- try {
- const health = await checkInstanceHealth(
- session.project_id,
- session.repository_id,
- session.id
- );
- setTunnelHealth((prev) => ({
- ...prev,
- [session.id]: health,
- }));
- } catch {
- setTunnelHealth((prev) => ({
- ...prev,
- [session.id]: {
- healthy: false,
- container_status: "unknown",
- container_health: null,
- container_exit_code: null,
- tunnel_status: "error",
- tunnel_status_code: null,
- probe_status: "error",
- last_probe_output: null,
- error: "check failed",
- },
- }));
- }
- }
- };
+ // Poll tunnel health every 30 seconds for running instances
+ useEffect(() => {
+ const checkHealth = async () => {
+ const runningSessions = safeSessions.filter(
+ (s) => s.status === "running" && s.url,
+ );
+ for (const session of runningSessions) {
+ try {
+ const health = await checkInstanceHealth(
+ session.project_id,
+ session.repository_id,
+ session.id,
+ );
+ setTunnelHealth((prev) => ({
+ ...prev,
+ [session.id]: health,
+ }));
+ } catch {
+ setTunnelHealth((prev) => ({
+ ...prev,
+ [session.id]: {
+ healthy: false,
+ container_status: "unknown",
+ container_health: null,
+ container_exit_code: null,
+ tunnel_status: "error",
+ tunnel_status_code: null,
+ probe_status: "error",
+ last_probe_output: null,
+ error: "check failed",
+ },
+ }));
+ }
+ }
+ };
- void checkHealth();
- const interval = setInterval(() => {
- void checkHealth();
- }, 30000);
- return () => clearInterval(interval);
- }, [safeSessions]);
+ void checkHealth();
+ const interval = setInterval(() => {
+ void checkHealth();
+ }, 30000);
+ return () => clearInterval(interval);
+ }, [safeSessions]);
- useEffect(() => {
- if (!selectedProject) {
- setRepositories([]);
- return;
- }
+ useEffect(() => {
+ if (!selectedProject) {
+ setRepositories([]);
+ return;
+ }
- const loadRepos = async () => {
- try {
- const data = await listRepositories(selectedProject);
- setRepositories(data);
- } catch {
- setRepositories([]);
- }
- };
+ const loadRepos = async () => {
+ try {
+ const data = await listRepositories(selectedProject);
+ setRepositories(data);
+ } catch {
+ setRepositories([]);
+ }
+ };
- void loadRepos();
- }, [selectedProject]);
+ void loadRepos();
+ }, [selectedProject]);
- const activeSessions = useMemo(
- () => safeSessions.filter((session) => ["running", "building", "pending"].includes(session.status)),
- [safeSessions]
- );
+ const activeSessions = useMemo(
+ () =>
+ safeSessions.filter((session) =>
+ ["running", "building", "pending"].includes(session.status),
+ ),
+ [safeSessions],
+ );
- const handleCreateSuccess = async (instance: { id: string }) => {
- await updateUserConfig({ last_session_id: instance.id });
- setSelectedProject("");
- await loadHome();
- };
+ const handleCreateSuccess = async (instance: { id: string }) => {
+ await updateUserConfig({ last_session_id: instance.id });
+ setSelectedProject("");
+ await loadHome();
+ };
- return (
-
-
-
-
Workspace overview
-
Home
-
Open sessions, available projects, and the fastest path back into work.
-
-
-
-
-
-
+ return (
+
+
+
+
Workspace overview
+
Home
+
+ Open sessions, available projects, and the fastest path back into
+ work.
+
+
+
+
+
+
+
- {status === "loading" && }
+ {status === "loading" && }
- {status === "error" && void loadHome()} />}
+ {status === "error" && (
+ void loadHome()}
+ />
+ )}
- {status === "ready" && summary && (
- <>
-
- {summaryCards.map((card) => (
-
- {card.label}
-
- {card.key === "openSessions"
- ? activeSessions.length
- : card.key === "projects"
- ? summary.projects
- : summary.repositories}
-
-
- ))}
-
+ {status === "ready" && summary && (
+ <>
+
+ {summaryCards.map((card) => (
+
+ {card.label}
+
+ {card.key === "openSessions"
+ ? activeSessions.length
+ : card.key === "projects"
+ ? summary.projects
+ : summary.repositories}
+
+
+ ))}
+
-
-
-
-
Open sessions
-
{safeSessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)).length}
-
-
-
-
+
+
+
+
Open sessions
+
+ {
+ safeSessions.filter((s) =>
+ [
+ "running",
+ "building",
+ "pending",
+ "starting",
+ "probing",
+ "unhealthy",
+ ].includes(s.status),
+ ).length
+ }
+
+
+
+
+
-
-
-
-
Available projects
-
{projects.length}
-
-
-
- {projects.length === 0 ? (
-
- ) : (
-
- {projects.map((project) => (
-
-
-
{project.name}
- {project.description &&
{project.description}
}
-
-
-
- ))}
-
- )}
-
+
+
+
+
Available projects
+
{projects.length}
+
+
+
+ {projects.length === 0 ? (
+
+ ) : (
+
+ {projects.map((project) => (
+
+
+
{project.name}
+ {project.description && (
+
{project.description}
+ )}
+
+
+
+ ))}
+
+ )}
+
-
-
-
-
Quick create
-
Start a session
-
-
- setSelectedProject(projectId)}
- onSuccess={handleCreateSuccess}
- />
-
+
+
+
+
Quick create
+
Start a session
+
+
+ setSelectedProject(projectId)}
+ onSuccess={handleCreateSuccess}
+ />
+
- {safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length > 0 && (
-
-
-
-
Recent sessions
-
{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length}
-
-
-
-
- )}
- >
- )}
-
- );
+ {safeSessions.filter((s) => ["stopped", "error"].includes(s.status))
+ .length > 0 && (
+
+
+
+
Recent sessions
+
+ {
+ safeSessions.filter((s) =>
+ ["stopped", "error"].includes(s.status),
+ ).length
+ }
+
+
+
+
+
+ )}
+ >
+ )}
+
+ );
};
export { HomePage as DashboardPage };
diff --git a/apps/web/src/pages/projects.tsx b/apps/web/src/pages/projects.tsx
index 92df8f0..622eb35 100644
--- a/apps/web/src/pages/projects.tsx
+++ b/apps/web/src/pages/projects.tsx
@@ -15,7 +15,11 @@ import {
deleteWorkspace,
syncWorkspace,
} from "../api/workspaces";
-import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
+import {
+ EmptyState,
+ ErrorState,
+ LoadingState,
+} from "../components/data-states";
import { Icon } from "../components/icon";
import { WorkspaceCreateForm } from "../components/workspace-create-form";
import { useAsyncData } from "../hooks/use-async-data";
@@ -24,10 +28,11 @@ import type { ProjectWithRepos, WorkspaceSummary } from "../types";
type DialogMode = "none" | "create" | "edit";
export const ProjectsPage = () => {
- const { data: projects, status, reload } = useAsyncData(
- listProjects,
- [],
- );
+ const {
+ data: projects,
+ status,
+ reload,
+ } = useAsyncData(listProjects, []);
const [dialogMode, setDialogMode] = useState("none");
const [editingProject, setEditingProject] = useState(
null,
@@ -203,11 +208,7 @@ export const ProjectsPage = () => {
if (action === "sync") {
void handleSyncWorkspace(project.id, repoId, workspace);
} else if (action === "delete") {
- void handleDeleteWorkspace(
- project.id,
- repoId,
- workspace,
- );
+ void handleDeleteWorkspace(project.id, repoId, workspace);
}
}}
workspaceLoading={workspaceLoading}
@@ -317,7 +318,10 @@ function ProjectCard({
workspaceLoading: string | null;
onCancelCreate: () => void;
showCreateForm: string | null;
- onSubmitCreate: (repoId: string, data: { name: string; branch: string }) => Promise;
+ onSubmitCreate: (
+ repoId: string,
+ data: { name: string; branch: string },
+ ) => Promise;
}) {
return (
@@ -328,10 +332,7 @@ function ProjectCard({
type="button"
aria-expanded={expanded}
>
-
+
{project.name}
{project.repositories.length > 0 && (
@@ -400,9 +401,7 @@ function ProjectCard({
- onSubmitCreate(repo.id, data)
- }
+ onSubmit={(data) => onSubmitCreate(repo.id, data)}
onCancel={onCancelCreate}
/>
)}
@@ -428,15 +427,9 @@ function ProjectCard({
))}
diff --git a/apps/web/src/pages/workspaces.tsx b/apps/web/src/pages/workspaces.tsx
index 7a9f8cc..df2e261 100644
--- a/apps/web/src/pages/workspaces.tsx
+++ b/apps/web/src/pages/workspaces.tsx
@@ -1,34 +1,26 @@
-/** Workspaces list page. */
+/** Workspaces list page with direct creation. */
-import { useState } from "react";
+import { useState, useEffect, useCallback } from "react";
import { Icon } from "../components/icon";
import { useWorkspaces } from "../hooks/use-workspaces";
import { useWorkspaceActions } from "../hooks/use-workspace-actions";
import { WorkspaceCard } from "../components/workspace-card";
-import { WorkspaceCreateForm } from "../components/workspace-create-form";
import { StartToolModal } from "../components/start-tool-modal";
import { createInstance, startInstance } from "../api/sessions";
+import { listProjects } from "../api/projects";
+import { listRepositories } from "../api/git_repositories";
+import { createWorkspaceTopLevel } from "../api/workspaces";
import type { Workspace } from "../types/workspace";
+import type { ProjectWithRepos } from "../types";
+import type { GitRepository } from "../api/git_repositories";
export function WorkspacesPage() {
const [showCreate, setShowCreate] = useState(false);
const [startWorkspace, setStartWorkspace] = useState
(null);
- const [createTarget, setCreateTarget] = useState<{
- projectId: string;
- repoId: string;
- } | null>(null);
const { workspaces, loading, error, refresh } = useWorkspaces();
const actions = useWorkspaceActions();
- const handleCreate = async (data: { name: string; branch: string }) => {
- if (!createTarget) return;
- await actions.create(createTarget.projectId, createTarget.repoId, data);
- setShowCreate(false);
- setCreateTarget(null);
- await refresh();
- };
-
const handleDelete = async (workspace: Workspace) => {
await actions.delete(
workspace.project_id,
@@ -92,18 +84,7 @@ export function WorkspacesPage() {
{
- if (workspaces.length > 0) {
- const first = workspaces[0];
- setCreateTarget({
- projectId: first.project_id,
- repoId: first.repo_id,
- });
- setShowCreate(true);
- } else {
- alert("Navigate to a project to create your first workspace.");
- }
- }}
+ onClick={() => setShowCreate(true)}
>
New Workspace
@@ -112,15 +93,13 @@ export function WorkspacesPage() {
{error && {error}
}
- {showCreate && createTarget && (
- {
+ {showCreate && (
+ {
setShowCreate(false);
- setCreateTarget(null);
+ refresh();
}}
+ onCancel={() => setShowCreate(false)}
/>
)}
@@ -129,7 +108,12 @@ export function WorkspacesPage() {
) : workspaces.length === 0 ? (
No workspaces yet.
-
Navigate to a project to create your first workspace.
+
setShowCreate(true)}
+ >
+ Create your first workspace
+
) : (
@@ -156,3 +140,190 @@ export function WorkspacesPage() {
);
}
+
+/* ─── Inline Workspace Creation Form ─── */
+
+function WorkspaceCreateInline({
+ onCreated,
+ onCancel,
+}: {
+ onCreated: () => void;
+ onCancel: () => void;
+}) {
+ const [projects, setProjects] = useState([]);
+ const [repos, setRepos] = useState([]);
+ const [selectedProject, setSelectedProject] = useState("");
+ const [selectedRepo, setSelectedRepo] = useState("");
+ const [name, setName] = useState("");
+ const [branch, setBranch] = useState("main");
+ const [loading, setLoading] = useState(false);
+ const [fetching, setFetching] = useState(true);
+ const [error, setError] = useState(null);
+
+ const loadProjects = useCallback(async () => {
+ try {
+ const data = await listProjects();
+ setProjects(data);
+ if (data.length === 1) {
+ setSelectedProject(data[0].id);
+ }
+ } catch {
+ setError("Failed to load projects");
+ } finally {
+ setFetching(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void loadProjects();
+ }, [loadProjects]);
+
+ useEffect(() => {
+ if (!selectedProject) {
+ setRepos([]);
+ setSelectedRepo("");
+ return;
+ }
+ const loadRepos = async () => {
+ try {
+ const data = await listRepositories(selectedProject);
+ setRepos(data);
+ if (data.length === 1) {
+ setSelectedRepo(data[0].id);
+ }
+ } catch {
+ setError("Failed to load repositories");
+ }
+ };
+ void loadRepos();
+ }, [selectedProject]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!selectedRepo) {
+ setError("Please select a repository");
+ return;
+ }
+ if (!name.trim()) {
+ setError("Workspace name is required");
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ try {
+ await createWorkspaceTopLevel({
+ repo_id: selectedRepo,
+ name: name.trim(),
+ branch: branch.trim() || "main",
+ });
+ onCreated();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to create workspace");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (fetching) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ Create Workspace
+
+
+
+ );
+}
diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx
index dd393fa..dde0bfa 100644
--- a/apps/web/src/router.tsx
+++ b/apps/web/src/router.tsx
@@ -58,7 +58,10 @@ export const AppRouter = () => {
} />
} />
- } />
+ }
+ />
} />
Date: Mon, 1 Jun 2026 17:48:27 +0200
Subject: [PATCH 25/44] feat: workspace creation with branch dropdown and
auto-select
- Fetch branches from selected repo via listRepositoryBranches API
- Branch dropdown with default branch pre-selected
- '+ Create new branch...' option reveals text input for custom branch
- Auto-select first option when only one available:
- Project: auto-selects when only 1 project
- Repo: auto-selects when only 1 repo
- Branch: auto-selects when only 1 branch, otherwise defaults to remote default
- Falls back to free-text branch input if branch API fails
- TypeScript + eslint clean
---
apps/web/src/pages/workspaces.tsx | 104 +++++++++++++++++++++++++++---
1 file changed, 94 insertions(+), 10 deletions(-)
diff --git a/apps/web/src/pages/workspaces.tsx b/apps/web/src/pages/workspaces.tsx
index df2e261..84945e0 100644
--- a/apps/web/src/pages/workspaces.tsx
+++ b/apps/web/src/pages/workspaces.tsx
@@ -8,7 +8,7 @@ import { WorkspaceCard } from "../components/workspace-card";
import { StartToolModal } from "../components/start-tool-modal";
import { createInstance, startInstance } from "../api/sessions";
import { listProjects } from "../api/projects";
-import { listRepositories } from "../api/git_repositories";
+import { listRepositories, listRepositoryBranches } from "../api/git_repositories";
import { createWorkspaceTopLevel } from "../api/workspaces";
import type { Workspace } from "../types/workspace";
import type { ProjectWithRepos } from "../types";
@@ -152,10 +152,13 @@ function WorkspaceCreateInline({
}) {
const [projects, setProjects] = useState([]);
const [repos, setRepos] = useState([]);
+ const [branches, setBranches] = useState([]);
const [selectedProject, setSelectedProject] = useState("");
const [selectedRepo, setSelectedRepo] = useState("");
+ const [selectedBranch, setSelectedBranch] = useState("");
+ const [newBranchName, setNewBranchName] = useState("");
+ const [isNewBranch, setIsNewBranch] = useState(false);
const [name, setName] = useState("");
- const [branch, setBranch] = useState("main");
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(true);
const [error, setError] = useState(null);
@@ -198,6 +201,45 @@ function WorkspaceCreateInline({
void loadRepos();
}, [selectedProject]);
+ useEffect(() => {
+ if (!selectedProject || !selectedRepo) {
+ setBranches([]);
+ setSelectedBranch("");
+ setIsNewBranch(false);
+ return;
+ }
+ const loadBranches = async () => {
+ try {
+ const data = await listRepositoryBranches(selectedProject, selectedRepo);
+ const branchNames = data.branches.map((b) => b.name);
+ setBranches(branchNames);
+ if (branchNames.length === 1) {
+ setSelectedBranch(branchNames[0]);
+ setIsNewBranch(false);
+ } else if (data.default_branch) {
+ setSelectedBranch(data.default_branch);
+ setIsNewBranch(false);
+ }
+ } catch {
+ // If branch fetch fails, fall back to free-text
+ setBranches([]);
+ setIsNewBranch(true);
+ }
+ };
+ void loadBranches();
+ }, [selectedProject, selectedRepo]);
+
+ const handleBranchChange = (value: string) => {
+ if (value === "__new__") {
+ setIsNewBranch(true);
+ setSelectedBranch("__new__");
+ setNewBranchName("");
+ } else {
+ setIsNewBranch(false);
+ setSelectedBranch(value);
+ }
+ };
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedRepo) {
@@ -208,17 +250,24 @@ function WorkspaceCreateInline({
setError("Workspace name is required");
return;
}
+ const branchName = isNewBranch ? newBranchName.trim() : selectedBranch;
+ if (!branchName) {
+ setError("Please select or enter a branch");
+ return;
+ }
setLoading(true);
setError(null);
try {
await createWorkspaceTopLevel({
repo_id: selectedRepo,
name: name.trim(),
- branch: branch.trim() || "main",
+ branch: branchName,
});
onCreated();
} catch (err) {
- setError(err instanceof Error ? err.message : "Failed to create workspace");
+ setError(
+ err instanceof Error ? err.message : "Failed to create workspace",
+ );
} finally {
setLoading(false);
}
@@ -292,12 +341,47 @@ function WorkspaceCreateInline({
- setBranch(e.target.value)}
- placeholder="main"
- />
+ {branches.length > 0 ? (
+ <>
+
+ {isNewBranch && (
+ setNewBranchName(e.target.value)}
+ placeholder="new-branch-name"
+ required
+ style={{ marginTop: "0.5rem" }}
+ />
+ )}
+ >
+ ) : (
+ {
+ setIsNewBranch(true);
+ setNewBranchName(e.target.value);
+ setSelectedBranch("__new__");
+ }}
+ placeholder="main"
+ required
+ disabled={!selectedRepo}
+ />
+ )}
{error && (
From e956d7c30d4c268e3f51c851583d4bd341590e0d Mon Sep 17 00:00:00 2001
From: Alex Blank
Date: Mon, 1 Jun 2026 18:18:00 +0200
Subject: [PATCH 26/44] feat: unify workspace creation component with branch
dropdown
- Rewrite WorkspaceCreateForm as unified component used in both pages
- Standalone mode (WorkspacesPage): shows project/repo/branch selectors
- Contextual mode (ProjectsPage): accepts defaultProjectId/defaultRepoId,
skips project/repo selectors, shows only name + branch dropdown
- Branch dropdown fetched from repo via listRepositoryBranches API
- Auto-selects first/only option for project, repo, and branch
- '+ Create new branch...' option reveals text input for custom branch
- Falls back to free-text branch input if branch API fails
- Removes duplicated inline creation logic from WorkspacesPage
- TypeScript + eslint clean
---
.../src/components/workspace-create-form.tsx | 315 +++++++++++++++---
apps/web/src/pages/projects.tsx | 38 +--
apps/web/src/pages/workspaces.tsx | 287 +---------------
3 files changed, 281 insertions(+), 359 deletions(-)
diff --git a/apps/web/src/components/workspace-create-form.tsx b/apps/web/src/components/workspace-create-form.tsx
index c907f3f..f25b962 100644
--- a/apps/web/src/components/workspace-create-form.tsx
+++ b/apps/web/src/components/workspace-create-form.tsx
@@ -1,37 +1,152 @@
-/** Form for creating a new workspace. */
+/** Unified workspace creation form with project/repo/branch selectors. */
-import { useState } from "react";
+import { useState, useEffect, useCallback } from "react";
import { Icon } from "./icon";
-import type { CreateWorkspaceRequest } from "../types/workspace";
+import { listProjects } from "../api/projects";
+import { listRepositories, listRepositoryBranches } from "../api/git_repositories";
+import { createWorkspaceTopLevel } from "../api/workspaces";
+import type { ProjectWithRepos } from "../types";
+import type { GitRepository } from "../api/git_repositories";
export interface WorkspaceCreateFormProps {
- projectId: string;
- repoId: string;
- defaultBranch?: string;
- onSubmit: (data: CreateWorkspaceRequest) => Promise;
+ /** Called after successful creation. */
+ onSubmit: () => void | Promise;
+ /** Cancel callback. */
onCancel: () => void;
+ /** Optional: pre-selected project ID (hides project selector). */
+ defaultProjectId?: string;
+ /** Optional: pre-selected repo ID (hides repo selector). */
+ defaultRepoId?: string;
}
export function WorkspaceCreateForm({
- defaultBranch = "main",
onSubmit,
onCancel,
+ defaultProjectId,
+ defaultRepoId,
}: WorkspaceCreateFormProps) {
+ const isContextual = Boolean(defaultProjectId && defaultRepoId);
+
+ const [projects, setProjects] = useState([]);
+ const [repos, setRepos] = useState([]);
+ const [branches, setBranches] = useState([]);
+ const [selectedProject, setSelectedProject] = useState(defaultProjectId ?? "");
+ const [selectedRepo, setSelectedRepo] = useState(defaultRepoId ?? "");
+ const [selectedBranch, setSelectedBranch] = useState("");
+ const [newBranchName, setNewBranchName] = useState("");
+ const [isNewBranch, setIsNewBranch] = useState(false);
const [name, setName] = useState("");
- const [branch, setBranch] = useState(defaultBranch);
const [submitting, setSubmitting] = useState(false);
+ const [fetching, setFetching] = useState(!isContextual);
const [error, setError] = useState(null);
+ /* ── Load projects (standalone mode only) ── */
+ const loadProjects = useCallback(async () => {
+ if (isContextual) return;
+ try {
+ const data = await listProjects();
+ setProjects(data);
+ if (data.length === 1 && !defaultProjectId) {
+ setSelectedProject(data[0].id);
+ }
+ } catch {
+ setError("Failed to load projects");
+ } finally {
+ setFetching(false);
+ }
+ }, [isContextual, defaultProjectId]);
+
+ useEffect(() => {
+ void loadProjects();
+ }, [loadProjects]);
+
+ /* ── Load repos when project changes ── */
+ useEffect(() => {
+ if (!selectedProject) {
+ setRepos([]);
+ if (!defaultRepoId) setSelectedRepo("");
+ return;
+ }
+ const loadRepos = async () => {
+ try {
+ const data = await listRepositories(selectedProject);
+ setRepos(data);
+ if (data.length === 1 && !defaultRepoId) {
+ setSelectedRepo(data[0].id);
+ }
+ } catch {
+ setError("Failed to load repositories");
+ }
+ };
+ void loadRepos();
+ }, [selectedProject, defaultRepoId]);
+
+ /* ── Load branches when repo changes ── */
+ useEffect(() => {
+ if (!selectedProject || !selectedRepo) {
+ setBranches([]);
+ setSelectedBranch("");
+ setIsNewBranch(false);
+ return;
+ }
+ const loadBranches = async () => {
+ try {
+ const data = await listRepositoryBranches(selectedProject, selectedRepo);
+ const branchNames = data.branches.map((b) => b.name);
+ setBranches(branchNames);
+ if (branchNames.length >= 1) {
+ // Prefer default branch, else first branch
+ const preferred = data.default_branch && branchNames.includes(data.default_branch)
+ ? data.default_branch
+ : branchNames[0];
+ setSelectedBranch(preferred);
+ setIsNewBranch(false);
+ }
+ } catch {
+ // Fallback to free-text branch input
+ setBranches([]);
+ setIsNewBranch(true);
+ setSelectedBranch("__new__");
+ }
+ };
+ void loadBranches();
+ }, [selectedProject, selectedRepo]);
+
+ const handleBranchChange = (value: string) => {
+ if (value === "__new__") {
+ setIsNewBranch(true);
+ setSelectedBranch("__new__");
+ setNewBranchName("");
+ } else {
+ setIsNewBranch(false);
+ setSelectedBranch(value);
+ }
+ };
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
+ if (!selectedRepo) {
+ setError("Please select a repository");
+ return;
+ }
if (!name.trim()) {
setError("Workspace name is required");
return;
}
+ const branchName = isNewBranch ? newBranchName.trim() : selectedBranch;
+ if (!branchName) {
+ setError("Please select or enter a branch");
+ return;
+ }
setSubmitting(true);
setError(null);
try {
- await onSubmit({ name: name.trim(), branch: branch.trim() });
+ await createWorkspaceTopLevel({
+ repo_id: selectedRepo,
+ name: name.trim(),
+ branch: branchName,
+ });
+ await onSubmit();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to create workspace",
@@ -41,49 +156,149 @@ export function WorkspaceCreateForm({
}
};
+ if (fetching) {
+ return (
+
+ );
+ }
+
return (
-
@@ -299,7 +284,7 @@ function ProjectCard({
workspaceLoading,
showCreateForm,
onCancelCreate,
- onSubmitCreate,
+ onCreated,
}: {
project: ProjectWithRepos;
expanded: boolean;
@@ -318,10 +303,7 @@ function ProjectCard({
workspaceLoading: string | null;
onCancelCreate: () => void;
showCreateForm: string | null;
- onSubmitCreate: (
- repoId: string,
- data: { name: string; branch: string },
- ) => Promise;
+ onCreated: () => void;
}) {
return (
@@ -399,9 +381,9 @@ function ProjectCard({
{showCreateForm === repo.id && (
onSubmitCreate(repo.id, data)}
+ defaultProjectId={project.id}
+ defaultRepoId={repo.id}
+ onSubmit={onCreated}
onCancel={onCancelCreate}
/>
)}
diff --git a/apps/web/src/pages/workspaces.tsx b/apps/web/src/pages/workspaces.tsx
index 84945e0..9802f17 100644
--- a/apps/web/src/pages/workspaces.tsx
+++ b/apps/web/src/pages/workspaces.tsx
@@ -1,18 +1,14 @@
-/** Workspaces list page with direct creation. */
+/** Workspaces list page. */
-import { useState, useEffect, useCallback } from "react";
+import { useState } from "react";
import { Icon } from "../components/icon";
import { useWorkspaces } from "../hooks/use-workspaces";
import { useWorkspaceActions } from "../hooks/use-workspace-actions";
import { WorkspaceCard } from "../components/workspace-card";
+import { WorkspaceCreateForm } from "../components/workspace-create-form";
import { StartToolModal } from "../components/start-tool-modal";
import { createInstance, startInstance } from "../api/sessions";
-import { listProjects } from "../api/projects";
-import { listRepositories, listRepositoryBranches } from "../api/git_repositories";
-import { createWorkspaceTopLevel } from "../api/workspaces";
import type { Workspace } from "../types/workspace";
-import type { ProjectWithRepos } from "../types";
-import type { GitRepository } from "../api/git_repositories";
export function WorkspacesPage() {
const [showCreate, setShowCreate] = useState(false);
@@ -94,10 +90,10 @@ export function WorkspacesPage() {
{error && {error}
}
{showCreate && (
- {
+ {
setShowCreate(false);
- refresh();
+ await refresh();
}}
onCancel={() => setShowCreate(false)}
/>
@@ -140,274 +136,3 @@ export function WorkspacesPage() {
);
}
-
-/* ─── Inline Workspace Creation Form ─── */
-
-function WorkspaceCreateInline({
- onCreated,
- onCancel,
-}: {
- onCreated: () => void;
- onCancel: () => void;
-}) {
- const [projects, setProjects] = useState([]);
- const [repos, setRepos] = useState([]);
- const [branches, setBranches] = useState([]);
- const [selectedProject, setSelectedProject] = useState("");
- const [selectedRepo, setSelectedRepo] = useState("");
- const [selectedBranch, setSelectedBranch] = useState("");
- const [newBranchName, setNewBranchName] = useState("");
- const [isNewBranch, setIsNewBranch] = useState(false);
- const [name, setName] = useState("");
- const [loading, setLoading] = useState(false);
- const [fetching, setFetching] = useState(true);
- const [error, setError] = useState(null);
-
- const loadProjects = useCallback(async () => {
- try {
- const data = await listProjects();
- setProjects(data);
- if (data.length === 1) {
- setSelectedProject(data[0].id);
- }
- } catch {
- setError("Failed to load projects");
- } finally {
- setFetching(false);
- }
- }, []);
-
- useEffect(() => {
- void loadProjects();
- }, [loadProjects]);
-
- useEffect(() => {
- if (!selectedProject) {
- setRepos([]);
- setSelectedRepo("");
- return;
- }
- const loadRepos = async () => {
- try {
- const data = await listRepositories(selectedProject);
- setRepos(data);
- if (data.length === 1) {
- setSelectedRepo(data[0].id);
- }
- } catch {
- setError("Failed to load repositories");
- }
- };
- void loadRepos();
- }, [selectedProject]);
-
- useEffect(() => {
- if (!selectedProject || !selectedRepo) {
- setBranches([]);
- setSelectedBranch("");
- setIsNewBranch(false);
- return;
- }
- const loadBranches = async () => {
- try {
- const data = await listRepositoryBranches(selectedProject, selectedRepo);
- const branchNames = data.branches.map((b) => b.name);
- setBranches(branchNames);
- if (branchNames.length === 1) {
- setSelectedBranch(branchNames[0]);
- setIsNewBranch(false);
- } else if (data.default_branch) {
- setSelectedBranch(data.default_branch);
- setIsNewBranch(false);
- }
- } catch {
- // If branch fetch fails, fall back to free-text
- setBranches([]);
- setIsNewBranch(true);
- }
- };
- void loadBranches();
- }, [selectedProject, selectedRepo]);
-
- const handleBranchChange = (value: string) => {
- if (value === "__new__") {
- setIsNewBranch(true);
- setSelectedBranch("__new__");
- setNewBranchName("");
- } else {
- setIsNewBranch(false);
- setSelectedBranch(value);
- }
- };
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- if (!selectedRepo) {
- setError("Please select a repository");
- return;
- }
- if (!name.trim()) {
- setError("Workspace name is required");
- return;
- }
- const branchName = isNewBranch ? newBranchName.trim() : selectedBranch;
- if (!branchName) {
- setError("Please select or enter a branch");
- return;
- }
- setLoading(true);
- setError(null);
- try {
- await createWorkspaceTopLevel({
- repo_id: selectedRepo,
- name: name.trim(),
- branch: branchName,
- });
- onCreated();
- } catch (err) {
- setError(
- err instanceof Error ? err.message : "Failed to create workspace",
- );
- } finally {
- setLoading(false);
- }
- };
-
- if (fetching) {
- return (
-
- );
- }
-
- return (
-
-
- Create Workspace
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- setName(e.target.value)}
- placeholder="e.g., feature-branch"
- required
- />
-
-
-
-
- {branches.length > 0 ? (
- <>
-
- {isNewBranch && (
- setNewBranchName(e.target.value)}
- placeholder="new-branch-name"
- required
- style={{ marginTop: "0.5rem" }}
- />
- )}
- >
- ) : (
- {
- setIsNewBranch(true);
- setNewBranchName(e.target.value);
- setSelectedBranch("__new__");
- }}
- placeholder="main"
- required
- disabled={!selectedRepo}
- />
- )}
-
-
- {error && (
-
- {error}
-
- )}
-
-
-
- Cancel
-
-
- {loading ? "Creating..." : "Create Workspace"}
-
-
-
-
- );
-}
From b02cd978c302469358cc9cd4cb90634c9e6fc9f8 Mon Sep 17 00:00:00 2001
From: Alex Blank
Date: Mon, 1 Jun 2026 18:45:53 +0200
Subject: [PATCH 27/44] feat: unified git repo hook + fix branch dropdown in
workspace creation
- New useGitRepo hook: centralizes all git operations (branches, status,
history, commit, push, pull, fetch, checkout, create/delete branch, merge)
for a given project+repo. Auto-refreshes after mutating ops.
- Fix WorkspaceCreateForm branch dropdown:
- Always renders
) : !selectedWorkspace ? (
-
+
{
- const ws = workspaces.find(
- (w) => w.id === e.target.value,
- );
+ const ws = workspaces.find((w) => w.id === e.target.value);
if (ws) setSelectedWorkspace(ws);
}}
>
@@ -97,8 +93,7 @@ export function StartToolFAB() {
{selectedWorkspace.project_name} /{" "}
- {selectedWorkspace.repo_name} /{" "}
- {selectedWorkspace.name}
+ {selectedWorkspace.repo_name} / {selectedWorkspace.name}
(
const container = terminalRef.current;
// Define fitTerminal before connectWebSocket so it's available in onmessage
+ let lastSentCols = 0;
+ let lastSentRows = 0;
const fitTerminal = () => {
if (!fitAddonRef.current || !termRef.current) return;
try {
@@ -294,23 +296,35 @@ export const TerminalComponent = React.forwardRef(
return;
}
const { cols, rows } = termRef.current;
- // Force refresh if dimensions are valid
- if (cols > 0 && rows > 0) {
- try {
- termRef.current.refresh(0, rows - 1);
- } catch {
- // Ignore refresh errors
+ // Only send resize when dimensions actually changed
+ if (
+ cols > 0 &&
+ rows > 0 &&
+ (cols !== lastSentCols || rows !== lastSentRows)
+ ) {
+ lastSentCols = cols;
+ lastSentRows = rows;
+ const currentWs = wsRef.current;
+ if (currentWs?.readyState === WebSocket.OPEN) {
+ currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
}
}
- const currentWs = wsRef.current;
- if (currentWs?.readyState === WebSocket.OPEN && cols > 0 && rows > 0) {
- currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
- }
};
// Open xterm first (must happen before fit)
term.open(container);
term.focus();
+
+ // Allow ESC to propagate to browser when not in alternate buffer (vim/tmux)
+ term.attachCustomKeyEventHandler((e) => {
+ if (e.key === "Escape") {
+ const isAlternate =
+ term.buffer.active.type === "alternate";
+ return isAlternate; // true = xterm handles it, false = browser handles it
+ }
+ return true;
+ });
+
const ws = connectWebSocket();
// Mobile touch scroll.
diff --git a/apps/web/src/pages/dashboard.tsx b/apps/web/src/pages/dashboard.tsx
index aafee58..35305e7 100644
--- a/apps/web/src/pages/dashboard.tsx
+++ b/apps/web/src/pages/dashboard.tsx
@@ -8,10 +8,7 @@ import {
type Session as SessionApi,
type InstanceHealth,
} from "../api/sessions";
-import {
- ErrorState,
- LoadingState,
-} from "../components/data-states";
+import { ErrorState, LoadingState } from "../components/data-states";
import { SessionList } from "../components/session-list";
import { useInstanceActions } from "../hooks/use-instance-actions";
diff --git a/apps/web/src/pages/sessions.tsx b/apps/web/src/pages/sessions.tsx
index 2442249..bca9805 100644
--- a/apps/web/src/pages/sessions.tsx
+++ b/apps/web/src/pages/sessions.tsx
@@ -163,8 +163,8 @@ export const SessionsPage = () => {
Uncommitted Changes
The repository{" "}
- {dirtyDeleteSession.repository_name}{" "}
- has uncommitted changes. Deleting this session will permanently
+ {dirtyDeleteSession.repository_name} has
+ uncommitted changes. Deleting this session will permanently
lose these changes.
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 1f9171f..a41a2b7 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -5633,7 +5633,9 @@ a:active,
display: flex;
align-items: center;
justify-content: center;
- transition: transform 0.15s ease, box-shadow 0.15s ease;
+ transition:
+ transform 0.15s ease,
+ box-shadow 0.15s ease;
}
.start-tool-fab:hover {
diff --git a/docker-compose.yml b/docker-compose.yml
index 62f963d..dd813ba 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -63,6 +63,7 @@ services:
volumes:
- /data/repos:/data/repos
- /data/instances:/data/instances
+ - /data/working-copies:/data/working-copies
ports:
- "8000:8000"
depends_on:
From 8837031fd24d3128bab9acee17f4a738ba9d72f8 Mon Sep 17 00:00:00 2001
From: Alex Blank
Date: Mon, 1 Jun 2026 23:16:06 +0200
Subject: [PATCH 41/44] fix: ESC key, .config, workspace permissions, terminal
race condition
---
apps/api/src/services/manifest_compiler.py | 22 ++++++-
apps/api/src/services/terminal_manager.py | 13 +++--
apps/api/src/services/workspace_manager.py | 67 ++++++++++------------
apps/web/src/components/terminal.tsx | 19 +-----
4 files changed, 61 insertions(+), 60 deletions(-)
diff --git a/apps/api/src/services/manifest_compiler.py b/apps/api/src/services/manifest_compiler.py
index a971268..04f924e 100644
--- a/apps/api/src/services/manifest_compiler.py
+++ b/apps/api/src/services/manifest_compiler.py
@@ -118,6 +118,11 @@ def compile_dockerfile(manifest: dict) -> str:
# System packages (apt)
apt_packages = manifest.get("packages", {}).get("apt", [])
+ if manifest.get("user"):
+ # Ensure sudo is available for permission-fixing startup scripts
+ apt_packages = list(apt_packages)
+ if "sudo" not in apt_packages:
+ apt_packages.append("sudo")
if apt_packages:
lines.append("RUN apt-get update && apt-get install -y \\")
for pkg in apt_packages[:-1]:
@@ -168,10 +173,18 @@ def compile_dockerfile(manifest: dict) -> str:
lines.append(f"ENV USER={name}")
lines.append("")
# Ensure home directory exists and is writable by the user
- lines.append(f"RUN mkdir -p {home} && chown {name}:{name} {home} && chmod 755 {home}")
+ lines.append(
+ f"RUN mkdir -p {home} && chown {name}:{name} {home} && chmod 755 {home}"
+ )
lines.append("")
- # Build scripts
+ # Configure passwordless sudo so startup scripts can fix permissions
+ lines.append(
+ f'RUN echo "{name} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/{name} && chmod 0440 /etc/sudoers.d/{name}'
+ )
+ lines.append("")
+
+ # Build scripts
build_scripts = manifest.get("scripts", {}).get("build", [])
for script in build_scripts:
# Normalize multi-line scripts into single RUN command
@@ -184,6 +197,11 @@ def compile_dockerfile(manifest: dict) -> str:
if build_scripts:
lines.append("")
+ # After build scripts, ensure everything in home is owned by the user
+ if user and build_scripts:
+ lines.append(f"RUN chown -R {name}:{name} {home}")
+ lines.append("")
+
# Create mount target directories
mounts = manifest.get("mounts", [])
if mounts:
diff --git a/apps/api/src/services/terminal_manager.py b/apps/api/src/services/terminal_manager.py
index dfe7ff5..c767161 100644
--- a/apps/api/src/services/terminal_manager.py
+++ b/apps/api/src/services/terminal_manager.py
@@ -6,6 +6,7 @@ import uuid
from datetime import datetime, timezone
from fastapi import WebSocket
+from sqlalchemy.dialects.postgresql import insert as pg_insert
from src.database import SessionLocal
from src.models.terminal_session import TerminalSessionModel
@@ -83,18 +84,22 @@ class TerminalManager:
instance_id: uuid.UUID,
name: str,
) -> None:
- """Insert a TerminalSessionModel row into the database."""
+ """Insert a TerminalSessionModel row into the database.
+
+ Uses ON CONFLICT DO NOTHING to handle races when a session is
+ restored from DB and then re-inserted.
+ """
try:
async with SessionLocal() as db_session:
- db_row = TerminalSessionModel(
+ stmt = pg_insert(TerminalSessionModel).values(
id=uuid.UUID(session_id),
instance_id=instance_id,
name=name,
status="active",
created_at=datetime.now(timezone.utc),
last_activity_at=datetime.now(timezone.utc),
- )
- db_session.add(db_row)
+ ).on_conflict_do_nothing(index_elements=["id"])
+ await db_session.execute(stmt)
await db_session.commit()
logger.debug(
"Inserted terminal session row %s for instance %s",
diff --git a/apps/api/src/services/workspace_manager.py b/apps/api/src/services/workspace_manager.py
index ba1fb37..f6ee94b 100644
--- a/apps/api/src/services/workspace_manager.py
+++ b/apps/api/src/services/workspace_manager.py
@@ -2,9 +2,11 @@
from __future__ import annotations
+import contextlib
import logging
import os
import shutil
+import stat
import uuid
from dataclasses import dataclass
from datetime import datetime
@@ -76,10 +78,8 @@ class WorkspaceManager:
parent = os.path.dirname(path)
os.makedirs(parent, exist_ok=True)
# Ensure container users (various UIDs) can write to workspace dirs
- try:
+ with contextlib.suppress(OSError):
os.chmod(parent, 0o777)
- except OSError:
- pass
logger.info(
"Creating workspace: name=%s, repo=%s, branch=%s", name, repo.id, branch
@@ -104,23 +104,7 @@ class WorkspaceManager:
).decode()
await GitService.clone(repo.remote_url, branch, path, ssh_key=ssh_key)
-
- # Make workspace writable for any container user
- try:
- os.chmod(path, 0o777)
- for root, dirs, files in os.walk(path):
- for d in dirs:
- try:
- os.chmod(os.path.join(root, d), 0o777)
- except OSError:
- pass
- for f in files:
- try:
- os.chmod(os.path.join(root, f), 0o666)
- except OSError:
- pass
- except OSError:
- logger.warning("Failed to chmod workspace path: %s", path)
+ self._make_world_writable(path)
workspace = Workspace(
name=name,
@@ -213,28 +197,37 @@ class WorkspaceManager:
return SyncResult(branch_deleted=True)
await GitService.pull(workspace.path, workspace.branch, ssh_key=ssh_key)
-
- # Re-apply permissive permissions after sync
- try:
- os.chmod(workspace.path, 0o777)
- for root, dirs, files in os.walk(workspace.path):
- for d in dirs:
- try:
- os.chmod(os.path.join(root, d), 0o777)
- except OSError:
- pass
- for f in files:
- try:
- os.chmod(os.path.join(root, f), 0o666)
- except OSError:
- pass
- except OSError:
- logger.warning("Failed to chmod workspace after sync: %s", workspace.path)
+ self._make_world_writable(workspace.path)
workspace.last_sync_at = datetime.now()
logger.info("Workspace synced: %s", workspace.id)
return SyncResult(branch_deleted=False)
+ def _make_world_writable(self, path: str) -> None:
+ """Recursively make path readable/writable/traversable by any UID.
+
+ Directories get 777 (traversable). Files get rw for all while
+ preserving any existing execute bits.
+ """
+ with contextlib.suppress(OSError):
+ os.chmod(path, 0o777)
+ for root, dirs, files in os.walk(path):
+ for d in dirs:
+ dpath = os.path.join(root, d)
+ with contextlib.suppress(OSError):
+ os.chmod(dpath, 0o777)
+ for f in files:
+ fpath = os.path.join(root, f)
+ with contextlib.suppress(OSError):
+ mode = os.stat(fpath).st_mode
+ # Preserve execute bits, ensure read+write for all
+ new_mode = (mode & stat.S_IXUSR) | 0o666
+ if mode & stat.S_IXGRP:
+ new_mode |= stat.S_IXGRP
+ if mode & stat.S_IXOTH:
+ new_mode |= stat.S_IXOTH
+ os.chmod(fpath, new_mode)
+
async def _get_instances(
self,
workspace: Workspace,
diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx
index 06a3d47..5b963fe 100644
--- a/apps/web/src/components/terminal.tsx
+++ b/apps/web/src/components/terminal.tsx
@@ -314,17 +314,6 @@ export const TerminalComponent = React.forwardRef(
// Open xterm first (must happen before fit)
term.open(container);
term.focus();
-
- // Allow ESC to propagate to browser when not in alternate buffer (vim/tmux)
- term.attachCustomKeyEventHandler((e) => {
- if (e.key === "Escape") {
- const isAlternate =
- term.buffer.active.type === "alternate";
- return isAlternate; // true = xterm handles it, false = browser handles it
- }
- return true;
- });
-
const ws = connectWebSocket();
// Mobile touch scroll.
@@ -366,16 +355,12 @@ export const TerminalComponent = React.forwardRef(
// If the viewport is scrollable, scroll it directly.
// Otherwise we are in alternate screen (tmux/vim) and must
// send SGR 1006 mouse-wheel protocol data.
- const hasScrollback =
- viewport.scrollHeight > viewport.clientHeight;
+ const hasScrollback = viewport.scrollHeight > viewport.clientHeight;
if (hasScrollback) {
viewport.scrollTop += deltaY;
} else {
const ws = wsRef.current;
- if (
- ws?.readyState === WebSocket.OPEN &&
- termRef.current
- ) {
+ if (ws?.readyState === WebSocket.OPEN && termRef.current) {
// Use the cursor position as the wheel location so
// tmux knows which pane to scroll.
const buf = termRef.current.buffer.active;
From 56dd7d3fd340ee0910d7800f458926f28c8b5984 Mon Sep 17 00:00:00 2001
From: Alex Blank
Date: Mon, 1 Jun 2026 23:29:28 +0200
Subject: [PATCH 42/44] fix: tool start hanging + SSE 429 errors
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
1. Remove Docker build from create_instance for manifest types — the build
was blocking the HTTP request for several minutes, causing frontend
timeouts and retries. Image is now built lazily on start (via the
existing _prepare_manifest_instance path in start_instance).
2. Increase MAX_CONNECTIONS_PER_USER from 5 to 20 for SSE endpoint —
aggressive reconnect loops from the frontend were exhausting the limit
and causing 429 errors unrelated to tool starting.
Quality gates: ruff clean, tsc --noEmit clean, pytest workspaces (9 passed)
---
apps/api/src/api/events.py | 2 +-
apps/api/src/api/tool_instances.py | 38 +----------------------
apps/api/src/services/terminal_manager.py | 20 +++++++-----
3 files changed, 14 insertions(+), 46 deletions(-)
diff --git a/apps/api/src/api/events.py b/apps/api/src/api/events.py
index cebc8e5..3b33859 100644
--- a/apps/api/src/api/events.py
+++ b/apps/api/src/api/events.py
@@ -16,7 +16,7 @@ router = APIRouter(prefix="/events", tags=["events"])
# In-memory connection counter per user (single-process assumption)
_connection_counts: dict[uuid.UUID, int] = {}
-MAX_CONNECTIONS_PER_USER = 5
+MAX_CONNECTIONS_PER_USER = 20
@router.get("/stream")
diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py
index b86dca4..490be03 100644
--- a/apps/api/src/api/tool_instances.py
+++ b/apps/api/src/api/tool_instances.py
@@ -1039,7 +1039,7 @@ services:
write_compose_file(instance_dir, compose_content)
elif tool_type.definition_type == "manifest":
- # Manifest-based: build image and generate compose
+ # Manifest-based: generate compose only; image built lazily on start
from src.models.tool_definition_manifest import ToolDefinitionManifest
manifest_def = await session.get(
@@ -1061,44 +1061,8 @@ services:
deep_merge(dict(base_def.manifest), manifest)
)
- # Determine home directory for path expansion
- _home_dir = get_manifest_home_dir(manifest)
-
image_tag = compute_image_tag(tool_type.name, manifest)
- # Build image during creation so start is fast
- dockerfile = compile_dockerfile(manifest)
- entrypoint = compile_entrypoint(manifest)
- build_ctx = {
- "Dockerfile": dockerfile,
- ".headquarter/entrypoint.sh": entrypoint,
- }
-
- returncode, stdout, stderr = await asyncio.to_thread(
- build_image,
- instance_dir=instance_dir,
- dockerfile=dockerfile,
- tag=image_tag,
- build_context=build_ctx,
- )
-
- if returncode != 0:
- logger.error(
- "Failed to build image for manifest instance %s: %s",
- instance_name,
- stderr,
- )
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Failed to build Docker image: {stderr[:500]}",
- )
-
- logger.info(
- "Built manifest image %s for instance %s",
- image_tag,
- instance_name,
- )
-
variables = {
"IMAGE_TAG": image_tag,
"INSTANCE_NAME": instance_name.lower(),
diff --git a/apps/api/src/services/terminal_manager.py b/apps/api/src/services/terminal_manager.py
index c767161..b9605db 100644
--- a/apps/api/src/services/terminal_manager.py
+++ b/apps/api/src/services/terminal_manager.py
@@ -91,14 +91,18 @@ class TerminalManager:
"""
try:
async with SessionLocal() as db_session:
- stmt = pg_insert(TerminalSessionModel).values(
- id=uuid.UUID(session_id),
- instance_id=instance_id,
- name=name,
- status="active",
- created_at=datetime.now(timezone.utc),
- last_activity_at=datetime.now(timezone.utc),
- ).on_conflict_do_nothing(index_elements=["id"])
+ stmt = (
+ pg_insert(TerminalSessionModel)
+ .values(
+ id=uuid.UUID(session_id),
+ instance_id=instance_id,
+ name=name,
+ status="active",
+ created_at=datetime.now(timezone.utc),
+ last_activity_at=datetime.now(timezone.utc),
+ )
+ .on_conflict_do_nothing(index_elements=["id"])
+ )
await db_session.execute(stmt)
await db_session.commit()
logger.debug(
From 1bf42a7febc2a21c87f010b51f0d96fe663c5740 Mon Sep 17 00:00:00 2001
From: Alex Blank
Date: Mon, 1 Jun 2026 23:52:18 +0200
Subject: [PATCH 43/44] fix: workspace delete MissingGreenlet + nginx
cache-busting
- Convert WorkspaceHasInstancesError to store plain dicts instead of
SQLAlchemy ORM objects, preventing lazy-load failures outside async
session context (MissingGreenlet)
- Update both delete endpoints (top-level and nested) to use exc.instances
directly since they're already plain dicts
- Add no-cache headers for index.html in nginx.conf so browsers always
fetch new hashed JS/CSS bundles on deploy
---
apps/api/src/api/workspaces.py | 4 ++--
apps/api/src/services/workspace_manager.py | 6 ++++--
apps/web/nginx.conf | 6 ++++++
3 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/apps/api/src/api/workspaces.py b/apps/api/src/api/workspaces.py
index 0614475..a5526a8 100644
--- a/apps/api/src/api/workspaces.py
+++ b/apps/api/src/api/workspaces.py
@@ -91,7 +91,7 @@ async def delete_workspace_top_level(
status_code=409,
detail={
"message": "Workspace has running tool instances",
- "instances": [{"id": str(i.id), "name": i.name} for i in exc.instances],
+ "instances": exc.instances,
},
) from exc
except Exception as exc:
@@ -358,7 +358,7 @@ async def delete_workspace(
status_code=409,
detail={
"message": "Workspace has running tool instances",
- "instances": [{"id": str(i.id), "name": i.name} for i in exc.instances],
+ "instances": exc.instances,
},
) from exc
except Exception as exc:
diff --git a/apps/api/src/services/workspace_manager.py b/apps/api/src/services/workspace_manager.py
index f6ee94b..a9c5532 100644
--- a/apps/api/src/services/workspace_manager.py
+++ b/apps/api/src/services/workspace_manager.py
@@ -37,7 +37,7 @@ class SyncResult:
class WorkspaceHasInstancesError(Exception):
"""Raised when attempting to delete a workspace with running instances."""
- def __init__(self, instances: list[ToolInstance]) -> None:
+ def __init__(self, instances: list[dict]) -> None:
self.instances = instances
super().__init__(f"Workspace has {len(instances)} running tool instance(s)")
@@ -139,7 +139,9 @@ class WorkspaceManager:
instances = await self._get_instances(workspace, session)
if instances and not force:
- raise WorkspaceHasInstancesError(instances)
+ raise WorkspaceHasInstancesError(
+ [{"id": str(i.id), "name": i.name} for i in instances]
+ )
# Stop and delete all instances
for instance in instances:
diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf
index 9f6c007..cfc213f 100644
--- a/apps/web/nginx.conf
+++ b/apps/web/nginx.conf
@@ -15,6 +15,12 @@ server {
try_files $uri $uri/ /index.html;
}
+ # Never cache index.html so browsers always fetch new hashed JS/CSS
+ location = /index.html {
+ add_header Cache-Control "no-cache, no-store, must-revalidate";
+ add_header Pragma "no-cache";
+ }
+
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
From 04cd9ff4725c21b4777b46b20273f738e81baaaa Mon Sep 17 00:00:00 2001
From: Alex Blank
Date: Tue, 2 Jun 2026 00:00:33 +0200
Subject: [PATCH 44/44] chore: add diagnostic logging for manifest mount
resolution
- Log REPO_PATH, SSH_PATH, EXTRA_VOLUMES, manifest mounts, and resolved
volumes in compile_compose() to trace why mounts may be missing
- Log repo_path and generated compose content in _prepare_manifest_instance()
to verify the full compose YAML at start time
---
apps/api/src/api/tool_instances.py | 12 ++++++++++++
apps/api/src/services/manifest_compiler.py | 16 +++++++++++++++-
2 files changed, 27 insertions(+), 1 deletion(-)
diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py
index 490be03..e9d68d4 100644
--- a/apps/api/src/api/tool_instances.py
+++ b/apps/api/src/api/tool_instances.py
@@ -1430,6 +1430,18 @@ async def _prepare_manifest_instance(
compose_content = compile_compose(manifest, variables)
+ logger.debug(
+ "_prepare_manifest_instance for %s: repo_path=%s compose_volumes=%s",
+ instance.id,
+ repo_path or "",
+ manifest.get("mounts", []),
+ )
+ logger.debug(
+ "Generated compose for %s:\n%s",
+ instance.id,
+ compose_content,
+ )
+
# Cache
instance.image_tag = image_tag
instance.manifest_compiled_at = datetime.now()
diff --git a/apps/api/src/services/manifest_compiler.py b/apps/api/src/services/manifest_compiler.py
index 04f924e..6538d55 100644
--- a/apps/api/src/services/manifest_compiler.py
+++ b/apps/api/src/services/manifest_compiler.py
@@ -329,7 +329,21 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
service["volumes"] = sort_volumes_by_specificity(volumes)
compose = {"services": {"app": service}}
- return yaml.dump(compose, default_flow_style=False)
+ result = yaml.dump(compose, default_flow_style=False)
+
+ # Debug: log mount resolution so we can diagnose missing mounts
+ import logging
+ logger = logging.getLogger(__name__)
+ logger.debug(
+ "compile_compose: REPO_PATH=%s SSH_PATH=%s EXTRA_VOLUMES=%s mounts=%s volumes=%s",
+ variables.get("REPO_PATH", ""),
+ variables.get("SSH_PATH", ""),
+ variables.get("EXTRA_VOLUMES", []),
+ manifest.get("mounts", []),
+ volumes,
+ )
+
+ return result
def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str: