@cat-factory/app 0.311.2 → 0.312.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/README.md +92 -4
  2. package/app/app.config.ts +7 -0
  3. package/app/components/assistant/AssistantModal.vue +2 -5
  4. package/app/components/auth/AuthGate.vue +2 -1
  5. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +14 -15
  6. package/app/components/binaryOutput/StoredAssetView.vue +1 -3
  7. package/app/components/board/AddTaskModal.vue +3 -10
  8. package/app/components/board/AgentFailureCard.vue +4 -5
  9. package/app/components/board/AgentStopButton.vue +5 -7
  10. package/app/components/board/BoardCanvas.vue +31 -30
  11. package/app/components/board/CreateInitiativeModal.vue +2 -4
  12. package/app/components/board/RecurringPipelineModal.vue +2 -7
  13. package/app/components/board/nodes/AgentChip.vue +3 -2
  14. package/app/components/board/nodes/BlockNode.vue +14 -11
  15. package/app/components/board/nodes/InitiativeCard.vue +6 -3
  16. package/app/components/board/nodes/TaskCard.vue +45 -212
  17. package/app/components/bootstrap/AdoptionReviewModal.vue +2 -5
  18. package/app/components/bootstrap/BootstrapModal.vue +6 -15
  19. package/app/components/bootstrap/BootstrapRunSteps.vue +3 -1
  20. package/app/components/brainstorm/BrainstormWindow.vue +32 -26
  21. package/app/components/bugFishing/BugFishingWindow.vue +15 -13
  22. package/app/components/clarity/ClarityReviewWindow.vue +31 -26
  23. package/app/components/common/ClarificationItem.vue +3 -1
  24. package/app/components/common/EmptyState.vue +41 -19
  25. package/app/components/common/InterviewGateNotice.vue +3 -1
  26. package/app/components/common/Spinner.vue +28 -0
  27. package/app/components/consensus/ConsensusSessionWindow.vue +5 -5
  28. package/app/components/docs/DocInterviewWindow.vue +6 -6
  29. package/app/components/documents/ContextDocumentPicker.vue +2 -2
  30. package/app/components/documents/DocumentImportModal.vue +9 -8
  31. package/app/components/documents/DocumentSourceConnectModal.vue +10 -10
  32. package/app/components/documents/DocumentTemplatesModal.vue +13 -7
  33. package/app/components/documents/RepoContextDocPicker.vue +2 -2
  34. package/app/components/documents/SpawnPreviewModal.vue +4 -6
  35. package/app/components/documents/StartFromDesignModal.vue +2 -1
  36. package/app/components/documents/TaskContextDocs.vue +4 -5
  37. package/app/components/environments/EnvironmentStatusPanel.vue +11 -8
  38. package/app/components/environments/steps/EnvPickStep.vue +7 -3
  39. package/app/components/environments/steps/EnvPreflightStep.vue +6 -5
  40. package/app/components/focus/BlockFocusView.vue +6 -5
  41. package/app/components/followUp/FollowUpWindow.vue +7 -9
  42. package/app/components/forkDecision/ForkDecisionWindow.vue +9 -8
  43. package/app/components/foundational/FoundationalServiceCatalogList.vue +9 -5
  44. package/app/components/foundational/FoundationalServiceRegistry.vue +11 -7
  45. package/app/components/foundational/FoundationalServiceSources.vue +20 -22
  46. package/app/components/foundational/FoundationalSuppressions.vue +2 -2
  47. package/app/components/foundational/ServiceCatalogConnection.vue +15 -15
  48. package/app/components/fragments/FragmentLibraryManager.vue +36 -34
  49. package/app/components/fragments/FragmentSelector.vue +7 -3
  50. package/app/components/gates/GateResultView.vue +6 -9
  51. package/app/components/github/AddServiceFromRepoModal.vue +6 -10
  52. package/app/components/github/GitHubConnect.vue +10 -17
  53. package/app/components/github/GitHubPanel.vue +44 -47
  54. package/app/components/github/RepoTreeBrowser.vue +9 -5
  55. package/app/components/guidedReview/GuidedReviewModal.vue +16 -6
  56. package/app/components/guidedReview/GuidedReviewOverview.vue +2 -1
  57. package/app/components/guidedReview/GuidedReviewPicker.vue +10 -7
  58. package/app/components/guidedReview/GuidedReviewThread.vue +9 -4
  59. package/app/components/humanTest/HumanTestWindow.vue +2 -1
  60. package/app/components/initiative/InitiativePlanningWindow.vue +6 -6
  61. package/app/components/initiative/InitiativeTrackerWindow.vue +11 -11
  62. package/app/components/judge/JudgeResultView.vue +2 -7
  63. package/app/components/kaizen/KaizenPanel.vue +20 -5
  64. package/app/components/kaizen/KaizenStepStatus.vue +2 -1
  65. package/app/components/layout/AccountAuditLog.vue +11 -3
  66. package/app/components/layout/AccountDeploymentSettings.vue +17 -62
  67. package/app/components/layout/AccountFailureKindRules.vue +6 -5
  68. package/app/components/layout/AccountModelPolicySettings.vue +2 -6
  69. package/app/components/layout/AccountPlatformAlertSettings.vue +5 -13
  70. package/app/components/layout/AccountRiskPolicySettings.vue +13 -14
  71. package/app/components/layout/AccountRunCredentialSettings.vue +2 -2
  72. package/app/components/layout/AccountTeamSettings.vue +12 -9
  73. package/app/components/layout/BoardSwitcher.vue +2 -2
  74. package/app/components/layout/BoardToolbar.vue +27 -37
  75. package/app/components/layout/BoardTopOverlays.vue +8 -2
  76. package/app/components/layout/CommandBar.vue +7 -3
  77. package/app/components/layout/ConnectionStatusBanner.vue +2 -1
  78. package/app/components/layout/IntegrationsHub.vue +7 -3
  79. package/app/components/layout/ModelProvidersHub.vue +7 -3
  80. package/app/components/layout/NotificationsInbox.vue +3 -11
  81. package/app/components/layout/SideBar.vue +5 -0
  82. package/app/components/layout/WorkViewSwitcher.vue +64 -0
  83. package/app/components/layout/WorkspaceMembersSettings.vue +12 -5
  84. package/app/components/media/ArtifactLightbox.vue +4 -3
  85. package/app/components/notifications/NotificationSettingsPanel.vue +4 -11
  86. package/app/components/observability/ToolCallList.vue +12 -17
  87. package/app/components/panels/AgentStepDetail.vue +5 -5
  88. package/app/components/panels/GenericStructuredResultView.vue +6 -9
  89. package/app/components/panels/InspectorPanel.vue +2 -1
  90. package/app/components/panels/MergerResultView.vue +5 -6
  91. package/app/components/panels/ObservabilityPanel.vue +26 -41
  92. package/app/components/panels/OperatorDashboardPanel.vue +29 -17
  93. package/app/components/panels/ReportsPanel.vue +17 -11
  94. package/app/components/panels/ReportsSpendBreakdown.vue +7 -3
  95. package/app/components/panels/RunDetailLoadState.vue +4 -3
  96. package/app/components/panels/StepContainerStatus.vue +4 -3
  97. package/app/components/panels/StepDelegatedStatus.vue +3 -2
  98. package/app/components/panels/StepMetadataCard.vue +6 -8
  99. package/app/components/panels/StepRunMeta.vue +2 -5
  100. package/app/components/panels/inspector/ContainerSummary.vue +7 -1
  101. package/app/components/panels/inspector/EpicChildren.vue +7 -3
  102. package/app/components/panels/inspector/FrontendConfig.vue +7 -3
  103. package/app/components/panels/inspector/InitiativeInspector.vue +5 -2
  104. package/app/components/panels/inspector/ServiceConnections.vue +7 -3
  105. package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +2 -6
  106. package/app/components/panels/inspector/ServiceTestSecrets.vue +2 -6
  107. package/app/components/panels/inspector/ServiceTestingContext.vue +2 -6
  108. package/app/components/panels/inspector/ServiceValidationConfig.vue +38 -39
  109. package/app/components/panels/inspector/TaskExecution.vue +5 -6
  110. package/app/components/panels/inspector/TaskRunSettings.vue +7 -3
  111. package/app/components/panels/inspector/TaskStructure.vue +0 -1
  112. package/app/components/pipeline/AgentPromptEditor.vue +25 -18
  113. package/app/components/pipeline/PipelineBuilder.vue +14 -24
  114. package/app/components/pipeline/PipelineProgress.vue +16 -17
  115. package/app/components/prReview/PrReviewPhaseBadge.vue +3 -2
  116. package/app/components/prReview/PrReviewWindow.vue +16 -20
  117. package/app/components/providers/ApiKeysSection.vue +2 -6
  118. package/app/components/providers/PersonalCredentialModal.vue +2 -6
  119. package/app/components/providers/PersonalSubscriptionSection.vue +3 -7
  120. package/app/components/providers/VendorCredentialsModal.vue +2 -6
  121. package/app/components/provisioning/ProvisioningLogsDrawer.vue +10 -6
  122. package/app/components/queue/QueueCard.vue +269 -0
  123. package/app/components/queue/QueueView.vue +192 -0
  124. package/app/components/ralph/RalphLoopResultView.vue +2 -7
  125. package/app/components/requirements/RequirementsReviewWindow.vue +44 -39
  126. package/app/components/sandbox/SandboxPanel.vue +37 -25
  127. package/app/components/settings/AccountSettingsPanel.vue +6 -3
  128. package/app/components/settings/ApiTokensPanel.vue +2 -6
  129. package/app/components/settings/BudgetSettings.vue +2 -2
  130. package/app/components/settings/CapabilityCredentialsPanel.vue +2 -6
  131. package/app/components/settings/CloudflareHandlerSection.vue +3 -7
  132. package/app/components/settings/ConsensusGroupsSection.vue +7 -3
  133. package/app/components/settings/CustomManifestTypeEditor.vue +7 -3
  134. package/app/components/settings/DefaultProvisionTypeSection.vue +2 -2
  135. package/app/components/settings/InfraHandlersConfigurator.vue +8 -11
  136. package/app/components/settings/IssueTrackerPanel.vue +9 -9
  137. package/app/components/settings/LocalContainerPoolSettings.vue +2 -6
  138. package/app/components/settings/LocalModelEndpointsPanel.vue +5 -7
  139. package/app/components/settings/McpAuthorizeScreen.vue +8 -3
  140. package/app/components/settings/McpOAuthCallbackScreen.vue +2 -1
  141. package/app/components/settings/ModelConfigurationPanel.vue +45 -17
  142. package/app/components/settings/ObservabilityConnectionPanel.vue +4 -15
  143. package/app/components/settings/OpenRouterCatalogPanel.vue +8 -24
  144. package/app/components/settings/PackageRegistriesPanel.vue +2 -6
  145. package/app/components/settings/ProviderConnectionTab.vue +3 -7
  146. package/app/components/settings/RiskPolicyPanel.vue +4 -16
  147. package/app/components/settings/SharedStacksPanel.vue +8 -18
  148. package/app/components/settings/TaskTypeSuppressionsPanel.vue +10 -6
  149. package/app/components/settings/UsageSettings.vue +11 -2
  150. package/app/components/settings/UserSecretsSection.vue +5 -7
  151. package/app/components/settings/WorkspaceMetadataSettings.vue +9 -9
  152. package/app/components/settings/WorkspaceSettingsPanel.vue +3 -11
  153. package/app/components/skills/ReviewSkillQueue.vue +7 -3
  154. package/app/components/skills/SkillLibraryManager.vue +20 -18
  155. package/app/components/slack/SlackPanel.vue +5 -8
  156. package/app/components/spec/ServiceSpecWindow.vue +32 -29
  157. package/app/components/tasks/BugHuntModal.vue +23 -19
  158. package/app/components/tasks/ContextIssuePicker.vue +2 -2
  159. package/app/components/tasks/TaskContextIssues.vue +4 -5
  160. package/app/components/tasks/TaskImportModal.vue +10 -8
  161. package/app/components/tasks/TaskSourceConnectModal.vue +11 -11
  162. package/app/components/testing/TestReportWindow.vue +6 -12
  163. package/app/components/tutorial/TutorialCatalogue.vue +7 -3
  164. package/app/components/tutorial/TutorialOverlay.vue +2 -1
  165. package/app/components/tutorial/TutorialPrompt.vue +7 -3
  166. package/app/components/vcs/GitLabConnect.vue +2 -6
  167. package/app/components/visualConfirm/VisualConfirmationWindow.vue +9 -4
  168. package/app/composables/toastTitleKeys.spec.ts +143 -0
  169. package/app/composables/useActionToast.spec.ts +71 -0
  170. package/app/composables/useActionToast.ts +101 -0
  171. package/app/composables/useBlockDeletion.ts +3 -6
  172. package/app/composables/useConfirmAction.ts +5 -3
  173. package/app/composables/useContextLinking.spec.ts +3 -0
  174. package/app/composables/useContextLinking.ts +11 -16
  175. package/app/composables/useCopyToClipboard.ts +4 -3
  176. package/app/composables/useFrameLanes.ts +6 -82
  177. package/app/composables/useNavContributions.ts +6 -5
  178. package/app/composables/usePipelineErrorToast.spec.ts +24 -0
  179. package/app/composables/usePipelineErrorToast.ts +26 -2
  180. package/app/composables/usePipelineLibraryActions.ts +8 -9
  181. package/app/composables/useTaskActions.ts +254 -0
  182. package/app/composables/useTaskLaneClassifier.ts +88 -0
  183. package/app/modular/nav-contributions.spec.ts +4 -0
  184. package/app/modular/nav-contributions.ts +31 -0
  185. package/app/modular/tutorial-tours.ts +10 -0
  186. package/app/pages/index.vue +42 -8
  187. package/app/stores/board/context.ts +4 -3
  188. package/app/stores/board/placement.ts +8 -20
  189. package/app/stores/board/removal.ts +4 -13
  190. package/app/stores/board.spec.ts +10 -1
  191. package/app/stores/board.ts +3 -2
  192. package/app/stores/workView.ts +51 -0
  193. package/app/stores/workspaceQueue.ts +148 -0
  194. package/app/utils/bootstrapSteps.ts +11 -2
  195. package/app/utils/pipelineRender.ts +21 -8
  196. package/app/utils/queueSections.spec.ts +111 -0
  197. package/app/utils/queueSections.ts +117 -0
  198. package/app/utils/workView.spec.ts +16 -0
  199. package/app/utils/workView.ts +25 -0
  200. package/i18n/locales/de.json +73 -32
  201. package/i18n/locales/en.json +73 -32
  202. package/i18n/locales/es.json +73 -32
  203. package/i18n/locales/fr.json +73 -32
  204. package/i18n/locales/he.json +73 -32
  205. package/i18n/locales/it.json +73 -32
  206. package/i18n/locales/ja.json +73 -32
  207. package/i18n/locales/pl.json +73 -32
  208. package/i18n/locales/tr.json +73 -32
  209. package/i18n/locales/uk.json +73 -32
  210. package/package.json +1 -1
