/** * View-model hook for the project ticket list (F2). * * Consumes the {@link TicketGateway} for reads/creates and subscribes to the * `Issue*` domain events (via {@link SystemGateway}) to refresh the list on any * ticket mutation — including those made by agents through MCP. Filters/search * are part of the query and drive a re-fetch when they change. */ import { useCallback, useEffect, useMemo, useState } from "react"; import { isTicketEvent, type GatewayError, type Ticket, type TicketList } from "@/domain"; import type { CreateTicketInput, TicketListQuery } from "@/ports"; import { useGateways } from "@/app/di"; export interface TicketsViewModel { list: TicketList | null; error: string | null; busy: boolean; query: TicketListQuery; setQuery: (next: TicketListQuery) => void; refresh: () => Promise; create: (input: CreateTicketInput) => Promise; } function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); } return String(e); } export function useTickets(projectId: string): TicketsViewModel { const { ticket, system } = useGateways(); const [list, setList] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const [query, setQuery] = useState({}); // Stable dependency key so `refresh` only changes when a filter actually does. const queryKey = useMemo(() => JSON.stringify(query), [query]); const refresh = useCallback(async () => { setBusy(true); setError(null); try { setList(await ticket.list(projectId, query)); } catch (e) { setError(describe(e)); } finally { setBusy(false); } // `query` is captured via `queryKey`; listing it directly would rebuild the // callback on every render (a new object identity each time). // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId, ticket, queryKey]); const create = useCallback( async (input: CreateTicketInput): Promise => { setError(null); try { const created = await ticket.create(projectId, input); await refresh(); return created; } catch (e) { setError(describe(e)); return null; } }, [projectId, ticket, refresh], ); useEffect(() => { void refresh(); }, [refresh]); useEffect(() => { if (!system) return; let unsubscribe: (() => void) | undefined; let cancelled = false; void system .onDomainEvent((event) => { if (isTicketEvent(event)) void refresh(); }) .then((u) => { if (cancelled) u(); else unsubscribe = u; }); return () => { cancelled = true; unsubscribe?.(); }; }, [refresh, system]); return { list, error, busy, query, setQuery, refresh, create }; }