@cat-factory/app 0.311.1 → 0.311.3

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 (195) hide show
  1. package/README.md +70 -5
  2. package/app/app.config.ts +6 -18
  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/bootstrap/AdoptionReviewModal.vue +4 -7
  17. package/app/components/bootstrap/BootstrapModal.vue +6 -15
  18. package/app/components/bootstrap/BootstrapRunSteps.vue +3 -1
  19. package/app/components/brainstorm/BrainstormWindow.vue +32 -26
  20. package/app/components/bugFishing/BugFishingWindow.vue +15 -13
  21. package/app/components/clarity/ClarityReviewWindow.vue +31 -26
  22. package/app/components/common/AsyncViewError.vue +1 -1
  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 +15 -9
  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 +3 -11
  75. package/app/components/layout/CommandBar.vue +7 -3
  76. package/app/components/layout/ConnectionStatusBanner.vue +2 -1
  77. package/app/components/layout/IntegrationsHub.vue +7 -3
  78. package/app/components/layout/ModelProvidersHub.vue +7 -3
  79. package/app/components/layout/NotificationsInbox.vue +3 -11
  80. package/app/components/layout/SideBar.vue +1 -1
  81. package/app/components/layout/WorkspaceMembersSettings.vue +12 -5
  82. package/app/components/media/ArtifactLightbox.vue +4 -3
  83. package/app/components/notifications/NotificationSettingsPanel.vue +4 -11
  84. package/app/components/observability/ToolCallList.vue +12 -17
  85. package/app/components/panels/AgentStepDetail.vue +5 -5
  86. package/app/components/panels/GenericStructuredResultView.vue +6 -9
  87. package/app/components/panels/InspectorPanel.vue +2 -1
  88. package/app/components/panels/MergerResultView.vue +5 -6
  89. package/app/components/panels/ObservabilityPanel.vue +26 -41
  90. package/app/components/panels/OperatorDashboardPanel.vue +29 -17
  91. package/app/components/panels/ReportsPanel.vue +17 -11
  92. package/app/components/panels/ReportsSpendBreakdown.vue +7 -3
  93. package/app/components/panels/ResultWindowShell.vue +1 -1
  94. package/app/components/panels/RunDetailLoadState.vue +4 -3
  95. package/app/components/panels/StepContainerStatus.vue +4 -3
  96. package/app/components/panels/StepDelegatedStatus.vue +3 -2
  97. package/app/components/panels/StepMetadataCard.vue +6 -8
  98. package/app/components/panels/StepRunMeta.vue +2 -5
  99. package/app/components/panels/inspector/ContainerSummary.vue +7 -1
  100. package/app/components/panels/inspector/EpicChildren.vue +7 -3
  101. package/app/components/panels/inspector/FrontendConfig.vue +7 -3
  102. package/app/components/panels/inspector/InitiativeInspector.vue +5 -2
  103. package/app/components/panels/inspector/ServiceConnections.vue +7 -3
  104. package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +2 -6
  105. package/app/components/panels/inspector/ServiceTestSecrets.vue +2 -6
  106. package/app/components/panels/inspector/ServiceTestingContext.vue +2 -6
  107. package/app/components/panels/inspector/ServiceValidationConfig.vue +38 -39
  108. package/app/components/panels/inspector/TaskExecution.vue +5 -6
  109. package/app/components/panels/inspector/TaskRunSettings.vue +7 -3
  110. package/app/components/panels/inspector/TaskStructure.vue +0 -1
  111. package/app/components/pipeline/AgentPromptEditor.vue +25 -18
  112. package/app/components/pipeline/PipelineBuilder.vue +14 -24
  113. package/app/components/pipeline/PipelineProgress.vue +16 -17
  114. package/app/components/prReview/PrReviewPhaseBadge.vue +3 -2
  115. package/app/components/prReview/PrReviewWindow.vue +16 -20
  116. package/app/components/providers/ApiKeysSection.vue +2 -6
  117. package/app/components/providers/PersonalCredentialModal.vue +2 -6
  118. package/app/components/providers/PersonalSubscriptionSection.vue +3 -7
  119. package/app/components/providers/VendorCredentialsModal.vue +2 -6
  120. package/app/components/provisioning/ProvisioningLogsDrawer.vue +10 -6
  121. package/app/components/ralph/RalphLoopResultView.vue +2 -7
  122. package/app/components/requirements/RequirementsReviewWindow.vue +44 -39
  123. package/app/components/sandbox/SandboxPanel.vue +37 -25
  124. package/app/components/settings/AccountSettingsPanel.vue +6 -3
  125. package/app/components/settings/ApiTokensPanel.vue +2 -6
  126. package/app/components/settings/BudgetSettings.vue +2 -2
  127. package/app/components/settings/CapabilityCredentialsPanel.vue +2 -6
  128. package/app/components/settings/CloudflareHandlerSection.vue +3 -7
  129. package/app/components/settings/ConsensusGroupsSection.vue +7 -3
  130. package/app/components/settings/CustomManifestTypeEditor.vue +7 -3
  131. package/app/components/settings/DefaultProvisionTypeSection.vue +2 -2
  132. package/app/components/settings/InfraHandlersConfigurator.vue +8 -11
  133. package/app/components/settings/IssueTrackerPanel.vue +9 -9
  134. package/app/components/settings/LocalContainerPoolSettings.vue +2 -6
  135. package/app/components/settings/LocalModelEndpointsPanel.vue +5 -7
  136. package/app/components/settings/McpAuthorizeScreen.vue +8 -3
  137. package/app/components/settings/McpOAuthCallbackScreen.vue +2 -1
  138. package/app/components/settings/ModelConfigurationPanel.vue +45 -17
  139. package/app/components/settings/ObservabilityConnectionPanel.vue +4 -15
  140. package/app/components/settings/OpenRouterCatalogPanel.vue +8 -24
  141. package/app/components/settings/PackageRegistriesPanel.vue +2 -6
  142. package/app/components/settings/ProviderConnectionTab.vue +3 -7
  143. package/app/components/settings/RiskPolicyPanel.vue +4 -16
  144. package/app/components/settings/SharedStacksPanel.vue +8 -18
  145. package/app/components/settings/TaskTypeSuppressionsPanel.vue +10 -6
  146. package/app/components/settings/UsageSettings.vue +11 -2
  147. package/app/components/settings/UserSecretsSection.vue +5 -7
  148. package/app/components/settings/WorkspaceMetadataSettings.vue +9 -9
  149. package/app/components/settings/WorkspaceSettingsPanel.vue +3 -11
  150. package/app/components/skills/ReviewSkillQueue.vue +7 -3
  151. package/app/components/skills/SkillLibraryManager.vue +20 -18
  152. package/app/components/slack/SlackPanel.vue +5 -8
  153. package/app/components/spec/ServiceSpecWindow.vue +32 -29
  154. package/app/components/tasks/BugHuntModal.vue +23 -19
  155. package/app/components/tasks/ContextIssuePicker.vue +2 -2
  156. package/app/components/tasks/TaskContextIssues.vue +4 -5
  157. package/app/components/tasks/TaskImportModal.vue +10 -8
  158. package/app/components/tasks/TaskSourceConnectModal.vue +11 -11
  159. package/app/components/testing/TestReportWindow.vue +6 -12
  160. package/app/components/tutorial/TutorialCatalogue.vue +7 -3
  161. package/app/components/tutorial/TutorialOverlay.vue +2 -1
  162. package/app/components/tutorial/TutorialPrompt.vue +7 -3
  163. package/app/components/vcs/GitLabConnect.vue +2 -6
  164. package/app/components/visualConfirm/VisualConfirmationWindow.vue +9 -4
  165. package/app/composables/toastTitleKeys.spec.ts +143 -0
  166. package/app/composables/useActionToast.spec.ts +71 -0
  167. package/app/composables/useActionToast.ts +101 -0
  168. package/app/composables/useBlockDeletion.ts +3 -6
  169. package/app/composables/useConfirmAction.ts +5 -3
  170. package/app/composables/useContextLinking.spec.ts +3 -0
  171. package/app/composables/useContextLinking.ts +11 -16
  172. package/app/composables/useCopyToClipboard.ts +4 -3
  173. package/app/composables/useNavContributions.ts +4 -5
  174. package/app/composables/usePipelineErrorToast.spec.ts +24 -0
  175. package/app/composables/usePipelineErrorToast.ts +26 -2
  176. package/app/composables/usePipelineLibraryActions.ts +8 -9
  177. package/app/pages/index.vue +3 -2
  178. package/app/stores/board/context.ts +4 -3
  179. package/app/stores/board/placement.ts +8 -20
  180. package/app/stores/board/removal.ts +4 -13
  181. package/app/stores/board.spec.ts +10 -1
  182. package/app/stores/board.ts +3 -2
  183. package/app/utils/bootstrapSteps.ts +11 -2
  184. package/app/utils/pipelineRender.ts +21 -8
  185. package/i18n/locales/de.json +12 -30
  186. package/i18n/locales/en.json +12 -30
  187. package/i18n/locales/es.json +12 -30
  188. package/i18n/locales/fr.json +12 -30
  189. package/i18n/locales/he.json +12 -30
  190. package/i18n/locales/it.json +12 -30
  191. package/i18n/locales/ja.json +12 -30
  192. package/i18n/locales/pl.json +12 -30
  193. package/i18n/locales/tr.json +12 -30
  194. package/i18n/locales/uk.json +12 -30
  195. package/package.json +1 -1
