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:
Developer
2026-06-16 21:20:04 +00:00
parent 39bac24100
commit 563ff13969
6 changed files with 958 additions and 34 deletions
@@ -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 (
<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}
type="button"
>
<Icon name="delete" size="sm" /> Delete
</button>
<button
className="btn btn-sm btn-secondary"
onClick={onCancelDelete}
type="button"
>
<Icon name="cancel" size="sm" /> Cancel
</button>
</div>
) : (
<>
<button
className="btn btn-sm btn-secondary"
onClick={onEdit}
type="button"
>
<Icon name="edit" size="sm" /> Edit
</button>
<button
className="btn btn-sm btn-danger"
onClick={onDelete}
type="button"
>
<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>
);
};
function ProjectRepoItem({
repo,
project,
sessions,
}: {
repo: RepositorySummary;
project: ProjectWithRepos;
sessions: Session[];
}) {
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]);
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>
);
}
+5 -34
View File
@@ -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 && (
<div className="project-list">
{projects.map((project) => (
<ProjectCard
<ProjectListItem
key={project.id}
project={project}
expanded={expandedProject === project.id}
sessions={sessions}
deleteConfirm={deleteConfirmId === project.id}
workspaceLoading={workspaceLoading}
showCreateForm={
creatingWorkspace?.projectId === project.id
? creatingWorkspace.repoId
: null
}
onToggle={() =>
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();
}}
/>
))}
</div>
+148
View File
@@ -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;