From f867065e52712f288fae55b9da65399edaeda156 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:51:53 +1000 Subject: [PATCH 1/3] Add `ct-toast` and use it for `omnibot` --- packages/patterns/default-app.tsx | 70 ++- .../ct-toast-stack/ct-toast-stack.ts | 375 ++++++++++++++++ .../src/v2/components/ct-toast-stack/index.ts | 8 + .../ui/src/v2/components/ct-toast/ct-toast.ts | 423 ++++++++++++++++++ .../ui/src/v2/components/ct-toast/index.ts | 8 + packages/ui/src/v2/index.ts | 2 + 6 files changed, 882 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/v2/components/ct-toast-stack/ct-toast-stack.ts create mode 100644 packages/ui/src/v2/components/ct-toast-stack/index.ts create mode 100644 packages/ui/src/v2/components/ct-toast/ct-toast.ts create mode 100644 packages/ui/src/v2/components/ct-toast/index.ts diff --git a/packages/patterns/default-app.tsx b/packages/patterns/default-app.tsx index 1305fd98e..f814eb179 100644 --- a/packages/patterns/default-app.tsx +++ b/packages/patterns/default-app.tsx @@ -1,10 +1,12 @@ /// import { + BuiltInLLMMessage, Cell, cell, derive, handler, ifElse, + lift, NAME, navigateTo, recipe, @@ -101,6 +103,46 @@ const toggle = handler }>((_, { value }) => { value.set(!value.get()); }); +const messagesToNotifications = lift< + { + messages: BuiltInLLMMessage[]; + seen: Cell; + notifications: Cell<{ id: string; text: string; timestamp: number }[]>; + } +>(({ messages, seen, notifications }) => { + if (messages.length > 0) { + if (seen.get() >= messages.length) return; + + const latestMessage = messages[messages.length - 1]; + if (latestMessage.role === "assistant") { + const contentText = typeof latestMessage.content === "string" + ? latestMessage.content + : latestMessage.content.map((part) => { + if (part.type === "text") { + return part.text; + } else if (part.type === "tool-call") { + return `Tool call: ${part.toolName}`; + } else if (part.type === "tool-result") { + return part.output.type === "text" + ? part.output.value + : JSON.stringify(part.output.value); + } else if (part.type === "image") { + return "[Image]"; + } + return ""; + }).join(""); + + notifications.push({ + id: Math.random().toString(36), + text: contentText, + timestamp: Date.now(), + }); + + seen.set(messages.length); + } + } +}); + export default recipe( "DefaultCharmList", (_) => { @@ -110,6 +152,8 @@ export default recipe( ); const index = BacklinksIndex({ allCharms }); const fabExpanded = cell(false); + const notifications = cell<{ text: string; timestamp: number }[]>([]); + const seen = cell(0); const omnibot = Chatbot({ messages: [], @@ -126,6 +170,14 @@ export default recipe( }, }); + messagesToNotifications({ + messages: omnibot.messages, + seen: seen as unknown as Cell, + notifications: notifications as unknown as Cell< + { id: string; text: string; timestamp: number }[] + >, + }); + return { backlinksIndex: index, [NAME]: str`DefaultCharmList (${allCharms.length})`, @@ -215,10 +267,20 @@ export default recipe( {omnibot.ui.chatLog as any} ), - fabUI: ifElse( - fabExpanded, - omnibot.ui.promptInput, - , + fabUI: ( + <> + + {ifElse( + fabExpanded, + omnibot.ui.promptInput, + , + )} + ), }; }, diff --git a/packages/ui/src/v2/components/ct-toast-stack/ct-toast-stack.ts b/packages/ui/src/v2/components/ct-toast-stack/ct-toast-stack.ts new file mode 100644 index 000000000..e21f9e179 --- /dev/null +++ b/packages/ui/src/v2/components/ct-toast-stack/ct-toast-stack.ts @@ -0,0 +1,375 @@ +import { css, html } from "lit"; +import { property } from "lit/decorators.js"; +import { consume } from "@lit/context"; +import { repeat } from "lit/directives/repeat.js"; +import { BaseElement } from "../../core/base-element.ts"; +import { + applyThemeToElement, + type CTTheme, + defaultTheme, + themeContext, +} from "../theme-context.ts"; +import { type Cell, isCell } from "@commontools/runner"; +import type { ToastNotification } from "../ct-toast/ct-toast.ts"; +import "../ct-toast/ct-toast.ts"; + +/** + * Executes a mutation on a Cell within a transaction + */ +function mutateCell(cell: Cell, mutator: (cell: Cell) => void): void { + const tx = cell.runtime.edit(); + mutator(cell.withTx(tx)); + tx.commit(); +} + +/** + * CTToastStack - Toast notification container with configurable positioning + * Supports Cell for reactive data binding + * + * @element ct-toast-stack + * + * @attr {ToastNotification[]|Cell} notifications - Array of toast notifications (supports both plain array and Cell) + * @attr {string} position - Stack position: "top-right" | "top-left" | "bottom-right" | "bottom-left" | "top-center" | "bottom-center" + * @attr {number} auto-dismiss - Auto-dismiss duration in milliseconds (0 to disable) + * @attr {number} max-toasts - Maximum number of toasts to display at once + * + * @fires ct-toast-dismiss - Fired when a toast is dismissed (bubbles up from ct-toast) + * @fires ct-toast-action - Fired when a toast action is clicked (bubbles up from ct-toast) + * + * @example + * + */ + +export type ToastPosition = + | "top-right" + | "top-left" + | "bottom-right" + | "bottom-left" + | "top-center" + | "bottom-center"; + +export class CTToastStack extends BaseElement { + static override styles = [ + BaseElement.baseStyles, + css` + :host { + display: block; + box-sizing: border-box; + position: fixed; + z-index: 9999; + pointer-events: none; + } + + *, + *::before, + *::after { + box-sizing: inherit; + } + + .toast-stack { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; + pointer-events: none; + } + + .toast-stack > * { + pointer-events: auto; + } + + /* Position variants */ + :host([position="top-right"]) { + top: 0; + right: 0; + } + + :host([position="top-left"]) { + top: 0; + left: 0; + } + + :host([position="bottom-right"]) { + bottom: 0; + right: 0; + } + + :host([position="bottom-left"]) { + bottom: 0; + left: 0; + } + + :host([position="top-center"]) { + top: 0; + left: 50%; + transform: translateX(-50%); + } + + :host([position="bottom-center"]) { + bottom: 0; + left: 50%; + transform: translateX(-50%); + } + + /* Reverse stack order for bottom positions */ + :host([position="bottom-right"]) .toast-stack, + :host([position="bottom-left"]) .toast-stack, + :host([position="bottom-center"]) .toast-stack { + flex-direction: column-reverse; + } + `, + ]; + + static override properties = { + notifications: { type: Object, attribute: false }, + position: { type: String, reflect: true }, + autoDismiss: { type: Number, attribute: "auto-dismiss" }, + maxToasts: { type: Number, attribute: "max-toasts" }, + theme: { type: Object, attribute: false }, + }; + + @consume({ context: themeContext, subscribe: true }) + @property({ attribute: false }) + declare theme?: CTTheme; + + @property({ attribute: false }) + declare notifications: Cell | null; + + @property({ type: String, reflect: true }) + declare position: ToastPosition; + + @property({ type: Number, attribute: "auto-dismiss" }) + declare autoDismiss: number; + + @property({ type: Number, attribute: "max-toasts" }) + declare maxToasts: number; + + private _dismissTimers = new Map(); + private _unsubscribe: (() => void) | null = null; + + constructor() { + super(); + this.notifications = null; + this.position = "top-right"; + this.autoDismiss = 5000; // 5 seconds default + this.maxToasts = 5; + } + + override firstUpdated(changed: Map) { + super.firstUpdated(changed); + this._updateThemeProperties(); + } + + override updated( + changedProperties: Map, + ) { + super.updated(changedProperties); + + if (changedProperties.has("notifications")) { + // Clean up previous subscription + if (this._unsubscribe) { + this._unsubscribe(); + this._unsubscribe = null; + } + + // Subscribe to new Cell + if (this.notifications && isCell(this.notifications)) { + this._unsubscribe = this.notifications.sink(() => { + this.requestUpdate(); + this._updateAutoDismissTimers(); + }); + } + + this._updateAutoDismissTimers(); + } + if (changedProperties.has("theme")) { + this._updateThemeProperties(); + } + } + + private _updateThemeProperties() { + const currentTheme = this.theme || defaultTheme; + applyThemeToElement(this, currentTheme); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + if (this._unsubscribe) { + this._unsubscribe(); + this._unsubscribe = null; + } + this._clearAllTimers(); + } + + override render() { + if (!this.notifications) { + return html` + + `; + } + + const notificationsArray = this.notifications.get(); + + // // Ensure all notifications have IDs - if any are missing, update the Cell + // const needsIds = notificationsArray.some((n) => !n.id); + // if (needsIds && isCell(this.notifications)) { + // mutateCell(this.notifications, (cell) => { + // const current = cell.get(); + // const withIds = current.map((n) => + // n.id ? n : { ...n, id: this._generateId() } + // ); + // cell.set(withIds); + // }); + // // Will re-render with IDs + // return html` + + // `; + // } + + const visibleNotifications = notificationsArray.slice(0, this.maxToasts); + + return html` +
+ ${repeat( + visibleNotifications, + (notification) => notification.id || this._generateId(), + (notification) => + html` + + `, + )} +
+ `; + } + + /** + * Add a new toast notification + */ + public addToast( + text: string, + options?: Partial>, + ): string { + if (!this.notifications) return ""; + + const id = this._generateId(); + const notification: ToastNotification = { + id, + text, + timestamp: Date.now(), + variant: options?.variant || "default", + actionLabel: options?.actionLabel, + }; + + mutateCell(this.notifications, (cell) => { + const current = cell.get(); + cell.set([...current, notification]); + }); + + return id; + } + + /** + * Remove a toast notification by ID + */ + public removeToast(id: string): void { + if (!this.notifications) return; + + mutateCell(this.notifications, (cell) => { + const current = cell.get(); + cell.set(current.filter((n) => n.id !== id)); + }); + + this._clearTimer(id); + } + + /** + * Clear all toast notifications + */ + public clearAll(): void { + if (!this.notifications) return; + + mutateCell(this.notifications, (cell) => { + cell.set([]); + }); + + this._clearAllTimers(); + } + + private _updateAutoDismissTimers(): void { + if (this.autoDismiss <= 0 || !this.notifications) return; + + const notificationsArray = isCell(this.notifications) + ? this.notifications.get() + : this.notifications; + + // Clear timers for removed notifications + for (const [id] of this._dismissTimers) { + if (!notificationsArray.some((n) => n.id === id)) { + this._clearTimer(id); + } + } + + // Set timers for new notifications (only if they have IDs) + for (const notification of notificationsArray) { + if (notification.id && !this._dismissTimers.has(notification.id)) { + const timer = setTimeout(() => { + this.removeToast(notification.id); + }, this.autoDismiss); + this._dismissTimers.set(notification.id, timer); + } + } + } + + private _clearTimer(id: string): void { + const timer = this._dismissTimers.get(id); + if (timer) { + clearTimeout(timer); + this._dismissTimers.delete(id); + } + } + + private _clearAllTimers(): void { + for (const timer of this._dismissTimers.values()) { + clearTimeout(timer); + } + this._dismissTimers.clear(); + } + + private _handleToastDismiss(id: string, event: CustomEvent): void { + event.stopPropagation(); + this.removeToast(id); + this.emit("ct-toast-dismiss", { id, ...event.detail }); + } + + private _handleToastAction(id: string, event: CustomEvent): void { + event.stopPropagation(); + this.emit("ct-toast-action", { id, ...event.detail }); + } + + private _generateId(): string { + return `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + } +} + +globalThis.customElements.define("ct-toast-stack", CTToastStack); diff --git a/packages/ui/src/v2/components/ct-toast-stack/index.ts b/packages/ui/src/v2/components/ct-toast-stack/index.ts new file mode 100644 index 000000000..7a41e316c --- /dev/null +++ b/packages/ui/src/v2/components/ct-toast-stack/index.ts @@ -0,0 +1,8 @@ +import { CTToastStack } from "./ct-toast-stack.ts"; + +if (!customElements.get("ct-toast-stack")) { + customElements.define("ct-toast-stack", CTToastStack); +} + +export { CTToastStack }; +export type { ToastPosition } from "./ct-toast-stack.ts"; diff --git a/packages/ui/src/v2/components/ct-toast/ct-toast.ts b/packages/ui/src/v2/components/ct-toast/ct-toast.ts new file mode 100644 index 000000000..772d0f6ac --- /dev/null +++ b/packages/ui/src/v2/components/ct-toast/ct-toast.ts @@ -0,0 +1,423 @@ +import { css, html } from "lit"; +import { property } from "lit/decorators.js"; +import { consume } from "@lit/context"; +import { classMap } from "lit/directives/class-map.js"; +import { BaseElement } from "../../core/base-element.ts"; +import { + applyThemeToElement, + type CTTheme, + defaultTheme, + themeContext, +} from "../theme-context.ts"; + +/** + * CTToast - Individual toast notification component + * + * @element ct-toast + * + * @attr {string} variant - Visual style variant: "default" | "destructive" | "warning" | "success" | "info" + * @attr {string} text - Toast message text + * @attr {number} timestamp - Timestamp in milliseconds (for display or auto-dismiss) + * @attr {boolean} dismissible - Whether the toast can be dismissed with an X button + * @attr {string} action-label - Optional action button label + * + * @fires ct-dismiss - Fired when toast is dismissed + * @fires ct-action - Fired when action button is clicked + * + * @example + * + * + * @example + * + */ + +export type ToastVariant = + | "default" + | "destructive" + | "warning" + | "success" + | "info"; + +export interface ToastNotification { + id: string; + variant?: ToastVariant; + text: string; + timestamp: number; + actionLabel?: string; +} + +export class CTToast extends BaseElement { + static override styles = [ + BaseElement.baseStyles, + css` + :host { + display: block; + box-sizing: border-box; + } + + *, + *::before, + *::after { + box-sizing: inherit; + } + + .toast { + position: relative; + display: flex; + align-items: center; + width: 100%; + min-width: 20rem; + max-width: 28rem; + border-radius: 0.5rem; + border: 1px solid; + padding: 1rem; + gap: 0.75rem; + font-family: inherit; + font-size: 0.875rem; + line-height: 1.5; + box-shadow: + 0 10px 15px -3px rgb(0 0 0 / 0.1), + 0 4px 6px -4px rgb(0 0 0 / 0.1); + transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); + animation: slideIn 200ms cubic-bezier(0.4, 0, 0.2, 1); + } + + @keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } + } + + :host([dismissing]) .toast { + animation: slideOut 200ms cubic-bezier(0.4, 0, 0.2, 1); + } + + @keyframes slideOut { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(100%); + opacity: 0; + } + } + + .toast-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .toast-text { + margin: 0; + } + + .toast-timestamp { + font-size: 0.75rem; + opacity: 0.6; + } + + .toast-actions { + display: flex; + gap: 0.5rem; + align-items: center; + } + + .action-button, + .dismiss-button { + all: unset; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-weight: 500; + } + + .action-button { + text-decoration: underline; + } + + .action-button:hover { + opacity: 0.8; + } + + .dismiss-button { + padding: 0.25rem; + opacity: 0.7; + } + + .dismiss-button:hover { + opacity: 1; + } + + .dismiss-button:focus-visible, + .action-button:focus-visible { + outline: 2px solid transparent; + outline-offset: 2px; + box-shadow: 0 0 0 2px + var( + --ct-theme-color-primary, + var(--ct-color-primary, #3b82f6) + ); + } + + .dismiss-button svg { + width: 1rem; + height: 1rem; + } + + /* Default variant */ + .toast.variant-default { + background-color: var( + --ct-theme-color-background, + var(--ct-color-background, #ffffff) + ); + color: var(--ct-theme-color-text, var(--ct-color-text, #0f172a)); + border-color: var(--ct-theme-color-border, var(--ct-color-border, #e2e8f0)); + } + + /* Destructive variant */ + .toast.variant-destructive { + background-color: var( + --ct-theme-color-error-foreground, + var(--ct-color-error-foreground, #fef2f2) + ); + color: var(--ct-theme-color-error, var(--ct-color-error, #dc2626)); + border-color: var(--ct-theme-color-error, var(--ct-color-error, #dc2626)); + } + + /* Warning variant */ + .toast.variant-warning { + background-color: var( + --ct-theme-color-warning-foreground, + var(--ct-color-warning-foreground, #fef3c7) + ); + color: var( + --ct-theme-color-warning, + var(--ct-color-warning, #d97706) + ); + border-color: var( + --ct-theme-color-warning, + var(--ct-color-warning, #d97706) + ); + } + + /* Success variant */ + .toast.variant-success { + background-color: var( + --ct-theme-color-success-foreground, + var(--ct-color-success-foreground, #f0fdf4) + ); + color: var( + --ct-theme-color-success, + var(--ct-color-success, #16a34a) + ); + border-color: var( + --ct-theme-color-success, + var(--ct-color-success, #16a34a) + ); + } + + /* Info variant */ + .toast.variant-info { + background-color: var( + --ct-theme-color-primary-foreground, + var(--ct-color-primary-foreground, #eff6ff) + ); + color: var( + --ct-theme-color-primary, + var(--ct-color-primary, #3b82f6) + ); + border-color: var( + --ct-theme-color-primary, + var(--ct-color-primary, #3b82f6) + ); + } + + /* Adjust padding when dismissible */ + :host([dismissible]) .toast { + padding-right: 1rem; + } + `, + ]; + + static override properties = { + variant: { type: String }, + text: { type: String }, + timestamp: { type: Number }, + dismissible: { type: Boolean, reflect: true }, + actionLabel: { type: String, attribute: "action-label" }, + theme: { type: Object, attribute: false }, + }; + + @consume({ context: themeContext, subscribe: true }) + @property({ attribute: false }) + declare theme?: CTTheme; + + @property({ type: String }) + declare variant: ToastVariant; + + @property({ type: String }) + declare text: string; + + @property({ type: Number }) + declare timestamp: number; + + @property({ type: Boolean, reflect: true }) + declare dismissible: boolean; + + @property({ type: String, attribute: "action-label" }) + declare actionLabel?: string; + + constructor() { + super(); + this.variant = "default"; + this.text = ""; + this.timestamp = Date.now(); + this.dismissible = true; + } + + override firstUpdated(changed: Map) { + super.firstUpdated(changed); + this._updateThemeProperties(); + } + + override updated(changed: Map) { + super.updated(changed); + if (changed.has("theme")) { + this._updateThemeProperties(); + } + } + + private _updateThemeProperties() { + const currentTheme = this.theme || defaultTheme; + applyThemeToElement(this, currentTheme); + } + + override render() { + const classes = { + toast: true, + [`variant-${this.variant}`]: true, + }; + + return html` +
+
+

${this.text}

+ ${this._renderTimestamp()} +
+
+ ${this.actionLabel + ? html` + + ` + : null} ${this.dismissible + ? html` + + ` + : null} +
+
+ `; + } + + private _renderTimestamp() { + if (!this.timestamp) return null; + + const timeAgo = this._formatTimeAgo(this.timestamp); + return html` + ${timeAgo} + `; + } + + private _formatTimeAgo(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + const seconds = Math.floor(diff / 1000); + + if (seconds < 60) return "just now"; + if (seconds < 3600) { + const minutes = Math.floor(seconds / 60); + return `${minutes}m ago`; + } + if (seconds < 86400) { + const hours = Math.floor(seconds / 3600); + return `${hours}h ago`; + } + const days = Math.floor(seconds / 86400); + return `${days}d ago`; + } + + private _handleDismiss = (event: Event): void => { + event.preventDefault(); + event.stopPropagation(); + + this.setAttribute("dismissing", ""); + + setTimeout(() => { + this.emit("ct-dismiss", { + variant: this.variant, + text: this.text, + timestamp: this.timestamp, + }); + }, 200); + }; + + private _handleAction = (event: Event): void => { + event.preventDefault(); + event.stopPropagation(); + + this.emit("ct-action", { + variant: this.variant, + text: this.text, + timestamp: this.timestamp, + actionLabel: this.actionLabel, + }); + }; + } + + globalThis.customElements.define("ct-toast", CTToast); diff --git a/packages/ui/src/v2/components/ct-toast/index.ts b/packages/ui/src/v2/components/ct-toast/index.ts new file mode 100644 index 000000000..9183dc0d7 --- /dev/null +++ b/packages/ui/src/v2/components/ct-toast/index.ts @@ -0,0 +1,8 @@ +import { CTToast } from "./ct-toast.ts"; + +if (!customElements.get("ct-toast")) { + customElements.define("ct-toast", CTToast); +} + +export { CTToast }; +export type { ToastNotification, ToastVariant } from "./ct-toast.ts"; diff --git a/packages/ui/src/v2/index.ts b/packages/ui/src/v2/index.ts index fd85c10a4..ff0b98eee 100644 --- a/packages/ui/src/v2/index.ts +++ b/packages/ui/src/v2/index.ts @@ -64,6 +64,8 @@ export * from "./components/ct-list-item/index.ts"; export * from "./components/ct-tags/index.ts"; export * from "./components/ct-table/index.ts"; export * from "./components/ct-tile/index.ts"; +export * from "./components/ct-toast/index.ts"; +export * from "./components/ct-toast-stack/index.ts"; export * from "./components/ct-textarea/index.ts"; export * from "./components/ct-toggle/index.ts"; export * from "./components/ct-toggle-group/index.ts"; From 7fd002effd7d81938773e3d4940fced335687df1 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Thu, 23 Oct 2025 16:22:32 +1000 Subject: [PATCH 2/3] Use timestamp as ID --- packages/patterns/default-app.tsx | 3 +- .../ct-toast-stack/ct-toast-stack.ts | 91 +++++-------------- .../ui/src/v2/components/ct-toast/ct-toast.ts | 1 - 3 files changed, 25 insertions(+), 70 deletions(-) diff --git a/packages/patterns/default-app.tsx b/packages/patterns/default-app.tsx index f814eb179..e9a39439d 100644 --- a/packages/patterns/default-app.tsx +++ b/packages/patterns/default-app.tsx @@ -107,7 +107,7 @@ const messagesToNotifications = lift< { messages: BuiltInLLMMessage[]; seen: Cell; - notifications: Cell<{ id: string; text: string; timestamp: number }[]>; + notifications: Cell<{ text: string; timestamp: number }[]>; } >(({ messages, seen, notifications }) => { if (messages.length > 0) { @@ -133,7 +133,6 @@ const messagesToNotifications = lift< }).join(""); notifications.push({ - id: Math.random().toString(36), text: contentText, timestamp: Date.now(), }); diff --git a/packages/ui/src/v2/components/ct-toast-stack/ct-toast-stack.ts b/packages/ui/src/v2/components/ct-toast-stack/ct-toast-stack.ts index e21f9e179..11b790526 100644 --- a/packages/ui/src/v2/components/ct-toast-stack/ct-toast-stack.ts +++ b/packages/ui/src/v2/components/ct-toast-stack/ct-toast-stack.ts @@ -149,7 +149,7 @@ export class CTToastStack extends BaseElement { @property({ type: Number, attribute: "max-toasts" }) declare maxToasts: number; - private _dismissTimers = new Map(); + private _dismissTimers = new Map(); private _unsubscribe: (() => void) | null = null; constructor() { @@ -215,29 +215,13 @@ export class CTToastStack extends BaseElement { const notificationsArray = this.notifications.get(); - // // Ensure all notifications have IDs - if any are missing, update the Cell - // const needsIds = notificationsArray.some((n) => !n.id); - // if (needsIds && isCell(this.notifications)) { - // mutateCell(this.notifications, (cell) => { - // const current = cell.get(); - // const withIds = current.map((n) => - // n.id ? n : { ...n, id: this._generateId() } - // ); - // cell.set(withIds); - // }); - // // Will re-render with IDs - // return html` - - // `; - // } - const visibleNotifications = notificationsArray.slice(0, this.maxToasts); return html`
${repeat( visibleNotifications, - (notification) => notification.id || this._generateId(), + (notification) => notification.timestamp, (notification) => html` @@ -263,44 +247,18 @@ export class CTToastStack extends BaseElement { `; } - /** - * Add a new toast notification - */ - public addToast( - text: string, - options?: Partial>, - ): string { - if (!this.notifications) return ""; - - const id = this._generateId(); - const notification: ToastNotification = { - id, - text, - timestamp: Date.now(), - variant: options?.variant || "default", - actionLabel: options?.actionLabel, - }; - - mutateCell(this.notifications, (cell) => { - const current = cell.get(); - cell.set([...current, notification]); - }); - - return id; - } - /** * Remove a toast notification by ID */ - public removeToast(id: string): void { + public removeToast(timestamp: number): void { if (!this.notifications) return; mutateCell(this.notifications, (cell) => { const current = cell.get(); - cell.set(current.filter((n) => n.id !== id)); + cell.set(current.filter((n) => n.timestamp !== timestamp)); }); - this._clearTimer(id); + this._clearTimer(timestamp); } /** @@ -324,28 +282,31 @@ export class CTToastStack extends BaseElement { : this.notifications; // Clear timers for removed notifications - for (const [id] of this._dismissTimers) { - if (!notificationsArray.some((n) => n.id === id)) { - this._clearTimer(id); + for (const [timestamp] of this._dismissTimers) { + if (!notificationsArray.some((n) => n.timestamp === timestamp)) { + this._clearTimer(timestamp); } } // Set timers for new notifications (only if they have IDs) for (const notification of notificationsArray) { - if (notification.id && !this._dismissTimers.has(notification.id)) { + if ( + notification.timestamp && + !this._dismissTimers.has(notification.timestamp) + ) { const timer = setTimeout(() => { - this.removeToast(notification.id); + this.removeToast(notification.timestamp); }, this.autoDismiss); - this._dismissTimers.set(notification.id, timer); + this._dismissTimers.set(notification.timestamp, timer); } } } - private _clearTimer(id: string): void { - const timer = this._dismissTimers.get(id); + private _clearTimer(timestamp: number): void { + const timer = this._dismissTimers.get(timestamp); if (timer) { clearTimeout(timer); - this._dismissTimers.delete(id); + this._dismissTimers.delete(timestamp); } } @@ -356,19 +317,15 @@ export class CTToastStack extends BaseElement { this._dismissTimers.clear(); } - private _handleToastDismiss(id: string, event: CustomEvent): void { + private _handleToastDismiss(timestamp: number, event: CustomEvent): void { event.stopPropagation(); - this.removeToast(id); - this.emit("ct-toast-dismiss", { id, ...event.detail }); + this.removeToast(timestamp); + this.emit("ct-toast-dismiss", { id: timestamp, ...event.detail }); } - private _handleToastAction(id: string, event: CustomEvent): void { + private _handleToastAction(timestamp: number, event: CustomEvent): void { event.stopPropagation(); - this.emit("ct-toast-action", { id, ...event.detail }); - } - - private _generateId(): string { - return `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + this.emit("ct-toast-action", { id: timestamp, ...event.detail }); } } diff --git a/packages/ui/src/v2/components/ct-toast/ct-toast.ts b/packages/ui/src/v2/components/ct-toast/ct-toast.ts index 772d0f6ac..734d800b4 100644 --- a/packages/ui/src/v2/components/ct-toast/ct-toast.ts +++ b/packages/ui/src/v2/components/ct-toast/ct-toast.ts @@ -43,7 +43,6 @@ export type ToastVariant = | "info"; export interface ToastNotification { - id: string; variant?: ToastVariant; text: string; timestamp: number; From 9149568f52019f9abcd3177e92ca5edb704f7b39 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Thu, 23 Oct 2025 16:26:22 +1000 Subject: [PATCH 3/3] Reset if messages cleared --- packages/patterns/default-app.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/patterns/default-app.tsx b/packages/patterns/default-app.tsx index e9a39439d..9e086f913 100644 --- a/packages/patterns/default-app.tsx +++ b/packages/patterns/default-app.tsx @@ -111,7 +111,14 @@ const messagesToNotifications = lift< } >(({ messages, seen, notifications }) => { if (messages.length > 0) { - if (seen.get() >= messages.length) return; + if (seen.get() >= messages.length) { + // If messages length went backwards, reset seen counter + if (seen.get() > messages.length) { + seen.set(0); + } else { + return; + } + } const latestMessage = messages[messages.length - 1]; if (latestMessage.role === "assistant") {