@walwal-harness/cli 7.1.27 → 7.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,722 +1,1724 @@
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
- };
42
-
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
- };
89
+ const [selectedMissionId, setSelectedMissionId] = useState(snapshot.missions[0]?.missionId ?? null);
90
+ const selectedMission =
91
+ snapshot.missions.find((mission) => mission.missionId === selectedMissionId) ??
92
+ snapshot.missions[0] ??
93
+ null;
94
+ const goalGroups = useMemo(() => groupMissions(snapshot.missions), [snapshot.missions]);
95
+ const selectedGoal = useMemo(() => {
96
+ if (!selectedMission) return goalGroups[0]?.goal ?? null;
97
+ const parentId = selectedMission.missionId.split("/")[0];
98
+ return goalGroups.find((g) => g.goal.missionId === parentId)?.goal ?? selectedMission;
99
+ }, [goalGroups, selectedMission]);
100
+ const goalScopeMissions = useMemo(() => {
101
+ if (!selectedGoal) return snapshot.missions;
102
+ const parentId = selectedGoal.missionId.split("/")[0];
103
+ return snapshot.missions.filter(
104
+ (m) => m.missionId === parentId || m.missionId.startsWith(`${parentId}/`)
105
+ );
106
+ }, [snapshot.missions, selectedGoal]);
60
107
 
61
- const handleMissionClick = (mission: MissionDoc) => {
62
- setSelectedMission(mission);
63
- setSelectedNode(null);
64
- setSelectedAgent(null);
65
- setDrawerTab("mission-flow");
66
- };
108
+ const lanes = useMemo(() => buildLanes(snapshot, selectedMission), [selectedMission, snapshot]);
109
+ const sessionStartedAtRef = useRef(Date.now());
110
+ const [heatSamples, setHeatSamples] = useState<HeatSample[]>([]);
111
+ const sampleSourceRef = useRef({ lanes, snapshot, selectedMission });
112
+ useEffect(() => {
113
+ sampleSourceRef.current = { lanes, snapshot, selectedMission };
114
+ });
115
+ useEffect(() => {
116
+ const tick = () => {
117
+ const { lanes, snapshot, selectedMission } = sampleSourceRef.current;
118
+ setHeatSamples((prev) => {
119
+ const now = Date.now();
120
+ const activeMission = selectedMission ?? snapshot.missions[0] ?? null;
121
+ const next: HeatSample[] = [];
122
+ for (const lane of lanes) {
123
+ const activity =
124
+ lane.kind === "worker"
125
+ ? lane.worker?.active
126
+ ? 2
127
+ : lane.worker?.status === "IN_PROGRESS"
128
+ ? 1
129
+ : 0
130
+ : lane.todos +
131
+ lane.workers.filter((w) => w.active).length +
132
+ (lane.mission ? 1 : 0);
133
+ if (activity <= 0) continue;
134
+ next.push({
135
+ ts: now,
136
+ laneId: lane.id,
137
+ count: activity,
138
+ hotfix: activeMission?.type === "hotfix" || lane.status === "error",
139
+ missionId: activeMission?.missionId ?? null,
140
+ });
141
+ }
142
+ if (next.length === 0) return prev;
143
+ return [...prev, ...next]
144
+ .filter((sample) => now - sample.ts < 30 * 60 * 1000)
145
+ .slice(-1500);
146
+ });
147
+ };
148
+ tick();
149
+ const id = setInterval(tick, 2000);
150
+ return () => clearInterval(id);
151
+ }, []);
152
+ const { cells: heatmap, totalBuckets } = useMemo(
153
+ () => buildHeatmap(snapshot, lanes, heatSamples, sessionStartedAtRef.current),
154
+ [heatSamples, lanes, snapshot]
155
+ );
156
+ const [selectedLaneId, setSelectedLaneId] = useState(lanes[0]?.id ?? "ceo");
157
+ const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null);
158
+ const [dragEnd, setDragEnd] = useState<{ x: number; y: number } | null>(null);
159
+ const [tooltip, setTooltip] = useState<{ mission: MissionDoc; x: number; y: number } | null>(null);
160
+ const [commandLogOpen, setCommandLogOpen] = useState(false);
161
+ const [docExpanded, setDocExpanded] = useState(false);
67
162
 
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
163
  void lang;
93
164
 
165
+ const selectedLane = lanes.find((lane) => lane.id === selectedLaneId) ?? lanes[0] ?? null;
166
+ const selectedCells = getSelectedCells(heatmap, dragStart, dragEnd);
167
+ const activeWorkers =
168
+ snapshot.missions[0]?.workers.filter((w) => w.active).length ?? 0;
169
+ const hotfixCount = snapshot.missions.filter((m) => m.type === "hotfix").length;
170
+
171
+ const handleSelectWorker = (mission: MissionDoc, worker: WorkerDocEntry) => {
172
+ setSelectedMissionId(mission.missionId);
173
+ setSelectedLaneId(`${worker.owner}:${worker.name}`);
174
+ };
175
+
94
176
  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>
177
+ <div className="flex h-[100dvh] flex-col overflow-hidden bg-[#070d1d] text-slate-200">
178
+ <TopHeader
179
+ projectName={snapshot.projectName}
180
+ missionId={selectedMission?.missionId ?? null}
181
+ connectionState={connectionState}
182
+ activeWorkers={activeWorkers}
183
+ hotfixCount={hotfixCount}
184
+ />
137
185
 
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}
186
+ <div className="flex min-h-0 flex-1">
187
+ <HistoryNavigator
188
+ missions={snapshot.missions}
189
+ ownerHistory={snapshot.ownerHistory}
190
+ activeMissionId={selectedMission?.missionId ?? null}
191
+ onSelect={setSelectedMissionId}
192
+ commandLogOpen={commandLogOpen}
193
+ onToggleCommandLog={() => setCommandLogOpen((v) => !v)}
194
+ onCloseCommandLog={() => setCommandLogOpen(false)}
195
+ />
196
+
197
+ <main className="flex min-w-0 flex-1 flex-col gap-2 p-2">
198
+ {/* Top row: Knowledge | Layer Activity | Recent Report */}
199
+ <section className="grid shrink-0 grid-cols-3 gap-2" style={{ height: 280 }}>
200
+ <KnowledgeBasePanel
201
+ gotchas={snapshot.gotchas}
202
+ conventions={snapshot.conventions}
203
+ baselineTs={selectedGoal?.ts ?? null}
204
+ />
205
+ <LayerActivityPanel
206
+ goalScope={goalScopeMissions}
207
+ todos={snapshot.todos}
208
+ label={selectedGoal?.missionId ?? "no goal"}
209
+ />
210
+ <RecentReportPanel
211
+ missions={snapshot.missions}
212
+ onSelectWorker={handleSelectWorker}
147
213
  />
148
214
  </section>
149
- </div>
150
215
 
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
- : "—"
216
+ {/* Heatmap section with cadence strip + main grid + click tooltip */}
217
+ <section className="panel-shell relative flex min-h-0 flex-1 flex-col gap-2 p-2">
218
+ <CadenceStrip ownerHistory={snapshot.ownerHistory} />
219
+ <WorkflowHeatmap
220
+ lanes={lanes}
221
+ cells={heatmap}
222
+ totalBuckets={totalBuckets}
223
+ selectedCells={selectedCells}
224
+ dragStart={dragStart}
225
+ dragEnd={dragEnd}
226
+ onDragStart={setDragStart}
227
+ onDragMove={setDragEnd}
228
+ onDragEnd={(missionId) => {
229
+ if (missionId) setSelectedMissionId(missionId);
230
+ }}
231
+ onSelectLane={setSelectedLaneId}
232
+ onCellClick={(cell, event) => {
233
+ if (!cell.mission) {
234
+ setTooltip(null);
235
+ return;
200
236
  }
237
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
238
+ setTooltip({
239
+ mission: cell.mission,
240
+ x: rect.left + rect.width / 2,
241
+ y: rect.top,
242
+ });
243
+ setSelectedMissionId(cell.mission.missionId);
244
+ }}
245
+ />
246
+ {tooltip && (
247
+ <ApprovalTooltip
248
+ mission={tooltip.mission}
249
+ x={tooltip.x}
250
+ y={tooltip.y}
251
+ onClose={() => setTooltip(null)}
201
252
  />
202
253
  )}
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>
254
+ </section>
255
+ </main>
218
256
 
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>
257
+ <aside
258
+ id="doc-aside"
259
+ className={`doc-aside flex shrink-0 flex-col gap-2 overflow-hidden border-l border-slate-800 bg-[#081124] p-2 ${
260
+ docExpanded ? "expanded" : ""
261
+ }`}
262
+ >
263
+ <DocumentViewer
264
+ mission={selectedMission}
265
+ lane={selectedLane}
266
+ expanded={docExpanded}
267
+ onToggle={() => setDocExpanded((v) => !v)}
268
+ />
269
+ </aside>
270
+ </div>
242
271
  </div>
243
272
  );
244
273
  }
245
274
 
