@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
|
@@ -22,6 +22,7 @@ import { useBoardStore } from '~/stores/board'
|
|
|
22
22
|
import { useBinaryCandidatesStore } from '~/stores/binaryCandidates'
|
|
23
23
|
import {
|
|
24
24
|
BINARY_CANDIDATE_NO_CHOICE_KEYS,
|
|
25
|
+
binaryCandidateAbsence,
|
|
25
26
|
binaryCandidateHasWarnings,
|
|
26
27
|
binaryCandidateView,
|
|
27
28
|
} from '~/utils/binaryCandidates'
|
|
@@ -45,6 +46,16 @@ const { open, blockId, instanceId, stepIndex, close } = useResultView('binary-ca
|
|
|
45
46
|
},
|
|
46
47
|
})
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Re-run the warm-up read after a failed one (the Retry beside the error state). Refuses while one
|
|
51
|
+
* is already in flight: the store sequences its attempts so a superseded settle can't write, and
|
|
52
|
+
* this is the authoritative half, so the refusal holds for any future caller.
|
|
53
|
+
*/
|
|
54
|
+
function retryLoad(): void {
|
|
55
|
+
const id = instanceId.value
|
|
56
|
+
if (id && !candidates.loading) void candidates.load(id)
|
|
57
|
+
}
|
|
58
|
+
|
|
48
59
|
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|
|
49
60
|
const headerTitle = computed(() =>
|
|
50
61
|
block.value
|
|
@@ -59,6 +70,14 @@ const step = computed(() => {
|
|
|
59
70
|
return instance.value.steps[stepIndex.value] ?? null
|
|
60
71
|
})
|
|
61
72
|
const view = computed(() => binaryCandidateView(step.value))
|
|
73
|
+
/**
|
|
74
|
+
* Which of the four no-comparison states the body renders; see `binaryCandidateAbsence`. The run id
|
|
75
|
+
* is part of the question: with no run there was no read, so neither the spinner nor "nothing to
|
|
76
|
+
* compare" would be about this window.
|
|
77
|
+
*/
|
|
78
|
+
const absence = computed(() =>
|
|
79
|
+
binaryCandidateAbsence(candidates.loading, candidates.error, instanceId.value),
|
|
80
|
+
)
|
|
62
81
|
const warnings = computed(() => (view.value ? binaryCandidateHasWarnings(view.value) : false))
|
|
63
82
|
const noChoiceKey = computed(() => {
|
|
64
83
|
const reason = view.value?.state.noChoiceReason
|
|
@@ -95,6 +114,15 @@ function toggle(id: string): void {
|
|
|
95
114
|
selected.value = [id]
|
|
96
115
|
}
|
|
97
116
|
|
|
117
|
+
/**
|
|
118
|
+
* The accessible name for a candidate's select control. Ticking one is the ONLY way to keep an
|
|
119
|
+
* asset, so this control is the gate's critical path and cannot be an unlabelled input: falls back
|
|
120
|
+
* through what the agent actually declared, ending at the id, which is always present.
|
|
121
|
+
*/
|
|
122
|
+
function candidateLabel(row: { label?: string; subject?: string; generator?: string; id: string }) {
|
|
123
|
+
return row.label ?? row.subject ?? row.generator ?? row.id
|
|
124
|
+
}
|
|
125
|
+
|
|
98
126
|
/**
|
|
99
127
|
* Whether the request would be accepted. Mirrors the backend's own refusals rather than only
|
|
100
128
|
* disabling on emptiness: keeping several candidates under one name would store one artifact and
|
|
@@ -125,8 +153,37 @@ async function onKeep() {
|
|
|
125
153
|
return alias ? { candidateId, storeAs: alias } : { candidateId }
|
|
126
154
|
})
|
|
127
155
|
const text = note.value.trim()
|
|
128
|
-
|
|
156
|
+
const kept = await candidates
|
|
157
|
+
.keep(id, { keep, ...(text ? { note: text } : {}) })
|
|
158
|
+
.then(() => true)
|
|
159
|
+
// The store records the message; the inline error strip below renders it.
|
|
160
|
+
.catch(() => false)
|
|
161
|
+
// Drop the drafts only once they are actually recorded — the window stays open as the RECORD of
|
|
162
|
+
// the decision, and leaving a submitted note in the box would make the unsaved guard below prompt
|
|
163
|
+
// over text that is already saved.
|
|
164
|
+
if (kept) {
|
|
165
|
+
note.value = ''
|
|
166
|
+
aliases.value = {}
|
|
167
|
+
}
|
|
129
168
|
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Confirm before discarding a typed rationale or a store-as alias (UX-79). The note is the only
|
|
172
|
+
* place the reasoning behind this choice is ever written down, and the aliases are what keeps two
|
|
173
|
+
* candidates from overwriting each other, so an Escape or a stray backdrop click used to cost work
|
|
174
|
+
* with no way to get it back. Nothing typed still closes straight through.
|
|
175
|
+
*/
|
|
176
|
+
const { requestClose } = useUnsavedGuard({
|
|
177
|
+
open,
|
|
178
|
+
close: () => close(),
|
|
179
|
+
saving: () => candidates.keeping,
|
|
180
|
+
snapshot: () => ({
|
|
181
|
+
note: note.value.trim(),
|
|
182
|
+
// Only the aliases of candidates still ticked count: one left behind on an unticked candidate
|
|
183
|
+
// is not going anywhere on Keep either, so prompting over it would be a false alarm.
|
|
184
|
+
aliases: selected.value.map((id) => (aliases.value[id] ?? '').trim()).filter(Boolean),
|
|
185
|
+
}),
|
|
186
|
+
})
|
|
130
187
|
</script>
|
|
131
188
|
|
|
132
189
|
<template>
|
|
@@ -138,7 +195,7 @@ async function onKeep() {
|
|
|
138
195
|
:subtitle="t('binaryCandidates.subtitle')"
|
|
139
196
|
width="5xl"
|
|
140
197
|
testid="binary-candidates-window"
|
|
141
|
-
@close="
|
|
198
|
+
@close="requestClose"
|
|
142
199
|
>
|
|
143
200
|
<div v-if="view" class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
|
144
201
|
<!-- Why there was nothing to choose between. Its own line per reason: a model that never
|
|
@@ -187,14 +244,44 @@ async function onKeep() {
|
|
|
187
244
|
v-for="row in group.rows"
|
|
188
245
|
:key="row.id"
|
|
189
246
|
class="rounded border p-2 transition"
|
|
190
|
-
:class="
|
|
247
|
+
:class="[
|
|
191
248
|
selected.includes(row.id) || row.kept
|
|
192
249
|
? 'border-sky-400/60 bg-sky-500/5'
|
|
193
|
-
: 'border-slate-700/60'
|
|
194
|
-
|
|
250
|
+
: 'border-slate-700/60',
|
|
251
|
+
view.awaiting ? 'cursor-pointer' : '',
|
|
252
|
+
]"
|
|
195
253
|
data-testid="binary-candidate-card"
|
|
196
254
|
@click="toggle(row.id)"
|
|
197
255
|
>
|
|
256
|
+
<!-- The REAL control, not the card's click handler: ticking a candidate is the only
|
|
257
|
+
way to keep an asset, so without a focusable input the gate could not be completed
|
|
258
|
+
by keyboard at all (UX-80). The whole window is ONE radio group in single-select
|
|
259
|
+
mode, because `toggle` replaces the selection across every subject rather than per
|
|
260
|
+
group.
|
|
261
|
+
|
|
262
|
+
`@click.stop` belongs on the LABEL, which is the element the card's own toggle has
|
|
263
|
+
to be shielded from. On the input alone it stopped the wrong click: activating a
|
|
264
|
+
label forwards a synthetic click to its input (which `.stop` there does not
|
|
265
|
+
prevent, only its propagation), so a click on the label TEXT bubbled to the card
|
|
266
|
+
and toggled, then the forwarded click toggled back. On a checkbox that nets to no
|
|
267
|
+
change, which means no re-render, which leaves the box ticked over a candidate
|
|
268
|
+
that is no longer selected. -->
|
|
269
|
+
<label
|
|
270
|
+
v-if="view.awaiting"
|
|
271
|
+
class="mb-1.5 flex cursor-pointer items-center gap-2 text-[11px] text-slate-400"
|
|
272
|
+
@click.stop
|
|
273
|
+
>
|
|
274
|
+
<input
|
|
275
|
+
:type="view.multiSelect ? 'checkbox' : 'radio'"
|
|
276
|
+
name="binary-candidate"
|
|
277
|
+
class="accent-sky-500"
|
|
278
|
+
:checked="selected.includes(row.id)"
|
|
279
|
+
:aria-label="candidateLabel(row)"
|
|
280
|
+
data-testid="binary-candidate-select"
|
|
281
|
+
@change="toggle(row.id)"
|
|
282
|
+
/>
|
|
283
|
+
<span class="truncate">{{ candidateLabel(row) }}</span>
|
|
284
|
+
</label>
|
|
198
285
|
<!-- Staged through the platform's OWN asset storage: we hold the bytes, so the card
|
|
199
286
|
renders them (and offers to open or save one) rather than waiting for a public
|
|
200
287
|
link the shipped storage never issues. Checked first because a candidate can
|
|
@@ -292,5 +379,55 @@ async function onKeep() {
|
|
|
292
379
|
</div>
|
|
293
380
|
</div>
|
|
294
381
|
</div>
|
|
382
|
+
|
|
383
|
+
<!-- No state on the step. The four ways that happens need different reactions, so they render
|
|
384
|
+
as four different things rather than as the blank body this window used to leave behind
|
|
385
|
+
(UX-80): there is no run to read, the read is still in flight, the read FAILED (offer a
|
|
386
|
+
Retry), or the step genuinely compared nothing. Collapsing any of the first three into the
|
|
387
|
+
last would put "nothing to compare" in front of a person whose candidates exist and were
|
|
388
|
+
simply not fetched. -->
|
|
389
|
+
<div
|
|
390
|
+
v-else-if="absence === 'no_run'"
|
|
391
|
+
class="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-5 py-10 text-center text-slate-400"
|
|
392
|
+
data-testid="binary-candidates-no-run"
|
|
393
|
+
>
|
|
394
|
+
<UIcon name="i-lucide-unlink" class="h-8 w-8 opacity-40" />
|
|
395
|
+
<p class="text-sm">{{ t('binaryCandidates.noRun.title') }}</p>
|
|
396
|
+
<p class="max-w-md text-[11px] text-slate-500">{{ t('binaryCandidates.noRun.hint') }}</p>
|
|
397
|
+
</div>
|
|
398
|
+
<div
|
|
399
|
+
v-else-if="absence === 'loading'"
|
|
400
|
+
class="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-5 py-10 text-center text-slate-400"
|
|
401
|
+
data-testid="binary-candidates-loading"
|
|
402
|
+
>
|
|
403
|
+
<UIcon name="i-lucide-loader-circle" class="h-8 w-8 animate-spin opacity-60" />
|
|
404
|
+
</div>
|
|
405
|
+
<div
|
|
406
|
+
v-else-if="absence === 'load_failed'"
|
|
407
|
+
class="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-5 py-10 text-center text-slate-400"
|
|
408
|
+
data-testid="binary-candidates-load-error"
|
|
409
|
+
>
|
|
410
|
+
<UIcon name="i-lucide-triangle-alert" class="h-8 w-8 text-amber-400/70" />
|
|
411
|
+
<p class="text-sm">{{ t('binaryCandidates.loadFailed') }}</p>
|
|
412
|
+
<p class="max-w-md break-words text-[11px] text-slate-500">{{ candidates.error }}</p>
|
|
413
|
+
<UButton
|
|
414
|
+
size="xs"
|
|
415
|
+
color="neutral"
|
|
416
|
+
variant="subtle"
|
|
417
|
+
icon="i-lucide-refresh-cw"
|
|
418
|
+
data-testid="binary-candidates-retry"
|
|
419
|
+
@click="retryLoad"
|
|
420
|
+
>
|
|
421
|
+
{{ t('common.retry') }}
|
|
422
|
+
</UButton>
|
|
423
|
+
</div>
|
|
424
|
+
<div
|
|
425
|
+
v-else
|
|
426
|
+
class="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-5 py-10 text-center text-slate-400"
|
|
427
|
+
data-testid="binary-candidates-empty"
|
|
428
|
+
>
|
|
429
|
+
<UIcon name="i-lucide-images" class="h-8 w-8 opacity-40" />
|
|
430
|
+
<p class="text-sm">{{ t('binaryCandidates.empty.title') }}</p>
|
|
431
|
+
</div>
|
|
295
432
|
</ResultWindowShell>
|
|
296
433
|
</template>
|
|
@@ -694,6 +694,12 @@ async function add() {
|
|
|
694
694
|
async function submitCreate(acknowledgeReviewDebt: boolean) {
|
|
695
695
|
const containerId = ui.addTaskContainerId
|
|
696
696
|
if (!containerId) return
|
|
697
|
+
// Entry guard (UX-78). `saving` gates the form's own Add button, but the friction dialog's
|
|
698
|
+
// "Create anyway" is a SECOND entry point into this same function, and the first await below is
|
|
699
|
+
// a network round-trip per staged attachment — long enough for a second click to file a second
|
|
700
|
+
// task and start a second pipeline run. The dialog also disables its button; this is the
|
|
701
|
+
// authoritative half, since it holds for any future caller.
|
|
702
|
+
if (saving.value) return
|
|
697
703
|
saving.value = true
|
|
698
704
|
try {
|
|
699
705
|
// Attachments are fetched BEFORE the task is written. A page that moved, a token without
|
|
@@ -825,6 +831,9 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
|
|
|
825
831
|
threshold: typeof details.threshold === 'number' ? details.threshold : null,
|
|
826
832
|
debt,
|
|
827
833
|
onConfirm: isWarn ? () => void submitCreate(true) : null,
|
|
834
|
+
// A getter, not a snapshot: the dialog reads it inside a computed so its button spins and
|
|
835
|
+
// locks for as long as the retry actually runs (UX-78).
|
|
836
|
+
pending: () => saving.value,
|
|
828
837
|
})
|
|
829
838
|
}
|
|
830
839
|
</script>
|
|
@@ -12,10 +12,22 @@ const ui = useUiStore()
|
|
|
12
12
|
|
|
13
13
|
const ctx = computed(() => ui.reviewFrictionContext)
|
|
14
14
|
|
|
15
|
+
// True while the opener's create is actually in flight. Read through the context's getter inside a
|
|
16
|
+
// computed so it tracks the opener's own `saving` ref (UX-78): the context object itself is
|
|
17
|
+
// captured once at open, so a copied boolean would never update.
|
|
18
|
+
//
|
|
19
|
+
// While it holds, EVERY way out is closed, not just the buttons. Gating the two actions and leaving
|
|
20
|
+
// Close, Escape and the backdrop live was the worse half of the asymmetry: it locked the safe exit
|
|
21
|
+
// (go review the waiting tasks) and left open the one that tears the dialog down mid-create, so the
|
|
22
|
+
// user could not tell whether a task had been filed.
|
|
23
|
+
const pending = computed(() => ctx.value?.pending?.() ?? false)
|
|
24
|
+
|
|
15
25
|
const open = computed({
|
|
16
26
|
get: () => ctx.value !== null,
|
|
17
27
|
set: (v: boolean) => {
|
|
18
|
-
|
|
28
|
+
// Refuse a USER dismissal while the create is in flight (see `pending`). The opener still closes
|
|
29
|
+
// this dialog directly through the store on success, so only the human's exits are gated.
|
|
30
|
+
if (!v && !pending.value) ui.closeReviewFriction()
|
|
19
31
|
},
|
|
20
32
|
})
|
|
21
33
|
|
|
@@ -54,12 +66,19 @@ function goReview() {
|
|
|
54
66
|
}
|
|
55
67
|
|
|
56
68
|
function createAnyway() {
|
|
69
|
+
if (pending.value) return
|
|
57
70
|
ctx.value?.onConfirm?.()
|
|
58
71
|
}
|
|
59
72
|
</script>
|
|
60
73
|
|
|
61
74
|
<template>
|
|
62
|
-
<UModal
|
|
75
|
+
<UModal
|
|
76
|
+
v-model:open="open"
|
|
77
|
+
:title="title"
|
|
78
|
+
:dismissible="!pending"
|
|
79
|
+
:close="{ disabled: pending }"
|
|
80
|
+
:ui="{ content: 'max-w-xl' }"
|
|
81
|
+
>
|
|
63
82
|
<template #body>
|
|
64
83
|
<div v-if="ctx" class="space-y-5">
|
|
65
84
|
<p class="text-sm text-slate-300">{{ body }}</p>
|
|
@@ -72,7 +91,8 @@ function createAnyway() {
|
|
|
72
91
|
<li v-for="item in ctx.debt" :key="item.blockId">
|
|
73
92
|
<button
|
|
74
93
|
type="button"
|
|
75
|
-
class="flex w-full items-center justify-between gap-3 rounded-md px-2 py-1.5 text-left text-sm hover:bg-slate-800/60"
|
|
94
|
+
class="flex w-full items-center justify-between gap-3 rounded-md px-2 py-1.5 text-left text-sm hover:bg-slate-800/60 disabled:opacity-50"
|
|
95
|
+
:disabled="pending"
|
|
76
96
|
@click="goToBlock(item.blockId)"
|
|
77
97
|
>
|
|
78
98
|
<span class="truncate text-slate-200">
|
|
@@ -91,6 +111,8 @@ function createAnyway() {
|
|
|
91
111
|
color="neutral"
|
|
92
112
|
variant="ghost"
|
|
93
113
|
size="sm"
|
|
114
|
+
:disabled="pending"
|
|
115
|
+
data-testid="review-friction-close"
|
|
94
116
|
@click="
|
|
95
117
|
() => {
|
|
96
118
|
open = false
|
|
@@ -104,11 +126,20 @@ function createAnyway() {
|
|
|
104
126
|
color="neutral"
|
|
105
127
|
variant="subtle"
|
|
106
128
|
size="sm"
|
|
129
|
+
:loading="pending"
|
|
130
|
+
:disabled="pending"
|
|
131
|
+
data-testid="review-friction-create-anyway"
|
|
107
132
|
@click="createAnyway"
|
|
108
133
|
>
|
|
109
134
|
{{ t('errors.reviewFriction.createAnyway') }}
|
|
110
135
|
</UButton>
|
|
111
|
-
<UButton
|
|
136
|
+
<UButton
|
|
137
|
+
color="primary"
|
|
138
|
+
size="sm"
|
|
139
|
+
icon="i-lucide-list-checks"
|
|
140
|
+
:disabled="pending"
|
|
141
|
+
@click="goReview"
|
|
142
|
+
>
|
|
112
143
|
{{ t('errors.reviewFriction.goReview') }}
|
|
113
144
|
</UButton>
|
|
114
145
|
</div>
|
|
@@ -156,6 +156,24 @@ async function submitReply(item: BrainstormItem) {
|
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Confirm before discarding typed replies (UX-79). Each draft answers one of the brainstorm's open
|
|
161
|
+
* questions and the redo comment steers a re-run; both are held here until their own button is
|
|
162
|
+
* pressed, on a window Escape and a backdrop click both close. Sending them on close is not the
|
|
163
|
+
* fix: a reply RESOLVES an item and the redo comment starts another agent pass.
|
|
164
|
+
*/
|
|
165
|
+
const { requestClose } = useUnsavedGuard({
|
|
166
|
+
open,
|
|
167
|
+
close: () => close(),
|
|
168
|
+
saving: () => acting.value || reworking.value,
|
|
169
|
+
snapshot: () => ({
|
|
170
|
+
replies: Object.values(drafts.value)
|
|
171
|
+
.map((text) => text.trim())
|
|
172
|
+
.filter(Boolean),
|
|
173
|
+
redo: redoComment.value.trim(),
|
|
174
|
+
}),
|
|
175
|
+
})
|
|
176
|
+
|
|
159
177
|
async function setStatus(item: BrainstormItem, itemStatus: BrainstormItemStatus) {
|
|
160
178
|
if (!session.value) return
|
|
161
179
|
try {
|
|
@@ -247,7 +265,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
247
265
|
:subtitle="block?.title"
|
|
248
266
|
variant="centered"
|
|
249
267
|
width="full"
|
|
250
|
-
@close="
|
|
268
|
+
@close="requestClose"
|
|
251
269
|
>
|
|
252
270
|
<template v-if="session" #header-extras>
|
|
253
271
|
<UBadge color="neutral" variant="subtle" size="sm">
|
|
@@ -4,10 +4,36 @@
|
|
|
4
4
|
// rendering its own modal. UModal already provides the focus trap, Escape-to-close and
|
|
5
5
|
// backdrop, so this component adds none of that — it only resolves the pending promise and,
|
|
6
6
|
// crucially, resolves `false` whenever the modal closes without an explicit choice.
|
|
7
|
+
import { computed, onUnmounted, watch } from 'vue'
|
|
8
|
+
import { sharedOverlayStack, type OverlayStackTicket } from '@modular-vue/core'
|
|
7
9
|
|
|
8
10
|
const { t } = useI18n()
|
|
9
11
|
const { open, current, accept, cancel, dismissed } = useConfirm()
|
|
10
12
|
|
|
13
|
+
// Register on the app's ONE overlay stack for as long as the dialog is up.
|
|
14
|
+
//
|
|
15
|
+
// The stack is what `useModalBehavior` (`ResultWindowShell`, the lightbox, every modular overlay)
|
|
16
|
+
// asks before it handles Escape, and this dialog is a Nuxt UI modal, so without a registration it
|
|
17
|
+
// is invisible to that question. A confirm opened FROM a result window then lost the race for its
|
|
18
|
+
// own Escape key: the shell's capture-phase listener still believed it was topmost, so it
|
|
19
|
+
// preventDefault-ed and re-entered its close request, which supersedes the confirm the user was
|
|
20
|
+
// trying to cancel. Pushing a ticket makes the shell stand down while this is on top, which is
|
|
21
|
+
// exactly the ordering the stack exists to state.
|
|
22
|
+
let ticket: OverlayStackTicket | null = null
|
|
23
|
+
function release(): void {
|
|
24
|
+
ticket?.release()
|
|
25
|
+
ticket = null
|
|
26
|
+
}
|
|
27
|
+
watch(
|
|
28
|
+
open,
|
|
29
|
+
(isOpen) => {
|
|
30
|
+
if (isOpen) ticket ??= sharedOverlayStack.push()
|
|
31
|
+
else release()
|
|
32
|
+
},
|
|
33
|
+
{ immediate: true },
|
|
34
|
+
)
|
|
35
|
+
onUnmounted(release)
|
|
36
|
+
|
|
11
37
|
const model = computed({
|
|
12
38
|
get: () => open.value,
|
|
13
39
|
set: (v: boolean) => {
|
|
@@ -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
|
|
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,
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
},
|
|
58
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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="
|
|
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
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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="
|
|
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="
|
|
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="
|
|
199
|
+
@close="requestClose"
|
|
187
200
|
>
|
|
188
201
|
<template #header-extras>
|
|
189
202
|
<UBadge
|