@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
@@ -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
  }
@@ -146,14 +146,17 @@ export const useTasksStore = defineStore('tasks', () => {
146
146
 
147
147
  /**
148
148
  * Search a connected tracker's issues by free text (title/content). `blockId`
149
- * (a service frame or a task/module under one) scopes a GitHub search to that
150
- * service's linked repo so hits stay in-repo and a pasted URL / bare issue
151
- * number resolves to the exact issue. Omitted an unscoped workspace search.
149
+ * (a service frame or a task/module under one) is REQUIRED: it scopes a GitHub
150
+ * search to that service's linked repo, so hits stay in-repo and a bare issue
151
+ * number resolves to the exact issue. There is no unscoped mode — an unscoped
152
+ * GitHub search reaches every repository the backend's credential can see. An
153
+ * issue in another repo is linked by pasting its URL (the picker's by-reference
154
+ * row imports it directly instead of searching).
152
155
  */
153
156
  async function search(
154
157
  source: TaskSourceKind,
155
158
  query: string,
156
- blockId?: string,
159
+ blockId: string,
157
160
  ): Promise<TaskSearchResult[]> {
158
161
  const { results } = await api.searchTaskSource(workspace.requireId(), source, query, blockId)
159
162
  return results
@@ -0,0 +1,151 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { createPinia, setActivePinia } from 'pinia'
3
+ import { nextTick } from 'vue'
4
+ import { useUiModeStore } from '~/stores/uiMode'
5
+ import type { UiMode } from '~/utils/uiMode'
6
+
7
+ /**
8
+ * Point `runtimeConfig.public.uiMode` at a value and hand back a store built against it.
9
+ * The env value is read ONCE at store setup (it is build-time-baked in a `ssr: false` SPA),
10
+ * so every case needs a fresh Pinia — which is also what proves the read isn't reactive.
11
+ */
12
+ function storeWithEnv(uiMode: string | undefined) {
13
+ vi.stubGlobal('useRuntimeConfig', () => ({ public: { apiBase: '', uiMode } }))
14
+ setActivePinia(createPinia())
15
+ return useUiModeStore()
16
+ }
17
+
18
+ describe('useUiModeStore mode resolution', () => {
19
+ beforeEach(() => {
20
+ // The default runtime-config stub carries no `uiMode` at all — the shape a deployment
21
+ // that never set NUXT_PUBLIC_UI_MODE ends up with once Nuxt applies the '' default.
22
+ vi.stubGlobal('useRuntimeConfig', () => ({ public: { apiBase: '', uiMode: '' } }))
23
+ })
24
+
25
+ it('boots in basic mode with no env pin and nothing stored', () => {
26
+ const ui = useUiModeStore()
27
+ expect(ui.mode).toBe('basic')
28
+ expect(ui.isAdvanced).toBe(false)
29
+ expect(ui.envPinned).toBe(false)
30
+ })
31
+
32
+ it('honours the browser-stored choice when no env pin is present', () => {
33
+ const ui = useUiModeStore()
34
+ ui.setMode('advanced')
35
+ expect(ui.mode).toBe('advanced')
36
+ expect(ui.isAdvanced).toBe(true)
37
+ // Persisted, so a reload restores it (the persist plugin picks `storedMode`).
38
+ expect(ui.storedMode).toBe('advanced')
39
+ })
40
+
41
+ it('lets the env pin override a conflicting stored choice, in both directions', () => {
42
+ const pinnedBasic = storeWithEnv('advanced')
43
+ pinnedBasic.storedMode = 'basic'
44
+ expect(pinnedBasic.mode).toBe('advanced')
45
+
46
+ const pinnedAdvanced = storeWithEnv('basic')
47
+ pinnedAdvanced.storedMode = 'advanced'
48
+ expect(pinnedAdvanced.mode).toBe('basic')
49
+ })
50
+
51
+ it('refuses to record a preference under an env pin', () => {
52
+ const ui = storeWithEnv('basic')
53
+ expect(ui.envPinned).toBe(true)
54
+ ui.setMode('advanced')
55
+ ui.toggleMode()
56
+ // Nothing written: the resolver would ignore it, so persisting it would be a lie.
57
+ expect(ui.storedMode).toBeNull()
58
+ expect(ui.mode).toBe('basic')
59
+ })
60
+
61
+ it('ignores an unrecognised env value rather than failing the boot', () => {
62
+ const ui = storeWithEnv('expert')
63
+ expect(ui.envPinned).toBe(false)
64
+ expect(ui.mode).toBe('basic')
65
+ ui.setMode('advanced')
66
+ expect(ui.mode).toBe('advanced')
67
+ })
68
+
69
+ it('toggleMode flips between the two tiers', () => {
70
+ const ui = useUiModeStore()
71
+ ui.toggleMode()
72
+ expect(ui.mode).toBe('advanced')
73
+ ui.toggleMode()
74
+ expect(ui.mode).toBe('basic')
75
+ })
76
+ })
77
+
78
+ describe('useUiModeStore navbar collapse', () => {
79
+ beforeEach(() => {
80
+ vi.stubGlobal('useRuntimeConfig', () => ({ public: { apiBase: '', uiMode: '' } }))
81
+ })
82
+
83
+ it('starts collapsed in basic mode and expanded in advanced mode', () => {
84
+ expect(useUiModeStore().navCollapsed).toBe(true)
85
+
86
+ const advanced = storeWithEnv('advanced')
87
+ expect(advanced.navCollapsed).toBe(false)
88
+ })
89
+
90
+ it('remembers the rail choice in EITHER tier, across a reload', () => {
91
+ // The persisted shape is per-tier, so an expand in basic is remembered just as an advanced
92
+ // one is — the default is what differs between the tiers, not whether a choice sticks.
93
+ const ui = useUiModeStore()
94
+ ui.toggleNav()
95
+ expect(ui.navCollapsed).toBe(false)
96
+ expect(ui.railCollapsed.basic).toBe(false)
97
+
98
+ const advanced = storeWithEnv('advanced')
99
+ advanced.toggleNav()
100
+ expect(advanced.navCollapsed).toBe(true)
101
+ expect(advanced.railCollapsed.advanced).toBe(true)
102
+ })
103
+
104
+ it('keeps each tier’s rail state independent across a switch', async () => {
105
+ const ui = useUiModeStore()
106
+ // Expand in basic, then collapse in advanced: neither choice may leak into the other tier.
107
+ ui.toggleNav()
108
+ expect(ui.navCollapsed).toBe(false)
109
+
110
+ ui.setMode('advanced')
111
+ await nextTick()
112
+ expect(ui.navCollapsed).toBe(false) // advanced's own default
113
+ ui.toggleNav()
114
+ expect(ui.navCollapsed).toBe(true)
115
+
116
+ ui.setMode('basic')
117
+ await nextTick()
118
+ expect(ui.navCollapsed).toBe(false) // the basic expand survived the round trip
119
+
120
+ ui.setMode('advanced')
121
+ await nextTick()
122
+ expect(ui.navCollapsed).toBe(true)
123
+ })
124
+
125
+ it('falls back to the tier default when the restored preference is missing a key', () => {
126
+ // A persisted blob from an older build (or a hand-edited cookie) can omit a tier; a rail
127
+ // stuck on `undefined` would render expanded, which is wrong for basic.
128
+ const ui = useUiModeStore()
129
+ ui.railCollapsed = {} as Record<UiMode, boolean>
130
+ expect(ui.navCollapsed).toBe(true)
131
+ ui.setMode('advanced')
132
+ expect(ui.navCollapsed).toBe(false)
133
+ })
134
+
135
+ it('setNavCollapsed is idempotent', () => {
136
+ const ui = storeWithEnv('advanced')
137
+ ui.setNavCollapsed(true)
138
+ ui.setNavCollapsed(true)
139
+ expect(ui.navCollapsed).toBe(true)
140
+ ui.setNavCollapsed(false)
141
+ expect(ui.navCollapsed).toBe(false)
142
+ })
143
+ })
144
+
145
+ describe('useUiModeStore typing', () => {
146
+ it('exposes the mode as the closed union', () => {
147
+ const ui = useUiModeStore()
148
+ const mode: UiMode = ui.mode
149
+ expect(['basic', 'advanced']).toContain(mode)
150
+ })
151
+ })
@@ -0,0 +1,88 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import { DEFAULT_RAIL_COLLAPSED, parseUiMode, resolveUiMode, type UiMode } from '~/utils/uiMode'
4
+
5
+ /**
6
+ * The interface tier (`basic` / `advanced`) and the side-navbar collapse state.
7
+ *
8
+ * Mode resolution is `env → browser-stored → basic` (see `utils/uiMode.ts`). The env value
9
+ * is `runtimeConfig.public.uiMode`, i.e. `NUXT_PUBLIC_UI_MODE`: the SPA is `ssr: false`, so
10
+ * — exactly like `apiBase` — it is baked in at build time and cannot change while the app
11
+ * is loaded. It is therefore read ONCE here rather than tracked reactively. Only the user's
12
+ * own choice is persisted, so a deployment that later drops its env pin falls back to
13
+ * whatever the user last picked instead of to the default.
14
+ *
15
+ * The collapse state is a PER-TIER preference (`railCollapsed`), not a single boolean. The two
16
+ * tiers want different resting states — basic opens railed, advanced expanded — so one shared
17
+ * flag can only serve one of them: it would either override the other tier's default or throw
18
+ * away the user's choice on every switch. Keying the preference by tier gives each its own
19
+ * default (`DEFAULT_RAIL_COLLAPSED`) AND its own memory, so switching tiers restores what that
20
+ * tier last looked like and neither has to forget. That is also why there is no watcher here:
21
+ * the collapse follows `mode` because it is indexed by it, not because something resets on
22
+ * change.
23
+ */
24
+ export const useUiModeStore = defineStore(
25
+ 'uiMode',
26
+ () => {
27
+ const envMode = parseUiMode(useRuntimeConfig().public.uiMode)
28
+
29
+ /** The user's explicit pick, persisted. `null` until they choose one. */
30
+ const storedMode = ref<UiMode | null>(null)
31
+ /** The rail state each tier was last left in, persisted. Seeded from the per-tier defaults. */
32
+ const railCollapsed = ref<Record<UiMode, boolean>>({ ...DEFAULT_RAIL_COLLAPSED })
33
+
34
+ const mode = computed<UiMode>(() => resolveUiMode(envMode, storedMode.value))
35
+ const isAdvanced = computed(() => mode.value === 'advanced')
36
+ /** Pinned by the deployment: the switcher is read-only, since a write would be ignored. */
37
+ const envPinned = computed(() => envMode !== null)
38
+
39
+ // `?? the default` because the restored value is untrusted input, exactly like the env
40
+ // string: a persisted blob written by an older build (or hand-edited) can be missing this
41
+ // tier's key, and a rail stuck on `undefined` would render as expanded in basic mode.
42
+ const navCollapsed = computed(
43
+ () => railCollapsed.value[mode.value] ?? DEFAULT_RAIL_COLLAPSED[mode.value],
44
+ )
45
+
46
+ /**
47
+ * Record the user's pick. A no-op under an env pin — the resolver would ignore it.
48
+ *
49
+ * This guard is hygiene, not the invariant. `storedMode` is returned from the store because
50
+ * a setup store can only persist what it returns (as in `auth`, `accounts`, `workspace`), so
51
+ * a caller CAN write it directly and skip this check. That is harmless by construction:
52
+ * `mode` runs `resolveUiMode(env, stored)`, which consults the env pin FIRST, so a direct
53
+ * write can leave a stale persisted value but can never change the tier under a pin. The
54
+ * "env pin wins, in both directions" case above is what actually pins that.
55
+ */
56
+ function setMode(next: UiMode) {
57
+ if (envPinned.value) return
58
+ storedMode.value = next
59
+ }
60
+
61
+ function toggleMode() {
62
+ setMode(isAdvanced.value ? 'basic' : 'advanced')
63
+ }
64
+
65
+ /** Remember the rail state for the CURRENT tier; the other tier keeps its own. */
66
+ function setNavCollapsed(collapsed: boolean) {
67
+ railCollapsed.value = { ...railCollapsed.value, [mode.value]: collapsed }
68
+ }
69
+
70
+ function toggleNav() {
71
+ setNavCollapsed(!navCollapsed.value)
72
+ }
73
+
74
+ return {
75
+ mode,
76
+ isAdvanced,
77
+ envPinned,
78
+ storedMode,
79
+ railCollapsed,
80
+ navCollapsed,
81
+ setMode,
82
+ toggleMode,
83
+ setNavCollapsed,
84
+ toggleNav,
85
+ }
86
+ },
87
+ { persist: { pick: ['storedMode', 'railCollapsed'] } },
88
+ )
@@ -1,28 +1,28 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { freshPromptTokens } from './observability'
2
+ import { totalInputTokens } from './observability'
3
3
 
4
- describe('freshPromptTokens', () => {
5
- it('subtracts the cached prefix from the prompt-token sum', () => {
6
- expect(freshPromptTokens(1000, 300)).toBe(700)
4
+ describe('totalInputTokens', () => {
5
+ it('sums all three input classes, so the headline matches Claude Code’s context gauge', () => {
6
+ expect(
7
+ totalInputTokens({ promptTokens: 685, cacheReadTokens: 31_099_813, cacheWriteTokens: 0 }),
8
+ ).toBe(31_100_498)
7
9
  })
8
10
 
9
- it('reduces a near-fully-cached agentic run to its tiny fresh input (inclusive shape)', () => {
10
- // The 31M-cache-read shape from the investigation on the INCLUSIVE shape (subscription
11
- // harness / OpenAI-style): prompt tokens INCLUDE the cache reads, so subtracting the cached
12
- // subset leaves the few-hundred-token fresh figure, not tens of millions.
13
- expect(freshPromptTokens(31_100_498, 31_099_813)).toBe(685)
11
+ it('counts cache WRITES too they occupy the window like any other input token', () => {
12
+ expect(
13
+ totalInputTokens({ promptTokens: 100, cacheReadTokens: 900, cacheWriteTokens: 40 }),
14
+ ).toBe(1040)
14
15
  })
15
16
 
16
- it('treats promptTokens as already-fresh when cached exceeds it (Anthropic separate shape)', () => {
17
- // Anthropic via the LLM proxy reports cache reads SEPARATELY, so promptTokens is fresh-only
18
- // and cachedPromptTokens can far exceed it. The fresh figure is promptTokens itself — NOT 0
19
- // (the old subtract-and-clamp collapsed real fresh input to zero on this shape).
20
- expect(freshPromptTokens(685, 31_099_813)).toBe(685)
17
+ it('does NOT lead with the fresh figure on a cache-dominated run', () => {
18
+ // The regression this pins: leading with fresh made a ~31M-token run render as 685 tokens,
19
+ // discounting cache reads because their dollar cost is low. Volume is the thing being
20
+ // measured here, and a cached token costs the same context window as a fresh one.
21
+ const m = { promptTokens: 685, cacheReadTokens: 31_099_813, cacheWriteTokens: 0 }
22
+ expect(totalInputTokens(m)).toBeGreaterThan(m.promptTokens * 1000)
21
23
  })
22
24
 
23
- it('never returns negative, and treats a fully-cached inclusive rollup as 0 fresh', () => {
24
- expect(freshPromptTokens(500, 500)).toBe(0)
25
- // cached > prompt ⇒ separate shape ⇒ fresh = prompt (never a negative number).
26
- expect(freshPromptTokens(500, 600)).toBe(500)
25
+ it('degrades to the fresh count when an older snapshot carries no cache fields', () => {
26
+ expect(totalInputTokens({ promptTokens: 500 })).toBe(500)
27
27
  })
28
28
  })
@@ -12,31 +12,28 @@ export function formatTokens(n: number): string {
12
12
  }
13
13
 
14
14
  /**
15
- * FRESH (uncached) prompt tokens: the input tokens actually processed this call/rollup after
16
- * excluding the prefix served from the provider's cache. A long agentic run re-sends its whole
17
- * growing transcript every turn, so on the "inclusive" shape the raw `promptTokens` sum is
18
- * dominated by cache reads (often >99%) — showing THAT as "tokens burned" reads as a blow-up
19
- * when almost nothing fresh was processed. This surfaces the fresh figure alongside cached.
15
+ * TOTAL input tokens: fresh + cache read + cache write. This is the headline "↑" figure on
16
+ * every LLM surface, and it deliberately COUNTS THE CACHED CLASSES.
20
17
  *
21
- * `cachedPromptTokens` has PROVIDER-DEPENDENT semantics (see the field docs on `stepMetricsSchema`
22
- * / `llmCallMetricSchema`), so we can't blindly subtract:
23
- * - Inclusive shape (OpenAI/DeepSeek, and the subscription-CLI harness, which folds cache into
24
- * `promptTokens`): cached is a SUBSET of prompt fresh = prompt cached.
25
- * - Separate shape (Anthropic via the LLM proxy): cache reads are reported SEPARATELY and
26
- * `promptTokens` is ALREADY fresh-only, so cached can EXCEED prompt. Subtracting there would
27
- * wrongly collapse a real fresh input to 0 — when cached ≥ prompt, `promptTokens` itself IS
28
- * the fresh figure.
18
+ * That is the like-for-like measure of Claude Code's own context gauge, which sums exactly these
19
+ * buckets because a cached token still physically occupies the context window. Leading with the
20
+ * fresh figure instead (what this surface used to do, on the grounds that the raw sum "reads as a
21
+ * blow-up") discounts cache reads because their DOLLAR cost is low but the quota, latency and
22
+ * context-window cost is the whole thing an autonomous run burns, and hiding it is what let a
23
+ * ~31M-token run look like a 685-token one. See
24
+ * `docs/initiatives/token-burn-instrumentation.md`.
29
25
  *
30
- * NOTE: with only these two aggregates we cannot distinguish the separate shape while cached is
31
- * still prompt (there the subtraction under-counts fresh by the cache-read amount). A fully
32
- * exact split needs the wire contract to carry cache-read vs cache-write distinctly at the
33
- * source; this heuristic fixes the dominant (cached prompt) case and never returns negative.
26
+ * The three classes are still rendered as the breakdown beneath it, because they are priced an
27
+ * order of magnitude apart in opposite directions this makes the volume honest WITHOUT making
28
+ * the cost unreadable. The two fields are optional on an older snapshot, where absent reads as 0
29
+ * and the total degrades to the fresh count.
34
30
  */
35
- export function freshPromptTokens(promptTokens: number, cachedPromptTokens: number): number {
36
- // Separate shape: promptTokens is already fresh-only (cache reads counted separately).
37
- if (cachedPromptTokens > promptTokens) return promptTokens
38
- // Inclusive shape: cached is a subset of prompt.
39
- return promptTokens - cachedPromptTokens
31
+ export function totalInputTokens(m: {
32
+ promptTokens: number
33
+ cacheReadTokens?: number
34
+ cacheWriteTokens?: number
35
+ }): number {
36
+ return m.promptTokens + (m.cacheReadTokens ?? 0) + (m.cacheWriteTokens ?? 0)
40
37
  }
41
38
 
42
39
  /** Compact duration: 850 → "850ms", 1500 → "1.5s", 90_000 → "1m 30s". */