@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
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ The SPA source lives under `app/` (the Nuxt srcDir).
|
|
|
15
15
|
- [What it is](#what-it-is)
|
|
16
16
|
- [Tech stack](#tech-stack)
|
|
17
17
|
- [Layout](#layout)
|
|
18
|
+
- [Interface modes (basic / advanced)](#interface-modes-basic--advanced)
|
|
18
19
|
- [Key UI surfaces](#key-ui-surfaces)
|
|
19
20
|
- [Develop & test](#develop--test)
|
|
20
21
|
|
|
@@ -52,6 +53,48 @@ over the WebSocket. How that sync works is written up in
|
|
|
52
53
|
| `types/` | TypeScript domain unions (`domain.ts`) and wire types mirroring the contracts. |
|
|
53
54
|
| `utils/` | Small pure helpers. |
|
|
54
55
|
|
|
56
|
+
## Interface modes (basic / advanced)
|
|
57
|
+
|
|
58
|
+
The SPA renders at one of two **interface tiers**. `basic` (the default) is the everyday
|
|
59
|
+
surface: the power-user nav destinations are hidden and the run/pipeline options that only
|
|
60
|
+
exist to override a workspace-level default are left at that default. `advanced` shows
|
|
61
|
+
everything. The tier resolves in a fixed order, first match wins:
|
|
62
|
+
|
|
63
|
+
1. **`NUXT_PUBLIC_UI_MODE`** (`basic` | `advanced`) — the deployment pin. Like
|
|
64
|
+
`NUXT_PUBLIC_API_BASE` it is baked in at **build** time (`ssr: false`), and while it is
|
|
65
|
+
set the in-app switcher is a read-only indicator, since a preference the resolver ignores
|
|
66
|
+
would be a lie. An unrecognised value is ignored rather than failing the boot.
|
|
67
|
+
2. **The user's own choice**, persisted client-side (the `uiMode` store) and changed from the
|
|
68
|
+
switcher at the bottom of the sidebar — or from the **command palette** entry, which is
|
|
69
|
+
deliberately _not_ an advanced item: basic is the default, so the route back to the
|
|
70
|
+
advanced half has to exist inside basic mode.
|
|
71
|
+
3. **`basic`.**
|
|
72
|
+
|
|
73
|
+
The sidebar can independently be **collapsed to an icon rail** (the toggle at its top, lg+
|
|
74
|
+
only — below `lg` the navbar is already an off-canvas drawer). The rail preference is
|
|
75
|
+
**per-tier**: basic _defaults_ to railed and advanced to expanded, and each tier remembers its
|
|
76
|
+
own choice, so an expand in either survives a reload and a round trip through the other.
|
|
77
|
+
|
|
78
|
+
Two seams carry the tier, and a new feature should use them rather than reading the store ad
|
|
79
|
+
hoc where it can be avoided:
|
|
80
|
+
|
|
81
|
+
- **A nav destination** declares `advanced: true` in `app/modular/nav-contributions.ts`. The
|
|
82
|
+
shared `navSlotFilter` drops it in basic mode across all three shells (sidebar, command
|
|
83
|
+
palette, toolbar), independently of its RBAC `gate` — both must pass. A consumer module's
|
|
84
|
+
own contributions take the same flag.
|
|
85
|
+
- **A less-used option inside a surface** reads `useUiModeStore().isAdvanced`. Hide, never
|
|
86
|
+
disable, and only ever hide an OVERRIDE: what remains must be exactly the default the hidden
|
|
87
|
+
field would have shown, so a basic-mode user never gets different behaviour from an advanced
|
|
88
|
+
one — only fewer choices. An input nothing else supplies (the pipeline, the apriori branches)
|
|
89
|
+
stays in both tiers however advanced it feels.
|
|
90
|
+
- **An override control on an EXISTING entity gates on `showOverrideField(isAdvanced, …values)`**
|
|
91
|
+
(`app/utils/uiMode.ts`) rather than on `isAdvanced` alone. Hiding an override is only safe
|
|
92
|
+
while it is unset — always true for a creation form, never guaranteed for a block that a
|
|
93
|
+
teammate on the advanced tier (or the API) already wrote one onto. The helper reveals the
|
|
94
|
+
control, editable, as soon as any value it edits is set (`false` included — a tri-state
|
|
95
|
+
`false` is a choice, not absence), so basic mode can never conceal a setting a run will
|
|
96
|
+
actually use.
|
|
97
|
+
|
|
55
98
|
## Extending the layer (consumer modules)
|
|
56
99
|
|
|
57
100
|
A deployment can contribute its own components — result windows, nav entries, inspector
|
|
@@ -69,8 +112,8 @@ example ships in [`deploy/frontend`](../../deploy/frontend) (the `acme:security`
|
|
|
69
112
|
`ModuleFrame`, `TaskCard`), dependency edges, the per-block `AgentFailureCard` /
|
|
70
113
|
`AgentStopButton`, and a deep-zoom `focus/BlockFocusView`.
|
|
71
114
|
- **Sidebar & chrome** (`components/layout`) — board/account switchers, palettes
|
|
72
|
-
entry points, the
|
|
73
|
-
queue).
|
|
115
|
+
entry points, the language + [interface-mode](#interface-modes-basic--advanced)
|
|
116
|
+
switchers, the `SpendWarningBanner`, and the toolbar (zoom, LOD, decision queue).
|
|
74
117
|
- **Palettes** (`components/palettes`) — drag blocks, pipelines and agents onto
|
|
75
118
|
the board.
|
|
76
119
|
- **Inspector** (`components/panels` + `panels/inspector`) — per-block tabs:
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
3
3
|
|
|
4
4
|
// Signed-in identity + per-user actions, shown in the sidebar when auth is enabled.
|
|
5
|
+
//
|
|
6
|
+
// `collapsed` renders the avatar-only rail variant (the sidebar's collapsed state); the
|
|
7
|
+
// dropdown is unchanged, so "My setup" / sign-out stay reachable from the rail.
|
|
8
|
+
withDefaults(defineProps<{ collapsed?: boolean }>(), { collapsed: false })
|
|
9
|
+
|
|
5
10
|
const auth = useAuthStore()
|
|
6
11
|
const ui = useUiStore()
|
|
7
12
|
const { t } = useI18n()
|
|
@@ -30,7 +35,9 @@ const items = computed<DropdownMenuItem[][]>(() => [
|
|
|
30
35
|
<UDropdownMenu v-if="auth.user" :items="items" :content="{ side: 'top', align: 'start' }">
|
|
31
36
|
<button
|
|
32
37
|
type="button"
|
|
38
|
+
:title="collapsed ? auth.user.name || auth.user.login : undefined"
|
|
33
39
|
class="flex w-full items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/60 p-2 text-start transition hover:bg-slate-800/60"
|
|
40
|
+
:class="collapsed ? 'justify-center' : ''"
|
|
34
41
|
>
|
|
35
42
|
<UAvatar
|
|
36
43
|
:src="auth.user.avatarUrl ?? undefined"
|
|
@@ -38,13 +45,13 @@ const items = computed<DropdownMenuItem[][]>(() => [
|
|
|
38
45
|
size="xs"
|
|
39
46
|
icon="i-lucide-user"
|
|
40
47
|
/>
|
|
41
|
-
<div class="min-w-0 flex-1">
|
|
48
|
+
<div v-if="!collapsed" class="min-w-0 flex-1">
|
|
42
49
|
<div class="truncate text-xs font-medium text-white">
|
|
43
50
|
{{ auth.user.name || auth.user.login }}
|
|
44
51
|
</div>
|
|
45
52
|
<div class="truncate text-[10px] text-slate-500">@{{ auth.user.login }}</div>
|
|
46
53
|
</div>
|
|
47
|
-
<UIcon name="i-lucide-chevron-up" class="h-4 w-4 shrink-0 text-slate-500" />
|
|
54
|
+
<UIcon v-if="!collapsed" name="i-lucide-chevron-up" class="h-4 w-4 shrink-0 text-slate-500" />
|
|
48
55
|
</button>
|
|
49
56
|
</UDropdownMenu>
|
|
50
57
|
</template>
|
|
@@ -32,6 +32,14 @@ import { parseConflict } from '~/composables/usePipelineErrorToast'
|
|
|
32
32
|
import { pipelineAllowedForManualStart } from '~/utils/pipeline'
|
|
33
33
|
|
|
34
34
|
const ui = useUiStore()
|
|
35
|
+
// Interface tier. In BASIC mode this form asks for the task itself (type, title,
|
|
36
|
+
// description, per-type fields, context, the pipeline) and hides the OVERRIDES: the run
|
|
37
|
+
// knobs with a workspace-level default (merge policy, model preset), the per-task deviation
|
|
38
|
+
// from the service's best-practice fragments, and the technical/business hint the engine
|
|
39
|
+
// infers on its own. Hidden, never disabled — each one falls back to exactly the value it
|
|
40
|
+
// would have shown, so a basic-mode task behaves identically, it just asks less. The
|
|
41
|
+
// inspector's `TaskRunSettings` applies the same split after creation.
|
|
42
|
+
const uiMode = useUiModeStore()
|
|
35
43
|
const board = useBoardStore()
|
|
36
44
|
const documents = useDocumentsStore()
|
|
37
45
|
const tasks = useTasksStore()
|
|
@@ -864,7 +872,7 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
|
|
|
864
872
|
</UFormField>
|
|
865
873
|
</template>
|
|
866
874
|
|
|
867
|
-
<UCheckbox v-model="technical" name="technical">
|
|
875
|
+
<UCheckbox v-if="uiMode.isAdvanced" v-model="technical" name="technical">
|
|
868
876
|
<template #label>
|
|
869
877
|
<span class="text-sm text-slate-200">{{ t('board.addTask.technical') }}</span>
|
|
870
878
|
</template>
|
|
@@ -1113,7 +1121,9 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
|
|
|
1113
1121
|
</template>
|
|
1114
1122
|
</div>
|
|
1115
1123
|
|
|
1116
|
-
|
|
1124
|
+
<!-- One column in basic mode, where the pipeline picker is the only survivor and a
|
|
1125
|
+
two-column grid would leave it stranded beside an empty cell. -->
|
|
1126
|
+
<div class="grid gap-3" :class="uiMode.isAdvanced ? 'grid-cols-2' : 'grid-cols-1'">
|
|
1117
1127
|
<UFormField :label="t('board.addTask.pipeline')">
|
|
1118
1128
|
<PipelinePicker
|
|
1119
1129
|
:model-value="pipelineId"
|
|
@@ -1124,8 +1134,12 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
|
|
|
1124
1134
|
/>
|
|
1125
1135
|
</UFormField>
|
|
1126
1136
|
|
|
1127
|
-
<!-- A review task merges nothing, so its risk (merge) policy is meaningless — omit it.
|
|
1128
|
-
|
|
1137
|
+
<!-- A review task merges nothing, so its risk (merge) policy is meaningless — omit it.
|
|
1138
|
+
Basic mode leaves it (and the model preset below) on the workspace default. -->
|
|
1139
|
+
<UFormField
|
|
1140
|
+
v-if="!isReview && uiMode.isAdvanced"
|
|
1141
|
+
:label="t('board.addTask.mergePolicy')"
|
|
1142
|
+
>
|
|
1129
1143
|
<RiskPolicyPicker
|
|
1130
1144
|
:model-value="riskPolicyId"
|
|
1131
1145
|
:options="riskPolicies.presets"
|
|
@@ -1136,7 +1150,7 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
|
|
|
1136
1150
|
/>
|
|
1137
1151
|
</UFormField>
|
|
1138
1152
|
|
|
1139
|
-
<UFormField :label="t('board.addTask.modelPreset')">
|
|
1153
|
+
<UFormField v-if="uiMode.isAdvanced" :label="t('board.addTask.modelPreset')">
|
|
1140
1154
|
<UDropdownMenu :items="modelPresetMenu" class="w-full">
|
|
1141
1155
|
<UButton
|
|
1142
1156
|
color="neutral"
|
|
@@ -1184,8 +1198,11 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
|
|
|
1184
1198
|
</div>
|
|
1185
1199
|
|
|
1186
1200
|
<!-- Best-practice fragments pinned on the task at creation, scoped to the frame's type.
|
|
1187
|
-
Pre-seeded from the enclosing service's standards; the task owns them from here.
|
|
1188
|
-
|
|
1201
|
+
Pre-seeded from the enclosing service's standards; the task owns them from here.
|
|
1202
|
+
Hidden in basic mode: `fragmentIds` still carries the service-seeded selection, so
|
|
1203
|
+
the task ships with its service's standards either way — advanced mode is what
|
|
1204
|
+
lets you deviate from them per task. -->
|
|
1205
|
+
<div v-if="uiMode.isAdvanced" class="space-y-2">
|
|
1189
1206
|
<FragmentSelector
|
|
1190
1207
|
v-model="fragmentIds"
|
|
1191
1208
|
:pool="fragmentPool"
|
|
@@ -1349,10 +1366,12 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
|
|
|
1349
1366
|
{{ t('board.addTask.attach') }}
|
|
1350
1367
|
</UButton>
|
|
1351
1368
|
</div>
|
|
1369
|
+
<!-- The container is what scopes the issue search to a repo, so the picker is
|
|
1370
|
+
only rendered once we have one (the modal is open, so we always do). -->
|
|
1352
1371
|
<ContextIssuePicker
|
|
1353
|
-
v-if="showIssuePicker && issuesConnected"
|
|
1372
|
+
v-if="showIssuePicker && issuesConnected && ui.addTaskContainerId"
|
|
1354
1373
|
:chosen-keys="chosenIssueKeys"
|
|
1355
|
-
:scope-block-id="ui.addTaskContainerId
|
|
1374
|
+
:scope-block-id="ui.addTaskContainerId"
|
|
1356
1375
|
@pick="addPending"
|
|
1357
1376
|
/>
|
|
1358
1377
|
<div v-if="pendingIssues.length" class="space-y-1">
|
|
@@ -6,6 +6,13 @@ import type { CloudProvider } from '~/types/domain'
|
|
|
6
6
|
// active board within it, and manages boards (new / rename / delete). The account
|
|
7
7
|
// row is shown only when accounts exist (auth on); in dev it falls back to a plain
|
|
8
8
|
// board switcher over the single unscoped context.
|
|
9
|
+
//
|
|
10
|
+
// `collapsed` renders the icon-only rail variant (the sidebar's collapsed state): the
|
|
11
|
+
// account row folds away — it is a label with a menu duplicated inside the board menu's
|
|
12
|
+
// reach — and the board button keeps only its glyph. Both dropdowns are unchanged, so
|
|
13
|
+
// switching boards never requires expanding the sidebar first.
|
|
14
|
+
withDefaults(defineProps<{ collapsed?: boolean }>(), { collapsed: false })
|
|
15
|
+
|
|
9
16
|
const { t } = useI18n()
|
|
10
17
|
|
|
11
18
|
const accounts = useAccountsStore()
|
|
@@ -245,9 +252,9 @@ async function submitPrompt() {
|
|
|
245
252
|
|
|
246
253
|
<template>
|
|
247
254
|
<div class="space-y-1.5">
|
|
248
|
-
<!-- account selector (only when accounts exist) -->
|
|
255
|
+
<!-- account selector (only when accounts exist, and not in the collapsed rail) -->
|
|
249
256
|
<UDropdownMenu
|
|
250
|
-
v-if="accounts.enabled"
|
|
257
|
+
v-if="accounts.enabled && !collapsed"
|
|
251
258
|
:items="accountItems"
|
|
252
259
|
:content="{ align: 'start' }"
|
|
253
260
|
class="w-full"
|
|
@@ -275,14 +282,24 @@ async function submitPrompt() {
|
|
|
275
282
|
<UDropdownMenu :items="boardItems" :content="{ align: 'start' }" class="w-full">
|
|
276
283
|
<button
|
|
277
284
|
type="button"
|
|
285
|
+
:title="
|
|
286
|
+
collapsed
|
|
287
|
+
? (workspace.activeWorkspace?.name ?? t('layout.boardSwitcher.boardFallback'))
|
|
288
|
+
: undefined
|
|
289
|
+
"
|
|
278
290
|
class="flex w-full items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/60 px-2.5 py-1.5 text-start transition hover:bg-slate-800/60"
|
|
291
|
+
:class="collapsed ? 'justify-center' : ''"
|
|
279
292
|
:disabled="busy"
|
|
280
293
|
>
|
|
281
294
|
<UIcon name="i-lucide-layout-dashboard" class="h-4 w-4 shrink-0 text-indigo-400" />
|
|
282
|
-
<span class="truncate text-sm font-medium text-white">
|
|
295
|
+
<span v-if="!collapsed" class="truncate text-sm font-medium text-white">
|
|
283
296
|
{{ workspace.activeWorkspace?.name ?? t('layout.boardSwitcher.boardFallback') }}
|
|
284
297
|
</span>
|
|
285
|
-
<UIcon
|
|
298
|
+
<UIcon
|
|
299
|
+
v-if="!collapsed"
|
|
300
|
+
name="i-lucide-chevron-down"
|
|
301
|
+
class="ms-auto h-4 w-4 shrink-0 text-slate-500"
|
|
302
|
+
/>
|
|
286
303
|
</button>
|
|
287
304
|
</UDropdownMenu>
|
|
288
305
|
|
|
@@ -235,13 +235,14 @@ function indexOf(cmd: Command) {
|
|
|
235
235
|
<template>
|
|
236
236
|
<UModal v-model:open="open" :ui="{ content: 'max-w-xl' }">
|
|
237
237
|
<template #content>
|
|
238
|
-
<div class="flex flex-col" @keydown="onKeydown">
|
|
238
|
+
<div class="flex flex-col" data-testid="command-bar" @keydown="onKeydown">
|
|
239
239
|
<div class="flex items-center gap-2 border-b border-slate-800 px-3">
|
|
240
240
|
<UIcon name="i-lucide-search" class="h-4 w-4 shrink-0 text-slate-500" />
|
|
241
241
|
<UInput
|
|
242
242
|
ref="inputRef"
|
|
243
243
|
v-model="query"
|
|
244
244
|
variant="none"
|
|
245
|
+
data-testid="command-bar-input"
|
|
245
246
|
:placeholder="t('layout.commandBar.searchPlaceholder')"
|
|
246
247
|
class="w-full"
|
|
247
248
|
:ui="{ base: 'py-3 text-sm' }"
|
|
@@ -264,6 +265,7 @@ function indexOf(cmd: Command) {
|
|
|
264
265
|
v-for="cmd in group.items"
|
|
265
266
|
:key="cmd.id"
|
|
266
267
|
type="button"
|
|
268
|
+
:data-testid="`command-${cmd.id}`"
|
|
267
269
|
class="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-start text-sm transition"
|
|
268
270
|
:class="
|
|
269
271
|
indexOf(cmd) === activeIndex
|
|
@@ -7,6 +7,11 @@ import { useLocaleStore } from '~/stores/locale'
|
|
|
7
7
|
// the user menu. The list is data-driven from the i18n config (`useI18n().locales`), so
|
|
8
8
|
// adding a locale in nuxt.config.ts surfaces it here automatically. Selecting one switches
|
|
9
9
|
// the live locale AND persists the choice (the locale store) so it survives a reload.
|
|
10
|
+
//
|
|
11
|
+
// `collapsed` renders the icon-only rail variant (the sidebar's collapsed state); the
|
|
12
|
+
// dropdown itself is unchanged, so the picker stays fully usable from the rail.
|
|
13
|
+
withDefaults(defineProps<{ collapsed?: boolean }>(), { collapsed: false })
|
|
14
|
+
|
|
10
15
|
const { t, locale, locales, setLocale } = useI18n()
|
|
11
16
|
const localeStore = useLocaleStore()
|
|
12
17
|
|
|
@@ -39,16 +44,18 @@ const items = computed<DropdownMenuItem[][]>(() => [
|
|
|
39
44
|
type="button"
|
|
40
45
|
data-testid="language-switcher"
|
|
41
46
|
:aria-label="t('language.switcher')"
|
|
47
|
+
:title="collapsed ? `${t('language.switcher')}: ${current}` : undefined"
|
|
42
48
|
class="flex w-full items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/60 p-2 text-start transition hover:bg-slate-800/60"
|
|
49
|
+
:class="collapsed ? 'justify-center' : ''"
|
|
43
50
|
>
|
|
44
51
|
<UIcon name="i-lucide-languages" class="h-4 w-4 shrink-0 text-slate-400" />
|
|
45
|
-
<div class="min-w-0 flex-1">
|
|
52
|
+
<div v-if="!collapsed" class="min-w-0 flex-1">
|
|
46
53
|
<div class="truncate text-[10px] uppercase tracking-wide text-slate-500">
|
|
47
54
|
{{ t('language.switcher') }}
|
|
48
55
|
</div>
|
|
49
56
|
<div class="truncate text-xs font-medium text-white">{{ current }}</div>
|
|
50
57
|
</div>
|
|
51
|
-
<UIcon name="i-lucide-chevron-up" class="h-4 w-4 shrink-0 text-slate-500" />
|
|
58
|
+
<UIcon v-if="!collapsed" name="i-lucide-chevron-up" class="h-4 w-4 shrink-0 text-slate-500" />
|
|
52
59
|
</button>
|
|
53
60
|
</UDropdownMenu>
|
|
54
61
|
</template>
|
|
@@ -5,9 +5,15 @@
|
|
|
5
5
|
// actions, repository management, integration management, the workspace-wide
|
|
6
6
|
// context-fragment library, and workspace configuration (merge thresholds +
|
|
7
7
|
// default models).
|
|
8
|
+
//
|
|
9
|
+
// Two orthogonal ways this panel shrinks. WHICH destinations exist is the interface
|
|
10
|
+
// TIER (basic hides the `advanced` contributions, filtered upstream in `navSlotFilter`);
|
|
11
|
+
// how much room they take is the COLLAPSE state (the icon-only rail). Basic mode starts
|
|
12
|
+
// railed, but either can be changed independently from the footer switcher / the toggle.
|
|
8
13
|
import { useEventListener, useScrollLock } from '@vueuse/core'
|
|
9
14
|
import BoardSwitcher from '~/components/layout/BoardSwitcher.vue'
|
|
10
15
|
import LanguageSwitcher from '~/components/layout/LanguageSwitcher.vue'
|
|
16
|
+
import UiModeSwitcher from '~/components/layout/UiModeSwitcher.vue'
|
|
11
17
|
import UserMenu from '~/components/auth/UserMenu.vue'
|
|
12
18
|
import { useViewport } from '~/composables/useViewport'
|
|
13
19
|
|
|
@@ -34,6 +40,13 @@ const { sidebarGroups, invoke } = useNavContributions()
|
|
|
34
40
|
// above it the aside is static and the drawer flag is inert.
|
|
35
41
|
const { isCompact } = useViewport()
|
|
36
42
|
|
|
43
|
+
// The collapsed RAIL: labels drop away and the aside narrows to its icons. Scoped to the
|
|
44
|
+
// STATIC aside — an off-canvas drawer is already a deliberate, temporary reveal, so opening
|
|
45
|
+
// it only to find a rail would be two taps for one destination. The tier decides the default
|
|
46
|
+
// (basic starts collapsed), the user's toggle wins from there — see `stores/uiMode.ts`.
|
|
47
|
+
const uiMode = useUiModeStore()
|
|
48
|
+
const railed = computed(() => !isCompact.value && uiMode.navCollapsed)
|
|
49
|
+
|
|
37
50
|
// The off-canvas drawer is a modal surface on compact viewports, so give it the
|
|
38
51
|
// expected affordances:
|
|
39
52
|
// • Escape closes it (keyboard parity with the backdrop tap),
|
|
@@ -135,48 +148,87 @@ watch(
|
|
|
135
148
|
:aria-label="isCompact ? t('nav.menu') : undefined"
|
|
136
149
|
:inert="isCompact && !ui.mobileNavOpen"
|
|
137
150
|
class="fixed inset-y-0 start-0 z-40 flex h-full w-64 shrink-0 flex-col gap-4 overflow-y-auto border-e border-slate-800 bg-slate-900/95 px-3 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] backdrop-blur transition-transform duration-200 focus:outline-none lg:static lg:z-auto lg:translate-x-0 lg:bg-slate-900/80"
|
|
138
|
-
:class="
|
|
139
|
-
ui.mobileNavOpen
|
|
140
|
-
|
|
151
|
+
:class="[
|
|
152
|
+
ui.mobileNavOpen
|
|
153
|
+
? 'translate-x-0'
|
|
154
|
+
: '-translate-x-full rtl:translate-x-full lg:translate-x-0',
|
|
155
|
+
// The rail is lg-only (see `railed`), so the narrow width is too — below lg the drawer
|
|
156
|
+
// keeps its full width whatever the collapse state says.
|
|
157
|
+
railed ? 'lg:w-14 lg:px-2' : '',
|
|
158
|
+
]"
|
|
159
|
+
:data-collapsed="railed ? 'true' : 'false'"
|
|
141
160
|
>
|
|
142
|
-
|
|
161
|
+
<!-- Rail toggle. lg-only: below it the hamburger + backdrop already own showing and
|
|
162
|
+
hiding the whole navbar, and a second collapse control there would fight them. -->
|
|
163
|
+
<!-- The panel glyphs are drawn for a left-hand navbar; under RTL the aside is `start`-anchored
|
|
164
|
+
to the right, so mirror them like the other directional icons in the SPA. -->
|
|
165
|
+
<UButton
|
|
166
|
+
class="hidden shrink-0 lg:flex"
|
|
167
|
+
:class="railed ? 'justify-center' : 'justify-end'"
|
|
168
|
+
:ui="{ leadingIcon: 'rtl:-scale-x-100' }"
|
|
169
|
+
:icon="railed ? 'i-lucide-panel-left-open' : 'i-lucide-panel-left-close'"
|
|
170
|
+
color="neutral"
|
|
171
|
+
variant="ghost"
|
|
172
|
+
size="xs"
|
|
173
|
+
:aria-label="railed ? t('nav.expandSidebar') : t('nav.collapseSidebar')"
|
|
174
|
+
:title="railed ? t('nav.expandSidebar') : t('nav.collapseSidebar')"
|
|
175
|
+
:aria-expanded="!railed"
|
|
176
|
+
data-testid="sidebar-collapse-toggle"
|
|
177
|
+
@click="uiMode.toggleNav()"
|
|
178
|
+
/>
|
|
179
|
+
|
|
180
|
+
<BoardSwitcher :collapsed="railed" />
|
|
143
181
|
|
|
144
182
|
<div class="contents" @click="onNavAction">
|
|
145
183
|
<!-- Command bar launcher (⌘K) — the primary way to create blocks / pipelines
|
|
146
184
|
and reach every action below. -->
|
|
147
185
|
<button
|
|
148
186
|
type="button"
|
|
149
|
-
class="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-800/60
|
|
187
|
+
class="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-800/60 py-2 text-start text-sm text-slate-400 transition hover:border-slate-500 hover:bg-slate-800"
|
|
188
|
+
:class="railed ? 'justify-center px-0' : 'px-2.5'"
|
|
189
|
+
:aria-label="t('nav.commandBar')"
|
|
190
|
+
:title="railed ? t('nav.commandBar') : undefined"
|
|
191
|
+
data-testid="command-bar-launcher"
|
|
150
192
|
@click="ui.openCommandBar()"
|
|
151
193
|
>
|
|
152
194
|
<UIcon name="i-lucide-search" class="h-4 w-4 shrink-0" />
|
|
153
|
-
<span class="flex-1 truncate">{{ t('nav.commandBar') }}</span>
|
|
154
|
-
<UKbd value="⌘K" />
|
|
195
|
+
<span v-if="!railed" class="flex-1 truncate">{{ t('nav.commandBar') }}</span>
|
|
196
|
+
<UKbd v-if="!railed" value="⌘K" />
|
|
155
197
|
</button>
|
|
156
198
|
|
|
157
199
|
<!-- Sections + items come from the shared nav manifest, already gated by the
|
|
158
|
-
reactive slotFilter (docs/initiatives/modular-vue-adoption.md, slice 1)
|
|
159
|
-
|
|
200
|
+
reactive slotFilter (docs/initiatives/modular-vue-adoption.md, slice 1) — which
|
|
201
|
+
also drops the `advanced` items in basic interface mode. An empty section is
|
|
202
|
+
dropped upstream, so there is no per-section `v-if` here.
|
|
203
|
+
In the rail the section HEADERS go (they'd wrap to nothing at 3.5rem) but the
|
|
204
|
+
separators stay, so the grouping is still legible as icon clusters. -->
|
|
160
205
|
<template v-for="section in sidebarGroups" :key="section.group">
|
|
161
206
|
<USeparator />
|
|
162
207
|
<section>
|
|
163
|
-
<h2
|
|
208
|
+
<h2
|
|
209
|
+
v-if="!railed"
|
|
210
|
+
class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400"
|
|
211
|
+
>
|
|
164
212
|
{{ t(section.labelKey) }}
|
|
165
213
|
</h2>
|
|
166
214
|
<div class="space-y-1.5">
|
|
167
215
|
<UButton
|
|
168
216
|
v-for="item in section.items"
|
|
169
217
|
:key="item.id"
|
|
170
|
-
block
|
|
218
|
+
:block="!railed"
|
|
171
219
|
color="primary"
|
|
172
220
|
variant="soft"
|
|
173
221
|
size="sm"
|
|
174
222
|
:icon="item.icon"
|
|
175
|
-
|
|
223
|
+
:square="railed"
|
|
224
|
+
class="w-full"
|
|
225
|
+
:class="railed ? 'justify-center' : 'justify-start'"
|
|
226
|
+
:aria-label="railed ? t(item.labelKey) : undefined"
|
|
227
|
+
:title="railed ? t(item.labelKey) : undefined"
|
|
176
228
|
:data-testid="item.testId"
|
|
177
229
|
@click="invoke(item)"
|
|
178
230
|
>
|
|
179
|
-
{{ t(item.labelKey) }}
|
|
231
|
+
<span v-if="!railed">{{ t(item.labelKey) }}</span>
|
|
180
232
|
</UButton>
|
|
181
233
|
</div>
|
|
182
234
|
</section>
|
|
@@ -184,8 +236,9 @@ watch(
|
|
|
184
236
|
</div>
|
|
185
237
|
|
|
186
238
|
<div class="mt-auto space-y-2">
|
|
187
|
-
<
|
|
188
|
-
<
|
|
239
|
+
<UiModeSwitcher :collapsed="railed" />
|
|
240
|
+
<LanguageSwitcher :collapsed="railed" />
|
|
241
|
+
<UserMenu :collapsed="railed" />
|
|
189
242
|
</div>
|
|
190
243
|
</aside>
|
|
191
244
|
</template>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
3
|
+
import { computed } from 'vue'
|
|
4
|
+
import { UI_MODES, type UiMode } from '~/utils/uiMode'
|
|
5
|
+
|
|
6
|
+
// Interface-tier picker, shown at the sidebar bottom next to the language switcher (the
|
|
7
|
+
// same shape, deliberately: both are per-user shell preferences rather than board state).
|
|
8
|
+
// Basic mode hides the power-user destinations and the less-used run options; advanced
|
|
9
|
+
// shows everything. The user's pick is persisted client-side — unless the deployment pinned
|
|
10
|
+
// the tier via NUXT_PUBLIC_UI_MODE, in which case the row is a read-only indicator, since
|
|
11
|
+
// writing a preference the resolver ignores would be a lie (see `stores/uiMode.ts`).
|
|
12
|
+
withDefaults(defineProps<{ collapsed?: boolean }>(), { collapsed: false })
|
|
13
|
+
|
|
14
|
+
const uiMode = useUiModeStore()
|
|
15
|
+
const { t } = useI18n()
|
|
16
|
+
|
|
17
|
+
// Static literal `t()` keys, one per UI_MODES member, so the typed-message-keys check sees
|
|
18
|
+
// them (a runtime-assembled key wouldn't be checkable).
|
|
19
|
+
const MODE_LABELS: Record<UiMode, string> = {
|
|
20
|
+
basic: 'uiMode.basic',
|
|
21
|
+
advanced: 'uiMode.advanced',
|
|
22
|
+
}
|
|
23
|
+
const MODE_HINTS: Record<UiMode, string> = {
|
|
24
|
+
basic: 'uiMode.basicHint',
|
|
25
|
+
advanced: 'uiMode.advancedHint',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const currentLabel = computed(() => t(MODE_LABELS[uiMode.mode]))
|
|
29
|
+
const icon = computed(() => (uiMode.isAdvanced ? 'i-lucide-toggle-right' : 'i-lucide-toggle-left'))
|
|
30
|
+
/** Tooltip for the collapsed rail (and the pinned row): mode + why it can't be changed. */
|
|
31
|
+
const title = computed(() =>
|
|
32
|
+
uiMode.envPinned
|
|
33
|
+
? `${t('uiMode.switcher')}: ${currentLabel.value} (${t('uiMode.pinned')})`
|
|
34
|
+
: `${t('uiMode.switcher')}: ${currentLabel.value}`,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const items = computed<DropdownMenuItem[][]>(() => [
|
|
38
|
+
UI_MODES.map((mode) => ({
|
|
39
|
+
label: t(MODE_LABELS[mode]),
|
|
40
|
+
icon: mode === uiMode.mode ? 'i-lucide-check' : undefined,
|
|
41
|
+
onSelect: () => uiMode.setMode(mode),
|
|
42
|
+
})),
|
|
43
|
+
])
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<template>
|
|
47
|
+
<!-- Pinned by the deployment: no dropdown, just the current tier. -->
|
|
48
|
+
<div
|
|
49
|
+
v-if="uiMode.envPinned"
|
|
50
|
+
data-testid="ui-mode-pinned"
|
|
51
|
+
:title="title"
|
|
52
|
+
class="flex w-full items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/40 p-2 text-start"
|
|
53
|
+
:class="collapsed ? 'justify-center' : ''"
|
|
54
|
+
>
|
|
55
|
+
<UIcon :name="icon" class="h-4 w-4 shrink-0 text-slate-500" />
|
|
56
|
+
<div v-if="!collapsed" class="min-w-0 flex-1">
|
|
57
|
+
<div class="truncate text-[10px] uppercase tracking-wide text-slate-500">
|
|
58
|
+
{{ t('uiMode.switcher') }}
|
|
59
|
+
</div>
|
|
60
|
+
<div class="truncate text-xs font-medium text-slate-300">{{ currentLabel }}</div>
|
|
61
|
+
</div>
|
|
62
|
+
<UIcon v-if="!collapsed" name="i-lucide-lock" class="h-3.5 w-3.5 shrink-0 text-slate-600" />
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<UDropdownMenu v-else :items="items" :content="{ side: 'top', align: 'start' }">
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
data-testid="ui-mode-switcher"
|
|
69
|
+
:aria-label="t('uiMode.switcher')"
|
|
70
|
+
:title="title"
|
|
71
|
+
class="flex w-full items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/60 p-2 text-start transition hover:bg-slate-800/60"
|
|
72
|
+
:class="collapsed ? 'justify-center' : ''"
|
|
73
|
+
>
|
|
74
|
+
<UIcon :name="icon" class="h-4 w-4 shrink-0 text-slate-400" />
|
|
75
|
+
<div v-if="!collapsed" class="min-w-0 flex-1">
|
|
76
|
+
<div class="truncate text-[10px] uppercase tracking-wide text-slate-500">
|
|
77
|
+
{{ t('uiMode.switcher') }}
|
|
78
|
+
</div>
|
|
79
|
+
<div class="truncate text-xs font-medium text-white">{{ currentLabel }}</div>
|
|
80
|
+
</div>
|
|
81
|
+
<UIcon v-if="!collapsed" name="i-lucide-chevron-up" class="h-4 w-4 shrink-0 text-slate-500" />
|
|
82
|
+
</button>
|
|
83
|
+
</UDropdownMenu>
|
|
84
|
+
|
|
85
|
+
<!-- The one-line "what this tier gives you", so the choice is self-explanatory. Dropped in
|
|
86
|
+
the collapsed rail, where the tooltip above carries the mode instead. -->
|
|
87
|
+
<p v-if="!collapsed" class="px-1 text-[10px] leading-snug text-slate-500">
|
|
88
|
+
{{ t(MODE_HINTS[uiMode.mode]) }}
|
|
89
|
+
</p>
|
|
90
|
+
</template>
|
|
@@ -4,10 +4,10 @@ import type { StepMetrics } from '~/types/execution'
|
|
|
4
4
|
import {
|
|
5
5
|
formatMs,
|
|
6
6
|
formatTokens,
|
|
7
|
-
freshPromptTokens,
|
|
8
7
|
headroomColor,
|
|
9
8
|
headroomRatio,
|
|
10
9
|
pct,
|
|
10
|
+
totalInputTokens,
|
|
11
11
|
transportRatio,
|
|
12
12
|
} from '~/utils/observability'
|
|
13
13
|
|
|
@@ -23,12 +23,16 @@ defineEmits<{ inspect: [] }>()
|
|
|
23
23
|
const { t } = useI18n()
|
|
24
24
|
|
|
25
25
|
const m = computed(() => props.metrics)
|
|
26
|
-
// The headline "↑" is
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
// The headline "↑" is TOTAL input — fresh + both cache classes — the like-for-like measure of
|
|
27
|
+
// Claude Code's own context gauge, which counts the same buckets because a cached token still
|
|
28
|
+
// occupies the context window (see `totalInputTokens`). The three classes then render as the
|
|
29
|
+
// breakdown, because they are priced an order of magnitude apart in opposite directions: a read
|
|
30
|
+
// is ~0.1x base input, a write 1.25-2x. Volume in the headline, cost in the breakdown — leading
|
|
31
|
+
// with fresh made a 31M-token run read as a 685-token one.
|
|
32
|
+
const totalInput = computed(() => totalInputTokens(m.value))
|
|
33
|
+
const cacheRead = computed(() => m.value.cacheReadTokens ?? 0)
|
|
34
|
+
const cacheWrite = computed(() => m.value.cacheWriteTokens ?? 0)
|
|
35
|
+
const hasCache = computed(() => cacheRead.value > 0 || cacheWrite.value > 0)
|
|
32
36
|
const headroom = computed(() => headroomRatio(m.value))
|
|
33
37
|
const transport = computed(() => transportRatio(m.value))
|
|
34
38
|
const headroomTone = computed(() => headroomColor(headroom.value, m.value.truncatedCalls > 0))
|
|
@@ -53,20 +57,9 @@ const headroomTone = computed(() => headroomColor(headroom.value, m.value.trunca
|
|
|
53
57
|
<span class="text-slate-500">·</span>
|
|
54
58
|
<span
|
|
55
59
|
class="tabular-nums text-slate-400"
|
|
56
|
-
:title="t('observability.metricsBar.
|
|
60
|
+
:title="t('observability.metricsBar.inputCompletionTokens')"
|
|
57
61
|
>
|
|
58
|
-
{{ formatTokens(
|
|
59
|
-
</span>
|
|
60
|
-
<span
|
|
61
|
-
v-if="(m.cachedPromptTokens ?? 0) > 0"
|
|
62
|
-
class="tabular-nums text-emerald-400/80"
|
|
63
|
-
:title="t('observability.metricsBar.cachedTokensHint')"
|
|
64
|
-
>
|
|
65
|
-
{{
|
|
66
|
-
t('observability.metricsBar.cachedTokens', {
|
|
67
|
-
tokens: formatTokens(m.cachedPromptTokens ?? 0),
|
|
68
|
-
})
|
|
69
|
-
}}
|
|
62
|
+
{{ formatTokens(totalInput) }}↑ {{ formatTokens(m.completionTokens) }}↓
|
|
70
63
|
</span>
|
|
71
64
|
<div class="ms-auto flex items-center gap-1">
|
|
72
65
|
<UBadge v-if="m.errors > 0" color="error" variant="subtle" size="sm">
|
|
@@ -83,6 +76,29 @@ const headroomTone = computed(() => headroomColor(headroom.value, m.value.trunca
|
|
|
83
76
|
</div>
|
|
84
77
|
</div>
|
|
85
78
|
|
|
79
|
+
<!-- input breakdown: the headline's three classes, priced an order of magnitude apart -->
|
|
80
|
+
<div v-if="hasCache" class="mt-1.5 flex items-center gap-1.5 text-[11px] tabular-nums">
|
|
81
|
+
<span class="text-slate-400" :title="t('observability.metricsBar.freshHint')">
|
|
82
|
+
{{ t('observability.metricsBar.fresh', { tokens: formatTokens(m.promptTokens) }) }}
|
|
83
|
+
</span>
|
|
84
|
+
<span v-if="cacheRead > 0" class="text-slate-600">·</span>
|
|
85
|
+
<span
|
|
86
|
+
v-if="cacheRead > 0"
|
|
87
|
+
class="text-emerald-400/80"
|
|
88
|
+
:title="t('observability.metricsBar.cacheReadHint')"
|
|
89
|
+
>
|
|
90
|
+
{{ t('observability.metricsBar.cacheRead', { tokens: formatTokens(cacheRead) }) }}
|
|
91
|
+
</span>
|
|
92
|
+
<span v-if="cacheWrite > 0" class="text-slate-600">·</span>
|
|
93
|
+
<span
|
|
94
|
+
v-if="cacheWrite > 0"
|
|
95
|
+
class="text-amber-400/80"
|
|
96
|
+
:title="t('observability.metricsBar.cacheWriteHint')"
|
|
97
|
+
>
|
|
98
|
+
{{ t('observability.metricsBar.cacheWrite', { tokens: formatTokens(cacheWrite) }) }}
|
|
99
|
+
</span>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
86
102
|
<!-- output-limit headroom -->
|
|
87
103
|
<div v-if="headroom !== null" class="mt-2">
|
|
88
104
|
<div class="flex items-center justify-between text-[11px]">
|