@cat-factory/app 0.312.0 → 0.314.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +12 -0
  2. package/app/components/board/AddTaskModal.vue +29 -30
  3. package/app/components/board/ReviewFrictionDialog.vue +8 -5
  4. package/app/components/layout/AiProvidersBanner.vue +3 -2
  5. package/app/components/layout/GitHubPatBanner.vue +2 -2
  6. package/app/components/layout/GitHubPatPermissionsBanner.vue +4 -12
  7. package/app/components/layout/InfraSetupBanner.vue +6 -7
  8. package/app/components/pipeline/PipelinePicker.vue +8 -2
  9. package/app/components/queue/DescribeWorkModal.vue +367 -0
  10. package/app/components/queue/QueueView.vue +18 -0
  11. package/app/components/setup/SetupChecklistPanel.vue +340 -0
  12. package/app/composables/useNavContributions.ts +2 -0
  13. package/app/composables/useSetupChecklist.ts +122 -0
  14. package/app/composables/useSetupRemedies.ts +72 -0
  15. package/app/composables/useTaskActions.ts +51 -28
  16. package/app/modular/nav-contributions.spec.ts +3 -0
  17. package/app/modular/nav-contributions.ts +46 -0
  18. package/app/pages/index.vue +6 -0
  19. package/app/stores/github/context.ts +1 -0
  20. package/app/stores/github/probe.ts +7 -3
  21. package/app/stores/github.ts +9 -0
  22. package/app/stores/ui/modals.ts +42 -0
  23. package/app/utils/describeWork.spec.ts +76 -0
  24. package/app/utils/describeWork.ts +71 -0
  25. package/app/utils/reviewFriction.ts +47 -0
  26. package/app/utils/setupChecklist.spec.ts +184 -0
  27. package/app/utils/setupChecklist.ts +229 -0
  28. package/i18n/locales/de.json +97 -3
  29. package/i18n/locales/en.json +97 -3
  30. package/i18n/locales/es.json +97 -3
  31. package/i18n/locales/fr.json +97 -3
  32. package/i18n/locales/he.json +97 -3
  33. package/i18n/locales/it.json +97 -3
  34. package/i18n/locales/ja.json +97 -3
  35. package/i18n/locales/pl.json +97 -3
  36. package/i18n/locales/tr.json +97 -3
  37. package/i18n/locales/uk.json +97 -3
  38. package/package.json +1 -1
@@ -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',
@@ -210,6 +211,7 @@ export const NAV_ACTIONS = [
210
211
  'foundationalServices',
211
212
  'mergeThresholds',
212
213
  'workspaceSettings',
214
+ 'setupChecklist',
213
215
  'modelConfiguration',
214
216
  'serviceFragmentDefaults',
215
217
  'localModels',
@@ -345,6 +347,26 @@ const S = (...s: NavSurface[]) => s as readonly NavSurface[]
345
347
  * the whole sidebar costs it nothing it is there to do.
346
348
  */
347
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
+ },
348
370
  {
349
371
  // The in-app assistant. `intake: true` and not `advanced`, because it is the shortest route
350
372
  // to the two things a narrowed role is on the board to do (put a repository on it, file a
@@ -538,6 +560,30 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
538
560
  keywordsKey: 'layout.commandBar.keywords.workspaceSettings',
539
561
  },
540
562
  },
