Files
IdeA/frontend/src/features/projects/ProjectsView.tsx
Blomios c1d82eee8d feat(tickets): surface frontend V1 du système de tickets
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>
2026-07-02 22:56:36 +02:00

411 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* `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>
);
}