@cat-factory/app 0.183.0 → 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.
@@ -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>
@@ -10,6 +10,7 @@ import type {
10
10
  SandboxExperimentStatus,
11
11
  SandboxFixtureKind,
12
12
  SandboxGrade,
13
+ SandboxPromptOrigin,
13
14
  SandboxPromptVersion,
14
15
  SandboxRun,
15
16
  } from '~/types/sandbox'
@@ -35,6 +36,47 @@ const FIXTURE_KIND_LABEL = computed<Record<SandboxFixtureKind, string>>(() => ({
35
36
  'repo-feature': t('sandbox.fixtureKind.repo-feature'),
36
37
  'repo-bug': t('sandbox.fixtureKind.repo-bug'),
37
38
  }))
39
+ /**
40
+ * Badge colour per prompt origin. An exhaustive Record over the closed union rather than a
41
+ * ternary, so adding an origin fails the typecheck here instead of silently rendering as
42
+ * "candidate" — the drift guard the i18n conventions ask for on enum-keyed lookups.
43
+ */
44
+ const PROMPT_ORIGIN_COLOR: Record<SandboxPromptOrigin, 'neutral' | 'primary' | 'warning'> = {
45
+ baseline: 'neutral',
46
+ candidate: 'primary',
47
+ workspace: 'warning',
48
+ }
49
+
50
+ /** Which version is mid-promotion, so only its button spins. */
51
+ const promoting = ref<string | null>(null)
52
+
53
+ /**
54
+ * Promotion is offered on anything that is not already what the workspace runs: a graded
55
+ * candidate (the point of the tool) and an older workspace revision (rolling back). Not on the
56
+ * live row, where it is a no-op the backend would swallow anyway — and not on a shipped baseline,
57
+ * since "run what the product ships" is the revert in the prompt editor, not a promotion that
58
+ * would pin today's wording as a stored override.
59
+ */
60
+ function canPromote(version: SandboxPromptVersion): boolean {
61
+ return version.origin !== 'baseline' && version.live !== true
62
+ }
63
+
64
+ async function promote(version: SandboxPromptVersion) {
65
+ promoting.value = version.id
66
+ try {
67
+ await store.promotePrompt(version)
68
+ toast.add({
69
+ title: t('sandbox.prompts.promoted', { agent: version.agentKind }),
70
+ color: 'success',
71
+ icon: 'i-lucide-rocket',
72
+ })
73
+ } catch {
74
+ toast.add({ title: t('sandbox.prompts.promoteFailed'), color: 'error' })
75
+ } finally {
76
+ promoting.value = null
77
+ }
78
+ }
79
+
38
80
  const FIXTURE_ORIGIN_LABEL = computed<Record<'builtin' | 'custom', string>>(() => ({
39
81
  builtin: t('sandbox.fixtureOrigin.builtin'),
40
82
  custom: t('sandbox.fixtureOrigin.custom'),
@@ -508,17 +550,20 @@ async function archive(prompt: SandboxPromptVersion) {
508
550
  <div class="min-w-0">
509
551
  <div class="flex items-center gap-2">
510
552
  <span class="truncate text-slate-200">{{ p.name }}</span>
511
- <UBadge
512
- :color="p.origin === 'baseline' ? 'neutral' : 'primary'"
513
- variant="soft"
514
- size="xs"
515
- >
553
+ <UBadge :color="PROMPT_ORIGIN_COLOR[p.origin]" variant="soft" size="xs">
516
554
  {{
517
555
  p.origin === 'baseline'
518
556
  ? t('sandbox.baseline')
519
557
  : t('sandbox.versionLabel', { version: p.version })
520
558
  }}
521
559
  </UBadge>
560
+ <UBadge v-if="p.origin === 'workspace'" color="warning" variant="soft" size="xs">
561
+ {{
562
+ p.live
563
+ ? t('sandbox.prompts.liveInWorkspace')
564
+ : t('sandbox.prompts.fromWorkspace')
565
+ }}
566
+ </UBadge>
522
567
  </div>
523
568
  <span class="text-[11px] text-slate-500">{{ p.agentKind }}</span>
524
569
  </div>
@@ -535,6 +580,19 @@ async function archive(prompt: SandboxPromptVersion) {
535
580
  "
536
581
  @click="edit(p)"
537
582
  />
583
+ <!-- Deploy: make this version the workspace's live prompt for its agent kind.
584
+ Offered on a graded candidate and on an older workspace revision (rolling
585
+ back), but not on the one already live, where it would be a no-op. -->
586
+ <UButton
587
+ v-if="canPromote(p)"
588
+ icon="i-lucide-rocket"
589
+ color="primary"
590
+ variant="ghost"
591
+ size="xs"
592
+ :loading="promoting === p.id"
593
+ :title="t('sandbox.prompts.promoteTitle')"
594
+ @click="promote(p)"
595
+ />
538
596
  <UButton
539
597
  v-if="p.origin === 'candidate'"
540
598
  icon="i-lucide-archive"