@cat-factory/app 0.283.0 → 0.285.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +19 -0
  2. package/app/components/board/BoardCanvas.logic.spec.ts +71 -0
  3. package/app/components/board/BoardCanvas.logic.ts +92 -0
  4. package/app/components/board/BoardCanvas.vue +14 -27
  5. package/app/components/board/nodes/TaskPipelineMini.vue +2 -1
  6. package/app/components/followUp/FollowUpWindow.vue +49 -9
  7. package/app/components/panels/AgentStepDetail.vue +27 -1
  8. package/app/components/panels/ResultWindowShell.vue +19 -0
  9. package/app/components/panels/RunDetailLoadState.vue +41 -0
  10. package/app/components/panels/inspector/TaskExecution.vue +3 -3
  11. package/app/components/pipeline/PipelineProgress.vue +7 -3
  12. package/app/components/settings/KubernetesEngineForm.vue +1 -1
  13. package/app/components/settings/KubernetesEnvironmentForm.vue +1 -1
  14. package/app/composables/api/execution.ts +12 -0
  15. package/app/composables/api/followUps.ts +11 -3
  16. package/app/composables/useBlockDrag.ts +51 -5
  17. package/app/composables/useSingleFlight.spec.ts +42 -0
  18. package/app/composables/useSingleFlight.ts +37 -0
  19. package/app/composables/useStepApproval.ts +19 -0
  20. package/app/composables/useStepTimer.ts +70 -14
  21. package/app/composables/useUpsertList.spec.ts +73 -0
  22. package/app/composables/useUpsertList.ts +52 -6
  23. package/app/composables/useViewport.ts +13 -3
  24. package/app/stores/consensus.ts +8 -1
  25. package/app/stores/docInterview.ts +10 -1
  26. package/app/stores/execution/reconcile.ts +182 -0
  27. package/app/stores/execution/wholeRunReads.ts +139 -0
  28. package/app/stores/execution.spec.ts +297 -1
  29. package/app/stores/execution.ts +57 -110
  30. package/app/stores/followUps.spec.ts +94 -0
  31. package/app/stores/followUps.ts +17 -3
  32. package/app/stores/kaizen.spec.ts +77 -14
  33. package/app/stores/kaizen.ts +75 -17
  34. package/app/stores/notifications.spec.ts +65 -0
  35. package/app/stores/notifications.ts +29 -0
  36. package/app/stores/observability/agentContext.ts +128 -0
  37. package/app/stores/observability/toolCalls.ts +30 -2
  38. package/app/stores/observability.spec.ts +98 -0
  39. package/app/stores/observability.ts +51 -79
  40. package/app/stores/requirements/settlement.ts +55 -0
  41. package/app/stores/requirements.ts +25 -23
  42. package/app/stores/ui/k3sDeepLink.spec.ts +5 -5
  43. package/app/stores/workspace/hydrate.ts +11 -0
  44. package/app/stores/workspace/refreshFunnel.spec.ts +15 -1
  45. package/app/types/execution.ts +1 -0
  46. package/i18n/locales/de.json +10 -1
  47. package/i18n/locales/en.json +10 -1
  48. package/i18n/locales/es.json +10 -1
  49. package/i18n/locales/fr.json +10 -1
  50. package/i18n/locales/he.json +10 -1
  51. package/i18n/locales/it.json +10 -1
  52. package/i18n/locales/ja.json +10 -1
  53. package/i18n/locales/pl.json +10 -1
  54. package/i18n/locales/tr.json +10 -1
  55. package/i18n/locales/uk.json +10 -1
  56. package/package.json +6 -6
package/README.md CHANGED
@@ -1030,6 +1030,25 @@ event left to restore it.
1030
1030
  (`refreshMark()` / `hydratedSince()`) and stands down if so, which is what stops a mutation that
1031
1031
  refreshes directly AND raises a coarse event from paying for two snapshots. That skip rests on
1032
1032
  the server emitting a coarse `board` event only after committing what it announces.
