diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 8de814d..f66d72d 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -168,6 +168,17 @@ function sortTickets( }); } +function sameTicketCreator( + actor: Ticket["createdBy"], + filter: NonNullable, +): boolean { + if (actor.kind !== filter.kind) return false; + if (actor.kind === "agent" && filter.kind === "agent") { + return actor.agentId === filter.agentId; + } + return true; +} + export class MockSystemGateway implements SystemGateway { private listeners = new Set<(e: DomainEvent) => void>(); @@ -2840,6 +2851,7 @@ export class MockTicketGateway implements TicketGateway { (t) => !q.assignedAgentId || t.assignedAgentIds.includes(q.assignedAgentId), ) + .filter((t) => !q.createdBy || sameTicketCreator(t.createdBy, q.createdBy)) .filter( (t) => !text || @@ -2861,6 +2873,7 @@ export class MockTicketGateway implements TicketGateway { priority: t.priority, sprintId: t.sprintId ?? null, assignedAgentIds: [...t.assignedAgentIds], + createdBy: structuredClone(t.createdBy), updatedAt: t.updatedAt, })), ...(end < rows.length ? { nextCursor: String(end) } : {}), diff --git a/frontend/src/adapters/ticket.test.ts b/frontend/src/adapters/ticket.test.ts index 874cc38..519e4a8 100644 --- a/frontend/src/adapters/ticket.test.ts +++ b/frontend/src/adapters/ticket.test.ts @@ -39,4 +39,19 @@ describe("TauriTicketGateway invoke payloads", () => { }, }); }); + + it("passes createdBy through the ticket list request DTO", async () => { + await new TauriTicketGateway().list("proj-1", { + createdBy: { kind: "agent", agentId: "agent-1" }, + }); + + expect(invoke).toHaveBeenCalledWith("ticket_list", { + request: { + projectId: "proj-1", + statuses: [], + priorities: [], + createdBy: { kind: "agent", agentId: "agent-1" }, + }, + }); + }); }); diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 6ad9675..07ffa4b 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -1528,6 +1528,7 @@ export interface TicketSummary { /** Sprint membership (`null` ⇒ no sprint). Additive (ticket #10). */ sprintId?: string | null; assignedAgentIds: string[]; + createdBy: TicketActor; updatedAt: number; } diff --git a/frontend/src/features/tickets/TicketDetail.tsx b/frontend/src/features/tickets/TicketDetail.tsx index e551d3a..44906fd 100644 --- a/frontend/src/features/tickets/TicketDetail.tsx +++ b/frontend/src/features/tickets/TicketDetail.tsx @@ -18,6 +18,7 @@ import { useProjectAgents } from "./useProjectAgents"; import { TicketAssistantPanel } from "./TicketAssistantPanel"; import { TicketPicker } from "./TicketPicker"; import { TicketViewportSelect } from "./TicketViewportSelect"; +import { ticketActorLabel } from "./ticketActor"; import { PriorityBadge, StatusBadge, @@ -256,6 +257,9 @@ export function TicketDetail({
{/* ── Fields (F3) ── */}
+

+ Créé par : {ticketActorLabel(t.createdBy, nameOf)} +

diff --git a/frontend/src/features/tickets/TicketsPanel.tsx b/frontend/src/features/tickets/TicketsPanel.tsx index 2cfcb0e..049beb1 100644 --- a/frontend/src/features/tickets/TicketsPanel.tsx +++ b/frontend/src/features/tickets/TicketsPanel.tsx @@ -17,6 +17,11 @@ import { SprintManager } from "./SprintManager"; import { SprintPicker } from "./SprintPicker"; import { TicketFacetsBar } from "./TicketFacetsBar"; import { TicketViewportSelect } from "./TicketViewportSelect"; +import { + creatorFilterFromValue, + creatorFilterValue, + ticketActorLabel, +} from "./ticketActor"; import { PriorityBadge, StatusBadge, @@ -37,17 +42,26 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) { const vm = useTickets(projectId); const { agents, nameOf, loaded: agentsLoaded } = useProjectAgents(projectId); - // Reconcile a *restored* assignee filter (ticket #29) against the live roster: - // once the agents are known, an assignee that no longer exists is dropped - // (which also clears it from persisted storage via `useTickets`). Guarded on - // `agentsLoaded` so a still-valid assignee is never cleared during the load - // window when the roster is momentarily empty. + // Reconcile *restored* agent-scoped filters (ticket #29/#109) against the live + // roster: once agents are known, stale ids are dropped and persisted storage + // follows via `useTickets`. Guarded on `agentsLoaded` so a still-valid agent is + // never cleared during the load window when the roster is momentarily empty. useEffect(() => { if (!agentsLoaded) return; - const id = vm.query.assignedAgentId; - if (id && !agents.some((a) => a.id === id)) { - const { assignedAgentId: _drop, ...rest } = vm.query; - vm.setQuery(rest); + const validAgentIds = new Set(agents.map((a) => a.id)); + const staleAssignee = + vm.query.assignedAgentId && !validAgentIds.has(vm.query.assignedAgentId); + const staleCreator = + vm.query.createdBy?.kind === "agent" && + !validAgentIds.has(vm.query.createdBy.agentId); + if (staleAssignee || staleCreator) { + const { assignedAgentId: _dropAssignee, createdBy: _dropCreator, ...rest } = + vm.query; + vm.setQuery({ + ...rest, + ...(staleAssignee ? {} : { assignedAgentId: vm.query.assignedAgentId }), + ...(staleCreator ? {} : { createdBy: vm.query.createdBy }), + }); } }, [agentsLoaded, agents, vm.query, vm.setQuery]); const [showCreate, setShowCreate] = useState(false); @@ -305,6 +319,26 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) { }) } /> + ({ + value: `agent:${agent.id}`, + label: agent.name, + })), + ]} + onChange={(next) => { + const { createdBy: _drop, ...rest } = vm.query; + const createdBy = creatorFilterFromValue(next); + vm.setQuery({ + ...rest, + ...(createdBy ? { createdBy } : {}), + }); + }} + />
@@ -485,6 +519,9 @@ function SprintSection({ {t.assignedAgentIds.map(nameOf).join(", ")} )} + + Créé par : {ticketActorLabel(t.createdBy, nameOf)} + diff --git a/frontend/src/features/tickets/ticketActor.ts b/frontend/src/features/tickets/ticketActor.ts new file mode 100644 index 0000000..77fcf57 --- /dev/null +++ b/frontend/src/features/tickets/ticketActor.ts @@ -0,0 +1,37 @@ +import type { TicketActor } from "@/domain"; + +export type TicketCreatorFilterValue = "" | "user" | `agent:${string}`; + +export function ticketActorLabel( + actor: TicketActor, + nameOf: (agentId: string) => string, +): string { + switch (actor.kind) { + case "agent": + return `Agent : ${nameOf(actor.agentId)}`; + case "system": + return "Système"; + case "user": + default: + return "Utilisateur"; + } +} + +export function creatorFilterValue( + actor: { kind: "user" } | { kind: "agent"; agentId: string } | undefined, +): TicketCreatorFilterValue { + if (!actor) return ""; + if (actor.kind === "user") return "user"; + return `agent:${actor.agentId}`; +} + +export function creatorFilterFromValue( + value: string, +): { kind: "user" } | { kind: "agent"; agentId: string } | undefined { + if (value === "user") return { kind: "user" }; + if (value.startsWith("agent:")) { + const agentId = value.slice("agent:".length); + return agentId ? { kind: "agent", agentId } : undefined; + } + return undefined; +} diff --git a/frontend/src/features/tickets/ticketFilterPersistence.test.ts b/frontend/src/features/tickets/ticketFilterPersistence.test.ts index ca8bb64..c0acb7e 100644 --- a/frontend/src/features/tickets/ticketFilterPersistence.test.ts +++ b/frontend/src/features/tickets/ticketFilterPersistence.test.ts @@ -69,6 +69,22 @@ describe("parseTicketFilters (validation)", () => { .assignedAgentId, ).toBe("gone"); }); + + it("keeps valid creator filters and drops obsolete creator agents", () => { + expect(parseTicketFilters({ createdBy: { kind: "user" } }).createdBy).toEqual({ + kind: "user", + }); + expect( + parseTicketFilters({ createdBy: { kind: "agent", agentId: "a1" } }, { + validAgentIds: new Set(["a1"]), + }).createdBy, + ).toEqual({ kind: "agent", agentId: "a1" }); + expect( + parseTicketFilters({ createdBy: { kind: "agent", agentId: "gone" } }, { + validAgentIds: new Set(["a1"]), + }).createdBy, + ).toBeUndefined(); + }); }); describe("sanitize (persistable subsets)", () => { @@ -77,6 +93,7 @@ describe("sanitize (persistable subsets)", () => { statuses: ["open"], priorities: ["high"], assignedAgentId: "a1", + createdBy: { kind: "agent", agentId: "a1" }, sort: { field: "title", direction: "asc" }, limit: 100, cursor: "opaque", @@ -89,6 +106,7 @@ describe("sanitize (persistable subsets)", () => { statuses: ["open"], priorities: ["high"], assignedAgentId: "a1", + createdBy: { kind: "agent", agentId: "a1" }, sort: { field: "title", direction: "asc" }, limit: 100, }); @@ -104,6 +122,7 @@ describe("sanitize (persistable subsets)", () => { sort: { field: "title", direction: "asc" }, }); expect("assignedAgentId" in out).toBe(false); + expect("createdBy" in out).toBe(false); expect("limit" in out).toBe(false); expect("cursor" in out).toBe(false); }); diff --git a/frontend/src/features/tickets/ticketFilterPersistence.ts b/frontend/src/features/tickets/ticketFilterPersistence.ts index 0718638..c0ca84b 100644 --- a/frontend/src/features/tickets/ticketFilterPersistence.ts +++ b/frontend/src/features/tickets/ticketFilterPersistence.ts @@ -11,7 +11,7 @@ * - `tickets:pickerFilters:` — the reusable ticket picker. * * We only ever store the *stable* criteria (`text`, `statuses`, `priorities`, - * `sort`, `limit`, and — main list only — `assignedAgentId`). The opaque + * `sort`, `limit`, and — main list only — `assignedAgentId`/`createdBy`). The opaque * `cursor` is **never** persisted: it is a transient pagination token whose reuse * across sessions is meaningless (G3-bis). Reads are defensive — unknown values * are dropped and a corrupt blob yields the default empty query. @@ -133,6 +133,21 @@ export function parseTicketFilters( } } + if (isObject(raw.createdBy)) { + if (raw.createdBy.kind === "user") { + query.createdBy = { kind: "user" }; + } else if ( + raw.createdBy.kind === "agent" && + typeof raw.createdBy.agentId === "string" && + raw.createdBy.agentId + ) { + const { validAgentIds } = options; + if (!validAgentIds || validAgentIds.has(raw.createdBy.agentId)) { + query.createdBy = { kind: "agent", agentId: raw.createdBy.agentId }; + } + } + } + const sort = parseSort(raw.sort); if (sort) query.sort = sort; @@ -161,6 +176,7 @@ export function sanitizeListFilters(query: TicketListQuery): TicketListQuery | n out.priorities = query.priorities; } if (query.assignedAgentId) out.assignedAgentId = query.assignedAgentId; + if (query.createdBy) out.createdBy = query.createdBy; if (query.sort) out.sort = query.sort; if (typeof query.limit === "number") out.limit = query.limit; return Object.keys(out).length > 0 ? out : null; diff --git a/frontend/src/features/tickets/tickets.test.tsx b/frontend/src/features/tickets/tickets.test.tsx index 93cbab0..3982966 100644 --- a/frontend/src/features/tickets/tickets.test.tsx +++ b/frontend/src/features/tickets/tickets.test.tsx @@ -103,6 +103,28 @@ describe("MockTicketGateway", () => { expect(search.items.map((i) => i.ref)).toEqual(["#2"]); }); + it("filters by creator and exposes createdBy on summaries (#109)", async () => { + await ticket.create(PROJECT_ID, { title: "User ticket" }); + const agentTicket = await ticket.create(PROJECT_ID, { title: "Agent ticket" }); + ticket._seedTicket(PROJECT_ID, { + ...agentTicket, + createdBy: { kind: "agent", agentId: "agent-1" }, + }); + + const byUser = await ticket.list(PROJECT_ID, { createdBy: { kind: "user" } }); + expect(byUser.items.map((i) => i.title)).toEqual(["User ticket"]); + expect(byUser.items[0]?.createdBy).toEqual({ kind: "user" }); + + const byAgent = await ticket.list(PROJECT_ID, { + createdBy: { kind: "agent", agentId: "agent-1" }, + }); + expect(byAgent.items.map((i) => i.title)).toEqual(["Agent ticket"]); + expect(byAgent.items[0]?.createdBy).toEqual({ + kind: "agent", + agentId: "agent-1", + }); + }); + it("combines status and priority facets with AND (#12)", async () => { await ticket.create(PROJECT_ID, { title: "a", @@ -419,6 +441,45 @@ describe("TicketsView", () => { ); }); + it("shows ticket creators and filters the list by creator (#109)", async () => { + const creator = await seedAgent(agent, "CreatorAgent"); + await ticket.create(PROJECT_ID, { title: "Created by user" }); + const agentTicket = await ticket.create(PROJECT_ID, { + title: "Created by agent", + }); + ticket._seedTicket(PROJECT_ID, { + ...agentTicket, + createdBy: { kind: "agent", agentId: creator.id }, + }); + const listSpy = vi.spyOn(ticket, "list"); + + renderView(ticket, system, agent); + + await screen.findByText("Created by user"); + expect(screen.getByText("Créé par : Utilisateur")).toBeTruthy(); + expect(screen.getByText("Créé par : Agent : CreatorAgent")).toBeTruthy(); + + fireEvent.click(screen.getByLabelText("filter by creator")); + fireEvent.click(await screen.findByRole("option", { name: "CreatorAgent" })); + + await waitFor(() => + expect(listSpy.mock.calls.at(-1)?.[1]?.createdBy).toEqual({ + kind: "agent", + agentId: creator.id, + }), + ); + await waitFor(() => expect(screen.queryByText("Created by user")).toBeNull()); + expect(screen.getByText("Created by agent")).toBeTruthy(); + + fireEvent.click(screen.getByText("Created by agent")); + const dialog = await screen.findByRole("dialog", { + name: `ticket ${agentTicket.ref}`, + }); + expect( + within(dialog).getByText("Créé par : Agent : CreatorAgent"), + ).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); diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 48b44d7..1acc0fd 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -998,6 +998,7 @@ export interface TicketListQuery { statuses?: TicketStatus[]; priorities?: TicketPriority[]; assignedAgentId?: string; + createdBy?: TicketCreatorFilter; /** Free-text match over title/description. */ text?: string; sort?: TicketListSort; @@ -1005,6 +1006,10 @@ export interface TicketListQuery { cursor?: string; } +export type TicketCreatorFilter = + | { kind: "user" } + | { kind: "agent"; agentId: string }; + export type TicketListSortField = "number" | "priority" | "status" | "title"; export type TicketListSortDirection = "asc" | "desc";