Heads up: 2023.6 longer has persistent notifications in states: what to do?

cant seem to edit my response, but this is an updated Lit.js component that allows for filtering and querystring filtering, so you can create a link with the notification_id in it so clicking on a notification from the app will take you to the dashboard and filter by the correct notification id:

import {
    LitElement,
    html,
    css
} from "https://unpkg.com/[email protected]/lit-element.js?module";
import {
    marked
} from "https://unpkg.com/[email protected]/lib/marked.esm.js?module";
import DOMPurify from "https://unpkg.com/[email protected]/dist/purify.es.mjs?module";

class PersistentNotificationsCard extends LitElement {
    static properties = {
        hass: {
            type: Object
        },
        _notifications: {
            state: true
        },
        _filtered: {
            state: true
        },
        _filters: {
            state: true
        },
        _copiedId: {
            state: true
        },
    };

    constructor() {
        super();
        this._notifications = [];
        this._filtered = [];
        this._filters = {
            search: "",
            id: ""
        };
        this._refreshInterval = null;
        this._debounceTimer = null;
        this._copiedId = null;
    }

    setConfig(config) {
        this.config = config || {};
    }

    connectedCallback() {
        super.connectedCallback();
        this._loadFiltersFromURL();
        this._loadNotifications();
        this._refreshInterval = setInterval(() => this._loadNotifications(), 15000);
    }

    disconnectedCallback() {
        super.disconnectedCallback();
        if (this._refreshInterval) clearInterval(this._refreshInterval);
    }

    async _loadNotifications() {
        if (!this.hass?.connection) return;
        try {
            const result = await this.hass.connection.sendMessagePromise({
                type: "persistent_notification/get",
            });

            result.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));

            const parsed = result.map((n) => ({
                ...n,
                html_message: DOMPurify.sanitize(marked.parse(n.message || "")),
            }));

