@cat-factory/app 0.311.2 → 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 (192) hide show
  1. package/README.md +65 -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/bootstrap/AdoptionReviewModal.vue +2 -5
  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/ClarificationItem.vue +3 -1
  23. package/app/components/common/EmptyState.vue +41 -19
  24. package/app/components/common/InterviewGateNotice.vue +3 -1
  25. package/app/components/common/Spinner.vue +28 -0
  26. package/app/components/consensus/ConsensusSessionWindow.vue +5 -5
  27. package/app/components/docs/DocInterviewWindow.vue +6 -6
  28. package/app/components/documents/ContextDocumentPicker.vue +2 -2
  29. package/app/components/documents/DocumentImportModal.vue +9 -8
  30. package/app/components/documents/DocumentSourceConnectModal.vue +10 -10
  31. package/app/components/documents/DocumentTemplatesModal.vue +13 -7
  32. package/app/components/documents/RepoContextDocPicker.vue +2 -2
  33. package/app/components/documents/SpawnPreviewModal.vue +4 -6
  34. package/app/components/documents/StartFromDesignModal.vue +2 -1
  35. package/app/components/documents/TaskContextDocs.vue +4 -5
  36. package/app/components/environments/EnvironmentStatusPanel.vue +11 -8
  37. package/app/components/environments/steps/EnvPickStep.vue +7 -3
  38. package/app/components/environments/steps/EnvPreflightStep.vue +6 -5
  39. package/app/components/focus/BlockFocusView.vue +6 -5
  40. package/app/components/followUp/FollowUpWindow.vue +7 -9
  41. package/app/components/forkDecision/ForkDecisionWindow.vue +9 -8
  42. package/app/components/foundational/FoundationalServiceCatalogList.vue +9 -5
  43. package/app/components/foundational/FoundationalServiceRegistry.vue +11 -7
  44. package/app/components/foundational/FoundationalServiceSources.vue +20 -22
  45. package/app/components/foundational/FoundationalSuppressions.vue +2 -2
  46. package/app/components/foundational/ServiceCatalogConnection.vue +15 -15
  47. package/app/components/fragments/FragmentLibraryManager.vue +36 -34
  48. package/app/components/fragments/FragmentSelector.vue +7 -3
  49. package/app/components/gates/GateResultView.vue +6 -9
  50. package/app/components/github/AddServiceFromRepoModal.vue +6 -10
  51. package/app/components/github/GitHubConnect.vue +10 -17
  52. package/app/components/github/GitHubPanel.vue +44 -47
  53. package/app/components/github/RepoTreeBrowser.vue +9 -5
  54. package/app/components/guidedReview/GuidedReviewModal.vue +16 -6
  55. package/app/components/guidedReview/GuidedReviewOverview.vue +2 -1
  56. package/app/components/guidedReview/GuidedReviewPicker.vue +10 -7
  57. package/app/components/guidedReview/GuidedReviewThread.vue +9 -4
  58. package/app/components/humanTest/HumanTestWindow.vue +2 -1
  59. package/app/components/initiative/InitiativePlanningWindow.vue +6 -6
  60. package/app/components/initiative/InitiativeTrackerWindow.vue +11 -11
  61. package/app/components/judge/JudgeResultView.vue +2 -7
  62. package/app/components/kaizen/KaizenPanel.vue +20 -5
  63. package/app/components/kaizen/KaizenStepStatus.vue +2 -1
  64. package/app/components/layout/AccountAuditLog.vue +11 -3
  65. package/app/components/layout/AccountDeploymentSettings.vue +17 -62
  66. package/app/components/layout/AccountFailureKindRules.vue +6 -5
  67. package/app/components/layout/AccountModelPolicySettings.vue +2 -6
  68. package/app/components/layout/AccountPlatformAlertSettings.vue +5 -13
  69. package/app/components/layout/AccountRiskPolicySettings.vue +13 -14
  70. package/app/components/layout/AccountRunCredentialSettings.vue +2 -2
  71. package/app/components/layout/AccountTeamSettings.vue +12 -9
  72. package/app/components/layout/BoardSwitcher.vue +2 -2
  73. package/app/components/layout/BoardToolbar.vue +3 -11
  74. package/app/components/layout/CommandBar.vue +7 -3
  75. package/app/components/layout/ConnectionStatusBanner.vue +2 -1
  76. package/app/components/layout/IntegrationsHub.vue +7 -3
  77. package/app/components/layout/ModelProvidersHub.vue +7 -3
  78. package/app/components/layout/NotificationsInbox.vue +3 -11
  79. package/app/components/layout/WorkspaceMembersSettings.vue +12 -5
  80. package/app/components/media/ArtifactLightbox.vue +4 -3
  81. package/app/components/notifications/NotificationSettingsPanel.vue +4 -11
  82. package/app/components/observability/ToolCallList.vue +12 -17
  83. package/app/components/panels/AgentStepDetail.vue +5 -5
  84. package/app/components/panels/GenericStructuredResultView.vue +6 -9
  85. package/app/components/panels/InspectorPanel.vue +2 -1
  86. package/app/components/panels/MergerResultView.vue +5 -6
  87. package/app/components/panels/ObservabilityPanel.vue +26 -41
  88. package/app/components/panels/OperatorDashboardPanel.vue +29 -17
  89. package/app/components/panels/ReportsPanel.vue +17 -11
  90. package/app/components/panels/ReportsSpendBreakdown.vue +7 -3
  91. package/app/components/panels/RunDetailLoadState.vue +4 -3
  92. package/app/components/panels/StepContainerStatus.vue +4 -3
  93. package/app/components/panels/StepDelegatedStatus.vue +3 -2
  94. package/app/components/panels/StepMetadataCard.vue +6 -8
  95. package/app/components/panels/StepRunMeta.vue +2 -5
  96. package/app/components/panels/inspector/ContainerSummary.vue +7 -1
  97. package/app/components/panels/inspector/EpicChildren.vue +7 -3
  98. package/app/components/panels/inspector/FrontendConfig.vue +7 -3
  99. package/app/components/panels/inspector/InitiativeInspector.vue +5 -2
  100. package/app/components/panels/inspector/ServiceConnections.vue +7 -3
  101. package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +2 -6
  102. package/app/components/panels/inspector/ServiceTestSecrets.vue +2 -6
  103. package/app/components/panels/inspector/ServiceTestingContext.vue +2 -6
  104. package/app/components/panels/inspector/ServiceValidationConfig.vue +38 -39
  105. package/app/components/panels/inspector/TaskExecution.vue +5 -6
  106. package/app/components/panels/inspector/TaskRunSettings.vue +7 -3
  107. package/app/components/panels/inspector/TaskStructure.vue +0 -1
  108. package/app/components/pipeline/AgentPromptEditor.vue +25 -18
  109. package/app/components/pipeline/PipelineBuilder.vue +14 -24
  110. package/app/components/pipeline/PipelineProgress.vue +16 -17
  111. package/app/components/prReview/PrReviewPhaseBadge.vue +3 -2
  112. package/app/components/prReview/PrReviewWindow.vue +16 -20
  113. package/app/components/providers/ApiKeysSection.vue +2 -6
  114. package/app/components/providers/PersonalCredentialModal.vue +2 -6
  115. package/app/components/providers/PersonalSubscriptionSection.vue +3 -7
  116. package/app/components/providers/VendorCredentialsModal.vue +2 -6
  117. package/app/components/provisioning/ProvisioningLogsDrawer.vue +10 -6
  118. package/app/components/ralph/RalphLoopResultView.vue +2 -7
  119. package/app/components/requirements/RequirementsReviewWindow.vue +44 -39
  120. package/app/components/sandbox/SandboxPanel.vue +37 -25
  121. package/app/components/settings/AccountSettingsPanel.vue +6 -3
  122. package/app/components/settings/ApiTokensPanel.vue +2 -6
  123. package/app/components/settings/BudgetSettings.vue +2 -2
  124. package/app/components/settings/CapabilityCredentialsPanel.vue +2 -6
  125. package/app/components/settings/CloudflareHandlerSection.vue +3 -7
  126. package/app/components/settings/ConsensusGroupsSection.vue +7 -3
  127. package/app/components/settings/CustomManifestTypeEditor.vue +7 -3
  128. package/app/components/settings/DefaultProvisionTypeSection.vue +2 -2
  129. package/app/components/settings/InfraHandlersConfigurator.vue +8 -11
  130. package/app/components/settings/IssueTrackerPanel.vue +9 -9
  131. package/app/components/settings/LocalContainerPoolSettings.vue +2 -6
  132. package/app/components/settings/LocalModelEndpointsPanel.vue +5 -7
  133. package/app/components/settings/McpAuthorizeScreen.vue +8 -3
  134. package/app/components/settings/McpOAuthCallbackScreen.vue +2 -1
  135. package/app/components/settings/ModelConfigurationPanel.vue +45 -17
  136. package/app/components/settings/ObservabilityConnectionPanel.vue +4 -15
  137. package/app/components/settings/OpenRouterCatalogPanel.vue +8 -24
  138. package/app/components/settings/PackageRegistriesPanel.vue +2 -6
  139. package/app/components/settings/ProviderConnectionTab.vue +3 -7
  140. package/app/components/settings/RiskPolicyPanel.vue +4 -16
  141. package/app/components/settings/SharedStacksPanel.vue +8 -18
  142. package/app/components/settings/TaskTypeSuppressionsPanel.vue +10 -6
  143. package/app/components/settings/UsageSettings.vue +11 -2
  144. package/app/components/settings/UserSecretsSection.vue +5 -7
  145. package/app/components/settings/WorkspaceMetadataSettings.vue +9 -9
  146. package/app/components/settings/WorkspaceSettingsPanel.vue +3 -11
  147. package/app/components/skills/ReviewSkillQueue.vue +7 -3
  148. package/app/components/skills/SkillLibraryManager.vue +20 -18
  149. package/app/components/slack/SlackPanel.vue +5 -8
  150. package/app/components/spec/ServiceSpecWindow.vue +32 -29
  151. package/app/components/tasks/BugHuntModal.vue +23 -19
  152. package/app/components/tasks/ContextIssuePicker.vue +2 -2
  153. package/app/components/tasks/TaskContextIssues.vue +4 -5
  154. package/app/components/tasks/TaskImportModal.vue +10 -8
  155. package/app/components/tasks/TaskSourceConnectModal.vue +11 -11
  156. package/app/components/testing/TestReportWindow.vue +6 -12
  157. package/app/components/tutorial/TutorialCatalogue.vue +7 -3
  158. package/app/components/tutorial/TutorialOverlay.vue +2 -1
  159. package/app/components/tutorial/TutorialPrompt.vue +7 -3
  160. package/app/components/vcs/GitLabConnect.vue +2 -6
  161. package/app/components/visualConfirm/VisualConfirmationWindow.vue +9 -4
  162. package/app/composables/toastTitleKeys.spec.ts +143 -0
  163. package/app/composables/useActionToast.spec.ts +71 -0
  164. package/app/composables/useActionToast.ts +101 -0
  165. package/app/composables/useBlockDeletion.ts +3 -6
  166. package/app/composables/useConfirmAction.ts +5 -3
  167. package/app/composables/useContextLinking.spec.ts +3 -0
  168. package/app/composables/useContextLinking.ts +11 -16
  169. package/app/composables/useCopyToClipboard.ts +4 -3
  170. package/app/composables/useNavContributions.ts +4 -5
  171. package/app/composables/usePipelineErrorToast.spec.ts +24 -0
  172. package/app/composables/usePipelineErrorToast.ts +26 -2
  173. package/app/composables/usePipelineLibraryActions.ts +8 -9
  174. package/app/pages/index.vue +3 -2
  175. package/app/stores/board/context.ts +4 -3
  176. package/app/stores/board/placement.ts +8 -20
  177. package/app/stores/board/removal.ts +4 -13
  178. package/app/stores/board.spec.ts +10 -1
  179. package/app/stores/board.ts +3 -2
  180. package/app/utils/bootstrapSteps.ts +11 -2
  181. package/app/utils/pipelineRender.ts +21 -8
  182. package/i18n/locales/de.json +12 -30
  183. package/i18n/locales/en.json +12 -30
  184. package/i18n/locales/es.json +12 -30
  185. package/i18n/locales/fr.json +12 -30
  186. package/i18n/locales/he.json +12 -30
  187. package/i18n/locales/it.json +12 -30
  188. package/i18n/locales/ja.json +12 -30
  189. package/i18n/locales/pl.json +12 -30
  190. package/i18n/locales/tr.json +12 -30
  191. package/i18n/locales/uk.json +12 -30
  192. package/package.json +1 -1
