@cat-factory/app 0.311.3 → 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.
Files changed (46) hide show
  1. package/README.md +32 -0
  2. package/app/components/board/nodes/TaskCard.vue +45 -212
  3. package/app/components/layout/AiProvidersBanner.vue +3 -2
  4. package/app/components/layout/BoardToolbar.vue +24 -26
  5. package/app/components/layout/BoardTopOverlays.vue +8 -2
  6. package/app/components/layout/GitHubPatBanner.vue +2 -2
  7. package/app/components/layout/GitHubPatPermissionsBanner.vue +4 -12
  8. package/app/components/layout/InfraSetupBanner.vue +6 -7
  9. package/app/components/layout/SideBar.vue +5 -0
  10. package/app/components/layout/WorkViewSwitcher.vue +64 -0
  11. package/app/components/queue/QueueCard.vue +269 -0
  12. package/app/components/queue/QueueView.vue +192 -0
  13. package/app/components/setup/SetupChecklistPanel.vue +340 -0
  14. package/app/composables/useFrameLanes.ts +6 -82
  15. package/app/composables/useNavContributions.ts +3 -0
  16. package/app/composables/useSetupChecklist.ts +122 -0
  17. package/app/composables/useSetupRemedies.ts +72 -0
  18. package/app/composables/useTaskActions.ts +254 -0
  19. package/app/composables/useTaskLaneClassifier.ts +88 -0
  20. package/app/modular/nav-contributions.spec.ts +6 -0
  21. package/app/modular/nav-contributions.ts +56 -0
  22. package/app/modular/tutorial-tours.ts +10 -0
  23. package/app/pages/index.vue +43 -6
  24. package/app/stores/github/context.ts +1 -0
  25. package/app/stores/github/probe.ts +7 -3
  26. package/app/stores/github.ts +9 -0
  27. package/app/stores/ui/modals.ts +14 -0
  28. package/app/stores/workView.ts +51 -0
  29. package/app/stores/workspaceQueue.ts +148 -0
  30. package/app/utils/queueSections.spec.ts +111 -0
  31. package/app/utils/queueSections.ts +117 -0
  32. package/app/utils/setupChecklist.spec.ts +184 -0
  33. package/app/utils/setupChecklist.ts +229 -0
  34. package/app/utils/workView.spec.ts +16 -0
  35. package/app/utils/workView.ts +25 -0
  36. package/i18n/locales/de.json +132 -3
  37. package/i18n/locales/en.json +132 -3
  38. package/i18n/locales/es.json +132 -3
  39. package/i18n/locales/fr.json +132 -3
  40. package/i18n/locales/he.json +132 -3
  41. package/i18n/locales/it.json +132 -3
  42. package/i18n/locales/ja.json +132 -3
  43. package/i18n/locales/pl.json +132 -3
  44. package/i18n/locales/tr.json +132 -3
  45. package/i18n/locales/uk.json +132 -3
  46. package/package.json +1 -1
