@agent-native/toolkit 0.198.2 → 0.198.3-nightly-20260930124743

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.
Files changed (110) hide show
  1. package/dist/app/agentkit/react/components.d.ts.map +1 -1
  2. package/dist/app/agentkit/react/components.js +4 -1
  3. package/dist/app/agentkit/react/components.js.map +1 -1
  4. package/dist/app/chat/AgentKitAssistantChat.d.ts.map +1 -1
  5. package/dist/app/chat/AgentKitAssistantChat.js +20 -6
  6. package/dist/app/chat/AgentKitAssistantChat.js.map +1 -1
  7. package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
  8. package/dist/app/i18n/catalogs/ar-SA.js +211 -0
  9. package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
  10. package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
  11. package/dist/app/i18n/catalogs/de-DE.js +147 -0
  12. package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
  13. package/dist/app/i18n/catalogs/en-US.d.ts +147 -0
  14. package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
  15. package/dist/app/i18n/catalogs/en-US.js +147 -0
  16. package/dist/app/i18n/catalogs/en-US.js.map +1 -1
  17. package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
  18. package/dist/app/i18n/catalogs/es-ES.js +163 -0
  19. package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
  20. package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
  21. package/dist/app/i18n/catalogs/fr-FR.js +163 -0
  22. package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
  23. package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
  24. package/dist/app/i18n/catalogs/hi-IN.js +147 -0
  25. package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
  26. package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
  27. package/dist/app/i18n/catalogs/ja-JP.js +131 -0
  28. package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
  29. package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
  30. package/dist/app/i18n/catalogs/ko-KR.js +131 -0
  31. package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
  32. package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
  33. package/dist/app/i18n/catalogs/pt-BR.js +163 -0
  34. package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
  35. package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
  36. package/dist/app/i18n/catalogs/zh-CN.js +131 -0
  37. package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
  38. package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
  39. package/dist/app/i18n/catalogs/zh-TW.js +131 -0
  40. package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
  41. package/dist/app/observability/ObservabilityDashboard.d.ts.map +1 -1
  42. package/dist/app/observability/ObservabilityDashboard.js +101 -109
  43. package/dist/app/observability/ObservabilityDashboard.js.map +1 -1
  44. package/dist/app/observability/RunInsights.d.ts +22 -0
  45. package/dist/app/observability/RunInsights.d.ts.map +1 -0
  46. package/dist/app/observability/RunInsights.js +658 -0
  47. package/dist/app/observability/RunInsights.js.map +1 -0
  48. package/package.json +5 -5
  49. package/src/app/agent-page/ConnectionsTab.spec.tsx +0 -3
  50. package/src/app/agentkit/react/components.tsx +4 -1
  51. package/src/app/agentkit/react/composer-context.spec.tsx +4 -1
  52. package/src/app/chat/AgentKitAssistantChat.spec.tsx +57 -0
  53. package/src/app/chat/AgentKitAssistantChat.tsx +44 -8
  54. package/src/app/chat/AgentPanel.header.spec.ts +22 -22
  55. package/src/app/chat/agentkit-chat/action-widget.spec.tsx +17 -2
  56. package/src/app/chat/agentkit-chat/approval-card.spec.tsx +2 -2
  57. package/src/app/chat/agentkit-chat/connections.spec.tsx +1 -1
  58. package/src/app/chat/chat/markdown-renderer.spec.tsx +1 -1
  59. package/src/app/chat/chat/tool-call-display.spec.tsx +25 -2
  60. package/src/app/chat/chat/widgets/DataTableWidget.spec.tsx +38 -26
  61. package/src/app/chat/chat/widgets/agent-team-progress-widget.spec.tsx +7 -1
  62. package/src/app/chat/chat/widgets/builtin-connect-required-renderer.spec.tsx +24 -1
  63. package/src/app/chat/chat/widgets/record-change-widget.spec.tsx +16 -1
  64. package/src/app/chat/thinking-display.spec.tsx +1 -1
  65. package/src/app/db-admin/useAgentSync.spec.tsx +1 -1
  66. package/src/app/extensions/ExtensionViewer.spec.tsx +2 -1
  67. package/src/app/i18n/catalogs/ar-SA.ts +282 -0
  68. package/src/app/i18n/catalogs/de-DE.ts +198 -0
  69. package/src/app/i18n/catalogs/en-US.ts +187 -0
  70. package/src/app/i18n/catalogs/es-ES.ts +221 -0
  71. package/src/app/i18n/catalogs/fr-FR.ts +219 -0
  72. package/src/app/i18n/catalogs/hi-IN.ts +182 -0
  73. package/src/app/i18n/catalogs/ja-JP.ts +160 -0
  74. package/src/app/i18n/catalogs/ko-KR.ts +160 -0
  75. package/src/app/i18n/catalogs/pt-BR.ts +220 -0
  76. package/src/app/i18n/catalogs/zh-CN.ts +154 -0
  77. package/src/app/i18n/catalogs/zh-TW.ts +154 -0
  78. package/src/app/integrations/BuilderIntegrationPage.spec.tsx +3 -3
  79. package/src/app/integrations/ChannelsPage.spec.tsx +3 -3
  80. package/src/app/integrations/IntegrationsPage.spec.tsx +3 -3
  81. package/src/app/integrations/IntegrationsPanel.spec.tsx +7 -4
  82. package/src/app/observability/ObservabilityDashboard.review.spec.tsx +211 -52
  83. package/src/app/observability/ObservabilityDashboard.spec.tsx +80 -20
  84. package/src/app/observability/ObservabilityDashboard.tsx +240 -400
  85. package/src/app/observability/RunInsights.tsx +1603 -0
  86. package/src/app/observability/ThumbsFeedback.spec.tsx +29 -12
  87. package/src/app/onboarding/FirstRunOnboarding.spec.tsx +25 -5
  88. package/src/app/onboarding/first-run-startup-gate.spec.tsx +12 -7
  89. package/src/app/resources/McpConnectionSuggestion.render.spec.tsx +43 -5
  90. package/src/app/resources/McpIntegrationDialog.spec.tsx +20 -14
  91. package/src/app/resources/ResourcesPanel.spec.ts +9 -7
  92. package/src/app/review/ReviewThreadPanel.spec.tsx +2 -2
  93. package/src/app/settings/SecretsSection.spec.tsx +12 -3
  94. package/src/app/settings/SettingsTabsPage.spec.tsx +4 -1
  95. package/src/app/settings/VoiceTranscriptionSection.spec.tsx +12 -1
  96. package/src/app/settings/account/PreferencesSettings.spec.tsx +13 -1
  97. package/src/app/settings/account/ProfileSettings.spec.tsx +13 -1
  98. package/src/app/settings/account/SecuritySettings.spec.tsx +12 -1
  99. package/src/app/settings/shell/SettingsShell.spec.tsx +35 -15
  100. package/src/app/settings/shell/pages/app-group-pages.spec.tsx +7 -0
  101. package/src/app/settings/shell/pages/audit.spec.tsx +5 -2
  102. package/src/app/settings/shell/pages/resource-pages.spec.tsx +25 -16
  103. package/src/app/settings/shell/pages/sub-agents.spec.tsx +24 -15
  104. package/src/app/settings/usage/UsagePage.spec.tsx +4 -0
  105. package/src/app/setup-connections/BuilderConnectCard.spec.tsx +27 -8
  106. package/src/app/sharing/ShareButton.spec.tsx +7 -3
  107. package/src/app/sharing/ShareDialog.spec.tsx +4 -4
  108. package/src/app/sharing/useShareButtonController.spec.tsx +1 -1
  109. package/src/app/sharing/useShareDialogController.spec.tsx +2 -2
  110. package/src/app/transcription/BuilderTranscriptionCta.spec.tsx +1 -1
