@lightdash/cli 2.265.0 → 2.267.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -117,14 +117,14 @@ order, `sortBy` describes result ordering, `totalColumnCount` exposes truncation
117
117
  - `external-fetch`: `client.externalFetch(alias, opts)` calls an external
118
118
  connection linked to this app. The connection must already be linked by the
119
119
  host; you cannot add one from app code.
120
- - `ai-insights`: `useInsights()` returns the host's AI analysis of the current
121
- view (headline, summary, limitations, anomalies) plus `analyse()`;
122
- `useInsights(result)` narrows to one chart and adds `matches(row)` so flagged
123
- points can be marked, with `investigate(id)` / `continueInAskAi(id)` for the
124
- action menu. Render a summary block and markers (only `high`, `medium`,
125
- `positive`; never `info`); on line charts the marker must also be the
126
- `activeDot` so hover does not cover it. Render nothing when
127
- `status === 'unavailable'`. Full contract in `/app/references/ai-analysis.md`.
120
+ - `ai-insights`: wire the shipped components in `src/components/insights/`
121
+ (`InsightsSummary`, `InsightMarker` / `insightCellProps`,
122
+ `InvestigateMenuItem`, `InvestigationCard`) rather than writing your own:
123
+ summary block at the top, markers on every query-bound chart (as both `dot`
124
+ and `activeDot`), Investigate in every point menu, the card under the chart.
125
+ A short ask ("add AI analysis") gets the whole set. They sit on
126
+ `useInsights()` / `useInsights(result)` and render nothing when the host has
127
+ no analysis. Full contract in `/app/references/ai-analysis.md`.
128
128
  - `ai-prompt`: `useAiPrompt()` returns `{ available, ask, loading, text, error }`;
129
129
  `ask({ prompt, sources: [{ result, label }], focus?: { row } })` sends an
130
130
  author-written question over loaded `useLightdash` results and resolves to
@@ -9,8 +9,26 @@ Two operations exist, both run by Lightdash:
9
9
  - **Detect** — reads the results the app already loaded and returns a headline, a summary, a list of notable data points (anomalies) with the query, field and row they refer to, and the limitations of the data (no comparison period, truncation, and so on).
10
10
  - **Investigate** — for one anomaly, an AI agent with read-only query tools looks for possible drivers using data beyond the page and returns a Markdown explanation with evidence and a confidence line. It runs asynchronously; the app shows progress from the pushed status.
11
11
 
12
+ ## Default build
13
+
14
+ When the user asks for AI analysis, an executive summary, anomalies, "what changed", "flag anything unusual", or insights and gives no further detail, build **all** of this with the shipped components in `src/components/insights/` (never rewrite them):
15
+
16
+ 1. `<InsightsSummary />` near the top of the page.
17
+ 2. On every query-bound chart, `InsightMarker` as both `dot` and `activeDot` (lines/areas) or `insightCellProps` on each `Cell` (bars), one per series with its `dataKey` as `fieldId`, plus the finding text in the tooltip via `insightTooltipText`.
18
+ 3. `<InvestigateMenuItem />` in every data-point action menu, next to "Filter by …" and "View underlying data".
19
+ 4. `<InvestigationCard />` under the chart once an investigation starts.
20
+
21
+ Narrower asks build only that part: "just an executive summary" is step 1 alone; "mark anomalies on the charts" is steps 2 to 4. All four components render nothing when the host has no analysis to offer, so nothing else needs gating.
22
+
12
23
  ```tsx
13
- import { useInsights } from '@lightdash/query-sdk';
24
+ import { useInsights, useLightdash } from '@lightdash/query-sdk';
25
+ import {
26
+ InsightMarker,
27
+ InsightsSummary,
28
+ InvestigateMenuItem,
29
+ InvestigationCard,
30
+ insightTooltipText,
31
+ } from '@/components/insights';
14
32
  ```
15
33
 
16
34
  ## View-level: the executive summary block