package/README.md CHANGED
@@ -257,14 +257,72 @@ a recognised refusal keeps that branch and drains only its fallback into the fun
257
257
  answered, so a local check that never left the browser must not be dressed up as one: a synthesized
258
258
  `new Error(t('...'))` has no envelope and no status, which is precisely the input `describeGenericFailure`
259
259
  reads as a network fault. A blank required field then renders as "The server could not be reached",
260
- with the real sentence hidden behind a disclosure. Client-side validation stays a plain
261
- `toast.add` with translated title and description (`components/settings/ModelConfigurationPanel.vue`).
260
+ with the real sentence hidden behind a disclosure. Client-side validation is
261
+ `useActionToast().error(titleKey, { description })` instead (see below). A failure the backend
262
+ reports as DATA (a job row with `status: 'failed'` and its `error`, a probe verdict's `message`) is
263
+ `presentReported(detail, titleKey, { descriptionKey })`: translated copy as the headline, the
264
+ backend's prose behind "Show details" and in the copied report, the same as a failed call.
262
265
 
263
266
  The still-open remainder is the INLINE family: `error.value = e.message` rendered in a panel, and
264
267
  `testResult = { ok: false, message }` rendered by `ConnectionTestVerdict`. Those need a render
265
268
  surface rather than a toast, and are tracked as G4 in
266
269
  [`error-message-coverage.md`](https://github.com/kibertoad/cat-factory/blob/main/docs/initiatives/error-message-coverage.md).
267
270
 
271
+ #### Every other toast goes through the second funnel, `useActionToast`
272
+
273
+ **`toast.add` is called from exactly two composables**: `usePipelineErrorToast` (with its
274
+ `pipelineErrorToast/` factory) for a failed call, and `useActionToast` for everything else. A call
275
+ site picks a TONE and supplies a title KEY; the tone fixes the colour, the icon and the duration, so
276
+ two toasts of one tone render identically wherever they come from. Before this, some success toasts
277
+ were green with a check and the next was grey with no icon, depending on who wrote the site.
278
+
279
+ | Tone | For | Dismisses |
280
+ | --------- | -------------------------------------------------------------------------------------------------------- | ----------- |
281
+ | `success` | The action the user asked for completed (saved, connected, removed, copied, moved). | After 5s |
282
+ | `info` | Progress or a state report that asks nothing ("drafting", "up to date"). | After 5s |
283
+ | `warning` | The action ran, but the outcome is partial or needs a look. | When closed |
284
+ | `error` | A refusal in translated copy that is NOT a failed call: client-side validation, a mapped refusal reason. | When closed |
285
+
286
+ `warning` and `error` stay for the same reason the failure funnel's toast does: they ask the reader
287
+ to do something. Options are content, never styling: `params` and `plural` for the title key, a
288
+ resolved `description`, `actions` for a call-to-action, and `undo: { run, windowMs }` for a write
289
+ held open behind an Undo (the board's delete and move), which keeps the toast exactly as long as
290
+ the window. A toast is action feedback. Something the reader must act on later belongs in an inline
291
+ `UAlert` or the notification inbox, not a toast.
292
+
293
+ #### Loading: the component's `loading` prop, `USkeleton` for content, `Spinner` for a glyph
294
+
295
+ - **A Nuxt UI component with a `loading` prop takes `:loading`.** Never spin its icon by hand
296
+ (`:ui="{ leadingIcon: 'animate-spin' }"`, or an `:icon` swapped to a loader while busy). One
297
+ exception: `UButton`'s `loading` also DISABLES it (`disabled || isLoading`). A button that must
298
+ stay clickable while its work runs (a tab whose thread is mid-reply, a "planning in progress"
299
+ button that opens the planning window) puts `<Spinner>` in its `#leading` slot instead, with a
300
+ comment saying why. `UInput` and the other inputs do not disable on `loading`.
301
+ - **A panel or list waiting on its first fetch shows `USkeleton` rows sized like the content they
302
+ replace**, so it does not show nothing and then everything. Its fill is `bg-accented`, set once
303
+ in `app.config.ts`: Nuxt UI's default skeleton is `bg-elevated`, which disappears on the
304
+ `bg-elevated` and `app-950` window bodies and cards many panels paint. An ACTION in progress (a button
305
+ press, a sync) is not a content load and keeps `:loading` or a spinner.
306
+ - **A bare glyph showing work in progress is `<Spinner>`** (`components/common/Spinner.vue`), the
307
+ one file that writes `animate-spin`. `:spinning` covers a status icon that spins in one state
308
+ only (a running step), and `name` defaults to Nuxt UI's own loading icon so it matches the one
309
+ `:loading` draws. It spins under `motion-safe:` only.
310
+
311
+ #### Empty states: `EmptyState`, always
312
+
313
+ **A list, panel or section with nothing to show renders `common/EmptyState.vue`**, with its copy as
314
+ `title` / `description` and any button that fills the list in its default slot. `compact` is the
315
+ inline size for an inspector section or a picker. It wraps Nuxt UI's `UEmpty` (the structure, the
316
+ action row and the tokens) and decides once what each `UEmpty` caller would otherwise pick: the
317
+ `naked` variant, because an empty state sits inside a panel that already draws its border; the
318
+ spacing, because `UEmpty` pads `p-4 sm:p-6 lg:p-8` for a page body; and plain text for the title,
319
+ because `UEmpty` renders it as an `<h2>`, which would put a heading into every picker. An inline
320
+ value placeholder ("None", a dash in a table cell) is not an empty state.
321
+
322
+ `scripts/check-frontend-feedback.mjs` (CI's `repo-guards` job) bans `useToast(` and `toast.add(`
323
+ outside the two funnels and `animate-spin` outside `Spinner.vue`. Its `PENDING` list names files another open
324
+ change is rewriting, and fails once one of them is clean so the entry cannot outlive the reason.
325
+
268
326
  ### A panel that seeds state on open uses `onModalOpen`, never a bare `watch(open)`
269
327
 
270
328
  **Work done on the way IN to a modal goes through `onModalOpen(open, fn)`
@@ -412,9 +470,12 @@ protects, so the two never contradict silently:
412
470
  skill teaches the hand-rolled `UForm` + `UFormField` shape for every form; when the backend
413
471
  declares the fields and the SPA only collects them, that shape is forbidden here.
414
472
  [Rule](#a-backend-declared-form-renders-through-descriptorfieldsvue).
415
- - **Failure toasts go through the funnel, not a bare `toast.add`.** The skill uses `useToast()`
416
- freely; here a failed backend call is reported with `usePipelineErrorToast().present(error, key)`.
473
+ - **Toasts go through the two funnels, never a bare `toast.add`.** The skill uses `useToast()`
474
+ freely; here a failed backend call is reported with `usePipelineErrorToast().present(error, key)`
475
+ and every other toast with `useActionToast().success|info|warning|error(key, opts)`.
417
476
  [Rule](#every-failure-toast-goes-through-one-funnel).
477
+ - **Loading is `:loading`, `USkeleton` or `<Spinner>`; empty is `<EmptyState>`.** Never a
478
+ hand-written `animate-spin`. [Rule](#loading-the-components-loading-prop-uskeleton-for-content-spinner-for-a-glyph).
418
479
  - **Icon-only buttons carry an accessible name.** Use `common/IconButton.vue`
419
480
  ([source](./app/components/common/IconButton.vue)), which applies `label` as both the tooltip
420
481
  and `aria-label`, rather than a bare `<UButton icon>`.
package/app/app.config.ts CHANGED
@@ -23,6 +23,13 @@ export default defineAppConfig({
23
23
  badge: {
24
24
  defaultVariants: { color: 'primary', variant: 'solid', size: 'md' },
25
25
  },
26
+ // A skeleton is one step darker than Nuxt UI's `bg-elevated`, which is also the default
27
+ // skeleton fill. Many windows paint their body or inner cards `bg-elevated` or `app-950`
28
+ // (neutral-100 in light mode, the same colour), and a default skeleton there is invisible.
29
+ // `bg-accented` reads on those, on `bg-default`, and in dark mode.
30
+ skeleton: {
31
+ base: 'animate-pulse rounded-md bg-accented',
32
+ },
26
33
  // The toaster's scroll region is the ONE `[data-slot='viewport']` the app's own
27
34
  // CSS may target: Nuxt UI reuses that attribute for the item list of every menu
28
35
  // component too. This marker class is what `main.css` hangs the safe-area offset
@@ -23,6 +23,7 @@
23
23
  import type { AssistantActionId, AssistantOutcome } from '~/types/domain'
24
24
  import { answerFor, assistantSurface, revealTarget, submitGate } from './AssistantModal.logic'
25
25
  import SectionLabel from '~/components/common/SectionLabel.vue'
26
+ import Spinner from '~/components/common/Spinner.vue'
26
27
 
27
28
  const { t } = useI18n()
28
29
  const ui = useUiStore()
@@ -243,11 +244,7 @@ function reveal(blockId: string): void {
243
244
  :class="submitReason ? 'text-app-warning-400' : 'text-dimmed'"
244
245
  data-testid="assistant-submit-status"
245
246
  >
246
- <UIcon
247
- v-if="gate.state === 'checking'"
248
- name="i-lucide-loader-circle"
249
- class="h-3 w-3 shrink-0 animate-spin"
250
- />
247
+ <Spinner v-if="gate.state === 'checking'" class="h-3 w-3 shrink-0" />
251
248
  {{ submitReason ?? t('assistant.submitHint') }}
252
249
  </span>
253
250
  </div>
@@ -2,6 +2,7 @@
2
2
  import { computed } from 'vue'
3
3
  import BackendMisconfiguredScreen from '~/components/auth/BackendMisconfiguredScreen.vue'
4
4
  import LoginScreen from '~/components/auth/LoginScreen.vue'
5
+ import Spinner from '~/components/common/Spinner.vue'
5
6
 
6
7
  // Resolves auth state once on mount, then either renders the app (auth off, or
7
8
  // on with a signed-in user) or the login screen. The board's own bootstrap runs
@@ -24,7 +25,7 @@ onMounted(() => void auth.bootstrap().finally(() => markBoot('auth-ready')))
24
25
  v-if="!auth.ready"
25
26
  class="flex h-screen w-screen flex-col items-center justify-center gap-3 bg-app-950 text-muted"
26
27
  >
27
- <UIcon name="i-lucide-loader" class="h-8 w-8 animate-spin" />
28
+ <Spinner class="h-8 w-8" />
28
29
  <span class="text-sm">{{ t('auth.gate.loading') }}</span>
29
30
  </div>
30
31
 
@@ -28,6 +28,7 @@ import {
28
28
  } from '~/utils/binaryCandidates'
29
29
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
30
30
  import StoredAssetView from '~/components/binaryOutput/StoredAssetView.vue'
31
+ import EmptyState from '~/components/common/EmptyState.vue'
31
32
 
32
33
  const execution = useExecutionStore()
33
34
  const board = useBoardStore()
@@ -399,21 +400,20 @@ const { requestClose } = useUnsavedGuard({
399
400
  Retry), or the step genuinely compared nothing. Collapsing any of the first three into the
400
401
  last would put "nothing to compare" in front of a person whose candidates exist and were
401
402
  simply not fetched. -->
402
- <div
403
+ <EmptyState
403
404
  v-else-if="absence === 'no_run'"
404
- class="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-5 py-10 text-center text-muted"
405
+ class="min-h-0 flex-1 px-5"
406
+ icon="i-lucide-unlink"
407
+ :title="t('binaryCandidates.noRun.title')"
408
+ :description="t('binaryCandidates.noRun.hint')"
405
409
  data-testid="binary-candidates-no-run"
406
- >
407
- <UIcon name="i-lucide-unlink" class="h-8 w-8 opacity-40" />
408
- <p class="text-sm">{{ t('binaryCandidates.noRun.title') }}</p>
409
- <p class="max-w-md text-2xs text-dimmed">{{ t('binaryCandidates.noRun.hint') }}</p>
410
- </div>
410
+ />
411
411
  <div
412
412
  v-else-if="absence === 'loading'"
413
- class="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-5 py-10 text-center text-muted"
413
+ class="grid min-h-0 flex-1 grid-cols-1 content-start gap-3 px-5 py-4 sm:grid-cols-2 lg:grid-cols-3"
414
414
  data-testid="binary-candidates-loading"
415
415
  >
416
- <UIcon name="i-lucide-loader-circle" class="h-8 w-8 animate-spin opacity-60" />
416
+ <USkeleton v-for="n in 3" :key="n" class="h-48 w-full rounded-lg" />
417
417
  </div>
418
418
  <div
419
419
  v-else-if="absence === 'load_failed'"
@@ -434,13 +434,12 @@ const { requestClose } = useUnsavedGuard({
434
434
  {{ t('common.retry') }}
435
435
  </UButton>
436
436
  </div>
437
- <div
437
+ <EmptyState
438
438
  v-else
439
- class="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-5 py-10 text-center text-muted"
439
+ class="min-h-0 flex-1 px-5"
440
+ icon="i-lucide-images"
441
+ :title="t('binaryCandidates.empty.title')"
440
442
  data-testid="binary-candidates-empty"
441
- >
442
- <UIcon name="i-lucide-images" class="h-8 w-8 opacity-40" />
443
- <p class="text-sm">{{ t('binaryCandidates.empty.title') }}</p>
444
- </div>
443
+ />
445
444
  </ResultWindowShell>
446
445
  </template>
@@ -105,9 +105,7 @@ const filename = computed(() => {
105
105
  {{ t('binaryOutput.asset.retry') }}
106
106
  </UButton>
107
107
  </p>
108
- <p v-else class="text-2xs text-dimmed" data-testid="stored-asset-loading">
109
- {{ t('binaryOutput.asset.loading') }}
110
- </p>
108
+ <USkeleton v-else class="h-16 w-full rounded-sm" data-testid="stored-asset-loading" />
111
109
 
112
110
  <!-- The two things a person does with a delivered asset. Both hang off the object URL, so
113
111
  they appear only once the bytes are in hand. -->
@@ -65,7 +65,7 @@ const modelPresets = useModelPresetsStore()
65
65
  const pipelines = usePipelinesStore()
66
66
  const agentConfig = useAgentConfigStore()
67
67
  const fragments = useFragmentsStore()
68
- const toast = useToast()
68
+ const actionToast = useActionToast()
69
69
  const { present } = usePipelineErrorToast()
70
70
  const { t } = useI18n()
71
71
 
@@ -789,9 +789,7 @@ async function submitCreate(acknowledgeReviewDebt: boolean) {
789
789
  const { resolved, failures } = await resolvePending(pendingContext.value)
790
790
  pendingContext.value = resolved
791
791
  if (failures.length) {
792
- presentLinkFailures(failures, undefined, {
793
- title: (count) => t('board.addTask.contextFailed', { count }, count),
794
- })
792
+ presentLinkFailures(failures, undefined, { titleKey: 'board.addTask.contextFailed' })
795
793
  return
796
794
  }
797
795
  const typeFields = buildTypeFields()
@@ -839,12 +837,7 @@ async function submitCreate(acknowledgeReviewDebt: boolean) {
839
837
  }
840
838
  const refusal = createRefusalMessage(e)
841
839
  if (refusal) {
842
- toast.add({
843
- title: t('board.addTask.addFailedTitle'),
844
- description: refusal,
845
- icon: 'i-lucide-triangle-alert',
846
- color: 'error',
847
- })
840
+ actionToast.error('board.addTask.addFailedTitle', { description: refusal })
848
841
  } else present(e, 'board.addTask.addFailedTitle')
849
842
  } finally {
850
843
  saving.value = false
@@ -202,15 +202,14 @@ async function retry() {
202
202
  variant="ghost"
203
203
  class="nodrag flex items-center gap-1 rounded-md bg-app-error-900/40 text-app-error-200 hover:bg-app-error-900/70 disabled:opacity-60"
204
204
  :class="compact ? 'px-2 py-0.5 text-3xs' : 'px-2 py-1 text-2xs'"
205
- :disabled="retrying || !access.canExecuteRuns.value"
205
+ icon="i-lucide-rotate-ccw"
206
+ :loading="retrying"
207
+ :ui="{ leadingIcon: compact ? 'h-3 w-3' : 'h-3.5 w-3.5' }"
208
+ :disabled="!access.canExecuteRuns.value"
206
209
  :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
207
210
  data-testid="agent-failure-retry"
208
211
  @click.stop="retry"
209
212
  >
210
- <UIcon
211
- :name="retrying ? 'i-lucide-loader-circle' : 'i-lucide-rotate-ccw'"
212
- :class="[compact ? 'h-3 w-3' : 'h-3.5 w-3.5', { 'animate-spin': retrying }]"
213
- />
214
213
  {{ retrying ? t('board.failure.retrying') : compact ? t('common.retry') : retryLabel }}
215
214
  </UButton>
216
215
 
@@ -22,7 +22,7 @@ const props = withDefaults(
22
22
  const { t } = useI18n()
23
23
  const agentRuns = useAgentRunsStore()
24
24
  const access = useWorkspaceAccess()
25
- const toast = useToast()
25
+ const actionToast = useActionToast()
26
26
  const { present } = usePipelineErrorToast()
27
27
  const { confirm } = useConfirm()
28
28
  const stopping = ref(false)
@@ -44,12 +44,10 @@ async function stop() {
44
44
  stopping.value = true
45
45
  try {
46
46
  const kind = await agentRuns.stop(props.runId)
47
- toast.add({
48
- title: kind === 'bootstrap' ? t('board.stop.bootstrapStopped') : t('board.stop.runStopped'),
49
- description: t('board.stop.stoppedDescription'),
50
- icon: 'i-lucide-circle-stop',
51
- color: 'warning',
52
- })
47
+ actionToast.success(
48
+ kind === 'bootstrap' ? 'board.stop.bootstrapStopped' : 'board.stop.runStopped',
49
+ { description: t('board.stop.stoppedDescription') },
50
+ )
53
51
  } catch (e) {
54
52
  present(e, 'board.stop.stopFailed')
55
53
  } finally {
@@ -16,6 +16,7 @@ import { useFrameOverlapGuard } from '~/composables/useFrameOverlapGuard'
16
16
  import { useViewport } from '~/composables/useViewport'
17
17
  import { boardPanMode } from '~/utils/boardPanMode'
18
18
  import { createBoardNodeProjection } from './BoardCanvas.logic'
19
+ import EmptyState from '~/components/common/EmptyState.vue'
19
20
 
20
21
  const board = useBoardStore()
21
22
  const ui = useUiStore()
@@ -162,39 +163,39 @@ function onPaneClick() {
162
163
  the buttons themselves. -->
163
164
  <div
164
165
  v-if="board.frames.length === 0"
165
- class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-4 text-center"
166
+ class="pointer-events-none absolute inset-0 flex items-center justify-center"
166
167
  >
167
- <UIcon name="i-lucide-layout-dashboard" class="h-10 w-10 text-app-600" />
168
- <div>
169
- <h2 class="text-base font-semibold text-toned">{{ t('board.canvas.emptyTitle') }}</h2>
170
- <p class="mt-1 max-w-sm text-sm text-dimmed">
171
- {{ t('board.canvas.emptyBody') }}
172
- </p>
173
- </div>
174
- <div
175
- v-if="
176
- access.canManageIntegrations.value || (github.available && access.canWriteBoard.value)
177
- "
178
- class="pointer-events-auto flex flex-wrap items-center justify-center gap-2"
168
+ <EmptyState
169
+ icon="i-lucide-layout-dashboard"
170
+ :title="t('board.canvas.emptyTitle')"
171
+ :description="t('board.canvas.emptyBody')"
172
+ class="max-w-sm"
179
173
  >
180
- <UButton
181
- v-if="access.canManageIntegrations.value"
182
- icon="i-lucide-git-branch-plus"
183
- @click="ui.openBootstrap()"
174
+ <div
175
+ v-if="
176
+ access.canManageIntegrations.value || (github.available && access.canWriteBoard.value)
177
+ "
178
+ class="pointer-events-auto flex flex-wrap items-center justify-center gap-2"
184
179
  >
185
- {{ t('nav.bootstrapRepo') }}
186
- </UButton>
187
- <UButton
188
- v-if="github.available && access.canWriteBoard.value"
189
- variant="soft"
190
- icon="i-lucide-folder-git-2"
191
- @click="ui.openAddService()"
192
- >
193
- {{ t('nav.addFromRepo') }}
194
- </UButton>
195
- </div>
196
- <!-- A read-only viewer sees the empty state but no create affordances. -->
197
- <p v-else class="max-w-sm text-xs text-app-600">{{ t('access.noBoardWrite') }}</p>
180
+ <UButton
181
+ v-if="access.canManageIntegrations.value"
182
+ icon="i-lucide-git-branch-plus"
183
+ @click="ui.openBootstrap()"
184
+ >
185
+ {{ t('nav.bootstrapRepo') }}
186
+ </UButton>
187
+ <UButton
188
+ v-if="github.available && access.canWriteBoard.value"
189
+ variant="soft"
190
+ icon="i-lucide-folder-git-2"
191
+ @click="ui.openAddService()"
192
+ >
193
+ {{ t('nav.addFromRepo') }}
194
+ </UButton>
195
+ </div>
196
+ <!-- A read-only viewer sees the empty state but no create affordances. -->
197
+ <p v-else class="max-w-sm text-xs text-app-600">{{ t('access.noBoardWrite') }}</p>
198
+ </EmptyState>
198
199
  </div>
199
200
 
200
201
  <!-- task dependency arrows, overlaid in screen space -->
@@ -146,9 +146,7 @@ async function create() {
146
146
  const { resolved, failures } = await resolvePending(pendingContext.value)
147
147
  pendingContext.value = resolved
148
148
  if (failures.length) {
149
- presentLinkFailures(failures, undefined, {
150
- title: (count) => t('initiative.create.contextFailed', { count }, count),
151
- })
149
+ presentLinkFailures(failures, undefined, { titleKey: 'initiative.create.contextFailed' })
152
150
  return
153
151
  }
154
152
  const { block } = await initiatives.create(frameId, {
@@ -163,7 +161,7 @@ async function create() {
163
161
  // doc another task already holds), surfaced with its specific cause rather than a bare count.
164
162
  // The initiative is already created, so a failed link never costs the user the form.
165
163
  presentLinkFailures(await linkPending(block.id, pendingContext.value), block.id, {
166
- title: (count) => t('initiative.create.linkFailed', { count }, count),
164
+ titleKey: 'initiative.create.linkFailed',
167
165
  })
168
166
  ui.closeCreateInitiative()
169
167
  // Select the fresh block so the inspector offers "Run planning" right away.
@@ -20,7 +20,7 @@ const pipelines = usePipelinesStore()
20
20
  const recurring = useRecurringPipelinesStore()
21
21
  const tracker = useTrackerStore()
22
22
  const tasks = useTasksStore()
23
- const toast = useToast()
23
+ const actionToast = useActionToast()
24
24
  const { present } = usePipelineErrorToast()
25
25
  const access = useWorkspaceAccess()
26
26
  const { t, te } = useI18n()
@@ -394,12 +394,7 @@ async function add() {
394
394
  } catch (e) {
395
395
  const refusal = intakeRefusalCopy(e)
396
396
  if (refusal) {
397
- toast.add({
398
- title: t('board.recurring.addFailedTitle'),
399
- description: refusal,
400
- icon: 'i-lucide-triangle-alert',
401
- color: 'error',
402
- })
397
+ actionToast.error('board.recurring.addFailedTitle', { description: refusal })
403
398
  } else present(e, 'board.recurring.addFailedTitle')
404
399
  } finally {
405
400
  saving.value = false
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { AgentState, PipelineStep } from '~/types/domain'
3
3
  import { agentKindMeta } from '~/utils/catalog'
4
+ import Spinner from '~/components/common/Spinner.vue'
4
5
 
5
6
  const props = defineProps<{
6
7
  step: PipelineStep
@@ -19,7 +20,7 @@ const stateRing: Record<AgentState, string> = {
19
20
 
20
21
  const stateIcon: Record<AgentState, string | null> = {
21
22
  pending: null,
22
- working: 'i-lucide-loader',
23
+ working: null,
23
24
  waiting_decision: 'i-lucide-circle-help',
24
25
  done: 'i-lucide-check',
25
26
  }
@@ -39,7 +40,7 @@ const dim = computed(() => (props.size === 'sm' ? 'h-7 w-7' : 'h-9 w-9'))
39
40
  v-if="step.state === 'working'"
40
41
  class="absolute -bottom-1 -end-1 rounded-full bg-default p-0.5"
41
42
  >
42
- <UIcon :name="stateIcon.working!" class="h-3 w-3 animate-spin text-primary" />
43
+ <Spinner class="h-3 w-3 text-primary" />
43
44
  </span>
44
45
  <span
45
46
  v-else-if="stateIcon[step.state]"
@@ -10,6 +10,7 @@ import AgentStopButton from '~/components/board/AgentStopButton.vue'
10
10
  import AdoptionReviewModal from '~/components/bootstrap/AdoptionReviewModal.vue'
11
11
  import BootstrapRunSteps from '~/components/bootstrap/BootstrapRunSteps.vue'
12
12
  import IconButton from '~/components/common/IconButton.vue'
13
+ import Spinner from '~/components/common/Spinner.vue'
13
14
  import { useBlockDrag } from '~/composables/useBlockDrag'
14
15
  import { useFrameStacking } from '~/composables/useFrameStacking'
15
16
  import { useViewport } from '~/composables/useViewport'
@@ -224,9 +225,10 @@ const bootstrapPct = computed(() => {
224
225
  // The actual todo items the agent is working through, surfaced on the expanded
225
226
  // card so a zoomed-in user sees the task list, not just the "N/M" count.
226
227
  const bootstrapItems = computed(() => bootstrapSubtasks.value?.items ?? [])
227
- const ITEM_ICON: Record<string, string> = {
228
+ // `in_progress` takes the spinner's own loading glyph.
229
+ const ITEM_ICON: Record<string, string | undefined> = {
228
230
  completed: 'i-lucide-check-circle-2',
229
- in_progress: 'i-lucide-loader-circle',
231
+ in_progress: undefined,
230
232
  pending: 'i-lucide-circle',
231
233
  }
232
234
  </script>
@@ -302,10 +304,7 @@ const ITEM_ICON: Record<string, string> = {
302
304
  data-testid="bootstrap-progress"
303
305
  >
304
306
  <div class="flex items-center gap-1.5 text-xs">
305
- <UIcon
306
- name="i-lucide-loader-circle"
307
- class="h-4 w-4 shrink-0 animate-spin text-app-warning-400"
308
- />
307
+ <Spinner class="h-4 w-4 shrink-0 text-app-warning-400" />
309
308
  <span class="text-app-warning-300">{{ t('board.frame.bootstrappingRepository') }}</span>
310
309
  <span v-if="bootstrapSubtasks" class="ms-auto text-app-warning-200/80">
311
310
  {{
@@ -336,13 +335,17 @@ const ITEM_ICON: Record<string, string> = {
336
335
  : 'text-app-warning-200/80'
337
336
  "
338
337
  >
339
- <UIcon
338
+ <Spinner
340
339
  :name="ITEM_ICON[item.status]"
340
+ :spinning="item.status === 'in_progress'"
341
341
  class="mt-px h-3 w-3 shrink-0"
342
- :class="[
343
- item.status === 'in_progress' ? 'animate-spin text-app-warning-400' : '',
344
- item.status === 'completed' ? 'text-app-success-400' : 'text-app-warning-400/70',
345
- ]"
342
+ :class="
343
+ item.status === 'in_progress'
344
+ ? 'text-app-warning-400'
345
+ : item.status === 'completed'
346
+ ? 'text-app-success-400'
347
+ : 'text-app-warning-400/70'
348
+ "
346
349
  />
347
350
  <span>{{ item.label }}</span>
348
351
  </li>
@@ -18,6 +18,7 @@
18
18
  // renders now. An initiative is still a first-class board block with its own inspector.
19
19
  import type { InitiativeStatus } from '~/types/domain'
20
20
  import { useInitiativePlanning } from '~/composables/useInitiativePlanning'
21
+ import Spinner from '~/components/common/Spinner.vue'
21
22
  import {
22
23
  INITIATIVE_ATTENTION_ICONS,
23
24
  INITIATIVE_ATTENTION_LABEL_KEYS,
@@ -120,17 +121,19 @@ function select() {
120
121
  >
121
122
  {{ t('initiative.inspector.answerPlanning') }}
122
123
  </UButton>
123
- <!-- Mid-pass: nothing to answer, but keep the route into the window (it shows the wait). -->
124
+ <!-- Mid-pass: nothing to answer, but keep the route into the window (it shows the wait).
125
+ A spinner in the leading slot, not `:loading`, which would disable the button. -->
124
126
  <UButton
125
127
  v-else-if="interviewing"
126
128
  data-testid="initiative-card-planning-in-progress"
127
129
  size="xs"
128
130
  variant="soft"
129
131
  color="neutral"
130
- icon="i-lucide-loader-circle"
131
- :ui="{ leadingIcon: 'animate-spin' }"
132
132
  @click.stop="openPlanning"
133
133
  >
134
+ <template #leading>
135
+ <Spinner class="size-4 shrink-0" />
136
+ </template>
134
137
  {{ t('initiative.inspector.planningInProgress') }}
135
138
  </UButton>
136
139
  <UButton
@@ -15,7 +15,7 @@ const props = defineProps<{ job: BootstrapJob }>()
15
15
  const emit = defineEmits<{ close: [] }>()
16
16
 
17
17
  const agentRuns = useAgentRunsStore()
18
- const toast = useToast()
18
+ const actionToast = useActionToast()
19
19
  const { present } = usePipelineErrorToast()
20
20
  const { t } = useI18n()
21
21
 
@@ -146,13 +146,10 @@ async function submit() {
146
146
  })),
147
147
  ...(overallNotes.value.trim() ? { notes: overallNotes.value.trim() } : {}),
148
148
  })
149
- toast.add({
150
- title: t('bootstrap.adoption.toast.approved'),
149
+ actionToast.success('bootstrap.adoption.toast.approved', {
151
150
  description: t('bootstrap.adoption.toast.approvedDesc', {
152
151
  directory: props.job.monorepo?.directory ?? '',
153
152
  }),
154
- icon: 'i-lucide-check',
155
- color: 'success',
156
153
  })
157
154
  open.value = false
158
155
  emit('close')
@@ -29,8 +29,8 @@ const bootstrap = useBootstrapStore()
29
29
  const agentRuns = useAgentRunsStore()
30
30
  const github = useGitHubStore()
31
31
  const board = useBoardStore()
32
- const toast = useToast()
33
- const { present } = usePipelineErrorToast()
32
+ const actionToast = useActionToast()
33
+ const { present, presentReported } = usePipelineErrorToast()
34
34
  const { freeFramePosition, focusFrame } = useFramePlacement()
35
35
  const { t } = useI18n()
36
36
  const { confirmAction, toastDone } = useConfirmAction()
@@ -367,11 +367,8 @@ async function openCreateRepo() {
367
367
  private: isPrivate.value,
368
368
  description: description.value.trim() || undefined,
369
369
  })
370
- toast.add({
371
- title: t('bootstrap.toast.repoCreated'),
370
+ actionToast.success('bootstrap.toast.repoCreated', {
372
371
  description: `${repo.owner}/${repo.name}`,
373
- icon: 'i-lucide-check',
374
- color: 'success',
375
372
  })
376
373
  } catch (e) {
377
374
  present(e, 'bootstrap.toast.repoCreateFailed')
@@ -426,26 +423,20 @@ async function launch() {
426
423
  if (job.status === 'failed') {
427
424
  // The container couldn't even start (pre-flight failure, e.g. the target
428
425
  // repo isn't empty) — surfaced synchronously, before any board frame.
429
- toast.add({
430
- title: t('bootstrap.toast.failed'),
431
- description: job.error ?? t('bootstrap.toast.failedFallback'),
432
- icon: 'i-lucide-triangle-alert',
433
- color: 'error',
426
+ presentReported(job.error, 'bootstrap.toast.failed', {
427
+ descriptionKey: 'bootstrap.toast.failedFallback',
434
428
  })
435
429
  } else {
436
430
  // Running: the container is spinning up. A provisional service card now
437
431
  // shows on the board and tracks live progress; the run continues in the
438
432
  // background and becomes a real, droppable service when it finishes.
439
- toast.add({
440
- title: t('bootstrap.toast.started'),
433
+ actionToast.info('bootstrap.toast.started', {
441
434
  // A monorepo run does not run straight through: it surveys, then waits for the
442
435
  // reviewer. Saying "bootstrapping…" there would set the wrong expectation about who
443
436
  // the next move belongs to.
444
437
  description: job.monorepo
445
438
  ? t('bootstrap.toast.startedMonorepoDesc', { directory: job.monorepo.directory })
446
439
  : t('bootstrap.toast.startedDesc', { repo: job.repoName }),
447
- icon: 'i-lucide-loader-circle',
448
- color: 'info',
449
440
  })
450
441
  repoName.value = ''
451
442
  description.value = ''
@@ -12,6 +12,7 @@
12
12
  // are one rule, not two. How a state RENDERS is `BOOTSTRAP_STEP_STYLE`, beside the vocabulary
13
13
  // it is keyed by.
14
14
  import type { BootstrapStepId, BootstrapStepState } from '@cat-factory/contracts'
15
+ import Spinner from '~/components/common/Spinner.vue'
15
16
 
16
17
  const props = defineProps<{ runId: string }>()
17
18
 
@@ -38,8 +39,9 @@ function stateLabel(state: BootstrapStepState): string {
38
39
  :data-step="step.id"
39
40
  :data-state="step.state"
40
41
  >
41
- <UIcon
42
+ <Spinner
42
43
  :name="BOOTSTRAP_STEP_STYLE[step.state].icon"
44
+ :spinning="BOOTSTRAP_STEP_STYLE[step.state].spin"
43
45
  class="mt-px h-3 w-3 shrink-0"
44
46
  :class="BOOTSTRAP_STEP_STYLE[step.state].iconClass"
45
47
  />