@walwal-harness/cli 7.1.26 → 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,591 +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
- };
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]);
42
107
 
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
- };
60
-
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="grid min-h-0 flex-1 min-w-0 gap-4 lg:grid-cols-2">
100
- <div className="scrollbar-hidden min-h-0 min-w-0 overflow-y-auto pr-1">
101
- <section className="min-w-0">
102
- <div className="mb-3 flex flex-wrap items-end justify-between gap-3">
103
- <div>
104
- <p className="text-[11px] font-mono uppercase tracking-[0.24em] text-cyan-300/80">
105
- Company Structure
106
- </p>
107
- <h1 className="mt-1 text-2xl font-semibold text-gray-100">
108
- {snapshot.projectName || "walwal-harness"} · Organization
109
- </h1>
110
- </div>
111
- <ActivityIndicator snapshot={snapshot} connectionState={connectionState} />
112
- </div>
113
- <div
114
- data-testid="brick-office-stage"
115
- className="w-full overflow-x-auto rounded-md border border-gray-700/80 bg-[#12151b] shadow-2xl"
116
- >
117
- <div className="pointer-events-none flex items-center justify-between gap-3 border-b border-white/10 bg-black/30 px-4 py-2 font-mono text-[10px] text-gray-300 backdrop-blur">
118
- <span>Owner → CEO → CXX → Workers</span>
119
- <span className="truncate">
120
- {snapshot.missions?.[0]?.missionId ?? "no active mission"}
121
- {snapshot.missions?.[0]?.cxxPresent.length
122
- ? ` · ${snapshot.missions[0].cxxPresent.join(" / ")}`
123
- : ""}
124
- </span>
125
- </div>
126
- <OrgTree
127
- snapshot={snapshot}
128
- activeNodeId={selectedNode?.id ?? null}
129
- onNodeClick={handleNodeClick}
130
- />
131
- </div>
132
- </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
+ />
133
185
 
134
- <section className="mt-4">
135
- <div className="mb-2">
136
- <p className="font-mono text-[10px] uppercase tracking-[0.2em] text-cyan-300/60">Mission History</p>
137
- <p className="text-[11px] text-gray-500 mt-0.5">goal · submission · hot-fix 명령별 파생 작업 흐름</p>
138
- </div>
139
- <MissionTimeline
140
- missions={snapshot.missions ?? []}
141
- activeMissionId={selectedMission?.missionId ?? null}
142
- 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}
143
213
  />
144
214
  </section>
145
- </div>
146
215
 
147
- <div className="min-h-0 min-w-0">
148
- <Drawer
149
- mode="inline"
150
- open={true}
151
- tab={drawerTab}
152
- title={drawerTitle}
153
- onClose={() => undefined}
154
- onTabChange={setDrawerTab}
155
- scrollResetKey={drawerScrollResetKey}
156
- >
157
- {drawerTab === "mission-flow" && (
158
- <MissionFlowTab
159
- mission={selectedMission ?? snapshot.missions?.[0] ?? null}
160
- ownerHistory={snapshot.ownerHistory ?? []}
161
- runtime={snapshot.runtime}
162
- />
163
- )}
164
- {drawerTab === "history" && (
165
- <OwnerHistoryTab
166
- ownerHistory={snapshot.ownerHistory ?? []}
167
- mission={selectedMission ?? snapshot.missions?.[0] ?? null}
168
- />
169
- )}
170
- {drawerTab === "gotchas" && (
171
- <GotchasTab gotchas={snapshot.gotchas ?? []} />
172
- )}
173
- {drawerTab === "design-preview" && (
174
- <DesignPreviewTab mission={selectedMission ?? snapshot.missions?.[0] ?? null} />
175
- )}
176
- {drawerTab === "mission-doc" && selectedNode && (
177
- <MissionDocTab
178
- missions={snapshot.missions ?? []}
179
- role={selectedDocumentRole as "ceo" | "cto" | "cqo" | "coo" | "cdo" | "ops" | "worker"}
180
- workerName={selectedWorkerName}
181
- fromLabel={
182
- selectedWorkerName
183
- ? selectedNode.role.toUpperCase()
184
- : selectedNode.role === "ceo"
185
- ? "Owner"
186
- : "CEO"
187
- }
188
- toLabel={
189
- selectedWorkerName
190
- ? "Evidence"
191
- : selectedNode.role === "cto"
192
- ? "Workers"
193
- : selectedNode.role === "cqo"
194
- ? "Owner Report"
195
- : "—"
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;
196
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)}
197
252
  />
198
253
  )}