package/README.md CHANGED
@@ -17,6 +17,7 @@ The SPA source lives under `app/` (the Nuxt srcDir).
17
17
  - [Layout](#layout)
18
18
  - [Nuxt UI: agent tooling and overrides](#nuxt-ui-agent-tooling-and-where-the-spa-overrides-the-skill)
19
19
  - [Task swimlanes](#task-swimlanes)
20
+ - [Queue view](#queue-view)
20
21
  - [Roles (engineer / product manager / designer)](#roles-engineer--product-manager--designer)
21
22
  - [Interface modes (basic / advanced)](#interface-modes-basic--advanced)
22
23
  - [Agent tiers (basic / intermediate / advanced)](#agent-tiers-basic--intermediate--advanced)
@@ -624,6 +625,32 @@ does not visibly jump when the response lands. "No module" is the EMPTY STRING o
624
625
  every other clearable field spells a clear; `undefined` is dropped by `JSON.stringify` and reaches
625
626
  the server as an empty patch.
626
627
 
628
+ ## Queue view
629
+
630
+ A board has two views (issue [#2258](https://github.com/kibertoad/cat-factory/issues/2258)): the
631
+ **queue**, which lists every task across every service in the column that says what it needs, and
632
+ the **board**, the canvas where the work is planned. They are two views of one board, not two
633
+ products. The sidebar's Views switcher (`components/layout/WorkViewSwitcher.vue`) moves between
634
+ them, and so do the palette entries and a `?view=queue` / `?view=board` link. The initiative it
635
+ starts, with the slices after it: [`delivery-surface.md`](https://github.com/kibertoad/cat-factory/blob/main/docs/initiatives/delivery-surface.md).
636
+
637
+ - **Which view opens is the person's own choice**: the view they used last in this browser
638
+ (`stores/workView.ts`, a cookie persisted like the interface tier, so two accounts on one browser
639
+ share it). A person who has not chosen
640
+ lands on the queue, because it answers the first question a person opens the app with. Both
641
+ views exist in every tier and every role: neither is an override of the other.
642
+ - **The queue states the canvas's verdict, regrouped.** `useTaskLaneClassifier` is the one
643
+ classifier the frame swimlanes and the queue share; `utils/queueSections.ts` only regroups its
644
+ output (a waiting pull request gets its own column), and no task crosses a lane. The
645
+ workspace-wide walk lives on one store (`stores/workspaceQueue.ts`), per the swimlane rule on
646
+ workspace-wide derivations.
647
+ - **A card's actions are the board card's** (`useTaskActions`), so Start, Resolve and Merge
648
+ cannot drift between the two views.
649
+ - **The queue sits over the canvas**, which stays mounted, so switching back keeps its camera and
650
+ selection. The advisory banners render in the page flow above the queue's columns
651
+ (`BoardTopOverlays` with `in-flow`), and the canvas toolbar does not, being the canvas's own
652
+ chrome. The canvas's decision counter opens the queue view.
653
+
627
654
  ## Roles (engineer / product manager / designer)
628
655
 
629
656
  The outermost of the three narrowing axes, and the only one the app **asks about**: on a first-ever
@@ -1465,6 +1492,11 @@ example ships in [`deploy/frontend`](https://github.com/kibertoad/cat-factory/tr
1465
1492
  switchers, the `SpendWarningBanner`, and the toolbar (zoom, LOD, decision queue).
1466
1493
  - **Palettes** (`components/palettes`): drag blocks, pipelines and agents onto
1467
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.
1468
1500
  - **Inspector** (`components/panels` + `panels/inspector`): per-block tabs:
1469
1501
  structure, dependencies, model + fragment picker, live execution, and linked
1470
1502
  docs/issues/scenarios. Decisions resolve via `DecisionModal`. A `review` task
@@ -1,8 +1,6 @@
1
1
  <script setup lang="ts">
2
- import { defaultBuildPipelineId } from '@cat-factory/contracts'
3
2
  import type { Block } from '~/types/domain'
4
3
  import { STATUS_META, MODULE_META, taskTypeMeta } from '~/utils/catalog'
5
- import { composeRunOutcome, hasOutcomeToShow } from '~/utils/runOutcome'
6
4
  import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
7
5
  import TaskPipelineMini from './TaskPipelineMini.vue'
8
6
  import IconButton from '~/components/common/IconButton.vue'
@@ -10,16 +8,28 @@ import IconButton from '~/components/common/IconButton.vue'
10
8
  const props = defineProps<{ taskId: string }>()
11
9
 
12
10
  const board = useBoardStore()
13
- const execution = useExecutionStore()
14
- const pipelines = usePipelinesStore()
15
11
  const ui = useUiStore()
16
12
  const agentRuns = useAgentRunsStore()
17
- const reviews = useReviewStage()
18
- const toast = useToast()
19
13
  const { t } = useI18n()
20
- const { confirm } = useConfirm()
21
14
 
22
- const task = computed<Block | undefined>(() => board.getBlock(props.taskId))
15
+ // What the card offers a human to do, shared with the queue's card (see `useTaskActions`).
16
+ const {
17
+ task,
18
+ sandboxed,
19
+ unmet,
20
+ runnable,
21
+ canExecute,
22
+ executeDeniedTitle,
23
+ defaultPipeline,
24
+ outcomeReadable,
25
+ openOutcome,
26
+ starting,
27
+ start: run,
28
+ merge,
29
+ reviewStage,
30
+ reviewStageLabel,
31
+ attention,
32
+ } = useTaskActions(() => props.taskId)
23
33
  const statusMeta = computed(() => (task.value ? STATUS_META[task.value.status] : null))
24
34
 
25
35
  // A type badge for any NON-default task type — built-in (bug/document/spike/review/ralph) or a
@@ -39,13 +49,6 @@ const typeBadge = computed(() => {
39
49
  })
40
50
  const selected = computed(() => ui.selectedBlockId === props.taskId)
41
51
 
42
- // This card's Start is a ONE-TAP live start, so it has nothing to ask for and offers no dry-run
43
- // request. What it does owe the reader is the half that is not a choice: a preset that sandboxes
44
- // their role means this button opens a pull request and merges nothing, and a card that said so
45
- // only after the fact would leave that to be discovered from a run that stops at the merge.
46
- const { forcedFor } = useDryRunPolicy()
47
- const sandboxed = computed(() => forcedFor(props.taskId))
48
-
49
52
  // Drag-to-connect: dragging from this card's handle onto another task makes THAT task
50
53
  // depend on this one (this is the prerequisite). The composable tracks the gesture.
51
54
  const { start: startConnect } = useDependencyConnect()
@@ -56,84 +59,28 @@ const deps = computed(() =>
56
59
  )
57
60
  const uiMode = useUiModeStore()
58
61
 
59
- /** Deps that haven't merged yet — these block this task from running. */
60
- const unmet = computed(() => board.unmetDeps(props.taskId))
61
- const runnable = computed(() => board.isRunnable(props.taskId))
62
-
63
62
  /** Label a dependency, noting its frame when it lives in another one. */
64
63
  const { depLabel: labelDep } = useDepLabels()
65
64
  const depLabel = (dep: Block) => labelDep(dep, task.value?.parentId)
66
65
 
67
- /**
68
- * The pipeline a plain "Start" will use: the task's pinned pipeline, else the build rung this
69
- * INTERFACE MODE defaults to (`defaultBuildPipelineId` — the fixed Standard build in basic mode,
70
- * the Adaptive one in advanced). The workspace's positional first pipeline remains the last
71
- * resort, for a board whose catalog does not carry the rung (an older seed, or a deployment that
72
- * retired it).
73
- *
74
- * A PIN is honoured even when the library holds no row for it, and that branch is the whole reason
75
- * this returns a descriptor rather than a `Pipeline`. An INTERNAL pipeline is withheld from the
76
- * library on purpose (the platform starts it on its own behalf, so no picker may offer it), and a
77
- * task can legitimately be pinned to one — the docs-refresh preset spawns its tasks onto
78
- * `pl_code_comments`. Resolving that pin through the library alone answers undefined, and the
79
- * fallback below then starts a FULL BUILD on a comment-only task while the button still reads as
80
- * an ordinary Start. The fallback chain exists for a task with NO pin; a pin the library cannot
81
- * show is still the task's answer, and the backend resolves the id for the run.
82
- */
83
- const defaultPipeline = computed<{ id: string; name: string } | undefined>(() => {
84
- const pinnedId = task.value?.pipelineId
85
- if (pinnedId) {
86
- return (
87
- pipelines.getPipeline(pinnedId) ?? {
88
- id: pinnedId,
89
- // The catalog NAME map spans the whole catalog (unlike the versions map), so an internal
90
- // pin still names itself here; the generic label covers a pin to something this build's
91
- // catalog does not know at all.
92
- name: pipelines.catalogNames[pinnedId] ?? t('board.task.pipelineFallback'),
93
- }
94
- )
95
- }
96
- // No pin: the workspace's own DECLARED in-app default outranks the interface-mode rung, because
97
- // an operator who named one said something a tier cannot overrule.
98
- const declared = pipelines.declaredDefaultId('interactive')
99
- return (
100
- pipelines.getPipeline(declared ?? defaultBuildPipelineId(uiMode.isAdvanced)) ??
101
- pipelines.pipelines[0]
102
- )
103
- })
104
-
105
66
  /** The PR the implementer agent opened for this task, if any. */
106
67
  const pr = computed(() => task.value?.pullRequest)
107
68
  const prLabel = computed(() =>
108
69
  pr.value?.number ? t('board.task.prNumber', { number: pr.value.number }) : t('board.task.pr'),
109
70
  )
110
71
 
111
- /**
112
- * Reading the result starts at the OUTCOME summary (what changed in product terms, with the
113
- * captured evidence), and the pull request is one click inside it. In BASIC mode that replaces
114
- * the card's raw PR chip: the diff is still exactly as reachable, through a surface that says
115
- * what the diff is about first. Advanced mode keeps both, since a reader who wants the diff
116
- * directly is the reader that tier is for.
117
- *
118
- * Offered only where there is something to read, asked of the SAME reduction the window renders
119
- * and the inspector's button gates on: a card that offered "read the result" on a task whose
120
- * every section says "nothing here" would teach people the surface is empty. A task marked done
121
- * by hand, with no pull request and no run, is that task.
122
- */
123
72
  const laneView = useLaneViewStore()
124
- const outcomeReadable = computed(() => {
125
- const block = task.value
126
- if (!block) return false
127
- return hasOutcomeToShow(
128
- composeRunOutcome({ block, instance: execution.getInstance(block.executionId) ?? null }),
129
- )
130
- })
131
73
  /**
132
- * The card's raw pull-request chip, which basic mode drops in favour of the outcome card
133
- * carrying the same link at the top. Written as the INVARIANT ("the diff never stops being
134
- * reachable from this card") rather than as `isAdvanced` alone, so the tier can only ever
135
- * reorder two routes and never remove the last one: where the outcome card is not offered,
136
- * the chip stays in both tiers.
74
+ * The card's raw pull-request chip. Reading the result starts at the OUTCOME summary (what changed
75
+ * in product terms, with the captured evidence), and the pull request is one click inside it, so
76
+ * BASIC mode drops the chip in favour of the outcome card carrying the same link at the top: the
77
+ * diff is still exactly as reachable, through a surface that says what the diff is about first.
78
+ * Advanced mode keeps both, since a reader who wants the diff directly is the reader that tier is
79
+ * for.
80
+ *
81
+ * Written as the INVARIANT ("the diff never stops being reachable from this card") rather than as
82
+ * `isAdvanced` alone, so the tier can only ever reorder two routes and never remove the last one:
83
+ * where the outcome card is not offered, the chip stays in both tiers.
137
84
  */
138
85
  const showPrChip = computed(
139
86
  () => Boolean(pr.value) && (uiMode.isAdvanced || !outcomeReadable.value),
@@ -149,10 +96,6 @@ const showPrChip = computed(
149
96
  const showModuleChip = computed(
150
97
  () => Boolean(task.value?.moduleName) && laneView.groupKey !== 'module',
151
98
  )
152
- function openOutcome() {
153
- ui.openOutcome(props.taskId, task.value?.executionId ?? null)
154
- }
155
-
156
99
  // This task's current agent run (if any). A failed run must surface the shared
157
100
  // failure banner + retry — NOT a stuck progress bar — so the card never looks
158
101
  // like it's still working after the run has terminated.
@@ -164,129 +107,6 @@ const runFailed = computed(() => agentRun.value?.status === 'failed')
164
107
  const recurring = useRecurringPipelinesStore()
165
108
  const schedule = computed(() => recurring.byBlock(props.taskId))
166
109
 
167
- // Optimistic "Start": flip the button into a spinning "Starting…" state the
168
- // instant it's clicked, before the server confirms. The button naturally
169
- // unmounts once the stream pushes the block into `in_progress`; if the start
170
- // call faults we revert and surface a toast.
171
- const starting = ref(false)
172
-
173
- async function run() {
174
- if (!runnable.value) {
175
- toast.add({
176
- title: t('board.task.blockedByDependenciesTitle'),
177
- description: t('board.task.waitingOn', { deps: unmet.value.map((d) => d.title).join(', ') }),
178
- icon: 'i-lucide-lock',
179
- })
180
- return
181
- }
182
- const pipeline = defaultPipeline.value
183
- if (!pipeline) {
184
- toast.add({
185
- title: t('board.task.noPipelineTitle'),
186
- description: t('board.task.noPipelineBody'),
187
- })
188
- return
189
- }
190
- starting.value = true
191
- // false ⇒ the run never started (the user cancelled the personal-password prompt, or
192
- // the start was refused — the store surfaces the actionable toast itself). Revert the
193
- // optimistic state; on success the button unmounts once the stream pushes in_progress.
194
- const started = await execution.start(props.taskId, pipeline)
195
- if (started) {
196
- // Confirm the (optimistic) start landed — the button unmounts once the stream pushes
197
- // in_progress, so without this the successful action gives no feedback. A sandboxed start
198
- // says so here rather than borrowing the live wording: this is the moment the reader learns
199
- // what they just started, and the two runs differ in what they will end up doing.
200
- toast.add({
201
- title: sandboxed.value ? t('board.dryRunToast.title') : t('board.task.startedToast.title'),
202
- description: sandboxed.value
203
- ? t('board.dryRunToast.body', { name: pipeline.name })
204
- : t('board.task.startedToast.body', { name: pipeline.name }),
205
- color: sandboxed.value ? 'warning' : 'success',
206
- icon: sandboxed.value ? 'i-lucide-shield' : 'i-lucide-play',
207
- })
208
- } else {
209
- starting.value = false
210
- }
211
- }
212
-
213
- function review() {
214
- ui.select(props.taskId)
215
- ui.focus(props.taskId)
216
- }
217
-
218
- async function merge() {
219
- // Merging a PR into its base is consequential and effectively irreversible — gate it behind a
220
- // confirm (plain, not the destructive shape). `execution.mergePr` surfaces its own error toast.
221
- const ok = await confirm({
222
- title: t('board.task.mergeConfirm.title'),
223
- description: t('board.task.mergeConfirm.body'),
224
- confirmLabel: t('board.task.mergeConfirm.confirm'),
225
- icon: 'i-lucide-git-merge',
226
- })
227
- if (!ok) return
228
- await execution.mergePr(props.taskId)
229
- }
230
-
231
- // A `blocked` task is waiting on a human for one of two reasons — an agent-raised
232
- // decision OR an approval gate — and both must surface here (a failed run is shown
233
- // separately by the AgentFailureCard above). The board previously only handled
234
- // decisions, so an approval-gated task was a dead end: it read "Decision needed"
235
- // (the old generic `blocked` label) with no badge and a click that did nothing.
236
- // Read off the per-block index rather than scanning the workspace-wide list: this computed is
237
- // mounted once per card and invalidated by every execution event, so a `find` over `openDecisions`
238
- // cost O(cards x open gates) per event. `decisionsByBlock` is the index built for exactly this.
239
- const pendingDecision = computed(() => execution.decisionsByBlock.get(props.taskId)?.[0])
240
- // The async stage an iterative reviewer gate (requirements-review / clarity-review) is
241
- // mid-cycle in (folding the answers, then re-reviewing), or null. While set, the gate
242
- // needs NO human action, so its approval is suppressed below and a working indicator
243
- // shows instead.
244
- const reviewStage = computed(() => reviews.stageForBlock(props.taskId))
245
- const reviewStageLabel = computed(() =>
246
- reviewStage.value === 'incorporating'
247
- ? t('board.task.incorporatingAnswers')
248
- : reviewStage.value === 'reviewing'
249
- ? t('board.task.reReviewing')
250
- : reviewStage.value === 'recommending'
251
- ? t('board.task.recommending')
252
- : null,
253
- )
254
- const pendingApproval = computed(() => {
255
- const a = execution.approvalsByBlock.get(props.taskId)?.[0]
256
- // A reviewer gate whose review is incorporating / re-reviewing in the driver is doing
257
- // background work, not awaiting a human — don't surface it as "Approval needed".
258
- if (a && reviews.isBackground(a.agentKind, props.taskId)) return undefined
259
- return a
260
- })
261
-
262
- /** What this blocked task actually needs from a human — drives the card's label,
263
- * pulse and action. Decision takes precedence over approval (a step never holds
264
- * both at once; this is just a stable order). Null when nothing is pending. */
265
- const attention = computed<{
266
- label: string
267
- icon: string
268
- action: string
269
- open: () => void
270
- } | null>(() => {
271
- const d = pendingDecision.value
272
- if (d)
273
- return {
274
- label: t('board.task.decisionNeeded'),
275
- icon: 'i-lucide-circle-help',
276
- action: t('board.task.resolve'),
277
- open: () => ui.openDecision(d.instanceId, d.decision.id),
278
- }
279
- const a = pendingApproval.value
280
- if (a)
281
- return {
282
- label: t('board.task.approvalNeeded'),
283
- icon: 'i-lucide-shield-check',
284
- action: t('board.task.approve'),
285
- open: () => ui.openApprovalDetail(a.instanceId, a.approval.id),
286
- }
287
- return null
288
- })
289
-
290
110
  /** Specific header copy: a failed run reads "Failed", a parked task reads its
291
111
  * decision/approval reason, otherwise the generic status label. */
292
112
  const statusText = computed(() =>
@@ -295,6 +115,11 @@ const statusText = computed(() =>
295
115
  : (reviewStageLabel.value ?? attention.value?.label ?? statusMeta.value?.label ?? ''),
296
116
  )
297
117
 
118
+ function review() {
119
+ ui.select(props.taskId)
120
+ ui.focus(props.taskId)
121
+ }
122
+
298
123
  // Clicking the card body only selects the task (opening the inspector so the human can
299
124
  // interact with it). Whatever the task is parked on — a decision, an approval, or the
300
125
  // requirements review — is opened explicitly via the action button below, never by a
@@ -461,10 +286,11 @@ function selectTask() {
461
286
  size="xs"
462
287
  :icon="!runnable ? 'i-lucide-lock' : sandboxed ? 'i-lucide-shield' : 'i-lucide-play'"
463
288
  :loading="starting"
464
- :disabled="!runnable || starting"
289
+ :disabled="!runnable || starting || !canExecute"
465
290
  data-testid="task-start"
466
291
  :title="
467
- !runnable
292
+ executeDeniedTitle ??
293
+ (!runnable
468
294
  ? t('board.task.waitingOn', { deps: unmet.map((d) => d.title).join(', ') })
469
295
  : sandboxed
470
296
  ? t('board.task.startPipelineDryRun', {
@@ -472,7 +298,7 @@ function selectTask() {
472
298
  })
473
299
  : t('board.task.startPipeline', {
474
300
  name: defaultPipeline?.name ?? t('board.task.pipelineFallback'),
475
- })
301
+ }))
476
302
  "
477
303
  @click.stop="run"
478
304
  >
@@ -529,7 +355,14 @@ function selectTask() {
529
355
  >
530
356
  {{ t('board.task.review') }}
531
357
  </UButton>
532
- <UButton color="success" size="xs" icon="i-lucide-git-merge" @click.stop="merge">
358
+ <UButton
359
+ color="success"
360
+ size="xs"
361
+ icon="i-lucide-git-merge"
362
+ :disabled="!canExecute"
363
+ :title="executeDeniedTitle"
364
+ @click.stop="merge"
365
+ >
533
366
  {{ t('board.task.merge') }}
534
367
  </UButton>
535
368
  </template>
@@ -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>
@@ -12,6 +12,8 @@ const board = useBoardStore()
12
12
  // the sidebar/command entries with zero edits here.
13
13
  const { toolbarItems, invoke: invokeNav } = useNavContributions()
14
14
  const execution = useExecutionStore()
15
+ const views = useWorkViewStore()
16
+ const queue = useWorkspaceQueueStore()
15
17
  const workspace = useWorkspaceStore()
16
18
  const workspaceSettings = useWorkspaceSettingsStore()
17
19
  const services = useServicesStore()
@@ -104,19 +106,13 @@ const spendLabel = computed(() => {
104
106
  }
105
107
  return `${fmt(s.costSpent)} / ${fmt(s.costLimit)}`
106
108
  })
107
- const spendColor = computed(() => (spend.value?.exceeded ? 'error' : 'neutral'))
109
+ /** The decisions this counts are in every service, so the queue opens on all of them. */
110
+ function openQueue() {
111
+ queue.clearFilter()
112
+ views.show('queue')
113
+ }
108
114
 
109
- const decisionItems = computed(() =>
110
- execution.openDecisions.map((d) => {
111
- const b = board.getBlock(d.blockId)
112
- return {
113
- label: b?.title ?? t('common.block'),
114
- description: d.decision.question,
115
- icon: 'i-lucide-circle-help',
116
- onSelect: () => ui.openDecision(d.instanceId, d.decision.id),
117
- }
118
- }),
119
- )
115
+ const spendColor = computed(() => (spend.value?.exceeded ? 'error' : 'neutral'))
120
116
  </script>
121
117
 
122
118
  <template>
@@ -191,21 +187,23 @@ const decisionItems = computed(() =>
191
187
 
192
188
  <USeparator orientation="vertical" class="mx-1 h-6" />
193
189
 
194
- <!-- decisions queue -->
195
- <UDropdownMenu v-if="execution.pendingDecisionCount" :items="decisionItems">
196
- <UButton
197
- color="warning"
198
- variant="soft"
199
- size="sm"
200
- icon="i-lucide-circle-help"
201
- data-testid="decision-badge"
190
+ <!-- The decision count. It opens the queue view, which lists every decision with its Answer
191
+ button beside the rest of what needs a person (issue #2258). -->
192
+ <UButton
193
+ v-if="execution.pendingDecisionCount"
194
+ color="warning"
195
+ variant="soft"
196
+ size="sm"
197
+ icon="i-lucide-circle-help"
198
+ :title="t('views.openQueue')"
199
+ data-testid="decision-badge"
200
+ @click="openQueue"
201
+ >
202
+ {{ execution.pendingDecisionCount
203
+ }}<span class="hidden sm:inline"
204
+ >&nbsp;{{ t('board.toolbar.decisionWord', execution.pendingDecisionCount) }}</span
202
205
  >
203
- {{ execution.pendingDecisionCount
204
- }}<span class="hidden sm:inline"
205
- >&nbsp;{{ t('board.toolbar.decisionWord', execution.pendingDecisionCount) }}</span
206
- >
207
- </UButton>
208
- </UDropdownMenu>
206
+ </UButton>
209
207
 
210
208
  <!-- how every frame's swimlanes are ordered + grouped (an override; advanced tier) -->
211
209
  <LaneViewControl />
@@ -33,10 +33,15 @@ import DefaultTestEnvBanner from '~/components/layout/DefaultTestEnvBanner.vue'
33
33
 
34
34
  // The live-stream state the connection strip renders. Passed down rather than resolved here:
35
35
  // the page owns the single stream instance, and creating another would open a second socket.
36
+ //
37
+ // `inFlow` is the queue view's placement: the same region, laid out in the page flow above the
38
+ // queue's columns instead of floating over a canvas, and without the toolbar, which is the
39
+ // canvas's own chrome (zoom, fit, lane view).
36
40
  defineProps<{
37
41
  connected: boolean
38
42
  everConnected: boolean
39
43
  connectionFailed: boolean
44
+ inFlow?: boolean
40
45
  }>()
41
46
 
42
47
  const ui = useUiStore()
@@ -44,7 +49,8 @@ const ui = useUiStore()
44
49
 
45
50
  <template>
46
51
  <div
47
- class="pointer-events-none absolute inset-x-0 top-0 z-40 flex flex-col items-center gap-2 px-4 pt-3"
52
+ class="pointer-events-none z-40 flex flex-col items-center gap-2 px-4"
53
+ :class="inFlow ? 'relative pt-3' : 'absolute inset-x-0 top-0 pt-3'"
48
54
  >
49
55
  <!-- Compact-viewport nav trigger: the SideBar is an off-canvas drawer below lg, so surface
50
56
  a hamburger to open it. Out of the column's flow (it is a corner control, not a
@@ -62,7 +68,7 @@ const ui = useUiStore()
62
68
 
63
69
  <!-- FIRST, and deliberately so: the toolbar is standing board chrome, and a tour step
64
70
  anchors it. Below the advisories it would move every time one appears or clears. -->
65
- <BoardToolbar />
71
+ <BoardToolbar v-if="!inFlow" />
66
72
 
67
73
  <!-- Then the advisories, most urgent first. Ordering is by what the user loses by not
68
74
  reading it now, not by how loud the card is.
@@ -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,7 @@ import UserMenu from '~/components/auth/UserMenu.vue'
21
21
  import { useViewport } from '~/composables/useViewport'
22
22
  import type { NavContribution } from '~/modular/nav-contributions'
23
23
  import SectionLabel from '~/components/common/SectionLabel.vue'
24
+ import WorkViewSwitcher from '~/components/layout/WorkViewSwitcher.vue'
24
25
 
25
26
  const { t } = useI18n()
26
27
 
@@ -227,6 +228,10 @@ watch(
227
228
  </div>
228
229
 
229
230
  <div class="contents" @click="onNavAction">
231
+ <!-- The two views of the board (issue #2258), first because they decide what the main
232
+ area shows. -->
233
+ <WorkViewSwitcher :collapsed="railed" />
234
+
230
235
  <!-- Command bar launcher (⌘K) — the primary way to create blocks / pipelines
231
236
  and reach every action below. -->
232
237
  <UButton