@cat-factory/app 0.29.1 → 0.30.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.
@@ -188,7 +188,13 @@ async function onDrop(event: DragEvent) {
188
188
  </script>
189
189
 
190
190
  <template>
191
- <div ref="boardEl" class="relative h-full w-full" @drop="onDrop" @dragover="onDragOver">
191
+ <div
192
+ ref="boardEl"
193
+ data-testid="board-canvas"
194
+ class="relative h-full w-full"
195
+ @drop="onDrop"
196
+ @dragover="onDragOver"
197
+ >
192
198
  <VueFlow
193
199
  :id="BOARD_FLOW_ID"
194
200
  :nodes="nodes"
@@ -173,6 +173,8 @@ function selectTask() {
173
173
  <div
174
174
  v-if="task && statusMeta"
175
175
  :data-block-id="task.id"
176
+ :data-status="task.status"
177
+ data-testid="task-card"
176
178
  class="nodrag w-full cursor-pointer rounded-lg border bg-slate-950/70 p-2 text-left transition"
177
179
  :class="[
178
180
  selected ? 'border-white' : 'border-slate-700 hover:border-slate-500',
@@ -273,6 +275,7 @@ function selectTask() {
273
275
  color="warning"
274
276
  variant="soft"
275
277
  size="xs"
278
+ data-testid="task-resolve"
276
279
  :icon="attention.icon"
277
280
  @click.stop="attention.open()"
278
281
  >
@@ -17,6 +17,7 @@ const open = computed({
17
17
  if (!v) ui.closeDocumentImport()
18
18
  },
19
19
  })
20
+ const back = useIntegrationBack(open)
20
21
 
21
22
  const targetFrameId = computed(() => ui.documentImport?.targetFrameId ?? null)
22
23
  const targetFrameTitle = computed(() =>
@@ -76,13 +77,7 @@ function preview(externalId: string) {
76
77
  <template>
77
78
  <UModal v-model:open="open" title="Import from a document source">
78
79
  <template #title>
79
- <IntegrationBackTitle
80
- title="Import from a document source"
81
- @back="
82
- open = false
83
- ui.openIntegrations()
84
- "
85
- />
80
+ <IntegrationBackTitle title="Import from a document source" @back="back" />
86
81
  </template>
87
82
  <template #body>
88
83
  <div v-if="!documents.anyConnected" class="space-y-3 text-center">
@@ -25,6 +25,7 @@ const open = computed({
25
25
  if (!v) ui.closeDocumentConnect()
26
26
  },
27
27
  })
28
+ const back = useIntegrationBack(open)
28
29
 
29
30
  /** One value per credential field, reset whenever the modal (re)opens. */
30
31
  const values = ref<Record<string, string>>({})
@@ -80,13 +81,7 @@ async function disconnect() {
80
81
  <template>
81
82
  <UModal v-model:open="open" :title="descriptor?.label ?? 'Connect source'">
82
83
  <template #title>
83
- <IntegrationBackTitle
84
- :title="descriptor?.label ?? 'Connect source'"
85
- @back="
86
- open = false
87
- ui.openIntegrations()
88
- "
89
- />
84
+ <IntegrationBackTitle :title="descriptor?.label ?? 'Connect source'" @back="back" />
90
85
  </template>
91
86
  <template #body>
92
87
  <div v-if="descriptor" class="space-y-4">
@@ -22,6 +22,7 @@ const open = computed({
22
22
  if (!v) ui.closeGitHub()
23
23
  },
24
24
  })
25
+ const back = useIntegrationBack(open)
25
26
 
26
27
  // On open: refresh projections when connected. The not-connected state renders
27
28
  // <GitHubConnect>, which discovers and links installations on its own.
@@ -201,13 +202,7 @@ async function merge(pr: GitHubPullRequest) {
201
202
  <template>
202
203
  <UModal v-model:open="open" title="GitHub" :ui="{ content: 'max-w-2xl' }">
203
204
  <template #title>
204
- <IntegrationBackTitle
205
- title="GitHub"
206
- @back="
207
- open = false
208
- ui.openIntegrations()
209
- "
210
- />
205
+ <IntegrationBackTitle title="GitHub" @back="back" />
211
206
  </template>
212
207
  <template #body>
213
208
  <div class="space-y-5">
@@ -108,7 +108,13 @@ const decisionItems = computed(() =>
108
108
 
109
109
  <!-- decisions queue -->
110
110
  <UDropdownMenu v-if="execution.pendingDecisionCount" :items="decisionItems">
111
- <UButton color="warning" variant="soft" size="sm" icon="i-lucide-circle-help">
111
+ <UButton
112
+ color="warning"
113
+ variant="soft"
114
+ size="sm"
115
+ icon="i-lucide-circle-help"
116
+ data-testid="decision-badge"
117
+ >
112
118
  {{ execution.pendingDecisionCount }} decision{{
113
119
  execution.pendingDecisionCount === 1 ? '' : 's'
114
120
  }}
@@ -32,7 +32,7 @@ function choose(option: string) {
32
32
  <template>
33
33
  <UModal v-model:open="open" title="Decision required">
34
34
  <template #body>
35
- <div v-if="decision && agent" class="space-y-4">
35
+ <div v-if="decision && agent" class="space-y-4" data-testid="decision-modal">
36
36
  <div class="flex items-center gap-2 text-sm text-slate-400">
37
37
  <div
38
38
  class="flex h-8 w-8 items-center justify-center rounded-lg"
@@ -56,6 +56,7 @@ function choose(option: string) {
56
56
  color="primary"
57
57
  variant="soft"
58
58
  block
59
+ data-testid="decision-option"
59
60
  class="justify-start"
60
61
  @click="choose(opt)"
61
62
  >
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
- import { computed, reactive, watch } from 'vue'
2
+ import { computed, reactive, ref, watch } from 'vue'
3
3
  import { onKeyStroke } from '@vueuse/core'
4
- import type { LlmCallMetric } from '~/types/execution'
4
+ import type { AgentContextSnapshot, LlmCallMetric } from '~/types/execution'
5
5
  import { agentKindMeta } from '~/utils/catalog'
6
6
  import { formatMs, formatTokens, pct } from '~/utils/observability'
7
7
 
@@ -32,11 +32,38 @@ const error = computed(() =>
32
32
  executionId.value ? (observability.errors[executionId.value] ?? null) : null,
33
33
  )
34
34
 
35
- // Load (and refresh) whenever a different run's panel opens.
35
+ // Which view is shown: the per-call model activity, or the complete provided context.
36
+ const view = ref<'calls' | 'context'>('calls')
37
+
38
+ const contextSnapshots = computed<AgentContextSnapshot[]>(() =>
39
+ executionId.value ? observability.contextFor(executionId.value) : [],
40
+ )
41
+ const contextLoading = computed(
42
+ () => !!executionId.value && observability.isContextLoading(executionId.value),
43
+ )
44
+
45
+ // Load (and refresh) whenever a different run's panel opens. Reset to the calls view
46
+ // and load both the calls and the provided-context snapshots.
36
47
  watch(executionId, (id) => {
37
- if (id) void observability.load(id)
48
+ if (id) {
49
+ view.value = 'calls'
50
+ void observability.load(id)
51
+ void observability.loadContext(id)
52
+ }
38
53
  })
39
54
 
55
+ const expandedCtx = reactive<Record<string, boolean>>({})
56
+ function toggleCtx(s: AgentContextSnapshot) {
57
+ expandedCtx[s.id] = !expandedCtx[s.id]
58
+ }
59
+ function prettyExtras(extras: Record<string, unknown>): string {
60
+ try {
61
+ return JSON.stringify(extras, null, 2)
62
+ } catch {
63
+ return String(extras)
64
+ }
65
+ }
66
+
40
67
  // Run-level totals, derived from the loaded calls.
41
68
  const totals = computed(() => {
42
69
  const c = calls.value
@@ -124,7 +151,32 @@ function exportJson() {
124
151
  </p>
125
152
  </div>
126
153
  <div class="ml-auto flex items-center gap-1.5">
154
+ <div class="mr-1 flex rounded-lg border border-slate-800 p-0.5 text-[12px]">
155
+ <button
156
+ class="rounded-md px-2.5 py-1 transition"
157
+ :class="
158
+ view === 'calls'
159
+ ? 'bg-slate-800 text-slate-100'
160
+ : 'text-slate-400 hover:text-slate-200'
161
+ "
162
+ @click="view = 'calls'"
163
+ >
164
+ Model activity
165
+ </button>
166
+ <button
167
+ class="rounded-md px-2.5 py-1 transition"
168
+ :class="
169
+ view === 'context'
170
+ ? 'bg-slate-800 text-slate-100'
171
+ : 'text-slate-400 hover:text-slate-200'
172
+ "
173
+ @click="view = 'context'"
174
+ >
175
+ Provided context
176
+ </button>
177
+ </div>
127
178
  <UButton
179
+ v-if="view === 'calls'"
128
180
  icon="i-lucide-download"
129
181
  color="neutral"
130
182
  variant="soft"
@@ -148,7 +200,7 @@ function exportJson() {
148
200
  </header>
149
201
 
150
202
  <div class="flex-1 overflow-auto px-6 py-6">
151
- <div class="mx-auto max-w-4xl space-y-5">
203
+ <div v-if="view === 'calls'" class="mx-auto max-w-4xl space-y-5">
152
204
  <!-- run-level summary -->
153
205
  <section class="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
154
206
  <dl class="grid grid-cols-2 gap-x-6 gap-y-3 text-[13px] sm:grid-cols-4">
@@ -333,6 +385,126 @@ function exportJson() {
333
385
  </li>
334
386
  </ul>
335
387
  </div>
388
+
389
+ <!-- Provided context: the complete context each container agent was given. -->
390
+ <div v-else class="mx-auto max-w-4xl space-y-5">
391
+ <p
392
+ v-if="contextLoading && !contextSnapshots.length"
393
+ class="flex items-center justify-center gap-2 py-8 text-center text-sm text-slate-500"
394
+ >
395
+ <UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" /> Loading provided
396
+ context…
397
+ </p>
398
+ <p
399
+ v-else-if="!contextSnapshots.length"
400
+ class="rounded-lg border border-dashed border-slate-800 py-8 text-center text-sm text-slate-500"
401
+ >
402
+ No agent context stored for this run. It is captured per dispatch when the workspace
403
+ has "Store full agent context" enabled.
404
+ </p>
405
+
406
+ <ul v-else class="space-y-2">
407
+ <li
408
+ v-for="s in contextSnapshots"
409
+ :key="s.id"
410
+ class="overflow-hidden rounded-xl border border-slate-800 bg-slate-900/40"
411
+ >
412
+ <button
413
+ class="flex w-full items-center gap-3 px-4 py-2.5 text-left transition hover:bg-slate-900/70"
414
+ @click="toggleCtx(s)"
415
+ >
416
+ <UIcon
417
+ name="i-lucide-chevron-right"
418
+ class="h-4 w-4 shrink-0 text-slate-500 transition-transform"
419
+ :class="expandedCtx[s.id] ? 'rotate-90' : ''"
420
+ />
421
+ <UIcon
422
+ :name="agentMeta(s.agentKind).icon"
423
+ class="h-4 w-4 shrink-0"
424
+ :style="{ color: agentMeta(s.agentKind).color }"
425
+ />
426
+ <span class="text-[13px] text-slate-200">{{ agentMeta(s.agentKind).label }}</span>
427
+ <span v-if="s.model" class="hidden truncate text-[11px] text-slate-500 sm:inline">
428
+ {{ s.model }}
429
+ </span>
430
+ <div
431
+ class="ml-auto flex items-center gap-2.5 text-[11px] tabular-nums text-slate-400"
432
+ >
433
+ <span :title="'Injected context files'">{{ s.contextFiles.length }} files</span>
434
+ <span :title="'Best-practice fragments'"
435
+ >{{ s.fragments.length }} fragments</span
436
+ >
437
+ <span class="hidden text-slate-600 md:inline">{{ clock(s.createdAt) }}</span>
438
+ </div>
439
+ </button>
440
+
441
+ <div v-if="expandedCtx[s.id]" class="border-t border-slate-800 px-4 py-3 space-y-3">
442
+ <div>
443
+ <div class="mb-1 text-[11px] uppercase tracking-wide text-slate-500">
444
+ System prompt
445
+ </div>
446
+ <pre
447
+ class="max-h-72 overflow-auto rounded-lg bg-slate-950/70 p-3 text-[11px] leading-relaxed text-slate-300"
448
+ >{{ s.systemPrompt || '—' }}</pre
449
+ >
450
+ </div>
451
+ <div>
452
+ <div class="mb-1 text-[11px] uppercase tracking-wide text-slate-500">
453
+ User prompt
454
+ </div>
455
+ <pre
456
+ class="max-h-72 overflow-auto rounded-lg bg-slate-950/70 p-3 text-[11px] leading-relaxed text-slate-300"
457
+ >{{ s.userPrompt || '—' }}</pre
458
+ >
459
+ </div>
460
+ <div v-if="s.fragments.length">
461
+ <div class="mb-1 text-[11px] uppercase tracking-wide text-slate-500">
462
+ Best-practice fragments
463
+ </div>
464
+ <div
465
+ v-for="f in s.fragments"
466
+ :key="f.id"
467
+ class="mb-2 rounded-lg bg-slate-950/70 p-3"
468
+ >
469
+ <div class="mb-1 text-[11px] text-slate-400">{{ f.id }}</div>
470
+ <pre
471
+ class="max-h-48 overflow-auto text-[11px] leading-relaxed text-slate-300"
472
+ >{{ f.body }}</pre
473
+ >
474
+ </div>
475
+ </div>
476
+ <div v-if="s.contextFiles.length">
477
+ <div class="mb-1 text-[11px] uppercase tracking-wide text-slate-500">
478
+ Injected context files
479
+ </div>
480
+ <div
481
+ v-for="file in s.contextFiles"
482
+ :key="file.path"
483
+ class="mb-2 rounded-lg bg-slate-950/70 p-3"
484
+ >
485
+ <div class="mb-1 text-[11px] text-slate-400">
486
+ {{ file.title }}
487
+ <span class="text-slate-600">· {{ file.path }}</span>
488
+ </div>
489
+ <pre
490
+ class="max-h-72 overflow-auto text-[11px] leading-relaxed text-slate-300"
491
+ >{{ file.content }}</pre
492
+ >
493
+ </div>
494
+ </div>
495
+ <div>
496
+ <div class="mb-1 text-[11px] uppercase tracking-wide text-slate-500">
497
+ Details
498
+ </div>
499
+ <pre
500
+ class="max-h-48 overflow-auto rounded-lg bg-slate-950/70 p-3 text-[11px] leading-relaxed text-slate-400"
501
+ >{{ prettyExtras(s.extras) }}</pre
502
+ >
503
+ </div>
504
+ </div>
505
+ </li>
506
+ </ul>
507
+ </div>
336
508
  </div>
337
509
  </div>
338
510
  </Transition>
@@ -18,6 +18,7 @@ const open = computed({
18
18
  get: () => ui.vendorCredentialsOpen,
19
19
  set: (v: boolean) => (v ? ui.openVendorCredentials() : ui.closeVendorCredentials()),
20
20
  })
21
+ const back = useIntegrationBack(open)
21
22
 
22
23
  // Horizontal tabs replace the old long vertical scroll: each credential kind is its own
23
24
  // section (pooled subscriptions, direct vendor keys, proxy/gateway keys, personal subs).
@@ -118,13 +119,7 @@ function vendorLabel(v: SubscriptionVendor): string {
118
119
  <template>
119
120
  <UModal v-model:open="open" title="LLM Vendors" :ui="{ content: 'max-w-2xl' }">
120
121
  <template #title>
121
- <IntegrationBackTitle
122
- title="LLM Vendors"
123
- @back="
124
- open = false
125
- ui.openIntegrations()
126
- "
127
- />
122
+ <IntegrationBackTitle title="LLM Vendors" @back="back" />
128
123
  </template>
129
124
  <template #body>
130
125
  <UTabs
@@ -17,6 +17,7 @@ const open = computed({
17
17
  get: () => ui.localModelsOpen,
18
18
  set: (v: boolean) => (v ? ui.openLocalModels() : ui.closeLocalModels()),
19
19
  })
20
+ const back = useIntegrationBack(open)
20
21
 
21
22
  // Load the user's endpoints whenever the panel opens (loaded independently of the
22
23
  // workspace snapshot, like personal subscriptions).
@@ -154,13 +155,7 @@ async function remove(p: LocalRunner) {
154
155
  <template>
155
156
  <UModal v-model:open="open" title="My local runners" :ui="{ content: 'max-w-2xl' }">
156
157
  <template #title>
157
- <IntegrationBackTitle
158
- title="My local runners"
159
- @back="
160
- open = false
161
- ui.openIntegrations()
162
- "
163
- />
158
+ <IntegrationBackTitle title="My local runners" @back="back" />
164
159
  </template>
165
160
  <template #body>
166
161
  <div class="space-y-4">
@@ -16,6 +16,7 @@ const open = computed({
16
16
  get: () => ui.observabilityConnectionOpen,
17
17
  set: (v: boolean) => (v ? ui.openObservabilityConnection() : ui.closeObservabilityConnection()),
18
18
  })
19
+ const back = useIntegrationBack(open)
19
20
 
20
21
  // The providers a user can connect. Datadog only today; the picker is ready for more.
21
22
  const PROVIDERS: { value: ObservabilityProviderKind; label: string }[] = [
@@ -130,13 +131,7 @@ const connectedLabel = computed(() => {
130
131
  <template>
131
132
  <UModal v-model:open="open" title="Post-release health" :ui="{ content: 'max-w-lg' }">
132
133
  <template #title>
133
- <IntegrationBackTitle
134
- title="Post-release health"
135
- @back="
136
- open = false
137
- ui.openIntegrations()
138
- "
139
- />
134
+ <IntegrationBackTitle title="Post-release health" @back="back" />
140
135
  </template>
141
136
  <template #body>
142
137
  <div class="space-y-4">
@@ -22,6 +22,7 @@ const open = computed({
22
22
  get: () => ui.openRouterOpen,
23
23
  set: (v: boolean) => (v ? ui.openOpenRouter() : ui.closeOpenRouter()),
24
24
  })
25
+ const back = useIntegrationBack(open)
25
26
 
26
27
  // Popular slugs offered by "Enable recommended" — these mirror the curated `openrouter`
27
28
  // refs in the backend MODEL_CATALOG. Only the ones present in the live browse list are
@@ -195,13 +196,7 @@ function manageKeys() {
195
196
  <template>
196
197
  <UModal v-model:open="open" title="OpenRouter" :ui="{ content: 'max-w-2xl' }">
197
198
  <template #title>
198
- <IntegrationBackTitle
199
- title="OpenRouter"
200
- @back="
201
- open = false
202
- ui.openIntegrations()
203
- "
204
- />
199
+ <IntegrationBackTitle title="OpenRouter" @back="back" />
205
200
  </template>
206
201
  <template #body>
207
202
  <div class="space-y-4">
@@ -38,6 +38,7 @@ const open = computed({
38
38
  if (!v) ui.closeProviderConnection()
39
39
  },
40
40
  })
41
+ const back = useIntegrationBack(open)
41
42
 
42
43
  const meta = computed(() => (kind.value ? META[kind.value] : null))
43
44
  const descriptor = computed(() => (kind.value ? store.descriptorFor(kind.value) : null))
@@ -224,13 +225,7 @@ function fieldHelp(key: string): string | undefined {
224
225
  <template>
225
226
  <UModal v-model:open="open" :title="meta?.title ?? 'Provider'" :ui="{ content: 'max-w-xl' }">
226
227
  <template #title>
227
- <IntegrationBackTitle
228
- :title="meta?.title ?? 'Provider'"
229
- @back="
230
- open = false
231
- ui.openIntegrations()
232
- "
233
- />
228
+ <IntegrationBackTitle :title="meta?.title ?? 'Provider'" @back="back" />
234
229
  </template>
235
230
  <template #body>
236
231
  <div v-if="descriptor" class="space-y-4">
@@ -16,6 +16,7 @@ const open = computed({
16
16
  get: () => ui.userSecretsOpen,
17
17
  set: (v: boolean) => (v ? ui.openUserSecrets() : ui.closeUserSecrets()),
18
18
  })
19
+ const back = useIntegrationBack(open)
19
20
 
20
21
  // The kind being edited (only `github_pat` today; descriptors drive the rest generically).
21
22
  const kind = ref<UserSecretKind>('github_pat')
@@ -125,13 +126,7 @@ async function remove() {
125
126
  <template>
126
127
  <UModal v-model:open="open" title="My GitHub token" :ui="{ content: 'max-w-xl' }">
127
128
  <template #title>
128
- <IntegrationBackTitle
129
- title="My GitHub token"
130
- @back="
131
- open = false
132
- ui.openIntegrations()
133
- "
134
- />
129
+ <IntegrationBackTitle title="My GitHub token" @back="back" />
135
130
  </template>
136
131
  <template #body>
137
132
  <div class="space-y-4">
@@ -22,6 +22,7 @@ const open = computed({
22
22
  get: () => ui.workspaceSettingsOpen,
23
23
  set: (v: boolean) => (v ? ui.openWorkspaceSettings() : ui.closeWorkspaceSettings()),
24
24
  })
25
+ const back = useIntegrationBack(open)
25
26
 
26
27
  // Which tab is shown — driven by the ui store so other surfaces (command bar,
27
28
  // integrations) can deep-link straight to a tab.
@@ -66,6 +67,7 @@ const draft = reactive({
66
67
  taskLimitMode: 'off' as TaskLimitMode,
67
68
  taskLimitShared: 5 as number,
68
69
  perType: {} as Record<CreateTaskType, number>,
70
+ storeAgentContext: true,
69
71
  // Budget: empty string ⇒ "use the built-in default" (null on the wire).
70
72
  spendCurrency: '',
71
73
  spendMonthlyLimit: '',
@@ -79,6 +81,7 @@ function hydrate() {
79
81
  draft.taskLimitShared = s.taskLimitShared ?? 5
80
82
  const pt = s.taskLimitPerType ?? {}
81
83
  for (const t of TASK_TYPES) draft.perType[t] = pt[t] ?? 3
84
+ draft.storeAgentContext = s.storeAgentContext
82
85
  draft.spendCurrency = s.spendCurrency ?? ''
83
86
  draft.spendMonthlyLimit = s.spendMonthlyLimit == null ? '' : String(s.spendMonthlyLimit)
84
87
  draft.spendModelPrices = s.spendModelPrices ? JSON.stringify(s.spendModelPrices, null, 2) : ''
@@ -105,6 +108,7 @@ async function save() {
105
108
  {} as Record<CreateTaskType, number>,
106
109
  )
107
110
  : null,
111
+ storeAgentContext: draft.storeAgentContext,
108
112
  })
109
113
  toast.add({ title: 'Settings saved', icon: 'i-lucide-check', color: 'success' })
110
114
  } catch (e) {
@@ -162,13 +166,7 @@ async function saveBudget() {
162
166
  <template>
163
167
  <UModal v-model:open="open" title="Workspace settings" :ui="{ content: 'max-w-3xl' }">
164
168
  <template #title>
165
- <IntegrationBackTitle
166
- title="Workspace settings"
167
- @back="
168
- open = false
169
- ui.openIntegrations()
170
- "
171
- />
169
+ <IntegrationBackTitle title="Workspace settings" @back="back" />
172
170
  </template>
173
171
  <template #body>
174
172
  <UTabs
@@ -238,6 +236,22 @@ async function saveBudget() {
238
236
  </div>
239
237
  </section>
240
238
 
239
+ <!-- Agent observability -->
240
+ <section class="space-y-2">
241
+ <h3 class="text-sm font-semibold text-slate-200">Agent observability</h3>
242
+ <p class="text-[11px] text-slate-400">
243
+ Store the complete context provided to each agent — the composed prompts, the
244
+ best-practice fragments folded in, and the full content of the files injected into
245
+ its container — so it can be inspected later in the observability view. The bodies
246
+ are kept for the same window as the per-call LLM telemetry. Turn off to stop storing
247
+ it (existing snapshots are pruned by retention).
248
+ </p>
249
+ <label class="flex items-center gap-2">
250
+ <USwitch v-model="draft.storeAgentContext" size="sm" />
251
+ <span class="text-sm text-slate-200">Store full agent context</span>
252
+ </label>
253
+ </section>
254
+
241
255
  <div class="flex justify-end">
242
256
  <UButton
243
257
  color="primary"
@@ -17,6 +17,7 @@ const open = computed({
17
17
  get: () => ui.slackOpen,
18
18
  set: (v: boolean) => (v ? ui.openSlack() : ui.closeSlack()),
19
19
  })
20
+ const back = useIntegrationBack(open)
20
21
 
21
22
  const ROUTABLE: { type: NotificationType; label: string }[] = [
22
23
  { type: 'merge_review', label: 'Merge review' },
@@ -143,13 +144,7 @@ async function saveMapping() {
143
144
  <template>
144
145
  <UModal v-model:open="open" title="Slack notifications" :ui="{ content: 'max-w-2xl' }">
145
146
  <template #title>
146
- <IntegrationBackTitle
147
- title="Slack notifications"
148
- @back="
149
- open = false
150
- ui.openIntegrations()
151
- "
152
- />
147
+ <IntegrationBackTitle title="Slack notifications" @back="back" />
153
148
  </template>
154
149
  <template #body>
155
150
  <div class="space-y-5">
@@ -21,6 +21,7 @@ const open = computed({
21
21
  if (!v) ui.closeTaskImport()
22
22
  },
23
23
  })
24
+ const back = useIntegrationBack(open)
24
25
 
25
26
  const source = ref<TaskSourceKind | undefined>(undefined)
26
27
  const ref_ = ref('')
@@ -197,13 +198,7 @@ async function doSpawnEpic() {
197
198
  <template>
198
199
  <UModal v-model:open="open" :title="title">
199
200
  <template #title>
200
- <IntegrationBackTitle
201
- :title="title"
202
- @back="
203
- open = false
204
- ui.openIntegrations()
205
- "
206
- />
201
+ <IntegrationBackTitle :title="title" @back="back" />
207
202
  </template>
208
203
  <template #body>
209
204
  <!-- Empty state: no source offered (none connected/installed, or all disabled) -->
@@ -32,6 +32,7 @@ const open = computed({
32
32
  if (!v) ui.closeTaskConnect()
33
33
  },
34
34
  })
35
+ const back = useIntegrationBack(open)
35
36
 
36
37
  /** One value per credential field, reset whenever the modal (re)opens. */
37
38
  const values = ref<Record<string, string>>({})
@@ -107,13 +108,7 @@ async function toggleEnabled(enabled: boolean) {
107
108
  <template>
108
109
  <UModal v-model:open="open" :title="descriptor?.label ?? 'Task source'">
109
110
  <template #title>
110
- <IntegrationBackTitle
111
- :title="descriptor?.label ?? 'Task source'"
112
- @back="
113
- open = false
114
- ui.openIntegrations()
115
- "
116
- />
111
+ <IntegrationBackTitle :title="descriptor?.label ?? 'Task source'" @back="back" />
117
112
  </template>
118
113
  <template #body>
119
114
  <div v-if="descriptor" class="space-y-4">
@@ -1,5 +1,6 @@
1
1
  import type { Block, ExecutionInstance } from '~/types/domain'
2
2
  import type {
3
+ AgentContextSnapshot,
3
4
  IterationCapChoice,
4
5
  LlmCallMetric,
5
6
  LlmMetricsExport,
@@ -120,6 +121,13 @@ export function executionApi({ http, ws, pwHeaders }: ApiContext) {
120
121
  `${ws(workspaceId)}/executions/${encodeURIComponent(executionId)}/llm-metrics/export`,
121
122
  ),
122
123
 
124
+ // The complete provided context per container-agent dispatch (composed prompts,
125
+ // folded-in fragments, injected files). Empty when not wired / storing is off.
126
+ getAgentContext: (workspaceId: string, executionId: string) =>
127
+ http<{ executionId: string; snapshots: AgentContextSnapshot[] }>(
128
+ `${ws(workspaceId)}/executions/${encodeURIComponent(executionId)}/agent-context`,
129
+ ),
130
+
123
131
  // ---- spend safeguard --------------------------------------------------
124
132
  resumeSpend: (workspaceId: string) =>
125
133
  http<ExecutionInstance[]>(`${ws(workspaceId)}/spend/resume`, { method: 'POST' }),
@@ -0,0 +1,20 @@
1
+ import type { Ref, WritableComputedRef } from 'vue'
2
+
3
+ /**
4
+ * The shared "back to Integrations" handler for an integration sub-panel's modal header.
5
+ * Every panel reached from the Integrations hub closes itself and reopens the hub when its
6
+ * {@link IntegrationBackTitle} Back control fires `@back`. Centralising it keeps the ~13
7
+ * panels from each re-implementing the two-step close-then-reopen inline — which also
8
+ * dodges a Vue SFC-compiler trap: a multi-statement inline template handler
9
+ * (`open = false` ⏎ `ui.openIntegrations()`) is rejected at build time, so callers had to
10
+ * resort to an obscure comma-operator expression. A named handler reads clearly instead.
11
+ *
12
+ * Pass the panel's `open` model (the writable ref/computed bound to its `UModal`).
13
+ */
14
+ export function useIntegrationBack(open: Ref<boolean> | WritableComputedRef<boolean>) {
15
+ const ui = useUiStore()
16
+ return () => {
17
+ open.value = false
18
+ ui.openIntegrations()
19
+ }
20
+ }
@@ -1,6 +1,6 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref } from 'vue'
3
- import type { LlmCallActivity, LlmCallMetric } from '~/types/execution'
3
+ import type { AgentContextSnapshot, LlmCallActivity, LlmCallMetric } from '~/types/execution'
4
4
  import { useWorkspaceStore } from '~/stores/workspace'
5
5
 
6
6
  /**
@@ -19,6 +19,10 @@ export const useObservabilityStore = defineStore('observability', () => {
19
19
 
20
20
  /** Per-execution-id call list (newest first). */
21
21
  const callsByExecution = ref<Record<string, LlmCallMetric[]>>({})
22
+ /** Per-execution-id provided-context snapshot list (newest first). */
23
+ const contextByExecution = ref<Record<string, AgentContextSnapshot[]>>({})
24
+ /** Execution ids whose context is currently loading. */
25
+ const contextLoading = ref<Set<string>>(new Set())
22
26
  /** Execution ids currently loading. */
23
27
  const loading = ref<Set<string>>(new Set())
24
28
  /** Execution ids currently exporting. */
@@ -109,6 +113,27 @@ export const useObservabilityStore = defineStore('observability', () => {
109
113
  callsByExecution.value = { ...callsByExecution.value, [executionId]: [row, ...existing] }
110
114
  }
111
115
 
116
+ function contextFor(executionId: string): AgentContextSnapshot[] {
117
+ return contextByExecution.value[executionId] ?? []
118
+ }
119
+ function isContextLoading(executionId: string): boolean {
120
+ return contextLoading.value.has(executionId)
121
+ }
122
+
123
+ /** Load (or refresh) the per-dispatch provided-context snapshots for a run. */
124
+ async function loadContext(executionId: string) {
125
+ if (!workspace.workspaceId) return
126
+ withFlag(contextLoading, executionId, true)
127
+ try {
128
+ const { snapshots } = await api.getAgentContext(workspace.requireId(), executionId)
129
+ contextByExecution.value = { ...contextByExecution.value, [executionId]: snapshots }
130
+ } catch {
131
+ // Best-effort: the panel shows an empty state; nothing is persisted client-side.
132
+ } finally {
133
+ withFlag(contextLoading, executionId, false)
134
+ }
135
+ }
136
+
112
137
  /**
113
138
  * Fetch the LLM-friendly export bundle and trigger a client-side download. The
114
139
  * events socket auths via a Bearer header (a plain `<a download>` can't), so we
@@ -140,5 +165,9 @@ export const useObservabilityStore = defineStore('observability', () => {
140
165
  load,
141
166
  appendCall,
142
167
  downloadExport,
168
+ contextByExecution,
169
+ contextFor,
170
+ isContextLoading,
171
+ loadContext,
143
172
  }
144
173
  })
@@ -9,6 +9,7 @@ const DEFAULTS: WorkspaceSettings = {
9
9
  taskLimitMode: 'off',
10
10
  taskLimitShared: null,
11
11
  taskLimitPerType: null,
12
+ storeAgentContext: true,
12
13
  spendCurrency: null,
13
14
  spendMonthlyLimit: null,
14
15
  spendModelPrices: null,
@@ -493,6 +493,8 @@ export interface WorkspaceSettings {
493
493
  taskLimitShared: number | null
494
494
  /** The per-type caps, when `taskLimitMode` is `per_type` (type → cap). */
495
495
  taskLimitPerType: Partial<Record<CreateTaskType, number>> | null
496
+ /** Whether to store the complete provided-context snapshot for each container agent. */
497
+ storeAgentContext: boolean
496
498
  /** Spend budget currency (ISO 4217). Null ⇒ the built-in default (`EUR`). */
497
499
  spendCurrency: string | null
498
500
  /** Monthly spend budget in `spendCurrency`. Null ⇒ the built-in default. */
@@ -507,6 +509,7 @@ export interface UpdateWorkspaceSettingsInput {
507
509
  taskLimitMode?: TaskLimitMode
508
510
  taskLimitShared?: number | null
509
511
  taskLimitPerType?: Partial<Record<CreateTaskType, number>> | null
512
+ storeAgentContext?: boolean
510
513
  spendCurrency?: string | null
511
514
  spendMonthlyLimit?: number | null
512
515
  spendModelPrices?: Record<string, { inputPerMillion: number; outputPerMillion: number }> | null
@@ -229,6 +229,42 @@ export type LlmCallActivity = Omit<
229
229
  'promptText' | 'responseText' | 'reasoningText' | 'promptPrefixCount' | 'promptHash'
230
230
  >
231
231
 
232
+ /** One best-practice fragment folded into an agent's system prompt. */
233
+ export interface AgentContextFragment {
234
+ id: string
235
+ body: string
236
+ }
237
+
238
+ /** One file injected into the agent's container as context, with its full body. */
239
+ export interface AgentContextFile {
240
+ path: string
241
+ title: string
242
+ url: string
243
+ content: string
244
+ }
245
+
246
+ /**
247
+ * The complete, redacted context provided to one container-agent dispatch: the composed
248
+ * system + user prompts, the fragment bodies folded in, and the full content of the files
249
+ * injected into the container. Loaded on demand for the observability view. Mirrors the
250
+ * backend `AgentContextSnapshot` (it never carries any credential).
251
+ */
252
+ export interface AgentContextSnapshot {
253
+ id: string
254
+ workspaceId: string
255
+ executionId: string
256
+ agentKind: string
257
+ stepIndex: number
258
+ createdAt: number
259
+ model: string | null
260
+ harness: string | null
261
+ systemPrompt: string
262
+ userPrompt: string
263
+ fragments: AgentContextFragment[]
264
+ contextFiles: AgentContextFile[]
265
+ extras: Record<string, unknown>
266
+ }
267
+
232
268
  /** One per-agent-kind insight in the LLM-friendly export (rollup + derived ratios). */
233
269
  export interface LlmExportInsight extends StepMetrics {
234
270
  agentKind: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.29.1",
3
+ "version": "0.30.1",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",