@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.
- package/README.md +45 -2
- package/app/components/auth/UserMenu.vue +9 -2
- package/app/components/board/AddTaskModal.vue +28 -9
- package/app/components/layout/BoardSwitcher.vue +21 -4
- package/app/components/layout/CommandBar.vue +3 -1
- package/app/components/layout/LanguageSwitcher.vue +9 -2
- package/app/components/layout/SideBar.vue +68 -15
- package/app/components/layout/UiModeSwitcher.vue +90 -0
- package/app/components/observability/StepMetricsBar.vue +36 -20
- package/app/components/panels/ObservabilityPanel.vue +60 -23
- package/app/components/panels/inspector/TaskRunSettings.vue +35 -8
- package/app/components/tasks/BugHuntModal.vue +3 -1
- package/app/components/tasks/ContextIssuePicker.vue +22 -5
- package/app/components/tasks/TaskImportModal.vue +1 -1
- package/app/composables/api/errors.ts +16 -0
- package/app/composables/api/tasks.ts +5 -2
- package/app/composables/useNavContributions.ts +3 -0
- package/app/docs/consumer-extensions.md +6 -1
- package/app/modular/nav-contributions.spec.ts +59 -1
- package/app/modular/nav-contributions.ts +62 -4
- package/app/modular/nav-gates.ts +7 -1
- package/app/modular/registry.spec.ts +1 -0
- package/app/stores/bugHunt.ts +2 -10
- package/app/stores/tasks.ts +7 -4
- package/app/stores/uiMode.spec.ts +151 -0
- package/app/stores/uiMode.ts +88 -0
- package/app/utils/observability.spec.ts +18 -18
- package/app/utils/observability.ts +19 -22
- package/app/utils/uiMode.spec.ts +67 -0
- package/app/utils/uiMode.ts +71 -0
- package/i18n/locales/de.json +35 -10
- package/i18n/locales/en.json +38 -10
- package/i18n/locales/es.json +35 -10
- package/i18n/locales/fr.json +35 -10
- package/i18n/locales/he.json +35 -10
- package/i18n/locales/it.json +35 -10
- package/i18n/locales/ja.json +35 -10
- package/i18n/locales/pl.json +35 -10
- package/i18n/locales/tr.json +35 -10
- package/i18n/locales/uk.json +35 -10
- package/nuxt.config.ts +5 -0
- 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
|
|
396
|
-
* `installModularApp` as the global `slotFilter`.
|
|
445
|
+
* `useReactiveSlots` it re-runs when a permission, connection, or the interface
|
|
446
|
+
* mode flips. Passed to `installModularApp` as the global `slotFilter`.
|
|
447
|
+
*
|
|
448
|
+
* The two axes are independent and BOTH must pass: an `advanced` item is dropped in
|
|
449
|
+
* basic mode, and every item still answers to its own `gate`. Order doesn't matter
|
|
450
|
+
* (it's a conjunction) but the tier is checked first, since it's the cheaper read.
|
|
397
451
|
*
|
|
398
452
|
* Typed against `AppSlots` (not the generic `SlotFilter`) so it matches the
|
|
399
453
|
* filter shape the runtime infers for this registry. `deps` is widened to an
|
|
@@ -406,7 +460,11 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
|
|
|
406
460
|
...slots,
|
|
407
461
|
// No gates service wired (tests / bare install) ⇒ show everything, matching
|
|
408
462
|
// the dev-open "absent access allows all" backend parity.
|
|
409
|
-
nav: gates
|
|
463
|
+
nav: gates
|
|
464
|
+
? nav.filter(
|
|
465
|
+
(i) => (i.advanced ? gates.advancedMode : true) && (i.gate ? i.gate(gates) : true),
|
|
466
|
+
)
|
|
467
|
+
: nav,
|
|
410
468
|
}
|
|
411
469
|
}
|
|
412
470
|
|
package/app/modular/nav-gates.ts
CHANGED
|
@@ -13,7 +13,9 @@ import type { NavGates } from '~/modular/nav-contributions'
|
|
|
13
13
|
* permission or connection flip re-gates every shell with no `recalculateSlots()`.
|
|
14
14
|
*
|
|
15
15
|
* This mirrors the exact gating the pre-slice-1 `SideBar` computeds encoded (see
|
|
16
|
-
* `useWorkspaceAccess` for the dev-open "absent access ⇒ allow all" parity)
|
|
16
|
+
* `useWorkspaceAccess` for the dev-open "absent access ⇒ allow all" parity), plus
|
|
17
|
+
* the interface tier (`advancedMode`), which rides the same service so a mode flip
|
|
18
|
+
* re-gates all three shells through the same reactive path as a permission flip.
|
|
17
19
|
*/
|
|
18
20
|
export function createNavGates(): NavGates {
|
|
19
21
|
const access = useWorkspaceAccess()
|
|
@@ -22,6 +24,7 @@ export function createNavGates(): NavGates {
|
|
|
22
24
|
const accounts = useAccountsStore()
|
|
23
25
|
const auth = useAuthStore()
|
|
24
26
|
const providerConnections = useProviderConnectionsStore()
|
|
27
|
+
const uiMode = useUiModeStore()
|
|
25
28
|
|
|
26
29
|
const infrastructureAvailable = computed(
|
|
27
30
|
() =>
|
|
@@ -59,5 +62,8 @@ export function createNavGates(): NavGates {
|
|
|
59
62
|
get isAccountAdmin() {
|
|
60
63
|
return isAccountAdmin.value
|
|
61
64
|
},
|
|
65
|
+
get advancedMode() {
|
|
66
|
+
return uiMode.isAdvanced
|
|
67
|
+
},
|
|
62
68
|
}
|
|
63
69
|
}
|
package/app/stores/bugHunt.ts
CHANGED
|
@@ -1,18 +1,10 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { computed, ref } from 'vue'
|
|
3
3
|
import type { BugHuntResult, RunBugHuntInput, TaskSourceKind, TrackerBoard } from '~/types/domain'
|
|
4
|
-
import {
|
|
4
|
+
import { apiErrorReason } from '~/composables/api/errors'
|
|
5
5
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
6
6
|
import { usePersonalSubscriptionsStore } from '~/stores/personalSubscriptions'
|
|
7
7
|
|
|
8
|
-
/** The backend's `error.details.reason` code, when it sent one (see `useApi`'s error envelope). */
|
|
9
|
-
function errorReasonOf(error: unknown): string | null {
|
|
10
|
-
const details = apiErrorEnvelope(error)?.details
|
|
11
|
-
if (!details || typeof details !== 'object') return null
|
|
12
|
-
const reason = (details as Record<string, unknown>).reason
|
|
13
|
-
return typeof reason === 'string' ? reason : null
|
|
14
|
-
}
|
|
15
|
-
|
|
16
8
|
/**
|
|
17
9
|
* Bug-hunt state: the boards of the tracker being browsed, the last hunt's ranked candidates,
|
|
18
10
|
* and the actions behind the three steps (list boards → run the hunt → adopt one candidate).
|
|
@@ -68,7 +60,7 @@ export const useBugHuntStore = defineStore('bugHunt', () => {
|
|
|
68
60
|
if (boardsSource.value !== source) return
|
|
69
61
|
boards.value = []
|
|
70
62
|
boardsError.value = e instanceof Error ? e.message : String(e)
|
|
71
|
-
boardsErrorReason.value =
|
|
63
|
+
boardsErrorReason.value = apiErrorReason(e)
|
|
72
64
|
} finally {
|
|
73
65
|
boardsLoading.value = false
|
|
74
66
|
}
|
package/app/stores/tasks.ts
CHANGED
|
@@ -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
|
|
150
|
-
* service's linked repo
|
|
151
|
-
* number resolves to the exact issue.
|
|
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
|
|
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 {
|
|
2
|
+
import { totalInputTokens } from './observability'
|
|
3
3
|
|
|
4
|
-
describe('
|
|
5
|
-
it('
|
|
6
|
-
expect(
|
|
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('
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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('
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
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('
|
|
24
|
-
expect(
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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". */
|