@cat-factory/app 0.161.0 → 0.162.1

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.
Files changed (33) hide show
  1. package/app/components/panels/AgentStepDetail.vue +58 -6
  2. package/app/components/panels/ReportsPanel.logic.spec.ts +76 -0
  3. package/app/components/panels/ReportsPanel.logic.ts +71 -0
  4. package/app/components/panels/ReportsPanel.vue +471 -0
  5. package/app/components/panels/ReportsSpendBreakdown.vue +70 -0
  6. package/app/components/panels/inspector/TaskExecution.vue +25 -1
  7. package/app/components/pipeline/PipelineProgress.vue +13 -3
  8. package/app/composables/api/reports.ts +19 -0
  9. package/app/composables/useApi.ts +2 -0
  10. package/app/composables/useNavContributions.ts +1 -0
  11. package/app/composables/useStepApproval.ts +10 -9
  12. package/app/modular/nav-contributions.spec.ts +1 -0
  13. package/app/modular/nav-contributions.ts +11 -0
  14. package/app/pages/index.vue +2 -0
  15. package/app/stores/execution/commands.ts +47 -16
  16. package/app/stores/reports.spec.ts +113 -0
  17. package/app/stores/reports.ts +91 -0
  18. package/app/stores/ui/modals.ts +14 -0
  19. package/app/stores/ui/resultViews.ts +5 -1
  20. package/app/types/execution.ts +8 -0
  21. package/app/utils/pipelineRender.spec.ts +72 -0
  22. package/app/utils/pipelineRender.ts +26 -0
  23. package/i18n/locales/de.json +74 -1
  24. package/i18n/locales/en.json +74 -1
  25. package/i18n/locales/es.json +74 -1
  26. package/i18n/locales/fr.json +74 -1
  27. package/i18n/locales/he.json +74 -1
  28. package/i18n/locales/it.json +74 -1
  29. package/i18n/locales/ja.json +74 -1
  30. package/i18n/locales/pl.json +74 -1
  31. package/i18n/locales/tr.json +74 -1
  32. package/i18n/locales/uk.json +74 -1
  33. package/package.json +2 -2
