@walwal-harness/cli 7.1.8 → 7.1.9

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 (66) hide show
  1. package/README.md +40 -0
  2. package/apps/harness-dashboard/app/api/env/route.ts +62 -0
  3. package/apps/harness-dashboard/app/api/log/route.ts +19 -0
  4. package/apps/harness-dashboard/app/api/missions/route.ts +15 -0
  5. package/apps/harness-dashboard/app/api/snapshot/route.ts +15 -0
  6. package/apps/harness-dashboard/app/api/stream/route.ts +71 -0
  7. package/apps/harness-dashboard/app/globals.css +17 -0
  8. package/apps/harness-dashboard/app/layout.tsx +29 -0
  9. package/apps/harness-dashboard/app/page.tsx +35 -0
  10. package/apps/harness-dashboard/components/Drawer.tsx +97 -0
  11. package/apps/harness-dashboard/components/Header.tsx +40 -0
  12. package/apps/harness-dashboard/components/MissionTimeline.tsx +210 -0
  13. package/apps/harness-dashboard/components/OrgTree.tsx +330 -0
  14. package/apps/harness-dashboard/components/Scene.tsx +563 -0
  15. package/apps/harness-dashboard/components/Wordmark.tsx +14 -0
  16. package/apps/harness-dashboard/components/drawer/AgentLogTab.tsx +56 -0
  17. package/apps/harness-dashboard/components/drawer/GotchasTab.tsx +89 -0
  18. package/apps/harness-dashboard/components/drawer/HypothesisTab.tsx +42 -0
  19. package/apps/harness-dashboard/components/drawer/IncidentsTab.tsx +50 -0
  20. package/apps/harness-dashboard/components/drawer/MissionDocTab.tsx +92 -0
  21. package/apps/harness-dashboard/components/drawer/MissionFlowTab.tsx +517 -0
  22. package/apps/harness-dashboard/components/drawer/OwnerHistoryTab.tsx +156 -0
  23. package/apps/harness-dashboard/components/drawer/RoomMetricsTab.tsx +90 -0
  24. package/apps/harness-dashboard/components/drawer/TracksTab.tsx +47 -0
  25. package/apps/harness-dashboard/components/three/ArchiveBoxes.tsx +95 -0
  26. package/apps/harness-dashboard/components/three/DeptBoard.tsx +175 -0
  27. package/apps/harness-dashboard/components/three/Floor3D.tsx +404 -0
  28. package/apps/harness-dashboard/components/three/GoalCard3D.tsx +123 -0
  29. package/apps/harness-dashboard/components/three/HypothesisCards.tsx +71 -0
  30. package/apps/harness-dashboard/components/three/MeetingWhiteboard.tsx +98 -0
  31. package/apps/harness-dashboard/components/three/Minifig3D.tsx +391 -0
  32. package/apps/harness-dashboard/components/three/OpsSiren.tsx +99 -0
  33. package/apps/harness-dashboard/components/three/Stage3D.tsx +104 -0
  34. package/apps/harness-dashboard/components/three/TrackRibbons.tsx +81 -0
  35. package/apps/harness-dashboard/hooks/useHarnessStream.ts +53 -0
  36. package/apps/harness-dashboard/lib/__tests__/company-flow.test.ts +270 -0
  37. package/apps/harness-dashboard/lib/__tests__/harness-state.test.ts +194 -0
  38. package/apps/harness-dashboard/lib/__tests__/i18n.test.ts +16 -0
  39. package/apps/harness-dashboard/lib/__tests__/iso.test.ts +42 -0
  40. package/apps/harness-dashboard/lib/__tests__/safe-json.test.ts +46 -0
  41. package/apps/harness-dashboard/lib/__tests__/state-mapping.test.ts +159 -0
  42. package/apps/harness-dashboard/lib/__tests__/watcher.test.ts +48 -0
  43. package/apps/harness-dashboard/lib/agent-roster.ts +42 -0
  44. package/apps/harness-dashboard/lib/furniture.ts +165 -0
  45. package/apps/harness-dashboard/lib/harness-root.ts +20 -0
  46. package/apps/harness-dashboard/lib/harness-state.ts +1162 -0
  47. package/apps/harness-dashboard/lib/i18n.ts +26 -0
  48. package/apps/harness-dashboard/lib/iso.ts +150 -0
  49. package/apps/harness-dashboard/lib/log-filter.ts +23 -0
  50. package/apps/harness-dashboard/lib/markdown.tsx +306 -0
  51. package/apps/harness-dashboard/lib/path-planning.ts +72 -0
  52. package/apps/harness-dashboard/lib/safe-json.ts +24 -0
  53. package/apps/harness-dashboard/lib/state-mapping.ts +126 -0
  54. package/apps/harness-dashboard/lib/types.ts +329 -0
  55. package/apps/harness-dashboard/lib/watcher.ts +64 -0
  56. package/apps/harness-dashboard/next-env.d.ts +6 -0
  57. package/apps/harness-dashboard/next.config.mjs +9 -0
  58. package/apps/harness-dashboard/package-lock.json +4280 -0
  59. package/apps/harness-dashboard/package.json +38 -0
  60. package/apps/harness-dashboard/playwright.config.ts +23 -0
  61. package/apps/harness-dashboard/postcss.config.js +6 -0
  62. package/apps/harness-dashboard/tailwind.config.ts +49 -0
  63. package/apps/harness-dashboard/tsconfig.json +24 -0
  64. package/apps/harness-dashboard/vitest.config.ts +15 -0
  65. package/package.json +11 -1
  66. package/scripts/harness-dashboard-up.sh +24 -4
