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>
This commit is contained in:
2026-07-02 22:56:36 +02:00
parent 8de7be01a8
commit c1d82eee8d
16 changed files with 2150 additions and 1 deletions

View File

@ -0,0 +1,450 @@
/**
* Ticket detail / edit overlay (F3, F4, F5, F7).
*
* A full-screen dialog mirroring the ticket: editable title/description, status
* and priority, the ticket-scoped Markdown **carnet** (replaceable save, F4),
* links to other tickets (F5) and agent assignments limited to known agents
* (F5). Optimistic-concurrency conflicts surface a clear banner and reload
* (F3). `#ref`s are clickable throughout (F7), and a one-click "delegation
* context" prefill lets the user hand an agent "work on #N" (F7).
*/
import { useEffect, useState } from "react";
import type { TicketLinkKind } from "@/domain";
import { Button, Input, Spinner, cn } from "@/shared";
import { useTicketDetail } from "./useTicketDetail";
import { useProjectAgents } from "./useProjectAgents";
import {
PriorityBadge,
StatusBadge,
TICKET_LINK_KINDS,
TICKET_PRIORITIES,
TICKET_STATUSES,
TicketRef,
linkKindLabel,
linkifyTicketRefs,
priorityLabel,
statusLabel,
} from "./ticketMeta";
const selectClass = cn(
"h-9 rounded-md bg-raised px-3 text-sm text-content",
"border border-border outline-none transition-colors",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
);
const textareaClass = cn(
"w-full rounded-md bg-raised p-3 text-sm text-content",
"border border-border outline-none transition-colors",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
);
export interface TicketDetailProps {
projectId: string;
ticketRef: string;
onClose: () => void;
/** Navigate to another ticket (from a link or an inline `#ref`, F7). */
onOpenRef: (ref: string) => void;
}
async function copyToClipboard(text: string): Promise<boolean> {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
return false;
}
function Section({
title,
hint,
children,
}: {
title: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<section className="flex flex-col gap-2 border-b border-border px-5 py-4">
<div>
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
{title}
</h4>
{hint && <p className="mt-0.5 text-xs text-muted">{hint}</p>}
</div>
{children}
</section>
);
}
export function TicketDetail({
projectId,
ticketRef,
onClose,
onOpenRef,
}: TicketDetailProps) {
const vm = useTicketDetail(projectId, ticketRef);
const { agents, nameOf } = useProjectAgents(projectId);
const t = vm.ticket;
// Local draft state, re-seeded whenever the loaded ticket version changes so a
// conflict-reload or an external edit refreshes the fields.
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [carnet, setCarnet] = useState("");
const [linkTarget, setLinkTarget] = useState("");
const [linkKind, setLinkKind] = useState<TicketLinkKind>("relatesTo");
const [assignPick, setAssignPick] = useState("");
const [copied, setCopied] = useState(false);
const version = t?.version;
useEffect(() => {
if (!t) return;
setTitle(t.title);
setDescription(t.description);
setCarnet(t.carnet ?? "");
}, [t, version]);
const dirtyFields =
!!t && (title !== t.title || description !== t.description);
const dirtyCarnet = !!t && carnet !== (t.carnet ?? "");
const assignable = agents.filter((a) => !t?.assignedAgentIds.includes(a.id));
async function copyDelegation() {
if (!t) return;
const ok = await copyToClipboard(
`Travaille sur le ticket ${t.ref} : ${t.title}`,
);
if (ok) {
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
}
}
return (
<div
className="fixed inset-0 z-50 flex flex-col bg-canvas"
role="dialog"
aria-modal="true"
aria-label={`ticket ${ticketRef}`}
>
{/* ── Header ── */}
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-3">
<div className="flex min-w-0 items-center gap-2">
<TicketRef ticketRef={ticketRef} />
{t && <StatusBadge status={t.status} />}
{t && <PriorityBadge priority={t.priority} />}
<span className="truncate text-sm font-medium text-content">
{t?.title ?? ticketRef}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
size="sm"
variant="ghost"
disabled={!t}
onClick={() => void copyDelegation()}
title="Copy a delegation prompt to hand this ticket to an agent"
>
{copied ? "Copied" : "Delegation context"}
</Button>
<Button size="sm" variant="ghost" onClick={onClose}>
Close
</Button>
</div>
</header>
{vm.conflict && (
<p
role="alert"
className="shrink-0 border-b border-warning/40 bg-warning/10 px-5 py-2 text-sm text-warning"
>
This ticket was modified elsewhere and reloaded. Re-apply your change.
</p>
)}
{vm.error && !vm.conflict && (
<p
role="alert"
className="shrink-0 border-b border-danger/40 bg-danger/10 px-5 py-2 text-sm text-danger"
>
{vm.error}
</p>
)}
{vm.busy && !t ? (
<div className="flex flex-1 items-center gap-2 p-5 text-sm text-muted">
<Spinner size={14} />
<span>Loading ticket</span>
</div>
) : !t ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted">
Ticket not found.
</div>
) : (
<div className="flex-1 overflow-auto">
{/* ── Fields (F3) ── */}
<Section title="Details">
<label className="text-xs font-medium text-muted" htmlFor="td-title">
Title
</label>
<Input
id="td-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={vm.busy}
/>
<label
className="mt-2 text-xs font-medium text-muted"
htmlFor="td-desc"
>
Description
</label>
<textarea
id="td-desc"
className={cn(textareaClass, "min-h-24")}
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={vm.busy}
/>
{description.trim() && (
<p className="whitespace-pre-wrap break-words rounded-md bg-raised/40 px-3 py-2 text-xs text-muted">
{linkifyTicketRefs(description, onOpenRef)}
</p>
)}
<div className="mt-1 flex items-center gap-2">
<Button
size="sm"
disabled={!dirtyFields || vm.busy}
loading={vm.busy && dirtyFields}
onClick={() =>
void vm.updateFields({ title, description })
}
>
Save changes
</Button>
{dirtyFields && (
<Button
size="sm"
variant="ghost"
disabled={vm.busy}
onClick={() => {
setTitle(t.title);
setDescription(t.description);
}}
>
Reset
</Button>
)}
</div>
</Section>
{/* ── Status & priority (F3) ── */}
<Section title="Status & priority">
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-xs text-muted">
Status
<select
aria-label="ticket status"
className={selectClass}
value={t.status}
disabled={vm.busy}
onChange={(e) =>
void vm.updateFields({
status: e.target.value as (typeof TICKET_STATUSES)[number],
})
}
>
{TICKET_STATUSES.map((s) => (
<option key={s} value={s}>
{statusLabel(s)}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 text-xs text-muted">
Priority
<select
aria-label="ticket priority"
className={selectClass}
value={t.priority}
disabled={vm.busy}
onChange={(e) =>
void vm.updateFields({
priority:
e.target.value as (typeof TICKET_PRIORITIES)[number],
})
}
>
{TICKET_PRIORITIES.map((p) => (
<option key={p} value={p}>
{priorityLabel(p)}
</option>
))}
</select>
</label>
</div>
</Section>
{/* ── Carnet (F4) ── */}
<Section
title="Carnet"
hint="Markdown notebook scoped to this ticket only — separate from project memory. Saving replaces the whole carnet."
>
<textarea
aria-label="ticket carnet"
className={cn(textareaClass, "min-h-40 font-mono")}
value={carnet}
onChange={(e) => setCarnet(e.target.value)}
disabled={vm.busy}
/>
<div className="flex items-center gap-2">
<Button
size="sm"
disabled={!dirtyCarnet || vm.busy}
loading={vm.busy && dirtyCarnet}
onClick={() => void vm.saveCarnet(carnet)}
>
Save carnet
</Button>
{dirtyCarnet && (
<Button
size="sm"
variant="ghost"
disabled={vm.busy}
onClick={() => setCarnet(t.carnet ?? "")}
>
Reset
</Button>
)}
</div>
</Section>
{/* ── Links (F5) ── */}
<Section title="Linked tickets">
{t.links.length === 0 ? (
<p className="text-xs text-muted">No linked tickets.</p>
) : (
<ul className="flex flex-col gap-1">
{t.links.map((l) => (
<li
key={`${l.kind}-${l.targetRef}`}
className="flex items-center gap-2 text-sm"
>
<span className="text-xs text-muted">
{linkKindLabel(l.kind)}
</span>
<TicketRef ticketRef={l.targetRef} onOpen={onOpenRef} />
<Button
size="sm"
variant="ghost"
disabled={vm.busy}
onClick={() => void vm.unlink(l.targetRef, l.kind)}
>
Remove
</Button>
</li>
))}
</ul>
)}
<div className="mt-1 flex flex-wrap items-center gap-2">
<select
aria-label="link kind"
className={selectClass}
value={linkKind}
disabled={vm.busy}
onChange={(e) => setLinkKind(e.target.value as TicketLinkKind)}
>
{TICKET_LINK_KINDS.map((k) => (
<option key={k} value={k}>
{linkKindLabel(k)}
</option>
))}
</select>
<Input
aria-label="link target ref"
placeholder="#id"
className="w-24"
value={linkTarget}
onChange={(e) => setLinkTarget(e.target.value)}
/>
<Button
size="sm"
disabled={!linkTarget.trim() || vm.busy}
onClick={async () => {
const target = linkTarget.trim().startsWith("#")
? linkTarget.trim()
: `#${linkTarget.trim()}`;
const ok = await vm.link(target, linkKind);
if (ok) setLinkTarget("");
}}
>
Add link
</Button>
</div>
</Section>
{/* ── Assignments (F5) ── */}
<Section
title="Assigned agents"
hint="Only agents known to this project can be assigned."
>
{t.assignedAgentIds.length === 0 ? (
<p className="text-xs text-muted">No agents assigned.</p>
) : (
<ul className="flex flex-wrap gap-2">
{t.assignedAgentIds.map((id) => (
<li
key={id}
className="flex items-center gap-1 rounded-full bg-raised px-2 py-0.5 text-xs text-content"
>
{nameOf(id)}
<button
type="button"
aria-label={`unassign ${nameOf(id)}`}
className="text-muted hover:text-danger disabled:opacity-50"
disabled={vm.busy}
onClick={() => void vm.assign(id, false)}
>
×
</button>
</li>
))}
</ul>
)}
<div className="mt-1 flex items-center gap-2">
<select
aria-label="assign agent"
className={selectClass}
value={assignPick}
disabled={vm.busy || assignable.length === 0}
onChange={(e) => setAssignPick(e.target.value)}
>
<option value="">
{assignable.length === 0
? "No more agents"
: "Select an agent…"}
</option>
{assignable.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
<Button
size="sm"
disabled={!assignPick || vm.busy}
onClick={async () => {
const ok = await vm.assign(assignPick, true);
if (ok) setAssignPick("");
}}
>
Assign
</Button>
</div>
</Section>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,256 @@
/**
* Tickets list panel (F2) — the project's ticket board in the sidebar.
*
* Filterable by status / priority / assignee + free-text search; each row shows
* the `#ref`, title, status, priority and assigned agents. Clicking a row (or
* its `#ref`) opens the detail via `onOpen`. Refreshes live on `Issue*` events
* through {@link useTickets}.
*/
import { useState } from "react";
import type { TicketPriority, TicketStatus } from "@/domain";
import { Button, Input, Panel, Spinner, cn } from "@/shared";
import { useTickets } from "./useTickets";
import { useProjectAgents } from "./useProjectAgents";
import {
PriorityBadge,
StatusBadge,
TICKET_PRIORITIES,
TICKET_STATUSES,
TicketRef,
priorityLabel,
statusLabel,
} from "./ticketMeta";
const selectClass = cn(
"h-8 rounded-md bg-raised px-2 text-xs text-content",
"border border-border outline-none transition-colors",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
);
export interface TicketsPanelProps {
projectId: string;
/** Opens the detail overlay for the given `#ref` (F7). */
onOpen: (ref: string) => void;
}
export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
const vm = useTickets(projectId);
const { agents, nameOf } = useProjectAgents(projectId);
const [showCreate, setShowCreate] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [newPriority, setNewPriority] = useState<TicketPriority>("medium");
const items = vm.list?.items ?? [];
async function submitCreate(e: React.FormEvent) {
e.preventDefault();
if (!newTitle.trim()) return;
const created = await vm.create({
title: newTitle.trim(),
priority: newPriority,
});
if (created) {
setNewTitle("");
setNewPriority("medium");
setShowCreate(false);
onOpen(created.ref);
}
}
return (
<Panel
title="Tickets"
actions={
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => setShowCreate((v) => !v)}
>
{showCreate ? "Cancel" : "New"}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => void vm.refresh()}
loading={vm.busy}
>
Refresh
</Button>
</div>
}
>
{vm.error && (
<p
role="alert"
className="mb-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
{vm.error}
</p>
)}
{showCreate && (
<form
onSubmit={submitCreate}
className="mb-3 flex flex-col gap-2 rounded-md border border-border bg-raised/50 p-2"
>
<Input
aria-label="new ticket title"
placeholder="Ticket title"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
/>
<div className="flex items-center gap-2">
<select
aria-label="new ticket priority"
className={selectClass}
value={newPriority}
onChange={(e) => setNewPriority(e.target.value as TicketPriority)}
>
{TICKET_PRIORITIES.map((p) => (
<option key={p} value={p}>
{priorityLabel(p)}
</option>
))}
</select>
<Button
type="submit"
size="sm"
disabled={!newTitle.trim() || vm.busy}
>
Create
</Button>
</div>
</form>
)}
{/* ── Filters ── */}
<div className="mb-3 flex flex-col gap-2">
<Input
aria-label="search tickets"
placeholder="Search title/description…"
value={vm.query.text ?? ""}
onChange={(e) =>
vm.setQuery({ ...vm.query, text: e.target.value || undefined })
}
/>
<div className="flex flex-wrap items-center gap-2">
<select
aria-label="filter by status"
className={selectClass}
value={vm.query.status ?? ""}
onChange={(e) =>
vm.setQuery({
...vm.query,
status: (e.target.value || undefined) as TicketStatus | undefined,
})
}
>
<option value="">All statuses</option>
{TICKET_STATUSES.map((s) => (
<option key={s} value={s}>
{statusLabel(s)}
</option>
))}
</select>
<select
aria-label="filter by priority"
className={selectClass}
value={vm.query.priority ?? ""}
onChange={(e) =>
vm.setQuery({
...vm.query,
priority: (e.target.value || undefined) as
| TicketPriority
| undefined,
})
}
>
<option value="">All priorities</option>
{TICKET_PRIORITIES.map((p) => (
<option key={p} value={p}>
{priorityLabel(p)}
</option>
))}
</select>
<select
aria-label="filter by assignee"
className={selectClass}
value={vm.query.assignedAgentId ?? ""}
onChange={(e) =>
vm.setQuery({
...vm.query,
assignedAgentId: e.target.value || undefined,
})
}
>
<option value="">All assignees</option>
{agents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</div>
</div>
{/* ── List ── */}
{vm.busy && vm.list === null ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>Loading tickets</span>
</div>
) : items.length === 0 ? (
<p className="text-sm text-muted">No tickets.</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{items.map((t) => (
<li key={t.ref} className="py-2 first:pt-0 last:pb-0">
<div className="flex items-start gap-2">
<TicketRef ticketRef={t.ref} onOpen={onOpen} className="mt-0.5" />
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => onOpen(t.ref)}
>
<span className="block truncate text-sm font-medium text-content hover:underline">
{t.title}
</span>
{t.assignedAgentIds.length > 0 && (
<span className="mt-0.5 block truncate text-xs text-muted">
{t.assignedAgentIds.map(nameOf).join(", ")}
</span>
)}
</button>
<span className="flex shrink-0 flex-col items-end gap-1">
<StatusBadge status={t.status} />
<PriorityBadge priority={t.priority} />
</span>
</div>
</li>
))}
</ul>
)}
{vm.list?.nextCursor && (
<div className="mt-3 flex justify-center">
<Button
size="sm"
variant="ghost"
loading={vm.busy}
onClick={() =>
vm.setQuery({
...vm.query,
limit: (vm.query.limit ?? 100) + 100,
})
}
>
Load more
</Button>
</div>
)}
</Panel>
);
}

View File

@ -0,0 +1,34 @@
/**
* Tickets feature container: the sidebar list (F2) plus the full-screen detail
* overlay (F3/F4/F5/F7). Owns the currently-open `#ref` so a click in the list
* — or on any inline `#ref` inside the detail — swaps the overlay to that
* ticket.
*/
import { useState } from "react";
import { TicketsPanel } from "./TicketsPanel";
import { TicketDetail } from "./TicketDetail";
export interface TicketsViewProps {
projectId: string;
}
export function TicketsView({ projectId }: TicketsViewProps) {
const [openRef, setOpenRef] = useState<string | null>(null);
return (
<>
<TicketsPanel projectId={projectId} onOpen={setOpenRef} />
{openRef && (
<TicketDetail
key={`${projectId}-${openRef}`}
projectId={projectId}
ticketRef={openRef}
onClose={() => setOpenRef(null)}
onOpenRef={setOpenRef}
/>
)}
</>
);
}

View File

@ -0,0 +1,22 @@
/** Tickets feature (F1F5, F7): project ticket board, detail/edit, carnet,
* links & assignments, and clickable `#ref` navigation. */
export { TicketsView } from "./TicketsView";
export type { TicketsViewProps } from "./TicketsView";
export { TicketsPanel } from "./TicketsPanel";
export type { TicketsPanelProps } from "./TicketsPanel";
export { TicketDetail } from "./TicketDetail";
export type { TicketDetailProps } from "./TicketDetail";
export { useTickets } from "./useTickets";
export type { TicketsViewModel } from "./useTickets";
export { useTicketDetail } from "./useTicketDetail";
export type { TicketDetailViewModel } from "./useTicketDetail";
export {
TicketRef,
StatusBadge,
PriorityBadge,
linkifyTicketRefs,
statusLabel,
priorityLabel,
linkKindLabel,
} from "./ticketMeta";

View File

@ -0,0 +1,169 @@
/**
* Shared presentational helpers for tickets: human labels, badge styling, and
* the clickable `#ref` element (F7). Kept pure/presentational so both the list
* and the detail surfaces render tickets consistently.
*/
import type { ReactNode } from "react";
import type { TicketLinkKind, TicketPriority, TicketStatus } from "@/domain";
import { cn } from "@/shared";
export const TICKET_STATUSES: TicketStatus[] = [
"open",
"inProgress",
"QA",
"closed",
];
export const TICKET_PRIORITIES: TicketPriority[] = [
"low",
"medium",
"high",
"critical",
];
export const TICKET_LINK_KINDS: TicketLinkKind[] = [
"relatesTo",
"blocks",
"blockedBy",
"duplicates",
"dependsOn",
];
export function statusLabel(status: TicketStatus): string {
switch (status) {
case "open":
return "Open";
case "inProgress":
return "In progress";
case "QA":
return "QA";
case "closed":
return "Closed";
}
}
export function priorityLabel(priority: TicketPriority): string {
return priority.charAt(0).toUpperCase() + priority.slice(1);
}
export function linkKindLabel(kind: TicketLinkKind): string {
switch (kind) {
case "relatesTo":
return "relates to";
case "blocks":
return "blocks";
case "blockedBy":
return "blocked by";
case "duplicates":
return "duplicates";
case "dependsOn":
return "depends on";
}
}
function statusClass(status: TicketStatus): string {
switch (status) {
case "open":
return "bg-raised text-muted";
case "inProgress":
return "bg-warning/15 text-warning";
case "QA":
return "bg-primary/15 text-primary";
case "closed":
return "bg-success/15 text-success";
}
}
function priorityClass(priority: TicketPriority): string {
switch (priority) {
case "low":
return "bg-raised text-muted";
case "medium":
return "bg-primary/10 text-primary";
case "high":
return "bg-warning/15 text-warning";
case "critical":
return "bg-danger/15 text-danger";
}
}
function badgeBase(className?: string): string {
return cn(
"inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium",
className,
);
}
export function StatusBadge({ status }: { status: TicketStatus }) {
return <span className={badgeBase(statusClass(status))}>{statusLabel(status)}</span>;
}
export function PriorityBadge({ priority }: { priority: TicketPriority }) {
return (
<span className={badgeBase(priorityClass(priority))}>
{priorityLabel(priority)}
</span>
);
}
/**
* A clickable ticket reference badge (`#42`). Renders as a button when
* `onOpen` is provided (F7 — opens the ticket detail), else as plain text.
*/
export function TicketRef({
ticketRef,
onOpen,
className,
}: {
ticketRef: string;
onOpen?: (ref: string) => void;
className?: string;
}) {
const base = cn(
"inline-flex shrink-0 items-center rounded bg-raised px-1.5 py-0.5 font-mono text-xs text-content",
className,
);
if (!onOpen) return <code className={base}>{ticketRef}</code>;
return (
<button
type="button"
aria-label={`open ticket ${ticketRef}`}
className={cn(base, "transition-colors hover:bg-primary/15 hover:text-primary")}
onClick={() => onOpen(ticketRef)}
>
{ticketRef}
</button>
);
}
const REF_PATTERN = /#\d+/g;
/**
* Linkifies inline `#42` references inside arbitrary text (F7). Non-ref segments
* are preserved verbatim; each `#n` becomes a clickable {@link TicketRef} when
* `onOpen` is provided.
*/
export function linkifyTicketRefs(
text: string,
onOpen?: (ref: string) => void,
): ReactNode[] {
const out: ReactNode[] = [];
let last = 0;
let index = 0;
for (const match of text.matchAll(REF_PATTERN)) {
const start = match.index ?? 0;
if (start > last) out.push(text.slice(last, start));
out.push(
<TicketRef
key={`ref-${index++}-${start}`}
ticketRef={match[0]}
onOpen={onOpen}
/>,
);
last = start + match[0].length;
}
if (last < text.length) out.push(text.slice(last));
return out;
}

View File

@ -0,0 +1,245 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { DIProvider } from "@/app/di";
import {
MockAgentGateway,
MockSystemGateway,
MockTicketGateway,
} from "@/adapters/mock";
import type { Agent } from "@/domain";
import type { Gateways } from "@/ports";
import { TicketsView } from "./TicketsView";
import { TicketDetail } from "./TicketDetail";
const PROJECT_ID = "project-tickets-test";
function stubClipboard() {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });
return writeText;
}
async function seedAgent(agent: MockAgentGateway, name: string): Promise<Agent> {
return agent.createAgent(PROJECT_ID, { name, profileId: "p-1" });
}
function renderView(
ticket: MockTicketGateway,
system: MockSystemGateway,
agent: MockAgentGateway,
) {
const gateways = { ticket, system, agent } as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<TicketsView projectId={PROJECT_ID} />
</DIProvider>,
);
}
describe("MockTicketGateway", () => {
let system: MockSystemGateway;
let ticket: MockTicketGateway;
beforeEach(() => {
system = new MockSystemGateway();
ticket = new MockTicketGateway(system);
});
it("assigns sequential #refs and emits issueCreated", async () => {
const events: string[] = [];
await system.onDomainEvent((e) => events.push(e.type));
const a = await ticket.create(PROJECT_ID, { title: "First" });
const b = await ticket.create(PROJECT_ID, { title: "Second" });
expect(a.ref).toBe("#1");
expect(b.ref).toBe("#2");
expect(b.version).toBe(1);
expect(events).toContain("issueCreated");
});
it("filters by status and searches text", async () => {
await ticket.create(PROJECT_ID, { title: "alpha bug", status: "open" });
const t2 = await ticket.create(PROJECT_ID, { title: "beta task" });
await ticket.update(PROJECT_ID, t2.ref, {
status: "closed",
expectedVersion: t2.version,
});
const open = await ticket.list(PROJECT_ID, { status: "open" });
expect(open.items.map((i) => i.ref)).toEqual(["#1"]);
const search = await ticket.list(PROJECT_ID, { text: "beta" });
expect(search.items.map((i) => i.ref)).toEqual(["#2"]);
});
it("rejects a stale write with a version conflict", async () => {
const t = await ticket.create(PROJECT_ID, { title: "x" });
await ticket.update(PROJECT_ID, t.ref, {
title: "y",
expectedVersion: t.version,
});
await expect(
ticket.update(PROJECT_ID, t.ref, {
title: "z",
expectedVersion: t.version, // stale (1, now 2)
}),
).rejects.toMatchObject({ message: expect.stringContaining("version conflict") });
});
it("links and unlinks tickets, bumping the version", async () => {
const a = await ticket.create(PROJECT_ID, { title: "a" });
await ticket.create(PROJECT_ID, { title: "b" });
const linked = await ticket.link(PROJECT_ID, a.ref, "#2", "blocks", a.version);
expect(linked.links).toEqual([{ targetRef: "#2", kind: "blocks" }]);
const unlinked = await ticket.unlink(
PROJECT_ID,
a.ref,
"#2",
linked.version,
"blocks",
);
expect(unlinked.links).toEqual([]);
});
});
describe("TicketsView", () => {
let system: MockSystemGateway;
let ticket: MockTicketGateway;
let agent: MockAgentGateway;
beforeEach(() => {
system = new MockSystemGateway();
ticket = new MockTicketGateway(system);
agent = new MockAgentGateway();
});
it("shows the empty state then a created ticket", async () => {
renderView(ticket, system, agent);
expect(await screen.findByText("No tickets.")).toBeTruthy();
await ticket.create(PROJECT_ID, { title: "Live created" });
// The panel refreshes on the issueCreated event.
expect(await screen.findByText("Live created")).toBeTruthy();
});
it("opens the detail and edits status with a version bump", async () => {
const t = await ticket.create(PROJECT_ID, { title: "Editable" });
renderView(ticket, system, agent);
fireEvent.click(await screen.findByText("Editable"));
const dialog = await screen.findByRole("dialog");
const statusSelect = within(dialog).getByLabelText("ticket status");
fireEvent.change(statusSelect, { target: { value: "inProgress" } });
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.status).toBe("inProgress");
expect(fresh.version).toBe(2);
});
});
it("saves the ticket-scoped carnet (replaceable)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "WithCarnet" });
renderView(ticket, system, agent);
fireEvent.click(await screen.findByText("WithCarnet"));
const dialog = await screen.findByRole("dialog");
fireEvent.change(within(dialog).getByLabelText("ticket carnet"), {
target: { value: "## notes\nscoped body" },
});
fireEvent.click(within(dialog).getByText("Save carnet"));
await waitFor(async () => {
const carnet = await ticket.readCarnet(PROJECT_ID, t.ref);
expect(carnet.carnet).toBe("## notes\nscoped body");
});
});
it("assigns only known project agents", async () => {
const known = await seedAgent(agent, "Backend");
const t = await ticket.create(PROJECT_ID, { title: "Assignable" });
renderView(ticket, system, agent);
fireEvent.click(await screen.findByText("Assignable"));
const dialog = await screen.findByRole("dialog");
fireEvent.change(within(dialog).getByLabelText("assign agent"), {
target: { value: known.id },
});
fireEvent.click(within(dialog).getByText("Assign"));
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.assignedAgentIds).toEqual([known.id]);
});
expect(within(dialog).getAllByText("Backend").length).toBeGreaterThan(0);
});
it("surfaces a version-conflict banner and reloads (F3)", async () => {
// No system gateway ⇒ the detail does not auto-refresh on events, so we can
// deterministically make its held version go stale under an external write.
const t = await ticket.create(PROJECT_ID, { title: "Racy" });
const gateways = { ticket, agent } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<TicketDetail
projectId={PROJECT_ID}
ticketRef={t.ref}
onClose={() => {}}
onOpenRef={() => {}}
/>
</DIProvider>,
);
const dialog = await screen.findByRole("dialog");
// The detail now holds version 1. Bump the store to version 2 behind its back.
await ticket.update(PROJECT_ID, t.ref, {
description: "external",
expectedVersion: t.version,
});
fireEvent.change(within(dialog).getByLabelText("Title"), {
target: { value: "Racy edited" },
});
fireEvent.click(within(dialog).getByText("Save changes"));
expect(
await within(dialog).findByText(/modified elsewhere and reloaded/i),
).toBeTruthy();
// After the reload the detail field reflects the reloaded (title unchanged)
// ticket rather than the abandoned local edit.
await waitFor(() =>
expect(
(within(dialog).getByLabelText("Title") as HTMLInputElement)
.value,
).toBe("Racy"),
);
});
it("copies a delegation context prompt (F7)", async () => {
const writeText = stubClipboard();
const t = await ticket.create(PROJECT_ID, { title: "Delegate me" });
renderView(ticket, system, agent);
fireEvent.click(await screen.findByText("Delegate me"));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByText("Delegation context"));
await waitFor(() =>
expect(writeText).toHaveBeenCalledWith(
`Travaille sur le ticket ${t.ref} : Delegate me`,
),
);
});
});

