@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
|
@@ -14,6 +14,7 @@ const agentRuns = useAgentRunsStore()
|
|
|
14
14
|
const reviews = useReviewStage()
|
|
15
15
|
const toast = useToast()
|
|
16
16
|
const { t } = useI18n()
|
|
17
|
+
const { confirm } = useConfirm()
|
|
17
18
|
|
|
18
19
|
const task = computed<Block | undefined>(() => board.getBlock(props.taskId))
|
|
19
20
|
const statusMeta = computed(() => (task.value ? STATUS_META[task.value.status] : null))
|
|
@@ -106,8 +107,17 @@ function review() {
|
|
|
106
107
|
ui.focus(props.taskId)
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
function merge() {
|
|
110
|
-
|
|
110
|
+
async function merge() {
|
|
111
|
+
// Merging a PR into its base is consequential and effectively irreversible — gate it behind a
|
|
112
|
+
// confirm (plain, not the destructive shape). `execution.mergePr` surfaces its own error toast.
|
|
113
|
+
const ok = await confirm({
|
|
114
|
+
title: t('board.task.mergeConfirm.title'),
|
|
115
|
+
description: t('board.task.mergeConfirm.body'),
|
|
116
|
+
confirmLabel: t('board.task.mergeConfirm.confirm'),
|
|
117
|
+
icon: 'i-lucide-git-merge',
|
|
118
|
+
})
|
|
119
|
+
if (!ok) return
|
|
120
|
+
await execution.mergePr(props.taskId)
|
|
111
121
|
}
|
|
112
122
|
|
|
113
123
|
// A `blocked` task is waiting on a human for one of two reasons — an agent-raised
|
|
@@ -444,7 +444,11 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
|
|
|
444
444
|
>
|
|
445
445
|
<div class="space-y-2">
|
|
446
446
|
<div class="flex items-center gap-2">
|
|
447
|
-
<UInput
|
|
447
|
+
<UInput
|
|
448
|
+
v-model="repoName"
|
|
449
|
+
:placeholder="t('bootstrap.targetRepo.namePlaceholder')"
|
|
450
|
+
class="w-full"
|
|
451
|
+
/>
|
|
448
452
|
<UButton
|
|
449
453
|
color="neutral"
|
|
450
454
|
variant="subtle"
|
|
@@ -493,7 +497,7 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
|
|
|
493
497
|
>
|
|
494
498
|
<UInput
|
|
495
499
|
v-model="description"
|
|
496
|
-
placeholder="
|
|
500
|
+
:placeholder="t('bootstrap.description.placeholder')"
|
|
497
501
|
class="w-full"
|
|
498
502
|
/>
|
|
499
503
|
</UFormField>
|
|
@@ -640,7 +644,7 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
|
|
|
640
644
|
<USelect
|
|
641
645
|
v-model="archRepoSlug"
|
|
642
646
|
:items="repoOptions"
|
|
643
|
-
placeholder="
|
|
647
|
+
:placeholder="t('bootstrap.arch.pickRepo.placeholder')"
|
|
644
648
|
class="w-full"
|
|
645
649
|
/>
|
|
646
650
|
</UFormField>
|
|
@@ -650,14 +654,26 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
|
|
|
650
654
|
:description="t('bootstrap.arch.name.description')"
|
|
651
655
|
required
|
|
652
656
|
>
|
|
653
|
-
<UInput
|
|
657
|
+
<UInput
|
|
658
|
+
v-model="archForm.name"
|
|
659
|
+
:placeholder="t('bootstrap.arch.name.placeholder')"
|
|
660
|
+
class="w-full"
|
|
661
|
+
/>
|
|
654
662
|
</UFormField>
|
|
655
663
|
<div class="grid grid-cols-2 gap-2">
|
|
656
664
|
<UFormField :label="t('bootstrap.arch.repoOwner')" required>
|
|
657
|
-
<UInput
|
|
665
|
+
<UInput
|
|
666
|
+
v-model="archForm.repoOwner"
|
|
667
|
+
:placeholder="t('bootstrap.arch.repoOwnerPlaceholder')"
|
|
668
|
+
class="w-full"
|
|
669
|
+
/>
|
|
658
670
|
</UFormField>
|
|
659
671
|
<UFormField :label="t('bootstrap.arch.repoName')" required>
|
|
660
|
-
<UInput
|
|
672
|
+
<UInput
|
|
673
|
+
v-model="archForm.repoName"
|
|
674
|
+
:placeholder="t('bootstrap.arch.repoNamePlaceholder')"
|
|
675
|
+
class="w-full"
|
|
676
|
+
/>
|
|
661
677
|
</UFormField>
|
|
662
678
|
</div>
|
|
663
679
|
<UFormField :label="t('bootstrap.description.label')">
|
|
@@ -674,7 +690,7 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
|
|
|
674
690
|
<UTextarea
|
|
675
691
|
v-model="archForm.defaultInstructions"
|
|
676
692
|
:rows="2"
|
|
677
|
-
placeholder="
|
|
693
|
+
:placeholder="t('bootstrap.arch.defaultInstructions.placeholder')"
|
|
678
694
|
class="w-full"
|
|
679
695
|
/>
|
|
680
696
|
</UFormField>
|
|
@@ -256,6 +256,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
256
256
|
>
|
|
257
257
|
<div
|
|
258
258
|
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"
|
|
259
|
+
role="dialog"
|
|
260
|
+
aria-modal="true"
|
|
259
261
|
>
|
|
260
262
|
<!-- header -->
|
|
261
263
|
<header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
|
|
@@ -246,6 +246,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
246
246
|
>
|
|
247
247
|
<div
|
|
248
248
|
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"
|
|
249
|
+
role="dialog"
|
|
250
|
+
aria-modal="true"
|
|
249
251
|
>
|
|
250
252
|
<!-- header -->
|
|
251
253
|
<header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
|
|
@@ -98,6 +98,8 @@ function topScore(c: ConsensusContribution): { label: string; value: number } |
|
|
|
98
98
|
>
|
|
99
99
|
<div
|
|
100
100
|
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"
|
|
101
|
+
role="dialog"
|
|
102
|
+
aria-modal="true"
|
|
101
103
|
>
|
|
102
104
|
<!-- header -->
|
|
103
105
|
<header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
|
|
@@ -62,6 +62,8 @@ function openApprovalFor(approvalId: string) {
|
|
|
62
62
|
<div
|
|
63
63
|
v-if="block && statusMeta && typeMeta"
|
|
64
64
|
class="absolute inset-0 z-30 flex flex-col bg-slate-950/95 backdrop-blur"
|
|
65
|
+
role="dialog"
|
|
66
|
+
aria-modal="true"
|
|
65
67
|
>
|
|
66
68
|
<!-- header / breadcrumb -->
|
|
67
69
|
<header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
|
|
@@ -90,6 +90,8 @@ const STATUS_META: Record<
|
|
|
90
90
|
>
|
|
91
91
|
<div
|
|
92
92
|
class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
|
|
93
|
+
role="dialog"
|
|
94
|
+
aria-modal="true"
|
|
93
95
|
>
|
|
94
96
|
<!-- Header -->
|
|
95
97
|
<header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
|
|
@@ -170,6 +170,8 @@ const conflictVerdict = computed(() => {
|
|
|
170
170
|
>
|
|
171
171
|
<div
|
|
172
172
|
class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
|
|
173
|
+
role="dialog"
|
|
174
|
+
aria-modal="true"
|
|
173
175
|
>
|
|
174
176
|
<!-- Header -->
|
|
175
177
|
<header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
|
|
@@ -142,6 +142,8 @@ const canDestroy = computed(
|
|
|
142
142
|
>
|
|
143
143
|
<div
|
|
144
144
|
class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
|
|
145
|
+
role="dialog"
|
|
146
|
+
aria-modal="true"
|
|
145
147
|
>
|
|
146
148
|
<!-- Header -->
|
|
147
149
|
<header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
3
|
+
|
|
4
|
+
// A slim top strip shown when the real-time WebSocket has dropped and is reconnecting, so a
|
|
5
|
+
// silently-frozen board (events stop arriving, nothing updates) is no longer indistinguishable
|
|
6
|
+
// from an idle one. `useWorkspaceStream` already reconnects with exponential backoff and
|
|
7
|
+
// resyncs on reconnect — this only makes that state visible. The `connected` ref is passed in
|
|
8
|
+
// as a prop (the page owns the single stream instance; creating another here would open a
|
|
9
|
+
// second socket).
|
|
10
|
+
const props = defineProps<{ connected: boolean }>()
|
|
11
|
+
|
|
12
|
+
const { t } = useI18n()
|
|
13
|
+
|
|
14
|
+
// Only surface a RE-connection, never the initial connect: once we've been connected we know a
|
|
15
|
+
// later drop is a real interruption worth flagging. A short debounce rides out a quick socket
|
|
16
|
+
// flap so a momentary blip doesn't flash the strip.
|
|
17
|
+
const everConnected = ref(false)
|
|
18
|
+
const showAfterDelay = ref(false)
|
|
19
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
20
|
+
|
|
21
|
+
watch(
|
|
22
|
+
() => props.connected,
|
|
23
|
+
(connected) => {
|
|
24
|
+
if (connected) {
|
|
25
|
+
everConnected.value = true
|
|
26
|
+
showAfterDelay.value = false
|
|
27
|
+
if (timer) {
|
|
28
|
+
clearTimeout(timer)
|
|
29
|
+
timer = null
|
|
30
|
+
}
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
if (!everConnected.value || timer) return
|
|
34
|
+
timer = setTimeout(() => {
|
|
35
|
+
showAfterDelay.value = true
|
|
36
|
+
timer = null
|
|
37
|
+
}, 1500)
|
|
38
|
+
},
|
|
39
|
+
{ immediate: true },
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const visible = computed(() => everConnected.value && !props.connected && showAfterDelay.value)
|
|
43
|
+
|
|
44
|
+
// Don't leave a pending debounce timer firing into a torn-down component.
|
|
45
|
+
onBeforeUnmount(() => {
|
|
46
|
+
if (timer) {
|
|
47
|
+
clearTimeout(timer)
|
|
48
|
+
timer = null
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<Transition name="fade">
|
|
55
|
+
<div
|
|
56
|
+
v-if="visible"
|
|
57
|
+
class="pointer-events-none absolute inset-x-0 top-0 z-50 flex justify-center px-4 pt-2"
|
|
58
|
+
>
|
|
59
|
+
<div
|
|
60
|
+
class="pointer-events-auto flex items-center gap-2 rounded-full border border-amber-500/60 bg-amber-950/90 px-3 py-1.5 text-xs text-amber-100 shadow-lg backdrop-blur"
|
|
61
|
+
role="status"
|
|
62
|
+
aria-live="polite"
|
|
63
|
+
data-testid="stream-reconnecting"
|
|
64
|
+
>
|
|
65
|
+
<UIcon name="i-lucide-loader" class="h-3.5 w-3.5 animate-spin" />
|
|
66
|
+
<span>{{ t('app.reconnecting') }}</span>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</Transition>
|
|
70
|
+
</template>
|
|
71
|
+
|
|
72
|
+
<style scoped>
|
|
73
|
+
.fade-enter-active,
|
|
74
|
+
.fade-leave-active {
|
|
75
|
+
transition: opacity 0.2s ease;
|
|
76
|
+
}
|
|
77
|
+
.fade-enter-from,
|
|
78
|
+
.fade-leave-to {
|
|
79
|
+
opacity: 0;
|
|
80
|
+
}
|
|
81
|
+
</style>
|
|
@@ -16,6 +16,17 @@ const toast = useToast()
|
|
|
16
16
|
|
|
17
17
|
const busy = ref<string | null>(null)
|
|
18
18
|
|
|
19
|
+
/** Toast a failed act/dismiss — the store throws, so without this a failure was silent and the
|
|
20
|
+
* item just stayed in the inbox with no explanation. */
|
|
21
|
+
function notifyError(title: string, e: unknown) {
|
|
22
|
+
toast.add({
|
|
23
|
+
title,
|
|
24
|
+
description: e instanceof Error ? e.message : String(e),
|
|
25
|
+
icon: 'i-lucide-triangle-alert',
|
|
26
|
+
color: 'error',
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
/** Per-type display metadata (icon, colour). The primary-action label is resolved
|
|
20
31
|
* separately through the i18n catalog (`ACTION_KEYS`). */
|
|
21
32
|
type Accent = 'warning' | 'primary' | 'error'
|
|
@@ -98,6 +109,8 @@ async function act(n: Notification) {
|
|
|
98
109
|
color: 'success',
|
|
99
110
|
icon: 'i-lucide-check',
|
|
100
111
|
})
|
|
112
|
+
} catch (e) {
|
|
113
|
+
notifyError(t('layout.notifications.toast.actFailed'), e)
|
|
101
114
|
} finally {
|
|
102
115
|
busy.value = null
|
|
103
116
|
}
|
|
@@ -112,6 +125,8 @@ async function dismiss(n: Notification) {
|
|
|
112
125
|
color: 'neutral',
|
|
113
126
|
icon: 'i-lucide-check',
|
|
114
127
|
})
|
|
128
|
+
} catch (e) {
|
|
129
|
+
notifyError(t('layout.notifications.toast.dismissFailed'), e)
|
|
115
130
|
} finally {
|
|
116
131
|
busy.value = null
|
|
117
132
|
}
|
|
@@ -60,6 +60,8 @@ const customJson = computed<string | null>(() => {
|
|
|
60
60
|
>
|
|
61
61
|
<div
|
|
62
62
|
class="m-4 flex w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
|
|
63
|
+
role="dialog"
|
|
64
|
+
aria-modal="true"
|
|
63
65
|
>
|
|
64
66
|
<!-- Header -->
|
|
65
67
|
<header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
|
|
@@ -54,6 +54,21 @@ const block = computed<Block | undefined>(() =>
|
|
|
54
54
|
)
|
|
55
55
|
const level = computed(() => block.value?.level ?? 'frame')
|
|
56
56
|
const isFrame = computed(() => level.value === 'frame')
|
|
57
|
+
|
|
58
|
+
// The title to fall back to when the user clears the field and blurs. Editing the title binds
|
|
59
|
+
// straight to the store object via v-model, so there is nothing to fall back to otherwise —
|
|
60
|
+
// restore this rather than persisting (and showing) an empty title. It is captured fresh at
|
|
61
|
+
// edit start (`captureTitle` on focus), so it always reflects the current known-good value —
|
|
62
|
+
// robust against a failed save (which rolls the title back) and an external rename of the same
|
|
63
|
+
// block. Seeded here on block switch so the fallback is sane even before the first focus.
|
|
64
|
+
const lastSavedTitle = ref('')
|
|
65
|
+
watch(
|
|
66
|
+
() => block.value?.id,
|
|
67
|
+
() => {
|
|
68
|
+
lastSavedTitle.value = block.value?.title ?? ''
|
|
69
|
+
},
|
|
70
|
+
{ immediate: true },
|
|
71
|
+
)
|
|
57
72
|
const isContainer = computed(() => level.value === 'frame' || level.value === 'module')
|
|
58
73
|
const isTask = computed(() => level.value === 'task')
|
|
59
74
|
const isEpic = computed(() => level.value === 'epic')
|
|
@@ -103,11 +118,24 @@ const started = computed(
|
|
|
103
118
|
)
|
|
104
119
|
const editable = computed(() => !started.value)
|
|
105
120
|
|
|
121
|
+
// Snapshot the current title as the fallback the moment editing begins, so it reflects the
|
|
122
|
+
// last known-good value (survives a failed save or an external rename) rather than a value we
|
|
123
|
+
// only optimistically assumed had persisted.
|
|
124
|
+
function captureTitle() {
|
|
125
|
+
lastSavedTitle.value = block.value?.title ?? ''
|
|
126
|
+
}
|
|
106
127
|
function saveTitle() {
|
|
107
128
|
const b = block.value
|
|
108
129
|
if (!b) return
|
|
109
130
|
const next = b.title.trim()
|
|
110
|
-
|
|
131
|
+
// An emptied title can't persist — restore the last saved value so the field never shows a
|
|
132
|
+
// blank the user didn't intend (and the board keeps a real label).
|
|
133
|
+
if (!next) {
|
|
134
|
+
b.title = lastSavedTitle.value
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
b.title = next
|
|
138
|
+
board.updateBlock(b.id, { title: next })
|
|
111
139
|
}
|
|
112
140
|
function saveDescription() {
|
|
113
141
|
const b = block.value
|
|
@@ -253,6 +281,7 @@ const showOriginalDescription = ref(false)
|
|
|
253
281
|
size="sm"
|
|
254
282
|
class="w-full"
|
|
255
283
|
:placeholder="t('panels.inspector.titlePlaceholder')"
|
|
284
|
+
@focus="captureTitle"
|
|
256
285
|
@change="saveTitle"
|
|
257
286
|
@blur="saveTitle"
|
|
258
287
|
/>
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Dedicated result view for a completed `merger` step. The merger agent scores the PR
|
|
3
|
+
// (complexity / risk / impact + a rationale) and the engine records its structured
|
|
4
|
+
// decision on the step (`step.custom`, a `MergeDecision`): whether it auto-merged or
|
|
5
|
+
// routed the PR to a human, and WHY. This renders that verdict — the three scores as
|
|
6
|
+
// bars against their preset ceilings, the rationale, and a plain-language decision
|
|
7
|
+
// banner — instead of the agent's raw JSON. Opened via the universal result-view host,
|
|
8
|
+
// the same seam the requirements / tester windows use.
|
|
9
|
+
import { computed } from 'vue'
|
|
10
|
+
import type { MergeAxis, MergeDecision } from '@cat-factory/contracts'
|
|
11
|
+
import StepRunMeta from '~/components/panels/StepRunMeta.vue'
|
|
12
|
+
import StepRestartControl from '~/components/panels/StepRestartControl.vue'
|
|
13
|
+
|
|
14
|
+
const board = useBoardStore()
|
|
15
|
+
const execution = useExecutionStore()
|
|
16
|
+
const agents = useAgentsStore()
|
|
17
|
+
const { t, n } = useI18n()
|
|
18
|
+
|
|
19
|
+
// Shared seam contract (open/blockId/close + Escape). No loader: the verdict is read
|
|
20
|
+
// straight off the execution step.
|
|
21
|
+
const { open, blockId, instanceId, stepIndex, close } = useResultView('merger')
|
|
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 meta = computed(() => (step.value ? agents.get(step.value.agentKind) : undefined))
|
|
32
|
+
|
|
33
|
+
const headerLabel = computed(() => meta.value?.label ?? t('panels.mergerResult.title'))
|
|
34
|
+
const headerTitle = computed(() =>
|
|
35
|
+
block.value
|
|
36
|
+
? t('panels.mergerResult.titleWithBlock', { title: block.value.title })
|
|
37
|
+
: headerLabel.value,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
/** The engine's structured verdict; null for a step that predates the structured decision. */
|
|
41
|
+
const decision = computed<MergeDecision | null>(() => {
|
|
42
|
+
const custom = step.value?.custom
|
|
43
|
+
return custom && typeof custom === 'object' && 'outcome' in custom
|
|
44
|
+
? (custom as MergeDecision)
|
|
45
|
+
: null
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const merged = computed(() => decision.value?.outcome === 'auto_merged')
|
|
49
|
+
// Only redden bars when a threshold breach is the ACTUAL reason for review. For
|
|
50
|
+
// `auto_merge_disabled` / `no_rationale` / `no_assessment` a score above its ceiling is
|
|
51
|
+
// incidental, so it must not imply the axis is what caused the review.
|
|
52
|
+
const exceeded = computed(() =>
|
|
53
|
+
decision.value?.reason === 'exceeded_thresholds'
|
|
54
|
+
? new Set<MergeAxis>(decision.value.exceededAxes)
|
|
55
|
+
: new Set<MergeAxis>(),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// Exhaustive enum → i18n-key maps keyed off the contract unions, so adding a new
|
|
59
|
+
// `MergeDecision` reason/outcome (or a merge axis) fails typecheck here until its key is
|
|
60
|
+
// added — the drift guard the dynamic `t(\`...\${x}\`)` lookups can't provide on their own.
|
|
61
|
+
const REASON_KEYS: Record<MergeDecision['reason'], string> = {
|
|
62
|
+
within_thresholds: 'panels.mergerResult.reason.within_thresholds',
|
|
63
|
+
exceeded_thresholds: 'panels.mergerResult.reason.exceeded_thresholds',
|
|
64
|
+
auto_merge_disabled: 'panels.mergerResult.reason.auto_merge_disabled',
|
|
65
|
+
no_rationale: 'panels.mergerResult.reason.no_rationale',
|
|
66
|
+
no_assessment: 'panels.mergerResult.reason.no_assessment',
|
|
67
|
+
merge_failed: 'panels.mergerResult.reason.merge_failed',
|
|
68
|
+
}
|
|
69
|
+
const OUTCOME_KEYS: Record<MergeDecision['outcome'], string> = {
|
|
70
|
+
auto_merged: 'panels.mergerResult.outcome.auto_merged',
|
|
71
|
+
awaiting_review: 'panels.mergerResult.outcome.awaiting_review',
|
|
72
|
+
}
|
|
73
|
+
const AXIS_KEYS: Record<MergeAxis, string> = {
|
|
74
|
+
complexity: 'panels.mergerResult.axis.complexity',
|
|
75
|
+
risk: 'panels.mergerResult.axis.risk',
|
|
76
|
+
impact: 'panels.mergerResult.axis.impact',
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const outcomeText = computed(() => (decision.value ? t(OUTCOME_KEYS[decision.value.outcome]) : ''))
|
|
80
|
+
|
|
81
|
+
/** The three axes with their score + preset ceiling, for the bar rows. */
|
|
82
|
+
const axes = computed(() => {
|
|
83
|
+
const d = decision.value
|
|
84
|
+
if (!d?.assessment) return []
|
|
85
|
+
return [
|
|
86
|
+
{
|
|
87
|
+
key: 'complexity' as const,
|
|
88
|
+
label: t(AXIS_KEYS.complexity),
|
|
89
|
+
score: d.assessment.complexity,
|
|
90
|
+
ceiling: d.thresholds.maxComplexity,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
key: 'risk' as const,
|
|
94
|
+
label: t(AXIS_KEYS.risk),
|
|
95
|
+
score: d.assessment.risk,
|
|
96
|
+
ceiling: d.thresholds.maxRisk,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
key: 'impact' as const,
|
|
100
|
+
label: t(AXIS_KEYS.impact),
|
|
101
|
+
score: d.assessment.impact,
|
|
102
|
+
ceiling: d.thresholds.maxImpact,
|
|
103
|
+
},
|
|
104
|
+
]
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
/** The plain-language "why" line, interpolating the preset + any exceeded axes. */
|
|
108
|
+
const reasonText = computed(() => {
|
|
109
|
+
const d = decision.value
|
|
110
|
+
if (!d) return ''
|
|
111
|
+
const axisLabels = d.exceededAxes.map((a) => t(AXIS_KEYS[a])).join(', ')
|
|
112
|
+
return t(REASON_KEYS[d.reason], {
|
|
113
|
+
preset: d.thresholds.presetName,
|
|
114
|
+
axes: axisLabels,
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
</script>
|
|
118
|
+
|
|
119
|
+
<template>
|
|
120
|
+
<Teleport to="body">
|
|
121
|
+
<div
|
|
122
|
+
v-if="open"
|
|
123
|
+
class="fixed inset-0 z-50 flex max-h-[100dvh] items-stretch justify-center bg-slate-950/70 backdrop-blur-sm"
|
|
124
|
+
@click.self="close"
|
|
125
|
+
>
|
|
126
|
+
<div
|
|
127
|
+
class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
|
|
128
|
+
>
|
|
129
|
+
<!-- Header -->
|
|
130
|
+
<header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
|
|
131
|
+
<span
|
|
132
|
+
class="flex h-8 w-8 items-center justify-center rounded-lg bg-lime-500/15 text-lime-300"
|
|
133
|
+
>
|
|
134
|
+
<UIcon :name="meta?.icon ?? 'i-lucide-git-pull-request'" class="h-4 w-4" />
|
|
135
|
+
</span>
|
|
136
|
+
<div class="min-w-0 flex-1">
|
|
137
|
+
<h2 class="truncate text-sm font-semibold text-slate-100">{{ headerTitle }}</h2>
|
|
138
|
+
<p class="truncate text-[11px] text-slate-400">
|
|
139
|
+
{{ t('panels.mergerResult.description') }}
|
|
140
|
+
</p>
|
|
141
|
+
</div>
|
|
142
|
+
<StepRestartControl
|
|
143
|
+
:instance-id="instanceId"
|
|
144
|
+
:step-index="stepIndex"
|
|
145
|
+
@restarted="close"
|
|
146
|
+
/>
|
|
147
|
+
<button
|
|
148
|
+
class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
|
|
149
|
+
@click="close"
|
|
150
|
+
>
|
|
151
|
+
<UIcon name="i-lucide-x" class="h-4 w-4" />
|
|
152
|
+
</button>
|
|
153
|
+
</header>
|
|
154
|
+
|
|
155
|
+
<div class="flex min-h-0 flex-1">
|
|
156
|
+
<div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
|
|
157
|
+
<template v-if="decision">
|
|
158
|
+
<!-- Decision banner: auto-merged (success) vs awaiting human review (warning). -->
|
|
159
|
+
<div
|
|
160
|
+
class="mb-4 flex items-start gap-3 rounded-lg border p-3"
|
|
161
|
+
:class="
|
|
162
|
+
merged
|
|
163
|
+
? 'border-emerald-800/70 bg-emerald-500/10'
|
|
164
|
+
: 'border-amber-800/70 bg-amber-500/10'
|
|
165
|
+
"
|
|
166
|
+
data-testid="merger-decision"
|
|
167
|
+
:data-outcome="decision.outcome"
|
|
168
|
+
>
|
|
169
|
+
<UIcon
|
|
170
|
+
:name="merged ? 'i-lucide-git-merge' : 'i-lucide-user-round-check'"
|
|
171
|
+
class="mt-0.5 h-5 w-5 shrink-0"
|
|
172
|
+
:class="merged ? 'text-emerald-300' : 'text-amber-300'"
|
|
173
|
+
/>
|
|
174
|
+
<div class="min-w-0">
|
|
175
|
+
<p
|
|
176
|
+
class="text-sm font-semibold"
|
|
177
|
+
:class="merged ? 'text-emerald-200' : 'text-amber-200'"
|
|
178
|
+
>
|
|
179
|
+
{{ outcomeText }}
|
|
180
|
+
</p>
|
|
181
|
+
<p class="mt-0.5 text-[13px] leading-relaxed text-slate-300">{{ reasonText }}</p>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<!-- Scores vs the resolved preset's ceilings. -->
|
|
186
|
+
<template v-if="axes.length">
|
|
187
|
+
<h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
188
|
+
{{ t('panels.mergerResult.scores') }}
|
|
189
|
+
</h3>
|
|
190
|
+
<div class="space-y-2 rounded-lg border border-slate-800 bg-slate-950/40 p-3">
|
|
191
|
+
<div v-for="axis in axes" :key="axis.key" class="flex items-center gap-2">
|
|
192
|
+
<span class="w-20 shrink-0 text-xs text-slate-400">{{ axis.label }}</span>
|
|
193
|
+
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-800">
|
|
194
|
+
<div
|
|
195
|
+
class="h-full rounded-full"
|
|
196
|
+
:class="exceeded.has(axis.key) ? 'bg-rose-500' : 'bg-emerald-500'"
|
|
197
|
+
:style="{ width: `${Math.round(axis.score * 100)}%` }"
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
<span
|
|
201
|
+
class="w-11 shrink-0 text-end text-xs tabular-nums"
|
|
202
|
+
:class="exceeded.has(axis.key) ? 'text-rose-300' : 'text-slate-300'"
|
|
203
|
+
>
|
|
204
|
+
{{ n(axis.score, { key: 'percent' }) }}
|
|
205
|
+
</span>
|
|
206
|
+
<span class="w-24 shrink-0 text-end text-[10px] tabular-nums text-slate-500">
|
|
207
|
+
{{
|
|
208
|
+
t('panels.mergerResult.ceiling', {
|
|
209
|
+
value: n(axis.ceiling, { key: 'percent' }),
|
|
210
|
+
})
|
|
211
|
+
}}
|
|
212
|
+
</span>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
</template>
|
|
216
|
+
|
|
217
|
+
<!-- The agent's prose justification. -->
|
|
218
|
+
<template v-if="decision.assessment?.rationale">
|
|
219
|
+
<h3
|
|
220
|
+
class="mb-2 mt-4 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
|
|
221
|
+
>
|
|
222
|
+
{{ t('panels.mergerResult.rationale') }}
|
|
223
|
+
</h3>
|
|
224
|
+
<p class="whitespace-pre-wrap text-[13px] leading-relaxed text-slate-300">
|
|
225
|
+
{{ decision.assessment.rationale }}
|
|
226
|
+
</p>
|
|
227
|
+
</template>
|
|
228
|
+
<p v-else class="text-[13px] italic leading-relaxed text-slate-500">
|
|
229
|
+
{{ t('panels.mergerResult.noAssessment') }}
|
|
230
|
+
</p>
|
|
231
|
+
</template>
|
|
232
|
+
|
|
233
|
+
<!-- Pre-structured runs kept only the raw prose output. -->
|
|
234
|
+
<p
|
|
235
|
+
v-else-if="step?.output"
|
|
236
|
+
class="whitespace-pre-wrap text-[13px] leading-relaxed text-slate-300"
|
|
237
|
+
>
|
|
238
|
+
{{ step.output }}
|
|
239
|
+
</p>
|
|
240
|
+
<div
|
|
241
|
+
v-else
|
|
242
|
+
class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
|
|
243
|
+
>
|
|
244
|
+
<UIcon name="i-lucide-git-pull-request" class="h-8 w-8 opacity-40" />
|
|
245
|
+
<p class="text-sm">{{ t('panels.mergerResult.noResult') }}</p>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<!-- Sidebar: shared run metadata. -->
|
|
250
|
+
<aside
|
|
251
|
+
class="hidden w-60 shrink-0 flex-col gap-4 border-s border-slate-800 bg-slate-900/50 px-4 py-4 lg:flex"
|
|
252
|
+
>
|
|
253
|
+
<StepRunMeta
|
|
254
|
+
v-if="step"
|
|
255
|
+
:step="step"
|
|
256
|
+
:instance-id="instanceId ?? undefined"
|
|
257
|
+
:step-number="stepIndex === null ? undefined : stepIndex + 1"
|
|
258
|
+
:total-steps="instance?.steps.length"
|
|
259
|
+
:run-failed="instance?.status === 'failed'"
|
|
260
|
+
:failure-at="instance?.failure?.occurredAt"
|
|
261
|
+
/>
|
|
262
|
+
</aside>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
</Teleport>
|
|
267
|
+
</template>
|
|
@@ -22,6 +22,7 @@ import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindo
|
|
|
22
22
|
import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
|
|
23
23
|
import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
|
|
24
24
|
import FollowUpWindow from '~/components/followUp/FollowUpWindow.vue'
|
|
25
|
+
import MergerResultView from '~/components/panels/MergerResultView.vue'
|
|
25
26
|
|
|
26
27
|
const ui = useUiStore()
|
|
27
28
|
|
|
@@ -48,6 +49,9 @@ const STEP_RESULT_VIEWS: Record<string, Component> = {
|
|
|
48
49
|
// The future-looking Follow-up companion: the Coder's surfaced loose ends / questions.
|
|
49
50
|
// Opened directly via `ui.openFollowUps` (the blinking chip + the `followup_pending` card).
|
|
50
51
|
'follow-ups': FollowUpWindow,
|
|
52
|
+
// The merger's verdict: the PR's complexity/risk/impact scores + the engine's auto-merge
|
|
53
|
+
// or awaiting-review decision (and why), instead of the agent's raw JSON.
|
|
54
|
+
merger: MergerResultView,
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
const active = computed<Component | null>(() => {
|