@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
@@ -0,0 +1,254 @@
1
+ import { computed, ref } from 'vue'
2
+ import { defaultBuildPipelineId } from '@cat-factory/contracts'
3
+ import type { Block } from '~/types/domain'
4
+ import { composeRunOutcome, hasOutcomeToShow } from '~/utils/runOutcome'
5
+
6
+ /**
7
+ * What a task offers a human to DO, and the handlers that do it.
8
+ *
9
+ * Shared by the two surfaces that put a task's action within one click: the board's task card
10
+ * and the queue's card. Both must offer the same Start (same pipeline), the same Resolve (same
11
+ * gate) and the same Merge (same confirm), or a reader would learn two products.
12
+ */
13
+ export function useTaskActions(taskId: () => string) {
14
+ const board = useBoardStore()
15
+ const execution = useExecutionStore()
16
+ const ui = useUiStore()
17
+ const reviews = useReviewStage()
18
+ const toast = useActionToast()
19
+ const { t } = useI18n()
20
+ const { confirm } = useConfirm()
21
+ const access = useWorkspaceAccess()
22
+
23
+ const task = computed<Block | undefined>(() => board.getBlock(taskId()))
24
+
25
+ // This Start is a ONE-TAP live start, so it has nothing to ask for and offers no dry-run
26
+ // request. What it does owe the reader is the half that is not a choice: a preset that sandboxes
27
+ // their role means this button opens a pull request and merges nothing, and a card that said so
28
+ // only after the fact would leave that to be discovered from a run that stops at the merge.
29
+ const { forcedFor } = useDryRunPolicy()
30
+ const sandboxed = computed(() => forcedFor(taskId()))
31
+
32
+ /** Deps that haven't merged yet: these block this task from running. */
33
+ const unmet = computed(() => board.unmetDeps(taskId()))
34
+ const runnable = computed(() => board.isRunnable(taskId()))
35
+
36
+ // Start and Merge both need `runs.execute`. A read-only member sees them disabled with the reason,
37
+ // the way the shared failure banner treats Retry, rather than pressing a button the server
38
+ // refuses. Asked once here so the two cards cannot gate them differently.
39
+ const canExecute = computed(() => access.canExecuteRuns.value)
40
+ const executeDeniedTitle = computed(() =>
41
+ canExecute.value ? undefined : t('access.noRunExecute'),
42
+ )
43
+
44
+ const pipelineFor = useStartPipelineResolver()
45
+ const defaultPipeline = computed(() => pipelineFor(task.value))
46
+
47
+ /**
48
+ * Whether the outcome summary has something to show, asked of the SAME reduction the window
49
+ * renders and the inspector's button gates on: a surface that offered "read the result" on a
50
+ * task whose every section says "nothing here" would teach people the surface is empty. A task
51
+ * marked done by hand, with no pull request and no run, is that task.
52
+ */
53
+ const outcomeReadable = computed(() => {
54
+ const block = task.value
55
+ if (!block) return false
56
+ return hasOutcomeToShow(
57
+ composeRunOutcome({ block, instance: execution.getInstance(block.executionId) ?? null }),
58
+ )
59
+ })
60
+
61
+ function openOutcome() {
62
+ ui.openOutcome(taskId(), task.value?.executionId ?? null)
63
+ }
64
+
65
+ // Optimistic "Start": flip the button into a spinning "Starting…" state the instant it's
66
+ // clicked, before the server confirms. The button naturally unmounts once the stream pushes the
67
+ // block into `in_progress`; if the start call faults we revert and surface a toast.
68
+ const starting = ref(false)
69
+
70
+ async function start() {
71
+ if (!runnable.value) {
72
+ // A refusal decided in the browser, in translated copy: the `error` tone (`useActionToast`).
73
+ toast.error('board.task.blockedByDependenciesTitle', {
74
+ description: t('board.task.waitingOn', {
75
+ deps: unmet.value.map((d) => d.title).join(', '),
76
+ }),
77
+ })
78
+ return
79
+ }
80
+ const pipeline = defaultPipeline.value
81
+ if (!pipeline) {
82
+ toast.error('board.task.noPipelineTitle', { description: t('board.task.noPipelineBody') })
83
+ return
84
+ }
85
+ 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
+ }
109
+ }
110
+
111
+ async function merge() {
112
+ // Merging a PR into its base is consequential and effectively irreversible: gate it behind a
113
+ // confirm (plain, not the destructive shape). `execution.mergePr` surfaces its own error toast.
114
+ const ok = await confirm({
115
+ title: t('board.task.mergeConfirm.title'),
116
+ description: t('board.task.mergeConfirm.body'),
117
+ confirmLabel: t('board.task.mergeConfirm.confirm'),
118
+ icon: 'i-lucide-git-merge',
119
+ })
120
+ if (!ok) return
121
+ await execution.mergePr(taskId())
122
+ }
123
+
124
+ // A `blocked` task is waiting on a human for one of two reasons, an agent-raised decision OR an
125
+ // approval gate, and both must surface (a failed run is shown separately by the
126
+ // AgentFailureCard). Read off the per-block index rather than scanning the workspace-wide list:
127
+ // this computed is mounted once per card and invalidated by every execution event.
128
+ const pendingDecision = computed(() => execution.decisionsByBlock.get(taskId())?.[0])
129
+ // The async stage an iterative reviewer gate (requirements-review / clarity-review) is mid-cycle
130
+ // in (folding the answers, then re-reviewing), or null. While set, the gate needs NO human
131
+ // action, so its approval is suppressed below and a working indicator shows instead.
132
+ const reviewStage = computed(() => reviews.stageForBlock(taskId()))
133
+ const reviewStageLabel = computed(() =>
134
+ reviewStage.value === 'incorporating'
135
+ ? t('board.task.incorporatingAnswers')
136
+ : reviewStage.value === 'reviewing'
137
+ ? t('board.task.reReviewing')
138
+ : reviewStage.value === 'recommending'
139
+ ? t('board.task.recommending')
140
+ : null,
141
+ )
142
+ const pendingApproval = computed(() => {
143
+ // A reviewer gate whose review is incorporating / re-reviewing in the driver is doing
144
+ // background work, not awaiting a human: skip it, and offer the first gate a human IS
145
+ // awaiting. The lane classifier counts any such gate, so a card filed under Needs you for an
146
+ // approval always has that approval to open.
147
+ return execution.approvalsByBlock
148
+ .get(taskId())
149
+ ?.find((a) => !reviews.isBackground(a.agentKind, taskId()))
150
+ })
151
+
152
+ /**
153
+ * What this blocked task actually needs from a human: drives the card's label, pulse and
154
+ * action. Decision takes precedence over approval (a step never holds both at once; this is
155
+ * just a stable order). Null when nothing is pending.
156
+ */
157
+ const attention = computed<{
158
+ label: string
159
+ icon: string
160
+ action: string
161
+ open: () => void
162
+ } | null>(() => {
163
+ const d = pendingDecision.value
164
+ if (d)
165
+ return {
166
+ label: t('board.task.decisionNeeded'),
167
+ icon: 'i-lucide-circle-help',
168
+ action: t('board.task.resolve'),
169
+ open: () => ui.openDecision(d.instanceId, d.decision.id),
170
+ }
171
+ const a = pendingApproval.value
172
+ if (a)
173
+ return {
174
+ label: t('board.task.approvalNeeded'),
175
+ icon: 'i-lucide-shield-check',
176
+ action: t('board.task.approve'),
177
+ open: () => ui.openApprovalDetail(a.instanceId, a.approval.id),
178
+ }
179
+ return null
180
+ })
181
+
182
+ return {
183
+ task,
184
+ sandboxed,
185
+ unmet,
186
+ runnable,
187
+ canExecute,
188
+ executeDeniedTitle,
189
+ defaultPipeline,
190
+ outcomeReadable,
191
+ openOutcome,
192
+ starting,
193
+ start,
194
+ merge,
195
+ pendingDecision,
196
+ reviewStage,
197
+ reviewStageLabel,
198
+ attention,
199
+ }
200
+ }
201
+
202
+ /** The pipeline a plain "Start" runs, as the descriptor `execution.start` takes. */
203
+ export interface StartPipeline {
204
+ id: string
205
+ name: string
206
+ }
207
+
208
+ /**
209
+ * Resolve the pipeline a plain "Start" uses for a task. A function rather than a computed so
210
+ * a caller can ask it about any task, not only about the one its card shows.
211
+ */
212
+ export function useStartPipelineResolver() {
213
+ const pipelines = usePipelinesStore()
214
+ const uiMode = useUiModeStore()
215
+ const { t } = useI18n()
216
+
217
+ /**
218
+ * The pipeline a plain "Start" will use: the task's pinned pipeline, else the build rung this
219
+ * INTERFACE MODE defaults to (`defaultBuildPipelineId`: the fixed Standard build in basic mode,
220
+ * the Adaptive one in advanced). The workspace's positional first pipeline remains the last
221
+ * resort, for a board whose catalog does not carry the rung (an older seed, or a deployment that
222
+ * retired it).
223
+ *
224
+ * A PIN is honoured even when the library holds no row for it, and that branch is the whole reason
225
+ * this returns a descriptor rather than a `Pipeline`. An INTERNAL pipeline is withheld from the
226
+ * library on purpose (the platform starts it on its own behalf, so no picker may offer it), and a
227
+ * task can legitimately be pinned to one: the docs-refresh preset spawns its tasks onto
228
+ * `pl_code_comments`. Resolving that pin through the library alone answers undefined, and the
229
+ * fallback below then starts a FULL BUILD on a comment-only task while the button still reads as
230
+ * an ordinary Start. The fallback chain exists for a task with NO pin; a pin the library cannot
231
+ * show is still the task's answer, and the backend resolves the id for the run.
232
+ */
233
+ return (task: Pick<Block, 'pipelineId'> | undefined): StartPipeline | undefined => {
234
+ const pinnedId = task?.pipelineId
235
+ if (pinnedId) {
236
+ return (
237
+ pipelines.getPipeline(pinnedId) ?? {
238
+ id: pinnedId,
239
+ // The catalog NAME map spans the whole catalog (unlike the versions map), so an internal
240
+ // pin still names itself here; the generic label covers a pin to something this build's
241
+ // catalog does not know at all.
242
+ name: pipelines.catalogNames[pinnedId] ?? t('board.task.pipelineFallback'),
243
+ }
244
+ )
245
+ }
246
+ // No pin: the workspace's own DECLARED in-app default outranks the interface-mode rung, because
247
+ // an operator who named one said something a tier cannot overrule.
248
+ const declared = pipelines.declaredDefaultId('interactive')
249
+ return (
250
+ pipelines.getPipeline(declared ?? defaultBuildPipelineId(uiMode.isAdvanced)) ??
251
+ pipelines.pipelines[0]
252
+ )
253
+ }
254
+ }
@@ -0,0 +1,88 @@
1
+ import type { Block } from '~/types/domain'
2
+ import { runActivityAt, runWaitingSince, type LaneTaskEntry } from '~/utils/laneSort'
3
+ import { classifyTask, type TaskLane } from '~/utils/swimlanes'
4
+
5
+ /** A lane entry plus the lane it was classified into. */
6
+ export interface ClassifiedEntry {
7
+ readonly entry: LaneTaskEntry
8
+ readonly lane: TaskLane
9
+ }
10
+
11
+ /**
12
+ * The store-facing half of `classifyTask`: resolve one task's lane, reason and rendered entry.
13
+ *
14
+ * Shared by the two surfaces that lay tasks out in lanes, a service frame's swimlanes
15
+ * (`useFrameLanes`) and the workspace-wide queue (`useWorkspaceQueueStore`), so both state the SAME
16
+ * claim about a task. A card in "Needs you" on the board and in "In flight" in the queue would
17
+ * make the reader arbitrate between two answers to one question.
18
+ *
19
+ * Every cross-block lookup here is a Map read off an index a STORE maintains, never a reduction
20
+ * of its own: `useFrameLanes` runs one instance per mounted frame, so deriving a workspace-wide
21
+ * fact here makes a board with n frames pay for it n times on every change. The review-debt map
22
+ * (`notifications.reviewDebtByBlock`) is the worked example: a reduction over the whole
23
+ * workspace's open notifications, and it belongs on the store that owns its input.
24
+ */
25
+ export function useTaskLaneClassifier() {
26
+ const board = useBoardStore()
27
+ const execution = useExecutionStore()
28
+ const agentRuns = useAgentRunsStore()
29
+ const notifications = useNotificationsStore()
30
+ const reviews = useReviewStage()
31
+
32
+ /**
33
+ * The module a task belongs to: the module BLOCK's title when it already lives in one, else
34
+ * the module it DECLARES. The engine only materialises the block on merge
35
+ * (`applyModuleAssignment`), so keying on the parent alone would leave every unmerged task in
36
+ * "no module" while its own card names one.
37
+ */
38
+ function moduleNameOf(task: Block): string | null {
39
+ const parent = task.parentId ? board.getBlock(task.parentId) : undefined
40
+ if (parent?.level === 'module') return parent.title
41
+ return task.moduleName?.trim() || null
42
+ }
43
+
44
+ /** One task's lane, reason and rendered entry. `order` is the caller's stable tiebreak. */
45
+ function classify(task: Block, order: number): ClassifiedEntry {
46
+ const run = execution.getByBlock(task.id) ?? null
47
+ const decisions = execution.decisionsByBlock.get(task.id) ?? []
48
+ const allApprovals = execution.approvalsByBlock.get(task.id) ?? []
49
+ // The same suppression the card and the frame badge apply: an iterative reviewer mid-cycle
50
+ // holds a pending approval while the driver folds answers in, and nobody is waiting on it.
51
+ const humanApprovals = allApprovals.filter((a) => !reviews.isBackground(a.agentKind, a.blockId))
52
+
53
+ const { lane, reason } = classifyTask({
54
+ status: task.status,
55
+ // Read from the coarse per-block summary, which also covers a bootstrap run.
56
+ runFailed: agentRuns.byBlock[task.id]?.status === 'failed',
57
+ run,
58
+ // A park is background exactly when everything asking was suppressed AND nothing else
59
+ // asks. With no approvals at all it is NOT background: it is a park on a surface this
60
+ // layer cannot name, which `classifyTask` reports as `parked` rather than as work.
61
+ parkIsBackground:
62
+ decisions.length === 0 &&
63
+ humanApprovals.length === 0 &&
64
+ allApprovals.length > humanApprovals.length,
65
+ pendingDecision: decisions.length > 0,
66
+ pendingApproval: humanApprovals.length > 0,
67
+ hasUnmetDeps: board.unmetDeps(task.id).length > 0,
68
+ })
69
+
70
+ return {
71
+ lane,
72
+ entry: {
73
+ task,
74
+ reason,
75
+ order,
76
+ activityAt: runActivityAt(run),
77
+ waitingSince: runWaitingSince(run, notifications.reviewDebtByBlock.get(task.id) ?? null),
78
+ moduleName: moduleNameOf(task),
79
+ initiativeName: task.initiativeId
80
+ ? (board.getBlock(task.initiativeId)?.title ?? null)
81
+ : null,
82
+ epicName: board.epicOf(task)?.title ?? null,
83
+ },
84
+ }
85
+ }
86
+
87
+ return { classify }
88
+ }
@@ -224,6 +224,8 @@ 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
+ 'queue-view': 'which view of the board a person works in is their own choice, in every role',
228
+ 'board-view': 'the way back to the canvas from the queue view, in every role',
227
229
  }
