From 6335fcc6fc186aaaffacaa60ab576adddb8693ad Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Wed, 29 Apr 2026 14:56:41 -0700 Subject: [PATCH 01/66] docs: add multi-session chat design spec Design for a ChatGPT-style multi-session feature in the embed: device-local storage, responsive sidebar/drawer, opt-in flag with default-on in Flowise core. Covers architecture, data model and v1 -> v2 migration, UI surface, data flows, error handling, and out-of-scope items. Also ignore .superpowers/ (visual brainstorming companion artifacts). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 +- .../2026-04-29-multi-session-chat-design.md | 393 ++++++++++++++++++ 2 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-04-29-multi-session-chat-design.md diff --git a/.gitignore b/.gitignore index 85a461aee..68caf4e80 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules .idea .env .claude/settings.json -.claude/settings.local.json \ No newline at end of file +.claude/settings.local.json +.superpowers/ \ No newline at end of file diff --git a/docs/superpowers/specs/2026-04-29-multi-session-chat-design.md b/docs/superpowers/specs/2026-04-29-multi-session-chat-design.md new file mode 100644 index 000000000..8020de0d6 --- /dev/null +++ b/docs/superpowers/specs/2026-04-29-multi-session-chat-design.md @@ -0,0 +1,393 @@ +# Multi-Session Chat — Design + +**Status:** Design (pre-implementation) +**Date:** 2026-04-29 +**Repo:** FlowiseChatEmbed (with thin Flowise-core companion section) + +--- + +## 1. Summary + +Add a ChatGPT-style multi-session experience to the FlowiseChatEmbed widget: one embedded chatbot can host a list of independent conversations, each with its own message history. Users can create new chats, switch between them, rename, and delete. Storage is device-local (`localStorage`); no backend changes are required for v1. + +The feature ships **opt-in** in the embed (a `BotProps.multiSession.enabled` flag) and **default-on** in Flowise core's chat preview surfaces, so chatflow and agentflow builders get it automatically. + +--- + +## 2. Scope Decisions + +These were decided during brainstorming and are the load-bearing assumptions for the rest of the design. + +| # | Decision | Rationale | +|---|---|---| +| 1 | **Device-local storage only (Option A).** No server-side session list, no cross-device sync. | Smallest blast radius; no Flowise backend changes; no identity primitive required. Cross-device is future work. | +| 2 | **One responsive panel (Option B).** Same `SessionPanel` component renders as a left sidebar in full-page mode and as a slide-in drawer in bubble/popup. | One UI pattern, one mental model, one code path. CSS adapts. | +| 3 | **Polished v1 feature set (Option B).** New chat · switch · delete · sort by recency · auto-title from truncated first user message · manual rename. **No** search, pinning, archive, or LLM-generated titles. | Auto-title alone leaves users wanting rename; LLM titles + search require backend work that conflicts with Decision 1. | +| 4 | **Opt-in for the embed; default-on for Flowise core (Option C).** Embedders flip a flag; Flowise's own admin previews enable it by default. | Doesn't disrupt customer sites; lets Flowise dogfood the feature. | +| 5 | **In-place migration (Option A).** Existing `${chatflowid}_EXTERNAL` thread becomes session #1 on first read of the new code. | Idempotent, invisible to end-users, zero data loss. | +| 6 | **Spec scope = embed end-to-end + thin Flowise companion section (Option B).** Implementation lives in this repo; a checklist of Flowise-side touchpoints is included but not designed in detail. | Engineering complexity is in the embed; Flowise-side work is mostly configuration. | +| 7 | **Architecture: Solid store + reactive Bot (Approach 2).** A new `sessionStore` is the source of truth; both `SessionPanel` and `Bot.tsx` subscribe. | Avoids the remount-and-refetch cost of a wrapper-keyed approach; rename/delete/migration logic lives naturally in the store. | +| 8 | **Storage shape: split index from per-session message bodies.** Index in `${chatflowid}_EXTERNAL`; messages in `${chatflowid}_EXTERNAL_msgs_${chatId}`. | Append-during-streaming touches only the active session's blob, not the whole list. Total bytes are unchanged; per-write cost scales with the active session, not the whole list. | +| 9 | **Cap: 50 sessions per chatflowid; soft warn once, then FIFO eviction by `updatedAt`.** | Bounded localStorage cost; rare-but-graceful eviction; one-time toast keeps users informed. | +| 10 | **Stream-during-switch: cancel + switch (Option a).** Best-effort abort on the in-flight assistant message, then switch. | Predictable, small change. Background-continue is a polish item for later. | +| 11 | **Cross-tab: last-write-wins with `storage`-event re-read.** | Realistic for v1. Real-time merge is future work. | +| 12 | **`flowise-clear-chat` (existing event) repurposed when multi-session is on**: deletes the active session and starts a fresh one. With multi-session off, behavior is unchanged. | Preserves end-user expectation of "clear what I see." | +| 13 | **Test framework: defer (Option B).** v1 ships with manual testing + heavy code review; Vitest + Solid Testing Library is a follow-up. | Avoid scope creep on infra. **Risk:** migration and store actions are exactly the code that benefits most from unit tests; reviewers and authors must compensate. | + +--- + +## 3. Architecture & File Layout + +A new Solid store is the single source of truth. The sidebar/drawer is a sibling component to ``. `Bot.tsx` reads the active session from the store and routes writes through store actions; it no longer touches `localStorage` directly for chat state. + +### Component tree + +``` + ← new shell; renders panel only when multiSession.enabled + ← reads sessions, activeId; dispatches actions + ← reads activeSession; derives messages from store + +``` + +When `multiSession.enabled === false`, `` is a no-op shell that renders `` directly. The store still exists internally but only ever holds one session, and no panel UI renders. This keeps `Bot.tsx` on a single code path regardless of the flag. + +### New files + +``` +src/ + state/ + sessionStore.ts // Solid store: signals, actions, persistence wiring + sessionStorage.ts // localStorage read/write with shape versioning + GC + sessionMigration.ts // v1 → v2 in-place migration + components/ + sessions/ + SessionPanel.tsx // Responsive panel (sidebar in full-page; drawer in bubble/popup) + SessionListItem.tsx // One row: title, recency, hover actions, inline rename, inline delete confirm + NewChatButton.tsx // "+ New chat" CTA + SessionPanelToggle.tsx // Hamburger / collapse caret (mode-aware) + ChatRoot.tsx // Shell that conditionally renders panel + Bot + utils/ + titleFromMessage.ts // First-user-message truncation; fallback "New chat" +``` + +### Modified files + +- **`src/components/Bot.tsx`** — replace internal `chatId()` and `messages()` signals with store-derived ones; replace direct `getLocalStorageChatflow` / `setLocalStorageChatflow` calls in mount/append paths with store actions. Mount-time fetches (`/public-chatbotConfig`, `/chatflows-streaming`) run once for the chatflow, not per session switch. +- **`src/types.ts`** — add `MultiSessionConfig` to `BotProps`. +- **`src/features/bubble/types.ts`** (and full/popup equivalents) — extend `chatWindow` theme with `sessionPanel` keys. +- **`src/utils/index.ts`** — keep `getLocalStorageChatflow` / `setLocalStorageChatflow` as backwards-compat wrappers that delegate to the new storage module so non-session consumers (notably `lead`) keep working. The wrapper performs **field-level merge** on the Index — e.g., `setLocalStorageChatflow({ lead })` updates only the top-level `lead` and leaves `version` / `activeChatId` / `sessions` untouched. Reads return a v1-shaped projection (`{ chatId, chatHistory, lead }`) derived from the active session, so any third-party caller using the old API keeps seeing what it expects. +- **`src/features/bubble/components/Bubble.tsx`**, **`src/features/full/components/Full.tsx`**, **`src/features/popup/components/Popup.tsx`** — render `` instead of `` directly. + +--- + +## 4. Data Model & Migration + +### v2 storage shape + +```ts +type SessionV2 = { + chatId: string; // same uuid pattern; preserves "${customerId}+${uuid}" if customerId set + title: string; // auto-derived from first user message; user-editable; "New chat" sentinel + createdAt: number; // ms epoch + updatedAt: number; // ms epoch; bumped on each new message; drives sort order +}; + +type ChatflowIndexV2 = { + version: 2; + activeChatId: string; + sessions: SessionV2[]; // NO messages here — kept thin + lead?: LeadCaptureData; // preserved at top level (current behavior) +}; + +// Index — small, fast (~80 bytes per session) +localStorage[`${chatflowid}_EXTERNAL`] = ChatflowIndexV2; + +// Messages — one key per session +localStorage[`${chatflowid}_EXTERNAL_msgs_${chatId}`] = MessageType[]; +``` + +`MessageType` and `LeadCaptureData` keep their current shapes — message format is untouched. + +### v1 → v2 migration + +``` +read localStorage[`${chatflowid}_EXTERNAL`] + ↓ +no entry? → fresh v2: { version: 2, activeChatId: newUuid, + sessions: [emptySession], lead: undefined } + write Index + empty MsgKey +has `version: 2`? → return as-is (idempotent) +has `chatId` + `chatHistory` (v1)? + → wrap into v2: + session = { chatId, title: titleFrom(chatHistory) ?? "Previous chat", + createdAt: Date.now(), updatedAt: Date.now() } + Index = { version: 2, activeChatId: chatId, sessions: [session], lead: existing.lead } + write Index + write MsgKey(chatId) = chatHistory +neither shape? → log warning; treat as no entry; DO NOT clobber +``` + +Migration is idempotent. Re-reading a v2 entry returns it unchanged. + +### Title derivation (`titleFromMessage`) + +Signature: `(messages: MessageType[]) => string | null`. + +- Take first message with `type === 'userMessage'`. +- Strip markdown control characters; collapse whitespace; trim. +- Truncate to 40 characters; append `…` if cut. +- **Returns `null`** if no `userMessage` is found (the caller decides the fallback string). + +Callers and their fallbacks: +- **Migration** uses `titleFromMessage(chatHistory) ?? "Previous chat"`. +- **New chat** flow doesn't call this; it sets `title: "New chat"` directly (the sentinel). +- **Auto-title** flow only fires after a user message has just been appended, so it gets a non-null result. +- **Rename** with empty input uses `titleFromMessage(messages) ?? "New chat"`. + +### `customerId` prefix preservation + +If `chatflowConfig.vars.customerId` is set, every newly generated `chatId` (across all sessions) uses `${customerId}+${uuid}`. Existing prefixed chatIds in v1 storage are preserved through migration. Changing `customerId` mid-life does not re-prefix existing sessions; new sessions get the new prefix. + +--- + +## 5. UI Surface & Config + +### Panel layout + +- **Full-page mode** — persistent left sidebar, default width 260 px, collapsible to a 44 px rail (collapse state persisted in `localStorage` under a separate `${chatflowid}_EXTERNAL_panelCollapsed` boolean key). +- **Bubble / popup modes** — sidebar is hidden by default; a `☰` toggle in the chat header opens it as a drawer (~75% of the chat-window width) over a dimmed backdrop. Tap a session row → drawer auto-closes and that session loads. Tap backdrop → drawer closes, active stays. + +### Panel anatomy + +- **Header** — "Conversations" label + collapse caret (full-page only). +- **+ New chat button** — primary CTA at top. +- **Session list** — sorted by `updatedAt` descending. Active row highlighted. Hovering a row reveals ✎ rename and × delete icons. +- **Footer** — small `N of 50 conversations` counter, only shown when `sessions.length >= 40`. + +### Item interactions + +- **Rename** — click ✎ → row swaps to inline input pre-filled with current title; **Enter** saves; **Esc** or click-outside cancels. Empty/whitespace-only saves fall back to `titleFromMessage` of the first user message, else `"New chat"`. +- **Delete** — click × → inline `Delete? [Yes] [No]` confirmation in the row. **Yes**: remove session and its `MsgKey`; if the deleted session was active, switch to the most recently updated remaining session (or run **New chat** if list is empty). +- **Switch** — click anywhere else on the row. + +### Empty state + +When the panel is rendered but no sessions exist (only happens on a fresh chatflow with the flag enabled), show a centered "No conversations yet" + "+ New chat" button. + +### Cap-warning toast + +Shown once per chatflowid the first time eviction occurs. Persisted via a one-time `${chatflowid}_EXTERNAL_capWarned` boolean in localStorage. Dismissible. + +### Config surface — `BotProps` additions + +```ts +type MultiSessionConfig = { + enabled: boolean; // default false in embed; default true when wrapped by Flowise core + maxSessions?: number; // default 50 +}; + +type BotProps = { + // ...existing fields + multiSession?: MultiSessionConfig; +}; +``` + +### Theme additions — `BubbleTheme.chatWindow.sessionPanel` (and equivalent for full/popup themes) + +```ts +sessionPanel?: { + // Layout + width?: string | number; // default 260px + collapsedWidth?: string | number; // default 44px + + // Colors — fall through to chatWindow palette if unset + backgroundColor?: string; + textColor?: string; + activeBackgroundColor?: string; + activeTextColor?: string; + hoverBackgroundColor?: string; + borderColor?: string; + newChatButtonColor?: string; + newChatButtonTextColor?: string; + + // Strings (i18n hooks) + newChatLabel?: string; // default "New chat" + emptyStateText?: string; // default "No conversations yet" + capWarningText?: string; // default "Conversation limit reached. Starting new ones will remove the oldest." +}; +``` + +Themes that don't set `sessionPanel.*` colors get sensible defaults derived from existing `chatWindow` palette colors (background → `backgroundColor`, text → `textColor`, etc.). + +### Imperative API — custom events + +Matches the existing `flowise-clear-chat` pattern. + +| Event | Direction | Detail | Behavior | +|---|---|---|---| +| `flowise-new-session` | host → embed | `{}` | Creates a new session; sets it active. | +| `flowise-switch-session` | host → embed | `{ chatId: string }` | Switches active session if `chatId` exists; no-op otherwise. | +| `flowise-session-changed` | embed → host | `{ chatId: string, title: string }` | Emitted on switch and on title change. | +| `flowise-clear-chat` | host → embed | (existing) | **Multi-session on:** deletes active session, creates a fresh one. **Off:** unchanged (clears the one thread). | + +### Accessibility + +- Panel: `role="navigation"`, `aria-label="Conversations"`. +- List: `role="list"`; items `role="listitem"` with `aria-current="true"` on the active session. +- Up/Down arrows move focus through items; **Enter** switches; **Delete** triggers inline delete confirm. +- Drawer (bubble/popup): focus traps inside drawer when open; **Esc** closes; backdrop is `aria-hidden`. +- Inline rename: input gets focus + selects all text; `aria-label="Rename conversation"`. +- Cap-warning toast: `role="alert"`. + +--- + +## 6. Data Flows + +Notation: **Index** = `localStorage[chatflowid_EXTERNAL]`; **MsgKey(id)** = `localStorage[chatflowid_EXTERNAL_msgs_${id}]`. + +### Store init / mount + +1. Read Index. +2. **Migration:** if v1 shape → wrap `chatHistory` into `sessions[0]`; write Index (v2) and MsgKey for that one session; preserve `lead`. If no entry → fresh v2 with one empty session. If unknown shape → log warning, treat as no entry, **do not clobber**. +3. **Reconcile / GC:** scan all `${chatflowid}_EXTERNAL_msgs_*` keys. + - Key not in Index → orphan; `removeItem`. + - Index entry without a matching key → seed empty MsgKey; log info. +4. Read `MsgKey(activeChatId)` only — other sessions stay cold until switched to. Cache in memory on read. +5. Attach a `window.addEventListener('storage', ...)` to detect cross-tab changes; on event, re-read Index and (if active session changed underneath) re-read its MsgKey. +6. If `multiSession.enabled === false` → store still runs; UI panel does not render. + +### New chat + +1. Generate `chatId` (with `customerId+` prefix if `chatflowConfig.vars.customerId` is set). +2. Write `MsgKey(chatId) = []`. +3. Update Index: prepend `{ chatId, title: "New chat", createdAt: now, updatedAt: now }`; set `activeChatId`; write Index. +4. **Cap check:** if `sessions.length > maxSessions`, find the session with lowest `updatedAt`; remove from Index; `removeItem(MsgKey(evicted.chatId))`. If this is the first eviction ever for this chatflowid (tracked via `${chatflowid}_EXTERNAL_capWarned`), emit the cap-warning toast and set the flag. +5. Emit `flowise-session-changed`. + +### Switch session + +1. User clicks list item with `chatId = X` (or host dispatches `flowise-switch-session`). +2. **If a response is currently streaming**, call `chatmessage/abort/{chatflowid}/{currentChatId}` (best-effort; ignore failure). +3. If `X` already cached in memory → set as active and re-render. +4. Else read `MsgKey(X)` → cache → set as active. +5. In bubble/popup, close the drawer. +6. Emit `flowise-session-changed`. + +### Append message (during streaming or one-shot) + +1. Bot calls `store.appendMessage(activeChatId, msg)`. +2. Store mutates in-memory active messages → Bot re-renders incrementally. +3. **Persist throttle:** writes to `MsgKey(activeChatId)` are debounced ~150ms so streaming doesn't fire one localStorage write per token. +4. **Flush triggers:** stream-end event from backend, `pagehide`, `beforeunload` — all flush pending writes immediately. +5. After persist, bump `session.updatedAt` and write Index (Index is small; per-message Index writes are cheap). +6. **Auto-title:** if the appended message is the first `userMessage` in this session **and** `session.title === "New chat"` (the sentinel set by the New chat flow), derive title via `titleFromMessage` and write Index. Once a session's title differs from the sentinel — whether via auto-title or manual rename — auto-titling never fires again for that session. Edge case: a user who manually renames *back to* the literal string `"New chat"` will, on their next first-user-message in a freshly created session, see auto-title fire again. Acceptable trade-off; vanishingly rare. + +### Rename + +1. Click ✎ → row swaps to inline input. +2. **Enter** → `session.title = sanitize(input)` (trim, max 80 chars, fallback if empty); write Index. +3. **Esc** / click-outside → discard. + +### Delete (inline confirm) + +1. Click × → row shows `Delete? [Yes] [No]`. +2. **Yes:** + - Remove session from Index; `removeItem(MsgKey(chatId))`; write Index. + - If deleted was active and other sessions remain → switch to most recently updated remaining session (load its MsgKey). + - If deleted was active and list is now empty → run **New chat** flow to seed a fresh session. + +### `flowise-clear-chat` (existing event) + +- **Multi-session on:** behaves like Delete on the active session (no inline confirm — the host page is the actor). +- **Multi-session off:** unchanged behavior (clear the one thread's messages). + +--- + +## 7. Error Handling & Edge Cases + +### `QuotaExceededError` on writes + +The 50-session cap is a *count* limit; bytes can still blow up on huge sessions. On any localStorage write failure: + +1. Catch `QuotaExceededError` (DOMException name match). +2. **Emergency eviction:** drop the **non-active** session with the lowest `updatedAt` (Index + MsgKey); retry the write. The active session is never evicted by this path — that would be self-defeating. +3. Repeat up to 5 times or until only the active session remains. +4. If still failing → surface a toast ("Storage is full. Some history could not be saved.") and skip the persist. In-memory state continues; on next clean write, things re-converge. +5. **Never** clobber `lead` during emergency eviction. + +### Corrupt or unknown storage shapes + +If `JSON.parse` throws or the parsed value matches neither v1 nor v2, log a console warning and treat as no entry. **Do not overwrite** — the user might own that key (custom integration, name collision). + +### Storage drift / orphans + +Reconcile/GC on init handles ungraceful tab closes mid-write: +- MsgKey exists with no matching Index entry → orphan; delete. +- Index entry exists with no matching MsgKey → seed empty MsgKey; log info. + +### Stream abort failures + +`chatmessage/abort/{chatflowid}/{chatId}` is best-effort. If it fails (network down, race with completion), swallow and move on. The UI is already showing the new session; the original session's last message may end up complete or truncated mid-token depending on backend race — acceptable for v1. + +### Active session deleted mid-stream + +Same handling as switch-mid-stream: abort then delete. Streaming response is dropped. + +### Multi-tab concurrency + +Two tabs on the same chatflowid share localStorage. Last-write-wins. A `storage` event listener re-reads Index on cross-tab change so each tab notices renames/deletes/new-chats from the other. Real-time merge of concurrent edits is not supported in v1 — both tabs editing the same session simultaneously can race. + +--- + +## 8. Testing + +**Decision:** Defer test framework setup. v1 ships with manual testing + heavy code review. Vitest + Solid Testing Library is a follow-up spec. + +**Risk:** Migration and store actions are precisely the code that benefits most from unit tests. Reviewers and authors must compensate by: + +- **Manual matrix testing** — every flow in Section 6 against every UI mode (bubble, full-page, popup), with both `multiSession.enabled = true` and `false`. +- **Migration testing** — manually craft localStorage entries matching v1 shape (with/without `lead`, with/without messages, with/without `customerId` prefix); reload and verify v2 result. +- **Cap testing** — write a small script in `public/index.html` to seed 50 sessions then create a 51st; verify FIFO eviction + one-time toast. +- **Quota testing** — fill localStorage to ~5 MB manually; verify emergency eviction. +- **Streaming-mid-switch** — start a long response; switch sessions mid-stream; verify abort fires and original session's tail is dropped cleanly. +- **Cross-tab** — open two tabs of the same chatflow; create/rename/delete in one; verify the other re-syncs on focus. + +A follow-up "add Vitest" spec should be opened the same week this lands. + +--- + +## 9. Flowise Core Companion (thin section) + +This is what the parent Flowise repo needs to do to enable default-on previews. Not implementation work in this spec — just touchpoints to call out. + +1. **Set `multiSession: { enabled: true }`** wherever Flowise's admin UI mounts the embed for chatflow / agentflow previews. Likely a `BotProps` extension or pre-baked config object. +2. **Verify `chatflowid` is stable** across preview reloads. If admin uses transient ids, multi-session won't persist there — flag back to the embed team. +3. **Optional follow-up (own spec):** expose an "Enable session history" toggle in the chatflow / agentflow settings UI; the value is copied into the embed snippet customers paste. Out of scope here. + +**Agentflows vs chatflows:** no special handling. The embed doesn't know whether the backend is a chatflow or agentflow — both go through `/api/v1/prediction/{chatflowid}` and both have stable chatflowids. Multi-session works the same for both. + +--- + +## 10. Out of Scope (Future Work) + +- Server-backed persistence and cross-device sync. +- LLM-generated session titles. +- Search across sessions. +- Pinning, archiving, folders/tags. +- Sharing or exporting a session. +- Per-session model selection. +- **Background streaming on session switch** (v1 cancels; "background continue" is a polish follow-up). +- Real-time merge of concurrent edits across tabs. +- Touch swipe gestures for drawer (tap-only in v1). +- Per-chatflow cap configuration in admin UI (cap is a code default + `BotProps` override in v1). +- Flowise admin "Enable session history" toggle (own spec). +- Vitest + Solid Testing Library setup (own follow-up spec). + +--- + +## 11. Risks & Open Items + +- **No automated tests in v1.** Mitigated by manual matrix + heavy review. Highest-risk areas: migration, store actions, cap eviction, streaming-mid-switch. +- **Bot.tsx surgery.** The file is large (~1500 lines) and replacing internal signals with store-derived ones is a wide diff. Mitigated by keeping the store interface narrow (read active session + a small set of action methods) so the diff is mechanical rather than architectural. +- **localStorage size headroom.** With 50 sessions of ~50 KB each, headroom against a 5 MB browser quota is comfortable. Heavy attachment use or very long bot responses can erode this; emergency eviction is the safety net. +- **`storage` event coverage.** Modern browsers fire it consistently for cross-tab changes, but Safari has historically had quirks under private browsing. If issues surface, fall back to re-reading Index on window focus. From 46e0f8fd54f659bd6648161ec322a798c099581c Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Wed, 29 Apr 2026 14:57:35 -0700 Subject: [PATCH 02/66] docs: prettier-format multi-session spec Apply pre-commit prettier formatting that wasn't re-staged into the previous commit. No content changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-29-multi-session-chat-design.md | 52 ++++++++++--------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/docs/superpowers/specs/2026-04-29-multi-session-chat-design.md b/docs/superpowers/specs/2026-04-29-multi-session-chat-design.md index 8020de0d6..15834aece 100644 --- a/docs/superpowers/specs/2026-04-29-multi-session-chat-design.md +++ b/docs/superpowers/specs/2026-04-29-multi-session-chat-design.md @@ -18,21 +18,21 @@ The feature ships **opt-in** in the embed (a `BotProps.multiSession.enabled` fla These were decided during brainstorming and are the load-bearing assumptions for the rest of the design. -| # | Decision | Rationale | -|---|---|---| -| 1 | **Device-local storage only (Option A).** No server-side session list, no cross-device sync. | Smallest blast radius; no Flowise backend changes; no identity primitive required. Cross-device is future work. | -| 2 | **One responsive panel (Option B).** Same `SessionPanel` component renders as a left sidebar in full-page mode and as a slide-in drawer in bubble/popup. | One UI pattern, one mental model, one code path. CSS adapts. | -| 3 | **Polished v1 feature set (Option B).** New chat · switch · delete · sort by recency · auto-title from truncated first user message · manual rename. **No** search, pinning, archive, or LLM-generated titles. | Auto-title alone leaves users wanting rename; LLM titles + search require backend work that conflicts with Decision 1. | -| 4 | **Opt-in for the embed; default-on for Flowise core (Option C).** Embedders flip a flag; Flowise's own admin previews enable it by default. | Doesn't disrupt customer sites; lets Flowise dogfood the feature. | -| 5 | **In-place migration (Option A).** Existing `${chatflowid}_EXTERNAL` thread becomes session #1 on first read of the new code. | Idempotent, invisible to end-users, zero data loss. | -| 6 | **Spec scope = embed end-to-end + thin Flowise companion section (Option B).** Implementation lives in this repo; a checklist of Flowise-side touchpoints is included but not designed in detail. | Engineering complexity is in the embed; Flowise-side work is mostly configuration. | -| 7 | **Architecture: Solid store + reactive Bot (Approach 2).** A new `sessionStore` is the source of truth; both `SessionPanel` and `Bot.tsx` subscribe. | Avoids the remount-and-refetch cost of a wrapper-keyed approach; rename/delete/migration logic lives naturally in the store. | -| 8 | **Storage shape: split index from per-session message bodies.** Index in `${chatflowid}_EXTERNAL`; messages in `${chatflowid}_EXTERNAL_msgs_${chatId}`. | Append-during-streaming touches only the active session's blob, not the whole list. Total bytes are unchanged; per-write cost scales with the active session, not the whole list. | -| 9 | **Cap: 50 sessions per chatflowid; soft warn once, then FIFO eviction by `updatedAt`.** | Bounded localStorage cost; rare-but-graceful eviction; one-time toast keeps users informed. | -| 10 | **Stream-during-switch: cancel + switch (Option a).** Best-effort abort on the in-flight assistant message, then switch. | Predictable, small change. Background-continue is a polish item for later. | -| 11 | **Cross-tab: last-write-wins with `storage`-event re-read.** | Realistic for v1. Real-time merge is future work. | -| 12 | **`flowise-clear-chat` (existing event) repurposed when multi-session is on**: deletes the active session and starts a fresh one. With multi-session off, behavior is unchanged. | Preserves end-user expectation of "clear what I see." | -| 13 | **Test framework: defer (Option B).** v1 ships with manual testing + heavy code review; Vitest + Solid Testing Library is a follow-up. | Avoid scope creep on infra. **Risk:** migration and store actions are exactly the code that benefits most from unit tests; reviewers and authors must compensate. | +| # | Decision | Rationale | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | **Device-local storage only (Option A).** No server-side session list, no cross-device sync. | Smallest blast radius; no Flowise backend changes; no identity primitive required. Cross-device is future work. | +| 2 | **One responsive panel (Option B).** Same `SessionPanel` component renders as a left sidebar in full-page mode and as a slide-in drawer in bubble/popup. | One UI pattern, one mental model, one code path. CSS adapts. | +| 3 | **Polished v1 feature set (Option B).** New chat · switch · delete · sort by recency · auto-title from truncated first user message · manual rename. **No** search, pinning, archive, or LLM-generated titles. | Auto-title alone leaves users wanting rename; LLM titles + search require backend work that conflicts with Decision 1. | +| 4 | **Opt-in for the embed; default-on for Flowise core (Option C).** Embedders flip a flag; Flowise's own admin previews enable it by default. | Doesn't disrupt customer sites; lets Flowise dogfood the feature. | +| 5 | **In-place migration (Option A).** Existing `${chatflowid}_EXTERNAL` thread becomes session #1 on first read of the new code. | Idempotent, invisible to end-users, zero data loss. | +| 6 | **Spec scope = embed end-to-end + thin Flowise companion section (Option B).** Implementation lives in this repo; a checklist of Flowise-side touchpoints is included but not designed in detail. | Engineering complexity is in the embed; Flowise-side work is mostly configuration. | +| 7 | **Architecture: Solid store + reactive Bot (Approach 2).** A new `sessionStore` is the source of truth; both `SessionPanel` and `Bot.tsx` subscribe. | Avoids the remount-and-refetch cost of a wrapper-keyed approach; rename/delete/migration logic lives naturally in the store. | +| 8 | **Storage shape: split index from per-session message bodies.** Index in `${chatflowid}_EXTERNAL`; messages in `${chatflowid}_EXTERNAL_msgs_${chatId}`. | Append-during-streaming touches only the active session's blob, not the whole list. Total bytes are unchanged; per-write cost scales with the active session, not the whole list. | +| 9 | **Cap: 50 sessions per chatflowid; soft warn once, then FIFO eviction by `updatedAt`.** | Bounded localStorage cost; rare-but-graceful eviction; one-time toast keeps users informed. | +| 10 | **Stream-during-switch: cancel + switch (Option a).** Best-effort abort on the in-flight assistant message, then switch. | Predictable, small change. Background-continue is a polish item for later. | +| 11 | **Cross-tab: last-write-wins with `storage`-event re-read.** | Realistic for v1. Real-time merge is future work. | +| 12 | **`flowise-clear-chat` (existing event) repurposed when multi-session is on**: deletes the active session and starts a fresh one. With multi-session off, behavior is unchanged. | Preserves end-user expectation of "clear what I see." | +| 13 | **Test framework: defer (Option B).** v1 ships with manual testing + heavy code review; Vitest + Solid Testing Library is a follow-up. | Avoid scope creep on infra. **Risk:** migration and store actions are exactly the code that benefits most from unit tests; reviewers and authors must compensate. | --- @@ -139,6 +139,7 @@ Signature: `(messages: MessageType[]) => string | null`. - **Returns `null`** if no `userMessage` is found (the caller decides the fallback string). Callers and their fallbacks: + - **Migration** uses `titleFromMessage(chatHistory) ?? "Previous chat"`. - **New chat** flow doesn't call this; it sets `title: "New chat"` directly (the sentinel). - **Auto-title** flow only fires after a user message has just been appended, so it gets a non-null result. @@ -182,8 +183,8 @@ Shown once per chatflowid the first time eviction occurs. Persisted via a one-ti ```ts type MultiSessionConfig = { - enabled: boolean; // default false in embed; default true when wrapped by Flowise core - maxSessions?: number; // default 50 + enabled: boolean; // default false in embed; default true when wrapped by Flowise core + maxSessions?: number; // default 50 }; type BotProps = { @@ -223,12 +224,12 @@ Themes that don't set `sessionPanel.*` colors get sensible defaults derived from Matches the existing `flowise-clear-chat` pattern. -| Event | Direction | Detail | Behavior | -|---|---|---|---| -| `flowise-new-session` | host → embed | `{}` | Creates a new session; sets it active. | -| `flowise-switch-session` | host → embed | `{ chatId: string }` | Switches active session if `chatId` exists; no-op otherwise. | -| `flowise-session-changed` | embed → host | `{ chatId: string, title: string }` | Emitted on switch and on title change. | -| `flowise-clear-chat` | host → embed | (existing) | **Multi-session on:** deletes active session, creates a fresh one. **Off:** unchanged (clears the one thread). | +| Event | Direction | Detail | Behavior | +| ------------------------- | ------------ | ----------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `flowise-new-session` | host → embed | `{}` | Creates a new session; sets it active. | +| `flowise-switch-session` | host → embed | `{ chatId: string }` | Switches active session if `chatId` exists; no-op otherwise. | +| `flowise-session-changed` | embed → host | `{ chatId: string, title: string }` | Emitted on switch and on title change. | +| `flowise-clear-chat` | host → embed | (existing) | **Multi-session on:** deletes active session, creates a fresh one. **Off:** unchanged (clears the one thread). | ### Accessibility @@ -280,7 +281,7 @@ Notation: **Index** = `localStorage[chatflowid_EXTERNAL]`; **MsgKey(id)** = `loc 3. **Persist throttle:** writes to `MsgKey(activeChatId)` are debounced ~150ms so streaming doesn't fire one localStorage write per token. 4. **Flush triggers:** stream-end event from backend, `pagehide`, `beforeunload` — all flush pending writes immediately. 5. After persist, bump `session.updatedAt` and write Index (Index is small; per-message Index writes are cheap). -6. **Auto-title:** if the appended message is the first `userMessage` in this session **and** `session.title === "New chat"` (the sentinel set by the New chat flow), derive title via `titleFromMessage` and write Index. Once a session's title differs from the sentinel — whether via auto-title or manual rename — auto-titling never fires again for that session. Edge case: a user who manually renames *back to* the literal string `"New chat"` will, on their next first-user-message in a freshly created session, see auto-title fire again. Acceptable trade-off; vanishingly rare. +6. **Auto-title:** if the appended message is the first `userMessage` in this session **and** `session.title === "New chat"` (the sentinel set by the New chat flow), derive title via `titleFromMessage` and write Index. Once a session's title differs from the sentinel — whether via auto-title or manual rename — auto-titling never fires again for that session. Edge case: a user who manually renames _back to_ the literal string `"New chat"` will, on their next first-user-message in a freshly created session, see auto-title fire again. Acceptable trade-off; vanishingly rare. ### Rename @@ -307,7 +308,7 @@ Notation: **Index** = `localStorage[chatflowid_EXTERNAL]`; **MsgKey(id)** = `loc ### `QuotaExceededError` on writes -The 50-session cap is a *count* limit; bytes can still blow up on huge sessions. On any localStorage write failure: +The 50-session cap is a _count_ limit; bytes can still blow up on huge sessions. On any localStorage write failure: 1. Catch `QuotaExceededError` (DOMException name match). 2. **Emergency eviction:** drop the **non-active** session with the lowest `updatedAt` (Index + MsgKey); retry the write. The active session is never evicted by this path — that would be self-defeating. @@ -322,6 +323,7 @@ If `JSON.parse` throws or the parsed value matches neither v1 nor v2, log a cons ### Storage drift / orphans Reconcile/GC on init handles ungraceful tab closes mid-write: + - MsgKey exists with no matching Index entry → orphan; delete. - Index entry exists with no matching MsgKey → seed empty MsgKey; log info. From 5add8d6fb719d7f9ed0959257bd749e9a5c56f1d Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Wed, 29 Apr 2026 15:28:27 -0700 Subject: [PATCH 03/66] docs: add multi-session chat implementation plan 23 tasks across 8 phases covering storage foundations, store layer, config surface, panel UI, mode-specific layouts, Bot.tsx integration, events/cross-tab, and final wire-up. Verification is manual (per spec Decision #13) via a public/debug-sessions.html harness for pure-logic tasks and demo-page recipes for UI tasks. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-29-multi-session-chat.md | 2999 +++++++++++++++++ 1 file changed, 2999 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-29-multi-session-chat.md diff --git a/docs/superpowers/plans/2026-04-29-multi-session-chat.md b/docs/superpowers/plans/2026-04-29-multi-session-chat.md new file mode 100644 index 000000000..0fcc64c7a --- /dev/null +++ b/docs/superpowers/plans/2026-04-29-multi-session-chat.md @@ -0,0 +1,2999 @@ +# Multi-Session Chat Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a ChatGPT-style multi-session chat experience to FlowiseChatEmbed: one chatflow can host a list of independent conversations the user can create, switch between, rename, and delete. Storage is device-local (`localStorage`); v1 ships opt-in via `BotProps.multiSession.enabled`. + +**Architecture:** A new Solid store (`sessionStore`) becomes the single source of truth for sessions. A `SessionPanel` component renders responsively (sidebar in full-page; drawer in bubble/popup). `Bot.tsx` is refactored to read its active thread from the store. Storage uses a *split shape*: a small index keyed by chatflowid plus one localStorage key per session for messages — so streamed appends only rewrite the active session's blob, not the whole list. + +**Tech Stack:** Solid.js (1.7), TypeScript, Tailwind, Rollup. No new runtime deps. No new test framework in v1 (deferred per spec Decision #13). + +**Spec:** `docs/superpowers/specs/2026-04-29-multi-session-chat-design.md` + +--- + +## Verification Approach (No Test Runner in v1) + +Per spec Decision #13, Vitest is **deferred** to a follow-up. Every task in this plan still has a verification step — they're **manual recipes** rather than automated tests: + +- **Pure-logic tasks** (storage, migration, title derivation) → verified via a small harness file `public/debug-sessions.html` that we build up over the plan. The author opens it in a browser, runs the harness, and confirms `console.assert` results in the DevTools console. +- **UI tasks** → verified by running `npm run dev` (Rollup watch) + `npm start` (Express server), opening the existing demo at `public/index.html`, and exercising the feature with a written recipe ("click X, expect Y"). +- **Integration tasks** → verified end-to-end on the demo page across all three modes (bubble, full-page, popup). + +Reviewers must compensate for the missing automated coverage — see spec Section 8. + +--- + +## File Structure + +**New files:** +``` +src/state/ + sessionStorage.ts // Index + per-session MsgKey I/O, GC, quota recovery + sessionMigration.ts // v1 → v2 in-place migration + sessionStore.ts // Solid store: signals, actions, persistence wiring +src/utils/ + titleFromMessage.ts // Truncate first user message; null fallback +src/components/sessions/ + ChatRoot.tsx // Shell: conditionally renders panel + Bot + SessionPanel.tsx // Responsive panel (sidebar + drawer) + SessionListItem.tsx // Row: title, recency, hover actions, rename, delete + NewChatButton.tsx // "+ New chat" CTA + SessionPanelToggle.tsx // Mode-aware open/collapse trigger + CapWarningToast.tsx // One-time first-eviction notice +public/ + debug-sessions.html // Manual verification harness (kept long-term) +``` + +**Modified files:** +``` +src/components/Bot.tsx // Replace internal chatId/messages signals with store-derived +src/types.ts // Add MultiSessionConfig +src/features/bubble/types.ts // Extend BubbleTheme.chatWindow with sessionPanel keys +src/features/full/types.ts // Same for full-page theme +src/features/popup/types.ts // Same for popup theme +src/utils/index.ts // Field-merge wrapper for setLocalStorageChatflow +src/features/bubble/components/Bubble.tsx // Render instead of +src/features/full/components/Full.tsx // Same +src/features/popup/components/Popup.tsx // Same +.gitignore // (already done) ignore .superpowers/ +``` + +--- + +## Phase 1: Storage Foundations + +The store doesn't exist yet. Phase 1 builds the pure-logic layer underneath it: title derivation, raw localStorage I/O, migration. Each is independently verifiable in the browser console. + +### Task 1: Title derivation utility + +**Files:** +- Create: `src/utils/titleFromMessage.ts` +- Create (or modify if it exists later): `public/debug-sessions.html` + +- [ ] **Step 1: Create the utility file** + +```ts +// src/utils/titleFromMessage.ts +import type { MessageType } from '@/components/Bot'; + +const MAX_TITLE_LEN = 40; + +export const titleFromMessage = (messages: MessageType[]): string | null => { + const firstUser = messages.find((m) => m.type === 'userMessage'); + if (!firstUser) return null; + + const stripped = (firstUser.message ?? '') + .replace(/[`*_~#>[\]()]/g, '') + .replace(/\s+/g, ' ') + .trim(); + + if (stripped.length === 0) return null; + + if (stripped.length <= MAX_TITLE_LEN) return stripped; + return stripped.slice(0, MAX_TITLE_LEN).trimEnd() + '…'; +}; +``` + +- [ ] **Step 2: Create the manual verification harness** + +```html + + + + + + Multi-session debug harness + + + +

Multi-session debug harness

+

Open DevTools console — failures are red. Each section is added as the plan progresses.

+

+
+    
+  
+
+```
+
+> **Note:** `import('/src/utils/titleFromMessage.ts')` works because the harness is served by Rollup's dev server, which transpiles TS on the fly. If your local dev server doesn't, replace with `import('/dist/web.js')` after running `npm run build`.
+
+- [ ] **Step 3: Run the harness**
+
+Run: `npm run dev` in one terminal, `npm start` in another, open `http://localhost:3000/debug-sessions.html` (path matches `server.js` static-serve config — verify the actual port/path; if `server.js` doesn't expose `public/`, add the file directly under wherever it does).
+
+Expected: 7 green ✓ lines for the title cases.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/utils/titleFromMessage.ts public/debug-sessions.html
+git commit -m "feat(sessions): add titleFromMessage utility and debug harness"
+```
+
+---
+
+### Task 2: Session storage I/O — types and reads
+
+Storage is a separate module so it can be unit-verified without Solid runtime. This task adds the types and the read path. Writes come in Task 3.
+
+**Files:**
+- Create: `src/state/sessionStorage.ts`
+- Modify: `public/debug-sessions.html`
+
+- [ ] **Step 1: Create the storage module with types and read functions**
+
+```ts
+// src/state/sessionStorage.ts
+import type { MessageType } from '@/components/Bot';
+
+export type LeadCaptureData = Record;
+
+export type SessionV2 = {
+  chatId: string;
+  title: string;
+  createdAt: number;
+  updatedAt: number;
+};
+
+export type ChatflowIndexV2 = {
+  version: 2;
+  activeChatId: string;
+  sessions: SessionV2[];
+  lead?: LeadCaptureData;
+};
+
+const indexKey = (chatflowid: string) => `${chatflowid}_EXTERNAL`;
+const msgKey = (chatflowid: string, chatId: string) => `${chatflowid}_EXTERNAL_msgs_${chatId}`;
+const capWarnedKey = (chatflowid: string) => `${chatflowid}_EXTERNAL_capWarned`;
+const panelCollapsedKey = (chatflowid: string) => `${chatflowid}_EXTERNAL_panelCollapsed`;
+
+const safeParse = (raw: string | null): T | null => {
+  if (raw === null) return null;
+  try {
+    return JSON.parse(raw) as T;
+  } catch {
+    return null;
+  }
+};
+
+export const readIndex = (chatflowid: string): ChatflowIndexV2 | null => {
+  const parsed = safeParse(localStorage.getItem(indexKey(chatflowid)));
+  if (!parsed || typeof parsed !== 'object') return null;
+  if ((parsed as ChatflowIndexV2).version === 2) return parsed as ChatflowIndexV2;
+  return null;
+};
+
+export const readMessages = (chatflowid: string, chatId: string): MessageType[] => {
+  return safeParse(localStorage.getItem(msgKey(chatflowid, chatId))) ?? [];
+};
+
+export const readPanelCollapsed = (chatflowid: string): boolean => {
+  return localStorage.getItem(panelCollapsedKey(chatflowid)) === '1';
+};
+
+export const readCapWarned = (chatflowid: string): boolean => {
+  return localStorage.getItem(capWarnedKey(chatflowid)) === '1';
+};
+
+export const _internalKeys = { indexKey, msgKey, capWarnedKey, panelCollapsedKey };
+```
+
+- [ ] **Step 2: Add storage-read assertions to the harness**
+
+Append inside the existing ``:
+
+```js
+// Section: sessionStorage reads
+const { readIndex, readMessages, readPanelCollapsed, _internalKeys } = await import(
+  '/src/state/sessionStorage.ts'
+);
+
+const cf = '__test_cf_' + Date.now();
+
+// Empty state
+__assert(readIndex(cf) === null, 'no entry → readIndex returns null');
+__assert(readMessages(cf, 'x').length === 0, 'no messages → readMessages returns []');
+__assert(readPanelCollapsed(cf) === false, 'no collapse pref → false');
+
+// v2 entry
+const v2 = {
+  version: 2,
+  activeChatId: 'a',
+  sessions: [{ chatId: 'a', title: 'Hello', createdAt: 1, updatedAt: 2 }],
+};
+localStorage.setItem(_internalKeys.indexKey(cf), JSON.stringify(v2));
+const got = readIndex(cf);
+__assert(got && got.activeChatId === 'a', 'v2 entry round-trips');
+
+// Corrupt entry
+localStorage.setItem(_internalKeys.indexKey(cf), 'not json');
+__assert(readIndex(cf) === null, 'corrupt JSON → null');
+
+// v1 entry (no version field) → null at this layer (migration handles upgrade later)
+localStorage.setItem(_internalKeys.indexKey(cf), JSON.stringify({ chatId: 'x', chatHistory: [] }));
+__assert(readIndex(cf) === null, 'v1-shaped entry → null at storage layer');
+
+// Cleanup
+localStorage.removeItem(_internalKeys.indexKey(cf));
+```
+
+- [ ] **Step 3: Run the harness**
+
+Refresh the debug page. Expected: previous 7 green ✓ lines plus 5 more for storage reads.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/state/sessionStorage.ts public/debug-sessions.html
+git commit -m "feat(sessions): add sessionStorage read API and types"
+```
+
+---
+
+### Task 3: Session storage I/O — writes, GC, quota recovery
+
+**Files:**
+- Modify: `src/state/sessionStorage.ts`
+- Modify: `public/debug-sessions.html`
+
+- [ ] **Step 1: Add write/delete and GC functions to the storage module**
+
+Append to `src/state/sessionStorage.ts`:
+
+```ts
+export class StorageQuotaError extends Error {
+  constructor() {
+    super('localStorage quota exceeded');
+    this.name = 'StorageQuotaError';
+  }
+}
+
+const isQuotaError = (e: unknown): boolean => {
+  if (!(e instanceof Error)) return false;
+  return (
+    e.name === 'QuotaExceededError' ||
+    e.name === 'NS_ERROR_DOM_QUOTA_REACHED' ||
+    (e as { code?: number }).code === 22
+  );
+};
+
+const safeWrite = (key: string, value: string) => {
+  try {
+    localStorage.setItem(key, value);
+  } catch (e) {
+    if (isQuotaError(e)) throw new StorageQuotaError();
+    throw e;
+  }
+};
+
+export const writeIndex = (chatflowid: string, index: ChatflowIndexV2): void => {
+  safeWrite(indexKey(chatflowid), JSON.stringify(index));
+};
+
+export const writeMessages = (
+  chatflowid: string,
+  chatId: string,
+  messages: MessageType[],
+): void => {
+  safeWrite(msgKey(chatflowid, chatId), JSON.stringify(messages));
+};
+
+export const removeMessages = (chatflowid: string, chatId: string): void => {
+  localStorage.removeItem(msgKey(chatflowid, chatId));
+};
+
+export const writePanelCollapsed = (chatflowid: string, collapsed: boolean): void => {
+  safeWrite(panelCollapsedKey(chatflowid), collapsed ? '1' : '0');
+};
+
+export const writeCapWarned = (chatflowid: string): void => {
+  safeWrite(capWarnedKey(chatflowid), '1');
+};
+
+/**
+ * Reconcile MsgKey orphans against an Index.
+ * - Returns chatIds whose MsgKey was deleted (orphans, not in index).
+ * - Returns chatIds in index that have no MsgKey (caller should seed empty).
+ */
+export const reconcileOrphans = (
+  chatflowid: string,
+  index: ChatflowIndexV2,
+): { deletedOrphans: string[]; missingMsgKeys: string[] } => {
+  const indexIds = new Set(index.sessions.map((s) => s.chatId));
+  const prefix = `${chatflowid}_EXTERNAL_msgs_`;
+
+  const deletedOrphans: string[] = [];
+  for (let i = 0; i < localStorage.length; i++) {
+    const k = localStorage.key(i);
+    if (!k || !k.startsWith(prefix)) continue;
+    const chatId = k.slice(prefix.length);
+    if (!indexIds.has(chatId)) {
+      localStorage.removeItem(k);
+      deletedOrphans.push(chatId);
+      i--; // length shrunk
+    }
+  }
+
+  const missingMsgKeys: string[] = [];
+  for (const s of index.sessions) {
+    if (localStorage.getItem(msgKey(chatflowid, s.chatId)) === null) {
+      missingMsgKeys.push(s.chatId);
+    }
+  }
+
+  return { deletedOrphans, missingMsgKeys };
+};
+```
+
+- [ ] **Step 2: Add write/GC assertions to the harness**
+
+Append to the harness `
   
 
@@ -197,6 +185,7 @@ git commit -m "feat(sessions): add titleFromMessage utility and debug harness"
 Storage is a separate module so it can be unit-verified without Solid runtime. This task adds the types and the read path. Writes come in Task 3.
 
 **Files:**
+
 - Create: `src/state/sessionStorage.ts`
 - Modify: `public/debug-sessions.html`
 
@@ -227,7 +216,7 @@ const msgKey = (chatflowid: string, chatId: string) => `${chatflowid}_EXTERNAL_m
 const capWarnedKey = (chatflowid: string) => `${chatflowid}_EXTERNAL_capWarned`;
 const panelCollapsedKey = (chatflowid: string) => `${chatflowid}_EXTERNAL_panelCollapsed`;
 
-const safeParse = (raw: string | null): T | null => {
+const safeParse = (raw: string | null): T | null => {
   if (raw === null) return null;
   try {
     return JSON.parse(raw) as T;
@@ -264,9 +253,7 @@ Append inside the existing `
+  
+
diff --git a/src/utils/titleFromMessage.ts b/src/utils/titleFromMessage.ts
new file mode 100644
index 000000000..60431e534
--- /dev/null
+++ b/src/utils/titleFromMessage.ts
@@ -0,0 +1,18 @@
+import type { MessageType } from '@/components/Bot';
+
+const MAX_TITLE_LEN = 40;
+
+export const titleFromMessage = (messages: MessageType[]): string | null => {
+  const firstUser = messages.find((m) => m.type === 'userMessage');
+  if (!firstUser) return null;
+
+  const stripped = (firstUser.message ?? '')
+    .replace(/[`*_~#>[\]()]/g, '')
+    .replace(/\s+/g, ' ')
+    .trim();
+
+  if (stripped.length === 0) return null;
+
+  if (stripped.length <= MAX_TITLE_LEN) return stripped;
+  return stripped.slice(0, MAX_TITLE_LEN).trimEnd() + '…';
+};
diff --git a/src/web.ts b/src/web.ts
index 32a9cef12..8338b341a 100644
--- a/src/web.ts
+++ b/src/web.ts
@@ -1,5 +1,6 @@
 import { registerWebComponents } from './register';
 import { parseChatbot, injectChatbotInWindow } from './window';
+import { titleFromMessage } from './utils/titleFromMessage';
 
 registerWebComponents();
 
@@ -8,3 +9,4 @@ const chatbot = parseChatbot();
 injectChatbotInWindow(chatbot);
 
 export default chatbot;
+export { titleFromMessage };

From b4278c46d8cebed2f1744929ecdbb4e428d84aa5 Mon Sep 17 00:00:00 2001
From: chloebyun-wd 
Date: Thu, 30 Apr 2026 09:43:08 -0700
Subject: [PATCH 08/66] fix(sessions): revert web.ts public export; inline
 harness port

---
 public/debug-sessions.html | 16 ++++++++++++++--
 src/web.ts                 |  2 --
 2 files changed, 14 insertions(+), 4 deletions(-)

diff --git a/public/debug-sessions.html b/public/debug-sessions.html
index b9fa15726..b0ef7786a 100644
--- a/public/debug-sessions.html
+++ b/public/debug-sessions.html
@@ -43,8 +43,20 @@ 

Multi-session debug harness

window.__assert = (cond, msg) => print(msg, !!cond); // Section: titleFromMessage - // Try importing from built dist/web.js (fallback for when TS transpilation isn't available in dev) - const { titleFromMessage } = await import('/web.js'); + // Inline JS port of src/utils/titleFromMessage.ts for harness verification. + // Keep in sync with the source file when changing logic. + const MAX_TITLE_LEN = 40; + const titleFromMessage = (messages) => { + const firstUser = messages.find((m) => m.type === 'userMessage'); + if (!firstUser) return null; + const stripped = (firstUser.message ?? '') + .replace(/[`*_~#>[\]()]/g, '') + .replace(/\s+/g, ' ') + .trim(); + if (stripped.length === 0) return null; + if (stripped.length <= MAX_TITLE_LEN) return stripped; + return stripped.slice(0, MAX_TITLE_LEN).trimEnd() + '…'; + }; __assert(titleFromMessage([]) === null, 'empty messages → null'); __assert( diff --git a/src/web.ts b/src/web.ts index 8338b341a..32a9cef12 100644 --- a/src/web.ts +++ b/src/web.ts @@ -1,6 +1,5 @@ import { registerWebComponents } from './register'; import { parseChatbot, injectChatbotInWindow } from './window'; -import { titleFromMessage } from './utils/titleFromMessage'; registerWebComponents(); @@ -9,4 +8,3 @@ const chatbot = parseChatbot(); injectChatbotInWindow(chatbot); export default chatbot; -export { titleFromMessage }; From f211431db0a973eefd800ce68f341a1fb79d42af Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Thu, 30 Apr 2026 09:51:55 -0700 Subject: [PATCH 09/66] refactor(sessions): drop dead null-coalesce on firstUser.message Co-Authored-By: Claude Opus 4.7 (1M context) --- public/debug-sessions.html | 2 +- src/utils/titleFromMessage.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/debug-sessions.html b/public/debug-sessions.html index b0ef7786a..bd6dc4f27 100644 --- a/public/debug-sessions.html +++ b/public/debug-sessions.html @@ -49,7 +49,7 @@

Multi-session debug harness

const titleFromMessage = (messages) => { const firstUser = messages.find((m) => m.type === 'userMessage'); if (!firstUser) return null; - const stripped = (firstUser.message ?? '') + const stripped = firstUser.message .replace(/[`*_~#>[\]()]/g, '') .replace(/\s+/g, ' ') .trim(); diff --git a/src/utils/titleFromMessage.ts b/src/utils/titleFromMessage.ts index 60431e534..a325bea10 100644 --- a/src/utils/titleFromMessage.ts +++ b/src/utils/titleFromMessage.ts @@ -6,7 +6,7 @@ export const titleFromMessage = (messages: MessageType[]): string | null => { const firstUser = messages.find((m) => m.type === 'userMessage'); if (!firstUser) return null; - const stripped = (firstUser.message ?? '') + const stripped = firstUser.message .replace(/[`*_~#>[\]()]/g, '') .replace(/\s+/g, ' ') .trim(); From a3b8ee6702ddcfcb03388ccf2bf64b8ae6b41b90 Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Thu, 30 Apr 2026 09:54:31 -0700 Subject: [PATCH 10/66] feat(sessions): add sessionStorage read API and types --- public/debug-sessions.html | 46 ++++++++++++++++++++++++++++++++ src/state/sessionStorage.ts | 52 +++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/state/sessionStorage.ts diff --git a/public/debug-sessions.html b/public/debug-sessions.html index bd6dc4f27..874ab8e51 100644 --- a/public/debug-sessions.html +++ b/public/debug-sessions.html @@ -82,6 +82,52 @@

Multi-session debug harness

titleFromMessage([{ type: 'userMessage', message: ' ' }]) === null, 'whitespace-only user message → null', ); + + // Section: sessionStorage reads + // Inline JS port for harness verification (keep in sync with src/state/sessionStorage.ts) + const _indexKey = (cf) => `${cf}_EXTERNAL`; + const _msgKey = (cf, id) => `${cf}_EXTERNAL_msgs_${id}`; + const _panelCollapsedKey = (cf) => `${cf}_EXTERNAL_panelCollapsed`; + const _safeParse = (raw) => { + if (raw === null) return null; + try { return JSON.parse(raw); } catch { return null; } + }; + const readIndex = (cf) => { + const parsed = _safeParse(localStorage.getItem(_indexKey(cf))); + if (!parsed || typeof parsed !== 'object') return null; + if (parsed.version === 2) return parsed; + return null; + }; + const readMessages = (cf, id) => _safeParse(localStorage.getItem(_msgKey(cf, id))) ?? []; + const readPanelCollapsed = (cf) => localStorage.getItem(_panelCollapsedKey(cf)) === '1'; + + const cf = '__test_cf_' + Date.now(); + + // Empty state + __assert(readIndex(cf) === null, 'no entry → readIndex returns null'); + __assert(readMessages(cf, 'x').length === 0, 'no messages → readMessages returns []'); + __assert(readPanelCollapsed(cf) === false, 'no collapse pref → false'); + + // v2 entry + const v2 = { + version: 2, + activeChatId: 'a', + sessions: [{ chatId: 'a', title: 'Hello', createdAt: 1, updatedAt: 2 }], + }; + localStorage.setItem(_indexKey(cf), JSON.stringify(v2)); + const got = readIndex(cf); + __assert(got && got.activeChatId === 'a', 'v2 entry round-trips'); + + // Corrupt entry + localStorage.setItem(_indexKey(cf), 'not json'); + __assert(readIndex(cf) === null, 'corrupt JSON → null'); + + // v1 entry (no version field) → null at this layer (migration handles upgrade later) + localStorage.setItem(_indexKey(cf), JSON.stringify({ chatId: 'x', chatHistory: [] })); + __assert(readIndex(cf) === null, 'v1-shaped entry → null at storage layer'); + + // Cleanup + localStorage.removeItem(_indexKey(cf)); diff --git a/src/state/sessionStorage.ts b/src/state/sessionStorage.ts new file mode 100644 index 000000000..8eed5d131 --- /dev/null +++ b/src/state/sessionStorage.ts @@ -0,0 +1,52 @@ +import type { MessageType } from '@/components/Bot'; + +export type LeadCaptureData = Record; + +export type SessionV2 = { + chatId: string; + title: string; + createdAt: number; + updatedAt: number; +}; + +export type ChatflowIndexV2 = { + version: 2; + activeChatId: string; + sessions: SessionV2[]; + lead?: LeadCaptureData; +}; + +const indexKey = (chatflowid: string) => `${chatflowid}_EXTERNAL`; +const msgKey = (chatflowid: string, chatId: string) => `${chatflowid}_EXTERNAL_msgs_${chatId}`; +const capWarnedKey = (chatflowid: string) => `${chatflowid}_EXTERNAL_capWarned`; +const panelCollapsedKey = (chatflowid: string) => `${chatflowid}_EXTERNAL_panelCollapsed`; + +const safeParse = (raw: string | null): T | null => { + if (raw === null) return null; + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +}; + +export const readIndex = (chatflowid: string): ChatflowIndexV2 | null => { + const parsed = safeParse(localStorage.getItem(indexKey(chatflowid))); + if (!parsed || typeof parsed !== 'object') return null; + if ((parsed as ChatflowIndexV2).version === 2) return parsed as ChatflowIndexV2; + return null; +}; + +export const readMessages = (chatflowid: string, chatId: string): MessageType[] => { + return safeParse(localStorage.getItem(msgKey(chatflowid, chatId))) ?? []; +}; + +export const readPanelCollapsed = (chatflowid: string): boolean => { + return localStorage.getItem(panelCollapsedKey(chatflowid)) === '1'; +}; + +export const readCapWarned = (chatflowid: string): boolean => { + return localStorage.getItem(capWarnedKey(chatflowid)) === '1'; +}; + +export const _internalKeys = { indexKey, msgKey, capWarnedKey, panelCollapsedKey }; From 8ac047c388bbf7c42c8e10d13162cb0748dbfa74 Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Thu, 30 Apr 2026 10:06:14 -0700 Subject: [PATCH 11/66] fix(sessions): array-guard readMessages; document harness inline-port limitation - Add Array.isArray() validation to readMessages() to prevent undefined behavior when localStorage contains corrupted-but-valid JSON (e.g. objects or primitives typed as MessageType[]). - Update inline JS port of readMessages in debug-sessions.html to match. - Document the harness's inline-port limitation and explain why a real test runner (Vitest + Solid Testing Library) is needed (spec Decision #13). Co-Authored-By: Claude Opus 4.7 (1M context) --- public/debug-sessions.html | 14 +++++++++++++- src/state/sessionStorage.ts | 5 +++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/public/debug-sessions.html b/public/debug-sessions.html index 874ab8e51..4451e10d6 100644 --- a/public/debug-sessions.html +++ b/public/debug-sessions.html @@ -42,6 +42,15 @@

Multi-session debug harness

}; window.__assert = (cond, msg) => print(msg, !!cond); + // ⚠️ Inline-port limitation: + // This harness can't `await import('/src/.../...ts')` because the dev server + // doesn't transpile TypeScript for static HTML imports. We compensate by + // inlining a JS port of each module's logic below. This means the harness + // verifies the *spec's intended behavior*, not the actual TS source — drift + // between the source and the inline port will not be caught here. + // See spec Decision #13 (test framework deferred). Move to a real test + // runner (Vitest + Solid Testing Library) when adding the follow-up spec. + // Section: titleFromMessage // Inline JS port of src/utils/titleFromMessage.ts for harness verification. // Keep in sync with the source file when changing logic. @@ -98,7 +107,10 @@

Multi-session debug harness

if (parsed.version === 2) return parsed; return null; }; - const readMessages = (cf, id) => _safeParse(localStorage.getItem(_msgKey(cf, id))) ?? []; + const readMessages = (cf, id) => { + const parsed = _safeParse(localStorage.getItem(_msgKey(cf, id))); + return Array.isArray(parsed) ? parsed : []; + }; const readPanelCollapsed = (cf) => localStorage.getItem(_panelCollapsedKey(cf)) === '1'; const cf = '__test_cf_' + Date.now(); diff --git a/src/state/sessionStorage.ts b/src/state/sessionStorage.ts index 8eed5d131..32b16a689 100644 --- a/src/state/sessionStorage.ts +++ b/src/state/sessionStorage.ts @@ -21,7 +21,7 @@ const msgKey = (chatflowid: string, chatId: string) => `${chatflowid}_EXTERNAL_m const capWarnedKey = (chatflowid: string) => `${chatflowid}_EXTERNAL_capWarned`; const panelCollapsedKey = (chatflowid: string) => `${chatflowid}_EXTERNAL_panelCollapsed`; -const safeParse = (raw: string | null): T | null => { +const safeParse = (raw: string | null): T | null => { if (raw === null) return null; try { return JSON.parse(raw) as T; @@ -38,7 +38,8 @@ export const readIndex = (chatflowid: string): ChatflowIndexV2 | null => { }; export const readMessages = (chatflowid: string, chatId: string): MessageType[] => { - return safeParse(localStorage.getItem(msgKey(chatflowid, chatId))) ?? []; + const parsed = safeParse(localStorage.getItem(msgKey(chatflowid, chatId))); + return Array.isArray(parsed) ? (parsed as MessageType[]) : []; }; export const readPanelCollapsed = (chatflowid: string): boolean => { From 000920ed77afcb47b9a0bdf1add3b3a67081d71c Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Thu, 30 Apr 2026 10:13:05 -0700 Subject: [PATCH 12/66] feat(sessions): add storage writes, GC, and quota error type --- public/debug-sessions.html | 73 ++++++++++++++++++++++++++++++++ src/state/sessionStorage.ts | 83 +++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/public/debug-sessions.html b/public/debug-sessions.html index 4451e10d6..bc714e875 100644 --- a/public/debug-sessions.html +++ b/public/debug-sessions.html @@ -140,6 +140,79 @@

Multi-session debug harness

// Cleanup localStorage.removeItem(_indexKey(cf)); + + // Section: sessionStorage writes + reconcile + // Inline JS port for harness verification (keep in sync with src/state/sessionStorage.ts) + const _isQuotaError = (e) => { + if (!(e instanceof Error)) return false; + return e.name === 'QuotaExceededError' || e.name === 'NS_ERROR_DOM_QUOTA_REACHED' || e.code === 22; + }; + class _StorageQuotaError extends Error { + constructor() { super('localStorage quota exceeded'); this.name = 'StorageQuotaError'; } + } + const _safeWrite = (key, value) => { + try { localStorage.setItem(key, value); } + catch (e) { if (_isQuotaError(e)) throw new _StorageQuotaError(); throw e; } + }; + const _capWarnedKey = (cf) => `${cf}_EXTERNAL_capWarned`; + const writeIndex = (cf, idx) => _safeWrite(_indexKey(cf), JSON.stringify(idx)); + const writeMessages = (cf, id, msgs) => _safeWrite(_msgKey(cf, id), JSON.stringify(msgs)); + const removeMessages = (cf, id) => localStorage.removeItem(_msgKey(cf, id)); + const reconcileOrphans = (cf, idx) => { + const indexIds = new Set(idx.sessions.map((s) => s.chatId)); + const prefix = `${cf}_EXTERNAL_msgs_`; + const deletedOrphans = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (!k || !k.startsWith(prefix)) continue; + const chatId = k.slice(prefix.length); + if (!indexIds.has(chatId)) { localStorage.removeItem(k); deletedOrphans.push(chatId); i--; } + } + const missingMsgKeys = []; + for (const s of idx.sessions) { + if (localStorage.getItem(_msgKey(cf, s.chatId)) === null) missingMsgKeys.push(s.chatId); + } + return { deletedOrphans, missingMsgKeys }; + }; + + const cf2 = '__test_cf2_' + Date.now(); + + // Round-trip + const idx = { + version: 2, + activeChatId: 'a', + sessions: [ + { chatId: 'a', title: 'A', createdAt: 1, updatedAt: 1 }, + { chatId: 'b', title: 'B', createdAt: 2, updatedAt: 2 }, + ], + }; + writeIndex(cf2, idx); + writeMessages(cf2, 'a', [{ type: 'userMessage', message: 'hi' }]); + writeMessages(cf2, 'b', [{ type: 'userMessage', message: 'yo' }]); + + const got2 = readIndex(cf2); + __assert(got2 && got2.sessions.length === 2, 'index write+read round-trip'); + __assert(readMessages(cf2, 'a').length === 1, 'messages a round-trip'); + __assert(readMessages(cf2, 'b').length === 1, 'messages b round-trip'); + + // Orphan: write a MsgKey for a chatId not in the index + writeMessages(cf2, 'orphan-id', [{ type: 'userMessage', message: 'lost' }]); + const result1 = reconcileOrphans(cf2, idx); + __assert(result1.deletedOrphans.includes('orphan-id'), 'orphan MsgKey detected and deleted'); + __assert(readMessages(cf2, 'orphan-id').length === 0, 'orphan MsgKey actually removed'); + + // Missing: index has 'c' but no MsgKey + const idxWithMissing = { + ...idx, + sessions: [...idx.sessions, { chatId: 'c', title: 'C', createdAt: 3, updatedAt: 3 }], + }; + const result2 = reconcileOrphans(cf2, idxWithMissing); + __assert(result2.missingMsgKeys.includes('c'), 'missing MsgKey detected'); + + // Cleanup + removeMessages(cf2, 'a'); + removeMessages(cf2, 'b'); + localStorage.removeItem(`${cf2}_EXTERNAL`); diff --git a/src/state/sessionStorage.ts b/src/state/sessionStorage.ts index 32b16a689..4fa2c52ef 100644 --- a/src/state/sessionStorage.ts +++ b/src/state/sessionStorage.ts @@ -51,3 +51,86 @@ export const readCapWarned = (chatflowid: string): boolean => { }; export const _internalKeys = { indexKey, msgKey, capWarnedKey, panelCollapsedKey }; + +export class StorageQuotaError extends Error { + constructor() { + super('localStorage quota exceeded'); + this.name = 'StorageQuotaError'; + } +} + +const isQuotaError = (e: unknown): boolean => { + if (!(e instanceof Error)) return false; + return ( + e.name === 'QuotaExceededError' || + e.name === 'NS_ERROR_DOM_QUOTA_REACHED' || + (e as { code?: number }).code === 22 + ); +}; + +const safeWrite = (key: string, value: string) => { + try { + localStorage.setItem(key, value); + } catch (e) { + if (isQuotaError(e)) throw new StorageQuotaError(); + throw e; + } +}; + +export const writeIndex = (chatflowid: string, index: ChatflowIndexV2): void => { + safeWrite(indexKey(chatflowid), JSON.stringify(index)); +}; + +export const writeMessages = ( + chatflowid: string, + chatId: string, + messages: MessageType[], +): void => { + safeWrite(msgKey(chatflowid, chatId), JSON.stringify(messages)); +}; + +export const removeMessages = (chatflowid: string, chatId: string): void => { + localStorage.removeItem(msgKey(chatflowid, chatId)); +}; + +export const writePanelCollapsed = (chatflowid: string, collapsed: boolean): void => { + safeWrite(panelCollapsedKey(chatflowid), collapsed ? '1' : '0'); +}; + +export const writeCapWarned = (chatflowid: string): void => { + safeWrite(capWarnedKey(chatflowid), '1'); +}; + +/** + * Reconcile MsgKey orphans against an Index. + * - Returns chatIds whose MsgKey was deleted (orphans, not in index). + * - Returns chatIds in index that have no MsgKey (caller should seed empty). + */ +export const reconcileOrphans = ( + chatflowid: string, + index: ChatflowIndexV2, +): { deletedOrphans: string[]; missingMsgKeys: string[] } => { + const indexIds = new Set(index.sessions.map((s) => s.chatId)); + const prefix = `${chatflowid}_EXTERNAL_msgs_`; + + const deletedOrphans: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (!k || !k.startsWith(prefix)) continue; + const chatId = k.slice(prefix.length); + if (!indexIds.has(chatId)) { + localStorage.removeItem(k); + deletedOrphans.push(chatId); + i--; // length shrunk + } + } + + const missingMsgKeys: string[] = []; + for (const s of index.sessions) { + if (localStorage.getItem(msgKey(chatflowid, s.chatId)) === null) { + missingMsgKeys.push(s.chatId); + } + } + + return { deletedOrphans, missingMsgKeys }; +}; From f6f23c0fbd961e6e289137674344f21707b927ac Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Thu, 30 Apr 2026 10:23:32 -0700 Subject: [PATCH 13/66] =?UTF-8?q?feat(sessions):=20add=20v1=E2=86=92v2=20i?= =?UTF-8?q?n-place=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/debug-sessions.html | 107 ++++++++++++++++++++++++++++++++++ src/state/sessionMigration.ts | 103 ++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 src/state/sessionMigration.ts diff --git a/public/debug-sessions.html b/public/debug-sessions.html index bc714e875..0700cf1ba 100644 --- a/public/debug-sessions.html +++ b/public/debug-sessions.html @@ -213,6 +213,113 @@

Multi-session debug harness

removeMessages(cf2, 'a'); removeMessages(cf2, 'b'); localStorage.removeItem(`${cf2}_EXTERNAL`); + + // Section: migration + // Inline JS port for harness verification (keep in sync with src/state/sessionMigration.ts) + const _isV1Shape = (raw) => { + if (!raw || typeof raw !== 'object') return false; + if ('version' in raw) return false; + return typeof raw.chatId === 'string' || Array.isArray(raw.chatHistory); + }; + + const loadOrMigrate = (cf, newChatId) => { + const raw = localStorage.getItem(_indexKey(cf)); + const writeFresh = () => { + const id = newChatId(); + const fresh = { + version: 2, + activeChatId: id, + sessions: [{ chatId: id, title: 'New chat', createdAt: Date.now(), updatedAt: Date.now() }], + }; + writeIndex(cf, fresh); + writeMessages(cf, id, []); + return fresh; + }; + if (raw === null) return writeFresh(); + let parsed; + try { parsed = JSON.parse(raw); } catch { return writeFresh(); } + if (parsed && typeof parsed === 'object' && parsed.version === 2) return parsed; + if (_isV1Shape(parsed)) { + const chatId = parsed.chatId ?? newChatId(); + const messages = parsed.chatHistory ?? []; + const now = Date.now(); + const title = titleFromMessage(messages) ?? 'Previous chat'; + const v2 = { + version: 2, + activeChatId: chatId, + sessions: [{ chatId, title, createdAt: now, updatedAt: now }], + lead: parsed.lead, + }; + writeIndex(cf, v2); + writeMessages(cf, chatId, messages); + return v2; + } + // unknown shape — fresh in memory, do NOT clobber + const id = newChatId(); + return { + version: 2, + activeChatId: id, + sessions: [{ chatId: id, title: 'New chat', createdAt: Date.now(), updatedAt: Date.now() }], + }; + }; + + const mkId = () => 'mig-' + Math.random().toString(16).slice(2, 8); + const cf3 = '__test_cf3_' + Date.now(); + + // Empty → fresh v2 + const r1 = loadOrMigrate(cf3, mkId); + __assert(r1.version === 2 && r1.sessions.length === 1, 'empty → fresh v2 with one session'); + __assert(r1.sessions[0].title === 'New chat', 'fresh session titled "New chat"'); + localStorage.removeItem(`${cf3}_EXTERNAL`); + + // v1 with chatHistory → migrate + localStorage.setItem( + `${cf3}_EXTERNAL`, + JSON.stringify({ + chatId: 'old-1', + chatHistory: [ + { type: 'userMessage', message: 'How do I export to CSV?' }, + { type: 'apiMessage', message: 'You can use…' }, + ], + lead: { email: 'a@b.com' }, + }), + ); + const r2 = loadOrMigrate(cf3, mkId); + __assert(r2.activeChatId === 'old-1', 'v1 chatId carried over'); + __assert(r2.sessions[0].title.startsWith('How do I export'), 'title derived from first user msg'); + __assert(r2.lead && r2.lead.email === 'a@b.com', 'lead preserved'); + __assert(readMessages(cf3, 'old-1').length === 2, 'chatHistory written to MsgKey'); + __assert(readIndex(cf3)?.version === 2, 'storage now contains v2'); + localStorage.removeItem(`${cf3}_EXTERNAL`); + localStorage.removeItem(`${cf3}_EXTERNAL_msgs_old-1`); + + // v1 with empty chatHistory → "Previous chat" + localStorage.setItem(`${cf3}_EXTERNAL`, JSON.stringify({ chatId: 'p', chatHistory: [] })); + const r3 = loadOrMigrate(cf3, mkId); + __assert(r3.sessions[0].title === 'Previous chat', 'empty v1 history → "Previous chat"'); + localStorage.removeItem(`${cf3}_EXTERNAL`); + localStorage.removeItem(`${cf3}_EXTERNAL_msgs_p`); + + // v2 idempotent + const v2idx = { + version: 2, + activeChatId: 'a', + sessions: [{ chatId: 'a', title: 'kept', createdAt: 9, updatedAt: 9 }], + }; + localStorage.setItem(`${cf3}_EXTERNAL`, JSON.stringify(v2idx)); + const r4 = loadOrMigrate(cf3, mkId); + __assert(r4.activeChatId === 'a' && r4.sessions[0].title === 'kept', 'v2 idempotent'); + localStorage.removeItem(`${cf3}_EXTERNAL`); + + // Unknown shape → does NOT clobber + localStorage.setItem(`${cf3}_EXTERNAL`, JSON.stringify({ totally: 'unknown' })); + const r5 = loadOrMigrate(cf3, mkId); + __assert(r5.version === 2, 'unknown shape → fresh in-memory v2'); + __assert( + JSON.parse(localStorage.getItem(`${cf3}_EXTERNAL`)).totally === 'unknown', + 'unknown shape NOT overwritten in storage', + ); + localStorage.removeItem(`${cf3}_EXTERNAL`); diff --git a/src/state/sessionMigration.ts b/src/state/sessionMigration.ts new file mode 100644 index 000000000..30c43a788 --- /dev/null +++ b/src/state/sessionMigration.ts @@ -0,0 +1,103 @@ +import type { MessageType } from '@/components/Bot'; +import { + type ChatflowIndexV2, + type LeadCaptureData, + readMessages, + writeIndex, + writeMessages, +} from './sessionStorage'; +import { titleFromMessage } from '@/utils/titleFromMessage'; +import { v4 as uuidv4 } from 'uuid'; + +type RawV1 = { + chatId?: string; + chatHistory?: MessageType[]; + lead?: LeadCaptureData; +}; + +const indexKey = (chatflowid: string) => `${chatflowid}_EXTERNAL`; + +const isV1Shape = (raw: unknown): raw is RawV1 => { + if (!raw || typeof raw !== 'object') return false; + const r = raw as Record; + if ('version' in r) return false; + return typeof r.chatId === 'string' || Array.isArray(r.chatHistory); +}; + +/** + * Read whatever is at localStorage[chatflowid_EXTERNAL] and return a v2 index. + * - v2 already → returned as-is. + * - v1 shape → wrapped into a single session, written back to storage, returned. + * - unknown shape → log warning, return a fresh v2 (does not clobber). + * - missing → fresh v2 with one empty session. + * + * Pass `newChatId` so callers can plumb in their `customerId+uuid` prefix. + */ +export const loadOrMigrate = ( + chatflowid: string, + newChatId: () => string, +): ChatflowIndexV2 => { + const raw = localStorage.getItem(indexKey(chatflowid)); + + // No entry → fresh + if (raw === null) { + const id = newChatId(); + const fresh: ChatflowIndexV2 = { + version: 2, + activeChatId: id, + sessions: [{ chatId: id, title: 'New chat', createdAt: Date.now(), updatedAt: Date.now() }], + }; + writeIndex(chatflowid, fresh); + writeMessages(chatflowid, id, []); + return fresh; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + console.warn(`[sessions] could not parse ${indexKey(chatflowid)}; treating as fresh.`); + const id = newChatId(); + const fresh: ChatflowIndexV2 = { + version: 2, + activeChatId: id, + sessions: [{ chatId: id, title: 'New chat', createdAt: Date.now(), updatedAt: Date.now() }], + }; + writeIndex(chatflowid, fresh); + writeMessages(chatflowid, id, []); + return fresh; + } + + // v2 → return as-is + if (parsed && typeof parsed === 'object' && (parsed as ChatflowIndexV2).version === 2) { + return parsed as ChatflowIndexV2; + } + + // v1 → migrate + if (isV1Shape(parsed)) { + const v1 = parsed as RawV1; + const chatId = v1.chatId ?? newChatId(); + const messages = v1.chatHistory ?? []; + const now = Date.now(); + const title = titleFromMessage(messages) ?? 'Previous chat'; + + const v2: ChatflowIndexV2 = { + version: 2, + activeChatId: chatId, + sessions: [{ chatId, title, createdAt: now, updatedAt: now }], + lead: v1.lead, + }; + writeIndex(chatflowid, v2); + writeMessages(chatflowid, chatId, messages); + return v2; + } + + // Unknown shape → log, do NOT clobber, return fresh in memory only + console.warn(`[sessions] unknown shape at ${indexKey(chatflowid)}; using fresh in-memory index.`); + const id = newChatId(); + return { + version: 2, + activeChatId: id, + sessions: [{ chatId: id, title: 'New chat', createdAt: Date.now(), updatedAt: Date.now() }], + }; +}; From 286d929b7dfd825f3e21faf6ae9f07491031f94e Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Thu, 30 Apr 2026 10:27:43 -0700 Subject: [PATCH 14/66] refactor(sessions): drop unused imports from sessionMigration Removed readMessages import from sessionStorage and uuid v4 import that were included speculatively but never used in the module. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/state/sessionMigration.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/state/sessionMigration.ts b/src/state/sessionMigration.ts index 30c43a788..0cfb83b85 100644 --- a/src/state/sessionMigration.ts +++ b/src/state/sessionMigration.ts @@ -2,12 +2,10 @@ import type { MessageType } from '@/components/Bot'; import { type ChatflowIndexV2, type LeadCaptureData, - readMessages, writeIndex, writeMessages, } from './sessionStorage'; import { titleFromMessage } from '@/utils/titleFromMessage'; -import { v4 as uuidv4 } from 'uuid'; type RawV1 = { chatId?: string; @@ -33,10 +31,7 @@ const isV1Shape = (raw: unknown): raw is RawV1 => { * * Pass `newChatId` so callers can plumb in their `customerId+uuid` prefix. */ -export const loadOrMigrate = ( - chatflowid: string, - newChatId: () => string, -): ChatflowIndexV2 => { +export const loadOrMigrate = (chatflowid: string, newChatId: () => string): ChatflowIndexV2 => { const raw = localStorage.getItem(indexKey(chatflowid)); // No entry → fresh From e0f3db13a2a1a62af601f79fe46849717ecb90c3 Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Thu, 30 Apr 2026 10:29:54 -0700 Subject: [PATCH 15/66] feat(sessions): scaffold sessionStore with init + selectors --- public/debug-sessions.html | 9 +++++ src/state/sessionStore.ts | 73 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 src/state/sessionStore.ts diff --git a/public/debug-sessions.html b/public/debug-sessions.html index 0700cf1ba..b3f70afb9 100644 --- a/public/debug-sessions.html +++ b/public/debug-sessions.html @@ -320,6 +320,15 @@

Multi-session debug harness

'unknown shape NOT overwritten in storage', ); localStorage.removeItem(`${cf3}_EXTERNAL`); + + // Section: sessionStore (no harness verification) + // The store requires Solid's reactive runtime (createSignal, createMemo, batch). + // The harness's inline-port pattern can't easily mock Solid, so the store is + // verified via integration tests in later tasks: Bot.tsx wiring (Task 16-17), + // the demo page exercising new-chat / switch / rename / delete on Tasks 11-15. + // See spec Decision #13 — the follow-up Vitest spec will give us proper + // component-level coverage. + __assert(true, 'sessionStore — verification deferred to integration (Task 11+)'); diff --git a/src/state/sessionStore.ts b/src/state/sessionStore.ts new file mode 100644 index 000000000..1dfd673e3 --- /dev/null +++ b/src/state/sessionStore.ts @@ -0,0 +1,73 @@ +import { createSignal, createMemo } from 'solid-js'; +import type { MessageType } from '@/components/Bot'; +import { + type ChatflowIndexV2, + type SessionV2, + readMessages, + reconcileOrphans, + writeIndex, + writeMessages, +} from './sessionStorage'; +import { loadOrMigrate } from './sessionMigration'; + +const DEFAULT_MAX_SESSIONS = 50; + +export type SessionStoreOptions = { + chatflowid: string; + newChatId: () => string; + maxSessions?: number; +}; + +export type SessionStore = ReturnType; + +export const createSessionStore = (opts: SessionStoreOptions) => { + const { chatflowid, newChatId } = opts; + const maxSessions = opts.maxSessions ?? DEFAULT_MAX_SESSIONS; + + // ---- init ---- + const initial = loadOrMigrate(chatflowid, newChatId); + const reconcile = reconcileOrphans(chatflowid, initial); + for (const id of reconcile.missingMsgKeys) writeMessages(chatflowid, id, []); + + const [index, setIndex] = createSignal(initial); + + // Lazy in-memory cache: chatId → messages. Populated on read. + const messageCache = new Map(); + messageCache.set(initial.activeChatId, readMessages(chatflowid, initial.activeChatId)); + const [activeMessages, setActiveMessages] = createSignal( + messageCache.get(initial.activeChatId)!, + ); + + // ---- selectors ---- + const sessions = createMemo(() => + [...index().sessions].sort((a, b) => b.updatedAt - a.updatedAt), + ); + const activeChatId = createMemo(() => index().activeChatId); + const activeSession = createMemo(() => + index().sessions.find((s) => s.chatId === activeChatId()), + ); + const lead = createMemo(() => index().lead); + + // ---- internal helpers (used by Task 6) ---- + const _persistIndex = (next: ChatflowIndexV2) => { + writeIndex(chatflowid, next); + setIndex(next); + }; + + return { + chatflowid, + maxSessions, + sessions, + activeChatId, + activeSession, + activeMessages, + lead, + _internal: { + index, + setIndex, + messageCache, + setActiveMessages, + persistIndex: _persistIndex, + }, + }; +}; From 2720fe40d3c97c1edcc64062f0d39cdb4ef7fb39 Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Thu, 30 Apr 2026 17:01:01 -0700 Subject: [PATCH 16/66] feat(sessions): add store actions (new/switch/upsert/rename/delete) --- public/debug-sessions.html | 5 + src/state/sessionStore.ts | 198 +++++++++++++++++++++++++++++++++---- 2 files changed, 185 insertions(+), 18 deletions(-) diff --git a/public/debug-sessions.html b/public/debug-sessions.html index b3f70afb9..943cebafb 100644 --- a/public/debug-sessions.html +++ b/public/debug-sessions.html @@ -329,6 +329,11 @@

Multi-session debug harness

// See spec Decision #13 — the follow-up Vitest spec will give us proper // component-level coverage. __assert(true, 'sessionStore — verification deferred to integration (Task 11+)'); + + // Section: sessionStore actions (no harness verification) + // Same reasoning as Task 5: the actions are reactive Solid wiring; integration + // verification happens once the store is wired into Bot.tsx and the demo page. + __assert(true, 'sessionStore actions — verification deferred to integration'); diff --git a/src/state/sessionStore.ts b/src/state/sessionStore.ts index 1dfd673e3..d63a2d47e 100644 --- a/src/state/sessionStore.ts +++ b/src/state/sessionStore.ts @@ -1,14 +1,8 @@ -import { createSignal, createMemo } from 'solid-js'; +import { createSignal, createMemo, batch } from 'solid-js'; import type { MessageType } from '@/components/Bot'; -import { - type ChatflowIndexV2, - type SessionV2, - readMessages, - reconcileOrphans, - writeIndex, - writeMessages, -} from './sessionStorage'; +import { type ChatflowIndexV2, type SessionV2, type LeadCaptureData, readMessages, reconcileOrphans, writeIndex, writeMessages } from './sessionStorage'; import { loadOrMigrate } from './sessionMigration'; +import { titleFromMessage } from '@/utils/titleFromMessage'; const DEFAULT_MAX_SESSIONS = 50; @@ -34,18 +28,12 @@ export const createSessionStore = (opts: SessionStoreOptions) => { // Lazy in-memory cache: chatId → messages. Populated on read. const messageCache = new Map(); messageCache.set(initial.activeChatId, readMessages(chatflowid, initial.activeChatId)); - const [activeMessages, setActiveMessages] = createSignal( - messageCache.get(initial.activeChatId)!, - ); + const [activeMessages, setActiveMessages] = createSignal(messageCache.get(initial.activeChatId)!); // ---- selectors ---- - const sessions = createMemo(() => - [...index().sessions].sort((a, b) => b.updatedAt - a.updatedAt), - ); + const sessions = createMemo(() => [...index().sessions].sort((a, b) => b.updatedAt - a.updatedAt)); const activeChatId = createMemo(() => index().activeChatId); - const activeSession = createMemo(() => - index().sessions.find((s) => s.chatId === activeChatId()), - ); + const activeSession = createMemo(() => index().sessions.find((s) => s.chatId === activeChatId())); const lead = createMemo(() => index().lead); // ---- internal helpers (used by Task 6) ---- @@ -54,6 +42,171 @@ export const createSessionStore = (opts: SessionStoreOptions) => { setIndex(next); }; + // ---- actions ---- + const newChat = (): string => { + const id = newChatId(); + const now = Date.now(); + const session: SessionV2 = { + chatId: id, + title: 'New chat', + createdAt: now, + updatedAt: now, + }; + writeMessages(chatflowid, id, []); + messageCache.set(id, []); + + const evicted: string[] = []; + batch(() => { + const next: ChatflowIndexV2 = { + ...index(), + activeChatId: id, + sessions: [session, ...index().sessions], + }; + + // Cap eviction (silent FIFO; toast is wired in Task 11). + while (next.sessions.length > maxSessions) { + // Find lowest updatedAt that ISN'T the new active. + let oldestIdx = -1; + let oldestAt = Infinity; + for (let i = 0; i < next.sessions.length; i++) { + const s = next.sessions[i]; + if (s.chatId === id) continue; + if (s.updatedAt < oldestAt) { + oldestAt = s.updatedAt; + oldestIdx = i; + } + } + if (oldestIdx === -1) break; + const removed = next.sessions.splice(oldestIdx, 1)[0]; + evicted.push(removed.chatId); + } + + _persistIndex(next); + setActiveMessages([]); + }); + + for (const eid of evicted) { + localStorage.removeItem(`${chatflowid}_EXTERNAL_msgs_${eid}`); + messageCache.delete(eid); + } + + return id; + }; + + const switchSession = (chatId: string): void => { + if (chatId === activeChatId()) return; + const exists = index().sessions.some((s) => s.chatId === chatId); + if (!exists) return; + let messages = messageCache.get(chatId); + if (!messages) { + messages = readMessages(chatflowid, chatId); + messageCache.set(chatId, messages); + } + batch(() => { + _persistIndex({ ...index(), activeChatId: chatId }); + setActiveMessages(messages!); + }); + }; + + /** + * Append or replace a message in the active session. + * If `messageId` is provided and matches an existing message, that message is + * replaced (used for streaming token updates). Otherwise the message is appended. + * Persists with a 150ms debounce on MsgKey writes. + */ + let pendingPersist: ReturnType | null = null; + const flushPending = () => { + if (pendingPersist === null) return; + clearTimeout(pendingPersist); + pendingPersist = null; + const id = activeChatId(); + const msgs = messageCache.get(id); + if (msgs) writeMessages(chatflowid, id, msgs); + }; + + const upsertMessage = (msg: MessageType): void => { + const id = activeChatId(); + const cached = messageCache.get(id) ?? []; + let next: MessageType[]; + const existingIdx = + msg.messageId !== undefined ? cached.findIndex((m) => m.messageId === msg.messageId) : -1; + if (existingIdx >= 0) { + next = [...cached]; + next[existingIdx] = msg; + } else { + next = [...cached, msg]; + } + messageCache.set(id, next); + setActiveMessages(next); + + // Debounce MsgKey writes for streaming. + if (pendingPersist !== null) clearTimeout(pendingPersist); + pendingPersist = setTimeout(() => { + pendingPersist = null; + writeMessages(chatflowid, id, next); + }, 150); + + // Bump session.updatedAt and (if first user msg) auto-title. Index writes are cheap. + const isFirstUserMsg = + msg.type === 'userMessage' && next.filter((m) => m.type === 'userMessage').length === 1; + const current = index(); + const sIdx = current.sessions.findIndex((s) => s.chatId === id); + if (sIdx < 0) return; + const session = current.sessions[sIdx]; + let nextSession: SessionV2 = { ...session, updatedAt: Date.now() }; + if (isFirstUserMsg && session.title === 'New chat') { + const t = titleFromMessage(next); + if (t) nextSession = { ...nextSession, title: t }; + } + const sessions = [...current.sessions]; + sessions[sIdx] = nextSession; + _persistIndex({ ...current, sessions }); + }; + + const renameSession = (chatId: string, rawTitle: string): void => { + const trimmed = rawTitle.trim().slice(0, 80); + const current = index(); + const sIdx = current.sessions.findIndex((s) => s.chatId === chatId); + if (sIdx < 0) return; + let nextTitle = trimmed; + if (nextTitle.length === 0) { + const cached = messageCache.get(chatId) ?? readMessages(chatflowid, chatId); + nextTitle = titleFromMessage(cached) ?? 'New chat'; + } + const sessions = [...current.sessions]; + sessions[sIdx] = { ...sessions[sIdx], title: nextTitle }; + _persistIndex({ ...current, sessions }); + }; + + const deleteSession = (chatId: string): void => { + const current = index(); + const sessions = current.sessions.filter((s) => s.chatId !== chatId); + localStorage.removeItem(`${chatflowid}_EXTERNAL_msgs_${chatId}`); + messageCache.delete(chatId); + + if (sessions.length === 0) { + // Last session deleted → seed a fresh one. + _persistIndex({ ...current, sessions: [] }); + newChat(); + return; + } + + let nextActive = current.activeChatId; + if (nextActive === chatId) { + // Pick most recently updated remaining session. + const sortedByRecent = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt); + nextActive = sortedByRecent[0].chatId; + const cached = messageCache.get(nextActive) ?? readMessages(chatflowid, nextActive); + messageCache.set(nextActive, cached); + setActiveMessages(cached); + } + _persistIndex({ ...current, activeChatId: nextActive, sessions }); + }; + + const setLead = (lead: LeadCaptureData | undefined): void => { + _persistIndex({ ...index(), lead }); + }; + return { chatflowid, maxSessions, @@ -62,6 +215,15 @@ export const createSessionStore = (opts: SessionStoreOptions) => { activeSession, activeMessages, lead, + actions: { + newChat, + switchSession, + upsertMessage, + renameSession, + deleteSession, + setLead, + flushPending, + }, _internal: { index, setIndex, From 47ffcdce2e21325410d4c566e8fe15820c1ee2bf Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Thu, 30 Apr 2026 17:10:37 -0700 Subject: [PATCH 17/66] feat(sessions): add emergency eviction on QuotaExceededError --- src/state/sessionStore.ts | 77 +++++++++++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 15 deletions(-) diff --git a/src/state/sessionStore.ts b/src/state/sessionStore.ts index d63a2d47e..fccd78c73 100644 --- a/src/state/sessionStore.ts +++ b/src/state/sessionStore.ts @@ -1,6 +1,14 @@ import { createSignal, createMemo, batch } from 'solid-js'; import type { MessageType } from '@/components/Bot'; -import { type ChatflowIndexV2, type SessionV2, type LeadCaptureData, readMessages, reconcileOrphans, writeIndex, writeMessages } from './sessionStorage'; +import { + type ChatflowIndexV2, + type SessionV2, + type LeadCaptureData, + readMessages, + reconcileOrphans, + writeIndex, + writeMessages, +} from './sessionStorage'; import { loadOrMigrate } from './sessionMigration'; import { titleFromMessage } from '@/utils/titleFromMessage'; @@ -42,6 +50,46 @@ export const createSessionStore = (opts: SessionStoreOptions) => { setIndex(next); }; + /** + * Run a write op; on QuotaExceededError, evict the oldest non-active session and retry. + * Up to `attempts` retries; if it still fails, surfaces a callback to show a toast. + */ + let onQuotaPanic: (() => void) | null = null; + const setQuotaPanicHandler = (cb: () => void) => { + onQuotaPanic = cb; + }; + + const withQuotaRecovery = (op: () => void) => { + let attempt = 0; + while (attempt < 5) { + try { + op(); + return; + } catch (e) { + if (!(e as Error)?.name?.includes('Quota') && !(e instanceof Error && e.message.includes('quota'))) throw e; + // Evict oldest non-active session. + const cur = index(); + const candidates = cur.sessions + .filter((s) => s.chatId !== cur.activeChatId) + .sort((a, b) => a.updatedAt - b.updatedAt); + if (candidates.length === 0) break; + const victim = candidates[0]; + localStorage.removeItem(`${chatflowid}_EXTERNAL_msgs_${victim.chatId}`); + messageCache.delete(victim.chatId); + const sessions = cur.sessions.filter((s) => s.chatId !== victim.chatId); + // Best-effort persist of pruned index (this might also throw — counts as an attempt). + try { + writeIndex(chatflowid, { ...cur, sessions }); + setIndex({ ...cur, sessions }); + } catch { + // ignore; loop will retry op anyway + } + attempt++; + } + } + if (onQuotaPanic) onQuotaPanic(); + }; + // ---- actions ---- const newChat = (): string => { const id = newChatId(); @@ -52,7 +100,7 @@ export const createSessionStore = (opts: SessionStoreOptions) => { createdAt: now, updatedAt: now, }; - writeMessages(chatflowid, id, []); + withQuotaRecovery(() => writeMessages(chatflowid, id, [])); messageCache.set(id, []); const evicted: string[] = []; @@ -81,7 +129,7 @@ export const createSessionStore = (opts: SessionStoreOptions) => { evicted.push(removed.chatId); } - _persistIndex(next); + withQuotaRecovery(() => _persistIndex(next)); setActiveMessages([]); }); @@ -103,7 +151,7 @@ export const createSessionStore = (opts: SessionStoreOptions) => { messageCache.set(chatId, messages); } batch(() => { - _persistIndex({ ...index(), activeChatId: chatId }); + withQuotaRecovery(() => _persistIndex({ ...index(), activeChatId: chatId })); setActiveMessages(messages!); }); }; @@ -121,15 +169,14 @@ export const createSessionStore = (opts: SessionStoreOptions) => { pendingPersist = null; const id = activeChatId(); const msgs = messageCache.get(id); - if (msgs) writeMessages(chatflowid, id, msgs); + if (msgs) withQuotaRecovery(() => writeMessages(chatflowid, id, msgs)); }; const upsertMessage = (msg: MessageType): void => { const id = activeChatId(); const cached = messageCache.get(id) ?? []; let next: MessageType[]; - const existingIdx = - msg.messageId !== undefined ? cached.findIndex((m) => m.messageId === msg.messageId) : -1; + const existingIdx = msg.messageId !== undefined ? cached.findIndex((m) => m.messageId === msg.messageId) : -1; if (existingIdx >= 0) { next = [...cached]; next[existingIdx] = msg; @@ -143,12 +190,11 @@ export const createSessionStore = (opts: SessionStoreOptions) => { if (pendingPersist !== null) clearTimeout(pendingPersist); pendingPersist = setTimeout(() => { pendingPersist = null; - writeMessages(chatflowid, id, next); + withQuotaRecovery(() => writeMessages(chatflowid, id, next)); }, 150); // Bump session.updatedAt and (if first user msg) auto-title. Index writes are cheap. - const isFirstUserMsg = - msg.type === 'userMessage' && next.filter((m) => m.type === 'userMessage').length === 1; + const isFirstUserMsg = msg.type === 'userMessage' && next.filter((m) => m.type === 'userMessage').length === 1; const current = index(); const sIdx = current.sessions.findIndex((s) => s.chatId === id); if (sIdx < 0) return; @@ -160,7 +206,7 @@ export const createSessionStore = (opts: SessionStoreOptions) => { } const sessions = [...current.sessions]; sessions[sIdx] = nextSession; - _persistIndex({ ...current, sessions }); + withQuotaRecovery(() => _persistIndex({ ...current, sessions })); }; const renameSession = (chatId: string, rawTitle: string): void => { @@ -175,7 +221,7 @@ export const createSessionStore = (opts: SessionStoreOptions) => { } const sessions = [...current.sessions]; sessions[sIdx] = { ...sessions[sIdx], title: nextTitle }; - _persistIndex({ ...current, sessions }); + withQuotaRecovery(() => _persistIndex({ ...current, sessions })); }; const deleteSession = (chatId: string): void => { @@ -186,7 +232,7 @@ export const createSessionStore = (opts: SessionStoreOptions) => { if (sessions.length === 0) { // Last session deleted → seed a fresh one. - _persistIndex({ ...current, sessions: [] }); + withQuotaRecovery(() => _persistIndex({ ...current, sessions: [] })); newChat(); return; } @@ -200,11 +246,11 @@ export const createSessionStore = (opts: SessionStoreOptions) => { messageCache.set(nextActive, cached); setActiveMessages(cached); } - _persistIndex({ ...current, activeChatId: nextActive, sessions }); + withQuotaRecovery(() => _persistIndex({ ...current, activeChatId: nextActive, sessions })); }; const setLead = (lead: LeadCaptureData | undefined): void => { - _persistIndex({ ...index(), lead }); + withQuotaRecovery(() => _persistIndex({ ...index(), lead })); }; return { @@ -223,6 +269,7 @@ export const createSessionStore = (opts: SessionStoreOptions) => { deleteSession, setLead, flushPending, + setQuotaPanicHandler, }, _internal: { index, From 16e39d0ea6c2caa650663b590e56bf10ca254c7a Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Thu, 30 Apr 2026 17:15:43 -0700 Subject: [PATCH 18/66] feat(sessions): add MultiSessionConfig to BotProps and sessionPanel theme keys --- src/components/Bot.tsx | 10 ++- src/features/bubble/types.ts | 15 +++++ src/features/full/types.ts | 118 +++++++++++++++++++++++++++++++++++ src/features/popup/types.ts | 115 +++++++++++++++++++++++++++++++++- 4 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 src/features/full/types.ts diff --git a/src/components/Bot.tsx b/src/components/Bot.tsx index 9e7b76d27..a4ec5a0cc 100644 --- a/src/components/Bot.tsx +++ b/src/components/Bot.tsx @@ -149,6 +149,11 @@ type IUploads = { type observerConfigType = (accessor: string | boolean | object | MessageType[]) => void; export type observersConfigType = Record<'observeUserInput' | 'observeLoading' | 'observeMessages', observerConfigType>; +export type MultiSessionConfig = { + enabled: boolean; + maxSessions?: number; +}; + export type BotProps = { chatflowid: string; apiHost?: string; @@ -188,6 +193,7 @@ export type BotProps = { closeBot?: () => void; hasCustomHeader?: boolean; dialogContainer?: HTMLElement; + multiSession?: MultiSessionConfig; }; export type LeadsConfig = { @@ -579,7 +585,7 @@ export const Bot = (botProps: BotProps & { class?: string }) => { }); } - scrollToBottom() + scrollToBottom(); let isProgrammaticScroll = false; const handleScroll = () => { @@ -954,7 +960,7 @@ export const Bot = (botProps: BotProps & { class?: string }) => { if (typeof value === 'object') return Object.keys(value as Record).length === 0; return false; }; - + const shouldRemoveEmptyApiMessage = (message?: MessageType) => { if (!message || message.type !== 'apiMessage') return false; const payload = { diff --git a/src/features/bubble/types.ts b/src/features/bubble/types.ts index 692184b19..06d42a663 100644 --- a/src/features/bubble/types.ts +++ b/src/features/bubble/types.ts @@ -84,6 +84,21 @@ export type ChatWindowTheme = { dateTimeToggle?: DateTimeToggleTheme; renderHTML?: boolean; headerHtml?: string; + sessionPanel?: { + width?: string | number; + collapsedWidth?: string | number; + backgroundColor?: string; + textColor?: string; + activeBackgroundColor?: string; + activeTextColor?: string; + hoverBackgroundColor?: string; + borderColor?: string; + newChatButtonColor?: string; + newChatButtonTextColor?: string; + newChatLabel?: string; + emptyStateText?: string; + capWarningText?: string; + }; }; export type ButtonTheme = { diff --git a/src/features/full/types.ts b/src/features/full/types.ts new file mode 100644 index 000000000..ce00fc185 --- /dev/null +++ b/src/features/full/types.ts @@ -0,0 +1,118 @@ +export type FullParams = { + theme?: FullTheme; +}; + +export type FullTheme = { + chatWindow?: ChatWindowTheme; + disclaimer?: DisclaimerPopUpTheme; + customCSS?: string; + form?: FormTheme; +}; + +export type FormTheme = { + backgroundColor?: string; + textColor?: string; +}; + +export type TextInputTheme = { + backgroundColor?: string; + textColor?: string; + placeholder?: string; + sendButtonColor?: string; + maxChars?: number; + maxCharsWarningMessage?: string; + autoFocus?: boolean; + sendMessageSound?: boolean; + sendSoundLocation?: string; + receiveMessageSound?: boolean; + receiveSoundLocation?: string; + enableInputHistory?: boolean; +}; + +export type UserMessageTheme = { + backgroundColor?: string; + textColor?: string; + showAvatar?: boolean; + avatarSrc?: string; +}; + +export type BotMessageTheme = { + backgroundColor?: string; + textColor?: string; + showAvatar?: boolean; + avatarSrc?: string; +}; + +export type FooterTheme = { + showFooter?: boolean; + textColor?: string; + text?: string; + company?: string; + companyLink?: string; +}; + +export type FeedbackTheme = { + color?: string; +}; + +export type ChatWindowTheme = { + showTitle?: boolean; + showAgentMessages?: boolean; // parameter to show agent reasonings when using agentflows + title?: string; + titleAvatarSrc?: string; + titleTextColor?: string; + titleBackgroundColor?: string; + welcomeMessage?: string; + errorMessage?: string; + backgroundColor?: string; + backgroundImage?: string; + height?: number | string; + width?: number | string; + fontSize?: number; + userMessage?: UserMessageTheme; + botMessage?: BotMessageTheme; + textInput?: TextInputTheme; + feedback?: FeedbackTheme; + footer?: FooterTheme; + sourceDocsTitle?: string; + poweredByTextColor?: string; + starterPrompts?: string[]; + starterPromptFontSize?: number; + clearChatOnReload?: boolean; + dateTimeToggle?: DateTimeToggleTheme; + renderHTML?: boolean; + headerHtml?: string; + sessionPanel?: { + width?: string | number; + collapsedWidth?: string | number; + backgroundColor?: string; + textColor?: string; + activeBackgroundColor?: string; + activeTextColor?: string; + hoverBackgroundColor?: string; + borderColor?: string; + newChatButtonColor?: string; + newChatButtonTextColor?: string; + newChatLabel?: string; + emptyStateText?: string; + capWarningText?: string; + }; +}; + +export type DisclaimerPopUpTheme = { + title?: string; + message?: string; + textColor?: string; + buttonColor?: string; + buttonTextColor?: string; + buttonText?: string; + blurredBackgroundColor?: string; + backgroundColor?: string; + denyButtonBgColor?: string; + denyButtonText?: string; +}; + +export type DateTimeToggleTheme = { + date?: boolean; + time?: boolean; +}; diff --git a/src/features/popup/types.ts b/src/features/popup/types.ts index ed14b9c03..3d21900bb 100644 --- a/src/features/popup/types.ts +++ b/src/features/popup/types.ts @@ -1,7 +1,118 @@ export type PopupParams = { autoShowDelay?: number; - theme?: { - width?: string; + theme?: PopupTheme; +}; + +export type PopupTheme = { + width?: string; + backgroundColor?: string; + chatWindow?: ChatWindowTheme; +}; + +export type FormTheme = { + backgroundColor?: string; + textColor?: string; +}; + +export type TextInputTheme = { + backgroundColor?: string; + textColor?: string; + placeholder?: string; + sendButtonColor?: string; + maxChars?: number; + maxCharsWarningMessage?: string; + autoFocus?: boolean; + sendMessageSound?: boolean; + sendSoundLocation?: string; + receiveMessageSound?: boolean; + receiveSoundLocation?: string; + enableInputHistory?: boolean; +}; + +export type UserMessageTheme = { + backgroundColor?: string; + textColor?: string; + showAvatar?: boolean; + avatarSrc?: string; +}; + +export type BotMessageTheme = { + backgroundColor?: string; + textColor?: string; + showAvatar?: boolean; + avatarSrc?: string; +}; + +export type FooterTheme = { + showFooter?: boolean; + textColor?: string; + text?: string; + company?: string; + companyLink?: string; +}; + +export type FeedbackTheme = { + color?: string; +}; + +export type ChatWindowTheme = { + showTitle?: boolean; + showAgentMessages?: boolean; // parameter to show agent reasonings when using agentflows + title?: string; + titleAvatarSrc?: string; + titleTextColor?: string; + titleBackgroundColor?: string; + welcomeMessage?: string; + errorMessage?: string; + backgroundColor?: string; + backgroundImage?: string; + height?: number | string; + width?: number | string; + fontSize?: number; + userMessage?: UserMessageTheme; + botMessage?: BotMessageTheme; + textInput?: TextInputTheme; + feedback?: FeedbackTheme; + footer?: FooterTheme; + sourceDocsTitle?: string; + poweredByTextColor?: string; + starterPrompts?: string[]; + starterPromptFontSize?: number; + clearChatOnReload?: boolean; + dateTimeToggle?: DateTimeToggleTheme; + renderHTML?: boolean; + headerHtml?: string; + sessionPanel?: { + width?: string | number; + collapsedWidth?: string | number; backgroundColor?: string; + textColor?: string; + activeBackgroundColor?: string; + activeTextColor?: string; + hoverBackgroundColor?: string; + borderColor?: string; + newChatButtonColor?: string; + newChatButtonTextColor?: string; + newChatLabel?: string; + emptyStateText?: string; + capWarningText?: string; }; }; + +export type DisclaimerPopUpTheme = { + title?: string; + message?: string; + textColor?: string; + buttonColor?: string; + buttonTextColor?: string; + buttonText?: string; + blurredBackgroundColor?: string; + backgroundColor?: string; + denyButtonBgColor?: string; + denyButtonText?: string; +}; + +export type DateTimeToggleTheme = { + date?: boolean; + time?: boolean; +}; From 7790a3c258277c04d2d93884aea855e9b6310c07 Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Thu, 30 Apr 2026 17:19:24 -0700 Subject: [PATCH 19/66] refactor(sessions): drop unused full/popup types files; keep bubble + BotProps additions Remove scope creep from Task 8: - Delete src/features/full/types.ts (118 lines, never imported) - Revert src/features/popup/types.ts to pre-Task-8 state (minimal PopupParams) Surviving Task 8 changes: - src/components/Bot.tsx: MultiSessionConfig type + multiSession field - src/features/bubble/types.ts: sessionPanel field in ChatWindowTheme (valid) Full and popup types were dead code; popup theme wiring will be added in Task 21. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/features/full/types.ts | 118 ------------------------------------ src/features/popup/types.ts | 115 +---------------------------------- 2 files changed, 2 insertions(+), 231 deletions(-) delete mode 100644 src/features/full/types.ts diff --git a/src/features/full/types.ts b/src/features/full/types.ts deleted file mode 100644 index ce00fc185..000000000 --- a/src/features/full/types.ts +++ /dev/null @@ -1,118 +0,0 @@ -export type FullParams = { - theme?: FullTheme; -}; - -export type FullTheme = { - chatWindow?: ChatWindowTheme; - disclaimer?: DisclaimerPopUpTheme; - customCSS?: string; - form?: FormTheme; -}; - -export type FormTheme = { - backgroundColor?: string; - textColor?: string; -}; - -export type TextInputTheme = { - backgroundColor?: string; - textColor?: string; - placeholder?: string; - sendButtonColor?: string; - maxChars?: number; - maxCharsWarningMessage?: string; - autoFocus?: boolean; - sendMessageSound?: boolean; - sendSoundLocation?: string; - receiveMessageSound?: boolean; - receiveSoundLocation?: string; - enableInputHistory?: boolean; -}; - -export type UserMessageTheme = { - backgroundColor?: string; - textColor?: string; - showAvatar?: boolean; - avatarSrc?: string; -}; - -export type BotMessageTheme = { - backgroundColor?: string; - textColor?: string; - showAvatar?: boolean; - avatarSrc?: string; -}; - -export type FooterTheme = { - showFooter?: boolean; - textColor?: string; - text?: string; - company?: string; - companyLink?: string; -}; - -export type FeedbackTheme = { - color?: string; -}; - -export type ChatWindowTheme = { - showTitle?: boolean; - showAgentMessages?: boolean; // parameter to show agent reasonings when using agentflows - title?: string; - titleAvatarSrc?: string; - titleTextColor?: string; - titleBackgroundColor?: string; - welcomeMessage?: string; - errorMessage?: string; - backgroundColor?: string; - backgroundImage?: string; - height?: number | string; - width?: number | string; - fontSize?: number; - userMessage?: UserMessageTheme; - botMessage?: BotMessageTheme; - textInput?: TextInputTheme; - feedback?: FeedbackTheme; - footer?: FooterTheme; - sourceDocsTitle?: string; - poweredByTextColor?: string; - starterPrompts?: string[]; - starterPromptFontSize?: number; - clearChatOnReload?: boolean; - dateTimeToggle?: DateTimeToggleTheme; - renderHTML?: boolean; - headerHtml?: string; - sessionPanel?: { - width?: string | number; - collapsedWidth?: string | number; - backgroundColor?: string; - textColor?: string; - activeBackgroundColor?: string; - activeTextColor?: string; - hoverBackgroundColor?: string; - borderColor?: string; - newChatButtonColor?: string; - newChatButtonTextColor?: string; - newChatLabel?: string; - emptyStateText?: string; - capWarningText?: string; - }; -}; - -export type DisclaimerPopUpTheme = { - title?: string; - message?: string; - textColor?: string; - buttonColor?: string; - buttonTextColor?: string; - buttonText?: string; - blurredBackgroundColor?: string; - backgroundColor?: string; - denyButtonBgColor?: string; - denyButtonText?: string; -}; - -export type DateTimeToggleTheme = { - date?: boolean; - time?: boolean; -}; diff --git a/src/features/popup/types.ts b/src/features/popup/types.ts index 3d21900bb..ed14b9c03 100644 --- a/src/features/popup/types.ts +++ b/src/features/popup/types.ts @@ -1,118 +1,7 @@ export type PopupParams = { autoShowDelay?: number; - theme?: PopupTheme; -}; - -export type PopupTheme = { - width?: string; - backgroundColor?: string; - chatWindow?: ChatWindowTheme; -}; - -export type FormTheme = { - backgroundColor?: string; - textColor?: string; -}; - -export type TextInputTheme = { - backgroundColor?: string; - textColor?: string; - placeholder?: string; - sendButtonColor?: string; - maxChars?: number; - maxCharsWarningMessage?: string; - autoFocus?: boolean; - sendMessageSound?: boolean; - sendSoundLocation?: string; - receiveMessageSound?: boolean; - receiveSoundLocation?: string; - enableInputHistory?: boolean; -}; - -export type UserMessageTheme = { - backgroundColor?: string; - textColor?: string; - showAvatar?: boolean; - avatarSrc?: string; -}; - -export type BotMessageTheme = { - backgroundColor?: string; - textColor?: string; - showAvatar?: boolean; - avatarSrc?: string; -}; - -export type FooterTheme = { - showFooter?: boolean; - textColor?: string; - text?: string; - company?: string; - companyLink?: string; -}; - -export type FeedbackTheme = { - color?: string; -}; - -export type ChatWindowTheme = { - showTitle?: boolean; - showAgentMessages?: boolean; // parameter to show agent reasonings when using agentflows - title?: string; - titleAvatarSrc?: string; - titleTextColor?: string; - titleBackgroundColor?: string; - welcomeMessage?: string; - errorMessage?: string; - backgroundColor?: string; - backgroundImage?: string; - height?: number | string; - width?: number | string; - fontSize?: number; - userMessage?: UserMessageTheme; - botMessage?: BotMessageTheme; - textInput?: TextInputTheme; - feedback?: FeedbackTheme; - footer?: FooterTheme; - sourceDocsTitle?: string; - poweredByTextColor?: string; - starterPrompts?: string[]; - starterPromptFontSize?: number; - clearChatOnReload?: boolean; - dateTimeToggle?: DateTimeToggleTheme; - renderHTML?: boolean; - headerHtml?: string; - sessionPanel?: { - width?: string | number; - collapsedWidth?: string | number; + theme?: { + width?: string; backgroundColor?: string; - textColor?: string; - activeBackgroundColor?: string; - activeTextColor?: string; - hoverBackgroundColor?: string; - borderColor?: string; - newChatButtonColor?: string; - newChatButtonTextColor?: string; - newChatLabel?: string; - emptyStateText?: string; - capWarningText?: string; }; }; - -export type DisclaimerPopUpTheme = { - title?: string; - message?: string; - textColor?: string; - buttonColor?: string; - buttonTextColor?: string; - buttonText?: string; - blurredBackgroundColor?: string; - backgroundColor?: string; - denyButtonBgColor?: string; - denyButtonText?: string; -}; - -export type DateTimeToggleTheme = { - date?: boolean; - time?: boolean; -}; From b49f6c5f0c14ccf5d19ae71ed070b3e4e1bef4d8 Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Thu, 30 Apr 2026 17:22:10 -0700 Subject: [PATCH 20/66] refactor(sessions): make setLocalStorageChatflow a field-merge wrapper over v2 index --- src/utils/index.ts | 76 +++++++++++++++++++++++++++++++++------------- 1 file changed, 55 insertions(+), 21 deletions(-) diff --git a/src/utils/index.ts b/src/utils/index.ts index 6db0debc3..f5c3e5857 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,5 @@ +import { readIndex, readMessages, writeIndex } from '@/state/sessionStorage'; + export const isNotDefined = (value: T | undefined | null): value is undefined | null => value === undefined || value === null; export const isDefined = (value: T | undefined | null): value is NonNullable => value !== undefined && value !== null; @@ -74,33 +76,65 @@ export const sendRequest = async ( } }; -export const setLocalStorageChatflow = (chatflowid: string, chatId: string, saveObj: Record = {}) => { - const chatDetails = localStorage.getItem(`${chatflowid}_EXTERNAL`); - const obj = { ...saveObj }; - if (chatId) obj.chatId = chatId; - - if (!chatDetails) { - localStorage.setItem(`${chatflowid}_EXTERNAL`, JSON.stringify(obj)); - } else { - try { - const parsedChatDetails = JSON.parse(chatDetails); - localStorage.setItem(`${chatflowid}_EXTERNAL`, JSON.stringify({ ...parsedChatDetails, ...obj })); - } catch (e) { - const chatId = chatDetails; - obj.chatId = chatId; - localStorage.setItem(`${chatflowid}_EXTERNAL`, JSON.stringify(obj)); +/** + * v1-compatible wrapper. Writes are field-level merges over the v2 index + * (and active-session messages where applicable), so callers writing + * `{ lead }` or `{ chatHistory }` don't clobber other v2 fields. + */ +export const setLocalStorageChatflow = ( + chatflowid: string, + chatId: string, + saveObj: Record = {}, +) => { + const idx = readIndex(chatflowid); + if (!idx) { + // No v2 yet: fall back to legacy single-key write so nothing breaks if + // the store hasn't initialized. The store will migrate on next mount. + const existingRaw = localStorage.getItem(`${chatflowid}_EXTERNAL`); + let existing: Record = {}; + if (existingRaw) { + try { + existing = JSON.parse(existingRaw); + } catch { + // ignore + } } + const merged = { ...existing, ...saveObj }; + if (chatId) merged.chatId = chatId; + localStorage.setItem(`${chatflowid}_EXTERNAL`, JSON.stringify(merged)); + return; } + + // v2 path: merge known fields. + const next = { ...idx }; + if ('lead' in saveObj) next.lead = saveObj.lead; + // chatHistory writes are no-ops on the v2 index (messages live elsewhere); the + // new write path is via store.upsertMessage. + writeIndex(chatflowid, next); }; +/** + * v1-compatible projection. Returns a v1-shaped object derived from the active + * session of the v2 index, so existing callers (notably the lead-capture path) + * keep working. + */ export const getLocalStorageChatflow = (chatflowid: string) => { - const chatDetails = localStorage.getItem(`${chatflowid}_EXTERNAL`); - if (!chatDetails) return {}; - try { - return JSON.parse(chatDetails); - } catch (e) { - return {}; + const idx = readIndex(chatflowid); + if (!idx) { + const raw = localStorage.getItem(`${chatflowid}_EXTERNAL`); + if (!raw) return {}; + try { + return JSON.parse(raw); + } catch { + return {}; + } } + const messages = readMessages(chatflowid, idx.activeChatId); + return { + chatId: idx.activeChatId, + chatHistory: messages, + lead: idx.lead, + }; }; export const removeLocalStorageChatHistory = (chatflowid: string) => { From 3bf9e4a73eeadd88ec35868aee11503b2339461a Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Thu, 30 Apr 2026 17:23:46 -0700 Subject: [PATCH 21/66] feat(sessions): add ChatRoot shell component --- src/components/index.ts | 1 + src/components/sessions/ChatRoot.tsx | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 src/components/sessions/ChatRoot.tsx diff --git a/src/components/index.ts b/src/components/index.ts index deb55f1e9..04eb8e31a 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,2 +1,3 @@ export * from './buttons/SendButton'; export * from './TypingBubble'; +export * from './sessions/ChatRoot'; diff --git a/src/components/sessions/ChatRoot.tsx b/src/components/sessions/ChatRoot.tsx new file mode 100644 index 000000000..41eff1727 --- /dev/null +++ b/src/components/sessions/ChatRoot.tsx @@ -0,0 +1,21 @@ +import { Show } from 'solid-js'; +import { Bot, type BotProps } from '@/components/Bot'; + +type ChatRootProps = BotProps & { class?: string }; + +/** + * Wraps with the session panel slot. When multiSession is disabled, + * renders directly. The panel itself is added in a later task. + */ +export const ChatRoot = (props: ChatRootProps) => { + const enabled = () => props.multiSession?.enabled === true; + + return ( + }> +
+
+ +
+ + ); +}; From d386d082eb5ec1650b4ade1b992264c2315cd344 Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Fri, 1 May 2026 09:31:30 -0700 Subject: [PATCH 22/66] fix(sessions): add multiSession to defaultBotProps so solid-element propagates it Task 8 added the type but missed the runtime schema; without this entry, Object.assign(element, props) sets multiSession on the host element but the SolidElement wrapper never forwards it to the underlying component, so ChatRoot's enabled() always evaluates false. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/constants.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/constants.ts b/src/constants.ts index ab5bf19a2..8d10b65be 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -8,6 +8,7 @@ export const defaultBotProps: BubbleProps = { theme: undefined, observersConfig: undefined, dialogContainer: undefined, + multiSession: undefined, }; export const CHAT_HEADER_HEIGHT = 50; From 842693bb2e61a5cb08818908f3c5dd7f6e936541 Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Fri, 1 May 2026 09:31:44 -0700 Subject: [PATCH 23/66] feat(sessions): render SessionPanel from ChatRoot with theme cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan Task 11. ChatRoot now creates the session store, lays out a side-by-side panel + Bot when multiSession is enabled, and forwards a derived sessionPanel theme. The theme prop on ChatRootProps is widened to unknown for now — Task 21 will tighten this with the actual mode-specific theme types. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/sessions/ChatRoot.tsx | 36 ++++-- src/components/sessions/SessionPanel.tsx | 144 +++++++++++++++++++++++ 2 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 src/components/sessions/SessionPanel.tsx diff --git a/src/components/sessions/ChatRoot.tsx b/src/components/sessions/ChatRoot.tsx index 41eff1727..29c172132 100644 --- a/src/components/sessions/ChatRoot.tsx +++ b/src/components/sessions/ChatRoot.tsx @@ -1,20 +1,40 @@ -import { Show } from 'solid-js'; +import { Show, createMemo } from 'solid-js'; import { Bot, type BotProps } from '@/components/Bot'; +import { SessionPanel } from './SessionPanel'; +import { createSessionStore } from '@/state/sessionStore'; +import { v4 as uuidv4 } from 'uuid'; -type ChatRootProps = BotProps & { class?: string }; +// `theme` is widened to `unknown` here because each mount mode (bubble/full/popup) +// has its own theme shape; Task 21 will tighten this with the actual theme types. +type ChatRootProps = BotProps & { class?: string; theme?: unknown }; -/** - * Wraps with the session panel slot. When multiSession is disabled, - * renders directly. The panel itself is added in a later task. - */ export const ChatRoot = (props: ChatRootProps) => { const enabled = () => props.multiSession?.enabled === true; + const newChatId = () => { + const customerId = (props.chatflowConfig as { vars?: { customerId?: string } } | undefined)?.vars?.customerId; + return customerId ? `${customerId.toString()}+${uuidv4()}` : uuidv4(); + }; + + const store = createSessionStore({ + chatflowid: props.chatflowid, + newChatId, + maxSessions: props.multiSession?.maxSessions, + }); + + // Best-effort theme cascade — first usable theme wins (full > popup > bubble). + const panelTheme = createMemo(() => { + const anyProps = props as unknown as Record; + return anyProps.theme?.chatWindow?.sessionPanel ?? anyProps.chatWindow?.sessionPanel ?? undefined; + }); + return ( }>
-
- + +
+ +
); diff --git a/src/components/sessions/SessionPanel.tsx b/src/components/sessions/SessionPanel.tsx new file mode 100644 index 000000000..9a0bd4418 --- /dev/null +++ b/src/components/sessions/SessionPanel.tsx @@ -0,0 +1,144 @@ +import { For, Show } from 'solid-js'; +import type { SessionStore } from '@/state/sessionStore'; + +type SessionPanelTheme = { + width?: string | number; + collapsedWidth?: string | number; + backgroundColor?: string; + textColor?: string; + activeBackgroundColor?: string; + activeTextColor?: string; + hoverBackgroundColor?: string; + borderColor?: string; + newChatButtonColor?: string; + newChatButtonTextColor?: string; + newChatLabel?: string; + emptyStateText?: string; + capWarningText?: string; +}; + +type Props = { + store: SessionStore; + isFullPage: boolean; + panelTheme?: SessionPanelTheme; + // Cascade: fall through to chatWindow palette if panel keys unset. + chatWindowBackground?: string; + chatWindowText?: string; +}; + +const px = (v: string | number | undefined, fallback: string) => (v === undefined ? fallback : typeof v === 'number' ? `${v}px` : v); + +export const SessionPanel = (props: Props) => { + const sessions = () => props.store.sessions(); + const activeId = () => props.store.activeChatId(); + const newChatLabel = () => props.panelTheme?.newChatLabel ?? 'New chat'; + const emptyText = () => props.panelTheme?.emptyStateText ?? 'No conversations yet'; + + const bg = () => props.panelTheme?.backgroundColor ?? props.chatWindowBackground ?? '#f8fafc'; + const fg = () => props.panelTheme?.textColor ?? props.chatWindowText ?? '#334155'; + const activeBg = () => props.panelTheme?.activeBackgroundColor ?? '#e0e7ff'; + const activeFg = () => props.panelTheme?.activeTextColor ?? '#1e1b4b'; + const hoverBg = () => props.panelTheme?.hoverBackgroundColor ?? 'rgba(0,0,0,0.04)'; + const border = () => props.panelTheme?.borderColor ?? '#e2e8f0'; + const newBtnBg = () => props.panelTheme?.newChatButtonColor ?? '#4f46e5'; + const newBtnFg = () => props.panelTheme?.newChatButtonTextColor ?? '#ffffff'; + + const handleNewChat = () => { + props.store.actions.newChat(); + }; + const handleSwitch = (id: string) => { + props.store.actions.switchSession(id); + }; + + return ( + + ); +}; From 2eff1ed00101d49d3cb0a3161c9c83a19f84b5ce Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Fri, 1 May 2026 09:32:17 -0700 Subject: [PATCH 24/66] feat(sessions): wire ChatRoot into Full mode for Phase 4 visual testing Partial pull-forward of plan Task 21, scoped to Full only, so the panel UI (Tasks 11-15) can be visually verified before the Bubble + Popup wiring lands. Demo HTML switches to initFull with multiSession enabled and adds host-sizing CSS for flowise-fullchatbot, which has no built-in :host sizing. Co-Authored-By: Claude Opus 4.7 (1M context) --- public/index.html | 11 ++++++++--- src/features/full/components/Full.tsx | 7 +++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/public/index.html b/public/index.html index 76550ed26..7b741652b 100644 --- a/public/index.html +++ b/public/index.html @@ -13,6 +13,10 @@ Flowise Chatbot Widget + @@ -27,9 +31,10 @@ // salesbot=ghi123-jkl456,https://example.com // // Then use the environment variable name as chatflowid: - Chatbot.init({ - chatflowid: 'agent1', // or 'support', 'salesbot', etc. - apiHost: window.location.origin + Chatbot.initFull({ + chatflowid: '3c95bf39-1208-4f0a-baf1-1c605a3e30b4', // or 'support', 'salesbot', etc. + apiHost: 'http://localhost:3000', + multiSession: { enabled: true } }) diff --git a/src/features/full/components/Full.tsx b/src/features/full/components/Full.tsx index 8045321af..6c144fba7 100644 --- a/src/features/full/components/Full.tsx +++ b/src/features/full/components/Full.tsx @@ -1,5 +1,6 @@ import styles from '../../../assets/index.css'; -import { Bot, BotProps } from '@/components/Bot'; +import type { BotProps } from '@/components/Bot'; +import { ChatRoot } from '@/components/sessions/ChatRoot'; import { BubbleParams } from '@/features/bubble/types'; import { createSignal, onCleanup, onMount, Show } from 'solid-js'; import { resolveDialogContainer } from '@/utils'; @@ -69,7 +70,7 @@ export const Full = (props: FullProps, { element }: { element: HTMLElement }) => overflow: 'hidden', // Ensure no extra scrolling due to content overflow }} > - dateTimeToggle={props.theme?.chatWindow?.dateTimeToggle} renderHTML={props.theme?.chatWindow?.renderHTML} dialogContainer={resolveDialogContainer(props.dialogContainer)} + multiSession={props.multiSession} + theme={props.theme} />
From 6dace457872af618a8eb7ad858d8a53f1291b1fb Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Fri, 1 May 2026 09:37:48 -0700 Subject: [PATCH 25/66] fix(sessions): scope session-store init to multiSession-enabled branch Per-task code review caught two issues: 1. createSessionStore was running on every ChatRoot mount, including the multiSession.enabled !== true fallback path. loadOrMigrate persists a v2 index on first read, so users with the feature disabled were having their localStorage migrated unnecessarily. Factored the panel layout into an inner ChatRootEnabled component so the store is constructed only inside the truthy branch. 2. Removed the unreachable second leg of the panelTheme cascade (anyProps.chatWindow?.sessionPanel) since no mount surface passes a top-level chatWindow prop; left a forward-looking comment about Task 21. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/sessions/ChatRoot.tsx | 29 ++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/components/sessions/ChatRoot.tsx b/src/components/sessions/ChatRoot.tsx index 29c172132..3f7898ff1 100644 --- a/src/components/sessions/ChatRoot.tsx +++ b/src/components/sessions/ChatRoot.tsx @@ -11,6 +11,16 @@ type ChatRootProps = BotProps & { class?: string; theme?: unknown }; export const ChatRoot = (props: ChatRootProps) => { const enabled = () => props.multiSession?.enabled === true; + return ( + }> + + + ); +}; + +// Inner component so that createSessionStore (which runs loadOrMigrate and may +// persist a v2 index on first read) only executes when multiSession is enabled. +const ChatRootEnabled = (props: ChatRootProps) => { const newChatId = () => { const customerId = (props.chatflowConfig as { vars?: { customerId?: string } } | undefined)?.vars?.customerId; return customerId ? `${customerId.toString()}+${uuidv4()}` : uuidv4(); @@ -22,20 +32,19 @@ export const ChatRoot = (props: ChatRootProps) => { maxSessions: props.multiSession?.maxSessions, }); - // Best-effort theme cascade — first usable theme wins (full > popup > bubble). + // Theme cascade: every mount surface passes `theme` as a structured object; + // Task 21 will tighten the typing per mode. const panelTheme = createMemo(() => { - const anyProps = props as unknown as Record; - return anyProps.theme?.chatWindow?.sessionPanel ?? anyProps.chatWindow?.sessionPanel ?? undefined; + const themeAny = (props as unknown as Record).theme; + return themeAny?.chatWindow?.sessionPanel ?? undefined; }); return ( - }> -
- -
- -
+
+ +
+
- +
); }; From 9f6d40b336b4426649c962a491e6bc1781dee585 Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Fri, 1 May 2026 09:51:30 -0700 Subject: [PATCH 26/66] feat(sessions): inline rename and delete-confirm on session items Plan Task 12. Extracts the For-body from SessionPanel into a SessionListItem component that handles the inline rename input (Enter commits, Esc cancels, blur cancels, empty value falls back to the derived title or "New chat") and the inline delete confirmation prompt. Keyboard nav: Enter switches sessions, Delete opens the confirm prompt. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/sessions/SessionListItem.tsx | 210 ++++++++++++++++++++ src/components/sessions/SessionPanel.tsx | 52 ++--- 2 files changed, 226 insertions(+), 36 deletions(-) create mode 100644 src/components/sessions/SessionListItem.tsx diff --git a/src/components/sessions/SessionListItem.tsx b/src/components/sessions/SessionListItem.tsx new file mode 100644 index 000000000..324b8a59b --- /dev/null +++ b/src/components/sessions/SessionListItem.tsx @@ -0,0 +1,210 @@ +import { Show, createSignal } from 'solid-js'; +import type { SessionV2 } from '@/state/sessionStorage'; + +type Theme = { + textColor: string; + activeBackgroundColor: string; + activeTextColor: string; + hoverBackgroundColor: string; +}; + +type Props = { + session: SessionV2; + active: boolean; + theme: Theme; + onSwitch: () => void; + onRename: (next: string) => void; + onDelete: () => void; +}; + +export const SessionListItem = (props: Props) => { + const [editing, setEditing] = createSignal(false); + const [draft, setDraft] = createSignal(props.session.title); + const [confirmingDelete, setConfirmingDelete] = createSignal(false); + const [hovered, setHovered] = createSignal(false); + + const startEdit = (e: MouseEvent) => { + e.stopPropagation(); + setDraft(props.session.title); + setEditing(true); + }; + const commit = () => { + if (editing()) { + props.onRename(draft()); + setEditing(false); + } + }; + const cancel = () => { + setDraft(props.session.title); + setEditing(false); + }; + + const onClick = () => { + if (editing() || confirmingDelete()) return; + props.onSwitch(); + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !editing() && !confirmingDelete()) { + e.preventDefault(); + props.onSwitch(); + } + if (e.key === 'Delete' && !editing()) { + e.preventDefault(); + setConfirmingDelete(true); + } + }; + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + padding: '8px 10px', + 'border-radius': '6px', + 'margin-bottom': '2px', + cursor: 'pointer', + background: props.active ? props.theme.activeBackgroundColor : hovered() ? props.theme.hoverBackgroundColor : 'transparent', + color: props.active ? props.theme.activeTextColor : props.theme.textColor, + display: 'flex', + 'align-items': 'center', + gap: '6px', + }} + > + + Delete? + + +
+ } + > + setDraft(e.currentTarget.value)} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === 'Enter') commit(); + if (e.key === 'Escape') cancel(); + }} + onBlur={cancel} + ref={(el) => { + if (el) { + setTimeout(() => { + el.focus(); + el.select(); + }, 0); + } + }} + aria-label="Rename conversation" + style={{ + flex: 1, + border: '1px solid #f59e0b', + background: 'white', + color: '#111', + padding: '3px 6px', + 'border-radius': '3px', + 'font-size': '12px', + }} + /> + + } + > +
+ {props.session.title} +
+ + + ✎ + + { + e.stopPropagation(); + setConfirmingDelete(true); + }} + style={{ + 'font-size': '13px', + color: '#dc2626', + cursor: 'pointer', + padding: '0 4px', + opacity: 0.7, + }} + > + × + + + +
+ ); +}; diff --git a/src/components/sessions/SessionPanel.tsx b/src/components/sessions/SessionPanel.tsx index 9a0bd4418..bc98bb7c4 100644 --- a/src/components/sessions/SessionPanel.tsx +++ b/src/components/sessions/SessionPanel.tsx @@ -1,5 +1,6 @@ import { For, Show } from 'solid-js'; import type { SessionStore } from '@/state/sessionStore'; +import { SessionListItem } from './SessionListItem'; type SessionPanelTheme = { width?: string | number; @@ -100,42 +101,21 @@ export const SessionPanel = (props: Props) => { >
- {(s) => { - const isActive = () => s.chatId === activeId(); - return ( -
handleSwitch(s.chatId)} - style={{ - padding: '8px 10px', - 'border-radius': '6px', - 'margin-bottom': '2px', - cursor: 'pointer', - background: isActive() ? activeBg() : 'transparent', - color: isActive() ? activeFg() : fg(), - }} - onMouseEnter={(e) => { - if (!isActive()) (e.currentTarget as HTMLDivElement).style.background = hoverBg(); - }} - onMouseLeave={(e) => { - if (!isActive()) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; - }} - > -
- {s.title} -
-
- ); - }} + {(s) => ( + handleSwitch(s.chatId)} + onRename={(next) => props.store.actions.renameSession(s.chatId, next)} + onDelete={() => props.store.actions.deleteSession(s.chatId)} + /> + )}
From 6663abb533c439a609e78be235198741a753a5c4 Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Fri, 1 May 2026 10:17:58 -0700 Subject: [PATCH 27/66] feat(sessions): one-time cap-warning toast on first eviction Plan Task 13. Adds a capWarning signal + dismissCapWarning action to the session store, fired from newChat after the eviction loop on the first overflow per chatflow (gated by the _capWarned localStorage flag). SessionPanel renders the new CapWarningToast component below the "+ New chat" button. Theme key panelTheme.capWarningText overrides the default copy. Note: this commit also picks up pre-existing Prettier reflows in sessionStore.ts left over from prior pre-commit hook runs; behavior is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/sessions/CapWarningToast.tsx | 45 +++++++++++++++++++++ src/components/sessions/SessionPanel.tsx | 7 ++++ src/state/sessionStore.ts | 14 +++++-- 3 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 src/components/sessions/CapWarningToast.tsx diff --git a/src/components/sessions/CapWarningToast.tsx b/src/components/sessions/CapWarningToast.tsx new file mode 100644 index 000000000..760c821b3 --- /dev/null +++ b/src/components/sessions/CapWarningToast.tsx @@ -0,0 +1,45 @@ +import { Show } from 'solid-js'; + +type Props = { + visible: boolean; + text: string; + onDismiss: () => void; +}; + +export const CapWarningToast = (props: Props) => { + return ( + +
+
{props.text}
+
+ +
+
+
+ ); +}; diff --git a/src/components/sessions/SessionPanel.tsx b/src/components/sessions/SessionPanel.tsx index bc98bb7c4..c59c04cdd 100644 --- a/src/components/sessions/SessionPanel.tsx +++ b/src/components/sessions/SessionPanel.tsx @@ -1,6 +1,7 @@ import { For, Show } from 'solid-js'; import type { SessionStore } from '@/state/sessionStore'; import { SessionListItem } from './SessionListItem'; +import { CapWarningToast } from './CapWarningToast'; type SessionPanelTheme = { width?: string | number; @@ -95,6 +96,12 @@ export const SessionPanel = (props: Props) => {
+ props.store.actions.dismissCapWarning()} + /> + 0} fallback={
{emptyText()}
} diff --git a/src/state/sessionStore.ts b/src/state/sessionStore.ts index fccd78c73..f1932f570 100644 --- a/src/state/sessionStore.ts +++ b/src/state/sessionStore.ts @@ -4,8 +4,10 @@ import { type ChatflowIndexV2, type SessionV2, type LeadCaptureData, + readCapWarned, readMessages, reconcileOrphans, + writeCapWarned, writeIndex, writeMessages, } from './sessionStorage'; @@ -37,6 +39,7 @@ export const createSessionStore = (opts: SessionStoreOptions) => { const messageCache = new Map(); messageCache.set(initial.activeChatId, readMessages(chatflowid, initial.activeChatId)); const [activeMessages, setActiveMessages] = createSignal(messageCache.get(initial.activeChatId)!); + const [capWarning, setCapWarning] = createSignal(false); // ---- selectors ---- const sessions = createMemo(() => [...index().sessions].sort((a, b) => b.updatedAt - a.updatedAt)); @@ -69,9 +72,7 @@ export const createSessionStore = (opts: SessionStoreOptions) => { if (!(e as Error)?.name?.includes('Quota') && !(e instanceof Error && e.message.includes('quota'))) throw e; // Evict oldest non-active session. const cur = index(); - const candidates = cur.sessions - .filter((s) => s.chatId !== cur.activeChatId) - .sort((a, b) => a.updatedAt - b.updatedAt); + const candidates = cur.sessions.filter((s) => s.chatId !== cur.activeChatId).sort((a, b) => a.updatedAt - b.updatedAt); if (candidates.length === 0) break; const victim = candidates[0]; localStorage.removeItem(`${chatflowid}_EXTERNAL_msgs_${victim.chatId}`); @@ -138,6 +139,11 @@ export const createSessionStore = (opts: SessionStoreOptions) => { messageCache.delete(eid); } + if (evicted.length > 0 && !readCapWarned(chatflowid)) { + writeCapWarned(chatflowid); + setCapWarning(true); + } + return id; }; @@ -261,6 +267,7 @@ export const createSessionStore = (opts: SessionStoreOptions) => { activeSession, activeMessages, lead, + capWarning, actions: { newChat, switchSession, @@ -270,6 +277,7 @@ export const createSessionStore = (opts: SessionStoreOptions) => { setLead, flushPending, setQuotaPanicHandler, + dismissCapWarning: () => setCapWarning(false), }, _internal: { index, From 6299b66be785de53f2a28eb579c8591e0824a77e Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Fri, 1 May 2026 10:21:43 -0700 Subject: [PATCH 28/66] feat(sessions): collapsible sidebar with persisted state in full-page mode Plan Task 14. SessionPanel now reads/writes the panelCollapsed flag from localStorage when isFullPage is true, renders a header caret that toggles between expanded (260px) and collapsed (44px) widths, hides the new-chat button, toast, and session list while collapsed, and animates the width change. The caret is gated on isFullPage so bubble/popup modes (when wired) do not render it. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/sessions/SessionPanel.tsx | 131 ++++++++++++++--------- 1 file changed, 82 insertions(+), 49 deletions(-) diff --git a/src/components/sessions/SessionPanel.tsx b/src/components/sessions/SessionPanel.tsx index c59c04cdd..c111f3496 100644 --- a/src/components/sessions/SessionPanel.tsx +++ b/src/components/sessions/SessionPanel.tsx @@ -1,5 +1,6 @@ -import { For, Show } from 'solid-js'; +import { For, Show, createSignal } from 'solid-js'; import type { SessionStore } from '@/state/sessionStore'; +import { readPanelCollapsed, writePanelCollapsed } from '@/state/sessionStorage'; import { SessionListItem } from './SessionListItem'; import { CapWarningToast } from './CapWarningToast'; @@ -45,6 +46,14 @@ export const SessionPanel = (props: Props) => { const newBtnBg = () => props.panelTheme?.newChatButtonColor ?? '#4f46e5'; const newBtnFg = () => props.panelTheme?.newChatButtonTextColor ?? '#ffffff'; + const [collapsed, setCollapsed] = createSignal(props.isFullPage ? readPanelCollapsed(props.store.chatflowid) : false); + const toggleCollapsed = () => { + if (!props.isFullPage) return; + const next = !collapsed(); + setCollapsed(next); + writePanelCollapsed(props.store.chatflowid, next); + }; + const handleNewChat = () => { props.store.actions.newChat(); }; @@ -57,74 +66,98 @@ export const SessionPanel = (props: Props) => { role="navigation" aria-label="Conversations" style={{ - width: px(props.panelTheme?.width, '260px'), + width: collapsed() ? px(props.panelTheme?.collapsedWidth, '44px') : px(props.panelTheme?.width, '260px'), background: bg(), color: fg(), 'border-right': `1px solid ${border()}`, display: 'flex', 'flex-direction': 'column', height: '100%', + transition: 'width 150ms ease', }} >
- Conversations + + Conversations + + + +
-
- -
+ +
+ +
- props.store.actions.dismissCapWarning()} - /> + props.store.actions.dismissCapWarning()} + /> - 0} - fallback={
{emptyText()}
} - > -
- - {(s) => ( - handleSwitch(s.chatId)} - onRename={(next) => props.store.actions.renameSession(s.chatId, next)} - onDelete={() => props.store.actions.deleteSession(s.chatId)} - /> - )} - -
+ 0} + fallback={
{emptyText()}
} + > +
+ + {(s) => ( + handleSwitch(s.chatId)} + onRename={(next) => props.store.actions.renameSession(s.chatId, next)} + onDelete={() => props.store.actions.deleteSession(s.chatId)} + /> + )} + +
+
); From dfef4363fa38a9167eb3623d913f201b8198f260 Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Fri, 1 May 2026 10:27:20 -0700 Subject: [PATCH 29/66] feat(sessions): drawer mode for bubble/popup with header toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan Task 15. SessionPanel takes new isDrawer/drawerOpen/onDrawerClose props; in drawer mode it renders a backdrop overlay plus an absolutely positioned panel taking 75% width when drawerOpen is true, and renders nothing when closed. handleSwitch auto-closes the drawer on session switch. ChatRoot owns the drawerOpen signal and listens for the flowise-toggle-session-drawer custom event so the chat header's new ☰ button can flip it from inside Bot.tsx. Bot.tsx adds a leading ☰ button to the chat header, gated on multiSession.enabled && !isFullPage; clicking it dispatches the toggle event. Visual verification deferred to Task 21 (which wires ChatRoot into Bubble + Popup, the surfaces where drawer mode applies). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/Bot.tsx | 18 ++++++ src/components/sessions/ChatRoot.tsx | 20 +++++- src/components/sessions/SessionPanel.tsx | 81 +++++++++++++++++++----- 3 files changed, 99 insertions(+), 20 deletions(-) diff --git a/src/components/Bot.tsx b/src/components/Bot.tsx index a4ec5a0cc..b67cf896f 100644 --- a/src/components/Bot.tsx +++ b/src/components/Bot.tsx @@ -2625,6 +2625,24 @@ export const Bot = (botProps: BotProps & { class?: string }) => { 'border-top-right-radius': props.isFullPage || props.hasCustomHeader ? '0px' : '6px', }} > + + + <>
diff --git a/src/components/sessions/ChatRoot.tsx b/src/components/sessions/ChatRoot.tsx index 3f7898ff1..4562411ee 100644 --- a/src/components/sessions/ChatRoot.tsx +++ b/src/components/sessions/ChatRoot.tsx @@ -1,4 +1,4 @@ -import { Show, createMemo } from 'solid-js'; +import { Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; import { Bot, type BotProps } from '@/components/Bot'; import { SessionPanel } from './SessionPanel'; import { createSessionStore } from '@/state/sessionStore'; @@ -39,9 +39,23 @@ const ChatRootEnabled = (props: ChatRootProps) => { return themeAny?.chatWindow?.sessionPanel ?? undefined; }); + const isDrawer = !props.isFullPage; + const [drawerOpen, setDrawerOpen] = createSignal(false); + const onToggleDrawer = () => setDrawerOpen((v) => !v); + onMount(() => window.addEventListener('flowise-toggle-session-drawer', onToggleDrawer)); + onCleanup(() => window.removeEventListener('flowise-toggle-session-drawer', onToggleDrawer)); + return ( -
- +
+ setDrawerOpen(false)} + panelTheme={panelTheme()} + chatWindowBackground={props.backgroundColor} + />
diff --git a/src/components/sessions/SessionPanel.tsx b/src/components/sessions/SessionPanel.tsx index c111f3496..880a2e873 100644 --- a/src/components/sessions/SessionPanel.tsx +++ b/src/components/sessions/SessionPanel.tsx @@ -1,4 +1,4 @@ -import { For, Show, createSignal } from 'solid-js'; +import { For, Show, createSignal, type JSX } from 'solid-js'; import type { SessionStore } from '@/state/sessionStore'; import { readPanelCollapsed, writePanelCollapsed } from '@/state/sessionStorage'; import { SessionListItem } from './SessionListItem'; @@ -23,6 +23,9 @@ type SessionPanelTheme = { type Props = { store: SessionStore; isFullPage: boolean; + isDrawer: boolean; + drawerOpen?: () => boolean; + onDrawerClose?: () => void; panelTheme?: SessionPanelTheme; // Cascade: fall through to chatWindow palette if panel keys unset. chatWindowBackground?: string; @@ -59,23 +62,11 @@ export const SessionPanel = (props: Props) => { }; const handleSwitch = (id: string) => { props.store.actions.switchSession(id); + if (props.isDrawer) props.onDrawerClose?.(); }; - return ( - + + ); + + return ( + + {panelBody()} + + } + > + +
props.onDrawerClose?.()} + aria-hidden="true" + /> + + + ); }; From 9e61a39db27a22b3cb4025149540e7168aba7fff Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Fri, 1 May 2026 10:32:16 -0700 Subject: [PATCH 30/66] docs(sessions): note the no-lifecycle constraint on panelBody render helper Per-task code review flagged that panelBody is a plain function inside SessionPanel, called from two branches. JSX + signal reads are fine, but lifecycle primitives or new signals inside the helper would attach to the parent component's owner and could leak across branch toggles. Added a comment to make the constraint explicit so future edits don't introduce that hazard. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/sessions/SessionPanel.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/sessions/SessionPanel.tsx b/src/components/sessions/SessionPanel.tsx index 880a2e873..1c91ec0ac 100644 --- a/src/components/sessions/SessionPanel.tsx +++ b/src/components/sessions/SessionPanel.tsx @@ -65,6 +65,10 @@ export const SessionPanel = (props: Props) => { if (props.isDrawer) props.onDrawerClose?.(); }; + // Render helper, NOT a component. Do not add lifecycle primitives + // (createEffect, onMount, onCleanup) or new signals here — those must live + // in component scope so Solid can track ownership correctly. Pure JSX + + // signal reads only. const panelBody = (): JSX.Element => ( <>
Date: Fri, 1 May 2026 10:40:42 -0700 Subject: [PATCH 31/66] refactor(bot): read active session from sessionStore via context Co-Authored-By: Claude Sonnet 4.6 --- src/components/Bot.tsx | 113 +++++++++++++++------------ src/components/sessions/ChatRoot.tsx | 36 +++++---- 2 files changed, 86 insertions(+), 63 deletions(-) diff --git a/src/components/Bot.tsx b/src/components/Bot.tsx index b67cf896f..be9fe7172 100644 --- a/src/components/Bot.tsx +++ b/src/components/Bot.tsx @@ -46,6 +46,7 @@ import { import { FollowUpPromptBubble } from '@/components/bubbles/FollowUpPromptBubble'; import { fetchEventSource, EventStreamContentType } from '@microsoft/fetch-event-source'; import { CHAT_HEADER_HEIGHT } from '@/constants'; +import { useSessionStore } from './sessions/ChatRoot'; export type FileEvent = { target: T; @@ -477,6 +478,7 @@ const FormInputView = (props: { export const Bot = (botProps: BotProps & { class?: string }) => { // set a default value for showTitle if not set and merge with other props const props = mergeProps({ showTitle: true }, botProps); + const sessionStore = useSessionStore(); let chatContainer: HTMLDivElement | undefined; let bottomSpacer: HTMLDivElement | undefined; let botContainer: HTMLDivElement | undefined; @@ -487,7 +489,7 @@ export const Bot = (botProps: BotProps & { class?: string }) => { const [loading, setLoading] = createSignal(false); const [sourcePopupOpen, setSourcePopupOpen] = createSignal(false); const [sourcePopupSrc, setSourcePopupSrc] = createSignal({}); - const [messages, setMessages] = createSignal( + const [fallbackMessages, setFallbackMessages] = createSignal( [ { message: props.welcomeMessage ?? defaultWelcomeMessage, @@ -496,9 +498,19 @@ export const Bot = (botProps: BotProps & { class?: string }) => { ], { equals: false }, ); + const messages = () => sessionStore?.activeMessages() ?? fallbackMessages(); + const setMessages = (next: MessageType[] | ((prev: MessageType[]) => MessageType[])) => { + if (sessionStore) return; + setFallbackMessages((prev) => (typeof next === 'function' ? next(prev) : next)); + }; const [isChatFlowAvailableToStream, setIsChatFlowAvailableToStream] = createSignal(false); - const [chatId, setChatId] = createSignal(''); + const [fallbackChatId, setFallbackChatId] = createSignal(''); + const chatId = () => sessionStore?.activeChatId() ?? fallbackChatId(); + const setChatId = (next: string) => { + if (sessionStore) return; + setFallbackChatId(next); + }; const [isMessageStopping, setIsMessageStopping] = createSignal(false); const [starterPrompts, setStarterPrompts] = createSignal([], { equals: false }); const [chatFeedbackStatus, setChatFeedbackStatus] = createSignal(false); @@ -560,10 +572,13 @@ export const Bot = (botProps: BotProps & { class?: string }) => { let isTTSActionRef = false; let ttsTimeoutRef: ReturnType | null = null; - createMemo(() => { - const customerId = (props.chatflowConfig?.vars as any)?.customerId; - setChatId(customerId ? `${customerId.toString()}+${uuidv4()}` : uuidv4()); - }); + if (!sessionStore) { + // eslint-disable-next-line solid/reactivity + createMemo(() => { + const customerId = (props.chatflowConfig?.vars as any)?.customerId; + setFallbackChatId(customerId ? `${customerId.toString()}+${uuidv4()}` : uuidv4()); + }); + } onMount(() => { if (botProps?.observersConfig) { @@ -1523,49 +1538,51 @@ export const Bot = (botProps: BotProps & { class?: string }) => { setDisclaimerPopupOpen(false); } - const chatMessage = getLocalStorageChatflow(props.chatflowid); - if (chatMessage && Object.keys(chatMessage).length) { - if (chatMessage.chatId) setChatId(chatMessage.chatId); - const savedLead = chatMessage.lead; - if (savedLead) { - setIsLeadSaved(!!savedLead); - setLeadEmail(savedLead.email); + if (!sessionStore) { + const chatMessage = getLocalStorageChatflow(props.chatflowid); + if (chatMessage && Object.keys(chatMessage).length) { + if (chatMessage.chatId) setChatId(chatMessage.chatId); + const savedLead = chatMessage.lead; + if (savedLead) { + setIsLeadSaved(!!savedLead); + setLeadEmail(savedLead.email); + } + const loadedMessages: MessageType[] = + chatMessage?.chatHistory?.length > 0 + ? chatMessage.chatHistory?.map((message: MessageType) => { + const chatHistory: MessageType = { + messageId: message?.messageId, + message: message.message, + type: message.type, + rating: message.rating, + dateTime: message.dateTime, + }; + if (message.sourceDocuments) chatHistory.sourceDocuments = message.sourceDocuments; + if (message.fileAnnotations) chatHistory.fileAnnotations = message.fileAnnotations; + if (message.fileUploads) chatHistory.fileUploads = message.fileUploads; + if (message.agentReasoning) chatHistory.agentReasoning = message.agentReasoning; + if ((message as any).reasonContent && typeof (message as any).reasonContent === 'object') { + chatHistory.thinking = (message as any).reasonContent.thinking; + chatHistory.thinkingDuration = (message as any).reasonContent.thinkingDuration; + } + if (message.thinking) chatHistory.thinking = message.thinking; + if (message.thinkingDuration !== undefined) chatHistory.thinkingDuration = message.thinkingDuration; + if (message.action) chatHistory.action = message.action; + if (message.artifacts) chatHistory.artifacts = message.artifacts; + if (message.followUpPrompts) chatHistory.followUpPrompts = message.followUpPrompts; + if (message.execution && message.execution.executionData) + chatHistory.agentFlowExecutedData = + typeof message.execution.executionData === 'string' ? JSON.parse(message.execution.executionData) : message.execution.executionData; + if (message.agentFlowExecutedData) + chatHistory.agentFlowExecutedData = + typeof message.agentFlowExecutedData === 'string' ? JSON.parse(message.agentFlowExecutedData) : message.agentFlowExecutedData; + return chatHistory; + }) + : [{ message: props.welcomeMessage ?? defaultWelcomeMessage, type: 'apiMessage' }]; + + const filteredMessages = loadedMessages.filter((message) => message.type !== 'leadCaptureMessage'); + setMessages([...filteredMessages]); } - const loadedMessages: MessageType[] = - chatMessage?.chatHistory?.length > 0 - ? chatMessage.chatHistory?.map((message: MessageType) => { - const chatHistory: MessageType = { - messageId: message?.messageId, - message: message.message, - type: message.type, - rating: message.rating, - dateTime: message.dateTime, - }; - if (message.sourceDocuments) chatHistory.sourceDocuments = message.sourceDocuments; - if (message.fileAnnotations) chatHistory.fileAnnotations = message.fileAnnotations; - if (message.fileUploads) chatHistory.fileUploads = message.fileUploads; - if (message.agentReasoning) chatHistory.agentReasoning = message.agentReasoning; - if ((message as any).reasonContent && typeof (message as any).reasonContent === 'object') { - chatHistory.thinking = (message as any).reasonContent.thinking; - chatHistory.thinkingDuration = (message as any).reasonContent.thinkingDuration; - } - if (message.thinking) chatHistory.thinking = message.thinking; - if (message.thinkingDuration !== undefined) chatHistory.thinkingDuration = message.thinkingDuration; - if (message.action) chatHistory.action = message.action; - if (message.artifacts) chatHistory.artifacts = message.artifacts; - if (message.followUpPrompts) chatHistory.followUpPrompts = message.followUpPrompts; - if (message.execution && message.execution.executionData) - chatHistory.agentFlowExecutedData = - typeof message.execution.executionData === 'string' ? JSON.parse(message.execution.executionData) : message.execution.executionData; - if (message.agentFlowExecutedData) - chatHistory.agentFlowExecutedData = - typeof message.agentFlowExecutedData === 'string' ? JSON.parse(message.agentFlowExecutedData) : message.agentFlowExecutedData; - return chatHistory; - }) - : [{ message: props.welcomeMessage ?? defaultWelcomeMessage, type: 'apiMessage' }]; - - const filteredMessages = loadedMessages.filter((message) => message.type !== 'leadCaptureMessage'); - setMessages([...filteredMessages]); } // Determine if particular chatflow is available for streaming diff --git a/src/components/sessions/ChatRoot.tsx b/src/components/sessions/ChatRoot.tsx index 4562411ee..6d5d3f983 100644 --- a/src/components/sessions/ChatRoot.tsx +++ b/src/components/sessions/ChatRoot.tsx @@ -1,9 +1,13 @@ -import { Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; +import { Show, createContext, createMemo, createSignal, onCleanup, onMount, useContext } from 'solid-js'; import { Bot, type BotProps } from '@/components/Bot'; import { SessionPanel } from './SessionPanel'; -import { createSessionStore } from '@/state/sessionStore'; +import { createSessionStore, type SessionStore } from '@/state/sessionStore'; import { v4 as uuidv4 } from 'uuid'; +const SessionContext = createContext(); + +export const useSessionStore = (): SessionStore | undefined => useContext(SessionContext); + // `theme` is widened to `unknown` here because each mount mode (bubble/full/popup) // has its own theme shape; Task 21 will tighten this with the actual theme types. type ChatRootProps = BotProps & { class?: string; theme?: unknown }; @@ -46,19 +50,21 @@ const ChatRootEnabled = (props: ChatRootProps) => { onCleanup(() => window.removeEventListener('flowise-toggle-session-drawer', onToggleDrawer)); return ( -
- setDrawerOpen(false)} - panelTheme={panelTheme()} - chatWindowBackground={props.backgroundColor} - /> -
- + +
+ setDrawerOpen(false)} + panelTheme={panelTheme()} + chatWindowBackground={props.backgroundColor} + /> +
+ +
-
+ ); }; From cccae729bf4797ec8a89829b605721c65df734c5 Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Fri, 1 May 2026 13:08:42 -0700 Subject: [PATCH 32/66] feat(sessions): add removeMessageById, replaceActiveMessages, upsertMessage replaceId Three small additions to the store API to support routing Bot.tsx writes through the store. removeMessageById cleans up empty placeholder apiMessages on stream abort/error. replaceActiveMessages atomically truncates the active session for regenerate-style operations. upsertMessage now accepts an optional replaceId so the metadata event can swap a streaming temp id for the server-assigned chatMessageId without an append+remove churn. Co-Authored-By: Claude Sonnet 4.6 --- src/state/sessionStore.ts | 40 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/state/sessionStore.ts b/src/state/sessionStore.ts index f1932f570..d561ec04d 100644 --- a/src/state/sessionStore.ts +++ b/src/state/sessionStore.ts @@ -178,11 +178,12 @@ export const createSessionStore = (opts: SessionStoreOptions) => { if (msgs) withQuotaRecovery(() => writeMessages(chatflowid, id, msgs)); }; - const upsertMessage = (msg: MessageType): void => { + const upsertMessage = (msg: MessageType, options?: { replaceId?: string }): void => { const id = activeChatId(); const cached = messageCache.get(id) ?? []; let next: MessageType[]; - const existingIdx = msg.messageId !== undefined ? cached.findIndex((m) => m.messageId === msg.messageId) : -1; + const findId = options?.replaceId ?? msg.messageId; + const existingIdx = findId !== undefined ? cached.findIndex((m) => m.messageId === findId) : -1; if (existingIdx >= 0) { next = [...cached]; next[existingIdx] = msg; @@ -215,6 +216,39 @@ export const createSessionStore = (opts: SessionStoreOptions) => { withQuotaRecovery(() => _persistIndex({ ...current, sessions })); }; + /** + * Remove a message from the active session by its messageId. No-op if not found. + * Used to clean up empty placeholder messages after stream abort/error. + */ + const removeMessageById = (messageId: string): void => { + const id = activeChatId(); + const cached = messageCache.get(id) ?? []; + const next = cached.filter((m) => m.messageId !== messageId); + if (next.length === cached.length) return; + messageCache.set(id, next); + setActiveMessages(next); + if (pendingPersist !== null) clearTimeout(pendingPersist); + pendingPersist = setTimeout(() => { + pendingPersist = null; + withQuotaRecovery(() => writeMessages(chatflowid, id, next)); + }, 150); + }; + + /** + * Replace the entire active session message list atomically. Used for + * regenerate-style operations that truncate the message history. + */ + const replaceActiveMessages = (next: MessageType[]): void => { + const id = activeChatId(); + messageCache.set(id, next); + setActiveMessages(next); + if (pendingPersist !== null) clearTimeout(pendingPersist); + pendingPersist = setTimeout(() => { + pendingPersist = null; + withQuotaRecovery(() => writeMessages(chatflowid, id, next)); + }, 150); + }; + const renameSession = (chatId: string, rawTitle: string): void => { const trimmed = rawTitle.trim().slice(0, 80); const current = index(); @@ -272,6 +306,8 @@ export const createSessionStore = (opts: SessionStoreOptions) => { newChat, switchSession, upsertMessage, + removeMessageById, + replaceActiveMessages, renameSession, deleteSession, setLead, From 56e29173584d12f64395e25d27bf98b424c446a4 Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Fri, 1 May 2026 13:17:41 -0700 Subject: [PATCH 33/66] refactor(bot): route message writes through sessionStore Replaces the legacy setMessages+addChatMessage pipeline with two context-aware helpers (replaceLastApiMessage, appendMessage) that route through sessionStore.actions.upsertMessage in store mode and preserve the original setFallbackMessages+localStorage behavior for non-store users. Adds a streamingApiMessageId tracker so streaming token/sourceDocs/etc. events can find and update the in-flight apiMessage by id; metadata's chatMessageId swap uses upsertMessage's new replaceId option. Empty placeholder cleanup on onclose/onerror routes through removeMessageById. handleRegenerateResponse uses replaceActiveMessages for atomic truncation. Other store-mode wiring: addChatMessage is a no-op when sessionStore is present (store handles its own persistence), and pagehide/beforeunload now flush the store's debounced writes. Co-Authored-By: Claude Sonnet 4.6 --- src/components/Bot.tsx | 506 +++++++++++++++++++++++------------------ 1 file changed, 282 insertions(+), 224 deletions(-) diff --git a/src/components/Bot.tsx b/src/components/Bot.tsx index be9fe7172..423af3745 100644 --- a/src/components/Bot.tsx +++ b/src/components/Bot.tsx @@ -636,6 +636,18 @@ export const Bot = (botProps: BotProps & { class?: string }) => { isProgrammaticScroll = false; }, 500); }; + + // Flush any debounced session-store writes when the page is hidden / unloading. + // pagehide is the modern, reliable hook (covers BFCache navigations); beforeunload is a belt-and-suspenders. + if (sessionStore) { + const flush = () => sessionStore.actions.flushPending(); + window.addEventListener('pagehide', flush); + window.addEventListener('beforeunload', flush); + onCleanup(() => { + window.removeEventListener('pagehide', flush); + window.removeEventListener('beforeunload', flush); + }); + } }); let programmaticScrollGuard: (fn: () => void) => void = (fn) => fn(); @@ -672,9 +684,12 @@ export const Bot = (botProps: BotProps & { class?: string }) => { }; /** - * Add each chat message into localStorage + * Add each chat message into localStorage. In store mode this is a no-op + * because sessionStore handles its own persistence; the legacy + * setLocalStorageChatflow call would only churn the v2 index for nothing. */ const addChatMessage = (allMessage: MessageType[]) => { + if (sessionStore) return; const messages = allMessage.map((item) => { if (item.fileUploads) { const fileUploads = item?.fileUploads.map((file) => ({ @@ -689,6 +704,55 @@ export const Bot = (botProps: BotProps & { class?: string }) => { setLocalStorageChatflow(props.chatflowid, chatId(), { chatHistory: messages }); }; + // Tracks the messageId currently being mutated by a streaming response. Set on stream + // start (assigned a temp uuid), swapped on the metadata event when the server returns + // its real chatMessageId, and cleared on stream close. + let streamingApiMessageId: string | undefined; + + /** + * Mutate the last apiMessage (the one being streamed). In store mode this routes + * through upsertMessage keyed by streamingApiMessageId. In fallback mode it + * preserves the original addChatMessage call so localStorage-based reload still + * works for non-store users. + */ + const replaceLastApiMessage = (updater: (last: MessageType) => MessageType, options?: { persistFallback?: boolean }): void => { + if (sessionStore) { + const cur = sessionStore.activeMessages(); + if (cur.length === 0) return; + const last = cur[cur.length - 1]; + if (!last || last.type === 'userMessage') return; + const updated = updater(last); + sessionStore.actions.upsertMessage(updated); + return; + } + setFallbackMessages((prev) => { + if (prev.length === 0) return prev; + const last = prev[prev.length - 1]; + if (!last || last.type === 'userMessage') return prev; + const updated = updater(last); + const next = [...prev.slice(0, -1), updated]; + if (options?.persistFallback !== false) addChatMessage(next); + return next; + }); + }; + + /** + * Append a brand-new message. In store mode routes via upsertMessage (treated + * as append because the messageId is either fresh or undefined). In fallback + * mode appends to the signal and persists via addChatMessage. + */ + const appendMessage = (msg: MessageType, options?: { persistFallback?: boolean }): void => { + if (sessionStore) { + sessionStore.actions.upsertMessage(msg); + return; + } + setFallbackMessages((prev) => { + const next = [...prev, msg]; + if (options?.persistFallback !== false) addChatMessage(next); + return next; + }); + }; + // Define the audioRef let audioRef: HTMLAudioElement | undefined; // CDN link for default receive sound @@ -714,151 +778,94 @@ export const Bot = (botProps: BotProps & { class?: string }) => { let isStreaming = false; const updateLastMessage = (text: string) => { - setMessages((prevMessages) => { - const lastMsg = prevMessages[prevMessages.length - 1]; - if (lastMsg.type === 'userMessage') return prevMessages; - if (!text) return prevMessages; - const updatedMsg = { ...lastMsg, message: lastMsg.message + text, rating: undefined, dateTime: new Date().toISOString() }; - if (!hasSoundPlayed) { - playReceiveSound(); - hasSoundPlayed = true; - } - const allMessages = [...prevMessages.slice(0, -1), updatedMsg]; - if (!isStreaming) addChatMessage(allMessages); - return allMessages; - }); + if (!text) return; + if (!hasSoundPlayed) { + playReceiveSound(); + hasSoundPlayed = true; + } + replaceLastApiMessage( + (lastMsg) => ({ + ...lastMsg, + message: lastMsg.message + text, + rating: undefined, + dateTime: new Date().toISOString(), + }), + { persistFallback: !isStreaming }, + ); }; const updateErrorMessage = (errorMessage: string) => { const cleanedMessage = errorMessage.replace(/^Error:\s*\S+\s*-\s*/, ''); - setMessages((prevMessages) => { - const allMessages = [...prevMessages, { message: props.errorMessage || cleanedMessage, type: 'apiMessage' as messageType }]; - addChatMessage(allMessages); - return allMessages; - }); + appendMessage({ message: props.errorMessage || cleanedMessage, type: 'apiMessage' as messageType }); }; const updateLastMessageSourceDocuments = (sourceDocuments: any) => { - setMessages((data) => { - const updated = data.map((item, i) => { - if (i === data.length - 1) { - return { ...item, sourceDocuments }; - } - return item; - }); - if (!isStreaming) addChatMessage(updated); - return [...updated]; - }); + replaceLastApiMessage((lastMsg) => ({ ...lastMsg, sourceDocuments }), { persistFallback: !isStreaming }); }; const updateLastMessageUsedTools = (usedTools: any[]) => { - setMessages((prevMessages) => { - const lastMsg = prevMessages[prevMessages.length - 1]; - if (lastMsg.type === 'userMessage') return prevMessages; - const allMessages = [...prevMessages.slice(0, -1), { ...lastMsg, usedTools }]; - if (!isStreaming) addChatMessage(allMessages); - return allMessages; - }); + replaceLastApiMessage((lastMsg) => ({ ...lastMsg, usedTools }), { persistFallback: !isStreaming }); }; const updateLastMessageFileAnnotations = (fileAnnotations: any) => { - setMessages((prevMessages) => { - const lastMsg = prevMessages[prevMessages.length - 1]; - if (lastMsg.type === 'userMessage') return prevMessages; - const allMessages = [...prevMessages.slice(0, -1), { ...lastMsg, fileAnnotations }]; - if (!isStreaming) addChatMessage(allMessages); - return allMessages; - }); + replaceLastApiMessage((lastMsg) => ({ ...lastMsg, fileAnnotations }), { persistFallback: !isStreaming }); }; const updateLastMessageAgentReasoning = (agentReasoning: string | IAgentReasoning[]) => { - setMessages((data) => { - const updated = data.map((item, i) => { - if (i === data.length - 1) { - return { ...item, agentReasoning: typeof agentReasoning === 'string' ? JSON.parse(agentReasoning) : agentReasoning }; - } - return item; - }); - if (!isStreaming) addChatMessage(updated); - return [...updated]; - }); + replaceLastApiMessage( + (lastMsg) => ({ + ...lastMsg, + agentReasoning: typeof agentReasoning === 'string' ? JSON.parse(agentReasoning) : agentReasoning, + }), + { persistFallback: !isStreaming }, + ); }; const updateAgentFlowEvent = (event: string) => { if (event === 'INPROGRESS') { - setMessages((prevMessages) => [...prevMessages, { message: '', type: 'apiMessage', agentFlowEventStatus: event }]); + // INPROGRESS appends a new placeholder apiMessage. Use a fresh streaming id so + // subsequent mutate-last calls find it. + streamingApiMessageId = uuidv4(); + appendMessage({ messageId: streamingApiMessageId, message: '', type: 'apiMessage', agentFlowEventStatus: event }); } else { - setMessages((prevMessages) => { - const lastMsg = prevMessages[prevMessages.length - 1]; - if (lastMsg.type === 'userMessage') return prevMessages; - return [...prevMessages.slice(0, -1), { ...lastMsg, agentFlowEventStatus: event }]; - }); + replaceLastApiMessage((lastMsg) => ({ ...lastMsg, agentFlowEventStatus: event })); } }; const updateAgentFlowExecutedData = (agentFlowExecutedData: any) => { - setMessages((prevMessages) => { - const lastMsg = prevMessages[prevMessages.length - 1]; - if (lastMsg.type === 'userMessage') return prevMessages; - const allMessages = [...prevMessages.slice(0, -1), { ...lastMsg, agentFlowExecutedData }]; - if (!isStreaming) addChatMessage(allMessages); - return allMessages; - }); + replaceLastApiMessage((lastMsg) => ({ ...lastMsg, agentFlowExecutedData }), { persistFallback: !isStreaming }); }; const updateLastMessageArtifacts = (artifacts: FileUpload[]) => { - setMessages((prevMessages) => { - const lastMsg = prevMessages[prevMessages.length - 1]; - if (lastMsg.type === 'userMessage') return prevMessages; - const allMessages = [...prevMessages.slice(0, -1), { ...lastMsg, artifacts }]; - if (!isStreaming) addChatMessage(allMessages); - return allMessages; - }); + replaceLastApiMessage((lastMsg) => ({ ...lastMsg, artifacts }), { persistFallback: !isStreaming }); }; const updateLastMessageAction = (action: IAction) => { - setMessages((data) => { - const updated = data.map((item, i) => { - if (i === data.length - 1) { - return { ...item, action: typeof action === 'string' ? JSON.parse(action) : action }; - } - return item; - }); - if (!isStreaming) addChatMessage(updated); - return [...updated]; - }); + replaceLastApiMessage( + (lastMsg) => ({ + ...lastMsg, + action: typeof action === 'string' ? JSON.parse(action as any) : action, + }), + { persistFallback: !isStreaming }, + ); }; const handleThinkingEvent = (data: string, duration?: number) => { if (data && duration === undefined) { setIsThinking(true); - setMessages((prevMessages) => { - const lastMsg = prevMessages[prevMessages.length - 1]; - if (lastMsg.type === 'userMessage') return prevMessages; - const allMessages = [...prevMessages.slice(0, -1), { ...lastMsg, thinking: (lastMsg.thinking || '') + data, isThinking: true }]; - if (!isStreaming) addChatMessage(allMessages); - return allMessages; + replaceLastApiMessage((lastMsg) => ({ ...lastMsg, thinking: (lastMsg.thinking || '') + data, isThinking: true }), { + persistFallback: !isStreaming, }); } else if (data === '' && duration !== undefined) { setIsThinking(false); - setMessages((prevMessages) => { - const lastMsg = prevMessages[prevMessages.length - 1]; - if (lastMsg.type === 'userMessage') return prevMessages; - const allMessages = [...prevMessages.slice(0, -1), { ...lastMsg, thinkingDuration: duration, isThinking: false }]; - if (!isStreaming) addChatMessage(allMessages); - return allMessages; - }); + replaceLastApiMessage((lastMsg) => ({ ...lastMsg, thinkingDuration: duration, isThinking: false }), { persistFallback: !isStreaming }); } }; const finalizeThinking = () => { if (isThinking()) { setIsThinking(false); - setMessages((prevMessages) => { - const lastMsg = prevMessages[prevMessages.length - 1]; - if (lastMsg.type === 'userMessage') return prevMessages; - return [...prevMessages.slice(0, -1), { ...lastMsg, isThinking: false }]; - }); + replaceLastApiMessage((lastMsg) => ({ ...lastMsg, isThinking: false }), { persistFallback: false }); } }; @@ -874,11 +881,7 @@ export const Bot = (botProps: BotProps & { class?: string }) => { if (!preventOverride && props.errorMessage) { errMessage = props.errorMessage; } - setMessages((prevMessages) => { - const messages: MessageType[] = [...prevMessages, { message: errMessage, type: 'apiMessage' }]; - addChatMessage(messages); - return messages; - }); + appendMessage({ message: errMessage, type: 'apiMessage' }); setLoading(false); setUserInput(''); setUploadedFiles([]); @@ -913,8 +916,12 @@ export const Bot = (botProps: BotProps & { class?: string }) => { setFollowUpPrompts([]); const updatedMessages = currentMessages.slice(0, messageIndex); - addChatMessage(updatedMessages); - setMessages(updatedMessages); + if (sessionStore) { + sessionStore.actions.replaceActiveMessages(updatedMessages); + } else { + addChatMessage(updatedMessages); + setMessages(updatedMessages); + } // Note: chatId is kept so the server retains conversation context up to this point. // The server's history will still include messages that were removed client-side @@ -928,37 +935,53 @@ export const Bot = (botProps: BotProps & { class?: string }) => { // set message id that is needed for feedback if (data.chatMessageId) { - setMessages((prevMessages) => { - const lastMsg = prevMessages[prevMessages.length - 1]; - if (lastMsg.type === 'apiMessage') { - const allMessages = [...prevMessages.slice(0, -1), { ...lastMsg, messageId: data.chatMessageId }]; - addChatMessage(allMessages); - return allMessages; + if (sessionStore && streamingApiMessageId) { + const current = sessionStore.activeMessages(); + const lastMsg = current[current.length - 1]; + if (lastMsg && lastMsg.type === 'apiMessage') { + sessionStore.actions.upsertMessage({ ...lastMsg, messageId: data.chatMessageId }, { replaceId: streamingApiMessageId }); + streamingApiMessageId = data.chatMessageId; } - return prevMessages; - }); + } else if (!sessionStore) { + setFallbackMessages((prev) => { + const lastMsg = prev[prev.length - 1]; + if (lastMsg && lastMsg.type === 'apiMessage') { + const allMessages = [...prev.slice(0, -1), { ...lastMsg, messageId: data.chatMessageId }]; + addChatMessage(allMessages); + // Track the assigned id so any subsequent metadata events keep working in fallback. + streamingApiMessageId = data.chatMessageId; + return allMessages; + } + return prev; + }); + } } if (input === '' && data.question) { // the response contains the question even if it was in an audio format // so if input is empty but the response contains the question, update the user message to show the question - setMessages((prevMessages) => { - const secondLast = prevMessages[prevMessages.length - 2]; - if (secondLast.type === 'apiMessage') return prevMessages; - const allMessages = [...prevMessages.slice(0, -2), { ...secondLast, message: data.question }, prevMessages[prevMessages.length - 1]]; - addChatMessage(allMessages); - return allMessages; - }); + if (sessionStore) { + const current = sessionStore.activeMessages(); + if (current.length >= 2) { + const secondLast = current[current.length - 2]; + if (secondLast.type !== 'apiMessage' && secondLast.messageId) { + sessionStore.actions.upsertMessage({ ...secondLast, message: data.question }); + } + } + } else { + setFallbackMessages((prev) => { + if (prev.length < 2) return prev; + const secondLast = prev[prev.length - 2]; + if (secondLast.type === 'apiMessage') return prev; + const allMessages = [...prev.slice(0, -2), { ...secondLast, message: data.question }, prev[prev.length - 1]]; + addChatMessage(allMessages); + return allMessages; + }); + } } if (data.followUpPrompts) { - setMessages((prevMessages) => { - const lastMsg = prevMessages[prevMessages.length - 1]; - if (lastMsg.type === 'userMessage') return prevMessages; - const allMessages = [...prevMessages.slice(0, -1), { ...lastMsg, followUpPrompts: data.followUpPrompts }]; - addChatMessage(allMessages); - return allMessages; - }); + replaceLastApiMessage((lastMsg) => ({ ...lastMsg, followUpPrompts: data.followUpPrompts })); setFollowUpPrompts(JSON.parse(data.followUpPrompts)); } }; @@ -1035,7 +1058,10 @@ export const Bot = (botProps: BotProps & { class?: string }) => { switch (payload.event) { case 'start': isStreaming = true; - setMessages((prevMessages) => [...prevMessages, { message: '', type: 'apiMessage' }]); + // Assign a temporary messageId so subsequent token / mutate-last events upsert the + // same record. The id will be swapped to the server's chatMessageId on metadata. + streamingApiMessageId = uuidv4(); + appendMessage({ messageId: streamingApiMessageId, message: '', type: 'apiMessage' }); break; case 'token': updateLastMessage(payload.data); @@ -1083,10 +1109,12 @@ export const Bot = (botProps: BotProps & { class?: string }) => { case 'end': isStreaming = false; finalizeThinking(); - setMessages((prev) => { - addChatMessage(prev); - return prev; - }); + if (!sessionStore) { + setFallbackMessages((prev) => { + addChatMessage(prev); + return prev; + }); + } setLocalStorageChatflow(chatflowid, chatId); closeResponse(); break; @@ -1106,29 +1134,49 @@ export const Bot = (botProps: BotProps & { class?: string }) => { }, async onclose() { isStreaming = false; - setMessages((prev) => { - const last = prev[prev.length - 1]; - if (shouldRemoveEmptyApiMessage(last)) { - const cleaned = prev.slice(0, -1); - addChatMessage(cleaned); - return cleaned; + if (sessionStore) { + if (streamingApiMessageId) { + const current = sessionStore.activeMessages(); + const last = current[current.length - 1]; + if (last && shouldRemoveEmptyApiMessage(last)) { + sessionStore.actions.removeMessageById(streamingApiMessageId); + } } - return prev; - }); + } else { + setFallbackMessages((prev) => { + const last = prev[prev.length - 1]; + if (shouldRemoveEmptyApiMessage(last)) { + const cleaned = prev.slice(0, -1); + addChatMessage(cleaned); + return cleaned; + } + return prev; + }); + } closeResponse(); }, onerror(err) { console.error('EventSource Error: ', err); isStreaming = false; - setMessages((prev) => { - const last = prev[prev.length - 1]; - if (shouldRemoveEmptyApiMessage(last)) { - const cleaned = prev.slice(0, -1); - addChatMessage(cleaned); - return cleaned; + if (sessionStore) { + if (streamingApiMessageId) { + const current = sessionStore.activeMessages(); + const last = current[current.length - 1]; + if (last && shouldRemoveEmptyApiMessage(last)) { + sessionStore.actions.removeMessageById(streamingApiMessageId); + } } - return prev; - }); + } else { + setFallbackMessages((prev) => { + const last = prev[prev.length - 1]; + if (shouldRemoveEmptyApiMessage(last)) { + const cleaned = prev.slice(0, -1); + addChatMessage(cleaned); + return cleaned; + } + return prev; + }); + } closeResponse(); throw err; }, @@ -1141,6 +1189,7 @@ export const Bot = (botProps: BotProps & { class?: string }) => { setUploadedFiles([]); hasSoundPlayed = false; isStreaming = false; + streamingApiMessageId = undefined; setTimeout(() => { scrollToBottom(); }, 100); @@ -1152,15 +1201,16 @@ export const Bot = (botProps: BotProps & { class?: string }) => { // Stop all TTS when aborting message stopAllTTS(); - setMessages((prevMessages) => { - const lastMsg = prevMessages[prevMessages.length - 1]; - if (lastMsg.type === 'userMessage') return prevMessages; - const lastAgentReasoning = lastMsg.agentReasoning; - if (lastAgentReasoning && lastAgentReasoning.length > 0) { - return [...prevMessages.slice(0, -1), { ...lastMsg, agentReasoning: lastAgentReasoning.filter((reasoning) => !reasoning.nextAgent) }]; - } - return prevMessages; - }); + replaceLastApiMessage( + (lastMsg) => { + const lastAgentReasoning = lastMsg.agentReasoning; + if (lastAgentReasoning && lastAgentReasoning.length > 0) { + return { ...lastMsg, agentReasoning: lastAgentReasoning.filter((reasoning) => !reasoning.nextAgent) }; + } + return lastMsg; + }, + { persistFallback: false }, + ); }; const handleAbort = async () => { @@ -1308,11 +1358,7 @@ export const Bot = (botProps: BotProps & { class?: string }) => { clearPreviews(); if (!options?.skipAddUserMessage) { - setMessages((prevMessages) => { - const messages: MessageType[] = [...prevMessages, { message: value as string, type: 'userMessage', fileUploads: uploads }]; - addChatMessage(messages); - return messages; - }); + appendMessage({ messageId: uuidv4(), message: value as string, type: 'userMessage', fileUploads: uploads }); } const body: IncomingInput = { @@ -1357,27 +1403,24 @@ export const Bot = (botProps: BotProps & { class?: string }) => { playReceiveSound(); - setMessages((prevMessages) => { - const newMessage = { - message: text, - id: data?.chatMessageId, - sourceDocuments: data?.sourceDocuments, - usedTools: data?.usedTools, - fileAnnotations: data?.fileAnnotations, - agentReasoning: data?.agentReasoning, - agentFlowExecutedData: data?.agentFlowExecutedData, - action: data?.action, - artifacts: data?.artifacts, - thinking: data?.reasonContent?.thinking, - thinkingDuration: data?.reasonContent?.thinkingDuration, - type: 'apiMessage' as messageType, - feedback: null, - dateTime: new Date().toISOString(), - }; - const allMessages = [...prevMessages, newMessage]; - addChatMessage(allMessages); - return allMessages; - }); + const newMessage = { + messageId: data?.chatMessageId ?? uuidv4(), + message: text, + id: data?.chatMessageId, + sourceDocuments: data?.sourceDocuments, + usedTools: data?.usedTools, + fileAnnotations: data?.fileAnnotations, + agentReasoning: data?.agentReasoning, + agentFlowExecutedData: data?.agentFlowExecutedData, + action: data?.action, + artifacts: data?.artifacts, + thinking: data?.reasonContent?.thinking, + thinkingDuration: data?.reasonContent?.thinkingDuration, + type: 'apiMessage' as messageType, + feedback: null, + dateTime: new Date().toISOString(), + } as MessageType; + appendMessage(newMessage); updateMetadata(data, value); @@ -1404,23 +1447,40 @@ export const Bot = (botProps: BotProps & { class?: string }) => { // Update last question to avoid saving base64 data to localStorage if (uploads && uploads.length > 0) { - setMessages((data) => { - const messages = data.map((item, i) => { - if (i === data.length - 2 && item.type === 'userMessage') { - if (item.fileUploads) { - const fileUploads = item?.fileUploads.map((file) => ({ - type: file.type, - name: file.name, - mime: file.mime, - })); - return { ...item, fileUploads }; - } + if (sessionStore) { + const current = sessionStore.activeMessages(); + if (current.length >= 2) { + const idx = current.length - 2; + const item = current[idx]; + if (item.type === 'userMessage' && item.fileUploads) { + const fileUploads = item.fileUploads.map((file) => ({ + type: file.type, + name: file.name, + mime: file.mime, + })); + // upsertMessage will key by messageId (assigned earlier when appended) + sessionStore.actions.upsertMessage({ ...item, fileUploads }); } - return item; + } + } else { + setFallbackMessages((data) => { + const messages = data.map((item, i) => { + if (i === data.length - 2 && item.type === 'userMessage') { + if (item.fileUploads) { + const fileUploads = item?.fileUploads.map((file) => ({ + type: file.type, + name: file.name, + mime: file.mime, + })); + return { ...item, fileUploads }; + } + } + return item; + }); + addChatMessage(messages); + return [...messages]; }); - addChatMessage(messages); - return [...messages]; - }); + } } }; @@ -1449,16 +1509,7 @@ export const Bot = (botProps: BotProps & { class?: string }) => { const handleActionClick = async (elem: any, action: IAction | undefined | null) => { setUserInput(elem.label); - setMessages((data) => { - const updated = data.map((item, i) => { - if (i === data.length - 1) { - return { ...item, action: null }; - } - return item; - }); - addChatMessage(updated); - return [...updated]; - }); + replaceLastApiMessage((lastMsg) => ({ ...lastMsg, action: null })); if (elem.type.includes('agentflowv2')) { const type = elem.type.includes('approve') ? 'proceed' : 'reject'; setFeedbackType(type); @@ -1572,7 +1623,9 @@ export const Bot = (botProps: BotProps & { class?: string }) => { if (message.followUpPrompts) chatHistory.followUpPrompts = message.followUpPrompts; if (message.execution && message.execution.executionData) chatHistory.agentFlowExecutedData = - typeof message.execution.executionData === 'string' ? JSON.parse(message.execution.executionData) : message.execution.executionData; + typeof message.execution.executionData === 'string' + ? JSON.parse(message.execution.executionData) + : message.execution.executionData; if (message.agentFlowExecutedData) chatHistory.agentFlowExecutedData = typeof message.agentFlowExecutedData === 'string' ? JSON.parse(message.agentFlowExecutedData) : message.agentFlowExecutedData; @@ -2048,19 +2101,24 @@ export const Bot = (botProps: BotProps & { class?: string }) => { [data.chatMessageId]: true, })); - setMessages((prevMessages) => { - const lastMsg = prevMessages[prevMessages.length - 1]; - if (lastMsg.type === 'userMessage') return prevMessages; - const existingId = lastMsg.id || lastMsg.messageId; - let id = lastMsg.id; - if (!existingId) { - id = data.chatMessageId; - } else if (!lastMsg.id) { - id = existingId; + { + const cur = messages(); + if (cur.length > 0) { + const lastMsg = cur[cur.length - 1]; + if (lastMsg.type !== 'userMessage') { + const existingId = lastMsg.id || lastMsg.messageId; + let id = lastMsg.id; + if (!existingId) { + id = data.chatMessageId; + } else if (!lastMsg.id) { + id = existingId; + } + if (id !== lastMsg.id) { + replaceLastApiMessage((m) => ({ ...m, id }), { persistFallback: false }); + } + } } - if (id === lastMsg.id) return prevMessages; - return [...prevMessages.slice(0, -1), { ...lastMsg, id }]; - }); + } setTtsStreamingState({ mediaSource: null, From 6f2d2239de225469d11afb8f16fcfe581a0fa172 Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Fri, 1 May 2026 13:29:26 -0700 Subject: [PATCH 34/66] fix(sessions): tighten upsertMessage replaceId, bump updatedAt on truncate - upsertMessage: when replaceId is provided but not found, no-op instead of appending a duplicate (guards streaming placeholder cleanup) - replaceActiveMessages: bump session.updatedAt in the index so sidebar sort order reflects regenerate/truncate even if no upsertMessage follows - Bot.tsx 'end' case: gate setLocalStorageChatflow inside !sessionStore block so it doesn't run unnecessarily in store mode - replaceLastApiMessage: tighten type guard from !== 'userMessage' to === 'apiMessage' in both store and fallback branches Co-Authored-By: Claude Sonnet 4.6 --- src/components/Bot.tsx | 6 +++--- src/state/sessionStore.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/components/Bot.tsx b/src/components/Bot.tsx index 423af3745..0a9a63a91 100644 --- a/src/components/Bot.tsx +++ b/src/components/Bot.tsx @@ -720,7 +720,7 @@ export const Bot = (botProps: BotProps & { class?: string }) => { const cur = sessionStore.activeMessages(); if (cur.length === 0) return; const last = cur[cur.length - 1]; - if (!last || last.type === 'userMessage') return; + if (!last || last.type !== 'apiMessage') return; const updated = updater(last); sessionStore.actions.upsertMessage(updated); return; @@ -728,7 +728,7 @@ export const Bot = (botProps: BotProps & { class?: string }) => { setFallbackMessages((prev) => { if (prev.length === 0) return prev; const last = prev[prev.length - 1]; - if (!last || last.type === 'userMessage') return prev; + if (!last || last.type !== 'apiMessage') return prev; const updated = updater(last); const next = [...prev.slice(0, -1), updated]; if (options?.persistFallback !== false) addChatMessage(next); @@ -1114,8 +1114,8 @@ export const Bot = (botProps: BotProps & { class?: string }) => { addChatMessage(prev); return prev; }); + setLocalStorageChatflow(chatflowid, chatId); } - setLocalStorageChatflow(chatflowid, chatId); closeResponse(); break; case 'tts_start': diff --git a/src/state/sessionStore.ts b/src/state/sessionStore.ts index d561ec04d..b5b3c8531 100644 --- a/src/state/sessionStore.ts +++ b/src/state/sessionStore.ts @@ -187,6 +187,8 @@ export const createSessionStore = (opts: SessionStoreOptions) => { if (existingIdx >= 0) { next = [...cached]; next[existingIdx] = msg; + } else if (options?.replaceId !== undefined) { + return; // explicit replace target missing: no-op } else { next = [...cached, msg]; } @@ -247,6 +249,12 @@ export const createSessionStore = (opts: SessionStoreOptions) => { pendingPersist = null; withQuotaRecovery(() => writeMessages(chatflowid, id, next)); }, 150); + const current = index(); + const sIdx = current.sessions.findIndex((s) => s.chatId === id); + if (sIdx < 0) return; + const sessions = [...current.sessions]; + sessions[sIdx] = { ...sessions[sIdx], updatedAt: Date.now() }; + withQuotaRecovery(() => _persistIndex({ ...current, sessions })); }; const renameSession = (chatId: string, rawTitle: string): void => { From c54f742de3fc49e8f46120e178244d67c30bfcad Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Fri, 1 May 2026 13:32:57 -0700 Subject: [PATCH 35/66] feat(sessions): best-effort stream abort on session switch Co-Authored-By: Claude Sonnet 4.6 --- src/components/Bot.tsx | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/components/Bot.tsx b/src/components/Bot.tsx index 0a9a63a91..0973492e7 100644 --- a/src/components/Bot.tsx +++ b/src/components/Bot.tsx @@ -648,6 +648,32 @@ export const Bot = (botProps: BotProps & { class?: string }) => { window.removeEventListener('beforeunload', flush); }); } + + // Best-effort abort of any in-flight stream when the user switches to a different session. + if (sessionStore) { + let lastSeenChatId = sessionStore.activeChatId(); + createEffect( + on( + () => sessionStore.activeChatId(), + (current) => { + if (current === lastSeenChatId) return; + const previousChatId = lastSeenChatId; + lastSeenChatId = current; + // Only abort if there was an in-flight request for the previous session. + if (!loading()) return; + // Fire-and-forget: don't await, don't surface errors. Best-effort. + abortMessageQuery({ + chatflowid: props.chatflowid, + apiHost: props.apiHost, + chatId: previousChatId, + onRequest: props.onRequest, + }).catch(() => { + /* best-effort abort; ignore failures */ + }); + }, + ), + ); + } }); let programmaticScrollGuard: (fn: () => void) => void = (fn) => fn(); From f3d060f1163b4178b7ed3bd942f584122c9f9102 Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Fri, 1 May 2026 13:39:38 -0700 Subject: [PATCH 36/66] feat(sessions): custom events for new/switch/changed and repurpose clear-chat Co-Authored-By: Claude Sonnet 4.6 --- src/components/Bot.tsx | 26 ++++++++++++++------------ src/components/sessions/ChatRoot.tsx | 24 ++++++++++++++++++++++++ src/state/sessionStore.ts | 20 ++++++++++++++++++++ 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/src/components/Bot.tsx b/src/components/Bot.tsx index 0973492e7..6cabde1b1 100644 --- a/src/components/Bot.tsx +++ b/src/components/Bot.tsx @@ -613,18 +613,20 @@ export const Bot = (botProps: BotProps & { class?: string }) => { chatContainer?.addEventListener('scroll', handleScroll, { passive: true }); onCleanup(() => chatContainer?.removeEventListener('scroll', handleScroll)); - const handleExternalClearChat = async (e: Event) => { - const targetId = (e as CustomEvent).detail?.id; - if (targetId) { - const root = chatContainer?.getRootNode(); - const hostEl = root instanceof ShadowRoot ? root.host : chatContainer?.closest(`#${CSS.escape(targetId)}`); - if (!hostEl || hostEl.id !== targetId) return; - } - if (loading()) await handleAbort(); - clearChat(); - }; - document.addEventListener('flowise-clear-chat', handleExternalClearChat); - onCleanup(() => document.removeEventListener('flowise-clear-chat', handleExternalClearChat)); + if (!sessionStore) { + const handleExternalClearChat = async (e: Event) => { + const targetId = (e as CustomEvent).detail?.id; + if (targetId) { + const root = chatContainer?.getRootNode(); + const hostEl = root instanceof ShadowRoot ? root.host : chatContainer?.closest(`#${CSS.escape(targetId)}`); + if (!hostEl || hostEl.id !== targetId) return; + } + if (loading()) await handleAbort(); + clearChat(); + }; + document.addEventListener('flowise-clear-chat', handleExternalClearChat); + onCleanup(() => document.removeEventListener('flowise-clear-chat', handleExternalClearChat)); + } // Expose programmatic scroll guard to outer scope let guardTimeout: ReturnType | null = null; diff --git a/src/components/sessions/ChatRoot.tsx b/src/components/sessions/ChatRoot.tsx index 6d5d3f983..1ad66da47 100644 --- a/src/components/sessions/ChatRoot.tsx +++ b/src/components/sessions/ChatRoot.tsx @@ -49,6 +49,30 @@ const ChatRootEnabled = (props: ChatRootProps) => { onMount(() => window.addEventListener('flowise-toggle-session-drawer', onToggleDrawer)); onCleanup(() => window.removeEventListener('flowise-toggle-session-drawer', onToggleDrawer)); + const onNew = () => store.actions.newChat(); + const onSwitch = (e: Event) => { + const detail = (e as CustomEvent<{ chatId?: string }>).detail; + if (detail?.chatId) store.actions.switchSession(detail.chatId); + }; + const onClear = () => { + store.actions.deleteSession(store.activeChatId()); + }; + + store.actions.setOnSessionChanged((detail) => { + window.dispatchEvent(new CustomEvent('flowise-session-changed', { detail })); + }); + + onMount(() => { + window.addEventListener('flowise-new-session', onNew); + window.addEventListener('flowise-switch-session', onSwitch); + window.addEventListener('flowise-clear-chat', onClear); + }); + onCleanup(() => { + window.removeEventListener('flowise-new-session', onNew); + window.removeEventListener('flowise-switch-session', onSwitch); + window.removeEventListener('flowise-clear-chat', onClear); + }); + return (
diff --git a/src/state/sessionStore.ts b/src/state/sessionStore.ts index b5b3c8531..1e5efe26c 100644 --- a/src/state/sessionStore.ts +++ b/src/state/sessionStore.ts @@ -53,6 +53,17 @@ export const createSessionStore = (opts: SessionStoreOptions) => { setIndex(next); }; + // ---- session-changed callback ---- + let onSessionChanged: ((detail: { chatId: string; title: string }) => void) | null = null; + const setOnSessionChanged = (cb: typeof onSessionChanged) => { + onSessionChanged = cb; + }; + const emitSessionChanged = () => { + if (!onSessionChanged) return; + const s = activeSession(); + if (s) onSessionChanged({ chatId: s.chatId, title: s.title }); + }; + /** * Run a write op; on QuotaExceededError, evict the oldest non-active session and retry. * Up to `attempts` retries; if it still fails, surfaces a callback to show a toast. @@ -132,6 +143,7 @@ export const createSessionStore = (opts: SessionStoreOptions) => { withQuotaRecovery(() => _persistIndex(next)); setActiveMessages([]); + emitSessionChanged(); }); for (const eid of evicted) { @@ -159,6 +171,7 @@ export const createSessionStore = (opts: SessionStoreOptions) => { batch(() => { withQuotaRecovery(() => _persistIndex({ ...index(), activeChatId: chatId })); setActiveMessages(messages!); + emitSessionChanged(); }); }; @@ -270,6 +283,9 @@ export const createSessionStore = (opts: SessionStoreOptions) => { const sessions = [...current.sessions]; sessions[sIdx] = { ...sessions[sIdx], title: nextTitle }; withQuotaRecovery(() => _persistIndex({ ...current, sessions })); + // Only emit session-changed when the renamed session is the active one, + // since the event detail includes the title listeners care about. + if (chatId === current.activeChatId) emitSessionChanged(); }; const deleteSession = (chatId: string): void => { @@ -295,6 +311,9 @@ export const createSessionStore = (opts: SessionStoreOptions) => { setActiveMessages(cached); } withQuotaRecovery(() => _persistIndex({ ...current, activeChatId: nextActive, sessions })); + // Emit only when the active session actually changed (deleted the active one). + // When sessions.length === 0, newChat() above already emits — don't double-emit. + if (nextActive !== current.activeChatId) emitSessionChanged(); }; const setLead = (lead: LeadCaptureData | undefined): void => { @@ -321,6 +340,7 @@ export const createSessionStore = (opts: SessionStoreOptions) => { setLead, flushPending, setQuotaPanicHandler, + setOnSessionChanged, dismissCapWarning: () => setCapWarning(false), }, _internal: { From 7cd0cc03e0543ce35335ee9f4c4f9a8ef4d4f46a Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Fri, 1 May 2026 13:41:39 -0700 Subject: [PATCH 37/66] feat(sessions): cross-tab sync via storage event Add a `window.storage` listener in `createSessionStore` that picks up index changes written by other tabs/windows for the same chatflowid. When the active session changes cross-tab the new session's messages are loaded into the cache and surfaced via `setActiveMessages`. A `dispose` function is exposed on the store (top-level, not in `actions`) and called from `ChatRootEnabled`'s `onCleanup` to remove the listener on teardown. Co-Authored-By: Claude Sonnet 4.6 --- src/components/sessions/ChatRoot.tsx | 1 + src/state/sessionStore.ts | 31 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/components/sessions/ChatRoot.tsx b/src/components/sessions/ChatRoot.tsx index 1ad66da47..08b6f257b 100644 --- a/src/components/sessions/ChatRoot.tsx +++ b/src/components/sessions/ChatRoot.tsx @@ -71,6 +71,7 @@ const ChatRootEnabled = (props: ChatRootProps) => { window.removeEventListener('flowise-new-session', onNew); window.removeEventListener('flowise-switch-session', onSwitch); window.removeEventListener('flowise-clear-chat', onClear); + store.dispose(); }); return ( diff --git a/src/state/sessionStore.ts b/src/state/sessionStore.ts index 1e5efe26c..754d9022a 100644 --- a/src/state/sessionStore.ts +++ b/src/state/sessionStore.ts @@ -35,6 +35,36 @@ export const createSessionStore = (opts: SessionStoreOptions) => { const [index, setIndex] = createSignal(initial); + // ---- cross-tab sync ---- + const indexLsKey = `${chatflowid}_EXTERNAL`; + const onStorage = (e: StorageEvent) => { + if (e.key !== indexLsKey || e.newValue === null) return; + try { + const parsed = JSON.parse(e.newValue); + if (parsed && typeof parsed === 'object' && parsed.version === 2) { + const next = parsed as ChatflowIndexV2; + setIndex(next); + // Also re-read active session's messages if active changed underneath. + if (next.activeChatId !== activeChatId()) { + const msgs = readMessages(chatflowid, next.activeChatId); + messageCache.set(next.activeChatId, msgs); + setActiveMessages(msgs); + } + } + } catch { + // ignore corrupt cross-tab write + } + }; + if (typeof window !== 'undefined') { + window.addEventListener('storage', onStorage); + } + + const dispose = () => { + if (typeof window !== 'undefined') { + window.removeEventListener('storage', onStorage); + } + }; + // Lazy in-memory cache: chatId → messages. Populated on read. const messageCache = new Map(); messageCache.set(initial.activeChatId, readMessages(chatflowid, initial.activeChatId)); @@ -329,6 +359,7 @@ export const createSessionStore = (opts: SessionStoreOptions) => { activeMessages, lead, capWarning, + dispose, actions: { newChat, switchSession, From aac5b5f6abe17f476d92d19fc8e6ce5e05949d6c Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Fri, 1 May 2026 13:45:14 -0700 Subject: [PATCH 38/66] fix(sessions): capture previous activeChatId before setIndex in cross-tab handler --- src/state/sessionStore.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/state/sessionStore.ts b/src/state/sessionStore.ts index 754d9022a..a47b77a4a 100644 --- a/src/state/sessionStore.ts +++ b/src/state/sessionStore.ts @@ -43,9 +43,10 @@ export const createSessionStore = (opts: SessionStoreOptions) => { const parsed = JSON.parse(e.newValue); if (parsed && typeof parsed === 'object' && parsed.version === 2) { const next = parsed as ChatflowIndexV2; + const prevActiveChatId = activeChatId(); setIndex(next); // Also re-read active session's messages if active changed underneath. - if (next.activeChatId !== activeChatId()) { + if (next.activeChatId !== prevActiveChatId) { const msgs = readMessages(chatflowid, next.activeChatId); messageCache.set(next.activeChatId, msgs); setActiveMessages(msgs); From 41cdac18cb3706ae13cce82618d11a293977c7fe Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Fri, 1 May 2026 13:47:40 -0700 Subject: [PATCH 39/66] feat(sessions): wire ChatRoot into bubble mode Swap for in Bubble.tsx and pass multiSession and theme props so the drawer/session-panel cascade works in bubble mode. Co-Authored-By: Claude Sonnet 4.6 --- src/features/bubble/components/Bubble.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/features/bubble/components/Bubble.tsx b/src/features/bubble/components/Bubble.tsx index e22526568..a836b115b 100644 --- a/src/features/bubble/components/Bubble.tsx +++ b/src/features/bubble/components/Bubble.tsx @@ -2,7 +2,8 @@ import { createSignal, Show, splitProps, onCleanup, createEffect, onMount } from import styles from '../../../assets/index.css'; import { BubbleButton } from './BubbleButton'; import { BubbleParams } from '../types'; -import { Bot, BotProps } from '../../../components/Bot'; +import type { BotProps } from '../../../components/Bot'; +import { ChatRoot } from '@/components/sessions/ChatRoot'; import Tooltip from './Tooltip'; import { getBubbleButtonSize, resolveDialogContainer } from '@/utils'; import DOMPurify from 'dompurify'; @@ -175,7 +176,7 @@ export const Bubble = (props: BubbleProps) => { - { hasCustomHeader={!!bubbleProps.theme?.chatWindow?.headerHtml} closeBot={closeBot} dialogContainer={resolveDialogContainer(props.dialogContainer) ?? bubbleContainerRef()} + multiSession={props.multiSession} + theme={bubbleProps.theme} />
From bc300b6705cec58ba98b9f53c2596864bcacf8a9 Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Fri, 1 May 2026 13:50:27 -0700 Subject: [PATCH 40/66] feat(sessions): keyboard nav, focus trap, and Escape-to-close Co-Authored-By: Claude Sonnet 4.6 --- src/components/sessions/SessionPanel.tsx | 44 ++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/components/sessions/SessionPanel.tsx b/src/components/sessions/SessionPanel.tsx index 1c91ec0ac..e64974b3f 100644 --- a/src/components/sessions/SessionPanel.tsx +++ b/src/components/sessions/SessionPanel.tsx @@ -1,4 +1,4 @@ -import { For, Show, createSignal, type JSX } from 'solid-js'; +import { For, Show, createEffect, createSignal, onCleanup, onMount, type JSX } from 'solid-js'; import type { SessionStore } from '@/state/sessionStore'; import { readPanelCollapsed, writePanelCollapsed } from '@/state/sessionStorage'; import { SessionListItem } from './SessionListItem'; @@ -65,6 +65,45 @@ export const SessionPanel = (props: Props) => { if (props.isDrawer) props.onDrawerClose?.(); }; + const onListKey = (e: KeyboardEvent) => { + const target = e.currentTarget as HTMLElement; + const items = target.querySelectorAll('[role="listitem"]'); + if (items.length === 0) return; + const focused = document.activeElement as HTMLElement | null; + let idx = -1; + items.forEach((el, i) => { + if (el === focused) idx = i; + }); + if (e.key === 'ArrowDown') { + e.preventDefault(); + items[Math.min(items.length - 1, Math.max(idx + 1, 0))].focus(); + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + items[Math.max(0, idx - 1)].focus(); + } + }; + + let drawerRoot: HTMLElement | undefined; + + createEffect(() => { + if (!props.isDrawer) return; + const open = props.drawerOpen?.() ?? false; + if (open && drawerRoot) { + queueMicrotask(() => { + const first = drawerRoot?.querySelector('button, [tabindex="0"]'); + first?.focus(); + }); + } + }); + + const onEscapeKey = (e: KeyboardEvent) => { + if (!props.isDrawer || !(props.drawerOpen?.() ?? false)) return; + if (e.key === 'Escape') props.onDrawerClose?.(); + }; + onMount(() => document.addEventListener('keydown', onEscapeKey)); + onCleanup(() => document.removeEventListener('keydown', onEscapeKey)); + // Render helper, NOT a component. Do not add lifecycle primitives // (createEffect, onMount, onCleanup) or new signals here — those must live // in component scope so Solid can track ownership correctly. Pure JSX + @@ -133,7 +172,7 @@ export const SessionPanel = (props: Props) => { when={sessions().length > 0} fallback={
{emptyText()}
} > -
+
{(s) => ( { aria-hidden="true" /> From c8bf144bf5a7753c2a138343bb377e1bf45b10b8 Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Fri, 1 May 2026 14:50:43 -0700 Subject: [PATCH 47/66] fix(sessions): match SessionPanel font to Bot's chatbot-container stack SessionPanel renders as a sibling of , both children of the ChatRoot wrapper. sets the Open Sans + system font stack via the .chatbot-container CSS class, but the panel was inheriting the host element's default (typically Times/system serif), making the typography visibly inconsistent across the panel and chat. Apply the same font stack inline to the ChatRoot wrapper so both children inherit the same family. Kept inline (rather than adding the chatbot-container class) because that class also pulls in CSS vars (--chatbot-container-bg-image / -bg-color) which only resolve inside shadow DOM and would otherwise leave the wrapper with undefined fallbacks. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/sessions/ChatRoot.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/sessions/ChatRoot.tsx b/src/components/sessions/ChatRoot.tsx index 271445fa6..68896b1fd 100644 --- a/src/components/sessions/ChatRoot.tsx +++ b/src/components/sessions/ChatRoot.tsx @@ -84,7 +84,18 @@ const ChatRootEnabled = (props: ChatRootProps) => { return ( -
+
) inherits the same typography. + // Keeping this in sync with src/assets/index.css `.chatbot-container`. + 'font-family': + "'Open Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'", + }} + > Date: Fri, 1 May 2026 14:53:25 -0700 Subject: [PATCH 48/66] fix(sessions): keep rename input alive when streaming bumps session updatedAt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each streamed token calls upsertMessageInSession which bumps the streaming session's updatedAt and creates a new SessionV2 reference. Solid's keys items by reference identity, so the SessionListItem for the streaming session was being unmounted+remounted every token — which reset the local `editing` signal back to false, making it impossible to rename the active session mid-stream. Lift the edit + delete-confirm state up to SessionPanel, keyed by chatId, so the lifetime of those flags is independent of 's row remounts. SessionListItem becomes a controlled component: editing, editingDraft, and confirmingDelete come in as props, with corresponding callbacks for start/change/commit/cancel. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/sessions/SessionListItem.tsx | 83 ++++++++++++--------- src/components/sessions/SessionPanel.tsx | 36 ++++++++- 2 files changed, 82 insertions(+), 37 deletions(-) diff --git a/src/components/sessions/SessionListItem.tsx b/src/components/sessions/SessionListItem.tsx index 01fab02ca..50b7ba35e 100644 --- a/src/components/sessions/SessionListItem.tsx +++ b/src/components/sessions/SessionListItem.tsx @@ -12,47 +12,43 @@ type Theme = { type Props = { session: SessionV2; active: boolean; + // Edit + delete state is owned by the parent SessionPanel and keyed by chatId + // so it survives re-mounts triggered by streaming-driven updatedAt bumps. + editing: boolean; + editingDraft: string; + confirmingDelete: boolean; theme: Theme; onSwitch: () => void; - onRename: (next: string) => void; - onDelete: () => void; + onStartEdit: () => void; + onChangeDraft: (next: string) => void; + onCommitEdit: () => void; + onCancelEdit: () => void; + onStartDelete: () => void; + onCancelDelete: () => void; + onConfirmDelete: () => void; }; export const SessionListItem = (props: Props) => { - const [editing, setEditing] = createSignal(false); - const [draft, setDraft] = createSignal(props.session.title); - const [confirmingDelete, setConfirmingDelete] = createSignal(false); const [hovered, setHovered] = createSignal(false); const startEdit = (e: MouseEvent) => { e.stopPropagation(); - setDraft(props.session.title); - setEditing(true); - }; - const commit = () => { - if (editing()) { - props.onRename(draft()); - setEditing(false); - } - }; - const cancel = () => { - setDraft(props.session.title); - setEditing(false); + props.onStartEdit(); }; const onClick = () => { - if (editing() || confirmingDelete()) return; + if (props.editing || props.confirmingDelete) return; props.onSwitch(); }; const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter' && !editing() && !confirmingDelete()) { + if (e.key === 'Enter' && !props.editing && !props.confirmingDelete) { e.preventDefault(); props.onSwitch(); } - if (e.key === 'Delete' && !editing()) { + if (e.key === 'Delete' && !props.editing) { e.preventDefault(); - setConfirmingDelete(true); + props.onStartDelete(); } }; @@ -98,10 +94,10 @@ export const SessionListItem = (props: Props) => { /> Delete? @@ -109,8 +105,7 @@ export const SessionListItem = (props: Props) => { type="button" onClick={(e) => { e.stopPropagation(); - props.onDelete(); - setConfirmingDelete(false); + props.onConfirmDelete(); }} style={{ background: '#dc2626', @@ -129,7 +124,7 @@ export const SessionListItem = (props: Props) => { type="button" onClick={(e) => { e.stopPropagation(); - setConfirmingDelete(false); + props.onCancelDelete(); }} style={{ background: 'transparent', @@ -148,15 +143,15 @@ export const SessionListItem = (props: Props) => { > setDraft(e.currentTarget.value)} + value={props.editingDraft} + onInput={(e) => props.onChangeDraft(e.currentTarget.value)} onClick={(e) => e.stopPropagation()} onKeyDown={(e) => { e.stopPropagation(); - if (e.key === 'Enter') commit(); - if (e.key === 'Escape') cancel(); + if (e.key === 'Enter') props.onCommitEdit(); + if (e.key === 'Escape') props.onCancelEdit(); }} - onBlur={cancel} + onBlur={() => props.onCancelEdit()} ref={(el) => { if (el) { setTimeout(() => { @@ -222,7 +217,17 @@ export const SessionListItem = (props: Props) => { e.currentTarget.style.background = 'transparent'; }} > - From 3ec75c633843e6631f2666853e692191ddecfae6 Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Fri, 1 May 2026 15:07:26 -0700 Subject: [PATCH 49/66] feat(sessions): starred section + transparent chat-name header with menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In multi-session mode, the chat now renders a transparent header at the top of showing only the active session title (left-aligned), replacing the blue title bar + Clear button. Clicking the title opens a menu with three actions: - Star / Unstar — toggles a `starred` flag on the session, optimistically pinning it to a new "Starred" section in the SessionPanel above "Recents" (matching Claude's two-section layout). Within each section, sessions are ordered by updatedAt desc. - Rename — swaps the title into an inline input; Enter commits, Escape cancels. Routes to the same store.actions.renameSession the panel uses, so edits sync everywhere. - Delete — calls store.actions.deleteSession; SessionPanel reflects the removal immediately via the index signal. Bubble/popup modes get a hamburger button on the left of the new header (replacing the previous ☰ unicode glyph with a proper SVG icon). Data layer: - SessionV2 gets an optional `starred?: boolean` field (backward compat: absence === false). - SessionStore exposes `starredSessions` / `recentSessions` memos and a new `toggleStarred(chatId)` action. - SessionListItem gains an `onToggleStar` prop and renders a star toggle in its action row. --- dist/components/Bot.d.ts | 5 + dist/components/Bot.d.ts.map | 2 +- dist/components/index.d.ts | 1 + dist/components/index.d.ts.map | 2 +- dist/components/sessions/CapWarningToast.d.ts | 8 + .../sessions/CapWarningToast.d.ts.map | 1 + dist/components/sessions/ChatRoot.d.ts | 10 + dist/components/sessions/ChatRoot.d.ts.map | 1 + dist/components/sessions/SessionListItem.d.ts | 28 + .../sessions/SessionListItem.d.ts.map | 1 + dist/components/sessions/SessionPanel.d.ts | 31 + .../components/sessions/SessionPanel.d.ts.map | 1 + .../sessions/SessionTitleHeader.d.ts | 16 + .../sessions/SessionTitleHeader.d.ts.map | 1 + dist/constants.d.ts.map | 2 +- dist/features/bubble/components/Bubble.d.ts | 2 +- .../bubble/components/Bubble.d.ts.map | 2 +- dist/features/bubble/types.d.ts | 15 + dist/features/bubble/types.d.ts.map | 2 +- dist/features/full/components/Full.d.ts | 2 +- dist/features/full/components/Full.d.ts.map | 2 +- dist/state/sessionMigration.d.ts | 12 + dist/state/sessionMigration.d.ts.map | 1 + dist/state/sessionStorage.d.ts | 43 + dist/state/sessionStorage.d.ts.map | 1 + dist/state/sessionStore.d.ts | 54 + dist/state/sessionStore.d.ts.map | 1 + dist/utils/index.d.ts | 10 + dist/utils/index.d.ts.map | 2 +- dist/utils/titleFromMessage.d.ts | 3 + dist/utils/titleFromMessage.d.ts.map | 1 + dist/web.js | 86854 ++++++++++++++- dist/web.umd.js | 86862 +++++++++++++++- .../2026-04-29-multi-session-chat.md | 6 +- src/components/Bot.tsx | 30 +- src/components/sessions/SessionListItem.tsx | 47 + src/components/sessions/SessionPanel.tsx | 115 +- .../sessions/SessionTitleHeader.tsx | 353 + src/state/sessionMigration.ts | 7 +- src/state/sessionStorage.ts | 21 +- src/state/sessionStore.ts | 17 + src/utils/index.ts | 6 +- 42 files changed, 174483 insertions(+), 98 deletions(-) create mode 100644 dist/components/sessions/CapWarningToast.d.ts create mode 100644 dist/components/sessions/CapWarningToast.d.ts.map create mode 100644 dist/components/sessions/ChatRoot.d.ts create mode 100644 dist/components/sessions/ChatRoot.d.ts.map create mode 100644 dist/components/sessions/SessionListItem.d.ts create mode 100644 dist/components/sessions/SessionListItem.d.ts.map create mode 100644 dist/components/sessions/SessionPanel.d.ts create mode 100644 dist/components/sessions/SessionPanel.d.ts.map create mode 100644 dist/components/sessions/SessionTitleHeader.d.ts create mode 100644 dist/components/sessions/SessionTitleHeader.d.ts.map create mode 100644 dist/state/sessionMigration.d.ts create mode 100644 dist/state/sessionMigration.d.ts.map create mode 100644 dist/state/sessionStorage.d.ts create mode 100644 dist/state/sessionStorage.d.ts.map create mode 100644 dist/state/sessionStore.d.ts create mode 100644 dist/state/sessionStore.d.ts.map create mode 100644 dist/utils/titleFromMessage.d.ts create mode 100644 dist/utils/titleFromMessage.d.ts.map create mode 100644 src/components/sessions/SessionTitleHeader.tsx diff --git a/dist/components/Bot.d.ts b/dist/components/Bot.d.ts index 95a35bde9..084a54090 100644 --- a/dist/components/Bot.d.ts +++ b/dist/components/Bot.d.ts @@ -83,6 +83,10 @@ export type MessageType = { }; type observerConfigType = (accessor: string | boolean | object | MessageType[]) => void; export type observersConfigType = Record<'observeUserInput' | 'observeLoading' | 'observeMessages', observerConfigType>; +export type MultiSessionConfig = { + enabled: boolean; + maxSessions?: number; +}; export type BotProps = { chatflowid: string; apiHost?: string; @@ -124,6 +128,7 @@ export type BotProps = { closeBot?: () => void; hasCustomHeader?: boolean; dialogContainer?: HTMLElement; + multiSession?: MultiSessionConfig; }; export type LeadsConfig = { status: boolean; diff --git a/dist/components/Bot.d.ts.map b/dist/components/Bot.d.ts.map index 08434ea73..82104dedd 100644 --- a/dist/components/Bot.d.ts.map +++ b/dist/components/Bot.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"Bot.d.ts","sourceRoot":"","sources":["../../src/components/Bot.tsx"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"Bot.d.ts","sourceRoot":"","sources":["../../src/components/Bot.tsx"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/components/index.d.ts b/dist/components/index.d.ts index 8e2814056..2880a4b5e 100644 --- a/dist/components/index.d.ts +++ b/dist/components/index.d.ts @@ -1,3 +1,4 @@ export * from './buttons/SendButton'; export * from './TypingBubble'; +export * from './sessions/ChatRoot'; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/components/index.d.ts.map b/dist/components/index.d.ts.map index 87bc3a508..9a0ece33b 100644 --- a/dist/components/index.d.ts.map +++ b/dist/components/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,qBAAqB,CAAC"} \ No newline at end of file diff --git a/dist/components/sessions/CapWarningToast.d.ts b/dist/components/sessions/CapWarningToast.d.ts new file mode 100644 index 000000000..5630db9fc --- /dev/null +++ b/dist/components/sessions/CapWarningToast.d.ts @@ -0,0 +1,8 @@ +type Props = { + visible: boolean; + text: string; + onDismiss: () => void; +}; +export declare const CapWarningToast: (props: Props) => import("solid-js").JSX.Element; +export {}; +//# sourceMappingURL=CapWarningToast.d.ts.map \ No newline at end of file diff --git a/dist/components/sessions/CapWarningToast.d.ts.map b/dist/components/sessions/CapWarningToast.d.ts.map new file mode 100644 index 000000000..23ffbc4e4 --- /dev/null +++ b/dist/components/sessions/CapWarningToast.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"CapWarningToast.d.ts","sourceRoot":"","sources":["../../../src/components/sessions/CapWarningToast.tsx"],"names":[],"mappings":"AAEA,KAAK,KAAK,GAAG;IACX,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,IAAI,CAAC;CACvB,CAAC;AAEF,eAAO,MAAM,eAAe,UAAW,KAAK,mCAoC3C,CAAC"} \ No newline at end of file diff --git a/dist/components/sessions/ChatRoot.d.ts b/dist/components/sessions/ChatRoot.d.ts new file mode 100644 index 000000000..bcd09538a --- /dev/null +++ b/dist/components/sessions/ChatRoot.d.ts @@ -0,0 +1,10 @@ +import { type BotProps } from '@/components/Bot'; +import { type SessionStore } from '@/state/sessionStore'; +export declare const useSessionStore: () => SessionStore | undefined; +type ChatRootProps = BotProps & { + class?: string; + theme?: unknown; +}; +export declare const ChatRoot: (props: ChatRootProps) => import("solid-js").JSX.Element; +export {}; +//# sourceMappingURL=ChatRoot.d.ts.map \ No newline at end of file diff --git a/dist/components/sessions/ChatRoot.d.ts.map b/dist/components/sessions/ChatRoot.d.ts.map new file mode 100644 index 000000000..acf57b86f --- /dev/null +++ b/dist/components/sessions/ChatRoot.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"ChatRoot.d.ts","sourceRoot":"","sources":["../../../src/components/sessions/ChatRoot.tsx"],"names":[],"mappings":"AACA,OAAO,EAAO,KAAK,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAEtD,OAAO,EAAsB,KAAK,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAK7E,eAAO,MAAM,eAAe,QAAO,YAAY,GAAG,SAAuC,CAAC;AAI1F,KAAK,aAAa,GAAG,QAAQ,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAEpE,eAAO,MAAM,QAAQ,UAAW,aAAa,mCAQ5C,CAAC"} \ No newline at end of file diff --git a/dist/components/sessions/SessionListItem.d.ts b/dist/components/sessions/SessionListItem.d.ts new file mode 100644 index 000000000..0d9b54395 --- /dev/null +++ b/dist/components/sessions/SessionListItem.d.ts @@ -0,0 +1,28 @@ +import type { SessionV2 } from '@/state/sessionStorage'; +type Theme = { + textColor: string; + activeBackgroundColor: string; + activeTextColor: string; + hoverBackgroundColor: string; + accentColor: string; +}; +type Props = { + session: SessionV2; + active: boolean; + editing: boolean; + editingDraft: string; + confirmingDelete: boolean; + theme: Theme; + onSwitch: () => void; + onStartEdit: () => void; + onChangeDraft: (next: string) => void; + onCommitEdit: () => void; + onCancelEdit: () => void; + onStartDelete: () => void; + onCancelDelete: () => void; + onConfirmDelete: () => void; + onToggleStar?: () => void; +}; +export declare const SessionListItem: (props: Props) => import("solid-js").JSX.Element; +export {}; +//# sourceMappingURL=SessionListItem.d.ts.map \ No newline at end of file diff --git a/dist/components/sessions/SessionListItem.d.ts.map b/dist/components/sessions/SessionListItem.d.ts.map new file mode 100644 index 000000000..68a48c9e8 --- /dev/null +++ b/dist/components/sessions/SessionListItem.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"SessionListItem.d.ts","sourceRoot":"","sources":["../../../src/components/sessions/SessionListItem.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAExD,KAAK,KAAK,GAAG;IACX,SAAS,EAAE,MAAM,CAAC;IAClB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,eAAe,EAAE,MAAM,CAAC;IACxB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,KAAK,KAAK,GAAG;IACX,OAAO,EAAE,SAAS,CAAC;IACnB,MAAM,EAAE,OAAO,CAAC;IAGhB,OAAO,EAAE,OAAO,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,KAAK,EAAE,KAAK,CAAC;IACb,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,WAAW,EAAE,MAAM,IAAI,CAAC;IACxB,aAAa,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,YAAY,EAAE,MAAM,IAAI,CAAC;IACzB,YAAY,EAAE,MAAM,IAAI,CAAC;IACzB,aAAa,EAAE,MAAM,IAAI,CAAC;IAC1B,cAAc,EAAE,MAAM,IAAI,CAAC;IAC3B,eAAe,EAAE,MAAM,IAAI,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;CAC3B,CAAC;AAEF,eAAO,MAAM,eAAe,UAAW,KAAK,mCAgT3C,CAAC"} \ No newline at end of file diff --git a/dist/components/sessions/SessionPanel.d.ts b/dist/components/sessions/SessionPanel.d.ts new file mode 100644 index 000000000..ef8e93997 --- /dev/null +++ b/dist/components/sessions/SessionPanel.d.ts @@ -0,0 +1,31 @@ +import { type JSX } from 'solid-js'; +import type { SessionStore } from '@/state/sessionStore'; +type SessionPanelTheme = { + width?: string | number; + collapsedWidth?: string | number; + backgroundColor?: string; + textColor?: string; + activeBackgroundColor?: string; + activeTextColor?: string; + hoverBackgroundColor?: string; + borderColor?: string; + newChatButtonColor?: string; + newChatButtonTextColor?: string; + newChatLabel?: string; + emptyStateText?: string; + capWarningText?: string; +}; +type Props = { + store: SessionStore; + isFullPage: boolean; + isDrawer: boolean; + drawerOpen?: () => boolean; + onDrawerClose?: () => void; + panelTheme?: SessionPanelTheme; + chatWindowBackground?: string; + chatWindowText?: string; + chatBrandColor: string; +}; +export declare const SessionPanel: (props: Props) => JSX.Element; +export {}; +//# sourceMappingURL=SessionPanel.d.ts.map \ No newline at end of file diff --git a/dist/components/sessions/SessionPanel.d.ts.map b/dist/components/sessions/SessionPanel.d.ts.map new file mode 100644 index 000000000..269b13a10 --- /dev/null +++ b/dist/components/sessions/SessionPanel.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"SessionPanel.d.ts","sourceRoot":"","sources":["../../../src/components/sessions/SessionPanel.tsx"],"names":[],"mappings":"AAAA,OAAO,EAA6D,KAAK,GAAG,EAAE,MAAM,UAAU,CAAC;AAC/F,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAKzD,KAAK,iBAAiB,GAAG;IACvB,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACjC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF,KAAK,KAAK,GAAG;IACX,KAAK,EAAE,YAAY,CAAC;IACpB,UAAU,EAAE,OAAO,CAAC;IACpB,QAAQ,EAAE,OAAO,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,OAAO,CAAC;IAC3B,aAAa,CAAC,EAAE,MAAM,IAAI,CAAC;IAC3B,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAE/B,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,cAAc,CAAC,EAAE,MAAM,CAAC;IAIxB,cAAc,EAAE,MAAM,CAAC;CACxB,CAAC;AAUF,eAAO,MAAM,YAAY,UAAW,KAAK,gBA8XxC,CAAC"} \ No newline at end of file diff --git a/dist/components/sessions/SessionTitleHeader.d.ts b/dist/components/sessions/SessionTitleHeader.d.ts new file mode 100644 index 000000000..ad248f325 --- /dev/null +++ b/dist/components/sessions/SessionTitleHeader.d.ts @@ -0,0 +1,16 @@ +type Props = { + isFullPage: boolean; + textColor?: string; + bubbleBackground?: string; +}; +/** + * Transparent header shown at the top of when multiSession is enabled. + * Replaces the blue title bar + Clear button with a minimal "left-aligned chat + * name" + click-menu (Star / Rename / Delete) — matching ChatGPT/Claude/Gemini. + * + * On non-full-page mounts (bubble/popup drawer mode), a hamburger button on the + * left toggles the session drawer. + */ +export declare const SessionTitleHeader: (props: Props) => import("solid-js").JSX.Element; +export {}; +//# sourceMappingURL=SessionTitleHeader.d.ts.map \ No newline at end of file diff --git a/dist/components/sessions/SessionTitleHeader.d.ts.map b/dist/components/sessions/SessionTitleHeader.d.ts.map new file mode 100644 index 000000000..50b9dba09 --- /dev/null +++ b/dist/components/sessions/SessionTitleHeader.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"SessionTitleHeader.d.ts","sourceRoot":"","sources":["../../../src/components/sessions/SessionTitleHeader.tsx"],"names":[],"mappings":"AAGA,KAAK,KAAK,GAAG;IACX,UAAU,EAAE,OAAO,CAAC;IAIpB,SAAS,CAAC,EAAE,MAAM,CAAC;IAGnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB,UAAW,KAAK,mCA2S9C,CAAC"} \ No newline at end of file diff --git a/dist/constants.d.ts.map b/dist/constants.d.ts.map index 41d60f132..5861abea6 100644 --- a/dist/constants.d.ts.map +++ b/dist/constants.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,eAAO,MAAM,eAAe,EAAE,WAQ7B,CAAC;AAEF,eAAO,MAAM,kBAAkB,KAAK,CAAC"} \ No newline at end of file +{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,eAAO,MAAM,eAAe,EAAE,WAS7B,CAAC;AAEF,eAAO,MAAM,kBAAkB,KAAK,CAAC"} \ No newline at end of file diff --git a/dist/features/bubble/components/Bubble.d.ts b/dist/features/bubble/components/Bubble.d.ts index 2828d64ee..3da4f12ea 100644 --- a/dist/features/bubble/components/Bubble.d.ts +++ b/dist/features/bubble/components/Bubble.d.ts @@ -1,5 +1,5 @@ import { BubbleParams } from '../types'; -import { BotProps } from '../../../components/Bot'; +import type { BotProps } from '../../../components/Bot'; export type BubbleProps = BotProps & BubbleParams; export declare const Bubble: (props: BubbleProps) => import("solid-js").JSX.Element; //# sourceMappingURL=Bubble.d.ts.map \ No newline at end of file diff --git a/dist/features/bubble/components/Bubble.d.ts.map b/dist/features/bubble/components/Bubble.d.ts.map index 568e924e4..72888966c 100644 --- a/dist/features/bubble/components/Bubble.d.ts.map +++ b/dist/features/bubble/components/Bubble.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"Bubble.d.ts","sourceRoot":"","sources":["../../../../src/features/bubble/components/Bubble.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AACxC,OAAO,EAAO,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAQxD,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,YAAY,CAAC;AAElD,eAAO,MAAM,MAAM,UAAW,WAAW,mCA8MxC,CAAC"} \ No newline at end of file +{"version":3,"file":"Bubble.d.ts","sourceRoot":"","sources":["../../../../src/features/bubble/components/Bubble.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AACxC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AASxD,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,YAAY,CAAC;AAElD,eAAO,MAAM,MAAM,UAAW,WAAW,mCAgNxC,CAAC"} \ No newline at end of file diff --git a/dist/features/bubble/types.d.ts b/dist/features/bubble/types.d.ts index 03ae1443c..70abfba52 100644 --- a/dist/features/bubble/types.d.ts +++ b/dist/features/bubble/types.d.ts @@ -76,6 +76,21 @@ export type ChatWindowTheme = { dateTimeToggle?: DateTimeToggleTheme; renderHTML?: boolean; headerHtml?: string; + sessionPanel?: { + width?: string | number; + collapsedWidth?: string | number; + backgroundColor?: string; + textColor?: string; + activeBackgroundColor?: string; + activeTextColor?: string; + hoverBackgroundColor?: string; + borderColor?: string; + newChatButtonColor?: string; + newChatButtonTextColor?: string; + newChatLabel?: string; + emptyStateText?: string; + capWarningText?: string; + }; }; export type ButtonTheme = { size?: 'small' | 'medium' | 'large' | number; diff --git a/dist/features/bubble/types.d.ts.map b/dist/features/bubble/types.d.ts.map index 87f94bbe3..95413a5da 100644 --- a/dist/features/bubble/types.d.ts.map +++ b/dist/features/bubble/types.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/features/bubble/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/features/bubble/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/features/full/components/Full.d.ts b/dist/features/full/components/Full.d.ts index 15e8bc3a8..986999995 100644 --- a/dist/features/full/components/Full.d.ts +++ b/dist/features/full/components/Full.d.ts @@ -1,4 +1,4 @@ -import { BotProps } from '@/components/Bot'; +import type { BotProps } from '@/components/Bot'; import { BubbleParams } from '@/features/bubble/types'; export type FullProps = BotProps & BubbleParams; export declare const Full: (props: FullProps, { element }: { diff --git a/dist/features/full/components/Full.d.ts.map b/dist/features/full/components/Full.d.ts.map index 2eb8f121d..36330e387 100644 --- a/dist/features/full/components/Full.d.ts.map +++ b/dist/features/full/components/Full.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"Full.d.ts","sourceRoot":"","sources":["../../../../src/features/full/components/Full.tsx"],"names":[],"mappings":"AACA,OAAO,EAAO,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAOvD,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,YAAY,CAAC;AAEhD,eAAO,MAAM,IAAI,UAAW,SAAS;aAA0B,WAAW;oCAoGzE,CAAC"} \ No newline at end of file +{"version":3,"file":"Full.d.ts","sourceRoot":"","sources":["../../../../src/features/full/components/Full.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAEjD,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAOvD,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,YAAY,CAAC;AAEhD,eAAO,MAAM,IAAI,UAAW,SAAS;aAA0B,WAAW;oCAsGzE,CAAC"} \ No newline at end of file diff --git a/dist/state/sessionMigration.d.ts b/dist/state/sessionMigration.d.ts new file mode 100644 index 000000000..4524a625e --- /dev/null +++ b/dist/state/sessionMigration.d.ts @@ -0,0 +1,12 @@ +import { type ChatflowIndexV2 } from './sessionStorage'; +/** + * Read whatever is at localStorage[chatflowid_EXTERNAL] and return a v2 index. + * - v2 already → returned as-is. + * - v1 shape → wrapped into a single session, written back to storage, returned. + * - unknown shape → log warning, return a fresh v2 (does not clobber). + * - missing → fresh v2 with one empty session. + * + * Pass `newChatId` so callers can plumb in their `customerId+uuid` prefix. + */ +export declare const loadOrMigrate: (chatflowid: string, newChatId: () => string) => ChatflowIndexV2; +//# sourceMappingURL=sessionMigration.d.ts.map \ No newline at end of file diff --git a/dist/state/sessionMigration.d.ts.map b/dist/state/sessionMigration.d.ts.map new file mode 100644 index 000000000..f05010676 --- /dev/null +++ b/dist/state/sessionMigration.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"sessionMigration.d.ts","sourceRoot":"","sources":["../../src/state/sessionMigration.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,eAAe,EAAmD,MAAM,kBAAkB,CAAC;AAkBzG;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa,eAAgB,MAAM,aAAa,MAAM,MAAM,KAAG,eAgE3E,CAAC"} \ No newline at end of file diff --git a/dist/state/sessionStorage.d.ts b/dist/state/sessionStorage.d.ts new file mode 100644 index 000000000..9a8c36027 --- /dev/null +++ b/dist/state/sessionStorage.d.ts @@ -0,0 +1,43 @@ +import type { MessageType } from '@/components/Bot'; +export type LeadCaptureData = Record; +export type SessionV2 = { + chatId: string; + title: string; + createdAt: number; + updatedAt: number; + starred?: boolean; +}; +export type ChatflowIndexV2 = { + version: 2; + activeChatId: string; + sessions: SessionV2[]; + lead?: LeadCaptureData; +}; +export declare const readIndex: (chatflowid: string) => ChatflowIndexV2 | null; +export declare const readMessages: (chatflowid: string, chatId: string) => MessageType[]; +export declare const readPanelCollapsed: (chatflowid: string) => boolean; +export declare const readCapWarned: (chatflowid: string) => boolean; +export declare const _internalKeys: { + indexKey: (chatflowid: string) => string; + msgKey: (chatflowid: string, chatId: string) => string; + capWarnedKey: (chatflowid: string) => string; + panelCollapsedKey: (chatflowid: string) => string; +}; +export declare class StorageQuotaError extends Error { + constructor(); +} +export declare const writeIndex: (chatflowid: string, index: ChatflowIndexV2) => void; +export declare const writeMessages: (chatflowid: string, chatId: string, messages: MessageType[]) => void; +export declare const removeMessages: (chatflowid: string, chatId: string) => void; +export declare const writePanelCollapsed: (chatflowid: string, collapsed: boolean) => void; +export declare const writeCapWarned: (chatflowid: string) => void; +/** + * Reconcile MsgKey orphans against an Index. + * - Returns chatIds whose MsgKey was deleted (orphans, not in index). + * - Returns chatIds in index that have no MsgKey (caller should seed empty). + */ +export declare const reconcileOrphans: (chatflowid: string, index: ChatflowIndexV2) => { + deletedOrphans: string[]; + missingMsgKeys: string[]; +}; +//# sourceMappingURL=sessionStorage.d.ts.map \ No newline at end of file diff --git a/dist/state/sessionStorage.d.ts.map b/dist/state/sessionStorage.d.ts.map new file mode 100644 index 000000000..25d6b3bc1 --- /dev/null +++ b/dist/state/sessionStorage.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"sessionStorage.d.ts","sourceRoot":"","sources":["../../src/state/sessionStorage.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpD,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEtD,MAAM,MAAM,SAAS,GAAG;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAIlB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,EAAE,CAAC,CAAC;IACX,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,SAAS,EAAE,CAAC;IACtB,IAAI,CAAC,EAAE,eAAe,CAAC;CACxB,CAAC;AAgBF,eAAO,MAAM,SAAS,eAAgB,MAAM,KAAG,eAAe,GAAG,IAKhE,CAAC;AAEF,eAAO,MAAM,YAAY,eAAgB,MAAM,UAAU,MAAM,KAAG,WAAW,EAG5E,CAAC;AAEF,eAAO,MAAM,kBAAkB,eAAgB,MAAM,KAAG,OAEvD,CAAC;AAEF,eAAO,MAAM,aAAa,eAAgB,MAAM,KAAG,OAElD,CAAC;AAEF,eAAO,MAAM,aAAa;2BAlCI,MAAM;yBACR,MAAM,UAAU,MAAM;+BAChB,MAAM;oCACD,MAAM;CA+BqC,CAAC;AAEnF,qBAAa,iBAAkB,SAAQ,KAAK;;CAK3C;AAgBD,eAAO,MAAM,UAAU,eAAgB,MAAM,SAAS,eAAe,KAAG,IAEvE,CAAC;AAEF,eAAO,MAAM,aAAa,eAAgB,MAAM,UAAU,MAAM,YAAY,WAAW,EAAE,KAAG,IAE3F,CAAC;AAEF,eAAO,MAAM,cAAc,eAAgB,MAAM,UAAU,MAAM,KAAG,IAEnE,CAAC;AAEF,eAAO,MAAM,mBAAmB,eAAgB,MAAM,aAAa,OAAO,KAAG,IAE5E,CAAC;AAEF,eAAO,MAAM,cAAc,eAAgB,MAAM,KAAG,IAEnD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,eAAgB,MAAM,SAAS,eAAe;oBAAqB,MAAM,EAAE;oBAAkB,MAAM,EAAE;CAwBjI,CAAC"} \ No newline at end of file diff --git a/dist/state/sessionStore.d.ts b/dist/state/sessionStore.d.ts new file mode 100644 index 000000000..37be799ad --- /dev/null +++ b/dist/state/sessionStore.d.ts @@ -0,0 +1,54 @@ +import type { MessageType } from '@/components/Bot'; +import { type ChatflowIndexV2, type SessionV2, type LeadCaptureData } from './sessionStorage'; +export type SessionStoreOptions = { + chatflowid: string; + newChatId: () => string; + maxSessions?: number; +}; +export type SessionStore = ReturnType; +export declare const createSessionStore: (opts: SessionStoreOptions) => { + chatflowid: string; + maxSessions: number; + sessions: import("solid-js").Accessor; + starredSessions: import("solid-js").Accessor; + recentSessions: import("solid-js").Accessor; + activeChatId: import("solid-js").Accessor; + activeSession: import("solid-js").Accessor; + activeMessages: import("solid-js").Accessor; + lead: import("solid-js").Accessor; + capWarning: import("solid-js").Accessor; + dispose: () => void; + actions: { + newChat: () => string; + switchSession: (chatId: string) => void; + upsertMessage: (msg: MessageType, options?: { + replaceId?: string; + }) => void; + upsertMessageInSession: (chatId: string, msg: MessageType, options?: { + replaceId?: string; + }) => void; + removeMessageById: (messageId: string) => void; + removeMessageByIdInSession: (chatId: string, messageId: string) => void; + replaceActiveMessages: (next: MessageType[]) => void; + getSessionMessages: (chatId: string) => MessageType[]; + renameSession: (chatId: string, rawTitle: string) => void; + toggleStarred: (chatId: string) => void; + deleteSession: (chatId: string) => void; + setLead: (lead: LeadCaptureData | undefined) => void; + flushPending: () => void; + setQuotaPanicHandler: (cb: () => void) => void; + setOnSessionChanged: (cb: ((detail: { + chatId: string; + title: string; + }) => void) | null) => void; + dismissCapWarning: () => false; + }; + _internal: { + index: import("solid-js").Accessor; + setIndex: import("solid-js").Setter; + messageCache: Map; + setActiveMessages: import("solid-js").Setter; + persistIndex: (next: ChatflowIndexV2) => void; + }; +}; +//# sourceMappingURL=sessionStore.d.ts.map \ No newline at end of file diff --git a/dist/state/sessionStore.d.ts.map b/dist/state/sessionStore.d.ts.map new file mode 100644 index 000000000..412c8057e --- /dev/null +++ b/dist/state/sessionStore.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"sessionStore.d.ts","sourceRoot":"","sources":["../../src/state/sessionStore.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,SAAS,EACd,KAAK,eAAe,EAOrB,MAAM,kBAAkB,CAAC;AAM1B,MAAM,MAAM,mBAAmB,GAAG;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,MAAM,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEjE,eAAO,MAAM,kBAAkB,SAAU,mBAAmB;;;;;;;;;;;;;uBAkHtC,MAAM;gCAwDK,MAAM,KAAG,IAAI;6BA4GhB,WAAW,YAAY;YAAE,SAAS,CAAC,EAAE,MAAM,CAAA;SAAE,KAAG,IAAI;yCAtCxC,MAAM,OAAO,WAAW,YAAY;YAAE,SAAS,CAAC,EAAE,MAAM,CAAA;SAAE,KAAG,IAAI;uCA2DnE,MAAM,KAAG,IAAI;6CAbP,MAAM,aAAa,MAAM,KAAG,IAAI;sCAqBvC,WAAW,EAAE,KAAG,IAAI;qCAnFrB,MAAM,KAAG,WAAW,EAAE;gCA0G3B,MAAM,YAAY,MAAM,KAAG,IAAI;gCAV/B,MAAM,KAAG,IAAI;gCA4Bb,MAAM,KAAG,IAAI;wBA4BrB,eAAe,GAAG,SAAS,KAAG,IAAI;;mCAxSvB,MAAM,IAAI;4CAfZ;YAAE,MAAM,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAA;SAAE,KAAK,IAAI;;;;;;;;6BAN7C,eAAe;;CAuW7C,CAAC"} \ No newline at end of file diff --git a/dist/utils/index.d.ts b/dist/utils/index.d.ts index f32e7650d..45982c493 100644 --- a/dist/utils/index.d.ts +++ b/dist/utils/index.d.ts @@ -15,7 +15,17 @@ export declare const sendRequest: (params: string | { data?: ResponseData | undefined; error?: Error | undefined; }>; +/** + * v1-compatible wrapper. Writes are field-level merges over the v2 index + * (and active-session messages where applicable), so callers writing + * `{ lead }` or `{ chatHistory }` don't clobber other v2 fields. + */ export declare const setLocalStorageChatflow: (chatflowid: string, chatId: string, saveObj?: Record) => void; +/** + * v1-compatible projection. Returns a v1-shaped object derived from the active + * session of the v2 index, so existing callers (notably the lead-capture path) + * keep working. + */ export declare const getLocalStorageChatflow: (chatflowid: string) => any; export declare const removeLocalStorageChatHistory: (chatflowid: string) => void; export declare const getBubbleButtonSize: (size: 'small' | 'medium' | 'large' | number | undefined) => number; diff --git a/dist/utils/index.d.ts.map b/dist/utils/index.d.ts.map index a4e9bd113..7166623fe 100644 --- a/dist/utils/index.d.ts.map +++ b/dist/utils/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,YAAY,+DAAuG,CAAC;AAEjI,eAAO,MAAM,SAAS,6DAAqG,CAAC;AAE5H,eAAO,MAAM,OAAO,UAAW,MAAM,GAAG,SAAS,GAAG,IAAI,uBAA8E,CAAC;AAEvI,eAAO,MAAM,UAAU,UAAW,MAAM,GAAG,SAAS,GAAG,IAAI,oBAA2E,CAAC;AAEvI,eAAO,MAAM,WAAW;SAGX,MAAM;YACH,MAAM;;;;;2BAKQ,WAAW,KAAK,QAAQ,IAAI,CAAC;;;;;EAyD1D,CAAC;AAEF,eAAO,MAAM,uBAAuB,eAAgB,MAAM,UAAU,MAAM,YAAW,OAAO,MAAM,EAAE,GAAG,CAAC,SAiBvG,CAAC;AAEF,eAAO,MAAM,uBAAuB,eAAgB,MAAM,QAQzD,CAAC;AAEF,eAAO,MAAM,6BAA6B,eAAgB,MAAM,SAgB/D,CAAC;AAEF,eAAO,MAAM,mBAAmB,SAAU,OAAO,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,WAO1F,CAAC;AAEF,eAAO,MAAM,SAAS,UAAW,MAAM,UAAU,MAAM,UAAU,MAAM,SAKtE,CAAC;AAEF,eAAO,MAAM,SAAS,UAAW,MAAM,KAAG,MAczC,CAAC;AAEF,eAAO,MAAM,sBAAsB,QAAS,OAAO,KAAG,WAAW,GAAG,SAenE,CAAC;AAEF,eAAO,MAAM,4BAA4B,SAAU,MAAM,WAiBxD,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY,+DAAuG,CAAC;AAEjI,eAAO,MAAM,SAAS,6DAAqG,CAAC;AAE5H,eAAO,MAAM,OAAO,UAAW,MAAM,GAAG,SAAS,GAAG,IAAI,uBAA8E,CAAC;AAEvI,eAAO,MAAM,UAAU,UAAW,MAAM,GAAG,SAAS,GAAG,IAAI,oBAA2E,CAAC;AAEvI,eAAO,MAAM,WAAW;SAGX,MAAM;YACH,MAAM;;;;;2BAKQ,WAAW,KAAK,QAAQ,IAAI,CAAC;;;;;EAyD1D,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,eAAgB,MAAM,UAAU,MAAM,YAAW,OAAO,MAAM,EAAE,GAAG,CAAC,SA0BvG,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,eAAgB,MAAM,QAiBzD,CAAC;AAEF,eAAO,MAAM,6BAA6B,eAAgB,MAAM,SAgB/D,CAAC;AAEF,eAAO,MAAM,mBAAmB,SAAU,OAAO,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,WAO1F,CAAC;AAEF,eAAO,MAAM,SAAS,UAAW,MAAM,UAAU,MAAM,UAAU,MAAM,SAKtE,CAAC;AAEF,eAAO,MAAM,SAAS,UAAW,MAAM,KAAG,MAczC,CAAC;AAEF,eAAO,MAAM,sBAAsB,QAAS,OAAO,KAAG,WAAW,GAAG,SAenE,CAAC;AAEF,eAAO,MAAM,4BAA4B,SAAU,MAAM,WAiBxD,CAAC"} \ No newline at end of file diff --git a/dist/utils/titleFromMessage.d.ts b/dist/utils/titleFromMessage.d.ts new file mode 100644 index 000000000..1fadbd217 --- /dev/null +++ b/dist/utils/titleFromMessage.d.ts @@ -0,0 +1,3 @@ +import type { MessageType } from '@/components/Bot'; +export declare const titleFromMessage: (messages: MessageType[]) => string | null; +//# sourceMappingURL=titleFromMessage.d.ts.map \ No newline at end of file diff --git a/dist/utils/titleFromMessage.d.ts.map b/dist/utils/titleFromMessage.d.ts.map new file mode 100644 index 000000000..624aa0e12 --- /dev/null +++ b/dist/utils/titleFromMessage.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"titleFromMessage.d.ts","sourceRoot":"","sources":["../../src/utils/titleFromMessage.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAIpD,eAAO,MAAM,gBAAgB,aAAc,WAAW,EAAE,KAAG,MAAM,GAAG,IAanE,CAAC"} \ No newline at end of file diff --git a/dist/web.js b/dist/web.js index 9e1b13188..83b5aab02 100644 --- a/dist/web.js +++ b/dist/web.js @@ -1 +1,86853 @@ -function e(e){return Object.keys(e).reduce(((r,o)=>{var a=e[o];return r[o]=Object.assign({},a),!t(a.value)||function(e){return"[object Function]"===Object.prototype.toString.call(e)}(a.value)||Array.isArray(a.value)||(r[o].value=Object.assign({},a.value)),Array.isArray(a.value)&&(r[o].value=a.value.slice(0)),r}),{})}function r(e){if(e)try{return JSON.parse(e)}catch(r){return e}}function o(e,r,o){if(null==o||!1===o)return e.removeAttribute(r);let t=JSON.stringify(o);e.__updating[r]=!0,"true"===t&&(t=""),e.setAttribute(r,t),Promise.resolve().then((()=>delete e.__updating[r]))}function t(e){return null!=e&&("object"==typeof e||"function"==typeof e)}let a;function l(t,l){const n=Object.keys(l);return class extends t{static get observedAttributes(){return n.map((e=>l[e].attribute))}constructor(){super(),this.__initialized=!1,this.__released=!1,this.__releaseCallbacks=[],this.__propertyChangedCallbacks=[],this.__updating={},this.props={}}connectedCallback(){if(!this.__initialized){this.__releaseCallbacks=[],this.__propertyChangedCallbacks=[],this.__updating={},this.props=function(t,a){const l=e(a);return Object.keys(a).forEach((e=>{const a=l[e],n=t.getAttribute(a.attribute),i=t[e];n&&(a.value=a.parse?r(n):n),null!=i&&(a.value=Array.isArray(i)?i.slice(0):i),a.reflect&&o(t,a.attribute,a.value),Object.defineProperty(t,e,{get:()=>a.value,set(r){var t=a.value;a.value=r,a.reflect&&o(this,a.attribute,a.value);for(let o=0,a=this.__propertyChangedCallbacks.length;o(r[o]=e[o].value,r)),{})}(this.props),n=this.Component,i=a;try{(a=this).__initialized=!0,function(e){return"function"==typeof e&&0===e.toString().indexOf("class")}(n)?new n(t,{element:this}):n(t,{element:this})}finally{a=i}}}async disconnectedCallback(){if(await Promise.resolve(),!this.isConnected){this.__propertyChangedCallbacks.length=0;for(var e=null;e=this.__releaseCallbacks.pop();)e(this);delete this.__initialized,this.__released=!0}}attributeChangedCallback(e,o,t){!this.__initialized||this.__updating[e]||(e=this.lookupProp(e))in l&&(null==t&&!this[e]||(this[e]=l[e].parse?r(t):t))}lookupProp(e){if(l)return n.find((r=>e===r||e===l[r].attribute))}get renderRoot(){return this.shadowRoot||this.attachShadow({mode:"open"})}addReleaseCallback(e){this.__releaseCallbacks.push(e)}addPropertyChangedCallback(e){this.__propertyChangedCallbacks.push(e)}}}function n(e,r={},o={}){const{BaseElement:a=HTMLElement,extension:n}=o;return o=>{if(!e)throw new Error("tag is required to register a Component");let i=customElements.get(e);return i?i.prototype.Component=o:((i=l(a,function(e){return e?Object.keys(e).reduce(((r,o)=>{var a=e[o];return r[o]=t(a)&&"value"in a?a:{value:a},r[o].attribute||(r[o].attribute=function(e){return e.replace(/\.?([A-Z]+)/g,((e,r)=>"-"+r.toLowerCase())).replace("_","-").replace(/^-/,"")}(o)),r[o].parse="parse"in r[o]?r[o].parse:"string"!=typeof r[o].value,r}),{}):{}}(r))).prototype.Component=o,i.prototype.registeredTag=e,customElements.define(e,i,n)),i}}const i=Symbol("solid-proxy"),d=Symbol("solid-track"),s=Symbol("solid-dev-component"),m={equals:(e,r)=>e===r};let g=D;const c=1,u=2,x={owned:null,cleanups:null,context:null,owner:null};var p=null;let h=null,v=null,S=null,b=null,y=0;function f(e,r){const o=v,t=p,a=0===e.length,l=a?x:{owned:null,cleanups:null,context:null,owner:void 0===r?t:r},n=a?e:()=>e((()=>T((()=>H(l)))));p=l,v=null;try{return k(n,!0)}finally{v=o,p=t}}function $(e,r){const o={value:e,observers:null,observerSlots:null,comparator:(r=r?Object.assign({},m,r):m).equals||void 0};return[E.bind(o),e=>("function"==typeof e&&(e=e(o.value)),B(o,e))]}function P(e,r,o){N(O(e,r,!1,c))}function A(e,r,o){g=R,(e=O(e,r,!1,c)).user=!0,b?b.push(e):N(e)}function M(e,r,o){return o=o?Object.assign({},m,o):m,(e=O(e,r,!0,0)).observers=null,e.observerSlots=null,e.comparator=o.equals||void 0,N(e),E.bind(e)}function T(e){if(null===v)return e();var r=v;v=null;try{return e()}finally{v=r}}function _(e){A((()=>T(e)))}function w(e){return null!==p&&(null===p.cleanups?p.cleanups=[e]:p.cleanups.push(e)),e}function L(){return v}function G(e){var r;return void 0!==(r=V(p,e.id))?r:e.defaultValue}function C(e){const r=M(e),o=M((()=>W(r())));return o.toArray=()=>{var e=o();return Array.isArray(e)?e:null!=e?[e]:[]},o}function E(){var e;return this.sources&&this.state&&(this.state===c?N(this):(e=S,S=null,k((()=>F(this)),!1),S=e)),v&&(e=this.observers?this.observers.length:0,v.sources?(v.sources.push(this),v.sourceSlots.push(e)):(v.sources=[this],v.sourceSlots=[e]),this.observers?(this.observers.push(v),this.observerSlots.push(v.sources.length-1)):(this.observers=[v],this.observerSlots=[v.sources.length-1])),this.value}function B(e,r,o){var t=e.value;return e.comparator&&e.comparator(t,r)||(e.value=r,e.observers&&e.observers.length&&k((()=>{for(let t=0;tF(e,o[0])),!1),S=r)}}}function k(e,r){if(S)return e();let o=!1;r||(S=[]),b?o=!0:b=[],y++;try{var t=e();return function(e){if(S&&(D(S),S=null),!e){const e=b;b=null,e.length&&k((()=>g(e)),!1)}}(o),t}catch(e){o||(b=null),S=null,U(e)}}function D(e){for(let r=0;ro=T((()=>(p.context={[e]:r.value},C((()=>r.children)))))),void 0),o}}const Z=Symbol("fallback");function z(e){for(let r=0;re(r||{})))}function Y(){return!0}const J={get:(e,r,o)=>r===i?o:e.get(r),has:(e,r)=>r===i||e.has(r),set:Y,deleteProperty:Y,getOwnPropertyDescriptor:(e,r)=>({configurable:!0,enumerable:!0,get:()=>e.get(r),set:Y,deleteProperty:Y}),ownKeys:e=>e.keys()};function j(e){return(e="function"==typeof e?e():e)||{}}function q(...e){let r=!1;for(let t=0;tnew Proxy({get:o=>r.includes(o)?e[o]:void 0,has:o=>r.includes(o)&&o in e,keys:()=>r.filter((r=>r in e))},J)))).push(new Proxy({get:r=>o.has(r)?void 0:e[r],has:r=>!o.has(r)&&r in e,keys:()=>Object.keys(e).filter((e=>!o.has(e)))},J)),t;const a=Object.getOwnPropertyDescriptors(e);return r.push(Object.keys(a).filter((e=>!o.has(e)))),r.map((r=>{var o={};for(let t=0;te[l],set:()=>!0,enumerable:!0})}return o}))}function re(e){var r="fallback"in e&&{fallback:()=>e.fallback};return M(function(e,r,o={}){let t=[],a=[],l=[],n=0,i=1z(l))),()=>{let s,m,g=e()||[];return g[d],T((()=>{let e,r,d,u,x,p,h,v,S,b=g.length;if(0===b)0!==n&&(z(l),l=[],t=[],a=[],n=0,i=i&&[]),o.fallback&&(t=[Z],a[0]=f((e=>(l[0]=e,o.fallback()))),n=1);else if(0===n){for(a=new Array(b),m=0;m=p&&v>=p&&t[h]===g[v];h--,v--)d[v]=a[h],u[v]=l[h],i&&(x[v]=i[h]);for(e=new Map,r=new Array(v+1),m=v;m>=p;m--)S=g[m],s=e.get(S),r[m]=void 0===s?-1:s,e.set(S,m);for(s=p;s<=h;s++)S=t[s],void 0!==(m=e.get(S))&&-1!==m?(d[m]=a[s],u[m]=l[s],i&&(x[m]=i[s]),m=r[m],e.set(S,m)):l[s]();for(m=p;me.each),e.children,r||void 0))}function oe(e){const r=e.keyed,o=M((()=>e.when),void 0,{equals:(e,o)=>r?e===o:!e==!o});return M((()=>{const t=o();if(t){const a=e.children;return"function"==typeof a&&0a(r?t:()=>{if(T(o))return e.when;throw(e=>`Stale read from <${e}>.`)("Show")}))):a}return e.fallback}),void 0,void 0)}const te=new Set(["className","value","readOnly","formNoValidate","isMap","noModule","playsInline","allowfullscreen","async","autofocus","autoplay","checked","controls","default","disabled","formnovalidate","hidden","indeterminate","ismap","loop","multiple","muted","nomodule","novalidate","open","playsinline","readonly","required","reversed","seamless","selected"]),ae=new Set(["innerHTML","textContent","innerText","children"]),le=Object.assign(Object.create(null),{className:"class",htmlFor:"for"}),ne=Object.assign(Object.create(null),{class:"className",formnovalidate:{$:"formNoValidate",BUTTON:1,INPUT:1},ismap:{$:"isMap",IMG:1},nomodule:{$:"noModule",SCRIPT:1},playsinline:{$:"playsInline",VIDEO:1},readonly:{$:"readOnly",INPUT:1,TEXTAREA:1}});const ie=new Set(["beforeinput","click","dblclick","contextmenu","focusin","focusout","input","keydown","keyup","mousedown","mousemove","mouseout","mouseover","mouseup","pointerdown","pointermove","pointerout","pointerover","pointerup","touchend","touchmove","touchstart"]),de=new Set(["altGlyph","altGlyphDef","altGlyphItem","animate","animateColor","animateMotion","animateTransform","circle","clipPath","color-profile","cursor","defs","desc","ellipse","feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence","filter","font","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","foreignObject","g","glyph","glyphRef","hkern","image","line","linearGradient","marker","mask","metadata","missing-glyph","mpath","path","pattern","polygon","polyline","radialGradient","rect","set","stop","svg","switch","symbol","text","textPath","tref","tspan","use","view","vkern"]),se={xlink:"http://www.w3.org/1999/xlink",xml:"http://www.w3.org/XML/1998/namespace"};const me="_$DX_DELEGATE";function ge(e,r,o){let t;const a=()=>{var r=document.createElement("template");return r.innerHTML=e,(o?r.content.firstChild:r.content).firstChild};return(r=r?()=>(t=t||a()).cloneNode(!0):()=>T((()=>document.importNode(t=t||a(),!0)))).cloneNode=r}function ce(e,r=window.document){var o=r[me]||(r[me]=new Set);for(let a=0,l=e.length;at.call(e,o[1],r))}else e.addEventListener(r,o)}function he(e,r,o){if(!r)return o?ue(e,"style"):r;var t=e.style;if("string"==typeof r)return t.cssText=r;let a,l;for(l in"string"==typeof o&&(t.cssText=o=void 0),r=r||{},o=o||{})null==r[l]&&t.removeProperty(l),delete o[l];for(l in r)(a=r[l])!==o[l]&&(t.setProperty(l,a),o[l]=a);return o}function ve(e,r={},o,t){const a={};return t||P((()=>a.children=Pe(e,r.children,a.children))),P((()=>r.ref&&r.ref(e))),P((()=>function(e,r,o,t,a={},l=!1){r=r||{};for(const t in a)t in r||"children"!==t&&(a[t]=fe(e,t,null,a[t],o,l));for(const i in r){var n;"children"===i?t||Pe(e,r.children):(n=r[i],a[i]=fe(e,i,n,a[i],o,l))}}(e,r,o,!0,a,!0))),a}function Se(e,r,o){return T((()=>e(r,o)))}function be(e,r,o,t){if(void 0!==o&&(t=t||[]),"function"!=typeof r)return Pe(e,r,t,o);P((t=>Pe(e,r(),t,o)),t)}function ye(e,r,o){var t=r.trim().split(/\s+/);for(let r=0,a=t.length;rr.toUpperCase()))}(r)]=o):(t=a&&-1o||document});o;){var t=o[r];if(t&&!o.disabled){var a=o[r+"Data"];if(void 0!==a?t.call(o,a,e):t.call(o,e),e.cancelBubble)return}o=o._$host||o.parentNode||o.host}}function Pe(e,r,o,t,a){for(;"function"==typeof o;)o=o();if(r!==o){var l=typeof r,n=void 0!==t;if(e=n&&o[0]&&o[0].parentNode||e,"string"==l||"number"==l)if("number"==l&&(r=r.toString()),n){let a=o[0];a&&3===a.nodeType?a.data=r:a=document.createTextNode(r),o=Te(e,o,t,a)}else o=""!==o&&"string"==typeof o?e.firstChild.data=r:e.textContent=r;else if(null==r||"boolean"==l)o=Te(e,o,t);else{if("function"==l)return P((()=>{let a=r();for(;"function"==typeof a;)a=a();o=Pe(e,a,o,t)})),()=>o;if(Array.isArray(r)){const i=[];if(l=o&&Array.isArray(o),Ae(i,r,o,a))return P((()=>o=Pe(e,i,o,t,!0))),()=>o;if(0===i.length){if(o=Te(e,o,t),n)return o}else l?0===o.length?Me(e,i,t):function(e,r,o){let t=o.length,a=r.length,l=t,n=0,i=0,d=r[a-1].nextSibling,s=null;for(;nc-i)for(var u=r[n];i{a=a||function(e,r){var o=p,t=v;p=e,v=null;try{return k(r,!0)}catch(e){U(e)}finally{p=o,v=t}}(t,(()=>e.children));const l=e.mount||document.body;if(l instanceof HTMLHeadElement){const[e,r]=$(!1);f((r=>be(l,(()=>e()?r():a),null))),w((()=>r(!0)))}else{const t=we(e.isSVG?"g":"div",e.isSVG),n=r&&t.attachShadow?t.attachShadow({mode:"open"}):t;Object.defineProperty(t,"_$host",{get:()=>o.parentNode,configurable:!0}),be(n,a),l.appendChild(t),e.ref&&e.ref(t),w((()=>l.removeChild(t)))}})),o}function Ge(e){const[r,o]=ee(e,["component"]),t=M((()=>r.component));return M((()=>{const e=t();switch(typeof e){case"function":return Object.assign(e,{[s]:!0}),T((()=>e(o)));case"string":var r=de.has(e),a=we(e,r);return ve(a,o,r),a}}))}function Ce(e){return(r,o)=>{const t=o.element;return f((a=>{const l=function(e){var r=Object.keys(e),o={};for(let t=0;te))}})}return o}(r);t.addPropertyChangedCallback(((e,r)=>l[e]=r)),t.addReleaseCallback((()=>{t.renderRoot.textContent="",a()}));var n=e(l,o);return be(t.renderRoot,n)}),function(e){if(e.assignedSlot&&e.assignedSlot._$owner)return e.assignedSlot._$owner;let r=e.parentNode;for(;r&&!r._$owner&&(!r.assignedSlot||!r.assignedSlot._$owner);)r=r.parentNode;return(r&&r.assignedSlot?r.assignedSlot:e)._$owner}(t))}}function Ee(e,r,o){return 2===arguments.length&&(o=r,r={}),n(e,r)(Ce(o))}const Be={chatflowid:"",apiHost:void 0,onRequest:void 0,chatflowConfig:void 0,theme:void 0,observersConfig:void 0,dialogContainer:void 0};var Ne='/*! tailwindcss v3.3.1 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}html{-webkit-text-size-adjust:100%;font-feature-settings:normal;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-variation-settings:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]{display:none}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em;margin-top:1.25em}.prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);font-size:1.25em;line-height:1.6;margin-bottom:1.2em;margin-top:1.2em}.prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-links);font-weight:500;text-decoration:underline}.prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal;margin-bottom:1.25em;margin-top:1.25em;padding-left:1.625em}.prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:disc;margin-bottom:1.25em;margin-top:1.25em;padding-left:1.625em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-counters);font-weight:400}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.25em}.prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-bottom:3em;margin-top:3em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){border-left-color:var(--tw-prose-quote-borders);border-left-width:.25rem;color:var(--tw-prose-quotes);font-style:italic;font-weight:500;margin-bottom:1.6em;margin-top:1.6em;padding-left:1em;quotes:"\\201C""\\201D""\\2018""\\2019"}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-size:2.25em;font-weight:800;line-height:1.1111111;margin-bottom:.8888889em;margin-top:0}.prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:900}.prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-size:1.5em;font-weight:700;line-height:1.3333333;margin-bottom:1em;margin-top:2em}.prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:800}.prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-size:1.25em;font-weight:600;line-height:1.6;margin-bottom:.6em;margin-top:1.6em}.prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:700}.prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;line-height:1.5;margin-bottom:.5em;margin-top:1.5em}.prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:700}.prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:2em;margin-top:2em}.prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){display:block;margin-bottom:2em;margin-top:2em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){border-radius:.3125rem;box-shadow:0 0 0 1px rgb(var(--tw-prose-kbd-shadows)/10%),0 3px 0 rgb(var(--tw-prose-kbd-shadows)/10%);color:var(--tw-prose-kbd);font-family:inherit;font-size:.875em;font-weight:500;padding:.1875em .375em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-code);font-size:.875em;font-weight:600}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:"`"}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:"`"}.prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--tw-prose-pre-bg);border-radius:.375rem;color:var(--tw-prose-pre-code);font-size:.875em;font-weight:400;line-height:1.7142857;margin-bottom:1.7142857em;margin-top:1.7142857em;overflow-x:auto;padding:.8571429em 1.1428571em}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;border-radius:0;border-width:0;color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;line-height:inherit;padding:0}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:none}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.875em;line-height:1.7142857;margin-bottom:2em;margin-top:2em;table-layout:auto;text-align:left;width:100%}.prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-color:var(--tw-prose-th-borders);border-bottom-width:1px}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;padding-bottom:.5714286em;padding-left:.5714286em;padding-right:.5714286em;vertical-align:bottom}.prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-color:var(--tw-prose-td-borders);border-bottom-width:1px}.prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-color:var(--tw-prose-th-borders);border-top-width:1px}.prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0;margin-top:0}.prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);font-size:.875em;line-height:1.4285714;margin-top:.8571429em}.prose{--tw-prose-body:#374151;--tw-prose-headings:#111827;--tw-prose-lead:#4b5563;--tw-prose-links:#111827;--tw-prose-bold:#111827;--tw-prose-counters:#6b7280;--tw-prose-bullets:#d1d5db;--tw-prose-hr:#e5e7eb;--tw-prose-quotes:#111827;--tw-prose-quote-borders:#e5e7eb;--tw-prose-captions:#6b7280;--tw-prose-kbd:#111827;--tw-prose-kbd-shadows:17 24 39;--tw-prose-code:#111827;--tw-prose-pre-code:#e5e7eb;--tw-prose-pre-bg:#1f2937;--tw-prose-th-borders:#d1d5db;--tw-prose-td-borders:#e5e7eb;--tw-prose-invert-body:#d1d5db;--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:#9ca3af;--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:#9ca3af;--tw-prose-invert-bullets:#4b5563;--tw-prose-invert-hr:#374151;--tw-prose-invert-quotes:#f3f4f6;--tw-prose-invert-quote-borders:#374151;--tw-prose-invert-captions:#9ca3af;--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:255 255 255;--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:#d1d5db;--tw-prose-invert-pre-bg:rgba(0,0,0,.5);--tw-prose-invert-th-borders:#4b5563;--tw-prose-invert-td-borders:#374151;font-size:1rem;line-height:1.75}.prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0;margin-top:0}.prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:2em;margin-top:2em}.prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:.5em;margin-top:.5em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-left:.375em}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-left:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:.75em;margin-top:.75em}.prose :where(.prose>ul>li>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:.75em;margin-top:.75em}.prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em;margin-top:1.25em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-left:1.625em}.prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-left:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-right:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding:.5714286em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-left:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-right:0}.prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:2em;margin-top:2em}.prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.collapse{visibility:collapse}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-0{bottom:0}.bottom-\\[140px\\]{bottom:140px}.left-0{left:0}.left-1\\/2{left:50%}.right-0{right:0}.right-\\[-8px\\]{right:-8px}.top-0{top:0}.z-0{z-index:0}.z-10{z-index:10}.z-40{z-index:40}.z-50{z-index:50}.z-\\[1001\\]{z-index:1001}.z-\\[1002\\]{z-index:1002}.float-right{float:right}.m-0{margin:0}.m-\\[6px\\]{margin:6px}.m-auto{margin:auto}.mx-4{margin-left:16px;margin-right:16px}.my-2{margin-bottom:8px;margin-top:8px}.my-6{margin-bottom:24px;margin-top:24px}.-ml-1{margin-left:-4px}.mb-1{margin-bottom:4px}.mb-2{margin-bottom:8px}.mb-4{margin-bottom:16px}.mb-6{margin-bottom:24px}.ml-1{margin-left:4px}.ml-1\\.5{margin-left:6px}.ml-10{margin-left:40px}.ml-2{margin-left:8px}.ml-auto{margin-left:auto}.mr-1{margin-right:4px}.mr-2{margin-right:8px}.mr-3{margin-right:12px}.mr-\\[10px\\]{margin-right:10px}.mt-2{margin-top:8px}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.hidden{display:none}.h-10{height:40px}.h-12{height:48px}.h-14{height:56px}.h-2{height:8px}.h-4{height:16px}.h-5{height:20px}.h-6{height:24px}.h-7{height:28px}.h-8{height:32px}.h-\\[58px\\]{height:58px}.h-auto{height:auto}.h-full{height:100%}.max-h-\\[128px\\]{max-height:128px}.max-h-\\[192px\\]{max-height:192px}.min-h-0{min-height:0}.min-h-\\[56px\\]{min-height:56px}.min-h-full{min-height:100%}.w-10{width:40px}.w-12{width:48px}.w-2{width:8px}.w-4{width:16px}.w-5{width:20px}.w-6{width:24px}.w-64{width:256px}.w-7{width:28px}.w-8{width:32px}.w-\\[200px\\]{width:200px}.w-full{width:100%}.min-w-full{min-width:100%}.max-w-3xl{max-width:768px}.max-w-\\[128px\\]{max-width:128px}.max-w-full{max-width:100%}.max-w-max{max-width:-moz-max-content;max-width:max-content}.max-w-md{max-width:448px}.flex-1{flex:1 1 0%}.flex-auto{flex:1 1 auto}.flex-none{flex:none}.flex-shrink{flex-shrink:1}.flex-shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.flex-grow-0{flex-grow:0}.basis-auto{flex-basis:auto}.border-collapse{border-collapse:collapse}.-translate-x-1\\/2{--tw-translate-x:-50%}.-rotate-180,.-translate-x-1\\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-rotate-180{--tw-rotate:-180deg}.rotate-0{--tw-rotate:0deg}.rotate-0,.scale-0{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-0{--tw-scale-x:0;--tw-scale-y:0}.scale-100{--tw-scale-x:1;--tw-scale-y:1}.scale-100,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes fade-in{0%{opacity:0}to{opacity:1}}.animate-fade-in{animation:fade-in .3s ease-out}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.resize{resize:both}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:4px}.gap-2{gap:8px}.gap-3{gap:12px}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(8px*(1 - var(--tw-space-x-reverse)));margin-right:calc(8px*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(16px*(1 - var(--tw-space-x-reverse)));margin-right:calc(16px*var(--tw-space-x-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(8px*var(--tw-space-y-reverse));margin-top:calc(8px*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(16px*var(--tw-space-y-reverse));margin-top:calc(16px*(1 - var(--tw-space-y-reverse)))}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-scroll{overflow-y:scroll}.whitespace-pre-wrap{white-space:pre-wrap}.rounded{border-radius:4px}.rounded-\\[10px\\]{border-radius:10px}.rounded-\\[6px\\]{border-radius:6px}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:8px}.rounded-md{border-radius:6px}.rounded-none{border-radius:0}.rounded-xl{border-radius:12px}.rounded-b{border-bottom-left-radius:4px;border-bottom-right-radius:4px}.rounded-t{border-top-left-radius:4px;border-top-right-radius:4px}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-4{border-width:4px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-t-4{border-top-width:4px}.border-solid{border-style:solid}.border-dashed{border-style:dashed}.border-\\[\\#eeeeee\\]{--tw-border-opacity:1;border-color:rgb(238 238 238/var(--tw-border-opacity))}.border-current{border-color:currentColor}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity))}.border-green-600{--tw-border-opacity:1;border-color:rgb(22 163 74/var(--tw-border-opacity))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity))}.border-red-600{--tw-border-opacity:1;border-color:rgb(220 38 38/var(--tw-border-opacity))}.border-yellow-300{--tw-border-opacity:1;border-color:rgb(253 224 71/var(--tw-border-opacity))}.border-t-transparent{border-top-color:transparent}.border-t-white{--tw-border-opacity:1;border-top-color:rgb(255 255 255/var(--tw-border-opacity))}.bg-\\[rgba\\(0\\2c 0\\2c 0\\2c 0\\.3\\)\\]{background-color:rgba(0,0,0,.3)}.bg-\\[rgba\\(0\\2c 0\\2c 0\\2c 0\\.4\\)\\]{background-color:rgba(0,0,0,.4)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity))}.bg-black\\/10{background-color:rgba(0,0,0,.1)}.bg-black\\/60{background-color:rgba(0,0,0,.6)}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity))}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity))}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-cover{background-size:cover}.bg-center{background-position:50%}.fill-transparent{fill:transparent}.stroke-2{stroke-width:2}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:4px}.p-10{padding:40px}.p-2{padding:8px}.p-2\\.5{padding:10px}.p-3{padding:12px}.p-4{padding:16px}.p-5{padding:20px}.p-6{padding:24px}.px-1{padding-left:4px;padding-right:4px}.px-12{padding-left:48px;padding-right:48px}.px-2{padding-left:8px;padding-right:8px}.px-3{padding-left:12px;padding-right:12px}.px-4{padding-left:16px;padding-right:16px}.px-5{padding-left:20px;padding-right:20px}.px-6{padding-left:24px;padding-right:24px}.px-\\[10px\\]{padding-left:10px;padding-right:10px}.py-1{padding-bottom:4px;padding-top:4px}.py-2{padding-bottom:8px;padding-top:8px}.py-4{padding-bottom:16px;padding-top:16px}.py-8{padding-bottom:32px;padding-top:32px}.py-\\[10px\\]{padding-bottom:10px;padding-top:10px}.pb-1{padding-bottom:4px}.pb-2{padding-bottom:8px}.pb-\\[10px\\]{padding-bottom:10px}.pl-4{padding-left:16px}.pr-0{padding-right:0}.pr-3{padding-right:12px}.pt-2{padding-top:8px}.pt-4{padding-top:16px}.pt-\\[6px\\]{padding-top:6px}.pt-\\[70px\\]{padding-top:70px}.text-left{text-align:left}.text-center{text-align:center}.font-sans{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-2xl{font-size:24px;line-height:32px}.text-\\[13px\\]{font-size:13px}.text-base{font-size:16px;line-height:24px}.text-sm{font-size:14px;line-height:20px}.text-xl{font-size:20px;line-height:28px}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-4{line-height:16px}.leading-none{line-height:1}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity))}.text-inherit{color:inherit}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity))}.text-transparent{color:transparent}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.text-yellow-700{--tw-text-opacity:1;color:rgb(161 98 7/var(--tw-text-opacity))}.underline{text-decoration-line:underline}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.blur-\\[2px\\]{--tw-blur:blur(2px)}.blur-\\[2px\\],.blur-none{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-none{--tw-blur:blur(0)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-150,.transition-transform{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-linear{transition-timing-function:linear}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}:host{--chatbot-container-bg-image:none;--chatbot-container-bg-color:transparent;--chatbot-container-font-family:"Open Sans";--chatbot-button-bg-color:#0042da;--chatbot-button-color:#fff;--chatbot-host-bubble-bg-color:#f7f8ff;--chatbot-host-bubble-color:#303235;--chatbot-guest-bubble-bg-color:#3b81f6;--chatbot-guest-bubble-color:#fff;--chatbot-input-bg-color:#fff;--chatbot-input-color:#303235;--chatbot-input-placeholder-color:#9095a0;--chatbot-header-bg-color:#fff;--chatbot-header-color:#303235;--chatbot-border-radius:6px;--PhoneInputCountryFlag-borderColor:transparent;--PhoneInput-color--focus:transparent}a{color:#16bed7;font-weight:500}a:hover{text-decoration:underline}pre{word-wrap:break-word;font-size:13px;margin:5px;overflow:auto;padding:5px;white-space:pre-wrap;white-space:-moz-pre-wrap;white-space:-pre-wrap;white-space:-o-pre-wrap;width:auto}.string{color:green}.number{color:#ff8c00}.boolean{color:blue}.null{color:#f0f}.key{color:#002b36}.scrollable-container{scrollbar-color:rgba(0,0,0,.2) transparent;scrollbar-width:thin}.scrollable-container::-webkit-scrollbar{width:4px}.scrollable-container::-webkit-scrollbar-track{background:transparent}.scrollable-container::-webkit-scrollbar-thumb{background-color:rgba(0,0,0,.2);border-radius:4px}.scrollable-container::-webkit-scrollbar-thumb:hover{background-color:rgba(0,0,0,.35)}@media (hover:none) and (pointer:coarse){.scrollable-container{scrollbar-width:none}.scrollable-container::-webkit-scrollbar{display:none}}.text-fade-in{transition:opacity .4s ease-in .2s}.bubble-typing{transition:width .4s ease-out,height .4s ease-out}.bubble1,.bubble2,.bubble3{background-color:var(--chatbot-host-bubble-color);opacity:.5}.bubble1,.bubble2{animation:chatBubbles 1s ease-in-out infinite}.bubble2{animation-delay:.3s}.bubble3{animation:chatBubbles 1s ease-in-out infinite;animation-delay:.5s}@keyframes chatBubbles{0%{transform:translateY(0)}50%{transform:translateY(-5px)}to{transform:translateY(0)}}button,input,textarea{font-weight:300}.slate-a{text-decoration:underline}.slate-html-container>div{min-height:24px}.slate-bold{font-weight:700}.slate-italic{font-style:oblique}.slate-underline{text-decoration:underline}.text-input::-moz-placeholder{color:#9095a0!important;opacity:1!important}.text-input::placeholder{color:#9095a0!important;opacity:1!important}.chatbot-container{background-color:var(--chatbot-container-bg-color);background-image:var(--chatbot-container-bg-image);font-family:Open Sans,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol}.file-annotation-button{background-color:#02a0a0c2;border:1px solid #02a0a0c2;border-radius:var(--chatbot-border-radius);color:var(--chatbot-button-color)}.chatbot-button{background-color:#0042da;border:1px solid #0042da;border-radius:var(--chatbot-border-radius);color:var(--chatbot-button-color)}.chatbot-button.selectable{border:1px solid #0042da}.chatbot-button.selectable,.chatbot-host-bubble{background-color:#f7f8ff;color:var(--chatbot-host-bubble-color)}.chatbot-host-bubble{word-wrap:break-word;overflow-wrap:break-word;white-space:normal;word-break:break-word}.bot-markdown-content :is(a,h1,h2,h3,h4,h5,h6,strong,em,blockquote,li){color:var(--bot-markdown-text-color,var(--chatbot-host-bubble-color))}.bot-markdown-content pre,.bot-markdown-content pre code{color:var(--bot-markdown-code-color,#fff)}.bot-markdown-content code:not(pre code){color:var(--bot-markdown-inline-code-color,#4caf50)}.chatbot-host-bubble>.bubble-typing{background-color:#f7f8ff;border:var(--chatbot-host-bubble-border);border-radius:6px}.chatbot-host-bubble iframe,.chatbot-host-bubble img,.chatbot-host-bubble video{border-radius:var(--chatbot-border-radius)}.chatbot-guest-bubble{word-wrap:break-word;background-color:#3b81f6;border-radius:6px;color:var(--chatbot-guest-bubble-color);overflow-wrap:break-word;white-space:normal;word-break:break-word}.chatbot-input,.feedback-input{background-color:#fff;border-radius:var(--chatbot-border-radius);box-shadow:0 2px 6px -1px rgba(0,0,0,.1);color:#303235}.chatbot-input-error-message{color:#303235}.chatbot-button>.send-icon{fill:var(--chatbot-button-color);stroke:var(--chatbot-button-color)}.regenerate-response-button>.icon-tabler-refresh{height:16px;width:16px}.chatbot-chat-view{max-width:800px}.ping span{background-color:#0042da}.rating-icon-container svg{stroke:#0042da;fill:#f7f8ff;height:42px;transition:fill .1s ease-out;width:42px}.rating-icon-container.selected svg{fill:#0042da}.rating-icon-container:hover svg{filter:brightness(.9)}.rating-icon-container:active svg{filter:brightness(.75)}.upload-progress-bar{background-color:#0042da;border-radius:var(--chatbot-border-radius)}.total-files-indicator{background-color:#0042da;color:var(--chatbot-button-color);font-size:10px}.chatbot-upload-input{transition:border-color .1s ease-out}.chatbot-upload-input.dragging-over{border-color:#0042da}.secondary-button{background-color:#f7f8ff;border-radius:var(--chatbot-border-radius);color:var(--chatbot-host-bubble-color)}.chatbot-country-select{color:#303235}.chatbot-country-select,.chatbot-date-input{background-color:#fff;border-radius:var(--chatbot-border-radius)}.chatbot-date-input{color:#303235;color-scheme:light}.chatbot-popup-blocked-toast{border-radius:var(--chatbot-border-radius)}.messagelist{border-radius:.5rem;height:100%;overflow-y:scroll;width:100%}.messagelistloading{display:flex;justify-content:center;margin-top:1rem;width:100%}.usermessage{padding:1rem 1.5rem}.usermessagewaiting-light{background:linear-gradient(270deg,#ede7f6,#e3f2fd,#ede7f6);background-position:-100% 0;background-size:200% 200%}.usermessagewaiting-dark,.usermessagewaiting-light{animation:loading-gradient 2s ease-in-out infinite;animation-direction:alternate;animation-name:loading-gradient;padding:1rem 1.5rem}.usermessagewaiting-dark{background:linear-gradient(270deg,#2e2352,#1d3d60,#2e2352);background-position:-100% 0;background-size:200% 200%;color:#ececf1}@keyframes loading-gradient{0%{background-position:-100% 0}to{background-position:100% 0}}.apimessage{animation:fadein .5s;padding:1rem 1.5rem}@keyframes fadein{0%{opacity:0}to{opacity:1}}.apimessage,.usermessage,.usermessagewaiting{display:flex}.markdownanswer{line-height:1.75}.markdownanswer a:hover{opacity:.8}.markdownanswer a{color:#16bed7;font-weight:500}.markdownanswer code{color:#15cb19;font-weight:500;white-space:pre-wrap!important}.markdownanswer ol,.markdownanswer ul{margin:1rem}.boticon,.usericon{border-radius:1rem;margin-right:1rem}.markdownanswer h1,.markdownanswer h2,.markdownanswer h3{font-size:inherit}.chatbot-host-bubble .prose,.chatbot-host-bubble.prose{font-size:inherit;line-height:1.55}.chatbot-host-bubble .prose h1,.chatbot-host-bubble.prose h1{font-size:1.25em;font-weight:700;line-height:1.3;margin-bottom:.3em;margin-top:.6em}.chatbot-host-bubble .prose h2,.chatbot-host-bubble.prose h2{font-size:1.125em;font-weight:600;line-height:1.35;margin-bottom:.25em;margin-top:.5em}.chatbot-host-bubble .prose h3,.chatbot-host-bubble.prose h3{font-size:1em;font-weight:600;line-height:1.4;margin-bottom:.2em;margin-top:.4em}:is(.chatbot-host-bubble.prose,.chatbot-host-bubble .prose) :is(h4,h5,h6){font-size:.95em;font-weight:600;line-height:1.4;margin-bottom:.15em;margin-top:.3em}.chatbot-host-bubble .prose p,.chatbot-host-bubble.prose p{margin-bottom:.35em;margin-top:.35em}.chatbot-host-bubble .prose ol,.chatbot-host-bubble .prose ul,.chatbot-host-bubble.prose ol,.chatbot-host-bubble.prose ul{margin-bottom:.25em;margin-top:.25em;padding-left:1.25em}.chatbot-host-bubble .prose li,.chatbot-host-bubble.prose li{margin-bottom:.1em;margin-top:.1em}.chatbot-host-bubble .prose blockquote,.chatbot-host-bubble.prose blockquote{border-left:3px solid;margin-bottom:.3em;margin-top:.3em;padding-left:.75em}.chatbot-host-bubble .prose pre,.chatbot-host-bubble.prose pre{word-wrap:break-word;font-size:.85em;margin-bottom:.4em;margin-top:.4em;overflow:auto;padding:5px;white-space:pre-wrap}.chatbot-host-bubble .prose hr,.chatbot-host-bubble.prose hr{margin-bottom:.5em;margin-top:.5em}.chatbot-host-bubble .prose table,.chatbot-host-bubble.prose table{font-size:.9em;margin-bottom:.3em;margin-top:.3em}.chatbot-host-bubble .prose>:first-child,.chatbot-host-bubble.prose>:first-child{margin-top:0}.chatbot-host-bubble .prose>:last-child,.chatbot-host-bubble.prose>:last-child{margin-bottom:0}.center{flex-direction:column;padding:10px;position:relative}.center,.cloud{align-items:center;display:flex;justify-content:center}.cloud{border-radius:.5rem;height:calc(100% - 50px);width:400px}input,textarea{background-color:transparent;border:none;font-family:Poppins,sans-serif;padding:10px}@media (max-width:640px){div[part=bot]{height:100%!important;left:0!important;max-height:unset!important;max-width:unset!important;overflow:auto;overflow-x:hidden;position:fixed!important;top:0!important;width:100%!important}.chatbot-container,.rounded-lg,div[class="flex flex-row items-center w-full h-[50px] absolute top-0 left-0 z-10"],div[part=button]{border-radius:0!important}button{cursor:default!important}}.tooltip{background:var(--tooltip-background-color,#000);border-radius:5px;color:var(--tooltip-text-color,#fff);font-size:var(--tooltip-font-size,12px);max-width:calc(100vw - 20px);padding:5px 10px;position:fixed;transition:opacity .3s ease-in-out;white-space:pre-wrap;word-break:break-word;z-index:42424242}@keyframes spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.spinner{animation:spin 1s linear infinite;border:4px solid hsla(0,0%,100%,.3);border-radius:50%;border-top-color:#fff;height:24px;width:24px}.hover\\:scale-110:hover{--tw-scale-x:1.1;--tw-scale-y:1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.hover\\:bg-green-600:hover{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity))}.hover\\:bg-red-600:hover{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity))}.hover\\:bg-transparent:hover{background-color:transparent}.hover\\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.hover\\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.hover\\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\\:brightness-90:hover{--tw-brightness:brightness(.9);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.focus\\:border-blue-500:focus{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity))}.focus\\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity))}.active\\:scale-95:active{--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.active\\:bg-emerald-600:active{--tw-bg-opacity:1;background-color:rgb(5 150 105/var(--tw-bg-opacity))}.active\\:brightness-75:active{--tw-brightness:brightness(.75);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.disabled\\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\\:opacity-50:disabled{opacity:.5}.disabled\\:brightness-100:disabled{--tw-brightness:brightness(1);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.group:hover .group-hover\\:flex{display:flex}@media (min-width:640px){.sm\\:right-5{right:20px}.sm\\:my-8{margin-bottom:32px;margin-top:32px}.sm\\:w-\\[400px\\]{width:400px}.sm\\:w-full{width:100%}.sm\\:max-w-lg{max-width:512px}.sm\\:p-0{padding:0}}';const Oe=e=>null==e,Ie=e=>null!=e,ke=async e=>{try{var r="string"==typeof e?e:e.url,o="string"!=typeof e&&Ie(e.body)?{"Content-Type":"application/json",...e.headers}:void 0;let n="string"!=typeof e&&Ie(e.body)?JSON.stringify(e.body):void 0;"string"!=typeof e&&e.formData&&(n=e.formData);var t={method:"string"==typeof e?"GET":e.method,mode:"cors",headers:o,body:n,signal:"string"!=typeof e?e.signal:void 0},a=("string"!=typeof e&&e.onRequest&&await e.onRequest(t),await fetch(r,t));let i;var l=a.headers.get("Content-Type");if(i=l&&l.includes("application/json")?await a.json():"string"!=typeof e&&"blob"===e.type?await a.blob():await a.text(),a.ok)return{data:i};{let e;throw e="object"==typeof i&&"error"in i?i.error:i||a.statusText}}catch(e){return console.error(e),{error:e}}},De=(e,r,o={})=>{var t=localStorage.getItem(e+"_EXTERNAL");o={...o};if(r&&(o.chatId=r),t)try{var a=JSON.parse(t);localStorage.setItem(e+"_EXTERNAL",JSON.stringify({...a,...o}))}catch(a){const r=t;o.chatId=r,localStorage.setItem(e+"_EXTERNAL",JSON.stringify(o))}else localStorage.setItem(e+"_EXTERNAL",JSON.stringify(o))},Re=e=>{if(!(e=localStorage.getItem(e+"_EXTERNAL")))return{};try{return JSON.parse(e)}catch(e){return{}}},Fe=e=>e?"number"==typeof e?e:"small"===e?32:"medium"!==e&&"large"===e?64:48:48,Xe=e=>{if("string"==typeof e)try{var r=document.querySelector(e);return null===r&&console.warn(`[Flowise] dialogContainer selector "${e}" did not match any element. Dialog will render inline.`),r??void 0}catch{return void console.warn(`[Flowise] Invalid dialogContainer selector: "${e}". Dialog will render inline.`)}if(e instanceof HTMLElement)return e},He=ge(''),Ue=ge('Bubble button icon'),Ve=ge('
');function Cn(e){return e?(e=e.replace(/&/g,"&").replace(//g,">")).replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,(function(e){let r="number";return/^"/.test(e)?r=/:$/.test(e)?"key":"string":/true|false/.test(e)?r="boolean":/null/.test(e)&&(r="null"),''+e+""})):""}const En=()=>ql(),Bn=()=>en(),Nn=()=>rn(),On=()=>on(),In=()=>tn(),kn=()=>an(),Dn=()=>ln(),Rn=e=>{{const r=nn();return P((o=>{var t=e.size,a=e.size;return t!==o._v$&&ue(r,"width",o._v$=t),a!==o._v$2&&ue(r,"height",o._v$2=a),o}),{_v$:void 0,_v$2:void 0}),r}},Fn=e=>{const r=e.size??24,o=Rl(e.name);if(o){const t=dn();return t.style.setProperty("flex-shrink","0"),t.style.setProperty("display","flex"),t.style.setProperty("align-items","center"),t.style.setProperty("justify-content","center"),r+"px"!=null?t.style.setProperty("width",r+"px"):t.style.removeProperty("width"),r+"px"!=null?t.style.setProperty("height",r+"px"):t.style.removeProperty("height"),be(t,(()=>o.icon({size:Math.round(.55*r),color:"white"}))),P((r=>{var a=e.borderRadius??"50%",l=e.bgColor??o.color;return a!==r._v$3&&(null!=(r._v$3=a)?t.style.setProperty("border-radius",a):t.style.removeProperty("border-radius")),l!==r._v$4&&(null!=(r._v$4=l)?t.style.setProperty("background",l):t.style.removeProperty("background")),r}),{_v$3:void 0,_v$4:void 0}),t}if(e.apiHost){const[o,t]=$(!1);{const a=dn();return a.style.setProperty("flex-shrink","0"),a.style.setProperty("display","flex"),a.style.setProperty("align-items","center"),a.style.setProperty("justify-content","center"),r+"px"!=null?a.style.setProperty("width",r+"px"):a.style.removeProperty("width"),r+"px"!=null?a.style.setProperty("height",r+"px"):a.style.removeProperty("height"),a.style.setProperty("overflow","hidden"),be(a,Q(oe,{get when(){return!o()},get fallback(){return Q(Rn,{get size(){return Math.round(.6*r)}})},get children(){const r=sn();return r.addEventListener("error",(()=>t(!0))),r.style.setProperty("width","100%"),r.style.setProperty("height","100%"),r.style.setProperty("padding","3px"),r.style.setProperty("object-fit","contain"),P((o=>{var t=e.apiHost+"/api/v1/node-icon/"+e.name,a=e.name;return t!==o._v$5&&ue(r,"src",o._v$5=t),a!==o._v$6&&ue(r,"alt",o._v$6=a),o}),{_v$5:void 0,_v$6:void 0}),r}})),P((r=>{var t=e.borderRadius??"50%",l=e.bgColor??(o()?"#9e9e9e":"#f5f5f5");return t!==r._v$7&&(null!=(r._v$7=t)?a.style.setProperty("border-radius",t):a.style.removeProperty("border-radius")),l!==r._v$8&&(null!=(r._v$8=l)?a.style.setProperty("background",l):a.style.removeProperty("background")),r}),{_v$7:void 0,_v$8:void 0}),a}}{const o=dn();return o.style.setProperty("flex-shrink","0"),o.style.setProperty("display","flex"),o.style.setProperty("align-items","center"),o.style.setProperty("justify-content","center"),r+"px"!=null?o.style.setProperty("width",r+"px"):o.style.removeProperty("width"),r+"px"!=null?o.style.setProperty("height",r+"px"):o.style.removeProperty("height"),o.style.setProperty("background","#9e9e9e"),be(o,Q(Rn,{get size(){return Math.round(.6*r)}})),P((()=>null!=(e.borderRadius??"50%")?o.style.setProperty("border-radius",e.borderRadius??"50%"):o.style.removeProperty("border-radius"))),o}},Xn=(e,r)=>Array.isArray(r)&&(r=r.find((r=>r.name===e)),r?.toolNode?.name)?r.toolNode.name:e,Hn=e=>{const[r,o]=$(e.defaultOpen??!1),t=()=>o(!r());{const o=mn(),a=o.firstChild,l=a.firstChild,n=l.nextSibling;return o.style.setProperty("border-radius","8px"),o.style.setProperty("margin-bottom","6px"),o.style.setProperty("overflow","hidden"),a.$$click=t,a.style.setProperty("display","flex"),a.style.setProperty("align-items","center"),a.style.setProperty("width","100%"),a.style.setProperty("padding","10px 12px"),a.style.setProperty("cursor","pointer"),a.style.setProperty("user-select","none"),a.style.setProperty("gap","8px"),a.style.setProperty("font-size","0.85rem"),a.style.setProperty("font-family","inherit"),a.style.setProperty("color","inherit"),a.style.setProperty("text-align","left"),a.style.setProperty("border","none"),a.style.setProperty("background","transparent"),l.style.setProperty("flex","1"),l.style.setProperty("display","flex"),l.style.setProperty("align-items","center"),l.style.setProperty("gap","8px"),l.style.setProperty("text-align","left"),be(l,(()=>e.header)),n.style.setProperty("display","flex"),n.style.setProperty("align-items","center"),n.style.setProperty("justify-content","center"),n.style.setProperty("flex-shrink","0"),n.style.setProperty("transition","transform 0.2s ease"),n.style.setProperty("opacity","0.5"),be(n,Q(Bn,{})),be(o,Q(oe,{get when(){return r()},get children(){var r=dn();return r.style.setProperty("padding","10px 12px"),r.style.setProperty("border-top","1px solid rgba(0,0,0,0.08)"),r.style.setProperty("background","rgba(0,0,0,0.02)"),be(r,(()=>e.children)),r}}),null),P((t=>{var l="1px solid "+(e.borderColor||"rgba(0,0,0,0.1)"),i=e.bgColor||"transparent",d=r(),s=r()?"rotate(180deg)":"rotate(0deg)";return l!==t._v$9&&(null!=(t._v$9=l)?o.style.setProperty("border",l):o.style.removeProperty("border")),i!==t._v$10&&(null!=(t._v$10=i)?o.style.setProperty("background",i):o.style.removeProperty("background")),d!==t._v$11&&ue(a,"aria-expanded",t._v$11=d),s!==t._v$12&&(null!=(t._v$12=s)?n.style.setProperty("transform",s):n.style.removeProperty("transform")),t}),{_v$9:void 0,_v$10:void 0,_v$11:void 0,_v$12:void 0}),o}},Un={"font-family":"'SF Mono', 'Fira Code', 'Fira Mono', Menlo, Consolas, monospace","font-size":"0.75rem","line-height":"1.5",padding:"12px","border-radius":"6px",background:"rgba(0,0,0,0.04)","overflow-x":"auto","white-space":"pre-wrap","word-break":"break-word",margin:"0"},Vn=e=>{{const r=gn();return he(r,Un),P((()=>r.innerHTML=Cn(JSON.stringify(e.data,null,2)))),r}},Wn=e=>{Wo.setOptions({isNoP:!0,sanitize:!0});const r=()=>{if(!e.content)return null;try{return JSON.parse(e.content)}catch{return null}};return Q(oe,{get when(){return r()},get fallback(){return Q(oe,{get when(){return e.content},get fallback(){return(e=un()).style.setProperty("font-size","0.85rem"),e.style.setProperty("opacity","0.5"),e;var e},get children(){const r=cn();return r.style.setProperty("word-break","break-word"),r.style.setProperty("font-size","0.85rem"),r.style.setProperty("line-height","1.6"),P((()=>r.innerHTML=Wo.parse(e.content))),r}})},get children(){return Q(Vn,{get data(){return r()}})}})},Kn=e=>{var r=e.artifact?.type;const o=e.artifact?.data,t={border:"1px solid rgba(0,0,0,0.12)","border-radius":"6px",overflow:"hidden",background:"rgba(0,0,0,0.02)"};if("png"===r||"jpeg"===r||"jpg"===r){const r=((e,r,o,t)=>e.startsWith("FILE-STORAGE::")&&r?r+`/api/v1/get-upload-file?chatflowId=${o}&chatId=${t}&fileName=`+e.replace("FILE-STORAGE::",""):e)(o,e.apiHost,e.chatflowid,e.chatId);{const o=xn(),a=o.firstChild;return ue(a,"src",r),a.style.setProperty("max-height","400px"),a.style.setProperty("max-width","100%"),a.style.setProperty("object-fit","contain"),a.style.setProperty("display","block"),P((r=>{var l={...t,display:"flex","justify-content":"center"},n="artifact-"+e.index;return r._v$13=he(o,l,r._v$13),n!==r._v$14&&ue(a,"alt",r._v$14=n),r}),{_v$13:void 0,_v$14:void 0}),o}}if("html"===r){const e=dn();return P((r=>{var a={...t,padding:"8px 12px","font-size":"0.85rem"},l=aa.sanitize(o);return r._v$15=he(e,a,r._v$15),l!==r._v$16&&(e.innerHTML=r._v$16=l),r}),{_v$15:void 0,_v$16:void 0}),e}{const e=dn();return be(e,Q(Wn,{content:o})),P((r=>he(e,{...t,padding:"8px 12px"},r))),e}},Zn=e=>{const r=async()=>{if(e.apiHost)try{var r=await(await fetch(e.apiHost+"/api/v1/openai-assistants-file/download",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({fileName:e.annotation.fileName,chatflowId:e.chatflowid,chatId:e.chatId})})).blob(),o=window.URL.createObjectURL(r),t=document.createElement("a");t.href=o,t.download=e.annotation.fileName,document.body.appendChild(t),t.click(),t.remove(),window.URL.revokeObjectURL(o)}catch(r){console.error("Download failed:",r)}};{const o=pn(),t=o.firstChild;return o.$$click=r,o.style.setProperty("display","inline-flex"),o.style.setProperty("align-items","center"),o.style.setProperty("gap","6px"),o.style.setProperty("padding","4px 12px"),o.style.setProperty("border","1px solid rgba(0,0,0,0.2)"),o.style.setProperty("border-radius","6px"),o.style.setProperty("background","transparent"),o.style.setProperty("cursor","pointer"),o.style.setProperty("font-size","0.8rem"),o.style.setProperty("color","inherit"),be(t,(()=>e.annotation.fileName)),be(o,Q(Dn,{}),null),P((()=>ue(o,"title","Download "+e.annotation.fileName))),o}},zn=e=>{const[r,o]=$("rendered"),[t,a]=$(null),l=()=>e.node?.data?jl(e.node.data):{},n=(e,r)=>({display:"inline-flex","align-items":"center",gap:"4px",padding:"3px 10px","border-radius":"12px","font-size":"0.75rem","font-weight":"500",background:e,color:r,border:`1px solid ${r}33`}),i={"margin-bottom":"16px"},d={"font-weight":"700","font-size":"0.9rem","margin-bottom":"8px"},s={border:"1px solid rgba(0,0,0,0.1)","border-radius":"10px",padding:"14px 16px",background:"rgba(0,0,0,0.015)"},m=()=>{const r=l();var o,t,m,g,c,u,x;return r?(g=(m=(t=(o=An()).firstChild).firstChild).nextSibling,x=(u=(c=t.nextSibling).firstChild).nextSibling,be(o,Q(oe,{get when(){return r.output?.availableTools?.length},get children(){var o=fn(),t=o.firstChild,a=t.nextSibling;return he(o,i),he(t,d),he(a,s),be(a,Q(re,{get each(){return r.output.availableTools},children:o=>{a=o.name,l=r.output?.usedTools;const t=Array.isArray(l)&&l.some((e=>e.tool===a));var a,l;const i=o.toolNode?.name||o.name;return Q(Hn,{borderColor:t?"#4CAF50":void 0,bgColor:t?"rgba(76,175,80,0.05)":void 0,get header(){return[Q(Fn,{name:i,get apiHost(){return e.apiHost},size:22}),(be(r=hn(),(()=>o.toolNode?.label||o.name)),r),Q(oe,{when:t,get children(){const e=Mn();return P((r=>he(e,n("#E8F5E9","#2E7D32"),r))),e}})];var r},get children(){return Q(Vn,{data:o})}})}})),o}}),t),he(t,i),he(m,d),he(g,s),be(g,Q(oe,{get when(){return r.input?.messages&&Array.isArray(r.input.messages)&&0r.input.code)),e}})},get children(){return Q(Vn,{get data(){return r.input.form||r.input.http||r.input.conditions}})}})},get children(){var o=dn();return o.style.setProperty("display","flex"),o.style.setProperty("flex-direction","column"),o.style.setProperty("gap","4px"),be(o,Q(re,{get each(){return r.input.messages},children:o=>((r,o)=>{const t=r.role||"unknown",l=(e=>{switch(e?.toLowerCase()){case"assistant":case"ai":return{background:"#E8F5E9",color:"#2E7D32"};case"system":return{background:"#FFF3E0",color:"#E65100"};case"developer":case"user":case"human":return{background:"#E3F2FD",color:"#1565C0"};case"tool":case"function":return{background:"#F3E5F5",color:"#7B1FA2"};default:return{background:"#F5F5F5",color:"#616161"}}})(t),n=(e,r,o,t)=>{{const a=hn();return be(a,o),P((o=>he(a,{display:"inline-flex","align-items":"center",gap:"4px",padding:"3px 10px","border-radius":"12px","font-size":"0.75rem","font-weight":"500",background:e,color:r,border:`1px solid ${r}33`,...t},o))),a}};return(i=dn()).style.setProperty("margin-bottom","8px"),i.style.setProperty("padding","8px"),i.style.setProperty("border-radius","6px"),i.style.setProperty("background","rgba(0,0,0,0.02)"),be(i,(()=>n(l.background,l.color,t)),null),be(i,Q(oe,{get when(){return r.name},get children(){return n(l.background,l.color,r.name,{"margin-left":"4px"})}}),null),be(i,Q(oe,{get when(){return r.tool_calls&&Array.isArray(r.tool_calls)&&0Q(Hn,{borderColor:"#FFC107",bgColor:"rgba(255,193,7,0.05)",get header(){return[Q(kn,{}),(be(r=hn(),(()=>e.function?.name||e.name||"Tool Call")),r),M((()=>n("#FFF3E0","#E65100","Called")))];var r},get children(){return Q(Vn,{data:e})}})})}}),null),be(i,Q(oe,{get when(){return"tool"===t&&r.name},get children(){var e=vn(),o=e.firstChild;return e.style.setProperty("display","flex"),e.style.setProperty("align-items","center"),e.style.setProperty("gap","8px"),e.style.setProperty("margin-top","4px"),e.style.setProperty("flex-wrap","wrap"),be(e,Q(kn,{}),o),o.style.setProperty("font-size","0.85rem"),be(o,(()=>r.name)),be(e,Q(oe,{get when(){return r.tool_call_id},get children(){return n("#F5F5F5","#616161",r.tool_call_id)}}),null),e}}),null),be(i,Q(oe,{get when(){return r.additional_kwargs?.usedTools?.length},get children(){var t=dn();return t.style.setProperty("margin-top","8px"),t.style.setProperty("display","flex"),t.style.setProperty("gap","4px"),t.style.setProperty("flex-wrap","wrap"),be(t,Q(re,{get each(){return r.additional_kwargs.usedTools},children:r=>Q(oe,{when:r,get children(){const t=Sn(),l=t.firstChild;return t.$$click=()=>a(r),t.style.setProperty("display","inline-flex"),t.style.setProperty("align-items","center"),t.style.setProperty("gap","4px"),t.style.setProperty("padding","3px 10px"),t.style.setProperty("border-radius","12px"),t.style.setProperty("font-size","0.75rem"),t.style.setProperty("font-weight","500"),t.style.setProperty("cursor","pointer"),t.style.setProperty("transition","filter 0.15s ease"),be(t,Q(Fn,{get name(){return Xn(r.tool,o?.output?.availableTools)},get apiHost(){return e.apiHost},size:16,borderRadius:"4px"}),l),be(t,(()=>r.tool),null),P((e=>{var o=r.error?"#FFEBEE":"#F3E5F5",a=r.error?"#C62828":"#7B1FA2",l="1px solid "+(r.error?"#C6282833":"#7B1FA233");return o!==e._v$17&&(null!=(e._v$17=o)?t.style.setProperty("background",o):t.style.removeProperty("background")),a!==e._v$18&&(null!=(e._v$18=a)?t.style.setProperty("color",a):t.style.removeProperty("color")),l!==e._v$19&&(null!=(e._v$19=l)?t.style.setProperty("border",l):t.style.removeProperty("border")),e}),{_v$17:void 0,_v$18:void 0,_v$19:void 0}),t}})})),t}}),null),be(i,Q(oe,{get when(){return r.additional_kwargs?.artifacts?.length},get children(){var o=dn();return o.style.setProperty("margin-top","8px"),o.style.setProperty("display","flex"),o.style.setProperty("flex-direction","column"),o.style.setProperty("gap","8px"),be(o,Q(re,{get each(){return r.additional_kwargs.artifacts},children:(r,o)=>Q(Kn,{artifact:r,get index(){return o()},get apiHost(){return e.apiHost},get chatflowid(){return e.chatflowid},get chatId(){return e.chatId}})})),o}}),null),be(i,Q(oe,{get when(){return"user"===t&&Array.isArray(r.content)&&0{const t="stored-file"===r.type&&e.apiHost?`${e.apiHost}/api/v1/get-upload-file?chatflowId=${e.chatflowid}&chatId=${e.chatId}&fileName=`+r.name:r.name;{const e=xn(),r=e.firstChild;return e.style.setProperty("border","1px solid rgba(0,0,0,0.12)"),e.style.setProperty("border-radius","6px"),e.style.setProperty("overflow","hidden"),e.style.setProperty("display","flex"),e.style.setProperty("justify-content","center"),e.style.setProperty("background","rgba(0,0,0,0.02)"),ue(r,"src",t),r.style.setProperty("max-height","400px"),r.style.setProperty("max-width","100%"),r.style.setProperty("object-fit","contain"),r.style.setProperty("display","block"),P((()=>ue(r,"alt","file-upload-"+o()))),e}}})),o}}),null),be(i,Q(oe,{get when(){return"string"==typeof r.content&&r.content},get children(){var e=dn();return e.style.setProperty("margin-top","4px"),be(e,Q(Wn,{get content(){return r.content}})),e}}),null),be(i,Q(oe,{get when(){return!r.content},get children(){var e=un();return e.style.setProperty("margin-top","4px"),e.style.setProperty("font-size","0.85rem"),e.style.setProperty("opacity","0.5"),e}}),null),be(i,Q(oe,{get when(){return r.additional_kwargs?.fileAnnotations?.length},get children(){var o=dn();return o.style.setProperty("margin-top","8px"),o.style.setProperty("display","flex"),o.style.setProperty("gap","6px"),o.style.setProperty("flex-wrap","wrap"),be(o,Q(re,{get each(){return r.additional_kwargs.fileAnnotations},children:r=>Q(Zn,{annotation:r,get apiHost(){return e.apiHost},get chatflowid(){return e.chatflowid},get chatId(){return e.chatId}})})),o}}),null),i;var i})(o,r)})),o}})),he(c,i),he(u,d),he(x,s),be(x,Q(oe,{get when(){return!(r.output?.form||r.output?.http||r.output?.conditions)},get fallback(){return Q(oe,{get when(){return r.output?.conditions},get fallback(){return Q(Vn,{get data(){return r.output?.form||r.output?.http}})},get children(){return(e=>Q(re,{each:e.filter((e=>e.isFulfilled)),children:(e,r)=>{{const o=bn(),t=o.firstChild,a=t.firstChild,l=a.nextSibling;return o.style.setProperty("border","1px solid #4CAF50"),o.style.setProperty("border-radius","6px"),o.style.setProperty("padding","8px 12px"),o.style.setProperty("margin-bottom","6px"),t.style.setProperty("display","flex"),t.style.setProperty("justify-content","space-between"),t.style.setProperty("align-items","center"),a.style.setProperty("font-size","0.85rem"),be(a,(()=>{const o=M((()=>!("string"!==e.type||"equal"!==e.operation||e.value1||e.value2)));return()=>o()?"Else condition fulfilled":"Condition "+r()})()),be(o,Q(oe,{get when(){return!("string"===e.type&&"equal"===e.operation&&!e.value1&&!e.value2)},get children(){var r=dn();return r.style.setProperty("margin-top","4px"),be(r,Q(Vn,{data:e})),r}}),null),P((e=>he(l,n("#E8F5E9","#2E7D32"),e))),o}}}))(r.output.conditions)}})},get children(){return[Q(oe,{get when(){return r.output?.usedTools?.length},get children(){var o=dn();return o.style.setProperty("display","flex"),o.style.setProperty("gap","4px"),o.style.setProperty("flex-wrap","wrap"),o.style.setProperty("margin-bottom","8px"),be(o,Q(re,{get each(){return r.output.usedTools},children:o=>Q(oe,{when:o,get children(){const t=Sn(),l=t.firstChild;return t.$$click=()=>a(o),be(t,Q(Fn,{get name(){return Xn(o.tool,r.output?.availableTools)},get apiHost(){return e.apiHost},size:16,borderRadius:"4px"}),l),be(t,(()=>o.tool),null),P((e=>he(t,{...n(o.error?"#FFEBEE":"#F3E5F5",o.error?"#C62828":"#7B1FA2"),cursor:"pointer",transition:"filter 0.15s ease"},e))),t}})})),o}}),Q(oe,{get when(){return r.output?.artifacts?.length},get children(){var o=dn();return o.style.setProperty("display","flex"),o.style.setProperty("flex-direction","column"),o.style.setProperty("gap","8px"),o.style.setProperty("margin-bottom","8px"),be(o,Q(re,{get each(){return r.output.artifacts},children:(r,o)=>Q(Kn,{artifact:r,get index(){return o()},get apiHost(){return e.apiHost},get chatflowid(){return e.chatflowid},get chatId(){return e.chatId}})})),o}}),Q(oe,{get when(){return r.output?.content},get fallback(){return Q(oe,{get when(){return r.output},get children(){var e=un();return e.style.setProperty("font-size","0.85rem"),e.style.setProperty("opacity","0.5"),e}})},get children(){return Q(Wn,{get content(){return r.output.content}})}}),Q(oe,{get when(){return r.output?.fileAnnotations?.length},get children(){var o=dn();return o.style.setProperty("margin-top","8px"),o.style.setProperty("display","flex"),o.style.setProperty("gap","6px"),o.style.setProperty("flex-wrap","wrap"),be(o,Q(re,{get each(){return r.output.fileAnnotations},children:r=>Q(Zn,{annotation:r,get apiHost(){return e.apiHost},get chatflowid(){return e.chatflowid},get chatId(){return e.chatId}})})),o}})]}})),be(o,Q(oe,{get when(){return r.error},get children(){const e=$n(),o=e.firstChild,t=o.nextSibling,a=t.firstChild;return he(e,i),a.style.setProperty("padding","8px 12px"),a.style.setProperty("border-radius","6px"),a.style.setProperty("border","1px solid #F44336"),a.style.setProperty("background","rgba(244,67,54,0.08)"),a.style.setProperty("color","#C62828"),a.style.setProperty("font-size","0.85rem"),a.style.setProperty("white-space","pre-wrap"),a.style.setProperty("word-break","break-word"),be(a,(()=>{const e=M((()=>"object"==typeof r.error));return()=>e()?JSON.stringify(r.error,null,2):r.error})()),P((e=>{var r={...d,color:"#C62828"},a={...s,"border-color":"rgba(244,67,54,0.4)",background:"rgba(244,67,54,0.04)"};return e._v$20=he(o,r,e._v$20),e._v$21=he(t,a,e._v$21),e}),{_v$20:void 0,_v$21:void 0}),e}}),null),be(o,Q(oe,{get when(){return M((()=>!!r.state))()&&0e.node.label)),x.style.setProperty("display","flex"),x.style.setProperty("flex-wrap","wrap"),x.style.setProperty("align-items","center"),x.style.setProperty("gap","8px"),x.style.setProperty("flex","1"),be(x,Q(oe,{get when(){return(e=>{var r;return e=e?.output,e&&(r={},e.timeMetadata?.delta&&(r.time=(e.timeMetadata.delta/1e3).toFixed(2)+" seconds"),e.usageMetadata?.total_tokens&&(r.tokens=e.usageMetadata.total_tokens+" tokens"),null!=e.usageMetadata?.total_cost&&0<=Number(e.usageMetadata.total_cost)&&(e=Number(e.usageMetadata.total_cost),r.cost=.01<=e?"$"+e.toFixed(2):"$"+e.toFixed(6)),0[Q(oe,{get when(){return e().time},get children(){var r=Sn(),o=r.firstChild;return r.style.setProperty("display","inline-flex"),r.style.setProperty("align-items","center"),r.style.setProperty("gap","4px"),r.style.setProperty("padding","4px 12px"),r.style.setProperty("border-radius","14px"),r.style.setProperty("font-size","0.75rem"),r.style.setProperty("font-weight","600"),r.style.setProperty("white-space","nowrap"),r.style.setProperty("background","#4caf50"),r.style.setProperty("color","#fff"),be(r,Q(Nn,{}),o),be(r,(()=>e().time),null),r}}),Q(oe,{get when(){return e().tokens},get children(){var r=Sn(),o=r.firstChild;return r.style.setProperty("display","inline-flex"),r.style.setProperty("align-items","center"),r.style.setProperty("gap","4px"),r.style.setProperty("padding","4px 12px"),r.style.setProperty("border-radius","14px"),r.style.setProperty("font-size","0.75rem"),r.style.setProperty("font-weight","600"),r.style.setProperty("white-space","nowrap"),r.style.setProperty("background","#7c4dff"),r.style.setProperty("color","#fff"),be(r,Q(In,{}),o),be(r,(()=>e().tokens),null),r}}),Q(oe,{get when(){return e().cost},get children(){var r=Sn(),o=r.firstChild;return r.style.setProperty("display","inline-flex"),r.style.setProperty("align-items","center"),r.style.setProperty("gap","4px"),r.style.setProperty("padding","4px 12px"),r.style.setProperty("border-radius","14px"),r.style.setProperty("font-size","0.75rem"),r.style.setProperty("font-weight","600"),r.style.setProperty("white-space","nowrap"),r.style.setProperty("background","#ff9800"),r.style.setProperty("color","#fff"),be(r,Q(On,{}),o),be(r,(()=>e().cost),null),r}})]})),g),(()=>{const e=wn(),t=e.firstChild,a=t.nextSibling;return e.style.setProperty("display","flex"),e.style.setProperty("gap","0"),e.style.setProperty("padding","0 20px"),e.style.setProperty("border-bottom","1px solid rgba(0,0,0,0.1)"),t.$$click=()=>o("rendered"),t.style.setProperty("padding","10px 16px"),t.style.setProperty("border","none"),t.style.setProperty("background","transparent"),t.style.setProperty("cursor","pointer"),t.style.setProperty("font-size","0.875rem"),t.style.setProperty("font-weight","500"),t.style.setProperty("font-family","inherit"),t.style.setProperty("margin-bottom","-1px"),a.$$click=()=>o("raw"),a.style.setProperty("padding","10px 16px"),a.style.setProperty("border","none"),a.style.setProperty("background","transparent"),a.style.setProperty("cursor","pointer"),a.style.setProperty("font-size","0.875rem"),a.style.setProperty("font-weight","500"),a.style.setProperty("font-family","inherit"),a.style.setProperty("margin-bottom","-1px"),P((e=>{var o="rendered"===r()?"#1976d2":"inherit",l="rendered"===r()?1:.6,n="rendered"===r()?"2px solid #1976d2":"2px solid transparent",i="raw"===r()?"#1976d2":"inherit",d="raw"===r()?1:.6,s="raw"===r()?"2px solid #1976d2":"2px solid transparent";return o!==e._v$22&&(null!=(e._v$22=o)?t.style.setProperty("color",o):t.style.removeProperty("color")),l!==e._v$23&&(null!=(e._v$23=l)?t.style.setProperty("opacity",l):t.style.removeProperty("opacity")),n!==e._v$24&&(null!=(e._v$24=n)?t.style.setProperty("border-bottom",n):t.style.removeProperty("border-bottom")),i!==e._v$25&&(null!=(e._v$25=i)?a.style.setProperty("color",i):a.style.removeProperty("color")),d!==e._v$26&&(null!=(e._v$26=d)?a.style.setProperty("opacity",d):a.style.removeProperty("opacity")),s!==e._v$27&&(null!=(e._v$27=s)?a.style.setProperty("border-bottom",s):a.style.removeProperty("border-bottom")),e}),{_v$22:void 0,_v$23:void 0,_v$24:void 0,_v$25:void 0,_v$26:void 0,_v$27:void 0}),e})(),((c=dn()).style.setProperty("padding","16px 20px"),c.style.setProperty("overflow-y","auto"),c.style.setProperty("flex","1"),be(c,Q(oe,{get when(){return"rendered"===r()},get fallback(){{const e=gn();return he(e,Un),P((()=>e.innerHTML=Cn(JSON.stringify(l(),null,2)))),e}},get children(){return m()}})),c),Q(oe,{get when(){return t()},get children(){return[(()=>{const r=Gn(),o=r.firstChild,n=o.firstChild,i=n.firstChild,d=i.firstChild,s=i.nextSibling,m=n.nextSibling;return r.$$click=()=>a(null),r.style.setProperty("position","fixed"),r.style.setProperty("inset","0"),r.style.setProperty("z-index","1004"),r.style.setProperty("display","flex"),r.style.setProperty("align-items","center"),r.style.setProperty("justify-content","center"),o.$$click=e=>e.stopPropagation(),o.style.setProperty("position","relative"),o.style.setProperty("width","100%"),o.style.setProperty("max-width","560px"),o.style.setProperty("margin","24px 16px"),o.style.setProperty("border-radius","8px"),o.style.setProperty("box-shadow","0 20px 40px -4px rgba(0,0,0,0.2), 0 8px 16px -4px rgba(0,0,0,0.1)"),o.style.setProperty("display","flex"),o.style.setProperty("flex-direction","column"),o.style.setProperty("max-height","calc(100% - 80px)"),o.style.setProperty("overflow","hidden"),n.style.setProperty("display","flex"),n.style.setProperty("align-items","center"),n.style.setProperty("justify-content","space-between"),n.style.setProperty("padding","14px 20px"),n.style.setProperty("border-bottom","1px solid rgba(0,0,0,0.1)"),i.style.setProperty("display","flex"),i.style.setProperty("align-items","center"),i.style.setProperty("gap","8px"),be(i,Q(Fn,{get name(){return Xn(t()?.tool||"",l()?.output?.availableTools)},get apiHost(){return e.apiHost},size:24}),d),d.style.setProperty("font-weight","600"),d.style.setProperty("font-size","0.95rem"),be(d,(()=>t()?.tool||"Tool Detail")),be(i,Q(oe,{get when(){return t()?.error},get children(){var e=Ln();return e.style.setProperty("display","inline-flex"),e.style.setProperty("align-items","center"),e.style.setProperty("padding","2px 8px"),e.style.setProperty("border-radius","10px"),e.style.setProperty("font-size","0.7rem"),e.style.setProperty("font-weight","500"),e.style.setProperty("background","#FFEBEE"),e.style.setProperty("color","#C62828"),e.style.setProperty("border","1px solid #C6282833"),e}}),null),s.$$click=()=>a(null),s.style.setProperty("border","none"),s.style.setProperty("background","transparent"),s.style.setProperty("cursor","pointer"),s.style.setProperty("padding","4px"),s.style.setProperty("border-radius","4px"),s.style.setProperty("display","flex"),s.style.setProperty("align-items","center"),s.style.setProperty("color","inherit"),s.style.setProperty("opacity","0.7"),be(s,Q(En,{})),m.style.setProperty("padding","16px 20px"),m.style.setProperty("overflow-y","auto"),be(m,Q(Vn,{get data(){return t()}})),P((r=>{var t=e.backgroundColor??"#ffffff",a=e.textColor??"#303235";return t!==r._v$28&&(null!=(r._v$28=t)?o.style.setProperty("background-color",t):o.style.removeProperty("background-color")),a!==r._v$29&&(null!=(r._v$29=a)?o.style.setProperty("color",a):o.style.removeProperty("color")),r}),{_v$28:void 0,_v$29:void 0}),r})(),((r=dn()).$$click=e=>{e.stopPropagation(),a(null)},r.style.setProperty("position","fixed"),r.style.setProperty("inset","0"),r.style.setProperty("z-index","1003"),r.style.setProperty("background-color","rgba(0,0,0,0.35)"),r)];var r}})];var g,c,u,x},Qn=(ce(["click"]),ge('')),Yn=ge(''),Jn=ge(''),jn=ge(""),qn=ge("
"),ei=ge('
'),ri=ge("
'),td=ge("
");const ad=e=>{let r;const[o]=ee(e,["onOpen","onClose","isOpen","value"]),[t,a]=(_((()=>{r&&(r.innerHTML=function(e){return(e=(e="string"!=typeof e?JSON.stringify(e,void 0,2):e).replace(/&/g,"&").replace(//g,">")).replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,(function(e){let r="number";return/^"/.test(e)?r=/:$/.test(e)?"key":"string":/true|false/.test(e)?r="boolean":/null/.test(e)&&(r="null"),''+e+""}))}(JSON.stringify(e?.value,void 0,2)))})),$(o.isOpen??!1)),l=(A((()=>{Oe(e.isOpen)||e.isOpen===t()||i()})),e=>{e.stopPropagation()}),n=()=>{a(!1),o.onClose?.(),document.body.style.overflow="auto"},i=()=>{t()?n():(a(!0),o.onOpen?.(),document.body.style.overflow="hidden")};return Q(oe,{get when(){return t()},get children(){return[(be(a=rd(),Ne),a),(a=od(),o=a.firstChild,t=o.nextSibling.nextSibling.firstChild.firstChild,a.style.setProperty("z-index","1100"),a.addEventListener("click",n),be(o,Ne),t.style.setProperty("background-color","transparent"),t.style.setProperty("margin-left","20px"),t.style.setProperty("margin-right","20px"),t.addEventListener("click",l),t.addEventListener("pointerdown",l),be(t,(()=>{const o=M((()=>!!e.value));return()=>{return o()&&(t=(e=td()).firstChild,e.style.setProperty("background","white"),e.style.setProperty("margin","auto"),e.style.setProperty("padding","7px"),"function"==typeof(a=r)?Se(a,t):r=t,e);var e,t,a}})()),a)];var o,t,a}})},ld=ge('