@@ -32,7 +32,7 @@ const store = useWorkspaceSettingsStore()
32
32
  const workspace = useWorkspaceStore()
33
33
  const access = useWorkspaceAccess()
34
34
  const pipelines = usePipelinesStore()
35
- const toast = useToast()
35
+ const actionToast = useActionToast()
36
36
  const { present } = usePipelineErrorToast()
37
37
  const slots = useReactiveSlots<AppSlots>()
38
38
  // Whether the deployment registers any reusable operation at all, hidden or not, so the
@@ -283,11 +283,7 @@ async function save() {
283
283
  // Mirror the backend's enforce-mode validation client-side so the user gets an immediate,
284
284
  // localized message instead of the raw 422 (enforce needs at least one hard trigger).
285
285
  if (draft.reviewFrictionMode === 'enforce' && blockCount == null && blockStuckMinutes == null) {
286
- toast.add({
287
- title: t('settings.workspaceSettings.reviewFriction.needsTrigger'),
288
- icon: 'i-lucide-triangle-alert',
289
- color: 'warning',
290
- })
286
+ actionToast.error('settings.workspaceSettings.reviewFriction.needsTrigger')
291
287
  return
292
288
  }
293
289
  saving.value = true
@@ -322,11 +318,7 @@ async function save() {
322
318
  reviewFrictionBlockCount: blockCount,
323
319
  reviewFrictionBlockStuckMinutes: blockStuckMinutes,
324
320
  })