246
- function CommandDeck({
247
- snapshot,
275
+ // ===== Top header =====
276
+
277
+ function TopHeader({
278
+ projectName,
279
+ missionId,
248
280
  connectionState,
281
+ activeWorkers,
282
+ hotfixCount,
249
283
  }: {
250
- snapshot: HarnessSnapshot;
251
- connectionState: ConnectionState;
284
+ projectName: string;
285
+ missionId: string | null;
286
+ connectionState: string;
287
+ activeWorkers: number;
288
+ hotfixCount: number;
252
289
  }) {
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
290
  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" />
291
+ <header className="flex shrink-0 items-center gap-4 border-b border-slate-700/60 bg-[#0b1328]/90 px-4 py-2 backdrop-blur">
292
+ <Wordmark className="text-base" />
293
+ <span
294
+ className="rounded bg-slate-800/60 px-2 py-0.5 font-mono text-[10px] font-semibold text-amber-300"
295
+ title={projectName}
296
+ >
297
+ {projectName}
298
+ </span>
299
+ <span className="hidden text-[11px] text-slate-400 sm:inline">
300
+ walwal-harness 라이브 운영 대시보드
301
+ </span>
302
+ <span className="min-w-0 flex-1 truncate font-mono text-[11px] text-cyan-200/80">
303
+ {missionId ?? "no active transaction"}
304
+ </span>
305
+ <div className="flex shrink-0 items-center gap-2">
306
+ <HeaderBadge
307
+ label="SSE"
308
+ value={connectionState}
309
+ tone={connectionState === "open" ? "green" : "orange"}
310
+ />
311
+ <HeaderBadge label="Workers" value={String(activeWorkers)} tone="cyan" />
312
+ <HeaderBadge label="Hot-fix" value={String(hotfixCount)} tone="red" />
313
+ <PresenceLegend />
291
314
  </div>
292
- </section>
315
+ </header>
293
316
  );
294
317
  }
295
318
 
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",
319
+ function HeaderBadge({
320
+ label,
321
+ value,
322
+ tone,
323
+ }: {
324
+ label: string;
325
+ value: string;
326
+ tone: "green" | "orange" | "red" | "cyan";
327
+ }) {
328
+ const color = {
329
+ green: "border-emerald-400/30 bg-emerald-400/10 text-emerald-200",
330
+ orange: "border-orange-400/30 bg-orange-400/10 text-orange-200",
331
+ red: "border-rose-400/30 bg-rose-400/10 text-rose-200",
332
+ cyan: "border-cyan-400/30 bg-cyan-400/10 text-cyan-200",
302
333
  }[tone];
303
334
  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>
335
+ <div className={`rounded-full border px-2.5 py-1 font-mono text-[10px] ${color}`}>
336
+ <span className="text-slate-500">{label}</span>
337
+ <span className="ml-1.5">{value}</span>
307
338
  </div>
308
339
  );
309
340
  }
310
341
 
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
- });
342
+ function PresenceLegend() {
323
343
  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}
344
+ <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">
345
+ <span className="inline-flex items-center gap-1">
346
+ <span className="size-1.5 rounded-full bg-aura-idle" /> idle
347
+ </span>
348
+ <span className="inline-flex items-center gap-1">
349
+ <span className="size-1.5 rounded-full bg-aura-typing" /> typing
350
+ </span>
351
+ <span className="inline-flex items-center gap-1">
352
+ <span className="size-1.5 rounded-full bg-aura-talking" /> talking
353
+ </span>
354
+ <span className="inline-flex items-center gap-1">
355
+ <span className="size-1.5 rounded-full bg-aura-alert" /> alert
356
+ </span>
357
+ </div>
358
+ );
359
+ }
360
+
361
+ // ===== History Navigator =====
362
+
363
+ function groupMissions(missions: MissionDoc[]): GoalGroup[] {
364
+ const parentSegment = (id: string) => id.split("/")[0] ?? id;
365
+ const groups: GoalGroup[] = [];
366
+ const groupById = new Map<string, GoalGroup>();
367
+
368
+ const ensureGroupFor = (mission: MissionDoc): GoalGroup => {
369
+ const key = parentSegment(mission.missionId);
370
+ const existing = groupById.get(key);
371
+ if (existing) return existing;
372
+ const group: GoalGroup = { goal: mission, children: [] };
373
+ groups.push(group);
374
+ groupById.set(key, group);
375
+ return group;
376
+ };
377
+
378
+ for (const mission of missions) {
379
+ if (mission.missionId.includes("/")) continue;
380
+ if (mission.type === "goal" || mission.type === "feature") ensureGroupFor(mission);
381
+ }
382
+ for (const mission of missions) {
383
+ if (mission.missionId.includes("/")) continue;
384
+ if (mission.type === "goal" || mission.type === "feature") continue;
385
+ ensureGroupFor(mission);
386
+ }
387
+ for (const mission of missions) {
388
+ if (!mission.missionId.includes("/")) continue;
389
+ const key = parentSegment(mission.missionId);
390
+ const group = groupById.get(key);
391
+ if (group) group.children.push(mission);
392
+ else ensureGroupFor(mission);
393
+ }
394
+
395
+ for (const g of groups) {
396
+ g.children.sort((a, b) => (a.ts ?? "").localeCompare(b.ts ?? ""));
397
+ }
398
+ groups.sort((a, b) => (b.goal.ts ?? "").localeCompare(a.goal.ts ?? ""));
399
+ return groups;
400
+ }
401
+
402
+ function HistoryNavigator({
403
+ missions,
404
+ ownerHistory,
405
+ activeMissionId,
406
+ onSelect,
407
+ commandLogOpen,
408
+ onToggleCommandLog,
409
+ onCloseCommandLog,
410
+ }: {
411
+ missions: MissionDoc[];
412
+ ownerHistory: OwnerPromptEntry[];
413
+ activeMissionId: string | null;
414
+ onSelect: (missionId: string) => void;
415
+ commandLogOpen: boolean;
416
+ onToggleCommandLog: () => void;
417
+ onCloseCommandLog: () => void;
418
+ }) {
419
+ const groups = useMemo(() => groupMissions(missions), [missions]);
420
+ const popoverRef = useRef<HTMLDivElement>(null);
421
+ const buttonRef = useRef<HTMLButtonElement>(null);
422
+
423
+ useEffect(() => {
424
+ if (!commandLogOpen) return;
425
+ const onDocClick = (event: MouseEvent) => {
426
+ const target = event.target as Node;
427
+ if (popoverRef.current?.contains(target) || buttonRef.current?.contains(target)) return;
428
+ onCloseCommandLog();
429
+ };
430
+ document.addEventListener("mousedown", onDocClick);
431
+ return () => document.removeEventListener("mousedown", onDocClick);
432
+ }, [commandLogOpen, onCloseCommandLog]);
433
+
434
+ return (
435
+ <aside className="flex w-[230px] shrink-0 flex-col border-r border-slate-800 bg-[#081124] px-3 py-3">
436
+ <div className="relative mb-3 flex items-center justify-between gap-2">
437
+ <div className="flex min-w-0 items-center gap-2">
438
+ <div className="grid h-7 w-7 shrink-0 place-items-center rounded bg-slate-700/70 text-xs">
439
+ AI
440
+ </div>
441
+ <div className="min-w-0">
442
+ <div className="text-xs font-semibold text-slate-100">Administrator</div>
443
+ <div className="font-mono text-[9px] text-slate-500">workflow history</div>
444
+ </div>
334
445
  </div>
446
+ <button
447
+ ref={buttonRef}
448
+ type="button"
449
+ onClick={onToggleCommandLog}
450
+ className="relative grid h-7 w-7 shrink-0 place-items-center rounded text-slate-400 hover:bg-white/10 hover:text-cyan-200"
451
+ title={`Command Log (${ownerHistory.length})`}
452
+ aria-label="Open command log"
453
+ >
454
+ <svg
455
+ width="14"
456
+ height="14"
457
+ viewBox="0 0 24 24"
458
+ fill="none"
459
+ stroke="currentColor"
460
+ strokeWidth="2"
461
+ strokeLinejoin="round"
462
+ >
463
+ <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" />
464
+ </svg>
465
+ {ownerHistory.length > 0 && (
466
+ <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">
467
+ {ownerHistory.length}
468
+ </span>
469
+ )}
470
+ </button>
471
+
472
+ {commandLogOpen && (
473
+ <CommandLogPopover
474
+ ref={popoverRef}
475
+ entries={ownerHistory}
476
+ onClose={onCloseCommandLog}
477
+ />
478
+ )}
335
479
  </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"
480
+
481
+ <nav className="min-h-0 flex-1 space-y-2 overflow-auto pr-1 text-xs">
482
+ <div className="font-mono text-[10px] uppercase tracking-[0.18em] text-slate-500">
483
+ /goal
484
+ </div>
485
+ {groups.length ? (
486
+ groups.map(({ goal, children }) => (
487
+ <div
488
+ key={goal.missionId}
489
+ className="rounded border border-slate-800/80 bg-slate-950/35"
490
+ >
491
+ <button
492
+ type="button"
493
+ onClick={() => onSelect(goal.missionId)}
494
+ className={`w-full truncate px-2 py-2 text-left ${
495
+ activeMissionId === goal.missionId
496
+ ? "bg-cyan-400/[0.12] text-cyan-100"
497
+ : "text-slate-300 hover:bg-white/[0.04]"
350
498
  }`}
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"}
499
+ title={goal.label || goal.missionId}
500
+ >
501
+ {goal.label || goal.missionId}
502
+ </button>
503
+ {children.length > 0 && (
504
+ <div className="border-t border-slate-800/70 py-1 pl-3">
505
+ {children.map((child) => (
506
+ <button
507
+ key={child.missionId}
508
+ type="button"
509
+ onClick={() => onSelect(child.missionId)}
510
+ className={`block w-full truncate rounded px-2 py-1.5 text-left ${
511
+ activeMissionId === child.missionId
512
+ ? "bg-cyan-400/[0.12] text-cyan-100"
513
+ : child.type === "hotfix"
514
+ ? "text-rose-300 hover:bg-rose-400/10"
515
+ : "text-slate-400 hover:bg-white/[0.04]"
516
+ }`}
517
+ title={child.label || child.missionId}
518
+ >
519
+ <span className="font-mono text-[9px] text-slate-500">
520
+ {child.type === "hotfix" ? "/hot-fix" : "/submission"}
521
+ </span>{" "}
522
+ {child.label || child.missionId}
523
+ </button>
524
+ ))}
525
+ </div>
526
+ )}
365
527
  </div>
528
+ ))
529
+ ) : (
530
+ <div className="rounded border border-slate-800 px-2 py-2 text-slate-500">
531
+ No goal history
366
532
  </div>
367
- ))}
533
+ )}
534
+ </nav>
535
+ </aside>
536
+ );
537
+ }
538
+
539
+ const CommandLogPopover = (() => {
540
+ const Inner = (
541
+ {
542
+ entries,
543
+ onClose,
544
+ }: { entries: OwnerPromptEntry[]; onClose: () => void },
545
+ ref: React.Ref<HTMLDivElement>
546
+ ) => {
547
+ const colorFor = (type: string) =>
548
+ type === "hot-fix"
549
+ ? "border-rose-500/40 bg-rose-950/30"
550
+ : type === "submission"
551
+ ? "border-emerald-500/30 bg-emerald-950/30"
552
+ : "bg-slate-950/60";
553
+ const labelFor = (type: string) =>
554
+ type === "hot-fix"
555
+ ? "text-rose-300"
556
+ : type === "submission"
557
+ ? "text-emerald-300"
558
+ : "text-cyan-300";
559
+ return (
560
+ <div
561
+ ref={ref}
562
+ 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)]"
563
+ style={{ width: 340 }}
564
+ >
565
+ <div className="flex items-center justify-between">
566
+ <div className="font-mono text-[9px] uppercase tracking-[0.2em] text-slate-500">
567
+ Command Log
568
+ </div>
569
+ <button
570
+ type="button"
571
+ onClick={onClose}
572
+ className="text-sm leading-none text-slate-400 hover:text-slate-100"
573
+ aria-label="Close"
574
+ >
575
+ ×
576
+ </button>
577
+ </div>
578
+ <div className="mt-1.5 max-h-[320px] space-y-1 overflow-auto pr-1">
579
+ {entries.length === 0 ? (
580
+ <div className="rounded bg-slate-950/50 px-2 py-2 text-[10px] text-slate-500">
581
+ No owner prompts yet.
582
+ </div>
583
+ ) : (
584
+ entries.map((entry, idx) => (
585
+ <div
586
+ key={`${entry.ts}-${idx}`}
587
+ className={`rounded border border-transparent px-2 py-1.5 ${colorFor(entry.type)}`}
588
+ >
589
+ <div className="flex items-center justify-between text-[9px]">
590
+ <span className={`font-mono ${labelFor(entry.type)}`}>/{entry.type}</span>
591
+ <span className="text-slate-500">{formatTime(entry.ts)}</span>
592
+ </div>
593
+ <div className="mt-0.5 line-clamp-2 text-[10px] text-slate-300">
594
+ {entry.content}
595
+ </div>
596
+ </div>
597
+ ))
598
+ )}
599
+ </div>
600
+ <div className="absolute -left-[6px] top-3 size-3 rotate-45 border-b border-l border-cyan-400/60 bg-[#0b1328]" />
368
601
  </div>
369
- </section>
602
+ );
603
+ };
604
+ return Object.assign(
605
+ // eslint-disable-next-line react/display-name
606
+ (
607
+ props: { entries: OwnerPromptEntry[]; onClose: () => void } & {
608
+ ref?: React.Ref<HTMLDivElement>;
609
+ }
610
+ ) => Inner(props, props.ref ?? null),
611
+ {}
370
612
  );