@@ -0,0 +1,471 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { onKeyStroke } from '@vueuse/core'
4
+ import type {
5
+ ReportActivityDimension,
6
+ ReportActivityRow,
7
+ ReportSpendRow,
8
+ ReportWindow,
9
+ } from '~/types/execution'
10
+ import { formatMs, formatTokens } from '~/utils/observability'
11
+ import {
12
+ activitySegments,
13
+ columnPct,
14
+ isUnattributed,
15
+ maxOf,
16
+ segmentPct,
17
+ trendMagnitude,
18
+ } from './ReportsPanel.logic'
19
+
20
+ // Reports: cross-cutting usage analytics for the active account — where the spend and the
21
+ // work actually go. Spend per model and agent kind, spend + run activity per workspace /
22
+ // service / task type, and a spend trend, over a selectable window and optionally narrowed
23
+ // to one board. Admin-gated; opened via `ui.openReports()` from the sidebar. The sibling of
24
+ // `OperatorDashboardPanel`, which answers the health question over the same account scope.
25
+ //
26
+ // Charting follows the panel-native idiom (Tailwind marks, no charting dependency), with
27
+ // two deliberate encodings:
28
+ // - SPEND is two series — `violet-500` metered (real money) and `amber-600` subscription
29
+ // (the illustrative equivalent-API cost of flat-rate quota usage). The pair is validated
30
+ // colorblind-safe against the dark surface, and a legend is always present.
31
+ // - ACTIVITY uses the app's RESERVED status colors (emerald done / rose failed / sky
32
+ // running / slate other), which are red-green adjacent by design across the product.
33
+ // Every status is therefore ALSO carried as a number under its bar and named in the
34
+ // legend, so identity is never colour alone.
35
+ const ui = useUiStore()
36
+ const accounts = useAccountsStore()
37
+ const workspace = useWorkspaceStore()
38
+ const reports = useReportsStore()
39
+ const { t, te, d, n } = useI18n()
40
+
41
+ const open = computed(() => ui.reportsOpen)
42
+ const view = computed(() => reports.view)
43
+ const loading = computed(() => reports.loading)
44
+ const failed = computed(() => reports.failed)
45
+ /** The backend's untranslated message, shown as detail under the localized heading. */
46
+ const error = computed(() => reports.error)
47
+ const accountName = computed(() => accounts.activeAccount?.name ?? '')
48
+
49
+ // Window options as static literal keys (keeps the typed-message-key check live).
50
+ const WINDOWS: { value: ReportWindow; label: string }[] = [
51
+ { value: '24h', label: t('reports.window.oneDay') },
52
+ { value: '7d', label: t('reports.window.sevenDays') },
53
+ { value: '30d', label: t('reports.window.thirtyDays') },
54
+ { value: '90d', label: t('reports.window.ninetyDays') },
55
+ ]
56
+
57
+ // The dimension the paired spend + activity breakdowns are grouped by. Model and agent
58
+ // kind have no activity counterpart (a run carries no single kind), so they render
59
+ // unconditionally above rather than joining this switch.
60
+ const DIMENSIONS: { value: ReportActivityDimension; label: string }[] = [
61
+ { value: 'workspace', label: t('reports.dimension.workspace') },
62
+ { value: 'service', label: t('reports.dimension.service') },
63
+ { value: 'taskType', label: t('reports.dimension.taskType') },
64
+ ]
65
+ const dimension = ref<ReportActivityDimension>('workspace')
66
+
67
+ const currency = computed(() => view.value?.currency ?? 'EUR')
68
+ const money = (value: number) => n(value, { key: 'currency', currency: currency.value })
69
+
70
+ /** A slice's display name: the resolved label, the raw key, or the unattributed notice. */
71
+ function sliceLabel(row: { key: string; label: string | null }): string {
72
+ if (isUnattributed(row.key)) return t('reports.unattributed')
73
+ return row.label ?? row.key
74
+ }
75
+
76
+ const spendByDimension = computed<ReportSpendRow[]>(() => {
77
+ const spend = view.value?.spend
78
+ if (!spend) return []
79
+ if (dimension.value === 'workspace') return spend.byWorkspace
80
+ if (dimension.value === 'service') return spend.byService
81
+ return spend.byTaskType
82
+ })
83
+ const activityByDimension = computed<ReportActivityRow[]>(() => {
84
+ const activity = view.value?.activity
85
+ if (!activity) return []
86
+ if (dimension.value === 'workspace') return activity.byWorkspace
87
+ if (dimension.value === 'service') return activity.byService
88
+ return activity.byTaskType
89
+ })
90
+
91
+ const maxTrend = computed(() => maxOf(view.value?.trend.points ?? [], trendMagnitude))
92
+ const hasSpend = computed(() => (view.value?.totals.calls ?? 0) > 0)
93
+ // Hoisted: every activity bar is scaled against the busiest slice in the SAME list, so this
94
+ // is invariant across the row loop. Computing it inline would re-scan the list once per
95
+ // status segment of every row.
96
+ const maxRuns = computed(() => maxOf(activityByDimension.value, (row) => row.runs))
97
+
98
+ /** Boards the filter offers — the active account's, since the report is account-scoped. */
99
+ const boards = computed(() => workspace.accountWorkspaces)
100
+
101
+ function trendTooltip(point: { start: number; meteredCost: number; subscriptionCost: number }) {
102
+ return `${d(new Date(point.start), 'short')} · ${t('reports.legend.metered')} ${money(point.meteredCost)} · ${t('reports.legend.subscription')} ${money(point.subscriptionCost)}`
103
+ }
104
+
105
+ // Exhaustive enum→key map (the tier-2 dynamic-key guard): a new activity status fails the
106
+ // typecheck here rather than silently rendering a raw code.
107
+ const STATUS_KEYS: Record<'done' | 'failed' | 'running' | 'other', string> = {
108
+ done: 'reports.status.done',
109
+ failed: 'reports.status.failed',
110
+ running: 'reports.status.running',
111
+ other: 'reports.status.other',
112
+ }
113
+ const STATUS_CLASSES: Record<'done' | 'failed' | 'running' | 'other', string> = {
114
+ done: 'bg-emerald-500',
115
+ failed: 'bg-rose-500',
116
+ running: 'bg-sky-500',
117
+ other: 'bg-slate-500',
118
+ }
119
+ /** `te`-guarded so a locale missing the key shows the raw status, never a raw message key. */
120
+ function statusLabel(status: 'done' | 'failed' | 'running' | 'other'): string {
121
+ const key = STATUS_KEYS[status]
122
+ return te(key) ? t(key) : status
123
+ }
124
+
125
+ function refresh() {
126
+ void reports.load()
127
+ }
128
+ function close() {
129
+ ui.closeReports()
130
+ }
131
+ onKeyStroke('Escape', () => {
132
+ if (open.value) close()
133
+ })
134
+
135
+ // Load (and refresh) whenever the panel opens.
136
+ watch(
137
+ open,
138
+ (isOpen) => {
139
+ if (isOpen) void reports.load()
140
+ },
141
+ { immediate: true },
142
+ )
143
+ </script>
144
+
145
+ <template>
146
+ <Teleport to="body">
147
+ <Transition name="reports-fade">
148
+ <div
149
+ v-if="open"
150
+ class="fixed inset-0 z-[60] flex flex-col bg-slate-950/96 backdrop-blur-sm"
151
+ role="dialog"
152
+ aria-modal="true"
153
+ data-testid="reports-panel"
154
+ >
155
+ <header class="flex flex-wrap items-center gap-3 border-b border-slate-800 px-6 py-4">
156
+ <div
157
+ class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-violet-500/15"
158
+ >
159
+ <UIcon name="i-lucide-chart-column" class="h-5 w-5 text-violet-400" />
160
+ </div>
161
+ <div class="min-w-0">
162
+ <h1 class="truncate text-base font-semibold text-white">{{ t('reports.title') }}</h1>
163
+ <p v-if="accountName" class="truncate text-xs text-slate-500">{{ accountName }}</p>
164
+ </div>
165
+ <!-- Filters in ONE row above the charts: window, then board scope. -->
166
+ <div class="ms-auto flex flex-wrap items-center gap-1.5">
167
+ <select
168
+ class="rounded-lg border border-slate-800 bg-slate-900 px-2.5 py-1.5 text-[12px] text-slate-200"
169
+ :value="reports.workspaceFilter ?? ''"
170
+ :aria-label="t('reports.filter.board')"
171
+ data-testid="reports-board-filter"
172
+ @change="
173
+ reports.setWorkspaceFilter(($event.target as HTMLSelectElement).value || null)
174
+ "
175
+ >
176
+ <option value="">{{ t('reports.filter.allBoards') }}</option>
177
+ <option v-for="board in boards" :key="board.id" :value="board.id">
178
+ {{ board.name }}
179
+ </option>
180
+ </select>
181
+ <div class="me-1 flex rounded-lg border border-slate-800 p-0.5 text-[12px]">
182
+ <button
183
+ v-for="opt in WINDOWS"
184
+ :key="opt.value"
185
+ class="rounded-md px-2.5 py-1 transition"
186
+ :class="
187
+ reports.window === opt.value
188
+ ? 'bg-slate-800 text-slate-100'
189
+ : 'text-slate-400 hover:text-slate-200'
190
+ "
191
+ :data-testid="`reports-window-${opt.value}`"
192
+ @click="reports.setWindow(opt.value)"
193
+ >
194
+ {{ opt.label }}
195
+ </button>
196
+ </div>
197
+ <button
198
+ class="rounded-lg border border-slate-800 p-1.5 text-slate-400 transition hover:text-slate-200"
199
+ :aria-label="t('reports.refresh')"
200
+ :title="t('reports.refresh')"
201
+ @click="refresh"
202
+ >
203
+ <UIcon
204
+ name="i-lucide-refresh-cw"
205
+ class="h-4 w-4"
206
+ :class="{ 'animate-spin': loading }"
207
+ />
208
+ </button>
209
+ <button
210
+ class="rounded-lg border border-slate-800 p-1.5 text-slate-400 transition hover:text-slate-200"
211
+ :aria-label="t('common.close')"
212
+ @click="close"
213
+ >
214
+ <UIcon name="i-lucide-x" class="h-4 w-4" />
215
+ </button>
216
+ </div>
217
+ </header>
218
+
219
+ <div class="flex-1 overflow-y-auto px-6 py-5">
220
+ <div
221
+ v-if="failed"
222
+ class="mx-auto max-w-2xl rounded-lg border border-rose-800/60 bg-rose-950/40 p-4 text-sm text-rose-200"
223
+ >
224
+ <p>{{ t('reports.error') }}</p>
225
+ <p v-if="error" class="mt-1 text-xs text-rose-300/80">{{ error }}</p>
226
+ <button
227
+ class="mt-2 rounded-md border border-rose-700 px-3 py-1 text-xs hover:bg-rose-900/40"
228
+ @click="refresh"
229
+ >
230
+ {{ t('reports.retry') }}
231
+ </button>
232
+ </div>
233
+
234
+ <div v-else-if="loading && !view" class="py-16 text-center text-sm text-slate-400">
235
+ {{ t('reports.loading') }}
236
+ </div>
237
+
238
+ <div v-else-if="view" class="mx-auto flex max-w-5xl flex-col gap-6">
239
+ <p class="text-[11px] text-slate-500">
240
+ {{
241
+ t('reports.period', {
242
+ from: d(new Date(view.since), 'short'),
243
+ to: d(new Date(view.generatedAt), 'short'),
244
+ })
245
+ }}
246
+ </p>
247
+
248
+ <!-- Headline totals. A stat tile, not a chart: these are single numbers. -->
249
+ <section>
250
+ <h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
251
+ {{ t('reports.totals.title') }}
252
+ </h2>
253
+ <div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
254
+ <div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
255
+ <p class="text-2xl font-semibold text-violet-300" data-testid="reports-metered">
256
+ {{ money(view.totals.meteredCost) }}
257
+ </p>
258
+ <p class="text-xs text-slate-500">{{ t('reports.totals.metered') }}</p>
259
+ </div>
260
+ <div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
261
+ <p class="text-2xl font-semibold text-amber-300">
262
+ {{ money(view.totals.subscriptionCost) }}
263
+ </p>
264
+ <p class="text-xs text-slate-500">{{ t('reports.totals.subscription') }}</p>
265
+ </div>
266
+ <div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
267
+ <p class="text-2xl font-semibold text-white">
268
+ {{ n(view.totals.calls, 'decimal') }}
269
+ </p>
270
+ <p class="text-xs text-slate-500">{{ t('reports.totals.calls') }}</p>
271
+ </div>
272
+ <div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
273
+ <p class="text-2xl font-semibold text-white">
274
+ {{ formatTokens(view.totals.inputTokens + view.totals.outputTokens) }}
275
+ </p>
276
+ <p class="text-xs text-slate-500">{{ t('reports.totals.tokens') }}</p>
277
+ </div>
278
+ </div>
279
+ <p class="mt-1.5 text-[10px] text-slate-500">
280
+ {{ t('reports.totals.illustrative') }}
281
+ </p>
282
+ </section>
283
+
284
+ <!-- Spend over time. One axis, two stacked series, legend always present. -->
285
+ <section>
286
+ <h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
287
+ {{ t('reports.trend.title') }}
288
+ </h2>
289
+ <div class="rounded-lg border border-slate-800 bg-slate-900/40 p-4">
290
+ <div v-if="!hasSpend" class="py-6 text-center text-xs text-slate-500">
291
+ {{ t('reports.trend.empty') }}
292
+ </div>
293
+ <div v-else class="flex h-28 items-end gap-0.5" data-testid="reports-trend">
294
+ <div
295
+ v-for="point in view.trend.points"
296
+ :key="point.start"
297
+ class="flex flex-1 flex-col justify-end gap-[2px]"
298
+ :title="trendTooltip(point)"
299
+ >
300
+ <div
301
+ class="w-full rounded-t-sm bg-amber-600"
302
+ :style="{ height: `${columnPct(point.subscriptionCost, maxTrend)}%` }"
303
+ />
304
+ <div
305
+ class="w-full rounded-sm bg-violet-500"
306
+ :style="{ height: `${columnPct(point.meteredCost, maxTrend)}%` }"
307
+ />
308
+ </div>
309
+ </div>
310
+ <div class="mt-2 flex items-center gap-4 text-[11px] text-slate-500">
311
+ <span class="flex items-center gap-1">
312
+ <span class="h-2 w-2 rounded-sm bg-violet-500" />{{
313
+ t('reports.legend.metered')
314
+ }}
315
+ </span>
316
+ <span class="flex items-center gap-1">
317
+ <span class="h-2 w-2 rounded-sm bg-amber-600" />{{
318
+ t('reports.legend.subscription')
319
+ }}
320
+ </span>
321
+ </div>
322
+ </div>
323
+ </section>
324
+
325
+ <!-- Spend by model + by agent kind: the two axes a run has no single value for. -->
326
+ <div class="grid gap-6 md:grid-cols-2">
327
+ <section>
328
+ <h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
329
+ {{ t('reports.spend.byModel') }}
330
+ </h2>
331
+ <ReportsSpendBreakdown
332
+ :rows="view.spend.byModel"
333
+ :currency="currency"
334
+ test-id="reports-spend-model"
335
+ :label-of="sliceLabel"
336
+ />
337
+ </section>
338
+ <section>
339
+ <h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
340
+ {{ t('reports.spend.byAgentKind') }}
341
+ </h2>
342
+ <ReportsSpendBreakdown
343
+ :rows="view.spend.byAgentKind"
344
+ :currency="currency"
345
+ test-id="reports-spend-agent-kind"
346
+ :label-of="sliceLabel"
347
+ />
348
+ </section>
349
+ </div>
350
+
351
+ <!-- The shared axis: spend AND activity for the same grouping, side by side. -->
352
+ <section class="flex flex-col gap-3">
353
+ <div class="flex flex-wrap items-center gap-2">
354
+ <h2 class="text-xs font-semibold uppercase tracking-wide text-slate-500">
355
+ {{ t('reports.breakdown.title') }}
356
+ </h2>
357
+ <div class="flex rounded-lg border border-slate-800 p-0.5 text-[12px]">
358
+ <button
359
+ v-for="opt in DIMENSIONS"
360
+ :key="opt.value"
361
+ class="rounded-md px-2.5 py-1 transition"
362
+ :class="
363
+ dimension === opt.value
364
+ ? 'bg-slate-800 text-slate-100'
365
+ : 'text-slate-400 hover:text-slate-200'
366
+ "
367
+ :data-testid="`reports-dimension-${opt.value}`"
368
+ @click="dimension = opt.value"
369
+ >
370
+ {{ opt.label }}
371
+ </button>
372
+ </div>
373
+ </div>
374
+ <div class="grid gap-6 md:grid-cols-2">
375
+ <div>
376
+ <h3 class="mb-2 text-[11px] text-slate-500">{{ t('reports.spend.heading') }}</h3>
377
+ <ReportsSpendBreakdown
378
+ :rows="spendByDimension"
379
+ :currency="currency"
380
+ test-id="reports-spend-dimension"
381
+ :label-of="sliceLabel"
382
+ />
383
+ </div>
384
+ <div>
385
+ <h3 class="mb-2 text-[11px] text-slate-500">
386
+ {{ t('reports.activity.heading') }}
387
+ </h3>
388
+ <div class="rounded-lg border border-slate-800 bg-slate-900/40 p-4">
389
+ <div
390
+ v-if="!activityByDimension.length"
391
+ class="py-4 text-center text-xs text-slate-500"
392
+ >
393
+ {{ t('reports.activity.empty') }}
394
+ </div>
395
+ <template v-else>
396
+ <ul class="flex flex-col gap-3" data-testid="reports-activity">
397
+ <li
398
+ v-for="row in activityByDimension"
399
+ :key="row.key"
400
+ class="text-xs"
401
+ data-testid="reports-activity-row"
402
+ >
403
+ <div class="mb-1 flex items-baseline justify-between gap-2">
404
+ <span class="min-w-0 truncate text-slate-300">{{
405
+ sliceLabel(row)
406
+ }}</span>
407
+ <span class="shrink-0 tabular-nums text-slate-400">
408
+ {{ t('reports.activity.runs', { count: row.runs }, row.runs) }}
409
+ </span>
410
+ </div>
411
+ <div class="flex h-1.5 gap-[2px]">
412
+ <div
413
+ v-for="segment in activitySegments(row)"
414
+ :key="segment.status"
415
+ class="h-1.5 rounded-full"
416
+ :class="STATUS_CLASSES[segment.status]"
417
+ :style="{
418
+ width: `${segmentPct(segment.count, maxRuns)}%`,
419
+ }"
420
+ />
421
+ </div>
422
+ <!-- The status counts in text: the reserved status hues are
423
+ red-green adjacent, so identity is never colour alone. -->
424
+ <p class="mt-1 flex flex-wrap gap-x-2 text-[10px] text-slate-500">
425
+ <span v-for="segment in activitySegments(row)" :key="segment.status">
426
+ {{ n(segment.count, 'decimal') }} {{ statusLabel(segment.status) }}
427
+ </span>
428
+ <span v-if="row.avgDurationMs != null">
429
+ ·
430
+ {{
431
+ t('reports.activity.avg', { value: formatMs(row.avgDurationMs) })
432
+ }}
433
+ </span>
434
+ </p>
435
+ </li>
436
+ </ul>
437
+ <div
438
+ class="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-slate-500"
439
+ >
440
+ <span
441
+ v-for="status in ['done', 'failed', 'running', 'other'] as const"
442
+ :key="status"
443
+ class="flex items-center gap-1"
444
+ >
445
+ <span class="h-2 w-2 rounded-sm" :class="STATUS_CLASSES[status]" />{{
446
+ statusLabel(status)
447
+ }}
448
+ </span>
449
+ </div>
450
+ </template>
451
+ </div>
452
+ </div>
453
+ </div>
454
+ </section>
455
+ </div>
456
+ </div>
457
+ </div>
458
+ </Transition>
459
+ </Teleport>
460
+ </template>
461
+
462
+ <style scoped>
463
+ .reports-fade-enter-active,
464
+ .reports-fade-leave-active {
465
+ transition: opacity 0.15s ease;
466
+ }
467
+ .reports-fade-enter-from,
468
+ .reports-fade-leave-to {
469
+ opacity: 0;
470
+ }
471
+ </style>
@@ -0,0 +1,70 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { ReportSpendRow } from '~/types/execution'
4
+ import { maxOf, segmentPct, spendMagnitude } from './ReportsPanel.logic'
5
+
6
+ // One ranked spend breakdown: a horizontal bar per slice, split into the metered
7
+ // (`violet-500`, real money) and subscription (`amber-600`, illustrative equivalent-API
8
+ // cost) segments with a surface gap between them. Extracted from `ReportsPanel.vue` because
9
+ // the panel renders four of these against different dimensions — the shape is identical,
10
+ // only the row list and the heading differ.
11
+ //
12
+ // Every bar is scaled against the HEAVIEST slice in this list, so a full bar means "the
13
+ // biggest consumer here", never an absolute budget. The panel owns the legend (the two
14
+ // series mean the same thing in every breakdown, so repeating it per card would be noise).
15
+ const props = defineProps<{
16
+ rows: ReportSpendRow[]
17
+ currency: string
18
+ testId: string
19
+ /** Resolves a slice's display name (the panel owns the unattributed/i18n vocabulary). */
20
+ labelOf: (row: ReportSpendRow) => string
21
+ }>()
22
+
23
+ const { t, n } = useI18n()
24
+ const money = (value: number) => n(value, { key: 'currency', currency: props.currency })
25
+ const max = computed(() => maxOf(props.rows, spendMagnitude))
26
+ </script>
27
+
28
+ <template>
29
+ <div class="rounded-lg border border-slate-800 bg-slate-900/40 p-4">
30
+ <div v-if="!rows.length" class="py-4 text-center text-xs text-slate-500">
31
+ {{ t('reports.spend.empty') }}
32
+ </div>
33
+ <ul v-else class="flex flex-col gap-2.5" :data-testid="testId">
34
+ <li v-for="row in rows" :key="row.key" class="text-xs" data-testid="reports-spend-row">
35
+ <div class="mb-1 flex items-baseline justify-between gap-2">
36
+ <span class="min-w-0 truncate text-slate-300">{{ labelOf(row) }}</span>
37
+ <!-- The metered figure alone is the slice's SPEND. The subscription cost rides
38
+ beside it, in its own series colour and explicitly named, because it is the
39
+ illustrative cost of flat-rate quota usage — adding the two into one currency
40
+ figure would report money that was never billed. -->
41
+ <span class="shrink-0 tabular-nums text-slate-400">
42
+ {{ money(row.meteredCost) }}
43
+ <span v-if="row.subscriptionCost > 0" class="text-amber-400">
44
+ {{ t('reports.spend.subscriptionAside', { value: money(row.subscriptionCost) }) }}
45
+ </span>
46
+ </span>
47
+ </div>
48
+ <div class="flex h-1.5 gap-[2px]">
49
+ <div
50
+ class="h-1.5 rounded-full bg-violet-500"
51
+ :style="{ width: `${segmentPct(row.meteredCost, max)}%` }"
52
+ />
53
+ <div
54
+ class="h-1.5 rounded-full bg-amber-600"
55
+ :style="{ width: `${segmentPct(row.subscriptionCost, max)}%` }"
56
+ />
57
+ </div>
58
+ <p class="mt-1 text-[10px] text-slate-500">
59
+ {{ t('reports.spend.calls', { count: row.calls }, row.calls) }} ·
60
+ {{
61
+ t('reports.spend.tokens', {
62
+ input: formatTokens(row.inputTokens),
63
+ output: formatTokens(row.outputTokens),
64
+ })
65
+ }}
66
+ </p>
67
+ </li>
68
+ </ul>
69
+ </div>
70
+ </template>
@@ -6,6 +6,7 @@ import {
6
6
  COMPANION_STATE_META,
7
7
  isCompanionKind,
8
8
  containerPhaseLabel,
9
+ dedicatedParkView,
9
10
  } from '~/utils/pipelineRender'
