@octabits-io/nuxt-ui-kit 0.11.0 → 0.13.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 CHANGED
@@ -50,6 +50,15 @@ itself, so it has no Nuxt dependency, only `vue`.
50
50
  an app-side `useDateFormat`), plus source-shipped `./components/DateInput.vue`,
51
51
  `DateRangeInput.vue` (travel/booking end-date semantics, blocked dates via
52
52
  props, injected `availabilityCheck`), and `PeriodDisplay.vue`
53
+ - **`./events`** — the browser side of `@octabits-io/framework/events`:
54
+ `createEventStreamClient`, a fetch-based SSE reader (the stream is
55
+ authenticated with an `Authorization` header, which native `EventSource`
56
+ cannot set — so reconnect, `Last-Event-ID` replay, and full-jitter backoff
57
+ live here), with a durable-only watermark, bounded seen-id dedupe, a
58
+ `degraded` state for honest fallback-polling UX, and a content-type guard
59
+ (a 200 `text/html` SPA fallback is a failure, not a stream);
60
+ `createSseFrameParser`; `useEventStream` (reactive connection state +
61
+ scope-bound lifecycle)
53
62
  - **`./ai`** — frontend AI-workflow engine: `useAiWorkflow` /
54
63
  `useAiWorkflowGuard` (poll-driven state over injected transport),
