@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
|
@@ -332,9 +332,15 @@ const showOriginalDescription = ref(false)
|
|
|
332
332
|
with the panel on top; the region now paints above at `z-40`, which only swaps which side
|
|
333
333
|
loses). Sitting the panel below fixes it whichever way the stacking goes, and needs no
|
|
334
334
|
left/right arithmetic to stay correct under RTL. -->
|
|
335
|
+
<!-- `data-inspector-block` names WHICH block the panel is showing, so a caller can tell a
|
|
336
|
+
panel left open on the previous selection from one that followed a new click. Deliberately
|
|
337
|
+
NOT `data-block-id`: that attribute is the board's card selector, which the canvas drivers
|
|
338
|
+
measure geometry through (`utils/blockRects.ts`), and a panel answering to it would offer
|
|
339
|
+
the arrows a rect that is not on the canvas at all. -->
|
|
335
340
|
<div
|
|
336
341
|
v-if="block && statusMeta && typeMeta"
|
|
337
342
|
data-testid="inspector-panel"
|
|
343
|
+
:data-inspector-block="block.id"
|
|
338
344
|
class="fixed inset-x-0 bottom-0 z-20 overflow-hidden rounded-t-2xl border border-slate-700 bg-slate-900/95 shadow-2xl backdrop-blur lg:absolute lg:inset-x-auto lg:bottom-auto lg:end-4 lg:top-16 lg:w-80 lg:rounded-2xl"
|
|
339
345
|
>
|
|
340
346
|
<div class="h-1.5 w-full" :style="{ backgroundColor: statusMeta.color }" />
|
|
@@ -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
|
+
})
|
|
@@ -3,6 +3,7 @@ import { computed, reactive, ref, watch } from 'vue'
|
|
|
3
3
|
import type { Block } from '~/types/domain'
|
|
4
4
|
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
5
5
|
import SecretInput from '~/components/common/SecretInput.vue'
|
|
6
|
+
import { uid } from '~/utils/catalog'
|
|
6
7
|
|
|
7
8
|
// Per-service (frame) SENSITIVE test credentials: a genuinely secret token a Tester needs
|
|
8
9
|
// to exercise a third-party integration (e.g. a Stripe API key). Unlike the non-sensitive
|
|
@@ -25,6 +26,13 @@ const { confirmAction, toastDone } = useConfirmAction()
|
|
|
25
26
|
const busy = ref(false)
|
|
26
27
|
|
|
27
28
|
interface DraftRow {
|
|
29
|
+
/**
|
|
30
|
+
* Client-only stable row identity (UX-94, the UX-23 convention). The `v-for` MUST key on this
|
|
31
|
+
* rather than the array index: an index key rebinds a deleted row's inputs onto its neighbour,
|
|
32
|
+
* and because the value field is masked that rebind is invisible — so removing a middle row
|
|
33
|
+
* could save one secret's value under the next row's key.
|
|
34
|
+
*/
|
|
35
|
+
uid: string
|
|
28
36
|
key: string
|
|
29
37
|
description: string
|
|
30
38
|
value: string
|
|
@@ -34,7 +42,7 @@ const draft = reactive<{ rows: DraftRow[] }>({ rows: [] })
|
|
|
34
42
|
const configured = computed(() => store.entriesForBlock(props.block.id))
|
|
35
43
|
const available = computed(() => store.available !== false)
|
|
36
44
|
|
|
37
|
-
const blankRow = (): DraftRow => ({ key: '', description: '', value: '' })
|
|
45
|
+
const blankRow = (): DraftRow => ({ uid: uid('sec'), key: '', description: '', value: '' })
|
|
38
46
|
|
|
39
47
|
// Load this frame's configured refs once, then (re)hydrate the editor from them. Runs again
|
|
40
48
|
// after a save/clear (the store refs change) so the just-typed secret values don't linger in
|
|
@@ -46,7 +54,7 @@ watch(
|
|
|
46
54
|
configured,
|
|
47
55
|
(entries) => {
|
|
48
56
|
draft.rows = entries.length
|
|
49
|
-
? entries.map((e) => ({ key: e.key, description: e.description, value: '' }))
|
|
57
|
+
? entries.map((e) => ({ uid: uid('sec'), key: e.key, description: e.description, value: '' }))
|
|
50
58
|
: [blankRow()]
|
|
51
59
|
},
|
|
52
60
|
{ immediate: true },
|
|
@@ -87,8 +95,10 @@ const canSave = computed(
|
|
|
87
95
|
function addRow() {
|
|
88
96
|
draft.rows.push(blankRow())
|
|
89
97
|
}
|
|
90
|
-
|
|
91
|
-
|
|
98
|
+
/** Remove by row identity, so it can never be read against a stale index. */
|
|
99
|
+
function removeRow(rowUid: string) {
|
|
100
|
+
const at = draft.rows.findIndex((r) => r.uid === rowUid)
|
|
101
|
+
if (at >= 0) draft.rows.splice(at, 1)
|
|
92
102
|
}
|
|
93
103
|
|
|
94
104
|
async function save() {
|
|
@@ -166,9 +176,10 @@ async function clearAll() {
|
|
|
166
176
|
</p>
|
|
167
177
|
|
|
168
178
|
<div class="space-y-3">
|
|
179
|
+
<!-- Keyed by the row's own `uid`, never the index — see DraftRow.uid. -->
|
|
169
180
|
<div
|
|
170
181
|
v-for="(row, index) in draft.rows"
|
|
171
|
-
:key="
|
|
182
|
+
:key="row.uid"
|
|
172
183
|
class="space-y-2 rounded-md border border-slate-800 p-2.5"
|
|
173
184
|
:data-testid="`test-secret-row-${index}`"
|
|
174
185
|
>
|
|
@@ -198,7 +209,7 @@ async function clearAll() {
|
|
|
198
209
|
class="mt-5 shrink-0"
|
|
199
210
|
:aria-label="t('inspector.testSecrets.removeRow')"
|
|
200
211
|
:data-testid="`test-secret-remove-${index}`"
|
|
201
|
-
@click="removeRow(
|
|
212
|
+
@click="removeRow(row.uid)"
|
|
202
213
|
/>
|
|
203
214
|
</div>
|
|
204
215
|
|
|
@@ -19,6 +19,9 @@ const { invalid, outdated, newPipelines, retired, hasIssues } = usePipelineHealt
|
|
|
19
19
|
// Dumping `error.message` instead would put untranslated backend prose in front of every non-English
|
|
20
20
|
// user — on the one screen whose whole purpose is telling them what to do next.
|
|
21
21
|
const { present } = usePipelineErrorToast()
|
|
22
|
+
// Deleting is the one irreversible action on this screen (see the note above `reseedAll`), so it
|
|
23
|
+
// routes through the shared destructive confirm rather than firing on first click.
|
|
24
|
+
const { confirmAction, toastDone } = useConfirmAction()
|
|
22
25
|
|
|
23
26
|
const open = computed({
|
|
24
27
|
get: () => ui.pipelineHealthOpen,
|
|
@@ -31,15 +34,29 @@ const open = computed({
|
|
|
31
34
|
const busy = ref<Set<string>>(new Set())
|
|
32
35
|
const isBusy = (id: string) => busy.value.has(id)
|
|
33
36
|
const anyBusy = computed(() => busy.value.size > 0)
|
|
37
|
+
/**
|
|
38
|
+
* The pipeline whose confirm prompt is open, tracked apart from `busy` because a confirm is not work
|
|
39
|
+
* in flight: the row must not spin while the human reads the prompt.
|
|
40
|
+
*
|
|
41
|
+
* It still LOCKS every other control. `useConfirm` is a singleton, so a second Delete click
|
|
42
|
+
* supersedes the pending request and settles it `false`: the first pipeline was then silently not
|
|
43
|
+
* deleted, and nothing on screen said so.
|
|
44
|
+
*/
|
|
45
|
+
const confirmingId = ref<string | null>(null)
|
|
46
|
+
/** True while any row is mid-action OR holding an open confirm. Every control here reads this. */
|
|
47
|
+
const locked = computed(() => anyBusy.value || confirmingId.value !== null)
|
|
34
48
|
|
|
35
49
|
/** `failTitleKey` is an i18n KEY (not resolved copy) — `present` uses it only when the failure has
|
|
36
|
-
* no mapped conflict reason of its own.
|
|
50
|
+
* no mapped conflict reason of its own. Resolves `true` only when the action actually settled, so
|
|
51
|
+
* a caller can withhold its success toast on a refusal. */
|
|
37
52
|
async function run(id: string, action: () => Promise<unknown>, failTitleKey: string) {
|
|
38
53
|
busy.value = new Set(busy.value).add(id)
|
|
39
54
|
try {
|
|
40
55
|
await action()
|
|
56
|
+
return true
|
|
41
57
|
} catch (e) {
|
|
42
58
|
present(e, failTitleKey)
|
|
59
|
+
return false
|
|
43
60
|
} finally {
|
|
44
61
|
const next = new Set(busy.value)
|
|
45
62
|
next.delete(id)
|
|
@@ -49,18 +66,39 @@ async function run(id: string, action: () => Promise<unknown>, failTitleKey: str
|
|
|
49
66
|
|
|
50
67
|
const reseed = (id: string) =>
|
|
51
68
|
run(id, () => pipelines.reseed(id), 'pipeline.health.toast.reseedFailed')
|
|
52
|
-
|
|
53
|
-
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Confirm, then delete. Both removal buttons land here (UX-93): a reseed restores what the catalog
|
|
72
|
+
* says, but a delete is the one irreversible action on this screen — a built-in the catalog no
|
|
73
|
+
* longer defines cannot be reseeded back — and it used to fire on first click, one stray Enter away
|
|
74
|
+
* from destroying a workspace's pipeline. The confirm NAMES the pipeline, because the two sections
|
|
75
|
+
* render several rows of near-identical buttons and "which one did I just delete" is unanswerable
|
|
76
|
+
* afterwards.
|
|
77
|
+
*/
|
|
78
|
+
async function confirmRemove(pipeline: { id: string; name: string }, failTitleKey: string) {
|
|
79
|
+
// The entry guard is the authoritative half of the lock the buttons show: it holds for any future
|
|
80
|
+
// caller, and it is what makes "one confirmed click per pipeline" true rather than aspirational.
|
|
81
|
+
if (locked.value) return
|
|
82
|
+
confirmingId.value = pipeline.id
|
|
83
|
+
const confirmed = await confirmAction('remove', pipeline.name).finally(() => {
|
|
84
|
+
confirmingId.value = null
|
|
85
|
+
})
|
|
86
|
+
if (!confirmed) return
|
|
87
|
+
if (await run(pipeline.id, () => pipelines.removePipeline(pipeline.id), failTitleKey))
|
|
88
|
+
toastDone('remove', pipeline.name)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const remove = (pipeline: { id: string; name: string }) =>
|
|
92
|
+
confirmRemove(pipeline, 'pipeline.health.toast.deleteFailed')
|
|
54
93
|
// Same call as `remove`, different failure copy: the retired section says "Remove" (the pipeline is
|
|
55
94
|
// gone from the catalog), so a failure toast reading "could not DELETE" would name an action the
|
|
56
95
|
// user was never offered. This is only the FALLBACK title — the likely failure here is a recurring
|
|
57
96
|
// schedule still pointing at the pipeline, which arrives as a 409 the presenter words itself.
|
|
58
|
-
const removeRetired = (id: string) =>
|
|
59
|
-
|
|
97
|
+
const removeRetired = (pipeline: { id: string; name: string }) =>
|
|
98
|
+
confirmRemove(pipeline, 'pipeline.health.toast.removeFailed')
|
|
60
99
|
|
|
61
|
-
// Removals are deliberately per-row with no bulk twin, unlike the reseeds below:
|
|
62
|
-
//
|
|
63
|
-
// the catalog no longer defines cannot be reseeded back). One click per pipeline is the point.
|
|
100
|
+
// Removals are deliberately per-row with no bulk twin, unlike the reseeds below: one confirmed
|
|
101
|
+
// click per pipeline is the point.
|
|
64
102
|
|
|
65
103
|
/** Reseed every reseedable pipeline (new + outdated built-ins + invalid built-ins) in one go. */
|
|
66
104
|
async function reseedAll() {
|
|
@@ -117,7 +155,7 @@ const reseedableCount = computed(
|
|
|
117
155
|
variant="subtle"
|
|
118
156
|
icon="i-lucide-plus"
|
|
119
157
|
:loading="isBusy(p.id)"
|
|
120
|
-
:disabled="
|
|
158
|
+
:disabled="locked"
|
|
121
159
|
@click="reseed(p.id)"
|
|
122
160
|
>
|
|
123
161
|
{{ t('pipeline.health.add') }}
|
|
@@ -171,7 +209,7 @@ const reseedableCount = computed(
|
|
|
171
209
|
variant="subtle"
|
|
172
210
|
icon="i-lucide-rotate-ccw"
|
|
173
211
|
:loading="isBusy(h.pipeline.id)"
|
|
174
|
-
:disabled="
|
|
212
|
+
:disabled="locked"
|
|
175
213
|
@click="reseed(h.pipeline.id)"
|
|
176
214
|
>
|
|
177
215
|
{{ t('pipeline.health.reseed') }}
|
|
@@ -183,8 +221,8 @@ const reseedableCount = computed(
|
|
|
183
221
|
variant="subtle"
|
|
184
222
|
icon="i-lucide-trash-2"
|
|
185
223
|
:loading="isBusy(h.pipeline.id)"
|
|
186
|
-
:disabled="
|
|
187
|
-
@click="remove(h.pipeline
|
|
224
|
+
:disabled="locked"
|
|
225
|
+
@click="remove(h.pipeline)"
|
|
188
226
|
>
|
|
189
227
|
{{ t('pipeline.health.delete') }}
|
|
190
228
|
</UButton>
|
|
@@ -226,8 +264,8 @@ const reseedableCount = computed(
|
|
|
226
264
|
variant="subtle"
|
|
227
265
|
icon="i-lucide-trash-2"
|
|
228
266
|
:loading="isBusy(r.pipeline.id)"
|
|
229
|
-
:disabled="
|
|
230
|
-
@click="removeRetired(r.pipeline
|
|
267
|
+
:disabled="locked"
|
|
268
|
+
@click="removeRetired(r.pipeline)"
|
|
231
269
|
>
|
|
232
270
|
{{ t('pipeline.health.remove') }}
|
|
233
271
|
</UButton>
|
|
@@ -264,7 +302,7 @@ const reseedableCount = computed(
|
|
|
264
302
|
variant="subtle"
|
|
265
303
|
icon="i-lucide-rotate-ccw"
|
|
266
304
|
:loading="isBusy(h.pipeline.id)"
|
|
267
|
-
:disabled="
|
|
305
|
+
:disabled="locked"
|
|
268
306
|
@click="reseed(h.pipeline.id)"
|
|
269
307
|
>
|
|
270
308
|
{{ t('pipeline.health.reseed') }}
|
|
@@ -283,6 +321,7 @@ const reseedableCount = computed(
|
|
|
283
321
|
variant="ghost"
|
|
284
322
|
icon="i-lucide-rotate-ccw"
|
|
285
323
|
:loading="anyBusy"
|
|
324
|
+
:disabled="locked"
|
|
286
325
|
@click="reseedAll"
|
|
287
326
|
>
|
|
288
327
|
{{ t('pipeline.health.reseedAll', { count: reseedableCount }) }}
|
|
@@ -291,7 +330,7 @@ const reseedableCount = computed(
|
|
|
291
330
|
<UButton
|
|
292
331
|
color="neutral"
|
|
293
332
|
variant="ghost"
|
|
294
|
-
:disabled="
|
|
333
|
+
:disabled="locked"
|
|
295
334
|
@click="ui.closePipelineHealth()"
|
|
296
335
|
>
|
|
297
336
|
{{ hasIssues ? t('pipeline.health.dismiss') : t('pipeline.health.done') }}
|
|
@@ -248,9 +248,16 @@ async function submitChallenge(id: string): Promise<void> {
|
|
|
248
248
|
const inst = instanceId.value
|
|
249
249
|
if (!inst || !canResolve.value) return
|
|
250
250
|
const question = challengeText.value.trim()
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
await prReview
|
|
251
|
+
// Close the box only once the turn is actually recorded (UX-83): clearing first made a failed
|
|
252
|
+
// dispatch cost the typed concern, and left the guard below with nothing to protect.
|
|
253
|
+
await prReview
|
|
254
|
+
.challenge(inst, id, question || undefined)
|
|
255
|
+
.then(() => {
|
|
256
|
+
challengeForId.value = null
|
|
257
|
+
challengeText.value = ''
|
|
258
|
+
})
|
|
259
|
+
// The store records the message; the inline error strip renders it.
|
|
260
|
+
.catch(() => {})
|
|
254
261
|
}
|
|
255
262
|
async function onDismiss(id: string): Promise<void> {
|
|
256
263
|
const inst = instanceId.value
|
|
@@ -258,6 +265,18 @@ async function onDismiss(id: string): Promise<void> {
|
|
|
258
265
|
if (challengeForId.value === id) cancelChallenge()
|
|
259
266
|
await prReview.dismiss(inst, id).catch(() => {})
|
|
260
267
|
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Confirm before discarding a drafted challenge (UX-79). The concern box is open against exactly
|
|
271
|
+
* one finding, its text is held here until Send, and this window closes on Escape and on a backdrop
|
|
272
|
+
* click. Auto-sending it instead would spend a reviewer turn on the user's behalf.
|
|
273
|
+
*/
|
|
274
|
+
const { requestClose } = useUnsavedGuard({
|
|
275
|
+
open,
|
|
276
|
+
close: () => close(),
|
|
277
|
+
saving: () => working.value,
|
|
278
|
+
snapshot: () => challengeText.value.trim(),
|
|
279
|
+
})
|
|
261
280
|
</script>
|
|
262
281
|
|
|
263
282
|
<template>
|
|
@@ -269,7 +288,7 @@ async function onDismiss(id: string): Promise<void> {
|
|
|
269
288
|
:subtitle="t('prReview.subtitle')"
|
|
270
289
|
width="full"
|
|
271
290
|
testid="pr-review-window"
|
|
272
|
-
@close="
|
|
291
|
+
@close="requestClose"
|
|
273
292
|
>
|
|
274
293
|
<template v-if="state?.prUrl" #header-extras>
|
|
275
294
|
<a
|
|
@@ -128,6 +128,24 @@ const hasFindings = computed(
|
|
|
128
128
|
() => globalFindings.value.trim() !== '' || pairs.value.some((p) => perViewNotes[p.view]?.trim()),
|
|
129
129
|
)
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Confirm before discarding the drafted findings (UX-79). Both halves count: the per-view notes are
|
|
133
|
+
* anchored to a specific screenshot and cannot be reconstructed from memory, and the freeform box is
|
|
134
|
+
* the overall verdict. They are composed into one findings string only when Request fix is pressed,
|
|
135
|
+
* which resolves the gate and dispatches a fixer, so a stray Escape may not send them.
|
|
136
|
+
*
|
|
137
|
+
* The snapshot is exactly what Request fix WOULD send, read off `buildFindings` rather than off the
|
|
138
|
+
* note map. A recapture returns a different pair set and never prunes `perViewNotes`, so a note left
|
|
139
|
+
* behind against a view that is gone is unsendable, and reporting it here would prompt to discard
|
|
140
|
+
* something the button could not have submitted anyway.
|
|
141
|
+
*/
|
|
142
|
+
const { requestClose } = useUnsavedGuard({
|
|
143
|
+
open,
|
|
144
|
+
close: () => close(),
|
|
145
|
+
saving: () => busy.value,
|
|
146
|
+
snapshot: () => buildFindings().structured,
|
|
147
|
+
})
|
|
148
|
+
|
|
131
149
|
/** Compose the per-view notes + freeform text into the fixer's findings (and a structured
|
|
132
150
|
* mirror, so a future structured-findings contract is a one-line swap). */
|
|
133
151
|
function buildFindings(): { text: string; structured: { view?: string; note: string }[] } {
|
|
@@ -210,7 +228,7 @@ async function onFilePicked(e: Event) {
|
|
|
210
228
|
:title="headerTitle"
|
|
211
229
|
:subtitle="phase ? PHASE_LABEL[phase] : t('visualConfirm.subtitle')"
|
|
212
230
|
width="5xl"
|
|
213
|
-
@close="
|
|
231
|
+
@close="requestClose"
|
|
214
232
|
>
|
|
215
233
|
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
|
|
216
234
|
<div
|