@cat-factory/app 0.314.0 → 0.315.1

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.
@@ -2,6 +2,11 @@
2
2
  import type { Notification } from '~/types/domain'
3
3
  import type { ReviewEffort } from '~/types/merge'
4
4
  import SectionLabel from '~/components/common/SectionLabel.vue'
5
+ import {
6
+ notificationTarget,
7
+ notificationVerbKey,
8
+ type NotificationRouteDeps,
9
+ } from '~/utils/notificationRoutes'
5
10
 
6
11
  // The board's notification inbox: a bell with an open-count badge that opens a
7
12
  // panel of human-actionable items (a PR awaiting a merge decision, a completed
@@ -133,6 +138,45 @@ function actionLabel(n: Notification): string {
133
138
  return te(key) ? t(key) : t('layout.notifications.action.markRead')
134
139
  }
135
140
 
141
+ // The store reads and openers every route uses (`utils/notificationRoutes.ts`).
142
+ const routeDeps: NotificationRouteDeps = {
143
+ getInstance: (id) => execution.getInstance(id),
144
+ select: (id) => ui.select(id),
145
+ openStepDetail: (id, index) => ui.openStepDetail(id, index),
146
+ openDecision: (id, decisionId) => ui.openDecision(id, decisionId),
147
+ openFollowUps: (id) => ui.openFollowUps(id),
148
+ openForkDecision: (id) => ui.openForkDecision(id),
149
+ openPrReview: (id) => ui.openPrReview(id),
150
+ openBugFishing: (id) => ui.openBugFishing(id),
151
+ openRequirementReview: (id) => ui.openRequirementReview(id),
152
+ openClarityReview: (id) => ui.openClarityReview(id),
153
+ openInitiativeTracker: (id) => ui.openInitiativeTracker(id),
154
+ openOperatorDashboard: () => ui.openOperatorDashboard(),
155
+ }
156
+
157
+ /**
158
+ * The primary verb for a card whose act button only marks it read, from the same resolution its
159
+ * click takes. `te()`-guarded like the act label: a locale missing the key gets no button rather
160
+ * than a raw key path.
161
+ */
162
+ function goToLabel(n: Notification): string | null {
163
+ const key = notificationVerbKey(n, routeDeps)
164
+ return key && te(key) ? t(key) : null
165
+ }
166
+
167
+ /** Each open card's go-to label, computed once per change rather than once per binding. */
168
+ const goToLabels = computed(
169
+ () => new Map(notifications.open.map((n) => [n.id, goToLabel(n)] as const)),
170
+ )
171
+
172
+ // The popover is closed when a card takes the person somewhere, so it does not stay open under
173
+ // the window it opened and reappear when that window closes.
174
+ const open = ref(false)
175
+ function goTo(n: Notification) {
176
+ open.value = false
177
+ reveal(n)
178
+ }
179
+
136
180
  /** A notification the escalation sweep has flagged as overdue (waited past the threshold). */
137
181
  function isUrgent(n: Notification): boolean {
138
182
  return n.severity === 'urgent'
@@ -217,120 +261,9 @@ async function dismiss(n: Notification) {
217
261
  }
218
262
  }
219
263
 
