@cat-factory/app 0.95.1 → 0.96.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/AgentFailureHistory.vue +3 -33
- package/app/components/board/FailureHistoryList.vue +46 -0
- package/app/components/clarity/ClarityReviewWindow.vue +83 -27
- package/app/components/layout/ConnectionStatusBanner.vue +42 -24
- package/app/components/panels/AgentStepDetail.vue +45 -0
- package/app/components/panels/ObservabilityPanel.vue +176 -7
- package/app/components/requirements/RequirementsReviewWindow.vue +27 -12
- package/app/components/spec/ServiceSpecWindow.vue +16 -1
- package/app/composables/api/execution.ts +9 -0
- package/app/composables/usePipelineErrorToast.ts +6 -0
- package/app/composables/useResultView.ts +11 -1
- package/app/composables/useWorkspaceStream.ts +57 -14
- package/app/pages/index.vue +7 -1
- package/app/stores/board.ts +12 -1
- package/app/stores/execution.spec.ts +35 -0
- package/app/stores/execution.ts +38 -3
- package/app/stores/observability.ts +52 -2
- package/app/stores/preview.ts +35 -3
- package/app/types/execution.ts +3 -0
- package/i18n/locales/en.json +16 -3
- package/i18n/locales/es.json +16 -3
- package/i18n/locales/fr.json +16 -3
- package/i18n/locales/he.json +16 -3
- package/i18n/locales/ja.json +16 -3
- package/i18n/locales/pl.json +16 -3
- package/i18n/locales/tr.json +16 -3
- package/i18n/locales/uk.json +16 -3
- package/package.json +2 -2
|
@@ -5,14 +5,11 @@
|
|
|
5
5
|
// banner disappears, but this collapsed history stays available so every previous error
|
|
6
6
|
// remains viewable. Renders nothing when there is no trail.
|
|
7
7
|
import type { AgentFailure } from '~/types/domain'
|
|
8
|
-
import
|
|
8
|
+
import FailureHistoryList from '~/components/board/FailureHistoryList.vue'
|
|
9
9
|
|
|
10
10
|
const props = defineProps<{ failures: AgentFailure[] }>()
|
|
11
11
|
|
|
12
|
-
const { t
|
|
13
|
-
|
|
14
|
-
// Newest attempt first — the most recent failure is the most relevant to look at.
|
|
15
|
-
const ordered = computed(() => [...props.failures].reverse())
|
|
12
|
+
const { t } = useI18n()
|
|
16
13
|
</script>
|
|
17
14
|
|
|
18
15
|
<template>
|
|
@@ -28,33 +25,6 @@ const ordered = computed(() => [...props.failures].reverse())
|
|
|
28
25
|
{{ t('board.failure.history.previousErrors', { count: failures.length }, failures.length) }}
|
|
29
26
|
</summary>
|
|
30
27
|
|
|
31
|
-
<
|
|
32
|
-
<li
|
|
33
|
-
v-for="failure in ordered"
|
|
34
|
-
:key="failure.occurredAt"
|
|
35
|
-
class="rounded-md border border-slate-800/80 bg-slate-950/50 px-2.5 py-2"
|
|
36
|
-
data-testid="agent-failure-history-entry"
|
|
37
|
-
>
|
|
38
|
-
<div class="flex items-center gap-1.5 text-[10px] text-slate-500">
|
|
39
|
-
<UIcon name="i-lucide-alert-triangle" class="h-3 w-3 shrink-0 text-rose-400/70" />
|
|
40
|
-
<time>{{ d(new Date(failure.occurredAt), 'long') }}</time>
|
|
41
|
-
</div>
|
|
42
|
-
|
|
43
|
-
<p class="mt-1 text-[11px] leading-snug text-slate-300" :title="failure.message">
|
|
44
|
-
{{ failure.message }}
|
|
45
|
-
</p>
|
|
46
|
-
|
|
47
|
-
<p v-if="failure.hint" class="mt-1 text-[10px] leading-snug text-slate-500">
|
|
48
|
-
{{ failure.hint }}
|
|
49
|
-
</p>
|
|
50
|
-
|
|
51
|
-
<FailureDetail
|
|
52
|
-
:detail="failure.detail"
|
|
53
|
-
:message="failure.message"
|
|
54
|
-
summary-class="text-[10px] text-slate-500 hover:text-slate-300"
|
|
55
|
-
pre-class="bg-slate-950/80 text-[10px] text-slate-400"
|
|
56
|
-
/>
|
|
57
|
-
</li>
|
|
58
|
-
</ol>
|
|
28
|
+
<FailureHistoryList :failures="props.failures" class="mt-2" />
|
|
59
29
|
</details>
|
|
60
30
|
</template>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The newest-first list of failed-attempt entries (timestamp + message + hint + collapsible
|
|
3
|
+
// detail), shared by the task-inspector's "previous errors" disclosure (AgentFailureHistory)
|
|
4
|
+
// and the step-detail overlay's per-step "execution history". Presentational only — the caller
|
|
5
|
+
// decides which trail to pass (the whole run's, or one step's) and how to reveal it.
|
|
6
|
+
import type { AgentFailure } from '~/types/domain'
|
|
7
|
+
import FailureDetail from '~/components/board/FailureDetail.vue'
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{ failures: AgentFailure[] }>()
|
|
10
|
+
|
|
11
|
+
const { d } = useI18n()
|
|
12
|
+
|
|
13
|
+
// Newest attempt first — the most recent failure is the most relevant to look at.
|
|
14
|
+
const ordered = computed(() => [...props.failures].reverse())
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<ol class="space-y-2">
|
|
19
|
+
<li
|
|
20
|
+
v-for="failure in ordered"
|
|
21
|
+
:key="failure.occurredAt"
|
|
22
|
+
class="rounded-md border border-slate-800/80 bg-slate-950/50 px-2.5 py-2"
|
|
23
|
+
data-testid="agent-failure-history-entry"
|
|
24
|
+
>
|
|
25
|
+
<div class="flex items-center gap-1.5 text-[10px] text-slate-500">
|
|
26
|
+
<UIcon name="i-lucide-alert-triangle" class="h-3 w-3 shrink-0 text-rose-400/70" />
|
|
27
|
+
<time>{{ d(new Date(failure.occurredAt), 'long') }}</time>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<p class="mt-1 text-[11px] leading-snug text-slate-300" :title="failure.message">
|
|
31
|
+
{{ failure.message }}
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
<p v-if="failure.hint" class="mt-1 text-[10px] leading-snug text-slate-500">
|
|
35
|
+
{{ failure.hint }}
|
|
36
|
+
</p>
|
|
37
|
+
|
|
38
|
+
<FailureDetail
|
|
39
|
+
:detail="failure.detail"
|
|
40
|
+
:message="failure.message"
|
|
41
|
+
summary-class="text-[10px] text-slate-500 hover:text-slate-300"
|
|
42
|
+
pre-class="bg-slate-950/80 text-[10px] text-slate-400"
|
|
43
|
+
/>
|
|
44
|
+
</li>
|
|
45
|
+
</ol>
|
|
46
|
+
</template>
|
|
@@ -27,6 +27,10 @@ const { t } = useI18n()
|
|
|
27
27
|
|
|
28
28
|
// Draft replies, keyed by item id, so editing one item doesn't disturb others.
|
|
29
29
|
const drafts = ref<Record<string, string>>({})
|
|
30
|
+
// The server-side reply each draft was last seeded/synced to, so the seeding watch can refresh
|
|
31
|
+
// a draft when the recorded reply changes server-side WITHOUT clobbering one the human is
|
|
32
|
+
// actively editing (mirrors the requirements window).
|
|
33
|
+
const seededReply = ref<Record<string, string>>({})
|
|
30
34
|
// Freeform "do it differently" comment when redoing a merge the human was unhappy with.
|
|
31
35
|
const redoComment = ref('')
|
|
32
36
|
const showRedo = ref(false)
|
|
@@ -39,10 +43,14 @@ const showRedo = ref(false)
|
|
|
39
43
|
const { open, blockId, close } = useResultView('clarity-review', {
|
|
40
44
|
onOpen: (id) => {
|
|
41
45
|
drafts.value = {}
|
|
46
|
+
seededReply.value = {}
|
|
42
47
|
redoComment.value = ''
|
|
43
48
|
showRedo.value = false
|
|
44
49
|
void clarity.load(id)
|
|
45
50
|
},
|
|
51
|
+
// Flush any typed-but-unblurred answer before the view tears down (X, backdrop, Escape) so
|
|
52
|
+
// closing the window never silently drops it (UX-33).
|
|
53
|
+
onClose: () => void flushDrafts(),
|
|
46
54
|
})
|
|
47
55
|
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|
|
48
56
|
const review = computed<ClarityReview | null>(() =>
|
|
@@ -144,18 +152,70 @@ function notifyError(title: string, e: unknown) {
|
|
|
144
152
|
})
|
|
145
153
|
}
|
|
146
154
|
|
|
147
|
-
|
|
148
|
-
|
|
155
|
+
// Answers auto-save on blur — no explicit "save" button (matching the requirements window, so
|
|
156
|
+
// muscle memory carries across the two, UX-34). The textarea is pre-seeded with the recorded
|
|
157
|
+
// reply (see the watch below); persist only when the trimmed draft actually differs from what's
|
|
158
|
+
// already recorded, so blurring an untouched field is a no-op.
|
|
159
|
+
async function persistDraft(item: ClarityReviewItem, r: ClarityReview | null = review.value) {
|
|
160
|
+
if (!r || frozen.value) return
|
|
149
161
|
const text = (drafts.value[item.id] ?? '').trim()
|
|
150
|
-
if (!text) return
|
|
162
|
+
if (!text || text === (item.reply ?? '').trim()) return
|
|
151
163
|
try {
|
|
152
|
-
await clarity.reply(
|
|
153
|
-
drafts.value = { ...drafts.value, [item.id]: '' }
|
|
164
|
+
await clarity.reply(r, item.id, text)
|
|
154
165
|
} catch (e) {
|
|
155
166
|
notifyError(t('clarity.error.saveAnswer'), e)
|
|
156
167
|
}
|
|
157
168
|
}
|
|
158
169
|
|
|
170
|
+
// Persist every dirty draft before an action that consumes the answers (or on window close).
|
|
171
|
+
// Snapshots the review up front and threads it through, so the persist completes even if the
|
|
172
|
+
// window closes mid-flush (the reactive `review` goes null the moment the view tears down).
|
|
173
|
+
async function flushDrafts() {
|
|
174
|
+
const r = review.value
|
|
175
|
+
if (!r) return
|
|
176
|
+
for (const item of r.items) {
|
|
177
|
+
if (item.status === 'open' || item.status === 'answered') await persistDraft(item, r)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Seed a draft for each finding from its recorded reply so the textarea shows the current
|
|
182
|
+
// answer (editing in place). New findings from a re-review get seeded; a draft the user hasn't
|
|
183
|
+
// diverged from is refreshed when the recorded reply changes server-side; drafts the user is
|
|
184
|
+
// actively editing are left untouched.
|
|
185
|
+
watch(
|
|
186
|
+
review,
|
|
187
|
+
(r) => {
|
|
188
|
+
if (!r) return
|
|
189
|
+
const nextDrafts = { ...drafts.value }
|
|
190
|
+
const nextSeeded = { ...seededReply.value }
|
|
191
|
+
let changed = false
|
|
192
|
+
for (const item of r.items) {
|
|
193
|
+
const reply = item.reply ?? ''
|
|
194
|
+
if (!(item.id in nextDrafts)) {
|
|
195
|
+
nextDrafts[item.id] = reply
|
|
196
|
+
nextSeeded[item.id] = reply
|
|
197
|
+
changed = true
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
const draft = nextDrafts[item.id] ?? ''
|
|
201
|
+
const seeded = nextSeeded[item.id] ?? ''
|
|
202
|
+
if (draft === seeded && draft !== reply) {
|
|
203
|
+
nextDrafts[item.id] = reply
|
|
204
|
+
nextSeeded[item.id] = reply
|
|
205
|
+
changed = true
|
|
206
|
+
} else if (draft === reply && seeded !== reply) {
|
|
207
|
+
nextSeeded[item.id] = reply
|
|
208
|
+
changed = true
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (changed) {
|
|
212
|
+
drafts.value = nextDrafts
|
|
213
|
+
seededReply.value = nextSeeded
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
{ immediate: true },
|
|
217
|
+
)
|
|
218
|
+
|
|
159
219
|
async function setStatus(item: ClarityReviewItem, itemStatus: ClarityItemStatus) {
|
|
160
220
|
if (!review.value) return
|
|
161
221
|
try {
|
|
@@ -168,6 +228,7 @@ async function setStatus(item: ClarityReviewItem, itemStatus: ClarityItemStatus)
|
|
|
168
228
|
async function incorporate(feedback?: string) {
|
|
169
229
|
if (!review.value || !blockId.value) return
|
|
170
230
|
try {
|
|
231
|
+
await flushDrafts()
|
|
171
232
|
await clarity.incorporate(review.value, feedback)
|
|
172
233
|
} catch (e) {
|
|
173
234
|
notifyError(t('clarity.error.incorporate'), e)
|
|
@@ -208,6 +269,7 @@ async function proceed() {
|
|
|
208
269
|
if (!blockId.value) return
|
|
209
270
|
acting.value = true
|
|
210
271
|
try {
|
|
272
|
+
await flushDrafts()
|
|
211
273
|
await clarity.proceed(blockId.value)
|
|
212
274
|
toast.add({ title: t('clarity.toast.proceeding'), icon: 'i-lucide-arrow-right' })
|
|
213
275
|
} catch (e) {
|
|
@@ -269,7 +331,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
269
331
|
</div>
|
|
270
332
|
</header>
|
|
271
333
|
|
|
272
|
-
<div class="flex min-h-0 flex-1">
|
|
334
|
+
<div class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
|
273
335
|
<!-- main column -->
|
|
274
336
|
<div class="min-w-0 flex-1 overflow-y-auto px-6 py-5">
|
|
275
337
|
<p class="mb-4 text-sm text-slate-400">
|
|
@@ -372,9 +434,10 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
372
434
|
{{ item.detail }}
|
|
373
435
|
</p>
|
|
374
436
|
|
|
375
|
-
<!-- recorded answer
|
|
437
|
+
<!-- recorded answer (only for non-editable findings — for editable ones
|
|
438
|
+
the answer lives in the textarea below, seeded from the reply) -->
|
|
376
439
|
<div
|
|
377
|
-
v-if="item.reply"
|
|
440
|
+
v-if="item.reply && item.status !== 'open' && item.status !== 'answered'"
|
|
378
441
|
class="mt-2 rounded-md border-s-2 border-slate-700 bg-slate-950/40 px-3 py-1.5 text-sm text-slate-300"
|
|
379
442
|
>
|
|
380
443
|
<span class="text-[10px] uppercase tracking-wide text-slate-500">
|
|
@@ -383,7 +446,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
383
446
|
<p class="whitespace-pre-line">{{ item.reply }}</p>
|
|
384
447
|
</div>
|
|
385
448
|
|
|
386
|
-
<!-- react: answer (relevant) or dismiss (irrelevant).
|
|
449
|
+
<!-- react: answer (relevant) or dismiss (irrelevant). The answer
|
|
450
|
+
auto-saves on blur — no explicit save button. Disabled once the
|
|
387
451
|
bug report is clarified / awaiting a higher-level decision. -->
|
|
388
452
|
<template v-if="item.status === 'open' || item.status === 'answered'">
|
|
389
453
|
<UTextarea
|
|
@@ -392,24 +456,11 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
392
456
|
autoresize
|
|
393
457
|
size="sm"
|
|
394
458
|
class="mt-2 w-full"
|
|
395
|
-
:placeholder="
|
|
396
|
-
item.reply
|
|
397
|
-
? t('clarity.refineAnswerPlaceholder')
|
|
398
|
-
: t('clarity.answerPlaceholder')
|
|
399
|
-
"
|
|
459
|
+
:placeholder="t('clarity.answerPlaceholder')"
|
|
400
460
|
:disabled="frozen"
|
|
461
|
+
@blur="persistDraft(item)"
|
|
401
462
|
/>
|
|
402
463
|
<div class="mt-2 flex flex-wrap items-center gap-2">
|
|
403
|
-
<UButton
|
|
404
|
-
color="primary"
|
|
405
|
-
variant="soft"
|
|
406
|
-
size="xs"
|
|
407
|
-
icon="i-lucide-corner-down-left"
|
|
408
|
-
:disabled="!(drafts[item.id] ?? '').trim() || frozen"
|
|
409
|
-
@click="submitReply(item)"
|
|
410
|
-
>
|
|
411
|
-
{{ t('clarity.saveAnswer') }}
|
|
412
|
-
</UButton>
|
|
413
464
|
<UButton
|
|
414
465
|
color="neutral"
|
|
415
466
|
variant="ghost"
|
|
@@ -476,10 +527,15 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
476
527
|
</template>
|
|
477
528
|
</div>
|
|
478
529
|
|
|
479
|
-
<!-- right action
|
|
480
|
-
|
|
530
|
+
<!-- action rail: a right-hand column on wide screens, a bottom action bar below `lg`
|
|
531
|
+
(never hidden — the gate is otherwise unadvanceable on a laptop split-screen /
|
|
532
|
+
tablet, UX-32). The informational stats collapse away below `lg` to keep the
|
|
533
|
+
bottom bar compact; the actions themselves always show. -->
|
|
534
|
+
<aside
|
|
535
|
+
class="flex w-full shrink-0 flex-col border-t border-slate-800 lg:w-72 lg:border-s lg:border-t-0"
|
|
536
|
+
>
|
|
481
537
|
<div class="flex flex-col gap-4 px-4 py-5">
|
|
482
|
-
<div v-if="review" class="space-y-2 text-xs text-slate-400">
|
|
538
|
+
<div v-if="review" class="hidden space-y-2 text-xs text-slate-400 lg:block">
|
|
483
539
|
<div class="flex items-center justify-between">
|
|
484
540
|
<span>{{ t('clarity.rail.findings') }}</span>
|
|
485
541
|
<span class="text-slate-300">{{ review.items.length }}</span>
|
|
@@ -1,36 +1,44 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
3
3
|
|
|
4
|
-
// A slim top strip shown when the real-time WebSocket
|
|
4
|
+
// A slim top strip shown when the real-time WebSocket isn't delivering events, so a
|
|
5
5
|
// silently-frozen board (events stop arriving, nothing updates) is no longer indistinguishable
|
|
6
|
-
// from an idle one.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
6
|
+
// from an idle one. Two states:
|
|
7
|
+
// - RE-connecting: we were live and the socket dropped (`useWorkspaceStream` reconnects with
|
|
8
|
+
// exponential backoff and resyncs on reconnect — this just makes that state visible).
|
|
9
|
+
// - Offline: the very first handshake keeps failing (`connectionFailed`), so the board loaded
|
|
10
|
+
// over REST but will never go live — a user watching a run would otherwise see a frozen
|
|
11
|
+
// board with no hint why.
|
|
12
|
+
// The `connected` / `everConnected` / `connectionFailed` refs are passed in as props (the page
|
|
13
|
+
// owns the single stream instance; creating another here would open a second socket).
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
connected: boolean
|
|
16
|
+
everConnected: boolean
|
|
17
|
+
connectionFailed: boolean
|
|
18
|
+
}>()
|
|
11
19
|
|
|
12
20
|
const { t } = useI18n()
|
|
13
21
|
|
|
14
|
-
//
|
|
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)
|
|
22
|
+
// A short debounce rides out a quick socket flap so a momentary blip doesn't flash the strip.
|
|
18
23
|
const showAfterDelay = ref(false)
|
|
19
24
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
20
25
|
|
|
26
|
+
function clearTimer() {
|
|
27
|
+
if (timer) {
|
|
28
|
+
clearTimeout(timer)
|
|
29
|
+
timer = null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
watch(
|
|
22
34
|
() => props.connected,
|
|
23
35
|
(connected) => {
|
|
24
36
|
if (connected) {
|
|
25
|
-
everConnected.value = true
|
|
26
37
|
showAfterDelay.value = false
|
|
27
|
-
|
|
28
|
-
clearTimeout(timer)
|
|
29
|
-
timer = null
|
|
30
|
-
}
|
|
38
|
+
clearTimer()
|
|
31
39
|
return
|
|
32
40
|
}
|
|
33
|
-
if (
|
|
41
|
+
if (timer) return
|
|
34
42
|
timer = setTimeout(() => {
|
|
35
43
|
showAfterDelay.value = true
|
|
36
44
|
timer = null
|
|
@@ -39,24 +47,24 @@ watch(
|
|
|
39
47
|
{ immediate: true },
|
|
40
48
|
)
|
|
41
49
|
|
|
42
|
-
|
|
50
|
+
// Reconnection: only surface once we've been connected — a later drop is a real interruption.
|
|
51
|
+
const reconnecting = computed(() => props.everConnected && !props.connected && showAfterDelay.value)
|
|
52
|
+
// Offline: never connected and repeated attempts failed. No debounce — it already took several
|
|
53
|
+
// backoff cycles to flag, so it's not a flap.
|
|
54
|
+
const offline = computed(() => props.connectionFailed && !props.connected && !props.everConnected)
|
|
43
55
|
|
|
44
56
|
// 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
|
-
})
|
|
57
|
+
onBeforeUnmount(clearTimer)
|
|
51
58
|
</script>
|
|
52
59
|
|
|
53
60
|
<template>
|
|
54
61
|
<Transition name="fade">
|
|
55
62
|
<div
|
|
56
|
-
v-if="
|
|
63
|
+
v-if="reconnecting || offline"
|
|
57
64
|
class="pointer-events-none absolute inset-x-0 top-0 z-50 flex justify-center px-4 pt-2"
|
|
58
65
|
>
|
|
59
66
|
<div
|
|
67
|
+
v-if="reconnecting"
|
|
60
68
|
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
69
|
role="status"
|
|
62
70
|
aria-live="polite"
|
|
@@ -65,6 +73,16 @@ onBeforeUnmount(() => {
|
|
|
65
73
|
<UIcon name="i-lucide-loader" class="h-3.5 w-3.5 animate-spin" />
|
|
66
74
|
<span>{{ t('app.reconnecting') }}</span>
|
|
67
75
|
</div>
|
|
76
|
+
<div
|
|
77
|
+
v-else
|
|
78
|
+
class="pointer-events-auto flex items-center gap-2 rounded-full border border-rose-500/60 bg-rose-950/90 px-3 py-1.5 text-xs text-rose-100 shadow-lg backdrop-blur"
|
|
79
|
+
role="status"
|
|
80
|
+
aria-live="polite"
|
|
81
|
+
data-testid="stream-offline"
|
|
82
|
+
>
|
|
83
|
+
<UIcon name="i-lucide-wifi-off" class="h-3.5 w-3.5" />
|
|
84
|
+
<span>{{ t('app.offline') }}</span>
|
|
85
|
+
</div>
|
|
68
86
|
</div>
|
|
69
87
|
</Transition>
|
|
70
88
|
</template>
|
|
@@ -11,6 +11,7 @@ import FrontendBindingsResolved from '~/components/panels/inspector/FrontendBind
|
|
|
11
11
|
import { UI_TESTER_AGENT_KIND } from '@cat-factory/contracts'
|
|
12
12
|
import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
|
|
13
13
|
import IterationCapPrompt from '~/components/pipeline/IterationCapPrompt.vue'
|
|
14
|
+
import FailureHistoryList from '~/components/board/FailureHistoryList.vue'
|
|
14
15
|
import { useStepTimer } from '~/composables/useStepTimer'
|
|
15
16
|
import { useStepProse } from '~/composables/useStepProse'
|
|
16
17
|
import { useStepApproval } from '~/composables/useStepApproval'
|
|
@@ -84,6 +85,19 @@ const runNotes = computed(() => (isFrontendFrame.value ? (instance.value?.notes
|
|
|
84
85
|
const showProvisioning = ref(false)
|
|
85
86
|
const executionId = computed(() => instance.value?.id ?? null)
|
|
86
87
|
|
|
88
|
+
// This step's own "execution history": the run-level failure trail narrowed to the failures
|
|
89
|
+
// recorded for THIS step (each carries the `stepIndex` it failed at). Includes the current
|
|
90
|
+
// failure when the run is presently failed at this step (it moves into `failureHistory` only on
|
|
91
|
+
// the next retry). Revealed behind a toggle, mirroring the infra-attempts drawer above.
|
|
92
|
+
const stepFailures = computed(() => {
|
|
93
|
+
const idx = ctx.value?.stepIndex
|
|
94
|
+
if (idx == null) return []
|
|
95
|
+
const trail = [...(instance.value?.failureHistory ?? [])]
|
|
96
|
+
if (instance.value?.failure) trail.push(instance.value.failure)
|
|
97
|
+
return trail.filter((f) => f.stepIndex === idx)
|
|
98
|
+
})
|
|
99
|
+
const showHistory = ref(false)
|
|
100
|
+
|
|
87
101
|
// A failed run is no longer executing: a step left mid-flight (state still
|
|
88
102
|
// `working`, no `finishedAt`) must stop looking live — no ticking clock, no
|
|
89
103
|
// "spinning up" phase, no spinner.
|
|
@@ -188,6 +202,8 @@ watch(
|
|
|
188
202
|
() => {
|
|
189
203
|
prose.reset()
|
|
190
204
|
approval.resetForStep()
|
|
205
|
+
// Collapse the per-step execution history so reopening a different step starts clean.
|
|
206
|
+
showHistory.value = false
|
|
191
207
|
},
|
|
192
208
|
)
|
|
193
209
|
|
|
@@ -423,6 +439,35 @@ async function copyOutput() {
|
|
|
423
439
|
/>
|
|
424
440
|
</div>
|
|
425
441
|
|
|
442
|
+
<!-- this step's failure trail (the run-level history narrowed to this step),
|
|
443
|
+
behind a toggle — mirrors the "previous errors" history on the task inspector
|
|
444
|
+
but scoped to the step the user is looking at -->
|
|
445
|
+
<div v-if="stepFailures.length">
|
|
446
|
+
<UButton
|
|
447
|
+
:icon="showHistory ? 'i-lucide-chevron-up' : 'i-lucide-history'"
|
|
448
|
+
variant="ghost"
|
|
449
|
+
size="xs"
|
|
450
|
+
data-testid="step-execution-history-toggle"
|
|
451
|
+
@click="
|
|
452
|
+
() => {
|
|
453
|
+
showHistory = !showHistory
|
|
454
|
+
}
|
|
455
|
+
"
|
|
456
|
+
>
|
|
457
|
+
{{
|
|
458
|
+
showHistory
|
|
459
|
+
? t('panels.stepDetail.hideExecutionHistory')
|
|
460
|
+
: t('panels.stepDetail.executionHistory')
|
|
461
|
+
}}
|
|
462
|
+
</UButton>
|
|
463
|
+
<FailureHistoryList
|
|
464
|
+
v-if="showHistory"
|
|
465
|
+
class="mt-2"
|
|
466
|
+
:failures="stepFailures"
|
|
467
|
+
data-testid="step-execution-history"
|
|
468
|
+
/>
|
|
469
|
+
</div>
|
|
470
|
+
|
|
426
471
|
<!-- tester report: what was tested, the per-area outcomes, the concerns
|
|
427
472
|
it raised and the greenlight verdict; plus the fixer-loop phase -->
|
|
428
473
|
<StepTestReport v-if="testReport" :report="testReport" :phase="testPhase" />
|