@@ -31,86 +49,14 @@ const view = useInsights();
31
49
  // view.analyse() ask Lightdash to (re)run the analysis
32
50
  ```
33
51
 
34
- Render a block near the top of the page. It must handle every status:
35
-
36
- ```tsx
37
- import { useInsights } from '@lightdash/query-sdk';
38
- import { Button } from '@/components/ui/button';
39
- import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
40
- import { Loader2, RefreshCw, Sparkles } from 'lucide-react';
41
-
42
- function ExecutiveSummary() {
43
- const view = useInsights();
44
-
45
- // The org has not enabled AI analysis: render nothing, never a stub.
46
- if (view.status === 'unavailable') return null;
47
-
48
- return (
49
- <Card>
50
- <CardHeader className="flex flex-row items-center justify-between">
51
- <CardTitle className="flex items-center gap-2">
52
- <Sparkles className="h-4 w-4" /> Executive summary
53
- </CardTitle>
54
- <Button
55
- variant="outline"
56
- size="sm"
57
- onClick={view.analyse}
58
- disabled={view.status === 'analysing'}
59
- >
60
- {view.status === 'analysing' ? (
61
- <Loader2 className="h-4 w-4 animate-spin" />
62
- ) : (
63
- <RefreshCw className="h-4 w-4" />
64
- )}
65
- {view.status === 'ready' ? 'Refresh' : 'Analyse'}
66
- </Button>
67
- </CardHeader>
68
- <CardContent>
69
- {view.status === 'idle' && (
70
- <p className="text-muted-foreground">
71
- Click Analyse for a summary of what is on this page.
72
- </p>
73
- )}
74
- {view.status === 'analysing' && (
75
- <p className="text-muted-foreground">Reading the data on this page…</p>
76
- )}
77
- {view.status === 'error' && (
78
- <p className="text-destructive">{view.error}</p>
79
- )}
80
- {view.status === 'ready' && (
81
- <>
82
- {view.stale && (
83
- <p className="text-sm text-muted-foreground">
84
- The view changed since this was generated. Refresh to update.
85
- </p>
86
- )}
87
- <h3 className="text-lg font-semibold">{view.headline}</h3>
88
- <p>{view.summary}</p>
89
- {view.limitations.length > 0 && (
90
- <ul className="text-sm text-muted-foreground">
91
- {view.limitations.map((l) => (
92
- <li key={l}>{l}</li>
93
- ))}
94
- </ul>
95
- )}
96
- <p className="text-xs text-muted-foreground">
97
- AI-generated from the data on this page
98
- {view.dataAsOf ? ` · Data as of ${view.dataAsOf}` : ''}
99
- </p>
100
- </>
101
- )}
102
- </CardContent>
103
- </Card>
104
- );
105
- }
106
- ```
52
+ Render `<InsightsSummary />` near the top of the page. It handles every status (idle, analysing, ready, error, stale) and renders nothing when unavailable. Props: `title`, `idleText`, `className`. Write your own block only when the user asks for a layout the component cannot give, and then keep every status branch.
107
53
 
108
54
  Rules for the block:
109
55
 
110
56
  - Say "AI-generated" in the footer. Never name a model or provider.
111
57
  - Show the limitations; they are what keeps the summary honest.
112
58
  - Never mount it unconditionally in a modal or overlay; it is page content.
113
- - `analyse()` re-runs even when a result exists (that is the Refresh case). Do not call it in an effect on mount; the viewer clicks.
59
+ - `analyse()` re-runs even when a result exists (that is the Regenerate case). Do not call it in an effect on mount; the viewer clicks. Lightdash reuses a stored analysis of identical rows on open by itself.
114
60
 
115
61
  ## Per-chart: anomalies on the data points
116
62
 
@@ -121,7 +67,7 @@ const orders = useLightdash(ordersQuery);
121
67
  const insights = useInsights(orders);
122
68
  // insights.status same as the view
123
69
  // insights.anomalies the anomalies that refer to this query
124
- // insights.matches(row) the anomalies whose dimension values match this row
70
+ // insights.matches(row, fieldId?) the anomalies on this row; fieldId narrows to one metric
125
71
  // insights.canInvestigate false when no agent is available
126
72
  // insights.canContinue false when the org keeps viewers at the explanation
127
73
  // insights.investigate(id) start an investigation of one anomaly
@@ -150,98 +96,77 @@ Each anomaly:
150
96
  }
151
97
  ```
