@cat-factory/app 0.280.0 → 0.280.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -332,9 +332,15 @@ const showOriginalDescription = ref(false)
332
332
  with the panel on top; the region now paints above at `z-40`, which only swaps which side
333
333
  loses). Sitting the panel below fixes it whichever way the stacking goes, and needs no
334
334
  left/right arithmetic to stay correct under RTL. -->
335
+ <!-- `data-inspector-block` names WHICH block the panel is showing, so a caller can tell a
336
+ panel left open on the previous selection from one that followed a new click. Deliberately
337
+ NOT `data-block-id`: that attribute is the board's card selector, which the canvas drivers
338
+ measure geometry through (`utils/blockRects.ts`), and a panel answering to it would offer
339
+ the arrows a rect that is not on the canvas at all. -->
335
340
  <div
336
341
  v-if="block && statusMeta && typeMeta"
337
342
  data-testid="inspector-panel"
343
+ :data-inspector-block="block.id"
338
344
  class="fixed inset-x-0 bottom-0 z-20 overflow-hidden rounded-t-2xl border border-slate-700 bg-slate-900/95 shadow-2xl backdrop-blur lg:absolute lg:inset-x-auto lg:bottom-auto lg:end-4 lg:top-16 lg:w-80 lg:rounded-2xl"
339
345
  >
340
346
  <div class="h-1.5 w-full" :style="{ backgroundColor: statusMeta.color }" />
@@ -1,4 +1,5 @@
1
1
  import { inject, onBeforeUnmount, onMounted, provide, type InjectionKey, type Ref } from 'vue'
2
+ import { createWakeGate } from '~/utils/boardWakeGate'
2
3
 
3
4
  /**
4
5
  * The board's shared "something may have moved" pulse.
@@ -16,9 +17,13 @@ import { inject, onBeforeUnmount, onMounted, provide, type InjectionKey, type Re
16
17
  * - a `MutationObserver` over the canvas subtree, watching structure plus `style` / `class`.
17
18
  * That is every Vue-driven render change on the board, Vue Flow's own pan/zoom transform
18
19
  * included. Attribute changes the drivers themselves write (`x1`/`y1` on the edge overlay)
19
- * are outside the filter, so a driver cannot pulse itself awake forever.
20
+ * are outside the filter, so a driver cannot pulse itself awake forever. These wakes are
21
+ * RATE-LIMITED (see `boardWakeGate`): a live board re-renders its cards on every execution
22
+ * event, and admitting each one kept the measuring loops from ever parking on exactly the
23
+ * board where measuring costs the most. The gesture and camera signals below are admitted
24
+ * unthrottled, so nothing the user is actually moving waits on an interval.
20
25
  * - a `ResizeObserver` on the canvas, plus window `resize`: layout changes with no mutation.
21
- * - pointer, wheel and scroll gestures on the canvas: the user moving something.
26
+ * - pointer, wheel and scroll gestures, listened for on the WINDOW: the user moving something.
22
27
  *
23
28
  * What it does NOT catch is a reflow with no mutation and no gesture, such as a late-loading
24
29
  * image or font resizing a card. Those settle on the next pulse of any kind.
@@ -28,6 +33,14 @@ export type BoardActivity = {
28
33
  subscribe: (onPulse: () => void) => () => void
29
34
  /** Fire the pulse from a signal the observers above cannot see. */
30
35
  pulse: () => void
36
+ /**
37
+ * Where the pointer last was over the canvas (viewport coordinates), or null once it left.
38
+ *
39
+ * Owned here because the pulse already listens for the same gestures: a driver that wants the
40
+ * position registered a SECOND `pointermove` listener on the same element to learn what this
41
+ * one had just seen. Read inside a measurement pass, never subscribed to.
42
+ */
43
+ pointer: () => { x: number; y: number } | null
31
44
  }
32
45
 
33
46
  const boardActivityKey: InjectionKey<BoardActivity> = Symbol('boardActivity')
@@ -42,19 +55,62 @@ export function provideBoardActivity(container: Ref<HTMLElement | null>): BoardA
42
55
  for (const onPulse of subscribers) onPulse()
43
56
  }
44
57
 