228
230
  const intake = NAV_CONTRIBUTIONS.filter((i) => i.intake).map((i) => i.id)
229
231
  expect(intake.sort()).toEqual(Object.keys(REASON).sort())
@@ -489,6 +491,7 @@ describe('nav grouping helpers', () => {
489
491
  expect(integrations?.items.map((i) => i.id)).toEqual(['integrations-hub'])
490
492
  const configuration = groups.find((g) => g.group === 'configuration')
491
493
  expect(configuration?.items.map((i) => i.id)).toEqual([
494
+ 'setup-checklist',
492
495
  'workspace-settings',
493
496
  'account-settings',
494
497
  'operator-dashboard',
@@ -522,6 +525,9 @@ describe('nav grouping helpers', () => {
522
525
  'ui-role',
523
526
  'tutorial',
524
527
  'foundational-services',
528
+ 'setup-checklist',
529
+ 'queue-view',
530
+ 'board-view',
525
531
  ])
526
532
  })
527
533
 
@@ -210,6 +210,7 @@ export const NAV_ACTIONS = [
210
210
  'foundationalServices',
211
211
  'mergeThresholds',
212
212
  'workspaceSettings',
213
+ 'setupChecklist',
213
214
  'modelConfiguration',
214
215
  'serviceFragmentDefaults',
215
216
  'localModels',
@@ -220,6 +221,8 @@ export const NAV_ACTIONS = [
220
221
  'tutorial',
221
222
  'toggleUiMode',
222
223
  'chooseRole',
224
+ 'queueView',
225
+ 'boardView',
223
226
  ] as const
224
227
 
225
228
  export type NavActionId = (typeof NAV_ACTIONS)[number]
@@ -536,6 +539,30 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
536
539
  keywordsKey: 'layout.commandBar.keywords.workspaceSettings',
537
540
  },
538
541
  },
542
+ {
543
+ // Every prerequisite for "a task can run and merge", composed from the probes the setup
544
+ // banners already read (issue #2258). First in Configuration, because a workspace that cannot
545
+ // run a task yet needs it before anything else there. Both tiers: getting to green is not an
546
+ // advanced job. Gated on either permission, since its rows open both kinds of surface. Not
547
+ // `intake`: the designer surface never configures the platform, and the setup banners give it
548
+ // no actionable card either. Each row still applies the banners' per-owner rule
549
+ // (`utils/setupAudience.ts`), because holding one of these permissions does not make a caller
550
+ // the owner of every gap.
551
+ id: 'setup-checklist',
552
+ labelKey: 'nav.setupChecklist',
553
+ icon: 'i-lucide-list-checks',
554
+ surfaces: S('sidebar', 'command'),
555
+ gate: (g) => g.canManageIntegrations || g.canManageSettings,
556
+ action: 'setupChecklist',
557
+ testId: 'nav-setup-checklist',
558
+ sidebar: { group: 'configuration', order: 5 },
559
+ command: {
560
+ group: 'workspace',
561
+ order: 120,
562
+ labelKey: 'layout.commandBar.cmd.setupChecklist',
563
+ keywordsKey: 'layout.commandBar.keywords.setupChecklist',
564
+ },
565
+ },
539
566
  {
540
567
  id: 'model-config',
541
568
  labelKey: 'nav.modelConfiguration',
@@ -737,6 +764,35 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
737
764
  keywordsKey: 'layout.commandBar.keywords.chooseRole',
738
765
  },
739
766
  },
