@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.
- package/app/components/board/AddTaskModal.vue +146 -3
- package/app/components/board/nodes/TaskCard.vue +29 -1
- package/app/components/fragments/FragmentLibraryManager.vue +188 -18
- package/app/components/panels/AgentStepDetail.vue +11 -0
- package/app/components/panels/StepEffortReport.vue +86 -0
- package/app/components/panels/StepFragmentAdherence.vue +85 -0
- package/app/components/prReview/PrReviewWindow.vue +8 -0
- package/app/components/settings/WorkspaceSettingsPanel.vue +14 -8
- package/app/composables/api/fragments.ts +17 -0
- package/app/docs/consumer-extensions.md +41 -8
- package/app/modular/agent-kinds.spec.ts +1 -39
- package/app/modular/agent-kinds.ts +7 -58
- package/app/modular/capabilities.spec.ts +88 -0
- package/app/modular/capabilities.ts +77 -0
- package/app/modular/nav-contributions.spec.ts +2 -0
- package/app/modular/registry.ts +8 -1
- package/app/modular/slots.ts +13 -1
- package/app/modular/task-types.ts +27 -0
- package/app/plugins/modular.client.ts +8 -3
- package/app/stores/agents.spec.ts +13 -9
- package/app/stores/agents.ts +16 -17
- package/app/stores/fragmentLibrary.ts +16 -0
- package/app/stores/taskTypes.spec.ts +100 -0
- package/app/stores/taskTypes.ts +88 -0
- package/app/stores/workspace.ts +14 -4
- package/app/types/domain.ts +23 -0
- package/app/types/execution.ts +3 -0
- package/app/types/fragments.ts +2 -0
- package/app/utils/catalog.ts +107 -0
- package/i18n/locales/de.json +23 -4
- package/i18n/locales/en.json +23 -4
- package/i18n/locales/es.json +23 -4
- package/i18n/locales/fr.json +23 -4
- package/i18n/locales/he.json +23 -4
- package/i18n/locales/it.json +23 -4
- package/i18n/locales/ja.json +23 -4
- package/i18n/locales/pl.json +23 -4
- package/i18n/locales/tr.json +23 -4
- package/i18n/locales/uk.json +23 -4
- 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
|
|
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
|
-
|
|
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
|
|
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="
|
|
599
|
+
class="rounded-md border border-slate-800 bg-slate-900/60 p-3"
|
|
526
600
|
>
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
<
|
|
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>
|