@cat-factory/app 0.140.0 → 0.141.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.
@@ -288,17 +288,34 @@ const selectedModelPresetLabel = computed(() => {
288
288
 
289
289
  // Hide UI-testing pipelines (`tester-ui` / `visual-confirmation`) when the target frame has no
290
290
  // UI to exercise — they'd be refused server-side (see utils/pipeline + the backend gate). Also
291
- // hide `'recurring'`-only pipelines: a one-off task start of one is refused at run start.
291
+ // hide `'recurring'`-only pipelines (a one-off task start of one is refused at run start) and,
292
+ // for a `document` task, every non-document pipeline (it authors a doc — only document pipelines
293
+ // are relevant, per the `purpose` classifier). Re-filters as the chosen task type changes.
292
294
  const selectablePipelines = computed(() =>
293
- pipelines.pipelines.filter((p) => pipelineAllowedForManualStart(p, frame.value, board.blocks)),
295
+ pipelines.pipelines.filter((p) =>
296
+ pipelineAllowedForManualStart(p, frame.value, board.blocks, taskType.value),
297
+ ),
294
298
  )
295
- // Picking the Ralph loop task type auto-selects its pipeline, so the per-task validation
296
- // command + iteration budget (contributed by the `ralph` agent) surface immediately — the
297
- // loop is meaningless without them, so "choose at run time" would be a dead end here.
299
+ // Some task types want their type-default pipeline surfaced in the modal up front, so picking the
300
+ // type auto-selects it (the user can still change it among the still-offered pipelines). This is a
301
+ // DELIBERATE SUBSET of the backend `defaultPipelineIdForTaskType` only the types whose default
302
+ // must appear in the form BEFORE creation:
303
+ // - `ralph` needs its preset so the per-task validation command + iteration budget the `ralph`
304
+ // agent contributes surface for editing ("choose at run time" would be a dead end);
305
+ // - a `document` task defaults to `pl_document` so its document-only picker (the `purpose` gate
306
+ // hides every non-document pipeline) is never rendered empty.
307
+ // The other typed defaults (spike/review) carry no up-front config and don't narrow their picker,
308
+ // so the modal leaves `pipelineId` unset and `BoardService` applies the backend type-default at
309
+ // creation. Keep these ids in step with the backend helper.
310
+ const DEFAULT_PIPELINE_FOR_TYPE: Partial<Record<TaskTypeChoice, string>> = {
311
+ ralph: 'pl_ralph',
312
+ document: 'pl_document',
313
+ }
298
314
  watch(taskType, (next) => {
299
- if (next !== 'ralph') return
300
- const ralph = pipelines.pipelines.find((p) => p.id === 'pl_ralph')
301
- if (ralph) pipelineId.value = ralph.id
315
+ const preset = DEFAULT_PIPELINE_FOR_TYPE[next]
316
+ if (!preset) return
317
+ const match = pipelines.pipelines.find((p) => p.id === preset)
318
+ if (match) pipelineId.value = match.id
302
319
  })
303
320
 
304
321
  // Task-level agent config contributed by the selected pipeline's agents (e.g. the
@@ -455,7 +472,10 @@ watch(open, (isOpen) => {
455
472
  delete docKindFieldValues[key]
456
473
  riskPolicyId.value = ''
457
474
  modelPresetId.value = ''
458
- pipelineId.value = ''
475
+ // Seed the pipeline from the (possibly doc-repo-forced) task type's default, so a document
476
+ // repo opens with `pl_document` pre-selected rather than empty. This runs AFTER the `taskType`
477
+ // watcher fired during this reset, so it is the authoritative default (see DEFAULT_PIPELINE_FOR_TYPE).
478
+ pipelineId.value = DEFAULT_PIPELINE_FOR_TYPE[taskType.value] ?? ''
459
479
  agentConfigValues.value = {}
460
480
  pendingContext.value = []
461
481
  showDocPicker.value = false
@@ -1,54 +1,70 @@
1
1
  <script setup lang="ts">
2
2
  import type { DropdownMenuItem } from '@nuxt/ui'
3
- import type { Block, DocumentSourceKind } from '~/types/domain'
3
+ import type { Block } from '~/types/domain'
4
+ import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
4
5
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
5
6
 
6
7
  // Documents (from any source) attached to a task as agent context, shown inside
7
- // the InspectorPanel. Linked docs are fed to agents during execution (see the
8
- // backend's userPromptFor). Rendered only when the integration is available.
8
+ // the InspectorPanel. Attaching uses the SAME inline picker as task creation
9
+ // (source selector + repo→file browse + free-text search + paste-by-reference —
10
+ // ContextDocumentPicker), NOT the old dropdown that opened a second, page-level
11
+ // "Import a page…" modal on top of the inspector. Stacked page-level modals don't
12
+ // interact here, so that menu appeared to open something with nothing clickable.
13
+ // Because the block already exists, a picked item is imported-when-needed then
14
+ // linked immediately via useContextLinking (the add-task flow's shared
15
+ // orchestration), so failures surface with their real cause. Mirrors
16
+ // TaskContextIssues.vue; rendered only when the integration is available.
9
17
  const props = defineProps<{ block: Block }>()
10
18
 
11
19
  const { t } = useI18n()
12
20
  const documents = useDocumentsStore()
13
21
  const ui = useUiStore()
14
22
  const toast = useToast()
23
+ const { linkPending, presentLinkFailures } = useContextLinking()
15
24
 
16
25
  onMounted(() => {
17
26
  documents.loadDocuments().catch(() => {})
18
27
  })
19
28
 
20
29
  const linked = computed(() => documents.docsForBlock(props.block.id))
30
+ // Already-linked docs, so the inline picker filters them out / never re-offers them.
31
+ const chosenKeys = computed(() =>
32
+ linked.value.map((d) =>
33
+ contextKey({ kind: 'document', source: d.source, externalId: d.externalId }),
34
+ ),
35
+ )
21
36
 
22
- async function attach(source: DocumentSourceKind, externalId: string) {
37
+ const connected = computed(() => documents.available && documents.anyConnected)
38
+ // Sources the user could connect right now to unlock the picker, when none is
39
+ // connected yet (GitHub docs are implicitly connected via the App, so never here).
40
+ const connectableSources = computed(() =>
41
+ documents.available ? documents.sources.filter((s) => !documents.isConnected(s.source)) : [],
42
+ )
43
+ const connectMenu = computed<DropdownMenuItem[][]>(() => [
44
+ connectableSources.value.map((s) => ({
45
+ label: s.label,
46
+ icon: s.icon,
47
+ onSelect: () => ui.openDocumentConnect(s.source),
48
+ })),
49
+ ])
50
+
51
+ const showPicker = ref(false)
52
+ const linking = ref(false)
53
+
54
+ // The block exists, so import-when-needed then link immediately (vs the add-task
55
+ // flow which stages the pick and links after create). linkPending never throws —
56
+ // it captures each failure with its cause for the shared presenter.
57
+ async function attach(item: PendingContext) {
58
+ if (linking.value) return
59
+ linking.value = true
23
60
  try {
24
- await documents.linkToBlock(props.block.id, source, externalId)
25
- toast.add({ title: t('documents.taskDocs.attached'), icon: 'i-lucide-link' })
26
- } catch (e) {
27
- toast.add({
28
- title: t('documents.taskDocs.attachFailed'),
29
- description: e instanceof Error ? e.message : String(e),
30
- icon: 'i-lucide-triangle-alert',
31
- color: 'error',
32
- })
61
+ const failures = await linkPending(props.block.id, [item])
62
+ if (failures.length) presentLinkFailures(failures, props.block.id)
63
+ else toast.add({ title: t('documents.taskDocs.attached'), icon: 'i-lucide-link' })
64
+ } finally {
65
+ linking.value = false
33
66
  }
34
67
  }
35
-
36
- const attachMenu = computed<DropdownMenuItem[][]>(() => {
37
- const linkedKeys = new Set(linked.value.map((d) => `${d.source}:${d.externalId}`))
38
- const items: DropdownMenuItem[] = documents.documents
39
- .filter((d) => !linkedKeys.has(`${d.source}:${d.externalId}`))
40
- .map((d) => ({
41
- label: d.title,
42
- icon: documents.descriptorFor(d.source)?.icon ?? 'i-lucide-file-text',
43
- onSelect: () => attach(d.source, d.externalId),
44
- }))
45
- items.push({
46
- label: t('documents.taskDocs.importPage'),
47
- icon: 'i-lucide-file-down',
48
- onSelect: () => ui.openDocumentImport(null),
49
- })
50
- return [items]
51
- })
52
68
  </script>
53
69
 
54
70
  <template>
@@ -59,13 +75,43 @@ const attachMenu = computed<DropdownMenuItem[][]>(() => {
59
75
  :count="linked.length"
60
76
  >
61
77
  <template #actions>
62
- <UDropdownMenu :items="attachMenu" :content="{ side: 'bottom', align: 'end' }">
63
- <UButton color="neutral" variant="soft" size="xs" icon="i-lucide-plus">{{
64
- t('documents.taskDocs.attach')
65
- }}</UButton>
78
+ <UButton
79
+ v-if="connected"
80
+ color="neutral"
81
+ variant="soft"
82
+ size="xs"
83
+ :icon="showPicker ? 'i-lucide-x' : 'i-lucide-plus'"
84
+ @click="showPicker = !showPicker"
85
+ >
86
+ {{ showPicker ? t('common.done') : t('documents.taskDocs.attach') }}
87
+ </UButton>
88
+ <UDropdownMenu
89
+ v-else-if="connectableSources.length > 1"
90
+ :items="connectMenu"
91
+ :content="{ side: 'bottom', align: 'end' }"
92
+ >
93
+ <UButton color="neutral" variant="soft" size="xs" icon="i-lucide-plug">
94
+ {{ t('documents.taskDocs.connectSource') }}
95
+ </UButton>
66
96
  </UDropdownMenu>
97
+ <UButton
98
+ v-else-if="connectableSources.length === 1"
99
+ color="neutral"
100
+ variant="soft"
101
+ size="xs"
102
+ icon="i-lucide-plug"
103
+ @click="ui.openDocumentConnect(connectableSources[0]!.source)"
104
+ >
105
+ {{ t('documents.taskDocs.connectSourceNamed', { source: connectableSources[0]!.label }) }}
106
+ </UButton>
67
107
  </template>
68
108
 
109
+ <ContextDocumentPicker
110
+ v-if="showPicker && connected"
111
+ :chosen-keys="chosenKeys"
112
+ @pick="attach"
113
+ />
114
+
69
115
  <div v-if="linked.length" class="space-y-1">
70
116
  <a
71
117
  v-for="doc in linked"
@@ -28,12 +28,13 @@ const deps = computed(() =>
28
28
  (block.value?.dependsOn ?? []).map((id) => board.getBlock(id)).filter((b): b is Block => !!b),
29
29
  )
30
30
 
31
- // Hide UI-testing pipelines when this block's frame has no UI to exercise, and `'recurring'`-only
32
- // pipelines (a manual run of one is refused server-side) — see the backend gate.
31
+ // Hide UI-testing pipelines when this block's frame has no UI to exercise, `'recurring'`-only
32
+ // pipelines (a manual run of one is refused server-side), and for a `document` task — every
33
+ // non-document pipeline (per the `purpose` classifier) — see the backend gate.
33
34
  const runMenu = computed(() => {
34
35
  const frame = block.value ? board.serviceOf(block.value) : undefined
35
36
  return pipelines.pipelines
36
- .filter((p) => pipelineAllowedForManualStart(p, frame, board.blocks))
37
+ .filter((p) => pipelineAllowedForManualStart(p, frame, board.blocks, block.value?.taskType))
37
38
  .map((p) => ({
38
39
  label: p.name,
39
40
  icon: 'i-lucide-play',
@@ -1,21 +1,29 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
3
  import { useLocalStorage } from '@vueuse/core'
4
- import type { AgentKind } from '~/types/domain'
4
+ import { purposeAllowsAgentCategory } from '@cat-factory/contracts'
5
+ import type { AgentKind, PipelinePurpose } from '~/types/domain'
5
6
  import { AGENT_CATEGORIES, OBSERVABILITY_GATE_ARCHETYPE } from '~/utils/catalog'
6
7
 
7
8
  const { t } = useI18n()
8
9
  const agents = useAgentsStore()
9
10
  const releaseHealth = useReleaseHealthStore()
10
11
  defineEmits<{ (e: 'add', kind: AgentKind): void }>()
12
+ // The purpose of the pipeline being built. When set to a non-`build` classifier, the
13
+ // Implementation (`build`) and Testing (`test`) categories are hidden — such a pipeline writes
14
+ // no product code and runs no tests (see `purposeAllowsAgentCategory`). `null`/`build` shows all.
15
+ const props = defineProps<{ purpose?: PipelinePurpose | null }>()
11
16
 
12
17
  // The post-release-health gate is only meaningful — and only accepted by the backend —
13
18
  // with an observability integration connected, so it appears in the palette ONLY then.
14
- const palette = computed(() =>
15
- releaseHealth.connection.connected
19
+ const palette = computed(() => {
20
+ const all = releaseHealth.connection.connected
16
21
  ? [...agents.archetypes, OBSERVABILITY_GATE_ARCHETYPE]
17
- : agents.archetypes,
18
- )
22
+ : agents.archetypes
23
+ // Hide the categories the pipeline's purpose doesn't build from (an uncategorized custom kind
24
+ // has no category to gate, so it always shows).
25
+ return all.filter((a) => !a.category || purposeAllowsAgentCategory(props.purpose, a.category))
26
+ })
19
27
 
20
28
  // Group the palette into the ordered catalog categories, plus a trailing "Custom" bucket
21
29
  // for runtime-added agents that carry no category. Empty groups are dropped.
@@ -133,13 +133,14 @@ function setModelPreset(id: string) {
133
133
  const selectedPipeline = computed(() =>
134
134
  props.block.pipelineId ? pipelines.getPipeline(props.block.pipelineId) : undefined,
135
135
  )
136
- // Hide UI-testing pipelines when this task's frame has no UI to exercise, and `'recurring'`-only
137
- // pipelines (the task's manual Run control can't start one) — they'd be refused at run start
138
- // (see utils/pipeline + the backend gate).
136
+ // Hide UI-testing pipelines when this task's frame has no UI to exercise, `'recurring'`-only
137
+ // pipelines (the task's manual Run control can't start one), and for a `document` task every
138
+ // non-document pipeline (it authors a doc, so a build/test pipeline makes no sense). All would be
139
+ // refused / wrong at run start (see utils/pipeline + the backend gate + the purpose classifier).
139
140
  const taskFrame = computed(() => board.serviceOf(props.block))
140
141
  const selectablePipelines = computed(() =>
141
142
  pipelines.pipelines.filter((p) =>
142
- pipelineAllowedForManualStart(p, taskFrame.value, board.blocks),
143
+ pipelineAllowedForManualStart(p, taskFrame.value, board.blocks, props.block.taskType),
143
144
  ),
144
145
  )
145
146
  function setPipeline(id: string) {
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, watch } from 'vue'
3
- import type { AgentKind, Pipeline } from '~/types/domain'
3
+ import { purposeAllowsAgentCategory } from '@cat-factory/contracts'
4
+ import type { AgentKind, Pipeline, PipelinePurpose } from '~/types/domain'
4
5
  import AgentPalette from '~/components/palettes/AgentPalette.vue'
5
6
  import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
6
7
  import {
@@ -22,6 +23,18 @@ const CONSENSUS_STRATEGIES = computed<{ value: ConsensusStrategy; label: string
22
23
  { value: 'ranked-voting', label: t('pipeline.builder.strategyOption.ranked-voting') },
23
24
  ])
24
25
 
26
+ // The use-case classifier options for the pipeline (its `purpose`). Static literal `t()` keys, one
27
+ // per `PIPELINE_PURPOSES` member, so the typed-message-keys check sees them (a runtime-built key
28
+ // wouldn't be checkable). A non-`build` purpose hides the Implementation/Testing agent kinds in
29
+ // the palette below (see `AgentPalette`), and drives which task pickers offer the saved pipeline.
30
+ const PURPOSE_OPTIONS = computed<{ value: PipelinePurpose; label: string }[]>(() => [
31
+ { value: 'build', label: t('pipeline.builder.purposeOption.build') },
32
+ { value: 'document', label: t('pipeline.builder.purposeOption.document') },
33
+ { value: 'review', label: t('pipeline.builder.purposeOption.review') },
34
+ { value: 'research', label: t('pipeline.builder.purposeOption.research') },
35
+ { value: 'planning', label: t('pipeline.builder.purposeOption.planning') },
36
+ ])
37
+
25
38
  /** Add a blank participant to the draft step's consensus config. */
26
39
  function addParticipant(i: number) {
27
40
  const cfg = pipelines.draftConsensus[i]
@@ -56,6 +69,19 @@ const skillStepNeedsPick = computed(() =>
56
69
  ),
57
70
  )
58
71
 
72
+ // Steps whose agent category the chosen purpose forbids (a non-`build` purpose writes no code and
73
+ // runs no tests, so the Implementation/Testing categories are disallowed — see
74
+ // `purposeAllowsAgentCategory`). The palette hides those kinds, so this is only reachable by
75
+ // switching an existing draft to a non-`build` purpose AFTER such steps were added. The backend has
76
+ // no kind→category map to gate on, so the builder is the enforcement point: save is blocked until
77
+ // the offending steps are removed (or the purpose set back to Build).
78
+ const stepsDisallowedByPurpose = computed(() =>
79
+ pipelines.draft.filter((kind) => {
80
+ const category = agentKindMeta(kind).category
81
+ return !!category && !purposeAllowsAgentCategory(pipelines.draftPurpose, category)
82
+ }),
83
+ )
84
+
59
85
  // A step's picked skill id is no longer in the account catalog (the source dir was renamed or
60
86
  // unlinked). The step will fail cleanly at dispatch; flag it so the user re-picks.
61
87
  function skillMissing(index: number): boolean {
@@ -282,7 +308,7 @@ async function clone(p: Pipeline) {
282
308
  </UButton>
283
309
  </div>
284
310
  <div class="flex-1 pe-1 lg:min-h-0 lg:overflow-y-auto">
285
- <AgentPalette @add="add" />
311
+ <AgentPalette :purpose="pipelines.draftPurpose" @add="add" />
286
312
  </div>
287
313
  </div>
288
314
 
@@ -310,6 +336,24 @@ async function clone(p: Pipeline) {
310
336
  class="mb-2"
311
337
  />
312
338
 
339
+ <!-- Purpose: the pipeline's use-case classifier. Drives which task pickers offer it (a
340
+ document task offers only `document` pipelines) and narrows the palette below (a
341
+ non-build purpose hides the Implementation/Testing kinds). -->
342
+ <div class="mb-2 flex items-center gap-2">
343
+ <label class="shrink-0 text-[11px] font-medium text-slate-400">
344
+ {{ t('pipeline.builder.purposeLabel') }}
345
+ </label>
346
+ <USelect
347
+ :model-value="pipelines.draftPurpose ?? undefined"
348
+ :items="PURPOSE_OPTIONS"
349
+ value-key="value"
350
+ size="sm"
351
+ class="min-w-40"
352
+ :placeholder="t('pipeline.builder.purposePlaceholder')"
353
+ @update:model-value="pipelines.draftPurpose = $event"
354
+ />
355
+ </div>
356
+
313
357
  <!-- Description: the prose summary shown next to the step list in the pipeline pickers. -->
314
358
  <UTextarea
315
359
  v-model="pipelines.draftDescription"
@@ -360,6 +404,14 @@ async function clone(p: Pipeline) {
360
404
  {{ t('pipeline.builder.skillNeedsPick') }}
361
405
  </p>
362
406
 
407
+ <p
408
+ v-if="stepsDisallowedByPurpose.length"
409
+ class="mb-2 flex items-center gap-1.5 rounded-md border border-amber-800/50 bg-amber-950/30 px-2 py-1 text-[11px] text-amber-300"
410
+ >
411
+ <UIcon name="i-lucide-alert-triangle" class="h-3.5 w-3.5 shrink-0" />
412
+ {{ t('pipeline.builder.purposeStepsConflict') }}
413
+ </p>
414
+
363
415
  <div
364
416
  v-if="pipelines.draft.length === 0"
365
417
  class="flex flex-1 items-center justify-center rounded-lg border border-dashed border-slate-700 p-4 text-center text-xs text-slate-500"
@@ -1015,7 +1067,7 @@ async function clone(p: Pipeline) {
1015
1067
  color="primary"
1016
1068
  icon="i-lucide-save"
1017
1069
  size="sm"
1018
- :disabled="pipelines.draft.length === 0"
1070
+ :disabled="pipelines.draft.length === 0 || stepsDisallowedByPurpose.length > 0"
1019
1071
  @click="save"
1020
1072
  >
1021
1073
  {{ pipelines.editingId ? t('pipeline.builder.update') : t('pipeline.builder.save') }}
@@ -1,11 +1,18 @@
1
1
  <script setup lang="ts">
2
- // Inspector section for a task block: the tracker issues (Jira, …) attached to
3
- // it as agent context, plus an "Attach" menu to link an already-imported issue
4
- // or open the import modal. Mirrors TaskContextDocs.vue; shown only when the
5
- // task-source integration is available. Each linked issue shows its status so
6
- // the structured nature of an issue is visible at a glance.
2
+ // Inspector section for a task block: the tracker issues (Jira, GitHub Issues, …)
3
+ // attached to it as agent context. Attaching uses the SAME inline picker as task
4
+ // creation (source selector + in-repo search + paste-by-reference
5
+ // ContextIssuePicker), NOT the old dropdown that opened a second, page-level
6
+ // "Import an issue…" modal on top of the inspector (stacked page-level modals
7
+ // don't interact here, so the menu appeared to open something with nothing
8
+ // clickable). Because the block already exists, a picked item is
9
+ // imported-when-needed then linked immediately via useContextLinking, scoped to
10
+ // this block's repo. Mirrors TaskContextDocs.vue; shown only when the task-source
11
+ // integration is available. Each linked issue shows its status so the structured
12
+ // nature of an issue is visible at a glance.
7
13
  import type { DropdownMenuItem } from '@nuxt/ui'
8
- import type { Block, TaskSourceKind } from '~/types/domain'
14
+ import type { Block } from '~/types/domain'
15
+ import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
9
16
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
10
17
 
11
18
  const props = defineProps<{ block: Block }>()
@@ -14,43 +21,50 @@ const { t } = useI18n()
14
21
  const tasks = useTasksStore()
15
22
  const ui = useUiStore()
16
23
  const toast = useToast()
24
+ const { linkPending, presentLinkFailures } = useContextLinking()
17
25
 
18
26
  onMounted(() => {
19
27
  tasks.loadTasks().catch(() => {})
20
28
  })
21
29
 
22
30
  const linked = computed(() => tasks.tasksForBlock(props.block.id))
31
+ // Already-linked issues, so the inline picker filters them out / never re-offers them.
32
+ const chosenKeys = computed(() =>
33
+ linked.value.map((issue) =>
34
+ contextKey({ kind: 'task', source: issue.source, externalId: issue.externalId }),
35
+ ),
36
+ )
23
37
 
24
- async function attach(source: TaskSourceKind, externalId: string) {
38
+ const connected = computed(() => tasks.available && tasks.anyOffered)
39
+ // Trackers the user could connect right now to unlock the picker, when none is offered yet.
40
+ const connectableSources = computed(() =>
41
+ tasks.available ? tasks.sources.filter((s) => !s.available) : [],
42
+ )
43
+ const connectMenu = computed<DropdownMenuItem[][]>(() => [
44
+ connectableSources.value.map((s) => ({
45
+ label: s.label,
46
+ icon: s.icon,
47
+ onSelect: () => ui.openTaskConnect(s.source),
48
+ })),
49
+ ])
50
+
51
+ const showPicker = ref(false)
52
+ const linking = ref(false)
53
+
54
+ // The block exists, so import-when-needed then link immediately (vs the add-task
55
+ // flow which stages the pick and links after create). linkPending never throws —
56
+ // it captures each failure with its cause for the shared presenter.
57
+ async function attach(item: PendingContext) {
58
+ if (linking.value) return
59
+ linking.value = true
25
60
  try {
26
- await tasks.linkToBlock(props.block.id, source, externalId)
27
- toast.add({ title: t('tasks.contextIssues.attached'), icon: 'i-lucide-link' })
28
- } catch (e) {
29
- toast.add({
30
- title: t('tasks.contextIssues.attachFailed'),
31
- description: e instanceof Error ? e.message : String(e),
32
- icon: 'i-lucide-triangle-alert',
33
- color: 'error',
34
- })
61
+ const failures = await linkPending(props.block.id, [item])
62
+ if (failures.length) presentLinkFailures(failures, props.block.id)
63
+ else toast.add({ title: t('tasks.contextIssues.attached'), icon: 'i-lucide-link' })
64
+ } finally {
65
+ linking.value = false
35
66
  }
36
67
  }
37
-
38
- const attachMenu = computed<DropdownMenuItem[][]>(() => {
39
- const linkedKeys = new Set(linked.value.map((t) => `${t.source}:${t.externalId}`))
40
- const items: DropdownMenuItem[] = tasks.tasks
41
- .filter((t) => !linkedKeys.has(`${t.source}:${t.externalId}`))
42
- .map((t) => ({
43
- label: `${t.externalId} · ${t.title}`,
44
- icon: tasks.descriptorFor(t.source)?.icon ?? 'i-lucide-square-check',
45
- onSelect: () => attach(t.source, t.externalId),
46
- }))
47
- items.push({
48
- label: t('tasks.contextIssues.importIssue'),
49
- icon: 'i-lucide-file-down',
50
- onSelect: () => ui.openTaskImport(),
51
- })
52
- return [items]
53
- })
54
68
  </script>
55
69
 
56
70
  <template>
@@ -61,29 +75,60 @@ const attachMenu = computed<DropdownMenuItem[][]>(() => {
61
75
  :count="linked.length"
62
76
  >
63
77
  <template #actions>
64
- <UDropdownMenu :items="attachMenu" :content="{ side: 'bottom', align: 'end' }">
65
- <UButton color="neutral" variant="soft" size="xs" icon="i-lucide-plus">{{
66
- t('tasks.contextIssues.attach')
67
- }}</UButton>
78
+ <UButton
79
+ v-if="connected"
80
+ color="neutral"
81
+ variant="soft"
82
+ size="xs"
83
+ :icon="showPicker ? 'i-lucide-x' : 'i-lucide-plus'"
84
+ @click="showPicker = !showPicker"
85
+ >
86
+ {{ showPicker ? t('common.done') : t('tasks.contextIssues.attach') }}
87
+ </UButton>
88
+ <UDropdownMenu
89
+ v-else-if="connectableSources.length > 1"
90
+ :items="connectMenu"
91
+ :content="{ side: 'bottom', align: 'end' }"
92
+ >
93
+ <UButton color="neutral" variant="soft" size="xs" icon="i-lucide-plug">
94
+ {{ t('tasks.contextIssues.connectSource') }}
95
+ </UButton>
68
96
  </UDropdownMenu>
97
+ <UButton
98
+ v-else-if="connectableSources.length === 1"
99
+ color="neutral"
100
+ variant="soft"
101
+ size="xs"
102
+ icon="i-lucide-plug"
103
+ @click="ui.openTaskConnect(connectableSources[0]!.source)"
104
+ >
105
+ {{ t('tasks.contextIssues.connectSourceNamed', { source: connectableSources[0]!.label }) }}
106
+ </UButton>
69
107
  </template>
70
108
 
109
+ <ContextIssuePicker
110
+ v-if="showPicker && connected"
111
+ :chosen-keys="chosenKeys"
112
+ :scope-block-id="block.id"
113
+ @pick="attach"
114
+ />
115
+
71
116
  <div v-if="linked.length" class="space-y-1">
72
117
  <a
73
- v-for="task in linked"
74
- :key="`${task.source}:${task.externalId}`"
75
- :href="task.url"
118
+ v-for="issue in linked"
119
+ :key="`${issue.source}:${issue.externalId}`"
120
+ :href="issue.url"
76
121
  target="_blank"
77
122
  rel="noopener"
78
123
  class="flex items-center gap-1.5 rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-xs text-slate-300 hover:bg-slate-800/60"
79
124
  >
80
125
  <UIcon
81
- :name="tasks.descriptorFor(task.source)?.icon ?? 'i-lucide-square-check'"
126
+ :name="tasks.descriptorFor(issue.source)?.icon ?? 'i-lucide-square-check'"
82
127
  class="h-3.5 w-3.5 shrink-0 text-indigo-400"
83
128
  />
84
- <span class="truncate">{{ task.externalId }} · {{ task.title }}</span>
129
+ <span class="truncate">{{ issue.externalId }} · {{ issue.title }}</span>
85
130
  <UBadge color="neutral" variant="soft" size="xs" class="ms-auto shrink-0">
86
- {{ task.status }}
131
+ {{ issue.status }}
87
132
  </UBadge>
88
133
  </a>
89
134
  </div>
@@ -2,7 +2,7 @@ import { defineStore } from 'pinia'
2
2
  import { computed, ref } from 'vue'
3
3
  import type { AgentKind, Pipeline } from '~/types/domain'
4
4
  import type { ConsensusStepConfig, StepGating } from '~/types/consensus'
5
- import type { StepOptions, TesterQualityConfig } from '@cat-factory/contracts'
5
+ import type { PipelinePurpose, StepOptions, TesterQualityConfig } from '@cat-factory/contracts'
6
6
  import { companionForProducer, uid } from '~/utils/catalog'
7
7
  import { useUpsertList } from '~/composables/useUpsertList'
8
8
  import { useWorkspaceStore } from '~/stores/workspace'
@@ -81,6 +81,13 @@ export const usePipelinesStore = defineStore('pipelines', () => {
81
81
  const draftStepOptions = ref<(StepOptions | null)[]>([])
82
82
  /** Organizational labels for the pipeline being assembled/edited. */
83
83
  const draftLabels = ref<string[]>([])
84
+ /**
85
+ * The use-case classifier of the pipeline being assembled/edited (`build` / `document` /
86
+ * `review` / `research` / `planning`), or null when unclassified. Drives which task pickers
87
+ * offer the saved pipeline and which agent kinds the builder palette shows (a non-`build`
88
+ * purpose hides the Implementation/Testing kinds).
89
+ */
90
+ const draftPurpose = ref<PipelinePurpose | null>(null)
84
91
  const draftName = ref('New pipeline')
85
92
  /** Prose description for the pipeline being assembled/edited (shown in the pickers). */
86
93
  const draftDescription = ref('')
@@ -323,6 +330,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
323
330
  draftTesterQuality.value = []
324
331
  draftStepOptions.value = []
325
332
  draftLabels.value = []
333
+ draftPurpose.value = null
326
334
  draftName.value = 'New pipeline'
327
335
  draftDescription.value = ''
328
336
  editingId.value = null
@@ -342,6 +350,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
342
350
  )
343
351
  draftStepOptions.value = pipeline.agentKinds.map((_, i) => pipeline.stepOptions?.[i] ?? null)
344
352
  draftLabels.value = [...(pipeline.labels ?? [])]
353
+ draftPurpose.value = pipeline.purpose ?? null
345
354
  draftName.value = pipeline.name
346
355
  draftDescription.value = pipeline.description ?? ''
347
356
  editingId.value = pipeline.id
@@ -389,6 +398,10 @@ export const usePipelinesStore = defineStore('pipelines', () => {
389
398
  stepOptions: draftStepOptions.value.map((o) => o ?? null),
390
399
  // Only send labels when there are any.
391
400
  ...(draftLabels.value.length ? { labels: [...draftLabels.value] } : {}),
401
+ // Only send purpose when the pipeline is classified (null ⇒ leave unclassified). Like the
402
+ // legacy per-step arrays, an omitted `purpose` on update reads as "keep existing"; clearing
403
+ // a classification back to unclassified is not a supported edit (every built-in ships one).
404
+ ...(draftPurpose.value ? { purpose: draftPurpose.value } : {}),
392
405
  }
393
406
  }
394
407
 
@@ -459,6 +472,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
459
472
  draftTesterQuality,
460
473
  draftStepOptions,
461
474
  draftLabels,
475
+ draftPurpose,
462
476
  draftName,
463
477
  draftDescription,
464
478
  editingId,
@@ -60,6 +60,7 @@ export type {
60
60
  AgentCategory,
61
61
  CustomAgentKind,
62
62
  Pipeline,
63
+ PipelinePurpose,
63
64
  SpendStatus,
64
65
  BudgetCaps,
65
66
  Workspace,