@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.
@@ -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.name,
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
- return mergePresets.presets.find((p) => p.id === mergePresetId.value)?.name ?? 'Workspace default'
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
- const block = await board.addTask(
268
- containerId,
269
- title.value.trim(),
270
- description.value.trim() || undefined,
271
- {
272
- taskType: taskType.value as CreateTaskType,
273
- ...(typeFields ? { taskTypeFields: typeFields } : {}),
274
- ...(mergePresetId.value ? { mergePresetId: mergePresetId.value } : {}),
275
- ...(modelPresetId.value ? { modelPresetId: modelPresetId.value } : {}),
276
- ...(pipelineId.value ? { pipelineId: pipelineId.value } : {}),
277
- ...(Object.keys(agentConfigValues.value).length
278
- ? { agentConfig: agentConfigValues.value }
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
- <UFormField label="Description">
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="Describe the work — context, acceptance criteria, anything the agent should know…"
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.name,
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(externalId: string, title: string, status: string) {
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.externalId, t.title, t.status)"
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 — we do
122
- // NOT dump the issue body into the description; the link is enough.
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.22.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",