613
+ })() as React.ForwardRefExoticComponent<
614
+ { entries: OwnerPromptEntry[]; onClose: () => void } & React.RefAttributes<HTMLDivElement>
615
+ >;
616
+
617
+ function formatTime(iso: string | null | undefined) {
618
+ if (!iso) return "";
619
+ try {
620
+ const d = new Date(iso);
621
+ if (isNaN(d.getTime())) return "";
622
+ // Always render in the browser's local timezone — never UTC.
623
+ const pad = (n: number) => String(n).padStart(2, "0");
624
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
625
+ } catch {
626
+ return "";
627
+ }
371
628
  }
372
629
 
373
- function ActivityIndicator({
374
- snapshot,
375
- connectionState,
630
+ // ===== Knowledge base panel =====
631
+
632
+ function KnowledgeBasePanel({
633
+ gotchas,
634
+ conventions,
635
+ baselineTs,
376
636
  }: {
377
- snapshot: HarnessSnapshot;
378
- connectionState: ConnectionState;
637
+ gotchas: GotchaEntry[];
638
+ conventions: ConventionEntry[];
639
+ baselineTs: string | null;
379
640
  }) {
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;
641
+ const baseline = baselineTs ? new Date(baselineTs).getTime() : 0;
642
+ const isNew = (updatedAt?: string | null) => {
643
+ if (!updatedAt || !baseline) return false;
644
+ const t = new Date(updatedAt).getTime();
645
+ return Number.isFinite(t) && t >= baseline;
646
+ };
647
+ const newGotchas = gotchas.filter((g) => isNew(g.updatedAt)).length;
648
+ const newConventions = conventions.filter((c) => isNew(c.updatedAt)).length;
649
+
388
650
  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
- )}
651
+ <div className="panel-shell fade-in flex min-h-0 flex-col p-2">
652
+ <SectionLabel>Info · Knowledge Base</SectionLabel>
653
+ <div className="mt-2 grid min-h-0 flex-1 grid-cols-2 gap-2">
654
+ <KnowledgeList
655
+ title="Gotchas"
656
+ total={gotchas.length}
657
+ added={newGotchas}
658
+ entries={gotchas}
659
+ isNew={isNew}
660
+ dirRel=".harness/gotchas"
661
+ />
662
+ <KnowledgeList
663
+ title="Conventions"
664
+ total={conventions.length}
665
+ added={newConventions}
666
+ entries={conventions}
667
+ isNew={isNew}
668
+ dirRel=".harness/conventions"
669
+ />
670
+ </div>
425
671
  </div>
426
672
  );
427
673
  }
428
674
 
