@cat-factory/app 0.283.0 → 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 (48) 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/TaskExecution.vue +3 -3
  10. package/app/components/pipeline/PipelineProgress.vue +7 -3
  11. package/app/composables/api/execution.ts +12 -0
  12. package/app/composables/useBlockDrag.ts +51 -5
  13. package/app/composables/useSingleFlight.spec.ts +42 -0
  14. package/app/composables/useSingleFlight.ts +37 -0
  15. package/app/composables/useStepApproval.ts +19 -0
  16. package/app/composables/useStepTimer.ts +70 -14
  17. package/app/composables/useUpsertList.spec.ts +73 -0
  18. package/app/composables/useUpsertList.ts +52 -6
  19. package/app/composables/useViewport.ts +13 -3
  20. package/app/stores/consensus.ts +8 -1
  21. package/app/stores/docInterview.ts +10 -1
  22. package/app/stores/execution/reconcile.ts +182 -0
  23. package/app/stores/execution/wholeRunReads.ts +139 -0
  24. package/app/stores/execution.spec.ts +297 -1
  25. package/app/stores/execution.ts +57 -110
  26. package/app/stores/kaizen.spec.ts +77 -14
  27. package/app/stores/kaizen.ts +75 -17
  28. package/app/stores/notifications.spec.ts +65 -0
  29. package/app/stores/notifications.ts +29 -0
  30. package/app/stores/observability/agentContext.ts +128 -0
  31. package/app/stores/observability/toolCalls.ts +30 -2
  32. package/app/stores/observability.spec.ts +98 -0
  33. package/app/stores/observability.ts +51 -79
  34. package/app/stores/requirements/settlement.ts +55 -0
  35. package/app/stores/requirements.ts +25 -23
  36. package/app/stores/workspace/hydrate.ts +11 -0
  37. package/app/stores/workspace/refreshFunnel.spec.ts +15 -1
  38. package/i18n/locales/de.json +4 -0
  39. package/i18n/locales/en.json +4 -0
  40. package/i18n/locales/es.json +4 -0
  41. package/i18n/locales/fr.json +4 -0
  42. package/i18n/locales/he.json +4 -0
  43. package/i18n/locales/it.json +4 -0
  44. package/i18n/locales/ja.json +4 -0
  45. package/i18n/locales/pl.json +4 -0
  46. package/i18n/locales/tr.json +4 -0
  47. package/i18n/locales/uk.json +4 -0
  48. 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,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
@@ -1,4 +1,5 @@
1
1
  import { ref } from 'vue'
2
+ import { tryOnScopeDispose } from '@vueuse/core'
2
3
  import type { Block } from '~/types/domain'
3
4
 
4
5
  // Only one block is ever dragged at a time, so the dragged id is a module-level
