@aparte/angular 0.23.0 → 0.25.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.
@@ -1,8 +1,8 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, inject, Injectable, makeEnvironmentProviders, provideAppInitializer, DestroyRef, effect, ElementRef, signal, computed, output, numberAttribute, booleanAttribute, ViewChildren, ViewChild, Input, ChangeDetectionStrategy, CUSTOM_ELEMENTS_SCHEMA, Component, Renderer2, Directive, EventEmitter, HostListener, Output } from '@angular/core';
3
- import { AparteClient, aparteGlobalConfig, isAwaitingReply, uuid, AparteChatHost, APARTE_DEFAULT_UI_EVENTS, applyElementProps, AparteConversationManager } from '@aparte/core';
2
+ import { InjectionToken, inject, Injectable, makeEnvironmentProviders, provideAppInitializer, DestroyRef, TemplateRef, Directive, effect, untracked, afterNextRender, ElementRef, Injector, PLATFORM_ID, input, linkedSignal, booleanAttribute, signal, computed, numberAttribute, contentChild, output, ViewChildren, ViewChild, ChangeDetectionStrategy, CUSTOM_ELEMENTS_SCHEMA, Component, ViewContainerRef, Renderer2, Input, EventEmitter, HostListener, Output } from '@angular/core';
3
+ import { AparteClient, aparteGlobalConfig, isAwaitingReply, releaseDeferredHydration, uuid, AparteChatHost, defineMountedToolRenderer, defineMountedSegmentRenderer, overrideSegmentRenderer, APARTE_DEFAULT_UI_EVENTS, applyElementProps, AparteConversationManager } from '@aparte/core';
4
4
  export { aparteGlobalConfig } from '@aparte/core';
5
- import { DOCUMENT, NgTemplateOutlet } from '@angular/common';
5
+ import { DOCUMENT, isPlatformBrowser, NgTemplateOutlet } from '@angular/common';
6
6
 
7
7
  /**
8
8
  * Injection token for AparteClient configuration. Provided by `provideAparte()`;
@@ -210,7 +210,37 @@ function provideAparte(options = {}) {
210
210
  }
211
211
 
212
212
  /**
213
- * AparteChatComponent — Angular 19 Wrapper
213
+ * Marks the template `<aparte-chat>` draws each message with, in place of
214
+ * `<aparte-chat-bubble>`: write it as content of the chat, the message as `$implicit`.
215
+ *
216
+ * ```html
217
+ * <aparte-chat [messages]="messages">
218
+ * <ng-template aparteBubble let-message>
219
+ * <app-bubble [message]="message" />
220
+ * </ng-template>
221
+ * </aparte-chat>
222
+ * ```
223
+ *
224
+ * The same thing as passing a `TemplateRef` to `[bubbleTemplate]`, written where Angular
225
+ * developers write a per-item template. When both are given, `[bubbleTemplate]` wins.
226
+ * The built-in action bar (retry, edit, the version picker) belongs to the native bubble;
227
+ * a template of yours owns whatever it wires.
228
+ */
229
+ class AparteBubbleDirective {
230
+ template = inject(TemplateRef);
231
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: AparteBubbleDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
232
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.25", type: AparteBubbleDirective, isStandalone: true, selector: "ng-template[aparteBubble]", ngImport: i0 });
233
+ }
234
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: AparteBubbleDirective, decorators: [{
235
+ type: Directive,
236
+ args: [{
237
+ selector: 'ng-template[aparteBubble]',
238
+ standalone: true,
239
+ }]
240
+ }] });
241
+
242
+ /**
243
+ * AparteChatComponent — the Angular wrapper (19.2 to 21, zone or zoneless)
214
244
  *
215
245
  * Standalone component wrapping aparté Web Components with Angular Signals.
216
246
  * The streaming / branch-navigation / host-method orchestration (orphan-stream
@@ -232,42 +262,83 @@ class AparteChatComponent {
232
262
  // bubbles `bubbleRefs.changes` hid the divergence; under a custom
233
263
  // `[bubbleTemplate]` that query never fires and this was the only path.
234
264
  this.messages();
235
- if (this._syncRafId !== null)
265
+ // A server render has no frame to wait for and no host to sync.
266
+ if (!this._isBrowser || this._syncRafId !== null)
236
267
  return;
237
268
  this._syncRafId = requestAnimationFrame(() => {
238
269
  this._syncRafId = null;
239
270
  this._host?.syncBubbles();
240
271
  });
241
272
  });
273
+ // A parent that empties [messages] starts a new conversation: the host lets go of
274
+ // its render cache then — the same rule React, Vue and Svelte apply.
275
+ effect(() => {
276
+ const next = this.messagesInput();
277
+ // An echo of the chat's own emptied list is not a parent starting over.
278
+ if (next.length === 0 && !this._emitted.has(next))
279
+ untracked(() => this._host?.clearRenderCache());
280
+ });
281
+ // [conversationId] drives the host; an echo of the id the host already holds
282
+ // (e.g. the one it just created and reported) is a no-op.
283
+ effect(() => {
284
+ const next = this.conversationIdInput() ?? null;
285
+ untracked(() => {
286
+ if (next === this._conversationId)
287
+ return;
288
+ this._conversationId = next;
289
+ if (this._host)
290
+ void this._host.setConversationId(next);
291
+ });
292
+ });
293
+ // The host touches the DOM and the custom elements: browser only, never in a server
294
+ // render, where there is no element to drive. Built in ngAfterViewInit (below), so a
295
+ // parent's own ngAfterViewInit, which runs after it, can already call the API; this
296
+ // render hook is the fallback, and does nothing once the host exists.
297
+ afterNextRender(() => this._createHost());
298
+ }
299
+ ngAfterViewInit() {
300
+ if (this._isBrowser)
301
+ this._createHost();
242
302
  }
243
303
  elementRef = inject(ElementRef);
304
+ _injector = inject(Injector);
305
+ _isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
244
306
  /**
245
307
  * Template bridge to core's shared `isAwaitingReply` (an Angular template can't
246
308
  * call an imported function). One rule for the four wrappers and core itself,
247
309
  * so "is this bubble in flight" can't drift between them.
248
310
  */
