@cat-factory/app 0.179.0 → 0.181.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.
@@ -10,6 +10,13 @@
10
10
  // unavailable or failed still shows its candidates (flagged as unassessed, since the scan is
11
11
  // useful on its own), and a scan that hit its cap says so — a silently shortened list reads
12
12
  // exactly like an exhaustive one.
13
+ //
14
+ // The tracker selector doubles as the "add a tracker" affordance (the same two-tier menu
15
+ // `<ContextIssuePicker>` renders, off the shared `buildSourceChoices`): a hunt is a common
16
+ // place to find out the tracker holding the bugs isn't connected here yet, and the answer
17
+ // has to be a route to that tracker's own connect screen rather than "go find the
18
+ // Integrations hub". The connect modal opens OVER the hunt, so nothing typed here is lost.
19
+ import type { DropdownMenuItem } from '@nuxt/ui'
13
20
  import type { TaskSourceReadReason } from '@cat-factory/contracts'
14
21
  import type {
15
22
  BugHuntAnalysisStatus,
@@ -17,6 +24,7 @@ import type {
17
24
  BugHuntConfidence,
18
25
  TaskSourceKind,
19
26
  } from '~/types/domain'
27
+ import { type SourceChoice, buildSourceChoices, reconcileSource } from '~/utils/taskSources'
20
28
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
21
29
 
22
30
  const { t, d, n } = useI18n()
@@ -54,8 +62,64 @@ const containerItems = computed(() =>
54
62
  })),
55
63
  )
56
64
 
