@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.
package/README.md CHANGED
@@ -148,7 +148,10 @@ example ships in [`deploy/frontend`](../../deploy/frontend) (the `acme:security`
148
148
  - **Context attachments** (`components/context`) — `ContextAttachmentFields`, the
149
149
  shared staged-attachment form used by both the add-task and create-initiative
150
150
  modals. Picks are held locally and import-and-linked once the block exists (see
151
- `composables/useContextLinking`), because linking needs a block id.
151
+ `composables/useContextLinking`), because linking needs a block id. Both hosts
152
+ attach to the SAME per-block linkage, so the inspector's `TaskContextDocs` /
153
+ `TaskContextIssues` sections render for a task AND an initiative — an initiative's
154
+ attachments would otherwise be invisible the moment the create modal closed.
152
155
  - **Integrations** — modals/panels for `github` (the source-control panel, shared
153
156
  by every VCS provider), `vcs` (the GitLab personal-access-token connect),
154
157
  `bootstrap`, `documents`, `tasks`, `requirements` (review), `scenarios`
@@ -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>
@@ -10,40 +10,66 @@
10
10
  // (`ui.openInitiativePlanning`) or as the interviewer step's result view. Live `initiative`
11
11
  // stream events patch the store, so an open window follows the interview as it progresses.
12
12
  //
13
+ // An interview runs over MULTIPLE ROUNDS and the entity keeps the settled ones, so the list is a
14
+ // mix of what the human still owes an answer and what they already dealt with. It renders pending
15
+ // first (`orderInterviewQuestions`) — see the `order` snapshot below for why that is recomputed per
16
+ // round rather than live.
17
+ //
13
18
  // CONTINUE/PROCEED ARE ASYNC. They only record the intent on the parked step and wake the durable
14
19
  // driver; the interviewer LLM then runs for as long as it takes, and the response carries the
15
20
  // PRE-resume entity. So the window must not key its body on the entity alone — that renders
16
21
  // identically before and after the click, which reads as the button having done nothing. The
17
22
  // phase below folds the planning RUN's status in, so the wait is visible and a failed pass says
18
23
  // so instead of leaving the human staring at questions they already submitted.
19
- import { computed, reactive, watch } from 'vue'
24
+ import { computed, reactive, ref, watch } from 'vue'
20
25
  import ClarificationItem from '~/components/common/ClarificationItem.vue'
21
26
  import InterviewGateNotice from '~/components/common/InterviewGateNotice.vue'
22
- import { INITIATIVE_STATUS_LABEL_KEYS } from '~/utils/initiative'
27
+ import {
28
+ INITIATIVE_STATUS_LABEL_KEYS,
29
+ isPendingQuestion,
30
+ orderInterviewQuestions,
31
+ } from '~/utils/initiative'
23
32
  import { interviewGatePhase } from '~/utils/interviewGate'
24
33
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
34
+ import StepRunMeta from '~/components/panels/StepRunMeta.vue'
25
35
 
26
36
  const board = useBoardStore()
27
37
  const initiatives = useInitiativesStore()
28
- const execution = useExecutionStore()
29
38
  const { t } = useI18n()
30
39
 