58
+ // Renders reach the pulse through the gate; everything the user is moving goes straight to it.
59
+ const renderWakes = createWakeGate({
60
+ wake: pulse,
61
+ // `window.setTimeout` rather than the bare global: the DOM overload returns the numeric
62
+ // handle the gate's scheduler is typed on, where Node's returns a `Timeout` object.
63
+ scheduler: {
64
+ schedule: (run, delayMs) => window.setTimeout(run, delayMs),
65
+ cancel: (handle) => window.clearTimeout(handle),
66
+ },
67
+ })
68
+
69
+ let pointer: { x: number; y: number } | null = null
70
+
71
+ /**
72
+ * Track the pointer and pulse, in that order, off the SAME listener.
73
+ *
74
+ * `pointerleave` does not bubble, but a CAPTURE-phase listener sees one fired at any element
75
+ * below it, and the pointer moving from a card onto the canvas around it is exactly that
76
+ * event. So only the canvas's OWN leave clears the position; treating a descendant's as "the
77
+ * pointer is gone" would collapse the hovered card the moment the pointer crossed one of its
78
+ * inner elements. That check is on the TARGET, so it reads the same from the window as it did
79
+ * from the canvas.
80
+ */
81
+ const onGesture = (event: Event) => {
82
+ if (event.type === 'pointerleave') {
83
+ if (event.target === container.value) pointer = null
84
+ } else if (event.type === 'pointermove' || event.type === 'pointerdown') {
85
+ const { clientX, clientY } = event as PointerEvent
86
+ pointer = { x: clientX, y: clientY }
87
+ }
88
+ pulse()
89
+ }
90
+
45
91
  const activity: BoardActivity = {
46
92
  subscribe(onPulse) {
47
93
  subscribers.add(onPulse)
48
94
  return () => subscribers.delete(onPulse)
49
95
  },
50
96
  pulse,
97
+ pointer: () => pointer,
51
98
  }
52
99
  provide(boardActivityKey, activity)
53
100
 
54
- const mutations = new MutationObserver(pulse)
101
+ const mutations = new MutationObserver(renderWakes.request)
55
102
  const resizes = new ResizeObserver(pulse)
56
103
  // `scroll` does not bubble, so it is caught in the capture phase; the gestures are
57
104
  // passive listeners because the pulse never wants to cancel one.
105
+ //
106
+ // They are bound to the WINDOW rather than to the canvas, because a drag does not end at the
107
+ // canvas's edge: `useBlockDrag` tracks the pointer on the window precisely so a card keeps
108
+ // following it, and the toolbar region and the inspector are SIBLINGS painted over the canvas,
109
+ // not descendants of it. Bound to the canvas, a drag whose cursor crossed one of them stopped
110
+ // delivering the gesture that keeps the measuring loops awake, and the arrows fell back to the
111
+ // rate-limited mutation wake for as long as the cursor was over it: a visible lag in the one
112
+ // interaction this pulse exists to keep smooth. Capture on the window sees every one of those
113
+ // events wherever it is dispatched, so nothing else about the handler changes.
58
114
  const gestures = [
59
115
  'pointerdown',
60
116
  'pointermove',
@@ -77,15 +133,15 @@ export function provideBoardActivity(container: Ref<HTMLElement | null>): BoardA
77
133
  attributeFilter: ['style', 'class'],
78
134
  })
79
135
  resizes.observe(el)
80
- for (const type of gestures) el.addEventListener(type, pulse, gestureOptions)
136
+ for (const type of gestures) window.addEventListener(type, onGesture, gestureOptions)
81
137
  window.addEventListener('resize', pulse)
82
138
  })
83
139
 
84
140
  onBeforeUnmount(() => {
85
141
  mutations.disconnect()
86
142
  resizes.disconnect()
87
- const el = container.value
88
- for (const type of gestures) el?.removeEventListener(type, pulse, gestureOptions)
143
+ renderWakes.cancel()
144
+ for (const type of gestures) window.removeEventListener(type, onGesture, gestureOptions)
89
145
  window.removeEventListener('resize', pulse)
90
146
  subscribers.clear()
91
147
  })
@@ -3,6 +3,7 @@ import { onMounted, onBeforeUnmount } from 'vue'
3
3
  import { lodAtLeast } from '~/composables/useSemanticZoom'
4
4
  import { onBoardActivity, type BoardActivity } from '~/composables/useBoardActivity'
5
5
  import { useSettlingRaf } from '~/composables/useSettlingRaf'
6
+ import { measureBlocks, type BlockMeasurements } from '~/utils/blockRects'
6
7
  import { headerDistanceSq, type Rect } from '~/utils/taskExpansionRanking'
7
8
 
