diff --git a/schema.graphql b/schema.graphql index bce38d49..b50491cf 100644 --- a/schema.graphql +++ b/schema.graphql @@ -69,6 +69,7 @@ type AssignedItemCount { enum AssignmentItemType { TICKET KNOWLEDGE_ARTICLE + INSIGHT } enum AssignmentTargetType { @@ -1894,6 +1895,17 @@ type Query { a list of insights must not be able to pull notes row by row. """ insightNotes(insightId: ID!, first: Int, after: String): InsightNoteConnection! + """ + A ready-to-send chat message about one insight: a short ask plus the `@insight:` marker the + assistant expands into the finding itself. Built server-side so the marker carries the stored id, + which the client never sees, and the text cannot go stale when the detector refreshes the insight. + """ + insightChatPrompt(id: ID!): String! + """ + Which status an insight may move to from each status it can be in. The mutations below enforce + exactly this table, so a client can drive its controls from it rather than restating the rules. + """ + insightStatusTransitions: [InsightStatusTransition!]! """ Relay Node interface queries""" node(id: ID!): Node @@ -3467,6 +3479,13 @@ input SnoozeInsightInput { until: Instant! } +type InsightStatusTransition { + "The status an insight is in." + from: InsightStatus! + "Every status it may move to from there." + to: [InsightStatus!]! +} + type InsightConnection { edges: [InsightEdge!]! pageInfo: PageInfo! diff --git a/src/app/(app)/incidents/components/incident-assigned-tickets.tsx b/src/app/(app)/incidents/components/incident-assigned-tickets.tsx new file mode 100644 index 00000000..9f7d006c --- /dev/null +++ b/src/app/(app)/incidents/components/incident-assigned-tickets.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { AssignedItemsView, useAssignedItems } from '@/components/assignments'; + +/** + * "Assigned Ticket": the tickets filed from this incident. The server records + * the link as an assignment owned by the insight (item INSIGHT → target TICKET) + * when a ticket is created with `insightId`, so this is the ticket page's + * Assigned Items view pointed at the incident. Nothing while loading or empty — + * a section that appears with its rows, not a slot the page reserves. + */ +export function IncidentAssignedTickets({ incidentId }: { incidentId: string }) { + const { tickets } = useAssignedItems({ itemId: incidentId, itemType: 'INSIGHT' }); + if (!tickets?.length) return null; + return ( +
+

Assigned Ticket

+ +
+ ); +} diff --git a/src/app/(app)/incidents/components/incident-details-view.tsx b/src/app/(app)/incidents/components/incident-details-view.tsx index 523dbfa1..06419312 100644 --- a/src/app/(app)/incidents/components/incident-details-view.tsx +++ b/src/app/(app)/incidents/components/incident-details-view.tsx @@ -11,14 +11,16 @@ import { InsightStatus } from '@/generated/schema-enums'; import { routes } from '@/lib/routes'; import { CONTEXT_ENTITY_KIND } from '../../mingo/context/context-types'; import { useTrackOpenView } from '../../mingo/context/use-track-open-view'; -import { useMingoLauncherStore } from '../../mingo/stores/mingo-launcher-store'; -import { useIncident } from '../hooks/use-incident'; +import { mingoActionFor, useFixWithMingo } from '../hooks/use-fix-with-mingo'; +import { useIncident, useIncidentDetail } from '../hooks/use-incident'; +import { useLatestIncidentDialog } from '../hooks/use-incident-dialogs'; import { useIncidentTransitions } from '../hooks/use-incident-transitions'; -import { incidentMingoDraft } from '../utils/fix-with-mingo-prompt'; -import { INCIDENT_TRANSITION_ACTIONS, transitionsFrom } from '../utils/incident-labels'; +import { INCIDENT_TRANSITION_ACTIONS } from '../utils/incident-labels'; +import { transitionsFrom } from '../utils/incident-transform'; +import { IncidentAssignedTickets } from './incident-assigned-tickets'; import { IncidentAssignee } from './incident-assignee'; import { IncidentNotes } from './incident-notes'; -import { IncidentQueryResults } from './incident-query-results'; +import { IncidentQueryResults, IncidentQueryResultsSkeleton } from './incident-query-results'; import { IncidentSummaryCard, IncidentSummaryCardSkeleton } from './incident-summary-card'; import { SnoozeIncidentModal } from './snooze-incident-modal'; import { transitionMenuItems } from './transition-menu-items'; @@ -41,36 +43,39 @@ const TICKET_PREFILL_DESCRIPTION_MAX = 1000; * what waits for it. */ function IncidentHeader({ incidentId }: IncidentDetailsViewProps) { - const incident = useIncident(incidentId); + const { incident, transitions } = useIncidentDetail(incidentId); const handleBack = useSafeBack(routes.incidents.list); - const canOpenMingo = useMingoLauncherStore(state => state.canOpen); + const mingoControls = useFixWithMingo(); + // One Mingo button: reopen the chat already started from this incident, or start one. + const mingoSession = useLatestIncidentDialog(incident.insightId); // Mingo's "open view": this incident rides on every message sent while the page is up. - useTrackOpenView({ type: CONTEXT_ENTITY_KIND.INSIGHT, id: incident.id, label: incident.title }); + useTrackOpenView({ type: CONTEXT_ENTITY_KIND.INSIGHT, id: incident.insightId, label: incident.title }); const { transition, snoozeTarget, cancelSnooze, confirmSnooze, isMutating, isSnoozing } = useIncidentTransitions(); // The status button is named after the transition the technician most likely // wants — Resolve while the incident is open — and its menu lists every legal // one (design: "Resolve ▾"). A resolved incident offers Archive / Reopen. - const targets = transitionsFrom(incident.status); + const targets = transitionsFrom(transitions, incident.status); const primary = targets.includes(InsightStatus.RESOLVED) ? InsightStatus.RESOLVED : targets[0]; // A deleted assignee is not offered by the ticket picker — the ticket starts unassigned. const ticketAssignee = incident.assignee && !incident.assignee.deleted ? incident.assignee : undefined; + const mingoIcon = ( + + ); + const mingoAction: PageActionButton = { + ...mingoActionFor(incident, mingoSession, mingoControls), + variant: 'outline', + icon: mingoIcon, + }; + const actions: PageActionButton[] = [ - { - label: 'Fix with Mingo', - variant: 'outline', - icon: ( - - ), - onClick: () => useMingoLauncherStore.getState().draftToMingo(incidentMingoDraft(incident)), - disabled: !canOpenMingo, - }, + mingoAction, { label: 'Create Ticket', variant: 'outline', @@ -84,6 +89,8 @@ function IncidentHeader({ incidentId }: IncidentDetailsViewProps) { deviceName: incident.deviceName, assigneeId: ticketAssignee?.id, assigneeName: ticketAssignee?.name, + insightId: incident.insightId, + insightTitle: incident.title, }), }, ...(primary @@ -92,7 +99,7 @@ function IncidentHeader({ incidentId }: IncidentDetailsViewProps) { label: INCIDENT_TRANSITION_ACTIONS[primary].label, variant: 'outline' as const, disabled: isMutating, - submenu: transitionMenuItems(incident, transition, isMutating), + submenu: transitionMenuItems(incident, transitions, transition, isMutating), }, ] : []), @@ -168,6 +175,7 @@ export function IncidentDetailsSkeleton() { <> + } /> @@ -195,7 +203,8 @@ export const IncidentDetailsView = memo(function IncidentDetailsViewImpl({ incid - + + }> diff --git a/src/app/(app)/incidents/components/incident-query-results.tsx b/src/app/(app)/incidents/components/incident-query-results.tsx index 729dfa13..cbba8789 100644 --- a/src/app/(app)/incidents/components/incident-query-results.tsx +++ b/src/app/(app)/incidents/components/incident-query-results.tsx @@ -1,6 +1,7 @@ 'use client'; import { QueryReportTable } from '@flamingo-stack/openframe-frontend-core/components/ui'; +import type { ReactNode } from 'react'; import type { Incident } from '../utils/incident-transform'; /** The first column of the evidence table: the machine every row came from. */ @@ -17,8 +18,7 @@ export function IncidentQueryResults({ incident }: { incident: Incident }) { const rows = (incident.queryResult ?? []).map(row => ({ ...row, [DEVICE_COLUMN]: incident.deviceName })); return ( -
-

Query Details

+ + + ); +} + +/** The section's shell — the heading over the table — shared by the loaded state and its skeleton. */ +function QueryResultsFrame({ children }: { children: ReactNode }) { + return ( +
+

Query Details

+ {children}
); } + +/** + * The same heading over the report table's own loading rows. The column count + * is the detecting query's and unknown until the record lands; four is the + * common shape (device + three query columns), one row the common count. + */ +export function IncidentQueryResultsSkeleton() { + return ( + + + + ); +} diff --git a/src/app/(app)/incidents/components/incidents-table.tsx b/src/app/(app)/incidents/components/incidents-table.tsx index 0e1ddf05..400d738a 100644 --- a/src/app/(app)/incidents/components/incidents-table.tsx +++ b/src/app/(app)/incidents/components/incidents-table.tsx @@ -45,10 +45,10 @@ import { getRelayErrorMessage } from '@/lib/handle-api-error'; import { openInNewTab } from '@/lib/open-in-new-tab'; import { routes } from '@/lib/routes'; import { multiSelectFilterFn } from '@/lib/table-filters'; -import { useMingoLauncherStore } from '../../mingo/stores/mingo-launcher-store'; import { type FacetEntry, type FacetOption, facetToSortedOptions } from '../../scripts/shared/utils/facet-options'; +import { type MingoAction, mingoActionFor, useFixWithMingo } from '../hooks/use-fix-with-mingo'; +import { useLatestIncidentDialogs } from '../hooks/use-incident-dialogs'; import { useIncidentTransitions } from '../hooks/use-incident-transitions'; -import { incidentMingoDraft } from '../utils/fix-with-mingo-prompt'; import { enumMembers, INCIDENT_SEVERITY_LABELS, @@ -57,7 +57,7 @@ import { labelOf, WORKING_SET_STATUSES, } from '../utils/incident-labels'; -import { type IncidentRow, toIncidentRow } from '../utils/incident-transform'; +import { type IncidentRow, toIncidentRow, toTransitionTable } from '../utils/incident-transform'; import { IncidentSeverityTag, IncidentStatusTag } from './incident-tags'; import { INCIDENT_COLUMNS, INCIDENTS_TABLE_COLUMNS } from './incidents-table-columns'; import { SnoozeIncidentModal } from './snooze-incident-modal'; @@ -72,6 +72,17 @@ const PAGE_SIZE = 20; */ type ColumnFilterState = { id: string; value: unknown }[]; +/** + * A row plus everything its Mingo button draws. It rides IN the row data rather + * than in the column defs on purpose: `DataTableRow` is memoized on the row + * object, which TanStack keeps stable across column changes, so a cell reading + * any value from the columns' closure keeps showing what it saw first. The + * `rows` memo below rebuilds on every input of the action. + */ +interface IncidentTableRow extends IncidentRow { + mingoAction: MingoAction; +} + /** * Dropdown options for an enum facet, labelled with this app's names so the * dropdown matches the cells; server order is kept. (The customer facet keeps @@ -130,7 +141,7 @@ function IncidentsTableContent({ }: IncidentsTableContentProps) { const { toast } = useToast(); const environment = useRelayEnvironment(); - const canOpenMingo = useMingoLauncherStore(state => state.canOpen); + const mingoControls = useFixWithMingo(); // One round-trip per interaction: the filter facets (`insightFilters`) ride the // list operation — see the query docstring for the facet semantics. @@ -151,8 +162,18 @@ function IncidentsTableContent({ IncidentsFragmentKey >(incidentsTableRelayFragment, queryData); - const rows: IncidentRow[] = (data.insights?.edges ?? []).flatMap(edge => - edge?.node ? [toIncidentRow(edge.node)] : [], + // Manual memos on purpose: TanStack compares `data` by identity, so `rows` + // must only change when an input does — and `incidents` is its stable input. + const incidents = useMemo( + () => (data.insights?.edges ?? []).flatMap(edge => (edge?.node ? [toIncidentRow(edge.node)] : [])), + [data.insights?.edges], + ); + // Which rows already have a chat: their button reopens it instead of starting one. + const mingoSessions = useLatestIncidentDialogs(incidents.map(row => row.insightId)); + const rows = useMemo( + () => + incidents.map(row => ({ ...row, mingoAction: mingoActionFor(row, mingoSessions[row.insightId], mingoControls) })), + [incidents, mingoSessions, mingoControls], ); // A failed page must stop the footer: its sentinel stays in view, Relay @@ -214,17 +235,18 @@ function IncidentsTableContent({ const { transition, snoozeTarget, cancelSnooze, confirmSnooze, isMutating, isSnoozing } = useIncidentTransitions(refreshFilterMeta); + const transitionTable = toTransitionTable(queryData); - const columns = useMemo[]>(() => { + const columns = useMemo[]>(() => { const renderRowActions = (row: IncidentRow) => { - const items = transitionMenuItems(row, transition, isMutating); + const items = transitionMenuItems(row, transitionTable, transition, isMutating); return items.length > 0 ? : null; }; return [ { accessorKey: 'type', header: INCIDENT_COLUMNS.incident.header, - cell: ({ row }: { row: Row }) => ( + cell: ({ row }: { row: Row }) => (
{row.original.title} {/* The Status column is hidden below lg; its time line moves under the title. */} @@ -249,7 +271,7 @@ function IncidentsTableContent({ // device facet, and a customer narrows the fleet the way a technician does. accessorKey: 'organizationId', header: INCIDENT_COLUMNS.device.header, - cell: ({ row }: { row: Row }) => ( + cell: ({ row }: { row: Row }) => (
{renderDeviceTypeIcon(row.original.deviceType ?? undefined, 'size-6 shrink-0 text-ods-text-secondary')} @@ -271,7 +293,7 @@ function IncidentsTableContent({ { accessorKey: 'severity', header: INCIDENT_COLUMNS.severity.header, - cell: ({ row }: { row: Row }) => , + cell: ({ row }: { row: Row }) => , enableSorting: false, filterFn: multiSelectFilterFn, meta: liveColumnMeta(INCIDENT_COLUMNS.severity, { filter: { options: severityOptions } }), @@ -279,7 +301,7 @@ function IncidentsTableContent({ { accessorKey: 'status', header: INCIDENT_COLUMNS.status.header, - cell: ({ row }: { row: Row }) => ( + cell: ({ row }: { row: Row }) => (
@@ -297,7 +319,7 @@ function IncidentsTableContent({ }, { id: 'actions', - cell: ({ row }: { row: Row }) => ( + cell: ({ row }: { row: Row }) => (
{renderRowActions(row.original)}
@@ -306,35 +328,39 @@ function IncidentsTableContent({ meta: liveColumnMeta(INCIDENT_COLUMNS.actions), }, { - // "Fix with Mingo": opens the drawer on a fresh chat with the incident - // prefilled in the composer — nothing sent. Disabled while no drawer is - // mounted (locked workspace). + // The Mingo button, as `mingoActionFor` decides it (see `IncidentTableRow` + // for why the cell reads nothing but the row). id: 'mingo', - cell: ({ row }: { row: Row }) => ( -
-
- ), + cell: ({ row }: { row: Row }) => { + const { label, onClick, disabled, loading } = row.original.mingoAction; + return ( +
+
+ ); + }, enableSorting: false, meta: liveColumnMeta(INCIDENT_COLUMNS.mingo), }, { id: 'open', - cell: ({ row }: { row: Row }) => ( + cell: ({ row }: { row: Row }) => (