@cat-factory/app 0.282.2 → 0.284.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 (57) 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/panels/AgentStepDetail.vue +27 -1
  7. package/app/components/panels/ResultWindowShell.vue +19 -0
  8. package/app/components/panels/RunDetailLoadState.vue +41 -0
  9. package/app/components/panels/inspector/TaskEstimateBadge.vue +63 -7
  10. package/app/components/panels/inspector/TaskExecution.vue +3 -3
  11. package/app/components/pipeline/PipelineProgress.vue +7 -3
  12. package/app/composables/api/execution.ts +12 -0
  13. package/app/composables/useBlockDrag.ts +51 -5
  14. package/app/composables/usePipelineDraftWarnings.ts +6 -4
  15. package/app/composables/usePipelineHealth.spec.ts +13 -1
  16. package/app/composables/usePipelineHealth.ts +8 -9
  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/kaizen.spec.ts +77 -14
  31. package/app/stores/kaizen.ts +75 -17
  32. package/app/stores/notifications.spec.ts +65 -0
  33. package/app/stores/notifications.ts +29 -0
  34. package/app/stores/observability/agentContext.ts +128 -0
  35. package/app/stores/observability/toolCalls.ts +30 -2
  36. package/app/stores/observability.spec.ts +98 -0
  37. package/app/stores/observability.ts +51 -79
  38. package/app/stores/requirements/settlement.ts +55 -0
  39. package/app/stores/requirements.ts +25 -23
  40. package/app/stores/workspace/hydrate.ts +11 -0
  41. package/app/stores/workspace/refreshFunnel.spec.ts +15 -1
  42. package/app/utils/catalog.spec.ts +1 -0
  43. package/app/utils/catalog.ts +18 -0
  44. package/app/utils/estimateGating.spec.ts +22 -0
  45. package/app/utils/estimateGating.ts +32 -0
  46. package/app/utils/pipelineRender.ts +2 -0
  47. package/i18n/locales/de.json +14 -2
  48. package/i18n/locales/en.json +14 -2
  49. package/i18n/locales/es.json +14 -2
  50. package/i18n/locales/fr.json +14 -2
  51. package/i18n/locales/he.json +14 -2
  52. package/i18n/locales/it.json +14 -2
  53. package/i18n/locales/ja.json +14 -2
  54. package/i18n/locales/pl.json +14 -2
  55. package/i18n/locales/tr.json +14 -2
  56. package/i18n/locales/uk.json +14 -2
  57. package/package.json +2 -2
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
  />
@@ -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,9 +1,12 @@
1
1
  <script setup lang="ts">
2
- // Compact display of a task's estimator triage (Complexity / Risk / Impact), shown on the
3
- // inspector once a `task-estimator` step has run. Read-only produced by the estimator,
4
- // used to gate consensus steps. Hidden when no estimate exists.
2
+ // Compact display of a task's triage scores (Complexity / Risk / Impact), shown on the inspector
3
+ // once a step has produced them. Read-only: a `task-estimator` step FORECASTS them before the work
4
+ // starts and a `task-reassessor` step MEASURES them afterwards from the change that landed, so the
5
+ // section says which reading it is showing, names the reading it corrected, and shows the earlier
6
+ // number beside each axis that actually moved. Hidden when no estimate exists.
5
7
  import { computed } from 'vue'
6
8
  import type { Block } from '~/types/domain'
9
+ import { estimateBasisLabelKey } from '~/utils/estimateGating'
7
10
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
8
11
 
9
12
  const props = defineProps<{ block: Block }>()
@@ -11,14 +14,53 @@ const { t, n } = useI18n()
11
14
 
12
15
  const estimate = computed(() => props.block.estimate ?? null)
13
16
 
