Merge feature/tickets-window into develop

Lot C du sprint UI rework : fenêtre dédiée de gestion des tickets (#17).
Clôt le sprint UI rework (#16, #18, #19, #17 intégrés).
Tests verts (tsc clean, suite complète 530/530).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 06:35:26 +02:00
4 changed files with 178 additions and 55 deletions

View File

@ -12,10 +12,11 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { TicketLinkKind } from "@/domain"; import type { TicketLinkKind } from "@/domain";
import { Button, Input, Spinner, cn } from "@/shared"; import { Button, FloatingWindow, Input, Spinner, cn } from "@/shared";
import { useTicketDetail } from "./useTicketDetail"; import { useTicketDetail } from "./useTicketDetail";
import { useProjectAgents } from "./useProjectAgents"; import { useProjectAgents } from "./useProjectAgents";
import { TicketAssistantPanel } from "./TicketAssistantPanel"; import { TicketAssistantPanel } from "./TicketAssistantPanel";
import { TicketPicker } from "./TicketPicker";
import { import {
PriorityBadge, PriorityBadge,
StatusBadge, StatusBadge,
@ -98,7 +99,6 @@ export function TicketDetail({
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [carnet, setCarnet] = useState(""); const [carnet, setCarnet] = useState("");
const [linkTarget, setLinkTarget] = useState("");
const [linkKind, setLinkKind] = useState<TicketLinkKind>("relatesTo"); const [linkKind, setLinkKind] = useState<TicketLinkKind>("relatesTo");
const [assignPick, setAssignPick] = useState(""); const [assignPick, setAssignPick] = useState("");
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@ -106,6 +106,11 @@ export function TicketDetail({
const [confirmClose, setConfirmClose] = useState(false); const [confirmClose, setConfirmClose] = useState(false);
// Confirmation shown before deleting the ticket (#6). // Confirmation shown before deleting the ticket (#6).
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
// Add-link TicketPicker open state (#17): replaces the manual `#id` input.
const [showLinkPicker, setShowLinkPicker] = useState(false);
// AI writing-assistant window open state (#17): opened from the bottom-right
// floating button so the assistant is an obvious, first-class affordance.
const [showAssistant, setShowAssistant] = useState(false);
// The ticket was deleted (by this view or another actor): close the surface. // The ticket was deleted (by this view or another actor): close the surface.
// The removal from lists flows from the same `issueDeleted` event (#6). // The removal from lists flows from the same `issueDeleted` event (#6).
@ -187,14 +192,14 @@ export function TicketDetail({
} }
return ( return (
<div <FloatingWindow
className="fixed inset-0 z-50 flex flex-col bg-canvas" open
role="dialog" title={`ticket ${ticketRef}`}
aria-modal="true" size="xl"
aria-label={`ticket ${ticketRef}`} onClose={requestClose}
> >
{/* ── Header ── */} {/* ── Toolbar (badges + ticket-level actions) ── */}
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-3"> <div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-3">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<TicketRef ticketRef={ticketRef} /> <TicketRef ticketRef={ticketRef} />
{t && <StatusBadge status={t.status} />} {t && <StatusBadge status={t.status} />}
@ -223,16 +228,13 @@ export function TicketDetail({
> >
Supprimer Supprimer
</Button> </Button>
<Button size="sm" variant="ghost" onClick={requestClose}>
Close
</Button>
</div> </div>
</header> </div>
{vm.conflict && ( {vm.conflict && (
<p <p
role="alert" role="alert"
className="shrink-0 border-b border-warning/40 bg-warning/10 px-5 py-2 text-sm text-warning" className="border-b border-warning/40 bg-warning/10 px-1 py-2 text-sm text-warning"
> >
This ticket was modified elsewhere and reloaded. Re-apply your change. This ticket was modified elsewhere and reloaded. Re-apply your change.
</p> </p>
@ -240,23 +242,23 @@ export function TicketDetail({
{vm.error && !vm.conflict && ( {vm.error && !vm.conflict && (
<p <p
role="alert" role="alert"
className="shrink-0 border-b border-danger/40 bg-danger/10 px-5 py-2 text-sm text-danger" className="border-b border-danger/40 bg-danger/10 px-1 py-2 text-sm text-danger"
> >
{vm.error} {vm.error}
</p> </p>
)} )}
{vm.busy && !t ? ( {vm.busy && !t ? (
<div className="flex flex-1 items-center gap-2 p-5 text-sm text-muted"> <div className="flex items-center gap-2 py-5 text-sm text-muted">
<Spinner size={14} /> <Spinner size={14} />
<span>Loading ticket</span> <span>Loading ticket</span>
</div> </div>
) : !t ? ( ) : !t ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted"> <div className="flex items-center justify-center py-10 text-sm text-muted">
Ticket not found. Ticket not found.
</div> </div>
) : ( ) : (
<div className="flex-1 overflow-auto"> <div className="-mx-4">
{/* ── Fields (F3) ── */} {/* ── Fields (F3) ── */}
<Section title="Details"> <Section title="Details">
<label className="text-xs font-medium text-muted" htmlFor="td-title"> <label className="text-xs font-medium text-muted" htmlFor="td-title">
@ -421,6 +423,8 @@ export function TicketDetail({
))} ))}
</ul> </ul>
)} )}
{/* Add a link via the TicketPicker popup (#17): the kind is chosen
here, the target ticket is selected in the popup (no manual #id). */}
<div className="mt-1 flex flex-wrap items-center gap-2"> <div className="mt-1 flex flex-wrap items-center gap-2">
<select <select
aria-label="link kind" aria-label="link kind"
@ -435,23 +439,11 @@ export function TicketDetail({
</option> </option>
))} ))}
</select> </select>
<Input
aria-label="link target ref"
placeholder="#id"
className="w-24"
value={linkTarget}
onChange={(e) => setLinkTarget(e.target.value)}
/>
<Button <Button
size="sm" size="sm"
disabled={!linkTarget.trim() || vm.busy} aria-label="add link"
onClick={async () => { disabled={vm.busy}
const target = linkTarget.trim().startsWith("#") onClick={() => setShowLinkPicker(true)}
? linkTarget.trim()
: `#${linkTarget.trim()}`;
const ok = await vm.link(target, linkKind);
if (ok) setLinkTarget("");
}}
> >
Add link Add link
</Button> </Button>
@ -518,16 +510,48 @@ export function TicketDetail({
</div> </div>
</Section> </Section>
{/* ── AI assistant chat (#8, F2) ── */} {/* ── Add-link picker (#17) — nested (token 60) over this window ── */}
<Section <TicketPicker
title="Assistant IA" open={showLinkPicker}
hint="Conversez avec un agent IA pour faire évoluer ce ticket. L'agent lit le projet en lecture seule et ne peut éditer que ce ticket." projectId={projectId}
> title="Lier un ticket"
<TicketAssistantPanel projectId={projectId} ticketRef={ticketRef} /> selectionMode="single"
</Section> excludeRefs={[ticketRef, ...t.links.map((l) => l.targetRef)]}
onSelect={(result) => {
const picked = Array.isArray(result) ? result[0] : result;
if (picked) void vm.link(picked.ref, linkKind);
setShowLinkPicker(false);
}}
onClose={() => setShowLinkPicker(false)}
/>
</div> </div>
)} )}
{/* ── Floating "Assistant IA" button (#17), bottom-right of the window.
Made prominent per the explicit user request; opens the ticket-writing
AI assistant in a nested window. Sticks to the window's bottom edge. ── */}
{t && (
<div className="pointer-events-none sticky bottom-0 z-10 -mx-4 -mb-4 flex justify-end px-4 pb-4 pt-2">
<Button
aria-label="ouvrir l'assistant IA"
className="pointer-events-auto shadow-lg"
onClick={() => setShowAssistant(true)}
>
🤖 Assistant IA
</Button>
</div>
)}
{/* AI writing assistant, in a nested window (token 60) above this one. */}
<FloatingWindow
open={showAssistant}
title={`Assistant IA — ${ticketRef}`}
size="lg"
onClose={() => setShowAssistant(false)}
>
<TicketAssistantPanel projectId={projectId} ticketRef={ticketRef} />
</FloatingWindow>
{/* ── Unsaved-changes confirmation on close (#9) ── */} {/* ── Unsaved-changes confirmation on close (#9) ── */}
{confirmClose && ( {confirmClose && (
<div <div
@ -623,6 +647,6 @@ export function TicketDetail({
</div> </div>
</div> </div>
)} )}
</div> </FloatingWindow>
); );
} }

View File

@ -645,47 +645,96 @@ describe("TicketsView", () => {
); );
const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` }); const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
expect(within(detail).getByText("Assistant IA")).toBeTruthy();
const profileSelect = await within(detail).findByLabelText( // The assistant is opened from the prominent bottom-right floating button;
// it is not inline anymore (#17).
expect(within(detail).queryByLabelText("profil de l'assistant")).toBeNull();
fireEvent.click(within(detail).getByLabelText("ouvrir l'assistant IA"));
// It opens in its own nested window.
const assistant = await screen.findByRole("dialog", {
name: `Assistant IA — ${t.ref}`,
});
const profileSelect = await within(assistant).findByLabelText(
"profil de l'assistant", "profil de l'assistant",
); );
expect(within(detail).getByText("Ouvrir la conversation")).toBeTruthy(); expect(within(assistant).getByText("Ouvrir la conversation")).toBeTruthy();
fireEvent.change(profileSelect, { target: { value: "qa-assistant" } }); fireEvent.change(profileSelect, { target: { value: "qa-assistant" } });
fireEvent.click(within(detail).getByText("Ouvrir la conversation")); fireEvent.click(within(assistant).getByText("Ouvrir la conversation"));
expect( expect(
await within(detail).findByLabelText("message à l'assistant"), await within(assistant).findByLabelText("message à l'assistant"),
).toBeTruthy(); ).toBeTruthy();
fireEvent.change(within(detail).getByLabelText("message à l'assistant"), { fireEvent.change(within(assistant).getByLabelText("message à l'assistant"), {
target: { value: "Crée un plan de correction" }, target: { value: "Crée un plan de correction" },
}); });
fireEvent.click(within(detail).getByText("Envoyer")); fireEvent.click(within(assistant).getByText("Envoyer"));
expect(await within(detail).findByText("Crée un plan de correction")).toBeTruthy();
expect( expect(
await within(detail).findByText( await within(assistant).findByText("Crée un plan de correction"),
).toBeTruthy();
expect(
await within(assistant).findByText(
"Assistant: reçu « Crée un plan de correction ».", "Assistant: reçu « Crée un plan de correction ».",
), ),
).toBeTruthy(); ).toBeTruthy();
fireEvent.click(within(detail).getByText("Fermer la conversation")); fireEvent.click(within(assistant).getByText("Fermer la conversation"));
await waitFor(() => await waitFor(() =>
expect( expect(
within(detail).queryByLabelText("message à l'assistant"), within(assistant).queryByLabelText("message à l'assistant"),
).toBeNull(), ).toBeNull(),
); );
expect(within(detail).getByText("Ouvrir la conversation")).toBeTruthy(); expect(within(assistant).getByText("Ouvrir la conversation")).toBeTruthy();
expect( expect(
within(detail).queryByText("Crée un plan de correction"), within(assistant).queryByText("Crée un plan de correction"),
).toBeNull(); ).toBeNull();
expect( expect(
within(detail).queryByText("Assistant: reçu « Crée un plan de correction »."), within(assistant).queryByText(
"Assistant: reçu « Crée un plan de correction ».",
),
).toBeNull(); ).toBeNull();
}); });
it("adds a link via the TicketPicker popup instead of a manual #id (#17)", async () => {
const a = await ticket.create(PROJECT_ID, { title: "Source ticket" });
const b = await ticket.create(PROJECT_ID, { title: "Target ticket" });
const gateways = { ticket, system, agent } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<TicketDetail
projectId={PROJECT_ID}
ticketRef={a.ref}
onClose={() => {}}
onOpenRef={() => {}}
/>
</DIProvider>,
);
const detail = await screen.findByRole("dialog", { name: `ticket ${a.ref}` });
// The old manual "#id" input is gone.
expect(within(detail).queryByLabelText("link target ref")).toBeNull();
// Choose the link kind, then open the picker and select the target ticket.
fireEvent.change(within(detail).getByLabelText("link kind"), {
target: { value: "blocks" },
});
fireEvent.click(within(detail).getByLabelText("add link"));
// The picker excludes the ticket itself; the target is offered.
expect(screen.queryByLabelText(`select ticket ${a.ref}`)).toBeNull();
fireEvent.click(await screen.findByLabelText(`select ticket ${b.ref}`));
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, a.ref);
expect(fresh.links).toEqual([{ targetRef: b.ref, kind: "blocks" }]);
});
});
}); });
describe("SprintManager (#11)", () => { describe("SprintManager (#11)", () => {

View File

@ -7,6 +7,41 @@ import { MenuBar } from "./MenuBar";
import { zIndex } from "./zIndex"; import { zIndex } from "./zIndex";
describe("FloatingWindow (#16, G1)", () => { describe("FloatingWindow (#16, G1)", () => {
it("Escape closes only the topmost window when stacked (#17)", () => {
const onCloseOuter = vi.fn();
const onCloseInner = vi.fn();
const { rerender } = render(
<>
<FloatingWindow open title="Outer" onClose={onCloseOuter}>
<p>outer body</p>
</FloatingWindow>
<FloatingWindow open title="Inner" onClose={onCloseInner}>
<p>inner body</p>
</FloatingWindow>
</>,
);
// The inner (last-mounted) window is topmost: Escape closes only it.
fireEvent.keyDown(document, { key: "Escape" });
expect(onCloseInner).toHaveBeenCalledTimes(1);
expect(onCloseOuter).not.toHaveBeenCalled();
// Unmount the inner window; the outer becomes topmost and now handles Escape.
rerender(
<>
<FloatingWindow open title="Outer" onClose={onCloseOuter}>
<p>outer body</p>
</FloatingWindow>
<FloatingWindow open={false} title="Inner" onClose={onCloseInner}>
<p>inner body</p>
</FloatingWindow>
</>,
);
fireEvent.keyDown(document, { key: "Escape" });
expect(onCloseOuter).toHaveBeenCalledTimes(1);
expect(onCloseInner).toHaveBeenCalledTimes(1);
});
it("renders nothing when closed", () => { it("renders nothing when closed", () => {
const { container } = render( const { container } = render(
<FloatingWindow open={false} title="Panel" onClose={vi.fn()}> <FloatingWindow open={false} title="Panel" onClose={vi.fn()}>

View File

@ -34,6 +34,13 @@ const SIZE_CLASS: Record<FloatingWindowSize, string> = {
const FOCUSABLE = const FOCUSABLE =
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'; 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
/**
* Stack of currently-mounted windows (most-recent last). Only the topmost window
* reacts to Escape and traps Tab, so a window opened over another (e.g. a ticket
* detail over the tickets list) doesn't close/steal from the one beneath it.
*/
const windowStack: object[] = [];
export interface FloatingWindowProps { export interface FloatingWindowProps {
/** Whether the window is shown. Rendered as `null` when closed. */ /** Whether the window is shown. Rendered as `null` when closed. */
open: boolean; open: boolean;
@ -76,11 +83,17 @@ function FloatingWindowBody({
useEffect(() => { useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null; const previouslyFocused = document.activeElement as HTMLElement | null;
const node = dialogRef.current; const node = dialogRef.current;
// Register on the window stack so only the topmost handles keys.
const token = {};
windowStack.push(token);
const isTopmost = () => windowStack[windowStack.length - 1] === token;
// Move focus into the window (first focusable, else the dialog itself). // Move focus into the window (first focusable, else the dialog itself).
const first = node?.querySelector<HTMLElement>(FOCUSABLE); const first = node?.querySelector<HTMLElement>(FOCUSABLE);
(first ?? node)?.focus(); (first ?? node)?.focus();
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
// Ignore keys while a later window is stacked on top of this one.
if (!isTopmost()) return;
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
onClose(); onClose();
@ -111,6 +124,8 @@ function FloatingWindowBody({
document.addEventListener("keydown", onKeyDown); document.addEventListener("keydown", onKeyDown);
return () => { return () => {
document.removeEventListener("keydown", onKeyDown); document.removeEventListener("keydown", onKeyDown);
const i = windowStack.indexOf(token);
if (i >= 0) windowStack.splice(i, 1);
previouslyFocused?.focus?.(); previouslyFocused?.focus?.();
}; };
}, [onClose]); }, [onClose]);