@cat-factory/app 0.172.0 → 0.174.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.
Files changed (35) hide show
  1. package/README.md +4 -0
  2. package/app/components/board/AddTaskModal.vue +10 -252
  3. package/app/components/board/CreateInitiativeModal.vue +27 -0
  4. package/app/components/board/nodes/InitiativeCard.vue +14 -0
  5. package/app/components/common/InterviewGateNotice.vue +39 -0
  6. package/app/components/context/ContextAttachmentFields.vue +289 -0
  7. package/app/components/docs/DocInterviewWindow.vue +77 -14
  8. package/app/components/focus/BlockFocusView.vue +35 -1
  9. package/app/components/fragments/FragmentLibraryManager.vue +91 -13
  10. package/app/components/fragments/GitHubDocUrlImport.vue +83 -0
  11. package/app/components/github/RepoTreeBrowser.vue +65 -10
  12. package/app/components/initiative/InitiativePlanningWindow.vue +127 -16
  13. package/app/components/panels/InspectorPanel.vue +17 -3
  14. package/app/components/panels/inspector/InitiativeInspector.vue +15 -0
  15. package/app/components/panels/inspector/TaskExecution.vue +5 -20
  16. package/app/composables/useBlockDeletion.ts +8 -1
  17. package/app/composables/useContextLinking.ts +14 -2
  18. package/app/composables/useInitiativePlanning.ts +28 -6
  19. package/app/composables/useRunReset.ts +48 -0
  20. package/app/modular/panels/inspector.logic.spec.ts +8 -2
  21. package/app/modular/panels/inspector.logic.ts +14 -2
  22. package/app/stores/observability.ts +4 -0
  23. package/app/utils/interviewGate.spec.ts +51 -0
  24. package/app/utils/interviewGate.ts +51 -0
  25. package/i18n/locales/de.json +66 -25
  26. package/i18n/locales/en.json +69 -25
  27. package/i18n/locales/es.json +66 -25
  28. package/i18n/locales/fr.json +66 -25
  29. package/i18n/locales/he.json +66 -25
  30. package/i18n/locales/it.json +66 -25
  31. package/i18n/locales/ja.json +66 -25
  32. package/i18n/locales/pl.json +66 -25
  33. package/i18n/locales/tr.json +66 -25
  34. package/i18n/locales/uk.json +66 -25
  35. package/package.json +2 -2
