diff --git a/frontend/src/features/tickets/TicketDetail.tsx b/frontend/src/features/tickets/TicketDetail.tsx index 398f091..8e47759 100644 --- a/frontend/src/features/tickets/TicketDetail.tsx +++ b/frontend/src/features/tickets/TicketDetail.tsx @@ -12,10 +12,11 @@ import { useEffect, useRef, useState } from "react"; 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 { useProjectAgents } from "./useProjectAgents"; import { TicketAssistantPanel } from "./TicketAssistantPanel"; +import { TicketPicker } from "./TicketPicker"; import { PriorityBadge, StatusBadge, @@ -98,7 +99,6 @@ export function TicketDetail({ const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [carnet, setCarnet] = useState(""); - const [linkTarget, setLinkTarget] = useState(""); const [linkKind, setLinkKind] = useState("relatesTo"); const [assignPick, setAssignPick] = useState(""); const [copied, setCopied] = useState(false); @@ -106,6 +106,11 @@ export function TicketDetail({ const [confirmClose, setConfirmClose] = useState(false); // Confirmation shown before deleting the ticket (#6). 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 removal from lists flows from the same `issueDeleted` event (#6). @@ -187,14 +192,14 @@ export function TicketDetail({ } return ( -
- {/* ── Header ── */} -
+ {/* ── Toolbar (badges + ticket-level actions) ── */} +
{t && } @@ -223,16 +228,13 @@ export function TicketDetail({ > Supprimer -
-
+
{vm.conflict && (

This ticket was modified elsewhere and reloaded. Re-apply your change.

@@ -240,23 +242,23 @@ export function TicketDetail({ {vm.error && !vm.conflict && (

{vm.error}

)} {vm.busy && !t ? ( -
+
Loading ticket…
) : !t ? ( -
+
Ticket not found.
) : ( -
+
{/* ── Fields (F3) ── */}
- {/* ── AI assistant chat (#8, F2) ── */} -
- -
+ {/* ── Add-link picker (#17) — nested (token 60) over this window ── */} + 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)} + />
)} + {/* ── 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 && ( +
+ +
+ )} + + {/* AI writing assistant, in a nested window (token 60) above this one. */} + setShowAssistant(false)} + > + + + {/* ── Unsaved-changes confirmation on close (#9) ── */} {confirmClose && (
)} -
+ ); } diff --git a/frontend/src/features/tickets/tickets.test.tsx b/frontend/src/features/tickets/tickets.test.tsx index 10880ff..0e0537c 100644 --- a/frontend/src/features/tickets/tickets.test.tsx +++ b/frontend/src/features/tickets/tickets.test.tsx @@ -645,47 +645,96 @@ describe("TicketsView", () => { ); 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", ); - expect(within(detail).getByText("Ouvrir la conversation")).toBeTruthy(); + expect(within(assistant).getByText("Ouvrir la conversation")).toBeTruthy(); fireEvent.change(profileSelect, { target: { value: "qa-assistant" } }); - fireEvent.click(within(detail).getByText("Ouvrir la conversation")); + fireEvent.click(within(assistant).getByText("Ouvrir la conversation")); expect( - await within(detail).findByLabelText("message à l'assistant"), + await within(assistant).findByLabelText("message à l'assistant"), ).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" }, }); - 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( - 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 ».", ), ).toBeTruthy(); - fireEvent.click(within(detail).getByText("Fermer la conversation")); + fireEvent.click(within(assistant).getByText("Fermer la conversation")); await waitFor(() => expect( - within(detail).queryByLabelText("message à l'assistant"), + within(assistant).queryByLabelText("message à l'assistant"), ).toBeNull(), ); - expect(within(detail).getByText("Ouvrir la conversation")).toBeTruthy(); + expect(within(assistant).getByText("Ouvrir la conversation")).toBeTruthy(); expect( - within(detail).queryByText("Crée un plan de correction"), + within(assistant).queryByText("Crée un plan de correction"), ).toBeNull(); 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(); }); + + 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( + + {}} + onOpenRef={() => {}} + /> + , + ); + + 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)", () => { diff --git a/frontend/src/shared/ui/FloatingWindow.test.tsx b/frontend/src/shared/ui/FloatingWindow.test.tsx index 596ad7e..d50af5b 100644 --- a/frontend/src/shared/ui/FloatingWindow.test.tsx +++ b/frontend/src/shared/ui/FloatingWindow.test.tsx @@ -7,6 +7,41 @@ import { MenuBar } from "./MenuBar"; import { zIndex } from "./zIndex"; 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( + <> + +

outer body

+
+ +

inner body

+
+ , + ); + + // 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( + <> + +

outer body

+
+ +

inner body

+
+ , + ); + fireEvent.keyDown(document, { key: "Escape" }); + expect(onCloseOuter).toHaveBeenCalledTimes(1); + expect(onCloseInner).toHaveBeenCalledTimes(1); + }); + it("renders nothing when closed", () => { const { container } = render( diff --git a/frontend/src/shared/ui/FloatingWindow.tsx b/frontend/src/shared/ui/FloatingWindow.tsx index 4aed8fe..39dee57 100644 --- a/frontend/src/shared/ui/FloatingWindow.tsx +++ b/frontend/src/shared/ui/FloatingWindow.tsx @@ -34,6 +34,13 @@ const SIZE_CLASS: Record = { const FOCUSABLE = '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 { /** Whether the window is shown. Rendered as `null` when closed. */ open: boolean; @@ -76,11 +83,17 @@ function FloatingWindowBody({ useEffect(() => { const previouslyFocused = document.activeElement as HTMLElement | null; 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). const first = node?.querySelector(FOCUSABLE); (first ?? node)?.focus(); function onKeyDown(e: KeyboardEvent) { + // Ignore keys while a later window is stacked on top of this one. + if (!isTopmost()) return; if (e.key === "Escape") { e.preventDefault(); onClose(); @@ -111,6 +124,8 @@ function FloatingWindowBody({ document.addEventListener("keydown", onKeyDown); return () => { document.removeEventListener("keydown", onKeyDown); + const i = windowStack.indexOf(token); + if (i >= 0) windowStack.splice(i, 1); previouslyFocused?.focus?.(); }; }, [onClose]);