@walwal-harness/cli 7.1.27 → 7.1.29

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.
@@ -1,722 +1,1911 @@
1
1
  "use client";
2
- import { useEffect, useState } from "react";
2
+
3
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
+ import ReactMarkdown, { type Components } from "react-markdown";
5
+ import remarkGfm from "remark-gfm";
3
6
  import type {
4
- AgentState,
5
- EnvFileSummary,
6
- EscalationEntry,
7
+ ConventionEntry,
8
+ CxxTodo,
9
+ GotchaEntry,
7
10
  HarnessSnapshot,
8
11
  MissionDoc,
9
- OpsServiceHealth,
10
- OrgNodeDef,
11
- WorkerSnapshot,
12
+ OwnerPromptEntry,
13
+ WorkerDocEntry,
12
14
  } 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 { DesignPreviewTab } from "./drawer/DesignPreviewTab";
21
- import { MissionTimeline } from "./MissionTimeline";
22
- import { OrgTree } from "./OrgTree";
15
+ import { useHarnessStream } from "@/hooks/useHarnessStream";
16
+ import { Wordmark } from "./Wordmark";
23
17
 
24
18
  interface SceneProps {
25
19
  snapshot: HarnessSnapshot;
26
20
  lang?: "ko" | "en";
27
21
  }
28
22
 
23
+ type AgentLane = {
24
+ id: string;
25
+ label: string;
26
+ role: string;
27
+ group: string;
28
+ kind: "cxx" | "worker";
29
+ status: "normal" | "waiting" | "error";
30
+ todos: number;
31
+ workers: WorkerDocEntry[];
32
+ worker?: WorkerDocEntry;
33
+ mission: MissionDoc | null;
34
+ };
35
+
36
+ type HeatCell = {
37
+ x: number;
38
+ y: number;
39
+ laneId: string;
40
+ laneLabel: string;
41
+ bucketLabel: string;
42
+ count: number;
43
+ hotfix: boolean;
44
+ mission: MissionDoc | null;
45
+ };
46
+
47
+ type HeatSample = {
48
+ ts: number;
49
+ laneId: string;
50
+ count: number;
51
+ hotfix: boolean;
52
+ missionId: string | null;
53
+ };
54
+
55
+ type GoalGroup = { goal: MissionDoc; children: MissionDoc[] };
56
+
57
+ const CXX_ROLES = ["ceo", "coo", "cdo", "cto", "cqo", "ops"] as const;
58
+ type CxxRole = typeof CXX_ROLES[number];
59
+ // Workers always sit under a non-CEO CXX (CEO delegates, never owns workers).
60
+ const WORKER_OWNERS = ["coo", "cdo", "cto", "cqo", "ops"] as const;
61
+ type WorkerOwnerRole = typeof WORKER_OWNERS[number];
62
+ const BUCKET_MS = 60_000;
63
+ const VISIBLE_BUCKETS = 60;
64
+ const LANE_LABEL_WIDTH = 110;
65
+ const TIME_LABEL_HEIGHT = 18;
66
+ const LANE_ROW_HEIGHT = 18;
67
+ const MIN_BUCKET_PX = 8;
68
+
69
+ const CXX_COLOR: Record<CxxRole, string> = {
70
+ ceo: "bg-cyan-500",
71
+ coo: "bg-emerald-500",
72
+ cdo: "bg-violet-500",
73
+ cto: "bg-orange-500",
74
+ cqo: "bg-fuchsia-500",
75
+ ops: "bg-amber-500",
76
+ };
77
+
78
+ const CXX_TEXT: Record<CxxRole, string> = {
79
+ ceo: "text-cyan-200",
80
+ coo: "text-emerald-300",
81
+ cdo: "text-violet-300",
82
+ cto: "text-orange-300",
83
+ cqo: "text-fuchsia-300",
84
+ ops: "text-amber-300",
85
+ };
86
+
29
87
  export function Scene({ snapshot: initial, lang = "ko" }: SceneProps) {
30
88
  const { snapshot, connectionState } = useHarnessStream(initial);
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
- };
89
+ const initialActiveMission = snapshot.missions.find((mission) => mission.active) ?? snapshot.missions[0] ?? null;
90
+ const [selectedMissionId, setSelectedMissionId] = useState(initialActiveMission?.missionId ?? null);
91
+ const selectedMission =
92
+ snapshot.missions.find((mission) => mission.missionId === selectedMissionId) ??
93
+ snapshot.missions.find((mission) => mission.active) ??
94
+ snapshot.missions[0] ??
95
+ null;
96
+ // Owner-direct prompts only — strip auto-generated <task-notification> pings
97
+ // (used by the autonomous wake-up loop, not by the owner).
98
+ const ownerPrompts = useMemo(
99
+ () =>
100
+ snapshot.ownerHistory.filter(
101
+ (entry) => !(entry.content ?? "").trimStart().startsWith("<task-notification")
102
+ ),
103
+ [snapshot.ownerHistory]
104
+ );
105
+ const goalGroups = useMemo(() => groupMissions(snapshot.missions), [snapshot.missions]);
106
+ const selectedGoal = useMemo(() => {
107
+ if (!selectedMission) return goalGroups[0]?.goal ?? null;
108
+ const parentId = selectedMission.missionId.split("/")[0];
109
+ return goalGroups.find((g) => g.goal.missionId === parentId)?.goal ?? selectedMission;
110
+ }, [goalGroups, selectedMission]);
111
+ const goalScopeMissions = useMemo(() => {
112
+ if (!selectedGoal) return snapshot.missions;
113
+ const parentId = selectedGoal.missionId.split("/")[0];
114
+ return snapshot.missions.filter(
115
+ (m) => m.missionId === parentId || m.missionId.startsWith(`${parentId}/`)
116
+ );
117
+ }, [snapshot.missions, selectedGoal]);
42
118
 
43
- const handleNodeClick = (node: OrgNodeDef) => {
44
- const currentMission = snapshot.missions?.[0] ?? null;
45
- setSelectedNode(node);
46
- setSelectedAgent(null);
47
- if (currentMission) {
48
- setSelectedMission(currentMission);
49
- }
50
- if (node.id === "owner") {
51
- setDrawerTab("history");
52
- } else if (node.id === "cdo") {
53
- setDrawerTab("design-preview");
54
- } else if (node.id.startsWith("worker-")) {
55
- setDrawerTab("mission-doc");
56
- } else {
57
- setDrawerTab("mission-doc");
58
- }
59
- };
119
+ const lanes = useMemo(() => buildLanes(snapshot, selectedMission), [selectedMission, snapshot]);
120
+ const sessionStartedAtRef = useRef(Date.now());
121
+ const [heatSamples, setHeatSamples] = useState<HeatSample[]>([]);
122
+ const sampleSourceRef = useRef({ lanes, snapshot, selectedMission });
123
+ useEffect(() => {
124
+ sampleSourceRef.current = { lanes, snapshot, selectedMission };
125
+ });
126
+ useEffect(() => {
127
+ const tick = () => {
128
+ const { lanes, snapshot, selectedMission } = sampleSourceRef.current;
129
+ // Gate live samples on the harness runtime — once the session reports
130
+ // completion (or there's no current/next agent), every "active" signal
131
+ // is stale (todos stay status="active", worker mtimes stay recent), so
132
+ // we stop emitting in-progress samples until something resumes.
133
+ const rt = snapshot.runtime;
134
+ const harnessIdle =
135
+ rt?.agentStatus === "completed" ||
136
+ rt?.agentStatus === "complete" ||
137
+ ((rt?.currentAgent ?? null) === null &&
138
+ (rt?.nextAgent === null || rt?.nextAgent === "none"));
139
+ if (harnessIdle) {
140
+ const nowMinute = Math.floor(Date.now() / BUCKET_MS);
141
+ setHeatSamples((prev) =>
142
+ prev.filter((sample) => Math.floor(sample.ts / BUCKET_MS) < nowMinute)
143
+ );
144
+ return;
145
+ }
146
+ setHeatSamples((prev) => {
147
+ const now = Date.now();
148
+ const activeMission = selectedMission ?? snapshot.missions[0] ?? null;
149
+ const next: HeatSample[] = [];
150
+ for (const lane of lanes) {
151
+ // 3-state activity:
152
+ // 0 = none (not on this mission)
153
+ // 1 = standby (participated / has output, no live work)
154
+ // 2 = in-progress (actively running right now)
155
+ let activity = 0;
156
+ if (lane.kind === "worker") {
157
+ const w = lane.worker;
158
+ if (w) {
159
+ if (w.active || w.status === "IN_PROGRESS") activity = 2;
160
+ else if (w.content || w.status === "COMPLETE") activity = 1;
161
+ }
162
+ } else {
163
+ const presentInMission =
164
+ lane.mission?.cxxPresent.includes(lane.role) ?? false;
165
+ const liveTodos = snapshot.todos.filter(
166
+ (t) =>
167
+ t.owner === lane.role &&
168
+ t.status !== "done" &&
169
+ t.status !== "completed"
170
+ ).length;
171
+ const runningWorkers = lane.workers.filter(
172
+ (w) => w.active || w.status === "IN_PROGRESS"
173
+ ).length;
174
+ if (liveTodos > 0 && runningWorkers === 0) activity = 2;
175
+ else if (presentInMission || lane.workers.length > 0 || runningWorkers > 0) activity = 1;
176
+ }
177
+ if (activity <= 0) continue;
178
+ next.push({
179
+ ts: now,
180
+ laneId: lane.id,
181
+ count: activity,
182
+ hotfix: activeMission?.type === "hotfix" || lane.status === "error",
183
+ missionId: activeMission?.missionId ?? null,
184
+ });
185
+ }
186
+ if (next.length === 0) return prev;
187
+ return [...prev, ...next]
188
+ .filter((sample) => now - sample.ts < 30 * 60 * 1000)
189
+ .slice(-1500);
190
+ });
191
+ };
192
+ tick();
193
+ const id = setInterval(tick, 1000);
194
+ return () => clearInterval(id);
195
+ }, []);
196
+ const { cells: heatmap, totalBuckets } = useMemo(
197
+ () => buildHeatmap(snapshot, lanes, heatSamples, sessionStartedAtRef.current),
198
+ [heatSamples, lanes, snapshot]
199
+ );
200
+ const [selectedLaneId, setSelectedLaneId] = useState(lanes[0]?.id ?? "ceo");
201
+ const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null);
202
+ const [dragEnd, setDragEnd] = useState<{ x: number; y: number } | null>(null);
203
+ const [tooltip, setTooltip] = useState<{ mission: MissionDoc; x: number; y: number } | null>(null);
204
+ const [commandLogOpen, setCommandLogOpen] = useState(false);
205
+ const [docExpanded, setDocExpanded] = useState(false);
206
+ const [customDoc, setCustomDoc] = useState<{
207
+ source: string;
208
+ title: string;
209
+ content: string;
210
+ } | null>(null);
60
211
 
61
- const handleMissionClick = (mission: MissionDoc) => {
62
- setSelectedMission(mission);
63
- setSelectedNode(null);
64
- setSelectedAgent(null);
65
- setDrawerTab("mission-flow");
66
- };
212
+ // Selecting a mission or lane returns the viewer to mission-context mode.
213
+ useEffect(() => {
214
+ setCustomDoc(null);
215
+ }, [selectedMissionId, selectedLaneId]);
67
216
 
68
- const drawerTitle =
69
- selectedMission && drawerTab === "mission-flow"
70
- ? selectedMission.missionId
71
- : selectedNode?.id === "owner"
72
- ? "Owner · History"
73
- : selectedNode
74
- ? selectedNode.label
75
- : "Mission Flow";
76
- const drawerScrollResetKey = [
77
- drawerTab,
78
- selectedMission?.missionId ?? "",
79
- selectedNode?.id ?? "",
80
- selectedAgent?.id ?? "",
81
- ].join(":");
82
- const selectedWorkerName = selectedNode?.id.startsWith("worker-")
83
- ? selectedNode.id.replace(/^worker-[^-]+-/, "")
84
- : undefined;
85
- const selectedDocumentRole = selectedWorkerName
86
- ? "worker"
87
- : selectedNode?.role === "owner"
88
- ? "ceo"
89
- : selectedNode?.role;
90
-
91
- // Suppress unused lang warning — kept for future i18n
92
217
  void lang;
93
218
 
