@cat-factory/app 0.171.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.
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/fragments/FragmentLibraryManager.vue +91 -13
  9. package/app/components/fragments/GitHubDocUrlImport.vue +83 -0
  10. package/app/components/github/RepoTreeBrowser.vue +65 -10
  11. package/app/components/initiative/InitiativePlanningWindow.vue +82 -13
  12. package/app/components/layout/DefaultTestEnvBanner.vue +117 -0
  13. package/app/components/panels/inspector/InitiativeInspector.vue +15 -0
  14. package/app/components/settings/DefaultProvisionTypeSection.vue +176 -0
  15. package/app/components/settings/InfrastructureWindow.vue +8 -1
  16. package/app/composables/useContextLinking.ts +14 -2
  17. package/app/composables/useInitiativePlanning.ts +28 -6
  18. package/app/pages/index.vue +12 -1
  19. package/app/stores/ui/modals.ts +46 -0
  20. package/app/stores/workspaceSettings.ts +5 -0
  21. package/app/utils/defaultProvisioning.spec.ts +100 -0
  22. package/app/utils/defaultProvisioning.ts +99 -0
  23. package/app/utils/interviewGate.spec.ts +51 -0
  24. package/app/utils/interviewGate.ts +51 -0
  25. package/i18n/locales/de.json +70 -22
  26. package/i18n/locales/en.json +73 -22
  27. package/i18n/locales/es.json +70 -22
  28. package/i18n/locales/fr.json +70 -22
  29. package/i18n/locales/he.json +70 -22
  30. package/i18n/locales/it.json +70 -22
  31. package/i18n/locales/ja.json +70 -22
  32. package/i18n/locales/pl.json +70 -22
  33. package/i18n/locales/tr.json +70 -22
  34. package/i18n/locales/uk.json +70 -22
  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
+
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
  >
