@cat-factory/app 0.312.0 → 0.314.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 (38) hide show
  1. package/README.md +12 -0
  2. package/app/components/board/AddTaskModal.vue +29 -30
  3. package/app/components/board/ReviewFrictionDialog.vue +8 -5
  4. package/app/components/layout/AiProvidersBanner.vue +3 -2
  5. package/app/components/layout/GitHubPatBanner.vue +2 -2
  6. package/app/components/layout/GitHubPatPermissionsBanner.vue +4 -12
  7. package/app/components/layout/InfraSetupBanner.vue +6 -7
  8. package/app/components/pipeline/PipelinePicker.vue +8 -2
  9. package/app/components/queue/DescribeWorkModal.vue +367 -0
  10. package/app/components/queue/QueueView.vue +18 -0
  11. package/app/components/setup/SetupChecklistPanel.vue +340 -0
  12. package/app/composables/useNavContributions.ts +2 -0
  13. package/app/composables/useSetupChecklist.ts +122 -0
  14. package/app/composables/useSetupRemedies.ts +72 -0
  15. package/app/composables/useTaskActions.ts +51 -28
  16. package/app/modular/nav-contributions.spec.ts +3 -0
  17. package/app/modular/nav-contributions.ts +46 -0
  18. package/app/pages/index.vue +6 -0
  19. package/app/stores/github/context.ts +1 -0
  20. package/app/stores/github/probe.ts +7 -3
  21. package/app/stores/github.ts +9 -0
  22. package/app/stores/ui/modals.ts +42 -0
  23. package/app/utils/describeWork.spec.ts +76 -0
  24. package/app/utils/describeWork.ts +71 -0
  25. package/app/utils/reviewFriction.ts +47 -0
  26. package/app/utils/setupChecklist.spec.ts +184 -0
  27. package/app/utils/setupChecklist.ts +229 -0
  28. package/i18n/locales/de.json +97 -3
  29. package/i18n/locales/en.json +97 -3
  30. package/i18n/locales/es.json +97 -3
  31. package/i18n/locales/fr.json +97 -3
  32. package/i18n/locales/he.json +97 -3
  33. package/i18n/locales/it.json +97 -3
  34. package/i18n/locales/ja.json +97 -3
  35. package/i18n/locales/pl.json +97 -3
  36. package/i18n/locales/tr.json +97 -3
  37. package/i18n/locales/uk.json +97 -3
  38. package/package.json +1 -1
