@cat-factory/app 0.311.2 → 0.312.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 (210) hide show
  1. package/README.md +92 -4
  2. package/app/app.config.ts +7 -0
  3. package/app/components/assistant/AssistantModal.vue +2 -5
  4. package/app/components/auth/AuthGate.vue +2 -1
  5. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +14 -15
  6. package/app/components/binaryOutput/StoredAssetView.vue +1 -3
  7. package/app/components/board/AddTaskModal.vue +3 -10
  8. package/app/components/board/AgentFailureCard.vue +4 -5
  9. package/app/components/board/AgentStopButton.vue +5 -7
  10. package/app/components/board/BoardCanvas.vue +31 -30
  11. package/app/components/board/CreateInitiativeModal.vue +2 -4
  12. package/app/components/board/RecurringPipelineModal.vue +2 -7
  13. package/app/components/board/nodes/AgentChip.vue +3 -2
  14. package/app/components/board/nodes/BlockNode.vue +14 -11
  15. package/app/components/board/nodes/InitiativeCard.vue +6 -3
  16. package/app/components/board/nodes/TaskCard.vue +45 -212
  17. package/app/components/bootstrap/AdoptionReviewModal.vue +2 -5
  18. package/app/components/bootstrap/BootstrapModal.vue +6 -15
  19. package/app/components/bootstrap/BootstrapRunSteps.vue +3 -1
  20. package/app/components/brainstorm/BrainstormWindow.vue +32 -26
  21. package/app/components/bugFishing/BugFishingWindow.vue +15 -13
  22. package/app/components/clarity/ClarityReviewWindow.vue +31 -26
  23. package/app/components/common/ClarificationItem.vue +3 -1
  24. package/app/components/common/EmptyState.vue +41 -19
  25. package/app/components/common/InterviewGateNotice.vue +3 -1
  26. package/app/components/common/Spinner.vue +28 -0
  27. package/app/components/consensus/ConsensusSessionWindow.vue +5 -5
  28. package/app/components/docs/DocInterviewWindow.vue +6 -6
  29. package/app/components/documents/ContextDocumentPicker.vue +2 -2
  30. package/app/components/documents/DocumentImportModal.vue +9 -8
  31. package/app/components/documents/DocumentSourceConnectModal.vue +10 -10
  32. package/app/components/documents/DocumentTemplatesModal.vue +13 -7
  33. package/app/components/documents/RepoContextDocPicker.vue +2 -2
  34. package/app/components/documents/SpawnPreviewModal.vue +4 -6
  35. package/app/components/documents/StartFromDesignModal.vue +2 -1
  36. package/app/components/documents/TaskContextDocs.vue +4 -5
  37. package/app/components/environments/EnvironmentStatusPanel.vue +11 -8
  38. package/app/components/environments/steps/EnvPickStep.vue +7 -3
  39. package/app/components/environments/steps/EnvPreflightStep.vue +6 -5
  40. package/app/components/focus/BlockFocusView.vue +6 -5
  41. package/app/components/followUp/FollowUpWindow.vue +7 -9
  42. package/app/components/forkDecision/ForkDecisionWindow.vue +9 -8
  43. package/app/components/foundational/FoundationalServiceCatalogList.vue +9 -5
  44. package/app/components/foundational/FoundationalServiceRegistry.vue +11 -7
  45. package/app/components/foundational/FoundationalServiceSources.vue +20 -22
  46. package/app/components/foundational/FoundationalSuppressions.vue +2 -2
  47. package/app/components/foundational/ServiceCatalogConnection.vue +15 -15
  48. package/app/components/fragments/FragmentLibraryManager.vue +36 -34
  49. package/app/components/fragments/FragmentSelector.vue +7 -3
  50. package/app/components/gates/GateResultView.vue +6 -9
  51. package/app/components/github/AddServiceFromRepoModal.vue +6 -10
  52. package/app/components/github/GitHubConnect.vue +10 -17
  53. package/app/components/github/GitHubPanel.vue +44 -47
  54. package/app/components/github/RepoTreeBrowser.vue +9 -5
  55. package/app/components/guidedReview/GuidedReviewModal.vue +16 -6
  56. package/app/components/guidedReview/GuidedReviewOverview.vue +2 -1
  57. package/app/components/guidedReview/GuidedReviewPicker.vue +10 -7
  58. package/app/components/guidedReview/GuidedReviewThread.vue +9 -4
  59. package/app/components/humanTest/HumanTestWindow.vue +2 -1
  60. package/app/components/initiative/InitiativePlanningWindow.vue +6 -6
  61. package/app/components/initiative/InitiativeTrackerWindow.vue +11 -11
  62. package/app/components/judge/JudgeResultView.vue +2 -7
  63. package/app/components/kaizen/KaizenPanel.vue +20 -5
  64. package/app/components/kaizen/KaizenStepStatus.vue +2 -1
  65. package/app/components/layout/AccountAuditLog.vue +11 -3
  66. package/app/components/layout/AccountDeploymentSettings.vue +17 -62
  67. package/app/components/layout/AccountFailureKindRules.vue +6 -5
  68. package/app/components/layout/AccountModelPolicySettings.vue +2 -6
  69. package/app/components/layout/AccountPlatformAlertSettings.vue +5 -13
  70. package/app/components/layout/AccountRiskPolicySettings.vue +13 -14
  71. package/app/components/layout/AccountRunCredentialSettings.vue +2 -2
  72. package/app/components/layout/AccountTeamSettings.vue +12 -9
  73. package/app/components/layout/BoardSwitcher.vue +2 -2
  74. package/app/components/layout/BoardToolbar.vue +27 -37
  75. package/app/components/layout/BoardTopOverlays.vue +8 -2
  76. package/app/components/layout/CommandBar.vue +7 -3
  77. package/app/components/layout/ConnectionStatusBanner.vue +2 -1
  78. package/app/components/layout/IntegrationsHub.vue +7 -3
  79. package/app/components/layout/ModelProvidersHub.vue +7 -3
  80. package/app/components/layout/NotificationsInbox.vue +3 -11
  81. package/app/components/layout/SideBar.vue +5 -0
  82. package/app/components/layout/WorkViewSwitcher.vue +64 -0
  83. package/app/components/layout/WorkspaceMembersSettings.vue +12 -5
  84. package/app/components/media/ArtifactLightbox.vue +4 -3
  85. package/app/components/notifications/NotificationSettingsPanel.vue +4 -11
  86. package/app/components/observability/ToolCallList.vue +12 -17
  87. package/app/components/panels/AgentStepDetail.vue +5 -5
  88. package/app/components/panels/GenericStructuredResultView.vue +6 -9
  89. package/app/components/panels/InspectorPanel.vue +2 -1
  90. package/app/components/panels/MergerResultView.vue +5 -6
  91. package/app/components/panels/ObservabilityPanel.vue +26 -41
  92. package/app/components/panels/OperatorDashboardPanel.vue +29 -17
  93. package/app/components/panels/ReportsPanel.vue +17 -11
  94. package/app/components/panels/ReportsSpendBreakdown.vue +7 -3
  95. package/app/components/panels/RunDetailLoadState.vue +4 -3
  96. package/app/components/panels/StepContainerStatus.vue +4 -3
  97. package/app/components/panels/StepDelegatedStatus.vue +3 -2
  98. package/app/components/panels/StepMetadataCard.vue +6 -8
  99. package/app/components/panels/StepRunMeta.vue +2 -5
  100. package/app/components/panels/inspector/ContainerSummary.vue +7 -1
  101. package/app/components/panels/inspector/EpicChildren.vue +7 -3
  102. package/app/components/panels/inspector/FrontendConfig.vue +7 -3
  103. package/app/components/panels/inspector/InitiativeInspector.vue +5 -2
  104. package/app/components/panels/inspector/ServiceConnections.vue +7 -3
  105. package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +2 -6
  106. package/app/components/panels/inspector/ServiceTestSecrets.vue +2 -6
  107. package/app/components/panels/inspector/ServiceTestingContext.vue +2 -6
  108. package/app/components/panels/inspector/ServiceValidationConfig.vue +38 -39
  109. package/app/components/panels/inspector/TaskExecution.vue +5 -6
  110. package/app/components/panels/inspector/TaskRunSettings.vue +7 -3
  111. package/app/components/panels/inspector/TaskStructure.vue +0 -1
  112. package/app/components/pipeline/AgentPromptEditor.vue +25 -18
  113. package/app/components/pipeline/PipelineBuilder.vue +14 -24
  114. package/app/components/pipeline/PipelineProgress.vue +16 -17
  115. package/app/components/prReview/PrReviewPhaseBadge.vue +3 -2
  116. package/app/components/prReview/PrReviewWindow.vue +16 -20
  117. package/app/components/providers/ApiKeysSection.vue +2 -6
  118. package/app/components/providers/PersonalCredentialModal.vue +2 -6
  119. package/app/components/providers/PersonalSubscriptionSection.vue +3 -7
  120. package/app/components/providers/VendorCredentialsModal.vue +2 -6
  121. package/app/components/provisioning/ProvisioningLogsDrawer.vue +10 -6
  122. package/app/components/queue/QueueCard.vue +269 -0
  123. package/app/components/queue/QueueView.vue +192 -0
  124. package/app/components/ralph/RalphLoopResultView.vue +2 -7
  125. package/app/components/requirements/RequirementsReviewWindow.vue +44 -39
  126. package/app/components/sandbox/SandboxPanel.vue +37 -25
  127. package/app/components/settings/AccountSettingsPanel.vue +6 -3
  128. package/app/components/settings/ApiTokensPanel.vue +2 -6
  129. package/app/components/settings/BudgetSettings.vue +2 -2
  130. package/app/components/settings/CapabilityCredentialsPanel.vue +2 -6
  131. package/app/components/settings/CloudflareHandlerSection.vue +3 -7
  132. package/app/components/settings/ConsensusGroupsSection.vue +7 -3
  133. package/app/components/settings/CustomManifestTypeEditor.vue +7 -3
  134. package/app/components/settings/DefaultProvisionTypeSection.vue +2 -2
  135. package/app/components/settings/InfraHandlersConfigurator.vue +8 -11
  136. package/app/components/settings/IssueTrackerPanel.vue +9 -9
  137. package/app/components/settings/LocalContainerPoolSettings.vue +2 -6
  138. package/app/components/settings/LocalModelEndpointsPanel.vue +5 -7
  139. package/app/components/settings/McpAuthorizeScreen.vue +8 -3
  140. package/app/components/settings/McpOAuthCallbackScreen.vue +2 -1
  141. package/app/components/settings/ModelConfigurationPanel.vue +45 -17
  142. package/app/components/settings/ObservabilityConnectionPanel.vue +4 -15
  143. package/app/components/settings/OpenRouterCatalogPanel.vue +8 -24
  144. package/app/components/settings/PackageRegistriesPanel.vue +2 -6
  145. package/app/components/settings/ProviderConnectionTab.vue +3 -7
  146. package/app/components/settings/RiskPolicyPanel.vue +4 -16
  147. package/app/components/settings/SharedStacksPanel.vue +8 -18
  148. package/app/components/settings/TaskTypeSuppressionsPanel.vue +10 -6
  149. package/app/components/settings/UsageSettings.vue +11 -2
  150. package/app/components/settings/UserSecretsSection.vue +5 -7
  151. package/app/components/settings/WorkspaceMetadataSettings.vue +9 -9
  152. package/app/components/settings/WorkspaceSettingsPanel.vue +3 -11
  153. package/app/components/skills/ReviewSkillQueue.vue +7 -3
  154. package/app/components/skills/SkillLibraryManager.vue +20 -18
  155. package/app/components/slack/SlackPanel.vue +5 -8
  156. package/app/components/spec/ServiceSpecWindow.vue +32 -29
  157. package/app/components/tasks/BugHuntModal.vue +23 -19
  158. package/app/components/tasks/ContextIssuePicker.vue +2 -2
  159. package/app/components/tasks/TaskContextIssues.vue +4 -5
  160. package/app/components/tasks/TaskImportModal.vue +10 -8
  161. package/app/components/tasks/TaskSourceConnectModal.vue +11 -11
  162. package/app/components/testing/TestReportWindow.vue +6 -12
  163. package/app/components/tutorial/TutorialCatalogue.vue +7 -3
  164. package/app/components/tutorial/TutorialOverlay.vue +2 -1
  165. package/app/components/tutorial/TutorialPrompt.vue +7 -3
  166. package/app/components/vcs/GitLabConnect.vue +2 -6
  167. package/app/components/visualConfirm/VisualConfirmationWindow.vue +9 -4
  168. package/app/composables/toastTitleKeys.spec.ts +143 -0
  169. package/app/composables/useActionToast.spec.ts +71 -0
  170. package/app/composables/useActionToast.ts +101 -0
  171. package/app/composables/useBlockDeletion.ts +3 -6
  172. package/app/composables/useConfirmAction.ts +5 -3
  173. package/app/composables/useContextLinking.spec.ts +3 -0
  174. package/app/composables/useContextLinking.ts +11 -16
  175. package/app/composables/useCopyToClipboard.ts +4 -3
  176. package/app/composables/useFrameLanes.ts +6 -82
  177. package/app/composables/useNavContributions.ts +6 -5
  178. package/app/composables/usePipelineErrorToast.spec.ts +24 -0
  179. package/app/composables/usePipelineErrorToast.ts +26 -2
  180. package/app/composables/usePipelineLibraryActions.ts +8 -9
  181. package/app/composables/useTaskActions.ts +254 -0
  182. package/app/composables/useTaskLaneClassifier.ts +88 -0
  183. package/app/modular/nav-contributions.spec.ts +4 -0
  184. package/app/modular/nav-contributions.ts +31 -0
  185. package/app/modular/tutorial-tours.ts +10 -0
  186. package/app/pages/index.vue +42 -8
  187. package/app/stores/board/context.ts +4 -3
  188. package/app/stores/board/placement.ts +8 -20
  189. package/app/stores/board/removal.ts +4 -13
  190. package/app/stores/board.spec.ts +10 -1
  191. package/app/stores/board.ts +3 -2
  192. package/app/stores/workView.ts +51 -0
  193. package/app/stores/workspaceQueue.ts +148 -0
  194. package/app/utils/bootstrapSteps.ts +11 -2
  195. package/app/utils/pipelineRender.ts +21 -8
  196. package/app/utils/queueSections.spec.ts +111 -0
  197. package/app/utils/queueSections.ts +117 -0
  198. package/app/utils/workView.spec.ts +16 -0
  199. package/app/utils/workView.ts +25 -0
  200. package/i18n/locales/de.json +73 -32
  201. package/i18n/locales/en.json +73 -32
  202. package/i18n/locales/es.json +73 -32
  203. package/i18n/locales/fr.json +73 -32
  204. package/i18n/locales/he.json +73 -32
  205. package/i18n/locales/it.json +73 -32
  206. package/i18n/locales/ja.json +73 -32
  207. package/i18n/locales/pl.json +73 -32
  208. package/i18n/locales/tr.json +73 -32
  209. package/i18n/locales/uk.json +73 -32
  210. package/package.json +1 -1