14
- const AXES = computed(
15
- () =>
17
+ /**
18
+ * The three axes with both readings, so the template asks nothing about which one to show.
19
+ *
20
+ * `was` is present only where the superseded reading DIFFERS. An axis whose score did not move
21
+ * renders "was 40% 40%", which reads as a correction that did not happen; and the interesting
22
+ * thing about a re-measurement is exactly which axes it moved.
23
+ */
24
+ const AXES = computed(() => {
25
+ const current = estimate.value
26
+ const prior = current?.supersedes ?? null
27
+ return (
16
28
  [
17
29
  { key: 'complexity', label: t('inspector.estimate.complexity') },
18
30
  { key: 'risk', label: t('inspector.estimate.risk') },
19
31
  { key: 'impact', label: t('inspector.estimate.impact') },
20
- ] as const,
21
- )
32
+ ] as const
33
+ ).map((axis) => ({
34
+ ...axis,
35
+ was:
36
+ prior && current && prior[axis.key] !== current[axis.key]
37
+ ? n(prior[axis.key], { key: 'percent' })
38
+ : null,
39
+ }))
40
+ })
41
+
42
+ /**
43
+ * What this reading is, in one line. Which key that is lives in `utils/estimateGating`, beside the
44
+ * rest of the estimate presentation vocabulary and under test: `basis` is PERSISTED and read back
45
+ * without a schema pass, so absent, known and unrecognised are three distinct answers.
46
+ */
47
+ const basisLabel = computed(() => t(estimateBasisLabelKey(estimate.value?.basis)))
48
+
49
+ /**
50
+ * What the current reading REPLACED, named by ITS basis rather than left implicit.
51
+ *
52
+ * A "was 80%" chip beside a header reading "Forecast before the work started" says the earlier
53
+ * number was an earlier forecast, and after a re-run of the estimator on a measured task that is
54
+ * the wrong way round: the superseded reading is the MEASUREMENT. The backend summary prefixes the
55
+ * same movement with the same label for the same reason.
56
+ */
57
+ const supersededLabel = computed(() => {
58
+ const prior = estimate.value?.supersedes
59
+ if (!prior) return null
60
+ return t('inspector.estimate.supersededBasis', {
61
+ basis: t(estimateBasisLabelKey(prior.basis)),
62
+ })
63
+ })
22
64
 
23
65
  /** Cool→hot bar colour by severity (low = sky, mid = amber, high = rose). */
24
66
  function barClass(n: number): string {
@@ -37,6 +79,14 @@ function barClass(n: number): string {
37
79
  default-open
38
80
  >
39
81
  <div class="space-y-1.5 rounded-lg border border-slate-800 bg-slate-900/40 p-2.5">
82
+ <p class="text-[11px] text-slate-500" data-testid="task-estimate-basis">{{ basisLabel }}</p>
83
+ <p
84
+ v-if="supersededLabel"
85
+ class="text-[11px] text-slate-500"
86
+ data-testid="task-estimate-superseded"
87
+ >
88
+ {{ supersededLabel }}
89
+ </p>
40
90
  <div v-for="axis in AXES" :key="axis.key" class="flex items-center gap-2">
41
91
  <span class="w-20 shrink-0 text-xs text-slate-400">{{ axis.label }}</span>
42
92
  <div class="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-800">
@@ -46,6 +96,12 @@ function barClass(n: number): string {
46
96
  :style="{ width: `${Math.round(estimate[axis.key] * 100)}%` }"
47
97
  />
48
98
  </div>
99
+ <span
100
+ v-if="axis.was"
101
+ class="shrink-0 text-[11px] tabular-nums text-slate-500"
102
+ :data-testid="`task-estimate-was-${axis.key}`"
103
+ >{{ t('inspector.estimate.was', { value: axis.was }) }}</span
104
+ >
49
105
  <span class="w-9 shrink-0 text-end text-xs tabular-nums text-slate-300">{{
50
106
  n(estimate[axis.key], { key: 'percent' })
51
107
  }}</span>
@@ -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>
@@ -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