@cat-factory/app 0.261.2 → 0.261.4

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 (90) hide show
  1. package/README.md +40 -0
  2. package/app/components/board/AddTaskModal.vue +10 -6
  3. package/app/components/board/AgentStopButton.vue +2 -5
  4. package/app/components/board/CreateInitiativeModal.vue +2 -7
  5. package/app/components/board/RecurringPipelineModal.vue +10 -6
  6. package/app/components/bootstrap/BootstrapModal.vue +5 -24
  7. package/app/components/brainstorm/BrainstormWindow.vue +7 -15
  8. package/app/components/clarity/ClarityReviewWindow.vue +7 -15
  9. package/app/components/documents/DocumentImportModal.vue +2 -6
  10. package/app/components/documents/DocumentSourceConnectModal.vue +3 -12
  11. package/app/components/documents/DocumentSyncState.vue +2 -7
  12. package/app/components/documents/DocumentTemplatesModal.vue +4 -19
  13. package/app/components/documents/SpawnPreviewModal.vue +3 -12
  14. package/app/components/foundational/FoundationalServiceCatalogList.vue +3 -11
  15. package/app/components/foundational/FoundationalServiceRegistry.vue +3 -11
  16. package/app/components/foundational/FoundationalServiceSources.vue +5 -13
  17. package/app/components/foundational/FoundationalSuppressions.vue +2 -6
  18. package/app/components/fragments/FragmentLibraryManager.vue +12 -20
  19. package/app/components/github/AddServiceFromRepoModal.vue +3 -12
  20. package/app/components/github/GitHubConnect.vue +4 -12
  21. package/app/components/github/GitHubPanel.vue +9 -17
  22. package/app/components/github/RepoTreeBrowser.vue +2 -7
  23. package/app/components/humanTest/HumanTestWindow.vue +2 -7
  24. package/app/components/initiative/InitiativeTrackerWindow.vue +2 -3
  25. package/app/components/layout/AccountAuditLog.vue +2 -7
  26. package/app/components/layout/AccountDeploymentSettings.vue +11 -51
  27. package/app/components/layout/AccountModelPolicySettings.vue +2 -5
  28. package/app/components/layout/AccountPlatformAlertSettings.vue +2 -5
  29. package/app/components/layout/AccountRunCredentialSettings.vue +2 -5
  30. package/app/components/layout/AccountTeamSettings.vue +9 -18
  31. package/app/components/layout/BoardSwitcher.vue +6 -14
  32. package/app/components/layout/BoardToolbar.vue +3 -10
  33. package/app/components/layout/NotificationsInbox.vue +3 -13
  34. package/app/components/layout/WorkspaceMembersSettings.vue +6 -15
  35. package/app/components/notifications/NotificationSettingsPanel.vue +3 -11
  36. package/app/components/panels/DecisionModal.vue +2 -7
  37. package/app/components/panels/inspector/RecurringScheduleSettings.vue +4 -20
  38. package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +3 -11
  39. package/app/components/panels/inspector/ServiceTestSecrets.vue +3 -11
  40. package/app/components/panels/inspector/ServiceValidationConfig.vue +4 -12
  41. package/app/components/pipeline/PipelineBuilder.vue +4 -7
  42. package/app/components/providers/ApiKeysSection.vue +4 -15
  43. package/app/components/providers/PersonalCredentialModal.vue +2 -5
  44. package/app/components/providers/PersonalSubscriptionSection.vue +3 -10
  45. package/app/components/providers/VendorCredentialsModal.vue +5 -20
  46. package/app/components/requirements/RequirementsReviewWindow.vue +11 -19
  47. package/app/components/sandbox/SandboxPanel.vue +4 -18
  48. package/app/components/settings/ApiTokensPanel.vue +4 -12
  49. package/app/components/settings/BudgetSettings.vue +2 -5
  50. package/app/components/settings/CloudflareHandlerSection.vue +3 -12
  51. package/app/components/settings/ConsensusGroupsSection.vue +3 -12
  52. package/app/components/settings/CustomManifestTypeEditor.vue +3 -13
  53. package/app/components/settings/DefaultProvisionTypeSection.vue +2 -5
  54. package/app/components/settings/InfraHandlersConfigurator.vue +7 -14
  55. package/app/components/settings/InfrastructureBackendPicker.vue +2 -7
  56. package/app/components/settings/IssueTrackerPanel.vue +4 -18
  57. package/app/components/settings/LocalContainerPoolSettings.vue +2 -6
  58. package/app/components/settings/LocalModelEndpointsPanel.vue +3 -11
  59. package/app/components/settings/ModelConfigurationPanel.vue +13 -16
  60. package/app/components/settings/ModelPresetHealthModal.vue +3 -13
  61. package/app/components/settings/ObservabilityConnectionPanel.vue +6 -14
  62. package/app/components/settings/OpenRouterCatalogPanel.vue +3 -12
  63. package/app/components/settings/PackageRegistriesPanel.vue +4 -12
  64. package/app/components/settings/ProviderConnectionTab.vue +5 -13
  65. package/app/components/settings/RiskPolicyHealthModal.vue +2 -7
  66. package/app/components/settings/RiskPolicyPanel.vue +5 -13
  67. package/app/components/settings/ServiceFragmentDefaultsPanel.vue +2 -7
  68. package/app/components/settings/SharedStacksPanel.vue +10 -20
  69. package/app/components/settings/TaskTypeSuppressionsPanel.vue +3 -12
  70. package/app/components/settings/UserSecretsSection.vue +3 -11
  71. package/app/components/settings/WorkspaceMetadataSettings.vue +2 -6
  72. package/app/components/settings/WorkspaceSettingsPanel.vue +2 -6
  73. package/app/components/skills/SkillLibraryManager.vue +5 -13
  74. package/app/components/slack/SlackPanel.vue +7 -15
  75. package/app/components/tasks/BugHuntModal.vue +2 -6
  76. package/app/components/tasks/TaskImportModal.vue +2 -6
  77. package/app/components/tasks/TaskSourceConnectModal.vue +4 -18
  78. package/app/composables/useBlockDeletion.ts +2 -6
  79. package/app/composables/useCopyToClipboard.ts +7 -1
  80. package/app/composables/usePipelineErrorToast.spec.ts +76 -16
  81. package/app/composables/usePipelineErrorToast.ts +109 -50
  82. package/app/stores/board/context.ts +9 -2
  83. package/app/stores/board/dependencies.ts +3 -13
  84. package/app/stores/board/mutations.ts +2 -7
  85. package/app/stores/board/placement.ts +12 -25
  86. package/app/stores/board/removal.ts +2 -7
  87. package/app/stores/board.spec.ts +15 -8
  88. package/app/stores/board.ts +11 -1
  89. package/app/stores/recurringPipelines.ts +5 -11
  90. package/package.json +2 -2
