@cat-factory/app 0.138.1 → 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.
Files changed (47) hide show
  1. package/app/components/board/AddTaskModal.vue +12 -39
  2. package/app/components/brainstorm/BrainstormWindow.vue +338 -374
  3. package/app/components/clarity/ClarityReviewWindow.vue +323 -353
  4. package/app/components/consensus/ConsensusSessionWindow.vue +138 -150
  5. package/app/components/docs/DocInterviewWindow.vue +108 -136
  6. package/app/components/documents/ContextDocumentPicker.vue +89 -70
  7. package/app/components/documents/RepoContextDocPicker.vue +277 -0
  8. package/app/components/followUp/FollowUpWindow.vue +3 -5
  9. package/app/components/forkDecision/ForkDecisionWindow.vue +1 -3
  10. package/app/components/gates/GateResultView.vue +3 -5
  11. package/app/components/github/RepoTreeBrowser.vue +19 -10
  12. package/app/components/humanTest/HumanTestWindow.vue +3 -5
  13. package/app/components/initiative/InitiativePlanningWindow.vue +89 -112
  14. package/app/components/initiative/InitiativeTrackerWindow.vue +389 -424
  15. package/app/components/panels/GenericStructuredResultView.vue +3 -5
  16. package/app/components/panels/MergerResultView.vue +3 -5
  17. package/app/components/panels/ResultWindowShell.vue +1 -1
  18. package/app/components/panels/inspector/TaskRunSettings.vue +16 -23
  19. package/app/components/pipeline/PipelineBuilder.vue +10 -0
  20. package/app/components/pipeline/PipelinePicker.vue +120 -0
  21. package/app/components/pipeline/PipelinePreview.vue +49 -0
  22. package/app/components/prReview/PrReviewWindow.vue +181 -205
  23. package/app/components/ralph/RalphLoopResultView.vue +3 -5
  24. package/app/components/requirements/RequirementsReviewWindow.vue +512 -557
  25. package/app/components/spec/ServiceSpecWindow.vue +235 -266
  26. package/app/components/testing/TestReportWindow.vue +4 -6
  27. package/app/components/visualConfirm/VisualConfirmationWindow.vue +3 -5
  28. package/app/composables/api/github.ts +8 -0
  29. package/app/composables/useContextLinking.spec.ts +138 -0
  30. package/app/composables/useContextLinking.ts +120 -8
  31. package/app/composables/useCopyToClipboard.spec.ts +27 -0
  32. package/app/composables/useCopyToClipboard.ts +17 -1
  33. package/app/composables/useResultView.ts +10 -21
  34. package/app/stores/github.ts +24 -0
  35. package/app/stores/pipelines.ts +9 -0
  36. package/app/utils/pipeline.ts +25 -1
  37. package/i18n/locales/de.json +20 -0
  38. package/i18n/locales/en.json +20 -0
  39. package/i18n/locales/es.json +20 -0
  40. package/i18n/locales/fr.json +20 -0
  41. package/i18n/locales/he.json +20 -0
  42. package/i18n/locales/it.json +20 -0
  43. package/i18n/locales/ja.json +20 -0
  44. package/i18n/locales/pl.json +20 -0
  45. package/i18n/locales/tr.json +20 -0
  46. package/i18n/locales/uk.json +20 -0
  47. package/package.json +2 -2
@@ -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
  }
@@ -13,23 +13,20 @@
13
13
  * A synchronous window (one that reads its data straight off the execution step, like the
14
14
  * test report) simply omits `onOpen`.
15
15
  *
16
- * `onClose` runs on EVERY close path — the X button, backdrop click, and the Escape key
17
- * handled here — BEFORE the view is torn down, so a window with unsaved draft input (the
18
- * review windows) can flush it in one place instead of every caller having to remember to.
19
- * It runs synchronously; if it kicks off async work it must capture whatever it needs first,
20
- * because `blockId`/the derived state go null the moment the view closes.
16
+ * `onClose` runs on EVERY close path — the X button, backdrop click, and the Escape key
17
+ * BEFORE the view is torn down, so a window with unsaved draft input (the review windows) can
18
+ * flush it in one place instead of every caller having to remember to. It runs synchronously;
19
+ * if it kicks off async work it must capture whatever it needs first, because `blockId`/the
20
+ * derived state go null the moment the view closes.
21
21
  *
