@cat-factory/app 0.232.1 → 0.233.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.
@@ -107,12 +107,17 @@ const boardItems = computed<DropdownMenuItem[][]>(() => [
107
107
  // manages membership can see at a glance which boards are scoped vs open to the account.
108
108
  icon: w.accessMode === 'restricted' ? 'i-lucide-lock' : 'i-lucide-layout-dashboard',
109
109
  trailingIcon: w.id === workspace.workspaceId ? 'i-lucide-check' : undefined,
110
+ // Addressable per board id: which board a row opens is the one thing about this menu a test
111
+ // (or a screen reader's user testing a switch) cannot get from the label, which is a name a
112
+ // person chose and two boards may share.
113
+ 'data-testid': `board-option-${w.id}`,
110
114
  onSelect: () => void switchBoard(w.id),
111
115
  })),
112
116
  [
113
117
  {
114
118
  label: t('layout.boardSwitcher.board.new'),
115
119
  icon: 'i-lucide-plus',
120
+ 'data-testid': 'board-new',
116
121
  onSelect: () => openPrompt('board'),
117
122
  },
118
123
  // Rename/delete of the active board is `settings.manage` — omit for a member/viewer,
@@ -290,6 +295,8 @@ async function submitPrompt() {
290
295
  class="flex w-full items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/60 px-2.5 py-1.5 text-start transition hover:bg-slate-800/60"
291
296
  :class="collapsed ? 'justify-center' : ''"
292
297
  :disabled="busy"
298
+ data-testid="board-switcher"
299
+ :data-board-id="workspace.workspaceId ?? ''"
293
300
  >
294
301
  <UIcon name="i-lucide-layout-dashboard" class="h-4 w-4 shrink-0 text-indigo-400" />
295
302
  <span v-if="!collapsed" class="truncate text-sm font-medium text-white">
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts" generic="T extends string">
2
+ // The ok/failure narrowing shared by the observability panel's two drill-downs (model calls and
3
+ // the tool-call trajectory).
4
+ //
5
+ // Every option carries its COUNT and stays rendered at zero. A chip that disappeared when its
6
+ // class was empty would answer "are there any failures?" by absence, which reads exactly like a
7
+ // list that has not finished loading — and "no tool call failed" is precisely the fact this
8
+ // panel exists to state out loud rather than imply.
9
+ const props = defineProps<{
10
+ /** The options, in display order. `count` is what each would show if selected. */
11
+ options: readonly { value: T; label: string; count: number; tone?: 'error' | 'warning' }[]
12
+ modelValue: T
13
+ }>()
14
+ const emit = defineEmits<{ 'update:modelValue': [value: T] }>()
15
+
16
+ /** Colour a chip by what it selects, so the failing one reads as failing even unselected. */
17
+ function toneClass(option: (typeof props.options)[number], active: boolean): string {
18
+ if (active) return 'bg-slate-800 text-slate-100'
19
+ if (option.count === 0) return 'text-slate-600 hover:text-slate-400'
20
+ if (option.tone === 'error') return 'text-rose-400 hover:text-rose-300'
21
+ if (option.tone === 'warning') return 'text-amber-400 hover:text-amber-300'
22
+ return 'text-slate-400 hover:text-slate-200'
23
+ }
24
+ </script>
25
+
26
+ <template>
27
+ <div class="flex rounded-lg border border-slate-800 p-0.5 text-[12px]">
28
+ <button
29
+ v-for="option in options"
30
+ :key="option.value"
31
+ type="button"
32
+ class="rounded-md px-2.5 py-1 transition"
33
+ :class="toneClass(option, option.value === modelValue)"
34
+ :aria-pressed="option.value === modelValue"
35
+ @click="emit('update:modelValue', option.value)"
36
+ >
37
+ {{ option.label }}
38
+ <span class="tabular-nums opacity-70">{{ option.count }}</span>
39
+ </button>
40
+ </div>
41
+ </template>
@@ -0,0 +1,243 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { RunFailureEvidence } from '~/utils/observability'
4
+ import { noFailingCallReason } from '~/utils/observability'
5
+ import { agentKindMeta } from '~/utils/catalog'
6
+ import { FAILURE_KIND_KEYS } from '~/utils/failureKinds'
7
+ import FailureDetail from '~/components/board/FailureDetail.vue'
8
+
9
+ // The panel's FIRST section: what broke, pinned above the call list instead of found by
10
+ // scrolling it.
11
+ //
12
+ // Three things are shown together because each answers a question the others cannot:
13
+ //
14
+ // - the run's own structured failure record (`agent_runs.failure`), which names the class of
15
+ // death and where to look, but never which call;
16
+ // - the last MODEL call that failed, the transport / proxy / spend-gate side;
17
+ // - the last TOOL call that failed, the side no rollup counts — a tool that errors inside the
18
+ // container leaves the model call that requested it reporting `ok` with a clean finish
19
+ // reason, so every LLM number on this panel reads healthy right up to the moment the run dies.
20
+ //
21
+ // The two evidence rows are shown in a FIXED order and are not ranked against each other. They
22
+ // come from different clocks (a call's recorded `createdAt`, a tool span's harness-stamped
23
+ // `startedAt`), so "which happened last" is not a comparison this can make honestly, and a
24
+ // confident wrong ordering here is worse than none: the whole section exists to be believed.
25
+ //
26
+ // Every count it prints is a run-level SQL aggregate rather than the length of a list the panel
27
+ // happens to hold, and any bound it renders under says so. Counting off the loaded rows would be
28
+ // the same mistake one layer up as filtering a bounded prefix in JS, and it fails the same way:
29
+ // silently, on exactly the long runs worth opening this panel for.
30
+ const props = defineProps<{ evidence: RunFailureEvidence }>()
31
+ const emit = defineEmits<{
32
+ /** Open the model call with this id in the call list. */
33
+ showCall: [callId: string]
34
+ /** Open the tool-call trajectory, narrowed to the failures. */
35
+ showFailingTools: []
36
+ /** Re-request whichever telemetry read failed. */
37
+ retry: []
38
+ }>()
39
+
40
+ const { t, d } = useI18n()
41
+
42
+ /** Why no failing call could be pinned, or null when one was. See `noFailingCallReason`. */
43
+ const emptyReason = computed(() => noFailingCallReason(props.evidence))
44
+
45
+ function clock(ms: number): string {
46
+ return d(new Date(ms), 'long')
47
+ }
48
+ function agentMeta(kind: string) {
49
+ return agentKindMeta(kind)
50
+ }
51
+
52
+ /**
53
+ * The failure's kind as translated copy.
54
+ *
55
+ * Through the shared `FAILURE_KIND_KEYS` map, never the raw enum: `job_failed` and
56
+ * `companion_rejected` are storage spellings, and the map is an exhaustive
57
+ * `Record<AgentFailureKind, …>` so a kind added to the contract fails the typecheck here instead
58
+ * of surfacing as a code in the middle of a sentence.
59
+ */
60
+ const failureKindLabel = computed(() => {
61
+ const failure = props.evidence.failure
62
+ return failure ? t(FAILURE_KIND_KEYS[failure.kind]) : ''
63
+ })
64
+
65
+ /**
66
+ * A tool call's result text, or null when there is nothing honest to show.
67
+ *
68
+ * `withheld` is NOT an empty result: the bodies were never captured (the deployment switch or
69
+ * the workspace opt-out), so rendering `''` would present the failing call as a tool that said
70
+ * nothing about why it failed. The template says which of the two it is.
71
+ */
72
+ const failedToolResult = computed(() => {
73
+ const call = props.evidence.lastFailedToolCall
74
+ if (!call || call.bodies !== 'stored') return null
75
+ return call.result || null
76
+ })
77
+
78
+ /** How many failing tool calls precede the pinned one, or null when it is the only one. */
79
+ const earlierFailedToolCalls = computed(() =>
80
+ props.evidence.failedToolCallCount > 1 ? props.evidence.failedToolCallCount - 1 : null,
81
+ )
82
+ </script>
83
+
84
+ <template>
85
+ <section class="rounded-xl border border-rose-900/60 bg-rose-950/20 p-4">
86
+ <div class="flex items-start gap-3">
87
+ <UIcon name="i-lucide-siren" class="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
88
+ <div class="min-w-0 flex-1">
89
+ <h2 class="text-[13px] font-semibold text-rose-200">
90
+ {{ t('observability.failure.title') }}
91
+ </h2>
92
+
93
+ <!-- The run's own structured record. Absent on a run that is still going, or that
94
+ failed without one; the evidence below stands on its own either way. -->
95
+ <template v-if="evidence.failure">
96
+ <p class="mt-1 text-[13px] text-slate-200">{{ evidence.failure.message }}</p>
97
+ <div class="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-slate-500">
98
+ <span>{{ t('observability.failure.kind', { kind: failureKindLabel }) }}</span>
99
+ <span v-if="evidence.failure.stepIndex != null">
100
+ {{ t('observability.failure.atStep', { index: evidence.failure.stepIndex + 1 }) }}
101
+ </span>
102
+ <span>{{ clock(evidence.failure.occurredAt) }}</span>
103
+ </div>
104
+ <!-- The shared disclosure the failure banner and the prior-errors history use:
105
+ collapsed, copyable, and silent when the detail merely repeats the message. A
106
+ pinned triage header is the last place an unbounded stack trace should sit
107
+ expanded by default, pushing the failing call it exists to surface off-screen. -->
108
+ <FailureDetail
109
+ :detail="evidence.failure.detail"
110
+ :message="evidence.failure.message"
111
+ summary-class="text-[11px] text-slate-500 hover:text-slate-300"
112
+ pre-class="bg-slate-950/60 text-[11px] text-slate-400"
113
+ />
114
+ <p v-if="evidence.failure.hint" class="mt-1.5 text-[12px] text-slate-300">
115
+ {{ evidence.failure.hint }}
116
+ </p>
117
+ </template>
118
+ </div>
119
+ </div>
120
+
121
+ <!-- The failing calls themselves. -->
122
+ <div class="mt-3 space-y-2">
123
+ <!-- Last model call that FAILED outright. -->
124
+ <button
125
+ v-if="evidence.lastErroredCall"
126
+ type="button"
127
+ class="flex w-full items-start gap-3 rounded-lg border border-rose-900/50 bg-slate-950/50 px-3 py-2 text-start transition hover:bg-slate-900/70"
128
+ @click="emit('showCall', evidence.lastErroredCall.id)"
129
+ >
130
+ <UIcon
131
+ :name="agentMeta(evidence.lastErroredCall.agentKind).icon"
132
+ class="mt-0.5 h-4 w-4 shrink-0"
133
+ :style="{ color: agentMeta(evidence.lastErroredCall.agentKind).color }"
134
+ />
135
+ <div class="min-w-0 flex-1">
136
+ <div class="flex flex-wrap items-baseline gap-x-2 text-[12px]">
137
+ <span class="font-medium text-slate-200">
138
+ {{ t('observability.failure.lastErroredCall') }}
139
+ </span>
140
+ <span class="text-slate-500">
141
+ {{ agentMeta(evidence.lastErroredCall.agentKind).label }} ·
142
+ {{ evidence.lastErroredCall.provider }}:{{ evidence.lastErroredCall.model }}
143
+ </span>
144
+ <UBadge color="error" variant="subtle" size="sm">
145
+ {{ evidence.lastErroredCall.httpStatus ?? t('observability.call.error') }}
146
+ </UBadge>
147
+ </div>
148
+ <p v-if="evidence.lastErroredCall.errorMessage" class="mt-0.5 text-[12px] text-rose-300">
149
+ {{ evidence.lastErroredCall.errorMessage }}
150
+ </p>
151
+ <p v-if="evidence.erroredCallCount > 1" class="mt-0.5 text-[11px] text-slate-500">
152
+ {{
153
+ t(
154
+ 'observability.failure.moreErroredCalls',
155
+ { count: evidence.erroredCallCount - 1 },
156
+ evidence.erroredCallCount - 1,
157
+ )
158
+ }}
159
+ </p>
160
+ </div>
161
+ <UIcon name="i-lucide-chevron-right" class="mt-0.5 h-4 w-4 shrink-0 text-slate-600" />
162
+ </button>
163
+
164
+ <!-- Last TOOL call that failed: the row no rollup counts. -->
165
+ <button
166
+ v-if="evidence.lastFailedToolCall"
167
+ type="button"
168
+ class="flex w-full items-start gap-3 rounded-lg border border-rose-900/50 bg-slate-950/50 px-3 py-2 text-start transition hover:bg-slate-900/70"
169
+ @click="emit('showFailingTools')"
170
+ >
171
+ <UIcon name="i-lucide-wrench" class="mt-0.5 h-4 w-4 shrink-0 text-rose-400" />
172
+ <div class="min-w-0 flex-1">
173
+ <div class="flex flex-wrap items-baseline gap-x-2 text-[12px]">
174
+ <span class="font-medium text-slate-200">
175
+ <!-- "One of the failing calls" when even the failures were bounded: the row is
176
+ real either way, but calling it the LAST would be a claim about rows this
177
+ read never saw. -->
178
+ {{
179
+ evidence.failedToolCallsTruncated
180
+ ? t('observability.failure.aFailedToolCall')
181
+ : t('observability.failure.lastFailedToolCall')
182
+ }}
183
+ </span>
184
+ <span class="font-mono text-slate-300">{{ evidence.lastFailedToolCall.tool }}</span>
185
+ <span class="text-slate-500">
186
+ {{ agentMeta(evidence.lastFailedToolCall.agentKind).label }}
187
+ </span>
188
+ </div>
189
+ <pre
190
+ v-if="failedToolResult"
191
+ class="mt-1 max-h-32 overflow-auto whitespace-pre-wrap text-[11px] leading-relaxed text-rose-300"
192
+ >{{ failedToolResult }}</pre>
193
+ <p v-else class="mt-0.5 text-[11px] italic text-slate-500">
194
+ {{
195
+ evidence.lastFailedToolCall.bodies === 'stored'
196
+ ? t('observability.failure.toolReturnedNothing')
197
+ : t('observability.failure.toolBodiesWithheld')
198
+ }}
199
+ </p>
200
+ <p v-if="earlierFailedToolCalls" class="mt-0.5 text-[11px] text-slate-500">
201
+ {{
202
+ t(
203
+ 'observability.failure.moreFailedToolCalls',
204
+ { count: earlierFailedToolCalls },
205
+ earlierFailedToolCalls,
206
+ )
207
+ }}
208
+ </p>
209
+ </div>
210
+ <UIcon name="i-lucide-chevron-right" class="mt-0.5 h-4 w-4 shrink-0 text-slate-600" />
211
+ </button>
212
+
213
+ <!-- Nothing failing to point at. Which of the reasons it is decides what an operator should
214
+ do next, so each gets its own sentence rather than one shared shrug. `emptyReason` is
215
+ null whenever EITHER sink held a failure, so it already covers the model-call arm
216
+ above, and null while a sink is still loading, which is what stops a read that has not
217
+ come back from being reported as one that came back clean. -->
218
+ <div
219
+ v-else-if="emptyReason"
220
+ class="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-dashed px-3 py-2 text-[12px]"
221
+ :class="
222
+ emptyReason === 'sink-unreachable'
223
+ ? 'border-amber-900/60 text-amber-300'
224
+ : 'border-slate-800 text-slate-400'
225
+ "
226
+ >
227
+ <span>{{ t(`observability.failure.noFailingCall.${emptyReason}`) }}</span>
228
+ <!-- The one empty state with an action attached: the others are answers, this one is the
229
+ absence of one. -->
230
+ <UButton
231
+ v-if="emptyReason === 'sink-unreachable'"
232
+ icon="i-lucide-rotate-cw"
233
+ color="neutral"
234
+ variant="soft"
235
+ size="xs"
236
+ @click="emit('retry')"
237
+ >
238
+ {{ t('common.retry') }}
239
+ </UButton>
240
+ </div>
241
+ </div>
242
+ </section>
243
+ </template>
@@ -0,0 +1,290 @@
1
+ <script setup lang="ts">
2
+ import { computed, reactive } from 'vue'
3
+ import type { AgentToolCall, RunToolCallFailures, RunToolCallTrajectory } from '~/types/execution'
4
+ import type { ToolOutcomeFilter } from '~/utils/observability'
5
+ import { filterToolCallsByOutcome, formatMs } from '~/utils/observability'
6
+ import { agentKindMeta } from '~/utils/catalog'
7
+ import OutcomeFilterChips from '~/components/observability/OutcomeFilterChips.vue'
8
+
9
+ // The tool-call TRAJECTORY drill-down: what the run's agents DID, oldest first, in the order
10
+ // they did it. The sibling of the model-call list, and the one that holds the failures no LLM
11
+ // rollup counts — a tool that errors inside the container leaves the call that requested it
12
+ // reporting `ok`.
13
+ //
14
+ // Rows keep their trajectory order under every filter. Narrowing to the failures and reading
15
+ // them in sequence is what tells one tool that failed and was worked around from an edit loop
16
+ // stuck repeating the same failing call, and re-sorting by anything else destroys exactly that.
17
+ //
18
+ // TWO sources, because they answer at different bounds. `trajectory` is a bounded PREFIX of the
19
+ // run, so counting or narrowing it here would repeat, in JavaScript, the exact mistake the
20
+ // stores refuse to make in SQL: a run whose failures came after its opening moves would be shown
21
+ // as one whose tools all worked. So the failing rows and every count come from `failures`, which
22
+ // the backend narrowed and aggregated over the whole run, and only the browse view reads the
23
+ // prefix — under a banner that says it is one.
24
+ const props = defineProps<{
25
+ trajectory: RunToolCallTrajectory
26
+ /** The run-level failure read: exact counts, and the failing rows in trajectory order. */
27
+ failures: RunToolCallFailures | null
28
+ loading: boolean
29
+ error: string | null
30
+ failuresLoading: boolean
31
+ failuresError: string | null
32
+ }>()
33
+ const emit = defineEmits<{
34
+ /** Re-request the trajectory. */
35
+ retry: []
36
+ /** Re-request the failure read. */
37
+ retryFailures: []
38
+ }>()
39
+
40
+ /** Which calls the list is narrowed to. Two-way bound so the failure summary can jump here. */
41
+ const filter = defineModel<ToolOutcomeFilter>('filter', { required: true })
42
+
43
+ const { t, d } = useI18n()
44
+
45
+ /**
46
+ * Chip counts, taken from the run-level aggregate rather than the loaded rows.
47
+ *
48
+ * A chip is a claim about the run ("4 failed"), so counting the prefix would make it a claim
49
+ * about the first two thousand calls wearing the run's name. Null until the aggregate answers:
50
+ * the chips then read 0, which the loading state above them already accounts for.
51
+ */
52
+ const counts = computed(() => {
53
+ const total = props.failures?.total ?? 0
54
+ const failed = props.failures?.failed ?? 0
55
+ return { all: total, error: failed, ok: total - failed }
56
+ })
57
+ const filterOptions = computed(
58
+ () =>
59
+ [
60
+ { value: 'all', label: t('observability.filter.all'), count: counts.value.all },
61
+ {
62
+ value: 'error',
63
+ label: t('observability.filter.failed'),
64
+ count: counts.value.error,
65
+ tone: 'error',
66
+ },
67
+ { value: 'ok', label: t('observability.filter.ok'), count: counts.value.ok },
68
+ ] as const,
69
+ )
70
+
71
+ /**
72
+ * The rows on screen.
73
+ *
74
+ * `error` is served from the run-level failure read, so narrowing to the failures shows the
75
+ * run's failures rather than the prefix's. `all` and `ok` browse the prefix, which is what the
76
+ * banner below is for.
77
+ */
78
+ const visible = computed(() =>
79
+ filter.value === 'error'
80
+ ? (props.failures?.failures ?? [])
81
+ : filterToolCallsByOutcome(props.trajectory.toolCalls, filter.value),
82
+ )
83
+
84
+ /**
85
+ * Whether what is on screen is bounded, and by which read.
86
+ *
87
+ * Stated per view rather than once for the component: narrowing to the failures escapes the
88
+ * trajectory's prefix entirely, so carrying that bound's warning into the failure view would
89
+ * cast doubt on a list that has none.
90
+ */
91
+ /**
92
+ * Which read backs the current view, with its own loading and error state.
93
+ *
94
+ * Per view, not per component. The failure view does not read the trajectory at all, so a
95
+ * trajectory that is still loading (or failed to) must not blank out failing rows already in
96
+ * hand — reporting an unrelated read's trouble as this view's emptiness is the same class of
97
+ * mistake as reporting a prefix as a run.
98
+ */
99
+ const source = computed(() =>
100
+ filter.value === 'error'
101
+ ? {
102
+ loading: props.failuresLoading,
103
+ error: props.failuresError,
104
+ retry: () => emit('retryFailures'),
105
+ }
106
+ : { loading: props.loading, error: props.error, retry: () => emit('retry') },
107
+ )
108
+
109
+ const boundedNotice = computed(() => {
110
+ if (filter.value === 'error') {
111
+ return props.failures?.failuresTruncated
112
+ ? t('observability.toolCalls.failuresTruncated', { shown: visible.value.length })
113
+ : null
114
+ }
115
+ return props.trajectory.truncated
116
+ ? t('observability.toolCalls.truncated', { shown: props.trajectory.toolCalls.length })
117
+ : null
118
+ })
119
+
120
+ const expanded = reactive<Record<string, boolean>>({})
121
+ function toggle(call: AgentToolCall) {
122
+ expanded[call.id] = !expanded[call.id]
123
+ }
124
+
125
+ function agentMeta(kind: string) {
126
+ return agentKindMeta(kind)
127
+ }
128
+ function clock(ms: number): string {
129
+ return d(new Date(ms), 'long')
130
+ }
131
+ /** Pretty-print JSON arguments; fall back to the raw string when they are not JSON. */
132
+ function prettyArgs(raw: string): string {
133
+ try {
134
+ return JSON.stringify(JSON.parse(raw), null, 2)
135
+ } catch {
136
+ return raw
137
+ }
138
+ }
139
+ </script>
140
+
141
+ <template>
142
+ <div class="space-y-4">
143
+ <div class="flex flex-wrap items-center justify-between gap-2">
144
+ <div>
145
+ <h2 class="text-[11px] uppercase tracking-wide text-slate-500">
146
+ {{ t('observability.toolCalls.title') }}
147
+ </h2>
148
+ <p class="text-[11px] text-slate-600">{{ t('observability.toolCalls.subtitle') }}</p>
149
+ </div>
150
+ <OutcomeFilterChips v-model="filter" :options="filterOptions" />
151
+ </div>
152
+
153
+ <!-- Every state below is gated on having NOTHING to show: rows already in hand outrank a
154
+ read still in flight behind them, and outrank one that failed. -->
155
+ <p
156
+ v-if="source.loading && !visible.length"
157
+ class="flex items-center justify-center gap-2 py-8 text-center text-sm text-slate-500"
158
+ >
159
+ <UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
160
+ {{ t('observability.toolCalls.loading') }}
161
+ </p>
162
+ <div
163
+ v-else-if="source.error && !visible.length"
164
+ 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"
165
+ >
166
+ {{ t('observability.toolCalls.error') }}
167
+ <UButton
168
+ icon="i-lucide-rotate-cw"
169
+ color="neutral"
170
+ variant="soft"
171
+ size="xs"
172
+ :loading="source.loading"
173
+ @click="source.retry()"
174
+ >
175
+ {{ t('common.retry') }}
176
+ </UButton>
177
+ </div>
178
+ <!-- "The run made no tool calls" is a claim about the RUN, so it comes off the aggregate,
179
+ never off an empty prefix that may simply not have loaded. -->
180
+ <p
181
+ v-else-if="!counts.all"
182
+ class="rounded-lg border border-dashed border-slate-800 py-8 text-center text-sm text-slate-500"
183
+ >
184
+ {{ t('observability.toolCalls.none') }}
185
+ </p>
186
+ <!-- Narrowed to nothing is a different statement from recorded nothing, and it is the more
187
+ reassuring of the two: the operator asked for the failures and there are none. -->
188
+ <p
189
+ v-else-if="!visible.length"
190
+ class="rounded-lg border border-dashed border-slate-800 py-8 text-center text-sm text-slate-500"
191
+ >
192
+ {{ t('observability.toolCalls.noneMatching') }}
193
+ </p>
194
+
195
+ <template v-else>
196
+ <!-- What this view is bounded by, when it is. A cap nobody can see is a prefix read as a
197
+ whole run: the count beside every chip is the run's, so a shorter list than the count
198
+ implies has to explain itself here. -->
199
+ <p
200
+ v-if="boundedNotice"
201
+ class="rounded-lg border border-dashed border-amber-900/50 px-3 py-2 text-[11px] text-amber-300/90"
202
+ >
203
+ {{ boundedNotice }}
204
+ </p>
205
+
206
+ <ul class="space-y-2">
207
+ <li
208
+ v-for="call in visible"
209
+ :key="call.id"
210
+ class="overflow-hidden rounded-xl border border-slate-800 bg-slate-900/40"
211
+ :class="!call.ok ? 'border-rose-900/60' : ''"
212
+ >
213
+ <button
214
+ class="flex w-full items-center gap-3 px-4 py-2.5 text-start transition hover:bg-slate-900/70"
215
+ @click="toggle(call)"
216
+ >
217
+ <UIcon
218
+ name="i-lucide-chevron-right"
219
+ class="h-4 w-4 shrink-0 text-slate-500 transition-transform"
220
+ :class="expanded[call.id] ? 'rotate-90' : ''"
221
+ />
222
+ <UIcon
223
+ :name="agentMeta(call.agentKind).icon"
224
+ class="h-4 w-4 shrink-0"
225
+ :style="{ color: agentMeta(call.agentKind).color }"
226
+ :title="agentMeta(call.agentKind).label"
227
+ />
228
+ <span class="font-mono text-[13px] text-slate-200">{{ call.tool }}</span>
229
+ <div class="ms-auto flex items-center gap-2.5 text-[11px] tabular-nums text-slate-400">
230
+ <span :title="t('observability.toolCalls.durationHint')">
231
+ {{ formatMs(Math.max(0, call.endedAt - call.startedAt)) }}
232
+ </span>
233
+ <UBadge v-if="!call.ok" color="error" variant="subtle" size="sm">
234
+ {{ t('observability.toolCalls.failed') }}
235
+ </UBadge>
236
+ <span class="hidden text-slate-600 md:inline">{{ clock(call.startedAt) }}</span>
237
+ </div>
238
+ </button>
239
+
240
+ <div v-if="expanded[call.id]" class="border-t border-slate-800 px-4 py-3 space-y-3">
241
+ <div class="flex flex-wrap gap-x-5 gap-y-1 text-[11px] text-slate-500">
242
+ <span>{{ t('observability.toolCalls.dispatch', { jobId: call.jobId }) }}</span>
243
+ <span>{{ t('observability.toolCalls.seq', { seq: call.seq }) }}</span>
244
+ </div>
245
+ <!-- `withheld` is not an empty body: nothing was captured, so an empty `args` here
246
+ must not read as a tool that took none. -->
247
+ <p v-if="call.bodies !== 'stored'" class="text-[12px] italic text-slate-500">
248
+ {{ t('observability.toolCalls.bodiesWithheld') }}
249
+ </p>
250
+ <template v-else>
251
+ <div>
252
+ <div
253
+ class="mb-1 flex items-center gap-2 text-[11px] uppercase tracking-wide text-slate-500"
254
+ >
255
+ <span>{{ t('observability.toolCalls.arguments') }}</span>
256
+ <span
257
+ v-if="call.argsDropped > 0"
258
+ class="normal-case tracking-normal text-slate-600"
259
+ >
260
+ {{ t('observability.toolCalls.dropped', { chars: call.argsDropped }) }}
261
+ </span>
262
+ </div>
263
+ <pre
264
+ class="max-h-60 overflow-auto rounded-lg bg-slate-950/70 p-3 text-[11px] leading-relaxed text-slate-300"
265
+ >{{ call.args ? prettyArgs(call.args) : '—' }}</pre>
266
+ </div>
267
+ <div>
268
+ <div
269
+ class="mb-1 flex items-center gap-2 text-[11px] uppercase tracking-wide text-slate-500"
270
+ >
271
+ <span>{{ t('observability.toolCalls.result') }}</span>
272
+ <span
273
+ v-if="call.resultDropped > 0"
274
+ class="normal-case tracking-normal text-slate-600"
275
+ >
276
+ {{ t('observability.toolCalls.dropped', { chars: call.resultDropped }) }}
277
+ </span>
278
+ </div>
279
+ <pre
280
+ class="max-h-60 overflow-auto rounded-lg bg-slate-950/70 p-3 text-[11px] leading-relaxed"
281
+ :class="call.ok ? 'text-slate-300' : 'text-rose-300'"
282
+ >{{ call.result || '—' }}</pre>
283
+ </div>
284
+ </template>
285
+ </div>
286
+ </li>
287
+ </ul>
288
+ </template>
289
+ </div>
290
+ </template>
@@ -86,6 +86,7 @@ function toggle(id: string) {
86
86
  type="button"
87
87
  class="flex w-full items-center gap-2.5 rounded-lg border border-slate-700 bg-slate-800/60 p-2 text-start transition hover:border-slate-500 hover:bg-slate-800"
88
88
  :title="a.description"
89
+ :data-testid="`palette-agent-${a.kind}`"
89
90
  @click="$emit('add', a.kind)"
90
91
  >
91
92
  <div
@@ -304,9 +304,13 @@ async function copyOutput() {
304
304
  <template>
305
305
  <Teleport to="body">
306
306
  <Transition name="reader-fade">
307
+ <!-- `data-agent-kind` names WHICH step this window is scoped to, as the kind rather than as
308
+ the heading beside it: the heading is a translated display label, so it is the wrong
309
+ thing to read for anything that needs to know which agent's step is open. -->
307
310
  <div
308
311
  v-if="open && step && agent"
309
312
  data-testid="step-detail"
313
+ :data-agent-kind="step?.agentKind"
310
314
  class="fixed inset-0 z-50 flex max-h-[100dvh] bg-slate-950/96 backdrop-blur-sm"
311
315
  role="dialog"
312
316
  aria-modal="true"