220
- /**
221
- * Clicking a notification's title takes the user where they can act on it. A
222
- * `requirement_review` / `clarity_review` summons them straight back into the matching review
223
- * window (the async incorporate/re-review raised new findings or hit the cap); every other
224
- * type just focuses the related block on the board.
225
- */
264
+ /** Clicking a notification's title, or its verb, takes the user where they can act on it. */
226
265
  function reveal(n: Notification) {
227
- // A `platform_health` card is deployment-scoped (no block) — send the operator to the
228
- // dashboard where the live aggregate numbers behind the alert live.
229
- if (n.type === 'platform_health') return ui.openOperatorDashboard()
230
- if (!n.blockId) return
231
- if (n.type === 'requirement_review') ui.openRequirementReview(n.blockId)
232
- else if (n.type === 'clarity_review') ui.openClarityReview(n.blockId)
233
- else if (n.type === 'decision_required') revealDecision(n)
234
- else if (n.type === 'human_test_ready') revealHumanTest(n)
235
- else if (n.type === 'visual_confirmation_ready') revealVisualConfirm(n)
236
- else if (n.type === 'human_review') revealHumanReview(n)
237
- else if (n.type === 'followup_pending') revealFollowUps(n)
238
- else if (n.type === 'fork_decision_pending') revealForkDecision(n)
239
- else if (n.type === 'judge_review') revealJudge(n)
240
- else if (n.type === 'pr_review_ready') revealPrReview(n)
241
- else if (n.type === 'bug_fishing_triage') revealBugFishing(n)
242
- else if (n.type === 'initiative') ui.openInitiativeTracker(n.blockId)
243
- else ui.select(n.blockId)
244
- }
245
-
246
- /**
247
- * Open the gate window for a parked `human-review` gate: find the run's human-review step and
248
- * open it through the universal step dispatch (its archetype declares the `gate` result view,
249
- * where the human can request a freeform fix). Falls back to focusing the block.
250
- */
251
- function revealHumanReview(n: Notification) {
252
- const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
253
- const idx = instance?.steps.findIndex((s) => s.agentKind === 'human-review') ?? -1
254
- if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
255
- else if (n.blockId) ui.select(n.blockId)
256
- }
257
-
258
- /**
259
- * Open the Follow-up companion window for a run whose Coder parked on undecided items.
260
- * Falls back to focusing the block when the run isn't loaded.
261
- */
262
- function revealFollowUps(n: Notification) {
263
- if (n.executionId && execution.getInstance(n.executionId)) ui.openFollowUps(n.executionId)
264
- else if (n.blockId) ui.select(n.blockId)
265
- }
266
-
267
- /**
268
- * Open the implementation-fork decision window for a run parked awaiting a fork choice.
269
- * Falls back to focusing the block when the run isn't loaded.
270
- */
271
- function revealForkDecision(n: Notification) {
272
- if (n.executionId && execution.getInstance(n.executionId)) ui.openForkDecision(n.executionId)
273
- else if (n.blockId) ui.select(n.blockId)
274
- }
275
-
276
- /**
277
- * Open the judge window for a run parked on a rubric verdict: find the step carrying the parked
278
- * verdict and open it through the universal step dispatch (a registered judge declares the
279
- * `judge` result view). Falls back to focusing the block when the run isn't loaded.
280
- */
281
- function revealJudge(n: Notification) {
282
- const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
283
- const idx = instance?.steps.findIndex((s) => s.judge?.status === 'awaiting_decision') ?? -1
284
- if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
285
- else if (n.blockId) ui.select(n.blockId)
286
- }
287
-
288
- /**
289
- * Open the PR deep-review window for a run parked awaiting a finding selection.
290
- * Falls back to focusing the block when the run isn't loaded.
291
- */
292
- function revealPrReview(n: Notification) {
293
- if (n.executionId && execution.getInstance(n.executionId)) ui.openPrReview(n.executionId)
294
- else if (n.blockId) ui.select(n.blockId)
295
- }
296
-
297
- /**
298
- * Open the bug-fishing expedition window for a run whose angles have all settled. Falls back to
299
- * focusing the block when the run is not loaded, exactly like its PR-review sibling.
300
- */
301
- function revealBugFishing(n: Notification) {
302
- if (n.executionId && execution.getInstance(n.executionId)) ui.openBugFishing(n.executionId)
303
- else if (n.blockId) ui.select(n.blockId)
304
- }
305
-
306
- /**
307
- * Open the human-testing window for a parked `human-test` gate: find the run's parked
308
- * human-test step and open it through the universal step dispatch (its archetype declares
309
- * the `human-test` result view). Falls back to focusing the block.
310
- */
311
- function revealHumanTest(n: Notification) {
312
- const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
313
- const idx =
314
- instance?.steps.findIndex(
315
- (s) => s.agentKind === 'human-test' && s.state === 'waiting_decision',
316
- ) ?? -1
317
- if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
318
- else if (n.blockId) ui.select(n.blockId)
319
- }
320
-
321
- /**
322
- * Open the visual-confirmation window for a parked `visual-confirmation` gate: find the run's
323
- * parked step and open it through the universal step dispatch (its archetype declares the
324
- * `visual-confirm` result view). Falls back to focusing the block.
325
- */
326
- function revealVisualConfirm(n: Notification) {
327
- const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
328
- const idx =
329
- instance?.steps.findIndex(
330
- (s) => s.agentKind === 'visual-confirmation' && s.state === 'waiting_decision',
331
- ) ?? -1
332
- if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
333
- else if (n.blockId) ui.select(n.blockId)
266
+ notificationTarget(n, routeDeps)?.open()
334
267
  }
