@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.
Files changed (43) hide show
  1. package/README.md +45 -2
  2. package/app/components/auth/UserMenu.vue +9 -2
  3. package/app/components/board/AddTaskModal.vue +28 -9
  4. package/app/components/layout/BoardSwitcher.vue +21 -4
  5. package/app/components/layout/CommandBar.vue +3 -1
  6. package/app/components/layout/LanguageSwitcher.vue +9 -2
  7. package/app/components/layout/SideBar.vue +68 -15
  8. package/app/components/layout/UiModeSwitcher.vue +90 -0
  9. package/app/components/panels/inspector/TaskRunSettings.vue +35 -8
  10. package/app/components/settings/ConnectionWarnings.vue +38 -0
  11. package/app/components/settings/KubernetesEnvironmentForm.vue +5 -1
  12. package/app/components/settings/ProviderConnectionTab.vue +5 -1
  13. package/app/components/settings/ProviderManifestEditor.vue +5 -1
  14. package/app/components/tasks/BugHuntModal.vue +3 -1
  15. package/app/components/tasks/ContextIssuePicker.vue +22 -5
  16. package/app/components/tasks/TaskImportModal.vue +1 -1
  17. package/app/composables/api/errors.ts +16 -0
  18. package/app/composables/api/tasks.ts +5 -2
  19. package/app/composables/useNavContributions.ts +3 -0
  20. package/app/docs/consumer-extensions.md +6 -1
  21. package/app/modular/nav-contributions.spec.ts +59 -1
  22. package/app/modular/nav-contributions.ts +62 -4
  23. package/app/modular/nav-gates.ts +7 -1
  24. package/app/modular/registry.spec.ts +1 -0
  25. package/app/stores/bugHunt.ts +2 -10
  26. package/app/stores/tasks.ts +7 -4
  27. package/app/stores/uiMode.spec.ts +151 -0
  28. package/app/stores/uiMode.ts +88 -0
  29. package/app/utils/connectionWarnings.ts +17 -0
  30. package/app/utils/uiMode.spec.ts +67 -0
  31. package/app/utils/uiMode.ts +71 -0
  32. package/i18n/locales/de.json +23 -5
  33. package/i18n/locales/en.json +26 -5
  34. package/i18n/locales/es.json +23 -5
  35. package/i18n/locales/fr.json +23 -5
  36. package/i18n/locales/he.json +23 -5
  37. package/i18n/locales/it.json +23 -5
  38. package/i18n/locales/ja.json +23 -5
  39. package/i18n/locales/pl.json +23 -5
  40. package/i18n/locales/tr.json +23 -5
  41. package/i18n/locales/uk.json +23 -5
  42. package/nuxt.config.ts +5 -0
  43. 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: { ok: boolean; message?: string } | null
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<{ ok: boolean; message?: string } | null>(null)
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: { ok: boolean; message?: string } | null
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 === 'boards_unsupported',
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). Scopes a GitHub search to that service's linked repo, so hits stay
25
- * in-repo and a pasted URL / bare issue number resolves to the exact issue.
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?: string
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
- searchError.value = e instanceof Error ? e.message : String(e)
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
- {{ t('tasks.picker.searchFailed', { error: searchError }) }}
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?: string,
81
+ blockId: string,
79
82
  ) =>
80
83
  send(searchTasksContract, {
81
84
  pathPrefix: ws(workspaceId),
82
85
  pathParams: { source },
83
- body: { query, ...(blockId ? { blockId } : {}) },
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? }` | the three shells via `useNavContributions` |
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 or connection flips. Passed to
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 ? nav.filter((i) => (i.gate ? i.gate(gates) : true)) : nav,
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
 
@@ -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
  }
@@ -12,6 +12,7 @@ const NO_GATES: NavGates = {
12
12
  infrastructureAvailable: false,
13
13
  accountsEnabled: false,
14
14
  isAccountAdmin: false,
15
+ advancedMode: false,
15
16
  }
16
17
 
17
18
  describe('app modular registry', () => {
@@ -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 { apiErrorEnvelope } from '~/composables/api/errors'
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 = errorReasonOf(e)
63
+ boardsErrorReason.value = apiErrorReason(e)
72
64
  } finally {
73
65
  boardsLoading.value = false
74
66
  }