429
- function Panel({
675
+ function KnowledgeList({
430
676
  title,
431
- eyebrow,
432
- children,
677
+ total,
678
+ added,
679
+ entries,
680
+ isNew,
681
+ dirRel,
433
682
  }: {
434
683
  title: string;
435
- eyebrow?: string;
436
- children: React.ReactNode;
684
+ total: number;
685
+ added: number;
686
+ entries: Array<GotchaEntry | ConventionEntry>;
687
+ isNew: (ts?: string | null) => boolean;
688
+ dirRel: string;
437
689
  }) {
438
690
  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>
691
+ <div className="inset-shell flex min-h-0 flex-col p-2">
692
+ <div className="flex items-center justify-between">
693
+ <span className="text-[11px] text-cyan-200">{title}</span>
694
+ <span className="text-[10px] text-slate-400">
695
+ {total}
696
+ {added > 0 && (
697
+ <>
698
+ {" "}
699
+ <span className="font-semibold text-emerald-300">+{added}</span>
700
+ </>
446
701
  )}
447
- <h2 className="text-sm font-semibold text-gray-100">{title}</h2>
448
- </div>
702
+ </span>
449
703
  </div>
450
- {children}
451
- </section>
704
+ <div className="mt-1.5 min-h-0 flex-1 space-y-0.5 overflow-auto pr-1 text-[10px]">
705
+ {entries.length === 0 ? (
706
+ <div className="text-slate-500">(empty)</div>
707
+ ) : (
708
+ entries.map((entry) => (
709
+ <a
710
+ key={entry.id}
711
+ className="block cursor-pointer truncate text-slate-300 hover:text-cyan-200"
712
+ title={`${dirRel}/${entry.id}.md`}
713
+ >
714
+ → {entry.id}.md
715
+ {isNew(entry.updatedAt) && <span className="text-emerald-300"> ●</span>}
716
+ </a>
717
+ ))
718
+ )}
719
+ </div>
720
+ </div>
452
721
  );
453
722
  }
454
723
 
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";
724
+ // ===== Layer activity panel =====
725
+
726
+ function LayerActivityPanel({
727
+ goalScope,
728
+ todos,
729
+ label,
730
+ }: {
731
+ goalScope: MissionDoc[];
732
+ todos: CxxTodo[];
733
+ label: string;
734
+ }) {
735
+ // Sum workers across every mission in the selected goal sub-tree so the
736
+ // participation bar reflects the whole goal, not a single sub-mission.
737
+ const allWorkers = useMemo(
738
+ () => goalScope.flatMap((m) => m.workers),
739
+ [goalScope]
740
+ );
741
+ const workerByCxx = useMemo(() => {
742
+ const map = new Map<WorkerOwnerRole, number>();
743
+ for (const role of WORKER_OWNERS) map.set(role, 0);
744
+ for (const w of allWorkers) {
745
+ const r = w.owner;
746
+ if ((WORKER_OWNERS as readonly string[]).includes(r)) {
747
+ const role = r as WorkerOwnerRole;
748
+ map.set(role, (map.get(role) ?? 0) + 1);
749
+ }
750
+ }
751
+ return map;
752
+ }, [allWorkers]);
753
+
754
+ const layer = useMemo(
755
+ () => computeGoalScopeLayers(goalScope, todos),
756
+ [goalScope, todos]
757
+ );
758
+
759
+ const activeCxxCount = Array.from(workerByCxx.values()).filter((v) => v > 0).length;
760
+
457
761
  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>
762
+ <div className="panel-shell fade-in flex min-h-0 flex-col p-2">
763
+ <SectionLabel>Layer · {label}</SectionLabel>
764
+
765
+ <div className="inset-shell mt-2 p-2">
766
+ <div className="flex items-center justify-between font-mono text-[9px] text-slate-500">
767
+ <span>실무자 비중 (worker by CXX, CEO 제외)</span>
768
+ <span>
769
+ {allWorkers.length}w · {activeCxxCount} CXX
770
+ </span>
771
+ </div>
772
+ <div className="mt-1.5 flex h-3 overflow-hidden rounded">
773
+ {WORKER_OWNERS.map((role) => {
774
+ const count = workerByCxx.get(role) ?? 0;
775
+ return (
776
+ <div
777
+ key={role}
778
+ className={`stack-seg transition-[flex] duration-500 ease-out ${
779
+ count > 0 ? CXX_COLOR[role] : `${CXX_COLOR[role]}/30`
780
+ }`}
781
+ style={{ flex: Math.max(0.1, count) }}
782
+ title={`${role.toUpperCase()} · ${count}`}
783
+ />
784
+ );
785
+ })}
786
+ </div>
787
+ <div className="mt-1.5 flex flex-wrap gap-x-2 gap-y-1 font-mono text-[9px] text-slate-400">
788
+ {WORKER_OWNERS.map((role) => (
789
+ <span key={role} className="inline-flex items-center gap-1">
790
+ <span
791
+ className={`inline-block size-2 rounded-sm ${
792
+ (workerByCxx.get(role) ?? 0) > 0 ? CXX_COLOR[role] : `${CXX_COLOR[role]}/30`
793
+ }`}
794
+ />
795
+ {role.toUpperCase()} {workerByCxx.get(role) ?? 0}
796
+ </span>
797
+ ))}
798
+ </div>
799
+ </div>
800
+
801
+ <div className="inset-shell mt-2 min-h-0 flex-1 overflow-auto">
802
+ <table className="w-full text-[10px]">
803
+ <thead className="sticky top-0 bg-slate-900/60 font-mono text-[9px] uppercase tracking-wider">
804
+ <tr>
805
+ <th className="px-2 py-1 text-left text-slate-500">Layer</th>
806
+ <th className="px-2 py-1 text-right text-slate-500">TODO</th>
807
+ <th className="px-2 py-1 text-right text-emerald-400/80">DONE</th>
808
+ <th className="px-2 py-1 text-right text-orange-300/80">Remain</th>
809
+ </tr>
810
+ </thead>
811
+ <tbody>
812
+ <LayerRow label="CEO" stats={layer.ceo} />
813
+ <LayerRow label="CXX" stats={layer.cxx} />
814
+ <LayerRow label="Worker" stats={layer.worker} />
815
+ <tr className="border-t border-slate-800 bg-slate-900/40">
816
+ <td className="px-2 py-1 font-semibold text-slate-300">∑</td>
817
+ <td className="px-2 py-1 text-right font-semibold text-slate-100">
818
+ {layer.ceo.total + layer.cxx.total + layer.worker.total}
819
+ </td>
820
+ <td className="px-2 py-1 text-right font-semibold text-emerald-300">
821
+ {layer.ceo.done + layer.cxx.done + layer.worker.done}
822
+ </td>
823
+ <td className="px-2 py-1 text-right font-semibold text-orange-300">
824
+ {layer.ceo.remain + layer.cxx.remain + layer.worker.remain}
825
+ </td>
826
+ </tr>
827
+ </tbody>
828
+ </table>
829
+ </div>
461
830
  </div>
462
831
  );
463
832
  }
464
833
 
465
- function StatusLine({ label, value }: { label: string; value: React.ReactNode }) {
834
+ function LayerRow({ label, stats }: { label: string; stats: LayerStats }) {
466
835
  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>
836
+ <tr className="border-t border-slate-800">
837
+ <td className="px-2 py-1 text-cyan-200">{label}</td>
838
+ <td className="px-2 py-1 text-right text-slate-200">{stats.total}</td>
839
+ <td className="px-2 py-1 text-right text-emerald-300">{stats.done}</td>
840
+ <td
841
+ className={`px-2 py-1 text-right ${stats.remain > 0 ? "text-orange-300" : "text-slate-500"}`}
842
+ >
843
+ {stats.remain}
844
+ </td>
845
+ </tr>
471
846
  );
472
847
  }
473
848
 
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
- }
849
+ type LayerStats = { total: number; done: number; remain: number };
477
850
 
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";
851
+ function normalizeKind(s: string) {
852
+ return s.replace(/[_-]/g, "").toLowerCase();
483
853
  }
484
854
 
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";
490
- }
855
+ function computeGoalScopeLayers(
856
+ goalScope: MissionDoc[],
857
+ todos: CxxTodo[]
858
+ ): { ceo: LayerStats; cxx: LayerStats; worker: LayerStats } {
859
+ // === CEO layer: ceo todos (status.json) ↔ mission directories of matching kind ===
860
+ // Runtime rarely flips status to "done", so infer completion from how many
861
+ // mission directories of each kind already exist under the goal scope.
862
+ const ceoTodos = todos.filter((t) => t.owner.toLowerCase() === "ceo");
863
+ const todoByKind = new Map<string, number>();
864
+ for (const t of ceoTodos) {
865
+ const k = normalizeKind(t.kind || "unknown");
866
+ todoByKind.set(k, (todoByKind.get(k) ?? 0) + 1);
867
+ }
868
+ const missionByKind = new Map<string, number>();
869
+ for (const m of goalScope) {
870
+ const k = normalizeKind(m.type);
871
+ missionByKind.set(k, (missionByKind.get(k) ?? 0) + 1);
872
+ }
873
+ const ceo: LayerStats = { total: ceoTodos.length, done: 0, remain: 0 };
874
+ for (const [kind, count] of todoByKind) {
875
+ const matched = missionByKind.get(kind) ?? 0;
876
+ ceo.done += Math.min(count, matched);
877
+ }
878
+ ceo.remain = ceo.total - ceo.done;
879
+
880
+ // === CXX layer: a CXX is "done" on a mission once its <role>.md exists ===
881
+ // cxxPresent already encodes that signal. CEO is excluded — CEO is its own layer.
882
+ let cxxTotal = 0;
883
+ let cxxDone = 0;
884
+ for (const m of goalScope) {
885
+ const presentNonCeo = m.cxxPresent.filter(
886
+ (r) => (WORKER_OWNERS as readonly string[]).includes(r)
887
+ );
888
+ cxxTotal += presentNonCeo.length;
889
+ cxxDone += presentNonCeo.length;
890
+ }
891
+ const cxx: LayerStats = { total: cxxTotal, done: cxxDone, remain: cxxTotal - cxxDone };
491
892
 
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";
893
+ // === Worker layer: every worker doc that submitted output (status COMPLETE) ===
894
+ let wTotal = 0;
895
+ let wDone = 0;
896
+ for (const m of goalScope) {
897
+ wTotal += m.workers.length;
898
+ wDone += m.workers.filter((w) => w.status === "COMPLETE").length;
899
+ }
900
+ const worker: LayerStats = { total: wTotal, done: wDone, remain: wTotal - wDone };
901
+
902
+ return { ceo, cxx, worker };
497
903
  }
498
904
 