55
64
  `createAiProgressCore` (cross-page tracking + completion/applied signals —
@@ -0,0 +1,101 @@
1
+ import { Ref } from "vue";
2
+ //#region src/events/sseParser.d.ts
3
+ /**
4
+ * Incremental SSE frame parser — pure logic, no I/O, exhaustively unit
5
+ * tested. Feed it decoded text chunks as they arrive; it returns completed
6
+ * frames (an empty line terminates a frame, per the SSE spec).
7
+ *
8
+ * Only the fields the event stream uses are surfaced (`id`, `event`, `data`,
9
+ * `retry`); comment lines (`: hb` heartbeats) and unknown fields are
10
+ * discarded. CRLF and bare-CR line endings are normalized.
11
+ */
12
+ interface SseFrame {
13
+ /** The `id:` field — present only on durable events (the watermark rule). */
14
+ id?: string;
15
+ /** The `event:` field (the envelope type, informational). */
16
+ event?: string;
17
+ /** The `data:` field(s), newline-joined. */
18
+ data: string;
19
+ /** A `retry:` field, parsed to ms. */
20
+ retry?: number;
21
+ }
22
+ interface SseFrameParser {
23
+ /** Consume a chunk; returns every frame completed by it. */
24
+ push(chunk: string): SseFrame[];
25
+ /** Discard any partial frame state (call on reconnect). */
26
+ reset(): void;
27
+ }
28
+ declare function createSseFrameParser(): SseFrameParser;
29
+ //#endregion
30
+ //#region src/events/client.d.ts
31
+ /**
32
+ * Structural duplicate of the framework's `EventEnvelope` — the kit has no
33
+ * dependency on `@octabits-io/framework`, and the wire format is the
34
+ * contract, not the type.
35
+ */
36
+ interface StreamedEvent<T = unknown> {
37
+ id: string;
38
+ seq?: number;
39
+ type: string;
40
+ scopeKey: string;
41
+ at: string;
42
+ lane: 'durable' | 'ephemeral';
43
+ data: T;
44
+ actor?: {
45
+ type: string;
46
+ id?: string;
47
+ name?: string;
48
+ };
49
+ resources?: string[];
50
+ }
51
+ type EventStreamState = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'degraded' | 'stopped';
52
+ interface EventStreamRequest {
53
+ url: string;
54
+ /** Extra headers — put your `Authorization` here, fresh per attempt. */
55
+ headers?: Record<string, string>;
56
+ }
57
+ interface EventStreamClientOptions {
58
+ /**
59
+ * Build the request for each (re)connect attempt. Called every attempt so
60
+ * the auth token is always fresh. May be async (token refresh).
61
+ */
62
+ buildRequest: () => EventStreamRequest | Promise<EventStreamRequest>;
63
+ /** Deduped envelope delivery, both lanes. */
64
+ onEvent: (event: StreamedEvent) => void;
65
+ onStateChange?: (state: EventStreamState) => void;
66
+ /** Injected fetch (default `globalThis.fetch`). */
67
+ fetchImpl?: typeof fetch;
68
+ /** Base reconnect delay, overridden by the server's `retry:` hint (default 3 000 ms). */
69
+ retryMs?: number;
70
+ /** Reconnect delay ceiling under sustained failure (default 30 000 ms). */
71
+ maxRetryMs?: number;
72
+ /** Continuous failure duration before state turns `degraded` (default 60 000 ms). */
73
+ degradedAfterMs?: number;
74
+ /** Seen-id dedupe set bound (default 2 000). */
75
+ maxSeenIds?: number;
76
+ /** Resume watermark persisted from a previous session, if any. */
77
+ initialLastEventId?: string | null;
78
+ }
79
+ interface EventStreamClient {
80
+ start(): void;
81
+ stop(): void;
82
+ state(): EventStreamState;
83
+ /** The current watermark (last durable SSE id seen). */
84
+ lastEventId(): string | null;
85
+ }
86
+ declare function createEventStreamClient(options: EventStreamClientOptions): EventStreamClient;
87
+ //#endregion
88
+ //#region src/events/useEventStream.d.ts
89
+ interface UseEventStreamReturn {
90
+ /** Reactive connection state — drive fallback-polling and UI hints off this. */
91
+ state: Readonly<Ref<EventStreamState>>;
92
+ /** Reactive count of events delivered (deduped) this session. */
93
+ received: Readonly<Ref<number>>;
94
+ start(): void;
95
+ stop(): void;
96
+ /** Current watermark (persist it to resume replay across page loads). */
97
+ lastEventId(): string | null;
98
+ }
99
+ declare function useEventStream(options: EventStreamClientOptions): UseEventStreamReturn;
100
+ //#endregion
101
+ export { type EventStreamClient, type EventStreamClientOptions, type EventStreamRequest, type EventStreamState, type SseFrame, type SseFrameParser, type StreamedEvent, type UseEventStreamReturn, createEventStreamClient, createSseFrameParser, useEventStream };
@@ -0,0 +1,245 @@
1
+ import { onScopeDispose, readonly, ref } from "vue";
2
+ //#region src/events/sseParser.ts
3
+ function createSseFrameParser() {
4
+ let buffer = "";
5
+ let id;
6
+ let event;
7
+ let retry;
8
+ let dataLines = [];
9
+ function resetFrame() {
10
+ id = void 0;
11
+ event = void 0;
12
+ retry = void 0;
13
+ dataLines = [];
14
+ }
15
+ function processLine(line, frames) {
16
+ if (line === "") {
17
+ if (dataLines.length > 0 || id !== void 0 || event !== void 0 || retry !== void 0) frames.push({
18
+ ...id !== void 0 ? { id } : {},
19
+ ...event !== void 0 ? { event } : {},
20
+ ...retry !== void 0 ? { retry } : {},
21
+ data: dataLines.join("\n")
22
+ });
23
+ resetFrame();
24
+ return;
25
+ }
26
+ if (line.startsWith(":")) return;
27
+ const colon = line.indexOf(":");
28
+ const field = colon === -1 ? line : line.slice(0, colon);
29
+ let value = colon === -1 ? "" : line.slice(colon + 1);
30
+ if (value.startsWith(" ")) value = value.slice(1);
31
+ switch (field) {
32
+ case "id":
33
+ if (!value.includes("\0")) id = value;
34
+ break;
35
+ case "event":
36
+ event = value;
37
+ break;
38
+ case "data":
39
+ dataLines.push(value);
40
+ break;
41
+ case "retry": {
42
+ const parsed = Number.parseInt(value, 10);
43
+ if (Number.isInteger(parsed) && parsed >= 0) retry = parsed;
44
+ break;
45
+ }
46
+ default: break;
47
+ }
48
+ }
49
+ function push(chunk) {
50
+ buffer += chunk.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
51
+ const frames = [];
52
+ let newline = buffer.indexOf("\n");
53
+ while (newline !== -1) {
54
+ const line = buffer.slice(0, newline);
55
+ buffer = buffer.slice(newline + 1);
56
+ processLine(line, frames);
57
+ newline = buffer.indexOf("\n");
58
+ }
59
+ return frames;
60
+ }
61
+ function reset() {
62
+ buffer = "";
63
+ resetFrame();
64
+ }
65
+ return {
66
+ push,
67
+ reset
68
+ };
69
+ }
70
+ //#endregion
71
+ //#region src/events/client.ts
72
+ /**
73
+ * The fetch-based SSE event-stream client. A hand-rolled reader rather than
74
+ * native `EventSource` because the stream is authenticated with an
75
+ * `Authorization` header, which `new EventSource(url)` cannot set — so
76
+ * reconnect, `Last-Event-ID`, and backoff are implemented here, once.
77
+ *
78
+ * Semantics baked in (mirroring the server contract in
79
+ * `@octabits-io/framework/events`):
80
+ *
81
+ * - **Watermark**: only frames carrying an SSE `id:` advance the persisted
82
+ * watermark (the server sets `id:` on durable events only) — sent back as
83
+ * the `Last-Event-ID` header on every reconnect for replay.
84
+ * - **Dedupe**: replay overlaps and at-least-once delivery mean duplicates
85
+ * are normal; a bounded seen-id set (envelope `id`, not `seq`) filters
86
+ * them before `onEvent`.
87
+ * - **Reconnect is routine, not an error**: the server caps connection age
88
+ * (~5 min) so auth is re-evaluated; a server-side close re-connects after
89
+ * the server's `retry:` hint with **full jitter**. Only sustained failure
90
+ * moves the state to `degraded` (UI hint to resume fallback polling).
91
+ */
92
+ function createEventStreamClient(options) {
93
+ const { buildRequest, onEvent, onStateChange, fetchImpl = globalThis.fetch.bind(globalThis), retryMs = 3e3, maxRetryMs = 3e4, degradedAfterMs = 6e4, maxSeenIds = 2e3, initialLastEventId = null } = options;
94
+ let state = "idle";
95
+ let lastEventId = initialLastEventId;
96
+ let serverRetryMs = null;
97
+ let abort = null;
98
+ let running = false;
99
+ let attempt = 0;
100
+ let failingSince = null;
101
+ let retryTimer;
102
+ const seen = /* @__PURE__ */ new Set();
103
+ const seenOrder = [];
104
+ function setState(next) {
105
+ if (state === next) return;
106
+ state = next;
107
+ onStateChange?.(next);
108
+ }
109
+ function markSeen(id) {
110
+ if (seen.has(id)) return false;
111
+ seen.add(id);
112
+ seenOrder.push(id);
113
+ if (seenOrder.length > maxSeenIds) {
114
+ const evicted = seenOrder.shift();
115
+ if (evicted !== void 0) seen.delete(evicted);
116
+ }
117
+ return true;
118
+ }
119
+ function scheduleReconnect() {
120
+ if (!running) return;
121
+ attempt += 1;
122
+ if (failingSince === null) failingSince = Date.now();
123
+ setState(Date.now() - failingSince >= degradedAfterMs ? "degraded" : "reconnecting");
124
+ const cap = Math.min(maxRetryMs, (serverRetryMs ?? retryMs) * 2 ** Math.min(attempt - 1, 8));
125
+ const delay = Math.random() * cap;
126
+ retryTimer = setTimeout(() => void connect(), delay);
127
+ }
128
+ function handleFrame(frame) {
129
+ if (frame.retry !== void 0) serverRetryMs = frame.retry;
130
+ if (frame.id !== void 0 && frame.id !== "") lastEventId = frame.id;
131
+ if (frame.data === "") return;
132
+ let envelope;
133
+ try {
134
+ envelope = JSON.parse(frame.data);
135
+ } catch {
136
+ return;
137
+ }
138
+ if (typeof envelope !== "object" || envelope === null || typeof envelope.id !== "string") return;
139
+ if (!markSeen(envelope.id)) return;
140
+ onEvent(envelope);
141
+ }
142
+ async function connect() {
143
+ if (!running) return;
144
+ if (state === "idle" || state === "stopped") setState("connecting");
145
+ abort = new AbortController();
146
+ const parser = createSseFrameParser();
147
+ try {
148
+ const request = await buildRequest();
149
+ const response = await fetchImpl(request.url, {
150
+ headers: {
151
+ accept: "text/event-stream",
152
+ ...lastEventId !== null ? { "last-event-id": lastEventId } : {},
153
+ ...request.headers
154
+ },
155
+ signal: abort.signal
156
+ });
157
+ const contentType = response.headers.get("content-type") ?? "";
158
+ if (!response.ok || !response.body || !contentType.includes("text/event-stream")) {
159
+ scheduleReconnect();
160
+ return;
161
+ }
162
+ setState("connected");
163
+ attempt = 0;
164
+ failingSince = null;
165
+ const reader = response.body.getReader();
166
+ const decoder = new TextDecoder();
167
+ for (;;) {
168
+ const { value, done } = await reader.read();
169
+ if (done) break;
170
+ for (const frame of parser.push(decoder.decode(value, { stream: true }))) handleFrame(frame);
171
+ }
172
+ if (running) {
173
+ setState("reconnecting");
174
+ retryTimer = setTimeout(() => void connect(), Math.random() * (serverRetryMs ?? retryMs));
175
+ }
176
+ } catch (error) {
177
+ if (!running || error instanceof DOMException && error.name === "AbortError") return;
178
+ scheduleReconnect();
179
+ }
180
+ }
181
+ function start() {
182
+ if (running) return;
183
+ running = true;
184
+ connect();
185
+ }
186
+ function stop() {
187
+ running = false;
188
+ if (retryTimer) clearTimeout(retryTimer);
189
+ abort?.abort();
190
+ abort = null;
191
+ setState("stopped");
192
+ }
193
+ return {
194
+ start,
195
+ stop,
196
+ state: () => state,
197
+ lastEventId: () => lastEventId
198
+ };
199
+ }
200
+ //#endregion
201
+ //#region src/events/useEventStream.ts
202
+ /**
203
+ * Vue composable over {@link createEventStreamClient}: reactive connection
204
+ * state, scope-bound lifecycle, and a typed per-event-type handler registry.
205
+ *
206
+ * The app owns *what to do* with events (invalidation registry, toasts, …);
207
+ * this composable owns the connection. Typical wiring, once, in the app
208
+ * shell:
209
+ *
210
+ * ```ts
211
+ * const stream = useEventStream({
212
+ * buildRequest: () => ({
213
+ * url: `${apiBase}/events`,
214
+ * headers: { authorization: `Bearer ${auth.accessToken}` },
215
+ * }),
216
+ * onEvent: (event) => invalidation.dispatch(event),
217
+ * });
218
+ * watch(tenantReady, (ready) => (ready ? stream.start() : stream.stop()));
219
+ * ```
220
+ */
221
+ function useEventStream(options) {
222
+ const state = ref("idle");
223
+ const received = ref(0);
224
+ const client = createEventStreamClient({
225
+ ...options,
226
+ onEvent: (event) => {
227
+ received.value += 1;
228
+ options.onEvent(event);
229
+ },
230
+ onStateChange: (next) => {
231
+ state.value = next;
232
+ options.onStateChange?.(next);
233
+ }
234
+ });
235
+ onScopeDispose(() => client.stop());
236
+ return {
237
+ state: readonly(state),
238
+ received: readonly(received),
239
+ start: client.start,
240
+ stop: client.stop,
241
+ lastEventId: client.lastEventId
242
+ };
243
+ }
244
+ //#endregion
245
+ export { createEventStreamClient, createSseFrameParser, useEventStream };
@@ -41,6 +41,7 @@ interface KitMessages {
41
41
  back: string;
42
42
  moreActions: string;
43
43
  help: string;
44
+ ai: string;
44
45
  };
45
46
  }
