@cat-factory/app 0.148.1 → 0.150.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.
@@ -135,6 +135,80 @@ const draftValid = computed(
135
135
  () => draft.value.title.trim() && draft.value.summary.trim() && draft.value.body.trim(),
136
136
  )
137
137
 
138
+ // ---- auto-generate a title from the fragment's content (inline LLM call) ---
139
+ // Shared by the create form and the inline editor; keyed so only the button that triggered it
140
+ // spins. Generation needs a body (the title is derived from it); the summary rides along.
141
+ const generatingTitleFor = ref<string | null>(null)
142
+ async function autofillTitle(
143
+ key: string,
144
+ get: () => { body: string; summary?: string },
145
+ set: (title: string) => void,
146
+ ) {
147
+ const { body, summary } = get()
148
+ if (!body.trim() || generatingTitleFor.value) return
149
+ generatingTitleFor.value = key
150
+ try {
151
+ const title = await library.generateTitle({
152
+ body: body.trim(),
153
+ summary: summary?.trim() || undefined,
154
+ })
155
+ set(title)
156
+ } catch (e) {
157
+ notifyError(t('fragments.authored.titleGenFailed'), e)
158
+ } finally {
159
+ generatingTitleFor.value = null
160
+ }
161
+ }
162
+
163
+ // ---- edit an existing hand-authored fragment (title / summary / body / tags) ---
164
+ const editDraft = ref<{
165
+ id: string
166
+ title: string
167
+ summary: string
168
+ body: string
169
+ tags: string
170
+ } | null>(null)
171
+ function startEdit(f: (typeof library.fragments)[number]) {
172
+ editDraft.value = {
173
+ id: f.id,
174
+ title: f.title,
175
+ summary: f.summary,
176
+ body: f.body,
177
+ tags: (f.tags ?? []).join(', '),
178
+ }
179
+ }
180
+ function cancelEdit() {
181
+ editDraft.value = null
182
+ }
183
+ const editValid = computed(
184
+ () =>
185
+ !!editDraft.value &&
186
+ !!editDraft.value.title.trim() &&
187
+ !!editDraft.value.summary.trim() &&
188
+ !!editDraft.value.body.trim(),
189
+ )
190
+ async function saveEdit() {
191
+ const d = editDraft.value
192
+ if (!d || !editValid.value) return
193
+ await withRow(`edit:${d.id}`, async () => {
194
+ try {
195
+ await library.update(d.id, {
196
+ title: d.title.trim(),
197
+ summary: d.summary.trim(),
198
+ body: d.body.trim(),
199
+ tags: d.tags
200
+ .split(',')
201
+ .map((t) => t.trim())
202
+ .filter(Boolean),
203
+ })
204
+ editDraft.value = null
205
+ toast.add({ title: t('fragments.toast.updated'), icon: 'i-lucide-check' })
206
+ } catch (e) {
207
+ notifyError(t('fragments.toast.updateFailed'), e)
208
+ }
209
+ })
210
+ }
211
+
138
212
  async function createFragment() {
139
213
  if (!draftValid.value) return
140
214
  creating.value = true
@@ -522,26 +596,96 @@ async function unlinkSource(id: string) {
522
596
  <div
523
597
  v-for="f in library.fragments"
524
598
  :key="f.id"
525
- class="flex items-start gap-2 rounded-md border border-slate-800 bg-slate-900/60 p-3"
599
+ class="rounded-md border border-slate-800 bg-slate-900/60 p-3"
526
600
  >
527
- <div class="min-w-0">
528
- <div class="flex items-center gap-2">
529
- <span class="font-medium text-slate-100">{{ f.title }}</span>
530
- <UBadge v-if="f.source" size="xs" color="info" variant="subtle">{{
531
- t('fragments.authored.fromRepo')
532
- }}</UBadge>
601
+ <!-- Inline editor (hand-authored fragments): title / summary / body / tags, with the
602
+ same auto-generate-title button as the create form. -->
603
+ <div v-if="editDraft && editDraft.id === f.id" class="flex flex-col gap-2">
604
+ <div class="flex gap-2">
605
+ <UInput
606
+ v-model="editDraft.title"
607
+ :placeholder="t('fragments.authored.titlePlaceholder')"
608
+ class="flex-1"
609
+ />
610
+ <UButton
611
+ icon="i-lucide-wand-2"
612
+ size="sm"
613
+ variant="outline"
614
+ :disabled="!editDraft.body.trim()"
615
+ :loading="generatingTitleFor === `edit:${f.id}`"
616
+ :title="t('fragments.authored.generateTitleHint')"
617
+ @click="
618
+ autofillTitle(
619
+ `edit:${f.id}`,
620
+ () => ({ body: editDraft!.body, summary: editDraft!.summary }),
621
+ (title) => {
622
+ if (editDraft) editDraft.title = title
623
+ },
624
+ )
625
+ "
626
+ >
627
+ {{ t('fragments.authored.generateTitle') }}
628
+ </UButton>
629
+ </div>
630
+ <UInput
631
+ v-model="editDraft.summary"
632
+ :placeholder="t('fragments.authored.summaryPlaceholder')"
633
+ />
634
+ <UTextarea
635
+ v-model="editDraft.body"
636
+ :placeholder="t('fragments.authored.bodyPlaceholder')"
637
+ :rows="4"
638
+ />
639
+ <UInput
640
+ v-model="editDraft.tags"
641
+ :placeholder="t('fragments.authored.tagsPlaceholder')"
642
+ />
643
+ <div class="flex gap-2">
644
+ <UButton
645
+ size="sm"
646
+ :disabled="!editValid"
647
+ :loading="rowBusy(`edit:${f.id}`)"
648
+ @click="saveEdit"
649
+ >
650
+ {{ t('common.save') }}
651
+ </UButton>
652
+ <UButton size="sm" variant="ghost" color="neutral" @click="cancelEdit">
653
+ {{ t('common.cancel') }}
654
+ </UButton>
655
+ </div>
656
+ </div>
657
+ <!-- Row -->
658
+ <div v-else class="flex items-start gap-2">
659
+ <div class="min-w-0">
660
+ <div class="flex items-center gap-2">
661
+ <span class="font-medium text-slate-100">{{ f.title }}</span>
662
+ <UBadge v-if="f.source" size="xs" color="info" variant="subtle">{{
663
+ t('fragments.authored.fromRepo')
664
+ }}</UBadge>
665
+ </div>
666
+ <p class="text-sm text-slate-400">{{ f.summary }}</p>
667
+ </div>
668
+ <div class="ms-auto flex gap-1">
669
+ <!-- Editing a repo-SOURCED fragment locally would be overwritten on the next sync,
670
+ so only hand-authored fragments are editable here. -->
671
+ <UButton
672
+ v-if="!f.source"
673
+ icon="i-lucide-pencil"
674
+ size="xs"
675
+ variant="ghost"
676
+ :title="t('common.edit')"
677
+ @click="startEdit(f)"
678
+ />
679
+ <UButton
680
+ icon="i-lucide-trash-2"
681
+ size="xs"
682
+ color="error"
683
+ variant="ghost"
684
+ :loading="rowBusy(`remove:${f.id}`)"
685
+ @click="removeFragment(f.id)"
686
+ />
533
687
  </div>
534
- <p class="text-sm text-slate-400">{{ f.summary }}</p>
535
688
  </div>
536
- <UButton
537
- icon="i-lucide-trash-2"
538
- size="xs"
539
- color="error"
540
- variant="ghost"
541
- class="ms-auto"
542
- :loading="rowBusy(`remove:${f.id}`)"
543
- @click="removeFragment(f.id)"
544
- />
545
689
  </div>
546
690
  <p v-if="!library.fragments.length" class="text-sm text-slate-500">
547
691
  {{
@@ -554,7 +698,33 @@ async function unlinkSource(id: string) {
554
698
  <div class="rounded-md border border-slate-800 p-3">
555
699
  <p class="mb-2 text-sm font-medium">{{ t('fragments.authored.addTitle') }}</p>
556
700
  <div class="flex flex-col gap-2">
557
- <UInput v-model="draft.title" :placeholder="t('fragments.authored.titlePlaceholder')" />
701
+ <div class="flex gap-2">
702
+ <UInput
703
+ v-model="draft.title"
704
+ :placeholder="t('fragments.authored.titlePlaceholder')"
705
+ class="flex-1"
706
+ />
707
+ <UButton
708
+ icon="i-lucide-wand-2"
709
+ size="sm"
710
+ variant="outline"
711
+ :disabled="!draft.body.trim()"
712
+ :loading="generatingTitleFor === 'create'"
713
+ :title="t('fragments.authored.generateTitleHint')"
714
+ data-testid="fragment-generate-title"
715
+ @click="
716
+ autofillTitle(
717
+ 'create',
718
+ () => ({ body: draft.body, summary: draft.summary }),
719
+ (title) => {
720
+ draft.title = title
721
+ },
722
+ )
723
+ "
724
+ >
725
+ {{ t('fragments.authored.generateTitle') }}
726
+ </UButton>
727
+ </div>
558
728
  <UInput
559
729
  v-model="draft.summary"
560
730
  :placeholder="t('fragments.authored.summaryPlaceholder')"
@@ -483,6 +483,17 @@ async function copyOutput() {
483
483
  it raised and the greenlight verdict; plus the fixer-loop phase -->
484
484
  <StepTestReport v-if="testReport" :report="testReport" :phase="testPhase" />
485
485
 
486
+ <!-- code/PR reviewer's best-practice adherence: per standard, a 1..10 rating of how
487
+ well the change adheres + the issues it surfaced. Only on a review step. -->
488
+ <StepFragmentAdherence
489
+ v-if="step.fragmentAdherence?.length"
490
+ :items="step.fragmentAdherence"
491
+ />
492
+
493
+ <!-- container agent's effort self-assessment (how hard it was, what reduced its
494
+ effectiveness, key obstacles). Only when the agent reported one. -->
495
+ <StepEffortReport v-if="step.effortReport" :report="step.effortReport" />
496
+
486
497
  <!-- edit-then-approve: a direct editor over the raw conclusions; the
487
498
  edits become the approved proposal that flows to the next step -->
488
499
  <section v-if="editing" class="scroll-mt-4">
@@ -0,0 +1,86 @@
1
+ <script setup lang="ts">
2
+ // The container agent's effort self-assessment, surfaced in run details: how hard the work was
3
+ // (1..10), what reduced its effectiveness, and the key obstacles it hit. Populated by the harness
4
+ // from the agent's sentinel file and recorded on the step (`step.effortReport`). Rendered only when
5
+ // present, so a run on an older harness image (or an agent that wrote none) shows nothing.
6
+ import type { AgentEffortReport } from '~/types/execution'
7
+
8
+ const props = defineProps<{ report: AgentEffortReport }>()
9
+ const { t } = useI18n()
10
+
11
+ // Clamp for the bar width; the schema already bounds 1..10 but be defensive against a stray value.
12
+ const difficultyPct = computed(() =>
13
+ Math.min(100, Math.max(0, (props.report.difficulty / 10) * 100)),
14
+ )
15
+ // Colour the difficulty by band: easy (emerald) → moderate (amber) → hard (rose).
16
+ const difficultyClass = computed(() =>
17
+ props.report.difficulty >= 8
18
+ ? 'bg-rose-400'
19
+ : props.report.difficulty >= 5
20
+ ? 'bg-amber-400'
21
+ : 'bg-emerald-400',
22
+ )
23
+ </script>
24
+
25
+ <template>
26
+ <section
27
+ data-testid="step-effort-report"
28
+ class="scroll-mt-4 rounded-xl border border-slate-800 bg-slate-900/50 p-4"
29
+ >
30
+ <div
31
+ class="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400"
32
+ >
33
+ <UIcon name="i-lucide-gauge" class="h-3.5 w-3.5" />
34
+ <span>{{ t('panels.stepDetail.effort.heading') }}</span>
35
+ </div>
36
+
37
+ <div class="flex items-center gap-2">
38
+ <span class="text-[12px] text-slate-300">{{ t('panels.stepDetail.effort.difficulty') }}</span>
39
+ <div class="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-700/60">
40
+ <div
41
+ class="h-full rounded-full"
42
+ :class="difficultyClass"
43
+ :style="{ width: `${difficultyPct}%` }"
44
+ />
45
+ </div>
46
+ <span data-testid="step-effort-difficulty" class="text-[12px] font-medium text-slate-200">
47
+ {{ t('panels.stepDetail.effort.outOfTen', { value: report.difficulty }) }}
48
+ </span>
49
+ </div>
50
+
51
+ <p
52
+ v-if="report.summary"
53
+ class="mt-2 whitespace-pre-wrap text-[13px] leading-relaxed text-slate-300"
54
+ >
55
+ {{ report.summary }}
56
+ </p>
57
+
58
+ <div v-if="report.reducedEffectiveness" class="mt-3">
59
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
60
+ {{ t('panels.stepDetail.effort.reduced') }}
61
+ </p>
62
+ <p class="mt-0.5 whitespace-pre-wrap text-[12px] text-slate-300">
63
+ {{ report.reducedEffectiveness }}
64
+ </p>
65
+ </div>
66
+
67
+ <div v-if="report.obstacles?.length" class="mt-3">
68
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
69
+ {{ t('panels.stepDetail.effort.obstacles') }}
70
+ </p>
71
+ <ul class="mt-0.5 space-y-1">
72
+ <li
73
+ v-for="(obstacle, i) in report.obstacles"
74
+ :key="i"
75
+ class="flex items-start gap-1.5 text-[12px] text-slate-300"
76
+ >
77
+ <UIcon
78
+ name="i-lucide-alert-triangle"
79
+ class="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-400/80"
80
+ />
81
+ <span>{{ obstacle }}</span>
82
+ </li>
83
+ </ul>
84
+ </div>
85
+ </section>
86
+ </template>
@@ -0,0 +1,85 @@
1
+ <script setup lang="ts">
2
+ // A code/PR review step's best-practice adherence report, surfaced in run details: for each
3
+ // best-practice standard (prompt fragment) folded into the reviewer's prompt, how well the reviewed
4
+ // change adheres (1..10) and the issues that standard surfaced. Recorded on the step
5
+ // (`step.fragmentAdherence`) by the engine from the review agent's output. Rendered only when the
6
+ // reviewer reported at least one standard; when none were reachable the reviewer says so in its
7
+ // summary instead (so there is nothing to show here).
8
+ import type { FragmentAdherence } from '~/types/execution'
9
+
10
+ const props = defineProps<{ items: FragmentAdherence }>()
11
+ const { t } = useI18n()
12
+
13
+ /** Rating band → bar colour: poor adherence (rose) → partial (amber) → strong (emerald). */
14
+ function ratingClass(rating: number): string {
15
+ return rating >= 8 ? 'bg-emerald-400' : rating >= 5 ? 'bg-amber-400' : 'bg-rose-400'
16
+ }
17
+ function ratingPct(rating: number): number {
18
+ return Math.min(100, Math.max(0, (rating / 10) * 100))
19
+ }
20
+ /** The label the reviewer was asked to cite the standard by: its title, else its id. */
21
+ function label(item: FragmentAdherence[number]): string {
22
+ return item.title?.trim() || item.fragmentId || t('panels.stepDetail.adherence.unnamed')
23
+ }
24
+ </script>
25
+
26
+ <template>
27
+ <section
28
+ v-if="items.length"
29
+ data-testid="step-fragment-adherence"
30
+ class="scroll-mt-4 rounded-xl border border-slate-800 bg-slate-900/50 p-4"
31
+ >
32
+ <div
33
+ class="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400"
34
+ >
35
+ <UIcon name="i-lucide-clipboard-check" class="h-3.5 w-3.5" />
36
+ <span>{{ t('panels.stepDetail.adherence.heading') }}</span>
37
+ </div>
38
+
39
+ <div class="space-y-2.5">
40
+ <article
41
+ v-for="(item, i) in items"
42
+ :key="item.fragmentId ?? i"
43
+ data-testid="step-fragment-adherence-item"
44
+ class="rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2"
45
+ >
46
+ <div class="flex items-center gap-2">
47
+ <span class="min-w-0 flex-1 truncate text-[13px] font-medium text-slate-100">{{
48
+ label(item)
49
+ }}</span>
50
+ <div class="h-1.5 w-16 shrink-0 overflow-hidden rounded-full bg-slate-700/60">
51
+ <div
52
+ class="h-full rounded-full"
53
+ :class="ratingClass(item.rating)"
54
+ :style="{ width: `${ratingPct(item.rating)}%` }"
55
+ />
56
+ </div>
57
+ <span class="shrink-0 text-[12px] font-medium text-slate-200">
58
+ {{ t('panels.stepDetail.adherence.outOfTen', { value: item.rating }) }}
59
+ </span>
60
+ </div>
61
+ <p
62
+ v-if="item.assessment"
63
+ class="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-slate-300"
64
+ >
65
+ {{ item.assessment }}
66
+ </p>
67
+ <div v-if="item.relatedFindings.length" class="mt-1.5">
68
+ <p class="text-[10px] font-semibold uppercase tracking-wide text-slate-500">
69
+ {{ t('panels.stepDetail.adherence.relatedFindings') }}
70
+ </p>
71
+ <ul class="mt-0.5 space-y-0.5">
72
+ <li
73
+ v-for="(finding, fi) in item.relatedFindings"
74
+ :key="fi"
75
+ class="flex items-start gap-1.5 text-[11px] text-slate-400"
76
+ >
77
+ <UIcon name="i-lucide-dot" class="mt-0.5 h-3.5 w-3.5 shrink-0" />
78
+ <span>{{ finding }}</span>
79
+ </li>
80
+ </ul>
81
+ </div>
82
+ </article>
83
+ </div>
84
+ </section>
85
+ </template>
@@ -466,6 +466,14 @@ async function onDismiss(id: string): Promise<void> {
466
466
  {{ state.summary }}
467
467
  </p>
468
468
 
469
+ <!-- Best-practice adherence: per standard folded into the reviewer's prompt, a 1..10
470
+ rating of how well the PR adheres + the issues that standard surfaced. -->
471
+ <StepFragmentAdherence
472
+ v-if="step?.fragmentAdherence?.length"
473
+ :items="step.fragmentAdherence"
474
+ class="mb-3"
475
+ />
476
+
469
477
  <!-- A clean PR / resolved review with no findings. -->
470
478
  <div
471
479
  v-if="findings.length === 0"
@@ -210,6 +210,23 @@ async function add() {
210
210
  }
211
211
  }
212
212
 
213
+ /** Route an update to the scope the key lives in (account / workspace / user). */
214
+ async function updateKey(k: ApiKey, patch: { enabled?: boolean; isDefault?: boolean }) {
215
+ try {
216
+ if (k.scope === 'account') await keys.updateAccountKey(k.id, patch)
217
+ else if (k.scope === 'workspace') await keys.updateWorkspaceKey(k.id, patch)
218
+ else await keys.updateUserKey(k.id, patch)
219
+ // Enabling/disabling changes provider selectability in the picker — refresh it.
220
+ if (workspace.workspaceId) await models.refresh(workspace.workspaceId)
221
+ } catch (e) {
222
+ toast.add({
223
+ title: t('providers.apiKeys.toast.updateFailed'),
224
+ description: e instanceof Error ? e.message : String(e),
225
+ color: 'error',
226
+ })
227
+ }
228
+ }
229
+
213
230
  async function remove(k: ApiKey) {
214
231
  const noun = t('providers.apiKeys.keyNoun')
215
232
  if (!(await confirmAction('remove', noun))) return
@@ -352,10 +369,14 @@ async function remove(k: ApiKey) {
352
369
  v-for="k in connected"
353
370
  :key="k.id"
354
371
  class="flex items-center justify-between rounded-md border border-slate-700 bg-slate-900/50 px-3 py-2 text-sm"
372
+ :class="{ 'opacity-55': !k.enabled }"
355
373
  >
356
374
  <div>
357
375
  <span class="font-medium text-slate-200">{{ k.label }}</span>
358
376
  <span class="ms-2 text-xs text-slate-500">{{ providerLabel(k.provider) }}</span>
377
+ <UBadge v-if="!k.enabled" color="neutral" variant="subtle" size="sm" class="ms-2">
378
+ {{ t('providers.apiKeys.disabledBadge') }}
379
+ </UBadge>
359
380
  <div class="text-[11px] tabular-nums text-slate-500">
360
381
  {{
361
382
  t(
@@ -366,13 +387,32 @@ async function remove(k: ApiKey) {
366
387
  }}
367
388
  </div>
368
389
  </div>
369
- <UButton
370
- icon="i-lucide-trash-2"
371
- color="error"
372
- variant="ghost"
373
- size="xs"
374
- @click="remove(k)"
375
- />
390
+ <div class="flex items-center gap-2">
391
+ <UButton
392
+ :icon="k.isDefault ? 'i-lucide-star' : 'i-lucide-star-off'"
393
+ :color="k.isDefault ? 'primary' : 'neutral'"
394
+ :variant="k.isDefault ? 'subtle' : 'ghost'"
395
+ size="xs"
396
+ @click="updateKey(k, { isDefault: !k.isDefault })"
397
+ >
398
+ {{
399
+ k.isDefault ? t('providers.apiKeys.defaultBadge') : t('providers.apiKeys.pinDefault')
400
+ }}
401
+ </UButton>
402
+ <USwitch
403
+ :model-value="k.enabled"
404
+ size="sm"
405
+ :aria-label="t('providers.apiKeys.enableToggle')"
406
+ @update:model-value="(v: boolean) => updateKey(k, { enabled: v })"
407
+ />
408
+ <UButton
409
+ icon="i-lucide-trash-2"
410
+ color="error"
411
+ variant="ghost"
412
+ size="xs"
413
+ @click="remove(k)"
414
+ />
415
+ </div>
376
416
  </div>
377
417
  </div>
378
418
  </div>
@@ -141,6 +141,30 @@ async function add() {
141
141
  }
142
142
  }
143
143
 
144
+ async function toggleEnabled(cred: { id: string }, enabled: boolean) {
145
+ try {
146
+ await creds.update(cred.id, { enabled })
147
+ } catch (e) {
148
+ toast.add({
149
+ title: t('providers.vendorCredentials.toast.updateFailed'),
150
+ description: e instanceof Error ? e.message : String(e),
151
+ color: 'error',
152
+ })
153
+ }
154
+ }
155
+
156
+ async function toggleDefault(cred: { id: string; isDefault: boolean }) {
157
+ try {
158
+ await creds.update(cred.id, { isDefault: !cred.isDefault })
159
+ } catch (e) {
160
+ toast.add({
161
+ title: t('providers.vendorCredentials.toast.updateFailed'),
162
+ description: e instanceof Error ? e.message : String(e),
163
+ color: 'error',
164
+ })
165
+ }
166
+ }
167
+
144
168
  async function remove(cred: { id: string; label: string }) {
145
169
  const ok = await confirm({
146
170
  title: t('providers.vendorCredentials.confirmRemove.title'),
@@ -245,10 +269,14 @@ function vendorLabel(v: SubscriptionVendor): string {
245
269
  v-for="c in creds.credentials"
246
270
  :key="c.id"
247
271
  class="flex items-center justify-between rounded-md border border-slate-700 bg-slate-900/50 px-3 py-2 text-sm"
272
+ :class="{ 'opacity-55': !c.enabled }"
248
273
  >
249
274
  <div>
250
275
  <span class="font-medium text-slate-200">{{ c.label }}</span>
251
276
  <span class="ms-2 text-xs text-slate-500">{{ vendorLabel(c.vendor) }}</span>
277
+ <UBadge v-if="!c.enabled" color="neutral" variant="subtle" size="sm" class="ms-2">
278
+ {{ t('providers.vendorCredentials.disabledBadge') }}
279
+ </UBadge>
252
280
  <div class="text-[11px] tabular-nums text-slate-500">
253
281
  {{
254
282
  t(
@@ -262,13 +290,34 @@ function vendorLabel(v: SubscriptionVendor): string {
262
290
  }}
263
291
  </div>
264
292
  </div>
265
- <UButton
266
- icon="i-lucide-trash-2"
267
- color="error"
268
- variant="ghost"
269
- size="xs"
270
- @click="remove(c)"
271
- />
293
+ <div class="flex items-center gap-2">
294
+ <UButton
295
+ :icon="c.isDefault ? 'i-lucide-star' : 'i-lucide-star-off'"
296
+ :color="c.isDefault ? 'primary' : 'neutral'"
297
+ :variant="c.isDefault ? 'subtle' : 'ghost'"
298
+ size="xs"
299
+ @click="toggleDefault(c)"
300
+ >
301
+ {{
302
+ c.isDefault
303
+ ? t('providers.vendorCredentials.defaultBadge')
304
+ : t('providers.vendorCredentials.pinDefault')
305
+ }}
306
+ </UButton>
307
+ <USwitch
308
+ :model-value="c.enabled"
309
+ size="sm"
310
+ :aria-label="t('providers.vendorCredentials.enableToggle')"
311
+ @update:model-value="(v: boolean) => toggleEnabled(c, v)"
312
+ />
313
+ <UButton
314
+ icon="i-lucide-trash-2"
315
+ color="error"
316
+ variant="ghost"
317
+ size="xs"
318
+ @click="remove(c)"
319
+ />
320
+ </div>
272
321
  </div>
273
322
  </div>
274
323
  </div>
@@ -3,6 +3,7 @@ import {
3
3
  createPromptFragmentContract,
4
4
  deletePromptFragmentContract,
5
5
  fragmentSourceStatusContract,
6
+ generatePromptFragmentTitleContract,
6
7
  linkFragmentSourceContract,
7
8
  listFragmentCatalogContract,
8
9
  listFragmentSourcesContract,
@@ -17,6 +18,7 @@ import type {
17
18
  CreateDocumentFragmentInput,
18
19
  CreatePromptFragmentInput,
19
20
  FragmentOwnerKind,
21
+ GenerateFragmentTitleInput,
20
22
  LinkFragmentSourceInput,
21
23
  UpdatePromptFragmentInput,
22
24
  } from '~/types/domain'
@@ -58,6 +60,21 @@ export function fragmentsApi({ send, ws, scope }: ApiContext) {
58
60
  pathParams: { fragmentId },
59
61
  }),
60
62
 
63
+ // Auto-generate a title for a hand-authored fragment from its content (an inline LLM call).
64
+ // At the account scope the backend resolves the model against `viaWorkspaceId`'s scope;
65
+ // ignored at the workspace scope (which uses the addressed workspace).
66
+ generateFragmentTitle: (
67
+ kind: FragmentOwnerKind,
68
+ id: string,
69
+ body: GenerateFragmentTitleInput,
70
+ viaWorkspaceId?: string,
71
+ ) =>
72
+ send(generatePromptFragmentTitleContract, {
73
+ pathPrefix: scope(kind, id),
74
+ queryParams: { viaWorkspaceId },
75
+ body,
76
+ }),
77
+
61
78
  // Link an external document (Confluence/Notion/GitHub) as a living fragment.
62
79
  createDocumentFragment: (
63
80
  kind: FragmentOwnerKind,