@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,202 @@
1
+ <script setup lang="ts">
2
+ import type { DropdownMenuItem } from '@nuxt/ui'
3
+
4
+ // Account + board switching. Picks the active account (personal / org) and the
5
+ // active board within it, and manages boards (new / rename / delete). The account
6
+ // row is shown only when accounts exist (auth on); in dev it falls back to a plain
7
+ // board switcher over the single unscoped context.
8
+ const accounts = useAccountsStore()
9
+ const workspace = useWorkspaceStore()
10
+ const toast = useToast()
11
+
12
+ const busy = ref(false)
13
+
14
+ function notifyError(title: string, e: unknown) {
15
+ toast.add({
16
+ title,
17
+ description: e instanceof Error ? e.message : String(e),
18
+ icon: 'i-lucide-triangle-alert',
19
+ color: 'error',
20
+ })
21
+ }
22
+
23
+ // ---- account + board menus -------------------------------------------------
24
+ const accountItems = computed<DropdownMenuItem[][]>(() => [
25
+ accounts.accounts.map((a) => ({
26
+ label: a.name,
27
+ icon: a.type === 'org' ? 'i-lucide-users' : 'i-lucide-user',
28
+ trailingIcon: a.id === accounts.activeAccountId ? 'i-lucide-check' : undefined,
29
+ onSelect: () => void selectAccount(a.id),
30
+ })),
31
+ [{ label: 'New organization…', icon: 'i-lucide-plus', onSelect: () => openPrompt('account') }],
32
+ ])
33
+
34
+ const boardItems = computed<DropdownMenuItem[][]>(() => [
35
+ workspace.accountWorkspaces.map((w) => ({
36
+ label: w.name,
37
+ icon: 'i-lucide-layout-dashboard',
38
+ trailingIcon: w.id === workspace.workspaceId ? 'i-lucide-check' : undefined,
39
+ onSelect: () => void switchBoard(w.id),
40
+ })),
41
+ [
42
+ { label: 'New board…', icon: 'i-lucide-plus', onSelect: () => openPrompt('board') },
43
+ { label: 'Rename board…', icon: 'i-lucide-pencil', onSelect: () => openPrompt('rename') },
44
+ {
45
+ label: 'Delete board',
46
+ icon: 'i-lucide-trash-2',
47
+ color: 'error' as const,
48
+ onSelect: () => void removeBoard(),
49
+ },
50
+ ],
51
+ ])
52
+
53
+ async function selectAccount(id: string) {
54
+ if (id === accounts.activeAccountId) return
55
+ busy.value = true
56
+ try {
57
+ await workspace.selectAccount(id)
58
+ } catch (e) {
59
+ notifyError('Could not switch account', e)
60
+ } finally {
61
+ busy.value = false
62
+ }
63
+ }
64
+
65
+ async function switchBoard(id: string) {
66
+ busy.value = true
67
+ try {
68
+ await workspace.switchTo(id)
69
+ } catch (e) {
70
+ notifyError('Could not open board', e)
71
+ } finally {
72
+ busy.value = false
73
+ }
74
+ }
75
+
76
+ async function removeBoard() {
77
+ const id = workspace.workspaceId
78
+ if (!id) return
79
+ busy.value = true
80
+ try {
81
+ await workspace.remove(id)
82
+ toast.add({ title: 'Board deleted', icon: 'i-lucide-check' })
83
+ } catch (e) {
84
+ notifyError('Could not delete board', e)
85
+ } finally {
86
+ busy.value = false
87
+ }
88
+ }
89
+
90
+ // ---- prompt modal (create account / create board / rename) -----------------
91
+ type PromptKind = 'account' | 'board' | 'rename'
92
+ const prompt = ref<PromptKind | null>(null)
93
+ const promptValue = ref('')
94
+ const promptOpen = computed({
95
+ get: () => prompt.value !== null,
96
+ set: (v: boolean) => {
97
+ if (!v) prompt.value = null
98
+ },
99
+ })
100
+ const promptMeta: Record<PromptKind, { title: string; placeholder: string; cta: string }> = {
101
+ account: { title: 'New organization', placeholder: 'Acme Inc.', cta: 'Create' },
102
+ board: { title: 'New board', placeholder: 'Untitled board', cta: 'Create' },
103
+ rename: { title: 'Rename board', placeholder: 'Board name', cta: 'Save' },
104
+ }
105
+
106
+ function openPrompt(kind: PromptKind) {
107
+ prompt.value = kind
108
+ promptValue.value = kind === 'rename' ? (workspace.activeWorkspace?.name ?? '') : ''
109
+ }
110
+
111
+ async function submitPrompt() {
112
+ const kind = prompt.value
113
+ const name = promptValue.value.trim()
114
+ if (!kind || (!name && kind !== 'board')) return
115
+ busy.value = true
116
+ try {
117
+ if (kind === 'account') {
118
+ await accounts.createOrg(name)
119
+ // The new org starts empty — open (create) its first board.
120
+ await workspace.selectAccount(accounts.activeAccountId!)
121
+ } else if (kind === 'board') {
122
+ await workspace.create(name || undefined)
123
+ } else if (workspace.workspaceId) {
124
+ await workspace.rename(workspace.workspaceId, name)
125
+ }
126
+ prompt.value = null
127
+ } catch (e) {
128
+ notifyError('Action failed', e)
129
+ } finally {
130
+ busy.value = false
131
+ }
132
+ }
133
+ </script>
134
+
135
+ <template>
136
+ <div class="space-y-1.5">
137
+ <!-- account selector (only when accounts exist) -->
138
+ <UDropdownMenu
139
+ v-if="accounts.enabled"
140
+ :items="accountItems"
141
+ :content="{ align: 'start' }"
142
+ class="w-full"
143
+ >
144
+ <button
145
+ type="button"
146
+ class="flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left transition hover:bg-slate-800/60"
147
+ :disabled="busy"
148
+ >
149
+ <UIcon
150
+ :name="accounts.activeAccount?.type === 'org' ? 'i-lucide-users' : 'i-lucide-user'"
151
+ class="h-3.5 w-3.5 shrink-0 text-slate-400"
152
+ />
153
+ <span class="truncate text-[11px] font-medium uppercase tracking-wide text-slate-400">
154
+ {{ accounts.activeAccount?.name ?? 'Account' }}
155
+ </span>
156
+ <UIcon
157
+ name="i-lucide-chevrons-up-down"
158
+ class="ml-auto h-3.5 w-3.5 shrink-0 text-slate-600"
159
+ />
160
+ </button>
161
+ </UDropdownMenu>
162
+
163
+ <!-- board selector -->
164
+ <UDropdownMenu :items="boardItems" :content="{ align: 'start' }" class="w-full">
165
+ <button
166
+ type="button"
167
+ class="flex w-full items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/60 px-2.5 py-1.5 text-left transition hover:bg-slate-800/60"
168
+ :disabled="busy"
169
+ >
170
+ <UIcon name="i-lucide-layout-dashboard" class="h-4 w-4 shrink-0 text-indigo-400" />
171
+ <span class="truncate text-sm font-medium text-white">
172
+ {{ workspace.activeWorkspace?.name ?? 'Board' }}
173
+ </span>
174
+ <UIcon name="i-lucide-chevron-down" class="ml-auto h-4 w-4 shrink-0 text-slate-500" />
175
+ </button>
176
+ </UDropdownMenu>
177
+
178
+ <!-- create / rename prompt -->
179
+ <UModal v-model:open="promptOpen" :title="prompt ? promptMeta[prompt].title : ''">
180
+ <template #body>
181
+ <form class="space-y-3" @submit.prevent="submitPrompt">
182
+ <UFormField label="Name">
183
+ <UInput
184
+ v-model="promptValue"
185
+ autofocus
186
+ :placeholder="prompt ? promptMeta[prompt].placeholder : ''"
187
+ class="w-full"
188
+ />
189
+ </UFormField>
190
+ <div class="flex justify-end gap-2">
191
+ <UButton color="neutral" variant="ghost" :disabled="busy" @click="prompt = null">
192
+ Cancel
193
+ </UButton>
194
+ <UButton type="submit" color="primary" :loading="busy">
195
+ {{ prompt ? promptMeta[prompt].cta : '' }}
196
+ </UButton>
197
+ </div>
198
+ </form>
199
+ </template>
200
+ </UModal>
201
+ </div>
202
+ </template>
@@ -0,0 +1,109 @@
1
+ <script setup lang="ts">
2
+ import { useBoardFlow } from '~/composables/useBoardFlow'
3
+
4
+ const ui = useUiStore()
5
+ const board = useBoardStore()
6
+ const execution = useExecutionStore()
7
+ const workspace = useWorkspaceStore()
8
+ const { fitView, zoomIn, zoomOut } = useBoardFlow()
9
+
10
+ const zoomPct = computed(() => Math.round(ui.zoom * 100))
11
+ const lodLabel = computed(() => ({ far: 'Overview', mid: 'Summary', close: 'Detail' })[ui.lod])
12
+
13
+ // Live spend indicator: shown once any tokens have been metered this period.
14
+ const spend = computed(() => workspace.spend)
15
+ const showSpend = computed(() => !!spend.value && spend.value.costSpent > 0)
16
+ const spendLabel = computed(() => {
17
+ const s = spend.value
18
+ if (!s) return ''
19
+ const fmt = (n: number) => {
20
+ try {
21
+ return new Intl.NumberFormat(undefined, { style: 'currency', currency: s.currency }).format(n)
22
+ } catch {
23
+ return `${n.toFixed(2)} ${s.currency}`
24
+ }
25
+ }
26
+ return `${fmt(s.costSpent)} / ${fmt(s.costLimit)}`
27
+ })
28
+ const spendColor = computed(() => (spend.value?.exceeded ? 'error' : 'neutral'))
29
+
30
+ const decisionItems = computed(() =>
31
+ execution.openDecisions.map((d) => {
32
+ const b = board.getBlock(d.blockId)
33
+ return {
34
+ label: b?.title ?? 'Block',
35
+ description: d.decision.question,
36
+ icon: 'i-lucide-circle-help',
37
+ onSelect: () => ui.openDecision(d.instanceId, d.decision.id),
38
+ }
39
+ }),
40
+ )
41
+
42
+ async function resetBoard() {
43
+ await workspace.reset()
44
+ ui.select(null)
45
+ ui.focus(null)
46
+ setTimeout(() => fitView({ padding: 0.2 }), 50)
47
+ }
48
+ </script>
49
+
50
+ <template>
51
+ <div
52
+ class="absolute left-1/2 top-4 z-20 flex -translate-x-1/2 items-center gap-1 rounded-full border border-slate-700 bg-slate-900/90 px-2 py-1.5 shadow-xl backdrop-blur"
53
+ >
54
+ <!-- zoom controls -->
55
+ <UButton
56
+ icon="i-lucide-zoom-out"
57
+ color="neutral"
58
+ variant="ghost"
59
+ size="sm"
60
+ @click="zoomOut()"
61
+ />
62
+ <div class="w-20 text-center text-xs tabular-nums text-slate-300">
63
+ {{ zoomPct }}%
64
+ <div class="text-[9px] uppercase tracking-wide text-slate-500">{{ lodLabel }}</div>
65
+ </div>
66
+ <UButton icon="i-lucide-zoom-in" color="neutral" variant="ghost" size="sm" @click="zoomIn()" />
67
+ <UButton
68
+ icon="i-lucide-maximize"
69
+ color="neutral"
70
+ variant="ghost"
71
+ size="sm"
72
+ @click="fitView({ padding: 0.2 })"
73
+ />
74
+
75
+ <USeparator orientation="vertical" class="mx-1 h-6" />
76
+
77
+ <!-- decisions queue -->
78
+ <UDropdownMenu v-if="execution.pendingDecisionCount" :items="decisionItems">
79
+ <UButton color="warning" variant="soft" size="sm" icon="i-lucide-circle-help">
80
+ {{ execution.pendingDecisionCount }} decision{{
81
+ execution.pendingDecisionCount === 1 ? '' : 's'
82
+ }}
83
+ </UButton>
84
+ </UDropdownMenu>
85
+
86
+ <!-- spend safeguard usage -->
87
+ <UButton
88
+ v-if="showSpend"
89
+ :color="spendColor"
90
+ variant="soft"
91
+ size="sm"
92
+ icon="i-lucide-wallet"
93
+ :title="spend?.exceeded ? 'Spend limit reached — runs paused' : 'Token spend this month'"
94
+ >
95
+ {{ spendLabel }}
96
+ </UButton>
97
+
98
+ <USeparator orientation="vertical" class="mx-1 h-6" />
99
+
100
+ <UButton
101
+ icon="i-lucide-rotate-ccw"
102
+ color="neutral"
103
+ variant="ghost"
104
+ size="sm"
105
+ title="Reset board to sample"
106
+ @click="resetBoard"
107
+ />
108
+ </div>
109
+ </template>
@@ -0,0 +1,193 @@
1
+ <script setup lang="ts">
2
+ import BlockPalette from '~/components/palettes/BlockPalette.vue'
3
+ import PipelinePalette from '~/components/palettes/PipelinePalette.vue'
4
+ import BoardSwitcher from '~/components/layout/BoardSwitcher.vue'
5
+ import UserMenu from '~/components/auth/UserMenu.vue'
6
+
7
+ const documents = useDocumentsStore()
8
+ const tasks = useTasksStore()
9
+ const github = useGitHubStore()
10
+ const library = useFragmentLibraryStore()
11
+ const workspace = useWorkspaceStore()
12
+ const ui = useUiStore()
13
+
14
+ // Resolve whether the document-source / task-source / GitHub integrations are
15
+ // enabled on the backend, so each section is hidden entirely when it is off
16
+ // (mirrors how auth gates its UI). A 503 from a probe flips its `available` to
17
+ // false. Re-probe whenever the active board changes — connections are per board.
18
+ watch(
19
+ () => workspace.workspaceId,
20
+ (id) => {
21
+ if (!id) return
22
+ void documents.probe()
23
+ void tasks.probe()
24
+ void github.probe()
25
+ void library.probe()
26
+ },
27
+ { immediate: true },
28
+ )
29
+ </script>
30
+
31
+ <template>
32
+ <aside
33
+ class="flex h-full w-64 shrink-0 flex-col gap-4 overflow-y-auto border-r border-slate-800 bg-slate-900/80 p-3 backdrop-blur"
34
+ >
35
+ <BoardSwitcher />
36
+
37
+ <USeparator />
38
+
39
+ <section>
40
+ <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
41
+ Building blocks
42
+ </h2>
43
+ <BlockPalette />
44
+ </section>
45
+
46
+ <USeparator />
47
+
48
+ <section>
49
+ <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
50
+ Pipelines
51
+ </h2>
52
+ <PipelinePalette />
53
+ </section>
54
+
55
+ <USeparator />
56
+ <section>
57
+ <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
58
+ Repositories
59
+ </h2>
60
+ <UButton
61
+ block
62
+ color="neutral"
63
+ variant="soft"
64
+ size="sm"
65
+ icon="i-lucide-git-branch-plus"
66
+ class="justify-start"
67
+ @click="ui.openBootstrap()"
68
+ >
69
+ Bootstrap repo
70
+ </UButton>
71
+ </section>
72
+
73
+ <template v-if="library.available">
74
+ <USeparator />
75
+ <section>
76
+ <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
77
+ Prompt library
78
+ </h2>
79
+ <UButton
80
+ block
81
+ color="neutral"
82
+ variant="soft"
83
+ size="sm"
84
+ icon="i-lucide-book-marked"
85
+ class="justify-start"
86
+ @click="ui.openFragmentLibrary()"
87
+ >
88
+ Best-practice fragments
89
+ </UButton>
90
+ </section>
91
+ </template>
92
+
93
+ <template v-if="github.available">
94
+ <USeparator />
95
+ <section>
96
+ <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
97
+ GitHub
98
+ </h2>
99
+ <UButton
100
+ block
101
+ color="neutral"
102
+ variant="soft"
103
+ size="sm"
104
+ icon="i-lucide-github"
105
+ class="justify-start"
106
+ @click="ui.openGitHub()"
107
+ >
108
+ <span class="truncate">
109
+ {{ github.connected ? github.connection?.accountLogin : 'Connect GitHub' }}
110
+ </span>
111
+ </UButton>
112
+ </section>
113
+ </template>
114
+
115
+ <template v-if="documents.available && documents.sources.length">
116
+ <USeparator />
117
+ <section>
118
+ <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
119
+ Document sources
120
+ </h2>
121
+ <div class="space-y-1.5">
122
+ <UButton
123
+ v-for="src in documents.sources"
124
+ :key="src.source"
125
+ block
126
+ color="neutral"
127
+ variant="soft"
128
+ size="sm"
129
+ :icon="src.icon"
130
+ class="justify-start"
131
+ @click="ui.openDocumentConnect(src.source)"
132
+ >
133
+ <span class="truncate">
134
+ {{ documents.isConnected(src.source) ? src.label : `Connect ${src.label}` }}
135
+ </span>
136
+ </UButton>
137
+ <UButton
138
+ v-if="documents.anyConnected"
139
+ block
140
+ color="neutral"
141
+ variant="soft"
142
+ size="sm"
143
+ icon="i-lucide-file-down"
144
+ class="justify-start"
145
+ @click="ui.openDocumentImport(null)"
146
+ >
147
+ Import &amp; spawn
148
+ </UButton>
149
+ </div>
150
+ </section>
151
+ </template>
152
+
153
+ <template v-if="tasks.available && tasks.sources.length">
154
+ <USeparator />
155
+ <section>
156
+ <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
157
+ Task sources
158
+ </h2>
159
+ <div class="space-y-1.5">
160
+ <UButton
161
+ v-for="src in tasks.sources"
162
+ :key="src.source"
163
+ block
164
+ color="neutral"
165
+ variant="soft"
166
+ size="sm"
167
+ :icon="src.icon"
168
+ class="justify-start"
169
+ @click="ui.openTaskConnect(src.source)"
170
+ >
171
+ <span class="truncate">
172
+ {{ tasks.isConnected(src.source) ? src.label : `Connect ${src.label}` }}
173
+ </span>
174
+ </UButton>
175
+ <UButton
176
+ v-if="tasks.anyConnected"
177
+ block
178
+ color="neutral"
179
+ variant="soft"
180
+ size="sm"
181
+ icon="i-lucide-file-down"
182
+ class="justify-start"
183
+ @click="ui.openTaskImport(null)"
184
+ >
185
+ Import issues
186
+ </UButton>
187
+ </div>
188
+ </section>
189
+ </template>
190
+
191
+ <UserMenu class="mt-auto" />
192
+ </aside>
193
+ </template>
@@ -0,0 +1,107 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ const workspace = useWorkspaceStore()
5
+
6
+ const spend = computed(() => workspace.spend)
7
+ /** Show the large warning only once the budget has been reached. */
8
+ const exceeded = computed(() => spend.value?.exceeded ?? false)
9
+
10
+ function money(amount: number, currency: string) {
11
+ try {
12
+ return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(amount)
13
+ } catch {
14
+ // Fall back if the currency code isn't recognised by the runtime.
15
+ return `${amount.toFixed(2)} ${currency}`
16
+ }
17
+ }
18
+
19
+ const tokens = computed(() => {
20
+ const s = spend.value
21
+ if (!s) return ''
22
+ return new Intl.NumberFormat().format(s.inputTokens + s.outputTokens)
23
+ })
24
+
25
+ const resuming = ref(false)
26
+ async function resume() {
27
+ resuming.value = true
28
+ try {
29
+ await workspace.resumeSpend()
30
+ } finally {
31
+ resuming.value = false
32
+ }
33
+ }
34
+ </script>
35
+
36
+ <template>
37
+ <Transition name="fade">
38
+ <div
39
+ v-if="exceeded && spend"
40
+ class="absolute inset-x-0 top-0 z-50 flex justify-center px-4 pt-4"
41
+ >
42
+ <div
43
+ class="w-full max-w-3xl rounded-2xl border-2 border-red-500/70 bg-red-950/95 p-5 shadow-2xl backdrop-blur"
44
+ role="alert"
45
+ >
46
+ <div class="flex items-start gap-4">
47
+ <UIcon name="i-lucide-octagon-alert" class="mt-0.5 h-10 w-10 shrink-0 text-red-400" />
48
+ <div class="min-w-0 flex-1">
49
+ <h2 class="text-lg font-semibold text-red-100">
50
+ Spend limit reached — agent execution paused
51
+ </h2>
52
+ <p class="mt-1 text-sm text-red-200/90">
53
+ This month's token spend has hit the configured budget, so running pipelines were
54
+ paused to avoid further cost. Execution resumes automatically when the budget rolls
55
+ over next month, or once the limit is raised.
56
+ </p>
57
+
58
+ <dl class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
59
+ <div class="rounded-lg bg-red-900/50 px-3 py-2">
60
+ <dt class="text-[11px] uppercase tracking-wide text-red-300/80">Spent</dt>
61
+ <dd class="text-base font-semibold tabular-nums text-red-50">
62
+ {{ money(spend.costSpent, spend.currency) }}
63
+ </dd>
64
+ </div>
65
+ <div class="rounded-lg bg-red-900/50 px-3 py-2">
66
+ <dt class="text-[11px] uppercase tracking-wide text-red-300/80">Budget</dt>
67
+ <dd class="text-base font-semibold tabular-nums text-red-50">
68
+ {{ money(spend.costLimit, spend.currency) }}
69
+ </dd>
70
+ </div>
71
+ <div class="rounded-lg bg-red-900/50 px-3 py-2">
72
+ <dt class="text-[11px] uppercase tracking-wide text-red-300/80">Tokens</dt>
73
+ <dd class="text-base font-semibold tabular-nums text-red-50">{{ tokens }}</dd>
74
+ </div>
75
+ </dl>
76
+
77
+ <div class="mt-4 flex items-center gap-3">
78
+ <UButton
79
+ color="error"
80
+ variant="solid"
81
+ icon="i-lucide-play"
82
+ :loading="resuming"
83
+ @click="resume"
84
+ >
85
+ Resume anyway
86
+ </UButton>
87
+ <span class="text-xs text-red-300/70">
88
+ Resuming continues spending; it will pause again if still over budget.
89
+ </span>
90
+ </div>
91
+ </div>
92
+ </div>
93
+ </div>
94
+ </div>
95
+ </Transition>
96
+ </template>
97
+
98
+ <style scoped>
99
+ .fade-enter-active,
100
+ .fade-leave-active {
101
+ transition: opacity 0.2s ease;
102
+ }
103
+ .fade-enter-from,
104
+ .fade-leave-to {
105
+ opacity: 0;
106
+ }
107
+ </style>
@@ -0,0 +1,33 @@
1
+ <script setup lang="ts">
2
+ import type { AgentKind } from '~/types/domain'
3
+
4
+ const agents = useAgentsStore()
5
+ defineEmits<{ (e: 'add', kind: AgentKind): void }>()
6
+ </script>
7
+
8
+ <template>
9
+ <div class="space-y-2">
10
+ <p class="px-1 text-[11px] text-slate-500">Click an agent to append it to the pipeline.</p>
11
+ <div class="space-y-1.5">
12
+ <button
13
+ v-for="a in agents.archetypes"
14
+ :key="a.kind"
15
+ type="button"
16
+ class="flex w-full items-center gap-2.5 rounded-lg border border-slate-700 bg-slate-800/60 p-2 text-left transition hover:border-slate-500 hover:bg-slate-800"
17
+ @click="$emit('add', a.kind)"
18
+ >
19
+ <div
20
+ class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
21
+ :style="{ backgroundColor: a.color + '22' }"
22
+ >
23
+ <UIcon :name="a.icon" class="h-4 w-4" :style="{ color: a.color }" />
24
+ </div>
25
+ <div class="min-w-0">
26
+ <div class="text-xs font-semibold text-slate-100">{{ a.label }}</div>
27
+ <div class="truncate text-[10px] text-slate-400">{{ a.description }}</div>
28
+ </div>
29
+ <UIcon name="i-lucide-plus" class="ml-auto h-4 w-4 shrink-0 text-slate-500" />
30
+ </button>
31
+ </div>
32
+ </div>
33
+ </template>
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ import type { BlockType } from '~/types/domain'
3
+ import { BLOCK_TYPE_META } from '~/utils/catalog'
4
+ import { setDndPayload } from '~/utils/dnd'
5
+
6
+ const types = Object.keys(BLOCK_TYPE_META) as BlockType[]
7
+
8
+ function onDragStart(event: DragEvent, blockType: BlockType) {
9
+ setDndPayload(event, { kind: 'block', blockType })
10
+ ;(event.target as HTMLElement).classList.add('palette-dragging')
11
+ }
12
+
13
+ function onDragEnd(event: DragEvent) {
14
+ ;(event.target as HTMLElement).classList.remove('palette-dragging')
15
+ }
16
+ </script>
17
+
18
+ <template>
19
+ <div class="space-y-2">
20
+ <p class="px-1 text-[11px] text-slate-500">Drag a block onto the board.</p>
21
+ <div class="grid grid-cols-2 gap-2">
22
+ <div
23
+ v-for="t in types"
24
+ :key="t"
25
+ draggable="true"
26
+ class="flex cursor-grab select-none flex-col items-center gap-1 rounded-lg border border-slate-700 bg-slate-800/60 p-2.5 transition hover:border-slate-500 hover:bg-slate-800 active:cursor-grabbing"
27
+ @dragstart="onDragStart($event, t)"
28
+ @dragend="onDragEnd"
29
+ >
30
+ <UIcon
31
+ :name="BLOCK_TYPE_META[t].icon"
32
+ class="h-5 w-5"
33
+ :style="{ color: BLOCK_TYPE_META[t].accent }"
34
+ />
35
+ <span class="text-[11px] font-medium text-slate-200">
36
+ {{ BLOCK_TYPE_META[t].label }}
37
+ </span>
38
+ </div>
39
+ </div>
40
+ </div>
41
+ </template>