@cat-factory/app 0.312.0 → 0.313.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 CHANGED
@@ -1492,6 +1492,11 @@ 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
+ - **Setup checklist** (`components/setup/SetupChecklistPanel.vue`, `utils/setupChecklist.ts`):
1496
+ every prerequisite for "a task can run and merge" in one list, for people who can change the
1497
+ setup. It adds no probe of its own: each row reads what its banner or startup dialog already
1498
+ reads (`useSetupChecklist`) and opens the same surface. "Checking" and "missing" are separate
1499
+ states, because an unanswered probe and an absent capability are opposite facts.
1495
1500
  - **Inspector** (`components/panels` + `panels/inspector`): per-block tabs:
1496
1501
  structure, dependencies, model + fragment picker, live execution, and linked
1497
1502
  docs/issues/scenarios. Decisions resolve via `DecisionModal`. A `review` task
@@ -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'
@@ -0,0 +1,340 @@
1
+ <script setup lang="ts">
2
+ import type { SetupRow, SetupRowCause, SetupRowId, SetupRowStatus } from '~/utils/setupChecklist'
3
+ import type { SetupRemedy } from '~/utils/setupAudience'
4
+ import SectionLabel from '~/components/common/SectionLabel.vue'
5
+ import Spinner from '~/components/common/Spinner.vue'
6
+
7
+ /**
8
+ * The setup checklist: every prerequisite for "a task can run and merge", its state, and one
9
+ * action per row (issue #2258).
10
+ *
11
+ * The probes and the surfaces each row opens are the ones the setup banners, the AI-provider
12
+ * dialogs and the startup health advisories already use (`useSetupChecklist`), and so is the rule
13
+ * for who may act: a row the caller cannot fix shows who can instead of a button. What it adds is
14
+ * the whole list in one place, with a finish line, instead of one interruption per missing piece.
15
+ * Opening a row's surface closes the checklist, so the two never stack.
16
+ */
17
+ const { t } = useI18n()
18
+ const ui = useUiStore()
19
+ const { checklist, actionFor, refresh, patSource, patMissing } = useSetupChecklist()
20
+
21
+ const open = computed({
22
+ get: () => ui.setupChecklistOpen,
23
+ set: (v: boolean) => (v ? ui.openSetupChecklist() : ui.closeSetupChecklist()),
24
+ })
25
+ watch(
26
+ () => ui.setupChecklistOpen,
27
+ (isOpen) => {
28
+ if (isOpen) refresh()
29
+ },
30
+ { immediate: true },
31
+ )
32
+
33
+ /** Open a surface from the checklist: close the checklist first, so the two never stack. */
34
+ function go(fn: () => void) {
35
+ ui.closeSetupChecklist()
36
+ fn()
37
+ }
38
+ function act(row: SetupRow) {
39
+ const action = actionFor(row)
40
+ if (!action) return
41
+ // Asking a probe again keeps the checklist open: its answer lands here.
42
+ if (row.cause === 'probeFailed') action()
43
+ else go(action)
44
+ }
45
+
46
+ /** The rows this caller can act on, with the action to show. */
47
+ function canAct(row: SetupRow): boolean {
48
+ return row.remedy === null && actionFor(row) !== null
49
+ }
50
+
51
+ /**
52
+ * The ONE solid button in the view: the first required row that is not done and that this caller
53
+ * can act on. Every other open row takes a lighter variant, so the list reads in order.
54
+ */
55
+ const primaryRowId = computed<SetupRowId | null>(
56
+ () =>
57
+ checklist.value.rows.find(
58
+ (r) => r.required && (r.status === 'missing' || r.status === 'attention') && canAct(r),
59
+ )?.id ?? null,
60
+ )
61
+
62
+ const pipelineHealth = usePipelineHealth()
63
+ const riskPolicyHealth = useRiskPolicyHealth()
64
+ const modelPresetHealth = useModelPresetHealth()
65
+
66
+ const STATUS_META: Record<SetupRowStatus, { icon: string; class: string }> = {
67
+ done: { icon: 'i-lucide-circle-check', class: 'text-success' },
68
+ missing: { icon: 'i-lucide-circle', class: 'text-dimmed' },
69
+ attention: { icon: 'i-lucide-circle-alert', class: 'text-warning' },
70
+ checking: { icon: 'i-lucide-loader-circle', class: 'text-dimmed' },
71
+ not_needed: { icon: 'i-lucide-circle-minus', class: 'text-dimmed' },
72
+ }
73
+
74
+ /**
75
+ * The action's word per state. Null for the two states that never carry an action
76
+ * (`useSetupChecklist.actionFor`), so no copy ships for a button that cannot render.
77
+ */
78
+ const ACTION_KEYS: Record<SetupRowStatus, string | null> = {
79
+ done: 'setup.action.change',
80
+ missing: 'setup.action.connect',
81
+ attention: 'setup.action.fix',
82
+ checking: null,
83
+ not_needed: null,
84
+ }
85
+
86
+ function actionLabel(row: SetupRow): string {
87
+ if (row.cause === 'probeFailed') return t('setup.action.retry')
88
+ const key = ACTION_KEYS[row.status]
89
+ return key ? t(key) : ''
90
+ }
91
+
92
+ /** Whose token a token row is about: the remedy differs, so the row says it, as the banner does. */
93
+ const patSourceKey = computed(() =>
94
+ patSource.value === 'deployment'
95
+ ? 'layout.githubPatPermissionsBanner.sourceDeployment'
96
+ : 'layout.githubPatPermissionsBanner.sourceInitiator',
97
+ )
98
+
99
+ /**
100
+ * Each row's title and its line for the states that are about the row. An `attention` row's line
101
+ * is its CAUSE (`CAUSE_KEYS`): the title already names the area, and each cause needs its own fix.
102
+ */
103
+ const ROW_COPY: Record<
104
+ SetupRowId,
105
+ { title: string } & Record<Exclude<SetupRowStatus, 'attention'>, string>
106
+ > = {
107
+ model: {
108
+ title: 'setup.rows.model.title',
109
+ done: 'setup.rows.model.done',
110
+ missing: 'setup.rows.model.missing',
111
+ checking: 'setup.rows.checking',
112
+ not_needed: 'setup.rows.notNeeded',
113
+ },
114
+ sourceControl: {
115
+ title: 'setup.rows.sourceControl.title',
116
+ done: 'setup.rows.sourceControl.done',
117
+ missing: 'setup.rows.sourceControl.missing',
118
+ checking: 'setup.rows.checking',
119
+ not_needed: 'setup.rows.notNeeded',
120
+ },
121
+ agentExecutor: {
122
+ title: 'setup.rows.agentExecutor.title',
123
+ done: 'setup.rows.agentExecutor.done',
124
+ missing: 'setup.rows.agentExecutor.missing',
125
+ checking: 'setup.rows.checking',
126
+ not_needed: 'setup.rows.agentExecutor.notNeeded',
127
+ },
128
+ ephemeralEnvironments: {
129
+ title: 'setup.rows.ephemeralEnvironments.title',
130
+ done: 'setup.rows.ephemeralEnvironments.done',
131
+ missing: 'setup.rows.ephemeralEnvironments.missing',
132
+ checking: 'setup.rows.checking',
133
+ not_needed: 'setup.rows.notNeeded',
134
+ },
135
+ binaryStorage: {
136
+ title: 'setup.rows.binaryStorage.title',
137
+ done: 'setup.rows.binaryStorage.done',
138
+ missing: 'setup.rows.binaryStorage.missing',
139
+ checking: 'setup.rows.checking',
140
+ not_needed: 'setup.rows.notNeeded',
141
+ },
142
+ }
143
+ const CAUSE_KEYS: Record<SetupRowCause, string> = {
144
+ unreachable: 'setup.causes.unreachable',
145
+ incomplete: 'setup.causes.incomplete',
146
+ rejected: 'setup.causes.rejected',
147
+ underscoped: 'setup.causes.underscoped',
148
+ presetBroken: 'setup.causes.presetBroken',
149
+ probeFailed: 'setup.causes.probeFailed',
150
+ notReported: 'setup.causes.notReported',
151
+ }
152
+ function rowLine(row: SetupRow): string {
153
+ return row.status === 'attention' ? t(CAUSE_KEYS[row.cause]) : t(ROW_COPY[row.id][row.status])
154
+ }
155
+
156
+ // The same "who can fix this" lines the infrastructure notice shows a caller who cannot act.
157
+ const REMEDY_KEYS: Record<SetupRemedy, string> = {
158
+ workspace_admin: 'layout.infraSetupNotice.owner.workspace_admin',
159
+ account_admin: 'layout.infraSetupNotice.owner.account_admin',
160
+ operator: 'layout.infraSetupNotice.owner.operator',
161
+ full_surface: 'layout.infraSetupNotice.fullSurface',
162
+ }
163
+
164
+ /**
165
+ * The secondary list: advisories that used to open as startup dialogs, each count on its own so
166
+ * no sentence has to agree with a number. Only rows with work.
167
+ */
168
+ const also = computed(() =>
169
+ [
170
+ {
171
+ id: 'pipelines',
172
+ labelKey: 'setup.also.pipelines',
173
+ counts: [
174
+ {
175
+ key: 'setup.also.counts.repair',
176
+ n:
177
+ pipelineHealth.health.value.filter((h) => h.invalid).length +
178
+ pipelineHealth.retired.value.length,
179
+ },
180
+ {
181
+ key: 'setup.also.counts.outdated',
182
+ n: pipelineHealth.health.value.filter((h) => !h.invalid && h.outdated).length,
183
+ },
184
+ { key: 'setup.also.counts.new', n: pipelineHealth.newPipelines.value.length },
185
+ ],
186
+ open: () => go(() => ui.openPipelineHealth()),
187
+ },
188
+ {
189
+ id: 'riskPolicies',
190
+ labelKey: 'setup.also.riskPolicies',
191
+ counts: [
192
+ { key: 'setup.also.counts.outdated', n: riskPolicyHealth.outdated.value.length },
193
+ { key: 'setup.also.counts.new', n: riskPolicyHealth.newPresets.value.length },
194
+ ],
195
+ open: () => go(() => ui.openRiskPolicyHealth()),
196
+ },
197
+ {
198
+ id: 'modelPresets',
199
+ labelKey: 'setup.also.modelPresets',
200
+ counts: [
201
+ { key: 'setup.also.counts.outdated', n: modelPresetHealth.outdated.value.length },
202
+ { key: 'setup.also.counts.new', n: modelPresetHealth.newPresets.value.length },
203
+ ],
204
+ open: () => go(() => ui.openModelPresetHealth()),
205
+ },
206
+ ]
207
+ .map((row) => ({ ...row, counts: row.counts.filter((c) => c.n > 0) }))
208
+ .filter((row) => row.counts.length > 0),
209
+ )
210
+ </script>
211
+
212
+ <template>
213
+ <UModal v-model:open="open" :title="t('setup.title')" :ui="{ content: 'sm:max-w-3xl' }">
214
+ <template #title>
215
+ <span class="flex items-baseline gap-3">
216
+ {{ t('setup.title') }}
217
+ <!-- While a probe has not answered, the count is not one yet, so the badge says so. -->
218
+ <UBadge color="neutral" variant="subtle" data-testid="setup-progress">
219
+ {{
220
+ checklist.checking
221
+ ? t('setup.rows.checking')
222
+ : t('setup.progress', { done: checklist.done, total: checklist.total })
223
+ }}
224
+ </UBadge>
225
+ </span>
226
+ </template>
227
+ <template #body>
228
+ <div data-testid="setup-checklist">
229
+ <p class="text-sm text-muted">{{ t('setup.intro') }}</p>
230
+
231
+ <ul class="mt-6 space-y-3">
232
+ <li
233
+ v-for="row in checklist.rows"
234
+ :key="row.id"
235
+ class="flex items-center gap-3 rounded-lg border border-muted bg-default p-4"
236
+ data-testid="setup-row"
237
+ :data-row="row.id"
238
+ :data-status="row.status"
239
+ :data-cause="row.cause ?? undefined"
240
+ :data-remedy="row.remedy ?? undefined"
241
+ >
242
+ <Spinner
243
+ :name="STATUS_META[row.status].icon"
244
+ :spinning="row.status === 'checking'"
245
+ class="h-5 w-5 shrink-0"
246
+ :class="STATUS_META[row.status].class"
247
+ />
248
+ <div class="min-w-0 flex-1">
249
+ <div class="flex flex-wrap items-center gap-2">
250
+ <span class="font-medium text-highlighted">{{ t(ROW_COPY[row.id].title) }}</span>
251
+ <UBadge v-if="row.status !== 'done'" size="sm" color="neutral" variant="outline">
252
+ {{ row.required ? t('setup.required') : t('setup.optional') }}
253
+ </UBadge>
254
+ </div>
255
+ <p class="mt-0.5 text-xs text-muted">{{ rowLine(row) }}</p>
256
+ <!-- The token rows carry the banners' second half: whose token it is, and, for a
257
+ missing local token, where the new one goes. -->
258
+ <p
259
+ v-if="row.cause === 'rejected' || row.cause === 'underscoped'"
260
+ class="mt-0.5 text-xs text-dimmed"
261
+ >
262
+ {{ t(patSourceKey) }}
263
+ </p>
264
+ <p
265
+ v-else-if="row.id === 'sourceControl' && row.status === 'missing' && patMissing"
266
+ class="mt-0.5 text-xs text-dimmed"
267
+ >
268
+ <i18n-t keypath="layout.githubPatBanner.thenSet" tag="span" scope="global">
269
+ <template #envVar>
270
+ <code class="font-mono">GITHUB_PAT</code>
271
+ </template>
272
+ </i18n-t>
273
+ </p>
274
+ <!-- A gap this caller cannot close: who can, in place of a button their save would
275
+ be refused on. A done row needs neither. -->
276
+ <p
277
+ v-if="row.remedy && row.status !== 'done' && row.status !== 'not_needed'"
278
+ class="mt-0.5 text-xs text-dimmed"
279
+ data-testid="setup-row-remedy"
280
+ >
281
+ {{ t(REMEDY_KEYS[row.remedy]) }}
282
+ </p>
283
+ </div>
284
+ <UButton
285
+ v-if="canAct(row)"
286
+ size="sm"
287
+ :color="row.status === 'done' ? 'neutral' : 'primary'"
288
+ :variant="
289
+ row.id === primaryRowId ? 'solid' : row.status === 'done' ? 'outline' : 'soft'
290
+ "
291
+ data-testid="setup-row-action"
292
+ @click="act(row)"
293
+ >
294
+ {{ actionLabel(row) }}
295
+ </UButton>
296
+ </li>
297
+ </ul>
298
+
299
+ <USeparator class="my-6" />
300
+ <SectionLabel as="h2" class="mb-3">{{ t('setup.also.title') }}</SectionLabel>
301
+ <ul class="space-y-2 text-sm">
302
+ <li v-for="row in also" :key="row.id" class="flex flex-wrap items-center gap-2">
303
+ <span class="text-muted">{{ t(row.labelKey) }}</span>
304
+ <UBadge v-for="c in row.counts" :key="c.key" size="sm" color="neutral" variant="subtle">
305
+ {{ t(c.key, { count: c.n }) }}
306
+ </UBadge>
307
+ <UButton
308
+ class="ms-auto"
309
+ size="xs"
310
+ color="neutral"
311
+ variant="outline"
312
+ @click="row.open()"
313
+ >
314
+ {{ t('setup.action.review') }}
315
+ </UButton>
316
+ </li>
317
+ <li class="flex flex-wrap items-center gap-2 text-muted">
318
+ <span class="flex-1">{{ t('setup.also.settings') }}</span>
319
+ <UButton
320
+ size="xs"
321
+ color="neutral"
322
+ variant="ghost"
323
+ @click="go(() => ui.openWorkspaceSettings())"
324
+ >
325
+ {{ t('setup.also.workspaceSettings') }}
326
+ </UButton>
327
+ <UButton
328
+ size="xs"
329
+ color="neutral"
330
+ variant="ghost"
331
+ @click="go(() => ui.openIntegrations())"
332
+ >
333
+ {{ t('setup.also.integrations') }}
334
+ </UButton>
335
+ </li>
336
+ </ul>
337
+ </div>
338
+ </template>
339
+ </UModal>
340
+ </template>
@@ -55,6 +55,7 @@ export function useNavContributions() {
55
55
  foundationalServices: () => ui.openFoundationalServices(),
56
56
  mergeThresholds: () => ui.openWorkspaceSettings('merge'),
57
57
  workspaceSettings: () => ui.openWorkspaceSettings(),
58
+ setupChecklist: () => ui.openSetupChecklist(),
58
59
  modelConfiguration: () => ui.openModelConfig(),
59
60
  serviceFragmentDefaults: () => ui.openWorkspaceSettings('fragments'),
60
61
  localModels: () => ui.openLocalModels(),
@@ -0,0 +1,122 @@
1
+ import { computed, ref } from 'vue'
2
+ import { githubPatCheckNeedsAttention } from '@cat-factory/contracts'
3
+ import { deriveSetupChecklist, type SetupRow } from '~/utils/setupChecklist'
4
+
5
+ /**
6
+ * The store-facing half of `utils/setupChecklist.ts`: resolve every probe the checklist reads,
7
+ * and the existing surface each row's action opens.
8
+ *
9
+ * Every action goes to the surface the matching banner or dialog opens for the same state, so
10
+ * the checklist is a new index over the setup the product has, never a second copy of it.
11
+ */
12
+ export function useSetupChecklist() {
13
+ const ui = useUiStore()
14
+ const github = useGitHubStore()
15
+ const models = useModelsStore()
16
+ const workspace = useWorkspaceStore()
17
+ const providerConnections = useProviderConnectionsStore()
18
+ const ai = useAiReadiness()
19
+ const { actor } = useSetupActor()
20
+ // Where each gap is fixed: the map the setup banners open through too.
21
+ const remedies = useSetupRemedies()
22
+ /** Local mode with no token: the row says where the new one goes, as the banner does. */
23
+ const patMissing = computed(() => !!remedies.patSetupUrl.value)
24
+
25
+ // The catalog is loaded on board open, and a failed load is dropped there on purpose (it stays
26
+ // retryable, see `models.prefetchForBoard`). The checklist is where that failure is stated, so
27
+ // it asks for the catalog itself and keeps the outcome.
28
+ const modelLoadFailed = ref(false)
29
+ async function loadModels() {
30
+ const id = workspace.workspaceId
31
+ if (!id) return
32
+ modelLoadFailed.value = false
33
+ try {
34
+ await models.ensureLoaded(id)
35
+ } catch {
36
+ modelLoadFailed.value = true
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Ask every probe the checklist reads, so a row does not wait on a banner that happens to be
42
+ * mounted. Each is single-flighted or load-once in its store, so this costs nothing when the
43
+ * board already asked.
44
+ */
45
+ function refresh() {
46
+ void loadModels()
47
+ void providerConnections.ensureLoaded()
48
+ void github.ensureProbed()
49
+ }
50
+
51
+ const patCheck = computed(() => github.patCheck)
52
+ const patProblem = computed<'rejected' | 'underscoped' | null>(() => {
53
+ const check = patCheck.value
54
+ if (!check || !githubPatCheckNeedsAttention(check)) return null
55
+ return check.state === 'token_rejected' ? 'rejected' : 'underscoped'
56
+ })
57
+
58
+ const checklist = computed(() =>
59
+ deriveSetupChecklist({
60
+ model: {
61
+ loaded: ai.ready.value,
62
+ loadFailed: modelLoadFailed.value,
63
+ usable: ai.hasUsableModel.value,
64
+ presetBroken: ai.defaultPresetBroken.value,
65
+ },
66
+ sourceControl: {
67
+ available: github.available,
68
+ unavailableCause: github.unavailableCause,
69
+ connected: github.connected,
70
+ patMissing: patMissing.value,
71
+ patProblem: patProblem.value,
72
+ },
73
+ infra: workspace.infraSetup ?? null,
74
+ workspaceLoaded: workspace.ready,
75
+ infraOwners: workspace.infraSetupOwners ?? null,
76
+ providers: {
77
+ loaded: providerConnections.loaded,
78
+ needingConfig: providerConnections.needingConfig,
79
+ },
80
+ actor: actor.value,
81
+ }),
82
+ )
83
+
84
+ /**
85
+ * Where a row's action goes, by row AND state, through the remedies the matching banner uses.
86
+ * Null where there is nothing to open: the deployment does not use the area, or does not report
87
+ * it.
88
+ */
89
+ function actionFor(row: SetupRow): (() => void) | null {
90
+ if (row.status === 'not_needed' || row.status === 'checking') return null
91
+ if (row.cause === 'notReported') return null
92
+ switch (row.id) {
93
+ case 'model':
94
+ if (row.cause === 'probeFailed') return () => void loadModels()
95
+ if (row.status === 'missing') return remedies.openModelSetup
96
+ if (row.cause === 'presetBroken') return remedies.openPresetMismatch
97
+ return () => ui.openModelProviders()
98
+ case 'sourceControl': {
99
+ const setupUrl = remedies.patSetupUrl.value
100
+ if (setupUrl) return () => window.open(setupUrl, '_blank', 'noopener')
101
+ if (row.cause === 'rejected' || row.cause === 'underscoped') {
102
+ const remint = remedies.patRemintUrl.value
103
+ return () => window.open(remint, '_blank', 'noopener')
104
+ }
105
+ if (row.cause === 'probeFailed') return () => void github.probe()
106
+ return () => ui.openGitHub()
107
+ }
108
+ case 'agentExecutor':
109
+ case 'ephemeralEnvironments':
110
+ case 'binaryStorage': {
111
+ const area = row.id
112
+ return () => remedies.openInfraArea(area)
113
+ }
114
+ default: {
115
+ const unreachable: never = row.id
116
+ return unreachable
117
+ }
118
+ }
119
+ }
120
+
121
+ return { checklist, actionFor, refresh, patSource: remedies.patSource, patMissing }
122
+ }