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