@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
@@ -16,7 +16,7 @@
16
16
  // identically before and after the click, which reads as the button having done nothing. The
17
17
  // phase below folds the document RUN's status in, so the wait is visible and a failed pass says
18
18
  // so instead of leaving the human staring at questions they already submitted.
19
- import { computed, reactive, watch } from 'vue'
19
+ import { computed } from 'vue'
20
20
  import InterviewGateNotice from '~/components/common/InterviewGateNotice.vue'
21
21
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
22
22
  import {
@@ -33,6 +33,11 @@ const access = useWorkspaceAccess()
33
33
 
34
34
  const { open, blockId, close } = useResultView('doc-interview', {
35
35
  onOpen: ({ blockId }) => void docInterview.load(blockId),
36
+ // Persist any typed-but-unsubmitted answer before the view tears down (X, backdrop, Escape), so
37
+ // closing the window never silently drops it (UX-79). The flush seam is right here rather than a
38
+ // discard prompt because saving ONE answer is a plain save: it records the reply without
39
+ // resolving the interview, which is what the window's own two commands do.
40
+ onClose: () => flushOnClose(),
36
41
  })
37
42
 
38
43
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
@@ -45,18 +50,25 @@ const questions = computed(() =>
45
50
  )
46
51
  const pending = computed(() => questions.value.filter((q) => !(q.answer ?? '').trim()))
47
52
 
48
- // Per-question answer drafts, seeded from the entity and refreshed as new rounds arrive without
49
- // clobbering an answer the human is mid-edit on.
50
- const drafts = reactive<Record<string, string>>({})
51
- watch(
52
- questions,
53
- (list) => {
54
- for (const q of list) {
55
- if (!(q.key in drafts)) drafts[q.key] = q.answer ?? ''
56
- }
57
- },
58
- { immediate: true },
59
- )
53
+ // Per-question answer drafts, plus the two ways they leave the browser: one on blur, all of them on
54
+ // the way out. The shared seam with the initiative planner's interviewer, which holds the same kind
55
+ // of draft for the same reason; `useInterviewDrafts` records what a per-window copy kept getting
56
+ // wrong, including the flush that used to abandon every answer after the first failed write.
57
+ const { drafts, addressable, unanswered, saveAnswer, flushDrafts, flushThen } = useInterviewDrafts({
58
+ blockId: () => blockId.value,
59
+ questions: () => questions.value,
60
+ pending: () => pending.value,
61
+ write: (block, questionId, answer) => docInterview.answerQuestion(block, questionId, answer),
62
+ failureTitleKeys: { one: 'docInterview.saveFailed', many: 'docInterview.saveFailedCount' },
63
+ })
64
+
65
+ /**
66
+ * A hoisted indirection for the close hook above. The seam that owns `flushDrafts` needs the
67
+ * `blockId` this very `useResultView` call produces, so the hook cannot name the const directly.
68
+ */
69
+ function flushOnClose(): void {
70
+ flushDrafts()
71
+ }
60
72
 
61
73
  const resuming = computed(() => docInterview.resuming)
62
74
 
@@ -78,12 +90,6 @@ const phase = computed(() =>
78
90
  /** The interview converged: the synthesized authoring brief is what the window shows. */
79
91
  const converged = computed(() => phase.value === 'converged')
80
92
 
81
- /**
82
- * Questions still missing a drafted answer. Submit is only meaningful once this is empty — but a
83
- * disabled button with no stated reason is itself a "nothing happened", so the count is rendered.
84
- */
85
- const unanswered = computed(() => pending.value.filter((q) => !drafts[q.key]?.trim()).length)
86
-
87
93
  /** Why Submit is unavailable, or undefined when it is. RBAC first: it outranks a draft gap. */
88
94
  const continueBlockedReason = computed(() => {
89
95
  if (!access.canExecuteRuns.value) return t('access.noRunExecute')
@@ -91,24 +97,10 @@ const continueBlockedReason = computed(() => {
91
97
  return undefined
92
98
  })
93
99
 
94
- /** Persist one answer if its draft differs from what's recorded. */
95
- async function persist(q: { id?: string; key: string; answer?: string }) {
96
- const id = q.id
97
- if (!id || !blockId.value) return
98
- const next = (drafts[q.key] ?? '').trim()
99
- if (!next || next === (q.answer ?? '').trim()) return
100
- await docInterview.answerQuestion(blockId.value, id, next)
101
- }
102
-
103
- /** Flush all dirty drafts, then run a window action (continue / proceed). */
104
- async function flushThen(action: (id: string) => Promise<unknown>) {
105
- if (!blockId.value) return
106
- for (const q of questions.value) await persist(q)
107
- await action(blockId.value)
108
- }
109
-
110
- const onContinue = () => flushThen((id) => docInterview.continueInterview(id))
111
- const onProceed = () => flushThen((id) => docInterview.proceedInterview(id))
100
+ const onContinue = () =>
101
+ flushThen((id) => docInterview.continueInterview(id), 'docInterview.continueFailed')
102
+ const onProceed = () =>
103
+ flushThen((id) => docInterview.proceedInterview(id), 'docInterview.proceedFailed')
112
104
  </script>
113
105
 
114
106
  <template>
@@ -210,11 +202,21 @@ const onProceed = () => flushThen((id) => docInterview.proceedInterview(id))
210
202
  v-model="drafts[q.key]"
211
203
  :rows="2"
212
204
  autoresize
205
+ :disabled="!addressable(q)"
213
206
  :placeholder="t('docInterview.answerPlaceholder')"
214
207
  class="w-full"
215
208
  data-testid="doc-interview-answer"
216
- @blur="persist(q)"
209
+ @blur="saveAnswer(q)"
217
210
  />
211
+ <!-- The answer write addresses a question by id, so an exchange without one has nowhere
212
+ for an answer to go. Saying so beats taking text the flush could only drop. -->
213
+ <p
214
+ v-if="!addressable(q)"
215
+ class="mt-1 text-[11px] text-amber-300"
216
+ data-testid="doc-interview-unanswerable"
217
+ >
218
+ {{ t('docInterview.unanswerable') }}
219
+ </p>
218
220
  </li>
219
221
  </ul>
220
222
  </template>
@@ -59,13 +59,43 @@ async function onQueue(item: FollowUpItem) {
59
59
  async function onAnswer(item: FollowUpItem) {
60
60
  const id = execId()
61
61
  const answer = (drafts[item.id] ?? '').trim()
62
- if (id && answer) await followUps.answerItem(id, item.id, answer).catch(() => {})
62
+ if (!id || !answer) return
63
+ // Clear the draft only once the answer is actually recorded: clearing first would make a failed
64
+ // send cost the typed answer, and would also leave the unsaved guard below with nothing to protect.
65
+ await followUps
66
+ .answerItem(id, item.id, answer)
67
+ .then(() => {
68
+ delete drafts[item.id]
69
+ })
70
+ // The store records the message; the inline error strip renders it.
71
+ .catch(() => {})
63
72
  }
64
73
  async function onDismiss(item: FollowUpItem) {
65
74
  const id = execId()
66
75
  if (id) await followUps.dismissItem(id, item.id).catch(() => {})
67
76
  }
68
77
 
78
+ /**
79
+ * Confirm before discarding typed answers (UX-79). Each draft answers a question the Coder is
80
+ * blocked on, is held only in this component until "Answer & send" is pressed, and the window is
81
+ * dismissible by Escape and by a backdrop click. Auto-sending them on close is deliberately NOT
82
+ * the fix: sending an answer DECIDES the item and re-arms the run, which is not something a stray
83
+ * Escape may do on the user's behalf.
84
+ */
85
+ const { requestClose } = useUnsavedGuard({
86
+ open,
87
+ close: () => close(),
88
+ // Any item mid-action is about to rewrite the list; don't interrupt it with a prompt.
89
+ saving: () => followUps.acting.size > 0,
90
+ // Only drafts against items still awaiting a decision count: one left over from an item that has
91
+ // since been filed or dismissed elsewhere can no longer be sent anywhere.
92
+ snapshot: () =>
93
+ items.value
94
+ .filter((item) => item.status === 'pending')
95
+ .map((item) => (drafts[item.id] ?? '').trim())
96
+ .filter(Boolean),
97
+ })
98
+
69
99
  // Exhaustive map of the item status enum → label key (literal keys keep the typed-key
70
100
  // drift guard live, vs a runtime-built `followUp.status.${status}`).
71
101
  const STATUS_LABEL_KEYS: Record<FollowUpItem['status'], string> = {
@@ -96,7 +126,7 @@ const STATUS_META: Record<
96
126
  :title="headerTitle"
97
127
  :subtitle="t('followUp.subtitle')"
98
128
  width="3xl"
99
- @close="close"
129
+ @close="requestClose"
100
130
  >
101
131
  <template #header-extras>
102
132
  <UBadge :color="pendingCount > 0 ? 'warning' : 'success'" variant="subtle" size="sm">
@@ -96,16 +96,55 @@ async function onChoose() {
96
96
  selected.value === 'custom'
97
97
  ? { custom: customText.value.trim(), note: noteText }
98
98
  : { forkId: selected.value!, note: noteText }
99
- await forkDecision.choose(id, choice).catch(() => {})
99
+ const chosen = await forkDecision
100
+ .choose(id, choice)
101
+ .then(() => true)
102
+ // The store records the message; the inline error strip above renders it.
103
+ .catch(() => false)
104
+ // Drop the drafts once the decision is committed. The window stays open as the RECORD of what was
105
+ // chosen, so leaving the approach and the steering note in their boxes would have the unsaved
106
+ // guard below prompt to discard work that was submitted seconds ago.
107
+ if (chosen) {
108
+ customText.value = ''
109
+ note.value = ''
110
+ }
100
111
  }
101
112
 
102
113
  async function onSend() {
103
114
  const id = instanceId.value
104
115
  const text = chatInput.value.trim()
105
116
  if (!id || !text || !canChat.value) return
106
- chatInput.value = ''
107
- await forkDecision.chat(id, text).catch(() => {})
117
+ // Clear the box only once the turn is actually recorded: clearing first made a failed send cost
118
+ // the typed question, with nothing on screen saying the send had failed.
119
+ await forkDecision
120
+ .chat(id, text)
121
+ .then(() => {
122
+ chatInput.value = ''
123
+ })
124
+ // The store records the message; the inline error strip above renders it.
125
+ .catch(() => {})
108
126
  }
127
+
128
+ /**
129
+ * Confirm before discarding typed input (UX-79). A custom approach, a steering note and an
130
+ * unsent chat question are all things the human WROTE, none of them are persisted anywhere until
131
+ * the matching button is pressed, and this window is dismissible by Escape and by a backdrop
132
+ * click. Flushing them instead is not an option: sending a chat turn spends the run's bounded
133
+ * human-turn budget and choosing a fork commits the whole decision, so an accidental dismissal
134
+ * must never do either on the user's behalf. A window with nothing typed closes as before.
135
+ */
136
+ const { requestClose } = useUnsavedGuard({
137
+ open,
138
+ close: () => close(),
139
+ saving: () => forkDecision.choosing,
140
+ snapshot: () => ({
141
+ // Only counts while the custom path is actually selected — text left in the box under a
142
+ // proposed fork is not part of the decision being made and would prompt for nothing.
143
+ custom: selected.value === 'custom' ? customText.value.trim() : '',
144
+ note: note.value.trim(),
145
+ chat: chatInput.value.trim(),
146
+ }),
147
+ })
109
148
  </script>
110
149
 
111
150
  <template>
@@ -117,7 +156,7 @@ async function onSend() {
117
156
  :subtitle="t('forkDecision.subtitle')"
118
157
  width="3xl"
119
158
  testid="fork-decision-window"
120
- @close="close"
159
+ @close="requestClose"
121
160
  >
122
161
  <div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
123
162
  <!-- Proposing: the read-only proposer is still working. -->
@@ -352,7 +391,7 @@ async function onSend() {
352
391
  v-if="interactive"
353
392
  class="flex items-center justify-end gap-2 border-t border-slate-800 px-5 py-3"
354
393
  >
355
- <UButton color="neutral" variant="ghost" size="sm" @click="close">
394
+ <UButton color="neutral" variant="ghost" size="sm" @click="requestClose">
356
395
  {{ t('common.cancel') }}
357
396
  </UButton>
358
397
  <UButton
@@ -75,6 +75,19 @@ async function submitFix() {
75
75
  fixInstructions.value = ''
76
76
  }
77
77
 
78
+ /**
79
+ * Confirm before discarding drafted fix instructions (UX-79). The box is the only thing that tells
80
+ * the fixer WHAT to change, it is held here until Request fix is pressed, and this window closes on
81
+ * Escape and on a backdrop click. Sending it on close is not the fix: it resolves the human-review
82
+ * gate and dispatches an agent.
83
+ */
84
+ const { requestClose } = useUnsavedGuard({
85
+ open,
86
+ close: () => close(),
87
+ saving: () => fixBusy.value,
88
+ snapshot: () => fixInstructions.value.trim(),
89
+ })
90
+
78
91
  // The displayed "required approvals" is derived from the cached branch-protection count via
79
92
  // the gate's effective floor (`max(1, …)`, see review.logic.ts) rather than persisted twice.
80
93
  const requiredApprovals = computed(() => Math.max(1, gate.value?.requiredApprovingReviewCount ?? 1))
@@ -183,7 +196,7 @@ const conflictVerdict = computed(() => {
183
196
  :subtitle="subtitle"
184
197
  :step-ref="{ instanceId, stepIndex }"
185
198
  width="3xl"
186
- @close="close"
199
+ @close="requestClose"
187
200
  >
188
201
  <template #header-extras>
189
202
  <UBadge
@@ -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" />