46
47
  declare const kitMessagesEn: KitMessages;
@@ -29,7 +29,8 @@ const kitMessagesEn = {
29
29
  pageChrome: {
30
30
  back: "Back",
31
31
  moreActions: "More actions",
32
- help: "Help"
32
+ help: "Help",
33
+ ai: "AI"
33
34
  }
34
35
  };
35
36
  //#endregion
package/dist/index.d.ts CHANGED
@@ -129,6 +129,14 @@ interface PageActionsItem {
129
129
  key: string;
130
130
  icon: string;
131
131
  label: string;
132
+ /**
133
+ * 'ai' renders the item in the AI cluster: sparkles + primary-soft (AiButton
134
+ * styling). One inline AI item → verb-labeled button; several → a labeled
135
+ * "AI ∨" dropdown. Collapsed AI items form their own menu group.
136
+ */
137
+ kind?: 'action' | 'ai';
138
+ /** Menu-only helper text (shown in the AI dropdown / overflow rows). */
139
+ description?: string;
132
140
  /** Inline button tone. At most ONE 'primary' item should be visible per state. */
133
141
  tone?: 'primary' | 'neutral';
134
142
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octabits-io/nuxt-ui-kit",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Frontend kit for Nuxt/Vue admin SPAs: OIDC session harness (oidc-client-ts), Eden Treaty client factory, auth/org store cores, and a route-guard builder — factory-style seams the app wires into its own plugins, stores, and middleware",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,6 +45,11 @@
45
45
  "import": "./dist/ai/index.js",
46
46
  "default": "./dist/ai/index.js"
47
47
  },
