@cat-factory/app 0.207.0 → 0.208.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 +125 -92
- package/app/components/fragments/FragmentLibraryManager.vue +4 -0
- package/app/components/fragments/FragmentLibraryPanel.vue +18 -6
- package/app/components/layout/IntegrationsHub.vue +3 -1
- package/app/components/pipeline/PipelineBuilder.vue +9 -2
- package/app/components/tutorial/TutorialPrompt.vue +20 -12
- package/app/composables/useTutorialTours.ts +9 -5
- package/app/docs/architecture.md +4 -4
- package/app/docs/consumer-extensions.md +47 -47
- package/app/modular/nav-contributions.ts +21 -9
- package/app/modular/tutorial-tours.spec.ts +190 -11
- package/app/modular/tutorial-tours.ts +226 -5
- package/app/utils/tutorial.spec.ts +19 -0
- package/app/utils/tutorial.ts +31 -1
- package/i18n/locales/de.json +101 -2
- package/i18n/locales/en.json +101 -2
- package/i18n/locales/es.json +101 -2
- package/i18n/locales/fr.json +101 -2
- package/i18n/locales/he.json +101 -2
- package/i18n/locales/it.json +101 -2
- package/i18n/locales/ja.json +101 -2
- package/i18n/locales/pl.json +101 -2
- package/i18n/locales/tr.json +101 -2
- package/i18n/locales/uk.json +101 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# `@cat-factory/app
|
|
1
|
+
# `@cat-factory/app`: Frontend (Nuxt layer)
|
|
2
2
|
|
|
3
3
|
The user-facing app, packaged as a **reusable Nuxt 4 layer**: a single-page app
|
|
4
4
|
that runs entirely in the browser and renders the architecture board, drives agent
|
|
@@ -26,8 +26,8 @@ The SPA source lives under `app/` (the Nuxt srcDir).
|
|
|
26
26
|
A spatial planning surface. You lay out a system as a **board** of frames
|
|
27
27
|
(services), modules and tasks on a [Vue Flow](https://vueflow.dev) canvas, wire up
|
|
28
28
|
dependencies, attach requirements, and apply **agent pipelines** to blocks.
|
|
29
|
-
Execution streams back in real time
|
|
30
|
-
prompts, failures with retry
|
|
29
|
+
Execution streams back in real time (step/subtask progress bars, decision
|
|
30
|
+
prompts, failures with retry) so the canvas doubles as a live dashboard.
|
|
31
31
|
|
|
32
32
|
It is a thin client: there is **no business logic here**. Every mutation calls the
|
|
33
33
|
Worker API and the stores hydrate from server snapshots and live updates pushed
|
|
@@ -36,11 +36,11 @@ over the WebSocket. How that sync works is written up in
|
|
|
36
36
|
|
|
37
37
|
## Tech stack
|
|
38
38
|
|
|
39
|
-
- **Nuxt 4 / Vue 3** SPA
|
|
40
|
-
- **Pinia** (+ `pinia-plugin-persistedstate`)
|
|
41
|
-
- **Vue Flow** (`core`, `background`, `controls`, `node-resizer`)
|
|
42
|
-
- **Nuxt UI** + Tailwind
|
|
43
|
-
- **VueUse
|
|
39
|
+
- **Nuxt 4 / Vue 3** SPA: single route (`pages/index.vue`).
|
|
40
|
+
- **Pinia** (+ `pinia-plugin-persistedstate`): feature stores.
|
|
41
|
+
- **Vue Flow** (`core`, `background`, `controls`, `node-resizer`): the canvas.
|
|
42
|
+
- **Nuxt UI** + Tailwind: components and styling.
|
|
43
|
+
- **VueUse**: composable utilities.
|
|
44
44
|
- Lint/format via **oxlint** + **oxfmt**; tests via **vitest** + **happy-dom**.
|
|
45
45
|
|
|
46
46
|
## Layout
|
|
@@ -48,7 +48,7 @@ over the WebSocket. How that sync works is written up in
|
|
|
48
48
|
| Path | Contents |
|
|
49
49
|
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
50
50
|
| `app.vue` | Root; wraps the page in `AuthGate`. |
|
|
51
|
-
| `pages/index.vue` | The only route
|
|
51
|
+
| `pages/index.vue` | The only route: mounts the sidebar, canvas, toolbar, inspector, focus view, and all modals. |
|
|
52
52
|
| `components/` | UI grouped by area (see [Key UI surfaces](#key-ui-surfaces)). |
|
|
53
53
|
| `composables/` | `useApi` (typed client), `useWorkspaceStream` (WebSocket sync), `useBlockDrag`, `useBlockQueries`, `useBoardFlow`, `useSemanticZoom`, `useDepLabels`. |
|
|
54
54
|
| `stores/` | Pinia stores, one per feature domain. |
|
|
@@ -64,14 +64,14 @@ component exists. So nothing a store reaches for at setup time may require an ac
|
|
|
64
64
|
component instance.
|
|
65
65
|
|
|
66
66
|
The one that bites is **`useI18n()`, which throws `MUST_BE_CALL_SETUP_TOP` outside a
|
|
67
|
-
component
|
|
67
|
+
component**, and because it happens inside a plugin, Nuxt's error boundary replaces the
|
|
68
68
|
whole app with its 500 page rather than surfacing a broken feature. Resolve translations
|
|
69
69
|
through the Nuxt app's global i18n instance instead (`useNuxtApp().$i18n`, typed as
|
|
70
70
|
`ReturnType<typeof useI18n>`), as `stores/board.ts`, `stores/recurringPipelines.ts` and
|
|
71
71
|
`composables/usePipelineErrorToast.ts` do. This costs no typed-message-key coverage: tier 1
|
|
72
72
|
only sees literal keys written in a `<script setup>`, never in a `.ts` store or composable.
|
|
73
73
|
|
|
74
|
-
The blast radius is why this is a rule rather than a preference
|
|
74
|
+
The blast radius is why this is a rule rather than a preference: a store reached one call
|
|
75
75
|
earlier than before takes the entire SPA down at boot, and the unit suite cannot see it
|
|
76
76
|
(nothing there installs the plugin). Every e2e spec does, because every one of them boots
|
|
77
77
|
the app.
|
|
@@ -89,17 +89,17 @@ The failure is silent, which is why this is a rule rather than a preference. An
|
|
|
89
89
|
## Interface modes (basic / advanced)
|
|
90
90
|
|
|
91
91
|
The SPA renders at one of two **interface tiers**. `basic` (the default) is the everyday
|
|
92
|
-
**delivery** surface
|
|
92
|
+
**delivery** surface: plan work on a board, run it, review and merge it: the run/pipeline
|
|
93
93
|
options that only exist to override a workspace-level default are left at that default, and
|
|
94
94
|
the nav is trimmed to what that loop needs. `advanced` shows everything. The tier resolves in
|
|
95
95
|
a fixed order, first match wins:
|
|
96
96
|
|
|
97
|
-
1. **`NUXT_PUBLIC_UI_MODE`** (`basic` | `advanced`)
|
|
97
|
+
1. **`NUXT_PUBLIC_UI_MODE`** (`basic` | `advanced`): the deployment pin. Like
|
|
98
98
|
`NUXT_PUBLIC_API_BASE` it is baked in at **build** time (`ssr: false`), and while it is
|
|
99
99
|
set the in-app switcher is a read-only indicator, since a preference the resolver ignores
|
|
100
100
|
would be a lie. An unrecognised value is ignored rather than failing the boot.
|
|
101
101
|
2. **The user's own choice**, persisted client-side (the `uiMode` store) and changed from the
|
|
102
|
-
switcher at the top of the sidebar, under the board switcher
|
|
102
|
+
switcher at the top of the sidebar, under the board switcher, or from the **command
|
|
103
103
|
palette** entry, which is deliberately _not_ an advanced item: basic is the default, so the
|
|
104
104
|
route back to the advanced half has to exist inside basic mode.
|
|
105
105
|
3. **`basic`.**
|
|
@@ -112,7 +112,7 @@ rail it degrades to one button that flips the tier (with only two modes a toggle
|
|
|
112
112
|
unambiguous), keeping the current tier's name under the glyph.
|
|
113
113
|
|
|
114
114
|
The sidebar can independently be **collapsed to an icon rail** (the toggle at its top, lg+
|
|
115
|
-
only
|
|
115
|
+
only: below `lg` the navbar is already an off-canvas drawer). The rail preference is
|
|
116
116
|
**per-tier**: basic _defaults_ to railed and advanced to expanded, and each tier remembers its
|
|
117
117
|
own choice, so an expand in either survives a reload and a round trip through the other.
|
|
118
118
|
|
|
@@ -121,13 +121,13 @@ hoc where it can be avoided:
|
|
|
121
121
|
|
|
122
122
|
- **A nav destination** declares `advanced: true` in `app/modular/nav-contributions.ts`. The
|
|
123
123
|
shared `navSlotFilter` drops it in basic mode across all three shells (sidebar, command
|
|
124
|
-
palette, toolbar), independently of its RBAC `gate
|
|
124
|
+
palette, toolbar), independently of its RBAC `gate`: both must pass. A consumer module's
|
|
125
125
|
own contributions take the same flag. The bar is **whether the everyday delivery loop needs
|
|
126
126
|
it**, and marking an item does one of two distinguishable things:
|
|
127
|
-
- **Reached another way
|
|
127
|
+
- **Reached another way**; a shortcut whose surface a basic destination also opens, so
|
|
128
128
|
nothing is lost (the Merge / Service-best-practices palette entries into Workspace
|
|
129
129
|
settings, the local-models knob the Model providers hub already offers).
|
|
130
|
-
- **Out of the tier
|
|
130
|
+
- **Out of the tier**: the sole route, hidden on purpose, so the capability is _absent_
|
|
131
131
|
from basic mode and the tier switch is the way to it (Sandbox, Kaizen, repo bootstrap,
|
|
132
132
|
and the deployment-wide operator + reports rollups).
|
|
133
133
|
|
|
@@ -140,27 +140,27 @@ hoc where it can be avoided:
|
|
|
140
140
|
- **A less-used option inside a surface** reads `useUiModeStore().isAdvanced`. Hide, never
|
|
141
141
|
disable, and only ever hide an OVERRIDE: what remains must be exactly the default the hidden
|
|
142
142
|
field would have shown, so a basic-mode user never gets different behaviour from an advanced
|
|
143
|
-
one
|
|
143
|
+
one; only fewer choices. An input nothing else supplies (the pipeline, the apriori branches)
|
|
144
144
|
stays in both tiers however advanced it feels.
|
|
145
|
-
- **A whole AUTHORING affordance** may be tier-scoped the same way
|
|
146
|
-
recurring-schedule and initiative buttons are advanced-only
|
|
145
|
+
- **A whole AUTHORING affordance** may be tier-scoped the same way (the frame header's
|
|
146
|
+
recurring-schedule and initiative buttons are advanced-only) but only while the tier hides
|
|
147
147
|
the ability to CREATE, never the ability to SEE. Existing state has to stay legible in basic
|
|
148
148
|
mode through its normal surfaces (a live schedule still badges its task card and opens its
|
|
149
149
|
inspector panel; an initiative is still a block on the board with its own inspector), or the
|
|
150
150
|
tier turns into a way for a user to be acted on by configuration they cannot find.
|
|
151
151
|
- **An override control on an EXISTING entity gates on `showOverrideField(isAdvanced, …values)`**
|
|
152
152
|
(`app/utils/uiMode.ts`) rather than on `isAdvanced` alone. Hiding an override is only safe
|
|
153
|
-
while it is unset
|
|
153
|
+
while it is unset: always true for a creation form, never guaranteed for a block that a
|
|
154
154
|
teammate on the advanced tier (or the API) already wrote one onto. The helper reveals the
|
|
155
|
-
control, editable, as soon as any value it edits is set (`false` included
|
|
155
|
+
control, editable, as soon as any value it edits is set (`false` included: a tri-state
|
|
156
156
|
`false` is a choice, not absence), so basic mode can never conceal a setting a run will
|
|
157
157
|
actually use.
|
|
158
158
|
|
|
159
159
|
## Agent tiers (basic / intermediate / advanced)
|
|
160
160
|
|
|
161
161
|
A separate, narrower axis: how deep into the **agent catalog** a surface reaches. Every agent
|
|
162
|
-
kind carries a `tier
|
|
163
|
-
regularly) or `advanced` (specialist)
|
|
162
|
+
kind carries a `tier`: `basic` (the everyday delivery loop), `intermediate` (reached for
|
|
163
|
+
regularly) or `advanced` (specialist), and the two surfaces that enumerate the catalog, the
|
|
164
164
|
**pipeline builder's palette** and the **model preset's per-agent override list**, show the
|
|
165
165
|
selected tier and everything below it. They open on `basic`; the `AgentTierSelect` control on
|
|
166
166
|
each widens them, with `advanced` showing the whole catalog. The choice is one shared,
|
|
@@ -169,16 +169,16 @@ picking what each of them runs on are halves of the same job.
|
|
|
169
169
|
|
|
170
170
|
- The vocabulary, the default and the cumulative predicate live in `@cat-factory/contracts`
|
|
171
171
|
(`AGENT_TIERS` / `DEFAULT_AGENT_TIER` / `agentTierVisibleAt`), beside
|
|
172
|
-
`purposeAllowsAgentCategory
|
|
172
|
+
`purposeAllowsAgentCategory`, so a **deployment-registered kind's** declared tier
|
|
173
173
|
(`presentation.tier`, carried in the workspace snapshot) and the SPA's own built-ins are
|
|
174
174
|
read by one rule. A kind that declares no tier is treated as `intermediate`.
|
|
175
175
|
- **This is not the interface mode.** That tier decides which surfaces the whole SPA offers;
|
|
176
|
-
this one decides how much of one surface's catalog is listed. They are independent
|
|
177
|
-
advanced-mode user still starts on the basic agent tier
|
|
176
|
+
this one decides how much of one surface's catalog is listed. They are independent (an
|
|
177
|
+
advanced-mode user still starts on the basic agent tier) and the tier control is present in
|
|
178
178
|
**both** interface modes, since it is the only route to the kinds it hides.
|
|
179
179
|
- A narrowed catalog states what it is holding back (the "n hidden at this tier" hint), and
|
|
180
180
|
the model preset list **always keeps a kind the edited preset already pins a model for**,
|
|
181
|
-
whatever the tier
|
|
181
|
+
whatever the tier: the same rule `showOverrideField` states for a single field: a row the
|
|
182
182
|
user can neither read nor clear is worse than a longer list.
|
|
183
183
|
|
|
184
184
|
## In-app tutorial tours
|
|
@@ -188,69 +188,81 @@ whether the user wants a guided tour. The answer is SAVED per browser (`stores/t
|
|
|
188
188
|
persisted like the interface tier): "no thanks" stops the prompt for good, and closing without
|
|
189
189
|
answering defers it to the next launch.
|
|
190
190
|
|
|
191
|
-
**The prompt is the OFFER; the catalogue is the library.** `TutorialCatalogue.vue`
|
|
192
|
-
sidebar's Help section, the palette, and a button in the prompt's own footer
|
|
191
|
+
**The prompt is the OFFER; the catalogue is the library.** `TutorialCatalogue.vue` (the
|
|
192
|
+
sidebar's Help section, the palette, and a button in the prompt's own footer) lists every tour
|
|
193
193
|
the deployment ships and lets any of them be started, resumed or repeated at any time. The two
|
|
194
194
|
surfaces exist separately because they answer different questions, and the split is what keeps
|
|
195
195
|
the prompt a short answerable one rather than a browsing surface. Start / Resume / Repeat /
|
|
196
196
|
Back-to-the-tour is decided ONCE for both (`useTutorialLaunch` over the pure `tourState` +
|
|
197
197
|
`launchActionFor`), or the same button would mean different things on two screens.
|
|
198
198
|
|
|
199
|
+
**Which is why a tour can be catalogue-only** (`offeredAtLaunch: false`, read through the pure
|
|
200
|
+
`isLaunchOffer`; `useTutorialTours` exposes `offered` for the prompt beside `tours` for the
|
|
201
|
+
overlay). The catalog covers the PLATFORM as well as the delivery loop (the engine, the pipeline
|
|
202
|
+
builder, the standards library, the integrations), and those tours gate on a PERMISSION rather
|
|
203
|
+
than on board state, so every one of them is startable on a brand-new board. Offered unfiltered
|
|
204
|
+
they would put six walkthroughs in front of someone whose board has neither a repository nor a
|
|
205
|
+
task, burying the two they can act on. The default is OFFERED, so a consumer deployment's tour
|
|
206
|
+
appears beside the built-ins with nothing to declare, and a tour cannot fall out of the offer by
|
|
207
|
+
omission. It thins an offer, never the library: an un-offered tour is listed, startable, counted
|
|
208
|
+
in the progress line and one footer button away, and `requires` remains the only thing that can
|
|
209
|
+
hold a tour back, which is always reported.
|
|
210
|
+
|
|
199
211
|
**The catalogue lists the tours it CANNOT start, and says what would unlock each.** That is the
|
|
200
212
|
reason a tour's preconditions are declared (`TutorialRequirement`: an id, a copy key, and the
|
|
201
213
|
gate predicate) rather than being an anonymous `when(gates)`. A predicate can only answer "no",
|
|
202
214
|
and a list that quietly omits four of six walkthroughs is indistinguishable from a deployment
|
|
203
|
-
that ships two
|
|
215
|
+
that ships two: to exactly the user who came looking for the rest. It also forces the two
|
|
204
216
|
unavailable cases apart, because they need different reactions: `blocked` names something the
|
|
205
|
-
reader can go and do ("A service on the board"), while `not-applicable`
|
|
206
|
-
every step is about a branch this board isn't on
|
|
217
|
+
reader can go and do ("A service on the board"), while `not-applicable` (requirements met, but
|
|
218
|
+
every step is about a branch this board isn't on) names nothing at all, and telling them to fix
|
|
207
219
|
it would send them hunting for a control that was never missing.
|
|
208
220
|
|
|
209
221
|
**Tour gating therefore does NOT live in `navSlotFilter`**, unlike every other gated slot. A
|
|
210
222
|
`SlotFilter` maps slots to slots, so it can only drop; `resolveTourCatalogue` (pure,
|
|
211
223
|
gates-nullable, in `utils/tutorial.ts`) returns every tour with its availability and its unmet
|
|
212
|
-
requirements, and `useTutorialTours` runs it once
|
|
224
|
+
requirements, and `useTutorialTours` runs it once: exposing `tours` (what can start now, which
|
|
213
225
|
is what the prompt and the overlay have always seen) and `catalogue` (everything, annotated). It
|
|
214
226
|
reads the SAME registered `gates` service the nav filter does, through the shared-dependency
|
|
215
227
|
`useOptional('gates')`, so the two can never disagree about what this board offers.
|
|
216
228
|
|
|
217
229
|
Progress is per tour id and per browser. The catalogue's counter is over the WHOLE catalog, not
|
|
218
|
-
the runnable part
|
|
230
|
+
the runnable part: counting only today's runnable tours would move the denominator every time a
|
|
219
231
|
repo was linked, and "2 of 2 completed" on a board with four walkthroughs still waiting reads as
|
|
220
232
|
a finished tutorial. `Reset progress` clears the completions, the resume point AND the saved
|
|
221
233
|
launch answer, because everyone who asks for it (demoing, handing the app to a colleague) wants
|
|
222
234
|
the first-launch experience back; it leaves a RUNNING tour alone, since a click about history
|
|
223
235
|
must not end the walkthrough in progress. **It is therefore offered whenever ANY of those three
|
|
224
|
-
is set, not only when a tour was taken
|
|
236
|
+
is set, not only when a tour was taken**: someone who answered "No thanks" and stopped there has
|
|
225
237
|
nothing completed and nothing paused, and that saved answer is the whole of what stands between
|
|
226
238
|
them and the offer they came to restore.
|
|
227
239
|
|
|
228
240
|
**The coach marks stand down while a tutorial-owned window is open** (`ownWindowOpen`). The
|
|
229
241
|
overlay renders at `z-[70]`, above the app's own modals, because a step legitimately points INTO
|
|
230
|
-
one
|
|
242
|
+
one, but no step points into the prompt or the catalogue, so there the same rule would float a
|
|
231
243
|
highlight ring and a tooltip over the window the user just opened. The catalogue reaches that
|
|
232
244
|
state by design: it is openable mid-tour, which is what the `continue` action is for. The overlay
|
|
233
245
|
is SUPPRESSED rather than unmounted, because it holds the running tour's resolved script and a
|
|
234
246
|
remount would re-resolve it against gates that may have flipped since the tour started.
|
|
235
247
|
|
|
236
|
-
The arc this surface is being built along
|
|
237
|
-
is still open
|
|
248
|
+
The arc this surface is being built along (what has landed, what each slice learned, and what
|
|
249
|
+
is still open) is tracked in
|
|
238
250
|
[`docs/initiatives/in-app-tutorials.md`](../../docs/initiatives/in-app-tutorials.md). This
|
|
239
251
|
section is the authority on how the thing WORKS.
|
|
240
252
|
|
|
241
253
|
A tour is **data, not components**: an ordered list of steps, each pointing at an on-screen
|
|
242
|
-
control by its `data-testid` (the e2e anchor vocabulary
|
|
254
|
+
control by its `data-testid` (the e2e anchor vocabulary; cover a control that has none by
|
|
243
255
|
adding the test id first) and carrying i18n keys for its copy. One shared runtime
|
|
244
256
|
(`components/tutorial/TutorialOverlay.vue`) renders every tour: it highlights the current
|
|
245
257
|
step's control, places the tooltip (`utils/tutorial.ts` owns the pure geometry + types),
|
|
246
|
-
advances on Next or
|
|
258
|
+
advances on Next or (for `advanceOn: 'target-click'` steps) on the user really clicking
|
|
247
259
|
the control, so the app's real response (the actual modal, the actual task) is what the next
|
|
248
260
|
step anchors to. `target-click` is for BUTTONS, where the click is the completed action; a
|
|
249
261
|
text field keeps Next, or the tooltip would leave the instruction the moment the user clicked
|
|
250
262
|
in to type. A step whose anchor never appears within its wait is SKIPPED, because controls
|
|
251
263
|
come and go with RBAC, tier, and deployment wiring: a tour is a set of opportunities, not a
|
|
252
264
|
fixed script. Reaching the end having skipped steps is reported on the final card rather than
|
|
253
|
-
congratulating the user on a walkthrough they did not see
|
|
265
|
+
congratulating the user on a walkthrough they did not see, and a tour that could only ever
|
|
254
266
|
be abridged should not be offered at all, which is what each tour's `requires` is for (the
|
|
255
267
|
task-creation tour needs board write AND a service frame to add a task to).
|
|
256
268
|
|
|
@@ -263,13 +275,13 @@ missed half of it, every time. `resolveTourCatalogue` (in `utils/tutorial.ts`) d
|
|
|
263
275
|
rejected steps and marks a tour left with none `not-applicable` rather than ready, so a tour
|
|
264
276
|
whose every step is branch-specific can never open on an empty cursor. With no gates service
|
|
265
277
|
wired at all (a bare install withholds nothing) every branch survives instead, and only one
|
|
266
|
-
of them can anchor
|
|
278
|
+
of them can anchor, so the abridged notice ignores any skipped step that carries a `when`,
|
|
267
279
|
which has already declared that not applying is legitimate.
|
|
268
280
|
|
|
269
281
|
**Gates decide what is OFFERED; the running tour's script is resolved once and HELD.** The
|
|
270
282
|
overlay snapshots its tour when it starts rather than re-reading the gated slot on every
|
|
271
283
|
flip. This is not an optimisation: gates over live run state flip as a direct result of
|
|
272
|
-
following the tour
|
|
284
|
+
following the tour; `answer-park` is offered while something waits for a human, so the
|
|
273
285
|
moment the user answers, its `when` goes false. A re-reading overlay tore itself down there,
|
|
274
286
|
one step short of its own finish card and with nothing recorded as completed, at exactly the
|
|
275
287
|
moment the user succeeded. Holding the script also freezes the branch `resolveTours` chose,
|
|
@@ -285,60 +297,81 @@ are task-scoped for the same reason.
|
|
|
285
297
|
**Fixed proper nouns ride `bodyParams`, not the catalogs.** A step naming the sample
|
|
286
298
|
repository slug (`SAMPLE_REPO` in `modular/tutorial-tours.ts`) passes it as a `{repo}`
|
|
287
299
|
interpolation, so it is written once in code rather than translated into ten catalogs that
|
|
288
|
-
each drift on their own
|
|
289
|
-
|
|
290
|
-
The built-ins
|
|
291
|
-
leaves behind, so the launch prompt only ever offers what this board can demonstrate:
|
|
292
|
-
basics, add a repository (`add-service`), create a task (`first-task`), run it (`run-task`),
|
|
293
|
-
answer it when it parks (`answer-park`), review and merge the result (`review-merge`).
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
300
|
+
each drift on their own: the same split components make for inline placeholders.
|
|
301
|
+
|
|
302
|
+
The built-ins come in two halves. The DELIVERY LOOP, end to end, each tour gated on the state the
|
|
303
|
+
previous one leaves behind, so the launch prompt only ever offers what this board can demonstrate:
|
|
304
|
+
board basics, add a repository (`add-service`), create a task (`first-task`), run it (`run-task`),
|
|
305
|
+
answer it when it parks (`answer-park`), review and merge the result (`review-merge`). Then the
|
|
306
|
+
PLATFORM behind it, catalogue-only: connect an engine (`wire-models`), assemble a flow
|
|
307
|
+
(`design-pipeline`), curate the standards agents read (`agent-standards`), link the systems a run
|
|
308
|
+
talks to (`connect-systems`). Each of those covers ONE surface and ends there, because the surface
|
|
309
|
+
opens as a modal over the sidebar it was reached from, so a later step could not click another
|
|
310
|
+
sidebar entry anyway, and each declares exactly the permission that renders the entry it clicks,
|
|
311
|
+
since a weaker requirement offers a tour to someone with no such control and it then reports itself
|
|
312
|
+
abridged.
|
|
313
|
+
|
|
314
|
+
**That pairing is DERIVED from the nav catalog, not restated.** A step whose anchor IS a nav
|
|
315
|
+
entry's `testId` is checked by `navRequirementDrift` (`tutorial-tours.spec.ts`, beside the anchor
|
|
316
|
+
guard) against that `NavContribution`'s OWN `gate`: over every combination of the `NavGates`
|
|
317
|
+
booleans, a gate set satisfying the tour's `requires` must also be one the entry RENDERS in.
|
|
318
|
+
Restating the permission in a spec's gate literal is what this replaced, and it could not catch
|
|
319
|
+
either edit that really breaks the pairing, because neither one touches the tour. Tightening an
|
|
320
|
+
entry's `gate` is the obvious one. The subtler one is marking it `advanced: true`, which hides it
|
|
321
|
+
from BASIC mode; basic is the shipped default, so that tour would be offered to nearly everyone
|
|
322
|
+
and find nothing. "Renders" therefore means the gate AND the tier, and a tour that wants an
|
|
323
|
+
advanced entry has to declare the tier as a requirement of its own.
|
|
324
|
+
|
|
325
|
+
Two deliberate asymmetries about click-to-advance: `run-task` points at Start without it,
|
|
326
|
+
because starting a run spends real model budget and nobody should discover they agreed to that by
|
|
327
|
+
following a tutorial; `design-pipeline` points at Save without it, because Save is DISABLED until
|
|
328
|
+
the draft holds a step, and a click-to-advance step whose control cannot be clicked has no Next
|
|
329
|
+
button either, so it strands the tour.
|
|
297
330
|
|
|
298
331
|
Two runtime constraints worth knowing before changing the overlay: it must keep
|
|
299
332
|
`pointer-events-auto` and swallow `pointerdown`, because Nuxt UI modals are reka-ui
|
|
300
333
|
dismissable layers that set `body { pointer-events: none }` and dismiss on an outside
|
|
301
|
-
pointerdown
|
|
334
|
+
pointerdown; without both, the tooltip's own buttons go inert and pressing one closes the
|
|
302
335
|
user's half-filled form. And everything that DECIDES (skip direction, wait budget,
|
|
303
336
|
target-click matching, which skips count as abridged) lives in
|
|
304
337
|
`components/tutorial/TutorialOverlay.logic.ts` so it is unit-tested; the SFC keeps only the
|
|
305
|
-
DOM work.
|
|
338
|
+
DOM work. Target-click matching is by SELECTOR, not by the highlighted element:
|
|
306
339
|
several anchors (`task-card`, `task-resolve`, `run-step`) render once per board item and the
|
|
307
340
|
ring can only sit on one of them, so requiring the click to land on that one left a user who
|
|
308
|
-
clicked the card the copy asked for with no way forward
|
|
341
|
+
clicked the card the copy asked for with no way forward; such a step renders no Next.
|
|
309
342
|
|
|
310
343
|
**An anchor that is on the page but off SCREEN is revealed before it is pointed at.** An
|
|
311
344
|
element scrolled out of a panel or panned off the board still HAS layout boxes, so it passes
|
|
312
|
-
the visibility check
|
|
345
|
+
the visibility check, and the ring was drawn at off-screen coordinates while the tooltip
|
|
313
346
|
clamped to a viewport edge, leaving the user reading "click this" beside nothing. The two
|
|
314
347
|
`task-card` steps hit this hardest, since they anchor whichever card is first in the DOM.
|
|
315
348
|
`needsReveal` (in `utils/tutorial.ts`, unit-tested) decides, measuring against
|
|
316
|
-
`min(anchorArea, viewportArea)` so a control bigger than the viewport
|
|
317
|
-
`sidebar`
|
|
349
|
+
`min(anchorArea, viewportArea)` so a control bigger than the viewport (`board-canvas`,
|
|
350
|
+
`sidebar`) is judged on how much of the SCREEN it fills rather than on a fraction of its own
|
|
318
351
|
area it could never clear. The mechanism then depends on the container: the board is a
|
|
319
352
|
transform-panned Vue Flow canvas, where `scrollIntoView` does nothing and the camera has to
|
|
320
353
|
move instead (clamped to the current zoom, or fitting one button would throw away the user's
|
|
321
354
|
view of their board), and everything else is an ordinary scroll. `boardNodeIdFor` asks the
|
|
322
|
-
DOM which it is, rather than keying off the target id
|
|
355
|
+
DOM which it is, rather than keying off the target id: the same id is a canvas node on the
|
|
323
356
|
board and a plain row in a panel. A reveal is attempted at most once per step, because both
|
|
324
357
|
mechanisms are animations longer than a tracking tick.
|
|
325
358
|
|
|
326
359
|
**Tracking is event-driven once an anchor is held**: only the hunt for a not-yet-mounted
|
|
327
360
|
anchor polls fast, and it is bounded by the step's wait budget. Movement arrives from scroll
|
|
328
361
|
(capture phase, so every scroll container counts), window resize, a `ResizeObserver` on the
|
|
329
|
-
anchor, and the board camera
|
|
362
|
+
anchor, and the board camera: with a slow backstop tick that also RE-RESOLVES the selector,
|
|
330
363
|
which is what lets a step re-anchor when its control is replaced underneath it. Every one of
|
|
331
364
|
those re-measures is coalesced into one per animation frame, because `measure()` reads layout
|
|
332
365
|
and then writes it, and capture-phase scroll fires for every container many times a frame.
|
|
333
366
|
|
|
334
367
|
**Accessibility.** The card is a non-modal `dialog` and deliberately not a focus trap: half
|
|
335
368
|
the catalog asks the user to operate the real control behind it. Focus moves onto the card
|
|
336
|
-
when the tour starts and on every Next/Back
|
|
337
|
-
whole page, since the overlay is teleported to the end of `body`
|
|
369
|
+
when the tour starts and on every Next/Back (without that a keyboard user has to tab the
|
|
370
|
+
whole page, since the overlay is teleported to the end of `body`) but never on a
|
|
338
371
|
`target-click` advance, where the app is opening a modal that rightly autofocuses its own
|
|
339
372
|
first field and the NEXT step is usually the one telling the user to type in it. That is a
|
|
340
373
|
decision, so it is `shouldFocusCard` in the logic module with a test on it, not an `if` at
|
|
341
|
-
the call site
|
|
374
|
+
the call site: it was an inline one, and a call site that forgot it is exactly how the card
|
|
342
375
|
came to steal focus from the modal it had just opened.
|
|
343
376
|
|
|
344
377
|
Step changes are announced through a separate `role="status"` region rather than `aria-live`
|
|
@@ -346,28 +379,28 @@ on the card, because the card's entire contents are replaced per step and a whol
|
|
|
346
379
|
swap inside a dialog is not reliably announced. Two things about that region are load-bearing
|
|
347
380
|
and easy to undo by accident: it lives OUTSIDE the overlay's `v-if` and its text lands a tick
|
|
348
381
|
after the node does, because assistive tech announces a CHANGE to a live region and routinely
|
|
349
|
-
says nothing about one that was inserted already populated
|
|
382
|
+
says nothing about one that was inserted already populated, which would silently cost the
|
|
350
383
|
first step of every tour. And it is the SOLE announcement: the card carries no
|
|
351
384
|
`aria-describedby`, or the body would be read a second time on every focus move.
|
|
352
385
|
|
|
353
386
|
Motion is honoured on both sides: `motion-safe:` on the ring transition and the searching
|
|
354
387
|
spinner, and an instant scroll and camera move under `prefers-reduced-motion`.
|
|
355
388
|
|
|
356
|
-
**Breaking off a tour leaves a resume point.** Esc and Skip are both easy to reach
|
|
357
|
-
accident, one to get the overlay out of the way for a moment
|
|
389
|
+
**Breaking off a tour leaves a resume point.** Esc and Skip are both easy to reach (one by
|
|
390
|
+
accident, one to get the overlay out of the way for a moment) and what they discarded was
|
|
358
391
|
the whole walkthrough. `stopTour()` records where it stopped and the prompt offers Resume
|
|
359
392
|
instead of only Start. Session-only, like the cursor itself: within a session the board is
|
|
360
393
|
still in the state the tour left it in, which is exactly what a DOM-anchored position needs.
|
|
361
394
|
The store validates no index (it knows nothing about which tours exist), so the overlay
|
|
362
|
-
clamps a resume that lands past the end of a script the gates have thinned since
|
|
395
|
+
clamps a resume that lands past the end of a script the gates have thinned since, and the
|
|
363
396
|
runtime's own bail-out on an unresolvable tour passes `resumable: false`, or resuming would
|
|
364
397
|
put the user straight back into the same dead overlay. There is ONE slot, and starting a tour
|
|
365
398
|
clears only that tour's own entry: another tour's position is not this action's to discard,
|
|
366
|
-
and it loses the slot soon enough
|
|
399
|
+
and it loses the slot soon enough, when this one is broken off past step 0.
|
|
367
400
|
|
|
368
401
|
The catalog is the `tutorialTours` slot: first-party tours live in
|
|
369
402
|
`modular/tutorial-tours.ts`, and a consumer deployment contributes its own through
|
|
370
|
-
`registerAppModule
|
|
403
|
+
`registerAppModule`; they appear in the prompt and the catalogue beside the built-ins, held
|
|
371
404
|
back per tour by its own `requires` (resolved against the same reactive gates service the nav
|
|
372
405
|
uses). A consumer writes its own requirement objects with its own copy keys; the first-party
|
|
373
406
|
ones are shared constants (`TUTORIAL_REQUIREMENTS`), because a second copy of "a service on the
|
|
@@ -377,7 +410,7 @@ per tour id, so renaming an id resets its state.
|
|
|
377
410
|
**A built-in tour's anchors are drift-guarded** (`tutorial-tours.spec.ts`), because they are
|
|
378
411
|
the one thing about a tour that nothing else in the build checks: a renamed `data-testid`
|
|
379
412
|
passes typecheck, lint and the whole e2e suite, and several anchors have no other consumer at
|
|
380
|
-
all. The failure it prevents is worse than a dead step
|
|
413
|
+
all. The failure it prevents is worse than a dead step: those steps carry no `when`, so the
|
|
381
414
|
miss counts as an unexpected skip and every user lands on a permanent "you missed N steps"
|
|
382
415
|
notice, a false claim the tour goes on making in production with nothing red anywhere. The
|
|
383
416
|
guard scans the layer for both ways an id is named: written onto an element, or declared as a
|
|
@@ -386,8 +419,8 @@ is scoped to the built-in catalog, since a consumer's tours anchor on its own la
|
|
|
386
419
|
|
|
387
420
|
## Extending the layer (consumer modules)
|
|
388
421
|
|
|
389
|
-
A deployment can contribute its own components
|
|
390
|
-
panels, agent-kind palette data
|
|
422
|
+
A deployment can contribute its own components (result windows, nav entries, inspector
|
|
423
|
+
panels, agent-kind palette data) plus two DATA-only seams that need no components at all:
|
|
391
424
|
its own applications in an **External tools** sidebar section (`externalTools`, each
|
|
392
425
|
resolving its URL from the acting user / open workspace / this board's custom fields) and the
|
|
393
426
|
**custom workspace metadata fields** those resolvers read (`workspaceMetadataFields`, edited
|
|
@@ -402,49 +435,49 @@ example ships in [`deploy/frontend`](../../deploy/frontend) (the `acme:security`
|
|
|
402
435
|
|
|
403
436
|
## Key UI surfaces
|
|
404
437
|
|
|
405
|
-
- **Board canvas** (`components/board`)
|
|
438
|
+
- **Board canvas** (`components/board`): `BoardCanvas` + `nodes/` (`BlockNode`,
|
|
406
439
|
`ModuleFrame`, `TaskCard`), dependency edges, the per-block `AgentFailureCard` /
|
|
407
440
|
`AgentStopButton`, and a deep-zoom `focus/BlockFocusView`. A running task card expands
|
|
408
441
|
its build pipeline (`TaskPipelineMini`) on hover at any zoom level, and across every
|
|
409
|
-
on-screen card past the `steps` zoom band
|
|
442
|
+
on-screen card past the `steps` zoom band: the two grants are combined in the
|
|
410
443
|
`taskExpansion` store and driven by `useTaskExpansion`.
|
|
411
|
-
- **Sidebar & chrome** (`components/layout`)
|
|
444
|
+
- **Sidebar & chrome** (`components/layout`): board/account switchers, palettes
|
|
412
445
|
entry points, the language + [interface-mode](#interface-modes-basic--advanced)
|
|
413
446
|
switchers, the `SpendWarningBanner`, and the toolbar (zoom, LOD, decision queue).
|
|
414
|
-
- **Palettes** (`components/palettes`)
|
|
447
|
+
- **Palettes** (`components/palettes`): drag blocks, pipelines and agents onto
|
|
415
448
|
the board.
|
|
416
|
-
- **Inspector** (`components/panels` + `panels/inspector`)
|
|
449
|
+
- **Inspector** (`components/panels` + `panels/inspector`): per-block tabs:
|
|
417
450
|
structure, dependencies, model + fragment picker, live execution, and linked
|
|
418
451
|
docs/issues/scenarios. Decisions resolve via `DecisionModal`. A `review` task
|
|
419
|
-
additionally leads with `TaskReviewTarget`, linking the pull request it reviews
|
|
452
|
+
additionally leads with `TaskReviewTarget`, linking the pull request it reviews:
|
|
420
453
|
distinct from the execution panel's link to the PR a run PRODUCED, which a review
|
|
421
454
|
task never has.
|
|
422
|
-
- **Pipeline builder** (`components/pipeline`)
|
|
455
|
+
- **Pipeline builder** (`components/pipeline`): assemble/edit agent chains and
|
|
423
456
|
watch `PipelineProgress`. `PipelinePicker` (+ its `PipelinePreview` pane) is the
|
|
424
|
-
single way a pipeline is chosen anywhere
|
|
425
|
-
schedule, the focus view's Run menu
|
|
457
|
+
single way a pipeline is chosen anywhere (add-task, run settings, the recurring
|
|
458
|
+
schedule, the focus view's Run menu) so every surface explains a pipeline by the
|
|
426
459
|
ordered steps it will run rather than by its name alone.
|
|
427
|
-
- **Context attachments** (`components/context`)
|
|
460
|
+
- **Context attachments** (`components/context`): `ContextAttachmentFields`, the
|
|
428
461
|
shared staged-attachment form used by both the add-task and create-initiative
|
|
429
462
|
modals. Picks are held locally and import-and-linked once the block exists (see
|
|
430
463
|
`composables/useContextLinking`), because linking needs a block id. Both hosts
|
|
431
464
|
attach to the SAME per-block linkage, so the inspector's `TaskContextDocs` /
|
|
432
|
-
`TaskContextIssues` sections render for a task AND an initiative
|
|
465
|
+
`TaskContextIssues` sections render for a task AND an initiative: an initiative's
|
|
433
466
|
attachments would otherwise be invisible the moment the create modal closed.
|
|
434
|
-
- **Integrations
|
|
467
|
+
- **Integrations**: modals/panels for `github` (the source-control panel, shared
|
|
435
468
|
by every VCS provider), `vcs` (the GitLab personal-access-token connect),
|
|
436
469
|
`bootstrap`, `documents`, `tasks`, `requirements` (review), `scenarios`
|
|
437
470
|
(acceptance), and `fragments` (the prompt-fragment library).
|
|
438
|
-
- **Model providers
|
|
471
|
+
- **Model providers**: `ModelProvidersHub.vue`, the sibling hub for the ENGINES
|
|
439
472
|
(OpenRouter, vendor keys, personal subscriptions, own-machine runners). Kept out
|
|
440
473
|
of the Integrations hub on purpose: an integration is optional context in or
|
|
441
474
|
output out, while a provider is what executes the work, so a deployment with none
|
|
442
475
|
connected runs nothing at all. **A new provider-shaped connection belongs here,
|
|
443
476
|
never in `IntegrationsHub.vue`.** Both hubs, plus the user-scoped `PersonalSetupModal`,
|
|
444
477
|
share the `IntegrationBackTitle` Back control, which returns to whichever hub set
|
|
445
|
-
its came-from marker (`ui.cameFrom{Integrations,ModelProviders,Personal}`)
|
|
478
|
+
its came-from marker (`ui.cameFrom{Integrations,ModelProviders,Personal}`): a panel
|
|
446
479
|
reachable from more than one hub must not hard-code its return.
|
|
447
|
-
- **Auth** (`components/auth`)
|
|
480
|
+
- **Auth** (`components/auth`): `AuthGate` / `LoginScreen` / `UserMenu`; the app
|
|
448
481
|
is gated when the backend requires sign-in.
|
|
449
482
|
|
|
450
483
|
## Where a surface lives
|
|
@@ -453,7 +486,7 @@ Two placements are load-bearing enough to state, because putting a new one in th
|
|
|
453
486
|
wrong place is invisible until a user cannot find it:
|
|
454
487
|
|
|
455
488
|
- **The sidebar section is a claim about what the destination IS.** `models` is the
|
|
456
|
-
model layer
|
|
489
|
+
model layer: the engines, the per-agent model choice, and the surfaces that
|
|
457
490
|
evaluate a prompt+agent+model combination (Sandbox, Kaizen); `integrations` the
|
|
458
491
|
optional EXTERNAL systems; `infrastructure` where agent containers and test
|
|
459
492
|
environments run; `configuration` workspace/account settings. A surface that
|
|
@@ -471,7 +504,7 @@ wrong place is invisible until a user cannot find it:
|
|
|
471
504
|
- **"It talks to an external service" is not what puts a surface in `integrations`.**
|
|
472
505
|
Private package registries connect to npmjs.com and GitHub Packages and still belong
|
|
473
506
|
in Infrastructure, because the question they answer is _what may a container install
|
|
474
|
-
from_
|
|
507
|
+
from_: a property of where agents RUN, which is what the Infrastructure window is
|
|
475
508
|
for. `integrations` is for a system the WORKSPACE links in and would still be a
|
|
476
509
|
coherent product without. Ask which question the destination answers, not whether a
|
|
477
510
|
credential leaves the building. A surface moved between sections must also move its
|
|
@@ -488,6 +521,6 @@ pnpm typecheck # nuxt typecheck
|
|
|
488
521
|
pnpm lint # oxlint + oxfmt --check
|
|
489
522
|
```
|
|
490
523
|
|
|
491
|
-
> Building/deploying the static site is covered in the deployment docs
|
|
524
|
+
> Building/deploying the static site is covered in the deployment docs: see the
|
|
492
525
|
> [top-level README → Deployment](../../README.md#deployment) and
|
|
493
526
|
> [`deploy/frontend/README.md`](../../deploy/frontend/README.md).
|
|
@@ -599,6 +599,10 @@ async function unlinkSource(id: string) {
|
|
|
599
599
|
</script>
|
|
600
600
|
|
|
601
601
|
<template>
|
|
602
|
+
<!-- Deliberately UNNAMED. This manager is mounted at two scopes (the board's library modal and
|
|
603
|
+
the account settings' fragment tab), so a `data-testid` on its root would be one id over
|
|
604
|
+
two elements. The tutorial tour's anchor is named by the WORKSPACE entry point instead —
|
|
605
|
+
see `FragmentLibraryPanel.vue`. -->
|
|
602
606
|
<div class="flex flex-col gap-4">
|
|
603
607
|
<!-- The library is opt-out; if a deployment disabled it, don't offer forms that
|
|
604
608
|
would fail with a raw 503 — say so instead (any entry point lands here). -->
|
|
@@ -20,12 +20,24 @@ const open = computed({
|
|
|
20
20
|
<template>
|
|
21
21
|
<UModal v-model:open="open" :title="t('fragments.panel.title')" :ui="{ content: 'max-w-3xl' }">
|
|
22
22
|
<template #body>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
<!-- The anchor for the tutorial tour about steering agents with standards, which points at
|
|
24
|
+
the library as a whole. Named HERE rather than inside the manager because that manager
|
|
25
|
+
is mounted at two scopes (this modal and the account settings' fragment tab), so an id
|
|
26
|
+
on its root would be one id over two elements, leaving which one the tour highlights to
|
|
27
|
+
DOM order. A wrapper rather than an attribute passed down to it, because the anchor
|
|
28
|
+
DRIFT GUARD reads test ids out of the templates that write them: Vue's attribute
|
|
29
|
+
fallthrough would still satisfy the guard on the day someone adds a second root node to
|
|
30
|
+
the manager and the anchor stops rendering.
|
|
31
|
+
The tour requires the library to be enabled, so it never lands on the manager's own
|
|
32
|
+
unavailable notice. -->
|
|
33
|
+
<div data-testid="fragment-library">
|
|
34
|
+
<FragmentLibraryManager
|
|
35
|
+
v-if="workspace.workspaceId"
|
|
36
|
+
kind="workspace"
|
|
37
|
+
:owner-id="workspace.workspaceId"
|
|
38
|
+
show-catalog
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
29
41
|
</template>
|
|
30
42
|
</UModal>
|
|
31
43
|
</template>
|
|
@@ -364,7 +364,9 @@ const filteredGroups = computed<IntegrationGroup[]>(() => {
|
|
|
364
364
|
:ui="{ content: 'max-w-xl' }"
|
|
365
365
|
>
|
|
366
366
|
<template #body>
|
|
367
|
-
|
|
367
|
+
<!-- Named for the same reason `model-providers-hub` is: the tutorial tour that explains
|
|
368
|
+
what a connection adds to a run points at this list. -->
|
|
369
|
+
<div class="space-y-5" data-testid="integrations-hub">
|
|
368
370
|
<p class="text-xs text-slate-400">
|
|
369
371
|
{{ t('layout.integrationsHub.intro') }}
|
|
370
372
|
</p>
|
|
@@ -455,7 +455,10 @@ async function clone(p: Pipeline) {
|
|
|
455
455
|
columns filling the full height. -->
|
|
456
456
|
<div class="grid grid-cols-1 gap-4 lg:h-full lg:grid-cols-3">
|
|
457
457
|
<!-- agent palette -->
|
|
458
|
-
<div
|
|
458
|
+
<div
|
|
459
|
+
class="flex flex-col lg:min-h-0 lg:overflow-hidden"
|
|
460
|
+
data-testid="pipeline-builder-palette"
|
|
461
|
+
>
|
|
459
462
|
<div class="mb-2 flex shrink-0 items-center justify-between gap-2">
|
|
460
463
|
<h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
|
461
464
|
{{ t('pipeline.builder.agentPalette') }}
|
|
@@ -476,7 +479,10 @@ async function clone(p: Pipeline) {
|
|
|
476
479
|
</div>
|
|
477
480
|
|
|
478
481
|
<!-- draft chain -->
|
|
479
|
-
<div
|
|
482
|
+
<div
|
|
483
|
+
class="flex flex-col lg:min-h-0 lg:overflow-hidden"
|
|
484
|
+
data-testid="pipeline-builder-draft"
|
|
485
|
+
>
|
|
480
486
|
<div class="mb-2 flex items-center justify-between gap-2">
|
|
481
487
|
<h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
|
482
488
|
{{ t('pipeline.builder.pipeline') }}
|
|
@@ -1294,6 +1300,7 @@ async function clone(p: Pipeline) {
|
|
|
1294
1300
|
icon="i-lucide-save"
|
|
1295
1301
|
size="sm"
|
|
1296
1302
|
:disabled="pipelines.draft.length === 0 || stepsDisallowedByPurpose.length > 0"
|
|
1303
|
+
data-testid="pipeline-builder-save"
|
|
1297
1304
|
@click="save"
|
|
1298
1305
|
>
|
|
1299
1306
|
{{ pipelines.editingId ? t('pipeline.builder.update') : t('pipeline.builder.save') }}
|