@cat-factory/app 0.167.1 → 0.169.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 (42) 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/observability/StepMetricsBar.vue +36 -20
  10. package/app/components/panels/ObservabilityPanel.vue +60 -23
  11. package/app/components/panels/inspector/TaskRunSettings.vue +35 -8
  12. package/app/components/tasks/BugHuntModal.vue +3 -1
  13. package/app/components/tasks/ContextIssuePicker.vue +22 -5
  14. package/app/components/tasks/TaskImportModal.vue +1 -1
  15. package/app/composables/api/errors.ts +16 -0
  16. package/app/composables/api/tasks.ts +5 -2
  17. package/app/composables/useNavContributions.ts +3 -0
  18. package/app/docs/consumer-extensions.md +6 -1
  19. package/app/modular/nav-contributions.spec.ts +59 -1
  20. package/app/modular/nav-contributions.ts +62 -4
  21. package/app/modular/nav-gates.ts +7 -1
  22. package/app/modular/registry.spec.ts +1 -0
  23. package/app/stores/bugHunt.ts +2 -10
  24. package/app/stores/tasks.ts +7 -4
  25. package/app/stores/uiMode.spec.ts +151 -0
  26. package/app/stores/uiMode.ts +88 -0
  27. package/app/utils/observability.spec.ts +18 -18
  28. package/app/utils/observability.ts +19 -22
  29. package/app/utils/uiMode.spec.ts +67 -0
  30. package/app/utils/uiMode.ts +71 -0
  31. package/i18n/locales/de.json +35 -10
  32. package/i18n/locales/en.json +38 -10
  33. package/i18n/locales/es.json +35 -10
  34. package/i18n/locales/fr.json +35 -10
  35. package/i18n/locales/he.json +35 -10
  36. package/i18n/locales/it.json +35 -10
  37. package/i18n/locales/ja.json +35 -10
  38. package/i18n/locales/pl.json +35 -10
  39. package/i18n/locales/tr.json +35 -10
  40. package/i18n/locales/uk.json +35 -10
  41. package/nuxt.config.ts +5 -0
  42. package/package.json +2 -2
@@ -8,7 +8,7 @@ import type {
8
8
  WebSearchProvider,
9
9
  } from '~/types/execution'
10
10
  import { agentKindMeta } from '~/utils/catalog'
11
- import { formatMs, formatTokens, freshPromptTokens, pct } from '~/utils/observability'
11
+ import { formatMs, formatTokens, pct, totalInputTokens } from '~/utils/observability'
12
12
 
13
13
  // Drill-down overlay for a run's LLM activity. Opened via
14
14
  // `ui.openObservability(instanceId)` from a step surface; loads the full per-call
