@cat-factory/app 0.9.0 → 0.10.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 (29) hide show
  1. package/app/components/board/AddTaskModal.vue +209 -21
  2. package/app/components/board/nodes/BlockNode.vue +2 -2
  3. package/app/components/focus/BlockFocusView.vue +2 -2
  4. package/app/components/layout/CommandBar.vue +7 -33
  5. package/app/components/layout/IntegrationsHub.vue +230 -0
  6. package/app/components/layout/SideBar.vue +8 -170
  7. package/app/components/panels/GenericStructuredResultView.vue +131 -0
  8. package/app/components/panels/InspectorPanel.vue +6 -2
  9. package/app/components/panels/StepResultViewHost.vue +4 -0
  10. package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +148 -0
  11. package/app/components/settings/IssueTrackerWritebackPanel.vue +45 -57
  12. package/app/components/settings/MergeThresholdsPanel.vue +189 -226
  13. package/app/components/settings/ObservabilityConnectionPanel.vue +151 -0
  14. package/app/components/settings/ServiceFragmentDefaultsPanel.vue +46 -61
  15. package/app/components/settings/WorkspaceSettingsPanel.vue +136 -63
  16. package/app/composables/api/releaseHealth.ts +11 -10
  17. package/app/pages/index.vue +4 -8
  18. package/app/stores/agents.ts +27 -2
  19. package/app/stores/releaseHealth.ts +48 -12
  20. package/app/stores/ui.ts +34 -42
  21. package/app/stores/workspace.ts +4 -0
  22. package/app/types/domain.ts +33 -1
  23. package/app/types/execution.ts +6 -0
  24. package/app/types/releaseHealth.ts +19 -11
  25. package/app/utils/catalog.spec.ts +10 -0
  26. package/app/utils/catalog.ts +20 -6
  27. package/package.json +2 -2
  28. package/app/components/board/ContextPicker.vue +0 -367
  29. package/app/components/settings/DatadogPanel.vue +0 -213
@@ -4,13 +4,13 @@
4
4
  // task lands in `planned` state; it is never launched here. The user starts a
5
5
  // pipeline on it explicitly (and can keep editing it until they do).
6
6
  //
7
- // When the document/task integrations are available, the user can also attach
8
- // external context up front via <ContextPicker>: search a connected source
9
- // (Confluence / Notion / GitHub repo docs / Jira / GitHub issues) by title or
10
- // content, paste a page/issue URL, or pick something already imported. Linking
11
- // needs the block id, so we create the task first, then import-and-link the
12
- // chosen items to it before closing — the same context the agents see for every
13
- // step of the run (see the backend's linkedContextSection).
7
+ // The form also shows ungated "Context documents" / "Context issues" sections
8
+ // (mirroring the task inspector): pick already-imported docs/issues or open the
9
+ // import flow to attach as agent context. When the relevant integration isn't
10
+ // connected the Attach button is disabled with a hint. Linking needs the block id,
11
+ // so chosen items are staged locally and import-and-linked once the task is created
12
+ // (see useContextLinking) — the same context the agents see for every step of the run.
13
+ import type { DropdownMenuItem } from '@nuxt/ui'
14
14
  import type { CreateTaskType, TaskTypeFields } from '~/types/domain'
15
15
 
16
16
  const ui = useUiStore()
@@ -179,13 +179,83 @@ function setConfig(id: string, value: string) {
179
179
  agentConfigValues.value = { ...agentConfigValues.value, [id]: value }
180
180
  }
181
181
 
182
- // Context the user chose to attach to the new task (search hits, pasted URLs,
183
- // already-imported items), collected by <ContextPicker> and committed on add.
182
+ // Context the user chose to attach to the new task (already-imported items + the
183
+ // import flow), committed once the block exists (see add() → linkPending).
184
184
  const pendingContext = ref<PendingContext[]>([])
185
185
 