@@ -0,0 +1,563 @@
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import type {
4
+ AgentState,
5
+ EnvFileSummary,
6
+ EscalationEntry,
7
+ HarnessSnapshot,
8
+ MissionDoc,
9
+ OpsServiceHealth,
10
+ OrgNodeDef,
11
+ WorkerSnapshot,
12
+ } from "@/lib/types";
13
+ import { useHarnessStream, type ConnectionState } from "@/hooks/useHarnessStream";
14
+ import { Drawer, type DrawerTab } from "./Drawer";
15
+ import { AgentLogTab } from "./drawer/AgentLogTab";
16
+ import { OwnerHistoryTab } from "./drawer/OwnerHistoryTab";
17
+ import { MissionDocTab } from "./drawer/MissionDocTab";
18
+ import { MissionFlowTab } from "./drawer/MissionFlowTab";
19
+ import { GotchasTab } from "./drawer/GotchasTab";
20
+ import { MissionTimeline } from "./MissionTimeline";
21
+ import { OrgTree } from "./OrgTree";
22
+
23
+ interface SceneProps {
24
+ snapshot: HarnessSnapshot;
25
+ lang?: "ko" | "en";
26
+ }
27
+
28
+ export function Scene({ snapshot: initial, lang = "ko" }: SceneProps) {
29
+ const { snapshot, connectionState } = useHarnessStream(initial);
30
+ const [drawerOpen, setDrawerOpen] = useState(false);
31
+ const [drawerTab, setDrawerTab] = useState<DrawerTab>("mission-flow");
32
+ const [selectedAgent, setSelectedAgent] = useState<AgentState | null>(null);
33
+ const [selectedNode, setSelectedNode] = useState<OrgNodeDef | null>(null);
34
+ const [selectedMission, setSelectedMission] = useState<MissionDoc | null>(null);
35
+
36
+ const handleAgentClick = (id: string) => {
37
+ const a = snapshot.agents.find((x) => x.id === id) ?? null;
38
+ setSelectedAgent(a);
39
+ setSelectedNode(null);
40
+ setDrawerTab("logs");
41
+ setDrawerOpen(true);
42
+ };
43
+
44
+ const handleNodeClick = (node: OrgNodeDef) => {
45
+ setSelectedNode(node);
46
+ setSelectedAgent(null);
47
+ // Auto-select current mission for non-owner nodes
48
+ if (!selectedMission && snapshot.missions?.[0]) {
49
+ setSelectedMission(snapshot.missions[0]);
50
+ }
51
+ if (node.id === "owner") {
52
+ setDrawerTab("history");
53
+ } else if (node.id.startsWith("worker-")) {
54
+ setDrawerTab("mission-flow");
55
+ } else {
56
+ setDrawerTab("mission-flow");
57
+ }
58
+ setDrawerOpen(true);
59
+ };
60
+
61
+ const handleMissionClick = (mission: MissionDoc) => {
62
+ setSelectedMission(mission);
63
+ setSelectedNode(null);
64
+ setSelectedAgent(null);
65
+ setDrawerTab("mission-flow");
66
+ setDrawerOpen(true);
67
+ };
68
+
69
+ const drawerTitle =
70
+ selectedMission && drawerTab === "mission-flow"
71
+ ? selectedMission.missionId
72
+ : selectedNode?.id === "owner"
73
+ ? "Owner · History"
74
+ : selectedNode
75
+ ? selectedNode.label
76
+ : "Mission Flow";
77
+
78
+ // Suppress unused lang warning — kept for future i18n
79
+ void lang;
80
+
81
+ return (
82
+ <div className="mx-auto max-w-[1920px] px-4 py-5">
83
+ {snapshot.escalations.length > 0 && (
84
+ <EscalationStrip escalations={snapshot.escalations} />
85
+ )}
86
+ <div className="min-w-0">
87
+ <section className="min-w-0">
88
+ <div className="mb-3 flex flex-wrap items-end justify-between gap-3">
89
+ <div>
90
+ <p className="text-[11px] font-mono uppercase tracking-[0.24em] text-cyan-300/80">
91
+ Company Structure
92
+ </p>
93
+ <h1 className="mt-1 text-2xl font-semibold text-gray-100">
94
+ {snapshot.projectName || "walwal-harness"} · Organization
95
+ </h1>
96
+ </div>
97
+ <ActivityIndicator snapshot={snapshot} connectionState={connectionState} />
98
+ </div>
99
+ <div
100
+ data-testid="brick-office-stage"
101
+ className="w-full overflow-hidden rounded-md border border-gray-700/80 bg-[#12151b] shadow-2xl"
102
+ >
103
+ <div className="pointer-events-none flex items-center justify-between border-b border-white/10 bg-black/30 px-4 py-2 font-mono text-[10px] text-gray-300 backdrop-blur">
104
+ <span>Owner → CEO → CXX → Workers</span>
105
+ <span>
106
+ {snapshot.missions?.[0]?.missionId ?? "no active mission"}
107
+ {snapshot.missions?.[0]?.cxxPresent.length
108
+ ? ` · ${snapshot.missions[0].cxxPresent.join(" / ")}`
109
+ : ""}
110
+ </span>
111
+ </div>
112
+ <OrgTree
113
+ snapshot={snapshot}
114
+ activeNodeId={selectedNode?.id ?? null}
115
+ onNodeClick={handleNodeClick}
116
+ />
117
+ </div>
118
+ </section>
119
+
120
+ <section className="mt-4">
121
+ <div className="mb-2">
122
+ <p className="font-mono text-[10px] uppercase tracking-[0.2em] text-cyan-300/60">Mission History</p>
123
+ <p className="text-[11px] text-gray-500 mt-0.5">goal · hot-fix 명령별 파생 작업 흐름</p>
124
+ </div>
125
+ <MissionTimeline
126
+ missions={snapshot.missions ?? []}
127
+ activeMissionId={selectedMission?.missionId ?? null}
128
+ onMissionClick={handleMissionClick}
129
+ />
130
+ </section>
131
+ </div>
132
+
133
+ {/* Hidden DOM index for E2E + a11y */}
134
+ <ul className="sr-only" data-testid="scene-index">
135
+ {snapshot.rooms.map((r) => (
136
+ <li
137
+ key={r.id}
138
+ data-testid={`room-${r.id}`}
139
+ data-room-id={r.id}
140
+ >
141
+ {r.label_ko}
142
+ </li>
143
+ ))}
144
+ {snapshot.agents.map((a) => (
145
+ <li
146
+ key={a.id}
147
+ data-testid={`minifig-${a.id}`}
148
+ data-agent-id={a.id}
149
+ data-minifig-state={a.minifigState}
150
+ data-room={a.room}
151
+ >
152
+ {a.name}
153
+ </li>
154
+ ))}
155
+ </ul>
156
+
157
+ <Drawer
158
+ open={drawerOpen}
159
+ tab={drawerTab}
160
+ title={drawerTitle}
161
+ onClose={() => setDrawerOpen(false)}
162
+ onTabChange={setDrawerTab}
163
+ >
164
+ {drawerTab === "mission-flow" && (
165
+ <MissionFlowTab
166
+ mission={selectedMission ?? snapshot.missions?.[0] ?? null}
167
+ ownerHistory={snapshot.ownerHistory ?? []}
168
+ />
169
+ )}
170
+ {drawerTab === "history" && (
171
+ <OwnerHistoryTab
172
+ ownerHistory={snapshot.ownerHistory ?? []}
173
+ mission={selectedMission ?? snapshot.missions?.[0] ?? null}
174
+ />
175
+ )}
176
+ {drawerTab === "gotchas" && (
177
+ <GotchasTab gotchas={snapshot.gotchas ?? []} />
178
+ )}
179
+ {drawerTab === "mission-doc" && selectedNode && (
180
+ <MissionDocTab
181
+ missions={snapshot.missions ?? []}
182
+ role={
183
+ selectedNode.role === "owner"
184
+ ? "ceo"
185
+ : (selectedNode.role as "ceo" | "cto" | "cqo" | "coo" | "cdo" | "ops")
186
+ }
187
+ fromLabel={selectedNode.role === "ceo" ? "Owner" : "CEO"}
188
+ toLabel={
189
+ selectedNode.role === "cto"
190
+ ? "Workers"
191
+ : selectedNode.role === "cqo"
192
+ ? "Owner Report"
193
+ : "—"
194
+ }
195
+ />
196
+ )}
197
+ {drawerTab === "logs" && selectedNode && (
198
+ <AgentLogTab agentId={selectedNode.agentIds[0] ?? selectedNode.id} />
199
+ )}
200
+ {drawerTab === "logs" && selectedAgent && !selectedNode && (
201
+ <AgentLogTab agentId={selectedAgent.id} />
202
+ )}
203
+ {drawerTab === "logs" && !selectedNode && !selectedAgent && (
204
+ <div className="text-gray-500 text-xs">Select a node to view logs.</div>
205
+ )}
206
+ {drawerTab === "mission-flow" && !selectedMission && !snapshot.missions?.length && (
207
+ <div className="text-gray-500 text-xs">No missions found in .harness/documents/</div>
208
+ )}
209
+ </Drawer>
210
+ </div>
211
+ );
212
+ }
213
+
214
+ function ActivityIndicator({
215
+ snapshot,
216
+ connectionState,
217
+ }: {
218
+ snapshot: HarnessSnapshot;
219
+ connectionState: ConnectionState;
220
+ }) {
221
+ const [now, setNow] = useState(Date.now());
222
+ useEffect(() => {
223
+ const id = setInterval(() => setNow(Date.now()), 5000);
224
+ return () => clearInterval(id);
225
+ }, []);
226
+ const ts = Date.parse(snapshot.ts);
227
+ const ageSec = Number.isFinite(ts) ? Math.max(0, Math.floor((now - ts) / 1000)) : null;
228
+ const isStale = ageSec !== null && ageSec > 30;
229
+ return (
230
+ <div className="flex items-center gap-2 text-[10px] font-mono">
231
+ <span
232
+ data-testid="connection-state"
233
+ data-state={connectionState}
234
+ className={`inline-block rounded px-2 py-0.5 ${
235
+ connectionState === "open"
236
+ ? "bg-aura-typing/20 text-aura-typing"
237
+ : connectionState === "stale"
238
+ ? "bg-aura-talking/20 text-aura-talking"
239
+ : connectionState === "failed"
240
+ ? "bg-aura-alert/20 text-aura-alert"
241
+ : "bg-gray-500/20 text-gray-400"
242
+ }`}
243
+ >
244
+ SSE: {connectionState}
245
+ </span>
246
+ <span
247
+ data-testid="activity-age"
248
+ data-stale={isStale}
249
+ className={`inline-block rounded px-2 py-0.5 ${
250
+ isStale
251
+ ? "bg-aura-alert/20 text-aura-alert"
252
+ : "bg-aura-typing/20 text-aura-typing"
253
+ }`}
254
+ >
255
+ {ageSec === null
256
+ ? "—"
257
+ : isStale
258
+ ? `stale ${ageSec}s — 회사가 멈춰 보입니다`
259
+ : `live ${ageSec}s ago`}
260
+ </span>
261
+ {snapshot.errorBanner && (
262
+ <span className="inline-block rounded bg-aura-alert/20 px-2 py-0.5 text-aura-alert">
263
+ banner: {snapshot.errorBanner.level}
264
+ </span>
265
+ )}
266
+ </div>
267
+ );
268
+ }
269
+
270
+ function Panel({
271
+ title,
272
+ eyebrow,
273
+ children,
274
+ }: {
275
+ title: string;
276
+ eyebrow?: string;
277
+ children: React.ReactNode;
278
+ }) {
279
+ return (
280
+ <section className="rounded-md border border-gray-700/80 bg-[#171a20]/95 p-3 shadow-xl">
281
+ <div className="mb-3 flex items-center justify-between gap-3">
282
+ <div>
283
+ {eyebrow && (
284
+ <p className="font-mono text-[10px] uppercase tracking-[0.2em] text-cyan-300/70">
285
+ {eyebrow}
286
+ </p>
287
+ )}
288
+ <h2 className="text-sm font-semibold text-gray-100">{title}</h2>
289
+ </div>
290
+ </div>
291
+ {children}
292
+ </section>
293
+ );
294
+ }
295
+
296
+ function Metric({ label, value, tone }: { label: string; value: React.ReactNode; tone: "neutral" | "cyan" | "green" }) {
297
+ const color = tone === "cyan" ? "text-cyan-300" : tone === "green" ? "text-emerald-300" : "text-gray-200";
298
+ return (
299
+ <div className="rounded border border-gray-700/70 bg-black/20 px-2 py-2">
300
+ <div className="font-mono text-[10px] uppercase text-gray-500">{label}</div>
301
+ <div className={`mt-1 text-lg font-semibold ${color}`}>{value}</div>
302
+ </div>
303
+ );
304
+ }
305
+
306
+ function StatusLine({ label, value }: { label: string; value: React.ReactNode }) {
307
+ return (
308
+ <div className="flex items-center justify-between gap-2 rounded bg-black/20 px-2 py-1 font-mono">
309
+ <span className="text-gray-500">{label}</span>
310
+ <span className="truncate text-gray-200">{value}</span>
311
+ </div>
312
+ );
313
+ }
314
+
315
+ function EmptyLine({ text }: { text: string }) {
316
+ return <div className="rounded border border-dashed border-gray-700 px-3 py-6 text-center text-xs text-gray-500">{text}</div>;
317
+ }
318
+
319
+ function statusTone(status: WorkerSnapshot["status"]): string {
320
+ if (status === "spawned" || status === "running") return "bg-cyan-400/15 text-cyan-300";
321
+ if (status === "recorded") return "bg-amber-400/15 text-amber-300";
322
+ if (status === "blocked") return "bg-rose-500/15 text-rose-300";
323
+ return "bg-gray-500/15 text-gray-300";
324
+ }
325
+
326
+ function featureTone(status: string): string {
327
+ if (status === "passed") return "text-emerald-300";
328
+ if (status === "failed" || status === "blocked") return "text-rose-300";
329
+ if (status === "in_progress" || status === "ready") return "text-cyan-300";
330
+ return "text-gray-500";
331
+ }
332
+
333
+ function serviceTone(status: OpsServiceHealth["status"]): string {
334
+ if (status === "ok") return "bg-emerald-400/15 text-emerald-300";
335
+ if (status === "degraded") return "bg-amber-400/15 text-amber-300";
336
+ if (status === "down") return "bg-rose-500/15 text-rose-300";
337
+ return "bg-gray-500/15 text-gray-300";
338
+ }
339
+
340
+ function EscalationStrip({ escalations }: { escalations: EscalationEntry[] }) {
341
+ return (
342
+ <div
343
+ data-testid="escalation-strip"
344
+ role="alert"
345
+ className="mb-3 flex flex-wrap items-center gap-2 rounded border border-aura-alert/60 bg-aura-alert/10 px-3 py-2 text-xs font-mono text-aura-alert"
346
+ >
347
+ <span className="font-semibold uppercase tracking-widest">
348
+ ↑ {escalations.length} escalation{escalations.length === 1 ? "" : "s"}
349
+ </span>
350
+ {escalations.slice(0, 3).map((e) => (
351
+ <span
352
+ key={e.id}
353
+ data-testid={`escalation-${e.id}`}
354
+ className="rounded bg-aura-alert/20 px-2 py-0.5 text-[10px]"
355
+ >
356
+ {e.id} · {e.reason}
357
+ </span>
358
+ ))}
359
+ {escalations.length > 3 && (
360
+ <span className="text-[10px] opacity-70">+{escalations.length - 3} more</span>
361
+ )}
362
+ </div>
363
+ );
364
+ }
365
+
366
+ // Keep unused helper functions to avoid breaking other panels if needed later
367
+ void Panel;
368
+ void Metric;
369
+ void StatusLine;
370
+ void EmptyLine;
371
+ void statusTone;
372
+ void featureTone;
373
+ void serviceTone;
374
+
375
+ function OpsPanel({ services }: { services: OpsServiceHealth[] }) {
376
+ return (
377
+ <section className="rounded-md border border-gray-700/80 bg-[#171a20]/95 p-3 shadow-xl">
378
+ <div className="mb-3">
379
+ <p className="font-mono text-[10px] uppercase tracking-[0.2em] text-cyan-300/70">production monitor</p>
380
+ <h2 className="text-sm font-semibold text-gray-100">Service-Ops</h2>
381
+ </div>
382
+ {services.length === 0 ? (
383
+ <div className="rounded border border-dashed border-gray-700 px-3 py-6 text-center text-xs text-gray-500">No production services configured.</div>
384
+ ) : (
385
+ <div className="space-y-1.5">
386
+ {services.map((s) => (
387
+ <div key={`${s.name}-${s.port}`} className="flex items-center justify-between gap-2 rounded border border-gray-700/60 bg-black/20 px-2 py-1.5">
388
+ <div className="min-w-0">
389
+ <div className="truncate text-xs font-medium text-gray-100">{s.name}</div>
390
+ <div className="font-mono text-[10px] text-gray-500">
391
+ {s.host}:{s.port}{s.health_path ? ` · ${s.health_path}` : ""}
392
+ </div>
393
+ </div>
394
+ <div className="text-right">
395
+ <span className={`rounded px-2 py-0.5 font-mono text-[10px] ${serviceTone(s.status)}`}>
396
+ {s.status}
397
+ </span>
398
+ <div className="mt-1 font-mono text-[10px] text-gray-500">
399
+ {s.health_status ?? s.port_state ?? "—"}
400
+ </div>
401
+ </div>
402
+ </div>
403
+ ))}
404
+ </div>
405
+ )}
406
+ </section>
407
+ );
408
+ }
409
+
410
+ function EnvPanel({ envFiles }: { envFiles: EnvFileSummary[] }) {
411
+ const first = envFiles[0];
412
+ const firstKey = first?.keys[0]?.key ?? "";
413
+ const [file, setFile] = useState(first?.path ?? ".env");
414
+ const [key, setKey] = useState(firstKey);
415
+ const [value, setValue] = useState("");
416
+ const [status, setStatus] = useState<string | null>(null);
417
+
418
+ const submitEnv = async () => {
419
+ setStatus("saving");
420
+ try {
421
+ const res = await fetch("/api/env", {
422
+ method: "POST",
423
+ headers: { "Content-Type": "application/json" },
424
+ body: JSON.stringify({ file, key, value }),
425
+ });
426
+ const body = await res.json().catch(() => ({}));
427
+ if (!res.ok || !body.ok) {
428
+ setStatus(body.error ?? "failed");
429
+ return;
430
+ }
431
+ setValue("");
432
+ setStatus(body.replaced ? "updated" : "added");
433
+ } catch {
434
+ setStatus("failed");
435
+ }
436
+ };
437
+
438
+ return (
439
+ <section className="rounded-md border border-gray-700/80 bg-[#171a20]/95 p-3 shadow-xl">
440
+ <div className="mb-3">
441
+ <p className="font-mono text-[10px] uppercase tracking-[0.2em] text-cyan-300/70">masked .env overview</p>
442
+ <h2 className="text-sm font-semibold text-gray-100">Environment Control</h2>
443
+ </div>
444
+ {envFiles.length === 0 ? (
445
+ <div className="rounded border border-dashed border-gray-700 px-3 py-6 text-center text-xs text-gray-500">No .env files found at project root.</div>
446
+ ) : (
447
+ <div className="space-y-2">
448
+ <div className="rounded border border-cyan-400/30 bg-cyan-400/5 p-2">
449
+ <div className="grid gap-2 sm:grid-cols-[1fr_1fr]">
450
+ <label className="grid gap-1 font-mono text-[10px] text-gray-400">
451
+ file
452
+ <select
453
+ value={file}
454
+ onChange={(e) => {
455
+ setFile(e.target.value);
456
+ const nextFile = envFiles.find((item) => item.path === e.target.value);
457
+ setKey(nextFile?.keys[0]?.key ?? "");
458
+ }}
459
+ className="rounded border border-gray-700 bg-[#11151a] px-2 py-1 text-xs text-gray-100"
460
+ >
461
+ {envFiles.map((item) => (
462
+ <option key={item.path} value={item.path}>
463
+ {item.path}
464
+ </option>
465
+ ))}
466
+ </select>
467
+ </label>
468
+ <label className="grid gap-1 font-mono text-[10px] text-gray-400">
469
+ key
470
+ <select
471
+ value={key}
472
+ onChange={(e) => setKey(e.target.value)}
473
+ className="rounded border border-gray-700 bg-[#11151a] px-2 py-1 text-xs text-gray-100"
474
+ >
475
+ {(envFiles.find((item) => item.path === file)?.keys ?? []).map((item) => (
476
+ <option key={item.key} value={item.key}>
477
+ {item.key}
478
+ </option>
479
+ ))}
480
+ </select>
481
+ </label>
482
+ </div>
483
+ <div className="mt-2 flex gap-2">
484
+ <input
485
+ value={value}
486
+ onChange={(e) => setValue(e.target.value)}
487
+ placeholder="new value, never stored in browser"
488
+ className="min-w-0 flex-1 rounded border border-gray-700 bg-[#11151a] px-2 py-1 text-xs text-gray-100 placeholder:text-gray-600"
489
+ />
490
+ <button
491
+ type="button"
492
+ onClick={submitEnv}
493
+ disabled={!file || !key}
494
+ className="rounded border border-cyan-400/50 px-3 py-1 font-mono text-[10px] uppercase text-cyan-200 hover:bg-cyan-400/10 disabled:cursor-not-allowed disabled:opacity-40"
495
+ >
496
+ Apply
497
+ </button>
498
+ </div>
499
+ {status && <div className="mt-1 font-mono text-[10px] text-amber-300">{status}</div>}
500
+ </div>
501
+ {envFiles.map((file) => (
502
+ <div key={file.path} className="rounded border border-gray-700/70 bg-black/20 p-2">
503
+ <div className="flex items-center justify-between gap-2">
504
+ <div className="font-mono text-xs text-gray-100">{file.path}</div>
505
+ <div className="font-mono text-[10px] text-gray-500">{file.key_count} keys</div>
506
+ </div>
507
+ <div className="mt-2 grid gap-1">
508
+ {file.keys.slice(0, 5).map((k) => (
509
+ <div key={k.key} className="flex items-center justify-between gap-2 font-mono text-[10px]">
510
+ <span className="truncate text-gray-400">{k.key}</span>
511
+ <span className={k.category === "secret" ? "text-rose-300" : "text-cyan-300/80"}>
512
+ {k.masked}
513
+ </span>
514
+ </div>
515
+ ))}
516
+ </div>
517
+ <p className="mt-2 text-[10px] leading-relaxed text-gray-500">
518
+ Server-side editor target. Values stay masked until an explicit edit flow is opened.
519
+ </p>
520
+ </div>
521
+ ))}
522
+ </div>
523
+ )}
524
+ </section>
525
+ );
526
+ }
527
+
528
+ // Keep OpsPanel and EnvPanel available for future reuse
529
+ void OpsPanel;
530
+ void EnvPanel;
531
+
532
+ function ArchiveList({ snapshot }: { snapshot: HarnessSnapshot }) {
533
+ const list = snapshot.archive.all ?? snapshot.archive.recent ?? [];
534
+ if (list.length === 0) {
535
+ return <div className="text-gray-500">No archived sprints yet.</div>;
536
+ }
537
+ return (
538
+ <ul className="space-y-1">
539
+ {list.map((entry) => (
540
+ <li
541
+ key={entry.dir}
542
+ className="flex justify-between rounded border border-brick-wall bg-brick-wall/30 px-2 py-1"
543
+ >
544
+ <span>{entry.label}</span>
545
+ <span
546
+ className={
547
+ entry.result === "PASS"
548
+ ? "text-aura-typing"
549
+ : entry.result === "FAIL"
550
+ ? "text-aura-alert"
551
+ : "text-gray-500"
552
+ }
553
+ >
554
+ {entry.result}
555
+ </span>
556
+ </li>
557
+ ))}
558
+ </ul>
559
+ );
560
+ }
561
+
562
+ // Keep ArchiveList available for future reuse
563
+ void ArchiveList;
@@ -0,0 +1,14 @@
1
+ // Wordmark for the dashboard. Avoids LEGO trade dress: no studded brick logos,
2
+ // no 2x4 standard ratio, no LEGO red/yellow scheme. Plain typographic mark.
3
+ export function Wordmark({ className = "" }: { className?: string }) {
4
+ return (
5
+ <span
6
+ className={`font-mono text-xl tracking-[0.18em] uppercase ${className}`}
7
+ aria-label="Brick Office"
8
+ >
9
+ <span className="text-white">Brick</span>
10
+ <span className="text-aura-typing"> · </span>
11
+ <span className="text-gray-200">Office</span>
12
+ </span>
13
+ );
14
+ }
@@ -0,0 +1,56 @@
1
+ "use client";
2
+ import { useEffect, useRef, useState } from "react";
3
+
4
+ interface AgentLogTabProps {
5
+ agentId: string;
6
+ }
7
+
8
+ export function AgentLogTab({ agentId }: AgentLogTabProps) {
9
+ const [lines, setLines] = useState<string[] | null>(null);
10
+ const [error, setError] = useState<string | null>(null);
11
+ const scrollRef = useRef<HTMLDivElement>(null);
12
+
13
+ useEffect(() => {
14
+ let cancelled = false;
15
+ setLines(null);
16
+ setError(null);
17
+ fetch(`/api/log?agent=${encodeURIComponent(agentId)}&limit=50`)
18
+ .then((r) => r.json())
19
+ .then((d) => {
20
+ if (cancelled) return;
21
+ setLines(d.lines ?? []);
22
+ })
23
+ .catch((e) => {
24
+ if (cancelled) return;
25
+ setError(String(e));
26
+ });
27
+ return () => {
28
+ cancelled = true;
29
+ };
30
+ }, [agentId]);
31
+
32
+ useEffect(() => {
33
+ if (lines && scrollRef.current) {
34
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
35
+ }
36
+ }, [lines]);
37
+
38
+ if (error) {
39
+ return <div className="text-aura-alert">Error loading log: {error}</div>;
40
+ }
41
+ if (lines === null) {
42
+ return <div className="text-gray-500">Loading…</div>;
43
+ }
44
+ if (lines.length === 0) {
45
+ return <div className="text-gray-500">No log lines for {agentId}.</div>;
46
+ }
47
+ return (
48
+ <div ref={scrollRef} className="space-y-1 max-h-full">
49
+ {lines.map((line, i) => (
50
+ <div key={i} className="whitespace-pre-wrap break-words text-[11px] leading-relaxed">
51
+ {line}
52
+ </div>
53
+ ))}
54
+ </div>
55
+ );
56
+ }