@cat-factory/app 0.139.0 → 0.140.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.
@@ -310,6 +310,16 @@ async function clone(p: Pipeline) {
310
310
  class="mb-2"
311
311
  />
312
312
 
313
+ <!-- Description: the prose summary shown next to the step list in the pipeline pickers. -->
314
+ <UTextarea
315
+ v-model="pipelines.draftDescription"
316
+ :placeholder="t('pipeline.builder.descriptionPlaceholder')"
317
+ :rows="2"
318
+ autoresize
319
+ size="sm"
320
+ class="mb-2 w-full"
321
+ />
322
+
313
323
  <!-- Labels: organize the pipeline in the library (filter/search). -->
314
324
  <div class="mb-3 flex flex-wrap items-center gap-1.5">
315
325
  <UBadge
@@ -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
+ })
@@ -1,4 +1,5 @@
1
1
  import type { DocumentSourceKind, TaskSourceKind } from '~/types/domain'
2
+ import { apiErrorEnvelope, apiErrorStatus } from './api/errors'
2
3
 
3
4
  // Shared model + orchestration for attaching external context (imported docs and
4
5
  // tracker issues) to a board block. A "pending" item is something the user has
@@ -30,22 +31,96 @@ export interface PendingContext {
30
31
  needsImport: boolean
31
32
  }
32
33
 
34
+ /**
35
+ * A single pending attachment that failed to import or link, captured with its
36
+ * actual cause instead of swallowed. The message is the server's own explanation
37
+ * (e.g. "GitHub denied access to …" / "… was not found on the default branch"),
38
+ * the status is the HTTP code, and the code is the backend error code
39
+ * (`conflict` / `validation` / …) — enough to both display a specific reason and
40
+ * assemble a copy-pasteable diagnostic report.
41
+ */
42
+ export interface LinkFailure {
43
+ item: PendingContext
44
+ /** The server's message (or a network-fault message) explaining why it failed. */
45
+ message: string
46
+ /** HTTP status of the failed request, when the error carried one. */
47
+ status?: number
48
+ /** Backend error code (`conflict` / `validation` / …), when present. */
49
+ code?: string
50
+ /**
51
+ * The backend error envelope's `details` bag, when present — for a GitHub doc read
52
+ * this carries the repo coordinates + the UPSTREAM GitHub status (e.g. `status: 403`),
53
+ * which differs from the HTTP `status` above (the mapped response code, e.g. 409). Kept
54
+ * so the diagnostic report shows the real GitHub status, not just the mapped one.
55
+ */
56
+ details?: Record<string, unknown>
57
+ }
58
+
33
59
  /** Stable key for a pending item, used for dedupe + selection toggles. */
34
60
  export function contextKey(c: Pick<PendingContext, 'kind' | 'source' | 'externalId'>): string {
35
61
  return `${c.kind}:${c.source}:${c.externalId}`
36
62
  }
37
63
 
