diff --git a/package-lock.json b/package-lock.json index 02a7fe97e..179036a6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@astrojs/sitemap": "^3.7.2", "@astrojs/starlight": "^0.41.4", "@astrojs/vercel": "^11.0.3", - "@kapaai/react-sdk": "^0.9.2", + "@kapaai/react-sdk": "^0.17.0", "@radix-ui/react-popover": "^1.1.15", "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", @@ -1244,15 +1244,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", @@ -2206,25 +2197,11 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, - "node_modules/@hcaptcha/loader": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@hcaptcha/loader/-/loader-2.3.0.tgz", - "integrity": "sha512-i4lnNxKBe+COf3R1nFZEWaZoHIoJjvDgWqvcNrdZq8ehoSNMN6KVZ56dcQ02qKie2h3+BkbkwlJA9DOIuLlK/g==", - "license": "MIT" - }, "node_modules/@hcaptcha/react-hcaptcha": { - "version": "1.17.4", - "resolved": "https://registry.npmjs.org/@hcaptcha/react-hcaptcha/-/react-hcaptcha-1.17.4.tgz", - "integrity": "sha512-rIvgesG1N7SS9sAYYHFoWm+nXqRrxq7RcA9z2pKkDWV+S1GdfmrTNYA1aPyVWVe3eowphTCwyDJvl97Swwy0mw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.17.9", - "@hcaptcha/loader": "^2.3.0" - }, - "peerDependencies": { - "react": ">= 16.3.0", - "react-dom": ">= 16.3.0" - } + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@hcaptcha/react-hcaptcha/-/react-hcaptcha-2.2.0.tgz", + "integrity": "sha512-/HrPgNLRrJziLrh2s+qFRO33ubBSuHn9sdNsQcP0wQElpORxlpR6wnq3X9j/bZGd+uQtYIvPl4QSU2la+Jl9Hg==", + "license": "MIT" }, "node_modules/@iconify/types": { "version": "2.0.0", @@ -2849,15 +2826,15 @@ } }, "node_modules/@kapaai/react-sdk": { - "version": "0.9.10", - "resolved": "https://registry.npmjs.org/@kapaai/react-sdk/-/react-sdk-0.9.10.tgz", - "integrity": "sha512-osQyFgBJmhNM207MpB0aZbjs2kjNHjEJbNc5YEReJuyFE72Iydn9CoZGxox2GYRgMIlQ7wljHCzgQqkvR8lXqA==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@kapaai/react-sdk/-/react-sdk-0.17.0.tgz", + "integrity": "sha512-wBqknpqI++w4/ttbdZl8Hf+PBk1Jm+Z3J11S/SwVYViW5xyD5JKn/4CwbjR6FTMr5WLCznjRq28LOH2Ot21IRQ==", "license": "MIT", "dependencies": { "@fingerprintjs/fingerprintjs-pro-react": "^2.7.0", "@fingerprintjs/fingerprintjs-pro-spa": "^1.3.0", - "@hcaptcha/react-hcaptcha": "^1.12.0", - "js-cookie": "^3.0.5", + "@hcaptcha/react-hcaptcha": "^2.0.2", + "js-cookie": "^3.0.7", "tldts": "^7.0.7" }, "peerDependencies": { diff --git a/package.json b/package.json index 1d073f305..1cda063d4 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:tracking-redirect": "node --test scripts/tracking-redirect.test.mjs", "test:agent-discovery": "node --test scripts/agent-discovery.test.mjs", "test:tracking-routes": "node scripts/validate-tracking-routes.mjs", + "test:kapa-history": "node --test src/lib/kapa-conversation-history.test.mjs", "test:kapa-webmcp": "node --test src/lib/kapa-webmcp.test.mjs", "test:kapa-support-escalation": "node --test src/lib/kapa-support-escalation.test.mjs", "test:homepage-json-ld": "node scripts/validate-homepage-json-ld.mjs", @@ -38,7 +39,7 @@ "@astrojs/sitemap": "^3.7.2", "@astrojs/starlight": "^0.41.4", "@astrojs/vercel": "^11.0.3", - "@kapaai/react-sdk": "^0.9.2", + "@kapaai/react-sdk": "^0.17.0", "@radix-ui/react-popover": "^1.1.15", "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", diff --git a/src/components/KapaChatLauncher.css b/src/components/KapaChatLauncher.css index 9eeec47e3..980487b43 100644 --- a/src/components/KapaChatLauncher.css +++ b/src/components/KapaChatLauncher.css @@ -42,48 +42,19 @@ border: 0; } +.sl-kapa-panel__header-start, .sl-kapa-panel__header-actions { display: flex; align-items: center; gap: 0.5rem; } -.sl-kapa-mcp-connect__trigger { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.375rem; - min-height: 2rem; - padding: 0.375rem 0.625rem; - border: 1px solid color-mix(in srgb, var(--sl-color-text-accent), var(--warp-control-border) 48%); - border-radius: var(--sl-radius-sm); - background: color-mix(in srgb, var(--sl-color-text-accent), var(--warp-control-bg) 88%); - color: var(--sl-color-text-accent); - font: inherit; - font-size: var(--sl-text-xs); - font-weight: 600; - line-height: 1.2; - white-space: nowrap; - cursor: pointer; - transition: - border-color 0.15s ease, - background-color 0.15s ease, - color 0.15s ease; -} - -.sl-kapa-mcp-connect__trigger svg { - width: 0.875rem; - height: 0.875rem; - flex: none; -} -.sl-kapa-mcp-connect__trigger:hover, .sl-kapa-mcp-connect__trigger[data-state='open'] { - border-color: var(--sl-color-text-accent); - background: color-mix(in srgb, var(--sl-color-text-accent), var(--warp-control-bg) 78%); - color: var(--sl-color-white); + border-color: var(--warp-control-border-hover); + background: var(--warp-control-bg-hover); + color: var(--warp-control-text-hover); } -.sl-kapa-mcp-connect__trigger:focus-visible, .sl-kapa-mcp-connect__action:focus-visible { outline: 2px solid var(--sl-color-accent-high); outline-offset: 2px; @@ -303,6 +274,11 @@ transform: translateY(0); } +.sl-kapa-icon-button--tooltip-end[data-tooltip]::after { + left: auto; + right: 0; +} + .sl-kapa-icon-button:hover:not(:disabled), .sl-kapa-feedback__button:hover:not(:disabled) { border-color: var(--warp-control-border-hover); @@ -320,11 +296,343 @@ flex: 1 1 auto; overflow-y: auto; padding: 1rem; + /* Keep the latest card frame/controls/CTA clear of the composer edge. */ + padding-bottom: 1.5rem; + scroll-padding-bottom: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.sl-kapa-history { display: flex; + flex: 1; flex-direction: column; gap: 1rem; } +.sl-kapa-history__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.sl-kapa-history__header h2, +.sl-kapa-history__header p, +.sl-kapa-history__empty { + margin: 0; +} + +.sl-kapa-history__header h2 { + color: var(--sl-color-white); + font-size: var(--sl-text-lg); + line-height: 1.3; +} + +.sl-kapa-history__header p, +.sl-kapa-history__empty { + margin-top: 0.375rem; + color: var(--sl-color-gray-3); + font-size: var(--sl-text-xs); + line-height: 1.45; +} + +.sl-kapa-history__open:focus-visible, +.sl-kapa-history__icon-button:focus-visible, +.sl-kapa-history__text-button:focus-visible { + outline: 2px solid var(--sl-color-accent-high); + outline-offset: 2px; +} + +.sl-kapa-history__rename input:focus-visible { + outline: 2px solid var(--sl-color-accent-high); + /* Keep the focus ring inside the row instead of spilling past the left edge. */ + outline-offset: -2px; +} + +.sl-kapa-history__empty { + margin: auto 0; + padding: 1rem; + border: 1px dashed var(--sl-color-gray-5); + border-radius: var(--sl-radius-lg); +} + +.sl-kapa-history__status { + margin: 0; + padding: 0.5rem 0.625rem; + border: 1px solid var(--sl-color-gray-5); + border-radius: var(--sl-radius-sm); + background: color-mix(in srgb, var(--sl-color-black), var(--sl-color-gray-6) 22%); + color: var(--sl-color-gray-2); + font-size: var(--sl-text-xs); + line-height: 1.4; +} + +.sl-kapa-history__list { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; +} + +.sl-kapa-history__row { + /* relative so hovered-row z-index can lift tooltips above neighbors. + Keep overflow visible so rename/delete tooltips are not clipped. */ + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: center; + min-height: 3rem; + padding: 0; + border: 1px solid var(--sl-color-gray-5); + border-radius: var(--sl-radius-sm); + background: color-mix(in srgb, var(--sl-color-black), var(--sl-color-gray-6) 28%); + color: var(--sl-color-gray-1); +} + +/* Lift the active action row so tooltips paint above the next card. */ +.sl-kapa-history__row:has(.sl-kapa-history__icon-button:hover), +.sl-kapa-history__row:has(.sl-kapa-history__icon-button:focus-visible) { + z-index: 3; +} + +.sl-kapa-history__row:hover { + border-color: var(--warp-control-border-hover); + background: var(--warp-control-bg-hover); +} + +.sl-kapa-history__row[data-active='true'] { + border-color: color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-gray-5) 55%); + background: color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-black) 78%); + color: var(--sl-color-white); +} + +.sl-kapa-history__row[data-pending-delete='true'], +.sl-kapa-history__row:has(> .sl-kapa-history__rename) { + /* Inset rename/confirm chrome so the field stays inside the card radius. */ + padding: 0.5rem 0.625rem; + box-sizing: border-box; +} + +/* Full-row hit target stacked under rename/delete in the same grid cell. + Only direct children of the row — rename/confirm keep their own layout. */ +.sl-kapa-history__row > .sl-kapa-history__open { + grid-column: 1; + grid-row: 1; + z-index: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + width: 100%; + min-width: 0; + min-height: 3rem; + /* Leave room on the right so title does not run under action buttons. */ + padding: 0.5rem 4.25rem 0.5rem 0.625rem; + border: 0; + border-radius: inherit; + background: transparent; + color: inherit; + font: inherit; + font-size: var(--sl-text-sm); + line-height: 1.4; + text-align: left; + cursor: pointer; +} + +.sl-kapa-history__open-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sl-kapa-history__active { + flex: none; + color: color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-white) 35%); + font-size: var(--sl-text-2xs); + font-weight: 600; +} + +.sl-kapa-history__row-actions { + display: inline-flex; + align-items: center; + gap: 0.25rem; + flex: none; +} + +.sl-kapa-history__row > .sl-kapa-history__row-actions { + grid-column: 1; + grid-row: 1; + justify-self: end; + align-self: center; + position: relative; + z-index: 1; + margin-right: 0.375rem; +} + +.sl-kapa-history__icon-button { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: 1px solid transparent; + border-radius: var(--sl-radius-sm); + background: transparent; + color: inherit; + cursor: pointer; +} + +.sl-kapa-history__icon-button svg { + width: 0.95rem; + height: 0.95rem; +} + +.sl-kapa-history__icon-button:hover { + border-color: var(--warp-control-border-hover); + background: var(--warp-control-bg-hover); +} + +.sl-kapa-history__icon-button[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + /* Prefer above the control so scroll-panel bottoms clip less often. */ + bottom: calc(100% + 0.35rem); + top: auto; + right: 0; + padding: 0.3rem 0.45rem; + border: 1px solid var(--warp-control-border-hover); + border-radius: var(--sl-radius-sm); + background: var(--warp-control-bg); + color: var(--warp-control-text-hover); + font-family: var(--__sl-font, 'Inter', sans-serif); + font-size: var(--sl-text-2xs); + font-weight: 400; + line-height: 1.2; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transform: translateY(2px); + transition: + opacity 0.15s ease, + transform 0.15s ease; + transition-delay: 0.35s; + z-index: 20; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} + +.sl-kapa-history__icon-button[data-tooltip]:hover::after, +.sl-kapa-history__icon-button[data-tooltip]:focus-visible::after { + opacity: 1; + transform: translateY(0); +} + +.sl-kapa-history__rename { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.5rem; + align-items: center; + width: 100%; + min-width: 0; + max-width: 100%; + box-sizing: border-box; +} + +.sl-kapa-history__rename input { + box-sizing: border-box; + min-width: 0; + max-width: 100%; + width: 100%; + height: 2rem; + padding: 0 0.5rem; + border: 1px solid var(--sl-color-gray-5); + border-radius: var(--sl-radius-sm); + background: var(--sl-color-black); + color: var(--sl-color-white); + font: inherit; + font-size: var(--sl-text-sm); +} + +.sl-kapa-history__rename-error { + grid-column: 1 / -1; + margin: 0; + color: #fecaca; + font-size: var(--sl-text-2xs); +} + +.sl-kapa-history__confirm { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + width: 100%; +} + +.sl-kapa-history__confirm-copy { + margin: 0; + font-size: var(--sl-text-sm); + font-weight: 600; +} + +.sl-kapa-history__text-button { + min-height: 1.75rem; + padding: 0.25rem 0.5rem; + border: 1px solid var(--sl-color-gray-5); + border-radius: var(--sl-radius-sm); + background: transparent; + color: inherit; + font: inherit; + font-size: var(--sl-text-xs); + font-weight: 600; + cursor: pointer; +} + +.sl-kapa-history__text-button:hover { + border-color: var(--warp-control-border-hover); + background: var(--warp-control-bg-hover); +} + +.sl-kapa-history__text-button--danger { + border-color: color-mix(in srgb, #ef4444, var(--sl-color-gray-5) 35%); + color: #fecaca; +} + +:root[data-theme='light'] .sl-kapa-history__row[data-active='true'] { + border-color: color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-gray-5) 45%); + background: color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-black) 88%); + color: var(--sl-color-gray-1); +} + +/* Title lives inside a +
+ + +
+ {isHistoryOpen ? ( +
+
+
+

{conversationHistory.title}

+

{conversationHistory.privacy}

+
+
+ {historyStatusMessage ? ( +

+ {historyStatusMessage} +

+ ) : null} + {conversationHistory.isEmpty ? ( +

{conversationHistory.empty}

+ ) : ( +
    + {conversationHistory.items.map((savedConversation) => { + const isRenaming = renamingThreadId === savedConversation.threadId; + const isPendingDelete = pendingDeleteThreadId === savedConversation.threadId; + return ( +
  • +
    + {isRenaming ? ( +
    { + event.preventDefault(); + commitRename(savedConversation.threadId); + }} + > + + { + setRenameDraft(event.target.value); + setRenameError(null); + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + cancelRename(); + } + }} + aria-invalid={renameError ? true : undefined} + aria-describedby={ + renameError + ? `sl-kapa-history-rename-error-${savedConversation.threadId}` + : undefined + } + /> +
    + + +
    + {renameError ? ( +

    + {renameError} +

    + ) : null} +
    + ) : isPendingDelete ? ( +
    { + if (event.key === 'Escape') { + event.preventDefault(); + cancelDelete(); + } + }} + > +

    + {conversationHistory.confirmDelete} +

    +
    + + +
    +
    + ) : ( + <> + + {/* z-index above full-row open control; stopPropagation keeps restore from firing. */} +
    + + +
    + + )} +
    +
  • + ); + })} +
