@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
@@ -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>
@@ -15,7 +15,7 @@ const props = defineProps<{ job: BootstrapJob }>()
15
15
  const emit = defineEmits<{ close: [] }>()
16
16
 
17
17
  const agentRuns = useAgentRunsStore()
18
- const toast = useToast()
18
+ const actionToast = useActionToast()
19
19
  const { present } = usePipelineErrorToast()
20
20
  const { t } = useI18n()
21
21
 
@@ -146,13 +146,10 @@ async function submit() {
146
146
  })),
147
147
  ...(overallNotes.value.trim() ? { notes: overallNotes.value.trim() } : {}),
148
148
  })
149
- toast.add({
150
- title: t('bootstrap.adoption.toast.approved'),
149
+ actionToast.success('bootstrap.adoption.toast.approved', {
151
150
  description: t('bootstrap.adoption.toast.approvedDesc', {
152
151
  directory: props.job.monorepo?.directory ?? '',
153
152
  }),
154
- icon: 'i-lucide-check',
155
- color: 'success',
156
153
  })
157
154
  open.value = false
158
155
  emit('close')
@@ -29,8 +29,8 @@ const bootstrap = useBootstrapStore()
29
29
  const agentRuns = useAgentRunsStore()
30
30
  const github = useGitHubStore()
31
31
  const board = useBoardStore()
32
- const toast = useToast()
33
- const { present } = usePipelineErrorToast()
32
+ const actionToast = useActionToast()
33
+ const { present, presentReported } = usePipelineErrorToast()
34
34
  const { freeFramePosition, focusFrame } = useFramePlacement()
35
35
  const { t } = useI18n()
36
36
  const { confirmAction, toastDone } = useConfirmAction()
@@ -367,11 +367,8 @@ async function openCreateRepo() {
367
367
  private: isPrivate.value,
368
368
  description: description.value.trim() || undefined,
369
369
  })
370
- toast.add({
371
- title: t('bootstrap.toast.repoCreated'),
370
+ actionToast.success('bootstrap.toast.repoCreated', {
372
371
  description: `${repo.owner}/${repo.name}`,
373
- icon: 'i-lucide-check',
374
- color: 'success',
375
372
  })
376
373
  } catch (e) {
377
374
  present(e, 'bootstrap.toast.repoCreateFailed')
@@ -426,26 +423,20 @@ async function launch() {
426
423
  if (job.status === 'failed') {
427
424
  // The container couldn't even start (pre-flight failure, e.g. the target
428
425
  // repo isn't empty) — surfaced synchronously, before any board frame.
429
- toast.add({
430
- title: t('bootstrap.toast.failed'),
431
- description: job.error ?? t('bootstrap.toast.failedFallback'),
432
- icon: 'i-lucide-triangle-alert',
433
- color: 'error',
426
+ presentReported(job.error, 'bootstrap.toast.failed', {
427
+ descriptionKey: 'bootstrap.toast.failedFallback',
434
428
  })
435
429
  } else {
436
430
  // Running: the container is spinning up. A provisional service card now
437
431
  // shows on the board and tracks live progress; the run continues in the
438
432
  // background and becomes a real, droppable service when it finishes.
439
- toast.add({
440
- title: t('bootstrap.toast.started'),
433
+ actionToast.info('bootstrap.toast.started', {
441
434
  // A monorepo run does not run straight through: it surveys, then waits for the
442
435
  // reviewer. Saying "bootstrapping…" there would set the wrong expectation about who
443
436
  // the next move belongs to.
444
437
  description: job.monorepo
445
438
  ? t('bootstrap.toast.startedMonorepoDesc', { directory: job.monorepo.directory })
446
439
  : t('bootstrap.toast.startedDesc', { repo: job.repoName }),
447
- icon: 'i-lucide-loader-circle',
448
- color: 'info',
449
440
  })
450
441
  repoName.value = ''
451
442
  description.value = ''
@@ -12,6 +12,7 @@
12
12
  // are one rule, not two. How a state RENDERS is `BOOTSTRAP_STEP_STYLE`, beside the vocabulary
13
13
  // it is keyed by.
14
14
  import type { BootstrapStepId, BootstrapStepState } from '@cat-factory/contracts'
15
+ import Spinner from '~/components/common/Spinner.vue'
15
16
 
16
17
  const props = defineProps<{ runId: string }>()
17
18
 
@@ -38,8 +39,9 @@ function stateLabel(state: BootstrapStepState): string {
38
39
  :data-step="step.id"
39
40
  :data-state="step.state"
40
41
  >
41
- <UIcon
42
+ <Spinner
42
43
  :name="BOOTSTRAP_STEP_STYLE[step.state].icon"
44
+ :spinning="BOOTSTRAP_STEP_STYLE[step.state].spin"
43
45
  class="mt-px h-3 w-3 shrink-0"
44
46
  :class="BOOTSTRAP_STEP_STYLE[step.state].iconClass"
45
47
  />
@@ -20,10 +20,12 @@ import type {
20
20
  ReviewItemStatus,
21
21
  } from '~/types/brainstorm'
22
22
  import SectionLabel from '~/components/common/SectionLabel.vue'
23
+ import Spinner from '~/components/common/Spinner.vue'
24
+ import EmptyState from '~/components/common/EmptyState.vue'
23
25
 
24
26
  const board = useBoardStore()
25
27
  const brainstorm = useBrainstormStore()
26
- const toast = useToast()
28
+ const actionToast = useActionToast()
27
29
  const { present } = usePipelineErrorToast()
28
30
  const { t } = useI18n()
29
31
  const access = useWorkspaceAccess()
@@ -194,10 +196,9 @@ async function incorporate(feedback?: string) {
194
196
  }
195
197
  redoComment.value = ''
196
198
  showRedo.value = false
197
- toast.add({
198
- title: t('brainstorm.toast.draftingTitle', { doc: docNoun.value }),
199
+ actionToast.info('brainstorm.toast.draftingTitle', {
200
+ params: { doc: docNoun.value },
199
201
  description: t('brainstorm.toast.draftingDescription'),
200
- icon: 'i-lucide-wand-sparkles',
201
202
  })
202
203
  close()
203
204
  }
@@ -207,15 +208,16 @@ async function reReview() {
207
208
  try {
208
209
  const updated = await brainstorm.reReview(blockId.value, activeStage.value)
209
210
  const newCount = brainstorm.openCount(updated)
210
- toast.add({
211
- title:
212
- updated.status === 'incorporated'
213
- ? t('brainstorm.toast.reReviewSettled')
214
- : updated.status === 'exceeded'
215
- ? t('brainstorm.toast.reReviewExceeded')
216
- : t('brainstorm.toast.reReviewNewOptions', { count: newCount }, newCount),
217
- icon: 'i-lucide-sparkles',
218
- })
211
+ if (updated.status === 'incorporated') {
212
+ actionToast.success('brainstorm.toast.reReviewSettled')
213
+ } else if (updated.status === 'exceeded') {
214
+ actionToast.warning('brainstorm.toast.reReviewExceeded')
215
+ } else {
216
+ actionToast.info('brainstorm.toast.reReviewNewOptions', {
217
+ params: { count: newCount },
218
+ plural: newCount,
219
+ })
220
+ }
219
221
  } catch (e) {
220
222
  present(e, 'brainstorm.toast.reReviewError')
221
223
  }
@@ -226,7 +228,7 @@ async function proceed() {
226
228
  acting.value = true
227
229
  try {
228
230
  await brainstorm.proceed(blockId.value, activeStage.value)
229
- toast.add({ title: t('brainstorm.toast.proceeding'), icon: 'i-lucide-arrow-right' })
231
+ actionToast.info('brainstorm.toast.proceeding')
230
232
  } catch (e) {
231
233
  present(e, 'brainstorm.toast.proceedError')
232
234
  } finally {
@@ -240,12 +242,12 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
240
242
  try {
241
243
  await brainstorm.resolveExceeded(blockId.value, activeStage.value, choice)
242
244
  if (choice === 'stop-reset') {
243
- toast.add({ title: t('brainstorm.toast.taskReset'), icon: 'i-lucide-undo' })
245
+ actionToast.info('brainstorm.toast.taskReset')
244
246
  close()
245
247
  } else if (choice === 'proceed') {
246
- toast.add({ title: t('brainstorm.toast.proceeding'), icon: 'i-lucide-arrow-right' })
248
+ actionToast.info('brainstorm.toast.proceeding')
247
249
  } else {
248
- toast.add({ title: t('brainstorm.toast.extraRoundGranted'), icon: 'i-lucide-rotate-cw' })
250
+ actionToast.info('brainstorm.toast.extraRoundGranted')
249
251
  }
250
252
  } catch (e) {
251
253
  present(e, 'brainstorm.toast.resolveError')
@@ -291,20 +293,24 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
291
293
  </p>
292
294
 
293
295
  <!-- empty state -->
294
- <div
296
+ <EmptyState
295
297
  v-if="!session && !busy && !loading"
296
- class="rounded-lg border border-dashed border-muted p-8 text-center text-sm text-dimmed"
297
- >
298
- {{ t('brainstorm.empty') }}
298
+ icon="i-lucide-lightbulb"
299
+ :title="t('brainstorm.empty')"
300
+ />
301
+
302
+ <!-- initial fetch on open -->
303
+ <div v-else-if="loading && !busy && !session" class="flex flex-col gap-3">
304
+ <USkeleton v-for="n in 3" :key="n" class="h-20 w-full rounded-lg" />
299
305
  </div>
300
306
 
301
- <!-- working state (initial fetch on open, or an agent pass running) -->
307
+ <!-- an agent pass running -->
302
308
  <div
303
- v-else-if="(busy || loading) && !session"
309
+ v-else-if="busy && !session"
304
310
  class="flex items-center justify-center gap-2 p-8 text-sm text-muted"
305
311
  >
306
- <UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
307
- {{ loading && !busy ? t('brainstorm.loading') : t('brainstorm.generating') }}
312
+ <Spinner class="h-4 w-4" />
313
+ {{ t('brainstorm.generating') }}
308
314
  </div>
309
315
 
310
316
  <template v-else-if="session">
@@ -332,7 +338,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
332
338
  v-else-if="working"
333
339
  class="mb-4 flex items-center gap-2 rounded-lg border border-app-warning-900/60 bg-app-warning-950/30 p-4 text-sm text-app-warning-200"
334
340
  >
335
- <UIcon name="i-lucide-loader-circle" class="h-5 w-5 shrink-0 animate-spin" />
341
+ <Spinner class="h-5 w-5 shrink-0" />
336
342
  <span v-if="incorporating">
337
343
  {{ t('brainstorm.working.incorporating', { doc: docNoun }) }}
338
344
  </span>
@@ -29,6 +29,8 @@ import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
29
29
  import StepRunMeta from '~/components/panels/StepRunMeta.vue'
30
30
  import MarkdownProse from '~/components/common/MarkdownProse.vue'
31
31
  import SectionLabel from '~/components/common/SectionLabel.vue'
32
+ import Spinner from '~/components/common/Spinner.vue'
33
+ import EmptyState from '~/components/common/EmptyState.vue'
32
34
 
33
35
  const execution = useExecutionStore()
34
36
  const board = useBoardStore()
@@ -304,11 +306,12 @@ const PHASE_ICON: Record<string, string> = {
304
306
  :data-testid="`bug-fishing-phase-${phase.id}`"
305
307
  @click="selectedPassKey = passKey(phase)"
306
308
  >
307
- <UIcon
309
+ <Spinner
308
310
  :name="PHASE_ICON[phase.status] ?? 'i-lucide-circle-dashed'"
311
+ :spinning="phase.status === 'fishing'"
309
312
  class="mt-0.5 h-3.5 w-3.5 shrink-0"
310
313
  :class="{
311
- 'animate-spin text-app-info-300': phase.status === 'fishing',
314
+ 'text-app-info-300': phase.status === 'fishing',
312
315
  'text-app-success-400': phase.status === 'completed',
313
316
  'text-app-warning-400': phase.status === 'failed',
314
317
  'text-app-600': phase.status === 'pending',
@@ -379,7 +382,7 @@ const PHASE_ICON: Record<string, string> = {
379
382
  data-testid="bug-fishing-in-progress"
380
383
  class="mb-4 flex items-center gap-2 rounded-lg border border-app-info-500/25 bg-app-info-500/5 px-3 py-2 text-xs text-app-info-200"
381
384
  >
382
- <UIcon name="i-lucide-loader-circle" class="h-4 w-4 shrink-0 animate-spin" />
385
+ <Spinner class="h-4 w-4 shrink-0" />
383
386
  <span>
384
387
  {{
385
388
  t('bugFishing.stillFishing', {
@@ -459,20 +462,19 @@ const PHASE_ICON: Record<string, string> = {
459
462
  {{ bugFishing.error }}
460
463
  </p>
461
464
 
462
- <!-- An expedition that caught nothing is a real answer, not an empty state. The copy
463
- says which of the two it is, because "nothing found" and "nothing left to triage"
464
- are different things to be told. -->
465
- <div
465
+ <!-- An expedition that caught nothing is a real answer. The copy says which of the two it
466
+ is, because "nothing found" and "nothing left to triage" are different things to be
467
+ told. -->
468
+ <EmptyState
466
469
  v-if="visibleFindings.length === 0"
467
470
  data-testid="bug-fishing-empty"
468
- class="rounded-lg border border-default bg-default/40 px-4 py-8 text-center text-xs text-muted"
469
- >
470
- {{
471
+ :icon="findings.length === 0 ? 'i-lucide-fish' : 'i-lucide-check-check'"
472
+ :title="
471
473
  findings.length === 0
472
474
  ? t('bugFishing.empty.nothingCaught')
473
475
  : t('bugFishing.empty.allTriaged')
474
- }}
475
- </div>
476
+ "
477
+ />
476
478
 
477
479
  <ul v-else class="space-y-2">
478
480
  <li
@@ -591,7 +593,7 @@ const PHASE_ICON: Record<string, string> = {
591
593
  data-testid="bug-fishing-finding-spawning"
592
594
  class="mt-2 flex flex-wrap items-center gap-2 text-2xs text-muted"
593
595
  >
594
- <UIcon name="i-lucide-loader-circle" class="h-3.5 w-3.5 animate-spin" />
596
+ <Spinner class="h-3.5 w-3.5" />
595
597
  <span>{{ t('bugFishing.finding.spawning') }}</span>
596
598
  </div>
597
599