@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.
- package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +142 -5
- package/app/components/board/AddTaskModal.vue +9 -0
- package/app/components/board/ReviewFrictionDialog.vue +35 -4
- package/app/components/brainstorm/BrainstormWindow.vue +19 -1
- package/app/components/common/ConfirmDialog.vue +26 -0
- package/app/components/docs/DocInterviewWindow.vue +40 -38
- package/app/components/followUp/FollowUpWindow.vue +32 -2
- package/app/components/forkDecision/ForkDecisionWindow.vue +44 -5
- package/app/components/gates/GateResultView.vue +14 -1
- package/app/components/humanTest/HumanTestWindow.vue +13 -1
- package/app/components/initiative/InitiativePlanDecision.vue +16 -3
- package/app/components/initiative/InitiativePlanReview.vue +16 -1
- package/app/components/initiative/InitiativePlanningWindow.vue +50 -52
- package/app/components/initiative/InitiativeTrackerWindow.vue +59 -1
- package/app/components/judge/JudgeResultView.vue +14 -1
- package/app/components/panels/ResultWindowDrafts.logic.spec.ts +233 -0
- package/app/components/panels/inspector/ServiceTestSecrets.vue +17 -6
- package/app/components/pipeline/PipelineHealthModal.vue +55 -16
- package/app/components/prReview/PrReviewWindow.vue +23 -4
- package/app/components/visualConfirm/VisualConfirmationWindow.vue +19 -1
- package/app/composables/useConfirm.spec.ts +62 -0
- package/app/composables/useConfirm.ts +6 -1
- package/app/composables/useInterviewDrafts.spec.ts +198 -0
- package/app/composables/useInterviewDrafts.ts +184 -0
- package/app/stores/binaryCandidates.ts +26 -3
- package/app/stores/ui/modals.ts +11 -0
- package/app/utils/binaryCandidates.spec.ts +45 -1
- package/app/utils/binaryCandidates.ts +33 -0
- package/i18n/locales/de.json +20 -2
- package/i18n/locales/en.json +20 -2
- package/i18n/locales/es.json +20 -2
- package/i18n/locales/fr.json +20 -2
- package/i18n/locales/he.json +20 -2
- package/i18n/locales/it.json +20 -2
- package/i18n/locales/ja.json +20 -2
- package/i18n/locales/pl.json +20 -2
- package/i18n/locales/tr.json +20 -2
- package/i18n/locales/uk.json +20 -2
- 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="
|
|
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
|
-
|
|
34
|
-
|
|
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,
|
|
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,
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
172
|
-
|
|
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
|
-
|
|
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 = () =>
|
|
186
|
-
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
+
})
|