@@ -0,0 +1,83 @@
1
+ <script setup lang="ts">
2
+ // Paste a GitHub/GitLab file or directory URL to start a document-fragment link without
3
+ // the repo typeahead: the URL is parsed client-side (owner/repo + path + file-vs-dir) and
4
+ // the repo resolved through the SHARED available-repos list by its exact slug — the
5
+ // backend point-reads an exact `owner/name`, so this never depends on the provider's
6
+ // name search (where a pasted URL matches nothing). Emits the resolved repo + location;
7
+ // the manager stages a file or opens the tree browser at the directory for bulk picking.
8
+ import { parseRepoWebUrl } from '@cat-factory/contracts'
9
+ import type { GitHubAvailableRepo } from '~/types/domain'
10
+
11
+ const emit = defineEmits<{
12
+ resolved: [{ repo: GitHubAvailableRepo; path: string; kind: 'file' | 'dir' }]
13
+ }>()
14
+
15
+ const { t } = useI18n()
16
+ const github = useGitHubStore()
17
+
18
+ const url = ref('')
19
+ const resolving = ref(false)
20
+ const error = ref<string | null>(null)
21
+
22
+ async function importUrl() {
23
+ const input = url.value.trim()
24
+ if (!input || resolving.value) return
25
+ const parsed = parseRepoWebUrl(input)
26
+ if (!parsed) {
27
+ error.value = t('fragments.documents.urlImport.invalid')
28
+ return
29
+ }
30
+ resolving.value = true
31
+ error.value = null
32
+ const slug = `${parsed.owner}/${parsed.repo}`
33
+ try {
34
+ // Load into the SHARED picker list (not the side-effect-free search) so the repo
35
+ // select alongside this field can render the resolved selection's label.
36
+ await github.loadAvailableRepos(slug)
37
+ const repo = github.availableRepos.find(
38
+ (r) =>
39
+ r.owner.toLowerCase() === parsed.owner.toLowerCase() &&
40
+ r.name.toLowerCase() === parsed.repo.toLowerCase(),
41
+ )
42
+ if (!repo) {
43
+ error.value = t('fragments.documents.urlImport.notFound', { slug })
44
+ return
45
+ }
46
+ emit('resolved', { repo, path: parsed.path, kind: parsed.kind })
47
+ url.value = ''
48
+ } catch (e) {
49
+ error.value = e instanceof Error ? e.message : String(e)
50
+ } finally {
51
+ resolving.value = false
52
+ }
53
+ }
54
+ </script>
55
+
56
+ <template>
57
+ <div class="flex flex-col gap-1">
58
+ <div class="flex gap-2">
59
+ <UInput
60
+ v-model="url"
61
+ icon="i-lucide-link-2"
62
+ :placeholder="t('fragments.documents.urlImport.placeholder')"
63
+ class="flex-1"
64
+ data-testid="fragment-url-import-input"
65
+ @keyup.enter="importUrl"
66
+ />
67
+ <UButton
68
+ size="sm"
69
+ variant="outline"
70
+ icon="i-lucide-folder-search"
71
+ :loading="resolving"
72
+ :disabled="!url.trim()"
73
+ data-testid="fragment-url-import-button"
74
+ @click="importUrl"
75
+ >
76
+ {{ t('fragments.documents.urlImport.action') }}
77
+ </UButton>
78
+ </div>
79
+ <p v-if="error" class="text-xs text-red-400" data-testid="fragment-url-import-error">
80
+ {{ error }}
81
+ </p>
82
+ </div>
83
+ </template>
@@ -62,6 +62,22 @@ const isEmpty = computed(() =>
62
62
  props.mode === 'dir' ? dirEntries.value.length === 0 : treeEntries.value.length === 0,
63
63
  )
64
64
 
