@cat-factory/app 0.172.0 → 0.173.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.
@@ -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
+
51
63
  /**
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".
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.
54
68
  */
55
- const allAnswered = computed(() => pending.value.every((q) => drafts[q.key]?.trim()))
69
+ const phase = computed(() =>
70
+ resuming.value
71
+ ? 'working'
72
+ : interviewGatePhase(initiative.value?.interview?.status, run.value?.status),
73
+ )
74
+
75
+ /**
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.
80
+ */
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:
@@ -135,9 +161,38 @@ const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
135
161
  {{ t('initiative.planning.intro') }}
136
162
  </p>
137
163
 
164
+ <!-- A pass is running: the human is waiting on the planner. Without this the window is
165
+ byte-identical to the parked state and the submit reads as a no-op. -->
166
+ <InterviewGateNotice
167
+ v-if="phase === 'working'"
168
+ variant="working"
169
+ :title="t('initiative.planning.working')"
170
+ :hint="t('initiative.planning.workingHint')"
171
+ testid="initiative-planning-working"
172
+ />
173
+
174
+ <!-- The planning run stopped before the interview settled — a dead end otherwise. -->
175
+ <InterviewGateNotice
176
+ v-else-if="phase === 'failed'"
177
+ variant="failed"
178
+ :title="t('initiative.planning.failed')"
179
+ :hint="t('initiative.planning.failedHint')"
180
+ testid="initiative-planning-failed"
181
+ />
182
+
183
+ <!-- Planning was never started, so there is nothing to answer YET (distinct from
184
+ converged, which means the planner already has what it needs). -->
185
+ <div
186
+ v-else-if="phase === 'idle' && questions.length === 0"
187
+ class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
188
+ data-testid="initiative-planning-idle"
189
+ >
190
+ {{ t('initiative.planning.idle') }}
191
+ </div>
192
+
138
193
  <!-- Converged / no pending questions -->
139
194
  <div
140
- v-if="converged || questions.length === 0"
195
+ v-else-if="phase === 'converged' || questions.length === 0"
141
196
  class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
142
197
  data-testid="initiative-planning-converged"
143
198
  >
@@ -166,13 +221,21 @@ const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
166
221
  </template>
167
222
  </div>
168
223
 
169
- <!-- Action rail -->
224
+ <!-- Action rail. Only while the run is actually parked on the human: mid-pass these would
225
+ re-submit a question set already in flight, and the resume is a no-op once it isn't. -->
170
226
  <footer
171
- v-if="initiative && !converged && questions.length > 0"
227
+ v-if="initiative && phase === 'awaiting' && questions.length > 0"
172
228
  class="flex items-center justify-between gap-3 border-t border-slate-800 px-5 py-3"
173
229
  >
174
230
  <p class="text-[11px] text-slate-500">
175
- {{ t('initiative.planning.hint') }}
231
+ <span
232
+ v-if="unanswered > 0"
233
+ class="text-amber-400/90"
234
+ data-testid="initiative-planning-unanswered"
235
+ >
236
+ {{ t('initiative.planning.unanswered', { count: unanswered }) }}
237
+ </span>
238
+ <span v-else>{{ t('initiative.planning.hint') }}</span>
176
239
  </p>
177
240
  <div class="flex items-center gap-2">
178
241
  <UButton
@@ -180,6 +243,7 @@ const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
180
243
  variant="ghost"
181
244
  size="sm"
182
245
  :loading="resuming"
246
+ :title="t('initiative.planning.proceedTitle')"
183
247
  data-testid="initiative-planning-proceed"
184
248
  @click="onProceed"
185
249
  >
@@ -189,7 +253,12 @@ const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
189
253
  color="primary"
190
254
  size="sm"
191
255
  :loading="resuming"
192
- :disabled="!allAnswered"
256
+ :disabled="unanswered > 0"
257
+ :title="
258
+ unanswered > 0
259
+ ? t('initiative.planning.unanswered', { count: unanswered })
260
+ : t('initiative.planning.continueTitle')
261
+ "
193
262
  data-testid="initiative-planning-continue"
194
263
  @click="onContinue"
195
264
  >
