@cat-factory/app 0.71.2 → 0.73.1
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/board/nodes/TaskCard.vue +12 -2
- package/app/components/bootstrap/BootstrapModal.vue +23 -7
- package/app/components/brainstorm/BrainstormWindow.vue +2 -0
- package/app/components/clarity/ClarityReviewWindow.vue +2 -0
- package/app/components/consensus/ConsensusSessionWindow.vue +2 -0
- package/app/components/focus/BlockFocusView.vue +2 -0
- package/app/components/followUp/FollowUpWindow.vue +2 -0
- package/app/components/gates/GateResultView.vue +2 -0
- package/app/components/humanTest/HumanTestWindow.vue +2 -0
- package/app/components/layout/ConnectionStatusBanner.vue +81 -0
- package/app/components/layout/NotificationsInbox.vue +15 -0
- package/app/components/panels/GenericStructuredResultView.vue +2 -0
- package/app/components/panels/InspectorPanel.vue +30 -1
- package/app/components/panels/MergerResultView.vue +267 -0
- package/app/components/panels/StepResultViewHost.vue +4 -0
- package/app/components/panels/inspector/FrontendConfig.vue +111 -1
- package/app/components/panels/inspector/TaskExecution.vue +31 -2
- package/app/components/pipeline/PipelineBuilder.vue +110 -7
- package/app/components/requirements/RequirementsReviewWindow.vue +2 -0
- package/app/components/spec/ServiceSpecWindow.vue +2 -0
- package/app/components/testing/TestReportWindow.vue +91 -0
- package/app/composables/api/preview.ts +20 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useKeyboardShortcuts.ts +10 -2
- package/app/pages/index.vue +6 -4
- package/app/stores/board.spec.ts +58 -1
- package/app/stores/board.ts +59 -15
- package/app/stores/brainstorm.spec.ts +35 -0
- package/app/stores/brainstorm.ts +11 -2
- package/app/stores/clarity.spec.ts +33 -0
- package/app/stores/clarity.ts +11 -2
- package/app/stores/execution.spec.ts +71 -13
- package/app/stores/execution.ts +44 -6
- package/app/stores/pipelines.ts +53 -0
- package/app/stores/preview.ts +94 -0
- package/app/stores/recurringPipelines.ts +5 -1
- package/app/stores/requirements.spec.ts +21 -0
- package/app/stores/requirements.ts +11 -2
- package/app/stores/workspace.ts +1 -1
- package/app/types/domain.ts +2 -0
- package/app/utils/catalog.ts +12 -0
- package/i18n/locales/en.json +94 -5
- package/i18n/locales/es.json +94 -5
- package/i18n/locales/fr.json +94 -5
- package/i18n/locales/he.json +94 -5
- package/i18n/locales/ja.json +94 -5
- package/i18n/locales/pl.json +94 -5
- package/i18n/locales/tr.json +94 -5
- package/i18n/locales/uk.json +94 -5
- package/package.json +2 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed } from 'vue'
|
|
2
|
+
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
|
3
3
|
import type {
|
|
4
4
|
Block,
|
|
5
5
|
FrontendBackendBinding,
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
FrontendEnvInjection,
|
|
8
8
|
FrontendPackageManager,
|
|
9
9
|
FrontendServeMode,
|
|
10
|
+
PreviewStatus,
|
|
10
11
|
} from '~/types/domain'
|
|
11
12
|
|
|
12
13
|
// Frontend-frame (`type: 'frontend'`) configuration: how to build, serve, and mock this
|
|
@@ -19,6 +20,7 @@ const props = defineProps<{ block: Block }>()
|
|
|
19
20
|
|
|
20
21
|
const board = useBoardStore()
|
|
21
22
|
const auth = useAuthStore()
|
|
23
|
+
const preview = usePreviewStore()
|
|
22
24
|
const { t } = useI18n()
|
|
23
25
|
|
|
24
26
|
// A browsable preview needs a long-lived host serve, so it is a local/node runtime capability
|
|
@@ -94,6 +96,39 @@ function addBinding() {
|
|
|
94
96
|
function removeBinding(index: number) {
|
|
95
97
|
save({ backendBindings: bindings.value.filter((_, i) => i !== index) })
|
|
96
98
|
}
|
|
99
|
+
|
|
100
|
+
// ---- Browsable preview (live runtime state, distinct from the persisted toggle) ----------
|
|
101
|
+
// The live preview is a separate resource fetched from the preview endpoints; the store keys it
|
|
102
|
+
// by frame id and self-polls while it is `starting`. Only relevant on a preview-capable runtime
|
|
103
|
+
// with the toggle on.
|
|
104
|
+
const previewActive = computed(() => previewSupported.value && config.value.previewEnabled === true)
|
|
105
|
+
const previewState = computed(() => preview.byFrame[props.block.id])
|
|
106
|
+
const previewStatus = computed<PreviewStatus>(() => previewState.value?.status ?? 'stopped')
|
|
107
|
+
const previewBusy = computed(() => preview.busy[props.block.id] === true)
|
|
108
|
+
// A start/stop request error (e.g. an unsupported runtime 503s) — shown inline; distinct from a
|
|
109
|
+
// `failed` preview (a build that came up then broke), which carries `previewState.error`.
|
|
110
|
+
const previewRequestError = computed(() => preview.requestError[props.block.id])
|
|
111
|
+
|
|
112
|
+
// Exhaustive status → catalog key (tier-2 guard for the runtime-built lookup): adding a
|
|
113
|
+
// PreviewStatus value without a label trips the typecheck on this map.
|
|
114
|
+
const PREVIEW_STATUS_KEYS: Record<PreviewStatus, string> = {
|
|
115
|
+
starting: 'inspector.frontendConfig.preview.status.starting',
|
|
116
|
+
ready: 'inspector.frontendConfig.preview.status.ready',
|
|
117
|
+
failed: 'inspector.frontendConfig.preview.status.failed',
|
|
118
|
+
stopped: 'inspector.frontendConfig.preview.status.stopped',
|
|
119
|
+
}
|
|
120
|
+
const previewStatusLabel = computed(() => t(PREVIEW_STATUS_KEYS[previewStatus.value]))
|
|
121
|
+
|
|
122
|
+
// Load the current state when the preview surface becomes relevant (mount + toggling it on, or
|
|
123
|
+
// selecting a different frontend frame), so the inspector reflects an already-running preview.
|
|
124
|
+
function refreshPreview() {
|
|
125
|
+
if (previewActive.value) void preview.refresh(props.block.id)
|
|
126
|
+
}
|
|
127
|
+
onMounted(refreshPreview)
|
|
128
|
+
watch(() => [previewActive.value, props.block.id], refreshPreview)
|
|
129
|
+
// Stop the store's self-poll for this frame when the inspector closes, so a `starting` preview
|
|
130
|
+
// doesn't keep polling in the background after the panel is gone.
|
|
131
|
+
onUnmounted(() => preview.stopPolling(props.block.id))
|
|
97
132
|
</script>
|
|
98
133
|
|
|
99
134
|
<template>
|
|
@@ -359,6 +394,81 @@ function removeBinding(index: number) {
|
|
|
359
394
|
: t('inspector.frontendConfig.previewUnsupported')
|
|
360
395
|
}}
|
|
361
396
|
</p>
|
|
397
|
+
|
|
398
|
+
<!-- Live preview control: start / open (clickable URL) / stop, reflecting the runtime state. -->
|
|
399
|
+
<div v-if="previewActive" class="mt-2 space-y-1.5" data-testid="preview-panel">
|
|
400
|
+
<div class="flex items-center gap-2">
|
|
401
|
+
<span
|
|
402
|
+
class="text-[11px] font-medium"
|
|
403
|
+
:class="{
|
|
404
|
+
'text-emerald-400': previewStatus === 'ready',
|
|
405
|
+
'text-amber-400': previewStatus === 'starting',
|
|
406
|
+
'text-rose-400': previewStatus === 'failed',
|
|
407
|
+
'text-slate-400': previewStatus === 'stopped',
|
|
408
|
+
}"
|
|
409
|
+
data-testid="preview-status"
|
|
410
|
+
>
|
|
411
|
+
{{ previewStatusLabel }}
|
|
412
|
+
</span>
|
|
413
|
+
|
|
414
|
+
<UButton
|
|
415
|
+
v-if="previewStatus === 'ready' && previewState?.url"
|
|
416
|
+
:to="previewState.url"
|
|
417
|
+
target="_blank"
|
|
418
|
+
rel="noopener"
|
|
419
|
+
size="xs"
|
|
420
|
+
variant="soft"
|
|
421
|
+
color="primary"
|
|
422
|
+
trailing-icon="i-lucide-external-link"
|
|
423
|
+
data-testid="preview-url"
|
|
424
|
+
>
|
|
425
|
+
{{ t('inspector.frontendConfig.preview.open') }}
|
|
426
|
+
</UButton>
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
<div class="flex flex-wrap gap-1">
|
|
430
|
+
<UButton
|
|
431
|
+
v-if="previewStatus === 'stopped' || previewStatus === 'failed'"
|
|
432
|
+
size="xs"
|
|
433
|
+
variant="soft"
|
|
434
|
+
color="primary"
|
|
435
|
+
icon="i-lucide-play"
|
|
436
|
+
:loading="previewBusy"
|
|
437
|
+
data-testid="preview-start"
|
|
438
|
+
@click="preview.start(props.block.id)"
|
|
439
|
+
>
|
|
440
|
+
{{ t('inspector.frontendConfig.preview.start') }}
|
|
441
|
+
</UButton>
|
|
442
|
+
<UButton
|
|
443
|
+
v-if="previewStatus === 'ready' || previewStatus === 'starting'"
|
|
444
|
+
size="xs"
|
|
445
|
+
variant="ghost"
|
|
446
|
+
color="neutral"
|
|
447
|
+
icon="i-lucide-square"
|
|
448
|
+
:loading="previewBusy"
|
|
449
|
+
data-testid="preview-stop"
|
|
450
|
+
@click="preview.stop(props.block.id)"
|
|
451
|
+
>
|
|
452
|
+
{{ t('inspector.frontendConfig.preview.stop') }}
|
|
453
|
+
</UButton>
|
|
454
|
+
</div>
|
|
455
|
+
|
|
456
|
+
<p
|
|
457
|
+
v-if="previewStatus === 'failed' && previewState?.error"
|
|
458
|
+
class="text-[11px] leading-snug text-rose-400"
|
|
459
|
+
data-testid="preview-error"
|
|
460
|
+
>
|
|
461
|
+
{{ previewState.error }}
|
|
462
|
+
</p>
|
|
463
|
+
|
|
464
|
+
<p
|
|
465
|
+
v-if="previewRequestError"
|
|
466
|
+
class="text-[11px] leading-snug text-rose-400"
|
|
467
|
+
data-testid="preview-request-error"
|
|
468
|
+
>
|
|
469
|
+
{{ previewRequestError }}
|
|
470
|
+
</p>
|
|
471
|
+
</div>
|
|
362
472
|
</div>
|
|
363
473
|
</div>
|
|
364
474
|
</template>
|
|
@@ -18,6 +18,7 @@ const ui = useUiStore()
|
|
|
18
18
|
const models = useModelsStore()
|
|
19
19
|
const reviews = useReviewStage()
|
|
20
20
|
const { t, te } = useI18n()
|
|
21
|
+
const { confirm } = useConfirm()
|
|
21
22
|
|
|
22
23
|
// The async stage this task's iterative reviewer gate (requirements-review / clarity-review)
|
|
23
24
|
// is mid-cycle in (folding the answers, then re-reviewing), or null. While set, the gate is
|
|
@@ -99,7 +100,12 @@ function labelForStep(s: {
|
|
|
99
100
|
// container is still cold-booting → "Spinning up"; up with a known phase → the phase
|
|
100
101
|
// label ("Agent running" / "Preparing workspace"), so a finished cold-boot no longer
|
|
101
102
|
// collapses into a blank "Working". A failed run's mid-flight step isn't booting.
|
|
102
|
-
|
|
103
|
+
//
|
|
104
|
+
// Only while the step is STILL RUNNING, though: the run's one shared container is kept
|
|
105
|
+
// alive until the pipeline's final step, so a step that has already finished (e.g. the
|
|
106
|
+
// merger, which resolves + advances to a trailing gate) would otherwise keep reading the
|
|
107
|
+
// stale "Agent running" phase even though its state is `done`. A done step reads "Done".
|
|
108
|
+
if (!runFailed.value && s.state !== 'done') {
|
|
103
109
|
if (s.container?.status === 'starting') return t('inspector.execution.spinningUp')
|
|
104
110
|
if (s.container?.status === 'up') {
|
|
105
111
|
const label = containerPhaseLabel(s.container.phase, { t, te })
|
|
@@ -141,6 +147,16 @@ async function stopRun() {
|
|
|
141
147
|
const resetting = ref(false)
|
|
142
148
|
async function resetRun() {
|
|
143
149
|
if (resetting.value) return
|
|
150
|
+
// Destructive: discards the run and returns the task to planned — gate it behind a confirm,
|
|
151
|
+
// matching the confirm-then-mutate contract the board delete path uses.
|
|
152
|
+
const ok = await confirm({
|
|
153
|
+
title: t('inspector.execution.resetConfirm.title'),
|
|
154
|
+
description: t('inspector.execution.resetConfirm.body'),
|
|
155
|
+
variant: 'destructive',
|
|
156
|
+
confirmLabel: t('inspector.execution.resetConfirm.confirm'),
|
|
157
|
+
icon: 'i-lucide-trash-2',
|
|
158
|
+
})
|
|
159
|
+
if (!ok) return
|
|
144
160
|
resetting.value = true
|
|
145
161
|
try {
|
|
146
162
|
await execution.cancel(props.block.id)
|
|
@@ -148,6 +164,19 @@ async function resetRun() {
|
|
|
148
164
|
resetting.value = false
|
|
149
165
|
}
|
|
150
166
|
}
|
|
167
|
+
|
|
168
|
+
// Merging a PR is consequential and effectively irreversible — confirm first. `execution.mergePr`
|
|
169
|
+
// surfaces its own error toast, so no catch is needed here.
|
|
170
|
+
async function mergePr() {
|
|
171
|
+
const ok = await confirm({
|
|
172
|
+
title: t('inspector.execution.mergeConfirm.title'),
|
|
173
|
+
description: t('inspector.execution.mergeConfirm.body'),
|
|
174
|
+
confirmLabel: t('inspector.execution.mergeConfirm.confirm'),
|
|
175
|
+
icon: 'i-lucide-git-merge',
|
|
176
|
+
})
|
|
177
|
+
if (!ok) return
|
|
178
|
+
await execution.mergePr(props.block.id)
|
|
179
|
+
}
|
|
151
180
|
</script>
|
|
152
181
|
|
|
153
182
|
<template>
|
|
@@ -431,7 +460,7 @@ async function resetRun() {
|
|
|
431
460
|
size="sm"
|
|
432
461
|
icon="i-lucide-git-merge"
|
|
433
462
|
block
|
|
434
|
-
@click="
|
|
463
|
+
@click="mergePr"
|
|
435
464
|
>
|
|
436
465
|
{{ t('inspector.execution.mergePr') }}
|
|
437
466
|
</UButton>
|
|
@@ -3,7 +3,12 @@ import { computed, ref, watch } from 'vue'
|
|
|
3
3
|
import type { AgentKind, Pipeline } from '~/types/domain'
|
|
4
4
|
import AgentPalette from '~/components/palettes/AgentPalette.vue'
|
|
5
5
|
import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
agentKindMeta,
|
|
8
|
+
companionForProducer,
|
|
9
|
+
isConsensusEligibleKind,
|
|
10
|
+
isTesterKind,
|
|
11
|
+
} from '~/utils/catalog'
|
|
7
12
|
import type { ConsensusStrategy } from '~/types/consensus'
|
|
8
13
|
|
|
9
14
|
type DraftUnit = { index: number; kind: AgentKind; companionIndex: number | null }
|
|
@@ -146,15 +151,18 @@ function companionLabel(kind: string): string | null {
|
|
|
146
151
|
}
|
|
147
152
|
|
|
148
153
|
// Surfaced as an inline hint: a gated step needs a task-estimator before it (mirrors the
|
|
149
|
-
// backend validation, which also rejects the save/start).
|
|
154
|
+
// backend validation, which also rejects the save/start). Both the companion estimate gate
|
|
155
|
+
// (`draftGating`) and the Tester QC companion's estimate gate (`draftTesterQuality[i].gating`)
|
|
156
|
+
// count — either without a preceding estimator is rejected on save.
|
|
150
157
|
const gatingNeedsEstimator = computed(() => {
|
|
151
158
|
const kinds = pipelines.draft
|
|
159
|
+
const hasEstimatorBefore = (i: number) =>
|
|
160
|
+
kinds.slice(0, i).some((k, j) => k === 'task-estimator' && pipelines.draftEnabled[j] !== false)
|
|
152
161
|
for (let i = 0; i < kinds.length; i++) {
|
|
153
|
-
if (
|
|
154
|
-
const
|
|
155
|
-
.
|
|
156
|
-
|
|
157
|
-
if (!hasEstimator) return true
|
|
162
|
+
if (pipelines.draftEnabled[i] === false) continue
|
|
163
|
+
const gated =
|
|
164
|
+
pipelines.draftGating[i]?.enabled || pipelines.draftTesterQuality[i]?.gating?.enabled
|
|
165
|
+
if (gated && !hasEstimatorBefore(i)) return true
|
|
158
166
|
}
|
|
159
167
|
return false
|
|
160
168
|
})
|
|
@@ -434,6 +442,30 @@ async function clone(p: Pipeline) {
|
|
|
434
442
|
"
|
|
435
443
|
@click="pipelines.toggleDraftFollowUps(unit.index)"
|
|
436
444
|
/>
|
|
445
|
+
<!-- Test quality-control companion: audits the Tester's report for coverage
|
|
446
|
+
before the greenlight/fixer decision and loops the Tester on gaps (Tester
|
|
447
|
+
steps only). Enabled by default. -->
|
|
448
|
+
<UButton
|
|
449
|
+
v-if="isTesterKind(unit.kind)"
|
|
450
|
+
:icon="
|
|
451
|
+
pipelines.draftTesterQuality[unit.index]?.enabled === false
|
|
452
|
+
? 'i-lucide-shield-off'
|
|
453
|
+
: 'i-lucide-shield-check'
|
|
454
|
+
"
|
|
455
|
+
:color="
|
|
456
|
+
pipelines.draftTesterQuality[unit.index]?.enabled === false
|
|
457
|
+
? 'neutral'
|
|
458
|
+
: 'secondary'
|
|
459
|
+
"
|
|
460
|
+
variant="ghost"
|
|
461
|
+
size="xs"
|
|
462
|
+
:title="
|
|
463
|
+
pipelines.draftTesterQuality[unit.index]?.enabled === false
|
|
464
|
+
? t('pipeline.builder.testerQualityEnableTooltip')
|
|
465
|
+
: t('pipeline.builder.testerQualityDisableTooltip')
|
|
466
|
+
"
|
|
467
|
+
@click="pipelines.toggleDraftTesterQuality(unit.index)"
|
|
468
|
+
/>
|
|
437
469
|
<UButton
|
|
438
470
|
icon="i-lucide-chevron-up"
|
|
439
471
|
color="neutral"
|
|
@@ -642,6 +674,77 @@ async function clone(p: Pipeline) {
|
|
|
642
674
|
</template>
|
|
643
675
|
</div>
|
|
644
676
|
</div>
|
|
677
|
+
|
|
678
|
+
<!-- Test quality-control companion config (shown when QC is enabled on a Tester
|
|
679
|
+
step): an optional estimate gate so only heavy tasks get the coverage audit. -->
|
|
680
|
+
<div
|
|
681
|
+
v-if="
|
|
682
|
+
isTesterKind(unit.kind) &&
|
|
683
|
+
pipelines.draftTesterQuality[unit.index]?.enabled !== false
|
|
684
|
+
"
|
|
685
|
+
class="ms-6 space-y-2 rounded-md border border-sky-800/40 bg-sky-950/20 p-2 text-xs"
|
|
686
|
+
>
|
|
687
|
+
<div class="flex items-center gap-1.5">
|
|
688
|
+
<UIcon name="i-lucide-shield-check" class="h-3.5 w-3.5 text-sky-400" />
|
|
689
|
+
<span class="min-w-0 flex-1 truncate text-slate-200">
|
|
690
|
+
{{ t('pipeline.builder.testerQualityLabel') }}
|
|
691
|
+
</span>
|
|
692
|
+
<UButton
|
|
693
|
+
:icon="
|
|
694
|
+
pipelines.draftTesterQuality[unit.index]?.gating?.enabled
|
|
695
|
+
? 'i-lucide-toggle-right'
|
|
696
|
+
: 'i-lucide-toggle-left'
|
|
697
|
+
"
|
|
698
|
+
:color="
|
|
699
|
+
pipelines.draftTesterQuality[unit.index]?.gating?.enabled
|
|
700
|
+
? 'success'
|
|
701
|
+
: 'neutral'
|
|
702
|
+
"
|
|
703
|
+
variant="ghost"
|
|
704
|
+
size="xs"
|
|
705
|
+
:label="t('pipeline.builder.gateOnEstimate')"
|
|
706
|
+
:title="t('pipeline.builder.testerQualityGateTooltip')"
|
|
707
|
+
@click="pipelines.toggleDraftTesterQualityGating(unit.index)"
|
|
708
|
+
/>
|
|
709
|
+
</div>
|
|
710
|
+
<div
|
|
711
|
+
v-if="pipelines.draftTesterQuality[unit.index]?.gating?.enabled"
|
|
712
|
+
class="flex flex-wrap items-center gap-2 border-t border-slate-800 pt-2"
|
|
713
|
+
>
|
|
714
|
+
<span class="text-[10px] text-slate-500">{{
|
|
715
|
+
t('pipeline.builder.runWhenAny')
|
|
716
|
+
}}</span>
|
|
717
|
+
<label class="text-slate-400">{{
|
|
718
|
+
t('pipeline.builder.complexityThreshold')
|
|
719
|
+
}}</label>
|
|
720
|
+
<input
|
|
721
|
+
v-model.number="pipelines.draftTesterQuality[unit.index]!.gating!.minComplexity"
|
|
722
|
+
type="number"
|
|
723
|
+
min="0"
|
|
724
|
+
max="1"
|
|
725
|
+
step="0.1"
|
|
726
|
+
class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
|
|
727
|
+
/>
|
|
728
|
+
<label class="text-slate-400">{{ t('pipeline.builder.riskThreshold') }}</label>
|
|
729
|
+
<input
|
|
730
|
+
v-model.number="pipelines.draftTesterQuality[unit.index]!.gating!.minRisk"
|
|
731
|
+
type="number"
|
|
732
|
+
min="0"
|
|
733
|
+
max="1"
|
|
734
|
+
step="0.1"
|
|
735
|
+
class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
|
|
736
|
+
/>
|
|
737
|
+
<label class="text-slate-400">{{ t('pipeline.builder.impactThreshold') }}</label>
|
|
738
|
+
<input
|
|
739
|
+
v-model.number="pipelines.draftTesterQuality[unit.index]!.gating!.minImpact"
|
|
740
|
+
type="number"
|
|
741
|
+
min="0"
|
|
742
|
+
max="1"
|
|
743
|
+
step="0.1"
|
|
744
|
+
class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
|
|
745
|
+
/>
|
|
746
|
+
</div>
|
|
747
|
+
</div>
|
|
645
748
|
</li>
|
|
646
749
|
</ol>
|
|
647
750
|
</div>
|
|
@@ -414,6 +414,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
414
414
|
>
|
|
415
415
|
<div
|
|
416
416
|
class="flex max-h-[90dvh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
|
|
417
|
+
role="dialog"
|
|
418
|
+
aria-modal="true"
|
|
417
419
|
>
|
|
418
420
|
<!-- header -->
|
|
419
421
|
<header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
|
|
@@ -123,6 +123,8 @@ function kindLabel(item: RequirementItem): string {
|
|
|
123
123
|
>
|
|
124
124
|
<div
|
|
125
125
|
class="flex max-h-[90dvh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
|
|
126
|
+
role="dialog"
|
|
127
|
+
aria-modal="true"
|
|
126
128
|
>
|
|
127
129
|
<!-- header -->
|
|
128
130
|
<header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
|
|
@@ -47,6 +47,12 @@ const testState = computed(() => step.value?.test ?? null)
|
|
|
47
47
|
// ended), newest first, so the otherwise-opaque fixer sub-jobs have a surface here.
|
|
48
48
|
const fixerAttempts = computed(() => [...(testState.value?.attemptLog ?? [])].reverse())
|
|
49
49
|
|
|
50
|
+
// Test quality-control companion state: the coverage audit the QC reviewer ran on each report
|
|
51
|
+
// (before the greenlight/fixer decision) plus its loop budget. Verdicts newest-first, so the
|
|
52
|
+
// most recent audit leads. Absent when the companion is disabled or never ran.
|
|
53
|
+
const quality = computed(() => step.value?.testerQuality ?? null)
|
|
54
|
+
const qualityVerdicts = computed(() => [...(quality.value?.verdicts ?? [])].reverse())
|
|
55
|
+
|
|
50
56
|
// Infrastructure observability — parity with the Coder's generic step detail, so the
|
|
51
57
|
// Tester window surfaces WHERE its job runs (the container lifecycle: spinning up /
|
|
52
58
|
// running phase / id+url / errored), the ephemeral environment it tests against, and the
|
|
@@ -551,6 +557,91 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
|
|
|
551
557
|
</ol>
|
|
552
558
|
</section>
|
|
553
559
|
|
|
560
|
+
<!-- Test quality-control companion: the coverage audit(s) the QC reviewer ran on
|
|
561
|
+
the report before the greenlight/fixer decision. Each verdict says whether the
|
|
562
|
+
report adequately covered what the task needed tested, with the gaps that
|
|
563
|
+
looped the Tester for a focused additional pass. -->
|
|
564
|
+
<section
|
|
565
|
+
v-if="quality && qualityVerdicts.length"
|
|
566
|
+
data-testid="tester-quality"
|
|
567
|
+
class="space-y-2"
|
|
568
|
+
>
|
|
569
|
+
<div class="flex items-center gap-2">
|
|
570
|
+
<h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
571
|
+
{{ t('testing.quality.heading') }}
|
|
572
|
+
</h3>
|
|
573
|
+
<span
|
|
574
|
+
v-if="quality.attempts"
|
|
575
|
+
class="text-[11px] text-slate-400"
|
|
576
|
+
:title="t('testing.quality.reruns')"
|
|
577
|
+
>
|
|
578
|
+
{{
|
|
579
|
+
t('testing.quality.rerunCount', {
|
|
580
|
+
attempts: quality.attempts,
|
|
581
|
+
max: quality.maxAttempts,
|
|
582
|
+
})
|
|
583
|
+
}}
|
|
584
|
+
</span>
|
|
585
|
+
<UBadge
|
|
586
|
+
v-if="quality.exceeded"
|
|
587
|
+
color="warning"
|
|
588
|
+
variant="subtle"
|
|
589
|
+
size="sm"
|
|
590
|
+
data-testid="tester-quality-exceeded"
|
|
591
|
+
>
|
|
592
|
+
{{ t('testing.quality.exceeded') }}
|
|
593
|
+
</UBadge>
|
|
594
|
+
</div>
|
|
595
|
+
<ol class="space-y-2">
|
|
596
|
+
<li
|
|
597
|
+
v-for="(vd, vi) in qualityVerdicts"
|
|
598
|
+
:key="`qc${vi}`"
|
|
599
|
+
data-testid="tester-quality-verdict"
|
|
600
|
+
class="rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2"
|
|
601
|
+
>
|
|
602
|
+
<div class="flex items-center gap-2">
|
|
603
|
+
<UIcon
|
|
604
|
+
:name="vd.adequate ? 'i-lucide-shield-check' : 'i-lucide-shield-alert'"
|
|
605
|
+
class="h-3.5 w-3.5 shrink-0"
|
|
606
|
+
:class="vd.adequate ? 'text-emerald-400' : 'text-amber-300'"
|
|
607
|
+
/>
|
|
608
|
+
<span class="text-[13px] font-medium text-slate-200">
|
|
609
|
+
{{
|
|
610
|
+
vd.adequate
|
|
611
|
+
? t('testing.quality.adequate')
|
|
612
|
+
: t('testing.quality.inadequate')
|
|
613
|
+
}}
|
|
614
|
+
</span>
|
|
615
|
+
<span v-if="vd.model" class="ms-auto font-mono text-[10px] text-slate-500">{{
|
|
616
|
+
vd.model
|
|
617
|
+
}}</span>
|
|
618
|
+
<span class="text-[11px] text-slate-500" :class="{ 'ms-auto': !vd.model }">{{
|
|
619
|
+
d(new Date(vd.at), 'short')
|
|
620
|
+
}}</span>
|
|
621
|
+
</div>
|
|
622
|
+
<p v-if="vd.feedback" class="mt-1 text-[12px] leading-snug text-slate-400">
|
|
623
|
+
{{ vd.feedback }}
|
|
624
|
+
</p>
|
|
625
|
+
<div v-if="vd.gaps.length" class="mt-1.5">
|
|
626
|
+
<p class="text-[11px] text-slate-500">{{ t('testing.quality.gaps') }}</p>
|
|
627
|
+
<ul class="mt-1 space-y-0.5">
|
|
628
|
+
<li
|
|
629
|
+
v-for="(gap, gi) in vd.gaps"
|
|
630
|
+
:key="`qc${vi}-g${gi}`"
|
|
631
|
+
class="flex items-start gap-1.5 text-[12px] text-slate-300"
|
|
632
|
+
>
|
|
633
|
+
<UIcon
|
|
634
|
+
name="i-lucide-dot"
|
|
635
|
+
class="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-400"
|
|
636
|
+
/>
|
|
637
|
+
<span>{{ gap }}</span>
|
|
638
|
+
</li>
|
|
639
|
+
</ul>
|
|
640
|
+
</div>
|
|
641
|
+
</li>
|
|
642
|
+
</ol>
|
|
643
|
+
</section>
|
|
644
|
+
|
|
554
645
|
<div
|
|
555
646
|
v-if="!report"
|
|
556
647
|
class="flex flex-col items-center justify-center gap-2 py-12 text-center text-slate-400"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getPreviewContract,
|
|
3
|
+
startPreviewContract,
|
|
4
|
+
stopPreviewContract,
|
|
5
|
+
} from '@cat-factory/contracts'
|
|
6
|
+
import type { ApiContext } from './context'
|
|
7
|
+
|
|
8
|
+
/** Browsable frontend preview: start / poll / stop a served preview for a `frontend` frame. */
|
|
9
|
+
export function previewApi({ send, ws }: ApiContext) {
|
|
10
|
+
return {
|
|
11
|
+
getPreview: (workspaceId: string, frameId: string) =>
|
|
12
|
+
send(getPreviewContract, { pathPrefix: ws(workspaceId), pathParams: { frameId } }),
|
|
13
|
+
|
|
14
|
+
startPreview: (workspaceId: string, frameId: string) =>
|
|
15
|
+
send(startPreviewContract, { pathPrefix: ws(workspaceId), pathParams: { frameId } }),
|
|
16
|
+
|
|
17
|
+
stopPreview: (workspaceId: string, frameId: string) =>
|
|
18
|
+
send(stopPreviewContract, { pathPrefix: ws(workspaceId), pathParams: { frameId } }),
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -22,6 +22,7 @@ import { presetsApi } from './api/presets'
|
|
|
22
22
|
import { providerConnectionsApi } from './api/providerConnections'
|
|
23
23
|
import { provisioningLogsApi } from './api/provisioningLogs'
|
|
24
24
|
import { recurringApi } from './api/recurring'
|
|
25
|
+
import { previewApi } from './api/preview'
|
|
25
26
|
import { releaseHealthApi } from './api/releaseHealth'
|
|
26
27
|
import { sandboxApi } from './api/sandbox'
|
|
27
28
|
import { reviewsApi } from './api/reviews'
|
|
@@ -111,6 +112,7 @@ export function useApi() {
|
|
|
111
112
|
...infraHandlersApi(ctx),
|
|
112
113
|
...provisioningLogsApi(ctx),
|
|
113
114
|
...releaseHealthApi(ctx),
|
|
115
|
+
...previewApi(ctx),
|
|
114
116
|
...recurringApi(ctx),
|
|
115
117
|
...sandboxApi(ctx),
|
|
116
118
|
...githubApi(ctx),
|
|
@@ -20,9 +20,17 @@ export function useKeyboardShortcuts(): void {
|
|
|
20
20
|
const board = useBoardStore()
|
|
21
21
|
const { deleteBlock } = useBlockDeletion()
|
|
22
22
|
|
|
23
|
-
/** A modal
|
|
23
|
+
/** A modal / full-screen window is on screen — let it own the keyboard; don't run global
|
|
24
|
+
* shortcuts (else e.g. Delete would delete the selected block hidden BEHIND the window). The
|
|
25
|
+
* hand-rolled result-view + focus windows now carry `role="dialog"`, so the DOM check catches
|
|
26
|
+
* them; the store flags are belt-and-suspenders for the same windows. */
|
|
24
27
|
function modalOpen(): boolean {
|
|
25
|
-
return
|
|
28
|
+
return (
|
|
29
|
+
ui.commandBarOpen ||
|
|
30
|
+
!!ui.resultView ||
|
|
31
|
+
!!ui.focusBlockId ||
|
|
32
|
+
!!document.querySelector('[role="dialog"]')
|
|
33
|
+
)
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
/** The event originates from a text field, so printable/Delete keys are edits, not shortcuts. */
|
package/app/pages/index.vue
CHANGED
|
@@ -3,6 +3,7 @@ import BoardCanvas from '~/components/board/BoardCanvas.vue'
|
|
|
3
3
|
import SideBar from '~/components/layout/SideBar.vue'
|
|
4
4
|
import BoardToolbar from '~/components/layout/BoardToolbar.vue'
|
|
5
5
|
import SpendWarningBanner from '~/components/layout/SpendWarningBanner.vue'
|
|
6
|
+
import ConnectionStatusBanner from '~/components/layout/ConnectionStatusBanner.vue'
|
|
6
7
|
import TranslationWarningBanner from '~/components/layout/TranslationWarningBanner.vue'
|
|
7
8
|
import GitHubPatBanner from '~/components/layout/GitHubPatBanner.vue'
|
|
8
9
|
import AiProvidersBanner from '~/components/layout/AiProvidersBanner.vue'
|
|
@@ -275,7 +276,7 @@ watch(
|
|
|
275
276
|
class="m-auto flex flex-col items-center gap-3 text-slate-400"
|
|
276
277
|
>
|
|
277
278
|
<UIcon name="i-lucide-loader" class="h-8 w-8 animate-spin" />
|
|
278
|
-
<span class="text-sm">
|
|
279
|
+
<span class="text-sm">{{ $t('app.loading') }}</span>
|
|
279
280
|
</div>
|
|
280
281
|
|
|
281
282
|
<!-- App enabled but not installed on this workspace: hard onboarding gate. -->
|
|
@@ -312,6 +313,7 @@ watch(
|
|
|
312
313
|
/>
|
|
313
314
|
<BoardToolbar />
|
|
314
315
|
<SpendWarningBanner />
|
|
316
|
+
<ConnectionStatusBanner :connected="streamConnected" />
|
|
315
317
|
<InspectorPanel />
|
|
316
318
|
<!-- Code-split focus view. The fade lives here (not inside the component) so the
|
|
317
319
|
leave animation still plays when `focusBlockId` clears and the v-if unmounts
|
|
@@ -368,17 +370,17 @@ watch(
|
|
|
368
370
|
<!-- Backend unreachable / bootstrap failed -->
|
|
369
371
|
<div v-else-if="workspace.error" class="m-auto max-w-md p-8 text-center">
|
|
370
372
|
<UIcon name="i-lucide-plug-zap" class="mx-auto mb-3 h-10 w-10 text-amber-400" />
|
|
371
|
-
<h1 class="mb-1 text-lg font-semibold">
|
|
373
|
+
<h1 class="mb-1 text-lg font-semibold">{{ $t('app.backendUnreachable') }}</h1>
|
|
372
374
|
<p class="mb-4 text-sm text-slate-400">{{ workspace.error }}</p>
|
|
373
375
|
<UButton color="primary" icon="i-lucide-rotate-ccw" @click="workspace.init()">
|
|
374
|
-
|
|
376
|
+
{{ $t('common.retry') }}
|
|
375
377
|
</UButton>
|
|
376
378
|
</div>
|
|
377
379
|
|
|
378
380
|
<!-- Initial load -->
|
|
379
381
|
<div v-else class="m-auto flex flex-col items-center gap-3 text-slate-400">
|
|
380
382
|
<UIcon name="i-lucide-loader" class="h-8 w-8 animate-spin" />
|
|
381
|
-
<span class="text-sm">
|
|
383
|
+
<span class="text-sm">{{ $t('app.loadingBoard') }}</span>
|
|
382
384
|
</div>
|
|
383
385
|
</div>
|
|
384
386
|
</template>
|
package/app/stores/board.spec.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest'
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { setActivePinia, createPinia } from 'pinia'
|
|
2
3
|
import type { Block, BlockStatus } from '~/types/domain'
|
|
3
4
|
import { useBoardStore } from '~/stores/board'
|
|
5
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
6
|
|
|
5
7
|
/** Minimal Block factory — only the fields the read getters care about. */
|
|
6
8
|
function block(id: string, over: Partial<Block> = {}): Block {
|
|
@@ -224,6 +226,22 @@ describe('board store read getters', () => {
|
|
|
224
226
|
expect(() => store.previewMove('missing', { x: 1, y: 1 })).not.toThrow()
|
|
225
227
|
})
|
|
226
228
|
|
|
229
|
+
it('updateBlock restores the patched fields and toasts when the write fails', async () => {
|
|
230
|
+
// Capture the toast the store surfaces on failure. Re-stub before creating the store so it
|
|
231
|
+
// binds this spy (the store resolves `useToast()` once at setup).
|
|
232
|
+
const addSpy = vi.fn()
|
|
233
|
+
vi.stubGlobal('useToast', () => ({ add: addSpy }))
|
|
234
|
+
setActivePinia(createPinia())
|
|
235
|
+
const s = useBoardStore()
|
|
236
|
+
s.hydrate([frame('f1', { title: 'Original', description: 'orig' })])
|
|
237
|
+
// With no active workspace, `requireId()` throws inside updateBlock's try — the same catch
|
|
238
|
+
// that a rejected API write hits — so this exercises the optimistic-rollback + toast path.
|
|
239
|
+
await s.updateBlock('f1', { title: 'Edited', description: 'changed' })
|
|
240
|
+
expect(s.getBlock('f1')?.title).toBe('Original')
|
|
241
|
+
expect(s.getBlock('f1')?.description).toBe('orig')
|
|
242
|
+
expect(addSpy).toHaveBeenCalledWith(expect.objectContaining({ color: 'error' }))
|
|
243
|
+
})
|
|
244
|
+
|
|
227
245
|
it('hydrate replaces and upsert inserts/updates cached blocks', () => {
|
|
228
246
|
store.hydrate([frame('f1')])
|
|
229
247
|
store.upsert(task('t1', 'f1', { title: 'first' }))
|
|
@@ -233,3 +251,42 @@ describe('board store read getters', () => {
|
|
|
233
251
|
expect(store.allTasks).toHaveLength(1)
|
|
234
252
|
})
|
|
235
253
|
})
|
|
254
|
+
|
|
255
|
+
describe('board store optimistic rollback', () => {
|
|
256
|
+
// These instantiate their own store AFTER stubbing the api (the store captures
|
|
257
|
+
// `useApi()` at setup), unlike the read-getter suite above.
|
|
258
|
+
beforeEach(() => {
|
|
259
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('moveBlock restores the pre-drag position when the API rejects', async () => {
|
|
263
|
+
vi.stubGlobal('useApi', () => ({
|
|
264
|
+
moveBlock: () => Promise.reject(new Error('conflict')),
|
|
265
|
+
}))
|
|
266
|
+
const store = useBoardStore()
|
|
267
|
+
store.hydrate([frame('f1'), task('t1', 'f1', { position: { x: 10, y: 20 } })])
|
|
268
|
+
await store.moveBlock('t1', { x: 500, y: 600 })
|
|
269
|
+
expect(store.getBlock('t1')?.position).toEqual({ x: 10, y: 20 })
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('moveBlock keeps the new position on success', async () => {
|
|
273
|
+
vi.stubGlobal('useApi', () => ({
|
|
274
|
+
moveBlock: async () => task('t1', 'f1', { position: { x: 500, y: 600 } }),
|
|
275
|
+
}))
|
|
276
|
+
const store = useBoardStore()
|
|
277
|
+
store.hydrate([frame('f1'), task('t1', 'f1', { position: { x: 10, y: 20 } })])
|
|
278
|
+
await store.moveBlock('t1', { x: 500, y: 600 })
|
|
279
|
+
expect(store.getBlock('t1')?.position).toEqual({ x: 500, y: 600 })
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('updateBlock restores only the patched fields when the API rejects', async () => {
|
|
283
|
+
vi.stubGlobal('useApi', () => ({
|
|
284
|
+
updateBlock: () => Promise.reject(new Error('validation')),
|
|
285
|
+
}))
|
|
286
|
+
const store = useBoardStore()
|
|
287
|
+
store.hydrate([frame('f1'), task('t1', 'f1', { title: 'orig', description: 'keep' })])
|
|
288
|
+
await store.updateBlock('t1', { title: 'renamed' })
|
|
289
|
+
expect(store.getBlock('t1')?.title).toBe('orig')
|
|
290
|
+
expect(store.getBlock('t1')?.description).toBe('keep')
|
|
291
|
+
})
|
|
292
|
+
})
|