tanuki-telemetry 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/tanuki.mjs CHANGED
@@ -183,6 +183,9 @@ function buildImages() {
183
183
  }
184
184
 
185
185
  function startDashboard() {
186
+ // Kill all tanuki containers for clean slate (idempotent setup)
187
+ run("docker rm -f tanuki-mcp 2>/dev/null || true", { quiet: true, ignoreError: true });
188
+ run("docker rm -f telemetry-mcp 2>/dev/null || true", { quiet: true, ignoreError: true });
186
189
  run("docker rm -f tanuki-dashboard 2>/dev/null || true", { quiet: true, ignoreError: true });
187
190
  run("docker rm -f telemetry-dashboard 2>/dev/null || true", { quiet: true, ignoreError: true });
188
191
  run(`docker run -d --rm --name tanuki-dashboard -p ${PORT}:3333 -v "${DATA_DIR}:/data" tanuki-dashboard:latest`, { quiet: true });
@@ -1,70 +1,77 @@
1
- import { useState, useEffect, useRef } from "react"
2
- import gsap from "gsap"
3
- import { Header } from "@/components/Header"
4
- import { SessionList } from "@/components/SessionList"
5
- import { SessionDetail } from "@/components/SessionDetail"
6
- import { KnowledgePage } from "@/components/KnowledgePage"
7
- import { CoordinatorPage } from "@/components/CoordinatorPage"
8
- import { LoginPage } from "@/components/LoginPage"
9
- import { SettingsPage } from "@/components/SettingsPage"
10
- import { WalkthroughPage } from "@/components/WalkthroughPage"
11
- import { useAuth } from "@/hooks/useAuth"
12
- import { useStats, useSessions, useSessionDetail } from "@/hooks/useApi"
13
- import { useWebSocket } from "@/hooks/useWebSocket"
14
- import { cn } from "@/lib/utils"
15
-
16
- type Tab = "sessions" | "knowledge" | "coordinator" | "walkthroughs" | "settings"
1
+ import { useState, useEffect, useRef } from "react";
2
+ import gsap from "gsap";
3
+ import { Header } from "@/components/Header";
4
+ import { SessionList } from "@/components/SessionList";
5
+ import { SessionDetail } from "@/components/SessionDetail";
6
+ import { KnowledgePage } from "@/components/KnowledgePage";
7
+ import { CoordinatorPage } from "@/components/CoordinatorPage";
8
+ import { LoginPage } from "@/components/LoginPage";
9
+ import { SettingsPage } from "@/components/SettingsPage";
10
+ import { WalkthroughPage } from "@/components/WalkthroughPage";
11
+ import { DemosPage } from "@/components/DemosPage";
12
+ import { useAuth } from "@/hooks/useAuth";
13
+ import { useStats, useSessions, useSessionDetail } from "@/hooks/useApi";
14
+ import { useWebSocket } from "@/hooks/useWebSocket";
15
+ import { cn } from "@/lib/utils";
16
+
17
+ type Tab =
18
+ | "sessions"
19
+ | "knowledge"
20
+ | "coordinator"
21
+ | "walkthroughs"
22
+ | "demos"
23
+ | "settings";
17
24
 
18
25
  export default function App() {
19
- const { user, loading: authLoading, authEnabled } = useAuth()
20
- const [tab, setTab] = useState<Tab>("coordinator")
21
- const [filter, setFilter] = useState("")
22
- const [activeId, setActiveId] = useState<string | null>(null)
23
- const [parentId, setParentId] = useState<string | null>(null)
26
+ const { user, loading: authLoading, authEnabled } = useAuth();
27
+ const [tab, setTab] = useState<Tab>("coordinator");
28
+ const [filter, setFilter] = useState("");
29
+ const [activeId, setActiveId] = useState<string | null>(null);
30
+ const [parentId, setParentId] = useState<string | null>(null);
24
31
 
25
- const tabBarRef = useRef<HTMLDivElement>(null)
32
+ const tabBarRef = useRef<HTMLDivElement>(null);
26
33
 
27
- const { stats, reload: reloadStats } = useStats()
28
- const { sessions, reload: reloadSessions } = useSessions(filter)
29
- const { detail, loading, reload: reloadDetail } = useSessionDetail(activeId)
30
- const { status: wsStatus, messages } = useWebSocket()
34
+ const { stats, reload: reloadStats } = useStats();
35
+ const { sessions, reload: reloadSessions } = useSessions(filter);
36
+ const { detail, loading, reload: reloadDetail } = useSessionDetail(activeId);
37
+ const { status: wsStatus, messages } = useWebSocket();
31
38
 
32
39
  // Staggered fade-in for tab bar
33
40
  useEffect(() => {
34
- if (!tabBarRef.current) return
41
+ if (!tabBarRef.current) return;
35
42
  gsap.fromTo(
36
43
  tabBarRef.current.children,
37
44
  { opacity: 0 },
38
- { opacity: 1, duration: 0.3, stagger: 0.06, ease: "power2.out" }
39
- )
40
- }, [])
45
+ { opacity: 1, duration: 0.3, stagger: 0.06, ease: "power2.out" },
46
+ );
47
+ }, []);
41
48
 