@@ -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"
@@ -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,
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { interviewGatePhase } from './interviewGate'
3
+
4
+ // `interviewGatePhase` is what stops continue/proceed reading as no-ops in BOTH interview windows
5
+ // (initiative planning, document interview): the resume is asynchronous — the HTTP call only wakes
6
+ // the durable driver, so it returns the PRE-resume entity — and these pin that the RUN status is
7
+ // what distinguishes "parked, waiting on you" from "a pass is running", a distinction the entity
8
+ // alone cannot make.
9
+
10
+ describe('interviewGatePhase', () => {
11
+ it('is awaiting while the run is parked on the human', () => {
12
+ expect(interviewGatePhase('awaiting', 'blocked')).toBe('awaiting')
13
+ })
14
+
15
+ it('is working once the resumed run is running again, even though the entity still says awaiting', () => {
16
+ // The exact regression: continue/proceed leave the entity's status untouched until the pass
17
+ // finishes, so an entity-only reading renders the same questions and looks like a dead button.
18
+ expect(interviewGatePhase('awaiting', 'running')).toBe('working')
19
+ })
20
+
21
+ it('is working for the FIRST pass, before any question exists', () => {
22
+ expect(interviewGatePhase(undefined, 'running')).toBe('working')
23
+ })
24
+
25
+ it('is failed when the run stopped before the interview settled', () => {
26
+ // Must not stay `working`: a pass that dies would otherwise spin forever.
27
+ expect(interviewGatePhase('awaiting', 'failed')).toBe('failed')
28
+ expect(interviewGatePhase(undefined, 'failed')).toBe('failed')
29
+ })
30
+
31
+ it('is converged once the interview settled, whatever the run went on to do', () => {
32
+ // `converged` outranks `failed`: a later step's failure belongs to that step, not the
33
+ // interview, and the block's own failure surface reports it.
34
+ expect(interviewGatePhase('done', 'running')).toBe('converged')
35
+ expect(interviewGatePhase('done', 'failed')).toBe('converged')
36
+ expect(interviewGatePhase('done', undefined)).toBe('converged')
37
+ })
38
+
39
+ it('is idle when the interview never ran', () => {
40
+ expect(interviewGatePhase(undefined, undefined)).toBe('idle')
41
+ })
42
+
43
+ it('degrades to the entity-only reading when the run is not cached', () => {
44
+ // A window opened before the execution snapshot lands must show the questions, never a spinner.
45
+ expect(interviewGatePhase('awaiting', undefined)).toBe('awaiting')
46
+ })
47
+
48
+ it('keeps a paused run answerable', () => {
49
+ expect(interviewGatePhase('awaiting', 'paused')).toBe('awaiting')
50
+ })
51
+ })
@@ -0,0 +1,51 @@
1
+ import type { ExecutionInstance } from '~/types/domain'
2
+
3
+ // The frontend dual of the backend's shared `InterviewGateController` spine. Both interview gates
4
+ // — the initiative-planning interviewer and the document interviewer — park their run on a
5
+ // decision-wait, expose the SAME `awaiting | done` status on their entity, and resume the same
6
+ // way, so how their windows read "what is happening right now" is shared vocabulary rather than
7
+ // two copies. See `docs/initiatives/clarification-items.md`.
8
+
9
+ /**
10
+ * What an interview gate is doing right now, from the human's point of view. Drives the interview
11
+ * window's body AND (for the initiative) the card/inspector affordances, so the surfaces can't
12
+ * disagree about whether there is anything to answer.
13
+ *
14
+ * - `idle` — the interview has not run yet (nothing to answer, nothing in flight).
15
+ * - `working` — an interviewer pass is running; the human waits.
16
+ * - `awaiting` — the run is parked on the human's answers.
17
+ * - `converged` — the interview settled; the run moved on.
18
+ * - `failed` — the run stopped before the interview settled.
19
+ */
20
+ export type InterviewGatePhase = 'idle' | 'working' | 'awaiting' | 'converged' | 'failed'
21
+
22
+ /**
23
+ * Resolve the phase from the interview entity's status AND its run's status.
24
+ *
25
+ * The RUN status is load-bearing, not redundant. Continue/proceed are ASYNC by design: the HTTP
26
+ * call only records the intent on the parked step and wakes the durable driver, which then runs
27
+ * the (slow) interviewer LLM — so the response carries the PRE-resume entity, with the same
28
+ * questions and the same `awaiting` status. Keyed on the entity alone a window is therefore
29
+ * byte-identical before and after the click, which is indistinguishable from the button doing
30
+ * nothing for however long the pass takes. A resumed run flips `blocked` → `running` and emits,
31
+ * so `running` while the interview is unsettled is exactly "a pass is in flight".
32
+ *
33
+ * Deriving this from the run rather than a local in-flight flag also survives a reload and cannot
34
+ * wedge: a pass that FAILS takes the run to `failed`, so the window drops out of `working` and
35
+ * says so instead of spinning forever. An unknown run (no instance cached yet) degrades to the
36
+ * entity-only reading, never to a spinner.
37
+ *
38
+ * `converged` wins over `failed` on purpose: once the interview settled, a later failure belongs
39
+ * to the step that failed (the analyst/planner, the writer), and the block's own failure surface
40
+ * reports it — the interview window claiming the interview broke would be wrong.
41
+ */
42
+ export function interviewGatePhase(
43
+ status: 'awaiting' | 'done' | undefined,
44
+ runStatus: ExecutionInstance['status'] | undefined,
45
+ ): InterviewGatePhase {
46
+ if (status === 'done') return 'converged'
47
+ if (runStatus === 'failed') return 'failed'
48
+ if (runStatus === 'running') return 'working'
49
+ if (status === 'awaiting') return 'awaiting'
50
+ return 'idle'
51
+ }