249
311
  awaitingReply = isAwaitingReply;
250
- // ── Input Signals ────────────────────────────────────────────────────────
251
- /** Messages to display */
252
- set messagesInput(val) {
253
- this.messages.set(val);
254
- // When messages are cleared (new conversation), reset the host's render cache
255
- // so the next conversation starts clean — same as React/Vue/Svelte do. (The
256
- // viewport's own clearMessages() is @deprecated in core and only clears its
257
- // repo; the host's clearAll() path already owns the real teardown.)
258
- if (val.length === 0)
259
- this._host?.clearRenderCache();
260
- }
261
- messages = signal([]);
312
+ // ── Inputs ───────────────────────────────────────────────────────────────
313
+ //
314
+ // Signal inputs under the same public names as before (`[messages]`, `disabled`,
315
+ // `centerWhenEmpty`…), so no template changes. Where the chat also writes a value
316
+ // itself (the list, the typing line), the input feeds a `linkedSignal`: a parent write
317
+ // resets it, the chat's own writes move it, and neither emits — `messagesChange` is
318
+ // emitted by the host's report only, never by a `set` (which is why this is not a
319
+ // `model()`: a model emits on every write, and an optimistic append must stay silent).
320
+ /** Messages to display. Two-way: `[(messages)]`, with `(messagesChange)` reporting the chat's own changes. */
321
+ messagesInput = input([], { alias: 'messages' });
322
+ /** Every list the host reported — read only by the render-cache rule below. */
323
+ _emitted = new WeakSet();
324
+ /**
325
+ * Under `[(messages)]` each report comes back through the input — possibly an EARLIER one,
326
+ * after the chat appended without reporting (an optimistic append, or core's append right
327
+ * after a flush) — and an echo is not the parent's word: it keeps the local list. The host
328
+ * tells them apart (`takeEcho`), the rule React, Vue and Svelte apply, so a list the parent
329
+ * restores later (a reopened conversation, an undo) is applied. Before the host exists
330
+ * nothing was reported, so the input is taken as it is.
331
+ */
332
+ messages = linkedSignal({
333
+ source: this.messagesInput,
334
+ computation: (next, previous) => (previous && this._host?.takeEcho(next) ? previous.value : next),
335
+ });
262
336
  /** Input placeholder text */
263
- set placeholderInput(val) { this.placeholder.set(val); }
264
- placeholder = signal('Type a message...');
337
+ placeholder = input('Type a message...');
265
338
  /** Whether the input is disabled */
266
- set disabledInput(val) { this.disabled.set(val); }
267
- disabled = signal(false);
339
+ disabled = input(false, { transform: booleanAttribute });
268
340
  /** When false, Shift+Enter submits and a bare Enter inserts a newline. */
269
- set submitOnEnterInput(val) { this.submitOnEnter.set(val); }
270
- submitOnEnter = signal(true);
341
+ submitOnEnter = input(true, { transform: booleanAttribute });
271
342
  /**
272
343
  * Opt in to the "centered composer when empty" layout: the composer sits
273
344
  * vertically centered with the `[slot='empty-state']` content above it while
@@ -275,23 +346,29 @@ class AparteChatComponent {
275
346
  * Off by default — additive (adds the `--auto-center` modifier + a
276
347
  * `data-aparte-empty` attribute the shipped `aparte.css` recipe keys off).
277
348
  */
278
- set centerWhenEmptyInput(val) { this.centerWhenEmpty.set(val); }
279
- centerWhenEmpty = signal(false);
349
+ centerWhenEmpty = input(false, { transform: booleanAttribute });
280
350
  /**
281
351
  * The conversation is on its way: the viewport draws two skeleton turns, the empty
282
352
  * state stays off and the composer is disabled until it lands. The conversation
283
353
  * controller sets this itself while it fetches through your adapter's `loadFull()`;
284
354
  * the input is for a wait of your own. Off by default.
285
355
  */
286
- set loadingInput(val) { this.loading.set(val); }
287
- loading = signal(false);
356
+ loading = input(false, { transform: booleanAttribute });
288
357
  /** The controller's wait (a conversation fetched through the adapter). */
289
358
  hostLoading = signal(false);
290
359
  waiting = computed(() => this.loading() || this.hostLoading());
291
360
  /** Bumped on every config change, so the two locale strings below follow a language switch under OnPush. */
292
361
  _localeTick = signal(0);
362
+ /**
363
+ * True until the host is built — on the server always, and in the browser only while Angular
364
+ * hydrates the server's HTML: the host element is then the one the server sent, still carrying
365
+ * the attribute. A client-only render holds nothing, so a component projected into the empty
366
+ * state (built in an @if, after the host binding) talks to live elements in its own
367
+ * ngAfterViewInit, which runs before this component's.
368
+ */
369
+ hydrating = signal(!this._isBrowser || this.elementRef.nativeElement.hasAttribute('defer-hydration'));
293
370
  _unsubscribeLocale;
294
- get loadingText() { this._localeTick(); return (this.config ?? aparteGlobalConfig).t('loadingConversation'); }
371
+ get loadingText() { this._localeTick(); return (this.config() ?? aparteGlobalConfig).t('loadingConversation'); }
295
372
  /**
296
373
  * Class(es) merged onto the INNER `.aparte-chat-container`, not onto this
297
374
  * component's own `<aparte-chat>` host.
@@ -304,11 +381,12 @@ class AparteChatComponent {
304
381
  * selector on Angular alone. This input is that parity. Additive: the recipe's
305
382
  * own classes stay.
306
383
  */