42
49
  // Navigate to a child session, remembering the parent
43
50
  const handleSelectSession = (id: string) => {
44
51
  if (detail?.session) {
45
- setParentId(detail.session.id)
52
+ setParentId(detail.session.id);
46
53
  }
47
- setActiveId(id)
48
- }
54
+ setActiveId(id);
55
+ };
49
56
 
50
57
  // Navigate back to parent
51
58
  const handleBackToParent = () => {
52
59
  if (parentId) {
53
- setActiveId(parentId)
54
- setParentId(null)
60
+ setActiveId(parentId);
61
+ setParentId(null);
55
62
  } else if (detail?.session?.parent_session_id) {
56
- setActiveId(detail.session.parent_session_id)
63
+ setActiveId(detail.session.parent_session_id);
57
64
  }
58
- }
65
+ };
59
66
 
60
67
  // React to WebSocket messages
61
68
  useEffect(() => {
62
- if (messages.length === 0) return
63
- const latest = messages[messages.length - 1]
69
+ if (messages.length === 0) return;
70
+ const latest = messages[messages.length - 1];
64
71
 
65
72
  if (latest.type === "session_new" || latest.type === "session_update") {
66
- reloadSessions()
67
- reloadStats()
73
+ reloadSessions();
74
+ reloadStats();
68
75
  }
69
76
 
70
77
  if (activeId) {
@@ -74,68 +81,104 @@ export default function App() {
74
81
  (latest.type === "screenshot" && latest.data.session_id === activeId) ||
75
82
  (latest.type === "artifact" && latest.data.session_id === activeId) ||
76
83
  (latest.type === "insight" && latest.data.session_id === activeId) ||
77
- (latest.type === "plan_step_new" && latest.data.session_id === activeId) ||
78
- (latest.type === "plan_step_update" && latest.data.session_id === activeId) ||
79
- (latest.type === "session_update" && latest.data.id === activeId)
84
+ (latest.type === "plan_step_new" &&
85
+ latest.data.session_id === activeId) ||
86
+ (latest.type === "plan_step_update" &&
87
+ latest.data.session_id === activeId) ||
88
+ (latest.type === "session_update" && latest.data.id === activeId);
80
89
 
81
90
  if (affectsActive) {
82
- reloadDetail()
91
+ reloadDetail();
83
92
  }
84
93
 
85
94
  // If a child session of the active session updates, also refresh
86
95
  if (detail?.child_sessions && detail.child_sessions.length > 0) {
87
- const childIds = new Set(detail.child_sessions.map((c) => c.id))
96
+ const childIds = new Set(detail.child_sessions.map((c) => c.id));
88
97
  const affectsChild =
89
98
  (latest.type === "session_update" && childIds.has(latest.data.id)) ||
90
- (latest.type === "session_new" && latest.data.parent_session_id === activeId)
99
+ (latest.type === "session_new" &&
100
+ latest.data.parent_session_id === activeId);
91
101
  if (affectsChild) {
92
- reloadDetail()
102
+ reloadDetail();
93
103
  }
94
104
  }
95
105
  }
96
- }, [messages, activeId, reloadSessions, reloadStats, reloadDetail, detail?.child_sessions])
106
+ }, [
107
+ messages,
108
+ activeId,
109
+ reloadSessions,
110
+ reloadStats,
111
+ reloadDetail,
112
+ detail?.child_sessions,
113
+ ]);
97
114
 
98
115
  // Auth loading state
