@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.
- package/README.md +26 -1
- 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/board/TaskDependencyEdges.vue +26 -15
- package/app/components/board/nodes/TaskCard.vue +6 -1
- package/app/components/brainstorm/BrainstormWindow.vue +19 -1
- package/app/components/common/AsyncViewError.vue +30 -0
- 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/AgentStepDetail.vue +651 -659
- package/app/components/panels/InspectorPanel.vue +6 -0
- 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/useBoardActivity.ts +62 -6
- 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/composables/useTaskExpansion.ts +10 -25
- package/app/docs/consumer-extensions.md +9 -0
- package/app/modular/result-views.ts +58 -21
- package/app/pages/index.vue +68 -64
- package/app/stores/binaryCandidates.ts +26 -3
- package/app/stores/ui/modals.ts +11 -0
- package/app/utils/asyncView.ts +24 -0
- package/app/utils/binaryCandidates.spec.ts +45 -1
- package/app/utils/binaryCandidates.ts +33 -0
- package/app/utils/blockRects.spec.ts +82 -0
- package/app/utils/blockRects.ts +61 -0
- package/app/utils/boardWakeGate.spec.ts +101 -0
- package/app/utils/boardWakeGate.ts +78 -0
- package/i18n/locales/de.json +25 -2
- package/i18n/locales/en.json +25 -2
- package/i18n/locales/es.json +25 -2
- package/i18n/locales/fr.json +25 -2
- package/i18n/locales/he.json +25 -2
- package/i18n/locales/it.json +25 -2
- package/i18n/locales/ja.json +25 -2
- package/i18n/locales/pl.json +25 -2
- package/i18n/locales/tr.json +25 -2
- package/i18n/locales/uk.json +25 -2
- 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
|
|
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
|
|
@@ -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" />
|