diff --git a/apps/web/src/components/features/project/ProjectListItem.tsx b/apps/web/src/components/features/project/ProjectListItem.tsx new file mode 100644 index 0000000..ca94413 --- /dev/null +++ b/apps/web/src/components/features/project/ProjectListItem.tsx @@ -0,0 +1,200 @@ +import { useMemo } from "react"; +import { Link } from "react-router-dom"; + +import { Icon } from "../../icon"; +import type { Session } from "../../../api/sessions"; +import type { IconName } from "../../../utils/icons"; +import type { ProjectWithRepos, RepositorySummary } from "../../../types"; + +interface ProjectListItemProps { + project: ProjectWithRepos; + deleteConfirm: boolean; + onEdit: () => void; + onDelete: () => void; + onConfirmDelete: () => void; + onCancelDelete: () => void; + sessions: Session[]; +} + +export const ProjectListItem = ({ + project, + deleteConfirm, + onEdit, + onDelete, + onConfirmDelete, + onCancelDelete, + sessions, +}: ProjectListItemProps) => { + return ( +
+
+
+

{project.name}

+ {project.description && ( +

{project.description}

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

No repositories.

+ ) : ( + project.repositories.map((repo) => ( + + )) + )} +
+
+ ); +}; + +function ProjectRepoItem({ + repo, + project, + sessions, +}: { + repo: RepositorySummary; + project: ProjectWithRepos; + sessions: Session[]; +}) { + 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]); + + 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) => ( +
  • + +
  • + ))} +
