@cat-factory/app 0.180.0 → 0.182.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.
@@ -1,6 +1,10 @@
1
1
  <script setup lang="ts">
2
- // Inspector section for a task block: the tracker issues (Jira, GitHub Issues, …)
3
- // attached to it as agent context. Attaching uses the SAME inline picker as task
2
+ // Inspector section for a task OR initiative block: the tracker issues (Jira,
3
+ // GitHub Issues, …) attached to it as agent context. An initiative takes the same
4
+ // attachments (the create-initiative modal stages them exactly as the add-task one
5
+ // does) and its whole planning pipeline reads them, so it gets the same section —
6
+ // only the prose differs, which is why the hint/empty copy is level-keyed below.
7
+ // Attaching uses the SAME inline picker as task
4
8
  // creation (source selector + in-repo search + paste-by-reference —
5
9
  // ContextIssuePicker), NOT the old dropdown that opened a second, page-level
6
10
  // "Import an issue…" modal on top of the inspector (stacked page-level modals
@@ -28,6 +32,20 @@ onMounted(() => {
28
32
  })
29
33
 
30
34
  const linked = computed(() => tasks.tasksForBlock(props.block.id))
35
+
36
+ // Two STATIC literal keys per string, picked by level — the copy names what reads the issue
37
+ // (the agents implementing a task vs the pipeline that plans an initiative), which is the
38
+ // whole point of the hint. Assembling one key from `block.level` would defeat the typed
39
+ // message-key check for a two-member choice that gains nothing from being dynamic.
40
+ const isInitiative = computed(() => props.block.level === 'initiative')
41
+ const hint = computed(() =>
42
+ isInitiative.value ? t('tasks.contextIssues.hintInitiative') : t('tasks.contextIssues.hint'),
43
+ )
44
+ const emptyHint = computed(() =>
45
+ isInitiative.value
46
+ ? t('tasks.contextIssues.emptyHintInitiative')
47
+ : t('tasks.contextIssues.emptyHint'),
48
+ )
31
49
  // Already-linked issues, so the inline picker filters them out / never re-offers them.
