Compare commits

...

3 Commits

Author SHA1 Message Date
alex 88a973dc68 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
2026-06-01 17:18:51 +02:00
alex 27c77af591 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
2026-06-01 17:04:44 +02:00
alex e7587ca9f5 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
2026-06-01 16:47:09 +02:00
32 changed files with 4308 additions and 421 deletions
+73 -14
View File
@@ -4,13 +4,19 @@ 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.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 +82,77 @@ 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(
@@ -184,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):
+114
View File
@@ -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}
+203
View File
@@ -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
],
}
+60
View File
@@ -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
]
+54 -1
View File
@@ -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,
+6
View File
@@ -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")
+128
View File
@@ -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
+223
View File
@@ -0,0 +1,223 @@
"""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
+84
View File
@@ -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")
+3 -3
View File
@@ -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<Project[]> => {
const response = await apiClient.get<Project[]>("/projects");
export const listProjects = async (): Promise<ProjectWithRepos[]> => {
const response = await apiClient.get<ProjectWithRepos[]>("/projects");
return response.data;
};
+45
View File
@@ -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<FileEntry[]> {
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<string> {
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<void> {
await apiClient.post(`/workspaces/${workspaceId}/files/content`, {
path,
content,
message: commitMessage,
});
}
+75
View File
@@ -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<GitStatus> {
const response = await apiClient.get<GitStatus>(
`/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<void> {
await apiClient.post(`/workspaces/${workspaceId}/git/commit`, { message });
}
export async function gitPush(workspaceId: string): Promise<void> {
await apiClient.post(`/workspaces/${workspaceId}/git/push`);
}
export async function gitPull(workspaceId: string): Promise<void> {
await apiClient.post(`/workspaces/${workspaceId}/git/pull`);
}
export async function gitFetch(workspaceId: string): Promise<void> {
await apiClient.post(`/workspaces/${workspaceId}/git/fetch`);
}
export async function gitCheckout(
workspaceId: string,
branch: string,
): Promise<void> {
await apiClient.post(`/workspaces/${workspaceId}/git/checkout`, { branch });
}
export async function getGitHistory(
workspaceId: string,
path?: string,
limit: number = 50,
): Promise<Commit[]> {
const response = await apiClient.get<{ commits: Commit[] }>(
`/workspaces/${workspaceId}/git/history`,
{ params: { path, limit } },
);
return response.data.commits;
}
+30
View File
@@ -0,0 +1,30 @@
/** Workspace instance API client. */
import { apiClient } from "./client";
import type { ToolInstance } from "./sessions";
export async function listWorkspaceInstances(
workspaceId: string,
): Promise<ToolInstance[]> {
const response = await apiClient.get<ToolInstance[]>(
`/workspaces/${workspaceId}/instances/`,
);
return response.data;
}
export async function createWorkspaceInstance(
workspaceId: string,
toolTypeId: string,
displayName?: string,
configProfileId?: string,
): Promise<ToolInstance> {
const response = await apiClient.post<ToolInstance>(
`/workspaces/${workspaceId}/instances/`,
{
tool_type_id: toolTypeId,
display_name: displayName,
config_profile_id: configProfileId,
},
);
return response.data;
}
+7 -1
View File
@@ -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 {
+9 -6
View File
@@ -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 (
<article className={`card workspace-card ${loading ? "loading" : ""}`}>
<div className="workspace-header">
<h4>{workspace.name}</h4>
<span className={`status-badge ${statusClass}`}>
{workspace.status}
</span>
</div>
<Link to={`/workspaces/${workspace.id}`} className="workspace-header-link">
<div className="workspace-header">
<h4>{workspace.name}</h4>
<span className={`status-badge ${statusClass}`}>
{workspace.status}
</span>
</div>
</Link>
<div className="workspace-meta">
<p className="workspace-project">
{workspace.project_name} / {workspace.repo_name}
+68
View File
@@ -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<void>;
loadFile: (path: string) => Promise<void>;
saveFile: (path: string, content: string, message?: string) => Promise<void>;
}
export function useWorkspaceFiles(
workspaceId: string,
): UseWorkspaceFilesResult {
const [entries, setEntries] = useState<FileEntry[]>([]);
const [content, setContent] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 };
}
+109
View File
@@ -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<void>;
commit: (message: string) => Promise<void>;
push: () => Promise<void>;
pull: () => Promise<void>;
fetch: () => Promise<void>;
checkout: (branch: string) => Promise<void>;
}
export function useWorkspaceGit(workspaceId: string): UseWorkspaceGitResult {
const [status, setStatus] = useState<GitStatus | null>(null);
const [branches, setBranches] = useState<string[]>([]);
const [currentBranch, setCurrentBranch] = useState("");
const [history, setHistory] = useState<Commit[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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,
};
}
@@ -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<void>;
create: (
toolTypeId: string,
displayName?: string,
configProfileId?: string,
) => Promise<ToolInstance>;
}
export function useWorkspaceInstances(
workspaceId: string,
): UseWorkspaceInstancesResult {
const [instances, setInstances] = useState<ToolInstance[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 };
}
+2 -2
View File
@@ -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<MobileView>("list");
const [status, setStatus] = useState<Status>("loading");
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(
+2 -2
View File
@@ -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<HomeStatus>("loading");
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [sessions, setSessions] = useState<SessionView[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState("");
+446 -189
View File
@@ -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<Project[]>(listProjects, []);
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [formName, setFormName] = useState("");
const [formDescription, setFormDescription] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const { data: projects, status, reload } = useAsyncData<ProjectWithRepos[]>(
listProjects,
[],
);
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
const [editingProject, setEditingProject] = useState<ProjectWithRepos | null>(
null,
);
const [formName, setFormName] = useState("");
const [formDescription, setFormDescription] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [expandedProject, setExpandedProject] = useState<string | null>(null);
const [creatingWorkspace, setCreatingWorkspace] = useState<{
projectId: string;
repoId: string;
} | null>(null);
const [workspaceLoading, setWorkspaceLoading] = useState<string | null>(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 (
<section className="stack">
<div className="page-header">
<h1>Projects</h1>
<button className="primary-button" onClick={openCreate} type="button">
<Icon name="add" size="sm" />
New Project
</button>
</div>
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" && <LoadingState message="Loading projects..." />}
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" && <ErrorState message="Failed to load projects" onRetry={reload} />}
const isEmpty = status === "ready" && safeProjects.length === 0;
{isEmpty && <EmptyState message="No projects yet. Create your first project above." />}
return (
<section className="stack">
<div className="page-header">
<h1>Projects</h1>
<button className="primary-button" onClick={openCreate} type="button">
<Icon name="add" size="sm" />
New Project
</button>
</div>
{status === "ready" && safeProjects.length > 0 && (
<div className="project-list">
{safeProjects.map((project) => (
<article className="card project-card" key={project.id}>
<div className="project-info">
<h3>{project.name}</h3>
{project.description && <p className="muted">{project.description}</p>}
</div>
<div className="project-actions">
<Link className="ghost-button" to={`/projects/${project.id}`}>
Open Workspace
</Link>
<button
className="ghost-button"
onClick={() => openEdit(project)}
type="button"
>
<Icon name="edit" size="sm" />
Edit
</button>
{deleteConfirmId === project.id ? (
<div className="delete-confirm">
<span>Are you sure?</span>
<button
className="danger-button"
onClick={() => void handleDelete(project.id)}
type="button"
>
<Icon name="delete" size="sm" />
Delete
</button>
<button
className="ghost-button"
onClick={() => setDeleteConfirmId(null)}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
</div>
) : (
<button
className="ghost-button danger-text"
onClick={() => setDeleteConfirmId(project.id)}
type="button"
>
<Icon name="delete" size="sm" />
Delete
</button>
)}
</div>
</article>
))}
</div>
)}
{status === "loading" && <LoadingState message="Loading projects..." />}
{dialogMode !== "none" && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>{dialogMode === "create" ? "Create Project" : "Edit Project"}</h2>
<form onSubmit={handleSubmit} className="stack">
<label className="form-field">
Name
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="Project name"
/>
</label>
<label className="form-field">
Description
<textarea
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
placeholder="Optional description"
rows={3}
/>
</label>
{formError && <p className="error-text">{formError}</p>}
<div className="dialog-actions">
<button className="secondary-button" onClick={closeDialog} type="button">
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
{dialogMode === "create" ? (
<>
<Icon name="add" size="sm" />
Create
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
</button>
</div>
</form>
</div>
</div>
)}
</section>
);
{status === "error" && (
<ErrorState message="Failed to load projects" onRetry={reload} />
)}
{isEmpty && (
<EmptyState message="No projects yet. Create your first project above." />
)}
{status === "ready" && safeProjects.length > 0 && (
<div className="project-list">
{safeProjects.map((project) => (
<ProjectCard
key={project.id}
project={project}
expanded={expandedProject === project.id}
onToggle={() =>
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)
}
/>
))}
</div>
)}
{dialogMode !== "none" && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>
{dialogMode === "create" ? "Create Project" : "Edit Project"}
</h2>
<form onSubmit={handleSubmit} className="stack">
<label className="form-field">
Name
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="Project name"
/>
</label>
<label className="form-field">
Description
<textarea
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
placeholder="Optional description"
rows={3}
/>
</label>
{formError && <p className="error-text">{formError}</p>}
<div className="dialog-actions">
<button
className="secondary-button"
onClick={closeDialog}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
{dialogMode === "create" ? (
<>
<Icon name="add" size="sm" />
Create
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
</button>
</div>
</form>
</div>
</div>
)}
</section>
);
};
/* ─── 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<void>;
}) {
return (
<article className="card project-card">
<div className="project-info-row">
<button
className="project-toggle"
onClick={onToggle}
type="button"
aria-expanded={expanded}
>
<Icon
name={expanded ? "chevron-down" : "chevron-right"}
size="sm"
/>
<h3>{project.name}</h3>
{project.repositories.length > 0 && (
<span className="repo-count">
{project.repositories.length} repo
{project.repositories.length > 1 ? "s" : ""}
</span>
)}
</button>
<div className="project-actions">
<button className="ghost-button" onClick={onEdit} type="button">
<Icon name="edit" size="sm" />
Edit
</button>
{deleteConfirm ? (
<div className="delete-confirm">
<span>Are you sure?</span>
<button
className="danger-button"
onClick={onConfirmDelete}
type="button"
>
<Icon name="delete" size="sm" />
Delete
</button>
<button
className="ghost-button"
onClick={onCancelDelete}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
</div>
) : (
<button
className="ghost-button danger-text"
onClick={onDelete}
type="button"
>
<Icon name="delete" size="sm" />
Delete
</button>
)}
</div>
</div>
{expanded && (
<div className="project-detail">
{project.repositories.length === 0 ? (
<p className="muted">No repositories yet.</p>
) : (
<div className="repo-list">
{project.repositories.map((repo) => (
<div key={repo.id} className="repo-block">
<div className="repo-header">
<h4>{repo.name}</h4>
<button
className="btn btn-sm btn-primary"
onClick={() => onCreateWorkspace(repo.id)}
type="button"
>
<Icon name="add" size="sm" /> New Workspace
</button>
</div>
{showCreateForm === repo.id && (
<WorkspaceCreateForm
projectId={project.id}
repoId={repo.id}
onSubmit={(data) =>
onSubmitCreate(repo.id, data)
}
onCancel={onCancelCreate}
/>
)}
{repo.workspaces.length === 0 ? (
<p className="muted">No workspaces.</p>
) : (
<div className="workspace-grid">
{repo.workspaces.map((ws) => (
<div
key={ws.id}
className={`workspace-chip ${ws.status}`}
>
<a href={`/workspaces/${ws.id}`}>{ws.name}</a>
<span className="ws-branch">
<Icon name="branch" size="sm" /> {ws.branch}
</span>
{ws.instance_count > 0 && (
<span className="ws-instances">
{ws.instance_count} tool
{ws.instance_count > 1 ? "s" : ""}
</span>
)}
<div className="ws-actions">
<button
type="button"
disabled={
workspaceLoading === ws.id
}
onClick={() =>
onWorkspaceAction(
repo.id,
ws,
"sync",
)
}
>
<Icon name="refresh" size="sm" />
</button>
<button
type="button"
className="danger-text"
disabled={
workspaceLoading === ws.id
}
onClick={() =>
onWorkspaceAction(
repo.id,
ws,
"delete",
)
}
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</article>
);
}
+2 -2
View File
@@ -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<Session[]>([]);
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState<string>("");
+466
View File
@@ -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<Tab>("files");
const isMobile = useMobileViewport();
const { workspaces, loading: wsLoading } = useWorkspaces();
const workspace = workspaces.find((w) => w.id === workspaceId);
if (wsLoading) {
return <div className="loading-state">Loading workspace...</div>;
}
if (!workspace) {
return (
<div className="empty-state">
<h2>Workspace not found</h2>
<p>The workspace you are looking for does not exist.</p>
</div>
);
}
return (
<div className={`workspace-detail ${isMobile ? "mobile" : ""}`}>
<WorkspaceHeader workspace={workspace} />
<TabBar active={activeTab} onChange={setActiveTab} />
<div className="workspace-content">
{activeTab === "files" && <FilesTab workspaceId={workspace.id} />}
{activeTab === "git" && <GitTab workspaceId={workspace.id} />}
{activeTab === "tools" && <ToolsTab workspaceId={workspace.id} />}
{activeTab === "settings" && <SettingsTab workspace={workspace} />}
</div>
{isMobile && <MobileTabBar active={activeTab} onChange={setActiveTab} />}
</div>
);
}
function WorkspaceHeader({
workspace,
}: {
workspace: {
name: string;
repo_name: string;
project_name: string;
branch: string;
};
}) {
return (
<header className="workspace-header">
<div className="workspace-breadcrumb">
<span>{workspace.project_name}</span>
<span className="sep">/</span>
<span>{workspace.repo_name}</span>
<span className="sep">/</span>
<strong>{workspace.name}</strong>
</div>
<div className="workspace-actions">
<span className="branch-badge">
<Icon name="branch" size="sm" /> {workspace.branch}
</span>
</div>
</header>
);
}
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 (
<nav className="tab-bar" role="tablist">
{tabs.map((tab) => (
<button
key={tab.id}
className={`tab ${active === tab.id ? "active" : ""}`}
onClick={() => onChange(tab.id)}
role="tab"
aria-selected={active === tab.id}
>
<Icon name={tab.icon as "folder" | "branch" | "terminal" | "settings"} size="sm" />
{tab.label}
</button>
))}
</nav>
);
}
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 (
<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 as "folder" | "branch" | "terminal" | "settings"} />
<span>{tab.label}</span>
</button>
))}
</nav>
);
}
/* ─── 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<string | null>(null);
const [editContent, setEditContent] = useState<string | null>(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 (
<div className="files-tab">
{status && (
<div className="git-toolbar">
<div className="git-toolbar-status">
{status.modified.length > 0 && (
<span className="status-modified">
M {status.modified.length}
</span>
)}
{status.added.length > 0 && (
<span className="status-added">A {status.added.length}</span>
)}
{status.deleted.length > 0 && (
<span className="status-deleted">D {status.deleted.length}</span>
)}
{status.untracked.length > 0 && (
<span className="status-untracked">
? {status.untracked.length}
</span>
)}
</div>
<div className="git-toolbar-actions">
<input
type="text"
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
placeholder="Commit message"
/>
<button
onClick={() => commit(commitMessage)}
disabled={!commitMessage}
>
Commit
</button>
<button onClick={push}>Push</button>
<button onClick={pull}>Pull</button>
<button onClick={fetch}>Fetch</button>
</div>
</div>
)}
<div className="files-split">
<div className="file-tree">
{loading && <p className="muted">Loading...</p>}
{error && <p className="error-text">{error}</p>}
{entries.map((entry) => (
<button
key={entry.path}
className={`tree-entry ${entry.type} ${selectedPath === entry.path ? "selected" : ""}`}
onClick={() => handleSelect(entry)}
type="button"
>
<Icon
name={entry.type === "directory" ? "folder" : "file"}
size="sm"
/>
{entry.name}
</button>
))}
</div>
<div className="file-viewer">
{selectedPath ? (
<>
<div className="file-viewer-header">
<span>{selectedPath}</span>
{!isEditing && <button onClick={handleEdit}>Edit</button>}
</div>
{isEditing ? (
<>
<textarea
className="file-editor"
value={editContent || ""}
onChange={(e) => setEditContent(e.target.value)}
/>
<div className="file-editor-actions">
<button onClick={() => setIsEditing(false)}>Cancel</button>
<button onClick={handleSave}>Save</button>
</div>
</>
) : (
<pre className="file-content">{content || "Loading..."}</pre>
)}
</>
) : (
<p className="muted">Select a file to view</p>
)}
</div>
</div>
</div>
);
}
/* ─── Git Tab ─── */
function GitTab({ workspaceId }: { workspaceId: string }) {
const { history, branches, currentBranch, checkout, loading, error } =
useWorkspaceGit(workspaceId);
return (
<div className="git-tab">
<div className="git-tab-header">
<select
value={currentBranch}
onChange={(e) => checkout(e.target.value)}
>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</div>
{loading && <p className="muted">Loading history...</p>}
{error && <p className="error-text">{error}</p>}
<div className="commit-history">
{history.map((commit) => (
<div key={commit.hash} className="commit-row">
<span className="commit-hash">{commit.hash.slice(0, 7)}</span>
<span className="commit-message">{commit.message}</span>
<span className="commit-author">{commit.author}</span>
<span className="commit-date">{commit.date}</span>
</div>
))}
</div>
</div>
);
}
/* ─── Tools Tab ─── */
function ToolsTab({ workspaceId }: { workspaceId: string }) {
const { instances, loading, create } = useWorkspaceInstances(workspaceId);
const [showModal, setShowModal] = useState(false);
return (
<div className="tools-tab">
{loading && <p className="muted">Loading instances...</p>}
{instances.length === 0 ? (
<div className="empty-state-card">
<Icon name="terminal" size="lg" />
<h3>No tools running</h3>
<p>Start a tool to begin coding in this workspace</p>
<button
className="btn btn-primary"
onClick={() => setShowModal(true)}
>
Start Tool
</button>
</div>
) : (
<>
<div className="instances-grid">
{instances.map((instance) => (
<div
key={instance.id}
className={`instance-card ${instance.status}`}
>
<h4>{instance.display_name}</h4>
<span className="status-badge">{instance.status}</span>
{instance.url && (
<a
href={instance.url}
target="_blank"
rel="noopener noreferrer"
>
Open
</a>
)}
</div>
))}
</div>
<button
className="btn btn-primary"
onClick={() => setShowModal(true)}
>
Start Another Tool
</button>
</>
)}
{showModal && (
<StartToolModal
onClose={() => setShowModal(false)}
onStart={async (toolTypeId: string) => {
await create(toolTypeId);
setShowModal(false);
}}
/>
)}
</div>
);
}
/* ─── Settings Tab ─── */
function SettingsTab({
workspace,
}: {
workspace: {
id: string;
name: string;
branch: string;
path: string;
status: string;
created_at: string;
};
}) {
return (
<div className="settings-tab">
<div className="settings-section">
<h3>Workspace Info</h3>
<div className="form-group">
<label>Name</label>
<input type="text" value={workspace.name} readOnly />
</div>
<div className="form-group">
<label>Branch</label>
<input type="text" value={workspace.branch} readOnly />
</div>
<div className="form-group">
<label>Path</label>
<input type="text" value={workspace.path} readOnly />
</div>
<div className="form-group">
<label>Status</label>
<span className={`status-badge ${workspace.status}`}>
{workspace.status}
</span>
</div>
<div className="form-group">
<label>Created</label>
<span>{workspace.created_at}</span>
</div>
</div>
</div>
);
}
/* ─── Start Tool Modal ─── */
function StartToolModal({
onClose,
onStart,
}: {
onClose: () => void;
onStart: (toolTypeId: string) => Promise<void>;
}) {
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 (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3>Start Tool</h3>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Tool Type</label>
<select
value={toolTypeId}
onChange={(e) => setToolTypeId(e.target.value)}
>
<option value="">Select...</option>
<option value="code-server">Code Server</option>
<option value="jupyter-notebook">Jupyter Notebook</option>
<option value="terminal">Terminal</option>
</select>
</div>
<div className="form-actions">
<button type="button" onClick={onClose} disabled={submitting}>
Cancel
</button>
<button type="submit" disabled={!toolTypeId || submitting}>
{submitting ? "Starting..." : "Start"}
</button>
</div>
</form>
</div>
</div>
);
}
+43 -19
View File
@@ -13,7 +13,10 @@ import type { Workspace } from "../types/workspace";
export function WorkspacesPage() {
const [showCreate, setShowCreate] = useState(false);
const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(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() {
>
<Icon name="refresh" size="sm" />
</button>
<button
className="btn btn-primary"
onClick={() => {
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.");
}
}}
>
<Icon name="add" size="sm" /> New Workspace
</button>
<button
className="btn btn-primary"
onClick={() => {
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.");
}
}}
>
<Icon name="add" size="sm" /> New Workspace
</button>
</div>
</header>
@@ -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);
}}
/>
)}
+2 -2
View File
@@ -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";
@@ -17,6 +16,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 (
@@ -36,7 +36,6 @@ export const AppRouter = () => {
>
<Route index element={<HomePage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:projectId" element={<RepoWorkspace />} />
<Route
path="projects/:projectId/repositories"
element={<GitRepositoriesPage />}
@@ -59,6 +58,7 @@ export const AppRouter = () => {
</Route>
<Route path="sessions" element={<SessionsPage />} />
<Route path="workspaces" element={<WorkspacesPage />} />
<Route path="workspaces/:workspaceId" element={<WorkspaceDetailPage />} />
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
<Route
path="instances/:instanceId/terminal"
+587
View File
@@ -4867,3 +4867,590 @@ a:active,
transform: translateX(100%);
}
}
/* ─── Workspace Detail Page ─── */
.workspace-detail {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.workspace-header-link {
display: block;
text-decoration: none;
color: inherit;
}
.workspace-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
background: var(--panel);
flex-shrink: 0;
}
.workspace-breadcrumb {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--muted);
font-size: var(--font-size-sm);
}
.workspace-breadcrumb .sep {
color: var(--border);
}
.workspace-breadcrumb strong {
color: var(--ink);
font-size: var(--font-size-lg);
}
.branch-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 999px;
font-size: var(--font-size-sm);
color: var(--muted);
}
/* Tab Bar */
.tab-bar {
display: flex;
gap: var(--space-1);
padding: var(--space-2) var(--space-5);
border-bottom: 1px solid var(--border);
background: var(--panel);
flex-shrink: 0;
overflow-x: auto;
}
.tab {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-4);
border: 1px solid transparent;
border-radius: 10px;
background: none;
color: var(--muted);
font: inherit;
font-size: var(--font-size-sm);
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
}
.tab:hover {
background: var(--bg);
color: var(--ink);
}
.tab.active {
background: var(--brand);
color: var(--primary-fg);
}
/* Mobile Tab Bar */
.mobile-tab-bar {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
justify-content: space-around;
padding: var(--space-2) 0;
background: var(--panel);
border-top: 1px solid var(--border);
z-index: 50;
}
.mobile-tab {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: var(--space-1) var(--space-2);
border: none;
background: none;
color: var(--muted);
font: inherit;
font-size: var(--font-size-xs);
cursor: pointer;
}
.mobile-tab.active {
color: var(--brand);
}
/* Workspace Content */
.workspace-content {
flex: 1;
overflow: hidden;
padding: var(--space-4) var(--space-5);
overflow-y: auto;
}
/* Files Tab */
.files-tab {
display: flex;
flex-direction: column;
gap: var(--space-3);
height: 100%;
}
.git-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
flex-wrap: wrap;
}
.git-toolbar-status {
display: flex;
gap: var(--space-2);
}
.git-toolbar-status span {
padding: var(--space-1) var(--space-2);
border-radius: 6px;
font-size: var(--font-size-xs);
font-weight: 600;
}
.status-modified {
background: var(--warning-light);
color: var(--warning);
}
.status-added {
background: var(--success-light);
color: var(--success);
}
.status-deleted {
background: var(--danger-light);
color: var(--danger);
}
.status-untracked {
background: rgba(107, 114, 128, 0.1);
color: #6b7280;
}
.git-toolbar-actions {
display: flex;
gap: var(--space-2);
align-items: center;
flex-wrap: wrap;
}
.git-toolbar-actions input {
padding: var(--space-1) var(--space-3);
border: 1px solid var(--border);
border-radius: 6px;
font: inherit;
background: var(--panel);
color: var(--ink);
min-width: 180px;
}
.files-split {
display: grid;
grid-template-columns: 260px 1fr;
gap: var(--space-4);
flex: 1;
min-height: 0;
overflow: hidden;
}
.file-tree {
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 10px;
padding: var(--space-3);
background: var(--panel);
}
.tree-entry {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-1) var(--space-2);
border: none;
border-radius: 6px;
background: none;
color: var(--ink);
font: inherit;
font-size: var(--font-size-sm);
text-align: left;
cursor: pointer;
white-space: nowrap;
}
.tree-entry:hover {
background: var(--bg);
}
.tree-entry.selected {
background: var(--brand);
color: var(--primary-fg);
}
.file-viewer {
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel);
overflow: hidden;
}
.file-viewer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3);
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.file-content {
flex: 1;
padding: var(--space-4);
overflow: auto;
margin: 0;
font-family: "IBM Plex Mono", monospace;
font-size: var(--font-size-sm);
line-height: 1.6;
white-space: pre-wrap;
}
.file-editor {
flex: 1;
padding: var(--space-3);
border: none;
font-family: "IBM Plex Mono", monospace;
font-size: var(--font-size-sm);
line-height: 1.6;
resize: none;
background: var(--panel);
color: var(--ink);
}
.file-editor-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-3);
border-top: 1px solid var(--border);
}
/* Git Tab */
.git-tab {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.git-tab-header {
display: flex;
gap: var(--space-3);
align-items: center;
}
.git-tab-header select {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: 6px;
font: inherit;
background: var(--panel);
color: var(--ink);
}
.commit-history {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.commit-row {
display: grid;
grid-template-columns: 60px 1fr 120px 120px;
gap: var(--space-3);
padding: var(--space-3);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
align-items: center;
font-size: var(--font-size-sm);
}
.commit-hash {
font-family: monospace;
color: var(--brand);
}
.commit-message {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.commit-author,
.commit-date {
color: var(--muted);
font-size: var(--font-size-xs);
}
/* Tools Tab */
.tools-tab {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.empty-state-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-10);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
text-align: center;
}
.empty-state-card h3 {
margin: 0;
}
.empty-state-card p {
margin: 0;
color: var(--muted);
}
.instances-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--space-4);
}
.instance-card {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
}
.instance-card.running {
border-color: var(--success);
}
/* Settings Tab */
.settings-tab {
max-width: 640px;
}
.settings-section {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-5);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
}
.settings-section h3 {
margin: 0;
}
/* Mobile Workspace Detail */
@media (max-width: 767px) {
.workspace-detail.mobile .workspace-content {
padding-bottom: 72px;
}
.mobile-tab-bar {
display: flex;
}
.files-split {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
.commit-row {
grid-template-columns: 1fr;
gap: var(--space-1);
}
.git-toolbar {
flex-direction: column;
align-items: flex-start;
}
}
/* Workspace Card Link */
.workspace-header-link {
display: block;
text-decoration: none;
color: inherit;
margin: -1rem -1rem 0;
padding: 1rem;
}
.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);
}
+25
View File
@@ -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;
};
+1 -180
View File
@@ -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";
@@ -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
```