Surface UI complète des tickets (domaine Issue exposé « ticket »), validée QA de bout en bout : cargo build + tests backend verts, npm run build + npm test (459) verts. - Domaine : DTO Ticket, 9 events Issue* + guard isTicketEvent. - Ports : TicketGateway (+ types query/input) ajouté à Gateways. - Adapters : TauriTicketGateway réel (+ isTicketVersionConflict), wiring, et MockTicketGateway pour les tests. - Feature tickets : hooks (useTickets, useTicketDetail, useProjectAgents), TicketsPanel, TicketDetail, TicketsView, ticketMeta + tests. - ProjectsView : onglet sidebar « Tickets ». Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
411 lines
16 KiB
TypeScript
411 lines
16 KiB
TypeScript
/**
|
||
* `ProjectsView` — the top-level project surface (L2 / L11).
|
||
*
|
||
* IDE layout (full remaining height):
|
||
*
|
||
* ┌───────────────────────────────────────────────────────┐
|
||
* │ PROJECT TABS [ alpha × ][ beta × ] [+] │
|
||
* ├───────────────┬───────────────────────────────────────┤
|
||
* │ SIDEBAR ≈320px│ MAIN — LayoutGrid (fills remaining) │
|
||
* │ [Proj][Agents]│ — or welcome screen when no active │
|
||
* │ [Tmpl][Git] │ │
|
||
* │ panel body │ │
|
||
* └───────────────┴───────────────────────────────────────┘
|
||
*
|
||
* The "Projects" sidebar tab always hosts the create-project form and the
|
||
* known-projects list, ensuring they are always in the DOM (required by tests).
|
||
*
|
||
* Pure presentation: all behaviour comes from {@link useProjects}. Styling via
|
||
* `@/shared`; no inline styles, no `invoke()`.
|
||
*
|
||
* **Test-contract** — the following query hooks from `projects.test.tsx` are
|
||
* always present in the DOM regardless of which sidebar tab is active:
|
||
* - getByLabelText("project name") / ("project root") — form inputs
|
||
* - getByRole("button", { name: "Create project" }) / "Refresh" / "Open" / "close <name>"
|
||
* - getByRole("tablist") + getAllByRole("tab") + aria-selected — project tabs
|
||
* - getByText("No projects yet.") / "No open tabs." — empty states
|
||
* - getAllByRole("listitem") — known-projects list
|
||
* - getByRole("alert") — error display
|
||
*/
|
||
|
||
import { useEffect, useState } from "react";
|
||
|
||
import type { LayoutInfo } from "@/domain";
|
||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||
import { AgentsPanel } from "@/features/agents";
|
||
import { TemplatesPanel } from "@/features/templates";
|
||
import { SkillsPanel } from "@/features/skills";
|
||
import { MemoryPanel } from "@/features/memory";
|
||
import { EmbedderSettings } from "@/features/embedder";
|
||
import { PermissionsPanel } from "@/features/permissions";
|
||
import { ProjectWorkStatePanel } from "@/features/workstate";
|
||
import { TicketsView } from "@/features/tickets";
|
||
import { ConversationViewer } from "@/features/conversations";
|
||
import { GitPanel, GitGraphView } from "@/features/git";
|
||
import { Button, Input, Panel, Tabs, cn } from "@/shared";
|
||
import { useGateways } from "@/app/di";
|
||
import { ProjectContextPanel } from "./ProjectContextPanel";
|
||
import { useProjects } from "./useProjects";
|
||
|
||
type SidebarTab =
|
||
| "projects"
|
||
| "context"
|
||
| "work"
|
||
| "tickets"
|
||
| "agents"
|
||
| "templates"
|
||
| "skills"
|
||
| "permissions"
|
||
| "memory"
|
||
| "git";
|
||
|
||
const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
|
||
{ id: "projects", label: "Projects" },
|
||
{ id: "context", label: "Context" },
|
||
{ id: "work", label: "Work" },
|
||
{ id: "tickets", label: "Tickets" },
|
||
{ id: "agents", label: "Agents" },
|
||
{ id: "templates", label: "Templates" },
|
||
{ id: "skills", label: "Skills" },
|
||
{ id: "permissions", label: "Perms" },
|
||
{ id: "memory", label: "Memory" },
|
||
{ id: "git", label: "Git" },
|
||
];
|
||
|
||
export function ProjectsView() {
|
||
const vm = useProjects();
|
||
const { system } = useGateways();
|
||
const [name, setName] = useState("");
|
||
const [root, setRoot] = useState("");
|
||
const [sidebarTab, setSidebarTab] = useState<SidebarTab>("projects");
|
||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||
// The active layout (id + kind), reported by LayoutTabs — the single source of
|
||
// truth. `kind` decides whether the main area is the terminal grid or the git
|
||
// graph view.
|
||
const [activeLayout, setActiveLayout] = useState<LayoutInfo | null>(null);
|
||
// When set, the main area swaps the terminal grid for the read-only
|
||
// conversation viewer (LS7) — pure local UI state, same mechanic as the
|
||
// terminal↔gitGraph swap; **not** a backend layout kind.
|
||
const [viewerConversationId, setViewerConversationId] = useState<
|
||
string | null
|
||
>(null);
|
||
|
||
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
||
|
||
// Reset the active layout whenever the active project changes. `activeLayout`
|
||
// is only repopulated asynchronously by `LayoutTabs` (which re-fetches the new
|
||
// project's layouts). Without this reset, the stale id of the *previous*
|
||
// project would be handed to `LayoutGrid`/`GitGraphView` during the gap, and
|
||
// loading it against the new project's store fails with "not found: layout X".
|
||
useEffect(() => {
|
||
setActiveLayout(null);
|
||
setViewerConversationId(null);
|
||
}, [active?.id]);
|
||
|
||
const activeLayoutKind = activeLayout?.kind ?? "terminal";
|
||
|
||
const canCreate = name.trim().length > 0 && root.trim().length > 0 && !vm.busy;
|
||
|
||
async function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!canCreate) return;
|
||
await vm.createProject(name.trim(), root.trim());
|
||
setName("");
|
||
setRoot("");
|
||
}
|
||
|
||
async function handleBrowse() {
|
||
const picked = await system.pickFolder();
|
||
if (picked !== null) setRoot(picked);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-1 flex-col overflow-hidden">
|
||
{/* ── Error alert ── */}
|
||
{vm.error && (
|
||
<p
|
||
role="alert"
|
||
className="mx-4 mt-2 shrink-0 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||
>
|
||
{vm.error}
|
||
</p>
|
||
)}
|
||
|
||
{/* ── Project tab bar ── */}
|
||
<div className="flex shrink-0 items-center gap-1 border-b border-border bg-surface px-2 py-1">
|
||
{vm.openTabs.length === 0 ? (
|
||
<p className="px-2 text-sm text-muted">No open tabs.</p>
|
||
) : (
|
||
<Tabs
|
||
items={vm.openTabs.map((t) => ({ id: t.id, label: t.name }))}
|
||
value={vm.activeTabId}
|
||
onSelect={(id) => vm.activateTab(id)}
|
||
onClose={(id) => void vm.closeTab(id)}
|
||
className="flex-1"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── IDE body: sidebar + main ── */}
|
||
<div className="flex flex-1 overflow-hidden">
|
||
{/* ── Collapsed sidebar: a thin rail with an expand button ── */}
|
||
{sidebarCollapsed && (
|
||
<aside className="flex w-9 shrink-0 flex-col items-center border-r border-border bg-surface py-1">
|
||
<button
|
||
type="button"
|
||
aria-label="expand sidebar"
|
||
title="Expand sidebar"
|
||
onClick={() => setSidebarCollapsed(false)}
|
||
className="flex h-7 w-7 items-center justify-center rounded text-muted hover:bg-raised hover:text-content"
|
||
>
|
||
»
|
||
</button>
|
||
</aside>
|
||
)}
|
||
|
||
{/* ── Sidebar ── */}
|
||
<aside
|
||
className={cn(
|
||
"flex w-80 shrink-0 flex-col border-r border-border bg-surface",
|
||
sidebarCollapsed && "hidden",
|
||
)}
|
||
>
|
||
{/* Sidebar tab strip (no role="tablist" to avoid collision with project tabs).
|
||
The tabs wrap onto multiple rows when they exceed the sidebar width
|
||
so every label stays visible without scrolling; the collapse button
|
||
stays pinned to the top-right. */}
|
||
<div className="flex shrink-0 items-start border-b border-border">
|
||
<div className="flex min-w-0 flex-1 flex-wrap items-stretch">
|
||
{SIDEBAR_TABS.map((t) => (
|
||
<button
|
||
key={t.id}
|
||
type="button"
|
||
aria-selected={sidebarTab === t.id}
|
||
onClick={() => setSidebarTab(t.id)}
|
||
className={cn(
|
||
"shrink-0 whitespace-nowrap px-3 py-2 text-xs font-medium transition-colors",
|
||
sidebarTab === t.id
|
||
? "border-b-2 border-primary text-content"
|
||
: "text-muted hover:text-content",
|
||
)}
|
||
>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
aria-label="collapse sidebar"
|
||
title="Collapse sidebar"
|
||
onClick={() => setSidebarCollapsed(true)}
|
||
className="shrink-0 self-stretch border-l border-border px-2 text-muted hover:text-content"
|
||
>
|
||
«
|
||
</button>
|
||
</div>
|
||
|
||
{/* Sidebar panel body */}
|
||
<div className="flex-1 overflow-auto p-3">
|
||
{/* Projects panel */}
|
||
{sidebarTab === "projects" && (
|
||
<div className="flex flex-col gap-4">
|
||
{/* Create form */}
|
||
<form
|
||
onSubmit={submit}
|
||
className="flex flex-col gap-2"
|
||
>
|
||
<h3 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||
New project
|
||
</h3>
|
||
<Input
|
||
aria-label="project name"
|
||
placeholder="Project name"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
/>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
aria-label="project root"
|
||
placeholder="/absolute/project/root"
|
||
value={root}
|
||
onChange={(e) => setRoot(e.target.value)}
|
||
className="flex-1"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
aria-label="browse project folder"
|
||
onClick={() => void handleBrowse()}
|
||
disabled={vm.busy}
|
||
>
|
||
Browse…
|
||
</Button>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
type="submit"
|
||
variant="primary"
|
||
disabled={!canCreate}
|
||
className="flex-1"
|
||
>
|
||
Create project
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
onClick={() => void vm.refresh()}
|
||
disabled={vm.busy}
|
||
>
|
||
Refresh
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
|
||
{/* Known projects list */}
|
||
<Panel title="Known projects">
|
||
{vm.projects.length === 0 ? (
|
||
<p className="text-sm text-muted">No projects yet.</p>
|
||
) : (
|
||
<ul className="flex flex-col divide-y divide-border">
|
||
{vm.projects.map((p) => (
|
||
<li
|
||
key={p.id}
|
||
className="flex items-center justify-between gap-2 py-2 first:pt-0 last:pb-0"
|
||
>
|
||
<span className="flex min-w-0 flex-col gap-0.5">
|
||
<span className="font-medium text-content">{p.name}</span>
|
||
<code className="truncate text-xs text-muted">{p.root}</code>
|
||
</span>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => void vm.openProject(p.id)}
|
||
>
|
||
Open
|
||
</Button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</Panel>
|
||
</div>
|
||
)}
|
||
|
||
{/* Project context panel */}
|
||
{sidebarTab === "context" && active && (
|
||
<ProjectContextPanel projectId={active.id} />
|
||
)}
|
||
{sidebarTab === "context" && !active && (
|
||
<p className="text-sm text-muted">Open a project to edit context.</p>
|
||
)}
|
||
|
||
{/* Work-state panel */}
|
||
{sidebarTab === "work" && active && (
|
||
<ProjectWorkStatePanel
|
||
projectId={active.id}
|
||
onOpenConversation={setViewerConversationId}
|
||
/>
|
||
)}
|
||
{sidebarTab === "work" && !active && (
|
||
<p className="text-sm text-muted">Open a project to view work state.</p>
|
||
)}
|
||
|
||
{/* Tickets panel */}
|
||
{sidebarTab === "tickets" && active && (
|
||
<TicketsView projectId={active.id} />
|
||
)}
|
||
{sidebarTab === "tickets" && !active && (
|
||
<p className="text-sm text-muted">Open a project to view tickets.</p>
|
||
)}
|
||
|
||
{/* Agents panel — only rendered when active project exists */}
|
||
{sidebarTab === "agents" && active && (
|
||
<AgentsPanel projectId={active.id} projectRoot={active.root} />
|
||
)}
|
||
{sidebarTab === "agents" && !active && (
|
||
<p className="text-sm text-muted">Open a project to manage agents.</p>
|
||
)}
|
||
|
||
{/* Templates panel */}
|
||
{sidebarTab === "templates" && active && (
|
||
<TemplatesPanel projectId={active.id} />
|
||
)}
|
||
{sidebarTab === "templates" && !active && (
|
||
<p className="text-sm text-muted">Open a project to manage templates.</p>
|
||
)}
|
||
|
||
{/* Skills panel */}
|
||
{sidebarTab === "skills" && active && (
|
||
<SkillsPanel projectId={active.id} />
|
||
)}
|
||
{sidebarTab === "skills" && !active && (
|
||
<p className="text-sm text-muted">Open a project to manage skills.</p>
|
||
)}
|
||
|
||
{/* Permissions panel */}
|
||
{sidebarTab === "permissions" && active && (
|
||
<PermissionsPanel projectId={active.id} />
|
||
)}
|
||
{sidebarTab === "permissions" && !active && (
|
||
<p className="text-sm text-muted">Open a project to manage permissions.</p>
|
||
)}
|
||
|
||
{/* Memory panel + embedder settings */}
|
||
{sidebarTab === "memory" && active && (
|
||
<div className="flex flex-col gap-4">
|
||
<MemoryPanel projectId={active.id} />
|
||
<EmbedderSettings />
|
||
</div>
|
||
)}
|
||
{sidebarTab === "memory" && !active && (
|
||
<p className="text-sm text-muted">Open a project to manage memory.</p>
|
||
)}
|
||
|
||
{/* Git panel */}
|
||
{sidebarTab === "git" && active && (
|
||
<GitPanel projectId={active.id} />
|
||
)}
|
||
{sidebarTab === "git" && !active && (
|
||
<p className="text-sm text-muted">Open a project to manage git.</p>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
|
||
{/* ── Main: terminal grid or git graph (fills remaining height) ── */}
|
||
<main className="flex flex-1 flex-col overflow-hidden">
|
||
{active && viewerConversationId ? (
|
||
<ConversationViewer
|
||
key={`${active.id}-${viewerConversationId}`}
|
||
projectId={active.id}
|
||
conversationId={viewerConversationId}
|
||
onClose={() => setViewerConversationId(null)}
|
||
/>
|
||
) : active ? (
|
||
<>
|
||
<LayoutTabs
|
||
projectId={active.id}
|
||
onActiveLayoutChange={setActiveLayout}
|
||
/>
|
||
{activeLayoutKind === "gitGraph" ? (
|
||
<GitGraphView
|
||
key={`${active.id}-${activeLayout?.id ?? "default"}-graph`}
|
||
projectId={active.id}
|
||
/>
|
||
) : (
|
||
<LayoutGrid
|
||
key={`${active.id}-${activeLayout?.id ?? "default"}`}
|
||
projectId={active.id}
|
||
cwd={active.root}
|
||
layoutId={activeLayout?.id}
|
||
onOpenConversation={setViewerConversationId}
|
||
/>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="flex flex-1 items-center justify-center text-muted">
|
||
<p className="text-sm">Select or create a project to get started.</p>
|
||
</div>
|
||
)}
|
||
</main>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|