@cat-factory/app 0.139.0 → 0.141.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.
- package/app/components/board/AddTaskModal.vue +41 -48
- package/app/components/documents/ContextDocumentPicker.vue +89 -70
- package/app/components/documents/RepoContextDocPicker.vue +277 -0
- package/app/components/focus/BlockFocusView.vue +4 -3
- package/app/components/github/RepoTreeBrowser.vue +19 -10
- package/app/components/palettes/AgentPalette.vue +13 -5
- package/app/components/panels/inspector/TaskRunSettings.vue +21 -27
- package/app/components/pipeline/PipelineBuilder.vue +65 -3
- package/app/components/pipeline/PipelinePicker.vue +120 -0
- package/app/components/pipeline/PipelinePreview.vue +49 -0
- package/app/composables/api/github.ts +8 -0
- package/app/composables/useContextLinking.spec.ts +138 -0
- package/app/composables/useContextLinking.ts +120 -8
- package/app/composables/useCopyToClipboard.spec.ts +27 -0
- package/app/composables/useCopyToClipboard.ts +17 -1
- package/app/stores/github.ts +24 -0
- package/app/stores/pipelines.ts +24 -1
- package/app/types/domain.ts +1 -0
- package/app/utils/pipeline.spec.ts +66 -0
- package/app/utils/pipeline.ts +43 -4
- package/i18n/locales/de.json +31 -1
- package/i18n/locales/en.json +31 -1
- package/i18n/locales/es.json +31 -1
- package/i18n/locales/fr.json +31 -1
- package/i18n/locales/he.json +31 -1
- package/i18n/locales/it.json +31 -1
- package/i18n/locales/ja.json +31 -1
- package/i18n/locales/pl.json +31 -1
- package/i18n/locales/tr.json +31 -1
- package/i18n/locales/uk.json +31 -1
- package/package.json +2 -2
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
// via `v-model`. The component owns its own navigation/loading state so callers
|
|
8
8
|
// just bind a repo id + mode; it self-loads on mount and when those change.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
10
|
+
// Both modes additionally support `multiple`: instead of the single `v-model`
|
|
11
11
|
// path, the caller passes the current `selectedPaths` (a cart) + `addedPaths`
|
|
12
|
-
// (
|
|
13
|
-
// event to add/remove a
|
|
14
|
-
//
|
|
15
|
-
// away never drops
|
|
12
|
+
// (paths already chosen elsewhere, shown disabled) and handles the `toggle`
|
|
13
|
+
// event to add/remove a path. In `dir` mode this accumulates service directories
|
|
14
|
+
// from ANY parent folder (the monorepo add flow); in `file` mode it accumulates
|
|
15
|
+
// context-document files from anywhere in the tree — navigating away never drops
|
|
16
|
+
// earlier picks.
|
|
16
17
|
import type { RepoTreeEntry } from '~/types/domain'
|
|
17
18
|
|
|
18
19
|
const props = withDefaults(
|
|
@@ -23,11 +24,11 @@ const props = withDefaults(
|
|
|
23
24
|
modelValue?: string
|
|
24
25
|
/** Directory to open at (e.g. a monorepo service's subdirectory). */
|
|
25
26
|
startPath?: string
|
|
26
|
-
/**
|
|
27
|
+
/** Accumulate a set of picks (via `selectedPaths`/`toggle`) instead of one. Either mode. */
|
|
27
28
|
multiple?: boolean
|
|
28
|
-
/** `
|
|
29
|
+
/** `multiple`: the current cart of picked paths (repo-root-relative). */
|
|
29
30
|
selectedPaths?: string[]
|
|
30
|
-
/** `
|
|
31
|
+
/** `multiple`: paths already chosen elsewhere — listed but not selectable. */
|
|
31
32
|
addedPaths?: string[]
|
|
32
33
|
}>(),
|
|
33
34
|
{ mode: 'dir', startPath: '', multiple: false, selectedPaths: () => [], addedPaths: () => [] },
|
|
@@ -182,14 +183,22 @@ watch(
|
|
|
182
183
|
<button
|
|
183
184
|
type="button"
|
|
184
185
|
class="flex items-center gap-2 truncate text-sm hover:text-primary-400"
|
|
185
|
-
:class="
|
|
186
|
+
:class="isPicked(entry.path) ? 'text-primary-400' : 'text-slate-300'"
|
|
187
|
+
:disabled="isAdded(entry.path)"
|
|
186
188
|
@click="pick(entry.path)"
|
|
187
189
|
>
|
|
188
190
|
<UIcon name="i-lucide-file" class="h-4 w-4 shrink-0 text-slate-400" />
|
|
189
191
|
<span class="truncate">{{ entry.name }}</span>
|
|
190
192
|
</button>
|
|
193
|
+
<span
|
|
194
|
+
v-if="isAdded(entry.path)"
|
|
195
|
+
class="flex shrink-0 items-center gap-1 text-xs text-slate-500"
|
|
196
|
+
>
|
|
197
|
+
<UIcon name="i-lucide-check" class="h-3.5 w-3.5" />
|
|
198
|
+
{{ t('github.repoTree.added') }}
|
|
199
|
+
</span>
|
|
191
200
|
<UIcon
|
|
192
|
-
v-if="
|
|
201
|
+
v-else-if="isPicked(entry.path)"
|
|
193
202
|
name="i-lucide-check"
|
|
194
203
|
class="h-4 w-4 shrink-0 text-primary-400"
|
|
195
204
|
/>
|
|
@@ -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,29 +133,16 @@ 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
|
-
const pipelineMenu = computed(() => [
|
|
146
|
-
[
|
|
147
|
-
{
|
|
148
|
-
label: t('inspector.runSettings.noDefault'),
|
|
149
|
-
icon: 'i-lucide-rotate-ccw',
|
|
150
|
-
onSelect: () => setPipeline(''),
|
|
151
|
-
},
|
|
152
|
-
...selectablePipelines.value.map((p) => ({
|
|
153
|
-
label: p.name,
|
|
154
|
-
icon: 'i-lucide-workflow',
|
|
155
|
-
onSelect: () => setPipeline(p.id),
|
|
156
|
-
})),
|
|
157
|
-
],
|
|
158
|
-
])
|
|
159
146
|
function setPipeline(id: string) {
|
|
160
147
|
board.updateBlock(props.block.id, { pipelineId: id })
|
|
161
148
|
}
|
|
@@ -271,15 +258,22 @@ const technicalLabel = computed(() => {
|
|
|
271
258
|
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
272
259
|
{{ t('inspector.runSettings.pipeline') }}
|
|
273
260
|
</span>
|
|
274
|
-
<
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
261
|
+
<PipelinePicker
|
|
262
|
+
:model-value="block.pipelineId ?? ''"
|
|
263
|
+
:options="selectablePipelines"
|
|
264
|
+
:none-label="t('inspector.runSettings.noDefault')"
|
|
265
|
+
@update:model-value="setPipeline"
|
|
266
|
+
>
|
|
267
|
+
<template #trigger>
|
|
268
|
+
<UButton
|
|
269
|
+
size="xs"
|
|
270
|
+
variant="ghost"
|
|
271
|
+
color="neutral"
|
|
272
|
+
icon="i-lucide-workflow"
|
|
273
|
+
trailing-icon="i-lucide-chevron-down"
|
|
274
|
+
/>
|
|
275
|
+
</template>
|
|
276
|
+
</PipelinePicker>
|
|
283
277
|
</div>
|
|
284
278
|
<div v-if="selectedPipeline" class="flex items-center gap-1">
|
|
285
279
|
<UBadge
|
|
@@ -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,34 @@ 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
|
+
|
|
357
|
+
<!-- Description: the prose summary shown next to the step list in the pipeline pickers. -->
|
|
358
|
+
<UTextarea
|
|
359
|
+
v-model="pipelines.draftDescription"
|
|
360
|
+
:placeholder="t('pipeline.builder.descriptionPlaceholder')"
|
|
361
|
+
:rows="2"
|
|
362
|
+
autoresize
|
|
363
|
+
size="sm"
|
|
364
|
+
class="mb-2 w-full"
|
|
365
|
+
/>
|
|
366
|
+
|
|
313
367
|
<!-- Labels: organize the pipeline in the library (filter/search). -->
|
|
314
368
|
<div class="mb-3 flex flex-wrap items-center gap-1.5">
|
|
315
369
|
<UBadge
|
|
@@ -350,6 +404,14 @@ async function clone(p: Pipeline) {
|
|
|
350
404
|
{{ t('pipeline.builder.skillNeedsPick') }}
|
|
351
405
|
</p>
|
|
352
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
|
+
|
|
353
415
|
<div
|
|
354
416
|
v-if="pipelines.draft.length === 0"
|
|
355
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"
|
|
@@ -1005,7 +1067,7 @@ async function clone(p: Pipeline) {
|
|
|
1005
1067
|
color="primary"
|
|
1006
1068
|
icon="i-lucide-save"
|
|
1007
1069
|
size="sm"
|
|
1008
|
-
:disabled="pipelines.draft.length === 0"
|
|
1070
|
+
:disabled="pipelines.draft.length === 0 || stepsDisallowedByPurpose.length > 0"
|
|
1009
1071
|
@click="save"
|
|
1010
1072
|
>
|
|
1011
1073
|
{{ pipelines.editingId ? t('pipeline.builder.update') : t('pipeline.builder.save') }}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The rich pipeline picker used wherever a pipeline is chosen (add-task modal, inspector run
|
|
3
|
+
// settings). A master–detail popover: the left column lists the selectable pipelines (plus a
|
|
4
|
+
// "none / choose at run time" row), and hovering a row reveals that pipeline's full preview — its
|
|
5
|
+
// description + the ordered agent steps — in the right column, so a user sees exactly what a
|
|
6
|
+
// pipeline does before picking it. The trigger is customizable via the `#trigger` slot (the
|
|
7
|
+
// inspector uses a bare icon button; the modal a full-width labelled one).
|
|
8
|
+
import { computed, ref } from 'vue'
|
|
9
|
+
import type { Pipeline } from '~/types/domain'
|
|
10
|
+
|
|
11
|
+
const props = withDefaults(
|
|
12
|
+
defineProps<{
|
|
13
|
+
/** Selected pipeline id, or '' for the "none" option. */
|
|
14
|
+
modelValue: string
|
|
15
|
+
/** The pipelines offered (already filtered for the surface, e.g. manual-start allowed). */
|
|
16
|
+
options: Pipeline[]
|
|
17
|
+
/** Label for the "none" row (e.g. "Choose at run time" / "No default"). */
|
|
18
|
+
noneLabel: string
|
|
19
|
+
/** Extra classes for the default trigger button (e.g. full-width in the modal). */
|
|
20
|
+
triggerClass?: string
|
|
21
|
+
}>(),
|
|
22
|
+
{ triggerClass: '' },
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const emit = defineEmits<{ 'update:modelValue': [string] }>()
|
|
26
|
+
const { t } = useI18n()
|
|
27
|
+
|
|
28
|
+
const open = ref(false)
|
|
29
|
+
// The row currently hovered, driving the right-column preview. `undefined` ⇒ fall back to the
|
|
30
|
+
// selected pipeline; the sentinel '' means the "none" row is hovered (show the none hint).
|
|
31
|
+
const hoverId = ref<string | undefined>(undefined)
|
|
32
|
+
|
|
33
|
+
const selected = computed(() => props.options.find((p) => p.id === props.modelValue))
|
|
34
|
+
const triggerLabel = computed(() => selected.value?.name ?? props.noneLabel)
|
|
35
|
+
|
|
36
|
+
/** The pipeline the right pane previews: the hovered row, else the current selection. */
|
|
37
|
+
const previewPipeline = computed<Pipeline | null>(() => {
|
|
38
|
+
const id = hoverId.value ?? props.modelValue
|
|
39
|
+
return id ? (props.options.find((p) => p.id === id) ?? null) : null
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
function choose(id: string) {
|
|
43
|
+
emit('update:modelValue', id)
|
|
44
|
+
open.value = false
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<UPopover v-model:open="open" :content="{ align: 'start' }">
|
|
50
|
+
<slot name="trigger" :label="triggerLabel">
|
|
51
|
+
<UButton
|
|
52
|
+
color="neutral"
|
|
53
|
+
variant="subtle"
|
|
54
|
+
size="sm"
|
|
55
|
+
icon="i-lucide-workflow"
|
|
56
|
+
trailing-icon="i-lucide-chevron-down"
|
|
57
|
+
:class="triggerClass"
|
|
58
|
+
data-testid="pipeline-picker-trigger"
|
|
59
|
+
>
|
|
60
|
+
{{ triggerLabel }}
|
|
61
|
+
</UButton>
|
|
62
|
+
</slot>
|
|
63
|
+
|
|
64
|
+
<template #content>
|
|
65
|
+
<div
|
|
66
|
+
class="flex max-h-[24rem] w-[min(44rem,94vw)]"
|
|
67
|
+
data-testid="pipeline-picker-panel"
|
|
68
|
+
@mouseleave="hoverId = undefined"
|
|
69
|
+
>
|
|
70
|
+
<!-- left: selectable options -->
|
|
71
|
+
<ul class="w-1/2 shrink-0 overflow-y-auto border-e border-slate-800 p-1">
|
|
72
|
+
<li>
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm hover:bg-slate-800/60"
|
|
76
|
+
:class="modelValue ? 'text-slate-300' : 'text-slate-100'"
|
|
77
|
+
data-testid="pipeline-option-none"
|
|
78
|
+
@mouseenter="hoverId = ''"
|
|
79
|
+
@click="choose('')"
|
|
80
|
+
>
|
|
81
|
+
<UIcon name="i-lucide-rotate-ccw" class="h-4 w-4 shrink-0 text-slate-400" />
|
|
82
|
+
<span class="flex-1 truncate">{{ noneLabel }}</span>
|
|
83
|
+
<UIcon
|
|
84
|
+
v-if="!modelValue"
|
|
85
|
+
name="i-lucide-check"
|
|
86
|
+
class="h-4 w-4 shrink-0 text-primary-400"
|
|
87
|
+
/>
|
|
88
|
+
</button>
|
|
89
|
+
</li>
|
|
90
|
+
<li v-for="p in options" :key="p.id">
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm hover:bg-slate-800/60"
|
|
94
|
+
:class="modelValue === p.id ? 'text-slate-100' : 'text-slate-300'"
|
|
95
|
+
:data-testid="`pipeline-option-${p.id}`"
|
|
96
|
+
@mouseenter="hoverId = p.id"
|
|
97
|
+
@click="choose(p.id)"
|
|
98
|
+
>
|
|
99
|
+
<UIcon name="i-lucide-workflow" class="h-4 w-4 shrink-0 text-slate-400" />
|
|
100
|
+
<span class="flex-1 truncate">{{ p.name }}</span>
|
|
101
|
+
<UIcon
|
|
102
|
+
v-if="modelValue === p.id"
|
|
103
|
+
name="i-lucide-check"
|
|
104
|
+
class="h-4 w-4 shrink-0 text-primary-400"
|
|
105
|
+
/>
|
|
106
|
+
</button>
|
|
107
|
+
</li>
|
|
108
|
+
</ul>
|
|
109
|
+
|
|
110
|
+
<!-- right: preview of the hovered (or selected) pipeline -->
|
|
111
|
+
<div class="w-1/2 overflow-y-auto p-3">
|
|
112
|
+
<PipelinePreview v-if="previewPipeline" :pipeline="previewPipeline" />
|
|
113
|
+
<div v-else class="text-[12px] leading-snug text-slate-500">
|
|
114
|
+
{{ t('pipeline.picker.noneHint') }}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</template>
|
|
119
|
+
</UPopover>
|
|
120
|
+
</template>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// A compact, read-only summary of a pipeline: its name, prose description (when authored), and the
|
|
3
|
+
// ordered list of enabled agent steps rendered as icon+label chips (a human-gated step is flagged).
|
|
4
|
+
// Shared by the pipeline pickers' hover preview so "what a pipeline consists of" is explained the
|
|
5
|
+
// same way everywhere. Resolves each step's display metadata through the single `agentKindMeta`
|
|
6
|
+
// path (via <AgentKindIcon>), so a system/custom kind can never blow up the renderer.
|
|
7
|
+
import { computed } from 'vue'
|
|
8
|
+
import type { Pipeline } from '~/types/domain'
|
|
9
|
+
import { pipelineDisplaySteps } from '~/utils/pipeline'
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{ pipeline: Pipeline }>()
|
|
12
|
+
const { t } = useI18n()
|
|
13
|
+
|
|
14
|
+
const steps = computed(() => pipelineDisplaySteps(props.pipeline))
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<div class="space-y-2" data-testid="pipeline-preview">
|
|
19
|
+
<div class="text-sm font-semibold text-slate-100">{{ pipeline.name }}</div>
|
|
20
|
+
<p
|
|
21
|
+
v-if="pipeline.description"
|
|
22
|
+
class="text-[12px] leading-snug text-slate-400"
|
|
23
|
+
data-testid="pipeline-preview-description"
|
|
24
|
+
>
|
|
25
|
+
{{ pipeline.description }}
|
|
26
|
+
</p>
|
|
27
|
+
<div>
|
|
28
|
+
<div class="mb-1 flex items-center gap-1 text-[10px] uppercase tracking-wide text-slate-500">
|
|
29
|
+
<UIcon name="i-lucide-workflow" class="h-3 w-3" />
|
|
30
|
+
{{ t('pipeline.preview.stepCount', { count: steps.length }, steps.length) }}
|
|
31
|
+
</div>
|
|
32
|
+
<ol class="flex flex-wrap items-center gap-1">
|
|
33
|
+
<li
|
|
34
|
+
v-for="(s, i) in steps"
|
|
35
|
+
:key="i"
|
|
36
|
+
class="inline-flex items-center gap-1 rounded bg-slate-800/70 px-1.5 py-0.5"
|
|
37
|
+
>
|
|
38
|
+
<AgentKindIcon :kind="s.kind" show-label icon-class="h-3.5 w-3.5" />
|
|
39
|
+
<UIcon
|
|
40
|
+
v-if="s.gated"
|
|
41
|
+
name="i-lucide-shield-check"
|
|
42
|
+
class="h-3 w-3 text-amber-400"
|
|
43
|
+
:title="t('pipeline.preview.gated')"
|
|
44
|
+
/>
|
|
45
|
+
</li>
|
|
46
|
+
</ol>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
listGitHubIssuesContract,
|
|
14
14
|
listGitHubPullsContract,
|
|
15
15
|
listGitHubReposContract,
|
|
16
|
+
listGitHubRepoFilesContract,
|
|
16
17
|
listGitHubRepoTreeContract,
|
|
17
18
|
mergeGitHubPullRequestContract,
|
|
18
19
|
openGitHubPullRequestContract,
|
|
@@ -92,6 +93,13 @@ export function githubApi({ send, ws }: ApiContext) {
|
|
|
92
93
|
queryParams: { path },
|
|
93
94
|
}),
|
|
94
95
|
|
|
96
|
+
// List every file in a repo (whole tree, one recursive read) for file-path search.
|
|
97
|
+
listGitHubRepoFiles: (workspaceId: string, repoGithubId: number) =>
|
|
98
|
+
send(listGitHubRepoFilesContract, {
|
|
99
|
+
pathPrefix: ws(workspaceId),
|
|
100
|
+
pathParams: { repoGithubId: String(repoGithubId) },
|
|
101
|
+
}),
|
|
102
|
+
|
|
95
103
|
listGitHubBranches: (workspaceId: string, repoGithubId: number) =>
|
|
96
104
|
send(listGitHubBranchesContract, {
|
|
97
105
|
pathPrefix: ws(workspaceId),
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
buildLinkFailureReport,
|
|
4
|
+
contextKey,
|
|
5
|
+
type LinkFailure,
|
|
6
|
+
type PendingContext,
|
|
7
|
+
useContextLinking,
|
|
8
|
+
} from '~/composables/useContextLinking'
|
|
9
|
+
|
|
10
|
+
// The context-linking path used to swallow every attachment failure into a bare count.
|
|
11
|
+
// `buildLinkFailureReport` is the pure diagnostic dump the "Copy details" toast action
|
|
12
|
+
// puts on the clipboard, so it must carry the item coordinates, the HTTP status + backend
|
|
13
|
+
// code, and the server's message — the exact context a bug report needs.
|
|
14
|
+
|
|
15
|
+
function item(overrides: Partial<PendingContext> = {}): PendingContext {
|
|
16
|
+
return {
|
|
17
|
+
kind: 'document',
|
|
18
|
+
source: 'github',
|
|
19
|
+
externalId: 'acme/repo:docs/x.md',
|
|
20
|
+
title: 'x.md',
|
|
21
|
+
needsImport: true,
|
|
22
|
+
...overrides,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('buildLinkFailureReport', () => {
|
|
27
|
+
it('captures every failure with its coordinates, status, code, and message', () => {
|
|
28
|
+
const failures: LinkFailure[] = [
|
|
29
|
+
{
|
|
30
|
+
item: item(),
|
|
31
|
+
message: 'GitHub denied access to "docs/x.md" in acme/repo (HTTP 403).',
|
|
32
|
+
status: 403,
|
|
33
|
+
code: 'conflict',
|
|
34
|
+
},
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
const report = buildLinkFailureReport(failures, {
|
|
38
|
+
workspaceId: 'ws_1',
|
|
39
|
+
blockId: 'blk_1',
|
|
40
|
+
when: '2026-07-19T00:00:00.000Z',
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
expect(report).toContain('Context link failures: 1')
|
|
44
|
+
expect(report).toContain('workspace: ws_1')
|
|
45
|
+
expect(report).toContain('block: blk_1')
|
|
46
|
+
expect(report).toContain('document/github: acme/repo:docs/x.md')
|
|
47
|
+
expect(report).toContain('status: 403')
|
|
48
|
+
expect(report).toContain('code: conflict')
|
|
49
|
+
expect(report).toContain('error: GitHub denied access')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('omits absent optional fields (no status/code/context)', () => {
|
|
53
|
+
const report = buildLinkFailureReport([{ item: item(), message: 'network error' }])
|
|
54
|
+
expect(report).toContain('Context link failures: 1')
|
|
55
|
+
expect(report).not.toContain('status:')
|
|
56
|
+
expect(report).not.toContain('code:')
|
|
57
|
+
expect(report).not.toContain('workspace:')
|
|
58
|
+
expect(report).toContain('error: network error')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('dumps the backend details bag, distinguishing the upstream status from the HTTP status', () => {
|
|
62
|
+
const report = buildLinkFailureReport([
|
|
63
|
+
{
|
|
64
|
+
item: item(),
|
|
65
|
+
message: 'GitHub denied access to "docs/x.md" in acme/repo (HTTP 403).',
|
|
66
|
+
status: 409,
|
|
67
|
+
code: 'conflict',
|
|
68
|
+
details: { owner: 'acme', repo: 'repo', path: 'docs/x.md', status: 403 },
|
|
69
|
+
},
|
|
70
|
+
])
|
|
71
|
+
// The mapped HTTP status and the upstream GitHub status are both present, unambiguously.
|
|
72
|
+
expect(report).toContain('status: 409')
|
|
73
|
+
expect(report).toContain('details.status: 403')
|
|
74
|
+
expect(report).toContain('details.owner: acme')
|
|
75
|
+
expect(report).toContain('details.path: docs/x.md')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('contextKey', () => {
|
|
80
|
+
it('is stable and distinguishes kind/source/externalId', () => {
|
|
81
|
+
expect(contextKey(item())).toBe('document:github:acme/repo:docs/x.md')
|
|
82
|
+
expect(contextKey(item({ kind: 'task', source: 'github' }))).toBe(
|
|
83
|
+
'task:github:acme/repo:docs/x.md',
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('presentLinkFailures', () => {
|
|
89
|
+
// Stub the Nuxt auto-imports `useContextLinking` pulls in, so the toast-orchestration
|
|
90
|
+
// side of the composable can be exercised without a full Nuxt runtime.
|
|
91
|
+
function stub() {
|
|
92
|
+
const add = vi.fn()
|
|
93
|
+
// `copyAction` echoes the text it would copy so we can assert the report content.
|
|
94
|
+
const copyAction = vi.fn((text: string) => ({ label: 'copy', text, onClick: () => {} }))
|
|
95
|
+
vi.stubGlobal('useDocumentsStore', () => ({}))
|
|
96
|
+
vi.stubGlobal('useTasksStore', () => ({}))
|
|
97
|
+
vi.stubGlobal('useWorkspaceStore', () => ({ workspaceId: 'ws_1' }))
|
|
98
|
+
vi.stubGlobal('useToast', () => ({ add }))
|
|
99
|
+
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
|
100
|
+
vi.stubGlobal('useCopyToClipboard', () => ({ copyAction }))
|
|
101
|
+
return { add, copyAction }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
105
|
+
|
|
106
|
+
it('is a no-op when nothing failed', () => {
|
|
107
|
+
const { add } = stub()
|
|
108
|
+
useContextLinking().presentLinkFailures([])
|
|
109
|
+
expect(add).not.toHaveBeenCalled()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('raises one sticky, actionable toast whose Copy action carries the full report', () => {
|
|
113
|
+
const { add, copyAction } = stub()
|
|
114
|
+
const failures: LinkFailure[] = [
|
|
115
|
+
{
|
|
116
|
+
item: item(),
|
|
117
|
+
message: 'GitHub denied access to "docs/x.md" in acme/repo (HTTP 403).',
|
|
118
|
+
status: 409,
|
|
119
|
+
code: 'conflict',
|
|
120
|
+
details: { owner: 'acme', repo: 'repo', path: 'docs/x.md', status: 403 },
|
|
121
|
+
},
|
|
122
|
+
]
|
|
123
|
+
useContextLinking().presentLinkFailures(failures, 'blk_1')
|
|
124
|
+
|
|
125
|
+
expect(add).toHaveBeenCalledTimes(1)
|
|
126
|
+
const toast = add.mock.calls[0]![0]
|
|
127
|
+
// Sticky so the cause stays readable, titled by the count key, and per-item reason shown.
|
|
128
|
+
expect(toast.title).toBe('board.addTask.linkFailed')
|
|
129
|
+
expect(toast.duration).toBe(0)
|
|
130
|
+
expect(toast.description).toContain('GitHub denied access')
|
|
131
|
+
expect(toast.actions).toHaveLength(1)
|
|
132
|
+
// The Copy action's payload is the full diagnostic report (block + upstream status).
|
|
133
|
+
const report = copyAction.mock.calls[0]![0]
|
|
134
|
+
expect(report).toContain('block: blk_1')
|
|
135
|
+
expect(report).toContain('details.status: 403')
|
|
136
|
+
expect(toast.actions[0]).toMatchObject({ text: report })
|
|
137
|
+
})
|
|
138
|
+
})
|