8
9
  function intersects(a: Rect, b: Rect) {
@@ -54,21 +55,6 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>, activity: B
54
55
  // card is still tested at its expanded extent and stays denied. Stable.
55
56
  const expandedHeight = new Map<string, number>()
56
57
 
57
- // Last pointer position over the board (viewport coords), or null when the pointer has
58
- // left it. The card under the pointer is expanded on hover (see `hoveredTaskId`).
59
- let pointer: { x: number; y: number } | null = null
60
- function onPointerMove(e: PointerEvent) {
61
- pointer = { x: e.clientX, y: e.clientY }
62
- }
63
- function onPointerLeave() {
64
- pointer = null
65
- }
66
-
67
- function rectOf(id: string): DOMRect | null {
68
- const el = document.querySelector(`[data-block-id="${id}"]`) as HTMLElement | null
69
- return el ? el.getBoundingClientRect() : null
70
- }
71
-
72
58
  // The task whose card is topmost at the pointer, or null. Using elementFromPoint (not a
73
59
  // rect test) means an open pipeline stacked above a neighbour wins the hit, so hovering
74
60
  // a region obscured by another pipeline doesn't switch to the card hidden beneath it.
@@ -77,6 +63,9 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>, activity: B
77
63
  // a frame, a module, or a task with no run expands to nothing, and granting it would
78
64
  // still lift an empty card over its neighbours (see LaneTask's z-index).
79
65
  function hoveredTaskId(): string | null {
66
+ // Where the pointer is comes from the pulse, which already listens for the same gestures on
67
+ // the same element (see `BoardActivity.pointer`).
68
+ const pointer = activity.pointer()
80
69
  if (!pointer) return null
81
70
  const hit = document.elementFromPoint(pointer.x, pointer.y)
82
71
  const id = hit?.closest('[data-block-id]')?.getAttribute('data-block-id') ?? null
@@ -107,6 +96,8 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>, activity: B
107
96
  }
108
97
  const view = container.value?.getBoundingClientRect()
109
98
  if (!view) return changed
99
+ // One DOM query for the whole sweep instead of one per candidate task (see `measureBlocks`).
100
+ const blocks: BlockMeasurements = measureBlocks()
110
101
  const cx = view.left + view.width / 2
111
102
  const cy = view.top + view.height / 2
112
103
 
@@ -115,8 +106,9 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>, activity: B
115
106
  for (const t of board.allTasks) {
116
107
  // Only tasks whose run actually has steps would expand a pipeline list.
117
108
  if (!execution.getByBlock(t.id)?.steps.length) continue
118
- const rect = rectOf(t.id)
119
- if (!rect) continue
109
+ const el = blocks.elementFor(t.id)
110
+ if (!el) continue
111
+ const rect = blocks.rectFor(el)
120
112
  liveIds.add(t.id)
121
113
  // While a card is granted it's rendered expanded, so its live height is its
122
114
  // expanded footprint — cache it. A denied card keeps its last cached value.
@@ -166,19 +158,12 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>, activity: B
166
158
  }
167
159
 
168
160
  const { poke } = useSettlingRaf(recompute)
169
- // The pointer listeners below only record where the pointer IS; the pulse (which watches the
170
- // same gestures) is what schedules the frame that acts on it.
161
+ // The pulse both records where the pointer is and schedules the frame that acts on it.
171
162
  onBoardActivity(activity, poke)
172
163
  onMounted(() => {
173
164
  store.setDriverActive(true)
174
- const el = container.value
175
- el?.addEventListener('pointermove', onPointerMove)
176
- el?.addEventListener('pointerleave', onPointerLeave)
177
165
  })
178
166
  onBeforeUnmount(() => {
179
- const el = container.value
180
- el?.removeEventListener('pointermove', onPointerMove)
181
- el?.removeEventListener('pointerleave', onPointerLeave)
182
167
  store.setDriverActive(false)
183
168
  })
184
169
  }
@@ -102,6 +102,15 @@ An unpaired id degrades to the generic prose panel (a dev-console warning names
102
102
  id); a structured kind with no bespoke window gets the built-in `generic-structured` viewer
103
103
  for free.
104
104
 
105
+ **Contribute the component asynchronously.** Every first-party window is registered as
106
+ `defineAsyncView(() => import('...'))`, so its code is fetched on the click that opens it rather
107
+ than on every board load; an async component is an ordinary `Component`, so the slot entry and the
108
+ host's mount are identical either way. A window is a modal opened deliberately, so copy that shape
109
+ unless yours is small enough not to matter. Prefer the seam over a bare `defineAsyncComponent`: it
110
+ attaches the shared failure notice, so a chunk that 404s (which is what a deploy landing under an
111
+ open tab makes of every chunk the session had not yet fetched) states itself instead of opening
112
+ your window onto a blank screen.
113
+
105
114
  ### Navigation
106
115
 
