@roj-ai/debug 0.0.2

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 (77) hide show
  1. package/dist/components/debug/DebugContext.d.ts +10 -0
  2. package/dist/components/debug/DebugNavigation.d.ts +29 -0
  3. package/dist/components/debug/DebugShell.d.ts +18 -0
  4. package/dist/components/debug/LLMCallDetail.d.ts +7 -0
  5. package/dist/components/debug/TimelineDetailInspector.d.ts +6 -0
  6. package/dist/components/debug/communication/CommunicationDiagram.d.ts +9 -0
  7. package/dist/components/debug/communication/DiagramHeader.d.ts +7 -0
  8. package/dist/components/debug/communication/ParticipantLane.d.ts +7 -0
  9. package/dist/components/debug/communication/TimeAxis.d.ts +9 -0
  10. package/dist/components/debug/communication/elements/IdleGap.d.ts +9 -0
  11. package/dist/components/debug/communication/elements/LLMBlock.d.ts +9 -0
  12. package/dist/components/debug/communication/elements/MessageArrow.d.ts +10 -0
  13. package/dist/components/debug/communication/elements/ToolBlock.d.ts +9 -0
  14. package/dist/components/debug/communication/hooks/useDiagramData.d.ts +12 -0
  15. package/dist/components/debug/communication/hooks/useTimeCompression.d.ts +7 -0
  16. package/dist/components/debug/communication/hooks/useZoomPan.d.ts +11 -0
  17. package/dist/components/debug/communication/popovers/ElementPopover.d.ts +8 -0
  18. package/dist/components/debug/communication/types.d.ts +136 -0
  19. package/dist/components/debug/index.d.ts +11 -0
  20. package/dist/components/debug/pages/AgentDetailPage.d.ts +3 -0
  21. package/dist/components/debug/pages/AgentsPage.d.ts +1 -0
  22. package/dist/components/debug/pages/CommunicationPage.d.ts +1 -0
  23. package/dist/components/debug/pages/DashboardPage.d.ts +1 -0
  24. package/dist/components/debug/pages/EventsPage.d.ts +1 -0
  25. package/dist/components/debug/pages/FilesPage.d.ts +1 -0
  26. package/dist/components/debug/pages/LLMCallPage.d.ts +1 -0
  27. package/dist/components/debug/pages/LLMCallsPage.d.ts +1 -0
  28. package/dist/components/debug/pages/LogsPage.d.ts +1 -0
  29. package/dist/components/debug/pages/MailboxPage.d.ts +1 -0
  30. package/dist/components/debug/pages/ServicesPage.d.ts +1 -0
  31. package/dist/components/debug/pages/TimelinePage.d.ts +1 -0
  32. package/dist/components/debug/pages/UserChatPage.d.ts +1 -0
  33. package/dist/components/debug/pages/index.d.ts +13 -0
  34. package/dist/index.d.ts +9 -0
  35. package/dist/lib/domain-utils.d.ts +7 -0
  36. package/dist/providers/EventPollingProvider.d.ts +27 -0
  37. package/dist/stores/event-store.d.ts +93 -0
  38. package/dist/utils/format.d.ts +1 -0
  39. package/package.json +43 -0
  40. package/src/components/debug/DebugContext.tsx +18 -0
  41. package/src/components/debug/DebugNavigation.tsx +55 -0
  42. package/src/components/debug/DebugShell.tsx +321 -0
  43. package/src/components/debug/LLMCallDetail.tsx +740 -0
  44. package/src/components/debug/TimelineDetailInspector.tsx +204 -0
  45. package/src/components/debug/communication/CommunicationDiagram.tsx +260 -0
  46. package/src/components/debug/communication/DiagramHeader.tsx +113 -0
  47. package/src/components/debug/communication/ParticipantLane.tsx +60 -0
  48. package/src/components/debug/communication/TimeAxis.tsx +106 -0
  49. package/src/components/debug/communication/elements/IdleGap.tsx +90 -0
  50. package/src/components/debug/communication/elements/LLMBlock.tsx +107 -0
  51. package/src/components/debug/communication/elements/MessageArrow.tsx +119 -0
  52. package/src/components/debug/communication/elements/ToolBlock.tsx +99 -0
  53. package/src/components/debug/communication/hooks/useDiagramData.ts +294 -0
  54. package/src/components/debug/communication/hooks/useTimeCompression.ts +140 -0
  55. package/src/components/debug/communication/hooks/useZoomPan.ts +87 -0
  56. package/src/components/debug/communication/popovers/ElementPopover.tsx +158 -0
  57. package/src/components/debug/communication/types.ts +180 -0
  58. package/src/components/debug/index.ts +37 -0
  59. package/src/components/debug/pages/AgentDetailPage.tsx +1295 -0
  60. package/src/components/debug/pages/AgentsPage.tsx +297 -0
  61. package/src/components/debug/pages/CommunicationPage.tsx +89 -0
  62. package/src/components/debug/pages/DashboardPage.tsx +1504 -0
  63. package/src/components/debug/pages/EventsPage.tsx +276 -0
  64. package/src/components/debug/pages/FilesPage.tsx +366 -0
  65. package/src/components/debug/pages/LLMCallPage.tsx +32 -0
  66. package/src/components/debug/pages/LLMCallsPage.tsx +473 -0
  67. package/src/components/debug/pages/LogsPage.tsx +199 -0
  68. package/src/components/debug/pages/MailboxPage.tsx +232 -0
  69. package/src/components/debug/pages/ServicesPage.tsx +193 -0
  70. package/src/components/debug/pages/TimelinePage.tsx +569 -0
  71. package/src/components/debug/pages/UserChatPage.tsx +250 -0
  72. package/src/components/debug/pages/index.ts +13 -0
  73. package/src/index.ts +55 -0
  74. package/src/lib/domain-utils.ts +12 -0
  75. package/src/providers/EventPollingProvider.tsx +60 -0
  76. package/src/stores/event-store.ts +497 -0
  77. package/src/utils/format.ts +8 -0
