# OpenSpec Spec: Project Pane Rework ## Change `project-pane-rework` ## Parent Proposal `openspec/proposals/project-pane-rework.md` ## Status spec --- ## 1. Scope Rework the desktop Projects page (`apps/web/src/pages/ProjectsPage.tsx`) into a large flat list of project entries. Each entry shows: - Project name and Edit/Delete buttons in the header. - A horizontal list of repositories. - Each repo shows its name, current branch, and a vertical list of workspaces with running tools. - Clickable workspace names and tool names. Mobile behavior remains unchanged for now. --- ## 2. Component: `ProjectListItem` Create `apps/web/src/components/features/project/ProjectListItem.tsx`. ### Props ```ts import type { ProjectWithRepos, RepositorySummary, WorkspaceSummary } from "../../../types"; import type { Session } from "../../../api/sessions"; interface ProjectListItemProps { project: ProjectWithRepos; deleteConfirm: boolean; onEdit: () => void; onDelete: () => void; onConfirmDelete: () => void; onCancelDelete: () => void; sessions: Session[]; } ``` ### Render structure ```tsx

{project.name}

{project.description &&

{project.description}

}
{deleteConfirm ? (
Are you sure?
) : ( <> )}
{project.repositories.length === 0 ? (

No repositories.

) : ( project.repositories.map((repo) => ( )) )}
``` ### Helper: `ProjectRepoItem` Inline in the same file or separate: ```tsx function ProjectRepoItem({ repo, project, sessions, }: { repo: RepositorySummary; project: ProjectWithRepos; sessions: Session[]; }) { // Group running sessions by workspace name const sessionsByWorkspace = useMemo(() => { const map = new Map(); for (const ws of repo.workspaces) { map.set(ws.name, []); } for (const session of sessions) { if (session.project_id !== project.id || session.repository_id !== repo.id) continue; if (session.status !== "running") continue; const list = map.get(session.workspace_name) || []; list.push(session); map.set(session.workspace_name, list); } return map; }, [sessions, project.id, repo.id, repo.workspaces]); // Branch: prefer a running session's workspace branch, else first workspace branch, else "—" const branch = useMemo(() => { const firstWs = repo.workspaces[0]; if (!firstWs) return "—"; return firstWs.branch; }, [repo.workspaces]); return (
{repo.name} {branch}
{repo.workspaces.length === 0 ? (

No workspaces.

) : ( repo.workspaces.map((ws) => { const runningSessions = sessionsByWorkspace.get(ws.name) || []; return (
{ws.name} {runningSessions.length === 0 ? ( No running tools ) : (
    {runningSessions.map((session) => (
  • ))}
)}
); }) )}
); } ``` ### Helper: `SessionToolLink` ```tsx function SessionToolLink({ session }: { session: Session }) { const hasTerminal = session.tool_type_interfaces.includes("terminal"); const hasWeb = session.tool_type_interfaces.includes("web"); const href = session.url && hasWeb ? session.url : hasTerminal ? `/instances/${session.id}/terminal` : `/projects/${session.project_id}`; const isExternal = href.startsWith("http"); return ( {session.display_name || session.tool_type_name} ); } ``` --- ## 3. ProjectsPage Integration In `apps/web/src/pages/ProjectsPage.tsx`: - Keep existing mobile view untouched. - In desktop view, replace the `ProjectCard` map with a map of `ProjectListItem`: ```tsx import { ProjectListItem } from "../components/features/project/ProjectListItem"; import { useSessions } from "../state/sessions"; // inside desktop view const { sessions } = useSessions(); ...
{projects.map((project) => ( openEdit(project)} onDelete={() => setDeleteConfirmId(project.id)} onConfirmDelete={() => void handleDelete(project.id)} onCancelDelete={() => setDeleteConfirmId(null)} /> ))}
``` Remove the `expandedProject` state and toggle logic from desktop view if no longer needed. Keep `creatingWorkspace`, `workspaceLoading`, `showCreateForm` only if workspace creation from project list is still desired. **If workspace creation is no longer desired in this new layout, remove those props/logic from the desktop view.** For now, preserve the "New Workspace" creation flow by keeping `ProjectCard` for mobile and providing an alternate entry point later. This spec removes `ProjectCard` from desktop only. --- ## 4. CSS Add to `apps/web/src/styles/pages/projects.css`: ```css .project-list { display: flex; flex-direction: column; gap: var(--space-4); } .project-list-item { display: flex; flex-direction: column; gap: var(--space-4); padding: var(--space-4); background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-md); } .project-list-item-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); } .project-list-item-title h3 { margin: 0; font-size: var(--font-size-lg); } .project-list-item-title p { margin: var(--space-1) 0 0; } .project-list-item-actions { display: flex; align-items: center; gap: var(--space-2); flex-shrink: 0; } .project-repo-list { display: flex; flex-direction: row; flex-wrap: wrap; gap: var(--space-3); } .project-repo-item { display: flex; flex-direction: column; gap: var(--space-3); min-width: 260px; max-width: 320px; flex: 1 1 260px; padding: var(--space-3); background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-md); } .project-repo-item-header { display: flex; align-items: center; gap: var(--space-2); padding-bottom: var(--space-2); border-bottom: 1px solid var(--border); } .project-repo-name { font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .project-repo-branch { display: inline-flex; align-items: center; gap: var(--space-1); font-size: var(--font-size-xs); color: var(--muted); } .project-repo-workspaces { display: flex; flex-direction: column; gap: var(--space-2); } .project-repo-workspace { display: flex; flex-direction: column; gap: var(--space-1); } .workspace-name { font-weight: 500; color: var(--brand); } .workspace-name:hover { text-decoration: underline; } .workspace-tool-list { display: flex; flex-direction: column; gap: var(--space-1); margin: 0; padding: 0; list-style: none; } .workspace-tool-link { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-2); border-radius: var(--radius-md); background: var(--panel); border: 1px solid var(--border); color: var(--ink); font-size: var(--font-size-sm); text-decoration: none; } .workspace-tool-link:hover { background: var(--bg); } .session-status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); } .session-status-dot.running { background: var(--success); } .tool-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } ``` --- ## 5. Acceptance Criteria - [ ] `ProjectListItem` component exists and is used in desktop `ProjectsPage`. - [ ] Each project shows name, description, Edit, Delete in the header. - [ ] Repositories are displayed horizontally. - [ ] Each repo shows name + branch. - [ ] Each workspace under a repo shows running tools vertically. - [ ] Workspace name links to `/workspaces/:workspaceId`. - [ ] Tool name links to the correct tool URL (web tunnel or terminal page). - [ ] Mobile view remains functional (still uses `ProjectCard` or equivalent). - [ ] `npm run typecheck` passes. - [ ] `npm run lint` passes. --- ## 6. Verification Plan 1. Run `cd apps/web && npm run typecheck`. 2. Run `cd apps/web && npm run lint`. 3. Open Projects page on desktop. 4. Verify project headers, horizontal repo lists, branch labels, workspace links, and running tool links. 5. Click a workspace name → navigate to workspace detail. 6. Click a running tool name → open tool. 7. Resize to mobile → verify old project list/detail still works. --- ## 7. Next Phase After approval, create tasks and delegate to `sdd-apply`.