335
268
 
336
269
  /**
@@ -370,23 +303,10 @@ function revealFailingRun(run: { executionId: string; blockId: string | null })
370
303
  if (execution.getInstance(run.executionId)) ui.openObservability(run.executionId)
371
304
  else if (run.blockId) ui.select(run.blockId)
372
305
  }
373
-
374
- /**
375
- * Open the decision surface for a parked iteration-cap run: find the run's step that is
376
- * waiting on a human and open it through the universal step dispatch — which routes a
377
- * `requirements-review` step to the review window and a companion step to its detail
378
- * panel (where the iteration-cap prompt lives). Falls back to focusing the block.
379
- */
380
- function revealDecision(n: Notification) {
381
- const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
382
- const idx = instance?.steps.findIndex((s) => s.state === 'waiting_decision') ?? -1
383
- if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
384
- else if (n.blockId) ui.select(n.blockId)
385
- }
386
306
  </script>
387
307
 
388
308
  <template>
389
- <UPopover v-if="notifications.count" :content="{ align: 'end' }">
309
+ <UPopover v-if="notifications.count" v-model:open="open" :content="{ align: 'end' }">
390
310
  <UButton
391
311
  data-testid="notifications-bell"
392
312
  :color="hasUrgent ? 'error' : 'warning'"