65
+ // file + multiple: the bulk-pick header. "Select all" checks every file of the CURRENT
66
+ // listing that isn't already picked or added elsewhere; unchecking clears only this
67
+ // listing's picks (never the cart entries staged from other folders).
68
+ const selectableFiles = computed(() =>
69
+ props.mode === 'file' && props.multiple ? fileEntries.value.filter((e) => !isAdded(e.path)) : [],
70
+ )
71
+ const allSelected = computed(
72
+ () => selectableFiles.value.length > 0 && selectableFiles.value.every((e) => isPicked(e.path)),
73
+ )
74
+ function toggleAllFiles() {
75
+ const check = !allSelected.value
76
+ for (const entry of selectableFiles.value) {
77
+ if (isPicked(entry.path) !== check) emit('toggle', entry.path)
78
+ }
79
+ }
80
+
65
81
  const breadcrumbs = computed(() => {
66
82
  const segments = currentPath.value ? currentPath.value.split('/') : []
67
83
  let acc = ''
@@ -175,21 +191,60 @@ watch(
175
191
  </UButton>
176
192
  </li>
177
193
  <template v-if="mode === 'file'">
194
+ <!-- multiple: a bulk header so a whole directory of documents is one click -->
178
195
  <li
179
- v-for="entry in fileEntries"
180
- :key="entry.path"
181
- class="flex items-center justify-between gap-2 px-3 py-1.5"
196
+ v-if="selectableFiles.length > 1"
197
+ class="flex items-center gap-2 bg-slate-900/60 px-3 py-1.5"
182
198
  >
199
+ <UCheckbox
200
+ :model-value="allSelected"
201
+ :aria-label="
202
+ t(
203
+ 'github.repoTree.selectAllFiles',
204
+ { count: selectableFiles.length },
205
+ selectableFiles.length,
206
+ )
207
+ "
208
+ data-testid="repo-tree-select-all"
209
+ @update:model-value="toggleAllFiles"
210
+ />
183
211
  <button
184
212
  type="button"
185
- class="flex items-center gap-2 truncate text-sm hover:text-primary-400"
186
- :class="isPicked(entry.path) ? 'text-primary-400' : 'text-slate-300'"
187
- :disabled="isAdded(entry.path)"
188
- @click="pick(entry.path)"
213
+ class="text-xs text-slate-400 hover:text-primary-400"
214
+ @click="toggleAllFiles"
189
215
  >
190
- <UIcon name="i-lucide-file" class="h-4 w-4 shrink-0 text-slate-400" />
191
- <span class="truncate">{{ entry.name }}</span>
216
+ {{
217
+ t(
218
+ 'github.repoTree.selectAllFiles',
219
+ { count: selectableFiles.length },
220
+ selectableFiles.length,
221
+ )
222
+ }}
192
223
  </button>
224
+ </li>
225
+ <li
226
+ v-for="entry in fileEntries"
227
+ :key="entry.path"
228
+ class="flex items-center justify-between gap-2 px-3 py-1.5"
229
+ >
230
+ <div class="flex min-w-0 items-center gap-2">
231
+ <UCheckbox
232
+ v-if="multiple && !isAdded(entry.path)"
233
+ :model-value="isPicked(entry.path)"
234
+ :aria-label="entry.name"
235
+ @update:model-value="pick(entry.path)"
236
+ />
237
+ <button
238
+ type="button"
239
+ class="flex items-center gap-2 truncate text-sm hover:text-primary-400"
240
+ :class="isPicked(entry.path) ? 'text-primary-400' : 'text-slate-300'"
241
+ :disabled="isAdded(entry.path)"
242
+ @click="pick(entry.path)"
243
+ >
244
+ <UIcon name="i-lucide-file" class="h-4 w-4 shrink-0 text-slate-400" />
245
+ <span class="truncate">{{ entry.name }}</span>
246
+ </button>
247
+ </div>
193
248
  <span
194
249
  v-if="isAdded(entry.path)"
195
250
  class="flex shrink-0 items-center gap-1 text-xs text-slate-500"
@@ -198,7 +253,7 @@ watch(
198
253
  {{ t('github.repoTree.added') }}
199
254
  </span>
200
255
  <UIcon
201
- v-else-if="isPicked(entry.path)"
256
+ v-else-if="!multiple && isPicked(entry.path)"
202
257
  name="i-lucide-check"
203
258
  class="h-4 w-4 shrink-0 text-primary-400"
204
259
  />
@@ -1,19 +1,31 @@
1
1
  <script setup lang="ts">
2
2
  // The interactive-planning Q&A window (slice 2) — the dedicated view of the initiative
3
3
  // INTERVIEWER gate. While the planning run is parked, the interviewer's clarifying questions
4
- // (pending `qa` entries with an empty answer) are shown here; the human answers them, then
5
- // either CONTINUES (the interviewer re-runs and may ask follow-ups) or PROCEEDS (skip
6
- // remaining questions — the interviewer converges and the run advances to the analyst/planner).
4
+ // (pending `qa` entries with an empty answer) are shown here; the human answers them, then either
5
+ // SUBMITS them (the `continue` action: the interviewer re-runs and may ask follow-ups) or plans
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
9
  // Opened via the universal result-view host: from the inspector / card
8
10
  // (`ui.openInitiativePlanning`) or as the interviewer step's result view. Live `initiative`
9
11
  // stream events patch the store, so an open window follows the interview as it progresses.
12
+ //
13
+ // CONTINUE/PROCEED ARE ASYNC. They only record the intent on the parked step and wake the durable
14
+ // driver; the interviewer LLM then runs for as long as it takes, and the response carries the
15
+ // PRE-resume entity. So the window must not key its body on the entity alone — that renders
16
+ // identically before and after the click, which reads as the button having done nothing. The
17
+ // phase below folds the planning RUN's status in, so the wait is visible and a failed pass says
18
+ // so instead of leaving the human staring at questions they already submitted.
10
19
  import { computed, reactive, watch } from 'vue'
11
20
  import ClarificationItem from '~/components/common/ClarificationItem.vue'
21
+ import InterviewGateNotice from '~/components/common/InterviewGateNotice.vue'
12
22
  import { INITIATIVE_STATUS_LABEL_KEYS } from '~/utils/initiative'
23
+ import { interviewGatePhase } from '~/utils/interviewGate'
13
24
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
14
25
 
15
26
  const board = useBoardStore()
16
27
  const initiatives = useInitiativesStore()
28
+ const execution = useExecutionStore()
17
29
  const { t } = useI18n()
18
30
 
19
31
  const { open, blockId, close } = useResultView('initiative-planning', {
@@ -22,6 +34,7 @@ const { open, blockId, close } = useResultView('initiative-planning', {
22
34
 
23
35
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
24
36
  const initiative = computed(() => (blockId.value ? initiatives.forBlock(blockId.value) : null))
37
+ const run = computed(() => (blockId.value ? execution.getByBlock(blockId.value) : undefined))
25
38
 
26
39
  /** Every interview exchange, with a stable key for the list + draft map. */
27
40
  const questions = computed(() =>
@@ -31,8 +44,6 @@ const questions = computed(() =>
31
44
  const pending = computed(() =>
32
45
  questions.value.filter((q) => q.status !== 'dismissed' && !(q.answer ?? '').trim()),
33
46
  )
34
- /** The interview converged (or never started with a model): nothing left to answer. */
35
- const converged = computed(() => initiative.value?.interview?.status === 'done')
36
47
 
37
48
  // Per-question answer drafts, seeded from the entity and refreshed as new rounds arrive
38
49
  // without clobbering an answer the human is mid-edit on.
@@ -48,11 +59,26 @@ watch(
48
59
  )
49
60
 
50
61
  const resuming = computed(() => initiatives.resuming)
62
+
63
+ /**
64
+ * The live phase (see `interviewGatePhase`). `resuming` folds in the request itself so the
65
+ * body swaps to the waiting state on the click rather than a beat later when the run's `running`
66
+ * event lands — and if the request fails, `resuming` clears and the phase falls back to whatever
67
+ * the run actually says, so the questions come back rather than the window sticking on a spinner.
68
+ */
69
+ const phase = computed(() =>
70
+ resuming.value
71
+ ? 'working'
72
+ : interviewGatePhase(initiative.value?.interview?.status, run.value?.status),
73
+ )
74
+
51
75
  /**
52
- * Continue is meaningful once every pending question has a drafted answer. A dismissed question
53
- * doesn't count (it was set aside), so an all-dismissed round is trivially "answered".
76
+ * Questions still missing a drafted answer. Continue is only meaningful once this is empty but a
77
+ * disabled button with no stated reason is itself a "nothing happened", so the count is rendered.
78
+ * A dismissed question doesn't count (it was set aside), so an all-dismissed round is trivially
79
+ * answered.
54
80
  */
55
- const allAnswered = computed(() => pending.value.every((q) => drafts[q.key]?.trim()))
81
+ const unanswered = computed(() => pending.value.filter((q) => !drafts[q.key]?.trim()).length)
56
82
 
57
83
  /**
58
84
  * Persist one answer if its draft differs from what's recorded. A `dismissed` question is skipped:
@@ -101,6 +127,25 @@ async function flushThen(action: (id: string) => Promise<unknown>) {
101
127
 
102
128
  const onContinue = () => flushThen((id) => initiatives.continuePlanning(id))
103
129
  const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
130
+
131
+ /**
132
+ * The escape hatch for a planning run that stalled. It belongs HERE, not only in the inspector's
133
+ * execution panel behind this window: submit and plan-now are the two things that wedge, so the
134
+ * human who needs a way out is looking at exactly this footer. Offered whenever a run still owns
135
+ * the block — including mid-pass and after a failed pass, which is where a wedge actually shows up
136
+ * and where neither of the other two buttons is even rendered.
137
+ *
138
+ * Discarding returns the block to `planned`, which re-enables "Run planning"; the interviewer gate
139
+ * drops the previous run's round bookkeeping on that fresh start, so the re-run genuinely
140
+ * re-interviews instead of force-converging on its first pass. Close on success — leaving the
141
+ * window open on the now-empty idle state would read as another dead end.
142
+ */
143
+ const { resetting, resetRun } = useRunReset()
144
+ const canDiscard = computed(() => !!block.value?.executionId)
145
+ async function onDiscard() {
146
+ if (!blockId.value) return
147
+ if (await resetRun(blockId.value)) close()
148
+ }
104
149
  </script>
105
150
 
106
151
  <template>
@@ -135,9 +180,38 @@ const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
135
180
  {{ t('initiative.planning.intro') }}
136
181
  </p>
137
182
 
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
+ />
192
+
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
+ />
201
+
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>
211
+
138
212
  <!-- Converged / no pending questions -->
139
213
  <div
140
- v-if="converged || questions.length === 0"
214
+ v-else-if="phase === 'converged' || questions.length === 0"
141
215
  class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
142
216
  data-testid="initiative-planning-converged"
143
217
  >
@@ -166,20 +240,52 @@ const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
166
240
  </template>
167
241
  </div>
168
242
 
169
- <!-- Action rail -->
243
+ <!-- Action rail. The submit/plan-now pair shows only while the run is actually parked on the
244
+ human: mid-pass they would re-submit a question set already in flight, and the resume is a
245
+ no-op once it isn't. Discard is the opposite — it is offered for as long as a run owns the
246
+ block, because the phases where those two are hidden (working, failed) are exactly the ones
247
+ a wedged run sits in. -->
170
248
  <footer
171
- v-if="initiative && !converged && questions.length > 0"
249
+ v-if="initiative && (canDiscard || (phase === 'awaiting' && questions.length > 0))"
172
250
  class="flex items-center justify-between gap-3 border-t border-slate-800 px-5 py-3"
173
251
  >
174
- <p class="text-[11px] text-slate-500">
175
- {{ t('initiative.planning.hint') }}
176
- </p>
177
- <div class="flex items-center gap-2">
252
+ <UButton
253
+ v-if="canDiscard"
254
+ color="error"
255
+ variant="ghost"
256
+ size="sm"
257
+ icon="i-lucide-trash-2"
258
+ :loading="resetting"
259
+ :disabled="resuming"
260
+ :title="t('initiative.planning.discardTitle')"
261
+ data-testid="initiative-planning-discard"
262
+ @click="onDiscard"
263
+ >
264
+ {{ t('initiative.planning.discard') }}
265
+ </UButton>
266
+ <!-- `ms-auto` rather than relying on `justify-between`: discard is conditional, and without
267
+ it this group left-aligns on the (transient) render where it is the only child. -->
268
+ <div
269
+ v-if="phase === 'awaiting' && questions.length > 0"
270
+ class="ms-auto flex items-center gap-2"
271
+ >
272
+ <p class="text-[11px] text-slate-500">
273
+ <span
274
+ v-if="unanswered > 0"
275
+ class="text-amber-400/90"
276
+ data-testid="initiative-planning-unanswered"
277
+ >
278
+ {{ t('initiative.planning.unanswered', { count: unanswered }) }}
279
+ </span>
280
+ <span v-else>{{ t('initiative.planning.hint') }}</span>
281
+ </p>
178
282
  <UButton
179
283
  color="neutral"
180
284
  variant="ghost"
181
285
  size="sm"
182
286
  :loading="resuming"
287
+ :disabled="resetting"
288
+ :title="t('initiative.planning.proceedTitle')"
183
289
  data-testid="initiative-planning-proceed"
184
290
  @click="onProceed"
185
291
  >
@@ -189,7 +295,12 @@ const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
189
295
  color="primary"
190
296
  size="sm"
191
297
  :loading="resuming"
192
- :disabled="!allAnswered"
298
+ :disabled="unanswered > 0 || resetting"
299
+ :title="
300
+ unanswered > 0
301
+ ? t('initiative.planning.unanswered', { count: unanswered })
302
+ : t('initiative.planning.continueTitle')
303
+ "
193
304
  data-testid="initiative-planning-continue"
194
305
  @click="onContinue"
195
306
  >
@@ -60,6 +60,14 @@ watch(
60
60
  )
61
61
  const isContainer = computed(() => level.value === 'frame' || level.value === 'module')
62
62
  const isTask = computed(() => level.value === 'task')
63
+ const isInitiative = computed(() => level.value === 'initiative')
64
+ /**
65
+ * Blocks whose inspector carries a pipeline RUN — a task, and an initiative (whose planning
66
+ * pipeline is an ordinary run of ordinary agent steps). Both get the execution panel and the
67
+ * Focus view; what differs is only how the run is STARTED (a task picks any pipeline, an
68
+ * initiative may only run its planning one, so it keeps its own "Run planning" control).
69
+ */
70
+ const hasRuns = computed(() => isTask.value || isInitiative.value)
63
71
 
64
72
  const instance = computed(() => execution.getInstance(block.value?.executionId))
65
73
  const typeMeta = computed(() => (block.value ? blockTypeMeta(block.value.type) : null))
@@ -104,7 +112,10 @@ const runBlockedReason = computed(() => {
104
112
  const canRun = computed(() => runnable.value && access.canExecuteRuns.value)
105
113
 
106
114
  // The delete control names what it removes, so selecting a task and deleting it
107
- // reads as "Delete task" rather than ambiguously removing the whole service.
115
+ // reads as "Delete task" rather than ambiguously removing the whole service. An
116
+ // initiative is its own level (it hangs off a frame like a module does), so it must
117
+ // name ITSELF — offering to "delete service" there describes the wrong blast radius
118
+ // entirely: the frame and every other thing under it survive.
108
119
  const deleteLabel = computed(() =>
109
120
  schedule.value
110
121
  ? t('panels.inspector.deleteRecurringPipeline')
@@ -112,7 +123,9 @@ const deleteLabel = computed(() =>
112
123
  ? t('panels.inspector.deleteTask')
113
124
  : level.value === 'module'
114
125
  ? t('panels.inspector.deleteModule')
115
- : t('panels.inspector.deleteService'),
126
+ : isInitiative.value
127
+ ? t('panels.inspector.deleteInitiative')
128
+ : t('panels.inspector.deleteService'),
116
129
  )
117
130
 
118
131
  // A task is "started" once a pipeline has been launched on it (it has an
@@ -543,11 +556,12 @@ const showOriginalDescription = ref(false)
543
556
  </UButton>
544
557
  </UDropdownMenu>
545
558
  <UButton
546
- v-if="isTask"
559
+ v-if="hasRuns"
547
560
  color="neutral"
548
561
  variant="soft"
549
562
  size="sm"
550
563
  icon="i-lucide-maximize-2"
564
+ data-testid="inspector-focus"
551
565
  @click="ui.focus(block.id)"
552
566
  >
553
567
  {{ t('panels.inspector.focus') }}
@@ -23,6 +23,7 @@ const {
23
23
  planningPipeline,
24
24
  running,
25
25
  awaitingAnswers,
26
+ interviewing,
26
27
  starting,
27
28
  runPlanning,
28
29
  openPlanning,
@@ -66,6 +67,20 @@ function control(action: 'pause' | 'resume' | 'cancel') {
66
67
  >
67
68
  {{ t('initiative.inspector.answerPlanning') }}
68
69
  </UButton>
70
+ <!-- Mid-pass there is nothing to answer, but the window must stay reachable — it is where
71
+ the "planner is working" state is shown. -->
72
+ <UButton
73
+ v-else-if="interviewing"
74
+ data-testid="initiative-planning-in-progress"
75
+ color="neutral"
76
+ variant="soft"
77
+ size="sm"
78
+ icon="i-lucide-loader-circle"
79
+ :ui="{ leadingIcon: 'animate-spin' }"
80
+ @click="openPlanning"
81
+ >
82
+ {{ t('initiative.inspector.planningInProgress') }}
83
+ </UButton>
69
84
  <UButton
70
85
  data-testid="initiative-run-planning"
71
86
  color="primary"
@@ -192,26 +192,11 @@ async function stopRun() {
192
192
  stopping.value = false
193
193
  }
194
194
  }
195
- const resetting = ref(false)
196
- async function resetRun() {
197
- if (resetting.value) return
198
- // Destructive: discards the run and returns the task to planned — gate it behind a confirm,
199
- // matching the confirm-then-mutate contract the board delete path uses.
200
- const ok = await confirm({
201
- title: t('inspector.execution.resetConfirm.title'),
202
- description: t('inspector.execution.resetConfirm.body'),
203
- variant: 'destructive',
204
- confirmLabel: t('inspector.execution.resetConfirm.confirm'),
205
- icon: 'i-lucide-trash-2',
206
- })
207
- if (!ok) return
208
- resetting.value = true
209
- try {
210
- await execution.cancel(props.block.id)
211
- } finally {
212
- resetting.value = false
213
- }
214
- }
195
+ // Destructive: discards the run and returns the block to planned, behind a confirm. Shared with
196
+ // the initiative planning window (which offers the same escape hatch in place) so the two can't
197
+ // drift on the prompt or on what "discard" means.
198
+ const { resetting, resetRun: discardRun } = useRunReset()
199
+ const resetRun = () => discardRun(props.block.id)
215
200
 
216
201
  /**
217
202
  * The reviewer-effort tag for this merge, preselected from evidence rather than starting blank: if
@@ -76,7 +76,14 @@ export function useBlockDeletion() {
76
76
  ? 'task'
77
77
  : block.level === 'module'
78
78
  ? 'module'
79
- : 'service'
79
+ : // An initiative names itself rather than falling through to the service copy, which
80
+ // would describe a blast radius orders of magnitude larger than the real one. Its
81
+ // cascade is also genuinely different from a container's: the plan goes with it, but
82
+ // the tasks its loop already spawned are NOT descendants — the backend only detaches
83
+ // their membership link — so the count branch below deliberately doesn't apply.
84
+ block.level === 'initiative'
85
+ ? 'initiative'
86
+ : 'service'
80
87
  const title = t(`panels.inspector.confirmDelete.${kind}.title`)
81
88
  // For a container (service/module) state the exact cascade size so the blast radius is
82
89
  // explicit — "and everything inside it" hides how many tasks/modules go with it.
@@ -161,8 +161,18 @@ export function useContextLinking() {
161
161
  * reasons as the body, and a "Copy details" action that puts the full diagnostic
162
162
  * report ({@link buildLinkFailureReport}) on the clipboard. Sticky (`duration: 0`)
163
163
  * so the cause stays readable long enough to act on. No-op when nothing failed.
164
+ *
165
+ * `opts.title` names what WAS created, which differs per host ("Task added, but …" vs
166
+ * "Initiative created, but …"). It is a resolver over the count rather than a message key, so
167
+ * each caller keeps a literal key at its own translation call site — passing the key through
168
+ * would make it a variable, which defeats both the typed-message-key check and the extractor's
169
+ * static scan — and the plural choice has to be made against the same count.
164
170
  */
165
- function presentLinkFailures(failures: LinkFailure[], blockId?: string): void {
171
+ function presentLinkFailures(
172
+ failures: LinkFailure[],
173
+ blockId?: string,
174
+ opts: { title?: (count: number) => string } = {},
175
+ ): void {
166
176
  if (failures.length === 0) return
167
177
  const description = failures.map((f) => `${f.item.title}: ${f.message}`).join('\n')
168
178
  const report = buildLinkFailureReport(failures, {
@@ -171,7 +181,9 @@ export function useContextLinking() {
171
181
  when: new Date().toISOString(),
172
182
  })
173
183
  toast.add({
174
- title: t('board.addTask.linkFailed', { count: failures.length }, failures.length),
184
+ title:
185
+ opts.title?.(failures.length) ??
186
+ t('board.addTask.linkFailed', { count: failures.length }, failures.length),
175
187
  description,
176
188
  icon: 'i-lucide-triangle-alert',
177
189
  color: 'warning',
@@ -4,6 +4,7 @@ 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
8
 
8
9
  /**
9
10
  * Shared planning affordances for an `initiative`-level block, used by BOTH the board card
@@ -37,13 +38,32 @@ export function useInitiativePlanning(blockId: MaybeRefOrGetter<string>) {
37
38
  const running = computed(() => !!block.value?.executionId)
38
39
 
39
40
  /**
40
- * The interviewer has PARKED the planning run for the human. Keyed purely on the interview's
41
- * parked `status` (`awaiting`) NOT on whether individual questions are still blank — so the
42
- * "Answer planning questions" affordance stays available even after every question is filled but
43
- * before the human resumes. Gating on unanswered questions would hide the only path back to the
44
- * interview window once all are answered, stranding the still-parked run.
41
+ * The live interview phase, derived from the entity AND the planning run (see
42
+ * {@link interviewGatePhase} for why the run status is load-bearing).
45
43
  */
46
- const awaitingAnswers = computed(() => initiative.value?.interview?.status === 'awaiting')
44
+ const interviewPhase = computed(() =>
45
+ interviewGatePhase(
46
+ initiative.value?.interview?.status,
47
+ execution.getByBlock(toValue(blockId))?.status,
48
+ ),
49
+ )
50
+
51
+ /**
52
+ * The interviewer has PARKED the planning run for the human. NOT keyed on whether individual
53
+ * questions are still blank — the "Answer planning questions" affordance must stay available
54
+ * after every question is filled but before the human resumes, or the only path back to the
55
+ * interview window disappears and the still-parked run is stranded.
56
+ *
57
+ * It IS keyed on the run not being mid-pass: after a continue/proceed the entity still reads
58
+ * `awaiting` for the whole (slow) interviewer pass, so an entity-only reading keeps the card
59
+ * pulsing and offering "Answer planning questions" over a question set that is already
60
+ * submitted and about to be replaced. {@link interviewing} covers that window instead, and a
61
+ * pass that fails takes the run out of `running`, so this comes back rather than stranding.
62
+ */
63
+ const awaitingAnswers = computed(() => interviewPhase.value === 'awaiting')
64
+
65
+ /** An interviewer pass is running — the human is waiting on the planner, not the reverse. */
66
+ const interviewing = computed(() => interviewPhase.value === 'working')
47
67
 
48
68
  /**
49
69
  * Optimistic start flag: flip true the instant "Run planning" is clicked, before the stream
@@ -82,7 +102,9 @@ export function useInitiativePlanning(blockId: MaybeRefOrGetter<string>) {
82
102
  return {
83
103
  planningPipeline,
84
104
  running,
105
+ interviewPhase,
85
106
  awaitingAnswers,
107
+ interviewing,
86
108
  starting,
87
109
  runPlanning,
88
110
  openPlanning,