307
- set containerClassInput(val) { this.containerClass.set(val ?? ''); }
308
- containerClass = signal('');
384
+ containerClass = input('', { alias: 'containerClass', transform: (val) => val ?? '' });
309
385
  /** Inline style for the same div — a string or a `{ prop: value }` map. */
310
- set containerStyleInput(val) { this.containerStyle.set(val ?? null); }
311
- containerStyle = signal(null);
386
+ containerStyle = input(null, {
387
+ alias: 'containerStyle',
388
+ transform: (val) => val ?? null,
389
+ });
312
390
  /**
313
391
  * Overlay the composer on the transcript (the ChatGPT anatomy): the scroll
314
392
  * surface spans the whole column, the scrollbar runs edge to edge, and the
@@ -317,8 +395,7 @@ class AparteChatComponent {
317
395
  * core's recipe and the viewport's measurement both key on the attribute
318
396
  * there. Read when the viewport mounts.
319
397
  */
320
- set overlayComposerInput(val) { this.overlayComposer.set(val); }
321
- overlayComposer = signal(false);
398
+ overlayComposer = input(false, { transform: booleanAttribute });
322
399
  /**
323
400
  * Add the file picker + chips strip to the default composer shell. **Off by
324
401
  * default**, because the capability needs a host that consumes the files: an
@@ -328,8 +405,7 @@ class AparteChatComponent {
328
405
  * project a `[slot='composer']` (drop `<aparte-composer-add-attachment>` and
329
406
  * `<aparte-composer-attachments>` in it yourself).
330
407
  */
331
- set attachmentsInput(val) { this.attachments.set(val); }
332
- attachments = signal(false);
408
+ attachments = input(false, { transform: booleanAttribute });
333
409
  /**
334
410
  * Render the `<aparte-elicitation>` presenter inside the host — on by default, as
335
411
  * it is in `<aparte-chat>`: the built-in approval gate and `requestUserInput()`
@@ -337,51 +413,45 @@ class AparteChatComponent {
337
413
  * when you mount a presenter of your own (`setElicitationPresenter`) or place the
338
414
  * element yourself.
339
415
  */
340
- set elicitationInput(val) { this.elicitation.set(val); }
341
- elicitation = signal(true);
342
- /** Whether the assistant is currently typing/streaming */
343
- set isTypingInput(val) { this.isTyping.set(val); }
344
- isTyping = signal(false);
416
+ elicitation = input(true, { transform: booleanAttribute });
417
+ /** Whether the assistant is currently typing/streaming. The host turns it off on the first token. */
418
+ isTypingInput = input(false, { alias: 'isTyping', transform: booleanAttribute });
419
+ isTyping = linkedSignal(() => this.isTypingInput());
345
420
  /** Text to show in the typing status */
346
- set typingTextInput(val) { this.typingText.set(val); }
347
- typingText = signal(undefined);
421
+ typingText = input(undefined);
348
422
  /** The status line's word: the input, else the locale's, like the rest of the chat. */
349
- get typingLabel() { this._localeTick(); return this.typingText() ?? (this.config ?? aparteGlobalConfig).t('typing'); }
350
- /** Duration in ms to freeze spacer recalculation after a conversation swap. */
351
- layoutTransitionMs = 0;
423
+ get typingLabel() { this._localeTick(); return this.typingText() ?? (this.config() ?? aparteGlobalConfig).t('typing'); }
424
+ /** Duration in ms to freeze spacer recalculation after a conversation swap. Read when the host is created. */
425
+ layoutTransitionMs = input(0, { transform: numberAttribute });
352
426
  /**
353
427
  * Active conversation id. When provided, the host attaches an
354
428
  * `AparteConversationController` that loads/persists messages via the
355
429
  * `AparteConversationManager` registered in `aparteGlobalConfig`. Setting a different id
356
430
  * mid-stream aborts the previous request; `null` deselects.
357
431
  */
358
- set conversationIdInput(val) {
359
- const next = val ?? null;
360
- if (next === this._conversationId)
361
- return;
362
- this._conversationId = next;
363
- if (this._host)
364
- void this._host.setConversationId(next);
365
- }
432
+ conversationIdInput = input(undefined, { alias: 'conversationId' });
366
433
  _conversationId = null;
367
434
  /**
368
435
  * Instance {@link AparteConfig} for this chat. When set, aparté components
369
436
  * inside resolve THIS config instead of `aparteGlobalConfig`, so
370
437
  * several independently-configured chats can coexist on one page. Omit for the
371
- * global config. Read once in `ngAfterViewInit` when the host is created.
438
+ * global config. Read once, when the host is created after the first render.
372
439
  */
373
- config;
440
+ config = input(undefined);
374
441
  /**
375
442
  * Render your OWN element per message in place of `<aparte-chat-bubble>`.
376
- * Opt-in — provide a `<ng-template let-message>` and pass its ref. Driven by
377
- * the reactive `messages()` signal, so it updates live during streaming.
378
- * The built-in action bar (retry/edit/branch) belongs to the native bubble;
379
- * a custom bubble owns whatever it wires.
443
+ * Opt-in — provide a `<ng-template let-message>` and pass its ref, or write the
444
+ * template as content with {@link AparteBubbleDirective} (`<ng-template aparteBubble
445
+ * let-message>`); this input wins when both are given. Driven by the reactive
446
+ * `messages()` signal, so it updates live during streaming. The built-in action bar
447
+ * (retry/edit/branch) belongs to the native bubble; a custom bubble owns whatever it wires.
380
448
  * @example
381
449
  * <aparte-chat [bubbleTemplate]="tpl" />
382
450
  * <ng-template #tpl let-message>{{ message.content }}</ng-template>
383
451
  */
