@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 CHANGED
@@ -1492,6 +1492,13 @@ example ships in [`deploy/frontend`](https://github.com/kibertoad/cat-factory/tr
1492
1492
  switchers, the `SpendWarningBanner`, and the toolbar (zoom, LOD, decision queue).
1493
1493
  - **Palettes** (`components/palettes`): drag blocks, pipelines and agents onto
1494
1494
  the board.
1495
+ - **Describe new work** (`components/queue/DescribeWorkModal.vue`, `utils/describeWork.ts`): the
1496
+ one-sentence intake, from Create, the palette and the queue header. It files a feature or a bug
1497
+ from a sentence and a service, titled by the first sentence; the overrides sit behind one
1498
+ disclosure, and the merge policy and model preset follow the full form's tier rule
1499
+ (`showOverrideField`), so basic mode and the designer role see the pipeline only.
1500
+ It rebuilds no other kind: "More kinds" and "Attach context" hand the text to `AddTaskModal`,
1501
+ which stays the one home of those rules. Both share `utils/reviewFriction.ts` for the review-debt 409.
1495
1502
  - **Setup checklist** (`components/setup/SetupChecklistPanel.vue`, `utils/setupChecklist.ts`):
1496
1503
  every prerequisite for "a task can run and merge" in one list, for people who can change the
1497
1504
  setup. It adds no probe of its own: each row reads what its banner or startup dialog already
@@ -36,6 +36,7 @@ import FragmentSelector from '~/components/fragments/FragmentSelector.vue'
36
36
  import ReviewSkillQueue from '~/components/skills/ReviewSkillQueue.vue'
37
37
  import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
38
38
  import { parseConflict } from '~/composables/usePipelineErrorToast'
39
+ import { isReviewDebtConflict, reviewFrictionContext } from '~/utils/reviewFriction'
39
40
  import { apiErrorEnvelope } from '~/composables/api/errors'
40
41
  import type { ReviewTargetReason } from '@cat-factory/contracts'
41
42
  import {
@@ -526,6 +527,8 @@ function defaultPipelineIdFor(type: TaskTypeChoice): string {
526
527
  return offered ? preset : ''
527
528
  }
528
529
 
530
+ // Set while a prefill's type is being seeded with a carried pipeline (see `onModalOpen` below).
531
+ let seedingPrefill = false
529
532
  watch(taskType, (next) => {
530
533
  const custom = customTaskTypes.value.find((tt) => tt.taskType === next)
531
534
  // A custom type owns a fresh field bag on every switch (its descriptors differ per type), seeded
@@ -533,6 +536,10 @@ watch(taskType, (next) => {
533
536
  customFieldValues.value = descriptorFieldDefaults(custom?.fields ?? [])
534
537
  // An unresolvable preset leaves the current selection alone rather than blanking it: a type
535
538
  // switch is an edit to a form the user is already filling in, not a reset.
539
+ if (seedingPrefill) {
540
+ seedingPrefill = false
541
+ return
542
+ }
536
543
  const preset = defaultPipelineIdFor(next)
537
544
  if (preset) pipelineId.value = preset
538
545
  })
@@ -667,13 +674,23 @@ onModalOpen(open, () => {
667
674
  agentConfigValues.value = {}
668
675
  pendingContext.value = []
669
676
  // Seed from a prefill when opened from another surface (e.g. "create task from
670
- // issue" sets the title + stages the issue as linked context). Pipeline / preset
671
- // are intentionally left at their defaults so the user confirms them here.
677
+ // issue" sets the title + stages the issue as linked context). Pipeline / preset stay at their
678
+ // defaults unless the opener carries a choice the person already made (the one-sentence
679
+ // intake's kind and overrides), which the hand-off must not reset.
672
680
  const prefill = ui.addTaskPrefill
673
681
  if (prefill) {
674
682
  if (prefill.title) title.value = prefill.title
675
683
  if (prefill.description) description.value = prefill.description
676
684
  if (prefill.context?.length) pendingContext.value = [...prefill.context]
685
+ if (prefill.taskType && prefill.taskType !== taskType.value) {
686
+ taskType.value = prefill.taskType
687
+ // The type watcher runs after this block and would replace a carried pipeline with the
688
+ // new type's default; a seeded type is not the edit that watcher is for.
689
+ seedingPrefill = !!prefill.pipelineId
690
+ }
691
+ if (prefill.pipelineId) pipelineId.value = prefill.pipelineId
692
+ if (prefill.riskPolicyId) riskPolicyId.value = prefill.riskPolicyId
693
+ if (prefill.modelPresetId) modelPresetId.value = prefill.modelPresetId
677
694
  }
678
695
  documents.loadDocuments().catch(() => {})
679
696
  tasks.loadTasks().catch(() => {})
@@ -831,8 +848,15 @@ async function submitCreate(acknowledgeReviewDebt: boolean) {
831
848
  ui.closeAddTask()
832
849
  } catch (e) {
833
850
  const conflict = parseConflict(e)
834
- if (conflict?.reason === 'review_debt_warn' || conflict?.reason === 'review_debt_blocked') {
835
- openReviewFrictionDialog(conflict)
851
+ if (isReviewDebtConflict(conflict)) {
852
+ ui.openReviewFriction(
853
+ reviewFrictionContext(
854
+ conflict,
855
+ () => void submitCreate(true),
856
+ () => saving.value,
857
+ () => ui.closeAddTask(),
858
+ ),
859
+ )
836
860
  return
837
861
  }
838
862
  const refusal = createRefusalMessage(e)
@@ -883,32 +907,6 @@ function createRefusalMessage(error: unknown): string | null {
883
907
  if (reason === 'task_type_fields_invalid') return t('board.addTask.customFieldsInvalid')
884
908
  return REVIEW_TARGET_MESSAGES[reason as ReviewTargetReason]?.(details) ?? null
885
909
  }
886
-
887
- /** Turn a parsed review-debt friction 409 into the dialog context (see ReviewFrictionDialog.vue). */
888
- function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseConflict>>) {
889
- const details = conflict.details
890
- const rawDebt = Array.isArray(details.debt) ? details.debt : []
891
- const debt = rawDebt.map((d) => {
892
- const row = (d ?? {}) as { blockId?: unknown; title?: unknown; waitingMinutes?: unknown }
893
- return {
894
- blockId: typeof row.blockId === 'string' ? row.blockId : '',
895
- title: typeof row.title === 'string' ? row.title : null,
896
- waitingMinutes: typeof row.waitingMinutes === 'number' ? row.waitingMinutes : 0,
897
- }
898
- })
899
- const isWarn = conflict.reason === 'review_debt_warn'
900
- ui.openReviewFriction({
901
- kind: isWarn ? 'warn' : 'blocked',
902
- reason:
903
- details.friction === 'count' || details.friction === 'stuck' ? details.friction : undefined,
904
- threshold: typeof details.threshold === 'number' ? details.threshold : null,
905
- debt,
906
- onConfirm: isWarn ? () => void submitCreate(true) : null,
907
- // A getter, not a snapshot: the dialog reads it inside a computed so its button spins and
908
- // locks for as long as the retry actually runs (UX-78).
909
- pending: () => saving.value,
910
- })
911
- }
912
910
  </script>
913
911
 
914
912
  <template>
@@ -954,6 +952,7 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
954
952
  :icon="ty.icon"
955
953
  size="xs"
956
954
  :title="ty.description"
955
+ :aria-pressed="taskType === ty.value"
957
956
  :data-testid="`task-type-${ty.value}`"
958
957
  @click="
959
958
  () => {
@@ -53,17 +53,20 @@ const body = computed(() => {
53
53
  /** Deep-link to a waiting task's block and dismiss the whole friction flow. */
54
54
  function goToBlock(blockId: string) {
55
55
  if (blockId) ui.select(blockId)
56
+ leave()
57
+ }
58
+
59
+ /** Close the friction and the form that raised it. */
60
+ function leave() {
61
+ const onLeave = ctx.value?.onLeave
56
62
  ui.closeReviewFriction()
57
- ui.closeAddTask()
63
+ onLeave?.()
58
64
  }
59
65
 
60
66
  function goReview() {
61
67
  const first = ctx.value?.debt[0]
62
68
  if (first) goToBlock(first.blockId)
63
- else {
64
- ui.closeReviewFriction()
65
- ui.closeAddTask()
66
- }
69
+ else leave()
67
70
  }
68
71
 
69
72
  function createAnyway() {
@@ -2,6 +2,11 @@
2
2
  import type { Notification } from '~/types/domain'
3
3
  import type { ReviewEffort } from '~/types/merge'
4
4
  import SectionLabel from '~/components/common/SectionLabel.vue'
5
+ import {
6
+ notificationTarget,
7
+ notificationVerbKey,
8
+ type NotificationRouteDeps,
9
+ } from '~/utils/notificationRoutes'
5
10
 
6
11
  // The board's notification inbox: a bell with an open-count badge that opens a
7
12
  // panel of human-actionable items (a PR awaiting a merge decision, a completed
@@ -133,6 +138,45 @@ function actionLabel(n: Notification): string {
133
138
  return te(key) ? t(key) : t('layout.notifications.action.markRead')
134
139
  }
135
140
 
141
+ // The store reads and openers every route uses (`utils/notificationRoutes.ts`).
142
+ const routeDeps: NotificationRouteDeps = {
143
+ getInstance: (id) => execution.getInstance(id),
144
+ select: (id) => ui.select(id),
145
+ openStepDetail: (id, index) => ui.openStepDetail(id, index),
146
+ openDecision: (id, decisionId) => ui.openDecision(id, decisionId),
147
+ openFollowUps: (id) => ui.openFollowUps(id),
148
+ openForkDecision: (id) => ui.openForkDecision(id),
149
+ openPrReview: (id) => ui.openPrReview(id),
150
+ openBugFishing: (id) => ui.openBugFishing(id),
151
+ openRequirementReview: (id) => ui.openRequirementReview(id),
152
+ openClarityReview: (id) => ui.openClarityReview(id),
153
+ openInitiativeTracker: (id) => ui.openInitiativeTracker(id),
154
+ openOperatorDashboard: () => ui.openOperatorDashboard(),
155
+ }
156
+
157
+ /**
158
+ * The primary verb for a card whose act button only marks it read, from the same resolution its
159
+ * click takes. `te()`-guarded like the act label: a locale missing the key gets no button rather
160
+ * than a raw key path.
161
+ */
162
+ function goToLabel(n: Notification): string | null {
163
+ const key = notificationVerbKey(n, routeDeps)
164
+ return key && te(key) ? t(key) : null
165
+ }
166
+
167
+ /** Each open card's go-to label, computed once per change rather than once per binding. */
168
+ const goToLabels = computed(
169
+ () => new Map(notifications.open.map((n) => [n.id, goToLabel(n)] as const)),
170
+ )
171
+
172
+ // The popover is closed when a card takes the person somewhere, so it does not stay open under
173
+ // the window it opened and reappear when that window closes.
174
+ const open = ref(false)
175
+ function goTo(n: Notification) {
176
+ open.value = false
177
+ reveal(n)
178
+ }
179
+
136
180
  /** A notification the escalation sweep has flagged as overdue (waited past the threshold). */
137
181
  function isUrgent(n: Notification): boolean {
138
182
  return n.severity === 'urgent'
@@ -217,120 +261,9 @@ async function dismiss(n: Notification) {
217
261
  }
218
262
  }
219
263
 
220
- /**
221
- * Clicking a notification's title takes the user where they can act on it. A
222
- * `requirement_review` / `clarity_review` summons them straight back into the matching review
223
- * window (the async incorporate/re-review raised new findings or hit the cap); every other
224
- * type just focuses the related block on the board.
225
- */
264
+ /** Clicking a notification's title, or its verb, takes the user where they can act on it. */
226
265
  function reveal(n: Notification) {
227
- // A `platform_health` card is deployment-scoped (no block) — send the operator to the
228
- // dashboard where the live aggregate numbers behind the alert live.
229
- if (n.type === 'platform_health') return ui.openOperatorDashboard()
230
- if (!n.blockId) return
231
- if (n.type === 'requirement_review') ui.openRequirementReview(n.blockId)
232
- else if (n.type === 'clarity_review') ui.openClarityReview(n.blockId)
233
- else if (n.type === 'decision_required') revealDecision(n)
234
- else if (n.type === 'human_test_ready') revealHumanTest(n)
235
- else if (n.type === 'visual_confirmation_ready') revealVisualConfirm(n)
236
- else if (n.type === 'human_review') revealHumanReview(n)
237
- else if (n.type === 'followup_pending') revealFollowUps(n)
238
- else if (n.type === 'fork_decision_pending') revealForkDecision(n)
239
- else if (n.type === 'judge_review') revealJudge(n)
240
- else if (n.type === 'pr_review_ready') revealPrReview(n)
241
- else if (n.type === 'bug_fishing_triage') revealBugFishing(n)
242
- else if (n.type === 'initiative') ui.openInitiativeTracker(n.blockId)
243
- else ui.select(n.blockId)
244
- }
245
-
246
- /**
247
- * Open the gate window for a parked `human-review` gate: find the run's human-review step and
248
- * open it through the universal step dispatch (its archetype declares the `gate` result view,
249
- * where the human can request a freeform fix). Falls back to focusing the block.
250
- */
251
- function revealHumanReview(n: Notification) {
252
- const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
253
- const idx = instance?.steps.findIndex((s) => s.agentKind === 'human-review') ?? -1
254
- if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
255
- else if (n.blockId) ui.select(n.blockId)
256
- }
257
-
258
- /**
259
- * Open the Follow-up companion window for a run whose Coder parked on undecided items.
260
- * Falls back to focusing the block when the run isn't loaded.
261
- */
262
- function revealFollowUps(n: Notification) {
263
- if (n.executionId && execution.getInstance(n.executionId)) ui.openFollowUps(n.executionId)
264
- else if (n.blockId) ui.select(n.blockId)
265
- }
266
-
267
- /**
268
- * Open the implementation-fork decision window for a run parked awaiting a fork choice.
269
- * Falls back to focusing the block when the run isn't loaded.
270
- */
271
- function revealForkDecision(n: Notification) {
272
- if (n.executionId && execution.getInstance(n.executionId)) ui.openForkDecision(n.executionId)
273
- else if (n.blockId) ui.select(n.blockId)
274
- }
275
-
276
- /**
277
- * Open the judge window for a run parked on a rubric verdict: find the step carrying the parked
278
- * verdict and open it through the universal step dispatch (a registered judge declares the
279
- * `judge` result view). Falls back to focusing the block when the run isn't loaded.
280
- */
281
- function revealJudge(n: Notification) {
282
- const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
283
- const idx = instance?.steps.findIndex((s) => s.judge?.status === 'awaiting_decision') ?? -1
284
- if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
285
- else if (n.blockId) ui.select(n.blockId)
286
- }
287
-
288
- /**
289
- * Open the PR deep-review window for a run parked awaiting a finding selection.
290
- * Falls back to focusing the block when the run isn't loaded.
291
- */
292
- function revealPrReview(n: Notification) {
293
- if (n.executionId && execution.getInstance(n.executionId)) ui.openPrReview(n.executionId)
294
- else if (n.blockId) ui.select(n.blockId)
295
- }
296
-
297
- /**
298
- * Open the bug-fishing expedition window for a run whose angles have all settled. Falls back to
299
- * focusing the block when the run is not loaded, exactly like its PR-review sibling.
300
- */
301
- function revealBugFishing(n: Notification) {
302
- if (n.executionId && execution.getInstance(n.executionId)) ui.openBugFishing(n.executionId)
303
- else if (n.blockId) ui.select(n.blockId)
304
- }
305
-
306
- /**
307
- * Open the human-testing window for a parked `human-test` gate: find the run's parked
308
- * human-test step and open it through the universal step dispatch (its archetype declares
309
- * the `human-test` result view). Falls back to focusing the block.
310
- */
311
- function revealHumanTest(n: Notification) {
312
- const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
313
- const idx =
314
- instance?.steps.findIndex(
315
- (s) => s.agentKind === 'human-test' && s.state === 'waiting_decision',
316
- ) ?? -1
317
- if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
318
- else if (n.blockId) ui.select(n.blockId)
319
- }
320
-
321
- /**
322
- * Open the visual-confirmation window for a parked `visual-confirmation` gate: find the run's
323
- * parked step and open it through the universal step dispatch (its archetype declares the
324
- * `visual-confirm` result view). Falls back to focusing the block.
325
- */
326
- function revealVisualConfirm(n: Notification) {
327
- const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
328
- const idx =
329
- instance?.steps.findIndex(
330
- (s) => s.agentKind === 'visual-confirmation' && s.state === 'waiting_decision',
331
- ) ?? -1
332
- if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
333
- else if (n.blockId) ui.select(n.blockId)
266
+ notificationTarget(n, routeDeps)?.open()
334
267
  }
335
268
 
336
269
  /**
@@ -370,23 +303,10 @@ function revealFailingRun(run: { executionId: string; blockId: string | null })
370
303
  if (execution.getInstance(run.executionId)) ui.openObservability(run.executionId)
371
304
  else if (run.blockId) ui.select(run.blockId)
372
305
  }
373
-
374
- /**
375
- * Open the decision surface for a parked iteration-cap run: find the run's step that is
376
- * waiting on a human and open it through the universal step dispatch — which routes a
377
- * `requirements-review` step to the review window and a companion step to its detail
378
- * panel (where the iteration-cap prompt lives). Falls back to focusing the block.
379
- */
380
- function revealDecision(n: Notification) {
381
- const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
382
- const idx = instance?.steps.findIndex((s) => s.state === 'waiting_decision') ?? -1
383
- if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
384
- else if (n.blockId) ui.select(n.blockId)
385
- }
386
306
  </script>
387
307
 
388
308
  <template>
389
- <UPopover v-if="notifications.count" :content="{ align: 'end' }">
309
+ <UPopover v-if="notifications.count" v-model:open="open" :content="{ align: 'end' }">
390
310
  <UButton
391
311
  data-testid="notifications-bell"
392
312
  :color="hasUrgent ? 'error' : 'warning'"
@@ -426,7 +346,7 @@ function revealDecision(n: Notification) {
426
346
  variant="link"
427
347
  class="block min-w-0 flex-1 truncate p-0 text-start text-sm font-medium text-default hover:underline"
428
348
  :title="n.title"
429
- @click="reveal(n)"
349
+ @click="goTo(n)"
430
350
  >
431
351
  {{ n.title }}
432
352
  </UButton>
@@ -502,11 +422,24 @@ function revealDecision(n: Notification) {
502
422
  @update:model-value="effortByCard[n.id] = $event"
503
423
  />
504
424
  <div class="mt-2 flex items-center gap-1.5">
425
+ <!-- The action, where marking read is not one: opens what the title opens. -->
426
+ <!-- Soft, not solid: the inbox lists many cards, and a solid button on each would put
427
+ many primaries in one view. -->
505
428
  <UButton
506
- data-testid="notification-act"
429
+ v-if="goToLabels.get(n.id)"
430
+ data-testid="notification-go-to"
507
431
  :color="accent(n)"
508
432
  variant="soft"
509
433
  size="xs"
434
+ @click="goTo(n)"
435
+ >
436
+ {{ goToLabels.get(n.id) }}
437
+ </UButton>
438
+ <UButton
439
+ data-testid="notification-act"
440
+ :color="goToLabels.get(n.id) ? 'neutral' : accent(n)"
441
+ :variant="goToLabels.get(n.id) ? 'outline' : 'soft'"
442
+ size="xs"
510
443
  :loading="busy === n.id"
511
444
  :disabled="!access.canExecuteRuns.value"
512
445
  :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
@@ -21,6 +21,12 @@ const props = withDefaults(
21
21
  * chosen row — and the row is left out entirely.
22
22
  */
23
23
  noneLabel?: string
24
+ /**
25
+ * What the preview pane says about the "none" row. Defaults to the full form's meaning ("no
26
+ * default; you choose when you run it"); a surface whose "none" row means the workspace default
27
+ * names what that default resolves to instead.
28
+ */
29
+ noneHint?: string
24
30
  /**
25
31
  * Default-trigger text while nothing is selected. Defaults to {@link noneLabel}, which reads
26
32
  * correctly when the "none" row IS the empty state; a surface without that row supplies its
@@ -30,7 +36,7 @@ const props = withDefaults(
30
36
  /** Extra classes for the default trigger button (e.g. full-width in the modal). */
31
37
  triggerClass?: string
32
38
  }>(),
33
- { noneLabel: undefined, placeholder: undefined, triggerClass: '' },
39
+ { noneLabel: undefined, noneHint: undefined, placeholder: undefined, triggerClass: '' },
34
40
  )
35
41
 
36
42
  const emit = defineEmits<{ 'update:modelValue': [string] }>()
@@ -59,7 +65,7 @@ const previewPipeline = computed<Pipeline | null>(() => {
59
65
  */
60
66
  const emptyHint = computed(() =>
61
67
  props.noneLabel !== undefined && (hoverId.value ?? props.modelValue) === ''
62
- ? t('pipeline.picker.noneHint')
68
+ ? (props.noneHint ?? t('pipeline.picker.noneHint'))
63
69
  : t('pipeline.picker.hoverHint'),
64
70
  )
65
71