99
116
  if (authLoading) {
100
117
  return (
101
118
  <div className="h-screen flex items-center justify-center bg-bg">
102
- <div className="text-accent text-xs font-mono animate-pulse">INITIALIZING...</div>
119
+ <div className="text-accent text-xs font-mono animate-pulse">
120
+ INITIALIZING...
121
+ </div>
103
122
  </div>
104
- )
123
+ );
105
124
  }
106
125
 
107
126
  // Auth gate — if auth is enabled and user is not logged in, show login page
108
127
  if (authEnabled && !user) {
109
- return <LoginPage />
128
+ return <LoginPage />;
110
129
  }
111
130
 
112
131
  return (
113
132
  <div className="h-screen flex flex-col overflow-hidden bg-bg">
114
133
  {/* Scanline overlay */}
115
134
  <div className="pointer-events-none fixed inset-0 z-[100] opacity-[0.03]">
116
- <div className="w-full h-px bg-accent" style={{
117
- animation: "scanline 8s linear infinite"
118
- }} />
135
+ <div
136
+ className="w-full h-px bg-accent"
137
+ style={{
138
+ animation: "scanline 8s linear infinite",
139
+ }}
140
+ />
119
141
  </div>
120
142
 
121
143
  <Header stats={stats} />
122
144
 
123
145
  {/* Tab bar */}
124
- <div ref={tabBarRef} className="border-b border-border px-5 flex items-center gap-1 bg-bg flex-shrink-0">
125
- <TabButton active={tab === "coordinator"} onClick={() => setTab("coordinator")}>
146
+ <div
147
+ ref={tabBarRef}
148
+ className="border-b border-border px-5 flex items-center gap-1 bg-bg flex-shrink-0"
149
+ >
150
+ <TabButton
151
+ active={tab === "coordinator"}
152
+ onClick={() => setTab("coordinator")}
153
+ >
126
154
  COORDINATOR
127
155
  </TabButton>
128
- <TabButton active={tab === "sessions"} onClick={() => setTab("sessions")}>
156
+ <TabButton
157
+ active={tab === "sessions"}
158
+ onClick={() => setTab("sessions")}
159
+ >
129
160
  SESSIONS
130
161
  </TabButton>
131
- <TabButton active={tab === "knowledge"} onClick={() => setTab("knowledge")}>
162
+ <TabButton
163
+ active={tab === "knowledge"}
164
+ onClick={() => setTab("knowledge")}
165
+ >
132
166
  KNOWLEDGE
133
167
  </TabButton>
134
- <TabButton active={tab === "walkthroughs"} onClick={() => setTab("walkthroughs")}>
168
+ <TabButton
169
+ active={tab === "walkthroughs"}
170
+ onClick={() => setTab("walkthroughs")}
171
+ >
135
172
  WALKTHROUGHS
136
173
  </TabButton>
174
+ <TabButton active={tab === "demos"} onClick={() => setTab("demos")}>
175
+ DEMOS
176
+ </TabButton>
137
177
  {authEnabled && (
138
- <TabButton active={tab === "settings"} onClick={() => setTab("settings")}>
178
+ <TabButton
179
+ active={tab === "settings"}
180
+ onClick={() => setTab("settings")}
181
+ >
139
182
  SETTINGS
140
183
  </TabButton>
141
184
  )}
@@ -151,8 +194,8 @@ export default function App() {
151
194
  activeId={activeId}
152
195
  filter={filter}
153
196
  onSelect={(id) => {
154
- setParentId(null)
155
- setActiveId(id)
197
+ setParentId(null);
198
+ setActiveId(id);
156
199
  }}
157
200
  onFilterChange={setFilter}
158
201
  />
@@ -179,6 +222,8 @@ export default function App() {
179
222
  <KnowledgePage wsMessages={messages} />
180
223
  ) : tab === "walkthroughs" ? (
181
224
  <WalkthroughPage />
225
+ ) : tab === "demos" ? (
226
+ <DemosPage />
182
227
  ) : tab === "settings" ? (
183
228
  <SettingsPage user={user} />
184
229
  ) : null}
@@ -196,14 +241,23 @@ export default function App() {
196
241
  </>
197
242
  )}
198
243
  <span className="flex items-center gap-1.5">
