@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
@@ -15,6 +15,7 @@ import StepExecutionHistory from '~/components/board/StepExecutionHistory.vue'
15
15
  import { useStepTimer } from '~/composables/useStepTimer'
16
16
  import { useStepProse } from '~/composables/useStepProse'
17
17
  import { useStepApproval } from '~/composables/useStepApproval'
18
+ import { dedicatedParkView } from '~/utils/pipelineRender'
18
19
 
19
20
  // Detail overlay for a single pipeline step. Opened by clicking an agent in the
20
21
  // inspector list (TaskExecution) or the focus-view pipeline (PipelineProgress) via
@@ -151,6 +152,25 @@ const approvalId = computed(() => step.value?.approval?.id ?? null)
151
152
  // approve/request-changes/reject rail, it shows the shared iteration-cap prompt
152
153
  // (one more round / proceed / stop & reset), resolved through its own endpoint.
153
154
  const companionExceeded = computed(() => approvalPending.value && !!step.value?.companion?.exceeded)
155
+ // A park a DEDICATED window owns (fork choice / follow-up triage): the generic approve
156
+ // resolver refuses these server-side, so the rail is replaced by a redirect to that window.
157
+ // Computed live, since a coder step can park on one WHILE this overlay is already open
158
+ // (the routing in `dispatchStepView` only covers the open click).
159
+ const dedicatedPark = computed(() => (step.value ? dedicatedParkView(step.value) : null))
160
+ /** The generic approve/request-changes/reject rail applies (no dedicated surface owns the park). */
161
+ const genericApprovalPending = computed(
162
+ () => approvalPending.value && !companionExceeded.value && !dedicatedPark.value,
163
+ )
164
+
165
+ /** Jump from this overlay to the window that can actually resolve the dedicated park. */
166
+ function openDedicatedWindow() {
167
+ const c = ctx.value
168
+ const park = dedicatedPark.value
169
+ if (!c || !park) return
170
+ close()
171
+ if (park === 'follow-ups') ui.openFollowUps(c.instanceId, c.stepIndex)
172
+ else ui.openForkDecision(c.instanceId, c.stepIndex)
173
+ }
154
174
 