152
98
 
153
- Mark the flagged rows on the chart. `matches(row)` takes the raw result row, so call it per datum. Only `high`, `medium` and `positive` earn a marker; `info` is context for the summary (a gap, a partial period), so never paint it on the chart:
99
+ Mark the flagged rows with the shipped helpers. Only `high`, `medium` and `positive` earn a marker; `info` is context for the summary and is never painted. Every helper takes the series' `dataKey` as `fieldId`, so on a chart plotting revenue and orders a revenue anomaly marks only the revenue series. The marker element must be both `dot` and `activeDot`, otherwise the hover dot covers it and takes the click:
154
100
 
155
101
  ```tsx
156
- import { Bar, BarChart, Cell } from 'recharts';
157
-
158
- const MARKED = new Set(['high', 'medium', 'positive']);
159
- const marker = (insights, row) =>
160
- insights.matches(row).find((a) => MARKED.has(a.severity)) ?? null;
102
+ import { Line, LineChart, Tooltip } from 'recharts';
103
+ import { ChartTooltipSurface } from '@/lib/floating';
161
104
 
162
- function OrdersByStatus() {
163
- const orders = useLightdash(ordersQuery);
164
- const insights = useInsights(orders);
105
+ function RevenueByMonth() {
106
+ const revenue = useLightdash(revenueQuery);
107
+ const insights = useInsights(revenue);
108
+ const [menu, setMenu] = useState(null);
109
+ const openMenu = (row, e, fieldId) =>
110
+ setMenu({ row, fieldId, x: e.clientX, y: e.clientY });
111
+ const dot = (
112
+ <InsightMarker insights={insights} fieldId="revenue" onOpenMenu={openMenu} />
113
+ );
165
114
 
166
115
  return (
167
- <BarChart data={orders.data}>
168
- <Bar dataKey="orders_count">
169
- {orders.data.map((row) => {
170
- const flagged = marker(insights, row) !== null;
171
- return (
172
- <Cell
173
- key={row.orders_status}
174
- stroke={flagged ? 'var(--destructive)' : undefined}
175
- strokeWidth={flagged ? 2 : 0}
176
- />
177
- );
178
- })}
179
- </Bar>
180
- </BarChart>
116
+ <LineChart data={revenue.data}>
117
+ <Tooltip
118
+ content={({ payload, label }) => {
119
+ const row = payload?.[0]?.payload;
120
+ const finding = insightTooltipText(insights, row, 'revenue');
121
+ return row ? (
122
+ <ChartTooltipSurface>
123
+ <div className="font-semibold">{label}</div>
124
+ <div>{revenue.format(row, 'revenue')}</div>
125
+ {finding && <div className="text-destructive text-xs">{finding}</div>}
126
+ </ChartTooltipSurface>
127
+ ) : null;
128
+ }}
129
+ />
130
+ <Line dataKey="revenue" dot={dot} activeDot={dot} />
131
+ </LineChart>
181
132
  );
182
133
  }
183
134
  ```
184
135
 
185
- Keep the marker subtle (an outline, a dot, a badge in the tooltip), and show the anomaly `text` on hover so the viewer learns why the point is marked. Tint by severity: `positive` is not a warning.
186
-
187
- ### Markers on line and area charts must stay clickable on hover
188
-
189
- Recharts draws `activeDot` on top of `dot` while the cursor is over a point. With the default `activeDot={{ r: 5 }}` the hover dot covers the marker and takes the click, so the action menu only opens from a one-pixel rim. Render the same marker component for both, with the same click handler, or turn the hover dot off:
136
+ Bar charts use `insightCellProps` on each `Cell` and open the menu from the bar's `onClick`:
190
137
 
