@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.
package/README.md CHANGED
@@ -131,7 +131,10 @@ example ships in [`deploy/frontend`](../../deploy/frontend) (the `acme:security`
131
131
 
132
132
  - **Board canvas** (`components/board`) — `BoardCanvas` + `nodes/` (`BlockNode`,
133
133
  `ModuleFrame`, `TaskCard`), dependency edges, the per-block `AgentFailureCard` /
134
- `AgentStopButton`, and a deep-zoom `focus/BlockFocusView`.
134
+ `AgentStopButton`, and a deep-zoom `focus/BlockFocusView`. A running task card expands
135
+ its build pipeline (`TaskPipelineMini`) on hover at any zoom level, and across every
136
+ on-screen card past the `steps` zoom band — the two grants are combined in the
137
+ `taskExpansion` store and driven by `useTaskExpansion`.
135
138
  - **Sidebar & chrome** (`components/layout`) — board/account switchers, palettes
136
139
  entry points, the language + [interface-mode](#interface-modes-basic--advanced)
137
140
  switchers, the `SpendWarningBanner`, and the toolbar (zoom, LOD, decision queue).
@@ -148,7 +151,10 @@ example ships in [`deploy/frontend`](../../deploy/frontend) (the `acme:security`
148
151
  - **Context attachments** (`components/context`) — `ContextAttachmentFields`, the
149
152
  shared staged-attachment form used by both the add-task and create-initiative
150
153
  modals. Picks are held locally and import-and-linked once the block exists (see
151
- `composables/useContextLinking`), because linking needs a block id.
154
+ `composables/useContextLinking`), because linking needs a block id. Both hosts
155
+ attach to the SAME per-block linkage, so the inspector's `TaskContextDocs` /
156
+ `TaskContextIssues` sections render for a task AND an initiative — an initiative's
157
+ attachments would otherwise be invisible the moment the create modal closed.
152
158
  - **Integrations** — modals/panels for `github` (the source-control panel, shared
153
159
  by every VCS provider), `vcs` (the GitLab personal-access-token connect),
154
160
  `bootstrap`, `documents`, `tasks`, `requirements` (review), `scenarios`
@@ -10,8 +10,9 @@ const task = computed(() => board.getBlock(props.taskId))
10
10
  const { draggingId, startDrag } = useBlockDrag()
11
11
 
12
12
  // An expanded pipeline grows downward over its neighbours, so it must stack above the
13
- // other (compact) task cards — never let a neighbour render on top of the pipeline.
14
- const expanded = computed(() => expansion.allowed.has(props.taskId))
13
+ // other (compact) task cards — never let a neighbour render on top of the pipeline. Reads
14
+ // the same predicate the pipeline itself renders on, so the two can't disagree.
15
+ const expanded = computed(() => expansion.isExpanded(props.taskId))
15
16
 
16
17
  // Once a task is merged it stops being a unit of work and becomes part of the
17
18
  // architecture: it no longer renders as a draggable card (arrows fall back to its
@@ -12,11 +12,11 @@ import { lodAtLeast } from '~/composables/useSemanticZoom'
12
12
  import { prReviewPhase } from '~/utils/prReviewProgress'
13
13
  import PrReviewPhaseBadge from '~/components/prReview/PrReviewPhaseBadge.vue'
14
14
 
15
- // Spatial drill-down inside a task card: at the `steps` zoom band the task's
16
- // build-pipeline steps appear, and one band deeper (`subtasks`) each step's live
17
- // todo breakdown expands — done / in-progress / pending — exactly the way a
18
- // zoomed-in bootstrap card reads. Renders nothing until the task has a run and
19
- // the user has zoomed in far enough, so it's safe to mount on every task card.
15
+ // Drill-down inside a task card: the task's build-pipeline steps appear on hover (at any
16
+ // zoom level) or once the `steps` zoom band is reached, and one band deeper (`subtasks`)
17
+ // each step's live todo breakdown expands — done / in-progress / pending — exactly the way
18
+ // a zoomed-in bootstrap card reads. Renders nothing until the task has a run and the card
19
+ // is expanded, so it's safe to mount on every task card.
20
20
  const props = defineProps<{ taskId: string }>()
21
21
 
22
22
  const execution = useExecutionStore()
@@ -40,13 +40,10 @@ const runFailed = computed(() => instance.value?.status === 'failed')
40
40
  // (spinning "Running") rather than a frozen subtask list.
41
41
  const companionByStep = computed(() => steps.value.map((s) => gateCompanionFor(s, runFailed.value)))
42
42
 
43
- // Expand the pipeline list only when zoomed in far enough AND the board driver
44
- // permits this card — on-screen, and the centre-most of any cards that would
45
- // otherwise overlap (see useTaskExpansion) so deep-zoom expansions don't pile up.
46
- const showSteps = computed(
47
- () =>
48
- lodAtLeast(lod.value, 'steps') && steps.value.length > 0 && expansion.canExpand(props.taskId),
49
- )
43
+ // Expand the pipeline list when the board driver says so: this card is hovered (at any
44
+ // zoom level), or the deep zoom bands granted it — on-screen, and the centre-most of any
45
+ // cards that would otherwise overlap. See stores/taskExpansion.ts.
46
+ const showSteps = computed(() => steps.value.length > 0 && expansion.isExpanded(props.taskId))
50
47
  const showItems = computed(() => lodAtLeast(lod.value, 'subtasks'))
51
48
 
52
49
  // Clicking a step opens the full agent step-detail overlay — execution metadata
@@ -19,7 +19,11 @@
19
19
  import { computed, reactive, watch } from 'vue'
20
20
  import InterviewGateNotice from '~/components/common/InterviewGateNotice.vue'
21
21
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
22
- import { interviewGatePhase } from '~/utils/interviewGate'
22
+ import {
23
+ DOC_INTERVIEWER_KIND,
24
+ interviewGatePhase,
25
+ interviewStepReached,
26
+ } from '~/utils/interviewGate'
23
27
 
24
28
  const board = useBoardStore()
25
29
  const docInterview = useDocInterviewStore()
@@ -63,7 +67,13 @@ const resuming = computed(() => docInterview.resuming)
63
67
  * actually says, so the questions come back rather than the window sticking on a spinner.
64
68
  */
65
69
  const phase = computed(() =>
66
- resuming.value ? 'working' : interviewGatePhase(session.value?.status, run.value?.status),
70
+ resuming.value
71
+ ? 'working'
72
+ : interviewGatePhase(
73
+ session.value?.status,
74
+ run.value?.status,
75
+ interviewStepReached(run.value, DOC_INTERVIEWER_KIND),
76
+ ),
67
77
  )
68
78
  /** The interview converged: the synthesized authoring brief is what the window shows. */
69
79
  const converged = computed(() => phase.value === 'converged')
@@ -133,10 +143,21 @@ const onProceed = () => flushThen((id) => docInterview.proceedInterview(id))
133
143
  {{ t('docInterview.intro') }}
134
144
  </p>
135
145
 
146
+ <!-- The run is still ahead of the interview (the researcher + outliner steps). Same
147
+ chrome, different claim: nothing has been asked yet, so the "working on your answers"
148
+ copy below would describe answers that do not exist. -->
149
+ <InterviewGateNotice
150
+ v-if="phase === 'preparing'"
151
+ variant="working"
152
+ :title="t('docInterview.preparing')"
153
+ :hint="t('docInterview.preparingHint')"
154
+ testid="doc-interview-preparing"
155
+ />
156
+
136
157
  <!-- A pass is running: the human is waiting on the interviewer. Without this the window is
137
158
  byte-identical to the parked state and the submit reads as a no-op. -->
138
159
  <InterviewGateNotice
139
- v-if="phase === 'working'"
160
+ v-else-if="phase === 'working'"
140
161
  variant="working"
141
162
  :title="t('docInterview.working')"
142
163
  :hint="t('docInterview.workingHint')"
@@ -4,8 +4,12 @@ import type { Block } from '~/types/domain'
4
4
  import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
5
5
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
6
6
 
7
- // Documents (from any source) attached to a task as agent context, shown inside
8
- // the InspectorPanel. Attaching uses the SAME inline picker as task creation
7
+ // Documents (from any source) attached to a task OR an initiative as agent
8
+ // context, shown inside the InspectorPanel. An initiative takes the same
9
+ // attachments (the create-initiative modal stages them exactly as the add-task one
10
+ // does) and its whole planning pipeline reads them, so it gets the same section —
11
+ // only the prose differs, which is why the hint/empty copy is level-keyed below.
12
+ // Attaching uses the SAME inline picker as task creation
9
13
  // (source selector + repo→file browse + free-text search + paste-by-reference —
10
14
  // ContextDocumentPicker), NOT the old dropdown that opened a second, page-level
11
15
  // "Import a page…" modal on top of the inspector. Stacked page-level modals don't
@@ -27,6 +31,18 @@ onMounted(() => {
27
31
  })
28
32
 
29
33
  const linked = computed(() => documents.docsForBlock(props.block.id))
34
+
35
+ // Two STATIC literal keys per string, picked by level — the copy names what reads the
36
+ // document (the agents implementing a task vs the pipeline that plans an initiative), which
37
+ // is the whole point of the hint. Assembling one key from `block.level` would defeat the
38
+ // typed message-key check for a two-member choice that gains nothing from being dynamic.
39
+ const isInitiative = computed(() => props.block.level === 'initiative')
40
+ const hint = computed(() =>
41
+ isInitiative.value ? t('documents.taskDocs.hintInitiative') : t('documents.taskDocs.hint'),
42
+ )
43
+ const emptyHint = computed(() =>
44
+ isInitiative.value ? t('documents.taskDocs.emptyInitiative') : t('documents.taskDocs.empty'),
45
+ )
30
46
  // Already-linked docs, so the inline picker filters them out / never re-offers them.
31
47
  const chosenKeys = computed(() =>
32
48
  linked.value.map((d) =>
@@ -71,7 +87,7 @@ async function attach(item: PendingContext) {
71
87
  <InspectorSection
72
88
  v-if="documents.available"
73
89
  :title="t('documents.taskDocs.heading')"
74
- :hint="t('documents.taskDocs.hint')"
90
+ :hint="hint"
75
91
  :count="linked.length"
76
92
  >
77
93
  <template #actions>
@@ -130,7 +146,7 @@ async function attach(item: PendingContext) {
130
146
  </a>
131
147
  </div>
132
148
  <p v-else class="text-[11px] text-slate-500">
133
- {{ t('documents.taskDocs.empty') }}
149
+ {{ emptyHint }}
134
150
  </p>
135
151
  </InspectorSection>
136
152
  </template>
@@ -4,8 +4,9 @@
4
4
  // (pending `qa` entries with an empty answer) are shown here; the human answers them, then either
5
5
  // SUBMITS them (the `continue` action: the interviewer re-runs and may ask follow-ups) or plans
6
6
  // now (the `proceed` action: skip the remaining questions — the interviewer converges and the run
7
- // advances to the analyst/planner). The labels say submit/plan-now rather than continue/proceed
8
- // because the latter pair both read as "go forward" and were indistinguishable in use.
7
+ // advances to the planner; the analyst already ran, ahead of this gate). The labels say
8
+ // submit/plan-now rather than continue/proceed because the latter pair both read as "go forward"
9
+ // and were indistinguishable in use.
9
10
  // Opened via the universal result-view host: from the inspector / card
10
11
  // (`ui.openInitiativePlanning`) or as the interviewer step's result view. Live `initiative`
11
12
  // stream events patch the store, so an open window follows the interview as it progresses.
@@ -29,21 +30,44 @@ import {
29
30
  isPendingQuestion,
30
31
  orderInterviewQuestions,
31
32
  } from '~/utils/initiative'
32
- import { interviewGatePhase } from '~/utils/interviewGate'
33
+ import {
34
+ INITIATIVE_INTERVIEWER_KIND,
35
+ interviewGatePhase,
36
+ interviewStepReached,
37
+ } from '~/utils/interviewGate'
33
38
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
39
+ import StepRunMeta from '~/components/panels/StepRunMeta.vue'
34
40
 
35
41
  const board = useBoardStore()
36
42
  const initiatives = useInitiativesStore()
37
- const execution = useExecutionStore()
38
43
  const { t } = useI18n()
39
44
 
40
- const { open, blockId, close } = useResultView('initiative-planning', {
45
+ const { open, blockId, instanceId, stepIndex, close } = useResultView('initiative-planning', {
41
46
  onOpen: ({ blockId }) => void initiatives.load(blockId),
42
47
  })
43
48
 
44
49
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
45
50
  const initiative = computed(() => (blockId.value ? initiatives.forBlock(blockId.value) : null))
46
- const run = computed(() => (blockId.value ? execution.getByBlock(blockId.value) : undefined))
51
+
52
+ /**
53
+ * The planning run + the interviewer step's run details, resolved through the shared seam so this
54
+ * window reports the same "which run is this / how did the model do" facts as every other agent
55
+ * window — including on the card/inspector entry point, which carries no step (see
56
+ * `useResultViewRunMeta`). `run` is the same instance the phase below reads.
57
+ */
58
+ const {
59
+ instance: run,
60
+ step: metaStep,
61
+ instanceId: runId,
62
+ position,
63
+ totalSteps,
64
+ runFailed,
65
+ failureAt,
66
+ } = useResultViewRunMeta('initiative-planning', {
67
+ blockId: () => blockId.value,
68
+ instanceId: () => instanceId.value,
69
+ stepIndex: () => stepIndex.value,
70
+ })
47
71
 
48
72
  /** Every interview exchange, with a stable key for the list + draft map. */
49
73
  const questions = computed(() =>
@@ -98,7 +122,11 @@ const resuming = computed(() => initiatives.resuming)
98
122
  const phase = computed(() =>
99
123
  resuming.value
100
124
  ? 'working'
101
- : interviewGatePhase(initiative.value?.interview?.status, run.value?.status),
125
+ : interviewGatePhase(
126
+ initiative.value?.interview?.status,
127
+ run.value?.status,
128
+ interviewStepReached(run.value, INITIATIVE_INTERVIEWER_KIND),
129
+ ),
102
130
  )
103
131
 
104
132
  /**
@@ -184,7 +212,7 @@ async function onDiscard() {
184
212
  icon-class="bg-indigo-500/15 text-indigo-300"
185
213
  :title="initiative?.title ?? block?.title ?? t('initiative.planning.title')"
186
214
  :subtitle="t('initiative.planning.subtitle')"
187
- width="3xl"
215
+ width="4xl"
188
216
  testid="initiative-planning-window"
189
217
  @close="close"
190
218
  >
@@ -194,86 +222,122 @@ async function onDiscard() {
194
222
  </UBadge>
195
223
  </template>
196
224
 
197
- <div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
198
- <!-- No entity yet -->
199
- <div
200
- v-if="!initiative"
201
- class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
202
- >
203
- <UIcon name="i-lucide-messages-square" class="h-8 w-8 opacity-40" />
204
- <p class="text-sm">{{ t('initiative.planning.empty') }}</p>
205
- </div>
225
+ <div class="flex min-h-0 flex-1">
226
+ <div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
227
+ <!-- No entity yet -->
228
+ <div
229
+ v-if="!initiative"
230
+ class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
231
+ >
232
+ <UIcon name="i-lucide-messages-square" class="h-8 w-8 opacity-40" />
233
+ <p class="text-sm">{{ t('initiative.planning.empty') }}</p>
234
+ </div>
206
235
 
207
- <template v-else>
208
- <p class="mb-4 text-[13px] leading-relaxed text-slate-300">
209
- {{ t('initiative.planning.intro') }}
210
- </p>
236
+ <template v-else>
237
+ <p class="mb-4 text-[13px] leading-relaxed text-slate-300">
238
+ {{ t('initiative.planning.intro') }}
239
+ </p>
211
240
 
212
- <!-- A pass is running: the human is waiting on the planner. Without this the window is
213
- byte-identical to the parked state and the submit reads as a no-op. -->
214
- <InterviewGateNotice
215
- v-if="phase === 'working'"
216
- variant="working"
217
- :title="t('initiative.planning.working')"
218
- :hint="t('initiative.planning.workingHint')"
219
- testid="initiative-planning-working"
220
- />
241
+ <!-- The run is still ahead of the interview the codebase analysis that grounds it. It
242
+ wears the working chrome but says something different on purpose: nothing has been
243
+ asked yet, so "working on your answers" would describe answers that do not exist. -->
244
+ <InterviewGateNotice
245
+ v-if="phase === 'preparing'"
246
+ variant="working"
247
+ :title="t('initiative.planning.preparing')"
248
+ :hint="t('initiative.planning.preparingHint')"
249
+ testid="initiative-planning-preparing"
250
+ />
221
251
 
222
- <!-- The planning run stopped before the interview settled a dead end otherwise. -->
223
- <InterviewGateNotice
224
- v-else-if="phase === 'failed'"
225
- variant="failed"
226
- :title="t('initiative.planning.failed')"
227
- :hint="t('initiative.planning.failedHint')"
228
- testid="initiative-planning-failed"
229
- />
252
+ <!-- A pass is running: the human is waiting on the planner. Without this the window is
253
+ byte-identical to the parked state and the submit reads as a no-op. -->
254
+ <InterviewGateNotice
255
+ v-else-if="phase === 'working'"
256
+ variant="working"
257
+ :title="t('initiative.planning.working')"
258
+ :hint="t('initiative.planning.workingHint')"
259
+ testid="initiative-planning-working"
260
+ />
230
261
 
231
- <!-- Planning was never started, so there is nothing to answer YET (distinct from
232
- converged, which means the planner already has what it needs). -->
233
- <div
234
- v-else-if="phase === 'idle' && questions.length === 0"
235
- class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
236
- data-testid="initiative-planning-idle"
237
- >
238
- {{ t('initiative.planning.idle') }}
239
- </div>
262
+ <!-- The planning run stopped before the interview settled a dead end otherwise. -->
263
+ <InterviewGateNotice
264
+ v-else-if="phase === 'failed'"
265
+ variant="failed"
266
+ :title="t('initiative.planning.failed')"
267
+ :hint="t('initiative.planning.failedHint')"
268
+ testid="initiative-planning-failed"
269
+ />
240
270
 
241
- <!-- Converged / no pending questions -->
242
- <div
243
- v-else-if="phase === 'converged' || questions.length === 0"
244
- class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
245
- data-testid="initiative-planning-converged"
246
- >
247
- {{ t('initiative.planning.converged') }}
248
- </div>
271
+ <!-- Planning was never started, so there is nothing to answer YET (distinct from
272
+ converged, which means the planner already has what it needs). -->
273
+ <div
274
+ v-else-if="phase === 'idle' && questions.length === 0"
275
+ class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
276
+ data-testid="initiative-planning-idle"
277
+ >
278
+ {{ t('initiative.planning.idle') }}
279
+ </div>
249
280
 
250
- <!-- Interview questions the shared clarification surface (answer / not-relevant /
251
- recommend), reused with the requirements-review window. -->
252
- <ul v-else class="space-y-4">
253
- <li v-for="q in orderedQuestions" :key="q.key" data-testid="initiative-planning-question">
254
- <ClarificationItem
255
- v-model:answer="drafts[q.key]"
256
- :prompt="q.question"
257
- :dismissed="q.status === 'dismissed'"
258
- :recommendation="q.recommendation"
259
- :recommending="!!q.id && initiatives.recommending.has(q.id)"
260
- :answer-placeholder="t('initiative.planning.answerPlaceholder')"
261
- @persist="persist(q)"
262
- @dismiss="setStatus(q, 'dismissed')"
263
- @reopen="setStatus(q, 'open')"
264
- @recommend="recommend(q)"
265
- @use-recommendation="useRecommendation(q)"
266
- />
267
- </li>
268
- </ul>
269
- </template>
281
+ <!-- Converged / no pending questions -->
282
+ <div
283
+ v-else-if="phase === 'converged' || questions.length === 0"
284
+ class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
285
+ data-testid="initiative-planning-converged"
286
+ >
287
+ {{ t('initiative.planning.converged') }}
288
+ </div>
289
+
290
+ <!-- Interview questions — the shared clarification surface (answer / not-relevant /
291
+ recommend), reused with the requirements-review window. -->
292
+ <ul v-else class="space-y-4">
293
+ <li
294
+ v-for="q in orderedQuestions"
295
+ :key="q.key"
296
+ data-testid="initiative-planning-question"
297
+ >
298
+ <ClarificationItem
299
+ v-model:answer="drafts[q.key]"
300
+ :prompt="q.question"
301
+ :dismissed="q.status === 'dismissed'"
302
+ :recommendation="q.recommendation"
303
+ :recommending="!!q.id && initiatives.recommending.has(q.id)"
304
+ :answer-placeholder="t('initiative.planning.answerPlaceholder')"
305
+ @persist="persist(q)"
306
+ @dismiss="setStatus(q, 'dismissed')"
307
+ @reopen="setStatus(q, 'open')"
308
+ @recommend="recommend(q)"
309
+ @use-recommendation="useRecommendation(q)"
310
+ />
311
+ </li>
312
+ </ul>
313
+ </template>
314
+ </div>
315
+
316
+ <!-- Run details: the shared run-metadata + LLM model-activity block every agent window
317
+ carries (step position, live duration, model, run id, calls + token usage). Resolved
318
+ through `useResultViewRunMeta`, so it is present on the card / inspector entry point
319
+ too — where this window carries no step index of its own. -->
320
+ <aside
321
+ v-if="metaStep"
322
+ data-testid="initiative-planning-run-meta"
323
+ class="hidden w-60 shrink-0 flex-col gap-4 overflow-y-auto border-s border-slate-800 bg-slate-900/50 px-4 py-4 lg:flex"
324
+ >
325
+ <StepRunMeta
326
+ :step="metaStep"
327
+ :instance-id="runId"
328
+ :step-number="position"
329
+ :total-steps="totalSteps"
330
+ :run-failed="runFailed"
331
+ :failure-at="failureAt"
332
+ />
333
+ </aside>
270
334
  </div>
271
335
 
272
336
  <!-- Action rail. The submit/plan-now pair shows only while the run is actually parked on the
273
337
  human: mid-pass they would re-submit a question set already in flight, and the resume is a
274
338
  no-op once it isn't. Discard is the opposite — it is offered for as long as a run owns the
275
- block, because the phases where those two are hidden (working, failed) are exactly the ones
276
- a wedged run sits in. -->
339
+ block, because the phases where those two are hidden (preparing, working, failed) are
340
+ exactly the ones a wedged run sits in. -->
277
341
  <footer
278
342
  v-if="initiative && (canDiscard || (phase === 'awaiting' && questions.length > 0))"
279
343
  class="flex items-center justify-between gap-3 border-t border-slate-800 px-5 py-3"