@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.
@@ -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 FailureDetail from '~/components/board/FailureDetail.vue'
8
+ import FailureHistoryList from '~/components/board/FailureHistoryList.vue'
9
9
 
10
10
  const props = defineProps<{ failures: AgentFailure[] }>()
11
11
 
12
- const { t, d } = useI18n()
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
- <ol class="mt-2 space-y-2">
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
- async function submitReply(item: ClarityReviewItem) {
148
- if (!review.value) return
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(review.value, item.id, text)
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). Disabled once the
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 rail -->
480
- <aside class="hidden w-72 shrink-0 flex-col border-s border-slate-800 lg:flex">
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 has dropped and is reconnecting, so a
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. `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 }>()
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
- // 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)
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
- if (timer) {
28
- clearTimeout(timer)
29
- timer = null
30
- }
38
+ clearTimer()
31
39
  return
32
40
  }
33
- if (!everConnected.value || timer) return
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
- const visible = computed(() => everConnected.value && !props.connected && showAfterDelay.value)
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="visible"
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" />