186
- // The picker is offered whenever either integration is configured (even with
187
- // nothing imported yet you can search/paste a URL to attach the first item).
188
- const showContext = computed(() => documents.available || tasks.available)
186
+ // The Context documents / Context issues sections mirror the task inspector but are
187
+ // always shown (ungated): when the relevant integration isn't connected the Attach
188
+ // button is disabled with a tooltip rather than the section being hidden.
189
+ const docsConnected = computed(() => documents.available && documents.anyConnected)
190
+ const issuesConnected = computed(() => tasks.available && tasks.anyConnected)
191
+ const pendingDocs = computed(() => pendingContext.value.filter((c) => c.kind === 'document'))
192
+ const pendingIssues = computed(() => pendingContext.value.filter((c) => c.kind === 'task'))
193
+
194
+ function addPending(item: PendingContext) {
195
+ if (pendingContext.value.some((c) => contextKey(c) === contextKey(item))) return
196
+ pendingContext.value = [...pendingContext.value, item]
197
+ }
198
+ function removePending(item: PendingContext) {
199
+ pendingContext.value = pendingContext.value.filter((c) => contextKey(c) !== contextKey(item))
200
+ }
201
+
202
+ // Attach menus: already-imported items not yet chosen, plus the import entry — the
203
+ // same affordances the inspector offers. Picking one stages it locally (it links
204
+ // after the task is created).
205
+ const docAttachMenu = computed<DropdownMenuItem[][]>(() => {
206
+ const chosen = new Set(pendingContext.value.map(contextKey))
207
+ const items: DropdownMenuItem[] = documents.documents
208
+ .filter(
209
+ (d) =>
210
+ !chosen.has(contextKey({ kind: 'document', source: d.source, externalId: d.externalId })),
211
+ )
212
+ .map((d) => ({
213
+ label: d.title,
214
+ icon: documents.descriptorFor(d.source)?.icon ?? 'i-lucide-file-text',
215
+ onSelect: () =>
216
+ addPending({
217
+ kind: 'document',
218
+ source: d.source,
219
+ externalId: d.externalId,
220
+ title: d.title,
221
+ icon: documents.descriptorFor(d.source)?.icon ?? 'i-lucide-file-text',
222
+ needsImport: false,
223
+ }),
224
+ }))
225
+ items.push({
226
+ label: 'Import a page…',
227
+ icon: 'i-lucide-file-down',
228
+ onSelect: () => ui.openDocumentImport(null),
229
+ })
230
+ return [items]
231
+ })
232
+
233
+ const issueAttachMenu = computed<DropdownMenuItem[][]>(() => {
234
+ const chosen = new Set(pendingContext.value.map(contextKey))
235
+ const items: DropdownMenuItem[] = tasks.tasks
236
+ .filter(
237
+ (t) => !chosen.has(contextKey({ kind: 'task', source: t.source, externalId: t.externalId })),
238
+ )
239
+ .map((t) => ({
240
+ label: `${t.externalId} · ${t.title}`,
241
+ icon: tasks.descriptorFor(t.source)?.icon ?? 'i-lucide-square-check',
242
+ onSelect: () =>
243
+ addPending({
244
+ kind: 'task',
245
+ source: t.source,
246
+ externalId: t.externalId,
247
+ title: `${t.externalId} · ${t.title}`,
248
+ icon: tasks.descriptorFor(t.source)?.icon ?? 'i-lucide-square-check',
249
+ needsImport: false,
250
+ }),
251
+ }))
252
+ items.push({
253
+ label: 'Import an issue…',
254
+ icon: 'i-lucide-file-down',
255
+ onSelect: () => ui.openTaskImport(),
256
+ })
257
+ return [items]
258
+ })
189
259
 
190
260
  // Reset the form whenever the modal opens for a (new) container, and refresh the
191
261
  // imported docs/issues so the quick-pick list is current.