1033
+ - **The board snapshot's runs are a LEAN PROJECTION, so a hydrate can WITHHOLD what it does not
1034
+ clobber.** `projectExecutionForBoard` (contracts) strips each step's captured prose from the
1035
+ snapshot's executions and stamps the instance `projected`; a live `execution` event still carries
1036
+ the whole run. Two rules follow, and both are the clobber rule in a new shape. A step-detail
1037
+ surface fetches the run before rendering (`execution.ensureFull`, asked by the two overlay HOSTS
1038
+ so no window can forget) and reads `stepHasOutput`, never `step.output`, for the "there is prose
1039
+ here" affordance. And the store's reconcile carries the withheld fields forward ONLY at an equal
1040
+ `rev`: at the same revision the withheld prose IS what the cache holds, one revision later it may
1041
+ not be, so a newer projection replaces and the open overlay re-fetches.
1042
+ - **`execution.instances` is a `shallowRef`, so a write must both TRIGGER and change IDENTITY.**
1043
+ Nothing under the ref is a reactive proxy any more, so the only dependency a reader can hold is
1044
+ the ref itself, and nearly every reader holds it through an identity-stable chain
1045
+ (`computed(() => getInstance(id))` to `steps[i]` to one field). `triggerRef` re-runs the FIRST
1046
+ computed in that chain, and Vue stops propagating when its recomputed value is `===` the previous
1047
+ one, so a run patched in place reaches that computed and nothing below it. `upsert` replaces the
1048
+ run object; `echoAfter` applies the action store's patch to a COPY of the run and its steps and
1049
+ swaps that in. A missing trigger and an in-place patch are both SILENT, so
1050
+ `stores/execution.spec.ts` pins each write shape twice: once on the array, once through the
1051
+ `getInstance` chain a window actually reads.
1033
1052
  - **Pin it with a store-level unit test** (`stores/workspace.spec.ts` for refreshes,
1034
1053
  `stores/workspace/refreshFunnel.spec.ts` for the funnel's own rules, `stores/execution.spec.ts`
1035
1054
  for echoes): drive the two orderings and assert the fresher one wins.
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ createBoardNodeProjection,
4
+ frameZIndex,
5
+ type BoardNodeSource,
6
+ } from '~/components/board/BoardCanvas.logic'
7
+
8
+ const NO_STACK = { draggingId: null, hoveredFrameId: null }
9
+
10
+ function at(id: string, x = 0, y = 0): BoardNodeSource {
11
+ return { id, position: { x, y } }
12
+ }
13
+
14
+ describe('frameZIndex', () => {
15
+ it('lifts the dragged frame above the hovered one, and both above the rest', () => {
16
+ const stacking = { draggingId: 'dragged', hoveredFrameId: 'hovered' }
17
+ expect(frameZIndex('dragged', stacking)).toBeGreaterThan(frameZIndex('hovered', stacking))
18
+ expect(frameZIndex('hovered', stacking)).toBeGreaterThan(frameZIndex('other', stacking))
19
+ })
20
+ })
21
+
22
+ describe('createBoardNodeProjection', () => {
23
+ it('projects frames and epics with the fields the canvas binds', () => {
24
+ const project = createBoardNodeProjection()
25
+ const [frame, epic] = project([at('f1', 10, 20)], [at('e1', 30, 40)], NO_STACK)
26
+ expect(frame).toMatchObject({
27
+ id: 'f1',
28
+ type: 'block',
29
+ position: { x: 10, y: 20 },
30
+ draggable: false,
31
+ zIndex: 1,
32
+ })
33
+ expect(epic).toMatchObject({
34
+ id: 'e1',
35
+ type: 'epic',
36
+ position: { x: 30, y: 40 },
37
+ draggable: true,
38
+ })
39
+ })
40
+
41
+ // The whole point of the memo: a hover changes two nodes, so it must allocate two nodes.
42
+ it('reuses every node a hover did not change', () => {
43
+ const project = createBoardNodeProjection()
44
+ const frames = [at('a'), at('b'), at('c')]
45
+ const before = project(frames, [], NO_STACK)
46
+ const after = project(frames, [], { draggingId: null, hoveredFrameId: 'b' })
47
+
48
+ expect(after[0]).toBe(before[0])
49
+ expect(after[2]).toBe(before[2])
50
+ expect(after[1]).not.toBe(before[1])
51
+ expect(after[1]!.zIndex).toBeGreaterThan(before[1]!.zIndex!)
52
+ })
53
+
54
+ it('rebuilds a node whose position moved', () => {
55
+ const project = createBoardNodeProjection()
56
+ const before = project([at('a', 0, 0)], [], NO_STACK)
57
+ const after = project([at('a', 5, 0)], [], NO_STACK)
58
+ expect(after[0]).not.toBe(before[0])
59
+ expect(after[0]!.position).toEqual({ x: 5, y: 0 })
60
+ })
61
+
62
+ it('does not keep a removed frame alive in the memo', () => {
63
+ const project = createBoardNodeProjection()
64
+ const first = project([at('a'), at('b')], [], NO_STACK)
65
+ project([at('b')], [], NO_STACK)
66
+ // 'a' is gone from the cache, so its return is a fresh object rather than the stale one.
67
+ const back = project([at('a'), at('b')], [], NO_STACK)
68
+ expect(back[0]).not.toBe(first[0])
69
+ expect(back.map((n) => n.id)).toEqual(['a', 'b'])
70
+ })
71
+ })
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The board's Vue Flow node projection, and the memo that keeps a hover cheap.
3
+ *
4
+ * Only frames and epics are canvas nodes (tasks live inside their frame, laid out in swimlanes).
5
+ * The projection is trivial, but it is recomputed for a reason that has nothing to do with the
6
+ * board changing: a frame's `zIndex` encodes the STACKING state (which frame is being dragged,
7
+ * which is hovered), so every pointer move between two overlapping services re-derived the whole
8
+ * array, and Vue Flow re-diffed every node in it against a set of freshly allocated objects that
9
+ * were, for all but one or two, identical to the ones it already held.
10
+ *
11
+ * Memoising per node fixes that without changing what the array contains: the key carries every
12
+ * field the node has, so a node whose fields are unchanged comes back as the SAME object and a
13
+ * changed one is rebuilt. A hover then allocates two nodes instead of all of them, and Vue Flow's
14
+ * diff sees two changes instead of N.
15
+ *
16
+ * The cache is rebuilt from the hits of each pass, so a deleted frame's entry does not outlive it.
17
+ */
18
+ export interface BoardNodeSource {
19
+ id: string
20
+ position: { x: number; y: number }
21
+ }
22
+
23
+ export interface BoardFlowNode {
24
+ id: string
25
+ type: 'block' | 'epic'
26
+ position: { x: number; y: number }
27
+ draggable: boolean
28
+ zIndex?: number
29
+ data: Record<string, never>
30
+ }
31
+
32
+ /** Where a frame sits in the stack: dragged on top, then hovered, then everything else. */
33
+ export interface FrameStacking {
34
+ draggingId: string | null
35
+ hoveredFrameId: string | null
36
+ }
37
+
38
+ /**
39
+ * Vue Flow's `elevate-nodes-on-select` is OFF (see BoardCanvas), so stacking is driven purely by
40
+ * these two: the frame being dragged is lifted above all, then the hovered one (the un-obscured
41
+ * frame under the pointer), so overlapping services can always be reached and reordered.
42
+ */
43
+ export function frameZIndex(id: string, stacking: FrameStacking): number {
44
+ if (stacking.draggingId === id) return 1000
45
+ if (stacking.hoveredFrameId === id) return 100
46
+ return 1
47
+ }
48
+
49
+ export function createBoardNodeProjection() {
50
+ let cache = new Map<string, BoardFlowNode>()
51
+
52
+ return function project(
53
+ frames: readonly BoardNodeSource[],
54
+ epics: readonly BoardNodeSource[],
55
+ stacking: FrameStacking,
56
+ ): BoardFlowNode[] {
57
+ const next = new Map<string, BoardFlowNode>()
58
+ const nodes: BoardFlowNode[] = []
59
+
60
+ const take = (key: string, build: () => BoardFlowNode) => {
61
+ const node = cache.get(key) ?? build()
62
+ next.set(key, node)
63
+ nodes.push(node)
64
+ }
65
+
66
+ for (const frame of frames) {
67
+ const zIndex = frameZIndex(frame.id, stacking)
68
+ take(`block|${frame.id}|${frame.position.x}|${frame.position.y}|${zIndex}`, () => ({
69
+ id: frame.id,
70
+ type: 'block',
71
+ position: { x: frame.position.x, y: frame.position.y },
72
+ // Always-expanded frames fill the viewport; keep them non-draggable so the pane pans
73
+ // through them (they move via their header handle, see BlockNode).
74
+ draggable: false,
75
+ zIndex,
76
+ data: {},
77
+ }))
78
+ }
79
+ for (const epic of epics) {
80
+ take(`epic|${epic.id}|${epic.position.x}|${epic.position.y}`, () => ({
81
+ id: epic.id,
82
+ type: 'epic',
83
+ position: { x: epic.position.x, y: epic.position.y },
84
+ draggable: true,
85
+ data: {},
86
+ }))
87
+ }
88
+
89
+ cache = next
90
+ return nodes
91
+ }
92
+ }
@@ -14,6 +14,7 @@ import { useFrameStacking } from '~/composables/useFrameStacking'
14
14
  import { useFramePlacement } from '~/composables/useFramePlacement'
