@cat-factory/app 0.6.0 → 0.7.3

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.
Files changed (33) hide show
  1. package/LICENSE +21 -21
  2. package/app/components/board/ContextPicker.vue +367 -367
  3. package/app/components/gates/GateResultView.vue +90 -12
  4. package/app/components/layout/SideBar.vue +11 -0
  5. package/app/components/observability/StepMetricsBar.vue +102 -102
  6. package/app/components/observability/StepModelActivity.vue +49 -0
  7. package/app/components/panels/ObservabilityPanel.vue +1 -1
  8. package/app/components/panels/StepMetadataCard.vue +4 -16
  9. package/app/components/panels/StepRunMeta.vue +105 -0
  10. package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -178
  11. package/app/components/panels/inspector/TaskRunSettings.vue +77 -0
  12. package/app/components/recurring/RecurrenceEditor.vue +124 -124
  13. package/app/components/settings/IssueTrackerWritebackPanel.vue +103 -0
  14. package/app/components/testing/TestReportWindow.vue +17 -8
  15. package/app/composables/useBlockQueries.ts +154 -154
  16. package/app/composables/useContextLinking.ts +65 -65
  17. package/app/composables/useFrameResize.ts +54 -54
  18. package/app/pages/index.vue +2 -0
  19. package/app/stores/documents.ts +176 -176
  20. package/app/stores/services.ts +87 -87
  21. package/app/stores/tracker.ts +39 -27
  22. package/app/stores/ui.ts +12 -0
  23. package/app/types/documents.ts +104 -104
  24. package/app/types/domain.ts +5 -1
  25. package/app/types/execution.ts +18 -0
  26. package/app/types/github.ts +173 -173
  27. package/app/types/services.ts +27 -27
  28. package/app/types/tasks.ts +82 -82
  29. package/app/types/tracker.ts +27 -18
  30. package/app/utils/agentOutput.spec.ts +128 -128
  31. package/app/utils/agentOutput.ts +173 -173
  32. package/app/utils/observability.ts +52 -52
  33. package/package.json +6 -1
@@ -9,6 +9,7 @@ import { computed } from 'vue'
9
9
  import { agentKindMeta } from '~/utils/catalog'
10
10
  import type { GateStepState } from '~/types/execution'
11
11
  import StepRestartControl from '~/components/panels/StepRestartControl.vue'
12
+ import StepRunMeta from '~/components/panels/StepRunMeta.vue'
12
13
 
13
14
  const board = useBoardStore()
14
15
  const execution = useExecutionStore()
@@ -17,6 +18,7 @@ const execution = useExecutionStore()
17
18
  // nothing to fetch on open (no `onOpen` loader).
18
19
  const { open, blockId, instanceId, stepIndex, close } = useResultView('gate')
19
20
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
21
+ const prUrl = computed(() => block.value?.pullRequest?.url ?? null)
20
22
 
