@tolle_/tolle-ui 21.1.2 → 21.2.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.
Files changed (128) hide show
  1. package/fesm2022/tolle-ui.mjs +30099 -5285
  2. package/fesm2022/tolle-ui.mjs.map +1 -1
  3. package/package.json +17 -5
  4. package/preset.js +35 -1
  5. package/registry/docs-content.json +11486 -0
  6. package/registry/llms-full.txt +4067 -0
  7. package/registry/llms.txt +127 -0
  8. package/registry/manifest.json +12656 -0
  9. package/registry/r/accordion-item.json +21 -0
  10. package/registry/r/accordion.json +23 -0
  11. package/registry/r/alert-dialog-dynamic.json +31 -0
  12. package/registry/r/alert-dialog.json +26 -0
  13. package/registry/r/alert.json +23 -0
  14. package/registry/r/aspect-ratio.json +21 -0
  15. package/registry/r/attachment.json +23 -0
  16. package/registry/r/avatar-fallback.json +16 -0
  17. package/registry/r/avatar.json +23 -0
  18. package/registry/r/badge.json +23 -0
  19. package/registry/r/bar-list.json +21 -0
  20. package/registry/r/breadcrumb-item.json +16 -0
  21. package/registry/r/breadcrumb-link.json +16 -0
  22. package/registry/r/breadcrumb-separator.json +16 -0
  23. package/registry/r/breadcrumb.json +21 -0
  24. package/registry/r/bubble.json +23 -0
  25. package/registry/r/button-group.json +21 -0
  26. package/registry/r/button.json +23 -0
  27. package/registry/r/calendar.json +23 -0
  28. package/registry/r/card.json +21 -0
  29. package/registry/r/carousel.json +18 -0
  30. package/registry/r/category-bar.json +21 -0
  31. package/registry/r/chain-of-thought.json +23 -0
  32. package/registry/r/chart-pie.json +30 -0
  33. package/registry/r/chart-spark.json +30 -0
  34. package/registry/r/chart.json +28 -0
  35. package/registry/r/checkbox.json +23 -0
  36. package/registry/r/checkpoint.json +23 -0
  37. package/registry/r/collapsible.json +21 -0
  38. package/registry/r/combobox.json +31 -0
  39. package/registry/r/command-dialog.json +21 -0
  40. package/registry/r/command.json +26 -0
  41. package/registry/r/confirmation.json +23 -0
  42. package/registry/r/context-menu-trigger.json +23 -0
  43. package/registry/r/context-menu.json +28 -0
  44. package/registry/r/context.json +26 -0
  45. package/registry/r/conversation.json +23 -0
  46. package/registry/r/country-selector.json +31 -0
  47. package/registry/r/data-table.json +38 -0
  48. package/registry/r/date-picker.json +27 -0
  49. package/registry/r/date-range-picker.json +32 -0
  50. package/registry/r/date-time-picker.json +28 -0
  51. package/registry/r/direction.json +16 -0
  52. package/registry/r/dropdown-item.json +21 -0
  53. package/registry/r/dropdown-label.json +16 -0
  54. package/registry/r/dropdown-menu.json +16 -0
  55. package/registry/r/dropdown-separator.json +16 -0
  56. package/registry/r/dropdown-trigger.json +20 -0
  57. package/registry/r/empty-state.json +23 -0
  58. package/registry/r/field.json +23 -0
  59. package/registry/r/hover-card.json +23 -0
  60. package/registry/r/inline-citation.json +26 -0
  61. package/registry/r/input-group.json +23 -0
  62. package/registry/r/input.json +21 -0
  63. package/registry/r/item.json +23 -0
  64. package/registry/r/kbd.json +23 -0
  65. package/registry/r/label.json +21 -0
  66. package/registry/r/marker.json +23 -0
  67. package/registry/r/masked-input.json +21 -0
  68. package/registry/r/menubar.json +24 -0
  69. package/registry/r/message-scroller.json +26 -0
  70. package/registry/r/message.json +23 -0
  71. package/registry/r/modal.json +36 -0
  72. package/registry/r/model-selector.json +32 -0
  73. package/registry/r/multi-select.json +33 -0
  74. package/registry/r/native-select.json +23 -0
  75. package/registry/r/navigation-menu.json +24 -0
  76. package/registry/r/otp-group.json +16 -0
  77. package/registry/r/otp-slot.json +21 -0
  78. package/registry/r/otp.json +21 -0
  79. package/registry/r/pagination.json +33 -0
  80. package/registry/r/phone-number-input.json +34 -0
  81. package/registry/r/plan.json +23 -0
  82. package/registry/r/popover-content.json +21 -0
  83. package/registry/r/popover.json +18 -0
  84. package/registry/r/progress-circle.json +21 -0
  85. package/registry/r/progress.json +21 -0
  86. package/registry/r/prompt-input.json +23 -0
  87. package/registry/r/queue.json +23 -0
  88. package/registry/r/radio-group.json +26 -0
  89. package/registry/r/radio-item.json +26 -0
  90. package/registry/r/range-calendar.json +28 -0
  91. package/registry/r/reasoning.json +23 -0
  92. package/registry/r/resizable-panel-item.json +23 -0
  93. package/registry/r/resizable-panel.json +23 -0
  94. package/registry/r/resizable.json +21 -0
  95. package/registry/r/scroll-area.json +21 -0
  96. package/registry/r/segment.json +21 -0
  97. package/registry/r/select-group.json +21 -0
  98. package/registry/r/select-item.json +26 -0
  99. package/registry/r/select-separator.json +21 -0
  100. package/registry/r/select.json +32 -0
  101. package/registry/r/separator.json +21 -0
  102. package/registry/r/sheet-wrapper.json +31 -0
  103. package/registry/r/sheet.json +21 -0
  104. package/registry/r/shimmer.json +23 -0
  105. package/registry/r/sidebar.json +21 -0
  106. package/registry/r/skeleton.json +23 -0
  107. package/registry/r/slider.json +21 -0
  108. package/registry/r/sources.json +23 -0
  109. package/registry/r/spinner.json +23 -0
  110. package/registry/r/suggestion.json +23 -0
  111. package/registry/r/switch.json +23 -0
  112. package/registry/r/table.json +21 -0
  113. package/registry/r/tabs.json +23 -0
  114. package/registry/r/tag-input.json +25 -0
  115. package/registry/r/task.json +23 -0
  116. package/registry/r/textarea.json +21 -0
  117. package/registry/r/time-picker.json +24 -0
  118. package/registry/r/toaster.json +26 -0
  119. package/registry/r/toggle-group.json +25 -0
  120. package/registry/r/toggle.json +23 -0
  121. package/registry/r/tolle-cell.json +16 -0
  122. package/registry/r/tool.json +23 -0
  123. package/registry/r/tooltip.json +23 -0
  124. package/registry/r/tracker.json +25 -0
  125. package/registry/r/typography.json +23 -0
  126. package/registry/registry.json +2545 -0
  127. package/theme.css +230 -10
  128. package/types/tolle-ui.d.ts +7433 -823
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "context",
3
+ "type": "registry:ui",
4
+ "title": "Context",
5
+ "description": "Context component.",
6
+ "category": "ai",
7
+ "registryDependencies": [
8
+ "hover-card"
9
+ ],
10
+ "dependencies": [
11
+ "@floating-ui/dom",
12
+ "class-variance-authority"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "context.component.ts",
17
+ "target": "ui/context.component.ts",
18
+ "content": "import {\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n Component,\n Injectable,\n Input,\n OnChanges,\n OnDestroy,\n OnInit,\n forwardRef,\n inject,\n} from '@angular/core';\n\nimport { BehaviorSubject, Subscription } from 'rxjs';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\nimport {\n HoverCardComponent,\n HoverCardTriggerComponent,\n HoverCardContentComponent,\n} from './hover-card.component';\n\n/** How close the context window is to being full. */\nexport type ContextLevel = 'normal' | 'warning' | 'critical';\n\n/** Everything a `tolle-context` knows about the current window. */\nexport interface ContextUsage {\n used: number;\n total: number;\n inputTokens: number | null;\n outputTokens: number | null;\n cost: number | null;\n /** Fraction of the window consumed, clamped to 0–1. */\n ratio: number;\n level: ContextLevel;\n}\n\nconst contextTriggerVariants = cva(\n 'inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs font-medium tabular-nums transition-colors',\n {\n variants: {\n level: {\n normal: 'text-muted-foreground hover:text-foreground',\n warning: 'text-warning hover:text-warning',\n critical: 'text-destructive hover:text-destructive',\n },\n },\n defaultVariants: { level: 'normal' },\n }\n);\n\nconst contextBarVariants = cva(\n 'h-full rounded-full transition-[width,background-color] duration-300',\n {\n variants: {\n level: {\n normal: 'bg-primary',\n warning: 'bg-warning',\n critical: 'bg-destructive',\n },\n },\n defaultVariants: { level: 'normal' },\n }\n);\n\nexport type ContextProps = VariantProps<typeof contextTriggerVariants>;\n\n/** Formats a raw token count as a short, readable string. */\nexport function formatTokens(value: number): string {\n if (!isFinite(value)) return '0';\n const abs = Math.abs(value);\n if (abs >= 1_000_000) return (value / 1_000_000).toFixed(1).replace(/\\.0$/, '') + 'M';\n if (abs >= 1_000) return (value / 1_000).toFixed(1).replace(/\\.0$/, '') + 'K';\n return String(Math.round(value));\n}\n\n/**\n * Publishes the parent `tolle-context`'s numbers to its OnPush trigger and\n * content children, which receive none of it as their own inputs.\n */\n@Injectable()\nexport class ContextService {\n private readonly usageSource = new BehaviorSubject<ContextUsage>({\n used: 0,\n total: 0,\n inputTokens: null,\n outputTokens: null,\n cost: null,\n ratio: 0,\n level: 'normal',\n });\n\n /** Emits the current context-window usage. */\n readonly usage$ = this.usageSource.asObservable();\n\n get usage(): ContextUsage {\n return this.usageSource.value;\n }\n\n setUsage(usage: ContextUsage): void {\n this.usageSource.next(usage);\n }\n}\n\n/**\n * A token / context-window usage indicator: a compact percentage and bar that\n * shifts to the warning and destructive tokens as the window fills, with a\n * hover breakdown of input, output and cost.\n *\n * ```html\n * <tolle-context [used]=\"82000\" [total]=\"128000\" [inputTokens]=\"61000\" [outputTokens]=\"21000\" [cost]=\"0.42\"></tolle-context>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-context',\n styles: [':host { display: block; }'],\n imports: [\n HoverCardComponent,\n HoverCardTriggerComponent,\n HoverCardContentComponent,\n forwardRef(() => ContextTriggerComponent),\n forwardRef(() => ContextContentComponent),\n ],\n providers: [ContextService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <tolle-hover-card\n [openDelay]=\"openDelay\"\n [closeDelay]=\"closeDelay\"\n [placement]=\"placement\"\n (mouseenter)=\"scheduleRefresh(openDelay)\"\n (mouseleave)=\"scheduleRefresh(closeDelay)\">\n <tolle-hover-card-trigger>\n <tolle-context-trigger></tolle-context-trigger>\n </tolle-hover-card-trigger>\n <tolle-hover-card-content class=\"w-60 p-0\">\n <tolle-context-content></tolle-context-content>\n </tolle-hover-card-content>\n </tolle-hover-card>\n `,\n})\nexport class ContextComponent implements OnInit, OnChanges, OnDestroy {\n /** Tokens consumed so far. @default 0 */\n @Input() used = 0;\n /** Size of the context window in tokens. @default 0 */\n @Input() total = 0;\n /** Tokens spent on the prompt, shown in the breakdown. @default null */\n @Input() inputTokens: number | null = null;\n /** Tokens produced by the model, shown in the breakdown. @default null */\n @Input() outputTokens: number | null = null;\n /** Spend for this context, shown in the breakdown. @default null */\n @Input() cost: number | null = null;\n /** Ratio at or above which the indicator turns warning-coloured. @default 0.75 */\n @Input() warningThreshold = 0.75;\n /** Ratio at or above which the indicator turns destructive-coloured. @default 0.9 */\n @Input() criticalThreshold = 0.9;\n /** Side of the indicator the breakdown opens on. @default 'top' */\n @Input() placement: 'top' | 'bottom' | 'left' | 'right' = 'top';\n /** Milliseconds to wait before opening the breakdown. @default 150 */\n @Input() openDelay = 150;\n /** Milliseconds to wait before closing the breakdown. @default 200 */\n @Input() closeDelay = 200;\n /** Extra Tailwind classes merged onto the indicator via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(ContextService);\n private readonly cdr = inject(ChangeDetectorRef);\n private refreshTimer?: ReturnType<typeof setTimeout>;\n\n ngOnInit(): void {\n this.push();\n }\n\n ngOnChanges(): void {\n this.push();\n }\n\n /** Fraction of the window consumed, clamped to 0–1. */\n get ratio(): number {\n if (!this.total || this.total <= 0) return 0;\n return Math.min(1, Math.max(0, this.used / this.total));\n }\n\n /** Which colour band the current usage falls into. */\n get level(): ContextLevel {\n const ratio = this.ratio;\n if (ratio >= this.criticalThreshold) return 'critical';\n if (ratio >= this.warningThreshold) return 'warning';\n return 'normal';\n }\n\n private push(): void {\n this.service.setUsage({\n used: this.used,\n total: this.total,\n inputTokens: this.inputTokens,\n outputTokens: this.outputTokens,\n cost: this.cost,\n ratio: this.ratio,\n level: this.level,\n });\n }\n\n /**\n * `tolle-hover-card` toggles its own visibility from a `setTimeout`, which\n * never marks this OnPush view dirty — re-check once the delay has elapsed.\n */\n scheduleRefresh(delay: number): void {\n clearTimeout(this.refreshTimer);\n this.refreshTimer = setTimeout(() => this.cdr.markForCheck(), delay + 32);\n }\n\n ngOnDestroy(): void {\n clearTimeout(this.refreshTimer);\n }\n}\n\n/** The always-visible part of a `tolle-context`: percentage plus usage bar. */\n@Component({\n selector: 'tolle-context-trigger',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <span\n role=\"meter\"\n [attr.aria-valuenow]=\"percent\"\n aria-valuemin=\"0\"\n aria-valuemax=\"100\"\n [attr.aria-label]=\"'Context window ' + percent + '% used'\"\n [attr.data-level]=\"usage.level\"\n [class]=\"computedClass\">\n <span class=\"h-1.5 w-10 overflow-hidden rounded-full bg-muted\">\n <span [class]=\"barClass\" [style.width.%]=\"percent\"></span>\n </span>\n <span>{{ percent }}%</span>\n <ng-content></ng-content>\n </span>\n `,\n})\nexport class ContextTriggerComponent implements OnChanges, OnInit, OnDestroy {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(ContextService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n usage: ContextUsage = this.service.usage;\n\n ngOnInit(): void {\n this.sub.add(\n this.service.usage$.subscribe(usage => {\n this.usage = usage;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n /** Usage as a whole percentage. */\n get percent(): number {\n return Math.round(this.usage.ratio * 100);\n }\n\n get computedClass() {\n return cn(contextTriggerVariants({ level: this.usage.level }), this.class);\n }\n\n get barClass() {\n return cn('block', contextBarVariants({ level: this.usage.level }));\n }\n}\n\n/** The hover breakdown of a `tolle-context`: used, total, input, output, cost. */\n@Component({\n selector: 'tolle-context-content',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <div class=\"flex items-baseline justify-between gap-2\">\n <span class=\"text-sm font-medium text-popover-foreground\">Context</span>\n <span class=\"text-xs tabular-nums text-muted-foreground\"\n >{{ tokens(usage.used) }} / {{ tokens(usage.total) }}</span\n >\n </div>\n <dl class=\"mt-2 flex flex-col gap-1 text-xs\">\n @if (usage.inputTokens !== null) {\n <div class=\"flex items-center justify-between gap-2\">\n <dt class=\"text-muted-foreground\">Input</dt>\n <dd class=\"tabular-nums text-popover-foreground\">{{ tokens(usage.inputTokens) }}</dd>\n </div>\n }\n @if (usage.outputTokens !== null) {\n <div class=\"flex items-center justify-between gap-2\">\n <dt class=\"text-muted-foreground\">Output</dt>\n <dd class=\"tabular-nums text-popover-foreground\">{{ tokens(usage.outputTokens) }}</dd>\n </div>\n }\n @if (usage.cost !== null) {\n <div class=\"flex items-center justify-between gap-2\">\n <dt class=\"text-muted-foreground\">Cost</dt>\n <dd class=\"tabular-nums text-popover-foreground\">{{ currency }}{{ usage.cost }}</dd>\n </div>\n }\n </dl>\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class ContextContentComponent implements OnChanges, OnInit, OnDestroy {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Symbol prefixed to the cost figure. @default '$' */\n @Input() currency = '$';\n /** Extra Tailwind classes merged onto the breakdown via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(ContextService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n usage: ContextUsage = this.service.usage;\n protected tokens = formatTokens;\n\n ngOnInit(): void {\n this.sub.add(\n this.service.usage$.subscribe(usage => {\n this.usage = usage;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n get computedClass() {\n return cn('flex flex-col p-3', this.class);\n }\n}\n"
19
+ },
20
+ {
21
+ "path": "utils/cn.ts",
22
+ "target": "ui/utils/cn.ts",
23
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
24
+ }
25
+ ]
26
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "conversation",
3
+ "type": "registry:ui",
4
+ "title": "Conversation",
5
+ "description": "Conversation component with size options.",
6
+ "category": "ai",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "conversation.component.ts",
14
+ "target": "ui/conversation.component.ts",
15
+ "content": "import {\n AfterViewInit,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n Component,\n ElementRef,\n EventEmitter,\n Injectable,\n Input,\n OnChanges,\n OnDestroy,\n OnInit,\n Output,\n ViewChild,\n inject,\n} from '@angular/core';\n\nimport { BehaviorSubject, Subject, Subscription } from 'rxjs';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\n/**\n * Coordinates a `tolle-conversation` and its projected parts.\n *\n * The transcript owns the scroll position; the empty state and the\n * \"jump to latest\" button are OnPush children that cannot read the parent's\n * fields directly, so everything they need is published here as an observable.\n *\n * Provided on `ConversationComponent`, so each transcript gets its own instance.\n */\n@Injectable()\nexport class ConversationService {\n private readonly atBottomSource = new BehaviorSubject<boolean>(true);\n /** Emits whether the transcript is scrolled to (or near) the newest message. */\n readonly atBottom$ = this.atBottomSource.asObservable();\n\n private readonly emptySource = new BehaviorSubject<boolean>(false);\n /** Emits whether the transcript has no messages yet. */\n readonly empty$ = this.emptySource.asObservable();\n\n private readonly scrollRequestSource = new Subject<ScrollBehavior>();\n /** Emits when a child asks the transcript to scroll to the newest message. */\n readonly scrollRequest$ = this.scrollRequestSource.asObservable();\n\n /** True while the transcript is pinned to the bottom. */\n get atBottom(): boolean {\n return this.atBottomSource.value;\n }\n\n /** True while the transcript is empty. */\n get empty(): boolean {\n return this.emptySource.value;\n }\n\n setAtBottom(value: boolean): void {\n if (this.atBottomSource.value !== value) this.atBottomSource.next(value);\n }\n\n setEmpty(value: boolean): void {\n if (this.emptySource.value !== value) this.emptySource.next(value);\n }\n\n /** Asks the transcript to scroll to the newest message. */\n requestScrollToBottom(behavior: ScrollBehavior = 'smooth'): void {\n this.scrollRequestSource.next(behavior);\n }\n}\n\nconst conversationVariants = cva('relative flex w-full flex-col overflow-hidden', {\n variants: {\n size: {\n sm: 'max-h-72',\n default: 'max-h-[32rem]',\n lg: 'max-h-[48rem]',\n full: 'h-full max-h-full',\n },\n },\n defaultVariants: {\n size: 'default',\n },\n});\n\nexport type ConversationProps = VariantProps<typeof conversationVariants>;\n\n/**\n * A scrollable chat transcript. Announces new messages politely, keeps itself\n * pinned to the newest message while the reader is already at the bottom, and\n * hosts the empty state plus the floating \"jump to latest\" button.\n *\n * Compose it from `tolle-conversation-content`,\n * `tolle-conversation-empty-state` and `tolle-conversation-scroll-button`.\n * @new\n */\n@Component({\n selector: 'tolle-conversation',\n styles: [':host { display: block; }'],\n imports: [],\n providers: [ConversationService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <div\n #viewport\n data-conversation-viewport\n role=\"log\"\n aria-live=\"polite\"\n aria-relevant=\"additions text\"\n [attr.aria-label]=\"ariaLabel || null\"\n [class]=\"viewportComputedClass\"\n (scroll)=\"onScroll()\">\n <ng-content></ng-content>\n </div>\n\n <ng-content select=\"tolle-conversation-scroll-button\"></ng-content>\n </div>\n `,\n})\nexport class ConversationComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /** Height preset for the scroll region. @default 'default' */\n @Input() size: ConversationProps['size'] = 'default';\n /** Keeps the newest message in view while the reader is already at the bottom. @default true */\n @Input() autoScroll = true;\n /** Set true before the first message so the empty state shows. @default false */\n @Input() empty = false;\n /** Distance from the bottom, in px, still counted as \"at the bottom\". @default 32 */\n @Input() threshold = 32;\n /** Accessible name for the transcript. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the transcript via `cn()` (last-wins). */\n @Input() class = '';\n /** Extra Tailwind classes merged onto the scrolling viewport via `cn()` (last-wins). */\n @Input() viewportClass = '';\n\n /** Emitted whenever the transcript becomes pinned to, or leaves, the bottom. */\n @Output() atBottomChange = new EventEmitter<boolean>();\n\n @ViewChild('viewport') private viewport?: ElementRef<HTMLElement>;\n\n private readonly service = inject(ConversationService);\n private readonly subscriptions = new Subscription();\n private observer?: MutationObserver;\n\n ngOnInit(): void {\n this.service.setEmpty(this.empty);\n this.subscriptions.add(\n this.service.scrollRequest$.subscribe(behavior => this.scrollToBottom(behavior))\n );\n }\n\n ngOnChanges(): void {\n this.service.setEmpty(this.empty);\n\n // A bound `class` input is written through Angular's styling path,\n // which does not mark an OnPush view dirty on its own.\n this.cdr.markForCheck();\n }\n\n ngAfterViewInit(): void {\n this.measure();\n\n // New messages arrive as DOM mutations rather than as inputs on this\n // component, so watching the subtree is the only content-agnostic way to\n // keep a transcript of projected messages pinned to the bottom.\n if (typeof MutationObserver !== 'undefined' && this.viewport) {\n this.observer = new MutationObserver(() => this.onContentChanged());\n this.observer.observe(this.viewport.nativeElement, {\n childList: true,\n subtree: true,\n characterData: true,\n });\n }\n }\n\n ngOnDestroy(): void {\n this.observer?.disconnect();\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn(conversationVariants({ size: this.size }), this.class);\n }\n\n get viewportComputedClass() {\n return cn('flex-1 overflow-y-auto overflow-x-hidden outline-none', this.viewportClass);\n }\n\n /** Scrolls the transcript to the newest message. */\n scrollToBottom(behavior: ScrollBehavior = 'smooth'): void {\n const el = this.viewport?.nativeElement;\n if (!el) return;\n if (typeof el.scrollTo === 'function') {\n el.scrollTo({ top: el.scrollHeight, behavior });\n } else {\n el.scrollTop = el.scrollHeight;\n }\n this.measure();\n }\n\n /** Recomputes whether the viewport is at the bottom and publishes the result. */\n onScroll(): void {\n this.measure();\n }\n\n private onContentChanged(): void {\n if (this.autoScroll && this.service.atBottom) {\n this.scrollToBottom('auto');\n } else {\n this.measure();\n }\n }\n\n private measure(): void {\n const el = this.viewport?.nativeElement;\n if (!el) return;\n const distance = el.scrollHeight - el.scrollTop - el.clientHeight;\n const next = distance <= this.threshold;\n if (next !== this.service.atBottom) {\n this.service.setAtBottom(next);\n this.atBottomChange.emit(next);\n }\n }\n}\n\n/** Vertical stack of messages inside a `tolle-conversation`. */\n@Component({\n selector: 'tolle-conversation-content',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class ConversationContentComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the stack via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn('flex flex-col gap-4 p-4', this.class);\n }\n}\n\n/**\n * Placeholder shown while a `tolle-conversation` has no messages. Renders\n * nothing once the transcript reports content, so it can stay in the template.\n */\n@Component({\n selector: 'tolle-conversation-empty-state',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @if (visible) {\n <div data-conversation-empty [class]=\"computedClass\">\n @if (icon) {\n <div\n class=\"flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground\">\n <i [class]=\"icon\" aria-hidden=\"true\"></i>\n </div>\n }\n @if (title) {\n <p class=\"text-sm font-medium text-foreground\">{{ title }}</p>\n }\n @if (description) {\n <p class=\"max-w-sm text-sm text-muted-foreground\">{{ description }}</p>\n }\n <ng-content></ng-content>\n </div>\n }\n `,\n})\nexport class ConversationEmptyStateComponent implements OnChanges, OnInit, OnDestroy {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Remixicon class for the illustration. @default 'ri-chat-3-line' */\n @Input() icon = 'ri-chat-3-line';\n /** Headline shown above the description. @default 'No messages yet' */\n @Input() title = 'No messages yet';\n /** Supporting copy under the headline. @default 'Start the conversation by sending a message.' */\n @Input() description = 'Start the conversation by sending a message.';\n /** Extra Tailwind classes merged onto the empty state via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(ConversationService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n visible = false;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.empty$.subscribe(empty => {\n this.visible = empty;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn('flex flex-col items-center justify-center gap-2 px-6 py-12 text-center', this.class);\n }\n}\n\nconst conversationScrollButtonVariants = cva(\n 'absolute bottom-4 left-1/2 z-10 inline-flex -translate-x-1/2 items-center justify-center gap-1.5 rounded-full border shadow-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',\n {\n variants: {\n variant: {\n default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/90',\n secondary:\n 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',\n outline:\n 'border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground',\n },\n size: {\n sm: 'h-7 px-2 text-xs',\n default: 'h-8 px-3 text-xs',\n icon: 'size-8 p-0 text-sm',\n },\n },\n defaultVariants: {\n variant: 'outline',\n size: 'default',\n },\n }\n);\n\nexport type ConversationScrollButtonProps = VariantProps<typeof conversationScrollButtonVariants>;\n\n/**\n * Floating \"jump to latest\" control. Appears only while the transcript is\n * scrolled away from the newest message, and scrolls it back on click.\n */\n@Component({\n selector: 'tolle-conversation-scroll-button',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @if (visible) {\n <button\n type=\"button\"\n data-conversation-scroll-button\n [attr.aria-label]=\"label\"\n [class]=\"computedClass\"\n (click)=\"onClick()\">\n <i class=\"ri-arrow-down-line\" aria-hidden=\"true\"></i>\n @if (size !== 'icon') {\n <span>{{ label }}</span>\n }\n </button>\n }\n `,\n})\nexport class ConversationScrollButtonComponent implements OnChanges, OnInit, OnDestroy {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Visual style of the button. @default 'outline' */\n @Input() variant: ConversationScrollButtonProps['variant'] = 'outline';\n /** Size of the button; 'icon' drops the text label. @default 'default' */\n @Input() size: ConversationScrollButtonProps['size'] = 'default';\n /** Button text and accessible name. @default 'Jump to latest' */\n @Input() label = 'Jump to latest';\n /** Extra Tailwind classes merged onto the button via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted when the reader jumps back to the newest message. */\n @Output() jumped = new EventEmitter<void>();\n\n private readonly service = inject(ConversationService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n visible = false;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.atBottom$.subscribe(atBottom => {\n this.visible = !atBottom;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn(\n conversationScrollButtonVariants({ variant: this.variant, size: this.size }),\n this.class\n );\n }\n\n onClick(): void {\n this.service.requestScrollToBottom('smooth');\n this.jumped.emit();\n }\n}\n"
16
+ },
17
+ {
18
+ "path": "utils/cn.ts",
19
+ "target": "ui/utils/cn.ts",
20
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
21
+ }
22
+ ]
23
+ }