15
15
  import { useViewport } from '~/composables/useViewport'
16
16
  import { boardPanMode } from '~/utils/boardPanMode'
17
+ import { createBoardNodeProjection } from './BoardCanvas.logic'
17
18
 
18
19
  const board = useBoardStore()
19
20
  const pipelines = usePipelinesStore()
@@ -70,33 +71,19 @@ useTaskExpansion(boardEl, boardActivity)
70
71
  // default adds +1000 to a selected node's z-index, so a frame stayed pinned on top
71
72
  // after a click and no amount of hovering another frame could surface it. Stacking
72
73
  // is driven purely by hover/drag here; the selection highlight is the ring, not z.
73
- function frameZIndex(id: string) {
74
- if (draggingId.value === id) return 1000
75
- if (hoveredFrameId.value === id) return 100
76
- return 1
77
- }
78
-
79
- const nodes = computed(() => [
80
- ...board.frames.map((b) => ({
81
- id: b.id,
82
- type: 'block',
83
- position: { x: b.position.x, y: b.position.y },
84
- // Always-expanded frames fill the viewport; keep them non-draggable so the pane
85
- // pans through them (they move via their header handle, see BlockNode).
86
- draggable: false,
87
- zIndex: frameZIndex(b.id),
88
- data: {},
89
- })),
90
- // Epics are top-level grouping nodes (non-structural), drawn alongside frames and
91
- // linked to their member tasks by the dependency-edge overlay.
92
- ...board.epics.map((b) => ({
93
- id: b.id,
94
- type: 'epic',
95
- position: { x: b.position.x, y: b.position.y },
96
- draggable: true,
97
- data: {},
98
- })),
99
- ])
74
+ //
75
+ // The projection is MEMOISED per node (`BoardCanvas.logic.ts`) because the stacking state is
76
+ // what changes most often: a pointer moving between two overlapping services rebuilt every
77
+ // node object on the board, for a change that concerns two of them. Epics are top-level
78
+ // grouping nodes (non-structural), drawn alongside frames and linked to their member tasks by
79
+ // the dependency-edge overlay.
80
+ const projectNodes = createBoardNodeProjection()
81
+ const nodes = computed(() =>
82
+ projectNodes(board.frames, board.epics, {
83
+ draggingId: draggingId.value,
84
+ hoveredFrameId: hoveredFrameId.value,
85
+ }),
86
+ )
100
87
 
