@cat-factory/app 0.22.0 → 0.23.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.
@@ -11,7 +11,7 @@
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
17
 
@@ -193,6 +193,61 @@ const issuesConnected = computed(() => tasks.available && tasks.anyOffered)
193
193
  const pendingDocs = computed(() => pendingContext.value.filter((c) => c.kind === 'document'))
194
194
  const pendingIssues = computed(() => pendingContext.value.filter((c) => c.kind === 'task'))
195
195
 
196
+ // Linked issues whose body is in hand, surfaced read-only above the description so the
197
+ // user SEES the original issue description is included in the task (and can add notes on
198
+ // top). The bodies are folded into the saved description on submit (see `add`).
199
+ const linkedIssueBodies = computed(() =>
200
+ pendingIssues.value
201
+ .filter((i) => (i.description ?? '').trim().length > 0)
202
+ .map((i) => ({ key: contextKey(i), title: i.title, body: (i.description ?? '').trim() })),
203
+ )
204
+ const hasLinkedIssueBody = computed(() => linkedIssueBodies.value.length > 0)
205
+ // True while we're fetching a search-hit issue's body so the read-only preview can show
206
+ // a placeholder instead of silently appearing late.
207
+ const resolvingIssueBodies = ref(false)
208
+
209
+ // A staged issue picked from search results carries no body yet (`needsImport`, and the
210
+ // search result has no description). Resolve it once the form opens — from the local cache
211
+ // when already imported, else by importing it (idempotent; we'd import on add anyway) — so
212
+ // its description can be shown read-only and folded into the task. Best-effort: a failure
213
+ // just leaves that issue without a preview, still linked on add.
214
+ async function resolvePendingIssueBodies() {
215
+ const unresolved = pendingContext.value.filter(
216
+ (c) => c.kind === 'task' && !(c.description ?? '').trim(),
217
+ )
218
+ if (!unresolved.length) return
219
+ resolvingIssueBodies.value = true
220
+ try {
221
+ const resolved: Record<string, string> = {}
222
+ for (const item of unresolved) {
223
+ const source = item.source as TaskSourceKind
224
+ const cached = tasks.tasks.find(
225
+ (t) => t.source === source && t.externalId === item.externalId,
226
+ )
227
+ if ((cached?.description ?? '').trim()) {
228
+ resolved[contextKey(item)] = cached!.description
229
+ continue
230
+ }
231
+ if (!item.needsImport) continue
232
+ try {
233
+ const imported = await tasks.importTask(source, item.externalId)
234
+ if ((imported.description ?? '').trim()) resolved[contextKey(item)] = imported.description
235
+ } catch {
236
+ // Unreadable/forbidden issue — skip the preview; it still links on add.
237
+ }
238
+ }
239
+ if (Object.keys(resolved).length) {
240
+ // The issue is now imported, so it links directly on add (needsImport → false).
241
+ pendingContext.value = pendingContext.value.map((c) => {
242
+ const body = resolved[contextKey(c)]
243
+ return body ? { ...c, description: body, needsImport: false } : c
244
+ })
245
+ }
246
+ } finally {
247
+ resolvingIssueBodies.value = false
248
+ }
249
+ }
250
+
196
251
  function addPending(item: PendingContext) {
197
252
  if (pendingContext.value.some((c) => contextKey(c) === contextKey(item))) return
198
253
  pendingContext.value = [...pendingContext.value, item]
@@ -241,6 +296,8 @@ watch(open, (isOpen) => {
241
296
  }
242
297
  documents.loadDocuments().catch(() => {})
243
298
  tasks.loadTasks().catch(() => {})
299
+ // Fetch any staged search-hit issue's body so its description shows read-only below.
300
+ resolvePendingIssueBodies().catch(() => {})
244
301
  })
245
302
 
246
303
  // A recurring task only needs a target frame (its details are filled in the schedule
@@ -264,21 +321,23 @@ async function add() {
264
321
  saving.value = true
265
322
  try {
266
323
  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
- )
324
+ // The saved description includes each linked issue's body (shown read-only above)
325
+ // followed by the user's own notes, so the original issue description is part of the
326
+ // task — not only reachable via the context link.
327
+ const notes = description.value.trim()
328
+ const fullDescription =
329
+ [...linkedIssueBodies.value.map((b) => b.body), notes].filter(Boolean).join('\n\n') ||
330
+ undefined
331
+ const block = await board.addTask(containerId, title.value.trim(), fullDescription, {
332
+ taskType: taskType.value as CreateTaskType,
333
+ ...(typeFields ? { taskTypeFields: typeFields } : {}),
334
+ ...(mergePresetId.value ? { mergePresetId: mergePresetId.value } : {}),
335
+ ...(modelPresetId.value ? { modelPresetId: modelPresetId.value } : {}),
336
+ ...(pipelineId.value ? { pipelineId: pipelineId.value } : {}),
337
+ ...(Object.keys(agentConfigValues.value).length
338
+ ? { agentConfig: agentConfigValues.value }
339
+ : {}),
340
+ })
282
341
  if (block) {
283
342
  const failed = await linkPending(block.id, pendingContext.value)
284
343
  if (failed > 0) {
@@ -352,12 +411,37 @@ async function add() {
352
411
  />
353
412
  </UFormField>
354
413
 
355
- <UFormField label="Description">
414
+ <!-- Linked issue description(s), read-only: shown so the user sees the original
415
+ issue description is included in the task. It's folded into the saved
416
+ description (before their notes) on add. -->
417
+ <UFormField
418
+ v-for="issue in linkedIssueBodies"
419
+ :key="issue.key"
420
+ :label="`${issue.title} (from issue, included)`"
421
+ >
422
+ <UTextarea
423
+ :model-value="issue.body"
424
+ :rows="4"
425
+ autoresize
426
+ readonly
427
+ class="w-full"
428
+ :ui="{ base: 'cursor-default text-slate-300' }"
429
+ />
430
+ </UFormField>
431
+ <p v-if="resolvingIssueBodies" class="text-[11px] text-slate-500">
432
+ Loading the linked issue's description…
433
+ </p>
434
+
435
+ <UFormField :label="hasLinkedIssueBody ? 'Additional notes' : 'Description'">
356
436
  <UTextarea
357
437
  v-model="description"
358
438
  :rows="4"
359
439
  autoresize
360
- placeholder="Describe the work — context, acceptance criteria, anything the agent should know…"
440
+ :placeholder="
441
+ hasLinkedIssueBody
442
+ ? 'Add anything else the agent should know — appended to the issue description above…'
443
+ : 'Describe the work — context, acceptance criteria, anything the agent should know…'
444
+ "
361
445
  class="w-full"
362
446
  />
363
447
  </UFormField>
@@ -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
  }
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.0",
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",