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); +});