21
23
  const instance = computed(() =>
22
24
  instanceId.value === null ? null : (execution.getInstance(instanceId.value) ?? null),
@@ -35,6 +37,13 @@ const helperMeta = computed(() => agentKindMeta(helperKind.value))
35
37
  const failingChecks = computed(() => gate.value?.failingChecks ?? [])
36
38
  const shortSha = computed(() => (gate.value?.headSha ? gate.value.headSha.slice(0, 7) : null))
37
39
 
40
+ // The helper-agent attempts this gate dispatched, newest first for the timeline.
41
+ const attempts = computed(() => [...(gate.value?.attemptLog ?? [])].reverse())
42
+
43
+ function formatClock(ms?: number | null): string | null {
44
+ return ms ? new Date(ms).toLocaleString() : null
45
+ }
46
+
38
47
  /**
39
48
  * The display status — a roll-up of the persisted gate state + the run's status, so the
40
49
  * window reads as a conclusion rather than raw fields:
@@ -189,7 +198,21 @@ const conflictVerdict = computed(() => {
189
198
  class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-950/40 px-3 py-1.5"
190
199
  >
191
200
  <UIcon name="i-lucide-circle-x" class="h-3.5 w-3.5 shrink-0 text-rose-400" />
192
- <span class="min-w-0 flex-1 truncate text-[13px] text-slate-200">{{
201
+ <a
202
+ v-if="c.url"
203
+ :href="c.url"
204
+ target="_blank"
205
+ rel="noopener"
206
+ class="group min-w-0 flex-1 truncate text-[13px] text-sky-300 hover:text-sky-200 hover:underline"
207
+ :title="`Open ${c.name} on GitHub`"
208
+ >
209
+ {{ c.name }}
210
+ <UIcon
211
+ name="i-lucide-external-link"
212
+ class="ml-0.5 inline h-3 w-3 opacity-60 group-hover:opacity-100"
213
+ />
214
+ </a>
215
+ <span v-else class="min-w-0 flex-1 truncate text-[13px] text-slate-200">{{
193
216
  c.name
194
217
  }}</span>
195
218
  <span class="shrink-0 text-[11px] uppercase text-rose-300">
@@ -202,7 +225,7 @@ const conflictVerdict = computed(() => {
202
225
  </p>
203
226
  </template>
204
227
 
205
- <!-- Conflicts: verdict + note (no file-level detail from GitHub) -->
228
+ <!-- Conflicts: verdict + the resolver's account of what it left -->
206
229
  <template v-else>
207
230
  <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
208
231
  Mergeability
@@ -217,17 +240,67 @@ const conflictVerdict = computed(() => {
217
240
  />
218
241
  <span class="text-[13px] text-slate-200">{{ conflictVerdict }}</span>
219
242
  </div>
220
- <p class="mt-2 text-[11px] leading-relaxed text-slate-500">
221
- GitHub reports mergeability as a single verdict, so there's no file-level conflict
222
- list here. The conflict resolver inspects the branch directly.
243
+ <!-- GitHub's API reports mergeability as a single bit (no file list), but the
244
+ conflict resolver discovers the conflicting files in the container and
245
+ reports them back surface that account here. -->
246
+ <p
247
+ v-if="gate.lastFailureSummary"
248
+ class="mt-2 whitespace-pre-wrap rounded-md border border-slate-800 bg-slate-950/40 px-3 py-2 text-[12px] leading-relaxed text-slate-300"
249
+ >
250
+ {{ gate.lastFailureSummary }}
223
251
  </p>
252
+ <a
253
+ v-if="prUrl"
254
+ :href="prUrl"
255
+ target="_blank"
256
+ rel="noopener"
257
+ class="mt-2 inline-flex items-center gap-1 text-[12px] text-sky-300 hover:text-sky-200 hover:underline"
258
+ >
259
+ View pull request on GitHub
260
+ <UIcon name="i-lucide-external-link" class="h-3 w-3" />
261
+ </a>
224
262
  </template>
263
+
264
+ <!-- Attempt history (both gates): what each helper run did and how it ended. -->
265
+ <section v-if="attempts.length" class="mt-5">
266
+ <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
267
+ {{ helperMeta.label }} attempts
268
+ </h3>
269
+ <ol class="space-y-2">
270
+ <li
271
+ v-for="a in attempts"
272
+ :key="a.attempt"
273
+ class="rounded-md border border-slate-800 bg-slate-950/40 px-3 py-2"
274
+ >
275
+ <div class="flex items-center gap-2">
276
+ <span class="text-[12px] font-semibold text-slate-200"
277
+ >Attempt {{ a.attempt }}</span
278
+ >
279
+ <UBadge
280
+ :color="a.outcome === 'failed' ? 'error' : 'neutral'"
281
+ variant="subtle"
282
+ size="sm"
283
+ >{{ a.outcome }}</UBadge
284
+ >
285
+ <span v-if="formatClock(a.at)" class="ml-auto text-[11px] text-slate-500">{{
286
+ formatClock(a.at)
287
+ }}</span>
288
+ </div>
289
+ <p
290
+ v-if="a.summary"
291
+ class="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-slate-400"
292
+ >
293
+ {{ a.summary }}
294
+ </p>
295
+ </li>
296
+ </ol>
297
+ </section>
225
298
  </template>
226
299
  </div>
227
300
 
228
301
  <!-- Sidebar: gate state -->
229
302
  <aside
230
- class="hidden w-56 shrink-0 flex-col gap-4 border-l border-slate-800 bg-slate-900/50 px-4 py-4 lg:flex"
303
+ class="hidden w-60 shrink-0 flex-col gap-4 border-l border-slate-800 bg-slate-900/50 px-4 py-4 lg:flex"
231
304
  >
232
305
  <div v-if="gate">
233
306
  <h4 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
@@ -263,12 +336,17 @@ const conflictVerdict = computed(() => {
263
336
  <p class="font-mono text-[12px] text-slate-300">{{ shortSha }}</p>
264
337
  </div>
265
338
 
266
- <div v-if="step?.model">
267
- <h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
268
- Model
269
- </h4>
270
- <p class="break-all text-[12px] text-slate-300">{{ step.model }}</p>
271
- </div>
339
+ <!-- Shared run metadata + embedded observability (model, run id, timing,
340
+ model-activity rollup) identical to the agent step detail. -->
341
+ <StepRunMeta
342
+ v-if="step"
343
+ :step="step"
344
+ :instance-id="instanceId ?? undefined"
345
+ :step-number="stepIndex === null ? undefined : stepIndex + 1"
346
+ :total-steps="instance?.steps.length"
347
+ :run-failed="instance?.status === 'failed'"
348
+ :failure-at="instance?.failure?.occurredAt"
349
+ />
272
350
 
273
351
  <p class="mt-auto text-[10px] leading-relaxed text-slate-600">
274
352
  A gate runs a programmatic precheck and only spins up the
@@ -262,6 +262,17 @@ watch(
262
262
  >
263
263
  Workspace settings
264
264
  </UButton>
265
+ <UButton
266
+ block
267
+ color="primary"
268
+ variant="soft"
269
+ size="sm"
270
+ icon="i-lucide-message-square-reply"
271
+ class="justify-start"
272
+ @click="ui.openIssueWriteback()"
273
+ >
274
+ Issue tracker writeback
275
+ </UButton>
265
276
  <UButton
266
277
  block
267
278
  color="primary"
@@ -1,102 +1,102 @@
1
- <script setup lang="ts">
2
- import { computed } from 'vue'
3
- import type { StepMetrics } from '~/types/execution'
4
- import {
5
- formatMs,
6
- formatTokens,
7
- headroomColor,
8
- headroomRatio,
9
- pct,
10
- transportRatio,
11
- } from '~/utils/observability'
12
-
13
- // Compact, at-a-glance LLM rollup for one pipeline step: token usage, an
14
- // output-limit headroom bar (how close the step ran to truncation), a
15
- // transport-vs-execution latency split, and error/warning badges. Rendered inline
16
- // on the step surfaces (step detail, pipeline timeline). A no-op when there are no
17
- // recorded calls. Clicking anywhere emits `inspect` so a parent can open the
18
- // drill-down panel.
19
- const props = defineProps<{ metrics: StepMetrics; clickable?: boolean }>()
20
- defineEmits<{ inspect: [] }>()
21
-
22
- const m = computed(() => props.metrics)
23
- const headroom = computed(() => headroomRatio(m.value))
24
- const transport = computed(() => transportRatio(m.value))
25
- const headroomTone = computed(() => headroomColor(headroom.value, m.value.truncatedCalls > 0))
26
- </script>
27
-
28
- <template>
29
- <div
30
- v-if="m.calls > 0"
31
- class="rounded-lg border border-slate-800 bg-slate-900/40 p-2.5 text-[12px]"
32
- :class="
33
- clickable ? 'cursor-pointer transition hover:border-slate-700 hover:bg-slate-900/70' : ''
34
- "
35
- :role="clickable ? 'button' : undefined"
36
- @click="clickable ? $emit('inspect') : undefined"
37
- >
38
- <!-- header line: call count + tokens + warning/error badges -->
39
- <div class="flex items-center gap-2">
40
- <UIcon name="i-lucide-activity" class="h-3.5 w-3.5 shrink-0 text-slate-500" />
41
- <span class="text-slate-300"> {{ m.calls }} {{ m.calls === 1 ? 'call' : 'calls' }} </span>
42
- <span class="text-slate-500">·</span>
43
- <span class="tabular-nums text-slate-400" title="Prompt / completion tokens">
44
- {{ formatTokens(m.promptTokens) }}↑ {{ formatTokens(m.completionTokens) }}↓
45
- </span>
46
- <div class="ml-auto flex items-center gap-1">
47
- <UBadge v-if="m.errors > 0" color="error" variant="subtle" size="sm">
48
- {{ m.errors }} error{{ m.errors === 1 ? '' : 's' }}
49
- </UBadge>
50
- <UBadge v-if="m.warnings > 0" color="warning" variant="subtle" size="sm">
51
- {{ m.warnings }} warning{{ m.warnings === 1 ? '' : 's' }}
52
- </UBadge>
53
- <UIcon v-if="clickable" name="i-lucide-chevron-right" class="h-3.5 w-3.5 text-slate-600" />
54
- </div>
55
- </div>
56
-
57
- <!-- output-limit headroom -->
58
- <div v-if="headroom !== null" class="mt-2">
59
- <div class="flex items-center justify-between text-[11px]">
60
- <span class="text-slate-500">Output limit</span>
61
- <span class="tabular-nums" :class="headroomTone">
62
- {{ formatTokens(m.peakCompletionTokens) }} /
63
- {{ formatTokens(m.maxOutputTokens ?? 0) }} ({{ pct(headroom) }}%)
64
- </span>
65
- </div>
66
- <div class="mt-1 h-1 overflow-hidden rounded-full bg-slate-700/60">
67
- <div
68
- class="h-full rounded-full transition-all duration-500"
69
- :class="
70
- m.truncatedCalls > 0 || headroom >= 0.98
71
- ? 'bg-rose-400'
72
- : headroom >= 0.8
73
- ? 'bg-amber-400'
74
- : 'bg-emerald-400'
75
- "
76
- :style="{ width: `${Math.max(2, pct(headroom))}%` }"
77
- />
78
- </div>
79
- <p v-if="m.truncatedCalls > 0" class="mt-1 text-[11px] text-rose-400">
80
- {{ m.truncatedCalls }} call{{ m.truncatedCalls === 1 ? '' : 's' }} truncated at the limit
81
- </p>
82
- </div>
83
-
84
- <!-- transport overhead vs model execution -->
85
- <div v-if="transport !== null" class="mt-2">
86
- <div class="flex items-center justify-between text-[11px]">
87
- <span class="text-slate-500">Transport vs execution</span>
88
- <span class="tabular-nums text-slate-400">
89
- {{ formatMs(m.overheadMs) }} / {{ formatMs(m.upstreamMs) }}
90
- </span>
91
- </div>
92
- <div class="mt-1 flex h-1 overflow-hidden rounded-full bg-slate-700/60">
93
- <div
94
- class="h-full bg-sky-400/80"
95
- :style="{ width: `${pct(transport)}%` }"
96
- title="Transport / proxy overhead"
97
- />
98
- <div class="h-full bg-indigo-400/80 flex-1" title="Model execution" />
99
- </div>
100
- </div>
101
- </div>
102
- </template>
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { StepMetrics } from '~/types/execution'
4
+ import {
5
+ formatMs,
6
+ formatTokens,
7
+ headroomColor,
8
+ headroomRatio,
9
+ pct,
10
+ transportRatio,
11
+ } from '~/utils/observability'
12
+
13
+ // Compact, at-a-glance LLM rollup for one pipeline step: token usage, an
14
+ // output-limit headroom bar (how close the step ran to truncation), a
15
+ // transport-vs-execution latency split, and error/warning badges. Rendered inline
16
+ // on the step surfaces (step detail, pipeline timeline). A no-op when there are no
17
+ // recorded calls. Clicking anywhere emits `inspect` so a parent can open the
18
+ // drill-down panel.
19
+ const props = defineProps<{ metrics: StepMetrics; clickable?: boolean }>()
20
+ defineEmits<{ inspect: [] }>()
21
+
22
+ const m = computed(() => props.metrics)
23
+ const headroom = computed(() => headroomRatio(m.value))
24
+ const transport = computed(() => transportRatio(m.value))
25
+ const headroomTone = computed(() => headroomColor(headroom.value, m.value.truncatedCalls > 0))
26
+ </script>
27
+
28
+ <template>
29
+ <div
30
+ v-if="m.calls > 0"
31
+ class="rounded-lg border border-slate-800 bg-slate-900/40 p-2.5 text-[12px]"
32
+ :class="
33
+ clickable ? 'cursor-pointer transition hover:border-slate-700 hover:bg-slate-900/70' : ''
34
+ "
35
+ :role="clickable ? 'button' : undefined"
36
+ @click="clickable ? $emit('inspect') : undefined"
37
+ >
38
+ <!-- header line: call count + tokens + warning/error badges -->
39
+ <div class="flex items-center gap-2">
40
+ <UIcon name="i-lucide-activity" class="h-3.5 w-3.5 shrink-0 text-slate-500" />
41
+ <span class="text-slate-300"> {{ m.calls }} {{ m.calls === 1 ? 'call' : 'calls' }} </span>
42
+ <span class="text-slate-500">·</span>
43
+ <span class="tabular-nums text-slate-400" title="Prompt / completion tokens">
44
+ {{ formatTokens(m.promptTokens) }}↑ {{ formatTokens(m.completionTokens) }}↓
45
+ </span>
46
+ <div class="ml-auto flex items-center gap-1">
47
+ <UBadge v-if="m.errors > 0" color="error" variant="subtle" size="sm">
48
+ {{ m.errors }} error{{ m.errors === 1 ? '' : 's' }}
49
+ </UBadge>
50
+ <UBadge v-if="m.warnings > 0" color="warning" variant="subtle" size="sm">
51
+ {{ m.warnings }} warning{{ m.warnings === 1 ? '' : 's' }}
52
+ </UBadge>
53
+ <UIcon v-if="clickable" name="i-lucide-chevron-right" class="h-3.5 w-3.5 text-slate-600" />
54
+ </div>
55
+ </div>
56
+
57
+ <!-- output-limit headroom -->
58
+ <div v-if="headroom !== null" class="mt-2">
59
+ <div class="flex items-center justify-between text-[11px]">
60
+ <span class="text-slate-500">Output limit</span>
61
+ <span class="tabular-nums" :class="headroomTone">
62
+ {{ formatTokens(m.peakCompletionTokens) }} /
63
+ {{ formatTokens(m.maxOutputTokens ?? 0) }} ({{ pct(headroom) }}%)
64
+ </span>
65
+ </div>
66
+ <div class="mt-1 h-1 overflow-hidden rounded-full bg-slate-700/60">
67
+ <div
68
+ class="h-full rounded-full transition-all duration-500"
69
+ :class="
70
+ m.truncatedCalls > 0 || headroom >= 0.98
71
+ ? 'bg-rose-400'
72
+ : headroom >= 0.8
73
+ ? 'bg-amber-400'
74
+ : 'bg-emerald-400'
75
+ "
76
+ :style="{ width: `${Math.max(2, pct(headroom))}%` }"
77
+ />
78
+ </div>
79
+ <p v-if="m.truncatedCalls > 0" class="mt-1 text-[11px] text-rose-400">
80
+ {{ m.truncatedCalls }} call{{ m.truncatedCalls === 1 ? '' : 's' }} truncated at the limit
81
+ </p>
82
+ </div>
83
+
84
+ <!-- transport overhead vs model execution -->
85
+ <div v-if="transport !== null" class="mt-2">
86
+ <div class="flex items-center justify-between text-[11px]">
87
+ <span class="text-slate-500">Transport vs execution</span>
88
+ <span class="tabular-nums text-slate-400">
89
+ {{ formatMs(m.overheadMs) }} / {{ formatMs(m.upstreamMs) }}
90
+ </span>
91
+ </div>
92
+ <div class="mt-1 flex h-1 overflow-hidden rounded-full bg-slate-700/60">
93
+ <div
94
+ class="h-full bg-sky-400/80"
95
+ :style="{ width: `${pct(transport)}%` }"
96
+ title="Transport / proxy overhead"
97
+ />
98
+ <div class="h-full bg-indigo-400/80 flex-1" title="Model execution" />
99
+ </div>
100
+ </div>
101
+ </div>
102
+ </template>
@@ -0,0 +1,49 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { StepMetrics } from '~/types/execution'
4
+ import StepMetricsBar from '~/components/observability/StepMetricsBar.vue'
5
+
6
+ // The shared "Model activity" block: the LLM observability rollup (StepMetricsBar) under
7
+ // a labelled header with a "View all calls →" link into the full per-call panel. Used by
8
+ // every step surface that shows a single step's metrics (the step metadata card, the
9
+ // gate / tester result windows) so the embedded-observability treatment can't drift.
10
+ // The "View all calls →" link opens the run-level panel, so it appears for any step that
11
+ // belongs to a run — including a gate, whose programmatic precheck records no per-step
12
+ // calls (the bar is omitted, but the link still reaches the helper agents' calls). Renders
13
+ // nothing only when there's neither a run to inspect nor any recorded calls.
14
+ const props = defineProps<{
15
+ metrics?: StepMetrics | null
16
+ /** The run whose per-call panel the header link / bar click opens. */
17
+ instanceId?: string
18
+ }>()
19
+
20
+ const ui = useUiStore()
21
+ const hasCalls = computed(() => !!props.metrics && props.metrics.calls > 0)
22
+
23
+ function openObservability() {
24
+ if (props.instanceId) ui.openObservability(props.instanceId)
25
+ }
26
+ </script>
27
+
28
+ <template>
29
+ <div v-if="instanceId || hasCalls">
30
+ <div class="mb-1 flex items-center justify-between">
31
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
32
+ Model activity
33
+ </span>
34
+ <button
35
+ v-if="instanceId"
36
+ class="text-[11px] text-sky-400 hover:text-sky-300"
37
+ @click="openObservability"
38
+ >
39
+ View all calls →
40
+ </button>
41
+ </div>
42
+ <StepMetricsBar
43
+ v-if="hasCalls && metrics"
44
+ :metrics="metrics"
45
+ clickable
46
+ @inspect="openObservability"
47
+ />
48
+ </div>
49
+ </template>
@@ -109,7 +109,7 @@ function exportJson() {
109
109
  <Transition name="obs-fade">
110
110
  <div
111
111
  v-if="open"
112
- class="fixed inset-0 z-50 flex flex-col bg-slate-950/96 backdrop-blur-sm"
112
+ class="fixed inset-0 z-[60] flex flex-col bg-slate-950/96 backdrop-blur-sm"
113
113
  role="dialog"
114
114
  aria-modal="true"
115
115
  >
@@ -2,7 +2,7 @@
2
2
  import { computed } from 'vue'
3
3
  import type { AgentState, PipelineStep, CompanionVerdict } from '~/types/execution'
4
4
  import { subtaskIconClass } from '~/utils/pipelineRender'
5
- import StepMetricsBar from '~/components/observability/StepMetricsBar.vue'
5
+ import StepModelActivity from '~/components/observability/StepModelActivity.vue'
6
6
 
7
7
  // The step's metadata card body: state/timing/model/run id, the container cold-boot
8
8
  // phase, the live subtask breakdown, the LLM observability rollup, the applied
@@ -20,7 +20,6 @@ const props = defineProps<{
20
20
  latestVerdict: CompanionVerdict | null
21
21
  }>()
22
22
 
23
- const ui = useUiStore()
24
23
  const models = useModelsStore()
25
24
 
26
25
  const STATE_META: Record<AgentState, { label: string; color: string }> = {
@@ -56,10 +55,6 @@ async function copyRunId() {
56
55
  const id = props.step.runId ?? props.instanceId
57
56
  if (id) await navigator.clipboard?.writeText(id)
58
57
  }
59
-
60
- function openObservability() {
61
- if (props.instanceId) ui.openObservability(props.instanceId)
62
- }
63
58
  </script>
64
59
 
65
60
  <template>
@@ -169,16 +164,9 @@ function openObservability() {
169
164
  </div>
170
165
 
171
166
  <!-- LLM observability rollup (tokens, output-limit headroom,
172
- transport-vs-execution); click to open the full per-call panel -->
173
- <div v-if="step.metrics && step.metrics.calls > 0" class="mt-4">
174
- <div class="mb-1 flex items-center justify-between">
175
- <span class="text-[11px] uppercase tracking-wide text-slate-500"> Model activity </span>
176
- <button class="text-[11px] text-sky-400 hover:text-sky-300" @click="openObservability">
177
- View all calls →
178
- </button>
179
- </div>
180
- <StepMetricsBar :metrics="step.metrics" clickable @inspect="openObservability" />
181
- </div>
167
+ transport-vs-execution); click to open the full per-call panel. Self-gates: the
168
+ "View all calls →" link shows for any run, the metrics bar only when calls exist. -->
169
+ <StepModelActivity class="mt-4" :metrics="step.metrics" :instance-id="instanceId" />
182
170
 
183
171
  <!-- standards (prompt fragments) folded into this step -->
184
172
  <div v-if="step.selectedFragmentIds && step.selectedFragmentIds.length" class="mt-4">
@@ -0,0 +1,105 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { PipelineStep } from '~/types/execution'
4
+ import { useStepTimer } from '~/composables/useStepTimer'
5
+ import StepModelActivity from '~/components/observability/StepModelActivity.vue'
6
+
7
+ // Shared run-metadata + observability block for the step-backed result windows
8
+ // (the CI/conflicts gate, the tester report). It carries the facts every step has in
9
+ // common — step position, live duration, model, run id, and the LLM model-activity
10
+ // rollup — so each window keeps only its own bespoke detail (the gate's verdict, the
11
+ // tester's scenarios) and the universal "which run is this / how did the model do"
12
+ // facts read the same everywhere. Laid out as a stack of labelled fields to drop into
13
+ // a window's sidebar; the canonical full-width version lives in StepMetadataCard.
14
+ const props = defineProps<{
15
+ step: PipelineStep
16
+ /** The enclosing run; copyable + opens the per-call observability panel. */
17
+ instanceId?: string
18
+ /** 1-based position in the pipeline, shown as "N of M" when both are given. */
19
+ stepNumber?: number
20
+ totalSteps?: number
21
+ /** The run failed: freezes the clock and reports a mid-flight step honestly. */
22
+ runFailed?: boolean
23
+ /** Epoch ms the run failed, so the frozen duration is the failure time. */
24
+ failureAt?: number | null
25
+ }>()
26
+
27
+ const models = useModelsStore()
28
+
29
+ const { isRunning, durationLabel } = useStepTimer({
30
+ step: () => props.step,
31
+ runFailed: () => props.runFailed ?? false,
32
+ failureAt: () => props.failureAt,
33
+ })
34
+
35
+ const modelLabel = computed(() => (props.step.model ? models.labelForRef(props.step.model) : null))
36
+ const runId = computed(() => props.step.runId ?? props.instanceId ?? null)
37
+
38
+ function formatClock(ms?: number | null): string | null {
39
+ return ms ? new Date(ms).toLocaleString() : null
40
+ }
41
+
42
+ async function copyRunId() {
43
+ if (runId.value) await navigator.clipboard?.writeText(runId.value)
44
+ }
45
+ </script>
46
+
47
+ <template>
48
+ <div class="flex flex-col gap-4">
49
+ <div v-if="stepNumber && totalSteps">
50
+ <h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">Step</h4>
51
+ <p class="text-[12px] text-slate-300">{{ stepNumber }} of {{ totalSteps }}</p>
52
+ </div>
53
+
54
+ <div v-if="durationLabel">
55
+ <h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
56
+ Duration
57
+ </h4>
58
+ <p class="flex items-center gap-1.5 text-[12px] tabular-nums text-slate-300">
59
+ <UIcon
60
+ v-if="isRunning"
61
+ name="i-lucide-loader-circle"
62
+ class="h-3 w-3 animate-spin text-indigo-400"
63
+ />
64
+ {{ durationLabel }}
65
+ <span v-if="isRunning" class="text-[11px] text-slate-500">elapsed</span>
66
+ </p>
67
+ </div>
68
+
69
+ <div v-if="formatClock(step.startedAt)">
70
+ <h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">Started</h4>
71
+ <p class="text-[12px] text-slate-300">{{ formatClock(step.startedAt) }}</p>
72
+ </div>
73
+
74
+ <div v-if="formatClock(step.finishedAt)">
75
+ <h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
76
+ Finished
77
+ </h4>
78
+ <p class="text-[12px] text-slate-300">{{ formatClock(step.finishedAt) }}</p>
79
+ </div>
80
+
81
+ <div v-if="step.model">
82
+ <h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">Model</h4>
83
+ <p class="break-all text-[12px] text-slate-300" :title="step.model">
84
+ {{ modelLabel ?? step.model }}
85
+ </p>
86
+ </div>
87
+
88
+ <div v-if="runId">
89
+ <h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">Run</h4>
90
+ <p
91
+ class="cursor-pointer break-all font-mono text-[12px] text-slate-400 hover:text-slate-200"
92
+ :title="`${runId} — click to copy`"
93
+ @click="copyRunId"
94
+ >
95
+ {{ runId }}
96
+ </p>
97
+ </div>
98
+
99
+ <!-- The model-activity rollup, embedded inline. The "View all calls →" link opens the
100
+ run's observability panel even when this step recorded no calls (e.g. a gate that
101
+ passed its precheck with no helper spun up), so every window reaches it the same
102
+ way; the metrics bar shows only when the step itself made calls. -->
103
+ <StepModelActivity :metrics="step.metrics" :instance-id="instanceId" />
104
+ </div>
105
+ </template>