@cat-factory/app 0.174.0 → 0.178.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.
- package/README.md +52 -13
- package/app/components/board/nodes/BlockNode.vue +30 -0
- package/app/components/environments/steps/EnvPickStep.vue +3 -0
- package/app/components/layout/IntegrationBackTitle.vue +11 -8
- package/app/components/layout/IntegrationsHub.vue +35 -56
- package/app/components/layout/ModelProvidersHub.vue +250 -0
- package/app/components/panels/InspectorPanel.vue +3 -28
- package/app/components/panels/inspector/ContainerSummary.vue +54 -62
- package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +11 -0
- package/app/components/panels/inspector/ServiceValidationConfig.vue +99 -1
- package/app/components/settings/ComposeEnvironmentSetupSection.vue +102 -0
- package/app/components/settings/InfrastructureWindow.vue +11 -1
- package/app/composables/api/validationChecks.ts +8 -0
- package/app/composables/useIntegrationBack.ts +5 -2
- package/app/composables/useNavContributions.ts +1 -1
- package/app/modular/nav-contributions.spec.ts +64 -21
- package/app/modular/nav-contributions.ts +59 -26
- package/app/pages/index.vue +4 -0
- package/app/stores/ui/modals.ts +32 -6
- package/app/stores/validationChecks.ts +17 -2
- package/app/types/validationChecks.ts +3 -0
- package/app/utils/validationDetection.spec.ts +51 -0
- package/app/utils/validationDetection.ts +67 -0
- package/i18n/locales/de.json +110 -33
- package/i18n/locales/en.json +110 -33
- package/i18n/locales/es.json +110 -33
- package/i18n/locales/fr.json +110 -33
- package/i18n/locales/he.json +110 -33
- package/i18n/locales/it.json +110 -33
- package/i18n/locales/ja.json +110 -33
- package/i18n/locales/pl.json +110 -33
- package/i18n/locales/tr.json +110 -33
- package/i18n/locales/uk.json +110 -33
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -56,10 +56,10 @@ over the WebSocket. How that sync works is written up in
|
|
|
56
56
|
## Interface modes (basic / advanced)
|
|
57
57
|
|
|
58
58
|
The SPA renders at one of two **interface tiers**. `basic` (the default) is the everyday
|
|
59
|
-
surface
|
|
60
|
-
|
|
61
|
-
to
|
|
62
|
-
match wins:
|
|
59
|
+
**delivery** surface — plan work on a board, run it, review and merge it: the run/pipeline
|
|
60
|
+
options that only exist to override a workspace-level default are left at that default, and
|
|
61
|
+
the nav is trimmed to what that loop needs. `advanced` shows everything. The tier resolves in
|
|
62
|
+
a fixed order, first match wins:
|
|
63
63
|
|
|
64
64
|
1. **`NUXT_PUBLIC_UI_MODE`** (`basic` | `advanced`) — the deployment pin. Like
|
|
65
65
|
`NUXT_PUBLIC_API_BASE` it is baked in at **build** time (`ssr: false`), and while it is
|
|
@@ -82,20 +82,32 @@ hoc where it can be avoided:
|
|
|
82
82
|
- **A nav destination** declares `advanced: true` in `app/modular/nav-contributions.ts`. The
|
|
83
83
|
shared `navSlotFilter` drops it in basic mode across all three shells (sidebar, command
|
|
84
84
|
palette, toolbar), independently of its RBAC `gate` — both must pass. A consumer module's
|
|
85
|
-
own contributions take the same flag. The bar
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
85
|
+
own contributions take the same flag. The bar is **whether the everyday delivery loop needs
|
|
86
|
+
it**, and marking an item does one of two distinguishable things:
|
|
87
|
+
- **Reached another way** — a shortcut whose surface a basic destination also opens, so
|
|
88
|
+
nothing is lost (the Merge / Service-best-practices palette entries into Workspace
|
|
89
|
+
settings, the local-models knob the Model providers hub already offers).
|
|
90
|
+
- **Out of the tier** — the sole route, hidden on purpose, so the capability is _absent_
|
|
91
|
+
from basic mode and the tier switch is the way to it (Sandbox, Kaizen, repo bootstrap,
|
|
92
|
+
and the deployment-wide operator + reports rollups).
|
|
93
|
+
|
|
94
|
+
Sole-route items stay in basic when the delivery loop runs on them: the pipeline builder,
|
|
95
|
+
add-from-repo, the fragment library, the infrastructure/PREnv windows, and the workspace /
|
|
96
|
+
model configuration a run actually reads. `nav-contributions.spec.ts` pins the advanced set
|
|
97
|
+
against a table naming each item's kind and reason, so promoting one forces that claim to be
|
|
98
|
+
written down rather than assumed.
|
|
99
|
+
|
|
94
100
|
- **A less-used option inside a surface** reads `useUiModeStore().isAdvanced`. Hide, never
|
|
95
101
|
disable, and only ever hide an OVERRIDE: what remains must be exactly the default the hidden
|
|
96
102
|
field would have shown, so a basic-mode user never gets different behaviour from an advanced
|
|
97
103
|
one — only fewer choices. An input nothing else supplies (the pipeline, the apriori branches)
|
|
98
104
|
stays in both tiers however advanced it feels.
|
|
105
|
+
- **A whole AUTHORING affordance** may be tier-scoped the same way — the frame header's
|
|
106
|
+
recurring-schedule and initiative buttons are advanced-only — but only while the tier hides
|
|
107
|
+
the ability to CREATE, never the ability to SEE. Existing state has to stay legible in basic
|
|
108
|
+
mode through its normal surfaces (a live schedule still badges its task card and opens its
|
|
109
|
+
inspector panel; an initiative is still a block on the board with its own inspector), or the
|
|
110
|
+
tier turns into a way for a user to be acted on by configuration they cannot find.
|
|
99
111
|
- **An override control on an EXISTING entity gates on `showOverrideField(isAdvanced, …values)`**
|
|
100
112
|
(`app/utils/uiMode.ts`) rather than on `isAdvanced` alone. Hiding an override is only safe
|
|
101
113
|
while it is unset — always true for a creation form, never guaranteed for a block that a
|
|
@@ -141,9 +153,36 @@ example ships in [`deploy/frontend`](../../deploy/frontend) (the `acme:security`
|
|
|
141
153
|
by every VCS provider), `vcs` (the GitLab personal-access-token connect),
|
|
142
154
|
`bootstrap`, `documents`, `tasks`, `requirements` (review), `scenarios`
|
|
143
155
|
(acceptance), and `fragments` (the prompt-fragment library).
|
|
156
|
+
- **Model providers** — `ModelProvidersHub.vue`, the sibling hub for the ENGINES
|
|
157
|
+
(OpenRouter, vendor keys, personal subscriptions, own-machine runners). Kept out
|
|
158
|
+
of the Integrations hub on purpose: an integration is optional context in or
|
|
159
|
+
output out, while a provider is what executes the work, so a deployment with none
|
|
160
|
+
connected runs nothing at all. **A new provider-shaped connection belongs here,
|
|
161
|
+
never in `IntegrationsHub.vue`.** Both hubs, plus the user-scoped `PersonalSetupModal`,
|
|
162
|
+
share the `IntegrationBackTitle` Back control, which returns to whichever hub set
|
|
163
|
+
its came-from marker (`ui.cameFrom{Integrations,ModelProviders,Personal}`) — a panel
|
|
164
|
+
reachable from more than one hub must not hard-code its return.
|
|
144
165
|
- **Auth** (`components/auth`) — `AuthGate` / `LoginScreen` / `UserMenu`; the app
|
|
145
166
|
is gated when the backend requires sign-in.
|
|
146
167
|
|
|
168
|
+
## Where a surface lives
|
|
169
|
+
|
|
170
|
+
Two placements are load-bearing enough to state, because putting a new one in the
|
|
171
|
+
wrong place is invisible until a user cannot find it:
|
|
172
|
+
|
|
173
|
+
- **The sidebar section is a claim about what the destination IS.** `models` is the
|
|
174
|
+
engines, `integrations` the optional systems, `infrastructure` where agent
|
|
175
|
+
containers and test environments run, `configuration` workspace/account settings.
|
|
176
|
+
`nav-contributions.spec.ts` pins the section order and each section's membership.
|
|
177
|
+
- **A flow that edits ONE entity's config is a section of the window that owns that
|
|
178
|
+
config, not a sibling nav entry.** The guided Docker Compose environment setup
|
|
179
|
+
(`ComposeEnvironmentSetupSection.vue` → `EnvironmentSetupWizard.vue`) lives inside
|
|
180
|
+
Infrastructure → Test environments for exactly this reason: it writes a service's
|
|
181
|
+
Compose recipe plus the workspace's Compose handler, both configured in that tab.
|
|
182
|
+
It also carries a full "how it works / when you need this / when you can skip it"
|
|
183
|
+
explanation there rather than a one-line hint, since the decision to run it has to
|
|
184
|
+
be made before opening a five-minute wizard.
|
|
185
|
+
|
|
147
186
|
## Develop & test
|
|
148
187
|
|
|
149
188
|
```bash
|
|
@@ -24,6 +24,7 @@ const agentRuns = useAgentRunsStore()
|
|
|
24
24
|
const services = useServicesStore()
|
|
25
25
|
const reviews = useReviewStage()
|
|
26
26
|
const access = useWorkspaceAccess()
|
|
27
|
+
const uiMode = useUiModeStore()
|
|
27
28
|
const { t } = useI18n()
|
|
28
29
|
const { lod } = useSemanticZoom()
|
|
29
30
|
// Coarse-pointer (touch) bumps the frame-header actions from `xs` to `sm` so
|
|
@@ -161,6 +162,13 @@ function addRecurring() {
|
|
|
161
162
|
ui.openAddRecurring(props.id)
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
// Hunt this service's tracker board for a bug worth picking up. Scoped to THIS frame, so
|
|
166
|
+
// an adopted candidate lands here rather than wherever the board's first frame happens to be.
|
|
167
|
+
function huntBugs() {
|
|
168
|
+
ui.expandFrame(props.id)
|
|
169
|
+
ui.openBugHunt(null, props.id)
|
|
170
|
+
}
|
|
171
|
+
|
|
164
172
|
function createInitiative() {
|
|
165
173
|
ui.expandFrame(props.id)
|
|
166
174
|
ui.openCreateInitiative(props.id)
|
|
@@ -491,8 +499,18 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
491
499
|
:title="t('board.frame.createTaskFromIssueTitle')"
|
|
492
500
|
@click.stop="createTaskFromIssue"
|
|
493
501
|
/>
|
|
502
|
+
<!-- Recurring pipelines + initiatives are ADVANCED-tier authoring: both plan
|
|
503
|
+
work rather than do it (a schedule that fires runs on a cadence, an
|
|
504
|
+
initiative that groups tasks under a goal), and the basic frame header is
|
|
505
|
+
the most-used control strip on the board. Hiding the CREATE affordance
|
|
506
|
+
removes neither's existing state from basic mode — a live schedule still
|
|
507
|
+
badges its task card and opens its inspector panel, and an initiative is
|
|
508
|
+
still a block on the board with its own inspector — so what a basic-mode
|
|
509
|
+
user loses is the ability to author a new one, not sight of one. -->
|
|
494
510
|
<UButton
|
|
511
|
+
v-if="uiMode.isAdvanced"
|
|
495
512
|
class="nodrag"
|
|
513
|
+
data-testid="frame-add-recurring"
|
|
496
514
|
:size="isTouch ? 'sm' : 'xs'"
|
|
497
515
|
variant="ghost"
|
|
498
516
|
color="neutral"
|
|
@@ -501,6 +519,7 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
501
519
|
@click.stop="addRecurring"
|
|
502
520
|
/>
|
|
503
521
|
<UButton
|
|
522
|
+
v-if="uiMode.isAdvanced"
|
|
504
523
|
class="nodrag"
|
|
505
524
|
data-testid="frame-add-initiative"
|
|
506
525
|
:size="isTouch ? 'sm' : 'xs'"
|
|
@@ -510,6 +529,17 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
510
529
|
:title="t('board.frame.createInitiativeTitle')"
|
|
511
530
|
@click.stop="createInitiative"
|
|
512
531
|
/>
|
|
532
|
+
<UButton
|
|
533
|
+
v-if="tasks.anyOffered"
|
|
534
|
+
class="nodrag"
|
|
535
|
+
data-testid="frame-hunt-bugs"
|
|
536
|
+
:size="isTouch ? 'sm' : 'xs'"
|
|
537
|
+
variant="ghost"
|
|
538
|
+
color="neutral"
|
|
539
|
+
icon="i-lucide-radar"
|
|
540
|
+
:title="t('board.frame.huntBugsTitle')"
|
|
541
|
+
@click.stop="huntBugs"
|
|
542
|
+
/>
|
|
513
543
|
</template>
|
|
514
544
|
<UButton
|
|
515
545
|
class="nodrag"
|
|
@@ -18,6 +18,9 @@ const { t } = useI18n()
|
|
|
18
18
|
<template>
|
|
19
19
|
<section class="space-y-3" data-testid="env-setup-step-pick">
|
|
20
20
|
<p class="text-sm text-slate-400">{{ t('environmentWizard.pick.intro') }}</p>
|
|
21
|
+
<!-- The one-service scope caveat, stated on the step that asks for the service: nothing
|
|
22
|
+
here changes any other service, and running it twice just re-edits this one's recipe. -->
|
|
23
|
+
<p class="text-xs leading-relaxed text-slate-500">{{ t('environmentWizard.pick.scope') }}</p>
|
|
21
24
|
<p v-if="!store.serviceFrames.length" class="text-sm text-slate-500">
|
|
22
25
|
{{ t('environmentWizard.pick.empty') }}
|
|
23
26
|
</p>
|
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
// Title content for an integration sub-panel's modal header. Renders the panel
|
|
3
3
|
// title with a leading "back" control that returns to the hub the panel was reached
|
|
4
|
-
// from — the workspace Integrations hub (`ui.cameFromIntegrations`)
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// from — the workspace Integrations hub (`ui.cameFromIntegrations`), the Model providers
|
|
5
|
+
// hub (`ui.cameFromModelProviders`) or the user-scoped "My setup" hub
|
|
6
|
+
// (`ui.cameFromPersonal`) — shown only when there is one. Panels opened from the command
|
|
7
|
+
// bar, sidebar, a banner or an inspector link don't grow a dead Back.
|
|
7
8
|
// Dropped into a UModal's #title slot, so it inherits the modal's title styling; it
|
|
8
9
|
// emits `back` and the host panel closes itself + reopens the right hub.
|
|
9
10
|
defineProps<{ title?: string }>()
|
|
10
11
|
const emit = defineEmits<{ back: [] }>()
|
|
11
12
|
const { t } = useI18n()
|
|
12
13
|
const ui = useUiStore()
|
|
13
|
-
const cameFromHub = computed(
|
|
14
|
-
|
|
15
|
-
ui.cameFromPersonal
|
|
16
|
-
? t('layout.integrationBack.backToMySetup')
|
|
17
|
-
: t('layout.integrationBack.backToIntegrations'),
|
|
14
|
+
const cameFromHub = computed(
|
|
15
|
+
() => ui.cameFromIntegrations || ui.cameFromModelProviders || ui.cameFromPersonal,
|
|
18
16
|
)
|
|
17
|
+
const backLabel = computed(() => {
|
|
18
|
+
if (ui.cameFromPersonal) return t('layout.integrationBack.backToMySetup')
|
|
19
|
+
if (ui.cameFromModelProviders) return t('layout.integrationBack.backToModelProviders')
|
|
20
|
+
return t('layout.integrationBack.backToIntegrations')
|
|
21
|
+
})
|
|
19
22
|
</script>
|
|
20
23
|
|
|
21
24
|
<template>
|
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
import { showOverrideField } from '~/utils/uiMode'
|
|
3
|
+
|
|
4
|
+
// The Integrations hub: a single modal that lists the OPTIONAL external systems the WORKSPACE
|
|
5
|
+
// can enable or link in — the ones that feed a run its context (source control, documents,
|
|
6
|
+
// trackers) or receive its output (chat, observability). Each row reuses the existing
|
|
7
|
+
// per-integration panel handlers on the `ui` store (so the integrations themselves are
|
|
8
|
+
// unchanged); opening one closes the hub and reveals that integration's own panel/modal.
|
|
6
9
|
//
|
|
7
10
|
// Sections gate on the same `available` probes the navbar used, so a system that the backend
|
|
8
11
|
// has turned off simply doesn't appear here.
|
|
9
12
|
//
|
|
10
|
-
// Scope split
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
13
|
+
// Scope split 1 — MODEL PROVIDERS are NOT integrations here. They are the engines the
|
|
14
|
+
// harnesses run on (with none connected nothing runs at all), so they have their own
|
|
15
|
+
// top-level hub, `ModelProvidersHub.vue`. Keeping them out is the point: an integration is
|
|
16
|
+
// something a deployment can live without, and burying the one mandatory connection among a
|
|
17
|
+
// dozen optional ones is what made this hub confusing.
|
|
18
|
+
//
|
|
19
|
+
// Scope split 2 — per-USER connections (a personal GitHub token, own-machine runners, personal
|
|
20
|
+
// subscriptions) live in the "My setup" hub (UserMenu → My setup), NOT here, keeping this hub
|
|
21
|
+
// workspace-scoped. When auth is disabled there is no UserMenu to host them, so a "Personal
|
|
22
|
+
// (only you)" group falls back into this hub so the workspace-adjacent ones stay reachable
|
|
23
|
+
// (the model-shaped ones fall back into the Model providers hub instead).
|
|
14
24
|
const { t } = useI18n()
|
|
15
25
|
const ui = useUiStore()
|
|
16
26
|
const auth = useAuthStore()
|
|
@@ -23,8 +33,7 @@ const releaseHealth = useReleaseHealthStore()
|
|
|
23
33
|
const packageRegistries = usePackageRegistriesStore()
|
|
24
34
|
const publicApiKeys = usePublicApiKeysStore()
|
|
25
35
|
const userSecrets = useUserSecretsStore()
|
|
26
|
-
const
|
|
27
|
-
const workspace = useWorkspaceStore()
|
|
36
|
+
const uiMode = useUiModeStore()
|
|
28
37
|
|
|
29
38
|
// True when the per-user "My setup" hub is reachable (UserMenu renders only when signed in).
|
|
30
39
|
// When false (auth disabled / local mode) we fold the personal rows back into this hub so
|
|
@@ -54,8 +63,6 @@ watch(
|
|
|
54
63
|
void packageRegistries.ensureLoaded().catch(() => {})
|
|
55
64
|
void publicApiKeys.ensureLoaded().catch(() => {})
|
|
56
65
|
void userSecrets.load().catch(() => {})
|
|
57
|
-
// Drives the OpenRouter row's "Key connected" badge.
|
|
58
|
-
if (workspace.workspaceId) void apiKeys.load(workspace.workspaceId).catch(() => {})
|
|
59
66
|
}
|
|
60
67
|
},
|
|
61
68
|
// Lazy v-if mount: the hub mounts with `integrationsOpen` already true → load immediately.
|
|
@@ -111,42 +118,9 @@ function go(fn: () => void) {
|
|
|
111
118
|
const groups = computed<IntegrationGroup[]>(() => {
|
|
112
119
|
const out: IntegrationGroup[] = []
|
|
113
120
|
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
out.push({
|
|
118
|
-
title: t('layout.integrationsHub.groups.models'),
|
|
119
|
-
items: [
|
|
120
|
-
{
|
|
121
|
-
key: 'openrouter',
|
|
122
|
-
icon: 'i-lucide-waypoints',
|
|
123
|
-
label: 'OpenRouter',
|
|
124
|
-
description: t('layout.integrationsHub.items.openrouter.description'),
|
|
125
|
-
status: openRouterKeyConnected
|
|
126
|
-
? t('layout.integrationsHub.status.keyConnected')
|
|
127
|
-
: undefined,
|
|
128
|
-
connected: openRouterKeyConnected,
|
|
129
|
-
recommended: true,
|
|
130
|
-
onClick: () => go(ui.openOpenRouter),
|
|
131
|
-
},
|
|
132
|
-
{
|
|
133
|
-
key: 'vendors',
|
|
134
|
-
icon: 'i-lucide-key-round',
|
|
135
|
-
label: t('layout.integrationsHub.items.vendors.label'),
|
|
136
|
-
description: t('layout.integrationsHub.items.vendors.description'),
|
|
137
|
-
onClick: () => go(ui.openVendorCredentials),
|
|
138
|
-
},
|
|
139
|
-
],
|
|
140
|
-
// Personal (individual-usage) subscriptions are per-USER, so they live in My setup —
|
|
141
|
-
// but this hub is where people look first when connecting "their Claude plan". A quiet
|
|
142
|
-
// pointer link keeps them findable without a workspace-scoped row competing here.
|
|
143
|
-
footerLink: {
|
|
144
|
-
key: 'personal-subs',
|
|
145
|
-
icon: 'i-lucide-user',
|
|
146
|
-
label: t('layout.integrationsHub.items.personalSubs.label'),
|
|
147
|
-
onClick: () => go(() => ui.openVendorCredentials('personal')),
|
|
148
|
-
},
|
|
149
|
-
})
|
|
121
|
+
// NOTE: model providers (OpenRouter, vendor keys, personal subscriptions, local runners)
|
|
122
|
+
// are NOT listed here — they have their own top-level hub (`ModelProvidersHub.vue`, SideBar
|
|
123
|
+
// → "Model providers"). See the scope split at the top of this file.
|
|
150
124
|
|
|
151
125
|
// --- Source control --------------------------------------------------------
|
|
152
126
|
const code: IntegrationItem[] = []
|
|
@@ -268,7 +242,17 @@ const groups = computed<IntegrationGroup[]>(() => {
|
|
|
268
242
|
// Gated like every other backend-toggleable system: hidden until a probe confirms
|
|
269
243
|
// the observability module is enabled (`available === true`), so a disabled backend
|
|
270
244
|
// doesn't show a dead "Connect" row that only 503s.
|
|
271
|
-
|
|
245
|
+
//
|
|
246
|
+
// Also an ADVANCED-tier row. Post-release health is the one integration here that acts
|
|
247
|
+
// AFTER delivery rather than during it — it watches monitors once a release ships and can
|
|
248
|
+
// spawn an on-call agent — so it sits outside the everyday loop the basic tier serves.
|
|
249
|
+
// Gated with `showOverrideField` rather than a bare `isAdvanced`: an already-connected
|
|
250
|
+
// Datadog is live behaviour on this workspace, and a tier must never conceal a connection a
|
|
251
|
+
// basic-mode user would then have no way to inspect or disconnect.
|
|
252
|
+
if (
|
|
253
|
+
releaseHealth.available &&
|
|
254
|
+
showOverrideField(uiMode.isAdvanced, releaseHealth.connection.connected || null)
|
|
255
|
+
) {
|
|
272
256
|
out.push({
|
|
273
257
|
title: t('layout.integrationsHub.groups.observability'),
|
|
274
258
|
items: [
|
|
@@ -325,6 +309,8 @@ const groups = computed<IntegrationGroup[]>(() => {
|
|
|
325
309
|
// --- Personal (only you) — fallback when there is no UserMenu to host "My setup" -------
|
|
326
310
|
// Per-user connections normally live in the My-setup hub; with auth disabled they fold in
|
|
327
311
|
// here so they stay reachable. (The badge reflects the signed-in user's stored secret.)
|
|
312
|
+
// Only the source-control one: the per-user MODEL connections are listed unconditionally
|
|
313
|
+
// in the Model providers hub, so they need no fallback.
|
|
328
314
|
if (!personalHubReachable.value) {
|
|
329
315
|
const pat = !!userSecrets.statusFor('github_pat')
|
|
330
316
|
out.push({
|
|
@@ -339,13 +325,6 @@ const groups = computed<IntegrationGroup[]>(() => {
|
|
|
339
325
|
connected: pat,
|
|
340
326
|
onClick: () => go(ui.openUserSecrets),
|
|
341
327
|
},
|
|
342
|
-
{
|
|
343
|
-
key: 'local-runners',
|
|
344
|
-
icon: 'i-lucide-server',
|
|
345
|
-
label: t('layout.integrationsHub.items.localRunners.label'),
|
|
346
|
-
description: t('layout.integrationsHub.items.localRunners.description'),
|
|
347
|
-
onClick: () => go(ui.openLocalModels),
|
|
348
|
-
},
|
|
349
328
|
],
|
|
350
329
|
})
|
|
351
330
|
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The Model providers hub: where the ENGINES the harnesses run on are connected.
|
|
3
|
+
//
|
|
4
|
+
// Split out of the Integrations hub deliberately. Both are technically "external systems we
|
|
5
|
+
// hold a credential for", but they answer different questions: an integration is an OPTIONAL
|
|
6
|
+
// system that feeds a run context or receives its output (source control, trackers, documents,
|
|
7
|
+
// chat, observability), while a model provider is what actually executes the work — with none
|
|
8
|
+
// connected, nothing runs at all. Listing the providers among a dozen optional connectors
|
|
9
|
+
// buried the one connection every deployment must make.
|
|
10
|
+
//
|
|
11
|
+
// Rows reuse the existing per-provider panel handlers on the `ui` store via
|
|
12
|
+
// `ui.openFromModelProviders(...)`, so opening one closes this hub and gives that panel a
|
|
13
|
+
// "Back to Model providers" control (IntegrationBackTitle). Layout mirrors the Integrations
|
|
14
|
+
// hub row-for-row so the two read as siblings.
|
|
15
|
+
const { t } = useI18n()
|
|
16
|
+
const ui = useUiStore()
|
|
17
|
+
const apiKeys = useApiKeysStore()
|
|
18
|
+
const localModels = useLocalModelsStore()
|
|
19
|
+
const personalSubs = usePersonalSubscriptionsStore()
|
|
20
|
+
const workspace = useWorkspaceStore()
|
|
21
|
+
|
|
22
|
+
const open = computed({
|
|
23
|
+
get: () => ui.modelProvidersOpen,
|
|
24
|
+
set: (v: boolean) => (v ? ui.openModelProviders() : ui.closeModelProviders()),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// Free-text filter over the rows (label + description), matching the Integrations hub.
|
|
28
|
+
// Declared before the `immediate` watch below, which resets it on open.
|
|
29
|
+
const query = ref('')
|
|
30
|
+
|
|
31
|
+
// Load the cheap status reads each time the hub opens so every badge is accurate.
|
|
32
|
+
watch(
|
|
33
|
+
() => ui.modelProvidersOpen,
|
|
34
|
+
(isOpen) => {
|
|
35
|
+
if (!isOpen) return
|
|
36
|
+
query.value = ''
|
|
37
|
+
if (workspace.workspaceId) void apiKeys.load(workspace.workspaceId).catch(() => {})
|
|
38
|
+
void localModels.load().catch(() => {})
|
|
39
|
+
void personalSubs.load().catch(() => {})
|
|
40
|
+
},
|
|
41
|
+
// Lazy v-if mount: the hub mounts with `modelProvidersOpen` already true → load immediately.
|
|
42
|
+
{ immediate: true },
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
/** One provider row. `connected` drives the green badge; `status` is its line. */
|
|
46
|
+
interface ProviderItem {
|
|
47
|
+
key: string
|
|
48
|
+
icon: string
|
|
49
|
+
label: string
|
|
50
|
+
description: string
|
|
51
|
+
status?: string
|
|
52
|
+
connected?: boolean
|
|
53
|
+
/** Tags the fastest path to a working deployment while nothing is connected yet. */
|
|
54
|
+
recommended?: boolean
|
|
55
|
+
onClick: () => void
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ProviderGroup {
|
|
59
|
+
title: string
|
|
60
|
+
/** Rendered under the group's rows — the scope caveat for the personal group. */
|
|
61
|
+
note?: string
|
|
62
|
+
items: ProviderItem[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function go(fn: () => void) {
|
|
66
|
+
ui.openFromModelProviders(fn)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const groups = computed<ProviderGroup[]>(() => {
|
|
70
|
+
const openRouterKeyConnected = apiKeys.configuredProviders.has('openrouter')
|
|
71
|
+
const runnerCount = localModels.endpoints.length
|
|
72
|
+
const subCount = personalSubs.subscriptions.length
|
|
73
|
+
|
|
74
|
+
return [
|
|
75
|
+
// --- Workspace-wide providers ------------------------------------------
|
|
76
|
+
// An OpenRouter key is the fastest path to 300+ models, so it leads.
|
|
77
|
+
{
|
|
78
|
+
title: t('layout.modelProvidersHub.groups.workspace'),
|
|
79
|
+
items: [
|
|
80
|
+
{
|
|
81
|
+
key: 'openrouter',
|
|
82
|
+
icon: 'i-lucide-waypoints',
|
|
83
|
+
label: 'OpenRouter',
|
|
84
|
+
description: t('layout.modelProvidersHub.items.openrouter.description'),
|
|
85
|
+
status: openRouterKeyConnected
|
|
86
|
+
? t('layout.modelProvidersHub.status.keyConnected')
|
|
87
|
+
: undefined,
|
|
88
|
+
connected: openRouterKeyConnected,
|
|
89
|
+
recommended: true,
|
|
90
|
+
onClick: () => go(ui.openOpenRouter),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
key: 'vendors',
|
|
94
|
+
icon: 'i-lucide-key-round',
|
|
95
|
+
label: t('layout.modelProvidersHub.items.vendors.label'),
|
|
96
|
+
description: t('layout.modelProvidersHub.items.vendors.description'),
|
|
97
|
+
onClick: () => go(ui.openVendorCredentials),
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
// --- Personal (per-user) providers -------------------------------------
|
|
102
|
+
// These live on the user, not the workspace, and are also reachable from "My setup".
|
|
103
|
+
// They are listed here as full rows rather than a pointer link because this hub is
|
|
104
|
+
// scoped by TOPIC (what runs the work) rather than by ownership — "where do I put my
|
|
105
|
+
// Claude plan" is the single most common reason to open it.
|
|
106
|
+
{
|
|
107
|
+
title: t('layout.modelProvidersHub.groups.personal'),
|
|
108
|
+
note: t('layout.modelProvidersHub.groups.personalNote'),
|
|
109
|
+
items: [
|
|
110
|
+
{
|
|
111
|
+
key: 'personal-subs',
|
|
112
|
+
icon: 'i-lucide-user',
|
|
113
|
+
label: t('layout.modelProvidersHub.items.personalSubs.label'),
|
|
114
|
+
description: t('layout.modelProvidersHub.items.personalSubs.description'),
|
|
115
|
+
status: subCount
|
|
116
|
+
? t('layout.modelProvidersHub.status.connectedCount', { count: subCount }, subCount)
|
|
117
|
+
: undefined,
|
|
118
|
+
connected: subCount > 0,
|
|
119
|
+
onClick: () => go(() => ui.openVendorCredentials('personal')),
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
key: 'local-runners',
|
|
123
|
+
icon: 'i-lucide-server',
|
|
124
|
+
label: t('layout.modelProvidersHub.items.localRunners.label'),
|
|
125
|
+
description: t('layout.modelProvidersHub.items.localRunners.description'),
|
|
126
|
+
status: runnerCount
|
|
127
|
+
? t(
|
|
128
|
+
'layout.modelProvidersHub.status.connectedCount',
|
|
129
|
+
{ count: runnerCount },
|
|
130
|
+
runnerCount,
|
|
131
|
+
)
|
|
132
|
+
: undefined,
|
|
133
|
+
connected: runnerCount > 0,
|
|
134
|
+
onClick: () => go(ui.openLocalModels),
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
]
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// Connected rows first, then idle — a stable rank so each group reads "what's live" top-down.
|
|
142
|
+
function stateRank(item: ProviderItem): number {
|
|
143
|
+
return item.connected ? 0 : 1
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const allItems = computed(() => groups.value.flatMap((g) => g.items))
|
|
147
|
+
const anyConnected = computed(() => allItems.value.some((i) => i.connected))
|
|
148
|
+
|
|
149
|
+
function matches(text: string, q: string): boolean {
|
|
150
|
+
return text.toLowerCase().includes(q)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const filteredGroups = computed<ProviderGroup[]>(() => {
|
|
154
|
+
const q = query.value.trim().toLowerCase()
|
|
155
|
+
return groups.value
|
|
156
|
+
.map((g) => ({
|
|
157
|
+
...g,
|
|
158
|
+
items: (q ? g.items.filter((i) => matches(i.label, q) || matches(i.description, q)) : g.items)
|
|
159
|
+
.slice()
|
|
160
|
+
.sort((a, b) => stateRank(a) - stateRank(b)),
|
|
161
|
+
}))
|
|
162
|
+
.filter((g) => g.items.length)
|
|
163
|
+
})
|
|
164
|
+
</script>
|
|
165
|
+
|
|
166
|
+
<template>
|
|
167
|
+
<UModal
|
|
168
|
+
v-model:open="open"
|
|
169
|
+
:title="t('layout.modelProvidersHub.title')"
|
|
170
|
+
:ui="{ content: 'max-w-xl' }"
|
|
171
|
+
>
|
|
172
|
+
<template #body>
|
|
173
|
+
<div class="space-y-5" data-testid="model-providers-hub">
|
|
174
|
+
<p class="text-xs text-slate-400">
|
|
175
|
+
{{ t('layout.modelProvidersHub.intro') }}
|
|
176
|
+
</p>
|
|
177
|
+
|
|
178
|
+
<!-- Nothing connected at all is a hard stop, not a nudge: no provider means no agent
|
|
179
|
+
step can run. Say so plainly rather than showing an empty list of options. -->
|
|
180
|
+
<div
|
|
181
|
+
v-if="!anyConnected"
|
|
182
|
+
class="rounded-lg border border-primary-500/40 bg-primary-500/10 p-3"
|
|
183
|
+
>
|
|
184
|
+
<div class="mb-1.5 flex items-center gap-2 text-sm font-medium text-primary-200">
|
|
185
|
+
<UIcon name="i-lucide-rocket" class="h-4 w-4 shrink-0" />
|
|
186
|
+
<span>{{ t('layout.modelProvidersHub.getStarted.title') }}</span>
|
|
187
|
+
</div>
|
|
188
|
+
<p class="text-xs text-slate-300">
|
|
189
|
+
{{ t('layout.modelProvidersHub.getStarted.body') }}
|
|
190
|
+
</p>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<UInput
|
|
194
|
+
v-model="query"
|
|
195
|
+
icon="i-lucide-search"
|
|
196
|
+
size="sm"
|
|
197
|
+
:placeholder="t('layout.modelProvidersHub.searchPlaceholder')"
|
|
198
|
+
class="w-full"
|
|
199
|
+
/>
|
|
200
|
+
|
|
201
|
+
<p v-if="!filteredGroups.length" class="px-1 py-6 text-center text-sm text-slate-500">
|
|
202
|
+
{{ t('layout.modelProvidersHub.noMatches', { query }) }}
|
|
203
|
+
</p>
|
|
204
|
+
|
|
205
|
+
<section v-for="group in filteredGroups" :key="group.title">
|
|
206
|
+
<h3 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
207
|
+
{{ group.title }}
|
|
208
|
+
</h3>
|
|
209
|
+
<div class="space-y-1.5">
|
|
210
|
+
<button
|
|
211
|
+
v-for="item in group.items"
|
|
212
|
+
:key="item.key"
|
|
213
|
+
type="button"
|
|
214
|
+
class="flex w-full items-center gap-3 rounded-lg border border-slate-800 bg-slate-900/50 px-3 py-2.5 text-start transition hover:border-slate-700 hover:bg-slate-900"
|
|
215
|
+
:data-testid="`model-provider-${item.key}`"
|
|
216
|
+
@click="item.onClick()"
|
|
217
|
+
>
|
|
218
|
+
<UIcon :name="item.icon" class="h-5 w-5 shrink-0 text-slate-300" />
|
|
219
|
+
<div class="min-w-0 flex-1">
|
|
220
|
+
<div class="flex items-center gap-2">
|
|
221
|
+
<span class="truncate text-sm font-medium text-slate-100">{{ item.label }}</span>
|
|
222
|
+
<UBadge v-if="item.connected" color="success" variant="subtle" size="sm">
|
|
223
|
+
{{ item.status || t('layout.modelProvidersHub.status.connected') }}
|
|
224
|
+
</UBadge>
|
|
225
|
+
<span v-else class="text-[11px] text-slate-500">{{
|
|
226
|
+
t('layout.modelProvidersHub.status.notConnected')
|
|
227
|
+
}}</span>
|
|
228
|
+
<UBadge
|
|
229
|
+
v-if="!anyConnected && item.recommended"
|
|
230
|
+
color="primary"
|
|
231
|
+
variant="subtle"
|
|
232
|
+
size="sm"
|
|
233
|
+
>
|
|
234
|
+
{{ t('layout.modelProvidersHub.status.recommended') }}
|
|
235
|
+
</UBadge>
|
|
236
|
+
</div>
|
|
237
|
+
<p class="truncate text-xs text-slate-400">{{ item.description }}</p>
|
|
238
|
+
</div>
|
|
239
|
+
<UIcon
|
|
240
|
+
name="i-lucide-chevron-right"
|
|
241
|
+
class="h-4 w-4 shrink-0 text-slate-500 rtl:-scale-x-100"
|
|
242
|
+
/>
|
|
243
|
+
</button>
|
|
244
|
+
</div>
|
|
245
|
+
<p v-if="group.note" class="mt-1.5 px-1 text-[11px] text-slate-500">{{ group.note }}</p>
|
|
246
|
+
</section>
|
|
247
|
+
</div>
|
|
248
|
+
</template>
|
|
249
|
+
</UModal>
|
|
250
|
+
</template>
|
|
@@ -13,7 +13,6 @@ const pipelines = usePipelinesStore()
|
|
|
13
13
|
const execution = useExecutionStore()
|
|
14
14
|
const ui = useUiStore()
|
|
15
15
|
const documents = useDocumentsStore()
|
|
16
|
-
const tasks = useTasksStore()
|
|
17
16
|
const fragments = useFragmentsStore()
|
|
18
17
|
const agentRuns = useAgentRunsStore()
|
|
19
18
|
const github = useGitHubStore()
|
|
@@ -450,33 +449,9 @@ const showOriginalDescription = ref(false)
|
|
|
450
449
|
>
|
|
451
450
|
{{ block!.pullRequest!.branch }}
|
|
452
451
|
</UButton>
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
variant="soft"
|
|
457
|
-
size="xs"
|
|
458
|
-
icon="i-lucide-ticket"
|
|
459
|
-
@click="ui.openTaskImport()"
|
|
460
|
-
>
|
|
461
|
-
{{
|
|
462
|
-
tasks.anyOffered
|
|
463
|
-
? t('panels.inspector.importIssue')
|
|
464
|
-
: t('panels.inspector.connectTracker')
|
|
465
|
-
}}
|
|
466
|
-
</UButton>
|
|
467
|
-
<!-- Hunt this service's tracker board for a bug worth picking up. Scoped to the
|
|
468
|
-
selected container, so an adopted candidate lands here rather than wherever the
|
|
469
|
-
board's first frame happens to be. -->
|
|
470
|
-
<UButton
|
|
471
|
-
v-if="isContainer && tasks.anyOffered"
|
|
472
|
-
color="neutral"
|
|
473
|
-
variant="soft"
|
|
474
|
-
size="xs"
|
|
475
|
-
icon="i-lucide-radar"
|
|
476
|
-
@click="ui.openBugHunt(null, block!.id)"
|
|
477
|
-
>
|
|
478
|
-
{{ t('panels.inspector.huntBugs') }}
|
|
479
|
-
</UButton>
|
|
452
|
+
<!-- Tracker entry points (import an issue, hunt bugs) live on the service frame's
|
|
453
|
+
header, where they are already scoped to a frame — the inspector doesn't
|
|
454
|
+
duplicate them. -->
|
|
480
455
|
<UButton
|
|
481
456
|
v-if="isContainer && documents.available && documents.anyConnected"
|
|
482
457
|
color="neutral"
|