@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
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
WebSearchProvider,
|
|
9
9
|
} from '~/types/execution'
|
|
10
10
|
import { agentKindMeta } from '~/utils/catalog'
|
|
11
|
-
import { formatMs, formatTokens,
|
|
11
|
+
import { formatMs, formatTokens, pct, totalInputTokens } from '~/utils/observability'
|
|
12
12
|
|
|
13
13
|
// Drill-down overlay for a run's LLM activity. Opened via
|
|
14
14
|
// `ui.openObservability(instanceId)` from a step surface; loads the full per-call
|
|
@@ -126,15 +126,18 @@ const totals = computed(() => {
|
|
|
126
126
|
const upstreamMs = sum(c, (x) => x.upstreamMs)
|
|
127
127
|
const overheadMs = sum(c, (x) => x.overheadMs)
|
|
128
128
|
const total = upstreamMs + overheadMs
|
|
129
|
+
// The three input classes are orthogonal at the source, so they are simply summed —
|
|
130
|
+
// `promptTokens` IS the fresh figure and needs no heuristic to recover it; the headline is
|
|
131
|
+
// their TOTAL (see `totalInputTokens` — the like-for-like Claude Code context gauge).
|
|
129
132
|
const promptTokens = sum(c, (x) => x.promptTokens)
|
|
130
|
-
const
|
|
133
|
+
const cacheReadTokens = sum(c, (x) => x.cacheReadTokens)
|
|
134
|
+
const cacheWriteTokens = sum(c, (x) => x.cacheWriteTokens)
|
|
131
135
|
return {
|
|
132
136
|
calls: c.length,
|
|
133
137
|
promptTokens,
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
freshPromptTokens: freshPromptTokens(promptTokens, cachedPromptTokens),
|
|
138
|
+
cacheReadTokens,
|
|
139
|
+
cacheWriteTokens,
|
|
140
|
+
inputTokens: totalInputTokens({ promptTokens, cacheReadTokens, cacheWriteTokens }),
|
|
138
141
|
completionTokens: sum(c, (x) => x.completionTokens),
|
|
139
142
|
upstreamMs,
|
|
140
143
|
overheadMs,
|
|
@@ -290,18 +293,47 @@ function exportJson() {
|
|
|
290
293
|
{{ t('observability.summary.tokensInOut') }}
|
|
291
294
|
</dt>
|
|
292
295
|
<dd class="mt-0.5 tabular-nums text-slate-200">
|
|
293
|
-
|
|
294
|
-
|
|
296
|
+
<span :title="t('observability.summary.inputTokensHint')">
|
|
297
|
+
{{ formatTokens(totals.inputTokens) }} /
|
|
298
|
+
{{ formatTokens(totals.completionTokens) }}
|
|
299
|
+
</span>
|
|
295
300
|
<span
|
|
296
|
-
v-if="totals.
|
|
297
|
-
class="
|
|
301
|
+
v-if="totals.cacheReadTokens > 0 || totals.cacheWriteTokens > 0"
|
|
302
|
+
class="mt-0.5 block text-[11px]"
|
|
298
303
|
>
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
304
|
+
<span class="text-slate-400" :title="t('observability.summary.freshHint')">
|
|
305
|
+
{{
|
|
306
|
+
t('observability.summary.fresh', {
|
|
307
|
+
tokens: formatTokens(totals.promptTokens),
|
|
308
|
+
})
|
|
309
|
+
}}
|
|
310
|
+
</span>
|
|
311
|
+
<template v-if="totals.cacheReadTokens > 0">
|
|
312
|
+
<span class="text-slate-600"> · </span>
|
|
313
|
+
<span
|
|
314
|
+
class="text-emerald-400/80"
|
|
315
|
+
:title="t('observability.summary.cacheReadHint')"
|
|
316
|
+
>
|
|
317
|
+
{{
|
|
318
|
+
t('observability.summary.cacheRead', {
|
|
319
|
+
tokens: formatTokens(totals.cacheReadTokens),
|
|
320
|
+
})
|
|
321
|
+
}}
|
|
322
|
+
</span>
|
|
323
|
+
</template>
|
|
324
|
+
<template v-if="totals.cacheWriteTokens > 0">
|
|
325
|
+
<span class="text-slate-600"> · </span>
|
|
326
|
+
<span
|
|
327
|
+
class="text-amber-400/80"
|
|
328
|
+
:title="t('observability.summary.cacheWriteHint')"
|
|
329
|
+
>
|
|
330
|
+
{{
|
|
331
|
+
t('observability.summary.cacheWrite', {
|
|
332
|
+
tokens: formatTokens(totals.cacheWriteTokens),
|
|
333
|
+
})
|
|
334
|
+
}}
|
|
335
|
+
</span>
|
|
336
|
+
</template>
|
|
305
337
|
</span>
|
|
306
338
|
</dd>
|
|
307
339
|
</div>
|
|
@@ -418,12 +450,14 @@ function exportJson() {
|
|
|
418
450
|
<span
|
|
419
451
|
:title="
|
|
420
452
|
t('observability.call.tokensTitle', {
|
|
421
|
-
|
|
453
|
+
input: totalInputTokens(c),
|
|
454
|
+
fresh: c.promptTokens,
|
|
422
455
|
completion: c.completionTokens,
|
|
423
456
|
})
|
|
424
457
|
"
|
|
425
458
|
>
|
|
426
|
-
{{ formatTokens(c
|
|
459
|
+
{{ formatTokens(totalInputTokens(c)) }}↑
|
|
460
|
+
{{ formatTokens(c.completionTokens) }}↓
|
|
427
461
|
</span>
|
|
428
462
|
<span
|
|
429
463
|
v-if="headroomOf(c) !== null"
|
|
@@ -467,11 +501,14 @@ function exportJson() {
|
|
|
467
501
|
<span v-if="c.requestMaxTokens != null">{{
|
|
468
502
|
t('observability.call.maxTokens', { value: c.requestMaxTokens })
|
|
469
503
|
}}</span>
|
|
470
|
-
<span v-if="c.
|
|
471
|
-
t('observability.call.
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
})
|
|
504
|
+
<span v-if="c.cacheReadTokens > 0 || c.cacheWriteTokens > 0">{{
|
|
505
|
+
t('observability.call.fresh', { tokens: c.promptTokens })
|
|
506
|
+
}}</span>
|
|
507
|
+
<span v-if="c.cacheReadTokens > 0" class="text-emerald-400">{{
|
|
508
|
+
t('observability.call.cacheRead', { tokens: c.cacheReadTokens })
|
|
509
|
+
}}</span>
|
|
510
|
+
<span v-if="c.cacheWriteTokens > 0" class="text-amber-400">{{
|
|
511
|
+
t('observability.call.cacheWrite', { tokens: c.cacheWriteTokens })
|
|
475
512
|
}}</span>
|
|
476
513
|
<span>{{
|
|
477
514
|
t('observability.call.total', { duration: formatMs(c.totalMs) })
|
|
@@ -4,6 +4,7 @@ import { connectionNeighborIds } from '@cat-factory/contracts'
|
|
|
4
4
|
import type { Block } from '~/types/domain'
|
|
5
5
|
import type { WritebackOverride } from '~/types/tracker'
|
|
6
6
|
import { pipelineAllowedForManualStart } from '~/utils/pipeline'
|
|
7
|
+
import { showOverrideField } from '~/utils/uiMode'
|
|
7
8
|
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
8
9
|
import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
|
|
9
10
|
import TaskAprioriBranches from '~/components/panels/inspector/TaskAprioriBranches.vue'
|
|
@@ -18,6 +19,23 @@ const pipelines = usePipelinesStore()
|
|
|
18
19
|
const accounts = useAccountsStore()
|
|
19
20
|
const tracker = useTrackerStore()
|
|
20
21
|
const ui = useUiStore()
|
|
22
|
+
// Interface tier. Basic mode hides only what OVERRIDES something already decided elsewhere:
|
|
23
|
+
// a workspace-level default (merge policy, model preset, tracker writeback) or an
|
|
24
|
+
// engine-inferred value (the technical/business label). That bound is what makes hiding safe
|
|
25
|
+
// — what's left is exactly the value the hidden field would have displayed, so a basic-mode
|
|
26
|
+
// task behaves identically. Everything that carries an input NOTHING else supplies stays in
|
|
27
|
+
// both tiers, however advanced it feels: the pipeline, the involved services, the apriori
|
|
28
|
+
// branches, the responsible person, auto-start. The same split applies at creation time in
|
|
29
|
+
// `AddTaskModal`.
|
|
30
|
+
//
|
|
31
|
+
// Unlike creation — which always starts from the defaults — an EXISTING block can already
|
|
32
|
+
// carry an override (a teammate on the advanced tier, the API, or this user before they
|
|
33
|
+
// switched down). Hiding it then would break the very bound above: the task would run on
|
|
34
|
+
// settings a basic-mode user can neither see nor clear, and no other inspector panel shows
|
|
35
|
+
// them. So each group below asks `showOverrideField`, which keeps the field whenever it is
|
|
36
|
+
// actually set. Basic mode stays clean for the common (unset) case without ever concealing a
|
|
37
|
+
// deviation.
|
|
38
|
+
const uiMode = useUiModeStore()
|
|
21
39
|
const { ready, unavailableInPreset } = useAiReadiness()
|
|
22
40
|
const { t, n } = useI18n()
|
|
23
41
|
|
|
@@ -290,8 +308,8 @@ const technicalLabel = computed(() => {
|
|
|
290
308
|
</p>
|
|
291
309
|
</div>
|
|
292
310
|
|
|
293
|
-
<!-- merge policy preset -->
|
|
294
|
-
<div>
|
|
311
|
+
<!-- merge policy preset (advanced, or basic with an override already set) -->
|
|
312
|
+
<div v-if="showOverrideField(uiMode.isAdvanced, block.riskPolicyId)">
|
|
295
313
|
<div class="mb-1 flex items-center justify-between">
|
|
296
314
|
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
297
315
|
{{ t('inspector.runSettings.mergePolicy') }}
|
|
@@ -348,8 +366,8 @@ const technicalLabel = computed(() => {
|
|
|
348
366
|
</p>
|
|
349
367
|
</div>
|
|
350
368
|
|
|
351
|
-
<!-- model preset -->
|
|
352
|
-
<div>
|
|
369
|
+
<!-- model preset (advanced, or basic with an override already set) -->
|
|
370
|
+
<div v-if="showOverrideField(uiMode.isAdvanced, block.modelPresetId)">
|
|
353
371
|
<div class="mb-1 flex items-center justify-between">
|
|
354
372
|
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
355
373
|
{{ t('inspector.runSettings.modelPreset') }}
|
|
@@ -422,8 +440,8 @@ const technicalLabel = computed(() => {
|
|
|
422
440
|
</p>
|
|
423
441
|
</div>
|
|
424
442
|
|
|
425
|
-
<!-- technical label (tri-state) -->
|
|
426
|
-
<div>
|
|
443
|
+
<!-- technical label (tri-state) — unset lets the engine infer it -->
|
|
444
|
+
<div v-if="showOverrideField(uiMode.isAdvanced, block.technical)">
|
|
427
445
|
<div class="mb-1 flex items-center justify-between">
|
|
428
446
|
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
429
447
|
{{ t('inspector.runSettings.taskKind') }}
|
|
@@ -498,8 +516,17 @@ const technicalLabel = computed(() => {
|
|
|
498
516
|
<!-- reference repositories: read-only repos the doc-writer reads while drafting (doc tasks) -->
|
|
499
517
|
<DocReferenceRepos v-if="block.taskType === 'document'" :block="block" />
|
|
500
518
|
|
|
501
|
-
<!-- issue-tracker writeback overrides -->
|
|
502
|
-
<div
|
|
519
|
+
<!-- issue-tracker writeback overrides (any one set reveals the whole group) -->
|
|
520
|
+
<div
|
|
521
|
+
v-if="
|
|
522
|
+
showOverrideField(
|
|
523
|
+
uiMode.isAdvanced,
|
|
524
|
+
block.trackerCommentOnPrOpen,
|
|
525
|
+
block.trackerResolveOnMerge,
|
|
526
|
+
block.trackerQuestionsOnPark,
|
|
527
|
+
)
|
|
528
|
+
"
|
|
529
|
+
>
|
|
503
530
|
<div class="mb-1 flex items-center justify-between">
|
|
504
531
|
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
505
532
|
{{ t('inspector.runSettings.issueWriteback') }}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// unavailable or failed still shows its candidates (flagged as unassessed, since the scan is
|
|
11
11
|
// useful on its own), and a scan that hit its cap says so — a silently shortened list reads
|
|
12
12
|
// exactly like an exhaustive one.
|
|
13
|
+
import type { TaskSourceReadReason } from '@cat-factory/contracts'
|
|
13
14
|
import type {
|
|
14
15
|
BugHuntAnalysisStatus,
|
|
15
16
|
BugHuntCandidate,
|
|
@@ -69,8 +70,9 @@ const boardItems = computed(() =>
|
|
|
69
70
|
* backend's reason code, not on "any error": an unreachable tracker or an expired token would
|
|
70
71
|
* otherwise present as a free-text field that simply moves the same failure to the next click.
|
|
71
72
|
*/
|
|
73
|
+
const BOARDS_UNSUPPORTED: TaskSourceReadReason = 'boards_unsupported'
|
|
72
74
|
const boardIsFreeText = computed(
|
|
73
|
-
() => !hunt.boardsLoading && hunt.boardsErrorReason ===
|
|
75
|
+
() => !hunt.boardsLoading && hunt.boardsErrorReason === BOARDS_UNSUPPORTED,
|
|
74
76
|
)
|
|
75
77
|
|
|
76
78
|
/** A board read that failed for a reason the user has to fix — shown, never silently swallowed. */
|
|
@@ -7,12 +7,19 @@
|
|
|
7
7
|
// them once the block exists (see useContextLinking). A search hit / pasted ref
|
|
8
8
|
// carries `needsImport: true` so it's fetched + persisted before linking.
|
|
9
9
|
//
|
|
10
|
+
// Search results are always confined to ONE repository (`scopeBlockId`'s service).
|
|
11
|
+
// An issue in another repo is reachable only by pasting its URL, which the explicit
|
|
12
|
+
// "attach by reference" row below imports directly — it never comes back as a hit,
|
|
13
|
+
// so what the search offers is exactly what the service owns.
|
|
14
|
+
//
|
|
10
15
|
// The tracker being searched is ALWAYS on screen (even when the workspace offers
|
|
11
16
|
// exactly one), and its menu doubles as the "add a tracker" affordance: attaching a
|
|
12
17
|
// context issue is where a missing integration is discovered, and the connect modal
|
|
13
18
|
// opens over the caller's form rather than navigating away from it.
|
|
14
19
|
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
20
|
+
import type { TaskSourceReadReason } from '@cat-factory/contracts'
|
|
15
21
|
import type { SourceTask, TaskSearchResult, TaskSourceKind } from '~/types/domain'
|
|
22
|
+
import { apiErrorReason } from '~/composables/api/errors'
|
|
16
23
|
import EmptyState from '~/components/common/EmptyState.vue'
|
|
17
24
|
import { buildSourceChoices, reconcileSource } from '~/components/tasks/ContextIssuePicker.logic'
|
|
18
25
|
|
|
@@ -21,10 +28,13 @@ const props = defineProps<{
|
|
|
21
28
|
chosenKeys?: string[]
|
|
22
29
|
/**
|
|
23
30
|
* The block the picker is attaching context to (a service frame or a task/module
|
|
24
|
-
* under one).
|
|
25
|
-
* in-repo and a
|
|
31
|
+
* under one). REQUIRED: it is what scopes a GitHub search to that service's linked
|
|
32
|
+
* repo, so hits stay in-repo and a bare issue number resolves to the exact issue.
|
|
33
|
+
* A search with no scope reaches every repository the deployment's credential can
|
|
34
|
+
* see (under a PAT, all of public GitHub), so there is no unscoped mode — a caller
|
|
35
|
+
* that has no block yet must not render the picker.
|
|
26
36
|
*/
|
|
27
|
-
scopeBlockId
|
|
37
|
+
scopeBlockId: string
|
|
28
38
|
/**
|
|
29
39
|
* Controlled source: when provided the parent owns the selected tracker (via
|
|
30
40
|
* `v-model:source`); omitted, the picker manages it internally (the add-task case).
|
|
@@ -147,7 +157,14 @@ async function runSearch() {
|
|
|
147
157
|
results.value = await tasks.search(source.value, q, props.scopeBlockId)
|
|
148
158
|
} catch (e) {
|
|
149
159
|
results.value = []
|
|
150
|
-
|
|
160
|
+
// "This service has no repo" is the one failure with an action attached, so it gets its
|
|
161
|
+
// own localized copy off the backend's machine-readable reason rather than the raw
|
|
162
|
+
// message (CLAUDE.md "Backend strings"). Anything else keeps the generic wording.
|
|
163
|
+
const notLinked: TaskSourceReadReason = 'repo_not_linked'
|
|
164
|
+
searchError.value =
|
|
165
|
+
apiErrorReason(e) === notLinked
|
|
166
|
+
? t('tasks.picker.searchNeedsRepo')
|
|
167
|
+
: t('tasks.picker.searchFailed', { error: e instanceof Error ? e.message : String(e) })
|
|
151
168
|
} finally {
|
|
152
169
|
searching.value = false
|
|
153
170
|
}
|
|
@@ -295,7 +312,7 @@ onMounted(() => {
|
|
|
295
312
|
/>
|
|
296
313
|
|
|
297
314
|
<p v-if="searchError" class="px-1 text-[11px] text-amber-400">
|
|
298
|
-
{{
|
|
315
|
+
{{ searchError }}
|
|
299
316
|
</p>
|
|
300
317
|
|
|
301
318
|
<div class="max-h-56 space-y-0.5 overflow-y-auto">
|
|
@@ -163,7 +163,7 @@ async function doSpawnEpic() {
|
|
|
163
163
|
or paste a URL/key — choosing one opens the prefilled add-task form. The
|
|
164
164
|
search is scoped to the chosen container's repo (so a GitHub search stays
|
|
165
165
|
in that service's repo and a pasted URL / bare number resolves there). -->
|
|
166
|
-
<UFormField :label="t('tasks.import.searchIssues')">
|
|
166
|
+
<UFormField v-if="containerId" :label="t('tasks.import.searchIssues')">
|
|
167
167
|
<ContextIssuePicker
|
|
168
168
|
v-model:source="source"
|
|
169
169
|
:scope-block-id="containerId"
|
|
@@ -54,6 +54,22 @@ export function apiErrorEnvelope(error: unknown): ApiErrorEnvelope | undefined {
|
|
|
54
54
|
return envelopeOf(e?.body) ?? envelopeOf(e?.data)
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The backend's machine-readable `error.details.reason` code, when it sent one.
|
|
59
|
+
*
|
|
60
|
+
* This is the client half of the "backend strings" contract (see CLAUDE.md): a localizable
|
|
61
|
+
* server condition emits a stable code and the SPA maps it to a message key, with the raw
|
|
62
|
+
* prose `message` as the untranslated last resort. Callers compare the result against a union
|
|
63
|
+
* imported from `@cat-factory/contracts`, so a renamed code fails the typecheck instead of
|
|
64
|
+
* silently falling through to the generic wording.
|
|
65
|
+
*/
|
|
66
|
+
export function apiErrorReason(error: unknown): string | null {
|
|
67
|
+
const details = apiErrorEnvelope(error)?.details
|
|
68
|
+
if (!details || typeof details !== 'object') return null
|
|
69
|
+
const reason = (details as Record<string, unknown>).reason
|
|
70
|
+
return typeof reason === 'string' ? reason : null
|
|
71
|
+
}
|
|
72
|
+
|
|
57
73
|
/** The HTTP status of a thrown API error, when present (contract client or `$fetch`). */
|
|
58
74
|
export function apiErrorStatus(error: unknown): number | undefined {
|
|
59
75
|
const e = error as { statusCode?: unknown; status?: unknown }
|
|
@@ -71,16 +71,19 @@ export function tasksApi({ send, ws }: ApiContext) {
|
|
|
71
71
|
importTask: (workspaceId: string, source: TaskSourceKind, body: { ref: string }) =>
|
|
72
72
|
send(importTaskContract, { pathPrefix: ws(workspaceId), pathParams: { source }, body }),
|
|
73
73
|
|
|
74
|
+
// `blockId` is required: it is what scopes a GitHub search to the block's service
|
|
75
|
+
// repo, and the backend refuses an unscoped one (it would reach every repository the
|
|
76
|
+
// deployment's credential can see).
|
|
74
77
|
searchTaskSource: (
|
|
75
78
|
workspaceId: string,
|
|
76
79
|
source: TaskSourceKind,
|
|
77
80
|
query: string,
|
|
78
|
-
blockId
|
|
81
|
+
blockId: string,
|
|
79
82
|
) =>
|
|
80
83
|
send(searchTasksContract, {
|
|
81
84
|
pathPrefix: ws(workspaceId),
|
|
82
85
|
pathParams: { source },
|
|
83
|
-
body: { query,
|
|
86
|
+
body: { query, blockId },
|
|
84
87
|
}),
|
|
85
88
|
|
|
86
89
|
linkTask: (
|
|
@@ -47,6 +47,9 @@ export function useNavContributions() {
|
|
|
47
47
|
operatorDashboard: () => ui.openOperatorDashboard(),
|
|
48
48
|
reports: () => ui.openReports(),
|
|
49
49
|
shortcuts: () => ui.openShortcutsHelp(),
|
|
50
|
+
// No-op under an env pin (`setMode` refuses), so the palette entry matches the sidebar
|
|
51
|
+
// switcher's read-only state rather than pretending to flip a tier the resolver fixes.
|
|
52
|
+
toggleUiMode: () => useUiModeStore().toggleMode(),
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
/** Run a contribution's action (consumer `run` closure wins over the id map). */
|
|
@@ -61,12 +61,17 @@ export default defineNuxtPlugin(() => {
|
|
|
61
61
|
| Run-detail windows | `resultViews` | `{ id: '<ns>:<name>', component }` | `StepResultViewHost` via `dispatchStepView` |
|
|
62
62
|
| Agent kinds (palette data) | `agentKinds` | `{ kind, container, presentation: { label, icon, color, description, category?, resultView? } }` | agents store merge → `agentKindMeta` |
|
|
63
63
|
| Custom task types | `taskTypes` | `{ taskType: '<ns>:<name>', presentation, fields?, defaultPipelineId?, formPanel? }` | `AddTaskModal` picker/fields + `TaskCard` badge (via `taskTypeMeta`) |
|
|
64
|
-
| Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, run, sidebar?, command?, toolbar? }`
|
|
64
|
+
| Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, advanced?, run, sidebar?, command?, toolbar? }` | the three shells via `useNavContributions` |
|
|
65
65
|
| Inspector body panels | `inspectorPanels` | `{ id, component, when(block), order }` (`PanelEntry<Block>`) | `<PanelsOutlet>` in `InspectorPanel` |
|
|
66
66
|
| Top-level overlays | `appOverlays` | `{ id: '<ns>:<name>', component }` | `<AppOverlayHost>` via `useAppOverlays().open(id)` |
|
|
67
67
|
| Multi-step wizards | (journeys) | `registerJourney` + step modules | `<JourneyHost>` / `<JourneyOutlet>` |
|
|
68
68
|
| Locale strings | (i18n) | `i18n/locales/*.json` in the deployment | `@nuxtjs/i18n` layer deep-merge |
|
|
69
69
|
|
|
70
|
+
A `nav` entry may also declare `advanced: true`, which hides it in **basic** interface mode
|
|
71
|
+
(the shipped default) exactly as it does for the first-party destinations — see
|
|
72
|
+
[the layer README](../../README.md#interface-modes-basic--advanced). Use it for a power-user
|
|
73
|
+
destination; the flag is independent of `gate`, so both must pass for the item to render.
|
|
74
|
+
|
|
70
75
|
### Run-detail windows (`resultViews` + `agentKinds`)
|
|
71
76
|
|
|
72
77
|
Backend data selects a frontend component, joined by a namespaced id:
|
|
@@ -32,6 +32,9 @@ const NO_GATES: NavGates = {
|
|
|
32
32
|
infrastructureAvailable: false,
|
|
33
33
|
accountsEnabled: false,
|
|
34
34
|
isAccountAdmin: false,
|
|
35
|
+
// The permission axis is what these cases vary; keep the interface tier at `advanced`
|
|
36
|
+
// so a dropped item is unambiguously an RBAC/availability drop, not a tier drop.
|
|
37
|
+
advancedMode: true,
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
const ALL_GATES: NavGates = {
|
|
@@ -43,6 +46,7 @@ const ALL_GATES: NavGates = {
|
|
|
43
46
|
infrastructureAvailable: true,
|
|
44
47
|
accountsEnabled: true,
|
|
45
48
|
isAccountAdmin: true,
|
|
49
|
+
advancedMode: true,
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
const slots = (): AppSlots => ({
|
|
@@ -84,6 +88,58 @@ describe('navSlotFilter', () => {
|
|
|
84
88
|
const kept = ids(navSlotFilter(slots(), {}))
|
|
85
89
|
expect(kept.sort()).toEqual(NAV_CONTRIBUTIONS.map((i) => i.id).sort())
|
|
86
90
|
})
|
|
91
|
+
|
|
92
|
+
it('drops every advanced destination in basic interface mode', () => {
|
|
93
|
+
const gates: NavGates = { ...ALL_GATES, advancedMode: false }
|
|
94
|
+
const kept = ids(navSlotFilter(slots(), { gates }))
|
|
95
|
+
const basicOnly = NAV_CONTRIBUTIONS.filter((i) => !i.advanced).map((i) => i.id)
|
|
96
|
+
expect(kept.sort()).toEqual(basicOnly.sort())
|
|
97
|
+
// The everyday surface survives...
|
|
98
|
+
expect(kept).toContain('add-from-repo')
|
|
99
|
+
expect(kept).toContain('integrations-hub')
|
|
100
|
+
expect(kept).toContain('workspace-settings')
|
|
101
|
+
expect(kept).toContain('model-config')
|
|
102
|
+
// ...and the authoring / operator surfaces don't.
|
|
103
|
+
expect(kept).not.toContain('build-pipeline')
|
|
104
|
+
expect(kept).not.toContain('fragments')
|
|
105
|
+
expect(kept).not.toContain('sandbox')
|
|
106
|
+
expect(kept).not.toContain('operator-dashboard')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('keeps the tier switch itself reachable in basic mode', () => {
|
|
110
|
+
// Basic is the shipped default, so this palette entry is how a user who never finds the
|
|
111
|
+
// (icon-only, in the basic rail) sidebar switcher gets to the advanced half at all. Marking
|
|
112
|
+
// it `advanced` would hide the way back from exactly the tier that needs it.
|
|
113
|
+
const uiModeItem = NAV_CONTRIBUTIONS.find((i) => i.id === 'ui-mode')
|
|
114
|
+
expect(uiModeItem?.advanced).toBeUndefined()
|
|
115
|
+
expect(uiModeItem?.gate).toBeUndefined()
|
|
116
|
+
|
|
117
|
+
const gates: NavGates = { ...NO_GATES, advancedMode: false }
|
|
118
|
+
expect(ids(navSlotFilter(slots(), { gates }))).toContain('ui-mode')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('keeps the tier and the permission axes independent — both must pass', () => {
|
|
122
|
+
// `build-pipeline` is advanced AND needs board.write: neither axis alone reveals it.
|
|
123
|
+
const advancedOnly: NavGates = { ...NO_GATES, advancedMode: true }
|
|
124
|
+
expect(ids(navSlotFilter(slots(), { gates: advancedOnly }))).not.toContain('build-pipeline')
|
|
125
|
+
|
|
126
|
+
const permissionOnly: NavGates = { ...NO_GATES, canWriteBoard: true, advancedMode: false }
|
|
127
|
+
expect(ids(navSlotFilter(slots(), { gates: permissionOnly }))).not.toContain('build-pipeline')
|
|
128
|
+
|
|
129
|
+
const both: NavGates = { ...NO_GATES, canWriteBoard: true, advancedMode: true }
|
|
130
|
+
expect(ids(navSlotFilter(slots(), { gates: both }))).toContain('build-pipeline')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('leaves at least one sidebar destination in every basic-mode section it keeps', () => {
|
|
134
|
+
// A section whose every item is advanced is dropped wholesale upstream (groupSidebar drops
|
|
135
|
+
// empties), which is intended — but a basic-mode sidebar with NO destinations at all would
|
|
136
|
+
// be a broken shell, so pin that the everyday surface is non-empty.
|
|
137
|
+
const gates: NavGates = { ...ALL_GATES, advancedMode: false }
|
|
138
|
+
const kept = (navSlotFilter(slots(), { gates }) as AppSlots).nav
|
|
139
|
+
const groups = groupSidebar(kept)
|
|
140
|
+
expect(groups.length).toBeGreaterThan(0)
|
|
141
|
+
for (const group of groups) expect(group.items.length).toBeGreaterThan(0)
|
|
142
|
+
})
|
|
87
143
|
})
|
|
88
144
|
|
|
89
145
|
describe('NAV_CONTRIBUTIONS catalog integrity', () => {
|
|
@@ -172,7 +228,8 @@ describe('nav grouping helpers', () => {
|
|
|
172
228
|
|
|
173
229
|
it('groupCommands preserves the pre-slice-1 workspace-group order', () => {
|
|
174
230
|
const workspace = groupCommands(NAV_CONTRIBUTIONS).find((g) => g.group === 'workspace')
|
|
175
|
-
// Same order the old CommandBar pushed them in (parity, not a reorder)
|
|
231
|
+
// Same order the old CommandBar pushed them in (parity, not a reorder), with genuinely
|
|
232
|
+
// new entries appended after it rather than interleaved.
|
|
176
233
|
expect(workspace?.items.map((ci) => ci.item.id)).toEqual([
|
|
177
234
|
'fragments',
|
|
178
235
|
'merge-thresholds',
|
|
@@ -182,6 +239,7 @@ describe('nav grouping helpers', () => {
|
|
|
182
239
|
'local-models',
|
|
183
240
|
'sandbox',
|
|
184
241
|
'keyboard-shortcuts',
|
|
242
|
+
'ui-mode',
|
|
185
243
|
])
|
|
186
244
|
})
|
|
187
245
|
|