@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.
- package/README.md +7 -0
- package/app/components/board/AddTaskModal.vue +29 -30
- package/app/components/board/ReviewFrictionDialog.vue +8 -5
- package/app/components/layout/NotificationsInbox.vue +62 -129
- package/app/components/pipeline/PipelinePicker.vue +8 -2
- package/app/components/queue/DescribeWorkModal.vue +367 -0
- package/app/components/queue/QueueView.vue +18 -0
- package/app/components/settings/ApiTokensPanel.vue +86 -7
- package/app/composables/useNavContributions.ts +1 -0
- package/app/composables/usePipelineErrorToast.ts +4 -0
- package/app/composables/useTaskActions.ts +51 -28
- package/app/modular/nav-contributions.spec.ts +1 -0
- package/app/modular/nav-contributions.ts +21 -0
- package/app/pages/index.vue +2 -0
- package/app/stores/publicApiKeys.spec.ts +28 -0
- package/app/stores/publicApiKeys.ts +17 -3
- package/app/stores/ui/modals.ts +28 -0
- package/app/utils/describeWork.spec.ts +76 -0
- package/app/utils/describeWork.ts +71 -0
- package/app/utils/notificationRoutes.spec.ts +81 -0
- package/app/utils/notificationRoutes.ts +179 -0
- package/app/utils/reviewFriction.ts +47 -0
- package/i18n/locales/de.json +50 -7
- package/i18n/locales/en.json +50 -7
- package/i18n/locales/es.json +50 -7
- package/i18n/locales/fr.json +50 -7
- package/i18n/locales/he.json +50 -7
- package/i18n/locales/it.json +50 -7
- package/i18n/locales/ja.json +50 -7
- package/i18n/locales/pl.json +50 -7
- package/i18n/locales/tr.json +50 -7
- package/i18n/locales/uk.json +50 -7
- package/package.json +2 -2
|
@@ -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
|
package/app/pages/index.vue
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/app/stores/ui/modals.ts
CHANGED
|
@@ -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
|
+
}
|