@@ -126,15 +126,18 @@ const totals = computed(() => {
126
126
  const upstreamMs = sum(c, (x) => x.upstreamMs)
127
127
  const overheadMs = sum(c, (x) => x.overheadMs)
128
128
  const total = upstreamMs + overheadMs
129
+ // The three input classes are orthogonal at the source, so they are simply summed —
130
+ // `promptTokens` IS the fresh figure and needs no heuristic to recover it; the headline is
131
+ // their TOTAL (see `totalInputTokens` — the like-for-like Claude Code context gauge).
129
132
  const promptTokens = sum(c, (x) => x.promptTokens)
130
- const cachedPromptTokens = sum(c, (x) => x.cachedPromptTokens)
133
+ const cacheReadTokens = sum(c, (x) => x.cacheReadTokens)
134
+ const cacheWriteTokens = sum(c, (x) => x.cacheWriteTokens)
131
135
  return {
132
136
  calls: c.length,
133
137
  promptTokens,
134
- cachedPromptTokens,
135
- // Fresh (uncached) input — the raw prompt-token sum is dominated by per-turn cache reads
136
- // on a long agentic run, so surface fresh vs cached rather than the misleading raw total.
137
- freshPromptTokens: freshPromptTokens(promptTokens, cachedPromptTokens),
138
+ cacheReadTokens,
139
+ cacheWriteTokens,
140
+ inputTokens: totalInputTokens({ promptTokens, cacheReadTokens, cacheWriteTokens }),
138
141
  completionTokens: sum(c, (x) => x.completionTokens),
139
142
  upstreamMs,
140
143
  overheadMs,
@@ -290,18 +293,47 @@ function exportJson() {
290
293
  {{ t('observability.summary.tokensInOut') }}
291
294
  </dt>
292
295
  <dd class="mt-0.5 tabular-nums text-slate-200">
293
- {{ formatTokens(totals.freshPromptTokens) }} /
294
- {{ formatTokens(totals.completionTokens) }}
296
+ <span :title="t('observability.summary.inputTokensHint')">
297
+ {{ formatTokens(totals.inputTokens) }} /
298
+ {{ formatTokens(totals.completionTokens) }}
299
+ </span>
295
300
  <span
296
- v-if="totals.cachedPromptTokens > 0"
297
- class="ms-1 text-[11px] text-emerald-400/80"
301
+ v-if="totals.cacheReadTokens > 0 || totals.cacheWriteTokens > 0"
302
+ class="mt-0.5 block text-[11px]"
298
303
  >
299
- ·
300
- {{
301
- t('observability.summary.cached', {
302
- tokens: formatTokens(totals.cachedPromptTokens),
303
- })
304
- }}
304
+ <span class="text-slate-400" :title="t('observability.summary.freshHint')">
305
+ {{
306
+ t('observability.summary.fresh', {
307
+ tokens: formatTokens(totals.promptTokens),
308
+ })
309
+ }}
310
+ </span>
311
+ <template v-if="totals.cacheReadTokens > 0">
312
+ <span class="text-slate-600"> · </span>
313
+ <span
314
+ class="text-emerald-400/80"
315
+ :title="t('observability.summary.cacheReadHint')"
316
+ >
317
+ {{
318
+ t('observability.summary.cacheRead', {
319
+ tokens: formatTokens(totals.cacheReadTokens),
320
+ })
321
+ }}
322
+ </span>
323
+ </template>
324
+ <template v-if="totals.cacheWriteTokens > 0">
325
+ <span class="text-slate-600"> · </span>
326
+ <span
327
+ class="text-amber-400/80"
328
+ :title="t('observability.summary.cacheWriteHint')"
329
+ >
330
+ {{
331
+ t('observability.summary.cacheWrite', {
332
+ tokens: formatTokens(totals.cacheWriteTokens),
333
+ })
334
+ }}
335
+ </span>
336
+ </template>
305
337
  </span>
306
338
  </dd>
307
339
  </div>
@@ -418,12 +450,14 @@ function exportJson() {
418
450
  <span
419
451
  :title="
420
452
  t('observability.call.tokensTitle', {
421
- prompt: c.promptTokens,
453
+ input: totalInputTokens(c),
454
+ fresh: c.promptTokens,
422
455
  completion: c.completionTokens,
423
456
  })
424
457
  "
425
458
  >
426
- {{ formatTokens(c.promptTokens) }}↑ {{ formatTokens(c.completionTokens) }}↓
459
+ {{ formatTokens(totalInputTokens(c)) }}↑
460
+ {{ formatTokens(c.completionTokens) }}↓
427
461
  </span>
428
462
  <span
429
463
  v-if="headroomOf(c) !== null"
@@ -467,11 +501,14 @@ function exportJson() {
467
501
  <span v-if="c.requestMaxTokens != null">{{
468
502
  t('observability.call.maxTokens', { value: c.requestMaxTokens })
469
503
  }}</span>
470
- <span v-if="c.cachedPromptTokens > 0" class="text-emerald-400">{{
471
- t('observability.call.promptCached', {
472
- cached: c.cachedPromptTokens,
473
- prompt: c.promptTokens,
474
- })
504
+ <span v-if="c.cacheReadTokens > 0 || c.cacheWriteTokens > 0">{{
505
+ t('observability.call.fresh', { tokens: c.promptTokens })
506
+ }}</span>
507
+ <span v-if="c.cacheReadTokens > 0" class="text-emerald-400">{{
508
+ t('observability.call.cacheRead', { tokens: c.cacheReadTokens })
509
+ }}</span>
510
+ <span v-if="c.cacheWriteTokens > 0" class="text-amber-400">{{
511
+ t('observability.call.cacheWrite', { tokens: c.cacheWriteTokens })
475
512
  }}</span>
476
513
  <span>{{
477
514
  t('observability.call.total', { duration: formatMs(c.totalMs) })
@@ -4,6 +4,7 @@ import { connectionNeighborIds } from '@cat-factory/contracts'
4
4
  import type { Block } from '~/types/domain'
5
5
  import type { WritebackOverride } from '~/types/tracker'
6
6
  import { pipelineAllowedForManualStart } from '~/utils/pipeline'
7
+ import { showOverrideField } from '~/utils/uiMode'
7
8
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
8
9
  import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
9
10
  import TaskAprioriBranches from '~/components/panels/inspector/TaskAprioriBranches.vue'
@@ -18,6 +19,23 @@ const pipelines = usePipelinesStore()
18
19
  const accounts = useAccountsStore()
19
20
  const tracker = useTrackerStore()
20
21
  const ui = useUiStore()
22
+ // Interface tier. Basic mode hides only what OVERRIDES something already decided elsewhere:
23
+ // a workspace-level default (merge policy, model preset, tracker writeback) or an
24
+ // engine-inferred value (the technical/business label). That bound is what makes hiding safe
25
+ // — what's left is exactly the value the hidden field would have displayed, so a basic-mode
26
+ // task behaves identically. Everything that carries an input NOTHING else supplies stays in
27
+ // both tiers, however advanced it feels: the pipeline, the involved services, the apriori
28
+ // branches, the responsible person, auto-start. The same split applies at creation time in
29
+ // `AddTaskModal`.
30
+ //
31
+ // Unlike creation — which always starts from the defaults — an EXISTING block can already
32
+ // carry an override (a teammate on the advanced tier, the API, or this user before they
33
+ // switched down). Hiding it then would break the very bound above: the task would run on
34
+ // settings a basic-mode user can neither see nor clear, and no other inspector panel shows
35
+ // them. So each group below asks `showOverrideField`, which keeps the field whenever it is
36
+ // actually set. Basic mode stays clean for the common (unset) case without ever concealing a
37
+ // deviation.
38
+ const uiMode = useUiModeStore()
21
39
  const { ready, unavailableInPreset } = useAiReadiness()
22
40
  const { t, n } = useI18n()
23
41
 
@@ -290,8 +308,8 @@ const technicalLabel = computed(() => {
290
308
  </p>
291
309
  </div>
292
310
 
293
- <!-- merge policy preset -->
294
- <div>
311
+ <!-- merge policy preset (advanced, or basic with an override already set) -->
312
+ <div v-if="showOverrideField(uiMode.isAdvanced, block.riskPolicyId)">
295
313
  <div class="mb-1 flex items-center justify-between">
296
314
  <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
297
315
  {{ t('inspector.runSettings.mergePolicy') }}
@@ -348,8 +366,8 @@ const technicalLabel = computed(() => {
348
366
  </p>
349
367
  </div>
350
368
 
351
- <!-- model preset -->
352
- <div>
369
+ <!-- model preset (advanced, or basic with an override already set) -->
370
+ <div v-if="showOverrideField(uiMode.isAdvanced, block.modelPresetId)">
353
371
  <div class="mb-1 flex items-center justify-between">
354
372
  <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
355
373
  {{ t('inspector.runSettings.modelPreset') }}
@@ -422,8 +440,8 @@ const technicalLabel = computed(() => {
422
440
  </p>
423
441
  </div>
424
442
 
425
- <!-- technical label (tri-state) -->
426
- <div>
443
+ <!-- technical label (tri-state) — unset lets the engine infer it -->
444
+ <div v-if="showOverrideField(uiMode.isAdvanced, block.technical)">
427
445
  <div class="mb-1 flex items-center justify-between">
428
446
  <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
429
447
  {{ t('inspector.runSettings.taskKind') }}
@@ -498,8 +516,17 @@ const technicalLabel = computed(() => {
498
516
  <!-- reference repositories: read-only repos the doc-writer reads while drafting (doc tasks) -->
499
517
  <DocReferenceRepos v-if="block.taskType === 'document'" :block="block" />
500
518
 
501
- <!-- issue-tracker writeback overrides -->
502
- <div>
519
+ <!-- issue-tracker writeback overrides (any one set reveals the whole group) -->
520
+ <div
521
+ v-if="
522
+ showOverrideField(
523
+ uiMode.isAdvanced,
524
+ block.trackerCommentOnPrOpen,
525
+ block.trackerResolveOnMerge,
526
+ block.trackerQuestionsOnPark,
527
+ )
528
+ "
529
+ >
503
530
  <div class="mb-1 flex items-center justify-between">
504
531
  <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
505
532
  {{ t('inspector.runSettings.issueWriteback') }}
@@ -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