@cat-factory/app 0.313.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.
@@ -196,6 +196,7 @@ export interface NavCommandSpec {
196
196
  * dead button. Consumer modules don't use these — they carry their own `run`.
197
197
  */
198
198
  export const NAV_ACTIONS = [
199
+ 'describeWork',
199
200
  'assistant',
200
201
  'guidedReview',
201
202
  'buildPipeline',
@@ -346,6 +347,26 @@ const S = (...s: NavSurface[]) => s as readonly NavSurface[]
346
347
  * the whole sidebar costs it nothing it is there to do.
347
348
  */
348
349
  export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
350
+ {
351
+ // The one-sentence intake (issue #2258): what needs doing, a service, feature or bug. First in
352
+ // Create, because filing a task is the most frequent thing anyone creates, and until now it had
353
+ // no entry here at all (it lived on each frame). Every tier and the intake role: describing
354
+ // work is what that role is for.
355
+ id: 'describe-work',
356
+ labelKey: 'queue.describeWork',
357
+ icon: 'i-lucide-plus',
358
+ surfaces: S('sidebar', 'command'),
359
+ gate: (g) => g.canWriteBoard && g.boardHasService,
360
+ action: 'describeWork',
361
+ intake: true,
362
+ testId: 'nav-describe-work',
363
+ sidebar: { group: 'create', order: 1 },
364
+ command: {
365
+ group: 'create',
366
+ order: 1,
367
+ keywordsKey: 'layout.commandBar.keywords.describeWork',
368
+ },
369
+ },
349
370
  {
350
371
  // The in-app assistant. `intake: true` and not `advanced`, because it is the shortest route
351
372
  // to the two things a narrowed role is on the board to do (put a repository on it, file a
@@ -164,6 +164,7 @@ const TutorialNudge = defineAsyncView(() => import('~/components/tutorial/Tutori
164
164
  const RolePrompt = defineAsyncView(() => import('~/components/layout/RolePrompt.vue'))
165
165
  // The queue: the board's second view (issue #2258), loaded the first time someone opens it.
166
166
  const QueueView = defineAsyncView(() => import('~/components/queue/QueueView.vue'))
167
+ const DescribeWorkModal = defineAsyncView(() => import('~/components/queue/DescribeWorkModal.vue'))
167
168
 
168
169
  const workspace = useWorkspaceStore()
169
170
  const github = useGitHubStore()
@@ -532,6 +533,7 @@ watch(
532
533
  <AddTaskModal />
533
534
  <ReviewFrictionDialog v-if="ui.reviewFrictionContext" />
534
535
  <CreateInitiativeModal />
536
+ <DescribeWorkModal v-if="ui.describeWorkOpen" />
535
537
  <CommandBar />
536
538
  <PersonalCredentialModal />
537
539
  <ConfirmDialog />
@@ -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
  }
@@ -27,6 +27,14 @@ export interface AddTaskPrefill {
27
27
  description?: string
28
28
  /** Context items staged on the new task (e.g. the source issue), linked once created. */
29
29
  context?: PendingContext[]
30
+ /**
31
+ * Choices a person already made on another surface (the one-sentence intake), carried over so
32
+ * the hand-off does not quietly reset them. Absent means the form's own defaults.
33
+ */
34
+ taskType?: 'feature' | 'bug'
35
+ pipelineId?: string
36
+ riskPolicyId?: string
37
+ modelPresetId?: string
30
38
  }
31
39
 
32
40
  /**
@@ -90,6 +98,11 @@ export interface ReviewFrictionModalContext {
90
98
  * started a second pipeline run against the same request.
91
99
  */
92
100
  pending?: () => boolean
101
+ /**
102
+ * Close the form that raised the friction. "Go review" and each debt row leave for a waiting
103
+ * task, and the form must not stay open over it; only the opener knows which form it is.
104
+ */
105
+ onLeave: () => void
93
106
  }
94
107
 
95
108
  /** Clears both hub came-from markers; injected into the slices whose `open*` handlers reset them. */
@@ -340,6 +353,17 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
340
353
  function closeStartFromDesign() {
341
354
  startFromDesign.value = null
342
355
  }
356
+ // The one-sentence intake ("Describe new work", issue #2258): open, and the service it opened
357
+ // on, or null to let the reader pick one. The full form above stays the home of every other kind.
358
+ const describeWorkOpen = ref(false)
359
+ const describeWorkServiceId = ref<string | null>(null)
360
+ function openDescribeWork(serviceId: string | null = null) {
361
+ describeWorkServiceId.value = serviceId
362
+ describeWorkOpen.value = true
363
+ }
364
+ function closeDescribeWork() {
365
+ describeWorkOpen.value = false
366
+ }
343
367
  function openAddTask(containerId: string, prefill: AddTaskPrefill | null = null) {
344
368
  addTaskPrefill.value = prefill
345
369
  addTaskContainerId.value = containerId
@@ -405,6 +429,10 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
405
429
  closeStartFromDesign,
406
430
  openAddTask,
407
431
  closeAddTask,
432
+ describeWorkOpen,
433
+ describeWorkServiceId,
434
+ openDescribeWork,
435
+ closeDescribeWork,
408
436
  openReviewFriction,
409
437
  closeReviewFriction,
410
438
  openAddRecurring,
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { DERIVED_TITLE_MAX, splitDescribedWork } from '~/utils/describeWork'
3
+
4
+ describe('splitDescribedWork', () => {
5
+ it('uses one short sentence as the title and adds no description', () => {
6
+ expect(splitDescribedWork(' Fix the login redirect ')).toEqual({
7
+ title: 'Fix the login redirect',
8
+ })
9
+ })
10
+
11
+ it('titles with the first sentence and keeps the WHOLE text as the description', () => {
12
+ const text = 'Add an audit log. Record each sign-in with the time and the user.'
13
+ expect(splitDescribedWork(text)).toEqual({ title: 'Add an audit log', description: text })
14
+ })
15
+
16
+ it('reads only the first line for the title', () => {
17
+ const text = 'Rate limit per API key\nUse a token bucket. 100 requests a minute.'
18
+ expect(splitDescribedWork(text)).toEqual({ title: 'Rate limit per API key', description: text })
19
+ })
20
+
21
+ it('shortens a long first sentence at a word boundary, with an ellipsis', () => {
22
+ const words = 'word '.repeat(40).trim()
23
+ const { title, description } = splitDescribedWork(words)
24
+ expect(title.length).toBeLessThanOrEqual(DERIVED_TITLE_MAX)
25
+ expect(title.endsWith('word…')).toBe(true)
26
+ expect(description).toBe(words)
27
+ })
28
+
29
+ it('does not cut a dotted name such as a file or a version', () => {
30
+ expect(splitDescribedWork('Bump vite to 7.1 in package.json').title).toBe(
31
+ 'Bump vite to 7.1 in package.json',
32
+ )
33
+ })
34
+
35
+ it('does not end a sentence at a common abbreviation', () => {
36
+ const text = 'Fix e.g. the login redirect loop. It happens on Safari.'
37
+ expect(splitDescribedWork(text)).toEqual({
38
+ title: 'Fix e.g. the login redirect loop',
39
+ description: text,
40
+ })
41
+ expect(splitDescribedWork('Ask Dr. Smith about the export. She owns it.').title).toBe(
42
+ 'Ask Dr. Smith about the export',
43
+ )
44
+ })
45
+
46
+ it('treats a run of dots as one mark', () => {
47
+ expect(splitDescribedWork('Fix this... later, after the release.').title).toBe('Fix this')
48
+ expect(splitDescribedWork('Use a cache, e.g. Redis, for sessions.').title).toBe(
49
+ 'Use a cache, e.g. Redis, for sessions',
50
+ )
51
+ })
52
+
53
+ it('ends a sentence at a full-width mark, with or without a space after it', () => {
54
+ const text = '修复登录重定向。记录每次登录。'
55
+ expect(splitDescribedWork(text)).toEqual({ title: '修复登录重定向', description: text })
56
+ })
57
+
58
+ it('keeps a question or an exclamation mark, and drops a period', () => {
59
+ expect(splitDescribedWork('Why does login fail?')).toEqual({ title: 'Why does login fail?' })
60
+ // A final period says nothing the title does not, so it adds no description.
61
+ expect(splitDescribedWork('Fix the login redirect.')).toEqual({
62
+ title: 'Fix the login redirect',
63
+ })
64
+ })
65
+
66
+ it('never cuts an emoji in half when it shortens', () => {
67
+ const text = `${'a'.repeat(DERIVED_TITLE_MAX - 2)}😀😀😀 more words after it`
68
+ const { title } = splitDescribedWork(text)
69
+ expect(title).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/)
70
+ expect(Array.from(title).length).toBeLessThanOrEqual(DERIVED_TITLE_MAX)
71
+ })
72
+
73
+ it('returns an empty title for blank input, so the dialog cannot submit it', () => {
74
+ expect(splitDescribedWork(' \n ')).toEqual({ title: '' })
75
+ })
76
+ })
@@ -0,0 +1,71 @@
1
+ /** The longest title the one-box intake derives. Long enough for a sentence, short for a card. */
2
+ export const DERIVED_TITLE_MAX = 80
3
+
4
+ /**
5
+ * Abbreviations whose period does not end a sentence. Only the common ones a task description
6
+ * starts with: the title is derived, so a miss costs a short title, never lost text (the whole text
7
+ * is the description).
8
+ */
9
+ const ABBREVIATIONS = new Set(['e.g', 'i.e', 'etc', 'vs', 'cf', 'dr', 'mr', 'mrs', 'ms', 'st'])
10
+
11
+ /** Ends a sentence. Full-width marks end one with or without a following space. */
12
+ const FULL_WIDTH_END = new Set(['。', '!', '?'])
13
+ const KEPT_END = new Set(['?', '!', '?', '!'])
14
+
15
+ /**
16
+ * Split what a person typed into the one-box intake into a task title and a description.
17
+ *
18
+ * The title is the first line, cut at its first sentence end, and shortened at a word boundary
19
+ * to {@link DERIVED_TITLE_MAX} with an ellipsis. A question or an exclamation keeps its mark, which
20
+ * is part of what it says; a period does not. The description is the WHOLE text, unedited,
21
+ * whenever it says more than the title does: nothing the person wrote is dropped, and the agents
22
+ * read the description, never the title alone. Returns an empty title for blank input.
23
+ */
24
+ export function splitDescribedWork(text: string): { title: string; description?: string } {
25
+ const body = text.trim()
26
+ if (!body) return { title: '' }
27
+ const firstLine = body.split(/\r?\n/, 1)[0]!.trim()
28
+ const title = shorten(firstSentence(firstLine) || firstLine)
29
+ return stripEnd(title) === stripEnd(body) ? { title } : { title, description: body }
30
+ }
31
+
32
+ /** The first sentence of a line, ending mark included only when it is `?` or `!`. */
33
+ function firstSentence(line: string): string {
34
+ const chars = Array.from(line)
35
+ for (let i = 0; i < chars.length; i++) {
36
+ const c = chars[i]!
37
+ const next = chars[i + 1]
38
+ // A run of dots ("this... later") is ONE mark: only its last dot can end the sentence, and
39
+ // the whole run is cut with it.
40
+ if (c === '.' && next === '.') continue
41
+ const ends =
42
+ FULL_WIDTH_END.has(c) || ('.!?'.includes(c) && (next === undefined || /\s/.test(next)))
43
+ if (!ends) continue
44
+ if (c === '.' && isAbbreviation(chars.slice(0, i).join(''))) continue
45
+ let end = KEPT_END.has(c) ? i + 1 : i
46
+ while (c === '.' && end > 0 && chars[end - 1] === '.') end--
47
+ return chars.slice(0, end).join('').trim()
48
+ }
49
+ return line
50
+ }
51
+
52
+ /** Whether the word before a period is an abbreviation ("e.g", "Dr"), not a sentence's last word. */
53
+ function isAbbreviation(before: string): boolean {
54
+ const word = before.split(/\s/).pop() ?? ''
55
+ return ABBREVIATIONS.has(word.toLowerCase())
56
+ }
57
+
58
+ /** The text without its final sentence mark, so "Fix the login." says no more than "Fix the login". */
59
+ function stripEnd(s: string): string {
60
+ return s.replace(/[.!?。!?…]+$/u, '')
61
+ }
62
+
63
+ /** Shorten by CODE POINTS, so a cut never splits an emoji or another astral character in half. */
64
+ function shorten(line: string): string {
65
+ const chars = Array.from(line)
66
+ if (chars.length <= DERIVED_TITLE_MAX) return line
67
+ const cut = chars.slice(0, DERIVED_TITLE_MAX - 1).join('')
68
+ const lastSpace = cut.lastIndexOf(' ')
69
+ const keep = lastSpace > cut.length / 2 ? cut.slice(0, lastSpace) : cut
70
+ return `${keep.trimEnd()}…`
71
+ }
@@ -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
+ })
@@ -0,0 +1,179 @@
1
+ import type { ExecutionInstance, Notification, PipelineStep } from '~/types/domain'
2
+
3
+ /**
4
+ * Where each notification takes a person, and the verb that names it (issue #2258, finding F4).
5
+ *
6
+ * ONE row per type holds both, so the button's word and the place it opens cannot drift apart:
7
+ * the inbox's title link and its primary button both dispatch through {@link notificationTarget},
8
+ * and the verb is decided from the SAME resolution. A route that can only fall back to selecting
9
+ * the task (its run is not loaded, or not parked) is named "Open", because that is what happens.
10
+ *
11
+ * Exhaustive over the notification types, so a new type has to decide. A `null` verb means the
12
+ * card's own act button already IS the action (Merge, Retry run, Acknowledge), or there is nowhere
13
+ * to go beyond the card itself.
14
+ */
15
+
16
+ /** The store reads and window openers a route needs, injected so this stays testable. */
17
+ export interface NotificationRouteDeps {
18
+ getInstance: (id: string) => ExecutionInstance | undefined
19
+ select: (blockId: string) => void
20
+ openStepDetail: (instanceId: string, stepIndex: number) => void
21
+ openDecision: (instanceId: string, decisionId: string) => void
22
+ openFollowUps: (instanceId: string) => void
23
+ openForkDecision: (instanceId: string) => void
24
+ openPrReview: (instanceId: string) => void
25
+ openBugFishing: (instanceId: string) => void
26
+ openRequirementReview: (blockId: string) => void
27
+ openClarityReview: (blockId: string) => void
28
+ openInitiativeTracker: (blockId: string) => void
29
+ openOperatorDashboard: () => void
30
+ }
31
+
32
+ /**
33
+ * Where a click lands. `surface` opens the window that answers the card; `task` only selects the
34
+ * task on the board, which is the fallback when that window cannot open.
35
+ */
36
+ export interface NotificationTarget {
37
+ readonly kind: 'surface' | 'task'
38
+ readonly open: () => void
39
+ }
40
+
41
+ interface NotificationRoute {
42
+ /** The verb for a `surface` target, or null when the card's act button is already the verb. */
43
+ readonly verbKey: string | null
44
+ readonly resolve: (n: Notification, deps: NotificationRouteDeps) => NotificationTarget | null
45
+ }
46
+
47
+ /** The verb for a route that can only select the task. */
48
+ export const OPEN_TASK_VERB_KEY = 'layout.notifications.goTo.open'
49
+
50
+ const surface = (open: () => void): NotificationTarget => ({ kind: 'surface', open })
51
+
52
+ /** Select the card's task: the fallback every block-bound route shares. */
53
+ function selectTask(n: Notification, deps: NotificationRouteDeps): NotificationTarget | null {
54
+ const blockId = n.blockId
55
+ return blockId ? { kind: 'task', open: () => deps.select(blockId) } : null
56
+ }
57
+
58
+ /** A window keyed by the card's run, when that run is loaded; else the task. */
59
+ function byRun(open: (deps: NotificationRouteDeps, instanceId: string) => void) {
60
+ return (n: Notification, deps: NotificationRouteDeps): NotificationTarget | null => {
61
+ const id = n.executionId
62
+ if (id && deps.getInstance(id)) return surface(() => open(deps, id))
63
+ return selectTask(n, deps)
64
+ }
65
+ }
66
+
67
+ /** The run's step matching `find`, opened through the universal step dispatch; else the task. */
68
+ function byStep(find: (step: PipelineStep) => boolean) {
69
+ return (n: Notification, deps: NotificationRouteDeps): NotificationTarget | null => {
70
+ const instance = n.executionId ? deps.getInstance(n.executionId) : undefined
71
+ const index = instance?.steps.findIndex(find) ?? -1
72
+ if (instance && index >= 0) return surface(() => deps.openStepDetail(instance.id, index))
73
+ return selectTask(n, deps)
74
+ }
75
+ }
76
+
77
+ /** A window keyed by the card's block. */
78
+ function byBlock(open: (deps: NotificationRouteDeps, blockId: string) => void) {
79
+ return (n: Notification, deps: NotificationRouteDeps): NotificationTarget | null => {
80
+ const blockId = n.blockId
81
+ return blockId ? surface(() => open(deps, blockId)) : null
82
+ }
83
+ }
84
+
85
+ /**
86
+ * A parked step: an agent's question opens the decision window, where it is answered (as from the
87
+ * task card and the toolbar); any other park (an approval gate, an iteration cap) opens the step.
88
+ * A card whose run is not parked, which a Ralph loop or an exhausted gate also raises, opens the
89
+ * task.
90
+ */
91
+ function parkedStep(n: Notification, deps: NotificationRouteDeps): NotificationTarget | null {
92
+ const instance = n.executionId ? deps.getInstance(n.executionId) : undefined
93
+ const index = instance?.steps.findIndex((s) => s.state === 'waiting_decision') ?? -1
94
+ const step = instance?.steps[index]
95
+ if (!instance || !step) return selectTask(n, deps)
96
+ const decision = step.decision
97
+ if (decision && !decision.chosen) {
98
+ return surface(() => deps.openDecision(instance.id, decision.id))
99
+ }
100
+ return surface(() => deps.openStepDetail(instance.id, index))
101
+ }
102
+
103
+ const verb = (leaf: string) => `layout.notifications.goTo.${leaf}`
104
+
105
+ export const NOTIFICATION_ROUTES: Record<Notification['type'], NotificationRoute> = {
106
+ merge_review: { verbKey: null, resolve: selectTask },
107
+ pipeline_complete: { verbKey: null, resolve: selectTask },
108
+ merge_tag_request: { verbKey: null, resolve: selectTask },
109
+ ci_failed: { verbKey: null, resolve: selectTask },
110
+ test_failed: { verbKey: null, resolve: selectTask },
111
+ deploy_blocked: { verbKey: null, resolve: selectTask },
112
+ release_regression: { verbKey: null, resolve: selectTask },
113
+ infra_unreachable: { verbKey: null, resolve: selectTask },
114
+ key_drift: { verbKey: null, resolve: selectTask },
115
+ // Raised with no block, so neither has anywhere to go beyond the card.
116
+ budget_threshold: { verbKey: null, resolve: selectTask },
117
+ budget_paused: { verbKey: null, resolve: selectTask },
118
+ // Deployment-scoped: the dashboard holds the live numbers behind the alert.
119
+ platform_health: { verbKey: null, resolve: (_n, deps) => surface(deps.openOperatorDashboard) },
120
+ requirement_review: {
121
+ verbKey: verb('review'),
122
+ resolve: byBlock((deps, id) => deps.openRequirementReview(id)),
123
+ },
124
+ clarity_review: {
125
+ verbKey: verb('review'),
126
+ resolve: byBlock((deps, id) => deps.openClarityReview(id)),
127
+ },
128
+ decision_required: { verbKey: verb('answer'), resolve: parkedStep },
129
+ human_test_ready: {
130
+ verbKey: verb('test'),
131
+ resolve: byStep((s) => s.agentKind === 'human-test' && s.state === 'waiting_decision'),
132
+ },
133
+ visual_confirmation_ready: {
134
+ verbKey: verb('confirm'),
135
+ resolve: byStep((s) => s.agentKind === 'visual-confirmation' && s.state === 'waiting_decision'),
136
+ },
137
+ human_review: { verbKey: verb('review'), resolve: byStep((s) => s.agentKind === 'human-review') },
138
+ judge_review: {
139
+ verbKey: verb('review'),
140
+ resolve: byStep((s) => s.judge?.status === 'awaiting_decision'),
141
+ },
142
+ followup_pending: {
143
+ verbKey: verb('triage'),
144
+ resolve: byRun((deps, id) => deps.openFollowUps(id)),
145
+ },
146
+ fork_decision_pending: {
147
+ verbKey: verb('choose'),
148
+ resolve: byRun((deps, id) => deps.openForkDecision(id)),
149
+ },
150
+ pr_review_ready: { verbKey: verb('review'), resolve: byRun((deps, id) => deps.openPrReview(id)) },
151
+ bug_fishing_triage: {
152
+ verbKey: verb('triage'),
153
+ resolve: byRun((deps, id) => deps.openBugFishing(id)),
154
+ },
155
+ initiative: {
156
+ verbKey: verb('open'),
157
+ resolve: byBlock((deps, id) => deps.openInitiativeTracker(id)),
158
+ },
159
+ }
160
+
161
+ /** Where a click on this card lands, or null when it has nowhere to go. */
162
+ export function notificationTarget(
163
+ n: Notification,
164
+ deps: NotificationRouteDeps,
165
+ ): NotificationTarget | null {
166
+ return NOTIFICATION_ROUTES[n.type].resolve(n, deps)
167
+ }
168
+
169
+ /**
170
+ * The primary button's verb, from the same resolution the click takes: the route's verb when its
171
+ * window can open, "Open" when only the task can, null when the card has no verb or nowhere to go.
172
+ */
173
+ export function notificationVerbKey(n: Notification, deps: NotificationRouteDeps): string | null {
174
+ const route = NOTIFICATION_ROUTES[n.type]
175
+ if (!route.verbKey) return null
176
+ const target = route.resolve(n, deps)
177
+ if (!target) return null
178
+ return target.kind === 'surface' ? route.verbKey : OPEN_TASK_VERB_KEY
179
+ }
@@ -0,0 +1,47 @@
1
+ import type { ReviewFrictionModalContext } from '~/stores/ui/modals'
2
+ import type { parseConflict } from '~/composables/usePipelineErrorToast'
3
+
4
+ /** A conflict `parseConflict` recognised. */
5
+ type ParsedConflict = NonNullable<ReturnType<typeof parseConflict>>
6
+
7
+ /** Whether a create was refused by the opt-in review-debt friction gate. */
8
+ export function isReviewDebtConflict(conflict: ParsedConflict | null): conflict is ParsedConflict {
9
+ return conflict?.reason === 'review_debt_warn' || conflict?.reason === 'review_debt_blocked'
10
+ }
11
+
12
+ /**
13
+ * Turn a parsed review-debt friction 409 into the dialog's context (see ReviewFrictionDialog.vue).
14
+ *
15
+ * Shared by every surface that creates a task, so the friction reads the same whichever form
16
+ * raised it. `retry` re-submits with `acknowledgeReviewDebt` and is offered only on the soft
17
+ * `warn` tier; `pending` is a GETTER over the opener's own in-flight flag, for the reason the
18
+ * context type documents; `leave` closes the opener when the person leaves to review.
19
+ */
20
+ export function reviewFrictionContext(
21
+ conflict: ParsedConflict,
22
+ retry: () => void,
23
+ pending: () => boolean,
24
+ leave: () => void,
25
+ ): ReviewFrictionModalContext {
26
+ const details = conflict.details
27
+ const rawDebt = Array.isArray(details.debt) ? details.debt : []
28
+ const debt = rawDebt.map((d) => {
29
+ const row = (d ?? {}) as { blockId?: unknown; title?: unknown; waitingMinutes?: unknown }
30
+ return {
31
+ blockId: typeof row.blockId === 'string' ? row.blockId : '',
32
+ title: typeof row.title === 'string' ? row.title : null,
33
+ waitingMinutes: typeof row.waitingMinutes === 'number' ? row.waitingMinutes : 0,
34
+ }
35
+ })
36
+ const isWarn = conflict.reason === 'review_debt_warn'
37
+ return {
38
+ kind: isWarn ? 'warn' : 'blocked',
39
+ reason:
40
+ details.friction === 'count' || details.friction === 'stuck' ? details.friction : undefined,
41
+ threshold: typeof details.threshold === 'number' ? details.threshold : null,
42
+ debt,
43
+ onConfirm: isWarn ? retry : null,
44
+ pending,
45
+ onLeave: leave,
46
+ }
47
+ }