Files
Developer 563ff13969 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
2026-06-16 21:20:04 +00:00

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>
  );
}
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:
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

  • 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.