+ )} +
+ ) : ( + <> {!hasConversation && (

Ask a question

@@ -812,7 +1338,7 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio
)} - {conversation.map((qa) => ( + {conversation.map((qa, responseIndex) => (
{qa.question}
@@ -894,22 +1420,24 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio
) : null} - {shouldShowSupportForAnswer(qa) ? ( -

- Need more help?{' '} - - View support options - - . -

- ) : null} + {shouldShowSupportForAnswer(qa, responseIndex) ? ( +

+ Need more help?{' '} + + View support options + + . +

+ ) : null}
))} {error ?
{getChatErrorMessage(error)}
: null} + + )}
- : null} @@ -974,13 +1502,16 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio export default function KapaChatLauncher({ autoOpen = false }: { autoOpen?: boolean } = {}) { const [chatSessionKey, setChatSessionKey] = useState(0); const [sessionAutoOpen, setSessionAutoOpen] = useState(autoOpen); + const [sessionOpenHistory, setSessionOpenHistory] = useState(false); if (!integrationId) { return null; } - const startNewConversation = () => { - // `autoOpen` is read once by each keyed `ChatSurface` remount so the - // fresh conversation opens immediately without controlling later renders. + const startNewConversation = (options?: NewConversationOptions) => { + // `autoOpen` / `initialHistoryOpen` are read once by each keyed + // `ChatSurface` remount so the fresh session opens immediately without + // controlling later renders. setSessionAutoOpen(true); + setSessionOpenHistory(options?.openHistory === true); setChatSessionKey((key) => key + 1); }; @@ -988,6 +1519,7 @@ export default function KapaChatLauncher({ autoOpen = false }: { autoOpen?: bool diff --git a/src/content/docs/factories/self-hosting/data-storage.mdx b/src/content/docs/factories/self-hosting/data-storage.mdx index 151878a52..871104c27 100644 --- a/src/content/docs/factories/self-hosting/data-storage.mdx +++ b/src/content/docs/factories/self-hosting/data-storage.mdx @@ -36,10 +36,13 @@ Data is not migrated when changing storage mappings. It stays in its original lo Mapping a category to your bucket changes where that category's objects are written and read. Everything else about a run stays the same. -Warp's control plane accesses self-hosted storage through provider-specific permissions that you grant and can revoke at any time. -The control plane writes to your storage over the course of a run, such as by updating the conversation transcript after -each turn. It also reads from the bucket to provide run data, including conversation history and the display of computer use screenshots. Warp periodically -accesses persisted data for background maintenance such as to run [scorers](/factories/measure-and-improve/scorers/). +Warp's control plane accesses self-hosted storage through provider-specific +permissions that you grant and can revoke at any time. The control plane writes +to your storage over the course of a run, such as by updating the conversation +transcript after each turn. It also reads from the bucket to provide run data, +including conversation history and the display of computer use screenshots. +Warp periodically accesses persisted data for background maintenance such as +to run [scorers](/factories/measure-and-improve/scorers/). Warp never caches bucket contents. It uses presigned URLs wherever possible so that data flows directly from your bucket to the client, without transiting Warp's backend. diff --git a/src/lib/kapa-conversation-history.mjs b/src/lib/kapa-conversation-history.mjs new file mode 100644 index 000000000..b3c402786 --- /dev/null +++ b/src/lib/kapa-conversation-history.mjs @@ -0,0 +1,493 @@ +export const KAPA_CONVERSATION_HISTORY_STORAGE_KEY = 'warp_docs_kapa_conversation_history'; +export const KAPA_CONVERSATION_TITLE_OVERRIDES_KEY = 'warp_docs_kapa_conversation_titles'; +/** Last active thread id for reload continuity (separate from the history store). */ +export const KAPA_ACTIVE_THREAD_STORAGE_KEY = 'warp_docs_kapa_thread_id'; + +export const KAPA_CONVERSATION_HISTORY_OPTIONS = Object.freeze({ + enabled: true, + maxThreads: 50, + storageKey: KAPA_CONVERSATION_HISTORY_STORAGE_KEY, +}); + +export const KAPA_CONVERSATION_HISTORY_COPY = Object.freeze({ + title: 'Conversation history', + empty: 'No saved conversations yet.', + privacy: 'Saved only in this browser. Renaming or deleting updates this device only.', + untitled: 'Untitled conversation', + rename: 'Rename conversation', + delete: 'Delete conversation', + confirmDelete: 'Are you sure?', + confirmDeleteAction: 'Delete', + cancelDeleteAction: 'Cancel', + renameSave: 'Save name', + renameCancel: 'Cancel rename', + current: 'Current conversation', + missingThread: 'That conversation is no longer saved on this device.', +}); + +/** Lightened active-row tokens for static/contrast contracts. */ +export const KAPA_HISTORY_ACTIVE_ROW_STYLE = Object.freeze({ + darkBackground: 'color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-black) 78%)', + darkText: 'var(--sl-color-white)', + lightBackground: 'color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-black) 88%)', + lightText: 'var(--sl-color-gray-1)', + /** Element-specific light title paint; row `color` alone is not enough on buttons. */ + lightTitleSelector: + ":root[data-theme='light'] .sl-kapa-history__row[data-active='true'] .sl-kapa-history__open-title", + lightTitleText: 'var(--sl-color-gray-1)', + lightRenameSelector: ":root[data-theme='light'] .sl-kapa-history__rename input", + lightRenameBackground: 'var(--sl-color-black)', + lightRenameText: 'var(--sl-color-gray-1)', +}); + +function safeParseJson(raw) { + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +function readStorage(key) { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +function writeStorage(key, value) { + try { + localStorage.setItem(key, value); + return true; + } catch { + return false; + } +} + +function removeStorage(key) { + try { + localStorage.removeItem(key); + } catch { + // ignore quota / private mode failures + } +} + +function objectItems(value) { + return Array.isArray(value) + ? value.filter((item) => item !== null && typeof item === 'object') + : []; +} + +/** Mirrors Chat SDK QA normalization so unrestorable rows are never listed. */ +export function normalizeStoredQA(value) { + if (!value || typeof value !== 'object') return null; + const raw = value; + if (typeof raw.id !== 'string' || raw.id.length === 0) return null; + const rawThinking = raw.thinking; + const thinking = + rawThinking && typeof rawThinking.startedAt === 'number' + ? { + startedAt: rawThinking.startedAt, + completedAt: + typeof rawThinking.completedAt === 'number' + ? rawThinking.completedAt + : rawThinking.startedAt, + } + : undefined; + return { + id: raw.id, + question: typeof raw.question === 'string' ? raw.question : '', + answer: typeof raw.answer === 'string' ? raw.answer : '', + sources: objectItems(raw.sources), + files: objectItems(raw.files), + toolCalls: objectItems(raw.toolCalls), + reaction: raw.reaction === 'upvote' || raw.reaction === 'downvote' ? raw.reaction : null, + isGenerationAborted: raw.isGenerationAborted === true, + isFeedbackSubmissionEnabled: true, + status: 'completed', + metadata: { + is_uncertain: + typeof raw.metadata === 'object' && + raw.metadata !== null && + raw.metadata.is_uncertain === true, + }, + thinking, + }; +} + +/** Mirrors Chat SDK thread normalization requirements. */ +export function normalizeStoredThread(value) { + if (!value || typeof value !== 'object') return null; + const thread = value; + if ( + typeof thread.threadId !== 'string' || + thread.threadId.length === 0 || + typeof thread.title !== 'string' || + typeof thread.createdAt !== 'number' || + typeof thread.updatedAt !== 'number' || + !Array.isArray(thread.qas) + ) { + return null; + } + const qas = thread.qas.map(normalizeStoredQA).filter((qa) => qa !== null); + if (qas.length === 0) return null; + return { + threadId: thread.threadId, + title: thread.title, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + qas, + }; +} + +export function hasOwnedHistoryStore(storageKey = KAPA_CONVERSATION_HISTORY_STORAGE_KEY) { + return readStorage(storageKey) !== null; +} + +export function readStoredHistoryThreads(storageKey = KAPA_CONVERSATION_HISTORY_STORAGE_KEY) { + const parsed = safeParseJson(readStorage(storageKey)); + if (!parsed || !Array.isArray(parsed.threads)) return []; + return parsed.threads + .map(normalizeStoredThread) + .filter((thread) => thread !== null) + .sort((a, b) => b.updatedAt - a.updatedAt); +} + +export function writeStoredHistoryThreads( + threads, + storageKey = KAPA_CONVERSATION_HISTORY_STORAGE_KEY +) { + const normalized = threads.map(normalizeStoredThread).filter((thread) => thread !== null); + const payload = JSON.stringify({ + version: 1, + threads: normalized, + }); + return writeStorage(storageKey, payload); +} + +export function getRestorableThread( + threadId, + storageKey = KAPA_CONVERSATION_HISTORY_STORAGE_KEY +) { + if (typeof threadId !== 'string' || threadId.length === 0) return null; + return readStoredHistoryThreads(storageKey).find((thread) => thread.threadId === threadId) || null; +} + +export function canRestoreThread(threadId, storageKey = KAPA_CONVERSATION_HISTORY_STORAGE_KEY) { + return getRestorableThread(threadId, storageKey) !== null; +} + +export function readTitleOverrides(key = KAPA_CONVERSATION_TITLE_OVERRIDES_KEY) { + const parsed = safeParseJson(readStorage(key)); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const overrides = {}; + for (const [threadId, title] of Object.entries(parsed)) { + if (typeof title === 'string' && title.trim()) { + overrides[threadId] = title.trim(); + } + } + return overrides; +} + +export function writeTitleOverrides(overrides, key = KAPA_CONVERSATION_TITLE_OVERRIDES_KEY) { + const cleaned = {}; + for (const [threadId, title] of Object.entries(overrides)) { + if (typeof title === 'string' && title.trim()) { + cleaned[threadId] = title.trim(); + } + } + if (Object.keys(cleaned).length === 0) { + removeStorage(key); + return true; + } + return writeStorage(key, JSON.stringify(cleaned)); +} + +export function pruneTitleOverrides( + threads, + overridesKey = KAPA_CONVERSATION_TITLE_OVERRIDES_KEY +) { + const liveIds = new Set( + (Array.isArray(threads) ? threads : []).map((thread) => thread.threadId).filter(Boolean) + ); + const current = readTitleOverrides(overridesKey); + const next = {}; + for (const [threadId, title] of Object.entries(current)) { + if (liveIds.has(threadId)) next[threadId] = title; + } + writeTitleOverrides(next, overridesKey); + return next; +} + +export function validateRenameTitle(value) { + const trimmed = typeof value === 'string' ? value.trim() : ''; + if (!trimmed) { + return { ok: false, title: '', error: 'Name cannot be empty.' }; + } + return { ok: true, title: trimmed, error: null }; +} + +/** + * Rename is stored in an overlay key because the Chat SDK re-derives `title` + * from the first question on every completed answer and would overwrite edits. + */ +export function renameStoredConversation( + threadId, + nextTitle, + { + storageKey = KAPA_CONVERSATION_HISTORY_STORAGE_KEY, + overridesKey = KAPA_CONVERSATION_TITLE_OVERRIDES_KEY, + } = {} +) { + const validation = validateRenameTitle(nextTitle); + if (!validation.ok) { + return { ok: false, error: validation.error, threads: readStoredHistoryThreads(storageKey) }; + } + const threads = readStoredHistoryThreads(storageKey); + if (!threads.some((thread) => thread.threadId === threadId)) { + return { ok: false, error: 'Conversation not found.', threads }; + } + const overrides = readTitleOverrides(overridesKey); + overrides[threadId] = validation.title; + writeTitleOverrides(overrides, overridesKey); + return { ok: true, error: null, title: validation.title, threads }; +} + +/** True local deletion from the browser history store (not a cosmetic hide). */ +export function deleteStoredConversation( + threadId, + { + storageKey = KAPA_CONVERSATION_HISTORY_STORAGE_KEY, + overridesKey = KAPA_CONVERSATION_TITLE_OVERRIDES_KEY, + } = {} +) { + const existing = readStoredHistoryThreads(storageKey); + const threads = existing.filter((thread) => thread.threadId !== threadId); + const removed = threads.length !== existing.length; + if (removed) { + // Always write (including empty) so stale SDK in-memory threads cannot + // reappear before the next provider remount or completed answer. + writeStoredHistoryThreads(threads, storageKey); + } + pruneTitleOverrides(threads, overridesKey); + return { ok: removed, threads, deletedActiveCandidate: threadId }; +} + +export function clearStoredConversationHistory({ + storageKey = KAPA_CONVERSATION_HISTORY_STORAGE_KEY, + overridesKey = KAPA_CONVERSATION_TITLE_OVERRIDES_KEY, +} = {}) { + writeStoredHistoryThreads([], storageKey); + removeStorage(overridesKey); +} + +/** + * Snapshot used by the UI and by storage-event handlers. + * Disk is authoritative whenever the owned history key exists. + */ +export function readHistoryUiSnapshot({ + storageKey = KAPA_CONVERSATION_HISTORY_STORAGE_KEY, + overridesKey = KAPA_CONVERSATION_TITLE_OVERRIDES_KEY, + sdkThreads = [], + activeThreadId = null, +} = {}) { + const owned = hasOwnedHistoryStore(storageKey); + const threads = owned ? readStoredHistoryThreads(storageKey) : sdkThreads.map(normalizeStoredThread).filter(Boolean); + const titleOverrides = pruneTitleOverrides(threads, overridesKey); + const activeStillPresent = + typeof activeThreadId === 'string' && + activeThreadId.length > 0 && + threads.some((thread) => thread.threadId === activeThreadId); + return { + owned, + threads, + titleOverrides, + activeThreadMissing: Boolean(activeThreadId) && owned && !activeStillPresent, + view: getConversationHistoryView(threads, activeThreadId, titleOverrides), + }; +} + +export function isOwnedHistoryStorageEvent(event, { + storageKey = KAPA_CONVERSATION_HISTORY_STORAGE_KEY, + overridesKey = KAPA_CONVERSATION_TITLE_OVERRIDES_KEY, +} = {}) { + if (!event) return false; + // A null key means clear() wiped the whole origin storage. + if (event.key === null) return true; + return event.key === storageKey || event.key === overridesKey; +} + +/** + * Pure decision helper for restore attempts. + * Never allow native restore unless a fully normalized disk thread exists. + */ +export function resolveRestoreAttempt(threadId, storageKey = KAPA_CONVERSATION_HISTORY_STORAGE_KEY) { + const thread = getRestorableThread(threadId, storageKey); + if (!thread) { + return { + ok: false, + reason: 'missing-or-invalid', + thread: null, + message: KAPA_CONVERSATION_HISTORY_COPY.missingThread, + }; + } + return { ok: true, reason: null, thread, message: null }; +} + +/** + * After local deletion, SDK memory may still hold the thread. Callers must not + * re-persist deleted IDs; this helper models the guard used before any upsert path. + */ +export function shouldPersistThreadId(threadId, storageKey = KAPA_CONVERSATION_HISTORY_STORAGE_KEY) { + if (!hasOwnedHistoryStore(storageKey)) return true; + return canRestoreThread(threadId, storageKey); +} + +export function readActiveThreadId(key = KAPA_ACTIVE_THREAD_STORAGE_KEY) { + const value = readStorage(key); + return typeof value === 'string' && value.length > 0 ? value : null; +} + +/** + * Persist the live thread id only when the owned history store still allows it. + * Deleted IDs are cleared instead of rewritten so reload cannot resurrect them. + */ +export function writeActiveThreadId( + threadId, + { + activeKey = KAPA_ACTIVE_THREAD_STORAGE_KEY, + storageKey = KAPA_CONVERSATION_HISTORY_STORAGE_KEY, + } = {} +) { + if (typeof threadId !== 'string' || threadId.length === 0) { + removeStorage(activeKey); + return false; + } + if (!shouldPersistThreadId(threadId, storageKey)) { + removeStorage(activeKey); + return false; + } + return writeStorage(activeKey, threadId); +} + +export function clearActiveThreadId(key = KAPA_ACTIVE_THREAD_STORAGE_KEY) { + removeStorage(key); +} + +/** + * Pure mount-time restore decision. + * Handles missing/stale keys, already-active sessions (StrictMode re-entry), + * and the shouldPersistThreadId invariant before any SDK restore call. + * + * @param {string | null | undefined} savedThreadId + * @param {{ + * currentThreadId?: string | null, + * storageKey?: string + * }} [options] + * @returns {{ + * action: 'none' | 'already-active' | 'clear-stale' | 'restore', + * threadId: string | null, + * message: string | null, + * reason: string | null + * }} + */ +export function resolveMountRestore( + savedThreadId, + { + currentThreadId = null, + storageKey = KAPA_CONVERSATION_HISTORY_STORAGE_KEY, + } = {} +) { + if (typeof savedThreadId !== 'string' || savedThreadId.length === 0) { + return { action: 'none', threadId: null, message: null, reason: 'no-saved-id' }; + } + if (currentThreadId === savedThreadId) { + return { + action: 'already-active', + threadId: savedThreadId, + message: null, + reason: 'already-restored', + }; + } + const attempt = resolveRestoreAttempt(savedThreadId, storageKey); + if (!attempt.ok) { + return { + action: 'clear-stale', + threadId: savedThreadId, + message: attempt.message, + reason: attempt.reason, + }; + } + if (!shouldPersistThreadId(savedThreadId, storageKey)) { + return { + action: 'clear-stale', + threadId: savedThreadId, + message: KAPA_CONVERSATION_HISTORY_COPY.missingThread, + reason: 'deleted-owned-id', + }; + } + return { + action: 'restore', + threadId: savedThreadId, + message: null, + reason: null, + }; +} + +/** + * Decide whether deleting a thread requires a keyed ChatSurface remount and + * whether that remount should land in history (remaining threads) or a fresh chat. + * + * @param {{ deletedThreadId?: string | null, activeThreadId?: string | null, remainingThreadCount?: number }} [input] + * @returns {{ remount: boolean, openHistory: boolean, reason: string }} + */ +export function resolvePostDeleteNavigation({ + deletedThreadId, + activeThreadId = null, + remainingThreadCount = 0, +} = {}) { + const deletedActive = + typeof deletedThreadId === 'string' && + deletedThreadId.length > 0 && + deletedThreadId === activeThreadId; + if (!deletedActive) { + return { + remount: false, + openHistory: false, + reason: 'inactive-delete', + }; + } + if (remainingThreadCount > 0) { + return { + remount: true, + openHistory: true, + reason: 'active-delete-with-remaining', + }; + } + return { + remount: true, + openHistory: false, + reason: 'active-delete-empty', + }; +} + +export function getConversationHistoryView(threads, activeThreadId, titleOverrides = {}) { + const items = threads.map((thread) => { + const override = titleOverrides[thread.threadId]; + const generatedTitle = typeof thread.title === 'string' ? thread.title.trim() : ''; + return { + threadId: thread.threadId, + title: override || generatedTitle || KAPA_CONVERSATION_HISTORY_COPY.untitled, + generatedTitle: generatedTitle || KAPA_CONVERSATION_HISTORY_COPY.untitled, + isActive: thread.threadId === activeThreadId, + }; + }); + + return { + ...KAPA_CONVERSATION_HISTORY_COPY, + isEmpty: items.length === 0, + items, + }; +} diff --git a/src/lib/kapa-conversation-history.test.mjs b/src/lib/kapa-conversation-history.test.mjs new file mode 100644 index 000000000..2b346a9fc --- /dev/null +++ b/src/lib/kapa-conversation-history.test.mjs @@ -0,0 +1,507 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { + KAPA_ACTIVE_THREAD_STORAGE_KEY, + KAPA_CONVERSATION_HISTORY_COPY, + KAPA_CONVERSATION_HISTORY_OPTIONS, + KAPA_CONVERSATION_HISTORY_STORAGE_KEY, + KAPA_CONVERSATION_TITLE_OVERRIDES_KEY, + KAPA_HISTORY_ACTIVE_ROW_STYLE, + canRestoreThread, + clearActiveThreadId, + clearStoredConversationHistory, + deleteStoredConversation, + getConversationHistoryView, + getRestorableThread, + hasOwnedHistoryStore, + isOwnedHistoryStorageEvent, + normalizeStoredThread, + pruneTitleOverrides, + readActiveThreadId, + readHistoryUiSnapshot, + readStoredHistoryThreads, + readTitleOverrides, + renameStoredConversation, + resolveMountRestore, + resolvePostDeleteNavigation, + resolveRestoreAttempt, + shouldPersistThreadId, + validateRenameTitle, + writeActiveThreadId, + writeStoredHistoryThreads, + writeTitleOverrides, +} from './kapa-conversation-history.mjs'; + +const memory = new Map(); + +function installLocalStorageMock() { + memory.clear(); + globalThis.localStorage = { + getItem(key) { + return memory.has(key) ? memory.get(key) : null; + }, + setItem(key, value) { + memory.set(key, String(value)); + }, + removeItem(key) { + memory.delete(key); + }, + }; +} + +function validThread(threadId, title, updatedAt = 1) { + return { + threadId, + title, + createdAt: 1, + updatedAt, + qas: [ + { + id: `${threadId}-qa`, + question: title, + answer: 'Answer', + sources: [], + files: [], + toolCalls: [], + reaction: null, + isGenerationAborted: false, + }, + ], + }; +} + +function seedThreads(threads) { + writeStoredHistoryThreads(threads); +} + +test('uses native SDK threads in recency order and exposes their restore IDs', () => { + const view = getConversationHistoryView( + [ + validThread('most-recent', 'Latest question', 20), + validThread('older', 'Earlier question', 10), + ], + 'older', + {} + ); + + assert.equal(view.isEmpty, false); + assert.equal('canClear' in view, false); + assert.deepEqual( + view.items.map((item) => item.threadId), + ['most-recent', 'older'] + ); + assert.equal(view.items[1].isActive, true); +}); + +test('renders the empty state and local-storage disclosure without creating a duplicate store', () => { + const view = getConversationHistoryView([], null, {}); + + assert.equal(view.isEmpty, true); + assert.equal('canClear' in view, false); + assert.equal('clear' in KAPA_CONVERSATION_HISTORY_COPY, false); + assert.deepEqual(view.items, []); + assert.match(view.privacy, /this browser/i); + assert.doesNotMatch(view.privacy, /clearing/i); + assert.deepEqual(KAPA_CONVERSATION_HISTORY_OPTIONS, { + enabled: true, + maxThreads: 50, + storageKey: KAPA_CONVERSATION_HISTORY_STORAGE_KEY, + }); + assert.equal(KAPA_CONVERSATION_HISTORY_COPY.confirmDelete, 'Are you sure?'); +}); + +test('validateRenameTitle rejects empty names and trims valid ones', () => { + assert.deepEqual(validateRenameTitle(' '), { + ok: false, + title: '', + error: 'Name cannot be empty.', + }); + assert.deepEqual(validateRenameTitle(' Agents '), { + ok: true, + title: 'Agents', + error: null, + }); +}); + +test('rename persists an overlay title without dropping the stored thread', () => { + installLocalStorageMock(); + seedThreads([validThread('t1', 'Generated from first question', 2)]); + + const result = renameStoredConversation('t1', ' My custom name '); + assert.equal(result.ok, true); + assert.equal(result.title, 'My custom name'); + assert.equal(readTitleOverrides().t1, 'My custom name'); + assert.equal(readStoredHistoryThreads()[0].title, 'Generated from first question'); + + const view = getConversationHistoryView(readStoredHistoryThreads(), 't1', readTitleOverrides()); + assert.equal(view.items[0].title, 'My custom name'); + assert.equal(view.items[0].generatedTitle, 'Generated from first question'); +}); + +test('rename validation failure leaves storage untouched', () => { + installLocalStorageMock(); + seedThreads([validThread('t1', 'Keep me')]); + writeTitleOverrides({ t1: 'Existing' }); + + const result = renameStoredConversation('t1', ' '); + assert.equal(result.ok, false); + assert.equal(readTitleOverrides().t1, 'Existing'); +}); + +test('delete removes the thread from browser storage and cleans title overrides', () => { + installLocalStorageMock(); + seedThreads([validThread('keep', 'Keep', 3), validThread('drop', 'Drop', 2)]); + writeTitleOverrides({ drop: 'Renamed drop', keep: 'Renamed keep' }); + + const result = deleteStoredConversation('drop'); + assert.equal(result.ok, true); + assert.deepEqual( + readStoredHistoryThreads().map((thread) => thread.threadId), + ['keep'] + ); + assert.deepEqual(readTitleOverrides(), { keep: 'Renamed keep' }); +}); + +test('deleting the last conversation writes an empty owned history store', () => { + installLocalStorageMock(); + seedThreads([validThread('only', 'Only')]); + writeTitleOverrides({ only: 'Solo' }); + + const result = deleteStoredConversation('only'); + assert.equal(result.ok, true); + assert.deepEqual(result.threads, []); + assert.deepEqual(readStoredHistoryThreads(), []); + assert.equal(hasOwnedHistoryStore(), true); + assert.equal(memory.get(KAPA_CONVERSATION_TITLE_OVERRIDES_KEY), undefined); +}); + +test('clear removes history threads and rename metadata together', () => { + installLocalStorageMock(); + seedThreads([validThread('t1', 'T')]); + writeTitleOverrides({ t1: 'Custom' }); + + clearStoredConversationHistory(); + assert.deepEqual(readStoredHistoryThreads(), []); + assert.equal(memory.get(KAPA_CONVERSATION_TITLE_OVERRIDES_KEY), undefined); +}); + +test('stale row restore is blocked unless a fully normalized disk thread exists', () => { + installLocalStorageMock(); + seedThreads([validThread('live', 'Live')]); + + assert.equal(canRestoreThread('missing'), false); + assert.equal(resolveRestoreAttempt('missing').ok, false); + assert.equal(resolveRestoreAttempt('live').ok, true); + assert.equal(getRestorableThread('live')?.threadId, 'live'); +}); + +test('cross-tab delete of inactive thread keeps active thread and prunes UI snapshot', () => { + installLocalStorageMock(); + seedThreads([validThread('active', 'Active', 5), validThread('other', 'Other', 4)]); + writeTitleOverrides({ active: 'A', other: 'O' }); + + deleteStoredConversation('other'); + const snapshot = readHistoryUiSnapshot({ activeThreadId: 'active' }); + assert.equal(snapshot.activeThreadMissing, false); + assert.deepEqual( + snapshot.threads.map((thread) => thread.threadId), + ['active'] + ); + assert.deepEqual(snapshot.titleOverrides, { active: 'A' }); +}); + +test('cross-tab delete of active thread marks active missing so UI can remount fresh', () => { + installLocalStorageMock(); + seedThreads([validThread('active', 'Active', 5), validThread('other', 'Other', 4)]); + + deleteStoredConversation('active'); + const snapshot = readHistoryUiSnapshot({ activeThreadId: 'active' }); + assert.equal(snapshot.activeThreadMissing, true); + assert.deepEqual( + snapshot.threads.map((thread) => thread.threadId), + ['other'] + ); +}); + +test('deleted thread cannot be re-persisted once owned store exists', () => { + installLocalStorageMock(); + seedThreads([validThread('keep', 'Keep'), validThread('drop', 'Drop')]); + deleteStoredConversation('drop'); + + assert.equal(shouldPersistThreadId('drop'), false); + assert.equal(shouldPersistThreadId('keep'), true); + assert.equal(resolveRestoreAttempt('drop').ok, false); + // Production write path must honor the same guard. + assert.equal(writeActiveThreadId('drop'), false); + assert.equal(readActiveThreadId(), null); + assert.equal(writeActiveThreadId('keep'), true); + assert.equal(readActiveThreadId(), 'keep'); +}); + +test('active thread id helpers clear on fresh start and reject empty values', () => { + installLocalStorageMock(); + seedThreads([validThread('live', 'Live')]); + assert.equal(writeActiveThreadId('live'), true); + assert.equal(readActiveThreadId(), 'live'); + assert.equal(memory.get(KAPA_ACTIVE_THREAD_STORAGE_KEY), 'live'); + + clearActiveThreadId(); + assert.equal(readActiveThreadId(), null); + assert.equal(memory.get(KAPA_ACTIVE_THREAD_STORAGE_KEY), undefined); + + assert.equal(writeActiveThreadId(''), false); + assert.equal(writeActiveThreadId(null), false); + assert.equal(readActiveThreadId(), null); +}); + +test('mount restore decides restore, already-active, and clear-stale paths', () => { + installLocalStorageMock(); + seedThreads([validThread('live', 'Live')]); + + assert.deepEqual(resolveMountRestore(null), { + action: 'none', + threadId: null, + message: null, + reason: 'no-saved-id', + }); + assert.deepEqual(resolveMountRestore('live', { currentThreadId: 'live' }), { + action: 'already-active', + threadId: 'live', + message: null, + reason: 'already-restored', + }); + assert.deepEqual(resolveMountRestore('live', { currentThreadId: null }), { + action: 'restore', + threadId: 'live', + message: null, + reason: null, + }); + + assert.equal(resolveMountRestore('missing').action, 'clear-stale'); + assert.equal( + resolveMountRestore('missing').message, + KAPA_CONVERSATION_HISTORY_COPY.missingThread + ); + + deleteStoredConversation('live'); + writeActiveThreadId('live'); // rejected by shouldPersistThreadId, but simulate stale key + memory.set(KAPA_ACTIVE_THREAD_STORAGE_KEY, 'live'); + const deleted = resolveMountRestore('live'); + assert.equal(deleted.action, 'clear-stale'); + assert.equal(deleted.reason, 'missing-or-invalid'); +}); + +test('storage-event helper recognizes history and title-overlay keys', () => { + assert.equal( + isOwnedHistoryStorageEvent({ key: KAPA_CONVERSATION_HISTORY_STORAGE_KEY }), + true + ); + assert.equal( + isOwnedHistoryStorageEvent({ key: KAPA_CONVERSATION_TITLE_OVERRIDES_KEY }), + true + ); + assert.equal(isOwnedHistoryStorageEvent({ key: null }), true); + assert.equal(isOwnedHistoryStorageEvent({ key: 'unrelated' }), false); +}); + +test('malformed threads and empty QA payloads are rejected by normalization', () => { + assert.equal(normalizeStoredThread({ threadId: 'x' }), null); + assert.equal( + normalizeStoredThread({ + threadId: 'x', + title: 't', + createdAt: 1, + updatedAt: 1, + qas: [{ question: 'no id' }], + }), + null + ); + installLocalStorageMock(); + writeStoredHistoryThreads([ + { + threadId: 'bad', + title: 'Bad', + createdAt: 1, + updatedAt: 1, + qas: [{ question: 'missing id' }], + }, + validThread('good', 'Good'), + ]); + assert.deepEqual( + readStoredHistoryThreads().map((thread) => thread.threadId), + ['good'] + ); +}); + +test('confirmation copy contracts cover Escape cancel and announce labels', () => { + assert.equal(KAPA_CONVERSATION_HISTORY_COPY.confirmDelete, 'Are you sure?'); + assert.equal(KAPA_CONVERSATION_HISTORY_COPY.cancelDeleteAction, 'Cancel'); + assert.equal(KAPA_CONVERSATION_HISTORY_COPY.confirmDeleteAction, 'Delete'); + assert.equal(KAPA_CONVERSATION_HISTORY_COPY.missingThread, 'That conversation is no longer saved on this device.'); +}); + +test('overlay pruning drops titles for threads removed by delete eviction or clear', () => { + installLocalStorageMock(); + seedThreads([validThread('keep', 'Keep'), validThread('drop', 'Drop')]); + writeTitleOverrides({ keep: 'K', drop: 'D', orphan: 'O' }); + + const afterDelete = pruneTitleOverrides(readStoredHistoryThreads()); + assert.deepEqual(afterDelete, { keep: 'K', drop: 'D' }); + + deleteStoredConversation('drop'); + assert.deepEqual(readTitleOverrides(), { keep: 'K' }); + + clearStoredConversationHistory(); + assert.deepEqual(readTitleOverrides(), {}); +}); + +test('post-delete navigation remounts active deletes and reopens history when threads remain', () => { + assert.deepEqual( + resolvePostDeleteNavigation({ + deletedThreadId: 'inactive', + activeThreadId: 'active', + remainingThreadCount: 3, + }), + { remount: false, openHistory: false, reason: 'inactive-delete' } + ); + assert.deepEqual( + resolvePostDeleteNavigation({ + deletedThreadId: 'active', + activeThreadId: 'active', + remainingThreadCount: 2, + }), + { remount: true, openHistory: true, reason: 'active-delete-with-remaining' } + ); + assert.deepEqual( + resolvePostDeleteNavigation({ + deletedThreadId: 'active', + activeThreadId: 'active', + remainingThreadCount: 0, + }), + { remount: true, openHistory: false, reason: 'active-delete-empty' } + ); +}); + +test('component wires history remount intent and drops bulk clear UI', () => { + const componentPath = join( + dirname(fileURLToPath(import.meta.url)), + '../components/KapaChatLauncher.tsx' + ); + const source = readFileSync(componentPath, 'utf8'); + assert.match(source, /initialHistoryOpen/); + assert.match(source, /resolvePostDeleteNavigation/); + assert.match(source, /openHistory:\s*navigation\.openHistory/); + assert.match(source, /startFreshConversation/); + assert.doesNotMatch(source, /isNewConversationActionEnabled/); + assert.match( + source, + /onClick=\{startFreshConversation\}\s+aria-label="New conversation"/, + 'new conversation button must not have a disabled condition' + ); + assert.doesNotMatch(source, /clearSavedConversations/); + assert.doesNotMatch(source, /sl-kapa-history__clear/); + assert.doesNotMatch(source, /Clear saved conversations/); + assert.doesNotMatch(source, /canClear/); +}); + +test('component restores saved thread on mount and clears active id on fresh conversation', () => { + const componentPath = join( + dirname(fileURLToPath(import.meta.url)), + '../components/KapaChatLauncher.tsx' + ); + const source = readFileSync(componentPath, 'utf8'); + + assert.match(source, /resolveMountRestore/); + assert.match(source, /readActiveThreadId/); + assert.match(source, /writeActiveThreadId/); + assert.match(source, /clearActiveThreadId/); + assert.match(source, /mountRestoreAttemptedRef/); + assert.match( + source, + /restoreThread\(decision\.threadId/ + ); + assert.match( + source, + /const startFreshConversation = \(\) => \{[\s\S]*?clearActiveThreadId\(\);[\s\S]*?onNewConversation\(\{ openHistory: false \}\);/, + 'fresh conversation must clear the active thread key before remount' + ); + // No leftover empty if (savedThreadId) body or raw localStorage thread-id access. + assert.doesNotMatch(source, /if \(savedThreadId\) \{\s*\}/); + assert.doesNotMatch(source, /localStorage\.(get|set|remove)Item\(['"]warp_docs_kapa_thread_id['"]\)/); + assert.match(source, /LuTrash2/); + assert.doesNotMatch( + source, + /onClick=\{\(\) => requestDelete\([\s\S]*? { + assert.match(KAPA_HISTORY_ACTIVE_ROW_STYLE.darkBackground, /78%/); + assert.equal(KAPA_HISTORY_ACTIVE_ROW_STYLE.darkText, 'var(--sl-color-white)'); + assert.match(KAPA_HISTORY_ACTIVE_ROW_STYLE.lightBackground, /88%/); + assert.equal(KAPA_HISTORY_ACTIVE_ROW_STYLE.lightText, 'var(--sl-color-gray-1)'); + assert.equal( + KAPA_HISTORY_ACTIVE_ROW_STYLE.lightTitleSelector, + ":root[data-theme='light'] .sl-kapa-history__row[data-active='true'] .sl-kapa-history__open-title" + ); + assert.equal(KAPA_HISTORY_ACTIVE_ROW_STYLE.lightTitleText, 'var(--sl-color-gray-1)'); + assert.equal( + KAPA_HISTORY_ACTIVE_ROW_STYLE.lightRenameSelector, + ":root[data-theme='light'] .sl-kapa-history__rename input" + ); + assert.equal(KAPA_HISTORY_ACTIVE_ROW_STYLE.lightRenameBackground, 'var(--sl-color-black)'); + assert.equal(KAPA_HISTORY_ACTIVE_ROW_STYLE.lightRenameText, 'var(--sl-color-gray-1)'); + + const cssPath = join(dirname(fileURLToPath(import.meta.url)), '../components/KapaChatLauncher.css'); + const css = readFileSync(cssPath, 'utf8'); + assert.match( + css, + /:root\[data-theme='light'\]\s*\.sl-kapa-history__row\[data-active='true'\]\s*\.sl-kapa-history__open-title\s*\{[^}]*color:\s*var\(--sl-color-gray-1\)/s + ); + assert.match( + css, + /:root\[data-theme='light'\]\s*\.sl-kapa-history__row\[data-active='true'\]\s*\.sl-kapa-history__open-title\s*\{[^}]*-webkit-text-fill-color:\s*var\(--sl-color-gray-1\)/s + ); + assert.match( + css, + /:root\[data-theme='light'\]\s*\.sl-kapa-history__rename input\s*\{[^}]*background:\s*var\(--sl-color-black\)/s + ); + assert.match( + css, + /:root\[data-theme='light'\]\s*\.sl-kapa-history__rename input\s*\{[^}]*color:\s*var\(--sl-color-gray-1\)/s + ); + assert.match( + css, + /:root\[data-theme='light'\]\s*\.sl-kapa-history__rename input\s*\{[^}]*-webkit-text-fill-color:\s*var\(--sl-color-gray-1\)/s + ); +}); + +test('history row action tooltips are not clipped by row overflow', () => { + const cssPath = join(dirname(fileURLToPath(import.meta.url)), '../components/KapaChatLauncher.css'); + const css = readFileSync(cssPath, 'utf8'); + const rowBlockMatch = css.match(/\.sl-kapa-history__row\s*\{[\s\S]*?\n\}/); + assert.ok(rowBlockMatch, 'expected .sl-kapa-history__row rule'); + const rowBlock = rowBlockMatch[0].replace(/\/\*[\s\S]*?\*\//g, ''); + + // Row must keep tooltips unclipped (no overflow clipping on the card itself). + assert.match(rowBlock, /position:\s*relative/); + assert.doesNotMatch( + rowBlock, + /overflow\s*:\s*hidden/, + 'history row overflow clipping cuts action tooltips outside the card' + ); + // Title ellipsis may still overflow:hidden on the title span only. + assert.match(css, /\.sl-kapa-history__open-title\s*\{[^}]*overflow:\s*hidden/s); + // Tooltip pseudo-elements must exist for both rename and delete icon buttons. + assert.match( + css, + /\.sl-kapa-history__icon-button\[data-tooltip\]::after\s*\{[^}]*content:\s*attr\(data-tooltip\)/s + ); + assert.match( + css, + /\.sl-kapa-history__icon-button\[data-tooltip\]::after\s*\{[^}]*bottom:\s*calc\(100%/s + ); +}); diff --git a/src/lib/kapa-support-escalation.js b/src/lib/kapa-support-escalation.js index ea2cb5f02..bfda89e63 100644 --- a/src/lib/kapa-support-escalation.js +++ b/src/lib/kapa-support-escalation.js @@ -1,20 +1,198 @@ -/** Show the support CTA after this many Q&A turns. */ +/** Show the support CTA on this 1-based response ordinal (the third assistant reply). */ export const SUPPORT_ESCALATION_CONVERSATION_LENGTH_THRESHOLD = 3; /** - * Whether the Kapa chat support CTA should appear for an answer. - * Escalation is limited to long conversations or an explicit thumbs-down. + * Whether the Kapa chat support CTA should appear for a specific answer card. + * + * Rules: + * - Empty answers never show a CTA. + * - A thumbs-down shows the CTA only on that downvoted response. + * - The conversation-length threshold pins a CTA only on response #3 + * (0-based index threshold - 1), not on later responses. * * @param {{ * answer?: string | null; + * responseIndex: number; * conversationLength: number; * isDownvoted: boolean; * }} options * @returns {boolean} */ -export function shouldShowSupportEscalation({ answer, conversationLength, isDownvoted }) { +export function shouldShowSupportEscalation({ + answer, + responseIndex, + conversationLength, + isDownvoted, +}) { if (typeof answer !== 'string' || !answer.trim()) return false; - return ( - conversationLength >= SUPPORT_ESCALATION_CONVERSATION_LENGTH_THRESHOLD || isDownvoted === true - ); + if (isDownvoted === true) return true; + if (typeof responseIndex !== 'number' || responseIndex < 0) return false; + if (conversationLength < SUPPORT_ESCALATION_CONVERSATION_LENGTH_THRESHOLD) return false; + // Threshold CTA stays on response #3 only — never propagates to #4+. + return responseIndex === SUPPORT_ESCALATION_CONVERSATION_LENGTH_THRESHOLD - 1; +} + +/** + * Stable key for a newly revealed support CTA on a response. + * + * @param {{ + * answer?: string | null; + * responseIndex: number; + * conversationLength: number; + * isDownvoted: boolean; + * responseId?: string | null; + * }} options + * @returns {string | null} + */ +export function getSupportCtaRevealKey({ + answer, + responseIndex, + conversationLength, + isDownvoted, + responseId = null, +}) { + if ( + !shouldShowSupportEscalation({ + answer, + responseIndex, + conversationLength, + isDownvoted, + }) + ) { + return null; + } + const idPart = + typeof responseId === 'string' && responseId.length > 0 + ? responseId + : `idx:${responseIndex}`; + if (isDownvoted === true) return `down:${idPart}`; + return `threshold:${idPart}`; +} + +/** + * Whether to auto-scroll so a newly appeared CTA on the last response is visible. + * Older responses and already-revealed keys must not steal scroll. + * + * @param {{ + * isLastResponse: boolean; + * revealKey: string | null; + * lastRevealedKey?: string | null; + * }} options + * @returns {boolean} + */ +export function shouldAutoRevealSupportCta({ + isLastResponse, + revealKey, + lastRevealedKey = null, +}) { + if (!isLastResponse) return false; + if (typeof revealKey !== 'string' || revealKey.length === 0) return false; + if (revealKey === lastRevealedKey) return false; + return true; } + +/** + * Stable key for the completed latest-response layout (answer + controls + optional CTA). + * Changes when the last card finishes, gains a CTA, or is thumbs-downed. + * + * @param {{ + * conversationLength: number; + * responseIndex: number; + * responseId?: string | null; + * answer?: string | null; + * isDownvoted?: boolean; + * }} options + * @returns {string | null} + */ +export function getLatestResponseCompletionKey({ + conversationLength, + responseIndex, + responseId = null, + answer = null, + isDownvoted = false, +}) { + if (typeof conversationLength !== 'number' || conversationLength <= 0) return null; + if (typeof responseIndex !== 'number' || responseIndex !== conversationLength - 1) return null; + if (typeof answer !== 'string' || !answer.trim()) return null; + const idPart = + typeof responseId === 'string' && responseId.length > 0 + ? responseId + : `idx:${responseIndex}`; + const showCta = shouldShowSupportEscalation({ + answer, + responseIndex, + conversationLength, + isDownvoted: isDownvoted === true, + }); + return [ + `len:${conversationLength}`, + `id:${idPart}`, + showCta ? 'cta' : 'nocta', + isDownvoted === true ? 'down' : 'nodown', + ].join('|'); +} + +/** + * Whether to scroll the message list to the true bottom after the latest response completes. + * Skips closed chat, history view, in-flight generation, incomplete answers, and repeats. + * + * @param {{ + * isOpen: boolean; + * isHistoryOpen: boolean; + * isBusy: boolean; + * completionKey: string | null; + * lastScrolledKey?: string | null; + * }} options + * @returns {boolean} + */ +export function shouldScrollLatestResponseToBottom({ + isOpen, + isHistoryOpen, + isBusy, + completionKey, + lastScrolledKey = null, +}) { + if (!isOpen || isHistoryOpen || isBusy) return false; + if (typeof completionKey !== 'string' || completionKey.length === 0) return false; + if (completionKey === lastScrolledKey) return false; + return true; +} + +/** + * Scroll a message container to its true bottom so the full latest card + * (frame, feedback controls, optional support CTA) clears the composer. + * Prefer this over scrollIntoView({ block: 'nearest' }), which can stop early. + * + * @param {Pick | null | undefined} container + * @param {{ behavior?: 'auto' | 'smooth' }} [options] + * @returns {boolean} + */ +export function scrollMessageContainerToBottom(container, { behavior = 'auto' } = {}) { + if (!container) return false; + const top = Math.max(0, container.scrollHeight - container.clientHeight); + if (behavior === 'smooth' && typeof container.scrollTo === 'function') { + container.scrollTo({ top, behavior: 'smooth' }); + } else { + container.scrollTop = container.scrollHeight; + } + return true; +} + +/** + * Scroll behavior for conversation auto-scroll. Honors prefers-reduced-motion when available. + * @param {Pick | null | undefined} [media] + * @returns {'auto' | 'smooth'} + */ +export function getSupportCtaScrollBehavior(media = typeof window !== 'undefined' ? window : null) { + try { + if (media?.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) { + return 'auto'; + } + } catch { + // ignore + } + return 'smooth'; +} + +/** Alias kept for call sites that talk about completion scroll rather than CTA-only reveal. */ +export const getConversationScrollBehavior = getSupportCtaScrollBehavior; diff --git a/src/lib/kapa-support-escalation.test.mjs b/src/lib/kapa-support-escalation.test.mjs index 4d8d88296..a123f2fb2 100644 --- a/src/lib/kapa-support-escalation.test.mjs +++ b/src/lib/kapa-support-escalation.test.mjs @@ -1,7 +1,17 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import { SUPPORT_ESCALATION_CONVERSATION_LENGTH_THRESHOLD, + getConversationScrollBehavior, + getLatestResponseCompletionKey, + getSupportCtaRevealKey, + getSupportCtaScrollBehavior, + scrollMessageContainerToBottom, + shouldAutoRevealSupportCta, + shouldScrollLatestResponseToBottom, shouldShowSupportEscalation, } from './kapa-support-escalation.js'; @@ -9,6 +19,7 @@ test('hides the CTA when the answer is empty', () => { assert.equal( shouldShowSupportEscalation({ answer: '', + responseIndex: SUPPORT_ESCALATION_CONVERSATION_LENGTH_THRESHOLD - 1, conversationLength: SUPPORT_ESCALATION_CONVERSATION_LENGTH_THRESHOLD, isDownvoted: true, }), @@ -17,6 +28,7 @@ test('hides the CTA when the answer is empty', () => { assert.equal( shouldShowSupportEscalation({ answer: ' ', + responseIndex: SUPPORT_ESCALATION_CONVERSATION_LENGTH_THRESHOLD - 1, conversationLength: SUPPORT_ESCALATION_CONVERSATION_LENGTH_THRESHOLD, isDownvoted: true, }), @@ -25,6 +37,7 @@ test('hides the CTA when the answer is empty', () => { assert.equal( shouldShowSupportEscalation({ answer: null, + responseIndex: SUPPORT_ESCALATION_CONVERSATION_LENGTH_THRESHOLD - 1, conversationLength: SUPPORT_ESCALATION_CONVERSATION_LENGTH_THRESHOLD, isDownvoted: true, }), @@ -32,40 +45,139 @@ test('hides the CTA when the answer is empty', () => { ); }); -test('shows the CTA after three conversation turns', () => { +test('pins the conversation-length CTA only to response #3', () => { + const answer = 'Here is an answer.'; + const threshold = SUPPORT_ESCALATION_CONVERSATION_LENGTH_THRESHOLD; + assert.equal( shouldShowSupportEscalation({ - answer: 'Here is an answer.', - conversationLength: SUPPORT_ESCALATION_CONVERSATION_LENGTH_THRESHOLD - 1, + answer, + responseIndex: threshold - 2, + conversationLength: threshold - 1, isDownvoted: false, }), false ); + + // At exactly three turns, only the third response owns the threshold CTA. assert.equal( shouldShowSupportEscalation({ - answer: 'Here is an answer.', - conversationLength: SUPPORT_ESCALATION_CONVERSATION_LENGTH_THRESHOLD, + answer, + responseIndex: 0, + conversationLength: threshold, + isDownvoted: false, + }), + false + ); + assert.equal( + shouldShowSupportEscalation({ + answer, + responseIndex: 1, + conversationLength: threshold, + isDownvoted: false, + }), + false + ); + assert.equal( + shouldShowSupportEscalation({ + answer, + responseIndex: threshold - 1, + conversationLength: threshold, + isDownvoted: false, + }), + true + ); + + // Later responses must not inherit the threshold CTA. + assert.equal( + shouldShowSupportEscalation({ + answer, + responseIndex: threshold, + conversationLength: threshold + 2, + isDownvoted: false, + }), + false + ); + assert.equal( + shouldShowSupportEscalation({ + answer, + responseIndex: threshold + 1, + conversationLength: threshold + 2, + isDownvoted: false, + }), + false + ); + // Response #3 still keeps its threshold CTA after the conversation continues. + assert.equal( + shouldShowSupportEscalation({ + answer, + responseIndex: threshold - 1, + conversationLength: threshold + 2, isDownvoted: false, }), true ); }); -test('shows the CTA immediately after a thumbs-down', () => { +test('shows the CTA on each specifically thumbs-downed response', () => { assert.equal( shouldShowSupportEscalation({ answer: 'Here is an answer.', + responseIndex: 0, conversationLength: 1, isDownvoted: true, }), true ); + assert.equal( + shouldShowSupportEscalation({ + answer: 'Here is an answer.', + responseIndex: 1, + conversationLength: 2, + isDownvoted: false, + }), + false + ); + assert.equal( + shouldShowSupportEscalation({ + answer: 'Here is an answer.', + responseIndex: 1, + conversationLength: 2, + isDownvoted: true, + }), + true + ); +}); + +test('dedupes threshold + downvote on response #3 into a single CTA owner', () => { + const answer = 'Here is an answer.'; + const index = SUPPORT_ESCALATION_CONVERSATION_LENGTH_THRESHOLD - 1; + assert.equal( + shouldShowSupportEscalation({ + answer, + responseIndex: index, + conversationLength: SUPPORT_ESCALATION_CONVERSATION_LENGTH_THRESHOLD, + isDownvoted: true, + }), + true + ); + assert.equal( + getSupportCtaRevealKey({ + answer, + responseIndex: index, + conversationLength: SUPPORT_ESCALATION_CONVERSATION_LENGTH_THRESHOLD, + isDownvoted: true, + responseId: 'qa-3', + }), + 'down:qa-3' + ); }); test('does not show the CTA for uncertain answers alone', () => { assert.equal( shouldShowSupportEscalation({ answer: 'Here is an answer.', + responseIndex: 0, conversationLength: 1, isDownvoted: false, // Uncertainty metadata must not influence escalation. @@ -74,3 +186,210 @@ test('does not show the CTA for uncertain answers alone', () => { false ); }); + +test('auto-reveal only fires for a new CTA on the last response', () => { + assert.equal( + shouldAutoRevealSupportCta({ + isLastResponse: true, + revealKey: 'threshold:qa-3', + lastRevealedKey: null, + }), + true + ); + assert.equal( + shouldAutoRevealSupportCta({ + isLastResponse: true, + revealKey: 'threshold:qa-3', + lastRevealedKey: 'threshold:qa-3', + }), + false + ); + assert.equal( + shouldAutoRevealSupportCta({ + isLastResponse: false, + revealKey: 'down:qa-1', + lastRevealedKey: null, + }), + false + ); + assert.equal( + shouldAutoRevealSupportCta({ + isLastResponse: true, + revealKey: null, + lastRevealedKey: null, + }), + false + ); + // Downvote on the latest response is a new reveal even if threshold already scrolled. + assert.equal( + shouldAutoRevealSupportCta({ + isLastResponse: true, + revealKey: 'down:qa-4', + lastRevealedKey: 'threshold:qa-3', + }), + true + ); +}); + +test('scroll behavior respects prefers-reduced-motion when available', () => { + assert.equal(getSupportCtaScrollBehavior(null), 'smooth'); + assert.equal(getConversationScrollBehavior(null), 'smooth'); + assert.equal( + getSupportCtaScrollBehavior({ + matchMedia: () => ({ matches: true }), + }), + 'auto' + ); + assert.equal( + getSupportCtaScrollBehavior({ + matchMedia: () => ({ matches: false }), + }), + 'smooth' + ); +}); + +test('completion key tracks latest finished response layout including CTA/downvote', () => { + assert.equal( + getLatestResponseCompletionKey({ + conversationLength: 2, + responseIndex: 1, + responseId: 'qa-2', + answer: '', + isDownvoted: false, + }), + null + ); + assert.equal( + getLatestResponseCompletionKey({ + conversationLength: 3, + responseIndex: 1, + responseId: 'qa-2', + answer: 'Done', + isDownvoted: false, + }), + null, + 'non-last response must not own the completion key' + ); + assert.equal( + getLatestResponseCompletionKey({ + conversationLength: 2, + responseIndex: 1, + responseId: 'qa-2', + answer: 'Done', + isDownvoted: false, + }), + 'len:2|id:qa-2|nocta|nodown' + ); + assert.equal( + getLatestResponseCompletionKey({ + conversationLength: 3, + responseIndex: 2, + responseId: 'qa-3', + answer: 'Done', + isDownvoted: false, + }), + 'len:3|id:qa-3|cta|nodown' + ); + assert.equal( + getLatestResponseCompletionKey({ + conversationLength: 2, + responseIndex: 1, + responseId: 'qa-2', + answer: 'Done', + isDownvoted: true, + }), + 'len:2|id:qa-2|cta|down' + ); +}); + +test('completion bottom-scroll fires once per finished latest layout', () => { + assert.equal( + shouldScrollLatestResponseToBottom({ + isOpen: true, + isHistoryOpen: false, + isBusy: false, + completionKey: 'len:3|id:qa-3|cta|nodown', + lastScrolledKey: null, + }), + true + ); + assert.equal( + shouldScrollLatestResponseToBottom({ + isOpen: true, + isHistoryOpen: false, + isBusy: false, + completionKey: 'len:3|id:qa-3|cta|nodown', + lastScrolledKey: 'len:3|id:qa-3|cta|nodown', + }), + false + ); + assert.equal( + shouldScrollLatestResponseToBottom({ + isOpen: true, + isHistoryOpen: false, + isBusy: true, + completionKey: 'len:3|id:qa-3|cta|nodown', + lastScrolledKey: null, + }), + false + ); + assert.equal( + shouldScrollLatestResponseToBottom({ + isOpen: false, + isHistoryOpen: false, + isBusy: false, + completionKey: 'len:3|id:qa-3|cta|nodown', + lastScrolledKey: null, + }), + false + ); + assert.equal( + shouldScrollLatestResponseToBottom({ + isOpen: true, + isHistoryOpen: true, + isBusy: false, + completionKey: 'len:3|id:qa-3|cta|nodown', + lastScrolledKey: null, + }), + false + ); +}); + +test('scrollMessageContainerToBottom writes true bottom scrollTop', () => { + const calls = []; + const container = { + scrollHeight: 1200, + clientHeight: 400, + scrollTop: 0, + scrollTo(opts) { + calls.push(opts); + this.scrollTop = opts.top; + }, + }; + assert.equal(scrollMessageContainerToBottom(null), false); + assert.equal(scrollMessageContainerToBottom(container, { behavior: 'auto' }), true); + assert.equal(container.scrollTop, 1200); + assert.equal(scrollMessageContainerToBottom(container, { behavior: 'smooth' }), true); + assert.deepEqual(calls.at(-1), { top: 800, behavior: 'smooth' }); +}); + +test('component wires completion bottom-scroll instead of CTA-only nearest reveal', () => { + const componentPath = join( + dirname(fileURLToPath(import.meta.url)), + '../components/KapaChatLauncher.tsx' + ); + const cssPath = join( + dirname(fileURLToPath(import.meta.url)), + '../components/KapaChatLauncher.css' + ); + const source = readFileSync(componentPath, 'utf8'); + const css = readFileSync(cssPath, 'utf8'); + assert.match(source, /getLatestResponseCompletionKey/); + assert.match(source, /shouldScrollLatestResponseToBottom/); + assert.match(source, /scrollMessageContainerToBottom/); + assert.match(source, /scrolledCompletionKeyRef/); + assert.doesNotMatch(source, /scrollIntoView\(\{\s*behavior,\s*block:\s*'nearest'/); + assert.doesNotMatch(source, /revealedSupportCtaKeyRef/); + assert.match(css, /\.sl-kapa-panel__body\s*\{[^}]*scroll-padding-bottom:\s*1\.5rem/s); + assert.match(css, /\.sl-kapa-panel__body\s*\{[^}]*padding-bottom:\s*1\.5rem/s); +});