191
138
  ```tsx
192
- // Recharts clones the dot element and merges cx, cy and payload into its
193
- // props, so extra props like insights and onOpenMenu pass straight through.
194
- const Marker = ({ cx, cy, payload, insights, onOpenMenu }) => {
195
- const anomaly = marker(insights, payload);
196
- return (
197
- <circle
198
- cx={cx}
199
- cy={cy}
200
- r={anomaly ? 5 : 3}
201
- fill={anomaly ? 'var(--destructive)' : 'var(--chart-1)'}
202
- style={{ cursor: 'pointer' }}
203
- onClick={(e) => onOpenMenu(payload, e)}
204
- />
205
- );
206
- };
207
-
208
- function OrdersOverTime() {
209
- const orders = useLightdash(ordersQuery);
210
- const insights = useInsights(orders);
211
- const [menu, setMenu] = useState(null);
212
- const openMenu = (row, e) => setMenu({ row, x: e.clientX, y: e.clientY });
213
- const dot = <Marker insights={insights} onOpenMenu={openMenu} />;
214
-
215
- return (
216
- <LineChart data={orders.data}>
217
- <Line dataKey="orders_count" dot={dot} activeDot={dot} />
218
- {/* or: activeDot={false} */}
219
- </LineChart>
220
- );
221
- }
139
+ <Bar dataKey="revenue" onClick={(d, _i, e) => openMenu(d.payload, e, 'revenue')}>
140
+ {rows.map((row) => (
141
+ <Cell key={row.status} {...insightCellProps(insights, row, 'revenue')} />
142
+ ))}
143
+ </Bar>
222
144
  ```
223
145
 
224
- Whatever element carries the click handler must be the topmost one at that position: render markers after the series, never under a hover overlay or a tooltip cursor.
146
+ `markerFor(insights, row, fieldId)` returns the anomaly behind a marker when you need it directly, for example to open an `InvestigationCard`.
225
147
 
226
148
  ## Investigate from the action menu
227
149
 
228
- When a clicked data point has a matching anomaly, add **Investigate** to the point's action menu next to "Filter by …" and "View underlying data". Gate it on `insights.canInvestigate` and on the investigation not already running or ready:
150
+ Add `<InvestigateMenuItem />` to the data-point action menu next to "Filter by …" and "View underlying data". It renders only when the clicked row has a flagged anomaly: **Investigate with AI** starts one when an agent is available, and **View investigation** reopens the card once one is running, done or failed, so a dismissed result is never lost:
229
151
 
230
152
  ```tsx
231
- const anomaly = insights.matches(row)[0];
232
-
233
- {anomaly && insights.canInvestigate && anomaly.investigation.status === 'idle' && (
234
- <DropdownMenuItem onSelect={() => insights.investigate(anomaly.id)}>
235
- Investigate with AI
153
+ <DropdownMenuContent>
154
+ <DropdownMenuItem onSelect={() => addFilter(menu.row)}>
155
+ Filter by {value}
156
+ </DropdownMenuItem>
157
+ <DropdownMenuItem onSelect={() => showUnderlyingData(menu.row)}>
158
+ View underlying data
236
159
  </DropdownMenuItem>
237
- )}
160
+ <InvestigateMenuItem
161
+ insights={insights}
162
+ row={menu.row}
163
+ fieldId={menu.fieldId}
164
+ onOpen={(anomaly) => setOpenInvestigation(anomaly.id)}
165
+ />
166
+ </DropdownMenuContent>
238
167
  ```
239
168
 
