@cat-factory/app 1.0.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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -0
  3. package/app/app.config.ts +8 -0
  4. package/app/app.vue +11 -0
  5. package/app/assets/css/main.css +100 -0
  6. package/app/components/auth/AuthGate.vue +24 -0
  7. package/app/components/auth/LoginScreen.vue +18 -0
  8. package/app/components/auth/UserMenu.vue +39 -0
  9. package/app/components/board/AgentFailureCard.vue +97 -0
  10. package/app/components/board/AgentStopButton.vue +61 -0
  11. package/app/components/board/BoardCanvas.vue +146 -0
  12. package/app/components/board/TaskDependencyEdges.vue +132 -0
  13. package/app/components/board/nodes/AgentChip.vue +59 -0
  14. package/app/components/board/nodes/BlockNode.vue +347 -0
  15. package/app/components/board/nodes/DecisionBadge.vue +21 -0
  16. package/app/components/board/nodes/DraggableTask.vue +69 -0
  17. package/app/components/board/nodes/ModuleFrame.vue +70 -0
  18. package/app/components/board/nodes/TaskCard.vue +237 -0
  19. package/app/components/bootstrap/BootstrapModal.vue +665 -0
  20. package/app/components/documents/DocumentImportModal.vue +161 -0
  21. package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
  22. package/app/components/documents/SpawnPreviewModal.vue +161 -0
  23. package/app/components/documents/TaskContextDocs.vue +83 -0
  24. package/app/components/focus/BlockFocusView.vue +161 -0
  25. package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
  26. package/app/components/github/GitHubConnect.vue +183 -0
  27. package/app/components/github/GitHubPanel.vue +584 -0
  28. package/app/components/layout/BoardSwitcher.vue +202 -0
  29. package/app/components/layout/BoardToolbar.vue +109 -0
  30. package/app/components/layout/SideBar.vue +193 -0
  31. package/app/components/layout/SpendWarningBanner.vue +107 -0
  32. package/app/components/palettes/AgentPalette.vue +33 -0
  33. package/app/components/palettes/BlockPalette.vue +41 -0
  34. package/app/components/palettes/PipelinePalette.vue +74 -0
  35. package/app/components/panels/DecisionModal.vue +71 -0
  36. package/app/components/panels/InspectorPanel.vue +296 -0
  37. package/app/components/panels/inspector/ContainerSummary.vue +74 -0
  38. package/app/components/panels/inspector/TaskDependencies.vue +70 -0
  39. package/app/components/panels/inspector/TaskExecution.vue +175 -0
  40. package/app/components/panels/inspector/TaskModelSettings.vue +128 -0
  41. package/app/components/panels/inspector/TaskStructure.vue +139 -0
  42. package/app/components/pipeline/PipelineBuilder.vue +227 -0
  43. package/app/components/pipeline/PipelineProgress.vue +246 -0
  44. package/app/components/requirements/RequirementReviewModal.vue +328 -0
  45. package/app/components/scenarios/FeatureScenarios.vue +162 -0
  46. package/app/components/scenarios/ScenarioCard.vue +109 -0
  47. package/app/components/tasks/TaskContextIssues.vue +88 -0
  48. package/app/components/tasks/TaskImportModal.vue +140 -0
  49. package/app/components/tasks/TaskSourceConnectModal.vue +122 -0
  50. package/app/composables/useApi.ts +535 -0
  51. package/app/composables/useBlockDrag.ts +75 -0
  52. package/app/composables/useBlockQueries.ts +136 -0
  53. package/app/composables/useBoardFlow.ts +11 -0
  54. package/app/composables/useDepLabels.ts +26 -0
  55. package/app/composables/useSemanticZoom.ts +16 -0
  56. package/app/composables/useWorkspaceStream.ts +125 -0
  57. package/app/docs/architecture.md +31 -0
  58. package/app/pages/index.vue +80 -0
  59. package/app/stores/accounts.ts +64 -0
  60. package/app/stores/agentRuns.ts +117 -0
  61. package/app/stores/agents.ts +40 -0
  62. package/app/stores/auth.ts +97 -0
  63. package/app/stores/board.spec.ts +197 -0
  64. package/app/stores/board.ts +147 -0
  65. package/app/stores/bootstrap.ts +97 -0
  66. package/app/stores/documents.ts +165 -0
  67. package/app/stores/execution.ts +115 -0
  68. package/app/stores/fragmentLibrary.ts +147 -0
  69. package/app/stores/fragments.ts +40 -0
  70. package/app/stores/github.ts +291 -0
  71. package/app/stores/models.ts +48 -0
  72. package/app/stores/pipelines.ts +77 -0
  73. package/app/stores/requirements.ts +133 -0
  74. package/app/stores/scenarios.spec.ts +82 -0
  75. package/app/stores/scenarios.ts +196 -0
  76. package/app/stores/tasks.spec.ts +71 -0
  77. package/app/stores/tasks.ts +149 -0
  78. package/app/stores/ui.ts +204 -0
  79. package/app/stores/workspace.ts +201 -0
  80. package/app/types/accounts.ts +38 -0
  81. package/app/types/bootstrap.ts +83 -0
  82. package/app/types/documents.ts +92 -0
  83. package/app/types/domain.ts +216 -0
  84. package/app/types/execution.ts +110 -0
  85. package/app/types/fragments.ts +72 -0
  86. package/app/types/github.ts +153 -0
  87. package/app/types/models.ts +48 -0
  88. package/app/types/requirements.ts +38 -0
  89. package/app/types/scenarios.ts +36 -0
  90. package/app/types/tasks.ts +67 -0
  91. package/app/utils/catalog.spec.ts +82 -0
  92. package/app/utils/catalog.ts +185 -0
  93. package/app/utils/dnd.ts +29 -0
  94. package/nuxt.config.ts +43 -0
  95. package/package.json +43 -0