499
- function EscalationStrip({ escalations }: { escalations: EscalationEntry[] }) {
905
+ // ===== Recent report panel =====
906
+
907
+ function RecentReportPanel({
908
+ missions,
909
+ onSelectWorker,
910
+ }: {
911
+ missions: MissionDoc[];
912
+ onSelectWorker: (mission: MissionDoc, worker: WorkerDocEntry) => void;
913
+ }) {
914
+ const reports = useMemo(() => {
915
+ const list: Array<{ mission: MissionDoc; worker: WorkerDocEntry; ts: number }> = [];
916
+ for (const mission of missions) {
917
+ for (const worker of mission.workers) {
918
+ if (!worker.updatedAt) continue;
919
+ const ts = new Date(worker.updatedAt).getTime();
920
+ if (!Number.isFinite(ts)) continue;
921
+ list.push({ mission, worker, ts });
922
+ }
923
+ }
924
+ list.sort((a, b) => b.ts - a.ts);
925
+ return list;
926
+ }, [missions]);
927
+
500
928
  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
- )}
929
+ <div className="panel-shell fade-in flex min-h-0 flex-col p-2">
930
+ <div className="flex items-center justify-between">
931
+ <SectionLabel>Recent Report (from CXX)</SectionLabel>
932
+ <span className="font-mono text-[9px] text-slate-500">latest on top</span>
933
+ </div>
934
+ <div className="mt-2 min-h-0 flex-1 space-y-1.5 overflow-auto pr-1 text-[10px]">
935
+ {reports.length === 0 ? (
936
+ <div className="text-slate-500">No worker reports yet.</div>
937
+ ) : (
938
+ reports.map(({ mission, worker, ts }) => {
939
+ const role = worker.owner;
940
+ const colorClass =
941
+ role in CXX_TEXT ? CXX_TEXT[role as CxxRole] : "text-slate-300";
942
+ // Strip leading YAML frontmatter (docmeta block) before scanning
943
+ // for the first content line, otherwise we'd show "docmeta:" etc.
944
+ const stripped = (worker.content || "").replace(/^---[\s\S]*?---\n+/, "");
945
+ const firstLine = stripped
946
+ .split("\n")
947
+ .map((line) => line.trim())
948
+ .find((line) => line && !line.startsWith("#"));
949
+ return (
950
+ <button
951
+ key={`${mission.missionId}:${worker.name}`}
952
+ type="button"
953
+ onClick={() => onSelectWorker(mission, worker)}
954
+ 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"
955
+ >
956
+ <div className="flex items-center justify-between text-[9px]">
957
+ <span className={colorClass}>
958
+ {role.toUpperCase()} / {worker.displayName}
959
+ </span>
960
+ <span className="text-slate-500">{formatTime(new Date(ts).toISOString())}</span>
961
+ </div>
962
+ <div className="mt-0.5 truncate text-[10px] text-slate-200">
963
+ {firstLine || `${mission.missionId} — report`}
964
+ </div>
965
+ </button>
966
+ );
967
+ })
968
+ )}
969
+ </div>
521
970
  </div>
522
971
  );
523
972
  }
524
973
 
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;
974
+ // ===== Cadence strip =====
975
+
976
+ function CadenceStrip({ ownerHistory }: { ownerHistory: OwnerPromptEntry[] }) {
977
+ const bucketEntries = useMemo(() => {
978
+ const now = Date.now();
979
+ const arr: OwnerPromptEntry[][] = Array.from({ length: 24 }, () => []);
980
+ for (const entry of ownerHistory) {
981
+ if (!entry.ts) continue;
982
+ const t = new Date(entry.ts).getTime();
983
+ if (!Number.isFinite(t)) continue;
984
+ const hoursAgo = Math.floor((now - t) / (60 * 60 * 1000));
985
+ if (hoursAgo < 0 || hoursAgo >= 24) continue;
986
+ arr[hoursAgo].push(entry);
987
+ }
988
+ // newest-first within each bucket
989
+ for (const list of arr) {
990
+ list.sort((a, b) => (b.ts ?? "").localeCompare(a.ts ?? ""));
991
+ }
992
+ return arr;
993
+ }, [ownerHistory]);
994
+ const buckets = useMemo(() => bucketEntries.map((b) => b.length), [bucketEntries]);
995
+
996
+ const max = Math.max(1, ...buckets);
997
+ const cellColor = (n: number) => {
998
+ if (n === 0) return "bg-slate-900";
999
+ const ratio = n / max;
1000
+ if (ratio > 0.75) return "bg-rose-500/80 ring-1 ring-rose-400";
1001
+ if (ratio > 0.5) return "bg-orange-500/70";
1002
+ if (ratio > 0.25) return "bg-emerald-500/60";
1003
+ return "bg-emerald-500/30";
1004
+ };
1005
+
1006
+ const [tooltip, setTooltip] = useState<{
1007
+ idx: number;
1008
+ entries: OwnerPromptEntry[];
1009
+ x: number;
1010
+ y: number;
1011
+ } | null>(null);
1012
+
1013
+ const tooltipRef = useRef<HTMLDivElement>(null);
1014
+ useEffect(() => {
1015
+ if (!tooltip) return;
1016
+ const handler = (event: MouseEvent) => {
1017
+ const target = event.target as Node;
1018
+ if (tooltipRef.current?.contains(target)) return;
1019
+ const el = target as HTMLElement;
1020
+ if (el.closest?.("[data-cadence-cell]")) return;
1021
+ setTooltip(null);
1022
+ };
1023
+ document.addEventListener("mousedown", handler);
1024
+ return () => document.removeEventListener("mousedown", handler);
1025
+ }, [tooltip]);
1026
+
1027
+ const handleCellClick = (idx: number, event: React.MouseEvent<HTMLButtonElement>) => {
1028
+ const entries = bucketEntries[idx];
1029
+ if (entries.length === 0) {
1030
+ setTooltip(null);
1031
+ return;
1032
+ }
1033
+ const rect = event.currentTarget.getBoundingClientRect();
1034
+ setTooltip({
1035
+ idx,
1036
+ entries,
1037
+ x: rect.left + rect.width / 2,
1038
+ y: rect.bottom,
1039
+ });
1040
+ };
533
1041
 
534
- function OpsPanel({ services }: { services: OpsServiceHealth[] }) {
535
1042
  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>
1043
+ <div className="inset-shell relative shrink-0 px-2 pb-1.5 pt-1.5">
1044
+ <div className="flex items-center justify-between font-mono text-[9px] text-slate-500">
1045
+ <span>Cadence · last 24h (owner prompts/h) · click cell for detail</span>
1046
+ <span className="text-emerald-300">max {max}</span>
540
1047
  </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>
1048
+ <div
1049
+ className="mt-1 grid gap-px"
1050
+ style={{ gridTemplateColumns: "repeat(24, minmax(0, 1fr))" }}
1051
+ >
1052
+ {buckets.map((count, idx) => {
1053
+ const interactive = count > 0;
1054
+ const isPeak = count > 0 && count === max;
1055
+ return (
1056
+ <button
1057
+ key={idx}
1058
+ data-cadence-cell
1059
+ type="button"
1060
+ disabled={!interactive}
1061
+ onClick={(event) => handleCellClick(idx, event)}
1062
+ className={`h-4 transition-all duration-300 ${cellColor(count)} ${
1063
+ isPeak ? "cad-peak" : ""
1064
+ } ${
1065
+ interactive
1066
+ ? "cursor-pointer hover:scale-y-150 hover:brightness-125"
1067
+ : "cursor-default"
1068
+ } ${tooltip?.idx === idx ? "ring-2 ring-cyan-300" : ""}`}
1069
+ title={interactive ? `-${idx}h · ${count} prompts` : ""}
1070
+ />
1071
+ );
1072
+ })}
1073
+ </div>
1074
+ <div className="mt-0.5 flex justify-between font-mono text-[8px] text-slate-600">
1075
+ <span>now</span>
1076
+ <span>-12h</span>
1077
+ <span>-24h</span>
1078
+ </div>
1079
+ {tooltip && (
1080
+ <CadencePromptTooltip
1081
+ ref={tooltipRef}
1082
+ entries={tooltip.entries}
1083
+ hourIdx={tooltip.idx}
1084
+ x={tooltip.x}
1085
+ y={tooltip.y}
1086
+ onClose={() => setTooltip(null)}
1087
+ />
1088
+ )}
1089
+ </div>
1090
+ );
1091
+ }
1092
+
1093
+ const CadencePromptTooltip = (() => {
1094
+ const Inner = (
1095
+ {
1096
+ entries,
1097
+ hourIdx,
1098
+ x,
1099
+ y,
1100
+ onClose,
1101
+ }: {
1102
+ entries: OwnerPromptEntry[];
1103
+ hourIdx: number;
1104
+ x: number;
1105
+ y: number;
1106
+ onClose: () => void;
1107
+ },
1108
+ ref: React.Ref<HTMLDivElement>
1109
+ ) => {
1110
+ const colorFor = (type: string) =>
1111
+ type === "hot-fix"
1112
+ ? "border-rose-500/40 bg-rose-950/30"
1113
+ : type === "submission"
1114
+ ? "border-emerald-500/30 bg-emerald-950/30"
1115
+ : "bg-slate-950/60";
1116
+ const labelFor = (type: string) =>
1117
+ type === "hot-fix"
1118
+ ? "text-rose-300"
1119
+ : type === "submission"
1120
+ ? "text-emerald-300"
1121
+ : "text-cyan-300";
1122
+ return (
1123
+ <div
1124
+ ref={ref}
1125
+ 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)]"
1126
+ style={{
1127
+ left: Math.max(8, x - 200),
1128
+ top: Math.min(window.innerHeight - 320, y + 8),
1129
+ width: 400,
1130
+ }}
1131
+ >
1132
+ <div className="flex items-center justify-between">
1133
+ <div className="font-mono text-[9px] uppercase tracking-[0.2em] text-slate-500">
1134
+ Owner Prompts · {hourIdx === 0 ? "this hour" : `-${hourIdx}h`} ({entries.length})
1135
+ </div>
1136
+ <button
1137
+ type="button"
1138
+ onClick={onClose}
1139
+ className="text-sm leading-none text-slate-400 hover:text-slate-100"
1140
+ aria-label="Close"
1141
+ >
1142
+ ×
1143
+ </button>
1144
+ </div>
1145
+ <div className="mt-1.5 max-h-[280px] space-y-1 overflow-auto pr-1">
1146
+ {entries.map((entry, idx) => (
1147
+ <div
1148
+ key={`${entry.ts}-${idx}`}
1149
+ className={`rounded border border-transparent px-2 py-1.5 ${colorFor(entry.type)}`}
1150
+ >
1151
+ <div className="flex items-center justify-between text-[9px]">
1152
+ <span className={`font-mono ${labelFor(entry.type)}`}>/{entry.type}</span>
1153
+ <span className="text-slate-500">{formatTime(entry.ts)}</span>
552
1154
  </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>
1155
+ <div className="mt-0.5 line-clamp-3 text-[10px] text-slate-300">
1156
+ {entry.content}
560
1157
  </div>
561
1158
  </div>
562
1159
  ))}
563
1160
  </div>
564
- )}
565
- </section>
1161
+ </div>
1162
+ );
1163
+ };
1164
+ return Object.assign(
1165
+ (
1166
+ props: {
1167
+ entries: OwnerPromptEntry[];
1168
+ hourIdx: number;
1169
+ x: number;
1170
+ y: number;
1171
+ onClose: () => void;
1172
+ } & { ref?: React.Ref<HTMLDivElement> }
1173
+ ) => Inner(props, props.ref ?? null),
1174
+ {}
566
1175
  );