+ )} +
+ ); + }) + )} +
+
+ ); +} + +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} + + + ); +} diff --git a/apps/web/src/pages/ProjectsPage.tsx b/apps/web/src/pages/ProjectsPage.tsx index aa296ed..2c529ab 100644 --- a/apps/web/src/pages/ProjectsPage.tsx +++ b/apps/web/src/pages/ProjectsPage.tsx @@ -7,11 +7,13 @@ import { import { Icon } from "../components/icon"; import { useMobileViewport } from "../hooks/use-mobile-viewport"; import { ProjectCard } from "../components/features/project/ProjectCard"; +import { ProjectListItem } from "../components/features/project/ProjectListItem"; import { ProjectDialog } from "../components/features/project/ProjectDialog"; import { RepositoryCreateDialog } from "../components/features/project/repository-create-dialog"; import { MobileListView } from "../components/features/mobile/mobile-list-view"; import { MobileFAB } from "../components/features/mobile/mobile-fab"; import { useProjects } from "../hooks/use-projects"; +import { useSessions } from "../state/sessions"; import type { ProjectWithRepos } from "../types"; type MobileView = "list" | "detail" | "create-project" | "create-repo"; @@ -34,8 +36,6 @@ export const ProjectsPage = () => { formError, deleteConfirmId, setDeleteConfirmId, - expandedProject, - setExpandedProject, creatingWorkspace, setCreatingWorkspace, workspaceLoading, @@ -48,6 +48,7 @@ export const ProjectsPage = () => { handleDeleteWorkspace, } = useProjects(); + const { sessions } = useSessions(); const isEmpty = status === "ready" && projects.length === 0; /* ── Mobile views ── */ @@ -242,45 +243,15 @@ export const ProjectsPage = () => { {status === "ready" && projects.length > 0 && (
{projects.map((project) => ( - - setExpandedProject( - expandedProject === project.id ? null : project.id, - ) - } onEdit={() => openEdit(project)} onDelete={() => setDeleteConfirmId(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(workspace); - } - }} - onCancelCreate={() => setCreatingWorkspace(null)} - onAddRepository={() => { - setSelectedProject(project); - setMobileView("create-repo"); - }} - onCreated={() => { - setCreatingWorkspace(null); - reload(); - }} /> ))}
diff --git a/apps/web/src/styles/pages/projects.css b/apps/web/src/styles/pages/projects.css index de01b53..d0cb59c 100644 --- a/apps/web/src/styles/pages/projects.css +++ b/apps/web/src/styles/pages/projects.css @@ -188,6 +188,154 @@ } } +/* ─── New Project Pane List Layout ─── */ +.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; +} + /* Inline radio buttons for repository creation mode */ .repo-mode-radios { display: flex; diff --git a/openspec/proposals/project-pane-rework.md b/openspec/proposals/project-pane-rework.md new file mode 100644 index 0000000..440e5cc --- /dev/null +++ b/openspec/proposals/project-pane-rework.md @@ -0,0 +1,106 @@ +# SDD Proposal: Project Pane Rework + +## Status +**Phase:** proposal +**Date:** 2026-06-16 +**Owner:** el Gentleman + +--- + +## User Story + +As a Headquarter user, I want the Projects page to be a large list where each project entry shows its name with edit/delete actions, a horizontal list of repositories with their branches, and a vertical list of running tools per workspace, so that I can see the entire project→repo→workspace→tool hierarchy at a glance and navigate quickly. + +--- + +## Problem Statement + +The current project card uses an expandable pattern with repository cards stacked vertically. The user wants: + +1. A **large list** of projects (not expandable cards). +2. Each project item has a **header** with name + Edit + Delete buttons. +3. In the content area, a **horizontal list of repositories**. +4. Each repo shows its **name** and the **branch** it was cloned from / is currently on. +5. Below each repo, a **vertical list of running tools** on that repo's workspaces. +6. Clicking a **workspace name** opens the workspace (`/workspaces/:workspaceId`). +7. Clicking a **tool name** opens the tool (web URL or terminal page). + +--- + +## Goals + +1. Replace the expandable project card with a flat list of project entries. +2. Show project actions (Edit, Delete) directly in each project header. +3. Show repositories horizontally per project. +4. Show workspaces/tools vertically under each repo, derived from live session data. +5. Make workspace and tool names clickable with correct navigation. + +--- + +## Non-Goals + +- No changes to backend APIs or data models. +- No changes to project creation/editing/deletion behavior. +- No new repo/workspace/tool actions beyond navigation. +- No mobile-specific redesign beyond responsive wrapping. + +--- + +## Data Sources + +1. **Projects + repos + workspaces**: `useProjects()` hook returns `ProjectWithRepos[]`, each with `repositories: RepositorySummary[]`, each with `workspaces: WorkspaceSummary[]`. +2. **Running tools**: `useSessions()` returns `Session[]`. Match by: + - `session.project_id === project.id` + - `session.repository_id === repo.id` + - `session.workspace_name === workspace.name` +3. **Workspace ID for links**: find the workspace in `repo.workspaces` whose `name` matches `session.workspace_name`; use its `id` for `/workspaces/:workspaceId`. + +--- + +## High-Level Approach + +1. Create a new `ProjectListItem` component. +2. Render it inside a `.project-list` container in `ProjectsPage.tsx`. +3. For each project: + - Header: name + Edit + Delete. + - Body: horizontal scroll/flex row of `.repo-item` cards. +4. For each repo: + - Show repo name + branch label. + - Show vertical list of workspaces with running tools. +5. For each workspace under the repo: + - Show workspace name (link to `/workspaces/:id`). + - Show running tool names/icons (link to tool URL or terminal). +6. Remove the old `ProjectCard` usage from desktop view (keep it for mobile detail view if still needed). +7. Add CSS for the new layout in `styles/pages/projects.css`. + +--- + +## Risks + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Workspace name match may be ambiguous | Medium | Use workspace ID when available; fall back to name match with a warning. | +| Many sessions make list noisy | Medium | Only show running sessions; group by workspace. | +| Horizontal repo list overflows on small screens | Low | Use flex-wrap and min-width; scroll on very small screens. | + +--- + +## Effort Estimate + +| Area | Files | Lines (est) | +|---|---|---| +| New component | 1 | ~180 | +| ProjectsPage integration | 1 | ~60 | +| CSS | 1 | ~120 | +| OpenSpec | 3 | ~80 | +| **Total** | **6** | **~440** | + +Slightly above 400-line budget; may need to split into component + page/CSS PRs. + +--- + +## Next Recommended Phase + +**Spec** — detail component props, exact markup, CSS classes, and navigation logic. + +Should I proceed to spec? diff --git a/openspec/specs/project-pane-rework.md b/openspec/specs/project-pane-rework.md new file mode 100644 index 0000000..69b9cea --- /dev/null +++ b/openspec/specs/project-pane-rework.md @@ -0,0 +1,419 @@ +# 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`. diff --git a/openspec/tasks/project-pane-rework.md b/openspec/tasks/project-pane-rework.md new file mode 100644 index 0000000..ce8f927 --- /dev/null +++ b/openspec/tasks/project-pane-rework.md @@ -0,0 +1,80 @@ +# OpenSpec Tasks: Project Pane Rework + +## Change +`project-pane-rework` + +## Parent Spec +`openspec/specs/project-pane-rework.md` + +## Status +tasks + +--- + +## Implementation Tasks + +- [ ] 1. Create `apps/web/src/components/features/project/ProjectListItem.tsx` + - Project header with name, description, Edit/Delete buttons + - Horizontal repo list using `ProjectRepoItem` helper + - Vertical workspace/tool lists using `SessionToolLink` helper + - Match running sessions from `useSessions` to workspaces/repos/projects + +- [ ] 2. Update `apps/web/src/pages/ProjectsPage.tsx` + - Import `ProjectListItem` and `useSessions` + - Replace desktop `ProjectCard` map with `ProjectListItem` map + - Remove unused `expandedProject` state and toggle logic from desktop view + - Keep mobile view and dialogs unchanged + +- [ ] 3. Add CSS to `apps/web/src/styles/pages/projects.css` + - `.project-list` + - `.project-list-item` + - `.project-list-item-header` + - `.project-list-item-title` + - `.project-list-item-actions` + - `.project-repo-list` + - `.project-repo-item` + - `.project-repo-item-header` + - `.project-repo-name` + - `.project-repo-branch` + - `.project-repo-workspaces` + - `.project-repo-workspace` + - `.workspace-name` + - `.workspace-tool-list` + - `.workspace-tool-link` + - `.session-status-dot` + - `.tool-name` + +- [ ] 4. Verification + - Run `cd apps/web && npm run typecheck` + - Run `cd apps/web && npm run lint` + - Spot-check desktop Projects page + - Test workspace and tool links + - Verify mobile view still works + +- [ ] 5. Update OpenSpec artifacts + - Mark tasks complete + - Add final report note + +--- + +## Acceptance Criteria + +- All tasks above are completed. +- `npm run typecheck` passes. +- `npm run lint` passes. +- Desktop Projects page shows the new flat list layout. +- Each project header has name + Edit + Delete. +- Repositories are horizontal. +- Each repo shows name + branch. +- Workspaces and running tools are listed vertically under each repo. +- Workspace links navigate to `/workspaces/:workspaceId`. +- Tool links open the tool. +- Mobile view remains functional. + +--- + +## Notes + +- Do not change backend APIs or data models. +- Keep mobile behavior unchanged. +- Prefer one commit per major area (component, page integration, CSS).