@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.
@@ -1,7 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, reactive, ref, watch } from 'vue'
3
3
  import { onKeyStroke } from '@vueuse/core'
4
- import type { AgentContextSnapshot, LlmCallMetric } from '~/types/execution'
4
+ import type {
5
+ AgentContextSnapshot,
6
+ AgentSearchQuery,
7
+ LlmCallMetric,
8
+ WebSearchProvider,
9
+ } from '~/types/execution'
5
10
  import { agentKindMeta } from '~/utils/catalog'
6
11
  import { formatMs, formatTokens, pct } from '~/utils/observability'
7
12
 
@@ -32,9 +37,20 @@ const exporting = computed(
32
37
  const error = computed(() =>
33
38
  executionId.value ? (observability.errors[executionId.value] ?? null) : null,
34
39
  )
40
+ const contextError = computed(() =>
41
+ executionId.value ? (observability.contextErrors[executionId.value] ?? null) : null,
42
+ )
43
+
44
+ function retryCalls() {
45
+ if (executionId.value) void observability.load(executionId.value)
46
+ }
47
+ function retryContext() {
48
+ if (executionId.value) void observability.loadContext(executionId.value)
49
+ }
35
50
 
36
- // Which view is shown: the per-call model activity, or the complete provided context.
37
- const view = ref<'calls' | 'context'>('calls')
51
+ // Which view is shown: per-call model activity, the complete provided context, or the
52
+ // performed web searches.
53
+ const view = ref<'calls' | 'context' | 'search'>('calls')
38
54
 
39
55
  const contextSnapshots = computed<AgentContextSnapshot[]>(() =>
40
56
  executionId.value ? observability.contextFor(executionId.value) : [],
@@ -43,6 +59,39 @@ const contextLoading = computed(
43
59
  () => !!executionId.value && observability.isContextLoading(executionId.value),
44
60
  )
45
61
 
62
+ const searchQueries = computed<AgentSearchQuery[]>(() =>
63
+ executionId.value ? observability.searchQueriesFor(executionId.value) : [],
64
+ )
65
+ const searchLoading = computed(
66
+ () => !!executionId.value && observability.isSearchQueriesLoading(executionId.value),
67
+ )
68
+
69
+ // Brand names, kept verbatim across locales (not translatable prose).
70
+ const PROVIDER_LABEL: Record<WebSearchProvider, string> = { brave: 'Brave', searxng: 'SearXNG' }
71
+ function providerLabel(provider: WebSearchProvider | null): string {
72
+ return provider ? PROVIDER_LABEL[provider] : ''
73
+ }
74
+
75
+ // Whether web search was available to this run's container agents, and which provider(s)
76
+ // served it — a static per-run fact set on each container step at dispatch (not gated by
77
+ // prompt-recording telemetry, unlike the performed queries below).
78
+ const searchAvailability = computed<{ available: boolean; providers: WebSearchProvider[] } | null>(
79
+ () => {
80
+ const steps = (instance.value?.steps ?? []).filter((s) => s.search)
81
+ if (!steps.length) return null
82
+ const available = steps.some((s) => s.search?.available)
83
+ const providers = [
84
+ ...new Set(
85
+ steps
86
+ .map((s) => s.search)
87
+ .filter((x): x is NonNullable<typeof x> => !!x?.available && !!x.provider)
88
+ .map((x) => x.provider as WebSearchProvider),
89
+ ),
90
+ ]
91
+ return { available, providers }
92
+ },
93
+ )
94
+
46
95
  // Load (and refresh) whenever a different run's panel opens. Reset to the calls view
47
96
  // and load both the calls and the provided-context snapshots.
48
97
  watch(
@@ -52,6 +101,7 @@ watch(
52
101
  view.value = 'calls'
53
102
  void observability.load(id)
54
103
  void observability.loadContext(id)
104
+ void observability.loadSearchQueries(id)
55
105
  }
56
106
  },
57
107
  // Lazy v-if mount: the panel mounts with executionId already set, so load immediately.
@@ -182,6 +232,17 @@ function exportJson() {
182
232
  >
183
233
  {{ t('observability.providedContext') }}
184
234
  </button>
235
+ <button
236
+ class="rounded-md px-2.5 py-1 transition"
237
+ :class="
238
+ view === 'search'
239
+ ? 'bg-slate-800 text-slate-100'
240
+ : 'text-slate-400 hover:text-slate-200'
241
+ "
242
+ @click="view = 'search'"
243
+ >
244
+ {{ t('observability.webSearch') }}
245
+ </button>
185
246
  </div>
186
247
  <UButton
187
248
  v-if="view === 'calls'"
@@ -282,12 +343,22 @@ function exportJson() {
282
343
  <UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
283
344
  {{ t('observability.loadingActivity') }}
284
345
  </p>
285
- <p
346
+ <div
286
347
  v-else-if="error"
287
- class="rounded-lg border border-dashed border-rose-900/60 py-6 text-center text-sm text-rose-400"
348
+ class="flex flex-col items-center gap-3 rounded-lg border border-dashed border-rose-900/60 py-6 text-center text-sm text-rose-400"
288
349
  >
289
350
  {{ error }}
290
- </p>
351
+ <UButton
352
+ icon="i-lucide-rotate-cw"
353
+ color="neutral"
354
+ variant="soft"
355
+ size="xs"
356
+ :loading="loading"
357
+ @click="retryCalls"
358
+ >
359
+ {{ t('common.retry') }}
360
+ </UButton>
361
+ </div>
291
362
  <p
292
363
  v-else-if="!calls.length"
293
364
  class="rounded-lg border border-dashed border-slate-800 py-8 text-center text-sm text-slate-500"
@@ -434,7 +505,7 @@ function exportJson() {
434
505
  </div>
435
506
 
436
507
  <!-- Provided context: the complete context each container agent was given. -->
437
- <div v-else class="mx-auto max-w-4xl space-y-5">
508
+ <div v-else-if="view === 'context'" class="mx-auto max-w-4xl space-y-5">
438
509
  <p
439
510
  v-if="contextLoading && !contextSnapshots.length"
440
511
  class="flex items-center justify-center gap-2 py-8 text-center text-sm text-slate-500"
@@ -442,6 +513,22 @@ function exportJson() {
442
513
  <UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
443
514
  {{ t('observability.loadingContext') }}
444
515
  </p>
516
+ <div
517
+ v-else-if="contextError && !contextSnapshots.length"
518
+ class="flex flex-col items-center gap-3 rounded-lg border border-dashed border-rose-900/60 py-8 text-center text-sm text-rose-400"
519
+ >
520
+ {{ t('observability.contextError') }}
521
+ <UButton
522
+ icon="i-lucide-rotate-cw"
523
+ color="neutral"
524
+ variant="soft"
525
+ size="xs"
526
+ :loading="contextLoading"
527
+ @click="retryContext"
528
+ >
529
+ {{ t('common.retry') }}
530
+ </UButton>
531
+ </div>
445
532
  <p
446
533
  v-else-if="!contextSnapshots.length"
447
534
  class="rounded-lg border border-dashed border-slate-800 py-8 text-center text-sm text-slate-500"
@@ -553,6 +640,88 @@ function exportJson() {
553
640
  </li>
554
641
  </ul>
555
642
  </div>
643
+
644
+ <div v-else class="mx-auto max-w-4xl space-y-5">
645
+ <!-- Availability header: a static per-run fact (not telemetry-gated). -->
646
+ <section
647
+ v-if="searchAvailability"
648
+ class="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-xl border border-slate-800 bg-slate-900/50 px-4 py-3 text-[13px]"
649
+ >
650
+ <span class="text-[11px] uppercase tracking-wide text-slate-500">
651
+ {{ t('observability.webSearch') }}
652
+ </span>
653
+ <span
654
+ class="inline-flex items-center gap-1.5"
655
+ :class="searchAvailability.available ? 'text-emerald-300' : 'text-slate-400'"
656
+ >
657
+ <UIcon
658
+ :name="searchAvailability.available ? 'i-lucide-globe' : 'i-lucide-globe-lock'"
659
+ class="h-4 w-4"
660
+ />
661
+ {{
662
+ searchAvailability.available
663
+ ? t('observability.search.available')
664
+ : t('observability.search.unavailable')
665
+ }}
666
+ </span>
667
+ <span v-if="searchAvailability.providers.length" class="text-slate-400 tabular-nums">
668
+ {{ t('observability.search.provider') }}:
669
+ {{ searchAvailability.providers.map(providerLabel).join(', ') }}
670
+ </span>
671
+ </section>
672
+
673
+ <p
674
+ v-if="searchLoading && !searchQueries.length"
675
+ class="flex items-center justify-center gap-2 py-8 text-center text-sm text-slate-500"
676
+ >
677
+ <UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
678
+ {{ t('observability.loadingSearch') }}
679
+ </p>
680
+ <p
681
+ v-else-if="!searchQueries.length"
682
+ class="rounded-lg border border-dashed border-slate-800 py-8 text-center text-sm text-slate-500"
683
+ >
684
+ {{ t('observability.noSearch') }}
685
+ </p>
686
+
687
+ <div v-else>
688
+ <div class="mb-2 text-[11px] uppercase tracking-wide text-slate-500">
689
+ {{ t('observability.search.queriesTitle') }}
690
+ </div>
691
+ <ul class="space-y-2">
692
+ <li
693
+ v-for="q in searchQueries"
694
+ :key="q.id"
695
+ class="flex items-center gap-3 rounded-xl border border-slate-800 bg-slate-900/40 px-4 py-2.5"
696
+ >
697
+ <UIcon
698
+ :name="agentMeta(q.agentKind).icon"
699
+ class="h-4 w-4 shrink-0"
700
+ :style="{ color: agentMeta(q.agentKind).color }"
701
+ :title="agentMeta(q.agentKind).label"
702
+ />
703
+ <span class="min-w-0 flex-1 truncate text-[13px] text-slate-200" :title="q.query">
704
+ {{ q.query }}
705
+ </span>
706
+ <div
707
+ class="flex shrink-0 items-center gap-2.5 text-[11px] tabular-nums text-slate-400"
708
+ >
709
+ <span v-if="q.provider" class="hidden sm:inline">{{
710
+ providerLabel(q.provider)
711
+ }}</span>
712
+ <span>{{
713
+ t(
714
+ 'observability.search.resultsCount',
715
+ { count: q.resultCount },
716
+ q.resultCount,
717
+ )
718
+ }}</span>
719
+ <span class="hidden text-slate-600 md:inline">{{ clock(q.createdAt) }}</span>
720
+ </div>
721
+ </li>
722
+ </ul>
723
+ </div>
724
+ </div>
556
725
  </div>
557
726
  </div>
558
727
  </Transition>
@@ -59,6 +59,10 @@ const { open, blockId, instanceId, stepIndex, close } = useResultView('requireme
59
59
  docCollapsedOverride.value = null
60
60
  void requirements.load(id)
61
61
  },
62
+ // Closing the window (X, backdrop, Escape) must not silently drop an answer the user typed
63
+ // but never blurred out of. Flush before the view tears down; flushDrafts captures the
64
+ // review up front so the persist survives blockId going null on close (UX-33).
65
+ onClose: () => void flushDrafts(),
62
66
  })
63
67
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
64
68
  const review = computed<RequirementReview | null>(() =>
@@ -167,23 +171,29 @@ function notifyError(title: string, e: unknown) {
167
171
  // the recorded reply (see the watch below); editing and blurring persists it. Persist only
168
172
  // when the trimmed draft actually differs from what's already recorded, so blurring an
169
173
  // untouched field is a no-op.
170
- async function persistDraft(item: RequirementReviewItem) {
171
- if (!review.value || frozen.value) return
174
+ async function persistDraft(
175
+ item: RequirementReviewItem,
176
+ r: RequirementReview | null = review.value,
177
+ ) {
178
+ if (!r || frozen.value) return
172
179
  const text = (drafts.value[item.id] ?? '').trim()
173
180
  if (!text || text === (item.reply ?? '').trim()) return
174
181
  try {
175
- await requirements.reply(review.value, item.id, text)
182
+ await requirements.reply(r, item.id, text)
176
183
  } catch (e) {
177
184
  notifyError(t('requirements.errors.saveAnswer'), e)
178
185
  }
179
186
  }
180
187
 
181
- // Persist every dirty draft before an action that consumes the answers, so a value the
182
- // user typed but never blurred out of isn't lost.
188
+ // Persist every dirty draft before an action that consumes the answers (or on window close),
189
+ // so a value the user typed but never blurred out of isn't lost. Snapshots the review up front
190
+ // and threads it through, so the persist completes even if the window closes mid-flush (the
191
+ // reactive `review` goes null the moment the view tears down).
183
192
  async function flushDrafts() {
184
- if (!review.value) return
185
- for (const item of review.value.items) {
186
- if (item.status === 'open' || item.status === 'answered') await persistDraft(item)
193
+ const r = review.value
194
+ if (!r) return
195
+ for (const item of r.items) {
196
+ if (item.status === 'open' || item.status === 'answered') await persistDraft(item, r)
187
197
  }
188
198
  }
189
199
 
@@ -483,7 +493,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
483
493
  </div>
484
494
  </header>
485
495
 
486
- <div class="flex min-h-0 flex-1">
496
+ <div class="flex min-h-0 flex-1 flex-col lg:flex-row">
487
497
  <!-- main column -->
488
498
  <div class="min-w-0 flex-1 overflow-y-auto px-6 py-5">
489
499
  <i18n-t
@@ -842,10 +852,15 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
842
852
  </template>
843
853
  </div>
844
854
 
845
- <!-- right action rail -->
846
- <aside class="hidden w-72 shrink-0 flex-col border-s border-slate-800 lg:flex">
855
+ <!-- action rail: a right-hand column on wide screens, a bottom action bar below `lg`
856
+ (never hidden the gate is otherwise unadvanceable on a laptop split-screen /
857
+ tablet, UX-32). The informational stats collapse away below `lg` to keep the
858
+ bottom bar compact; the actions themselves always show. -->
859
+ <aside
860
+ class="flex w-full shrink-0 flex-col border-t border-slate-800 lg:w-72 lg:border-s lg:border-t-0"
861
+ >
847
862
  <div class="flex flex-col gap-4 px-4 py-5">
848
- <div v-if="review" class="space-y-2 text-xs text-slate-400">
863
+ <div v-if="review" class="hidden space-y-2 text-xs text-slate-400 lg:block">
849
864
  <div class="flex items-center justify-between">
850
865
  <span>{{ t('requirements.stats.findings') }}</span>
851
866
  <span class="text-slate-300">{{ review.items.length }}</span>
@@ -88,6 +88,11 @@ function selectGroup(m: number, g: number) {
88
88
  selected.value = { m, g }
89
89
  }
90
90
 
91
+ // Re-fetch after a load failure — the only escape used to be close-and-reopen.
92
+ function retry() {
93
+ if (blockId.value) void serviceSpec.load(blockId.value)
94
+ }
95
+
91
96
  // Exhaustive priority → label/chip map. Literal `t()` keys keep the typed-key drift
92
97
  // guard live, vs a runtime-built `spec.priority.${value}`.
93
98
  const PRIORITY_META: Record<RequirementPriority, { label: string; chip: string }> = {
@@ -187,10 +192,20 @@ function kindLabel(item: RequirementItem): string {
187
192
  <!-- error -->
188
193
  <div
189
194
  v-else-if="errored"
190
- class="flex flex-1 flex-col items-center justify-center gap-2 p-8 text-center text-sm text-slate-400"
195
+ class="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center text-sm text-slate-400"
191
196
  >
192
197
  <UIcon name="i-lucide-triangle-alert" class="h-6 w-6 text-amber-400" />
193
198
  {{ t('spec.error') }}
199
+ <UButton
200
+ icon="i-lucide-rotate-cw"
201
+ color="neutral"
202
+ variant="soft"
203
+ size="xs"
204
+ :loading="loading"
205
+ @click="retry"
206
+ >
207
+ {{ t('common.retry') }}
208
+ </UButton>
194
209
  </div>
195
210
 
196
211
  <!-- empty: no spec on the repo's default branch yet -->
@@ -4,6 +4,7 @@ import {
4
4
  exportExecutionLlmMetricsContract,
5
5
  getExecutionAgentContextContract,
6
6
  getExecutionLlmMetricsContract,
7
+ getExecutionSearchQueriesContract,
7
8
  mergeBlockContract,
8
9
  rejectStepContract,
9
10
  requestStepChangesContract,
@@ -145,6 +146,14 @@ export function executionApi({ send, sendWith, ws, pwHeaders }: ApiContext) {
145
146
  pathParams: { executionId },
146
147
  }),
147
148
 
149
+ // The web searches each container agent performed in a run (query, provider,
150
+ // result count). Empty when not wired / storing is off.
151
+ getSearchQueries: (workspaceId: string, executionId: string) =>
152
+ send(getExecutionSearchQueriesContract, {
153
+ pathPrefix: ws(workspaceId),
154
+ pathParams: { executionId },
155
+ }),
156
+
148
157
  // ---- spend safeguard --------------------------------------------------
149
158
  resumeSpend: (workspaceId: string) =>
150
159
  send(resumeSpendContract, { pathPrefix: ws(workspaceId) }),
@@ -93,6 +93,9 @@ export function usePipelineErrorToast() {
93
93
  : (conflict.message ?? t('errors.conflict.fallbackMessage')),
94
94
  color: 'error',
95
95
  icon: 'i-lucide-cpu',
96
+ // Stay until dismissed: an actionable toast whose remedy button vanishes on the ~5s
97
+ // auto-dismiss takes the one-click fix with it before the user can reach it.
98
+ duration: 0,
96
99
  actions: [
97
100
  {
98
101
  label: t('errors.conflict.providersUnconfigured.action'),
@@ -117,6 +120,9 @@ export function usePipelineErrorToast() {
117
120
  : (conflict.message ?? t('errors.conflict.fallbackMessage')),
118
121
  color: 'error',
119
122
  icon: 'i-lucide-image',
123
+ // Sticky, like the providers-unconfigured toast above: keep the "Configure storage"
124
+ // remedy reachable instead of letting it auto-dismiss.
125
+ duration: 0,
120
126
  actions: [
121
127
  {
122
128
  label: t('errors.conflict.binaryStorageUnconfigured.action'),
@@ -12,8 +12,17 @@
12
12
  *
13
13
  * A synchronous window (one that reads its data straight off the execution step, like the
14
14
  * test report) simply omits `onOpen`.
15
+ *
16
+ * `onClose` runs on EVERY close path — the X button, backdrop click, and the Escape key
17
+ * handled here — BEFORE the view is torn down, so a window with unsaved draft input (the
18
+ * review windows) can flush it in one place instead of every caller having to remember to.
19
+ * It runs synchronously; if it kicks off async work it must capture whatever it needs first,
20
+ * because `blockId`/the derived state go null the moment the view closes.
15
21
  */
16
- export function useResultView(viewId: string, opts?: { onOpen?: (blockId: string) => void }) {
22
+ export function useResultView(
23
+ viewId: string,
24
+ opts?: { onOpen?: (blockId: string) => void; onClose?: () => void },
25
+ ) {
17
26
  const ui = useUiStore()
18
27
 
19
28
  const open = computed(() => ui.resultView?.view === viewId)
@@ -26,6 +35,7 @@ export function useResultView(viewId: string, opts?: { onOpen?: (blockId: string
26
35
  const stage = computed(() => (open.value ? (ui.resultView!.stage ?? null) : null))
27
36
 
28
37
  function close() {
38
+ if (open.value) opts?.onClose?.()
29
39
  ui.closeResultView()
30
40
  }
31
41
 
@@ -31,6 +31,17 @@ export function useWorkspaceStream() {
31
31
  const apiBase = useRuntimeConfig().public.apiBase
32
32
 
33
33
  const connected = ref(false)
34
+ // Have we EVER been fully live (connected AND reconciled) for the current workspace? Drives the
35
+ // "reconnecting" vs "never connected" distinction in the banner. Set together with `connected`
36
+ // AFTER the on-open resync settles — NOT at `onopen` — so the initial resync window (socket open
37
+ // but not yet announced) can't be mistaken for a re-connection and flash the amber banner.
38
+ const everConnected = ref(false)
39
+ // The very first handshake keeps failing (proxy/firewall blocks WS while REST works, or the
40
+ // ticket mint throws) — the board loaded over REST but will never go live. Flagged after a
41
+ // few failed attempts so the banner can say "not receiving live updates" instead of nothing.
42
+ const connectionFailed = ref(false)
43
+ // Failed connect attempts before we ever go live gates the offline flag above.
44
+ const INITIAL_FAIL_ATTEMPTS = 3
34
45
 
35
46
  let socket: WebSocket | null = null
36
47
  let stopped = false
@@ -41,9 +52,29 @@ export function useWorkspaceStream() {
41
52
  // http→ws, https→wss (apiBase is an absolute origin, see nuxt.config.ts).
42
53
  const wsBase = String(apiBase).replace(/^http/, 'ws')
43
54
 
55
+ // A coarse board refresh (the resync on reconnect, and the `board` event fan-out) must not be
56
+ // left silently stale by ONE transient failure: retry a few times with backoff so a blip
57
+ // self-heals. Bounded (the socket-level reconnect + the offline banner are the backstop for a
58
+ // genuine outage). Aborts between attempts if the stream stopped or the workspace switched.
59
+ const REFRESH_MAX_ATTEMPTS = 4
60
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
61
+ async function refreshWithRetry(workspaceId: string): Promise<void> {
62
+ for (let i = 0; i < REFRESH_MAX_ATTEMPTS; i++) {
63
+ if (stopped || workspace.workspaceId !== workspaceId) return
64
+ try {
65
+ await workspace.refresh()
66
+ return
67
+ } catch {
68
+ if (i < REFRESH_MAX_ATTEMPTS - 1) await sleep(Math.min(4_000, 400 * 2 ** i))
69
+ }
70
+ }
71
+ }
72
+
44
73
  function debouncedBoardRefresh() {
74
+ const workspaceId = workspace.workspaceId
75
+ if (!workspaceId) return
45
76
  if (boardDebounce) clearTimeout(boardDebounce)
46
- boardDebounce = setTimeout(() => void workspace.refresh(), 300)
77
+ boardDebounce = setTimeout(() => void refreshWithRetry(workspaceId), 300)
47
78
  }
48
79
 
49
80
  function onMessage(raw: string) {
@@ -143,6 +174,7 @@ export function useWorkspaceStream() {
143
174
 
144
175
  socket.onopen = () => {
145
176
  attempt = 0
177
+ connectionFailed.value = false
146
178
  // Resync on (re)connect BEFORE announcing `connected`: any event missed while
147
179
  // disconnected is reconciled first. The snapshot carries `bootstrapJobs` +
148
180
  // executions, so one refresh rehydrates agentRuns too — a missed terminal event
@@ -157,18 +189,20 @@ export function useWorkspaceStream() {
157
189
  // live "bootstrapping…" badge flickers out with no further board event to restore
158
190
  // it. Anything acting on a `connected` board (a user, or an e2e spec gating on
159
191
  // `data-connected`) then does so only after this reconcile, so a lagging resync
160
- // can't drop the state that action produces. `connected` is still set on failure
161
- // (we ARE connected; a transient refresh error must not wedge the indicator/tests).
162
- void workspace
163
- .refresh()
164
- .catch(() => {})
165
- .finally(() => {
166
- // A workspace switch (or stop()) may have happened while the refresh was in
167
- // flight don't announce a connection for a socket we've since abandoned.
168
- if (!stopped && socket && workspace.workspaceId === workspaceId) {
169
- connected.value = true
170
- }
171
- })
192
+ // can't drop the state that action produces. The resync RETRIES on a transient
193
+ // failure (`refreshWithRetry`) so a reconnect no longer presents as fully live while
194
+ // silently missing everything from the outage; `connected` is still set even if every
195
+ // retry fails (we ARE connected; a refresh error must not wedge the indicator/tests).
196
+ void refreshWithRetry(workspaceId).finally(() => {
197
+ // A workspace switch (or stop()) may have happened while the refresh was in
198
+ // flight don't announce a connection for a socket we've since abandoned.
199
+ if (!stopped && socket && workspace.workspaceId === workspaceId) {
200
+ // Flip `everConnected` here (not at onopen): only now are we "fully live", so a later
201
+ // drop reads as a real re-connection while this initial resync window does not.
202
+ everConnected.value = true
203
+ connected.value = true
204
+ }
205
+ })
172
206
  }
173
207
  socket.onmessage = (e) => onMessage(typeof e.data === 'string' ? e.data : '')
174
208
  socket.onclose = () => {
@@ -181,6 +215,10 @@ export function useWorkspaceStream() {
181
215
  function scheduleReconnect() {
182
216
  if (stopped) return
183
217
  socket = null
218
+ // If we've never gone live and keep failing, flag the board as offline so the banner can
219
+ // surface a "not receiving live updates" state (a REST-only board otherwise looks fine but
220
+ // silently never updates). Reset the moment a socket opens (see `onopen`).
221
+ if (!everConnected.value && attempt + 1 >= INITIAL_FAIL_ATTEMPTS) connectionFailed.value = true
184
222
  const delay = Math.min(30_000, 500 * 2 ** attempt) // 0.5s → 30s cap
185
223
  attempt += 1
186
224
  reconnectTimer = setTimeout(connect, delay)
@@ -188,6 +226,11 @@ export function useWorkspaceStream() {
188
226
 
189
227
  function start() {
190
228
  stopped = false
229
+ // Reset the per-workspace connection lifecycle so a switch to a NEW workspace whose socket
230
+ // fails is flagged offline on its own merits, not masked by the previous workspace's history.
231
+ attempt = 0
232
+ everConnected.value = false
233
+ connectionFailed.value = false
191
234
  connect()
192
235
  }
193
236
 
@@ -201,5 +244,5 @@ export function useWorkspaceStream() {
201
244
  }
202
245
 
203
246
  onScopeDispose(stop)
204
- return { start, stop, connected }
247
+ return { start, stop, connected, everConnected, connectionFailed }
205
248
  }
@@ -243,6 +243,8 @@ const stream = useWorkspaceStream()
243
243
  // in the template would not unwrap, since `stream` is a plain object). Drives the headless
244
244
  // `workspace-stream` readiness marker the e2e suite waits on.
245
245
  const streamConnected = computed(() => stream.connected.value)
246
+ const streamEverConnected = computed(() => stream.everConnected.value)
247
+ const streamConnectionFailed = computed(() => stream.connectionFailed.value)
246
248
  watch(
247
249
  () => workspace.workspaceId,
248
250
  (id) => {
@@ -320,7 +322,11 @@ watch(
320
322
  />
321
323
  <BoardToolbar />
322
324
  <SpendWarningBanner />
323
- <ConnectionStatusBanner :connected="streamConnected" />
325
+ <ConnectionStatusBanner
326
+ :connected="streamConnected"
327
+ :ever-connected="streamEverConnected"
328
+ :connection-failed="streamConnectionFailed"
329
+ />
324
330
  <InspectorPanel />
325
331
  <!-- Code-split focus view. The fade lives here (not inside the component) so the
326
332
  leave animation still plays when `focusBlockId` clears and the v-if unmounts
@@ -557,7 +557,18 @@ export const useBoardStore = defineStore('board', () => {
557
557
  const t = getBlock(targetId)
558
558
  if (!t || !t.dependsOn.includes(sourceId)) return
559
559
  // the backend exposes a single toggle; the edge exists, so toggling removes it
560
- upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
560
+ try {
561
+ upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
562
+ } catch (e) {
563
+ // Mirror `toggleDependency`: a failure must surface (and leave the edge visible) rather
564
+ // than rejecting unhandled with no feedback.
565
+ toast.add({
566
+ title: tr('board.toast.unlinkFailed'),
567
+ description: e instanceof Error ? e.message : String(e),
568
+ icon: 'i-lucide-triangle-alert',
569
+ color: 'error',
570
+ })
571
+ }
561
572
  }
562
573
 
563
574
  return {
@@ -76,6 +76,41 @@ describe('execution store snapshot/event reconcile', () => {
76
76
  expect(store.getInstance('e1')?.rev).toBe(2)
77
77
  })
78
78
 
79
+ it('drops a superseded failed run when a retry replaces it under a new id (same block)', () => {
80
+ // A failed run for a block is cached...
81
+ store.hydrate(
82
+ [{ id: 'e_old', blockId: 'b1', steps: [], status: 'failed', rev: 1 } as never],
83
+ 'ws1',
84
+ )
85
+ // ...then a retry mints a FRESH run (new id) for the SAME block and deletes the old one
86
+ // server-side. The post-retry snapshot carries only the new running run.
87
+ store.hydrate(
88
+ [{ id: 'e_new', blockId: 'b1', steps: [], status: 'running', rev: 1 } as never],
89
+ 'ws1',
90
+ )
91
+ // The dead predecessor must not linger and shadow the running run in the by-block projection.
92
+ expect(store.getInstance('e_old')).toBeUndefined()
93
+ expect(store.getInstance('e_new')?.status).toBe('running')
94
+ expect(store.getByBlock('b1')?.id).toBe('e_new')
95
+ })
96
+
97
+ it('keeps a live-added running run when a stale snapshot still lists its block predecessor', () => {
98
+ // A retry already minted e_new (running) for b1 — a live event added it to the cache...
99
+ store.hydrate(
100
+ [{ id: 'e_new', blockId: 'b1', steps: [], status: 'running', rev: 1 } as never],
101
+ 'ws1',
102
+ )
103
+ // ...but a reconnect resync fetched BEFORE the retry resolves late (under load) and still
104
+ // carries the now-deleted predecessor e_old (failed) for the same block.
105
+ store.hydrate(
106
+ [{ id: 'e_old', blockId: 'b1', steps: [], status: 'failed', rev: 1 } as never],
107
+ 'ws1',
108
+ )
109
+ // The live running run must survive — only a TERMINAL cached run is a superseded predecessor.
110
+ expect(store.getInstance('e_new')?.status).toBe('running')
111
+ expect(store.getByBlock('b1')?.id).toBe('e_new')
112
+ })
113
+
79
114
  it('a workspace switch replaces the cache outright (no cross-board leak)', () => {
80
115
  store.hydrate([run('e1', 1, 'running')], 'ws1')
81
116
  store.upsert(run('e2', 1, 'running'))