@omg-dev/admin 0.4.26 → 0.4.28

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.
package/src/react.tsx CHANGED
@@ -9,8 +9,9 @@
9
9
  // The console is admin-gated SERVER-side (every endpoint calls requireAdmin),
10
10
  // so a non-admin who somehow renders it just sees errors, never data.
11
11
 
12
- import { useCallback, useEffect, useMemo, useState, type FormEvent, type ReactElement } from "react"
12
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type FormEvent, type ReactElement, type ReactNode } from "react"
13
13
  import { ensureStyles } from "./styles"
14
+ import { safeHref } from "./safe-url"
14
15
  import {
15
16
  AdminClient,
16
17
  type AdminClientConfig,
@@ -21,6 +22,10 @@ import {
21
22
  type UserHit,
22
23
  type AdminUser,
23
24
  type UserBalance,
25
+ type ImessageBrainCapture,
26
+ type ImessageBrainTrace,
27
+ type ImessageBrainTraceStep,
28
+ type ImessageBrainMessage,
24
29
  type BugReportSummary,
25
30
  type BugReportDetail,
26
31
  type BugStatus,
@@ -638,20 +643,6 @@ function fmtTime(ms: number): string {
638
643
  }
639
644
  }
640
645
 
641
- // Bug-report URLs (pageUrl, screenshotUrl) are UNTRUSTED visitor input. Only let
642
- // http(s) reach an href/src — a stored "javascript:" / "data:" URL would be XSS
643
- // against the admin who clicks it. Returns undefined for anything else, so we
644
- // render plain text instead of a live link.
645
- function safeHref(v: unknown): string | undefined {
646
- if (typeof v !== "string") return undefined
647
- try {
648
- const u = new URL(v)
649
- return u.protocol === "http:" || u.protocol === "https:" ? v : undefined
650
- } catch {
651
- return undefined
652
- }
653
- }
654
-
655
646
  // Screenshots come from a report (untrusted). An <img src> to an arbitrary host
656
647
  // is a tracking pixel / IP-leak / cookie beacon against the admin. Only render
657
648
  // images served from our storage CDN; otherwise show nothing (the report still
@@ -841,7 +832,7 @@ export function ReportsPanel({ client }: { client: AdminClient }) {
841
832
  {detail.slug ? (
842
833
  <div>
843
834
  app:{" "}
844
- <a href={`https://omg.dev/${detail.slug}`} target="_blank" rel="noreferrer">{detail.slug}</a>
835
+ <a href={`https://app.omg.dev/${detail.slug}`} target="_blank" rel="noreferrer">{detail.slug}</a>
845
836
  {detail.version != null ? ` · v${detail.version}` : ""}
846
837
  </div>
847
838
  ) : (
@@ -864,10 +855,10 @@ export function ReportsPanel({ client }: { client: AdminClient }) {
864
855
 
865
856
  {detail.slug && (
866
857
  <div className="vadm-row" style={{ gap: 6, marginBottom: 8 }}>
867
- <a className="vadm-btn vadm-btn-sm" href={`https://omg.dev/${detail.slug}`} target="_blank" rel="noreferrer">
858
+ <a className="vadm-btn vadm-btn-sm" href={`https://app.omg.dev/${detail.slug}`} target="_blank" rel="noreferrer">
868
859
  Open app
869
860
  </a>
870
- <a className="vadm-btn vadm-btn-sm" href={`https://omg.dev/${detail.slug}/inspect/data`} target="_blank" rel="noreferrer">
861
+ <a className="vadm-btn vadm-btn-sm" href={`https://app.omg.dev/${detail.slug}/inspect/data`} target="_blank" rel="noreferrer">
871
862
  Inspect
872
863
  </a>
873
864
  </div>
@@ -934,6 +925,654 @@ export function ReportsPanel({ client }: { client: AdminClient }) {
934
925
  )
935
926
  }
936
927
 
928
+ // ── iMessage Brain routing panel ────────────────────────────────────────────
929
+
930
+ function shortId(value: string | null): string {
931
+ if (!value) return "none"
932
+ return value.length > 16 ? `${value.slice(0, 8)}…${value.slice(-5)}` : value
933
+ }
934
+
935
+ function maskedPhone(value: string): string {
936
+ const tail = value.replace(/\D/g, "").slice(-4)
937
+ return tail ? `••• ${tail}` : "unknown"
938
+ }
939
+
940
+ function formatMs(ms: number): string {
941
+ return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`
942
+ }
943
+
944
+ function shortTime(at: number): string {
945
+ const d = new Date(at)
946
+ const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
947
+ const sameDay = new Date().toDateString() === d.toDateString()
948
+ return sameDay ? time : `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${time}`
949
+ }
950
+
951
+ /** `computer.steer` → ["computer.", "steer"] so the namespace can be demoted. */
952
+ function splitDecision(decision: string): [string, string] {
953
+ const dot = decision.indexOf(".")
954
+ return dot === -1 ? ["", decision] : [decision.slice(0, dot + 1), decision.slice(dot + 1)]
955
+ }
956
+
957
+ /** routeVia is only ever "reply-reference" | "fallback"; never render null. */
958
+ function routeViaLabel(via: string | null): string | null {
959
+ if (!via) return null
960
+ return via === "reply-reference" ? "reply-ref" : via
961
+ }
962
+
963
+ function timingLine(fleetMs: number | null, llmMs: number | null, total: number | null): string {
964
+ const parts = [
965
+ total != null ? `${total}ms total` : null,
966
+ llmMs != null ? `brain ${llmMs}ms` : null,
967
+ fleetMs != null ? `fleet ${fleetMs}ms` : null,
968
+ ].filter(Boolean)
969
+ return parts.length ? parts.join(" · ") : "—"
970
+ }
971
+
972
+ // ── flow primitives ──────────────────────────────────────────────────────────
973
+ // Ported (not imported) from the docs site's <Flow> (apps/docs/app/components/
974
+ // flow.tsx): same Cloudflare-dashboard visual language — hairline connectors,
975
+ // SVG chevron, pill edge labels — expressed in `vadm-*` CSS because this
976
+ // package ships its own stylesheet and Tailwind never sees node_modules.
977
+ // Horizontal on wide viewports, collapsing to the docs' vertical trunk under
978
+ // 760px (a 100-row vertical-only list would be unusably tall).
979
+
980
+ // The chevron is the docs component's exact glyph, kept as inline SVG so it has
981
+ // no dependency and themes off currentColor; CSS rotates it per orientation.
982
+ function FlowArrow() {
983
+ return (
984
+ <svg className="vadm-flow-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true">
985
+ <path d="M6 13l6 6 6-6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
986
+ </svg>
987
+ )
988
+ }
989
+
990
+ /**
991
+ * Connector between two nodes — hairline + chevron + optional pill edge label
992
+ * (the docs Trunk). `danger` dashes it red, `running` pulses it.
993
+ */
994
+ function FlowEdge({
995
+ label,
996
+ danger,
997
+ running,
998
+ }: {
999
+ label?: string | null
1000
+ danger?: boolean
1001
+ running?: boolean
1002
+ }) {
1003
+ return (
1004
+ <div
1005
+ className="vadm-flow-edge"
1006
+ data-danger={danger ? "true" : undefined}
1007
+ data-running={running ? "true" : undefined}
1008
+ >
1009
+ {label ? <span className="vadm-flow-pill">{label}</span> : null}
1010
+ <FlowArrow />
1011
+ </div>
1012
+ )
1013
+ }
1014
+
1015
+ type FlowVariant = "bare" | "accent" | "changed" | "danger"
1016
+
1017
+ function FlowNode({
1018
+ variant,
1019
+ mono,
1020
+ href,
1021
+ children,
1022
+ sub,
1023
+ }: {
1024
+ /** Precedence is applied by the caller: danger > changed > accent. */
1025
+ variant?: FlowVariant
1026
+ mono?: boolean
1027
+ /** When set the whole node becomes the link (no separate button). */
1028
+ href?: string
1029
+ children: ReactNode
1030
+ sub?: ReactNode
1031
+ }) {
1032
+ const className = `vadm-flow-node${variant ? ` vadm-flow-node--${variant}` : ""}`
1033
+ const body = (
1034
+ <>
1035
+ <span className="vadm-flow-label" data-mono={mono ? "true" : undefined}>
1036
+ {children}
1037
+ {href ? <span className="vadm-flow-out" aria-hidden="true">↗</span> : null}
1038
+ </span>
1039
+ {sub ? <span className="vadm-flow-sub">{sub}</span> : null}
1040
+ </>
1041
+ )
1042
+ if (href) {
1043
+ return (
1044
+ <a className={className} href={href} target="_blank" rel="noreferrer">
1045
+ {body}
1046
+ </a>
1047
+ )
1048
+ }
1049
+ return <div className={className}>{body}</div>
1050
+ }
1051
+
1052
+ function BrainTraceSkeleton() {
1053
+ return (
1054
+ <div className="vadm-list" aria-label="Loading Brain routing traces">
1055
+ {Array.from({ length: 4 }).map((_, i) => (
1056
+ <div className="vadm-card vadm-brain-trace" key={i} aria-hidden="true">
1057
+ <div className="vadm-flow">
1058
+ <div className="vadm-flow-node vadm-flow-node--bare">
1059
+ <span className="vadm-skel" style={{ width: `${86 - i * 8}%` }} />
1060
+ </div>
1061
+ <FlowEdge />
1062
+ <div className="vadm-flow-node">
1063
+ <span className="vadm-skel" style={{ width: "70%" }} />
1064
+ <span className="vadm-skel" style={{ width: "88%", marginTop: 7 }} />
1065
+ </div>
1066
+ {i % 2 === 0 && (
1067
+ <>
1068
+ <FlowEdge />
1069
+ <div className="vadm-flow-node">
1070
+ <span className="vadm-skel" style={{ width: "64%" }} />
1071
+ </div>
1072
+ </>
1073
+ )}
1074
+ </div>
1075
+ </div>
1076
+ ))}
1077
+ </div>
1078
+ )
1079
+ }
1080
+
1081
+ // Routing traces fetched per user (capped 200 server-side). These annotate the
1082
+ // conversation — each inbound bubble maps to its trace for the tap-to-reveal —
1083
+ // and, unfiltered, derive the "recent conversations" chips. Messages older than
1084
+ // this window paginate in fine but lose their tappable trace.
1085
+ const BRAIN_FETCH_LIMIT = 200
1086
+ // Conversation page size: newest N first, "Load older" fetches the next N up.
1087
+ const BRAIN_TRANSCRIPT_PAGE = 30
1088
+
1089
+ /** An active user filter, sourced from either the email search or a message's
1090
+ * sender chip. `label` is what we show in the filter pill. */
1091
+ interface BrainUserFilter {
1092
+ id: string
1093
+ label: string
1094
+ }
1095
+
1096
+ export function BrainRoutingPanel({ client }: { client: AdminClient }) {
1097
+ const [traces, setTraces] = useState<ImessageBrainTrace[] | null>(null)
1098
+ // Steps for the loaded page, grouped by trace id. Fetched with the traces
1099
+ // (one cross-box hop, not one per expanded card) — see listBrainRouting's
1100
+ // `steps` flag.
1101
+ const [steps, setSteps] = useState<Map<string, ImessageBrainTraceStep[]>>(new Map())
1102
+ const [capture, setCapture] = useState<ImessageBrainCapture | null>(null)
1103
+ // Conversation, oldest-first (index 0 = oldest loaded, last = newest). Older
1104
+ // pages prepend at the front; the newest sits at the bottom of the pane.
1105
+ const [transcript, setTranscript] = useState<ImessageBrainMessage[] | null>(null)
1106
+ const [hasMore, setHasMore] = useState(false)
1107
+ const [loadingMore, setLoadingMore] = useState(false)
1108
+ const [error, setError] = useState<unknown>(null)
1109
+ const [query, setQuery] = useState("")
1110
+ const [hits, setHits] = useState<UserHit[]>([])
1111
+ const [filter, setFilter] = useState<BrainUserFilter | null>(null)
1112
+ // Single-open accordion for the conversation (keyed by message id).
1113
+ const [openMsgId, setOpenMsgId] = useState<number | null>(null)
1114
+
1115
+ // Scroll management for the reversed, paginated pane. `restoreRef` keeps the
1116
+ // viewport pinned to the same messages when older ones prepend; `bottomRef`
1117
+ // requests a scroll-to-newest after a fresh load.
1118
+ const scrollRef = useRef<HTMLDivElement | null>(null)
1119
+ const restoreRef = useRef<{ top: number; height: number } | null>(null)
1120
+ const bottomRef = useRef(false)
1121
+
1122
+ useLayoutEffect(() => {
1123
+ const el = scrollRef.current
1124
+ if (!el) return
1125
+ if (restoreRef.current) {
1126
+ el.scrollTop = restoreRef.current.top + (el.scrollHeight - restoreRef.current.height)
1127
+ restoreRef.current = null
1128
+ } else if (bottomRef.current) {
1129
+ el.scrollTop = el.scrollHeight
1130
+ bottomRef.current = false
1131
+ }
1132
+ }, [transcript])
1133
+
1134
+ const load = useCallback(async (userId?: string) => {
1135
+ setTraces(null)
1136
+ try {
1137
+ const result = await client.listImessageBrainRouting({ userId, limit: BRAIN_FETCH_LIMIT, steps: true })
1138
+ setTraces(result.traces)
1139
+ const grouped = new Map<string, ImessageBrainTraceStep[]>()
1140
+ for (const row of result.steps ?? []) {
1141
+ const list = grouped.get(row.traceId)
1142
+ if (list) list.push(row)
1143
+ else grouped.set(row.traceId, [row])
1144
+ }
1145
+ setSteps(grouped)
1146
+ setCapture(result.capture)
1147
+ setError(null)
1148
+ } catch (e) {
1149
+ setError(e)
1150
+ setTraces([])
1151
+ }
1152
+ }, [client])
1153
+
1154
+ const loadTranscript = useCallback(async (userId: string) => {
1155
+ setTranscript(null)
1156
+ setHasMore(false)
1157
+ setOpenMsgId(null)
1158
+ try {
1159
+ const result = await client.listImessageBrainTranscript({ userId, limit: BRAIN_TRANSCRIPT_PAGE })
1160
+ bottomRef.current = true // newest is at the bottom — land there
1161
+ setTranscript(result.messages)
1162
+ setHasMore(result.hasMore)
1163
+ } catch (e) {
1164
+ setError(e)
1165
+ setTranscript([])
1166
+ }
1167
+ }, [client])
1168
+
1169
+ const loadOlder = useCallback(async () => {
1170
+ if (!filter || loadingMore || !hasMore) return
1171
+ const el = scrollRef.current
1172
+ const oldest = transcript?.[0]
1173
+ if (!oldest) return
1174
+ setLoadingMore(true)
1175
+ try {
1176
+ const result = await client.listImessageBrainTranscript({
1177
+ userId: filter.id,
1178
+ limit: BRAIN_TRANSCRIPT_PAGE,
1179
+ before: oldest.id,
1180
+ })
1181
+ // Pin the viewport before the prepend so the pane doesn't jump.
1182
+ if (el) restoreRef.current = { top: el.scrollTop, height: el.scrollHeight }
1183
+ setTranscript((cur) => (cur ? [...result.messages, ...cur] : result.messages))
1184
+ setHasMore(result.hasMore)
1185
+ } catch (e) {
1186
+ setError(e)
1187
+ } finally {
1188
+ setLoadingMore(false)
1189
+ }
1190
+ }, [client, filter, hasMore, loadingMore, transcript])
1191
+
1192
+ useEffect(() => {
1193
+ ensureStyles()
1194
+ void load()
1195
+ }, [load])
1196
+
1197
+ // Email autocomplete dropdown — suppressed while a filter is pinned.
1198
+ useEffect(() => {
1199
+ if (!query.trim() || filter) {
1200
+ setHits([])
1201
+ return
1202
+ }
1203
+ let live = true
1204
+ const timer = setTimeout(async () => {
1205
+ try {
1206
+ const result = await client.findUsers(query.trim())
1207
+ if (live) setHits(result)
1208
+ } catch {
1209
+ if (live) setHits([])
1210
+ }
1211
+ }, 200)
1212
+ return () => {
1213
+ live = false
1214
+ clearTimeout(timer)
1215
+ }
1216
+ }, [client, query, filter])
1217
+
1218
+ function openConversation(next: BrainUserFilter) {
1219
+ setFilter(next)
1220
+ setQuery("")
1221
+ setHits([])
1222
+ void load(next.id) // user-scoped traces power the per-message reveal
1223
+ void loadTranscript(next.id)
1224
+ }
1225
+
1226
+ function pickUser(hit: UserHit) {
1227
+ openConversation({ id: hit.id, label: hit.name || hit.email })
1228
+ }
1229
+
1230
+ function clearFilter() {
1231
+ setFilter(null)
1232
+ setQuery("")
1233
+ setTranscript(null)
1234
+ setHasMore(false)
1235
+ void load()
1236
+ }
1237
+
1238
+ function refresh() {
1239
+ if (filter) {
1240
+ void load(filter.id)
1241
+ void loadTranscript(filter.id)
1242
+ } else {
1243
+ void load()
1244
+ }
1245
+ }
1246
+
1247
+ // Map an inbound message → its routing trace. Primary key is the provider
1248
+ // message id (trace.inboundMessageId); text is a fallback for older rows
1249
+ // captured before the id was recorded.
1250
+ const traceByMsgId = useMemo(() => {
1251
+ const m = new Map<string, ImessageBrainTrace>()
1252
+ for (const t of traces ?? []) if (t.inboundMessageId) m.set(t.inboundMessageId, t)
1253
+ return m
1254
+ }, [traces])
1255
+ const traceByText = useMemo(() => {
1256
+ const m = new Map<string, ImessageBrainTrace>()
1257
+ for (const t of traces ?? []) m.set(t.text, t) // last (most recent) wins
1258
+ return m
1259
+ }, [traces])
1260
+ function traceForMessage(msg: ImessageBrainMessage): ImessageBrainTrace | null {
1261
+ if (msg.direction !== "in") return null
1262
+ return (msg.providerMessageId ? traceByMsgId.get(msg.providerMessageId) : undefined)
1263
+ ?? traceByText.get(msg.text)
1264
+ ?? null
1265
+ }
1266
+
1267
+ // "Recent conversations" chips: distinct senders in the loaded window, busiest
1268
+ // first. This is the entry point into a user's conversation when unfiltered.
1269
+ const senders = useMemo(() => {
1270
+ if (!traces) return []
1271
+ const m = new Map<string, { userId: string; phone: string; count: number }>()
1272
+ for (const t of traces) {
1273
+ const e = m.get(t.userId)
1274
+ if (e) e.count++
1275
+ else m.set(t.userId, { userId: t.userId, phone: t.phone, count: 1 })
1276
+ }
1277
+ return [...m.values()].sort((a, b) => b.count - a.count)
1278
+ }, [traces])
1279
+
1280
+ return (
1281
+ <div>
1282
+ <div className="vadm-spread vadm-panel-head">
1283
+ <div>
1284
+ <p className="vadm-h">Chat Brain conversations</p>
1285
+ <p className="vadm-sub" style={{ margin: 0 }}>
1286
+ {filter
1287
+ ? "Their messages and our replies — tap any message to reveal the backend routing behind it."
1288
+ : "Pick a conversation to see the full thread and the routing behind each message."}
1289
+ </p>
1290
+ </div>
1291
+ <button className="vadm-btn vadm-btn-sm" onClick={refresh}>Refresh</button>
1292
+ </div>
1293
+
1294
+ {filter ? (
1295
+ <div className="vadm-filter-pill">
1296
+ <span className="vadm-muted">Conversation with</span>
1297
+ <strong>{filter.label}</strong>
1298
+ <button className="vadm-btn vadm-btn-sm" onClick={clearFilter}>← All</button>
1299
+ </div>
1300
+ ) : (
1301
+ <>
1302
+ <div className="vadm-search" style={{ position: "relative", marginBottom: 12 }}>
1303
+ <input
1304
+ className="vadm-input"
1305
+ placeholder="Find by account email…"
1306
+ value={query}
1307
+ onChange={(e) => setQuery(e.target.value)}
1308
+ />
1309
+ {hits.length > 0 && (
1310
+ <div className="vadm-card" style={{ position: "absolute", zIndex: 5, top: 38, left: 0, right: 0, padding: 4 }}>
1311
+ {hits.map((hit) => (
1312
+ <button key={hit.id} className="vadm-user-row" onClick={() => pickUser(hit)}>
1313
+ <UserAvatar user={hit} />
1314
+ <span className="vadm-grow">
1315
+ <span style={{ display: "block", fontWeight: 600 }}>{hit.name || hit.email}</span>
1316
+ <span className="vadm-muted" style={{ fontSize: 12 }}>{hit.email}</span>
1317
+ </span>
1318
+ </button>
1319
+ ))}
1320
+ </div>
1321
+ )}
1322
+ </div>
1323
+ {senders.length > 0 && (
1324
+ <div className="vadm-userchips">
1325
+ <span className="vadm-muted" style={{ fontSize: 12 }}>Recent:</span>
1326
+ {senders.slice(0, 8).map((s) => (
1327
+ <button
1328
+ key={s.userId}
1329
+ className="vadm-userchip"
1330
+ onClick={() => openConversation({ id: s.userId, label: maskedPhone(s.phone) })}
1331
+ >
1332
+ {maskedPhone(s.phone)}
1333
+ <span className="vadm-userchip-count">{s.count}</span>
1334
+ </button>
1335
+ ))}
1336
+ </div>
1337
+ )}
1338
+ </>
1339
+ )}
1340
+
1341
+ <ErrorBanner error={error} />
1342
+ {filter ? (
1343
+ transcript === null ? (
1344
+ <BrainTraceSkeleton />
1345
+ ) : transcript.length === 0 ? (
1346
+ <div className="vadm-empty">
1347
+ <strong style={{ display: "block", color: "var(--foreground)", marginBottom: 5 }}>
1348
+ No messages yet
1349
+ </strong>
1350
+ This account has no iMessage history with the line.
1351
+ </div>
1352
+ ) : (
1353
+ <div className="vadm-conv" ref={scrollRef}>
1354
+ {hasMore && (
1355
+ <div className="vadm-conv-more">
1356
+ <button className="vadm-btn vadm-btn-sm" onClick={loadOlder} disabled={loadingMore}>
1357
+ {loadingMore ? "Loading…" : "Load older messages"}
1358
+ </button>
1359
+ </div>
1360
+ )}
1361
+ {transcript.map((msg) => {
1362
+ const inbound = msg.direction === "in"
1363
+ const trace = traceForMessage(msg)
1364
+ const open = openMsgId === msg.id
1365
+ return (
1366
+ <div className={`vadm-bubble-row vadm-bubble-row--${inbound ? "in" : "out"}`} key={msg.id}>
1367
+ <button
1368
+ type="button"
1369
+ className="vadm-bubble"
1370
+ data-dir={inbound ? "in" : "out"}
1371
+ data-open={open ? "true" : undefined}
1372
+ data-has-trace={trace ? "true" : undefined}
1373
+ aria-expanded={trace ? open : undefined}
1374
+ onClick={trace ? () => setOpenMsgId(open ? null : msg.id) : undefined}
1375
+ >
1376
+ {msg.media && msg.media.length > 0 && (
1377
+ <span className="vadm-bubble-media">
1378
+ {msg.media.map((url) =>
1379
+ /\.(mp4|mov|webm)(?:$|\?)/i.test(url) ? (
1380
+ <video key={url} className="vadm-bubble-att" src={url} controls preload="metadata" />
1381
+ ) : (
1382
+ <img key={url} className="vadm-bubble-att" src={url} alt="attachment" loading="lazy" />
1383
+ ),
1384
+ )}
1385
+ </span>
1386
+ )}
1387
+ {msg.text && <span className="vadm-bubble-text">{msg.text}</span>}
1388
+ <span className="vadm-bubble-meta">
1389
+ <span>{inbound ? "them" : "us"}</span>
1390
+ <span aria-hidden="true">·</span>
1391
+ <time dateTime={new Date(msg.at).toISOString()}>{shortTime(msg.at)}</time>
1392
+ {trace && <span className="vadm-bubble-dot" data-outcome={trace.outcome} aria-hidden="true" />}
1393
+ </span>
1394
+ </button>
1395
+ {open && trace && (
1396
+ <div className="vadm-bubble-detail">
1397
+ <TraceRouting trace={trace} steps={steps.get(trace.id) ?? []} />
1398
+ </div>
1399
+ )}
1400
+ </div>
1401
+ )
1402
+ })}
1403
+ </div>
1404
+ )
1405
+ ) : traces === null ? (
1406
+ <BrainTraceSkeleton />
1407
+ ) : senders.length === 0 ? (
1408
+ <div className="vadm-empty">
1409
+ <strong style={{ display: "block", color: "var(--foreground)", marginBottom: 5 }}>
1410
+ No conversations yet
1411
+ </strong>
1412
+ No inbound iMessage has arrived since capture started. Send the line a message, then refresh.
1413
+ {capture?.gatewayStartedAt && (
1414
+ <span style={{ display: "block", marginTop: 7, fontSize: 12 }}>
1415
+ Gateway online since {new Date(capture.gatewayStartedAt).toLocaleString()}
1416
+ </span>
1417
+ )}
1418
+ </div>
1419
+ ) : null}
1420
+ </div>
1421
+ )
1422
+ }
1423
+
1424
+ /** The routing story for one message: the flow graph + error + detail grid.
1425
+ * Rendered inline when a message row is expanded. */
1426
+ /** Kind → label + accent. Rules read as ✓/· in their own name (set by the
1427
+ * gateway), so the badge only needs to name the category. */
1428
+ const STEP_KIND_LABEL: Record<ImessageBrainTraceStep["kind"], string> = {
1429
+ rule: "rule",
1430
+ context: "context",
1431
+ prompt: "prompt",
1432
+ "llm-text": "model",
1433
+ "tool-call": "tool →",
1434
+ "tool-result": "← result",
1435
+ effect: "effect",
1436
+ }
1437
+
1438
+ /**
1439
+ * The turn, step by step: which deterministic rules were evaluated (and which
1440
+ * declined), the prompt blocks the model was given, every tool call with its
1441
+ * arguments, every result, and the effects committed.
1442
+ *
1443
+ * This is the panel's answer to the operator complaint that motivated it —
1444
+ * "we don't actually see the actual routing logic … what tool calling is
1445
+ * happening behind the scene" (2026-07-25). Before this, the only thing
1446
+ * recorded about a model turn was its duration.
1447
+ *
1448
+ * Long payloads (a prompt snapshot is thousands of chars) collapse to a
1449
+ * summary line and expand on click, so the common case stays scannable.
1450
+ */
1451
+ function TraceSteps({ steps }: { steps: ImessageBrainTraceStep[] }) {
1452
+ const [openSeq, setOpenSeq] = useState<number | null>(null)
1453
+ if (!steps.length) return null
1454
+ const t0 = steps[0]!.at
1455
+ return (
1456
+ <div style={{ marginTop: 12, paddingTop: 10, borderTop: "1px dashed var(--vadm-border)" }}>
1457
+ <div style={{ fontSize: 11, opacity: 0.6, marginBottom: 6 }}>
1458
+ {steps.length} step{steps.length === 1 ? "" : "s"} — tap one to see its payload
1459
+ </div>
1460
+ {steps.map((step) => {
1461
+ const open = openSeq === step.seq
1462
+ const detail = step.detail ?? ""
1463
+ // A rule's rationale is short and IS the point, so show it inline
1464
+ // rather than hiding the one thing the operator came here to read.
1465
+ const inline = step.kind === "rule" || detail.length <= 120
1466
+ const firstLine = detail.split("\n")[0] ?? ""
1467
+ return (
1468
+ <div key={step.seq} style={{ borderTop: "1px solid var(--vadm-border)", padding: "5px 0" }}>
1469
+ <button
1470
+ type="button"
1471
+ onClick={() => setOpenSeq(open ? null : step.seq)}
1472
+ style={{
1473
+ display: "flex",
1474
+ gap: 8,
1475
+ alignItems: "baseline",
1476
+ width: "100%",
1477
+ background: "none",
1478
+ border: "none",
1479
+ padding: 0,
1480
+ textAlign: "left",
1481
+ cursor: detail ? "pointer" : "default",
1482
+ color: "inherit",
1483
+ font: "inherit",
1484
+ }}
1485
+ >
1486
+ <code style={{ fontSize: 10, opacity: 0.5, minWidth: 44 }}>+{formatMs(step.at - t0)}</code>
1487
+ <span style={{ fontSize: 10, opacity: 0.6, minWidth: 58 }}>{STEP_KIND_LABEL[step.kind]}</span>
1488
+ <strong style={{ fontSize: 12 }}>{step.name ?? ""}</strong>
1489
+ {step.durationMs != null && (
1490
+ <span style={{ fontSize: 10, opacity: 0.5 }}>{formatMs(step.durationMs)}</span>
1491
+ )}
1492
+ {!open && !inline && firstLine && (
1493
+ <span style={{ fontSize: 11, opacity: 0.55, overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis" }}>
1494
+ {firstLine}
1495
+ </span>
1496
+ )}
1497
+ </button>
1498
+ {(open || inline) && detail && (
1499
+ <pre
1500
+ style={{
1501
+ margin: "4px 0 0 52px",
1502
+ fontSize: 11,
1503
+ whiteSpace: "pre-wrap",
1504
+ wordBreak: "break-word",
1505
+ opacity: 0.8,
1506
+ maxHeight: open ? 420 : undefined,
1507
+ overflow: open ? "auto" : undefined,
1508
+ }}
1509
+ >
1510
+ {detail}
1511
+ </pre>
1512
+ )}
1513
+ </div>
1514
+ )
1515
+ })}
1516
+ </div>
1517
+ )
1518
+ }
1519
+
1520
+ function TraceRouting({ trace, steps }: { trace: ImessageBrainTrace; steps: ImessageBrainTraceStep[] }) {
1521
+ const failed = trace.outcome === "failed"
1522
+ const running = trace.outcome === "running"
1523
+ const sessionChanged = trace.activeSessionBefore !== trace.activeSessionAfter
1524
+ const duration = trace.completedAt ? trace.completedAt - trace.createdAt : null
1525
+ const targetHref = safeHref(trace.targetSessionUrl)
1526
+ // Graph length IS the story: a decision the brain handled inline
1527
+ // (protocol.quiet, brain.error) never grows the third node.
1528
+ const routed = trace.targetSessionId != null
1529
+ const [decisionPrefix, decisionVerb] = splitDecision(trace.decision)
1530
+ return (
1531
+ <>
1532
+ <div className="vadm-flow">
1533
+ <FlowNode variant="bare">
1534
+ <span className="vadm-flow-quote">{trace.text}</span>
1535
+ </FlowNode>
1536
+ <FlowEdge label={routeViaLabel(trace.routeVia)} />
1537
+ <FlowNode
1538
+ variant={failed ? "danger" : trace.decision.startsWith("computer.") ? "accent" : undefined}
1539
+ sub={trace.reason ? <span title={trace.reason}>{trace.reason}</span> : null}
1540
+ >
1541
+ {decisionPrefix && <span className="vadm-flow-pre">{decisionPrefix}</span>}
1542
+ {decisionVerb}
1543
+ </FlowNode>
1544
+ {routed && (
1545
+ <>
1546
+ <FlowEdge label={running ? null : duration != null ? formatMs(duration) : null} danger={failed} running={running} />
1547
+ <FlowNode
1548
+ variant={sessionChanged ? "changed" : undefined}
1549
+ mono
1550
+ href={targetHref ?? undefined}
1551
+ sub={sessionChanged ? `was ${shortId(trace.activeSessionBefore)}` : null}
1552
+ >
1553
+ {shortId(trace.targetSessionId)}
1554
+ </FlowNode>
1555
+ </>
1556
+ )}
1557
+ </div>
1558
+
1559
+ {trace.error && <div className="vadm-err vadm-flow-error">{trace.error}</div>}
1560
+
1561
+ <div className="vadm-kv" style={{ marginTop: 12, paddingTop: 10, borderTop: "1px dashed var(--vadm-border)" }}>
1562
+ <div><span>Generation</span><strong>{trace.generation}</strong></div>
1563
+ <div><span>From</span><strong>{maskedPhone(trace.phone)}</strong></div>
1564
+ <div><span>Reply to</span><strong><code>{shortId(trace.replyToMessageId)}</code></strong></div>
1565
+ <div><span>Timing</span><strong>{timingLine(trace.fleetMs, trace.llmMs, duration)}</strong></div>
1566
+ <div><span>Session</span><strong><code>{shortId(trace.activeSessionBefore)} → {shortId(trace.activeSessionAfter)}</code></strong></div>
1567
+ <div><span>Message</span><strong>{trace.text}</strong></div>
1568
+ {trace.reason && <div><span>Reason</span><strong>{trace.reason}</strong></div>}
1569
+ </div>
1570
+
1571
+ <TraceSteps steps={steps} />
1572
+ </>
1573
+ )
1574
+ }
1575
+
937
1576
  // ── Console (tab shell) ──────────────────────────────────────────────────────
938
1577
 
939
1578
  export interface PanelDef {
@@ -943,6 +1582,7 @@ export interface PanelDef {
943
1582
  }
944
1583
 
945
1584
  const DEFAULT_PANELS: PanelDef[] = [
1585
+ { id: "brain", label: "Chat Brain", render: (c) => <BrainRoutingPanel client={c} /> },
946
1586
  { id: "flags", label: "Flags", render: (c) => <FlagsPanel client={c} /> },
947
1587
  { id: "users", label: "Users", render: (c) => <UsersPanel client={c} /> },
948
1588
  { id: "reports", label: "Reports", render: (c) => <ReportsPanel client={c} /> },