199
- {drawerTab === "logs" && selectedNode && (
200
- <AgentLogTab agentId={selectedNode.agentIds[0] ?? selectedNode.id} />
201
- )}
202
- {drawerTab === "logs" && selectedAgent && !selectedNode && (
203
- <AgentLogTab agentId={selectedAgent.id} />
204
- )}
205
- {drawerTab === "logs" && !selectedNode && !selectedAgent && (
206
- <div className="text-gray-500 text-xs">Select a node to view logs.</div>
207
- )}
208
- {drawerTab === "mission-flow" && !selectedMission && !snapshot.missions?.length && (
209
- <div className="text-gray-500 text-xs">No missions found in .harness/documents/</div>
210
- )}
211
- </Drawer>
212
- </div>
213
- </div>
254
+ </section>
255
+ </main>
214
256
 
215
- {/* Hidden DOM index for E2E + a11y */}
216
- <ul className="sr-only" data-testid="scene-index">
217
- {snapshot.rooms.map((r) => (
218
- <li
219
- key={r.id}
220
- data-testid={`room-${r.id}`}
221
- data-room-id={r.id}
222
- >
223
- {r.label_ko}
224
- </li>
225
- ))}
226
- {snapshot.agents.map((a) => (
227
- <li
228
- key={a.id}
229
- data-testid={`minifig-${a.id}`}
230
- data-agent-id={a.id}
231
- data-minifig-state={a.minifigState}
232
- data-room={a.room}
233
- >
234
- {a.name}
235
- </li>
236
- ))}
237
- </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>
238
271
  </div>
239
272
  );
240
273
  }
241
274
 
242
- function ActivityIndicator({
243
- snapshot,
275
+ // ===== Top header =====
276
+
277
+ function TopHeader({
278
+ projectName,
279
+ missionId,
244
280
  connectionState,
281
+ activeWorkers,
282
+ hotfixCount,
245
283
  }: {
246
- snapshot: HarnessSnapshot;
247
- connectionState: ConnectionState;
284
+ projectName: string;
285
+ missionId: string | null;
286
+ connectionState: string;
287
+ activeWorkers: number;
288
+ hotfixCount: number;
248
289
  }) {
249
- const [now, setNow] = useState(Date.now());
250
- useEffect(() => {
251
- const id = setInterval(() => setNow(Date.now()), 5000);
252
- return () => clearInterval(id);
253
- }, []);
254
- const ts = Date.parse(snapshot.ts);
255
- const ageSec = Number.isFinite(ts) ? Math.max(0, Math.floor((now - ts) / 1000)) : null;
256
- const isStale = ageSec !== null && ageSec > 30;
257
290
  return (
258
- <div className="flex items-center gap-2 text-[10px] font-mono">
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" />
259
293
  <span
260
- data-testid="connection-state"
261
- data-state={connectionState}
262
- className={`inline-block rounded px-2 py-0.5 ${
263
- connectionState === "open"
264
- ? "bg-aura-typing/20 text-aura-typing"
265
- : connectionState === "stale"
266
- ? "bg-aura-talking/20 text-aura-talking"
267
- : connectionState === "failed"
268
- ? "bg-aura-alert/20 text-aura-alert"
269
- : "bg-gray-500/20 text-gray-400"
270
- }`}
294
+ className="rounded bg-slate-800/60 px-2 py-0.5 font-mono text-[10px] font-semibold text-amber-300"
295
+ title={projectName}
271
296
  >
272
- SSE: {connectionState}
297
+ {projectName}
273
298
  </span>
274
- <span
275
- data-testid="activity-age"
276
- data-stale={isStale}
277
- className={`inline-block rounded px-2 py-0.5 ${
278
- isStale
279
- ? "bg-aura-alert/20 text-aura-alert"
280
- : "bg-aura-typing/20 text-aura-typing"
281
- }`}
282
- >
283
- {ageSec === null
284
- ? "—"
285
- : isStale
286
- ? `stale ${ageSec}s — 회사가 멈춰 보입니다`
287
- : `live ${ageSec}s ago`}
299
+ <span className="hidden text-[11px] text-slate-400 sm:inline">
300
+ walwal-harness 라이브 운영 대시보드
288
301
  </span>
289
- {snapshot.errorBanner && (
290
- <span className="inline-block rounded bg-aura-alert/20 px-2 py-0.5 text-aura-alert">
291
- banner: {snapshot.errorBanner.level}
292
- </span>
293
- )}
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 />
314
+ </div>
315
+ </header>
316
+ );
317
+ }
318
+
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",
333
+ }[tone];
334
+ return (
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>
294
338
  </div>
295
339
  );
296
340
  }
