paseo-bm-plugin 0.0.0-placeholder.0
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/LICENSE +21 -0
- package/README.md +53 -0
- package/client/agent-tree.ts +308 -0
- package/client/answer-state.ts +62 -0
- package/client/bead-chips.tsx +147 -0
- package/client/beads-header-button.ts +108 -0
- package/client/beads-model.ts +581 -0
- package/client/beads-screen.tsx +516 -0
- package/client/beads-tab.tsx +58 -0
- package/client/chat-card.tsx +636 -0
- package/client/chat-cards.ts +1038 -0
- package/client/dashboard-actions.tsx +255 -0
- package/client/dashboard-model.ts +947 -0
- package/client/dashboard-view.ts +215 -0
- package/client/dashboard.tsx +318 -0
- package/client/launch-manager.ts +323 -0
- package/client/launcher.tsx +516 -0
- package/client/markdown-view.tsx +112 -0
- package/client/markdown.ts +145 -0
- package/client/settings.tsx +104 -0
- package/client/setup-model.ts +552 -0
- package/client/setup-screen.tsx +913 -0
- package/client/slot.ts +47 -0
- package/client/tree.tsx +204 -0
- package/client/ui.tsx +262 -0
- package/client/waiting-pills-model.ts +156 -0
- package/client/waiting-pills.tsx +201 -0
- package/index.client.tsx +232 -0
- package/index.server.ts +168 -0
- package/package.json +35 -0
- package/paseo-plugin.json +6 -0
- package/roles/manager.md +181 -0
- package/roles/reviewer.md +160 -0
- package/roles/worker.md +407 -0
- package/server/agent-labels.ts +194 -0
- package/server/agent-role.ts +102 -0
- package/server/answer-marks.ts +120 -0
- package/server/bead-actions.ts +88 -0
- package/server/bead-work.ts +80 -0
- package/server/beads-store.ts +342 -0
- package/server/bm-report.ts +433 -0
- package/server/chat-peers.ts +65 -0
- package/server/chat-rpc.ts +122 -0
- package/server/chat-waiting.ts +182 -0
- package/server/collector.ts +629 -0
- package/server/config-writer.ts +222 -0
- package/server/cost.ts +88 -0
- package/server/dashboard-rpc.ts +662 -0
- package/server/fallback-detect.ts +183 -0
- package/server/fallback-handover.ts +365 -0
- package/server/fallback-manager.ts +170 -0
- package/server/fallback-reviewer.ts +198 -0
- package/server/fallback-rpc.ts +306 -0
- package/server/fallback-settings.ts +322 -0
- package/server/fallback-state.ts +518 -0
- package/server/fallback-switch.ts +191 -0
- package/server/fallback-wait.ts +188 -0
- package/server/format-check.ts +352 -0
- package/server/install-home.ts +187 -0
- package/server/live-timeline.ts +129 -0
- package/server/manager-instructions.ts +9 -0
- package/server/manager.ts +647 -0
- package/server/model-costs.ts +238 -0
- package/server/notice-queue.ts +315 -0
- package/server/notices.ts +81 -0
- package/server/paseo-cli.ts +115 -0
- package/server/provider-id.ts +12 -0
- package/server/review-budget.ts +208 -0
- package/server/reviewer-instructions.ts +9 -0
- package/server/role-choices.ts +161 -0
- package/server/role-extras.ts +270 -0
- package/server/role-hook.ts +347 -0
- package/server/role-mode.ts +397 -0
- package/server/role-settings-rpc.ts +325 -0
- package/server/roles.ts +96 -0
- package/server/settings-notices.ts +112 -0
- package/server/setup-rpc.ts +70 -0
- package/server/setup-skills.ts +121 -0
- package/server/setup-tools.ts +162 -0
- package/server/shell.ts +68 -0
- package/server/stop-propagation.ts +365 -0
- package/server/tools-check.ts +118 -0
- package/server/trace-store.ts +1137 -0
- package/server/traces.ts +1356 -0
- package/server/worker-instructions.ts +9 -0
- package/server/workflow-steps.ts +422 -0
- package/shared/bead-ids.ts +25 -0
- package/shared/bm-fallback.ts +91 -0
- package/shared/bm-format.ts +424 -0
- package/shared/bm-questions.ts +213 -0
- package/shared/bm-report.ts +433 -0
- package/shared/contracts.ts +1371 -0
- package/shared/fallback-patterns.ts +201 -0
- package/shared/fallback.ts +46 -0
- package/shared/new-request.ts +20 -0
- package/shared/order.ts +22 -0
- package/shared/prices.ts +65 -0
- package/shared/settings.ts +57 -0
- package/shared/sole-worker.ts +20 -0
- package/shared/version.ts +6 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything the Dashboard shows and decides, without a renderer
|
|
3
|
+
* (WP-211.1; Dashboard Design §2.1, REQ-041 → REQ-057).
|
|
4
|
+
*
|
|
5
|
+
* Same split as `launch-manager.ts`: this file holds the logic, the wording and
|
|
6
|
+
* the styles, so all of it is testable without React, and `dashboard.tsx` only
|
|
7
|
+
* arranges components. No React, no React Native, no JSX, no `server/` import.
|
|
8
|
+
*
|
|
9
|
+
* The wording rules are part of the product, not decoration:
|
|
10
|
+
*
|
|
11
|
+
* - every derived number says how sure it is (`exact` / `inferred` / `unknown`);
|
|
12
|
+
* - a cost says whether it came from the tool or from the bundled price table,
|
|
13
|
+
* with the date of that table;
|
|
14
|
+
* - a duration is labelled wall-clock, using the sentence the server sends;
|
|
15
|
+
* - a destructive action always states what will be lost and defaults to "No".
|
|
16
|
+
*/
|
|
17
|
+
import type { PluginTheme } from "@getpaseo/plugin";
|
|
18
|
+
import type {
|
|
19
|
+
BeadStats,
|
|
20
|
+
Confidence,
|
|
21
|
+
StoreSize,
|
|
22
|
+
TraceDeleteScope,
|
|
23
|
+
RuntimeRow,
|
|
24
|
+
TraceDetail,
|
|
25
|
+
TraceSummary,
|
|
26
|
+
Usage,
|
|
27
|
+
WorkflowStepResult,
|
|
28
|
+
WorkspaceState,
|
|
29
|
+
} from "../shared/contracts";
|
|
30
|
+
|
|
31
|
+
/** Icon of the Dashboard (REQ-040a: same surface as the launcher). */
|
|
32
|
+
export const DASHBOARD_ICON = "LayoutDashboard";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Told to the user once per screen, because a screenshot of this Dashboard
|
|
36
|
+
* contains agent conversation (REQ-048d).
|
|
37
|
+
*/
|
|
38
|
+
export const PRIVACY_NOTICE =
|
|
39
|
+
"This screen shows agent conversation that paseo-bm stores on this machine. You can delete it at any time from the Storage section.";
|
|
40
|
+
|
|
41
|
+
/** Said next to the machine-wide threshold, since Paseo has no per-workspace settings (REQ-055e). */
|
|
42
|
+
export const HOST_SCOPE_NOTICE = "This threshold applies to every workspace on this machine.";
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Formatting.
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/** `null` becomes an em dash, never `0` (REQ-043d). */
|
|
49
|
+
export function formatDuration(ms: number | null): string {
|
|
50
|
+
if (ms === null) return "—";
|
|
51
|
+
if (ms < 1000) return `${ms} ms`;
|
|
52
|
+
const seconds = Math.round(ms / 1000);
|
|
53
|
+
if (seconds < 60) return `${seconds}s`;
|
|
54
|
+
const minutes = Math.floor(seconds / 60);
|
|
55
|
+
const restSeconds = seconds % 60;
|
|
56
|
+
if (minutes < 60) return restSeconds === 0 ? `${minutes}m` : `${minutes}m ${restSeconds}s`;
|
|
57
|
+
const hours = Math.floor(minutes / 60);
|
|
58
|
+
const restMinutes = minutes % 60;
|
|
59
|
+
return restMinutes === 0 ? `${hours}h` : `${hours}h ${restMinutes}m`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Elapsed time of something still running, from a start timestamp. */
|
|
63
|
+
export function formatElapsed(startedAt: string | null, now: Date): string {
|
|
64
|
+
if (startedAt === null) return "—";
|
|
65
|
+
const start = Date.parse(startedAt);
|
|
66
|
+
if (Number.isNaN(start)) return "—";
|
|
67
|
+
return `${formatDuration(Math.max(0, now.getTime() - start))} so far`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function formatBytes(bytes: number): string {
|
|
71
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
72
|
+
const units = ["KB", "MB", "GB"];
|
|
73
|
+
let value = bytes / 1024;
|
|
74
|
+
let unit = 0;
|
|
75
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
76
|
+
value /= 1024;
|
|
77
|
+
unit += 1;
|
|
78
|
+
}
|
|
79
|
+
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function formatTokens(tokens: number): string {
|
|
83
|
+
if (tokens < 1000) return String(tokens);
|
|
84
|
+
if (tokens < 1_000_000) return `${(tokens / 1000).toFixed(tokens < 10_000 ? 1 : 0)}k`;
|
|
85
|
+
return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Cost with its basis. An estimate always carries the word "estimated" and the
|
|
90
|
+
* date of the price table; an unknown model shows no money at all (REQ-052).
|
|
91
|
+
*/
|
|
92
|
+
export function formatCost(usage: Usage): string {
|
|
93
|
+
if (usage.costBasis === "unavailable" || usage.costUsd === null) return "cost unavailable";
|
|
94
|
+
const amount = usage.costUsd < 0.01 ? `$${usage.costUsd.toFixed(4)}` : `$${usage.costUsd.toFixed(2)}`;
|
|
95
|
+
if (usage.costBasis === "provider") return `${amount} (reported by the tool)`;
|
|
96
|
+
return `${amount} (estimated, prices of ${usage.pricesUpdatedAt ?? "unknown date"})`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Tokens broken out, so a cache-heavy run is visible rather than hidden in a total. */
|
|
100
|
+
export function formatUsage(usage: Usage): string {
|
|
101
|
+
return `${formatTokens(usage.inputTokens)} in · ${formatTokens(usage.cachedInputTokens)} cached · ${formatTokens(usage.outputTokens)} out`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Labels.
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
export type Tone = "muted" | "info" | "warning" | "danger" | "success";
|
|
109
|
+
|
|
110
|
+
export interface Badge {
|
|
111
|
+
text: string;
|
|
112
|
+
tone: Tone;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function stateBadge(state: TraceSummary["state"]): Badge {
|
|
116
|
+
switch (state) {
|
|
117
|
+
case "running":
|
|
118
|
+
return { text: "Running", tone: "info" };
|
|
119
|
+
case "waiting_user":
|
|
120
|
+
return { text: "Waiting for you", tone: "warning" };
|
|
121
|
+
case "completed":
|
|
122
|
+
return { text: "Completed", tone: "success" };
|
|
123
|
+
case "stopped":
|
|
124
|
+
return { text: "Stopped", tone: "muted" };
|
|
125
|
+
case "failed":
|
|
126
|
+
return { text: "Failed", tone: "danger" };
|
|
127
|
+
case "unknown":
|
|
128
|
+
return { text: "Unknown", tone: "muted" };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** How the row was linked to its agents. `exact` is not shown: it is the norm. */
|
|
133
|
+
export function linkingBadge(linking: Confidence): Badge | null {
|
|
134
|
+
switch (linking) {
|
|
135
|
+
case "exact":
|
|
136
|
+
return null;
|
|
137
|
+
case "inferred":
|
|
138
|
+
return { text: "Linked by time", tone: "warning" };
|
|
139
|
+
case "unknown":
|
|
140
|
+
return { text: "Not linked to a request", tone: "warning" };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function confidenceSuffix(confidence: Confidence): string {
|
|
145
|
+
switch (confidence) {
|
|
146
|
+
case "exact":
|
|
147
|
+
return "";
|
|
148
|
+
case "inferred":
|
|
149
|
+
return " (inferred)";
|
|
150
|
+
case "unknown":
|
|
151
|
+
return " (unknown)";
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function workspaceStateBadge(state: WorkspaceState): Badge | null {
|
|
156
|
+
switch (state) {
|
|
157
|
+
case "live":
|
|
158
|
+
return null;
|
|
159
|
+
case "archived":
|
|
160
|
+
return { text: "Archived workspace", tone: "muted" };
|
|
161
|
+
case "orphaned":
|
|
162
|
+
return { text: "Workspace no longer in Paseo", tone: "warning" };
|
|
163
|
+
case "unknown":
|
|
164
|
+
return { text: "Workspace state unknown", tone: "muted" };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Human names of the twelve steps, in the order the server returns them. */
|
|
169
|
+
export const STEP_LABELS: Readonly<Record<WorkflowStepResult["step"], string>> = {
|
|
170
|
+
classify_tier: "Size classified",
|
|
171
|
+
prd: "PRD",
|
|
172
|
+
design: "Technical design",
|
|
173
|
+
adr: "ADR",
|
|
174
|
+
plan: "Implementation plan",
|
|
175
|
+
review_plan: "Plan reviewed",
|
|
176
|
+
convert_to_beads: "Converted to beads",
|
|
177
|
+
polish_beads: "Beads polished",
|
|
178
|
+
implement: "Implemented",
|
|
179
|
+
review_batches: "Reviewed",
|
|
180
|
+
build_and_tests: "Build and tests",
|
|
181
|
+
close_with_evidence: "Closed with evidence",
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** One line summarising a bead count, with its confidence spelled out. */
|
|
185
|
+
export function beadCountLine(counts: TraceSummary["beadCounts"]): string {
|
|
186
|
+
const part = (label: string, entry: { count: number; confidence: Confidence }) =>
|
|
187
|
+
`${entry.count} ${label}${confidenceSuffix(entry.confidence)}`;
|
|
188
|
+
return [
|
|
189
|
+
part("created", counts.created),
|
|
190
|
+
part("updated", counts.updated),
|
|
191
|
+
part("closed", counts.closed),
|
|
192
|
+
].join(" · ");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The claim the UI is allowed to make about beads.
|
|
197
|
+
*
|
|
198
|
+
* "No beads" may only be said on an `exact` empty count — anything else is
|
|
199
|
+
* "not known" (REQ-044c).
|
|
200
|
+
*/
|
|
201
|
+
export function beadClaim(counts: TraceSummary["beadCounts"]): string {
|
|
202
|
+
const { created } = counts;
|
|
203
|
+
if (created.count > 0) return beadCountLine(counts);
|
|
204
|
+
if (created.confidence === "exact") return "No beads were created for this request";
|
|
205
|
+
return "Whether this request created beads is not known";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Row title. A trace can exist without its request text — collection starts
|
|
210
|
+
* when the plugin loads, so a request already in flight has no user turn on
|
|
211
|
+
* record — and the screen has to say that instead of showing a blank line.
|
|
212
|
+
*/
|
|
213
|
+
export function excerptLine(excerpt: string | null): string {
|
|
214
|
+
if (excerpt === null) return "Request text not recorded (this request started before the Dashboard did)";
|
|
215
|
+
return excerpt.trim() === "" ? "(empty request)" : excerpt;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Reviewer agents and review calls are different numbers and are shown as such (REQ-042b). */
|
|
219
|
+
export function reviewerLine(trace: TraceSummary): string {
|
|
220
|
+
const agents = `${trace.reviewerIds.length} reviewer${trace.reviewerIds.length === 1 ? "" : "s"}`;
|
|
221
|
+
const calls = trace.reviewCalls === null ? "review calls unknown" : `${trace.reviewCalls} review call${trace.reviewCalls === 1 ? "" : "s"}`;
|
|
222
|
+
const claimed = trace.guardrailReported?.total;
|
|
223
|
+
const selfReported = claimed === undefined || claimed === null ? "" : ` · worker reported ${claimed}`;
|
|
224
|
+
return `${agents} · ${calls}${selfReported}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** True when the observed call count and the Worker's own count disagree (REQ-042c). */
|
|
228
|
+
export function guardrailMismatch(trace: TraceSummary): boolean {
|
|
229
|
+
const claimed = trace.guardrailReported?.total;
|
|
230
|
+
if (claimed === undefined || claimed === null || trace.reviewCalls === null) return false;
|
|
231
|
+
return claimed !== trace.reviewCalls;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Bead statistics and storage.
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
export interface StorageView {
|
|
239
|
+
summary: string;
|
|
240
|
+
warning: Badge | null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Storage line plus the warning, compared against the host-scoped threshold.
|
|
245
|
+
*
|
|
246
|
+
* The comparison lives here, in the client, because Paseo's settings RPCs are
|
|
247
|
+
* client-facing; the server only reports bytes (design §3.6).
|
|
248
|
+
*/
|
|
249
|
+
export function storageView(store: StoreSize, warnAboveBytes: number): StorageView {
|
|
250
|
+
// Bytes only, deliberately. `measureStore` counts deletable units, which is
|
|
251
|
+
// not the number of rows the list shows: several unlinked agents collapse
|
|
252
|
+
// into one "could not be linked" group. Printing that count next to a list
|
|
253
|
+
// the user can count themselves produced two different numbers for the same
|
|
254
|
+
// workspace during the WP-214 acceptance run, so the count now lives only
|
|
255
|
+
// where it is exact — the delete preview, which counts what it will delete.
|
|
256
|
+
const summary = `${formatBytes(store.workspaceBytes)} of traces here · ${formatBytes(store.bytes)} in total`;
|
|
257
|
+
if (store.bytes > warnAboveBytes) {
|
|
258
|
+
return {
|
|
259
|
+
summary,
|
|
260
|
+
warning: {
|
|
261
|
+
text: `The trace store is over ${formatBytes(warnAboveBytes)}. Delete traces you no longer need. ${HOST_SCOPE_NOTICE}`,
|
|
262
|
+
tone: "warning",
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
return { summary, warning: null };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// Grouping.
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
export interface TraceGroup {
|
|
274
|
+
key: WorkspaceState;
|
|
275
|
+
title: string;
|
|
276
|
+
hint: string | null;
|
|
277
|
+
traces: TraceSummary[];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Splits rows into the groups design §3.7 defines. An orphaned workspace gets
|
|
282
|
+
* the hint that its traces can be reassigned — that is the only way a user
|
|
283
|
+
* finds the feature.
|
|
284
|
+
*/
|
|
285
|
+
export function groupTraces(traces: readonly TraceSummary[]): TraceGroup[] {
|
|
286
|
+
const order: WorkspaceState[] = ["live", "archived", "orphaned", "unknown"];
|
|
287
|
+
const titles: Record<WorkspaceState, string> = {
|
|
288
|
+
live: "Requests",
|
|
289
|
+
archived: "Archived workspace",
|
|
290
|
+
orphaned: "Workspaces no longer in Paseo",
|
|
291
|
+
unknown: "Workspace state unknown",
|
|
292
|
+
};
|
|
293
|
+
const hints: Record<WorkspaceState, string | null> = {
|
|
294
|
+
live: null,
|
|
295
|
+
archived: "This workspace is archived in Paseo. Its history is kept.",
|
|
296
|
+
orphaned:
|
|
297
|
+
"Paseo no longer lists this workspace. You can reassign these traces onto a workspace that exists, or delete them.",
|
|
298
|
+
unknown: "Paseo's workspace list could not be read, so these traces are shown without a state.",
|
|
299
|
+
};
|
|
300
|
+
return order
|
|
301
|
+
.map((key) => ({
|
|
302
|
+
key,
|
|
303
|
+
title: titles[key],
|
|
304
|
+
hint: hints[key],
|
|
305
|
+
traces: traces.filter((trace) => trace.workspaceState === key),
|
|
306
|
+
}))
|
|
307
|
+
.filter((group) => group.traces.length > 0);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// Destructive actions.
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
export type DeleteScope = TraceDeleteScope;
|
|
315
|
+
|
|
316
|
+
/** How far back the "delete older than" shortcut reaches. */
|
|
317
|
+
export const OLDER_THAN_DAYS = 30;
|
|
318
|
+
|
|
319
|
+
/** Cut-off timestamp for that shortcut, in UTC. */
|
|
320
|
+
export function olderThanCutoff(now: Date, days: number = OLDER_THAN_DAYS): string {
|
|
321
|
+
return new Date(now.getTime() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export interface PendingDelete {
|
|
325
|
+
kind: "delete";
|
|
326
|
+
scope: DeleteScope;
|
|
327
|
+
/** What the dry run said would be lost. */
|
|
328
|
+
preview: { traces: number; bytes: number };
|
|
329
|
+
/** Requests in scope that still have a running agent, from the dry run. */
|
|
330
|
+
running: number;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export interface PendingReassign {
|
|
334
|
+
kind: "reassign";
|
|
335
|
+
fromWorkspaceId: string;
|
|
336
|
+
toWorkspaceId: string;
|
|
337
|
+
preview: { traces: number; bytes: number };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export type PendingAction = PendingDelete | PendingReassign;
|
|
341
|
+
|
|
342
|
+
/** Wording of the confirmation. Always names what is lost, never just "are you sure". */
|
|
343
|
+
export function describeAction(action: PendingAction): { title: string; body: string[]; confirmLabel: string } {
|
|
344
|
+
if (action.kind === "reassign") {
|
|
345
|
+
return {
|
|
346
|
+
title: "Reassign these traces?",
|
|
347
|
+
body: [
|
|
348
|
+
`${action.preview.traces} trace(s) (${formatBytes(action.preview.bytes)}) move from ${action.fromWorkspaceId} to ${action.toWorkspaceId}.`,
|
|
349
|
+
"The traces keep the workspace they were recorded under; only where they are grouped changes.",
|
|
350
|
+
"Nothing else on this machine is touched.",
|
|
351
|
+
],
|
|
352
|
+
confirmLabel: "Reassign",
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const what =
|
|
357
|
+
"traceId" in action.scope
|
|
358
|
+
? "this one request"
|
|
359
|
+
: "before" in action.scope
|
|
360
|
+
? `every trace recorded before ${action.scope.before}`
|
|
361
|
+
: "every trace of this workspace";
|
|
362
|
+
const body = [
|
|
363
|
+
`${action.preview.traces} trace(s) (${formatBytes(action.preview.bytes)}) will be deleted: ${what}.`,
|
|
364
|
+
"This cannot be undone.",
|
|
365
|
+
"Beads, documents, agents and Paseo conversations are not affected — only paseo-bm's own recording.",
|
|
366
|
+
];
|
|
367
|
+
if (action.running > 0) {
|
|
368
|
+
body.push(
|
|
369
|
+
`${action.running} of them still have a running turn. What happens after this point will be recorded as a new trace.`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
return { title: "Delete these traces?", body, confirmLabel: "Delete" };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Confirmation gate for destructive actions.
|
|
377
|
+
*
|
|
378
|
+
* `confirm()` only does something when an action is pending, and nothing is
|
|
379
|
+
* pending until `request()` was called with a dry-run result — which is what
|
|
380
|
+
* makes "No" the default: the destructive call cannot be reached without a
|
|
381
|
+
* preview first (REQ-054b).
|
|
382
|
+
*/
|
|
383
|
+
export interface ConfirmationGate {
|
|
384
|
+
getPending(): PendingAction | null;
|
|
385
|
+
subscribe(listener: () => void): () => void;
|
|
386
|
+
request(action: PendingAction): void;
|
|
387
|
+
cancel(): void;
|
|
388
|
+
/** Hands the pending action to `run`, then clears it. No-ops when nothing is pending. */
|
|
389
|
+
confirm<T>(run: (action: PendingAction) => Promise<T>): Promise<T | null>;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function createConfirmationGate(): ConfirmationGate {
|
|
393
|
+
let pending: PendingAction | null = null;
|
|
394
|
+
const listeners = new Set<() => void>();
|
|
395
|
+
const emit = () => {
|
|
396
|
+
for (const listener of listeners) listener();
|
|
397
|
+
};
|
|
398
|
+
return {
|
|
399
|
+
getPending: () => pending,
|
|
400
|
+
subscribe(listener) {
|
|
401
|
+
listeners.add(listener);
|
|
402
|
+
return () => {
|
|
403
|
+
listeners.delete(listener);
|
|
404
|
+
};
|
|
405
|
+
},
|
|
406
|
+
request(action) {
|
|
407
|
+
pending = action;
|
|
408
|
+
emit();
|
|
409
|
+
},
|
|
410
|
+
cancel() {
|
|
411
|
+
pending = null;
|
|
412
|
+
emit();
|
|
413
|
+
},
|
|
414
|
+
async confirm(run) {
|
|
415
|
+
const action = pending;
|
|
416
|
+
if (action === null) return null;
|
|
417
|
+
try {
|
|
418
|
+
return await run(action);
|
|
419
|
+
} finally {
|
|
420
|
+
pending = null;
|
|
421
|
+
emit();
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// Overview: cards and charts (owner feedback after WP-214).
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
export interface OverviewCard {
|
|
432
|
+
label: string;
|
|
433
|
+
value: string;
|
|
434
|
+
hint: string;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Six numbers that answer "what is going on here" at a glance. */
|
|
438
|
+
export function overviewCards(traces: readonly TraceSummary[], stats: BeadStats | null): OverviewCard[] {
|
|
439
|
+
const count = (state: TraceSummary["state"]) => traces.filter((trace) => trace.state === state).length;
|
|
440
|
+
const workers = new Set(traces.flatMap((trace) => trace.workerIds)).size;
|
|
441
|
+
const reviewers = new Set(traces.flatMap((trace) => trace.reviewerIds)).size;
|
|
442
|
+
const input = traces.reduce((sum, trace) => sum + trace.usage.inputTokens, 0);
|
|
443
|
+
const cached = traces.reduce((sum, trace) => sum + trace.usage.cachedInputTokens, 0);
|
|
444
|
+
const output = traces.reduce((sum, trace) => sum + trace.usage.outputTokens, 0);
|
|
445
|
+
const priced = traces.filter((trace) => trace.usage.costUsd !== null);
|
|
446
|
+
const cost = priced.reduce((sum, trace) => sum + (trace.usage.costUsd ?? 0), 0);
|
|
447
|
+
const unpriced = traces.length - priced.length;
|
|
448
|
+
|
|
449
|
+
const beads: OverviewCard =
|
|
450
|
+
stats === null
|
|
451
|
+
? { label: "Beads", value: "…", hint: "loading" }
|
|
452
|
+
: !stats.present
|
|
453
|
+
? { label: "Beads", value: "—", hint: "no .beads/ in this workspace" }
|
|
454
|
+
: {
|
|
455
|
+
label: "Beads",
|
|
456
|
+
value: String(stats.total),
|
|
457
|
+
hint: `${stats.inProgress} in progress · ${stats.open} not started · ${stats.closed} done`,
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
return [
|
|
461
|
+
{
|
|
462
|
+
label: "Requests",
|
|
463
|
+
value: String(traces.length),
|
|
464
|
+
hint: `${count("running")} running · ${count("waiting_user")} waiting · ${count("completed")} done`,
|
|
465
|
+
},
|
|
466
|
+
beads,
|
|
467
|
+
{ label: "Agents", value: String(workers + reviewers), hint: `${workers} workers · ${reviewers} reviewers` },
|
|
468
|
+
{
|
|
469
|
+
label: "Messages",
|
|
470
|
+
value: String(traces.reduce((sum, trace) => sum + trace.messageCount, 0)),
|
|
471
|
+
hint: "sent and received",
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
label: "Tokens",
|
|
475
|
+
value: formatTokens(input + cached + output),
|
|
476
|
+
hint: `${formatTokens(input)} in · ${formatTokens(cached)} cached · ${formatTokens(output)} out`,
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
label: "Cost",
|
|
480
|
+
value: priced.length === 0 ? "—" : `$${cost.toFixed(2)}`,
|
|
481
|
+
hint: unpriced === 0 ? "estimated" : `estimated · ${unpriced} request(s) unpriced`,
|
|
482
|
+
},
|
|
483
|
+
];
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export interface Bar {
|
|
487
|
+
label: string;
|
|
488
|
+
value: number;
|
|
489
|
+
display: string;
|
|
490
|
+
/** Opened when the bar is pressed. */
|
|
491
|
+
agentId?: string;
|
|
492
|
+
/** Second line under the label. */
|
|
493
|
+
hint?: string;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* How a row says which turn of its request it is.
|
|
498
|
+
*
|
|
499
|
+
* Empty for a request nobody followed up, so those rows read exactly as they
|
|
500
|
+
* always did — a lone "turn 1 of 1" would be noise on the majority of rows.
|
|
501
|
+
*/
|
|
502
|
+
export function turnLabel(turn: TraceSummary["turn"]): string {
|
|
503
|
+
return turn === null ? "" : `turn ${turn.index} of ${turn.total} · `;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Requests per day for the last `days` days, oldest first.
|
|
508
|
+
*
|
|
509
|
+
* Counts REQUESTS, not rows (owner decision Q27). Since delta 20260917e the
|
|
510
|
+
* list carries one row per question asked, so a single request that went back
|
|
511
|
+
* and forth ten times would otherwise read as ten requests and the day-to-day
|
|
512
|
+
* comparison this chart exists for would be meaningless. Only the row that
|
|
513
|
+
* opens a request is counted.
|
|
514
|
+
*/
|
|
515
|
+
export function requestsPerDay(traces: readonly TraceSummary[], now: Date, days = 7): Bar[] {
|
|
516
|
+
const opening = traces.filter((trace) => trace.turn === null || trace.turn.index === 1);
|
|
517
|
+
const bars: Bar[] = [];
|
|
518
|
+
for (let offset = days - 1; offset >= 0; offset -= 1) {
|
|
519
|
+
const day = new Date(now.getTime() - offset * 86_400_000).toISOString().slice(0, 10);
|
|
520
|
+
const value = opening.filter((trace) => trace.requestedAt.slice(0, 10) === day).length;
|
|
521
|
+
bars.push({ label: day.slice(5), value, display: String(value) });
|
|
522
|
+
}
|
|
523
|
+
return bars;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** The Workers that used the most tokens, across the listed requests. */
|
|
527
|
+
/**
|
|
528
|
+
* Tokens and cost per model and role, over the same rows the overview cards
|
|
529
|
+
* count (delta 20260918 §4.4, REQ-058e): which model each role actually runs
|
|
530
|
+
* on, and what it costs. A model without a price shows tokens only; an unknown
|
|
531
|
+
* model keeps its bar instead of vanishing from the total.
|
|
532
|
+
*/
|
|
533
|
+
export function tokensByModelRole(traces: readonly TraceSummary[]): Bar[] {
|
|
534
|
+
const byPair = new Map<string, { label: string; tokens: number; cost: number | null }>();
|
|
535
|
+
for (const trace of traces) {
|
|
536
|
+
for (const entry of trace.usageByModelRole ?? []) {
|
|
537
|
+
const label = `${entry.model ?? "unknown model"} · ${entry.role}`;
|
|
538
|
+
const tokens = entry.usage.inputTokens + entry.usage.cachedInputTokens + entry.usage.outputTokens;
|
|
539
|
+
const current = byPair.get(label);
|
|
540
|
+
const cost = entry.usage.costUsd;
|
|
541
|
+
byPair.set(label, {
|
|
542
|
+
label,
|
|
543
|
+
tokens: (current?.tokens ?? 0) + tokens,
|
|
544
|
+
cost: cost === null ? (current?.cost ?? null) : (current?.cost ?? 0) + cost,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return [...byPair.values()]
|
|
549
|
+
.filter((pair) => pair.tokens > 0)
|
|
550
|
+
.sort((a, b) => b.tokens - a.tokens)
|
|
551
|
+
.map((pair) => ({
|
|
552
|
+
label: pair.label,
|
|
553
|
+
value: pair.tokens,
|
|
554
|
+
display: `${formatTokens(pair.tokens)}${pair.cost === null ? "" : ` · $${pair.cost.toFixed(2)}`}`,
|
|
555
|
+
}));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function heaviestWorkers(traces: readonly TraceSummary[], limit = 5): Bar[] {
|
|
559
|
+
const byWorker = new Map<string, { title: string | null; tokens: number; cost: number | null; request: string }>();
|
|
560
|
+
for (const trace of traces) {
|
|
561
|
+
for (const worker of trace.workerUsage) {
|
|
562
|
+
const tokens = worker.usage.inputTokens + worker.usage.cachedInputTokens + worker.usage.outputTokens;
|
|
563
|
+
const current = byWorker.get(worker.agentId);
|
|
564
|
+
const cost = worker.usage.costUsd;
|
|
565
|
+
byWorker.set(worker.agentId, {
|
|
566
|
+
title: current?.title ?? worker.title,
|
|
567
|
+
tokens: (current?.tokens ?? 0) + tokens,
|
|
568
|
+
cost: cost === null ? (current?.cost ?? null) : (current?.cost ?? 0) + cost,
|
|
569
|
+
request: current?.request ?? shorten(excerptLine(trace.excerpt), 40),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return [...byWorker.entries()]
|
|
574
|
+
.filter(([, worker]) => worker.tokens > 0)
|
|
575
|
+
.sort((a, b) => b[1].tokens - a[1].tokens)
|
|
576
|
+
.slice(0, limit)
|
|
577
|
+
.map(([agentId, worker]) => ({
|
|
578
|
+
agentId,
|
|
579
|
+
label: shorten(worker.title ?? `Worker ${agentId.slice(0, 8)}`, 36),
|
|
580
|
+
hint: worker.request,
|
|
581
|
+
value: worker.tokens,
|
|
582
|
+
display: `${formatTokens(worker.tokens)}${worker.cost === null ? "" : ` · $${worker.cost.toFixed(2)}`}`,
|
|
583
|
+
}));
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Bar width as a share of the largest value, 0..1. */
|
|
587
|
+
export function barShare(bar: Bar, bars: readonly Bar[]): number {
|
|
588
|
+
const max = Math.max(0, ...bars.map((entry) => entry.value));
|
|
589
|
+
return max === 0 ? 0 : bar.value / max;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
// Request graph: Request → Workers → Reviewers, each node expandable.
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
|
|
596
|
+
export type GraphNodeKind = "request" | "worker" | "reviewer";
|
|
597
|
+
|
|
598
|
+
export interface ChipGroup {
|
|
599
|
+
label: string;
|
|
600
|
+
chips: Badge[];
|
|
601
|
+
/** One line under the chips, e.g. what the colours mean. */
|
|
602
|
+
note?: string;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* One workflow step as a coloured chip: green = done (a report said so),
|
|
607
|
+
* blue = done (inferred from commands), grey = not needed for this size,
|
|
608
|
+
* amber = unknown (nothing seen either way).
|
|
609
|
+
*/
|
|
610
|
+
export function stepChip(row: WorkflowStepResult): Badge {
|
|
611
|
+
const label = STEP_LABELS[row.step];
|
|
612
|
+
if (row.status === "done") {
|
|
613
|
+
return row.confidence === "exact" ? { text: `✓ ${label}`, tone: "success" } : { text: `✓ ${label} ~`, tone: "info" };
|
|
614
|
+
}
|
|
615
|
+
if (row.status === "skipped") return { text: `– ${label}`, tone: "muted" };
|
|
616
|
+
return { text: `? ${label}`, tone: "warning" };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export const STEP_LEGEND = "green done · blue done (inferred) · grey not needed · amber unknown";
|
|
620
|
+
|
|
621
|
+
function skillChips(skills: readonly string[]): Badge[] {
|
|
622
|
+
return skills.map((skill) => ({ text: skill, tone: "info" as const }));
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* How each agent is drawn on the graph: a small Lucide icon on a soft tint of
|
|
627
|
+
* one theme colour. The request node stands for the Manager, which handled it.
|
|
628
|
+
* The shape tells the role apart even where the colours look alike.
|
|
629
|
+
*/
|
|
630
|
+
export const ROLE_MARK: Readonly<Record<GraphNode["kind"], { role: string; icon: string; tone: Tone; does: string }>> = {
|
|
631
|
+
request: { role: "Manager", icon: "BotMessageSquare", tone: "info", does: "takes the request, delegates" },
|
|
632
|
+
worker: { role: "Worker", icon: "Hammer", tone: "success", does: "docs, beads and code" },
|
|
633
|
+
reviewer: { role: "Reviewer", icon: "ScanEye", tone: "warning", does: "reviews each batch" },
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
export interface GraphNode {
|
|
637
|
+
id: string;
|
|
638
|
+
kind: GraphNodeKind;
|
|
639
|
+
/** 0 for the request, 1 for workers, 2 for reviewers under their worker. */
|
|
640
|
+
depth: number;
|
|
641
|
+
title: string;
|
|
642
|
+
subtitle: string;
|
|
643
|
+
badge: Badge;
|
|
644
|
+
/** Shown when the node is expanded; empty until the detail is loaded. */
|
|
645
|
+
details: string[];
|
|
646
|
+
/** Chip rows shown with the details. */
|
|
647
|
+
chipGroups: ChipGroup[];
|
|
648
|
+
/** Agent to open from the node, when it is an agent. */
|
|
649
|
+
agentId: string | null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export function shorten(text: string, max: number): string {
|
|
653
|
+
const line = text.replace(/\s+/g, " ").trim();
|
|
654
|
+
return line.length <= max ? line : `${line.slice(0, max - 1)}…`;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const shortId = (id: string) => id.slice(0, 8);
|
|
658
|
+
|
|
659
|
+
function timeOf(ms: number | null, startedAt: string | null, now: Date): string {
|
|
660
|
+
return ms === null ? formatElapsed(startedAt, now) : formatDuration(ms);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function usageLine(usage: Usage): string {
|
|
664
|
+
return `${formatUsage(usage)} · ${formatCost(usage)}`;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const turnsText = (turns: number) => `${turns} turn${turns === 1 ? "" : "s"}`;
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* One `Model: …` line per combination an agent ran on (delta 20260918 §4.4,
|
|
671
|
+
* REQ-058 a–c). A turn written before the collector recorded thinking and mode
|
|
672
|
+
* says so instead of guessing; a recorded `null` thinking option is the
|
|
673
|
+
* provider's default, which is a different thing.
|
|
674
|
+
*/
|
|
675
|
+
export function runtimeLines(rows: readonly RuntimeRow[] | undefined): string[] {
|
|
676
|
+
return (rows ?? []).map((row) => {
|
|
677
|
+
if (!row.recorded) {
|
|
678
|
+
return row.model === null
|
|
679
|
+
? `Model: not recorded · ${turnsText(row.turns)}`
|
|
680
|
+
: `Model: ${row.model} · thinking/mode: not recorded · ${turnsText(row.turns)}`;
|
|
681
|
+
}
|
|
682
|
+
return `Model: ${row.model ?? "not recorded"} · thinking: ${row.thinkingOptionId ?? "provider default"} · mode: ${row.modeId ?? "unknown"} · ${turnsText(row.turns)}`;
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** The agent's model when it ran on exactly one known model, for a node's subtitle. */
|
|
687
|
+
export function singleModelOf(rows: readonly RuntimeRow[] | undefined): string | null {
|
|
688
|
+
const models = new Set((rows ?? []).map((row) => row.model).filter((model): model is string => model !== null));
|
|
689
|
+
return models.size === 1 ? [...models][0]! : null;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** `Tokens by model: …` for a request; a model without a price shows tokens only (REQ-058d). */
|
|
693
|
+
export function tokensByModelLine(entries: TraceDetail["usageByModel"]): string | null {
|
|
694
|
+
if (entries === undefined || entries.length === 0) return null;
|
|
695
|
+
const parts = entries.map(({ model, usage }) => {
|
|
696
|
+
const tokens = `${formatTokens(usage.inputTokens + usage.cachedInputTokens + usage.outputTokens)} tokens`;
|
|
697
|
+
return `${model ?? "unknown model"} ${tokens}${usage.costUsd === null ? "" : ` · ${formatCost(usage)}`}`;
|
|
698
|
+
});
|
|
699
|
+
return `Tokens by model: ${parts.join(" · ")}`;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* The nodes of one request, in display order. Reviewers sit under the Worker
|
|
704
|
+
* when the request has exactly one (the normal case); with several Workers the
|
|
705
|
+
* trace does not say which Worker a Reviewer served, so they sit under the
|
|
706
|
+
* request instead of being attached on a guess.
|
|
707
|
+
*/
|
|
708
|
+
export function requestGraph(summary: TraceSummary, detail: TraceDetail | null, now: Date): GraphNode[] {
|
|
709
|
+
const usageOf = (agentId: string) => detail?.usageByAgent.find((entry) => entry.agentId === agentId)?.usage;
|
|
710
|
+
const runtimeOf = (agentId: string) => detail?.usageByAgent.find((entry) => entry.agentId === agentId)?.runtime;
|
|
711
|
+
/** What the user typed to this agent. */
|
|
712
|
+
const agentLines = (agentId: string): string[] => {
|
|
713
|
+
if (detail === null) return [];
|
|
714
|
+
const lines: string[] = [];
|
|
715
|
+
for (const message of detail.userMessages.filter((entry) => entry.agentId === agentId)) {
|
|
716
|
+
lines.push(`💬 You → ${shortId(agentId)} (${message.at.slice(11, 19)}): ${shorten(message.text, 300)}`);
|
|
717
|
+
}
|
|
718
|
+
return lines;
|
|
719
|
+
};
|
|
720
|
+
const duration =
|
|
721
|
+
summary.state === "running" ? formatElapsed(summary.requestedAt, now) : formatDuration(summary.durationMs);
|
|
722
|
+
|
|
723
|
+
const requestDetails: string[] = [];
|
|
724
|
+
if (detail !== null) {
|
|
725
|
+
requestDetails.push(
|
|
726
|
+
`Asked: ${detail.sent.userRequest === null ? "not recorded" : shorten(detail.sent.userRequest.text, 400)}`,
|
|
727
|
+
);
|
|
728
|
+
const reply = detail.received.managerReplies.at(-1);
|
|
729
|
+
if (reply !== undefined) requestDetails.push(`Answer: ${shorten(reply.text, 400)}`);
|
|
730
|
+
requestDetails.push(`Time: ${duration} (wall clock, includes waiting for you)`);
|
|
731
|
+
requestDetails.push(`Tokens: ${usageLine(summary.usage)}`);
|
|
732
|
+
const byModel = tokensByModelLine(detail.usageByModel);
|
|
733
|
+
if (byModel !== null) requestDetails.push(byModel);
|
|
734
|
+
// The Manager has no node of its own, so what it ran on is listed here.
|
|
735
|
+
for (const entry of detail.usageByAgent.filter((agent) => agent.role === "manager")) {
|
|
736
|
+
for (const line of runtimeLines(entry.runtime)) requestDetails.push(`Manager ${shortId(entry.agentId)} — ${line}`);
|
|
737
|
+
}
|
|
738
|
+
requestDetails.push(`Beads: ${beadClaim(summary.beadCounts)}`);
|
|
739
|
+
for (const bead of detail.beads.filter((entry) => entry.action === "created" || entry.action === "closed")) {
|
|
740
|
+
requestDetails.push(` ${bead.id} ${bead.action} · now ${bead.statusNow ?? "not in store"}${bead.title === null ? "" : ` · ${shorten(bead.title, 60)}`}`);
|
|
741
|
+
}
|
|
742
|
+
// The Manager is not a node of its own, so what the user told it lives here.
|
|
743
|
+
const managers = new Set(detail.usageByAgent.filter((entry) => entry.role === "manager").map((entry) => entry.agentId));
|
|
744
|
+
for (const message of detail.userMessages.filter((entry) => entry.agentId !== null && managers.has(entry.agentId))) {
|
|
745
|
+
requestDetails.push(`💬 You → Manager (${message.at.slice(11, 19)}): ${shorten(message.text, 300)}`);
|
|
746
|
+
}
|
|
747
|
+
for (const badge of [linkingBadge(summary.linking), workspaceStateBadge(summary.workspaceState)]) {
|
|
748
|
+
if (badge !== null) requestDetails.push(`Note: ${badge.text}`);
|
|
749
|
+
}
|
|
750
|
+
if (guardrailMismatch(summary)) requestDetails.push(`Note: review counts disagree (${reviewerLine(summary)})`);
|
|
751
|
+
for (const notice of summary.notices) requestDetails.push(`Note: ${notice}`);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// The opened detail also reads agents' own timelines, so it can know more.
|
|
755
|
+
const fromYou = Math.max(summary.userMessageCount, detail?.userMessages.length ?? 0);
|
|
756
|
+
const requestChips: ChipGroup[] = [];
|
|
757
|
+
if (detail !== null && detail.workflowSteps.length > 0) {
|
|
758
|
+
requestChips.push({ label: "Workflow steps", chips: detail.workflowSteps.map(stepChip), note: STEP_LEGEND });
|
|
759
|
+
}
|
|
760
|
+
const allSkills = [...new Set(detail?.skills.map((entry) => entry.skill) ?? [])];
|
|
761
|
+
if (allSkills.length > 0) requestChips.push({ label: "Skills used", chips: skillChips(allSkills) });
|
|
762
|
+
const agentChips = (agentId: string): ChipGroup[] => {
|
|
763
|
+
const skills = detail?.skills.filter((entry) => entry.agentId === agentId).map((entry) => entry.skill) ?? [];
|
|
764
|
+
return skills.length === 0 ? [] : [{ label: "Skills loaded", chips: skillChips(skills) }];
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
const nodes: GraphNode[] = [
|
|
768
|
+
{
|
|
769
|
+
// Rows of one request share a `traceId`, so the turn has to be part of
|
|
770
|
+
// the node id or two rows collide (delta 20260917e §4.3).
|
|
771
|
+
id: `request:${summary.traceId}${summary.turn === null ? "" : `#${summary.turn.index}`}`,
|
|
772
|
+
kind: "request",
|
|
773
|
+
depth: 0,
|
|
774
|
+
title: shorten(excerptLine(summary.excerpt), 90),
|
|
775
|
+
subtitle: `${turnLabel(summary.turn)}${summary.tier ?? "size ?"} · ${duration} · ${formatTokens(summary.usage.inputTokens + summary.usage.cachedInputTokens + summary.usage.outputTokens)} tokens · ${formatCost(summary.usage)}${fromYou > 0 ? ` · 💬 ${fromYou} from you` : ""}`,
|
|
776
|
+
badge: stateBadge(summary.state),
|
|
777
|
+
details: requestDetails,
|
|
778
|
+
chipGroups: requestChips,
|
|
779
|
+
agentId: null,
|
|
780
|
+
},
|
|
781
|
+
];
|
|
782
|
+
|
|
783
|
+
const reviewerNode = (agentId: string, depth: number): GraphNode => {
|
|
784
|
+
const timing = detail?.timing.reviewers.find((entry) => entry.agentId === agentId);
|
|
785
|
+
const request = detail?.sent.reviewRequests.find((entry) => entry.agentId === agentId);
|
|
786
|
+
const review = detail?.received.reviews.filter((entry) => entry.agentId === agentId).at(-1);
|
|
787
|
+
const batch = request?.batchId ?? review?.batchId ?? null;
|
|
788
|
+
const verdict = review?.verdict ?? null;
|
|
789
|
+
const usage = usageOf(agentId);
|
|
790
|
+
const details: string[] = [];
|
|
791
|
+
if (detail !== null) {
|
|
792
|
+
if (request !== undefined) details.push(`Asked to review: ${shorten(request.text, 300)}`);
|
|
793
|
+
details.push(...agentLines(agentId));
|
|
794
|
+
details.push(`Verdict: ${verdict ?? "none recorded"}${review?.blockingCount != null ? ` · ${review.blockingCount} blocking` : ""}`);
|
|
795
|
+
if (timing !== undefined) details.push(`Time: ${timeOf(timing.ms, timing.startedAt, now)}`);
|
|
796
|
+
if (usage !== undefined) details.push(`Tokens: ${usageLine(usage)}`);
|
|
797
|
+
details.push(...runtimeLines(runtimeOf(agentId)));
|
|
798
|
+
}
|
|
799
|
+
const reviewerModel = singleModelOf(runtimeOf(agentId));
|
|
800
|
+
return {
|
|
801
|
+
id: `reviewer:${agentId}`,
|
|
802
|
+
kind: "reviewer",
|
|
803
|
+
depth,
|
|
804
|
+
title: `Reviewer ${shortId(agentId)}${batch === null ? "" : ` · batch ${batch}`}`,
|
|
805
|
+
subtitle: `${timing === undefined ? "reviewer" : timeOf(timing.ms, timing.startedAt, now)}${reviewerModel === null ? "" : ` · ${reviewerModel}`}`,
|
|
806
|
+
badge:
|
|
807
|
+
verdict === null
|
|
808
|
+
? { text: "No verdict", tone: "muted" }
|
|
809
|
+
: { text: verdict, tone: /pass|approved/i.test(verdict) ? "success" : "warning" },
|
|
810
|
+
details,
|
|
811
|
+
chipGroups: agentChips(agentId),
|
|
812
|
+
agentId,
|
|
813
|
+
};
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
const single = summary.workerIds.length === 1;
|
|
817
|
+
for (const workerId of summary.workerIds) {
|
|
818
|
+
const timing = detail?.timing.workers.find((entry) => entry.agentId === workerId);
|
|
819
|
+
const reports = detail?.received.reports.filter((report) => report.agentId === workerId) ?? [];
|
|
820
|
+
const last = reports.at(-1);
|
|
821
|
+
const usage = usageOf(workerId);
|
|
822
|
+
const details: string[] = [];
|
|
823
|
+
if (detail !== null) {
|
|
824
|
+
const files = [...new Set(reports.flatMap((report) => report.filesChanged))];
|
|
825
|
+
details.push(`Last report: ${last?.phase ?? "none"}`);
|
|
826
|
+
details.push(`Files changed: ${files.length === 0 ? "none reported" : files.join(", ")}`);
|
|
827
|
+
if (last?.buildAndTests != null) details.push(`Checks: ${shorten(last.buildAndTests, 200)}`);
|
|
828
|
+
if (last?.blockers != null) details.push(`Open points: ${shorten(last.blockers, 300)}`);
|
|
829
|
+
if (timing !== undefined) details.push(`Time: ${timeOf(timing.ms, timing.startedAt, now)}`);
|
|
830
|
+
if (usage !== undefined) details.push(`Tokens: ${usageLine(usage)}`);
|
|
831
|
+
details.push(...runtimeLines(runtimeOf(workerId)));
|
|
832
|
+
details.push(...agentLines(workerId));
|
|
833
|
+
}
|
|
834
|
+
const fromUser = detail?.userMessages.filter((entry) => entry.agentId === workerId).length ?? 0;
|
|
835
|
+
const workerModel = singleModelOf(runtimeOf(workerId));
|
|
836
|
+
nodes.push({
|
|
837
|
+
id: `worker:${workerId}`,
|
|
838
|
+
kind: "worker",
|
|
839
|
+
depth: 1,
|
|
840
|
+
title: `Worker ${shortId(workerId)}`,
|
|
841
|
+
subtitle: `${reviewerLine(summary)}${timing === undefined ? "" : ` · ${timeOf(timing.ms, timing.startedAt, now)}`}${workerModel === null ? "" : ` · ${workerModel}`}${fromUser > 0 ? ` · 💬 ${fromUser}` : ""}`,
|
|
842
|
+
badge: stateBadge(timing?.state ?? summary.state),
|
|
843
|
+
details,
|
|
844
|
+
chipGroups: agentChips(workerId),
|
|
845
|
+
agentId: workerId,
|
|
846
|
+
});
|
|
847
|
+
if (single) for (const reviewerId of summary.reviewerIds) nodes.push(reviewerNode(reviewerId, 2));
|
|
848
|
+
}
|
|
849
|
+
if (!single) for (const reviewerId of summary.reviewerIds) nodes.push(reviewerNode(reviewerId, 1));
|
|
850
|
+
return nodes;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// ---------------------------------------------------------------------------
|
|
854
|
+
// Styles. Every colour comes from the theme; no literal colours.
|
|
855
|
+
// ---------------------------------------------------------------------------
|
|
856
|
+
|
|
857
|
+
export function toneColor(theme: PluginTheme, tone: Tone): string {
|
|
858
|
+
switch (tone) {
|
|
859
|
+
case "muted":
|
|
860
|
+
return theme.colors.foregroundMuted;
|
|
861
|
+
case "info":
|
|
862
|
+
return theme.colors.accent;
|
|
863
|
+
case "warning":
|
|
864
|
+
return theme.colors.statusWarning;
|
|
865
|
+
case "danger":
|
|
866
|
+
return theme.colors.statusDanger;
|
|
867
|
+
case "success":
|
|
868
|
+
return theme.colors.statusSuccess;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
export function dashboardStyles(theme: PluginTheme, compact: boolean) {
|
|
873
|
+
return {
|
|
874
|
+
screen: { flex: 1, backgroundColor: theme.colors.surface0 },
|
|
875
|
+
content: { padding: compact ? 12 : 24, gap: compact ? 8 : 12 },
|
|
876
|
+
title: { color: theme.colors.foreground, fontSize: compact ? 18 : 24, fontWeight: "600" as const },
|
|
877
|
+
sectionTitle: { color: theme.colors.foreground, fontSize: compact ? 14 : 16, fontWeight: "600" as const },
|
|
878
|
+
body: { color: theme.colors.foregroundMuted, fontSize: compact ? 12 : 14 },
|
|
879
|
+
mono: { color: theme.colors.foreground, fontSize: compact ? 11 : 13 },
|
|
880
|
+
card: {
|
|
881
|
+
gap: compact ? 6 : 8,
|
|
882
|
+
padding: compact ? 10 : 14,
|
|
883
|
+
borderRadius: 10,
|
|
884
|
+
borderWidth: 1,
|
|
885
|
+
borderColor: theme.colors.border,
|
|
886
|
+
backgroundColor: theme.colors.surface1,
|
|
887
|
+
},
|
|
888
|
+
badge: { fontSize: compact ? 11 : 12, fontWeight: "600" as const },
|
|
889
|
+
button: {
|
|
890
|
+
paddingVertical: 10,
|
|
891
|
+
paddingHorizontal: 14,
|
|
892
|
+
borderRadius: 8,
|
|
893
|
+
alignItems: "center" as const,
|
|
894
|
+
backgroundColor: theme.colors.accent,
|
|
895
|
+
},
|
|
896
|
+
buttonText: { color: theme.colors.accentForeground, fontSize: compact ? 13 : 14 },
|
|
897
|
+
dangerButton: {
|
|
898
|
+
paddingVertical: 10,
|
|
899
|
+
paddingHorizontal: 14,
|
|
900
|
+
borderRadius: 8,
|
|
901
|
+
alignItems: "center" as const,
|
|
902
|
+
borderWidth: 1,
|
|
903
|
+
borderColor: theme.colors.statusDanger,
|
|
904
|
+
backgroundColor: theme.colors.surface2,
|
|
905
|
+
},
|
|
906
|
+
dangerButtonText: { color: theme.colors.statusDanger, fontSize: compact ? 13 : 14 },
|
|
907
|
+
secondaryButton: {
|
|
908
|
+
paddingVertical: 10,
|
|
909
|
+
paddingHorizontal: 14,
|
|
910
|
+
borderRadius: 8,
|
|
911
|
+
alignItems: "center" as const,
|
|
912
|
+
borderWidth: 1,
|
|
913
|
+
borderColor: theme.colors.border,
|
|
914
|
+
backgroundColor: theme.colors.surface2,
|
|
915
|
+
},
|
|
916
|
+
secondaryButtonText: { color: theme.colors.foreground, fontSize: compact ? 13 : 14 },
|
|
917
|
+
spinner: { color: theme.colors.foregroundMuted },
|
|
918
|
+
cards: { flexDirection: "row" as const, flexWrap: "wrap" as const, gap: compact ? 6 : 10 },
|
|
919
|
+
statCard: {
|
|
920
|
+
minWidth: compact ? 140 : 160,
|
|
921
|
+
flexGrow: 1,
|
|
922
|
+
gap: 2,
|
|
923
|
+
padding: compact ? 10 : 12,
|
|
924
|
+
borderRadius: 10,
|
|
925
|
+
borderWidth: 1,
|
|
926
|
+
borderColor: theme.colors.border,
|
|
927
|
+
backgroundColor: theme.colors.surface1,
|
|
928
|
+
},
|
|
929
|
+
statValue: { color: theme.colors.foreground, fontSize: compact ? 20 : 26, fontWeight: "700" as const },
|
|
930
|
+
barTrack: { flex: 1, height: 10, borderRadius: 5, backgroundColor: theme.colors.surface2 },
|
|
931
|
+
barFill: { height: 10, borderRadius: 5, backgroundColor: theme.colors.accent },
|
|
932
|
+
chipRow: { flexDirection: "row" as const, flexWrap: "wrap" as const, gap: 6 },
|
|
933
|
+
chip: {
|
|
934
|
+
paddingVertical: 2,
|
|
935
|
+
paddingHorizontal: 8,
|
|
936
|
+
borderRadius: 999,
|
|
937
|
+
borderWidth: 1,
|
|
938
|
+
},
|
|
939
|
+
node: {
|
|
940
|
+
gap: 4,
|
|
941
|
+
paddingVertical: compact ? 6 : 8,
|
|
942
|
+
paddingHorizontal: compact ? 8 : 10,
|
|
943
|
+
borderLeftWidth: 2,
|
|
944
|
+
borderLeftColor: theme.colors.border,
|
|
945
|
+
},
|
|
946
|
+
};
|
|
947
|
+
}
|