240
- Then render the investigation state wherever the anomaly is shown (a card under the chart, a side sheet):
241
-
242
- - `running` — a spinner and "Investigating…". It can take a minute.
243
- - `ready` — render `explanation` as Markdown (it contains a summary, a "Possible drivers" list with the evidence for each, an "Evidence" list, and a "Confidence: …" line). If `partial` is true, say the query budget ran out. When `insights.canContinue` is true, offer a **Continue in Ask AI** button that calls `insights.continueInAskAi(anomaly.id)`; Lightdash opens the thread. When it is false the organisation keeps viewers at the explanation: render no button.
244
- - `error` — show `error` and offer Investigate again.
169
+ Then render `<InvestigationCard anomaly={anomaly} insights={insights} onDismiss={...} />` under the chart for the anomaly being investigated (find it with `insights.anomalies.find((a) => a.id === openInvestigation)`; re-read it on every render so the card follows the pushed status). The card shows the spinner while running, the explanation as Markdown when ready (a summary, a "Possible drivers" list with evidence, an "Evidence" list, a "Confidence: …" line), a note when `partial`, an "Investigate again" action on error, and **Continue in Ask AI** only when `insights.canContinue` is true. When it is false the organisation keeps viewers at the explanation and the card shows no button.
245
170
 
246
171
  Never open a dialog for the investigation yourself; the app renders state, Lightdash owns the run.
247
172
 