View File

@ -0,0 +1,47 @@
/**
* Minimal read-only list of a project's agents, used by the ticket surfaces to
* (a) resolve agent ids to names and (b) offer only *known* agents when
* assigning (F5). Deliberately lighter than the full `useAgents` view-model —
* tickets never launch or mutate agents.
*/
import { useEffect, useMemo, useState } from "react";
import type { Agent } from "@/domain";
import { useGateways } from "@/app/di";
export interface ProjectAgents {
agents: Agent[];
/** Maps an agent id to a display name, falling back to a short id. */
nameOf: (agentId: string) => string;
}
export function useProjectAgents(projectId: string): ProjectAgents {
const { agent } = useGateways();
const [agents, setAgents] = useState<Agent[]>([]);
useEffect(() => {
let cancelled = false;
void agent
.listAgents(projectId)
.then((list) => {
if (!cancelled) setAgents(list);
})
.catch(() => {
if (!cancelled) setAgents([]);
});
return () => {
cancelled = true;
};
}, [projectId, agent]);
const byId = useMemo(
() => new Map(agents.map((a) => [a.id, a.name])),
[agents],
);
const nameOf = (agentId: string): string =>
byId.get(agentId) ?? `${agentId.slice(0, 8)}`;
return { agents, nameOf };
}

