@cat-factory/app 0.182.1 → 0.184.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.
Files changed (33) hide show
  1. package/README.md +9 -0
  2. package/app/components/layout/IntegrationsHub.vue +6 -19
  3. package/app/components/pipeline/AgentPromptEditor.logic.spec.ts +93 -0
  4. package/app/components/pipeline/AgentPromptEditor.logic.ts +60 -0
  5. package/app/components/pipeline/AgentPromptEditor.vue +298 -0
  6. package/app/components/pipeline/PipelineBuilder.vue +62 -0
  7. package/app/components/sandbox/SandboxPanel.vue +63 -5
  8. package/app/components/settings/InfrastructureWindow.logic.spec.ts +100 -0
  9. package/app/components/settings/InfrastructureWindow.logic.ts +78 -0
  10. package/app/components/settings/InfrastructureWindow.vue +65 -46
  11. package/app/components/settings/PackageRegistriesPanel.vue +131 -123
  12. package/app/composables/api/agentPrompts.ts +41 -0
  13. package/app/composables/useApi.ts +2 -0
  14. package/app/composables/usePipelineErrorToast.ts +8 -0
  15. package/app/pages/index.vue +0 -1
  16. package/app/stores/agentPrompts.ts +100 -0
  17. package/app/stores/packageRegistries.spec.ts +111 -0
  18. package/app/stores/packageRegistries.ts +21 -8
  19. package/app/stores/sandbox.ts +15 -0
  20. package/app/stores/ui/modals.ts +20 -21
  21. package/app/types/agent-prompts.ts +13 -0
  22. package/app/types/providerConnections.ts +12 -0
  23. package/i18n/locales/de.json +53 -11
  24. package/i18n/locales/en.json +68 -11
  25. package/i18n/locales/es.json +53 -11
  26. package/i18n/locales/fr.json +53 -11
  27. package/i18n/locales/he.json +53 -11
  28. package/i18n/locales/it.json +53 -11
  29. package/i18n/locales/ja.json +53 -11
  30. package/i18n/locales/pl.json +53 -11
  31. package/i18n/locales/tr.json +53 -11
  32. package/i18n/locales/uk.json +53 -11
  33. package/package.json +2 -2
package/README.md CHANGED
@@ -192,6 +192,15 @@ wrong place is invisible until a user cannot find it:
192
192
  It also carries a full "how it works / when you need this / when you can skip it"
193
193
  explanation there rather than a one-line hint, since the decision to run it has to
194
194
  be made before opening a five-minute wizard.
195
+ - **"It talks to an external service" is not what puts a surface in `integrations`.**
196
+ Private package registries connect to npmjs.com and GitHub Packages and still belong
197
+ in Infrastructure, because the question they answer is _what may a container install
198
+ from_ — a property of where agents RUN, which is what the Infrastructure window is
199
+ for. `integrations` is for a system the WORKSPACE links in and would still be a
200
+ coherent product without. Ask which question the destination answers, not whether a
201
+ credential leaves the building. A surface moved between sections must also move its
202
+ entry point: leaving a hub row behind as a shortcut splits the answer across two
203
+ places, so the row goes and the window's tab becomes the single route in.
195
204
 
196
205
  ## Develop & test
197
206
 
@@ -30,7 +30,6 @@ const documents = useDocumentsStore()
30
30
  const tasks = useTasksStore()
31
31
  const tracker = useTrackerStore()
32
32
  const releaseHealth = useReleaseHealthStore()
33
- const packageRegistries = usePackageRegistriesStore()
34
33
  const publicApiKeys = usePublicApiKeysStore()
35
34
  const userSecrets = useUserSecretsStore()
36
35
  const uiMode = useUiModeStore()
@@ -60,7 +59,6 @@ watch(
60
59
  if (isOpen) {
61
60
  query.value = ''
62
61
  void releaseHealth.ensureLoaded().catch(() => {})
63
- void packageRegistries.ensureLoaded().catch(() => {})
64
62
  void publicApiKeys.ensureLoaded().catch(() => {})
65
63
  void userSecrets.load().catch(() => {})
66
64
  }
@@ -271,22 +269,10 @@ const groups = computed<IntegrationGroup[]>(() => {
271
269
  })
272
270
  }
