@cat-factory/app 0.280.0 → 0.280.2

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 (55) hide show
  1. package/README.md +26 -1
  2. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +142 -5
  3. package/app/components/board/AddTaskModal.vue +9 -0
  4. package/app/components/board/ReviewFrictionDialog.vue +35 -4
  5. package/app/components/board/TaskDependencyEdges.vue +26 -15
  6. package/app/components/board/nodes/TaskCard.vue +6 -1
  7. package/app/components/brainstorm/BrainstormWindow.vue +19 -1
  8. package/app/components/common/AsyncViewError.vue +30 -0
  9. package/app/components/common/ConfirmDialog.vue +26 -0
  10. package/app/components/docs/DocInterviewWindow.vue +40 -38
  11. package/app/components/followUp/FollowUpWindow.vue +32 -2
  12. package/app/components/forkDecision/ForkDecisionWindow.vue +44 -5
  13. package/app/components/gates/GateResultView.vue +14 -1
  14. package/app/components/humanTest/HumanTestWindow.vue +13 -1
  15. package/app/components/initiative/InitiativePlanDecision.vue +16 -3
  16. package/app/components/initiative/InitiativePlanReview.vue +16 -1
  17. package/app/components/initiative/InitiativePlanningWindow.vue +50 -52
  18. package/app/components/initiative/InitiativeTrackerWindow.vue +59 -1
  19. package/app/components/judge/JudgeResultView.vue +14 -1
  20. package/app/components/panels/AgentStepDetail.vue +651 -659
  21. package/app/components/panels/InspectorPanel.vue +6 -0
  22. package/app/components/panels/ResultWindowDrafts.logic.spec.ts +233 -0
  23. package/app/components/panels/inspector/ServiceTestSecrets.vue +17 -6
  24. package/app/components/pipeline/PipelineHealthModal.vue +55 -16
  25. package/app/components/prReview/PrReviewWindow.vue +23 -4
  26. package/app/components/visualConfirm/VisualConfirmationWindow.vue +19 -1
  27. package/app/composables/useBoardActivity.ts +62 -6
  28. package/app/composables/useConfirm.spec.ts +62 -0
  29. package/app/composables/useConfirm.ts +6 -1
  30. package/app/composables/useInterviewDrafts.spec.ts +198 -0
  31. package/app/composables/useInterviewDrafts.ts +184 -0
  32. package/app/composables/useTaskExpansion.ts +10 -25
  33. package/app/docs/consumer-extensions.md +9 -0
  34. package/app/modular/result-views.ts +58 -21
  35. package/app/pages/index.vue +68 -64
  36. package/app/stores/binaryCandidates.ts +26 -3
  37. package/app/stores/ui/modals.ts +11 -0
  38. package/app/utils/asyncView.ts +24 -0
  39. package/app/utils/binaryCandidates.spec.ts +45 -1
  40. package/app/utils/binaryCandidates.ts +33 -0
  41. package/app/utils/blockRects.spec.ts +82 -0
  42. package/app/utils/blockRects.ts +61 -0
  43. package/app/utils/boardWakeGate.spec.ts +101 -0
  44. package/app/utils/boardWakeGate.ts +78 -0
  45. package/i18n/locales/de.json +25 -2
  46. package/i18n/locales/en.json +25 -2
  47. package/i18n/locales/es.json +25 -2
  48. package/i18n/locales/fr.json +25 -2
  49. package/i18n/locales/he.json +25 -2
  50. package/i18n/locales/it.json +25 -2
  51. package/i18n/locales/ja.json +25 -2
  52. package/i18n/locales/pl.json +25 -2
  53. package/i18n/locales/tr.json +25 -2
  54. package/i18n/locales/uk.json +25 -2
  55. package/package.json +1 -1
@@ -332,9 +332,15 @@ const showOriginalDescription = ref(false)
332
332
  with the panel on top; the region now paints above at `z-40`, which only swaps which side
333
333
  loses). Sitting the panel below fixes it whichever way the stacking goes, and needs no
334
334
  left/right arithmetic to stay correct under RTL. -->