64
+ /**
65
+ * Render a batch of {@link LinkFailure}s into a single plain-text diagnostic block
66
+ * for the clipboard — the exact context a bug report needs (the item coordinates,
67
+ * the HTTP status + backend code, the backend `details` bag, and the server's message)
68
+ * so the user does not have to retype any of it. Deliberately English/technical (a log
69
+ * dump, not UI prose), mirroring how format/code examples stay out of the i18n catalog.
70
+ */
71
+ export function buildLinkFailureReport(
72
+ failures: LinkFailure[],
73
+ context: { workspaceId?: string | null; blockId?: string; when?: string } = {},
74
+ ): string {
75
+ const lines: string[] = []
76
+ lines.push(`Context link failures: ${failures.length}`)
77
+ if (context.when) lines.push(`when: ${context.when}`)
78
+ if (context.workspaceId) lines.push(`workspace: ${context.workspaceId}`)
79
+ if (context.blockId) lines.push(`block: ${context.blockId}`)
80
+ for (const f of failures) {
81
+ lines.push('')
82
+ lines.push(`- ${f.item.kind}/${f.item.source}: ${f.item.externalId}`)
83
+ lines.push(` title: ${f.item.title}`)
84
+ if (f.status !== undefined) lines.push(` status: ${f.status}`)
85
+ if (f.code) lines.push(` code: ${f.code}`)
86
+ // Dump the backend `details` (repo coordinates + upstream status), each key namespaced
87
+ // so its `status` reads clearly as the GitHub status, distinct from the HTTP `status`.
88
+ for (const [key, value] of Object.entries(f.details ?? {})) {
89
+ lines.push(` details.${key}: ${formatDetailValue(value)}`)
90
+ }
91
+ lines.push(` error: ${f.message}`)
92
+ }
93
+ return lines.join('\n')
94
+ }
95
+
96
+ /** One-line rendering of a `details` value for the diagnostic dump (objects → JSON). */
97
+ function formatDetailValue(value: unknown): string {
98
+ if (value === null || value === undefined) return String(value)
99
+ if (typeof value === 'object') {
100
+ try {
101
+ return JSON.stringify(value)
102
+ } catch {
103
+ return String(value)
104
+ }
105
+ }
106
+ return String(value)
107
+ }
108
+
38
109
  export function useContextLinking() {
39
110
  const documents = useDocumentsStore()
40
111
  const tasks = useTasksStore()
112
+ const workspace = useWorkspaceStore()
113
+ const toast = useToast()
114
+ const { t } = useI18n()
115
+ const { copyAction } = useCopyToClipboard()
41
116
 
42
117
  /**
43
118
  * Import (when needed) then link every pending item to `blockId`. Each failure
44
- * is counted rather than aborting the batch, so one bad attachment doesn't sink
45
- * the rest; returns how many failed.
119
+ * is captured with its actual cause rather than aborting the batch, so one bad
120
+ * attachment doesn't sink the rest; returns the failures (empty ⇒ all linked).
46
121
  */
47
- async function linkPending(blockId: string, items: PendingContext[]): Promise<number> {
48
- let failed = 0
122
+ async function linkPending(blockId: string, items: PendingContext[]): Promise<LinkFailure[]> {
123
+ const failures: LinkFailure[] = []
49
124
  for (const item of items) {
50
125
  try {
51
126
  if (item.kind === 'document') {
@@ -61,12 +136,49 @@ export function useContextLinking() {
61
136
  : item.externalId
62
137
  await tasks.linkToBlock(blockId, source, externalId)
63
138
  }
64
- } catch {
65
- failed++
139
+ } catch (e) {
140
+ // Never swallow the cause: capture the server's own message + status/code/details
141
+ // so the toast can name the specific reason and the copy affordance can carry the
142
+ // full context (incl. the upstream GitHub status the backend puts on `details`).
143
+ const envelope = apiErrorEnvelope(e)
144
+ failures.push({
145
+ item,
146
+ message: e instanceof Error ? e.message : String(e),
147
+ status: apiErrorStatus(e),
148
+ code: envelope?.code,
149
+ details:
150
+ envelope?.details && typeof envelope.details === 'object'
151
+ ? (envelope.details as Record<string, unknown>)
152
+ : undefined,
153
+ })
66
154
  }
67
155
  }
68
- return failed
156
+ return failures
157
+ }
158
+
159
+ /**
160
+ * Surface link failures as a single actionable toast: the specific per-item
161
+ * reasons as the body, and a "Copy details" action that puts the full diagnostic
162
+ * report ({@link buildLinkFailureReport}) on the clipboard. Sticky (`duration: 0`)
163
+ * so the cause stays readable long enough to act on. No-op when nothing failed.
164
+ */
165
+ function presentLinkFailures(failures: LinkFailure[], blockId?: string): void {
166
+ if (failures.length === 0) return
167
+ const description = failures.map((f) => `${f.item.title}: ${f.message}`).join('\n')
168
+ const report = buildLinkFailureReport(failures, {
169
+ workspaceId: workspace.workspaceId,
170
+ blockId,
171
+ when: new Date().toISOString(),
172
+ })
173
+ toast.add({
174
+ title: t('board.addTask.linkFailed', { count: failures.length }, failures.length),
175
+ description,
176
+ icon: 'i-lucide-triangle-alert',
177
+ color: 'warning',
178
+ duration: 0,
179
+ actions: [copyAction(report)],
180
+ })
69
181
  }
70
182
 
71
- return { linkPending }
183
+ return { linkPending, presentLinkFailures }
72
184
  }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ // `useCopyToClipboard` wraps VueUse's clipboard; mock it so `copyAction` can be exercised
4
+ // without a real clipboard. The global `useToast`/`useI18n` stubs come from test/setup.ts
5
+ // (`t` echoes the key), so the default label resolves to its i18n key.
6
+ const { writeClipboard } = vi.hoisted(() => ({ writeClipboard: vi.fn(async () => {}) }))
7
+ vi.mock('@vueuse/core', () => ({
8
+ useClipboard: () => ({ copy: writeClipboard, isSupported: { value: true } }),
9
+ }))
10
+
11
+ import { useCopyToClipboard } from '~/composables/useCopyToClipboard'
12
+
13
+ describe('copyAction', () => {
14
+ it('builds a Copy-details toast action that copies the given text', async () => {
15
+ const action = useCopyToClipboard().copyAction('diagnostic report')
16
+ expect(action.label).toBe('common.copyDetails')
17
+ expect(action.icon).toBe('i-lucide-clipboard')
18
+
19
+ action.onClick()
20
+ await Promise.resolve()
21
+ expect(writeClipboard).toHaveBeenCalledWith('diagnostic report')
22
+ })
23
+
24
+ it('honours a custom label', () => {
25
+ expect(useCopyToClipboard().copyAction('x', 'Custom label').label).toBe('Custom label')
26
+ })
27
+ })
@@ -25,5 +25,21 @@ export function useCopyToClipboard() {
25
25
  }
26
26
  }
27
27
 
28
- return { copy, isSupported }
28
+ /**
29
+ * A ready-made toast action that copies `text` (through {@link copy}, so it shows the
30
+ * same "Copied" / "Copy failed" feedback). Drop it into a toast's `actions` so any
31
+ * error/warning toast can offer a one-click "Copy details" — the message + context the
32
+ * user would otherwise have to retype into a bug report.
33
+ */
34
+ function copyAction(text: string, label?: string) {
35
+ return {
36
+ label: label ?? t('common.copyDetails'),
37
+ icon: 'i-lucide-clipboard',
38
+ onClick: () => {
39
+ void copy(text)
40
+ },
41
+ }
42
+ }
43
+
44
+ return { copy, copyAction, isSupported }
29
45
  }
