From 82ef26ee6e17e8bbb26f9d09f637db4635b15fd4 Mon Sep 17 00:00:00 2001 From: Oleksandr Koltunov Date: Mon, 21 Sep 2026 10:56:13 +0300 Subject: [PATCH 1/7] =?UTF-8?q?feat(incidents):=20follow=20the=20Insights?= =?UTF-8?q?=20API=20follow-ups=20=E2=80=94=20server=20transition=20table,?= =?UTF-8?q?=20chat=20prompt,=20insight=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns with openframe-saas-tenant#3149 (Insight ai and assigned items): - Status transitions come from `insightStatusTransitions` (an @inline fragment on Query, spread in the list and detail operations) instead of a client mirror of the validator — the mirror had already drifted (ACKNOWLEDGED/SNOOZED now only → RESOLVED). - "Fix with Mingo" fetches the server's `insightChatPrompt` and turns its leading `@insight:` / `@device:` markers into labelled mentions; the client-side prompt builder is gone. The chat created from that draft carries `insightId` on `POST /dialogs` (launcher store link, consumed on create, dropped on any change of conversation or a close). - Every Mingo context reference to an incident (picker item, open view, chip) now carries the STORED insight id — what the real `InsightContextResolver` looks up — not the Relay global handle. - Create Ticket from an incident sends `CreateTicketInput.insightId`; the form shows the "Assigned Incident" it is filed from (new prefill keys). - Incident details lists the Mingo sessions started from it (`dialogs(filter: { insightId })`), each opening in the drawer. Requires the #3149 API: the list/detail queries select the new field, so this must not deploy ahead of it. Co-Authored-By: Claude Fable 5.1 --- schema.graphql | 18 +++++ .../components/incident-details-view.tsx | 27 ++++--- .../components/incident-sessions.tsx | 77 +++++++++++++++++++ .../incidents/components/incidents-table.tsx | 34 +++++--- .../components/transition-menu-items.tsx | 13 ++-- .../incidents/hooks/use-fix-with-mingo.ts | 49 ++++++++++++ .../incidents/hooks/use-incident-dialogs.ts | 32 ++++++++ src/app/(app)/incidents/hooks/use-incident.ts | 14 +++- .../utils/fix-with-mingo-draft.test.ts | 50 ++++++++++++ .../incidents/utils/fix-with-mingo-draft.ts | 41 ++++++++++ .../incidents/utils/fix-with-mingo-prompt.ts | 66 ---------------- .../(app)/incidents/utils/incident-labels.ts | 20 ----- .../incidents/utils/incident-transform.ts | 29 +++++++ src/app/(app)/mingo/context/relay-items.tsx | 4 +- .../hooks/use-mingo-unified-chat-state.ts | 4 + .../(app)/mingo/services/mingo-api-service.ts | 10 ++- .../mingo/stores/mingo-launcher-store.ts | 50 ++++++++++-- .../create-edit/ticket-form-fields.tsx | 21 ++++- .../tickets/hooks/use-create-ticket-form.ts | 1 + src/app/(app)/tickets/types/ticket.types.ts | 2 + .../openframe-embeddable-chat-entry.tsx | 3 + src/graphql/insights/incident-detail-relay.ts | 1 + src/graphql/insights/incidents-table-relay.ts | 1 + .../insights/insight-chat-prompt-relay.ts | 13 ++++ src/graphql/insights/insight-transitions.ts | 17 ++++ src/lib/routes.ts | 4 + 26 files changed, 475 insertions(+), 126 deletions(-) create mode 100644 src/app/(app)/incidents/components/incident-sessions.tsx create mode 100644 src/app/(app)/incidents/hooks/use-fix-with-mingo.ts create mode 100644 src/app/(app)/incidents/hooks/use-incident-dialogs.ts create mode 100644 src/app/(app)/incidents/utils/fix-with-mingo-draft.test.ts create mode 100644 src/app/(app)/incidents/utils/fix-with-mingo-draft.ts delete mode 100644 src/app/(app)/incidents/utils/fix-with-mingo-prompt.ts create mode 100644 src/graphql/insights/insight-chat-prompt-relay.ts create mode 100644 src/graphql/insights/insight-transitions.ts diff --git a/schema.graphql b/schema.graphql index bce38d49..e0b1d20a 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1894,6 +1894,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 +3478,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-details-view.tsx b/src/app/(app)/incidents/components/incident-details-view.tsx index 523dbfa1..8814b9cd 100644 --- a/src/app/(app)/incidents/components/incident-details-view.tsx +++ b/src/app/(app)/incidents/components/incident-details-view.tsx @@ -11,14 +11,15 @@ 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 { useFixWithMingo } from '../hooks/use-fix-with-mingo'; +import { useIncident, useIncidentDetail } from '../hooks/use-incident'; 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 { IncidentAssignee } from './incident-assignee'; import { IncidentNotes } from './incident-notes'; import { IncidentQueryResults } from './incident-query-results'; +import { IncidentSessions } from './incident-sessions'; import { IncidentSummaryCard, IncidentSummaryCardSkeleton } from './incident-summary-card'; import { SnoozeIncidentModal } from './snooze-incident-modal'; import { transitionMenuItems } from './transition-menu-items'; @@ -41,18 +42,18 @@ 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 { fixWithMingo, pendingId: mingoPendingId, canOpenMingo } = useFixWithMingo(); // 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; @@ -68,8 +69,9 @@ function IncidentHeader({ incidentId }: IncidentDetailsViewProps) { cornerColor="var(--ods-flamingo-cyan-base)" /> ), - onClick: () => useMingoLauncherStore.getState().draftToMingo(incidentMingoDraft(incident)), - disabled: !canOpenMingo, + onClick: () => fixWithMingo(incident), + disabled: !canOpenMingo || mingoPendingId !== null, + loading: mingoPendingId !== null, }, { label: 'Create Ticket', @@ -84,6 +86,8 @@ function IncidentHeader({ incidentId }: IncidentDetailsViewProps) { deviceName: incident.deviceName, assigneeId: ticketAssignee?.id, assigneeName: ticketAssignee?.name, + insightId: incident.insightId, + insightTitle: incident.title, }), }, ...(primary @@ -92,7 +96,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), }, ] : []), @@ -195,6 +199,7 @@ export const IncidentDetailsView = memo(function IncidentDetailsViewImpl({ incid + diff --git a/src/app/(app)/incidents/components/incident-sessions.tsx b/src/app/(app)/incidents/components/incident-sessions.tsx new file mode 100644 index 00000000..5e2672f4 --- /dev/null +++ b/src/app/(app)/incidents/components/incident-sessions.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { MingoIcon } from '@flamingo-stack/openframe-frontend-core/components/icons'; +import { Chevron01RightIcon } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; +import { TruncateText } from '@flamingo-stack/openframe-frontend-core/components/ui'; +import { Suspense } from 'react'; +import { openMingoDialogInDrawer } from '@/app/components/notifications/open-mingo-dialog'; +import { ContentErrorBoundary } from '@/app/components/shared'; +import { formatDateTime } from '@/lib/format-date'; +import type { DialogNode } from '../../mingo/types'; +import { useIncident } from '../hooks/use-incident'; +import { useIncidentDialogs } from '../hooks/use-incident-dialogs'; + +/** A title is generated after the first exchange; until then the list shows the same placeholder as the drawer. */ +const UNTITLED = 'New Chat'; + +function sessionMeta(dialog: DialogNode): string { + const user = dialog.owner?.user; + const by = user ? [user.firstName, user.lastName].filter(Boolean).join(' ') : ''; + const when = dialog.createdAt ? formatDateTime(dialog.createdAt) : ''; + return [when, by].filter(Boolean).join(' · '); +} + +function SessionsList({ insightId }: { insightId: string }) { + const { data: dialogs } = useIncidentDialogs(insightId); + if (!dialogs || dialogs.length === 0) return null; + return ( +
+

Mingo Sessions

