@cat-factory/app 0.22.0 → 0.23.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.
- package/app/components/board/AddTaskModal.vue +108 -22
- package/app/components/panels/inspector/TaskRunSettings.vue +3 -2
- package/app/components/tasks/ContextIssuePicker.vue +9 -6
- package/app/components/tasks/TaskImportModal.vue +7 -3
- package/app/composables/useContextLinking.ts +7 -0
- package/app/utils/mergePreset.ts +19 -0
- package/package.json +1 -1
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
// button is disabled with a hint. Linking needs the block id,
|
|
12
12
|
// so chosen items are staged locally and import-and-linked once the task is created
|
|
13
13
|
// (see useContextLinking) — the same context the agents see for every step of the run.
|
|
14
|
-
import type { CreateTaskType, TaskTypeFields } from '~/types/domain'
|
|
14
|
+
import type { CreateTaskType, TaskSourceKind, TaskTypeFields } from '~/types/domain'
|
|
15
15
|
import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
|
|
16
16
|
import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
|
|
17
|
+
import { mergePresetOptionLabel, mergePresetThresholds } from '~/utils/mergePreset'
|
|
17
18
|
|
|
18
19
|
const ui = useUiStore()
|
|
19
20
|
const board = useBoardStore()
|
|
@@ -104,13 +105,13 @@ const presetMenu = computed(() => [
|
|
|
104
105
|
[
|
|
105
106
|
{
|
|
106
107
|
label: mergePresets.defaultPreset
|
|
107
|
-
? `Default (${mergePresets.defaultPreset.name})`
|
|
108
|
+
? `Default (${mergePresets.defaultPreset.name}) — ${mergePresetThresholds(mergePresets.defaultPreset)}`
|
|
108
109
|
: 'Workspace default',
|
|
109
110
|
icon: 'i-lucide-rotate-ccw',
|
|
110
111
|
onSelect: () => (mergePresetId.value = ''),
|
|
111
112
|
},
|
|
112
113
|
...mergePresets.presets.map((p) => ({
|
|
113
|
-
label: p
|
|
114
|
+
label: mergePresetOptionLabel(p),
|
|
114
115
|
icon: 'i-lucide-git-merge',
|
|
115
116
|
onSelect: () => (mergePresetId.value = p.id),
|
|
116
117
|
})),
|
|
@@ -119,10 +120,11 @@ const presetMenu = computed(() => [
|
|
|
119
120
|
const selectedPresetLabel = computed(() => {
|
|
120
121
|
if (!mergePresetId.value) {
|
|
121
122
|
return mergePresets.defaultPreset
|
|
122
|
-
? `Default (${mergePresets.defaultPreset.name})`
|
|
123
|
+
? `Default (${mergePresets.defaultPreset.name}) — ${mergePresetThresholds(mergePresets.defaultPreset)}`
|
|
123
124
|
: 'Workspace default'
|
|
124
125
|
}
|
|
125
|
-
|
|
126
|
+
const picked = mergePresets.presets.find((p) => p.id === mergePresetId.value)
|
|
127
|
+
return picked ? mergePresetOptionLabel(picked) : 'Workspace default'
|
|
126
128
|
})
|
|
127
129
|
|
|
128
130
|
// Model preset: which model each agent runs on. Empty = workspace default preset.
|
|
@@ -193,6 +195,61 @@ const issuesConnected = computed(() => tasks.available && tasks.anyOffered)
|
|
|
193
195
|
const pendingDocs = computed(() => pendingContext.value.filter((c) => c.kind === 'document'))
|
|
194
196
|
const pendingIssues = computed(() => pendingContext.value.filter((c) => c.kind === 'task'))
|
|
195
197
|
|
|
198
|
+
// Linked issues whose body is in hand, surfaced read-only above the description so the
|
|
199
|
+
// user SEES the original issue description is included in the task (and can add notes on
|
|
200
|
+
// top). The bodies are folded into the saved description on submit (see `add`).
|
|
201
|
+
const linkedIssueBodies = computed(() =>
|
|
202
|
+
pendingIssues.value
|
|
203
|
+
.filter((i) => (i.description ?? '').trim().length > 0)
|
|
204
|
+
.map((i) => ({ key: contextKey(i), title: i.title, body: (i.description ?? '').trim() })),
|
|
205
|
+
)
|
|
206
|
+
const hasLinkedIssueBody = computed(() => linkedIssueBodies.value.length > 0)
|
|
207
|
+
// True while we're fetching a search-hit issue's body so the read-only preview can show
|
|
208
|
+
// a placeholder instead of silently appearing late.
|
|
209
|
+
const resolvingIssueBodies = ref(false)
|
|
210
|
+
|
|
211
|
+
// A staged issue picked from search results carries no body yet (`needsImport`, and the
|
|
212
|
+
// search result has no description). Resolve it once the form opens — from the local cache
|
|
213
|
+
// when already imported, else by importing it (idempotent; we'd import on add anyway) — so
|
|
214
|
+
// its description can be shown read-only and folded into the task. Best-effort: a failure
|
|
215
|
+
// just leaves that issue without a preview, still linked on add.
|
|
216
|
+
async function resolvePendingIssueBodies() {
|
|
217
|
+
const unresolved = pendingContext.value.filter(
|
|
218
|
+
(c) => c.kind === 'task' && !(c.description ?? '').trim(),
|
|
219
|
+
)
|
|
220
|
+
if (!unresolved.length) return
|
|
221
|
+
resolvingIssueBodies.value = true
|
|
222
|
+
try {
|
|
223
|
+
const resolved: Record<string, string> = {}
|
|
224
|
+
for (const item of unresolved) {
|
|
225
|
+
const source = item.source as TaskSourceKind
|
|
226
|
+
const cached = tasks.tasks.find(
|
|
227
|
+
(t) => t.source === source && t.externalId === item.externalId,
|
|
228
|
+
)
|
|
229
|
+
if ((cached?.description ?? '').trim()) {
|
|
230
|
+
resolved[contextKey(item)] = cached!.description
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
if (!item.needsImport) continue
|
|
234
|
+
try {
|
|
235
|
+
const imported = await tasks.importTask(source, item.externalId)
|
|
236
|
+
if ((imported.description ?? '').trim()) resolved[contextKey(item)] = imported.description
|
|
237
|
+
} catch {
|
|
238
|
+
// Unreadable/forbidden issue — skip the preview; it still links on add.
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (Object.keys(resolved).length) {
|
|
242
|
+
// The issue is now imported, so it links directly on add (needsImport → false).
|
|
243
|
+
pendingContext.value = pendingContext.value.map((c) => {
|
|
244
|
+
const body = resolved[contextKey(c)]
|
|
245
|
+
return body ? { ...c, description: body, needsImport: false } : c
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
} finally {
|
|
249
|
+
resolvingIssueBodies.value = false
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
196
253
|
function addPending(item: PendingContext) {
|
|
197
254
|
if (pendingContext.value.some((c) => contextKey(c) === contextKey(item))) return
|
|
198
255
|
pendingContext.value = [...pendingContext.value, item]
|
|
@@ -241,6 +298,8 @@ watch(open, (isOpen) => {
|
|
|
241
298
|
}
|
|
242
299
|
documents.loadDocuments().catch(() => {})
|
|
243
300
|
tasks.loadTasks().catch(() => {})
|
|
301
|
+
// Fetch any staged search-hit issue's body so its description shows read-only below.
|
|
302
|
+
resolvePendingIssueBodies().catch(() => {})
|
|
244
303
|
})
|
|
245
304
|
|
|
246
305
|
// A recurring task only needs a target frame (its details are filled in the schedule
|
|
@@ -264,21 +323,23 @@ async function add() {
|
|
|
264
323
|
saving.value = true
|
|
265
324
|
try {
|
|
266
325
|
const typeFields = buildTypeFields()
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
326
|
+
// The saved description includes each linked issue's body (shown read-only above)
|
|
327
|
+
// followed by the user's own notes, so the original issue description is part of the
|
|
328
|
+
// task — not only reachable via the context link.
|
|
329
|
+
const notes = description.value.trim()
|
|
330
|
+
const fullDescription =
|
|
331
|
+
[...linkedIssueBodies.value.map((b) => b.body), notes].filter(Boolean).join('\n\n') ||
|
|
332
|
+
undefined
|
|
333
|
+
const block = await board.addTask(containerId, title.value.trim(), fullDescription, {
|
|
334
|
+
taskType: taskType.value as CreateTaskType,
|
|
335
|
+
...(typeFields ? { taskTypeFields: typeFields } : {}),
|
|
336
|
+
...(mergePresetId.value ? { mergePresetId: mergePresetId.value } : {}),
|
|
337
|
+
...(modelPresetId.value ? { modelPresetId: modelPresetId.value } : {}),
|
|
338
|
+
...(pipelineId.value ? { pipelineId: pipelineId.value } : {}),
|
|
339
|
+
...(Object.keys(agentConfigValues.value).length
|
|
340
|
+
? { agentConfig: agentConfigValues.value }
|
|
341
|
+
: {}),
|
|
342
|
+
})
|
|
282
343
|
if (block) {
|
|
283
344
|
const failed = await linkPending(block.id, pendingContext.value)
|
|
284
345
|
if (failed > 0) {
|
|
@@ -352,12 +413,37 @@ async function add() {
|
|
|
352
413
|
/>
|
|
353
414
|
</UFormField>
|
|
354
415
|
|
|
355
|
-
|
|
416
|
+
<!-- Linked issue description(s), read-only: shown so the user sees the original
|
|
417
|
+
issue description is included in the task. It's folded into the saved
|
|
418
|
+
description (before their notes) on add. -->
|
|
419
|
+
<UFormField
|
|
420
|
+
v-for="issue in linkedIssueBodies"
|
|
421
|
+
:key="issue.key"
|
|
422
|
+
:label="`${issue.title} (from issue, included)`"
|
|
423
|
+
>
|
|
424
|
+
<UTextarea
|
|
425
|
+
:model-value="issue.body"
|
|
426
|
+
:rows="4"
|
|
427
|
+
autoresize
|
|
428
|
+
readonly
|
|
429
|
+
class="w-full"
|
|
430
|
+
:ui="{ base: 'cursor-default text-slate-300' }"
|
|
431
|
+
/>
|
|
432
|
+
</UFormField>
|
|
433
|
+
<p v-if="resolvingIssueBodies" class="text-[11px] text-slate-500">
|
|
434
|
+
Loading the linked issue's description…
|
|
435
|
+
</p>
|
|
436
|
+
|
|
437
|
+
<UFormField :label="hasLinkedIssueBody ? 'Additional notes' : 'Description'">
|
|
356
438
|
<UTextarea
|
|
357
439
|
v-model="description"
|
|
358
440
|
:rows="4"
|
|
359
441
|
autoresize
|
|
360
|
-
placeholder="
|
|
442
|
+
:placeholder="
|
|
443
|
+
hasLinkedIssueBody
|
|
444
|
+
? 'Add anything else the agent should know — appended to the issue description above…'
|
|
445
|
+
: 'Describe the work — context, acceptance criteria, anything the agent should know…'
|
|
446
|
+
"
|
|
361
447
|
class="w-full"
|
|
362
448
|
/>
|
|
363
449
|
</UFormField>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { computed, onMounted } from 'vue'
|
|
3
3
|
import type { Block } from '~/types/domain'
|
|
4
4
|
import type { WritebackOverride } from '~/types/tracker'
|
|
5
|
+
import { mergePresetOptionLabel, mergePresetThresholds } from '~/utils/mergePreset'
|
|
5
6
|
|
|
6
7
|
const props = defineProps<{ block: Block }>()
|
|
7
8
|
|
|
@@ -61,13 +62,13 @@ const presetMenu = computed(() => [
|
|
|
61
62
|
[
|
|
62
63
|
{
|
|
63
64
|
label: mergePresets.defaultPreset
|
|
64
|
-
? `Default (${mergePresets.defaultPreset.name})`
|
|
65
|
+
? `Default (${mergePresets.defaultPreset.name}) — ${mergePresetThresholds(mergePresets.defaultPreset)}`
|
|
65
66
|
: 'Workspace default',
|
|
66
67
|
icon: 'i-lucide-rotate-ccw',
|
|
67
68
|
onSelect: () => setPreset(''),
|
|
68
69
|
},
|
|
69
70
|
...mergePresets.presets.map((p) => ({
|
|
70
|
-
label: p
|
|
71
|
+
label: mergePresetOptionLabel(p),
|
|
71
72
|
icon: 'i-lucide-git-merge',
|
|
72
73
|
onSelect: () => setPreset(p.id),
|
|
73
74
|
})),
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// It only *stages* a choice: the caller collects PendingContext items and links
|
|
7
7
|
// them once the block exists (see useContextLinking). A search hit / pasted ref
|
|
8
8
|
// carries `needsImport: true` so it's fetched + persisted before linking.
|
|
9
|
-
import type { TaskSearchResult, TaskSourceKind } from '~/types/domain'
|
|
9
|
+
import type { SourceTask, TaskSearchResult, TaskSourceKind } from '~/types/domain'
|
|
10
10
|
|
|
11
11
|
const props = defineProps<{
|
|
12
12
|
/** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
|
|
@@ -120,15 +120,18 @@ const empty = computed(
|
|
|
120
120
|
refRow.value === null,
|
|
121
121
|
)
|
|
122
122
|
|
|
123
|
-
function pickImported(
|
|
123
|
+
function pickImported(task: SourceTask) {
|
|
124
124
|
if (!source.value) return
|
|
125
125
|
emit('pick', {
|
|
126
126
|
kind: 'task',
|
|
127
127
|
source: source.value,
|
|
128
|
-
externalId,
|
|
129
|
-
title: `${externalId} · ${title}`,
|
|
130
|
-
subtitle: status || undefined,
|
|
128
|
+
externalId: task.externalId,
|
|
129
|
+
title: `${task.externalId} · ${task.title}`,
|
|
130
|
+
subtitle: task.status || undefined,
|
|
131
131
|
icon: icon.value,
|
|
132
|
+
// Already imported, so its body is in hand — carry it so the add-task form can
|
|
133
|
+
// show it read-only and fold it into the new task's description.
|
|
134
|
+
description: task.description || undefined,
|
|
132
135
|
needsImport: false,
|
|
133
136
|
})
|
|
134
137
|
}
|
|
@@ -200,7 +203,7 @@ onMounted(() => {
|
|
|
200
203
|
:key="`imp:${t.externalId}`"
|
|
201
204
|
type="button"
|
|
202
205
|
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/70"
|
|
203
|
-
@click="pickImported(t
|
|
206
|
+
@click="pickImported(t)"
|
|
204
207
|
>
|
|
205
208
|
<UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-indigo-400" />
|
|
206
209
|
<span class="truncate">{{ t.externalId }} · {{ t.title }}</span>
|
|
@@ -118,10 +118,13 @@ watch(open, (isOpen) => {
|
|
|
118
118
|
|
|
119
119
|
// Selecting an issue hands off to the add-task form, prefilled with the issue title
|
|
120
120
|
// and the issue staged as linked context (so agents see its description + comments).
|
|
121
|
-
// The user still confirms pipeline / preset there before the task is created
|
|
122
|
-
//
|
|
121
|
+
// The user still confirms pipeline / preset there before the task is created. The
|
|
122
|
+
// issue body is carried when already in hand (imported issues); for a search hit it's
|
|
123
|
+
// resolved in the add-task form (by importing). Either way the form shows it read-only
|
|
124
|
+
// and folds it into the new task's description, so the original description is visible
|
|
125
|
+
// and included — the user adds their own notes on top.
|
|
123
126
|
function selectIssue(
|
|
124
|
-
issue: { externalId: string; title: string; status?: string },
|
|
127
|
+
issue: { externalId: string; title: string; status?: string; description?: string },
|
|
125
128
|
needsImport: boolean,
|
|
126
129
|
) {
|
|
127
130
|
if (!source.value || !containerId.value) return
|
|
@@ -135,6 +138,7 @@ function selectIssue(
|
|
|
135
138
|
title: `${issue.externalId} · ${issue.title}`,
|
|
136
139
|
subtitle: issue.status || undefined,
|
|
137
140
|
icon: descriptor.value?.icon,
|
|
141
|
+
description: issue.description || undefined,
|
|
138
142
|
needsImport,
|
|
139
143
|
},
|
|
140
144
|
],
|
|
@@ -19,6 +19,13 @@ export interface PendingContext {
|
|
|
19
19
|
subtitle?: string
|
|
20
20
|
/** Lucide icon for the row. */
|
|
21
21
|
icon?: string
|
|
22
|
+
/**
|
|
23
|
+
* The item's body/description (Markdown), when known. Populated for an
|
|
24
|
+
* already-imported issue at pick time and resolved (by importing) for a search
|
|
25
|
+
* hit when the add-task form opens, so the form can surface it read-only and
|
|
26
|
+
* fold it into the new task's description. Absent until resolved.
|
|
27
|
+
*/
|
|
28
|
+
description?: string
|
|
22
29
|
/** True when the item must be imported before it can be linked. */
|
|
23
30
|
needsImport: boolean
|
|
24
31
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { MergeThresholdPreset } from '~/types/merge'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A compact one-line summary of a merge preset's auto-merge ceilings + CI-fix budget,
|
|
5
|
+
* suitable for a dropdown option label so the user sees each preset's actual thresholds
|
|
6
|
+
* (not just its name) while choosing one. Percentages are the stored 0..1 ratios
|
|
7
|
+
* rendered as whole percents.
|
|
8
|
+
*/
|
|
9
|
+
export function mergePresetThresholds(p: MergeThresholdPreset): string {
|
|
10
|
+
const pct = (n: number) => `${Math.round(n * 100)}%`
|
|
11
|
+
return `cx ≤${pct(p.maxComplexity)} · risk ≤${pct(p.maxRisk)} · impact ≤${pct(
|
|
12
|
+
p.maxImpact,
|
|
13
|
+
)} · ${p.ciMaxAttempts} CI fixes`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The preset name followed by its thresholds, for a single-line dropdown option. */
|
|
17
|
+
export function mergePresetOptionLabel(p: MergeThresholdPreset): string {
|
|
18
|
+
return `${p.name} — ${mergePresetThresholds(p)}`
|
|
19
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.1",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|