From 05402c75443da8f8b6bfb4d4721f7aed13db0118 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 28 Jul 2026 18:29:17 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(tickets):=20dropdowns=20ne=20sortent=20?= =?UTF-8?q?plus=20du=20viewport=20en=20bas=20d'=C3=A9cran=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace les setProfileId(e.target.value)} - > - - {vm.profiles.map((p) => ( - - ))} - + options={[ + { + value: "", + label: + vm.profiles.length === 0 + ? "Aucun profil disponible" + : "Choisir un profil IA…", + }, + ...vm.profiles.map((profile) => ({ + value: profile.id, + label: profile.name, + })), + ]} + onChange={setProfileId} + /> + {open && + position && + createPortal( +
+ {options.map((option) => ( + + ))} +
, + document.body, + )} + + ); +} diff --git a/frontend/src/features/tickets/TicketsPanel.tsx b/frontend/src/features/tickets/TicketsPanel.tsx index b733b0d..cca7727 100644 --- a/frontend/src/features/tickets/TicketsPanel.tsx +++ b/frontend/src/features/tickets/TicketsPanel.tsx @@ -10,12 +10,13 @@ import { useEffect, useState } from "react"; import type { Sprint, TicketPriority, TicketSummary } from "@/domain"; -import { Button, Input, Panel, Spinner, cn } from "@/shared"; +import { Button, Input, Panel, Spinner } from "@/shared"; import { useTickets } from "./useTickets"; import { useProjectAgents } from "./useProjectAgents"; import { SprintManager } from "./SprintManager"; import { SprintPicker } from "./SprintPicker"; import { TicketFacetsBar } from "./TicketFacetsBar"; +import { TicketViewportSelect } from "./TicketViewportSelect"; import { PriorityBadge, StatusBadge, @@ -24,12 +25,6 @@ import { priorityLabel, } 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). */ @@ -151,18 +146,15 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) { onChange={(e) => setNewTitle(e.target.value)} />
- + options={TICKET_PRIORITIES.map((priority) => ({ + value: priority, + label: priorityLabel(priority), + }))} + onChange={(next) => setNewPriority(next as TicketPriority)} + />
@@ -363,21 +354,20 @@ function SprintSection({
- + />
))} diff --git a/frontend/src/features/tickets/tickets.test.tsx b/frontend/src/features/tickets/tickets.test.tsx index cd4190f..ffad360 100644 --- a/frontend/src/features/tickets/tickets.test.tsx +++ b/frontend/src/features/tickets/tickets.test.tsx @@ -353,9 +353,8 @@ describe("TicketsView", () => { expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toBeUndefined(); // Choose a field ⇒ ascending by default, relayed in the query. - fireEvent.change(screen.getByLabelText("sort tickets by"), { - target: { value: "title" }, - }); + fireEvent.click(screen.getByLabelText("sort tickets by")); + fireEvent.click(await screen.findByRole("option", { name: "Titre" })); await waitFor(() => expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({ field: "title", @@ -375,9 +374,8 @@ describe("TicketsView", () => { ); // Back to « Par défaut » ⇒ `sort` dropped from the query again. - fireEvent.change(screen.getByLabelText("sort tickets by"), { - target: { value: "" }, - }); + fireEvent.click(screen.getByLabelText("sort tickets by")); + fireEvent.click(await screen.findByRole("option", { name: "Par défaut" })); await waitFor(() => expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toBeUndefined(), ); @@ -390,8 +388,8 @@ describe("TicketsView", () => { 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" } }); + fireEvent.click(within(dialog).getByLabelText("ticket status")); + fireEvent.click(await screen.findByRole("option", { name: "In progress" })); await waitFor(async () => { const fresh = await ticket.read(PROJECT_ID, t.ref); @@ -426,9 +424,8 @@ describe("TicketsView", () => { 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).getByLabelText("assign agent")); + fireEvent.click(await screen.findByRole("option", { name: "Backend" })); fireEvent.click(within(dialog).getByText("Assign")); await waitFor(async () => { @@ -495,9 +492,8 @@ describe("TicketsView", () => { // Change the priority — an immediate-apply mutation that re-fetches the ticket // and bumps its version. The unsaved draft must survive. - fireEvent.change(within(dialog).getByLabelText("ticket priority"), { - target: { value: "high" }, - }); + fireEvent.click(within(dialog).getByLabelText("ticket priority")); + fireEvent.click(await screen.findByRole("option", { name: "High" })); // The backend applied the priority bump… await waitFor(async () => { @@ -643,9 +639,8 @@ describe("TicketsView", () => { expect(within(bucket).getByText("Movable")).toBeTruthy(); // Pick the sprint in the row selector → assign it. - fireEvent.change(screen.getByLabelText(`sprint for ${t.ref}`), { - target: { value: "s1" }, - }); + fireEvent.click(screen.getByLabelText(`sprint for ${t.ref}`)); + fireEvent.click(await screen.findByRole("option", { name: "Sprint One" })); // The gateway recorded the membership… await waitFor(async () => { @@ -777,7 +772,8 @@ describe("TicketsView", () => { ); expect(within(assistant).getByText("Ouvrir la conversation")).toBeTruthy(); - fireEvent.change(profileSelect, { target: { value: "qa-assistant" } }); + fireEvent.click(profileSelect); + fireEvent.click(await screen.findByRole("option", { name: "QA Assistant" })); fireEvent.click(within(assistant).getByText("Ouvrir la conversation")); expect( @@ -837,9 +833,8 @@ describe("TicketsView", () => { 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("link kind")); + fireEvent.click(await screen.findByRole("option", { name: "blocks" })); fireEvent.click(within(detail).getByLabelText("add link")); // The picker excludes the ticket itself; the target is offered. From 5d6a2957152243b9713c45b6bec8f4ca447ba7e6 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 28 Jul 2026 19:09:07 +0200 Subject: [PATCH 2/2] feat(layout): refitEpoch test + implementation LayoutGrid --- .../layout/LayoutGrid.refitEpoch.test.tsx | 136 ++++++++++++++++++ frontend/src/features/layout/LayoutGrid.tsx | 21 ++- 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 frontend/src/features/layout/LayoutGrid.refitEpoch.test.tsx diff --git a/frontend/src/features/layout/LayoutGrid.refitEpoch.test.tsx b/frontend/src/features/layout/LayoutGrid.refitEpoch.test.tsx new file mode 100644 index 0000000..12820f4 --- /dev/null +++ b/frontend/src/features/layout/LayoutGrid.refitEpoch.test.tsx @@ -0,0 +1,136 @@ +import { useEffect } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; + +import type { Gateways } from "@/ports"; +import { MockLayoutGateway, MockTerminalGateway } from "@/adapters/mock"; +import { DIProvider } from "@/app/di"; + +const terminalViewSpy = vi.hoisted(() => vi.fn()); +const terminalMountSpy = vi.hoisted(() => vi.fn()); +const terminalUnmountSpy = vi.hoisted(() => vi.fn()); + +vi.mock("@/features/terminals", () => ({ + TerminalView: (props: { refitSignal?: number }) => { + terminalViewSpy(props); + useEffect(() => { + terminalMountSpy(); + return () => terminalUnmountSpy(); + }, []); + return ( +
+ ); + }, + ResumeConversationPopup: () => null, + useWritePortal: () => ({ + portal: { + onHumanData: () => {}, + isSuspended: () => false, + bindHandle: () => {}, + unbindHandle: () => {}, + }, + overlay: false, + }), +})); + +import { LayoutGrid } from "./LayoutGrid"; + +function renderGrid( + layout: MockLayoutGateway, + props: { projectId: string; cwd: string; layoutId?: string }, +) { + const gateways = { + layout, + terminal: new MockTerminalGateway(), + } as unknown as Gateways; + + return render( + + + , + ); +} + +function latestRefitSignal(): number { + const lastCall = terminalViewSpy.mock.calls.at(-1); + return Number(lastCall?.[0]?.refitSignal ?? -1); +} + +describe("LayoutGrid refit epoch (#4)", () => { + it("bumps TerminalView refitSignal after cwd/layout/project transitions", async () => { + const layout = new MockLayoutGateway(); + const { activeId: firstLayoutId } = await layout.listLayouts("p1"); + const { layoutId: secondLayoutId } = await layout.createLayout("p1", "Second"); + terminalViewSpy.mockClear(); + terminalMountSpy.mockClear(); + terminalUnmountSpy.mockClear(); + + const view = renderGrid(layout, { + projectId: "p1", + cwd: "/same/cwd", + layoutId: firstLayoutId, + }); + + await screen.findByTestId("mock-terminal-view"); + await waitFor(() => expect(latestRefitSignal()).toBeGreaterThan(0)); + const initialSignal = latestRefitSignal(); + expect(terminalMountSpy).toHaveBeenCalledTimes(1); + + view.rerender( + + + , + ); + + await waitFor(() => expect(latestRefitSignal()).toBeGreaterThan(initialSignal)); + const afterCwdSignal = latestRefitSignal(); + expect(terminalMountSpy).toHaveBeenCalledTimes(1); + expect(terminalUnmountSpy).not.toHaveBeenCalled(); + + view.rerender( + + + , + ); + + await waitFor(() => expect(latestRefitSignal()).toBeGreaterThan(afterCwdSignal)); + const afterLayoutSwitchSignal = latestRefitSignal(); + + view.rerender( + + + , + ); + + await waitFor(() => + expect(latestRefitSignal()).toBeGreaterThan(afterLayoutSwitchSignal), + ); + expect(terminalMountSpy).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index 7ae6c5c..86709e0 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -97,6 +97,11 @@ export function LayoutGrid({ }: LayoutGridProps) { const vm = useLayout(projectId, layoutId); const work = useProjectWorkState(projectId); + const [refitEpoch, setRefitEpoch] = useState(0); + + useEffect(() => { + setRefitEpoch((epoch) => epoch + 1); + }, [projectId, layoutId, cwd, vm.layoutVersion]); if (!vm.layout) { return ( @@ -129,6 +134,7 @@ export function LayoutGrid({ projectId={projectId} workState={work.state} refreshWorkState={work.refresh} + refitSignal={refitEpoch} onOpenConversation={onOpenConversation} onOpenPluginsSettings={onOpenPluginsSettings} /> @@ -145,6 +151,7 @@ interface NodeViewProps { projectId: string; workState: ProjectWorkState | null; refreshWorkState: () => Promise; + refitSignal: number; onOpenConversation?: (conversationId: string) => void; onOpenPluginsSettings?: () => void; } @@ -157,6 +164,7 @@ function NodeView({ projectId, workState, refreshWorkState, + refitSignal, onOpenConversation, onOpenPluginsSettings, }: NodeViewProps) { @@ -190,6 +198,7 @@ function NodeView({ projectId={projectId} workState={workState} refreshWorkState={refreshWorkState} + refitSignal={refitSignal} onOpenConversation={onOpenConversation} /> ); @@ -202,6 +211,7 @@ function NodeView({ projectId={projectId} workState={workState} refreshWorkState={refreshWorkState} + refitSignal={refitSignal} onOpenConversation={onOpenConversation} onOpenPluginsSettings={onOpenPluginsSettings} /> @@ -215,6 +225,7 @@ function NodeView({ projectId={projectId} workState={workState} refreshWorkState={refreshWorkState} + refitSignal={refitSignal} onOpenConversation={onOpenConversation} onOpenPluginsSettings={onOpenPluginsSettings} /> @@ -234,6 +245,7 @@ interface LeafViewProps { projectId: string; workState: ProjectWorkState | null; refreshWorkState: () => Promise; + refitSignal: number; onOpenConversation?: (conversationId: string) => void; } @@ -315,6 +327,7 @@ function LeafView({ projectId, workState, refreshWorkState, + refitSignal, onOpenConversation, }: LeafViewProps) { // A cell can be closed only when it lives inside a (binary) split: closing it @@ -950,7 +963,7 @@ function LeafView({ onSessionId={(sid) => void vm.setSession(id, sid)} agentMode={agentId != null} portal={agentId != null ? portal : undefined} - refitSignal={vm.layoutVersion} + refitSignal={refitSignal} /> {/* Write-portal overlay (ARCHITECTURE §20.3 step b/e): while a delegation is being injected into the agent's PTY, a grey veil with a centred @@ -1185,6 +1198,7 @@ interface SplitViewProps { projectId: string; workState: ProjectWorkState | null; refreshWorkState: () => Promise; + refitSignal: number; onOpenConversation?: (conversationId: string) => void; onOpenPluginsSettings?: () => void; } @@ -1196,6 +1210,7 @@ function SplitView({ projectId, workState, refreshWorkState, + refitSignal, onOpenConversation, onOpenPluginsSettings, }: SplitViewProps) { @@ -1241,6 +1256,7 @@ function SplitView({ projectId={projectId} workState={workState} refreshWorkState={refreshWorkState} + refitSignal={refitSignal} onOpenConversation={onOpenConversation} onOpenPluginsSettings={onOpenPluginsSettings} parentSplit={{ @@ -1336,6 +1352,7 @@ interface GridViewProps { projectId: string; workState: ProjectWorkState | null; refreshWorkState: () => Promise; + refitSignal: number; onOpenConversation?: (conversationId: string) => void; onOpenPluginsSettings?: () => void; } @@ -1347,6 +1364,7 @@ function GridView({ projectId, workState, refreshWorkState, + refitSignal, onOpenConversation, onOpenPluginsSettings, }: GridViewProps) { @@ -1388,6 +1406,7 @@ function GridView({ projectId={projectId} workState={workState} refreshWorkState={refreshWorkState} + refitSignal={refitSignal} onOpenConversation={onOpenConversation} onOpenPluginsSettings={onOpenPluginsSettings} />