@cat-factory/app 0.280.1 → 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 (39) hide show
  1. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +142 -5
  2. package/app/components/board/AddTaskModal.vue +9 -0
  3. package/app/components/board/ReviewFrictionDialog.vue +35 -4
  4. package/app/components/brainstorm/BrainstormWindow.vue +19 -1
  5. package/app/components/common/ConfirmDialog.vue +26 -0
  6. package/app/components/docs/DocInterviewWindow.vue +40 -38
  7. package/app/components/followUp/FollowUpWindow.vue +32 -2
  8. package/app/components/forkDecision/ForkDecisionWindow.vue +44 -5
  9. package/app/components/gates/GateResultView.vue +14 -1
  10. package/app/components/humanTest/HumanTestWindow.vue +13 -1
  11. package/app/components/initiative/InitiativePlanDecision.vue +16 -3
  12. package/app/components/initiative/InitiativePlanReview.vue +16 -1
  13. package/app/components/initiative/InitiativePlanningWindow.vue +50 -52
  14. package/app/components/initiative/InitiativeTrackerWindow.vue +59 -1
  15. package/app/components/judge/JudgeResultView.vue +14 -1
  16. package/app/components/panels/ResultWindowDrafts.logic.spec.ts +233 -0
  17. package/app/components/panels/inspector/ServiceTestSecrets.vue +17 -6
  18. package/app/components/pipeline/PipelineHealthModal.vue +55 -16
  19. package/app/components/prReview/PrReviewWindow.vue +23 -4
  20. package/app/components/visualConfirm/VisualConfirmationWindow.vue +19 -1
  21. package/app/composables/useConfirm.spec.ts +62 -0
  22. package/app/composables/useConfirm.ts +6 -1
  23. package/app/composables/useInterviewDrafts.spec.ts +198 -0
  24. package/app/composables/useInterviewDrafts.ts +184 -0
  25. package/app/stores/binaryCandidates.ts +26 -3
  26. package/app/stores/ui/modals.ts +11 -0
  27. package/app/utils/binaryCandidates.spec.ts +45 -1
  28. package/app/utils/binaryCandidates.ts +33 -0
  29. package/i18n/locales/de.json +20 -2
  30. package/i18n/locales/en.json +20 -2
  31. package/i18n/locales/es.json +20 -2
  32. package/i18n/locales/fr.json +20 -2
  33. package/i18n/locales/he.json +20 -2
  34. package/i18n/locales/it.json +20 -2
  35. package/i18n/locales/ja.json +20 -2
  36. package/i18n/locales/pl.json +20 -2
  37. package/i18n/locales/tr.json +20 -2
  38. package/i18n/locales/uk.json +20 -2
  39. package/package.json +1 -1