325
- toast.add({
326
- title: t('settings.workspaceSettings.toast.saved'),
327
- icon: 'i-lucide-check',
328
- color: 'success',
329
- })
321
+ actionToast.success('settings.workspaceSettings.toast.saved')
330
322
  } catch (e) {
331
323
  present(e, 'settings.workspaceSettings.toast.saveFailed')
332
324
  } finally {
@@ -18,6 +18,7 @@ import { computed, ref } from 'vue'
18
18
  import { MAX_REVIEW_SKILLS } from '@cat-factory/contracts'
19
19
  import { useSkillsStore } from '~/stores/skills'
20
20
  import SectionLabel from '~/components/common/SectionLabel.vue'
21
+ import EmptyState from '~/components/common/EmptyState.vue'
21
22
 
22
23
  const props = defineProps<{
23
24
  /** The queued skill ids, in the order the reviewer applies them (`v-model`). */
@@ -109,9 +110,12 @@ function toggle(id: string) {
109
110
  </span>
110
111
  </UButton>
111
112
  </template>
112
- <p v-else class="px-2 py-3 text-xs text-dimmed">
113
- {{ t('skills.reviewQueue.pickerEmpty') }}
114
- </p>
113
+ <EmptyState
114
+ v-else
115
+ compact
116
+ icon="i-lucide-book-open-check"
117
+ :title="t('skills.reviewQueue.pickerEmpty')"
118
+ />
115
119
  </div>
116
120
 
117
121
  <p
@@ -13,12 +13,13 @@ import { SKILL_GROUP_LABEL_KEYS } from '~/utils/skills'
13
13
  import GitHubRepoSearchSelect from '~/components/github/GitHubRepoSearchSelect.vue'
14
14
  import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
15
15
  import IconButton from '~/components/common/IconButton.vue'
16
+ import EmptyState from '~/components/common/EmptyState.vue'
16
17
 
17
18
  const props = defineProps<{ accountId: string }>()
18
19
 
19
20
  const library = useSkillLibrary(props.accountId)
20
21
  const github = useGitHubStore()
21
- const toast = useToast()
22
+ const actionToast = useActionToast()
22
23
  const { present } = usePipelineErrorToast()
23
24
  const { t, d } = useI18n()
24
25
  const { confirm } = useConfirm()
@@ -85,7 +86,7 @@ async function linkSource() {
85
86
  gitRef: sourceRef.value.trim() || undefined,
86
87
  })
87
88
  resetSourceDraft()
88
- toast.add({ title: t('skills.toast.sourceLinked'), icon: 'i-lucide-git-branch' })
89
+ actionToast.success('skills.toast.sourceLinked')
89
90
  } catch (e) {
90
91
  present(e, 'skills.toast.linkSourceFailed')
91
92
  } finally {
@@ -97,13 +98,11 @@ async function syncSource(id: string) {
97
98
  await withRow(`sync:${id}`, async () => {
98
99
  try {
99
100
  const result = await library.syncSource(id)
100
- toast.add({
101
- title: t('skills.toast.synced', {
101
+ actionToast.success('skills.toast.synced', {
102
+ params: {
102
103
  updated: result.upserted,
103
104
  removed: result.tombstoned,
104
- }),
105
- icon: 'i-lucide-refresh-cw',
106
- color: 'info',
105
+ },
107
106
  })
108
107
  } catch (e) {
109
108
  present(e, 'skills.toast.syncFailed')
@@ -115,10 +114,7 @@ async function checkSource(id: string) {
115
114
  await withRow(`check:${id}`, async () => {
116
115
  try {
117
116
  const status = await library.checkSource(id)
118
- toast.add({
119
- title: status.changed ? t('skills.toast.changesAvailable') : t('skills.toast.upToDate'),
120
- icon: status.changed ? 'i-lucide-bell-dot' : 'i-lucide-check',
121
- })
117
+ actionToast.info(status.changed ? 'skills.toast.changesAvailable' : 'skills.toast.upToDate')
122
118
  } catch (e) {
123
119
  present(e, 'skills.toast.checkSourceFailed')
124
120
  }
@@ -139,7 +135,7 @@ async function unlinkSource(id: string) {
139
135
  await withRow(`unlink:${id}`, async () => {
140
136
  try {
141
137
  await library.unlinkSource(id)
142
- toast.add({ title: t('skills.toast.sourceUnlinked'), icon: 'i-lucide-unplug' })
138
+ actionToast.success('skills.toast.sourceUnlinked')
143
139
  } catch (e) {
144
140
  present(e, 'skills.toast.unlinkSourceFailed')
145
141
  }
@@ -195,9 +191,12 @@ async function unlinkSource(id: string) {
195
191
  </p>
196
192
  </div>
197
193
  </div>
198
- <p v-if="!library.catalog.length" class="text-sm text-dimmed">
199
- {{ t('skills.catalog.empty') }}
200
- </p>
194
+ <EmptyState
195
+ v-if="!library.catalog.length"
196
+ compact
197
+ icon="i-lucide-book-open-check"
198
+ :title="t('skills.catalog.empty')"
199
+ />
201
200
  </div>
202
201
 
203
202
  <!-- Repo sources -->
@@ -264,9 +263,12 @@ async function unlinkSource(id: string) {
264
263
  />
265
264
  </div>
266
265
  </div>
267
- <p v-if="!library.sources.length" class="text-sm text-dimmed">
268
- {{ t('skills.sources.empty') }}
269
- </p>
266
+ <EmptyState
267
+ v-if="!library.sources.length"
268
+ compact
269
+ icon="i-lucide-git-branch"
270
+ :title="t('skills.sources.empty')"
271
+ />
270
272
 
271
273
  <!-- Link a new source. Needs the GitHub integration; hide the form when it's off. -->
272
274
  <div
@@ -20,7 +20,7 @@ import SectionLabel from '~/components/common/SectionLabel.vue'
20
20
 
21
21
  const ui = useUiStore()
22
22
  const slack = useSlackStore()
23
- const toast = useToast()
23
+ const actionToast = useActionToast()
24
24
  const { present } = usePipelineErrorToast()
25
25
  const { t } = useI18n()
26
26
  const { confirm } = useConfirm()
@@ -134,7 +134,7 @@ async function connectWithToken() {
134
134
  try {
135
135
  await slack.connectWithToken(tokenInput.value.trim())
136
136
  tokenInput.value = ''
137
- toast.add({ title: t('slack.toast.connected'), icon: 'i-lucide-check', color: 'success' })
137
+ actionToast.success('slack.toast.connected')
138
138
  } catch (e) {
139
139
  present(e, 'slack.error.connect')
140
140
  }
@@ -163,7 +163,7 @@ async function saveRouting() {
163
163
  routes: { ...routes },
164
164
  mentionsEnabled: mentionsEnabled.value,
165
165
  })
166
- toast.add({ title: t('slack.toast.routingSaved'), icon: 'i-lucide-check', color: 'success' })
166
+ actionToast.success('slack.toast.routingSaved')
167
167
  } catch (e) {
168
168
  present(e, 'slack.error.saveRouting')
169
169
  } finally {
@@ -182,11 +182,8 @@ async function saveMapping() {
182
182
  // dropped on save (UX-23) — block instead so the user doesn't lose the entry. A
183
183
  // fully-empty row is just an unused slot and is ignored.
184
184
  if (hasHalfFilledRow(mapping.value)) {
185
- toast.add({
186
- title: t('slack.members.incompleteTitle'),
185
+ actionToast.error('slack.members.incompleteTitle', {
187
186
  description: t('slack.members.incompleteBody'),
188
- icon: 'i-lucide-triangle-alert',
189
- color: 'warning',
190
187
  })
191
188
  return
192
189
  }
@@ -195,7 +192,7 @@ async function saveMapping() {
195
192
  const entries = toMemberEntries(mapping.value)
196
193
  await slack.updateMemberMapping(entries)
197
194
  mapping.value = slack.memberMapping.map((e) => toMemberRow(e, nextUid()))
198
- toast.add({ title: t('slack.toast.mapSaved'), icon: 'i-lucide-check', color: 'success' })
195
+ actionToast.success('slack.toast.mapSaved')
199
196
  } catch (e) {
200
197
  present(e, 'slack.error.saveMap')
201
198
  } finally {
@@ -23,6 +23,7 @@ import {
23
23
  } from './ServiceSpecWindow.logic'
24
24
  import type { BadgeColor } from '~/utils/badge'
25
25
  import SectionLabel from '~/components/common/SectionLabel.vue'
26
+ import EmptyState from '~/components/common/EmptyState.vue'
26
27
 
27
28
  const { t } = useI18n()
28
29
  const board = useBoardStore()
@@ -225,12 +226,14 @@ function kindLabel(item: RequirementItem): string {
225
226
  </template>
226
227
 
227
228
  <!-- loading -->
228
- <div
229
- v-if="loading && !view"
230
- class="flex flex-1 items-center justify-center gap-2 text-sm text-muted"
231
- >
232
- <UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
233
- {{ t('spec.loading') }}
229
+ <div v-if="loading && !view" class="flex min-h-0 flex-1">
230
+ <div class="w-64 shrink-0 space-y-2 border-e border-default px-3 py-4">
231
+ <USkeleton v-for="n in 6" :key="n" class="h-6 w-full" />
232
+ </div>
233
+ <div class="flex-1 space-y-3 px-6 py-4">
234
+ <USkeleton class="h-6 w-1/3" />
235
+ <USkeleton v-for="n in 4" :key="n" class="h-4 w-full" />
236
+ </div>
234
237
  </div>
235
238
 
236
239
  <!-- error -->
@@ -253,18 +256,13 @@ function kindLabel(item: RequirementItem): string {
253
256
  </div>
254
257
 
255
258
  <!-- empty: no spec on the repo's default branch yet -->
256
- <div
259
+ <EmptyState
257
260
  v-else-if="!present"
258
- class="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center"
259
- >
260
- <UIcon name="i-lucide-scroll-text" class="h-8 w-8 text-app-600" />
261
- <div>
262
- <p class="text-sm font-medium text-toned">{{ t('spec.empty.title') }}</p>
263
- <p class="mx-auto mt-1 max-w-md text-xs text-dimmed">
264
- {{ t('spec.empty.description') }}
265
- </p>
266
- </div>
267
- </div>
261
+ class="flex-1"
262
+ icon="i-lucide-scroll-text"
263
+ :title="t('spec.empty.title')"
264
+ :description="t('spec.empty.description')"
265
+ />
268
266
 
269
267
  <!-- spec body: navigable tree + detail -->
270
268
  <div v-else class="flex min-h-0 flex-1">
@@ -368,19 +366,22 @@ function kindLabel(item: RequirementItem): string {
368
366
  v-if="selectedFeature"
369
367
  class="mt-4 overflow-x-auto rounded-lg border border-default bg-app-950/60 p-4 text-xs leading-relaxed text-default"
370
368
  ><code>{{ selectedFeature.content }}</code></pre>
371
- <div
369
+ <EmptyState
372
370
  v-else
373
- class="mt-4 rounded-lg border border-dashed border-muted p-6 text-center text-sm text-dimmed"
374
- >
375
- {{ t('spec.noGherkinForGroup') }}
376
- </div>
371
+ class="mt-4"
372
+ icon="i-lucide-file-code"
373
+ :title="t('spec.noGherkinForGroup')"
374
+ />
377
375
  </template>
378
376
 
379
377
  <!-- STRUCTURED view: requirements + acceptance + domain rules -->
380
378
  <template v-else>
381
- <div v-if="reqCount(selectedGroup) === 0" class="mt-4 text-sm text-dimmed">
382
- {{ t('spec.noRequirements') }}
383
- </div>
379
+ <EmptyState
380
+ v-if="reqCount(selectedGroup) === 0"
381
+ class="mt-4"
382
+ icon="i-lucide-list-checks"
383
+ :title="t('spec.noRequirements')"
384
+ />
384
385
  <!-- per-group implementation-state rollup + the filter over the two halves -->
385
386
  <div
386
387
  v-else
@@ -421,12 +422,14 @@ function kindLabel(item: RequirementItem): string {
421
422
  rendering a blank pane that reads like "no requirements". The filter is sticky
422
423
  across groups, so the reader may have set it several groups ago — offer the way
423
424
  back here rather than making them find the toggle again. -->
424
- <div
425
+ <EmptyState
425
426
  v-if="reqCount(selectedGroup) > 0 && visibleRequirements.length === 0"
426
- class="mt-4 flex flex-wrap items-center gap-2 text-sm text-dimmed"
427
+ class="mt-4"
428
+ compact
429
+ icon="i-lucide-filter-x"
430
+ :title="t('spec.state.noneMatchFilter')"
427
431
  data-testid="spec-state-filter-empty"
428
432
  >
429
- {{ t('spec.state.noneMatchFilter') }}
430
433
  <UButton
431
434
  variant="link"
432
435
  size="xs"
@@ -440,7 +443,7 @@ function kindLabel(item: RequirementItem): string {
440
443
  >
441
444
  {{ t('spec.state.showAll') }}
442
445
  </UButton>
443
- </div>
446
+ </EmptyState>
444
447
  <ul class="mt-4 space-y-4">
445
448
  <li
446
449
  v-for="req in visibleRequirements"
@@ -43,6 +43,7 @@ import {
43
43
  sourceMenuItems,
44
44
  } from '~/utils/sourcePicker'
45
45
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
46
+ import EmptyState from '~/components/common/EmptyState.vue'
46
47
  import { appliesIntakePredicate } from '~/utils/intakePredicates'
47
48
  import { boardFromService as isBoardFromService, huntRequest } from './BugHuntModal.logic'
48
49
 
@@ -52,8 +53,8 @@ const tasks = useTasksStore()
52
53
  const hunt = useBugHuntStore()
53
54
  const board = useBoardStore()
54
55
  const github = useGitHubStore()
55
- const toast = useToast()
56
- const { present } = usePipelineErrorToast()
56
+ const actionToast = useActionToast()
57
+ const { present, presentReported } = usePipelineErrorToast()
57
58
 
58
59
  const open = computed({
59
60
  get: () => ui.bugHunt !== null,
@@ -195,8 +196,12 @@ const REFUSAL_KEYS: Partial<Record<TaskSourceReadReason, string>> = {
195
196
  board_from_service: 'bugHunt.refusal.boardFromService',
196
197
  missing_board: 'bugHunt.refusal.missingBoard',
197
198
  }
199
+ /** The translated key for a refusal reason this modal recognises, or undefined. */
200
+ function refusalKey(reason: string | null): string | undefined {
201
+ return reason ? REFUSAL_KEYS[reason as TaskSourceReadReason] : undefined
202
+ }
198
203
  function refusalText(reason: string | null, fallback: string | null): string | null {
199
- const key = reason ? REFUSAL_KEYS[reason as TaskSourceReadReason] : undefined
204
+ const key = refusalKey(reason)
200
205
  return key ? t(key) : fallback
201
206
  }
202
207
 
@@ -278,12 +283,11 @@ async function runHunt() {
278
283
  // `repo_not_linked` is worded by the panel itself beside the scope it invalidates.
279
284
  const attempt = await hunt.hunt(source.value, input)
280
285
  if (attempt === 'failed' && !huntNeedsRepo.value) {
281
- toast.add({
282
- title: t('bugHunt.huntFailed'),
283
- description: refusalText(hunt.huntErrorReason, hunt.huntError) ?? undefined,
284
- icon: 'i-lucide-triangle-alert',
285
- color: 'error',
286
- })
286
+ // A recognised refusal reads in translated copy; anything else is the backend's prose, which
287
+ // is detail behind the disclosure rather than the headline.
288
+ const reasonKey = refusalKey(hunt.huntErrorReason)
289
+ if (reasonKey) actionToast.error('bugHunt.huntFailed', { description: t(reasonKey) })
290
+ else presentReported(hunt.huntError, 'bugHunt.huntFailed')
287
291
  }
288
292
  }
289
293
 
@@ -294,10 +298,9 @@ async function adopt(candidate: BugHuntCandidate) {
294
298
  if (!blockId) return // the user cancelled the personal-credential prompt
295
299
  ui.closeBugHunt()
296
300
  ui.select(blockId)
297
- toast.add({
298
- title: t('bugHunt.adopted', { id: candidate.externalId }),
301
+ actionToast.success('bugHunt.adopted', {
302
+ params: { id: candidate.externalId },
299
303
  description: t('bugHunt.adoptedRunning'),
300
- icon: 'i-lucide-bug-play',
301
304
  })
302
305
  } catch (e) {
303
306
  present(e, 'bugHunt.adoptFailed')
@@ -338,9 +341,7 @@ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
338
341
  </template>
339
342
  <template #body>
340
343
  <!-- No tracker offered (none connected/installed, or all disabled). -->
341
- <div v-if="!tasks.anyOffered" class="space-y-3 text-center">
342
- <UIcon name="i-lucide-plug" class="mx-auto h-8 w-8 text-dimmed" />
343
- <p class="text-sm text-muted">{{ t('bugHunt.connectFirst') }}</p>
344
+ <EmptyState v-if="!tasks.anyOffered" icon="i-lucide-plug" :title="t('bugHunt.connectFirst')">
344
345
  <div class="flex flex-wrap justify-center gap-2">
345
346
  <UButton
346
347
  v-for="choice in addableSources"
@@ -352,7 +353,7 @@ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
352
353
  {{ ADD_LABEL[choice.action](choice.label) }}
353
354
  </UButton>
354
355
  </div>
355
- </div>
356
+ </EmptyState>
356
357
 
357
358
  <!-- No service frame yet → nowhere for an adopted bug to land. -->
358
359
  <p v-else-if="!containerItems.length" class="text-center text-xs text-dimmed">
@@ -576,9 +577,12 @@ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
576
577
  </div>
577
578
  </div>
578
579
 
579
- <p v-if="!hunt.candidates.length" class="text-center text-xs text-dimmed">
580
- {{ t('bugHunt.noCandidates') }}
581
- </p>
580
+ <EmptyState
581
+ v-if="!hunt.candidates.length"
582
+ compact
583
+ icon="i-lucide-bug-off"
584
+ :title="t('bugHunt.noCandidates')"
585
+ />
582
586
  </div>
583
587
  </div>
584
588
  </template>
@@ -321,8 +321,8 @@ onMounted(() => {
321
321
 
322
322
  <UInput
323
323
  v-model="query"
324
- :icon="searching ? 'i-lucide-loader-circle' : 'i-lucide-search'"
325
- :ui="{ leadingIcon: searching ? 'animate-spin' : '' }"
324
+ icon="i-lucide-search"
325
+ :loading="searching"
326
326
  size="sm"
327
327
  class="w-full"
328
328
  :placeholder="
@@ -18,13 +18,14 @@ import type { DropdownMenuItem } from '@nuxt/ui'
18
18
  import type { Block } from '~/types/domain'
19
19
  import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
20
20
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
21
+ import EmptyState from '~/components/common/EmptyState.vue'
21
22
 
22
23
  const props = defineProps<{ block: Block }>()
23
24
 
24
25
  const { t } = useI18n()
25
26
  const tasks = useTasksStore()
26
27
  const ui = useUiStore()
27
- const toast = useToast()
28
+ const actionToast = useActionToast()
28
29
  const { linkPending, presentLinkFailures } = useContextLinking()
29
30
 
30
31
  onMounted(() => {
@@ -78,7 +79,7 @@ async function attach(item: PendingContext) {
78
79
  try {
79
80
  const failures = await linkPending(props.block.id, [item])
80
81
  if (failures.length) presentLinkFailures(failures, props.block.id)
81
- else toast.add({ title: t('tasks.contextIssues.attached'), icon: 'i-lucide-link' })
82
+ else actionToast.success('tasks.contextIssues.attached')
82
83
  } finally {
83
84
  linking.value = false
84
85
  }
@@ -151,8 +152,6 @@ async function attach(item: PendingContext) {
151
152
  </UBadge>
152
153
  </ULink>
153
154
  </div>
154
- <p v-else class="text-2xs text-dimmed">
155
- {{ emptyHint }}
156
- </p>
155
+ <EmptyState v-else compact icon="i-lucide-ticket" :title="emptyHint" />
157
156
  </InspectorSection>
158
157
  </template>
@@ -18,11 +18,12 @@ import { type AddSourceLabels, addChoicesOf, buildSourceChoices } from '~/utils/
18
18
  import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
19
19
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
20
20
  import SectionLabel from '~/components/common/SectionLabel.vue'
21
+ import EmptyState from '~/components/common/EmptyState.vue'
21
22
 
22
23
  const { t } = useI18n()
23
24
  const ui = useUiStore()
24
25
  const tasks = useTasksStore()
25
- const toast = useToast()
26
+ const actionToast = useActionToast()
26
27
  const { present } = usePipelineErrorToast()
27
28
 
28
29
  const open = computed({
@@ -106,10 +107,9 @@ async function doSpawnEpic() {
106
107
  ref_.value = ''
107
108
  ui.closeTaskImport()
108
109
  ui.select(epic.id)
109
- toast.add({
110
- title: t('tasks.import.epicSpawned', { title: epic.title }),
110
+ actionToast.success('tasks.import.epicSpawned', {
111
+ params: { title: epic.title },
111
112
  description: t('tasks.import.epicChildren', { count: spawned.length }, spawned.length),
112
- icon: 'i-lucide-layers',
113
113
  })
114
114
  } catch (e) {
115
115
  present(e, 'tasks.import.epicFailed')
@@ -126,9 +126,11 @@ async function doSpawnEpic() {
126
126
  </template>
127
127
  <template #body>
128
128
  <!-- Empty state: no source offered (none connected/installed, or all disabled) -->
129
- <div v-if="!tasks.anyOffered" class="space-y-3 text-center">
130
- <UIcon name="i-lucide-plug" class="mx-auto h-8 w-8 text-dimmed" />
131
- <p class="text-sm text-muted">{{ t('tasks.import.connectFirst') }}</p>
129
+ <EmptyState
130
+ v-if="!tasks.anyOffered"
131
+ icon="i-lucide-plug"
132
+ :title="t('tasks.import.connectFirst')"
133
+ >
132
134
  <div class="flex justify-center gap-2">
133
135
  <UButton
134
136
  v-for="choice in addableSources"
@@ -140,7 +142,7 @@ async function doSpawnEpic() {
140
142
  {{ ADD_LABEL[choice.action](choice.label) }}
141
143
  </UButton>
142
144
  </div>
143
- </div>
145
+ </EmptyState>
144
146
 
145
147
  <!-- No service frame yet → nowhere to create a task. -->
146
148
  <p v-else-if="!containerItems.length" class="text-center text-xs text-dimmed">
@@ -16,7 +16,7 @@ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
16
16
  const { t } = useI18n()
17
17
  const ui = useUiStore()
18
18
  const tasks = useTasksStore()
19
- const toast = useToast()
19
+ const actionToast = useActionToast()
20
20
  const { present } = usePipelineErrorToast()
21
21
  const { confirmAction } = useConfirmAction()
22
22
 
@@ -63,10 +63,8 @@ async function submit() {
63
63
  saving.value = true
64
64
  try {
65
65
  await tasks.connect(source.value, credentials)
66
- toast.add({
67
- title: t('tasks.connect.connectedToast', { label: descriptor.value!.label }),
68
- icon: 'i-lucide-check',
69
- color: 'success',
66
+ actionToast.success('tasks.connect.connectedToast', {
67
+ params: { label: descriptor.value!.label },
70
68
  })
71
69
  // Re-probe so `available`/`enabled` reflect the new connection.
72
70
  await tasks.probe()
@@ -93,12 +91,14 @@ async function disconnect() {
93
91
  if (!source.value) return
94
92
  const label = descriptor.value?.label ?? t('tasks.connect.sourceFallback')
95
93
  if (!(await confirmAction('disconnect', label))) return
96
- await tasks.disconnect(source.value)
97
- await tasks.probe()
98
- toast.add({
99
- title: t('tasks.connect.disconnectedToast', { label }),
100
- icon: 'i-lucide-unplug',
101
- })
94
+ try {
95
+ await tasks.disconnect(source.value)
96
+ await tasks.probe()
97
+ } catch (e) {
98
+ present(e, 'tasks.connect.disconnectFailed')
99
+ return
100
+ }
101
+ actionToast.success('tasks.connect.disconnectedToast', { params: { label } })
102
102
  ui.closeTaskConnect()
103
103
  }
104
104
 
@@ -789,15 +789,12 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
789
789
  class="h-full w-full object-cover object-top"
790
790
  />
791
791
  <span
792
- v-else
792
+ v-else-if="blobs.statusFor(s.artifactId) === 'error'"
793
793
  class="flex h-full w-full items-center justify-center text-3xs text-app-600"
794
794
  >
795
- {{
796
- blobs.statusFor(s.artifactId) === 'error'
797
- ? t('testing.shot.failed')
798
- : t('testing.shot.loading')
799
- }}
795
+ {{ t('testing.shot.failed') }}
800
796
  </span>
797
+ <USkeleton v-else class="h-full w-full rounded-none" />
801
798
  <span
802
799
  class="absolute inset-x-0 bottom-0 truncate bg-app-950/80 px-1 py-0.5 text-3xs text-toned"
803
800
  >{{ s.view }}</span
@@ -830,15 +827,12 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
830
827
  class="h-full w-full object-cover object-top"
831
828
  />
832
829
  <span
833
- v-else
830
+ v-else-if="blobs.statusFor(s.artifactId) === 'error'"
834
831
  class="flex h-full w-full items-center justify-center text-2xs text-app-600"
835
832
  >
836
- {{
837
- blobs.statusFor(s.artifactId) === 'error'
838
- ? t('testing.shot.failedToLoad')
839
- : t('testing.shot.loading')
840
- }}
833
+ {{ t('testing.shot.failedToLoad') }}
841
834
  </span>
835
+ <USkeleton v-else class="h-full w-full rounded-none" />
842
836
  <span
843
837
  class="absolute inset-x-0 bottom-0 truncate bg-app-950/80 px-1.5 py-0.5 text-3xs text-toned"
844
838
  >{{ s.view }}</span
@@ -13,6 +13,7 @@
13
13
  import { buildCatalogueRows, summarizeProgress } from './TutorialCatalogue.logic'
14
14
  import type { TutorialCatalogueRow } from './TutorialCatalogue.logic'
15
15
  import { TUTORIAL_ACTION_KEYS, TUTORIAL_STATUS_KEYS } from '~/utils/tutorial'
16
+ import EmptyState from '~/components/common/EmptyState.vue'
16
17
 
17
18
  const { t } = useI18n()
18
19
  const tutorial = useTutorialStore()
@@ -129,9 +130,12 @@ const statusColor = (row: TutorialCatalogueRow) =>
129
130
  </ul>
130
131
  <!-- No tours at all is a real state (a deployment may register none of its own and
131
132
  strip the built-ins), and it is not the same as one whose tours are all blocked. -->
132
- <p v-if="rows.length === 0" class="text-sm text-muted">
133
- {{ t('tutorial.catalogue.empty') }}
134
- </p>
133
+ <EmptyState
134
+ v-if="rows.length === 0"
135
+ compact
136
+ icon="i-lucide-graduation-cap"
137
+ :title="t('tutorial.catalogue.empty')"
138
+ />
135
139
  </div>
136
140
  </template>
137
141
  <template #footer>
@@ -21,6 +21,7 @@ import {
21
21
  } from './TutorialOverlay.logic'
22
22
  import type { TutorialAdvanceCause, TutorialDirection } from './TutorialOverlay.logic'
23
23
  import SectionLabel from '~/components/common/SectionLabel.vue'
24
+ import Spinner from '~/components/common/Spinner.vue'
24
25
 
25
26
  // The one shared tour runtime: resolves the running tour from the `tutorialTours` slot,
26
27
  // anchors a highlight ring + tooltip to the current step's `data-testid`, and advances
@@ -593,7 +594,7 @@ onUnmounted(() => {
593
594
  class="mt-2 flex items-center gap-1.5 text-xs text-muted"
594
595
  data-testid="tutorial-searching"
595
596
  >
596
- <UIcon name="i-lucide-loader" class="h-3.5 w-3.5 motion-safe:animate-spin" />
597
+ <Spinner class="h-3.5 w-3.5" />
597
598
  {{ t('tutorial.overlay.searching') }}
598
599
  </p>
599
600
  <p