155
175
  function close() {
156
176
  // Reset the approval-mode sub-states so reopening the same step is clean
@@ -159,13 +179,14 @@ function close() {
159
179
  ui.closeStepDetail()
160
180
  }
161
181
 
162
- // The GitHub-style approval/review state machine for a pending gate step.
182
+ // The GitHub-style approval/review state machine for a pending gate step. A park a
183
+ // dedicated window owns is NOT reviewable here, so it doesn't count as pending.
163
184
  const approval = useStepApproval({
164
185
  step: () => step.value,
165
186
  scrollEl: () => scrollEl.value,
166
187
  instanceId: () => ctx.value?.instanceId,
167
188
  approvalId: () => approvalId.value,
168
- approvalPending: () => approvalPending.value,
189
+ approvalPending: () => approvalPending.value && !dedicatedPark.value,
169
190
  companionExceeded: () => companionExceeded.value,
170
191
  close,
171
192
  })
@@ -292,7 +313,7 @@ async function copyOutput() {
292
313
  </div>
293
314
  <div class="ms-auto flex items-center gap-1.5">
294
315
  <UBadge
295
- v-if="approvalPending && !companionExceeded"
316
+ v-if="genericApprovalPending"
296
317
  color="warning"
297
318
  variant="subtle"
298
319
  size="sm"
@@ -302,7 +323,7 @@ async function copyOutput() {
302
323
  {{ t('panels.stepDetail.approvalRequired') }}
303
324
  </UBadge>
304
325
  <UBadge
305
- v-else-if="companionExceeded"
326
+ v-else-if="companionExceeded || dedicatedPark"
306
327
  color="warning"
307
328
  variant="subtle"
308
329
  size="sm"
@@ -396,6 +417,37 @@ async function copyOutput() {
396
417
  @resolve="resolveCompanionCap"
397
418
  />
398
419
 
420
+ <!-- a park a dedicated window owns (fork choice / follow-up triage): the
421
+ generic approval rail can't resolve it (the server refuses), so point
422
+ the human at the window that can -->
423
+ <div
424
+ v-if="dedicatedPark"
425
+ class="rounded-xl border border-amber-500/30 bg-amber-500/5 p-4"
426
+ data-testid="dedicated-park-redirect"
427
+ >
428
+ <p class="text-[13px] leading-relaxed text-amber-200/90">
429
+ {{
430
+ dedicatedPark === 'follow-ups'
431
+ ? t('panels.stepDetail.followUpsParked')
432
+ : t('panels.stepDetail.forkParked')
433
+ }}
434
+ </p>
435
+ <UButton
436
+ class="mt-3"
437
+ color="primary"
438
+ size="sm"
439
+ :icon="dedicatedPark === 'follow-ups' ? 'i-lucide-compass' : 'i-lucide-git-fork'"
440
+ data-testid="dedicated-park-open"
441
+ @click="openDedicatedWindow"
442
+ >
443
+ {{
444
+ dedicatedPark === 'follow-ups'
445
+ ? t('panels.stepDetail.openFollowUps')
446
+ : t('panels.stepDetail.chooseApproach')
447
+ }}
448
+ </UButton>
449
+ </div>
450
+
399
451
  <!-- ephemeral environment lifecycle (spinning up / running / shut down /
400
452
  errored + the exact error), when this step runs against one -->
401
453
  <EnvironmentStatusPanel v-if="stepEnvironment" :environment="stepEnvironment" />
@@ -545,7 +597,7 @@ async function copyOutput() {
545
597
  class="reader-prose mt-1 text-[13px] leading-relaxed text-slate-300"
546
598
  :class="[
547
599
  s.depth > 0 ? 'ps-6' : '',
548
- approvalPending && !editing && !companionExceeded ? 'review-mode' : '',
600
+ genericApprovalPending && !editing ? 'review-mode' : '',
549
601
  ]"
550
602
  @click="onProseClick"
551
603
  v-html="s.bodyHtml"
@@ -567,7 +619,7 @@ async function copyOutput() {
567
619
  Approve / Request changes / Reject. A end-side rail on wide screens; a
568
620
  bottom sheet (still reachable) below lg, so the gate is always actionable. -->
569
621
  <aside
570
- v-if="approvalPending && !companionExceeded"
622
+ v-if="genericApprovalPending"
571
623
  class="absolute inset-x-0 bottom-0 z-10 flex max-h-[70dvh] flex-col rounded-t-2xl border-t border-slate-700 bg-slate-900/95 shadow-2xl backdrop-blur lg:static lg:inset-auto lg:z-auto lg:max-h-none lg:w-96 lg:shrink-0 lg:rounded-none lg:border-s lg:border-t-0 lg:border-slate-800 lg:bg-slate-900/60 lg:shadow-none lg:backdrop-blur-none"
572
624
  >
573
625
  <div class="border-b border-slate-800 px-4 py-3">
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { ReportActivityRow, ReportSpendRow } from '~/types/execution'
3
+ import {
4
+ activitySegments,
5
+ columnPct,
6
+ isUnattributed,
7
+ maxOf,
8
+ segmentPct,
9
+ spendMagnitude,
10
+ } from './ReportsPanel.logic'
11
+
12
+ const spend = (over: Partial<ReportSpendRow>): ReportSpendRow => ({
13
+ key: 'k',
14
+ label: null,
15
+ inputTokens: 0,
16
+ outputTokens: 0,
17
+ calls: 0,
18
+ meteredCost: 0,
19
+ subscriptionCost: 0,
20
+ ...over,
21
+ })
22
+
23
+ describe('ReportsPanel logic', () => {
24
+ it('ranks a spend slice by its combined footprint', () => {
25
+ // Ranking/scaling only: the sum mixes real money with the illustrative cost of
26
+ // flat-rate quota usage, so no caller may render it as an amount. `ReportsSpendBreakdown`
27
+ // shows `meteredCost` and `subscriptionCost` as separate figures for exactly that reason.
28
+ expect(spendMagnitude(spend({ meteredCost: 2, subscriptionCost: 3 }))).toBe(5)
29
+ })
30
+
31
+ it('scales every segment against the widest row, not its own row', () => {
32
+ // A per-row denominator would draw both bars full-width and erase the ranking.
33
+ const rows = [spend({ meteredCost: 10 }), spend({ meteredCost: 2 })]
34
+ const max = maxOf(rows, spendMagnitude)
35
+ expect(segmentPct(rows[0]!.meteredCost, max)).toBe(100)
36
+ expect(segmentPct(rows[1]!.meteredCost, max)).toBe(20)
37
+ })
38
+
39
+ it('returns a zero max for an empty list and draws nothing from it', () => {
40
+ expect(maxOf([], spendMagnitude)).toBe(0)
41
+ expect(segmentPct(5, 0)).toBe(0)
42
+ expect(columnPct(5, 0)).toBe(0)
43
+ })
44
+
45
+ it('floors a non-zero column so a small bucket stays visible', () => {
46
+ // Without the floor a single cheap call rounds to an empty column and reads as a
47
+ // quiet period, which is the opposite of what happened.
48
+ expect(columnPct(0.001, 1000)).toBe(2)
49
+ expect(columnPct(0, 1000)).toBe(0)
50
+ expect(columnPct(500, 1000)).toBe(50)
51
+ })
52
+
53
+ it('treats the empty key as the unattributed bucket', () => {
54
+ expect(isUnattributed('')).toBe(true)
55
+ expect(isUnattributed('feature')).toBe(false)
56
+ })
57
+
58
+ it('lists activity status splits in the legend order', () => {
59
+ const row: ReportActivityRow = {
60
+ key: 'ws',
61
+ label: 'Board',
62
+ runs: 6,
63
+ done: 3,
64
+ failed: 2,
65
+ running: 1,
66
+ other: 0,
67
+ avgDurationMs: 100,
68
+ }
69
+ expect(activitySegments(row)).toEqual([
70
+ { status: 'done', count: 3 },
71
+ { status: 'failed', count: 2 },
72
+ { status: 'running', count: 1 },
73
+ { status: 'other', count: 0 },
74
+ ])
75
+ })
76
+ })
@@ -0,0 +1,71 @@
1
+ import type { ReportActivityRow, ReportSpendRow, ReportTrendPoint } from '~/types/execution'
2
+
3
+ // Pure sizing/derivation behind `ReportsPanel.vue`: everything the template needs that is
4
+ // arithmetic rather than markup, so it is unit-tested directly instead of through the DOM.
5
+
6
+ // The two `*Magnitude` helpers add the metered and subscription costs together, which is a
7
+ // number NOBODY MAY RENDER AS MONEY: only `meteredCost` is real spend, and `subscriptionCost`
8
+ // is the illustrative equivalent-API cost of flat-rate quota usage, so their sum denominates
9
+ // nothing. They exist purely as the RANKING and BAR-SCALING measure — the one job the sum is
10
+ // valid for, because it orders slices by total footprint exactly as the SQL `ORDER BY` does.
11
+ // Anything shown to a reader with a currency symbol must come off one of the two fields.
12
+
13
+ /** A spend slice's combined footprint. Ranking/scaling only — never a displayed amount. */
14
+ export function spendMagnitude(row: ReportSpendRow): number {
15
+ return row.meteredCost + row.subscriptionCost
16
+ }
17
+
18
+ /** A trend bucket's combined footprint. Ranking/scaling only — never a displayed amount. */
19
+ export function trendMagnitude(point: ReportTrendPoint): number {
20
+ return point.meteredCost + point.subscriptionCost
21
+ }
22
+
23
+ /**
24
+ * The heaviest value in a list, or 0 when empty. Bars are drawn RELATIVE to this, so a
25
+ * full bar means "the biggest slice here", never "all of some absolute quota".
26
+ */
27
+ export function maxOf<T>(rows: T[], measure: (row: T) => number): number {
28
+ return rows.reduce((max, row) => Math.max(max, measure(row)), 0)
29
+ }
30
+
31
+ /**
32
+ * A segment's share of the widest row, as a percentage. Scaling every segment against the
33
+ * SAME denominator is what makes a stacked bar's total length comparable across rows; a
34
+ * per-row denominator would draw every bar full-width and destroy the ranking.
35
+ */
36
+ export function segmentPct(value: number, max: number): number {
37
+ if (max <= 0 || value <= 0) return 0
38
+ return (value / max) * 100
39
+ }
40
+
41
+ /**
42
+ * A trend column's height as a percentage of the tallest column. A non-zero value floors at
43
+ * 2% so a single small call is still a visible mark rather than an apparent gap — the one
44
+ * thing a reader would otherwise misread as "nothing happened here".
45
+ */
46
+ export function columnPct(value: number, max: number): number {
47
+ if (max <= 0 || value <= 0) return 0
48
+ return Math.max(2, (value / max) * 100)
49
+ }
50
+
51
+ /**
52
+ * Whether a breakdown key is the unattributed bucket. It is a REAL slice (a call whose run,
53
+ * service or task type could not be resolved), so it is rendered with an explicit label
54
+ * rather than silently dropped — omitting it would under-report the window while looking
55
+ * complete.
56
+ */
57
+ export function isUnattributed(key: string): boolean {
58
+ return key === ''
59
+ }
60
+
61
+ /** Every status split of an activity row, in the fixed order the legend declares. */
62
+ export function activitySegments(
63
+ row: ReportActivityRow,
64
+ ): Array<{ status: 'done' | 'failed' | 'running' | 'other'; count: number }> {
65
+ return [
66
+ { status: 'done', count: row.done },
67
+ { status: 'failed', count: row.failed },
68
+ { status: 'running', count: row.running },
69
+ { status: 'other', count: row.other },
70
+ ]
71
+ }