@cat-factory/app 0.144.1 → 0.145.2

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 (35) hide show
  1. package/app/components/board/TaskDependencyEdges.vue +37 -73
  2. package/app/components/brainstorm/BrainstormWindow.vue +2 -7
  3. package/app/components/clarity/ClarityReviewWindow.vue +2 -2
  4. package/app/components/consensus/ConsensusSessionWindow.vue +2 -2
  5. package/app/components/docs/DocInterviewWindow.vue +1 -1
  6. package/app/components/forkDecision/ForkDecisionWindow.vue +1 -1
  7. package/app/components/initiative/InitiativePlanningWindow.vue +1 -1
  8. package/app/components/initiative/InitiativeTrackerWindow.vue +1 -1
  9. package/app/components/observability/StepMetricsBar.vue +8 -1
  10. package/app/components/panels/ObservabilityPanel.vue +20 -3
  11. package/app/components/panels/inspector/TaskExecution.vue +23 -0
  12. package/app/components/pipeline/PipelineProgress.vue +33 -1
  13. package/app/components/prReview/PrReviewWindow.vue +2 -2
  14. package/app/components/requirements/RequirementsReviewWindow.vue +2 -2
  15. package/app/components/spec/ServiceSpecWindow.vue +2 -2
  16. package/app/composables/useResultView.ts +26 -3
  17. package/app/stores/pipelines.ts +50 -19
  18. package/app/stores/ui/modals.ts +599 -532
  19. package/app/stores/ui/resultViews.ts +8 -3
  20. package/app/stores/ui.dispatch.spec.ts +99 -0
  21. package/app/utils/catalog.spec.ts +1 -0
  22. package/app/utils/catalog.ts +18 -0
  23. package/app/utils/observability.spec.ts +28 -0
  24. package/app/utils/observability.ts +28 -0
  25. package/i18n/locales/de.json +9 -4
  26. package/i18n/locales/en.json +9 -4
  27. package/i18n/locales/es.json +9 -4
  28. package/i18n/locales/fr.json +9 -4
  29. package/i18n/locales/he.json +9 -4
  30. package/i18n/locales/it.json +9 -4
  31. package/i18n/locales/ja.json +9 -4
  32. package/i18n/locales/pl.json +9 -4
  33. package/i18n/locales/tr.json +9 -4
  34. package/i18n/locales/uk.json +9 -4
  35. package/package.json +1 -1
@@ -108,88 +108,52 @@ function border(cx: number, cy: number, hw: number, hh: number, tx: number, ty:
108
108
  return { x: cx + dx * t, y: cy + dy * t }
109
109
  }
110
110
 