10
11
  import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
11
12
  import AgentFailureHistory from '~/components/board/AgentFailureHistory.vue'
@@ -154,6 +155,12 @@ function openForkFor(i: number) {
154
155
  if (instance.value) ui.openForkDecision(instance.value.id, i)
155
156
  }
156
157
 
158
+ // Open the follow-up triage window for a coder step parked on undecided follow-up items
159
+ // (its dedicated chip — the generic approve resolver refuses this park server-side).
160
+ function openFollowUpsFor(i: number) {
161
+ if (instance.value) ui.openFollowUps(instance.value.id, i)
162
+ }
163
+
157
164
  // Open the PR deep-review findings-selection window for a pr-reviewer step parked awaiting
158
165
  // a selection (its dedicated chip, mirroring the fork-decision one above).
159
166
  function openPrReviewFor(i: number) {
@@ -425,7 +432,7 @@ async function mergePr() {
425
432
  v-else-if="
426
433
  s.approval &&
427
434
  s.approval.status === 'pending' &&
428
- s.forkDecision?.status === 'awaiting_choice'
435
+ dedicatedParkView(s) === 'fork-decision'
429
436
  "
430
437
  color="primary"
431
438
  variant="soft"
@@ -435,6 +442,23 @@ async function mergePr() {
435
442
  >
436
443
  {{ t('inspector.execution.chooseApproach') }}
437
444
  </UButton>
445
+ <!-- A coder step parked on undecided follow-up items: triage them (file /
446
+ send back / answer / dismiss) in the dedicated window, not a plain
447
+ approval — the generic approve resolver refuses this park. -->
448
+ <UButton
449
+ v-else-if="
450
+ s.approval &&
451
+ s.approval.status === 'pending' &&
452
+ dedicatedParkView(s) === 'follow-ups'
453
+ "
454
+ color="primary"
455
+ variant="soft"
456
+ size="xs"
457
+ icon="i-lucide-compass"
458
+ @click="openFollowUpsFor(i)"
459
+ >
460
+ {{ t('inspector.execution.triageFollowUps') }}
461
+ </UButton>
438
462
  <!-- A pr-reviewer step parked awaiting a finding selection: open the dedicated
439
463
  findings-selection window, not the generic approval gate. -->
440
464
  <UButton
@@ -10,6 +10,7 @@ import {
10
10
  isFailedStep,
11
11
  FAILED_STEP_META,
12
12
  containerPhaseLabel,
13
+ dedicatedParkView,
13
14
  } from '~/utils/pipelineRender'
14
15
  import { prReviewPhase } from '~/utils/prReviewProgress'
15
16
  import StepMetricsBar from '~/components/observability/StepMetricsBar.vue'
@@ -74,7 +75,9 @@ function followUpLabel(step: PipelineStep): string {
74
75
  /** The active fork-decision phase status on a coder step (proposing / awaiting a choice). */
75
76
  function forkPhase(step: PipelineStep): 'proposing' | 'awaiting_choice' | null {
76
77
  const status = step.forkDecision?.status
77
- return status === 'proposing' || status === 'awaiting_choice' ? status : null
78
+ if (status === 'proposing') return 'proposing'
79
+ // `answering` (a chat reply in flight) still belongs to the fork window's choice phase.
80
+ return status === 'awaiting_choice' || status === 'answering' ? 'awaiting_choice' : null
78
81
  }
79
82
 
80
83
  /**
@@ -670,9 +673,16 @@ const ITEM_ICON: Record<string, string> = {
670
673
  {{ reviewStageLabel(s.agentKind) }}
671
674
  </div>
672
675
 
673
- <!-- approval gate: review (and edit) the proposal before continuing -->
676
+ <!-- approval gate: review (and edit) the proposal before continuing. A park a
677
+ dedicated window owns (fork choice / follow-up triage) renders its own chip
678
+ above instead — the generic approve resolver refuses those server-side. -->
674
679
  <div
675
- v-else-if="s.approval && s.approval.status === 'pending' && !prReviewAwaiting(s)"
680
+ v-else-if="
681
+ s.approval &&
682
+ s.approval.status === 'pending' &&
683
+ !prReviewAwaiting(s) &&
684
+ !dedicatedParkView(s)
685
+ "
676
686
  class="mt-3"
677
687
  >
678
688
  <UButton
@@ -0,0 +1,19 @@
1
+ import { getReportsContract } from '@cat-factory/contracts'
2
+ import type { ReportWindow } from '~/types/execution'
3
+ import type { ApiContext } from './context'
4
+
5
+ /**
6
+ * Reports: cross-cutting usage analytics for an account over a time window
7
+ * (admin-gated). The sibling of `platformObservabilityApi` — same account scope,
8
+ * a different question ("where did the spend and the work go" vs "is it healthy").
9
+ * `workspaceId` narrows every breakdown to one board.
10
+ */
11
+ export function reportsApi({ send }: ApiContext) {
12
+ return {
13
+ getReports: (accountId: string, window: ReportWindow, workspaceId?: string | null) =>
14
+ send(getReportsContract, {
15
+ pathParams: { accountId },
16
+ queryParams: { window, ...(workspaceId ? { workspaceId } : {}) },
17
+ }),
18
+ }
19
+ }
@@ -3,6 +3,7 @@ import { createApiClient, createSend, createSendWith } from './api/client'
3
3
  import type { ApiContext } from './api/context'
4
4
  import { accountsApi } from './api/accounts'
5
5
  import { platformObservabilityApi } from './api/platformObservability'
6
+ import { reportsApi } from './api/reports'
6
7
  import { authApi } from './api/auth'
7
8
  import { bootstrapApi } from './api/bootstrap'
8
9
  import { boardApi } from './api/board'
@@ -112,6 +113,7 @@ export function useApi() {
112
113
  ...modelsApi(ctx),
113
114
  ...accountsApi(ctx),
114
115
  ...platformObservabilityApi(ctx),
116
+ ...reportsApi(ctx),
115
117
  ...workspacesApi(ctx),
116
118
  ...boardApi(ctx),
117
119
  ...executionApi(ctx),