563
+ {
564
+ // Every prerequisite for "a task can run and merge", composed from the probes the setup
565
+ // banners already read (issue #2258). First in Configuration, because a workspace that cannot
566
+ // run a task yet needs it before anything else there. Both tiers: getting to green is not an
567
+ // advanced job. Gated on either permission, since its rows open both kinds of surface. Not
568
+ // `intake`: the designer surface never configures the platform, and the setup banners give it
569
+ // no actionable card either. Each row still applies the banners' per-owner rule
570
+ // (`utils/setupAudience.ts`), because holding one of these permissions does not make a caller
571
+ // the owner of every gap.
572
+ id: 'setup-checklist',
573
+ labelKey: 'nav.setupChecklist',
574
+ icon: 'i-lucide-list-checks',
575
+ surfaces: S('sidebar', 'command'),
576
+ gate: (g) => g.canManageIntegrations || g.canManageSettings,
577
+ action: 'setupChecklist',
578
+ testId: 'nav-setup-checklist',
579
+ sidebar: { group: 'configuration', order: 5 },
580
+ command: {
581
+ group: 'workspace',
582
+ order: 120,
583
+ labelKey: 'layout.commandBar.cmd.setupChecklist',
584
+ keywordsKey: 'layout.commandBar.keywords.setupChecklist',
585
+ },
586
+ },
541
587
  {
542
588
  id: 'model-config',
543
589
  labelKey: 'nav.modelConfiguration',
@@ -104,6 +104,9 @@ const ModelProvidersHub = defineAsyncView(() => import('~/components/layout/Mode
104
104
  const PersonalSetupModal = defineAsyncView(
105
105
  () => import('~/components/layout/PersonalSetupModal.vue'),
106
106
  )
107
+ const SetupChecklistPanel = defineAsyncView(
108
+ () => import('~/components/setup/SetupChecklistPanel.vue'),
109
+ )
107
110
  const WorkspaceSettingsPanel = defineAsyncView(
108
111
  () => import('~/components/settings/WorkspaceSettingsPanel.vue'),
109
112
  )
@@ -161,6 +164,7 @@ const TutorialNudge = defineAsyncView(() => import('~/components/tutorial/Tutori
161
164
  const RolePrompt = defineAsyncView(() => import('~/components/layout/RolePrompt.vue'))
162
165
  // The queue: the board's second view (issue #2258), loaded the first time someone opens it.
163
166
  const QueueView = defineAsyncView(() => import('~/components/queue/QueueView.vue'))
167
+ const DescribeWorkModal = defineAsyncView(() => import('~/components/queue/DescribeWorkModal.vue'))
164
168
 
165
169
  const workspace = useWorkspaceStore()
166
170
  const github = useGitHubStore()
@@ -529,6 +533,7 @@ watch(
529
533
  <AddTaskModal />
530
534
  <ReviewFrictionDialog v-if="ui.reviewFrictionContext" />
531
535
  <CreateInitiativeModal />
536
+ <DescribeWorkModal v-if="ui.describeWorkOpen" />
532
537
  <CommandBar />
533
538
  <PersonalCredentialModal />
534
539
  <ConfirmDialog />
@@ -564,6 +569,7 @@ watch(
564
569
  <IntegrationsHub v-if="ui.integrationsOpen" />
565
570
  <ModelProvidersHub v-if="ui.modelProvidersOpen" />
566
571
  <PersonalSetupModal v-if="ui.personalSetupOpen" />
572
+ <SetupChecklistPanel v-if="ui.setupChecklistOpen" />
567
573
  <WorkspaceSettingsPanel v-if="ui.workspaceSettingsOpen" />
568
574
  <AccountSettingsPanel v-if="ui.accountSettingsOpen" />
569
575
  <ObservabilityConnectionPanel v-if="ui.observabilityConnectionOpen" />
@@ -26,6 +26,7 @@ export interface GitHubStoreContext {
26
26
  api: ReturnType<typeof useApi>
27
27
  workspace: ReturnType<typeof useWorkspaceStore>
28
28
  available: Ref<boolean | null>
29
+ unavailableCause: Ref<'disabled' | 'failed' | null>
29
30
  connection: Ref<GitHubConnection | null>
30
31
  /** The connect surfaces this deployment can serve (GitHub App / GitLab PAT / …). */
31
32
  connectOptions: Ref<VcsConnectOption[]>
@@ -2,6 +2,7 @@ import type { Ref } from 'vue'
2
2
  import type { GitHubPatCheck } from '~/types/domain'
3
3
  import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
4
4
  import type { GitHubStoreContext } from '~/stores/github/context'
5
+ import { apiErrorStatus } from '~/composables/api/errors'
5
6
 
6
7
  /**
7
8
  * The board-load probe: everything the store learns about the deployment's VCS setup in one round
@@ -37,7 +38,7 @@ export function createGitHubProbe(
37
38
  ctx: GitHubStoreContext,
38
39
  patCheck: Ref<GitHubPatCheck | null>,
39
40
  ): { probe: () => Promise<void>; ensureProbed: () => Promise<void> } {
40
- const { api, workspace, available, connection, connectOptions } = ctx
41
+ const { api, workspace, available, unavailableCause, connection, connectOptions } = ctx
41
42
 
42
43
  async function runPatCheck(): Promise<void> {
43
44
  // Which board asked, captured BEFORE the await: a workspace switch mid-flight must not land
@@ -65,11 +66,14 @@ export function createGitHubProbe(
65
66
  .catch(() => []),
66
67
  ])
67
68
  available.value = true
69
+ unavailableCause.value = null
68
70
  connection.value = conn
69
71
  connectOptions.value = options
70
- } catch {
71
- // 503 (integration disabled) or any error → hide the UI entry points.
72
+ } catch (err) {
73
+ // 503 (integration disabled) or any error → hide the UI entry points. The cause is kept, so
74
+ // a surface that states the setup can tell "switched off" from "could not ask".
72
75
  available.value = false
76
+ unavailableCause.value = apiErrorStatus(err) === 503 ? 'disabled' : 'failed'
73
77
  connection.value = null
74
78
  connectOptions.value = []
75
79
  }
@@ -38,6 +38,12 @@ export const useGitHubStore = defineStore('github', () => {
38
38
 
39
39
  /** null = unknown (not probed yet), true/false = integration on/off. */
40
40
  const available = ref<boolean | null>(null)
41
+ /**
42
+ * Why `available` is false: the deployment switched the integration off (a 503), or the probe
43
+ * itself failed. Both hide the GitHub UI, but only the first is a fact about the deployment;
44
+ * the second is an unanswered question. Null while `available` is not false.
45
+ */
46
+ const unavailableCause = ref<'disabled' | 'failed' | null>(null)
41
47
  /** The workspace's VCS connection (App installation or PAT), or null when not connected. */
42
48
  const connection = ref<GitHubConnection | null>(null)
43
49
  /** The connect surfaces this deployment serves; resolved by the probe alongside `connection`. */
@@ -160,6 +166,7 @@ export const useGitHubStore = defineStore('github', () => {
160
166
  api,
161
167
  workspace,
162
168
  available,
169
+ unavailableCause,
163
170
  connection,
164
171
  connectOptions,
165
172
  installations,
@@ -207,6 +214,7 @@ export const useGitHubStore = defineStore('github', () => {
207
214
  */
208
215
  function reset() {
209
216
  available.value = null
217
+ unavailableCause.value = null
210
218
  connection.value = null
211
219
  connectOptions.value = []
212
220
  patCheck.value = null
@@ -221,6 +229,7 @@ export const useGitHubStore = defineStore('github', () => {
221
229
 
222
230
  return {
223
231
  available,
232
+ unavailableCause,
224
233
  connection,
225
234
  connectOptions,
226
235
  patCheck,
@@ -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,
@@ -728,6 +756,17 @@ function createSettingsModals(resetHubReturn: ResetHubReturn) {
728
756
  // so a later plain open doesn't re-scroll. Null when no section was requested.
729
757
  const accountSettingsScrollTarget = ref<string | null>(null)
730
758
 
759
+ // Setup checklist: every prerequisite for "a task can run and merge" in one list, composed
760
+ // from the probes the setup banners already read (issue #2258).
761
+ const setupChecklistOpen = ref(false)
762
+ function openSetupChecklist() {
763
+ resetHubReturn()
764
+ setupChecklistOpen.value = true
765
+ }
766
+ function closeSetupChecklist() {
767
+ setupChecklistOpen.value = false
768
+ }
769
+
731
770
  function openWorkspaceSettings(tab = 'workspace') {
732
771
  resetHubReturn()
733
772
  workspaceSettingsTab.value = tab
@@ -765,6 +804,9 @@ function createSettingsModals(resetHubReturn: ResetHubReturn) {
765
804
  }
766
805
 
767
806
  return {
807
+ setupChecklistOpen,
808
+ openSetupChecklist,
809
+ closeSetupChecklist,
768
810
  workspaceSettingsOpen,
769
811
  workspaceSettingsTab,
770
812
  accountSettingsOpen,
@@ -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,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
+ }
@@ -0,0 +1,184 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { InfraSetup } from '@cat-factory/contracts'
3
+ import { deriveSetupChecklist, type SetupProbeInput } from '~/utils/setupChecklist'
4
+ import type { SetupActor } from '~/utils/setupAudience'
5
+
6
+ // The checklist adds no probe of its own; what it owes is to state each probe's answer without
7
+ // changing its meaning. Three rules matter most: an unanswered probe is never "missing" or
8
+ // "done", a broken setup is never "missing" (that would send the reader to create a second one),
9
+ // and a row only offers its action to someone whose save would land.
10
+
11
+ const ALL_CONFIGURED: InfraSetup = {
12
+ agentExecutor: 'configured',
13
+ ephemeralEnvironments: 'configured',
14
+ binaryStorage: 'configured',
15
+ }
16
+
17
+ const ADMIN: SetupActor = {
18
+ fullSurface: true,
19
+ canManageIntegrations: true,
20
+ canExecuteRuns: true,
21
+ isAccountAdmin: true,
22
+ accountsEnabled: true,
23
+ }
24
+
25
+ const SOURCE_OK: SetupProbeInput['sourceControl'] = {
26
+ available: true,
27
+ unavailableCause: null,
28
+ connected: true,
29
+ patMissing: false,
30
+ patProblem: null,
31
+ }
32
+
33
+ function input(overrides: Partial<SetupProbeInput> = {}): SetupProbeInput {
34
+ return {
35
+ model: { loaded: true, loadFailed: false, usable: true, presetBroken: false },
36
+ sourceControl: SOURCE_OK,
37
+ infra: ALL_CONFIGURED,
38
+ workspaceLoaded: true,
39
+ infraOwners: null,
40
+ providers: { loaded: true, needingConfig: [] },
41
+ actor: ADMIN,
42
+ ...overrides,
43
+ }
44
+ }
45
+
46
+ function row(i: SetupProbeInput, id: string) {
47
+ const found = deriveSetupChecklist(i).rows.find((r) => r.id === id)
48
+ if (!found) throw new Error(`no row ${id}`)
49
+ return found
50
+ }
51
+
52
+ describe('deriveSetupChecklist', () => {
53
+ it('reports a fully set-up workspace as done, with nothing left checking', () => {
54
+ const c = deriveSetupChecklist(input())
55
+ expect(c.done).toBe(c.total)
56
+ expect(c.checking).toBe(false)
57
+ })
58
+
59
+ it('reports an unanswered probe as checking, never as missing or done', () => {
60
+ const c = deriveSetupChecklist(
61
+ input({
62
+ model: { loaded: false, loadFailed: false, usable: false, presetBroken: false },
63
+ sourceControl: { ...SOURCE_OK, available: null, connected: false },
64
+ infra: null,
65
+ workspaceLoaded: false,
66
+ }),
67
+ )
68
+ expect(c.rows.every((r) => r.status === 'checking')).toBe(true)
69
+ expect(c.checking).toBe(true)
70
+ })
71
+
72
+ it('keeps a configured runner checking until the providers say whether it is complete', () => {
73
+ const waiting = input({ providers: { loaded: false, needingConfig: [] } })
74
+ expect(row(waiting, 'agentExecutor').status).toBe('checking')
75
+ expect(deriveSetupChecklist(waiting).checking).toBe(true)
76
+ // Storage has no provider connection, so it does not wait on them.
77
+ expect(row(waiting, 'binaryStorage').status).toBe('done')
78
+ })
79
+
80
+ it('names the cause of each broken setup, and tells it from an absent one', () => {
81
+ expect(
82
+ row(input({ infra: { ...ALL_CONFIGURED, agentExecutor: 'unreachable' } }), 'agentExecutor'),
83
+ ).toMatchObject({ status: 'attention', cause: 'unreachable' })
84
+ expect(
85
+ row(input({ providers: { loaded: true, needingConfig: ['runner-pool'] } }), 'agentExecutor'),
86
+ ).toMatchObject({ status: 'attention', cause: 'incomplete' })
87
+ expect(
88
+ row(input({ infra: { ...ALL_CONFIGURED, agentExecutor: 'not_defined' } }), 'agentExecutor'),
89
+ ).toMatchObject({ status: 'missing', cause: null })
90
+ expect(
91
+ row(
92
+ input({ model: { loaded: true, loadFailed: false, usable: true, presetBroken: true } }),
93
+ 'model',
94
+ ),
95
+ ).toMatchObject({ status: 'attention', cause: 'presetBroken' })
96
+ expect(
97
+ row(input({ sourceControl: { ...SOURCE_OK, patProblem: 'rejected' } }), 'sourceControl'),
98
+ ).toMatchObject({ status: 'attention', cause: 'rejected' })
99
+ })
100
+
101
+ it('states a failed probe as unanswered, never as an absent capability', () => {
102
+ expect(
103
+ row(
104
+ input({ model: { loaded: false, loadFailed: true, usable: false, presetBroken: false } }),
105
+ 'model',
106
+ ),
107
+ ).toMatchObject({ status: 'attention', cause: 'probeFailed' })
108
+ const failed = input({
109
+ sourceControl: {
110
+ ...SOURCE_OK,
111
+ available: false,
112
+ unavailableCause: 'failed',
113
+ connected: false,
114
+ },
115
+ })
116
+ expect(row(failed, 'sourceControl')).toMatchObject({
117
+ status: 'attention',
118
+ cause: 'probeFailed',
119
+ })
120
+ // A deployment that switched the integration off does not use it: no gap to close.
121
+ const disabled = input({
122
+ sourceControl: {
123
+ ...SOURCE_OK,
124
+ available: false,
125
+ unavailableCause: 'disabled',
126
+ connected: false,
127
+ },
128
+ })
129
+ expect(row(disabled, 'sourceControl').status).toBe('not_needed')
130
+ })
131
+
132
+ it('says so when the loaded workspace does not report its infrastructure, never spins', () => {
133
+ const i = input({ infra: null, workspaceLoaded: true })
134
+ expect(row(i, 'agentExecutor')).toMatchObject({ status: 'attention', cause: 'notReported' })
135
+ expect(deriveSetupChecklist(i).checking).toBe(false)
136
+ })
137
+
138
+ it('leaves an area the deployment does not use out of the count', () => {
139
+ const c = deriveSetupChecklist(
140
+ input({ infra: { ...ALL_CONFIGURED, ephemeralEnvironments: 'not_applicable' } }),
141
+ )
142
+ expect(c.total).toBe(c.rows.length - 1)
143
+ expect(c.done).toBe(c.total)
144
+ })
145
+
146
+ describe('who can act on a row (the banners rule, `utils/setupAudience.ts`)', () => {
147
+ const unconfigured: InfraSetup = {
148
+ agentExecutor: 'not_defined',
149
+ ephemeralEnvironments: 'not_defined',
150
+ binaryStorage: 'not_defined',
151
+ }
152
+
153
+ it('offers a board admin who is not an account admin only the gaps they own', () => {
154
+ const boardAdmin = { ...ADMIN, isAccountAdmin: false }
155
+ const i = input({
156
+ infra: unconfigured,
157
+ infraOwners: {
158
+ agentExecutor: 'workspace_admin',
159
+ ephemeralEnvironments: 'operator',
160
+ binaryStorage: 'account_admin',
161
+ },
162
+ actor: boardAdmin,
163
+ })
164
+ expect(row(i, 'agentExecutor').remedy).toBeNull()
165
+ expect(row(i, 'ephemeralEnvironments').remedy).toBe('operator')
166
+ expect(row(i, 'binaryStorage').remedy).toBe('account_admin')
167
+ })
168
+
169
+ it('tells someone on the designer surface they can fix it themselves', () => {
170
+ const i = input({ infra: unconfigured, actor: { ...ADMIN, fullSurface: false } })
171
+ expect(row(i, 'agentExecutor').remedy).toBe('full_surface')
172
+ })
173
+
174
+ it('gives every caller the token page for a personal access token', () => {
175
+ const member = { ...ADMIN, canManageIntegrations: false, isAccountAdmin: false }
176
+ const i = input({
177
+ sourceControl: { ...SOURCE_OK, patMissing: true, connected: false },
178
+ actor: member,
179
+ })
180
+ expect(row(i, 'sourceControl').remedy).toBeNull()
181
+ expect(row(input({ actor: member }), 'model').remedy).toBe('workspace_admin')
182
+ })
183
+ })
184
+ })