@@ -426,7 +346,7 @@ function revealDecision(n: Notification) {
426
346
  variant="link"
427
347
  class="block min-w-0 flex-1 truncate p-0 text-start text-sm font-medium text-default hover:underline"
428
348
  :title="n.title"
429
- @click="reveal(n)"
349
+ @click="goTo(n)"
430
350
  >
431
351
  {{ n.title }}
432
352
  </UButton>
@@ -502,11 +422,24 @@ function revealDecision(n: Notification) {
502
422
  @update:model-value="effortByCard[n.id] = $event"
503
423
  />
504
424
  <div class="mt-2 flex items-center gap-1.5">
425
+ <!-- The action, where marking read is not one: opens what the title opens. -->
426
+ <!-- Soft, not solid: the inbox lists many cards, and a solid button on each would put
427
+ many primaries in one view. -->
505
428
  <UButton
506
- data-testid="notification-act"
429
+ v-if="goToLabels.get(n.id)"
430
+ data-testid="notification-go-to"
507
431
  :color="accent(n)"
508
432
  variant="soft"
509
433
  size="xs"
434
+ @click="goTo(n)"
435
+ >
436
+ {{ goToLabels.get(n.id) }}
437
+ </UButton>
438
+ <UButton
439
+ data-testid="notification-act"
440
+ :color="goToLabels.get(n.id) ? 'neutral' : accent(n)"
441
+ :variant="goToLabels.get(n.id) ? 'outline' : 'soft'"
442
+ size="xs"
510
443
  :loading="busy === n.id"
511
444
  :disabled="!access.canExecuteRuns.value"
512
445
  :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
@@ -1,10 +1,11 @@
1
1
  <script setup lang="ts">
2
- // API access tokens — the workspace's inbound public-API keys external systems present to the
3
- // `/api/v1` surface (`Authorization: Bearer cf_live_…`). Keys are hashed one-way server-side,
4
- // so the raw secret is shown EXACTLY ONCE, on create; the list thereafter renders metadata
5
- // only (label + created / last-used). To rotate a token, revoke it and mint a new one.
6
- // Opened from the Integrations hub.
2
+ // API access tokens: the account's inbound public-API keys that reach this workspace, which
3
+ // external systems present to the `/api/v1` surface (`Authorization: Bearer cf_live_…`). Keys are
4
+ // hashed one-way server-side, so the raw secret is shown EXACTLY ONCE, on create; the list thereafter
5
+ // renders metadata only (label + created / last-used). To rotate a token, revoke it and mint a new
6
+ // one. Opened from the Integrations hub.
7
7
  import { computed, ref, watch } from 'vue'
8
+ import { showOverrideField } from '~/utils/uiMode'
8
9
  import type { PublicApiKey, PublicApiScope } from '~/types/publicApiKeys'
9
10
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
10
11
  import CopyButton from '~/components/common/CopyButton.vue'
@@ -36,6 +37,50 @@ const scopeItems = computed(() => SCOPES.map((value) => ({ value, label: scopeLa
36
37
  const ui = useUiStore()
37
38
  const auth = useAuthStore()
38
39
  const store = usePublicApiKeysStore()
40
+ const accounts = useAccountsStore()
41
+ const workspace = useWorkspaceStore()
42
+ const uiMode = useUiModeStore()
43
+
44
+ // A key's reach. The default is this board alone, which is what every key was before keys could
45
+ // span workspaces; the wider choices are an override only an account admin can make, so the field
46
+ // is hidden for everyone else rather than offered and refused.
47
+ type TokenReach = 'this' | 'all' | 'selected'
48
+ const reach = ref<TokenReach>('this')
49
+ const selectedWorkspaceIds = ref<string[]>([])
50
+ const canWidenReach = computed(
51
+ () =>
52
+ accounts.activeAccount?.roles?.includes('admin') === true &&
53
+ showOverrideField(uiMode.isAdvanced),
54
+ )
55
+ const reachItems = computed(() => [
56
+ { value: 'this' as const, label: t('settings.apiTokens.add.reachThis') },
57
+ { value: 'all' as const, label: t('settings.apiTokens.add.reachAll') },
58
+ { value: 'selected' as const, label: t('settings.apiTokens.add.reachSelected') },
59
+ ])
60
+ const workspaceItems = computed(() =>
61
+ workspace.accountWorkspaces.map((w) => ({ value: w.id, label: w.name })),
62
+ )
63
+ /** The `workspaceIds` to mint with: omitted for this board, `null` for all, or the picked list. */
64
+ function requestedReach(): string[] | null | undefined {
65
+ if (!canWidenReach.value || reach.value === 'this') return undefined
66
+ if (reach.value === 'all') return null
67
+ return selectedWorkspaceIds.value
68
+ }
69
+ // Gated on `canWidenReach` like `requestedReach`: a hidden picker left on 'selected' must not
70
+ // disable the create button.
71
+ const reachIncomplete = computed(
72
+ () =>
73
+ canWidenReach.value && reach.value === 'selected' && selectedWorkspaceIds.value.length === 0,
74
+ )
75
+
76
+ /** The reach badge for a listed key; none for a key reaching this one board. */
77
+ function reachLabel(key: PublicApiKey): string | null {
78
+ if (key.workspaceIds === null) return t('settings.apiTokens.list.reachAll')
79
+ if (key.workspaceIds.length > 1) {
80
+ return t('settings.apiTokens.list.reachCount', { count: key.workspaceIds.length })
81
+ }
82
+ return null
83
+ }
39
84
 
40
85
  /**
41
86
  * The minter to attribute a key to. When the minter is the signed-in user we show a localized
@@ -61,7 +106,7 @@ function minterLabel(key: PublicApiKey): string | null {
61
106
  /**
62
107
  * The badge for a key bound to a person's subscription, WHOSE person named.
63
108
  *
64
- * Keys are workspace-scoped and this list is shared, so a colleague's bound key is right here in
109
+ * This list shows every key reaching the board, so a colleague's bound key is right here in
65
110
  * everyone's panel: a fixed "your subscription" tells every other member that a token they never
66
111
  * minted reaches theirs, which is alarming and false. The same comparison {@link minterLabel} makes
67
112
  * is available (a binding is always to the minter), so the honest badge names the owner, and falls
@@ -147,11 +192,14 @@ async function createToken() {
147
192
  trimmed,
148
193
  scope.value,
149
194
  canBindSelf.value && identity.value === 'self',
195
+ requestedReach(),
150
196
  )
151
197
  newSecret.value = created.secret
152
198
  label.value = ''
153
199
  scope.value = 'write'
154
200
  identity.value = 'system'
201
+ reach.value = 'this'
202
+ selectedWorkspaceIds.value = []
155
203
  actionToast.success('settings.apiTokens.toast.created')
156
204
  } catch (e) {
157
205
  present(e, 'settings.apiTokens.toast.createFailed')
@@ -245,6 +293,15 @@ async function revokeToken(key: PublicApiKey) {
245
293
  <!-- Always shown, never mode-gated: an existing key can already carry the
246
294
  binding, and this is the only place its holder can see that a token they
247
295
  are about to hand out reaches a personal subscription. -->
296
+ <UBadge
297
+ v-if="reachLabel(key)"
298
+ color="info"
299
+ variant="subtle"
300
+ size="sm"
301
+ :data-testid="`api-token-reach-${key.id}`"
302
+ >
303
+ {{ reachLabel(key) }}
304
+ </UBadge>
248
305
  <UBadge
249
306
  v-if="boundLabel(key)"
250
307
  color="warning"
@@ -326,9 +383,31 @@ async function revokeToken(key: PublicApiKey) {
326
383
  data-testid="api-token-identity"
327
384
  />
328
385
  </UFormField>
386
+ <UFormField
387
+ v-if="canWidenReach"
388
+ :label="t('settings.apiTokens.add.reach')"
389
+ :help="t('settings.apiTokens.add.reachHelp')"
390
+ >
391
+ <USelect
392
+ v-model="reach"
393
+ :items="reachItems"
394
+ class="w-full"
395
+ data-testid="api-token-reach"
396
+ />
397
+ <USelectMenu
398
+ v-if="reach === 'selected'"
399
+ v-model="selectedWorkspaceIds"
400
+ :items="workspaceItems"
401
+ value-key="value"
402
+ multiple
403
+ :placeholder="t('settings.apiTokens.add.reachPick')"
404
+ class="mt-2 w-full"
405
+ data-testid="api-token-reach-workspaces"
406
+ />
407
+ </UFormField>
329
408
  <UButton
330
409
  :loading="busy"
331
- :disabled="!label.trim()"
410
+ :disabled="!label.trim() || reachIncomplete"
332
411
  data-testid="api-token-create"
333
412
  @click="createToken"
334
413
  >
@@ -183,6 +183,10 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
183
183
  titleKey: 'errors.conflict.title.session_stale',
184
184
  descriptionKey: 'errors.conflict.description.session_stale',
185
185
  },
186
+ cursor_expired: {
187
+ titleKey: 'errors.conflict.title.cursor_expired',
188
+ descriptionKey: 'errors.conflict.description.cursor_expired',
189
+ },
186
190
  task_limit_reached: {
187
191
  titleKey: 'errors.conflict.title.task_limit_reached',
188
192
  descriptionKey: 'errors.conflict.description.task_limit_reached',
@@ -9,6 +9,7 @@ function key(over: Partial<PublicApiKey> = {}): PublicApiKey {
9
9
  id: 'pak_1',
10
10
  accountId: 'acc1',
11
11
  workspaceId: 'ws1',
12
+ workspaceIds: ['ws1'],
12
13
  label: 'CI',
13
14
  scope: 'write',
14
15
  createdByUserId: null,
@@ -110,6 +111,33 @@ describe('publicApiKeys store', () => {
110
111
  expect(body).toHaveBeenCalledWith({ label: 'mine', scope: 'write', actsAsSelf: true })
111
112
  })
112
113
 
114
+ it('sends a reach only when one was chosen, so the default stays this workspace alone', async () => {
115
+ // An omitted `workspaceIds` and a `null` one mean opposite things on the wire (this board vs
116
+ // every board in the account), so the store must never turn "not chosen" into a value.
117
+ const body = vi.fn((_body: unknown) => Promise.resolve({ key: key(), secret: 's' }))
118
+ vi.stubGlobal('useApi', () => ({ createPublicApiKey: (_ws: string, b: unknown) => body(b) }))
119
+
120
+ const store = usePublicApiKeysStore()
121
+ await store.create('ci', 'read')
122
+ expect(body).toHaveBeenLastCalledWith({ label: 'ci', scope: 'read', actsAsSelf: false })
123
+
124
+ await store.create('all', 'read', false, null)
125
+ expect(body).toHaveBeenLastCalledWith({
126
+ label: 'all',
127
+ scope: 'read',
128
+ actsAsSelf: false,
129
+ workspaceIds: null,
130
+ })
131
+
132
+ await store.create('two', 'read', false, ['ws1', 'ws2'])
133
+ expect(body).toHaveBeenLastCalledWith({
134
+ label: 'two',
135
+ scope: 'read',
136
+ actsAsSelf: false,
137
+ workspaceIds: ['ws1', 'ws2'],
138
+ })
139
+ })
140
+
113
141
  it('revoke drops the key from the list', async () => {
114
142
  vi.stubGlobal('useApi', () => ({
115
143
  listPublicApiKeys: () => Promise.resolve({ keys: [key({ id: 'a' }), key({ id: 'b' })] }),
@@ -58,17 +58,31 @@ export const usePublicApiKeysStore = defineStore('publicApiKeys', () => {
58
58
  * `actsAsSelf` binds the key to the signed-in user's PERSONAL subscriptions, so a headless run
59
59
  * it starts can unlock them with the password sent on that call. Passed through rather than
60
60
  * defaulted here: the server writes the id off the session, so this is only ever a yes/no.
61
+ *
62
+ * `workspaceIds` is the key's reach: omitted for this workspace alone, `null` for every workspace
63
+ * in the account, or a list. Anything past this workspace needs an account admin.
61
64
  */
62
65
  async function create(
63
66
  label: string,
64
67
  scope: PublicApiScope,
65
68
  actsAsSelf = false,
69
+ workspaceIds?: string[] | null,
66
70
  ): Promise<CreatedPublicApiKey> {
67
71
  const ws = useWorkspaceStore()
68
- const created = await api.createPublicApiKey(ws.requireId(), { label, scope, actsAsSelf })
72
+ const workspaceId = ws.requireId()
73
+ const created = await api.createPublicApiKey(workspaceId, {
74
+ label,
75
+ scope,
76
+ actsAsSelf,
77
+ ...(workspaceIds === undefined ? {} : { workspaceIds }),
78
+ })
69
79
  // Prepend: the backend lists newest-first, so the freshly minted key belongs at the
70
- // top — matching the order a subsequent `load()` would produce.
71
- keys.value = [created.key, ...keys.value]
80
+ // top — matching the order a subsequent `load()` would produce. A key picked to reach only
81
+ // other workspaces is not in this board's list, and revoking it from here would be refused as
82
+ // a no-op, so it is not shown.
83
+ const reaches =
84
+ created.key.workspaceIds === null || created.key.workspaceIds.includes(workspaceId)
85
+ if (reaches) keys.value = [created.key, ...keys.value]
72
86
  available.value = true
73
87
  return created
74
88
  }
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import type { ExecutionInstance, Notification } from '~/types/domain'
3
+ import {
4
+ NOTIFICATION_ROUTES,
5
+ OPEN_TASK_VERB_KEY,
6
+ notificationTarget,
7
+ notificationVerbKey,
8
+ type NotificationRouteDeps,
9
+ } from '~/utils/notificationRoutes'
10
+ import { missingI18nKeys } from '../../test/i18nKeys'
11
+
12
+ // The card's verb and the place its click lands come from ONE resolution, so a button named for
13
+ // answering never just selects the task. These pin that, and that every verb names a real key.
14
+
15
+ function deps(instances: ExecutionInstance[] = []): NotificationRouteDeps {
16
+ return {
17
+ getInstance: (id) => instances.find((i) => i.id === id),
18
+ select: vi.fn(),
19
+ openStepDetail: vi.fn(),
20
+ openDecision: vi.fn(),
21
+ openFollowUps: vi.fn(),
22
+ openForkDecision: vi.fn(),
23
+ openPrReview: vi.fn(),
24
+ openBugFishing: vi.fn(),
25
+ openRequirementReview: vi.fn(),
26
+ openClarityReview: vi.fn(),
27
+ openInitiativeTracker: vi.fn(),
28
+ openOperatorDashboard: vi.fn(),
29
+ }
30
+ }
31
+
32
+ const card = (type: Notification['type'], over: Partial<Notification> = {}) =>
33
+ ({ id: 'n1', type, blockId: 'blk', executionId: 'run', ...over }) as Notification
34
+
35
+ const run = (steps: unknown[]) => ({ id: 'run', blockId: 'blk', steps }) as ExecutionInstance
36
+
37
+ describe('notification routes', () => {
38
+ it('names a real catalog key for every verb', () => {
39
+ const keys = Object.values(NOTIFICATION_ROUTES)
40
+ .map((r) => r.verbKey)
41
+ .filter((k): k is string => k !== null)
42
+ expect(missingI18nKeys([...keys, OPEN_TASK_VERB_KEY])).toEqual([])
43
+ })
44
+
45
+ it('answers a parked agent question in the decision window', () => {
46
+ const d = deps([run([{ state: 'waiting_decision', decision: { id: 'dec', chosen: null } }])])
47
+ const n = card('decision_required')
48
+ expect(notificationVerbKey(n, d)).toBe('layout.notifications.goTo.answer')
49
+ notificationTarget(n, d)!.open()
50
+ expect(d.openDecision).toHaveBeenCalledWith('run', 'dec')
51
+ })
52
+
53
+ it('opens the step for any other park, such as an approval gate', () => {
54
+ const d = deps([run([{ state: 'done' }, { state: 'waiting_decision' }])])
55
+ notificationTarget(card('decision_required'), d)!.open()
56
+ expect(d.openStepDetail).toHaveBeenCalledWith('run', 1)
57
+ })
58
+
59
+ it('says "Open" when only the task can be opened, and opens the task', () => {
60
+ // The run is not loaded, or it is loaded and not parked (a Ralph loop that gave up).
61
+ for (const d of [deps(), deps([run([{ state: 'failed' }])])]) {
62
+ const n = card('decision_required')
63
+ expect(notificationVerbKey(n, d)).toBe(OPEN_TASK_VERB_KEY)
64
+ notificationTarget(n, d)!.open()
65
+ expect(d.select).toHaveBeenCalledWith('blk')
66
+ }
67
+ expect(notificationVerbKey(card('fork_decision_pending'), deps())).toBe(OPEN_TASK_VERB_KEY)
68
+ })
69
+
70
+ it('gives no verb to a card whose act button is the action, or that has nowhere to go', () => {
71
+ expect(notificationVerbKey(card('merge_review'), deps())).toBeNull()
72
+ expect(notificationVerbKey(card('budget_paused', { blockId: null }), deps())).toBeNull()
73
+ expect(notificationTarget(card('budget_paused', { blockId: null }), deps())).toBeNull()
74
+ })
75
+
76
+ it('sends a deployment-scoped health alert to the operator dashboard, with no block', () => {
77
+ const d = deps()
78
+ notificationTarget(card('platform_health', { blockId: null }), d)!.open()
79
+ expect(d.openOperatorDashboard).toHaveBeenCalled()
80
+ })
81
+ })