111
+ /** Resolve the on-screen, origin-relative border-to-border segment between two blocks,
112
+ * or null when either end is missing or both collapsed into the same frame. */
113
+ function segmentBetween(sourceId: string, targetId: string, origin: DOMRect) {
114
+ const a = anchorEl(sourceId)
115
+ const b = anchorEl(targetId)
116
+ if (!a || !b || a === b) return null // missing, or both collapsed into the same frame
117
+ const ra = a.getBoundingClientRect()
118
+ const rb = b.getBoundingClientRect()
119
+ const ax = ra.left + ra.width / 2 - origin.left
120
+ const ay = ra.top + ra.height / 2 - origin.top
121
+ const bx = rb.left + rb.width / 2 - origin.left
122
+ const by = rb.top + rb.height / 2 - origin.top
123
+ const start = border(ax, ay, ra.width / 2, ra.height / 2, bx, by)
124
+ const end = border(bx, by, rb.width / 2, rb.height / 2, ax, ay)
125
+ return { x1: start.x, y1: start.y, x2: end.x, y2: end.y }
126
+ }
127
+
128
+ /** Map a list of {id, source, target} links to their drawable (arrowhead-agnostic) segments. */
129
+ function linkSegments(
130
+ links: { id: string; source: string; target: string }[],
131
+ origin: DOMRect,
132
+ ): { id: string; x1: number; y1: number; x2: number; y2: number }[] {
133
+ const out: { id: string; x1: number; y1: number; x2: number; y2: number }[] = []
134
+ for (const link of links) {
135
+ const seg = segmentBetween(link.source, link.target, origin)
136
+ if (seg) out.push({ id: link.id, ...seg })
137
+ }
138
+ return out
139
+ }
140
+
111
141
  function recompute() {
112
142
  const el = svg.value
113
143
  if (!el) return
114
144
  const origin = el.getBoundingClientRect()
115
- const next: Seg[] = []
116
145
 
146
+ const next: Seg[] = []
117
147
  for (const d of taskDeps.value) {
118
- const a = anchorEl(d.source)
119
- const b = anchorEl(d.target)
120
- if (!a || !b || a === b) continue // missing, or both collapsed into the same frame
121
-
122
- const ra = a.getBoundingClientRect()
123
- const rb = b.getBoundingClientRect()
124
- const ax = ra.left + ra.width / 2 - origin.left
125
- const ay = ra.top + ra.height / 2 - origin.top
126
- const bx = rb.left + rb.width / 2 - origin.left
127
- const by = rb.top + rb.height / 2 - origin.top
128
-
129
- const start = border(ax, ay, ra.width / 2, ra.height / 2, bx, by)
130
- const end = border(bx, by, rb.width / 2, rb.height / 2, ax, ay)
131
-
132
- next.push({
133
- id: d.id,
134
- x1: start.x,
135
- y1: start.y,
136
- x2: end.x,
137
- y2: end.y,
138
- done: board.getBlock(d.source)?.status === 'done',
139
- })
148
+ const seg = segmentBetween(d.source, d.target, origin)
149
+ if (!seg) continue
150
+ next.push({ id: d.id, ...seg, done: board.getBlock(d.source)?.status === 'done' })
140
151
  }
141
152
  segments.value = next
142
153
 
143
- const members: MemberSeg[] = []
144
- for (const link of epicLinks.value) {
145
- const a = anchorEl(link.source)
146
- const b = anchorEl(link.target)
147
- if (!a || !b || a === b) continue
148
- const ra = a.getBoundingClientRect()
149
- const rb = b.getBoundingClientRect()
150
- const ax = ra.left + ra.width / 2 - origin.left
151
- const ay = ra.top + ra.height / 2 - origin.top
152
- const bx = rb.left + rb.width / 2 - origin.left
153
- const by = rb.top + rb.height / 2 - origin.top
154
- const start = border(ax, ay, ra.width / 2, ra.height / 2, bx, by)
155
- const end = border(bx, by, rb.width / 2, rb.height / 2, ax, ay)
156
- members.push({ id: link.id, x1: start.x, y1: start.y, x2: end.x, y2: end.y })
157
- }
158
- memberSegments.value = members
159
-
160
- const fes: FrontendSeg[] = []
161
- for (const link of frontendLinks.value) {
162
- const a = anchorEl(link.source)
163
- const b = anchorEl(link.target)
164
- if (!a || !b || a === b) continue
165
- const ra = a.getBoundingClientRect()
166
- const rb = b.getBoundingClientRect()
167
- const ax = ra.left + ra.width / 2 - origin.left
168
- const ay = ra.top + ra.height / 2 - origin.top
169
- const bx = rb.left + rb.width / 2 - origin.left
170
- const by = rb.top + rb.height / 2 - origin.top
171
- const start = border(ax, ay, ra.width / 2, ra.height / 2, bx, by)
172
- const end = border(bx, by, rb.width / 2, rb.height / 2, ax, ay)
173
- fes.push({ id: link.id, x1: start.x, y1: start.y, x2: end.x, y2: end.y })
174
- }
175
- frontendSegments.value = fes
176
-
177
- const conns: ConnectionSeg[] = []
178
- for (const link of connectionLinks.value) {
179
- const a = anchorEl(link.source)
180
- const b = anchorEl(link.target)
181
- if (!a || !b || a === b) continue
182
- const ra = a.getBoundingClientRect()
183
- const rb = b.getBoundingClientRect()
184
- const ax = ra.left + ra.width / 2 - origin.left
185
- const ay = ra.top + ra.height / 2 - origin.top
186
- const bx = rb.left + rb.width / 2 - origin.left
187
- const by = rb.top + rb.height / 2 - origin.top
188
- const start = border(ax, ay, ra.width / 2, ra.height / 2, bx, by)
189
- const end = border(bx, by, rb.width / 2, rb.height / 2, ax, ay)
190
- conns.push({ id: link.id, x1: start.x, y1: start.y, x2: end.x, y2: end.y })
191
- }
192
- connectionSegments.value = conns
154
+ memberSegments.value = linkSegments(epicLinks.value, origin)
155
+ frontendSegments.value = linkSegments(frontendLinks.value, origin)
156
+ connectionSegments.value = linkSegments(connectionLinks.value, origin)
193
157
  }