32
50
  const chosenKeys = computed(() =>
33
51
  linked.value.map((issue) =>
@@ -71,7 +89,7 @@ async function attach(item: PendingContext) {
71
89
  <InspectorSection
72
90
  v-if="tasks.available"
73
91
  :title="t('tasks.contextIssues.title')"
74
- :hint="t('tasks.contextIssues.hint')"
92
+ :hint="hint"
75
93
  :count="linked.length"
76
94
  >
77
95
  <template #actions>
@@ -133,7 +151,7 @@ async function attach(item: PendingContext) {
133
151
  </a>
134
152
  </div>
135
153
  <p v-else class="text-[11px] text-slate-500">
136
- {{ t('tasks.contextIssues.emptyHint') }}
154
+ {{ emptyHint }}
137
155
  </p>
138
156
  </InspectorSection>
139
157
  </template>
@@ -4,7 +4,11 @@ import { useExecutionStore } from '~/stores/execution'
4
4
  import { useInitiativesStore } from '~/stores/initiative'
5
5
  import { usePipelinesStore } from '~/stores/pipelines'
6
6
  import { useUiStore } from '~/stores/ui'
7
- import { interviewGatePhase } from '~/utils/interviewGate'
7
+ import {
8
+ INITIATIVE_INTERVIEWER_KIND,
9
+ interviewGatePhase,
10
+ interviewStepReached,
11
+ } from '~/utils/interviewGate'
8
12
 
9
13
  /**
10
14
  * Shared planning affordances for an `initiative`-level block, used by BOTH the board card
@@ -41,12 +45,14 @@ export function useInitiativePlanning(blockId: MaybeRefOrGetter<string>) {
41
45
  * The live interview phase, derived from the entity AND the planning run (see
42
46
  * {@link interviewGatePhase} for why the run status is load-bearing).
43
47
  */
44
- const interviewPhase = computed(() =>
45
- interviewGatePhase(
48
+ const interviewPhase = computed(() => {
49
+ const run = execution.getByBlock(toValue(blockId))
50
+ return interviewGatePhase(
46
51
  initiative.value?.interview?.status,
47
- execution.getByBlock(toValue(blockId))?.status,
48
- ),
49
- )
52
+ run?.status,
53
+ interviewStepReached(run, INITIATIVE_INTERVIEWER_KIND),
54
+ )
55
+ })
50
56
 
51
57
  /**
52
58
  * The interviewer has PARKED the planning run for the human. NOT keyed on whether individual
@@ -62,8 +68,20 @@ export function useInitiativePlanning(blockId: MaybeRefOrGetter<string>) {
62
68
  */
63
69
  const awaitingAnswers = computed(() => interviewPhase.value === 'awaiting')
64
70
 
65
- /** An interviewer pass is running — the human is waiting on the planner, not the reverse. */
66
- const interviewing = computed(() => interviewPhase.value === 'working')
71
+ /**
72
+ * The planning run is mid-flight with nothing for the human to answer — either an interviewer
73
+ * pass is running (`working`) or the run has not reached the interview yet (`preparing`, the
74
+ * codebase analysis that now leads). BOTH phases, deliberately: this drives the card's and
75
+ * inspector's "Planning in progress" button, which is the only route into the planning window
76
+ * while a run owns the block. Narrowing it to `working` would drop that route for the whole of
77
+ * the analysis and fall through to a "Run planning" button for a run already running.
78
+ *
79
+ * The two phases are distinguished INSIDE the window, where there is room to say which is which;
80
+ * the affordance is the same either way.
81
+ */
82
+ const interviewing = computed(
83
+ () => interviewPhase.value === 'working' || interviewPhase.value === 'preparing',
84
+ )
67
85
 
68
86
  /**
69
87
  * Optimistic start flag: flip true the instant "Run planning" is clicked, before the stream
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { PipelineStep } from '~/types/execution'
3
+ import { resultViewStepIndex } from '~/composables/useResultViewRunMeta'
4
+
5
+ // The off-path fallback: which step a result window reports run metadata for when it was opened
6
+ // from a board card / the inspector rather than from the run's timeline. The precedence exists
7
+ // because a window's kind set can mix model-running steps with bookkeeping ones — the initiative
8
+ // tracker is registered for the analyst, the planner AND `initiative-committer`, which runs no
9
+ // model — so "the most recent one" alone would anchor on the step with no telemetry.
10
+
11
+ function step(agentKind: string, overrides: Partial<PipelineStep> = {}): PipelineStep {
12
+ return { agentKind, state: 'pending', ...overrides } as PipelineStep
13
+ }
14
+
15
+ /** A step that recorded model calls (the shape `attachStepMetrics` folds onto the run). */
16
+ function metered(agentKind: string, calls: number, startedAt = 1_000): PipelineStep {
17
+ return step(agentKind, { startedAt, metrics: { calls } as PipelineStep['metrics'] })
18
+ }
19
+
20
+ describe('resultViewStepIndex', () => {
21
+ it('is null when no step declares the view', () => {
22
+ const steps = [step('coder'), step('tester')]
23
+ expect(resultViewStepIndex(steps, 'initiative-tracker')).toBeNull()
24
+ })
25
+
26
+ it('falls back to the first declaring step before the run reaches any of them', () => {
27
+ const steps = [
28
+ step('initiative-interviewer'),
29
+ step('initiative-analyst'),
30
+ step('initiative-planner'),
31
+ ]
32
+ // Index 1 — the analyst; the interviewer declares the PLANNING window, not the tracker.
33
+ expect(resultViewStepIndex(steps, 'initiative-tracker')).toBe(1)
34
+ })
35
+
36
+ it('prefers the last started step while the run is mid-flight with no telemetry yet', () => {
37
+ const steps = [
38
+ step('initiative-interviewer', { startedAt: 10 }),
39
+ step('initiative-analyst', { startedAt: 20 }),
40
+ step('initiative-planner'),
41
+ ]
42
+ expect(resultViewStepIndex(steps, 'initiative-tracker')).toBe(1)
43
+ })
44
+
45
+ it('prefers the last step that actually ran a model over a later bookkeeping step', () => {
46
+ const steps = [
47
+ metered('initiative-interviewer', 4),
48
+ metered('initiative-analyst', 12),
49
+ metered('initiative-planner', 31),
50
+ // Runs no model, so it records no calls — and must not shadow the planner's telemetry.
51
+ step('initiative-committer', { startedAt: 2_000 }),
52
+ ]
53
+ expect(resultViewStepIndex(steps, 'initiative-tracker')).toBe(2)
54
+ })
55
+
56
+ it('ignores a metered step belonging to another window', () => {
57
+ const steps = [metered('initiative-interviewer', 9), step('initiative-analyst')]
58
+ expect(resultViewStepIndex(steps, 'initiative-tracker')).toBe(1)
59
+ expect(resultViewStepIndex(steps, 'initiative-planning')).toBe(0)
60
+ })
61
+
62
+ it('treats a zero-call rollup as unmetered', () => {
63
+ const steps = [
64
+ metered('initiative-analyst', 5),
65
+ step('initiative-planner', {
66
+ startedAt: 3_000,
67
+ metrics: { calls: 0 } as PipelineStep['metrics'],
68
+ }),
69
+ ]
70
+ // The analyst keeps the tier-1 match: a rollup that recorded nothing is not telemetry, so
71
+ // the later step doesn't shadow it just for having a `metrics` object.
72
+ expect(resultViewStepIndex(steps, 'initiative-tracker')).toBe(0)
73
+ })
74
+ })
@@ -0,0 +1,98 @@
1
+ import { computed } from 'vue'
2
+ import type { ExecutionInstance, PipelineStep } from '~/types/execution'
3
+ import { agentKindMeta } from '~/utils/catalog'
4
+
5
+ /**
6
+ * Resolves the `StepRunMeta` prop bundle for a dedicated result window, for BOTH of the ways
7
+ * such a window opens (see `stores/ui/resultViews.ts`):
8
+ *
9
+ * - the PIPELINE path (`dispatchStepView`), which carries `instanceId` + `stepIndex` — the step
10
+ * is exactly the one that was clicked, as every step-backed window already assumes; and
11
+ * - an OFF-PATH open (`ui.openInitiativeTracker`, `ui.openInitiativePlanning`, …), which carries
12
+ * only a block id, because the human entered from the board card or the inspector rather than
13
+ * from the run's timeline.
14
+ *
15
+ * Windows that are reachable BOTH ways used to fall back to no metadata at all on the second
16
+ * route, which is how the initiative windows ended up with no run details, no model and no token
17
+ * telemetry — on the entry point people actually use. The fallback resolves the block's live run
18
+ * and picks the step this window represents, so the two routes report the same facts.
19
+ */
20
+ export function useResultViewRunMeta(
21
+ viewId: string,
22
+ source: {
23
+ blockId: () => string | null
24
+ instanceId: () => string | null
25
+ stepIndex: () => number | null
26
+ },
27
+ ) {
28
+ const execution = useExecutionStore()
29
+
30
+ /**
31
+ * The run the metadata describes: the dispatched one on the pipeline path, else the block's
32
+ * live run. Read reactively rather than resolved once at open time, so a window left open
33
+ * across a run start/advance follows it instead of freezing on whatever existed at mount.
34
+ */
35
+ const instance = computed<ExecutionInstance | null>(() => {
36
+ const id = source.instanceId()
37
+ if (id !== null) return execution.getInstance(id) ?? null
38
+ const blockId = source.blockId()
39
+ return blockId ? (execution.getByBlock(blockId) ?? null) : null
40
+ })
41
+
42
+ const stepNumber = computed<number | null>(() => {
43
+ const index = source.stepIndex()
44
+ if (index !== null) return index
45
+ return instance.value ? resultViewStepIndex(instance.value.steps, viewId) : null
46
+ })
47
+
48
+ const step = computed<PipelineStep | null>(() => {
49
+ const index = stepNumber.value
50
+ if (index === null) return null
51
+ return instance.value?.steps[index] ?? null
52
+ })
53
+
54
+ return {
55
+ instance,
56
+ step,
57
+ /** The run id, for the copyable field + the "View all calls" observability link. */
58
+ instanceId: computed(() => instance.value?.id),
59
+ /** 1-based position, as `StepRunMeta` renders it ("N of M"). */
60
+ position: computed(() => (stepNumber.value === null ? undefined : stepNumber.value + 1)),
61
+ totalSteps: computed(() => instance.value?.steps.length),
62
+ runFailed: computed(() => instance.value?.status === 'failed'),
63
+ failureAt: computed(() => instance.value?.failure?.occurredAt),
64
+ }
65
+ }
66
+
67
+ /**
68
+ * The step an off-path open should report, among the run's steps whose agent kind declares
69
+ * `viewId` as its dedicated result view (the same `resultView` seam `dispatchStepView` routes
70
+ * on, so the mapping can never drift from the window registry).
71
+ *
72
+ * Precedence, most to least specific:
73
+ *
74
+ * 1. the last step that has actually RUN A MODEL. A window's kind set can span both model-running
75
+ * steps and bookkeeping ones — the initiative tracker is registered for the analyst and the
76
+ * planner but also for `initiative-committer`, which runs no model — and anchoring on the last
77
+ * STARTED step alone would hand a human who opened the window for its telemetry the one step
78
+ * guaranteed to have none.
79
+ * 2. the last started step, so a run mid-flight (or one whose telemetry sink isn't wired) still
80
+ * reports timing, model and run id rather than nothing.
81
+ * 3. the first declaring step, so a run that hasn't reached any of them yet still names which
82
+ * step is coming and how far into the pipeline it sits.
83
+ *
84
+ * Null when the run declares none — the caller hides the sidebar rather than rendering an empty one.
85
+ */
86
+ export function resultViewStepIndex(steps: readonly PipelineStep[], viewId: string): number | null {
87
+ let first: number | null = null
88
+ let lastStarted: number | null = null
89
+ let lastMetered: number | null = null
90
+ for (let i = 0; i < steps.length; i++) {
91
+ const step = steps[i]
92
+ if (!step || agentKindMeta(step.agentKind).resultView !== viewId) continue
93
+ if (first === null) first = i
94
+ if (step.startedAt != null) lastStarted = i
95
+ if ((step.metrics?.calls ?? 0) > 0) lastMetered = i
96
+ }
97
+ return lastMetered ?? lastStarted ?? first
98
+ }
@@ -15,22 +15,24 @@ function sameSet(a: Set<string>, b: Set<string>) {
15
15
  }
16
16
 
17
17
  /**
18
- * Board-level driver deciding which task cards may expand their full pipeline list
19
- * once zoomed in (the deep `steps`/`subtasks` bands). Two gates, recomputed every
20
- * frame against live DOM rects so they follow pan / zoom / drag / resize:
18
+ * Board-level driver deciding which task cards expand their full pipeline list.
19
+ * Recomputed every frame against live DOM rects so it follows pan / zoom / drag / resize,
20
+ * and writes two independent grants into the `taskExpansion` store (which combines them
21
+ * see the store for how they resolve):
21
22
  *
22
- * - visibility: a task expands only while its card overlaps the board viewport.
23
- * - overlap: walking the visible candidates nearest-header-to-screen-centre first, a
24
- * task expands only if its footprint doesn't collide with one already granted, so the
25
- * card you're looking at wins an overlap and the rest stay compact.
26
- * - hover: the task directly under the pointer is granted first, so hovering any card
27
- * expands its pipeline regardless of its position on screen. "Under the pointer" is
28
- * the TOPMOST card at the cursor (document.elementFromPoint), so hovering a region
23
+ * - hover: the task directly under the pointer, at ANY zoom level. "Under the pointer"
24
+ * is the TOPMOST card at the cursor (document.elementFromPoint), so hovering a region
29
25
  * already covered by another open pipeline keeps that pipeline, not the card beneath.
26
+ * - zoom: at the deep `steps`/`subtasks` bands, every on-screen card, minus overlaps —
27
+ * two sub-gates:
28
+ * - visibility: a task expands only while its card overlaps the board viewport.
29
+ * - overlap: walking the visible candidates nearest-header-to-screen-centre first, a
30
+ * task expands only if its footprint doesn't collide with one already granted, so
31
+ * the card you're looking at wins an overlap and the rest stay compact. The hovered
32
+ * card is granted first, so it wins every overlap it's part of.
30
33
  *
31
- * Writes the permitted id set into the `taskExpansion` store; `TaskPipelineMini` reads it.
32
- * Only tasks with a running pipeline (steps to show) are candidates a task that
33
- * wouldn't expand never blocks a neighbour.
34
+ * Only tasks with a running pipeline (steps to show) are candidates for either grant — a
35
+ * task that wouldn't expand never blocks a neighbour and never lifts an empty card.
34
36
  */
35
37
  export function useTaskExpansion(container: Ref<HTMLElement | null>) {
36
38
  const board = useBoardStore()
@@ -65,14 +67,27 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
65
67
  // The task whose card is topmost at the pointer, or null. Using elementFromPoint (not a
66
68
  // rect test) means an open pipeline stacked above a neighbour wins the hit, so hovering
67
69
  // a region obscured by another pipeline doesn't switch to the card hidden beneath it.
70
+ //
71
+ // Blocks with no pipeline to show are filtered out here rather than left to the card:
72
+ // a frame, a module, or a task with no run expands to nothing, and granting it would
73
+ // still lift an empty card over its neighbours (see DraggableTask's z-index).
68
74
  function hoveredTaskId(): string | null {
69
75
  if (!pointer) return null
70
76
  const hit = document.elementFromPoint(pointer.x, pointer.y)
71
- return hit?.closest('[data-block-id]')?.getAttribute('data-block-id') ?? null
77
+ const id = hit?.closest('[data-block-id]')?.getAttribute('data-block-id') ?? null
78
+ if (!id || !execution.getByBlock(id)?.steps.length) return null
79
+ return id
72
80
  }
73
81
 
74
82
  function recompute() {
75
- // Task cards only expand at the deep zoom bands; clear everything otherwise.
83
+ // Hover expands a card at ANY zoom band, so the pointer hit is resolved BEFORE the
84
+ // zoom gate below — resolving it after would collapse the hovered card the moment the
85
+ // user zoomed back out past the `steps` band.
86
+ const hovered = hoveredTaskId()
87
+ if (store.hoveredId !== hovered) store.setHovered(hovered)
88
+
89
+ // The zoom-driven expansion (every on-screen card, overlap-resolved) is deep-band
90
+ // only; clear its grants otherwise. The hover grant above stands on its own.
76
91
  if (!lodAtLeast(ui.lod, 'steps')) {
77
92
  if (store.allowed.size) store.setAllowed(new Set())
78
93
  return
@@ -117,7 +132,6 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
117
132
  // clears every footprint already granted, so the centre-most card wins any overlap.
118
133
  // The hovered card is granted FIRST, so hovering a card expands it regardless of its
119
134
  // distance from the centre (and a centre-most neighbour it overlaps yields to it).
120
- const hovered = hoveredTaskId()
121
135
  const claimed: Rect[] = []
122
136
  const next = new Set<string>()
123
137
  const hoveredCard = hovered ? candidates.find((c) => c.id === hovered) : undefined
@@ -170,16 +170,17 @@ re-deriving the "which run is this / how did the model do" facts. **Composables*
170
170
  **components** must be named through the `#components` virtual module (see the boxed note
171
171
  below). Compose these:
172
172
 
173
- | Building block | Reference it as | What it gives you |
174
- | ---------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
175
- | `ResultWindowShell` | `#components` → `PanelsResultWindowShell` | The shared modal chrome for a result window — backdrop, header (icon/title/subtitle), a `#header-extras` slot, close button, and the modal _behaviour_ (focus-trap + return, body-scroll lock, shared-stack Escape via `useModalBehavior`). Pass `stepRef` to surface the shared "restart from here" control. |
176
- | `StepRunMeta` | `#components` → `PanelsStepRunMeta` | **The shared run-details metadata block** every agent window reuses: step position, live duration, model, run id, and the LLM model-activity rollup. Drop it into your window's sidebar — never reinvent run metadata. |
177
- | `MarkdownProse` | `#components` → `CommonMarkdownProse` | Render an agent's prose output as markdown. |
178
- | `CopyButton` | `#components` → `CommonCopyButton` | The shared copy-to-clipboard affordance. |
179
- | `InspectorSection` | `#components` → `PanelsInspectorSection` | The collapsible inspector-section shell (chevron header, count, hint) so a consumer panel reads like a built-in one. |
180
- | `useResultView(id)` | auto-imported | The window seam contract: `{ open, blockId, instanceId, stepIndex, close }` (+ an `onOpen` loader for windows that fetch, and an `onClose` flush). Escape is owned by the shell, not here. |
181
- | `usePanelSubject<T>()` | `@modular-vue/core` | Read the block injected into an inspector panel by `<PanelsOutlet>`. |
182
- | `useAppOverlays()` | auto-imported | Open / close your own top-level overlays: `{ open(id, subject?), close(), active }`. The store-free seam a nav `run` closure uses to open an `appOverlays`-slot component (see "Top-level overlays"). |
173
+ | Building block | Reference it as | What it gives you |
174
+ | ----------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
175
+ | `ResultWindowShell` | `#components` → `PanelsResultWindowShell` | The shared modal chrome for a result window — backdrop, header (icon/title/subtitle), a `#header-extras` slot, close button, and the modal _behaviour_ (focus-trap + return, body-scroll lock, shared-stack Escape via `useModalBehavior`). Pass `stepRef` to surface the shared "restart from here" control. |
176
+ | `StepRunMeta` | `#components` → `PanelsStepRunMeta` | **The shared run-details metadata block** every agent window reuses: step position, live duration, model, run id, and the LLM model-activity rollup. Drop it into your window's sidebar — never reinvent run metadata. |
177
+ | `MarkdownProse` | `#components` → `CommonMarkdownProse` | Render an agent's prose output as markdown. |
178
+ | `CopyButton` | `#components` → `CommonCopyButton` | The shared copy-to-clipboard affordance. |
179
+ | `InspectorSection` | `#components` → `PanelsInspectorSection` | The collapsible inspector-section shell (chevron header, count, hint) so a consumer panel reads like a built-in one. |
180
+ | `useResultView(id)` | auto-imported | The window seam contract: `{ open, blockId, instanceId, stepIndex, close }` (+ an `onOpen` loader for windows that fetch, and an `onClose` flush). Escape is owned by the shell, not here. |
181
+ | `useResultViewRunMeta(id, …)` | auto-imported | The `StepRunMeta` prop bundle (`{ step, instanceId, position, totalSteps, runFailed, failureAt }`), resolved for BOTH ways a window opens. A window reachable off-path — from a board card or the inspector — carries no `stepIndex`, so wiring `StepRunMeta` straight off `useResultView` leaves it blank on exactly that route; this resolves the block's live run and the step your view id declares instead. |
182
+ | `usePanelSubject<T>()` | `@modular-vue/core` | Read the block injected into an inspector panel by `<PanelsOutlet>`. |
183
+ | `useAppOverlays()` | auto-imported | Open / close your own top-level overlays: `{ open(id, subject?), close(), active }`. The store-free seam a nav `run` closure uses to open an `appOverlays`-slot component (see "Top-level overlays"). |
183
184
 
184
185
  > **Reference layer components through `#components`, not bare tags.** Nuxt auto-registers a
185
186
  > layer's components under a **path-derived** name (`components/panels/ResultWindowShell.vue`
@@ -98,11 +98,18 @@ describe('inspector panel group', () => {
98
98
  expect(visibleIds(block('epic'))).toEqual(['epic-children'])
99
99
  })
100
100
 
101
- // An initiative's planning pipeline is an ordinary run, so it shares the task body's execution
102
- // panel its own inspector leading, the run detail under it. Pinned because the panel is the
103
- // only surface carrying the Stop / Discard-run controls that unwedge a stalled planning run.
104
- it('an initiative shows its inspector, then the shared execution panel', () => {
105
- expect(visibleIds(block('initiative'))).toEqual(['initiative-inspector', 'task-execution'])
101
+ // An initiative shares two of the task body's panels: the CONTEXT sections (the
102
+ // create-initiative modal attaches the same documents/issues, and the planning pipeline reads
103
+ // them, so the inspector must surface them) and the execution panel (planning is an ordinary
104
+ // run and that panel carries the only Stop / Discard-run controls that unwedge a stalled
105
+ // one). Order is pinned too: identity + controls, the context it was given, then the run.
106
+ it('an initiative shows its inspector, then the shared context + execution panels', () => {
107
+ expect(visibleIds(block('initiative'))).toEqual([
108
+ 'initiative-inspector',
109
+ 'task-context-docs',
110
+ 'task-context-issues',
111
+ 'task-execution',
112
+ ])
106
113
  })
107
114
 
108
115
  it('no subject selected resolves to no panels', () => {
@@ -76,12 +76,20 @@ const isTask = (b: Block) => b.level === 'task'
76
76
  const isFrame = (b: Block) => b.level === 'frame'
77
77
  /**
78
78
  * A block whose inspector carries a pipeline RUN. An initiative's planning pipeline is an
79
- * ordinary run of ordinary agent steps (intervieweranalyst → planner → committer), so it
79
+ * ordinary run of ordinary agent steps (analystinterviewer → planner → committer), so it
80
80
  * gets the same execution panel a task does — step list, live phases, step-detail drill-down,
81
81
  * and the Stop / Discard-run controls that are the only way to unwedge a stalled planning run.
82
82
  * Before this it had no run surface at all, which is why a stuck plan was a dead end.
83
83
  */
84
84
  const hasRuns = (b: Block) => isTask(b) || b.level === 'initiative'
85
+ /**
86
+ * A block that carries attached CONTEXT (imported documents + tracker issues). The
87
+ * create-initiative modal stages the same attachments the add-task modal does and links them
88
+ * to the initiative block, and the whole planning pipeline reads them — so an initiative
89
+ * whose inspector never showed them left the user with no evidence the attachment landed and
90
+ * no way to reach the source. Same panels, same store reads (both are keyed by block id only).
91
+ */
92
+ const takesContext = (b: Block) => isTask(b) || b.level === 'initiative'
85
93
  /** frame OR module — the "container" panels. */
86
94
  const isContainer = (b: Block) => b.level === 'frame' || b.level === 'module'
87
95
  /**
@@ -111,8 +119,9 @@ const isDeployableFrame = (b: Block) => isFrame(b) && b.type !== 'document'
111
119
  * extensibility value.
112
120
  */
113
121
  export const INSPECTOR_PANEL_SPECS: readonly InspectorPanelSpec[] = [
114
- { id: 'task-context-docs', order: 10, when: isTask },
115
- { id: 'task-context-issues', order: 20, when: isTask },
122
+ // Shared with the initiative body an initiative takes the same attachments a task does.
123
+ { id: 'task-context-docs', order: 10, when: takesContext },
124
+ { id: 'task-context-issues', order: 20, when: takesContext },
116
125
  { id: 'recurring-schedule', order: 30, when: isTask },
117
126
  // Shared with the initiative body — the panel renders a RUN, and an initiative has one.
118
127
  { id: 'task-execution', order: 40, when: hasRuns },
@@ -133,8 +142,8 @@ export const INSPECTOR_PANEL_SPECS: readonly InspectorPanelSpec[] = [
133
142
  // test-infra and release-health panels above it.
134
143
  { id: 'service-validation-checks', order: 180, when: isDeployableFrame },
135
144
  { id: 'epic-children', order: 200, when: (b) => b.level === 'epic' },
136
- // Ordered BEFORE the shared execution panel (40) so an initiative reads the way a task does:
137
- // its own identity + controls first, the run detail under them. Levels never overlap, so this
145
+ // Ordered FIRST so an initiative reads the way a task does: its own identity + controls, then
146
+ // the context it was given (10/20), then the run detail (40). Levels never overlap, so this
138
147
  // number only ever competes with the task ids the initiative body doesn't render.
139
- { id: 'initiative-inspector', order: 35, when: (b) => b.level === 'initiative' },
148
+ { id: 'initiative-inspector', order: 5, when: (b) => b.level === 'initiative' },
140
149
  ]
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { useTaskExpansionStore } from '~/stores/taskExpansion'
3
+ import { useUiStore } from '~/stores/ui'
4
+
5
+ /**
6
+ * The expansion gate combines two independent grants (hover at any zoom, the deep zoom
7
+ * bands otherwise). Both `TaskPipelineMini` (what renders) and `DraggableTask` (what
8
+ * stacks on top) read `isExpanded`, so these cases pin the rule they share.
9
+ *
10
+ * Zoom is set through the ui store's raw `zoom`, the same value the board canvas writes;
11
+ * `lod` derives from it (< 1.8 is shallower than the `steps` band).
12
+ */
13
+ function setup(zoom: number) {
14
+ const ui = useUiStore()
15
+ ui.zoom = zoom
16
+ const store = useTaskExpansionStore()
17
+ store.setDriverActive(true)
18
+ return store
19
+ }
20
+
21
+ describe('taskExpansion — hover expands at any zoom level', () => {
22
+ it('expands the hovered card while zoomed out past the steps band', () => {
23
+ const store = setup(0.5) // `far`
24
+ store.setHovered('task-a')
25
+ expect(store.isExpanded('task-a')).toBe(true)
26
+ // Hover is a grant of ONE card: its neighbours stay compact at this zoom.
27
+ expect(store.isExpanded('task-b')).toBe(false)
28
+ })
29
+
30
+ it('expands the hovered card at every band, not just the shallow ones', () => {
31
+ const store = setup(1) // `mid`
32
+ store.setHovered('task-a')
33
+ expect(store.isExpanded('task-a')).toBe(true)
34
+ useUiStore().zoom = 1.5 // `close`
35
+ expect(store.isExpanded('task-a')).toBe(true)
36
+ useUiStore().zoom = 3 // `subtasks`
37
+ expect(store.isExpanded('task-a')).toBe(true)
38
+ })
39
+
40
+ it('collapses again once the pointer leaves the card', () => {
41
+ const store = setup(0.5)
42
+ store.setHovered('task-a')
43
+ store.setHovered(null)
44
+ expect(store.isExpanded('task-a')).toBe(false)
45
+ })
46
+
47
+ it('expands a hovered card the zoom gate denied for overlapping a neighbour', () => {
48
+ const store = setup(2) // `steps`
49
+ store.setAllowed(new Set(['task-b']))
50
+ store.setHovered('task-a')
51
+ expect(store.isExpanded('task-a')).toBe(true)
52
+ expect(store.isExpanded('task-b')).toBe(true)
53
+ })
54
+ })
55
+
56
+ describe('taskExpansion — the zoom grant is unchanged', () => {
57
+ it('honours the driver grant only from the steps band up', () => {
58
+ const store = setup(1.5) // `close` — one band shallower than `steps`
59
+ store.setAllowed(new Set(['task-a']))
60
+ expect(store.isExpanded('task-a')).toBe(false)
61
+ useUiStore().zoom = 2 // `steps`
62
+ expect(store.isExpanded('task-a')).toBe(true)
63
+ })
64
+
65
+ it('denies a card the driver left out of the permitted set', () => {
66
+ const store = setup(2)
67
+ store.setAllowed(new Set(['task-b']))
68
+ expect(store.isExpanded('task-a')).toBe(false)
69
+ })
70
+
71
+ it('falls back to allowed with no driver mounted, still only at the deep bands', () => {
72
+ const ui = useUiStore()
73
+ ui.zoom = 2
74
+ const store = useTaskExpansionStore() // driverActive stays false
75
+ expect(store.isExpanded('task-a')).toBe(true)
76
+ ui.zoom = 1
77
+ expect(store.isExpanded('task-a')).toBe(false)
78
+ })
79
+
80
+ it('drops both grants when the driver unmounts', () => {
81
+ const store = setup(2)
82
+ store.setAllowed(new Set(['task-a']))
83
+ store.setHovered('task-b')
84
+ store.setDriverActive(false)
85
+ expect(store.allowed.size).toBe(0)
86
+ expect(store.hoveredId).toBeNull()
87
+ })
88
+ })
@@ -1,37 +1,57 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref } from 'vue'
3
+ import { lodAtLeast } from '~/composables/useSemanticZoom'
4
+ import { useUiStore } from '~/stores/ui'
3
5
 
4
6
  /**
5
- * Which task cards may expand their full build-pipeline list once zoomed in.
7
+ * Which task cards render their full build-pipeline list.
6
8
  *
7
- * Deep-zoom (`steps`/`subtasks`) grows a task card downward, and cards are
8
- * absolutely positioned in their frame, so several expanded cards stacked
9
- * vertically pile on top of each other. The board driver (`useTaskExpansion`)
10
- * recomputes a permitted set every frame — only on-screen cards, the one closest to
11
- * the screen centre when two would overlap, plus whichever card the pointer is hovering
12
- * (hover expands a card regardless of position) — and writes it here.
13
- * `TaskPipelineMini` reads `canExpand` to decide whether to expand or stay compact.
9
+ * Two independent grants, both written every frame by the board driver
10
+ * (`useTaskExpansion`) and combined HERE so the render (`TaskPipelineMini`) and the
11
+ * stacking (`DraggableTask`) can never disagree about which cards are expanded:
14
12
  *
15
- * `driverActive` lets the gate degrade gracefully: with no board driver mounted
16
- * (e.g. a card rendered in isolation) `canExpand` falls back to "allowed", so the
17
- * plain zoom behaviour is unchanged.
13
+ * - HOVER the card under the pointer expands at ANY zoom level. Pointing at a task
14
+ * is asking what it is doing right now, and that answer used to be reachable only
15
+ * by first zooming past the `steps` band.
16
+ * - ZOOM — past the `steps` band, every on-screen card expands. Deep-zoom grows a card
17
+ * downward and cards are absolutely positioned in their frame, so several expanded
18
+ * cards stacked vertically pile on top of each other; the driver denies whichever
19
+ * would collide with a card nearer the screen centre and writes the survivors here.
20
+ *
21
+ * `driverActive` lets the zoom grant degrade gracefully: with no board driver mounted
22
+ * (e.g. a card rendered in isolation) it falls back to "allowed", so the plain zoom
23
+ * behaviour is unchanged. The hover grant needs no fallback — without the driver
24
+ * nothing is hovered.
18
25
  */
19
26
  export const useTaskExpansionStore = defineStore('taskExpansion', () => {
27
+ const ui = useUiStore()
20
28
  const allowed = ref<Set<string>>(new Set())
29
+ const hoveredId = ref<string | null>(null)
21
30
  const driverActive = ref(false)
22
31
 
23
32
  function setAllowed(ids: Set<string>) {
24
33
  allowed.value = ids
25
34
  }
26
35
 
36
+ function setHovered(id: string | null) {
37
+ hoveredId.value = id
38
+ }
39
+
27
40
  function setDriverActive(active: boolean) {
28
41
  driverActive.value = active
29
- if (!active) allowed.value = new Set()
42
+ if (!active) {
43
+ allowed.value = new Set()
44
+ hoveredId.value = null
45
+ }
30
46
  }
31
47
 
32
- function canExpand(id: string) {
48
+ /** Whether this task card should render its pipeline: hovered at any zoom, else
49
+ * granted by the deep-zoom bands. */
50
+ function isExpanded(id: string) {
51
+ if (hoveredId.value === id) return true
52
+ if (!lodAtLeast(ui.lod, 'steps')) return false
33
53
  return driverActive.value ? allowed.value.has(id) : true
34
54
  }
35
55
 
36
- return { allowed, driverActive, setAllowed, setDriverActive, canExpand }
56
+ return { allowed, hoveredId, driverActive, setAllowed, setHovered, setDriverActive, isExpanded }
37
57
  })