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