@@ -16,6 +16,7 @@ const props = defineProps<{ accountId: string }>()
16
16
  const library = useSkillLibrary(props.accountId)
17
17
  const github = useGitHubStore()
18
18
  const toast = useToast()
19
+ const { present } = usePipelineErrorToast()
19
20
  const { t, d } = useI18n()
20
21
  const { confirm } = useConfirm()
21
22
 
@@ -33,15 +34,6 @@ watch(
33
34
  // the integration is off) the form falls back to manual text entry.
34
35
  const githubReady = computed(() => github.available === true && github.connected)
35
36
 
36
- function notifyError(title: string, e: unknown) {
37
- toast.add({
38
- title,
39
- description: e instanceof Error ? e.message : String(e),
40
- icon: 'i-lucide-triangle-alert',
41
- color: 'error',
42
- })
43
- }
44
-
45
37
  // Per-row in-flight tracking so only the control that triggered an action spins.
46
38
  const busyRows = reactive(new Set<string>())
47
39
  const rowBusy = (key: string) => busyRows.has(key)
@@ -102,7 +94,7 @@ async function linkSource() {
102
94
  resetSourceDraft()
103
95
  toast.add({ title: t('skills.toast.sourceLinked'), icon: 'i-lucide-git-branch' })
104
96
  } catch (e) {
105
- notifyError(t('skills.toast.linkSourceFailed'), e)
97
+ present(e, 'skills.toast.linkSourceFailed')
106
98
  } finally {
107
99
  linkingSource.value = false
108
100
  }
@@ -121,7 +113,7 @@ async function syncSource(id: string) {
121
113
  color: 'info',
122
114
  })
123
115
  } catch (e) {
124
- notifyError(t('skills.toast.syncFailed'), e)
116
+ present(e, 'skills.toast.syncFailed')
125
117
  }
126
118
  })
127
119
  }
@@ -135,7 +127,7 @@ async function checkSource(id: string) {
135
127
  icon: status.changed ? 'i-lucide-bell-dot' : 'i-lucide-check',
136
128
  })
137
129
  } catch (e) {
138
- notifyError(t('skills.toast.checkSourceFailed'), e)
130
+ present(e, 'skills.toast.checkSourceFailed')
139
131
  }
140
132
  })
141
133
  }
@@ -156,7 +148,7 @@ async function unlinkSource(id: string) {
156
148
  await library.unlinkSource(id)
157
149
  toast.add({ title: t('skills.toast.sourceUnlinked'), icon: 'i-lucide-unplug' })
158
150
  } catch (e) {
159
- notifyError(t('skills.toast.unlinkSourceFailed'), e)
151
+ present(e, 'skills.toast.unlinkSourceFailed')
160
152
  }
161
153
  })
162
154
  }
@@ -20,6 +20,7 @@ import SecretInput from '~/components/common/SecretInput.vue'
20
20
  const ui = useUiStore()