package/README.md CHANGED
@@ -1492,6 +1492,18 @@ example ships in [`deploy/frontend`](https://github.com/kibertoad/cat-factory/tr
1492
1492
  switchers, the `SpendWarningBanner`, and the toolbar (zoom, LOD, decision queue).
1493
1493
  - **Palettes** (`components/palettes`): drag blocks, pipelines and agents onto
1494
1494
  the board.
1495
+ - **Describe new work** (`components/queue/DescribeWorkModal.vue`, `utils/describeWork.ts`): the
1496
+ one-sentence intake, from Create, the palette and the queue header. It files a feature or a bug
1497
+ from a sentence and a service, titled by the first sentence; the overrides sit behind one
1498
+ disclosure, and the merge policy and model preset follow the full form's tier rule
1499
+ (`showOverrideField`), so basic mode and the designer role see the pipeline only.
1500
+ It rebuilds no other kind: "More kinds" and "Attach context" hand the text to `AddTaskModal`,
1501
+ which stays the one home of those rules. Both share `utils/reviewFriction.ts` for the review-debt 409.
1502
+ - **Setup checklist** (`components/setup/SetupChecklistPanel.vue`, `utils/setupChecklist.ts`):
1503
+ every prerequisite for "a task can run and merge" in one list, for people who can change the
1504
+ setup. It adds no probe of its own: each row reads what its banner or startup dialog already
1505
+ reads (`useSetupChecklist`) and opens the same surface. "Checking" and "missing" are separate
1506
+ states, because an unanswered probe and an absent capability are opposite facts.
1495
1507
  - **Inspector** (`components/panels` + `panels/inspector`): per-block tabs:
1496
1508
  structure, dependencies, model + fragment picker, live execution, and linked
1497
1509
  docs/issues/scenarios. Decisions resolve via `DecisionModal`. A `review` task
@@ -36,6 +36,7 @@ import FragmentSelector from '~/components/fragments/FragmentSelector.vue'
36
36
  import ReviewSkillQueue from '~/components/skills/ReviewSkillQueue.vue'
37
37
  import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
38
38
  import { parseConflict } from '~/composables/usePipelineErrorToast'
39
+ import { isReviewDebtConflict, reviewFrictionContext } from '~/utils/reviewFriction'
39
40
  import { apiErrorEnvelope } from '~/composables/api/errors'
40
41
  import type { ReviewTargetReason } from '@cat-factory/contracts'
41
42
  import {
@@ -526,6 +527,8 @@ function defaultPipelineIdFor(type: TaskTypeChoice): string {
526
527
  return offered ? preset : ''
527
528
  }
528
529
 
530
+ // Set while a prefill's type is being seeded with a carried pipeline (see `onModalOpen` below).
531
+ let seedingPrefill = false
529
532
  watch(taskType, (next) => {
530
533
  const custom = customTaskTypes.value.find((tt) => tt.taskType === next)
531
534
  // A custom type owns a fresh field bag on every switch (its descriptors differ per type), seeded
@@ -533,6 +536,10 @@ watch(taskType, (next) => {
533
536
  customFieldValues.value = descriptorFieldDefaults(custom?.fields ?? [])
534
537
  // An unresolvable preset leaves the current selection alone rather than blanking it: a type
535
538
  // switch is an edit to a form the user is already filling in, not a reset.
539
+ if (seedingPrefill) {
540
+ seedingPrefill = false
541
+ return
542
+ }
536
543
  const preset = defaultPipelineIdFor(next)
537
544
  if (preset) pipelineId.value = preset
538
545
  })
@@ -667,13 +674,23 @@ onModalOpen(open, () => {
667
674
  agentConfigValues.value = {}
668
675
  pendingContext.value = []
669
676
  // Seed from a prefill when opened from another surface (e.g. "create task from
670
- // issue" sets the title + stages the issue as linked context). Pipeline / preset
671
- // are intentionally left at their defaults so the user confirms them here.
677
+ // issue" sets the title + stages the issue as linked context). Pipeline / preset stay at their
678
+ // defaults unless the opener carries a choice the person already made (the one-sentence
679
+ // intake's kind and overrides), which the hand-off must not reset.
672
680
  const prefill = ui.addTaskPrefill
673
681
  if (prefill) {
674
682
  if (prefill.title) title.value = prefill.title
675
683
  if (prefill.description) description.value = prefill.description
676
684
  if (prefill.context?.length) pendingContext.value = [...prefill.context]
685
+ if (prefill.taskType && prefill.taskType !== taskType.value) {
686
+ taskType.value = prefill.taskType
687
+ // The type watcher runs after this block and would replace a carried pipeline with the
688
+ // new type's default; a seeded type is not the edit that watcher is for.
689
+ seedingPrefill = !!prefill.pipelineId
690
+ }
691
+ if (prefill.pipelineId) pipelineId.value = prefill.pipelineId
692
+ if (prefill.riskPolicyId) riskPolicyId.value = prefill.riskPolicyId
693
+ if (prefill.modelPresetId) modelPresetId.value = prefill.modelPresetId
677
694
  }
678
695
  documents.loadDocuments().catch(() => {})
679
696
  tasks.loadTasks().catch(() => {})
@@ -831,8 +848,15 @@ async function submitCreate(acknowledgeReviewDebt: boolean) {
831
848
  ui.closeAddTask()
832
849
  } catch (e) {
833
850
  const conflict = parseConflict(e)
834
- if (conflict?.reason === 'review_debt_warn' || conflict?.reason === 'review_debt_blocked') {
835
- openReviewFrictionDialog(conflict)
851
+ if (isReviewDebtConflict(conflict)) {
852
+ ui.openReviewFriction(
853
+ reviewFrictionContext(
854
+ conflict,
855
+ () => void submitCreate(true),
856
+ () => saving.value,
857
+ () => ui.closeAddTask(),
858
+ ),
859
+ )
836
860
  return
837
861
  }
838
862
  const refusal = createRefusalMessage(e)
@@ -883,32 +907,6 @@ function createRefusalMessage(error: unknown): string | null {
883
907
  if (reason === 'task_type_fields_invalid') return t('board.addTask.customFieldsInvalid')
884
908
  return REVIEW_TARGET_MESSAGES[reason as ReviewTargetReason]?.(details) ?? null
885
909
  }
886
-
887
- /** Turn a parsed review-debt friction 409 into the dialog context (see ReviewFrictionDialog.vue). */
888
- function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseConflict>>) {
889
- const details = conflict.details
890
- const rawDebt = Array.isArray(details.debt) ? details.debt : []
891
- const debt = rawDebt.map((d) => {
892
- const row = (d ?? {}) as { blockId?: unknown; title?: unknown; waitingMinutes?: unknown }
893
- return {
894
- blockId: typeof row.blockId === 'string' ? row.blockId : '',
895
- title: typeof row.title === 'string' ? row.title : null,
896
- waitingMinutes: typeof row.waitingMinutes === 'number' ? row.waitingMinutes : 0,
897
- }
898
- })
899
- const isWarn = conflict.reason === 'review_debt_warn'
900
- ui.openReviewFriction({
901
- kind: isWarn ? 'warn' : 'blocked',
902
- reason:
903
- details.friction === 'count' || details.friction === 'stuck' ? details.friction : undefined,
904
- threshold: typeof details.threshold === 'number' ? details.threshold : null,
905
- debt,
906
- onConfirm: isWarn ? () => void submitCreate(true) : null,
907
- // A getter, not a snapshot: the dialog reads it inside a computed so its button spins and
908
- // locks for as long as the retry actually runs (UX-78).
909
- pending: () => saving.value,
910
- })
911
- }
912
910
  </script>
913
911
 
914
912
  <template>
@@ -954,6 +952,7 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
954
952
  :icon="ty.icon"
955
953
  size="xs"
956
954
  :title="ty.description"
955
+ :aria-pressed="taskType === ty.value"
957
956
  :data-testid="`task-type-${ty.value}`"
958
957
  @click="
959
958
  () => {
@@ -53,17 +53,20 @@ const body = computed(() => {
53
53
  /** Deep-link to a waiting task's block and dismiss the whole friction flow. */
54
54
  function goToBlock(blockId: string) {
55
55
  if (blockId) ui.select(blockId)
56
+ leave()
57
+ }
58
+
59
+ /** Close the friction and the form that raised it. */
60
+ function leave() {
61
+ const onLeave = ctx.value?.onLeave
56
62
  ui.closeReviewFriction()
57
- ui.closeAddTask()
63
+ onLeave?.()
58
64
  }
59
65
 
60
66
  function goReview() {
61
67
  const first = ctx.value?.debt[0]
62
68
  if (first) goToBlock(first.blockId)
63
- else {
64
- ui.closeReviewFriction()
65
- ui.closeAddTask()
66
- }
69
+ else leave()
67
70
  }
68
71
 
69
72
  function createAnyway() {
@@ -8,6 +8,7 @@ import { computed } from 'vue'
8
8
 
9
9
  const { t } = useI18n()
10
10
  const ui = useUiStore()
11
+ const remedies = useSetupRemedies()
11
12
  const { ready, hasUsableModel, defaultPresetBroken } = useAiReadiness()
12
13
 
13
14
  const showSetup = computed(() => ready.value && !hasUsableModel.value && !ui.aiSetupDismissed)
@@ -47,7 +48,7 @@ const show = computed(() => showSetup.value || showPreset.value)
47
48
  {{ t('layout.aiProvidersBanner.setup.body') }}
48
49
  </p>
49
50
  <div class="mt-4">
50
- <UButton color="warning" icon="i-lucide-settings" @click="ui.openAiProviderSetup()">
51
+ <UButton color="warning" icon="i-lucide-settings" @click="remedies.openModelSetup()">
51
52
  {{ t('layout.aiProvidersBanner.setup.action') }}
52
53
  </UButton>
53
54
  </div>
@@ -88,7 +89,7 @@ const show = computed(() => showSetup.value || showPreset.value)
88
89
  size="sm"
89
90
  color="warning"
90
91
  icon="i-lucide-cpu"
91
- @click="ui.openAiPresetMismatch()"
92
+ @click="remedies.openPresetMismatch()"
92
93
  >
93
94
  {{ t('layout.aiProvidersBanner.preset.action') }}
94
95
  </UButton>
@@ -7,9 +7,9 @@ const { t } = useI18n()
7
7
  // repo-operating agent step (clone, push, open PR, CI gate, merge) will fail. The server
8
8
  // also logs this, but a dev terminal is easy to miss — so surface it in the UI with the
9
9
  // (scopes-preselected) creation URL as a one-click link straight to GitHub.
10
- const auth = useAuthStore()
10
+ const { patSetupUrl } = useSetupRemedies()
11
11
 
12
- const setupUrl = computed(() => auth.localMode?.githubPatSetupUrl ?? '')
12
+ const setupUrl = computed(() => patSetupUrl.value ?? '')
13
13
  const dismissed = ref(false)
14
14
  const show = computed(() => !!setupUrl.value && !dismissed.value)
15
15
  </script>
@@ -3,11 +3,9 @@ import { computed, ref } from 'vue'
3
3
  import {
4
4
  GITHUB_PAT_FINE_GRAINED_PERMISSIONS,
5
5
  githubPatCheckNeedsAttention,
6
- githubPatCheckSource,
7
6
  missingGitHubPatCapabilities,
8
7
  } from '@cat-factory/contracts'
9
- import type { GitHubPatCapability, GitHubPatKind } from '~/types/domain'
10
- import { githubPatRemintUrl } from '~/utils/vcs'
8
+ import type { GitHubPatCapability } from '~/types/domain'
11
9
 
12
10
  // The token a run would authenticate with cannot do what the pipeline needs.
13
11
  //
@@ -67,14 +65,8 @@ function capabilityLabel(capability: GitHubPatCapability): string {
67
65
  return t(CAPABILITY_KEYS[capability])
68
66
  }
69
67
 
70
- /**
71
- * The kind carried over to the re-mint link. A rejected token was never classified, so it falls
72
- * back to `unknown` — which lands on the classic form, the only one GitHub lets us pre-fill.
73
- */
74
- const kind = computed<GitHubPatKind>(() => report.value?.kind ?? 'unknown')
75
- const remintUrl = computed(() =>
76
- githubPatRemintUrl(kind.value, report.value?.webUrl ?? github.connection?.webUrl),
77
- )
68
+ // The re-mint page and whose token it is: the map the setup checklist opens through too.
69
+ const { patRemintUrl: remintUrl, patSource, patKind: kind } = useSetupRemedies()
78
70
 
79
71
  /**
80
72
  * Who has to act. A deployment token is replaced by whoever runs the deployment (local mode:
@@ -89,7 +81,7 @@ const remintUrl = computed(() =>
89
81
  * personal settings: the exact misrouting the wire field exists to prevent.
90
82
  */
91
83
  const sourceKey = computed(() =>
92
- check.value && githubPatCheckSource(check.value) === 'deployment'
84
+ patSource.value === 'deployment'
93
85
  ? 'layout.githubPatPermissionsBanner.sourceDeployment'
94
86
  : 'layout.githubPatPermissionsBanner.sourceInitiator',
95
87
  )
@@ -65,6 +65,8 @@ const ui = useUiStore()
65
65
  const auth = useAuthStore()
66
66
  const workspace = useWorkspaceStore()
67
67
  const { actor } = useSetupActor()
68
+ // Where each area is configured: the map the setup checklist opens through too.
69
+ const remedies = useSetupRemedies()
68
70
 
69
71
  // Severity order: no executor blocks EVERY agent, so it leads; a missing test environment blocks
70
72
  // only testing agents; missing storage only degrades the UI-tester's screenshots.
@@ -73,32 +75,29 @@ const AREAS: InfraSetupArea[] = ['agentExecutor', 'ephemeralEnvironments', 'bina
73
75
  // Exhaustive per-area presentation (an exhaustive `Record<InfraSetupArea, …>`, so adding an area
74
76
  // without a meta entry fails typecheck — the tier-2 guard). The i18n keys are resolved
75
77
  // dynamically (`t(AREA_META[area].titleKey)`), so tier-1 typed-key checking doesn't cover them;
76
- // the `i18n:check` drift guard (tier 3) catches any that are absent from the catalog. `action`
77
- // deep-links into the relevant setup surface.
78
+ // the `i18n:check` drift guard (tier 3) catches any that are absent from the catalog. The action
79
+ // opens the area's setup surface through `useSetupRemedies`.
78
80
  const AREA_META: Record<
79
81
  InfraSetupArea,
80
- { icon: string; titleKey: string; bodyKey: string; actionKey: string; onConfigure: () => void }
82
+ { icon: string; titleKey: string; bodyKey: string; actionKey: string }
81
83
  > = {
82
84
  agentExecutor: {
83
85
  icon: 'i-lucide-server-cog',
84
86
  titleKey: 'layout.infraSetupBanner.agentExecutor.title',
85
87
  bodyKey: 'layout.infraSetupBanner.agentExecutor.body',
86
88
  actionKey: 'layout.infraSetupBanner.agentExecutor.action',
87
- onConfigure: () => ui.openProviderConnection('runner-pool'),
88
89
  },
89
90
  ephemeralEnvironments: {
90
91
  icon: 'i-lucide-flask-conical',
91
92
  titleKey: 'layout.infraSetupBanner.ephemeralEnvironments.title',
92
93
  bodyKey: 'layout.infraSetupBanner.ephemeralEnvironments.body',
93
94
  actionKey: 'layout.infraSetupBanner.ephemeralEnvironments.action',
94
- onConfigure: () => ui.openProviderConnection('environment'),
95
95
  },
96
96
  binaryStorage: {
97
97
  icon: 'i-lucide-hard-drive',
98
98
  titleKey: 'layout.infraSetupBanner.binaryStorage.title',
99
99
  bodyKey: 'layout.infraSetupBanner.binaryStorage.body',
100
100
  actionKey: 'layout.infraSetupBanner.binaryStorage.action',
101
- onConfigure: () => ui.openContentStorageSettings(),
102
101
  },
103
102
  }
104
103
 
@@ -291,7 +290,7 @@ function dismissMenu(card: Pick<AreaCard, 'area' | 'kind'>): DropdownMenuItem[][
291
290
  :color="card.kind === 'outage' ? 'error' : 'warning'"
292
291
  icon="i-lucide-settings"
293
292
  :data-testid="`infra-setup-configure-${card.area}`"
294
- @click="AREA_META[card.area].onConfigure()"
293
+ @click="remedies.openInfraArea(card.area)"
295
294
  >
296
295
  {{
297
296
  card.kind === 'outage'
@@ -21,6 +21,12 @@ const props = withDefaults(
21
21
  * chosen row — and the row is left out entirely.
22
22
  */
23
23
  noneLabel?: string
24
+ /**
25
+ * What the preview pane says about the "none" row. Defaults to the full form's meaning ("no
26
+ * default; you choose when you run it"); a surface whose "none" row means the workspace default
27
+ * names what that default resolves to instead.
28
+ */
29
+ noneHint?: string
24
30
  /**
25
31
  * Default-trigger text while nothing is selected. Defaults to {@link noneLabel}, which reads
26
32
  * correctly when the "none" row IS the empty state; a surface without that row supplies its
@@ -30,7 +36,7 @@ const props = withDefaults(
30
36
  /** Extra classes for the default trigger button (e.g. full-width in the modal). */
31
37
  triggerClass?: string
32
38
  }>(),
33
- { noneLabel: undefined, placeholder: undefined, triggerClass: '' },
39
+ { noneLabel: undefined, noneHint: undefined, placeholder: undefined, triggerClass: '' },
34
40
  )
35
41
 
36
42
  const emit = defineEmits<{ 'update:modelValue': [string] }>()
@@ -59,7 +65,7 @@ const previewPipeline = computed<Pipeline | null>(() => {
59
65
  */
60
66
  const emptyHint = computed(() =>
61
67
  props.noneLabel !== undefined && (hoverId.value ?? props.modelValue) === ''
62
- ? t('pipeline.picker.noneHint')
68
+ ? (props.noneHint ?? t('pipeline.picker.noneHint'))
63
69
  : t('pipeline.picker.hoverHint'),
64
70
  )
65
71