@cat-factory/app 0.236.0 → 0.237.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 +42 -3
- package/app/components/auth/LoginScreen.vue +15 -3
- package/app/components/auth/UserMenu.vue +1 -0
- package/app/components/common/DescriptorFields.vue +102 -75
- package/app/components/layout/WorkspaceMembersSettings.vue +1 -0
- package/app/components/panels/inspector/TaskRunSettings.vue +1 -0
- package/app/components/riskPolicy/RiskPolicyPicker.vue +3 -0
- package/app/components/settings/RiskPolicyPanel.vue +22 -3
- package/app/components/settings/WorkspaceSettingsPanel.vue +7 -0
- package/app/composables/useAiReadiness.ts +4 -3
- package/app/docs/consumer-extensions.md +9 -5
- package/app/pages/index.vue +7 -1
- package/app/stores/models.spec.ts +64 -0
- package/app/stores/models.ts +21 -0
- package/app/utils/descriptorFields.spec.ts +68 -0
- package/app/utils/descriptorFields.ts +40 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -79,6 +79,30 @@ earlier than before takes the entire SPA down at boot, and the unit suite cannot
|
|
|
79
79
|
(nothing there installs the plugin). Every e2e spec does, because every one of them boots
|
|
80
80
|
the app.
|
|
81
81
|
|
|
82
|
+
### The persisted board pin is UNVALIDATED until `init()` resolves it
|
|
83
|
+
|
|
84
|
+
`workspace.workspaceId` is restored from persisted state SYNCHRONOUSLY, before any request fires,
|
|
85
|
+
so every `immediate: true` watcher on it (`pages/index.vue`) runs against an id nothing has
|
|
86
|
+
checked. The pin can name a board that was deleted, or one whose access was revoked while the
|
|
87
|
+
browser held it, and the RBAC gate answers both with a 404 (it hides a denial as a not-found, so
|
|
88
|
+
existence never leaks). `init()` then validates the pin against `GET /workspaces` and re-points it
|
|
89
|
+
at a board the user can actually reach.
|
|
90
|
+
|
|
91
|
+
Firing the per-board reads on the pin anyway is deliberate: it overlaps them with the workspace
|
|
92
|
+
list instead of queueing them behind it, which is why `init()` fetches the pinned SNAPSHOT
|
|
93
|
+
speculatively too. **What travels with that is the miss.** Each of those boot reads states its own
|
|
94
|
+
tolerance at its own seam (`init`'s `.catch(() => null)`, `github.ensureProbed`'s internal catch,
|
|
95
|
+
`models.prefetchForBoard`), because a 404 there is an expected outcome and not a fault: the
|
|
96
|
+
watcher fires again for the board init resolved, which is the read that counts. A bare
|
|
97
|
+
`void store.load(workspace.workspaceId)` in that chain is an uncaught rejection in a real user's
|
|
98
|
+
browser, and the e2e suite's `pageErrors` fixture fails the spec that boots a session whose access
|
|
99
|
+
was just revoked.
|
|
100
|
+
|
|
101
|
+
Tolerating the miss is not the same as pretending it succeeded: a dropped load leaves its store
|
|
102
|
+
UNLOADED (`models.loaded` stays false, so `useAiReadiness().ready` is false), which reads as
|
|
103
|
+
unresolved rather than as a board with nothing configured, and leaves the next caller free to
|
|
104
|
+
retry. Pin new boot reads with a store-level unit test (`stores/models.spec.ts`).
|
|
105
|
+
|
|
82
106
|
### A backend-DECLARED form renders through `DescriptorFields.vue`
|
|
83
107
|
|
|
84
108
|
When the backend declares the fields and the SPA only collects them, render them with the shared
|
|
@@ -87,12 +111,27 @@ When the backend declares the fields and the SPA only collects them, render them
|
|
|
87
111
|
surfaces use it: an initiative preset's create form and a reusable operation's per-case form on a
|
|
88
112
|
custom task type (`AddTaskModal`). Adding a third is a `:fields` binding, not a component.
|
|
89
113
|
|
|
114
|
+
**Grouping is the descriptor's own, through `descriptorFieldSections`**, not a wrapper each surface
|
|
115
|
+
builds: consecutive fields sharing a `section` render under one caption, and the reduction applies
|
|
116
|
+
`showWhen` first, so a section whose every field is hidden renders no caption. Never re-group or
|
|
117
|
+
re-order the fields at a call site, or a form renders in an order its author never wrote.
|
|
118
|
+
|
|
119
|
+
**A captioned run is rendered FLAT, never as a per-run wrapper element** (`descriptorFormRows`
|
|
120
|
+
carries each run's caption on the field that opens it). Run membership is derived state that shifts
|
|
121
|
+
as `showWhen` reveals fields, while a field's identity does not: nesting the fields inside a wrapper
|
|
122
|
+
re-parents them when a boundary moves, and Vue can only do that by unmounting and remounting. The
|
|
123
|
+
remounted input is typically the one being TYPED INTO, because typing into the trigger is what moved
|
|
124
|
+
the boundary, so it loses focus, caret and IME composition mid-keystroke. Keep every field a sibling
|
|
125
|
+
keyed by `field.key` and the diff MOVES it instead. The same trap as keying any list by index, with a
|
|
126
|
+
worse symptom: `descriptorFields.spec.ts` pins that a reveal preserves every field key.
|
|
127
|
+
|
|
90
128
|
Four rules travel with it. **Validate with the shared `validateDescriptorFields`** so the submit
|
|
91
129
|
button reflects exactly what the server will refuse, and **submit the shared
|
|
92
130
|
`sanitizeDescriptorFields` result** so a stale answer on a since-hidden `showWhen` field never
|
|
93
|
-
reaches the wire. **
|
|
94
|
-
|
|
95
|
-
|
|
131
|
+
reaches the wire. **Every string a descriptor carries is deployment-authored English rendered
|
|
132
|
+
verbatim**, labels, help, option captions and the `section` grouping captions alike: only the
|
|
133
|
+
platform's own chrome around them (the path-invalid message) is i18n, so no descriptor string enters
|
|
134
|
+
a locale catalog. And **the value-bag rules live in `utils/descriptorFields.ts`, not in the SFC**
|
|
96
135
|
(`defaultDescriptorValues` for the initial values, `setDescriptorValue` / `setDescriptorCheckbox` /
|
|
97
136
|
`toggleDescriptorGroupValue` for one edit): what an edit freezes on an entity is what a unit test
|
|
98
137
|
must be able to reach, and a rule inside a component is only reachable by mounting one.
|
|
@@ -205,7 +205,10 @@ const noSignInMethod = computed(
|
|
|
205
205
|
</script>
|
|
206
206
|
|
|
207
207
|
<template>
|
|
208
|
-
<div
|
|
208
|
+
<div
|
|
209
|
+
class="flex h-screen w-screen items-center justify-center bg-slate-950 text-slate-100"
|
|
210
|
+
data-testid="login-screen"
|
|
211
|
+
>
|
|
209
212
|
<div
|
|
210
213
|
class="w-full max-w-sm rounded-xl border border-slate-800 bg-slate-900/80 p-8 backdrop-blur"
|
|
211
214
|
>
|
|
@@ -392,6 +395,7 @@ const noSignInMethod = computed(
|
|
|
392
395
|
icon="i-lucide-at-sign"
|
|
393
396
|
size="lg"
|
|
394
397
|
class="w-full"
|
|
398
|
+
data-testid="login-email"
|
|
395
399
|
/>
|
|
396
400
|
<SecretInput
|
|
397
401
|
v-model="password"
|
|
@@ -400,9 +404,17 @@ const noSignInMethod = computed(
|
|
|
400
404
|
icon="i-lucide-lock"
|
|
401
405
|
size="lg"
|
|
402
406
|
class="w-full"
|
|
407
|
+
data-testid="login-password"
|
|
403
408
|
/>
|
|
404
|
-
<p v-if="error" class="text-sm text-rose-400">{{ error }}</p>
|
|
405
|
-
<UButton
|
|
409
|
+
<p v-if="error" class="text-sm text-rose-400" data-testid="login-error">{{ error }}</p>
|
|
410
|
+
<UButton
|
|
411
|
+
block
|
|
412
|
+
size="lg"
|
|
413
|
+
color="primary"
|
|
414
|
+
type="submit"
|
|
415
|
+
:loading="busy"
|
|
416
|
+
data-testid="login-submit"
|
|
417
|
+
>
|
|
406
418
|
{{ mode === 'signup' ? t('auth.login.createAccount') : t('auth.login.signIn') }}
|
|
407
419
|
</UButton>
|
|
408
420
|
<p class="text-center text-xs text-slate-400">
|
|
@@ -35,6 +35,7 @@ const items = computed<DropdownMenuItem[][]>(() => [
|
|
|
35
35
|
<UDropdownMenu v-if="auth.user" :items="items" :content="{ side: 'top', align: 'start' }">
|
|
36
36
|
<button
|
|
37
37
|
type="button"
|
|
38
|
+
data-testid="user-menu"
|
|
38
39
|
:title="collapsed ? auth.user.name || auth.user.login : undefined"
|
|
39
40
|
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
41
|
:class="collapsed ? 'justify-center' : ''"
|
|
@@ -8,16 +8,18 @@
|
|
|
8
8
|
//
|
|
9
9
|
// It extends `ProviderConnectionTab.vue`'s flat-field pattern with the shapes a declared form needs:
|
|
10
10
|
// `checkbox-group` (multi-select whose value is `string[]`), `path` (a repo-relative dir with inline
|
|
11
|
-
// safety validation),
|
|
12
|
-
// backend-supplied English (the `describeConfig`
|
|
11
|
+
// safety validation), single-condition `showWhen` visibility, and `section` grouping captions.
|
|
12
|
+
// Labels/help/option/section captions are backend-supplied English (the `describeConfig`
|
|
13
|
+
// convention); only the chrome is i18n.
|
|
13
14
|
//
|
|
14
15
|
// The model is the typed `DescriptorFieldValues` map (scalars stay strings, `number` a number,
|
|
15
16
|
// `checkbox` a boolean, `checkbox-group` a `string[]`), so it round-trips the wire contract and the
|
|
16
17
|
// shared `validateDescriptorFields` / `sanitizeDescriptorFields` rules unchanged.
|
|
17
18
|
import { computed } from 'vue'
|
|
18
|
-
import {
|
|
19
|
+
import { isSafeRepoDirPath } from '@cat-factory/contracts'
|
|
19
20
|
import type { DescriptorField, DescriptorFieldValue, DescriptorFieldValues } from '~/types/domain'
|
|
20
21
|
import {
|
|
22
|
+
descriptorFormRows,
|
|
21
23
|
descriptorGroupValue,
|
|
22
24
|
setDescriptorCheckbox,
|
|
23
25
|
setDescriptorValue,
|
|
@@ -39,12 +41,20 @@ const props = withDefaults(
|
|
|
39
41
|
const model = defineModel<DescriptorFieldValues>({ required: true })
|
|
40
42
|
const { t } = useI18n()
|
|
41
43
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
// The fields to render, each carrying the section caption that opens its run. Only fields whose
|
|
45
|
+
// `showWhen` holds against the current values are shown; a hidden field's stale value is kept in the
|
|
46
|
+
// model (so re-showing restores it) but the server + client both drop it at sanitize/validate time,
|
|
47
|
+
// so it can never freeze an unvalidated value.
|
|
48
|
+
//
|
|
49
|
+
// The grouping underneath is the shared `descriptorFieldSections`, so a caption spans exactly what
|
|
50
|
+
// one function says it spans, which is the same statement the boot check refuses a declaration
|
|
51
|
+
// against. A form declaring no section is one uncaptioned run, i.e. byte-for-byte the flat column
|
|
52
|
+
// this component always rendered.
|
|
53
|
+
//
|
|
54
|
+
// The rows are FLAT, and the template keeps them siblings keyed by `field.key`, because run
|
|
55
|
+
// membership shifts as `showWhen` reveals fields while a field's identity does not: see
|
|
56
|
+
// `descriptorFormRows` for why a per-run wrapper would remount the input being typed into.
|
|
57
|
+
const rows = computed(() => descriptorFormRows(props.fields, model.value))
|
|
48
58
|
|
|
49
59
|
// The value-mutation rules live in `utils/descriptorFields.ts` as pure functions over the bag (what
|
|
50
60
|
// an edit does to it, including the drop-when-empty rule that keeps an unset answer from freezing),
|
|
@@ -90,76 +100,93 @@ function selectItems(field: DescriptorField) {
|
|
|
90
100
|
</script>
|
|
91
101
|
|
|
92
102
|
<template>
|
|
93
|
-
<div v-if="
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
103
|
+
<div v-if="rows.length" class="space-y-4">
|
|
104
|
+
<!-- One keyed fragment per FIELD, never per run: a run's caption rides the field that opens it,
|
|
105
|
+
so revealing a field re-captions rows in place instead of re-parenting the inputs (which
|
|
106
|
+
Vue can only do by remounting them, destroying the one being typed into). -->
|
|
107
|
+
<template v-for="{ field, caption, startsGroup } in rows" :key="field.key">
|
|
108
|
+
<!-- The section caption, rendered verbatim above its run (deployment-authored English, like
|
|
109
|
+
the field labels themselves). Its testid is the same on every caption, because a caption
|
|
110
|
+
is arbitrary Unicode a deployment writes in its own language: a spec addresses one by the
|
|
111
|
+
TEXT it is asserting about (`getByTestId(...).filter({ hasText })`) rather than by a
|
|
112
|
+
testid that would have to be slugified to be selectable. -->
|
|
113
|
+
<p
|
|
114
|
+
v-if="caption"
|
|
115
|
+
class="-mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400"
|
|
116
|
+
:class="{ 'pt-2': startsGroup }"
|
|
117
|
+
:data-testid="`${testidPrefix}-section`"
|
|
118
|
+
>
|
|
119
|
+
{{ caption }}
|
|
120
|
+
</p>
|
|
121
|
+
<UFormField
|
|
122
|
+
:label="field.label"
|
|
123
|
+
:help="field.help"
|
|
124
|
+
:required="field.required"
|
|
125
|
+
:error="pathInvalid(field) ? t('common.pathInvalid') : undefined"
|
|
126
|
+
:class="{ 'pt-2': startsGroup && !caption }"
|
|
127
|
+
:data-testid="`${testidPrefix}-${field.key}`"
|
|
128
|
+
>
|
|
129
|
+
<!-- checkbox-group: a vertical list of toggles whose value is the checked option set. -->
|
|
130
|
+
<div v-if="field.type === 'checkbox-group'" class="space-y-1.5">
|
|
131
|
+
<UCheckbox
|
|
132
|
+
v-for="opt in field.options ?? []"
|
|
133
|
+
:key="opt.value"
|
|
134
|
+
:model-value="groupValue(field.key).includes(opt.value)"
|
|
135
|
+
:label="opt.label"
|
|
136
|
+
:data-testid="`${testidPrefix}-${field.key}-${opt.value}`"
|
|
137
|
+
@update:model-value="
|
|
138
|
+
(v: boolean | 'indeterminate') => toggleGroup(field.key, opt.value, v === true)
|
|
139
|
+
"
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
116
142
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
143
|
+
<USelect
|
|
144
|
+
v-else-if="field.type === 'select'"
|
|
145
|
+
:model-value="stringValue(field.key)"
|
|
146
|
+
:items="selectItems(field)"
|
|
147
|
+
class="w-full"
|
|
148
|
+
:placeholder="field.placeholder"
|
|
149
|
+
@update:model-value="(v: string) => set(field.key, v)"
|
|
150
|
+
/>
|
|
125
151
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
152
|
+
<USwitch
|
|
153
|
+
v-else-if="field.type === 'checkbox'"
|
|
154
|
+
:model-value="boolValue(field.key)"
|
|
155
|
+
@update:model-value="(v: boolean) => setCheckbox(field, v)"
|
|
156
|
+
/>
|
|
131
157
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
158
|
+
<UTextarea
|
|
159
|
+
v-else-if="field.type === 'textarea'"
|
|
160
|
+
:model-value="stringValue(field.key)"
|
|
161
|
+
:rows="3"
|
|
162
|
+
autoresize
|
|
163
|
+
class="w-full"
|
|
164
|
+
:maxlength="field.maxLength"
|
|
165
|
+
:placeholder="field.placeholder"
|
|
166
|
+
@update:model-value="(v: string) => set(field.key, v)"
|
|
167
|
+
/>
|
|
142
168
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
169
|
+
<UInput
|
|
170
|
+
v-else-if="field.type === 'number'"
|
|
171
|
+
:model-value="numberStr(field.key)"
|
|
172
|
+
type="number"
|
|
173
|
+
class="w-full font-mono"
|
|
174
|
+
:placeholder="field.placeholder"
|
|
175
|
+
@update:model-value="(v: string) => set(field.key, v === '' ? undefined : Number(v))"
|
|
176
|
+
/>
|
|
151
177
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
178
|
+
<!-- path + text/password (the untyped default): a single-line input. `path`s stay mono. -->
|
|
179
|
+
<UInput
|
|
180
|
+
v-else
|
|
181
|
+
:model-value="stringValue(field.key)"
|
|
182
|
+
:type="field.type === 'password' ? 'password' : 'text'"
|
|
183
|
+
class="w-full"
|
|
184
|
+
:class="{ 'font-mono': field.type === 'path' }"
|
|
185
|
+
:maxlength="field.maxLength"
|
|
186
|
+
:placeholder="field.placeholder"
|
|
187
|
+
@update:model-value="(v: string) => set(field.key, v)"
|
|
188
|
+
/>
|
|
189
|
+
</UFormField>
|
|
190
|
+
</template>
|
|
164
191
|
</div>
|
|
165
192
|
</template>
|
|
@@ -187,6 +187,7 @@ function memberLabel(userId: string, name?: string | null, email?: string | null
|
|
|
187
187
|
:key="m.userId"
|
|
188
188
|
class="flex items-center justify-between gap-2 rounded-md bg-slate-800/40 px-2 py-1"
|
|
189
189
|
data-testid="workspace-member-row"
|
|
190
|
+
:data-user-id="m.userId"
|
|
190
191
|
>
|
|
191
192
|
<span class="truncate">{{ memberLabel(m.userId, m.name, m.email) }}</span>
|
|
192
193
|
<span class="flex shrink-0 items-center gap-2">
|
|
@@ -113,6 +113,9 @@ function choose(id: string) {
|
|
|
113
113
|
|
|
114
114
|
<template>
|
|
115
115
|
<UPopover v-model:open="open" :content="{ align: 'start' }">
|
|
116
|
+
<!-- A consumer that supplies its own `#trigger` must carry `risk-policy-picker-trigger` on it:
|
|
117
|
+
the popover trigger is `as-child`, so the slotted element REPLACES the default button below
|
|
118
|
+
and takes its test hook with it (the inspector's icon button is the one such consumer). -->
|
|
116
119
|
<slot name="trigger" :label="triggerLabel">
|
|
117
120
|
<UButton
|
|
118
121
|
color="neutral"
|
|
@@ -282,7 +282,7 @@ async function create() {
|
|
|
282
282
|
</script>
|
|
283
283
|
|
|
284
284
|
<template>
|
|
285
|
-
<div class="space-y-4">
|
|
285
|
+
<div class="space-y-4" data-testid="risk-policy-panel">
|
|
286
286
|
<i18n-t
|
|
287
287
|
keypath="settings.riskPolicy.intro"
|
|
288
288
|
tag="p"
|
|
@@ -298,6 +298,8 @@ async function create() {
|
|
|
298
298
|
v-for="p in store.presets"
|
|
299
299
|
:key="p.id"
|
|
300
300
|
class="rounded-lg border border-slate-700 bg-slate-800/40 p-3"
|
|
301
|
+
data-testid="risk-policy-row"
|
|
302
|
+
:data-policy-id="p.id"
|
|
301
303
|
>
|
|
302
304
|
<div class="mb-3 flex items-center gap-2">
|
|
303
305
|
<UInput
|
|
@@ -478,6 +480,7 @@ async function create() {
|
|
|
478
480
|
v-model="draft.name"
|
|
479
481
|
size="sm"
|
|
480
482
|
:placeholder="t('settings.riskPolicy.create.namePlaceholder')"
|
|
483
|
+
data-testid="risk-policy-create-name"
|
|
481
484
|
/>
|
|
482
485
|
</label>
|
|
483
486
|
<label class="block w-20">
|
|
@@ -490,19 +493,34 @@ async function create() {
|
|
|
490
493
|
:min="0"
|
|
491
494
|
:max="100"
|
|
492
495
|
size="sm"
|
|
496
|
+
data-testid="risk-policy-create-complexity"
|
|
493
497
|
/>
|
|
494
498
|
</label>
|
|
495
499
|
<label class="block w-20">
|
|
496
500
|
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
497
501
|
{{ t('settings.riskPolicy.create.risk') }}
|
|
498
502
|
</span>
|
|
499
|
-
<UInput
|
|
503
|
+
<UInput
|
|
504
|
+
v-model.number="draft.maxRisk"
|
|
505
|
+
type="number"
|
|
506
|
+
:min="0"
|
|
507
|
+
:max="100"
|
|
508
|
+
size="sm"
|
|
509
|
+
data-testid="risk-policy-create-risk"
|
|
510
|
+
/>
|
|
500
511
|
</label>
|
|
501
512
|
<label class="block w-20">
|
|
502
513
|
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
503
514
|
{{ t('settings.riskPolicy.create.impact') }}
|
|
504
515
|
</span>
|
|
505
|
-
<UInput
|
|
516
|
+
<UInput
|
|
517
|
+
v-model.number="draft.maxImpact"
|
|
518
|
+
type="number"
|
|
519
|
+
:min="0"
|
|
520
|
+
:max="100"
|
|
521
|
+
size="sm"
|
|
522
|
+
data-testid="risk-policy-create-impact"
|
|
523
|
+
/>
|
|
506
524
|
</label>
|
|
507
525
|
<label class="block w-20">
|
|
508
526
|
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
@@ -549,6 +567,7 @@ async function create() {
|
|
|
549
567
|
icon="i-lucide-plus"
|
|
550
568
|
:loading="creating"
|
|
551
569
|
:disabled="!draft.name.trim()"
|
|
570
|
+
data-testid="risk-policy-create-submit"
|
|
552
571
|
@click="create"
|
|
553
572
|
>
|
|
554
573
|
{{ t('settings.riskPolicy.add') }}
|
|
@@ -312,6 +312,13 @@ async function save() {
|
|
|
312
312
|
</template>
|
|
313
313
|
<template #body>
|
|
314
314
|
<UTabs v-model="activeTab" :items="tabs" variant="link" :ui="tabsUi">
|
|
315
|
+
<!-- The tab LABEL, overridden only to carry a per-tab test hook: `UTabs` renders its own
|
|
316
|
+
triggers and forwards nothing from an item, so this slot is the one place a stable
|
|
317
|
+
selector can name which tab a click means (the labels themselves are translated). -->
|
|
318
|
+
<template #default="{ item }">
|
|
319
|
+
<span :data-testid="`workspace-settings-tab-${item.value}`">{{ item.label }}</span>
|
|
320
|
+
</template>
|
|
321
|
+
|
|
315
322
|
<!-- Workspace -->
|
|
316
323
|
<template #workspace>
|
|
317
324
|
<div class="space-y-6">
|
|
@@ -13,9 +13,10 @@ import type { ModelPreset } from '~/types/model-presets'
|
|
|
13
13
|
* still points at one or more that aren't usable (⇒ the preset-mismatch prompt). Gated on
|
|
14
14
|
* `hasUsableModel` so the no-AI prompt owns the "nothing works" case on its own.
|
|
15
15
|
*
|
|
16
|
-
* Read-only over the existing stores; the catalog is loaded elsewhere (on
|
|
17
|
-
* and after credential edits), so `ready` simply reports whether that load has landed
|
|
18
|
-
* the active workspace.
|
|
16
|
+
* Read-only over the existing stores; the catalog is loaded elsewhere (on the active board
|
|
17
|
+
* changing and after credential edits), so `ready` simply reports whether that load has landed
|
|
18
|
+
* for the active workspace. A load that FAILED leaves it false, which is what keeps the no-AI
|
|
19
|
+
* prompt off a board whose catalog never arrived (see `models.prefetchForBoard`).
|
|
19
20
|
*/
|
|
20
21
|
export function useAiReadiness() {
|
|
21
22
|
const models = useModelsStore()
|
|
@@ -185,11 +185,15 @@ module). The SPA merges it into the create-task picker and the card-badge catalo
|
|
|
185
185
|
`category` groups the picker (below).
|
|
186
186
|
- **`fields`** are descriptor-driven create-form inputs over the shared descriptor-form vocabulary
|
|
187
187
|
(`text` / `textarea` / `number` / `select` / `checkbox` / `checkbox-group` / `path`, with
|
|
188
|
-
defaults and `
|
|
189
|
-
value reaches prompts and telemetry). Their values land in the
|
|
190
|
-
`taskTypeFields.custom` bag (no migration). A BACKEND-registered descriptor is
|
|
191
|
-
server-side on create as well (required answers, option lists, lengths); a code-shipped
|
|
192
|
-
known only to the SPA, so the create form is its only check (see the Validation note below).
|
|
188
|
+
defaults, `showWhen` visibility and a `section` grouping caption; `password` is excluded by
|
|
189
|
+
construction because a task field value reaches prompts and telemetry). Their values land in the
|
|
190
|
+
task's sparse `taskTypeFields.custom` bag (no migration). A BACKEND-registered descriptor is
|
|
191
|
+
enforced server-side on create as well (required answers, option lists, lengths); a code-shipped
|
|
192
|
+
one is known only to the SPA, so the create form is its only check (see the Validation note below).
|
|
193
|
+
A `section` groups a long form into captioned runs and changes nothing else; declare a section's
|
|
194
|
+
fields consecutively, since a backend registration whose form could caption one twice fails boot
|
|
195
|
+
and a code-shipped one would simply render the caption twice. Interleaving a section with a
|
|
196
|
+
mutually exclusive `showWhen` branch is not that fault: only one half is ever on screen.
|
|
193
197
|
- **`formPanel`** optionally names a bespoke create-form section component you contribute to the
|
|
194
198
|
`taskTypeFormPanels` slot (paired by that id, like `resultViews`); shown INSTEAD of `fields`. An
|
|
195
199
|
unpaired id degrades to the descriptor fields.
|
package/app/pages/index.vue
CHANGED
|
@@ -191,10 +191,16 @@ const autoOpenedPreset = ref(false)
|
|
|
191
191
|
// availability reflects that workspace's keys/subscriptions). This populates the AI-readiness
|
|
192
192
|
// signals regardless of which lazy picker happens to mount, so the onboarding prompts below
|
|
193
193
|
// can fire. Credential edits re-fetch via `models.refresh()` in the provider panels.
|
|
194
|
+
//
|
|
195
|
+
// Through `prefetchForBoard` because the FIRST run reads the persisted pin, which `init()` has
|
|
196
|
+
// not validated yet: a board that was deleted, or whose access was revoked while the browser
|
|
197
|
+
// held the pin, 404s here exactly as it does for init's own speculative snapshot fetch. That
|
|
198
|
+
// board is not this watcher's last word (init re-points the pin and it fires again), so the
|
|
199
|
+
// miss is dropped rather than left to surface as an uncaught rejection in the page.
|
|
194
200
|
watch(
|
|
195
201
|
() => workspace.workspaceId,
|
|
196
202
|
(id, prev) => {
|
|
197
|
-
if (id) void models.
|
|
203
|
+
if (id) void models.prefetchForBoard(id)
|
|
198
204
|
// Switching workspaces resets the per-session AI-onboarding state: dismissals and the
|
|
199
205
|
// auto-open guards are scoped to one workspace, so a prompt dismissed in workspace A must
|
|
200
206
|
// not suppress the (independent) prompt for workspace B that also lacks a usable source.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import type { ModelOption } from '~/types/domain'
|
|
3
|
+
import { useModelsStore } from '~/stores/models'
|
|
4
|
+
|
|
5
|
+
/** Minimal catalog entry: only the fields the store's own reads touch. */
|
|
6
|
+
function model(over: Partial<ModelOption> = {}): ModelOption {
|
|
7
|
+
return {
|
|
8
|
+
id: 'qwen3',
|
|
9
|
+
label: 'Qwen3',
|
|
10
|
+
description: '',
|
|
11
|
+
flavor: 'cloudflare',
|
|
12
|
+
providerLabel: 'Cloudflare',
|
|
13
|
+
provider: 'cloudflare',
|
|
14
|
+
model: 'qwen3',
|
|
15
|
+
available: true,
|
|
16
|
+
...over,
|
|
17
|
+
} as ModelOption
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The boot-time catalog load runs against the PERSISTED PIN, before `workspace.init()` has
|
|
21
|
+
// validated it against the board list. A pin can name a board that was deleted, or one whose
|
|
22
|
+
// access was revoked while the browser held it, and the RBAC gate answers both with a 404, so
|
|
23
|
+
// the load has to tolerate a miss. Left bare it was an uncaught rejection in the page (the
|
|
24
|
+
// `Workspace not found` a removed member's browser threw on their next visit).
|
|
25
|
+
describe('models store: the speculative load of an unvalidated pin', () => {
|
|
26
|
+
it('drops a pin that 404s and leaves the catalog retryable for the board init resolves', async () => {
|
|
27
|
+
const get = vi
|
|
28
|
+
.fn<(workspaceId: string) => Promise<ModelOption[]>>()
|
|
29
|
+
.mockRejectedValueOnce(Object.assign(new Error('Workspace not found'), { statusCode: 404 }))
|
|
30
|
+
.mockResolvedValueOnce([model()])
|
|
31
|
+
vi.stubGlobal('useApi', () => ({ getWorkspaceModels: get }))
|
|
32
|
+
|
|
33
|
+
const store = useModelsStore()
|
|
34
|
+
// The revoked pin. Resolves rather than rejects: nothing in the page catches it.
|
|
35
|
+
await expect(store.prefetchForBoard('ws_revoked')).resolves.toBeUndefined()
|
|
36
|
+
|
|
37
|
+
// Nothing was latched, so this reads as UNRESOLVED rather than as a board with no models
|
|
38
|
+
// (`useAiReadiness().ready` is `loaded && loadedWorkspaceId === workspaceId`, which is what
|
|
39
|
+
// keeps the no-AI onboarding prompt from firing off a catalog that never landed).
|
|
40
|
+
expect(store.loaded).toBe(false)
|
|
41
|
+
expect(store.loadedWorkspaceId).toBeNull()
|
|
42
|
+
expect(store.models).toEqual([])
|
|
43
|
+
|
|
44
|
+
// ...and the board `init()` resolves instead still loads, on the same store.
|
|
45
|
+
await store.ensureLoaded('ws_reachable')
|
|
46
|
+
expect(store.loaded).toBe(true)
|
|
47
|
+
expect(store.loadedWorkspaceId).toBe('ws_reachable')
|
|
48
|
+
expect(store.models).toHaveLength(1)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('a pin that IS reachable loads the catalog once, and the later caller reuses it', async () => {
|
|
52
|
+
const get = vi.fn(() => Promise.resolve([model()]))
|
|
53
|
+
vi.stubGlobal('useApi', () => ({ getWorkspaceModels: get }))
|
|
54
|
+
|
|
55
|
+
const store = useModelsStore()
|
|
56
|
+
await store.prefetchForBoard('ws1')
|
|
57
|
+
// What the cold open pays for: `init()` hydrating the same board finds the catalog already
|
|
58
|
+
// there, so the prefetch is one request rather than a duplicate of the one that follows it.
|
|
59
|
+
await store.ensureLoaded('ws1')
|
|
60
|
+
|
|
61
|
+
expect(get).toHaveBeenCalledTimes(1)
|
|
62
|
+
expect(store.hasUsableModel).toBe(true)
|
|
63
|
+
})
|
|
64
|
+
})
|
package/app/stores/models.ts
CHANGED
|
@@ -122,6 +122,26 @@ export const useModelsStore = defineStore('models', () => {
|
|
|
122
122
|
loaded.value = true
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Load the catalog for a board the app has NOT yet validated: the persisted pin at boot,
|
|
127
|
+
* fetched in parallel with the workspace list rather than after it.
|
|
128
|
+
*
|
|
129
|
+
* A pin can name a board that was deleted, or one whose access has since been revoked, and the
|
|
130
|
+
* gate answers both with a 404 (`workspace.init()` guards the same speculative read of the same
|
|
131
|
+
* id with `.catch(() => null)`). So a failure here is an expected outcome rather than a fault,
|
|
132
|
+
* and dropping it is safe in both directions: `loaded` stays false, so this leaves the catalog
|
|
133
|
+
* RETRYABLE for the next `ensureLoaded` caller, and `useAiReadiness().ready` stays false, so a
|
|
134
|
+
* catalog that never landed reads as unresolved instead of as a board with no AI configured.
|
|
135
|
+
*/
|
|
136
|
+
async function prefetchForBoard(workspaceId: string): Promise<void> {
|
|
137
|
+
try {
|
|
138
|
+
await ensureLoaded(workspaceId)
|
|
139
|
+
} catch {
|
|
140
|
+
// Deliberate: see above. `init()` re-points the board it resolved, which loads the catalog
|
|
141
|
+
// that counts; anything else retries on the next caller.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
125
145
|
/** Force a re-fetch of the per-workspace catalog (e.g. after adding an API key). */
|
|
126
146
|
async function refresh(workspaceId: string) {
|
|
127
147
|
models.value = await api.getWorkspaceModels(workspaceId)
|
|
@@ -180,6 +200,7 @@ export const useModelsStore = defineStore('models', () => {
|
|
|
180
200
|
loaded,
|
|
181
201
|
loadedWorkspaceId,
|
|
182
202
|
ensureLoaded,
|
|
203
|
+
prefetchForBoard,
|
|
183
204
|
refresh,
|
|
184
205
|
byId,
|
|
185
206
|
getModel,
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
|
|
2
2
|
import type { DescriptorField } from '~/types/domain'
|
|
3
3
|
import {
|
|
4
4
|
defaultDescriptorValues,
|
|
5
|
+
descriptorFormRows,
|
|
5
6
|
descriptorGroupValue,
|
|
6
7
|
setDescriptorCheckbox,
|
|
7
8
|
setDescriptorValue,
|
|
@@ -124,3 +125,70 @@ describe('toggleDescriptorGroupValue', () => {
|
|
|
124
125
|
})
|
|
125
126
|
})
|
|
126
127
|
})
|
|
128
|
+
|
|
129
|
+
// The layout half of `section` grouping. What a caption SPANS is the shared contracts rule (tested
|
|
130
|
+
// there); what these assert is the property that rule's rendering has to preserve, and the reason the
|
|
131
|
+
// rows are flat at all: a field's identity survives a boundary move, so the keyed diff MOVES the
|
|
132
|
+
// live input instead of remounting it.
|
|
133
|
+
describe('descriptorFormRows', () => {
|
|
134
|
+
const keysOf = (rows: ReturnType<typeof descriptorFormRows>) => rows.map((r) => r.field.key)
|
|
135
|
+
|
|
136
|
+
it('carries each run caption on the field that OPENS it, once', () => {
|
|
137
|
+
const rows = descriptorFormRows(
|
|
138
|
+
[
|
|
139
|
+
field({ key: 'entity' }),
|
|
140
|
+
field({ key: 'style', section: 'Shape' }),
|
|
141
|
+
field({ key: 'verb', section: 'Shape' }),
|
|
142
|
+
field({ key: 'dir', type: 'path', section: 'Placement' }),
|
|
143
|
+
],
|
|
144
|
+
{},
|
|
145
|
+
)
|
|
146
|
+
expect(rows.map((r) => [r.field.key, r.caption, r.startsGroup])).toEqual([
|
|
147
|
+
['entity', undefined, false],
|
|
148
|
+
['style', 'Shape', true],
|
|
149
|
+
['verb', undefined, false],
|
|
150
|
+
['dir', 'Placement', true],
|
|
151
|
+
])
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('renders a sectionless form as the flat column, with no caption and no group gap', () => {
|
|
155
|
+
const rows = descriptorFormRows([field({ key: 'entity' }), field({ key: 'notes' })], {})
|
|
156
|
+
expect(rows.map((r) => [r.caption, r.startsGroup])).toEqual([
|
|
157
|
+
[undefined, false],
|
|
158
|
+
[undefined, false],
|
|
159
|
+
])
|
|
160
|
+
expect(descriptorFormRows([], {})).toEqual([])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('keeps a field key STABLE when a reveal moves it into another run', () => {
|
|
164
|
+
// The regression this shape exists for. `note` is unsectioned and gated, so revealing it splits
|
|
165
|
+
// the `Shape` run in two and re-captions `style`. Boot refuses THIS declaration (the split is
|
|
166
|
+
// reachable), and the renderer still has to be total over it, because a wire descriptor can
|
|
167
|
+
// arrive from a node whose build predates the refusal. Every field key present before is still
|
|
168
|
+
// present after, so Vue's keyed diff moves those nodes rather than unmounting them: typing into
|
|
169
|
+
// `advanced` (the trigger) cannot destroy the input being typed into.
|
|
170
|
+
const fields = [
|
|
171
|
+
field({ key: 'advanced', type: 'checkbox' }),
|
|
172
|
+
field({ key: 'verb', section: 'Shape' }),
|
|
173
|
+
field({ key: 'note', showWhen: { key: 'advanced', equals: true } }),
|
|
174
|
+
field({ key: 'style', section: 'Shape' }),
|
|
175
|
+
]
|
|
176
|
+
expect(keysOf(descriptorFormRows(fields, {}))).toEqual(['advanced', 'verb', 'style'])
|
|
177
|
+
|
|
178
|
+
const revealed = descriptorFormRows(fields, { advanced: true })
|
|
179
|
+
expect(keysOf(revealed)).toEqual(['advanced', 'verb', 'note', 'style'])
|
|
180
|
+
// The caption moved (the second run needs its own) while the field identities did not.
|
|
181
|
+
expect(revealed.map((r) => r.caption)).toEqual([undefined, 'Shape', undefined, 'Shape'])
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('marks a later UNCAPTIONED run as opening a group too, so the gap is not caption-only', () => {
|
|
185
|
+
const rows = descriptorFormRows(
|
|
186
|
+
[field({ key: 'verb', section: 'Shape' }), field({ key: 'notes' })],
|
|
187
|
+
{},
|
|
188
|
+
)
|
|
189
|
+
expect(rows.map((r) => [r.field.key, r.startsGroup])).toEqual([
|
|
190
|
+
['verb', false],
|
|
191
|
+
['notes', true],
|
|
192
|
+
])
|
|
193
|
+
})
|
|
194
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { descriptorFieldDefaults } from '@cat-factory/contracts'
|
|
1
|
+
import { descriptorFieldDefaults, descriptorFieldSections } from '@cat-factory/contracts'
|
|
2
2
|
import type { DescriptorField, DescriptorFieldValue, DescriptorFieldValues } from '~/types/domain'
|
|
3
3
|
|
|
4
4
|
// Form-side helpers over the shared descriptor-field vocabulary (`contracts/src/form-fields.ts`),
|
|
@@ -75,6 +75,45 @@ export function descriptorGroupValue(values: DescriptorFieldValues, key: string)
|
|
|
75
75
|
return Array.isArray(value) ? value : []
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/** One row of a rendered descriptor form: a field, plus the section chrome that precedes it. */
|
|
79
|
+
export interface DescriptorFormRow {
|
|
80
|
+
/** The field to render. Its `key` is the row's identity in the keyed diff. */
|
|
81
|
+
field: DescriptorField
|
|
82
|
+
/** The caption to print above this field, set only on the field that OPENS a captioned run. */
|
|
83
|
+
caption?: string
|
|
84
|
+
/** Whether this field opens a run with another run before it, i.e. needs the between-runs gap. */
|
|
85
|
+
startsGroup: boolean
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A descriptor form reduced to a FLAT list of rows: the shared `descriptorFieldSections` grouping,
|
|
90
|
+
* with each run's caption carried on the field that opens it rather than on a wrapper around it.
|
|
91
|
+
*
|
|
92
|
+
* Flat is the whole point, and it is a correctness rule rather than a layout preference. Run
|
|
93
|
+
* membership is DERIVED state that changes as `showWhen` reveals and hides fields, while a field's
|
|
94
|
+
* identity does not. Rendering the runs as nested `v-for`s re-parents a field the moment a boundary
|
|
95
|
+
* moves, and Vue cannot move a node between two parents: it unmounts and remounts it. The field being
|
|
96
|
+
* remounted is typically the one being TYPED INTO, because typing into a `showWhen` trigger is what
|
|
97
|
+
* moved the boundary, so the input loses focus, caret and any in-flight IME composition mid-keystroke.
|
|
98
|
+
* Keeping every field a sibling under one parent, keyed by `field.key`, makes that a MOVE, which
|
|
99
|
+
* preserves the live input: the behaviour the flat column had before sections existed.
|
|
100
|
+
*
|
|
101
|
+
* Presentation, so it lives here rather than in contracts: what a caption spans is the shared rule,
|
|
102
|
+
* and this is only how the SPA lays that out.
|
|
103
|
+
*/
|
|
104
|
+
export function descriptorFormRows(
|
|
105
|
+
fields: readonly DescriptorField[],
|
|
106
|
+
values: DescriptorFieldValues,
|
|
107
|
+
): DescriptorFormRow[] {
|
|
108
|
+
return descriptorFieldSections(fields, values).flatMap((group, groupIndex) =>
|
|
109
|
+
group.fields.map((field, fieldIndex) => ({
|
|
110
|
+
field,
|
|
111
|
+
...(fieldIndex === 0 && group.section !== undefined ? { caption: group.section } : {}),
|
|
112
|
+
startsGroup: fieldIndex === 0 && groupIndex > 0,
|
|
113
|
+
})),
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
78
117
|
/** One option toggled on/off in a `checkbox-group` field's value (deduped, order-preserving). */
|
|
79
118
|
export function toggleDescriptorGroupValue(
|
|
80
119
|
values: DescriptorFieldValues,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.237.0",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"valibot": "^1.4.2",
|
|
41
41
|
"vue": "3.5.40",
|
|
42
42
|
"wretch": "^3.0.9",
|
|
43
|
-
"@cat-factory/contracts": "0.
|
|
43
|
+
"@cat-factory/contracts": "0.257.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@toad-contracts/testing": "0.3.2",
|