1176
+ })() as React.ForwardRefExoticComponent<
1177
+ {
1178
+ entries: OwnerPromptEntry[];
1179
+ hourIdx: number;
1180
+ x: number;
1181
+ y: number;
1182
+ onClose: () => void;
1183
+ } & React.RefAttributes<HTMLDivElement>
1184
+ >;
1185
+
1186
+ // ===== Heatmap =====
1187
+
1188
+ function formatMinuteOffset(minutes: number) {
1189
+ if (minutes === 0) return "now";
1190
+ if (minutes < 60) return `-${minutes}m`;
1191
+ const h = Math.floor(minutes / 60);
1192
+ const m = minutes % 60;
1193
+ return m === 0 ? `-${h}h` : `-${h}h${m}m`;
567
1194
  }
568
1195
 
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");
1196
+ function WorkflowHeatmap({
1197
+ lanes,
1198
+ cells,
1199
+ totalBuckets,
1200
+ selectedCells,
1201
+ dragStart,
1202
+ dragEnd,
1203
+ onDragStart,
1204
+ onDragMove,
1205
+ onDragEnd,
1206
+ onSelectLane,
1207
+ onCellClick,
1208
+ }: {
1209
+ lanes: AgentLane[];
1210
+ cells: HeatCell[];
1211
+ totalBuckets: number;
1212
+ selectedCells: HeatCell[];
1213
+ dragStart: { x: number; y: number } | null;
1214
+ dragEnd: { x: number; y: number } | null;
1215
+ onDragStart: (point: { x: number; y: number }) => void;
1216
+ onDragMove: (point: { x: number; y: number }) => void;
1217
+ onDragEnd: (missionId: string | null) => void;
1218
+ onSelectLane: (id: string) => void;
1219
+ onCellClick: (cell: HeatCell, event: React.PointerEvent) => void;
1220
+ }) {
1221
+ const scrollRef = useRef<HTMLDivElement>(null);
1222
+ const measureRef = useRef<HTMLDivElement>(null);
1223
+ const [containerWidth, setContainerWidth] = useState(0);
1224
+ const prevBucketCountRef = useRef(totalBuckets);
1225
+
1226
+ useEffect(() => {
1227
+ const el = measureRef.current;
1228
+ if (!el) return;
1229
+ setContainerWidth(el.clientWidth);
1230
+ const ro = new ResizeObserver((entries) => {
1231
+ const entry = entries[0];
1232
+ if (entry) setContainerWidth(entry.contentRect.width);
1233
+ });
1234
+ ro.observe(el);
1235
+ return () => ro.disconnect();
1236
+ }, []);
1237
+
1238
+ const availableWidth = Math.max(360, containerWidth - LANE_LABEL_WIDTH);
1239
+ const bucketPx = Math.max(MIN_BUCKET_PX, availableWidth / VISIBLE_BUCKETS);
1240
+
1241
+ useLayoutEffect(() => {
1242
+ const el = scrollRef.current;
1243
+ if (!el) {
1244
+ prevBucketCountRef.current = totalBuckets;
1245
+ return;
594
1246
  }
1247
+ const diff = totalBuckets - prevBucketCountRef.current;
1248
+ if (diff > 0 && el.scrollLeft > 0) {
1249
+ el.scrollLeft += diff * bucketPx;
1250
+ }
1251
+ prevBucketCountRef.current = totalBuckets;
1252
+ }, [totalBuckets, bucketPx]);
1253
+
1254
+ const selectedSet = useMemo(
1255
+ () => new Set(selectedCells.map((cell) => `${cell.x}:${cell.y}`)),
1256
+ [selectedCells]
1257
+ );
1258
+ const cellByXY = useMemo(() => {
1259
+ const map = new Map<string, HeatCell>();
1260
+ for (const cell of cells) map.set(`${cell.x}:${cell.y}`, cell);
1261
+ return map;
1262
+ }, [cells]);
1263
+
1264
+ const handlePointerUp = () => {
1265
+ if (!dragStart || !dragEnd) {
1266
+ onDragEnd(null);
1267
+ return;
1268
+ }
1269
+ const cell = cellByXY.get(`${dragEnd.x}:${dragEnd.y}`);
1270
+ onDragEnd(cell?.mission?.missionId ?? null);
595
1271
  };
596
1272
 
1273
+ const totalWidth = LANE_LABEL_WIDTH + totalBuckets * bucketPx;
1274
+
597
1275
  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"
1276
+ <div ref={measureRef} className="inset-shell relative min-h-0 flex-1 p-2">
1277
+ <div ref={scrollRef} className="h-full overflow-auto">
1278
+ <div
1279
+ className="grid select-none gap-px"
1280
+ style={{
1281
+ gridTemplateColumns: `${LANE_LABEL_WIDTH}px repeat(${totalBuckets}, ${bucketPx}px)`,
1282
+ gridTemplateRows: `${TIME_LABEL_HEIGHT}px repeat(${lanes.length}, ${LANE_ROW_HEIGHT}px)`,
1283
+ width: `${totalWidth}px`,
1284
+ }}
1285
+ onPointerLeave={handlePointerUp}
1286
+ onPointerUp={handlePointerUp}
1287
+ >
1288
+ <div
1289
+ className="sticky left-0 z-20 bg-[#071022]"
1290
+ style={{ gridColumn: 1, gridRow: 1 }}
1291
+ />
1292
+ {Array.from({ length: totalBuckets }).map((_, idx) => {
1293
+ const showLabel = idx === 0 || idx % 10 === 0;
1294
+ return (
1295
+ <div
1296
+ key={`t-${idx}`}
1297
+ className="grid place-items-center font-mono text-[9px] leading-none text-slate-500"
1298
+ style={{ gridColumn: idx + 2, gridRow: 1 }}
654
1299
  >
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>
1300
+ {showLabel ? formatMinuteOffset(idx) : ""}
665
1301
  </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>
1302
+ );
1303
+ })}
1304
+ {lanes.map((lane, idx) => (
1305
+ <button
1306
+ key={`lane-${lane.id}`}
1307
+ type="button"
1308
+ onClick={() => onSelectLane(lane.id)}
1309
+ className={`sticky left-0 z-10 truncate rounded bg-[#071022] px-2 text-left font-mono text-[10px] leading-[18px] ${
1310
+ lane.kind === "worker" ? "text-slate-500" : "text-slate-300"
1311
+ } hover:text-cyan-200`}
1312
+ style={{ gridColumn: 1, gridRow: idx + 2 }}
1313
+ title={`${lane.label} · ${lane.group}`}
1314
+ >
1315
+ {lane.label}
1316
+ </button>
680
1317
  ))}
1318
+ {cells.map((cell) => {
1319
+ const selected = selectedSet.has(`${cell.x}:${cell.y}`);
1320
+ const interactive = cell.mission !== null;
1321
+ return (
1322
+ <button
1323
+ key={`${cell.x}-${cell.y}-${cell.laneId}`}
1324
+ type="button"
1325
+ disabled={!interactive}
1326
+ onPointerDown={(event) => {
1327
+ if (!interactive) return;
1328
+ event.preventDefault();
1329
+ onDragStart({ x: cell.x, y: cell.y });
1330
+ onDragMove({ x: cell.x, y: cell.y });
1331
+ }}
1332
+ onPointerEnter={(event) => {
1333
+ if (!interactive) return;
1334
+ if (event.buttons === 1) {
1335
+ onDragMove({ x: cell.x, y: cell.y });
1336
+ }
1337
+ }}
1338
+ onClick={(event) => {
1339
+ if (!interactive) return;
1340
+ onCellClick(cell, event as unknown as React.PointerEvent);
1341
+ }}
1342
+ className={`heat-cell min-h-0 rounded-sm border transition-transform ${
1343
+ interactive ? "cursor-pointer hover:scale-[1.4] hover:z-10 hover:relative" : "cursor-default"
1344
+ } ${
1345
+ cell.hotfix
1346
+ ? "border-rose-500"
1347
+ : selected
1348
+ ? "border-cyan-300"
1349
+ : "border-transparent"
1350
+ } ${heatColor(cell.count)}`}
1351
+ style={{ gridColumn: cell.x + 2, gridRow: cell.y + 2 }}
1352
+ title={
1353
+ interactive
1354
+ ? `${cell.bucketLabel} · ${cell.laneLabel} · ${cell.mission?.missionId}`
1355
+ : ""
1356
+ }
1357
+ />
1358
+ );
1359
+ })}
1360
+ </div>
1361
+ </div>
1362
+ {dragStart && dragEnd && (
1363
+ <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">
1364
+ selected {selectedCells.length} cells
681
1365
  </div>
682
1366
  )}
683
- </section>
1367
+ </div>
684
1368
  );