@@ -89,6 +89,18 @@ const ROUND_OUTCOME_LABEL: Record<HumanTestRoundOutcome, string> = {
89
89
  const findings = ref('')
90
90
  const showFindings = ref(false)
91
91
 
92
+ /**
93
+ * Confirm before discarding typed findings (UX-79). This box is what a human tester saw go wrong —
94
+ * the one record of it anywhere — held here until Request fix is pressed, on a window Escape and a
95
+ * backdrop click both close. Sending it on close would resolve the gate and dispatch a fixer.
96
+ */
97
+ const { requestClose } = useUnsavedGuard({
98
+ open,
99
+ close: () => close(),
100
+ saving: () => busy.value,
101
+ snapshot: () => findings.value.trim(),
102
+ })
103
+
92
104
  async function confirm() {
93
105
  if (!blockId.value) return
94
106
  await humanTest.confirm(blockId.value)
@@ -142,7 +154,7 @@ const canDestroy = computed(
142
154
  :title="headerTitle"
143
155
  :subtitle="phase ? t(PHASE_LABEL[phase]) : t('humanTest.subtitle')"
144
156
  width="3xl"
145
- @close="close"
157
+ @close="requestClose"
146
158
  >
147
159
  <div class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
148
160
  <div
@@ -13,7 +13,7 @@
13
13
  // an edit typed over it would reach nothing, and the engine refuses it outright
14
14
  // (`outputIsRendered` → 422). Requesting changes is the route for a correction, which is why an
15
15
  // anchored comment is worth having: it quotes the planner's own text back to it on the re-plan.
16
- import { computed, ref, watch } from 'vue'
16
+ import { computed, onUnmounted, ref, watch } from 'vue'
17
17
  import type { RequestStepChangesInput } from '@cat-factory/contracts'
18
18
 
19
19
  const props = defineProps<{
@@ -30,8 +30,16 @@ const props = defineProps<{
30
30
  comments?: RequestStepChangesInput['comments']
31
31
  }>()
32
32
 
33
- /** A send-back succeeded: the surface drops its anchored drafts (the feedback is cleared here). */
34
- const emit = defineEmits<{ sent: [] }>()
33
+ const emit = defineEmits<{
34
+ /** A send-back succeeded: the surface drops its anchored drafts (the feedback is cleared here). */
35
+ sent: []
36
+ /**
37
+ * Whether unsent feedback is typed here right now. Reported UPWARD because the field lives two
38
+ * components below the window that owns closing, and the window is what Escape and a backdrop
39
+ * click reach (UX-79) — without this the host has no way to know a review is in progress.
40
+ */
41
+ 'update:dirty': [boolean]
42
+ }>()
35
43
 
36
44
  const execution = useExecutionStore()
37
45
  const { t } = useI18n()
@@ -39,6 +47,11 @@ const { t } = useI18n()
39
47
  const feedback = ref('')
40
48
  const submitting = ref(false)
41
49
 
50
+ watch(feedback, (value) => emit('update:dirty', value.trim().length > 0))
51
+ // The gate resolving unmounts this surface; retract the claim rather than leaving the host holding
52
+ // a dirty flag for a field that no longer exists.
53
+ onUnmounted(() => emit('update:dirty', false))
54
+
42
55
  /** Changes can only be requested with something to act on — an empty send would re-plan blind. */
43
56
  const canRequestChanges = computed(
44
57
  () => !!feedback.value.trim() || (props.comments?.length ?? 0) > 0,
@@ -25,7 +25,7 @@
25
25
  // for the outline/collapse/scroll-spy, `useProseComments` for the anchoring, `InitiativePlanDecision`
26
26
  // for the two commands, and the global `.reader-prose` sheet for the presentation — so the surfaces
27
27
  // cannot drift.
28
- import { ref, watch } from 'vue'
28
+ import { onUnmounted, ref, watch } from 'vue'
29
29
  import type { StepApproval } from '~/types/execution'
30
30
  import { useStepProse } from '~/composables/useStepProse'
31
31
  import { useProseComments } from '~/composables/useProseComments'
@@ -97,6 +97,20 @@ watch(
97
97
  },
98
98
  )
99
99
 
100
+ /**
101
+ * Whether this review holds work that is not on the server yet, relayed to the window that owns
102
+ * closing (UX-79). Three things count and all three are lost on a stray Escape: anchored comments
103
+ * already placed, a comment being typed, and the decision's overall feedback. They are reported
104
+ * rather than auto-sent, because sending them RESOLVES the gate and re-plans the initiative.
105
+ */
106
+ const emit = defineEmits<{ 'update:dirty': [boolean] }>()
107
+ const decisionDirty = ref(false)
108
+ watch(
109
+ () => decisionDirty.value || planComments.value.length > 0 || draftBody.value.trim().length > 0,
110
+ (dirty) => emit('update:dirty', dirty),
111
+ )
112
+ onUnmounted(() => emit('update:dirty', false))
113
+
100
114
  /** Whether the sidebar's run-details stack is expanded (it is, until a reviewer wants the outline). */
101
115
  const runDetailsOpen = ref(true)
102
116
 
@@ -386,6 +400,7 @@ async function copyPlan() {
386
400
  :can-execute="canExecute"
387
401
  :comments="wireComments"
388
402
  @sent="resetComments"
403
+ @update:dirty="decisionDirty = $event"
389
404
  />
390
405
  </aside>
391
406
  </div>
@@ -22,7 +22,7 @@
22
22
  // identically before and after the click, which reads as the button having done nothing. The
23
23
  // phase below folds the planning RUN's status in, so the wait is visible and a failed pass says
24
24
  // so instead of leaving the human staring at questions they already submitted.
25
- import { computed, reactive, ref, watch } from 'vue'
25
+ import { computed, ref, watch } from 'vue'
26
26
  import ClarificationItem from '~/components/common/ClarificationItem.vue'
27
27
  import InterviewGateNotice from '~/components/common/InterviewGateNotice.vue'
28
28
  import {
@@ -44,6 +44,11 @@ const { t } = useI18n()
44
44
 
45
45
  const { open, blockId, instanceId, stepIndex, close } = useResultView('initiative-planning', {
46
46
  onOpen: ({ blockId }) => void initiatives.load(blockId),
47
+ // Persist any typed-but-unsubmitted answer before the view tears down (X, backdrop, Escape), so
48
+ // closing the window never silently drops it (UX-79). A flush rather than a discard prompt because
49
+ // saving ONE answer is a plain save: it records the reply without resuming the interview, which is
50
+ // what this window's own two commands do.
51
+ onClose: () => flushOnClose(),
47
52
  })
48
53
 
49
54
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
@@ -76,18 +81,32 @@ const questions = computed(() =>
76
81
  /** Questions still needing an answer: not dismissed, and not yet answered (mirrors backend). */
77
82
  const pending = computed(() => questions.value.filter(isPendingQuestion))
78
83
 
79
- // Per-question answer drafts, seeded from the entity and refreshed as new rounds arrive
80
- // without clobbering an answer the human is mid-edit on.
81
- const drafts = reactive<Record<string, string>>({})
82
- watch(
83
- questions,
84
- (list) => {
85
- for (const q of list) {
86
- if (!(q.key in drafts)) drafts[q.key] = q.answer ?? ''
87
- }
84
+ // Per-question answer drafts, plus the two ways they leave the browser: one on blur, all of them on
85
+ // the way out. The shared seam with the doc-authoring interviewer, which holds the same kind of draft
86
+ // for the same reason; `useInterviewDrafts` records what a per-window copy kept getting wrong.
87
+ //
88
+ // `writable` is this window's own rule: a question set aside as not-relevant had its recorded answer
89
+ // CLEARED, so writing a stale local draft back would silently re-answer it and leak it into the
90
+ // converged digest.
91
+ const { drafts, addressable, unanswered, saveAnswer, flushDrafts, flushThen } = useInterviewDrafts({
92
+ blockId: () => blockId.value,
93
+ questions: () => questions.value,
94
+ pending: () => pending.value,
95
+ write: (block, questionId, answer) => initiatives.answerQuestion(block, questionId, answer),
96
+ writable: (q) => q.status !== 'dismissed',
97
+ failureTitleKeys: {
98
+ one: 'initiative.planning.saveFailed',
99
+ many: 'initiative.planning.saveFailedCount',
88
100
  },
89
- { immediate: true },
90
- )
101
+ })
102
+
103
+ /**
104
+ * A hoisted indirection for the close hook above. The seam that owns `flushDrafts` needs the
105
+ * `blockId` this very `useResultView` call produces, so the hook cannot name the const directly.
106
+ */
107
+ function flushOnClose(): void {
108
+ flushDrafts()
109
+ }
91
110
 
92
111
  /**
93
112
  * Render order (pending first — see `orderInterviewQuestions`), re-snapshotted ONLY when the
@@ -129,33 +148,6 @@ const phase = computed(() =>
129
148
  ),
130
149
  )
131
150
 
132
- /**
133
- * Questions still missing a drafted answer. Continue is only meaningful once this is empty — but a
134
- * disabled button with no stated reason is itself a "nothing happened", so the count is rendered.
135
- * A dismissed question doesn't count (it was set aside), so an all-dismissed round is trivially
136
- * answered.
137
- */
138
- const unanswered = computed(() => pending.value.filter((q) => !drafts[q.key]?.trim()).length)
139
-
140
- /**
141
- * Persist one answer if its draft differs from what's recorded. A `dismissed` question is skipped:
142
- * it was set aside (its server answer cleared), and the `flushThen` sweep on continue/proceed must
143
- * NOT write a stale local draft back to it — that would silently re-answer a not-relevant question
144
- * and leak it into the converged digest.
145
- */
146
- async function persist(q: {
147
- id?: string
148
- key: string
149
- answer?: string
150
- status?: 'open' | 'dismissed'
151
- }) {
152
- const id = q.id
153
- if (!id || !blockId.value || q.status === 'dismissed') return
154
- const next = (drafts[q.key] ?? '').trim()
155
- if (!next || next === (q.answer ?? '').trim()) return
156
- await initiatives.answerQuestion(blockId.value, id, next)
157
- }
158
-
159
151
  /** Mark a question not-relevant / reopen it. */
160
152
  async function setStatus(q: { id?: string }, status: 'open' | 'dismissed') {
161
153
  if (!q.id || !blockId.value) return
@@ -168,22 +160,17 @@ async function recommend(q: { id?: string }) {
168
160
  await initiatives.recommendAnswer(blockId.value, q.id)
169
161
  }
170
162
 
171
- /** Adopt a suggested answer into the draft, then persist it. */
172
- async function useRecommendation(q: { id?: string; key: string; recommendation?: string | null }) {
163
+ /** Adopt a suggested answer into the draft, then record it. */
164
+ function useRecommendation(q: (typeof questions.value)[number]) {
173
165
  if (!q.recommendation) return
174
166
  drafts[q.key] = q.recommendation
175
- await persist(q)
176
- }
177
-
178
- /** Flush all dirty drafts, then run a window action (continue / proceed). */
179
- async function flushThen(action: (id: string) => Promise<unknown>) {
180
- if (!blockId.value) return
181
- for (const q of questions.value) await persist(q)
182
- await action(blockId.value)
167
+ saveAnswer(q)
183
168
  }
184
169
 
185
- const onContinue = () => flushThen((id) => initiatives.continuePlanning(id))
186
- const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
170
+ const onContinue = () =>
171
+ flushThen((id) => initiatives.continuePlanning(id), 'initiative.planning.continueFailed')
172
+ const onProceed = () =>
173
+ flushThen((id) => initiatives.proceedPlanning(id), 'initiative.planning.proceedFailed')
187
174
 
188
175
  /**
189
176
  * The escape hatch for a planning run that stalled. It belongs HERE, not only in the inspector's
@@ -301,13 +288,24 @@ async function onDiscard() {
301
288
  :dismissed="q.status === 'dismissed'"
302
289
  :recommendation="q.recommendation"
303
290
  :recommending="!!q.id && initiatives.recommending.has(q.id)"
291
+ :disabled="!addressable(q)"
304
292
  :answer-placeholder="t('initiative.planning.answerPlaceholder')"
305
- @persist="persist(q)"
293
+ @persist="saveAnswer(q)"
306
294
  @dismiss="setStatus(q, 'dismissed')"
307
295
  @reopen="setStatus(q, 'open')"
308
296
  @recommend="recommend(q)"
309
297
  @use-recommendation="useRecommendation(q)"
310
298
  />
299
+ <!-- Every action here addresses a question by id, so an exchange without one has
300
+ nowhere for an answer (or a dismissal) to go. Saying so beats taking text the
301
+ flush could only drop. -->
302
+ <p
303
+ v-if="!addressable(q)"
304
+ class="mt-1 text-[11px] text-amber-300"
305
+ data-testid="initiative-planning-unanswerable"
306
+ >
307
+ {{ t('initiative.planning.unanswerable') }}
308
+ </p>
311
309
  </li>
312
310
  </ul>
313
311
  </template>
@@ -136,6 +136,20 @@ const { planApproval } = useInitiativePlanning(() => blockId.value ?? '')
136
136
  */
137
137
  const planDocument = computed(() => planReviewDocument(planApproval.value))
138
138
 
139
+ /**
140
+ * Confirm before discarding an in-progress plan review (UX-79). While a plan gate is parked this
141
+ * window hands its whole body to `InitiativePlanReview`, which holds anchored per-block comments
142
+ * and the overall feedback — the reviewer's actual work, held only in the browser until Send back
143
+ * is pressed, on a surface Escape and a backdrop click both close. The review reports its own
144
+ * dirtiness upward (it lives two components down); this is the only place that can act on it.
145
+ *
146
+ * The tracker body has two drafts of its own, and both are typed values held here until their OWN
147
+ * Save: the follow-up promotion form's item title, and the policy form's two knobs (see
148
+ * `promoteState` / `policyState` below for why each is measured against what it was SEEDED with).
149
+ * The guard itself is registered at the bottom of this block, where all three are in scope.
150
+ */
151
+ const planReviewDirty = ref(false)
152
+
139
153
  const policyRules = computed(() => initiative.value?.policy?.rules ?? [])
140
154
  function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?: number }): string {
141
155
  const axes = [
@@ -161,6 +175,22 @@ function reportError(error: unknown) {
161
175
  // Follow-up promotion: an inline per-follow-up form (phase + optional title override).
162
176
  const promotingId = ref<string | null>(null)
163
177
  const promoteForm = reactive<{ phaseId: string; title: string }>({ phaseId: '', title: '' })
178
+ /**
179
+ * What the promote form held the moment it opened.
180
+ *
181
+ * The unsaved guard below reports the form's DIVERGENCE from this rather than its contents, because
182
+ * both of this window's inline forms are seeded from what is already stored: an opened-but-untouched
183
+ * form is not unsaved work, and prompting over one would train the reader to dismiss the prompt.
184
+ */
185
+ let promoteSeed = ''
186
+ function promoteState(): string {
187
+ return JSON.stringify([promoteForm.phaseId, promoteForm.title])
188
+ }
189
+ /** The promote form's unsaved edit, or `''` when it is closed or untouched. */
190
+ function promoteDraft(): string {
191
+ if (promotingId.value === null) return ''
192
+ return promoteState() === promoteSeed ? '' : promoteState()
193
+ }
164
194
 
165
195
  function startPromote(followUp: InitiativeFollowUp) {
166
196
  const sourcePhase = (initiative.value?.items ?? []).find(
@@ -168,6 +198,7 @@ function startPromote(followUp: InitiativeFollowUp) {
168
198
  )?.phaseId
169
199
  promoteForm.phaseId = sourcePhase ?? phases.value[0]?.id ?? ''
170
200
  promoteForm.title = followUp.title
201
+ promoteSeed = promoteState()
171
202
  promotingId.value = followUp.id
172
203
  }
173
204
 
@@ -212,12 +243,23 @@ const policyForm = reactive<{ maxConcurrent: number; defaultPipelineId: string }
212
243
  maxConcurrent: 1,
213
244
  defaultPipelineId: '',
214
245
  })
246
+ /** What the policy form held when it opened; see `promoteSeed` for why the guard compares to it. */
247
+ let policySeed = ''
248
+ function policyState(): string {
249
+ return JSON.stringify([policyForm.maxConcurrent, policyForm.defaultPipelineId])
250
+ }
251
+ /** The policy form's unsaved edit, or `''` when it is closed or untouched. */
252
+ function policyDraft(): string {
253
+ if (!editingPolicy.value) return ''
254
+ return policyState() === policySeed ? '' : policyState()
255
+ }
215
256
 
216
257
  function startEditPolicy() {
217
258
  const policy = initiative.value?.policy
218
259
  if (!policy) return
219
260
  policyForm.maxConcurrent = policy.maxConcurrent
220
261
  policyForm.defaultPipelineId = policy.defaultPipelineId
262
+ policySeed = policyState()
221
263
  editingPolicy.value = true
222
264
  }
223
265
 
@@ -235,6 +277,21 @@ async function savePolicy() {
235
277
  reportError(error)
236
278
  }
237
279
  }
280
+
281
+ // Registered last on purpose: the snapshot reads the two inline forms above, and
282
+ // `useUnsavedGuard` takes its baseline synchronously, so a `ref` declared further down would still
283
+ // be in its temporal dead zone.
284
+ const { requestClose } = useUnsavedGuard({
285
+ open,
286
+ close: () => close(),
287
+ snapshot: () => ({
288
+ // Only meaningful while the review is the thing on screen; with no parked gate the body below
289
+ // is what is rendered, and its own two forms are the drafts that count.
290
+ planReview: planApproval.value && planDocument.value ? planReviewDirty.value : false,
291
+ promote: promoteDraft(),
292
+ policy: policyDraft(),
293
+ }),
294
+ })
238
295
  </script>
239
296
 
240
297
  <template>
@@ -246,7 +303,7 @@ async function savePolicy() {
246
303
  :subtitle="t('initiative.tracker.subtitle')"
247
304
  width="full"
248
305
  testid="initiative-tracker-window"
249
- @close="close"
306
+ @close="requestClose"
250
307
  >
251
308
  <template #header-extras>
252
309
  <div v-if="progress" class="flex items-center gap-2" data-testid="initiative-progress">
@@ -277,6 +334,7 @@ async function savePolicy() {
277
334
  :instance-id="planApproval.instanceId"
278
335
  :can-execute="access.canExecuteRuns.value"
279
336
  :plan-document="planDocument"
337
+ @update:dirty="planReviewDirty = $event"
280
338
  >
281
339
  <template v-if="runMeta" #run-details>
282
340
  <StepRunMeta v-bind="runMeta" />
@@ -101,6 +101,19 @@ const DISPOSITION_LABELS = computed<
101
101
 
102
102
  const feedback = ref('')
103
103
  const busy = computed(() => judgeStore.resolving)
104
+
105
+ /**
106
+ * Confirm before discarding typed guidance (UX-79). The feedback box is what a bounced producer is
107
+ * handed as its rework brief, it is held here until one of the three commands is pressed, and this
108
+ * window closes on Escape and on a backdrop click. Flushing it is not an option: every command that
109
+ * would carry it also RESOLVES the parked verdict.
110
+ */
111
+ const { requestClose } = useUnsavedGuard({
112
+ open,
113
+ close: () => close(),
114
+ saving: () => busy.value,
115
+ snapshot: () => feedback.value.trim(),
116
+ })
104
117
  const canAct = computed(() => awaiting.value && access.canExecuteRuns.value && !busy.value)
105
118
 
106
119
  async function act(choice: 'proceed' | 'bounce' | 'stop') {
@@ -120,7 +133,7 @@ async function act(choice: 'proceed' | 'bounce' | 'stop') {
120
133
  :subtitle="t('judge.subtitle')"
121
134
  :step-ref="{ instanceId, stepIndex }"
122
135
  width="3xl"
123
- @close="close"
136
+ @close="requestClose"
124
137
  >
125
138
  <template #header-extras>
126
139
  <UBadge
@@ -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
+ })