219
+ const selectedLane = lanes.find((lane) => lane.id === selectedLaneId) ?? lanes[0] ?? null;
220
+ const selectedCells = getSelectedCells(heatmap, dragStart, dragEnd);
221
+ const activeWorkers =
222
+ (snapshot.missions.find((mission) => mission.active) ?? snapshot.missions[0])?.workers.filter((w) => w.active).length ?? 0;
223
+ const hotfixCount = snapshot.missions.filter((m) => m.type === "hotfix").length;
224
+
225
+ const handleSelectWorker = (mission: MissionDoc, worker: WorkerDocEntry) => {
226
+ setSelectedMissionId(mission.missionId);
227
+ setSelectedLaneId(`${worker.owner}:${worker.name}`);
228
+ };
229
+
94
230
  return (
95
- <div className="flex h-[100dvh] w-full flex-col overflow-hidden px-4 py-5">
96
- {snapshot.escalations.length > 0 && (
97
- <EscalationStrip escalations={snapshot.escalations} />
98
- )}
99
- <div className="mb-4 grid gap-3 xl:grid-cols-[1.25fr_1fr]">
100
- <CommandDeck snapshot={snapshot} connectionState={connectionState} />
101
- <ProcessPulse snapshot={snapshot} />
102
- </div>
103
- <div className="grid min-h-0 flex-1 min-w-0 gap-4 lg:grid-cols-[1.12fr_0.88fr]">
104
- <div className="scrollbar-hidden min-h-0 min-w-0 overflow-y-auto pr-1">
105
- <section className="min-w-0">
106
- <div className="mb-3 flex flex-wrap items-end justify-between gap-3">
107
- <div>
108
- <p className="text-[11px] font-mono uppercase tracking-[0.24em] text-cyan-300/80">
109
- Company Structure
110
- </p>
111
- <h1 className="mt-1 text-2xl font-semibold text-gray-100">
112
- {snapshot.projectName || "walwal-harness"} · Organization
113
- </h1>
114
- </div>
115
- <ActivityIndicator snapshot={snapshot} connectionState={connectionState} />
116
- </div>
117
- <div
118
- data-testid="brick-office-stage"
119
- className="glass-panel w-full overflow-x-auto rounded-lg"
120
- >
121
- <div className="pointer-events-none flex items-center justify-between gap-3 border-b border-white/10 bg-white/5 px-4 py-2 font-mono text-[10px] text-gray-300 backdrop-blur">
122
- <span>Owner → CEO → CXX → Workers</span>
123
- <span className="truncate">
124
- {snapshot.missions?.[0]?.missionId ?? "no active mission"}
125
- {snapshot.missions?.[0]?.cxxPresent.length
126
- ? ` · ${snapshot.missions[0].cxxPresent.join(" / ")}`
127
- : ""}
128
- </span>
129
- </div>
130
- <OrgTree
131
- snapshot={snapshot}
132
- activeNodeId={selectedNode?.id ?? null}
133
- onNodeClick={handleNodeClick}
134
- />
135
- </div>
136
- </section>
231
+ <div className="flex h-[100dvh] flex-col overflow-hidden bg-[#070d1d] text-slate-200">
232
+ <TopHeader
233
+ projectName={snapshot.projectName}
234
+ missionId={selectedMission?.missionId ?? null}
235
+ connectionState={connectionState}
236
+ activeWorkers={activeWorkers}
237
+ hotfixCount={hotfixCount}
238
+ />
137
239
 
138
- <section className="mt-4">
139
- <div className="mb-2">
140
- <p className="font-mono text-[10px] uppercase tracking-[0.2em] text-cyan-300/60">Mission History</p>
141
- <p className="text-[11px] text-gray-500 mt-0.5">goal · submission · hot-fix 명령별 파생 작업 흐름</p>
142
- </div>
143
- <MissionTimeline
144
- missions={snapshot.missions ?? []}
145
- activeMissionId={selectedMission?.missionId ?? null}
146
- onMissionClick={handleMissionClick}
240
+ <div className="flex min-h-0 flex-1">
241
+ <HistoryNavigator
242
+ missions={snapshot.missions}
243
+ ownerHistory={ownerPrompts}
244
+ activeMissionId={selectedMission?.missionId ?? null}
245
+ onSelect={setSelectedMissionId}
246
+ commandLogOpen={commandLogOpen}
247
+ onToggleCommandLog={() => setCommandLogOpen((v) => !v)}
248
+ onCloseCommandLog={() => setCommandLogOpen(false)}
249
+ />
250
+
251
+ <main className="flex min-w-0 flex-1 flex-col gap-2 p-2">
252
+ {/* Top row: Knowledge | Layer Activity | Recent Report */}
253
+ <section className="grid shrink-0 grid-cols-3 gap-2" style={{ height: 280 }}>
254
+ <KnowledgeBasePanel
255
+ gotchas={snapshot.gotchas}
256
+ conventions={snapshot.conventions}
257
+ baselineTs={selectedGoal?.ts ?? null}
258
+ onSelect={(entry, kind) =>
259
+ setCustomDoc({
260
+ source: kind,
261
+ title: `${entry.id}.md`,
262
+ content: entry.content,
263
+ })
264
+ }
265
+ />
266
+ <LayerActivityPanel
267
+ goalScope={goalScopeMissions}
268
+ todos={snapshot.todos}
269
+ label={selectedGoal?.missionId ?? "no goal"}
270
+ />
271
+ <RecentReportPanel
272
+ missions={snapshot.missions}
273
+ onSelectWorker={handleSelectWorker}
147
274
  />
148
275
  </section>
149
- </div>
150
276
 
151
- <div className="min-h-0 min-w-0">
152
- <Drawer
153
- mode="inline"
154
- open={true}
155
- tab={drawerTab}
156
- title={drawerTitle}
157
- onClose={() => undefined}
158
- onTabChange={setDrawerTab}
159
- scrollResetKey={drawerScrollResetKey}
160
- >
161
- {drawerTab === "mission-flow" && (
162
- <MissionFlowTab
163
- mission={selectedMission ?? snapshot.missions?.[0] ?? null}
164
- ownerHistory={snapshot.ownerHistory ?? []}
165
- runtime={snapshot.runtime}
166
- />
167
- )}
168
- {drawerTab === "history" && (
169
- <OwnerHistoryTab
170
- ownerHistory={snapshot.ownerHistory ?? []}
171
- mission={selectedMission ?? snapshot.missions?.[0] ?? null}
172
- />
173
- )}
174
- {drawerTab === "gotchas" && (
175
- <GotchasTab gotchas={snapshot.gotchas ?? []} />
176
- )}
177
- {drawerTab === "design-preview" && (
178
- <DesignPreviewTab mission={selectedMission ?? snapshot.missions?.[0] ?? null} />
179
- )}
180
- {drawerTab === "mission-doc" && selectedNode && (
181
- <MissionDocTab
182
- missions={snapshot.missions ?? []}
183
- role={selectedDocumentRole as "ceo" | "cto" | "cqo" | "coo" | "cdo" | "ops" | "worker"}
184
- workerName={selectedWorkerName}
185
- fromLabel={
186
- selectedWorkerName
187
- ? selectedNode.role.toUpperCase()
188
- : selectedNode.role === "ceo"
189
- ? "Owner"
190
- : "CEO"
191
- }
192
- toLabel={
193
- selectedWorkerName
194
- ? "Evidence"
195
- : selectedNode.role === "cto"
196
- ? "Workers"
197
- : selectedNode.role === "cqo"
198
- ? "Owner Report"
199
- : "—"
277
+ {/* Heatmap section with cadence strip + main grid + click tooltip */}
278
+ <section className="panel-shell relative flex min-h-0 flex-1 flex-col gap-2 p-2">
279
+ <CadenceStrip ownerHistory={ownerPrompts} />
280
+ <WorkflowHeatmap
281
+ lanes={lanes}
282
+ cells={heatmap}
283
+ totalBuckets={totalBuckets}
284
+ selectedCells={selectedCells}
285
+ dragStart={dragStart}
286
+ dragEnd={dragEnd}
287
+ onDragStart={setDragStart}
288
+ onDragMove={setDragEnd}
289
+ onDragEnd={(missionId) => {
290
+ if (missionId) setSelectedMissionId(missionId);
291
+ }}
292
+ onSelectLane={setSelectedLaneId}
293
+ onCellClick={(cell, event) => {
294
+ if (!cell.mission) {
295
+ setTooltip(null);
296
+ return;
200
297
  }
298
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
299
+ setTooltip({
300
+ mission: cell.mission,
301
+ x: rect.left + rect.width / 2,
302
+ y: rect.top,
303
+ });
304
+ setSelectedMissionId(cell.mission.missionId);
305
+ }}
306
+ />
307
+ {tooltip && (
308
+ <ApprovalTooltip
309
+ mission={tooltip.mission}
310
+ x={tooltip.x}
311
+ y={tooltip.y}
312
+ onClose={() => setTooltip(null)}
201
313
  />
202
314
  )}
203
- {drawerTab === "logs" && selectedNode && (
204
- <AgentLogTab agentId={selectedNode.agentIds[0] ?? selectedNode.id} />
205
- )}
206
- {drawerTab === "logs" && selectedAgent && !selectedNode && (
207
- <AgentLogTab agentId={selectedAgent.id} />
208
- )}
209
- {drawerTab === "logs" && !selectedNode && !selectedAgent && (
210
- <div className="text-gray-500 text-xs">Select a node to view logs.</div>
211
- )}
212
- {drawerTab === "mission-flow" && !selectedMission && !snapshot.missions?.length && (
213
- <div className="text-gray-500 text-xs">No missions found in .harness/documents/</div>
214
- )}
215
- </Drawer>
216
- </div>
217
- </div>
315
+ </section>
316
+ </main>
218
317
 
219
- {/* Hidden DOM index for E2E + a11y */}
220
- <ul className="sr-only" data-testid="scene-index">
221
- {snapshot.rooms.map((r) => (
222
- <li
223
- key={r.id}
224
- data-testid={`room-${r.id}`}
225
- data-room-id={r.id}
226
- >
227
- {r.label_ko}
228
- </li>
229
- ))}
230
- {snapshot.agents.map((a) => (
231
- <li
232
- key={a.id}
233
- data-testid={`minifig-${a.id}`}
234
- data-agent-id={a.id}
235
- data-minifig-state={a.minifigState}
236
- data-room={a.room}
237
- >
238
- {a.name}
239
- </li>
240
- ))}
241
- </ul>
318
+ <aside
319
+ id="doc-aside"
320
+ className={`doc-aside flex shrink-0 flex-col gap-2 overflow-hidden border-l border-slate-800 bg-[#081124] p-2 ${
321
+ docExpanded ? "expanded" : ""
322
+ }`}
323
+ >
324
+ <DocumentViewer
325
+ mission={selectedMission}
326
+ lane={selectedLane}
327
+ customDoc={customDoc}
328
+ expanded={docExpanded}
329
+ onToggle={() => setDocExpanded((v) => !v)}
330
+ />
331
+ </aside>
332
+ </div>
242
333
  </div>
243
334
  );
244
335
  }
245
336
 
246
- function CommandDeck({
247
- snapshot,
337
+ // ===== Top header =====
338
+
339
+ function TopHeader({
340
+ projectName,
341
+ missionId,
248
342
  connectionState,
343
+ activeWorkers,
344
+ hotfixCount,
249
345
  }: {
250
- snapshot: HarnessSnapshot;
251
- connectionState: ConnectionState;
346
+ projectName: string;
347
+ missionId: string | null;
348
+ connectionState: string;
349
+ activeWorkers: number;
350
+ hotfixCount: number;
252
351
  }) {
253
- const activeMission = snapshot.missions?.[0];
254
- const cxxCount = activeMission?.cxxPresent.length ?? 0;
255
- const activeWorkers = activeMission?.workers.filter((w) => w.active).length ?? 0;
256
- const activeTodos = snapshot.todos.filter((t) => t.status === "active").length;
257
- const blockedSignals =
258
- snapshot.escalations.length +
259
- snapshot.incidents.length +
260
- snapshot.dashboard.features.filter((f) => f.status === "blocked" || f.status === "failed").length;
261
- const stateLabel = blockedSignals > 0 ? "Needs Attention" : activeWorkers > 0 ? "In Motion" : "Observing";
262
-
263
352
  return (
264
- <section className="glass-panel relative overflow-hidden rounded-lg px-5 py-4">
265
- <div className="aurora-line absolute inset-x-0 top-0 h-1" />
266
- <div className="relative flex flex-wrap items-start justify-between gap-4">
267
- <div className="min-w-0">
268
- <p className="font-mono text-[10px] uppercase tracking-[0.28em] text-cyan-200/80">
269
- Company Harness Observatory
270
- </p>
271
- <h1 className="mt-1 text-2xl font-semibold tracking-tight text-white sm:text-3xl">
272
- {snapshot.projectName || "walwal-harness"}
273
- </h1>
274
- <p className="mt-2 max-w-2xl text-sm leading-6 text-slate-300">
275
- {activeMission?.missionId ?? snapshot.goal?.title ?? "No active mission detected"}
276
- </p>
277
- </div>
278
- <div className="brutal-tile min-w-[170px] rounded-md px-3 py-2">
279
- <div className="font-mono text-[9px] uppercase tracking-[0.2em] text-slate-500">state</div>
280
- <div className="mt-1 text-lg font-semibold text-white">{stateLabel}</div>
281
- <div className="mt-1 font-mono text-[10px] text-slate-400">
282
- {connectionState} · {snapshot.runtime.currentAgent ?? snapshot.runtime.nextAgent ?? "idle"}
283
- </div>
284
- </div>
285
- </div>
286
- <div className="relative mt-4 grid gap-2 sm:grid-cols-4">
287
- <MetricTile label="CXX Present" value={String(cxxCount)} tone="cyan" />
288
- <MetricTile label="Active Todos" value={String(activeTodos)} tone="green" />
289
- <MetricTile label="Open Incidents" value={String(snapshot.incidents.length)} tone="rose" />
290
- <MetricTile label="Missions" value={String(snapshot.missions.length)} tone="violet" />
353
+ <header className="flex shrink-0 items-center gap-4 border-b border-slate-700/60 bg-[#0b1328]/90 px-4 py-2 backdrop-blur">
354
+ <Wordmark className="text-base" />
355
+ <span
356
+ className="rounded bg-slate-800/60 px-2 py-0.5 font-mono text-[10px] font-semibold text-amber-300"
357
+ title={projectName}
358
+ >
359
+ {projectName}
360
+ </span>
361
+ <span className="hidden text-[11px] text-slate-400 sm:inline">
362
+ walwal-harness 라이브 운영 대시보드
363
+ </span>
364
+ <span className="min-w-0 flex-1 truncate font-mono text-[11px] text-cyan-200/80">
365
+ {missionId ?? "no active transaction"}
366
+ </span>
367
+ <div className="flex shrink-0 items-center gap-2">
368
+ <HeaderBadge
369
+ label="SSE"
370
+ value={connectionState}
371
+ tone={connectionState === "open" ? "green" : "orange"}
372
+ />
373
+ <HeaderBadge label="Workers" value={String(activeWorkers)} tone="cyan" />
374
+ <HeaderBadge label="Hot-fix" value={String(hotfixCount)} tone="red" />
375
+ <PresenceLegend />
291
376
  </div>
292
- </section>
377
+ </header>
293
378
  );
294
379
  }
295
380
 
296
- function MetricTile({ label, value, tone }: { label: string; value: string; tone: "cyan" | "green" | "rose" | "violet" }) {
297
- const toneClass = {
298
- cyan: "text-cyan-200 border-cyan-300/35",
299
- green: "text-emerald-200 border-emerald-300/35",
300
- rose: "text-rose-200 border-rose-300/35",
301
- violet: "text-violet-200 border-violet-300/35",
381
+ function HeaderBadge({
382
+ label,
383
+ value,
384
+ tone,
385
+ }: {
386
+ label: string;
387
+ value: string;
388
+ tone: "green" | "orange" | "red" | "cyan";
389
+ }) {
390
+ const color = {
391
+ green: "border-emerald-400/30 bg-emerald-400/10 text-emerald-200",
392
+ orange: "border-orange-400/30 bg-orange-400/10 text-orange-200",
393
+ red: "border-rose-400/30 bg-rose-400/10 text-rose-200",
394
+ cyan: "border-cyan-400/30 bg-cyan-400/10 text-cyan-200",
302
395
  }[tone];
303
396
  return (
304
- <div className={`brutal-tile rounded-md px-3 py-2 ${toneClass}`}>
305
- <div className="font-mono text-[9px] uppercase tracking-[0.18em] text-slate-500">{label}</div>
306
- <div className="mt-1 text-2xl font-semibold">{value}</div>
397
+ <div className={`rounded-full border px-2.5 py-1 font-mono text-[10px] ${color}`}>
398
+ <span className="text-slate-500">{label}</span>
399
+ <span className="ml-1.5">{value}</span>
307
400
  </div>
308
401
  );
309
402
  }
310
403
 
311
- function ProcessPulse({ snapshot }: { snapshot: HarnessSnapshot }) {
312
- const roles = ["ceo", "coo", "cdo", "cto", "cqo", "ops"] as const;
313
- const activeMission = snapshot.missions?.[0];
314
- const activeRoles = new Set(activeMission?.cxxPresent ?? []);
315
- const roleStatus = roles.map((role) => {
316
- const todos = snapshot.todos.filter((t) => t.owner === role);
317
- const activeTodos = todos.filter((t) => t.status === "active").length;
318
- const blockedTodos = todos.filter((t) => t.status === "blocked").length;
319
- const workerCount = activeMission?.workers.filter((w) => w.owner === role).length ?? 0;
320
- const activeCount = activeMission?.workers.filter((w) => w.owner === role && w.active).length ?? 0;
321
- return { role, workerCount, activeCount, activeTodos, blockedTodos, present: activeRoles.has(role) || todos.length > 0 };
322
- });
404
+ function PresenceLegend() {
323
405
  return (
324
- <section className="glass-panel rounded-lg px-4 py-4">
325
- <div className="flex items-center justify-between gap-3">
326
- <div>
327
- <p className="font-mono text-[10px] uppercase tracking-[0.24em] text-fuchsia-200/75">
328
- Process Monitor
329
- </p>
330
- <h2 className="mt-1 text-base font-semibold text-white">CXX queue readiness</h2>
331
- </div>
332
- <div className="rounded-full border border-white/15 bg-white/8 px-3 py-1 font-mono text-[10px] text-slate-300">
333
- {snapshot.runtime.agentStatus}
406
+ <div className="ml-2 hidden items-center gap-2 border-l border-slate-700/50 pl-3 font-mono text-[10px] text-gray-400 lg:flex">
407
+ <span className="inline-flex items-center gap-1">
408
+ <span className="size-1.5 rounded-full bg-aura-idle" /> idle
409
+ </span>
410
+ <span className="inline-flex items-center gap-1">
411
+ <span className="size-1.5 rounded-full bg-aura-typing" /> typing
412
+ </span>
413
+ <span className="inline-flex items-center gap-1">
414
+ <span className="size-1.5 rounded-full bg-aura-talking" /> talking
415
+ </span>
416
+ <span className="inline-flex items-center gap-1">
417
+ <span className="size-1.5 rounded-full bg-aura-alert" /> alert
418
+ </span>
419
+ </div>
420
+ );
421
+ }
422
+
423
+ // ===== History Navigator =====
424
+
425
+ function groupMissions(missions: MissionDoc[]): GoalGroup[] {
426
+ const parentSegment = (id: string) => id.split("/")[0] ?? id;
427
+ const groups: GoalGroup[] = [];
428
+ const groupById = new Map<string, GoalGroup>();
429
+
430
+ const ensureGroupFor = (mission: MissionDoc): GoalGroup => {
431
+ const key = parentSegment(mission.missionId);
432
+ const existing = groupById.get(key);
433
+ if (existing) return existing;
434
+ const group: GoalGroup = { goal: mission, children: [] };
435
+ groups.push(group);
436
+ groupById.set(key, group);
437
+ return group;
438
+ };
439
+
440
+ for (const mission of missions) {
441
+ if (mission.missionId.includes("/")) continue;
442
+ if (mission.type === "goal" || mission.type === "feature") ensureGroupFor(mission);
443
+ }
444
+ for (const mission of missions) {
445
+ if (mission.missionId.includes("/")) continue;
446
+ if (mission.type === "goal" || mission.type === "feature") continue;
447
+ ensureGroupFor(mission);
448
+ }
449
+ for (const mission of missions) {
450
+ if (!mission.missionId.includes("/")) continue;
451
+ const key = parentSegment(mission.missionId);
452
+ const group = groupById.get(key);
453
+ if (group) group.children.push(mission);
454
+ else ensureGroupFor(mission);
455
+ }
456
+
457
+ for (const g of groups) {
458
+ g.children.sort((a, b) => (a.ts ?? "").localeCompare(b.ts ?? ""));
459
+ }
460
+ groups.sort((a, b) => (b.goal.ts ?? "").localeCompare(a.goal.ts ?? ""));
461
+ return groups;
462
+ }
463
+
464
+ function HistoryNavigator({
465
+ missions,
466
+ ownerHistory,
467
+ activeMissionId,
468
+ onSelect,
469
+ commandLogOpen,
470
+ onToggleCommandLog,
471
+ onCloseCommandLog,
472
+ }: {
473
+ missions: MissionDoc[];
474
+ ownerHistory: OwnerPromptEntry[];
475
+ activeMissionId: string | null;
476
+ onSelect: (missionId: string) => void;
477
+ commandLogOpen: boolean;
478
+ onToggleCommandLog: () => void;
479
+ onCloseCommandLog: () => void;
480
+ }) {
481
+ const groups = useMemo(() => groupMissions(missions), [missions]);
482
+ const popoverRef = useRef<HTMLDivElement>(null);
483
+ const buttonRef = useRef<HTMLButtonElement>(null);
484
+
485
+ useEffect(() => {
486
+ if (!commandLogOpen) return;
487
+ const onDocClick = (event: MouseEvent) => {
488
+ const target = event.target as Node;
489
+ if (popoverRef.current?.contains(target) || buttonRef.current?.contains(target)) return;
490
+ onCloseCommandLog();
491
+ };
492
+ document.addEventListener("mousedown", onDocClick);
493
+ return () => document.removeEventListener("mousedown", onDocClick);
494
+ }, [commandLogOpen, onCloseCommandLog]);
495
+
496
+ return (
497
+ <aside className="flex w-[230px] shrink-0 flex-col border-r border-slate-800 bg-[#081124] px-3 py-3">
498
+ <div className="relative mb-3 flex items-center justify-between gap-2">
499
+ <div className="flex min-w-0 items-center gap-2">
500
+ <div className="grid h-7 w-7 shrink-0 place-items-center rounded bg-slate-700/70 text-xs">
501
+ AI
502
+ </div>
503
+ <div className="min-w-0">
504
+ <div className="text-xs font-semibold text-slate-100">Administrator</div>
505
+ <div className="font-mono text-[9px] text-slate-500">workflow history</div>
506
+ </div>
334
507
  </div>
508
+ <button
509
+ ref={buttonRef}
510
+ type="button"
511
+ onClick={onToggleCommandLog}
512
+ className="relative grid h-7 w-7 shrink-0 place-items-center rounded text-slate-400 hover:bg-white/10 hover:text-cyan-200"
513
+ title={`Command Log (${ownerHistory.length})`}
514
+ aria-label="Open command log"
515
+ >
516
+ <svg
517
+ width="14"
518
+ height="14"
519
+ viewBox="0 0 24 24"
520
+ fill="none"
521
+ stroke="currentColor"
522
+ strokeWidth="2"
523
+ strokeLinejoin="round"
524
+ >
525
+ <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
526
+ </svg>
527
+ {ownerHistory.length > 0 && (
528
+ <span className="absolute -right-0.5 -top-0.5 grid h-3.5 min-w-[14px] place-items-center rounded-full bg-cyan-500 px-1 font-mono text-[8px] font-bold text-slate-900 animate-pulse">
529
+ {ownerHistory.length}
530
+ </span>
531
+ )}
532
+ </button>
533
+
534
+ {commandLogOpen && (
535
+ <CommandLogPopover
536
+ ref={popoverRef}
537
+ entries={ownerHistory}
538
+ onClose={onCloseCommandLog}
539
+ />
540
+ )}
335
541
  </div>
336
- <div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
337
- {roleStatus.map((item) => (
338
- <div key={item.role} className="brutal-tile rounded-md px-3 py-2">
339
- <div className="flex items-center justify-between gap-2">
340
- <span className="font-mono text-[10px] uppercase tracking-[0.18em] text-slate-400">{item.role}</span>
341
- <span
342
- className={`h-2 w-2 rounded-full ${
343
- item.blockedTodos > 0
344
- ? "bg-rose-400"
345
- : item.activeTodos > 0 || item.activeCount > 0
346
- ? "bg-cyan-300 animate-pulse"
347
- : item.present
348
- ? "bg-emerald-300"
349
- : "bg-slate-600"
542
+
543
+ <nav className="min-h-0 flex-1 space-y-2 overflow-auto pr-1 text-xs">
544
+ <div className="font-mono text-[10px] uppercase tracking-[0.18em] text-slate-500">
545
+ /goal
546
+ </div>
547
+ {groups.length ? (
548
+ groups.map(({ goal, children }) => (
549
+ <div
550
+ key={goal.missionId}
551
+ className="rounded border border-slate-800/80 bg-slate-950/35"
552
+ >
553
+ <button
554
+ type="button"
555
+ onClick={() => onSelect(goal.missionId)}
556
+ className={`w-full truncate px-2 py-2 text-left ${
557
+ activeMissionId === goal.missionId
558
+ ? "bg-cyan-400/[0.12] text-cyan-100"
559
+ : "text-slate-300 hover:bg-white/[0.04]"
350
560
  }`}
351
- />
352
- </div>
353
- <div className="mt-2 text-xs text-slate-300">
354
- {item.blockedTodos > 0
355
- ? `${item.blockedTodos} blocked`
356
- : item.activeTodos > 0
357
- ? `${item.activeTodos} todo`
358
- : item.activeCount > 0
359
- ? `${item.activeCount} active`
360
- : item.workerCount > 0
361
- ? `${item.workerCount} workers`
362
- : item.present
363
- ? "documented"
364
- : "waiting"}
561
+ title={goal.label || goal.missionId}
562
+ >
563
+ {goal.label || goal.missionId}
564
+ </button>
565
+ {children.length > 0 && (
566
+ <div className="border-t border-slate-800/70 py-1 pl-3">
567
+ {children.map((child) => (
568
+ <button
569
+ key={child.missionId}
570
+ type="button"
571
+ onClick={() => onSelect(child.missionId)}
572
+ className={`block w-full truncate rounded px-2 py-1.5 text-left ${
573
+ activeMissionId === child.missionId
574
+ ? "bg-cyan-400/[0.12] text-cyan-100"
575
+ : child.type === "hotfix"
576
+ ? "text-rose-300 hover:bg-rose-400/10"
577
+ : "text-slate-400 hover:bg-white/[0.04]"
578
+ }`}
579
+ title={child.label || child.missionId}
580
+ >
581
+ <span className="font-mono text-[9px] text-slate-500">
582
+ {child.type === "hotfix" ? "/hot-fix" : "/submission"}
583
+ </span>{" "}
584
+ {child.label || child.missionId}
585
+ </button>
586
+ ))}
587
+ </div>
588
+ )}
365
589
  </div>
590
+ ))
591
+ ) : (
592
+ <div className="rounded border border-slate-800 px-2 py-2 text-slate-500">
593
+ No goal history
366
594
  </div>
367
- ))}
595
+ )}
596
+ </nav>
597
+ </aside>
598
+ );
599
+ }
600
+
601
+ const CommandLogPopover = (() => {
602
+ const Inner = (
603
+ {
604
+ entries,
605
+ onClose,
606
+ }: { entries: OwnerPromptEntry[]; onClose: () => void },
607
+ ref: React.Ref<HTMLDivElement>
608
+ ) => {
609
+ const colorFor = (type: string) =>
610
+ type === "hot-fix"
611
+ ? "border-rose-500/40 bg-rose-950/30"
612
+ : type === "submission"
613
+ ? "border-emerald-500/30 bg-emerald-950/30"
614
+ : "bg-slate-950/60";
615
+ const labelFor = (type: string) =>
616
+ type === "hot-fix"
617
+ ? "text-rose-300"
618
+ : type === "submission"
619
+ ? "text-emerald-300"
620
+ : "text-cyan-300";
621
+ return (
622
+ <div
623
+ ref={ref}
624
+ className="pop-in absolute left-full top-0 z-40 ml-3 rounded border border-cyan-400/60 bg-[#0b1328] px-3 py-2 shadow-[0_10px_30px_rgba(0,0,0,0.6)]"
625
+ style={{ width: 340 }}
626
+ >
627
+ <div className="flex items-center justify-between">
628
+ <div className="font-mono text-[9px] uppercase tracking-[0.2em] text-slate-500">
629
+ Command Log
630
+ </div>
631
+ <button
632
+ type="button"
633
+ onClick={onClose}
634
+ className="text-sm leading-none text-slate-400 hover:text-slate-100"
635
+ aria-label="Close"
636
+ >
637
+ ×
638
+ </button>
639
+ </div>
640
+ <div className="mt-1.5 max-h-[320px] space-y-1 overflow-auto pr-1">
641
+ {entries.length === 0 ? (
642
+ <div className="rounded bg-slate-950/50 px-2 py-2 text-[10px] text-slate-500">
643
+ No owner prompts yet.
644
+ </div>
645
+ ) : (
646
+ entries.map((entry, idx) => (
647
+ <div
648
+ key={`${entry.ts}-${idx}`}
649
+ className={`rounded border border-transparent px-2 py-1.5 ${colorFor(entry.type)}`}
650
+ >
651
+ <div className="flex items-center justify-between text-[9px]">
652
+ <span className={`font-mono ${labelFor(entry.type)}`}>/{entry.type}</span>
653
+ <span className="text-slate-500">{formatTime(entry.ts)}</span>
654
+ </div>
655
+ <div className="mt-0.5 line-clamp-2 text-[10px] text-slate-300">
656
+ {entry.content}
657
+ </div>
658
+ </div>
659
+ ))
660
+ )}
661
+ </div>
662
+ <div className="absolute -left-[6px] top-3 size-3 rotate-45 border-b border-l border-cyan-400/60 bg-[#0b1328]" />
368
663
  </div>
369
- </section>
664
+ );
665
+ };
666
+ return Object.assign(
667
+ // eslint-disable-next-line react/display-name
668
+ (
669
+ props: { entries: OwnerPromptEntry[]; onClose: () => void } & {
670
+ ref?: React.Ref<HTMLDivElement>;
671
+ }
672
+ ) => Inner(props, props.ref ?? null),
673
+ {}
370
674
  );
675
+ })() as React.ForwardRefExoticComponent<
676
+ { entries: OwnerPromptEntry[]; onClose: () => void } & React.RefAttributes<HTMLDivElement>
677
+ >;
678
+
679
+ function formatTime(iso: string | null | undefined) {
680
+ if (!iso) return "";
681
+ try {
682
+ const d = new Date(iso);
683
+ if (isNaN(d.getTime())) return "";
684
+ // Always render in the browser's local timezone — never UTC.
685
+ const pad = (n: number) => String(n).padStart(2, "0");
686
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
687
+ } catch {
688
+ return "";
689
+ }
371
690
  }
372
691
 
373
- function ActivityIndicator({
374
- snapshot,
375
- connectionState,
692
+ // ===== Knowledge base panel =====
693
+
694
+ type KnowledgeKind = "Gotcha" | "Convention";
695
+
696
+ function KnowledgeBasePanel({
697
+ gotchas,
698
+ conventions,
699
+ baselineTs,
700
+ onSelect,
376
701
  }: {
377
- snapshot: HarnessSnapshot;
378
- connectionState: ConnectionState;
702
+ gotchas: GotchaEntry[];
703
+ conventions: ConventionEntry[];
704
+ baselineTs: string | null;
705
+ onSelect: (entry: GotchaEntry | ConventionEntry, kind: KnowledgeKind) => void;
379
706
  }) {
380
- const [now, setNow] = useState(Date.now());
381
- useEffect(() => {
382
- const id = setInterval(() => setNow(Date.now()), 5000);
383
- return () => clearInterval(id);
384
- }, []);
385
- const ts = Date.parse(snapshot.ts);
386
- const ageSec = Number.isFinite(ts) ? Math.max(0, Math.floor((now - ts) / 1000)) : null;
387
- const isStale = ageSec !== null && ageSec > 30;
707
+ const baseline = baselineTs ? new Date(baselineTs).getTime() : 0;
708
+ const isNew = (updatedAt?: string | null) => {
709
+ if (!updatedAt || !baseline) return false;
710
+ const t = new Date(updatedAt).getTime();
711
+ return Number.isFinite(t) && t >= baseline;
712
+ };
713
+ const newGotchas = gotchas.filter((g) => isNew(g.updatedAt)).length;
714
+ const newConventions = conventions.filter((c) => isNew(c.updatedAt)).length;
715
+
388
716
  return (
389
- <div className="flex items-center gap-2 text-[10px] font-mono">
390
- <span
391
- data-testid="connection-state"
392
- data-state={connectionState}
393
- className={`inline-block rounded px-2 py-0.5 ${
394
- connectionState === "open"
395
- ? "bg-aura-typing/20 text-aura-typing"
396
- : connectionState === "stale"
397
- ? "bg-aura-talking/20 text-aura-talking"
398
- : connectionState === "failed"
399
- ? "bg-aura-alert/20 text-aura-alert"
400
- : "bg-gray-500/20 text-gray-400"
401
- }`}
402
- >
403
- SSE: {connectionState}
404
- </span>
405
- <span
406
- data-testid="activity-age"
407
- data-stale={isStale}
408
- className={`inline-block rounded px-2 py-0.5 ${
409
- isStale
410
- ? "bg-aura-alert/20 text-aura-alert"
411
- : "bg-aura-typing/20 text-aura-typing"
412
- }`}
413
- >
414
- {ageSec === null
415
- ? "—"
416
- : isStale
417
- ? `stale ${ageSec}s — 회사가 멈춰 보입니다`
418
- : `live ${ageSec}s ago`}
419
- </span>
420
- {snapshot.errorBanner && (
421
- <span className="inline-block rounded bg-aura-alert/20 px-2 py-0.5 text-aura-alert">
422
- banner: {snapshot.errorBanner.level}
423
- </span>
424
- )}
717
+ <div className="panel-shell fade-in flex min-h-0 flex-col p-2">
718
+ <SectionLabel>Info · Knowledge Base</SectionLabel>
719
+ <div className="mt-2 grid min-h-0 flex-1 grid-cols-2 gap-2">
720
+ <KnowledgeList
721
+ title="Gotchas"
722
+ kind="Gotcha"
723
+ total={gotchas.length}
724
+ added={newGotchas}
725
+ entries={gotchas}
726
+ isNew={isNew}
727
+ dirRel=".harness/gotchas"
728
+ onSelect={onSelect}
729
+ />
730
+ <KnowledgeList
731
+ title="Conventions"
732
+ kind="Convention"
733
+ total={conventions.length}
734
+ added={newConventions}
735
+ entries={conventions}
736
+ isNew={isNew}
737
+ dirRel=".harness/conventions"
738
+ onSelect={onSelect}
739
+ />
740
+ </div>
425
741
  </div>
426
742
  );
427
743
  }
428
744
 
429
- function Panel({
745
+ function KnowledgeList({
430
746
  title,
431
- eyebrow,
432
- children,
747
+ kind,
748
+ total,
749
+ added,
750
+ entries,
751
+ isNew,
752
+ dirRel,
753
+ onSelect,
433
754
  }: {
434
755
  title: string;
435
- eyebrow?: string;
436
- children: React.ReactNode;
756
+ kind: KnowledgeKind;
757
+ total: number;
758
+ added: number;
759
+ entries: Array<GotchaEntry | ConventionEntry>;
760
+ isNew: (ts?: string | null) => boolean;
761
+ dirRel: string;
762
+ onSelect: (entry: GotchaEntry | ConventionEntry, kind: KnowledgeKind) => void;
437
763
  }) {
438
764
  return (
439
- <section className="rounded-md border border-gray-700/80 bg-[#171a20]/95 p-3 shadow-xl">
440
- <div className="mb-3 flex items-center justify-between gap-3">
441
- <div>
442
- {eyebrow && (
443
- <p className="font-mono text-[10px] uppercase tracking-[0.2em] text-cyan-300/70">
444
- {eyebrow}
445
- </p>
765
+ <div className="inset-shell flex min-h-0 flex-col p-2">
766
+ <div className="flex items-center justify-between">
767
+ <span className="text-[11px] text-cyan-200">{title}</span>
768
+ <span className="text-[10px] text-slate-400">
769
+ {total}
770
+ {added > 0 && (
771
+ <>
772
+ {" "}
773
+ <span className="font-semibold text-emerald-300">+{added}</span>
774
+ </>
446
775
  )}
447
- <h2 className="text-sm font-semibold text-gray-100">{title}</h2>
448
- </div>
776
+ </span>
449
777
  </div>
450
- {children}
451
- </section>
778
+ <div className="mt-1.5 min-h-0 flex-1 space-y-0.5 overflow-auto pr-1 text-[10px]">
779
+ {entries.length === 0 ? (
780
+ <div className="text-slate-500">(empty)</div>
781
+ ) : (
782
+ entries.map((entry) => (
783
+ <button
784
+ key={entry.id}
785
+ type="button"
786
+ onClick={() => onSelect(entry, kind)}
787
+ className="block w-full cursor-pointer truncate rounded px-1 py-0.5 text-left text-slate-300 transition-colors hover:bg-cyan-400/5 hover:text-cyan-200"
788
+ title={`${dirRel}/${entry.id}.md`}
789
+ >
790
+ → {entry.id}.md
791
+ {isNew(entry.updatedAt) && <span className="text-emerald-300"> ●</span>}
792
+ </button>
793
+ ))
794
+ )}
795
+ </div>
796
+ </div>
452
797
  );
453
798
  }
454
799
 
455
- function Metric({ label, value, tone }: { label: string; value: React.ReactNode; tone: "neutral" | "cyan" | "green" }) {
456
- const color = tone === "cyan" ? "text-cyan-300" : tone === "green" ? "text-emerald-300" : "text-gray-200";
800
+ // ===== Layer activity panel =====
801
+
802
+ function LayerActivityPanel({
803
+ goalScope,
804
+ todos,
805
+ label,
806
+ }: {
807
+ goalScope: MissionDoc[];
808
+ todos: CxxTodo[];
809
+ label: string;
810
+ }) {
811
+ // Sum workers across every mission in the selected goal sub-tree so the
812
+ // participation bar reflects the whole goal, not a single sub-mission.
813
+ const allWorkers = useMemo(
814
+ () => goalScope.flatMap((m) => m.workers),
815
+ [goalScope]
816
+ );
817
+ const workerByCxx = useMemo(() => {
818
+ const map = new Map<WorkerOwnerRole, number>();
819
+ for (const role of WORKER_OWNERS) map.set(role, 0);
820
+ for (const w of allWorkers) {
821
+ const r = w.owner;
822
+ if ((WORKER_OWNERS as readonly string[]).includes(r)) {
823
+ const role = r as WorkerOwnerRole;
824
+ map.set(role, (map.get(role) ?? 0) + 1);
825
+ }
826
+ }
827
+ return map;
828
+ }, [allWorkers]);
829
+
830
+ const layer = useMemo(
831
+ () => computeGoalScopeLayers(goalScope, todos),
832
+ [goalScope, todos]
833
+ );
834
+
835
+ const activeCxxCount = Array.from(workerByCxx.values()).filter((v) => v > 0).length;
836
+
457
837
  return (
458
- <div className="rounded border border-gray-700/70 bg-black/20 px-2 py-2">
459
- <div className="font-mono text-[10px] uppercase text-gray-500">{label}</div>
460
- <div className={`mt-1 text-lg font-semibold ${color}`}>{value}</div>
838
+ <div className="panel-shell fade-in flex h-full min-h-0 flex-col overflow-hidden p-2">
839
+ <SectionLabel>Layer · {label}</SectionLabel>
840
+
841
+ <div className="inset-shell mt-2 shrink-0 p-2">
842
+ <div className="flex items-center justify-between font-mono text-[9px] text-slate-500">
843
+ <span>실무자 비중 (worker by CXX, CEO 제외)</span>
844
+ <span>
845
+ {allWorkers.length}w · {activeCxxCount} CXX
846
+ </span>
847
+ </div>
848
+ <div className="mt-1.5 flex h-3 overflow-hidden rounded">
849
+ {WORKER_OWNERS.map((role) => {
850
+ const count = workerByCxx.get(role) ?? 0;
851
+ return (
852
+ <div
853
+ key={role}
854
+ className={`stack-seg transition-[flex] duration-500 ease-out ${
855
+ count > 0 ? CXX_COLOR[role] : `${CXX_COLOR[role]}/30`
856
+ }`}
857
+ style={{ flex: Math.max(0.1, count) }}
858
+ title={`${role.toUpperCase()} · ${count}`}
859
+ />
860
+ );
861
+ })}
862
+ </div>
863
+ <div className="mt-1.5 flex flex-wrap gap-x-2 gap-y-1 font-mono text-[9px] text-slate-400">
864
+ {WORKER_OWNERS.map((role) => (
865
+ <span key={role} className="inline-flex items-center gap-1">
866
+ <span
867
+ className={`inline-block size-2 rounded-sm ${
868
+ (workerByCxx.get(role) ?? 0) > 0 ? CXX_COLOR[role] : `${CXX_COLOR[role]}/30`
869
+ }`}
870
+ />
871
+ {role.toUpperCase()} {workerByCxx.get(role) ?? 0}
872
+ </span>
873
+ ))}
874
+ </div>
875
+ </div>
876
+
877
+ <div className="inset-shell mt-2 h-0 min-h-[96px] flex-1 overflow-y-auto overflow-x-hidden">
878
+ <table className="w-full text-[10px]">
879
+ <thead className="sticky top-0 bg-slate-900/60 font-mono text-[9px] uppercase tracking-wider">
880
+ <tr>
881
+ <th className="px-2 py-1 text-left text-slate-500">Layer</th>
882
+ <th className="px-2 py-1 text-right text-slate-500">TODO</th>
883
+ <th className="px-2 py-1 text-right text-emerald-400/80">DONE</th>
884
+ <th className="px-2 py-1 text-right text-orange-300/80">Remain</th>
885
+ </tr>
886
+ </thead>
887
+ <tbody>
888
+ <LayerRow label="CEO" stats={layer.ceo} />
889
+ <LayerRow label="CXX" stats={layer.cxx} />
890
+ <LayerRow label="Worker" stats={layer.worker} />
891
+ {layer.agents.map((agent) => (
892
+ <tr key={agent.id} className="border-t border-slate-800/70 bg-slate-950/30">
893
+ <td
894
+ className={`max-w-0 truncate px-2 py-1 ${
895
+ agent.kind === "cxx" ? "font-semibold text-cyan-200" : "pl-5 text-slate-400"
896
+ }`}
897
+ title={agent.id}
898
+ >
899
+ {agent.kind === "worker" ? "└ " : ""}
900
+ {agent.label}
901
+ </td>
902
+ <td className="px-2 py-1 text-right text-slate-300">
903
+ {agent.total}
904
+ </td>
905
+ <td className="px-2 py-1 text-right text-emerald-300">
906
+ {agent.done}
907
+ </td>
908
+ <td className={`px-2 py-1 text-right ${agent.remain > 0 ? "text-orange-300" : "text-slate-600"}`}>
909
+ {agent.remain}
910
+ </td>
911
+ </tr>
912
+ ))}
913
+ <tr className="border-t border-slate-800 bg-slate-900/40">
914
+ <td className="px-2 py-1 font-semibold text-slate-300">∑</td>
915
+ <td className="px-2 py-1 text-right font-semibold text-slate-100">
916
+ {layer.ceo.total + layer.cxx.total + layer.worker.total}
917
+ </td>
918
+ <td className="px-2 py-1 text-right font-semibold text-emerald-300">
919
+ {layer.ceo.done + layer.cxx.done + layer.worker.done}
920
+ </td>
921
+ <td className="px-2 py-1 text-right font-semibold text-orange-300">
922
+ {layer.ceo.remain + layer.cxx.remain + layer.worker.remain}
923
+ </td>
924
+ </tr>
925
+ </tbody>
926
+ </table>
927
+ </div>
461
928
  </div>
462
929
  );
463
930
  }
464
931
 
465
- function StatusLine({ label, value }: { label: string; value: React.ReactNode }) {
932
+ function LayerRow({ label, stats }: { label: string; stats: LayerStats }) {
466
933
  return (
467
- <div className="flex items-center justify-between gap-2 rounded bg-black/20 px-2 py-1 font-mono">
468
- <span className="text-gray-500">{label}</span>
469
- <span className="truncate text-gray-200">{value}</span>
470
- </div>
934
+ <tr className="border-t border-slate-800">
935
+ <td className="px-2 py-1 text-cyan-200">{label}</td>
936
+ <td className="px-2 py-1 text-right text-slate-200">{stats.total}</td>
937
+ <td className="px-2 py-1 text-right text-emerald-300">{stats.done}</td>
938
+ <td
939
+ className={`px-2 py-1 text-right ${stats.remain > 0 ? "text-orange-300" : "text-slate-500"}`}
940
+ >
941
+ {stats.remain}
942
+ </td>
943
+ </tr>
471
944
  );
472
945
  }
473
946
 
474
- function EmptyLine({ text }: { text: string }) {
475
- return <div className="rounded border border-dashed border-gray-700 px-3 py-6 text-center text-xs text-gray-500">{text}</div>;
476
- }
947
+ type LayerStats = { total: number; done: number; remain: number };
477
948
 
478
- function statusTone(status: WorkerSnapshot["status"]): string {
479
- if (status === "spawned" || status === "running") return "bg-cyan-400/15 text-cyan-300";
480
- if (status === "recorded") return "bg-amber-400/15 text-amber-300";
481
- if (status === "blocked") return "bg-rose-500/15 text-rose-300";
482
- return "bg-gray-500/15 text-gray-300";
949
+ function normalizeKind(s: string) {
950
+ return s.replace(/[_-]/g, "").toLowerCase();
483
951
  }
484
952
 
485
- function featureTone(status: string): string {
486
- if (status === "passed") return "text-emerald-300";
487
- if (status === "failed" || status === "blocked") return "text-rose-300";
488
- if (status === "in_progress" || status === "ready") return "text-cyan-300";
489
- return "text-gray-500";
953
+ function isTerminalLifecycle(lifecycle: MissionDoc["lifecycle"]) {
954
+ return (
955
+ lifecycle === "closed" ||
956
+ lifecycle === "cancelled" ||
957
+ lifecycle === "superseded" ||
958
+ lifecycle === "complete"
959
+ );
490
960
  }
491
961
 
492
- function serviceTone(status: OpsServiceHealth["status"]): string {
493
- if (status === "ok") return "bg-emerald-400/15 text-emerald-300";
494
- if (status === "degraded") return "bg-amber-400/15 text-amber-300";
495
- if (status === "down") return "bg-rose-500/15 text-rose-300";
496
- return "bg-gray-500/15 text-gray-300";
962
+ function computeGoalScopeLayers(
963
+ goalScope: MissionDoc[],
964
+ todos: CxxTodo[]
965
+ ): {
966
+ ceo: LayerStats;
967
+ cxx: LayerStats;
968
+ worker: LayerStats;
969
+ agents: Array<{
970
+ id: string;
971
+ label: string;
972
+ kind: "cxx" | "worker";
973
+ total: number;
974
+ done: number;
975
+ remain: number;
976
+ }>;
977
+ } {
978
+ // === CEO layer: ceo todos (status.json) ↔ mission directories of matching kind ===
979
+ // Runtime rarely flips status to "done", so infer completion from how many
980
+ // mission directories of each kind already exist under the goal scope.
981
+ const ceoTodos = todos.filter((t) => t.owner.toLowerCase() === "ceo");
982
+ const todoByKind = new Map<string, number>();
983
+ for (const t of ceoTodos) {
984
+ const k = normalizeKind(t.kind || "unknown");
985
+ todoByKind.set(k, (todoByKind.get(k) ?? 0) + 1);
986
+ }
987
+ const missionByKind = new Map<string, number>();
988
+ for (const m of goalScope) {
989
+ const k = normalizeKind(m.type);
990
+ missionByKind.set(k, (missionByKind.get(k) ?? 0) + 1);
991
+ }
992
+ const ceo: LayerStats = { total: ceoTodos.length, done: 0, remain: 0 };
993
+ for (const [kind, count] of todoByKind) {
994
+ const matched = missionByKind.get(kind) ?? 0;
995
+ ceo.done += Math.min(count, matched);
996
+ }
997
+ ceo.remain = ceo.total - ceo.done;
998
+
999
+ // === CXX layer: a CXX is "done" on a mission once its <role>.md exists ===
1000
+ // cxxPresent already encodes that signal. CEO is excluded — CEO is its own layer.
1001
+ let cxxTotal = 0;
1002
+ let cxxDone = 0;
1003
+ for (const m of goalScope) {
1004
+ const presentNonCeo = m.cxxPresent.filter(
1005
+ (r) => (WORKER_OWNERS as readonly string[]).includes(r)
1006
+ );
1007
+ cxxTotal += presentNonCeo.length;
1008
+ cxxDone += presentNonCeo.length;
1009
+ }
1010
+ const cxx: LayerStats = { total: cxxTotal, done: cxxDone, remain: cxxTotal - cxxDone };
1011
+
1012
+ // === Worker layer: every worker doc that submitted output (status COMPLETE) ===
1013
+ let wTotal = 0;
1014
+ let wDone = 0;
1015
+ for (const m of goalScope) {
1016
+ wTotal += m.workers.length;
1017
+ wDone += isTerminalLifecycle(m.lifecycle)
1018
+ ? m.workers.length
1019
+ : m.workers.filter((w) => w.status === "COMPLETE").length;
1020
+ }
1021
+ const worker: LayerStats = { total: wTotal, done: wDone, remain: wTotal - wDone };
1022
+
1023
+ const agentRows: Array<{
1024
+ id: string;
1025
+ label: string;
1026
+ kind: "cxx" | "worker";
1027
+ total: number;
1028
+ done: number;
1029
+ remain: number;
1030
+ }> = [];
1031
+ const groupedRows = WORKER_OWNERS.map((role, index) => {
1032
+ const roleMissions = goalScope.filter(
1033
+ (m) => m.cxxPresent.includes(role) || m.workers.some((w) => w.owner === role)
1034
+ );
1035
+ const roleTotal = roleMissions.length;
1036
+ const roleIncomplete = roleMissions.filter(
1037
+ (m) =>
1038
+ !isTerminalLifecycle(m.lifecycle) &&
1039
+ m.workers.some((w) => w.owner === role && w.status !== "COMPLETE")
1040
+ ).length;
1041
+ const rows: typeof agentRows = [];
1042
+ rows.push({
1043
+ id: role,
1044
+ label: role.toUpperCase(),
1045
+ kind: "cxx",
1046
+ total: roleTotal,
1047
+ done: Math.max(0, roleTotal - roleIncomplete),
1048
+ remain: roleIncomplete,
1049
+ });
1050
+
1051
+ const workersByName = new Map<string, { label: string; total: number; done: number }>();
1052
+ for (const mission of goalScope) {
1053
+ for (const worker of mission.workers.filter((w) => w.owner === role)) {
1054
+ const entry =
1055
+ workersByName.get(worker.name) ??
1056
+ { label: worker.displayName || worker.name, total: 0, done: 0 };
1057
+ entry.total += 1;
1058
+ if (isTerminalLifecycle(mission.lifecycle) || worker.status === "COMPLETE") {
1059
+ entry.done += 1;
1060
+ }
1061
+ workersByName.set(worker.name, entry);
1062
+ }
1063
+ }
1064
+ for (const [name, entry] of workersByName) {
1065
+ rows.push({
1066
+ id: `${role}:${name}`,
1067
+ label: entry.label,
1068
+ kind: "worker",
1069
+ total: entry.total,
1070
+ done: entry.done,
1071
+ remain: Math.max(0, entry.total - entry.done),
1072
+ });
1073
+ }
1074
+ const priority = roleTotal === 0 ? 2 : roleIncomplete > 0 ? 0 : 1;
1075
+ return { priority, index, rows };
1076
+ });
1077
+ groupedRows
1078
+ .sort((a, b) => a.priority - b.priority || a.index - b.index)
1079
+ .forEach((group) => agentRows.push(...group.rows));
1080
+
1081
+ return { ceo, cxx, worker, agents: agentRows };
497
1082
  }
498
1083
 
499
- function EscalationStrip({ escalations }: { escalations: EscalationEntry[] }) {
1084
+ // ===== Recent report panel =====
1085
+
1086
+ function RecentReportPanel({
1087
+ missions,
1088
+ onSelectWorker,
1089
+ }: {
1090
+ missions: MissionDoc[];
1091
+ onSelectWorker: (mission: MissionDoc, worker: WorkerDocEntry) => void;
1092
+ }) {
1093
+ const reports = useMemo(() => {
1094
+ const list: Array<{ mission: MissionDoc; worker: WorkerDocEntry; ts: number }> = [];
1095
+ for (const mission of missions) {
1096
+ for (const worker of mission.workers) {
1097
+ if (!worker.updatedAt) continue;
1098
+ const ts = new Date(worker.updatedAt).getTime();
1099
+ if (!Number.isFinite(ts)) continue;
1100
+ list.push({ mission, worker, ts });
1101
+ }
1102
+ }
1103
+ list.sort((a, b) => b.ts - a.ts);
1104
+ return list;
1105
+ }, [missions]);
1106
+
500
1107
  return (
501
- <div
502
- data-testid="escalation-strip"
503
- role="alert"
504
- 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"
505
- >
506
- <span className="font-semibold uppercase tracking-widest">
507
- ↑ {escalations.length} escalation{escalations.length === 1 ? "" : "s"}
508
- </span>
509
- {escalations.slice(0, 3).map((e) => (
510
- <span
511
- key={e.id}
512
- data-testid={`escalation-${e.id}`}
513
- className="rounded bg-aura-alert/20 px-2 py-0.5 text-[10px]"
514
- >
515
- {e.id} · {e.reason}
516
- </span>
517
- ))}
518
- {escalations.length > 3 && (
519
- <span className="text-[10px] opacity-70">+{escalations.length - 3} more</span>
520
- )}
1108
+ <div className="panel-shell fade-in flex min-h-0 flex-col p-2">
1109
+ <div className="flex items-center justify-between">
1110
+ <SectionLabel>Recent Report (from CXX)</SectionLabel>
1111
+ <span className="font-mono text-[9px] text-slate-500">latest on top</span>
1112
+ </div>
1113
+ <div className="mt-2 min-h-0 flex-1 space-y-1.5 overflow-auto pr-1 text-[10px]">
1114
+ {reports.length === 0 ? (
1115
+ <div className="text-slate-500">No worker reports yet.</div>
1116
+ ) : (
1117
+ reports.map(({ mission, worker, ts }) => {
1118
+ const role = worker.owner;
1119
+ const colorClass =
1120
+ role in CXX_TEXT ? CXX_TEXT[role as CxxRole] : "text-slate-300";
1121
+ // Strip leading YAML frontmatter (docmeta block) before scanning
1122
+ // for the first content line, otherwise we'd show "docmeta:" etc.
1123
+ const stripped = (worker.content || "").replace(/^---[\s\S]*?---\n+/, "");
1124
+ const firstLine = stripped
1125
+ .split("\n")
1126
+ .map((line) => line.trim())
1127
+ .find((line) => line && !line.startsWith("#"));
1128
+ return (
1129
+ <button
1130
+ key={`${mission.missionId}:${worker.name}`}
1131
+ type="button"
1132
+ onClick={() => onSelectWorker(mission, worker)}
1133
+ className="inset-shell slide-in block w-full cursor-pointer px-2 py-1.5 text-left transition-transform hover:-translate-y-0.5 hover:border-cyan-400/40"
1134
+ >
1135
+ <div className="flex items-center justify-between text-[9px]">
1136
+ <span className={colorClass}>
1137
+ {role.toUpperCase()} / {worker.displayName}
1138
+ </span>
1139
+ <span className="text-slate-500">{formatTime(new Date(ts).toISOString())}</span>
1140
+ </div>
1141
+ <div className="mt-0.5 truncate text-[10px] text-slate-200">
1142
+ {firstLine || `${mission.missionId} — report`}
1143
+ </div>
1144
+ </button>
1145
+ );
1146
+ })
1147
+ )}
1148
+ </div>
521
1149
  </div>
522
1150
  );
523
1151
  }
524
1152
 
525
- // Keep unused helper functions to avoid breaking other panels if needed later
526
- void Panel;
527
- void Metric;
528
- void StatusLine;
529
- void EmptyLine;
530
- void statusTone;
531
- void featureTone;
532
- void serviceTone;
1153
+ // ===== Cadence strip =====
1154
+
1155
+ function CadenceStrip({ ownerHistory }: { ownerHistory: OwnerPromptEntry[] }) {
1156
+ const bucketEntries = useMemo(() => {
1157
+ const now = Date.now();
1158
+ const arr: OwnerPromptEntry[][] = Array.from({ length: 24 }, () => []);
1159
+ for (const entry of ownerHistory) {
1160
+ if (!entry.ts) continue;
1161
+ const t = new Date(entry.ts).getTime();
1162
+ if (!Number.isFinite(t)) continue;
1163
+ const hoursAgo = Math.floor((now - t) / (60 * 60 * 1000));
1164
+ if (hoursAgo < 0 || hoursAgo >= 24) continue;
1165
+ arr[hoursAgo].push(entry);
1166
+ }
1167
+ // newest-first within each bucket
1168
+ for (const list of arr) {
1169
+ list.sort((a, b) => (b.ts ?? "").localeCompare(a.ts ?? ""));
1170
+ }
1171
+ return arr;
1172
+ }, [ownerHistory]);
1173
+ const buckets = useMemo(() => bucketEntries.map((b) => b.length), [bucketEntries]);
1174
+
1175
+ const max = Math.max(1, ...buckets);
1176
+ const cellColor = (n: number) => {
1177
+ if (n === 0) return "bg-slate-900";
1178
+ const ratio = n / max;
1179
+ if (ratio > 0.75) return "bg-rose-500/80 ring-1 ring-rose-400";
1180
+ if (ratio > 0.5) return "bg-orange-500/70";
1181
+ if (ratio > 0.25) return "bg-emerald-500/60";
1182
+ return "bg-emerald-500/30";
1183
+ };
1184
+
1185
+ const [tooltip, setTooltip] = useState<{
1186
+ idx: number;
1187
+ entries: OwnerPromptEntry[];
1188
+ x: number;
1189
+ y: number;
1190
+ } | null>(null);
1191
+
1192
+ const tooltipRef = useRef<HTMLDivElement>(null);
1193
+ useEffect(() => {
1194
+ if (!tooltip) return;
1195
+ const handler = (event: MouseEvent) => {
1196
+ const target = event.target as Node;
1197
+ if (tooltipRef.current?.contains(target)) return;
1198
+ const el = target as HTMLElement;
1199
+ if (el.closest?.("[data-cadence-cell]")) return;
1200
+ setTooltip(null);
1201
+ };
1202
+ document.addEventListener("mousedown", handler);
1203
+ return () => document.removeEventListener("mousedown", handler);
1204
+ }, [tooltip]);
1205
+
1206
+ const handleCellClick = (idx: number, event: React.MouseEvent<HTMLButtonElement>) => {
1207
+ const entries = bucketEntries[idx];
1208
+ if (entries.length === 0) {
1209
+ setTooltip(null);
1210
+ return;
1211
+ }
1212
+ const rect = event.currentTarget.getBoundingClientRect();
1213
+ setTooltip({
1214
+ idx,
1215
+ entries,
1216
+ x: rect.left + rect.width / 2,
1217
+ y: rect.bottom,
1218
+ });
1219
+ };
533
1220
 
534
- function OpsPanel({ services }: { services: OpsServiceHealth[] }) {
535
1221
  return (
536
- <section className="rounded-md border border-gray-700/80 bg-[#171a20]/95 p-3 shadow-xl">
537
- <div className="mb-3">
538
- <p className="font-mono text-[10px] uppercase tracking-[0.2em] text-cyan-300/70">production monitor</p>
539
- <h2 className="text-sm font-semibold text-gray-100">Service-Ops</h2>
1222
+ <div className="inset-shell relative shrink-0 px-2 pb-1.5 pt-1.5">
1223
+ <div className="flex items-center justify-between font-mono text-[9px] text-slate-500">
1224
+ <span>Cadence · last 24h (owner prompts/h) · click cell for detail</span>
1225
+ <span className="text-emerald-300">max {max}</span>
540
1226
  </div>
541
- {services.length === 0 ? (
542
- <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>
543
- ) : (
544
- <div className="space-y-1.5">
545
- {services.map((s) => (
546
- <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">
547
- <div className="min-w-0">
548
- <div className="truncate text-xs font-medium text-gray-100">{s.name}</div>
549
- <div className="font-mono text-[10px] text-gray-500">
550
- {s.host}:{s.port}{s.health_path ? ` · ${s.health_path}` : ""}
551
- </div>
1227
+ <div
1228
+ className="mt-1 grid gap-px"
1229
+ style={{ gridTemplateColumns: "repeat(24, minmax(0, 1fr))" }}
1230
+ >
1231
+ {buckets.map((count, idx) => {
1232
+ const interactive = count > 0;
1233
+ const isPeak = count > 0 && count === max;
1234
+ return (
1235
+ <button
1236
+ key={idx}
1237
+ data-cadence-cell
1238
+ type="button"
1239
+ disabled={!interactive}
1240
+ onClick={(event) => handleCellClick(idx, event)}
1241
+ className={`h-4 transition-all duration-300 ${cellColor(count)} ${
1242
+ isPeak ? "cad-peak" : ""
1243
+ } ${
1244
+ interactive
1245
+ ? "cursor-pointer hover:scale-y-150 hover:brightness-125"
1246
+ : "cursor-default"
1247
+ } ${tooltip?.idx === idx ? "ring-2 ring-cyan-300" : ""}`}
1248
+ title={interactive ? `-${idx}h · ${count} prompts` : ""}
1249
+ />
1250
+ );
1251
+ })}
1252
+ </div>
1253
+ <div className="mt-0.5 flex justify-between font-mono text-[8px] text-slate-600">
1254
+ <span>now</span>
1255
+ <span>-12h</span>
1256
+ <span>-24h</span>
1257
+ </div>
1258
+ {tooltip && (
1259
+ <CadencePromptTooltip
1260
+ ref={tooltipRef}
1261
+ entries={tooltip.entries}
1262
+ hourIdx={tooltip.idx}
1263
+ x={tooltip.x}
1264
+ y={tooltip.y}
1265
+ onClose={() => setTooltip(null)}
1266
+ />
1267
+ )}
1268
+ </div>
1269
+ );
1270
+ }
1271
+
1272
+ const CadencePromptTooltip = (() => {
1273
+ const Inner = (
1274
+ {
1275
+ entries,
1276
+ hourIdx,
1277
+ x,
1278
+ y,
1279
+ onClose,
1280
+ }: {
1281
+ entries: OwnerPromptEntry[];
1282
+ hourIdx: number;
1283
+ x: number;
1284
+ y: number;
1285
+ onClose: () => void;
1286
+ },
1287
+ ref: React.Ref<HTMLDivElement>
1288
+ ) => {
1289
+ const colorFor = (type: string) =>
1290
+ type === "hot-fix"
1291
+ ? "border-rose-500/40 bg-rose-950/30"
1292
+ : type === "submission"
1293
+ ? "border-emerald-500/30 bg-emerald-950/30"
1294
+ : "bg-slate-950/60";
1295
+ const labelFor = (type: string) =>
1296
+ type === "hot-fix"
1297
+ ? "text-rose-300"
1298
+ : type === "submission"
1299
+ ? "text-emerald-300"
1300
+ : "text-cyan-300";
1301
+ return (
1302
+ <div
1303
+ ref={ref}
1304
+ className="pop-in pointer-events-auto fixed z-40 rounded border border-cyan-400/60 bg-[#0b1328] px-3 py-2 shadow-[0_10px_30px_rgba(0,0,0,0.6)]"
1305
+ style={{
1306
+ left: Math.max(8, x - 200),
1307
+ top: Math.min(window.innerHeight - 320, y + 8),
1308
+ width: 400,
1309
+ }}
1310
+ >
1311
+ <div className="flex items-center justify-between">
1312
+ <div className="font-mono text-[9px] uppercase tracking-[0.2em] text-slate-500">
1313
+ Owner Prompts · {hourIdx === 0 ? "this hour" : `-${hourIdx}h`} ({entries.length})
1314
+ </div>
1315
+ <button
1316
+ type="button"
1317
+ onClick={onClose}
1318
+ className="text-sm leading-none text-slate-400 hover:text-slate-100"
1319
+ aria-label="Close"
1320
+ >
1321
+ ×
1322
+ </button>
1323
+ </div>
1324
+ <div className="mt-1.5 max-h-[280px] space-y-1 overflow-auto pr-1">
1325
+ {entries.map((entry, idx) => (
1326
+ <div
1327
+ key={`${entry.ts}-${idx}`}
1328
+ className={`rounded border border-transparent px-2 py-1.5 ${colorFor(entry.type)}`}
1329
+ >
1330
+ <div className="flex items-center justify-between text-[9px]">
1331
+ <span className={`font-mono ${labelFor(entry.type)}`}>/{entry.type}</span>
1332
+ <span className="text-slate-500">{formatTime(entry.ts)}</span>
552
1333
  </div>
553
- <div className="text-right">
554
- <span className={`rounded px-2 py-0.5 font-mono text-[10px] ${serviceTone(s.status)}`}>
555
- {s.status}
556
- </span>
557
- <div className="mt-1 font-mono text-[10px] text-gray-500">
558
- {s.health_status ?? s.port_state ?? "—"}
559
- </div>
1334
+ <div className="mt-0.5 line-clamp-3 text-[10px] text-slate-300">
1335
+ {entry.content}
560
1336
  </div>
561
1337
  </div>
562
1338
  ))}
563
1339
  </div>
564
- )}
565
- </section>
1340
+ </div>
1341
+ );
1342
+ };
1343
+ return Object.assign(
1344
+ (
1345
+ props: {
1346
+ entries: OwnerPromptEntry[];
1347
+ hourIdx: number;
1348
+ x: number;
1349
+ y: number;
1350
+ onClose: () => void;
1351
+ } & { ref?: React.Ref<HTMLDivElement> }
1352
+ ) => Inner(props, props.ref ?? null),
1353
+ {}
566
1354
  );
1355
+ })() as React.ForwardRefExoticComponent<
1356
+ {
1357
+ entries: OwnerPromptEntry[];
1358
+ hourIdx: number;
1359
+ x: number;
1360
+ y: number;
1361
+ onClose: () => void;
1362
+ } & React.RefAttributes<HTMLDivElement>
1363
+ >;
1364
+
1365
+ // ===== Heatmap =====
1366
+
1367
+ function formatMinuteOffset(minutes: number) {
1368
+ if (minutes === 0) return "now";
1369
+ if (minutes < 60) return `-${minutes}m`;
1370
+ const h = Math.floor(minutes / 60);
1371
+ const m = minutes % 60;
1372
+ return m === 0 ? `-${h}h` : `-${h}h${m}m`;
567
1373
  }
568
1374
 
569
- function EnvPanel({ envFiles }: { envFiles: EnvFileSummary[] }) {
570
- const first = envFiles[0];
571
- const firstKey = first?.keys[0]?.key ?? "";
572
- const [file, setFile] = useState(first?.path ?? ".env");
573
- const [key, setKey] = useState(firstKey);
574
- const [value, setValue] = useState("");
575
- const [status, setStatus] = useState<string | null>(null);
576
-
577
- const submitEnv = async () => {
578
- setStatus("saving");
579
- try {
580
- const res = await fetch("/api/env", {
581
- method: "POST",
582
- headers: { "Content-Type": "application/json" },
583
- body: JSON.stringify({ file, key, value }),
584
- });
585
- const body = await res.json().catch(() => ({}));
586
- if (!res.ok || !body.ok) {
587
- setStatus(body.error ?? "failed");
588
- return;
589
- }
590
- setValue("");
591
- setStatus(body.replaced ? "updated" : "added");
592
- } catch {
593
- setStatus("failed");
1375
+ function WorkflowHeatmap({
1376
+ lanes,
1377
+ cells,
1378
+ totalBuckets,
1379
+ selectedCells,
1380
+ dragStart,
1381
+ dragEnd,
1382
+ onDragStart,
1383
+ onDragMove,
1384
+ onDragEnd,
1385
+ onSelectLane,
1386
+ onCellClick,
1387
+ }: {
1388
+ lanes: AgentLane[];
1389
+ cells: HeatCell[];
1390
+ totalBuckets: number;
1391
+ selectedCells: HeatCell[];
1392
+ dragStart: { x: number; y: number } | null;
1393
+ dragEnd: { x: number; y: number } | null;
1394
+ onDragStart: (point: { x: number; y: number }) => void;
1395
+ onDragMove: (point: { x: number; y: number }) => void;
1396
+ onDragEnd: (missionId: string | null) => void;
1397
+ onSelectLane: (id: string) => void;
1398
+ onCellClick: (cell: HeatCell, event: React.PointerEvent) => void;
1399
+ }) {
1400
+ const scrollRef = useRef<HTMLDivElement>(null);
1401
+ const measureRef = useRef<HTMLDivElement>(null);
1402
+ const [containerWidth, setContainerWidth] = useState(0);
1403
+ const prevBucketCountRef = useRef(totalBuckets);
1404
+
1405
+ useEffect(() => {
1406
+ const el = measureRef.current;
1407
+ if (!el) return;
1408
+ setContainerWidth(el.clientWidth);
1409
+ const ro = new ResizeObserver((entries) => {
1410
+ const entry = entries[0];
1411
+ if (entry) setContainerWidth(entry.contentRect.width);
1412
+ });
1413
+ ro.observe(el);
1414
+ return () => ro.disconnect();
1415
+ }, []);
1416
+
1417
+ const availableWidth = Math.max(360, containerWidth - LANE_LABEL_WIDTH);
1418
+ const bucketPx = Math.max(MIN_BUCKET_PX, availableWidth / VISIBLE_BUCKETS);
1419
+
1420
+ useLayoutEffect(() => {
1421
+ const el = scrollRef.current;
1422
+ if (!el) {
1423
+ prevBucketCountRef.current = totalBuckets;
1424
+ return;
594
1425
  }
1426
+ const diff = totalBuckets - prevBucketCountRef.current;
1427
+ if (diff > 0 && el.scrollLeft > 0) {
1428
+ el.scrollLeft += diff * bucketPx;
1429
+ }
1430
+ prevBucketCountRef.current = totalBuckets;
1431
+ }, [totalBuckets, bucketPx]);
1432
+
1433
+ const selectedSet = useMemo(
1434
+ () => new Set(selectedCells.map((cell) => `${cell.x}:${cell.y}`)),
1435
+ [selectedCells]
1436
+ );
1437
+ const cellByXY = useMemo(() => {
1438
+ const map = new Map<string, HeatCell>();
1439
+ for (const cell of cells) map.set(`${cell.x}:${cell.y}`, cell);
1440
+ return map;
1441
+ }, [cells]);
1442
+
1443
+ const handlePointerUp = () => {
1444
+ if (!dragStart || !dragEnd) {
1445
+ onDragEnd(null);
1446
+ return;
1447
+ }
1448
+ const cell = cellByXY.get(`${dragEnd.x}:${dragEnd.y}`);
1449
+ onDragEnd(cell?.mission?.missionId ?? null);
595
1450
  };
596
1451
 
1452
+ const totalWidth = LANE_LABEL_WIDTH + totalBuckets * bucketPx;
1453
+
597
1454
  return (
598
- <section className="rounded-md border border-gray-700/80 bg-[#171a20]/95 p-3 shadow-xl">
599
- <div className="mb-3">
600
- <p className="font-mono text-[10px] uppercase tracking-[0.2em] text-cyan-300/70">masked .env overview</p>
601
- <h2 className="text-sm font-semibold text-gray-100">Environment Control</h2>
602
- </div>
603
- {envFiles.length === 0 ? (
604
- <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>
605
- ) : (
606
- <div className="space-y-2">
607
- <div className="rounded border border-cyan-400/30 bg-cyan-400/5 p-2">
608
- <div className="grid gap-2 sm:grid-cols-[1fr_1fr]">
609
- <label className="grid gap-1 font-mono text-[10px] text-gray-400">
610
- file
611
- <select
612
- value={file}
613
- onChange={(e) => {
614
- setFile(e.target.value);
615
- const nextFile = envFiles.find((item) => item.path === e.target.value);
616
- setKey(nextFile?.keys[0]?.key ?? "");
617
- }}
618
- className="rounded border border-gray-700 bg-[#11151a] px-2 py-1 text-xs text-gray-100"
619
- >
620
- {envFiles.map((item) => (
621
- <option key={item.path} value={item.path}>
622
- {item.path}
623
- </option>
624
- ))}
625
- </select>
626
- </label>
627
- <label className="grid gap-1 font-mono text-[10px] text-gray-400">
628
- key
629
- <select
630
- value={key}
631
- onChange={(e) => setKey(e.target.value)}
632
- className="rounded border border-gray-700 bg-[#11151a] px-2 py-1 text-xs text-gray-100"
633
- >
634
- {(envFiles.find((item) => item.path === file)?.keys ?? []).map((item) => (
635
- <option key={item.key} value={item.key}>
636
- {item.key}
637
- </option>
638
- ))}
639
- </select>
640
- </label>
641
- </div>
642
- <div className="mt-2 flex gap-2">
643
- <input
644
- value={value}
645
- onChange={(e) => setValue(e.target.value)}
646
- placeholder="new value, never stored in browser"
647
- 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"
648
- />
649
- <button
650
- type="button"
651
- onClick={submitEnv}
652
- disabled={!file || !key}
653
- 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"
1455
+ <div ref={measureRef} className="inset-shell relative min-h-0 flex-1 p-2">
1456
+ <div ref={scrollRef} className="h-full overflow-auto">
1457
+ <div
1458
+ className="grid select-none gap-px"
1459
+ style={{
1460
+ gridTemplateColumns: `${LANE_LABEL_WIDTH}px repeat(${totalBuckets}, ${bucketPx}px)`,
1461
+ gridTemplateRows: `${TIME_LABEL_HEIGHT}px repeat(${lanes.length}, ${LANE_ROW_HEIGHT}px)`,
1462
+ width: `${totalWidth}px`,
1463
+ }}
1464
+ onPointerLeave={handlePointerUp}
1465
+ onPointerUp={handlePointerUp}
1466
+ >
1467
+ <div
1468
+ className="sticky left-0 z-20 bg-[#071022]"
1469
+ style={{ gridColumn: 1, gridRow: 1 }}
1470
+ />
1471
+ {Array.from({ length: totalBuckets }).map((_, idx) => {
1472
+ const showLabel = idx === 0 || idx % 10 === 0;
1473
+ return (
1474
+ <div
1475
+ key={`t-${idx}`}
1476
+ className="grid place-items-center font-mono text-[9px] leading-none text-slate-500"
1477
+ style={{ gridColumn: idx + 2, gridRow: 1 }}
654
1478
  >
655
- Apply
656
- </button>
657
- </div>
658
- {status && <div className="mt-1 font-mono text-[10px] text-amber-300">{status}</div>}
659
- </div>
660
- {envFiles.map((file) => (
661
- <div key={file.path} className="rounded border border-gray-700/70 bg-black/20 p-2">
662
- <div className="flex items-center justify-between gap-2">
663
- <div className="font-mono text-xs text-gray-100">{file.path}</div>
664
- <div className="font-mono text-[10px] text-gray-500">{file.key_count} keys</div>
1479
+ {showLabel ? formatMinuteOffset(idx) : ""}
665
1480
  </div>
666
- <div className="mt-2 grid gap-1">
667
- {file.keys.slice(0, 5).map((k) => (
668
- <div key={k.key} className="flex items-center justify-between gap-2 font-mono text-[10px]">
669
- <span className="truncate text-gray-400">{k.key}</span>
670
- <span className={k.category === "secret" ? "text-rose-300" : "text-cyan-300/80"}>
671
- {k.masked}
672
- </span>
673
- </div>
674
- ))}
675
- </div>
676
- <p className="mt-2 text-[10px] leading-relaxed text-gray-500">
677
- Server-side editor target. Values stay masked until an explicit edit flow is opened.
678
- </p>
679
- </div>
1481
+ );
1482
+ })}
1483
+ {lanes.map((lane, idx) => (
1484
+ <button
1485
+ key={`lane-${lane.id}`}
1486
+ type="button"
1487
+ onClick={() => onSelectLane(lane.id)}
1488
+ className={`sticky left-0 z-10 truncate rounded bg-[#071022] px-2 text-left font-mono text-[10px] leading-[18px] ${
1489
+ lane.kind === "worker" ? "text-slate-500" : "text-slate-300"
1490
+ } hover:text-cyan-200`}
1491
+ style={{ gridColumn: 1, gridRow: idx + 2 }}
1492
+ title={`${lane.label} · ${lane.group}`}
1493
+ >
1494
+ {lane.label}
1495
+ </button>
680
1496
  ))}
1497
+ {cells.map((cell) => {
1498
+ const selected = selectedSet.has(`${cell.x}:${cell.y}`);
1499
+ const interactive = cell.mission !== null;
1500
+ return (
1501
+ <button
1502
+ key={`${cell.x}-${cell.y}-${cell.laneId}`}
1503
+ type="button"
1504
+ disabled={!interactive}
1505
+ onPointerDown={(event) => {
1506
+ if (!interactive) return;
1507
+ event.preventDefault();
1508
+ onDragStart({ x: cell.x, y: cell.y });
1509
+ onDragMove({ x: cell.x, y: cell.y });
1510
+ }}
1511
+ onPointerEnter={(event) => {
1512
+ if (!interactive) return;
1513
+ if (event.buttons === 1) {
1514
+ onDragMove({ x: cell.x, y: cell.y });
1515
+ }
1516
+ }}
1517
+ onClick={(event) => {
1518
+ if (!interactive) return;
1519
+ onCellClick(cell, event as unknown as React.PointerEvent);
1520
+ }}
1521
+ className={`heat-cell min-h-0 rounded-sm border transition-transform ${
1522
+ interactive ? "cursor-pointer hover:scale-[1.4] hover:z-10 hover:relative" : "cursor-default"
1523
+ } ${
1524
+ cell.hotfix
1525
+ ? "border-rose-500"
1526
+ : selected
1527
+ ? "border-cyan-300"
1528
+ : "border-transparent"
1529
+ } ${heatColor(cell.count)}`}
1530
+ style={{ gridColumn: cell.x + 2, gridRow: cell.y + 2 }}
1531
+ title={
1532
+ interactive
1533
+ ? `${cell.bucketLabel} · ${cell.laneLabel} · ${cell.mission?.missionId}`
1534
+ : ""
1535
+ }
1536
+ />
1537
+ );
1538
+ })}
1539
+ </div>
1540
+ </div>
1541
+ {dragStart && dragEnd && (
1542
+ <div className="pointer-events-none absolute bottom-2 right-3 rounded bg-cyan-950/90 px-2 py-1 font-mono text-[10px] text-cyan-100">
1543
+ selected {selectedCells.length} cells
681
1544
  </div>
682
1545
  )}
683
- </section>
1546
+ </div>
684
1547
  );
685
1548
  }
686
1549
 
687
- // Keep OpsPanel and EnvPanel available for future reuse
688
- void OpsPanel;
689
- void EnvPanel;
690
-
691
- function ArchiveList({ snapshot }: { snapshot: HarnessSnapshot }) {
692
- const list = snapshot.archive.all ?? snapshot.archive.recent ?? [];
693
- if (list.length === 0) {
694
- return <div className="text-gray-500">No archived sprints yet.</div>;
695
- }
1550
+ function ApprovalTooltip({
1551
+ mission,
1552
+ x,
1553
+ y,
1554
+ onClose,
1555
+ }: {
1556
+ mission: MissionDoc;
1557
+ x: number;
1558
+ y: number;
1559
+ onClose: () => void;
1560
+ }) {
1561
+ const tone =
1562
+ mission.type === "hotfix"
1563
+ ? "border-rose-500/50"
1564
+ : mission.type === "submission"
1565
+ ? "border-emerald-500/40"
1566
+ : "border-cyan-400/60";
696
1567
  return (
697
- <ul className="space-y-1">
698
- {list.map((entry) => (
699
- <li
700
- key={entry.dir}
701
- className="flex justify-between rounded border border-brick-wall bg-brick-wall/30 px-2 py-1"
1568
+ <div
1569
+ className={`pop-in pointer-events-auto fixed z-40 rounded border bg-[#0b1328] px-3 py-2 shadow-[0_10px_30px_rgba(0,0,0,0.6)] ${tone}`}
1570
+ style={{
1571
+ left: Math.max(8, x - 180),
1572
+ top: Math.max(8, y - 110),
1573
+ minWidth: 360,
1574
+ maxWidth: 460,
1575
+ }}
1576
+ >
1577
+ <div className="flex items-center justify-between gap-3">
1578
+ <div className="truncate font-mono text-[10px] text-cyan-300">
1579
+ {mission.type.toUpperCase()} · {mission.missionId}
1580
+ </div>
1581
+ <button
1582
+ type="button"
1583
+ onClick={onClose}
1584
+ className="text-sm leading-none text-slate-400 hover:text-slate-100"
1585
+ aria-label="Close"
702
1586
  >
703
- <span>{entry.label}</span>
704
- <span
705
- className={
706
- entry.result === "PASS"
707
- ? "text-aura-typing"
708
- : entry.result === "FAIL"
709
- ? "text-aura-alert"
710
- : "text-gray-500"
711
- }
712
- >
713
- {entry.result}
1587
+ ×
1588
+ </button>
1589
+ </div>
1590
+ <div className="mt-1.5 flex flex-wrap items-center gap-1 text-[10px]">
1591
+ <FlowChip label="Owner" />
1592
+ <Arrow />
1593
+ <FlowChip label="CEO" />
1594
+ {mission.cxxPresent.map((role) => (
1595
+ <span key={role} className="inline-flex items-center gap-1">
1596
+ <Arrow />
1597
+ <FlowChip label={role.toUpperCase()} />
1598
+ {mission.workers
1599
+ .filter((worker) => worker.owner === role)
1600
+ .slice(0, 3)
1601
+ .map((worker) => (
1602
+ <span key={worker.name} className="inline-flex items-center gap-1">
1603
+ <Arrow />
1604
+ <FlowChip label={worker.displayName} muted />
1605
+ </span>
1606
+ ))}
714
1607
  </span>
715
- </li>
716
- ))}
1608
+ ))}
1609
+ </div>
1610
+ </div>
1611
+ );
1612
+ }
1613
+
1614
+ // ===== Document Viewer (expandable) =====
1615
+
1616
+ const MARKDOWN_COMPONENTS: Components = {
1617
+ h1: ({ children }) => (
1618
+ <h1 className="mb-2 mt-3 text-base font-semibold text-slate-100">{children}</h1>
1619
+ ),
1620
+ h2: ({ children }) => (
1621
+ <h2 className="mb-2 mt-3 text-sm font-semibold text-cyan-200">{children}</h2>
1622
+ ),
1623
+ h3: ({ children }) => (
1624
+ <h3 className="mb-1 mt-2 font-mono text-[11px] uppercase tracking-wider text-cyan-300">
1625
+ {children}
1626
+ </h3>
1627
+ ),
1628
+ h4: ({ children }) => (
1629
+ <h4 className="mb-1 mt-2 text-[11px] font-semibold text-slate-200">{children}</h4>
1630
+ ),
1631
+ p: ({ children }) => (
1632
+ <p className="my-1.5 text-[11px] leading-5 text-slate-300">{children}</p>
1633
+ ),
1634
+ ul: ({ children }) => (
1635
+ <ul className="my-1.5 ml-4 list-disc space-y-0.5 text-[11px] text-slate-300">
1636
+ {children}
717
1637
  </ul>
1638
+ ),
1639
+ ol: ({ children }) => (
1640
+ <ol className="my-1.5 ml-5 list-decimal space-y-0.5 text-[11px] text-slate-300">
1641
+ {children}
1642
+ </ol>
1643
+ ),
1644
+ li: ({ children }) => <li className="leading-5">{children}</li>,
1645
+ a: ({ children, href }) => (
1646
+ <a
1647
+ href={href}
1648
+ target="_blank"
1649
+ rel="noreferrer"
1650
+ className="text-cyan-300 underline decoration-cyan-300/40 hover:text-cyan-200"
1651
+ >
1652
+ {children}
1653
+ </a>
1654
+ ),
1655
+ blockquote: ({ children }) => (
1656
+ <blockquote className="my-2 border-l-2 border-cyan-500/40 pl-3 text-[11px] italic text-slate-400">
1657
+ {children}
1658
+ </blockquote>
1659
+ ),
1660
+ hr: () => <hr className="my-3 border-slate-800" />,
1661
+ strong: ({ children }) => (
1662
+ <strong className="font-semibold text-slate-100">{children}</strong>
1663
+ ),
1664
+ em: ({ children }) => <em className="italic text-slate-300">{children}</em>,
1665
+ table: ({ children }) => (
1666
+ <div className="my-2 overflow-x-auto">
1667
+ <table className="w-full border-collapse text-[10px]">{children}</table>
1668
+ </div>
1669
+ ),
1670
+ thead: ({ children }) => <thead className="bg-slate-900/70">{children}</thead>,
1671
+ th: ({ children }) => (
1672
+ <th className="border border-slate-800 px-2 py-1 text-left font-mono text-[10px] text-cyan-300">
1673
+ {children}
1674
+ </th>
1675
+ ),
1676
+ td: ({ children }) => (
1677
+ <td className="border border-slate-800 px-2 py-1 text-slate-300">{children}</td>
1678
+ ),
1679
+ pre: ({ children }) => (
1680
+ <pre className="my-2 overflow-auto rounded border border-slate-800 bg-[#040a18] p-2 font-mono text-[10px] leading-4 text-slate-200">
1681
+ {children}
1682
+ </pre>
1683
+ ),
1684
+ code: ({ className, children, ...props }) => {
1685
+ const isBlock = typeof className === "string" && className.startsWith("language-");
1686
+ if (isBlock) {
1687
+ return (
1688
+ <code className={`${className} text-slate-200`} {...props}>
1689
+ {children}
1690
+ </code>
1691
+ );
1692
+ }
1693
+ return (
1694
+ <code className="rounded bg-slate-800/80 px-1 py-0.5 font-mono text-[10px] text-cyan-200">
1695
+ {children}
1696
+ </code>
1697
+ );
1698
+ },
1699
+ };
1700
+
1701
+ function DocumentViewer({
1702
+ mission,
1703
+ lane,
1704
+ customDoc,
1705
+ expanded,
1706
+ onToggle,
1707
+ }: {
1708
+ mission: MissionDoc | null;
1709
+ lane: AgentLane | null;
1710
+ customDoc: { source: string; title: string; content: string } | null;
1711
+ expanded: boolean;
1712
+ onToggle: () => void;
1713
+ }) {
1714
+ const role = lane?.role as
1715
+ | keyof Pick<MissionDoc, "ceo" | "coo" | "cdo" | "cto" | "cqo" | "ops">
1716
+ | undefined;
1717
+ const roleDoc = role ? mission?.[role] : null;
1718
+ const workerDoc = lane?.worker?.content ?? null;
1719
+ const rawContent = customDoc
1720
+ ? customDoc.content
1721
+ : workerDoc ?? roleDoc ?? mission?.ceo ?? "_No document selected._";
1722
+ // Hide the leading YAML frontmatter (docmeta block) — keep only the body.
1723
+ const content = rawContent.replace(/^---[\s\S]*?---\n+/, "");
1724
+ const subtitle = customDoc
1725
+ ? `${customDoc.source} · ${customDoc.title}`
1726
+ : `${mission?.missionId ?? "select /goal or /submission"} · ${
1727
+ workerDoc
1728
+ ? lane?.worker?.displayName ?? "worker"
1729
+ : role
1730
+ ? role.toUpperCase()
1731
+ : "CEO"
1732
+ }`;
1733
+ return (
1734
+ <section className="panel-shell flex min-h-0 flex-1 flex-col p-2">
1735
+ <div className="flex items-center justify-between gap-2">
1736
+ <SectionLabel>Document Viewer · {subtitle}</SectionLabel>
1737
+ <button
1738
+ type="button"
1739
+ onClick={onToggle}
1740
+ className="grid h-6 w-6 shrink-0 place-items-center rounded text-[11px] text-slate-400 hover:bg-white/10 hover:text-cyan-200"
1741
+ title={expanded ? "Minimize viewer" : "Expand to 50%"}
1742
+ aria-label={expanded ? "Minimize viewer" : "Expand viewer"}
1743
+ >
1744
+ <span className={`doc-toggle-icon ${expanded ? "rotate-180" : ""}`}>↗</span>
1745
+ </button>
1746
+ </div>
1747
+ <div className="inset-shell mt-2 min-h-0 flex-1 overflow-auto p-3">
1748
+ <article className="markdown-doc text-[11px] leading-5 text-slate-300">
1749
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
1750
+ {content}
1751
+ </ReactMarkdown>
1752
+ </article>
1753
+ </div>
1754
+ </section>
1755
+ );
1756
+ }
1757
+
1758
+ // ===== Shared bits =====
1759
+
1760
+ function SectionLabel({ children }: { children: React.ReactNode }) {
1761
+ return (
1762
+ <div className="font-mono text-[9px] uppercase tracking-[0.2em] text-slate-500 truncate">
1763
+ {children}
1764
+ </div>
1765
+ );
1766
+ }
1767
+
1768
+ function FlowChip({ label, muted = false }: { label: string; muted?: boolean }) {
1769
+ return (
1770
+ <span
1771
+ className={`rounded border px-1.5 py-0.5 font-mono ${
1772
+ muted ? "border-slate-700 text-slate-400" : "border-cyan-400/30 text-cyan-200"
1773
+ }`}
1774
+ >
1775
+ {label}
1776
+ </span>
718
1777
  );
719
1778
  }
720
1779
 
721
- // Keep ArchiveList available for future reuse
722
- void ArchiveList;
1780
+ function Arrow() {
1781
+ return <span className="text-slate-600">→</span>;
1782
+ }
1783
+
1784
+ function heatColor(count: number) {
1785
+ // 3-state activity from sample.count: 0 idle / 1 standby / 2 in-progress.
1786
+ if (count >= 2) return "bg-emerald-500/80";
1787
+ if (count >= 1) return "bg-emerald-500/30";
1788
+ return "bg-slate-900";
1789
+ }
1790
+
1791
+ // ===== Lane / Heatmap builders =====
1792
+
1793
+ function buildLanes(snapshot: HarnessSnapshot, selectedMission: MissionDoc | null): AgentLane[] {
1794
+ const activeMission = selectedMission ?? snapshot.missions[0] ?? null;
1795
+ // CXX → its workers, then next CXX → its workers (proper hierarchy).
1796
+ // No cap: heatmap container scrolls vertically so OPS at the bottom is always
1797
+ // reachable even when upstream CXX bring many workers.
1798
+ const lanes: AgentLane[] = [];
1799
+ for (const role of CXX_ROLES) {
1800
+ const workers = activeMission?.workers.filter((w) => w.owner === role) ?? [];
1801
+ const todos = snapshot.todos.filter((t) => t.owner === role);
1802
+ const hasError =
1803
+ (role === "ops" && snapshot.incidents.length > 0) ||
1804
+ todos.some((t) => t.status === "blocked");
1805
+ const waiting =
1806
+ todos.some((t) => t.status === "pending" || t.status === "paused") ||
1807
+ workers.some((w) => !w.active && w.status !== "COMPLETE");
1808
+ lanes.push({
1809
+ id: role,
1810
+ label: role.toUpperCase(),
1811
+ role,
1812
+ group: `${role.toUpperCase()} group · ${workers.length} workers`,
1813
+ kind: "cxx",
1814
+ status: hasError ? "error" : waiting ? "waiting" : "normal",
1815
+ todos: todos.length,
1816
+ workers,
1817
+ mission: activeMission,
1818
+ });
1819
+ for (const worker of workers) {
1820
+ lanes.push({
1821
+ id: `${role}:${worker.name}`,
1822
+ label: `└ ${worker.displayName.slice(0, 16)}`,
1823
+ role,
1824
+ group: `${role.toUpperCase()} worker`,
1825
+ kind: "worker",
1826
+ status: worker.active ? "normal" : worker.status === "COMPLETE" ? "normal" : "waiting",
1827
+ todos: 0,
1828
+ workers: [],
1829
+ worker,
1830
+ mission: activeMission,
1831
+ });
1832
+ }
1833
+ }
1834
+ return lanes;
1835
+ }
1836
+
1837
+ function buildHeatmap(
1838
+ snapshot: HarnessSnapshot,
1839
+ lanes: AgentLane[],
1840
+ samples: HeatSample[],
1841
+ startedAt: number
1842
+ ): { cells: HeatCell[]; totalBuckets: number } {
1843
+ const cells: HeatCell[] = [];
1844
+ const now = Date.now();
1845
+ const nowMinute = Math.floor(now / BUCKET_MS);
1846
+ const startMinute = Math.floor(startedAt / BUCKET_MS);
1847
+ const elapsedMinutes = Math.max(0, nowMinute - startMinute);
1848
+ const totalBuckets = Math.max(VISIBLE_BUCKETS, elapsedMinutes + 1);
1849
+ const missionById = new Map(snapshot.missions.map((m) => [m.missionId, m]));
1850
+
1851
+ type Agg = { count: number; hotfix: boolean; missionId: string | null };
1852
+ const aggregated = new Map<string, Agg>();
1853
+ for (const sample of samples) {
1854
+ const sampleMinute = Math.floor(sample.ts / BUCKET_MS);
1855
+ const x = nowMinute - sampleMinute;
1856
+ if (x < 0 || x >= totalBuckets) continue;
1857
+ const key = `${x}:${sample.laneId}`;
1858
+ const prev = aggregated.get(key);
1859
+ if (prev) {
1860
+ // Keep the strongest observed state in this bucket (0 < 1 < 2),
1861
+ // never sum — the cell should reflect "what was happening" not "how
1862
+ // many polling ticks happened".
1863
+ prev.count = Math.max(prev.count, sample.count);
1864
+ prev.hotfix = prev.hotfix || sample.hotfix;
1865
+ if (sample.missionId) prev.missionId = sample.missionId;
1866
+ } else {
1867
+ aggregated.set(key, {
1868
+ count: sample.count,
1869
+ hotfix: sample.hotfix,
1870
+ missionId: sample.missionId ?? null,
1871
+ });
1872
+ }
1873
+ }
1874
+
1875
+ for (let y = 0; y < lanes.length; y++) {
1876
+ const lane = lanes[y];
1877
+ for (let x = 0; x < totalBuckets; x++) {
1878
+ const agg = aggregated.get(`${x}:${lane.id}`);
1879
+ // Only assign mission when the cell actually carries activity.
1880
+ // Empty cells (count=0, no sample) must stay missionless so clicks no-op.
1881
+ const hasData = !!agg && agg.count > 0;
1882
+ const missionId = hasData ? agg!.missionId : null;
1883
+ cells.push({
1884
+ x,
1885
+ y,
1886
+ laneId: lane.id,
1887
+ laneLabel: lane.label,
1888
+ bucketLabel: formatMinuteOffset(x),
1889
+ count: agg?.count ?? 0,
1890
+ hotfix: agg?.hotfix ?? false,
1891
+ mission: missionId ? missionById.get(missionId) ?? null : null,
1892
+ });
1893
+ }
1894
+ }
1895
+ return { cells, totalBuckets };
1896
+ }
1897
+
1898
+ function getSelectedCells(
1899
+ cells: HeatCell[],
1900
+ start: { x: number; y: number } | null,
1901
+ end: { x: number; y: number } | null
1902
+ ) {
1903
+ if (!start || !end) return [];
1904
+ const minX = Math.min(start.x, end.x);
1905
+ const maxX = Math.max(start.x, end.x);
1906
+ const minY = Math.min(start.y, end.y);
1907
+ const maxY = Math.max(start.y, end.y);
1908
+ return cells.filter(
1909
+ (cell) => cell.x >= minX && cell.x <= maxX && cell.y >= minY && cell.y <= maxY
1910
+ );
1911
+ }