384
- bubbleTemplate;
452
+ bubbleTemplate = input(undefined);
453
+ /** The `<ng-template aparteBubble>` written as content, if any. */
454
+ bubbleDirective = contentChild(AparteBubbleDirective);
385
455
  // ── Outputs ────────────────────────────────────────────────────────────
386
456
  /**
387
457
  * User submitted a message from the composer. It is **appended to the thread
@@ -415,14 +485,21 @@ class AparteChatComponent {
415
485
  isStreaming = computed(() => this._streamingId() !== null);
416
486
  /** rAF id used to coalesce rapid signal updates during streaming */
417
487
  _syncRafId = null;
418
- /** The framework-agnostic chat-host orchestrator (created in ngAfterViewInit). */
488
+ /** The framework-agnostic chat-host orchestrator (created after the first render, in the browser). */
419
489
  _host;
420
490
  _unbindHost;
421
491
  /** QueryList.changes subscription — released in ngOnDestroy so SPA route
422
492
  * churn doesn't leak a live subscription per mount. */
423
493
  _bubbleRefsSub;
424
- ngAfterViewInit() {
425
- this._unsubscribeLocale = (this.config ?? aparteGlobalConfig).subscribe(() => this._localeTick.update((n) => n + 1));
494
+ /** Set by ngOnDestroy: an afterNextRender that lands after a destroy must not build a host. */
495
+ _destroyed = false;
496
+ _createHost() {
497
+ if (this._destroyed || this._host)
498
+ return;
499
+ // The elements a server render left waiting are upgraded first: the host talks to them.
500
+ releaseDeferredHydration(this.elementRef.nativeElement);
501
+ this.hydrating.set(false);
502
+ this._unsubscribeLocale = (this.config() ?? aparteGlobalConfig).subscribe(() => this._localeTick.update((n) => n + 1));
426
503
  const host = this.elementRef.nativeElement;
427
504
  // Ensure a stable id so aparte-composer can reference the host via `target`,
428
505
  // letting AparteClient find it without DOM traversal across re-renders.
@@ -437,25 +514,29 @@ class AparteChatComponent {
437
514
  viewport: this.viewportRef?.nativeElement ?? null,
438
515
  getMessages: () => this.messages(),
439
516
  setMessages: (msgs) => this.messages.set(msgs),
440
- onMessagesChange: (msgs) => this.messagesChange.emit(msgs),
517
+ onMessagesChange: (msgs) => {
518
+ this._emitted.add(msgs);
519
+ this.messagesChange.emit(msgs);
520
+ },
441
521
  onMessageAppended: (msg) => this.messageAppended.emit(msg),
442
522
  onTypingChange: (typing) => { this.isTyping.set(typing); this.typingChange.emit(typing); },
443
523
  onStreamingChange: (id) => this._streamingId.set(id),
444
524
  onLoadingChange: (on) => this.hostLoading.set(on),
445
- // Sibling-info / deferred work runs after Angular has re-rendered.
446
- afterRender: (cb) => { setTimeout(cb, 0); },
525
+ // Sibling-info / deferred work runs after Angular has re-rendered — Angular's
526
+ // own hook for exactly that, which also holds under zoneless.
527
+ afterRender: (cb) => { afterNextRender(cb, { injector: this._injector }); },
447
528
  resetComposer: () => {
448
529
  this.inputRef?.nativeElement?.reset?.();
449
530
  },
450
531
  };
451
532
  this._host = new AparteChatHost(binding, {
452
- layoutTransitionMs: this.layoutTransitionMs,
533
+ layoutTransitionMs: this.layoutTransitionMs(),
453
534
  conversationId: this._conversationId,
454
535
  onConversationCreated: (id) => {
455
536
  this._conversationId = id;
456
537
  this.conversationCreated.emit(id);
457
538
  },
458
- config: this.config,
539
+ config: this.config(),
459
540
  });
460
541
  this._unbindHost = this._host.bind();
461
542
  // Custom bubble actions bubble to the host as `aparte-action`; surface them as
@@ -470,6 +551,7 @@ class AparteChatComponent {
470
551
  this.action.emit(event.detail);
471
552
  };
472
553
  ngOnDestroy() {
554
+ this._destroyed = true;
473
555
  this._unsubscribeLocale?.();
474
556
  this._unsubscribeLocale = undefined;
475
557
  if (this._syncRafId !== null) {
@@ -641,7 +723,7 @@ class AparteChatComponent {
641
723
  this.messageSent.emit(detail);
642
724
  }
643
725
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: AparteChatComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
644
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.25", type: AparteChatComponent, isStandalone: true, selector: "aparte-chat", inputs: { messagesInput: ["messages", "messagesInput"], placeholderInput: ["placeholder", "placeholderInput"], disabledInput: ["disabled", "disabledInput", booleanAttribute], submitOnEnterInput: ["submitOnEnter", "submitOnEnterInput", booleanAttribute], centerWhenEmptyInput: ["centerWhenEmpty", "centerWhenEmptyInput", booleanAttribute], loadingInput: ["loading", "loadingInput", booleanAttribute], containerClassInput: ["containerClass", "containerClassInput"], containerStyleInput: ["containerStyle", "containerStyleInput"], overlayComposerInput: ["overlayComposer", "overlayComposerInput", booleanAttribute], attachmentsInput: ["attachments", "attachmentsInput", booleanAttribute], elicitationInput: ["elicitation", "elicitationInput", booleanAttribute], isTypingInput: ["isTyping", "isTypingInput", booleanAttribute], typingTextInput: ["typingText", "typingTextInput"], layoutTransitionMs: ["layoutTransitionMs", "layoutTransitionMs", numberAttribute], conversationIdInput: ["conversationId", "conversationIdInput"], config: "config", bubbleTemplate: "bubbleTemplate" }, outputs: { messageSent: "messageSent", action: "action", messagesChange: "messagesChange", messageAppended: "messageAppended", typingChange: "typingChange", conversationCreated: "conversationCreated" }, host: { attributes: { "framework-managed": "" } }, viewQueries: [{ propertyName: "viewportRef", first: true, predicate: ["viewport"], descendants: true }, { propertyName: "inputRef", first: true, predicate: ["input"], descendants: true }, { propertyName: "bubbleRefs", predicate: ["bubble"], descendants: true }], ngImport: i0, template: `
726
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.25", type: AparteChatComponent, isStandalone: true, selector: "aparte-chat", inputs: { messagesInput: { classPropertyName: "messagesInput", publicName: "messages", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, submitOnEnter: { classPropertyName: "submitOnEnter", publicName: "submitOnEnter", isSignal: true, isRequired: false, transformFunction: null }, centerWhenEmpty: { classPropertyName: "centerWhenEmpty", publicName: "centerWhenEmpty", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, containerClass: { classPropertyName: "containerClass", publicName: "containerClass", isSignal: true, isRequired: false, transformFunction: null }, containerStyle: { classPropertyName: "containerStyle", publicName: "containerStyle", isSignal: true, isRequired: false, transformFunction: null }, overlayComposer: { classPropertyName: "overlayComposer", publicName: "overlayComposer", isSignal: true, isRequired: false, transformFunction: null }, attachments: { classPropertyName: "attachments", publicName: "attachments", isSignal: true, isRequired: false, transformFunction: null }, elicitation: { classPropertyName: "elicitation", publicName: "elicitation", isSignal: true, isRequired: false, transformFunction: null }, isTypingInput: { classPropertyName: "isTypingInput", publicName: "isTyping", isSignal: true, isRequired: false, transformFunction: null }, typingText: { classPropertyName: "typingText", publicName: "typingText", isSignal: true, isRequired: false, transformFunction: null }, layoutTransitionMs: { classPropertyName: "layoutTransitionMs", publicName: "layoutTransitionMs", isSignal: true, isRequired: false, transformFunction: null }, conversationIdInput: { classPropertyName: "conversationIdInput", publicName: "conversationId", isSignal: true, isRequired: false, transformFunction: null }, config: { classPropertyName: "config", publicName: "config", isSignal: true, isRequired: false, transformFunction: null }, bubbleTemplate: { classPropertyName: "bubbleTemplate", publicName: "bubbleTemplate", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { messageSent: "messageSent", action: "action", messagesChange: "messagesChange", messageAppended: "messageAppended", typingChange: "typingChange", conversationCreated: "conversationCreated" }, host: { attributes: { "framework-managed": "" }, properties: { "attr.defer-hydration": "hydrating() ? '' : null" } }, queries: [{ propertyName: "bubbleDirective", first: true, predicate: AparteBubbleDirective, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "viewportRef", first: true, predicate: ["viewport"], descendants: true }, { propertyName: "inputRef", first: true, predicate: ["input"], descendants: true }, { propertyName: "bubbleRefs", predicate: ["bubble"], descendants: true }], ngImport: i0, template: `
645
727
  <div
646
728
  class="aparte-chat-container"
647
729
  [class]="containerClass()"
@@ -668,11 +750,12 @@ class AparteChatComponent {
668
750
  <ng-content select="[slot='empty-state']"></ng-content>
669
751
  }
670
752
  @for (message of messages(); track message.id) {
671
- @if (bubbleTemplate) {
672
- <!-- Render your OWN element per message; driven by the reactive
673
- messages() signal so it streams live. -->
753
+ @if (bubbleTemplate() || bubbleDirective()) {
754
+ <!-- Render your OWN element per message ([bubbleTemplate], else an
755
+ <ng-template aparteBubble> written as content); driven by the
756
+ reactive messages() signal so it streams live. -->
674
757
  <ng-container
675
- [ngTemplateOutlet]="bubbleTemplate"
758
+ [ngTemplateOutlet]="bubbleTemplate() ?? bubbleDirective()?.template ?? null"
676
759
  [ngTemplateOutletContext]="{ $implicit: message }"
677
760
  ></ng-container>
678
761
  } @else {
@@ -743,7 +826,7 @@ class AparteChatComponent {
743
826
  }
744
827
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: AparteChatComponent, decorators: [{
745
828
  type: Component,
746
- args: [{ selector: 'aparte-chat', standalone: true, imports: [NgTemplateOutlet], schemas: [CUSTOM_ELEMENTS_SCHEMA], changeDetection: ChangeDetectionStrategy.OnPush, host: { 'framework-managed': '' }, template: `
829
+ args: [{ selector: 'aparte-chat', standalone: true, imports: [NgTemplateOutlet], schemas: [CUSTOM_ELEMENTS_SCHEMA], changeDetection: ChangeDetectionStrategy.OnPush, host: { 'framework-managed': '', '[attr.defer-hydration]': "hydrating() ? '' : null" }, template: `
747
830
  <div
748
831
  class="aparte-chat-container"
749
832
  [class]="containerClass()"
@@ -770,11 +853,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImpo
770
853
  <ng-content select="[slot='empty-state']"></ng-content>
771
854
  }
772
855
  @for (message of messages(); track message.id) {
773
- @if (bubbleTemplate) {
774
- <!-- Render your OWN element per message; driven by the reactive
775
- messages() signal so it streams live. -->
856
+ @if (bubbleTemplate() || bubbleDirective()) {
857
+ <!-- Render your OWN element per message ([bubbleTemplate], else an
858
+ <ng-template aparteBubble> written as content); driven by the
859
+ reactive messages() signal so it streams live. -->
776
860
  <ng-container
777
- [ngTemplateOutlet]="bubbleTemplate"
861
+ [ngTemplateOutlet]="bubbleTemplate() ?? bubbleDirective()?.template ?? null"
778
862
  [ngTemplateOutletContext]="{ $implicit: message }"
779
863
  ></ng-container>
780
864
  } @else {
@@ -842,56 +926,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImpo
842
926
  </aparte-composer>
843
927
  </div>
844
928
  `, styles: [":host{display:block;width:100%;height:100%}.aparte-chat-container{display:flex;flex-direction:column;height:100%;width:100%;overflow:hidden}aparte-chat-viewport{flex:1;min-height:0}aparte-composer{flex-shrink:0}\n"] }]
845
- }], ctorParameters: () => [], propDecorators: { messagesInput: [{
846
- type: Input,
847
- args: ['messages']
848
- }], placeholderInput: [{
849
- type: Input,
850
- args: ['placeholder']
851
- }], disabledInput: [{
852
- type: Input,
853
- args: [{ alias: 'disabled', transform: booleanAttribute }]
854
- }], submitOnEnterInput: [{
855
- type: Input,
856
- args: [{ alias: 'submitOnEnter', transform: booleanAttribute }]
857
- }], centerWhenEmptyInput: [{
858
- type: Input,
859
- args: [{ alias: 'centerWhenEmpty', transform: booleanAttribute }]
860
- }], loadingInput: [{
861
- type: Input,
862
- args: [{ alias: 'loading', transform: booleanAttribute }]
863
- }], containerClassInput: [{
864
- type: Input,
865
- args: ['containerClass']
866
- }], containerStyleInput: [{
867
- type: Input,
868
- args: ['containerStyle']
869
- }], overlayComposerInput: [{
870
- type: Input,
871
- args: [{ alias: 'overlayComposer', transform: booleanAttribute }]
872
- }], attachmentsInput: [{
873
- type: Input,
874
- args: [{ alias: 'attachments', transform: booleanAttribute }]
875
- }], elicitationInput: [{
876
- type: Input,
877
- args: [{ alias: 'elicitation', transform: booleanAttribute }]
878
- }], isTypingInput: [{
879
- type: Input,
880
- args: [{ alias: 'isTyping', transform: booleanAttribute }]
881
- }], typingTextInput: [{
882
- type: Input,
883
- args: ['typingText']
884
- }], layoutTransitionMs: [{
885
- type: Input,
886
- args: [{ alias: 'layoutTransitionMs', transform: numberAttribute }]
887
- }], conversationIdInput: [{
888
- type: Input,
889
- args: ['conversationId']
890
- }], config: [{
891
- type: Input
892
- }], bubbleTemplate: [{
893
- type: Input
894
- }], viewportRef: [{
929
+ }], ctorParameters: () => [], propDecorators: { viewportRef: [{
895
930
  type: ViewChild,
896
931
  args: ['viewport']
897
932
  }], inputRef: [{
@@ -902,6 +937,126 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImpo
902
937
  args: ['bubble']
903
938
  }] } });
904
939
 
940
+ /**
941
+ * Create the template's view where it is declared — so its injector, and what your app
942
+ * provides there, is the declaration's — then move its nodes into the element core gave.
943
+ * The same move the CDK's portal outlet makes. `update` hands the view the next state and
944
+ * checks it at once, which is what a zoneless app needs.
945
+ */
946
+ function mountTemplate(container, template, host, view) {
947
+ const ref = container.createEmbeddedView(template, { $implicit: view });
948
+ ref.detectChanges();
949
+ for (const node of ref.rootNodes)
950
+ host.appendChild(node);
951
+ return {
952
+ update: (next) => {
953
+ ref.context.$implicit = next;
954
+ ref.detectChanges();
955
+ },
956
+ destroy: () => { ref.destroy(); },
957
+ };
958
+ }
959
+ /**
960
+ * Draws a tool call with your own template, for as long as the template is in the page.
961
+ *
962
+ * ```html
963
+ * <ng-template aparteToolUI="get_weather" let-tool>
964
+ * <app-weather-card [tool]="tool" />
965
+ * </ng-template>
966
+ * ```
967
+ *
968
+ * `let-tool` is an `AparteToolCallView`; its `input` is the model's arguments, `unknown` until
969
+ * the card narrows it — give the card the view (`input.required<AparteToolCallView>()`) and type
970
+ * its arguments there.
971
+ *
972
+ * Every `get_weather` call is drawn with the template, the call's view as `$implicit`, and
973
+ * given back to core's row when the template leaves. The view is created where you declare
974
+ * the template, so the components in it inject what your app provides there. Underneath it is
975
+ * core's `overrideToolRenderer` + `defineMountedToolRenderer`; nothing is declared in a server
976
+ * render, and the cards are drawn in the browser.
977
+ */
978
+ class AparteToolUIDirective {
979
+ /** The tool this draws: a name, a `RegExp`, or `'*'` for every tool. */
980
+ name = input.required({ alias: 'aparteToolUI' });
981
+ /** The config to declare it on. Defaults to the global one. */
982
+ config = input(undefined, { alias: 'aparteToolUIConfig' });
983
+ template = inject(TemplateRef);
984
+ container = inject(ViewContainerRef);
985
+ constructor() {
986
+ const browser = isPlatformBrowser(inject(PLATFORM_ID));
987
+ effect((onCleanup) => {
988
+ const name = this.name();
989
+ const target = this.config() ?? aparteGlobalConfig;
990
+ if (!browser)
991
+ return;
992
+ const restore = untracked(() => target.overrideToolRenderer(name, defineMountedToolRenderer((host, view) => mountTemplate(this.container, this.template, host, view))));
993
+ onCleanup(restore);
994
+ });
995
+ }
996
+ /** Types `let-tool` in a strict template. */
997
+ static ngTemplateContextGuard(_dir, _ctx) {
998
+ return true;
999
+ }
1000
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: AparteToolUIDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1001
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "19.2.25", type: AparteToolUIDirective, isStandalone: true, selector: "ng-template[aparteToolUI]", inputs: { name: { classPropertyName: "name", publicName: "aparteToolUI", isSignal: true, isRequired: true, transformFunction: null }, config: { classPropertyName: "config", publicName: "aparteToolUIConfig", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
1002
+ }
1003
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: AparteToolUIDirective, decorators: [{
1004
+ type: Directive,
1005
+ args: [{
1006
+ selector: 'ng-template[aparteToolUI]',
1007
+ standalone: true,
1008
+ }]
1009
+ }], ctorParameters: () => [] });
1010
+ /**
1011
+ * Draws a segment type with your own template — one your stream emits, or a built-in such as
1012
+ * `code` — the segment as `$implicit`. Taking the last declaration off a built-in type gives it
1013
+ * back to core's renderer.
1014
+ *
1015
+ * ```html
1016
+ * <ng-template aparteSegmentUI="chart" let-segment>
1017
+ * <app-chart [segment]="segment" />
1018
+ * </ng-template>
1019
+ * ```
1020
+ *
1021
+ * `let-segment` is the base segment with its own fields: the component it is handed to types it.
1022
+ */
1023
+ class AparteSegmentUIDirective {
1024
+ /** The segment `type` this draws. */
1025
+ type = input.required({ alias: 'aparteSegmentUI' });
1026
+ /** The config to declare it on. Defaults to the global one. */
1027
+ config = input(undefined, { alias: 'aparteSegmentUIConfig' });
1028
+ template = inject(TemplateRef);
1029
+ container = inject(ViewContainerRef);
1030
+ constructor() {
1031
+ const browser = isPlatformBrowser(inject(PLATFORM_ID));
1032
+ effect((onCleanup) => {
1033
+ const type = this.type();
1034
+ const target = this.config();
1035
+ if (!browser)
1036
+ return;
1037
+ const restore = untracked(() => {
1038
+ const renderer = defineMountedSegmentRenderer(type, (host, segment) => mountTemplate(this.container, this.template, host, segment));
1039
+ return target ? overrideSegmentRenderer(renderer, target) : overrideSegmentRenderer(renderer);
1040
+ });
1041
+ onCleanup(restore);
1042
+ });
1043
+ }
1044
+ // The segment's shape is the app's: `let-segment` is typed as the base, narrow it in a pipe
1045
+ // or a component input.
1046
+ static ngTemplateContextGuard(_dir, _ctx) {
1047
+ return true;
1048
+ }
1049
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: AparteSegmentUIDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1050
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "19.2.25", type: AparteSegmentUIDirective, isStandalone: true, selector: "ng-template[aparteSegmentUI]", inputs: { type: { classPropertyName: "type", publicName: "aparteSegmentUI", isSignal: true, isRequired: true, transformFunction: null }, config: { classPropertyName: "config", publicName: "aparteSegmentUIConfig", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
1051
+ }
1052
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: AparteSegmentUIDirective, decorators: [{
1053
+ type: Directive,
1054
+ args: [{
1055
+ selector: 'ng-template[aparteSegmentUI]',
1056
+ standalone: true,
1057
+ }]
1058
+ }], ctorParameters: () => [] });
1059
+
905
1060
  /**
906
1061
  * AparteUiComponent - Universal UI Proxy
907
1062
  *
@@ -964,6 +1119,13 @@ class AparteUiComponent {
964
1119
  // ─────────────────────────────────────────────────────────────
965
1120
  /** The dynamically created Web Component element */
966
1121
  element = null;
1122
+ /**
1123
+ * On a server the element is not built: it would be serialized into the HTML without its
1124
+ * props or listeners, hydration would keep it, and the browser would append its own beside
1125
+ * it — two counters, two pickers. React, Vue and Svelte build theirs in an effect or onMount,
1126
+ * which a server never runs.
1127
+ */
1128
+ isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
967
1129
  /** Cleanup functions for event listeners */
968
1130
  eventCleanups = [];
969
1131
  /** Joined key of the bound event set, so a fresh inline `[events]` array
@@ -1000,7 +1162,7 @@ class AparteUiComponent {
1000
1162
  return (this.events ?? APARTE_DEFAULT_UI_EVENTS).join('|');
1001
1163
  }
1002
1164
  createElement() {
1003
- if (!this.name)
1165
+ if (!this.isBrowser || !this.name)
1004
1166
  return;
1005
1167
  // Create the custom element
1006
1168
  this.element = this.renderer.createElement(this.name);
@@ -1584,6 +1746,8 @@ class AparteConversationListDirective extends AparteElementBase {
1584
1746
  set loading(v) { this.write('loading', v); }
1585
1747
  /** Let the list finish the gesture: rename, pin, unpin, archive, unarchive and delete are performed on the conversation manager registered with `setConversationManager()`, instead of leaving you six listeners to write. The event still fires first, and `preventDefault()` on it takes the gesture back. Off by default, and selecting stays yours either way. */
1586
1748
  set manage(v) { this.write('manage', v); }
1749
+ /** Set the list of conversations to display. Triggers a re-render. */
1750
+ set conversations(v) { this.write('conversations', v); }
1587
1751
  /** A row's title was activated; the host loads that conversation. */
1588
1752
  conversationSelect = new EventEmitter();
1589
1753
  /** A rename was committed with a new, non-empty title. Cancelable: `preventDefault()` takes the gesture back from `manage`. Angular: this Output emits the detail, so preventDefault() is not on it — bind the DOM event for that: (aparte-conversation-rename)="$event.preventDefault()". */
@@ -1606,7 +1770,7 @@ class AparteConversationListDirective extends AparteElementBase {
1606
1770
  onConversationUnarchive(e) { this.conversationUnarchive.emit(e.detail); }
1607
1771
  onConversationDelete(e) { this.conversationDelete.emit(e.detail); }
1608
1772
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: AparteConversationListDirective, deps: null, target: i0.ɵɵFactoryTarget.Directive });
1609
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "16.1.0", version: "19.2.25", type: AparteConversationListDirective, isStandalone: true, selector: "aparte-conversation-list", inputs: { activeId: "activeId", flat: ["flat", "flat", booleanAttribute], loading: ["loading", "loading", booleanAttribute], manage: ["manage", "manage", booleanAttribute] }, outputs: { conversationSelect: "conversationSelect", conversationRename: "conversationRename", conversationPin: "conversationPin", conversationUnpin: "conversationUnpin", conversationArchive: "conversationArchive", conversationUnarchive: "conversationUnarchive", conversationDelete: "conversationDelete" }, host: { listeners: { "aparte-conversation-select": "onConversationSelect($event)", "aparte-conversation-rename": "onConversationRename($event)", "aparte-conversation-pin": "onConversationPin($event)", "aparte-conversation-unpin": "onConversationUnpin($event)", "aparte-conversation-archive": "onConversationArchive($event)", "aparte-conversation-unarchive": "onConversationUnarchive($event)", "aparte-conversation-delete": "onConversationDelete($event)" } }, usesInheritance: true, ngImport: i0 });
1773
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "16.1.0", version: "19.2.25", type: AparteConversationListDirective, isStandalone: true, selector: "aparte-conversation-list", inputs: { activeId: "activeId", flat: ["flat", "flat", booleanAttribute], loading: ["loading", "loading", booleanAttribute], manage: ["manage", "manage", booleanAttribute], conversations: "conversations" }, outputs: { conversationSelect: "conversationSelect", conversationRename: "conversationRename", conversationPin: "conversationPin", conversationUnpin: "conversationUnpin", conversationArchive: "conversationArchive", conversationUnarchive: "conversationUnarchive", conversationDelete: "conversationDelete" }, host: { listeners: { "aparte-conversation-select": "onConversationSelect($event)", "aparte-conversation-rename": "onConversationRename($event)", "aparte-conversation-pin": "onConversationPin($event)", "aparte-conversation-unpin": "onConversationUnpin($event)", "aparte-conversation-archive": "onConversationArchive($event)", "aparte-conversation-unarchive": "onConversationUnarchive($event)", "aparte-conversation-delete": "onConversationDelete($event)" } }, usesInheritance: true, ngImport: i0 });
1610
1774
  }
1611
1775
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: AparteConversationListDirective, decorators: [{
1612
1776
  type: Directive,
@@ -1622,6 +1786,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImpo
1622
1786
  }], manage: [{
1623
1787
  type: Input,
1624
1788
  args: [{ transform: booleanAttribute }]
1789
+ }], conversations: [{
1790
+ type: Input
1625
1791
  }], conversationSelect: [{
1626
1792
  type: Output
1627
1793
  }], conversationRename: [{
@@ -2167,5 +2333,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImpo
2167
2333
  * Generated bundle index. Do not edit.
2168
2334
  */
2169
2335
 
2170
- export { APARTE_CLIENT_OPTIONS, APARTE_CONFIG_TOKEN, APARTE_ELEMENT_DIRECTIVES, AparteAiService, AparteChatBubbleDirective, AparteChatComponent, AparteChatStatusDirective, AparteChatViewportDirective, AparteComposerActionDirective, AparteComposerAddAttachmentDirective, AparteComposerAttachmentsDirective, AparteComposerCancelDirective, AparteComposerDirective, AparteComposerInputDirective, AparteComposerSendDirective, AparteComposerToolbarDirective, AparteContextDirective, AparteConversationListDirective, AparteElicitationDirective, AparteIconDirective, AparteOptgroupDirective, AparteOptionDirective, AparteProgressSpinnerDirective, AparteScrollRailDirective, AparteSelectDirective, AparteSidebarDirective, AparteSplitDirective, AparteSuggestionsDirective, AparteUiComponent, ConversationManagerService, provideAparte };
2336
+ export { APARTE_CLIENT_OPTIONS, APARTE_CONFIG_TOKEN, APARTE_ELEMENT_DIRECTIVES, AparteAiService, AparteBubbleDirective, AparteChatBubbleDirective, AparteChatComponent, AparteChatStatusDirective, AparteChatViewportDirective, AparteComposerActionDirective, AparteComposerAddAttachmentDirective, AparteComposerAttachmentsDirective, AparteComposerCancelDirective, AparteComposerDirective, AparteComposerInputDirective, AparteComposerSendDirective, AparteComposerToolbarDirective, AparteContextDirective, AparteConversationListDirective, AparteElicitationDirective, AparteIconDirective, AparteOptgroupDirective, AparteOptionDirective, AparteProgressSpinnerDirective, AparteScrollRailDirective, AparteSegmentUIDirective, AparteSelectDirective, AparteSidebarDirective, AparteSplitDirective, AparteSuggestionsDirective, AparteToolUIDirective, AparteUiComponent, ConversationManagerService, provideAparte };
2171
2337
  //# sourceMappingURL=aparte-angular.mjs.map