@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
package/README.md CHANGED
@@ -133,6 +133,10 @@ example ships in [`deploy/frontend`](../../deploy/frontend) (the `acme:security`
133
133
  single way a pipeline is chosen anywhere — add-task, run settings, the recurring
134
134
  schedule, the focus view's Run menu — so every surface explains a pipeline by the
135
135
  ordered steps it will run rather than by its name alone.
136
+ - **Context attachments** (`components/context`) — `ContextAttachmentFields`, the
137
+ shared staged-attachment form used by both the add-task and create-initiative
138
+ modals. Picks are held locally and import-and-linked once the block exists (see
139
+ `composables/useContextLinking`), because linking needs a block id.
136
140
  - **Integrations** — modals/panels for `github` (the source-control panel, shared
137
141
  by every VCS provider), `vcs` (the GitLab personal-access-token connect),
138
142
  `bootstrap`, `documents`, `tasks`, `requirements` (review), `scenarios`
@@ -24,8 +24,7 @@ import { DOC_KINDS, DOC_KIND_FIELDS } from '~/types/domain'
24
24
  import { resolveComponentRegistry } from '@modular-vue/core'
25
25
  import { useReactiveSlots } from '@modular-vue/runtime'
26
26
  import type { AppSlots, ResultViewContribution } from '~/modular/slots'
27
- import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
28
- import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
27
+ import ContextAttachmentFields from '~/components/context/ContextAttachmentFields.vue'
29
28
  import FragmentSelector from '~/components/fragments/FragmentSelector.vue'
30
29
  import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
31
30
  import { parseConflict } from '~/composables/usePipelineErrorToast'
@@ -441,40 +440,6 @@ function setConfig(id: string, value: string) {
441
440
  // import flow), committed once the block exists (see add() → linkPending).
442
441
  const pendingContext = ref<PendingContext[]>([])
443
442
 
444
- // The Context documents / Context issues sections mirror the task inspector but are
445
- // always shown (ungated): when the relevant integration isn't connected the Attach
446
- // button becomes a "Connect a source" action instead. Connecting opens the source's
447
- // connect modal OVER this one (both are root-mounted with independent open flags), so
448
- // the user's in-progress task data is preserved rather than lost to a navigation away.
449
- const docsConnected = computed(() => documents.available && documents.anyConnected)
450
- const issuesConnected = computed(() => tasks.available && tasks.anyOffered)
451
-
452
- // Sources the user could connect right now to unlock the picker, when none is connected
453
- // yet: for documents, every configured source without a live connection (GitHub docs are
454
- // already implicitly connected via the App, so they never appear here); for issues, every
455
- // configured tracker not yet available (a connected credentialed source, or the installed
456
- // GitHub App). The connect modals are the same ones the Integrations hub opens.
457
- const connectableDocSources = computed(() =>
458
- documents.available ? documents.sources.filter((s) => !documents.isConnected(s.source)) : [],
459
- )
460
- const connectableIssueSources = computed(() =>
461
- tasks.available ? tasks.sources.filter((s) => !s.available) : [],
462
- )
463
- const connectDocMenu = computed(() => [
464
- connectableDocSources.value.map((s) => ({
465
- label: s.label,
466
- icon: s.icon,
467
- onSelect: () => ui.openDocumentConnect(s.source),
468
- })),
469
- ])
470
- const connectIssueMenu = computed(() => [
471
- connectableIssueSources.value.map((s) => ({
472
- label: s.label,
473
- icon: s.icon,
474
- onSelect: () => ui.openTaskConnect(s.source),
475
- })),
476
- ])
477
- const pendingDocs = computed(() => pendingContext.value.filter((c) => c.kind === 'document'))
478
443
  const pendingIssues = computed(() => pendingContext.value.filter((c) => c.kind === 'task'))
479
444
 
480
445
  // Linked issues whose body is in hand, surfaced read-only above the description so the
@@ -532,24 +497,6 @@ async function resolvePendingIssueBodies() {
532
497
  }
533
498
  }
534
499
 
535
- function addPending(item: PendingContext) {
536
- if (pendingContext.value.some((c) => contextKey(c) === contextKey(item))) return
537
- pendingContext.value = [...pendingContext.value, item]
538
- }
539
- function removePending(item: PendingContext) {
540
- pendingContext.value = pendingContext.value.filter((c) => contextKey(c) !== contextKey(item))
541
- }
542
-
543
- // Context documents and issues are both picked through an inline search picker
544
- // (ContextDocumentPicker / ContextIssuePicker) rather than a dropdown that opens a
545
- // second modal — stacked page-level modals don't interact here, which is why the
546
- // old "Import a page…" / "Import an issue…" entries appeared to open something but
547
- // nothing was clickable. The "Attach" button toggles the relevant picker open.
548
- const showDocPicker = ref(false)
549
- const chosenDocKeys = computed(() => pendingDocs.value.map(contextKey))
550
- const showIssuePicker = ref(false)
551
- const chosenIssueKeys = computed(() => pendingIssues.value.map(contextKey))
552
-
553
500
  // Reset the form whenever the modal opens for a (new) container, and refresh the
554
501
  // imported docs/issues so the quick-pick list is current.
555
502
  watch(open, (isOpen) => {
@@ -591,8 +538,6 @@ watch(open, (isOpen) => {
591
538
  pipelineId.value = DEFAULT_PIPELINE_FOR_TYPE[taskType.value] ?? ''
592
539
  agentConfigValues.value = {}
593
540
  pendingContext.value = []
594
- showDocPicker.value = false
595
- showIssuePicker.value = false
596
541
  // Seed from a prefill when opened from another surface (e.g. "create task from
597
542
  // issue" sets the title + stages the issue as linked context). Pipeline / preset
598
543
  // are intentionally left at their defaults so the user confirms them here.
@@ -1211,202 +1156,15 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
1211
1156
  />
1212
1157
  </div>
1213
1158
 
1214
- <!-- Context documents (ungated; Attach disabled until a source is connected). -->
1215
- <div class="space-y-2">
1216
- <div class="flex items-center justify-between">
1217
- <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
1218
- {{ t('board.addTask.contextDocuments') }}
1219
- </span>
1220
- <UButton
1221
- v-if="docsConnected"
1222
- color="neutral"
1223
- variant="soft"
1224
- size="xs"
1225
- :icon="showDocPicker ? 'i-lucide-x' : 'i-lucide-plus'"
1226
- @click="
1227
- () => {
1228
- showDocPicker = !showDocPicker
1229
- }
1230
- "
1231
- >
1232
- {{ showDocPicker ? t('board.addTask.done') : t('board.addTask.attach') }}
1233
- </UButton>
1234
- <UDropdownMenu
1235
- v-else-if="connectableDocSources.length > 1"
1236
- :items="connectDocMenu"
1237
- :content="{ side: 'bottom', align: 'end' }"
1238
- >
1239
- <UButton color="neutral" variant="soft" size="xs" icon="i-lucide-plug">
1240
- {{ t('board.addTask.connectSource') }}
1241
- </UButton>
1242
- </UDropdownMenu>
1243
- <UButton
1244
- v-else-if="connectableDocSources.length === 1"
1245
- color="neutral"
1246
- variant="soft"
1247
- size="xs"
1248
- icon="i-lucide-plug"
1249
- @click="ui.openDocumentConnect(connectableDocSources[0]!.source)"
1250
- >
1251
- {{
1252
- t('board.addTask.connectSourceNamed', { source: connectableDocSources[0]!.label })
1253
- }}
1254
- </UButton>
1255
- <UButton
1256
- v-else
1257
- color="neutral"
1258
- variant="soft"
1259
- size="xs"
1260
- icon="i-lucide-plus"
1261
- disabled
1262
- :title="
1263
- documents.available
1264
- ? t('board.addTask.attachDocDisabledConnect')
1265
- : t('board.addTask.attachDocDisabledEnable')
1266
- "
1267
- >
1268
- {{ t('board.addTask.attach') }}
1269
- </UButton>
1270
- </div>
1271
- <ContextDocumentPicker
1272
- v-if="showDocPicker && docsConnected"
1273
- :chosen-keys="chosenDocKeys"
1274
- @pick="addPending"
1275
- />
1276
- <div v-if="pendingDocs.length" class="space-y-1">
1277
- <div
1278
- v-for="item in pendingDocs"
1279
- :key="contextKey(item)"
1280
- class="flex items-center gap-1.5 rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-xs text-slate-300"
1281
- >
1282
- <UIcon
1283
- :name="item.icon ?? 'i-lucide-file-text'"
1284
- class="h-3.5 w-3.5 shrink-0 text-indigo-400"
1285
- />
1286
- <span class="truncate">{{ item.title }}</span>
1287
- <UBadge
1288
- v-if="item.needsImport"
1289
- color="neutral"
1290
- variant="soft"
1291
- size="xs"
1292
- class="ms-1 shrink-0"
1293
- >
1294
- {{ t('board.addTask.importsOnAdd') }}
1295
- </UBadge>
1296
- <button
1297
- type="button"
1298
- class="ms-auto shrink-0 text-slate-400 hover:text-slate-200"
1299
- @click="removePending(item)"
1300
- >
1301
- <UIcon name="i-lucide-x" class="h-3.5 w-3.5" />
1302
- </button>
1303
- </div>
1304
- </div>
1305
- <p v-else class="text-[11px] text-slate-500">
1306
- {{ t('board.addTask.noDocsHint') }}
1307
- </p>
1308
- </div>
1309
-
1310
- <!-- Context issues (ungated; Attach disabled until a tracker is connected). -->
1311
- <div class="space-y-2">
1312
- <div class="flex items-center justify-between">
1313
- <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
1314
- {{ t('board.addTask.contextIssues') }}
1315
- </span>
1316
- <UButton
1317
- v-if="issuesConnected"
1318
- color="neutral"
1319
- variant="soft"
1320
- size="xs"
1321
- :icon="showIssuePicker ? 'i-lucide-x' : 'i-lucide-plus'"
1322
- @click="
1323
- () => {
1324
- showIssuePicker = !showIssuePicker
1325
- }
1326
- "
1327
- >
1328
- {{ showIssuePicker ? t('board.addTask.done') : t('board.addTask.attach') }}
1329
- </UButton>
1330
- <UDropdownMenu
1331
- v-else-if="connectableIssueSources.length > 1"
1332
- :items="connectIssueMenu"
1333
- :content="{ side: 'bottom', align: 'end' }"
1334
- >
1335
- <UButton color="neutral" variant="soft" size="xs" icon="i-lucide-plug">
1336
- {{ t('board.addTask.connectSource') }}
1337
- </UButton>
1338
- </UDropdownMenu>
1339
- <UButton
1340
- v-else-if="connectableIssueSources.length === 1"
1341
- color="neutral"
1342
- variant="soft"
1343
- size="xs"
1344
- icon="i-lucide-plug"
1345
- @click="ui.openTaskConnect(connectableIssueSources[0]!.source)"
1346
- >
1347
- {{
1348
- t('board.addTask.connectSourceNamed', {
1349
- source: connectableIssueSources[0]!.label,
1350
- })
1351
- }}
1352
- </UButton>
1353
- <UButton
1354
- v-else
1355
- color="neutral"
1356
- variant="soft"
1357
- size="xs"
1358
- icon="i-lucide-plus"
1359
- disabled
1360
- :title="
1361
- tasks.available
1362
- ? t('board.addTask.attachIssueDisabledConnect')
1363
- : t('board.addTask.attachIssueDisabledEnable')
1364
- "
1365
- >
1366
- {{ t('board.addTask.attach') }}
1367
- </UButton>
1368
- </div>
1369
- <!-- The container is what scopes the issue search to a repo, so the picker is
1370
- only rendered once we have one (the modal is open, so we always do). -->
1371
- <ContextIssuePicker
1372
- v-if="showIssuePicker && issuesConnected && ui.addTaskContainerId"
1373
- :chosen-keys="chosenIssueKeys"
1374
- :scope-block-id="ui.addTaskContainerId"
1375
- @pick="addPending"
1376
- />
1377
- <div v-if="pendingIssues.length" class="space-y-1">
1378
- <div
1379
- v-for="item in pendingIssues"
1380
- :key="contextKey(item)"
1381
- class="flex items-center gap-1.5 rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-xs text-slate-300"
1382
- >
1383
- <UIcon
1384
- :name="item.icon ?? 'i-lucide-square-check'"
1385
- class="h-3.5 w-3.5 shrink-0 text-indigo-400"
1386
- />
1387
- <span class="truncate">{{ item.title }}</span>
1388
- <UBadge
1389
- v-if="item.needsImport"
1390
- color="neutral"
1391
- variant="soft"
1392
- size="xs"
1393
- class="ms-1 shrink-0"
1394
- >
1395
- {{ t('board.addTask.importsOnAdd') }}
1396
- </UBadge>
1397
- <button
1398
- type="button"
1399
- class="ms-auto shrink-0 text-slate-400 hover:text-slate-200"
1400
- @click="removePending(item)"
1401
- >
1402
- <UIcon name="i-lucide-x" class="h-3.5 w-3.5" />
1403
- </button>
1404
- </div>
1405
- </div>
1406
- <p v-else class="text-[11px] text-slate-500">
1407
- {{ t('board.addTask.noIssuesHint') }}
1408
- </p>
1409
- </div>
1159
+ <!-- Context documents + issues, staged here and linked once the task exists.
1160
+ Shared with the initiative create modal (ContextAttachmentFields). -->
1161
+ <ContextAttachmentFields
1162
+ v-if="ui.addTaskContainerId"
1163
+ v-model="pendingContext"
1164
+ :scope-block-id="ui.addTaskContainerId"
1165
+ :docs-hint="t('board.addTask.noDocsHint')"
1166
+ :issues-hint="t('board.addTask.noIssuesHint')"
1167
+ />
1410
1168
 
1411
1169
  <p class="text-[11px] text-slate-500">
1412
1170
  {{ t('board.addTask.plannedHint') }}
@@ -9,6 +9,11 @@
9
9
  // The preset form is rendered GENERICALLY from `descriptor.fields` (InitiativePresetFields) — zero
10
10
  // per-preset frontend code. A preset with a repo-detection probe prefills its form from the frame's
11
11
  // repo on selection (best-effort; failures fall back to descriptor defaults and never block create).
12
+ //
13
+ // The user can also attach CONTEXT — requirements, RFCs, PRDs, tracker issues — which the whole
14
+ // planning pipeline then reads: the interviewer stops asking what an attached document already
15
+ // answers, and the analyst and planner ground the plan in it. Linking needs a block id, so picks
16
+ // are staged and committed once the initiative exists (the add-task flow's shared orchestration).
12
17
  import { computed, ref, watch } from 'vue'
13
18
  import {
14
19
  sanitizeInitiativePresetInputs,
@@ -18,12 +23,15 @@ import type { InitiativePresetInputs, InitiativePresetInputValue } from '~/types
18
23
  import { defaultPresetInputs } from '~/utils/initiative'
19
24
  import { GENERIC_PRESET_ID } from '~/stores/initiative'
20
25
  import InitiativePresetFields from '~/components/board/InitiativePresetFields.vue'
26
+ import ContextAttachmentFields from '~/components/context/ContextAttachmentFields.vue'
27
+ import type { PendingContext } from '~/composables/useContextLinking'
21
28
 
22
29
  const ui = useUiStore()
23
30
  const board = useBoardStore()
24
31
  const initiatives = useInitiativesStore()
25
32
  const toast = useToast()
26
33
  const { t } = useI18n()
34
+ const { linkPending, presentLinkFailures } = useContextLinking()
27
35
 
28
36
  const open = computed({
29
37
  get: () => ui.createInitiativeFrameId !== null,
@@ -45,6 +53,8 @@ const selectedPreset = computed(() => initiatives.presetById(selectedPresetId.va
45
53
  const title = ref('')
46
54
  const description = ref('')
47
55
  const inputs = ref<InitiativePresetInputs>({})
56
+ // Context the user chose to attach, committed once the initiative block exists (see create()).
57
+ const pendingContext = ref<PendingContext[]>([])
48
58
 
49
59
  // Monotonic token so a slow probe response from a since-changed preset/frame is discarded.
50
60
  let probeSeq = 0
@@ -97,6 +107,7 @@ watch(open, (o) => {
97
107
  if (!o) return
98
108
  title.value = ''
99
109
  description.value = ''
110
+ pendingContext.value = []
100
111
  selectedPresetId.value = GENERIC_PRESET_ID
101
112
  applyPreset()
102
113
  })
@@ -123,6 +134,12 @@ async function create() {
123
134
  ? sanitizeInitiativePresetInputs(descriptor, inputs.value)
124
135
  : undefined,
125
136
  })
137
+ // Surface the SPECIFIC cause of any attachment that couldn't be linked (a GitHub
138
+ // permission/visibility error, a not-found doc, …) rather than a bare count. The initiative
139
+ // itself is already created, so a failed attachment never costs the user the form.
140
+ presentLinkFailures(await linkPending(block.id, pendingContext.value), block.id, {
141
+ title: (count) => t('initiative.create.linkFailed', { count }, count),
142
+ })
126
143
  ui.closeCreateInitiative()
127
144
  // Select the fresh block so the inspector offers "Run planning" right away.
128
145
  ui.select(block.id)
@@ -216,6 +233,16 @@ async function create() {
216
233
  :descriptor="selectedPreset"
217
234
  />
218
235
 
236
+ <!-- Attached requirements / issues, staged here and linked once the block exists. The
237
+ issue search is scoped to the service frame, which is what resolves its repo. -->
238
+ <ContextAttachmentFields
239
+ v-if="ui.createInitiativeFrameId"
240
+ v-model="pendingContext"
241
+ :scope-block-id="ui.createInitiativeFrameId"
242
+ :docs-hint="t('initiative.create.contextDocsHint')"
243
+ :issues-hint="t('initiative.create.contextIssuesHint')"
244
+ />
245
+
219
246
  <p class="text-[11px] text-slate-500">
220
247
  {{ t('initiative.create.hint') }}
221
248
  </p>
@@ -39,6 +39,7 @@ const {
39
39
  planningPipeline,
40
40
  running,
41
41
  awaitingAnswers,
42
+ interviewing,
42
43
  starting,
43
44
  runPlanning,
44
45
  openPlanning,
@@ -116,6 +117,19 @@ function onHandle(e: PointerEvent) {
116
117
  >
117
118
  {{ t('initiative.inspector.answerPlanning') }}
118
119
  </UButton>
120
+ <!-- Mid-pass: nothing to answer, but keep the route into the window (it shows the wait). -->
121
+ <UButton
122
+ v-else-if="interviewing"
123
+ data-testid="initiative-card-planning-in-progress"
124
+ size="xs"
125
+ variant="soft"
126
+ color="neutral"
127
+ icon="i-lucide-loader-circle"
128
+ :ui="{ leadingIcon: 'animate-spin' }"
129
+ @click.stop="openPlanning"
130
+ >
131
+ {{ t('initiative.inspector.planningInProgress') }}
132
+ </UButton>
119
133
  <UButton
120
134
  v-else
121
135
  data-testid="initiative-card-run-planning"
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ // The shared "the interview is not waiting on you" panel, rendered by BOTH interview-gate windows
3
+ // (initiative planning, document interview) in place of the question list. It exists because those
4
+ // two states — a pass running, and a run that stopped before the interview settled — are the ones
5
+ // a window keyed on the interview entity alone cannot show at all, which is what made continue /
6
+ // proceed read as dead buttons. Copy is per-feature (passed in); the treatment is shared, so the
7
+ // two windows can't drift on how a wait or a failure looks. See
8
+ // `docs/initiatives/clarification-items.md`.
9
+ defineProps<{
10
+ /** `working` = a pass is in flight (spinner); `failed` = the run stopped (error treatment). */
11
+ variant: 'working' | 'failed'
12
+ /** The headline, already localized by the calling window. */
13
+ title: string
14
+ /** The explanatory line under it, already localized. */
15
+ hint: string
16
+ /** The window's own `data-testid`, so each keeps a stable selector. */
17
+ testid: string
18
+ }>()
19
+ </script>
20
+
21
+ <template>
22
+ <div
23
+ v-if="variant === 'working'"
24
+ class="flex flex-col items-center gap-2 rounded-lg border border-slate-800 bg-slate-950/40 p-6 text-center"
25
+ :data-testid="testid"
26
+ >
27
+ <UIcon name="i-lucide-loader-circle" class="h-5 w-5 animate-spin text-indigo-300" />
28
+ <p class="text-[13px] text-slate-200">{{ title }}</p>
29
+ <p class="text-[12px] text-slate-400">{{ hint }}</p>
30
+ </div>
31
+ <div
32
+ v-else
33
+ class="rounded-lg border border-red-900/60 bg-red-950/20 p-4 text-center"
34
+ :data-testid="testid"
35
+ >
36
+ <p class="text-[13px] text-red-200">{{ title }}</p>
37
+ <p class="mt-1 text-[12px] text-slate-400">{{ hint }}</p>
38
+ </div>
39
+ </template>