31
- const { open, blockId, close } = useResultView('initiative-planning', {
40
+ const { open, blockId, instanceId, stepIndex, close } = useResultView('initiative-planning', {
32
41
  onOpen: ({ blockId }) => void initiatives.load(blockId),
33
42
  })
34
43
 
35
44
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
36
45
  const initiative = computed(() => (blockId.value ? initiatives.forBlock(blockId.value) : null))
37
- const run = computed(() => (blockId.value ? execution.getByBlock(blockId.value) : undefined))
46
+
47
+ /**
48
+ * The planning run + the interviewer step's run details, resolved through the shared seam so this
49
+ * window reports the same "which run is this / how did the model do" facts as every other agent
50
+ * window — including on the card/inspector entry point, which carries no step (see
51
+ * `useResultViewRunMeta`). `run` is the same instance the phase below reads.
52
+ */
53
+ const {
54
+ instance: run,
55
+ step: metaStep,
56
+ instanceId: runId,
57
+ position,
58
+ totalSteps,
59
+ runFailed,
60
+ failureAt,
61
+ } = useResultViewRunMeta('initiative-planning', {
62
+ blockId: () => blockId.value,
63
+ instanceId: () => instanceId.value,
64
+ stepIndex: () => stepIndex.value,
65
+ })
38
66
 
39
67
  /** Every interview exchange, with a stable key for the list + draft map. */
40
68
  const questions = computed(() =>
41
69
  (initiative.value?.qa ?? []).map((q, i) => ({ ...q, key: q.id ?? `q-${i}` })),
42
70
  )
43
71
  /** Questions still needing an answer: not dismissed, and not yet answered (mirrors backend). */
44
- const pending = computed(() =>
45
- questions.value.filter((q) => q.status !== 'dismissed' && !(q.answer ?? '').trim()),
46
- )
72
+ const pending = computed(() => questions.value.filter(isPendingQuestion))
47
73
 
48
74
  // Per-question answer drafts, seeded from the entity and refreshed as new rounds arrive
49
75
  // without clobbering an answer the human is mid-edit on.
@@ -58,6 +84,28 @@ watch(
58
84
  { immediate: true },
59
85
  )
60
86
 
87
+ /**
88
+ * Render order (pending first — see `orderInterviewQuestions`), re-snapshotted ONLY when the
89
+ * question SET changes, i.e. when a round lands. Deriving it live from the answers instead would
90
+ * yank a question out from under the human the moment they blurred its textarea and shuffle
91
+ * everything below it up, while they are reading down the list. Re-snapshotting per ROUND rather
92
+ * than per question is what keeps a window left open across rounds correct: a question answered in
93
+ * round one has to sink below round two's new ones, which a rank frozen at first sight never would.
94
+ */
95
+ const order = ref<string[]>([])
96
+ watch(
97
+ () => questions.value.map((q) => q.key).join('|'),
98
+ () => {
99
+ order.value = orderInterviewQuestions(questions.value).map((q) => q.key)
100
+ },
101
+ { immediate: true },
102
+ )
103
+ const orderedQuestions = computed(() => {
104
+ const rank = new Map(order.value.map((key, i) => [key, i]))
105
+ // A question the snapshot has not seen is by definition new, so it is pending and sorts first.
106
+ return [...questions.value].sort((a, b) => (rank.get(a.key) ?? -1) - (rank.get(b.key) ?? -1))
107
+ })
108
+
61
109
  const resuming = computed(() => initiatives.resuming)
62
110
 
63
111
  /**
@@ -155,7 +203,7 @@ async function onDiscard() {
155
203
  icon-class="bg-indigo-500/15 text-indigo-300"
156
204
  :title="initiative?.title ?? block?.title ?? t('initiative.planning.title')"
157
205
  :subtitle="t('initiative.planning.subtitle')"
158
- width="3xl"
206
+ width="4xl"
159
207
  testid="initiative-planning-window"
160
208
  @close="close"
161
209
  >
@@ -165,79 +213,104 @@ async function onDiscard() {
165
213
  </UBadge>
166
214
  </template>
167
215
 
168
- <div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
169
- <!-- No entity yet -->
170
- <div
171
- v-if="!initiative"
172
- class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
173
- >
174
- <UIcon name="i-lucide-messages-square" class="h-8 w-8 opacity-40" />
175
- <p class="text-sm">{{ t('initiative.planning.empty') }}</p>
176
- </div>
216
+ <div class="flex min-h-0 flex-1">
217
+ <div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
218
+ <!-- No entity yet -->
219
+ <div
220
+ v-if="!initiative"
221
+ class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
222
+ >
223
+ <UIcon name="i-lucide-messages-square" class="h-8 w-8 opacity-40" />
224
+ <p class="text-sm">{{ t('initiative.planning.empty') }}</p>
225
+ </div>
177
226
 
178
- <template v-else>
179
- <p class="mb-4 text-[13px] leading-relaxed text-slate-300">
180
- {{ t('initiative.planning.intro') }}
181
- </p>
227
+ <template v-else>
228
+ <p class="mb-4 text-[13px] leading-relaxed text-slate-300">
229
+ {{ t('initiative.planning.intro') }}
230
+ </p>
182
231
 
183
- <!-- A pass is running: the human is waiting on the planner. Without this the window is
184
- byte-identical to the parked state and the submit reads as a no-op. -->
185
- <InterviewGateNotice
186
- v-if="phase === 'working'"
187
- variant="working"
188
- :title="t('initiative.planning.working')"
189
- :hint="t('initiative.planning.workingHint')"
190
- testid="initiative-planning-working"
191
- />
232
+ <!-- A pass is running: the human is waiting on the planner. Without this the window is
233
+ byte-identical to the parked state and the submit reads as a no-op. -->
234
+ <InterviewGateNotice
235
+ v-if="phase === 'working'"
236
+ variant="working"
237
+ :title="t('initiative.planning.working')"
238
+ :hint="t('initiative.planning.workingHint')"
239
+ testid="initiative-planning-working"
240
+ />
192
241
 
193
- <!-- The planning run stopped before the interview settled — a dead end otherwise. -->
194
- <InterviewGateNotice
195
- v-else-if="phase === 'failed'"
196
- variant="failed"
197
- :title="t('initiative.planning.failed')"
198
- :hint="t('initiative.planning.failedHint')"
199
- testid="initiative-planning-failed"
200
- />
242
+ <!-- The planning run stopped before the interview settled — a dead end otherwise. -->
243
+ <InterviewGateNotice
244
+ v-else-if="phase === 'failed'"
245
+ variant="failed"
246
+ :title="t('initiative.planning.failed')"
247
+ :hint="t('initiative.planning.failedHint')"
248
+ testid="initiative-planning-failed"
249
+ />
201
250
 
202
- <!-- Planning was never started, so there is nothing to answer YET (distinct from
203
- converged, which means the planner already has what it needs). -->
204
- <div
205
- v-else-if="phase === 'idle' && questions.length === 0"
206
- class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
207
- data-testid="initiative-planning-idle"
208
- >
209
- {{ t('initiative.planning.idle') }}
210
- </div>
251
+ <!-- Planning was never started, so there is nothing to answer YET (distinct from
252
+ converged, which means the planner already has what it needs). -->
253
+ <div
254
+ v-else-if="phase === 'idle' && questions.length === 0"
255
+ class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
256
+ data-testid="initiative-planning-idle"
257
+ >
258
+ {{ t('initiative.planning.idle') }}
259
+ </div>
211
260
 
212
- <!-- Converged / no pending questions -->
213
- <div
214
- v-else-if="phase === 'converged' || questions.length === 0"
215
- class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
216
- data-testid="initiative-planning-converged"
217
- >
218
- {{ t('initiative.planning.converged') }}
219
- </div>
261
+ <!-- Converged / no pending questions -->
262
+ <div
263
+ v-else-if="phase === 'converged' || questions.length === 0"
264
+ class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
265
+ data-testid="initiative-planning-converged"
266
+ >
267
+ {{ t('initiative.planning.converged') }}
268
+ </div>
220
269
 
221
- <!-- Interview questions — the shared clarification surface (answer / not-relevant /
222
- recommend), reused with the requirements-review window. -->
223
- <ul v-else class="space-y-4">
224
- <li v-for="q in questions" :key="q.key" data-testid="initiative-planning-question">
225
- <ClarificationItem
226
- v-model:answer="drafts[q.key]"
227
- :prompt="q.question"
228
- :dismissed="q.status === 'dismissed'"
229
- :recommendation="q.recommendation"
230
- :recommending="!!q.id && initiatives.recommending.has(q.id)"
231
- :answer-placeholder="t('initiative.planning.answerPlaceholder')"
232
- @persist="persist(q)"
233
- @dismiss="setStatus(q, 'dismissed')"
234
- @reopen="setStatus(q, 'open')"
235
- @recommend="recommend(q)"
236
- @use-recommendation="useRecommendation(q)"
237
- />
238
- </li>
239
- </ul>
240
- </template>
270
+ <!-- Interview questions — the shared clarification surface (answer / not-relevant /
271
+ recommend), reused with the requirements-review window. -->
272
+ <ul v-else class="space-y-4">
273
+ <li
274
+ v-for="q in orderedQuestions"
275
+ :key="q.key"
276
+ data-testid="initiative-planning-question"
277
+ >
278
+ <ClarificationItem
279
+ v-model:answer="drafts[q.key]"
280
+ :prompt="q.question"
281
+ :dismissed="q.status === 'dismissed'"
282
+ :recommendation="q.recommendation"
283
+ :recommending="!!q.id && initiatives.recommending.has(q.id)"
284
+ :answer-placeholder="t('initiative.planning.answerPlaceholder')"
285
+ @persist="persist(q)"
286
+ @dismiss="setStatus(q, 'dismissed')"
287
+ @reopen="setStatus(q, 'open')"
288
+ @recommend="recommend(q)"
289
+ @use-recommendation="useRecommendation(q)"
290
+ />
291
+ </li>
292
+ </ul>
293
+ </template>
294
+ </div>
295
+
296
+ <!-- Run details: the shared run-metadata + LLM model-activity block every agent window
297
+ carries (step position, live duration, model, run id, calls + token usage). Resolved
298
+ through `useResultViewRunMeta`, so it is present on the card / inspector entry point
299
+ too — where this window carries no step index of its own. -->
300
+ <aside
301
+ v-if="metaStep"
302
+ data-testid="initiative-planning-run-meta"
303
+ 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"
304
+ >
305
+ <StepRunMeta
306
+ :step="metaStep"
307
+ :instance-id="runId"
308
+ :step-number="position"
309
+ :total-steps="totalSteps"
310
+ :run-failed="runFailed"
311
+ :failure-at="failureAt"
312
+ />
313
+ </aside>
241
314
  </div>
242
315
 
243
316
  <!-- Action rail. The submit/plan-now pair shows only while the run is actually parked on the