@cat-factory/app 0.147.7 → 0.149.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 (40) hide show
  1. package/app/components/board/AddTaskModal.vue +146 -3
  2. package/app/components/board/nodes/TaskCard.vue +29 -1
  3. package/app/components/fragments/FragmentLibraryManager.vue +188 -18
  4. package/app/components/panels/AgentStepDetail.vue +11 -0
  5. package/app/components/panels/StepEffortReport.vue +86 -0
  6. package/app/components/panels/StepFragmentAdherence.vue +85 -0
  7. package/app/components/prReview/PrReviewWindow.vue +8 -0
  8. package/app/components/settings/WorkspaceSettingsPanel.vue +14 -8
  9. package/app/composables/api/fragments.ts +17 -0
  10. package/app/docs/consumer-extensions.md +41 -8
  11. package/app/modular/agent-kinds.spec.ts +1 -39
  12. package/app/modular/agent-kinds.ts +7 -58
  13. package/app/modular/capabilities.spec.ts +88 -0
  14. package/app/modular/capabilities.ts +77 -0
  15. package/app/modular/nav-contributions.spec.ts +2 -0
  16. package/app/modular/registry.ts +8 -1
  17. package/app/modular/slots.ts +13 -1
  18. package/app/modular/task-types.ts +27 -0
  19. package/app/plugins/modular.client.ts +8 -3
  20. package/app/stores/agents.spec.ts +13 -9
  21. package/app/stores/agents.ts +16 -17
  22. package/app/stores/fragmentLibrary.ts +16 -0
  23. package/app/stores/taskTypes.spec.ts +100 -0
  24. package/app/stores/taskTypes.ts +88 -0
  25. package/app/stores/workspace.ts +14 -4
  26. package/app/types/domain.ts +23 -0
  27. package/app/types/execution.ts +3 -0
  28. package/app/types/fragments.ts +2 -0
  29. package/app/utils/catalog.ts +107 -0
  30. package/i18n/locales/de.json +23 -4
  31. package/i18n/locales/en.json +23 -4
  32. package/i18n/locales/es.json +23 -4
  33. package/i18n/locales/fr.json +23 -4
  34. package/i18n/locales/he.json +23 -4
  35. package/i18n/locales/it.json +23 -4
  36. package/i18n/locales/ja.json +23 -4
  37. package/i18n/locales/pl.json +23 -4
  38. package/i18n/locales/tr.json +23 -4
  39. package/i18n/locales/uk.json +23 -4
  40. package/package.json +2 -2
@@ -21,6 +21,9 @@ import type {
21
21
  TaskTypeFields,
22
22
  } from '~/types/domain'
23
23
  import { DOC_KINDS, DOC_KIND_FIELDS } from '~/types/domain'
24
+ import { resolveComponentRegistry } from '@modular-vue/core'
25
+ import { useReactiveSlots } from '@modular-vue/runtime'
26
+ import type { AppSlots, ResultViewContribution } from '~/modular/slots'
24
27
  import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
25
28
  import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
26
29
  import FragmentSelector from '~/components/fragments/FragmentSelector.vue'
@@ -131,6 +134,54 @@ const reviewFocus = ref('')
131
134
  const fragmentIds = ref<string[]>([])
132
135
  const isReview = computed(() => taskType.value === 'review')
133
136
 
