@cat-factory/app 0.136.0 → 0.137.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/panels/MergerResultView.vue +113 -139
- package/app/components/panels/ResultWindowShell.vue +141 -0
- package/app/components/pipeline/PipelineProgress.vue +2 -0
- package/app/composables/useResultView.ts +13 -3
- package/app/stores/auth.ts +5 -1
- package/app/stores/workspace.ts +5 -1
- package/app/utils/backendReady.spec.ts +69 -0
- package/app/utils/backendReady.ts +54 -0
- package/package.json +4 -4
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { computed } from 'vue'
|
|
10
10
|
import type { MergeAxis, MergeDecision } from '@cat-factory/contracts'
|
|
11
11
|
import StepRunMeta from '~/components/panels/StepRunMeta.vue'
|
|
12
|
-
import
|
|
12
|
+
import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
|
|
13
13
|
import MarkdownProse from '~/components/common/MarkdownProse.vue'
|
|
14
14
|
|
|
15
15
|
const board = useBoardStore()
|
|
@@ -17,9 +17,12 @@ const execution = useExecutionStore()
|
|
|
17
17
|
const agents = useAgentsStore()
|
|
18
18
|
const { t, n } = useI18n()
|
|
19
19
|
|
|
20
|
-
// Shared seam contract (open/blockId/close
|
|
21
|
-
//
|
|
22
|
-
|
|
20
|
+
// Shared seam contract (open/blockId/close). No loader: the verdict is read straight off
|
|
21
|
+
// the execution step. `manageEscape: false` — `ResultWindowShell` owns Escape (and focus
|
|
22
|
+
// trap + scroll lock + stacking) via the shared overlay behaviour.
|
|
23
|
+
const { open, blockId, instanceId, stepIndex, close } = useResultView('merger', {
|
|
24
|
+
manageEscape: false,
|
|
25
|
+
})
|
|
23
26
|
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|
|
24
27
|
|
|
25
28
|
const instance = computed(() =>
|
|
@@ -119,151 +122,122 @@ const reasonText = computed(() => {
|
|
|
119
122
|
</script>
|
|
120
123
|
|
|
121
124
|
<template>
|
|
122
|
-
<
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
:instance-id="instanceId"
|
|
146
|
-
:step-index="stepIndex"
|
|
147
|
-
@restarted="close"
|
|
148
|
-
/>
|
|
149
|
-
<button
|
|
150
|
-
class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
|
|
151
|
-
@click="close"
|
|
125
|
+
<ResultWindowShell
|
|
126
|
+
:open="open"
|
|
127
|
+
:icon="meta?.icon ?? 'i-lucide-git-pull-request'"
|
|
128
|
+
icon-class="bg-lime-500/15 text-lime-300"
|
|
129
|
+
:title="headerTitle"
|
|
130
|
+
:subtitle="t('panels.mergerResult.description')"
|
|
131
|
+
:step-ref="{ instanceId, stepIndex }"
|
|
132
|
+
width="3xl"
|
|
133
|
+
@close="close"
|
|
134
|
+
>
|
|
135
|
+
<div class="flex min-h-0 flex-1">
|
|
136
|
+
<div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
|
|
137
|
+
<template v-if="decision">
|
|
138
|
+
<!-- Decision banner: auto-merged (success) vs awaiting human review (warning). -->
|
|
139
|
+
<div
|
|
140
|
+
class="mb-4 flex items-start gap-3 rounded-lg border p-3"
|
|
141
|
+
:class="
|
|
142
|
+
merged
|
|
143
|
+
? 'border-emerald-800/70 bg-emerald-500/10'
|
|
144
|
+
: 'border-amber-800/70 bg-amber-500/10'
|
|
145
|
+
"
|
|
146
|
+
data-testid="merger-decision"
|
|
147
|
+
:data-outcome="decision.outcome"
|
|
152
148
|
>
|
|
153
|
-
<UIcon
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
class="mb-4 flex items-start gap-3 rounded-lg border p-3"
|
|
163
|
-
:class="
|
|
164
|
-
merged
|
|
165
|
-
? 'border-emerald-800/70 bg-emerald-500/10'
|
|
166
|
-
: 'border-amber-800/70 bg-amber-500/10'
|
|
167
|
-
"
|
|
168
|
-
data-testid="merger-decision"
|
|
169
|
-
:data-outcome="decision.outcome"
|
|
149
|
+
<UIcon
|
|
150
|
+
:name="merged ? 'i-lucide-git-merge' : 'i-lucide-user-round-check'"
|
|
151
|
+
class="mt-0.5 h-5 w-5 shrink-0"
|
|
152
|
+
:class="merged ? 'text-emerald-300' : 'text-amber-300'"
|
|
153
|
+
/>
|
|
154
|
+
<div class="min-w-0">
|
|
155
|
+
<p
|
|
156
|
+
class="text-sm font-semibold"
|
|
157
|
+
:class="merged ? 'text-emerald-200' : 'text-amber-200'"
|
|
170
158
|
>
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
<div class="min-w-0">
|
|
177
|
-
<p
|
|
178
|
-
class="text-sm font-semibold"
|
|
179
|
-
:class="merged ? 'text-emerald-200' : 'text-amber-200'"
|
|
180
|
-
>
|
|
181
|
-
{{ outcomeText }}
|
|
182
|
-
</p>
|
|
183
|
-
<p class="mt-0.5 text-[13px] leading-relaxed text-slate-300">{{ reasonText }}</p>
|
|
184
|
-
</div>
|
|
185
|
-
</div>
|
|
159
|
+
{{ outcomeText }}
|
|
160
|
+
</p>
|
|
161
|
+
<p class="mt-0.5 text-[13px] leading-relaxed text-slate-300">{{ reasonText }}</p>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
186
164
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
</div>
|
|
202
|
-
<span
|
|
203
|
-
class="w-11 shrink-0 text-end text-xs tabular-nums"
|
|
204
|
-
:class="exceeded.has(axis.key) ? 'text-rose-300' : 'text-slate-300'"
|
|
205
|
-
>
|
|
206
|
-
{{ n(axis.score, { key: 'percent' }) }}
|
|
207
|
-
</span>
|
|
208
|
-
<span class="w-24 shrink-0 text-end text-[10px] tabular-nums text-slate-500">
|
|
209
|
-
{{
|
|
210
|
-
t('panels.mergerResult.ceiling', {
|
|
211
|
-
value: n(axis.ceiling, { key: 'percent' }),
|
|
212
|
-
})
|
|
213
|
-
}}
|
|
214
|
-
</span>
|
|
215
|
-
</div>
|
|
165
|
+
<!-- Scores vs the resolved preset's ceilings. -->
|
|
166
|
+
<template v-if="axes.length">
|
|
167
|
+
<h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
168
|
+
{{ t('panels.mergerResult.scores') }}
|
|
169
|
+
</h3>
|
|
170
|
+
<div class="space-y-2 rounded-lg border border-slate-800 bg-slate-950/40 p-3">
|
|
171
|
+
<div v-for="axis in axes" :key="axis.key" class="flex items-center gap-2">
|
|
172
|
+
<span class="w-20 shrink-0 text-xs text-slate-400">{{ axis.label }}</span>
|
|
173
|
+
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-800">
|
|
174
|
+
<div
|
|
175
|
+
class="h-full rounded-full"
|
|
176
|
+
:class="exceeded.has(axis.key) ? 'bg-rose-500' : 'bg-emerald-500'"
|
|
177
|
+
:style="{ width: `${Math.round(axis.score * 100)}%` }"
|
|
178
|
+
/>
|
|
216
179
|
</div>
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
<template v-if="decision.assessment?.rationale">
|
|
221
|
-
<h3
|
|
222
|
-
class="mb-2 mt-4 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
|
|
180
|
+
<span
|
|
181
|
+
class="w-11 shrink-0 text-end text-xs tabular-nums"
|
|
182
|
+
:class="exceeded.has(axis.key) ? 'text-rose-300' : 'text-slate-300'"
|
|
223
183
|
>
|
|
224
|
-
{{
|
|
225
|
-
</
|
|
226
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
</
|
|
234
|
-
</
|
|
184
|
+
{{ n(axis.score, { key: 'percent' }) }}
|
|
185
|
+
</span>
|
|
186
|
+
<span class="w-24 shrink-0 text-end text-[10px] tabular-nums text-slate-500">
|
|
187
|
+
{{
|
|
188
|
+
t('panels.mergerResult.ceiling', {
|
|
189
|
+
value: n(axis.ceiling, { key: 'percent' }),
|
|
190
|
+
})
|
|
191
|
+
}}
|
|
192
|
+
</span>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
</template>
|
|
235
196
|
|
|
236
|
-
|
|
197
|
+
<!-- The agent's prose justification. -->
|
|
198
|
+
<template v-if="decision.assessment?.rationale">
|
|
199
|
+
<h3 class="mb-2 mt-4 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
200
|
+
{{ t('panels.mergerResult.rationale') }}
|
|
201
|
+
</h3>
|
|
237
202
|
<MarkdownProse
|
|
238
|
-
|
|
239
|
-
:text="step.output"
|
|
203
|
+
:text="decision.assessment.rationale"
|
|
240
204
|
class="text-[13px] leading-relaxed text-slate-300"
|
|
241
205
|
/>
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
:total-steps="instance?.steps.length"
|
|
261
|
-
:run-failed="instance?.status === 'failed'"
|
|
262
|
-
:failure-at="instance?.failure?.occurredAt"
|
|
263
|
-
/>
|
|
264
|
-
</aside>
|
|
206
|
+
</template>
|
|
207
|
+
<p v-else class="text-[13px] italic leading-relaxed text-slate-500">
|
|
208
|
+
{{ t('panels.mergerResult.noAssessment') }}
|
|
209
|
+
</p>
|
|
210
|
+
</template>
|
|
211
|
+
|
|
212
|
+
<!-- Pre-structured runs kept only the raw prose output. -->
|
|
213
|
+
<MarkdownProse
|
|
214
|
+
v-else-if="step?.output"
|
|
215
|
+
:text="step.output"
|
|
216
|
+
class="text-[13px] leading-relaxed text-slate-300"
|
|
217
|
+
/>
|
|
218
|
+
<div
|
|
219
|
+
v-else
|
|
220
|
+
class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
|
|
221
|
+
>
|
|
222
|
+
<UIcon name="i-lucide-git-pull-request" class="h-8 w-8 opacity-40" />
|
|
223
|
+
<p class="text-sm">{{ t('panels.mergerResult.noResult') }}</p>
|
|
265
224
|
</div>
|
|
266
225
|
</div>
|
|
226
|
+
|
|
227
|
+
<!-- Sidebar: shared run metadata. -->
|
|
228
|
+
<aside
|
|
229
|
+
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"
|
|
230
|
+
>
|
|
231
|
+
<StepRunMeta
|
|
232
|
+
v-if="step"
|
|
233
|
+
:step="step"
|
|
234
|
+
:instance-id="instanceId ?? undefined"
|
|
235
|
+
:step-number="stepIndex === null ? undefined : stepIndex + 1"
|
|
236
|
+
:total-steps="instance?.steps.length"
|
|
237
|
+
:run-failed="instance?.status === 'failed'"
|
|
238
|
+
:failure-at="instance?.failure?.occurredAt"
|
|
239
|
+
/>
|
|
240
|
+
</aside>
|
|
267
241
|
</div>
|
|
268
|
-
</
|
|
242
|
+
</ResultWindowShell>
|
|
269
243
|
</template>
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Shared modal shell for the agent-run result windows (slice 5 of the modular-vue
|
|
3
|
+
// adoption — docs/initiatives/modular-vue-adoption.md; progress in
|
|
4
|
+
// docs/initiatives/modular-vue-slice5-progress.md).
|
|
5
|
+
//
|
|
6
|
+
// Every result window (the merger verdict, the tester report, the requirements-review
|
|
7
|
+
// loop, the gates, …) used to hand-roll the SAME modal chrome — `<Teleport>`, a
|
|
8
|
+
// backdrop, a bordered card, a header row with an icon/title/close — and, worse,
|
|
9
|
+
// re-implemented the modal *behaviour* inconsistently: only 2 of ~18 trapped focus, each
|
|
10
|
+
// registered its own global Escape listener, and every one hard-coded `z-50` with no
|
|
11
|
+
// stacking. This shell centralises the chrome AND delegates the behaviour to the upstream
|
|
12
|
+
// `useModalBehavior` (`@modular-vue/core`, the slice-5 overlay-host release): focus-trap
|
|
13
|
+
// + focus-return, body-scroll lock, and a shared overlay STACK so the top overlay closes
|
|
14
|
+
// first on Escape. A window becomes body-only markup wrapped in `<ResultWindowShell>`; it
|
|
15
|
+
// keeps its `useResultView` seam but passes `manageEscape: false` (the shell owns Escape).
|
|
16
|
+
//
|
|
17
|
+
// The pick-one SELECTION of which window is active stays exactly the slice-2
|
|
18
|
+
// `resolveComponentRegistry` in `StepResultViewHost.vue` — this shell only owns the
|
|
19
|
+
// per-window chrome + behaviour, so windows convert one at a time behind it.
|
|
20
|
+
import { computed } from 'vue'
|
|
21
|
+
import { useModalBehavior } from '@modular-vue/core'
|
|
22
|
+
import StepRestartControl from '~/components/panels/StepRestartControl.vue'
|
|
23
|
+
|
|
24
|
+
/** A pipeline step reference — passed by step-result windows to surface the shared
|
|
25
|
+
* "restart from here" control. `StepRestartControl` self-hides for an off-path open
|
|
26
|
+
* (null ids), so a block-keyed window simply omits this prop. */
|
|
27
|
+
type StepRef = { instanceId: string | null; stepIndex: number | null }
|
|
28
|
+
|
|
29
|
+
const props = withDefaults(
|
|
30
|
+
defineProps<{
|
|
31
|
+
/** Whether the window is open — drives the modal behaviour's activation. */
|
|
32
|
+
open: boolean
|
|
33
|
+
/** Header icon (a `UIcon` name) + its badge colour classes. */
|
|
34
|
+
icon?: string
|
|
35
|
+
iconClass?: string
|
|
36
|
+
/** Header title (the accessible dialog name) + optional secondary line. */
|
|
37
|
+
title: string
|
|
38
|
+
subtitle?: string
|
|
39
|
+
/** Card width bucket + backdrop layout (the two pre-slice-5 chrome variants). */
|
|
40
|
+
width?: '3xl' | '4xl' | '5xl'
|
|
41
|
+
variant?: 'stretch' | 'centered'
|
|
42
|
+
/** Provide on step-result windows to show the shared restart control; omit on gates
|
|
43
|
+
* and block-keyed windows (no restart mid-gate / pre-run). */
|
|
44
|
+
stepRef?: StepRef
|
|
45
|
+
/** `data-testid` on the dialog root — pass a window's existing id to preserve e2e
|
|
46
|
+
* selectors; defaults to `result-window`. */
|
|
47
|
+
testid?: string
|
|
48
|
+
}>(),
|
|
49
|
+
{
|
|
50
|
+
icon: 'i-lucide-square',
|
|
51
|
+
iconClass: 'bg-slate-500/15 text-slate-300',
|
|
52
|
+
subtitle: undefined,
|
|
53
|
+
width: '3xl',
|
|
54
|
+
variant: 'stretch',
|
|
55
|
+
stepRef: undefined,
|
|
56
|
+
testid: undefined,
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const emit = defineEmits<{ close: [] }>()
|
|
61
|
+
const { t } = useI18n()
|
|
62
|
+
|
|
63
|
+
function requestClose() {
|
|
64
|
+
emit('close')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Managed modal behaviour (focus-trap + return, scroll lock, shared-stack Escape). The
|
|
68
|
+
// window unmounts on close, so deactivation + cleanup fire via `active` going false and
|
|
69
|
+
// unmount — no manual teardown here.
|
|
70
|
+
const { dialogRef } = useModalBehavior({
|
|
71
|
+
active: () => props.open,
|
|
72
|
+
onClose: requestClose,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const WIDTH: Record<'3xl' | '4xl' | '5xl', string> = {
|
|
76
|
+
'3xl': 'max-w-3xl',
|
|
77
|
+
'4xl': 'max-w-4xl',
|
|
78
|
+
'5xl': 'max-w-5xl',
|
|
79
|
+
}
|
|
80
|
+
const backdropClass = computed(() => [
|
|
81
|
+
'fixed inset-0 z-50 flex max-h-[100dvh] justify-center bg-slate-950/70 backdrop-blur-sm',
|
|
82
|
+
props.variant === 'centered' ? 'items-center p-4' : 'items-stretch',
|
|
83
|
+
])
|
|
84
|
+
const panelClass = computed(() => [
|
|
85
|
+
'flex w-full flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl',
|
|
86
|
+
WIDTH[props.width],
|
|
87
|
+
props.variant === 'centered' ? 'max-h-[90dvh]' : 'm-4',
|
|
88
|
+
])
|
|
89
|
+
</script>
|
|
90
|
+
|
|
91
|
+
<template>
|
|
92
|
+
<Teleport to="body">
|
|
93
|
+
<div
|
|
94
|
+
v-if="open"
|
|
95
|
+
:class="backdropClass"
|
|
96
|
+
data-testid="result-window-backdrop"
|
|
97
|
+
@click.self="requestClose"
|
|
98
|
+
>
|
|
99
|
+
<div
|
|
100
|
+
ref="dialogRef"
|
|
101
|
+
tabindex="-1"
|
|
102
|
+
:class="panelClass"
|
|
103
|
+
role="dialog"
|
|
104
|
+
aria-modal="true"
|
|
105
|
+
:aria-label="title"
|
|
106
|
+
:data-testid="testid ?? 'result-window'"
|
|
107
|
+
>
|
|
108
|
+
<header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
|
|
109
|
+
<span
|
|
110
|
+
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
|
|
111
|
+
:class="iconClass"
|
|
112
|
+
>
|
|
113
|
+
<UIcon :name="icon" class="h-4 w-4" />
|
|
114
|
+
</span>
|
|
115
|
+
<div class="min-w-0 flex-1">
|
|
116
|
+
<h2 class="truncate text-sm font-semibold text-slate-100">{{ title }}</h2>
|
|
117
|
+
<p v-if="subtitle" class="truncate text-[11px] text-slate-400">{{ subtitle }}</p>
|
|
118
|
+
</div>
|
|
119
|
+
<!-- Window-specific header content (status badges, counts). -->
|
|
120
|
+
<slot name="header-extras" />
|
|
121
|
+
<StepRestartControl
|
|
122
|
+
v-if="stepRef"
|
|
123
|
+
:instance-id="stepRef.instanceId"
|
|
124
|
+
:step-index="stepRef.stepIndex"
|
|
125
|
+
@restarted="requestClose"
|
|
126
|
+
/>
|
|
127
|
+
<button
|
|
128
|
+
class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
|
|
129
|
+
data-testid="result-window-close"
|
|
130
|
+
:aria-label="t('common.close')"
|
|
131
|
+
@click="requestClose"
|
|
132
|
+
>
|
|
133
|
+
<UIcon name="i-lucide-x" class="h-4 w-4" />
|
|
134
|
+
</button>
|
|
135
|
+
</header>
|
|
136
|
+
<!-- The window body. -->
|
|
137
|
+
<slot />
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</Teleport>
|
|
141
|
+
</template>
|
|
@@ -300,6 +300,8 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
300
300
|
>
|
|
301
301
|
<div
|
|
302
302
|
class="group flex cursor-pointer items-center gap-2"
|
|
303
|
+
data-testid="pipeline-step"
|
|
304
|
+
:data-step-kind="s.agentKind"
|
|
303
305
|
:title="
|
|
304
306
|
s.output
|
|
305
307
|
? t('pipeline.progress.viewDetailsOutput')
|
|
@@ -18,10 +18,18 @@
|
|
|
18
18
|
* review windows) can flush it in one place instead of every caller having to remember to.
|
|
19
19
|
* It runs synchronously; if it kicks off async work it must capture whatever it needs first,
|
|
20
20
|
* because `blockId`/the derived state go null the moment the view closes.
|
|
21
|
+
*
|
|
22
|
+
* `manageEscape` (default `true`) owns the global Escape-to-close listener. A window that
|
|
23
|
+
* renders through `ResultWindowShell` (slice 5 of the modular-vue adoption) passes `false`:
|
|
24
|
+
* the shell's `useModalBehavior` owns Escape via the shared overlay stack (so the top
|
|
25
|
+
* overlay closes first, and focus/scroll are managed too), and a second listener here would
|
|
26
|
+
* double-fire `close`. Un-converted windows keep the default so they still close on Escape.
|
|
27
|
+
* Remove this option once every result window is on the shell (the listener moves out
|
|
28
|
+
* entirely).
|
|
21
29
|
*/
|
|
22
30
|
export function useResultView(
|
|
23
31
|
viewId: string,
|
|
24
|
-
opts?: { onOpen?: (blockId: string) => void; onClose?: () => void },
|
|
32
|
+
opts?: { onOpen?: (blockId: string) => void; onClose?: () => void; manageEscape?: boolean },
|
|
25
33
|
) {
|
|
26
34
|
const ui = useUiStore()
|
|
27
35
|
|
|
@@ -42,8 +50,10 @@ export function useResultView(
|
|
|
42
50
|
function onKey(e: KeyboardEvent) {
|
|
43
51
|
if (e.key === 'Escape' && open.value) close()
|
|
44
52
|
}
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
if (opts?.manageEscape !== false) {
|
|
54
|
+
onMounted(() => window.addEventListener('keydown', onKey))
|
|
55
|
+
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
|
56
|
+
}
|
|
47
57
|
|
|
48
58
|
// The load-on-open contract: fire immediately on mount and on any later block switch.
|
|
49
59
|
if (opts?.onOpen) {
|
package/app/stores/auth.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
import { defineStore } from 'pinia'
|
|
7
7
|
import { computed, ref } from 'vue'
|
|
8
8
|
import type { AuthUser } from '~/types/domain'
|
|
9
|
+
import { retryWhileBackendUnreachable } from '~/utils/backendReady'
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* "Login with GitHub" session state. The backend mints a signed session token
|
|
@@ -184,7 +185,10 @@ export const useAuthStore = defineStore(
|
|
|
184
185
|
// which must be exchanged — not stored as a local token by `consumeRedirectToken`).
|
|
185
186
|
if (!(await maybeConnectMothership())) consumeRedirectToken()
|
|
186
187
|
try {
|
|
187
|
-
|
|
188
|
+
// Tolerate a cold-start race: when the SPA and backend boot together, this first call
|
|
189
|
+
// can beat the backend's listener by a second or two. Retry a not-listening-yet socket
|
|
190
|
+
// (the gate keeps showing its spinner) instead of degrading to the unreachable screen.
|
|
191
|
+
const config = await retryWhileBackendUnreachable(() => api.getAuthConfig())
|
|
188
192
|
required.value = config.enabled
|
|
189
193
|
if (config.providers) providers.value = config.providers
|
|
190
194
|
patProviders.value = config.patLogin?.providers ?? []
|
package/app/stores/workspace.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { useGitHubStore } from '~/stores/github'
|
|
|
35
35
|
import { useFragmentsStore } from '~/stores/fragments'
|
|
36
36
|
import { useProviderConnectionsStore } from '~/stores/providerConnections'
|
|
37
37
|
import { markBoot } from '~/utils/bootMarks'
|
|
38
|
+
import { retryWhileBackendUnreachable } from '~/utils/backendReady'
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* Owns the active workspace and bootstraps the app against the backend. On load
|
|
@@ -207,7 +208,10 @@ export const useWorkspaceStore = defineStore(
|
|
|
207
208
|
useAccountsStore()
|
|
208
209
|
.load()
|
|
209
210
|
.catch(() => {}),
|
|
210
|
-
|
|
211
|
+
// Retry a not-listening-yet backend (cold-start race) before surfacing the
|
|
212
|
+
// unreachable screen. This gates the rest of init, so once it resolves the
|
|
213
|
+
// backend is up and the speculative/follow-up snapshot fetches succeed too.
|
|
214
|
+
retryWhileBackendUnreachable(() => api.listWorkspaces()),
|
|
211
215
|
])
|
|
212
216
|
markBoot('workspaces-listed')
|
|
213
217
|
workspaces.value = workspaceList
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { SchemaValidationError } from '@toad-contracts/core'
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { ApiError } from '~/composables/api/errors'
|
|
4
|
+
import { isBackendUnreachable, retryWhileBackendUnreachable } from '~/utils/backendReady'
|
|
5
|
+
|
|
6
|
+
// The cold-start race: the SPA's first fetch can beat the backend's listener, throwing a
|
|
7
|
+
// status-less network fault. These lock in that we wait THAT out (with a deadline) but
|
|
8
|
+
// surface a real HTTP error response — a live-but-erroring backend — immediately.
|
|
9
|
+
|
|
10
|
+
describe('isBackendUnreachable', () => {
|
|
11
|
+
it('is true for a status-less network fault', () => {
|
|
12
|
+
expect(isBackendUnreachable(new Error('Failed to fetch'))).toBe(true)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('is false for an HTTP error response (the backend answered)', () => {
|
|
16
|
+
expect(isBackendUnreachable(new ApiError(500, {}))).toBe(false)
|
|
17
|
+
expect(isBackendUnreachable({ status: 503 })).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('is false for a schema-validation failure (a deterministic answer, not a dead socket)', () => {
|
|
21
|
+
// The backend answered but its body (or our request) didn't match the contract — retrying
|
|
22
|
+
// can never clear it, so it must surface at once rather than wait out the deadline.
|
|
23
|
+
expect(isBackendUnreachable(new SchemaValidationError([]))).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('retryWhileBackendUnreachable', () => {
|
|
28
|
+
afterEach(() => vi.useRealTimers())
|
|
29
|
+
|
|
30
|
+
it('returns the first result without waiting when the backend answers', async () => {
|
|
31
|
+
const fn = vi.fn(async () => 'ok')
|
|
32
|
+
await expect(retryWhileBackendUnreachable(fn)).resolves.toBe('ok')
|
|
33
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('retries a not-listening-yet backend, then succeeds', async () => {
|
|
37
|
+
vi.useFakeTimers()
|
|
38
|
+
let calls = 0
|
|
39
|
+
const fn = vi.fn(async () => {
|
|
40
|
+
if (++calls < 3) throw new Error('connection refused') // status-less
|
|
41
|
+
return 'ok'
|
|
42
|
+
})
|
|
43
|
+
const promise = retryWhileBackendUnreachable(fn)
|
|
44
|
+
await vi.advanceTimersByTimeAsync(10_000)
|
|
45
|
+
await expect(promise).resolves.toBe('ok')
|
|
46
|
+
expect(fn).toHaveBeenCalledTimes(3)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('rethrows an HTTP error response immediately (no retry)', async () => {
|
|
50
|
+
const fn = vi.fn(async () => {
|
|
51
|
+
throw new ApiError(500, {})
|
|
52
|
+
})
|
|
53
|
+
await expect(retryWhileBackendUnreachable(fn)).rejects.toBeInstanceOf(ApiError)
|
|
54
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('gives up and rethrows the last fault once the deadline passes', async () => {
|
|
58
|
+
vi.useFakeTimers()
|
|
59
|
+
const fn = vi.fn(async () => {
|
|
60
|
+
throw new Error('down')
|
|
61
|
+
})
|
|
62
|
+
const rejects = expect(retryWhileBackendUnreachable(fn, { deadlineMs: 1_000 })).rejects.toThrow(
|
|
63
|
+
'down',
|
|
64
|
+
)
|
|
65
|
+
await vi.advanceTimersByTimeAsync(2_000)
|
|
66
|
+
await rejects
|
|
67
|
+
expect(fn.mock.calls.length).toBeGreaterThan(1)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { SchemaValidationError } from '@toad-contracts/core'
|
|
2
|
+
import { apiErrorStatus } from '~/composables/api/errors'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* True only for a not-listening-yet backend — a connection-level fault (connection refused /
|
|
6
|
+
* reset / DNS) that throws before any HTTP round-trip completes, so it carries neither an HTTP
|
|
7
|
+
* status nor schema issues. Everything else is a real, deterministic answer that retrying can
|
|
8
|
+
* never clear, so it must surface at once, not loop:
|
|
9
|
+
*
|
|
10
|
+
* - An HTTP RESPONSE arrived — a declared non-2xx (`ApiError`) or an undeclared status
|
|
11
|
+
* (`UnexpectedResponseError`); both carry a `statusCode`, so `apiErrorStatus` returns it.
|
|
12
|
+
* - A `SchemaValidationError` — the backend answered but its body (or our own request) didn't
|
|
13
|
+
* match the contract, i.e. a version skew / client bug, NOT a dead socket.
|
|
14
|
+
*/
|
|
15
|
+
export function isBackendUnreachable(error: unknown): boolean {
|
|
16
|
+
if (error instanceof SchemaValidationError) return false
|
|
17
|
+
return apiErrorStatus(error) === undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface BackendRetryOptions {
|
|
21
|
+
/** Total wall-clock budget before we give up and rethrow (default 15s). */
|
|
22
|
+
deadlineMs?: number
|
|
23
|
+
/** First backoff delay; doubles each attempt up to {@link BackendRetryOptions.maxDelayMs}. */
|
|
24
|
+
baseDelayMs?: number
|
|
25
|
+
/** Backoff ceiling (default 2s). */
|
|
26
|
+
maxDelayMs?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Run `fn`, retrying while the backend is merely not-listening-yet (see
|
|
31
|
+
* {@link isBackendUnreachable}) with exponential backoff, up to a wall-clock deadline.
|
|
32
|
+
*
|
|
33
|
+
* This exists for the cold-open race: when the frontend and backend are started together,
|
|
34
|
+
* the SPA's first fetch can beat the backend's listener by a second or two. A single attempt
|
|
35
|
+
* would strand the board on the "Can't reach the backend" screen until a manual reload, even
|
|
36
|
+
* though the backend comes up moments later. An HTTP error response (any status) is a real
|
|
37
|
+
* answer, so it rethrows immediately rather than looping — we only wait out a dead socket.
|
|
38
|
+
*/
|
|
39
|
+
export async function retryWhileBackendUnreachable<T>(
|
|
40
|
+
fn: () => Promise<T>,
|
|
41
|
+
{ deadlineMs = 15_000, baseDelayMs = 300, maxDelayMs = 2_000 }: BackendRetryOptions = {},
|
|
42
|
+
): Promise<T> {
|
|
43
|
+
const start = Date.now()
|
|
44
|
+
for (let attempt = 0; ; attempt++) {
|
|
45
|
+
try {
|
|
46
|
+
return await fn()
|
|
47
|
+
} catch (error) {
|
|
48
|
+
const elapsed = Date.now() - start
|
|
49
|
+
if (!isBackendUnreachable(error) || elapsed >= deadlineMs) throw error
|
|
50
|
+
const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt, deadlineMs - elapsed)
|
|
51
|
+
await new Promise<void>((resolve) => setTimeout(resolve, delay))
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.137.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",
|
|
@@ -18,12 +18,12 @@
|
|
|
18
18
|
"access": "public"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@modular-frontend/core": "^0.
|
|
22
|
-
"@modular-vue/core": "^1.
|
|
21
|
+
"@modular-frontend/core": "^0.5.0",
|
|
22
|
+
"@modular-vue/core": "^1.4.0",
|
|
23
23
|
"@modular-vue/journeys": "^1.3.0",
|
|
24
24
|
"@modular-vue/nuxt": "^0.4.0",
|
|
25
25
|
"@modular-vue/runtime": "^1.4.1",
|
|
26
|
-
"@modular-vue/vue": "^1.
|
|
26
|
+
"@modular-vue/vue": "^1.4.0",
|
|
27
27
|
"@nuxt/ui": "^4.10.0",
|
|
28
28
|
"@nuxtjs/i18n": "^10.4.1",
|
|
29
29
|
"@pinia/nuxt": "^1.0.1",
|