335
+ <!-- `data-inspector-block` names WHICH block the panel is showing, so a caller can tell a
336
+ panel left open on the previous selection from one that followed a new click. Deliberately
337
+ NOT `data-block-id`: that attribute is the board's card selector, which the canvas drivers
338
+ measure geometry through (`utils/blockRects.ts`), and a panel answering to it would offer
339
+ the arrows a rect that is not on the canvas at all. -->
335
340
  <div
336
341
  v-if="block && statusMeta && typeMeta"
337
342
  data-testid="inspector-panel"
343
+ :data-inspector-block="block.id"
338
344
  class="fixed inset-x-0 bottom-0 z-20 overflow-hidden rounded-t-2xl border border-slate-700 bg-slate-900/95 shadow-2xl backdrop-blur lg:absolute lg:inset-x-auto lg:bottom-auto lg:end-4 lg:top-16 lg:w-80 lg:rounded-2xl"
339
345
  >
340
346
  <div class="h-1.5 w-full" :style="{ backgroundColor: statusMeta.color }" />
@@ -0,0 +1,233 @@
1
+ import { readFileSync, readdirSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ // UX-79: a result window that holds typed-but-unsubmitted input must not discard it when the user
6
+ // dismisses the window. `ResultWindowShell` closes on the X, on Escape and on a backdrop click, and
7
+ // a window wired `@close="close"` goes straight through all three — so a reviewer who tabbed away
8
+ // mid-sentence lost the sentence, with nothing on screen having said so.
9
+ //
10
+ // The seams to fix it already existed and were used by exactly two of the fourteen windows that
11
+ // needed them. That is the shape this file guards against: not a missing primitive, but a primitive the
12
+ // next window silently doesn't reach for (the re-audit's cross-cutting theme 8). Nothing in the type
13
+ // system can ask "does this window hold a draft?", so the table below is the answer, asserted
14
+ // against what the components actually do.
15
+ //
16
+ // The two sanctioned dispositions, and the rule for picking:
17
+ //
18
+ // 'flush' — `useResultView({ onClose })`, then `useInterviewDrafts` or the review windows' own
19
+ // flush. For a draft whose save is a PLAIN SAVE: recording it changes nothing else, so
20
+ // writing it on the way out is what the user meant. The review windows and the two
21
+ // interview windows persist one answer at a time.
22
+ // 'confirm' — `useUnsavedGuard` in front of the shell's close. For a draft whose only submit
23
+ // button also DECIDES something — resolves a gate, spends a bounded chat turn, keeps
24
+ // an artifact, re-runs an agent. A stray Escape may not do that on the user's behalf,
25
+ // so the only honest options are to keep the draft or to ask.
26
+ //
27
+ // A window with no draft state is 'none' and closes straight through, as it always did.
28
+
29
+ /** Anchored on the vitest root (`frontend/app`) — see the sibling width spec for why not `import.meta.url`. */
30
+ const componentsDir = resolve(process.cwd(), 'app/components')
31
+
32
+ type Disposition = 'none' | 'flush' | 'confirm'
33
+
34
+ /**
35
+ * Every `ResultWindowShell` consumer and how it treats unsubmitted input. The `why` is the point for
36
+ * anything other than 'none': it has to name the draft, because "this window has no draft" is the
37
+ * claim that gets silently falsified when someone adds an input to it.
38
+ */
39
+ const WINDOWS: Record<string, { drafts: Disposition; why: string }> = {
40
+ 'binaryCandidates/BinaryCandidatesWindow.vue': {
41
+ drafts: 'confirm',
42
+ why: 'the keep rationale + per-candidate store-as aliases; Keep commits the artifacts',
43
+ },
44
+ 'brainstorm/BrainstormWindow.vue': {
45
+ drafts: 'confirm',
46
+ why: 'per-item replies + the redo comment; a reply resolves an item and the redo starts a pass',
47
+ },
48
+ 'clarity/ClarityReviewWindow.vue': {
49
+ drafts: 'flush',
50
+ why: 'per-finding answers, each recorded on its own',
51
+ },
52
+ 'consensus/ConsensusSessionWindow.vue': { drafts: 'none', why: 'a read-only transcript' },
53
+ 'docs/DocInterviewWindow.vue': {
54
+ drafts: 'flush',
55
+ why: 'per-question answers, each recorded on its own (Submit is a separate command)',
56
+ },
57
+ 'followUp/FollowUpWindow.vue': {
58
+ drafts: 'confirm',
59
+ why: 'per-question answers; sending one decides the item and re-arms the run',
60
+ },
61
+ 'forkDecision/ForkDecisionWindow.vue': {
62
+ drafts: 'confirm',
63
+ why: 'the custom approach, the steering note and the chat box; chat spends a bounded turn budget',
64
+ },
65
+ 'gates/GateResultView.vue': {
66
+ drafts: 'confirm',
67
+ why: 'the human-review fix instructions; Request fix resolves the gate and dispatches a fixer',
68
+ },
69
+ 'humanTest/HumanTestWindow.vue': {
70
+ drafts: 'confirm',
71
+ why: 'the tester findings, the only record of what went wrong; Request fix resolves the gate',
72
+ },
73
+ 'initiative/InitiativePlanningWindow.vue': {
74
+ drafts: 'flush',
75
+ why: 'per-question answers, each recorded on its own (Submit / Plan now are separate commands)',
76
+ },
77
+ 'initiative/InitiativeTrackerWindow.vue': {
78
+ drafts: 'confirm',
79
+ why: 'the plan review it hands its body to (anchored comments + feedback send back a re-plan), plus its own follow-up promotion and execution-policy forms',
80
+ },
81
+ 'judge/JudgeResultView.vue': {
82
+ drafts: 'confirm',
83
+ why: 'the guidance box; every command carrying it also resolves the parked verdict',
84
+ },
85
+ 'outcome/OutcomeSummaryWindow.vue': { drafts: 'none', why: 'a read-only run summary' },
86
+ 'panels/GenericStructuredResultView.vue': {
87
+ drafts: 'none',
88
+ why: 'a read-only structured reader',
89
+ },
90
+ 'panels/MergerResultView.vue': { drafts: 'none', why: 'a read-only merge verdict' },
91
+ 'prReview/PrReviewWindow.vue': {
92
+ drafts: 'confirm',
93
+ why: 'the per-finding challenge box; sending it spends a reviewer turn',
94
+ },
95
+ 'ralph/RalphLoopResultView.vue': { drafts: 'none', why: 'a loop status readout' },
96
+ 'requirements/RequirementsReviewWindow.vue': {
97
+ drafts: 'flush',
98
+ why: 'per-finding answers, each recorded on its own',
99
+ },
100
+ 'spec/ServiceSpecWindow.vue': { drafts: 'none', why: 'a read-only spec reader' },
101
+ 'testing/TestReportWindow.vue': { drafts: 'none', why: 'a read-only test report' },
102
+ 'visualConfirm/VisualConfirmationWindow.vue': {
103
+ drafts: 'confirm',
104
+ why: 'per-view notes anchored to a screenshot + the overall findings box; Request fix resolves the gate',
105
+ },
106
+ }
107
+
108
+ /**
109
+ * `v-model` bindings that are VIEW state rather than a draft, and may therefore appear in a window
110
+ * declared draft-free. Listed as EXACT bindings rather than matched by shape, because the point of
111
+ * the inverse assertion below is that a new binding is unknown until someone classifies it: the
112
+ * planning window's `v-model:answer="drafts[q.key]"` sat unnoticed in a 'none' row precisely because
113
+ * it looked enough like the lightbox pair to pass a shape test.
114
+ */
115
+ const VIEW_STATE_BINDINGS = ['v-model:open="lightboxOpen"', 'v-model:index="lightboxIndex"']
116
+
117
+ /** Every component that mounts the shell, keyed by its path relative to `app/components`. */
118
+ function findConsumers(): Map<string, string> {
119
+ const found = new Map<string, string>()
120
+ for (const entry of readdirSync(componentsDir, { recursive: true, encoding: 'utf8' })) {
121
+ const rel = entry.replace(/\\/g, '/')
122
+ if (!rel.endsWith('.vue') || rel.endsWith('panels/ResultWindowShell.vue')) continue
123
+ const source = readFileSync(`${componentsDir}/${rel}`, 'utf8')
124
+ if (source.includes('<ResultWindowShell')) found.set(rel, source)
125
+ }
126
+ return found
127
+ }
128
+
129
+ /**
130
+ * The shell's OWN opening tag. Quote-aware, so a `>` inside an attribute value can't end it early,
131
+ * and bounded, so `@close` on a nested component further down the template can't be read as the
132
+ * shell's: an unbounded scan would let a window bypass the guard by binding its own close deeper in.
133
+ */
134
+ function shellTag(source: string): string {
135
+ const start = source.indexOf('<ResultWindowShell')
136
+ if (start < 0) return ''
137
+ let quote: string | null = null
138
+ for (let i = start; i < source.length; i += 1) {
139
+ const char = source[i]!
140
+ if (quote) {
141
+ if (char === quote) quote = null
142
+ continue
143
+ }
144
+ if (char === '"' || char === "'") quote = char
145
+ else if (char === '>') return source.slice(start, i + 1)
146
+ }
147
+ return ''
148
+ }
149
+
150
+ /** What the component's `<ResultWindowShell>` binds its close to. */
151
+ function closeBinding(source: string): string | null {
152
+ return /@close="([^"]+)"/.exec(shellTag(source))?.[1] ?? null
153
+ }
154
+
155
+ /** Whether the window registers the flush hook on its `useResultView` seam. */
156
+ function registersFlushHook(source: string): boolean {
157
+ return /useResultView\([\s\S]*?onClose:/.test(source)
158
+ }
159
+
160
+ /**
161
+ * Every state binding in the source: `v-model` with or without an argument, plus the explicit
162
+ * `:model-value` + `@update:model-value` pair that a per-row control inside a `v-for` has to use
163
+ * (which is the shape `BinaryCandidatesWindow`'s store-as aliases already have).
164
+ */
165
+ function stateBindings(source: string): string[] {
166
+ const pattern = /(?:v-model(?::[\w-]+)?|:?model-value|@update:model-value)="[^"]*"/g
167
+ return [...source.matchAll(pattern)].map((match) => match[0])
168
+ }
169
+
170
+ /** A native form control, which holds typed input whether or not it carries a `v-model`. */
171
+ function hasNativeControl(source: string): boolean {
172
+ return /<(?:input|textarea|select)\b/.test(source)
173
+ }
174
+
175
+ /** What makes this window suspect for a 'none' row, or `null` when nothing does. */
176
+ function draftEvidence(source: string): string | null {
177
+ const bound = stateBindings(source).filter((binding) => !VIEW_STATE_BINDINGS.includes(binding))
178
+ if (bound.length > 0) return bound[0]!
179
+ return hasNativeControl(source) ? 'a native input/textarea/select' : null
180
+ }
181
+
182
+ describe('result-window draft handling', () => {
183
+ const consumers = findConsumers()
184
+
185
+ it('covers every shell consumer, with no stale rows', () => {
186
+ expect(consumers.size).toBeGreaterThan(10)
187
+ expect([...consumers.keys()].sort()).toEqual(Object.keys(WINDOWS).sort())
188
+ })
189
+
190
+ // Every assertion below reads its row through this, so an UNLISTED consumer fails the coverage
191
+ // test above with a message that names it, instead of crashing the rest of the file on a
192
+ // `Cannot read properties of undefined` that names nothing.
193
+ const rows = () =>
194
+ [...consumers.entries()].flatMap(([file, source]) => {
195
+ const row = WINDOWS[file]
196
+ return row ? [{ file, source, row }] : []
197
+ })
198
+
199
+ // The defect UX-79 named: a window holding a draft whose close goes straight to `ui.closeResultView`.
200
+ // Asserted POSITIVELY — the disposition's own seam has to be present AND wired to the shell's own
201
+ // close. Merely checking that the binding is not the literal `close` passes a window that renamed
202
+ // its handler and still closed straight through.
203
+ it('wires every draft-holding window to the seam its disposition names', () => {
204
+ const offenders = rows().filter(({ source, row }) => {
205
+ if (row.drafts === 'flush') {
206
+ return !registersFlushHook(source) || closeBinding(source) !== 'close'
207
+ }
208
+ if (row.drafts === 'confirm') {
209
+ return !source.includes('useUnsavedGuard(') || closeBinding(source) !== 'requestClose'
210
+ }
211
+ return closeBinding(source) !== 'close'
212
+ })
213
+ expect(offenders.map(({ file }) => file)).toEqual([])
214
+ })
215
+
216
+ // The inverse, and the one that actually rots: a window declared draft-free that grew an input.
217
+ // Without this the table degrades into a list of what someone once believed. It reports WHAT it
218
+ // found, because "this file is suspect" alone sends the reader hunting.
219
+ it('finds no unsubmitted input in a window declared draft-free', () => {
220
+ const suspects = rows()
221
+ .filter(({ row }) => row.drafts === 'none')
222
+ .map(({ file, source }) => ({ file, evidence: draftEvidence(source) }))
223
+ .filter(({ evidence }) => evidence !== null)
224
+ expect(suspects).toEqual([])
225
+ })
226
+
227
+ // A row that says 'confirm'/'flush' but names no draft is a row nobody thought about.
228
+ it('makes every non-trivial row say what the draft is', () => {
229
+ for (const [file, row] of Object.entries(WINDOWS)) {
230
+ if (row.drafts !== 'none') expect(row.why.length, file).toBeGreaterThan(20)
231
+ }
232
+ })
233
+ })
@@ -3,6 +3,7 @@ import { computed, reactive, ref, watch } from 'vue'
3
3
  import type { Block } from '~/types/domain'