767
+ {
768
+ // The board's two views (issue #2258). The sidebar's Views switcher is the everyday route;
769
+ // these make each view reachable by name from the palette too. Every tier and role: which view
770
+ // a person works in is their own choice, and neither view is an override of the other.
771
+ id: 'queue-view',
772
+ labelKey: 'layout.commandBar.cmd.queueView',
773
+ icon: 'i-lucide-list-checks',
774
+ surfaces: S('command'),
775
+ intake: true,
776
+ action: 'queueView',
777
+ command: {
778
+ group: 'workspace',
779
+ order: 130,
780
+ keywordsKey: 'layout.commandBar.keywords.queueView',
781
+ },
782
+ },
783
+ {
784
+ id: 'board-view',
785
+ labelKey: 'layout.commandBar.cmd.boardView',
786
+ icon: 'i-lucide-map',
787
+ surfaces: S('command'),
788
+ intake: true,
789
+ action: 'boardView',
790
+ command: {
791
+ group: 'workspace',
792
+ order: 131,
793
+ keywordsKey: 'layout.commandBar.keywords.boardView',
794
+ },
795
+ },
740
796
  ]
741
797
 
742
798
  /**
@@ -194,6 +194,16 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
194
194
  titleKey: 'tutorial.tours.boardBasics.steps.canvas.title',
195
195
  bodyKey: 'tutorial.tours.boardBasics.steps.canvas.body',
196
196
  },
197
+ {
198
+ // A new person lands on the QUEUE view, and every tour moves them to the canvas, so the
199
+ // orientation tour is where they learn the queue exists. The switcher renders in the rail
200
+ // and the expanded sidebar, in every tier and role, so the step needs no `when`.
201
+ id: 'views',
202
+ target: 'work-view-switcher',
203
+ placement: 'right',
204
+ titleKey: 'tutorial.tours.boardBasics.steps.views.title',
205
+ bodyKey: 'tutorial.tours.boardBasics.steps.views.body',
206
+ },
197
207
  {
198
208
  id: 'sidebar',
199
209
  target: 'sidebar',
@@ -104,6 +104,9 @@ const ModelProvidersHub = defineAsyncView(() => import('~/components/layout/Mode
104
104
  const PersonalSetupModal = defineAsyncView(
105
105
  () => import('~/components/layout/PersonalSetupModal.vue'),
106
106
  )
107
+ const SetupChecklistPanel = defineAsyncView(
108
+ () => import('~/components/setup/SetupChecklistPanel.vue'),
109
+ )
107
110
  const WorkspaceSettingsPanel = defineAsyncView(
108
111
  () => import('~/components/settings/WorkspaceSettingsPanel.vue'),
109
112
  )
@@ -159,12 +162,15 @@ const TutorialNudge = defineAsyncView(() => import('~/components/tutorial/Tutori
159
162
  // The first-run role question (Engineer / Product manager / Designer). Same shape as the tutorial
160
163
  // launch prompt: mounted only while its store flag is set, so an answered question costs nothing.
161
164
  const RolePrompt = defineAsyncView(() => import('~/components/layout/RolePrompt.vue'))
165
+ // The queue: the board's second view (issue #2258), loaded the first time someone opens it.
166
+ const QueueView = defineAsyncView(() => import('~/components/queue/QueueView.vue'))
162
167
 
163
168
  const workspace = useWorkspaceStore()
164
169
  const github = useGitHubStore()
165
170
  const models = useModelsStore()
166
171
  const ui = useUiStore()
167
172
  const aiReadiness = useAiReadiness()
173
+ const views = useWorkViewStore()
168
174
 
169
175
  // App-wide keyboard shortcuts (Escape to deselect, Delete to remove the selected block, ?
170
176
  // for the cheatsheet). Registered ONCE here so a single global listener owns them.
@@ -176,6 +182,8 @@ onMounted(() => {
176
182
  // board it names is the one that opens. This is what makes the observability link on an
177
183
  // engine-published PR verification report resolve.
178
184
  useRunDeepLink()
185
+ // `?view=queue` / `?view=board`: a link that opens one of the board's two views.
186
+ views.consumeDeepLink()
179
187
  void workspace.init()
180
188
  // Honour a `cat-factory k3s` CLI hand-off (`?infraSetup=local-k3s&…`): open the Infrastructure
181
189
  // window pre-seeded with the provisioned connection so the user only pastes the token + saves.
@@ -401,6 +409,14 @@ useTutorialNudge()
401
409
  // plus the funnel counters. Unconditional and best-effort: with no accounts or no store wired it
402
410
  // simply has nothing to talk to, and the browser-persisted store carries on alone.
403
411
  useTutorialSync()
412
+ // Starting a tour switches to the canvas, which every tour walks (the view store holds it for the
413
+ // tour's length), and the canvas is then the view used last.
414
+ watch(
415
+ () => tutorial.touring,
416
+ (touring) => {
417
+ if (touring) views.show('board')
418
+ },
419
+ )
404
420
 
405
421
  // Subscribe to the backend's real-time event stream and (re)connect whenever the
406
422
  // active workspace changes. Runs advance durably server-side; progress arrives as
@@ -462,12 +478,32 @@ watch(
462
478
  <main class="relative min-w-0 flex-1">
463
479
  <BoardCanvas />
464
480
  <!-- Toolbar, nav trigger and every advisory banner, in ONE stacked region that owns
465
- their placement, so no two of them can cover each other. -->
466
- <BoardTopOverlays
467
- :connected="streamConnected"
468
- :ever-connected="streamEverConnected"
469
- :connection-failed="streamConnectionFailed"
470
- />
481
+ their placement, so no two of them can cover each other.
482
+
483
+ The queue view sits OVER the canvas rather than replacing it, so switching back keeps
484
+ the canvas's camera and selection. Below the inspector (z-20), so a task opened from
485
+ the queue shows its panel beside it. The same advisories render IN FLOW above its
486
+ columns, so they push the queue down instead of covering its header.
487
+
488
+ ONE overlay instance serves both views, at the same place in this template: two
489
+ instances would remount every banner on each switch, and a banner whose dismissal
490
+ is its own component state would come back. On the canvas the wrapper is
491
+ `display: contents`, so it adds no box and the overlays float over the canvas as
492
+ before. -->
493
+ <div
494
+ :class="
495
+ views.view === 'queue' ? 'absolute inset-0 z-10 flex flex-col bg-app-950' : 'contents'
496
+ "
497
+ :data-testid="views.view === 'queue' ? 'queue-view-host' : undefined"
498
+ >
499
+ <BoardTopOverlays
500
+ :in-flow="views.view === 'queue'"
501
+ :connected="streamConnected"
502
+ :ever-connected="streamEverConnected"
503
+ :connection-failed="streamConnectionFailed"
504
+ />
505
+ <QueueView v-if="views.view === 'queue'" class="min-h-0 flex-1" />
506
+ </div>
471
507
  <InspectorPanel />
472
508
  <!-- Code-split focus view. The fade lives here (not inside the component) so the
473
509
  leave animation still plays when `focusBlockId` clears and the v-if unmounts
@@ -531,6 +567,7 @@ watch(
531
567
  <IntegrationsHub v-if="ui.integrationsOpen" />
532
568
  <ModelProvidersHub v-if="ui.modelProvidersOpen" />
533
569
  <PersonalSetupModal v-if="ui.personalSetupOpen" />
570
+ <SetupChecklistPanel v-if="ui.setupChecklistOpen" />
534
571
  <WorkspaceSettingsPanel v-if="ui.workspaceSettingsOpen" />
535
572
  <AccountSettingsPanel v-if="ui.accountSettingsOpen" />
536
573
  <ObservabilityConnectionPanel v-if="ui.observabilityConnectionOpen" />
@@ -26,6 +26,7 @@ export interface GitHubStoreContext {
26
26
  api: ReturnType<typeof useApi>
27
27
  workspace: ReturnType<typeof useWorkspaceStore>
28
28
  available: Ref<boolean | null>
29
+ unavailableCause: Ref<'disabled' | 'failed' | null>
29
30
  connection: Ref<GitHubConnection | null>
30
31
  /** The connect surfaces this deployment can serve (GitHub App / GitLab PAT / …). */
