@cat-factory/app 0.167.1 → 0.168.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +45 -2
  2. package/app/components/auth/UserMenu.vue +9 -2
  3. package/app/components/board/AddTaskModal.vue +28 -9
  4. package/app/components/layout/BoardSwitcher.vue +21 -4
  5. package/app/components/layout/CommandBar.vue +3 -1
  6. package/app/components/layout/LanguageSwitcher.vue +9 -2
  7. package/app/components/layout/SideBar.vue +68 -15
  8. package/app/components/layout/UiModeSwitcher.vue +90 -0
  9. package/app/components/panels/inspector/TaskRunSettings.vue +35 -8
  10. package/app/components/tasks/BugHuntModal.vue +3 -1
  11. package/app/components/tasks/ContextIssuePicker.vue +22 -5
  12. package/app/components/tasks/TaskImportModal.vue +1 -1
  13. package/app/composables/api/errors.ts +16 -0
  14. package/app/composables/api/tasks.ts +5 -2
  15. package/app/composables/useNavContributions.ts +3 -0
  16. package/app/docs/consumer-extensions.md +6 -1
  17. package/app/modular/nav-contributions.spec.ts +59 -1
  18. package/app/modular/nav-contributions.ts +62 -4
  19. package/app/modular/nav-gates.ts +7 -1
  20. package/app/modular/registry.spec.ts +1 -0
  21. package/app/stores/bugHunt.ts +2 -10
  22. package/app/stores/tasks.ts +7 -4
  23. package/app/stores/uiMode.spec.ts +151 -0
  24. package/app/stores/uiMode.ts +88 -0
  25. package/app/utils/uiMode.spec.ts +67 -0
  26. package/app/utils/uiMode.ts +71 -0
  27. package/i18n/locales/de.json +17 -4
  28. package/i18n/locales/en.json +20 -4
  29. package/i18n/locales/es.json +17 -4
  30. package/i18n/locales/fr.json +17 -4
  31. package/i18n/locales/he.json +17 -4
  32. package/i18n/locales/it.json +17 -4
  33. package/i18n/locales/ja.json +17 -4
  34. package/i18n/locales/pl.json +17 -4
  35. package/i18n/locales/tr.json +17 -4
  36. package/i18n/locales/uk.json +17 -4
  37. package/nuxt.config.ts +5 -0
  38. 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 `SpendWarningBanner`, and the toolbar (zoom, LOD, decision
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
- <div class="grid grid-cols-2 gap-3">
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
- <UFormField v-if="!isReview" :label="t('board.addTask.mergePolicy')">
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
- <div class="space-y-2">
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 ?? undefined"
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 name="i-lucide-chevron-down" class="ms-auto h-4 w-4 shrink-0 text-slate-500" />
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 ? 'translate-x-0' : '-translate-x-full rtl:translate-x-full lg:translate-x-0'
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
- <BoardSwitcher />
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 px-2.5 py-2 text-start text-sm text-slate-400 transition hover:border-slate-500 hover:bg-slate-800"
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). An
159
- empty section is dropped upstream, so there is no per-section `v-if` here. -->
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 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
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
- class="justify-start"
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
- <LanguageSwitcher />
188
- <UserMenu />
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,6 +4,7 @@ import { connectionNeighborIds } from '@cat-factory/contracts'
4
4
  import type { Block } from '~/types/domain'
5
5
  import type { WritebackOverride } from '~/types/tracker'
6
6
  import { pipelineAllowedForManualStart } from '~/utils/pipeline'
7
+ import { showOverrideField } from '~/utils/uiMode'
7
8
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
8
9
  import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
9
10
  import TaskAprioriBranches from '~/components/panels/inspector/TaskAprioriBranches.vue'
@@ -18,6 +19,23 @@ const pipelines = usePipelinesStore()
18
19
  const accounts = useAccountsStore()
19
20
  const tracker = useTrackerStore()
20
21
  const ui = useUiStore()