@@ -450,16 +520,134 @@ async function add() {
450
520
  </div>
451
521
  </div>
452
522
 
453
- <div v-if="showContext" class="space-y-2">
454
- <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
455
- Extra context (optional)
456
- </span>
457
-
458
- <ContextPicker v-model="pendingContext" />
523
+ <!-- Context documents (ungated; Attach disabled until a source is connected). -->
524
+ <div class="space-y-2">
525
+ <div class="flex items-center justify-between">
526
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
527
+ Context documents
528
+ </span>
529
+ <UDropdownMenu
530
+ v-if="docsConnected"
531
+ :items="docAttachMenu"
532
+ :content="{ side: 'bottom', align: 'end' }"
533
+ >
534
+ <UButton color="neutral" variant="soft" size="xs" icon="i-lucide-plus">
535
+ Attach
536
+ </UButton>
537
+ </UDropdownMenu>
538
+ <UButton
539
+ v-else
540
+ color="neutral"
541
+ variant="soft"
542
+ size="xs"
543
+ icon="i-lucide-plus"
544
+ disabled
545
+ :title="
546
+ documents.available
547
+ ? 'Connect a document source first (Integrations)'
548
+ : 'Enable the documents integration first'
549
+ "
550
+ >
551
+ Attach
552
+ </UButton>
553
+ </div>
554
+ <div v-if="pendingDocs.length" class="space-y-1">
555
+ <div
556
+ v-for="item in pendingDocs"
557
+ :key="contextKey(item)"
558
+ class="flex items-center gap-1.5 rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-xs text-slate-300"
559
+ >
560
+ <UIcon
561
+ :name="item.icon ?? 'i-lucide-file-text'"
562
+ class="h-3.5 w-3.5 shrink-0 text-indigo-400"
563
+ />
564
+ <span class="truncate">{{ item.title }}</span>
565
+ <UBadge
566
+ v-if="item.needsImport"
567
+ color="neutral"
568
+ variant="soft"
569
+ size="xs"
570
+ class="ml-1 shrink-0"
571
+ >
572
+ imports on add
573
+ </UBadge>
574
+ <button
575
+ type="button"
576
+ class="ml-auto shrink-0 text-slate-400 hover:text-slate-200"
577
+ @click="removePending(item)"
578
+ >
579
+ <UIcon name="i-lucide-x" class="h-3.5 w-3.5" />
580
+ </button>
581
+ </div>
582
+ </div>
583
+ <p v-else class="text-[11px] text-slate-500">
584
+ Attach a requirement, RFC or PRD so agents see it while implementing this task.
585
+ </p>
586
+ </div>
459
587
 
460
- <p class="text-[11px] text-slate-500">
461
- Search a connected source, paste a page/issue URL, or pick something already imported
462
- it's fed to every agent step as context.
588
+ <!-- Context issues (ungated; Attach disabled until a tracker is connected). -->
589
+ <div class="space-y-2">
590
+ <div class="flex items-center justify-between">
591
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
592
+ Context issues
593
+ </span>
594
+ <UDropdownMenu
595
+ v-if="issuesConnected"
596
+ :items="issueAttachMenu"
597
+ :content="{ side: 'bottom', align: 'end' }"
598
+ >
599
+ <UButton color="neutral" variant="soft" size="xs" icon="i-lucide-plus">
600
+ Attach
601
+ </UButton>
602
+ </UDropdownMenu>
603
+ <UButton
604
+ v-else
605
+ color="neutral"
606
+ variant="soft"
607
+ size="xs"
608
+ icon="i-lucide-plus"
609
+ disabled
610
+ :title="
611
+ tasks.available
612
+ ? 'Connect an issue tracker first (Integrations)'
613
+ : 'Enable the issue-tracker integration first'
614
+ "
615
+ >
616
+ Attach
617
+ </UButton>
618
+ </div>
619
+ <div v-if="pendingIssues.length" class="space-y-1">
620
+ <div
621
+ v-for="item in pendingIssues"
622
+ :key="contextKey(item)"
623
+ class="flex items-center gap-1.5 rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-xs text-slate-300"
624
+ >
625
+ <UIcon
626
+ :name="item.icon ?? 'i-lucide-square-check'"
627
+ class="h-3.5 w-3.5 shrink-0 text-indigo-400"
628
+ />
629
+ <span class="truncate">{{ item.title }}</span>
630
+ <UBadge
631
+ v-if="item.needsImport"
632
+ color="neutral"
633
+ variant="soft"
634
+ size="xs"
635
+ class="ml-1 shrink-0"
636
+ >
637
+ imports on add
638
+ </UBadge>
639
+ <button
640
+ type="button"
641
+ class="ml-auto shrink-0 text-slate-400 hover:text-slate-200"
642
+ @click="removePending(item)"
643
+ >
644
+ <UIcon name="i-lucide-x" class="h-3.5 w-3.5" />
645
+ </button>
646
+ </div>
647
+ </div>
648
+ <p v-else class="text-[11px] text-slate-500">
649
+ Attach a tracker issue so agents see its description and comments while implementing
650
+ this task.
463
651
  </p>
464
652
  </div>
465
653
 
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { Block, BlockStatus } from '~/types/domain'
3
- import { BLOCK_TYPE_META, STATUS_META } from '~/utils/catalog'
3
+ import { blockTypeMeta, STATUS_META } from '~/utils/catalog'
4
4
  import DecisionBadge from './DecisionBadge.vue'
5
5
  import DraggableTask from './DraggableTask.vue'
6
6
  import ModuleFrame from './ModuleFrame.vue'
@@ -24,7 +24,7 @@ const { lod } = useSemanticZoom()
24
24
  const block = computed<Block | undefined>(() => board.getBlock(props.id))
25
25
  /** This service frame is mounted on more than one board in the org. */
26
26
  const isShared = computed(() => services.isSharedFrame(props.id))
27
- const typeMeta = computed(() => (block.value ? BLOCK_TYPE_META[block.value.type] : null))
27
+ const typeMeta = computed(() => (block.value ? blockTypeMeta(block.value.type) : null))
28
28
 
29
29
  // ---- this service's children (tasks + modules) -----------------------------
30
30
  const directTasks = computed(() => board.tasksOf(props.id))
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { onKeyStroke } from '@vueuse/core'
3
3
  import type { Block } from '~/types/domain'
4
- import { BLOCK_TYPE_META, STATUS_META } from '~/utils/catalog'
4
+ import { blockTypeMeta, STATUS_META } from '~/utils/catalog'
5
5
  import PipelineProgress from '~/components/pipeline/PipelineProgress.vue'
6
6
 
7
7
  const board = useBoardStore()
@@ -18,7 +18,7 @@ const block = computed<Block | undefined>(() =>
18
18
  )
19
19
  const instance = computed(() => execution.getInstance(block.value?.executionId))
20
20
  const statusMeta = computed(() => (block.value ? STATUS_META[block.value.status] : null))
21
- const typeMeta = computed(() => (block.value ? BLOCK_TYPE_META[block.value.type] : null))
21
+ const typeMeta = computed(() => (block.value ? blockTypeMeta(block.value.type) : null))
22
22
 
23
23
  const deps = computed(() =>
24
24
  (block.value?.dependsOn ?? []).map((id) => board.getBlock(id)).filter((b): b is Block => !!b),
@@ -1,11 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  // The command bar (⌘K / Ctrl+K) — a searchable launcher for every action that
3
- // used to live as a button or draggable in the left panel. It is the primary way
4
- // to create blocks and pipelines now that the draggable palettes are gone, and a
5
- // fast path to every integration / settings surface. Commands are assembled from
6
- // the live stores so only available actions (connected integrations, etc.) show.
7
- import type { BlockType } from '~/types/domain'
8
- import { BLOCK_TYPE_META } from '~/utils/catalog'
3
+ // used to live as a button or draggable in the left panel. It is a fast path to
4
+ // pipelines, repositories, every integration and the settings surfaces. (Raw
5
+ // block creation is gone services come from Bootstrap / Add-from-repo and tasks
6
+ // from the add-task flow.) Commands are assembled from the live stores so only
7
+ // available actions (connected integrations, etc.) show.
9
8
 
10
9
  interface Command {
11
10
  id: string
@@ -18,7 +17,6 @@ interface Command {
18
17
  }
19
18
 
20
19
  const ui = useUiStore()
21
- const board = useBoardStore()
22
20
  const github = useGitHubStore()
23
21
  const slack = useSlackStore()
24
22
  const documents = useDocumentsStore()
@@ -33,19 +31,6 @@ const open = computed({
33
31
  const query = ref('')
34
32
  const activeIndex = ref(0)
35
33
 
36
- // New top-level blocks are created without a drop position now, so stagger each
37
- // one slightly off the canvas origin to keep them from stacking exactly.
38
- let spawnCount = 0
39
- function spawnPosition() {
40
- const offset = (spawnCount++ % 6) * 28
41
- return { x: 160 + offset, y: 160 + offset }
42
- }
43
-
44
- async function addBlock(type: BlockType) {
45
- const block = await board.addBlock(type, spawnPosition())
46
- ui.select(block.id)
47
- }
48
-
49
34
  const commands = computed<Command[]>(() => {
50
35
  const list: Command[] = []
51
36
 
@@ -58,17 +43,6 @@ const commands = computed<Command[]>(() => {
58
43
  keywords: 'pipeline agents chain',
59
44
  run: () => ui.openBuilder(),
60
45
  })
61
- for (const type of Object.keys(BLOCK_TYPE_META) as BlockType[]) {
62
- const meta = BLOCK_TYPE_META[type]
63
- list.push({
64
- id: `add-block-${type}`,
65
- label: `Add ${meta.label} block`,
66
- group: 'Create',
67
- icon: meta.icon,
68
- keywords: 'block frame service create new',
69
- run: () => addBlock(type),
70
- })
71
- }
72
46
 
73
47
  // ---- Repositories -------------------------------------------------------
74
48
  if (github.available) {
@@ -173,7 +147,7 @@ const commands = computed<Command[]>(() => {
173
147
  group: 'Workspace',
174
148
  icon: 'i-lucide-git-merge',
175
149
  keywords: 'merge policy preset auto-merge ci',
176
- run: () => ui.openMergeThresholds(),
150
+ run: () => ui.openWorkspaceSettings('merge'),
177
151
  })
178
152
  list.push({
179
153
  id: 'workspace-settings',
@@ -197,7 +171,7 @@ const commands = computed<Command[]>(() => {
197
171
  group: 'Workspace',
198
172
  icon: 'i-lucide-book-open-check',
199
173
  keywords: 'fragment best practice guideline service default code-aware',
200
- run: () => ui.openServiceFragmentDefaults(),
174
+ run: () => ui.openWorkspaceSettings('fragments'),
201
175
  })
202
176
  list.push({
203
177
  id: 'local-models',
@@ -0,0 +1,230 @@
1
+ <script setup lang="ts">
2
+ // The Integrations hub: a single modal that lists every external system the
3
+ // workspace can enable or link in — replacing the per-integration buttons that
4
+ // used to clutter the left navbar. Each row reuses the existing per-integration
5
+ // panel handlers on the `ui` store (so the integrations themselves are unchanged);
6
+ // opening one closes the hub and reveals that integration's own panel/modal.
7
+ //
8
+ // Sections gate on the same `available` probes the navbar used, so a system that
9
+ // the backend has turned off simply doesn't appear here.
10
+ const ui = useUiStore()
11
+ const github = useGitHubStore()
12
+ const slack = useSlackStore()
13
+ const documents = useDocumentsStore()
14
+ const tasks = useTasksStore()
15
+ const releaseHealth = useReleaseHealthStore()
16
+
17
+ // The observability connection status drives the hub's connected badge. Load it
18
+ // lazily when the hub opens (the secret-less connection view is cheap).
19
+ watch(
20
+ () => ui.integrationsOpen,
21
+ (isOpen) => {
22
+ if (isOpen) void releaseHealth.ensureLoaded().catch(() => {})
23
+ },
24
+ )
25
+
26
+ const open = computed({
27
+ get: () => ui.integrationsOpen,
28
+ set: (v: boolean) => (v ? ui.openIntegrations() : ui.closeIntegrations()),
29
+ })
30
+
31
+ // One integration row. `status` is the connected-state line shown under the label
32
+ // (an account/team name, "Connected", or a hint); `connected` drives the badge.
33
+ interface IntegrationItem {
34
+ key: string
35
+ icon: string
36
+ label: string
37
+ description: string
38
+ status?: string
39
+ connected?: boolean
40
+ onClick: () => void
41
+ }
42
+
43
+ interface IntegrationGroup {
44
+ title: string
45
+ items: IntegrationItem[]
46
+ }
47
+
48
+ // Run an integration's open handler, then dismiss the hub so its panel takes over.
49
+ function go(fn: () => void) {
50
+ fn()
51
+ ui.closeIntegrations()
52
+ }
53
+
54
+ const groups = computed<IntegrationGroup[]>(() => {
55
+ const out: IntegrationGroup[] = []
56
+
57
+ // --- Source control --------------------------------------------------------
58
+ const code: IntegrationItem[] = []
59
+ if (github.available) {
60
+ code.push({
61
+ key: 'github',
62
+ icon: 'i-lucide-github',
63
+ label: 'GitHub',
64
+ description: 'Connect the workspace’s GitHub App, browse repos, PRs and issues.',
65
+ status: github.connected ? github.connection?.accountLogin : undefined,
66
+ connected: github.connected,
67
+ onClick: () => go(ui.openGitHub),
68
+ })
69
+ }
70
+ if (code.length) out.push({ title: 'Source control', items: code })
71
+
72
+ // --- Communication ---------------------------------------------------------
73
+ const comms: IntegrationItem[] = []
74
+ if (slack.available) {
75
+ comms.push({
76
+ key: 'slack',
77
+ icon: 'i-lucide-slack',
78
+ label: 'Slack',
79
+ description: 'Route notifications to your team’s Slack workspace.',
80
+ status: slack.connected ? slack.connection?.teamName : undefined,
81
+ connected: slack.connected,
82
+ onClick: () => go(ui.openSlack),
83
+ })
84
+ }
85
+ if (comms.length) out.push({ title: 'Communication', items: comms })
86
+
87
+ // --- Documents (dynamic sources: Confluence / Notion / GitHub) -------------
88
+ if (documents.available && documents.sources.length) {
89
+ const docs: IntegrationItem[] = documents.sources.map((src) => ({
90
+ key: `doc:${src.source}`,
91
+ icon: src.icon,
92
+ label: src.label,
93
+ description: `Link ${src.label} as a document source for requirements context.`,
94
+ status: documents.isConnected(src.source) ? 'Connected' : undefined,
95
+ connected: documents.isConnected(src.source),
96
+ onClick: () => go(() => ui.openDocumentConnect(src.source)),
97
+ }))
98
+ if (documents.anyConnected) {
99
+ docs.push({
100
+ key: 'doc:import',
101
+ icon: 'i-lucide-file-down',
102
+ label: 'Import & spawn',
103
+ description: 'Pull documents from a connected source and spawn structure.',
104
+ onClick: () => go(() => ui.openDocumentImport(null)),
105
+ })
106
+ }
107
+ out.push({ title: 'Documents', items: docs })
108
+ }
109
+
110
+ // --- Task trackers (dynamic sources: Jira / GitHub) ------------------------
111
+ if (tasks.available && tasks.sources.length) {
112
+ const trackers: IntegrationItem[] = tasks.sources.map((src) => ({
113
+ key: `task:${src.source}`,
114
+ icon: src.icon,
115
+ label: src.label,
116
+ description: `Link ${src.label} to import and reference tracker issues.`,
117
+ status: tasks.isConnected(src.source) ? 'Connected' : undefined,
118
+ connected: tasks.isConnected(src.source),
119
+ onClick: () => go(() => ui.openTaskConnect(src.source)),
120
+ }))
121
+ if (tasks.anyConnected) {
122
+ trackers.push({
123
+ key: 'task:import',
124
+ icon: 'i-lucide-file-down',
125
+ label: 'Import issues',
126
+ description: 'Pull issues from a connected tracker onto the board.',
127
+ onClick: () => go(() => ui.openTaskImport(null)),
128
+ })
129
+ }
130
+ trackers.push({
131
+ key: 'task:writeback',
132
+ icon: 'i-lucide-message-square-reply',
133
+ label: 'Issue tracker writeback',
134
+ description: 'Comment on the PR and close the linked issue on merge.',
135
+ onClick: () => go(() => ui.openWorkspaceSettings('writeback')),
136
+ })
137
+ out.push({ title: 'Task trackers', items: trackers })
138
+ }
139
+
140
+ // --- Observability ---------------------------------------------------------
141
+ // Gated like every other backend-toggleable system: hidden until a probe confirms
142
+ // the observability module is enabled (`available === true`), so a disabled backend
143
+ // doesn't show a dead "Connect" row that only 503s.
144
+ if (releaseHealth.available) {
145
+ out.push({
146
+ title: 'Observability',
147
+ items: [
148
+ {
149
+ key: 'observability',
150
+ icon: 'i-lucide-activity',
151
+ label: 'Post-release health',
152
+ description: 'Watch monitors and SLOs after a release ships (Datadog).',
153
+ status: releaseHealth.connection.connected ? 'Connected' : undefined,
154
+ connected: releaseHealth.connection.connected,
155
+ onClick: () => go(ui.openObservabilityConnection),
156
+ },
157
+ ],
158
+ })
159
+ }
160
+
161
+ // --- Models & providers ----------------------------------------------------
162
+ out.push({
163
+ title: 'Models & providers',
164
+ items: [
165
+ {
166
+ key: 'vendors',
167
+ icon: 'i-lucide-key-round',
168
+ label: 'Vendors & keys',
169
+ description: 'LLM vendor subscriptions and provider API keys.',
170
+ onClick: () => go(ui.openVendorCredentials),
171
+ },
172
+ {
173
+ key: 'local-runners',
174
+ icon: 'i-lucide-server',
175
+ label: 'My local runners',
176
+ description: 'Your own-machine model runners (Ollama, LM Studio, vLLM…).',
177
+ onClick: () => go(ui.openLocalModels),
178
+ },
179
+ {
180
+ key: 'openrouter',
181
+ icon: 'i-lucide-waypoints',
182
+ label: 'OpenRouter models',
183
+ description: 'Browse and enable models from the OpenRouter gateway.',
184
+ onClick: () => go(ui.openOpenRouter),
185
+ },
186
+ ],
187
+ })
188
+
189
+ return out
190
+ })
191
+ </script>
192
+
193
+ <template>
194
+ <UModal v-model:open="open" title="Integrations" :ui="{ content: 'max-w-xl' }">
195
+ <template #body>
196
+ <div class="space-y-5">
197
+ <p class="text-xs text-slate-400">
198
+ Connect and manage the external systems this workspace can link in.
199
+ </p>
200
+
201
+ <section v-for="group in groups" :key="group.title">
202
+ <h3 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
203
+ {{ group.title }}
204
+ </h3>
205
+ <div class="space-y-1.5">
206
+ <button
207
+ v-for="item in group.items"
208
+ :key="item.key"
209
+ type="button"
210
+ class="flex w-full items-center gap-3 rounded-lg border border-slate-700 bg-slate-800/40 px-3 py-2.5 text-left transition hover:border-slate-500 hover:bg-slate-800"
211
+ @click="item.onClick()"
212
+ >
213
+ <UIcon :name="item.icon" class="h-5 w-5 shrink-0 text-slate-300" />
214
+ <div class="min-w-0 flex-1">
215
+ <div class="flex items-center gap-2">
216
+ <span class="truncate text-sm font-medium text-slate-100">{{ item.label }}</span>
217
+ <UBadge v-if="item.connected" color="success" variant="subtle" size="sm">
218
+ {{ item.status || 'Connected' }}
219
+ </UBadge>
220
+ </div>
221
+ <p class="truncate text-xs text-slate-400">{{ item.description }}</p>
222
+ </div>
223
+ <UIcon name="i-lucide-chevron-right" class="h-4 w-4 shrink-0 text-slate-500" />
224
+ </button>
225
+ </div>
226
+ </section>
227
+ </div>
228
+ </template>
229
+ </UModal>
230
+ </template>