@@ -292,7 +217,7 @@ function RevenueTakeaway() {
292
217
  - Do not send rows or field values anywhere. The hooks expose results; Lightdash already has the data.
293
218
  - Do not add an external connection to a model provider for summaries or explanations. Use this hook.
294
219
  - Do not fabricate insights when the status is `idle` or `unavailable`; render the state.
295
- - Do not render `explanation` as HTML. It is Markdown; render it as text or through a Markdown component.
220
+ - Do not render `explanation` as HTML. `InvestigationCard` renders its Markdown safely; if you must render it elsewhere, use the shipped `Markdown` component from `@/components/insights/Markdown`.
296
221
  - Do not call `analyse()` on mount or on a timer.
297
222
 
298
223
  ## When nothing is notable
@@ -693,7 +693,7 @@ For any external HTTP API call, read `/app/references/external-apis.md` and use
693
693
 
694
694
  ### AI analysis — executive summary and anomalies
695
695
 
696
- When the user asks for an executive summary, "what changed and why", anomaly callouts, or any AI-generated insight about the data on the page, read `/app/references/ai-analysis.md` and use `useInsights` from the SDK. Lightdash runs the analysis over the queries the app already loaded and pushes the result in; the app renders it (a summary block, markers on flagged points, an Investigate action) and never sends rows or keys. Render nothing when `status === 'unavailable'`. For an author-written question over loaded results (a takeaway under a chart, "explain this row"), use `useAiPrompt` from the same reference.
696
+ When the user asks for AI analysis, an executive summary, "what changed and why", anomaly callouts, or any AI-generated insight about the data on the page, read `/app/references/ai-analysis.md` and build its **Default build** with the shipped components in `src/components/insights/` (`InsightsSummary`, `InsightMarker` / `insightCellProps`, `InvestigateMenuItem`, `InvestigationCard`): summary block at the top, markers on every query-bound chart, Investigate in every point menu, the investigation card under the chart. A short ask gets the whole set; only a narrower ask ("just a summary") gets a subset. Never rewrite those components. Lightdash runs the analysis over the queries the app already loaded and pushes the result in; the app renders it and never sends rows or keys. The components render nothing when the host has no analysis to offer. For an author-written question over loaded results (a takeaway under a chart, "explain this row"), use `useAiPrompt` from the same reference.
697
697
 
698
698
  ## Visual Design
699
699
 
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The view-level analysis block. Renders nothing when the host has no
3
+ * analysis to offer (embeds, tiles, org not rolled out).
4
+ */
5
+ import { useInsights } from '@lightdash/query-sdk';
6
+ import { Button } from '@/components/ui/button';
7
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
8
+ import { Loader2, RefreshCw, Sparkles } from 'lucide-react';
9
+ import type { ReactNode } from 'react';
10
+
11
+ type Props = {
12
+ title?: ReactNode;
13
+ /** Shown before the first run. */
14
+ idleText?: string;
15
+ className?: string;
16
+ };
17
+
18
+ export function InsightsSummary({
19
+ title = 'Executive summary',
20
+ idleText = 'Click Analyse for an AI-generated read of everything on this page.',
21
+ className,
22
+ }: Props) {
23
+ const view = useInsights();
24
+ if (view.status === 'unavailable') return null;
25
+ const busy = view.status === 'analysing';
26
+
27
+ return (
28
+ <Card className={className}>
29
+ <CardHeader className="flex flex-row items-center justify-between gap-4 space-y-0">
30
+ <CardTitle className="flex items-center gap-2 text-base">
31
+ <Sparkles className="h-4 w-4 text-muted-foreground" />
32
+ {title}
33
+ </CardTitle>
34
+ <Button
35
+ variant="outline"
36
+ size="sm"
37
+ onClick={view.analyse}
38
+ disabled={busy}
39
+ >
40
+ {busy ? (
41
+ <Loader2 className="h-4 w-4 animate-spin" />
42
+ ) : (
43
+ <RefreshCw className="h-4 w-4" />
44
+ )}
45
+ {view.status === 'ready' ? 'Regenerate' : 'Analyse'}
46
+ </Button>
47
+ </CardHeader>
48
+ <CardContent className="space-y-3">
49
+ {view.status === 'idle' && (
50
+ <p className="text-sm text-muted-foreground">{idleText}</p>
51
+ )}
52
+ {busy && (
53
+ <p className="text-sm text-muted-foreground">
54
+ Reading the data on this page…
55
+ </p>
56
+ )}
57
+ {view.status === 'error' && (
58
+ <p className="text-sm text-destructive">
59
+ {view.error ?? 'The analysis failed. Try again.'}
60
+ </p>
61
+ )}
62
+ {view.status === 'ready' && (
63
+ <>
64
+ {view.stale && (
65
+ <p className="text-xs text-muted-foreground">
66
+ The view changed since this was generated.
67
+ Regenerate to update.
68
+ </p>
69
+ )}
70
+ {view.headline && (
71
+ <p className="font-semibold leading-snug">
72
+ {view.headline}
73
+ </p>
74
+ )}
75
+ {view.summary && (
76
+ <p className="text-sm leading-relaxed">
77
+ {view.summary}
78
+ </p>
79
+ )}
80
+ {view.limitations.length > 0 && (
81
+ <ul className="list-disc space-y-1 pl-5 text-xs text-muted-foreground">
82
+ {view.limitations.map((limitation) => (
83
+ <li key={limitation}>{limitation}</li>
84
+ ))}
85
+ </ul>
86
+ )}
87
+ <p className="text-xs uppercase tracking-wide text-muted-foreground">
88
+ AI-generated from the data on this page
89
+ {view.dataAsOf ? ` · Data as of ${view.dataAsOf}` : ''}
90
+ </p>
91
+ </>
92
+ )}
93
+ </CardContent>
94
+ </Card>
95
+ );
96
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The investigation entry for a data point's action menu. Starts an
3
+ * investigation for an idle anomaly, reopens the card for one that is
4
+ * running, done or failed, and renders nothing when the row has no flagged
5
+ * anomaly.
6
+ */
7
+ import type { Insight, QueryInsights } from '@lightdash/query-sdk';
8
+ import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
9
+ import { Sparkles } from 'lucide-react';
10
+ import { markerFor } from './markers';
11
+
12
+ type Props = {
13
+ insights: QueryInsights;
14
+ row: Record<string, unknown> | null | undefined;
15
+ /** The clicked series' `dataKey`, from the marker's `onOpenMenu`. */
16
+ fieldId?: string;
17
+ /** Called with the anomaly whose card should be shown. */
18
+ onOpen: (anomaly: Insight) => void;
19
+ };
20
+
21
+ export function InvestigateMenuItem({
22
+ insights,
23
+ row,
24
+ fieldId,
25
+ onOpen,
26
+ }: Props) {
27
+ const anomaly = markerFor(insights, row, fieldId);
28
+ if (!anomaly) return null;
29
+
30
+ if (anomaly.investigation.status === 'idle') {
31
+ if (!insights.canInvestigate) return null;
32
+ return (
33
+ <DropdownMenuItem
34
+ onSelect={() => {
35
+ insights.investigate(anomaly.id);
36
+ onOpen(anomaly);
37
+ }}
38
+ >
39
+ <Sparkles className="mr-2 h-4 w-4" />
40
+ Investigate with AI
41
+ </DropdownMenuItem>
42
+ );
43
+ }
44
+
45
+ return (
46
+ <DropdownMenuItem onSelect={() => onOpen(anomaly)}>
47
+ <Sparkles className="mr-2 h-4 w-4" />
48
+ View investigation
49
+ </DropdownMenuItem>
50
+ );
51
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Renders one anomaly's investigation state under its chart. The app owns
3
+ * where it appears; Lightdash owns the run.
4
+ */
5
+ import type { Insight, QueryInsights } from '@lightdash/query-sdk';
6
+ import { Button } from '@/components/ui/button';
7
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
8
+ import { Loader2, MessageSquare, X } from 'lucide-react';
9
+ import { Markdown } from './Markdown';
10
+
11
+ type Props = {
12
+ anomaly: Insight;
13
+ insights: QueryInsights;
14
+ onDismiss?: () => void;
15
+ className?: string;
16
+ };
17
+
18
+ export function InvestigationCard({
19
+ anomaly,
20
+ insights,
21
+ onDismiss,
22
+ className,
23
+ }: Props) {
24
+ const { investigation } = anomaly;
25
+ if (investigation.status === 'idle') return null;
26
+
27
+ return (
28
+ <Card className={className}>
29
+ <CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
30
+ <CardTitle className="text-sm font-medium leading-snug">
31
+ Investigation · {anomaly.text}
32
+ </CardTitle>
33
+ {onDismiss && (
34
+ <Button
35
+ variant="ghost"
36
+ size="icon"
37
+ aria-label="Dismiss"
38
+ onClick={onDismiss}
39
+ >
40
+ <X className="h-4 w-4" />
41
+ </Button>
42
+ )}
43
+ </CardHeader>
44
+ <CardContent className="space-y-3 text-sm">
45
+ {investigation.status === 'running' && (
46
+ <p className="flex items-center gap-2 text-muted-foreground">
47
+ <Loader2 className="h-4 w-4 animate-spin" />
48
+ Investigating… this can take a minute.
49
+ </p>
50
+ )}
51
+ {investigation.status === 'error' && (
52
+ <>
53
+ <p className="text-destructive">
54
+ {investigation.error ?? 'The investigation failed.'}
55
+ </p>
56
+ {insights.canInvestigate && (
57
+ <Button
58
+ variant="outline"
59
+ size="sm"
60
+ onClick={() => insights.investigate(anomaly.id)}
61
+ >
62
+ Investigate again
63
+ </Button>
64
+ )}
65
+ </>
66
+ )}
67
+ {investigation.status === 'ready' && (
68
+ <>
69
+ <Markdown text={investigation.explanation ?? ''} />
70
+ {investigation.partial && (
71
+ <p className="text-xs text-muted-foreground">
72
+ The query budget ran out; this is a partial
73
+ answer.
74
+ </p>
75
+ )}
76
+ {insights.canContinue && (
77
+ <Button
78
+ variant="outline"
79
+ size="sm"
80
+ onClick={() =>
81
+ insights.continueInAskAi(anomaly.id)
82
+ }
83
+ >
84
+ <MessageSquare className="h-4 w-4" />
85
+ Continue in Ask AI
86
+ </Button>
87
+ )}
88
+ </>
89
+ )}
90
+ </CardContent>
91
+ </Card>
92
+ );
93
+ }