@@ -11,6 +11,7 @@ import type {
11
11
  GitHubRepo,
12
12
  MergePullRequestInput,
13
13
  OpenPullRequestInput,
14
+ RepoTreeEntry,
14
15
  ResyncRequest,
15
16
  } from '~/types/domain'
16
17
  import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
@@ -215,6 +216,26 @@ export const useGitHubStore = defineStore('github', () => {
215
216
  return api.listGitHubRepoTree(workspace.requireId(), repoGithubId, path)
216
217
  }
217
218
 
219
+ /** Full file listing per repo (recursive tree), cached by GitHub numeric id. */
220
+ const repoFiles = ref<Record<number, RepoTreeEntry[]>>({})
221
+
222
+ /**
223
+ * Load (and cache) EVERY file in a repo — the whole tree in one recursive read — so a
224
+ * picker can search files by path entirely client-side (no per-keystroke server call).
225
+ * Cached by repo id so re-opening the picker for the same repo is instant; force a
226
+ * refetch with `{ reload: true }`. Mirrors the branches cache.
227
+ */
228
+ async function loadRepoFiles(
229
+ repoGithubId: number,
230
+ opts: { reload?: boolean } = {},
231
+ ): Promise<RepoTreeEntry[]> {
232
+ const cached = repoFiles.value[repoGithubId]
233
+ if (cached && !opts.reload) return cached
234
+ const files = await api.listGitHubRepoFiles(workspace.requireId(), repoGithubId)
235
+ repoFiles.value = { ...repoFiles.value, [repoGithubId]: files }
236
+ return files
237
+ }
238
+
218
239
  /** The URL a workspace owner visits to install the App against this workspace. */
219
240
  function getInstallUrl(): Promise<string> {
220
241
  return api.getGitHubInstallUrl(workspace.requireId()).then((r) => r.url)
@@ -313,6 +334,7 @@ export const useGitHubStore = defineStore('github', () => {
313
334
  pulls.value = []
314
335
  issues.value = []
315
336
  branches.value = {}
337
+ repoFiles.value = {}
316
338
  }
317
339
 
318
340
  return {
@@ -347,6 +369,8 @@ export const useGitHubStore = defineStore('github', () => {
347
369
  searchAvailableRepos,
348
370
  setLinkedRepos,
349
371
  loadRepoTree,
372
+ repoFiles,
373
+ loadRepoFiles,
350
374
  loadBranches,
351
375
  getInstallUrl,
352
376
  loadInstallations,
@@ -82,6 +82,8 @@ export const usePipelinesStore = defineStore('pipelines', () => {
82
82
  /** Organizational labels for the pipeline being assembled/edited. */
83
83
  const draftLabels = ref<string[]>([])
84
84
  const draftName = ref('New pipeline')
85
+ /** Prose description for the pipeline being assembled/edited (shown in the pickers). */
86
+ const draftDescription = ref('')
85
87
  /** The id of the pipeline being edited, or null when assembling a brand-new one. */
86
88
  const editingId = ref<string | null>(null)
87
89
 
@@ -322,6 +324,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
322
324
  draftStepOptions.value = []
323
325
  draftLabels.value = []
324
326
  draftName.value = 'New pipeline'
327
+ draftDescription.value = ''
325
328
  editingId.value = null
326
329
  }
327
330
 
@@ -340,6 +343,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
340
343
  draftStepOptions.value = pipeline.agentKinds.map((_, i) => pipeline.stepOptions?.[i] ?? null)
341
344
  draftLabels.value = [...(pipeline.labels ?? [])]
342
345
  draftName.value = pipeline.name
346
+ draftDescription.value = pipeline.description ?? ''
343
347
  editingId.value = pipeline.id
344
348
  }
345
349
 
@@ -347,6 +351,10 @@ export const usePipelinesStore = defineStore('pipelines', () => {
347
351
  function draftPayload() {
348
352
  return {
349
353
  name: draftName.value.trim() || 'Untitled pipeline',
354
+ // ALWAYS send description (like stepOptions) so an update can CLEAR it — an omitted field
355
+ // reads as "keep existing", so toggling the last of the text away must send the empty string.
356
+ // The backend trims + drops a blank one, so this is a no-op on create / an empty description.
357
+ description: draftDescription.value.trim(),
350
358
  agentKinds: [...draft.value],
351
359
  // Only send gates when at least one step is gated.
352
360
  ...(draftGates.value.some(Boolean) ? { gates: [...draftGates.value] } : {}),
@@ -452,6 +460,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
452
460
  draftStepOptions,
453
461
  draftLabels,
454
462
  draftName,
463
+ draftDescription,
455
464
  editingId,
456
465
  units,
457
466
  hydrate,