            this._notifications = parsed;
            this._applyFilters();
        } catch (err) {
            console.error("Error fetching persistent notifications:", err);
        }
    }

    async _dismiss(notification_id) {
        if (!this.hass?.connection) return;
        try {
            await this.hass.callService("persistent_notification", "dismiss", {
                notification_id,
            });
            // Immediately remove the notification from the local state for a faster UI update
            this._notifications = this._notifications.filter(
                (n) => n.notification_id !== notification_id
            );
            this._applyFilters();
            // A full reload will happen on the next interval, but we should force one quickly to ensure HA state is reflected
            clearTimeout(this._debounceTimer);
            this._debounceTimer = setTimeout(() => this._loadNotifications(), 500);
        } catch (err) {
            console.error("Error dismissing notification:", err);
        }
    }

    // New function to dismiss all currently filtered notifications
    async _dismissAll() {
        if (!confirm(`Are you sure you want to dismiss all ${this._filtered.length} visible notifications?`)) {
            return;
        }

        const notificationsToDismiss = this._filtered.map(n => n.notification_id);
        
        // Use Promise.all to dismiss them concurrently for speed
        const dismissalPromises = notificationsToDismiss.map(id => 
            this.hass.callService("persistent_notification", "dismiss", {
                notification_id: id,
            })
        );
        
        try {
            await Promise.all(dismissalPromises);
            // After all dismissals are requested, force a load to refresh the list
            this._loadNotifications();
        } catch (err) {
            console.error("Error dismissing all notifications:", err);
            // Even if some fail, try to refresh the list to show partial success
            this._loadNotifications(); 
        }
    }

    async _copyId(id) {
        try {
            await navigator.clipboard.writeText(id);
            this._copiedId = id;
            // Reset after 3 seconds
            setTimeout(() => (this._copiedId = null), 3000);
        } catch (err) {
            console.error("Clipboard copy failed:", err);
        }
    }

    _loadFiltersFromURL() {
        const params = new URLSearchParams(window.location.search);
        const search = params.get("search") || "";
        const id = params.get("id") || "";
        this._filters = {
            search,
            id
        };
    }

    _updateQueryString() {
        const params = new URLSearchParams();
        if (this._filters.search) params.set("search", this._filters.search);
        if (this._filters.id) params.set("id", this._filters.id);
        const newURL =
            window.location.pathname + (params.toString() ? `?${params.toString()}` : "");
        window.history.replaceState({}, "", newURL);
    }

    _handleFilterChange(e, key) {
        this._filters = { ...this._filters,
            [key]: e.target.value
        };
        clearTimeout(this._debounceTimer);
        this._debounceTimer = setTimeout(() => {
            this._applyFilters();
            this._updateQueryString();
        }, 500);
    }

    _applyFilters() {
        const search = this._filters.search.toLowerCase();
        const id = this._filters.id.toLowerCase();

        this._filtered = this._notifications.filter((n) => {
            const matchSearch =
                !search ||
                (n.title && n.title.toLowerCase().includes(search)) ||
                (n.message && n.message.toLowerCase().includes(search));
            const matchId = !id || n.notification_id.toLowerCase().includes(id);
            return matchSearch && matchId;
        });
    }

    _clearFilters() {
        this._filters = {
            search: "",
            id: ""
        };
        window.history.replaceState({}, "", window.location.pathname);
        this._applyFilters();
    }

    static styles = css`
    :host {
      display: block;
    }

    .filters-container {
      padding: 20px;
      background: var(--card-background-color);
      border-radius: 10px;
      box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
      margin-bottom: 16px;
    }

    .filters-row {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      align-items: center;
    }
    
    .clear-all-row {
      margin-top: 10px; /* Space below filter inputs */
      display: flex;
      justify-content: flex-end; /* Align the button to the right */
    }
    
    input[type="text"] {
      flex: 1;
      min-width: 140px;
      padding: 6px 8px;
      border: 1px solid var(--divider-color);
      border-radius: 6px;
      background: var(--card-background-color);
      color: var(--primary-text-color);
    }

    .clear-btn {
      background: white;
      color: black;
      border: none;
      padding: 6px 12px;
      border-radius: 6px;
      cursor: pointer;
      transition: background 0.2s ease;
      white-space: nowrap;
      margin-right: 30px;
    }

    .clear-btn:hover {
      background: #ccc;
    }

    .dismiss-all-btn {
      background: var(--error-color);
      color: white;
      border: none;
      padding: 6px 12px;
      border-radius: 6px;
      cursor: pointer;
      transition: background 0.2s ease;
      white-space: nowrap;
    }

    .dismiss-all-btn:hover {
      background: #c62828;
    }

    .notification {
      background: var(--card-background-color);
      border-radius: 8px;
      padding: 16px;
      margin-bottom: 14px;
      box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05);
    }

    .header {
      display: flex;
      justify-content: space-between;
      flex-wrap: wrap;
      align-items: flex-start;
    }

    .title {
      font-weight: bold;
      font-size: 1.1em;
    }

    .meta {
      text-align: right;
    }

    .timestamp,
    .notif-id {
      color: var(--secondary-text-color);
      font-size: 0.9em;
      line-height: 1.4;
    }
    
    .notif-id-wrapper {
      position: relative; /* Establish positioning context for .copied-text */
      display: inline-block; /* Only take necessary width */
      line-height: 1.4; /* Match parent line-height */
    }

    .notif-id-btn {
      background: none;
      border: none;
      color: var(--secondary-text-color);
      font-size: 0.9em;
      text-decoration: underline;
      cursor: pointer;
      padding: 0;
      margin-right: 4px; /* Slight space from ID label */
      line-height: inherit; /* Inherit line height */
    }

    .notif-id-btn:hover {
      color: var(--primary-color);
    }

    .copied-text {
      position: absolute; /* Absolute position relative to .notif-id-wrapper */
      top: 100%; /* Position below the button/link */
      right: 0; /* Align to the right of the wrapper */
      white-space: nowrap;
      color: var(--primary-color); /* Make it stand out a little */
      font-size: 0.8em; /* Slightly smaller */
      opacity: 0;
      transition: opacity 0.3s ease-in-out;
      pointer-events: none; /* Prevents text from being clickable/interfering */
    }
    
    .copied-text.visible {
      opacity: 1;
    }

    .message {
      margin-top: 10px;
      line-height: 1.5;
    }

    .message img {
      max-width: 100%; /* Default for smaller screens */
      border-radius: 6px;
      margin-top: 8px;
      display: block;
    }

    @media (min-width: 600px) { /* <--- THIS WAS ADDED BACK */
      .message img {
        max-width: 50%; /* 50% width on screens 600px and wider */
      }
    }

    .message a {
      color: var(--primary-color);
      text-decoration: underline;
    }

    button.dismiss-btn {
      margin-top: 10px;
      background: var(--primary-color);
      border: none;
      color: white;
      padding: 6px 10px;
      border-radius: 6px;
      cursor: pointer;
      transition: background 0.2s ease;
    }

    button.dismiss-btn:hover {
      background: var(--primary-color-dark, #0277bd);
    }
  `;

    render() {
        // Determine if the "Clear All" button should be enabled
        const filteredCount = this._filtered.length;
        const isClearAllDisabled = filteredCount === 0;
        const clearAllLabel = `Dismiss All (${filteredCount})`;

        return html `
      <div class="filters-container">
        <div class="filters-row">
          <input
            type="text"
            placeholder="Search title/message..."
            .value=${this._filters.search}
            @input=${(e) => this._handleFilterChange(e, "search")}
          />
          <input
            type="text"
            placeholder="Filter by ID..."
            .value=${this._filters.id}
            @input=${(e) => this._handleFilterChange(e, "id")}
          />
        </div>
        <div class="clear-all-row">
          <button class="clear-btn" @click=${this._clearFilters}>Clear Filters</button>
          <button
            class="dismiss-all-btn"
            @click=${this._dismissAll}
            ?disabled=${isClearAllDisabled}
            style=${isClearAllDisabled ? 'opacity: 0.5; cursor: not-allowed;' : ''}
          >
            ${clearAllLabel}
          </button>
        </div>
      </div>

      ${filteredCount === 0
        ? html`<div style="padding:12px; color: var(--secondary-text-color);">
              No notifications found.
            </div>`
        : html`
              ${this._filtered.map(
                (n) => html`
                  <div class="notification">
                    <div class="header">
                      <div class="title">
                        ${n.title || html`<span style="opacity:.7">(no title)</span>`}
                      </div>
                      <div class="meta">
                        <div class="timestamp">
                          ${new Date(n.created_at).toLocaleString()}
                        </div>
                        <div class="notif-id">
                            ID: 
                            <span class="notif-id-wrapper">
                              <button
                                class="notif-id-btn"
                                @click=${() => this._copyId(n.notification_id)}
                              >
                                ${n.notification_id}
                              </button>
                              <div
                                class="copied-text ${this._copiedId === n.notification_id ? 'visible' : ''}"
                              >
                                Copied
                              </div>
                            </span>
                        </div>
                      </div>
                    </div>

                    <div class="message" .innerHTML=${n.html_message}></div>

                    <button class="dismiss-btn" @click=${() => this._dismiss(n.notification_id)}>
                      Dismiss
                    </button>
                  </div>
                `
              )}
            `}
    `;
    }

    getCardSize() {
        return Math.max(1, this._filtered.length);
    }
}

customElements.define("persistent-notifications-card", PersistentNotificationsCard);

I cannot for the life of me see where “sensor.persistent_notifications” is defined, either in post 91 by Mariusthvdb, or in the whole thread …? Am I just blind? Thanks

It would be a template sensor, you’d have to manually create it.

Marius showed it here

Thanks petro, I saw that one but its unique_id is “persistent_notifications_overview” rather than “persistent_notifications”. So confirming they are one and the same, ie if I create the sensor “persistent_notifications_overview” and make sure I use a matching name in the yaml?