@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.
- package/README.md +4 -0
- package/app/components/board/AddTaskModal.vue +10 -252
- package/app/components/board/CreateInitiativeModal.vue +27 -0
- package/app/components/board/nodes/InitiativeCard.vue +14 -0
- package/app/components/common/InterviewGateNotice.vue +39 -0
- package/app/components/context/ContextAttachmentFields.vue +289 -0
- package/app/components/docs/DocInterviewWindow.vue +77 -14
- package/app/components/fragments/FragmentLibraryManager.vue +91 -13
- package/app/components/fragments/GitHubDocUrlImport.vue +83 -0
- package/app/components/github/RepoTreeBrowser.vue +65 -10
- package/app/components/initiative/InitiativePlanningWindow.vue +82 -13
- package/app/components/layout/DefaultTestEnvBanner.vue +117 -0
- package/app/components/panels/inspector/InitiativeInspector.vue +15 -0
- package/app/components/settings/DefaultProvisionTypeSection.vue +176 -0
- package/app/components/settings/InfrastructureWindow.vue +8 -1
- package/app/composables/useContextLinking.ts +14 -2
- package/app/composables/useInitiativePlanning.ts +28 -6
- package/app/pages/index.vue +12 -1
- package/app/stores/ui/modals.ts +46 -0
- package/app/stores/workspaceSettings.ts +5 -0
- package/app/utils/defaultProvisioning.spec.ts +100 -0
- package/app/utils/defaultProvisioning.ts +99 -0
- package/app/utils/interviewGate.spec.ts +51 -0
- package/app/utils/interviewGate.ts +51 -0
- package/i18n/locales/de.json +70 -22
- package/i18n/locales/en.json +73 -22
- package/i18n/locales/es.json +70 -22
- package/i18n/locales/fr.json +70 -22
- package/i18n/locales/he.json +70 -22
- package/i18n/locales/it.json +70 -22
- package/i18n/locales/ja.json +70 -22
- package/i18n/locales/pl.json +70 -22
- package/i18n/locales/tr.json +70 -22
- package/i18n/locales/uk.json +70 -22
- 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-
|
|
180
|
-
|
|
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="
|
|
186
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
//
|
|
6
|
-
// remaining questions — the interviewer converges and the run
|
|
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
|
-
*
|
|
53
|
-
*
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
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="
|
|
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>
|