@meshmakers/octo-ai-console 3.3.1190

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.
@@ -0,0 +1,690 @@
1
+ import * as i0 from '@angular/core';
2
+ import { inject, Injectable, InjectionToken, makeEnvironmentProviders, input, computed, ChangeDetectionStrategy, Component, output, signal } from '@angular/core';
3
+ import { HttpClient, HttpParams } from '@angular/common/http';
4
+ import { ReplaySubject, Subject } from 'rxjs';
5
+ import * as i1 from '@angular/forms';
6
+ import { FormsModule } from '@angular/forms';
7
+ import { CommonModule } from '@angular/common';
8
+
9
+ /**
10
+ * Typed REST client for the AI Adapter's session endpoints. Every method maps
11
+ * 1:1 to a controller route on the C# side
12
+ * (`src/AiServices/TenantApi/v1/Controllers/SessionsController.cs`).
13
+ *
14
+ * The service does NOT cache responses or hold session state — each call is a
15
+ * pure pass-through to `HttpClient`. Components that need a derived view (e.g.
16
+ * a session list with status counts) should compose over the returned
17
+ * observables themselves; that keeps the client free of UI concerns and lets
18
+ * host apps mix Apollo / GraphQL state in the same component without dueling
19
+ * caches.
20
+ */
21
+ /**
22
+ * Not `providedIn: 'root'` — `AI_ADAPTER_OPTIONS` is provided per-route in host
23
+ * apps (the tenant id is read from the route param, which the root injector
24
+ * doesn't see). A root-provided service would resolve through the root injector
25
+ * and fail with NG0201 on `AI_ADAPTER_OPTIONS`. Standalone components that
26
+ * inject this service pick up the route-level provider via the element
27
+ * injector hierarchy.
28
+ */
29
+ class AiAdapterClientService {
30
+ http = inject(HttpClient);
31
+ options = inject(AI_ADAPTER_OPTIONS);
32
+ /**
33
+ * `GET /{tenantId}/v1/sessions` — list all sessions for the configured tenant.
34
+ * Optional `status` filter narrows to one lifecycle state when present.
35
+ */
36
+ listSessions(status) {
37
+ const url = `${this.tenantBase()}/sessions`;
38
+ let params = new HttpParams();
39
+ if (status) {
40
+ params = params.set('status', status);
41
+ }
42
+ return this.http.get(url, { params });
43
+ }
44
+ /** `GET /{tenantId}/v1/sessions/{sessionId}` — single session by rtId. */
45
+ getSession(sessionId) {
46
+ return this.http.get(`${this.tenantBase()}/sessions/${encodeURIComponent(sessionId)}`);
47
+ }
48
+ /**
49
+ * `POST /{tenantId}/v1/sessions` — start a new session. Returns the persisted
50
+ * session plus the quota gate's decision so the caller can render either a
51
+ * "Running" pill or a "Queued (n)" pill without a second round-trip.
52
+ */
53
+ createSession(request) {
54
+ return this.http.post(`${this.tenantBase()}/sessions`, request);
55
+ }
56
+ /**
57
+ * `POST /{tenantId}/v1/sessions/{sessionId}/cancel` — request graceful
58
+ * cancellation; the orchestrator transitions the session to `Cancelled`
59
+ * once the worker has acknowledged.
60
+ */
61
+ cancelSession(sessionId) {
62
+ return this.http.post(`${this.tenantBase()}/sessions/${encodeURIComponent(sessionId)}/cancel`, null);
63
+ }
64
+ /**
65
+ * `GET /{tenantId}/v1/sessions/{sessionId}/events?sinceSequence=N` — replay
66
+ * persisted events for a session. UI uses this on reconnect to backfill any
67
+ * events SignalR missed while disconnected (drives the resume-from-sequence
68
+ * path in {@link AiSessionStreamService}).
69
+ */
70
+ listEvents(sessionId, sinceSequence) {
71
+ const url = `${this.tenantBase()}/sessions/${encodeURIComponent(sessionId)}/events`;
72
+ let params = new HttpParams();
73
+ if (sinceSequence != null) {
74
+ params = params.set('sinceSequence', sinceSequence.toString());
75
+ }
76
+ return this.http.get(url, { params });
77
+ }
78
+ /**
79
+ * `POST /{tenantId}/v1/sessions/{sessionId}/approvals/{requestId}` — submit
80
+ * the user's decision on a paused approval gate. Server fans
81
+ * `OnApprovalDecidedAsync` to every connection on the session.
82
+ */
83
+ decideApproval(sessionId, requestId, decision) {
84
+ return this.http.post(`${this.tenantBase()}/sessions/${encodeURIComponent(sessionId)}` +
85
+ `/approvals/${encodeURIComponent(requestId)}`, decision);
86
+ }
87
+ /**
88
+ * `POST /{tenantId}/v1/credentials/tickets` — mint a one-time credential-
89
+ * registration ticket (#4133). The admin sees the plaintext code once in
90
+ * the response; the server stores only the SHA-256 hash. The matching
91
+ * redemption call (`POST /v1/credentials/tickets/redeem`) is anonymous and
92
+ * NOT in this client because the redeemer is by design a different person
93
+ * on a different machine — they use the bastion CLI, not the Studio.
94
+ */
95
+ issueCredentialTicket(request) {
96
+ return this.http.post(`${this.tenantBase()}/credentials/tickets`, request);
97
+ }
98
+ tenantBase() {
99
+ return `${this.options.baseUrl}/${encodeURIComponent(this.options.tenantId)}/v1`;
100
+ }
101
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AiAdapterClientService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
102
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AiAdapterClientService });
103
+ }
104
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AiAdapterClientService, decorators: [{
105
+ type: Injectable
106
+ }] });
107
+
108
+ /**
109
+ * Internal wrapper around the SignalR connection. The library does not pin a
110
+ * SignalR version — host applications inject a `HubConnectionBuilder` they
111
+ * already use, which keeps the library agnostic of the
112
+ * `@microsoft/signalr` major version line (six and seven have diverged on the
113
+ * `withAutomaticReconnect()` defaults).
114
+ *
115
+ * For now the connection is constructed deferred — `streamSession` builds it on
116
+ * demand so the host can defer the SignalR JS bundle behind a route lazy-load.
117
+ * Phase-1 ships a stub backed by `ReplaySubject` so demo apps can drive
118
+ * components without a live hub; the real connection wiring lives behind a
119
+ * `connectionFactory` provider the host overrides.
120
+ */
121
+ /**
122
+ * Not `providedIn: 'root'` — see <see cref="AiAdapterClientService"/> for the
123
+ * rationale: `AI_ADAPTER_OPTIONS` is a per-route token, the root injector
124
+ * cannot resolve it. Element-level injection picks up the route-level provider.
125
+ */
126
+ class AiSessionStreamService {
127
+ options = inject(AI_ADAPTER_OPTIONS);
128
+ client = inject(AiAdapterClientService);
129
+ connectionFactory = inject(AI_SESSION_STREAM_CONNECTION_FACTORY, { optional: true });
130
+ /**
131
+ * Open a streaming session. Returns the per-stream subjects and a
132
+ * `disconnect()` closer. When the host has provided an
133
+ * {@link AI_SESSION_STREAM_CONNECTION_FACTORY}, a live SignalR connection is
134
+ * built against {@link hubUrl} and the five hub callbacks
135
+ * (`OnSessionEventAsync`, `OnSessionStatusChangedAsync`,
136
+ * `OnApprovalRequestedAsync`, `OnApprovalDecidedAsync`, `OnQuotaWarningAsync`)
137
+ * are routed into the matching subject. Without a factory, the service
138
+ * degrades to REST-only backfill — the original Phase-1 stub contract.
139
+ *
140
+ * Tracking the highest sequence we've emitted lets the host reconnect after a
141
+ * network blip by passing `sinceSequence` to {@link replay} (the SignalR
142
+ * `onreconnected` hook fires that with the highest seen sequence).
143
+ */
144
+ streamSession(sessionId) {
145
+ const events$ = new ReplaySubject(256);
146
+ const statusChanges$ = new Subject();
147
+ const approvalsRequested$ = new Subject();
148
+ const approvalsDecided$ = new Subject();
149
+ const quotaWarnings$ = new Subject();
150
+ // Backfill any persisted events the hub-connect process would have missed
151
+ // — REST returns the full history up to "now", and the live channel takes
152
+ // it from there. The combination is at-least-once with deterministic
153
+ // ordering courtesy of the persisted `sequence` field.
154
+ let highestSequence = 0;
155
+ const backfill = this.client.listEvents(sessionId).subscribe({
156
+ next: (events) => {
157
+ for (const event of events) {
158
+ highestSequence = Math.max(highestSequence, event.sequence);
159
+ events$.next(event);
160
+ }
161
+ },
162
+ });
163
+ let connection = null;
164
+ if (this.connectionFactory) {
165
+ connection = this.connectionFactory(this.hubUrl);
166
+ }
167
+ if (connection) {
168
+ const conn = connection;
169
+ conn.on('OnSessionEventAsync', (payload) => {
170
+ const event = payload;
171
+ highestSequence = Math.max(highestSequence, event.sequence ?? 0);
172
+ events$.next(event);
173
+ });
174
+ conn.on('OnSessionStatusChangedAsync', (payload) => {
175
+ statusChanges$.next(payload);
176
+ });
177
+ conn.on('OnApprovalRequestedAsync', (payload) => {
178
+ approvalsRequested$.next(payload);
179
+ });
180
+ conn.on('OnApprovalDecidedAsync', (payload) => {
181
+ approvalsDecided$.next(payload);
182
+ });
183
+ conn.on('OnQuotaWarningAsync', (payload) => {
184
+ quotaWarnings$.next(payload);
185
+ });
186
+ conn.onreconnected(() => {
187
+ // Pull anything we missed during the disconnect window so the
188
+ // events$ stream stays gap-free without the caller having to do
189
+ // their own bookkeeping.
190
+ this.replay(sessionId, highestSequence, events$);
191
+ });
192
+ // Start the connection, then ask the hub to scope this connection's
193
+ // group membership to the session. Failures are intentionally swallowed
194
+ // — backfill stays valid and the UI degrades to REST-only instead of
195
+ // crashing.
196
+ conn
197
+ .start()
198
+ .then(() => conn.invoke('SubscribeToSessionAsync', sessionId))
199
+ .catch(() => {
200
+ /* hub start / subscribe failed — REST backfill still serves the UI */
201
+ });
202
+ }
203
+ return {
204
+ events$: events$.asObservable(),
205
+ statusChanges$: statusChanges$.asObservable(),
206
+ approvalsRequested$: approvalsRequested$.asObservable(),
207
+ approvalsDecided$: approvalsDecided$.asObservable(),
208
+ quotaWarnings$: quotaWarnings$.asObservable(),
209
+ disconnect: () => {
210
+ backfill.unsubscribe();
211
+ if (connection) {
212
+ // Best-effort — `stop()` may reject during teardown; the subjects
213
+ // get completed regardless so consumers see clean termination.
214
+ connection.stop().catch(() => undefined);
215
+ connection = null;
216
+ }
217
+ events$.complete();
218
+ statusChanges$.complete();
219
+ approvalsRequested$.complete();
220
+ approvalsDecided$.complete();
221
+ quotaWarnings$.complete();
222
+ },
223
+ };
224
+ }
225
+ /**
226
+ * Backfill missing events from a known sequence number and push them into
227
+ * the host's existing stream. Used by a SignalR `onreconnected` hook the
228
+ * host wires up.
229
+ */
230
+ replay(sessionId, sinceSequence, sink) {
231
+ this.client.listEvents(sessionId, sinceSequence).subscribe({
232
+ next: (events) => events.forEach((event) => sink.next(event)),
233
+ });
234
+ }
235
+ /**
236
+ * Tenant-scoped hub URL. Composition matches the AI Adapter's hub map —
237
+ * `app.MapHub<AiHub>("/{tenantId:tenantId}/aiHub")` — so the host's
238
+ * `hubPath` is appended after the tenant segment. Host apps that mount the
239
+ * hub at a different relative path override `hubPath` via
240
+ * `AI_ADAPTER_OPTIONS`.
241
+ */
242
+ get hubUrl() {
243
+ const trimmed = this.options.hubPath.startsWith('/')
244
+ ? this.options.hubPath
245
+ : `/${this.options.hubPath}`;
246
+ return `${this.options.baseUrl}/${this.options.tenantId}${trimmed}`;
247
+ }
248
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AiSessionStreamService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
249
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AiSessionStreamService });
250
+ }
251
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AiSessionStreamService, decorators: [{
252
+ type: Injectable
253
+ }] });
254
+
255
+ /**
256
+ * DI token the services in this library read at construction time.
257
+ * Provided by {@link provideOctoAiConsole}.
258
+ */
259
+ const AI_ADAPTER_OPTIONS = new InjectionToken('mm-octo-ai-console.adapter-options');
260
+ /**
261
+ * Optional DI token a host application provides to enable the live SignalR
262
+ * channel on {@link AiSessionStreamService}. When absent, the service degrades
263
+ * to REST-only backfill.
264
+ */
265
+ const AI_SESSION_STREAM_CONNECTION_FACTORY = new InjectionToken('mm-octo-ai-console.session-stream-connection-factory');
266
+ /**
267
+ * Composable provider that surfaces the library's adapter configuration to the
268
+ * Angular DI container. Host applications call this once in `app.config.ts`:
269
+ *
270
+ * ```ts
271
+ * providers: [
272
+ * provideOctoAiConsole({
273
+ * baseUrl: 'https://ai.acme.cloud',
274
+ * tenantId: 'acme',
275
+ * hubPath: '/hubs/ai',
276
+ * }),
277
+ * ]
278
+ * ```
279
+ */
280
+ function provideOctoAiConsole(options) {
281
+ // Provide AiAdapterClientService + AiSessionStreamService alongside the
282
+ // options token. Both services drop `providedIn: 'root'` (6d0e7daf) because
283
+ // the options they read are route-scoped — a host with multiple tenants
284
+ // composes the provider with the right tenant id per route, and root-
285
+ // singleton services would freeze the first route's options. Bundling the
286
+ // services into the same provider chain means a host's `providers: [
287
+ // provideOctoAiConsole({...}) ]` is enough; no manual listing required.
288
+ return makeEnvironmentProviders([
289
+ { provide: AI_ADAPTER_OPTIONS, useValue: options },
290
+ AiAdapterClientService,
291
+ AiSessionStreamService,
292
+ ]);
293
+ }
294
+
295
+ /**
296
+ * Status pill that mirrors the eight values `AiSessionStatus` carries on the
297
+ * wire. The component is purely visual — it takes a status string in and emits
298
+ * a CSS class + display label that the host's stylesheet can theme via the
299
+ * `--mm-ai-status-*` custom properties.
300
+ */
301
+ class AiJobStatusBadgeComponent {
302
+ status = input.required(...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
303
+ variant = computed(() => {
304
+ switch (this.status()) {
305
+ case 'Running':
306
+ return 'running';
307
+ case 'Queued':
308
+ return 'queued';
309
+ case 'Paused':
310
+ return 'paused';
311
+ case 'Completed':
312
+ return 'completed';
313
+ case 'Failed':
314
+ case 'Cancelled':
315
+ return 'failed';
316
+ case 'QuotaBlocked':
317
+ case 'RateLimited':
318
+ return 'blocked';
319
+ default:
320
+ return 'queued';
321
+ }
322
+ }, ...(ngDevMode ? [{ debugName: "variant" }] : /* istanbul ignore next */ []));
323
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AiJobStatusBadgeComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
324
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.9", type: AiJobStatusBadgeComponent, isStandalone: true, selector: "mm-ai-job-status-badge", inputs: { status: { classPropertyName: "status", publicName: "status", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: "<span class=\"mm-ai-status-badge mm-ai-status-badge--{{ variant() }}\">\n {{ status() }}\n</span>\n", styles: [":host{display:inline-block;--mm-ai-status-running-bg: #1f6feb;--mm-ai-status-running-fg: #ffffff;--mm-ai-status-queued-bg: #6e7681;--mm-ai-status-queued-fg: #ffffff;--mm-ai-status-paused-bg: #d29922;--mm-ai-status-paused-fg: #1c1c1c;--mm-ai-status-completed-bg: #2ea44f;--mm-ai-status-completed-fg: #ffffff;--mm-ai-status-failed-bg: #d73a49;--mm-ai-status-failed-fg: #ffffff;--mm-ai-status-blocked-bg: #8957e5;--mm-ai-status-blocked-fg: #ffffff}.mm-ai-status-badge{display:inline-block;padding:.15rem .65rem;border-radius:9999px;font-size:.8rem;font-weight:600;line-height:1.2;letter-spacing:.01em}.mm-ai-status-badge--running{background:var(--mm-ai-status-running-bg);color:var(--mm-ai-status-running-fg)}.mm-ai-status-badge--queued{background:var(--mm-ai-status-queued-bg);color:var(--mm-ai-status-queued-fg)}.mm-ai-status-badge--paused{background:var(--mm-ai-status-paused-bg);color:var(--mm-ai-status-paused-fg)}.mm-ai-status-badge--completed{background:var(--mm-ai-status-completed-bg);color:var(--mm-ai-status-completed-fg)}.mm-ai-status-badge--failed{background:var(--mm-ai-status-failed-bg);color:var(--mm-ai-status-failed-fg)}.mm-ai-status-badge--blocked{background:var(--mm-ai-status-blocked-bg);color:var(--mm-ai-status-blocked-fg)}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
325
+ }
326
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AiJobStatusBadgeComponent, decorators: [{
327
+ type: Component,
328
+ args: [{ selector: 'mm-ai-job-status-badge', imports: [], changeDetection: ChangeDetectionStrategy.OnPush, template: "<span class=\"mm-ai-status-badge mm-ai-status-badge--{{ variant() }}\">\n {{ status() }}\n</span>\n", styles: [":host{display:inline-block;--mm-ai-status-running-bg: #1f6feb;--mm-ai-status-running-fg: #ffffff;--mm-ai-status-queued-bg: #6e7681;--mm-ai-status-queued-fg: #ffffff;--mm-ai-status-paused-bg: #d29922;--mm-ai-status-paused-fg: #1c1c1c;--mm-ai-status-completed-bg: #2ea44f;--mm-ai-status-completed-fg: #ffffff;--mm-ai-status-failed-bg: #d73a49;--mm-ai-status-failed-fg: #ffffff;--mm-ai-status-blocked-bg: #8957e5;--mm-ai-status-blocked-fg: #ffffff}.mm-ai-status-badge{display:inline-block;padding:.15rem .65rem;border-radius:9999px;font-size:.8rem;font-weight:600;line-height:1.2;letter-spacing:.01em}.mm-ai-status-badge--running{background:var(--mm-ai-status-running-bg);color:var(--mm-ai-status-running-fg)}.mm-ai-status-badge--queued{background:var(--mm-ai-status-queued-bg);color:var(--mm-ai-status-queued-fg)}.mm-ai-status-badge--paused{background:var(--mm-ai-status-paused-bg);color:var(--mm-ai-status-paused-fg)}.mm-ai-status-badge--completed{background:var(--mm-ai-status-completed-bg);color:var(--mm-ai-status-completed-fg)}.mm-ai-status-badge--failed{background:var(--mm-ai-status-failed-bg);color:var(--mm-ai-status-failed-fg)}.mm-ai-status-badge--blocked{background:var(--mm-ai-status-blocked-bg);color:var(--mm-ai-status-blocked-fg)}\n"] }]
329
+ }], propDecorators: { status: [{ type: i0.Input, args: [{ isSignal: true, alias: "status", required: true }] }] } });
330
+
331
+ /**
332
+ * List view of an AI Adapter tenant's sessions. The host owns data fetching —
333
+ * the component takes the array as an input signal and emits the rtId on
334
+ * row click. Keeping it data-source-agnostic lets refinery wire its own
335
+ * Apollo cache while a bastion CLI could pass static fixtures in a Storybook.
336
+ */
337
+ class AiSessionListComponent {
338
+ sessions = input.required(...(ngDevMode ? [{ debugName: "sessions" }] : /* istanbul ignore next */ []));
339
+ selectedSessionId = input(null, ...(ngDevMode ? [{ debugName: "selectedSessionId" }] : /* istanbul ignore next */ []));
340
+ sessionSelected = output();
341
+ orderedSessions = computed(() => [...this.sessions()].sort((a, b) => b.startedAt.localeCompare(a.startedAt)), ...(ngDevMode ? [{ debugName: "orderedSessions" }] : /* istanbul ignore next */ []));
342
+ onSelect(sessionRtId) {
343
+ this.sessionSelected.emit(sessionRtId);
344
+ }
345
+ age(startedAt) {
346
+ const started = Date.parse(startedAt);
347
+ if (Number.isNaN(started)) {
348
+ return '';
349
+ }
350
+ const now = Date.now();
351
+ const seconds = Math.floor((now - started) / 1000);
352
+ if (seconds < 60) {
353
+ return `${seconds}s ago`;
354
+ }
355
+ const minutes = Math.floor(seconds / 60);
356
+ if (minutes < 60) {
357
+ return `${minutes}m ago`;
358
+ }
359
+ const hours = Math.floor(minutes / 60);
360
+ if (hours < 24) {
361
+ return `${hours}h ago`;
362
+ }
363
+ const days = Math.floor(hours / 24);
364
+ return `${days}d ago`;
365
+ }
366
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AiSessionListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
367
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.9", type: AiSessionListComponent, isStandalone: true, selector: "mm-ai-session-list", inputs: { sessions: { classPropertyName: "sessions", publicName: "sessions", isSignal: true, isRequired: true, transformFunction: null }, selectedSessionId: { classPropertyName: "selectedSessionId", publicName: "selectedSessionId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { sessionSelected: "sessionSelected" }, ngImport: i0, template: "<ul class=\"mm-ai-session-list\">\n @for (session of orderedSessions(); track session.sessionRtId) {\n <li\n class=\"mm-ai-session-list__row\"\n [class.mm-ai-session-list__row--selected]=\"\n session.sessionRtId === selectedSessionId()\n \"\n >\n <button\n type=\"button\"\n class=\"mm-ai-session-list__button\"\n (click)=\"onSelect(session.sessionRtId)\"\n >\n <div class=\"mm-ai-session-list__main\">\n <div class=\"mm-ai-session-list__goal\">{{ session.goalSummary }}</div>\n <div class=\"mm-ai-session-list__meta\">\n <span class=\"mm-ai-session-list__owner\">{{ session.ownerUserId }}</span>\n <span class=\"mm-ai-session-list__age\">{{ age(session.startedAt) }}</span>\n <span class=\"mm-ai-session-list__tokens\">\n {{ session.tokensConsumed }} tok\n </span>\n @if (session.previewUrl) {\n <!--\n #4136 \u2014 preview-deploy URL the server surfaces when the worker\n pod's nginx sidecar has a build to serve. Opens in a new tab so\n the operator's session-list scroll position survives.\n stopPropagation prevents the row click from also firing.\n -->\n <a\n class=\"mm-ai-session-list__preview\"\n [href]=\"session.previewUrl\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n (click)=\"$event.stopPropagation()\"\n >\n Open preview\n </a>\n }\n </div>\n </div>\n <mm-ai-job-status-badge [status]=\"session.status\" />\n </button>\n </li>\n } @empty {\n <li class=\"mm-ai-session-list__empty\">No sessions yet.</li>\n }\n</ul>\n", styles: [":host{display:block;--mm-ai-list-bg: #16191d;--mm-ai-list-row-bg: #1f2227;--mm-ai-list-row-selected: #2a3142;--mm-ai-list-border: #2f343b;--mm-ai-list-text: #e6e6e6;--mm-ai-list-muted: #9aa0a6;--mm-ai-list-accent: #64ceb9}.mm-ai-session-list{list-style:none;margin:0;padding:0;background:var(--mm-ai-list-bg);border-radius:.5rem;display:flex;flex-direction:column;gap:.25rem;padding:.25rem}.mm-ai-session-list__row{background:var(--mm-ai-list-row-bg);border:1px solid var(--mm-ai-list-border);border-radius:.4rem}.mm-ai-session-list__row--selected{background:var(--mm-ai-list-row-selected)}.mm-ai-session-list__button{width:100%;display:flex;align-items:center;gap:.75rem;padding:.5rem .75rem;background:transparent;color:var(--mm-ai-list-text);border:none;cursor:pointer;text-align:left;font-family:inherit}.mm-ai-session-list__button:hover{background:#ffffff08}.mm-ai-session-list__main{flex:1;min-width:0}.mm-ai-session-list__goal{font-size:.9rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.mm-ai-session-list__meta{display:flex;gap:.75rem;margin-top:.15rem;font-size:.75rem;color:var(--mm-ai-list-muted);font-variant-numeric:tabular-nums}.mm-ai-session-list__preview{color:var(--mm-ai-list-accent);font-size:.75rem;text-decoration:underline;text-underline-offset:2px}.mm-ai-session-list__preview:hover{text-decoration-thickness:2px}.mm-ai-session-list__empty{padding:1rem;text-align:center;color:var(--mm-ai-list-muted);font-style:italic}\n"], dependencies: [{ kind: "component", type: AiJobStatusBadgeComponent, selector: "mm-ai-job-status-badge", inputs: ["status"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
368
+ }
369
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AiSessionListComponent, decorators: [{
370
+ type: Component,
371
+ args: [{ selector: 'mm-ai-session-list', imports: [AiJobStatusBadgeComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: "<ul class=\"mm-ai-session-list\">\n @for (session of orderedSessions(); track session.sessionRtId) {\n <li\n class=\"mm-ai-session-list__row\"\n [class.mm-ai-session-list__row--selected]=\"\n session.sessionRtId === selectedSessionId()\n \"\n >\n <button\n type=\"button\"\n class=\"mm-ai-session-list__button\"\n (click)=\"onSelect(session.sessionRtId)\"\n >\n <div class=\"mm-ai-session-list__main\">\n <div class=\"mm-ai-session-list__goal\">{{ session.goalSummary }}</div>\n <div class=\"mm-ai-session-list__meta\">\n <span class=\"mm-ai-session-list__owner\">{{ session.ownerUserId }}</span>\n <span class=\"mm-ai-session-list__age\">{{ age(session.startedAt) }}</span>\n <span class=\"mm-ai-session-list__tokens\">\n {{ session.tokensConsumed }} tok\n </span>\n @if (session.previewUrl) {\n <!--\n #4136 \u2014 preview-deploy URL the server surfaces when the worker\n pod's nginx sidecar has a build to serve. Opens in a new tab so\n the operator's session-list scroll position survives.\n stopPropagation prevents the row click from also firing.\n -->\n <a\n class=\"mm-ai-session-list__preview\"\n [href]=\"session.previewUrl\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n (click)=\"$event.stopPropagation()\"\n >\n Open preview\n </a>\n }\n </div>\n </div>\n <mm-ai-job-status-badge [status]=\"session.status\" />\n </button>\n </li>\n } @empty {\n <li class=\"mm-ai-session-list__empty\">No sessions yet.</li>\n }\n</ul>\n", styles: [":host{display:block;--mm-ai-list-bg: #16191d;--mm-ai-list-row-bg: #1f2227;--mm-ai-list-row-selected: #2a3142;--mm-ai-list-border: #2f343b;--mm-ai-list-text: #e6e6e6;--mm-ai-list-muted: #9aa0a6;--mm-ai-list-accent: #64ceb9}.mm-ai-session-list{list-style:none;margin:0;padding:0;background:var(--mm-ai-list-bg);border-radius:.5rem;display:flex;flex-direction:column;gap:.25rem;padding:.25rem}.mm-ai-session-list__row{background:var(--mm-ai-list-row-bg);border:1px solid var(--mm-ai-list-border);border-radius:.4rem}.mm-ai-session-list__row--selected{background:var(--mm-ai-list-row-selected)}.mm-ai-session-list__button{width:100%;display:flex;align-items:center;gap:.75rem;padding:.5rem .75rem;background:transparent;color:var(--mm-ai-list-text);border:none;cursor:pointer;text-align:left;font-family:inherit}.mm-ai-session-list__button:hover{background:#ffffff08}.mm-ai-session-list__main{flex:1;min-width:0}.mm-ai-session-list__goal{font-size:.9rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.mm-ai-session-list__meta{display:flex;gap:.75rem;margin-top:.15rem;font-size:.75rem;color:var(--mm-ai-list-muted);font-variant-numeric:tabular-nums}.mm-ai-session-list__preview{color:var(--mm-ai-list-accent);font-size:.75rem;text-decoration:underline;text-underline-offset:2px}.mm-ai-session-list__preview:hover{text-decoration-thickness:2px}.mm-ai-session-list__empty{padding:1rem;text-align:center;color:var(--mm-ai-list-muted);font-style:italic}\n"] }]
372
+ }], propDecorators: { sessions: [{ type: i0.Input, args: [{ isSignal: true, alias: "sessions", required: true }] }], selectedSessionId: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectedSessionId", required: false }] }], sessionSelected: [{ type: i0.Output, args: ["sessionSelected"] }] } });
373
+
374
+ /**
375
+ * Read-only transcript of a session, derived from the events stream. The
376
+ * component takes the events as an input signal so the host owns lifetime —
377
+ * a parent that's already wired `AiSessionStreamService.events$` into a
378
+ * `toSignal()` just hands the same signal in.
379
+ *
380
+ * Markdown rendering is deliberately minimal in Phase 1: the component shows
381
+ * the raw payload (already JSON or stream-json) in a `<pre>` block. The
382
+ * concept §10 calls for proper markdown + code-block highlighting; that
383
+ * upgrade lands behind a `markdown` Input once we've picked a renderer
384
+ * (markdown-it vs marked) and verified its CSP behaviour.
385
+ */
386
+ class AiChatStreamComponent {
387
+ events = input.required(...(ngDevMode ? [{ debugName: "events" }] : /* istanbul ignore next */ []));
388
+ /**
389
+ * Sorted by sequence so out-of-order arrival (the SignalR + REST backfill
390
+ * union) renders deterministically.
391
+ */
392
+ orderedEvents = computed(() => [...this.events()].sort((a, b) => a.sequence - b.sequence), ...(ngDevMode ? [{ debugName: "orderedEvents" }] : /* istanbul ignore next */ []));
393
+ variant(kind) {
394
+ switch (kind) {
395
+ case 'Message':
396
+ return 'message';
397
+ case 'ToolCall':
398
+ return 'tool-call';
399
+ case 'ToolResult':
400
+ return 'tool-result';
401
+ case 'StatusChange':
402
+ return 'status';
403
+ case 'Hook':
404
+ return 'hook';
405
+ case 'Error':
406
+ return 'error';
407
+ default:
408
+ return 'message';
409
+ }
410
+ }
411
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AiChatStreamComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
412
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.9", type: AiChatStreamComponent, isStandalone: true, selector: "mm-ai-chat-stream", inputs: { events: { classPropertyName: "events", publicName: "events", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: "<div class=\"mm-ai-chat\">\n @for (event of orderedEvents(); track event.sequence) {\n <article class=\"mm-ai-chat__event mm-ai-chat__event--{{ variant(event.kind) }}\">\n <header class=\"mm-ai-chat__event-header\">\n <span class=\"mm-ai-chat__kind\">{{ event.kind }}</span>\n <span class=\"mm-ai-chat__actor\">{{ event.actorRef }}</span>\n <span class=\"mm-ai-chat__sequence\">#{{ event.sequence }}</span>\n <time class=\"mm-ai-chat__time\" [attr.datetime]=\"event.at\">\n {{ event.at }}\n </time>\n </header>\n <pre class=\"mm-ai-chat__payload\">{{ event.payload }}</pre>\n </article>\n } @empty {\n <div class=\"mm-ai-chat__empty\">\n No events yet \u2014 the worker hasn't emitted anything.\n </div>\n }\n</div>\n", styles: [":host{display:block;--mm-ai-chat-bg: #16191d;--mm-ai-chat-card-bg: #1f2227;--mm-ai-chat-border: #2f343b;--mm-ai-chat-text: #e6e6e6;--mm-ai-chat-muted: #9aa0a6;--mm-ai-chat-message: #1f6feb;--mm-ai-chat-tool-call: #d29922;--mm-ai-chat-tool-result: #2ea44f;--mm-ai-chat-status: #6e7681;--mm-ai-chat-hook: #8957e5;--mm-ai-chat-error: #d73a49}.mm-ai-chat{display:flex;flex-direction:column;gap:.5rem;padding:.5rem;background:var(--mm-ai-chat-bg);color:var(--mm-ai-chat-text);border-radius:.5rem}.mm-ai-chat__event{background:var(--mm-ai-chat-card-bg);border:1px solid var(--mm-ai-chat-border);border-left-width:3px;border-radius:.4rem;padding:.5rem .6rem}.mm-ai-chat__event--message{border-left-color:var(--mm-ai-chat-message)}.mm-ai-chat__event--tool-call{border-left-color:var(--mm-ai-chat-tool-call)}.mm-ai-chat__event--tool-result{border-left-color:var(--mm-ai-chat-tool-result)}.mm-ai-chat__event--status{border-left-color:var(--mm-ai-chat-status)}.mm-ai-chat__event--hook{border-left-color:var(--mm-ai-chat-hook)}.mm-ai-chat__event--error{border-left-color:var(--mm-ai-chat-error)}.mm-ai-chat__event-header{display:flex;gap:.5rem;align-items:baseline;font-size:.75rem;color:var(--mm-ai-chat-muted);margin-bottom:.3rem}.mm-ai-chat__kind{font-weight:700;color:var(--mm-ai-chat-text);text-transform:uppercase;letter-spacing:.04em}.mm-ai-chat__sequence,.mm-ai-chat__time{font-variant-numeric:tabular-nums}.mm-ai-chat__time{margin-left:auto}.mm-ai-chat__payload{margin:0;font-size:.8rem;white-space:pre-wrap;word-break:break-word}.mm-ai-chat__empty{color:var(--mm-ai-chat-muted);text-align:center;padding:1rem;font-style:italic}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
413
+ }
414
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AiChatStreamComponent, decorators: [{
415
+ type: Component,
416
+ args: [{ selector: 'mm-ai-chat-stream', imports: [], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"mm-ai-chat\">\n @for (event of orderedEvents(); track event.sequence) {\n <article class=\"mm-ai-chat__event mm-ai-chat__event--{{ variant(event.kind) }}\">\n <header class=\"mm-ai-chat__event-header\">\n <span class=\"mm-ai-chat__kind\">{{ event.kind }}</span>\n <span class=\"mm-ai-chat__actor\">{{ event.actorRef }}</span>\n <span class=\"mm-ai-chat__sequence\">#{{ event.sequence }}</span>\n <time class=\"mm-ai-chat__time\" [attr.datetime]=\"event.at\">\n {{ event.at }}\n </time>\n </header>\n <pre class=\"mm-ai-chat__payload\">{{ event.payload }}</pre>\n </article>\n } @empty {\n <div class=\"mm-ai-chat__empty\">\n No events yet \u2014 the worker hasn't emitted anything.\n </div>\n }\n</div>\n", styles: [":host{display:block;--mm-ai-chat-bg: #16191d;--mm-ai-chat-card-bg: #1f2227;--mm-ai-chat-border: #2f343b;--mm-ai-chat-text: #e6e6e6;--mm-ai-chat-muted: #9aa0a6;--mm-ai-chat-message: #1f6feb;--mm-ai-chat-tool-call: #d29922;--mm-ai-chat-tool-result: #2ea44f;--mm-ai-chat-status: #6e7681;--mm-ai-chat-hook: #8957e5;--mm-ai-chat-error: #d73a49}.mm-ai-chat{display:flex;flex-direction:column;gap:.5rem;padding:.5rem;background:var(--mm-ai-chat-bg);color:var(--mm-ai-chat-text);border-radius:.5rem}.mm-ai-chat__event{background:var(--mm-ai-chat-card-bg);border:1px solid var(--mm-ai-chat-border);border-left-width:3px;border-radius:.4rem;padding:.5rem .6rem}.mm-ai-chat__event--message{border-left-color:var(--mm-ai-chat-message)}.mm-ai-chat__event--tool-call{border-left-color:var(--mm-ai-chat-tool-call)}.mm-ai-chat__event--tool-result{border-left-color:var(--mm-ai-chat-tool-result)}.mm-ai-chat__event--status{border-left-color:var(--mm-ai-chat-status)}.mm-ai-chat__event--hook{border-left-color:var(--mm-ai-chat-hook)}.mm-ai-chat__event--error{border-left-color:var(--mm-ai-chat-error)}.mm-ai-chat__event-header{display:flex;gap:.5rem;align-items:baseline;font-size:.75rem;color:var(--mm-ai-chat-muted);margin-bottom:.3rem}.mm-ai-chat__kind{font-weight:700;color:var(--mm-ai-chat-text);text-transform:uppercase;letter-spacing:.04em}.mm-ai-chat__sequence,.mm-ai-chat__time{font-variant-numeric:tabular-nums}.mm-ai-chat__time{margin-left:auto}.mm-ai-chat__payload{margin:0;font-size:.8rem;white-space:pre-wrap;word-break:break-word}.mm-ai-chat__empty{color:var(--mm-ai-chat-muted);text-align:center;padding:1rem;font-style:italic}\n"] }]
417
+ }], propDecorators: { events: [{ type: i0.Input, args: [{ isSignal: true, alias: "events", required: true }] }] } });
418
+
419
+ /**
420
+ * Expandable card surface for one tool invocation in a session transcript.
421
+ * Click the header to toggle arguments + result; the body is rendered as
422
+ * pretty-printed JSON so the host doesn't need its own json formatter.
423
+ */
424
+ class AiToolCallComponent {
425
+ call = input.required(...(ngDevMode ? [{ debugName: "call" }] : /* istanbul ignore next */ []));
426
+ expanded = signal(false, ...(ngDevMode ? [{ debugName: "expanded" }] : /* istanbul ignore next */ []));
427
+ statusVariant = computed(() => {
428
+ const status = this.call().status;
429
+ switch (status) {
430
+ case 'Succeeded':
431
+ return 'succeeded';
432
+ case 'Failed':
433
+ return 'failed';
434
+ case 'Rejected':
435
+ return 'rejected';
436
+ case 'Approved':
437
+ case 'Pending':
438
+ return 'pending';
439
+ default:
440
+ return 'pending';
441
+ }
442
+ }, ...(ngDevMode ? [{ debugName: "statusVariant" }] : /* istanbul ignore next */ []));
443
+ argumentsJson = computed(() => this.format(this.call().arguments), ...(ngDevMode ? [{ debugName: "argumentsJson" }] : /* istanbul ignore next */ []));
444
+ resultJson = computed(() => {
445
+ const result = this.call().result;
446
+ return result === undefined ? null : this.format(result);
447
+ }, ...(ngDevMode ? [{ debugName: "resultJson" }] : /* istanbul ignore next */ []));
448
+ toggleExpanded() {
449
+ this.expanded.update((v) => !v);
450
+ }
451
+ format(value) {
452
+ if (value === null || value === undefined) {
453
+ return '';
454
+ }
455
+ try {
456
+ return JSON.stringify(value, null, 2);
457
+ }
458
+ catch {
459
+ return String(value);
460
+ }
461
+ }
462
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AiToolCallComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
463
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.9", type: AiToolCallComponent, isStandalone: true, selector: "mm-ai-tool-call", inputs: { call: { classPropertyName: "call", publicName: "call", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: "<div class=\"mm-ai-tool-call mm-ai-tool-call--{{ statusVariant() }}\">\n <button\n type=\"button\"\n class=\"mm-ai-tool-call__header\"\n (click)=\"toggleExpanded()\"\n [attr.aria-expanded]=\"expanded()\"\n >\n <span class=\"mm-ai-tool-call__chevron\">\n {{ expanded() ? '\u25BE' : '\u25B8' }}\n </span>\n <span class=\"mm-ai-tool-call__name\">{{ call().toolName }}</span>\n <span class=\"mm-ai-tool-call__status\">{{ call().status }}</span>\n @if (call().durationMs != null) {\n <span class=\"mm-ai-tool-call__duration\">\n {{ call().durationMs }}ms\n </span>\n }\n </button>\n\n @if (expanded()) {\n <div class=\"mm-ai-tool-call__body\">\n <div class=\"mm-ai-tool-call__section\">\n <h4 class=\"mm-ai-tool-call__heading\">Arguments</h4>\n <pre class=\"mm-ai-tool-call__code\">{{ argumentsJson() }}</pre>\n </div>\n\n @if (resultJson() !== null) {\n <div class=\"mm-ai-tool-call__section\">\n <h4 class=\"mm-ai-tool-call__heading\">Result</h4>\n <pre class=\"mm-ai-tool-call__code\">{{ resultJson() }}</pre>\n </div>\n }\n </div>\n }\n</div>\n", styles: [":host{display:block;--mm-ai-tool-bg: #1f2227;--mm-ai-tool-border: #2f343b;--mm-ai-tool-text: #e6e6e6;--mm-ai-tool-muted: #9aa0a6;--mm-ai-tool-success: #2ea44f;--mm-ai-tool-failed: #d73a49;--mm-ai-tool-rejected: #d29922;--mm-ai-tool-pending: #6e7681;--mm-ai-tool-code-bg: #16191d}.mm-ai-tool-call{border:1px solid var(--mm-ai-tool-border);border-radius:.5rem;background:var(--mm-ai-tool-bg);color:var(--mm-ai-tool-text);margin:.4rem 0;overflow:hidden}.mm-ai-tool-call--succeeded{border-left:3px solid var(--mm-ai-tool-success)}.mm-ai-tool-call--failed{border-left:3px solid var(--mm-ai-tool-failed)}.mm-ai-tool-call--rejected{border-left:3px solid var(--mm-ai-tool-rejected)}.mm-ai-tool-call--pending{border-left:3px solid var(--mm-ai-tool-pending)}.mm-ai-tool-call__header{width:100%;display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;background:transparent;color:inherit;border:none;cursor:pointer;text-align:left;font-family:inherit;font-size:.9rem}.mm-ai-tool-call__header:hover{background:#ffffff0a}.mm-ai-tool-call__chevron{width:1rem;text-align:center;color:var(--mm-ai-tool-muted)}.mm-ai-tool-call__name{font-weight:600;flex:1}.mm-ai-tool-call__status,.mm-ai-tool-call__duration{font-size:.75rem;color:var(--mm-ai-tool-muted);font-variant-numeric:tabular-nums}.mm-ai-tool-call__body{border-top:1px solid var(--mm-ai-tool-border);padding:.5rem .75rem}.mm-ai-tool-call__section+.mm-ai-tool-call__section{margin-top:.75rem}.mm-ai-tool-call__heading{margin:0 0 .25rem;font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;color:var(--mm-ai-tool-muted)}.mm-ai-tool-call__code{margin:0;padding:.5rem .6rem;background:var(--mm-ai-tool-code-bg);border-radius:.3rem;font-size:.8rem;overflow-x:auto;white-space:pre-wrap;word-break:break-word}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
464
+ }
465
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AiToolCallComponent, decorators: [{
466
+ type: Component,
467
+ args: [{ selector: 'mm-ai-tool-call', imports: [], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"mm-ai-tool-call mm-ai-tool-call--{{ statusVariant() }}\">\n <button\n type=\"button\"\n class=\"mm-ai-tool-call__header\"\n (click)=\"toggleExpanded()\"\n [attr.aria-expanded]=\"expanded()\"\n >\n <span class=\"mm-ai-tool-call__chevron\">\n {{ expanded() ? '\u25BE' : '\u25B8' }}\n </span>\n <span class=\"mm-ai-tool-call__name\">{{ call().toolName }}</span>\n <span class=\"mm-ai-tool-call__status\">{{ call().status }}</span>\n @if (call().durationMs != null) {\n <span class=\"mm-ai-tool-call__duration\">\n {{ call().durationMs }}ms\n </span>\n }\n </button>\n\n @if (expanded()) {\n <div class=\"mm-ai-tool-call__body\">\n <div class=\"mm-ai-tool-call__section\">\n <h4 class=\"mm-ai-tool-call__heading\">Arguments</h4>\n <pre class=\"mm-ai-tool-call__code\">{{ argumentsJson() }}</pre>\n </div>\n\n @if (resultJson() !== null) {\n <div class=\"mm-ai-tool-call__section\">\n <h4 class=\"mm-ai-tool-call__heading\">Result</h4>\n <pre class=\"mm-ai-tool-call__code\">{{ resultJson() }}</pre>\n </div>\n }\n </div>\n }\n</div>\n", styles: [":host{display:block;--mm-ai-tool-bg: #1f2227;--mm-ai-tool-border: #2f343b;--mm-ai-tool-text: #e6e6e6;--mm-ai-tool-muted: #9aa0a6;--mm-ai-tool-success: #2ea44f;--mm-ai-tool-failed: #d73a49;--mm-ai-tool-rejected: #d29922;--mm-ai-tool-pending: #6e7681;--mm-ai-tool-code-bg: #16191d}.mm-ai-tool-call{border:1px solid var(--mm-ai-tool-border);border-radius:.5rem;background:var(--mm-ai-tool-bg);color:var(--mm-ai-tool-text);margin:.4rem 0;overflow:hidden}.mm-ai-tool-call--succeeded{border-left:3px solid var(--mm-ai-tool-success)}.mm-ai-tool-call--failed{border-left:3px solid var(--mm-ai-tool-failed)}.mm-ai-tool-call--rejected{border-left:3px solid var(--mm-ai-tool-rejected)}.mm-ai-tool-call--pending{border-left:3px solid var(--mm-ai-tool-pending)}.mm-ai-tool-call__header{width:100%;display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;background:transparent;color:inherit;border:none;cursor:pointer;text-align:left;font-family:inherit;font-size:.9rem}.mm-ai-tool-call__header:hover{background:#ffffff0a}.mm-ai-tool-call__chevron{width:1rem;text-align:center;color:var(--mm-ai-tool-muted)}.mm-ai-tool-call__name{font-weight:600;flex:1}.mm-ai-tool-call__status,.mm-ai-tool-call__duration{font-size:.75rem;color:var(--mm-ai-tool-muted);font-variant-numeric:tabular-nums}.mm-ai-tool-call__body{border-top:1px solid var(--mm-ai-tool-border);padding:.5rem .75rem}.mm-ai-tool-call__section+.mm-ai-tool-call__section{margin-top:.75rem}.mm-ai-tool-call__heading{margin:0 0 .25rem;font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;color:var(--mm-ai-tool-muted)}.mm-ai-tool-call__code{margin:0;padding:.5rem .6rem;background:var(--mm-ai-tool-code-bg);border-radius:.3rem;font-size:.8rem;overflow-x:auto;white-space:pre-wrap;word-break:break-word}\n"] }]
468
+ }], propDecorators: { call: [{ type: i0.Input, args: [{ isSignal: true, alias: "call", required: true }] }] } });
469
+
470
+ /**
471
+ * Approval modal. The host opens it when a `AiApprovalRequestedDto` arrives on
472
+ * the SignalR stream, fills the `request` input, and listens for the `decide`
473
+ * output to call `AiAdapterClientService.decideApproval`. The modal itself is
474
+ * presentation-only: no networking, no global state, no portal magic.
475
+ *
476
+ * The host is expected to wrap the modal in its own backdrop / focus-trap so
477
+ * the library doesn't pull in a positioning dependency (CDK overlay, Kendo
478
+ * window, ng-bootstrap, …) — each host shop already has one.
479
+ */
480
+ class AiApprovalModalComponent {
481
+ request = input.required(...(ngDevMode ? [{ debugName: "request" }] : /* istanbul ignore next */ []));
482
+ decide = output();
483
+ dismiss = output();
484
+ comment = signal('', ...(ngDevMode ? [{ debugName: "comment" }] : /* istanbul ignore next */ []));
485
+ prettyPayload = computed(() => {
486
+ try {
487
+ return JSON.stringify(JSON.parse(this.request().payload), null, 2);
488
+ }
489
+ catch {
490
+ return this.request().payload;
491
+ }
492
+ }, ...(ngDevMode ? [{ debugName: "prettyPayload" }] : /* istanbul ignore next */ []));
493
+ approve() {
494
+ this.decide.emit({
495
+ outcome: 'Approved',
496
+ comment: this.commentOrUndefined(),
497
+ });
498
+ }
499
+ reject() {
500
+ this.decide.emit({
501
+ outcome: 'Rejected',
502
+ comment: this.commentOrUndefined(),
503
+ });
504
+ }
505
+ onDismiss() {
506
+ this.dismiss.emit();
507
+ }
508
+ commentOrUndefined() {
509
+ const trimmed = this.comment().trim();
510
+ return trimmed.length > 0 ? trimmed : undefined;
511
+ }
512
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AiApprovalModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
513
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.9", type: AiApprovalModalComponent, isStandalone: true, selector: "mm-ai-approval-modal", inputs: { request: { classPropertyName: "request", publicName: "request", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { decide: "decide", dismiss: "dismiss" }, ngImport: i0, template: "<div class=\"mm-ai-approval\">\n <header class=\"mm-ai-approval__header\">\n <h3 class=\"mm-ai-approval__title\">Approve tool call</h3>\n <button\n type=\"button\"\n class=\"mm-ai-approval__close\"\n (click)=\"onDismiss()\"\n aria-label=\"Dismiss\"\n >\n \u00D7\n </button>\n </header>\n\n <div class=\"mm-ai-approval__meta\">\n <div><span>Tool:</span> <code>{{ request().toolName }}</code></div>\n <div><span>Reason:</span> <code>{{ request().reason }}</code></div>\n <div><span>Session:</span> <code>{{ request().sessionId }}</code></div>\n </div>\n\n <div class=\"mm-ai-approval__section\">\n <h4 class=\"mm-ai-approval__heading\">Payload</h4>\n <pre class=\"mm-ai-approval__code\">{{ prettyPayload() }}</pre>\n </div>\n\n <div class=\"mm-ai-approval__section\">\n <h4 class=\"mm-ai-approval__heading\">Comment <span>(optional)</span></h4>\n <textarea\n class=\"mm-ai-approval__comment\"\n [ngModel]=\"comment()\"\n (ngModelChange)=\"comment.set($event)\"\n rows=\"3\"\n placeholder=\"Why is this approved or rejected?\"\n ></textarea>\n </div>\n\n <footer class=\"mm-ai-approval__actions\">\n <button\n type=\"button\"\n class=\"mm-ai-approval__btn mm-ai-approval__btn--reject\"\n (click)=\"reject()\"\n >\n Reject\n </button>\n <button\n type=\"button\"\n class=\"mm-ai-approval__btn mm-ai-approval__btn--approve\"\n (click)=\"approve()\"\n >\n Approve\n </button>\n </footer>\n</div>\n", styles: [":host{display:block;--mm-ai-approval-bg: #1f2227;--mm-ai-approval-border: #2f343b;--mm-ai-approval-text: #e6e6e6;--mm-ai-approval-muted: #9aa0a6;--mm-ai-approval-approve: #2ea44f;--mm-ai-approval-reject: #d73a49;--mm-ai-approval-code-bg: #16191d}.mm-ai-approval{background:var(--mm-ai-approval-bg);color:var(--mm-ai-approval-text);border:1px solid var(--mm-ai-approval-border);border-radius:.6rem;padding:1rem 1.25rem;width:min(36rem,100%)}.mm-ai-approval__header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem}.mm-ai-approval__title{margin:0;font-size:1rem}.mm-ai-approval__close{background:transparent;color:var(--mm-ai-approval-muted);border:none;font-size:1.4rem;cursor:pointer;line-height:1;padding:0 .25rem}.mm-ai-approval__close:hover{color:var(--mm-ai-approval-text)}.mm-ai-approval__meta{display:grid;gap:.25rem;font-size:.85rem;margin-bottom:.75rem}.mm-ai-approval__meta span{color:var(--mm-ai-approval-muted);margin-right:.35rem}.mm-ai-approval__meta code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.mm-ai-approval__section{margin-bottom:.75rem}.mm-ai-approval__heading{margin:0 0 .3rem;font-size:.75rem;color:var(--mm-ai-approval-muted);text-transform:uppercase;letter-spacing:.04em}.mm-ai-approval__heading span{text-transform:none;letter-spacing:0;opacity:.6}.mm-ai-approval__code{margin:0;padding:.5rem .6rem;background:var(--mm-ai-approval-code-bg);border-radius:.3rem;font-size:.8rem;overflow-x:auto;white-space:pre-wrap;word-break:break-word;max-height:14rem}.mm-ai-approval__comment{width:100%;box-sizing:border-box;padding:.5rem .6rem;background:var(--mm-ai-approval-code-bg);color:inherit;border:1px solid var(--mm-ai-approval-border);border-radius:.3rem;font-family:inherit;font-size:.85rem;resize:vertical}.mm-ai-approval__actions{display:flex;justify-content:flex-end;gap:.5rem;margin-top:.75rem}.mm-ai-approval__btn{padding:.45rem 1rem;border-radius:.3rem;font-weight:600;border:none;cursor:pointer;font-size:.85rem}.mm-ai-approval__btn--approve{background:var(--mm-ai-approval-approve);color:#fff}.mm-ai-approval__btn--reject{background:var(--mm-ai-approval-reject);color:#fff}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
514
+ }
515
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AiApprovalModalComponent, decorators: [{
516
+ type: Component,
517
+ args: [{ selector: 'mm-ai-approval-modal', imports: [FormsModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"mm-ai-approval\">\n <header class=\"mm-ai-approval__header\">\n <h3 class=\"mm-ai-approval__title\">Approve tool call</h3>\n <button\n type=\"button\"\n class=\"mm-ai-approval__close\"\n (click)=\"onDismiss()\"\n aria-label=\"Dismiss\"\n >\n \u00D7\n </button>\n </header>\n\n <div class=\"mm-ai-approval__meta\">\n <div><span>Tool:</span> <code>{{ request().toolName }}</code></div>\n <div><span>Reason:</span> <code>{{ request().reason }}</code></div>\n <div><span>Session:</span> <code>{{ request().sessionId }}</code></div>\n </div>\n\n <div class=\"mm-ai-approval__section\">\n <h4 class=\"mm-ai-approval__heading\">Payload</h4>\n <pre class=\"mm-ai-approval__code\">{{ prettyPayload() }}</pre>\n </div>\n\n <div class=\"mm-ai-approval__section\">\n <h4 class=\"mm-ai-approval__heading\">Comment <span>(optional)</span></h4>\n <textarea\n class=\"mm-ai-approval__comment\"\n [ngModel]=\"comment()\"\n (ngModelChange)=\"comment.set($event)\"\n rows=\"3\"\n placeholder=\"Why is this approved or rejected?\"\n ></textarea>\n </div>\n\n <footer class=\"mm-ai-approval__actions\">\n <button\n type=\"button\"\n class=\"mm-ai-approval__btn mm-ai-approval__btn--reject\"\n (click)=\"reject()\"\n >\n Reject\n </button>\n <button\n type=\"button\"\n class=\"mm-ai-approval__btn mm-ai-approval__btn--approve\"\n (click)=\"approve()\"\n >\n Approve\n </button>\n </footer>\n</div>\n", styles: [":host{display:block;--mm-ai-approval-bg: #1f2227;--mm-ai-approval-border: #2f343b;--mm-ai-approval-text: #e6e6e6;--mm-ai-approval-muted: #9aa0a6;--mm-ai-approval-approve: #2ea44f;--mm-ai-approval-reject: #d73a49;--mm-ai-approval-code-bg: #16191d}.mm-ai-approval{background:var(--mm-ai-approval-bg);color:var(--mm-ai-approval-text);border:1px solid var(--mm-ai-approval-border);border-radius:.6rem;padding:1rem 1.25rem;width:min(36rem,100%)}.mm-ai-approval__header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem}.mm-ai-approval__title{margin:0;font-size:1rem}.mm-ai-approval__close{background:transparent;color:var(--mm-ai-approval-muted);border:none;font-size:1.4rem;cursor:pointer;line-height:1;padding:0 .25rem}.mm-ai-approval__close:hover{color:var(--mm-ai-approval-text)}.mm-ai-approval__meta{display:grid;gap:.25rem;font-size:.85rem;margin-bottom:.75rem}.mm-ai-approval__meta span{color:var(--mm-ai-approval-muted);margin-right:.35rem}.mm-ai-approval__meta code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.mm-ai-approval__section{margin-bottom:.75rem}.mm-ai-approval__heading{margin:0 0 .3rem;font-size:.75rem;color:var(--mm-ai-approval-muted);text-transform:uppercase;letter-spacing:.04em}.mm-ai-approval__heading span{text-transform:none;letter-spacing:0;opacity:.6}.mm-ai-approval__code{margin:0;padding:.5rem .6rem;background:var(--mm-ai-approval-code-bg);border-radius:.3rem;font-size:.8rem;overflow-x:auto;white-space:pre-wrap;word-break:break-word;max-height:14rem}.mm-ai-approval__comment{width:100%;box-sizing:border-box;padding:.5rem .6rem;background:var(--mm-ai-approval-code-bg);color:inherit;border:1px solid var(--mm-ai-approval-border);border-radius:.3rem;font-family:inherit;font-size:.85rem;resize:vertical}.mm-ai-approval__actions{display:flex;justify-content:flex-end;gap:.5rem;margin-top:.75rem}.mm-ai-approval__btn{padding:.45rem 1rem;border-radius:.3rem;font-weight:600;border:none;cursor:pointer;font-size:.85rem}.mm-ai-approval__btn--approve{background:var(--mm-ai-approval-approve);color:#fff}.mm-ai-approval__btn--reject{background:var(--mm-ai-approval-reject);color:#fff}\n"] }]
518
+ }], propDecorators: { request: [{ type: i0.Input, args: [{ isSignal: true, alias: "request", required: true }] }], decide: [{ type: i0.Output, args: ["decide"] }], dismiss: [{ type: i0.Output, args: ["dismiss"] }] } });
519
+
520
+ /**
521
+ * Two-bar usage indicator (daily + monthly) with threshold colour state.
522
+ * Inputs are unit-agnostic counts and caps; the component does not parse the
523
+ * tenant's `AiQuotaLimit` itself — the host is expected to feed numbers the
524
+ * adapter already aggregated.
525
+ */
526
+ class AiQuotaIndicatorComponent {
527
+ dailyConsumed = input.required(...(ngDevMode ? [{ debugName: "dailyConsumed" }] : /* istanbul ignore next */ []));
528
+ dailyCap = input.required(...(ngDevMode ? [{ debugName: "dailyCap" }] : /* istanbul ignore next */ []));
529
+ monthlyConsumed = input(null, ...(ngDevMode ? [{ debugName: "monthlyConsumed" }] : /* istanbul ignore next */ []));
530
+ monthlyCap = input(null, ...(ngDevMode ? [{ debugName: "monthlyCap" }] : /* istanbul ignore next */ []));
531
+ dailyPercent = computed(() => this.percent(this.dailyConsumed(), this.dailyCap()), ...(ngDevMode ? [{ debugName: "dailyPercent" }] : /* istanbul ignore next */ []));
532
+ monthlyPercent = computed(() => {
533
+ const consumed = this.monthlyConsumed();
534
+ const cap = this.monthlyCap();
535
+ if (consumed == null || cap == null) {
536
+ return null;
537
+ }
538
+ return this.percent(consumed, cap);
539
+ }, ...(ngDevMode ? [{ debugName: "monthlyPercent" }] : /* istanbul ignore next */ []));
540
+ dailyState = computed(() => this.toState(this.dailyPercent()), ...(ngDevMode ? [{ debugName: "dailyState" }] : /* istanbul ignore next */ []));
541
+ monthlyState = computed(() => {
542
+ const pct = this.monthlyPercent();
543
+ return pct == null ? null : this.toState(pct);
544
+ }, ...(ngDevMode ? [{ debugName: "monthlyState" }] : /* istanbul ignore next */ []));
545
+ percent(consumed, cap) {
546
+ if (cap <= 0) {
547
+ return 0;
548
+ }
549
+ return Math.min(100, Math.round((consumed / cap) * 100));
550
+ }
551
+ toState(percent) {
552
+ if (percent >= 100) {
553
+ return 'critical';
554
+ }
555
+ if (percent >= 80) {
556
+ return 'warning';
557
+ }
558
+ return 'ok';
559
+ }
560
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AiQuotaIndicatorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
561
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.9", type: AiQuotaIndicatorComponent, isStandalone: true, selector: "mm-ai-quota-indicator", inputs: { dailyConsumed: { classPropertyName: "dailyConsumed", publicName: "dailyConsumed", isSignal: true, isRequired: true, transformFunction: null }, dailyCap: { classPropertyName: "dailyCap", publicName: "dailyCap", isSignal: true, isRequired: true, transformFunction: null }, monthlyConsumed: { classPropertyName: "monthlyConsumed", publicName: "monthlyConsumed", isSignal: true, isRequired: false, transformFunction: null }, monthlyCap: { classPropertyName: "monthlyCap", publicName: "monthlyCap", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<div class=\"mm-ai-quota\">\n <div class=\"mm-ai-quota__row\">\n <span class=\"mm-ai-quota__label\">Daily</span>\n <div class=\"mm-ai-quota__bar mm-ai-quota__bar--{{ dailyState() }}\">\n <div class=\"mm-ai-quota__fill\" [style.width.%]=\"dailyPercent()\"></div>\n </div>\n <span class=\"mm-ai-quota__count\">\n {{ dailyConsumed() }} / {{ dailyCap() }} ({{ dailyPercent() }}%)\n </span>\n </div>\n\n @if (monthlyPercent() !== null) {\n <div class=\"mm-ai-quota__row\">\n <span class=\"mm-ai-quota__label\">Monthly</span>\n <div class=\"mm-ai-quota__bar mm-ai-quota__bar--{{ monthlyState() }}\">\n <div class=\"mm-ai-quota__fill\" [style.width.%]=\"monthlyPercent()\"></div>\n </div>\n <span class=\"mm-ai-quota__count\">\n {{ monthlyConsumed() }} / {{ monthlyCap() }} ({{ monthlyPercent() }}%)\n </span>\n </div>\n }\n</div>\n", styles: [":host{display:block;--mm-ai-quota-track-bg: #2a2a2a;--mm-ai-quota-ok: #2ea44f;--mm-ai-quota-warning: #d29922;--mm-ai-quota-critical: #d73a49}.mm-ai-quota{display:flex;flex-direction:column;gap:.5rem;font-size:.85rem}.mm-ai-quota__row{display:grid;grid-template-columns:4.5rem 1fr auto;align-items:center;gap:.5rem}.mm-ai-quota__label{font-weight:600;opacity:.7}.mm-ai-quota__bar{height:.5rem;border-radius:9999px;background:var(--mm-ai-quota-track-bg);overflow:hidden}.mm-ai-quota__bar--ok .mm-ai-quota__fill{background:var(--mm-ai-quota-ok)}.mm-ai-quota__bar--warning .mm-ai-quota__fill{background:var(--mm-ai-quota-warning)}.mm-ai-quota__bar--critical .mm-ai-quota__fill{background:var(--mm-ai-quota-critical)}.mm-ai-quota__fill{height:100%;transition:width .2s ease}.mm-ai-quota__count{font-variant-numeric:tabular-nums;opacity:.8}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
562
+ }
563
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AiQuotaIndicatorComponent, decorators: [{
564
+ type: Component,
565
+ args: [{ selector: 'mm-ai-quota-indicator', imports: [], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"mm-ai-quota\">\n <div class=\"mm-ai-quota__row\">\n <span class=\"mm-ai-quota__label\">Daily</span>\n <div class=\"mm-ai-quota__bar mm-ai-quota__bar--{{ dailyState() }}\">\n <div class=\"mm-ai-quota__fill\" [style.width.%]=\"dailyPercent()\"></div>\n </div>\n <span class=\"mm-ai-quota__count\">\n {{ dailyConsumed() }} / {{ dailyCap() }} ({{ dailyPercent() }}%)\n </span>\n </div>\n\n @if (monthlyPercent() !== null) {\n <div class=\"mm-ai-quota__row\">\n <span class=\"mm-ai-quota__label\">Monthly</span>\n <div class=\"mm-ai-quota__bar mm-ai-quota__bar--{{ monthlyState() }}\">\n <div class=\"mm-ai-quota__fill\" [style.width.%]=\"monthlyPercent()\"></div>\n </div>\n <span class=\"mm-ai-quota__count\">\n {{ monthlyConsumed() }} / {{ monthlyCap() }} ({{ monthlyPercent() }}%)\n </span>\n </div>\n }\n</div>\n", styles: [":host{display:block;--mm-ai-quota-track-bg: #2a2a2a;--mm-ai-quota-ok: #2ea44f;--mm-ai-quota-warning: #d29922;--mm-ai-quota-critical: #d73a49}.mm-ai-quota{display:flex;flex-direction:column;gap:.5rem;font-size:.85rem}.mm-ai-quota__row{display:grid;grid-template-columns:4.5rem 1fr auto;align-items:center;gap:.5rem}.mm-ai-quota__label{font-weight:600;opacity:.7}.mm-ai-quota__bar{height:.5rem;border-radius:9999px;background:var(--mm-ai-quota-track-bg);overflow:hidden}.mm-ai-quota__bar--ok .mm-ai-quota__fill{background:var(--mm-ai-quota-ok)}.mm-ai-quota__bar--warning .mm-ai-quota__fill{background:var(--mm-ai-quota-warning)}.mm-ai-quota__bar--critical .mm-ai-quota__fill{background:var(--mm-ai-quota-critical)}.mm-ai-quota__fill{height:100%;transition:width .2s ease}.mm-ai-quota__count{font-variant-numeric:tabular-nums;opacity:.8}\n"] }]
566
+ }], propDecorators: { dailyConsumed: [{ type: i0.Input, args: [{ isSignal: true, alias: "dailyConsumed", required: true }] }], dailyCap: [{ type: i0.Input, args: [{ isSignal: true, alias: "dailyCap", required: true }] }], monthlyConsumed: [{ type: i0.Input, args: [{ isSignal: true, alias: "monthlyConsumed", required: false }] }], monthlyCap: [{ type: i0.Input, args: [{ isSignal: true, alias: "monthlyCap", required: false }] }] } });
567
+
568
+ /**
569
+ * Self-service credential-ticket issuer (#4133, concept §10). Admin picks a
570
+ * scope + TTL, hits **Issue ticket**, copies the displayed code, hands it to
571
+ * a developer / operator out-of-band. The plaintext code is shown exactly
572
+ * once — refreshing the panel after issue clears it so a careless screenshot
573
+ * doesn't leak.
574
+ *
575
+ * Theme-neutral by design: visuals use CSS custom properties with neutral
576
+ * defaults so the host (Refinery Studio LCARS, demo-app, etc.) can override
577
+ * them via `--mm-ai-ticket-*` variables without touching the component.
578
+ */
579
+ class AiCredentialTicketIssueComponent {
580
+ client = inject(AiAdapterClientService);
581
+ scope = signal('CredentialRegister', ...(ngDevMode ? [{ debugName: "scope" }] : /* istanbul ignore next */ []));
582
+ ttlMinutes = signal(5, ...(ngDevMode ? [{ debugName: "ttlMinutes" }] : /* istanbul ignore next */ []));
583
+ issuing = signal(false, ...(ngDevMode ? [{ debugName: "issuing" }] : /* istanbul ignore next */ []));
584
+ issued = signal(null, ...(ngDevMode ? [{ debugName: "issued" }] : /* istanbul ignore next */ []));
585
+ error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
586
+ copied = signal(false, ...(ngDevMode ? [{ debugName: "copied" }] : /* istanbul ignore next */ []));
587
+ /** Available scope choices — kept in sync with `AiCredentialTicketScope`. */
588
+ scopes = [
589
+ {
590
+ value: 'CredentialRegister',
591
+ label: 'Subscription token',
592
+ hint: 'For the bastion CLI to register an Anthropic OAuth pair on this tenant.',
593
+ },
594
+ {
595
+ value: 'DevSshKeyRegister',
596
+ label: 'Developer SSH key',
597
+ hint: 'For a developer to register their own SSH public key on this tenant.',
598
+ },
599
+ ];
600
+ onIssue() {
601
+ if (this.issuing()) {
602
+ return;
603
+ }
604
+ const ttl = this.normalizedTtl();
605
+ this.issuing.set(true);
606
+ this.error.set(null);
607
+ this.copied.set(false);
608
+ this.issued.set(null);
609
+ this.client
610
+ .issueCredentialTicket({
611
+ scope: this.scope(),
612
+ ttlMinutes: ttl,
613
+ })
614
+ .subscribe({
615
+ next: (response) => {
616
+ this.issued.set(response);
617
+ this.issuing.set(false);
618
+ },
619
+ error: (err) => {
620
+ this.error.set(this.formatError(err));
621
+ this.issuing.set(false);
622
+ },
623
+ });
624
+ }
625
+ onCopy() {
626
+ const code = this.issued()?.code;
627
+ if (!code) {
628
+ return;
629
+ }
630
+ // navigator.clipboard requires a secure context; if it's unavailable the
631
+ // operator can still select + copy by hand. Falling back silently keeps
632
+ // the panel usable on http://localhost dev origins.
633
+ if (navigator.clipboard && navigator.clipboard.writeText) {
634
+ navigator.clipboard.writeText(code).then(() => this.copied.set(true), () => this.copied.set(false));
635
+ }
636
+ }
637
+ onClear() {
638
+ this.issued.set(null);
639
+ this.copied.set(false);
640
+ }
641
+ normalizedTtl() {
642
+ const v = Math.trunc(this.ttlMinutes());
643
+ // Server clamps to 60 anyway; clamp here too so the slider can't push a
644
+ // value the request would silently truncate.
645
+ if (Number.isNaN(v) || v < 1) {
646
+ return 1;
647
+ }
648
+ if (v > 60) {
649
+ return 60;
650
+ }
651
+ return v;
652
+ }
653
+ formatError(err) {
654
+ if (typeof err === 'object' &&
655
+ err !== null &&
656
+ 'status' in err &&
657
+ typeof err.status === 'number') {
658
+ const status = err.status;
659
+ if (status === 401 || status === 403) {
660
+ return 'Not authorised — only tenant admins can issue tickets.';
661
+ }
662
+ if (status === 400) {
663
+ return 'Invalid request — check the scope value.';
664
+ }
665
+ return `Server returned HTTP ${status}.`;
666
+ }
667
+ return 'Failed to reach the AI Adapter — check the network.';
668
+ }
669
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AiCredentialTicketIssueComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
670
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.9", type: AiCredentialTicketIssueComponent, isStandalone: true, selector: "mm-ai-credential-ticket-issue", ngImport: i0, template: "<section class=\"mm-ai-ticket\">\n <header class=\"mm-ai-ticket__header\">\n <div class=\"mm-ai-ticket__title\">Issue credential ticket</div>\n <div class=\"mm-ai-ticket__subtitle\">\n Mint a one-time code an operator redeems on their own machine.\n </div>\n </header>\n\n @if (!issued()) {\n <form\n class=\"mm-ai-ticket__form\"\n (ngSubmit)=\"onIssue()\"\n #issueForm=\"ngForm\"\n >\n <label class=\"mm-ai-ticket__field\">\n <span class=\"mm-ai-ticket__label\">Scope</span>\n <select\n class=\"mm-ai-ticket__select\"\n [ngModel]=\"scope()\"\n (ngModelChange)=\"scope.set($event)\"\n name=\"scope\"\n [disabled]=\"issuing()\"\n >\n @for (s of scopes; track s.value) {\n <option [value]=\"s.value\">{{ s.label }}</option>\n }\n </select>\n </label>\n <p class=\"mm-ai-ticket__hint\">\n @for (s of scopes; track s.value) {\n @if (s.value === scope()) {\n {{ s.hint }}\n }\n }\n </p>\n\n <label class=\"mm-ai-ticket__field\">\n <span class=\"mm-ai-ticket__label\">TTL (minutes)</span>\n <input\n class=\"mm-ai-ticket__input\"\n type=\"number\"\n min=\"1\"\n max=\"60\"\n step=\"1\"\n [ngModel]=\"ttlMinutes()\"\n (ngModelChange)=\"ttlMinutes.set(+$event)\"\n name=\"ttlMinutes\"\n [disabled]=\"issuing()\"\n />\n </label>\n <p class=\"mm-ai-ticket__hint\">\n Default 5 minutes. Server clamps to 60 \u2014 long TTLs leak.\n </p>\n\n <div class=\"mm-ai-ticket__actions\">\n <button\n class=\"mm-ai-ticket__button mm-ai-ticket__button--primary\"\n type=\"submit\"\n [disabled]=\"issuing()\"\n >\n @if (issuing()) {\n Issuing\u2026\n } @else {\n Issue ticket\n }\n </button>\n </div>\n\n @if (error()) {\n <p class=\"mm-ai-ticket__error\">{{ error() }}</p>\n }\n </form>\n } @else {\n <div class=\"mm-ai-ticket__result\">\n <p class=\"mm-ai-ticket__result-label\">\n One-time code (copy now \u2014 not shown again):\n </p>\n <div class=\"mm-ai-ticket__code-row\">\n <code class=\"mm-ai-ticket__code\">{{ issued()!.code }}</code>\n <button\n class=\"mm-ai-ticket__button mm-ai-ticket__button--secondary\"\n type=\"button\"\n (click)=\"onCopy()\"\n >\n @if (copied()) {\n Copied\n } @else {\n Copy\n }\n </button>\n </div>\n <dl class=\"mm-ai-ticket__meta\">\n <div>\n <dt>Scope</dt>\n <dd>{{ issued()!.scope }}</dd>\n </div>\n <div>\n <dt>Expires at</dt>\n <dd>{{ issued()!.expiresAt }}</dd>\n </div>\n <div>\n <dt>Ticket rtId</dt>\n <dd><code>{{ issued()!.rtId }}</code></dd>\n </div>\n </dl>\n <div class=\"mm-ai-ticket__actions\">\n <button\n class=\"mm-ai-ticket__button\"\n type=\"button\"\n (click)=\"onClear()\"\n >\n Issue another\n </button>\n </div>\n </div>\n }\n</section>\n", styles: [":host{display:block;--mm-ai-ticket-bg: #16191d;--mm-ai-ticket-surface: #1f2227;--mm-ai-ticket-border: #2f343b;--mm-ai-ticket-text: #e6e6e6;--mm-ai-ticket-muted: #9aa0a6;--mm-ai-ticket-accent: #64ceb9;--mm-ai-ticket-error: #ec658f;--mm-ai-ticket-code-bg: #0f1115}.mm-ai-ticket{background:var(--mm-ai-ticket-bg);color:var(--mm-ai-ticket-text);border:1px solid var(--mm-ai-ticket-border);border-radius:.5rem;padding:1rem 1.25rem;display:flex;flex-direction:column;gap:.85rem;font-family:inherit}.mm-ai-ticket__header{display:flex;flex-direction:column;gap:.15rem}.mm-ai-ticket__title{font-size:1rem;font-weight:600}.mm-ai-ticket__subtitle{font-size:.8rem;color:var(--mm-ai-ticket-muted)}.mm-ai-ticket__form,.mm-ai-ticket__result{display:flex;flex-direction:column;gap:.6rem}.mm-ai-ticket__field{display:flex;flex-direction:column;gap:.25rem}.mm-ai-ticket__label{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:var(--mm-ai-ticket-muted)}.mm-ai-ticket__select,.mm-ai-ticket__input{background:var(--mm-ai-ticket-surface);color:var(--mm-ai-ticket-text);border:1px solid var(--mm-ai-ticket-border);border-radius:.3rem;padding:.4rem .55rem;font-family:inherit;font-size:.9rem}.mm-ai-ticket__select:focus,.mm-ai-ticket__input:focus{outline:2px solid var(--mm-ai-ticket-accent);outline-offset:1px}.mm-ai-ticket__select:disabled,.mm-ai-ticket__input:disabled{opacity:.6;cursor:not-allowed}.mm-ai-ticket__hint{margin:0;font-size:.75rem;color:var(--mm-ai-ticket-muted)}.mm-ai-ticket__actions{display:flex;gap:.5rem;margin-top:.25rem}.mm-ai-ticket__button{background:var(--mm-ai-ticket-surface);color:var(--mm-ai-ticket-text);border:1px solid var(--mm-ai-ticket-border);border-radius:.3rem;padding:.4rem .85rem;font-family:inherit;font-size:.85rem;cursor:pointer}.mm-ai-ticket__button:hover:not(:disabled){border-color:var(--mm-ai-ticket-accent)}.mm-ai-ticket__button:disabled{opacity:.5;cursor:not-allowed}.mm-ai-ticket__button--primary{background:var(--mm-ai-ticket-accent);color:#0a0c0f;border-color:var(--mm-ai-ticket-accent)}.mm-ai-ticket__error{margin:0;font-size:.8rem;color:var(--mm-ai-ticket-error)}.mm-ai-ticket__result-label{margin:0;font-size:.85rem;color:var(--mm-ai-ticket-muted)}.mm-ai-ticket__code-row{display:flex;align-items:center;gap:.5rem}.mm-ai-ticket__code{flex:1;background:var(--mm-ai-ticket-code-bg);color:var(--mm-ai-ticket-accent);padding:.5rem .75rem;border-radius:.3rem;font-family:Roboto Mono,ui-monospace,monospace;font-size:1.05rem;letter-spacing:.1em;-webkit-user-select:all;user-select:all}.mm-ai-ticket__meta{display:grid;grid-template-columns:max-content 1fr;gap:.2rem .75rem;margin:0;font-size:.8rem}.mm-ai-ticket__meta>div{display:contents}.mm-ai-ticket__meta dt{color:var(--mm-ai-ticket-muted);text-transform:uppercase;letter-spacing:.05em;font-size:.7rem}.mm-ai-ticket__meta dd{margin:0;font-family:Roboto Mono,ui-monospace,monospace}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { kind: "directive", type: i1.MaxValidator, selector: "input[type=number][max][formControlName],input[type=number][max][formControl],input[type=number][max][ngModel]", inputs: ["max"] }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
671
+ }
672
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AiCredentialTicketIssueComponent, decorators: [{
673
+ type: Component,
674
+ args: [{ selector: 'mm-ai-credential-ticket-issue', imports: [CommonModule, FormsModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<section class=\"mm-ai-ticket\">\n <header class=\"mm-ai-ticket__header\">\n <div class=\"mm-ai-ticket__title\">Issue credential ticket</div>\n <div class=\"mm-ai-ticket__subtitle\">\n Mint a one-time code an operator redeems on their own machine.\n </div>\n </header>\n\n @if (!issued()) {\n <form\n class=\"mm-ai-ticket__form\"\n (ngSubmit)=\"onIssue()\"\n #issueForm=\"ngForm\"\n >\n <label class=\"mm-ai-ticket__field\">\n <span class=\"mm-ai-ticket__label\">Scope</span>\n <select\n class=\"mm-ai-ticket__select\"\n [ngModel]=\"scope()\"\n (ngModelChange)=\"scope.set($event)\"\n name=\"scope\"\n [disabled]=\"issuing()\"\n >\n @for (s of scopes; track s.value) {\n <option [value]=\"s.value\">{{ s.label }}</option>\n }\n </select>\n </label>\n <p class=\"mm-ai-ticket__hint\">\n @for (s of scopes; track s.value) {\n @if (s.value === scope()) {\n {{ s.hint }}\n }\n }\n </p>\n\n <label class=\"mm-ai-ticket__field\">\n <span class=\"mm-ai-ticket__label\">TTL (minutes)</span>\n <input\n class=\"mm-ai-ticket__input\"\n type=\"number\"\n min=\"1\"\n max=\"60\"\n step=\"1\"\n [ngModel]=\"ttlMinutes()\"\n (ngModelChange)=\"ttlMinutes.set(+$event)\"\n name=\"ttlMinutes\"\n [disabled]=\"issuing()\"\n />\n </label>\n <p class=\"mm-ai-ticket__hint\">\n Default 5 minutes. Server clamps to 60 \u2014 long TTLs leak.\n </p>\n\n <div class=\"mm-ai-ticket__actions\">\n <button\n class=\"mm-ai-ticket__button mm-ai-ticket__button--primary\"\n type=\"submit\"\n [disabled]=\"issuing()\"\n >\n @if (issuing()) {\n Issuing\u2026\n } @else {\n Issue ticket\n }\n </button>\n </div>\n\n @if (error()) {\n <p class=\"mm-ai-ticket__error\">{{ error() }}</p>\n }\n </form>\n } @else {\n <div class=\"mm-ai-ticket__result\">\n <p class=\"mm-ai-ticket__result-label\">\n One-time code (copy now \u2014 not shown again):\n </p>\n <div class=\"mm-ai-ticket__code-row\">\n <code class=\"mm-ai-ticket__code\">{{ issued()!.code }}</code>\n <button\n class=\"mm-ai-ticket__button mm-ai-ticket__button--secondary\"\n type=\"button\"\n (click)=\"onCopy()\"\n >\n @if (copied()) {\n Copied\n } @else {\n Copy\n }\n </button>\n </div>\n <dl class=\"mm-ai-ticket__meta\">\n <div>\n <dt>Scope</dt>\n <dd>{{ issued()!.scope }}</dd>\n </div>\n <div>\n <dt>Expires at</dt>\n <dd>{{ issued()!.expiresAt }}</dd>\n </div>\n <div>\n <dt>Ticket rtId</dt>\n <dd><code>{{ issued()!.rtId }}</code></dd>\n </div>\n </dl>\n <div class=\"mm-ai-ticket__actions\">\n <button\n class=\"mm-ai-ticket__button\"\n type=\"button\"\n (click)=\"onClear()\"\n >\n Issue another\n </button>\n </div>\n </div>\n }\n</section>\n", styles: [":host{display:block;--mm-ai-ticket-bg: #16191d;--mm-ai-ticket-surface: #1f2227;--mm-ai-ticket-border: #2f343b;--mm-ai-ticket-text: #e6e6e6;--mm-ai-ticket-muted: #9aa0a6;--mm-ai-ticket-accent: #64ceb9;--mm-ai-ticket-error: #ec658f;--mm-ai-ticket-code-bg: #0f1115}.mm-ai-ticket{background:var(--mm-ai-ticket-bg);color:var(--mm-ai-ticket-text);border:1px solid var(--mm-ai-ticket-border);border-radius:.5rem;padding:1rem 1.25rem;display:flex;flex-direction:column;gap:.85rem;font-family:inherit}.mm-ai-ticket__header{display:flex;flex-direction:column;gap:.15rem}.mm-ai-ticket__title{font-size:1rem;font-weight:600}.mm-ai-ticket__subtitle{font-size:.8rem;color:var(--mm-ai-ticket-muted)}.mm-ai-ticket__form,.mm-ai-ticket__result{display:flex;flex-direction:column;gap:.6rem}.mm-ai-ticket__field{display:flex;flex-direction:column;gap:.25rem}.mm-ai-ticket__label{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:var(--mm-ai-ticket-muted)}.mm-ai-ticket__select,.mm-ai-ticket__input{background:var(--mm-ai-ticket-surface);color:var(--mm-ai-ticket-text);border:1px solid var(--mm-ai-ticket-border);border-radius:.3rem;padding:.4rem .55rem;font-family:inherit;font-size:.9rem}.mm-ai-ticket__select:focus,.mm-ai-ticket__input:focus{outline:2px solid var(--mm-ai-ticket-accent);outline-offset:1px}.mm-ai-ticket__select:disabled,.mm-ai-ticket__input:disabled{opacity:.6;cursor:not-allowed}.mm-ai-ticket__hint{margin:0;font-size:.75rem;color:var(--mm-ai-ticket-muted)}.mm-ai-ticket__actions{display:flex;gap:.5rem;margin-top:.25rem}.mm-ai-ticket__button{background:var(--mm-ai-ticket-surface);color:var(--mm-ai-ticket-text);border:1px solid var(--mm-ai-ticket-border);border-radius:.3rem;padding:.4rem .85rem;font-family:inherit;font-size:.85rem;cursor:pointer}.mm-ai-ticket__button:hover:not(:disabled){border-color:var(--mm-ai-ticket-accent)}.mm-ai-ticket__button:disabled{opacity:.5;cursor:not-allowed}.mm-ai-ticket__button--primary{background:var(--mm-ai-ticket-accent);color:#0a0c0f;border-color:var(--mm-ai-ticket-accent)}.mm-ai-ticket__error{margin:0;font-size:.8rem;color:var(--mm-ai-ticket-error)}.mm-ai-ticket__result-label{margin:0;font-size:.85rem;color:var(--mm-ai-ticket-muted)}.mm-ai-ticket__code-row{display:flex;align-items:center;gap:.5rem}.mm-ai-ticket__code{flex:1;background:var(--mm-ai-ticket-code-bg);color:var(--mm-ai-ticket-accent);padding:.5rem .75rem;border-radius:.3rem;font-family:Roboto Mono,ui-monospace,monospace;font-size:1.05rem;letter-spacing:.1em;-webkit-user-select:all;user-select:all}.mm-ai-ticket__meta{display:grid;grid-template-columns:max-content 1fr;gap:.2rem .75rem;margin:0;font-size:.8rem}.mm-ai-ticket__meta>div{display:contents}.mm-ai-ticket__meta dt{color:var(--mm-ai-ticket-muted);text-transform:uppercase;letter-spacing:.05em;font-size:.7rem}.mm-ai-ticket__meta dd{margin:0;font-family:Roboto Mono,ui-monospace,monospace}\n"] }]
675
+ }] });
676
+
677
+ /*
678
+ * Public API Surface of @meshmakers/octo-ai-console
679
+ *
680
+ * Reusable Angular building blocks for the OctoMesh AI Adapter session console
681
+ * (ADR-21, issue #4121). Host applications import individual standalone
682
+ * components or call `provideOctoAiConsole({...})` to wire the services.
683
+ */
684
+
685
+ /**
686
+ * Generated bundle index. Do not edit.
687
+ */
688
+
689
+ export { AI_ADAPTER_OPTIONS, AI_SESSION_STREAM_CONNECTION_FACTORY, AiAdapterClientService, AiApprovalModalComponent, AiChatStreamComponent, AiCredentialTicketIssueComponent, AiJobStatusBadgeComponent, AiQuotaIndicatorComponent, AiSessionListComponent, AiSessionStreamService, AiToolCallComponent, provideOctoAiConsole };
690
+ //# sourceMappingURL=meshmakers-octo-ai-console.mjs.map