31
32
  connectOptions: Ref<VcsConnectOption[]>
@@ -2,6 +2,7 @@ import type { Ref } from 'vue'
2
2
  import type { GitHubPatCheck } from '~/types/domain'
3
3
  import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
4
4
  import type { GitHubStoreContext } from '~/stores/github/context'
5
+ import { apiErrorStatus } from '~/composables/api/errors'
5
6
 
6
7
  /**
7
8
  * The board-load probe: everything the store learns about the deployment's VCS setup in one round
@@ -37,7 +38,7 @@ export function createGitHubProbe(
37
38
  ctx: GitHubStoreContext,
38
39
  patCheck: Ref<GitHubPatCheck | null>,
39
40
  ): { probe: () => Promise<void>; ensureProbed: () => Promise<void> } {
40
- const { api, workspace, available, connection, connectOptions } = ctx
41
+ const { api, workspace, available, unavailableCause, connection, connectOptions } = ctx
41
42
 
42
43
  async function runPatCheck(): Promise<void> {
43
44
  // Which board asked, captured BEFORE the await: a workspace switch mid-flight must not land
@@ -65,11 +66,14 @@ export function createGitHubProbe(
65
66
  .catch(() => []),
66
67
  ])
67
68
  available.value = true
69
+ unavailableCause.value = null
68
70
  connection.value = conn
69
71
  connectOptions.value = options
70
- } catch {
71
- // 503 (integration disabled) or any error → hide the UI entry points.
72
+ } catch (err) {
73
+ // 503 (integration disabled) or any error → hide the UI entry points. The cause is kept, so
74
+ // a surface that states the setup can tell "switched off" from "could not ask".
72
75
  available.value = false
76
+ unavailableCause.value = apiErrorStatus(err) === 503 ? 'disabled' : 'failed'
73
77
  connection.value = null
74
78
  connectOptions.value = []
75
79
  }
@@ -38,6 +38,12 @@ export const useGitHubStore = defineStore('github', () => {
38
38
 
39
39
  /** null = unknown (not probed yet), true/false = integration on/off. */