137
+ // Custom (deployment-registered) task types — the frontend-extension-mechanism slice B twin of
138
+ // custom agent kinds. The task-types store merges CODE-shipped (`taskTypes` slot) + BACKEND
139
+ // (snapshot `customTaskTypes`) into one catalog; the picker offers them alongside the built-ins.
140
+ // A document repo only accepts document/spike (server-rejected otherwise), so custom types are
141
+ // hidden there — mirroring the built-in `isDocRepo` filter.
142
+ const taskTypesStore = useTaskTypesStore()
143
+ const customTaskTypes = computed(() => (isDocRepo.value ? [] : taskTypesStore.customTaskTypes))
144
+ const selectedCustomType = computed(() =>
145
+ customTaskTypes.value.find((tt) => tt.taskType === taskType.value),
146
+ )
147
+ // Descriptor-field values for a selected custom type (or a bespoke form panel's own bag), folded
148
+ // into `taskTypeFields.custom` on submit. Cleared when the type changes / the modal reopens.
149
+ const customFieldValues = ref<Record<string, string | number>>({})
150
+ // A bespoke create-form section paired to the custom type's `formPanel` id via the
151
+ // `taskTypeFormPanels` slot; shown INSTEAD of the descriptor fields. Unpaired ⇒ descriptor fields
152
+ // (degrade, never crash) — the same pairing shape as the result-view windows.
153
+ const appSlots = useReactiveSlots<AppSlots>()
154
+ const formPanelRegistry = computed(() =>
155
+ resolveComponentRegistry((appSlots.value.taskTypeFormPanels ?? []) as ResultViewContribution[]),
156
+ )
157
+ const customFormPanel = computed(() => {
158
+ const id = selectedCustomType.value?.formPanel
159
+ return id ? (formPanelRegistry.value.get(id) ?? null) : null
160
+ })
161
+ // Whether every REQUIRED descriptor field of the selected custom type has a value. Only the
162
+ // descriptor path is enforced up front — a bespoke `formPanel` owns its own validation, so we
163
+ // don't block on it (nor can we read its required semantics). No custom type selected (or none
164
+ // of its fields are required) ⇒ trivially satisfied. Folded into `canAdd` so the required marker
165
+ // the form renders is actually honoured before submit.
166
+ const customRequiredFieldsFilled = computed(() => {
167
+ const custom = selectedCustomType.value
168
+ if (!custom || customFormPanel.value) return true
169
+ return (custom.fields ?? []).every((field) => {
170
+ if (!field.required) return true
171
+ const raw = customFieldValues.value[field.key]
172
+ return raw !== undefined && String(raw).trim() !== ''
173
+ })
174
+ })
175
+ // The type picker: the built-in choices (i18n labels) + the custom types (their wire presentation).
176
+ const typeChoices = computed<{ value: TaskTypeChoice; label: string; icon: string }[]>(() => [
177
+ ...TASK_TYPES.value,
178
+ ...customTaskTypes.value.map((tt) => ({
179
+ value: tt.taskType as TaskTypeChoice,
180
+ label: tt.presentation.label,
181
+ icon: tt.presentation.icon,
182
+ })),
183
+ ])
184
+
134
185
  // Parse the PR-reference input into the contract fields: a bare positive integer (optionally
135
186
  // `#`-prefixed) becomes `prNumber` (a PR on the service's linked repo); anything else is taken
136
187
  // as a full URL (`prUrl`). Returns undefined when blank or unparseable — the caller uses that
@@ -196,6 +247,33 @@ const DOC_FIELD_PLACEHOLDER_KEYS: Record<DocKindFieldKey, string> = {
196
247
  }
197
248
  const SEVERITIES = ['low', 'medium', 'high', 'critical'] as const
198
249
 