+
+ {dialogs.map(dialog => ( + // Opens the drawer on that conversation — the same path a notification click takes. + + ))} +
+
+ ); +} + +/** The record is needed for its STORED id — the dialog filter keys on that, not the Relay handle. */ +function SessionsForIncident({ incidentId }: { incidentId: string }) { + const incident = useIncident(incidentId); + return ; +} + +/** + * The Mingo chats started from this incident, each opening in the drawer. + * Nothing is drawn while they load or when there are none — a secondary list + * under the notes, not a slot the page reserves. + */ +export function IncidentSessions({ incidentId }: { incidentId: string }) { + return ( + + + + + + ); +} diff --git a/src/app/(app)/incidents/components/incidents-table.tsx b/src/app/(app)/incidents/components/incidents-table.tsx index 0e1ddf05..aa5608a0 100644 --- a/src/app/(app)/incidents/components/incidents-table.tsx +++ b/src/app/(app)/incidents/components/incidents-table.tsx @@ -45,10 +45,9 @@ 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 { useFixWithMingo } from '../hooks/use-fix-with-mingo'; import { useIncidentTransitions } from '../hooks/use-incident-transitions'; -import { incidentMingoDraft } from '../utils/fix-with-mingo-prompt'; import { enumMembers, INCIDENT_SEVERITY_LABELS, @@ -57,7 +56,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'; @@ -130,7 +129,7 @@ function IncidentsTableContent({ }: IncidentsTableContentProps) { const { toast } = useToast(); const environment = useRelayEnvironment(); - const canOpenMingo = useMingoLauncherStore(state => state.canOpen); + const { fixWithMingo, pendingId: mingoPendingId, canOpenMingo } = useFixWithMingo(); // One round-trip per interaction: the filter facets (`insightFilters`) ride the // list operation — see the query docstring for the facet semantics. @@ -214,10 +213,11 @@ function IncidentsTableContent({ const { transition, snoozeTarget, cancelSnooze, confirmSnooze, isMutating, isSnoozing } = useIncidentTransitions(refreshFilterMeta); + const transitionTable = toTransitionTable(queryData); 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 [ @@ -306,14 +306,14 @@ 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). + // "Fix with Mingo": fetches the server's prompt for the incident and + // opens the drawer on a fresh chat with it in the composer — nothing + // sent. Disabled while no drawer is mounted (locked workspace). id: 'mingo', cell: ({ row }: { row: Row }) => (
@@ -350,7 +351,18 @@ function IncidentsTableContent({ meta: liveColumnMeta(INCIDENT_COLUMNS.open), }, ]; - }, [transition, isMutating, canOpenMingo, typeOptions, customerOptions, severityOptions, statusOptions]); + }, [ + transition, + transitionTable, + isMutating, + canOpenMingo, + fixWithMingo, + mingoPendingId, + typeOptions, + customerOptions, + severityOptions, + statusOptions, + ]); const filterGroups = [ { id: INCIDENT_COLUMNS.incident.id, title: 'Category', options: typeOptions }, diff --git a/src/app/(app)/incidents/components/transition-menu-items.tsx b/src/app/(app)/incidents/components/transition-menu-items.tsx index c5f15565..8697af08 100644 --- a/src/app/(app)/incidents/components/transition-menu-items.tsx +++ b/src/app/(app)/incidents/components/transition-menu-items.tsx @@ -9,7 +9,8 @@ import type { ActionsMenuItem } from '@flamingo-stack/openframe-frontend-core/co import type { ComponentType } from 'react'; import type { InsightStatus } from '@/generated/schema-enums'; import type { TransitionTarget } from '../hooks/use-incident-transitions'; -import { INCIDENT_TRANSITION_ACTIONS, transitionsFrom } from '../utils/incident-labels'; +import { INCIDENT_TRANSITION_ACTIONS } from '../utils/incident-labels'; +import { type IncidentTransitionTable, transitionsFrom } from '../utils/incident-transform'; const TRANSITION_ICON: Record> = { NEW: Refresh01LeftIcon, @@ -20,16 +21,18 @@ const TRANSITION_ICON: Record void, disabled: boolean, ): ActionsMenuItem[] { - return transitionsFrom(incident.status).map(status => { + return transitionsFrom(transitions, incident.status).map(status => { const Icon = TRANSITION_ICON[status]; return { id: `transition-${status}`, diff --git a/src/app/(app)/incidents/hooks/use-fix-with-mingo.ts b/src/app/(app)/incidents/hooks/use-fix-with-mingo.ts new file mode 100644 index 00000000..60393e42 --- /dev/null +++ b/src/app/(app)/incidents/hooks/use-fix-with-mingo.ts @@ -0,0 +1,49 @@ +'use client'; + +import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; +import { useState } from 'react'; +import { fetchQuery, useRelayEnvironment } from 'react-relay'; +import type { insightChatPromptRelayQuery as InsightChatPromptQueryType } from '@/__generated__/insightChatPromptRelayQuery.graphql'; +import { insightChatPromptRelayQuery } from '@/graphql/insights/insight-chat-prompt-relay'; +import { getRelayErrorMessage } from '@/lib/handle-api-error'; +import { useMingoLauncherStore } from '../../mingo/stores/mingo-launcher-store'; +import { incidentMingoDraft } from '../utils/fix-with-mingo-draft'; +import type { IncidentRow } from '../utils/incident-transform'; + +/** + * "Fix with Mingo": fetch the server's prompt for the incident and open the + * drawer on a fresh chat with it prefilled — nothing sent. `pendingId` is the + * incident whose prompt is in flight, for the button that started it. + */ +export function useFixWithMingo() { + const environment = useRelayEnvironment(); + const { toast } = useToast(); + const canOpenMingo = useMingoLauncherStore(state => state.canOpen); + const [pendingId, setPendingId] = useState(null); + + const fixWithMingo = (incident: IncidentRow) => { + if (pendingId !== null) return; + setPendingId(incident.id); + fetchQuery( + environment, + insightChatPromptRelayQuery, + { id: incident.id }, + { fetchPolicy: 'network-only' }, + ) + .toPromise() + .then(data => { + if (!data) return; + useMingoLauncherStore.getState().draftToMingo(incidentMingoDraft(data.insightChatPrompt, incident)); + }) + .catch((error: unknown) => { + toast({ + title: 'Error', + description: getRelayErrorMessage(error, 'Failed to prepare the Mingo chat'), + variant: 'destructive', + }); + }) + .finally(() => setPendingId(null)); + }; + + return { fixWithMingo, pendingId, canOpenMingo }; +} diff --git a/src/app/(app)/incidents/hooks/use-incident-dialogs.ts b/src/app/(app)/incidents/hooks/use-incident-dialogs.ts new file mode 100644 index 00000000..f41063e5 --- /dev/null +++ b/src/app/(app)/incidents/hooks/use-incident-dialogs.ts @@ -0,0 +1,32 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api-client'; +import { GET_MINGO_DIALOGS_QUERY } from '../../mingo/queries/dialogs-queries'; +import type { DialogNode, DialogsResponse } from '../../mingo/types'; + +const SESSIONS_LIMIT = 20; + +/** + * The Mingo chats started from this incident ("Fix with Mingo" links the dialog + * it creates to the insight). `/chat/graphql` is the ai-agent — raw POST by + * design, see the Data Fetching Strategy. Errors throw into the section's + * boundary rather than hiding a list the user may be looking for. + */ +export function useIncidentDialogs(insightId: string) { + return useQuery({ + queryKey: ['incident-dialogs', insightId], + queryFn: async (): Promise => { + const response = await apiClient.post('/chat/graphql', { + query: GET_MINGO_DIALOGS_QUERY, + variables: { filter: { agentTypes: ['ADMIN'], insightId }, pagination: { limit: SESSIONS_LIMIT } }, + }); + if (!response.ok || !response.data) { + throw new Error(response.error || 'Failed to load Mingo sessions'); + } + return response.data.data.dialogs.edges.map(edge => edge.node); + }, + throwOnError: true, + staleTime: 30 * 1000, + }); +} diff --git a/src/app/(app)/incidents/hooks/use-incident.ts b/src/app/(app)/incidents/hooks/use-incident.ts index ac28337c..e7cca559 100644 --- a/src/app/(app)/incidents/hooks/use-incident.ts +++ b/src/app/(app)/incidents/hooks/use-incident.ts @@ -4,7 +4,12 @@ import { useLazyLoadQuery } from 'react-relay'; import type { incidentDetailRelayQuery as IncidentDetailQueryType } from '@/__generated__/incidentDetailRelayQuery.graphql'; import { useRetryKey } from '@/app/components/shared'; import { incidentDetailRelayQuery } from '@/graphql/insights/incident-detail-relay'; -import { type Incident, toIncident } from '../utils/incident-transform'; +import { + type Incident, + type IncidentTransitionTable, + toIncident, + toTransitionTable, +} from '../utils/incident-transform'; /** * The detail page's record, for every island that draws it. One place for the @@ -14,11 +19,16 @@ import { type Incident, toIncident } from '../utils/incident-transform'; * replays a retained rejection after Retry (see `ScriptSummary`). */ export function useIncident(incidentId: string): Incident { + return useIncidentDetail(incidentId).incident; +} + +/** The record plus the status-transition table that rides on the same query (the header needs both). */ +export function useIncidentDetail(incidentId: string): { incident: Incident; transitions: IncidentTransitionTable } { const retryKey = useRetryKey(); const data = useLazyLoadQuery( incidentDetailRelayQuery, { id: incidentId }, { fetchPolicy: 'store-and-network', fetchKey: retryKey }, ); - return toIncident(data.insight); + return { incident: toIncident(data.insight), transitions: toTransitionTable(data) }; } diff --git a/src/app/(app)/incidents/utils/fix-with-mingo-draft.test.ts b/src/app/(app)/incidents/utils/fix-with-mingo-draft.test.ts new file mode 100644 index 00000000..2b3d29c6 --- /dev/null +++ b/src/app/(app)/incidents/utils/fix-with-mingo-draft.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { InsightSeverity, InsightStatus, InsightType } from '@/generated/schema-enums'; +import { incidentMingoDraft } from './fix-with-mingo-draft'; +import type { IncidentRow } from './incident-transform'; + +const incident: IncidentRow = { + id: 'SW5zaWdodDpxYS1pbnNpZ2h0LTI', + insightId: 'qa-insight-2', + title: 'macOS firewall is off', + type: InsightType.SECURITY, + severity: InsightSeverity.HIGH, + status: InsightStatus.NEW, + snoozedUntil: null, + detectedAt: '2026-09-16T23:02:00Z', + machineId: 'm-1', + hasDevice: true, + deviceName: 'MacBook-Pro-Kirill.local', + deviceType: 'laptop', + organizationId: 'o-1', + organizationName: 'куукку', +}; + +describe('incidentMingoDraft', () => { + it('turns the leading markers into labelled mentions and keeps the ask as text', () => { + const draft = incidentMingoDraft( + '@insight:qa-insight-2 @device:m-1\n\nUsing the insight and device context, investigate this problem.', + incident, + ); + expect(draft.mentions).toEqual([ + { type: 'INSIGHT', id: 'qa-insight-2', label: 'macOS firewall is off' }, + { type: 'DEVICE', id: 'm-1', label: 'MacBook-Pro-Kirill.local' }, + ]); + expect(draft.text).toBe('Using the insight and device context, investigate this problem.'); + expect(draft.insightId).toBe('qa-insight-2'); + }); + + it('labels an unexpected id with the id itself and leaves an unknown marker in the text', () => { + const draft = incidentMingoDraft('@insight:other @widget:w-1 look', incident); + expect(draft.mentions).toEqual([{ type: 'INSIGHT', id: 'other', label: 'other' }]); + expect(draft.text).toBe('@widget:w-1 look'); + }); + + it('copes with a prompt that has no markers', () => { + expect(incidentMingoDraft('just text', incident)).toEqual({ + text: 'just text', + mentions: [], + insightId: 'qa-insight-2', + }); + }); +}); diff --git a/src/app/(app)/incidents/utils/fix-with-mingo-draft.ts b/src/app/(app)/incidents/utils/fix-with-mingo-draft.ts new file mode 100644 index 00000000..046e8d8d --- /dev/null +++ b/src/app/(app)/incidents/utils/fix-with-mingo-draft.ts @@ -0,0 +1,41 @@ +import { CONTEXT_ENTITY_KIND, CONTEXT_ENTITY_MARKER, type ContextEntityKind } from '../../mingo/context/context-types'; +import type { MingoDraft } from '../../mingo/stores/mingo-launcher-store'; +import type { IncidentRow } from './incident-transform'; + +const KIND_BY_MARKER = new Map( + (Object.entries(CONTEXT_ENTITY_MARKER) as [ContextEntityKind, string][]).map(([kind, marker]) => [marker, kind]), +); + +/** One leading `@marker:id` token of the server prompt. */ +const LEADING_MARKER = /^@([A-Za-z]+):(\S+)\s*/; + +/** + * The composer draft for "Fix with Mingo", from the server's `insightChatPrompt`. + * + * The prompt opens with the context markers (`@insight: @device:`) + * and continues with the ask. The markers become ATTACHED mentions rather than + * text — a `@marker:id` left in the text is parsed into a chip labelled with the + * id and outside the context strip — with the labels the row already knows; + * the ask stays as the text. A marker of a kind this build does not know is + * left in the text rather than dropped. + */ +export function incidentMingoDraft(prompt: string, incident: IncidentRow): MingoDraft { + const mentions: NonNullable = []; + let rest = prompt.trimStart(); + for (;;) { + const match = LEADING_MARKER.exec(rest); + if (!match) break; + const [token, marker, id] = match; + const kind = KIND_BY_MARKER.get(marker); + if (!kind) break; + mentions.push({ type: kind, id, label: labelFor(kind, id, incident) }); + rest = rest.slice(token.length); + } + return { text: rest.trim(), mentions, insightId: incident.insightId }; +} + +function labelFor(kind: ContextEntityKind, id: string, incident: IncidentRow): string { + if (kind === CONTEXT_ENTITY_KIND.INSIGHT && id === incident.insightId) return incident.title; + if (kind === CONTEXT_ENTITY_KIND.DEVICE && id === incident.machineId) return incident.deviceName; + return id; +} diff --git a/src/app/(app)/incidents/utils/fix-with-mingo-prompt.ts b/src/app/(app)/incidents/utils/fix-with-mingo-prompt.ts deleted file mode 100644 index ac7f3e91..00000000 --- a/src/app/(app)/incidents/utils/fix-with-mingo-prompt.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { formatDateTime } from '@/lib/format-date'; -import { CONTEXT_ENTITY_KIND } from '../../mingo/context/context-types'; -import type { MingoDraft } from '../../mingo/stores/mingo-launcher-store'; -import { INCIDENT_SEVERITY_LABELS, INCIDENT_TYPE_LABELS, labelOf } from './incident-labels'; -import type { Incident, IncidentRow } from './incident-transform'; - -/** Evidence rows past this many are summarized as a count — the prompt is for a chat, not a report. */ -const MAX_EVIDENCE_ROWS = 10; - -/** - * The draft "Fix with Mingo" puts in a fresh Mingo composer — NOT sent; the - * technician reads it, edits it, and decides. The incident rides along twice: - * as an attached `@insight:` context chip (what the ai-agent resolves once - * its insight resolver is real — today it answers "not available in this - * build") and spelled out in the text, so the prompt is useful either way. The - * detail page passes the full `Incident`, so the description and evidence - * ride along. - */ -export function incidentMingoDraft(incident: IncidentRow | Incident): MingoDraft { - return { - text: incidentMingoPrompt(incident), - // The device rides as a chip too (raw `machineId`, what the DEVICE resolver - // and `@device:` marker take) rather than as a `@device:` token in the text — - // the composer would parse that into an id-labelled chip that is not in the - // context strip. - mentions: [ - { type: CONTEXT_ENTITY_KIND.INSIGHT, id: incident.id, label: incident.title }, - { type: CONTEXT_ENTITY_KIND.DEVICE, id: incident.machineId, label: incident.deviceName }, - ], - }; -} - -function incidentMingoPrompt(incident: IncidentRow | Incident): string { - const customer = incident.organizationName ? ` (${incident.organizationName})` : ''; - const lines = [ - `Incident: ${incident.title}`, - `Category: ${labelOf(INCIDENT_TYPE_LABELS, incident.type)} · Severity: ${labelOf(INCIDENT_SEVERITY_LABELS, incident.severity)}`, - `Device: ${incident.deviceName}${customer}`, - `Detected: ${formatDateTime(incident.detectedAt)}`, - ]; - - if ('description' in incident && incident.description) { - lines.push(`What the check looks for: ${incident.description}`); - } - - if ('queryResult' in incident && incident.queryResult && incident.queryResult.length > 0) { - const rows = incident.queryResult; - lines.push('', `Evidence (${rows.length} row${rows.length === 1 ? '' : 's'} from the latest run):`); - for (const row of rows.slice(0, MAX_EVIDENCE_ROWS)) { - lines.push( - `- ${Object.entries(row) - .map(([key, value]) => `${key}: ${value ?? '—'}`) - .join(', ')}`, - ); - } - if (rows.length > MAX_EVIDENCE_ROWS) { - lines.push(`- … and ${rows.length - MAX_EVIDENCE_ROWS} more`); - } - } - - lines.push( - '', - 'Investigate this incident on the device, explain what it means and how urgent it is, and propose a fix.', - ); - return lines.join('\n'); -} diff --git a/src/app/(app)/incidents/utils/incident-labels.ts b/src/app/(app)/incidents/utils/incident-labels.ts index 4c94e58c..6b0a8459 100644 --- a/src/app/(app)/incidents/utils/incident-labels.ts +++ b/src/app/(app)/incidents/utils/incident-labels.ts @@ -30,26 +30,6 @@ export function labelOf(labels: Record, value: string): string { return labels[value] ?? value; } -/** - * Which statuses an incident may move to from each status — a mirror of the - * backend's `InsightStatusTransitionValidator`, so the UI offers only actions - * the server will accept. Archiving is reachable only from RESOLVED: an - * incident is filed away after it was dealt with, never instead of it. - */ -const INCIDENT_TRANSITIONS: Record = { - NEW: [InsightStatus.ACKNOWLEDGED, InsightStatus.SNOOZED, InsightStatus.RESOLVED], - ACKNOWLEDGED: [InsightStatus.SNOOZED, InsightStatus.RESOLVED], - SNOOZED: [InsightStatus.ACKNOWLEDGED, InsightStatus.RESOLVED], - RESOLVED: [InsightStatus.ARCHIVED, InsightStatus.NEW], - ARCHIVED: [InsightStatus.NEW], -}; - -/** Transitions offered from `status`; none for a status this build does not know (`"%future added value"`). */ -export function transitionsFrom(status: string): readonly InsightStatus[] { - const transitions: Record = INCIDENT_TRANSITIONS; - return transitions[status] ?? []; -} - /** How a transition INTO each status is named: the menu item, and the past tense the toast uses. */ export const INCIDENT_TRANSITION_ACTIONS: Record = { NEW: { label: 'Reopen', done: 'reopened' }, diff --git a/src/app/(app)/incidents/utils/incident-transform.ts b/src/app/(app)/incidents/utils/incident-transform.ts index fcb03cb7..f65e4c2f 100644 --- a/src/app/(app)/incidents/utils/incident-transform.ts +++ b/src/app/(app)/incidents/utils/incident-transform.ts @@ -5,13 +5,18 @@ import type { insightRowFields_insight$data, insightRowFields_insight$key, } from '@/__generated__/insightRowFields_insight.graphql'; +import type { insightTransitions_query$key } from '@/__generated__/insightTransitions_query.graphql'; import type { insightUserFields_user$key } from '@/__generated__/insightUserFields_user.graphql'; import { DELETED_EMPLOYEE_LABEL, isDeletedUserStatus } from '@/app/components/shared/deleted-user'; +import { InsightStatus } from '@/generated/schema-enums'; import { insightFieldsFragment } from '@/graphql/insights/insight-fields'; import { insightRowFieldsFragment } from '@/graphql/insights/insight-row-fields'; +import { insightTransitionsFragment } from '@/graphql/insights/insight-transitions'; import { insightUserFieldsFragment } from '@/graphql/insights/insight-user-fields'; import { getFullImageUrl } from '@/lib/image-url'; +import { decodeGlobalId } from '@/lib/relay-id'; import { getDeviceName } from '../../devices/utils/device-name'; +import { enumMembers } from './incident-labels'; /** * A row of the Incidents table. The enum fields keep the artifact's own union @@ -21,6 +26,12 @@ import { getDeviceName } from '../../devices/utils/device-name'; */ export interface IncidentRow { id: string; + /** + * The STORED id (`id` is the Relay global handle around it) — what the + * ai-agent's insight resolver looks up, so it is what every Mingo context + * reference (`@insight:` chip, open view, dialog link) carries. + */ + insightId: string; title: string; type: insightRowFields_insight$data['type']; severity: insightRowFields_insight$data['severity']; @@ -43,6 +54,7 @@ export function toIncidentRow(ref: insightRowFields_insight$key): IncidentRow { const machine = node.machine; return { id: node.id, + insightId: decodeGlobalId(node.id)?.rawId ?? node.id, title: node.title, type: node.type, severity: node.severity, @@ -121,3 +133,20 @@ export function toIncident(ref: insightFields_insight$key): Incident { organizationImageUrl: getFullImageUrl(node.organization?.image?.imageUrl, node.organization?.image?.hash), }; } + +/** Which statuses an incident may move to from each status — the server's table, keyed by `from`. */ +export type IncidentTransitionTable = Readonly>; + +/** `insightStatusTransitions` as a lookup. Statuses this build does not know are dropped, not offered. */ +export function toTransitionTable(ref: insightTransitions_query$key): IncidentTransitionTable { + const data = readInlineData(insightTransitionsFragment, ref); + const table: Record = {}; + for (const entry of data.insightStatusTransitions) { + table[entry.from] = enumMembers(entry.to, InsightStatus); + } + return table; +} + +export function transitionsFrom(table: IncidentTransitionTable, status: string): readonly InsightStatus[] { + return table[status] ?? []; +} diff --git a/src/app/(app)/mingo/context/relay-items.tsx b/src/app/(app)/mingo/context/relay-items.tsx index 2a465f3e..04246842 100644 --- a/src/app/(app)/mingo/context/relay-items.tsx +++ b/src/app/(app)/mingo/context/relay-items.tsx @@ -486,7 +486,9 @@ export function IncidentItems({ query, selectedKeys, onToggle, atLimit }: Contex return [ { type: CONTEXT_ENTITY_KIND.INSIGHT, - id: e.node.id, + // The stored id, not the Relay handle: that is what the ai-agent's + // resolver looks up (the chip re-encodes it for its own query). + id: decodeGlobalId(e.node.id)?.rawId ?? e.node.id, label: e.node.title, description: [labelOf(INCIDENT_SEVERITY_LABELS, e.node.severity), device].filter(Boolean).join(' · '), }, diff --git a/src/app/(app)/mingo/hooks/use-mingo-unified-chat-state.ts b/src/app/(app)/mingo/hooks/use-mingo-unified-chat-state.ts index 5e2a4106..246437cd 100644 --- a/src/app/(app)/mingo/hooks/use-mingo-unified-chat-state.ts +++ b/src/app/(app)/mingo/hooks/use-mingo-unified-chat-state.ts @@ -37,6 +37,7 @@ import type { } from '@flamingo-stack/openframe-frontend-core/components/chat'; import { buildDiscussPrompt } from '@flamingo-stack/openframe-frontend-core/components/chat'; import { useCallback, useDeferredValue, useMemo, useState } from 'react'; +import { useMingoLauncherStore } from '@/app/(app)/mingo/stores/mingo-launcher-store'; import { useAuthStore } from '@/app/(auth)/auth/stores/auth-store'; import { useAiModelStatus } from '@/app/hooks/use-ai-model'; import { EVENT_SUBTYPE, trackDashboardActivity } from '@/lib/analytics'; @@ -332,6 +333,9 @@ export function useMingoUnifiedChatState(): MingoUnifiedChat { // ─── Dialog selection (mirrors the /mingo page glue, minus URL syncing) ─── const selectDialog = useCallback( (id: string | null) => { + // Any change of conversation leaves the fresh chat a "Fix with Mingo" draft + // was linking to an insight (the entry re-sets the link after its prefill). + useMingoLauncherStore.getState().setDialogInsightId(null); if (id === null) { setActiveDialogId(null); return; diff --git a/src/app/(app)/mingo/services/mingo-api-service.ts b/src/app/(app)/mingo/services/mingo-api-service.ts index b3b03212..1f3f4e45 100644 --- a/src/app/(app)/mingo/services/mingo-api-service.ts +++ b/src/app/(app)/mingo/services/mingo-api-service.ts @@ -1,5 +1,6 @@ import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; import { useMutation } from '@tanstack/react-query'; +import { useMingoLauncherStore } from '@/app/(app)/mingo/stores/mingo-launcher-store'; import { apiClient } from '@/lib/api-client'; export interface CreateDialogResponse { @@ -25,6 +26,8 @@ export interface ApprovalResponse { export interface CreateDialogRequest { agentType: 'ADMIN'; + /** The STORED insight id when the chat is started from an incident ("Fix with Mingo"). */ + insightId?: string; } /** Minimal entity ref carried in the message payload. */ @@ -58,9 +61,10 @@ export interface SendMessageRequest { export function useCreateDialogMutation() { return useMutation({ mutationFn: async (): Promise => { - const response = await apiClient.post('/chat/api/v1/dialogs', { - agentType: 'ADMIN', - } as CreateDialogRequest); + // Consumed here, on the one create the draft chat produces. + const insightId = useMingoLauncherStore.getState().consumeDialogInsightId(); + const request: CreateDialogRequest = { agentType: 'ADMIN', ...(insightId && { insightId }) }; + const response = await apiClient.post('/chat/api/v1/dialogs', request); if (!response.ok) { throw new Error(response.error || `Failed to create dialog with status ${response.status}`); diff --git a/src/app/(app)/mingo/stores/mingo-launcher-store.ts b/src/app/(app)/mingo/stores/mingo-launcher-store.ts index 06d40a5a..fbf2d79f 100644 --- a/src/app/(app)/mingo/stores/mingo-launcher-store.ts +++ b/src/app/(app)/mingo/stores/mingo-launcher-store.ts @@ -3,7 +3,10 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; /** What `draftToMingo` puts in the composer — relayed verbatim to the panel's `prefillDraft`. */ -export type MingoDraft = ChatPrefillDraft; +export type MingoDraft = ChatPrefillDraft & { + /** The STORED insight id this draft is about; the dialog created from it is linked to the insight. */ + insightId?: string; +}; /** * Owns the Mingo drawer's open state (lifted out of `AppShell` so any page can @@ -37,6 +40,14 @@ interface MingoLauncherStore { pendingDraft: MingoDraft | null; /** One-shot "open on a fresh chat" request; false once consumed. */ pendingNewChat: boolean; + /** + * The insight the composer's current fresh chat was started from ("Fix with + * Mingo"), for `POST /dialogs` to link the two. Set once the draft is in the + * composer; consumed by the create; dropped by anything that leaves that + * fresh chat — another conversation, a new chat, a close (the drawer unmounts + * on close, and the draft with it). + */ + dialogInsightId: string | null; /** * The drawer was closed BY a navigation the same handler had just issued, so it * does not own the URL on this pass — the destination is already param-free. @@ -69,6 +80,9 @@ interface MingoLauncherStore { startNewChat: () => void; /** Read and clear the pending new-chat request (safe against double-consume). */ consumePendingNewChat: () => boolean; + setDialogInsightId: (insightId: string | null) => void; + /** Read and clear the insight link in one step — for the one dialog create it belongs to. */ + consumeDialogInsightId: () => string | null; } export const useMingoLauncherStore = create()( @@ -79,18 +93,26 @@ export const useMingoLauncherStore = create()( pendingPrompt: null, pendingDraft: null, pendingNewChat: false, + dialogInsightId: null, closedForNavigation: false, - setOpen: open => set(open ? { isOpen: true, closedForNavigation: false } : { isOpen: false }, false, 'setOpen'), + setOpen: open => + set( + open ? { isOpen: true, closedForNavigation: false } : { isOpen: false, dialogInsightId: null }, + false, + 'setOpen', + ), setCanOpen: canOpen => set({ canOpen }, false, 'setCanOpen'), toggle: () => set( - state => (state.isOpen ? { isOpen: false } : { isOpen: true, closedForNavigation: false }), + state => + state.isOpen ? { isOpen: false, dialogInsightId: null } : { isOpen: true, closedForNavigation: false }, false, 'toggle', ), - close: () => set({ isOpen: false }, false, 'close'), - closeForNavigation: () => set({ isOpen: false, closedForNavigation: true }, false, 'closeForNavigation'), + close: () => set({ isOpen: false, dialogInsightId: null }, false, 'close'), + closeForNavigation: () => + set({ isOpen: false, dialogInsightId: null, closedForNavigation: true }, false, 'closeForNavigation'), sendToMingo: prompt => set( @@ -99,6 +121,7 @@ export const useMingoLauncherStore = create()( pendingPrompt: prompt, pendingDraft: null, pendingNewChat: false, + dialogInsightId: null, closedForNavigation: false, }, false, @@ -126,11 +149,26 @@ export const useMingoLauncherStore = create()( startNewChat: () => set( - { isOpen: true, pendingNewChat: true, pendingPrompt: null, pendingDraft: null, closedForNavigation: false }, + { + isOpen: true, + pendingNewChat: true, + pendingPrompt: null, + pendingDraft: null, + dialogInsightId: null, + closedForNavigation: false, + }, false, 'startNewChat', ), + setDialogInsightId: insightId => set({ dialogInsightId: insightId }, false, 'setDialogInsightId'), + + consumeDialogInsightId: () => { + const { dialogInsightId } = get(); + if (dialogInsightId !== null) set({ dialogInsightId: null }, false, 'consumeDialogInsightId'); + return dialogInsightId; + }, + consumePendingNewChat: () => { const { pendingNewChat } = get(); if (pendingNewChat) set({ pendingNewChat: false }, false, 'consumePendingNewChat'); diff --git a/src/app/(app)/tickets/components/create-edit/ticket-form-fields.tsx b/src/app/(app)/tickets/components/create-edit/ticket-form-fields.tsx index 165d6dcd..38f171ca 100644 --- a/src/app/(app)/tickets/components/create-edit/ticket-form-fields.tsx +++ b/src/app/(app)/tickets/components/create-edit/ticket-form-fields.tsx @@ -1,14 +1,17 @@ 'use client'; 'use no memo'; +import { AlertTriangleIcon } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; import { Autocomplete, FileUpload, Input, Label } from '@flamingo-stack/openframe-frontend-core/components/ui'; import { useDebounce } from '@flamingo-stack/openframe-frontend-core/hooks'; +import Link from 'next/link'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Controller, type UseFormReturn, useWatch } from 'react-hook-form'; import { AssignmentsField } from '@/components/assignments'; import { getFullImageUrl } from '@/lib/image-url'; import { nativeFilePicker, type UploadSource } from '@/lib/native-files'; -import type { TicketPrefill } from '@/lib/routes'; +import { toGlobalId } from '@/lib/relay-id'; +import { routes, type TicketPrefill } from '@/lib/routes'; import type { useTempAttachments } from '../../hooks/use-temp-attachments'; import { type AutocompleteOption, @@ -204,6 +207,22 @@ export function TicketFormFields({ )} /> + {/* Filed from an incident: the link travels as the STORED insight id, the + details route takes the global one. Read-only — the incident is where + the ticket came from, not a field of it. */} + {prefill?.insightId && ( +
+ + + + {prefill.insightTitle || prefill.insightId} + +
+ )} + {/* Organization, Device, Assigned, Status — 4-column grid (2 on mobile) */}
>; From ee7cb4a1b5ad7fb7e0fd276c35e84a06b18d4a1c Mon Sep 17 00:00:00 2001 From: Oleksandr Koltunov Date: Mon, 21 Sep 2026 11:19:05 +0300 Subject: [PATCH 2/7] =?UTF-8?q?feat(incidents):=20incident=20=E2=86=94=20t?= =?UTF-8?q?icket=20link=20through=20the=20assignments=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openframe-oss-lib#2248 records a ticket filed from an incident as an assignment owned by the insight (item INSIGHT → target TICKET, from `CreateTicketInput.insightId`). So: - The incident page shows its tickets through `AssignedItemsView` pointed at the incident (`itemType: INSIGHT`) under an "Assigned Ticket" heading — the ticket page's component, not a new table. - The ticket form shows the incident it is filed from as a read-only `AssignmentsField` row (INSIGHT is a client-only target: not pickable, never written through `assignItem`; it goes out as `insightId`). The bespoke "Assigned Incident" block is gone. - `AssignmentItemType` gains INSIGHT; `ASSIGNMENT_TARGET_TYPES` stays the server list, `ALL_ASSIGNMENT_TARGET_TYPES` adds the client-only target. - The incident's Mingo-sessions query lives under the `mingo-dialogs` key, so the drawer's create invalidates it. Verified on tenant-3149-53: transitions from the server table (ACKNOWLEDGED offers only Resolve), Fix with Mingo from `insightChatPrompt` with both chips resolved by the real resolver, the created dialog linked to the insight and listed on the incident, Create Ticket → the ticket listed under Assigned Ticket. Co-Authored-By: Claude Fable 5.1 --- .../components/incident-assigned-tickets.tsx | 21 ++++++++++++++++ .../components/incident-details-view.tsx | 2 ++ .../incidents/hooks/use-incident-dialogs.ts | 4 +++- .../create-edit/ticket-form-fields.tsx | 24 +++---------------- .../tickets/hooks/use-create-ticket-form.ts | 9 +++++-- .../assignments/assigned-items-view.tsx | 16 ++++++++----- src/components/assignments/assignment-row.tsx | 8 ++++--- .../assignments/assignments-field.tsx | 16 ++++++++++--- src/components/assignments/relay-id.ts | 1 + src/components/assignments/schema.ts | 1 + src/components/assignments/target-config.ts | 20 ++++++++++++++-- src/components/assignments/types.ts | 22 ++++++++++++++--- .../assignments/use-assigned-items.ts | 11 +++++---- .../assignments/use-assignment-search.ts | 17 +++++++++---- 14 files changed, 122 insertions(+), 50 deletions(-) create mode 100644 src/app/(app)/incidents/components/incident-assigned-tickets.tsx 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..05e3378f --- /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, like the Mingo sessions. + */ +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 8814b9cd..07befd53 100644 --- a/src/app/(app)/incidents/components/incident-details-view.tsx +++ b/src/app/(app)/incidents/components/incident-details-view.tsx @@ -16,6 +16,7 @@ import { useIncident, useIncidentDetail } from '../hooks/use-incident'; import { useIncidentTransitions } from '../hooks/use-incident-transitions'; 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'; @@ -200,6 +201,7 @@ export const IncidentDetailsView = memo(function IncidentDetailsViewImpl({ incid + diff --git a/src/app/(app)/incidents/hooks/use-incident-dialogs.ts b/src/app/(app)/incidents/hooks/use-incident-dialogs.ts index f41063e5..b17d8ceb 100644 --- a/src/app/(app)/incidents/hooks/use-incident-dialogs.ts +++ b/src/app/(app)/incidents/hooks/use-incident-dialogs.ts @@ -15,7 +15,9 @@ const SESSIONS_LIMIT = 20; */ export function useIncidentDialogs(insightId: string) { return useQuery({ - queryKey: ['incident-dialogs', insightId], + // Under the `mingo-dialogs` prefix: the drawer invalidates that on every + // dialog it creates, which is exactly when this list changes. + queryKey: ['mingo-dialogs', 'insight', insightId], queryFn: async (): Promise => { const response = await apiClient.post('/chat/graphql', { query: GET_MINGO_DIALOGS_QUERY, diff --git a/src/app/(app)/tickets/components/create-edit/ticket-form-fields.tsx b/src/app/(app)/tickets/components/create-edit/ticket-form-fields.tsx index 38f171ca..726e6b9d 100644 --- a/src/app/(app)/tickets/components/create-edit/ticket-form-fields.tsx +++ b/src/app/(app)/tickets/components/create-edit/ticket-form-fields.tsx @@ -1,17 +1,14 @@ 'use client'; 'use no memo'; -import { AlertTriangleIcon } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; import { Autocomplete, FileUpload, Input, Label } from '@flamingo-stack/openframe-frontend-core/components/ui'; import { useDebounce } from '@flamingo-stack/openframe-frontend-core/hooks'; -import Link from 'next/link'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Controller, type UseFormReturn, useWatch } from 'react-hook-form'; import { AssignmentsField } from '@/components/assignments'; import { getFullImageUrl } from '@/lib/image-url'; import { nativeFilePicker, type UploadSource } from '@/lib/native-files'; -import { toGlobalId } from '@/lib/relay-id'; -import { routes, type TicketPrefill } from '@/lib/routes'; +import type { TicketPrefill } from '@/lib/routes'; import type { useTempAttachments } from '../../hooks/use-temp-attachments'; import { type AutocompleteOption, @@ -207,22 +204,6 @@ export function TicketFormFields({ )} /> - {/* Filed from an incident: the link travels as the STORED insight id, the - details route takes the global one. Read-only — the incident is where - the ticket came from, not a field of it. */} - {prefill?.insightId && ( -
- - - - {prefill.insightTitle || prefill.insightId} - -
- )} - {/* Organization, Device, Assigned, Status — 4-column grid (2 on mobile) */}
)} /> diff --git a/src/app/(app)/tickets/hooks/use-create-ticket-form.ts b/src/app/(app)/tickets/hooks/use-create-ticket-form.ts index 313b5b39..6ea7ecc6 100644 --- a/src/app/(app)/tickets/hooks/use-create-ticket-form.ts +++ b/src/app/(app)/tickets/hooks/use-create-ticket-form.ts @@ -81,7 +81,12 @@ export function useCreateTicketForm({ ticketId, prefill }: UseCreateTicketFormOp tagIds: [], description: prefill?.description ?? '', assignKnowledgeBase: false, - assignments: {}, + // Filed from an incident: the link is an assignment row on the form (read-only) + // and goes out as `CreateTicketInput.insightId` — the server owns the + // incident → ticket assignment, so it is not written through `assignItem`. + assignments: prefill?.insightId + ? { INSIGHT: [{ id: prefill.insightId, label: prefill.insightTitle || prefill.insightId }] } + : {}, }, }); @@ -188,7 +193,7 @@ export function useCreateTicketForm({ ticketId, prefill }: UseCreateTicketFormOp organizationId: data.organizationId || undefined, deviceId: data.deviceId || undefined, assigneeId: data.assignedTo || undefined, - insightId: prefill?.insightId, + insightId: nextAssignments.INSIGHT?.[0]?.id, tagIds: tagIds.length ? tagIds : undefined, tempAttachmentIds: tempAttachmentIds.length ? tempAttachmentIds : undefined, }); diff --git a/src/components/assignments/assigned-items-view.tsx b/src/components/assignments/assigned-items-view.tsx index 4cbb1312..5ab15dd3 100644 --- a/src/components/assignments/assigned-items-view.tsx +++ b/src/components/assignments/assigned-items-view.tsx @@ -7,7 +7,7 @@ import { DevicesAssignedTable } from './tables/devices-assigned-table'; import { KnowledgeBaseAssignedTable } from './tables/knowledge-base-assigned-table'; import { TicketsAssignedTable } from './tables/tickets-assigned-table'; import { TARGET_CONFIG } from './target-config'; -import { ASSIGNMENT_TARGET_TYPES, type AssignmentItemType, type AssignmentTargetType } from './types'; +import { ASSIGNMENT_TARGET_TYPES, type AssignmentItemType, type ServerAssignmentTargetType } from './types'; import { useAssignedItems } from './use-assigned-items'; export interface AssignedItemsViewProps { @@ -24,7 +24,7 @@ export function AssignedItemsView({ itemId, itemType, className, showTitle = tru const activeTypes = useMemo(() => ASSIGNMENT_TARGET_TYPES.filter(type => (value[type]?.length ?? 0) > 0), [value]); - const [pinnedTab, setPinnedTab] = useState(null); + const [pinnedTab, setPinnedTab] = useState(null); const activeTab = pinnedTab && activeTypes.includes(pinnedTab) ? pinnedTab : activeTypes[0]; const tabs: TabItem[] = useMemo( @@ -37,7 +37,7 @@ export function AssignedItemsView({ itemId, itemType, className, showTitle = tru [activeTypes], ); - const renderTabBody = (type: AssignmentTargetType) => { + const renderTabBody = (type: ServerAssignmentTargetType) => { switch (type) { case 'ORGANIZATION': return ; @@ -48,7 +48,7 @@ export function AssignedItemsView({ itemId, itemType, className, showTitle = tru case 'TICKET': return ; default: { - // `type` is `never` here because the cases above cover AssignmentTargetType. + // `type` is `never` here because the cases above cover ServerAssignmentTargetType. // Adding a member to that union makes this assignment fail to compile, which // is the point — a silent `undefined` tab body would just render nothing. const unreachable: never = type; @@ -76,9 +76,13 @@ export function AssignedItemsView({ itemId, itemType, className, showTitle = tru {activeTypes.length === 1 ? (
{renderTabBody(activeTypes[0])}
) : ( - setPinnedTab(id as AssignmentTargetType)}> + setPinnedTab(id as ServerAssignmentTargetType)} + > {active => ( -
{renderTabBody(active as AssignmentTargetType)}
+
{renderTabBody(active as ServerAssignmentTargetType)}
)}
)} diff --git a/src/components/assignments/assignment-row.tsx b/src/components/assignments/assignment-row.tsx index bdebbcea..9249092a 100644 --- a/src/components/assignments/assignment-row.tsx +++ b/src/components/assignments/assignment-row.tsx @@ -13,12 +13,14 @@ interface AssignmentRowProps { onChange: (next: AssignmentRef[]) => void; onRemoveRow: () => void; disabled?: boolean; + /** The picker cannot change the set — the row shows it, and the trash drops it. */ + readOnly?: boolean; } -export function AssignmentRow({ targetType, value, onChange, onRemoveRow, disabled }: AssignmentRowProps) { +export function AssignmentRow({ targetType, value, onChange, onRemoveRow, disabled, readOnly }: AssignmentRowProps) { const meta = TARGET_CONFIG[targetType]; const [searchInput, setSearchInput] = useState(''); - const { options, isLoading } = useAssignmentSearch(targetType, searchInput); + const { options, isLoading } = useAssignmentSearch(targetType, searchInput, !readOnly); const selectedIds = useMemo(() => value.map(ref => ref.id), [value]); @@ -59,7 +61,7 @@ export function AssignmentRow({ targetType, value, onChange, onRemoveRow, disabl placeholder="Add More..." loading={isLoading} disableClientFilter - disabled={disabled} + disabled={disabled || readOnly} />
- ))} -
- - ); -} - -/** The record is needed for its STORED id — the dialog filter keys on that, not the Relay handle. */ -function SessionsForIncident({ incidentId }: { incidentId: string }) { - const incident = useIncident(incidentId); - return ; -} - -/** - * The Mingo chats started from this incident, each opening in the drawer. - * Nothing is drawn while they load or when there are none — a secondary list - * under the notes, not a slot the page reserves. - */ -export function IncidentSessions({ incidentId }: { incidentId: string }) { - return ( - - - - - - ); -} diff --git a/src/app/(app)/incidents/hooks/use-incident-dialogs.ts b/src/app/(app)/incidents/hooks/use-incident-dialogs.ts index b17d8ceb..feb30343 100644 --- a/src/app/(app)/incidents/hooks/use-incident-dialogs.ts +++ b/src/app/(app)/incidents/hooks/use-incident-dialogs.ts @@ -5,30 +5,29 @@ import { apiClient } from '@/lib/api-client'; import { GET_MINGO_DIALOGS_QUERY } from '../../mingo/queries/dialogs-queries'; import type { DialogNode, DialogsResponse } from '../../mingo/types'; -const SESSIONS_LIMIT = 20; - /** - * The Mingo chats started from this incident ("Fix with Mingo" links the dialog - * it creates to the insight). `/chat/graphql` is the ai-agent — raw POST by - * design, see the Data Fetching Strategy. Errors throw into the section's - * boundary rather than hiding a list the user may be looking for. + * The latest Mingo chat started from this incident, or null — "Fix with Mingo" + * links the dialog it creates to the insight, and the header offers to reopen + * it instead of starting another. `/chat/graphql` is the ai-agent (raw POST by + * design, see the Data Fetching Strategy). A failed lookup is not surfaced: the + * header then simply offers a fresh chat, which is the right fallback. */ -export function useIncidentDialogs(insightId: string) { - return useQuery({ +export function useLatestIncidentDialog(insightId: string): { dialog: DialogNode | null; isLoading: boolean } { + const query = useQuery({ // Under the `mingo-dialogs` prefix: the drawer invalidates that on every - // dialog it creates, which is exactly when this list changes. + // dialog it creates, which is exactly when this answer changes. queryKey: ['mingo-dialogs', 'insight', insightId], - queryFn: async (): Promise => { + queryFn: async (): Promise => { const response = await apiClient.post('/chat/graphql', { query: GET_MINGO_DIALOGS_QUERY, - variables: { filter: { agentTypes: ['ADMIN'], insightId }, pagination: { limit: SESSIONS_LIMIT } }, + variables: { filter: { agentTypes: ['ADMIN'], insightId }, pagination: { limit: 1 } }, }); if (!response.ok || !response.data) { - throw new Error(response.error || 'Failed to load Mingo sessions'); + throw new Error(response.error || 'Failed to look up the Mingo session'); } - return response.data.data.dialogs.edges.map(edge => edge.node); + return response.data.data.dialogs.edges[0]?.node ?? null; }, - throwOnError: true, staleTime: 30 * 1000, }); + return { dialog: query.data ?? null, isLoading: query.isLoading }; } From 668eaa2e7bbfcd2aa1f8ef2ce8ced5d7122eb6cc Mon Sep 17 00:00:00 2001 From: Oleksandr Koltunov Date: Mon, 21 Sep 2026 12:02:00 +0300 Subject: [PATCH 4/7] fix(incidents): the table's Mingo button reopens a linked chat too; assigned-ticket links; no lookup spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Table rows carry the "chat already started" answer (one aggregate lookup query keyed on the page's insight ids, fanning out through `fetchQuery` so it shares the per-incident cache the detail header reads) and settle into "Open Mingo Session" / "Fix with Mingo" in place — the row data, not the column closure, because `DataTableRow` is memoized on the row object. - The detail header suspends on the lookup instead of showing a spinner, so it renders once with the right label. - Assigned tickets: api-service-core hands the target a Relay global id; the ticket pages key on the raw one, so the link said "ticket not found". Co-Authored-By: Claude Fable 5.1 --- .../components/incident-details-view.tsx | 6 +- .../incidents/components/incidents-table.tsx | 116 +++++++++++++----- .../incidents/hooks/use-incident-dialogs.ts | 63 ++++++++-- src/components/assignments/relay-id.ts | 7 ++ .../assignments/use-assigned-items.ts | 4 +- 5 files changed, 146 insertions(+), 50 deletions(-) diff --git a/src/app/(app)/incidents/components/incident-details-view.tsx b/src/app/(app)/incidents/components/incident-details-view.tsx index d8a8d71b..6adfbd22 100644 --- a/src/app/(app)/incidents/components/incident-details-view.tsx +++ b/src/app/(app)/incidents/components/incident-details-view.tsx @@ -49,7 +49,7 @@ function IncidentHeader({ incidentId }: IncidentDetailsViewProps) { const handleBack = useSafeBack(routes.incidents.list); const { fixWithMingo, pendingId: mingoPendingId, canOpenMingo } = useFixWithMingo(); // One Mingo button: reopen the chat already started from this incident, or start one. - const { dialog: mingoSession, isLoading: isMingoSessionLoading } = useLatestIncidentDialog(incident.insightId); + 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.insightId, label: incident.title }); const { transition, snoozeTarget, cancelSnooze, confirmSnooze, isMutating, isSnoozing } = useIncidentTransitions(); @@ -82,8 +82,8 @@ function IncidentHeader({ incidentId }: IncidentDetailsViewProps) { variant: 'outline', icon: mingoIcon, onClick: () => fixWithMingo(incident), - disabled: !canOpenMingo || mingoPendingId !== null || isMingoSessionLoading, - loading: mingoPendingId !== null || isMingoSessionLoading, + disabled: !canOpenMingo || mingoPendingId !== null, + loading: mingoPendingId !== null, }; const actions: PageActionButton[] = [ diff --git a/src/app/(app)/incidents/components/incidents-table.tsx b/src/app/(app)/incidents/components/incidents-table.tsx index aa5608a0..689705d5 100644 --- a/src/app/(app)/incidents/components/incidents-table.tsx +++ b/src/app/(app)/incidents/components/incidents-table.tsx @@ -31,6 +31,7 @@ import type { InsightFilter, } from '@/__generated__/incidentsTableRelayQuery.graphql'; import type { insightFacets_filters$key as InsightFacetsKey } from '@/__generated__/insightFacets_filters.graphql'; +import { openMingoDialogInDrawer } from '@/app/components/notifications/open-mingo-dialog'; import { EmptyState, liveColumnMeta, skeletonColumnDefs, useRetryKey } from '@/app/components/shared'; import { renderDeviceTypeIcon } from '@/app/components/shared/device-type-icon'; import { useDeferredQuery } from '@/app/hooks/use-deferred-query'; @@ -45,8 +46,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 type { DialogNode } from '../../mingo/types'; import { type FacetEntry, type FacetOption, facetToSortedOptions } from '../../scripts/shared/utils/facet-options'; import { useFixWithMingo } from '../hooks/use-fix-with-mingo'; +import { useLatestIncidentDialogs } from '../hooks/use-incident-dialogs'; import { useIncidentTransitions } from '../hooks/use-incident-transitions'; import { enumMembers, @@ -71,6 +74,19 @@ const PAGE_SIZE = 20; */ type ColumnFilterState = { id: string; value: unknown }[]; +/** + * A row plus the state 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 + * a value from the columns' closure keeps showing what it saw first. + */ +interface IncidentTableRow extends IncidentRow { + /** The chat started from this incident; null when none; undefined until the lookup answers. */ + mingoSession: DialogNode | null | undefined; + /** This row's "Fix with Mingo" prompt is being fetched. */ + mingoPending: boolean; +} + /** * 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 @@ -150,8 +166,22 @@ function IncidentsTableContent({ IncidentsFragmentKey >(incidentsTableRelayFragment, queryData); - const rows: IncidentRow[] = (data.insights?.edges ?? []).flatMap(edge => - edge?.node ? [toIncidentRow(edge.node)] : [], + 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)); + // Manual memo on purpose: TanStack compares `data` by identity, and a fresh + // array per render rebuilds the row model every time. + const rows = useMemo( + () => + incidents.map(row => ({ + ...row, + mingoSession: mingoSessions[row.insightId], + mingoPending: mingoPendingId === row.id, + })), + [incidents, mingoSessions, mingoPendingId], ); // A failed page must stop the footer: its sentinel stays in view, Relay @@ -215,7 +245,7 @@ function IncidentsTableContent({ useIncidentTransitions(refreshFilterMeta); const transitionTable = toTransitionTable(queryData); - const columns = useMemo[]>(() => { + const columns = useMemo[]>(() => { const renderRowActions = (row: IncidentRow) => { const items = transitionMenuItems(row, transitionTable, transition, isMutating); return items.length > 0 ? : null; @@ -224,7 +254,7 @@ function IncidentsTableContent({ { 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 +279,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 +301,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 +309,7 @@ function IncidentsTableContent({ { accessorKey: 'status', header: INCIDENT_COLUMNS.status.header, - cell: ({ row }: { row: Row }) => ( + cell: ({ row }: { row: Row }) => (
@@ -297,7 +327,7 @@ function IncidentsTableContent({ }, { id: 'actions', - cell: ({ row }: { row: Row }) => ( + cell: ({ row }: { row: Row }) => (
{renderRowActions(row.original)}
@@ -306,36 +336,56 @@ function IncidentsTableContent({ meta: liveColumnMeta(INCIDENT_COLUMNS.actions), }, { - // "Fix with Mingo": fetches the server's prompt for the incident and - // opens the drawer on a fresh chat with it in the composer — nothing - // sent. Disabled while no drawer is mounted (locked workspace). + // The Mingo button: "Open Mingo Session" once a chat was started from + // the incident, else "Fix with Mingo" — fetch the server's prompt and + // open the drawer on a fresh chat with it in the composer, nothing sent. + // Inert until the row's lookup answers (no spinner: it settles in place) + // and while no drawer is mounted (locked workspace). id: 'mingo', - cell: ({ row }: { row: Row }) => ( -
-
- ), + ); + return ( +
+ {session ? ( +
+ ); + }, enableSorting: false, meta: liveColumnMeta(INCIDENT_COLUMNS.mingo), }, { id: 'open', - cell: ({ row }: { row: Row }) => ( + cell: ({ row }: { row: Row }) => (
); }, @@ -401,18 +376,7 @@ function IncidentsTableContent({ meta: liveColumnMeta(INCIDENT_COLUMNS.open), }, ]; - }, [ - transition, - transitionTable, - isMutating, - canOpenMingo, - fixWithMingo, - mingoPendingId, - typeOptions, - customerOptions, - severityOptions, - statusOptions, - ]); + }, [transition, transitionTable, isMutating, typeOptions, customerOptions, severityOptions, statusOptions]); const filterGroups = [ { id: INCIDENT_COLUMNS.incident.id, title: 'Category', options: typeOptions }, @@ -487,7 +451,7 @@ function IncidentsTableContent({ : 'No incidents found. Try adjusting your filters.' } rowClassName="mb-1" - rowHref={(row: IncidentRow) => routes.incidents.details(row.id)} + rowHref={(row: IncidentTableRow) => routes.incidents.details(row.id)} /> state.canOpen); const [pendingId, setPendingId] = useState(null); + // The state is for the buttons; the ref is the guard — two clicks in one + // frame read the same stale state. + const inFlightRef = useRef(false); const fixWithMingo = (incident: IncidentRow) => { - if (pendingId !== null) return; + if (inFlightRef.current) return; + inFlightRef.current = true; setPendingId(incident.id); fetchQuery( environment, @@ -42,8 +48,49 @@ export function useFixWithMingo() { variant: 'destructive', }); }) - .finally(() => setPendingId(null)); + .finally(() => { + inFlightRef.current = false; + setPendingId(null); + }); }; return { fixWithMingo, pendingId, canOpenMingo }; } + +/** What the Mingo button does and says — the header and the table row share one decision. */ +export interface MingoAction { + label: 'Fix with Mingo' | 'Open Mingo Session'; + onClick: () => void; + disabled: boolean; + loading: boolean; +} + +type MingoControls = ReturnType; + +/** + * `session`: the chat about the incident, null for none, undefined while the + * lookup is pending (table rows only — the header suspends on it). Inert until + * the answer is in and while no drawer is mounted (locked workspace). + */ +export function mingoActionFor( + incident: IncidentRow, + session: DialogNode | null | undefined, + { fixWithMingo, canOpenMingo, pendingId }: MingoControls, +): MingoAction { + if (session) { + return { + label: 'Open Mingo Session', + onClick: () => openMingoDialogInDrawer(session.id), + // Also while another row's prompt is in flight: that draft, landing, + // resets the drawer to a new chat over the conversation just opened. + disabled: !canOpenMingo || pendingId !== null, + loading: false, + }; + } + return { + label: 'Fix with Mingo', + onClick: () => fixWithMingo(incident), + disabled: !canOpenMingo || session === undefined || pendingId !== null, + loading: pendingId === incident.id, + }; +} diff --git a/src/app/(app)/incidents/hooks/use-incident-dialogs.ts b/src/app/(app)/incidents/hooks/use-incident-dialogs.ts index 03e222db..968366ca 100644 --- a/src/app/(app)/incidents/hooks/use-incident-dialogs.ts +++ b/src/app/(app)/incidents/hooks/use-incident-dialogs.ts @@ -2,36 +2,48 @@ import { keepPreviousData, queryOptions, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; +import { ACTIVE_DIALOG_STATUSES } from '../../mingo/hooks/use-mingo-dialogs'; import { GET_MINGO_DIALOGS_QUERY } from '../../mingo/queries/dialogs-queries'; -import type { DialogNode, DialogsResponse } from '../../mingo/types'; +import type { DialogConnection, DialogNode } from '../../mingo/types'; +import type { GraphQlResponse } from '../../tickets/utils/graphql'; + +/** One freshness policy for one answer, whichever query asks. */ +const DIALOG_LOOKUP_STALE_MS = 30 * 1000; /** - * The latest Mingo chat started from an incident, or null — "Fix with Mingo" - * links the dialog it creates to the insight, and the Mingo button offers to - * reopen it instead of starting another. `/chat/graphql` is the ai-agent (raw - * POST by design, see the Data Fetching Strategy). + * The latest Mingo chat about an incident, or null — a chat whose first + * message carries the incident ("Fix with Mingo" attaches it) is listed under + * it, and the Mingo button offers to reopen that instead of starting another. + * Only the statuses the drawer can show: an archived chat would leave the + * button pointing at a conversation the drawer refuses. * - * A failed lookup answers null rather than throwing: the button then offers a - * fresh chat, which is the right fallback, and the header must not fall over - * because the chat service did. Keyed under `mingo-dialogs`: the drawer - * invalidates that on every dialog it creates, which is exactly when this - * answer changes. + * `/chat/graphql` is the ai-agent (raw POST by design, see the Data Fetching + * Strategy). Every failure — transport, or a GraphQL error envelope such as an + * ai-agent that does not know the `insightId` filter yet — answers null + * without a toast, deliberately: the button then offers a fresh chat, which is + * the right fallback, and the header must not fall over because the chat + * service did. Keyed under `mingo-dialogs`: the drawer invalidates that on + * every dialog it creates, which is exactly when this answer changes. */ function latestIncidentDialogQuery(insightId: string) { return queryOptions({ queryKey: ['mingo-dialogs', 'insight', insightId], queryFn: async (): Promise => { - const response = await apiClient.post('/chat/graphql', { + const response = await apiClient.post>('/chat/graphql', { query: GET_MINGO_DIALOGS_QUERY, - variables: { filter: { agentTypes: ['ADMIN'], insightId }, pagination: { limit: 1 } }, + variables: { + filter: { agentTypes: ['ADMIN'], statuses: ACTIVE_DIALOG_STATUSES, insightId }, + pagination: { limit: 1 }, + }, }); - if (!response.ok || !response.data) { - console.warn('[incidents] Mingo session lookup failed:', response.error); + const envelope = response.data; + if (!response.ok || !envelope?.data?.dialogs || envelope.errors?.length) { + console.warn('[incidents] Mingo session lookup failed:', response.error ?? envelope?.errors); return null; } - return response.data.data.dialogs.edges[0]?.node ?? null; + return envelope.data.dialogs.edges[0]?.node ?? null; }, - staleTime: 30 * 1000, + staleTime: DIALOG_LOOKUP_STALE_MS, }); } @@ -40,19 +52,21 @@ export function useLatestIncidentDialog(insightId: string): DialogNode | null { return useSuspenseQuery(latestIncidentDialogQuery(insightId)).data; } -/** - * Pending while `undefined`. The API filters by a single `insightId`, so this - * is one request per row — but ONE query for the table, keyed on the id set, - * whose function fans out through `fetchQuery` so each per-incident answer - * lands in the same cache entry the detail header reads. Per-row `useQueries` - * was tried: a fresh query list and combine per render churned the observer, - * and the table's rows need a stable answer object. `keepPreviousData` keeps - * the known rows answered while a scrolled-in page adds ids. - */ +/** Per incident: the chat, null for none, undefined while the lookup is pending. */ export type IncidentDialogLookup = Readonly>; const NO_LOOKUP: IncidentDialogLookup = {}; +/** + * The table's answers. The API filters by a single `insightId`, so this is one + * request per row — but ONE query for the table, keyed on the id set, whose + * function fans out through `fetchQuery` so each per-incident answer lands in + * the cache entry the detail header reads. (Per-row `useQueries` was tried: a + * fresh query list and combine per render churned the observer, and the rows + * need a stable answer object.) `keepPreviousData` keeps the known rows + * answered while a scrolled-in page adds ids. The per-id query never rejects, + * so one bad row cannot blank the column. + */ export function useLatestIncidentDialogs(insightIds: readonly string[]): IncidentDialogLookup { const queryClient = useQueryClient(); const ids = [...insightIds].sort(); @@ -65,7 +79,7 @@ export function useLatestIncidentDialogs(insightIds: readonly string[]): Inciden return Object.fromEntries(entries); }, enabled: ids.length > 0, - staleTime: 30 * 1000, + staleTime: DIALOG_LOOKUP_STALE_MS, placeholderData: keepPreviousData, }); return query.data ?? NO_LOOKUP; diff --git a/src/app/(app)/incidents/utils/fix-with-mingo-draft.test.ts b/src/app/(app)/incidents/utils/fix-with-mingo-draft.test.ts index 2b3d29c6..39cb67ee 100644 --- a/src/app/(app)/incidents/utils/fix-with-mingo-draft.test.ts +++ b/src/app/(app)/incidents/utils/fix-with-mingo-draft.test.ts @@ -31,7 +31,6 @@ describe('incidentMingoDraft', () => { { type: 'DEVICE', id: 'm-1', label: 'MacBook-Pro-Kirill.local' }, ]); expect(draft.text).toBe('Using the insight and device context, investigate this problem.'); - expect(draft.insightId).toBe('qa-insight-2'); }); it('labels an unexpected id with the id itself and leaves an unknown marker in the text', () => { @@ -44,7 +43,6 @@ describe('incidentMingoDraft', () => { expect(incidentMingoDraft('just text', incident)).toEqual({ text: 'just text', mentions: [], - insightId: 'qa-insight-2', }); }); }); diff --git a/src/app/(app)/incidents/utils/fix-with-mingo-draft.ts b/src/app/(app)/incidents/utils/fix-with-mingo-draft.ts index 046e8d8d..2264fef3 100644 --- a/src/app/(app)/incidents/utils/fix-with-mingo-draft.ts +++ b/src/app/(app)/incidents/utils/fix-with-mingo-draft.ts @@ -31,7 +31,7 @@ export function incidentMingoDraft(prompt: string, incident: IncidentRow): Mingo mentions.push({ type: kind, id, label: labelFor(kind, id, incident) }); rest = rest.slice(token.length); } - return { text: rest.trim(), mentions, insightId: incident.insightId }; + return { text: rest.trim(), mentions }; } function labelFor(kind: ContextEntityKind, id: string, incident: IncidentRow): string { diff --git a/src/app/(app)/incidents/utils/incident-transform.ts b/src/app/(app)/incidents/utils/incident-transform.ts index f65e4c2f..dba702e9 100644 --- a/src/app/(app)/incidents/utils/incident-transform.ts +++ b/src/app/(app)/incidents/utils/incident-transform.ts @@ -14,7 +14,7 @@ import { insightRowFieldsFragment } from '@/graphql/insights/insight-row-fields' import { insightTransitionsFragment } from '@/graphql/insights/insight-transitions'; import { insightUserFieldsFragment } from '@/graphql/insights/insight-user-fields'; import { getFullImageUrl } from '@/lib/image-url'; -import { decodeGlobalId } from '@/lib/relay-id'; +import { rawIdOf } from '@/lib/relay-id'; import { getDeviceName } from '../../devices/utils/device-name'; import { enumMembers } from './incident-labels'; @@ -54,7 +54,7 @@ export function toIncidentRow(ref: insightRowFields_insight$key): IncidentRow { const machine = node.machine; return { id: node.id, - insightId: decodeGlobalId(node.id)?.rawId ?? node.id, + insightId: rawIdOf(node.id), title: node.title, type: node.type, severity: node.severity, diff --git a/src/app/(app)/mingo/context/context-types.ts b/src/app/(app)/mingo/context/context-types.ts index 46124cfa..0b1d2bfe 100644 --- a/src/app/(app)/mingo/context/context-types.ts +++ b/src/app/(app)/mingo/context/context-types.ts @@ -44,7 +44,10 @@ export const CONTEXT_ENTITY_KIND = { POLICY: 'POLICY', QUERY: 'QUERY', SCHEDULED_SCRIPT: 'SCHEDULED_SCRIPT', - /** An incident (the API's `Insight`); the marker is `@insight:`. */ + /** + * An incident (the API's `Insight`); the marker is `@insight:` — + * the id INSIDE the Relay handle, like every other kind. + */ INSIGHT: 'INSIGHT', } as const; @@ -98,8 +101,9 @@ export const CONTEXT_RELAY_TYPENAME: Partial> KB_FOLDER: 'KnowledgeBaseItem', SCRIPT: 'Script', SCHEDULED_SCRIPT: 'ScriptSchedule', - // `Insight.id` is ALREADY the opaque global id (the `insight(id:)` query takes - // it as is), so `ensureGlobalIdForType` passes it through untouched. + // An INSIGHT reference carries the STORED id (what the ai-agent's resolver looks + // up); the chip re-encodes it through `ensureGlobalIdForType('Insight', …)` for + // the `insight(id:)` query, which takes the global handle. INSIGHT: 'Insight', }; diff --git a/src/app/(app)/mingo/context/relay-items.tsx b/src/app/(app)/mingo/context/relay-items.tsx index 04246842..589d03e4 100644 --- a/src/app/(app)/mingo/context/relay-items.tsx +++ b/src/app/(app)/mingo/context/relay-items.tsx @@ -36,7 +36,7 @@ import { DEFAULT_DEVICES_LIST_STATUSES } from '@/app/(app)/devices/constants/dev import { getDeviceName } from '@/app/(app)/devices/utils/device-name'; import { INCIDENT_SEVERITY_LABELS, labelOf, WORKING_SET_STATUSES } from '@/app/(app)/incidents/utils/incident-labels'; import { toRelayDeviceFilter } from '@/graphql/devices/to-relay-device-filter'; -import { decodeGlobalId } from '@/lib/relay-id'; +import { decodeGlobalId, rawIdOf } from '@/lib/relay-id'; import { CONTEXT_ENTITY_KIND } from './context-types'; import { type ContextItemsProps, MINGO_CONTEXT_PAGE_SIZE } from './items-shared'; @@ -179,7 +179,7 @@ export function OrganizationItems({ query, selectedKeys, onToggle, atLimit }: Co type: CONTEXT_ENTITY_KIND.ORGANIZATION, // Raw db id (organizationId), decoded from the global `id` // (`base64("Organization:")`); the chip re-encodes it. - id: decodeGlobalId(e.node.id)?.rawId ?? e.node.id, + id: rawIdOf(e.node.id), label: e.node.name || e.node.id, description: e.node.category ?? undefined, }, @@ -249,7 +249,7 @@ export function KnowledgeBaseItems({ query, selectedKeys, onToggle, atLimit }: C type: CONTEXT_ENTITY_KIND.KB_ARTICLE, // Raw db id, decoded from the global `id` // (`base64("KnowledgeBaseItem:")`); the chip re-encodes it. - id: decodeGlobalId(e.node.id)?.rawId ?? e.node.id, + id: rawIdOf(e.node.id), label: e.node.name || e.node.id, description: e.node.type ?? undefined, }, @@ -486,9 +486,7 @@ export function IncidentItems({ query, selectedKeys, onToggle, atLimit }: Contex return [ { type: CONTEXT_ENTITY_KIND.INSIGHT, - // The stored id, not the Relay handle: that is what the ai-agent's - // resolver looks up (the chip re-encodes it for its own query). - id: decodeGlobalId(e.node.id)?.rawId ?? e.node.id, + id: rawIdOf(e.node.id), label: e.node.title, description: [labelOf(INCIDENT_SEVERITY_LABELS, e.node.severity), device].filter(Boolean).join(' · '), }, diff --git a/src/app/(app)/mingo/hooks/use-mingo-chat.ts b/src/app/(app)/mingo/hooks/use-mingo-chat.ts index 8e8e68e0..45d7a49f 100644 --- a/src/app/(app)/mingo/hooks/use-mingo-chat.ts +++ b/src/app/(app)/mingo/hooks/use-mingo-chat.ts @@ -53,7 +53,8 @@ interface UseMingoChat { isLoading: boolean; // Actions - createDialog: () => Promise; + /** `insightId`: the stored id of the incident the chat is about, for the server to list it under. */ + createDialog: (insightId?: string) => Promise; sendMessage: (content: string, targetDialogId?: string, context?: MingoSendContext) => Promise; stopGeneration: () => Promise; @@ -195,32 +196,35 @@ export function useMingoChat(dialogId: string | null): UseMingoChat { return tail?.type === 'context_compaction' && tail.status === 'started'; }, [dialogId, messagesByDialog]); - const createDialog = useCallback(async (): Promise => { - if (isCreatingDialog) return null; + const createDialog = useCallback( + async (insightId?: string): Promise => { + if (isCreatingDialog) return null; - try { - setCreatingDialog(true); + try { + setCreatingDialog(true); - const result = await createDialogMutation.mutateAsync(); - queryClient.invalidateQueries({ queryKey: ['mingo-dialogs'] }); + const result = await createDialogMutation.mutateAsync({ insightId }); + queryClient.invalidateQueries({ queryKey: ['mingo-dialogs'] }); - return result.id; - } catch (error) { - console.error('[MingoChat] Failed to create dialog:', error); - // Surface the failure: callers (quick actions, launcher, draft send) only - // get a null id back and otherwise bail silently, so without this a dialog - // that can't be created leaves the user with no feedback. - toast({ - title: 'Failed to start conversation', - description: error instanceof Error ? error.message : 'Could not create a new chat', - variant: 'destructive', - duration: 5000, - }); - return null; - } finally { - setCreatingDialog(false); - } - }, [isCreatingDialog, setCreatingDialog, createDialogMutation, queryClient, toast]); + return result.id; + } catch (error) { + console.error('[MingoChat] Failed to create dialog:', error); + // Surface the failure: callers (quick actions, launcher, draft send) only + // get a null id back and otherwise bail silently, so without this a dialog + // that can't be created leaves the user with no feedback. + toast({ + title: 'Failed to start conversation', + description: error instanceof Error ? error.message : 'Could not create a new chat', + variant: 'destructive', + duration: 5000, + }); + return null; + } finally { + setCreatingDialog(false); + } + }, + [isCreatingDialog, setCreatingDialog, createDialogMutation, queryClient, toast], + ); const sendMessage = useCallback( async (content: string, targetDialogId?: string, context?: MingoSendContext): Promise => { diff --git a/src/app/(app)/mingo/hooks/use-mingo-dialogs.ts b/src/app/(app)/mingo/hooks/use-mingo-dialogs.ts index b8c56d30..7f0931e3 100644 --- a/src/app/(app)/mingo/hooks/use-mingo-dialogs.ts +++ b/src/app/(app)/mingo/hooks/use-mingo-dialogs.ts @@ -14,10 +14,12 @@ import { isAwaitingGeneratedTitle } from './use-mingo-dialog-selection'; // temporary workaround — disabled for now; flip this flag to restore it. const HIGHLIGHT_UNREAD_FROM_NOTIFICATIONS: boolean = false; -// Statuses shown in the active "Current Chats" list — every DialogStatus except -// ARCHIVED. The backend returns archived dialogs when no statuses are passed, so -// list them explicitly; archived dialogs live in the separate Chat Archive page. -const ACTIVE_DIALOG_STATUSES = ['ACTIVE', 'ACTION_REQUIRED', 'ON_HOLD', 'RESOLVED'] as const; +/** + * The statuses the drawer's list shows. Sent explicitly because the backend + * returns ARCHIVED dialogs too when no statuses are passed — and a dialog + * outside this set cannot be opened in the drawer. + */ +export const ACTIVE_DIALOG_STATUSES = ['ACTIVE', 'ACTION_REQUIRED', 'ON_HOLD', 'RESOLVED'] as const; function transformToDialogItem(dialog: DialogNode, unreadCount: number = 0): DialogItem { // Admin owner → trailing avatar in the chat-history rows (Figma 113:63224). diff --git a/src/app/(app)/mingo/hooks/use-mingo-unified-chat-state.ts b/src/app/(app)/mingo/hooks/use-mingo-unified-chat-state.ts index 246437cd..8516997b 100644 --- a/src/app/(app)/mingo/hooks/use-mingo-unified-chat-state.ts +++ b/src/app/(app)/mingo/hooks/use-mingo-unified-chat-state.ts @@ -37,11 +37,15 @@ import type { } from '@flamingo-stack/openframe-frontend-core/components/chat'; import { buildDiscussPrompt } from '@flamingo-stack/openframe-frontend-core/components/chat'; import { useCallback, useDeferredValue, useMemo, useState } from 'react'; -import { useMingoLauncherStore } from '@/app/(app)/mingo/stores/mingo-launcher-store'; import { useAuthStore } from '@/app/(auth)/auth/stores/auth-store'; import { useAiModelStatus } from '@/app/hooks/use-ai-model'; import { EVENT_SUBTYPE, trackDashboardActivity } from '@/lib/analytics'; -import { CONTEXT_ITEMS_MAX, RECENT_VIEWS_MAX } from '../context/context-types'; +import { + CONTEXT_ENTITY_KIND, + CONTEXT_ENTITY_MARKER, + CONTEXT_ITEMS_MAX, + RECENT_VIEWS_MAX, +} from '../context/context-types'; import { useMingoContextStore } from '../stores/mingo-context-store'; import { useMingoMessagesStore } from '../stores/mingo-messages-store'; import { type MingoSendContext, useMingoChat } from './use-mingo-chat'; @@ -51,6 +55,9 @@ import { useMingoDialogs } from './use-mingo-dialogs'; import { useMingoRealtimeSubscription } from './use-mingo-realtime-subscription'; const ADMIN_CHAT_TYPE = 'ADMIN_AI_CHAT' as const; + +/** The composer's inline incident mention: `@insight:`, a whole token. */ +const INSIGHT_MENTION = new RegExp(`(?:^|\\s)@${CONTEXT_ENTITY_MARKER.INSIGHT}:(\\S+)`); const WELCOME_TEXT = "Hi! I'm Mingo AI, ready to help with your technical tasks. What can I do for you?"; /** Metadata frame shape emitted by ``. */ @@ -333,9 +340,6 @@ export function useMingoUnifiedChatState(): MingoUnifiedChat { // ─── Dialog selection (mirrors the /mingo page glue, minus URL syncing) ─── const selectDialog = useCallback( (id: string | null) => { - // Any change of conversation leaves the fresh chat a "Fix with Mingo" draft - // was linking to an insight (the entry re-sets the link after its prefill). - useMingoLauncherStore.getState().setDialogInsightId(null); if (id === null) { setActiveDialogId(null); return; @@ -357,7 +361,17 @@ export function useMingoUnifiedChatState(): MingoUnifiedChat { const trimmed = text.trim(); if (!trimmed) return; - const newId = await createDialog(); + // A chat whose first message references an incident is listed under it. + // Read off the message itself, so nothing else's first send — a + // quick-action chip, a launcher prompt — can inherit a stale link. The + // strip is one carrier; the `@insight:` token in the text is the + // other, and the one that holds on the "Fix with Mingo" path: the + // prefilled mention reached the send as text with `contextItems` absent + // (observed), and the token is what the server resolves anyway. + const insightId = + context?.contextItems?.find(item => item.type === CONTEXT_ENTITY_KIND.INSIGHT)?.id ?? + INSIGHT_MENTION.exec(trimmed)?.[1]; + const newId = await createDialog(insightId); if (!newId) return; addMessage(newId, { id: `welcome-${newId}`, diff --git a/src/app/(app)/mingo/services/mingo-api-service.ts b/src/app/(app)/mingo/services/mingo-api-service.ts index 1f3f4e45..a7e94406 100644 --- a/src/app/(app)/mingo/services/mingo-api-service.ts +++ b/src/app/(app)/mingo/services/mingo-api-service.ts @@ -1,6 +1,5 @@ import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; import { useMutation } from '@tanstack/react-query'; -import { useMingoLauncherStore } from '@/app/(app)/mingo/stores/mingo-launcher-store'; import { apiClient } from '@/lib/api-client'; export interface CreateDialogResponse { @@ -26,7 +25,7 @@ export interface ApprovalResponse { export interface CreateDialogRequest { agentType: 'ADMIN'; - /** The STORED insight id when the chat is started from an incident ("Fix with Mingo"). */ + /** The STORED insight id when the chat is about an incident — the server lists the chat under it. */ insightId?: string; } @@ -60,9 +59,7 @@ export interface SendMessageRequest { */ export function useCreateDialogMutation() { return useMutation({ - mutationFn: async (): Promise => { - // Consumed here, on the one create the draft chat produces. - const insightId = useMingoLauncherStore.getState().consumeDialogInsightId(); + mutationFn: async ({ insightId }: Pick = {}): Promise => { const request: CreateDialogRequest = { agentType: 'ADMIN', ...(insightId && { insightId }) }; const response = await apiClient.post('/chat/api/v1/dialogs', request); diff --git a/src/app/(app)/mingo/stores/mingo-launcher-store.ts b/src/app/(app)/mingo/stores/mingo-launcher-store.ts index fbf2d79f..7fcd5d70 100644 --- a/src/app/(app)/mingo/stores/mingo-launcher-store.ts +++ b/src/app/(app)/mingo/stores/mingo-launcher-store.ts @@ -3,10 +3,7 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; /** What `draftToMingo` puts in the composer — relayed verbatim to the panel's `prefillDraft`. */ -export type MingoDraft = ChatPrefillDraft & { - /** The STORED insight id this draft is about; the dialog created from it is linked to the insight. */ - insightId?: string; -}; +export type MingoDraft = ChatPrefillDraft; /** * Owns the Mingo drawer's open state (lifted out of `AppShell` so any page can @@ -40,14 +37,6 @@ interface MingoLauncherStore { pendingDraft: MingoDraft | null; /** One-shot "open on a fresh chat" request; false once consumed. */ pendingNewChat: boolean; - /** - * The insight the composer's current fresh chat was started from ("Fix with - * Mingo"), for `POST /dialogs` to link the two. Set once the draft is in the - * composer; consumed by the create; dropped by anything that leaves that - * fresh chat — another conversation, a new chat, a close (the drawer unmounts - * on close, and the draft with it). - */ - dialogInsightId: string | null; /** * The drawer was closed BY a navigation the same handler had just issued, so it * does not own the URL on this pass — the destination is already param-free. @@ -80,9 +69,6 @@ interface MingoLauncherStore { startNewChat: () => void; /** Read and clear the pending new-chat request (safe against double-consume). */ consumePendingNewChat: () => boolean; - setDialogInsightId: (insightId: string | null) => void; - /** Read and clear the insight link in one step — for the one dialog create it belongs to. */ - consumeDialogInsightId: () => string | null; } export const useMingoLauncherStore = create()( @@ -93,26 +79,18 @@ export const useMingoLauncherStore = create()( pendingPrompt: null, pendingDraft: null, pendingNewChat: false, - dialogInsightId: null, closedForNavigation: false, - setOpen: open => - set( - open ? { isOpen: true, closedForNavigation: false } : { isOpen: false, dialogInsightId: null }, - false, - 'setOpen', - ), + setOpen: open => set(open ? { isOpen: true, closedForNavigation: false } : { isOpen: false }, false, 'setOpen'), setCanOpen: canOpen => set({ canOpen }, false, 'setCanOpen'), toggle: () => set( - state => - state.isOpen ? { isOpen: false, dialogInsightId: null } : { isOpen: true, closedForNavigation: false }, + state => (state.isOpen ? { isOpen: false } : { isOpen: true, closedForNavigation: false }), false, 'toggle', ), - close: () => set({ isOpen: false, dialogInsightId: null }, false, 'close'), - closeForNavigation: () => - set({ isOpen: false, dialogInsightId: null, closedForNavigation: true }, false, 'closeForNavigation'), + close: () => set({ isOpen: false }, false, 'close'), + closeForNavigation: () => set({ isOpen: false, closedForNavigation: true }, false, 'closeForNavigation'), sendToMingo: prompt => set( @@ -121,7 +99,6 @@ export const useMingoLauncherStore = create()( pendingPrompt: prompt, pendingDraft: null, pendingNewChat: false, - dialogInsightId: null, closedForNavigation: false, }, false, @@ -154,21 +131,12 @@ export const useMingoLauncherStore = create()( pendingNewChat: true, pendingPrompt: null, pendingDraft: null, - dialogInsightId: null, closedForNavigation: false, }, false, 'startNewChat', ), - setDialogInsightId: insightId => set({ dialogInsightId: insightId }, false, 'setDialogInsightId'), - - consumeDialogInsightId: () => { - const { dialogInsightId } = get(); - if (dialogInsightId !== null) set({ dialogInsightId: null }, false, 'consumeDialogInsightId'); - return dialogInsightId; - }, - consumePendingNewChat: () => { const { pendingNewChat } = get(); if (pendingNewChat) set({ pendingNewChat: false }, false, 'consumePendingNewChat'); diff --git a/src/app/components/openframe-embeddable-chat-entry.tsx b/src/app/components/openframe-embeddable-chat-entry.tsx index a5adb912..5e36342d 100644 --- a/src/app/components/openframe-embeddable-chat-entry.tsx +++ b/src/app/components/openframe-embeddable-chat-entry.tsx @@ -212,9 +212,6 @@ export function OpenframeEmbeddableChatEntry({ open, onOpenChange }: OpenframeEm const draft = consumePendingDraft(); if (!draft) return; chatHandle.current?.prefillDraft(draft); - // After, not before: the prefill lands on a fresh chat by way of - // `selectDialog(null)`, which drops whatever link was set. - if (draft.insightId) useMingoLauncherStore.getState().setDialogInsightId(draft.insightId); }, [pendingDraft, consumePendingDraft]); // Entity-context picker config (the `+` "Assign Item" menu + `@` trigger). diff --git a/src/components/assignments/relay-id.ts b/src/components/assignments/relay-id.ts index 1c83ee02..ec7853f4 100644 --- a/src/components/assignments/relay-id.ts +++ b/src/components/assignments/relay-id.ts @@ -57,10 +57,3 @@ function toGlobalId(type: AssignmentItemType | AssignmentTargetType, rawId: stri export function ensureGlobalId(type: AssignmentItemType | AssignmentTargetType, value: string): string { return decodeGlobalId(value) ? value : toGlobalId(type, value); } - -// The inverse, for what comes BACK: api-service-core hands every AssignableTarget a -// global id, while the ticket pages (ai-agent) key on the raw one — a global id in -// `/tickets/dialog?id=` is "ticket not found". -export function toRawId(value: string): string { - return decodeGlobalId(value)?.id ?? value; -} diff --git a/src/components/assignments/target-config.ts b/src/components/assignments/target-config.ts index 71b408bc..52de24e7 100644 --- a/src/components/assignments/target-config.ts +++ b/src/components/assignments/target-config.ts @@ -21,8 +21,10 @@ export interface TargetMeta { icon: ComponentType; /** * The row can be added from the "Assign Item" menu and its picker searched. - * INSIGHT is not: a ticket is linked to ONE incident by being filed from it, - * so the row only ever shows what the page arrived with (and can be dropped). + * A UX flag, not "the server accepts it as a target" (that is + * `ASSIGNMENT_TARGET_TYPES`), even though the two coincide today: INSIGHT is + * not pickable because a ticket is linked to ONE incident by being filed from + * it, so the row only ever shows what the page arrived with (and can be dropped). */ pickable: boolean; } diff --git a/src/components/assignments/types.ts b/src/components/assignments/types.ts index 1e495533..76f9cd87 100644 --- a/src/components/assignments/types.ts +++ b/src/components/assignments/types.ts @@ -1,16 +1,17 @@ +/** The server's `AssignmentTargetType` — what `assignedItems` is queried for and `assignItem` accepts. */ +export type ServerAssignmentTargetType = 'ORGANIZATION' | 'DEVICE' | 'TICKET' | 'KNOWLEDGE_ARTICLE'; + /** - * What an item can be assigned TO. The first four are the server's - * `AssignmentTargetType`; INSIGHT is client-only — the server links a ticket to - * the incident it is filed from in the other direction (item INSIGHT → target - * TICKET, via `CreateTicketInput.insightId`), so on the ticket form the incident - * is a row that is shown and sent as `insightId`, never assigned through - * `assignItem`. Hence `ASSIGNMENT_TARGET_TYPES` (the server list) vs + * What an item can be assigned TO, as a form may render it. INSIGHT is + * client-only — the server links a ticket to the incident it is filed from in + * the other direction (item INSIGHT → target TICKET, via + * `CreateTicketInput.insightId`), so on the ticket form the incident is a row + * that is shown and sent as `insightId`, never assigned through `assignItem`. + * Hence `ASSIGNMENT_TARGET_TYPES` (the server list) vs * `ALL_ASSIGNMENT_TARGET_TYPES` (what a form may render). */ export type AssignmentTargetType = ServerAssignmentTargetType | 'INSIGHT'; -export type ServerAssignmentTargetType = 'ORGANIZATION' | 'DEVICE' | 'TICKET' | 'KNOWLEDGE_ARTICLE'; - /** What can own assignments — the server's `AssignmentItemType`. */ export type AssignmentItemType = 'TICKET' | 'KNOWLEDGE_ARTICLE' | 'INSIGHT'; diff --git a/src/components/assignments/use-assigned-items.ts b/src/components/assignments/use-assigned-items.ts index 208db4a8..e8aa1257 100644 --- a/src/components/assignments/use-assigned-items.ts +++ b/src/components/assignments/use-assigned-items.ts @@ -7,8 +7,9 @@ import { getDeviceName } from '@/app/(app)/devices/utils/device-name'; import { type DeviceRowFields, rowFieldsToDevice } from '@/app/(app)/devices/utils/device-transform'; import type { KnowledgeBaseRow } from '@/app/(app)/knowledge-base/components/knowledge-base-table-columns'; import type { Dialog, DialogStatus } from '@/app/(app)/tickets/types/dialog.types'; +import { decodeGlobalId } from '@/lib/relay-id'; import { postGraphQl } from './graphql'; -import { ensureGlobalId, toRawId } from './relay-id'; +import { ensureGlobalId } from './relay-id'; import { ASSIGNMENT_TARGET_TYPES, type AssignmentItemType, @@ -144,8 +145,13 @@ function toMachineRowFields(target: AssignedTargetNode): DeviceRowFields { function toDialog(target: AssignedTargetNode): Dialog { const t = unaliasFields(target); + // api-service-core hands every AssignableTarget a global id, while the ticket + // pages key on the raw one — a global id in `/tickets/dialog?id=` is "ticket + // not found". Guarded on the typename: a raw 24-hex ObjectId is valid base64 + // too, and an unguarded decode would mangle the few that contain a colon byte. + const decoded = decodeGlobalId(target.id); return { - id: toRawId(target.id), + id: decoded?.typename === 'Ticket' ? decoded.rawId : target.id, title: (t.title as string) || 'Untitled Dialog', status: ((t.status as string) ?? 'ACTIVE') as DialogStatus, owner: { type: 'CLIENT' }, diff --git a/src/lib/relay-id.ts b/src/lib/relay-id.ts index 0464fb71..aefec6a9 100644 --- a/src/lib/relay-id.ts +++ b/src/lib/relay-id.ts @@ -54,6 +54,11 @@ export function ensureGlobalIdForType(typename: string, value: string): string { return toGlobalId(typename, value); } +/** The id INSIDE a global handle; a value that is not one comes back as is. */ +export function rawIdOf(value: string): string { + return decodeGlobalId(value)?.rawId ?? value; +} + export function notificationGlobalId(rawId: string): string { return ensureGlobalIdForType('Notification', rawId); } From c54d4f0e67359af84f9874fa4ab3098fff3f4d95 Mon Sep 17 00:00:00 2001 From: Oleksandr Koltunov Date: Mon, 21 Sep 2026 12:53:54 +0300 Subject: [PATCH 6/7] fix(incidents): details skeletons match the loaded layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Query Details had no skeleton and popped in under the notes; it now has the same heading over the report table's own loading rows (four columns, one row — the common shape), as the Suspense fallback and in the page skeleton. - The notes skeleton reserved an 80px bar; the loaded section with no notes is the heading over the "Add Note" button, so the bar is that button's size. Measured on a cold load vs loaded: header 96/96, summary card 260/260, notes 56/56; Query Details differs only by the record's own row count. Co-Authored-By: Claude Fable 5.1 --- .../components/incident-details-view.tsx | 5 ++-- .../components/incident-query-results.tsx | 27 +++++++++++++++++-- src/app/components/shared/notes-section.tsx | 7 ++++- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/app/(app)/incidents/components/incident-details-view.tsx b/src/app/(app)/incidents/components/incident-details-view.tsx index fb5ce2cb..06419312 100644 --- a/src/app/(app)/incidents/components/incident-details-view.tsx +++ b/src/app/(app)/incidents/components/incident-details-view.tsx @@ -20,7 +20,7 @@ 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'; @@ -175,6 +175,7 @@ export function IncidentDetailsSkeleton() { <> + } /> @@ -203,7 +204,7 @@ 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/components/shared/notes-section.tsx b/src/app/components/shared/notes-section.tsx index aa563f00..27c270c2 100644 --- a/src/app/components/shared/notes-section.tsx +++ b/src/app/components/shared/notes-section.tsx @@ -189,10 +189,15 @@ function NotesSectionFrame({ children }: { children: ReactNode }) { } /** The heading with a bar where the first note lands. */ +/** + * The heading over an "Add Note" sized bar — the shape of the loaded section + * when there are no notes yet, which is what most records have. Notes, when + * there are any, grow the section either way. + */ export function NotesSectionSkeleton() { return ( - + ); } From c11155000dfb223a203222f928973349452e7ac2 Mon Sep 17 00:00:00 2001 From: Oleksandr Koltunov Date: Mon, 21 Sep 2026 12:56:32 +0300 Subject: [PATCH 7/7] fix(incidents): an unknown marker in the Mingo prompt no longer hides the ones after it Code-review finding: the parser stopped at the first marker of a kind this build does not know; now it keeps that token in the text and reads on. Co-Authored-By: Claude Fable 5.1 --- .../incidents/utils/fix-with-mingo-draft.test.ts | 8 +++++++- .../(app)/incidents/utils/fix-with-mingo-draft.ts | 12 +++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/app/(app)/incidents/utils/fix-with-mingo-draft.test.ts b/src/app/(app)/incidents/utils/fix-with-mingo-draft.test.ts index 39cb67ee..ab940a6c 100644 --- a/src/app/(app)/incidents/utils/fix-with-mingo-draft.test.ts +++ b/src/app/(app)/incidents/utils/fix-with-mingo-draft.test.ts @@ -33,12 +33,18 @@ describe('incidentMingoDraft', () => { expect(draft.text).toBe('Using the insight and device context, investigate this problem.'); }); - it('labels an unexpected id with the id itself and leaves an unknown marker in the text', () => { + it('labels an unexpected id with the id itself and keeps an unknown marker in the text', () => { const draft = incidentMingoDraft('@insight:other @widget:w-1 look', incident); expect(draft.mentions).toEqual([{ type: 'INSIGHT', id: 'other', label: 'other' }]); expect(draft.text).toBe('@widget:w-1 look'); }); + it('reads the markers after an unknown one', () => { + const draft = incidentMingoDraft('@widget:w-1 @device:m-1 look', incident); + expect(draft.mentions).toEqual([{ type: 'DEVICE', id: 'm-1', label: 'MacBook-Pro-Kirill.local' }]); + expect(draft.text).toBe('@widget:w-1 look'); + }); + it('copes with a prompt that has no markers', () => { expect(incidentMingoDraft('just text', incident)).toEqual({ text: 'just text', diff --git a/src/app/(app)/incidents/utils/fix-with-mingo-draft.ts b/src/app/(app)/incidents/utils/fix-with-mingo-draft.ts index 2264fef3..2f8beb7d 100644 --- a/src/app/(app)/incidents/utils/fix-with-mingo-draft.ts +++ b/src/app/(app)/incidents/utils/fix-with-mingo-draft.ts @@ -17,21 +17,23 @@ const LEADING_MARKER = /^@([A-Za-z]+):(\S+)\s*/; * text — a `@marker:id` left in the text is parsed into a chip labelled with the * id and outside the context strip — with the labels the row already knows; * the ask stays as the text. A marker of a kind this build does not know is - * left in the text rather than dropped. + * kept in the text rather than dropped, and does not stop the ones after it + * from being read. */ export function incidentMingoDraft(prompt: string, incident: IncidentRow): MingoDraft { const mentions: NonNullable = []; + const unknown: string[] = []; let rest = prompt.trimStart(); for (;;) { const match = LEADING_MARKER.exec(rest); if (!match) break; const [token, marker, id] = match; - const kind = KIND_BY_MARKER.get(marker); - if (!kind) break; - mentions.push({ type: kind, id, label: labelFor(kind, id, incident) }); rest = rest.slice(token.length); + const kind = KIND_BY_MARKER.get(marker); + if (kind) mentions.push({ type: kind, id, label: labelFor(kind, id, incident) }); + else unknown.push(token.trim()); } - return { text: rest.trim(), mentions }; + return { text: [...unknown, rest.trim()].filter(Boolean).join(' '), mentions }; } function labelFor(kind: ContextEntityKind, id: string, incident: IncidentRow): string {