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
This commit is contained in:
2026-06-01 16:47:09 +02:00
parent 59b125d8e2
commit e7587ca9f5
14 changed files with 2347 additions and 32 deletions
@@ -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<Tab>("files");
const { workspace, loading } = useWorkspace(workspaceId);
if (loading) return <LoadingState />;
if (!workspace) return <NotFoundPage />;
return (
<div className="workspace-detail">
<WorkspaceHeader workspace={workspace} />
<WorkspaceTabs active={activeTab} onChange={setActiveTab} />
<div className="workspace-content">
{activeTab === "files" && <WorkspaceFilePanel workspace={workspace} />}
{activeTab === "git" && <WorkspaceGitPanel workspace={workspace} />}
{activeTab === "tools" && <WorkspaceToolsPanel workspace={workspace} />}
{activeTab === "settings" && <WorkspaceSettingsPanel workspace={workspace} />}
</div>
</div>
);
}
```
### Component: WorkspaceFilePanel
```tsx
export function WorkspaceFilePanel({ workspace }: { workspace: Workspace }) {
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const { entries, loading } = useWorkspaceFiles(workspace.id);
const { content } = useWorkspaceFileContent(workspace.id, selectedPath);
const { status } = useWorkspaceGitStatus(workspace.id);
return (
<div className="file-panel">
<GitToolbar workspace={workspace} status={status} />
<div className="file-panel-body">
<FileTree
entries={entries}
selectedPath={selectedPath}
onSelect={setSelectedPath}
gitStatus={status}
/>
<FileViewer
path={selectedPath}
content={content}
isEditing={isEditing}
onEdit={() => setIsEditing(true)}
onSave={async (newContent, message) => {
await saveWorkspaceFile(workspace.id, selectedPath, newContent, message);
setIsEditing(false);
}}
/>
</div>
</div>
);
}
```
### Component: GitToolbar
```tsx
export function GitToolbar({ workspace, status }: GitToolbarProps) {
const [expanded, setExpanded] = useState(false);
const [commitMessage, setCommitMessage] = useState("");
return (
<div className={`git-toolbar ${expanded ? "expanded" : ""}`}>
<div className="git-toolbar-summary">
<span className="git-status modified">M {status.modified.length}</span>
<span className="git-status added">A {status.added.length}</span>
<span className="git-status deleted">D {status.deleted.length}</span>
<button onClick={() => setExpanded(!expanded)}>Commit </button>
<button onClick={() => pushWorkspace(workspace.id)}>Push</button>
<button onClick={() => pullWorkspace(workspace.id)}>Pull</button>
<button onClick={() => fetchWorkspace(workspace.id)}>Fetch</button>
</div>
{expanded && (
<div className="git-toolbar-commit">
<textarea
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
placeholder="Commit message"
/>
<button
onClick={() => {
commitWorkspace(workspace.id, commitMessage);
setCommitMessage("");
setExpanded(false);
}}
>
Commit
</button>
</div>
)}
</div>
);
}
```
### Component: WorkspaceGitPanel
```tsx
export function WorkspaceGitPanel({ workspace }: { workspace: Workspace }) {
const { history, loading } = useWorkspaceGitHistory(workspace.id);
const [selectedCommit, setSelectedCommit] = useState<Commit | null>(null);
return (
<div className="git-panel">
<div className="git-panel-header">
<BranchSelector workspace={workspace} />
<button>New Branch</button>
</div>
<div className="git-panel-body">
<CommitHistory
commits={history}
selected={selectedCommit}
onSelect={setSelectedCommit}
/>
{selectedCommit && (
<CommitDetail commit={selectedCommit} workspace={workspace} />
)}
</div>
</div>
);
}
```
### Component: WorkspaceToolsPanel
```tsx
export function WorkspaceToolsPanel({ workspace }: { workspace: Workspace }) {
const { instances, loading, refresh } = useWorkspaceInstances(workspace.id);
const [showModal, setShowModal] = useState(false);
return (
<div className="tools-panel">
{instances.length === 0 ? (
<EmptyState
icon="terminal"
title="No tools running"
description="Start a tool to begin coding in this workspace"
action={{ label: "Start Tool", onClick: () => setShowModal(true) }}
/>
) : (
<>
<div className="tools-grid">
{instances.map((instance) => (
<InstanceCard
key={instance.id}
instance={instance}
onStop={refresh}
onStart={refresh}
/>
))}
</div>
<button onClick={() => setShowModal(true)}>Start Another Tool</button>
</>
)}
{showModal && (
<StartToolModal
workspace={workspace}
onClose={() => setShowModal(false)}
onStart={async (toolTypeId, configProfileId) => {
await createWorkspaceInstance(workspace.id, toolTypeId, configProfileId);
setShowModal(false);
refresh();
}}
/>
)}
</div>
);
}
```
### Component: ProjectCard (refreshed)
```tsx
export function ProjectCard({ project }: { project: EnrichedProject }) {
return (
<article className="card project-card">
<div className="project-header">
<h3>{project.name}</h3>
{project.description && <p className="muted">{project.description}</p>}
</div>
<div className="project-repos">
{project.repositories.map((repo) => (
<RepoSection key={repo.id} repo={repo} projectId={project.id} />
))}
</div>
<div className="project-actions">
<Link to={`/projects/${project.id}`}>Open</Link>
<button>Edit</button>
<button>Delete</button>
</div>
</article>
);
}
```
### Component: RepoSection
```tsx
export function RepoSection({ repo, projectId }: RepoSectionProps) {
const [expanded, setExpanded] = useState(true);
const [showForm, setShowForm] = useState(false);
return (
<div className="repo-section">
<button className="repo-header" onClick={() => setExpanded(!expanded)}>
<Icon name={expanded ? "arrow-down" : "arrow-right"} />
<span>{repo.name}</span>
<span className="muted">{repo.remote_url}</span>
</button>
{expanded && (
<div className="repo-workspaces">
{repo.workspaces.map((ws) => (
<Link key={ws.id} to={`/workspaces/${ws.id}`} className="workspace-chip">
<span className="workspace-name">{ws.name}</span>
<span className={`status-badge ${ws.status}`}>{ws.status}</span>
{ws.instance_count > 0 && (
<span className="instance-count"> {ws.instance_count}</span>
)}
</Link>
))}
{showForm ? (
<NewWorkspaceInline
projectId={projectId}
repoId={repo.id}
onCreated={() => setShowForm(false)}
onCancel={() => setShowForm(false)}
/>
) : (
<button className="new-workspace-btn" onClick={() => setShowForm(true)}>
<Icon name="add" size="sm" /> New Workspace
</button>
)}
</div>
)}
</div>
);
}
```
## 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 (
<nav className="mobile-tab-bar" role="tablist">
{tabs.map((tab) => (
<button
key={tab.id}
className={`mobile-tab ${active === tab.id ? "active" : ""}`}
onClick={() => onChange(tab.id)}
role="tab"
aria-selected={active === tab.id}
>
<Icon name={tab.icon} />
<span>{tab.label}</span>
</button>
))}
</nav>
);
}
```
### Mobile Workspace Detail
```tsx
export function MobileWorkspaceDetail({ workspace }: { workspace: Workspace }) {
const [activeTab, setActiveTab] = useState<Tab>("files");
return (
<div className="workspace-detail mobile">
<WorkspaceHeader workspace={workspace} compact />
<div className="workspace-content">
{activeTab === "files" && <MobileFilePanel workspace={workspace} />}
{activeTab === "git" && <MobileGitPanel workspace={workspace} />}
{activeTab === "tools" && <MobileToolsPanel workspace={workspace} />}
{activeTab === "settings" && <WorkspaceSettingsPanel workspace={workspace} />}
</div>
<MobileTabBar active={activeTab} onChange={setActiveTab} />
</div>
);
}
```
**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
<Route path="workspaces" element={<WorkspacesPage />} />
<Route path="workspaces/:workspaceId" element={<WorkspaceDetailPage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:projectId" element={<ProjectDetailPage />} />
// Remove old repo-workspace route
// <Route path="projects/:projectId" element={<RepoWorkspace />} /> — 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)
@@ -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
+366
View File
@@ -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
@@ -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
```