@@ -0,0 +1,117 @@
1
+ <script setup lang="ts">
2
+ // Convenience prompt to pick the board's DEFAULT test-environment provisioning mechanism — the
3
+ // provision type suggested for every newly added service. Fires on a workspace that has recorded
4
+ // no choice, which covers all three cases the product asked for with ONE condition rather than
5
+ // three: a board created by hand, the board the SPA creates implicitly on first launch, and an
6
+ // older board that predates the setting. All of them simply have `defaultProvisionType == null`.
7
+ //
8
+ // The nag ends the moment a decision is RECORDED, including `infraless` ("services stand up no
9
+ // environment") — that is a real answer, not an absence. See the contracts block comment on
10
+ // `defaultProvisionType` for why the field is nullable rather than defaulted.
11
+ //
12
+ // Positioning/stacking against the sibling advisory banners is owned by the shared click-through
13
+ // column in `pages/index.vue`; this renders only its card and re-enables pointer events on it.
14
+ import { computed } from 'vue'
15
+ import {
16
+ defaultProvisioningConfigUrl,
17
+ needsDefaultProvisioningChoice,
18
+ } from '~/utils/defaultProvisioning'
19
+
20
+ const { t } = useI18n()
21
+ const ui = useUiStore()
22
+ const workspace = useWorkspaceStore()
23
+ const settingsStore = useWorkspaceSettingsStore()
24
+ const { canManageSettings } = useWorkspaceAccess()
25
+
26
+ // Only prompt someone who can actually answer. A member/viewer cannot write workspace settings
27
+ // (the endpoint is `settings.manage`-gated), so for them this would be an un-actionable nag
28
+ // pointing at a screen that would refuse their save.
29
+ const show = computed(
30
+ () =>
31
+ workspace.ready &&
32
+ canManageSettings.value &&
33
+ needsDefaultProvisioningChoice(settingsStore.settings) &&
34
+ !ui.defaultProvisionDismissed,
35
+ )
36
+
37
+ // A real, copyable link to the configuration screen — so this can be handed to whoever owns the
38
+ // board's infrastructure instead of being described. Clicking it opens the screen in place
39
+ // (no reload); the `href` is what makes middle-click, "copy link address" and open-in-new-tab
40
+ // work, and the ui store consumes the same param on load so a pasted link lands on the section.
41
+ const configUrl = computed(() => defaultProvisioningConfigUrl())
42
+
43
+ function openConfig(event: MouseEvent) {
44
+ // Leave the modified clicks (new tab / new window / download) to the browser.
45
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return
46
+ event.preventDefault()
47
+ ui.openDefaultProvisionSettings()
48
+ }
49
+ </script>
50
+
51
+ <template>
52
+ <Transition name="fade">
53
+ <div
54
+ v-if="show"
55
+ class="pointer-events-auto w-full max-w-3xl rounded-2xl border border-sky-500/50 bg-sky-950/90 p-4 shadow-xl backdrop-blur"
56
+ role="status"
57
+ aria-live="polite"
58
+ data-testid="default-test-env-banner"
59
+ >
60
+ <div class="flex items-start gap-3">
61
+ <UIcon name="i-lucide-flask-conical" class="mt-0.5 h-7 w-7 shrink-0 text-sky-400" />
62
+ <div class="min-w-0 flex-1">
63
+ <div class="flex items-start justify-between gap-3">
64
+ <h2 class="text-sm font-semibold text-sky-100">
65
+ {{ t('layout.defaultTestEnvBanner.title') }}
66
+ </h2>
67
+ <UButton
68
+ color="neutral"
69
+ variant="ghost"
70
+ size="xs"
71
+ icon="i-lucide-x"
72
+ :aria-label="t('common.close')"
73
+ data-testid="default-test-env-dismiss"
74
+ @click="ui.dismissDefaultProvision()"
75
+ />
76
+ </div>
77
+ <p class="mt-1 text-[13px] text-sky-200/90">
78
+ {{ t('layout.defaultTestEnvBanner.body') }}
79
+ </p>
80
+ <div class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2">
81
+ <UButton
82
+ :to="configUrl"
83
+ size="sm"
84
+ color="info"
85
+ variant="solid"
86
+ icon="i-lucide-settings"
87
+ data-testid="default-test-env-configure"
88
+ @click="openConfig"
89
+ >
90
+ {{ t('layout.defaultTestEnvBanner.action') }}
91
+ </UButton>
92
+ <!-- The URL itself, shown so it can be read and copied, not just clicked. -->
93
+ <a
94
+ :href="configUrl"
95
+ class="min-w-0 truncate font-mono text-[11px] text-sky-300/80 underline decoration-dotted underline-offset-2 hover:text-sky-200"
96
+ data-testid="default-test-env-url"
97
+ @click="openConfig"
98
+ >
99
+ {{ configUrl }}
100
+ </a>
101
+ </div>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ </Transition>
106
+ </template>
107
+
108
+ <style scoped>
109
+ .fade-enter-active,
110
+ .fade-leave-active {
111
+ transition: opacity 0.2s ease;
112
+ }
113
+ .fade-enter-from,
114
+ .fade-leave-to {
115
+ opacity: 0;
116
+ }
117
+ </style>
@@ -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"
@@ -0,0 +1,176 @@
1
+ <script setup lang="ts">
2
+ // The workspace's DEFAULT test-environment provisioning mechanism — the provision type stamped
3
+ // onto every newly added service frame so the operator declares it once per board instead of
4
+ // once per service. Lives at the top of the Infrastructure window's "Test environments" tab,
5
+ // above the per-type handler configurator, because it answers the question that comes FIRST:
6
+ // what do this board's services produce, before you configure how each type is handled.
7
+ //
8
+ // This is a SUGGESTION for new services, never a run-time override: the engine still reads only
9
+ // a service's own `provisioning`, so saving a different default here never changes what an
10
+ // existing service provisions (each stays editable in its inspector).
11
+ //
12
+ // The section opens on the workspace's recorded choice, or — with nothing recorded — on the first
13
+ // REGISTERED custom provider when the deployment brought one. All of that precedence lives in
14
+ // `utils/defaultProvisioning.ts` so it is unit-tested and shared with the banner that nags about
15
+ // the unset state. Nothing is persisted until the operator saves.
16
+ import { computed, ref, watch } from 'vue'
17
+ import type { ProvisionType } from '@cat-factory/contracts'
18
+ import {
19
+ canSaveDefaultProvisioning,
20
+ suggestDefaultProvisioning,
21
+ type DefaultProvisioningSelection,
22
+ } from '~/utils/defaultProvisioning'
23
+
24
+ const { t } = useI18n()
25
+ const infra = useInfraConfigStore()
26
+ const settingsStore = useWorkspaceSettingsStore()
27
+ const toast = useToast()
28
+
29
+ onMounted(() => {
30
+ void infra.ensureLoaded()
31
+ })
32
+
33
+ // The saved state, and the (unsaved) edit state seeded from it. Re-seeded whenever the saved
34
+ // choice or the custom-type catalog changes — the catalog arrives asynchronously, so without the
35
+ // second dependency a board whose only sensible answer is a registered provider would open unset
36
+ // and never pick it up.
37
+ const selection = ref<DefaultProvisioningSelection>({ type: null, manifestId: null })
38
+ watch(
39
+ [() => settingsStore.settings, () => infra.customTypes],
40
+ ([settings, customTypes]) => {
41
+ selection.value = suggestDefaultProvisioning(settings, customTypes)
42
+ },
43
+ { immediate: true, deep: true },
44
+ )
45
+
46
+ // Whether what's on screen differs from what's stored — drives both the Save button and the
47
+ // "this is only a suggestion so far" hint, so a preselected-but-unsaved custom provider can
48
+ // never read as already configured.
49
+ const dirty = computed(
50
+ () =>
51
+ selection.value.type !== settingsStore.settings.defaultProvisionType ||
52
+ (selection.value.manifestId ?? null) !==
53
+ (settingsStore.settings.defaultProvisionManifestId ?? null),
54
+ )
55
+ const unset = computed(() => settingsStore.settings.defaultProvisionType == null)
56
+ const canSave = computed(() => canSaveDefaultProvisioning(selection.value) && dirty.value)
57
+
58
+ const PROVISION_TYPES = computed<{ value: ProvisionType; label: string }[]>(() => [
59
+ { value: 'infraless', label: t('inspector.testConfig.provisionTypes.infraless') },
60
+ { value: 'docker-compose', label: t('inspector.testConfig.provisionTypes.docker-compose') },
61
+ { value: 'kubernetes', label: t('inspector.testConfig.provisionTypes.kubernetes') },
62
+ { value: 'cloudflare', label: t('inspector.testConfig.provisionTypes.cloudflare') },
63
+ { value: 'custom', label: t('inspector.testConfig.provisionTypes.custom') },
64
+ ])
65
+
66
+ const customTypeItems = computed(() =>
67
+ infra.customTypes.map((c) => ({ label: `${c.label} (${c.manifestId})`, value: c.manifestId })),
68
+ )
69
+
70
+ function setType(type: ProvisionType) {
71
+ // Switching away from `custom` drops the pinned id, matching the server's normalisation — a
72
+ // retained id would silently reappear on switching back.
73
+ selection.value =
74
+ type === 'custom'
75
+ ? { type, manifestId: selection.value.manifestId ?? infra.customTypes[0]?.manifestId ?? null }
76
+ : { type, manifestId: null }
77
+ }
78
+
79
+ function setManifestId(manifestId: string) {
80
+ selection.value = { type: 'custom', manifestId: manifestId || null }
81
+ }
82
+
83
+ const saving = ref(false)
84
+ async function save() {
85
+ if (!canSave.value || saving.value) return
86
+ saving.value = true
87
+ try {
88
+ await settingsStore.update({
89
+ defaultProvisionType: selection.value.type,
90
+ defaultProvisionManifestId: selection.value.manifestId,
91
+ })
92
+ toast.add({ title: t('settings.defaultProvision.saved'), color: 'success' })
93
+ } catch (e) {
94
+ toast.add({
95
+ title: t('settings.defaultProvision.saveFailed'),
96
+ description: e instanceof Error ? e.message : undefined,
97
+ color: 'error',
98
+ })
99
+ } finally {
100
+ saving.value = false
101
+ }
102
+ }
103
+ </script>
104
+
105
+ <template>
106
+ <section class="space-y-3" data-testid="default-provision-section">
107
+ <div>
108
+ <h3 class="text-sm font-semibold text-slate-200">
109
+ {{ t('settings.defaultProvision.title') }}
110
+ </h3>
111
+ <p class="mt-1 text-[11px] leading-snug text-slate-500">
112
+ {{ t('settings.defaultProvision.hint') }}
113
+ </p>
114
+ </div>
115
+
116
+ <div class="flex flex-wrap gap-1">
117
+ <UButton
118
+ v-for="p in PROVISION_TYPES"
119
+ :key="p.value"
120
+ :color="selection.type === p.value ? 'primary' : 'neutral'"
121
+ :variant="selection.type === p.value ? 'soft' : 'ghost'"
122
+ size="xs"
123
+ :data-testid="`default-provision-type-${p.value}`"
124
+ @click="setType(p.value)"
125
+ >
126
+ {{ p.label }}
127
+ </UButton>
128
+ </div>
129
+
130
+ <div v-if="selection.type === 'custom'" class="space-y-1">
131
+ <label class="text-[11px] text-slate-400">
132
+ {{ t('inspector.testConfig.customManifestId') }}
133
+ </label>
134
+ <USelect
135
+ v-if="customTypeItems.length"
136
+ :model-value="selection.manifestId ?? ''"
137
+ :items="customTypeItems"
138
+ size="xs"
139
+ class="w-full"
140
+ data-testid="default-provision-manifest-id"
141
+ :placeholder="t('inspector.testConfig.customManifestIdPlaceholder')"
142
+ @update:model-value="(v: string) => setManifestId(v)"
143
+ />
144
+ <p v-else class="text-[11px] leading-snug text-amber-300/80">
145
+ {{ t('inspector.testConfig.customNoTypes') }}
146
+ </p>
147
+ </div>
148
+
149
+ <!-- A preselected suggestion must never read as already configured: say so explicitly while
150
+ the workspace still has nothing stored. -->
151
+ <p
152
+ v-if="unset && selection.type"
153
+ class="text-[11px] leading-snug text-amber-300/80"
154
+ data-testid="default-provision-suggestion-hint"
155
+ >
156
+ {{ t('settings.defaultProvision.suggestionHint') }}
157
+ </p>
158
+
159
+ <div class="flex items-center gap-2">
160
+ <UButton
161
+ size="xs"
162
+ color="primary"
163
+ icon="i-lucide-check"
164
+ :loading="saving"
165
+ :disabled="!canSave"
166
+ data-testid="default-provision-save"
167
+ @click="save"
168
+ >
169
+ {{ t('settings.defaultProvision.save') }}
170
+ </UButton>
171
+ <span v-if="!unset && !dirty" class="text-[11px] text-slate-500">
172
+ {{ t('settings.defaultProvision.savedState') }}
173
+ </span>
174
+ </div>
175
+ </section>
176
+ </template>