22
- * `manageEscape` (default `true`) owns the global Escape-to-close listener. A window that
23
- * renders through `ResultWindowShell` (slice 5 of the modular-vue adoption) passes `false`:
24
- * the shell's `useModalBehavior` owns Escape via the shared overlay stack (so the top
25
- * overlay closes first, and focus/scroll are managed too), and a second listener here would
26
- * double-fire `close`. Un-converted windows keep the default so they still close on Escape.
27
- * Remove this option once every result window is on the shell (the listener moves out
28
- * entirely).
22
+ * Escape-to-close is NOT owned here: every result window renders through `ResultWindowShell`
23
+ * (slice 5 of the modular-vue adoption), whose `useModalBehavior` owns Escape via the shared
24
+ * overlay stack (top overlay closes first, focus/scroll managed too). A listener here would
25
+ * double-fire `close`, so it was removed once the last window converted onto the shell.
29
26
  */
30
27
  export function useResultView(
31
28
  viewId: string,
32
- opts?: { onOpen?: (blockId: string) => void; onClose?: () => void; manageEscape?: boolean },
29
+ opts?: { onOpen?: (blockId: string) => void; onClose?: () => void },
33
30
  ) {
34
31
  const ui = useUiStore()
35
32
 
@@ -47,14 +44,6 @@ export function useResultView(
47
44
  ui.closeResultView()
48
45
  }
49
46
 
50
- function onKey(e: KeyboardEvent) {
51
- if (e.key === 'Escape' && open.value) close()
52
- }
53
- if (opts?.manageEscape !== false) {
54
- onMounted(() => window.addEventListener('keydown', onKey))
55
- onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
56
- }
57
-
58
47
  // The load-on-open contract: fire immediately on mount and on any later block switch.
59
48
  if (opts?.onOpen) {
60
49
  watch(
@@ -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,
@@ -1,5 +1,29 @@
1
1
  import { frameAllowsVisualPipeline, pipelineHasVisualStep } from '@cat-factory/contracts'
2
- import type { Block, Pipeline } from '~/types/domain'
2
+ import type { AgentKind, Block, Pipeline } from '~/types/domain'
3
+
4
+ /** One agent step of a pipeline as shown in a preview: its kind + whether it's a human-gated step. */
5
+ export interface PipelineDisplayStep {
6
+ kind: AgentKind
7
+ /** A human approval gate pauses the run after this step (`gates[i]`). */
8
+ gated: boolean
9
+ }
10
+
11
+ /**
12
+ * The steps a pipeline preview should render: the ENABLED steps in order (a step disabled by
13
+ * default — `enabled[i] === false` — is skipped at run, so it would misrepresent the pipeline to
14
+ * list it), each flagged when it carries a human approval gate. Companions are included as their
15
+ * own chips, mirroring how the run timeline lists every step.
16
+ */
17
+ export function pipelineDisplaySteps(pipeline: Pipeline): PipelineDisplayStep[] {
18
+ return pipeline.agentKinds
19
+ .map((kind, i) => ({
20
+ kind,
21
+ enabled: pipeline.enabled?.[i] !== false,
22
+ gated: pipeline.gates?.[i] === true,
23
+ }))
24
+ .filter((s) => s.enabled)
25
+ .map(({ kind, gated }) => ({ kind, gated }))
26
+ }
3
27
 
4
28
  // Surface counterpart to the backend's slice-4c run-start gate: a pipeline with a visual step
5
29
  // (`tester-ui` / `visual-confirmation`) may run only on a frame with a UI to exercise — a
@@ -2936,6 +2936,17 @@
2936
2936
  "emptySearchable": "Nach Titel suchen oder ein importiertes Dokument auswählen.",
2937
2937
  "emptyRefOnly": "Fügen Sie eine Seiten-URL oder -ID ein, um sie anzuhängen."
2938
2938
  },
2939
+ "repoPicker": {
2940
+ "searchRepoPlaceholder": "Repositorys suchen…",
2941
+ "clearRepo": "Repository entfernen",
2942
+ "searchTab": "Dateien suchen",
2943
+ "browseTab": "Durchsuchen",
2944
+ "searchFilesPlaceholder": "Dateien nach Pfad suchen…",
2945
+ "searchFilesHint": "Tippen, um Dateien in diesem Repository zu suchen.",
2946
+ "noFileMatches": "Keine passenden Dateien.",
2947
+ "filesFailed": "Dateien konnten nicht geladen werden: {error}",
2948
+ "moreFiles": "Es werden die ersten {count} Treffer angezeigt – grenzen Sie Ihre Suche ein."
2949
+ },
2939
2950
  "import": {
2940
2951
  "title": "Aus einer Dokumentquelle importieren",
2941
2952
  "connectFirst": "Verbinden Sie zuerst eine Dokumentquelle.",
@@ -3084,6 +3095,13 @@
3084
3095
  "proceed": "Trotzdem fortfahren",
3085
3096
  "stopReset": "Aufgabe stoppen & zurücksetzen"
3086
3097
  },
3098
+ "preview": {
3099
+ "stepCount": "{count} Schritt | {count} Schritte",
3100
+ "gated": "Manuelle Freigabe nach diesem Schritt"
3101
+ },
3102
+ "picker": {
3103
+ "noneHint": "Keine Standard-Pipeline. Du wählst beim Ausführen der Aufgabe eine aus."
3104
+ },
3087
3105
  "builder": {
3088
3106
  "title": "Pipeline-Builder",
3089
3107
  "agentPalette": "Agenten-Palette",
@@ -3092,6 +3110,7 @@
3092
3110
  "configureModels": "Modelle konfigurieren",
3093
3111
  "configureModelsTooltip": "Modell-Presets verwalten (auf welchem Modell jeder Agent läuft)",
3094
3112
  "namePlaceholder": "Pipeline-Name",
3113
+ "descriptionPlaceholder": "Beschreibung (wird bei der Pipeline-Auswahl angezeigt)",
3095
3114
  "labelPlaceholder": "+ Label",
3096
3115
  "gatingNeedsEstimator": "Ein Gated-Schritt benötigt davor einen Task Estimator. Fügen Sie einen hinzu, sonst wird die Pipeline nicht gespeichert.",
3097
3116
  "emptyDraft": "Klicken Sie links auf Agenten, um eine lineare Pipeline zusammenzustellen.",
@@ -3892,6 +3911,7 @@
3892
3911
  "retry": "Erneut versuchen",
3893
3912
  "copy": "Kopieren",
3894
3913
  "copied": "In die Zwischenablage kopiert",
3914
+ "copyDetails": "Details kopieren",
3895
3915
  "reveal": "Anzeigen",
3896
3916
  "hide": "Verbergen",
3897
3917
  "delete": "Löschen",
@@ -30,6 +30,7 @@
30
30
  "retry": "Retry",
31
31
  "copy": "Copy",
32
32
  "copied": "Copied to clipboard",
33
+ "copyDetails": "Copy details",
33
34
  "reveal": "Reveal",
34
35
  "hide": "Hide",
35
36
  "delete": "Delete",
@@ -3298,6 +3299,17 @@
3298
3299
  "emptySearchable": "Search by title, or pick an imported document.",
3299
3300
  "emptyRefOnly": "Paste a page URL or ID to attach it."
3300
3301
  },
3302
+ "repoPicker": {
3303
+ "searchRepoPlaceholder": "Search repositories…",
3304
+ "clearRepo": "Clear repository",
3305
+ "searchTab": "Search files",
3306
+ "browseTab": "Browse",
3307
+ "searchFilesPlaceholder": "Search files by path…",
3308
+ "searchFilesHint": "Type to search files in this repository.",
3309
+ "noFileMatches": "No matching files.",
3310
+ "filesFailed": "Couldn't load files: {error}",
3311
+ "moreFiles": "Showing the first {count} matches — refine your search to narrow it down."
3312
+ },
3301
3313
  "import": {
3302
3314
  "title": "Import from a document source",
3303
3315
  "connectFirst": "Connect a document source first.",
@@ -3449,6 +3461,13 @@
3449
3461
  "proceed": "Proceed anyway",
3450
3462
  "stopReset": "Stop & reset task"
3451
3463
  },
3464
+ "preview": {
3465
+ "stepCount": "{count} step | {count} steps",
3466
+ "gated": "Human approval gate after this step"
3467
+ },
3468
+ "picker": {
3469
+ "noneHint": "No default pipeline. You choose one when you run the task."
3470
+ },
3452
3471
  "builder": {
3453
3472
  "title": "Pipeline builder",
3454
3473
  "agentPalette": "Agent palette",
@@ -3457,6 +3476,7 @@
3457
3476
  "configureModels": "Configure models",
3458
3477
  "configureModelsTooltip": "Manage model presets (which model each agent runs on)",
3459
3478
  "namePlaceholder": "Pipeline name",
3479
+ "descriptionPlaceholder": "Description (shown when picking this pipeline)",
3460
3480
  "labelPlaceholder": "+ label",
3461
3481
  "gatingNeedsEstimator": "A gated step needs a Task Estimator before it. Add one or the pipeline won't save.",
3462
3482
  "emptyDraft": "Click agents on the left to assemble a linear pipeline.",
@@ -49,6 +49,7 @@
49
49
  "reconfigureHint": "Puedes volver a configurarlo más tarde."
50
50
  },
51
51
  "copied": "Copiado al portapapeles",
52
+ "copyDetails": "Copiar detalles",
52
53
  "reveal": "Mostrar",
53
54
  "hide": "Ocultar",
54
55
  "disconnect": "Desconectar",
@@ -3202,6 +3203,17 @@
3202
3203
  "emptySearchable": "Busca por título o elige un documento importado.",
3203
3204
  "emptyRefOnly": "Pega la URL o el ID de una página para adjuntarla."
3204
3205
  },
3206
+ "repoPicker": {
3207
+ "searchRepoPlaceholder": "Buscar repositorios…",
3208
+ "clearRepo": "Quitar repositorio",
3209
+ "searchTab": "Buscar archivos",
3210
+ "browseTab": "Explorar",
3211
+ "searchFilesPlaceholder": "Buscar archivos por ruta…",
3212
+ "searchFilesHint": "Escribe para buscar archivos en este repositorio.",
3213
+ "noFileMatches": "No hay archivos coincidentes.",
3214
+ "filesFailed": "No se pudieron cargar los archivos: {error}",
3215
+ "moreFiles": "Mostrando las primeras {count} coincidencias; refina tu búsqueda para acotarla."
3216
+ },
3205
3217
  "import": {
3206
3218
  "title": "Importar desde una fuente de documentos",
3207
3219
  "connectFirst": "Conecta primero una fuente de documentos.",
@@ -3350,6 +3362,13 @@
3350
3362
  "proceed": "Continuar de todos modos",
3351
3363
  "stopReset": "Detener y reiniciar la tarea"
3352
3364
  },
3365
+ "preview": {
3366
+ "stepCount": "{count} paso | {count} pasos",
3367
+ "gated": "Aprobación humana después de este paso"
3368
+ },
3369
+ "picker": {
3370
+ "noneHint": "Sin pipeline predeterminado. Eliges uno al ejecutar la tarea."
3371
+ },
3353
3372
  "builder": {
3354
3373
  "title": "Constructor de pipelines",
3355
3374
  "agentPalette": "Paleta de agentes",
@@ -3358,6 +3377,7 @@
3358
3377
  "configureModels": "Configurar modelos",
3359
3378
  "configureModelsTooltip": "Gestionar los preajustes de modelos (qué modelo ejecuta cada agente)",
3360
3379
  "namePlaceholder": "Nombre del pipeline",
3380
+ "descriptionPlaceholder": "Descripción (se muestra al elegir este pipeline)",
3361
3381
  "labelPlaceholder": "+ etiqueta",
3362
3382
  "gatingNeedsEstimator": "Un paso con compuerta necesita un Task Estimator antes. Añade uno o el pipeline no se guardará.",
3363
3383
  "emptyDraft": "Haz clic en los agentes de la izquierda para montar un pipeline lineal.",