194
158
 
195
159
  const { pause, resume } = useRafFn(recompute, { immediate: false })
@@ -22,7 +22,6 @@ import type {
22
22
 
23
23
  const board = useBoardStore()
24
24
  const brainstorm = useBrainstormStore()
25
- const ui = useUiStore()
26
25
  const toast = useToast()
27
26
  const { t } = useI18n()
28
27
  const access = useWorkspaceAccess()
@@ -32,15 +31,11 @@ const redoComment = ref('')
32
31
  const showRedo = ref(false)
33
32
 
34
33
  const { open, blockId, stage, close } = useResultView('brainstorm', {
35
- // `onOpen` fires synchronously from `useResultView`'s immediate watch, BEFORE the `stage`
36
- // const below is initialised — so read the stage straight off the store here (referencing
37
- // `stage.value` would hit its temporal dead zone and throw on every open).
38
- onOpen: (id) => {
34
+ onOpen: ({ blockId, stage }) => {
39
35
  drafts.value = {}
40
36
  redoComment.value = ''
41
37
  showRedo.value = false
42
- const openStage = ui.resultView?.stage
43
- if (openStage) void brainstorm.load(id, openStage)
38
+ if (stage) void brainstorm.load(blockId, stage)
44
39
  },
45
40
  })
46
41
  const activeStage = computed<BrainstormStage>(() => stage.value ?? 'requirements')
@@ -43,12 +43,12 @@ const showRedo = ref(false)
43
43
  // fresh each open, so a non-immediate per-window watch used to leave it empty for whichever
44
44
  // route (a pipeline step / "Review & approve") didn't warm the cache by selecting the block.
45
45
  const { open, blockId, close } = useResultView('clarity-review', {
46
- onOpen: (id) => {
46
+ onOpen: ({ blockId }) => {
47
47
  drafts.value = {}
48
48
  seededReply.value = {}
49
49
  redoComment.value = ''
50
50
  showRedo.value = false
51
- void clarity.load(id)
51
+ void clarity.load(blockId)
52
52
  },
53
53
  // Flush any typed-but-unblurred answer before the view tears down (X, backdrop, Escape) so
54
54
  // closing the window never silently drops it (UX-33).
@@ -20,8 +20,8 @@ const consensus = useConsensusStore()
20
20
  const models = useModelsStore()
21
21
 
22
22
  const { open, blockId, close } = useResultView('consensus-session', {
23
- onOpen: (id) => {
24
- void consensus.load(id)
23
+ onOpen: ({ blockId }) => {
24
+ void consensus.load(blockId)
25
25
  },
26
26
  })
27
27
 
@@ -16,7 +16,7 @@ const { t } = useI18n()
16
16
  const access = useWorkspaceAccess()
17
17
 
18
18
  const { open, blockId, close } = useResultView('doc-interview', {
19
- onOpen: (id) => void docInterview.load(id),
19
+ onOpen: ({ blockId }) => void docInterview.load(blockId),
20
20
  })
21
21
 
22
22
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
@@ -26,7 +26,7 @@ const { t } = useI18n()
26
26
  // Hybrid: state rides the coder step (like follow-ups), but warm it from the GET on open too.
27
27
  // No `stepRef`: this is a pre-run decision, so there's no "restart from here".
28
28
  const { open, blockId, instanceId, stepIndex, close } = useResultView('fork-decision', {
29
- onOpen: (id) => void forkDecision.load(id),
29
+ onOpen: ({ blockId }) => void forkDecision.load(blockId),
30
30
  })
31
31
 
32
32
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
@@ -17,7 +17,7 @@ const initiatives = useInitiativesStore()
17
17
  const { t } = useI18n()
18
18
 
19
19
  const { open, blockId, close } = useResultView('initiative-planning', {
20
- onOpen: (id) => void initiatives.load(id),
20
+ onOpen: ({ blockId }) => void initiatives.load(blockId),
21
21
  })
22
22
 
23
23
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
@@ -26,7 +26,7 @@ const { t } = useI18n()
26
26
  const toast = useToast()
27
27
 
28
28
  const { open, blockId, close } = useResultView('initiative-tracker', {
29
- onOpen: (id) => void initiatives.load(id),
29
+ onOpen: ({ blockId }) => void initiatives.load(blockId),
30
30
  })
31
31
 
32
32
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
@@ -4,6 +4,7 @@ import type { StepMetrics } from '~/types/execution'
4
4
  import {
5
5
  formatMs,
6
6
  formatTokens,
7
+ freshPromptTokens,
7
8
  headroomColor,
8
9
  headroomRatio,
9
10
  pct,
@@ -22,6 +23,12 @@ defineEmits<{ inspect: [] }>()
22
23
  const { t } = useI18n()
23
24
 
24
25
  const m = computed(() => props.metrics)
26
+ // The headline "↑" is FRESH (uncached) input, not the raw prompt-token sum: a long agentic
27
+ // run re-sends its whole transcript every turn, so the raw sum is ~all cache reads and reads
28
+ // as a blow-up. The cached prefix is shown separately (the green chip) so the two are distinct.
29
+ const freshPrompt = computed(() =>
30
+ freshPromptTokens(m.value.promptTokens, m.value.cachedPromptTokens ?? 0),
31
+ )
25
32
  const headroom = computed(() => headroomRatio(m.value))
26
33
  const transport = computed(() => transportRatio(m.value))
27
34
  const headroomTone = computed(() => headroomColor(headroom.value, m.value.truncatedCalls > 0))
@@ -48,7 +55,7 @@ const headroomTone = computed(() => headroomColor(headroom.value, m.value.trunca
48
55
  class="tabular-nums text-slate-400"
49
56
  :title="t('observability.metricsBar.promptCompletionTokens')"
50
57
  >
51
- {{ formatTokens(m.promptTokens) }}↑ {{ formatTokens(m.completionTokens) }}↓
58
+ {{ formatTokens(freshPrompt) }}↑ {{ formatTokens(m.completionTokens) }}↓
52
59
  </span>
53
60
  <span
54
61
  v-if="(m.cachedPromptTokens ?? 0) > 0"
@@ -8,7 +8,7 @@ import type {
8
8
  WebSearchProvider,
9
9
  } from '~/types/execution'
10
10
  import { agentKindMeta } from '~/utils/catalog'
11
- import { formatMs, formatTokens, pct } from '~/utils/observability'
11
+ import { formatMs, formatTokens, freshPromptTokens, pct } from '~/utils/observability'
12
12
 
13
13
  // Drill-down overlay for a run's LLM activity. Opened via
14
14
  // `ui.openObservability(instanceId)` from a step surface; loads the full per-call
@@ -126,9 +126,15 @@ const totals = computed(() => {
126
126
  const upstreamMs = sum(c, (x) => x.upstreamMs)
127
127
  const overheadMs = sum(c, (x) => x.overheadMs)
128
128
  const total = upstreamMs + overheadMs
129
+ const promptTokens = sum(c, (x) => x.promptTokens)
130
+ const cachedPromptTokens = sum(c, (x) => x.cachedPromptTokens)
129
131
  return {
130
132
  calls: c.length,
131
- promptTokens: sum(c, (x) => x.promptTokens),
133
+ promptTokens,
134
+ cachedPromptTokens,
135
+ // Fresh (uncached) input — the raw prompt-token sum is dominated by per-turn cache reads
136
+ // on a long agentic run, so surface fresh vs cached rather than the misleading raw total.
137
+ freshPromptTokens: freshPromptTokens(promptTokens, cachedPromptTokens),
132
138
  completionTokens: sum(c, (x) => x.completionTokens),
133
139
  upstreamMs,
134
140
  overheadMs,
@@ -284,8 +290,19 @@ function exportJson() {
284
290
  {{ t('observability.summary.tokensInOut') }}
285
291
  </dt>
286
292
  <dd class="mt-0.5 tabular-nums text-slate-200">
287
- {{ formatTokens(totals.promptTokens) }} /
293
+ {{ formatTokens(totals.freshPromptTokens) }} /
288
294
  {{ formatTokens(totals.completionTokens) }}
295
+ <span
296
+ v-if="totals.cachedPromptTokens > 0"
297
+ class="ms-1 text-[11px] text-emerald-400/80"
298
+ >
299
+ ·
300
+ {{
301
+ t('observability.summary.cached', {
302
+ tokens: formatTokens(totals.cachedPromptTokens),
303
+ })
304
+ }}
305
+ </span>
289
306
  </dd>
290
307
  </div>
291
308
  <div>
@@ -151,6 +151,12 @@ function openForkFor(i: number) {
151
151
  if (instance.value) ui.openForkDecision(instance.value.id, i)
152
152
  }
153
153
 
154
+ // Open the PR deep-review findings-selection window for a pr-reviewer step parked awaiting
155
+ // a selection (its dedicated chip, mirroring the fork-decision one above).
156
+ function openPrReviewFor(i: number) {
157
+ if (instance.value) ui.openPrReview(instance.value.id, i)
158
+ }
159
+
154
160
  // Stop the run WITHOUT deleting it: halts the container + driver and records a
155
161
  // `cancelled` failure, leaving the run readable + retryable (the block goes
156
162
  // `blocked`). The destructive reset (delete the run, return the task to `planned`)
@@ -393,6 +399,23 @@ async function mergePr() {
393
399
  >
394
400
  {{ t('inspector.execution.chooseApproach') }}
395
401
  </UButton>
402
+ <!-- A pr-reviewer step parked awaiting a finding selection: open the dedicated
403
+ findings-selection window, not the generic approval gate. -->
404
+ <UButton
405
+ v-else-if="
406
+ s.approval &&
407
+ s.approval.status === 'pending' &&
408
+ s.prReview?.status === 'awaiting_selection'
409
+ "
410
+ color="primary"
411
+ variant="soft"
412
+ size="xs"
413
+ icon="i-lucide-clipboard-check"
414
+ data-testid="pr-review-open"
415
+ @click="openPrReviewFor(i)"
416
+ >
417
+ {{ t('inspector.execution.reviewFindings') }}
418
+ </UButton>
396
419
  <UButton
397
420
  v-else-if="s.approval && s.approval.status === 'pending'"
398
421
  color="warning"
@@ -75,6 +75,15 @@ function forkPhase(step: PipelineStep): 'proposing' | 'awaiting_choice' | null {
75
75
  return status === 'proposing' || status === 'awaiting_choice' ? status : null
76
76
  }
77
77
 
78
+ /**
79
+ * Whether a `pr-reviewer` step is parked awaiting a human finding-selection — it carries a
80
+ * pending approval too, so we render a purpose-built "Review findings" chip (opening the
81
+ * PR-review window) ahead of the generic approval gate, mirroring the fork-decision chip.
82
+ */
83
+ function prReviewAwaiting(step: PipelineStep): boolean {
84
+ return step.prReview?.status === 'awaiting_selection'
85
+ }
86
+
78
87
  // --- restart from a step -----------------------------------------------------
79
88
  // Re-run the pipeline from a chosen step onward: the server resets that step +
80
89
  // every later step's iteration counters and re-drives a fresh run, keeping the
@@ -607,6 +616,26 @@ const ITEM_ICON: Record<string, string> = {
607
616
  </span>
608
617
  </button>
609
618
 
619
+ <!-- PR deep-review parked for a human to select which findings matter: a
620
+ purpose-built chip opening the findings-selection window, ahead of the
621
+ generic approval gate (mirrors the fork-decision chip above). -->
622
+ <button
623
+ v-if="prReviewAwaiting(s)"
624
+ type="button"
625
+ data-testid="pr-review-open"
626
+ class="mt-3 flex w-full items-center gap-2 rounded-lg border border-dashed border-indigo-500/50 bg-indigo-500/10 px-2.5 py-1.5 text-start transition followup-blink hover:border-indigo-400/60"
627
+ @click="ui.openPrReview(instance.id, i)"
628
+ >
629
+ <span
630
+ class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-indigo-500/40 bg-indigo-500/15"
631
+ >
632
+ <UIcon name="i-lucide-clipboard-check" class="h-3 w-3 text-indigo-300" />
633
+ </span>
634
+ <span class="min-w-0 flex-1 truncate text-[12px] text-slate-300">
635
+ {{ t('pipeline.progress.prReview.review') }}
636
+ </span>
637
+ </button>
638
+
610
639
  <!-- reviewer gate folding/re-reviewing in the background: a working indicator,
611
640
  NOT a "Review & approve" gate (the human is summoned only if needed) -->
612
641
  <div
@@ -618,7 +647,10 @@ const ITEM_ICON: Record<string, string> = {
618
647
  </div>
619
648
 
620
649
  <!-- approval gate: review (and edit) the proposal before continuing -->
621
- <div v-else-if="s.approval && s.approval.status === 'pending'" class="mt-3">
650
+ <div
651
+ v-else-if="s.approval && s.approval.status === 'pending' && !prReviewAwaiting(s)"
652
+ class="mt-3"
653
+ >
622
654
  <UButton
623
655
  color="warning"
624
656
  variant="soft"
@@ -30,8 +30,8 @@ const access = useWorkspaceAccess()
30
30
  const { t } = useI18n()
31
31
 
32
32
  const { open, blockId, instanceId, stepIndex, close } = useResultView('pr-review', {
33
- onOpen: (_id) => {
34
- if (instanceId.value) void prReview.load(instanceId.value)
33
+ onOpen: ({ instanceId }) => {
34
+ if (instanceId) void prReview.load(instanceId)
35
35
  },
36
36
  })
37
37
 
@@ -55,7 +55,7 @@ const docCollapsedOverride = ref<boolean | null>(null)
55
55
  // fresh each open, so a non-immediate per-window watch used to leave it empty for whichever
56
56
  // route (a pipeline step / "Review & approve") didn't warm the cache by selecting the block.
57
57
  const { open, blockId, instanceId, stepIndex, close } = useResultView('requirements-review', {
58
- onOpen: (id) => {
58
+ onOpen: ({ blockId }) => {
59
59
  drafts.value = {}
60
60
  seededReply.value = {}
61
61
  recommendMode.value = new Set()
@@ -64,7 +64,7 @@ const { open, blockId, instanceId, stepIndex, close } = useResultView('requireme
64
64
  redoComment.value = ''
65
65
  showRedo.value = false
66
66
  docCollapsedOverride.value = null
67
- void requirements.load(id)
67
+ void requirements.load(blockId)
68
68
  },
69
69
  // Closing the window (X, backdrop, Escape) must not silently drop an answer the user typed
70
70
  // but never blurred out of. Flush before the view tears down; flushDrafts captures the
@@ -25,10 +25,10 @@ const mode = ref<ViewMode>('structured')
25
25
  const selected = ref<{ m: number; g: number } | null>(null)
26
26
 
27
27
  const { open, blockId, close } = useResultView('service-spec', {
28
- onOpen: (id) => {
28
+ onOpen: ({ blockId }) => {
29
29
  mode.value = 'structured'
30
30
  selected.value = null
31
- void serviceSpec.load(id)
31
+ void serviceSpec.load(blockId)
32
32
  },
33
33
  })
34
34
 
@@ -24,9 +24,24 @@
24
24
  * overlay stack (top overlay closes first, focus/scroll managed too). A listener here would
25
25
  * double-fire `close`, so it was removed once the last window converted onto the shell.
26
26
  */
27
+ /**
28
+ * The fully-resolved view context handed to `onOpen`. Every field is already initialised by
29
+ * the time `onOpen` fires, so a loader takes exactly what it needs from here and never reaches
30
+ * back into the store or the composable's own return refs. That matters because `onOpen` fires
31
+ * synchronously from the `immediate` watch below — DURING the caller's `setup`, before the
32
+ * `const { … } = useResultView(…)` destructure has been assigned — so any callback that closed
33
+ * over those refs would hit their temporal dead zone and throw on every open.
34
+ */
35
+ export interface OpenResultView {
36
+ blockId: string
37
+ instanceId: string | null
38
+ stepIndex: number | null
39
+ stage: 'requirements' | 'architecture' | null
40
+ }
41
+
27
42
  export function useResultView(
28
43
  viewId: string,
29
- opts?: { onOpen?: (blockId: string) => void; onClose?: () => void },
44
+ opts?: { onOpen?: (view: OpenResultView) => void; onClose?: () => void },
30
45
  ) {
31
46
  const ui = useUiStore()
32
47
 
@@ -44,12 +59,20 @@ export function useResultView(
44
59
  ui.closeResultView()
45
60
  }
46
61
 
47
- // The load-on-open contract: fire immediately on mount and on any later block switch.
62
+ // The load-on-open contract: fire immediately on mount and on any later block switch. The
63
+ // callback receives the fully-resolved context (see OpenResultView) rather than the return
64
+ // refs, which aren't assigned yet at the initial synchronous fire.
48
65
  if (opts?.onOpen) {
49
66
  watch(
50
67
  blockId,
51
68
  (id) => {
52
- if (id) opts.onOpen!(id)
69
+ if (id)
70
+ opts.onOpen!({
71
+ blockId: id,
72
+ instanceId: instanceId.value,
73
+ stepIndex: stepIndex.value,
74
+ stage: stage.value,
75
+ })
53
76
  },
54
77
  { immediate: true },
55
78
  )
@@ -24,26 +24,11 @@ function defaultConsensusConfig(): ConsensusStepConfig {
24
24
  }
25
25
 
26
26
  /**
27
- * Saved, reusable pipelines (the pipeline palette) plus the in-progress draft
28
- * being assembled in the pipeline builder. Saved pipelines live on the backend;
29
- * the draft is transient client state. The draft doubles as the EDIT surface: a
30
- * custom pipeline can be loaded into it (`loadForEdit`) and saved back in place,
31
- * while a built-in is cloned first (`clonePipeline`) into an editable copy.
27
+ * The per-step, index-aligned draft arrays. Every one is kept the same length as `draft` and
28
+ * spliced/reordered in lockstep (see `insertAt` / `removeFromDraft` / `moveUnit`), so grouping
29
+ * their construction keeps that alignment contract and its documentation in one place.
32
30
  */
33
- export const usePipelinesStore = defineStore('pipelines', () => {
34
- const api = useApi()
35
- const {
36
- items: pipelines,
37
- upsert: upsertPipeline,
38
- remove: dropPipeline,
39
- } = useUpsertList<Pipeline>({ key: (p) => p.id })
40
- /**
41
- * Current built-in catalog versions (`seedPipelines()`), keyed by pipeline id, from the
42
- * workspace snapshot. A built-in whose stored `version` is below its catalog value here has
43
- * a newer definition available (see `usePipelineHealth`).
44
- */
45
- const catalogVersions = ref<Record<string, number>>({})
46
-
31
+ function createDraftStepState() {
47
32
  /** The chain currently being assembled in the builder. */
48
33
  const draft = ref<AgentKind[]>([])
49
34
  /** Per-step approval gates, kept index-aligned with `draft`. */
@@ -79,6 +64,52 @@ export const usePipelinesStore = defineStore('pipelines', () => {
79
64
  * default, so an entry exists only when a step opts out of something.
80
65
  */
81
66
  const draftStepOptions = ref<(StepOptions | null)[]>([])
67
+ return {
68
+ draft,
69
+ draftGates,
70
+ draftEnabled,
71
+ draftThresholds,
72
+ draftConsensus,
73
+ draftGating,
74
+ draftFollowUps,
75
+ draftTesterQuality,
76
+ draftStepOptions,
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Saved, reusable pipelines (the pipeline palette) plus the in-progress draft
82
+ * being assembled in the pipeline builder. Saved pipelines live on the backend;
83
+ * the draft is transient client state. The draft doubles as the EDIT surface: a
84
+ * custom pipeline can be loaded into it (`loadForEdit`) and saved back in place,
85
+ * while a built-in is cloned first (`clonePipeline`) into an editable copy.
86
+ */
87
+ export const usePipelinesStore = defineStore('pipelines', () => {
88
+ const api = useApi()
89
+ const {
90
+ items: pipelines,
91
+ upsert: upsertPipeline,
92
+ remove: dropPipeline,
93
+ } = useUpsertList<Pipeline>({ key: (p) => p.id })
94
+ /**
95
+ * Current built-in catalog versions (`seedPipelines()`), keyed by pipeline id, from the
96
+ * workspace snapshot. A built-in whose stored `version` is below its catalog value here has
97
+ * a newer definition available (see `usePipelineHealth`).
98
+ */
99
+ const catalogVersions = ref<Record<string, number>>({})
100
+
101
+ // The per-step, index-aligned draft arrays (kept in lockstep — see `createDraftStepState`).
102
+ const {
103
+ draft,
104
+ draftGates,
105
+ draftEnabled,
106
+ draftThresholds,
107
+ draftConsensus,
108
+ draftGating,
109
+ draftFollowUps,
110
+ draftTesterQuality,
111
+ draftStepOptions,
112
+ } = createDraftStepState()
82
113
  /** Organizational labels for the pipeline being assembled/edited. */
83
114
  const draftLabels = ref<string[]>([])
84
115
  /**