22
+ // Interface tier. Basic mode hides only what OVERRIDES something already decided elsewhere:
23
+ // a workspace-level default (merge policy, model preset, tracker writeback) or an
24
+ // engine-inferred value (the technical/business label). That bound is what makes hiding safe
25
+ // — what's left is exactly the value the hidden field would have displayed, so a basic-mode
26
+ // task behaves identically. Everything that carries an input NOTHING else supplies stays in
27
+ // both tiers, however advanced it feels: the pipeline, the involved services, the apriori
28
+ // branches, the responsible person, auto-start. The same split applies at creation time in
29
+ // `AddTaskModal`.
30
+ //
31
+ // Unlike creation — which always starts from the defaults — an EXISTING block can already
32
+ // carry an override (a teammate on the advanced tier, the API, or this user before they
33
+ // switched down). Hiding it then would break the very bound above: the task would run on
34
+ // settings a basic-mode user can neither see nor clear, and no other inspector panel shows
35
+ // them. So each group below asks `showOverrideField`, which keeps the field whenever it is
36
+ // actually set. Basic mode stays clean for the common (unset) case without ever concealing a
37
+ // deviation.
38
+ const uiMode = useUiModeStore()
21
39
  const { ready, unavailableInPreset } = useAiReadiness()
22
40
  const { t, n } = useI18n()
23
41
 
@@ -290,8 +308,8 @@ const technicalLabel = computed(() => {
290
308
  </p>
291
309
  </div>
292
310
 
293
- <!-- merge policy preset -->
294
- <div>
311
+ <!-- merge policy preset (advanced, or basic with an override already set) -->
312
+ <div v-if="showOverrideField(uiMode.isAdvanced, block.riskPolicyId)">
295
313
  <div class="mb-1 flex items-center justify-between">
296
314
  <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
297
315
  {{ t('inspector.runSettings.mergePolicy') }}
@@ -348,8 +366,8 @@ const technicalLabel = computed(() => {
348
366
  </p>
349
367
  </div>
350
368
 
351
- <!-- model preset -->
352
- <div>
369
+ <!-- model preset (advanced, or basic with an override already set) -->
370
+ <div v-if="showOverrideField(uiMode.isAdvanced, block.modelPresetId)">
353
371
  <div class="mb-1 flex items-center justify-between">
354
372
  <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
355
373
  {{ t('inspector.runSettings.modelPreset') }}
@@ -422,8 +440,8 @@ const technicalLabel = computed(() => {
422
440
  </p>
423
441
  </div>
424
442
 
425
- <!-- technical label (tri-state) -->
426
- <div>
443
+ <!-- technical label (tri-state) — unset lets the engine infer it -->
444
+ <div v-if="showOverrideField(uiMode.isAdvanced, block.technical)">
427
445
  <div class="mb-1 flex items-center justify-between">
428
446
  <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
429
447
  {{ t('inspector.runSettings.taskKind') }}
@@ -498,8 +516,17 @@ const technicalLabel = computed(() => {
498
516
  <!-- reference repositories: read-only repos the doc-writer reads while drafting (doc tasks) -->
499
517
  <DocReferenceRepos v-if="block.taskType === 'document'" :block="block" />
500
518
 
501
- <!-- issue-tracker writeback overrides -->
502
- <div>
519
+ <!-- issue-tracker writeback overrides (any one set reveals the whole group) -->
520
+ <div
521
+ v-if="
522
+ showOverrideField(
523
+ uiMode.isAdvanced,
524
+ block.trackerCommentOnPrOpen,
525
+ block.trackerResolveOnMerge,
526
+ block.trackerQuestionsOnPark,
527
+ )
528
+ "
529
+ >
503
530
  <div class="mb-1 flex items-center justify-between">
504
531
  <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
505
532
  {{ t('inspector.runSettings.issueWriteback') }}
@@ -10,6 +10,7 @@
10
10
  // unavailable or failed still shows its candidates (flagged as unassessed, since the scan is
11
11
  // useful on its own), and a scan that hit its cap says so — a silently shortened list reads
12
12
  // exactly like an exhaustive one.
13
+ import type { TaskSourceReadReason } from '@cat-factory/contracts'
13
14
  import type {
14
15
  BugHuntAnalysisStatus,
15
16
  BugHuntCandidate,
@@ -69,8 +70,9 @@ const boardItems = computed(() =>
69
70
  * backend's reason code, not on "any error": an unreachable tracker or an expired token would
70
71
  * otherwise present as a free-text field that simply moves the same failure to the next click.
71
72
  */
73
+ const BOARDS_UNSUPPORTED: TaskSourceReadReason = 'boards_unsupported'
72
74
  const boardIsFreeText = computed(
73
- () => !hunt.boardsLoading && hunt.boardsErrorReason === 'boards_unsupported',
75
+ () => !hunt.boardsLoading && hunt.boardsErrorReason === BOARDS_UNSUPPORTED,
74
76
  )
75
77
 
76
78
  /** A board read that failed for a reason the user has to fix — shown, never silently swallowed. */