@cat-factory/app 0.16.1 → 0.17.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.
@@ -230,6 +230,15 @@ watch(open, (isOpen) => {
230
230
  pendingContext.value = []
231
231
  showDocPicker.value = false
232
232
  showIssuePicker.value = false
233
+ // Seed from a prefill when opened from another surface (e.g. "create task from
234
+ // issue" sets the title + stages the issue as linked context). Pipeline / preset
235
+ // are intentionally left at their defaults so the user confirms them here.
236
+ const prefill = ui.addTaskPrefill
237
+ if (prefill) {
238
+ if (prefill.title) title.value = prefill.title
239
+ if (prefill.description) description.value = prefill.description
240
+ if (prefill.context?.length) pendingContext.value = [...prefill.context]
241
+ }
233
242
  documents.loadDocuments().catch(() => {})
234
243
  tasks.loadTasks().catch(() => {})
235
244
  })
@@ -582,6 +591,7 @@ async function add() {
582
591
  <ContextIssuePicker
583
592
  v-if="showIssuePicker && issuesConnected"
584
593
  :chosen-keys="chosenIssueKeys"
594
+ :scope-block-id="ui.addTaskContainerId ?? undefined"
585
595
  @pick="addPending"
586
596
  />
587
597
  <div v-if="pendingIssues.length" class="space-y-1">
@@ -16,6 +16,7 @@ const props = defineProps<{ id: string }>()
16
16
  const board = useBoardStore()
17
17
  const execution = useExecutionStore()
18
18
  const ui = useUiStore()
19
+ const tasks = useTasksStore()
19
20
  const agentRuns = useAgentRunsStore()
20
21
  const services = useServicesStore()
21
22
  const reviews = useReviewStage()
@@ -108,6 +109,13 @@ function addTask() {
108
109
  ui.openAddTask(props.id)
109
110
  }
110
111
 
112
+ // Open the tracker-issue modal scoped to THIS service: the create-in target and the
113
+ // repo-scoped issue search are both pinned to this frame (see TaskImportModal).
114
+ function createTaskFromIssue() {
115
+ ui.expandFrame(props.id)
116
+ ui.openTaskImport(null, props.id)
117
+ }
118
+
111
119
  function addRecurring() {
112
120
  ui.openAddRecurring(props.id)
113
121
  }
@@ -360,6 +368,16 @@ const ITEM_ICON: Record<string, string> = {
360
368
  title="Add task"
361
369
  @click.stop="addTask"
362
370
  />
371
+ <UButton
372
+ v-if="tasks.anyOffered"
373
+ class="nodrag"
374
+ size="xs"
375
+ variant="ghost"
376
+ color="neutral"
377
+ icon="i-lucide-ticket"
378
+ title="Create task from issue"
379
+ @click.stop="createTaskFromIssue"
380
+ />
363
381
  <UButton
364
382
  class="nodrag"
365
383
  size="xs"
@@ -11,6 +11,12 @@ import type { TaskSearchResult, TaskSourceKind } from '~/types/domain'
11
11
  const props = defineProps<{
12
12
  /** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
13
13
  chosenKeys?: string[]
14
+ /**
15
+ * The block the picker is attaching context to (a service frame or a task/module
16
+ * under one). Scopes a GitHub search to that service's linked repo, so hits stay
17
+ * in-repo and a pasted URL / bare issue number resolves to the exact issue.
18
+ */
19
+ scopeBlockId?: string
14
20
  }>()
15
21
  const emit = defineEmits<{ pick: [item: PendingContext] }>()
16
22
 
@@ -50,7 +56,7 @@ async function runSearch() {
50
56
  searching.value = true
51
57
  searchError.value = null
52
58
  try {
53
- results.value = await tasks.search(source.value, q)
59
+ results.value = await tasks.search(source.value, q, props.scopeBlockId)
54
60
  } catch (e) {
55
61
  results.value = []
56
62
  searchError.value = e instanceof Error ? e.message : String(e)
@@ -2,10 +2,12 @@
2
2
  // Import an issue from a connected task source (by key or URL) and review the
3
3
  // issues already imported into the workspace. An imported issue can be attached
4
4
  // to an existing task for context from the inspector (see TaskContextIssues.vue),
5
- // or turned directly into a new board task here pick a container (service frame
6
- // or module) and "Create task", which seeds a leaf block from the issue and links
7
- // the issue to it for context.
8
- import type { Block, TaskSearchResult, TaskSourceKind } from '~/types/domain'
5
+ // or turned into a new board task here: pick a container (service frame or module),
6
+ // then click an issue to open the prefilled add-task form (title seeded, issue
7
+ // staged as linked context) where the user confirms the pipeline / presets before
8
+ // creating it. A separate icon button on each row opens the issue on GitHub.
9
+ import type { TaskSearchResult, TaskSourceKind } from '~/types/domain'
10
+ import type { AddTaskPrefill } from '~/stores/ui'
9
11
 
10
12
  const ui = useUiStore()
11
13
  const tasks = useTasksStore()
@@ -23,12 +25,22 @@ const source = ref<TaskSourceKind | undefined>(undefined)
23
25
  const ref_ = ref('')
24
26
  const importing = ref(false)
25
27
 
28
+ // When opened from a service frame the modal is the "create a task from an issue"
29
+ // surface; opened standalone it's the general tracker-issue browser/importer.
30
+ const title = computed(() =>
31
+ ui.taskImport?.containerId ? 'Create task from issue' : 'Tracker issues',
32
+ )
33
+
26
34
  const sourceItems = computed(() =>
27
35
  tasks.offeredSources.map((s) => ({ label: s.label, value: s.source })),
28
36
  )
29
37
  const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
30
38
  const searchable = computed(() => descriptor.value?.searchable ?? false)
31
39
 
40
+ // The container (service frame or module) a new task is created in. Also the repo
41
+ // scope for the issue search — declared up here so the search watch can read it.
42
+ const containerId = ref<string | undefined>(undefined)
43
+
32
44
  // Browse the tracker by free text so an issue can be turned into a task without
33
45
  // knowing its key. Debounced; a created/imported hit also lands in the list below.
34
46
  const searchQuery = ref('')
@@ -37,7 +49,9 @@ const searching = ref(false)
37
49
  const searchError = ref<string | null>(null)
38
50
 
39
51
  let searchTimer: ReturnType<typeof setTimeout> | undefined
40
- watch([searchQuery, source], () => {
52
+ // Re-run when the chosen container changes too: a GitHub search is scoped to the
53
+ // selected service's repo, so switching containers re-scopes the results.
54
+ watch([searchQuery, source, () => containerId.value], () => {
41
55
  if (searchTimer) clearTimeout(searchTimer)
42
56
  searchResults.value = []
43
57
  searchError.value = null
@@ -52,7 +66,9 @@ async function runSearch() {
52
66
  searching.value = true
53
67
  searchError.value = null
54
68
  try {
55
- searchResults.value = await tasks.search(source.value, q)
69
+ // Scope to the selected container's repo so hits stay in-repo and a pasted
70
+ // URL / bare issue number resolves to the exact issue.
71
+ searchResults.value = await tasks.search(source.value, q, containerId.value)
56
72
  } catch (e) {
57
73
  searchResults.value = []
58
74
  searchError.value = e instanceof Error ? e.message : String(e)
@@ -75,7 +91,6 @@ const sourceTasks = computed(() =>
75
91
 
76
92
  // Containers a new task can be created in: every service frame and module on the
77
93
  // board. Modules are labelled with their parent frame so the choice is unambiguous.
78
- const containerId = ref<string | undefined>(undefined)
79
94
  const containerItems = computed(() =>
80
95
  board.blocks
81
96
  .filter((b) => b.level === 'frame' || b.level === 'module')
@@ -87,9 +102,6 @@ const containerItems = computed(() =>
87
102
  value: b.id,
88
103
  })),
89
104
  )
90
- // The issue currently being turned into a task (its row shows a spinner).
91
- const creatingId = ref<string | null>(null)
92
-
93
105
  watch(open, (isOpen) => {
94
106
  if (isOpen) {
95
107
  ref_.value = ''
@@ -97,33 +109,38 @@ watch(open, (isOpen) => {
97
109
  searchResults.value = []
98
110
  searchError.value = null
99
111
  source.value = ui.taskImport?.source ?? tasks.offeredSources[0]?.source ?? undefined
100
- containerId.value = containerItems.value[0]?.value
101
- creatingId.value = null
112
+ // Opened from a service frame → preselect it as the create-in target (and the
113
+ // search's repo scope); otherwise fall back to the first container on the board.
114
+ containerId.value = ui.taskImport?.containerId ?? containerItems.value[0]?.value
102
115
  tasks.loadTasks().catch(() => {})
103
116
  }
104
117
  })
105
118
 
106
- // Create a board task from an issue, seeding its title/description from the issue
107
- // and linking it back for writeback. A search hit isn't projected locally yet, so
108
- // `needsImport` fetches + persists it first (create-block requires it imported).
109
- async function createTask(externalId: string, needsImport = false) {
119
+ // Selecting an issue hands off to the add-task form, prefilled with the issue title
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.
123
+ function selectIssue(
124
+ issue: { externalId: string; title: string; status?: string },
125
+ needsImport: boolean,
126
+ ) {
110
127
  if (!source.value || !containerId.value) return
111
- creatingId.value = externalId
112
- try {
113
- if (needsImport) await tasks.importTask(source.value, externalId)
114
- const { block } = await tasks.createTaskFromIssue(source.value, externalId, containerId.value)
115
- board.upsert(block as Block)
116
- toast.add({ title: `Created task "${block.title}"`, icon: 'i-lucide-square-check' })
117
- } catch (e) {
118
- toast.add({
119
- title: 'Could not create task',
120
- description: e instanceof Error ? e.message : String(e),
121
- icon: 'i-lucide-triangle-alert',
122
- color: 'error',
123
- })
124
- } finally {
125
- creatingId.value = null
128
+ const prefill: AddTaskPrefill = {
129
+ title: issue.title,
130
+ context: [
131
+ {
132
+ kind: 'task',
133
+ source: source.value,
134
+ externalId: issue.externalId,
135
+ title: `${issue.externalId} · ${issue.title}`,
136
+ subtitle: issue.status || undefined,
137
+ icon: descriptor.value?.icon,
138
+ needsImport,
139
+ },
140
+ ],
126
141
  }
142
+ ui.closeTaskImport()
143
+ ui.openAddTask(containerId.value, prefill)
127
144
  }
128
145
 
129
146
  async function doImport() {
@@ -148,7 +165,7 @@ async function doImport() {
148
165
  </script>
149
166
 
150
167
  <template>
151
- <UModal v-model:open="open" title="Tracker issues">
168
+ <UModal v-model:open="open" :title="title">
152
169
  <template #body>
153
170
  <!-- Empty state: no source offered (none connected/installed, or all disabled) -->
154
171
  <div v-if="!tasks.anyOffered" class="space-y-3 text-center">
@@ -201,7 +218,7 @@ async function doImport() {
201
218
  v-model="searchQuery"
202
219
  :icon="searching ? 'i-lucide-loader-circle' : 'i-lucide-search'"
203
220
  :ui="{ leadingIcon: searching ? 'animate-spin' : '' }"
204
- placeholder="Search by title…"
221
+ placeholder="Search by title, paste an issue URL, or type an issue number…"
205
222
  class="w-full"
206
223
  />
207
224
  </UFormField>
@@ -226,7 +243,8 @@ async function doImport() {
226
243
  Add a service frame to the board first to create tasks from issues.
227
244
  </p>
228
245
 
229
- <!-- Search results (not yet imported): create a task directly from a hit. -->
246
+ <!-- Search results (not yet imported): click a hit to create a task from it
247
+ (opens the prefilled add-task form); the icon button views it on GitHub. -->
230
248
  <div v-if="searchError" class="text-[11px] text-amber-400">
231
249
  Search failed: {{ searchError }}
232
250
  </div>
@@ -237,38 +255,36 @@ async function doImport() {
237
255
  <div
238
256
  v-for="hit in freshHits"
239
257
  :key="`hit:${hit.source}:${hit.externalId}`"
240
- class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
258
+ class="flex items-start justify-between gap-2 rounded-lg border border-slate-800 bg-slate-900/60 p-3 transition-colors hover:border-primary-500/60 hover:bg-slate-900"
241
259
  >
242
- <div class="flex items-start justify-between gap-2">
243
- <div class="min-w-0">
244
- <a
245
- :href="hit.url"
246
- target="_blank"
247
- rel="noopener"
248
- class="truncate text-sm font-medium text-white hover:underline"
249
- >
250
- {{ hit.externalId }} · {{ hit.title }}
251
- </a>
252
- <p v-if="hit.excerpt" class="mt-0.5 line-clamp-2 text-xs text-slate-500">
253
- {{ hit.excerpt }}
254
- </p>
255
- </div>
256
- <div class="flex shrink-0 items-center gap-2">
257
- <UBadge v-if="hit.status" color="neutral" variant="soft" size="xs">
258
- {{ hit.status }}
259
- </UBadge>
260
- <UButton
261
- color="primary"
262
- variant="soft"
263
- size="xs"
264
- icon="i-lucide-square-check"
265
- :loading="creatingId === hit.externalId"
266
- :disabled="!containerId || creatingId !== null"
267
- @click="createTask(hit.externalId, true)"
268
- >
269
- Create task
270
- </UButton>
271
- </div>
260
+ <button
261
+ type="button"
262
+ class="min-w-0 flex-1 text-left disabled:cursor-not-allowed disabled:opacity-60"
263
+ :disabled="!containerId"
264
+ :title="containerId ? 'Create a task from this issue' : 'Pick a container first'"
265
+ @click="selectIssue(hit, true)"
266
+ >
267
+ <span class="block truncate text-sm font-medium text-white">
268
+ {{ hit.externalId }} · {{ hit.title }}
269
+ </span>
270
+ <span v-if="hit.excerpt" class="mt-0.5 line-clamp-2 block text-xs text-slate-500">
271
+ {{ hit.excerpt }}
272
+ </span>
273
+ </button>
274
+ <div class="flex shrink-0 items-center gap-2">
275
+ <UBadge v-if="hit.status" color="neutral" variant="soft" size="xs">
276
+ {{ hit.status }}
277
+ </UBadge>
278
+ <UButton
279
+ color="neutral"
280
+ variant="ghost"
281
+ size="xs"
282
+ icon="i-lucide-external-link"
283
+ :to="hit.url"
284
+ target="_blank"
285
+ rel="noopener"
286
+ :aria-label="`View ${hit.externalId} on GitHub`"
287
+ />
272
288
  </div>
273
289
  </div>
274
290
  </div>
@@ -281,36 +297,36 @@ async function doImport() {
281
297
  <div
282
298
  v-for="task in sourceTasks"
283
299
  :key="`${task.source}:${task.externalId}`"
284
- class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
300
+ class="flex items-start justify-between gap-2 rounded-lg border border-slate-800 bg-slate-900/60 p-3 transition-colors hover:border-primary-500/60 hover:bg-slate-900"
285
301
  >
286
- <div class="flex items-start justify-between gap-2">
287
- <div class="min-w-0">
288
- <a
289
- :href="task.url"
290
- target="_blank"
291
- rel="noopener"
292
- class="truncate text-sm font-medium text-white hover:underline"
293
- >
294
- {{ task.externalId }} · {{ task.title }}
295
- </a>
296
- <p class="mt-0.5 line-clamp-2 text-xs text-slate-500">{{ task.excerpt }}</p>
297
- </div>
298
- <div class="flex shrink-0 items-center gap-2">
299
- <UBadge color="neutral" variant="soft" size="xs">
300
- {{ task.status }}
301
- </UBadge>
302
- <UButton
303
- color="primary"
304
- variant="soft"
305
- size="xs"
306
- icon="i-lucide-square-check"
307
- :loading="creatingId === task.externalId"
308
- :disabled="!containerId || creatingId !== null"
309
- @click="createTask(task.externalId)"
310
- >
311
- Create task
312
- </UButton>
313
- </div>
302
+ <button
303
+ type="button"
304
+ class="min-w-0 flex-1 text-left disabled:cursor-not-allowed disabled:opacity-60"
305
+ :disabled="!containerId"
306
+ :title="containerId ? 'Create a task from this issue' : 'Pick a container first'"
307
+ @click="selectIssue(task, false)"
308
+ >
309
+ <span class="block truncate text-sm font-medium text-white">
310
+ {{ task.externalId }} · {{ task.title }}
311
+ </span>
312
+ <span class="mt-0.5 line-clamp-2 block text-xs text-slate-500">{{
313
+ task.excerpt
314
+ }}</span>
315
+ </button>
316
+ <div class="flex shrink-0 items-center gap-2">
317
+ <UBadge color="neutral" variant="soft" size="xs">
318
+ {{ task.status }}
319
+ </UBadge>
320
+ <UButton
321
+ color="neutral"
322
+ variant="ghost"
323
+ size="xs"
324
+ icon="i-lucide-external-link"
325
+ :to="task.url"
326
+ target="_blank"
327
+ rel="noopener"
328
+ :aria-label="`View ${task.externalId} on GitHub`"
329
+ />
314
330
  </div>
315
331
  </div>
316
332
  </div>
@@ -57,10 +57,15 @@ export function tasksApi({ http, ws }: ApiContext) {
57
57
  body,
58
58
  }),
59
59
 
60
- searchTaskSource: (workspaceId: string, source: TaskSourceKind, query: string) =>
60
+ searchTaskSource: (
61
+ workspaceId: string,
62
+ source: TaskSourceKind,
63
+ query: string,
64
+ blockId?: string,
65
+ ) =>
61
66
  http<{ results: TaskSearchResult[] }>(`${ws(workspaceId)}/task-sources/${source}/search`, {
62
67
  method: 'POST',
63
- body: { query },
68
+ body: { query, ...(blockId ? { blockId } : {}) },
64
69
  }),
65
70
 
66
71
  linkTask: (
@@ -170,9 +170,18 @@ export const useTasksStore = defineStore('tasks', () => {
170
170
  }
171
171
  }
172
172
 
173
- /** Search a connected tracker's issues by free text (title/content). */
174
- async function search(source: TaskSourceKind, query: string): Promise<TaskSearchResult[]> {
175
- const { results } = await api.searchTaskSource(workspace.requireId(), source, query)
173
+ /**
174
+ * Search a connected tracker's issues by free text (title/content). `blockId`
175
+ * (a service frame or a task/module under one) scopes a GitHub search to that
176
+ * service's linked repo — so hits stay in-repo and a pasted URL / bare issue
177
+ * number resolves to the exact issue. Omitted → an unscoped workspace search.
178
+ */
179
+ async function search(
180
+ source: TaskSourceKind,
181
+ query: string,
182
+ blockId?: string,
183
+ ): Promise<TaskSearchResult[]> {
184
+ const { results } = await api.searchTaskSource(workspace.requireId(), source, query, blockId)
176
185
  return results
177
186
  }
178
187
 
package/app/stores/ui.ts CHANGED
@@ -1,10 +1,19 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref, computed } from 'vue'
3
3
  import type { DocumentSourceKind, TaskSourceKind, LodLevel } from '~/types/domain'
4
+ import type { PendingContext } from '~/composables/useContextLinking'
4
5
  import { zoomToLod, lodAtLeast } from '~/composables/useSemanticZoom'
5
6
  import { useExecutionStore } from '~/stores/execution'
6
7
  import { agentKindMeta } from '~/utils/catalog'
7
8
 
9
+ /** Values used to seed the add-task form when it is opened from another surface. */
10
+ export interface AddTaskPrefill {
11
+ title?: string
12
+ description?: string
13
+ /** Context items staged on the new task (e.g. the source issue), linked once created. */
14
+ context?: PendingContext[]
15
+ }
16
+
8
17
  /** Transient UI state: selection, panels, zoom level. */
9
18
  export const useUiStore = defineStore('ui', () => {
10
19
  const selectedBlockId = ref<string | null>(null)
@@ -33,12 +42,19 @@ export const useUiStore = defineStore('ui', () => {
33
42
  // the modal pick a connected one (there is no spawn target — issues are linked
34
43
  // to a block for context, not expanded into structure).
35
44
  const taskConnect = ref<{ source: TaskSourceKind } | null>(null)
36
- const taskImport = ref<{ source: TaskSourceKind | null } | null>(null)
45
+ // `containerId` (a service frame) scopes the modal: it preselects that frame as
46
+ // the create-in target AND scopes the issue search to the frame's linked repo.
47
+ // Null → the unscoped "import an issue" surface (workspace-wide search).
48
+ const taskImport = ref<{ source: TaskSourceKind | null; containerId: string | null } | null>(null)
37
49
 
38
50
  // Add-task modal: the container (service frame or module) a new task is being
39
51
  // added to, or null when closed. The user types the title + description; nothing
40
52
  // is launched until they explicitly start the created task.
41
53
  const addTaskContainerId = ref<string | null>(null)
54
+ // Optional values to seed the add-task form with when it is opened from another
55
+ // surface (e.g. "create task from issue" prefills the title + stages the issue as
56
+ // linked context). The user still confirms pipeline / preset before adding.
57
+ const addTaskPrefill = ref<AddTaskPrefill | null>(null)
42
58
 
43
59
  // Add-recurring-pipeline modal: the service frame a new recurring pipeline is
44
60
  // being added to, or null when closed (mirrors the add-task flow — a button on
@@ -237,17 +253,19 @@ export const useUiStore = defineStore('ui', () => {
237
253
  function closeTaskConnect() {
238
254
  taskConnect.value = null
239
255
  }
240
- function openTaskImport(source: TaskSourceKind | null = null) {
241
- taskImport.value = { source }
256
+ function openTaskImport(source: TaskSourceKind | null = null, containerId: string | null = null) {
257
+ taskImport.value = { source, containerId }
242
258
  }
243
259
  function closeTaskImport() {
244
260
  taskImport.value = null
245
261
  }
246
- function openAddTask(containerId: string) {
262
+ function openAddTask(containerId: string, prefill: AddTaskPrefill | null = null) {
263
+ addTaskPrefill.value = prefill
247
264
  addTaskContainerId.value = containerId
248
265
  }
249
266
  function closeAddTask() {
250
267
  addTaskContainerId.value = null
268
+ addTaskPrefill.value = null
251
269
  }
252
270
  function openAddRecurring(frameId: string) {
253
271
  addRecurringFrameId.value = frameId
@@ -412,6 +430,7 @@ export const useUiStore = defineStore('ui', () => {
412
430
  taskConnect,
413
431
  taskImport,
414
432
  addTaskContainerId,
433
+ addTaskPrefill,
415
434
  addRecurringFrameId,
416
435
  bootstrapOpen,
417
436
  addServiceOpen,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.16.1",
3
+ "version": "0.17.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",