48
+ "./events": {
49
+ "types": "./dist/events/index.d.ts",
50
+ "import": "./dist/events/index.js",
51
+ "default": "./dist/events/index.js"
52
+ },
48
53
  "./styles.css": "./src/styles.css",
49
54
  "./components/*": "./src/components/*"
50
55
  },
@@ -75,7 +80,7 @@
75
80
  "vitest": "^4.1.10",
76
81
  "vue": "^3.5.40",
77
82
  "zod": "^4.4.3",
78
- "@octabits-io/framework": "^0.7.0"
83
+ "@octabits-io/framework": "^0.8.0"
79
84
  },
80
85
  "peerDependencies": {
81
86
  "@elysiajs/eden": "^1.4.0",
@@ -0,0 +1,44 @@
1
+ <script setup lang="ts">
2
+ // Shipped as source: the consumer's Vite compiles this SFC. All imports are
3
+ // explicit — no reliance on the consumer's auto-import configuration.
4
+ import UButton from '@nuxt/ui/components/Button.vue';
5
+
6
+ /**
7
+ * THE visual token for AI triggers: sparkles + primary-soft + verb label.
8
+ * Every button that launches AI work (workflow, generation, suggestion) renders
9
+ * through this primitive so "AI will do something" always looks the same.
10
+ * Convention: `i-lucide-sparkles` is reserved exclusively for AI triggers;
11
+ * soft/solid primary = AI acts on data, ghost = AI conversation.
12
+ */
13
+ withDefaults(defineProps<{
14
+ label: string
15
+ /** Leading icon. Keep the sparkles default unless a workflow icon is clearer. */
16
+ icon?: string
17
+ /** e.g. 'i-lucide-chevron-down' for dropdown triggers. */
18
+ trailingIcon?: string
19
+ size?: 'xs' | 'sm' | 'md'
20
+ variant?: 'soft' | 'solid' | 'subtle' | 'outline' | 'ghost'
21
+ loading?: boolean
22
+ disabled?: boolean
23
+ }>(), {
24
+ icon: 'i-lucide-sparkles',
25
+ trailingIcon: undefined,
26
+ size: 'sm',
27
+ variant: 'soft',
28
+ loading: false,
29
+ disabled: false,
30
+ });
31
+ </script>
32
+
33
+ <template>
34
+ <UButton
35
+ :label="label"
36
+ :icon="icon"
37
+ :trailing-icon="trailingIcon"
38
+ color="primary"
39
+ :variant="variant"
40
+ :size="size"
41
+ :loading="loading"
42
+ :disabled="disabled"
43
+ />
44
+ </template>
@@ -1,11 +1,14 @@
1
1
  <script setup lang="ts">
2
2
  // Shipped as source: the consumer's Vite compiles this SFC. All imports are
3
3
  // explicit — no reliance on the consumer's auto-import configuration.
4
- // i18n key contract: pageChrome.help (+ PageActionMenu's pageChrome.moreActions).
4
+ // i18n key contract: pageChrome.help, pageChrome.ai (+ PageActionMenu's pageChrome.moreActions).
5
5
  import { computed, inject } from 'vue';
6
6
  import { useI18n } from 'vue-i18n';
7
7
  import USeparator from '@nuxt/ui/components/Separator.vue';
8
+ import UDropdownMenu from '@nuxt/ui/components/DropdownMenu.vue';
9
+ import UIcon from '@nuxt/ui/components/Icon.vue';
8
10
  import type { DropdownMenuItem } from '@nuxt/ui';
11
+ import AiButton from './AiButton.vue';
9
12
  import PageAction from './PageAction.vue';
10
13
  import PageActionMenu from './PageActionMenu.vue';
11
14
  // Package-name import (not ../composables): only src/components is packed, and
@@ -55,13 +58,33 @@ const collapsed = computed(() => {
55
58
 
56
59
  const showHelp = computed(() => props.help && Boolean(helpPanel?.hasActions.value));
57
60
 
58
- const inlineItems = computed(() => props.items.filter(item =>
61
+ const actionItems = computed(() => props.items.filter(item => (item.kind ?? 'action') !== 'ai'));
62
+ const aiItems = computed(() => props.items.filter(item => item.kind === 'ai'));
63
+
64
+ const isInlineBound = (item: PageActionsItem) =>
59
65
  (item.visibility ?? 'auto') === 'always'
60
- || ((item.visibility ?? 'auto') === 'auto' && !collapsed.value),
61
- ));
66
+ || ((item.visibility ?? 'auto') === 'auto' && !collapsed.value);
67
+
68
+ const inlineItems = computed(() => actionItems.value.filter(isInlineBound));
69
+
70
+ // AI cluster: one inline item renders as its own verb-labeled AiButton; several
71
+ // share a labeled "AI ∨" dropdown (icons + descriptions per row).
72
+ const inlineAiItems = computed(() => aiItems.value.filter(isInlineBound));
73
+ const aiDropdownItems = computed<DropdownMenuItem[]>(() =>
74
+ inlineAiItems.value.map(item => ({
75
+ label: item.label,
76
+ description: item.description,
77
+ icon: item.icon,
78
+ disabled: item.disabled || Boolean(item.disabledReason),
79
+ loading: item.loading,
80
+ onSelect: item.onSelect,
81
+ })),
82
+ );
62
83
 
63
84
  const inlineUtilityItems = computed(() => collapsed.value ? [] : props.utilityItems);
64
85
 
86
+ // Descriptions render only in the dedicated AI dropdown (which has wrap/width
87
+ // styling) — the compact ⋯ overflow stays label-only.
65
88
  function toMenuItem(item: PageActionsItem): DropdownMenuItem {
66
89
  return {
67
90
  label: item.label,
@@ -77,18 +100,25 @@ function toMenuItem(item: PageActionsItem): DropdownMenuItem {
77
100
 
78
101
  const menuGroups = computed<DropdownMenuItem[][]>(() => {
79
102
  const collapsedAutos = collapsed.value
80
- ? props.items.filter(item => (item.visibility ?? 'auto') === 'auto')
103
+ ? actionItems.value.filter(item => (item.visibility ?? 'auto') === 'auto')
81
104
  : [];
82
105
 
83
106
  // Menu-only items grouped by section, in first-appearance order.
84
107
  const sections = new Map<string, PageActionsItem[]>();
85
- for (const item of props.items) {
108
+ for (const item of actionItems.value) {
86
109
  if ((item.visibility ?? 'auto') !== 'menu') continue;
87
110
  const section = item.section ?? 'default';
88
111
  if (!sections.has(section)) sections.set(section, []);
89
112
  sections.get(section)!.push(item);
90
113
  }
91
114
 
115
+ // AI items bound to the menu (explicit 'menu', or 'auto' while collapsed)
116
+ // form their own group between the action sections and the utilities.
117
+ const aiGroup = aiItems.value.filter(item =>
118
+ (item.visibility ?? 'auto') === 'menu'
119
+ || ((item.visibility ?? 'auto') === 'auto' && collapsed.value),
120
+ );
121
+
92
122
  const utilityGroup: DropdownMenuItem[] = collapsed.value
93
123
  ? [
94
124
  ...props.utilityItems.map(toMenuItem),
@@ -101,6 +131,7 @@ const menuGroups = computed<DropdownMenuItem[][]>(() => {
101
131
  return [
102
132
  collapsedAutos.map(toMenuItem),
103
133
  ...[...sections.values()].map(group => group.map(toMenuItem)),
134
+ aiGroup.map(toMenuItem),
104
135
  utilityGroup,
105
136
  ].filter(group => group.length > 0);
106
137
  });
@@ -125,6 +156,35 @@ const hasUtilityRegion = computed(() =>
125
156
  :target="item.target"
126
157
  @click="item.onSelect?.()"
127
158
  />
159
+ <!-- AI cluster: soft-primary sparkles = "AI acts on data". One item → its
160
+ verb label; several → the shared labeled dropdown. -->
161
+ <AiButton
162
+ v-if="inlineAiItems.length === 1"
163
+ :label="inlineAiItems[0]!.label"
164
+ :loading="inlineAiItems[0]!.loading"
165
+ :disabled="inlineAiItems[0]!.disabled || Boolean(inlineAiItems[0]!.disabledReason)"
166
+ @click="inlineAiItems[0]!.onSelect?.()"
167
+ />
168
+ <UDropdownMenu
169
+ v-else-if="inlineAiItems.length > 1"
170
+ :items="aiDropdownItems"
171
+ :content="{ align: 'end' }"
172
+ :ui="{
173
+ content: 'w-72',
174
+ item: 'gap-2.5 p-2',
175
+ itemLabel: 'font-medium text-highlighted',
176
+ itemDescription: 'mt-0.5 whitespace-normal text-xs/4',
177
+ }"
178
+ >
179
+ <AiButton :label="t('pageChrome.ai')" trailing-icon="i-lucide-chevron-down" />
180
+ <template #item-leading="{ item }">
181
+ <span
182
+ class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-primary/20 to-primary/5 text-primary ring-1 ring-primary/20 ring-inset"
183
+ >
184
+ <UIcon :name="(item.icon as string) ?? 'i-lucide-sparkles'" class="size-4" />
185
+ </span>
186
+ </template>
187
+ </UDropdownMenu>
128
188
  <PageActionMenu :items="menuGroups" />
129
189
  <template v-if="hasUtilityRegion">
130
190
  <USeparator orientation="vertical" class="h-5 mx-1" />
@@ -11,6 +11,14 @@ export interface PageActionsItem {
11
11
  key: string;
12
12
  icon: string;
13
13
  label: string;
14
+ /**
15
+ * 'ai' renders the item in the AI cluster: sparkles + primary-soft (AiButton
16
+ * styling). One inline AI item → verb-labeled button; several → a labeled
17
+ * "AI ∨" dropdown. Collapsed AI items form their own menu group.
18
+ */
19
+ kind?: 'action' | 'ai';
20
+ /** Menu-only helper text (shown in the AI dropdown / overflow rows). */
21
+ description?: string;
14
22
  /** Inline button tone. At most ONE 'primary' item should be visible per state. */
15
23
  tone?: 'primary' | 'neutral';
16
24
  /**