@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
@@ -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
 
@@ -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>
@@ -12,7 +12,7 @@ function reload() {
12
12
  <!-- safe-area-ok: a scrim with one card centred on it, so no content reaches an edge -->
13
13
  <div
14
14
  data-testid="async-view-error"
15
- class="fixed inset-0 z-50 grid place-items-center bg-app-950/80 p-6 backdrop-blur-sm"
15
+ class="fixed inset-0 z-50 grid place-items-center bg-elevated/75 p-6 backdrop-blur-sm"
16
16
  role="alert"
17
17
  >
18
18
  <div class="max-w-sm rounded-2xl border border-muted bg-default p-5 shadow-2xl">
@@ -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="