250
+ // A CUSTOM (deployment-registered) task type: fold the collected values into the sparse
251
+ // `taskTypeFields.custom` bag. A bespoke form panel owns the whole bag (taken verbatim); the
252
+ // descriptor path reads only the declared fields, coercing a `number` descriptor's string input.
253
+ function buildCustomTypeFields(): TaskTypeFields | undefined {
254
+ const custom = selectedCustomType.value
255
+ if (!custom) return undefined
256
+ const bag: Record<string, string | number> = {}
257
+ if (customFormPanel.value) {
258
+ for (const [key, value] of Object.entries(customFieldValues.value)) {
259
+ if (value !== undefined && value !== '') bag[key] = value
260
+ }
261
+ } else {
262
+ for (const field of custom.fields ?? []) {
263
+ const raw = customFieldValues.value[field.key]
264
+ if (raw === undefined || raw === '') continue
265
+ if (field.type === 'number') {
266
+ // Skip a non-numeric value rather than sending NaN (which serialises to null on the wire).
267
+ const n = Number(raw)
268
+ if (Number.isFinite(n)) bag[field.key] = n
269
+ } else {
270
+ bag[field.key] = raw
271
+ }
272
+ }
273
+ }
274
+ return Object.keys(bag).length ? { custom: bag } : undefined
275
+ }
276
+
199
277
  function buildTypeFields(): TaskTypeFields | undefined {
200
278
  if (taskType.value === 'bug') {
201
279
  const f: TaskTypeFields = {}
@@ -238,7 +316,7 @@ function buildTypeFields(): TaskTypeFields | undefined {
238
316
  if (reviewFocus.value.trim()) f.reviewFocus = reviewFocus.value.trim()
239
317
  return Object.keys(f).length ? f : undefined
240
318
  }
241
- return undefined
319
+ return buildCustomTypeFields()
242
320
  }
243
321
 
244
322
  // For a recurring task, the schedule attaches to the service frame: the container itself
@@ -346,7 +424,13 @@ const DEFAULT_PIPELINE_FOR_TYPE: Partial<Record<TaskTypeChoice, string>> = {
346
424
  review: 'pl_review',
347
425
  }
348
426
  watch(taskType, (next) => {
349
- const preset = DEFAULT_PIPELINE_FOR_TYPE[next]
427
+ // A custom type owns a fresh field bag on every switch (its descriptors differ per type).
428
+ customFieldValues.value = {}
429
+ // Pre-select the type's default pipeline: a custom type's registered `defaultPipelineId`, else
430
+ // the built-in map. (For a custom type with no default, `BoardService` applies the registry
431
+ // default at creation, so leaving the picker unset is fine.)
432
+ const custom = customTaskTypes.value.find((tt) => tt.taskType === next)
433
+ const preset = custom?.defaultPipelineId ?? DEFAULT_PIPELINE_FOR_TYPE[next]
350
434
  if (!preset) return
351
435
  const match = pipelines.pipelines.find((p) => p.id === preset)
352
436
  if (match) pipelineId.value = match.id
@@ -502,6 +586,7 @@ watch(open, (isOpen) => {
502
586
  docOutlineHints.value = ''
503
587
  reviewPrRef.value = ''
504
588
  reviewFocus.value = ''
589
+ customFieldValues.value = {}
505
590
  // Pre-seed the best-practice fragments from the enclosing service's standards, so a new task
506
591
  // ships with its service's fragments already selected (and freely add/removable here). The task
507
592
  // OWNS this selection from creation — the engine folds exactly these, without re-unioning the
@@ -587,6 +672,8 @@ const canAdd = computed(() => {
587
672
  configValue(RALPH_VALIDATION_COMMAND_ID, '').trim().length === 0
588
673
  )
589
674
  return false
675
+ // A custom type's required descriptor fields must be filled (its form renders them as required).
676
+ if (!customRequiredFieldsFilled.value) return false
590
677
  return true
591
678
  })
592
679
 
@@ -667,12 +754,13 @@ async function add() {
667
754
  <UFormField :label="t('board.addTask.typeLabel')">
668
755
  <div class="flex flex-wrap gap-1">
669
756
  <UButton
670
- v-for="ty in TASK_TYPES"
757
+ v-for="ty in typeChoices"
671
758
  :key="ty.value"
672
759
  :color="taskType === ty.value ? 'primary' : 'neutral'"
673
760
  :variant="taskType === ty.value ? 'soft' : 'ghost'"
674
761
  :icon="ty.icon"
675
762
  size="xs"
763
+ :data-testid="`task-type-${ty.value}`"
676
764
  @click="
677
765
  () => {
678
766
  taskType = ty.value
@@ -949,6 +1037,61 @@ async function add() {
949
1037
  </UFormField>
950
1038
  </div>
951
1039
 
1040
+ <!-- A CUSTOM (deployment-registered) task type: a bespoke create-form section when its
1041
+ `formPanel` is paired to the `taskTypeFormPanels` slot, else the descriptor-driven
1042
+ `fields` the type declares. None of the built-in `v-if` branches above match a
1043
+ namespaced custom type, so this renders on its own. -->
1044
+ <div v-if="selectedCustomType" class="space-y-3" data-testid="custom-task-fields">
1045
+ <component
1046
+ :is="customFormPanel"
1047
+ v-if="customFormPanel"
1048
+ :task-type="selectedCustomType"
1049
+ :model-value="customFieldValues"
1050
+ @update:model-value="customFieldValues = $event"
1051
+ />
1052
+ <template v-else>
1053
+ <UFormField
1054
+ v-for="field in selectedCustomType.fields ?? []"
1055
+ :key="field.key"
1056
+ :label="field.label"
1057
+ :help="field.help"
1058
+ :required="field.required"
1059
+ >
1060
+ <div v-if="field.type === 'select'" class="flex flex-wrap gap-1">
1061
+ <UButton
1062
+ v-for="opt in field.options ?? []"
1063
+ :key="opt.value"
1064
+ :color="customFieldValues[field.key] === opt.value ? 'primary' : 'neutral'"
1065
+ :variant="customFieldValues[field.key] === opt.value ? 'soft' : 'ghost'"
1066
+ size="xs"
1067
+ :data-testid="`custom-field-${field.key}-${opt.value}`"
1068
+ @click="() => (customFieldValues[field.key] = opt.value)"
1069
+ >
1070
+ {{ opt.label }}
1071
+ </UButton>
1072
+ </div>
1073
+ <UTextarea
1074
+ v-else-if="field.type === 'textarea'"
1075
+ v-model="customFieldValues[field.key]"
1076
+ :rows="2"
1077
+ :maxlength="field.maxLength"
1078
+ :placeholder="field.placeholder"
1079
+ :data-testid="`custom-field-${field.key}`"
1080
+ class="w-full"
1081
+ />
1082
+ <UInput
1083
+ v-else
1084
+ v-model="customFieldValues[field.key]"
1085
+ :type="field.type === 'number' ? 'number' : 'text'"
1086
+ :maxlength="field.maxLength"
1087
+ :placeholder="field.placeholder"
1088
+ :data-testid="`custom-field-${field.key}`"
1089
+ class="w-full"
1090
+ />
1091
+ </UFormField>
1092
+ </template>
1093
+ </div>
1094
+
952
1095
  <div class="grid grid-cols-2 gap-3">
953
1096
  <UFormField :label="t('board.addTask.pipeline')">
954
1097
  <PipelinePicker
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { Block } from '~/types/domain'
3
- import { STATUS_META, MODULE_META } from '~/utils/catalog'
3
+ import { STATUS_META, MODULE_META, taskTypeMeta } from '~/utils/catalog'
4
4
  import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
5
5
  import TaskPipelineMini from './TaskPipelineMini.vue'
6
6
 
@@ -18,6 +18,22 @@ const { confirm } = useConfirm()
18
18
 
19
19
  const task = computed<Block | undefined>(() => board.getBlock(props.taskId))
20
20
  const statusMeta = computed(() => (task.value ? STATUS_META[task.value.status] : null))
21
+
22
+ // A type badge for any NON-default task type — built-in (bug/document/spike/review/ralph) or a
23
+ // deployment CUSTOM type (resolved through the `taskTypeMeta` read-model, which degrades an
24
+ // unregistered namespaced type to the `feature` presentation, so a stale id never breaks the card).
25
+ // `feature` is the implicit default and shows no badge to keep ordinary cards uncluttered. A
26
+ // built-in type's label is an i18n key; a custom type's is a literal from its wire presentation.
27
+ const typeBadge = computed(() => {
28
+ const tt = task.value?.taskType
29
+ if (!tt || tt === 'feature') return null
30
+ const meta = taskTypeMeta(tt)
31
+ return {
32
+ icon: meta.icon,
33
+ color: meta.color,
34
+ label: meta.labelKey ? t(meta.labelKey) : (meta.label ?? tt),
35
+ }
36
+ })
21
37
  const selected = computed(() => ui.selectedBlockId === props.taskId)
22
38
 
23
39
  // Drag-to-connect: dragging from this card's handle onto another task makes THAT task
@@ -215,6 +231,18 @@ function selectTask() {
215
231
  on its own row rather than stealing horizontal space from the title. -->
216
232
  <div class="flex items-center gap-1.5">
217
233
  <span class="h-2 w-2 shrink-0 rounded-full" :style="{ backgroundColor: statusMeta.color }" />
234
+ <!-- Task-type badge (non-`feature` only): the type's icon, tinted with its accent, label on
235
+ hover. Renders a built-in OR a deployment-registered custom type via `taskTypeMeta`. -->
236
+ <span
237
+ v-if="typeBadge"
238
+ class="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded"
239
+ :style="{ color: typeBadge.color, backgroundColor: `${typeBadge.color}22` }"
240
+ :title="typeBadge.label"
241
+ :data-task-type-badge="task.taskType"
242
+ data-testid="task-type-badge"
243
+ >
244
+ <UIcon :name="typeBadge.icon" class="h-2.5 w-2.5" />
245
+ </span>
218
246
  <UIcon
219
247
  v-if="schedule"
220
248
  name="i-lucide-repeat"
@@ -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>