57
- const sourceItems = computed(() =>
58
- tasks.offeredSources.map((s) => ({ label: s.label, value: s.source })),
65
+ const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
66
+
67
+ // Two-tier tracker menu: pick one the workspace already offers, or add one it doesn't.
68
+ const sourceChoices = computed(() => buildSourceChoices(tasks.sources, source.value))
69
+ const sourceMenu = computed<DropdownMenuItem[][]>(() =>
70
+ sourceChoices.value.map((group) =>
71
+ group.map((choice) =>
72
+ choice.action === 'select'
73
+ ? {
74
+ label: choice.label,
75
+ icon: choice.icon,
76
+ trailingIcon: choice.active ? 'i-lucide-check' : undefined,
77
+ onSelect: () => {
78
+ source.value = choice.source
79
+ },
80
+ }
81
+ : {
82
+ label: addLabel(choice),
83
+ icon: 'i-lucide-plug',
84
+ onSelect: () => addSource(choice.source),
85
+ },
86
+ ),
87
+ ),
88
+ )
89
+
90
+ /** The trackers that can be added — the empty state's buttons, where none is offered yet. */
91
+ const addableSources = computed(() =>
92
+ sourceChoices.value.flat().filter((c) => c.action !== 'select'),
93
+ )
94
+
95
+ /**
96
+ * Wording for an addable tracker: `enable` is connected but toggled off for this workspace,
97
+ * so the user is never told to "connect" something they already connected.
98
+ */
99
+ function addLabel(choice: SourceChoice): string {
100
+ return choice.action === 'enable'
101
+ ? t('bugHunt.enableSource', { label: choice.label })
102
+ : t('bugHunt.connectSource', { label: choice.label })
103
+ }
104
+
105
+ /**
106
+ * The tracker the user left to add, so it becomes the selection the moment it turns up
107
+ * offered (the connect modal re-probes on success and this hunt stays open underneath it).
108
+ * Also the reconcile trigger for a source that STOPS being offered — disconnected, or
109
+ * toggled off in settings while the hunt sat open.
110
+ */
111
+ const awaitingConnect = ref<TaskSourceKind | null>(null)
112
+ function addSource(s: TaskSourceKind) {
113
+ awaitingConnect.value = s
114
+ ui.openTaskConnect(s)
115
+ }
116
+ watch(
117
+ () => tasks.offeredSources.map((s) => s.source),
118
+ (offered) => {
119
+ const next = reconcileSource(offered, source.value, awaitingConnect.value)
120
+ if (next && next === awaitingConnect.value) awaitingConnect.value = null
121
+ if (next !== source.value) source.value = next
122
+ },
59
123
  )
60
124
 
61
125
  const boardItems = computed(() =>
@@ -100,6 +164,7 @@ watch(open, (isOpen) => {
100
164
  boardId.value = ''
101
165
  issueType.value = ''
102
166
  labels.value = ''
167
+ awaitingConnect.value = null
103
168
  source.value = ui.bugHunt?.source ?? tasks.offeredSources[0]?.source ?? undefined
104
169
  containerId.value = ui.bugHunt?.containerId ?? containerItems.value[0]?.value
105
170
  if (source.value) hunt.loadBoards(source.value)
@@ -193,16 +258,16 @@ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
193
258
  <div v-if="!tasks.anyOffered" class="space-y-3 text-center">
194
259
  <UIcon name="i-lucide-plug" class="mx-auto h-8 w-8 text-slate-500" />
195
260
  <p class="text-sm text-slate-400">{{ t('bugHunt.connectFirst') }}</p>
196
- <div class="flex justify-center gap-2">
261
+ <div class="flex flex-wrap justify-center gap-2">
197
262
  <UButton
198
- v-for="s in tasks.sources"
199
- :key="s.source"
263
+ v-for="choice in addableSources"
264
+ :key="choice.source"
200
265
  color="primary"
201
266
  variant="soft"
202
- :icon="s.icon"
203
- @click="ui.openTaskConnect(s.source)"
267
+ :icon="choice.icon"
268
+ @click="addSource(choice.source)"
204
269
  >
205
- {{ t('bugHunt.connectSource', { label: s.label }) }}
270
+ {{ addLabel(choice) }}
206
271
  </UButton>
207
272
  </div>
208
273
  </div>
@@ -217,7 +282,24 @@ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
217
282
 
218
283
  <div class="grid gap-3 sm:grid-cols-2">
219
284
  <UFormField :label="t('bugHunt.tracker')">
220
- <USelect v-model="source" :items="sourceItems" class="w-full" />
285
+ <!-- The selector is also the way to ADD a tracker: each entry in the second group
286
+ opens that tracker's own connect screen over this modal, so the hunt (and
287
+ anything typed into it) is still here when the user comes back. -->
288
+ <UDropdownMenu
289
+ :items="sourceMenu"
290
+ :content="{ side: 'bottom', align: 'start' }"
291
+ class="w-full"
292
+ >
293
+ <UButton
294
+ color="neutral"
295
+ variant="soft"
296
+ :icon="descriptor?.icon"
297
+ trailing-icon="i-lucide-chevron-down"
298
+ class="w-full justify-between"
299
+ >
300
+ <span class="truncate">{{ descriptor?.label ?? t('bugHunt.pickTracker') }}</span>
301
+ </UButton>
302
+ </UDropdownMenu>
221
303
  </UFormField>
222
304
 
223
305
  <UFormField :label="t('bugHunt.board')">
@@ -21,7 +21,7 @@ import type { TaskSourceReadReason } from '@cat-factory/contracts'
21
21
  import type { SourceTask, TaskSearchResult, TaskSourceKind } from '~/types/domain'
22
22
  import { apiErrorReason } from '~/composables/api/errors'
23
23
  import EmptyState from '~/components/common/EmptyState.vue'
24
- import { buildSourceChoices, reconcileSource } from '~/components/tasks/ContextIssuePicker.logic'
24
+ import { buildSourceChoices, reconcileSource } from '~/utils/taskSources'
25
25
 
26
26
  const props = defineProps<{
27
27
  /** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
@@ -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>
@@ -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
+ }
@@ -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', () => {
@@ -82,6 +82,14 @@ const isFrame = (b: Block) => b.level === 'frame'
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
  ]
@@ -1,7 +1,7 @@
1
1
  import { INITIATIVE_ITEM_TERMINAL_STATUSES } from '@cat-factory/contracts'
2
2
  import { describe, it, expect } from 'vitest'
3
- import type { InitiativeItem, InitiativePhase } from '~/types/domain'
4
- import { pendingCheckpointPhase } from './initiative'
3
+ import type { InitiativeItem, InitiativePhase, InitiativeQa } from '~/types/domain'
4
+ import { isPendingQuestion, orderInterviewQuestions, pendingCheckpointPhase } from './initiative'
5
5
 
6
6
  // `pendingCheckpointPhase` mirrors the backend `pendingCheckpoint` (orchestration
7
7
  // `initiative.logic.ts`); these pin the same ordering/edge cases the loop pauses on, so the
@@ -71,3 +71,82 @@ describe('pendingCheckpointPhase', () => {
71
71
  expect(pendingCheckpointPhase(phases, [item('a', 'p1', status)])?.id).toBe('p1')
72
72
  })
73
73
  })
74
+
75
+ // `isPendingQuestion` mirrors the backend rule of the same name (orchestration
76
+ // `initiative.logic.ts`); `orderInterviewQuestions` is what the planning window renders by, so a
77
+ // multi-round interview puts what the human still owes an answer above what they already settled.
78
+
79
+ const qa = (over: Partial<InitiativeQa> & { id: string }): InitiativeQa => ({
80
+ question: over.id,
81
+ answer: '',
82
+ status: 'open',
83
+ ...over,
84
+ })
85
+
86
+ describe('isPendingQuestion', () => {
87
+ it('is pending while unanswered and not dismissed', () => {
88
+ expect(isPendingQuestion(qa({ id: 'a' }))).toBe(true)
89
+ })
90
+
91
+ it('is settled once answered', () => {
92
+ expect(isPendingQuestion(qa({ id: 'a', answer: 'yes' }))).toBe(false)
93
+ })
94
+
95
+ it('treats a whitespace-only answer as unanswered', () => {
96
+ expect(isPendingQuestion(qa({ id: 'a', answer: ' \n' }))).toBe(true)
97
+ })
98
+
99
+ it('is settled once dismissed, answered or not', () => {
100
+ expect(isPendingQuestion(qa({ id: 'a', status: 'dismissed' }))).toBe(false)
101
+ })
102
+
103
+ it('treats an absent answer/status (a hand-authored exchange) as pending', () => {
104
+ expect(isPendingQuestion({})).toBe(true)
105
+ })
106
+ })
107
+
108
+ describe('orderInterviewQuestions', () => {
109
+ const ids = (list: InitiativeQa[]) => orderInterviewQuestions(list).map((q) => q.id)
110
+
111
+ it('floats a later round of unanswered questions above the settled digest', () => {
112
+ // The shape the backend's `[...retainedQa, ...pending]` append produces on round two.
113
+ const list = [
114
+ qa({ id: 'r1-answered', answer: 'yes' }),
115
+ qa({ id: 'r1-dismissed', status: 'dismissed' }),
116
+ qa({ id: 'r2-a' }),
117
+ qa({ id: 'r2-b' }),
118
+ ]
119
+ expect(ids(list)).toEqual(['r2-a', 'r2-b', 'r1-answered', 'r1-dismissed'])
120
+ })
121
+
122
+ it('keeps chronological order within each group', () => {
123
+ const list = [
124
+ qa({ id: 'p1' }),
125
+ qa({ id: 's1', answer: 'yes' }),
126
+ qa({ id: 'p2' }),
127
+ qa({ id: 's2', status: 'dismissed' }),
128
+ qa({ id: 'p3' }),
129
+ ]
130
+ expect(ids(list)).toEqual(['p1', 'p2', 'p3', 's1', 's2'])
131
+ })
132
+
133
+ it('leaves a first round (all pending) exactly as the interviewer asked it', () => {
134
+ const list = [qa({ id: 'a' }), qa({ id: 'b' }), qa({ id: 'c' })]
135
+ expect(ids(list)).toEqual(['a', 'b', 'c'])
136
+ })
137
+
138
+ it('leaves a fully settled interview in its digest order', () => {
139
+ const list = [qa({ id: 'a', answer: 'x' }), qa({ id: 'b', status: 'dismissed' })]
140
+ expect(ids(list)).toEqual(['a', 'b'])
141
+ })
142
+
143
+ it('does not mutate the stored order (the interviewer prompt + tracker digest read it)', () => {
144
+ const list = [qa({ id: 'answered', answer: 'x' }), qa({ id: 'pending' })]
145
+ orderInterviewQuestions(list)
146
+ expect(list.map((q) => q.id)).toEqual(['answered', 'pending'])
147
+ })
148
+
149
+ it('handles an empty interview', () => {
150
+ expect(orderInterviewQuestions([])).toEqual([])
151
+ })
152
+ })
@@ -6,6 +6,7 @@ import type {
6
6
  InitiativePhase,
7
7
  InitiativePresetDescriptor,
8
8
  InitiativePresetInputs,
9
+ InitiativeQa,
9
10
  InitiativeStatus,
10
11
  } from '~/types/domain'
11
12
 
@@ -111,6 +112,37 @@ export function initiativeProgress(
111
112
  }
112
113
  }
113
114
 
115
+ /**
116
+ * Whether a planning-interview question still needs a human answer: not dismissed, and no answer
117
+ * yet. Mirrors the backend `isPendingQuestion` (orchestration `initiative.logic.ts`) — the rule the
118
+ * interviewer, the retained-across-rounds digest and the continue gate all key off — so the window's
119
+ * pending list, its unanswered counter and its render order can never disagree with the engine
120
+ * about what is still open.
121
+ */
122
+ export function isPendingQuestion(q: Partial<Pick<InitiativeQa, 'answer' | 'status'>>): boolean {
123
+ return q.status !== 'dismissed' && (q.answer ?? '').trim().length === 0
124
+ }
125
+
126
+ /**
127
+ * Interview questions in the order the planning window renders them: everything still pending
128
+ * first, everything already settled (answered, or dismissed as not relevant) after, each group
129
+ * keeping the interviewer's own chronological order.
130
+ *
131
+ * Each round APPENDS its new questions after the digest retained from the previous ones (backend
132
+ * `applyInterviewQuestions`: `[...retainedQa, ...pending]`), so from round two onwards the only
133
+ * questions the human still has to act on sit below a growing wall of ones they already settled —
134
+ * on a long interview, below the fold entirely. This reorders the RENDER only; the stored `qa`
135
+ * order, which the interviewer prompt and the in-repo tracker digest read, is untouched.
136
+ */
137
+ export function orderInterviewQuestions<T extends Partial<Pick<InitiativeQa, 'answer' | 'status'>>>(
138
+ qa: readonly T[],
139
+ ): T[] {
140
+ const pending: T[] = []
141
+ const settled: T[] = []
142
+ for (const q of qa) (isPendingQuestion(q) ? pending : settled).push(q)
143
+ return [...pending, ...settled]
144
+ }
145
+
114
146
  /**
115
147
  * The phase whose completed checkpoint (D2) is awaiting a human, or null. Mirrors the backend
116
148
  * `pendingCheckpoint` (orchestration `initiative.logic.ts`) so the SPA recomputes the pending