View File

@ -0,0 +1,179 @@
/**
* View-model hook for a single ticket's detail/edit surface (F3/F4/F5).
*
* Owns optimistic-concurrency handling: every mutation sends the ticket's
* current `version` as `expectedVersion`. When the backend reports a version
* conflict (the ticket moved underneath — e.g. an agent edited it), the hook
* flips `conflict`, reloads the fresh ticket and surfaces a clear message so the
* user can re-apply their change against the new version.
*
* The loaded ticket always includes its carnet body (F4); mutations that return
* a carnet-less ticket preserve the last known carnet so the editor never blanks
* out unexpectedly.
*/
import { useCallback, useEffect, useState } from "react";
import { isTicketEvent, type GatewayError, type Ticket, type TicketLinkKind } from "@/domain";
import { useGateways } from "@/app/di";
export interface TicketDetailViewModel {
ticket: Ticket | null;
error: string | null;
/** Set when the last write lost an optimistic-concurrency race (F3). */
conflict: boolean;
busy: boolean;
refresh: () => Promise<void>;
updateFields: (input: {
title?: string;
description?: string;
status?: Ticket["status"];
priority?: Ticket["priority"];
}) => Promise<boolean>;
saveCarnet: (carnet: string) => Promise<boolean>;
link: (targetRef: string, kind: TicketLinkKind) => Promise<boolean>;
unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>;
assign: (agentId: string, assigned: boolean) => Promise<boolean>;
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
/** A generic `INVALID` from the backend whose message flags a version conflict. */
function isVersionConflict(e: unknown): boolean {
return (
!!e &&
typeof e === "object" &&
typeof (e as GatewayError).message === "string" &&
(e as GatewayError).message.includes("version conflict")
);
}
export function useTicketDetail(
projectId: string,
ref: string,
): TicketDetailViewModel {
const { ticket: gateway, system } = useGateways();
const [ticket, setTicket] = useState<Ticket | null>(null);
const [error, setError] = useState<string | null>(null);
const [conflict, setConflict] = useState(false);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
setBusy(true);
setError(null);
try {
setTicket(await gateway.read(projectId, ref, true));
setConflict(false);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
}, [projectId, ref, gateway]);
useEffect(() => {
void refresh();
}, [refresh]);
// Refresh when *another* actor mutates this same ticket while it is open.
useEffect(() => {
if (!system) return;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system
.onDomainEvent((event) => {
if (isTicketEvent(event) && event.issueRef === ref) void refresh();
})
.then((u) => {
if (cancelled) u();
else unsubscribe = u;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [refresh, system, ref]);
/**
* Runs one mutation, preserving the loaded carnet (mutations return a
* carnet-less ticket) and translating a version conflict into `conflict` +
* a reload so the UI can prompt a clean retry.
*/
const run = useCallback(
async (op: (version: number) => Promise<Ticket>, carnet?: string): Promise<boolean> => {
if (!ticket) return false;
setBusy(true);
setError(null);
try {
const updated = await op(ticket.version);
setTicket({
...updated,
carnet: carnet !== undefined ? carnet : ticket.carnet,
});
setConflict(false);
return true;
} catch (e) {
if (isVersionConflict(e)) {
setConflict(true);
setError(
"This ticket was modified elsewhere. It has been reloaded — re-apply your change.",
);
await refresh();
} else {
setError(describe(e));
}
return false;
} finally {
setBusy(false);
}
},
[ticket, gateway, refresh],
);
const updateFields: TicketDetailViewModel["updateFields"] = useCallback(
(input) => run((version) => gateway.update(projectId, ref, { ...input, expectedVersion: version })),
[run, gateway, projectId, ref],
);
const saveCarnet: TicketDetailViewModel["saveCarnet"] = useCallback(
(carnet) =>
run((version) => gateway.updateCarnet(projectId, ref, carnet, version), carnet),
[run, gateway, projectId, ref],
);
const link: TicketDetailViewModel["link"] = useCallback(
(targetRef, kind) =>
run((version) => gateway.link(projectId, ref, targetRef, kind, version)),
[run, gateway, projectId, ref],
);
const unlink: TicketDetailViewModel["unlink"] = useCallback(
(targetRef, kind) =>
run((version) => gateway.unlink(projectId, ref, targetRef, version, kind)),
[run, gateway, projectId, ref],
);
const assign: TicketDetailViewModel["assign"] = useCallback(
(agentId, assigned) =>
run((version) => gateway.assign(projectId, ref, agentId, assigned, version)),
[run, gateway, projectId, ref],
);
return {
ticket,
error,
conflict,
busy,
refresh,
updateFields,
saveCarnet,
link,
unlink,
assign,
};
}

View File

@ -0,0 +1,96 @@
/**
* View-model hook for the project ticket list (F2).
*
* Consumes the {@link TicketGateway} for reads/creates and subscribes to the
* `Issue*` domain events (via {@link SystemGateway}) to refresh the list on any
* ticket mutation — including those made by agents through MCP. Filters/search
* are part of the query and drive a re-fetch when they change.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { isTicketEvent, type GatewayError, type Ticket, type TicketList } from "@/domain";
import type { CreateTicketInput, TicketListQuery } from "@/ports";
import { useGateways } from "@/app/di";
export interface TicketsViewModel {
list: TicketList | null;
error: string | null;
busy: boolean;
query: TicketListQuery;
setQuery: (next: TicketListQuery) => void;
refresh: () => Promise<void>;
create: (input: CreateTicketInput) => Promise<Ticket | null>;
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
export function useTickets(projectId: string): TicketsViewModel {
const { ticket, system } = useGateways();
const [list, setList] = useState<TicketList | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [query, setQuery] = useState<TicketListQuery>({});
// Stable dependency key so `refresh` only changes when a filter actually does.
const queryKey = useMemo(() => JSON.stringify(query), [query]);
const refresh = useCallback(async () => {
setBusy(true);
setError(null);
try {
setList(await ticket.list(projectId, query));
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
// `query` is captured via `queryKey`; listing it directly would rebuild the
// callback on every render (a new object identity each time).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId, ticket, queryKey]);
const create = useCallback(
async (input: CreateTicketInput): Promise<Ticket | null> => {
setError(null);
try {
const created = await ticket.create(projectId, input);
await refresh();
return created;
} catch (e) {
setError(describe(e));
return null;
}
},
[projectId, ticket, refresh],
);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
if (!system) return;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system
.onDomainEvent((event) => {
if (isTicketEvent(event)) void refresh();
})
.then((u) => {
if (cancelled) u();
else unsubscribe = u;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [refresh, system]);
return { list, error, busy, query, setQuery, refresh, create };
}