199
- <span className={cn("inline-block w-1.5 h-1.5", wsStatus === "connected" ? "bg-accent" : wsStatus === "connecting" ? "bg-warning" : "bg-error")} />
244
+ <span
245
+ className={cn(
246
+ "inline-block w-1.5 h-1.5",
247
+ wsStatus === "connected"
248
+ ? "bg-accent"
249
+ : wsStatus === "connecting"
250
+ ? "bg-warning"
251
+ : "bg-error",
252
+ )}
253
+ />
200
254
  </span>
201
255
  <span className="text-border-bright">|</span>
202
256
  {sessions.length} sessions
203
257
  </span>
204
258
  </div>
205
259
  </div>
206
- )
260
+ );
207
261
  }
208
262
 
209
263
  function TabButton({
@@ -211,9 +265,9 @@ function TabButton({
211
265
  onClick,
212
266
  children,
213
267
  }: {
214
- active: boolean
215
- onClick: () => void
216
- children: React.ReactNode
268
+ active: boolean;
269
+ onClick: () => void;
270
+ children: React.ReactNode;
217
271
  }) {
218
272
  return (
219
273
  <button
@@ -222,11 +276,10 @@ function TabButton({
222
276
  "px-3 py-2 text-[10px] font-bold uppercase tracking-widest border-b-2 transition-colors duration-200",
223
277
  active
224
278
  ? "border-b-accent text-accent"
225
- : "border-b-transparent text-text-dim hover:text-text-muted"
279
+ : "border-b-transparent text-text-dim hover:text-text-muted",
226
280
  )}
227
281
  >
228
282
  {children}
229
283
  </button>
230
- )
284
+ );
231
285
  }
232
-
@@ -0,0 +1,297 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+ import gsap from "gsap";
3
+ import { Play, Film, Clock, HardDrive, Calendar } from "lucide-react";
4
+ import type { Artifact } from "@/types";
5
+ import { cn, timeAgo } from "@/lib/utils";
6
+
7
+ function formatSize(bytes: number | null): string {
8
+ if (bytes == null) return "";
9
+ if (bytes < 1024) return `${bytes} B`;
10
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
11
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
12
+ }
13
+
14
+ function artifactUrl(id: number): string {
15
+ return `/api/artifacts/by-id/${id}`;
16
+ }
17
+
18
+ function isVideo(a: Artifact): boolean {
19
+ const mime = a.mime_type || "";
20
+ const ext = a.file_path?.split(".").pop()?.toLowerCase() || "";
21
+ return mime.startsWith("video/") || ["mp4", "webm", "mov"].includes(ext);
22
+ }
23
+
24
+ export function DemosPage() {
25
+ const [demos, setDemos] = useState<Artifact[]>([]);
26
+ const [activeId, setActiveId] = useState<number | null>(null);
27
+ const [loading, setLoading] = useState(true);
28
+ const sidebarRef = useRef<HTMLDivElement>(null);
29
+ const sidebarAnimated = useRef(false);
30
+
31
+ useEffect(() => {
32
+ fetch("/api/artifacts?type=demo,recording")
33
+ .then((r) => r.json())
34
+ .then((data) => {
35
+ const items = data.artifacts || [];
36
+ setDemos(items);
37
+ if (items.length > 0) {
38
+ setActiveId(items[0].id);
39
+ }
40
+ setLoading(false);
41
+ })
42
+ .catch(() => setLoading(false));
43
+ }, []);
44
+
45
+ // Animate sidebar entries on first load
46
+ useEffect(() => {
47
+ if (!sidebarRef.current || sidebarAnimated.current || demos.length === 0)
48
+ return;
49
+ sidebarAnimated.current = true;
50
+ const items = sidebarRef.current.querySelectorAll("[data-demo-item]");
51
+ gsap.fromTo(
52
+ items,
53
+ { opacity: 0, x: -8 },
54
+ { opacity: 1, x: 0, duration: 0.25, stagger: 0.03, ease: "power2.out" },
55
+ );
56
+ }, [demos.length > 0]);
57
+
58
+ const activeDemo = demos.find((d) => d.id === activeId) || null;
59
+
60
+ return (
61
+ <div className="flex flex-1 overflow-hidden">
62
+ {/* Left sidebar — demo list */}
63
+ <div
64
+ ref={sidebarRef}
65
+ className="w-[300px] min-w-[260px] border-r border-border flex flex-col overflow-hidden flex-shrink-0 bg-bg"
66
+ >
67
+ <div className="px-4 h-9 border-b border-border flex items-center justify-between sticky top-0 bg-bg z-10 flex-shrink-0">
68
+ <span className="text-text-dim text-[10px]">
69
+ <span className="text-accent">$</span> demos/
70
+ </span>
71
+ <span className="text-[10px] text-text-dim">
72
+ {demos.length} recordings
73
+ </span>
74
+ </div>
75
+
76
+ <div className="flex-1 overflow-y-auto">
77
+ {loading ? (
78
+ <div className="p-4 text-xs text-accent animate-pulse">
79
+ Loading...
80
+ </div>
81
+ ) : demos.length === 0 ? (
82
+ <div className="p-4 text-xs text-text-dim">
83
+ No demos or recordings yet.
84
+ <div className="mt-2 text-[10px]">
85
+ Use <code className="text-accent">/demo</code> to record one, or{" "}
86
+ <code className="text-accent">/record</code> for manual
87
+ recording.
88
+ </div>
89
+ </div>
90
+ ) : (
91
+ demos.map((demo) => (
92
+ <button
93
+ key={demo.id}
94
+ data-demo-item
95
+ onClick={() => setActiveId(demo.id)}
96
+ className={cn(
97
+ "w-full text-left px-4 py-2.5 border-b border-border transition-colors cursor-pointer",
98
+ activeId === demo.id
99
+ ? "bg-bg-elevated border-l-2 border-l-accent"
100
+ : "hover:bg-bg-elevated border-l-2 border-l-transparent",
101
+ )}
102
+ >
103
+ <div className="flex items-center gap-2">
104
+ <span
105
+ className={cn(
106
+ "text-[10px] font-bold px-1.5 py-0.5 rounded",
107
+ demo.artifact_type === "demo"
108
+ ? "bg-accent/15 text-accent"
109
+ : "bg-info/15 text-info",
110
+ )}
111
+ >
112
+ {demo.artifact_type === "demo" ? "DEMO" : "REC"}
113
+ </span>
114
+ <span className="text-xs text-text truncate font-mono">
115
+ {demo.description || `Recording #${demo.id}`}
116
+ </span>
117
+ </div>
118
+ <div className="flex items-center gap-2 mt-1 text-[10px] text-text-muted">
119
+ <span>{timeAgo(demo.timestamp)}</span>
120
+ {demo.file_size && (
121
+ <>
122
+ <span className="text-border-bright">|</span>
123
+ <span>{formatSize(demo.file_size)}</span>
124
+ </>
125
+ )}
126
+ </div>
127
+ </button>
128
+ ))
129
+ )}
130
+ </div>
131
+ </div>
132
+
133
+ {/* Right panel — video player */}
134
+ <div className="flex-1 overflow-y-auto">
135
+ {activeDemo ? (
136
+ <DemoDetail demo={activeDemo} />
137
+ ) : (
138
+ <div className="h-full flex items-center justify-center text-text-dim text-xs">
139
+ {loading ? (
140
+ <span className="text-accent animate-pulse">Loading...</span>
141
+ ) : demos.length === 0 ? (
142
+ <div className="text-center">
143
+ <Film className="mx-auto mb-3 text-text-dim" size={32} />
144
+ <div>No demos recorded yet</div>
145
+ <div className="text-[10px] mt-1">
146
+ Run <code className="text-accent">/demo</code> to create one
147
+ </div>
148
+ </div>
149
+ ) : (
150
+ "Select a demo"
151
+ )}
152
+ </div>
153
+ )}
154
+ </div>
155
+ </div>
156
+ );
157
+ }
158
+
159
+ function DemoDetail({ demo }: { demo: Artifact }) {
160
+ const detailRef = useRef<HTMLDivElement>(null);
161
+ const video = isVideo(demo);
162
+ const metadata = parseMetadata(demo.metadata);
163
+
164
+ useEffect(() => {
165
+ if (!detailRef.current) return;
166
+ gsap.fromTo(
167
+ detailRef.current.children,
168
+ { opacity: 0, y: 8 },
169
+ { opacity: 1, y: 0, duration: 0.3, stagger: 0.05, ease: "power2.out" },
170
+ );
171
+ }, [demo.id]);
172
+
173
+ return (
174
+ <div ref={detailRef} className="p-6">
175
+ {/* Header */}
176
+ <div className="mb-6">
177
+ <div className="flex items-center gap-3 mb-2">
178
+ <span
179
+ className={cn(
180
+ "text-[10px] font-bold px-1.5 py-0.5 rounded",
181
+ demo.artifact_type === "demo"
182
+ ? "bg-accent/15 text-accent"
183
+ : "bg-info/15 text-info",
184
+ )}
185
+ >
186
+ {demo.artifact_type === "demo" ? "DEMO" : "RECORDING"}
187
+ </span>
188
+ <h2 className="text-lg font-bold text-text font-mono truncate">
189
+ {demo.description || `Recording #${demo.id}`}
190
+ </h2>
191
+ </div>
192
+
193
+ {/* Meta row */}
194
+ <div className="flex items-center gap-4 text-xs text-text-muted mt-2">
195
+ <span className="flex items-center gap-1">
196
+ <Calendar size={10} />
197
+ {timeAgo(demo.timestamp)}
198
+ </span>
199
+ {demo.file_size && (
200
+ <span className="flex items-center gap-1">
201
+ <HardDrive size={10} />
202
+ {formatSize(demo.file_size)}
203
+ </span>
204
+ )}
205
+ {demo.mime_type && (
206
+ <span className="text-text-dim">{demo.mime_type}</span>
207
+ )}
208
+ {Boolean(metadata?.duration) && (
209
+ <span className="flex items-center gap-1">
210
+ <Clock size={10} />
211
+ {String(metadata!.duration)}
212
+ </span>
213
+ )}
214
+ </div>
215
+
216
+ {/* Session link */}
217
+ {demo.session_id && (
218
+ <div className="text-[10px] text-text-dim mt-1">
219
+ Session:{" "}
220
+ <span className="text-accent font-mono">
221
+ {demo.session_id.slice(0, 8)}
222
+ </span>
223
+ </div>
224
+ )}
225
+ </div>
226
+
227
+ {/* Video player or download */}
228
+ {video ? (
229
+ <div className="mb-6">
230
+ <div className="rounded-lg overflow-hidden border border-border bg-black">
231
+ <video
232
+ key={demo.id}
233
+ controls
234
+ preload="metadata"
235
+ className="w-full max-h-[70vh]"
236
+ poster={undefined}
237
+ >
238
+ <source
239
+ src={artifactUrl(demo.id)}
240
+ type={demo.mime_type || "video/mp4"}
241
+ />
242
+ Your browser does not support video playback.
243
+ </video>
244
+ </div>
245
+ <div className="flex items-center gap-3 mt-3">
246
+ <a
247
+ href={artifactUrl(demo.id)}
248
+ download
249
+ className="text-[10px] text-accent hover:underline flex items-center gap-1"
250
+ >
251
+ <Play size={10} /> Download video
252
+ </a>
253
+ </div>
254
+ </div>
255
+ ) : (
256
+ <div className="mb-6 p-4 bg-bg-elevated rounded border border-border">
257
+ <div className="text-xs text-text-dim mb-2">
258
+ This artifact is not a video file. You can download it directly:
259
+ </div>
260
+ <a
261
+ href={artifactUrl(demo.id)}
262
+ download
263
+ className="text-xs text-accent hover:underline"
264
+ >
265
+ Download {demo.file_path?.split("/").pop() || "file"}
266
+ </a>
267
+ </div>
268
+ )}
269
+
270
+ {/* Metadata */}
271
+ {metadata && Object.keys(metadata).length > 0 && (
272
+ <div>
273
+ <h3 className="text-[10px] text-text-dim uppercase tracking-wider font-bold mb-2">
274
+ Metadata
275
+ </h3>
276
+ <div className="bg-bg-elevated rounded p-3 border border-border text-xs font-mono">
277
+ {Object.entries(metadata).map(([key, value]) => (
278
+ <div key={key} className="flex gap-2">
279
+ <span className="text-accent">{key}:</span>
280
+ <span className="text-text">{String(value)}</span>
281
+ </div>
282
+ ))}
283
+ </div>
284
+ </div>
285
+ )}
286
+ </div>
287
+ );
288
+ }
289
+
290
+ function parseMetadata(raw: string | null): Record<string, unknown> | null {
291
+ if (!raw) return null;
292
+ try {
293
+ return JSON.parse(raw);
294
+ } catch {
295
+ return null;
296
+ }
297
+ }