101
88
  onNodeDragStop(({ node }) => {
102
89
  board.moveBlock(node.id, node.position)
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { AgentState, PipelineStep } from '~/types/domain'
3
+ import { stepHasOutput } from '@cat-factory/contracts'
3
4
  import { agentKindMeta } from '~/utils/catalog'
4
5
  import {
5
6
  subtaskIconClass,
@@ -120,7 +121,7 @@ const ITEM_ICON: Record<string, string> = {
120
121
  {{ agentKindMeta(s.agentKind).label }}
121
122
  </span>
122
123
  <UIcon
123
- v-if="s.output"
124
+ v-if="stepHasOutput(s)"
124
125
  name="i-lucide-file-text"
125
126
  class="h-2.5 w-2.5 shrink-0 text-slate-500"
126
127
  />
@@ -10,7 +10,7 @@ import { useResultView } from '~/composables/useResultView'
10
10
  import { useExecutionStore } from '~/stores/execution'
11
11
  import { useBoardStore } from '~/stores/board'
12
12
  import { useFollowUpsStore } from '~/stores/followUps'
13
- import type { FollowUpItem } from '~/types/execution'
13
+ import type { FollowUpItem, FollowUpResolution } from '~/types/execution'
14
14
  import { FOLLOW_UP_COMPANION_META } from '~/utils/catalog'
15
15
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
16
16
 
@@ -56,14 +56,23 @@ async function onQueue(item: FollowUpItem) {
56
56
  const id = execId()
57
57
  if (id) await followUps.queueItem(id, item.id).catch(() => {})
58
58
  }
59
- async function onAnswer(item: FollowUpItem) {
59
+ /**
60
+ * Answer a question, either way.
61
+ *
62
+ * `answered` buys the Coder another pass to apply what you said. `closed` records the reply as a
63
+ * RULING: the item is decided and the gate clears exactly the same, but no pass is spent and the
64
+ * Coder is told the topic is settled so it stops re-raising it. Two buttons rather than one with a
65
+ * toggle, because the choice is not a preference about this window: it is what the answer IS, and
66
+ * the person typing it is the only one who knows.
67
+ */
68
+ async function onAnswer(item: FollowUpItem, resolution: FollowUpResolution = 'answered') {
60
69
  const id = execId()
61
70
  const answer = (drafts[item.id] ?? '').trim()
62
71
  if (!id || !answer) return
63
72
  // Clear the draft only once the answer is actually recorded: clearing first would make a failed
64
73
  // send cost the typed answer, and would also leave the unsaved guard below with nothing to protect.
65
74
  await followUps
66
- .answerItem(id, item.id, answer)
75
+ .answerItem(id, item.id, answer, resolution)
67
76
  .then(() => {
68
77
  delete drafts[item.id]
69
78
  })
@@ -103,6 +112,7 @@ const STATUS_LABEL_KEYS: Record<FollowUpItem['status'], string> = {
103
112
  filed: 'followUp.status.filed',
104
113
  queued: 'followUp.status.queued',
105
114
  answered: 'followUp.status.answered',
115
+ closed: 'followUp.status.closed',
106
116
  dismissed: 'followUp.status.dismissed',
107
117
  }
108
118
 
@@ -114,8 +124,14 @@ const STATUS_META: Record<
114
124
  filed: { badge: 'success', text: 'text-emerald-300' },
115
125
  queued: { badge: 'info', text: 'text-sky-300' },
116
126
  answered: { badge: 'info', text: 'text-sky-300' },
127
+ closed: { badge: 'neutral', text: 'text-slate-300' },
117
128
  dismissed: { badge: 'neutral', text: 'text-slate-400' },
118
129
  }
130
+
131
+ /** Whether a decided item carries a recorded reply to show ('answered' or 'closed'). */
132
+ function hasRecordedAnswer(item: FollowUpItem): boolean {
133
+ return (item.status === 'answered' || item.status === 'closed') && !!item.answer
134
+ }
119
135
  </script>
120
136
 
121
137
  <template>
@@ -197,13 +213,19 @@ const STATUS_META: Record<
197
213
  {{ item.ticketExternalId ?? t('followUp.viewIssue') }}
198
214
  </a>
199
215
  </p>
200
- <p
201
- v-if="item.status === 'answered' && item.answer"
202
- class="mt-1 text-[11px] text-slate-300"
203
- >
204
- <span class="text-slate-500">{{ t('followUp.yourAnswer') }}</span>
216
+ <p v-if="hasRecordedAnswer(item)" class="mt-1 text-[11px] text-slate-300">
217
+ <span class="text-slate-500">
218
+ {{
219
+ item.status === 'closed' ? t('followUp.yourRuling') : t('followUp.yourAnswer')
220
+ }}
221
+ </span>
205
222
  {{ item.answer }}
206
223
  </p>
224
+ <!-- A decision that was made and then thrown away when the budget ran out. It must
225
+ not read like one the Coder acted on. -->
226
+ <p v-if="item.sendBackDropped" class="mt-1 text-[11px] text-amber-300">
227
+ {{ t('followUp.sendBackDropped') }}
228
+ </p>
207
229
 
208
230
  <!-- Actions (only while the item is still undecided) -->
209
231
  <div v-if="item.status === 'pending'" class="mt-2.5">
@@ -215,7 +237,10 @@ const STATUS_META: Record<
215
237
  :placeholder="t('followUp.answerPlaceholder')"
216
238
  class="w-full resize-y rounded-md border border-slate-700 bg-slate-950/60 px-2.5 py-1.5 text-[12px] text-slate-100 placeholder:text-slate-600 focus:border-sky-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/60"
217
239
  />
218
- <div class="flex items-center gap-2">
240
+ <!-- Wraps, like the follow-up row below: three buttons whose labels are two
241
+ words each in English are one long line in most of the other locales, and
242
+ the result window is a narrow panel. -->
243
+ <div class="flex flex-wrap items-center gap-2">
219
244
  <UButton
220
245
  size="xs"
221
246
  color="primary"
@@ -226,6 +251,21 @@ const STATUS_META: Record<
226
251
  >
227
252
  {{ t('followUp.actions.answerAndSend') }}
228
253
  </UButton>
254
+ <UButton
255
+ size="xs"
256
+ color="neutral"
257
+ variant="subtle"
258
+ :loading="followUps.isActing(item.id)"
259
+ :disabled="!(drafts[item.id] ?? '').trim() || !access.canExecuteRuns.value"
260
+ :title="
261
+ access.canExecuteRuns.value
262
+ ? t('followUp.actions.answerAndCloseHint')
263
+ : t('access.noRunExecute')
264
+ "
265
+ @click="onAnswer(item, 'closed')"
266
+ >
267
+ {{ t('followUp.actions.answerAndClose') }}
268
+ </UButton>
229
269
  <UButton
230
270
  size="xs"
231
271
  color="neutral"
@@ -28,6 +28,7 @@ import {
28
28
  runIsActive,
29
29
  } from '~/utils/pipelineRender'
30
30
  import InputGateNotice from '~/components/inputGate/InputGateNotice.vue'
31
+ import RunDetailLoadState from '~/components/panels/RunDetailLoadState.vue'
31
32
 
32
33
  // Detail overlay for a single pipeline step. Opened by clicking an agent in the
33
34
  // inspector list (TaskExecution) or the focus-view pipeline (PipelineProgress) via
@@ -53,6 +54,24 @@ const instance = computed(() => execution.getInstance(ctx.value?.instanceId))
53
54
  const step = computed(() =>
54
55
  ctx.value ? (instance.value?.steps[ctx.value.stepIndex] ?? null) : null,
55
56
  )
57
+ /**
58
+ * The run this overlay is about, and the fetch that makes it WHOLE. The board snapshot carries a
59
+ * lean projection (`projectExecutionForBoard`) whose steps withhold the very prose this reader
60
+ * renders, so opening a step asks for the run behind it. A run the store already holds whole (one
61
+ * a live `execution` event delivered, or one already fetched) costs nothing.
62
+ *
63
+ * Watched through `fullFetchKey` rather than the id, because the run does not stop being a
64
+ * projection once this overlay is open: any full refresh lands a lean projection over it, and at a
65
+ * NEWER revision the store cannot carry the cached prose forward. Keyed on the id, the reader
66
+ * would blank under an open overlay with nothing left to refill it.
67
+ */
68
+ watch(
69
+ () => execution.fullFetchKey(ctx.value?.instanceId ?? null),
70
+ () => void execution.ensureFull(ctx.value?.instanceId ?? null),
71
+ {
72
+ immediate: true,
73
+ },
74
+ )
56
75
  const block = computed(() => (instance.value ? board.getBlock(instance.value.blockId) : undefined))
57
76
  const agent = computed(() => (step.value ? agentKindMeta(step.value.agentKind) : null))
58
77
  const open = computed(() => !!ctx.value && !!step.value)
@@ -265,6 +284,9 @@ const approval = useStepApproval({
265
284
  approvalId: () => approvalId.value,
266
285
  approvalPending: () => approvalPending.value && !dedicatedPark.value,
267
286
  companionExceeded: () => companionExceeded.value,
287
+ // The editor seeds from the step's prose, which a board projection withholds until the
288
+ // whole-run read above lands.
289
+ runIsWhole: () => instance.value?.projected !== true,
268
290
  close,
269
291
  })
270
292
  const {
@@ -277,6 +299,7 @@ const {
277
299
  draftProposal,
278
300
  rejectArmed,
279
301
  canRequestChanges,
302
+ canEditProposal,
280
303
  quorum: gateQuorum,
281
304
  viewerHasApproved,
282
305
  approvalWouldClearGate,
@@ -487,6 +510,9 @@ async function copyOutput() {
487
510
  </div>
488
511
  </header>
489
512
 
513
+ <!-- Whether the whole-run fetch behind this reader's prose has landed. -->
514
+ <RunDetailLoadState :instance-id="ctx?.instanceId ?? null" />
515
+
490
516
  <div ref="scrollEl" class="flex-1 overflow-auto px-6 py-6" @scroll="onScroll">
491
517
  <div class="mx-auto max-w-3xl space-y-5">
492
518
  <!-- metadata card (always shown) -->
@@ -974,7 +1000,7 @@ async function copyOutput() {
974
1000
  size="sm"
975
1001
  icon="i-lucide-pencil"
976
1002
  block
977
- :disabled="rejectArmed || submitting || !!gateRefusal"
1003
+ :disabled="rejectArmed || submitting || !!gateRefusal || !canEditProposal"
978
1004
  @click="startEditing"
979
1005
  >
980
1006
  {{ t('panels.stepDetail.approveWithCorrections') }}
@@ -23,6 +23,7 @@
23
23
  import { computed, ref, watch } from 'vue'
24
24
  import { useModalBehavior } from '@modular-vue/core'
25
25
  import StepRestartControl from '~/components/panels/StepRestartControl.vue'
26
+ import RunDetailLoadState from '~/components/panels/RunDetailLoadState.vue'
26
27
  import StepEffortReport from '~/components/panels/StepEffortReport.vue'
27
28
  import StepValidationReport from '~/components/panels/StepValidationReport.vue'
28
29
  import StepReproductionReport from '~/components/panels/StepReproductionReport.vue'
@@ -134,6 +135,22 @@ const activeStep = computed(() => {
134
135
  return execution.getInstance(view.instanceId)?.steps[view.stepIndex] ?? null
135
136
  })
136
137
  const effortReport = computed(() => activeStep.value?.effortReport ?? null)
138
+ /**
139
+ * The run this window is about, and the fetch that makes it WHOLE. The board snapshot carries a
140
+ * lean projection (`projectExecutionForBoard`) whose steps withhold their captured prose, which is
141
+ * exactly what the windows mounted in this slot render. Asking here rather than in each window is
142
+ * the same reasoning as the trailing sections above: the host mounts exactly one window, so no
143
+ * window can forget to ask, and a run already held whole costs nothing (`ensureFull` returns).
144
+ */
145
+ const activeInstanceId = computed(() => ui.resultView?.instanceId ?? null)
146
+ // Watched through `fullFetchKey`, not the id: a full refresh lands a lean projection over an open
147
+ // run, and at a newer revision the store cannot carry the cached prose forward, so the window has
148
+ // to ask again rather than render the withheld fields as an absence.
149
+ watch(
150
+ () => execution.fullFetchKey(activeInstanceId.value),
151
+ () => void execution.ensureFull(activeInstanceId.value),
152
+ { immediate: true },
153
+ )
137
154
  // The step's PRE-PR VALIDATION report — the second universal trailing section, resolved the same
138
155
  // way and for the same reason: every window whose step ran a coding job can show whether the
139
156
  // checkout actually passed the service's checks before the PR opened (and, on a red run, exactly
@@ -291,6 +308,8 @@ const panelClass = computed(() => [
291
308
  <UIcon name="i-lucide-x" class="h-4 w-4" />
292
309
  </button>
293
310
  </header>
311
+ <!-- Whether the whole-run fetch behind this window's prose has landed. -->
312
+ <RunDetailLoadState :instance-id="activeInstanceId" />
294
313
  <!-- The window body. -->
295
314
  <slot />
296
315
 
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The one-line state of the WHOLE-RUN fetch a step-detail surface depends on.
4
+ *
5
+ * The board snapshot carries a lean projection of every run: each step's captured prose is
6
+ * WITHHELD, not absent (`projectExecutionForBoard`). A window that renders that prose asks the
7
+ * store for the whole run on open, and until that read lands (or when it fails) the surface has
8
+ * nothing to say about the prose. Both states have to be visible, because an empty reader and a
9
+ * failed fetch look identical and only one of them means "this step said nothing".
10
+ *
11
+ * Renders nothing for a run the store already holds whole, which is every run a live `execution`
12
+ * event delivered and every run already fetched once.
13
+ */
14
+ import { computed } from 'vue'
15
+
16
+ const props = defineProps<{ instanceId: string | null }>()
17
+
18
+ const execution = useExecutionStore()
19
+ const { t } = useI18n()
20
+
21
+ const loading = computed(() => execution.isFullPending(props.instanceId))
22
+ const error = computed(() => execution.fullError(props.instanceId))
23
+ </script>
24
+
25
+ <template>
26
+ <div
27
+ v-if="loading || error"
28
+ class="flex items-center gap-2 border-b border-slate-800 px-4 py-2 text-[11px]"
29
+ :class="error ? 'text-rose-300' : 'text-slate-400'"
30
+ data-testid="run-detail-load-state"
31
+ >
32
+ <UIcon
33
+ :name="error ? 'i-lucide-triangle-alert' : 'i-lucide-loader-circle'"
34
+ class="h-3.5 w-3.5 shrink-0"
35
+ :class="error ? '' : 'animate-spin'"
36
+ />
37
+ <span>{{
38
+ error ? t('panels.runDetail.loadFailed', { reason: error }) : t('panels.runDetail.loading')
39
+ }}</span>
40
+ </div>
41
+ </template>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { isDryRun } from '@cat-factory/contracts'
2
+ import { isDryRun, stepHasOutput } from '@cat-factory/contracts'
3
3
  import type { Block } from '~/types/domain'
4
4
  import { agentKindMeta } from '~/utils/catalog'
5
5
  import {
@@ -390,7 +390,7 @@ async function mergePr() {
390
390
  class="flex min-w-0 cursor-pointer items-center gap-2 text-start transition hover:text-white"
391
391
  data-testid="run-step-open"
392
392
  :title="
393
- s.output
393
+ stepHasOutput(s)
394
394
  ? t('inspector.execution.viewDetailsOutput')
395
395
  : t('inspector.execution.viewDetails')
396
396
  "
@@ -412,7 +412,7 @@ async function mergePr() {
412
412
  {{ t('inspector.execution.companion') }}
413
413
  </span>
414
414
  <UIcon
415
- :name="s.output ? 'i-lucide-book-open-text' : 'i-lucide-info'"
415
+ :name="stepHasOutput(s) ? 'i-lucide-book-open-text' : 'i-lucide-info'"
416
416
  class="h-3.5 w-3.5 shrink-0 text-slate-500"
417
417
  />
418
418
  </button>
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { AgentState, ExecutionInstance } from '~/types/domain'
3
3
  import type { PipelineStep } from '~/types/execution'
4
+ import { stepHasOutput } from '@cat-factory/contracts'
4
5
  import { agentKindMeta, FOLLOW_UP_COMPANION_META, FORK_DECISION_META } from '~/utils/catalog'
5
6
  import {
6
7
  subtaskIconClass,
@@ -341,7 +342,7 @@ const ITEM_ICON: Record<string, string> = {
341
342
  data-testid="pipeline-step"
342
343
  :data-step-kind="s.agentKind"
343
344
  :title="
344
- s.output
345
+ stepHasOutput(s)
345
346
  ? t('pipeline.progress.viewDetailsOutput')
346
347
  : t('pipeline.progress.viewDetails')
347
348
  "
@@ -441,7 +442,7 @@ const ITEM_ICON: Record<string, string> = {
441
442
  </template>
442
443
 
443
444
  <UIcon
444
- :name="s.output ? 'i-lucide-book-open-text' : 'i-lucide-info'"
445
+ :name="stepHasOutput(s) ? 'i-lucide-book-open-text' : 'i-lucide-info'"
445
446
  class="h-4 w-4 shrink-0 text-slate-500 transition-colors group-hover:text-indigo-300"
446
447
  />
447
448
  </div>
@@ -555,7 +556,10 @@ const ITEM_ICON: Record<string, string> = {
555
556
 
556
557
  <!-- A one-line hint that the agent produced prose; the full output (and
557
558
  all step metadata) lives in the step-detail overlay opened by click. -->
558
- <p v-if="s.output" class="mt-2 flex items-center gap-1 text-[11px] text-slate-500">
559
+ <p
560
+ v-if="stepHasOutput(s)"
561
+ class="mt-2 flex items-center gap-1 text-[11px] text-slate-500"
562
+ >
559
563
  <UIcon name="i-lucide-book-open-text" class="h-3 w-3 shrink-0" />
560
564
  {{ t('pipeline.progress.clickToRead') }}
561
565
  </p>
@@ -554,7 +554,7 @@ async function copyAutoSetupCommand() {
554
554
  <UInput
555
555
  v-model="form.namespaceTemplate"
556
556
  class="font-mono"
557
- placeholder="cf-env-{{pullNumber}}"
557
+ placeholder="cf-env-pr{{pullNumber}}"
558
558
  />
559
559
  </UFormField>
560
560
 
@@ -406,7 +406,7 @@ function optional(label: string): string {
406
406
  <UInput
407
407
  v-model="form.namespaceTemplate"
408
408
  class="font-mono"
409
- placeholder="cf-env-{{pullNumber}}"
409
+ placeholder="cf-env-pr{{pullNumber}}"
410
410
  />
411
411
  </UFormField>
412
412
 
@@ -3,6 +3,7 @@ import {
3
3
  cancelExecutionContract,
4
4
  exportExecutionLlmMetricsContract,
5
5
  getExecutionAgentContextContract,
6
+ getExecutionContract,
6
7
  getExecutionLlmMetricsContract,
7
8
  getExecutionSearchQueriesContract,
8
9
  getExecutionToolCallFailuresContract,
@@ -42,6 +43,17 @@ export function executionApi({ send, sendWith, ws, pwHeaders }: ApiContext) {
42
43
  body,
43
44
  }),
44
45
 
46
+ /**
47
+ * One run, WHOLE. The board snapshot serves a lean projection that withholds each step's
48
+ * captured prose, so a step-detail overlay fetches the run it is about through here before
49
+ * rendering. See `projectExecutionForBoard`.
50
+ */
51
+ getExecution: (workspaceId: string, executionId: string) =>
52
+ send(getExecutionContract, {
53
+ pathPrefix: ws(workspaceId),
54
+ pathParams: { executionId },
55
+ }),
56
+
45
57
  /**
46
58
  * Start ONE agent kind against a block — a run with no pipeline behind it (the service
47
59
  * frame's "Map service" action, the environment wizard's deep analysis). Gated on the