feat(tickets): fenêtre dédiée de gestion des tickets — liens via popup + assistant IA flottant (#17)

Lot C du sprint UI rework : fenêtre dédiée de gestion des tickets,
appuyée sur la primitive FloatingWindow (#16) et la popup TicketPicker (#18).

- TicketDetail : gestion des liens de ticket via la popup TicketPicker,
  assistant IA en fenêtre flottante
- FloatingWindow : ajustements pour l'usage fenêtre dédiée

Tests : tsc --noEmit clean, suite complète 530/530, suites tickets +
FloatingWindow 50/50.

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

View File

@ -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(
<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)", () => {