@cat-factory/app 0.167.0 → 0.168.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 +45 -2
- package/app/components/auth/UserMenu.vue +9 -2
- package/app/components/board/AddTaskModal.vue +28 -9
- package/app/components/layout/BoardSwitcher.vue +21 -4
- package/app/components/layout/CommandBar.vue +3 -1
- package/app/components/layout/LanguageSwitcher.vue +9 -2
- package/app/components/layout/SideBar.vue +68 -15
- package/app/components/layout/UiModeSwitcher.vue +90 -0
- package/app/components/panels/inspector/TaskRunSettings.vue +35 -8
- package/app/components/settings/ConnectionWarnings.vue +38 -0
- package/app/components/settings/KubernetesEnvironmentForm.vue +5 -1
- package/app/components/settings/ProviderConnectionTab.vue +5 -1
- package/app/components/settings/ProviderManifestEditor.vue +5 -1
- package/app/components/tasks/BugHuntModal.vue +3 -1
- package/app/components/tasks/ContextIssuePicker.vue +22 -5
- package/app/components/tasks/TaskImportModal.vue +1 -1
- package/app/composables/api/errors.ts +16 -0
- package/app/composables/api/tasks.ts +5 -2
- package/app/composables/useNavContributions.ts +3 -0
- package/app/docs/consumer-extensions.md +6 -1
- package/app/modular/nav-contributions.spec.ts +59 -1
- package/app/modular/nav-contributions.ts +62 -4
- package/app/modular/nav-gates.ts +7 -1
- package/app/modular/registry.spec.ts +1 -0
- package/app/stores/bugHunt.ts +2 -10
- package/app/stores/tasks.ts +7 -4
- package/app/stores/uiMode.spec.ts +151 -0
- package/app/stores/uiMode.ts +88 -0
- package/app/utils/connectionWarnings.ts +17 -0
- package/app/utils/uiMode.spec.ts +67 -0
- package/app/utils/uiMode.ts +71 -0
- package/i18n/locales/de.json +23 -5
- package/i18n/locales/en.json +26 -5
- package/i18n/locales/es.json +23 -5
- package/i18n/locales/fr.json +23 -5
- package/i18n/locales/he.json +23 -5
- package/i18n/locales/it.json +23 -5
- package/i18n/locales/ja.json +23 -5
- package/i18n/locales/pl.json +23 -5
- package/i18n/locales/tr.json +23 -5
- package/i18n/locales/uk.json +23 -5
- package/nuxt.config.ts +5 -0
- package/package.json +2 -2
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The non-fatal GAPS a connection test reports about the config it just probed: it connects,
|
|
3
|
+
// but something it never declared costs a recovery or cleanup path that only shows itself
|
|
4
|
+
// during an incident (a runner-pool manifest with no `release` template leaks a runner on
|
|
5
|
+
// every cancelled run).
|
|
6
|
+
//
|
|
7
|
+
// Rendered beside the pass/fail verdict rather than as part of it, because the two are
|
|
8
|
+
// independent — a green test with a gap is the common case, and folding the gap into the
|
|
9
|
+
// failure line would make a working connection look broken.
|
|
10
|
+
//
|
|
11
|
+
// The backend sends machine-readable codes (it does not localize prose); the copy lives in the
|
|
12
|
+
// catalog behind `CONNECTION_WARNING_KEYS`. A code this SPA build predates falls back to the
|
|
13
|
+
// backend's own English `message` — untranslated, but never a blank row.
|
|
14
|
+
import type { ConnectionWarning } from '@cat-factory/contracts'
|
|
15
|
+
import { CONNECTION_WARNING_KEYS } from '~/utils/connectionWarnings'
|
|
16
|
+
|
|
17
|
+
defineProps<{ warnings?: ConnectionWarning[] }>()
|
|
18
|
+
|
|
19
|
+
const { t, te } = useI18n()
|
|
20
|
+
|
|
21
|
+
const copy = (warning: ConnectionWarning): string => {
|
|
22
|
+
const key = CONNECTION_WARNING_KEYS[warning.code]
|
|
23
|
+
return key && te(key) ? t(key) : warning.message
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<div
|
|
29
|
+
v-if="warnings?.length"
|
|
30
|
+
class="rounded-md border border-amber-500/40 bg-amber-950/40 px-3 py-2 text-xs text-amber-200"
|
|
31
|
+
data-testid="connection-warnings"
|
|
32
|
+
>
|
|
33
|
+
<p class="font-semibold">{{ t('settings.providerConnection.test.warningsTitle') }}</p>
|
|
34
|
+
<ul class="mt-1 list-disc space-y-1 pl-4">
|
|
35
|
+
<li v-for="warning in warnings" :key="warning.code">{{ copy(warning) }}</li>
|
|
36
|
+
</ul>
|
|
37
|
+
</div>
|
|
38
|
+
</template>
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// driven (see docs/initiatives/descriptor-driven-infra-forms.md).
|
|
11
11
|
import { computed, reactive, ref, watch } from 'vue'
|
|
12
12
|
import { KUBERNETES_ENV_TOKEN_SECRET_KEY } from '@cat-factory/contracts'
|
|
13
|
+
import type { ConnectionTestResult } from '@cat-factory/contracts'
|
|
14
|
+
import ConnectionWarnings from '~/components/settings/ConnectionWarnings.vue'
|
|
13
15
|
import SecretInput from '~/components/common/SecretInput.vue'
|
|
14
16
|
import type { ProviderConnection } from '~/types/providerConnections'
|
|
15
17
|
|
|
@@ -18,7 +20,7 @@ const props = defineProps<{
|
|
|
18
20
|
supportsTest: boolean
|
|
19
21
|
testing: boolean
|
|
20
22
|
busy: boolean
|
|
21
|
-
testResult:
|
|
23
|
+
testResult: ConnectionTestResult | null
|
|
22
24
|
}>()
|
|
23
25
|
|
|
24
26
|
const emit = defineEmits<{
|
|
@@ -409,6 +411,8 @@ function optional(label: string): string {
|
|
|
409
411
|
</span>
|
|
410
412
|
</div>
|
|
411
413
|
|
|
414
|
+
<ConnectionWarnings :warnings="testResult?.warnings" />
|
|
415
|
+
|
|
412
416
|
<div class="flex items-center justify-end gap-3">
|
|
413
417
|
<p v-if="connectBlockedReason" class="flex-1 text-left text-xs text-rose-400">
|
|
414
418
|
{{ connectBlockedReason }}
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
// - a MANIFEST-driven provider (no template) → the full JSON manifest editor
|
|
13
13
|
// (ProviderManifestEditor), which replaces the old "use the API" disclaimer.
|
|
14
14
|
import { computed, ref, toRaw, watch } from 'vue'
|
|
15
|
+
import type { ConnectionTestResult } from '@cat-factory/contracts'
|
|
15
16
|
import type { ProviderConfigField, ProviderConnectionKind } from '~/types/providerConnections'
|
|
17
|
+
import ConnectionWarnings from '~/components/settings/ConnectionWarnings.vue'
|
|
16
18
|
import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
|
|
17
19
|
import ProviderManifestEditor from '~/components/settings/ProviderManifestEditor.vue'
|
|
18
20
|
import KubernetesEnvironmentForm from '~/components/settings/KubernetesEnvironmentForm.vue'
|
|
@@ -56,7 +58,7 @@ const showLogs = ref(false)
|
|
|
56
58
|
|
|
57
59
|
// --- Shared state -------------------------------------------------------------------
|
|
58
60
|
const values = ref<Record<string, string>>({})
|
|
59
|
-
const testResult = ref<
|
|
61
|
+
const testResult = ref<ConnectionTestResult | null>(null)
|
|
60
62
|
const testing = ref(false)
|
|
61
63
|
const busy = ref(false)
|
|
62
64
|
|
|
@@ -515,6 +517,8 @@ function fieldHelp(key: string): string | undefined {
|
|
|
515
517
|
</span>
|
|
516
518
|
</div>
|
|
517
519
|
|
|
520
|
+
<ConnectionWarnings :warnings="testResult?.warnings" />
|
|
521
|
+
|
|
518
522
|
<div class="flex justify-end">
|
|
519
523
|
<UButton
|
|
520
524
|
color="primary"
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
import { computed, ref, watch } from 'vue'
|
|
18
18
|
import * as v from 'valibot'
|
|
19
19
|
import { environmentManifestSchema, runnerPoolManifestSchema } from '@cat-factory/contracts'
|
|
20
|
+
import type { ConnectionTestResult } from '@cat-factory/contracts'
|
|
20
21
|
import type { ProviderConnectionKind } from '~/types/providerConnections'
|
|
22
|
+
import ConnectionWarnings from '~/components/settings/ConnectionWarnings.vue'
|
|
21
23
|
import SecretInput from '~/components/common/SecretInput.vue'
|
|
22
24
|
|
|
23
25
|
const props = defineProps<{
|
|
@@ -34,7 +36,7 @@ const props = defineProps<{
|
|
|
34
36
|
/** Bubbled-up busy state from the tab's store calls (so the editor shows loading). */
|
|
35
37
|
testing: boolean
|
|
36
38
|
busy: boolean
|
|
37
|
-
testResult:
|
|
39
|
+
testResult: ConnectionTestResult | null
|
|
38
40
|
}>()
|
|
39
41
|
|
|
40
42
|
const emit = defineEmits<{
|
|
@@ -286,6 +288,8 @@ function onSave() {
|
|
|
286
288
|
</span>
|
|
287
289
|
</div>
|
|
288
290
|
|
|
291
|
+
<ConnectionWarnings :warnings="testResult?.warnings" />
|
|
292
|
+
|
|
289
293
|
<div class="flex justify-end">
|
|
290
294
|
<UButton
|
|
291
295
|
color="primary"
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// unavailable or failed still shows its candidates (flagged as unassessed, since the scan is
|
|
11
11
|
// useful on its own), and a scan that hit its cap says so — a silently shortened list reads
|
|
12
12
|
// exactly like an exhaustive one.
|
|
13
|
+
import type { TaskSourceReadReason } from '@cat-factory/contracts'
|
|
13
14
|
import type {
|
|
14
15
|
BugHuntAnalysisStatus,
|
|
15
16
|
BugHuntCandidate,
|
|
@@ -69,8 +70,9 @@ const boardItems = computed(() =>
|
|
|
69
70
|
* backend's reason code, not on "any error": an unreachable tracker or an expired token would
|
|
70
71
|
* otherwise present as a free-text field that simply moves the same failure to the next click.
|
|
71
72
|
*/
|
|
73
|
+
const BOARDS_UNSUPPORTED: TaskSourceReadReason = 'boards_unsupported'
|
|
72
74
|
const boardIsFreeText = computed(
|
|
73
|
-
() => !hunt.boardsLoading && hunt.boardsErrorReason ===
|
|
75
|
+
() => !hunt.boardsLoading && hunt.boardsErrorReason === BOARDS_UNSUPPORTED,
|
|
74
76
|
)
|
|
75
77
|
|
|
76
78
|
/** A board read that failed for a reason the user has to fix — shown, never silently swallowed. */
|
|
@@ -7,12 +7,19 @@
|
|
|
7
7
|
// them once the block exists (see useContextLinking). A search hit / pasted ref
|
|
8
8
|
// carries `needsImport: true` so it's fetched + persisted before linking.
|
|
9
9
|
//
|
|
10
|
+
// Search results are always confined to ONE repository (`scopeBlockId`'s service).
|
|
11
|
+
// An issue in another repo is reachable only by pasting its URL, which the explicit
|
|
12
|
+
// "attach by reference" row below imports directly — it never comes back as a hit,
|
|
13
|
+
// so what the search offers is exactly what the service owns.
|
|
14
|
+
//
|
|
10
15
|
// The tracker being searched is ALWAYS on screen (even when the workspace offers
|
|
11
16
|
// exactly one), and its menu doubles as the "add a tracker" affordance: attaching a
|
|
12
17
|
// context issue is where a missing integration is discovered, and the connect modal
|
|
13
18
|
// opens over the caller's form rather than navigating away from it.
|
|
14
19
|
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
20
|
+
import type { TaskSourceReadReason } from '@cat-factory/contracts'
|
|
15
21
|
import type { SourceTask, TaskSearchResult, TaskSourceKind } from '~/types/domain'
|
|
22
|
+
import { apiErrorReason } from '~/composables/api/errors'
|
|
16
23
|
import EmptyState from '~/components/common/EmptyState.vue'
|
|
17
24
|
import { buildSourceChoices, reconcileSource } from '~/components/tasks/ContextIssuePicker.logic'
|
|
18
25
|
|
|
@@ -21,10 +28,13 @@ const props = defineProps<{
|
|
|
21
28
|
chosenKeys?: string[]
|
|
22
29
|
/**
|
|
23
30
|
* The block the picker is attaching context to (a service frame or a task/module
|
|
24
|
-
* under one).
|
|
25
|
-
* in-repo and a
|
|
31
|
+
* under one). REQUIRED: it is what scopes a GitHub search to that service's linked
|
|
32
|
+
* repo, so hits stay in-repo and a bare issue number resolves to the exact issue.
|
|
33
|
+
* A search with no scope reaches every repository the deployment's credential can
|
|
34
|
+
* see (under a PAT, all of public GitHub), so there is no unscoped mode — a caller
|
|
35
|
+
* that has no block yet must not render the picker.
|
|
26
36
|
*/
|
|
27
|
-
scopeBlockId
|
|
37
|
+
scopeBlockId: string
|
|
28
38
|
/**
|
|
29
39
|
* Controlled source: when provided the parent owns the selected tracker (via
|
|
30
40
|
* `v-model:source`); omitted, the picker manages it internally (the add-task case).
|
|
@@ -147,7 +157,14 @@ async function runSearch() {
|
|
|
147
157
|
results.value = await tasks.search(source.value, q, props.scopeBlockId)
|
|
148
158
|
} catch (e) {
|
|
149
159
|
results.value = []
|
|
150
|
-
|
|
160
|
+
// "This service has no repo" is the one failure with an action attached, so it gets its
|
|
161
|
+
// own localized copy off the backend's machine-readable reason rather than the raw
|
|
162
|
+
// message (CLAUDE.md "Backend strings"). Anything else keeps the generic wording.
|
|
163
|
+
const notLinked: TaskSourceReadReason = 'repo_not_linked'
|
|
164
|
+
searchError.value =
|
|
165
|
+
apiErrorReason(e) === notLinked
|
|
166
|
+
? t('tasks.picker.searchNeedsRepo')
|
|
167
|
+
: t('tasks.picker.searchFailed', { error: e instanceof Error ? e.message : String(e) })
|
|
151
168
|
} finally {
|
|
152
169
|
searching.value = false
|
|
153
170
|
}
|
|
@@ -295,7 +312,7 @@ onMounted(() => {
|
|
|
295
312
|
/>
|
|
296
313
|
|
|
297
314
|
<p v-if="searchError" class="px-1 text-[11px] text-amber-400">
|
|
298
|
-
{{
|
|
315
|
+
{{ searchError }}
|
|
299
316
|
</p>
|
|
300
317
|
|
|
301
318
|
<div class="max-h-56 space-y-0.5 overflow-y-auto">
|
|
@@ -163,7 +163,7 @@ async function doSpawnEpic() {
|
|
|
163
163
|
or paste a URL/key — choosing one opens the prefilled add-task form. The
|
|
164
164
|
search is scoped to the chosen container's repo (so a GitHub search stays
|
|
165
165
|
in that service's repo and a pasted URL / bare number resolves there). -->
|
|
166
|
-
<UFormField :label="t('tasks.import.searchIssues')">
|
|
166
|
+
<UFormField v-if="containerId" :label="t('tasks.import.searchIssues')">
|
|
167
167
|
<ContextIssuePicker
|
|
168
168
|
v-model:source="source"
|
|
169
169
|
:scope-block-id="containerId"
|
|
@@ -54,6 +54,22 @@ export function apiErrorEnvelope(error: unknown): ApiErrorEnvelope | undefined {
|
|
|
54
54
|
return envelopeOf(e?.body) ?? envelopeOf(e?.data)
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The backend's machine-readable `error.details.reason` code, when it sent one.
|
|
59
|
+
*
|
|
60
|
+
* This is the client half of the "backend strings" contract (see CLAUDE.md): a localizable
|
|
61
|
+
* server condition emits a stable code and the SPA maps it to a message key, with the raw
|
|
62
|
+
* prose `message` as the untranslated last resort. Callers compare the result against a union
|
|
63
|
+
* imported from `@cat-factory/contracts`, so a renamed code fails the typecheck instead of
|
|
64
|
+
* silently falling through to the generic wording.
|
|
65
|
+
*/
|
|
66
|
+
export function apiErrorReason(error: unknown): string | null {
|
|
67
|
+
const details = apiErrorEnvelope(error)?.details
|
|
68
|
+
if (!details || typeof details !== 'object') return null
|
|
69
|
+
const reason = (details as Record<string, unknown>).reason
|
|
70
|
+
return typeof reason === 'string' ? reason : null
|
|
71
|
+
}
|
|
72
|
+
|
|
57
73
|
/** The HTTP status of a thrown API error, when present (contract client or `$fetch`). */
|
|
58
74
|
export function apiErrorStatus(error: unknown): number | undefined {
|
|
59
75
|
const e = error as { statusCode?: unknown; status?: unknown }
|
|
@@ -71,16 +71,19 @@ export function tasksApi({ send, ws }: ApiContext) {
|
|
|
71
71
|
importTask: (workspaceId: string, source: TaskSourceKind, body: { ref: string }) =>
|
|
72
72
|
send(importTaskContract, { pathPrefix: ws(workspaceId), pathParams: { source }, body }),
|
|
73
73
|
|
|
74
|
+
// `blockId` is required: it is what scopes a GitHub search to the block's service
|
|
75
|
+
// repo, and the backend refuses an unscoped one (it would reach every repository the
|
|
76
|
+
// deployment's credential can see).
|
|
74
77
|
searchTaskSource: (
|
|
75
78
|
workspaceId: string,
|
|
76
79
|
source: TaskSourceKind,
|
|
77
80
|
query: string,
|
|
78
|
-
blockId
|
|
81
|
+
blockId: string,
|
|
79
82
|
) =>
|
|
80
83
|
send(searchTasksContract, {
|
|
81
84
|
pathPrefix: ws(workspaceId),
|
|
82
85
|
pathParams: { source },
|
|
83
|
-
body: { query,
|
|
86
|
+
body: { query, blockId },
|
|
84
87
|
}),
|
|
85
88
|
|
|
86
89
|
linkTask: (
|
|
@@ -47,6 +47,9 @@ export function useNavContributions() {
|
|
|
47
47
|
operatorDashboard: () => ui.openOperatorDashboard(),
|
|
48
48
|
reports: () => ui.openReports(),
|
|
49
49
|
shortcuts: () => ui.openShortcutsHelp(),
|
|
50
|
+
// No-op under an env pin (`setMode` refuses), so the palette entry matches the sidebar
|
|
51
|
+
// switcher's read-only state rather than pretending to flip a tier the resolver fixes.
|
|
52
|
+
toggleUiMode: () => useUiModeStore().toggleMode(),
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
/** Run a contribution's action (consumer `run` closure wins over the id map). */
|
|
@@ -61,12 +61,17 @@ export default defineNuxtPlugin(() => {
|
|
|
61
61
|
| Run-detail windows | `resultViews` | `{ id: '<ns>:<name>', component }` | `StepResultViewHost` via `dispatchStepView` |
|
|
62
62
|
| Agent kinds (palette data) | `agentKinds` | `{ kind, container, presentation: { label, icon, color, description, category?, resultView? } }` | agents store merge → `agentKindMeta` |
|
|
63
63
|
| Custom task types | `taskTypes` | `{ taskType: '<ns>:<name>', presentation, fields?, defaultPipelineId?, formPanel? }` | `AddTaskModal` picker/fields + `TaskCard` badge (via `taskTypeMeta`) |
|
|
64
|
-
| Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, run, sidebar?, command?, toolbar? }`
|
|
64
|
+
| Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, advanced?, run, sidebar?, command?, toolbar? }` | the three shells via `useNavContributions` |
|
|
65
65
|
| Inspector body panels | `inspectorPanels` | `{ id, component, when(block), order }` (`PanelEntry<Block>`) | `<PanelsOutlet>` in `InspectorPanel` |
|
|
66
66
|
| Top-level overlays | `appOverlays` | `{ id: '<ns>:<name>', component }` | `<AppOverlayHost>` via `useAppOverlays().open(id)` |
|
|
67
67
|
| Multi-step wizards | (journeys) | `registerJourney` + step modules | `<JourneyHost>` / `<JourneyOutlet>` |
|
|
68
68
|
| Locale strings | (i18n) | `i18n/locales/*.json` in the deployment | `@nuxtjs/i18n` layer deep-merge |
|
|
69
69
|
|
|
70
|
+
A `nav` entry may also declare `advanced: true`, which hides it in **basic** interface mode
|
|
71
|
+
(the shipped default) exactly as it does for the first-party destinations — see
|
|
72
|
+
[the layer README](../../README.md#interface-modes-basic--advanced). Use it for a power-user
|
|
73
|
+
destination; the flag is independent of `gate`, so both must pass for the item to render.
|
|
74
|
+
|
|
70
75
|
### Run-detail windows (`resultViews` + `agentKinds`)
|
|
71
76
|
|
|
72
77
|
Backend data selects a frontend component, joined by a namespaced id:
|
|
@@ -32,6 +32,9 @@ const NO_GATES: NavGates = {
|
|
|
32
32
|
infrastructureAvailable: false,
|
|
33
33
|
accountsEnabled: false,
|
|
34
34
|
isAccountAdmin: false,
|
|
35
|
+
// The permission axis is what these cases vary; keep the interface tier at `advanced`
|
|
36
|
+
// so a dropped item is unambiguously an RBAC/availability drop, not a tier drop.
|
|
37
|
+
advancedMode: true,
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
const ALL_GATES: NavGates = {
|
|
@@ -43,6 +46,7 @@ const ALL_GATES: NavGates = {
|
|
|
43
46
|
infrastructureAvailable: true,
|
|
44
47
|
accountsEnabled: true,
|
|
45
48
|
isAccountAdmin: true,
|
|
49
|
+
advancedMode: true,
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
const slots = (): AppSlots => ({
|
|
@@ -84,6 +88,58 @@ describe('navSlotFilter', () => {
|
|
|
84
88
|
const kept = ids(navSlotFilter(slots(), {}))
|
|
85
89
|
expect(kept.sort()).toEqual(NAV_CONTRIBUTIONS.map((i) => i.id).sort())
|
|
86
90
|
})
|
|
91
|
+
|
|
92
|
+
it('drops every advanced destination in basic interface mode', () => {
|
|
93
|
+
const gates: NavGates = { ...ALL_GATES, advancedMode: false }
|
|
94
|
+
const kept = ids(navSlotFilter(slots(), { gates }))
|
|
95
|
+
const basicOnly = NAV_CONTRIBUTIONS.filter((i) => !i.advanced).map((i) => i.id)
|
|
96
|
+
expect(kept.sort()).toEqual(basicOnly.sort())
|
|
97
|
+
// The everyday surface survives...
|
|
98
|
+
expect(kept).toContain('add-from-repo')
|
|
99
|
+
expect(kept).toContain('integrations-hub')
|
|
100
|
+
expect(kept).toContain('workspace-settings')
|
|
101
|
+
expect(kept).toContain('model-config')
|
|
102
|
+
// ...and the authoring / operator surfaces don't.
|
|
103
|
+
expect(kept).not.toContain('build-pipeline')
|
|
104
|
+
expect(kept).not.toContain('fragments')
|
|
105
|
+
expect(kept).not.toContain('sandbox')
|
|
106
|
+
expect(kept).not.toContain('operator-dashboard')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('keeps the tier switch itself reachable in basic mode', () => {
|
|
110
|
+
// Basic is the shipped default, so this palette entry is how a user who never finds the
|
|
111
|
+
// (icon-only, in the basic rail) sidebar switcher gets to the advanced half at all. Marking
|
|
112
|
+
// it `advanced` would hide the way back from exactly the tier that needs it.
|
|
113
|
+
const uiModeItem = NAV_CONTRIBUTIONS.find((i) => i.id === 'ui-mode')
|
|
114
|
+
expect(uiModeItem?.advanced).toBeUndefined()
|
|
115
|
+
expect(uiModeItem?.gate).toBeUndefined()
|
|
116
|
+
|
|
117
|
+
const gates: NavGates = { ...NO_GATES, advancedMode: false }
|
|
118
|
+
expect(ids(navSlotFilter(slots(), { gates }))).toContain('ui-mode')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('keeps the tier and the permission axes independent — both must pass', () => {
|
|
122
|
+
// `build-pipeline` is advanced AND needs board.write: neither axis alone reveals it.
|
|
123
|
+
const advancedOnly: NavGates = { ...NO_GATES, advancedMode: true }
|
|
124
|
+
expect(ids(navSlotFilter(slots(), { gates: advancedOnly }))).not.toContain('build-pipeline')
|
|
125
|
+
|
|
126
|
+
const permissionOnly: NavGates = { ...NO_GATES, canWriteBoard: true, advancedMode: false }
|
|
127
|
+
expect(ids(navSlotFilter(slots(), { gates: permissionOnly }))).not.toContain('build-pipeline')
|
|
128
|
+
|
|
129
|
+
const both: NavGates = { ...NO_GATES, canWriteBoard: true, advancedMode: true }
|
|
130
|
+
expect(ids(navSlotFilter(slots(), { gates: both }))).toContain('build-pipeline')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('leaves at least one sidebar destination in every basic-mode section it keeps', () => {
|
|
134
|
+
// A section whose every item is advanced is dropped wholesale upstream (groupSidebar drops
|
|
135
|
+
// empties), which is intended — but a basic-mode sidebar with NO destinations at all would
|
|
136
|
+
// be a broken shell, so pin that the everyday surface is non-empty.
|
|
137
|
+
const gates: NavGates = { ...ALL_GATES, advancedMode: false }
|
|
138
|
+
const kept = (navSlotFilter(slots(), { gates }) as AppSlots).nav
|
|
139
|
+
const groups = groupSidebar(kept)
|
|
140
|
+
expect(groups.length).toBeGreaterThan(0)
|
|
141
|
+
for (const group of groups) expect(group.items.length).toBeGreaterThan(0)
|
|
142
|
+
})
|
|
87
143
|
})
|
|
88
144
|
|
|
89
145
|
describe('NAV_CONTRIBUTIONS catalog integrity', () => {
|
|
@@ -172,7 +228,8 @@ describe('nav grouping helpers', () => {
|
|
|
172
228
|
|
|
173
229
|
it('groupCommands preserves the pre-slice-1 workspace-group order', () => {
|
|
174
230
|
const workspace = groupCommands(NAV_CONTRIBUTIONS).find((g) => g.group === 'workspace')
|
|
175
|
-
// Same order the old CommandBar pushed them in (parity, not a reorder)
|
|
231
|
+
// Same order the old CommandBar pushed them in (parity, not a reorder), with genuinely
|
|
232
|
+
// new entries appended after it rather than interleaved.
|
|
176
233
|
expect(workspace?.items.map((ci) => ci.item.id)).toEqual([
|
|
177
234
|
'fragments',
|
|
178
235
|
'merge-thresholds',
|
|
@@ -182,6 +239,7 @@ describe('nav grouping helpers', () => {
|
|
|
182
239
|
'local-models',
|
|
183
240
|
'sandbox',
|
|
184
241
|
'keyboard-shortcuts',
|
|
242
|
+
'ui-mode',
|
|
185
243
|
])
|
|
186
244
|
})
|
|
187
245
|
|
|
@@ -60,6 +60,12 @@ export interface NavGates {
|
|
|
60
60
|
accountsEnabled: boolean
|
|
61
61
|
/** The caller is an admin of the active account. */
|
|
62
62
|
isAccountAdmin: boolean
|
|
63
|
+
/**
|
|
64
|
+
* The interface tier is `advanced` (see `stores/uiMode.ts`). Read by
|
|
65
|
+
* {@link navSlotFilter} for every {@link NavContribution.advanced} item, and
|
|
66
|
+
* available to a `gate` predicate that needs to combine it with something else.
|
|
67
|
+
*/
|
|
68
|
+
advancedMode: boolean
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
/** Command-palette placement + copy for a contribution that appears in the palette. */
|
|
@@ -98,6 +104,7 @@ export const NAV_ACTIONS = [
|
|
|
98
104
|
'operatorDashboard',
|
|
99
105
|
'reports',
|
|
100
106
|
'shortcuts',
|
|
107
|
+
'toggleUiMode',
|
|
101
108
|
] as const
|
|
102
109
|
|
|
103
110
|
export type NavActionId = (typeof NAV_ACTIONS)[number]
|
|
@@ -111,6 +118,13 @@ export interface NavContribution {
|
|
|
111
118
|
surfaces: readonly NavSurface[]
|
|
112
119
|
/** Reactive predicate over {@link NavGates}; absent = always visible. */
|
|
113
120
|
gate?: (g: NavGates) => boolean
|
|
121
|
+
/**
|
|
122
|
+
* A power-user destination: shown only in ADVANCED interface mode (basic mode is the
|
|
123
|
+
* everyday surface — see `stores/uiMode.ts`). Declarative rather than folded into
|
|
124
|
+
* {@link gate}, so an item keeps its RBAC/availability predicate unchanged and the two
|
|
125
|
+
* axes stay independently assertable. Absent = visible in both tiers.
|
|
126
|
+
*/
|
|
127
|
+
advanced?: boolean
|
|
114
128
|
/**
|
|
115
129
|
* First-party action id, resolved to a `run()` against the host `ui` store by
|
|
116
130
|
* `useNavContributions`. A consumer module that has its own stores instead
|
|
@@ -138,6 +152,14 @@ const S = (...s: NavSurface[]) => s as readonly NavSurface[]
|
|
|
138
152
|
* previously showed it unconditionally; the account modal only makes sense
|
|
139
153
|
* with accounts enabled).
|
|
140
154
|
* - one icon per destination across shells.
|
|
155
|
+
*
|
|
156
|
+
* `advanced: true` marks the power-user half, hidden in basic interface mode. The line
|
|
157
|
+
* drawn here is "would a team shipping their first task open this?": connecting a repo or
|
|
158
|
+
* an integration, picking models, and the workspace/account settings stay; AUTHORING the
|
|
159
|
+
* machinery those defaults come from (the pipeline builder, the fragment library, merge
|
|
160
|
+
* thresholds, service fragment defaults), the experimentation surfaces (sandbox, Kaizen),
|
|
161
|
+
* the infrastructure/environment plumbing, per-user local models, and the operator-tier
|
|
162
|
+
* dashboards do not.
|
|
141
163
|
*/
|
|
142
164
|
export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
143
165
|
{
|
|
@@ -145,6 +167,7 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
145
167
|
labelKey: 'nav.buildPipeline',
|
|
146
168
|
icon: 'i-lucide-workflow',
|
|
147
169
|
surfaces: S('sidebar', 'command'),
|
|
170
|
+
advanced: true,
|
|
148
171
|
gate: (g) => g.canWriteBoard,
|
|
149
172
|
action: 'buildPipeline',
|
|
150
173
|
testId: 'nav-build-pipeline',
|
|
@@ -203,6 +226,7 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
203
226
|
labelKey: 'nav.sandbox',
|
|
204
227
|
icon: 'i-lucide-flask-conical',
|
|
205
228
|
surfaces: S('sidebar', 'command'),
|
|
229
|
+
advanced: true,
|
|
206
230
|
gate: (g) => g.canManageIntegrations,
|
|
207
231
|
action: 'sandbox',
|
|
208
232
|
testId: 'nav-sandbox',
|
|
@@ -219,6 +243,7 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
219
243
|
labelKey: 'nav.kaizen',
|
|
220
244
|
icon: 'i-lucide-sparkles',
|
|
221
245
|
surfaces: S('sidebar'),
|
|
246
|
+
advanced: true,
|
|
222
247
|
action: 'kaizen',
|
|
223
248
|
testId: 'nav-kaizen',
|
|
224
249
|
sidebar: { group: 'integrations', order: 30 },
|
|
@@ -228,6 +253,7 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
228
253
|
labelKey: 'nav.infrastructure',
|
|
229
254
|
icon: 'i-lucide-server-cog',
|
|
230
255
|
surfaces: S('sidebar'),
|
|
256
|
+
advanced: true,
|
|
231
257
|
gate: (g) => g.infrastructureAvailable,
|
|
232
258
|
action: 'infrastructure',
|
|
233
259
|
testId: 'nav-infrastructure',
|
|
@@ -238,6 +264,7 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
238
264
|
labelKey: 'nav.environmentSetup',
|
|
239
265
|
icon: 'i-lucide-flask-conical',
|
|
240
266
|
surfaces: S('sidebar'),
|
|
267
|
+
advanced: true,
|
|
241
268
|
gate: (g) => g.infrastructureAvailable,
|
|
242
269
|
action: 'environmentSetup',
|
|
243
270
|
testId: 'nav-environment-setup',
|
|
@@ -248,6 +275,7 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
248
275
|
labelKey: 'nav.contextFragments',
|
|
249
276
|
icon: 'i-lucide-book-marked',
|
|
250
277
|
surfaces: S('sidebar', 'command'),
|
|
278
|
+
advanced: true,
|
|
251
279
|
gate: (g) => g.libraryAvailable && g.canManageSettings,
|
|
252
280
|
action: 'fragmentLibrary',
|
|
253
281
|
testId: 'nav-fragments',
|
|
@@ -264,6 +292,7 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
264
292
|
labelKey: 'layout.commandBar.cmd.mergeThresholds',
|
|
265
293
|
icon: 'i-lucide-git-merge',
|
|
266
294
|
surfaces: S('command'),
|
|
295
|
+
advanced: true,
|
|
267
296
|
gate: (g) => g.canManageSettings,
|
|
268
297
|
action: 'mergeThresholds',
|
|
269
298
|
command: {
|
|
@@ -309,6 +338,7 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
309
338
|
labelKey: 'layout.commandBar.cmd.serviceFragmentDefaults',
|
|
310
339
|
icon: 'i-lucide-book-open-check',
|
|
311
340
|
surfaces: S('command'),
|
|
341
|
+
advanced: true,
|
|
312
342
|
gate: (g) => g.canManageSettings,
|
|
313
343
|
action: 'serviceFragmentDefaults',
|
|
314
344
|
command: {
|
|
@@ -322,6 +352,7 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
322
352
|
labelKey: 'layout.commandBar.cmd.localModels',
|
|
323
353
|
icon: 'i-lucide-server',
|
|
324
354
|
surfaces: S('command'),
|
|
355
|
+
advanced: true,
|
|
325
356
|
action: 'localModels',
|
|
326
357
|
command: {
|
|
327
358
|
group: 'workspace',
|
|
@@ -350,6 +381,7 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
350
381
|
labelKey: 'nav.operatorDashboard',
|
|
351
382
|
icon: 'i-lucide-gauge',
|
|
352
383
|
surfaces: S('sidebar'),
|
|
384
|
+
advanced: true,
|
|
353
385
|
gate: (g) => g.accountsEnabled && g.isAccountAdmin,
|
|
354
386
|
action: 'operatorDashboard',
|
|
355
387
|
testId: 'nav-operator-dashboard',
|
|
@@ -360,6 +392,7 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
360
392
|
labelKey: 'nav.reports',
|
|
361
393
|
icon: 'i-lucide-chart-column',
|
|
362
394
|
surfaces: S('sidebar'),
|
|
395
|
+
advanced: true,
|
|
363
396
|
gate: (g) => g.accountsEnabled && g.isAccountAdmin,
|
|
364
397
|
action: 'reports',
|
|
365
398
|
testId: 'nav-reports',
|
|
@@ -377,6 +410,23 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
377
410
|
keywordsKey: 'layout.commandBar.keywords.shortcuts',
|
|
378
411
|
},
|
|
379
412
|
},
|
|
413
|
+
{
|
|
414
|
+
// Deliberately NOT `advanced`: this is the way BACK. Basic mode is the shipped default, so
|
|
415
|
+
// for most users the sidebar switcher is their first sight of the tier — and in the basic
|
|
416
|
+
// rail it renders icon-only, which is a thin thread to hang the entire advanced half of the
|
|
417
|
+
// product on. The palette is reachable in both tiers, searchable by name, and is already
|
|
418
|
+
// "the primary way to reach every action", so the tier belongs in it.
|
|
419
|
+
id: 'ui-mode',
|
|
420
|
+
labelKey: 'layout.commandBar.cmd.toggleUiMode',
|
|
421
|
+
icon: 'i-lucide-toggle-right',
|
|
422
|
+
surfaces: S('command'),
|
|
423
|
+
action: 'toggleUiMode',
|
|
424
|
+
command: {
|
|
425
|
+
group: 'workspace',
|
|
426
|
+
order: 90,
|
|
427
|
+
keywordsKey: 'layout.commandBar.keywords.toggleUiMode',
|
|
428
|
+
},
|
|
429
|
+
},
|
|
380
430
|
]
|
|
381
431
|
|
|
382
432
|
/**
|
|
@@ -390,10 +440,14 @@ export const navigationModule = defineModule({
|
|
|
390
440
|
})
|
|
391
441
|
|
|
392
442
|
/**
|
|
393
|
-
* Reactive RBAC/availability filter over the merged `nav` slot. Reads
|
|
443
|
+
* Reactive RBAC/availability/interface-tier filter over the merged `nav` slot. Reads
|
|
394
444
|
* `deps.gates.*` (the reactive gate service) per item, so evaluated inside
|
|
395
|
-
* `useReactiveSlots` it re-runs when a permission
|
|
396
|
-
* `installModularApp` as the global `slotFilter`.
|
|
445
|
+
* `useReactiveSlots` it re-runs when a permission, connection, or the interface
|
|
446
|
+
* mode flips. Passed to `installModularApp` as the global `slotFilter`.
|
|
447
|
+
*
|
|
448
|
+
* The two axes are independent and BOTH must pass: an `advanced` item is dropped in
|
|
449
|
+
* basic mode, and every item still answers to its own `gate`. Order doesn't matter
|
|
450
|
+
* (it's a conjunction) but the tier is checked first, since it's the cheaper read.
|
|
397
451
|
*
|
|
398
452
|
* Typed against `AppSlots` (not the generic `SlotFilter`) so it matches the
|
|
399
453
|
* filter shape the runtime infers for this registry. `deps` is widened to an
|
|
@@ -406,7 +460,11 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
|
|
|
406
460
|
...slots,
|
|
407
461
|
// No gates service wired (tests / bare install) ⇒ show everything, matching
|
|
408
462
|
// the dev-open "absent access allows all" backend parity.
|
|
409
|
-
nav: gates
|
|
463
|
+
nav: gates
|
|
464
|
+
? nav.filter(
|
|
465
|
+
(i) => (i.advanced ? gates.advancedMode : true) && (i.gate ? i.gate(gates) : true),
|
|
466
|
+
)
|
|
467
|
+
: nav,
|
|
410
468
|
}
|
|
411
469
|
}
|
|
412
470
|
|
package/app/modular/nav-gates.ts
CHANGED
|
@@ -13,7 +13,9 @@ import type { NavGates } from '~/modular/nav-contributions'
|
|
|
13
13
|
* permission or connection flip re-gates every shell with no `recalculateSlots()`.
|
|
14
14
|
*
|
|
15
15
|
* This mirrors the exact gating the pre-slice-1 `SideBar` computeds encoded (see
|
|
16
|
-
* `useWorkspaceAccess` for the dev-open "absent access ⇒ allow all" parity)
|
|
16
|
+
* `useWorkspaceAccess` for the dev-open "absent access ⇒ allow all" parity), plus
|
|
17
|
+
* the interface tier (`advancedMode`), which rides the same service so a mode flip
|
|
18
|
+
* re-gates all three shells through the same reactive path as a permission flip.
|
|
17
19
|
*/
|
|
18
20
|
export function createNavGates(): NavGates {
|
|
19
21
|
const access = useWorkspaceAccess()
|
|
@@ -22,6 +24,7 @@ export function createNavGates(): NavGates {
|
|
|
22
24
|
const accounts = useAccountsStore()
|
|
23
25
|
const auth = useAuthStore()
|
|
24
26
|
const providerConnections = useProviderConnectionsStore()
|
|
27
|
+
const uiMode = useUiModeStore()
|
|
25
28
|
|
|
26
29
|
const infrastructureAvailable = computed(
|
|
27
30
|
() =>
|
|
@@ -59,5 +62,8 @@ export function createNavGates(): NavGates {
|
|
|
59
62
|
get isAccountAdmin() {
|
|
60
63
|
return isAccountAdmin.value
|
|
61
64
|
},
|
|
65
|
+
get advancedMode() {
|
|
66
|
+
return uiMode.isAdvanced
|
|
67
|
+
},
|
|
62
68
|
}
|
|
63
69
|
}
|
package/app/stores/bugHunt.ts
CHANGED
|
@@ -1,18 +1,10 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { computed, ref } from 'vue'
|
|
3
3
|
import type { BugHuntResult, RunBugHuntInput, TaskSourceKind, TrackerBoard } from '~/types/domain'
|
|
4
|
-
import {
|
|
4
|
+
import { apiErrorReason } from '~/composables/api/errors'
|
|
5
5
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
6
6
|
import { usePersonalSubscriptionsStore } from '~/stores/personalSubscriptions'
|
|
7
7
|
|
|
8
|
-
/** The backend's `error.details.reason` code, when it sent one (see `useApi`'s error envelope). */
|
|
9
|
-
function errorReasonOf(error: unknown): string | null {
|
|
10
|
-
const details = apiErrorEnvelope(error)?.details
|
|
11
|
-
if (!details || typeof details !== 'object') return null
|
|
12
|
-
const reason = (details as Record<string, unknown>).reason
|
|
13
|
-
return typeof reason === 'string' ? reason : null
|
|
14
|
-
}
|
|
15
|
-
|
|
16
8
|
/**
|
|
17
9
|
* Bug-hunt state: the boards of the tracker being browsed, the last hunt's ranked candidates,
|
|
18
10
|
* and the actions behind the three steps (list boards → run the hunt → adopt one candidate).
|
|
@@ -68,7 +60,7 @@ export const useBugHuntStore = defineStore('bugHunt', () => {
|
|
|
68
60
|
if (boardsSource.value !== source) return
|
|
69
61
|
boards.value = []
|
|
70
62
|
boardsError.value = e instanceof Error ? e.message : String(e)
|
|
71
|
-
boardsErrorReason.value =
|
|
63
|
+
boardsErrorReason.value = apiErrorReason(e)
|
|
72
64
|
} finally {
|
|
73
65
|
boardsLoading.value = false
|
|
74
66
|
}
|