685
1369
  }
686
1370
 
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
- }
1371
+ function ApprovalTooltip({
1372
+ mission,
1373
+ x,
1374
+ y,
1375
+ onClose,
1376
+ }: {
1377
+ mission: MissionDoc;
1378
+ x: number;
1379
+ y: number;
1380
+ onClose: () => void;
1381
+ }) {
1382
+ const tone =
1383
+ mission.type === "hotfix"
1384
+ ? "border-rose-500/50"
1385
+ : mission.type === "submission"
1386
+ ? "border-emerald-500/40"
1387
+ : "border-cyan-400/60";
696
1388
  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"
1389
+ <div
1390
+ 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}`}
1391
+ style={{
1392
+ left: Math.max(8, x - 180),
1393
+ top: Math.max(8, y - 110),
1394
+ minWidth: 360,
1395
+ maxWidth: 460,
1396
+ }}
1397
+ >
1398
+ <div className="flex items-center justify-between gap-3">
1399
+ <div className="truncate font-mono text-[10px] text-cyan-300">
1400
+ {mission.type.toUpperCase()} · {mission.missionId}
1401
+ </div>
1402
+ <button
1403
+ type="button"
1404
+ onClick={onClose}
1405
+ className="text-sm leading-none text-slate-400 hover:text-slate-100"
1406
+ aria-label="Close"
702
1407
  >
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}
1408
+ ×
1409
+ </button>
1410
+ </div>
1411
+ <div className="mt-1.5 flex flex-wrap items-center gap-1 text-[10px]">
1412
+ <FlowChip label="Owner" />
1413
+ <Arrow />
1414
+ <FlowChip label="CEO" />
1415
+ {mission.cxxPresent.map((role) => (
1416
+ <span key={role} className="inline-flex items-center gap-1">
1417
+ <Arrow />
1418
+ <FlowChip label={role.toUpperCase()} />
1419
+ {mission.workers
1420
+ .filter((worker) => worker.owner === role)
1421
+ .slice(0, 3)
1422
+ .map((worker) => (
1423
+ <span key={worker.name} className="inline-flex items-center gap-1">
1424
+ <Arrow />
1425
+ <FlowChip label={worker.displayName} muted />
1426
+ </span>
1427
+ ))}
714
1428
  </span>
715
- </li>
716
- ))}
1429
+ ))}
1430
+ </div>
1431
+ </div>
1432
+ );
1433
+ }
1434
+
1435
+ // ===== Document Viewer (expandable) =====
1436
+
1437
+ const MARKDOWN_COMPONENTS: Components = {
1438
+ h1: ({ children }) => (
1439
+ <h1 className="mb-2 mt-3 text-base font-semibold text-slate-100">{children}</h1>
1440
+ ),
1441
+ h2: ({ children }) => (
1442
+ <h2 className="mb-2 mt-3 text-sm font-semibold text-cyan-200">{children}</h2>
1443
+ ),
1444
+ h3: ({ children }) => (
1445
+ <h3 className="mb-1 mt-2 font-mono text-[11px] uppercase tracking-wider text-cyan-300">
1446
+ {children}
1447
+ </h3>
1448
+ ),
1449
+ h4: ({ children }) => (
1450
+ <h4 className="mb-1 mt-2 text-[11px] font-semibold text-slate-200">{children}</h4>
1451
+ ),
1452
+ p: ({ children }) => (
1453
+ <p className="my-1.5 text-[11px] leading-5 text-slate-300">{children}</p>
1454
+ ),
1455
+ ul: ({ children }) => (
1456
+ <ul className="my-1.5 ml-4 list-disc space-y-0.5 text-[11px] text-slate-300">
1457
+ {children}
717
1458
  </ul>
1459
+ ),
1460
+ ol: ({ children }) => (
1461
+ <ol className="my-1.5 ml-5 list-decimal space-y-0.5 text-[11px] text-slate-300">
1462
+ {children}
1463
+ </ol>
1464
+ ),
1465
+ li: ({ children }) => <li className="leading-5">{children}</li>,
1466
+ a: ({ children, href }) => (
1467
+ <a
1468
+ href={href}
1469
+ target="_blank"
1470
+ rel="noreferrer"
1471
+ className="text-cyan-300 underline decoration-cyan-300/40 hover:text-cyan-200"
1472
+ >
1473
+ {children}
1474
+ </a>
1475
+ ),
1476
+ blockquote: ({ children }) => (
1477
+ <blockquote className="my-2 border-l-2 border-cyan-500/40 pl-3 text-[11px] italic text-slate-400">
1478
+ {children}
1479
+ </blockquote>
1480
+ ),
1481
+ hr: () => <hr className="my-3 border-slate-800" />,
1482
+ strong: ({ children }) => (
1483
+ <strong className="font-semibold text-slate-100">{children}</strong>
1484
+ ),
1485
+ em: ({ children }) => <em className="italic text-slate-300">{children}</em>,
1486
+ table: ({ children }) => (
1487
+ <div className="my-2 overflow-x-auto">
1488
+ <table className="w-full border-collapse text-[10px]">{children}</table>
1489
+ </div>
1490
+ ),
1491
+ thead: ({ children }) => <thead className="bg-slate-900/70">{children}</thead>,
1492
+ th: ({ children }) => (
1493
+ <th className="border border-slate-800 px-2 py-1 text-left font-mono text-[10px] text-cyan-300">
1494
+ {children}
1495
+ </th>
1496
+ ),
1497
+ td: ({ children }) => (
1498
+ <td className="border border-slate-800 px-2 py-1 text-slate-300">{children}</td>
1499
+ ),
1500
+ pre: ({ children }) => (
1501
+ <pre className="my-2 overflow-auto rounded border border-slate-800 bg-[#040a18] p-2 font-mono text-[10px] leading-4 text-slate-200">
1502
+ {children}
1503
+ </pre>
1504
+ ),
1505
+ code: ({ className, children, ...props }) => {
1506
+ const isBlock = typeof className === "string" && className.startsWith("language-");
1507
+ if (isBlock) {
1508
+ return (
1509
+ <code className={`${className} text-slate-200`} {...props}>
1510
+ {children}
1511
+ </code>
1512
+ );
1513
+ }
1514
+ return (
1515
+ <code className="rounded bg-slate-800/80 px-1 py-0.5 font-mono text-[10px] text-cyan-200">
1516
+ {children}
1517
+ </code>
1518
+ );
1519
+ },
1520
+ };
1521
+
1522
+ function DocumentViewer({
1523
+ mission,
1524
+ lane,
1525
+ expanded,
1526
+ onToggle,
1527
+ }: {
1528
+ mission: MissionDoc | null;
1529
+ lane: AgentLane | null;
1530
+ expanded: boolean;
1531
+ onToggle: () => void;
1532
+ }) {
1533
+ const role = lane?.role as
1534
+ | keyof Pick<MissionDoc, "ceo" | "coo" | "cdo" | "cto" | "cqo" | "ops">
1535
+ | undefined;
1536
+ const roleDoc = role ? mission?.[role] : null;
1537
+ const workerDoc = lane?.worker?.content ?? null;
1538
+ const rawContent = workerDoc ?? roleDoc ?? mission?.ceo ?? "_No document selected._";
1539
+ // Hide the leading YAML frontmatter (docmeta block) — keep only the body.
1540
+ const content = rawContent.replace(/^---[\s\S]*?---\n+/, "");
1541
+ const source = workerDoc
1542
+ ? `${lane?.worker?.displayName ?? "worker"}`
1543
+ : role
1544
+ ? role.toUpperCase()
1545
+ : "CEO";
1546
+ return (
1547
+ <section className="panel-shell flex min-h-0 flex-1 flex-col p-2">
1548
+ <div className="flex items-center justify-between gap-2">
1549
+ <SectionLabel>
1550
+ Document Viewer · {mission?.missionId ?? "select /goal or /submission"} · {source}
1551
+ </SectionLabel>
1552
+ <button
1553
+ type="button"
1554
+ onClick={onToggle}
1555
+ 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"
1556
+ title={expanded ? "Minimize viewer" : "Expand to 50%"}
1557
+ aria-label={expanded ? "Minimize viewer" : "Expand viewer"}
1558
+ >
1559
+ <span className={`doc-toggle-icon ${expanded ? "rotate-180" : ""}`}>↗</span>
1560
+ </button>
1561
+ </div>
1562
+ <div className="inset-shell mt-2 min-h-0 flex-1 overflow-auto p-3">
1563
+ <article className="markdown-doc text-[11px] leading-5 text-slate-300">
1564
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
1565
+ {content}
1566
+ </ReactMarkdown>
1567
+ </article>
1568
+ </div>
1569
+ </section>
718
1570
  );
719
1571
  }
720
1572
 
721
- // Keep ArchiveList available for future reuse
722
- void ArchiveList;
1573
+ // ===== Shared bits =====
1574
+
1575
+ function SectionLabel({ children }: { children: React.ReactNode }) {
1576
+ return (
1577
+ <div className="font-mono text-[9px] uppercase tracking-[0.2em] text-slate-500 truncate">
1578
+ {children}
1579
+ </div>
1580
+ );
1581
+ }
1582
+
1583
+ function FlowChip({ label, muted = false }: { label: string; muted?: boolean }) {
1584
+ return (
1585
+ <span
1586
+ className={`rounded border px-1.5 py-0.5 font-mono ${
1587
+ muted ? "border-slate-700 text-slate-400" : "border-cyan-400/30 text-cyan-200"
1588
+ }`}
1589
+ >
1590
+ {label}
1591
+ </span>
1592
+ );
1593
+ }
1594
+
1595
+ function Arrow() {
1596
+ return <span className="text-slate-600">→</span>;
1597
+ }
1598
+
1599
+ function heatColor(count: number) {
1600
+ if (count > 6) return "bg-rose-500/90";
1601
+ if (count > 4) return "bg-orange-500/80";
1602
+ if (count > 2) return "bg-emerald-500/75";
1603
+ if (count > 0) return "bg-emerald-500/35";
1604
+ return "bg-slate-900";
1605
+ }
1606
+
1607
+ // ===== Lane / Heatmap builders =====
1608
+
1609
+ function buildLanes(snapshot: HarnessSnapshot, selectedMission: MissionDoc | null): AgentLane[] {
1610
+ const activeMission = selectedMission ?? snapshot.missions[0] ?? null;
1611
+ // CXX → its workers, then next CXX → its workers (proper hierarchy).
1612
+ // No cap: heatmap container scrolls vertically so OPS at the bottom is always
1613
+ // reachable even when upstream CXX bring many workers.
1614
+ const lanes: AgentLane[] = [];
1615
+ for (const role of CXX_ROLES) {
1616
+ const workers = activeMission?.workers.filter((w) => w.owner === role) ?? [];
1617
+ const todos = snapshot.todos.filter((t) => t.owner === role);
1618
+ const hasError =
1619
+ (role === "ops" && snapshot.incidents.length > 0) ||
1620
+ todos.some((t) => t.status === "blocked");
1621
+ const waiting =
1622
+ todos.some((t) => t.status === "pending" || t.status === "paused") ||
1623
+ workers.some((w) => !w.active && w.status !== "COMPLETE");
1624
+ lanes.push({
1625
+ id: role,
1626
+ label: role.toUpperCase(),
1627
+ role,
1628
+ group: `${role.toUpperCase()} group · ${workers.length} workers`,
1629
+ kind: "cxx",
1630
+ status: hasError ? "error" : waiting ? "waiting" : "normal",
1631
+ todos: todos.length,
1632
+ workers,
1633
+ mission: activeMission,
1634
+ });
1635
+ for (const worker of workers) {
1636
+ lanes.push({
1637
+ id: `${role}:${worker.name}`,
1638
+ label: `└ ${worker.displayName.slice(0, 16)}`,
1639
+ role,
1640
+ group: `${role.toUpperCase()} worker`,
1641
+ kind: "worker",
1642
+ status: worker.active ? "normal" : worker.status === "COMPLETE" ? "normal" : "waiting",
1643
+ todos: 0,
1644
+ workers: [],
1645
+ worker,
1646
+ mission: activeMission,
1647
+ });
1648
+ }
1649
+ }
1650
+ return lanes;
1651
+ }
1652
+
1653
+ function buildHeatmap(
1654
+ snapshot: HarnessSnapshot,
1655
+ lanes: AgentLane[],
1656
+ samples: HeatSample[],
1657
+ startedAt: number
1658
+ ): { cells: HeatCell[]; totalBuckets: number } {
1659
+ const cells: HeatCell[] = [];
1660
+ const now = Date.now();
1661
+ const nowMinute = Math.floor(now / BUCKET_MS);
1662
+ const startMinute = Math.floor(startedAt / BUCKET_MS);
1663
+ const elapsedMinutes = Math.max(0, nowMinute - startMinute);
1664
+ const totalBuckets = Math.max(VISIBLE_BUCKETS, elapsedMinutes + 1);
1665
+ const missionById = new Map(snapshot.missions.map((m) => [m.missionId, m]));
1666
+
1667
+ type Agg = { count: number; hotfix: boolean; missionId: string | null };
1668
+ const aggregated = new Map<string, Agg>();
1669
+ for (const sample of samples) {
1670
+ const sampleMinute = Math.floor(sample.ts / BUCKET_MS);
1671
+ const x = nowMinute - sampleMinute;
1672
+ if (x < 0 || x >= totalBuckets) continue;
1673
+ const key = `${x}:${sample.laneId}`;
1674
+ const prev = aggregated.get(key);
1675
+ if (prev) {
1676
+ prev.count += sample.count;
1677
+ prev.hotfix = prev.hotfix || sample.hotfix;
1678
+ if (sample.missionId) prev.missionId = sample.missionId;
1679
+ } else {
1680
+ aggregated.set(key, {
1681
+ count: sample.count,
1682
+ hotfix: sample.hotfix,
1683
+ missionId: sample.missionId ?? null,
1684
+ });
1685
+ }
1686
+ }
1687
+
1688
+ for (let y = 0; y < lanes.length; y++) {
1689
+ const lane = lanes[y];
1690
+ for (let x = 0; x < totalBuckets; x++) {
1691
+ const agg = aggregated.get(`${x}:${lane.id}`);
1692
+ // Only assign mission when the cell actually carries activity.
1693
+ // Empty cells (count=0, no sample) must stay missionless so clicks no-op.
1694
+ const hasData = !!agg && agg.count > 0;
1695
+ const missionId = hasData ? agg!.missionId : null;
1696
+ cells.push({
1697
+ x,
1698
+ y,
1699
+ laneId: lane.id,
1700
+ laneLabel: lane.label,
1701
+ bucketLabel: formatMinuteOffset(x),
1702
+ count: agg?.count ?? 0,
1703
+ hotfix: agg?.hotfix ?? false,
1704
+ mission: missionId ? missionById.get(missionId) ?? null : null,
1705
+ });
1706
+ }
1707
+ }
1708
+ return { cells, totalBuckets };
1709
+ }
1710
+
1711
+ function getSelectedCells(
1712
+ cells: HeatCell[],
1713
+ start: { x: number; y: number } | null,
1714
+ end: { x: number; y: number } | null
1715
+ ) {
1716
+ if (!start || !end) return [];
1717
+ const minX = Math.min(start.x, end.x);
1718
+ const maxX = Math.max(start.x, end.x);
1719
+ const minY = Math.min(start.y, end.y);
1720
+ const maxY = Math.max(start.y, end.y);
1721
+ return cells.filter(
1722
+ (cell) => cell.x >= minX && cell.x <= maxX && cell.y >= minY && cell.y <= maxY
1723
+ );
1724
+ }