@@ -27,6 +28,18 @@ export function useBlockDrag() {
27
28
  const ui = useUiStore()
28
29
  const access = useWorkspaceAccess()
29
30
 
31
+ /**
32
+ * Tear down the in-flight drag's window listeners.
33
+ *
34
+ * They used to be removed inside `onUp` alone, which covers only the drag that ENDS. A touch
35
+ * interruption (an incoming call, a system gesture) fires `pointercancel` and no `pointerup`,
36
+ * and unmounting the dragging component fires neither, so both stranded a `pointermove` and a
37
+ * `pointerup` on `window` plus a `draggingId` that never cleared, leaving the card dimmed and
38
+ * every frame's z-index elevated for the rest of the session.
39
+ */
40
+ let endDrag: (() => void) | null = null
41
+ tryOnScopeDispose(() => endDrag?.())
42
+
30
43
  function startDrag(
31
44
  block: Block,
32
45
  e: PointerEvent,
@@ -65,9 +78,32 @@ export function useBlockDrag() {
65
78
  // undoes. The `draggingId` state the card dims itself with is the whole feedback.
66
79
  if (positioned) board.previewMove(block.id, last)
67
80
  }
81
+ // What is currently bound to `window`, so the teardown below needs no forward reference to
82
+ // the handlers that call it.
83
+ const bound: Array<[string, (ev: PointerEvent) => void]> = []
84
+ /**
85
+ * Stop listening and clear the drag state, WITHOUT committing anything. The shared exit for
86
+ * every way a drag ends: the drop commits first and then calls this, and a cancel (a
87
+ * `pointercancel`, or the component unmounting mid-drag) calls it alone.
88
+ */
89
+ const detach = () => {
90
+ for (const [type, handler] of bound) {
91
+ window.removeEventListener(type, handler as EventListener)
92
+ }
93
+ bound.length = 0
94
+ endDrag = null
95
+ draggingId.value = null
96
+ }
97
+ /**
98
+ * A drag the pointer never finished. Nothing is persisted, so the local preview has to go
99
+ * back where it started: leaving it would show a position the server does not hold and the
100
+ * next refresh would silently snap the block back.
101
+ */
102
+ const onCancel = () => {
103
+ if (moved && positioned) board.previewMove(block.id, orig)
104
+ detach()
105
+ }
68
106
  const onUp = (ev: PointerEvent) => {
69
- window.removeEventListener('pointermove', onMove)
70
- window.removeEventListener('pointerup', onUp)
71
107
  if (moved) {
72
108
  // A successful reparent persists the move itself; otherwise commit the final
73
109
  // position in place. Either way it's a single write, not one per frame. Run
@@ -76,10 +112,20 @@ export function useBlockDrag() {
76
112
  const reparented = opts.reparent && reparentAt(block, ev.clientX, ev.clientY, positioned)
77
113
  if (!reparented && positioned) void board.moveBlock(block.id, last)
78
114
  }
79
- draggingId.value = null
115
+ detach()
116
+ }
117
+ // A second drag can only start after the first released or cancelled, but a stale listener
118
+ // set would silently drive it; end whatever is still attached before attaching this one.
119
+ endDrag?.()
120
+ endDrag = onCancel
121
+ for (const binding of [
122
+ ['pointermove', onMove],
123
+ ['pointerup', onUp],
124
+ ['pointercancel', onCancel],
125
+ ] as Array<[string, (ev: PointerEvent) => void]>) {
126
+ bound.push(binding)
127
+ window.addEventListener(binding[0], binding[1] as EventListener)
80
128
  }
81
- window.addEventListener('pointermove', onMove)
82
- window.addEventListener('pointerup', onUp)
83
129
  }
84
130
 
85
131
  /** Returns true when the block was dropped into a *different* container. */
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { useSingleFlight } from '~/composables/useSingleFlight'
3
+
4
+ describe('useSingleFlight', () => {
5
+ it('joins concurrent callers of one key onto a single call', async () => {
6
+ const fn = vi.fn(() => Promise.resolve('answer'))
7
+ const flight = useSingleFlight<string, string>()
8
+
9
+ const [a, b] = await Promise.all([flight.run('k', fn), flight.run('k', fn)])
10
+ expect(fn).toHaveBeenCalledTimes(1)
11
+ expect([a, b]).toEqual(['answer', 'answer'])
12
+ })
13
+
14
+ it('keeps different keys apart', async () => {
15
+ const fn = vi.fn((k: string) => Promise.resolve(k))
16
+ const flight = useSingleFlight<string, string>()
17
+
18
+ await Promise.all([flight.run('a', () => fn('a')), flight.run('b', () => fn('b'))])
19
+ expect(fn).toHaveBeenCalledTimes(2)
20
+ })
21
+
22
+ it('coalesces rather than caches: a later call runs again', async () => {
23
+ const fn = vi.fn(() => Promise.resolve('answer'))
24
+ const flight = useSingleFlight<string, string>()
25
+
26
+ await flight.run('k', fn)
27
+ expect(flight.isRunning('k')).toBe(false)
28
+ await flight.run('k', fn)
29
+ expect(fn).toHaveBeenCalledTimes(2)
30
+ })
31
+
32
+ it('gives every joiner the same failure, and lets the next caller retry', async () => {
33
+ const fn = vi.fn(() => Promise.reject(new Error('boom')))
34
+ const flight = useSingleFlight<string, string>()
35
+
36
+ const results = await Promise.allSettled([flight.run('k', fn), flight.run('k', fn)])
37
+ expect(results.map((r) => r.status)).toEqual(['rejected', 'rejected'])
38
+ expect(fn).toHaveBeenCalledTimes(1)
39
+
40
+ await expect(flight.run('k', () => Promise.resolve('ok'))).resolves.toBe('ok')
41
+ })
42
+ })
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Coalesce concurrent calls that ask for the SAME thing onto one request.
3
+ *
4
+ * Panel loads are the case this exists for: two openers routinely fire in the same tick (a window
5
+ * and the shell it mounts in, a deep link plus the click that follows it, a tab restoring its own
6
+ * state), and each fired its own fetch of the heaviest read on that surface. The second answer is
7
+ * byte-for-byte the first, so the only thing the duplicate adds is load and a second chance to
8
+ * land out of order.
9
+ *
10
+ * The in-flight entry is dropped when the promise SETTLES, so a later call re-fetches: this
11
+ * coalesces concurrent work, it does not cache the answer. A rejection propagates to every joiner,
12
+ * which is what makes joining equivalent to having asked.
13
+ *
14
+ * It does NOT replace a store's load-ordering ticket. Coalescing removes the duplicates a single
15
+ * key can produce; a ticket settles which of two loads issued at different times may commit.
16
+ */
17
+ export function useSingleFlight<K, T>() {
18
+ const inFlight = new Map<K, Promise<T>>()
19
+
20
+ /** Run `fn` for `key`, or join the call already running for it. */
21
+ function run(key: K, fn: () => Promise<T>): Promise<T> {
22
+ const pending = inFlight.get(key)
23
+ if (pending) return pending
24
+ const promise = fn().finally(() => {
25
+ inFlight.delete(key)
26
+ })
27
+ inFlight.set(key, promise)
28
+ return promise
29
+ }
30
+
31
+ /** Whether a call for `key` is currently running. */
32
+ function isRunning(key: K): boolean {
33
+ return inFlight.has(key)
34
+ }
35
+
36
+ return { run, isRunning }
37
+ }