@@ -0,0 +1,1295 @@
1
+ import type {
2
+ AssistantConversationMessageView,
3
+ ConversationMessageView,
4
+ ToolCallView,
5
+ ToolConversationMessageView,
6
+ } from "@roj-ai/shared";
7
+ import { AgentId } from "@roj-ai/shared";
8
+ import { type FormEvent, useCallback, useMemo, useState } from "react";
9
+ import { api, unwrap } from "@roj-ai/client";
10
+ import { useAgentDetail, useEventStore } from "../../../stores/event-store";
11
+ import { formatDuration } from "../../../utils/format";
12
+ import {
13
+ DebugLink,
14
+ useDebugParams,
15
+ useDebugSessionId,
16
+ } from "../DebugNavigation";
17
+
18
+ function formatTime(ts: number | undefined): string | null {
19
+ if (ts === undefined) return null;
20
+ return new Date(ts).toLocaleTimeString();
21
+ }
22
+
23
+ function Timestamp({ value }: { value: number | undefined }) {
24
+ const formatted = formatTime(value);
25
+ if (!formatted) return null;
26
+ return (
27
+ <span className="text-[10px] text-gray-400 tabular-nums ml-auto shrink-0">
28
+ {formatted}
29
+ </span>
30
+ );
31
+ }
32
+
33
+ function CopyButton({ text }: { text: string }) {
34
+ const [copied, setCopied] = useState(false);
35
+
36
+ const handleCopy = useCallback(
37
+ (e: React.MouseEvent) => {
38
+ e.stopPropagation();
39
+ navigator.clipboard.writeText(text);
40
+ setCopied(true);
41
+ setTimeout(() => setCopied(false), 1500);
42
+ },
43
+ [text],
44
+ );
45
+
46
+ return (
47
+ <button
48
+ type="button"
49
+ onClick={handleCopy}
50
+ className="absolute top-1.5 right-1.5 opacity-0 group-hover/code:opacity-100 transition-opacity p-1 rounded bg-white/80 hover:bg-gray-100 border border-gray-200 cursor-pointer"
51
+ title="Copy to clipboard"
52
+ >
53
+ {copied ? (
54
+ <svg
55
+ className="w-3.5 h-3.5 text-green-600"
56
+ fill="none"
57
+ stroke="currentColor"
58
+ viewBox="0 0 24 24"
59
+ >
60
+ <path
61
+ strokeLinecap="round"
62
+ strokeLinejoin="round"
63
+ strokeWidth={2}
64
+ d="M5 13l4 4L19 7"
65
+ />
66
+ </svg>
67
+ ) : (
68
+ <svg
69
+ className="w-3.5 h-3.5 text-gray-500"
70
+ fill="none"
71
+ stroke="currentColor"
72
+ viewBox="0 0 24 24"
73
+ >
74
+ <path
75
+ strokeLinecap="round"
76
+ strokeLinejoin="round"
77
+ strokeWidth={2}
78
+ d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
79
+ />
80
+ </svg>
81
+ )}
82
+ </button>
83
+ );
84
+ }
85
+
86
+ function CodeBlock({
87
+ children,
88
+ variant = "default",
89
+ }: {
90
+ children: string;
91
+ variant?: "default" | "success" | "error";
92
+ }) {
93
+ const styles = {
94
+ default: "bg-gray-50 border-gray-200 text-gray-800",
95
+ success: "bg-green-50 border-green-200 text-gray-800",
96
+ error: "bg-red-50 border-red-200 text-red-800",
97
+ };
98
+ return (
99
+ <div className="relative group/code">
100
+ <pre
101
+ className={`p-2 rounded-lg border overflow-x-auto text-[11px] font-mono whitespace-pre-wrap break-words ${styles[variant]}`}
102
+ >
103
+ {children}
104
+ </pre>
105
+ <CopyButton text={children} />
106
+ </div>
107
+ );
108
+ }
109
+
110
+ export function AgentDetailPage({
111
+ agentId: agentIdProp,
112
+ }: {
113
+ agentId?: string;
114
+ } = {}) {
115
+ const { agentId: agentIdParam } = useDebugParams<{ agentId: string }>();
116
+ const agentId = agentIdProp ?? agentIdParam ?? "";
117
+ const sessionId = useDebugSessionId();
118
+
119
+ const detail = useAgentDetail(AgentId(agentId));
120
+ const isLoading = useEventStore((s) => s.isLoading);
121
+
122
+ const handleRewind = useCallback(
123
+ async (messageIndex: number) => {
124
+ if (!sessionId || !agentId) return;
125
+ unwrap(
126
+ await api.call("agents.rewind", {
127
+ sessionId,
128
+ agentId: AgentId(agentId),
129
+ messageIndex,
130
+ }),
131
+ );
132
+ },
133
+ [sessionId, agentId],
134
+ );
135
+
136
+ if (isLoading) {
137
+ return <div className="text-gray-400 text-sm">Loading...</div>;
138
+ }
139
+
140
+ if (!detail) {
141
+ return <div className="text-gray-400 text-sm">Agent not found</div>;
142
+ }
143
+
144
+ const { counters } = detail;
145
+
146
+ return (
147
+ <div className="text-sm">
148
+ {/* Two-column layout */}
149
+ <div className="flex gap-5 items-start">
150
+ {/* Left column - Conversation (2/3) */}
151
+ <div className="flex-[2] min-w-0 space-y-5">
152
+ {/* Conversation History Section */}
153
+ <CollapsibleSection
154
+ title="Conversation History"
155
+ count={detail.conversationHistory.length}
156
+ defaultOpen
157
+ >
158
+ {detail.conversationHistory.length === 0 ? (
159
+ <div className="text-gray-400">Empty</div>
160
+ ) : (
161
+ <ConversationGroups
162
+ messages={detail.conversationHistory}
163
+ onRewind={handleRewind}
164
+ />
165
+ )}
166
+ </CollapsibleSection>
167
+ </div>
168
+
169
+ {/* Right column - Sidebar (1/3) */}
170
+ <div className="flex-1 min-w-0 space-y-4 sticky top-0">
171
+ {/* Agent Header Card */}
172
+ <div className="bg-white rounded-2xl shadow-card p-4">
173
+ <div className="flex items-center gap-3 mb-4">
174
+ <div className="w-9 h-9 rounded-xl bg-accent-peri/15 flex items-center justify-center shrink-0">
175
+ <svg
176
+ className="w-4.5 h-4.5 text-accent-peri"
177
+ fill="none"
178
+ stroke="currentColor"
179
+ viewBox="0 0 24 24"
180
+ >
181
+ <path
182
+ strokeLinecap="round"
183
+ strokeLinejoin="round"
184
+ strokeWidth={1.5}
185
+ d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
186
+ />
187
+ </svg>
188
+ </div>
189
+ <div className="flex-1 min-w-0">
190
+ <div className="font-bold text-gray-900">
191
+ {detail.definitionName}
192
+ </div>
193
+ <div className="text-[11px] text-gray-400 font-mono truncate">
194
+ {detail.id}
195
+ </div>
196
+ </div>
197
+ <StatusBadge status={detail.status} />
198
+ </div>
199
+
200
+ {detail.parentId && (
201
+ <div className="text-[11px] text-gray-400 mb-3">
202
+ Parent:{" "}
203
+ <DebugLink
204
+ to={`agents/${detail.parentId}`}
205
+ className="text-accent-peri hover:underline font-mono"
206
+ >
207
+ {detail.parentId.slice(0, 12)}...
208
+ </DebugLink>
209
+ </div>
210
+ )}
211
+
212
+ {detail.pauseReason && (
213
+ <div className="bg-red-50 border border-red-200 rounded-xl px-3 py-2 mb-3 flex items-center gap-2">
214
+ <div className="w-2 h-2 rounded-full bg-red-500 shrink-0" />
215
+ <span className="text-[11px] font-semibold text-red-700 flex-1">
216
+ Paused
217
+ {detail.pauseMessage
218
+ ? `: ${detail.pauseMessage}`
219
+ : ` (${detail.pauseReason})`}
220
+ </span>
221
+ <ResumeAgentButton sessionId={sessionId} agentId={agentId!} />
222
+ </div>
223
+ )}
224
+
225
+ {/* Counters Grid */}
226
+ <div className="grid grid-cols-2 gap-2">
227
+ <CounterItem label="Inferences" value={counters.inferenceCount} />
228
+ <CounterItem label="Tool Calls" value={counters.toolCallCount} />
229
+ <CounterItem label="Spawned" value={counters.spawnedAgentCount} />
230
+ <CounterItem
231
+ label="Messages"
232
+ value={counters.messagesSentCount}
233
+ />
234
+ </div>
235
+
236
+ {/* Cost */}
237
+ {detail.cost > 0 && (
238
+ <div className="mt-2 bg-emerald-50 rounded-xl px-3 py-2.5 border border-emerald-100 flex items-center gap-2">
239
+ <svg
240
+ className="w-4 h-4 text-emerald-600 shrink-0"
241
+ fill="none"
242
+ stroke="currentColor"
243
+ viewBox="0 0 24 24"
244
+ >
245
+ <path
246
+ strokeLinecap="round"
247
+ strokeLinejoin="round"
248
+ strokeWidth={1.5}
249
+ d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
250
+ />
251
+ </svg>
252
+ <span className="text-[10px] text-emerald-600 uppercase tracking-wider font-medium">
253
+ Cost
254
+ </span>
255
+ <span className="ml-auto text-sm font-bold tabular-nums text-emerald-700">
256
+ ${detail.cost.toFixed(4)}
257
+ </span>
258
+ </div>
259
+ )}
260
+ </div>
261
+
262
+ {/* Pending Tool Calls */}
263
+ <CollapsibleSection
264
+ title="Pending Tool Calls"
265
+ count={detail.pendingToolCalls.length}
266
+ warnIfPositive
267
+ defaultOpen={detail.pendingToolCalls.length > 0}
268
+ >
269
+ {detail.pendingToolCalls.length === 0 ? (
270
+ <div className="text-gray-400">None</div>
271
+ ) : (
272
+ <div className="space-y-2">
273
+ {detail.pendingToolCalls.map((tool) => (
274
+ <ToolCallCard key={tool.id} tool={tool} />
275
+ ))}
276
+ </div>
277
+ )}
278
+ </CollapsibleSection>
279
+
280
+ {/* Mailbox */}
281
+ <CollapsibleSection
282
+ title="Mailbox"
283
+ count={detail.mailbox.length}
284
+ defaultOpen={detail.mailbox.some((m) => !m.consumed)}
285
+ >
286
+ {detail.mailbox.length === 0 ? (
287
+ <div className="text-gray-400">Empty</div>
288
+ ) : (
289
+ <div className="space-y-2">
290
+ {detail.mailbox.map((msg) => (
291
+ <div
292
+ key={msg.id}
293
+ className={`rounded-xl px-3 py-2.5 border ${
294
+ msg.consumed
295
+ ? "bg-gray-50 border-gray-100"
296
+ : "bg-amber-50 border-amber-200"
297
+ }`}
298
+ >
299
+ <div className="flex items-center gap-2 text-[11px] text-gray-400 mb-1.5 flex-wrap">
300
+ <span className="font-medium">From:</span>
301
+ {msg.from !== "user" &&
302
+ msg.from !== "orchestrator" &&
303
+ msg.from !== "communicator" ? (
304
+ <DebugLink
305
+ to={`agents/${msg.from}`}
306
+ className="text-accent-peri hover:underline font-mono"
307
+ >
308
+ {msg.from.slice(0, 8)}...
309
+ </DebugLink>
310
+ ) : (
311
+ <span className="font-semibold text-gray-500">
312
+ {msg.from}
313
+ </span>
314
+ )}
315
+ {msg.consumed && (
316
+ <span className="text-[10px] bg-green-100 text-green-700 px-1.5 py-0.5 rounded-full font-medium">
317
+ consumed
318
+ </span>
319
+ )}
320
+ <span className="text-[10px] text-gray-400 tabular-nums ml-auto">
321
+ {new Date(msg.timestamp).toLocaleTimeString()}
322
+ </span>
323
+ </div>
324
+ <div className="whitespace-pre-wrap break-words text-gray-700 text-[12px] leading-relaxed">
325
+ {msg.content}
326
+ </div>
327
+ </div>
328
+ ))}
329
+ </div>
330
+ )}
331
+ </CollapsibleSection>
332
+
333
+ {/* Loaded Skills */}
334
+ {detail.loadedSkills.length > 0 && (
335
+ <CollapsibleSection
336
+ title="Loaded Skills"
337
+ count={detail.loadedSkills.length}
338
+ defaultOpen={false}
339
+ >
340
+ <div className="flex flex-wrap gap-1.5">
341
+ {detail.loadedSkills.map((skill) => (
342
+ <div
343
+ key={skill.id}
344
+ className="bg-accent-peri/10 text-gray-700 px-2 py-0.5 rounded-full text-[11px] font-medium"
345
+ >
346
+ {skill.name}
347
+ <span className="text-gray-400 ml-1">
348
+ {formatDuration(Date.now() - skill.loadedAt)} ago
349
+ </span>
350
+ </div>
351
+ ))}
352
+ </div>
353
+ </CollapsibleSection>
354
+ )}
355
+
356
+ {/* Typed Input */}
357
+ {detail.typedInput !== undefined && (
358
+ <CollapsibleSection title="Typed Input" defaultOpen={false}>
359
+ <pre className="bg-gray-50 rounded-xl px-3 py-2.5 text-[11px] font-mono overflow-x-auto text-gray-700">
360
+ {JSON.stringify(detail.typedInput, null, 2)}
361
+ </pre>
362
+ </CollapsibleSection>
363
+ )}
364
+
365
+ {/* Send Debug Message */}
366
+ <SendMessageForm sessionId={sessionId} agentId={agentId!} />
367
+ </div>
368
+ </div>
369
+ </div>
370
+ );
371
+ }
372
+
373
+ // ============================================================================
374
+ // CollapsibleSection
375
+ // ============================================================================
376
+
377
+ function CollapsibleSection({
378
+ title,
379
+ count,
380
+ warnIfPositive,
381
+ defaultOpen = true,
382
+ children,
383
+ }: {
384
+ title: string;
385
+ count?: number;
386
+ warnIfPositive?: boolean;
387
+ defaultOpen?: boolean;
388
+ children: React.ReactNode;
389
+ }) {
390
+ const [open, setOpen] = useState(defaultOpen);
391
+ const isWarning = warnIfPositive && count !== undefined && count > 0;
392
+
393
+ return (
394
+ <div className="bg-white rounded-2xl shadow-card overflow-hidden">
395
+ <button
396
+ type="button"
397
+ onClick={() => setOpen(!open)}
398
+ className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-gray-50 transition-colors cursor-pointer"
399
+ >
400
+ <svg
401
+ className={`w-3.5 h-3.5 text-gray-400 transition-transform ${open ? "rotate-90" : ""}`}
402
+ fill="none"
403
+ stroke="currentColor"
404
+ viewBox="0 0 24 24"
405
+ >
406
+ <path
407
+ strokeLinecap="round"
408
+ strokeLinejoin="round"
409
+ strokeWidth={2}
410
+ d="M9 5l7 7-7 7"
411
+ />
412
+ </svg>
413
+ <span className="text-sm font-semibold text-gray-900">{title}</span>
414
+ {count !== undefined && (
415
+ <span
416
+ className={`text-[11px] px-2 py-0.5 rounded-full font-medium tabular-nums ${
417
+ isWarning
418
+ ? "bg-amber-100 text-amber-700"
419
+ : "bg-gray-100 text-gray-500"
420
+ }`}
421
+ >
422
+ {count}
423
+ </span>
424
+ )}
425
+ </button>
426
+ {open && <div className="px-4 pb-4">{children}</div>}
427
+ </div>
428
+ );
429
+ }
430
+
431
+ // ============================================================================
432
+ // CounterItem
433
+ // ============================================================================
434
+
435
+ const counterIcons: Record<string, React.ReactNode> = {
436
+ Inferences: (
437
+ <svg
438
+ className="w-3.5 h-3.5"
439
+ fill="none"
440
+ stroke="currentColor"
441
+ viewBox="0 0 24 24"
442
+ >
443
+ <path
444
+ strokeLinecap="round"
445
+ strokeLinejoin="round"
446
+ strokeWidth={1.5}
447
+ d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z"
448
+ />
449
+ </svg>
450
+ ),
451
+ "Tool Calls": (
452
+ <svg
453
+ className="w-3.5 h-3.5"
454
+ fill="none"
455
+ stroke="currentColor"
456
+ viewBox="0 0 24 24"
457
+ >
458
+ <path
459
+ strokeLinecap="round"
460
+ strokeLinejoin="round"
461
+ strokeWidth={1.5}
462
+ d="M11.42 15.17l-5.1-5.1a1.5 1.5 0 010-2.12l.88-.88a1.5 1.5 0 012.12 0l2.83 2.83 5.66-5.66a1.5 1.5 0 012.12 0l.88.88a1.5 1.5 0 010 2.12l-7.07 7.07a1.5 1.5 0 01-2.12-.14z"
463
+ />
464
+ </svg>
465
+ ),
466
+ Spawned: (
467
+ <svg
468
+ className="w-3.5 h-3.5"
469
+ fill="none"
470
+ stroke="currentColor"
471
+ viewBox="0 0 24 24"
472
+ >
473
+ <path
474
+ strokeLinecap="round"
475
+ strokeLinejoin="round"
476
+ strokeWidth={1.5}
477
+ d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
478
+ />
479
+ </svg>
480
+ ),
481
+ Messages: (
482
+ <svg
483
+ className="w-3.5 h-3.5"
484
+ fill="none"
485
+ stroke="currentColor"
486
+ viewBox="0 0 24 24"
487
+ >
488
+ <path
489
+ strokeLinecap="round"
490
+ strokeLinejoin="round"
491
+ strokeWidth={1.5}
492
+ d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
493
+ />
494
+ </svg>
495
+ ),
496
+ };
497
+
498
+ function CounterItem({ label, value }: { label: string; value: number }) {
499
+ return (
500
+ <div className="bg-gray-50 rounded-xl px-3 py-2.5 border border-gray-100">
501
+ <div className="flex items-center gap-1.5 mb-1">
502
+ <span className="text-gray-400">{counterIcons[label]}</span>
503
+ <span className="text-[10px] text-gray-400 uppercase tracking-wider font-medium">
504
+ {label}
505
+ </span>
506
+ </div>
507
+ <div className="text-lg font-bold tabular-nums text-gray-900">
508
+ {value}
509
+ </div>
510
+ </div>
511
+ );
512
+ }
513
+
514
+ // ============================================================================
515
+ // ToolCallCard
516
+ // ============================================================================
517
+
518
+ function ToolCallCard({ tool }: { tool: ToolCallView }) {
519
+ const [expanded, setExpanded] = useState(false);
520
+
521
+ return (
522
+ <div className="rounded-xl border border-gray-100 bg-gray-50 overflow-hidden">
523
+ <button
524
+ type="button"
525
+ onClick={() => setExpanded(!expanded)}
526
+ className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-gray-100 transition-colors cursor-pointer"
527
+ >
528
+ <ToolStatusDot status={tool.status} />
529
+ <span className="font-mono font-medium text-sm text-gray-900">
530
+ {tool.name}
531
+ </span>
532
+ <ToolStatusBadge status={tool.status} />
533
+ <span className="text-[10px] text-gray-400 font-mono ml-auto">
534
+ {tool.id.slice(0, 12)}
535
+ </span>
536
+ <svg
537
+ className={`w-3 h-3 text-gray-400 transition-transform shrink-0 ${expanded ? "rotate-90" : ""}`}
538
+ fill="none"
539
+ stroke="currentColor"
540
+ viewBox="0 0 24 24"
541
+ >
542
+ <path
543
+ strokeLinecap="round"
544
+ strokeLinejoin="round"
545
+ strokeWidth={2}
546
+ d="M9 5l7 7-7 7"
547
+ />
548
+ </svg>
549
+ </button>
550
+ {expanded && (
551
+ <div className="px-3 pb-3 space-y-2 border-t border-gray-100">
552
+ {tool.input !== undefined && (
553
+ <div className="mt-2">
554
+ <div className="text-[10px] text-gray-500 uppercase tracking-wider font-medium mb-1">
555
+ Input
556
+ </div>
557
+ <CodeBlock>{JSON.stringify(tool.input, null, 2)}</CodeBlock>
558
+ </div>
559
+ )}
560
+ {tool.result !== undefined && (
561
+ <div>
562
+ <div className="text-[10px] text-green-700 uppercase tracking-wider font-medium mb-1">
563
+ Result
564
+ </div>
565
+ <CodeBlock variant="success">
566
+ {typeof tool.result === "string"
567
+ ? tool.result
568
+ : JSON.stringify(tool.result, null, 2)}
569
+ </CodeBlock>
570
+ </div>
571
+ )}
572
+ {tool.error && (
573
+ <div>
574
+ <div className="text-[10px] text-red-700 uppercase tracking-wider font-medium mb-1">
575
+ Error
576
+ </div>
577
+ <CodeBlock variant="error">{tool.error}</CodeBlock>
578
+ </div>
579
+ )}
580
+ </div>
581
+ )}
582
+ </div>
583
+ );
584
+ }
585
+
586
+ // ============================================================================
587
+ // Conversation Grouping
588
+ // ============================================================================
589
+
590
+ interface ToolInteraction {
591
+ call: NonNullable<AssistantConversationMessageView["toolCalls"]>[number];
592
+ response?: ToolConversationMessageView;
593
+ }
594
+
595
+ type ConversationGroup =
596
+ | { type: "message"; message: ConversationMessageView; messageIndex: number }
597
+ | {
598
+ type: "assistant-with-tools";
599
+ assistantMessage: AssistantConversationMessageView;
600
+ toolInteractions: ToolInteraction[];
601
+ messageIndex: number;
602
+ };
603
+
604
+ function groupConversation(
605
+ messages: ConversationMessageView[],
606
+ ): ConversationGroup[] {
607
+ const groups: ConversationGroup[] = [];
608
+ let i = 0;
609
+
610
+ while (i < messages.length) {
611
+ const msg = messages[i];
612
+
613
+ if (msg.role === "assistant" && msg.toolCalls && msg.toolCalls.length > 0) {
614
+ const toolResponses = new Map<string, ToolConversationMessageView>();
615
+ let j = i + 1;
616
+ while (j < messages.length) {
617
+ const nextMsg = messages[j];
618
+ if (nextMsg.role !== "tool") break;
619
+ toolResponses.set(nextMsg.toolCallId, nextMsg);
620
+ j++;
621
+ }
622
+
623
+ groups.push({
624
+ type: "assistant-with-tools",
625
+ assistantMessage: msg,
626
+ toolInteractions: msg.toolCalls.map((tc) => ({
627
+ call: tc,
628
+ response: toolResponses.get(tc.id),
629
+ })),
630
+ messageIndex: i,
631
+ });
632
+ i = j;
633
+ } else {
634
+ groups.push({ type: "message", message: msg, messageIndex: i });
635
+ i++;
636
+ }
637
+ }
638
+
639
+ return groups;
640
+ }
641
+
642
+ function ConversationGroups({
643
+ messages,
644
+ onRewind,
645
+ }: {
646
+ messages: ConversationMessageView[];
647
+ onRewind?: (messageIndex: number) => void;
648
+ }) {
649
+ const groups = useMemo(() => groupConversation(messages), [messages]);
650
+
651
+ // Compute previous assistant message's total input for cache validation.
652
+ // promptTokens (= Anthropic input_tokens) is the TOTAL input including cached/cache-write portions.
653
+ const prevTotalInputs = useMemo(() => {
654
+ const result: Array<number | undefined> = [];
655
+ let prevTotal: number | undefined;
656
+ for (const group of groups) {
657
+ result.push(prevTotal);
658
+ if (group.type === "assistant-with-tools") {
659
+ const msg = group.assistantMessage;
660
+ if (msg.promptTokens !== undefined) {
661
+ prevTotal = msg.promptTokens;
662
+ }
663
+ }
664
+ }
665
+ return result;
666
+ }, [groups]);
667
+
668
+ return (
669
+ <div className="space-y-3">
670
+ {groups.map((group, idx) => {
671
+ if (group.type === "message") {
672
+ return <ConversationMessage key={idx} msg={group.message} />;
673
+ }
674
+ return (
675
+ <AssistantToolGroup
676
+ key={idx}
677
+ group={group}
678
+ expectedCacheRead={prevTotalInputs[idx]}
679
+ onRewind={onRewind}
680
+ />
681
+ );
682
+ })}
683
+ </div>
684
+ );
685
+ }
686
+
687
+ // ============================================================================
688
+ // AssistantToolGroup
689
+ // ============================================================================
690
+
691
+ function formatTokens(n: number): string {
692
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
693
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
694
+ return n.toString();
695
+ }
696
+
697
+ function CacheStatsLine({
698
+ msg,
699
+ expectedCacheRead,
700
+ }: {
701
+ msg: AssistantConversationMessageView;
702
+ expectedCacheRead?: number;
703
+ }) {
704
+ if (msg.promptTokens === undefined) return null;
705
+
706
+ // promptTokens (= Anthropic input_tokens) is TOTAL input including cached/cache-write.
707
+ // Uncached = total - cached - cache_write (the remainder billed at full price).
708
+ const totalInput = msg.promptTokens ?? 0;
709
+ const cached = msg.cachedTokens ?? 0;
710
+ const cacheWrite = msg.cacheWriteTokens ?? 0;
711
+ const uncached = totalInput - cached - cacheWrite;
712
+
713
+ // Cache validation: compare actual cache read with expected (prev call total input)
714
+ let cacheValidation: { ratio: number; ok: boolean } | undefined;
715
+ if (expectedCacheRead !== undefined && expectedCacheRead > 0 && cached > 0) {
716
+ const ratio = cached / expectedCacheRead;
717
+ cacheValidation = { ratio, ok: ratio >= 0.9 };
718
+ }
719
+
720
+ return (
721
+ <div className="flex items-center gap-1.5 text-[10px] tabular-nums flex-wrap">
722
+ {uncached > 0 && (
723
+ <span className="text-gray-400">{formatTokens(uncached)} in</span>
724
+ )}
725
+ {cached > 0 && (
726
+ <span className="text-blue-500">
727
+ {uncached > 0 ? "+ " : ""}
728
+ {formatTokens(cached)} cached
729
+ </span>
730
+ )}
731
+ {cacheWrite > 0 && (
732
+ <span className="text-amber-500">
733
+ + {formatTokens(cacheWrite)} write
734
+ </span>
735
+ )}
736
+ {cacheValidation && (
737
+ <>
738
+ <span className="text-gray-300">|</span>
739
+ <span
740
+ className={cacheValidation.ok ? "text-green-600" : "text-red-500"}
741
+ >
742
+ prev {formatTokens(expectedCacheRead!)} →{" "}
743
+ {(cacheValidation.ratio * 100).toFixed(0)}% hit
744
+ </span>
745
+ </>
746
+ )}
747
+ </div>
748
+ );
749
+ }
750
+
751
+ function AssistantToolGroup({
752
+ group,
753
+ expectedCacheRead,
754
+ onRewind,
755
+ }: {
756
+ group: {
757
+ assistantMessage: AssistantConversationMessageView;
758
+ toolInteractions: ToolInteraction[];
759
+ messageIndex: number;
760
+ };
761
+ expectedCacheRead?: number;
762
+ onRewind?: (messageIndex: number) => void;
763
+ }) {
764
+ const { assistantMessage, toolInteractions } = group;
765
+ const [textExpanded, setTextExpanded] = useState(false);
766
+ const hasText = assistantMessage.content.trim().length > 0;
767
+ const isTextTruncated =
768
+ assistantMessage.content !== assistantMessage.fullContent;
769
+ const displayText = textExpanded
770
+ ? assistantMessage.fullContent
771
+ : assistantMessage.content;
772
+
773
+ return (
774
+ <div className="rounded-xl bg-white border border-gray-100 border-l-[3px] border-l-green-500 px-3.5 py-3">
775
+ <div className="flex items-center gap-2 mb-1">
776
+ <span className="text-[10px] font-bold uppercase tracking-wider text-green-700">
777
+ assistant
778
+ </span>
779
+ {toolInteractions.length > 0 && (
780
+ <span className="text-[10px] bg-green-100 text-green-700 px-1.5 py-0.5 rounded-full font-medium">
781
+ {toolInteractions.length} tool
782
+ {toolInteractions.length !== 1 ? "s" : ""}
783
+ </span>
784
+ )}
785
+ {assistantMessage.cost !== undefined && assistantMessage.cost > 0 && (
786
+ <span className="text-[10px] text-emerald-600 font-medium tabular-nums">
787
+ ${assistantMessage.cost.toFixed(4)}
788
+ </span>
789
+ )}
790
+ {assistantMessage.llmCallId && (
791
+ <DebugLink
792
+ to={`llm-calls/${assistantMessage.llmCallId}`}
793
+ className="text-[10px] text-accent-peri hover:underline font-medium"
794
+ >
795
+ view llm call →
796
+ </DebugLink>
797
+ )}
798
+ {onRewind && (
799
+ <button
800
+ type="button"
801
+ onClick={() => onRewind(group.messageIndex)}
802
+ className="text-[10px] text-orange-600 hover:underline font-medium cursor-pointer"
803
+ >
804
+ retry from here
805
+ </button>
806
+ )}
807
+ <Timestamp value={assistantMessage.timestamp} />
808
+ </div>
809
+
810
+ <CacheStatsLine
811
+ msg={assistantMessage}
812
+ expectedCacheRead={expectedCacheRead}
813
+ />
814
+
815
+ {hasText && (
816
+ <div className="mt-1">
817
+ <div className="whitespace-pre-wrap break-words text-gray-700 text-[12px] leading-relaxed">
818
+ {displayText}
819
+ </div>
820
+ {isTextTruncated && (
821
+ <button
822
+ type="button"
823
+ onClick={() => setTextExpanded(!textExpanded)}
824
+ className="text-[11px] text-accent-peri hover:underline mt-1 font-medium cursor-pointer"
825
+ >
826
+ {textExpanded ? "Show less" : "Show more"}
827
+ </button>
828
+ )}
829
+ </div>
830
+ )}
831
+
832
+ <div className={`space-y-1.5 ${hasText ? "mt-2" : ""}`}>
833
+ {toolInteractions.map(({ call, response }) => (
834
+ <ToolInteractionCard key={call.id} call={call} response={response} />
835
+ ))}
836
+ </div>
837
+ </div>
838
+ );
839
+ }
840
+
841
+ // ============================================================================
842
+ // ToolInteractionCard
843
+ // ============================================================================
844
+
845
+ function oneLinePreview(value: unknown, maxLen = 80): string {
846
+ const str = typeof value === "string" ? value : JSON.stringify(value);
847
+ const line = str.replace(/\n/g, " ").trim();
848
+ return line.length > maxLen ? line.slice(0, maxLen) + "…" : line;
849
+ }
850
+
851
+ function ToolInteractionCard({
852
+ call,
853
+ response,
854
+ }: {
855
+ call: { id: string; name: string; input: unknown };
856
+ response?: ToolConversationMessageView;
857
+ }) {
858
+ const [expanded, setExpanded] = useState(false);
859
+ const [resultExpanded, setResultExpanded] = useState(false);
860
+ const status = response
861
+ ? response.isError
862
+ ? "failed"
863
+ : "completed"
864
+ : "pending";
865
+ const isResultTruncated =
866
+ response !== undefined && response.content !== response.fullContent;
867
+ const displayResult = resultExpanded
868
+ ? response?.fullContent
869
+ : response?.content;
870
+
871
+ return (
872
+ <div
873
+ className={`rounded-lg border overflow-hidden ${
874
+ response?.isError
875
+ ? "border-red-200 bg-red-50/50"
876
+ : "border-gray-200 bg-gray-50/50"
877
+ }`}
878
+ >
879
+ <button
880
+ type="button"
881
+ onClick={() => setExpanded(!expanded)}
882
+ className="w-full flex items-start gap-2 px-2.5 py-2 text-left hover:bg-white/60 transition-colors cursor-pointer"
883
+ >
884
+ <ToolStatusDot status={status} />
885
+ <div className="flex-1 min-w-0">
886
+ <div className="flex items-center gap-1.5">
887
+ <span className="font-mono font-medium text-[11px] text-gray-800">
888
+ {call.name}
889
+ </span>
890
+ {response?.isError && (
891
+ <span className="text-[9px] bg-red-100 text-red-700 px-1.5 py-0.5 rounded-full font-semibold">
892
+ error
893
+ </span>
894
+ )}
895
+ <Timestamp value={response?.timestamp} />
896
+ </div>
897
+ {!expanded && (
898
+ <div className="mt-0.5 space-y-0">
899
+ {call.input !== undefined && (
900
+ <div className="text-[10px] text-gray-500 font-mono truncate">
901
+ ↑ {oneLinePreview(call.input)}
902
+ </div>
903
+ )}
904
+ {response && (
905
+ <div
906
+ className={`text-[10px] font-mono truncate ${response.isError ? "text-red-600" : "text-gray-500"}`}
907
+ >
908
+ ↓ {oneLinePreview(response.content)}
909
+ </div>
910
+ )}
911
+ </div>
912
+ )}
913
+ </div>
914
+ <svg
915
+ className={`w-3 h-3 text-gray-400 transition-transform shrink-0 mt-0.5 ${expanded ? "rotate-90" : ""}`}
916
+ fill="none"
917
+ stroke="currentColor"
918
+ viewBox="0 0 24 24"
919
+ >
920
+ <path
921
+ strokeLinecap="round"
922
+ strokeLinejoin="round"
923
+ strokeWidth={2}
924
+ d="M9 5l7 7-7 7"
925
+ />
926
+ </svg>
927
+ </button>
928
+
929
+ {expanded && (
930
+ <div className="px-2.5 pb-2.5 space-y-2 border-t border-gray-100">
931
+ {call.input !== undefined && (
932
+ <div className="mt-2">
933
+ <div className="text-[9px] text-gray-500 uppercase tracking-wider font-medium mb-1">
934
+ Input
935
+ </div>
936
+ <CodeBlock>{JSON.stringify(call.input, null, 2)}</CodeBlock>
937
+ </div>
938
+ )}
939
+
940
+ {response && !response.isError && (
941
+ <div>
942
+ <div className="text-[9px] text-green-700 uppercase tracking-wider font-medium mb-1">
943
+ Result
944
+ </div>
945
+ <CodeBlock variant="success">{displayResult ?? ""}</CodeBlock>
946
+ {isResultTruncated && (
947
+ <button
948
+ type="button"
949
+ onClick={(e) => {
950
+ e.stopPropagation();
951
+ setResultExpanded(!resultExpanded);
952
+ }}
953
+ className="text-[10px] text-accent-peri hover:underline mt-0.5 font-medium cursor-pointer"
954
+ >
955
+ {resultExpanded ? "Show less" : "Show more"}
956
+ </button>
957
+ )}
958
+ </div>
959
+ )}
960
+
961
+ {response?.isError && (
962
+ <div>
963
+ <div className="text-[9px] text-red-700 uppercase tracking-wider font-medium mb-1">
964
+ Error
965
+ </div>
966
+ <CodeBlock variant="error">{displayResult ?? ""}</CodeBlock>
967
+ {isResultTruncated && (
968
+ <button
969
+ type="button"
970
+ onClick={(e) => {
971
+ e.stopPropagation();
972
+ setResultExpanded(!resultExpanded);
973
+ }}
974
+ className="text-[10px] text-accent-peri hover:underline mt-0.5 font-medium cursor-pointer"
975
+ >
976
+ {resultExpanded ? "Show less" : "Show more"}
977
+ </button>
978
+ )}
979
+ </div>
980
+ )}
981
+ </div>
982
+ )}
983
+ </div>
984
+ );
985
+ }
986
+
987
+ // ============================================================================
988
+ // ConversationMessage (user, system, orphan tool)
989
+ // ============================================================================
990
+
991
+ const roleStyles: Record<
992
+ string,
993
+ { bg: string; leftBorder: string; label: string }
994
+ > = {
995
+ user: {
996
+ bg: "bg-white",
997
+ leftBorder: "border-l-blue-500",
998
+ label: "text-blue-600",
999
+ },
1000
+ assistant: {
1001
+ bg: "bg-white",
1002
+ leftBorder: "border-l-green-500",
1003
+ label: "text-green-700",
1004
+ },
1005
+ tool: {
1006
+ bg: "bg-white",
1007
+ leftBorder: "border-l-indigo-400",
1008
+ label: "text-indigo-600",
1009
+ },
1010
+ system: {
1011
+ bg: "bg-gray-50",
1012
+ leftBorder: "border-l-gray-300",
1013
+ label: "text-gray-500",
1014
+ },
1015
+ };
1016
+
1017
+ function ConversationMessage({ msg }: { msg: ConversationMessageView }) {
1018
+ const [expanded, setExpanded] = useState(false);
1019
+ const style = roleStyles[msg.role] ?? roleStyles.system;
1020
+ const isTruncated = msg.content !== msg.fullContent;
1021
+ const displayContent = expanded ? msg.fullContent : msg.content;
1022
+ const isError = msg.role === "tool" && msg.isError;
1023
+
1024
+ return (
1025
+ <div
1026
+ className={`rounded-xl border border-gray-100 border-l-[3px] px-3.5 py-3 ${
1027
+ isError
1028
+ ? "bg-red-50 border-l-red-500"
1029
+ : `${style.bg} ${style.leftBorder}`
1030
+ }`}
1031
+ >
1032
+ <div className="flex items-center gap-2 mb-2">
1033
+ <span
1034
+ className={`text-[10px] font-bold uppercase tracking-wider ${isError ? "text-red-700" : style.label}`}
1035
+ >
1036
+ {msg.role}
1037
+ </span>
1038
+ {msg.role === "tool" && (
1039
+ <>
1040
+ <span className="text-[10px] text-gray-400 font-mono">
1041
+ {msg.toolCallId.slice(0, 12)}
1042
+ </span>
1043
+ {msg.isError && (
1044
+ <span className="text-[9px] bg-red-100 text-red-700 px-1.5 py-0.5 rounded-full font-semibold">
1045
+ error
1046
+ </span>
1047
+ )}
1048
+ </>
1049
+ )}
1050
+ {msg.role === "assistant" && msg.cost !== undefined && msg.cost > 0 && (
1051
+ <span className="text-[10px] text-emerald-600 font-medium tabular-nums">
1052
+ ${msg.cost.toFixed(4)}
1053
+ </span>
1054
+ )}
1055
+ <Timestamp value={msg.timestamp} />
1056
+ </div>
1057
+ <div
1058
+ className={`whitespace-pre-wrap break-words text-[12px] leading-relaxed ${
1059
+ isError ? "text-red-800" : "text-gray-700"
1060
+ }`}
1061
+ >
1062
+ {displayContent}
1063
+ </div>
1064
+ {isTruncated && (
1065
+ <button
1066
+ type="button"
1067
+ onClick={() => setExpanded(!expanded)}
1068
+ className="text-[11px] text-accent-peri hover:underline mt-1.5 font-medium cursor-pointer"
1069
+ >
1070
+ {expanded ? "Show less" : "Show more"}
1071
+ </button>
1072
+ )}
1073
+ </div>
1074
+ );
1075
+ }
1076
+
1077
+ // ============================================================================
1078
+ // SendMessageForm
1079
+ // ============================================================================
1080
+
1081
+ function SendMessageForm({
1082
+ sessionId,
1083
+ agentId,
1084
+ }: {
1085
+ sessionId: string;
1086
+ agentId: string;
1087
+ }) {
1088
+ const [content, setContent] = useState("");
1089
+ const [senderType, setSenderType] = useState<"user" | "debug" | "custom">(
1090
+ "user",
1091
+ );
1092
+ const [customSender, setCustomSender] = useState("");
1093
+ const [sending, setSending] = useState(false);
1094
+ const [error, setError] = useState<string | null>(null);
1095
+
1096
+ const handleSend = useCallback(async () => {
1097
+ if (!content.trim()) return;
1098
+
1099
+ setSending(true);
1100
+ setError(null);
1101
+
1102
+ try {
1103
+ if (senderType === "debug") {
1104
+ unwrap(
1105
+ await api.call("mailbox.send", {
1106
+ sessionId,
1107
+ toAgentId: AgentId(agentId),
1108
+ content: content.trim(),
1109
+ debug: true,
1110
+ }),
1111
+ );
1112
+ } else {
1113
+ unwrap(
1114
+ await api.call("user-chat.sendMessage", {
1115
+ sessionId,
1116
+ agentId: AgentId(agentId),
1117
+ content: content.trim(),
1118
+ }),
1119
+ );
1120
+ }
1121
+ setContent("");
1122
+ } catch (e) {
1123
+ setError(e instanceof Error ? e.message : "Failed to send message");
1124
+ } finally {
1125
+ setSending(false);
1126
+ }
1127
+ }, [content, senderType, sessionId, agentId]);
1128
+
1129
+ const handleKeyDown = useCallback(
1130
+ (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
1131
+ if (e.key === "Enter" && !e.shiftKey) {
1132
+ e.preventDefault();
1133
+ handleSend();
1134
+ }
1135
+ },
1136
+ [handleSend],
1137
+ );
1138
+
1139
+ const handleSubmit = useCallback(
1140
+ (e: FormEvent) => {
1141
+ e.preventDefault();
1142
+ handleSend();
1143
+ },
1144
+ [handleSend],
1145
+ );
1146
+
1147
+ return (
1148
+ <div className="bg-white rounded-2xl shadow-card p-4">
1149
+ <h3 className="text-sm font-semibold text-gray-900 mb-3">
1150
+ Send Debug Message
1151
+ </h3>
1152
+ <form onSubmit={handleSubmit} className="space-y-3">
1153
+ <div className="flex items-center gap-2">
1154
+ <label className="text-[11px] text-gray-400 font-medium">From:</label>
1155
+ <select
1156
+ value={senderType}
1157
+ onChange={(e) =>
1158
+ setSenderType(e.target.value as "user" | "debug" | "custom")
1159
+ }
1160
+ className="text-[11px] border border-gray-200 rounded-lg px-2 py-1 bg-gray-50"
1161
+ >
1162
+ <option value="user">user</option>
1163
+ <option value="debug">debug</option>
1164
+ <option value="custom">custom...</option>
1165
+ </select>
1166
+ {senderType === "custom" && (
1167
+ <input
1168
+ type="text"
1169
+ value={customSender}
1170
+ onChange={(e) => setCustomSender(e.target.value)}
1171
+ placeholder="agent ID or role"
1172
+ className="text-[11px] border border-gray-200 rounded-lg px-2 py-1 flex-1 bg-gray-50"
1173
+ />
1174
+ )}
1175
+ </div>
1176
+ <textarea
1177
+ value={content}
1178
+ onChange={(e) => setContent(e.target.value)}
1179
+ onKeyDown={handleKeyDown}
1180
+ placeholder="Message content... (Enter to send, Shift+Enter for new line)"
1181
+ rows={3}
1182
+ className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm resize-y bg-gray-50"
1183
+ />
1184
+ {error && <div className="text-[11px] text-red-600">{error}</div>}
1185
+ <button
1186
+ type="submit"
1187
+ disabled={sending || !content.trim()}
1188
+ className={`px-3 py-1.5 text-[11px] font-semibold rounded-lg transition-all ${
1189
+ content.trim()
1190
+ ? "text-white bg-accent-peri hover:brightness-110 cursor-pointer shadow-sm"
1191
+ : "text-gray-400 bg-gray-100 cursor-not-allowed"
1192
+ } disabled:opacity-50 disabled:cursor-not-allowed`}
1193
+ >
1194
+ {sending ? "Sending..." : "Send Message"}
1195
+ </button>
1196
+ </form>
1197
+ </div>
1198
+ );
1199
+ }
1200
+
1201
+ // ============================================================================
1202
+ // ResumeAgentButton
1203
+ // ============================================================================
1204
+
1205
+ function ResumeAgentButton({
1206
+ sessionId,
1207
+ agentId,
1208
+ }: {
1209
+ sessionId: string;
1210
+ agentId: string;
1211
+ }) {
1212
+ const [resuming, setResuming] = useState(false);
1213
+
1214
+ const handleResume = useCallback(
1215
+ async (e: FormEvent) => {
1216
+ e.stopPropagation();
1217
+ setResuming(true);
1218
+ try {
1219
+ unwrap(
1220
+ await api.call("agents.resume", {
1221
+ sessionId,
1222
+ agentId: AgentId(agentId),
1223
+ }),
1224
+ );
1225
+ } catch {
1226
+ // Error is visible via state change (or lack thereof)
1227
+ } finally {
1228
+ setResuming(false);
1229
+ }
1230
+ },
1231
+ [sessionId, agentId],
1232
+ );
1233
+
1234
+ return (
1235
+ <button
1236
+ type="button"
1237
+ onClick={handleResume}
1238
+ disabled={resuming}
1239
+ className="text-[11px] font-semibold text-white bg-red-600 hover:bg-red-700 px-2.5 py-1 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors shrink-0 cursor-pointer"
1240
+ >
1241
+ {resuming ? "Resuming..." : "Resume"}
1242
+ </button>
1243
+ );
1244
+ }
1245
+
1246
+ // ============================================================================
1247
+ // Badges
1248
+ // ============================================================================
1249
+
1250
+ function StatusBadge({ status }: { status: string }) {
1251
+ const styles: Record<string, string> = {
1252
+ idle: "bg-gray-100 text-gray-600",
1253
+ thinking: "bg-accent-lime/30 text-gray-700",
1254
+ responding: "bg-accent-peri/30 text-gray-700",
1255
+ waiting_for_user: "bg-purple-100 text-purple-700",
1256
+ error: "bg-red-100 text-red-700",
1257
+ };
1258
+ return (
1259
+ <span
1260
+ className={`text-[11px] px-2.5 py-1 rounded-full font-semibold ${styles[status] ?? "bg-gray-100 text-gray-600"}`}
1261
+ >
1262
+ {status}
1263
+ </span>
1264
+ );
1265
+ }
1266
+
1267
+ function ToolStatusBadge({ status }: { status: string }) {
1268
+ const styles: Record<string, string> = {
1269
+ pending: "bg-gray-100 text-gray-600",
1270
+ executing: "bg-amber-100 text-amber-700",
1271
+ completed: "bg-green-100 text-green-700",
1272
+ failed: "bg-red-100 text-red-700",
1273
+ };
1274
+ return (
1275
+ <span
1276
+ className={`text-[10px] px-2 py-0.5 rounded-full font-semibold ${styles[status] ?? "bg-gray-100 text-gray-600"}`}
1277
+ >
1278
+ {status}
1279
+ </span>
1280
+ );
1281
+ }
1282
+
1283
+ function ToolStatusDot({ status }: { status: string }) {
1284
+ const colors: Record<string, string> = {
1285
+ pending: "bg-gray-400",
1286
+ executing: "bg-amber-400 animate-pulse",
1287
+ completed: "bg-green-500",
1288
+ failed: "bg-red-500",
1289
+ };
1290
+ return (
1291
+ <div
1292
+ className={`w-2 h-2 rounded-full shrink-0 ${colors[status] ?? "bg-gray-400"}`}
1293
+ />
1294
+ );
1295
+ }