107
116
  A consumer nav item carries its own `run` closure (first-party items use a typed `action`
@@ -1,28 +1,65 @@
1
1
  import type { Component } from 'vue'
2
2
  import { defineModule } from '@modular-vue/core'
3
3
  import { RESULT_VIEW_IDS, type ResultViewId } from '@cat-factory/contracts'
4
- import OutcomeSummaryWindow from '~/components/outcome/OutcomeSummaryWindow.vue'
5
- import RequirementsReviewWindow from '~/components/requirements/RequirementsReviewWindow.vue'
6
- import ClarityReviewWindow from '~/components/clarity/ClarityReviewWindow.vue'
7
- import BrainstormWindow from '~/components/brainstorm/BrainstormWindow.vue'
8
- import TestReportWindow from '~/components/testing/TestReportWindow.vue'
9
- import HumanTestWindow from '~/components/humanTest/HumanTestWindow.vue'
10
- import VisualConfirmationWindow from '~/components/visualConfirm/VisualConfirmationWindow.vue'
11
- import GateResultView from '~/components/gates/GateResultView.vue'
12
- import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindow.vue'
13
- import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
14
- import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
15
- import FollowUpWindow from '~/components/followUp/FollowUpWindow.vue'
16
- import BinaryCandidatesWindow from '~/components/binaryCandidates/BinaryCandidatesWindow.vue'
17
- import ForkDecisionWindow from '~/components/forkDecision/ForkDecisionWindow.vue'
18
- import PrReviewWindow from '~/components/prReview/PrReviewWindow.vue'
19
- import MergerResultView from '~/components/panels/MergerResultView.vue'
20
- import InitiativeTrackerWindow from '~/components/initiative/InitiativeTrackerWindow.vue'
21
- import InitiativePlanningWindow from '~/components/initiative/InitiativePlanningWindow.vue'
22
- import DocInterviewWindow from '~/components/docs/DocInterviewWindow.vue'
23
- import RalphLoopResultView from '~/components/ralph/RalphLoopResultView.vue'
24
- import JudgeResultView from '~/components/judge/JudgeResultView.vue'
25
4
  import type { ResultViewContribution } from './slots'
5
+ import { defineAsyncView } from '~/utils/asyncView'
6
+
7
+ /**
8
+ * Every built-in window is CODE-SPLIT: a result window opens on a deliberate click (an agent
9
+ * step, a gate, an outcome card), and there are more than twenty of them, so importing the
10
+ * catalog statically put all of them plus their dependencies (markdown-it, the review windows'
11
+ * prose readers) into the initial bundle of a board that may open none. `defineAsyncView`
12
+ * returns an ordinary `Component`, so the slot entry, the exhaustiveness check below and
13
+ * `StepResultViewHost`'s `<component :is>` mount are unchanged; only the fetch moves to the
14
+ * open. It wraps `defineAsyncComponent` with the shared failure notice, so a window whose chunk
15
+ * 404s after a deploy says so rather than opening onto nothing. A consumer window may be
16
+ * contributed either way, and should copy this one.
17
+ */
18
+ const OutcomeSummaryWindow = defineAsyncView(
19
+ () => import('~/components/outcome/OutcomeSummaryWindow.vue'),
20
+ )
21
+ const RequirementsReviewWindow = defineAsyncView(
22
+ () => import('~/components/requirements/RequirementsReviewWindow.vue'),
23
+ )
24
+ const ClarityReviewWindow = defineAsyncView(
25
+ () => import('~/components/clarity/ClarityReviewWindow.vue'),
26
+ )
27
+ const BrainstormWindow = defineAsyncView(
28
+ () => import('~/components/brainstorm/BrainstormWindow.vue'),
29
+ )
30
+ const TestReportWindow = defineAsyncView(() => import('~/components/testing/TestReportWindow.vue'))
31
+ const HumanTestWindow = defineAsyncView(() => import('~/components/humanTest/HumanTestWindow.vue'))
32
+ const VisualConfirmationWindow = defineAsyncView(
33
+ () => import('~/components/visualConfirm/VisualConfirmationWindow.vue'),
34
+ )
35
+ const GateResultView = defineAsyncView(() => import('~/components/gates/GateResultView.vue'))
36
+ const ConsensusSessionWindow = defineAsyncView(
37
+ () => import('~/components/consensus/ConsensusSessionWindow.vue'),
38
+ )
39
+ const GenericStructuredResultView = defineAsyncView(
40
+ () => import('~/components/panels/GenericStructuredResultView.vue'),
41
+ )
42
+ const ServiceSpecWindow = defineAsyncView(() => import('~/components/spec/ServiceSpecWindow.vue'))
43
+ const FollowUpWindow = defineAsyncView(() => import('~/components/followUp/FollowUpWindow.vue'))
44
+ const BinaryCandidatesWindow = defineAsyncView(
45
+ () => import('~/components/binaryCandidates/BinaryCandidatesWindow.vue'),
46
+ )
47
+ const ForkDecisionWindow = defineAsyncView(
48
+ () => import('~/components/forkDecision/ForkDecisionWindow.vue'),
49
+ )
50
+ const PrReviewWindow = defineAsyncView(() => import('~/components/prReview/PrReviewWindow.vue'))
51
+ const MergerResultView = defineAsyncView(() => import('~/components/panels/MergerResultView.vue'))
52
+ const InitiativeTrackerWindow = defineAsyncView(
53
+ () => import('~/components/initiative/InitiativeTrackerWindow.vue'),
54
+ )
55
+ const InitiativePlanningWindow = defineAsyncView(
56
+ () => import('~/components/initiative/InitiativePlanningWindow.vue'),
57
+ )
58
+ const DocInterviewWindow = defineAsyncView(() => import('~/components/docs/DocInterviewWindow.vue'))
59
+ const RalphLoopResultView = defineAsyncView(
60
+ () => import('~/components/ralph/RalphLoopResultView.vue'),
61
+ )
62
+ const JudgeResultView = defineAsyncView(() => import('~/components/judge/JudgeResultView.vue'))
26
63
 
27
64
  /**
28
65
  * The first-party result-view registry (slice 2 of the modular-vue adoption —
@@ -8,7 +8,6 @@ import TranslationWarningBanner from '~/components/layout/TranslationWarningBann
8
8
  import PipelineBuilder from '~/components/pipeline/PipelineBuilder.vue'
9
9
  import InspectorPanel from '~/components/panels/InspectorPanel.vue'
10
10
  import DecisionModal from '~/components/panels/DecisionModal.vue'
11
- import AgentStepDetail from '~/components/panels/AgentStepDetail.vue'
12
11
  import StepResultViewHost from '~/components/panels/StepResultViewHost.vue'
13
12
  import AppOverlayHost from '~/components/panels/AppOverlayHost.vue'
14
13
  import AddTaskModal from '~/components/board/AddTaskModal.vue'
@@ -18,126 +17,127 @@ import GitHubOnboarding from '~/components/github/GitHubOnboarding.vue'
18
17
  import CommandBar from '~/components/layout/CommandBar.vue'
19
18
  import PersonalCredentialModal from '~/components/providers/PersonalCredentialModal.vue'
20
19
  import ConfirmDialog from '~/components/common/ConfirmDialog.vue'
20
+ import { defineAsyncView } from '~/utils/asyncView'
21
21
  import KeyboardShortcutsHelp from '~/components/common/KeyboardShortcutsHelp.vue'
22
22
 
23
- // Heavy, rarely-open panels code-split into their own chunks via defineAsyncComponent
24
- // and mounted only while their ui open-flag is set (the v-if gates in the template), so
25
- // they stay out of the initial bundle and don't run setup/watchers while closed.
26
- const ObservabilityPanel = defineAsyncComponent(
23
+ // The step-detail reader, mounted only while a step is open. It is the one always-mounted
24
+ // surface whose chunk dominated the initial bundle: its prose reader pulls markdown-it (~200 kB
25
+ // raw of the ~2.1 MB eager graph) purely to render an agent's output, which nothing can be
26
+ // reading until a step has been opened. Store-gated like the panels below, so the reader loads
27
+ // on the click that needs it.
28
+ const AgentStepDetail = defineAsyncView(() => import('~/components/panels/AgentStepDetail.vue'))
29
+
30
+ // Heavy, rarely-open panels, code-split into their own chunks via `defineAsyncView` and
31
+ // mounted only while their ui open-flag is set (the v-if gates in the template), so they stay
32
+ // out of the initial bundle and don't run setup/watchers while closed. The seam (rather than a
33
+ // bare `defineAsyncComponent`) is what makes a chunk that 404s after a deploy say so.
34
+ const ObservabilityPanel = defineAsyncView(
27
35
  () => import('~/components/panels/ObservabilityPanel.vue'),
28
36
  )
29
- const OperatorDashboardPanel = defineAsyncComponent(
37
+ const OperatorDashboardPanel = defineAsyncView(
30
38
  () => import('~/components/panels/OperatorDashboardPanel.vue'),
31
39
  )
32
- const ReportsPanel = defineAsyncComponent(() => import('~/components/panels/ReportsPanel.vue'))
33
- const KaizenPanel = defineAsyncComponent(() => import('~/components/kaizen/KaizenPanel.vue'))
40
+ const ReportsPanel = defineAsyncView(() => import('~/components/panels/ReportsPanel.vue'))
41
+ const KaizenPanel = defineAsyncView(() => import('~/components/kaizen/KaizenPanel.vue'))
34
42
  // Occasional, externally store-gated surfaces — deferred to their own chunks like the
35
43
  // sibling document modals above. Each mounts only while its ui open-flag is set, so it
36
44
  // loads on first open instead of bloating the initial bundle.
37
- const BlockFocusView = defineAsyncComponent(() => import('~/components/focus/BlockFocusView.vue'))
38
- const TaskSourceConnectModal = defineAsyncComponent(
45
+ const BlockFocusView = defineAsyncView(() => import('~/components/focus/BlockFocusView.vue'))
46
+ const TaskSourceConnectModal = defineAsyncView(
39
47
  () => import('~/components/tasks/TaskSourceConnectModal.vue'),
40
48
  )
41
- const TaskImportModal = defineAsyncComponent(() => import('~/components/tasks/TaskImportModal.vue'))
42
- const BugHuntModal = defineAsyncComponent(() => import('~/components/tasks/BugHuntModal.vue'))
43
- const RecurringPipelineModal = defineAsyncComponent(
49
+ const TaskImportModal = defineAsyncView(() => import('~/components/tasks/TaskImportModal.vue'))
50
+ const BugHuntModal = defineAsyncView(() => import('~/components/tasks/BugHuntModal.vue'))
51
+ const RecurringPipelineModal = defineAsyncView(
44
52
  () => import('~/components/board/RecurringPipelineModal.vue'),
45
53
  )
46
- const DocumentSourceConnectModal = defineAsyncComponent(
54
+ const DocumentSourceConnectModal = defineAsyncView(
47
55
  () => import('~/components/documents/DocumentSourceConnectModal.vue'),
48
56
  )
49
- const DocumentImportModal = defineAsyncComponent(
57
+ const DocumentImportModal = defineAsyncView(
50
58
  () => import('~/components/documents/DocumentImportModal.vue'),
51
59
  )
52
- const DocumentTemplatesModal = defineAsyncComponent(
60
+ const DocumentTemplatesModal = defineAsyncView(
53
61
  () => import('~/components/documents/DocumentTemplatesModal.vue'),
54
62
  )
55
- const SpawnPreviewModal = defineAsyncComponent(
63
+ const SpawnPreviewModal = defineAsyncView(
56
64
  () => import('~/components/documents/SpawnPreviewModal.vue'),
57
65
  )
58
- const StartFromDesignModal = defineAsyncComponent(
66
+ const StartFromDesignModal = defineAsyncView(
59
67
  () => import('~/components/documents/StartFromDesignModal.vue'),
60
68
  )
61
- const BootstrapModal = defineAsyncComponent(
62
- () => import('~/components/bootstrap/BootstrapModal.vue'),
63
- )
64
- const AddServiceFromRepoModal = defineAsyncComponent(
69
+ const BootstrapModal = defineAsyncView(() => import('~/components/bootstrap/BootstrapModal.vue'))
70
+ const AddServiceFromRepoModal = defineAsyncView(
65
71
  () => import('~/components/github/AddServiceFromRepoModal.vue'),
66
72
  )
67
- const GitHubPanel = defineAsyncComponent(() => import('~/components/github/GitHubPanel.vue'))
68
- const SlackPanel = defineAsyncComponent(() => import('~/components/slack/SlackPanel.vue'))
69
- const NotificationSettingsPanel = defineAsyncComponent(
73
+ const GitHubPanel = defineAsyncView(() => import('~/components/github/GitHubPanel.vue'))
74
+ const SlackPanel = defineAsyncView(() => import('~/components/slack/SlackPanel.vue'))
75
+ const NotificationSettingsPanel = defineAsyncView(
70
76
  () => import('~/components/notifications/NotificationSettingsPanel.vue'),
71
77
  )
72
- const FragmentLibraryPanel = defineAsyncComponent(
78
+ const FragmentLibraryPanel = defineAsyncView(
73
79
  () => import('~/components/fragments/FragmentLibraryPanel.vue'),
74
80
  )
75
- const FoundationalServicePanel = defineAsyncComponent(
81
+ const FoundationalServicePanel = defineAsyncView(
76
82
  () => import('~/components/foundational/FoundationalServicePanel.vue'),
77
83
  )
78
84
  // Startup advisory for invalid / outdated pipelines — only mounted while open (auto-opened
79
85
  // at most once per session by the watcher below), so it stays out of the initial bundle.
80
- const PipelineHealthModal = defineAsyncComponent(
86
+ const PipelineHealthModal = defineAsyncView(
81
87
  () => import('~/components/pipeline/PipelineHealthModal.vue'),
82
88
  )
83
89
  // Startup advisory for new / outdated built-in merge presets — same once-per-session pattern.
84
- const RiskPolicyHealthModal = defineAsyncComponent(
90
+ const RiskPolicyHealthModal = defineAsyncView(
85
91
  () => import('~/components/settings/RiskPolicyHealthModal.vue'),
86
92
  )
87
93
  // Startup advisory for new / outdated built-in model presets — same once-per-session pattern.
88
- const ModelPresetHealthModal = defineAsyncComponent(
94
+ const ModelPresetHealthModal = defineAsyncView(
89
95
  () => import('~/components/settings/ModelPresetHealthModal.vue'),
90
96
  )
91
- const IntegrationsHub = defineAsyncComponent(
92
- () => import('~/components/layout/IntegrationsHub.vue'),
93
- )
94
- const ModelProvidersHub = defineAsyncComponent(
95
- () => import('~/components/layout/ModelProvidersHub.vue'),
96
- )
97
- const PersonalSetupModal = defineAsyncComponent(
97
+ const IntegrationsHub = defineAsyncView(() => import('~/components/layout/IntegrationsHub.vue'))
98
+ const ModelProvidersHub = defineAsyncView(() => import('~/components/layout/ModelProvidersHub.vue'))
99
+ const PersonalSetupModal = defineAsyncView(
98
100
  () => import('~/components/layout/PersonalSetupModal.vue'),
99
101
  )
100
- const WorkspaceSettingsPanel = defineAsyncComponent(
102
+ const WorkspaceSettingsPanel = defineAsyncView(
101
103
  () => import('~/components/settings/WorkspaceSettingsPanel.vue'),
102
104
  )
103
- const AccountSettingsPanel = defineAsyncComponent(
105
+ const AccountSettingsPanel = defineAsyncView(
104
106
  () => import('~/components/settings/AccountSettingsPanel.vue'),
105
107
  )
106
- const ObservabilityConnectionPanel = defineAsyncComponent(
108
+ const ObservabilityConnectionPanel = defineAsyncView(
107
109
  () => import('~/components/settings/ObservabilityConnectionPanel.vue'),
108
110
  )
109
- const PackageRegistriesPanel = defineAsyncComponent(
111
+ const PackageRegistriesPanel = defineAsyncView(
110
112
  () => import('~/components/settings/PackageRegistriesPanel.vue'),
111
113
  )
112
- const ApiTokensPanel = defineAsyncComponent(
113
- () => import('~/components/settings/ApiTokensPanel.vue'),
114
- )
115
- const InfrastructureWindow = defineAsyncComponent(
114
+ const ApiTokensPanel = defineAsyncView(() => import('~/components/settings/ApiTokensPanel.vue'))
115
+ const InfrastructureWindow = defineAsyncView(
116
116
  () => import('~/components/settings/InfrastructureWindow.vue'),
117
117
  )
118
- const EnvironmentSetupWizard = defineAsyncComponent(
118
+ const EnvironmentSetupWizard = defineAsyncView(
119
119
  () => import('~/components/environments/EnvironmentSetupWizard.vue'),
120
120
  )
121
- const ModelConfigurationPanel = defineAsyncComponent(
121
+ const ModelConfigurationPanel = defineAsyncView(
122
122
  () => import('~/components/settings/ModelConfigurationPanel.vue'),
123
123
  )
124
- const LocalModelEndpointsPanel = defineAsyncComponent(
124
+ const LocalModelEndpointsPanel = defineAsyncView(
125
125
  () => import('~/components/settings/LocalModelEndpointsPanel.vue'),
126
126
  )
127
- const SandboxPanel = defineAsyncComponent(() => import('~/components/sandbox/SandboxPanel.vue'))
128
- const UserSecretsSection = defineAsyncComponent(
127
+ const SandboxPanel = defineAsyncView(() => import('~/components/sandbox/SandboxPanel.vue'))
128
+ const UserSecretsSection = defineAsyncView(
129
129
  () => import('~/components/settings/UserSecretsSection.vue'),
130
130
  )
131
- const OpenRouterCatalogPanel = defineAsyncComponent(
131
+ const OpenRouterCatalogPanel = defineAsyncView(
132
132
  () => import('~/components/settings/OpenRouterCatalogPanel.vue'),
133
133
  )
134
- const VendorCredentialsModal = defineAsyncComponent(
134
+ const VendorCredentialsModal = defineAsyncView(
135
135
  () => import('~/components/providers/VendorCredentialsModal.vue'),
136
136
  )
137
- const AiProviderOnboardingModal = defineAsyncComponent(
137
+ const AiProviderOnboardingModal = defineAsyncView(
138
138
  () => import('~/components/providers/AiProviderOnboardingModal.vue'),
139
139
  )
140
- const AiPresetMismatchDialog = defineAsyncComponent(
140
+ const AiPresetMismatchDialog = defineAsyncView(
141
141
  () => import('~/components/providers/AiPresetMismatchDialog.vue'),
142
142
  )
143
143
  // The in-app tutorial: the launch prompt (auto-opened once for a user who never answered
@@ -145,19 +145,15 @@ const AiPresetMismatchDialog = defineAsyncComponent(
145
145
  // section or the palette, at any time), the coach-mark overlay that runs a tour, and the
146
146
  // contextual offer that raises the ONE walkthrough this board just made takeable. All
147
147
  // mount only while their store flag is set, so they cost the initial bundle nothing.
148
- const TutorialPrompt = defineAsyncComponent(
149
- () => import('~/components/tutorial/TutorialPrompt.vue'),
150
- )
151
- const TutorialCatalogue = defineAsyncComponent(
148
+ const TutorialPrompt = defineAsyncView(() => import('~/components/tutorial/TutorialPrompt.vue'))
149
+ const TutorialCatalogue = defineAsyncView(
152
150
  () => import('~/components/tutorial/TutorialCatalogue.vue'),
153
151
  )
154
- const TutorialOverlay = defineAsyncComponent(
155
- () => import('~/components/tutorial/TutorialOverlay.vue'),
156
- )
157
- const TutorialNudge = defineAsyncComponent(() => import('~/components/tutorial/TutorialNudge.vue'))
152
+ const TutorialOverlay = defineAsyncView(() => import('~/components/tutorial/TutorialOverlay.vue'))
153
+ const TutorialNudge = defineAsyncView(() => import('~/components/tutorial/TutorialNudge.vue'))
158
154
  // The first-run role question (Engineer / Product manager / Designer). Same shape as the tutorial
159
155
  // launch prompt: mounted only while its store flag is set, so an answered question costs nothing.
160
- const RolePrompt = defineAsyncComponent(() => import('~/components/layout/RolePrompt.vue'))
156
+ const RolePrompt = defineAsyncView(() => import('~/components/layout/RolePrompt.vue'))
161
157
 
162
158
  const workspace = useWorkspaceStore()
163
159
  const github = useGitHubStore()
@@ -479,7 +475,15 @@ watch(
479
475
  <!-- Always-mounted, fast-path surfaces. -->
480
476
  <PipelineBuilder />
481
477
  <DecisionModal />
482
- <AgentStepDetail />
478
+ <!-- Code-split step-detail reader. Teleport + fade live here, not inside the component,
479
+ for the same reason the focus view's do: an inner Transition never animates the
480
+ mount its own v-if gate causes, and is unmounted before it could animate the
481
+ unmount. -->
482
+ <Teleport to="body">
483
+ <Transition name="reader-fade">
484
+ <AgentStepDetail v-if="ui.stepDetail" />
485
+ </Transition>
486
+ </Teleport>
483
487
  <StepResultViewHost />
484
488
  <!-- Consumer-contributed top-level overlays (extension slice D). Renders nothing until a
485
489
  consumer opens one via `ui.openOverlay` / `useAppOverlays().open(...)`. -->
@@ -0,0 +1,24 @@
1
+ import { defineAsyncComponent, type AsyncComponentLoader, type Component } from 'vue'
2
+ import AsyncViewError from '~/components/common/AsyncViewError.vue'
3
+
4
+ /**
5
+ * Define a code-split surface (a window, a panel, a modal, the step reader) that STATES it when
6
+ * its chunk fails to load.
7
+ *
8
+ * A bare `defineAsyncComponent` renders nothing on a rejected loader, and the rejection is
9
+ * routine rather than exotic: the SPA is a hashed-chunk build, so a deployment that lands while
10
+ * a tab is open makes every not-yet-fetched chunk a 404, and the first click on a window the
11
+ * session had never opened resolves to a blank screen with no message. On the surfaces these
12
+ * windows serve, that blank is the one a person approves or rejects a run from, so it reads as
13
+ * "there is nothing to review" rather than as a failure. Same rule as the backend's: absent and
14
+ * empty must never render the same.
15
+ *
16
+ * The remedy is a reload rather than a retry, because the chunk the running document is asking
17
+ * for is gone from the origin and re-requesting the same URL cannot bring it back.
18
+ *
19
+ * Use this instead of `defineAsyncComponent` for every surface the app splits out, so a
20
+ * consumer copying the nearest example copies the loud one.
21
+ */
22
+ export function defineAsyncView(loader: AsyncComponentLoader): Component {
23
+ return defineAsyncComponent({ loader, errorComponent: AsyncViewError })
24
+ }