21
21
  const slack = useSlackStore()
22
22
  const toast = useToast()
23
+ const { present } = usePipelineErrorToast()
23
24
  const { t } = useI18n()
24
25
  const { confirm } = useConfirm()
25
26
 
@@ -90,15 +91,6 @@ const tokenInput = ref('')
90
91
  const busy = ref(false)
91
92
  const connectingOAuth = ref(false)
92
93
 
93
- function notifyError(title: string, e: unknown) {
94
- toast.add({
95
- title,
96
- description: e instanceof Error ? e.message : String(e),
97
- icon: 'i-lucide-triangle-alert',
98
- color: 'error',
99
- })
100
- }
101
-
102
94
  // Load everything the panel needs whenever it opens and Slack is connected.
103
95
  watch(
104
96
  () => open.value,
@@ -112,7 +104,7 @@ watch(
112
104
  mentionsEnabled.value = slack.settings?.mentionsEnabled ?? false
113
105
  mapping.value = slack.memberMapping.map((e) => toMemberRow(e, nextUid()))
114
106
  } catch (e) {
115
- notifyError(t('slack.error.loadSettings'), e)
107
+ present(e, 'slack.error.loadSettings')
116
108
  }
117
109
  },
118
110
  // Lazy v-if mount: the panel mounts with `open` already true, so load immediately.
@@ -127,7 +119,7 @@ async function connectViaOAuth() {
127
119
  window.location.href = await slack.installUrl()
128
120
  } catch (e) {
129
121
  connectingOAuth.value = false
130
- notifyError(t('slack.error.startOAuth'), e)
122
+ present(e, 'slack.error.startOAuth')
131
123
  }
132
124
  }
133
125
 
@@ -138,7 +130,7 @@ async function connectWithToken() {
138
130
  tokenInput.value = ''
139
131
  toast.add({ title: t('slack.toast.connected'), icon: 'i-lucide-check', color: 'success' })
140
132
  } catch (e) {
141
- notifyError(t('slack.error.connect'), e)
133
+ present(e, 'slack.error.connect')
142
134
  }
143
135
  }
144
136
 
@@ -154,7 +146,7 @@ async function disconnect() {
154
146
  try {
155
147
  await slack.disconnect()
156
148
  } catch (e) {
157
- notifyError(t('slack.error.disconnect'), e)
149
+ present(e, 'slack.error.disconnect')
158
150
  }
159
151
  }
160
152
 
@@ -167,7 +159,7 @@ async function saveRouting() {
167
159
  })
168
160
  toast.add({ title: t('slack.toast.routingSaved'), icon: 'i-lucide-check', color: 'success' })
169
161
  } catch (e) {
170
- notifyError(t('slack.error.saveRouting'), e)
162
+ present(e, 'slack.error.saveRouting')
171
163
  } finally {
172
164
  busy.value = false
173
165
  }
@@ -199,7 +191,7 @@ async function saveMapping() {
199
191
  mapping.value = slack.memberMapping.map((e) => toMemberRow(e, nextUid()))
200
192
  toast.add({ title: t('slack.toast.mapSaved'), icon: 'i-lucide-check', color: 'success' })
201
193
  } catch (e) {
202
- notifyError(t('slack.error.saveMap'), e)
194
+ present(e, 'slack.error.saveMap')
203
195
  } finally {
204
196
  busy.value = false
205
197
  }
@@ -43,6 +43,7 @@ const ui = useUiStore()
43
43
  const tasks = useTasksStore()
44
44
  const hunt = useBugHuntStore()
45
45
  const toast = useToast()
46
+ const { present } = usePipelineErrorToast()
46
47
 
47
48
  const open = computed({
48
49
  get: () => ui.bugHunt !== null,
@@ -212,12 +213,7 @@ async function adopt(candidate: BugHuntCandidate) {
212
213
  icon: 'i-lucide-bug-play',
213
214
  })
214
215
  } catch (e) {
215
- toast.add({
216
- title: t('bugHunt.adoptFailed'),
217
- description: e instanceof Error ? e.message : String(e),
218
- icon: 'i-lucide-triangle-alert',
219
- color: 'error',
220
- })
216
+ present(e, 'bugHunt.adoptFailed')
221
217
  }
222
218
  }
223
219
 
@@ -22,6 +22,7 @@ const { t } = useI18n()
22
22
  const ui = useUiStore()
23
23
  const tasks = useTasksStore()
24
24
  const toast = useToast()
25
+ const { present } = usePipelineErrorToast()
25
26
 
26
27
  const open = computed({
27
28
  get: () => ui.taskImport !== null,
@@ -112,12 +113,7 @@ async function doSpawnEpic() {
112
113
  icon: 'i-lucide-layers',
113
114
  })
114
115
  } catch (e) {
115
- toast.add({
116
- title: t('tasks.import.epicFailed'),
117
- description: e instanceof Error ? e.message : String(e),
118
- icon: 'i-lucide-triangle-alert',
119
- color: 'error',
120
- })
116
+ present(e, 'tasks.import.epicFailed')
121
117
  } finally {
122
118
  importing.value = false
123
119
  }
