@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
@@ -0,0 +1,143 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import { dirname, join, relative } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { hasI18nKey } from '../../test/i18nKeys'
6
+
7
+ // The two toast funnels take a title KEY, resolved inside the composable. Typed message keys and
8
+ // `vue-i18n-extract` only see a key written literally inside a `t()` call, so a key handed to a funnel is
9
+ // invisible to both: a typo renders its own key path in the toast, and deleting a live key reads
10
+ // as a clean removal (`i18n:check` even lists these keys as unused). This spec closes that gap by
11
+ // reading every literal key passed to a funnel and resolving it against the base catalog.
12
+
13
+ const appRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
14
+
15
+ function* sources(dir: string): Generator<string> {
16
+ for (const entry of readdirSync(dir)) {
17
+ const abs = join(dir, entry)
18
+ if (statSync(abs).isDirectory()) yield* sources(abs)
19
+ else if (/\.(vue|ts)$/.test(entry) && !entry.endsWith('.spec.ts')) yield abs
20
+ }
21
+ }
22
+
23
+ // The call heads whose FIRST argument is the title key, and those whose SECOND argument is: the
24
+ // error funnel's two (after the error or the reported detail) and `useInterviewDrafts`' `flushThen`,
25
+ // which forwards its key to `present`.
26
+ const FIRST = /actionToast\.(?:success|info|warning|error)\(\s*/g
27
+ const SECOND = /\b(?:present|presentReported|flushThen)\(/g
28
+ // Options that carry a key to a funnel through a helper. Only a LITERAL value is read: the same
29
+ // names also appear in type declarations and in the helpers that forward them.
30
+ const OPTION = /\b(?:titleKey|failureTitleKeys)\s*:\s*/g
31
+ // A dotted key literal. A conditional argument (`ok ? 'a.b' : 'a.c'`) yields both.
32
+ const KEY = /'([a-zA-Z][\w-]*(?:\.[\w-]+)+)'/g
33
+ // A tone passed around instead of called (`const show = ok ? actionToast.success : …`): every key
34
+ // then reaches the toast through the alias, where no call head above can read it.
35
+ const ALIAS = /actionToast\.(?:success|info|warning|error)\b(?!\s*\()/g
36
+
37
+ // Files whose funnel call takes its key from somewhere other than a literal argument. A prefix
38
+ // means the file spells its keys out in a table or in calls to a local helper, and every literal
39
+ // under that prefix in the file is resolved instead. `null` means the file only forwards a key
40
+ // its callers name, and those callers are read through `SECOND` and `OPTION`. A new indirect call
41
+ // fails until it is either made literal or listed here.
42
+ const INDIRECT: Record<string, string | null> = {
43
+ 'composables/useConfirmAction.ts': 'common.toast.',
44
+ 'components/github/GitHubPanel.vue': 'vcs.panel.pulls.',
45
+ 'components/pipeline/PipelineHealthModal.vue': 'pipeline.health.toast.',
46
+ 'components/settings/RiskPolicyPanel.vue': 'settings.riskPolicy.toast.',
47
+ 'composables/useInterviewDrafts.ts': null,
48
+ 'composables/useContextLinking.ts': null,
49
+ }
50
+
51
+ // The funnels themselves: they DEFINE the calls above (a signature reads like a call head), so
52
+ // they hold no key to check.
53
+ const FUNNELS =
54
+ /^composables\/(?:useActionToast|usePipelineErrorToast)\.ts$|^composables\/pipelineErrorToast\//
55
+
56
+ /** The argument starting at `from`: up to the first top-level `,` or the closing `)`. */
57
+ function argumentAt(text: string, from: number): string {
58
+ let depth = 0
59
+ for (let i = from; i < text.length; i++) {
60
+ const char = text[i]
61
+ if (char === '(' || char === '[' || char === '{') depth++
62
+ else if (char === ')' || char === ']' || char === '}') {
63
+ if (depth === 0) return text.slice(from, i)
64
+ depth--
65
+ } else if (char === ',' && depth === 0) return text.slice(from, i)
66
+ }
67
+ return text.slice(from)
68
+ }
69
+
70
+ /** Every key argument in `text`, each with the literal keys it contains (none for an indirect one). */
71
+ function keyArguments(text: string): { arg: string; keys: string[] }[] {
72
+ const args: string[] = []
73
+ for (const m of text.matchAll(FIRST)) args.push(argumentAt(text, m.index + m[0].length))
74
+ for (const m of text.matchAll(SECOND)) {
75
+ const first = argumentAt(text, m.index + m[0].length)
76
+ const comma = m.index + m[0].length + first.length
77
+ // `present(error)` with no key takes the funnel's own default title.
78
+ if (text[comma] === ',') args.push(argumentAt(text, comma + 1).trim())
79
+ }
80
+ const keyed = args.map((arg) => ({
81
+ arg: arg.trim(),
82
+ keys: [...arg.matchAll(KEY)].map(([, k]) => k!),
83
+ }))
84
+ for (const m of text.matchAll(OPTION)) {
85
+ const keys = [...argumentAt(text, m.index + m[0].length).matchAll(KEY)].map(([, k]) => k!)
86
+ if (keys.length) keyed.push({ arg: m[0], keys })
87
+ }
88
+ return keyed
89
+ }
90
+
91
+ describe('toast title keys', () => {
92
+ it('reads the key argument of every call shape', () => {
93
+ const keys = (src: string) => keyArguments(src).flatMap((a) => a.keys)
94
+ expect(keys(`actionToast.success('a.saved', { params: { n } })`)).toEqual(['a.saved'])
95
+ expect(keys(`present(error, 'b.failed')`)).toEqual(['b.failed'])
96
+ expect(keys(`presentReported(job.error, 'b.reported', { descriptionKey: 'x' })`)).toEqual([
97
+ 'b.reported',
98
+ ])
99
+ expect(keys(`actionToast.info(ok ? 'c.yes' : 'c.no')`)).toEqual(['c.yes', 'c.no'])
100
+ expect(keys(`presentLinkFailures(f, id, { titleKey: 'd.linkFailed' })`)).toEqual([
101
+ 'd.linkFailed',
102
+ ])
103
+ expect(keys(`flushThen(submit, 'e.submitFailed')`)).toEqual(['e.submitFailed'])
104
+ expect(keys(`failureTitleKeys: { one: 'f.one', many: 'f.many' }`)).toEqual(['f.one', 'f.many'])
105
+ // A declaration or a forward names no key, and is not a call to check.
106
+ expect(keyArguments(`titleKey: string`)).toEqual([])
107
+ expect(keyArguments(`present(e)`)).toEqual([])
108
+ expect(keyArguments(`actionToast.success(meta.toastKey)`)).toEqual([
109
+ { arg: 'meta.toastKey', keys: [] },
110
+ ])
111
+ })
112
+
113
+ it('resolves every key a funnel is handed against the base catalog', () => {
114
+ const missing: string[] = []
115
+ const unreadable: string[] = []
116
+ let seen = 0
117
+ for (const file of sources(appRoot)) {
118
+ const rel = relative(appRoot, file).replaceAll('\\', '/')
119
+ if (FUNNELS.test(rel)) continue
120
+ const text = readFileSync(file, 'utf8')
121
+ for (const alias of text.matchAll(ALIAS)) unreadable.push(`${rel}: ${alias[0]} passed around`)
122
+ for (const { arg, keys } of keyArguments(text)) {
123
+ if (!keys.length && !(rel in INDIRECT)) unreadable.push(`${rel}: ${arg}`)
124
+ for (const key of keys) {
125
+ seen++
126
+ if (!hasI18nKey(key)) missing.push(`${rel}: ${key}`)
127
+ }
128
+ }
129
+ const prefix = INDIRECT[rel]
130
+ if (prefix !== undefined && prefix !== null) {
131
+ for (const [, key] of text.matchAll(KEY)) {
132
+ if (!key!.startsWith(prefix)) continue
133
+ seen++
134
+ if (!hasI18nKey(key!)) missing.push(`${rel}: ${key}`)
135
+ }
136
+ }
137
+ }
138
+ // A scan that matched nothing would pass vacuously, so it must have read some keys.
139
+ expect(seen).toBeGreaterThan(0)
140
+ expect(unreadable).toEqual([])
141
+ expect(missing).toEqual([])
142
+ })
143
+ })
@@ -0,0 +1,71 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { useActionToast } from '~/composables/useActionToast'
3
+
4
+ // `test/setup.ts` stubs `useNuxtApp().$i18n.t` as a key echo; this spec swaps in a `t` that also
5
+ // records its params, and a `useToast` whose `add` it can read back.
6
+ const add = vi.fn()
7
+ const t = vi.fn((key: string, params?: Record<string, unknown>, plural?: number) =>
8
+ [key, params && Object.keys(params).length ? JSON.stringify(params) : '', plural ?? '']
9
+ .filter((part) => part !== '')
10
+ .join('|'),
11
+ )
12
+
13
+ beforeEach(() => {
14
+ add.mockReset()
15
+ t.mockClear()
16
+ vi.stubGlobal('useToast', () => ({ add }))
17
+ vi.stubGlobal('useNuxtApp', () => ({ $i18n: { t } }))
18
+ })
19
+
20
+ const lastToast = () => add.mock.calls.at(-1)?.[0]
21
+
22
+ describe('useActionToast', () => {
23
+ it('renders every success toast the same, whatever the call site', () => {
24
+ const toast = useActionToast()
25
+ toast.success('a.saved')
26
+ const first = lastToast()
27
+ toast.success('b.connected', { params: { name: 'x' } })
28
+ const second = lastToast()
29
+ for (const key of ['color', 'icon', 'duration'] as const) expect(second[key]).toBe(first[key])
30
+ expect(first.color).toBe('success')
31
+ })
32
+
33
+ it('auto-dismisses success and info, and keeps warning and error until closed', () => {
34
+ const toast = useActionToast()
35
+ toast.success('k')
36
+ expect(lastToast().duration).toBeUndefined()
37
+ toast.info('k')
38
+ expect(lastToast().duration).toBeUndefined()
39
+ toast.warning('k')
40
+ expect(lastToast().duration).toBe(0)
41
+ toast.error('k')
42
+ expect(lastToast().duration).toBe(0)
43
+ })
44
+
45
+ it('resolves the title key with its params and plural count', () => {
46
+ useActionToast().info('x.found', { params: { count: 3 }, plural: 3, description: 'body' })
47
+ expect(lastToast().title).toBe('x.found|{"count":3}|3')
48
+ expect(lastToast().description).toBe('body')
49
+ })
50
+
51
+ it('holds an undo toast exactly as long as the window and leads with the Undo action', () => {
52
+ const run = vi.fn()
53
+ const extra = { label: 'Open', onClick: vi.fn() }
54
+ useActionToast().success('board.toast.deleted', {
55
+ undo: { run, windowMs: 6000 },
56
+ actions: [extra],
57
+ })
58
+ const shown = lastToast()
59
+ expect(shown.duration).toBe(6000)
60
+ expect(shown.actions).toHaveLength(2)
61
+ expect(shown.actions[0].label).toBe('common.undo')
62
+ shown.actions[0].onClick()
63
+ expect(run).toHaveBeenCalledOnce()
64
+ expect(shown.actions[1]).toBe(extra)
65
+ })
66
+
67
+ it('sends no empty actions array', () => {
68
+ useActionToast().success('k')
69
+ expect(lastToast()).not.toHaveProperty('actions')
70
+ })
71
+ })
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The toast for everything that is NOT a failed call: the second of the SPA's two toast funnels.
3
+ * A failed call goes to `usePipelineErrorToast().present(error, titleKey)`; every other toast comes
4
+ * from here, so `toast.add` is called from exactly those two composables
5
+ * (`scripts/check-frontend-feedback.mjs` enforces it).
6
+ *
7
+ * Each tone fixes its colour, its icon and whether it auto-dismisses, so two toasts of one tone
8
+ * render identically wherever they come from. The call site picks a TONE and supplies COPY; it
9
+ * cannot restyle the toast. Before this, some success toasts carried `color: 'success'` and some
10
+ * none, and the icon was a choice made per site (`i-lucide-check`, `-link`, `-unplug`, `-copy`,
11
+ * `-rocket`, or nothing).
12
+ *
13
+ * - `success`: the action the user asked for completed. Dismisses after Nuxt UI's default.
14
+ * - `info`: progress or a state report that asks nothing of the reader ("drafting…", "up to
15
+ * date"). Dismisses after the default.
16
+ * - `warning`: the action ran, but the outcome is partial or nothing changed in a way the reader
17
+ * should know about. Stays until closed, because it asks the reader to look at something.
18
+ * - `error`: a refusal in TRANSLATED copy that is not a failed call: a client-side validation that
19
+ * never left the browser, or a recognised refusal reason the site maps to its own key. It has no
20
+ * envelope, so `present` would misread it as a network fault (README, "Every failure toast goes
21
+ * through ONE funnel"). Stays until closed, like every failure. A failure the backend reported
22
+ * as DATA with its own prose (a job row's `error`) is `presentReported` on the failure funnel
23
+ * instead, which keeps that prose as copyable detail rather than the headline.
24
+ *
25
+ * Titles are i18n KEYS, resolved here (the same contract as `present`), so a call site cannot hand
26
+ * in untranslated prose as the headline. A `description` is already-resolved text, because it
27
+ * often carries data (a repo name, a count line) built from several keys. A key handed in here is
28
+ * invisible to typed message keys and to `vue-i18n-extract`, which only read a key written literally inside a `t()` call, so
29
+ * `toastTitleKeys.spec.ts` resolves every literal key passed to either funnel against the catalog.
30
+ */
31
+
32
+ /** One button on the toast. A call-to-action is content, so the call site may add one. */
33
+ export interface ActionToastAction {
34
+ label: string
35
+ icon?: string
36
+ onClick: () => void
37
+ }
38
+
39
+ export interface ActionToastOptions {
40
+ /** Interpolation for the title key. */
41
+ params?: Record<string, unknown>
42
+ /** The plural count for the title key, when it has plural forms. */
43
+ plural?: number
44
+ /** Resolved body text. */
45
+ description?: string
46
+ actions?: ActionToastAction[]
47
+ /**
48
+ * Offer an Undo for a write whose real effect is deferred. The toast then stays exactly as long
49
+ * as the window the caller holds the write open for, because an Undo button that outlives the
50
+ * window would undo nothing.
51
+ */
52
+ undo?: { run: () => void; windowMs: number }
53
+ }
54
+
55
+ type Tone = 'success' | 'info' | 'warning' | 'error'
56
+
57
+ // The only place a tone's look is decided. `duration: 0` keeps the toast until the reader closes
58
+ // it; `undefined` takes Nuxt UI's default (5s).
59
+ const TONES: Record<Tone, { color: Tone; icon: string; duration: number | undefined }> = {
60
+ success: { color: 'success', icon: 'i-lucide-circle-check', duration: undefined },
61
+ info: { color: 'info', icon: 'i-lucide-info', duration: undefined },
62
+ warning: { color: 'warning', icon: 'i-lucide-triangle-alert', duration: 0 },
63
+ error: { color: 'error', icon: 'i-lucide-triangle-alert', duration: 0 },
64
+ }
65
+
66
+ export function useActionToast() {
67
+ const toast = useToast()
68
+ // The Nuxt app's global i18n instance, not `useI18n()`: this is reached from store setup (the
69
+ // board store's undo toasts) and from `useCopyToClipboard`, which the error funnel calls from
70
+ // store setup. Same reason as `usePipelineErrorToast`.
71
+ const { t } = useNuxtApp().$i18n as ReturnType<typeof useI18n>
72
+
73
+ function show(tone: Tone, titleKey: string, opts: ActionToastOptions = {}): void {
74
+ const look = TONES[tone]
75
+ const params = opts.params ?? {}
76
+ const title = opts.plural === undefined ? t(titleKey, params) : t(titleKey, params, opts.plural)
77
+ const actions = [
78
+ ...(opts.undo
79
+ ? [{ label: t('common.undo'), icon: 'i-lucide-undo-2', onClick: opts.undo.run }]
80
+ : []),
81
+ ...(opts.actions ?? []),
82
+ ]
83
+ toast.add({
84
+ title,
85
+ description: opts.description,
86
+ color: look.color,
87
+ icon: look.icon,
88
+ duration: opts.undo ? opts.undo.windowMs : look.duration,
89
+ ...(actions.length ? { actions } : {}),
90
+ })
91
+ }
92
+
93
+ return {
94
+ success: (titleKey: string, opts?: ActionToastOptions) => show('success', titleKey, opts),
95
+ info: (titleKey: string, opts?: ActionToastOptions) => show('info', titleKey, opts),
96
+ warning: (titleKey: string, opts?: ActionToastOptions) => show('warning', titleKey, opts),
97
+ error: (titleKey: string, opts?: ActionToastOptions) => show('error', titleKey, opts),
98
+ }
99
+ }
100
+
101
+ export type ActionToast = ReturnType<typeof useActionToast>
@@ -1,4 +1,5 @@
1
1
  import type { Block } from '~/types/domain'
2
+ import { useActionToast } from '~/composables/useActionToast'
2
3
 
3
4
  /**
4
5
  * The single, confirm-gated block deletion used by BOTH the inspector's Delete button and
@@ -15,7 +16,7 @@ export function useBlockDeletion() {
15
16
  const execution = useExecutionStore()
16
17
  const ui = useUiStore()
17
18
  const recurring = useRecurringPipelinesStore()
18
- const toast = useToast()
19
+ const actionToast = useActionToast()
19
20
  const { present } = usePipelineErrorToast()
20
21
  const { confirm } = useConfirm()
21
22
  const { t } = useI18n()
@@ -52,11 +53,7 @@ export function useBlockDeletion() {
52
53
  ui.select(null)
53
54
  try {
54
55
  await board.archiveService(block.id)
55
- toast.add({
56
- title: t('board.toast.archived', { name: block.title }),
57
- icon: 'i-lucide-archive',
58
- color: 'neutral',
59
- })
56
+ actionToast.success('board.toast.archived', { params: { name: block.title } })
60
57
  } catch (e) {
61
58
  present(e, 'board.toast.archiveFailed')
62
59
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Confirm-gate + success-toast for the recurring destructive actions that aren't board
3
3
  * blocks (disconnect a connection, remove/revoke a credential, clear a config, destroy an
4
- * environment). Built on the same `useConfirm()` singleton + `useToast()` the board delete
4
+ * environment). Built on the same `useConfirm()` singleton + `useActionToast()` the board delete
5
5
  * path uses, so every destructive affordance across the settings/connection surfaces routes
6
6
  * through ONE confirm-then-mutate + toast path rather than each re-inventing its own copy.
7
7
  *
@@ -19,6 +19,8 @@
19
19
  * per surface. `name` is a short noun for the target (a brand like "Slack", a data value like
20
20
  * the invite email, or a feature noun like "the test environment").
21
21
  */
22
+ import { useActionToast } from '~/composables/useActionToast'
23
+
22
24
  type ConfirmShape = 'disconnect' | 'remove' | 'revoke' | 'clear' | 'destroy'
23
25
 
24
26
  /**
@@ -71,7 +73,7 @@ const SHAPE_META: Record<
71
73
 
72
74
  export function useConfirmAction() {
73
75
  const { confirm } = useConfirm()
74
- const toast = useToast()
76
+ const actionToast = useActionToast()
75
77
  const { t } = useI18n()
76
78
 
77
79
  /** Prompt before a destructive action against `name`. Resolves `true` only if confirmed. */
@@ -89,7 +91,7 @@ export function useConfirmAction() {
89
91
  /** Toast the completed destructive action (call only on real success). */
90
92
  function toastDone(shape: ConfirmShape, name: string): void {
91
93
  const meta = SHAPE_META[shape]
92
- toast.add({ title: t(meta.toastKey, { name }), color: 'success', icon: 'i-lucide-check' })
94
+ actionToast.success(meta.toastKey, { params: { name } })
93
95
  }
94
96
 
95
97
  return { confirmAction, toastDone }
@@ -105,6 +105,7 @@ describe('resolvePending', () => {
105
105
  vi.stubGlobal('useWorkspaceStore', () => ({ workspaceId: 'ws_1' }))
106
106
  vi.stubGlobal('useToast', () => ({ add: vi.fn() }))
107
107
  vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
108
+ vi.stubGlobal('useNuxtApp', () => ({ $i18n: { t: (key: string) => key } }))
108
109
  vi.stubGlobal('useCopyToClipboard', () => ({ copyAction: () => ({ label: 'copy' }) }))
109
110
  }
110
111
 
@@ -192,6 +193,7 @@ describe('presentLinkFailures', () => {
192
193
  vi.stubGlobal('useWorkspaceStore', () => ({ workspaceId: 'ws_1' }))
193
194
  vi.stubGlobal('useToast', () => ({ add }))
194
195
  vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
196
+ vi.stubGlobal('useNuxtApp', () => ({ $i18n: { t: (key: string) => key } }))
195
197
  vi.stubGlobal('useCopyToClipboard', () => ({ copyAction }))
196
198
  return { add, copyAction }
197
199
  }
@@ -221,6 +223,7 @@ describe('presentLinkFailures', () => {
221
223
  const toast = add.mock.calls[0]![0]
222
224
  // Sticky so the cause stays readable, titled by the count key, and per-item reason shown.
223
225
  expect(toast.title).toBe('board.addTask.linkFailed')
226
+ expect(toast.color).toBe('warning')
224
227
  expect(toast.duration).toBe(0)
225
228
  expect(toast.description).toContain('GitHub denied access')
226
229
  expect(toast.actions).toHaveLength(1)
@@ -1,5 +1,6 @@
1
1
  import type { DocumentSourceKind, TaskSourceKind } from '~/types/domain'
2
2
  import { apiErrorEnvelope, apiErrorStatus } from './api/errors'
3
+ import { useActionToast } from '~/composables/useActionToast'
3
4
 
4
5
  // Shared model + orchestration for attaching external context (imported docs and
5
6
  // tracker issues) to a board block. A "pending" item is something the user has
@@ -121,7 +122,7 @@ export function useContextLinking() {
121
122
  const documents = useDocumentsStore()
122
123
  const tasks = useTasksStore()
123
124
  const workspace = useWorkspaceStore()
124
- const toast = useToast()
125
+ const actionToast = useActionToast()
125
126
  const { t } = useI18n()
126
127
  const { copyAction } = useCopyToClipboard()
127
128
 
@@ -240,14 +241,12 @@ export function useContextLinking() {
240
241
  /**
241
242
  * Surface link failures as a single actionable toast: the specific per-item
242
243
  * reasons as the body, and a "Copy details" action that puts the full diagnostic
243
- * report ({@link buildLinkFailureReport}) on the clipboard. Sticky (`duration: 0`)
244
- * so the cause stays readable long enough to act on. No-op when nothing failed.
244
+ * report ({@link buildLinkFailureReport}) on the clipboard. A warning, so it stays until
245
+ * closed and the cause stays readable long enough to act on. No-op when nothing failed.
245
246
  *
246
- * `opts.title` names what WAS created, which differs per host ("Task added, but …" vs
247
- * "Initiative created, but …"). It is a resolver over the count rather than a message key, so
248
- * each caller keeps a literal key at its own translation call site — passing the key through
249
- * would make it a variable, which defeats both the typed-message-key check and the extractor's
250
- * static scan — and the plural choice has to be made against the same count.
247
+ * `opts.titleKey` names what WAS created, which differs per host ("Task added, but …" vs
248
+ * "Initiative created, but …"). It is a plural key, resolved against the failure count;
249
+ * `toastTitleKeys.spec.ts` checks the literal each caller passes, which typed keys cannot see.
251
250
  */
252
251
  /**
253
252
  * A failure's line in the toast: TRANSLATED copy where the backend named a reason we have a
@@ -269,7 +268,7 @@ export function useContextLinking() {
269
268
  function presentLinkFailures(
270
269
  failures: LinkFailure[],
271
270
  blockId?: string,
272
- opts: { title?: (count: number) => string } = {},
271
+ opts: { titleKey?: string } = {},
273
272
  ): void {
274
273
  if (failures.length === 0) return
275
274
  const description = failures.map((f) => `${f.item.title}: ${describeFailure(f)}`).join('\n')
@@ -278,14 +277,10 @@ export function useContextLinking() {
278
277
  blockId,
279
278
  when: new Date().toISOString(),
280
279
  })
281
- toast.add({
282
- title:
283
- opts.title?.(failures.length) ??
284
- t('board.addTask.linkFailed', { count: failures.length }, failures.length),
280
+ actionToast.warning(opts.titleKey ?? 'board.addTask.linkFailed', {
281
+ params: { count: failures.length },
282
+ plural: failures.length,
285
283
  description,
286
- icon: 'i-lucide-triangle-alert',
287
- color: 'warning',
288
- duration: 0,
289
284
  actions: [copyAction(report)],
290
285
  })
291
286
  }
@@ -7,6 +7,7 @@
7
7
  // silent no-op the user couldn't tell apart from success. Routing every copy through this seam
8
8
  // makes the outcome always visible.
9
9
  import { useClipboard } from '@vueuse/core'
10
+ import { useActionToast } from '~/composables/useActionToast'
10
11
 
11
12
  export function useCopyToClipboard() {
12
13
  // Resolved through the Nuxt app's global i18n instance rather than `useI18n()`, which requires an
@@ -16,7 +17,7 @@ export function useCopyToClipboard() {
16
17
  // component. `useI18n()` there throws `MUST_BE_CALL_SETUP_TOP`, and a throw in a store body takes
17
18
  // the whole app to Nuxt's error page. Same reason, and the same fix, as `usePipelineErrorToast`.
18
19
  const { t } = useNuxtApp().$i18n as ReturnType<typeof useI18n>
19
- const toast = useToast()
20
+ const actionToast = useActionToast()
20
21
  const { copy: writeClipboard, isSupported } = useClipboard()
21
22
 
22
23
  async function copy(text: string) {
@@ -25,9 +26,9 @@ export function useCopyToClipboard() {
25
26
  try {
26
27
  if (!isSupported.value) throw new Error('clipboard unsupported')
27
28
  await writeClipboard(text)
28
- toast.add({ title: t('common.copied'), color: 'success', icon: 'i-lucide-check' })
29
+ actionToast.success('common.copied')
29
30
  } catch {
30
- toast.add({ title: t('common.copyFailed'), color: 'error', icon: 'i-lucide-x' })
31
+ actionToast.error('common.copyFailed')
31
32
  }
32
33
  }
33
34
 
@@ -4,6 +4,7 @@ import { groupCommands, groupSidebar, sortToolbar } from '~/modular/nav-contribu
4
4
  import { EXTERNAL_TOOL_UNAVAILABLE_KEYS, projectExternalTools } from '~/modular/external-tools'
5
5
  import { toMetadataBag } from '~/modular/workspace-metadata'
6
6
  import type { ExternalToolContext, ExternalToolContribution } from '~/modular/external-tools'
7
+ import { useActionToast } from '~/composables/useActionToast'
7
8
  import type {
8
9
  AppSlots,
9
10
  CommandGroup,
@@ -30,7 +31,7 @@ export function useNavContributions() {
30
31
  // an active component instance) — the same handle, and the same reason, as
31
32
  // `usePipelineErrorToast`: nothing about this composable should depend on WHERE it is called.
32
33
  const { t } = useNuxtApp().$i18n as ReturnType<typeof useI18n>
33
- const toast = useToast()
34
+ const actionToast = useActionToast()
34
35
  const auth = useAuthStore()
35
36
  const workspace = useWorkspaceStore()
36
37
  const workspaceSettings = useWorkspaceSettingsStore()
@@ -148,13 +149,11 @@ export function useNavContributions() {
148
149
  resolution.cause,
149
150
  )
150
151
  }
151
- toast.add({
152
- title: t('externalTools.unavailable.title', { tool: tool.title }),
152
+ actionToast.error('externalTools.unavailable.title', {
153
+ params: { tool: tool.title },
153
154
  description: t(EXTERNAL_TOOL_UNAVAILABLE_KEYS[resolution.reason], {
154
155
  fields: resolution.missing.join(', '),
155
156
  }),
156
- icon: 'i-lucide-triangle-alert',
157
- color: 'warning',
158
157
  })
159
158
  },
160
159
  },
@@ -272,6 +272,30 @@ describe('usePipelineErrorToast', () => {
272
272
  })
273
273
  })
274
274
 
275
+ describe('presentReported', () => {
276
+ it('keeps a failure reported as data behind the disclosure, under translated copy', () => {
277
+ // A job row's `error` is backend prose: it must be copyable detail, never the headline, and it
278
+ // must not be read as a network fault the way `present` reads a bare string.
279
+ usePipelineErrorToast().presentReported('stack exited with code 1', 'x.startFailed')
280
+ const arg = add.mock.calls[0]![0]
281
+ expect(arg.title).toBe('x.startFailed')
282
+ expect(arg.description).toBe('errors.reported.description')
283
+ expect(arg.duration).toBe(0)
284
+ arg.actions[0].onClick()
285
+ expect(update).toHaveBeenCalledWith('toast-1', {
286
+ description: 'stack exited with code 1',
287
+ actions: [expect.objectContaining({ label: 'common.copyDetails' })],
288
+ })
289
+ })
290
+
291
+ it('takes a site description key, and offers no disclosure when nothing was reported', () => {
292
+ usePipelineErrorToast().presentReported(null, 'x.failed', { descriptionKey: 'x.fallback' })
293
+ const arg = add.mock.calls[0]![0]
294
+ expect(arg.description).toBe('x.fallback')
295
+ expect(arg.actions).toEqual([expect.objectContaining({ label: 'common.copyDetails' })])
296
+ })
297
+ })
298
+
275
299
  describe('describeGenericFailure', () => {
276
300
  it('maps each known status class to its own description key', () => {
277
301
  for (const code of [
@@ -683,7 +683,12 @@ export function usePipelineErrorToast() {
683
683
  // ships in the base `en` catalog, so a locale missing it renders English via `fallbackLocale`
684
684
  // (better than the raw prose this replaced) and a bare key can never leak.
685
685
  const title = t(fallbackTitleKey, titleParams ?? {})
686
- const description = t(failure.descriptionKey)
686
+ showFailure(title, t(failure.descriptionKey), detail)
687
+ }
688
+
689
+ /** The failure toast both generic paths render: translated title and description, the raw
690
+ * detail behind "Show details", and the whole report copyable. */
691
+ function showFailure(title: string, description: string, detail: string): void {
687
692
  const report = [title, description, detail].filter((part) => part.length > 0).join('\n')
688
693
  const added = toast.add({
689
694
  title,
@@ -713,6 +718,25 @@ export function usePipelineErrorToast() {
713
718
  })
714
719
  }
715
720
 
721
+ /**
722
+ * A failure the backend reported as DATA rather than as a failed call: a job row with
723
+ * `status: 'failed'` and its `error` text, a probe verdict with a `message`. There is no
724
+ * envelope, so `present` would misread it as a network fault. The reported text is backend prose
725
+ * and so DETAIL, never the headline: it goes behind "Show details" and into the copied report,
726
+ * under a translated description (`descriptionKey`, or the generic "it reported a failure").
727
+ */
728
+ function presentReported(
729
+ detail: string | null | undefined,
730
+ titleKey: string,
731
+ opts: { params?: Record<string, unknown>; descriptionKey?: string } = {},
732
+ ): void {
733
+ showFailure(
734
+ t(titleKey, opts.params ?? {}),
735
+ t(opts.descriptionKey ?? 'errors.reported.description'),
736
+ detail?.trim() ?? '',
737
+ )
738
+ }
739
+
716
740
  /**
717
741
  * Present `error` as a toast. `fallbackTitleKey` is an i18n message key used for
718
742
  * non-conflict failures and any conflict reason without a dedicated title; `titleParams` carries
@@ -732,5 +756,5 @@ export function usePipelineErrorToast() {
732
756
  presentGenericFailure(error, fallbackTitleKey, titleParams)
733
757
  }
734
758
 
735
- return { present }
759
+ return { present, presentReported }
736
760
  }
@@ -2,6 +2,7 @@ import type { RunDefaultScope } from '@cat-factory/contracts'
2
2
  import type { Pipeline } from '~/types/domain'
3
3
  import { usePipelinesStore } from '~/stores/pipelines'
4
4
  import { usePipelineErrorToast } from '~/composables/usePipelineErrorToast'
5
+ import { useActionToast } from '~/composables/useActionToast'
5
6
 
6
7
  /**
7
8
  * The actions a row of the saved-pipeline LIBRARY offers: archive, promote to a scope default,
@@ -14,7 +15,7 @@ import { usePipelineErrorToast } from '~/composables/usePipelineErrorToast'
14
15
  */
15
16
  export function usePipelineLibraryActions() {
16
17
  const pipelines = usePipelinesStore()
17
- const toast = useToast()
18
+ const actionToast = useActionToast()
18
19
  const { t } = useI18n()
19
20
  const { present } = usePipelineErrorToast()
20
21
  const { confirm } = useConfirm()
@@ -24,8 +25,8 @@ export function usePipelineLibraryActions() {
24
25
  try {
25
26
  if (p.archived) await pipelines.unarchive(p.id)
26
27
  else await pipelines.archive(p.id)
27
- } catch {
28
- toast.add({ title: t('pipeline.builder.toast.updateFailed'), color: 'error' })
28
+ } catch (error) {
29
+ present(error, 'pipeline.builder.toast.updateFailed')
29
30
  }
30
31
  }
31
32
 
@@ -68,13 +69,11 @@ export function usePipelineLibraryActions() {
68
69
  async function clone(p: Pipeline) {
69
70
  try {
70
71
  const copy = await pipelines.clonePipeline(p.id)
71
- toast.add({
72
- title: t('pipeline.builder.toast.cloned', { name: p.name, copy: copy.name }),
73
- color: 'success',
74
- icon: 'i-lucide-copy',
72
+ actionToast.success('pipeline.builder.toast.cloned', {
73
+ params: { name: p.name, copy: copy.name },
75
74
  })
76
- } catch {
77
- toast.add({ title: t('pipeline.builder.toast.cloneFailed'), color: 'error' })
75
+ } catch (error) {
76
+ present(error, 'pipeline.builder.toast.cloneFailed')
78
77
  }
79
78
  }
80
79