40
40
  const available = ref<boolean | null>(null)
41
+ /**
42
+ * Why `available` is false: the deployment switched the integration off (a 503), or the probe
43
+ * itself failed. Both hide the GitHub UI, but only the first is a fact about the deployment;
44
+ * the second is an unanswered question. Null while `available` is not false.
45
+ */
46
+ const unavailableCause = ref<'disabled' | 'failed' | null>(null)
41
47
  /** The workspace's VCS connection (App installation or PAT), or null when not connected. */
42
48
  const connection = ref<GitHubConnection | null>(null)
43
49
  /** The connect surfaces this deployment serves; resolved by the probe alongside `connection`. */
@@ -160,6 +166,7 @@ export const useGitHubStore = defineStore('github', () => {
160
166
  api,
161
167
  workspace,
162
168
  available,
169
+ unavailableCause,
163
170
  connection,
164
171
  connectOptions,
165
172
  installations,
@@ -207,6 +214,7 @@ export const useGitHubStore = defineStore('github', () => {
207
214
  */
208
215
  function reset() {
209
216
  available.value = null
217
+ unavailableCause.value = null
210
218
  connection.value = null
211
219
  connectOptions.value = []
212
220
  patCheck.value = null
@@ -221,6 +229,7 @@ export const useGitHubStore = defineStore('github', () => {
221
229
 
222
230
  return {
223
231
  available,
232
+ unavailableCause,
224
233
  connection,
225
234
  connectOptions,
226
235
  patCheck,
@@ -728,6 +728,17 @@ function createSettingsModals(resetHubReturn: ResetHubReturn) {
728
728
  // so a later plain open doesn't re-scroll. Null when no section was requested.
729
729
  const accountSettingsScrollTarget = ref<string | null>(null)
730
730
 
731
+ // Setup checklist: every prerequisite for "a task can run and merge" in one list, composed
732
+ // from the probes the setup banners already read (issue #2258).
733
+ const setupChecklistOpen = ref(false)
734
+ function openSetupChecklist() {
735
+ resetHubReturn()
736
+ setupChecklistOpen.value = true
737
+ }
738
+ function closeSetupChecklist() {
739
+ setupChecklistOpen.value = false
740
+ }
741
+
731
742
  function openWorkspaceSettings(tab = 'workspace') {
732
743
  resetHubReturn()
733
744
  workspaceSettingsTab.value = tab
@@ -765,6 +776,9 @@ function createSettingsModals(resetHubReturn: ResetHubReturn) {
765
776
  }
766
777
 
767
778
  return {
779
+ setupChecklistOpen,
780
+ openSetupChecklist,
781
+ closeSetupChecklist,
768
782
  workspaceSettingsOpen,
769
783
  workspaceSettingsTab,
770
784
  accountSettingsOpen,