@@ -272,6 +272,30 @@ describe('usePipelineErrorToast', () => {
272
272
  })
273
273
  })
274
274
 
275
+ describe('presentReported', () => {
276
+ it('keeps a failure reported as data behind the disclosure, under translated copy', () => {
277
+ // A job row's `error` is backend prose: it must be copyable detail, never the headline, and it
278
+ // must not be read as a network fault the way `present` reads a bare string.
279
+ usePipelineErrorToast().presentReported('stack exited with code 1', 'x.startFailed')
280
+ const arg = add.mock.calls[0]![0]
281
+ expect(arg.title).toBe('x.startFailed')
282
+ expect(arg.description).toBe('errors.reported.description')
283
+ expect(arg.duration).toBe(0)
284
+ arg.actions[0].onClick()
285
+ expect(update).toHaveBeenCalledWith('toast-1', {
286
+ description: 'stack exited with code 1',
287
+ actions: [expect.objectContaining({ label: 'common.copyDetails' })],
288
+ })
289
+ })
290
+
291
+ it('takes a site description key, and offers no disclosure when nothing was reported', () => {
292
+ usePipelineErrorToast().presentReported(null, 'x.failed', { descriptionKey: 'x.fallback' })
293
+ const arg = add.mock.calls[0]![0]
294
+ expect(arg.description).toBe('x.fallback')
295
+ expect(arg.actions).toEqual([expect.objectContaining({ label: 'common.copyDetails' })])
296
+ })
297
+ })
298
+
275
299
  describe('describeGenericFailure', () => {
276
300
  it('maps each known status class to its own description key', () => {
277
301
  for (const code of [
@@ -683,7 +683,12 @@ export function usePipelineErrorToast() {
683
683
  // ships in the base `en` catalog, so a locale missing it renders English via `fallbackLocale`
684
684
  // (better than the raw prose this replaced) and a bare key can never leak.
685
685
  const title = t(fallbackTitleKey, titleParams ?? {})
686
- const description = t(failure.descriptionKey)
686
+ showFailure(title, t(failure.descriptionKey), detail)
687
+ }
688
+
689
+ /** The failure toast both generic paths render: translated title and description, the raw
690
+ * detail behind "Show details", and the whole report copyable. */
691
+ function showFailure(title: string, description: string, detail: string): void {
687
692
  const report = [title, description, detail].filter((part) => part.length > 0).join('\n')
688
693
  const added = toast.add({
689
694
  title,
@@ -713,6 +718,25 @@ export function usePipelineErrorToast() {
713
718
  })
714
719
  }
715
720
 
721
+ /**
722
+ * A failure the backend reported as DATA rather than as a failed call: a job row with
723
+ * `status: 'failed'` and its `error` text, a probe verdict with a `message`. There is no
724
+ * envelope, so `present` would misread it as a network fault. The reported text is backend prose
725
+ * and so DETAIL, never the headline: it goes behind "Show details" and into the copied report,
726
+ * under a translated description (`descriptionKey`, or the generic "it reported a failure").
727
+ */
728
+ function presentReported(
729
+ detail: string | null | undefined,
730
+ titleKey: string,
731
+ opts: { params?: Record<string, unknown>; descriptionKey?: string } = {},
732
+ ): void {
733
+ showFailure(
734
+ t(titleKey, opts.params ?? {}),
735
+ t(opts.descriptionKey ?? 'errors.reported.description'),
736
+ detail?.trim() ?? '',
737
+ )
738
+ }
739
+
716
740
  /**
717
741
  * Present `error` as a toast. `fallbackTitleKey` is an i18n message key used for
718
742
  * non-conflict failures and any conflict reason without a dedicated title; `titleParams` carries
@@ -732,5 +756,5 @@ export function usePipelineErrorToast() {
732
756
  presentGenericFailure(error, fallbackTitleKey, titleParams)
733
757
  }
734
758
 
735
- return { present }
759
+ return { present, presentReported }
736
760
  }
@@ -2,6 +2,7 @@ import type { RunDefaultScope } from '@cat-factory/contracts'
2
2
  import type { Pipeline } from '~/types/domain'
3
3
  import { usePipelinesStore } from '~/stores/pipelines'
4
4
  import { usePipelineErrorToast } from '~/composables/usePipelineErrorToast'
5
+ import { useActionToast } from '~/composables/useActionToast'
5
6
 
6
7
  /**
7
8
  * The actions a row of the saved-pipeline LIBRARY offers: archive, promote to a scope default,
@@ -14,7 +15,7 @@ import { usePipelineErrorToast } from '~/composables/usePipelineErrorToast'
14
15
  */
15
16
  export function usePipelineLibraryActions() {
16
17
  const pipelines = usePipelinesStore()
17
- const toast = useToast()
18
+ const actionToast = useActionToast()
18
19
  const { t } = useI18n()
19
20
  const { present } = usePipelineErrorToast()
20
21
  const { confirm } = useConfirm()
@@ -24,8 +25,8 @@ export function usePipelineLibraryActions() {
24
25
  try {
25
26
  if (p.archived) await pipelines.unarchive(p.id)
26
27
  else await pipelines.archive(p.id)
27
- } catch {
28
- toast.add({ title: t('pipeline.builder.toast.updateFailed'), color: 'error' })
28
+ } catch (error) {
29
+ present(error, 'pipeline.builder.toast.updateFailed')
29
30
  }
30
31
  }
31
32
 
@@ -68,13 +69,11 @@ export function usePipelineLibraryActions() {
68
69
  async function clone(p: Pipeline) {
69
70
  try {
70
71
  const copy = await pipelines.clonePipeline(p.id)
71
- toast.add({
72
- title: t('pipeline.builder.toast.cloned', { name: p.name, copy: copy.name }),
73
- color: 'success',
74
- icon: 'i-lucide-copy',
72
+ actionToast.success('pipeline.builder.toast.cloned', {
73
+ params: { name: p.name, copy: copy.name },
75
74
  })
76
- } catch {
77
- toast.add({ title: t('pipeline.builder.toast.cloneFailed'), color: 'error' })
75
+ } catch (error) {
76
+ present(error, 'pipeline.builder.toast.cloneFailed')
78
77
  }
79
78
  }
80
79
 
@@ -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())
@@ -522,6 +524,8 @@ describe('nav grouping helpers', () => {
522
524
  'ui-role',
523
525
  'tutorial',
524
526
  'foundational-services',
527
+ 'queue-view',
528
+ 'board-view',
525
529
  ])
526
530
  })
527
531
 
@@ -220,6 +220,8 @@ export const NAV_ACTIONS = [
220
220
  'tutorial',
221
221
  'toggleUiMode',
222
222
  'chooseRole',
223
+ 'queueView',
224
+ 'boardView',
223
225
  ] as const
224
226
 
225
227
  export type NavActionId = (typeof NAV_ACTIONS)[number]
@@ -737,6 +739,35 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
737
739
  keywordsKey: 'layout.commandBar.keywords.chooseRole',
738
740
  },
739
741
  },
742
+ {
743
+ // The board's two views (issue #2258). The sidebar's Views switcher is the everyday route;
744
+ // these make each view reachable by name from the palette too. Every tier and role: which view
745
+ // a person works in is their own choice, and neither view is an override of the other.
746
+ id: 'queue-view',
747
+ labelKey: 'layout.commandBar.cmd.queueView',
748
+ icon: 'i-lucide-list-checks',
749
+ surfaces: S('command'),
750
+ intake: true,
751
+ action: 'queueView',
752
+ command: {
753
+ group: 'workspace',
754
+ order: 130,
755
+ keywordsKey: 'layout.commandBar.keywords.queueView',
756
+ },
757
+ },
758
+ {
759
+ id: 'board-view',
760
+ labelKey: 'layout.commandBar.cmd.boardView',
761
+ icon: 'i-lucide-map',
762
+ surfaces: S('command'),
763
+ intake: true,
764
+ action: 'boardView',
765
+ command: {
766
+ group: 'workspace',
767
+ order: 131,
768
+ keywordsKey: 'layout.commandBar.keywords.boardView',
769
+ },
770
+ },
740
771
  ]
741
772
 
742
773
  /**
@@ -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',
@@ -17,6 +17,7 @@ import GitHubOnboarding from '~/components/github/GitHubOnboarding.vue'
17
17
  import CommandBar from '~/components/layout/CommandBar.vue'
18
18
  import PersonalCredentialModal from '~/components/providers/PersonalCredentialModal.vue'
19
19
  import ConfirmDialog from '~/components/common/ConfirmDialog.vue'
20
+ import Spinner from '~/components/common/Spinner.vue'
20
21
  import { defineAsyncView } from '~/utils/asyncView'
21
22
  import KeyboardShortcutsHelp from '~/components/common/KeyboardShortcutsHelp.vue'
22
23
 
@@ -158,12 +159,15 @@ const TutorialNudge = defineAsyncView(() => import('~/components/tutorial/Tutori
158
159
  // The first-run role question (Engineer / Product manager / Designer). Same shape as the tutorial
159
160
  // launch prompt: mounted only while its store flag is set, so an answered question costs nothing.
160
161
  const RolePrompt = defineAsyncView(() => import('~/components/layout/RolePrompt.vue'))
162
+ // The queue: the board's second view (issue #2258), loaded the first time someone opens it.
163
+ const QueueView = defineAsyncView(() => import('~/components/queue/QueueView.vue'))
161
164
 
162
165
  const workspace = useWorkspaceStore()
163
166
  const github = useGitHubStore()
164
167
  const models = useModelsStore()
165
168
  const ui = useUiStore()
166
169
  const aiReadiness = useAiReadiness()
170
+ const views = useWorkViewStore()
167
171
 
168
172
  // App-wide keyboard shortcuts (Escape to deselect, Delete to remove the selected block, ?
169
173
  // for the cheatsheet). Registered ONCE here so a single global listener owns them.
@@ -175,6 +179,8 @@ onMounted(() => {
175
179
  // board it names is the one that opens. This is what makes the observability link on an
176
180
  // engine-published PR verification report resolve.
177
181
  useRunDeepLink()
182
+ // `?view=queue` / `?view=board`: a link that opens one of the board's two views.
183
+ views.consumeDeepLink()
178
184
  void workspace.init()
179
185
  // Honour a `cat-factory k3s` CLI hand-off (`?infraSetup=local-k3s&…`): open the Infrastructure
180
186
  // window pre-seeded with the provisioned connection so the user only pastes the token + saves.
@@ -400,6 +406,14 @@ useTutorialNudge()
400
406
  // plus the funnel counters. Unconditional and best-effort: with no accounts or no store wired it
401
407
  // simply has nothing to talk to, and the browser-persisted store carries on alone.
402
408
  useTutorialSync()
409
+ // Starting a tour switches to the canvas, which every tour walks (the view store holds it for the
410
+ // tour's length), and the canvas is then the view used last.
411
+ watch(
412
+ () => tutorial.touring,
413
+ (touring) => {
414
+ if (touring) views.show('board')
415
+ },
416
+ )
403
417
 
404
418
  // Subscribe to the backend's real-time event stream and (re)connect whenever the
405
419
  // active workspace changes. Runs advance durably server-side; progress arrives as
@@ -438,7 +452,7 @@ watch(
438
452
  v-if="workspace.ready && githubProbePending"
439
453
  class="m-auto flex flex-col items-center gap-3 text-muted"
440
454
  >
441
- <UIcon name="i-lucide-loader" class="h-8 w-8 animate-spin" />
455
+ <Spinner class="h-8 w-8" />
442
456
  <span class="text-sm">{{ $t('app.loading') }}</span>
443
457
  </div>
444
458
 
@@ -461,12 +475,32 @@ watch(
461
475
  <main class="relative min-w-0 flex-1">
462
476
  <BoardCanvas />
463
477
  <!-- Toolbar, nav trigger and every advisory banner, in ONE stacked region that owns
464
- their placement, so no two of them can cover each other. -->
465
- <BoardTopOverlays
466
- :connected="streamConnected"
467
- :ever-connected="streamEverConnected"
468
- :connection-failed="streamConnectionFailed"
469
- />
478
+ their placement, so no two of them can cover each other.
479
+
480
+ The queue view sits OVER the canvas rather than replacing it, so switching back keeps
481
+ the canvas's camera and selection. Below the inspector (z-20), so a task opened from
482
+ the queue shows its panel beside it. The same advisories render IN FLOW above its
483
+ columns, so they push the queue down instead of covering its header.
484
+
485
+ ONE overlay instance serves both views, at the same place in this template: two
486
+ instances would remount every banner on each switch, and a banner whose dismissal
487
+ is its own component state would come back. On the canvas the wrapper is
488
+ `display: contents`, so it adds no box and the overlays float over the canvas as
489
+ before. -->
490
+ <div
491
+ :class="
492
+ views.view === 'queue' ? 'absolute inset-0 z-10 flex flex-col bg-app-950' : 'contents'
493
+ "
494
+ :data-testid="views.view === 'queue' ? 'queue-view-host' : undefined"
495
+ >
496
+ <BoardTopOverlays
497
+ :in-flow="views.view === 'queue'"
498
+ :connected="streamConnected"
499
+ :ever-connected="streamEverConnected"
500
+ :connection-failed="streamConnectionFailed"
501
+ />
502
+ <QueueView v-if="views.view === 'queue'" class="min-h-0 flex-1" />
503
+ </div>
470
504
  <InspectorPanel />
471
505
  <!-- Code-split focus view. The fade lives here (not inside the component) so the
472
506
  leave animation still plays when `focusBlockId` clears and the v-if unmounts
@@ -567,7 +601,7 @@ watch(
567
601
 
568
602
  <!-- Initial load -->
569
603
  <div v-else class="m-auto flex flex-col items-center gap-3 text-muted">
570
- <UIcon name="i-lucide-loader" class="h-8 w-8 animate-spin" />
604
+ <Spinner class="h-8 w-8" />
571
605
  <span class="text-sm">{{ $t('app.loadingBoard') }}</span>
572
606
  </div>
573
607
  </div>