@@ -0,0 +1,1603 @@
1
+ import { requestAgentChatThreadOpen } from "@agent-native/core/client/agent-chat";
2
+ import { useActionQuery } from "@agent-native/core/client/hooks";
3
+ import { useT } from "@agent-native/core/client/i18n";
4
+ import { Skeleton } from "@agent-native/toolkit/design-system";
5
+ import { Button } from "@agent-native/toolkit/ui/button";
6
+ import {
7
+ Sheet,
8
+ SheetContent,
9
+ SheetDescription,
10
+ SheetTitle,
11
+ } from "@agent-native/toolkit/ui/sheet";
12
+ import {
13
+ IconAlertTriangle,
14
+ IconChevronDown,
15
+ IconChevronUp,
16
+ IconCircleCheck,
17
+ IconCircleX,
18
+ IconInfoCircle,
19
+ IconMessages,
20
+ IconPigMoney,
21
+ IconThumbDown,
22
+ IconThumbUp,
23
+ IconX,
24
+ } from "@tabler/icons-react";
25
+ import { type ReactNode, useEffect, useMemo, useState } from "react";
26
+
27
+ type T = ReturnType<typeof useT>;
28
+
29
+ interface CostBreakdown {
30
+ cacheReadCents: number;
31
+ cacheWriteCents: number;
32
+ uncachedInputCents: number;
33
+ outputCents: number;
34
+ totalCents: number;
35
+ estimatedCents: number;
36
+ noCacheCents: number;
37
+ }
38
+
39
+ interface TokenTotals {
40
+ inputTokens: number;
41
+ outputTokens: number;
42
+ cacheReadTokens: number;
43
+ cacheWriteTokens: number;
44
+ }
45
+
46
+ type RestartCause = "tool-lookup" | "prefix-changed";
47
+
48
+ interface RunTool {
49
+ name: string;
50
+ calls: number;
51
+ failed: number;
52
+ error: string | null;
53
+ }
54
+
55
+ interface RunListItem {
56
+ runId: string;
57
+ threadId: string | null;
58
+ createdAt: number;
59
+ model: string;
60
+ prompt: string | null;
61
+ status: "success" | "error" | "unknown";
62
+ tokens: TokenTotals;
63
+ cost: CostBreakdown;
64
+ modelCalls: number;
65
+ tools: RunTool[];
66
+ restarts: {
67
+ count: number;
68
+ cents: number;
69
+ byCause: Record<RestartCause, { count: number; cents: number }>;
70
+ };
71
+ parallel: { calls: number; savedMs: number };
72
+ recoveredErrors: number;
73
+ durationMs: number | null;
74
+ feedback: "up" | "down" | null;
75
+ }
76
+
77
+ interface PeriodTotals {
78
+ runs: number;
79
+ tokens: TokenTotals;
80
+ cost: CostBreakdown;
81
+ }
82
+
83
+ interface UsageInsightsData {
84
+ sinceDays: number;
85
+ current: PeriodTotals;
86
+ previous: PeriodTotals;
87
+ runs: RunListItem[];
88
+ }
89
+
90
+ interface RunTurn {
91
+ index: number;
92
+ model: string;
93
+ durationMs: number;
94
+ tokens: TokenTotals;
95
+ cost: CostBreakdown;
96
+ status: string;
97
+ restart: { cause: RestartCause; cents: number } | null;
98
+ cacheExpired: boolean;
99
+ toolCalls: Array<{
100
+ name: string;
101
+ startedAt: number;
102
+ durationMs: number;
103
+ status: string;
104
+ errorMessage: string | null;
105
+ }>;
106
+ }
107
+
108
+ interface RunDetail extends RunListItem {
109
+ reply: string | null;
110
+ turns: RunTurn[];
111
+ scores: Array<{
112
+ source: "heuristic" | "judge" | "human";
113
+ criteria: string;
114
+ score: number;
115
+ }>;
116
+ }
117
+
118
+ // Apps don't share chart tokens, and this set is checked for colour-blind separation in both themes.
119
+ const LIGHT_PALETTE =
120
+ // guard:allow-raw-color — validated categorical palette, light surface
121
+ "--usage-cache-read: #2a78d6; --usage-cache-write: #eb6834; --usage-fresh-input: #1baf7a; --usage-output: #eda100;";
122
+ const DARK_PALETTE =
123
+ // guard:allow-raw-color — same palette stepped for the dark surface
124
+ "--usage-cache-read: #3987e5; --usage-cache-write: #d95926; --usage-fresh-input: #199e70; --usage-output: #c98500;";
125
+ const PALETTE_CSS = `.run-insights, .run-insights-panel { ${LIGHT_PALETTE} } .dark .run-insights, .dark .run-insights-panel { ${DARK_PALETTE} }`;
126
+
127
+ const WRITE_VERBS = new Set([
128
+ "add",
129
+ "apply",
130
+ "create",
131
+ "delete",
132
+ "duplicate",
133
+ "edit",
134
+ "export",
135
+ "generate",
136
+ "insert",
137
+ "move",
138
+ "remove",
139
+ "rename",
140
+ "save",
141
+ "send",
142
+ "set",
143
+ "update",
144
+ "upload",
145
+ "write",
146
+ ]);
147
+
148
+ function verbLabel(t: T, verb: string, object: string): string | null {
149
+ const labels: Record<string, () => string> = {
150
+ add: () => t("agentChat.observability.insights.toolVerb.add", { object }),
151
+ analyze: () =>
152
+ t("agentChat.observability.insights.toolVerb.analyze", { object }),
153
+ apply: () =>
154
+ t("agentChat.observability.insights.toolVerb.apply", { object }),
155
+ capture: () =>
156
+ t("agentChat.observability.insights.toolVerb.capture", { object }),
157
+ check: () =>
158
+ t("agentChat.observability.insights.toolVerb.check", { object }),
159
+ connect: () =>
160
+ t("agentChat.observability.insights.toolVerb.connect", { object }),
161
+ consume: () =>
162
+ t("agentChat.observability.insights.toolVerb.read", { object }),
163
+ create: () =>
164
+ t("agentChat.observability.insights.toolVerb.create", { object }),
165
+ delete: () =>
166
+ t("agentChat.observability.insights.toolVerb.delete", { object }),
167
+ duplicate: () =>
168
+ t("agentChat.observability.insights.toolVerb.duplicate", { object }),
169
+ edit: () => t("agentChat.observability.insights.toolVerb.edit", { object }),
170
+ export: () =>
171
+ t("agentChat.observability.insights.toolVerb.export", { object }),
172
+ fetch: () =>
173
+ t("agentChat.observability.insights.toolVerb.fetch", { object }),
174
+ find: () => t("agentChat.observability.insights.toolVerb.find", { object }),
175
+ generate: () =>
176
+ t("agentChat.observability.insights.toolVerb.generate", { object }),
177
+ get: () => t("agentChat.observability.insights.toolVerb.read", { object }),
178
+ index: () =>
179
+ t("agentChat.observability.insights.toolVerb.index", { object }),
180
+ insert: () =>
181
+ t("agentChat.observability.insights.toolVerb.insert", { object }),
182
+ list: () => t("agentChat.observability.insights.toolVerb.list", { object }),
183
+ move: () => t("agentChat.observability.insights.toolVerb.move", { object }),
184
+ navigate: () =>
185
+ t("agentChat.observability.insights.toolVerb.navigate", { object }),
186
+ open: () => t("agentChat.observability.insights.toolVerb.open", { object }),
187
+ present: () =>
188
+ t("agentChat.observability.insights.toolVerb.present", { object }),
189
+ propose: () =>
190
+ t("agentChat.observability.insights.toolVerb.propose", { object }),
191
+ query: () =>
192
+ t("agentChat.observability.insights.toolVerb.query", { object }),
193
+ read: () => t("agentChat.observability.insights.toolVerb.read", { object }),
194
+ remove: () =>
195
+ t("agentChat.observability.insights.toolVerb.remove", { object }),
196
+ rename: () =>
197
+ t("agentChat.observability.insights.toolVerb.rename", { object }),
198
+ reply: () =>
199
+ t("agentChat.observability.insights.toolVerb.reply", { object }),
200
+ resolve: () =>
201
+ t("agentChat.observability.insights.toolVerb.resolve", { object }),
202
+ run: () => t("agentChat.observability.insights.toolVerb.run", { object }),
203
+ save: () => t("agentChat.observability.insights.toolVerb.save", { object }),
204
+ search: () =>
205
+ t("agentChat.observability.insights.toolVerb.search", { object }),
206
+ send: () => t("agentChat.observability.insights.toolVerb.send", { object }),
207
+ set: () => t("agentChat.observability.insights.toolVerb.set", { object }),
208
+ take: () => t("agentChat.observability.insights.toolVerb.take", { object }),
209
+ update: () =>
210
+ t("agentChat.observability.insights.toolVerb.update", { object }),
211
+ upload: () =>
212
+ t("agentChat.observability.insights.toolVerb.upload", { object }),
213
+ view: () => t("agentChat.observability.insights.toolVerb.view", { object }),
214
+ write: () =>
215
+ t("agentChat.observability.insights.toolVerb.write", { object }),
216
+ };
217
+ return labels[verb]?.() ?? null;
218
+ }
219
+
220
+ function toolWords(name: string): string[] {
221
+ return name
222
+ .replace(/^mcp__[^_]+__/, "")
223
+ .split(/[-_]/)
224
+ .filter(Boolean);
225
+ }
226
+
227
+ function toolVerb(name: string): string | null {
228
+ const words = toolWords(name);
229
+ if (WRITE_VERBS.has(words[0]!) || READ_VERBS.has(words[0]!)) return words[0]!;
230
+ const last = words.at(-1)!;
231
+ if (WRITE_VERBS.has(last) || READ_VERBS.has(last)) return last;
232
+ return null;
233
+ }
234
+
235
+ const READ_VERBS = new Set([
236
+ "analyze",
237
+ "capture",
238
+ "check",
239
+ "connect",
240
+ "consume",
241
+ "fetch",
242
+ "find",
243
+ "get",
244
+ "index",
245
+ "list",
246
+ "navigate",
247
+ "open",
248
+ "present",
249
+ "propose",
250
+ "query",
251
+ "read",
252
+ "reply",
253
+ "resolve",
254
+ "run",
255
+ "search",
256
+ "take",
257
+ "view",
258
+ ]);
259
+
260
+ /** "edit-design" → "Edited design", "docs-search" → "Searched docs". */
261
+ function humanizeTool(t: T, name: string): string {
262
+ if (name === "tool-search") {
263
+ return t("agentChat.observability.insights.lookedForTools");
264
+ }
265
+ const words = toolWords(name);
266
+ const verb = toolVerb(name);
267
+ if (!verb) return words.join(" ");
268
+ const object = (words[0] === verb ? words.slice(1) : words.slice(0, -1)).join(
269
+ " ",
270
+ );
271
+ return verbLabel(t, verb, object) ?? words.join(" ");
272
+ }
273
+
274
+ function toolNoun(name: string): string {
275
+ return toolWords(name).join(" ");
276
+ }
277
+
278
+ function listTools(
279
+ t: T,
280
+ tools: Array<{ name: string; calls: number }>,
281
+ ): string {
282
+ return tools
283
+ .map((tool) =>
284
+ tool.calls > 1
285
+ ? t("agentChat.observability.insights.timesCount", {
286
+ label: humanizeTool(t, tool.name),
287
+ count: tool.calls,
288
+ })
289
+ : humanizeTool(t, tool.name),
290
+ )
291
+ .join(", ");
292
+ }
293
+
294
+ function outcomeLine(t: T, tools: RunTool[]): string | null {
295
+ const writes = tools.filter(
296
+ (tool) =>
297
+ WRITE_VERBS.has(toolVerb(tool.name) ?? "") && tool.calls > tool.failed,
298
+ );
299
+ if (writes.length === 0) return null;
300
+ return listTools(t, writes.slice(0, 4));
301
+ }
302
+
303
+ function turnLabel(t: T, turn: RunTurn, isLast: boolean): string {
304
+ if (turn.toolCalls.length === 0) {
305
+ return isLast
306
+ ? t("agentChat.observability.insights.turnReply")
307
+ : t("agentChat.observability.insights.turnThought");
308
+ }
309
+ const counts = new Map<string, number>();
310
+ for (const call of turn.toolCalls) {
311
+ counts.set(call.name, (counts.get(call.name) ?? 0) + 1);
312
+ }
313
+ return listTools(
314
+ t,
315
+ [...counts].map(([name, calls]) => ({ name, calls })),
316
+ );
317
+ }
318
+
319
+ function formatUsd(cents: number): string {
320
+ if (cents <= 0) return "$0";
321
+ if (cents < 1) return "<1¢";
322
+ if (cents < 100) return `${Math.round(cents)}¢`;
323
+ return `$${(cents / 100).toLocaleString(undefined, {
324
+ minimumFractionDigits: 2,
325
+ maximumFractionDigits: 2,
326
+ })}`;
327
+ }
328
+
329
+ function formatDuration(ms: number): string {
330
+ if (ms < 1000) return `${Math.max(1, Math.round(ms))}ms`;
331
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
332
+ return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
333
+ }
334
+
335
+ function formatTime(ms: number): string {
336
+ return new Date(ms).toLocaleString(undefined, {
337
+ month: "short",
338
+ day: "numeric",
339
+ hour: "numeric",
340
+ minute: "2-digit",
341
+ });
342
+ }
343
+
344
+ function compactTokens(value: number): string {
345
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
346
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
347
+ return Math.round(value).toLocaleString();
348
+ }
349
+
350
+ function percent(part: number, whole: number): number {
351
+ return whole > 0 ? Math.round((part / whole) * 100) : 0;
352
+ }
353
+
354
+ /** Restarts worth mentioning: at least 10% of what the prompt cost. */
355
+ function notableRestart(run: RunListItem): boolean {
356
+ return (
357
+ run.restarts.cents > 0 &&
358
+ run.restarts.cents >= run.cost.estimatedCents * 0.1
359
+ );
360
+ }
361
+
362
+ function failedTools(run: RunListItem): RunTool[] {
363
+ return run.tools.filter((tool) => tool.failed > 0);
364
+ }
365
+
366
+ function mainRestartCause(run: RunListItem): RestartCause {
367
+ const { byCause } = run.restarts;
368
+ return byCause["tool-lookup"].cents >= byCause["prefix-changed"].cents
369
+ ? "tool-lookup"
370
+ : "prefix-changed";
371
+ }
372
+
373
+ function restartReason(t: T, cause: RestartCause): string {
374
+ return cause === "tool-lookup"
375
+ ? t("agentChat.observability.insights.reasonToolLookup")
376
+ : t("agentChat.observability.insights.reasonPrefixChanged");
377
+ }
378
+
379
+ function restartFix(t: T, cause: RestartCause): string {
380
+ return cause === "tool-lookup"
381
+ ? t("agentChat.observability.insights.fixToolLookup")
382
+ : t("agentChat.observability.insights.fixPrefixChanged");
383
+ }
384
+
385
+ type InsightKind = "problem" | "saving" | "info";
386
+
387
+ interface Insight {
388
+ key: string;
389
+ kind: InsightKind;
390
+ title: string;
391
+ body: string;
392
+ fix?: string;
393
+ runIds: string[];
394
+ }
395
+
396
+ function buildInsights(t: T, runs: RunListItem[]): Insight[] {
397
+ const total = runs.reduce((sum, run) => sum + run.cost.estimatedCents, 0);
398
+ const insights: Insight[] = [];
399
+
400
+ const errored = runs.filter((run) => run.status === "error");
401
+ if (errored.length > 0) {
402
+ insights.push({
403
+ key: "errored",
404
+ kind: "problem",
405
+ title: t("agentChat.observability.insights.erroredTitle", {
406
+ count: errored.length,
407
+ }),
408
+ body: t("agentChat.observability.insights.erroredBody"),
409
+ fix: t("agentChat.observability.insights.erroredFix"),
410
+ runIds: errored.map((run) => run.runId),
411
+ });
412
+ }
413
+
414
+ const toolFailures = new Map<
415
+ string,
416
+ { error: string | null; runIds: string[] }
417
+ >();
418
+ for (const run of runs) {
419
+ for (const tool of failedTools(run)) {
420
+ const entry = toolFailures.get(tool.name) ?? {
421
+ error: tool.error,
422
+ runIds: [],
423
+ };
424
+ entry.runIds.push(run.runId);
425
+ toolFailures.set(tool.name, entry);
426
+ }
427
+ }
428
+ const [worstTool] = [...toolFailures].sort(
429
+ (a, b) => b[1].runIds.length - a[1].runIds.length,
430
+ );
431
+ if (worstTool) {
432
+ const [name, { error, runIds }] = worstTool;
433
+ const recovered = runs.filter(
434
+ (run) => runIds.includes(run.runId) && run.status === "success",
435
+ ).length;
436
+ const said = error
437
+ ? t("agentChat.observability.insights.toolFailedSaid", {
438
+ error: error.length > 180 ? `${error.slice(0, 179)}…` : error,
439
+ })
440
+ : t("agentChat.observability.insights.toolFailedGeneric");
441
+ const recovery =
442
+ recovered === 0
443
+ ? ""
444
+ : recovered === runIds.length
445
+ ? t("agentChat.observability.insights.toolRecoveredAll")
446
+ : t("agentChat.observability.insights.toolRecoveredSome", {
447
+ count: recovered,
448
+ });
449
+ insights.push({
450
+ key: `tool-${name}`,
451
+ kind: "problem",
452
+ title: t("agentChat.observability.insights.toolFailedTitle", {
453
+ tool: toolNoun(name),
454
+ count: runIds.length,
455
+ }),
456
+ body: recovery ? `${said} ${recovery}` : said,
457
+ runIds,
458
+ });
459
+ }
460
+
461
+ for (const cause of ["tool-lookup", "prefix-changed"] as const) {
462
+ const affected = runs.filter(
463
+ (run) => run.restarts.byCause[cause].cents > 0,
464
+ );
465
+ const cents = affected.reduce(
466
+ (sum, run) => sum + run.restarts.byCause[cause].cents,
467
+ 0,
468
+ );
469
+ if (cents >= Math.max(5, total * 0.1)) {
470
+ insights.push({
471
+ key: `restart-${cause}`,
472
+ kind: "saving",
473
+ title: t("agentChat.observability.insights.restartTitle", {
474
+ amount: formatUsd(cents),
475
+ percent: percent(cents, total),
476
+ }),
477
+ body: t("agentChat.observability.insights.restartBody", {
478
+ count: affected.length,
479
+ total: runs.length,
480
+ reason: restartReason(t, cause),
481
+ }),
482
+ fix: restartFix(t, cause),
483
+ runIds: affected.map((run) => run.runId),
484
+ });
485
+ }
486
+ }
487
+
488
+ const spend = runs.reduce((sum, run) => sum + run.cost.totalCents, 0);
489
+ const priciest = [...runs].sort(
490
+ (a, b) => b.cost.totalCents - a.cost.totalCents,
491
+ )[0];
492
+ if (
493
+ runs.length >= 3 &&
494
+ priciest &&
495
+ spend >= 50 &&
496
+ priciest.cost.totalCents > spend * 0.4
497
+ ) {
498
+ insights.push({
499
+ key: "priciest",
500
+ kind: "info",
501
+ title: t("agentChat.observability.insights.priciestTitle", {
502
+ percent: percent(priciest.cost.totalCents, spend),
503
+ }),
504
+ body: t("agentChat.observability.insights.priciestBody", {
505
+ prompt:
506
+ priciest.prompt ??
507
+ t("agentChat.observability.insights.untitledPrompt"),
508
+ amount: formatUsd(priciest.cost.totalCents),
509
+ count: priciest.modelCalls,
510
+ }),
511
+ runIds: [priciest.runId],
512
+ });
513
+ }
514
+
515
+ return insights.slice(0, 3);
516
+ }
517
+
518
+ function InsightCard({
519
+ insight,
520
+ onShow,
521
+ }: {
522
+ insight: Insight;
523
+ onShow: () => void;
524
+ }) {
525
+ const t = useT();
526
+ const style: Record<
527
+ InsightKind,
528
+ { icon: ReactNode; label: string; className: string }
529
+ > = {
530
+ problem: {
531
+ icon: (
532
+ <IconAlertTriangle className="size-4 text-amber-600 dark:text-amber-400" />
533
+ ),
534
+ label: t("agentChat.observability.insights.kindProblem"),
535
+ className: "border-amber-500/30",
536
+ },
537
+ saving: {
538
+ icon: (
539
+ <IconPigMoney className="size-4 text-emerald-600 dark:text-emerald-400" />
540
+ ),
541
+ label: t("agentChat.observability.insights.kindSaving"),
542
+ className: "border-emerald-500/30",
543
+ },
544
+ info: {
545
+ icon: <IconInfoCircle className="size-4 text-muted-foreground" />,
546
+ label: t("agentChat.observability.insights.kindInfo"),
547
+ className: "border-border/70",
548
+ },
549
+ };
550
+ const kind = style[insight.kind];
551
+ return (
552
+ <div className={`rounded-lg border bg-card p-4 ${kind.className}`}>
553
+ <div className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
554
+ {kind.icon}
555
+ {kind.label}
556
+ </div>
557
+ <h4 className="mt-1.5 text-sm font-semibold leading-5 text-foreground">
558
+ {insight.title}
559
+ </h4>
560
+ <p className="mt-1 text-xs leading-5 text-muted-foreground">
561
+ {insight.body}
562
+ </p>
563
+ {insight.fix ? (
564
+ <p className="mt-1.5 text-xs leading-5 text-foreground/90">
565
+ <span className="font-medium">
566
+ {t("agentChat.observability.insights.fixLabel")}{" "}
567
+ </span>
568
+ {insight.fix}
569
+ </p>
570
+ ) : null}
571
+ <Button
572
+ type="button"
573
+ variant="link"
574
+ size="sm"
575
+ className="mt-1 h-auto p-0 text-xs"
576
+ onClick={onShow}
577
+ >
578
+ {insight.runIds.length === 1
579
+ ? t("agentChat.observability.insights.openPrompt")
580
+ : t("agentChat.observability.insights.seePrompts", {
581
+ count: insight.runIds.length,
582
+ })}
583
+ </Button>
584
+ </div>
585
+ );
586
+ }
587
+
588
+ function RunRow({ run, onOpen }: { run: RunListItem; onOpen: () => void }) {
589
+ const t = useT();
590
+ const outcome = outcomeLine(t, run.tools);
591
+ const failures = failedTools(run);
592
+ return (
593
+ <button
594
+ type="button"
595
+ onClick={onOpen}
596
+ className="flex w-full items-start justify-between gap-4 px-4 py-3 text-left transition-colors hover:bg-accent/40 focus-visible:bg-accent/40 focus-visible:outline-none"
597
+ >
598
+ <div className="min-w-0">
599
+ <div className="flex min-w-0 items-center gap-2">
600
+ <span className="truncate text-sm text-foreground">
601
+ {run.prompt ?? t("agentChat.observability.insights.promptNotSaved")}
602
+ </span>
603
+ {run.feedback === "up" ? (
604
+ <IconThumbUp
605
+ aria-label={t("agentChat.observability.insights.ratedHelpful")}
606
+ className="size-3.5 shrink-0 text-muted-foreground"
607
+ />
608
+ ) : run.feedback === "down" ? (
609
+ <IconThumbDown
610
+ aria-label={t("agentChat.observability.insights.ratedUnhelpful")}
611
+ className="size-3.5 shrink-0 text-muted-foreground"
612
+ />
613
+ ) : null}
614
+ </div>
615
+ <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
616
+ {run.status === "error" ? (
617
+ <span className="flex min-w-0 items-center gap-1 text-destructive">
618
+ <IconCircleX aria-hidden className="size-3.5 shrink-0" />
619
+ <span className="truncate">
620
+ {t("agentChat.observability.insights.stoppedWithError")}
621
+ </span>
622
+ </span>
623
+ ) : run.status === "unknown" ? (
624
+ <span className="truncate">
625
+ {t("agentChat.observability.insights.detailsUnavailable")}
626
+ </span>
627
+ ) : (
628
+ <span className="truncate">
629
+ → {outcome ?? t("agentChat.observability.insights.answered")}
630
+ </span>
631
+ )}
632
+ {notableRestart(run) ? (
633
+ <span className="shrink-0 text-amber-700 dark:text-amber-400">
634
+ ·{" "}
635
+ {t("agentChat.observability.insights.startedOverShort", {
636
+ count: run.restarts.count,
637
+ })}
638
+ </span>
639
+ ) : run.recoveredErrors > 0 ? (
640
+ <span className="shrink-0">
641
+ ·{" "}
642
+ {t("agentChat.observability.insights.recoveredShort", {
643
+ count: run.recoveredErrors,
644
+ })}
645
+ </span>
646
+ ) : failures.length > 0 ? (
647
+ <span className="shrink-0 text-amber-700 dark:text-amber-400">
648
+ ·{" "}
649
+ {t("agentChat.observability.insights.toolsFailedShort", {
650
+ count: failures.length,
651
+ })}
652
+ </span>
653
+ ) : null}
654
+ </div>
655
+ </div>
656
+ <div className="shrink-0 text-right">
657
+ <div className="text-sm font-medium tabular-nums text-foreground">
658
+ {formatUsd(run.cost.totalCents)}
659
+ </div>
660
+ <div className="text-xs tabular-nums text-muted-foreground">
661
+ {run.durationMs
662
+ ? formatDuration(run.durationMs)
663
+ : formatTime(run.createdAt)}
664
+ </div>
665
+ </div>
666
+ </button>
667
+ );
668
+ }
669
+
670
+ function CostDetails({ run }: { run: RunDetail }) {
671
+ const t = useT();
672
+ const parts = [
673
+ {
674
+ label: t("agentChat.observability.insights.partReused"),
675
+ cents: run.cost.cacheReadCents,
676
+ tokens: run.tokens.cacheReadTokens,
677
+ color: "var(--usage-cache-read)",
678
+ },
679
+ {
680
+ label: t("agentChat.observability.insights.partSaved"),
681
+ cents: run.cost.cacheWriteCents,
682
+ tokens: run.tokens.cacheWriteTokens,
683
+ color: "var(--usage-cache-write)",
684
+ },
685
+ {
686
+ label: t("agentChat.observability.insights.partNew"),
687
+ cents: run.cost.uncachedInputCents,
688
+ tokens: Math.max(
689
+ 0,
690
+ run.tokens.inputTokens -
691
+ run.tokens.cacheReadTokens -
692
+ run.tokens.cacheWriteTokens,
693
+ ),
694
+ color: "var(--usage-fresh-input)",
695
+ },
696
+ {
697
+ label: t("agentChat.observability.insights.partOutput"),
698
+ cents: run.cost.outputCents,
699
+ tokens: run.tokens.outputTokens,
700
+ color: "var(--usage-output)",
701
+ },
702
+ ];
703
+ const checks = run.scores.filter((score) => score.source === "heuristic");
704
+ const judged = run.scores.filter((score) => score.source === "judge");
705
+ const estimated = run.cost.estimatedCents;
706
+ return (
707
+ <div className="space-y-4 text-xs">
708
+ <div>
709
+ {formatUsd(run.cost.noCacheCents) !== formatUsd(estimated) ? (
710
+ <p className="text-muted-foreground">
711
+ {t("agentChat.observability.insights.noCacheCompare", {
712
+ noCache: formatUsd(run.cost.noCacheCents),
713
+ estimated: formatUsd(estimated),
714
+ })}
715
+ </p>
716
+ ) : null}
717
+ <div className="mt-2 flex h-2 w-full gap-[2px] overflow-hidden rounded-full">
718
+ {parts.map((part) =>
719
+ part.cents > 0 && estimated > 0 ? (
720
+ <div
721
+ key={part.label}
722
+ style={{
723
+ width: `${(part.cents / estimated) * 100}%`,
724
+ minWidth: 3,
725
+ background: part.color,
726
+ }}
727
+ />
728
+ ) : null,
729
+ )}
730
+ </div>
731
+ <div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1.5">
732
+ {parts.map((part) => (
733
+ <div key={part.label} className="flex items-center gap-2">
734
+ <span
735
+ aria-hidden
736
+ className="size-2 shrink-0 rounded-[2px]"
737
+ style={{ background: part.color }}
738
+ />
739
+ <span className="text-muted-foreground">{part.label}</span>
740
+ <span className="ml-auto tabular-nums text-foreground">
741
+ {formatUsd(part.cents)}
742
+ </span>
743
+ <span className="w-12 text-right tabular-nums text-muted-foreground">
744
+ {compactTokens(part.tokens)}
745
+ </span>
746
+ </div>
747
+ ))}
748
+ </div>
749
+ </div>
750
+ <div>
751
+ <div className="font-medium text-foreground">
752
+ {t("agentChat.observability.insights.checksHeading")}
753
+ </div>
754
+ {judged.length === 0 && checks.length === 0 ? (
755
+ <p className="mt-1 text-muted-foreground">
756
+ {t("agentChat.observability.insights.checksNone")}
757
+ </p>
758
+ ) : (
759
+ <ul className="mt-1 space-y-0.5 text-muted-foreground">
760
+ {[...judged, ...checks].map((score) => (
761
+ <li key={score.criteria} className="flex justify-between gap-4">
762
+ <span>
763
+ {score.criteria.replaceAll("_", " ")}
764
+ {score.source === "judge"
765
+ ? ` ${t("agentChat.observability.insights.checksGraded")}`
766
+ : ""}
767
+ </span>
768
+ <span className="tabular-nums">
769
+ {Math.round(score.score * 100)}%
770
+ </span>
771
+ </li>
772
+ ))}
773
+ </ul>
774
+ )}
775
+ <p className="mt-1 text-[11px] text-muted-foreground">
776
+ {t("agentChat.observability.insights.checksNote")}
777
+ </p>
778
+ </div>
779
+ </div>
780
+ );
781
+ }
782
+
783
+ function StepList({ turns }: { turns: RunTurn[] }) {
784
+ const t = useT();
785
+ const [openTurn, setOpenTurn] = useState<number | null>(null);
786
+ return (
787
+ <ol className="mt-2 divide-y divide-border/50 rounded-lg border border-border/70">
788
+ {turns.map((turn, index) => {
789
+ const open = openTurn === turn.index;
790
+ const failed = turn.toolCalls.some((call) => call.status === "error");
791
+ return (
792
+ <li key={turn.index}>
793
+ <button
794
+ type="button"
795
+ className="flex w-full items-center gap-3 px-3 py-2 text-left text-xs hover:bg-accent/40"
796
+ onClick={() => setOpenTurn(open ? null : turn.index)}
797
+ aria-expanded={open}
798
+ >
799
+ <span className="w-5 shrink-0 text-right tabular-nums text-muted-foreground">
800
+ {turn.index}
801
+ </span>
802
+ <span className="min-w-0 flex-1 truncate text-foreground">
803
+ {turnLabel(t, turn, index === turns.length - 1)}
804
+ </span>
805
+ {turn.restart ? (
806
+ <span className="shrink-0 text-amber-700 dark:text-amber-400">
807
+ {t("agentChat.observability.insights.startedOverTag")}
808
+ </span>
809
+ ) : null}
810
+ {failed ? (
811
+ <span className="shrink-0 text-destructive">
812
+ {t("agentChat.observability.insights.toolFailedTag")}
813
+ </span>
814
+ ) : null}
815
+ <span className="w-12 shrink-0 text-right tabular-nums text-muted-foreground">
816
+ {formatDuration(turn.durationMs)}
817
+ </span>
818
+ <span className="w-10 shrink-0 text-right tabular-nums text-foreground">
819
+ {formatUsd(turn.cost.estimatedCents)}
820
+ </span>
821
+ </button>
822
+ {open ? (
823
+ <div className="space-y-1.5 bg-muted/30 px-3 py-2.5 pl-11 text-xs text-muted-foreground">
824
+ <p>
825
+ {t("agentChat.observability.insights.turnContext", {
826
+ tokens: compactTokens(turn.tokens.inputTokens),
827
+ percent: percent(
828
+ turn.tokens.cacheReadTokens,
829
+ turn.tokens.inputTokens,
830
+ ),
831
+ })}{" "}
832
+ {turn.cacheExpired
833
+ ? `${t("agentChat.observability.insights.turnExpired")} `
834
+ : ""}
835
+ {t("agentChat.observability.insights.turnOutput", {
836
+ tokens: compactTokens(turn.tokens.outputTokens),
837
+ })}
838
+ </p>
839
+ {turn.restart ? (
840
+ <p className="text-amber-700 dark:text-amber-400">
841
+ {t("agentChat.observability.insights.turnRestart", {
842
+ reason: restartReason(t, turn.restart.cause),
843
+ amount: formatUsd(turn.restart.cents),
844
+ })}
845
+ </p>
846
+ ) : null}
847
+ {turn.toolCalls.map((call, callIndex) => (
848
+ <p
849
+ key={callIndex}
850
+ className={
851
+ call.status === "error" ? "text-destructive" : ""
852
+ }
853
+ >
854
+ {humanizeTool(t, call.name)} ·{" "}
855
+ {formatDuration(call.durationMs)}
856
+ {call.status === "error" && call.errorMessage
857
+ ? ` · ${call.errorMessage}`
858
+ : ""}
859
+ </p>
860
+ ))}
861
+ </div>
862
+ ) : null}
863
+ </li>
864
+ );
865
+ })}
866
+ </ol>
867
+ );
868
+ }
869
+
870
+ function Expandable({
871
+ text,
872
+ lines,
873
+ limit,
874
+ }: {
875
+ text: string;
876
+ lines: "line-clamp-4" | "line-clamp-6";
877
+ limit: number;
878
+ }) {
879
+ const t = useT();
880
+ const [full, setFull] = useState(false);
881
+ return (
882
+ <>
883
+ <p className={`whitespace-pre-wrap ${full ? "" : lines}`}>{text}</p>
884
+ {text.length > limit ? (
885
+ <button
886
+ type="button"
887
+ className="mt-1 text-xs text-muted-foreground hover:text-foreground"
888
+ onClick={() => setFull((value) => !value)}
889
+ >
890
+ {full
891
+ ? t("agentChat.observability.insights.showLess")
892
+ : t("agentChat.observability.insights.showAll")}
893
+ </button>
894
+ ) : null}
895
+ </>
896
+ );
897
+ }
898
+
899
+ function handledText(
900
+ t: T,
901
+ parallel: { calls: number; savedMs: number },
902
+ recovered: number,
903
+ ): string[] {
904
+ const lines: string[] = [];
905
+ if (parallel.savedMs >= 1000) {
906
+ lines.push(
907
+ t("agentChat.observability.insights.handledParallel", {
908
+ count: parallel.calls,
909
+ duration: formatDuration(parallel.savedMs),
910
+ }),
911
+ );
912
+ }
913
+ if (recovered > 0) {
914
+ lines.push(
915
+ t("agentChat.observability.insights.handledRecovered", {
916
+ count: recovered,
917
+ }),
918
+ );
919
+ }
920
+ return lines;
921
+ }
922
+
923
+ function HandledNote({ run }: { run: RunListItem }) {
924
+ const t = useT();
925
+ const lines = handledText(t, run.parallel, run.recoveredErrors);
926
+ if (lines.length === 0) return null;
927
+ return (
928
+ <div className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3.5 py-3 text-xs leading-5">
929
+ <p className="font-medium text-foreground">
930
+ {t("agentChat.observability.insights.handledHeading")}
931
+ </p>
932
+ <ul className="mt-0.5 space-y-0.5 text-muted-foreground">
933
+ {lines.map((line) => (
934
+ <li key={line}>{line}</li>
935
+ ))}
936
+ </ul>
937
+ </div>
938
+ );
939
+ }
940
+
941
+ function RunPanel({
942
+ run,
943
+ renderRawTrace,
944
+ onClose,
945
+ }: {
946
+ run: RunListItem;
947
+ renderRawTrace: (runId: string) => ReactNode;
948
+ onClose: () => void;
949
+ }) {
950
+ const t = useT();
951
+ const query = useActionQuery<RunDetail | null>("get-usage-run", {
952
+ runId: run.runId,
953
+ scope: "me",
954
+ });
955
+ const [showSteps, setShowSteps] = useState(false);
956
+ const [showDetails, setShowDetails] = useState(false);
957
+ const [showRawTrace, setShowRawTrace] = useState(false);
958
+ const detail = query.data;
959
+ const failures = failedTools(run);
960
+ const outcome = outcomeLine(t, run.tools);
961
+ const cause = mainRestartCause(run);
962
+ const headline =
963
+ run.status === "error"
964
+ ? t("agentChat.observability.insights.stoppedWithError")
965
+ : run.status === "unknown"
966
+ ? t("agentChat.observability.insights.detailsUnavailable")
967
+ : t("agentChat.observability.insights.finished");
968
+
969
+ return (
970
+ <div className="flex h-full flex-col">
971
+ <div className="border-b border-border/70 px-5 pb-4 pt-5">
972
+ <div className="flex items-center gap-2 pr-20">
973
+ {run.status === "error" ? (
974
+ <IconCircleX className="size-4 shrink-0 text-destructive" />
975
+ ) : run.status === "unknown" ? (
976
+ <IconInfoCircle className="size-4 shrink-0 text-muted-foreground" />
977
+ ) : (
978
+ <IconCircleCheck className="size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
979
+ )}
980
+ <SheetTitle className="text-sm font-medium">
981
+ {headline}
982
+ {run.durationMs
983
+ ? ` ${t("agentChat.observability.insights.headerDuration", {
984
+ duration: formatDuration(run.durationMs),
985
+ })}`
986
+ : ""}{" "}
987
+ ·{" "}
988
+ {t("agentChat.observability.insights.stepsCount", {
989
+ count: run.modelCalls,
990
+ })}{" "}
991
+ · {formatUsd(run.cost.totalCents)}
992
+ </SheetTitle>
993
+ </div>
994
+ <SheetDescription className="mt-1 text-xs">
995
+ {formatTime(run.createdAt)} · {run.model} ·{" "}
996
+ {run.feedback === "up"
997
+ ? t("agentChat.observability.insights.ratedHelpful")
998
+ : run.feedback === "down"
999
+ ? t("agentChat.observability.insights.ratedUnhelpful")
1000
+ : t("agentChat.observability.insights.notRated")}
1001
+ </SheetDescription>
1002
+ {run.threadId ? (
1003
+ <button
1004
+ type="button"
1005
+ onClick={() => {
1006
+ requestAgentChatThreadOpen({ threadId: run.threadId! });
1007
+ onClose();
1008
+ }}
1009
+ className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
1010
+ >
1011
+ <IconMessages className="size-3.5" />
1012
+ {t("observability.openFullConversation")}
1013
+ </button>
1014
+ ) : null}
1015
+ </div>
1016
+
1017
+ <div className="flex-1 space-y-5 overflow-y-auto px-5 py-5">
1018
+ <div className="space-y-3">
1019
+ <div className="ml-auto max-w-[85%] rounded-2xl rounded-br-md bg-muted px-3.5 py-2.5 text-sm leading-6">
1020
+ <Expandable
1021
+ text={
1022
+ run.prompt ??
1023
+ t("agentChat.observability.insights.promptNotSaved")
1024
+ }
1025
+ lines="line-clamp-4"
1026
+ limit={240}
1027
+ />
1028
+ </div>
1029
+ <div className="max-w-[92%] space-y-2">
1030
+ {outcome ? (
1031
+ <p className="text-xs text-muted-foreground">
1032
+ <span className="font-medium text-foreground">
1033
+ {t("agentChat.observability.insights.whatItDid")}{" "}
1034
+ </span>
1035
+ {outcome}
1036
+ </p>
1037
+ ) : null}
1038
+ {query.isLoading ? (
1039
+ <Skeleton className="h-16 w-full" />
1040
+ ) : detail?.reply ? (
1041
+ <div className="rounded-2xl rounded-bl-md border border-border/70 px-3.5 py-2.5 text-sm leading-6">
1042
+ <Expandable
1043
+ text={detail.reply}
1044
+ lines="line-clamp-6"
1045
+ limit={400}
1046
+ />
1047
+ </div>
1048
+ ) : (
1049
+ <p className="text-xs text-muted-foreground">
1050
+ {t("agentChat.observability.insights.replyNotSaved")}
1051
+ </p>
1052
+ )}
1053
+ {run.tools.length > 0 ? (
1054
+ <div className="flex flex-wrap gap-1.5 pt-1">
1055
+ {run.tools.slice(0, 8).map((tool) => (
1056
+ <button
1057
+ key={tool.name}
1058
+ type="button"
1059
+ onClick={() => setShowSteps(true)}
1060
+ className={`rounded-full border px-2 py-0.5 text-[11px] ${tool.failed ? "border-destructive/40 text-destructive" : "border-border/70 text-muted-foreground hover:text-foreground"}`}
1061
+ >
1062
+ {tool.calls > 1
1063
+ ? t("agentChat.observability.insights.timesCount", {
1064
+ label: humanizeTool(t, tool.name),
1065
+ count: tool.calls,
1066
+ })
1067
+ : humanizeTool(t, tool.name)}
1068
+ {tool.failed
1069
+ ? ` · ${t("agentChat.observability.insights.failedSuffix")}`
1070
+ : ""}
1071
+ </button>
1072
+ ))}
1073
+ {run.tools.length > 8 ? (
1074
+ <span className="px-1 py-0.5 text-[11px] text-muted-foreground">
1075
+ {t("agentChat.observability.insights.moreTools", {
1076
+ count: run.tools.length - 8,
1077
+ })}
1078
+ </span>
1079
+ ) : null}
1080
+ </div>
1081
+ ) : null}
1082
+ </div>
1083
+ </div>
1084
+
1085
+ <HandledNote run={run} />
1086
+
1087
+ {notableRestart(run) || failures.length > 0 ? (
1088
+ <div className="space-y-2">
1089
+ {notableRestart(run) ? (
1090
+ <div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-3.5 py-3 text-xs leading-5">
1091
+ <p className="text-foreground">
1092
+ {t("agentChat.observability.insights.startedOverNote", {
1093
+ count: run.restarts.count,
1094
+ reason: restartReason(t, cause),
1095
+ amount: formatUsd(run.restarts.cents),
1096
+ total: formatUsd(run.cost.estimatedCents),
1097
+ })}
1098
+ </p>
1099
+ <p className="mt-1 text-muted-foreground">
1100
+ <span className="font-medium text-foreground/90">
1101
+ {t("agentChat.observability.insights.fixLabel")}{" "}
1102
+ </span>
1103
+ {restartFix(t, cause)}
1104
+ </p>
1105
+ </div>
1106
+ ) : null}
1107
+ {failures.slice(0, 1).map((tool) => (
1108
+ <div
1109
+ key={tool.name}
1110
+ className="rounded-lg border border-destructive/30 bg-destructive/5 px-3.5 py-3 text-xs leading-5"
1111
+ >
1112
+ <p className="text-foreground">
1113
+ {run.status === "success"
1114
+ ? t(
1115
+ "agentChat.observability.insights.toolFailedRecoveredNote",
1116
+ {
1117
+ tool: toolNoun(tool.name),
1118
+ count: tool.failed,
1119
+ },
1120
+ )
1121
+ : t("agentChat.observability.insights.toolFailedNote", {
1122
+ tool: toolNoun(tool.name),
1123
+ count: tool.failed,
1124
+ })}
1125
+ </p>
1126
+ {tool.error ? (
1127
+ <p className="mt-1 text-muted-foreground">{tool.error}</p>
1128
+ ) : null}
1129
+ </div>
1130
+ ))}
1131
+ </div>
1132
+ ) : null}
1133
+
1134
+ {run.status !== "unknown" ? (
1135
+ <section>
1136
+ <button
1137
+ type="button"
1138
+ className="flex items-center gap-1.5 text-xs font-medium text-foreground"
1139
+ onClick={() => setShowSteps((value) => !value)}
1140
+ aria-expanded={showSteps}
1141
+ >
1142
+ <IconChevronDown
1143
+ className={`size-3.5 transition-transform ${showSteps ? "rotate-180" : ""}`}
1144
+ />
1145
+ {showSteps
1146
+ ? t("agentChat.observability.insights.hideSteps")
1147
+ : t("agentChat.observability.insights.showSteps", {
1148
+ count: run.modelCalls,
1149
+ })}
1150
+ </button>
1151
+ {showSteps ? (
1152
+ detail ? (
1153
+ <StepList turns={detail.turns} />
1154
+ ) : (
1155
+ <Skeleton className="mt-2 h-24 w-full" />
1156
+ )
1157
+ ) : null}
1158
+ </section>
1159
+ ) : null}
1160
+
1161
+ <section>
1162
+ <button
1163
+ type="button"
1164
+ className="flex items-center gap-1.5 text-xs font-medium text-foreground"
1165
+ onClick={() => setShowDetails((value) => !value)}
1166
+ aria-expanded={showDetails}
1167
+ >
1168
+ <IconChevronDown
1169
+ className={`size-3.5 transition-transform ${showDetails ? "rotate-180" : ""}`}
1170
+ />
1171
+ {t("agentChat.observability.insights.costDetails")}
1172
+ </button>
1173
+ {showDetails && detail ? (
1174
+ <div className="mt-3">
1175
+ <CostDetails run={detail} />
1176
+ </div>
1177
+ ) : null}
1178
+ </section>
1179
+
1180
+ <section>
1181
+ <button
1182
+ type="button"
1183
+ className="flex items-center gap-1.5 text-xs font-medium text-foreground"
1184
+ onClick={() => setShowRawTrace((value) => !value)}
1185
+ aria-expanded={showRawTrace}
1186
+ >
1187
+ <IconChevronDown
1188
+ className={`size-3.5 transition-transform ${showRawTrace ? "rotate-180" : ""}`}
1189
+ />
1190
+ {t("agentChat.observability.insights.rawTrace")}
1191
+ </button>
1192
+ {showRawTrace ? (
1193
+ <div className="mt-3">{renderRawTrace(run.runId)}</div>
1194
+ ) : null}
1195
+ </section>
1196
+ </div>
1197
+ </div>
1198
+ );
1199
+ }
1200
+
1201
+ function median(values: number[]): number | null {
1202
+ if (values.length === 0) return null;
1203
+ const sorted = [...values].sort((a, b) => a - b);
1204
+ return sorted[Math.floor(sorted.length / 2)]!;
1205
+ }
1206
+
1207
+ function Stat({
1208
+ label,
1209
+ value,
1210
+ detail,
1211
+ }: {
1212
+ label: string;
1213
+ value: string;
1214
+ detail?: string;
1215
+ }) {
1216
+ return (
1217
+ <div className="min-w-0">
1218
+ <div className="text-xs text-muted-foreground">{label}</div>
1219
+ <div className="mt-0.5 text-lg font-semibold tabular-nums text-foreground">
1220
+ {value}
1221
+ </div>
1222
+ {detail ? (
1223
+ <div className="text-xs text-muted-foreground">{detail}</div>
1224
+ ) : null}
1225
+ </div>
1226
+ );
1227
+ }
1228
+
1229
+ function changeText(
1230
+ t: T,
1231
+ current: number,
1232
+ previous: number,
1233
+ ): string | undefined {
1234
+ if (previous <= 0) return undefined;
1235
+ const change = Math.round(((current - previous) / previous) * 100);
1236
+ if (change === 0) return t("agentChat.observability.insights.changeSame");
1237
+ return change > 0
1238
+ ? t("agentChat.observability.insights.changeUp", { percent: change })
1239
+ : t("agentChat.observability.insights.changeDown", { percent: -change });
1240
+ }
1241
+
1242
+ /** Which prompts the Conversations tab should show, and which one to open. */
1243
+ export interface RunFilter {
1244
+ runIds: string[];
1245
+ openRunId?: string;
1246
+ }
1247
+
1248
+ function LoadFailed({
1249
+ retrying,
1250
+ onRetry,
1251
+ }: {
1252
+ retrying: boolean;
1253
+ onRetry: () => void;
1254
+ }) {
1255
+ const t = useT();
1256
+ return (
1257
+ <div className="flex items-center gap-3">
1258
+ <p role="alert" className="text-sm text-muted-foreground">
1259
+ {t("agentChat.common.chunkLoadFailed")}
1260
+ </p>
1261
+ <Button size="sm" variant="outline" disabled={retrying} onClick={onRetry}>
1262
+ {t("agentChat.common.retry")}
1263
+ </Button>
1264
+ </div>
1265
+ );
1266
+ }
1267
+
1268
+ function useRunInsights(days: number) {
1269
+ return useActionQuery<UsageInsightsData>("get-usage-insights", {
1270
+ sinceDays: days,
1271
+ scope: "me",
1272
+ });
1273
+ }
1274
+
1275
+ export function RunInsightsOverview({
1276
+ days,
1277
+ extraStats = [],
1278
+ onShowRuns,
1279
+ }: {
1280
+ days: number;
1281
+ extraStats?: Array<{ label: string; value: string; detail?: string }>;
1282
+ onShowRuns: (filter: RunFilter) => void;
1283
+ }) {
1284
+ const t = useT();
1285
+ const { data, isError, isFetching, refetch } = useRunInsights(days);
1286
+ const runs = useMemo(() => data?.runs ?? [], [data]);
1287
+ const insights = useMemo(() => buildInsights(t, runs), [t, runs]);
1288
+
1289
+ if (isError) {
1290
+ return <LoadFailed retrying={isFetching} onRetry={() => void refetch()} />;
1291
+ }
1292
+ if (!data) {
1293
+ return (
1294
+ <div className="space-y-3">
1295
+ <Skeleton className="h-36 w-full" />
1296
+ <Skeleton className="h-32 w-full" />
1297
+ </div>
1298
+ );
1299
+ }
1300
+
1301
+ const { current, previous } = data;
1302
+ const avgCents = current.runs ? current.cost.totalCents / current.runs : 0;
1303
+ const previousAvg = previous.runs
1304
+ ? previous.cost.totalCents / previous.runs
1305
+ : 0;
1306
+ const known = runs.filter((run) => run.status !== "unknown");
1307
+ const completed = known.filter((run) => run.status === "success").length;
1308
+ const recovered = runs.reduce((sum, run) => sum + run.recoveredErrors, 0);
1309
+ const handled = handledText(
1310
+ t,
1311
+ {
1312
+ calls: runs.reduce((sum, run) => sum + run.parallel.calls, 0),
1313
+ savedMs: runs.reduce((sum, run) => sum + run.parallel.savedMs, 0),
1314
+ },
1315
+ recovered,
1316
+ );
1317
+ const typical = median(
1318
+ runs.map((run) => run.durationMs ?? 0).filter(Boolean),
1319
+ );
1320
+ const problems = insights.filter(
1321
+ (insight) => insight.kind === "problem",
1322
+ ).length;
1323
+
1324
+ return (
1325
+ <div className="space-y-4">
1326
+ <section className="rounded-lg border border-border/70 bg-card p-5">
1327
+ <div className="flex items-center gap-2 text-sm font-semibold text-foreground">
1328
+ {insights.length === 0 ? (
1329
+ <>
1330
+ <IconCircleCheck className="size-4 text-emerald-600 dark:text-emerald-400" />
1331
+ {t("agentChat.observability.insights.verdictSmooth")}
1332
+ </>
1333
+ ) : (
1334
+ <>
1335
+ <IconAlertTriangle className="size-4 text-amber-600 dark:text-amber-400" />
1336
+ {t("agentChat.observability.insights.verdictLook", {
1337
+ count: insights.length,
1338
+ })}
1339
+ {problems
1340
+ ? ` · ${t("agentChat.observability.insights.verdictProblems", {
1341
+ count: problems,
1342
+ })}`
1343
+ : ""}
1344
+ </>
1345
+ )}
1346
+ </div>
1347
+ <p className="mt-1 text-sm text-muted-foreground">
1348
+ {t("agentChat.observability.insights.spentSummary", {
1349
+ amount: formatUsd(current.cost.totalCents),
1350
+ count: current.runs,
1351
+ days,
1352
+ })}
1353
+ </p>
1354
+ {handled.length > 0 ? (
1355
+ <p className="mt-2 flex items-start gap-1.5 text-xs leading-5 text-muted-foreground">
1356
+ <IconCircleCheck className="mt-0.5 size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
1357
+ <span>
1358
+ <span className="font-medium text-foreground">
1359
+ {t("agentChat.observability.insights.handledLabel")}{" "}
1360
+ </span>
1361
+ {handled.join(" ")}
1362
+ </span>
1363
+ </p>
1364
+ ) : null}
1365
+ {current.runs > runs.length ? (
1366
+ <p className="mt-2 text-xs text-muted-foreground">
1367
+ {t("agentChat.observability.insights.sampleNote", {
1368
+ shown: runs.length,
1369
+ total: current.runs,
1370
+ })}
1371
+ </p>
1372
+ ) : null}
1373
+ <div className="mt-4 grid grid-cols-2 gap-4 border-t border-border/60 pt-4 sm:grid-cols-3 lg:grid-cols-5">
1374
+ <Stat
1375
+ label={t("agentChat.observability.insights.avgPerPrompt")}
1376
+ value={formatUsd(avgCents)}
1377
+ detail={changeText(t, avgCents, previousAvg)}
1378
+ />
1379
+ <Stat
1380
+ label={t("agentChat.observability.insights.completed")}
1381
+ value={known.length ? `${percent(completed, known.length)}%` : "—"}
1382
+ detail={
1383
+ known.length
1384
+ ? recovered
1385
+ ? t("agentChat.observability.insights.completedRecovered", {
1386
+ done: completed,
1387
+ total: known.length,
1388
+ count: recovered,
1389
+ })
1390
+ : t("agentChat.observability.insights.completedDetail", {
1391
+ done: completed,
1392
+ total: known.length,
1393
+ })
1394
+ : undefined
1395
+ }
1396
+ />
1397
+ <Stat
1398
+ label={t("agentChat.observability.insights.typicalTime")}
1399
+ value={typical ? formatDuration(typical) : "—"}
1400
+ detail={t("agentChat.observability.insights.median")}
1401
+ />
1402
+ {extraStats.map((stat) => (
1403
+ <Stat key={stat.label} {...stat} />
1404
+ ))}
1405
+ </div>
1406
+ </section>
1407
+
1408
+ {insights.length > 0 ? (
1409
+ <div
1410
+ className={`grid gap-3 ${insights.length > 1 ? "md:grid-cols-2" : ""} ${insights.length > 2 ? "xl:grid-cols-3" : ""}`}
1411
+ >
1412
+ {insights.map((insight) => (
1413
+ <InsightCard
1414
+ key={insight.key}
1415
+ insight={insight}
1416
+ onShow={() =>
1417
+ onShowRuns({
1418
+ runIds: insight.runIds,
1419
+ openRunId:
1420
+ insight.runIds.length === 1 ? insight.runIds[0] : undefined,
1421
+ })
1422
+ }
1423
+ />
1424
+ ))}
1425
+ </div>
1426
+ ) : null}
1427
+ </div>
1428
+ );
1429
+ }
1430
+
1431
+ export function RunInsightsConversations({
1432
+ days,
1433
+ filter,
1434
+ onClearFilter,
1435
+ renderRawTrace,
1436
+ }: {
1437
+ days: number;
1438
+ filter: RunFilter | null;
1439
+ onClearFilter: () => void;
1440
+ renderRawTrace: (runId: string) => ReactNode;
1441
+ }) {
1442
+ const t = useT();
1443
+ const { data, isError, isFetching, refetch } = useRunInsights(days);
1444
+ const runs = useMemo(() => data?.runs ?? [], [data]);
1445
+ const [sort, setSort] = useState<"newest" | "cost">("newest");
1446
+ const [openRunId, setOpenRunId] = useState<string | null>(
1447
+ filter?.openRunId ?? null,
1448
+ );
1449
+
1450
+ useEffect(() => {
1451
+ if (filter?.openRunId) setOpenRunId(filter.openRunId);
1452
+ }, [filter]);
1453
+
1454
+ const visible = useMemo(() => {
1455
+ const filtered = filter
1456
+ ? runs.filter((run) => filter.runIds.includes(run.runId))
1457
+ : runs;
1458
+ return sort === "cost"
1459
+ ? [...filtered].sort((a, b) => b.cost.totalCents - a.cost.totalCents)
1460
+ : filtered;
1461
+ }, [runs, filter, sort]);
1462
+ const openIndex = visible.findIndex((run) => run.runId === openRunId);
1463
+ const openRun = openIndex >= 0 ? visible[openIndex]! : null;
1464
+ const openAt = (index: number) => {
1465
+ const run = visible[index];
1466
+ if (run) setOpenRunId(run.runId);
1467
+ };
1468
+
1469
+ useEffect(() => {
1470
+ if (!openRun) return;
1471
+ const onKey = (event: KeyboardEvent) => {
1472
+ const target = event.target;
1473
+ if (
1474
+ target instanceof Element &&
1475
+ target.closest("input, textarea, [contenteditable=true]")
1476
+ ) {
1477
+ return;
1478
+ }
1479
+ if (event.key === "j") openAt(openIndex + 1);
1480
+ else if (event.key === "k") openAt(openIndex - 1);
1481
+ };
1482
+ window.addEventListener("keydown", onKey);
1483
+ return () => window.removeEventListener("keydown", onKey);
1484
+ });
1485
+
1486
+ if (isError) {
1487
+ return <LoadFailed retrying={isFetching} onRetry={() => void refetch()} />;
1488
+ }
1489
+ if (!data) {
1490
+ return (
1491
+ <div className="space-y-2">
1492
+ <Skeleton className="h-10 w-full" />
1493
+ <Skeleton className="h-10 w-full" />
1494
+ <Skeleton className="h-10 w-full" />
1495
+ </div>
1496
+ );
1497
+ }
1498
+
1499
+ return (
1500
+ <div className="run-insights">
1501
+ <style>{PALETTE_CSS}</style>
1502
+ <section className="rounded-lg border border-border/70 bg-card">
1503
+ <div className="flex flex-wrap items-center justify-between gap-2 px-4 py-3">
1504
+ <div className="flex items-center gap-2">
1505
+ <h3 className="text-sm font-semibold text-foreground">
1506
+ {t("agentChat.observability.insights.promptsHeading")}
1507
+ </h3>
1508
+ {filter ? (
1509
+ <button
1510
+ type="button"
1511
+ className="flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground hover:text-foreground"
1512
+ onClick={onClearFilter}
1513
+ >
1514
+ {t("agentChat.observability.insights.showing", {
1515
+ count: filter.runIds.length,
1516
+ })}{" "}
1517
+ <IconX className="size-3" />
1518
+ </button>
1519
+ ) : null}
1520
+ </div>
1521
+ <div className="flex items-center rounded-md border border-border/70 p-0.5">
1522
+ {(["newest", "cost"] as const).map((value) => (
1523
+ <Button
1524
+ key={value}
1525
+ type="button"
1526
+ size="sm"
1527
+ variant={sort === value ? "secondary" : "ghost"}
1528
+ className="h-7 px-2.5 text-xs"
1529
+ onClick={() => setSort(value)}
1530
+ aria-pressed={sort === value}
1531
+ >
1532
+ {value === "newest"
1533
+ ? t("agentChat.observability.insights.sortNewest")
1534
+ : t("agentChat.observability.insights.sortCost")}
1535
+ </Button>
1536
+ ))}
1537
+ </div>
1538
+ </div>
1539
+ {visible.length === 0 ? (
1540
+ <p className="border-t border-border/70 px-4 py-8 text-center text-sm text-muted-foreground">
1541
+ {t("agentChat.observability.insights.emptyPrompts")}
1542
+ </p>
1543
+ ) : (
1544
+ <div className="divide-y divide-border/60 border-t border-border/70">
1545
+ {visible.map((run) => (
1546
+ <RunRow
1547
+ key={run.runId}
1548
+ run={run}
1549
+ onOpen={() => setOpenRunId(run.runId)}
1550
+ />
1551
+ ))}
1552
+ </div>
1553
+ )}
1554
+ </section>
1555
+
1556
+ <Sheet
1557
+ open={Boolean(openRun)}
1558
+ onOpenChange={(open) => {
1559
+ if (!open) setOpenRunId(null);
1560
+ }}
1561
+ >
1562
+ <SheetContent className="run-insights-panel w-full gap-0 p-0 sm:max-w-[680px]">
1563
+ {openRun ? (
1564
+ <>
1565
+ <div className="absolute end-12 top-4 flex items-center gap-1">
1566
+ <Button
1567
+ type="button"
1568
+ variant="ghost"
1569
+ size="icon"
1570
+ className="size-7"
1571
+ disabled={openIndex <= 0}
1572
+ onClick={() => openAt(openIndex - 1)}
1573
+ aria-label={t("agentChat.observability.insights.prevPrompt")}
1574
+ title={t("agentChat.observability.insights.prevPrompt")}
1575
+ >
1576
+ <IconChevronUp className="size-4" />
1577
+ </Button>
1578
+ <Button
1579
+ type="button"
1580
+ variant="ghost"
1581
+ size="icon"
1582
+ className="size-7"
1583
+ disabled={openIndex >= visible.length - 1}
1584
+ onClick={() => openAt(openIndex + 1)}
1585
+ aria-label={t("agentChat.observability.insights.nextPrompt")}
1586
+ title={t("agentChat.observability.insights.nextPrompt")}
1587
+ >
1588
+ <IconChevronDown className="size-4" />
1589
+ </Button>
1590
+ </div>
1591
+ <RunPanel
1592
+ key={openRun.runId}
1593
+ run={openRun}
1594
+ renderRawTrace={renderRawTrace}
1595
+ onClose={() => setOpenRunId(null)}
1596
+ />
1597
+ </>
1598
+ ) : null}
1599
+ </SheetContent>
1600
+ </Sheet>
1601
+ </div>
1602
+ );
1603
+ }