@cat-factory/app 0.163.0 → 0.164.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/README.md CHANGED
@@ -77,7 +77,10 @@ example ships in [`deploy/frontend`](../../deploy/frontend) (the `acme:security`
77
77
  structure, dependencies, model + fragment picker, live execution, and linked
78
78
  docs/issues/scenarios. Decisions resolve via `DecisionModal`.
79
79
  - **Pipeline builder** (`components/pipeline`) — assemble/edit agent chains and
80
- watch `PipelineProgress`.
80
+ watch `PipelineProgress`. `PipelinePicker` (+ its `PipelinePreview` pane) is the
81
+ single way a pipeline is chosen anywhere — add-task, run settings, the recurring
82
+ schedule, the focus view's Run menu — so every surface explains a pipeline by the
83
+ ordered steps it will run rather than by its name alone.
81
84
  - **Integrations** — modals/panels for `github` (the source-control panel, shared
82
85
  by every VCS provider), `vcs` (the GitLab personal-access-token connect),
83
86
  `bootstrap`, `documents`, `tasks`, `requirements` (review), `scenarios`
@@ -70,17 +70,7 @@ function defaultRecurrence(): Recurrence {
70
70
  const selectablePipelines = computed(() =>
71
71
  pipelines.pipelines.filter((p) => pipelineAllowedForSchedule(p, frame.value, board.blocks)),
72
72
  )
73
- const pipelineMenu = computed(() => [
74
- selectablePipelines.value.map((p) => ({
75
- label: p.name,
76
- icon: 'i-lucide-workflow',
77
- onSelect: () => (pipelineId.value = p.id),
78
- })),
79
- ])
80
73
  const selectedPipeline = computed(() => pipelines.getPipeline(pipelineId.value))
81
- const selectedPipelineLabel = computed(
82
- () => selectedPipeline.value?.name ?? t('board.recurring.pickPipeline'),
83
- )
84
74
 
85
75
  // Infer the template from the picked pipeline so the backend seeds the right block
86
76
  // description (and so we know to show the tracker config).
@@ -269,19 +259,16 @@ async function add() {
269
259
  />
270
260
  </UFormField>
271
261
 
262
+ <!-- The rich picker, not a name-only menu: which steps a schedule will run unattended,
263
+ every time it fires, is exactly what the person setting it up needs to see. A schedule
264
+ must name a pipeline, so there is no "none" row. -->
272
265
  <UFormField :label="t('board.recurring.pipeline')" required>
273
- <UDropdownMenu :items="pipelineMenu" class="w-full">
274
- <UButton
275
- color="neutral"
276
- variant="subtle"
277
- size="sm"
278
- icon="i-lucide-workflow"
279
- trailing-icon="i-lucide-chevron-down"
280
- class="w-full justify-between"
281
- >
282
- {{ selectedPipelineLabel }}
283
- </UButton>
284
- </UDropdownMenu>
266
+ <PipelinePicker
267
+ v-model="pipelineId"
268
+ :options="selectablePipelines"
269
+ :placeholder="t('board.recurring.pickPipeline')"
270
+ trigger-class="w-full justify-between"
271
+ />
285
272
  </UFormField>
286
273
 
287
274
  <UFormField :label="t('board.recurring.prompt')">
@@ -31,17 +31,19 @@ const deps = computed(() =>
31
31
  // Hide UI-testing pipelines when this block's frame has no UI to exercise, `'recurring'`-only
32
32
  // pipelines (a manual run of one is refused server-side), and — for a `document` task — every
33
33
  // non-document pipeline (per the `purpose` classifier) — see the backend gate.
34
- const runMenu = computed(() => {
34
+ const runOptions = computed(() => {
35
35
  const frame = block.value ? board.serviceOf(block.value) : undefined
36
- return pipelines.pipelines
37
- .filter((p) => pipelineAllowedForManualStart(p, frame, board.blocks, block.value?.taskType))
38
- .map((p) => ({
39
- label: p.name,
40
- icon: 'i-lucide-play',
41
- onSelect: () => block.value && execution.start(block.value.id, p),
42
- }))
36
+ return pipelines.pipelines.filter((p) =>
37
+ pipelineAllowedForManualStart(p, frame, board.blocks, block.value?.taskType),
38
+ )
43
39
  })
44
40
 
41
+ /** Start the picked pipeline immediately — the Run menu chooses an ACTION, it stores no default. */
42
+ function runPipeline(id: string) {
43
+ const pipeline = pipelines.getPipeline(id)
44
+ if (pipeline && block.value) void execution.start(block.value.id, pipeline)
45
+ }
46
+
45
47
  function close() {
46
48
  ui.focus(null)
47
49
  }
@@ -97,17 +99,21 @@ function openApprovalFor(approvalId: string) {
97
99
  {{ statusMeta.label }}
98
100
  </UBadge>
99
101
  <div class="ms-auto flex items-center gap-2">
100
- <UDropdownMenu :items="runMenu">
101
- <UButton
102
- color="primary"
103
- variant="soft"
104
- size="sm"
105
- icon="i-lucide-play"
106
- trailing-icon="i-lucide-chevron-down"
107
- >
108
- {{ instance ? t('focus.rerunPipeline') : t('focus.runPipeline') }}
109
- </UButton>
110
- </UDropdownMenu>
102
+ <!-- The rich picker rather than a list of names: the run starts the moment a row is
103
+ clicked, so the preview is the only chance to see which agents it will run. -->
104
+ <PipelinePicker model-value="" :options="runOptions" @update:model-value="runPipeline">
105
+ <template #trigger>
106
+ <UButton
107
+ color="primary"
108
+ variant="soft"
109
+ size="sm"
110
+ icon="i-lucide-play"
111
+ trailing-icon="i-lucide-chevron-down"
112
+ >
113
+ {{ instance ? t('focus.rerunPipeline') : t('focus.runPipeline') }}
114
+ </UButton>
115
+ </template>
116
+ </PipelinePicker>
111
117
  <IconButton
112
118
  icon="i-lucide-x"
113
119
  color="neutral"
@@ -1,25 +1,36 @@
1
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).
2
+ // The rich pipeline picker used wherever a pipeline is chosen — as a stored default (add-task
3
+ // modal, inspector run settings, the recurring-schedule modal) or as an immediate action (the
4
+ // focus view's Run menu). A master–detail popover: the left column lists the selectable pipelines
5
+ // (plus an optional "none / choose at run time" row), and hovering a row reveals that pipeline's
6
+ // full preview its description and every step it will run, in order in the right column, so a
7
+ // user sees exactly what a pipeline does before picking it. The trigger is customizable via the
8
+ // `#trigger` slot (the inspector uses a bare icon button; the modal a full-width labelled one).
8
9
  import { computed, ref } from 'vue'
9
10
  import type { Pipeline } from '~/types/domain'
10
11
 
11
12
  const props = withDefaults(
12
13
  defineProps<{
13
- /** Selected pipeline id, or '' for the "none" option. */
14
+ /** Selected pipeline id, or '' for none. An action-mode caller passes a constant ''. */
14
15
  modelValue: string
15
16
  /** The pipelines offered (already filtered for the surface, e.g. manual-start allowed). */
16
17
  options: Pipeline[]
17
- /** Label for the "none" row (e.g. "Choose at run time" / "No default"). */
18
- noneLabel: string
18
+ /**
19
+ * Label for the "none" row (e.g. "Choose at run time" / "No default"). Omit where picking
20
+ * nothing is not a valid outcome — a schedule needs a pipeline, and a Run menu acts on the
21
+ * chosen row — and the row is left out entirely.
22
+ */
23
+ noneLabel?: string
24
+ /**
25
+ * Default-trigger text while nothing is selected. Defaults to {@link noneLabel}, which reads
26
+ * correctly when the "none" row IS the empty state; a surface without that row supplies its
27
+ * own prompt ("Pick a pipeline").
28
+ */
29
+ placeholder?: string
19
30
  /** Extra classes for the default trigger button (e.g. full-width in the modal). */
20
31
  triggerClass?: string
21
32
  }>(),
22
- { triggerClass: '' },
33
+ { noneLabel: undefined, placeholder: undefined, triggerClass: '' },
23
34
  )
24
35
 
25
36
  const emit = defineEmits<{ 'update:modelValue': [string] }>()
@@ -31,7 +42,9 @@ const open = ref(false)
31
42
  const hoverId = ref<string | undefined>(undefined)
32
43
 
33
44
  const selected = computed(() => props.options.find((p) => p.id === props.modelValue))
34
- const triggerLabel = computed(() => selected.value?.name ?? props.noneLabel)
45
+ const triggerLabel = computed(
46
+ () => selected.value?.name ?? props.placeholder ?? props.noneLabel ?? '',
47
+ )
35
48
 
36
49
  /** The pipeline the right pane previews: the hovered row, else the current selection. */
37
50
  const previewPipeline = computed<Pipeline | null>(() => {
@@ -39,6 +52,17 @@ const previewPipeline = computed<Pipeline | null>(() => {
39
52
  return id ? (props.options.find((p) => p.id === id) ?? null) : null
40
53
  })
41
54
 
55
+ /**
56
+ * With no pipeline to preview the pane explains WHY it's empty, and the two reasons differ: the
57
+ * "none" row is a real choice worth describing, while a picker without one is simply waiting for
58
+ * a hover. Sharing one hint would tell a Run menu it has no default pipeline, which is nonsense.
59
+ */
60
+ const emptyHint = computed(() =>
61
+ props.noneLabel !== undefined && (hoverId.value ?? props.modelValue) === ''
62
+ ? t('pipeline.picker.noneHint')
63
+ : t('pipeline.picker.hoverHint'),
64
+ )
65
+
42
66
  function choose(id: string) {
43
67
  emit('update:modelValue', id)
44
68
  open.value = false
@@ -63,13 +87,13 @@ function choose(id: string) {
63
87
 
64
88
  <template #content>
65
89
  <div
66
- class="flex max-h-[24rem] w-[min(44rem,94vw)]"
90
+ class="flex max-h-[28rem] w-[min(44rem,94vw)]"
67
91
  data-testid="pipeline-picker-panel"
68
92
  @mouseleave="hoverId = undefined"
69
93
  >
70
94
  <!-- left: selectable options -->
71
95
  <ul class="w-1/2 shrink-0 overflow-y-auto border-e border-slate-800 p-1">
72
- <li>
96
+ <li v-if="noneLabel !== undefined">
73
97
  <button
74
98
  type="button"
75
99
  class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm hover:bg-slate-800/60"
@@ -110,9 +134,7 @@ function choose(id: string) {
110
134
  <!-- right: preview of the hovered (or selected) pipeline -->
111
135
  <div class="w-1/2 overflow-y-auto p-3">
112
136
  <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>
137
+ <div v-else class="text-[12px] leading-snug text-slate-500">{{ emptyHint }}</div>
116
138
  </div>
117
139
  </div>
118
140
  </template>
@@ -1,17 +1,28 @@
1
1
  <script setup lang="ts">
2
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.
3
+ // ordered list of steps it will actually run each a numbered row carrying the agent's icon, label
4
+ // and catalog description, with the human approval gates flagged. The FULL list, not a step count:
5
+ // "what does this pipeline actually do" is the only question a picker's preview pane exists to
6
+ // answer, and a row of interchangeable chips answers just "how many". Shared by the pipeline
7
+ // pickers everywhere a pipeline is chosen, so the explanation is identical on every surface.
8
+ // Resolves each step's display metadata through the single `agentKindMeta` path (via
9
+ // <AgentKindIcon> and {@link stepDescription}), so a system/custom kind can never blow up the
10
+ // renderer.
7
11
  import { computed } from 'vue'
8
12
  import type { Pipeline } from '~/types/domain'
9
- import { pipelineDisplaySteps } from '~/utils/pipeline'
13
+ import { agentKindMeta } from '~/utils/catalog'
14
+ import { pipelineDisplaySteps, pipelineGateCount } from '~/utils/pipeline'
10
15
 
11
16
  const props = defineProps<{ pipeline: Pipeline }>()
12
17
  const { t } = useI18n()
13
18
 
14
19
  const steps = computed(() => pipelineDisplaySteps(props.pipeline))
20
+ const gateCount = computed(() => pipelineGateCount(props.pipeline))
21
+
22
+ /** What the agent at this step does — the same catalog prose the palette and step tooltips use. */
23
+ function stepDescription(kind: string): string {
24
+ return agentKindMeta(kind).description
25
+ }
15
26
  </script>
16
27
 
17
28
  <template>
@@ -24,26 +35,57 @@ const steps = computed(() => pipelineDisplaySteps(props.pipeline))
24
35
  >
25
36
  {{ pipeline.description }}
26
37
  </p>
27
- <div>
28
- <div class="mb-1 flex items-center gap-1 text-[10px] uppercase tracking-wide text-slate-500">
38
+
39
+ <div
40
+ class="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] uppercase tracking-wide text-slate-500"
41
+ >
42
+ <span class="inline-flex items-center gap-1">
29
43
  <UIcon name="i-lucide-workflow" class="h-3 w-3" />
30
44
  {{ 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>
45
+ </span>
46
+ <!-- Gates are the reason a run stops for a human, so they earn a headline of their own
47
+ rather than only the per-step marker below. -->
48
+ <span v-if="gateCount" class="inline-flex items-center gap-1 text-amber-500">
49
+ <UIcon name="i-lucide-shield-check" class="h-3 w-3" />
50
+ {{ t('pipeline.preview.gateCount', { count: gateCount }, gateCount) }}
51
+ </span>
47
52
  </div>
53
+
54
+ <!-- The ordered steps. The number column doubles as the flow connector (a rule drawn between
55
+ consecutive numbers), so the list reads as a sequence rather than an unordered set. -->
56
+ <ol>
57
+ <li
58
+ v-for="(s, i) in steps"
59
+ :key="i"
60
+ class="flex gap-2"
61
+ data-testid="pipeline-preview-step"
62
+ :data-step-kind="s.kind"
63
+ >
64
+ <div class="flex flex-col items-center">
65
+ <span
66
+ class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-slate-800 font-mono text-[9px] tabular-nums text-slate-400"
67
+ >
68
+ {{ i + 1 }}
69
+ </span>
70
+ <span v-if="i < steps.length - 1" class="w-px flex-1 bg-slate-800" />
71
+ </div>
72
+ <div class="min-w-0 flex-1 pb-2">
73
+ <div class="flex items-center gap-1">
74
+ <AgentKindIcon :kind="s.kind" show-label icon-class="h-3.5 w-3.5" />
75
+ <UIcon
76
+ v-if="s.gated"
77
+ name="i-lucide-shield-check"
78
+ class="h-3 w-3 shrink-0 text-amber-400"
79
+ :title="t('pipeline.preview.gated')"
80
+ />
81
+ </div>
82
+ <!-- Clamped: the catalog prose runs long for some kinds, and <AgentKindIcon> already
83
+ carries the full text in its hover tooltip. -->
84
+ <p class="line-clamp-2 text-[11px] leading-snug text-slate-500">
85
+ {{ stepDescription(s.kind) }}
86
+ </p>
87
+ </div>
88
+ </li>
89
+ </ol>
48
90
  </div>
49
91
  </template>
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { buildSourceChoices, reconcileSource } from './ContextIssuePicker.logic'
3
+ import type { TaskSourceState } from '~/types/domain'
4
+
5
+ /**
6
+ * The pure source-selection behind `<ContextIssuePicker>`. Pins what the always-visible
7
+ * selector promises: the tracker in use is named even when it is the only one, a tracker
8
+ * the workspace hasn't got yet is offered as something to ADD (worded for its actual
9
+ * state), and the selection stays valid as the offered set changes underneath it.
10
+ */
11
+ const state = (source: string, { available = true, enabled = true } = {}): TaskSourceState =>
12
+ ({
13
+ source,
14
+ label: source.toUpperCase(),
15
+ icon: `i-lucide-${source}`,
16
+ available,
17
+ enabled,
18
+ credentialFields: [],
19
+ refLabel: '',
20
+ refPlaceholder: '',
21
+ }) as unknown as TaskSourceState
22
+
23
+ describe('buildSourceChoices', () => {
24
+ it('lists a single offered tracker, marked active (the selector is never hidden)', () => {
25
+ const groups = buildSourceChoices([state('github')], 'github')
26
+ expect(groups).toEqual([
27
+ [
28
+ {
29
+ action: 'select',
30
+ source: 'github',
31
+ label: 'GITHUB',
32
+ icon: 'i-lucide-github',
33
+ active: true,
34
+ },
35
+ ],
36
+ ])
37
+ })
38
+
39
+ it('marks only the selected tracker active', () => {
40
+ const [offered] = buildSourceChoices([state('github'), state('jira')], 'jira')
41
+ expect(offered!.map((c) => [c.source, 'active' in c && c.active])).toEqual([
42
+ ['github', false],
43
+ ['jira', true],
44
+ ])
45
+ })
46
+
47
+ it('offers an unavailable tracker as `connect` and an available-but-off one as `enable`', () => {
48
+ const [, addable] = buildSourceChoices(
49
+ [state('github'), state('jira', { available: false }), state('linear', { enabled: false })],
50
+ 'github',
51
+ )
52
+ expect(addable).toEqual([
53
+ { action: 'connect', source: 'jira', label: 'JIRA', icon: 'i-lucide-jira' },
54
+ { action: 'enable', source: 'linear', label: 'LINEAR', icon: 'i-lucide-linear' },
55
+ ])
56
+ })
57
+
58
+ it('drops empty groups so the menu renders no stray separator', () => {
59
+ expect(buildSourceChoices([state('github')], 'github')).toHaveLength(1)
60
+ expect(buildSourceChoices([state('jira', { available: false })], undefined)).toHaveLength(1)
61
+ expect(buildSourceChoices([], undefined)).toEqual([])
62
+ })
63
+ })
64
+
65
+ describe('reconcileSource', () => {
66
+ it('selects the tracker the user just went off to connect, once it is offered', () => {
67
+ expect(reconcileSource(['github', 'jira'], 'github', 'jira')).toBe('jira')
68
+ })
69
+
70
+ it('keeps the current selection while the connect is still pending', () => {
71
+ expect(reconcileSource(['github'], 'github', 'jira')).toBe('github')
72
+ })
73
+
74
+ it('keeps a still-offered selection', () => {
75
+ expect(reconcileSource(['github', 'jira'], 'jira', null)).toBe('jira')
76
+ })
77
+
78
+ it('falls back to the first offered tracker when the selection stopped being offered', () => {
79
+ expect(reconcileSource(['github'], 'jira', null)).toBe('github')
80
+ })
81
+
82
+ it('selects the first offered tracker when nothing is selected yet', () => {
83
+ expect(reconcileSource(['github'], undefined, null)).toBe('github')
84
+ })
85
+
86
+ it('resolves to nothing when the workspace offers no tracker at all', () => {
87
+ expect(reconcileSource([], 'jira', 'jira')).toBeUndefined()
88
+ })
89
+ })
@@ -0,0 +1,74 @@
1
+ import type { TaskSourceKind, TaskSourceState } from '~/types/domain'
2
+
3
+ /**
4
+ * Pure source-selection logic for `<ContextIssuePicker>`: which tracker the picker is
5
+ * searching, and what the source menu offers. Kept out of the component so both the
6
+ * template's `computed`s and the unit spec call the same code.
7
+ *
8
+ * The menu is deliberately two-tier — pick an already-offered tracker, or go and add
9
+ * one — because "attach a context issue" is where a user first discovers a tracker is
10
+ * missing, and sending them to the Integrations hub loses their in-progress task.
11
+ */
12
+
13
+ /** One row of the picker's source menu. */
14
+ export type SourceChoice =
15
+ /** An offered tracker the picker can search right now. */
16
+ | { action: 'select'; source: TaskSourceKind; label: string; icon: string; active: boolean }
17
+ /**
18
+ * A configured tracker that is not offered yet, so it can be added from here. `connect`
19
+ * has no credential/App behind it; `enable` is connected but toggled off for the
20
+ * workspace. Both open the same connect modal, which serves either case — only the
21
+ * wording differs, so the user isn't told to "connect" something already connected.
22
+ */
23
+ | { action: 'connect' | 'enable'; source: TaskSourceKind; label: string; icon: string }
24
+
25
+ /**
26
+ * The picker's source menu, as non-empty groups (the offered trackers, then the ones the
27
+ * user could add). Empty groups are dropped so the menu never renders a stray separator.
28
+ */
29
+ export function buildSourceChoices(
30
+ sources: TaskSourceState[],
31
+ selected: TaskSourceKind | undefined,
32
+ ): SourceChoice[][] {
33
+ const offered: SourceChoice[] = []
34
+ const addable: SourceChoice[] = []
35
+ for (const s of sources) {
36
+ if (s.available && s.enabled) {
37
+ offered.push({
38
+ action: 'select',
39
+ source: s.source,
40
+ label: s.label,
41
+ icon: s.icon,
42
+ active: s.source === selected,
43
+ })
44
+ } else {
45
+ addable.push({
46
+ action: s.available ? 'enable' : 'connect',
47
+ source: s.source,
48
+ label: s.label,
49
+ icon: s.icon,
50
+ })
51
+ }
52
+ }
53
+ return [offered, addable].filter((group) => group.length > 0)
54
+ }
55
+
56
+ /**
57
+ * The source the picker should hold once the offered set changes — after a connect, a
58
+ * disconnect, or the per-workspace toggle flipping elsewhere.
59
+ *
60
+ * `awaiting` is the tracker the user just left to connect: the moment it becomes offered
61
+ * it wins, so they land back on the source they went to add rather than on whatever was
62
+ * selected before. Otherwise a still-offered selection is kept, and a selection that
63
+ * stopped being offered falls back to the first one (searching a tracker the workspace no
64
+ * longer offers only yields errors).
65
+ */
66
+ export function reconcileSource(
67
+ offered: TaskSourceKind[],
68
+ selected: TaskSourceKind | undefined,
69
+ awaiting: TaskSourceKind | null,
70
+ ): TaskSourceKind | undefined {
71
+ if (awaiting && offered.includes(awaiting)) return awaiting
72
+ if (selected && offered.includes(selected)) return selected
73
+ return offered[0]
74
+ }
@@ -6,8 +6,15 @@
6
6
  // It only *stages* a choice: the caller collects PendingContext items and links
7
7
  // them once the block exists (see useContextLinking). A search hit / pasted ref
8
8
  // carries `needsImport: true` so it's fetched + persisted before linking.
9
+ //
10
+ // The tracker being searched is ALWAYS on screen (even when the workspace offers
11
+ // exactly one), and its menu doubles as the "add a tracker" affordance: attaching a
12
+ // context issue is where a missing integration is discovered, and the connect modal
13
+ // opens over the caller's form rather than navigating away from it.
14
+ import type { DropdownMenuItem } from '@nuxt/ui'
9
15
  import type { SourceTask, TaskSearchResult, TaskSourceKind } from '~/types/domain'
10
16
  import EmptyState from '~/components/common/EmptyState.vue'
17
+ import { buildSourceChoices, reconcileSource } from '~/components/tasks/ContextIssuePicker.logic'
11
18
 
12
19
  const props = defineProps<{
13
20
  /** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
@@ -23,13 +30,6 @@ const props = defineProps<{
23
30
  * `v-model:source`); omitted, the picker manages it internally (the add-task case).
24
31
  */
25
32
  source?: TaskSourceKind
26
- /**
27
- * Always render the source selector, even with a single offered tracker — so the
28
- * user can see *which* tracker is being searched (the "create task from issue"
29
- * surface, where the source is otherwise invisible). Off by default: the inline
30
- * add-task picker stays compact and only shows a selector when there's a choice.
31
- */
32
- alwaysShowSource?: boolean
33
33
  }>()
34
34
  const emit = defineEmits<{
35
35
  pick: [item: PendingContext]
@@ -38,12 +38,14 @@ const emit = defineEmits<{
38
38
 
39
39
  const { t } = useI18n()
40
40
  const tasks = useTasksStore()
41
+ const ui = useUiStore()
41
42
 
42
43
  const chosen = computed(() => new Set(props.chosenKeys ?? []))
43
44
 
44
45
  // Source: default to the first offered tracker. Controlled when the parent passes
45
- // `source` (write-through to `update:source`), else internal. A selector appears when
46
- // more than one source is offered, or whenever the parent asks (`alwaysShowSource`).
46
+ // `source` (write-through to `update:source`), else internal. The selector is always
47
+ // rendered, single tracker or not which tracker is being searched decides what a
48
+ // pasted key resolves to, so it must never be invisible.
47
49
  const internalSource = ref<TaskSourceKind | undefined>(tasks.offeredSources[0]?.source)
48
50
  const source = computed<TaskSourceKind | undefined>({
49
51
  get: () => props.source ?? internalSource.value,
@@ -52,13 +54,49 @@ const source = computed<TaskSourceKind | undefined>({
52
54
  if (v) emit('update:source', v)
53
55
  },
54
56
  })
55
- const sourceItems = computed(() =>
56
- tasks.offeredSources.map((s) => ({ label: s.label, value: s.source })),
57
+ const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
58
+
59
+ // The tracker the user left to connect, so it becomes the selection the moment it turns
60
+ // up offered (the connect modal re-probes on success). Also the reconcile trigger for a
61
+ // source that stops being offered — disconnected, or toggled off in settings.
62
+ const awaitingConnect = ref<TaskSourceKind | null>(null)
63
+ function addSource(s: TaskSourceKind) {
64
+ awaitingConnect.value = s
65
+ ui.openTaskConnect(s)
66
+ }
67
+ watch(
68
+ () => tasks.offeredSources.map((s) => s.source),
69
+ (offered) => {
70
+ const next = reconcileSource(offered, source.value, awaitingConnect.value)
71
+ if (next && next === awaitingConnect.value) awaitingConnect.value = null
72
+ if (next !== source.value && next) source.value = next
73
+ },
57
74
  )
58
- const showSourceSelect = computed(
59
- () => sourceItems.value.length > 1 || (props.alwaysShowSource && sourceItems.value.length > 0),
75
+
76
+ // Two-tier menu: pick an offered tracker, or add one that isn't offered yet.
77
+ const sourceMenu = computed<DropdownMenuItem[][]>(() =>
78
+ buildSourceChoices(tasks.sources, source.value).map((group) =>
79
+ group.map((choice) =>
80
+ choice.action === 'select'
81
+ ? {
82
+ label: choice.label,
83
+ icon: choice.icon,
84
+ trailingIcon: choice.active ? 'i-lucide-check' : undefined,
85
+ onSelect: () => {
86
+ source.value = choice.source
87
+ },
88
+ }
89
+ : {
90
+ label:
91
+ choice.action === 'enable'
92
+ ? t('tasks.picker.enableSource', { label: choice.label })
93
+ : t('tasks.picker.connectSource', { label: choice.label }),
94
+ icon: 'i-lucide-plug',
95
+ onSelect: () => addSource(choice.source),
96
+ },
97
+ ),
98
+ ),
60
99
  )
61
- const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
62
100
  const searchable = computed(() => descriptor.value?.searchable ?? false)
63
101
 
64
102
  const query = ref('')
@@ -222,13 +260,25 @@ onMounted(() => {
222
260
 
223
261
  <template>
224
262
  <div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/40 p-2">
225
- <USelect
226
- v-if="showSourceSelect"
227
- v-model="source"
228
- :items="sourceItems"
229
- size="xs"
230
- class="w-full"
231
- />
263
+ <!-- Which tracker is being searched, always visible, plus the trackers the user
264
+ could add from here (each opens the connect modal over the caller's form). -->
265
+ <div class="flex items-center gap-1.5">
266
+ <span class="shrink-0 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
267
+ {{ t('tasks.picker.sourceLabel') }}
268
+ </span>
269
+ <UDropdownMenu :items="sourceMenu" :content="{ side: 'bottom', align: 'start' }">
270
+ <UButton
271
+ color="neutral"
272
+ variant="soft"
273
+ size="xs"
274
+ :icon="icon"
275
+ trailing-icon="i-lucide-chevron-down"
276
+ class="max-w-full"
277
+ >
278
+ <span class="truncate">{{ descriptor?.label ?? t('tasks.picker.noSource') }}</span>
279
+ </UButton>
280
+ </UDropdownMenu>
281
+ </div>
232
282
 
233
283
  <UInput
234
284
  v-model="query"
@@ -27,7 +27,7 @@ const back = useIntegrationBack(open)
27
27
 
28
28
  // The tracker being browsed. Owned here (not the picker) so the epic action and the
29
29
  // ref-input placeholder share the same selected source; passed to the picker via
30
- // `v-model:source` with `always-show-source` so it's always visible + selectable.
30
+ // `v-model:source`, whose selector always shows (and can add) the tracker in use.
31
31
  const source = ref<TaskSourceKind | undefined>(undefined)
32
32
  const ref_ = ref('')
33
33
  const importing = ref(false)
@@ -162,13 +162,11 @@ async function doSpawnEpic() {
162
162
  uses for context issues: search by title, pick an already-imported one,
163
163
  or paste a URL/key — choosing one opens the prefilled add-task form. The
164
164
  search is scoped to the chosen container's repo (so a GitHub search stays
165
- in that service's repo and a pasted URL / bare number resolves there), and
166
- the source selector is always shown so it's clear which tracker is in use. -->
165
+ in that service's repo and a pasted URL / bare number resolves there). -->
167
166
  <UFormField :label="t('tasks.import.searchIssues')">
168
167
  <ContextIssuePicker
169
168
  v-model:source="source"
170
169
  :scope-block-id="containerId"
171
- always-show-source
172
170
  @pick="createFromPick"
173
171
  />
174
172
  </UFormField>
@@ -1,13 +1,53 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { pipelineAllowedForTaskType, purposeAllowsAgentCategory } from '@cat-factory/contracts'
3
3
  import type { Block, Pipeline } from '~/types/domain'
4
- import { pipelineAllowedForManualStart } from '~/utils/pipeline'
4
+ import {
5
+ pipelineAllowedForManualStart,
6
+ pipelineDisplaySteps,
7
+ pipelineGateCount,
8
+ } from '~/utils/pipeline'
5
9
 
6
10
  // A minimal pipeline: only the fields the launch/task-type filters read matter here.
7
11
  function pipeline(over: Partial<Pipeline> = {}): Pipeline {
8
12
  return { id: 'pl_x', name: 'X', agentKinds: ['coder'], ...over } as Pipeline
9
13
  }
10
14
 
15
+ // The data behind every picker's preview pane: the ordered steps a run will actually execute.
16
+ describe('pipelineDisplaySteps', () => {
17
+ it('keeps the authored order and flags the gated steps', () => {
18
+ const p = pipeline({
19
+ agentKinds: ['task-estimator', 'coder', 'reviewer'],
20
+ gates: [false, true, false],
21
+ })
22
+ expect(pipelineDisplaySteps(p)).toEqual([
23
+ { kind: 'task-estimator', gated: false },
24
+ { kind: 'coder', gated: true },
25
+ { kind: 'reviewer', gated: false },
26
+ ])
27
+ })
28
+
29
+ it('drops steps disabled by default — they never run, so listing them would misdescribe it', () => {
30
+ // The short `enabled` array also pins "no entry ⇒ enabled": `tester` has none and stays.
31
+ const p = pipeline({ agentKinds: ['architect', 'coder', 'tester'], enabled: [false, true] })
32
+ expect(pipelineDisplaySteps(p).map((s) => s.kind)).toEqual(['coder', 'tester'])
33
+ })
34
+ })
35
+
36
+ describe('pipelineGateCount', () => {
37
+ it('counts only the gates that a run can actually stop at', () => {
38
+ expect(pipelineGateCount(pipeline({ agentKinds: ['coder'] }))).toBe(0)
39
+ expect(
40
+ pipelineGateCount(pipeline({ agentKinds: ['coder', 'merger'], gates: [true, true] })),
41
+ ).toBe(2)
42
+ // A gate declared on a disabled step gates nothing: the step is skipped at run.
43
+ expect(
44
+ pipelineGateCount(
45
+ pipeline({ agentKinds: ['coder', 'merger'], gates: [true, true], enabled: [true, false] }),
46
+ ),
47
+ ).toBe(1)
48
+ })
49
+ })
50
+
11
51
  describe('pipelineAllowedForTaskType', () => {
12
52
  it('a document task offers ONLY document-purpose pipelines', () => {
13
53
  expect(pipelineAllowedForTaskType(pipeline({ purpose: 'document' }), 'document')).toBe(true)
@@ -29,6 +29,15 @@ export function pipelineDisplaySteps(pipeline: Pipeline): PipelineDisplayStep[]
29
29
  .map(({ kind, gated }) => ({ kind, gated }))
30
30
  }
31
31
 
32
+ /**
33
+ * How many times a run of `pipeline` will stop for a human. Counted off the DISPLAYED steps, not
34
+ * `pipeline.gates`, because a gate declared on a step that is disabled by default gates nothing —
35
+ * that step never runs, so promising a stop there would misstate what the pipeline does.
36
+ */
37
+ export function pipelineGateCount(pipeline: Pipeline): number {
38
+ return pipelineDisplaySteps(pipeline).filter((s) => s.gated).length
39
+ }
40
+
32
41
  // Re-exported so a picker can import the task-type gate from the same module as the
33
42
  // launch/frame gates it composes with (the classifier itself lives in `@cat-factory/contracts`).
34
43
  export { pipelineAllowedForTaskType }
@@ -3245,7 +3245,11 @@
3245
3245
  "attachByReference": "{ref} per Referenz anhängen",
3246
3246
  "noMatches": "Keine passenden Issues.",
3247
3247
  "emptySearchable": "Nach Titel suchen oder ein importiertes Issue auswählen.",
3248
- "emptyRefOnly": "Fügen Sie eine Issue-URL oder einen -Schlüssel ein, um es anzuhängen."
3248
+ "emptyRefOnly": "Fügen Sie eine Issue-URL oder einen -Schlüssel ein, um es anzuhängen.",
3249
+ "sourceLabel": "Quelle",
3250
+ "noSource": "Quelle wählen",
3251
+ "connectSource": "{label} verbinden",
3252
+ "enableSource": "{label} aktivieren"
3249
3253
  },
3250
3254
  "contextIssues": {
3251
3255
  "title": "Kontext-Issues",
@@ -3358,10 +3362,12 @@
3358
3362
  },
3359
3363
  "preview": {
3360
3364
  "stepCount": "{count} Schritt | {count} Schritte",
3365
+ "gateCount": "{count} Freigabe | {count} Freigaben",
3361
3366
  "gated": "Manuelle Freigabe nach diesem Schritt"
3362
3367
  },
3363
3368
  "picker": {
3364
- "noneHint": "Keine Standard-Pipeline. Du wählst beim Ausführen der Aufgabe eine aus."
3369
+ "noneHint": "Keine Standard-Pipeline. Du wählst beim Ausführen der Aufgabe eine aus.",
3370
+ "hoverHint": "Fahre über eine Pipeline, um die Schritte zu sehen, die sie ausführt."
3365
3371
  },
3366
3372
  "builder": {
3367
3373
  "title": "Pipeline-Builder",
@@ -3644,7 +3644,11 @@
3644
3644
  "attachByReference": "Attach {ref} by reference",
3645
3645
  "noMatches": "No matching issues.",
3646
3646
  "emptySearchable": "Search by title, or pick an imported issue.",
3647
- "emptyRefOnly": "Paste an issue URL or key to attach it."
3647
+ "emptyRefOnly": "Paste an issue URL or key to attach it.",
3648
+ "sourceLabel": "Source",
3649
+ "noSource": "Choose a source",
3650
+ "connectSource": "Connect {label}",
3651
+ "enableSource": "Enable {label}"
3648
3652
  },
3649
3653
  "contextIssues": {
3650
3654
  "title": "Context issues",
@@ -3763,10 +3767,12 @@
3763
3767
  },
3764
3768
  "preview": {
3765
3769
  "stepCount": "{count} step | {count} steps",
3770
+ "gateCount": "{count} approval gate | {count} approval gates",
3766
3771
  "gated": "Human approval gate after this step"
3767
3772
  },
3768
3773
  "picker": {
3769
- "noneHint": "No default pipeline. You choose one when you run the task."
3774
+ "noneHint": "No default pipeline. You choose one when you run the task.",
3775
+ "hoverHint": "Hover a pipeline to see the steps it runs."
3770
3776
  },
3771
3777
  "builder": {
3772
3778
  "title": "Pipeline builder",
@@ -3539,7 +3539,11 @@
3539
3539
  "attachByReference": "Adjuntar {ref} por referencia",
3540
3540
  "noMatches": "No hay incidencias coincidentes.",
3541
3541
  "emptySearchable": "Busca por título o elige una incidencia importada.",
3542
- "emptyRefOnly": "Pega la URL o clave de una incidencia para adjuntarla."
3542
+ "emptyRefOnly": "Pega la URL o clave de una incidencia para adjuntarla.",
3543
+ "sourceLabel": "Fuente",
3544
+ "noSource": "Elige una fuente",
3545
+ "connectSource": "Conectar {label}",
3546
+ "enableSource": "Habilitar {label}"
3543
3547
  },
3544
3548
  "contextIssues": {
3545
3549
  "title": "Incidencias de contexto",
@@ -3652,10 +3656,12 @@
3652
3656
  },
3653
3657
  "preview": {
3654
3658
  "stepCount": "{count} paso | {count} pasos",
3659
+ "gateCount": "{count} aprobación | {count} aprobaciones",
3655
3660
  "gated": "Aprobación humana después de este paso"
3656
3661
  },
3657
3662
  "picker": {
3658
- "noneHint": "Sin pipeline predeterminado. Eliges uno al ejecutar la tarea."
3663
+ "noneHint": "Sin pipeline predeterminado. Eliges uno al ejecutar la tarea.",
3664
+ "hoverHint": "Pasa el cursor por una pipeline para ver los pasos que ejecuta."
3659
3665
  },
3660
3666
  "builder": {
3661
3667
  "title": "Constructor de pipelines",
@@ -3539,7 +3539,11 @@
3539
3539
  "attachByReference": "Joindre {ref} par référence",
3540
3540
  "noMatches": "Aucun ticket correspondant.",
3541
3541
  "emptySearchable": "Recherchez par titre ou choisissez un ticket importé.",
3542
- "emptyRefOnly": "Collez l'URL ou la clé d'un ticket pour le joindre."
3542
+ "emptyRefOnly": "Collez l'URL ou la clé d'un ticket pour le joindre.",
3543
+ "sourceLabel": "Source",
3544
+ "noSource": "Choisir une source",
3545
+ "connectSource": "Connecter {label}",
3546
+ "enableSource": "Activer {label}"
3543
3547
  },
3544
3548
  "contextIssues": {
3545
3549
  "title": "Tickets de contexte",
@@ -3652,10 +3656,12 @@
3652
3656
  },
3653
3657
  "preview": {
3654
3658
  "stepCount": "{count} étape | {count} étapes",
3659
+ "gateCount": "{count} validation | {count} validations",
3655
3660
  "gated": "Validation humaine après cette étape"
3656
3661
  },
3657
3662
  "picker": {
3658
- "noneHint": "Aucun pipeline par défaut. Vous en choisissez un au lancement de la tâche."
3663
+ "noneHint": "Aucun pipeline par défaut. Vous en choisissez un au lancement de la tâche.",
3664
+ "hoverHint": "Survolez un pipeline pour voir les étapes qu'il exécute."
3659
3665
  },
3660
3666
  "builder": {
3661
3667
  "title": "Constructeur de pipelines",
@@ -3550,7 +3550,11 @@
3550
3550
  "attachByReference": "צרף {ref} לפי הפניה",
3551
3551
  "noMatches": "אין ניושנים תואמים.",
3552
3552
  "emptySearchable": "חפש לפי כותרת, או בחר ניושן מיובא.",
3553
- "emptyRefOnly": "הדבק URL או מפתח של ניושן כדי לצרף אותו."
3553
+ "emptyRefOnly": "הדבק URL או מפתח של ניושן כדי לצרף אותו.",
3554
+ "sourceLabel": "מקור",
3555
+ "noSource": "בחר מקור",
3556
+ "connectSource": "חבר {label}",
3557
+ "enableSource": "הפעל {label}"
3554
3558
  },
3555
3559
  "contextIssues": {
3556
3560
  "title": "ניושני הקשר",
@@ -3663,10 +3667,12 @@
3663
3667
  },
3664
3668
  "preview": {
3665
3669
  "stepCount": "שלב {count} | {count} שלבים",
3670
+ "gateCount": "אישור {count} | {count} אישורים",
3666
3671
  "gated": "אישור אנושי אחרי שלב זה"
3667
3672
  },
3668
3673
  "picker": {
3669
- "noneHint": "אין צינור ברירת מחדל. בוחרים אחד כשמריצים את המשימה."
3674
+ "noneHint": "אין צינור ברירת מחדל. בוחרים אחד כשמריצים את המשימה.",
3675
+ "hoverHint": "העבירו את העכבר מעל צינור כדי לראות את השלבים שהוא מריץ."
3670
3676
  },
3671
3677
  "builder": {
3672
3678
  "title": "בונה הצינור",
@@ -3245,7 +3245,11 @@
3245
3245
  "attachByReference": "Allega {ref} per riferimento",
3246
3246
  "noMatches": "Nessuna issue corrispondente.",
3247
3247
  "emptySearchable": "Cerca per titolo, oppure scegli una issue importata.",
3248
- "emptyRefOnly": "Incolla l'URL o la chiave di una issue per allegarla."
3248
+ "emptyRefOnly": "Incolla l'URL o la chiave di una issue per allegarla.",
3249
+ "sourceLabel": "Fonte",
3250
+ "noSource": "Scegli una fonte",
3251
+ "connectSource": "Collega {label}",
3252
+ "enableSource": "Abilita {label}"
3249
3253
  },
3250
3254
  "contextIssues": {
3251
3255
  "title": "Issue di contesto",
@@ -3358,10 +3362,12 @@
3358
3362
  },
3359
3363
  "preview": {
3360
3364
  "stepCount": "{count} step | {count} step",
3365
+ "gateCount": "{count} approvazione | {count} approvazioni",
3361
3366
  "gated": "Approvazione umana dopo questo step"
3362
3367
  },
3363
3368
  "picker": {
3364
- "noneHint": "Nessuna pipeline predefinita. Ne scegli una quando esegui l'attivita."
3369
+ "noneHint": "Nessuna pipeline predefinita. Ne scegli una quando esegui l'attivita.",
3370
+ "hoverHint": "Passa il mouse su una pipeline per vedere gli step che esegue."
3365
3371
  },
3366
3372
  "builder": {
3367
3373
  "title": "Costruttore di pipeline",
@@ -3551,7 +3551,11 @@
3551
3551
  "attachByReference": "{ref} を参照で添付",
3552
3552
  "noMatches": "一致する課題がありません。",
3553
3553
  "emptySearchable": "タイトルで検索するか、インポート済みの課題を選択してください。",
3554
- "emptyRefOnly": "課題の URL またはキーを貼り付けて添付します。"
3554
+ "emptyRefOnly": "課題の URL またはキーを貼り付けて添付します。",
3555
+ "sourceLabel": "ソース",
3556
+ "noSource": "ソースを選択",
3557
+ "connectSource": "{label} を接続",
3558
+ "enableSource": "{label} を有効化"
3555
3559
  },
3556
3560
  "contextIssues": {
3557
3561
  "title": "コンテキスト課題",
@@ -3664,10 +3668,12 @@
3664
3668
  },
3665
3669
  "preview": {
3666
3670
  "stepCount": "{count}ステップ | {count}ステップ",
3671
+ "gateCount": "{count}件の承認 | {count}件の承認",
3667
3672
  "gated": "このステップの後に人による承認"
3668
3673
  },
3669
3674
  "picker": {
3670
- "noneHint": "デフォルトのパイプラインはありません。タスクの実行時に選択します。"
3675
+ "noneHint": "デフォルトのパイプラインはありません。タスクの実行時に選択します。",
3676
+ "hoverHint": "パイプラインにカーソルを合わせると、実行されるステップが表示されます。"
3671
3677
  },
3672
3678
  "builder": {
3673
3679
  "title": "パイプラインビルダー",
@@ -3539,7 +3539,11 @@
3539
3539
  "attachByReference": "Dołącz {ref} przez odniesienie",
3540
3540
  "noMatches": "Brak pasujących zgłoszeń.",
3541
3541
  "emptySearchable": "Szukaj według tytułu lub wybierz zaimportowane zgłoszenie.",
3542
- "emptyRefOnly": "Wklej adres URL lub klucz zgłoszenia, aby je dołączyć."
3542
+ "emptyRefOnly": "Wklej adres URL lub klucz zgłoszenia, aby je dołączyć.",
3543
+ "sourceLabel": "Źródło",
3544
+ "noSource": "Wybierz źródło",
3545
+ "connectSource": "Połącz {label}",
3546
+ "enableSource": "Włącz {label}"
3543
3547
  },
3544
3548
  "contextIssues": {
3545
3549
  "title": "Zgłoszenia kontekstowe",
@@ -3652,10 +3656,12 @@
3652
3656
  },
3653
3657
  "preview": {
3654
3658
  "stepCount": "{count} krok | {count} kroki | {count} kroków",
3659
+ "gateCount": "{count} zatwierdzenie | {count} zatwierdzenia | {count} zatwierdzeń",
3655
3660
  "gated": "Ręczne zatwierdzenie po tym kroku"
3656
3661
  },
3657
3662
  "picker": {
3658
- "noneHint": "Brak domyślnego pipeline'u. Wybierzesz go podczas uruchamiania zadania."
3663
+ "noneHint": "Brak domyślnego pipeline'u. Wybierzesz go podczas uruchamiania zadania.",
3664
+ "hoverHint": "Najedź na pipeline, aby zobaczyć kroki, które wykonuje."
3659
3665
  },
3660
3666
  "builder": {
3661
3667
  "title": "Kreator pipeline'ów",
@@ -3551,7 +3551,11 @@
3551
3551
  "attachByReference": "{ref} öğesini referansla ekle",
3552
3552
  "noMatches": "Eşleşen sorun yok.",
3553
3553
  "emptySearchable": "Başlığa göre ara veya içe aktarılmış bir sorun seç.",
3554
- "emptyRefOnly": "Eklemek için bir sorun URL'si veya anahtarı yapıştır."
3554
+ "emptyRefOnly": "Eklemek için bir sorun URL'si veya anahtarı yapıştır.",
3555
+ "sourceLabel": "Kaynak",
3556
+ "noSource": "Bir kaynak seç",
3557
+ "connectSource": "{label} bağla",
3558
+ "enableSource": "{label} etkinleştir"
3555
3559
  },
3556
3560
  "contextIssues": {
3557
3561
  "title": "Bağlam sorunları",
@@ -3664,10 +3668,12 @@
3664
3668
  },
3665
3669
  "preview": {
3666
3670
  "stepCount": "{count} adım | {count} adım",
3671
+ "gateCount": "{count} onay | {count} onay",
3667
3672
  "gated": "Bu adımdan sonra insan onayı"
3668
3673
  },
3669
3674
  "picker": {
3670
- "noneHint": "Varsayılan pipeline yok. Görevi çalıştırırken birini seçersin."
3675
+ "noneHint": "Varsayılan pipeline yok. Görevi çalıştırırken birini seçersin.",
3676
+ "hoverHint": "Çalıştırdığı adımları görmek için bir pipeline'ın üzerine gelin."
3671
3677
  },
3672
3678
  "builder": {
3673
3679
  "title": "Pipeline oluşturucu",
@@ -3539,7 +3539,11 @@
3539
3539
  "attachByReference": "Прикріпити {ref} за посиланням",
3540
3540
  "noMatches": "Немає відповідних завдань.",
3541
3541
  "emptySearchable": "Шукайте за назвою або виберіть імпортоване завдання.",
3542
- "emptyRefOnly": "Вставте URL або ключ завдання, щоб прикріпити його."
3542
+ "emptyRefOnly": "Вставте URL або ключ завдання, щоб прикріпити його.",
3543
+ "sourceLabel": "Джерело",
3544
+ "noSource": "Виберіть джерело",
3545
+ "connectSource": "Підключити {label}",
3546
+ "enableSource": "Увімкнути {label}"
3543
3547
  },
3544
3548
  "contextIssues": {
3545
3549
  "title": "Контекстні завдання",
@@ -3652,10 +3656,12 @@
3652
3656
  },
3653
3657
  "preview": {
3654
3658
  "stepCount": "{count} крок | {count} кроки | {count} кроків",
3659
+ "gateCount": "{count} підтвердження | {count} підтвердження | {count} підтверджень",
3655
3660
  "gated": "Ручне підтвердження після цього кроку"
3656
3661
  },
3657
3662
  "picker": {
3658
- "noneHint": "Немає типового пайплайну. Ви обираєте його під час запуску завдання."
3663
+ "noneHint": "Немає типового пайплайну. Ви обираєте його під час запуску завдання.",
3664
+ "hoverHint": "Наведіть курсор на пайплайн, щоб побачити кроки, які він виконує."
3659
3665
  },
3660
3666
  "builder": {
3661
3667
  "title": "Конструктор пайплайнів",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.163.0",
3
+ "version": "0.164.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",