# Design: Workspace-First UI Refresh ## Status | Field | Value | |---|---| | Phase | **Design** | | Based on | [Spec](spec.md) | | Next | Tasks | ## Backend Design ### Directory Structure ``` apps/api/src/ ├── api/ │ ├── workspace_files.py # NEW: GET/POST /workspaces/{id}/files │ ├── workspace_git.py # NEW: /workspaces/{id}/git/* │ ├── workspace_instances.py # NEW: /workspaces/{id}/instances │ └── workspaces.py # MODIFIED: add repo_id to POST, enrich responses ├── services/ │ ├── git_operations.py # NEW: workspace-scoped git commands │ └── file_service.py # NEW: workspace file operations └── models/ └── workspace.py # UNCHANGED ``` ### Service: FileService ```python class FileService: """Read/write files within a workspace directory.""" def list_directory(self, workspace: Workspace, path: str = "") -> list[FileEntry]: abs_path = os.path.join(workspace.path, path) entries = [] for item in os.listdir(abs_path): full = os.path.join(abs_path, item) stat = os.lstat(full) entries.append(FileEntry( name=item, path=os.path.join(path, item), type="directory" if os.path.isdir(full) else "file", size=stat.st_size if os.path.isfile(full) else None, )) return entries def read_file(self, workspace: Workspace, path: str) -> str: abs_path = os.path.join(workspace.path, path) with open(abs_path, "r") as f: return f.read() def write_file(self, workspace: Workspace, path: str, content: str) -> None: abs_path = os.path.join(workspace.path, path) os.makedirs(os.path.dirname(abs_path), exist_ok=True) with open(abs_path, "w") as f: f.write(content) ``` ### Service: GitOperations ```python class GitOperations: """Git commands scoped to a workspace directory.""" def __init__(self, workspace: Workspace) -> None: self.cwd = workspace.path self.branch = workspace.branch async def status(self) -> GitStatus: proc = await asyncio.create_subprocess_exec( "git", "-C", self.cwd, "status", "--porcelain", stdout=asyncio.subprocess.PIPE, ) stdout, _ = await proc.communicate() return self._parse_status(stdout.decode()) async def commit(self, message: str) -> None: await self._run("git", "-C", self.cwd, "add", "-A") await self._run("git", "-C", self.cwd, "commit", "-m", message) async def push(self) -> None: await self._run("git", "-C", self.cwd, "push", "origin", self.branch) async def pull(self) -> None: await self._run("git", "-C", self.cwd, "pull", "origin", self.branch) async def fetch(self) -> None: await self._run("git", "-C", self.cwd, "fetch", "origin") async def checkout(self, branch: str) -> None: await self._run("git", "-C", self.cwd, "checkout", branch) async def history(self, path: str | None = None, limit: int = 50) -> list[Commit]: cmd = ["git", "-C", self.cwd, "log", f"--max-count={limit}", "--pretty=format:%H|%s|%an|%ad"] if path: cmd.extend(["--", path]) stdout = await self._run_stdout(*cmd) return self._parse_log(stdout) ``` ### API: Workspace Files ```python @router.get("/{workspace_id}/files") async def list_files(workspace_id: uuid.UUID, path: str = ""): workspace = await get_workspace(workspace_id) entries = FileService().list_directory(workspace, path) return {"entries": [e.dict() for e in entries]} @router.get("/{workspace_id}/files/content") async def get_file_content(workspace_id: uuid.UUID, path: str): workspace = await get_workspace(workspace_id) content = FileService().read_file(workspace, path) return {"content": content, "path": path} @router.post("/{workspace_id}/files/content") async def write_file(workspace_id: uuid.UUID, data: dict): workspace = await get_workspace(workspace_id) FileService().write_file(workspace, data["path"], data["content"]) if data.get("message"): await GitOperations(workspace).commit(data["message"]) return {"status": "saved"} ``` ### API: Workspace Git ```python @router.get("/{workspace_id}/git/status") async def git_status(workspace_id: uuid.UUID): workspace = await get_workspace(workspace_id) return await GitOperations(workspace).status() @router.post("/{workspace_id}/git/commit") async def git_commit(workspace_id: uuid.UUID, data: dict): workspace = await get_workspace(workspace_id) await GitOperations(workspace).commit(data["message"]) return {"status": "committed"} @router.post("/{workspace_id}/git/push") async def git_push(workspace_id: uuid.UUID): workspace = await get_workspace(workspace_id) await GitOperations(workspace).push() return {"status": "pushed"} @router.post("/{workspace_id}/git/pull") async def git_pull(workspace_id: uuid.UUID): workspace = await get_workspace(workspace_id) await GitOperations(workspace).pull() return {"status": "pulled"} @router.post("/{workspace_id}/git/fetch") async def git_fetch(workspace_id: uuid.UUID): workspace = await get_workspace(workspace_id) await GitOperations(workspace).fetch() return {"status": "fetched"} @router.post("/{workspace_id}/git/checkout") async def git_checkout(workspace_id: uuid.UUID, data: dict): workspace = await get_workspace(workspace_id) await GitOperations(workspace).checkout(data["branch"]) workspace.branch = data["branch"] await session.commit() return {"status": "checked_out", "branch": data["branch"]} @router.get("/{workspace_id}/git/history") async def git_history(workspace_id: uuid.UUID, path: str | None = None, limit: int = 50): workspace = await get_workspace(workspace_id) return await GitOperations(workspace).history(path, limit) ``` ### API: Workspace Instances ```python @router.get("/{workspace_id}/instances") async def list_workspace_instances(workspace_id: uuid.UUID, session: AsyncSession): result = await session.execute( select(ToolInstance).where(ToolInstance.workspace_id == workspace_id) ) return [instance_to_dict(i) for i in result.scalars().all()] @router.post("/{workspace_id}/instances") async def create_workspace_instance( workspace_id: uuid.UUID, data: dict, user_id: uuid.UUID, session: AsyncSession, ): workspace = await get_workspace(workspace_id) # Reuse existing create_instance logic but with workspace_id pre-set return await create_instance_internal( project_id=workspace.repo.project_id, repo_id=workspace.repo_id, tool_type_id=data["tool_type_id"], workspace_id=workspace_id, display_name=data.get("display_name"), config_profile_id=data.get("config_profile_id"), ) ``` ### Modified: Projects API ```python @router.get("/") async def list_projects(user_id: uuid.UUID, session: AsyncSession): result = await session.execute( select(Project).where(Project.owner_id == user_id).order_by(Project.created_at.desc()) ) projects = [] for project in result.scalars().all(): repos = await session.execute( select(GitRepository).where(GitRepository.project_id == project.id) ) repo_list = [] for repo in repos.scalars().all(): workspaces = await session.execute( select(Workspace).where(Workspace.repo_id == repo.id) ) repo_list.append({ "id": str(repo.id), "name": repo.name, "remote_url": repo.remote_url, "workspaces": [ { "id": str(ws.id), "name": ws.name, "branch": ws.branch, "status": ws.status, "instance_count": ..., } for ws in workspaces.scalars().all() ], }) projects.append({ "id": str(project.id), "name": project.name, "description": project.description, "repositories": repo_list, }) return {"projects": projects} ``` ## Frontend Design ### Directory Structure ``` apps/web/src/ ├── pages/ │ ├── workspace-detail.tsx # NEW: /workspaces/:id │ ├── projects.tsx # MODIFIED: inline repos + workspaces │ └── workspaces.tsx # MODIFIED: link to detail ├── components/ │ ├── workspace/ │ │ ├── workspace-header.tsx # NEW: breadcrumb + actions │ │ ├── workspace-tabs.tsx # NEW: tab bar component │ │ ├── workspace-file-panel.tsx # NEW: Files tab (tree + viewer + git toolbar) │ │ ├── workspace-git-panel.tsx # NEW: Git tab (history + diff) │ │ ├── workspace-tools-panel.tsx # NEW: Tools tab (instances + spawn) │ │ ├── workspace-settings-panel.tsx # NEW: Settings tab │ │ ├── git-toolbar.tsx # NEW: collapsible git toolbar │ │ ├── file-tree.tsx # NEW: extracted from repo-workspace │ │ ├── file-viewer.tsx # NEW: extracted from repo-workspace │ │ └── start-tool-modal.tsx # EXISTING: move to workspace/ │ ├── project/ │ │ ├── project-card.tsx # NEW: card with inline repos │ │ ├── repo-section.tsx # NEW: expandable repo + workspaces │ │ ├── workspace-chip.tsx # NEW: small workspace card │ │ └── new-workspace-inline.tsx # NEW: inline form │ └── app-shell.tsx # MODIFIED: nav order ├── hooks/ │ ├── use-workspace-files.ts # NEW │ ├── use-workspace-git.ts # NEW │ ├── use-workspace-instances.ts # NEW │ └── use-projects-enriched.ts # NEW: projects with repos + workspaces ├── api/ │ ├── workspace-files.ts # NEW │ ├── workspace-git.ts # NEW │ ├── workspace-instances.ts # NEW │ └── projects.ts # MODIFIED: enriched response └── router.tsx # MODIFIED: routes ``` ### Component: WorkspaceDetailPage ```tsx export function WorkspaceDetailPage() { const { workspaceId } = useParams(); const [activeTab, setActiveTab] = useState("files"); const { workspace, loading } = useWorkspace(workspaceId); if (loading) return ; if (!workspace) return ; return (
{activeTab === "files" && } {activeTab === "git" && } {activeTab === "tools" && } {activeTab === "settings" && }
); } ``` ### Component: WorkspaceFilePanel ```tsx export function WorkspaceFilePanel({ workspace }: { workspace: Workspace }) { const [selectedPath, setSelectedPath] = useState(null); const [isEditing, setIsEditing] = useState(false); const { entries, loading } = useWorkspaceFiles(workspace.id); const { content } = useWorkspaceFileContent(workspace.id, selectedPath); const { status } = useWorkspaceGitStatus(workspace.id); return (
setIsEditing(true)} onSave={async (newContent, message) => { await saveWorkspaceFile(workspace.id, selectedPath, newContent, message); setIsEditing(false); }} />
); } ``` ### Component: GitToolbar ```tsx export function GitToolbar({ workspace, status }: GitToolbarProps) { const [expanded, setExpanded] = useState(false); const [commitMessage, setCommitMessage] = useState(""); return (
M {status.modified.length} A {status.added.length} D {status.deleted.length}
{expanded && (