@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
@@ -20,11 +20,13 @@ import type {
20
20
  ReviewItemStatus,
21
21
  } from '~/types/clarity'
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 clarity = useClarityStore()
26
28
  const models = useModelsStore()
27
- const toast = useToast()
29
+ const actionToast = useActionToast()
28
30
  const { present } = usePipelineErrorToast()
29
31
  const { t } = useI18n()
30
32
  const access = useWorkspaceAccess()
@@ -233,10 +235,8 @@ async function incorporate(feedback?: string) {
233
235
  showRedo.value = false
234
236
  // The fold + re-review now run in the durable driver. Hand the user back to the board;
235
237
  // a notification calls them back only if the re-review needs more input.
236
- toast.add({
237
- title: t('clarity.toast.clarifyingTitle'),
238
+ actionToast.info('clarity.toast.clarifyingTitle', {
238
239
  description: t('clarity.toast.clarifyingDescription'),
239
- icon: 'i-lucide-wand-sparkles',
240
240
  })
241
241
  close()
242
242
  }
@@ -246,15 +246,16 @@ async function reReview() {
246
246
  try {
247
247
  const updated = await clarity.reReview(blockId.value)
248
248
  const newFindings = clarity.openCount(updated)
249
- toast.add({
250
- title:
251
- updated.status === 'incorporated'
252
- ? t('clarity.toast.reReviewSatisfied')
253
- : updated.status === 'exceeded'
254
- ? t('clarity.toast.reReviewExceeded')
255
- : t('clarity.toast.reReviewNewFindings', { count: newFindings }, newFindings),
256
- icon: 'i-lucide-sparkles',
257
- })
249
+ if (updated.status === 'incorporated') {
250
+ actionToast.success('clarity.toast.reReviewSatisfied')
251
+ } else if (updated.status === 'exceeded') {
252
+ actionToast.warning('clarity.toast.reReviewExceeded')
253
+ } else {
254
+ actionToast.info('clarity.toast.reReviewNewFindings', {
255
+ params: { count: newFindings },
256
+ plural: newFindings,
257
+ })
258
+ }
258
259
  } catch (e) {
259
260
  present(e, 'clarity.error.reReview')
260
261
  }
@@ -266,7 +267,7 @@ async function proceed() {
266
267
  try {
267
268
  await flushDrafts()
268
269
  await clarity.proceed(blockId.value)
269
- toast.add({ title: t('clarity.toast.proceeding'), icon: 'i-lucide-arrow-right' })
270
+ actionToast.info('clarity.toast.proceeding')
270
271
  } catch (e) {
271
272
  present(e, 'clarity.error.proceed')
272
273
  } finally {
@@ -280,12 +281,12 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
280
281
  try {
281
282
  await clarity.resolveExceeded(blockId.value, choice)
282
283
  if (choice === 'stop-reset') {
283
- toast.add({ title: t('clarity.toast.taskReset'), icon: 'i-lucide-undo' })
284
+ actionToast.info('clarity.toast.taskReset')
284
285
  close()
285
286
  } else if (choice === 'proceed') {
286
- toast.add({ title: t('clarity.toast.proceeding'), icon: 'i-lucide-arrow-right' })
287
+ actionToast.info('clarity.toast.proceeding')
287
288
  } else {
288
- toast.add({ title: t('clarity.toast.extraRoundGranted'), icon: 'i-lucide-rotate-cw' })
289
+ actionToast.info('clarity.toast.extraRoundGranted')
289
290
  }
290
291
  } catch (e) {
291
292
  present(e, 'clarity.error.resolve')
@@ -329,20 +330,24 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
329
330
 
330
331
  <!-- empty state — the reviewer runs automatically as the first pipeline
331
332
  gate step, so there's nothing to do here until then -->
332
- <div
333
+ <EmptyState
333
334
  v-if="!review && !busy && !loading"
334
- class="rounded-lg border border-dashed border-muted p-8 text-center text-sm text-dimmed"
335
- >
336
- {{ t('clarity.empty') }}
335
+ icon="i-lucide-list-checks"
336
+ :title="t('clarity.empty')"
337
+ />
338
+
339
+ <!-- initial fetch on open -->
340
+ <div v-else-if="loading && !busy && !review" class="flex flex-col gap-3">
341
+ <USkeleton v-for="n in 3" :key="n" class="h-20 w-full rounded-lg" />
337
342
  </div>
338
343
 
339
- <!-- working state (initial fetch on open, or a reviewer pass running) -->
344
+ <!-- a reviewer pass running -->
340
345
  <div
341
- v-else-if="(busy || loading) && !review"
346
+ v-else-if="busy && !review"
342
347
  class="flex items-center justify-center gap-2 p-8 text-sm text-muted"
343
348
  >
344
- <UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
345
- {{ loading && !busy ? t('clarity.loadingReview') : t('clarity.triaging') }}
349
+ <Spinner class="h-4 w-4" />
350
+ {{ t('clarity.triaging') }}
346
351
  </div>
347
352
 
348
353
  <template v-else-if="review">
@@ -371,7 +376,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
371
376
  v-else-if="working"
372
377
  class="mb-4 flex items-center gap-2 rounded-lg border border-primary/60 bg-primary/10 p-4 text-sm text-primary"
373
378
  >
374
- <UIcon name="i-lucide-loader-circle" class="h-5 w-5 shrink-0 animate-spin" />
379
+ <Spinner class="h-5 w-5 shrink-0" />
375
380
  <span v-if="incorporating">
376
381
  {{ t('clarity.incorporatingStage') }}
377
382
  </span>
@@ -8,6 +8,8 @@
8
8
  // -specific extras (severity/category badges, a window's own recommendation section) ride the
9
9
  // `badges` / `actions` slots; the recommend button only EMITS, so each window wires its own
10
10
  // recommend mechanism. `dismissed`/`requested` hide the textarea (nothing to answer right now).
11
+ import Spinner from '~/components/common/Spinner.vue'
12
+
11
13
  const props = defineProps<{
12
14
  /** The question / finding headline. */
13
15
  prompt: string
@@ -82,7 +84,7 @@ const draft = computed({
82
84
  class="mt-2 inline-flex items-center gap-1 text-2xs text-primary"
83
85
  data-testid="clarification-requested"
84
86
  >
85
- <UIcon name="i-lucide-loader-circle" class="h-3.5 w-3.5 animate-spin" />
87
+ <Spinner class="h-3.5 w-3.5" />
86
88
  {{ t('clarification.generating') }}
87
89
  </div>
88
90
 
@@ -1,9 +1,22 @@
1
1
  <script setup lang="ts">
2
- // A small, presentational "nothing here yet" placeholder — the reusable version of the
3
- // hand-coded empty renders scattered across the app (context pickers, dependency lists,
4
- // execution history, preset lists). Dumb by design: it takes already-resolved copy and an
5
- // icon, and exposes a default slot for an optional call-to-action button. `compact` shrinks
6
- // it for inline use inside a narrow panel section.
2
+ // The SPA's one "nothing here yet" render. Every list, panel section and picker that can be empty
3
+ // shows this, so an empty state reads the same everywhere (frontend/app/README.md, "Empty states:
4
+ // `EmptyState`, always").
5
+ //
6
+ // It renders Nuxt UI's `UEmpty`, which owns the structure, the call-to-action row and the theme
7
+ // tokens. This file decides what `UEmpty` leaves to each caller, once:
8
+ //
9
+ // - The `naked` variant: an empty state sits INSIDE a panel that already draws its own border, so
10
+ // `UEmpty`'s default outline would draw a second box.
11
+ // - The spacing. `UEmpty`'s root pads `p-4 sm:p-6 lg:p-8` with a `gap-4`, sized for a page body.
12
+ // Every responsive step is reset here, because overriding the base `p-4` alone leaves 32px of
13
+ // padding inside a narrow picker at desktop widths.
14
+ // - The header markup. `UEmpty` renders its title as an `<h2>`, which would put a level-2 heading
15
+ // into every picker, dropdown and inspector section. The header slot renders the copy as plain
16
+ // text instead.
17
+ //
18
+ // The default slot is the call-to-action: a site with a button to fill the empty list passes it
19
+ // here. Dumb by design: it takes already-resolved copy and an icon.
7
20
  withDefaults(
8
21
  defineProps<{
9
22
  icon?: string
@@ -11,25 +24,34 @@ withDefaults(
11
24
  description?: string
12
25
  compact?: boolean
13
26
  }>(),
14
- { icon: 'i-lucide-inbox', compact: false },
27
+ { icon: 'i-lucide-inbox', description: undefined, compact: false },
15
28
  )
16
29
  </script>
17
30
 
18
31
  <template>
19
- <div
20
- class="flex flex-col items-center justify-center text-center text-dimmed"
21
- :class="compact ? 'gap-1 py-4' : 'gap-2 py-10'"
32
+ <UEmpty
33
+ variant="naked"
34
+ :ui="{
35
+ root: compact
36
+ ? 'gap-1 p-0 py-4 sm:p-0 sm:py-4 lg:p-0 lg:py-4'
37
+ : 'gap-2 p-0 py-10 sm:p-0 sm:py-10 lg:p-0 lg:py-10',
38
+ header: compact ? 'gap-1' : 'gap-2',
39
+ }"
22
40
  data-testid="empty-state"
23
41
  >
24
- <UIcon :name="icon" :class="compact ? 'h-5 w-5' : 'h-8 w-8'" class="text-app-600" />
25
- <p :class="compact ? 'text-xs' : 'text-sm'" class="font-medium text-muted">
26
- {{ title }}
27
- </p>
28
- <p v-if="description" :class="compact ? 'text-2xs' : 'text-xs'" class="max-w-xs">
29
- {{ description }}
30
- </p>
31
- <div v-if="$slots.default" :class="compact ? 'mt-1' : 'mt-2'">
42
+ <template #header>
43
+ <UIcon :name="icon" :class="compact ? 'size-5' : 'size-8'" class="text-app-600" />
44
+ <p :class="compact ? 'text-xs' : 'text-sm'" class="font-medium text-muted">{{ title }}</p>
45
+ <p
46
+ v-if="description"
47
+ :class="compact ? 'text-2xs' : 'text-xs'"
48
+ class="text-balance text-dimmed"
49
+ >
50
+ {{ description }}
51
+ </p>
52
+ </template>
53
+ <template v-if="$slots.default" #actions>
32
54
  <slot />
33
- </div>
34
- </div>
55
+ </template>
56
+ </UEmpty>
35
57
  </template>
@@ -6,6 +6,8 @@
6
6
  // proceed read as dead buttons. Copy is per-feature (passed in); the treatment is shared, so the
7
7
  // two windows can't drift on how a wait or a failure looks. See
8
8
  // `docs/initiatives/clarification-items.md`.
9
+ import Spinner from '~/components/common/Spinner.vue'
10
+
9
11
  defineProps<{
10
12
  /** `working` = a pass is in flight (spinner); `failed` = the run stopped (error treatment). */
11
13
  variant: 'working' | 'failed'
@@ -24,7 +26,7 @@ defineProps<{
24
26
  class="flex flex-col items-center gap-2 rounded-lg border border-default bg-app-950/40 p-6 text-center"
25
27
  :data-testid="testid"
26
28
  >
27
- <UIcon name="i-lucide-loader-circle" class="h-5 w-5 animate-spin text-primary" />
29
+ <Spinner class="h-5 w-5 text-primary" />
28
30
  <p class="text-sm text-default">{{ title }}</p>
29
31
  <p class="text-xs text-muted">{{ hint }}</p>
30
32
  </div>
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ // The one place `animate-spin` is written (`scripts/check-frontend-feedback.mjs` enforces it).
3
+ //
4
+ // Use it only where no Nuxt UI component carries the state: `UButton`, `UInput`, `USelectMenu` and
5
+ // the rest take `:loading`, and that prop is the answer there. This is for a bare icon that shows
6
+ // work in progress: a step-status glyph, a "loading…" line, a full-panel wait.
7
+ //
8
+ // `spinning` lets a status icon keep ONE element across its states (a step glyph that spins while
9
+ // it runs and stops when it settles) instead of two branches. `name` defaults to Nuxt UI's own
10
+ // loading icon, so a plain spinner matches the one `:loading` draws inside a button. Size and
11
+ // colour are the caller's classes, which land on the icon.
12
+ //
13
+ // `motion-safe:` so a reader who asked the OS for reduced motion sees the icon still; the state
14
+ // is carried by the icon itself and its surrounding copy, never by the motion alone.
15
+ withDefaults(defineProps<{ name?: string; spinning?: boolean }>(), {
16
+ name: undefined,
17
+ spinning: true,
18
+ })
19
+
20
+ const appConfig = useAppConfig()
21
+ </script>
22
+
23
+ <template>
24
+ <UIcon
25
+ :name="name ?? appConfig.ui.icons.loading"
26
+ :class="spinning ? 'motion-safe:animate-spin' : ''"
27
+ />
28
+ </template>
@@ -13,6 +13,7 @@ import MarkdownProse from '~/components/common/MarkdownProse.vue'
13
13
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
14
14
  import { agentKindMeta } from '~/utils/catalog'
15
15
  import SectionLabel from '~/components/common/SectionLabel.vue'
16
+ import EmptyState from '~/components/common/EmptyState.vue'
16
17
 
17
18
  const { t, n } = useI18n()
18
19
 
@@ -145,12 +146,11 @@ function topScore(c: ConsensusContribution): { label: string; value: number } |
145
146
  </template>
146
147
 
147
148
  <div class="flex-1 overflow-y-auto px-6 py-5">
148
- <div v-if="loading && !session" class="py-16 text-center text-sm text-dimmed">
149
- {{ t('consensus.loading') }}
150
- </div>
151
- <div v-else-if="!session" class="py-16 text-center text-sm text-dimmed">
152
- {{ t('consensus.empty') }}
149
+ <div v-if="loading && !session" class="space-y-4">
150
+ <USkeleton class="h-32 w-full rounded-lg" />
151
+ <USkeleton v-for="n in 3" :key="n" class="h-16 w-full rounded-lg" />
153
152
  </div>
153
+ <EmptyState v-else-if="!session" icon="i-lucide-users" :title="t('consensus.empty')" />
154
154
  <template v-else>
155
155
  <!-- failure -->
156
156
  <div
@@ -25,6 +25,7 @@ import {
25
25
  interviewStepReached,
26
26
  } from '~/utils/interviewGate'
27
27
  import SectionLabel from '~/components/common/SectionLabel.vue'
28
+ import EmptyState from '~/components/common/EmptyState.vue'
28
29
 
29
30
  const board = useBoardStore()
30
31
  const docInterview = useDocInterviewStore()
@@ -123,13 +124,12 @@ const onProceed = () =>
123
124
 
124
125
  <div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
125
126
  <!-- No session yet -->
126
- <div
127
+ <EmptyState
127
128
  v-if="!session"
128
- class="flex h-full flex-col items-center justify-center gap-2 text-center text-muted"
129
- >
130
- <UIcon name="i-lucide-messages-square" class="h-8 w-8 opacity-40" />
131
- <p class="text-sm">{{ t('docInterview.empty') }}</p>
132
- </div>
129
+ class="h-full"
130
+ icon="i-lucide-messages-square"
131
+ :title="t('docInterview.empty')"
132
+ />
133
133
 
134
134
  <template v-else>
135
135
  <p class="mb-4 text-sm leading-relaxed text-toned">
@@ -476,8 +476,8 @@ onMounted(() => {
476
476
  <template v-else>
477
477
  <UInput
478
478
  v-model="query"
479
- :icon="searching ? 'i-lucide-loader-circle' : 'i-lucide-search'"
480
- :ui="{ leadingIcon: searching ? 'animate-spin' : '' }"
479
+ icon="i-lucide-search"
480
+ :loading="searching"
481
481
  size="sm"
482
482
  class="w-full"
483
483
  :placeholder="
@@ -3,6 +3,7 @@ import type { DocumentSourceKind } from '~/types/domain'
3
3
  import DocumentSyncState from '~/components/documents/DocumentSyncState.vue'
4
4
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
5
5
  import SectionLabel from '~/components/common/SectionLabel.vue'
6
+ import EmptyState from '~/components/common/EmptyState.vue'
6
7
 
7
8
  // Import pages from a connected document source and pick one to expand into
8
9
  // board structure. A source selector lets the user choose which connected source
@@ -11,7 +12,7 @@ import SectionLabel from '~/components/common/SectionLabel.vue'
11
12
  const { t } = useI18n()
12
13
  const ui = useUiStore()
13
14
  const documents = useDocumentsStore()
14
- const toast = useToast()
15
+ const actionToast = useActionToast()
15
16
  const { present } = usePipelineErrorToast()
16
17
 
17
18
  const open = computed({
@@ -58,10 +59,7 @@ async function doImport() {
58
59
  try {
59
60
  const doc = await documents.importDocument(source.value, value)
60
61
  ref_.value = ''
61
- toast.add({
62
- title: t('documents.import.imported', { title: doc.title }),
63
- icon: 'i-lucide-file-down',
64
- })
62
+ actionToast.success('documents.import.imported', { params: { title: doc.title } })
65
63
  } catch (e) {
66
64
  present(e, 'documents.import.importFailed')
67
65
  } finally {
@@ -157,9 +155,12 @@ function preview(externalId: string) {
157
155
  </div>
158
156
  </div>
159
157
  </div>
160
- <p v-else class="text-center text-xs text-dimmed">
161
- {{ t('documents.import.noneImported') }}
162
- </p>
158
+ <EmptyState
159
+ v-else
160
+ compact
161
+ icon="i-lucide-file-down"
162
+ :title="t('documents.import.noneImported')"
163
+ />
163
164
  </div>
164
165
  </template>
165
166
  </UModal>
@@ -17,7 +17,7 @@ import SecretInput from '~/components/common/SecretInput.vue'
17
17
  const { t } = useI18n()
18
18
  const ui = useUiStore()
19
19
  const documents = useDocumentsStore()
20
- const toast = useToast()
20
+ const actionToast = useActionToast()
21
21
  const { present } = usePipelineErrorToast()
22
22
  const { confirmAction } = useConfirmAction()
23
23
 
@@ -82,10 +82,8 @@ async function submit() {
82
82
  saving.value = true
83
83
  try {
84
84
  await documents.connect(source.value, credentials)
85
- toast.add({
86
- title: t('documents.connect.connected', { source: descriptor.value!.label }),
87
- icon: 'i-lucide-check',
88
- color: 'success',
85
+ actionToast.success('documents.connect.connected', {
86
+ params: { source: descriptor.value!.label },
89
87
  })
90
88
  ui.closeDocumentConnect()
91
89
  } catch (e) {
@@ -99,11 +97,13 @@ async function disconnect() {
99
97
  if (!source.value) return
100
98
  const label = descriptor.value?.label ?? t('documents.connect.sourceFallback')
101
99
  if (!(await confirmAction('disconnect', label))) return
102
- await documents.disconnect(source.value)
103
- toast.add({
104
- title: t('documents.connect.disconnected', { source: label }),
105
- icon: 'i-lucide-unplug',
106
- })
100
+ try {
101
+ await documents.disconnect(source.value)
102
+ } catch (e) {
103
+ present(e, 'documents.connect.disconnectFailed')
104
+ return
105
+ }
106
+ actionToast.success('documents.connect.disconnected', { params: { source: label } })
107
107
  ui.closeDocumentConnect()
108
108
  }
109
109
  </script>
@@ -4,6 +4,7 @@ import type { DocKind, DocumentLinkRole, SourceDocument } from '~/types/domain'
4
4
  import DocumentOriginLink from '~/components/documents/DocumentOriginLink.vue'
5
5
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
6
6
  import SectionLabel from '~/components/common/SectionLabel.vue'
7
+ import EmptyState from '~/components/common/EmptyState.vue'
7
8
 
8
9
  // Manage the workspace's per-DocKind TEMPLATE (singular) + EXEMPLAR (multi) document links (WS1).
9
10
  // A kind can be pointed at one of the workspace's already-imported documents so its parsed
@@ -97,13 +98,15 @@ async function unlink(doc: SourceDocument) {
97
98
  <p class="text-xs text-muted">{{ t('documents.templates.intro') }}</p>
98
99
 
99
100
  <!-- No imported documents yet: a template/exemplar must be an imported document. -->
100
- <div v-if="!documents.documents.length" class="space-y-3 text-center">
101
- <UIcon name="i-lucide-file-plus" class="mx-auto h-8 w-8 text-dimmed" />
102
- <p class="text-sm text-muted">{{ t('documents.templates.importFirst') }}</p>
101
+ <EmptyState
102
+ v-if="!documents.documents.length"
103
+ icon="i-lucide-file-plus"
104
+ :title="t('documents.templates.importFirst')"
105
+ >
103
106
  <UButton variant="soft" icon="i-lucide-file-down" @click="ui.openDocumentImport()">
104
107
  {{ t('documents.templates.importButton') }}
105
108
  </UButton>
106
- </div>
109
+ </EmptyState>
107
110
 
108
111
  <template v-else>
109
112
  <UFormField :label="t('documents.templates.kindLabel')">
@@ -194,9 +197,12 @@ async function unlink(doc: SourceDocument) {
194
197
  </UButton>
195
198
  </div>
196
199
  </div>
197
- <p v-else class="mt-2 text-xs text-dimmed">
198
- {{ t('documents.templates.exemplarsEmpty') }}
199
- </p>
200
+ <EmptyState
201
+ v-else
202
+ compact
203
+ icon="i-lucide-files"
204
+ :title="t('documents.templates.exemplarsEmpty')"
205
+ />
200
206
  </section>
201
207
 
202
208
  <!-- Picker: choose an imported document, then set as template or add as example. -->
@@ -232,8 +232,8 @@ onMounted(() => {
232
232
  <div v-if="fileMode === 'search'" class="space-y-2">
233
233
  <UInput
234
234
  v-model="fileQuery"
235
- :icon="loadingFiles ? 'i-lucide-loader-circle' : 'i-lucide-file-search'"
236
- :ui="{ leadingIcon: loadingFiles ? 'animate-spin' : '' }"
235
+ icon="i-lucide-file-search"
236
+ :loading="loadingFiles"
237
237
  size="sm"
238
238
  class="w-full"
239
239
  :placeholder="t('documents.repoPicker.searchFilesPlaceholder')"
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { isDesignSource } from '@cat-factory/contracts'
3
3
  import type { DocumentBoardPlan } from '~/types/domain'
4
+ import Spinner from '~/components/common/Spinner.vue'
4
5
 
5
6
  // Preview the structure an imported document expands into, then spawn it. The
6
7
  // plan is fetched fresh on open; a badge makes clear whether an LLM or the
@@ -16,7 +17,7 @@ const { t } = useI18n()
16
17
  const ui = useUiStore()
17
18
  const board = useBoardStore()
18
19
  const documents = useDocumentsStore()
19
- const toast = useToast()
20
+ const actionToast = useActionToast()
20
21
  const { present } = usePipelineErrorToast()
21
22
 
22
23
  /** Design documents are planned into an existing service; prose documents at the board root. */
@@ -102,16 +103,13 @@ async function spawn() {
102
103
  modules: t('documents.spawn.moduleCount', { count: result.modules }, result.modules),
103
104
  tasks: t('documents.spawn.taskCount', { count: result.tasks }, result.tasks),
104
105
  })
105
- toast.add({
106
- title: t('documents.spawn.spawned'),
106
+ actionToast.success('documents.spawn.spawned', {
107
107
  // A reused module is stated only when there was one, and never folded into the created
108
108
  // count: a spawn into a service that already had every planned module otherwise reports
109
109
  // "0 modules" beside its tasks, which reads as the modules having failed.
110
110
  description: result.reusedModules
111
111
  ? `${summary} · ${t('documents.spawn.reusedCount', { count: result.reusedModules }, result.reusedModules)}`
112
112
  : summary,
113
- icon: 'i-lucide-check',
114
- color: 'success',
115
113
  })
116
114
  ui.closeSpawnPreview()
117
115
  ui.closeDocumentImport()
@@ -168,7 +166,7 @@ async function spawn() {
168
166
  </p>
169
167
 
170
168
  <div v-if="loadingPlan" class="flex items-center gap-2 text-sm text-muted">
171
- <UIcon name="i-lucide-loader" class="h-4 w-4 animate-spin" />
169
+ <Spinner class="h-4 w-4" />
172
170
  {{ t('documents.spawn.buildingPlan') }}
173
171
  </div>
174
172
 
@@ -20,6 +20,7 @@
20
20
  // render identically. For a designer that widening IS the defect, not a detail.
21
21
  import { refRowFor, classifyRefFailure, type RefState } from './ContextDocumentPicker.logic'
22
22
  import type { DocumentSourceKind, ResolvedDocumentRef } from '~/types/domain'
23
+ import Spinner from '~/components/common/Spinner.vue'
23
24
 
24
25
  const { t } = useI18n()
25
26
  const ui = useUiStore()
@@ -178,7 +179,7 @@ function stage() {
178
179
  v-if="state.status === 'checking'"
179
180
  class="flex items-center gap-2 text-sm text-muted"
180
181
  >
181
- <UIcon name="i-lucide-loader" class="h-4 w-4 animate-spin" />
182
+ <Spinner class="h-4 w-4" />
182
183
  {{ t('documents.startFromDesign.checking') }}
183
184
  </div>
184
185
 
@@ -6,6 +6,7 @@ import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.
6
6
  import DocumentOriginLink from '~/components/documents/DocumentOriginLink.vue'
7
7
  import DocumentSyncState from '~/components/documents/DocumentSyncState.vue'
8
8
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
9
+ import EmptyState from '~/components/common/EmptyState.vue'
9
10
 
10
11
  // Documents (from any source) attached to a task OR an initiative as agent
11
12
  // context, shown inside the InspectorPanel. An initiative takes the same
@@ -26,7 +27,7 @@ const props = defineProps<{ block: Block }>()
26
27
  const { t } = useI18n()
27
28
  const documents = useDocumentsStore()
28
29
  const ui = useUiStore()
29
- const toast = useToast()
30
+ const actionToast = useActionToast()
30
31
  const { linkPending, presentLinkFailures } = useContextLinking()
31
32
 
32
33
  onMounted(() => {
@@ -88,7 +89,7 @@ async function attach(item: PendingContext) {
88
89
  try {
89
90
  const failures = await linkPending(props.block.id, [item])
90
91
  if (failures.length) presentLinkFailures(failures, props.block.id)
91
- else toast.add({ title: t('documents.taskDocs.attached'), icon: 'i-lucide-link' })
92
+ else actionToast.success('documents.taskDocs.attached')
92
93
  } finally {
93
94
  linking.value = false
94
95
  }
@@ -165,8 +166,6 @@ async function attach(item: PendingContext) {
165
166
  <DocumentSyncState :doc="doc" class="mt-1" />
166
167
  </div>
167
168
  </div>
168
- <p v-else class="text-2xs text-dimmed">
169
- {{ emptyHint }}
170
- </p>
169
+ <EmptyState v-else compact icon="i-lucide-file-text" :title="emptyHint" />
171
170
  </InspectorSection>
172
171
  </template>
@@ -10,6 +10,8 @@ import {
10
10
  showsProviderFailure,
11
11
  } from '~/components/environments/EnvironmentStatusPanel.logic'
12
12
  import SectionLabel from '~/components/common/SectionLabel.vue'
13
+ import Spinner from '~/components/common/Spinner.vue'
14
+ import EmptyState from '~/components/common/EmptyState.vue'
13
15
 
14
16
  const props = defineProps<{
15
17
  environment: RunEnvironment | null
@@ -112,13 +114,11 @@ const envInTransition = computed(
112
114
  </SectionLabel>
113
115
  <div v-if="environment" class="space-y-2">
114
116
  <div class="flex items-center gap-2 text-sm">
115
- <UIcon
117
+ <Spinner
116
118
  :name="ENV_STATUS_META[environment.status].icon"
119
+ :spinning="runActive && envInTransition"
117
120
  class="h-3.5 w-3.5"
118
- :class="[
119
- ENV_STATUS_META[environment.status].color,
120
- { 'animate-spin': runActive && envInTransition },
121
- ]"
121
+ :class="ENV_STATUS_META[environment.status].color"
122
122
  />
123
123
  <span :class="ENV_STATUS_META[environment.status].color">{{
124
124
  ENV_STATUS_META[environment.status].label
@@ -166,8 +166,11 @@ const envInTransition = computed(
166
166
  {{ t('environments.statusNote', { note: statusNote }) }}
167
167
  </p>
168
168
  </div>
169
- <p v-else class="text-xs text-dimmed">
170
- {{ degradedReason ?? t('environments.empty') }}
171
- </p>
169
+ <EmptyState
170
+ v-else
171
+ compact
172
+ icon="i-lucide-server"
173
+ :title="degradedReason ?? t('environments.empty')"
174
+ />
172
175
  </section>
173
176
  </template>
@@ -5,6 +5,7 @@
5
5
  // advances to the review step. Rendered by `JourneyOutlet`, so it receives the
6
6
  // `ModuleEntryProps` (`input` / `exit` / `goBack`) as attributes.
7
7
  import type { EnvSelectOutput } from '~/modular/journeys/environmentSetup.logic'
8
+ import EmptyState from '~/components/common/EmptyState.vue'
8
9
 
9
10
  defineProps<{
10
11
  input: { frameId: string | null }
@@ -21,9 +22,12 @@ const { t } = useI18n()
21
22
  <!-- The one-service scope caveat, stated on the step that asks for the service: nothing
22
23
  here changes any other service, and running it twice just re-edits this one's recipe. -->
23
24
  <p class="text-xs leading-relaxed text-dimmed">{{ t('environmentWizard.pick.scope') }}</p>
24
- <p v-if="!store.serviceFrames.length" class="text-sm text-dimmed">
25
- {{ t('environmentWizard.pick.empty') }}
26
- </p>
25
+ <EmptyState
26
+ v-if="!store.serviceFrames.length"
27
+ compact
28
+ icon="i-lucide-box"
29
+ :title="t('environmentWizard.pick.empty')"
30
+ />
27
31
  <div v-else class="space-y-1.5">
28
32
  <UButton
29
33
  v-for="frame in store.serviceFrames"