@cat-factory/app 0.40.0 → 0.42.0
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/layout/NotificationsInbox.vue +19 -0
- package/app/components/panels/StepResultViewHost.vue +3 -0
- package/app/components/settings/WorkspaceSettingsPanel.vue +25 -0
- package/app/components/slack/SlackPanel.vue +2 -0
- package/app/components/visualConfirm/VisualConfirmationWindow.vue +357 -0
- package/app/composables/api/visualConfirm.ts +60 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/usePipelineErrorToast.spec.ts +100 -0
- package/app/composables/usePipelineErrorToast.ts +51 -26
- package/app/composables/usePipelineHealth.spec.ts +4 -2
- package/app/stores/agentRuns.ts +1 -1
- package/app/stores/execution.ts +3 -3
- package/app/stores/visualConfirm.ts +92 -0
- package/app/stores/workspaceSettings.ts +1 -0
- package/app/types/execution.ts +3 -0
- package/app/utils/catalog.spec.ts +3 -1
- package/app/utils/catalog.ts +25 -2
- package/app/utils/pipelineRender.ts +1 -1
- package/i18n/i18n.config.ts +29 -0
- package/i18n/locales/en.json +35 -0
- package/nuxt.config.ts +23 -1
- package/package.json +4 -2
|
@@ -36,6 +36,9 @@ const META: Record<Notification['type'], { icon: string; color: Accent; action:
|
|
|
36
36
|
// Clicking the title opens the human-testing window for the task (see `reveal`); "act" just
|
|
37
37
|
// marks it read (the gate is resolved in that window — confirm / request a fix — not here).
|
|
38
38
|
human_test_ready: { icon: 'i-lucide-user-check', color: 'primary', action: 'Mark read' },
|
|
39
|
+
// Clicking the title opens the visual-confirmation window for the task (see `reveal`); "act"
|
|
40
|
+
// just marks it read (the gate is resolved in that window — approve / request a fix — not here).
|
|
41
|
+
visual_confirmation_ready: { icon: 'i-lucide-camera', color: 'primary', action: 'Mark read' },
|
|
39
42
|
// Clicking the title opens the task's gate window (where the human can request a freeform
|
|
40
43
|
// fix); "act" just marks it read (approval happens on GitHub, not here).
|
|
41
44
|
human_review: { icon: 'i-lucide-users', color: 'primary', action: 'Mark read' },
|
|
@@ -87,6 +90,7 @@ function reveal(n: Notification) {
|
|
|
87
90
|
else if (n.type === 'clarity_review') ui.openClarityReview(n.blockId)
|
|
88
91
|
else if (n.type === 'decision_required') revealDecision(n)
|
|
89
92
|
else if (n.type === 'human_test_ready') revealHumanTest(n)
|
|
93
|
+
else if (n.type === 'visual_confirmation_ready') revealVisualConfirm(n)
|
|
90
94
|
else if (n.type === 'human_review') revealHumanReview(n)
|
|
91
95
|
else if (n.type === 'followup_pending') revealFollowUps(n)
|
|
92
96
|
else ui.select(n.blockId)
|
|
@@ -128,6 +132,21 @@ function revealHumanTest(n: Notification) {
|
|
|
128
132
|
else if (n.blockId) ui.select(n.blockId)
|
|
129
133
|
}
|
|
130
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Open the visual-confirmation window for a parked `visual-confirmation` gate: find the run's
|
|
137
|
+
* parked step and open it through the universal step dispatch (its archetype declares the
|
|
138
|
+
* `visual-confirm` result view). Falls back to focusing the block.
|
|
139
|
+
*/
|
|
140
|
+
function revealVisualConfirm(n: Notification) {
|
|
141
|
+
const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
|
|
142
|
+
const idx =
|
|
143
|
+
instance?.steps.findIndex(
|
|
144
|
+
(s) => s.agentKind === 'visual-confirmation' && s.state === 'waiting_decision',
|
|
145
|
+
) ?? -1
|
|
146
|
+
if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
|
|
147
|
+
else if (n.blockId) ui.select(n.blockId)
|
|
148
|
+
}
|
|
149
|
+
|
|
131
150
|
/**
|
|
132
151
|
* Open the decision surface for a parked iteration-cap run: find the run's step that is
|
|
133
152
|
* waiting on a human and open it through the universal step dispatch — which routes a
|
|
@@ -16,6 +16,7 @@ import ClarityReviewWindow from '~/components/clarity/ClarityReviewWindow.vue'
|
|
|
16
16
|
import BrainstormWindow from '~/components/brainstorm/BrainstormWindow.vue'
|
|
17
17
|
import TestReportWindow from '~/components/testing/TestReportWindow.vue'
|
|
18
18
|
import HumanTestWindow from '~/components/humanTest/HumanTestWindow.vue'
|
|
19
|
+
import VisualConfirmationWindow from '~/components/visualConfirm/VisualConfirmationWindow.vue'
|
|
19
20
|
import GateResultView from '~/components/gates/GateResultView.vue'
|
|
20
21
|
import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindow.vue'
|
|
21
22
|
import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
|
|
@@ -32,6 +33,8 @@ const STEP_RESULT_VIEWS: Record<string, Component> = {
|
|
|
32
33
|
tester: TestReportWindow,
|
|
33
34
|
// The human-testing gate: env URL + confirm / request-fix / pull-main / recreate / destroy.
|
|
34
35
|
'human-test': HumanTestWindow,
|
|
36
|
+
// The visual-confirmation gate: actual-vs-reference screenshot gallery + approve / request-fix.
|
|
37
|
+
'visual-confirm': VisualConfirmationWindow,
|
|
35
38
|
// Shared by both polling gates (`ci` + `conflicts`); the window branches on agentKind.
|
|
36
39
|
gate: GateResultView,
|
|
37
40
|
// Opened for any step that ran the consensus mechanism (routed in `ui.dispatchStepView`).
|
|
@@ -68,6 +68,7 @@ const draft = reactive({
|
|
|
68
68
|
taskLimitShared: 5 as number,
|
|
69
69
|
perType: {} as Record<CreateTaskType, number>,
|
|
70
70
|
storeAgentContext: true,
|
|
71
|
+
artifactRetentionDays: 14,
|
|
71
72
|
kaizenEnabled: true,
|
|
72
73
|
// Budget: empty string ⇒ "use the built-in default" (null on the wire).
|
|
73
74
|
spendCurrency: '',
|
|
@@ -82,6 +83,7 @@ function hydrate() {
|
|
|
82
83
|
const pt = s.taskLimitPerType ?? {}
|
|
83
84
|
for (const t of TASK_TYPES) draft.perType[t] = pt[t] ?? 3
|
|
84
85
|
draft.storeAgentContext = s.storeAgentContext
|
|
86
|
+
draft.artifactRetentionDays = s.artifactRetentionDays
|
|
85
87
|
draft.kaizenEnabled = s.kaizenEnabled
|
|
86
88
|
draft.spendCurrency = s.spendCurrency ?? ''
|
|
87
89
|
draft.spendMonthlyLimit = s.spendMonthlyLimit == null ? '' : String(s.spendMonthlyLimit)
|
|
@@ -111,6 +113,7 @@ async function save() {
|
|
|
111
113
|
)
|
|
112
114
|
: null,
|
|
113
115
|
storeAgentContext: draft.storeAgentContext,
|
|
116
|
+
artifactRetentionDays: draft.artifactRetentionDays,
|
|
114
117
|
kaizenEnabled: draft.kaizenEnabled,
|
|
115
118
|
})
|
|
116
119
|
toast.add({ title: 'Settings saved', icon: 'i-lucide-check', color: 'success' })
|
|
@@ -242,6 +245,28 @@ async function saveBudget() {
|
|
|
242
245
|
</label>
|
|
243
246
|
</section>
|
|
244
247
|
|
|
248
|
+
<!-- Visual-confirmation artifact retention -->
|
|
249
|
+
<section class="space-y-2">
|
|
250
|
+
<h3 class="text-sm font-semibold text-slate-200">Screenshot retention</h3>
|
|
251
|
+
<p class="text-[11px] text-slate-400">
|
|
252
|
+
How long to keep the UI tester’s captured screenshots and the reference design
|
|
253
|
+
images they’re reviewed against (the visual-confirmation gate). A daily cleanup job
|
|
254
|
+
deletes both the image bytes and their metadata once they age past this window.
|
|
255
|
+
</p>
|
|
256
|
+
<label class="block w-48">
|
|
257
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
258
|
+
Retention (days)
|
|
259
|
+
</span>
|
|
260
|
+
<UInput
|
|
261
|
+
v-model.number="draft.artifactRetentionDays"
|
|
262
|
+
type="number"
|
|
263
|
+
:min="1"
|
|
264
|
+
:max="3650"
|
|
265
|
+
size="sm"
|
|
266
|
+
/>
|
|
267
|
+
</label>
|
|
268
|
+
</section>
|
|
269
|
+
|
|
245
270
|
<!-- Kaizen agent -->
|
|
246
271
|
<section class="space-y-2">
|
|
247
272
|
<h3 class="text-sm font-semibold text-slate-200">Kaizen agent</h3>
|
|
@@ -28,6 +28,7 @@ const ROUTABLE: { type: NotificationType; label: string }[] = [
|
|
|
28
28
|
{ type: 'clarity_review', label: 'Clarity review' },
|
|
29
29
|
{ type: 'release_regression', label: 'Release regression' },
|
|
30
30
|
{ type: 'human_test_ready', label: 'Ready for human testing' },
|
|
31
|
+
{ type: 'visual_confirmation_ready', label: 'Ready for visual confirmation' },
|
|
31
32
|
]
|
|
32
33
|
|
|
33
34
|
/** Notification-role options for a mapped member (drives who gets @-mentioned). */
|
|
@@ -45,6 +46,7 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
|
|
|
45
46
|
// In-app only (not in ROUTABLE), but the map is exhaustive over the type.
|
|
46
47
|
decision_required: { enabled: false, channel: '' },
|
|
47
48
|
human_test_ready: { enabled: false, channel: '' },
|
|
49
|
+
visual_confirmation_ready: { enabled: false, channel: '' },
|
|
48
50
|
human_review: { enabled: false, channel: '' },
|
|
49
51
|
followup_pending: { enabled: false, channel: '' },
|
|
50
52
|
})
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Visual-confirmation gate window — the dedicated surface for a `visual-confirmation` step
|
|
3
|
+
// (opened via the universal result-view host, the same seam the human-test / tester windows
|
|
4
|
+
// use). It reads the gate's live state off the execution step (`step.visualConfirm`, pushed
|
|
5
|
+
// over the stream), renders each captured screenshot next to its reference design (paired by
|
|
6
|
+
// view), and drives the human actions: approve (advance), request a fix from findings (the
|
|
7
|
+
// Tester's fixer), or recapture (refresh the pairs). It also lets the human upload reference
|
|
8
|
+
// design images for the task.
|
|
9
|
+
import { onUnmounted, reactive, ref, watch } from 'vue'
|
|
10
|
+
import type { VisualConfirmStepState } from '~/types/execution'
|
|
11
|
+
import StepRunMeta from '~/components/panels/StepRunMeta.vue'
|
|
12
|
+
|
|
13
|
+
const board = useBoardStore()
|
|
14
|
+
const execution = useExecutionStore()
|
|
15
|
+
const visualConfirm = useVisualConfirmStore()
|
|
16
|
+
|
|
17
|
+
// Release the cached screenshot/reference object URLs when the window goes away, so the
|
|
18
|
+
// (potentially large) blob bytes don't linger in memory for the rest of the session.
|
|
19
|
+
onUnmounted(() => visualConfirm.revokeBlobs())
|
|
20
|
+
|
|
21
|
+
const { open, blockId, instanceId, stepIndex, close } = useResultView('visual-confirm')
|
|
22
|
+
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|
|
23
|
+
|
|
24
|
+
const instance = computed(() =>
|
|
25
|
+
instanceId.value === null ? null : (execution.getInstance(instanceId.value) ?? null),
|
|
26
|
+
)
|
|
27
|
+
const step = computed(() => {
|
|
28
|
+
if (instance.value === null || stepIndex.value === null) return null
|
|
29
|
+
return instance.value.steps[stepIndex.value] ?? null
|
|
30
|
+
})
|
|
31
|
+
const vc = computed<VisualConfirmStepState | null>(() => step.value?.visualConfirm ?? null)
|
|
32
|
+
const phase = computed(() => vc.value?.phase ?? null)
|
|
33
|
+
const pairs = computed(() => vc.value?.pairs ?? [])
|
|
34
|
+
const busy = computed(() => (blockId.value ? visualConfirm.isBusy(blockId.value) : false))
|
|
35
|
+
const awaitingHuman = computed(() => phase.value === 'awaiting_human')
|
|
36
|
+
const working = computed(() => phase.value === 'fixing')
|
|
37
|
+
|
|
38
|
+
const PHASE_LABEL: Record<NonNullable<VisualConfirmStepState['phase']>, string> = {
|
|
39
|
+
awaiting_human: 'Awaiting your review',
|
|
40
|
+
fixing: 'Fixer is addressing your findings…',
|
|
41
|
+
approved: 'Approved',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Resolve each pair's artifact ids to object URLs for the <img>s (cached in the store).
|
|
45
|
+
const urls = reactive<Record<string, string>>({})
|
|
46
|
+
async function resolveUrl(id: string | null | undefined) {
|
|
47
|
+
if (!id || urls[id]) return
|
|
48
|
+
const url = await visualConfirm.blobUrl(id)
|
|
49
|
+
if (url) urls[id] = url
|
|
50
|
+
}
|
|
51
|
+
watch(
|
|
52
|
+
pairs,
|
|
53
|
+
(next) => {
|
|
54
|
+
for (const p of next) {
|
|
55
|
+
void resolveUrl(p.actualArtifactId)
|
|
56
|
+
void resolveUrl(p.referenceArtifactId)
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
{ immediate: true },
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
const findings = ref('')
|
|
63
|
+
const showFindings = ref(false)
|
|
64
|
+
|
|
65
|
+
// When the gate flags its screenshots as an unreliable basis (`degradedReason` — no capture
|
|
66
|
+
// happened, a fix failed, or a fix landed AFTER these shots were taken), approving is no longer
|
|
67
|
+
// a safe one-click: require the human to explicitly acknowledge they reviewed the change another
|
|
68
|
+
// way (or recaptured) first. Re-armed whenever the reason changes so a fresh warning re-gates.
|
|
69
|
+
const ackDegraded = ref(false)
|
|
70
|
+
watch(
|
|
71
|
+
() => vc.value?.degradedReason ?? null,
|
|
72
|
+
() => {
|
|
73
|
+
ackDegraded.value = false
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
const needsAck = computed(() => !!vc.value?.degradedReason)
|
|
77
|
+
const canApprove = computed(
|
|
78
|
+
() => awaitingHuman.value && !busy.value && (!needsAck.value || ackDegraded.value),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
async function approve() {
|
|
82
|
+
if (!blockId.value || !canApprove.value) return
|
|
83
|
+
await visualConfirm.approve(blockId.value)
|
|
84
|
+
close()
|
|
85
|
+
}
|
|
86
|
+
async function submitFix() {
|
|
87
|
+
if (!blockId.value || !findings.value.trim()) return
|
|
88
|
+
await visualConfirm.requestFix(blockId.value, findings.value.trim())
|
|
89
|
+
findings.value = ''
|
|
90
|
+
showFindings.value = false
|
|
91
|
+
}
|
|
92
|
+
async function recapture() {
|
|
93
|
+
if (!blockId.value) return
|
|
94
|
+
await visualConfirm.recapture(blockId.value)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Reference upload.
|
|
98
|
+
const uploadView = ref('')
|
|
99
|
+
const fileInput = ref<HTMLInputElement | null>(null)
|
|
100
|
+
async function onFilePicked(e: Event) {
|
|
101
|
+
const input = e.target as HTMLInputElement
|
|
102
|
+
const file = input.files?.[0]
|
|
103
|
+
if (!file || !blockId.value) return
|
|
104
|
+
await visualConfirm.uploadReference(blockId.value, file, uploadView.value.trim())
|
|
105
|
+
uploadView.value = ''
|
|
106
|
+
if (fileInput.value) fileInput.value.value = ''
|
|
107
|
+
}
|
|
108
|
+
</script>
|
|
109
|
+
|
|
110
|
+
<template>
|
|
111
|
+
<Teleport to="body">
|
|
112
|
+
<div
|
|
113
|
+
v-if="open"
|
|
114
|
+
class="fixed inset-0 z-50 flex items-stretch justify-center bg-slate-950/70 backdrop-blur-sm"
|
|
115
|
+
@click.self="close"
|
|
116
|
+
>
|
|
117
|
+
<div
|
|
118
|
+
class="m-4 flex w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
|
|
119
|
+
>
|
|
120
|
+
<header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
|
|
121
|
+
<span
|
|
122
|
+
class="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/15 text-amber-300"
|
|
123
|
+
>
|
|
124
|
+
<UIcon name="i-lucide-image-play" class="h-4 w-4" />
|
|
125
|
+
</span>
|
|
126
|
+
<div class="min-w-0 flex-1">
|
|
127
|
+
<h2 class="truncate text-sm font-semibold text-slate-100">
|
|
128
|
+
Visual confirmation{{ block ? ` — ${block.title}` : '' }}
|
|
129
|
+
</h2>
|
|
130
|
+
<p class="truncate text-[11px] text-slate-400">
|
|
131
|
+
{{ phase ? PHASE_LABEL[phase] : 'Review the UI against the reference designs' }}
|
|
132
|
+
</p>
|
|
133
|
+
</div>
|
|
134
|
+
<button
|
|
135
|
+
class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
|
|
136
|
+
@click="close"
|
|
137
|
+
>
|
|
138
|
+
<UIcon name="i-lucide-x" class="h-4 w-4" />
|
|
139
|
+
</button>
|
|
140
|
+
</header>
|
|
141
|
+
|
|
142
|
+
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
|
|
143
|
+
<div
|
|
144
|
+
v-if="!vc"
|
|
145
|
+
class="flex flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
|
|
146
|
+
>
|
|
147
|
+
<UIcon name="i-lucide-image-play" class="h-8 w-8 opacity-40" />
|
|
148
|
+
<p class="text-sm">This step hasn't started yet.</p>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<template v-else>
|
|
152
|
+
<p
|
|
153
|
+
v-if="vc.degradedReason"
|
|
154
|
+
class="rounded-lg border border-amber-700/40 bg-amber-500/5 px-3 py-2 text-[12px] text-amber-300/90"
|
|
155
|
+
>
|
|
156
|
+
{{ vc.degradedReason }}
|
|
157
|
+
</p>
|
|
158
|
+
|
|
159
|
+
<p
|
|
160
|
+
v-if="working"
|
|
161
|
+
class="flex items-center gap-2 rounded-lg border border-slate-800 bg-slate-950/40 px-3 py-2 text-[12px] text-slate-300"
|
|
162
|
+
>
|
|
163
|
+
<UIcon name="i-lucide-loader" class="h-3.5 w-3.5 animate-spin text-amber-300" />
|
|
164
|
+
{{ phase ? PHASE_LABEL[phase] : '' }}
|
|
165
|
+
</p>
|
|
166
|
+
|
|
167
|
+
<!-- Actual-vs-reference gallery -->
|
|
168
|
+
<section v-if="pairs.length" class="space-y-4">
|
|
169
|
+
<div
|
|
170
|
+
v-for="(p, i) in pairs"
|
|
171
|
+
:key="i"
|
|
172
|
+
class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
|
|
173
|
+
>
|
|
174
|
+
<h3 class="mb-2 text-[12px] font-semibold text-slate-200">{{ p.view }}</h3>
|
|
175
|
+
<div class="grid grid-cols-2 gap-3">
|
|
176
|
+
<figure class="space-y-1">
|
|
177
|
+
<figcaption class="text-[10px] uppercase tracking-wide text-slate-500">
|
|
178
|
+
Actual
|
|
179
|
+
</figcaption>
|
|
180
|
+
<img
|
|
181
|
+
v-if="p.actualArtifactId && urls[p.actualArtifactId]"
|
|
182
|
+
:src="urls[p.actualArtifactId]"
|
|
183
|
+
:alt="`${p.view} (actual)`"
|
|
184
|
+
class="w-full rounded border border-slate-800"
|
|
185
|
+
/>
|
|
186
|
+
<div
|
|
187
|
+
v-else
|
|
188
|
+
class="flex h-32 items-center justify-center rounded border border-dashed border-slate-700 text-[11px] text-slate-600"
|
|
189
|
+
>
|
|
190
|
+
{{ p.actualArtifactId ? 'Loading…' : 'Not captured' }}
|
|
191
|
+
</div>
|
|
192
|
+
</figure>
|
|
193
|
+
<figure class="space-y-1">
|
|
194
|
+
<figcaption class="text-[10px] uppercase tracking-wide text-slate-500">
|
|
195
|
+
Reference
|
|
196
|
+
</figcaption>
|
|
197
|
+
<img
|
|
198
|
+
v-if="p.referenceArtifactId && urls[p.referenceArtifactId]"
|
|
199
|
+
:src="urls[p.referenceArtifactId]"
|
|
200
|
+
:alt="`${p.view} (reference)`"
|
|
201
|
+
class="w-full rounded border border-slate-800"
|
|
202
|
+
/>
|
|
203
|
+
<div
|
|
204
|
+
v-else
|
|
205
|
+
class="flex h-32 items-center justify-center rounded border border-dashed border-slate-700 text-[11px] text-slate-600"
|
|
206
|
+
>
|
|
207
|
+
{{ p.referenceArtifactId ? 'Loading…' : 'No reference' }}
|
|
208
|
+
</div>
|
|
209
|
+
</figure>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
</section>
|
|
213
|
+
<p v-else class="text-[12px] italic text-slate-500">
|
|
214
|
+
No screenshots were captured — review the change manually.
|
|
215
|
+
</p>
|
|
216
|
+
|
|
217
|
+
<!-- Reference upload -->
|
|
218
|
+
<section class="rounded-lg border border-slate-800 bg-slate-900/60 p-3">
|
|
219
|
+
<h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
220
|
+
Upload a reference design
|
|
221
|
+
</h3>
|
|
222
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
223
|
+
<input
|
|
224
|
+
v-model="uploadView"
|
|
225
|
+
placeholder="View name (e.g. login)"
|
|
226
|
+
class="rounded-md border border-slate-700 bg-slate-950 px-2 py-1 text-[12px] text-slate-200 placeholder:text-slate-600"
|
|
227
|
+
/>
|
|
228
|
+
<input
|
|
229
|
+
ref="fileInput"
|
|
230
|
+
type="file"
|
|
231
|
+
accept="image/png,image/jpeg"
|
|
232
|
+
:disabled="busy"
|
|
233
|
+
class="text-[12px] text-slate-300 file:mr-2 file:rounded file:border-0 file:bg-slate-800 file:px-2 file:py-1 file:text-slate-200"
|
|
234
|
+
@change="onFilePicked"
|
|
235
|
+
/>
|
|
236
|
+
</div>
|
|
237
|
+
</section>
|
|
238
|
+
|
|
239
|
+
<!-- Request fix -->
|
|
240
|
+
<section
|
|
241
|
+
v-if="awaitingHuman"
|
|
242
|
+
class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
|
|
243
|
+
>
|
|
244
|
+
<div class="flex items-center justify-between">
|
|
245
|
+
<h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
246
|
+
Needs changes?
|
|
247
|
+
</h3>
|
|
248
|
+
<button
|
|
249
|
+
class="text-[12px] text-slate-400 hover:text-slate-200"
|
|
250
|
+
@click="showFindings = !showFindings"
|
|
251
|
+
>
|
|
252
|
+
{{ showFindings ? 'Cancel' : 'Request a fix' }}
|
|
253
|
+
</button>
|
|
254
|
+
</div>
|
|
255
|
+
<div v-if="showFindings" class="mt-2 space-y-2">
|
|
256
|
+
<textarea
|
|
257
|
+
v-model="findings"
|
|
258
|
+
rows="4"
|
|
259
|
+
placeholder="Describe what looks wrong — the Fixer agent gets this as context."
|
|
260
|
+
class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2 text-[13px] text-slate-200 placeholder:text-slate-600 focus:border-amber-500 focus:outline-none"
|
|
261
|
+
/>
|
|
262
|
+
<UButton
|
|
263
|
+
size="sm"
|
|
264
|
+
color="warning"
|
|
265
|
+
icon="i-lucide-wrench"
|
|
266
|
+
:loading="busy"
|
|
267
|
+
:disabled="busy || !findings.trim()"
|
|
268
|
+
@click="submitFix"
|
|
269
|
+
>
|
|
270
|
+
Send to Fixer
|
|
271
|
+
</UButton>
|
|
272
|
+
</div>
|
|
273
|
+
</section>
|
|
274
|
+
|
|
275
|
+
<!-- Rounds history -->
|
|
276
|
+
<section
|
|
277
|
+
v-if="vc.rounds && vc.rounds.length"
|
|
278
|
+
class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
|
|
279
|
+
>
|
|
280
|
+
<h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
281
|
+
History ({{ vc.attempts }} round{{ vc.attempts === 1 ? '' : 's' }})
|
|
282
|
+
</h3>
|
|
283
|
+
<ol class="space-y-2">
|
|
284
|
+
<li v-for="(r, i) in vc.rounds" :key="i" class="flex items-start gap-2 text-[12px]">
|
|
285
|
+
<UIcon
|
|
286
|
+
name="i-lucide-wrench"
|
|
287
|
+
class="mt-0.5 h-3.5 w-3.5 shrink-0 text-slate-400"
|
|
288
|
+
/>
|
|
289
|
+
<div class="min-w-0 flex-1">
|
|
290
|
+
<span class="text-slate-200">Fix requested</span>
|
|
291
|
+
<span
|
|
292
|
+
class="ml-1.5 rounded px-1 text-[10px] uppercase"
|
|
293
|
+
:class="
|
|
294
|
+
r.outcome === 'completed'
|
|
295
|
+
? 'bg-emerald-500/15 text-emerald-300'
|
|
296
|
+
: r.outcome === 'failed'
|
|
297
|
+
? 'bg-rose-500/15 text-rose-300'
|
|
298
|
+
: 'bg-slate-500/15 text-slate-300'
|
|
299
|
+
"
|
|
300
|
+
>
|
|
301
|
+
{{ r.outcome ?? 'in progress' }}
|
|
302
|
+
</span>
|
|
303
|
+
<p v-if="r.findings" class="leading-snug text-slate-400">{{ r.findings }}</p>
|
|
304
|
+
</div>
|
|
305
|
+
</li>
|
|
306
|
+
</ol>
|
|
307
|
+
</section>
|
|
308
|
+
</template>
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
<footer
|
|
312
|
+
v-if="vc"
|
|
313
|
+
class="flex items-center justify-between gap-3 border-t border-slate-800 px-5 py-3"
|
|
314
|
+
>
|
|
315
|
+
<StepRunMeta
|
|
316
|
+
v-if="step"
|
|
317
|
+
:step="step"
|
|
318
|
+
:instance-id="instanceId ?? undefined"
|
|
319
|
+
:step-number="stepIndex === null ? undefined : stepIndex + 1"
|
|
320
|
+
:total-steps="instance?.steps.length"
|
|
321
|
+
:run-failed="instance?.status === 'failed'"
|
|
322
|
+
:failure-at="instance?.failure?.occurredAt"
|
|
323
|
+
/>
|
|
324
|
+
<div class="flex items-center gap-2">
|
|
325
|
+
<label
|
|
326
|
+
v-if="awaitingHuman && needsAck"
|
|
327
|
+
class="flex items-center gap-1.5 text-[11px] text-amber-300/90"
|
|
328
|
+
>
|
|
329
|
+
<input v-model="ackDegraded" type="checkbox" class="accent-amber-500" />
|
|
330
|
+
I've reviewed this manually
|
|
331
|
+
</label>
|
|
332
|
+
<UButton
|
|
333
|
+
size="sm"
|
|
334
|
+
variant="soft"
|
|
335
|
+
color="neutral"
|
|
336
|
+
icon="i-lucide-refresh-cw"
|
|
337
|
+
:loading="busy"
|
|
338
|
+
:disabled="busy || !awaitingHuman"
|
|
339
|
+
@click="recapture"
|
|
340
|
+
>
|
|
341
|
+
Recapture
|
|
342
|
+
</UButton>
|
|
343
|
+
<UButton
|
|
344
|
+
color="primary"
|
|
345
|
+
icon="i-lucide-circle-check"
|
|
346
|
+
:loading="busy"
|
|
347
|
+
:disabled="!canApprove"
|
|
348
|
+
@click="approve"
|
|
349
|
+
>
|
|
350
|
+
Approve — continue
|
|
351
|
+
</UButton>
|
|
352
|
+
</div>
|
|
353
|
+
</footer>
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
</Teleport>
|
|
357
|
+
</template>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
approveVisualConfirmContract,
|
|
3
|
+
recaptureVisualConfirmContract,
|
|
4
|
+
requestVisualConfirmFixContract,
|
|
5
|
+
} from '@cat-factory/contracts'
|
|
6
|
+
import type { ApiContext } from './context'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The visual-confirmation gate's run-driving actions + the artifact helpers its window needs
|
|
10
|
+
* (upload a reference design image, fetch a stored blob as an object URL). The action calls
|
|
11
|
+
* return the updated execution instance (the gate state rides on its current step and also
|
|
12
|
+
* arrives live via the execution stream). The blob/upload helpers use the authed `$fetch`
|
|
13
|
+
* (the artifact ingest/blob endpoints are raw, not contract-modelled, because they carry binary).
|
|
14
|
+
*/
|
|
15
|
+
export function visualConfirmApi({ send, ws, http }: ApiContext) {
|
|
16
|
+
return {
|
|
17
|
+
// Approve the reviewed screenshots: advance the pipeline.
|
|
18
|
+
approveVisualConfirm: (workspaceId: string, blockId: string) =>
|
|
19
|
+
send(approveVisualConfirmContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
|
|
20
|
+
|
|
21
|
+
// Submit findings and request a fix (dispatches the Tester's fixer, then re-parks).
|
|
22
|
+
requestVisualConfirmFix: (workspaceId: string, blockId: string, findings: string) =>
|
|
23
|
+
send(requestVisualConfirmFixContract, {
|
|
24
|
+
pathPrefix: ws(workspaceId),
|
|
25
|
+
pathParams: { blockId },
|
|
26
|
+
body: { findings },
|
|
27
|
+
}),
|
|
28
|
+
|
|
29
|
+
// Refresh the actual-vs-reference pairs from the latest UI-tester report.
|
|
30
|
+
recaptureVisualConfirm: (workspaceId: string, blockId: string) =>
|
|
31
|
+
send(recaptureVisualConfirmContract, {
|
|
32
|
+
pathPrefix: ws(workspaceId),
|
|
33
|
+
pathParams: { blockId },
|
|
34
|
+
}),
|
|
35
|
+
|
|
36
|
+
// Upload a reference design image for a block (kind=reference), tagged with its view name.
|
|
37
|
+
uploadReferenceArtifact: async (
|
|
38
|
+
workspaceId: string,
|
|
39
|
+
blockId: string,
|
|
40
|
+
file: File,
|
|
41
|
+
view: string,
|
|
42
|
+
): Promise<{ artifact: { id: string } }> => {
|
|
43
|
+
const form = new FormData()
|
|
44
|
+
form.append('file', file)
|
|
45
|
+
form.append('kind', 'reference')
|
|
46
|
+
form.append('blockId', blockId)
|
|
47
|
+
if (view) form.append('view', view)
|
|
48
|
+
return http(`${ws(workspaceId)}/artifacts`, { method: 'POST', body: form })
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
// Fetch a stored artifact's bytes and turn them into an object URL for an <img>.
|
|
52
|
+
fetchArtifactBlobUrl: async (workspaceId: string, artifactId: string): Promise<string> => {
|
|
53
|
+
const blob: Blob = await http(
|
|
54
|
+
`${ws(workspaceId)}/artifacts/${encodeURIComponent(artifactId)}/blob`,
|
|
55
|
+
{ method: 'GET', responseType: 'blob' },
|
|
56
|
+
)
|
|
57
|
+
return URL.createObjectURL(blob)
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -12,6 +12,7 @@ import { fragmentsApi } from './api/fragments'
|
|
|
12
12
|
import { githubApi } from './api/github'
|
|
13
13
|
import { humanReviewApi } from './api/humanReview'
|
|
14
14
|
import { humanTestApi } from './api/humanTest'
|
|
15
|
+
import { visualConfirmApi } from './api/visualConfirm'
|
|
15
16
|
import { kaizenApi } from './api/kaizen'
|
|
16
17
|
import { localSettingsApi } from './api/localSettings'
|
|
17
18
|
import { modelsApi } from './api/models'
|
|
@@ -98,6 +99,7 @@ export function useApi() {
|
|
|
98
99
|
...reviewsApi(ctx),
|
|
99
100
|
...followUpsApi(ctx),
|
|
100
101
|
...humanTestApi(ctx),
|
|
102
|
+
...visualConfirmApi(ctx),
|
|
101
103
|
...humanReviewApi(ctx),
|
|
102
104
|
...kaizenApi(ctx),
|
|
103
105
|
...localSettingsApi(ctx),
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { usePipelineErrorToast, parseConflict } from '~/composables/usePipelineErrorToast'
|
|
3
|
+
import { ApiError } from '~/composables/api/errors'
|
|
4
|
+
import en from '../../i18n/locales/en.json'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The i18n pilot: the pipeline-error toast resolves user-facing copy from
|
|
8
|
+
* `errors.conflict.*` message KEYS by the backend's machine-readable `reason`, and only
|
|
9
|
+
* ever shows raw backend prose as a last-resort description. These specs assert the KEYS
|
|
10
|
+
* and params a code path resolves (never the English text), so they stay locale-agnostic.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Dot-path lookup into the real `en.json`, so `te` mirrors which keys actually ship. */
|
|
14
|
+
function hasKey(path: string): boolean {
|
|
15
|
+
return (
|
|
16
|
+
path.split('.').reduce<unknown>((node, seg) => {
|
|
17
|
+
return node && typeof node === 'object' ? (node as Record<string, unknown>)[seg] : undefined
|
|
18
|
+
}, en) !== undefined
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let add: ReturnType<typeof vi.fn>
|
|
23
|
+
let t: ReturnType<typeof vi.fn>
|
|
24
|
+
let openAiProviderSetup: ReturnType<typeof vi.fn>
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
add = vi.fn()
|
|
28
|
+
// `t` echoes the key so the toast's title/description IS the resolved key — assert on it.
|
|
29
|
+
t = vi.fn((key: string) => key)
|
|
30
|
+
openAiProviderSetup = vi.fn()
|
|
31
|
+
vi.stubGlobal('useToast', () => ({ add }))
|
|
32
|
+
vi.stubGlobal('useUiStore', () => ({ openAiProviderSetup }))
|
|
33
|
+
vi.stubGlobal('useI18n', () => ({ t, te: (key: string) => hasKey(key) }))
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
function conflict(reason?: string, details: Record<string, unknown> = {}, message?: string) {
|
|
37
|
+
return new ApiError(409, {
|
|
38
|
+
error: { code: 'conflict', message, details: { reason, ...details } },
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('parseConflict', () => {
|
|
43
|
+
it('extracts reason + raw message + details from a 409 conflict', () => {
|
|
44
|
+
const parsed = parseConflict(conflict('dependencies_unmet', { foo: 1 }, 'raw msg'))
|
|
45
|
+
expect(parsed).toEqual({
|
|
46
|
+
reason: 'dependencies_unmet',
|
|
47
|
+
message: 'raw msg',
|
|
48
|
+
details: { reason: 'dependencies_unmet', foo: 1 },
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('returns null for a non-conflict error', () => {
|
|
53
|
+
expect(parseConflict(new ApiError(500, { error: { code: 'internal' } }))).toBeNull()
|
|
54
|
+
expect(parseConflict(new Error('network'))).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('usePipelineErrorToast', () => {
|
|
59
|
+
it('titles a mapped conflict reason from its errors.conflict.title.<reason> key', () => {
|
|
60
|
+
usePipelineErrorToast().present(conflict('dependencies_unmet'))
|
|
61
|
+
expect(add).toHaveBeenCalledTimes(1)
|
|
62
|
+
expect(add.mock.calls[0]![0].title).toBe('errors.conflict.title.dependencies_unmet')
|
|
63
|
+
expect(t).toHaveBeenCalledWith('errors.conflict.title.dependencies_unmet')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('falls back to the caller fallback key when the reason has no dedicated title', () => {
|
|
67
|
+
usePipelineErrorToast().present(conflict('totally_unknown_reason'), 'errors.action.retryFailed')
|
|
68
|
+
expect(add.mock.calls[0]![0].title).toBe('errors.action.retryFailed')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('shows the raw backend message as the conflict description', () => {
|
|
72
|
+
usePipelineErrorToast().present(conflict('dependencies_unmet', {}, 'A depends on B'))
|
|
73
|
+
expect(add.mock.calls[0]![0].description).toBe('A depends on B')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('falls back to a translated description when the backend sends no message', () => {
|
|
77
|
+
usePipelineErrorToast().present(conflict('dependencies_unmet'))
|
|
78
|
+
expect(add.mock.calls[0]![0].description).toBe('errors.conflict.fallbackMessage')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('interpolates the model list for providers_unconfigured and offers the AI setup jump', () => {
|
|
82
|
+
usePipelineErrorToast().present(
|
|
83
|
+
conflict('providers_unconfigured', { models: ['gpt-x', 'claude-y'] }),
|
|
84
|
+
)
|
|
85
|
+
const arg = add.mock.calls[0]![0]
|
|
86
|
+
expect(arg.title).toBe('errors.conflict.providersUnconfigured.title')
|
|
87
|
+
expect(t).toHaveBeenCalledWith('errors.conflict.providersUnconfigured.body', {
|
|
88
|
+
models: 'gpt-x, claude-y',
|
|
89
|
+
})
|
|
90
|
+
arg.actions[0].onClick()
|
|
91
|
+
expect(openAiProviderSetup).toHaveBeenCalledOnce()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('uses the fallback title key + raw message for a non-conflict error', () => {
|
|
95
|
+
usePipelineErrorToast().present(new Error('boom'), 'errors.action.startFailed')
|
|
96
|
+
const arg = add.mock.calls[0]![0]
|
|
97
|
+
expect(arg.title).toBe('errors.action.startFailed')
|
|
98
|
+
expect(arg.description).toBe('boom')
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -4,8 +4,15 @@
|
|
|
4
4
|
* `error.details.reason` (kernel `ConflictReason`), so we can word each case precisely
|
|
5
5
|
* instead of dumping the raw message — and, for `providers_unconfigured`, surface the
|
|
6
6
|
* SAME guidance + "Configure AI" jump as the no-AI-provider startup banner.
|
|
7
|
+
*
|
|
8
|
+
* i18n boundary (see CLAUDE.md / the i18n plan): user-facing titles are resolved from
|
|
9
|
+
* `errors.conflict.*` message keys by the machine-readable `reason`. The raw backend
|
|
10
|
+
* `message` is shown only as the description fallback and stays untranslated — the
|
|
11
|
+
* contract is "if a server message must be localizable, the backend emits a code and the
|
|
12
|
+
* frontend maps it", not "translate arbitrary server prose on the client".
|
|
7
13
|
*/
|
|
8
14
|
|
|
15
|
+
import type { ConflictReason } from '@cat-factory/contracts'
|
|
9
16
|
import { apiErrorEnvelope } from './api/errors'
|
|
10
17
|
|
|
11
18
|
/** The parsed shape of a backend conflict (`{ error: { code: 'conflict', details } }`). */
|
|
@@ -15,41 +22,55 @@ interface ConflictDetails {
|
|
|
15
22
|
[key: string]: unknown
|
|
16
23
|
}
|
|
17
24
|
|
|
18
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Per-reason toast title KEYS, keyed off the kernel/contracts `ConflictReason`. Being an
|
|
27
|
+
* EXHAUSTIVE `Record` over the union is the real drift guard: a new backend conflict reason
|
|
28
|
+
* fails THIS typecheck until it is mapped here. (The typed-message-keys feature can't see the
|
|
29
|
+
* `t()` lookup because the key is resolved at runtime via this map, not written as a literal —
|
|
30
|
+
* so the exhaustiveness of the map, not `t()`, is what makes a missing reason a build error.)
|
|
31
|
+
* `providers_unconfigured` is excluded: it has bespoke handling + its own `providersUnconfigured.*`
|
|
32
|
+
* key namespace, so it never reaches the generic lookup below.
|
|
33
|
+
*/
|
|
34
|
+
const CONFLICT_TITLE_KEYS: Record<Exclude<ConflictReason, 'providers_unconfigured'>, string> = {
|
|
35
|
+
dependencies_unmet: 'errors.conflict.title.dependencies_unmet',
|
|
36
|
+
task_limit_reached: 'errors.conflict.title.task_limit_reached',
|
|
37
|
+
tester_infra_unsupported: 'errors.conflict.title.tester_infra_unsupported',
|
|
38
|
+
agent_backend_unconfigured: 'errors.conflict.title.agent_backend_unconfigured',
|
|
39
|
+
run_not_retryable: 'errors.conflict.title.run_not_retryable',
|
|
40
|
+
no_pr_to_merge: 'errors.conflict.title.no_pr_to_merge',
|
|
41
|
+
github_not_connected: 'errors.conflict.title.github_not_connected',
|
|
42
|
+
bootstrap_not_retryable: 'errors.conflict.title.bootstrap_not_retryable',
|
|
43
|
+
bootstrap_reference_missing: 'errors.conflict.title.bootstrap_reference_missing',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Pull a 409 conflict's `{ reason, message, details }` out of a thrown API error, else null.
|
|
48
|
+
* `message` is the raw backend prose (may be absent); the translated fallback is applied at
|
|
49
|
+
* the call site where the i18n `t` is available.
|
|
50
|
+
*/
|
|
19
51
|
export function parseConflict(
|
|
20
52
|
error: unknown,
|
|
21
|
-
): { reason?: string; message
|
|
53
|
+
): { reason?: string; message?: string; details: ConflictDetails } | null {
|
|
22
54
|
const body = apiErrorEnvelope(error)
|
|
23
55
|
if (body?.code !== 'conflict') return null
|
|
24
56
|
const details = (body.details as ConflictDetails | undefined) ?? {}
|
|
25
57
|
return {
|
|
26
58
|
reason: typeof details.reason === 'string' ? details.reason : undefined,
|
|
27
|
-
message: body.message
|
|
59
|
+
message: typeof body.message === 'string' ? body.message : undefined,
|
|
28
60
|
details,
|
|
29
61
|
}
|
|
30
62
|
}
|
|
31
63
|
|
|
32
|
-
/** Per-reason toast titles for conflicts that don't get bespoke handling below. */
|
|
33
|
-
const CONFLICT_TITLES: Record<string, string> = {
|
|
34
|
-
dependencies_unmet: 'Blocked by dependencies',
|
|
35
|
-
task_limit_reached: 'Concurrency limit reached',
|
|
36
|
-
tester_infra_unsupported: 'Test infrastructure not configured',
|
|
37
|
-
run_not_retryable: 'Run can’t be retried',
|
|
38
|
-
no_pr_to_merge: 'No PR to merge',
|
|
39
|
-
github_not_connected: 'GitHub not connected',
|
|
40
|
-
bootstrap_not_retryable: 'Bootstrap can’t be retried',
|
|
41
|
-
bootstrap_reference_missing: 'Reference architecture is gone',
|
|
42
|
-
}
|
|
43
|
-
|
|
44
64
|
export function usePipelineErrorToast() {
|
|
45
65
|
const toast = useToast()
|
|
46
66
|
const ui = useUiStore()
|
|
67
|
+
const { t, te } = useI18n()
|
|
47
68
|
|
|
48
69
|
/**
|
|
49
|
-
* Present `error` as a toast. `
|
|
50
|
-
* conflict reason without a dedicated title.
|
|
70
|
+
* Present `error` as a toast. `fallbackTitleKey` is an i18n message key used for
|
|
71
|
+
* non-conflict failures and any conflict reason without a dedicated title.
|
|
51
72
|
*/
|
|
52
|
-
function present(error: unknown,
|
|
73
|
+
function present(error: unknown, fallbackTitleKey = 'common.actionFailed'): void {
|
|
53
74
|
const conflict = parseConflict(error)
|
|
54
75
|
|
|
55
76
|
// The headline case: a pipeline step's model has no usable provider. Name the
|
|
@@ -59,16 +80,15 @@ export function usePipelineErrorToast() {
|
|
|
59
80
|
const models = Array.isArray(conflict.details.models) ? conflict.details.models : []
|
|
60
81
|
const list = models.join(', ')
|
|
61
82
|
toast.add({
|
|
62
|
-
title: '
|
|
83
|
+
title: t('errors.conflict.providersUnconfigured.title'),
|
|
63
84
|
description: list
|
|
64
|
-
?
|
|
65
|
-
|
|
66
|
-
: conflict.message,
|
|
85
|
+
? t('errors.conflict.providersUnconfigured.body', { models: list })
|
|
86
|
+
: (conflict.message ?? t('errors.conflict.fallbackMessage')),
|
|
67
87
|
color: 'error',
|
|
68
88
|
icon: 'i-lucide-cpu',
|
|
69
89
|
actions: [
|
|
70
90
|
{
|
|
71
|
-
label: '
|
|
91
|
+
label: t('errors.conflict.providersUnconfigured.action'),
|
|
72
92
|
icon: 'i-lucide-settings',
|
|
73
93
|
onClick: () => ui.openAiProviderSetup(),
|
|
74
94
|
},
|
|
@@ -78,9 +98,14 @@ export function usePipelineErrorToast() {
|
|
|
78
98
|
}
|
|
79
99
|
|
|
80
100
|
if (conflict) {
|
|
101
|
+
// Per-reason title key from the exhaustive map; fall back to the caller's title key when
|
|
102
|
+
// this reason has no mapped/translated copy (`te` = translation-exists, so a key missing
|
|
103
|
+
// in the active locale never leaks as raw text). An unknown reason isn't in the map.
|
|
104
|
+
const reasonKey =
|
|
105
|
+
CONFLICT_TITLE_KEYS[conflict.reason as Exclude<ConflictReason, 'providers_unconfigured'>]
|
|
81
106
|
toast.add({
|
|
82
|
-
title:
|
|
83
|
-
description: conflict.message,
|
|
107
|
+
title: reasonKey && te(reasonKey) ? t(reasonKey) : t(fallbackTitleKey),
|
|
108
|
+
description: conflict.message ?? t('errors.conflict.fallbackMessage'),
|
|
84
109
|
color: 'warning',
|
|
85
110
|
icon: 'i-lucide-triangle-alert',
|
|
86
111
|
})
|
|
@@ -89,7 +114,7 @@ export function usePipelineErrorToast() {
|
|
|
89
114
|
|
|
90
115
|
// Not a conflict (a 4xx/5xx or a network fault) — surface its message plainly.
|
|
91
116
|
toast.add({
|
|
92
|
-
title:
|
|
117
|
+
title: t(fallbackTitleKey),
|
|
93
118
|
description: error instanceof Error ? error.message : String(error),
|
|
94
119
|
color: 'error',
|
|
95
120
|
icon: 'i-lucide-triangle-alert',
|
|
@@ -49,7 +49,9 @@ const BUILTIN_SEED_KINDS = [
|
|
|
49
49
|
'reviewer',
|
|
50
50
|
'blueprints',
|
|
51
51
|
'mocker',
|
|
52
|
-
'tester',
|
|
52
|
+
'tester-api',
|
|
53
|
+
'tester-ui',
|
|
54
|
+
'visual-confirmation',
|
|
53
55
|
'conflicts',
|
|
54
56
|
'ci',
|
|
55
57
|
'merger',
|
|
@@ -86,7 +88,7 @@ describe('usePipelineHealth', () => {
|
|
|
86
88
|
'coder',
|
|
87
89
|
'reviewer',
|
|
88
90
|
'blueprints',
|
|
89
|
-
'tester',
|
|
91
|
+
'tester-api',
|
|
90
92
|
'conflicts',
|
|
91
93
|
'ci',
|
|
92
94
|
'merger',
|
package/app/stores/agentRuns.ts
CHANGED
package/app/stores/execution.ts
CHANGED
|
@@ -141,7 +141,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
141
141
|
await ws.refresh()
|
|
142
142
|
})
|
|
143
143
|
} catch (e) {
|
|
144
|
-
runErrors.present(e, '
|
|
144
|
+
runErrors.present(e, 'errors.action.startFailed')
|
|
145
145
|
return false
|
|
146
146
|
}
|
|
147
147
|
}
|
|
@@ -240,7 +240,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
240
240
|
await api.mergeBlock(ws.requireId(), blockId)
|
|
241
241
|
await ws.refresh()
|
|
242
242
|
} catch (e) {
|
|
243
|
-
runErrors.present(e, '
|
|
243
|
+
runErrors.present(e, 'errors.action.mergeFailed')
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
246
|
|
|
@@ -261,7 +261,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
261
261
|
await ws.refresh()
|
|
262
262
|
})
|
|
263
263
|
} catch (e) {
|
|
264
|
-
runErrors.present(e, '
|
|
264
|
+
runErrors.present(e, 'errors.action.restartFailed')
|
|
265
265
|
return false
|
|
266
266
|
}
|
|
267
267
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import { useExecutionStore } from '~/stores/execution'
|
|
4
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Visual-confirmation gate actions. The gate's live state rides on its execution step
|
|
8
|
+
* (`step.visualConfirm`) and arrives via the execution stream, so this store holds NO gate
|
|
9
|
+
* state — it only drives the actions (approve / request a fix / recapture), uploads reference
|
|
10
|
+
* design images, and resolves stored artifacts into object URLs for the gallery. A per-block
|
|
11
|
+
* `busy` flag lets the window disable its controls while an action is in flight.
|
|
12
|
+
*/
|
|
13
|
+
export const useVisualConfirmStore = defineStore('visualConfirm', () => {
|
|
14
|
+
const api = useApi()
|
|
15
|
+
const ws = useWorkspaceStore()
|
|
16
|
+
const execution = useExecutionStore()
|
|
17
|
+
|
|
18
|
+
const busy = ref<Set<string>>(new Set())
|
|
19
|
+
/** Cache of artifactId → object URL, so the gallery doesn't re-fetch the same blob. */
|
|
20
|
+
const blobUrls = ref<Map<string, string>>(new Map())
|
|
21
|
+
|
|
22
|
+
function isBusy(blockId: string): boolean {
|
|
23
|
+
return busy.value.has(blockId)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function run(blockId: string, action: () => Promise<unknown>): Promise<void> {
|
|
27
|
+
const next = new Set(busy.value)
|
|
28
|
+
next.add(blockId)
|
|
29
|
+
busy.value = next
|
|
30
|
+
try {
|
|
31
|
+
const instance = await action()
|
|
32
|
+
if (instance && typeof instance === 'object' && 'steps' in instance) {
|
|
33
|
+
execution.upsert(instance as Parameters<typeof execution.upsert>[0])
|
|
34
|
+
}
|
|
35
|
+
} finally {
|
|
36
|
+
const after = new Set(busy.value)
|
|
37
|
+
after.delete(blockId)
|
|
38
|
+
busy.value = after
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Approve the reviewed screenshots: advance the pipeline. */
|
|
43
|
+
function approve(blockId: string): Promise<void> {
|
|
44
|
+
return run(blockId, () => api.approveVisualConfirm(ws.requireId(), blockId))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Submit findings and request a fix. */
|
|
48
|
+
function requestFix(blockId: string, findings: string): Promise<void> {
|
|
49
|
+
return run(blockId, () => api.requestVisualConfirmFix(ws.requireId(), blockId, findings))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Refresh the actual-vs-reference pairs from the latest UI-tester report. */
|
|
53
|
+
function recapture(blockId: string): Promise<void> {
|
|
54
|
+
return run(blockId, () => api.recaptureVisualConfirm(ws.requireId(), blockId))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Upload a reference design image for a block, tagged with the view it depicts. */
|
|
58
|
+
function uploadReference(blockId: string, file: File, view: string): Promise<void> {
|
|
59
|
+
return run(blockId, () => api.uploadReferenceArtifact(ws.requireId(), blockId, file, view))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Resolve a stored artifact to an object URL (cached). Returns null on failure. */
|
|
63
|
+
async function blobUrl(artifactId: string): Promise<string | null> {
|
|
64
|
+
const cached = blobUrls.value.get(artifactId)
|
|
65
|
+
if (cached) return cached
|
|
66
|
+
try {
|
|
67
|
+
const url = await api.fetchArtifactBlobUrl(ws.requireId(), artifactId)
|
|
68
|
+
blobUrls.value.set(artifactId, url)
|
|
69
|
+
return url
|
|
70
|
+
} catch {
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Release every cached object URL and clear the cache. `URL.createObjectURL` holds the
|
|
77
|
+
* blob in memory until explicitly revoked, so the gate window calls this on unmount to
|
|
78
|
+
* avoid leaking the (potentially large) screenshot bytes for the session's lifetime.
|
|
79
|
+
*/
|
|
80
|
+
function revokeBlobs(): void {
|
|
81
|
+
for (const url of blobUrls.value.values()) {
|
|
82
|
+
try {
|
|
83
|
+
URL.revokeObjectURL(url)
|
|
84
|
+
} catch {
|
|
85
|
+
// Ignore — a URL already revoked / unsupported environment.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
blobUrls.value = new Map()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { isBusy, approve, requestFix, recapture, uploadReference, blobUrl, revokeBlobs }
|
|
92
|
+
})
|
package/app/types/execution.ts
CHANGED
|
@@ -37,6 +37,9 @@ export type {
|
|
|
37
37
|
RunEnvironment,
|
|
38
38
|
HumanTestRound,
|
|
39
39
|
HumanTestStepState,
|
|
40
|
+
VisualConfirmStepState,
|
|
41
|
+
VisualConfirmPair,
|
|
42
|
+
VisualConfirmRound,
|
|
40
43
|
ExecutionInstance,
|
|
41
44
|
// The historical frontend name for a per-block review comment is the contract's
|
|
42
45
|
// StepReviewComment; the env-status union is the contract's EnvironmentStatus.
|
|
@@ -21,7 +21,8 @@ const AGENT_KINDS: AgentKind[] = [
|
|
|
21
21
|
'architect',
|
|
22
22
|
'researcher',
|
|
23
23
|
'coder',
|
|
24
|
-
'tester',
|
|
24
|
+
'tester-api',
|
|
25
|
+
'tester-ui',
|
|
25
26
|
'reviewer',
|
|
26
27
|
'documenter',
|
|
27
28
|
'integrator',
|
|
@@ -32,6 +33,7 @@ const AGENT_KINDS: AgentKind[] = [
|
|
|
32
33
|
'business-documenter',
|
|
33
34
|
'business-reviewer',
|
|
34
35
|
'human-test',
|
|
36
|
+
'visual-confirmation',
|
|
35
37
|
]
|
|
36
38
|
const BLOCK_TYPES: BlockType[] = [
|
|
37
39
|
'frontend',
|
package/app/utils/catalog.ts
CHANGED
|
@@ -134,8 +134,8 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
|
|
|
134
134
|
description: 'Builds WireMock mocks for external services and wires them into local/CI runs.',
|
|
135
135
|
},
|
|
136
136
|
{
|
|
137
|
-
kind: 'tester',
|
|
138
|
-
label: 'Tester',
|
|
137
|
+
kind: 'tester-api',
|
|
138
|
+
label: 'API Tester',
|
|
139
139
|
icon: 'i-lucide-flask-conical',
|
|
140
140
|
color: '#fbbf24',
|
|
141
141
|
category: 'test',
|
|
@@ -144,6 +144,17 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
|
|
|
144
144
|
// concerns tree) instead of the generic prose step-detail panel.
|
|
145
145
|
resultView: 'tester',
|
|
146
146
|
},
|
|
147
|
+
{
|
|
148
|
+
kind: 'tester-ui',
|
|
149
|
+
label: 'UI Tester',
|
|
150
|
+
icon: 'i-lucide-camera',
|
|
151
|
+
color: '#fbbf24',
|
|
152
|
+
category: 'test',
|
|
153
|
+
description:
|
|
154
|
+
'Drives a real browser through the new UI, captures a screenshot of each view, and reports outcomes.',
|
|
155
|
+
// Same structured test-report window; it additionally renders the captured screenshots.
|
|
156
|
+
resultView: 'tester',
|
|
157
|
+
},
|
|
147
158
|
{
|
|
148
159
|
kind: 'playwright',
|
|
149
160
|
label: 'Acceptance Test Author',
|
|
@@ -165,6 +176,18 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
|
|
|
165
176
|
// recreate / destroy) instead of the generic prose step-detail panel.
|
|
166
177
|
resultView: 'human-test',
|
|
167
178
|
},
|
|
179
|
+
{
|
|
180
|
+
kind: 'visual-confirmation',
|
|
181
|
+
label: 'Visual Confirmation',
|
|
182
|
+
icon: 'i-lucide-image-play',
|
|
183
|
+
color: '#f59e0b',
|
|
184
|
+
category: 'test',
|
|
185
|
+
description:
|
|
186
|
+
'Pauses for a person to review the UI tester’s screenshots against the uploaded reference designs — approve, or request a fix from findings — before the pipeline continues.',
|
|
187
|
+
// Opens the dedicated visual-confirmation window (actual-vs-reference gallery + approve /
|
|
188
|
+
// request-fix / recapture) instead of the generic prose step-detail panel.
|
|
189
|
+
resultView: 'visual-confirm',
|
|
190
|
+
},
|
|
168
191
|
{
|
|
169
192
|
kind: 'documenter',
|
|
170
193
|
label: 'Documenter',
|
|
@@ -83,7 +83,7 @@ export const COMPANION_STATE_META: Record<
|
|
|
83
83
|
* via `step.gate`, which all share the same possible/running/completed/skipped shape.
|
|
84
84
|
*/
|
|
85
85
|
export function gateCompanionFor(step: PipelineStep, runFailed = false): GateCompanion | null {
|
|
86
|
-
if (step.agentKind === 'tester') {
|
|
86
|
+
if (step.agentKind === 'tester-api' || step.agentKind === 'tester-ui') {
|
|
87
87
|
const attempts = step.test?.attempts ?? 0
|
|
88
88
|
if (step.state === 'done') {
|
|
89
89
|
// The gate finished: it ran the fixer iff it ever dispatched one.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// vue-i18n options for the @cat-factory/app layer. Referenced from `nuxt.config.ts`
|
|
2
|
+
// as the bare filename `i18n.config.ts` so @nuxtjs/i18n resolves it per-layer (see the
|
|
3
|
+
// `i18n` block there). `defineI18nConfig` is auto-imported by the module.
|
|
4
|
+
//
|
|
5
|
+
// Locale MESSAGES are NOT defined here — they live in `i18n/locales/*.json` so the
|
|
6
|
+
// module can deep-merge them across the `extends` layer chain. This file carries only
|
|
7
|
+
// the runtime vue-i18n behaviour (fallback, number/date formats) shared by every locale.
|
|
8
|
+
export default defineI18nConfig(() => ({
|
|
9
|
+
legacy: false,
|
|
10
|
+
fallbackLocale: 'en',
|
|
11
|
+
|
|
12
|
+
// Locale-aware number/currency formatting. Use `$n(value, 'currency')` etc. at call
|
|
13
|
+
// sites instead of a raw `Intl.NumberFormat`; `$n`/`$d` are thin `Intl` wrappers so
|
|
14
|
+
// `en` behaviour is identical. `currency` style needs a `currency` override per call
|
|
15
|
+
// (`$n(n, 'currency', { currency: s.currency })`) — the backend supplies the code.
|
|
16
|
+
numberFormats: {
|
|
17
|
+
en: {
|
|
18
|
+
decimal: { style: 'decimal' },
|
|
19
|
+
currency: { style: 'currency', currency: 'USD', currencyDisplay: 'narrowSymbol' },
|
|
20
|
+
percent: { style: 'percent', maximumFractionDigits: 1 },
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
datetimeFormats: {
|
|
24
|
+
en: {
|
|
25
|
+
short: { dateStyle: 'medium' },
|
|
26
|
+
long: { dateStyle: 'long', timeStyle: 'short' },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
}))
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"common": {
|
|
3
|
+
"save": "Save",
|
|
4
|
+
"cancel": "Cancel",
|
|
5
|
+
"retry": "Retry",
|
|
6
|
+
"actionFailed": "Action failed"
|
|
7
|
+
},
|
|
8
|
+
"errors": {
|
|
9
|
+
"action": {
|
|
10
|
+
"retryFailed": "Retry failed",
|
|
11
|
+
"startFailed": "Failed to start",
|
|
12
|
+
"mergeFailed": "Failed to merge",
|
|
13
|
+
"restartFailed": "Failed to restart"
|
|
14
|
+
},
|
|
15
|
+
"conflict": {
|
|
16
|
+
"title": {
|
|
17
|
+
"dependencies_unmet": "Blocked by dependencies",
|
|
18
|
+
"task_limit_reached": "Concurrency limit reached",
|
|
19
|
+
"tester_infra_unsupported": "Test infrastructure not configured",
|
|
20
|
+
"agent_backend_unconfigured": "Agent backend not configured",
|
|
21
|
+
"run_not_retryable": "Run can’t be retried",
|
|
22
|
+
"no_pr_to_merge": "No PR to merge",
|
|
23
|
+
"github_not_connected": "GitHub not connected",
|
|
24
|
+
"bootstrap_not_retryable": "Bootstrap can’t be retried",
|
|
25
|
+
"bootstrap_reference_missing": "Reference architecture is gone"
|
|
26
|
+
},
|
|
27
|
+
"fallbackMessage": "This action conflicts with the current state.",
|
|
28
|
+
"providersUnconfigured": {
|
|
29
|
+
"title": "No AI provider for this model",
|
|
30
|
+
"body": "No provider is configured for {models}. Add a provider key, connect a subscription, or enable Cloudflare AI to run it.",
|
|
31
|
+
"action": "Configure AI"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
package/nuxt.config.ts
CHANGED
|
@@ -32,7 +32,29 @@ export default defineNuxtConfig({
|
|
|
32
32
|
},
|
|
33
33
|
},
|
|
34
34
|
|
|
35
|
-
modules: ['@nuxt/ui', '@pinia/nuxt', 'pinia-plugin-persistedstate/nuxt'],
|
|
35
|
+
modules: ['@nuxt/ui', '@pinia/nuxt', 'pinia-plugin-persistedstate/nuxt', '@nuxtjs/i18n'],
|
|
36
|
+
|
|
37
|
+
// i18n lives in THIS layer's `i18n/` dir (the v9+ `restructureDir` convention).
|
|
38
|
+
// @nuxtjs/i18n is layer-aware: it scans `i18n/locales/` in every layer of the
|
|
39
|
+
// `extends` chain and DEEP-MERGES them (the consumer layer wins on key conflicts),
|
|
40
|
+
// so a downstream deployment can override/add a locale by dropping its own
|
|
41
|
+
// `i18n/locales/*.json` with no change here. Unlike the css block above, the paths
|
|
42
|
+
// here MUST be bare filenames (not `layerDir`-anchored absolutes): the module
|
|
43
|
+
// resolves `vueI18n`/`langDir` per-layer itself, and an absolute path would break
|
|
44
|
+
// that per-layer resolution.
|
|
45
|
+
i18n: {
|
|
46
|
+
// Pure SPA (`ssr: false`): a single in-app locale, no URL-prefix routing.
|
|
47
|
+
strategy: 'no_prefix',
|
|
48
|
+
defaultLocale: 'en',
|
|
49
|
+
locales: [{ code: 'en', language: 'en-US', file: 'en.json', name: 'English' }],
|
|
50
|
+
vueI18n: 'i18n.config.ts',
|
|
51
|
+
experimental: {
|
|
52
|
+
// Generate types from the `en` messages so an unknown `$t`/`t` key is a `nuxt
|
|
53
|
+
// typecheck` failure — the load-bearing maintainability guardrail given the repo
|
|
54
|
+
// lints with oxlint only (no `@intlify/eslint-plugin-vue-i18n` `no-raw-text`).
|
|
55
|
+
typedOptionsAndMessages: 'default',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
36
58
|
|
|
37
59
|
// This is a Nuxt *layer*. @pinia/nuxt's default `storesDirs` is an ABSOLUTE path
|
|
38
60
|
// resolved against the CONSUMER's srcDir, so when this layer is `extends`ed it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.0",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"app",
|
|
12
|
+
"i18n",
|
|
12
13
|
"nuxt.config.ts"
|
|
13
14
|
],
|
|
14
15
|
"type": "module",
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
},
|
|
19
20
|
"dependencies": {
|
|
20
21
|
"@nuxt/ui": "^4.9.0",
|
|
22
|
+
"@nuxtjs/i18n": "^10.4.0",
|
|
21
23
|
"@pinia/nuxt": "^0.11.3",
|
|
22
24
|
"@toad-contracts/core": "0.3.1",
|
|
23
25
|
"@toad-contracts/frontend-http-client": "0.3.1",
|
|
@@ -32,7 +34,7 @@
|
|
|
32
34
|
"pinia-plugin-persistedstate": "^4.7.1",
|
|
33
35
|
"vue": "^3.5.38",
|
|
34
36
|
"wretch": "^3.0.9",
|
|
35
|
-
"@cat-factory/contracts": "0.
|
|
37
|
+
"@cat-factory/contracts": "0.40.1"
|
|
36
38
|
},
|
|
37
39
|
"devDependencies": {
|
|
38
40
|
"@toad-contracts/testing": "0.3.1",
|