From cdb6a44a2c83555bb00ed6aadf898692899d9ed0 Mon Sep 17 00:00:00 2001 From: chadbyte Date: Sat, 19 Sep 2026 11:05:08 +1200 Subject: [PATCH] fix(sidebar): clarify session creation and provider selection Separate the Create session action from a labeled provider control with its avatar, name, and chevron. Preserve vendor creation and default preferences while improving narrow layouts and disclosure focus. Verify production desktop and mobile controls with repeatable browser assertions for payloads, focus, expanded state, and layout fit. --- lib/public/css/mobile-nav.css | 23 +-- lib/public/css/sidebar.css | 71 ++++++-- lib/public/modules/session-creation-cta.js | 33 ++++ lib/public/modules/sidebar-mobile.js | 71 ++++---- lib/public/modules/sidebar-sessions.js | 86 +++++----- .../session-creation-cta-browser-check.js | 153 ++++++++++++++++++ test/fixtures/session-creation-cta-server.js | 36 +++++ test/fixtures/session-creation-cta.html | 75 +++++++++ test/session-creation-cta-ui.test.js | 43 +++++ 9 files changed, 500 insertions(+), 91 deletions(-) create mode 100644 lib/public/modules/session-creation-cta.js create mode 100644 test/fixtures/session-creation-cta-browser-check.js create mode 100644 test/fixtures/session-creation-cta-server.js create mode 100644 test/fixtures/session-creation-cta.html create mode 100644 test/session-creation-cta-ui.test.js diff --git a/lib/public/css/mobile-nav.css b/lib/public/css/mobile-nav.css index 2a40c447..048d6e71 100644 --- a/lib/public/css/mobile-nav.css +++ b/lib/public/css/mobile-nav.css @@ -358,8 +358,7 @@ background: rgba(var(--overlay-rgb), 0.06); } - /* Split "New session" row: main action on the last-used vendor + a chevron - that expands .mobile-vendor-list below it. Mirrors the desktop sidebar. */ + /* Primary creation action + a labeled provider selector. */ .mobile-session-new-row { display: flex; gap: 6px; @@ -377,26 +376,30 @@ color: var(--text); gap: 8px; } - .mobile-session-new-row .mobile-session-new-chevron { - flex: 0 0 auto; - width: 44px; + .mobile-session-new-row .mobile-session-new-provider { + flex: 0 1 132px; + width: 132px; + min-width: 104px; margin-bottom: 0; - padding: 10px 0; + padding: 6px 9px; border: 1px solid var(--border-subtle); border-radius: 8px; background: rgba(var(--overlay-rgb), 0.03); color: var(--text-muted); - justify-content: center; - gap: 0; + display: grid; + gap: 2px; } - .mobile-session-new-row .mobile-session-new-chevron.expanded svg { + .mobile-session-new-row .mobile-session-new-provider.expanded .session-create-provider-chevron svg { transform: rotate(180deg); } .mobile-session-new-row .mobile-session-new-main:active, - .mobile-session-new-row .mobile-session-new-chevron:active { + .mobile-session-new-row .mobile-session-new-provider:active { background: rgba(var(--overlay-rgb), 0.1); } + .mobile-session-new-row .session-create-provider-label { font-size: 9px; } + .mobile-session-new-row .session-create-provider-name { font-size: 12px; } + .mobile-vendor-list { display: flex; flex-direction: column; diff --git a/lib/public/css/sidebar.css b/lib/public/css/sidebar.css index 66299140..3d8361f7 100644 --- a/lib/public/css/sidebar.css +++ b/lib/public/css/sidebar.css @@ -1262,26 +1262,77 @@ opacity: 1; } -/* Split button: main action + chevron sharing one grid cell. */ +/* Primary creation action + a visibly grouped provider selector. */ .session-top-action-split { display: flex; + flex-wrap: wrap; align-items: stretch; - gap: 2px; + gap: 6px; min-width: 0; } .session-top-action-split .split-main { - flex: 1 1 auto; + flex: 1 1 110px; width: auto; + min-width: 110px; + border: 1px solid color-mix(in srgb, var(--accent) 34%, transparent); + background: color-mix(in srgb, var(--accent) 13%, transparent); + color: var(--text); + font-weight: 600; + white-space: nowrap; } -.session-top-action-split .split-chevron { - flex: 0 0 auto; - width: 28px; - min-width: 28px; - padding: 0; - justify-content: center; - gap: 0; +.session-top-action-split .split-main:hover { + border-color: color-mix(in srgb, var(--accent) 50%, transparent); + background: color-mix(in srgb, var(--accent) 19%, transparent); +} + +.session-top-action-split .split-provider { + flex: 1 1 104px; + width: auto; + min-width: 104px; + padding: 5px 8px; + border: 1px solid var(--border-subtle); + background: color-mix(in srgb, var(--sidebar-hover) 48%, transparent); + border-radius: 9px; + display: grid; + gap: 1px; +} + +.session-create-provider-label { + display: block; + color: var(--text-dimmer); + font-size: 9px; + font-weight: 600; + line-height: 1; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.session-create-provider-choice { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; +} + +.session-create-provider-name { + overflow: hidden; + color: var(--text-secondary); + font-size: 11px; + font-weight: 600; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-create-provider-chevron { + display: inline-flex; + margin-left: auto; +} + +.session-create-provider[aria-expanded="true"] .session-create-provider-chevron svg { + transform: rotate(180deg); } .session-favorites-section { diff --git a/lib/public/modules/session-creation-cta.js b/lib/public/modules/session-creation-cta.js new file mode 100644 index 00000000..17fcf5c3 --- /dev/null +++ b/lib/public/modules/session-creation-cta.js @@ -0,0 +1,33 @@ +// Shared, presentation-only session creation controls for desktop and mobile. + +import { iconHtml } from './icons.js'; + +export function renderSessionCreationCta(options) { + var row = document.createElement("div"); + row.className = options.rowClass + " session-create-cta"; + + var createButton = document.createElement("button"); + createButton.className = options.createClass + " session-create-primary"; + createButton.type = "button"; + createButton.textContent = "Create session"; + createButton.title = options.createTitle; + createButton.setAttribute("aria-label", options.createTitle); + createButton.addEventListener("click", options.onCreate); + row.appendChild(createButton); + + var providerButton = document.createElement("button"); + providerButton.className = options.providerClass + " session-create-provider"; + providerButton.type = "button"; + providerButton.title = options.providerTitle; + providerButton.setAttribute("aria-label", "AI provider: " + options.vendorName + ". " + options.providerTitle); + providerButton.setAttribute("aria-expanded", "false"); + providerButton.setAttribute("aria-controls", options.menuId); + providerButton.innerHTML = 'AI provider' + + '' + + options.vendorName + '' + iconHtml("chevron-down", "session-create-provider-chevron") + ''; + providerButton.addEventListener("click", options.onProvider); + row.appendChild(providerButton); + + return { element: row, createButton: createButton, providerButton: providerButton }; +} diff --git a/lib/public/modules/sidebar-mobile.js b/lib/public/modules/sidebar-mobile.js index a0d9068a..18d3377a 100644 --- a/lib/public/modules/sidebar-mobile.js +++ b/lib/public/modules/sidebar-mobile.js @@ -16,6 +16,7 @@ import { } from './sidebar-sessions.js'; import { VENDOR_AVATARS, VENDOR_NAMES, VENDOR_ORDER, VENDOR_HOMEPAGES, isExperimentalVendor } from './app-rendering.js'; import { defaultVendorUnavailable, resolvePreferredVendor, saveDefaultVendor } from './default-vendor.js'; +import { renderSessionCreationCta } from './session-creation-cta.js'; import { getCachedProjectList, getCachedCurrentSlug, @@ -671,42 +672,49 @@ function createMobileLoopGroup(loopId, children, groupKey) { } // Helper: render sorted sessions into a container with date groups (with loop session grouping) -function renderMobileSessionsInto(container) { - // Mirrors the desktop sidebar's split button: one "New session" action on - // the project's last-used vendor, plus a chevron that expands the full - // vendor list. The list expands inline rather than as a fixed-position - // popover because it lives inside a scrolling bottom sheet. +export function renderMobileSessionsInto(container) { var mobileDefaultVendor = resolvePreferredVendor(store.get('installedVendors') || []) || resolveDefaultVendor(); var mobileInstalled = store.get('installedVendors') || []; var mobileDefaultState = store.get('defaultVendorState') || {}; - var newRow = document.createElement("div"); - newRow.className = "mobile-session-new-row"; - - var mainBtn = document.createElement("button"); - mainBtn.className = "mobile-session-new mobile-session-new-main"; - mainBtn.title = "New " + (VENDOR_NAMES[mobileDefaultVendor] || mobileDefaultVendor) + " session"; - if (defaultVendorUnavailable(mobileInstalled)) mainBtn.title += " (saved default is unavailable; using installed priority)"; - mainBtn.innerHTML = 'New ' + (VENDOR_NAMES[mobileDefaultVendor] || mobileDefaultVendor) + ' session'; - mainBtn.addEventListener("click", function () { - startNewSession(mobileDefaultVendor); - closeMobileSheet(); - }); - newRow.appendChild(mainBtn); - var vendorList = document.createElement("div"); vendorList.className = "mobile-vendor-list hidden"; - - var chevronBtn = document.createElement("button"); - chevronBtn.className = "mobile-session-new mobile-session-new-chevron"; - chevronBtn.setAttribute("aria-label", "Choose a vendor"); - chevronBtn.innerHTML = iconHtml("chevron-down"); - chevronBtn.addEventListener("click", function () { - vendorList.classList.toggle("hidden"); - chevronBtn.classList.toggle("expanded"); + vendorList.id = "mobile-session-provider-menu"; + var mobileVendorName = VENDOR_NAMES[mobileDefaultVendor] || mobileDefaultVendor; + var mobileCreateTitle = "Create a " + mobileVendorName + " session"; + if (defaultVendorUnavailable(mobileInstalled)) mobileCreateTitle += " (saved default is unavailable; using installed priority)"; + var controls; + controls = renderSessionCreationCta({ + rowClass: "mobile-session-new-row", + createClass: "mobile-session-new mobile-session-new-main", + providerClass: "mobile-session-new mobile-session-new-provider", + iconClass: "mobile-session-new-icon", + vendorName: mobileVendorName, + vendorAvatar: VENDOR_AVATARS[mobileDefaultVendor] || VENDOR_AVATARS.claude, + createTitle: mobileCreateTitle, + providerTitle: "Choose an AI provider", + menuId: vendorList.id, + onCreate: function () { startNewSession(mobileDefaultVendor); closeMobileSheet(); }, + onProvider: function () { + var opening = vendorList.classList.contains("hidden"); + vendorList.classList.toggle("hidden", !opening); + controls.providerButton.classList.toggle("expanded", opening); + controls.providerButton.setAttribute("aria-expanded", opening ? "true" : "false"); + if (opening) { + var first = vendorList.querySelector("button:not(:disabled)"); + if (first) first.focus(); + } + }, + }); + var newRow = controls.element; + vendorList.addEventListener("keydown", function (e) { + if (e.key !== "Escape") return; + e.preventDefault(); + vendorList.classList.add("hidden"); + controls.providerButton.classList.remove("expanded"); + controls.providerButton.setAttribute("aria-expanded", "false"); + controls.providerButton.focus(); }); - newRow.appendChild(chevronBtn); if (defaultVendorUnavailable(mobileInstalled) && mobileDefaultState.preference) { var mobileFallbackNote = document.createElement("div"); @@ -727,7 +735,7 @@ function renderMobileSessionsInto(container) { if (!isInstalled) vBtn.classList.add("disabled"); if (vendor === mobileDefaultVendor) vBtn.classList.add("active"); vBtn.innerHTML = '' + - '' + name + '' + + '' + (isInstalled ? "Create with " : "Learn about ") + name + '' + (isExperimentalVendor(vendor) ? '🧪' : '') + (isInstalled ? "" : 'Not installed'); vBtn.addEventListener("click", function () { @@ -735,6 +743,9 @@ function renderMobileSessionsInto(container) { window.open(VENDOR_HOMEPAGES[vendor], "_blank", "noopener"); return; } + vendorList.classList.add("hidden"); + controls.providerButton.classList.remove("expanded"); + controls.providerButton.setAttribute("aria-expanded", "false"); startNewSession(vendor); closeMobileSheet(); }); diff --git a/lib/public/modules/sidebar-sessions.js b/lib/public/modules/sidebar-sessions.js index ddce0aeb..b63bf818 100644 --- a/lib/public/modules/sidebar-sessions.js +++ b/lib/public/modules/sidebar-sessions.js @@ -17,6 +17,7 @@ import { openGroup, separateGroup } from './split-view.js'; import { groupedSessionIds } from './split-group-helpers.js'; import { defaultVendorUnavailable, resolvePreferredVendor, saveDefaultVendor } from './default-vendor.js'; import { openPairDialog } from './split-pair-ui.js'; +import { renderSessionCreationCta } from './session-creation-cta.js'; import { hierarchyItemMatches, prepareSidebarHierarchy, @@ -53,10 +54,11 @@ var armedDeleteTimer = null; store.subscribe(function (state, previous) { if (state.defaultVendorState !== previous.defaultVendorState || state.installedVendors !== previous.installedVendors) { var reopenNewMenu = !!(sessionCtxMenu && sessionCtxMenuAnchor); + var sessionList = getSessionListEl(); if (reopenNewMenu) closeSessionCtxMenu(); - if (getSessionListEl()) renderSessionList(); - if (reopenNewMenu) { - var freshAnchor = getSessionListEl().querySelector(".session-top-action.split-chevron"); + if (sessionList) renderSessionList(); + if (reopenNewMenu && sessionList) { + var freshAnchor = sessionList.querySelector(".session-top-action.split-provider"); if (freshAnchor) showNewSessionMenu(freshAnchor, resolveDefaultVendor()); } } @@ -356,58 +358,49 @@ export function startNewSession(vendor, extra) { }); } -function renderSessionTopActions() { +export function renderSessionTopActions() { var wrap = document.createElement("div"); wrap.className = "session-top-actions"; - // Single split button. The main half creates a session with the project's - // last-used vendor (for Claude the server applies the claudeOpenMode pref); - // the chevron opens the vendor picker plus alternate launch modes. var defaultVendor = resolveDefaultVendor(); - - var cell = document.createElement("div"); - cell.className = "session-top-action-split"; - - var mainBtn = document.createElement("button"); - mainBtn.className = "session-top-action split-main"; - mainBtn.type = "button"; - mainBtn.title = "New " + (VENDOR_NAMES[defaultVendor] || defaultVendor) + " session"; - if (defaultVendorUnavailable(store.get('installedVendors') || [])) mainBtn.title += " (saved default is unavailable; using installed priority)"; - mainBtn.innerHTML = 'New session'; - mainBtn.addEventListener("click", function () { - startNewSession(defaultVendor); - }); - cell.appendChild(mainBtn); - - var chevron = document.createElement("button"); - chevron.className = "session-top-action split-chevron"; - chevron.type = "button"; - chevron.title = "Choose a vendor or launch mode"; - chevron.setAttribute("aria-label", "Choose a vendor or launch mode"); - chevron.innerHTML = iconHtml("chevron-down"); - chevron.addEventListener("click", function (e) { - e.preventDefault(); - e.stopPropagation(); - if (sessionCtxMenu) { closeSessionCtxMenu(); return; } - showNewSessionMenu(chevron, defaultVendor); + var vendorName = VENDOR_NAMES[defaultVendor] || defaultVendor; + var createTitle = "Create a " + vendorName + " session"; + if (defaultVendorUnavailable(store.get('installedVendors') || [])) createTitle += " (saved default is unavailable; using installed priority)"; + var controls; + controls = renderSessionCreationCta({ + rowClass: "session-top-action-split", + createClass: "session-top-action split-main", + providerClass: "session-top-action split-provider", + iconClass: "session-top-action-icon", + vendorName: vendorName, + vendorAvatar: VENDOR_AVATARS[defaultVendor] || VENDOR_AVATARS.claude, + createTitle: createTitle, + providerTitle: "Choose an AI provider or launch mode", + menuId: "session-new-menu", + onCreate: function () { startNewSession(defaultVendor); }, + onProvider: function (e) { + e.preventDefault(); + e.stopPropagation(); + if (sessionCtxMenu) { closeSessionCtxMenu(); return; } + showNewSessionMenu(controls.providerButton, defaultVendor); + }, }); - cell.appendChild(chevron); - - wrap.appendChild(cell); + wrap.appendChild(controls.element); return wrap; } -// Dropdown anchored to the split-button chevron. Reuses the session-ctx-menu -// element/var so the global document click handler closes it. +// Provider disclosure anchored to the grouped provider control. Reuses the +// session-ctx-menu element/var so the global document click handler closes it. function showNewSessionMenu(anchorBtn, defaultVendor) { closeSessionCtxMenu(); var menu = document.createElement("div"); menu.className = "session-ctx-menu session-new-menu"; + menu.id = "session-new-menu"; var installed = store.get('installedVendors') || []; var defaultVendorState = store.get('defaultVendorState') || {}; sessionCtxMenuAnchor = anchorBtn; + anchorBtn.setAttribute("aria-expanded", "true"); if (defaultVendorUnavailable(installed) && defaultVendorState.preference) { var fallbackNote = document.createElement("div"); @@ -430,7 +423,7 @@ function showNewSessionMenu(anchorBtn, defaultVendor) { if (vendor === defaultVendor) item.classList.add("active"); item.type = "button"; item.innerHTML = '' + - '' + name + '' + + '' + (isInstalled ? "Create with " : "Learn about ") + name + '' + (isExperimentalVendor(vendor) ? '🧪' : '') + (isInstalled ? "" : 'Not installed' + iconHtml("external-link")); item.title = isInstalled @@ -526,6 +519,12 @@ function showNewSessionMenu(anchorBtn, defaultVendor) { document.body.appendChild(menu); sessionCtxMenu = menu; + menu.addEventListener("keydown", function (e) { + if (e.key !== "Escape") return; + e.preventDefault(); + e.stopPropagation(); + closeSessionCtxMenu(true); + }); refreshIcons(); requestAnimationFrame(function () { @@ -542,6 +541,8 @@ function showNewSessionMenu(anchorBtn, defaultVendor) { if (menuRect.bottom > window.innerHeight - 8) { menu.style.top = (btnRect.top - menuRect.height - 2) + "px"; } + var firstMenuItem = menu.querySelector("button:not(:disabled)"); + if (firstMenuItem) firstMenuItem.focus(); }); } @@ -718,13 +719,16 @@ export function getExpandedLoopRuns() { // --- Context menu --- -function closeSessionCtxMenu() { +function closeSessionCtxMenu(restoreFocus) { + var anchor = sessionCtxMenuAnchor; + if (anchor) anchor.setAttribute("aria-expanded", "false"); sessionCtxMenuAnchor = null; if (sessionCtxMenu) { sessionCtxMenu.remove(); sessionCtxMenu = null; sessionCtxSessionId = null; } + if (restoreFocus && anchor && document.contains(anchor)) anchor.focus(); } function showSessionCtxMenu(anchorBtn, sessionId, title, cliSid, sessionData) { diff --git a/test/fixtures/session-creation-cta-browser-check.js b/test/fixtures/session-creation-cta-browser-check.js new file mode 100644 index 00000000..26740f43 --- /dev/null +++ b/test/fixtures/session-creation-cta-browser-check.js @@ -0,0 +1,153 @@ +var assert = require("node:assert/strict"); +var childProcess = require("node:child_process"); +var fs = require("node:fs"); +var os = require("node:os"); +var path = require("node:path"); + +var sessionName = "clay-cta-check-" + process.pid; +var serverPath = path.join(__dirname, "session-creation-cta-server.js"); +var outputDir = process.argv[2] || path.join(os.tmpdir(), "clay-session-cta-browser"); +var server = null; + +function browser(args, capture) { + var command = ["agent-browser", "--session", sessionName].concat(args); + var output = childProcess.execFileSync("npx", command, { + cwd: path.join(__dirname, "../.."), + encoding: "utf8", + stdio: capture ? ["ignore", "pipe", "inherit"] : ["ignore", "ignore", "inherit"], + }); + return typeof output === "string" ? output.trim() : ""; +} + +function evaluate(expression) { + var value = JSON.parse(browser(["eval", expression], true)); + if (typeof value === "string" && (/^[\[{]/).test(value)) return JSON.parse(value); + return value; +} + +function waitForServer(child) { + return new Promise(function (resolve, reject) { + var buffer = ""; + function onData(chunk) { + buffer += chunk.toString(); + var newline = buffer.indexOf("\n"); + if (newline === -1) return; + child.stdout.off("data", onData); + resolve(buffer.slice(0, newline).trim()); + } + child.stdout.on("data", onData); + child.once("error", reject); + child.once("exit", function (code) { + if (code !== null && code !== 0) reject(new Error("Fixture server exited with " + code)); + }); + }); +} + +function pngDimensions(file) { + var data = fs.readFileSync(file); + return { width: data.readUInt32BE(16), height: data.readUInt32BE(20) }; +} + +function openAt(url, width, height) { + browser(["set", "viewport", String(width), String(height)], false); + browser(["open", url], false); + browser(["wait", "500"], false); + assert.equal(evaluate("!!document.querySelector('.session-create-primary')"), true); + var viewport = evaluate("JSON.stringify({width:window.innerWidth,height:window.innerHeight})"); + assert.deepEqual(viewport, { width: width, height: height }); +} + +async function main() { + fs.mkdirSync(outputDir, { recursive: true }); + server = childProcess.spawn(process.execPath, [serverPath], { stdio: ["ignore", "pipe", "inherit"] }); + var url = await waitForServer(server); + try { + openAt(url, 1280, 800); + var desktopMain = evaluate("window.fixtureMessages.splice(0);document.querySelector('.fixture-sidebar .session-create-primary').click();JSON.stringify(window.fixtureMessages)"); + assert.deepEqual(desktopMain, [{ type: "new_session", vendor: "codex" }]); + evaluate("document.querySelector('.fixture-sidebar .session-create-provider').focus();document.querySelector('.fixture-sidebar .session-create-provider').click();true"); + browser(["wait", "100"], false); + var desktopDisclosure = evaluate("JSON.stringify({messages:window.fixtureMessages,expanded:document.querySelector('.fixture-sidebar .session-create-provider').getAttribute('aria-expanded'),active:document.activeElement.textContent.trim()})"); + assert.equal(desktopDisclosure.messages.length, 1); + assert.equal(desktopDisclosure.expanded, "true"); + assert.match(desktopDisclosure.active, /^Create with Claude Code/); + browser(["press", "Escape"], false); + var desktopEscape = evaluate("JSON.stringify({expanded:document.querySelector('.fixture-sidebar .session-create-provider').getAttribute('aria-expanded'),focused:document.activeElement===document.querySelector('.fixture-sidebar .session-create-provider')})"); + assert.deepEqual(desktopEscape, { expanded: "false", focused: true }); + evaluate("document.querySelector('.fixture-sidebar .session-create-provider').click();document.querySelector('.session-new-vendor').click();true"); + assert.deepEqual(evaluate("JSON.stringify(window.fixtureMessages)"), [ + { type: "new_session", vendor: "codex" }, + { type: "new_session", vendor: "claude" }, + ]); + + openAt(url, 1280, 800); + evaluate("document.querySelector('.fixture-sidebar .session-create-provider').click();document.querySelector('.session-new-set-default').click();true"); + var defaultMessages = evaluate("JSON.stringify(window.fixtureMessages)"); + assert.equal(defaultMessages.length, 1); + assert.equal(defaultMessages[0].type, "default_vendor_set"); + assert.equal(defaultMessages[0].vendor, "claude"); + var desktop240 = evaluate("JSON.stringify((function(){var side=document.querySelector('.fixture-sidebar');var row=side.querySelector('.session-create-cta');var main=side.querySelector('.session-create-primary');return {fits:side.scrollWidth<=side.clientWidth,rowFits:row.scrollWidth<=row.clientWidth,mainFits:main.scrollWidth<=main.clientWidth,whiteSpace:getComputedStyle(main).whiteSpace,mainHeight:Math.round(main.getBoundingClientRect().height)};})())"); + assert.deepEqual(desktop240, { fits: true, rowFits: true, mainFits: true, whiteSpace: "nowrap", mainHeight: 34 }); + var desktop192 = evaluate("document.querySelector('.fixture-page').style.gridTemplateColumns='192px minmax(0,1fr)';JSON.stringify((function(){var side=document.querySelector('.fixture-sidebar');var row=side.querySelector('.session-create-cta');var main=side.querySelector('.session-create-primary');var provider=side.querySelector('.session-create-provider');return {fits:side.scrollWidth<=side.clientWidth,rowFits:row.scrollWidth<=row.clientWidth,mainFits:main.scrollWidth<=main.clientWidth,stacked:provider.getBoundingClientRect().top>main.getBoundingClientRect().top,mainWidth:Math.round(main.getBoundingClientRect().width),providerWidth:Math.round(provider.getBoundingClientRect().width)};})())"); + assert.equal(desktop192.fits, true); + assert.equal(desktop192.rowFits, true); + assert.equal(desktop192.mainFits, true); + assert.equal(desktop192.stacked, true); + assert.equal(desktop192.mainWidth, desktop192.providerWidth); + evaluate("document.querySelector('.fixture-page').style.gridTemplateColumns='240px minmax(0,1fr)';document.documentElement.classList.remove('light-theme');true"); + var desktopDark = path.join(outputDir, "desktop-1280x800-dark.png"); + browser(["screenshot", "body", desktopDark], false); + evaluate("document.documentElement.classList.add('light-theme');true"); + var desktopLight = path.join(outputDir, "desktop-1280x800-light.png"); + browser(["screenshot", "body", desktopLight], false); + assert.deepEqual(pngDimensions(desktopDark), { width: 1280, height: 800 }); + assert.deepEqual(pngDimensions(desktopLight), { width: 1280, height: 800 }); + + openAt(url, 390, 844); + var mobileMain = evaluate("window.fixtureMessages.splice(0);document.querySelector('#mobile-fixture .session-create-primary').click();JSON.stringify(window.fixtureMessages)"); + assert.deepEqual(mobileMain, [{ type: "new_session", vendor: "codex" }]); + evaluate("document.querySelector('#mobile-fixture .session-create-provider').focus();document.querySelector('#mobile-fixture .session-create-provider').click();true"); + browser(["wait", "100"], false); + var mobileDisclosure = evaluate("JSON.stringify({messages:window.fixtureMessages,expanded:document.querySelector('#mobile-fixture .session-create-provider').getAttribute('aria-expanded'),fits:document.getElementById('mobile-actions').scrollWidth<=document.getElementById('mobile-actions').clientWidth})"); + assert.equal(mobileDisclosure.messages.length, 1); + assert.equal(mobileDisclosure.expanded, "true"); + assert.equal(mobileDisclosure.fits, true); + browser(["press", "Escape"], false); + var mobileEscape = evaluate("JSON.stringify({expanded:document.querySelector('#mobile-fixture .session-create-provider').getAttribute('aria-expanded'),focused:document.activeElement===document.querySelector('#mobile-fixture .session-create-provider')})"); + assert.deepEqual(mobileEscape, { expanded: "false", focused: true }); + evaluate("document.querySelector('#mobile-fixture .session-create-provider').click();document.querySelector('#mobile-session-provider-menu .mobile-session-new-vendor').click();true"); + assert.deepEqual(evaluate("JSON.stringify(window.fixtureMessages)"), [ + { type: "new_session", vendor: "codex" }, + { type: "new_session", vendor: "claude" }, + ]); + openAt(url, 390, 844); + evaluate("document.querySelector('#mobile-fixture .session-create-provider').click();document.querySelector('#mobile-session-provider-menu .mobile-vendor-set-default').click();true"); + var mobileDefault = evaluate("JSON.stringify(window.fixtureMessages)"); + assert.equal(mobileDefault.length, 1); + assert.equal(mobileDefault[0].type, "default_vendor_set"); + assert.equal(mobileDefault[0].vendor, "claude"); + evaluate("document.documentElement.classList.remove('light-theme');true"); + var mobileDark = path.join(outputDir, "mobile-390x844-dark.png"); + browser(["screenshot", "body", mobileDark], false); + evaluate("document.documentElement.classList.add('light-theme');true"); + var mobileLight = path.join(outputDir, "mobile-390x844-light.png"); + browser(["screenshot", "body", mobileLight], false); + assert.deepEqual(pngDimensions(mobileDark), { width: 390, height: 844 }); + assert.deepEqual(pngDimensions(mobileLight), { width: 390, height: 844 }); + + process.stdout.write(JSON.stringify({ + desktop: { viewport: "1280x800", minimumSidebar: 192, payloads: "passed", focus: "passed", overflow: "passed" }, + mobile: { viewport: "390x844", payloads: "passed", focus: "passed", overflow: "passed" }, + screenshots: [desktopDark, desktopLight, mobileDark, mobileLight], + }) + "\n"); + } finally { + try { browser(["close"], false); } catch (error) {} + if (server && server.exitCode === null) server.kill("SIGTERM"); + } +} + +main().catch(function (error) { + process.stderr.write((error && error.stack) || String(error)); + process.stderr.write("\n"); + process.exitCode = 1; +}); diff --git a/test/fixtures/session-creation-cta-server.js b/test/fixtures/session-creation-cta-server.js new file mode 100644 index 00000000..5fb7e0b2 --- /dev/null +++ b/test/fixtures/session-creation-cta-server.js @@ -0,0 +1,36 @@ +var http = require("http"); +var fs = require("fs"); +var path = require("path"); + +var root = path.join(__dirname, "../.."); +var publicRoot = path.join(root, "lib/public"); + +function contentType(file) { + if (/\.css$/.test(file)) return "text/css"; + if (/\.js$/.test(file)) return "text/javascript"; + if (/\.html$/.test(file)) return "text/html"; + if (/\.png$/.test(file)) return "image/png"; + if (/\.svg$/.test(file)) return "image/svg+xml"; + return "application/octet-stream"; +} + +var server = http.createServer(function (req, res) { + var requestPath = req.url.split("?")[0]; + var file = requestPath === "/" ? path.join(__dirname, "session-creation-cta.html") : path.join(publicRoot, requestPath); + if (file.indexOf(publicRoot) !== 0 && file !== path.join(__dirname, "session-creation-cta.html")) { + res.writeHead(404); res.end("Not found"); return; + } + if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) { + res.writeHead(404); res.end("Not found"); return; + } + res.writeHead(200, { "content-type": contentType(file), "cache-control": "no-store" }); + fs.createReadStream(file).pipe(res); +}); + +server.listen(0, "127.0.0.1", function () { + process.stdout.write("http://127.0.0.1:" + server.address().port + "\n"); +}); + +function close() { server.close(function () { process.exit(0); }); } +process.on("SIGINT", close); +process.on("SIGTERM", close); diff --git a/test/fixtures/session-creation-cta.html b/test/fixtures/session-creation-cta.html new file mode 100644 index 00000000..5ff6945d --- /dev/null +++ b/test/fixtures/session-creation-cta.html @@ -0,0 +1,75 @@ + + + + + + Clay session creation CTA fixture + + + + + + + +
+
+

Sessions

+
+
+
+

Session creation controls

+

This harmless fixture mounts Clay's production desktop and mobile render functions with a mocked WebSocket. It starts no provider or daemon.

+
No session requested
+
+
+

Sessions

+
+
+
+ + + diff --git a/test/session-creation-cta-ui.test.js b/test/session-creation-cta-ui.test.js new file mode 100644 index 00000000..a6f1ac7a --- /dev/null +++ b/test/session-creation-cta-ui.test.js @@ -0,0 +1,43 @@ +var test = require("node:test"); +var assert = require("node:assert/strict"); +var fs = require("node:fs"); +var path = require("node:path"); + +var root = path.join(__dirname, ".."); +var shared = fs.readFileSync(path.join(root, "lib/public/modules/session-creation-cta.js"), "utf8"); +var desktop = fs.readFileSync(path.join(root, "lib/public/modules/sidebar-sessions.js"), "utf8"); +var mobile = fs.readFileSync(path.join(root, "lib/public/modules/sidebar-mobile.js"), "utf8"); +var desktopCss = fs.readFileSync(path.join(root, "lib/public/css/sidebar.css"), "utf8"); +var mobileCss = fs.readFileSync(path.join(root, "lib/public/css/mobile-nav.css"), "utf8"); + +test("desktop and mobile use the shared labeled session creation control", function () { + assert.match(shared, /textContent = "Create session"/); + assert.match(shared, /session-create-provider-label">AI provider/); + assert.match(shared, /aria-expanded", "false"/); + assert.doesNotMatch(shared, /aria-haspopup/); + assert.match(desktop, /renderSessionCreationCta\(\{/); + assert.match(mobile, /renderSessionCreationCta\(\{/); + assert.doesNotMatch(desktop, /split-chevron/); + assert.doesNotMatch(mobile, /mobile-session-new-chevron/); +}); + +test("provider menus state immediate creation and keep defaults separate", function () { + assert.match(desktop, /isInstalled \? "Create with " : "Learn about "/); + assert.match(mobile, /isInstalled \? "Create with " : "Learn about "/); + assert.match(desktop, /className = "session-new-set-default"/); + assert.match(mobile, /className = "mobile-vendor-set-default"/); + assert.match(desktop, /anchorBtn\.setAttribute\("aria-expanded", "true"\)/); + assert.match(desktop, /anchor\.setAttribute\("aria-expanded", "false"\)/); + assert.match(mobile, /controls\.providerButton\.setAttribute\("aria-expanded", opening \? "true" : "false"\)/); + assert.doesNotMatch(desktop, /role", "menu(?:item)?"/); + assert.doesNotMatch(mobile, /role", "menu(?:item)?"/); +}); + +test("compact controls have bounded flex sizing for narrow desktop and mobile", function () { + assert.match(desktopCss, /\.session-top-action-split\s*\{[^}]*flex-wrap:\s*wrap/s); + assert.match(desktopCss, /\.session-top-action-split \.split-main\s*\{[^}]*flex:\s*1 1 110px[^}]*white-space:\s*nowrap/s); + assert.match(desktopCss, /\.session-top-action-split \.split-provider\s*\{[^}]*flex:\s*1 1 104px[^}]*min-width:\s*104px/s); + assert.match(desktopCss, /\.session-create-provider-name\s*\{[^}]*overflow:\s*hidden[^}]*text-overflow:\s*ellipsis/s); + assert.match(mobileCss, /\.mobile-session-new-row \.mobile-session-new-provider\s*\{[^}]*flex:\s*0 1 132px[^}]*min-width:\s*104px/s); + assert.match(mobileCss, /\.mobile-session-new-row\s*\{[^}]*display:\s*flex/s); +});