Files
headquarter/openspec/changes/archive/2026-06-12-completed-changes-archive/workspace-first-ui/design.md
T
Developer 8c1948d226 chore: archive final 4 completed OpenSpec changes
Move the following completed changes from openspec/changes/ to
openspec/changes/archive/2026-06-12-completed-changes-archive/:
- multi-session-terminal-ux
- reorganize-long-files
- working-copies
- workspace-first-ui

Update parent and archive .pi-map*.md indexes to reflect the move and
remove the transient active-changes-archive grouping.

openspec/changes/ now contains only the archive/ directory.
2026-06-12 21:11:11 +00:00

24 KiB

Design: Workspace-First UI Refresh

Status

Field Value
Phase Design
Based on Spec
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

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

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

@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

@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

@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

@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

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

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

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

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

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)

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

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

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

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

// 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)