297
341
 
298
- function Panel({
299
- title,
300
- eyebrow,
301
- children,
342
+ function PresenceLegend() {
343
+ return (
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,
302
410
  }: {
303
- title: string;
304
- eyebrow?: string;
305
- children: React.ReactNode;
411
+ missions: MissionDoc[];
412
+ ownerHistory: OwnerPromptEntry[];
413
+ activeMissionId: string | null;
414
+ onSelect: (missionId: string) => void;
415
+ commandLogOpen: boolean;
416
+ onToggleCommandLog: () => void;
417
+ onCloseCommandLog: () => void;
306
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
+
307
434
  return (
308
- <section className="rounded-md border border-gray-700/80 bg-[#171a20]/95 p-3 shadow-xl">
309
- <div className="mb-3 flex items-center justify-between gap-3">
310
- <div>
311
- {eyebrow && (
312
- <p className="font-mono text-[10px] uppercase tracking-[0.2em] text-cyan-300/70">
313
- {eyebrow}
314
- </p>
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>
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
+ )}
479
+ </div>
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]"
498
+ }`}
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
+ )}
527
+ </div>
528
+ ))
529
+ ) : (
530
+ <div className="rounded border border-slate-800 px-2 py-2 text-slate-500">
531
+ No goal history
532
+ </div>
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
+ ))
315
598
  )}
316
- <h2 className="text-sm font-semibold text-gray-100">{title}</h2>
317
599
  </div>
600
+ <div className="absolute -left-[6px] top-3 size-3 rotate-45 border-b border-l border-cyan-400/60 bg-[#0b1328]" />
318
601
  </div>
319
- {children}
320
- </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
+ {}
321
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
+ }
322
628
  }
323
629
 
324
- function Metric({ label, value, tone }: { label: string; value: React.ReactNode; tone: "neutral" | "cyan" | "green" }) {
325
- const color = tone === "cyan" ? "text-cyan-300" : tone === "green" ? "text-emerald-300" : "text-gray-200";
630
+ // ===== Knowledge base panel =====
631
+
632
+ function KnowledgeBasePanel({
633
+ gotchas,
634
+ conventions,
635
+ baselineTs,
636
+ }: {
637
+ gotchas: GotchaEntry[];
638
+ conventions: ConventionEntry[];
639
+ baselineTs: string | null;
640
+ }) {
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
+
326
650
  return (
327
- <div className="rounded border border-gray-700/70 bg-black/20 px-2 py-2">
328
- <div className="font-mono text-[10px] uppercase text-gray-500">{label}</div>
329
- <div className={`mt-1 text-lg font-semibold ${color}`}>{value}</div>
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>
330
671
  </div>
331
672
  );
332
673
  }
333
674
 
334
- function StatusLine({ label, value }: { label: string; value: React.ReactNode }) {
675
+ function KnowledgeList({
676
+ title,
677
+ total,
678
+ added,
679
+ entries,
680
+ isNew,
681
+ dirRel,
682
+ }: {
683
+ title: string;
684
+ total: number;
685
+ added: number;
686
+ entries: Array<GotchaEntry | ConventionEntry>;
687
+ isNew: (ts?: string | null) => boolean;
688
+ dirRel: string;
689
+ }) {
335
690
  return (
336
- <div className="flex items-center justify-between gap-2 rounded bg-black/20 px-2 py-1 font-mono">
337
- <span className="text-gray-500">{label}</span>
338
- <span className="truncate text-gray-200">{value}</span>
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
+ </>
701
+ )}
702
+ </span>
703
+ </div>
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>
339
720
  </div>
340
721
  );
341
722
  }
342
723
 
343
- function EmptyLine({ text }: { text: string }) {
344
- return <div className="rounded border border-dashed border-gray-700 px-3 py-6 text-center text-xs text-gray-500">{text}</div>;
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
+
761
+ return (
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>
830
+ </div>
831
+ );
345
832
  }
346
833
 
347
- function statusTone(status: WorkerSnapshot["status"]): string {
348
- if (status === "spawned" || status === "running") return "bg-cyan-400/15 text-cyan-300";
349
- if (status === "recorded") return "bg-amber-400/15 text-amber-300";
350
- if (status === "blocked") return "bg-rose-500/15 text-rose-300";
351
- return "bg-gray-500/15 text-gray-300";
834
+ function LayerRow({ label, stats }: { label: string; stats: LayerStats }) {
835
+ return (
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>
846
+ );
352
847
  }
353
848
 
354
- function featureTone(status: string): string {
355
- if (status === "passed") return "text-emerald-300";
356
- if (status === "failed" || status === "blocked") return "text-rose-300";
357
- if (status === "in_progress" || status === "ready") return "text-cyan-300";
358
- return "text-gray-500";
849
+ type LayerStats = { total: number; done: number; remain: number };
850
+
851
+ function normalizeKind(s: string) {
852
+ return s.replace(/[_-]/g, "").toLowerCase();
359
853
  }
360
854
 
361
- function serviceTone(status: OpsServiceHealth["status"]): string {
362
- if (status === "ok") return "bg-emerald-400/15 text-emerald-300";
363
- if (status === "degraded") return "bg-amber-400/15 text-amber-300";
364
- if (status === "down") return "bg-rose-500/15 text-rose-300";
365
- return "bg-gray-500/15 text-gray-300";
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 };
892
+
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 };
366
903
  }
367
904
 
368
- 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
+
369
928
  return (
370
- <div
371
- data-testid="escalation-strip"
372
- role="alert"
373
- 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"
374
- >
375
- <span className="font-semibold uppercase tracking-widest">
376
- ↑ {escalations.length} escalation{escalations.length === 1 ? "" : "s"}
377
- </span>
378
- {escalations.slice(0, 3).map((e) => (
379
- <span
380
- key={e.id}
381
- data-testid={`escalation-${e.id}`}
382
- className="rounded bg-aura-alert/20 px-2 py-0.5 text-[10px]"
383
- >
384
- {e.id} · {e.reason}
385
- </span>
386
- ))}
387
- {escalations.length > 3 && (
388
- <span className="text-[10px] opacity-70">+{escalations.length - 3} more</span>
389
- )}
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>
390
970
  </div>
391
971
  );
392
972
  }
393
973
 
394
- // Keep unused helper functions to avoid breaking other panels if needed later
395
- void Panel;
396
- void Metric;
397
- void StatusLine;
398
- void EmptyLine;
399
- void statusTone;
400
- void featureTone;
401
- 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
+ };
402
1041
 
403
- function OpsPanel({ services }: { services: OpsServiceHealth[] }) {
404
1042
  return (
405
- <section className="rounded-md border border-gray-700/80 bg-[#171a20]/95 p-3 shadow-xl">
406
- <div className="mb-3">
407
- <p className="font-mono text-[10px] uppercase tracking-[0.2em] text-cyan-300/70">production monitor</p>
408
- <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>
409
1047
  </div>
410
- {services.length === 0 ? (
411
- <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>
412
- ) : (
413
- <div className="space-y-1.5">
414
- {services.map((s) => (
415
- <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">
416
- <div className="min-w-0">
417
- <div className="truncate text-xs font-medium text-gray-100">{s.name}</div>
418
- <div className="font-mono text-[10px] text-gray-500">
419
- {s.host}:{s.port}{s.health_path ? ` · ${s.health_path}` : ""}
420
- </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>
421
1154
  </div>
422
- <div className="text-right">
423
- <span className={`rounded px-2 py-0.5 font-mono text-[10px] ${serviceTone(s.status)}`}>
424
- {s.status}
425
- </span>
426
- <div className="mt-1 font-mono text-[10px] text-gray-500">
427
- {s.health_status ?? s.port_state ?? "—"}
428
- </div>
1155
+ <div className="mt-0.5 line-clamp-3 text-[10px] text-slate-300">
1156
+ {entry.content}
429
1157
  </div>
430
1158
  </div>
431
1159
  ))}
432
1160
  </div>
433
- )}
434
- </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
+ {}
435
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`;
436
1194
  }
437
1195
 
438
- function EnvPanel({ envFiles }: { envFiles: EnvFileSummary[] }) {
439
- const first = envFiles[0];
440
- const firstKey = first?.keys[0]?.key ?? "";
441
- const [file, setFile] = useState(first?.path ?? ".env");
442
- const [key, setKey] = useState(firstKey);
443
- const [value, setValue] = useState("");
444
- const [status, setStatus] = useState<string | null>(null);
445
-
446
- const submitEnv = async () => {
447
- setStatus("saving");
448
- try {
449
- const res = await fetch("/api/env", {
450
- method: "POST",
451
- headers: { "Content-Type": "application/json" },
452
- body: JSON.stringify({ file, key, value }),
453
- });
454
- const body = await res.json().catch(() => ({}));
455
- if (!res.ok || !body.ok) {
456
- setStatus(body.error ?? "failed");
457
- return;
458
- }
459
- setValue("");
460
- setStatus(body.replaced ? "updated" : "added");
461
- } catch {
462
- 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;
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;
463
1268
  }
1269
+ const cell = cellByXY.get(`${dragEnd.x}:${dragEnd.y}`);
1270
+ onDragEnd(cell?.mission?.missionId ?? null);
464
1271
  };
465
1272
 
1273
+ const totalWidth = LANE_LABEL_WIDTH + totalBuckets * bucketPx;
1274
+
466
1275
  return (
467
- <section className="rounded-md border border-gray-700/80 bg-[#171a20]/95 p-3 shadow-xl">
468
- <div className="mb-3">
469
- <p className="font-mono text-[10px] uppercase tracking-[0.2em] text-cyan-300/70">masked .env overview</p>
470
- <h2 className="text-sm font-semibold text-gray-100">Environment Control</h2>
471
- </div>
472
- {envFiles.length === 0 ? (
473
- <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>
474
- ) : (
475
- <div className="space-y-2">
476
- <div className="rounded border border-cyan-400/30 bg-cyan-400/5 p-2">
477
- <div className="grid gap-2 sm:grid-cols-[1fr_1fr]">
478
- <label className="grid gap-1 font-mono text-[10px] text-gray-400">
479
- file
480
- <select
481
- value={file}
482
- onChange={(e) => {
483
- setFile(e.target.value);
484
- const nextFile = envFiles.find((item) => item.path === e.target.value);
485
- setKey(nextFile?.keys[0]?.key ?? "");
486
- }}
487
- className="rounded border border-gray-700 bg-[#11151a] px-2 py-1 text-xs text-gray-100"
488
- >
489
- {envFiles.map((item) => (
490
- <option key={item.path} value={item.path}>
491
- {item.path}
492
- </option>
493
- ))}
494
- </select>
495
- </label>
496
- <label className="grid gap-1 font-mono text-[10px] text-gray-400">
497
- key
498
- <select
499
- value={key}
500
- onChange={(e) => setKey(e.target.value)}
501
- className="rounded border border-gray-700 bg-[#11151a] px-2 py-1 text-xs text-gray-100"
502
- >
503
- {(envFiles.find((item) => item.path === file)?.keys ?? []).map((item) => (
504
- <option key={item.key} value={item.key}>
505
- {item.key}
506
- </option>
507
- ))}
508
- </select>
509
- </label>
510
- </div>
511
- <div className="mt-2 flex gap-2">
512
- <input
513
- value={value}
514
- onChange={(e) => setValue(e.target.value)}
515
- placeholder="new value, never stored in browser"
516
- 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"
517
- />
518
- <button
519
- type="button"
520
- onClick={submitEnv}
521
- disabled={!file || !key}
522
- 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 }}
523
1299
  >
524
- Apply
525
- </button>
526
- </div>
527
- {status && <div className="mt-1 font-mono text-[10px] text-amber-300">{status}</div>}
528
- </div>
529
- {envFiles.map((file) => (
530
- <div key={file.path} className="rounded border border-gray-700/70 bg-black/20 p-2">
531
- <div className="flex items-center justify-between gap-2">
532
- <div className="font-mono text-xs text-gray-100">{file.path}</div>
533
- <div className="font-mono text-[10px] text-gray-500">{file.key_count} keys</div>
534
- </div>
535
- <div className="mt-2 grid gap-1">
536
- {file.keys.slice(0, 5).map((k) => (
537
- <div key={k.key} className="flex items-center justify-between gap-2 font-mono text-[10px]">
538
- <span className="truncate text-gray-400">{k.key}</span>
539
- <span className={k.category === "secret" ? "text-rose-300" : "text-cyan-300/80"}>
540
- {k.masked}
541
- </span>
542
- </div>
543
- ))}
1300
+ {showLabel ? formatMinuteOffset(idx) : ""}
544
1301
  </div>
545
- <p className="mt-2 text-[10px] leading-relaxed text-gray-500">
546
- Server-side editor target. Values stay masked until an explicit edit flow is opened.
547
- </p>
548
- </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>
549
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
550
1365
  </div>
551
1366
  )}
552
- </section>
1367
+ </div>
553
1368
  );
554
1369
  }
555
1370
 
556
- // Keep OpsPanel and EnvPanel available for future reuse
557
- void OpsPanel;
558
- void EnvPanel;
559
-
560
- function ArchiveList({ snapshot }: { snapshot: HarnessSnapshot }) {
561
- const list = snapshot.archive.all ?? snapshot.archive.recent ?? [];
562
- if (list.length === 0) {
563
- return <div className="text-gray-500">No archived sprints yet.</div>;
564
- }
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";
565
1388
  return (
566
- <ul className="space-y-1">
567
- {list.map((entry) => (
568
- <li
569
- key={entry.dir}
570
- 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"
571
1407
  >
572
- <span>{entry.label}</span>
573
- <span
574
- className={
575
- entry.result === "PASS"
576
- ? "text-aura-typing"
577
- : entry.result === "FAIL"
578
- ? "text-aura-alert"
579
- : "text-gray-500"
580
- }
581
- >
582
- {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
+ ))}
583
1428
  </span>
584
- </li>
585
- ))}
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}
586
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>
1570
+ );
1571
+ }
1572
+
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>
587
1580
  );
588
1581
  }
589
1582
 
590
- // Keep ArchiveList available for future reuse
591
- void ArchiveList;
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
+ }