@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
@@ -45,6 +45,7 @@ export function useNavContributions() {
45
45
  localModels: () => ui.openLocalModels(),
46
46
  accountSettings: () => ui.openAccountSettings(),
47
47
  operatorDashboard: () => ui.openOperatorDashboard(),
48
+ reports: () => ui.openReports(),
48
49
  shortcuts: () => ui.openShortcutsHelp(),
49
50
  }
50
51
 
@@ -100,14 +100,15 @@ export function useStepApproval(opts: {
100
100
  () => !!feedback.value.trim() || reviewComments.value.length > 0,
101
101
  )
102
102
 
103
- // Plain approve: accept the agent's proposal verbatim and advance.
103
+ // Plain approve: accept the agent's proposal verbatim and advance. Every action below
104
+ // closes the overlay ONLY when the command actually ran — a server refusal (surfaced as
105
+ // a toast by the store) or a cancelled credential prompt keeps the review open.
104
106
  async function approve() {
105
107
  const id = opts.approvalId()
106
108
  if (!opts.instanceId() || !id || submitting.value) return
107
109
  submitting.value = true
108
110
  try {
109
- await execution.approveStep(opts.instanceId()!, id)
110
- opts.close()
111
+ if (await execution.approveStep(opts.instanceId()!, id)) opts.close()
111
112
  } finally {
112
113
  submitting.value = false
113
114
  }
@@ -130,8 +131,7 @@ export function useStepApproval(opts: {
130
131
  if (!opts.instanceId() || !id || submitting.value) return
131
132
  submitting.value = true
132
133
  try {
133
- await execution.approveStep(opts.instanceId()!, id, draftProposal.value)
134
- opts.close()
134
+ if (await execution.approveStep(opts.instanceId()!, id, draftProposal.value)) opts.close()
135
135
  } finally {
136
136
  submitting.value = false
137
137
  }
@@ -141,7 +141,7 @@ export function useStepApproval(opts: {
141
141
  if (!opts.instanceId() || !id || submitting.value || !canRequestChanges.value) return
142
142
  submitting.value = true
143
143
  try {
144
- await execution.requestStepChanges(opts.instanceId()!, id, {
144
+ const ok = await execution.requestStepChanges(opts.instanceId()!, id, {
145
145
  feedback: feedback.value.trim() || undefined,
146
146
  comments: reviewComments.value.length
147
147
  ? reviewComments.value.map((c) => ({
@@ -152,7 +152,7 @@ export function useStepApproval(opts: {
152
152
  }))
153
153
  : undefined,
154
154
  })
155
- opts.close()
155
+ if (ok) opts.close()
156
156
  } finally {
157
157
  submitting.value = false
158
158
  }
@@ -168,8 +168,9 @@ export function useStepApproval(opts: {
168
168
  if (!opts.instanceId() || !id || submitting.value) return
169
169
  submitting.value = true
170
170
  try {
171
- await execution.rejectStep(opts.instanceId()!, id, feedback.value.trim() || undefined)
172
- opts.close()
171
+ if (await execution.rejectStep(opts.instanceId()!, id, feedback.value.trim() || undefined)) {
172
+ opts.close()
173
+ }
173
174
  } finally {
174
175
  submitting.value = false
175
176
  rejectArmed.value = false
@@ -166,6 +166,7 @@ describe('nav grouping helpers', () => {
166
166
  'model-config',
167
167
  'account-settings',
168
168
  'operator-dashboard',
169
+ 'reports',
169
170
  ])
170
171
  })
171
172
 
@@ -96,6 +96,7 @@ export const NAV_ACTIONS = [
96
96
  'localModels',
97
97
  'accountSettings',
98
98
  'operatorDashboard',
99
+ 'reports',
99
100
  'shortcuts',
100
101
  ] as const
101
102
 
@@ -354,6 +355,16 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
354
355
  testId: 'nav-operator-dashboard',
355
356
  sidebar: { group: 'configuration', order: 40 },
356
357
  },
358
+ {
359
+ id: 'reports',
360
+ labelKey: 'nav.reports',
361
+ icon: 'i-lucide-chart-column',
362
+ surfaces: S('sidebar'),
363
+ gate: (g) => g.accountsEnabled && g.isAccountAdmin,
364
+ action: 'reports',
365
+ testId: 'nav-reports',
366
+ sidebar: { group: 'configuration', order: 45 },
367
+ },
357
368
  {
358
369
  id: 'keyboard-shortcuts',
359
370
  labelKey: 'layout.commandBar.cmd.shortcuts',
@@ -35,6 +35,7 @@ const ObservabilityPanel = defineAsyncComponent(
35
35
  const OperatorDashboardPanel = defineAsyncComponent(
36
36
  () => import('~/components/panels/OperatorDashboardPanel.vue'),
37
37
  )
38
+ const ReportsPanel = defineAsyncComponent(() => import('~/components/panels/ReportsPanel.vue'))
38
39
  const KaizenPanel = defineAsyncComponent(() => import('~/components/kaizen/KaizenPanel.vue'))
39
40
  // Occasional, externally store-gated surfaces — deferred to their own chunks like the
40
41
  // sibling document modals above. Each mounts only while its ui open-flag is set, so it
@@ -400,6 +401,7 @@ watch(
400
401
  <RecurringPipelineModal v-if="ui.addRecurringFrameId" />
401
402
  <ObservabilityPanel v-if="ui.observabilityInstanceId" />
402
403
  <OperatorDashboardPanel v-if="ui.operatorDashboardOpen" />
404
+ <ReportsPanel v-if="ui.reportsOpen" />
403
405
  <KaizenPanel v-if="ui.kaizenScreenOpen" />
404
406
  <DocumentSourceConnectModal v-if="ui.documentConnect" />
405
407
  <DocumentImportModal v-if="ui.documentImport" />
@@ -69,35 +69,66 @@ export function createExecutionCommands(ctx: ExecutionCommandContext) {
69
69
  })
70
70
  }
71
71
 
72
- /** Approve a step's gated proposal (optionally edited); the run advances. */
73
- async function approveStep(instanceId: string, approvalId: string, proposal?: string) {
72
+ /**
73
+ * Approve a step's gated proposal (optionally edited); the run advances. Returns false —
74
+ * with the failure surfaced as an actionable toast — when the server refused (e.g. a 409
75
+ * for a park a dedicated window owns) or the user cancelled the credential prompt, so the
76
+ * approval rail can stay open instead of closing over a silently-failed approve.
77
+ */
78
+ async function approveStep(
79
+ instanceId: string,
80
+ approvalId: string,
81
+ proposal?: string,
82
+ ): Promise<boolean> {
74
83
  const ws = useWorkspaceStore()
75
84
  const personal = usePersonalSubscriptionsStore()
76
- return await personal.withCredential(async (password) => {
77
- await api.approveStep(ws.requireId(), instanceId, approvalId, { proposal }, password)
78
- await ws.refresh()
79
- })
85
+ try {
86
+ return await personal.withCredential(async (password) => {
87
+ await api.approveStep(ws.requireId(), instanceId, approvalId, { proposal }, password)
88
+ await ws.refresh()
89
+ })
90
+ } catch (e) {
91
+ runErrors.present(e, 'errors.action.approveFailed')
92
+ return false
93
+ }
80
94
  }
81
95
 
82
- /** Request changes on a gated proposal; the step re-runs with the review. */
96
+ /** Request changes on a gated proposal; the step re-runs with the review. Returns false
97
+ * (with a toast) on refusal / a cancelled credential prompt, like {@link approveStep}. */
83
98
  async function requestStepChanges(
84
99
  instanceId: string,
85
100
  approvalId: string,
86
101
  review: RequestStepChangesInput,
87
- ) {
102
+ ): Promise<boolean> {
88
103
  const ws = useWorkspaceStore()
89
104
  const personal = usePersonalSubscriptionsStore()
90
- return await personal.withCredential(async (password) => {
91
- await api.requestStepChanges(ws.requireId(), instanceId, approvalId, review, password)
92
- await ws.refresh()
93
- })
105
+ try {
106
+ return await personal.withCredential(async (password) => {
107
+ await api.requestStepChanges(ws.requireId(), instanceId, approvalId, review, password)
108
+ await ws.refresh()
109
+ })
110
+ } catch (e) {
111
+ runErrors.present(e, 'errors.action.requestChangesFailed')
112
+ return false
113
+ }
94
114
  }
95
115
 
96
- /** Reject a gated proposal; the run stops entirely (a retryable failure). */
97
- async function rejectStep(instanceId: string, approvalId: string, reason?: string) {
116
+ /** Reject a gated proposal; the run stops entirely (a retryable failure). Returns false
117
+ * (with a toast) when the server refused. */
118
+ async function rejectStep(
119
+ instanceId: string,
120
+ approvalId: string,
121
+ reason?: string,
122
+ ): Promise<boolean> {
98
123
  const ws = useWorkspaceStore()
99
- await api.rejectStep(ws.requireId(), instanceId, approvalId, { reason })
100
- await ws.refresh()
124
+ try {
125
+ await api.rejectStep(ws.requireId(), instanceId, approvalId, { reason })
126
+ await ws.refresh()
127
+ return true
128
+ } catch (e) {
129
+ runErrors.present(e, 'errors.action.rejectFailed')
130
+ return false
131
+ }
101
132
  }
102
133
 
103
134
  /**
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useAccountsStore } from '~/stores/accounts'
3
+ import { useReportsStore } from '~/stores/reports'
4
+ import type { ReportsView } from '~/types/execution'
5
+
6
+ // The reports view is loaded three ways that can each be triggered while another is still in
7
+ // flight — the window buttons, the board filter and the refresh button — so the store's
8
+ // monotonicity guard is the thing that keeps the panel from showing numbers for a window the
9
+ // user already left. These drive that race directly.
10
+
11
+ /** Minimal projection — only what the store stores and these assertions read. */
12
+ function view(over: Partial<ReportsView> = {}): ReportsView {
13
+ return {
14
+ window: '7d',
15
+ generatedAt: 1_000,
16
+ since: 0,
17
+ workspaceId: null,
18
+ currency: 'EUR',
19
+ totals: { inputTokens: 0, outputTokens: 0, calls: 0, meteredCost: 0, subscriptionCost: 0 },
20
+ spend: { byModel: [], byAgentKind: [], byWorkspace: [], byService: [], byTaskType: [] },
21
+ activity: { byWorkspace: [], byService: [], byTaskType: [] },
22
+ trend: { bucketMs: 1_000, points: [] },
23
+ ...over,
24
+ } as ReportsView
25
+ }
26
+
27
+ /** Seed an active account, since every load is scoped to one. */
28
+ function seedAccount() {
29
+ const accounts = useAccountsStore()
30
+ accounts.accounts = [
31
+ {
32
+ id: 'acc1',
33
+ type: 'org',
34
+ name: 'Acme',
35
+ githubAccountLogin: null,
36
+ createdAt: 0,
37
+ roles: null,
38
+ },
39
+ ] as never
40
+ accounts.activeAccountId = 'acc1'
41
+ }
42
+
43
+ describe('reports store — concurrent loads', () => {
44
+ beforeEach(() => {
45
+ seedAccount()
46
+ })
47
+
48
+ it('a stale load never overwrites a fresher one that already resolved', async () => {
49
+ // The user picks 24h, then immediately 90d. The 24h request is slower and lands LAST;
50
+ // committing it would leave the panel labelled 90d while showing 24h numbers.
51
+ let resolveSlow!: (v: ReportsView) => void
52
+ const slow = new Promise<ReportsView>((res) => {
53
+ resolveSlow = res
54
+ })
55
+ const responses: Record<string, Promise<ReportsView>> = {
56
+ '24h': slow,
57
+ '90d': Promise.resolve(view({ window: '90d', totals: { ...view().totals, calls: 42 } })),
58
+ }
59
+ vi.stubGlobal('useApi', () => ({
60
+ getReports: (_id: string, window: string) => responses[window]!,
61
+ }))
62
+ const store = useReportsStore()
63
+
64
+ const first = store.setWindow('24h')
65
+ const second = store.setWindow('90d')
66
+ await second
67
+ resolveSlow(view({ window: '24h', totals: { ...view().totals, calls: 7 } }))
68
+ await first
69
+
70
+ expect(store.view?.window).toBe('90d')
71
+ expect(store.view?.totals.calls).toBe(42)
72
+ // The superseded load must not resurrect the spinner it started either.
73
+ expect(store.loading).toBe(false)
74
+ })
75
+
76
+ it('a superseded FAILURE never replaces the newer view with an error', async () => {
77
+ // The same race the other way round: an in-flight load rejects after a later
78
+ // one succeeded. Committing it would blank a perfectly good panel.
79
+ let rejectSlow!: (e: Error) => void
80
+ const slow = new Promise<ReportsView>((_res, rej) => {
81
+ rejectSlow = rej
82
+ })
83
+ const responses: Record<string, Promise<ReportsView>> = {
84
+ '24h': slow,
85
+ '90d': Promise.resolve(view({ window: '90d' })),
86
+ }
87
+ vi.stubGlobal('useApi', () => ({
88
+ getReports: (_id: string, window: string) => responses[window]!,
89
+ }))
90
+ const store = useReportsStore()
91
+
92
+ const first = store.setWindow('24h')
93
+ await store.setWindow('90d')
94
+ rejectSlow(new Error('gateway timeout'))
95
+ await first
96
+
97
+ expect(store.failed).toBe(false)
98
+ expect(store.error).toBeNull()
99
+ expect(store.view?.window).toBe('90d')
100
+ })
101
+
102
+ it('records a failure from the newest load, with the raw message as detail', async () => {
103
+ vi.stubGlobal('useApi', () => ({
104
+ getReports: () => Promise.reject(new Error('reports are not available')),
105
+ }))
106
+ const store = useReportsStore()
107
+ await store.load()
108
+ expect(store.failed).toBe(true)
109
+ // Raw backend prose only — the localized heading is the panel's job.
110
+ expect(store.error).toBe('reports are not available')
111
+ expect(store.loading).toBe(false)
112
+ })
113
+ })
@@ -0,0 +1,91 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type { ReportWindow, ReportsView } from '~/types/execution'
4
+ import { useAccountsStore } from '~/stores/accounts'
5
+
6
+ /**
7
+ * Reports: cross-cutting usage analytics for the active account — spend per model and
8
+ * agent kind, spend + run activity per workspace / service / task type, and a spend trend,
9
+ * over a selectable window and optionally narrowed to one board.
10
+ *
11
+ * The sibling of the `platformObservability` store: same account scope, same admin gate,
12
+ * same on-demand load. Nothing is pushed live (these are periodic rollups); changing the
13
+ * window or the board filter re-fetches, and a manual refresh re-fetches unconditionally.
14
+ */
15
+ export const useReportsStore = defineStore('reports', () => {
16
+ const api = useApi()
17
+ const accounts = useAccountsStore()
18
+
19
+ const window = ref<ReportWindow>('7d')
20
+ /** The single board every breakdown is narrowed to, or null for the whole account. */
21
+ const workspaceFilter = ref<string | null>(null)
22
+ const view = ref<ReportsView | null>(null)
23
+ const loading = ref(false)
24
+ /** Whether the last load failed. The panel owns the localized wording. */
25
+ const failed = ref(false)
26
+ /**
27
+ * The backend's raw (untranslated) message for a failed load, shown beneath the localized
28
+ * heading as a last-resort detail — null when the failure carried no message at all.
29
+ */
30
+ const error = ref<string | null>(null)
31
+
32
+ const accountId = computed(() => accounts.activeAccount?.id ?? null)
33
+
34
+ /**
35
+ * Monotonicity guard. The window buttons, the board filter and the refresh button can each
36
+ * start a load, so two are easily in flight at once — and without this a staler response
37
+ * resolving later would overwrite the newer view (and its `loading`/error state) with
38
+ * numbers for a window the user already moved off. Only the newest load may commit.
39
+ */
40
+ let latest = 0
41
+
42
+ async function load() {
43
+ const id = accountId.value
44
+ if (!id) {
45
+ view.value = null
46
+ return
47
+ }
48
+ const seq = ++latest
49
+ loading.value = true
50
+ failed.value = false
51
+ error.value = null
52
+ try {
53
+ const next = await api.getReports(id, window.value, workspaceFilter.value)
54
+ if (seq !== latest) return
55
+ view.value = next
56
+ } catch (err) {
57
+ if (seq !== latest) return
58
+ failed.value = true
59
+ error.value = err instanceof Error ? err.message : null
60
+ } finally {
61
+ if (seq === latest) loading.value = false
62
+ }
63
+ }
64
+
65
+ /** Switch the window and reload (a no-op when it is already the loaded one). */
66
+ async function setWindow(next: ReportWindow) {
67
+ if (next === window.value && view.value) return
68
+ window.value = next
69
+ await load()
70
+ }
71
+
72
+ /** Narrow to one board (null = the whole account) and reload. */
73
+ async function setWorkspaceFilter(next: string | null) {
74
+ if (next === workspaceFilter.value && view.value) return
75
+ workspaceFilter.value = next
76
+ await load()
77
+ }
78
+
79
+ return {
80
+ window,
81
+ workspaceFilter,
82
+ view,
83
+ loading,
84
+ failed,
85
+ error,
86
+ accountId,
87
+ load,
88
+ setWindow,
89
+ setWorkspaceFilter,
90
+ }
91
+ })
@@ -440,6 +440,10 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
440
440
  // from `observabilityConnectionOpen` (the Datadog connection) AND `observabilityInstanceId`
441
441
  // (the per-run LLM call panel).
442
442
  const operatorDashboardOpen = ref(false)
443
+ // Reports: the cross-cutting usage-analytics panel over the same account scope (spend per
444
+ // model / agent kind, spend + activity per workspace / service / task type). Admin-gated.
445
+ // Distinct from `operatorDashboardOpen`, which answers the deployment-HEALTH question.
446
+ const reportsOpen = ref(false)
443
447
  // Private package registries: the workspace's npm/GitHub-Packages entries agent
444
448
  // containers install with. Opened from the Integrations hub.
445
449
  const packageRegistriesOpen = ref(false)
@@ -486,6 +490,13 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
486
490
  function closeOperatorDashboard() {
487
491
  operatorDashboardOpen.value = false
488
492
  }
493
+ function openReports() {
494
+ resetHubReturn()
495
+ reportsOpen.value = true
496
+ }
497
+ function closeReports() {
498
+ reportsOpen.value = false
499
+ }
489
500
  function openPackageRegistries() {
490
501
  resetHubReturn()
491
502
  packageRegistriesOpen.value = true
@@ -544,6 +555,7 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
544
555
  slackOpen,
545
556
  observabilityConnectionOpen,
546
557
  operatorDashboardOpen,
558
+ reportsOpen,
547
559
  packageRegistriesOpen,
548
560
  apiTokensOpen,
549
561
  modelConfigOpen,
@@ -559,6 +571,8 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
559
571
  openObservabilityConnection,
560
572
  closeObservabilityConnection,
561
573
  openOperatorDashboard,
574
+ openReports,
575
+ closeReports,
562
576
  closeOperatorDashboard,
563
577
  openPackageRegistries,
564
578
  closePackageRegistries,
@@ -1,6 +1,7 @@
1
1
  import { ref } from 'vue'
2
2
  import { useExecutionStore } from '~/stores/execution'
3
3
  import { agentKindMeta } from '~/utils/catalog'
4
+ import { dedicatedParkView } from '~/utils/pipelineRender'
4
5
 
5
6
  /**
6
7
  * The step-inspection / result-view slice of the UI store: the dedicated result-view overlay
@@ -73,12 +74,15 @@ export function createUiResultViews() {
73
74
  // A step carrying a PR deep-review parks with BOTH a pending approval and
74
75
  // `prReview.status`, so the generic approval button funnels here; route it to the
75
76
  // findings-selection window regardless of catalog/manifest state (mirrors consensus).
77
+ // Likewise a coder parked on the implementation-fork choice or on undecided follow-up
78
+ // items: those parks ride `step.approval` too, but the generic approve resolver refuses
79
+ // them server-side, so the step must open the window that CAN resolve it.
76
80
  const view = step?.consensus?.enabled
77
81
  ? 'consensus-session'
78
82
  : step?.prReview
79
83
  ? 'pr-review'
80
84
  : step
81
- ? agentKindMeta(step.agentKind).resultView
85
+ ? (dedicatedParkView(step) ?? agentKindMeta(step.agentKind).resultView)
82
86
  : undefined
83
87
  if (view && instance) {
84
88
  // The brainstorm window is shared by both stages; carry which one from the step's kind.
@@ -30,6 +30,14 @@ export type {
30
30
  PlatformOutcomeTotals,
31
31
  PlatformTrendPoint,
32
32
  PlatformFailureSlice,
33
+ ReportWindow,
34
+ ReportSpendDimension,
35
+ ReportActivityDimension,
36
+ ReportSpendRow,
37
+ ReportActivityRow,
38
+ ReportTrendPoint,
39
+ ReportTotals,
40
+ ReportsView,
33
41
  AgentSearchQuery,
34
42
  WebSearchAvailability,
35
43
  WebSearchProvider,
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { PipelineStep } from '~/types/execution'
3
+ import { dedicatedParkView } from './pipelineRender'
4
+
5
+ /** A minimal coder step; the predicate only reads approval/followUps/forkDecision. */
6
+ const step = (over: Partial<PipelineStep>): PipelineStep =>
7
+ ({
8
+ agentKind: 'coder',
9
+ state: 'waiting_decision',
10
+ approval: { id: 'ap_1', status: 'pending', proposal: '' },
11
+ ...over,
12
+ }) as PipelineStep
13
+
14
+ const followUps = (statuses: string[]) => ({
15
+ enabled: true,
16
+ items: statuses.map((status, i) => ({
17
+ id: `fu_${i}`,
18
+ kind: 'follow_up',
19
+ title: 't',
20
+ detail: '',
21
+ status,
22
+ createdAt: 0,
23
+ updatedAt: 0,
24
+ })),
25
+ loops: 0,
26
+ })
27
+
28
+ describe('dedicatedParkView', () => {
29
+ // The regression this pins: a coder parked on the follow-up gate (or the fork choice)
30
+ // carries a pending `step.approval`, but the generic approve resolver 409s on it — the
31
+ // surfaces must route these parks to their dedicated window, never the "Approve &
32
+ // proceed" rail.
33
+ it('owns a follow-up park (pending approval + undecided items)', () => {
34
+ expect(
35
+ dedicatedParkView(step({ followUps: followUps(['pending', 'answered']) as never })),
36
+ ).toBe('follow-ups')
37
+ })
38
+
39
+ it('does not claim a step whose follow-up items are all decided', () => {
40
+ expect(
41
+ dedicatedParkView(step({ followUps: followUps(['answered', 'dismissed']) as never })),
42
+ ).toBeNull()
43
+ })
44
+
45
+ it('does not claim a WORKING coder that is still streaming items (no approval raised)', () => {
46
+ // Clicking a live step must keep opening the ordinary detail (progress + output).
47
+ expect(
48
+ dedicatedParkView(
49
+ step({ state: 'working', approval: null, followUps: followUps(['pending']) as never }),
50
+ ),
51
+ ).toBeNull()
52
+ })
53
+
54
+ it('owns the fork park while awaiting a choice, and while a chat reply is in flight', () => {
55
+ expect(dedicatedParkView(step({ forkDecision: { status: 'awaiting_choice' } as never }))).toBe(
56
+ 'fork-decision',
57
+ )
58
+ expect(dedicatedParkView(step({ forkDecision: { status: 'answering' } as never }))).toBe(
59
+ 'fork-decision',
60
+ )
61
+ })
62
+
63
+ it('releases the step once the fork is resolved (chosen / single_path / skipped)', () => {
64
+ for (const status of ['chosen', 'single_path', 'skipped', 'proposing']) {
65
+ expect(dedicatedParkView(step({ forkDecision: { status } as never }))).toBeNull()
66
+ }
67
+ })
68
+
69
+ it('leaves a plain approval park to the generic rail', () => {
70
+ expect(dedicatedParkView(step({}))).toBeNull()
71
+ })
72
+ })
@@ -133,6 +133,32 @@ export function isCompanionKind(kind: string): boolean {
133
133
  )
134
134
  }
135
135
 
136
+ /**
137
+ * The dedicated window that owns a step's approval park, when the park is NOT a generic
138
+ * prose approval: the implementation-fork window while a coder waits on (or chats about)
139
+ * an approach choice, or the follow-up triage window while surfaced items are undecided.
140
+ * The generic approve/request-changes/reject resolvers deliberately refuse these parks
141
+ * server-side (`assertNotIterativeGate`), so every surface that offers a step's pending
142
+ * approval must route these to their window instead of the generic "Approve & proceed"
143
+ * rail — which would blink a 409 and resolve nothing.
144
+ */
145
+ export function dedicatedParkView(step: PipelineStep): 'follow-ups' | 'fork-decision' | null {
146
+ // The fork park sits BEFORE the coder's build dispatch; `answering` (a chat turn in
147
+ // flight) still belongs to the fork window, which renders the pending reply.
148
+ const fork = step.forkDecision?.status
149
+ if (fork === 'awaiting_choice' || fork === 'answering') return 'fork-decision'
150
+ // Follow-ups only own the park itself: while the coder is still WORKING (streaming
151
+ // items, no approval raised) a step click should keep opening the ordinary detail.
152
+ if (
153
+ step.approval?.status === 'pending' &&
154
+ step.followUps?.enabled &&
155
+ step.followUps.items.some((i) => i.status === 'pending')
156
+ ) {
157
+ return 'follow-ups'
158
+ }
159
+ return null
160
+ }
161
+
136
162
  /**
137
163
  * The friendly label for a container's live phase (clone → "Preparing workspace",
138
164
  * agent → "Agent running", …), falling back to the raw phase string for an unknown/new