273
271
 
274
- // --- Development (private package registries + API access tokens) -----------
275
- // Each row is gated like observability: hidden until a probe confirms its module is
276
- // wired (`available === true`), so an unconfigured backend doesn't show a dead row.
272
+ // --- Development (API access tokens) ---------------------------------------
273
+ // Gated like observability: hidden until a probe confirms its module is wired
274
+ // (`available === true`), so an unconfigured backend doesn't show a dead row.
277
275
  const development: IntegrationItem[] = []
278
- if (packageRegistries.available) {
279
- const hasEntries = packageRegistries.entries.length > 0
280
- development.push({
281
- key: 'package-registries',
282
- icon: 'i-lucide-package',
283
- label: t('layout.integrationsHub.items.packageRegistries.label'),
284
- description: t('layout.integrationsHub.items.packageRegistries.description'),
285
- status: hasEntries ? t('layout.integrationsHub.status.connected') : undefined,
286
- connected: hasEntries,
287
- onClick: () => go(ui.openPackageRegistries),
288
- })
289
- }
290
276
  if (publicApiKeys.available) {
291
277
  const hasKeys = publicApiKeys.keys.length > 0
292
278
  development.push({
@@ -303,8 +289,9 @@ const groups = computed<IntegrationGroup[]>(() => {
303
289
  out.push({ title: t('layout.integrationsHub.groups.development'), items: development })
304
290
 
305
291
  // NOTE: Infrastructure (agent-container execution + Tester environments + the local-mode
306
- // warm pool/checkout) is no longer listed here it moved to its OWN top-level navbar menu
307
- // (SideBar → "Infrastructure" → the tabbed Infrastructure window). See `ui.openInfrastructure`.
292
+ // warm pool/checkout + the private package registries a checkout installs from) is no longer
293
+ // listed here — it moved to its OWN top-level navbar menu (SideBar → "Infrastructure" → the
294
+ // tabbed Infrastructure window). See `ui.openInfrastructure`.
308
295
 
309
296
  // --- Personal (only you) — fallback when there is no UserMenu to host "My setup" -------
310
297
  // Per-user connections normally live in the My-setup hub; with auth disabled they fold in
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { AgentPromptDetail, AgentPromptRevision } from '~/types/agent-prompts'
3
+ import {
4
+ draftForRevision,
5
+ isDirty,
6
+ isRevisionConflict,
7
+ saveIntent,
8
+ } from './AgentPromptEditor.logic'
9
+
10
+ const BUILTIN = 'You are a careful engineer.'
11
+
12
+ function rev(overrides: Partial<AgentPromptRevision> = {}): AgentPromptRevision {
13
+ return { agentKind: 'coder', revision: 1, text: 'v1 text', createdAt: 1, ...overrides }
14
+ }
15
+
16
+ function detail(overrides: Partial<AgentPromptDetail> = {}): AgentPromptDetail {
17
+ return {
18
+ agentKind: 'coder',
19
+ builtinText: BUILTIN,
20
+ appendedText: '',
21
+ effectiveText: BUILTIN,
22
+ customized: false,
23
+ revisions: [],
24
+ ...overrides,
25
+ }
26
+ }
27
+
28
+ describe('saveIntent', () => {
29
+ it('sends the typed text as an override', () => {
30
+ expect(saveIntent('Be terse.', detail(), undefined)).toEqual({ text: 'Be terse.' })
31
+ })
32
+
33
+ it('sends null when the draft is the built-in, so the workspace keeps tracking it', () => {
34
+ // Storing a copy of the built-in would pin the workspace to today's wording — the exact
35
+ // thing the null revision exists to avoid.
36
+ expect(saveIntent(` ${BUILTIN} `, detail(), undefined)).toEqual({ text: null })
37
+ })
38
+
39
+ it('keeps restoredFrom while the draft still is that revision', () => {
40
+ const d = detail({ revisions: [rev({ revision: 3, text: 'v3 text' })] })
41
+ expect(saveIntent('v3 text', d, 3)).toEqual({ text: 'v3 text', restoredFrom: 3 })
42
+ })
43
+
44
+ it('DROPS restoredFrom once the draft has been edited away from that revision', () => {
45
+ // The regression this exists for: restore v3, tweak a line, save — and the log claims the
46
+ // new entry is v3 restored, so anyone tracing "what were we running" is misled by a record
47
+ // that reads as authoritative. Nothing errors; it is only wrong.
48
+ const d = detail({ revisions: [rev({ revision: 3, text: 'v3 text' })] })
49
+ expect(saveIntent('v3 text plus my edit', d, 3)).toEqual({ text: 'v3 text plus my edit' })
50
+ })
51
+
52
+ it('drops a restoredFrom naming a revision the (reloaded) log no longer has', () => {
53
+ // After a 409 the store holds the SERVER's log. A stale pick from before that reload must
54
+ // not be sent — the server would refuse it as `unknown_revision` and lose the user's text.
55
+ expect(saveIntent('anything', detail({ revisions: [rev({ revision: 1 })] }), 9)).toEqual({
56
+ text: 'anything',
57
+ })
58
+ })
59
+
60
+ it('keeps restoredFrom when restoring a revert revision, and still sends null', () => {
61
+ const d = detail({ revisions: [rev({ revision: 2, text: null })] })
62
+ expect(saveIntent(BUILTIN, d, 2)).toEqual({ text: null, restoredFrom: 2 })
63
+ })
64
+ })
65
+
66
+ describe('isDirty', () => {
67
+ it('ignores whitespace-only differences, which the payload would trim away anyway', () => {
68
+ expect(isDirty(` ${BUILTIN}\n`, detail())).toBe(false)
69
+ expect(isDirty(`${BUILTIN} and more`, detail())).toBe(true)
70
+ })
71
+ })
72
+
73
+ describe('draftForRevision', () => {
74
+ it('loads a revert revision as the built-in text', () => {
75
+ expect(draftForRevision(rev({ text: null }), detail())).toBe(BUILTIN)
76
+ })
77
+
78
+ it('loads an edited revision as its own text', () => {
79
+ expect(draftForRevision(rev({ text: 'mine' }), detail())).toBe('mine')
80
+ })
81
+ })
82
+
83
+ describe('isRevisionConflict', () => {
84
+ it('recognises the append-only log’s refusal, and nothing else', () => {
85
+ expect(
86
+ isRevisionConflict({ data: { error: { details: { reason: 'prompt_revision_conflict' } } } }),
87
+ ).toBe(true)
88
+ expect(
89
+ isRevisionConflict({ data: { error: { details: { reason: 'unknown_revision' } } } }),
90
+ ).toBe(false)
91
+ expect(isRevisionConflict(new Error('offline'))).toBe(false)
92
+ })
93
+ })
@@ -0,0 +1,60 @@
1
+ import type { AgentPromptDetail, AgentPromptRevision } from '~/types/agent-prompts'
2
+
3
+ // Pure decision logic for the agent system-prompt editor, split out of the component so the
4
+ // rules below are unit-testable without mounting Nuxt. Each one is a rule about the append-only
5
+ // revision log rather than about rendering, and each has a wrong answer that is silent rather
6
+ // than visible — which is why they are pinned rather than left inline in the template.
7
+
8
+ /**
9
+ * What a save should send for the current draft.
10
+ *
11
+ * Two rules, both non-obvious:
12
+ *
13
+ * - **Text identical to the built-in is a REVERT, not a copy of it.** Storing the copy would pin
14
+ * the workspace to today's wording and quietly stop it tracking the product's prompt as that is
15
+ * improved — the whole reason the null revision exists.
16
+ * - **`restoredFrom` only survives while the draft still IS that revision.** It is a claim the
17
+ * history renders ("restored from version 3"), so carrying it across an edit would label an
18
+ * entry as text it does not contain, and the next person tracing the log is misled by a record
19
+ * that looks authoritative. Comparing the text is what makes this total: it cannot be defeated
20
+ * by an edit-then-undo, and it needs no keystroke tracking in the component.
21
+ */
22
+ export function saveIntent(
23
+ draft: string,
24
+ detail: Pick<AgentPromptDetail, 'builtinText' | 'revisions'> | null,
25
+ restoredFrom: number | undefined,
26
+ ): { text: string | null; restoredFrom?: number } {
27
+ const text = draft.trim()
28
+ const builtin = (detail?.builtinText ?? '').trim()
29
+ const payload: { text: string | null } = { text: text === builtin ? null : text }
30
+ const source = detail?.revisions.find((r) => r.revision === restoredFrom)
31
+ if (!source) return payload
32
+ // A null-text revision restores the built-in, so its "text" for this comparison is that.
33
+ const sourceText = (source.text ?? detail?.builtinText ?? '').trim()
34
+ return sourceText === text ? { ...payload, restoredFrom } : payload
35
+ }
36
+
37
+ /**
38
+ * Whether the save button does anything. Trimmed on both sides because the payload is trimmed,
39
+ * so trailing whitespace alone is not an edit — a save button that enables on it appends a
40
+ * revision indistinguishable from its predecessor.
41
+ */
42
+ export function isDirty(draft: string, detail: Pick<AgentPromptDetail, 'effectiveText'> | null) {
43
+ return draft.trim() !== (detail?.effectiveText ?? '').trim()
44
+ }
45
+
46
+ /** The text `pick`ing a revision loads into the editor: its own, or the built-in for a revert. */
47
+ export function draftForRevision(
48
+ revision: AgentPromptRevision,
49
+ detail: Pick<AgentPromptDetail, 'builtinText'> | null,
50
+ ): string {
51
+ return revision.text ?? detail?.builtinText ?? ''
52
+ }
53
+
54
+ /** True when an error envelope is the append-only log's concurrent-editor refusal. */
55
+ export function isRevisionConflict(error: unknown): boolean {
56
+ return (
57
+ (error as { data?: { error?: { details?: { reason?: string } } } })?.data?.error?.details
58
+ ?.reason === 'prompt_revision_conflict'
59
+ )
60
+ }
@@ -0,0 +1,298 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import type { AgentPromptRevision } from '~/types/agent-prompts'
4
+ import { agentKindMeta } from '~/utils/catalog'
5
+ import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
6
+ import {
7
+ draftForRevision,
8
+ isDirty,
9
+ isRevisionConflict,
10
+ saveIntent,
11
+ } from '~/components/pipeline/AgentPromptEditor.logic'
12
+
13
+ // The per-workspace system-prompt editor for ONE agent kind, opened from the pipeline builder
14
+ // (where the kinds are actually chosen). It edits the SHIPPED track prompt only: the platform
15
+ // re-applies its own directives on top of whatever is saved here, so they cannot be deleted by
16
+ // accident — and it SHOWS that appended text (`detail.appendedText`, measured server-side from
17
+ // the real composition) rather than describing it, so what the editor promises can never drift
18
+ // from what the dispatch actually sends.
19
+ //
20
+ // History is the point, not a nicety: every save appends a revision and going back is another
21
+ // append, so nothing a user does here can lose the prompt their runs were on last week.
22
+ //
23
+ // The rules about WHAT to send live in ./AgentPromptEditor.logic.ts, unit-tested there: each has
24
+ // a wrong answer that is silently wrong (a mislabelled history entry, a revert stored as a copy)
25
+ // rather than visibly broken.
26
+
27
+ const props = defineProps<{ agentKind: string | null }>()
28
+ const emit = defineEmits<{ close: [] }>()
29
+
30
+ const { t } = useI18n()
31
+ const toast = useToast()
32
+ const prompts = useAgentPromptsStore()
33
+
34
+ const open = computed({
35
+ get: () => props.agentKind !== null,
36
+ set: (v: boolean) => {
37
+ if (!v) emit('close')
38
+ },
39
+ })
40
+
41
+ /** The textarea's working copy. Seeded from the effective prompt each time the editor opens. */
42
+ const draft = ref('')
43
+ /**
44
+ * The revision the working copy was lifted out of, when the user picked one from the history.
45
+ * Only a CANDIDATE: `saveIntent` drops it unless the draft still matches that revision's text,
46
+ * so editing after a restore cannot mislabel the entry the next reader tries to trace.
47
+ */
48
+ const restoredFrom = ref<number | undefined>(undefined)
49
+ /** Whether the built-in is shown beside the editor for comparison. */
50
+ const showBuiltin = ref(false)
51
+ /** Whether the non-editable text the platform appends is expanded. */
52
+ const showDirectives = ref(false)
53
+
54
+ watch(
55
+ () => props.agentKind,
56
+ async (kind) => {
57
+ if (!kind) {
58
+ prompts.reset()
59
+ return
60
+ }
61
+ showBuiltin.value = false
62
+ showDirectives.value = false
63
+ restoredFrom.value = undefined
64
+ draft.value = ''
65
+ try {
66
+ const detail = await prompts.load(kind)
67
+ draft.value = detail?.effectiveText ?? ''
68
+ } catch {
69
+ toast.add({ title: t('agentPrompt.toast.loadFailed'), color: 'error' })
70
+ emit('close')
71
+ }
72
+ },
73
+ { immediate: true },
74
+ )
75
+
76
+ const detail = computed(() => prompts.detail)
77
+ const label = computed(() => (props.agentKind ? agentKindMeta(props.agentKind).label : ''))
78
+
79
+ /** The live prompt, so "no change" can be reported instead of appending an identical revision. */
80
+ const dirty = computed(() => isDirty(draft.value, detail.value))
81
+ /** Nothing to revert to when the workspace is already running the shipped prompt. */
82
+ const canRevert = computed(() => detail.value?.customized === true)
83
+ /** What the platform appends to whatever is saved. Empty ⇒ the panel is not offered at all. */
84
+ const directives = computed(() => detail.value?.appendedText ?? '')
85
+
86
+ function pick(revision: AgentPromptRevision) {
87
+ draft.value = draftForRevision(revision, detail.value)
88
+ restoredFrom.value = revision.revision
89
+ }
90
+
91
+ function useBuiltin() {
92
+ draft.value = detail.value?.builtinText ?? ''
93
+ restoredFrom.value = undefined
94
+ }
95
+
96
+ async function save() {
97
+ const kind = props.agentKind
98
+ if (!kind) return
99
+ const intent = saveIntent(draft.value, detail.value, restoredFrom.value)
100
+ try {
101
+ const saved = await prompts.save(kind, intent.text, intent.restoredFrom)
102
+ draft.value = saved?.effectiveText ?? draft.value
103
+ restoredFrom.value = undefined
104
+ toast.add({ title: t('agentPrompt.toast.saved'), color: 'success', icon: 'i-lucide-check' })
105
+ } catch (error) {
106
+ const conflict = isRevisionConflict(error)
107
+ toast.add({
108
+ title: conflict ? t('agentPrompt.toast.conflict') : t('agentPrompt.toast.saveFailed'),
109
+ color: 'error',
110
+ })
111
+ // The server's view already replaced the store's on a conflict, so re-seed the textarea
112
+ // from what actually landed rather than leaving the user editing a lost revision — and drop
113
+ // the restore candidate with it, since it names a revision from the log we just replaced.
114
+ if (conflict) {
115
+ draft.value = prompts.detail?.effectiveText ?? draft.value
116
+ restoredFrom.value = undefined
117
+ }
118
+ }
119
+ }
120
+
121
+ async function revert() {
122
+ const kind = props.agentKind
123
+ if (!kind) return
124
+ try {
125
+ const saved = await prompts.save(kind, null)
126
+ draft.value = saved?.effectiveText ?? draft.value
127
+ restoredFrom.value = undefined
128
+ toast.add({ title: t('agentPrompt.toast.reverted'), color: 'success' })
129
+ } catch {
130
+ toast.add({ title: t('agentPrompt.toast.saveFailed'), color: 'error' })
131
+ }
132
+ }
133
+
134
+ const { d } = useI18n()
135
+ function revisionLabel(revision: AgentPromptRevision): string {
136
+ return revision.text === null
137
+ ? t('agentPrompt.revision.builtin', { n: revision.revision })
138
+ : revision.restoredFrom !== undefined
139
+ ? t('agentPrompt.revision.restored', { n: revision.revision, from: revision.restoredFrom })
140
+ : t('agentPrompt.revision.edit', { n: revision.revision })
141
+ }
142
+ </script>
143
+
144
+ <template>
145
+ <UModal
146
+ v-model:open="open"
147
+ :title="t('agentPrompt.title', { agent: label })"
148
+ :description="t('agentPrompt.description')"
149
+ :ui="{ content: 'max-w-[92vw] sm:max-w-3xl lg:max-w-5xl' }"
150
+ >
151
+ <template #body>
152
+ <div v-if="prompts.loadingDetail" class="py-8 text-center text-sm text-slate-400">
153
+ {{ t('common.loading') }}
154
+ </div>
155
+ <div v-else-if="detail" class="flex flex-col gap-3">
156
+ <div class="flex flex-wrap items-center gap-2 text-xs">
157
+ <AgentKindIcon v-if="agentKind" :kind="agentKind" icon-class="h-4 w-4" />
158
+ <span class="font-medium text-slate-200">{{ label }}</span>
159
+ <UBadge v-if="detail.builtinVersionLabel" color="neutral" variant="subtle" size="sm">
160
+ {{ detail.builtinVersionLabel }}
161
+ </UBadge>
162
+ <UBadge :color="detail.customized ? 'warning' : 'neutral'" variant="subtle" size="sm">
163
+ {{ detail.customized ? t('agentPrompt.customized') : t('agentPrompt.usingBuiltin') }}
164
+ </UBadge>
165
+ </div>
166
+
167
+ <!-- What the platform appends is SHOWN, not described. A prose summary of it is copy
168
+ that silently goes stale the moment a directive is added, and a user who does not
169
+ know what is already there writes a prompt that fights it. -->
170
+ <p v-if="directives" class="text-[11px] leading-relaxed text-slate-500">
171
+ {{ t('agentPrompt.managedNotice') }}
172
+ <UButton
173
+ variant="link"
174
+ size="xs"
175
+ class="px-1 align-baseline"
176
+ @click="showDirectives = !showDirectives"
177
+ >
178
+ {{ showDirectives ? t('agentPrompt.hideAppended') : t('agentPrompt.showAppended') }}
179
+ </UButton>
180
+ </p>
181
+ <div
182
+ v-if="directives && showDirectives"
183
+ class="rounded-md border border-slate-800 bg-slate-950/60 p-2"
184
+ >
185
+ <h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
186
+ {{ t('agentPrompt.appendedHeading') }}
187
+ </h4>
188
+ <pre
189
+ class="max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] text-slate-300"
190
+ >{{ directives.trim() }}</pre>
191
+ </div>
192
+
193
+ <UTextarea
194
+ v-model="draft"
195
+ :rows="16"
196
+ autoresize
197
+ :maxrows="24"
198
+ class="font-mono"
199
+ :placeholder="t('agentPrompt.placeholder')"
200
+ />
201
+
202
+ <div class="flex flex-wrap items-center gap-2">
203
+ <UButton
204
+ color="primary"
205
+ size="sm"
206
+ icon="i-lucide-save"
207
+ :loading="prompts.saving"
208
+ :disabled="!dirty || !draft.trim()"
209
+ @click="save"
210
+ >
211
+ {{ t('agentPrompt.save') }}
212
+ </UButton>
213
+ <UButton
214
+ color="neutral"
215
+ variant="soft"
216
+ size="sm"
217
+ icon="i-lucide-rotate-ccw"
218
+ :disabled="prompts.saving || !canRevert"
219
+ @click="revert"
220
+ >
221
+ {{ t('agentPrompt.revert') }}
222
+ </UButton>
223
+ <UButton
224
+ color="neutral"
225
+ variant="ghost"
226
+ size="sm"
227
+ icon="i-lucide-file-text"
228
+ @click="useBuiltin"
229
+ >
230
+ {{ t('agentPrompt.loadBuiltin') }}
231
+ </UButton>
232
+ <UButton
233
+ color="neutral"
234
+ variant="ghost"
235
+ size="sm"
236
+ :icon="showBuiltin ? 'i-lucide-eye-off' : 'i-lucide-eye'"
237
+ @click="showBuiltin = !showBuiltin"
238
+ >
239
+ {{ showBuiltin ? t('agentPrompt.hideBuiltin') : t('agentPrompt.showBuiltin') }}
240
+ </UButton>
241
+ <span v-if="restoredFrom !== undefined" class="text-[11px] text-slate-400">
242
+ {{ t('agentPrompt.restoringFrom', { n: restoredFrom }) }}
243
+ </span>
244
+ </div>
245
+
246
+ <div v-if="showBuiltin" class="rounded-md border border-slate-800 bg-slate-950/60 p-2">
247
+ <h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
248
+ {{ t('agentPrompt.builtinHeading') }}
249
+ </h4>
250
+ <pre
251
+ class="max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] text-slate-300"
252
+ >{{ detail.builtinText }}</pre>
253
+ </div>
254
+
255
+ <div>
256
+ <h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
257
+ {{ t('agentPrompt.historyHeading') }}
258
+ </h4>
259
+ <p v-if="!detail.revisions.length" class="text-[11px] text-slate-500">
260
+ {{ t('agentPrompt.historyEmpty') }}
261
+ </p>
262
+ <ul v-else class="max-h-52 divide-y divide-slate-800 overflow-y-auto text-xs">
263
+ <li
264
+ v-for="revision in detail.revisions"
265
+ :key="revision.revision"
266
+ class="flex items-center gap-2 py-1.5"
267
+ >
268
+ <UBadge
269
+ v-if="revision.revision === detail.revisions[0]?.revision"
270
+ color="primary"
271
+ variant="subtle"
272
+ size="sm"
273
+ >
274
+ {{ t('agentPrompt.live') }}
275
+ </UBadge>
276
+ <span class="min-w-0 flex-1 truncate text-slate-300">
277
+ {{ revisionLabel(revision) }}
278
+ </span>
279
+ <span class="shrink-0 text-[11px] text-slate-500">
280
+ {{ d(new Date(revision.createdAt), 'short') }}
281
+ </span>
282
+ <UButton
283
+ color="neutral"
284
+ variant="ghost"
285
+ size="xs"
286
+ icon="i-lucide-history"
287
+ :title="t('agentPrompt.restoreTooltip')"
288
+ @click="pick(revision)"
289
+ >
290
+ {{ t('agentPrompt.restore') }}
291
+ </UButton>
292
+ </li>
293
+ </ul>
294
+ </div>
295
+ </div>
296
+ </template>
297
+ </UModal>
298
+ </template>
@@ -4,6 +4,8 @@ import { purposeAllowsAgentCategory } from '@cat-factory/contracts'
4
4
  import type { AgentKind, Pipeline, PipelinePurpose } from '~/types/domain'
5
5
  import AgentPalette from '~/components/palettes/AgentPalette.vue'
6
6
  import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
7
+ import AgentPromptEditor from '~/components/pipeline/AgentPromptEditor.vue'
8
+ import { showOverrideField } from '~/utils/uiMode'
7
9
  import {
8
10
  agentKindMeta,
9
11
  companionForProducer,
@@ -54,6 +56,24 @@ function toggleGating(i: number) {
54
56
  }
55
57
  const agents = useAgentsStore()
56
58
  const ui = useUiStore()
59
+ const uiMode = useUiModeStore()
60
+ const agentPrompts = useAgentPromptsStore()
61
+
62
+ // The agent kind whose system prompt is open in the editor (null = closed). Per-KIND, not
63
+ // per-step: an override applies to every run of that agent in the workspace, so two steps of
64
+ // the same kind are two views of one prompt.
65
+ const promptEditorKind = ref<AgentKind | null>(null)
66
+
67
+ /**
68
+ * Whether the "edit this agent's prompt" affordance shows for a kind. Editing a prompt is an
69
+ * OVERRIDE of what the product ships, so it follows the override rule: hidden in basic mode
70
+ * while the kind is running the shipped prompt, and revealed as soon as the workspace actually
71
+ * carries an override — otherwise a basic-mode user would be running on an edited prompt they
72
+ * can neither see nor clear.
73
+ */
74
+ function showPromptEditor(kind: AgentKind): boolean {
75
+ return showOverrideField(uiMode.isAdvanced, agentPrompts.isCustomized(kind) || null)
76
+ }
57
77
  const releaseHealth = useReleaseHealthStore()
58
78
  const skills = useSkillsStore()
59
79
 
@@ -99,6 +119,10 @@ const open = computed({
99
119
  // the snapshot). Best-effort: a failure just leaves the gate hidden.
100
120
  watch(open, (isOpen) => {
101
121
  if (isOpen) releaseHealth.load().catch(() => {})
122
+ // The prompt-override index badges the steps whose agent no longer runs the shipped prompt.
123
+ // Best-effort: the builder is fully usable without it, and a deployment that wires no
124
+ // override store answers 503 here.
125
+ if (isOpen) agentPrompts.loadIndex().catch(() => {})
102
126
  })
103
127
 
104
128
  function add(kind: AgentKind) {
@@ -578,6 +602,21 @@ async function clone(p: Pipeline) {
578
602
  "
579
603
  @click="pipelines.toggleDraftAutoRecommend(unit.index)"
580
604
  />
605
+ <!-- System prompt: replace what this agent kind ships with, for every run in
606
+ this workspace, with the full revision history to switch back through. -->
607
+ <UButton
608
+ v-if="showPromptEditor(unit.kind)"
609
+ icon="i-lucide-file-pen-line"
610
+ :color="agentPrompts.isCustomized(unit.kind) ? 'warning' : 'neutral'"
611
+ variant="ghost"
612
+ size="xs"
613
+ :title="
614
+ agentPrompts.isCustomized(unit.kind)
615
+ ? t('pipeline.builder.promptEditedTooltip')
616
+ : t('pipeline.builder.promptEditTooltip')
617
+ "
618
+ @click="promptEditorKind = unit.kind"
619
+ />
581
620
  <UButton
582
621
  icon="i-lucide-chevron-up"
583
622
  color="neutral"
@@ -642,6 +681,25 @@ async function clone(p: Pipeline) {
642
681
  <span class="min-w-0 flex-1 truncate text-slate-200">
643
682
  {{ agentKindMeta(pipelines.draft[unit.companionIndex]!).label }}
644
683
  </span>
684
+ <!-- A companion is an agent kind with a prompt of its own, and this row is its
685
+ only route to it — so the affordance belongs here too, not only on producers. -->
686
+ <UButton
687
+ v-if="showPromptEditor(pipelines.draft[unit.companionIndex]!)"
688
+ icon="i-lucide-file-pen-line"
689
+ :color="
690
+ agentPrompts.isCustomized(pipelines.draft[unit.companionIndex]!)
691
+ ? 'warning'
692
+ : 'neutral'
693
+ "
694
+ variant="ghost"
695
+ size="xs"
696
+ :title="
697
+ agentPrompts.isCustomized(pipelines.draft[unit.companionIndex]!)
698
+ ? t('pipeline.builder.promptEditedTooltip')
699
+ : t('pipeline.builder.promptEditTooltip')
700
+ "
701
+ @click="promptEditorKind = pipelines.draft[unit.companionIndex]!"
702
+ />
645
703
  <UButton
646
704
  :icon="
647
705
  pipelines.draftGating[unit.companionIndex]?.enabled
@@ -1147,4 +1205,8 @@ async function clone(p: Pipeline) {
1147
1205
  </div>
1148
1206
  </template>
1149
1207
  </UModal>
1208
+
1209
+ <!-- The per-workspace system-prompt editor for one agent kind. Mounted alongside the builder
1210
+ (not inside its slideover body) so its own modal isn't nested inside the scrolling column. -->
1211
+ <AgentPromptEditor :agent-kind="promptEditorKind" @close="promptEditorKind = null" />
1150
1212
  </template>