feat(web/ui): rework project pane into flat list with repo/workspace/tool hierarchy
- Add ProjectListItem component with project header actions - Show repositories horizontally with branch labels - List workspaces vertically under each repo - Show running tools per workspace from useSessions - Link workspace names to /workspaces/:id - Link tool names to web URL or terminal page - Keep existing mobile view and dialogs unchanged
This commit is contained in:
@@ -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?
|
||||
@@ -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
|
||||
<article className="project-list-item">
|
||||
<header className="project-list-item-header">
|
||||
<div className="project-list-item-title">
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && <p className="muted">{project.description}</p>}
|
||||
</div>
|
||||
<div className="project-list-item-actions">
|
||||
{deleteConfirm ? (
|
||||
<div className="delete-confirm">
|
||||
<span>Are you sure?</span>
|
||||
<button className="btn btn-sm btn-danger" onClick={onConfirmDelete}>Delete</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={onCancelDelete}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-sm btn-secondary" onClick={onEdit}><Icon name="edit" size="sm" /> Edit</button>
|
||||
<button className="btn btn-sm btn-danger" onClick={onDelete}><Icon name="delete" size="sm" /> Delete</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="project-repo-list">
|
||||
{project.repositories.length === 0 ? (
|
||||
<p className="muted">No repositories.</p>
|
||||
) : (
|
||||
project.repositories.map((repo) => (
|
||||
<ProjectRepoItem key={repo.id} repo={repo} project={project} sessions={sessions} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
```
|
||||
|
||||
### 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<string, Session[]>();
|
||||
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 (
|
||||
<div className="project-repo-item">
|
||||
<div className="project-repo-item-header">
|
||||
<Icon name="folder" size="sm" />
|
||||
<span className="project-repo-name">{repo.name}</span>
|
||||
<span className="project-repo-branch">
|
||||
<Icon name="branch" size="sm" /> {branch}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="project-repo-workspaces">
|
||||
{repo.workspaces.length === 0 ? (
|
||||
<p className="muted text-sm">No workspaces.</p>
|
||||
) : (
|
||||
repo.workspaces.map((ws) => {
|
||||
const runningSessions = sessionsByWorkspace.get(ws.name) || [];
|
||||
return (
|
||||
<div key={ws.id} className="project-repo-workspace">
|
||||
<Link className="workspace-name" to={`/workspaces/${ws.id}`}>
|
||||
{ws.name}
|
||||
</Link>
|
||||
{runningSessions.length === 0 ? (
|
||||
<span className="muted text-xs">No running tools</span>
|
||||
) : (
|
||||
<ul className="workspace-tool-list">
|
||||
{runningSessions.map((session) => (
|
||||
<li key={session.id}>
|
||||
<SessionToolLink session={session} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 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 (
|
||||
<a
|
||||
href={href}
|
||||
target={isExternal ? `session-${session.id}` : undefined}
|
||||
rel={isExternal ? "noreferrer" : undefined}
|
||||
className="workspace-tool-link"
|
||||
title={`${session.display_name} (${session.status})`}
|
||||
>
|
||||
<span className={`session-status-dot ${session.status === "running" ? "running" : ""}`} />
|
||||
<Icon name={(session.tool_icon as IconName) || "terminal"} size="sm" />
|
||||
<span className="tool-name">{session.display_name || session.tool_type_name}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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();
|
||||
|
||||
...
|
||||
|
||||
<div className="project-list">
|
||||
{projects.map((project) => (
|
||||
<ProjectListItem
|
||||
key={project.id}
|
||||
project={project}
|
||||
sessions={sessions}
|
||||
deleteConfirm={deleteConfirmId === project.id}
|
||||
onEdit={() => openEdit(project)}
|
||||
onDelete={() => setDeleteConfirmId(project.id)}
|
||||
onConfirmDelete={() => void handleDelete(project.id)}
|
||||
onCancelDelete={() => setDeleteConfirmId(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
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`.
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user