@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.
- package/app/components/board/AddTaskModal.vue +29 -9
- package/app/components/documents/TaskContextDocs.vue +80 -34
- package/app/components/focus/BlockFocusView.vue +4 -3
- package/app/components/palettes/AgentPalette.vue +13 -5
- package/app/components/panels/inspector/TaskRunSettings.vue +5 -4
- package/app/components/pipeline/PipelineBuilder.vue +55 -3
- package/app/components/tasks/TaskContextIssues.vue +88 -43
- package/app/stores/pipelines.ts +15 -1
- package/app/types/domain.ts +1 -0
- package/app/utils/pipeline.spec.ts +66 -0
- package/app/utils/pipeline.ts +18 -3
- package/i18n/locales/de.json +16 -6
- package/i18n/locales/en.json +16 -6
- package/i18n/locales/es.json +16 -6
- package/i18n/locales/fr.json +16 -6
- package/i18n/locales/he.json +16 -6
- package/i18n/locales/it.json +16 -6
- package/i18n/locales/ja.json +16 -6
- package/i18n/locales/pl.json +16 -6
- package/i18n/locales/tr.json +16 -6
- package/i18n/locales/uk.json +16 -6
- package/package.json +2 -2
|
@@ -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
|
|
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) =>
|
|
295
|
+
pipelines.pipelines.filter((p) =>
|
|
296
|
+
pipelineAllowedForManualStart(p, frame.value, board.blocks, taskType.value),
|
|
297
|
+
),
|
|
294
298
|
)
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
8
|
-
//
|
|
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
|
-
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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,
|
|
32
|
-
// pipelines (a manual run of one is refused server-side) —
|
|
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
|
|
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,
|
|
137
|
-
// pipelines (the task's manual Run control can't start one) —
|
|
138
|
-
// (
|
|
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
|
|
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, …)
|
|
3
|
-
// it as agent context
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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="
|
|
74
|
-
:key="`${
|
|
75
|
-
:href="
|
|
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(
|
|
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">{{
|
|
129
|
+
<span class="truncate">{{ issue.externalId }} · {{ issue.title }}</span>
|
|
85
130
|
<UBadge color="neutral" variant="soft" size="xs" class="ms-auto shrink-0">
|
|
86
|
-
{{
|
|
131
|
+
{{ issue.status }}
|
|
87
132
|
</UBadge>
|
|
88
133
|
</a>
|
|
89
134
|
</div>
|
package/app/stores/pipelines.ts
CHANGED
|
@@ -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,
|