@@ -17,6 +17,7 @@ const { t } = useI18n()
17
17
  const ui = useUiStore()
18
18
  const tasks = useTasksStore()
19
19
  const toast = useToast()
20
+ const { present } = usePipelineErrorToast()
20
21
  const { confirmAction } = useConfirmAction()
21
22
 
22
23
  const source = computed(() => ui.taskConnect?.source ?? null)
@@ -70,12 +71,7 @@ async function submit() {
70
71
  // Re-probe so `available`/`enabled` reflect the new connection.
71
72
  await tasks.probe()
72
73
  } catch (e) {
73
- toast.add({
74
- title: t('tasks.connect.connectFailed'),
75
- description: e instanceof Error ? e.message : String(e),
76
- icon: 'i-lucide-triangle-alert',
77
- color: 'error',
78
- })
74
+ present(e, 'tasks.connect.connectFailed')
79
75
  } finally {
80
76
  saving.value = false
81
77
  }
@@ -88,12 +84,7 @@ async function startOAuth() {
88
84
  // Only Linear wires an OAuth flow today; the browser navigates away on success.
89
85
  if (source.value === 'linear') await tasks.startLinearOAuth()
90
86
  } catch (e) {
91
- toast.add({
92
- title: t('tasks.connect.connectFailed'),
93
- description: e instanceof Error ? e.message : String(e),
94
- icon: 'i-lucide-triangle-alert',
95
- color: 'error',
96
- })
87
+ present(e, 'tasks.connect.connectFailed')
97
88
  oauthStarting.value = false
98
89
  }
99
90
  }
@@ -117,12 +108,7 @@ async function toggleEnabled(enabled: boolean) {
117
108
  try {
118
109
  await tasks.setEnabled(source.value, enabled)
119
110
  } catch (e) {
120
- toast.add({
121
- title: t('tasks.connect.updateFailed'),
122
- description: e instanceof Error ? e.message : String(e),
123
- icon: 'i-lucide-triangle-alert',
124
- color: 'error',
125
- })
111
+ present(e, 'tasks.connect.updateFailed')
126
112
  } finally {
127
113
  togglingEnabled.value = false
128
114
  }
@@ -16,6 +16,7 @@ export function useBlockDeletion() {
16
16
  const ui = useUiStore()
17
17
  const recurring = useRecurringPipelinesStore()
18
18
  const toast = useToast()
19
+ const { present } = usePipelineErrorToast()
19
20
  const { confirm } = useConfirm()
20
21
  const { t } = useI18n()
21
22
  const access = useWorkspaceAccess()
@@ -57,12 +58,7 @@ export function useBlockDeletion() {
57
58
  color: 'neutral',
58
59
  })
59
60
  } catch (e) {
60
- toast.add({
61
- title: t('board.toast.archiveFailed'),
62
- description: e instanceof Error ? e.message : String(e),
63
- icon: 'i-lucide-triangle-alert',
64
- color: 'error',
65
- })
61
+ present(e, 'board.toast.archiveFailed')
66
62
  }
67
63
  return true
68
64
  }
@@ -9,7 +9,13 @@
9
9
  import { useClipboard } from '@vueuse/core'
10
10
 
