# 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`.