@@ -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
 
@@ -1,28 +1,18 @@
1
1
  import { computed, type Ref } from 'vue'
2
- import type { Block } from '~/types/domain'
3
2
  import { createLaneMemo } from '~/utils/laneIdentity'
4
3
  import {
5
4
  groupLaneTasks,
6
- runActivityAt,
7
- runWaitingSince,
8
5
  sortLaneTasks,
9
6
  type LaneTaskEntry,
10
7
  type RenderedLane,
11
8
  } from '~/utils/laneSort'
12
9
  import {
13
- classifyTask,
14
10
  selectDoneLaneTasks,
15
11
  TASK_LANES,
16
12
  type DoneLaneSelection,
17
13
  type TaskLane,
18
14
  } from '~/utils/swimlanes'
19
15
 
20
- /** A lane entry plus the lane it was classified into. */
21
- interface ClassifiedEntry {
22
- readonly entry: LaneTaskEntry
23
- readonly lane: TaskLane
24
- }
25
-
26
16
  /**
27
17
  * Assemble a service frame's tasks into swimlanes.
28
18
  *
@@ -34,12 +24,10 @@ interface ClassifiedEntry {
34
24
  */
35
25
  export function useFrameLanes(frameId: Ref<string>) {
36
26
  const board = useBoardStore()
37
- const execution = useExecutionStore()
38
- const agentRuns = useAgentRunsStore()
39
- const notifications = useNotificationsStore()
40
27
  const settings = useWorkspaceSettingsStore()
41
28
  const laneView = useLaneViewStore()
42
- const reviews = useReviewStage()
29
+ // The workspace-wide classification: every frame and the queue read one verdict per task.
30
+ const queue = useWorkspaceQueueStore()
43
31
 
44
32
  /** Tasks directly in the frame plus those inside its modules: a module renders no box now. */
45
33
  const tasks = computed(() => board.allTasksUnder(frameId.value))
@@ -49,78 +37,14 @@ export function useFrameLanes(frameId: Ref<string>) {
49
37
  () => new Map(board.modulesOf(frameId.value).map((m) => [m.title, m.id])),
50
38
  )
51
39
 
52
- /**
53
- * The module a task belongs to: the module BLOCK's title when it already lives in one, else
54
- * the module it DECLARES. The engine only materialises the block on merge
55
- * (`applyModuleAssignment`), so keying on the parent alone would leave every unmerged task in
56
- * "no module" while its own card names one.
57
- */
58
- function moduleNameOf(task: Block): string | null {
59
- const parent = task.parentId ? board.getBlock(task.parentId) : undefined
60
- if (parent?.level === 'module') return parent.title
61
- return task.moduleName?.trim() || null
62
- }
63
-
64
- /**
65
- * One task's lane, reason and rendered entry.
66
- *
67
- * Every cross-block lookup here is a Map read off an index a STORE maintains, never a reduction
68
- * of its own: this composable runs ONE INSTANCE PER MOUNTED FRAME, so deriving a workspace-wide
69
- * fact here makes a board with n frames pay for it n times on every change. The review-debt map
70
- * (`notifications.reviewDebtByBlock`) is the worked example: a reduction over the whole
71
- * workspace's open notifications, and it belongs on the store that owns its input. The rule for
72
- * anything else this assembly needs: a per-FRAME derivation belongs here, a workspace-wide one
73
- * belongs on that store.
74
- */
75
- function classify(task: Block, order: number): ClassifiedEntry {
76
- const run = execution.getByBlock(task.id) ?? null
77
- const decisions = execution.decisionsByBlock.get(task.id) ?? []
78
- const allApprovals = execution.approvalsByBlock.get(task.id) ?? []
79
- // The same suppression the card and the frame badge apply: an iterative reviewer mid-cycle
80
- // holds a pending approval while the driver folds answers in, and nobody is waiting on it.
81
- const humanApprovals = allApprovals.filter((a) => !reviews.isBackground(a.agentKind, a.blockId))
82
-
83
- const { lane, reason } = classifyTask({
84
- status: task.status,
85
- // Read from the coarse per-block summary, which also covers a bootstrap run.
86
- runFailed: agentRuns.byBlock[task.id]?.status === 'failed',
87
- run,
88
- // A park is background exactly when everything asking was suppressed AND nothing else
89
- // asks. With no approvals at all it is NOT background: it is a park on a surface this
90
- // layer cannot name, which `classifyTask` reports as `parked` rather than as work.
91
- parkIsBackground:
92
- decisions.length === 0 &&
93
- humanApprovals.length === 0 &&
94
- allApprovals.length > humanApprovals.length,
95
- pendingDecision: decisions.length > 0,
96
- pendingApproval: humanApprovals.length > 0,
97
- hasUnmetDeps: board.unmetDeps(task.id).length > 0,
98
- })
99
-
100
- return {
101
- lane,
102
- entry: {
103
- task,
104
- reason,
105
- order,
106
- activityAt: runActivityAt(run),
107
- waitingSince: runWaitingSince(run, notifications.reviewDebtByBlock.get(task.id) ?? null),
108
- moduleName: moduleNameOf(task),
109
- initiativeName: task.initiativeId
110
- ? (board.getBlock(task.initiativeId)?.title ?? null)
111
- : null,
112
- epicName: board.epicOf(task)?.title ?? null,
113
- },
114
- }
115
- }
116
-
117
40
  /** Every task bucketed by lane, in board order, before sorting. */
118
41
  const byLane = computed(() => {
119
42
  const buckets = new Map<TaskLane, LaneTaskEntry[]>(TASK_LANES.map((lane) => [lane, []]))
120
- tasks.value.forEach((task, order) => {
121
- const { lane, entry } = classify(task, order)
43
+ for (const task of tasks.value) {
44
+ // The store walks every frame's tasks, this one's included, so each has a verdict.
45
+ const { lane, entry } = queue.classified.get(task.id)!
122
46
  buckets.get(lane)!.push(entry)
123
- })
47
+ }
124
48
  return buckets
125
49
  })
126
50
 
@@ -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()
@@ -71,6 +72,8 @@ export function useNavContributions() {
71
72
  // The QUESTION, not a toggle: with three roles there is no unambiguous "next one", and the
72
73
  // prompt is the one surface that states what each role gives you before you pick it.
73
74
  chooseRole: () => useUiRoleStore().openPrompt(),
75
+ queueView: () => useWorkViewStore().show('queue'),
76
+ boardView: () => useWorkViewStore().show('board'),
74
77
  }
75
78
 
76
79
  /** Run a contribution's action (consumer `run` closure wins over the id map). */
@@ -148,13 +151,11 @@ export function useNavContributions() {
148
151
  resolution.cause,
149
152
  )
150
153
  }
151
- toast.add({
152
- title: t('externalTools.unavailable.title', { tool: tool.title }),
154
+ actionToast.error('externalTools.unavailable.title', {
155
+ params: { tool: tool.title },
153
156
  description: t(EXTERNAL_TOOL_UNAVAILABLE_KEYS[resolution.reason], {
154
157
  fields: resolution.missing.join(', '),
155
158
  }),
156
- icon: 'i-lucide-triangle-alert',
157
- color: 'warning',
158
159
  })
159
160
  },
160
161
  },