4
4
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
5
5
  import SecretInput from '~/components/common/SecretInput.vue'
6
+ import { uid } from '~/utils/catalog'
6
7
 
7
8
  // Per-service (frame) SENSITIVE test credentials: a genuinely secret token a Tester needs
8
9
  // to exercise a third-party integration (e.g. a Stripe API key). Unlike the non-sensitive
@@ -25,6 +26,13 @@ const { confirmAction, toastDone } = useConfirmAction()
25
26
  const busy = ref(false)
26
27
 
27
28
  interface DraftRow {
29
+ /**
30
+ * Client-only stable row identity (UX-94, the UX-23 convention). The `v-for` MUST key on this
31
+ * rather than the array index: an index key rebinds a deleted row's inputs onto its neighbour,
32
+ * and because the value field is masked that rebind is invisible — so removing a middle row
33
+ * could save one secret's value under the next row's key.
34
+ */
35
+ uid: string
28
36
  key: string
29
37
  description: string
30
38
  value: string
@@ -34,7 +42,7 @@ const draft = reactive<{ rows: DraftRow[] }>({ rows: [] })
34
42
  const configured = computed(() => store.entriesForBlock(props.block.id))
35
43
  const available = computed(() => store.available !== false)
36
44
 
37
- const blankRow = (): DraftRow => ({ key: '', description: '', value: '' })
45
+ const blankRow = (): DraftRow => ({ uid: uid('sec'), key: '', description: '', value: '' })
38
46
 
39
47
  // Load this frame's configured refs once, then (re)hydrate the editor from them. Runs again
40
48
  // after a save/clear (the store refs change) so the just-typed secret values don't linger in
@@ -46,7 +54,7 @@ watch(
46
54
  configured,
47
55
  (entries) => {
48
56
  draft.rows = entries.length
49
- ? entries.map((e) => ({ key: e.key, description: e.description, value: '' }))
57
+ ? entries.map((e) => ({ uid: uid('sec'), key: e.key, description: e.description, value: '' }))
50
58
  : [blankRow()]
51
59
  },
52
60
  { immediate: true },
@@ -87,8 +95,10 @@ const canSave = computed(
87
95
  function addRow() {
88
96
  draft.rows.push(blankRow())
89
97
  }
90
- function removeRow(index: number) {
91
- draft.rows.splice(index, 1)
98
+ /** Remove by row identity, so it can never be read against a stale index. */
99
+ function removeRow(rowUid: string) {
100
+ const at = draft.rows.findIndex((r) => r.uid === rowUid)
101
+ if (at >= 0) draft.rows.splice(at, 1)
92
102
  }
93
103
 
94
104
  async function save() {
@@ -166,9 +176,10 @@ async function clearAll() {
166
176
  </p>
167
177
 
168
178
  <div class="space-y-3">
179
+ <!-- Keyed by the row's own `uid`, never the index — see DraftRow.uid. -->
169
180
  <div
170
181
  v-for="(row, index) in draft.rows"
171
- :key="index"
182
+ :key="row.uid"
172
183
  class="space-y-2 rounded-md border border-slate-800 p-2.5"
173
184
  :data-testid="`test-secret-row-${index}`"
174
185
  >
@@ -198,7 +209,7 @@ async function clearAll() {
198
209
  class="mt-5 shrink-0"
199
210
  :aria-label="t('inspector.testSecrets.removeRow')"
200
211
  :data-testid="`test-secret-remove-${index}`"
201
- @click="removeRow(index)"
212
+ @click="removeRow(row.uid)"
202
213
  />
203
214
  </div>
204
215
 
@@ -19,6 +19,9 @@ const { invalid, outdated, newPipelines, retired, hasIssues } = usePipelineHealt
19
19
  // Dumping `error.message` instead would put untranslated backend prose in front of every non-English
20
20
  // user — on the one screen whose whole purpose is telling them what to do next.
21
21
  const { present } = usePipelineErrorToast()
22
+ // Deleting is the one irreversible action on this screen (see the note above `reseedAll`), so it
23
+ // routes through the shared destructive confirm rather than firing on first click.
24
+ const { confirmAction, toastDone } = useConfirmAction()
22
25
 
23
26
  const open = computed({
24
27
  get: () => ui.pipelineHealthOpen,
@@ -31,15 +34,29 @@ const open = computed({
31
34
  const busy = ref<Set<string>>(new Set())
32
35
  const isBusy = (id: string) => busy.value.has(id)
33
36
  const anyBusy = computed(() => busy.value.size > 0)
37
+ /**
38
+ * The pipeline whose confirm prompt is open, tracked apart from `busy` because a confirm is not work
39
+ * in flight: the row must not spin while the human reads the prompt.
40
+ *
41
+ * It still LOCKS every other control. `useConfirm` is a singleton, so a second Delete click
42
+ * supersedes the pending request and settles it `false`: the first pipeline was then silently not
43
+ * deleted, and nothing on screen said so.
44
+ */
45
+ const confirmingId = ref<string | null>(null)
46
+ /** True while any row is mid-action OR holding an open confirm. Every control here reads this. */
47
+ const locked = computed(() => anyBusy.value || confirmingId.value !== null)
34
48
 
35
49
  /** `failTitleKey` is an i18n KEY (not resolved copy) — `present` uses it only when the failure has
36
- * no mapped conflict reason of its own. */
50
+ * no mapped conflict reason of its own. Resolves `true` only when the action actually settled, so
51
+ * a caller can withhold its success toast on a refusal. */
37
52
  async function run(id: string, action: () => Promise<unknown>, failTitleKey: string) {
38
53
  busy.value = new Set(busy.value).add(id)
39
54
  try {
40
55
  await action()
56
+ return true
41
57
  } catch (e) {
42
58
  present(e, failTitleKey)
59
+ return false
43
60
  } finally {
44
61
  const next = new Set(busy.value)
45
62
  next.delete(id)
@@ -49,18 +66,39 @@ async function run(id: string, action: () => Promise<unknown>, failTitleKey: str
49
66
 
50
67
  const reseed = (id: string) =>
51
68
  run(id, () => pipelines.reseed(id), 'pipeline.health.toast.reseedFailed')
52
- const remove = (id: string) =>
53
- run(id, () => pipelines.removePipeline(id), 'pipeline.health.toast.deleteFailed')
69
+
70
+ /**
71
+ * Confirm, then delete. Both removal buttons land here (UX-93): a reseed restores what the catalog
72
+ * says, but a delete is the one irreversible action on this screen — a built-in the catalog no
73
+ * longer defines cannot be reseeded back — and it used to fire on first click, one stray Enter away
74
+ * from destroying a workspace's pipeline. The confirm NAMES the pipeline, because the two sections
75
+ * render several rows of near-identical buttons and "which one did I just delete" is unanswerable
76
+ * afterwards.
77
+ */
78
+ async function confirmRemove(pipeline: { id: string; name: string }, failTitleKey: string) {
79
+ // The entry guard is the authoritative half of the lock the buttons show: it holds for any future
80
+ // caller, and it is what makes "one confirmed click per pipeline" true rather than aspirational.
81
+ if (locked.value) return
82
+ confirmingId.value = pipeline.id
83
+ const confirmed = await confirmAction('remove', pipeline.name).finally(() => {
84
+ confirmingId.value = null
85
+ })
86
+ if (!confirmed) return
87
+ if (await run(pipeline.id, () => pipelines.removePipeline(pipeline.id), failTitleKey))
88
+ toastDone('remove', pipeline.name)
89
+ }
90
+
91
+ const remove = (pipeline: { id: string; name: string }) =>
92
+ confirmRemove(pipeline, 'pipeline.health.toast.deleteFailed')
54
93
  // Same call as `remove`, different failure copy: the retired section says "Remove" (the pipeline is
55
94
  // gone from the catalog), so a failure toast reading "could not DELETE" would name an action the
56
95
  // user was never offered. This is only the FALLBACK title — the likely failure here is a recurring
57
96
  // schedule still pointing at the pipeline, which arrives as a 409 the presenter words itself.
58
- const removeRetired = (id: string) =>
59
- run(id, () => pipelines.removePipeline(id), 'pipeline.health.toast.removeFailed')
97
+ const removeRetired = (pipeline: { id: string; name: string }) =>
98
+ confirmRemove(pipeline, 'pipeline.health.toast.removeFailed')
60
99
 
61
- // Removals are deliberately per-row with no bulk twin, unlike the reseeds below: a reseed restores
62
- // what the catalog says, while a delete is the one irreversible action on this screen (a built-in
63
- // the catalog no longer defines cannot be reseeded back). One click per pipeline is the point.
100
+ // Removals are deliberately per-row with no bulk twin, unlike the reseeds below: one confirmed
101
+ // click per pipeline is the point.
64
102
 
65
103
  /** Reseed every reseedable pipeline (new + outdated built-ins + invalid built-ins) in one go. */
66
104
  async function reseedAll() {
@@ -117,7 +155,7 @@ const reseedableCount = computed(
117
155
  variant="subtle"
118
156
  icon="i-lucide-plus"
119
157
  :loading="isBusy(p.id)"
120
- :disabled="anyBusy"
158
+ :disabled="locked"
121
159
  @click="reseed(p.id)"
122
160
  >
123
161
  {{ t('pipeline.health.add') }}
@@ -171,7 +209,7 @@ const reseedableCount = computed(
171
209
  variant="subtle"
172
210
  icon="i-lucide-rotate-ccw"
173
211
  :loading="isBusy(h.pipeline.id)"
174
- :disabled="anyBusy"
212
+ :disabled="locked"
175
213
  @click="reseed(h.pipeline.id)"
176
214
  >
177
215
  {{ t('pipeline.health.reseed') }}
@@ -183,8 +221,8 @@ const reseedableCount = computed(
183
221
  variant="subtle"
184
222
  icon="i-lucide-trash-2"
185
223
  :loading="isBusy(h.pipeline.id)"
186
- :disabled="anyBusy"
187
- @click="remove(h.pipeline.id)"
224
+ :disabled="locked"
225
+ @click="remove(h.pipeline)"
188
226
  >
189
227
  {{ t('pipeline.health.delete') }}
190
228
  </UButton>
@@ -226,8 +264,8 @@ const reseedableCount = computed(
226
264
  variant="subtle"
227
265
  icon="i-lucide-trash-2"
228
266
  :loading="isBusy(r.pipeline.id)"
229
- :disabled="anyBusy"
230
- @click="removeRetired(r.pipeline.id)"
267
+ :disabled="locked"
268
+ @click="removeRetired(r.pipeline)"
231
269
  >
232
270
  {{ t('pipeline.health.remove') }}
233
271
  </UButton>
@@ -264,7 +302,7 @@ const reseedableCount = computed(
264
302
  variant="subtle"
265
303
  icon="i-lucide-rotate-ccw"
266
304
  :loading="isBusy(h.pipeline.id)"
267
- :disabled="anyBusy"
305
+ :disabled="locked"
268
306
  @click="reseed(h.pipeline.id)"
269
307
  >
270
308
  {{ t('pipeline.health.reseed') }}
@@ -283,6 +321,7 @@ const reseedableCount = computed(
283
321
  variant="ghost"
284
322
  icon="i-lucide-rotate-ccw"
285
323
  :loading="anyBusy"
324
+ :disabled="locked"
286
325
  @click="reseedAll"
287
326
  >
288
327
  {{ t('pipeline.health.reseedAll', { count: reseedableCount }) }}
@@ -291,7 +330,7 @@ const reseedableCount = computed(
291
330
  <UButton
292
331
  color="neutral"
293
332
  variant="ghost"
294
- :disabled="anyBusy"
333
+ :disabled="locked"
295
334
  @click="ui.closePipelineHealth()"
296
335
  >
297
336
  {{ hasIssues ? t('pipeline.health.dismiss') : t('pipeline.health.done') }}
@@ -248,9 +248,16 @@ async function submitChallenge(id: string): Promise<void> {
248
248
  const inst = instanceId.value
249
249
  if (!inst || !canResolve.value) return
250
250
  const question = challengeText.value.trim()
251
- challengeForId.value = null
252
- challengeText.value = ''
253
- await prReview.challenge(inst, id, question || undefined).catch(() => {})
251
+ // Close the box only once the turn is actually recorded (UX-83): clearing first made a failed
252
+ // dispatch cost the typed concern, and left the guard below with nothing to protect.
253
+ await prReview
254
+ .challenge(inst, id, question || undefined)
255
+ .then(() => {
256
+ challengeForId.value = null
257
+ challengeText.value = ''
258
+ })
259
+ // The store records the message; the inline error strip renders it.
260
+ .catch(() => {})
254
261
  }
255
262
  async function onDismiss(id: string): Promise<void> {
256
263
  const inst = instanceId.value
@@ -258,6 +265,18 @@ async function onDismiss(id: string): Promise<void> {
258
265
  if (challengeForId.value === id) cancelChallenge()
259
266
  await prReview.dismiss(inst, id).catch(() => {})
260
267
  }
268
+
269
+ /**
270
+ * Confirm before discarding a drafted challenge (UX-79). The concern box is open against exactly
271
+ * one finding, its text is held here until Send, and this window closes on Escape and on a backdrop
272
+ * click. Auto-sending it instead would spend a reviewer turn on the user's behalf.
273
+ */
274
+ const { requestClose } = useUnsavedGuard({
275
+ open,
276
+ close: () => close(),
277
+ saving: () => working.value,
278
+ snapshot: () => challengeText.value.trim(),
279
+ })
261
280
  </script>
262
281
 
263
282
  <template>
@@ -269,7 +288,7 @@ async function onDismiss(id: string): Promise<void> {
269
288
  :subtitle="t('prReview.subtitle')"
270
289
  width="full"
271
290
  testid="pr-review-window"
272
- @close="close"
291
+ @close="requestClose"
273
292
  >
274
293
  <template v-if="state?.prUrl" #header-extras>
275
294
  <a
@@ -128,6 +128,24 @@ const hasFindings = computed(
128
128
  () => globalFindings.value.trim() !== '' || pairs.value.some((p) => perViewNotes[p.view]?.trim()),
129
129
  )
130
130
 
131
+ /**
132
+ * Confirm before discarding the drafted findings (UX-79). Both halves count: the per-view notes are
133
+ * anchored to a specific screenshot and cannot be reconstructed from memory, and the freeform box is
134
+ * the overall verdict. They are composed into one findings string only when Request fix is pressed,
135
+ * which resolves the gate and dispatches a fixer, so a stray Escape may not send them.
136
+ *
137
+ * The snapshot is exactly what Request fix WOULD send, read off `buildFindings` rather than off the
138
+ * note map. A recapture returns a different pair set and never prunes `perViewNotes`, so a note left
139
+ * behind against a view that is gone is unsendable, and reporting it here would prompt to discard
140
+ * something the button could not have submitted anyway.
141
+ */
142
+ const { requestClose } = useUnsavedGuard({
143
+ open,
144
+ close: () => close(),
145
+ saving: () => busy.value,
146
+ snapshot: () => buildFindings().structured,
147
+ })
148
+
131
149
  /** Compose the per-view notes + freeform text into the fixer's findings (and a structured
132
150
  * mirror, so a future structured-findings contract is a one-line swap). */
133
151
  function buildFindings(): { text: string; structured: { view?: string; note: string }[] } {
@@ -210,7 +228,7 @@ async function onFilePicked(e: Event) {
210
228
  :title="headerTitle"
211
229
  :subtitle="phase ? PHASE_LABEL[phase] : t('visualConfirm.subtitle')"
212
230
  width="5xl"
213
- @close="close"
231
+ @close="requestClose"
214
232
  >
215
233
  <div class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
216
234
  <div