@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
@@ -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(),
@@ -72,6 +73,7 @@ export function useNavContributions() {
72
73
  // The QUESTION, not a toggle: with three roles there is no unambiguous "next one", and the
73
74
  // prompt is the one surface that states what each role gives you before you pick it.
74
75
  chooseRole: () => useUiRoleStore().openPrompt(),
76
+ describeWork: () => ui.openDescribeWork(),
75
77
  queueView: () => useWorkViewStore().show('queue'),
76
78
  boardView: () => useWorkViewStore().show('board'),
77
79
  }
@@ -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
+ }
@@ -0,0 +1,72 @@
1
+ import { computed } from 'vue'
2
+ import { githubPatCheckSource } from '@cat-factory/contracts'
3
+ import type { GitHubPatKind, InfraSetupArea } from '~/types/domain'
4
+ import { githubPatRemintUrl } from '~/utils/vcs'
5
+
6
+ /**
7
+ * Where each setup gap is fixed: the ONE map the setup banners and the setup checklist both open
8
+ * through, so "the row opens what its banner opens" holds because there is one place that says
9
+ * it, never two copies kept in step by hand.
10
+ *
11
+ * Keyed by `InfraSetupArea` for the infrastructure, the vocabulary the server reports in, plus the
12
+ * model and source-control remedies, which have no area of their own.
13
+ */
14
+ export function useSetupRemedies() {
15
+ const ui = useUiStore()
16
+ const auth = useAuthStore()
17
+ const github = useGitHubStore()
18
+
19
+ const INFRA: Record<InfraSetupArea, () => void> = {
20
+ agentExecutor: () => ui.openProviderConnection('runner-pool'),
21
+ ephemeralEnvironments: () => ui.openProviderConnection('environment'),
22
+ binaryStorage: () => ui.openContentStorageSettings(),
23
+ }
24
+
25
+ /** Open the screen that configures an infrastructure area. */
26
+ function openInfraArea(area: InfraSetupArea) {
27
+ INFRA[area]()
28
+ }
29
+
30
+ /** No usable model: the AI onboarding dialog. */
31
+ function openModelSetup() {
32
+ ui.openAiProviderSetup()
33
+ }
34
+
35
+ /** Usable models, but the default preset names one that is not: the preset-mismatch dialog. */
36
+ function openPresetMismatch() {
37
+ ui.openAiPresetMismatch()
38
+ }
39
+
40
+ /** Local mode with no token: GitHub's token page, scopes preselected. Null outside that case. */
41
+ const patSetupUrl = computed(() => auth.localMode?.githubPatSetupUrl ?? null)
42
+
43
+ /**
44
+ * The token page for a token GitHub rejected or under-scoped. A rejected token was never
45
+ * classified, so it falls back to `unknown`, which lands on the classic form, the only one
46
+ * GitHub lets us pre-fill.
47
+ */
48
+ const patReport = computed(() =>
49
+ github.patCheck?.state === 'checked' ? github.patCheck.report : undefined,
50
+ )
51
+ /** The token's kind, which picks the form. `unknown` for a token GitHub rejected outright. */
52
+ const patKind = computed<GitHubPatKind>(() => patReport.value?.kind ?? 'unknown')
53
+ const patRemintUrl = computed(() =>
54
+ githubPatRemintUrl(patKind.value, patReport.value?.webUrl ?? github.connection?.webUrl),
55
+ )
56
+
57
+ /**
58
+ * Whose token that is: the deployment's (replaced by whoever runs it) or the person's own
59
+ * (replaced in their settings). Read off the check, which carries it in every state it reports.
60
+ */
61
+ const patSource = computed(() => (github.patCheck ? githubPatCheckSource(github.patCheck) : null))
62
+
63
+ return {
64
+ openInfraArea,
65
+ openModelSetup,
66
+ openPresetMismatch,
67
+ patSetupUrl,
68
+ patKind,
69
+ patRemintUrl,
70
+ patSource,
71
+ }
72
+ }
@@ -43,6 +43,7 @@ export function useTaskActions(taskId: () => string) {
43
43
 
44
44
  const pipelineFor = useStartPipelineResolver()
45
45
  const defaultPipeline = computed(() => pipelineFor(task.value))
46
+ const { startTask } = useStartWithFeedback()
46
47
 
47
48
  /**
48
49
  * Whether the outcome summary has something to show, asked of the SAME reduction the window
@@ -77,35 +78,12 @@ export function useTaskActions(taskId: () => string) {
77
78
  })
78
79
  return
79
80
  }
80
- const pipeline = defaultPipeline.value
81
- if (!pipeline) {
82
- toast.error('board.task.noPipelineTitle', { description: t('board.task.noPipelineBody') })
83
- return
84
- }
81
+ const block = task.value
82
+ if (!block) return
85
83
  starting.value = true
86
- // false ⇒ the run never started (the user cancelled the personal-password prompt, or the
87
- // start was refused: the store surfaces the actionable toast itself). Revert the optimistic
88
- // state; on success the button unmounts once the stream pushes in_progress.
89
- const started = await execution.start(taskId(), pipeline)
90
- if (started) {
91
- // Confirm the (optimistic) start landed: the button unmounts once the stream pushes
92
- // in_progress, so without this the successful action gives no feedback. A sandboxed start
93
- // says so here rather than borrowing the live wording: this is the moment the reader learns
94
- // what they just started, and the two runs differ in what they will end up doing.
95
- // A sandboxed run is `warning`: it ran, and its outcome differs in a way the reader should
96
- // know (it opens a pull request and merges nothing), so it stays until closed.
97
- if (sandboxed.value) {
98
- toast.warning('board.dryRunToast.title', {
99
- description: t('board.dryRunToast.body', { name: pipeline.name }),
100
- })
101
- } else {
102
- toast.success('board.task.startedToast.title', {
103
- description: t('board.task.startedToast.body', { name: pipeline.name }),
104
- })
105
- }
106
- } else {
107
- starting.value = false
108
- }
84
+ // On success the button unmounts once the stream pushes in_progress; otherwise revert the
85
+ // optimistic state (`startTask` has said why).
86
+ if (!(await startTask(block))) starting.value = false
109
87
  }
110
88
 
111
89
  async function merge() {
@@ -199,6 +177,51 @@ export function useTaskActions(taskId: () => string) {
199
177
  }
200
178
  }
201
179
 
180
+ /**
181
+ * Start a task on the pipeline a plain "Start" uses, and tell the person what happened: the
182
+ * card's Start and the intake's "Add and start" both go through it, so the two say the same thing.
183
+ * Resolves false when nothing started (no pipeline to run, a cancelled credential prompt, or a
184
+ * refused start, which `execution.start` reports itself).
185
+ */
186
+ export function useStartWithFeedback() {
187
+ const execution = useExecutionStore()
188
+ const toast = useActionToast()
189
+ const { t } = useI18n()
190
+ const { forcedFor } = useDryRunPolicy()
191
+ const pipelineFor = useStartPipelineResolver()
192
+
193
+ async function startTask(block: Pick<Block, 'id' | 'pipelineId'>): Promise<boolean> {
194
+ const pipeline = pipelineFor(block)
195
+ if (!pipeline) {
196
+ // A refusal decided in the browser, in translated copy: the `error` tone.
197
+ toast.error('board.task.noPipelineTitle', { description: t('board.task.noPipelineBody') })
198
+ return false
199
+ }
200
+ // Read before the start: the sandbox policy is a fact about this task and role now.
201
+ const sandboxed = forcedFor(block.id)
202
+ const started = await execution.start(block.id, pipeline)
203
+ if (started) {
204
+ // Confirm the start landed: without this the action gives no feedback until the stream
205
+ // moves the card. A sandboxed start says so rather than borrowing the live wording: this is
206
+ // the moment the reader learns what they just started, and the two runs end differently.
207
+ // A sandboxed run is `warning`: it ran, and it ends differently (it opens a pull request and
208
+ // merges nothing), so the toast stays until closed.
209
+ if (sandboxed) {
210
+ toast.warning('board.dryRunToast.title', {
211
+ description: t('board.dryRunToast.body', { name: pipeline.name }),
212
+ })
213
+ } else {
214
+ toast.success('board.task.startedToast.title', {
215
+ description: t('board.task.startedToast.body', { name: pipeline.name }),
216
+ })
217
+ }
218
+ }
219
+ return started
220
+ }
221
+
222
+ return { startTask }
223
+ }
224
+
202
225
  /** The pipeline a plain "Start" runs, as the descriptor `execution.start` takes. */
203
226
  export interface StartPipeline {
204
227
  id: string
@@ -224,6 +224,7 @@ describe('navSlotFilter', () => {
224
224
  tutorial: 'the walkthroughs - the surface with the fewest destinations needs them most',
225
225
  'keyboard-shortcuts': 'the cheatsheet covers the board and the palette, which every role has',
226
226
  'ui-role': 'the way BACK out of the narrowed role',
227
+ 'describe-work': 'filing a task from one sentence is the job the intake role exists for',
227
228
  'queue-view': 'which view of the board a person works in is their own choice, in every role',
228
229
  'board-view': 'the way back to the canvas from the queue view, in every role',
229
230
  }
@@ -491,6 +492,7 @@ describe('nav grouping helpers', () => {
491
492
  expect(integrations?.items.map((i) => i.id)).toEqual(['integrations-hub'])
492
493
  const configuration = groups.find((g) => g.group === 'configuration')
493
494
  expect(configuration?.items.map((i) => i.id)).toEqual([
495
+ 'setup-checklist',
494
496
  'workspace-settings',
495
497
  'account-settings',
496
498
  'operator-dashboard',
@@ -524,6 +526,7 @@ describe('nav grouping helpers', () => {
524
526
  'ui-role',
525
527
  'tutorial',
526
528
  'foundational-services',
529
+ 'setup-checklist',
527
530
  'queue-view',
528
531
  'board-view',
529
532
  ])