11
11
  export function useCopyToClipboard() {
12
- const { t } = useI18n()
12
+ // Resolved through the Nuxt app's global i18n instance rather than `useI18n()`, which requires an
13
+ // active component instance. This composable is reached from the failure-toast funnel, which is
14
+ // itself instantiated in STORE setup (`stores/execution.ts`, `stores/board.ts`), and a Pinia
15
+ // setup store runs its body on the first `useStore()` anywhere, which is not necessarily a
16
+ // component. `useI18n()` there throws `MUST_BE_CALL_SETUP_TOP`, and a throw in a store body takes
17
+ // the whole app to Nuxt's error page. Same reason, and the same fix, as `usePipelineErrorToast`.
18
+ const { t } = useNuxtApp().$i18n as ReturnType<typeof useI18n>
13
19
  const toast = useToast()
14
20
  const { copy: writeClipboard, isSupported } = useClipboard()
15
21
 
@@ -7,6 +7,25 @@ import {
7
7
  import { ApiError } from '~/composables/api/errors'
8
8
  import en from '../../i18n/locales/en.json'
9
9
 
10
+ /**
11
+ * The clipboard seam is mocked so the COPIED TEXT is assertable (and so the spec needs no
12
+ * `navigator.clipboard`). What the copy action carries is the whole point of it: a reader pastes
13
+ * that string into a bug report, and if the `requestId` is missing from it there is nothing joining
14
+ * the report to the one server log line that explains the failure.
15
+ */
16
+ const copied: string[] = []
17
+ vi.mock('~/composables/useCopyToClipboard', () => ({
18
+ useCopyToClipboard: () => ({
19
+ copy: async (text: string) => void copied.push(text),
20
+ copyAction: (text: string) => ({
21
+ label: 'common.copyDetails',
22
+ icon: 'i-lucide-clipboard',
23
+ onClick: () => void copied.push(text),
24
+ }),
25
+ isSupported: { value: true },
26
+ }),
27
+ }))
28
+
10
29
  /**
11
30
  * The i18n pilot: the pipeline-error toast resolves user-facing copy from
12
31
  * `errors.conflict.*` message KEYS by the backend's machine-readable `reason` — both the
@@ -93,21 +112,30 @@ describe('usePipelineErrorToast', () => {
93
112
  expect(arg.description).toBe('errors.conflict.description.dependencies_unmet')
94
113
  })
95
114
 
96
- it('falls back to the caller fallback key + raw message for an UNKNOWN reason', () => {
115
+ it('keeps raw prose as DETAIL for an UNKNOWN reason, never as the headline', () => {
116
+ // An unmapped reason is the path a reason this build has never heard of takes, so it is the
117
+ // one that most needs the funnel's two properties rather than the least: translated copy up
118
+ // front, the backend's untranslated prose behind the disclosure.
97
119
  usePipelineErrorToast().present(
98
120
  conflict('totally_unknown_reason', {}, 'raw detail'),
99
121
  'errors.action.retryFailed',
100
122
  )
101
123
  const arg = add.mock.calls[0]![0]
102
124
  expect(arg.title).toBe('errors.action.retryFailed')
103
- // Unmapped reason ⇒ raw backend prose is the last-resort description.
104
- expect(arg.description).toBe('raw detail')
105
- expect(arg.actions).toBeUndefined()
125
+ expect(arg.description).toBe('errors.conflict.fallbackMessage')
126
+ arg.actions[0].onClick()
127
+ expect(update).toHaveBeenCalledWith(
128
+ 'toast-1',
129
+ expect.objectContaining({ description: 'raw detail' }),
130
+ )
106
131
  })
107
132
 
108
133
  it('shows the fallback message for an unknown reason with no backend message', () => {
109
134
  usePipelineErrorToast().present(conflict('totally_unknown_reason'))
110
- expect(add.mock.calls[0]![0].description).toBe('errors.conflict.fallbackMessage')
135
+ const arg = add.mock.calls[0]![0]
136
+ expect(arg.description).toBe('errors.conflict.fallbackMessage')
137
+ // Nothing to disclose ⇒ no reveal, but the copy action is still the point.
138
+ expect(arg.actions).toHaveLength(1)
111
139
  })
112
140
 
113
141
  it('offers a jump action for a reason with a UI remedy (github_not_connected → connect GitHub)', () => {
@@ -122,11 +150,15 @@ describe('usePipelineErrorToast', () => {
122
150
  expect(ui.openGitHub).toHaveBeenCalledOnce()
123
151
  })
124
152
 
125
- it('leaves a reason without a UI remedy as a plain (auto-dismissing) toast', () => {
153
+ it('keeps a conflict on screen and copyable even with no UI remedy to jump to', () => {
154
+ // A conflict is a failure like any other: the reader has to be able to finish it, quote it,
155
+ // and paste it into a report. A remedy-less reason used to be the one that auto-dismissed.
126
156
  usePipelineErrorToast().present(conflict('dependencies_unmet'))
127
157
  const arg = add.mock.calls[0]![0]
128
- expect(arg.duration).toBeUndefined()
129
- expect(arg.actions).toBeUndefined()
158
+ expect(arg.duration).toBe(0)
159
+ expect(arg.actions.map((a: { label: string }) => a.label)).toEqual(['common.copyDetails'])
160
+ arg.actions[0].onClick()
161
+ expect(copied.at(-1)).toContain('errors.conflict.description.dependencies_unmet')
130
162
  })
131
163
 
132
164
  it('interpolates the model list for providers_unconfigured and offers the AI setup jump', () => {
@@ -159,24 +191,48 @@ describe('usePipelineErrorToast', () => {
159
191
  expect(add.mock.calls[0]![0].description).toBe('errors.generic.description.unavailable')
160
192
  })
161
193
 
162
- it('reveals the raw detail in place when "Show details" is clicked, and makes it sticky', () => {
194
+ it('reveals the raw detail in place when "Show details" is clicked', () => {
163
195
  usePipelineErrorToast().present(
164
196
  new ApiError(503, { error: { code: 'unavailable', message: 'Task sources not configured' } }),
165
197
  )
166
198
  const arg = add.mock.calls[0]![0]
167
- // Auto-dismissing until the user asks for detail: no `duration` override up front.
168
- expect(arg.duration).toBeUndefined()
169
199
  expect(arg.actions[0].label).toBe('errors.generic.showDetail')
170
200
  arg.actions[0].onClick()
171
- // Same toast, not a second one; sticky, and the button is dropped so it can't be re-clicked.
201
+ // The SAME toast, not a second one, so the two readings can't sit on screen disagreeing. The
202
+ // disclosure button is dropped (it would now be a no-op) and the copy action is re-passed,
203
+ // because `update` merges over the existing toast and copying is still the point.
172
204
  expect(add).toHaveBeenCalledTimes(1)
173
205
  expect(update).toHaveBeenCalledWith('toast-1', {
174
206
  description: 'Task sources not configured',
175
- duration: 0,
176
- actions: [],
207
+ actions: [expect.objectContaining({ label: 'common.copyDetails' })],
177
208
  })
178
209
  })
179
210
 
211
+ it('does NOT auto-dismiss, and can be copied whole in one click', () => {
212
+ // Both properties are the reason a failure goes through this funnel at all: the toast a user
213
+ // needs to read, quote or act on used to vanish after ~5s, and its detail could only be
214
+ // retyped off the screen. `duration: 0` keeps it until dismissed (it still has a close
215
+ // button); the copy action carries the failed action, the failure class, the backend prose and
216
+ // the requestId in one string.
217
+ usePipelineErrorToast().present(
218
+ new ApiError(503, {
219
+ error: { code: 'unavailable', message: 'Task sources not configured', requestId: 'req-9' },
220
+ }),
221
+ 'errors.action.startFailed',
222
+ )
223
+ const arg = add.mock.calls[0]![0]
224
+ expect(arg.duration).toBe(0)
225
+ const copy = arg.actions.find((a: { label: string }) => a.label === 'common.copyDetails')
226
+ copy.onClick()
227
+ expect(copied[copied.length - 1]).toBe(
228
+ [
229
+ 'errors.action.startFailed',
230
+ 'errors.generic.description.unavailable',
231
+ 'Task sources not configured · errors.generic.requestId',
232
+ ].join('\n'),
233
+ )
234
+ })
235
+
180
236
  it('folds validation issues and the requestId into the revealed detail', () => {
181
237
  usePipelineErrorToast().present(
182
238
  new ApiError(400, {
@@ -199,7 +255,7 @@ describe('usePipelineErrorToast', () => {
199
255
  )
200
256
  })
201
257
 
202
- it('offers no disclosure when there is no detail to reveal', () => {
258
+ it('offers no disclosure when there is no detail to reveal, but still offers the copy', () => {
203
259
  usePipelineErrorToast().present(new ApiError(503, { error: { code: 'unavailable' } }))
204
260
  const arg = add.mock.calls[0]![0]
205
261
  // `ApiError` synthesises `Request failed (HTTP 503)` when the envelope carries no message,
@@ -207,7 +263,11 @@ describe('usePipelineErrorToast', () => {
207
263
  expect(arg.description).toBe('errors.generic.description.unavailable')
208
264
  expect(arg.actions[0].label).toBe('errors.generic.showDetail')
209
265
  usePipelineErrorToast().present(null)
210
- expect(add.mock.calls[1]![0].actions).toBeUndefined()
266
+ // A disclosure that reveals nothing is worse than none, but the two translated lines are still
267
+ // worth copying: they name the action that failed and the class of failure.
268
+ expect(add.mock.calls[1]![0].actions).toEqual([
269
+ expect.objectContaining({ label: 'common.copyDetails' }),
270
+ ])
211
271
  })
212
272
  })
213
273
 
@@ -25,6 +25,9 @@
25
25
  */
26
26
 
27
27
  import { createBespokeConflictToasts } from '~/composables/pipelineErrorToast/bespokeConflicts'
28
+ // Imported by path rather than left to Nuxt's auto-import: this module is also loaded directly by
29
+ // unit tests (and from store setup), where the auto-import globals are not installed.
30
+ import { useCopyToClipboard } from '~/composables/useCopyToClipboard'
28
31
  import type { ApiErrorCode, ConflictReason, UnavailableReason } from '@cat-factory/contracts'
29
32
  import { apiErrorEnvelope, apiErrorReason, apiErrorStatus } from './api/errors'
30
33
 
@@ -424,6 +427,9 @@ export function usePipelineErrorToast() {
424
427
  // tier 1 only sees literal keys written in a `<script setup>`, never in a `.ts` composable —
425
428
  // the drift guard here is the exhaustive `CONFLICT_INFO` / `ApiErrorCode` records above.
426
429
  const { t, te } = useNuxtApp().$i18n as ReturnType<typeof useI18n>
430
+ // The shared clipboard seam (feedback toast included), so "Copy details" behaves like every other
431
+ // copy affordance in the product instead of silently failing where the API is unavailable.
432
+ const { copyAction } = useCopyToClipboard()
427
433
 
428
434
  // The five bespoke conflict reasons (a runtime-interpolated body + a "configure X" jump each)
429
435
  // live in a sibling factory over the same toast/ui/i18n handles, so this composable stays
@@ -434,43 +440,63 @@ export function usePipelineErrorToast() {
434
440
  * Per-reason copy from the exhaustive map: a translated title + description, and a jump
435
441
  * action for the reasons a UI panel can fix. `te` (translation-exists) guards every lookup,
436
442
  * so a key missing from the active locale falls back rather than leaking a raw key: the
437
- * title falls to the caller's key, the description to the raw backend `message`. An unknown
438
- * reason (not in the map) gets the same generic title + raw-message fallback.
443
+ * title falls to the caller's key, the description to the generic conflict line.
444
+ *
445
+ * A conflict is a FAILURE, so it carries the two properties `presentGenericFailure` documents
446
+ * below and for the same reasons: it does not auto-dismiss, and its detail is copyable. The
447
+ * backend's own prose is DETAIL here rather than the headline, which matters most on the
448
+ * unknown-reason path: that is where a reason this SPA build has never heard of lands, so
449
+ * leaving it as the description meant the newest failures were the ones shown untranslated,
450
+ * uncopyable, and gone in five seconds.
439
451
  */
440
- function presentMappedConflict(conflict: ParsedConflict, fallbackTitleKey: string): void {
452
+ function presentMappedConflict(
453
+ conflict: ParsedConflict,
454
+ fallbackTitleKey: string,
455
+ titleParams?: Record<string, unknown>,
456
+ ): void {
441
457
  const info = conflict.reason
442
458
  ? CONFLICT_INFO[conflict.reason as Exclude<ConflictReason, BespokeConflictReason>]
443
459
  : undefined
444
- if (info) {
445
- toast.add({
446
- title: te(info.titleKey) ? t(info.titleKey) : t(fallbackTitleKey),
447
- description: te(info.descriptionKey)
448
- ? t(info.descriptionKey)
449
- : (conflict.message ?? t('errors.conflict.fallbackMessage')),
450
- color: 'warning',
451
- icon: 'i-lucide-triangle-alert',
452
- // A reason with a jump action becomes an actionable, sticky toast (like the bespoke
453
- // conflicts above) so the one-click remedy doesn't auto-dismiss before it's reached.
454
- ...(info.action
455
- ? {
456
- duration: 0,
457
- actions: [
458
- {
459
- label: t(info.action.labelKey),
460
- icon: info.action.icon,
461
- onClick: () => info.action?.run(ui),
462
- },
463
- ],
464
- }
465
- : {}),
466
- })
467
- return
468
- }
469
- toast.add({
470
- title: t(fallbackTitleKey),
471
- description: conflict.message ?? t('errors.conflict.fallbackMessage'),
460
+ const title =
461
+ info && te(info.titleKey) ? t(info.titleKey) : t(fallbackTitleKey, titleParams ?? {})
462
+ const description =
463
+ info && te(info.descriptionKey)
464
+ ? t(info.descriptionKey)
465
+ : t('errors.conflict.fallbackMessage')
466
+ // The backend's prose, kept only when it adds something the translated line doesn't already say.
467
+ const detail = conflict.message && conflict.message !== description ? conflict.message : ''
468
+ const report = [title, description, detail].filter((part) => part.length > 0).join('\n')
469
+ const added = toast.add({
470
+ title,
471
+ description,
472
472
  color: 'warning',
473
473
  icon: 'i-lucide-triangle-alert',
474
+ duration: 0,
475
+ ui: { description: 'select-text' },
476
+ actions: [
477
+ // A reason with a jump action leads with its one-click remedy; the detail reveal and the
478
+ // copy follow it, so the primary affordance stays first in the row.
479
+ ...(info?.action
480
+ ? [
481
+ {
482
+ label: t(info.action.labelKey),
483
+ icon: info.action.icon,
484
+ onClick: () => info.action?.run(ui),
485
+ },
486
+ ]
487
+ : []),
488
+ ...(detail
489
+ ? [
490
+ {
491
+ label: t('errors.generic.showDetail'),
492
+ icon: 'i-lucide-info',
493
+ onClick: () =>
494
+ toast.update(added.id, { description: detail, actions: [copyAction(report)] }),
495
+ },
496
+ ]
497
+ : []),
498
+ copyAction(report),
499
+ ],
474
500
  })
475
501
  }
476
502
 
@@ -479,15 +505,32 @@ export function usePipelineErrorToast() {
479
505
  * behind a "Show details" button that swaps it into the same toast (G2).
480
506
  *
481
507
  * The reveal is an UPDATE rather than a second toast so the two readings can't sit on screen
482
- * disagreeing, and it makes the toast sticky at the same time the detail is what someone
483
- * copies into a bug report, and a ~5s auto-dismiss takes it away mid-copy. `actions: []` has to
484
- * be passed explicitly: `update` merges over the existing toast, so an omitted `actions` would
485
- * leave a "Show details" button that is now a no-op.
508
+ * disagreeing. `actions: []` is passed explicitly on the reveal: `update` merges over the
509
+ * existing toast, so an omitted `actions` would leave a "Show details" button that is now a
510
+ * no-op. The COPY action is re-passed, because it is still the point after the reveal.
511
+ *
512
+ * Two properties of this toast are the whole reason a failure goes through this funnel rather
513
+ * than a hand-built `toast.add`, and both were learned from someone trying to report a bug:
514
+ *
515
+ * - It does NOT auto-dismiss. A failure is the one toast a user needs to finish reading, quote,
516
+ * or act on, and a ~5s dismissal took the detail away mid-sentence. It carries a close button
517
+ * like every other toast, so staying is a decision the reader makes.
518
+ * - The detail is COPYABLE with one click, through the shared clipboard seam (so the copy's own
519
+ * success/failure is reported rather than silently no-op'ing in an insecure context). Selecting
520
+ * text inside a toast is fiddly at the best of times and impossible once it has gone, and what
521
+ * the person needs to paste is the whole of it: the failed action, the class of failure, the
522
+ * backend's prose, and above all the `requestId` that joins it to the one server log line
523
+ * explaining it. Retyping that id off a screenshot is how a report arrives without it.
486
524
  *
487
525
  * No detail worth showing (a network fault with an unhelpful `message` and no correlation id)
488
- * ⇒ no button at all, rather than a disclosure that reveals nothing.
526
+ * ⇒ no disclosure and nothing to copy beyond the two translated lines, which the copy action
527
+ * still carries.
489
528
  */
490
- function presentGenericFailure(error: unknown, fallbackTitleKey: string): void {
529
+ function presentGenericFailure(
530
+ error: unknown,
531
+ fallbackTitleKey: string,
532
+ titleParams?: Record<string, unknown>,
533
+ ): void {
491
534
  const failure = describeGenericFailure(error)
492
535
  const detail = [
493
536
  failure.message,
@@ -499,38 +542,54 @@ export function usePipelineErrorToast() {
499
542
  // No `te` guard: the key comes from a Record exhaustive over the wire union and every entry
500
543
  // ships in the base `en` catalog, so a locale missing it renders English via `fallbackLocale`
501
544
  // (better than the raw prose this replaced) and a bare key can never leak.
545
+ const title = t(fallbackTitleKey, titleParams ?? {})
546
+ const description = t(failure.descriptionKey)
547
+ const report = [title, description, detail].filter((part) => part.length > 0).join('\n')
502
548
  const added = toast.add({
503
- title: t(fallbackTitleKey),
504
- description: t(failure.descriptionKey),
549
+ title,
550
+ description,
505
551
  color: 'error',
506
552
  icon: 'i-lucide-triangle-alert',
507
- ...(detail
508
- ? {
509
- actions: [
553
+ duration: 0,
554
+ // Selectable in place too: the copy button is the reliable path, but a reader who only wants
555
+ // the request id should be able to drag over it.
556
+ ui: { description: 'select-text' },
557
+ actions: [
558
+ ...(detail
559
+ ? [
510
560
  {
511
561
  label: t('errors.generic.showDetail'),
512
562
  icon: 'i-lucide-info',
513
563
  onClick: () =>
514
- toast.update(added.id, { description: detail, duration: 0, actions: [] }),
564
+ toast.update(added.id, {
565
+ description: detail,
566
+ actions: [copyAction(report)],
567
+ }),
515
568
  },
516
- ],
517
- }
518
- : {}),
569
+ ]
570
+ : []),
571
+ copyAction(report),
572
+ ],
519
573
  })
520
574
  }
521
575
 
522
576
  /**
523
577
  * Present `error` as a toast. `fallbackTitleKey` is an i18n message key used for
524
- * non-conflict failures and any conflict reason without a dedicated title.
578
+ * non-conflict failures and any conflict reason without a dedicated title; `titleParams` carries
579
+ * that key's interpolation when it takes any (a title naming the thing that failed).
525
580
  */
526
- function present(error: unknown, fallbackTitleKey = 'common.actionFailed'): void {
581
+ function present(
582
+ error: unknown,
583
+ fallbackTitleKey = 'common.actionFailed',
584
+ titleParams?: Record<string, unknown>,
585
+ ): void {
527
586
  const conflict = parseConflict(error)
528
587
  if (conflict) {
529
588
  if (presentBespokeConflict(conflict)) return
530
- presentMappedConflict(conflict, fallbackTitleKey)
589
+ presentMappedConflict(conflict, fallbackTitleKey, titleParams)
531
590
  return
532
591
  }
533
- presentGenericFailure(error, fallbackTitleKey)
592
+ presentGenericFailure(error, fallbackTitleKey, titleParams)
534
593
  }
535
594
 
536
595
  return { present }