@@ -0,0 +1,175 @@
1
+ <script setup lang="ts">
2
+ import type { Block } from '~/types/domain'
3
+ import { AGENT_BY_KIND } from '~/utils/catalog'
4
+ import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
5
+
6
+ const props = defineProps<{ block: Block }>()
7
+
8
+ const execution = useExecutionStore()
9
+ const agentRuns = useAgentRunsStore()
10
+ const ui = useUiStore()
11
+ const models = useModelsStore()
12
+
13
+ const instance = computed(() => execution.getInstance(props.block.executionId))
14
+
15
+ // A failed pipeline run surfaces the shared failure banner + retry — the
16
+ // execution failure surface that the old `pr_ready` flip used to hide.
17
+ const failedRun = computed(() => {
18
+ const run = agentRuns.byBlock[props.block.id]
19
+ return run && run.status === 'failed' ? run : null
20
+ })
21
+
22
+ const pr = computed(() => props.block.pullRequest)
23
+ /** A PR is merged once the block is `done`; otherwise it is open awaiting merge. */
24
+ const prMerged = computed(() => props.block.status === 'done')
25
+ const prLabel = computed(() => {
26
+ const number = pr.value?.number
27
+ return number ? `PR #${number}` : 'Pull request'
28
+ })
29
+
30
+ const stepLabel: Record<string, string> = {
31
+ pending: 'Pending',
32
+ working: 'Working',
33
+ waiting_decision: 'Needs decision',
34
+ done: 'Done',
35
+ }
36
+
37
+ function openDecisionFor(decisionId: string) {
38
+ if (instance.value) ui.openDecision(instance.value.id, decisionId)
39
+ }
40
+ </script>
41
+
42
+ <template>
43
+ <div class="space-y-4">
44
+ <!-- running pipeline -->
45
+ <div v-if="instance">
46
+ <div class="mb-1 flex items-center justify-between">
47
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
48
+ {{ instance.pipelineName }}
49
+ </span>
50
+ <UButton
51
+ icon="i-lucide-square"
52
+ color="error"
53
+ variant="ghost"
54
+ size="xs"
55
+ @click="execution.cancel(block.id)"
56
+ >
57
+ Stop
58
+ </UButton>
59
+ </div>
60
+ <ul class="space-y-1">
61
+ <li
62
+ v-for="(s, i) in instance.steps"
63
+ :key="i"
64
+ class="rounded-md px-2 py-1"
65
+ :class="i === instance.currentStep ? 'bg-slate-800/70' : ''"
66
+ >
67
+ <div class="flex items-center gap-2">
68
+ <UIcon
69
+ :name="AGENT_BY_KIND[s.agentKind].icon"
70
+ class="h-4 w-4"
71
+ :style="{ color: AGENT_BY_KIND[s.agentKind].color }"
72
+ />
73
+ <span class="text-xs text-slate-200">{{ AGENT_BY_KIND[s.agentKind].label }}</span>
74
+ <span
75
+ v-if="s.subtasks && s.subtasks.total > 0"
76
+ class="ml-auto font-mono text-[10px] tabular-nums text-slate-300"
77
+ :title="
78
+ s.subtasks.inProgress > 0
79
+ ? `${s.subtasks.completed} of ${s.subtasks.total} subtasks done, ${s.subtasks.inProgress} in progress`
80
+ : `${s.subtasks.completed} of ${s.subtasks.total} subtasks done`
81
+ "
82
+ >
83
+ {{ s.subtasks.completed }}/{{ s.subtasks.total }}
84
+ </span>
85
+ <span class="text-[10px] text-slate-400" :class="{ 'ml-auto': !s.subtasks }">
86
+ {{ stepLabel[s.state] }}
87
+ </span>
88
+ <UButton
89
+ v-if="s.decision && !s.decision.chosen"
90
+ color="warning"
91
+ variant="soft"
92
+ size="xs"
93
+ icon="i-lucide-circle-help"
94
+ @click="openDecisionFor(s.decision.id)"
95
+ >
96
+ Resolve
97
+ </UButton>
98
+ </div>
99
+ <div
100
+ v-if="s.subtasks && s.subtasks.total > 0"
101
+ class="mt-1 ml-6 h-1 overflow-hidden rounded-full bg-slate-700/60"
102
+ >
103
+ <div
104
+ class="h-full rounded-full bg-indigo-400 transition-all duration-500"
105
+ :style="{ width: `${(s.subtasks.completed / s.subtasks.total) * 100}%` }"
106
+ />
107
+ </div>
108
+ <div
109
+ v-if="s.model"
110
+ class="mt-0.5 flex items-center gap-1 pl-6 text-[10px] text-slate-500"
111
+ :title="s.model"
112
+ >
113
+ <UIcon name="i-lucide-cpu" class="h-3 w-3" />
114
+ {{ models.labelForRef(s.model) }}
115
+ </div>
116
+ <!-- Prompt-fragment standards the library selected for this step. -->
117
+ <div
118
+ v-if="s.selectedFragmentIds && s.selectedFragmentIds.length"
119
+ class="mt-0.5 flex flex-wrap items-center gap-1 pl-6 text-[10px] text-slate-500"
120
+ :title="`Best-practice fragments folded into this step: ${s.selectedFragmentIds.join(', ')}`"
121
+ >
122
+ <UIcon name="i-lucide-book-marked" class="h-3 w-3 shrink-0" />
123
+ <span>{{ s.selectedFragmentIds.length }} standard(s) applied</span>
124
+ </div>
125
+ </li>
126
+ </ul>
127
+ </div>
128
+
129
+ <!-- failed run: shared failure banner + retry -->
130
+ <AgentFailureCard v-if="failedRun" :run="failedRun" />
131
+
132
+ <!-- Open PR: link straight to it on GitHub -->
133
+ <div v-if="pr" class="space-y-2">
134
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
135
+ Pull request
136
+ </span>
137
+ <UButton
138
+ :to="pr.url"
139
+ target="_blank"
140
+ rel="noopener"
141
+ external
142
+ color="neutral"
143
+ variant="soft"
144
+ size="sm"
145
+ icon="i-lucide-git-pull-request"
146
+ trailing-icon="i-lucide-external-link"
147
+ block
148
+ >
149
+ <span class="flex w-full items-center gap-2">
150
+ {{ prLabel }}
151
+ <UBadge :color="prMerged ? 'success' : 'info'" variant="subtle" size="sm" class="ml-auto">
152
+ {{ prMerged ? 'Merged' : 'Open' }}
153
+ </UBadge>
154
+ </span>
155
+ </UButton>
156
+ <p v-if="pr.branch" class="flex items-center gap-1 truncate text-[10px] text-slate-500">
157
+ <UIcon name="i-lucide-git-branch" class="h-3 w-3 shrink-0" />
158
+ <span class="truncate" :title="pr.branch">{{ pr.branch }}</span>
159
+ </p>
160
+ </div>
161
+
162
+ <!-- PR ready: merge -->
163
+ <UButton
164
+ v-if="block.status === 'pr_ready'"
165
+ color="success"
166
+ variant="solid"
167
+ size="sm"
168
+ icon="i-lucide-git-merge"
169
+ block
170
+ @click="execution.mergePr(block.id)"
171
+ >
172
+ Merge PR
173
+ </UButton>
174
+ </div>
175
+ </template>
@@ -0,0 +1,128 @@
1
+ <script setup lang="ts">
2
+ import type { Block } from '~/types/domain'
3
+ import { DEFAULT_CONFIDENCE_THRESHOLD } from '~/utils/catalog'
4
+
5
+ const props = defineProps<{ block: Block }>()
6
+
7
+ const board = useBoardStore()
8
+ const models = useModelsStore()
9
+
10
+ // ---- model selection -------------------------------------------------------
11
+ // The model picked for this block (resolved against the deployment's effective
12
+ // catalog); when none is selected the backend runs it with the default model.
13
+ const selectedModel = computed(() => models.getModel(props.block.modelId))
14
+
15
+ // Picker menu: a "Default" reset plus each catalog model. Each label shows the
16
+ // active flavour (Cloudflare vs the direct provider) so it's clear what will run.
17
+ const modelMenu = computed(() => [
18
+ [
19
+ {
20
+ label: 'Default (Qwen)',
21
+ icon: 'i-lucide-rotate-ccw',
22
+ onSelect: () => setModel(''),
23
+ },
24
+ ...models.models.map((m) => ({
25
+ label: `${m.label} · ${m.providerLabel}`,
26
+ icon: m.flavor === 'direct' ? 'i-lucide-zap' : 'i-lucide-cloud',
27
+ onSelect: () => setModel(m.id),
28
+ })),
29
+ ],
30
+ ])
31
+
32
+ function setModel(id: string) {
33
+ board.updateBlock(props.block.id, { modelId: id })
34
+ }
35
+
36
+ // ---- confidence threshold (percent <-> 0..1) -------------------------------
37
+ const thresholdPct = computed({
38
+ get: () => Math.round((props.block.confidenceThreshold ?? DEFAULT_CONFIDENCE_THRESHOLD) * 100),
39
+ set: (v: number) =>
40
+ board.updateBlock(props.block.id, {
41
+ confidenceThreshold: Math.min(100, Math.max(0, v)) / 100,
42
+ }),
43
+ })
44
+ const confidencePct = computed(() =>
45
+ props.block.confidence != null ? Math.round(props.block.confidence * 100) : null,
46
+ )
47
+ </script>
48
+
49
+ <template>
50
+ <div class="space-y-4">
51
+ <!-- model selection -->
52
+ <div>
53
+ <div class="mb-1 flex items-center justify-between">
54
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
55
+ Model
56
+ </span>
57
+ <UDropdownMenu :items="modelMenu">
58
+ <UButton
59
+ size="xs"
60
+ variant="ghost"
61
+ color="neutral"
62
+ icon="i-lucide-cpu"
63
+ trailing-icon="i-lucide-chevron-down"
64
+ />
65
+ </UDropdownMenu>
66
+ </div>
67
+ <div v-if="selectedModel" class="flex items-center gap-1">
68
+ <UBadge
69
+ color="primary"
70
+ variant="subtle"
71
+ size="sm"
72
+ class="cursor-pointer"
73
+ :title="selectedModel.description"
74
+ @click="setModel('')"
75
+ >
76
+ {{ selectedModel.label }}<UIcon name="i-lucide-x" class="ml-0.5 h-3 w-3" />
77
+ </UBadge>
78
+ <UBadge
79
+ :color="selectedModel.flavor === 'direct' ? 'success' : 'neutral'"
80
+ variant="subtle"
81
+ size="sm"
82
+ :title="
83
+ selectedModel.flavor === 'direct'
84
+ ? `Direct via ${selectedModel.providerLabel}`
85
+ : 'Cloudflare Workers AI'
86
+ "
87
+ >
88
+ {{ selectedModel.providerLabel }}
89
+ </UBadge>
90
+ </div>
91
+ <div v-else class="text-[11px] text-slate-500">
92
+ Default — runs the Qwen model ({{
93
+ models.getModel('qwen')?.providerLabel ?? 'Cloudflare'
94
+ }}).
95
+ </div>
96
+ </div>
97
+
98
+ <!-- confidence threshold -->
99
+ <div>
100
+ <div class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
101
+ Auto-merge threshold
102
+ </div>
103
+ <div class="flex items-center gap-2">
104
+ <UInput
105
+ v-model.number="thresholdPct"
106
+ type="number"
107
+ min="0"
108
+ max="100"
109
+ size="sm"
110
+ class="w-20"
111
+ />
112
+ <span class="text-[11px] text-slate-400">% confidence</span>
113
+ </div>
114
+ <div v-if="confidencePct != null" class="mt-1 text-[11px]">
115
+ Last run scored
116
+ <span
117
+ :class="
118
+ block.confidence! >= (block.confidenceThreshold ?? 0.8)
119
+ ? 'text-emerald-400'
120
+ : 'text-amber-400'
121
+ "
122
+ >
123
+ {{ confidencePct }}%
124
+ </span>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ </template>
@@ -0,0 +1,139 @@
1
+ <script setup lang="ts">
2
+ import type { Block } from '~/types/domain'
3
+
4
+ const props = defineProps<{ block: Block }>()
5
+
6
+ const board = useBoardStore()
7
+ const fragments = useFragmentsStore()
8
+
9
+ // ---- features implemented --------------------------------------------------
10
+ const newFeature = ref('')
11
+ function addFeature() {
12
+ const v = newFeature.value.trim()
13
+ if (!v) return
14
+ const list = props.block.features ? [...props.block.features] : []
15
+ if (!list.includes(v)) list.push(v)
16
+ board.updateBlock(props.block.id, { features: list })
17
+ newFeature.value = ''
18
+ }
19
+ function removeFeature(f: string) {
20
+ if (!props.block.features) return
21
+ board.updateBlock(props.block.id, { features: props.block.features.filter((x) => x !== f) })
22
+ }
23
+
24
+ // ---- best-practice prompt fragments ----------------------------------------
25
+ // Selected fragments (resolved against the catalog; unknown ids are dropped).
26
+ const selectedFragments = computed(() =>
27
+ (props.block.fragmentIds ?? [])
28
+ .map((id) => fragments.getFragment(id))
29
+ .filter((f): f is NonNullable<typeof f> => !!f),
30
+ )
31
+
32
+ // Picker menu: fragments suitable for this block's type, not already selected,
33
+ // grouped by category so the dropdown reads like the catalog.
34
+ const fragmentMenu = computed(() => {
35
+ const selected = new Set(props.block.fragmentIds ?? [])
36
+ const groups = new Map<string, { label: string; onSelect: () => void }[]>()
37
+ for (const f of fragments.forBlockType(props.block.type)) {
38
+ if (selected.has(f.id)) continue
39
+ const items = groups.get(f.category) ?? []
40
+ items.push({ label: f.title, onSelect: () => addFragment(f.id) })
41
+ groups.set(f.category, items)
42
+ }
43
+ return [...groups.values()]
44
+ })
45
+
46
+ function addFragment(id: string) {
47
+ const list = props.block.fragmentIds ? [...props.block.fragmentIds] : []
48
+ if (!list.includes(id)) list.push(id)
49
+ board.updateBlock(props.block.id, { fragmentIds: list })
50
+ }
51
+
52
+ function removeFragment(id: string) {
53
+ if (!props.block.fragmentIds) return
54
+ board.updateBlock(props.block.id, {
55
+ fragmentIds: props.block.fragmentIds.filter((x) => x !== id),
56
+ })
57
+ }
58
+ </script>
59
+
60
+ <template>
61
+ <div class="space-y-4">
62
+ <!-- module assignment -->
63
+ <div>
64
+ <div class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
65
+ Module
66
+ </div>
67
+ <UInput
68
+ v-model="block.moduleName"
69
+ size="sm"
70
+ class="w-full"
71
+ placeholder="e.g. Sessions (created on implement if new)"
72
+ icon="i-lucide-package"
73
+ />
74
+ </div>
75
+
76
+ <!-- features implemented -->
77
+ <div>
78
+ <div class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
79
+ Features implemented
80
+ </div>
81
+ <div v-if="block.features?.length" class="mb-1 flex flex-wrap gap-1">
82
+ <UBadge
83
+ v-for="f in block.features"
84
+ :key="f"
85
+ color="success"
86
+ variant="subtle"
87
+ size="sm"
88
+ class="cursor-pointer"
89
+ @click="removeFeature(f)"
90
+ >
91
+ {{ f }}<UIcon name="i-lucide-x" class="ml-0.5 h-3 w-3" />
92
+ </UBadge>
93
+ </div>
94
+ <UInput
95
+ v-model="newFeature"
96
+ size="sm"
97
+ class="w-full"
98
+ placeholder="Add a feature, press Enter"
99
+ icon="i-lucide-puzzle"
100
+ @keydown.enter="addFeature"
101
+ />
102
+ </div>
103
+
104
+ <!-- best practices (prompt fragments) -->
105
+ <div>
106
+ <div class="mb-1 flex items-center justify-between">
107
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
108
+ Best practices
109
+ </span>
110
+ <UDropdownMenu v-if="fragmentMenu.length" :items="fragmentMenu">
111
+ <UButton
112
+ size="xs"
113
+ variant="ghost"
114
+ color="neutral"
115
+ icon="i-lucide-plus"
116
+ trailing-icon="i-lucide-chevron-down"
117
+ />
118
+ </UDropdownMenu>
119
+ </div>
120
+ <div v-if="selectedFragments.length" class="mb-1 flex flex-wrap gap-1">
121
+ <UBadge
122
+ v-for="f in selectedFragments"
123
+ :key="f.id"
124
+ color="primary"
125
+ variant="subtle"
126
+ size="sm"
127
+ class="cursor-pointer"
128
+ :title="f.summary"
129
+ @click="removeFragment(f.id)"
130
+ >
131
+ {{ f.title }}<UIcon name="i-lucide-x" class="ml-0.5 h-3 w-3" />
132
+ </UBadge>
133
+ </div>
134
+ <div v-else class="text-[11px] text-slate-500">
135
+ None — agents follow their default guidance.
136
+ </div>
137
+ </div>
138
+ </div>
139
+ </template>
@@ -0,0 +1,227 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import type { AgentKind } from '~/types/domain'
4
+ import { AGENT_BY_KIND } from '~/utils/catalog'
5
+ import AgentPalette from '~/components/palettes/AgentPalette.vue'
6
+
7
+ const pipelines = usePipelinesStore()
8
+ const agents = useAgentsStore()
9
+ const ui = useUiStore()
10
+
11
+ const open = computed({
12
+ get: () => ui.builderOpen,
13
+ set: (v: boolean) => (ui.builderOpen = v),
14
+ })
15
+
16
+ function add(kind: AgentKind) {
17
+ pipelines.addToDraft(kind)
18
+ }
19
+
20
+ const toast = useToast()
21
+
22
+ // ---- "Add agent" mini-form -------------------------------------------------
23
+ const addAgentOpen = ref(false)
24
+ const newAgentName = ref('')
25
+ const newAgentDesc = ref('')
26
+
27
+ function openAddAgent() {
28
+ newAgentName.value = ''
29
+ newAgentDesc.value = ''
30
+ addAgentOpen.value = true
31
+ }
32
+
33
+ function createAgent() {
34
+ const agent = agents.addAgent({
35
+ label: newAgentName.value,
36
+ description: newAgentDesc.value,
37
+ })
38
+ toast.add({ title: `Added agent “${agent.label}”`, color: 'success', icon: 'i-lucide-check' })
39
+ addAgentOpen.value = false
40
+ }
41
+
42
+ function placeholder(what: string) {
43
+ toast.add({ title: 'Placeholder', description: what, icon: 'i-lucide-construction' })
44
+ }
45
+
46
+ async function save() {
47
+ try {
48
+ const saved = await pipelines.saveDraft()
49
+ if (saved) {
50
+ toast.add({ title: `Saved “${saved.name}”`, color: 'success', icon: 'i-lucide-check' })
51
+ ui.builderOpen = false
52
+ } else {
53
+ toast.add({ title: 'Add at least one agent first', color: 'warning' })
54
+ }
55
+ } catch {
56
+ toast.add({ title: 'Could not save pipeline', color: 'error' })
57
+ }
58
+ }
59
+ </script>
60
+
61
+ <template>
62
+ <USlideover v-model:open="open" title="Pipeline builder" side="left">
63
+ <template #body>
64
+ <div class="grid h-full grid-cols-2 gap-4">
65
+ <!-- agent palette -->
66
+ <div class="overflow-y-auto pr-1">
67
+ <div class="mb-2 flex items-center justify-between gap-2">
68
+ <h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
69
+ Agent palette
70
+ </h3>
71
+ <UButton
72
+ color="primary"
73
+ variant="soft"
74
+ size="xs"
75
+ icon="i-lucide-plus"
76
+ @click="openAddAgent"
77
+ >
78
+ Add agent
79
+ </UButton>
80
+ </div>
81
+ <AgentPalette @add="add" />
82
+ </div>
83
+
84
+ <!-- draft chain -->
85
+ <div class="flex flex-col">
86
+ <h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
87
+ Pipeline
88
+ </h3>
89
+ <UInput
90
+ v-model="pipelines.draftName"
91
+ placeholder="Pipeline name"
92
+ size="sm"
93
+ class="mb-3"
94
+ />
95
+
96
+ <div
97
+ v-if="pipelines.draft.length === 0"
98
+ class="flex flex-1 items-center justify-center rounded-lg border border-dashed border-slate-700 p-4 text-center text-xs text-slate-500"
99
+ >
100
+ Click agents on the left to assemble a linear pipeline.
101
+ </div>
102
+
103
+ <ol v-else class="flex-1 space-y-2 overflow-y-auto">
104
+ <li
105
+ v-for="(kind, i) in pipelines.draft"
106
+ :key="i"
107
+ class="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-800/60 p-2"
108
+ >
109
+ <span class="w-4 text-center text-[10px] text-slate-500">{{ i + 1 }}</span>
110
+ <UIcon
111
+ :name="AGENT_BY_KIND[kind].icon"
112
+ class="h-4 w-4"
113
+ :style="{ color: AGENT_BY_KIND[kind].color }"
114
+ />
115
+ <span class="text-xs text-slate-100">{{ AGENT_BY_KIND[kind].label }}</span>
116
+ <div class="ml-auto flex items-center">
117
+ <UButton
118
+ icon="i-lucide-chevron-up"
119
+ color="neutral"
120
+ variant="ghost"
121
+ size="xs"
122
+ :disabled="i === 0"
123
+ @click="pipelines.moveInDraft(i, i - 1)"
124
+ />
125
+ <UButton
126
+ icon="i-lucide-chevron-down"
127
+ color="neutral"
128
+ variant="ghost"
129
+ size="xs"
130
+ :disabled="i === pipelines.draft.length - 1"
131
+ @click="pipelines.moveInDraft(i, i + 1)"
132
+ />
133
+ <UButton
134
+ icon="i-lucide-x"
135
+ color="error"
136
+ variant="ghost"
137
+ size="xs"
138
+ @click="pipelines.removeFromDraft(i)"
139
+ />
140
+ </div>
141
+ </li>
142
+ </ol>
143
+ </div>
144
+ </div>
145
+ </template>
146
+
147
+ <template #footer>
148
+ <div class="flex w-full items-center justify-between">
149
+ <UButton color="neutral" variant="ghost" size="sm" @click="pipelines.clearDraft()">
150
+ Clear
151
+ </UButton>
152
+ <UButton
153
+ color="primary"
154
+ icon="i-lucide-save"
155
+ size="sm"
156
+ :disabled="pipelines.draft.length === 0"
157
+ @click="save"
158
+ >
159
+ Save pipeline
160
+ </UButton>
161
+ </div>
162
+ </template>
163
+ </USlideover>
164
+
165
+ <!-- Add-agent form -->
166
+ <UModal v-model:open="addAgentOpen" title="Add agent">
167
+ <template #body>
168
+ <div class="space-y-3">
169
+ <div>
170
+ <label
171
+ class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-400"
172
+ >
173
+ Name
174
+ </label>
175
+ <UInput
176
+ v-model="newAgentName"
177
+ placeholder="e.g. Security Auditor"
178
+ size="sm"
179
+ class="w-full"
180
+ />
181
+ </div>
182
+ <div>
183
+ <label
184
+ class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-400"
185
+ >
186
+ Description
187
+ </label>
188
+ <UTextarea
189
+ v-model="newAgentDesc"
190
+ :rows="2"
191
+ autoresize
192
+ size="sm"
193
+ class="w-full"
194
+ placeholder="What does this agent do?"
195
+ />
196
+ </div>
197
+ <UButton
198
+ color="neutral"
199
+ variant="soft"
200
+ size="xs"
201
+ icon="i-lucide-file-text"
202
+ block
203
+ @click="placeholder('Link context document')"
204
+ >
205
+ Link context document
206
+ </UButton>
207
+ </div>
208
+ </template>
209
+
210
+ <template #footer>
211
+ <div class="flex w-full items-center justify-end gap-2">
212
+ <UButton color="neutral" variant="ghost" size="sm" @click="addAgentOpen = false">
213
+ Cancel
214
+ </UButton>
215
+ <UButton
216
+ color="primary"
217
+ icon="i-lucide-plus"
218
+ size="sm"
219
+ :disabled="!newAgentName.trim()"
220
+ @click="createAgent"
221
+ >
222
+ Create agent
223
+ </UButton>
224
+ </div>
225
+ </template>
226
+ </UModal>
227
+ </template>