- 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
11 KiB
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
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
<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:
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
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
ProjectCardmap with a map ofProjectListItem:
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:
.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
ProjectListItemcomponent exists and is used in desktopProjectsPage.- 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
ProjectCardor equivalent). npm run typecheckpasses.npm run lintpasses.
6. Verification Plan
- Run
cd apps/web && npm run typecheck. - Run
cd apps/web && npm run lint. - Open Projects page on desktop.
- Verify project headers, horizontal repo lists, branch labels, workspace links, and running tool links.
- Click a workspace name → navigate to workspace detail.
- Click a running tool name → open tool.
- Resize to mobile → verify old project list/detail still works.
7. Next Phase
After approval, create tasks and delegate to sdd-apply.