@cat-factory/app 0.8.0 → 0.9.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.
@@ -18,6 +18,7 @@ const board = useBoardStore()
18
18
  const documents = useDocumentsStore()
19
19
  const tasks = useTasksStore()
20
20
  const mergePresets = useMergePresetsStore()
21
+ const modelPresets = useModelPresetsStore()
21
22
  const pipelines = usePipelinesStore()
22
23
  const agentConfig = useAgentConfigStore()
23
24
  const toast = useToast()
@@ -94,6 +95,7 @@ const recurringFrameId = computed(() => {
94
95
  // Run configuration picked up front. Empty string = use the default (workspace
95
96
  // default merge preset / no pinned pipeline).
96
97
  const mergePresetId = ref('')
98
+ const modelPresetId = ref('')
97
99
  const pipelineId = ref('')
98
100
 
99
101
  const presetMenu = computed(() => [
@@ -121,6 +123,32 @@ const selectedPresetLabel = computed(() => {
121
123
  return mergePresets.presets.find((p) => p.id === mergePresetId.value)?.name ?? 'Workspace default'
122
124
  })
123
125
 
126
+ // Model preset: which model each agent runs on. Empty = workspace default preset.
127
+ const modelPresetMenu = computed(() => [
128
+ [
129
+ {
130
+ label: modelPresets.defaultPreset
131
+ ? `Default (${modelPresets.defaultPreset.name})`
132
+ : 'Workspace default',
133
+ icon: 'i-lucide-rotate-ccw',
134
+ onSelect: () => (modelPresetId.value = ''),
135
+ },
136
+ ...modelPresets.presets.map((p) => ({
137
+ label: p.name,
138
+ icon: 'i-lucide-cpu',
139
+ onSelect: () => (modelPresetId.value = p.id),
140
+ })),
141
+ ],
142
+ ])
143
+ const selectedModelPresetLabel = computed(() => {
144
+ if (!modelPresetId.value) {
145
+ return modelPresets.defaultPreset
146
+ ? `Default (${modelPresets.defaultPreset.name})`
147
+ : 'Workspace default'
148
+ }
149
+ return modelPresets.presets.find((p) => p.id === modelPresetId.value)?.name ?? 'Workspace default'
150
+ })
151
+
124
152
  const pipelineMenu = computed(() => [
125
153
  [
126
154
  {
@@ -172,6 +200,7 @@ watch(open, (isOpen) => {
172
200
  timeboxHours.value = undefined
173
201
  docKind.value = ''
174
202
  mergePresetId.value = ''
203
+ modelPresetId.value = ''
175
204
  pipelineId.value = ''
176
205
  agentConfigValues.value = {}
177
206
  pendingContext.value = []
@@ -208,6 +237,7 @@ async function add() {
208
237
  taskType: taskType.value as CreateTaskType,
209
238
  ...(typeFields ? { taskTypeFields: typeFields } : {}),
210
239
  ...(mergePresetId.value ? { mergePresetId: mergePresetId.value } : {}),
240
+ ...(modelPresetId.value ? { modelPresetId: modelPresetId.value } : {}),
211
241
  ...(pipelineId.value ? { pipelineId: pipelineId.value } : {}),
212
242
  ...(Object.keys(agentConfigValues.value).length
213
243
  ? { agentConfig: agentConfigValues.value }
@@ -381,6 +411,21 @@ async function add() {
381
411
  </UButton>
382
412
  </UDropdownMenu>
383
413
  </UFormField>
414
+
415
+ <UFormField label="Model preset">
416
+ <UDropdownMenu :items="modelPresetMenu" class="w-full">
417
+ <UButton
418
+ color="neutral"
419
+ variant="subtle"
420
+ size="sm"
421
+ icon="i-lucide-cpu"
422
+ trailing-icon="i-lucide-chevron-down"
423
+ class="w-full justify-between"
424
+ >
425
+ {{ selectedModelPresetLabel }}
426
+ </UButton>
427
+ </UDropdownMenu>
428
+ </UFormField>
384
429
  </div>
385
430
 
386
431
  <div v-if="configDescriptors.length" class="space-y-3">
@@ -184,12 +184,12 @@ const commands = computed<Command[]>(() => {
184
184
  run: () => ui.openWorkspaceSettings(),
185
185
  })
186
186
  list.push({
187
- id: 'model-defaults',
188
- label: 'Default models for agents',
187
+ id: 'model-configuration',
188
+ label: 'Model Configuration',
189
189
  group: 'Workspace',
190
190
  icon: 'i-lucide-cpu',
191
- keywords: 'model llm routing agent kind default',
192
- run: () => ui.openModelDefaults(),
191
+ keywords: 'model llm routing agent kind default preset configuration',
192
+ run: () => ui.openModelConfig(),
193
193
  })
194
194
  list.push({
195
195
  id: 'service-fragment-defaults',
@@ -291,9 +291,9 @@ watch(
291
291
  size="sm"
292
292
  icon="i-lucide-cpu"
293
293
  class="justify-start"
294
- @click="ui.openModelDefaults()"
294
+ @click="ui.openModelConfig()"
295
295
  >
296
- Default models
296
+ Model Configuration
297
297
  </UButton>
298
298
  <UButton
299
299
  block
@@ -7,6 +7,7 @@ const props = defineProps<{ block: Block }>()
7
7
 
8
8
  const board = useBoardStore()
9
9
  const mergePresets = useMergePresetsStore()
10
+ const modelPresets = useModelPresetsStore()
10
11
  const pipelines = usePipelinesStore()
11
12
  const accounts = useAccountsStore()
12
13
  const tracker = useTrackerStore()
@@ -66,6 +67,32 @@ function setPreset(id: string) {
66
67
  board.updateBlock(props.block.id, { mergePresetId: id })
67
68
  }
68
69
 
70
+ // ---- model preset ----------------------------------------------------------
71
+ // Which model preset decides the model each agent step runs on. None selected → the
72
+ // workspace default preset. Changing it affects only steps that haven't started yet
73
+ // (a running step keeps the model it was dispatched with). A model pinned directly on
74
+ // the task still overrides the preset.
75
+ const selectedModelPreset = computed(() => modelPresets.resolve(props.block.modelPresetId))
76
+ const modelPresetMenu = computed(() => [
77
+ [
78
+ {
79
+ label: modelPresets.defaultPreset
80
+ ? `Default (${modelPresets.defaultPreset.name})`
81
+ : 'Workspace default',
82
+ icon: 'i-lucide-rotate-ccw',
83
+ onSelect: () => setModelPreset(''),
84
+ },
85
+ ...modelPresets.presets.map((p) => ({
86
+ label: p.name,
87
+ icon: 'i-lucide-cpu',
88
+ onSelect: () => setModelPreset(p.id),
89
+ })),
90
+ ],
91
+ ])
92
+ function setModelPreset(id: string) {
93
+ board.updateBlock(props.block.id, { modelPresetId: id })
94
+ }
95
+
69
96
  // ---- pipeline --------------------------------------------------------------
70
97
  // The pipeline this task's Run controls default to. None selected → the user picks
71
98
  // at run time (the board falls back to the first defined pipeline).
@@ -189,6 +216,38 @@ const resolveOnMergeLabel = computed(() =>
189
216
  </div>
190
217
  </div>
191
218
 
219
+ <!-- model preset -->
220
+ <div>
221
+ <div class="mb-1 flex items-center justify-between">
222
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
223
+ Model preset
224
+ </span>
225
+ <UDropdownMenu :items="modelPresetMenu">
226
+ <UButton
227
+ size="xs"
228
+ variant="ghost"
229
+ color="neutral"
230
+ icon="i-lucide-cpu"
231
+ trailing-icon="i-lucide-chevron-down"
232
+ />
233
+ </UDropdownMenu>
234
+ </div>
235
+ <div v-if="selectedModelPreset" class="text-[11px] text-slate-400">
236
+ <span class="text-slate-300">{{ selectedModelPreset.name }}</span>
237
+ — base {{ selectedModelPreset.baseModelId
238
+ }}<span v-if="Object.keys(selectedModelPreset.overrides).length">
239
+ , {{ Object.keys(selectedModelPreset.overrides).length }} override(s)</span
240
+ >.
241
+ <span v-if="!block.modelPresetId" class="text-slate-500">(workspace default)</span>
242
+ </div>
243
+ <div v-else class="text-[11px] text-slate-500">
244
+ No preset configured — agents run on the deployment's default routing.
245
+ </div>
246
+ <p class="mt-1 text-[11px] text-slate-500">
247
+ Changing this affects only steps that haven't started yet.
248
+ </p>
249
+ </div>
250
+
192
251
  <!-- issue-tracker writeback overrides -->
193
252
  <div>
194
253
  <div class="mb-1 flex items-center justify-between">
@@ -234,8 +234,8 @@ async function clone(p: Pipeline) {
234
234
  variant="soft"
235
235
  size="xs"
236
236
  icon="i-lucide-cpu"
237
- title="Pick which model each agent kind runs on"
238
- @click="ui.openModelDefaults()"
237
+ title="Manage model presets (which model each agent runs on)"
238
+ @click="ui.openModelConfig()"
239
239
  >
240
240
  Configure models
241
241
  </UButton>
@@ -0,0 +1,487 @@
1
+ <script setup lang="ts">
2
+ // Workspace settings: the Model Configuration screen. A workspace keeps a library of
3
+ // MODEL PRESETS — each a named base model applied to every agent kind plus optional
4
+ // per-agent overrides. A task picks a preset (or the workspace default); the chosen
5
+ // preset decides which model each pipeline step runs on, unless the task pins a model
6
+ // directly. The built-in default ("Kimi K2.7") points every agent at Kimi K2.7; a
7
+ // second built-in points everything at GLM-5.2.
8
+ //
9
+ // Styled as a dark full-screen window like the agent-output review overlay rather than
10
+ // a light modal, so the text stays readable regardless of the OS colour-mode
11
+ // preference. The list view shows each preset; the editor view creates/edits one with a
12
+ // base-model picker plus a filterable per-agent override list.
13
+ import { computed, ref, watch } from 'vue'
14
+ import { onKeyStroke } from '@vueuse/core'
15
+ import type { AgentKind } from '~/types/domain'
16
+ import type { ModelPreset } from '~/types/model-presets'
17
+ import { MODEL_CONFIGURABLE_SYSTEM_KINDS } from '~/utils/catalog'
18
+ import { contextLabel, costLabel, displayFlavor, isSelectable } from '~/stores/models'
19
+
20
+ const ui = useUiStore()
21
+ const models = useModelsStore()
22
+ const presets = useModelPresetsStore()
23
+ const agents = useAgentsStore()
24
+ const creds = useVendorCredentialsStore()
25
+ const workspace = useWorkspaceStore()
26
+ const toast = useToast()
27
+
28
+ const open = computed({
29
+ get: () => ui.modelConfigOpen,
30
+ set: (v: boolean) => (v ? ui.openModelConfig() : ui.closeModelConfig()),
31
+ })
32
+
33
+ // null = the preset list; an object = the create/edit form for one preset.
34
+ interface EditorState {
35
+ id?: string
36
+ name: string
37
+ baseModelId: string
38
+ overrides: Record<string, string>
39
+ isDefault: boolean
40
+ }
41
+ const editor = ref<EditorState | null>(null)
42
+ const busy = ref(false)
43
+ // Narrows the agent-kind override rows, for finding a kind fast in a long catalog.
44
+ const filter = ref('')
45
+
46
+ // The palette archetypes PLUS the engine-driven kinds that still run an LLM
47
+ // (spec-writer, merger, the fixers/resolver). The pure gates run no model, so they
48
+ // stay out — exactly the set the per-agent override list should cover.
49
+ const configurableKinds = computed(() => [...agents.archetypes, ...MODEL_CONFIGURABLE_SYSTEM_KINDS])
50
+ const filteredKinds = computed(() => {
51
+ const q = filter.value.trim().toLowerCase()
52
+ if (!q) return configurableKinds.value
53
+ return configurableKinds.value.filter(
54
+ (a) => a.label.toLowerCase().includes(q) || String(a.kind).toLowerCase().includes(q),
55
+ )
56
+ })
57
+
58
+ watch(open, (isOpen) => {
59
+ if (isOpen) {
60
+ editor.value = null
61
+ filter.value = ''
62
+ void models.ensureLoaded(workspace.workspaceId ?? undefined)
63
+ if (workspace.workspaceId) void creds.load(workspace.workspaceId)
64
+ }
65
+ })
66
+
67
+ onKeyStroke('Escape', () => {
68
+ if (!open.value) return
69
+ // Esc backs out of the editor first, then closes the panel.
70
+ if (editor.value) editor.value = null
71
+ else open.value = false
72
+ })
73
+
74
+ /** The selectable catalog models, as `{ id, label, suffix }` for a dropdown. */
75
+ const selectableModels = computed(() => {
76
+ const configured = creds.configuredVendors
77
+ return models.models
78
+ .filter((m) => isSelectable(m, configured))
79
+ .map((m) => {
80
+ const flavor = displayFlavor(m, configured)
81
+ const ctx = contextLabel(flavor.contextTokens)
82
+ const price = costLabel(flavor) ?? (flavor.quotaBased ? 'quota' : undefined)
83
+ const suffix = [flavor.providerLabel, ctx, price].filter(Boolean).join(' · ')
84
+ return {
85
+ id: m.id,
86
+ label: m.label,
87
+ suffix,
88
+ icon: flavor.quotaBased ? 'i-lucide-infinity' : 'i-lucide-cpu',
89
+ }
90
+ })
91
+ })
92
+
93
+ /** A readable label for a model id (catalog label + provider, else the raw id). */
94
+ function modelLabel(id: string | undefined): string {
95
+ if (!id) return '—'
96
+ const m = models.getModel(id)
97
+ if (!m) return id
98
+ return `${m.label} · ${displayFlavor(m, creds.configuredVendors).providerLabel}`
99
+ }
100
+
101
+ // ---- preset list -----------------------------------------------------------
102
+ const sortedPresets = computed(() => [...presets.presets].sort((a, b) => a.createdAt - b.createdAt))
103
+
104
+ function startCreate() {
105
+ editor.value = {
106
+ name: '',
107
+ baseModelId: selectableModels.value[0]?.id ?? 'kimi-k2.7',
108
+ overrides: {},
109
+ isDefault: false,
110
+ }
111
+ filter.value = ''
112
+ }
113
+ function startEdit(p: ModelPreset) {
114
+ editor.value = {
115
+ id: p.id,
116
+ name: p.name,
117
+ baseModelId: p.baseModelId,
118
+ overrides: { ...p.overrides },
119
+ isDefault: p.isDefault,
120
+ }
121
+ filter.value = ''
122
+ }
123
+
124
+ async function setDefault(p: ModelPreset) {
125
+ if (p.isDefault) return
126
+ busy.value = true
127
+ try {
128
+ await presets.update(p.id, { isDefault: true })
129
+ } catch (e) {
130
+ fail('Could not set the default preset', e)
131
+ } finally {
132
+ busy.value = false
133
+ }
134
+ }
135
+
136
+ async function remove(p: ModelPreset) {
137
+ busy.value = true
138
+ try {
139
+ await presets.remove(p.id)
140
+ } catch (e) {
141
+ fail('Could not delete the preset', e)
142
+ } finally {
143
+ busy.value = false
144
+ }
145
+ }
146
+
147
+ // ---- editor ----------------------------------------------------------------
148
+ /** The base-model dropdown items. */
149
+ const baseMenu = computed(() => [
150
+ selectableModels.value.map((m) => ({
151
+ label: `${m.label} · ${m.suffix}`,
152
+ icon: m.icon,
153
+ onSelect: () => {
154
+ if (editor.value) editor.value.baseModelId = m.id
155
+ },
156
+ })),
157
+ ])
158
+
159
+ /** A per-kind override dropdown: "Use base model" reset plus the catalog. */
160
+ function overrideMenu(kind: AgentKind) {
161
+ return [
162
+ [
163
+ {
164
+ label: 'Use base model',
165
+ icon: 'i-lucide-rotate-ccw',
166
+ onSelect: () => setOverride(kind, null),
167
+ },
168
+ ...selectableModels.value.map((m) => ({
169
+ label: `${m.label} · ${m.suffix}`,
170
+ icon: m.icon,
171
+ onSelect: () => setOverride(kind, m.id),
172
+ })),
173
+ ],
174
+ ]
175
+ }
176
+ function setOverride(kind: AgentKind, modelId: string | null) {
177
+ if (!editor.value) return
178
+ const next = { ...editor.value.overrides }
179
+ if (modelId) next[kind] = modelId
180
+ else delete next[kind]
181
+ editor.value.overrides = next
182
+ }
183
+ /** The label on a kind's override button: its override model, else "Base model". */
184
+ function overrideLabel(kind: AgentKind): string {
185
+ const id = editor.value?.overrides[kind]
186
+ return id ? modelLabel(id) : 'Base model'
187
+ }
188
+
189
+ async function save() {
190
+ const e = editor.value
191
+ if (!e) return
192
+ if (!e.name.trim()) {
193
+ fail('Name required', new Error('Give the preset a name.'))
194
+ return
195
+ }
196
+ busy.value = true
197
+ try {
198
+ if (e.id) {
199
+ await presets.update(e.id, {
200
+ name: e.name.trim(),
201
+ baseModelId: e.baseModelId,
202
+ overrides: e.overrides,
203
+ isDefault: e.isDefault,
204
+ })
205
+ } else {
206
+ await presets.create({
207
+ name: e.name.trim(),
208
+ baseModelId: e.baseModelId,
209
+ overrides: e.overrides,
210
+ isDefault: e.isDefault,
211
+ })
212
+ }
213
+ editor.value = null
214
+ } catch (err) {
215
+ fail('Could not save the preset', err)
216
+ } finally {
217
+ busy.value = false
218
+ }
219
+ }
220
+
221
+ function fail(title: string, e: unknown) {
222
+ toast.add({
223
+ title,
224
+ description: e instanceof Error ? e.message : String(e),
225
+ icon: 'i-lucide-triangle-alert',
226
+ color: 'error',
227
+ })
228
+ }
229
+ </script>
230
+
231
+ <template>
232
+ <Teleport to="body">
233
+ <Transition name="reader-fade">
234
+ <div
235
+ v-if="open"
236
+ class="fixed inset-0 z-50 flex flex-col bg-slate-950/96 backdrop-blur-sm"
237
+ role="dialog"
238
+ aria-modal="true"
239
+ >
240
+ <header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
241
+ <div
242
+ class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-indigo-500/15"
243
+ >
244
+ <UIcon name="i-lucide-cpu" class="h-5 w-5 text-indigo-300" />
245
+ </div>
246
+ <div class="min-w-0">
247
+ <h1 class="truncate text-base font-semibold text-white">Model Configuration</h1>
248
+ <p class="truncate text-xs text-slate-500">
249
+ Presets that map a model to every agent. A task picks one; tasks default to the
250
+ workspace default preset.
251
+ </p>
252
+ </div>
253
+ <UButton
254
+ v-if="editor"
255
+ icon="i-lucide-arrow-left"
256
+ color="neutral"
257
+ variant="ghost"
258
+ size="sm"
259
+ class="ml-auto"
260
+ @click="editor = null"
261
+ >
262
+ Back
263
+ </UButton>
264
+ <UButton
265
+ icon="i-lucide-x"
266
+ color="neutral"
267
+ variant="ghost"
268
+ size="sm"
269
+ :class="editor ? '' : 'ml-auto'"
270
+ title="Close (Esc)"
271
+ @click="open = false"
272
+ />
273
+ </header>
274
+
275
+ <div class="flex-1 overflow-auto px-6 py-6">
276
+ <div class="mx-auto max-w-3xl space-y-5">
277
+ <!-- ===== list view ===== -->
278
+ <template v-if="!editor">
279
+ <div class="flex items-center justify-between">
280
+ <p class="text-sm leading-relaxed text-slate-400">
281
+ Each preset sets a <span class="text-slate-300">base model</span> for every agent,
282
+ with optional per-agent overrides. A model pinned on an individual task still
283
+ overrides the preset.
284
+ </p>
285
+ <UButton
286
+ icon="i-lucide-plus"
287
+ color="primary"
288
+ size="sm"
289
+ class="shrink-0"
290
+ @click="startCreate"
291
+ >
292
+ New preset
293
+ </UButton>
294
+ </div>
295
+
296
+ <p v-if="models.models.length === 0" class="py-4 text-center text-sm text-slate-500">
297
+ Loading model catalog…
298
+ </p>
299
+
300
+ <div v-else class="space-y-3">
301
+ <div
302
+ v-for="p in sortedPresets"
303
+ :key="p.id"
304
+ class="rounded-xl border border-slate-800 bg-slate-900/50 p-4"
305
+ >
306
+ <div class="flex items-center gap-2">
307
+ <span class="truncate text-sm font-semibold text-slate-100">{{ p.name }}</span>
308
+ <UBadge v-if="p.isDefault" color="primary" variant="subtle" size="xs">
309
+ Default
310
+ </UBadge>
311
+ <div class="ml-auto flex items-center gap-1">
312
+ <UButton
313
+ v-if="!p.isDefault"
314
+ size="xs"
315
+ variant="ghost"
316
+ color="neutral"
317
+ icon="i-lucide-star"
318
+ :loading="busy"
319
+ title="Set as workspace default"
320
+ @click="setDefault(p)"
321
+ />
322
+ <UButton
323
+ size="xs"
324
+ variant="ghost"
325
+ color="neutral"
326
+ icon="i-lucide-pencil"
327
+ title="Edit preset"
328
+ @click="startEdit(p)"
329
+ />
330
+ <UButton
331
+ size="xs"
332
+ variant="ghost"
333
+ color="error"
334
+ icon="i-lucide-trash-2"
335
+ :disabled="p.isDefault"
336
+ :loading="busy"
337
+ :title="
338
+ p.isDefault ? 'The default preset cannot be deleted' : 'Delete preset'
339
+ "
340
+ @click="remove(p)"
341
+ />
342
+ </div>
343
+ </div>
344
+ <div class="mt-1.5 text-[11px] text-slate-400">
345
+ Base: <span class="text-slate-300">{{ modelLabel(p.baseModelId) }}</span>
346
+ <span v-if="Object.keys(p.overrides).length">
347
+ · {{ Object.keys(p.overrides).length }} override<span
348
+ v-if="Object.keys(p.overrides).length !== 1"
349
+ >s</span
350
+ >
351
+ </span>
352
+ </div>
353
+ </div>
354
+ <p
355
+ v-if="sortedPresets.length === 0"
356
+ class="py-6 text-center text-sm text-slate-500"
357
+ >
358
+ No presets yet — create one to map models to your agents.
359
+ </p>
360
+ </div>
361
+ </template>
362
+
363
+ <!-- ===== editor view ===== -->
364
+ <template v-else>
365
+ <div class="space-y-4 rounded-xl border border-slate-800 bg-slate-900/50 p-4">
366
+ <div>
367
+ <label
368
+ class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-400"
369
+ >
370
+ Name
371
+ </label>
372
+ <UInput
373
+ v-model="editor.name"
374
+ placeholder="e.g. Kimi K2.7"
375
+ size="sm"
376
+ class="w-full"
377
+ />
378
+ </div>
379
+
380
+ <div>
381
+ <label
382
+ class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-400"
383
+ >
384
+ Base model (applied to every agent)
385
+ </label>
386
+ <UDropdownMenu
387
+ :items="baseMenu"
388
+ :ui="{ content: 'max-h-80 overflow-y-auto z-[60]' }"
389
+ >
390
+ <UButton
391
+ size="sm"
392
+ color="primary"
393
+ variant="subtle"
394
+ trailing-icon="i-lucide-chevron-down"
395
+ class="w-full justify-between"
396
+ >
397
+ <span class="truncate">{{ modelLabel(editor.baseModelId) }}</span>
398
+ </UButton>
399
+ </UDropdownMenu>
400
+ </div>
401
+
402
+ <label class="flex items-center gap-2 text-sm text-slate-300">
403
+ <UCheckbox v-model="editor.isDefault" />
404
+ Make this the workspace default
405
+ </label>
406
+ </div>
407
+
408
+ <div>
409
+ <div class="mb-1 flex items-center justify-between">
410
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
411
+ Per-agent overrides
412
+ </span>
413
+ </div>
414
+ <UInput
415
+ v-model="filter"
416
+ icon="i-lucide-search"
417
+ size="sm"
418
+ placeholder="Filter agents…"
419
+ class="mb-3 w-full"
420
+ />
421
+ <div
422
+ class="divide-y divide-slate-800 rounded-xl border border-slate-800 bg-slate-900/50"
423
+ >
424
+ <div
425
+ v-for="a in filteredKinds"
426
+ :key="a.kind"
427
+ class="flex items-center gap-3 px-4 py-3"
428
+ >
429
+ <UIcon
430
+ :name="a.icon"
431
+ class="h-4 w-4 shrink-0"
432
+ :style="{ color: a.color }"
433
+ :title="a.description"
434
+ />
435
+ <div class="min-w-0 flex-1" :title="a.description">
436
+ <p class="truncate text-sm text-slate-200">{{ a.label }}</p>
437
+ </div>
438
+ <UDropdownMenu
439
+ :items="overrideMenu(a.kind)"
440
+ :ui="{ content: 'max-h-80 overflow-y-auto z-[60]' }"
441
+ >
442
+ <UButton
443
+ size="xs"
444
+ :color="editor.overrides[a.kind] ? 'primary' : 'neutral'"
445
+ :variant="editor.overrides[a.kind] ? 'subtle' : 'soft'"
446
+ trailing-icon="i-lucide-chevron-down"
447
+ class="w-64 shrink-0 justify-between"
448
+ >
449
+ <span class="truncate">{{ overrideLabel(a.kind) }}</span>
450
+ </UButton>
451
+ </UDropdownMenu>
452
+ </div>
453
+ <p
454
+ v-if="filteredKinds.length === 0"
455
+ class="px-4 py-6 text-center text-sm text-slate-500"
456
+ >
457
+ No agents match "{{ filter }}".
458
+ </p>
459
+ </div>
460
+ </div>
461
+
462
+ <div class="flex items-center justify-end gap-2">
463
+ <UButton color="neutral" variant="ghost" size="sm" @click="editor = null">
464
+ Cancel
465
+ </UButton>
466
+ <UButton color="primary" size="sm" :loading="busy" @click="save">
467
+ {{ editor.id ? 'Save changes' : 'Create preset' }}
468
+ </UButton>
469
+ </div>
470
+ </template>
471
+ </div>
472
+ </div>
473
+ </div>
474
+ </Transition>
475
+ </Teleport>
476
+ </template>
477
+
478
+ <style scoped>
479
+ .reader-fade-enter-active,
480
+ .reader-fade-leave-active {
481
+ transition: opacity 0.18s ease;
482
+ }
483
+ .reader-fade-enter-from,
484
+ .reader-fade-leave-to {
485
+ opacity: 0;
486
+ }
487
+ </style>