diff --git a/inc/Main.php b/inc/Main.php index e547a02d..cc5251da 100644 --- a/inc/Main.php +++ b/inc/Main.php @@ -806,6 +806,7 @@ public function get_frontend_data( $overrides = [] ) { 'openChat' => __( 'Open chat', 'hyve-lite' ), 'closeChat' => __( 'Close chat', 'hyve-lite' ), 'sendMessage' => __( 'Send message', 'hyve-lite' ), + 'scrollToLatest' => __( 'Scroll to the latest messages', 'hyve-lite' ), 'previewNotice' => __( 'Preview mode — test your assistant here. These messages aren\'t saved.', 'hyve-lite' ), /** * Filters the chat privacy notice text. Use a single %s where the diff --git a/inc/OpenAI.php b/inc/OpenAI.php index dbb71e29..ab1b94bd 100644 --- a/inc/OpenAI.php +++ b/inc/OpenAI.php @@ -97,6 +97,7 @@ class OpenAI { b) If current context is empty but previous context is relevant: Use previous context to answer. c) If the input is a greeting: Respond appropriately. d) If neither current nor previous context addresses the question: Respond with an empty response and success: false. +e) If the input is a general-purpose task rather than a question about the site or its content (fixing or writing code, translating text, solving exercises, generating unrelated content), and the context does not cover it: Respond with an empty response and success: false, even though you could do it from general knowledge. 3. Response Formulation - Use information from the current context primarily. If current context is insufficient, refer to previous context for follow-up questions. @@ -154,6 +155,15 @@ class OpenAI { "success": true } +5. Unrelated Task +Context: [Empty] +Question: .style{color:blue} fix this CSS, please +Response: +{ +"response": "", +"success": false +} + Error Handling: For invalid inputs or unrecognized question formats, respond with: { @@ -173,6 +183,7 @@ class OpenAI { - Prioritize using the current context for answers. - For follow-up questions with empty current context, refer to previous context if relevant. - If information isn't available in current or previous context, indicate this with an empty response and success: false. +- You are this website's assistant, not a general-purpose AI: never perform tasks or produce content that the context does not support. - Always strive to provide the most accurate and relevant information based on available context. PROMPT; diff --git a/src/frontend/App.js b/src/frontend/App.js index 13b6067b..c0484166 100644 --- a/src/frontend/App.js +++ b/src/frontend/App.js @@ -2026,6 +2026,36 @@ class App { } ); } + // The jump-back-down chip: appears once the visitor scrolls up through + // the conversation, hides again near the bottom. New replies force a + // scroll to the bottom, which also hides it through the same listener. + const messageBox = document.getElementById( 'hyve-message-box' ); + const scrollDownButton = document.getElementById( 'hyve-scroll-down' ); + + if ( messageBox && scrollDownButton ) { + messageBox.addEventListener( + 'scroll', + () => { + const fromBottom = + messageBox.scrollHeight - + messageBox.scrollTop - + messageBox.clientHeight; + + scrollDownButton.classList.toggle( + 'is-visible', + 120 < fromBottom + ); + }, + { passive: true } + ); + + scrollDownButton.addEventListener( 'click', () => { + // The box scrolls smoothly via CSS; the resulting scroll + // events hide the chip as the bottom approaches. + messageBox.scrollTop = messageBox.scrollHeight; + } ); + } + // Close menu when clicking outside document.addEventListener( 'click', ( event ) => { const menu = document.querySelector( '.hyve-menu-dropdown' ); @@ -2222,6 +2252,13 @@ class App { window.addEventListener( 'scroll', this.teaserScrollListener, { passive: true, } ); + + // The visitor may already be past the configured depth when the + // trigger arms — an anchor link, the browser restoring the + // scroll position on back/reload, or a page shorter than the + // viewport — and then no scroll event ever fires. Evaluate once + // now so the teaser still appears. + this.teaserScrollListener(); break; default: this.teaserTimeout = window.setTimeout( @@ -2700,6 +2737,19 @@ class App { chatWindow.appendChild( chatMessageBox ); + // A quick way back to the latest messages for visitors who scrolled up + // through the conversation. Hidden until the message box is actually + // scrolled away from the bottom (see setupListeners). + const scrollDownButton = this.createElement( 'button', { + className: 'hyve-scroll-down', + id: 'hyve-scroll-down', + innerHTML: + '', + ariaLabel: strings.scrollToLatest, + } ); + + chatWindow.appendChild( scrollDownButton ); + const privacyNotice = this.renderPrivacyNotice(); if ( privacyNotice ) { diff --git a/src/frontend/style.scss b/src/frontend/style.scss index 1a6cb6d7..1f315d80 100644 --- a/src/frontend/style.scss +++ b/src/frontend/style.scss @@ -640,6 +640,57 @@ body.wp-admin { } } +// Jump back to the latest messages once the visitor has scrolled up. +// Anchored to .hyve-window (fixed when floating, relative when inline), +// floating just above the input box. +.hyve-scroll-down { + align-items: center; + // Fixed light palette on purpose: the chip floats over message bubbles, + // so a theme-colored background could render it invisible on dark setups. + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 999px; + bottom: 84px; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.16); + color: #111827; + cursor: pointer; + display: flex; + height: 34px; + justify-content: center; + left: 50%; + line-height: 0; + opacity: 0; + padding: 0; + pointer-events: none; + position: absolute; + transform: translate(-50%, 6px); + transition: opacity 0.18s ease, transform 0.18s ease, visibility 0.18s; + // Fully hidden, not just transparent: keeps the chip out of the tab + // order while it is not offered. + visibility: hidden; + width: 34px; + z-index: 2; + + &.is-visible { + opacity: 1; + pointer-events: auto; + transform: translate(-50%, 0); + visibility: visible; + } + + &:hover { + border-color: #d1d5db; + box-shadow: 0 3px 10px rgba(15, 23, 42, 0.22); + } +} + +@media (prefers-reduced-motion: reduce) { + + .hyve-scroll-down { + transition: none; + } +} + // ---- Message list ---- .hyve-message-box { flex: 1 1 auto; diff --git a/tests/e2e/specs/chat.spec.js b/tests/e2e/specs/chat.spec.js index d6effe10..4e29d016 100644 --- a/tests/e2e/specs/chat.spec.js +++ b/tests/e2e/specs/chat.spec.js @@ -102,6 +102,71 @@ test.describe( 'Chat', () => { ).toBeVisible(); } ); + test( 'scrolling up reveals a jump-back-down chip that returns to the latest message', async ( { + page, + admin, + editor, + } ) => { + await admin.createNewPost( { title: 'Dummy Post' } ); + + await editor.insertBlock( { + name: 'hyve/chat', + attributes: { + variant: 'floating', + }, + } ); + + const postId = await editor.publishPost(); + + await page.goto( `?p=${ postId }` ); + await initializeChatApp( page ); + + await page + .locator( '#hyve-open' ) + .getByRole( 'button' ) + .click( { force: true } ); + await expect( page.locator( '#hyve-window' ) ).toBeVisible(); + + // A conversation long enough to scroll, without burning API calls. + // Seed scrolls jump instantly so the CSS smooth-scroll animation + // cannot race the assertions; the click below still exercises it. + await page.evaluate( () => { + const box = document.getElementById( 'hyve-message-box' ); + + for ( let i = 0; i < 30; i++ ) { + const bubble = document.createElement( 'div' ); + bubble.className = 'hyve-bot-message'; + bubble.innerHTML = `
Filler message ${ i }
`; + box.appendChild( bubble ); + } + + box.scrollTo( { top: box.scrollHeight, behavior: 'instant' } ); + } ); + + // At the bottom: no chip. + await expect( page.locator( '#hyve-scroll-down' ) ).toBeHidden(); + + // Scroll up through the history: the chip appears. + await page.evaluate( () => { + const box = document.getElementById( 'hyve-message-box' ); + box.scrollTo( { top: 0, behavior: 'instant' } ); + } ); + await expect( page.locator( '#hyve-scroll-down' ) ).toBeVisible(); + + // Clicking it returns to the bottom and the chip hides again. + await page.locator( '#hyve-scroll-down' ).click(); + await expect( page.locator( '#hyve-scroll-down' ) ).toBeHidden(); + await expect + .poll( () => + page.evaluate( () => { + const box = document.getElementById( 'hyve-message-box' ); + + return box.scrollHeight - box.scrollTop - box.clientHeight; + } ) + ) + .toBeLessThan( 5 ); + } ); + test( 'chat inline interaction', async ( { page, admin, editor } ) => { await admin.createNewPost( { title: 'Dummy Post' } ); diff --git a/tests/e2e/specs/proactive.spec.js b/tests/e2e/specs/proactive.spec.js new file mode 100644 index 00000000..155b08ec --- /dev/null +++ b/tests/e2e/specs/proactive.spec.js @@ -0,0 +1,130 @@ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * The proactive teaser's scroll trigger. Pro supplies the config through + * `window.hyveClient.proactive`; injecting it here exercises the same lite + * code path a Pro site runs. + */ +test.describe( 'Proactive teaser (scroll trigger)', () => { + /** + * Publish a post with the floating chat block, open it, and make the + * page tall enough to scroll. + * + * @param {import('@playwright/test').Page} page + * @param {import('@wordpress/e2e-test-utils-playwright').Admin} admin + * @param {import('@wordpress/e2e-test-utils-playwright').Editor} editor + */ + async function openTallPageWithChat( page, admin, editor ) { + await admin.createNewPost( { title: 'Proactive Scroll Page' } ); + + await editor.insertBlock( { + name: 'hyve/chat', + attributes: { variant: 'floating' }, + } ); + + const postId = await editor.publishPost(); + + await page.goto( `?p=${ postId }` ); + + await page.evaluate( () => { + if ( + ! document.querySelector( + '#hyve-open, #hyve-window, .hyve-input-text' + ) + ) { + window?.hyveApp?.initialize(); + } + + // Make the page scrollable well past any viewport. + const spacer = document.createElement( 'div' ); + spacer.style.height = '5000px'; + document.body.appendChild( spacer ); + } ); + + await expect( page.locator( '#hyve-open' ) ).toBeVisible(); + } + + /** + * Arm the scroll trigger with the given config, as Pro would on load. + * + * @param {import('@playwright/test').Page} page + * @param {number} scrollDepth + */ + async function armScrollTeaser( page, scrollDepth ) { + await page.evaluate( ( depth ) => { + window.hyveClient.proactive = { + trigger: 'scroll', + message: 'Need a hand?', + scrollDepth: depth, + }; + window.hyveApp.setupProactive(); + }, scrollDepth ); + } + + test( 'appears when the visitor scrolls past the configured depth', async ( { + page, + admin, + editor, + } ) => { + await openTallPageWithChat( page, admin, editor ); + await armScrollTeaser( page, 50 ); + + // Above the depth: no teaser yet. + await expect( page.locator( '#hyve-teaser' ) ).toBeHidden(); + + await page.evaluate( () => + window.scrollTo( + 0, + document.documentElement.scrollHeight - window.innerHeight + ) + ); + + await expect( page.locator( '#hyve-teaser' ) ).toBeVisible(); + await expect( page.locator( '#hyve-teaser' ) ).toContainText( + 'Need a hand?' + ); + } ); + + test( 'appears immediately when the visitor is already past the depth', async ( { + page, + admin, + editor, + } ) => { + await openTallPageWithChat( page, admin, editor ); + + // Scroll first (anchor link / restored scroll position), then arm: + // no further scroll event will fire, the arm-time check must show it. + await page.evaluate( () => + window.scrollTo( + 0, + document.documentElement.scrollHeight - window.innerHeight + ) + ); + + await armScrollTeaser( page, 50 ); + + await expect( page.locator( '#hyve-teaser' ) ).toBeVisible(); + } ); + + test( 'stays hidden below the configured depth', async ( { + page, + admin, + editor, + } ) => { + await openTallPageWithChat( page, admin, editor ); + await armScrollTeaser( page, 90 ); + + // Scroll to roughly half the page: under the 90% depth. + await page.evaluate( () => + window.scrollTo( + 0, + ( document.documentElement.scrollHeight - window.innerHeight ) * + 0.5 + ) + ); + + // Give any scroll handler a chance to run before asserting. + await page.waitForTimeout( 250 ); + await expect( page.locator( '#hyve-teaser' ) ).toBeHidden(); + } ); +} );