diff --git a/README.md b/README.md index 391fe82a..e7d0d24e 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ against a throwaway database. (on a Linux host; on a macOS host there is no Terminal view yet, see Requirements). Viewing a window never moves your own terminal. → [docs/mirroring.md](docs/mirroring.md) - **Session switcher, sidebar and search.** A bottom bar with Recent, Waiting, - Pins and All (and a pin button for the session you are on), and a sidebar + Pins and All, picked by an icon (and a ⋯ menu to pin the session you are on), and a sidebar that lists every session like channels in a chat app: the chief first (if you turned one on), then the sessions waiting on you, then the rest by recency, the long-idle ones folded under **Idle**. The search opens with the diff --git a/docs/mirroring.md b/docs/mirroring.md index 00c77b83..8f591150 100644 --- a/docs/mirroring.md +++ b/docs/mirroring.md @@ -165,8 +165,8 @@ host's MCP endpoint for its Claude Code sessions ([attention.md](attention.md#mi listed as `offline` for a day. The database holds `mirror_tokens`, as sha256 hashes, and your pins (below), both under row-level security. - **Pins.** On a session's own page (a window's Rendered view, an agent's chat - page, or the Terminal view of either) the session switcher has a pin button - beside its search that pins or unpins that session — a window with no + page, or the Terminal view of either) the session switcher's **⋯** menu, at + its right end, has **Pin this session** / **Unpin this session** — a window with no Rendered view is pinned from its Terminal view. Your pins are the session switcher's **Pins** view (below), on every device you sign in on, and come first in its **All** view. A mirrored pin is the window's address — host, session and tmux window id — @@ -177,8 +177,9 @@ host's MCP endpoint for its Claude Code sessions ([attention.md](attention.md#mi - **The session switcher.** The bar under the message box of a window's Rendered view and an agent's chat page, and at the bottom of the Terminal view of either (whether or not that view can send), so you switch sessions - without going back to the Rendered view. Its left button opens a menu of four - views: **Recent** (the entries you last opened, on any device), **Waiting** + without going back to the Rendered view. Its left button shows the current + view's icon — a clock, a bell, a pin, a grid — and opens a menu of four + views, each named beside its icon: **Recent** (the entries you last opened, on any device), **Waiting** (the entries waiting on you: an agent or a Claude Code session called `attention_ask`, a mirrored Claude Code session stopped at a prompt or holding an `AskUserQuestion` picker — the [attention queue](attention.md)'s diff --git a/e2e/tests/agents-list-ux.spec.ts b/e2e/tests/agents-list-ux.spec.ts index b2d3e0c7..6dcae6a0 100644 --- a/e2e/tests/agents-list-ux.spec.ts +++ b/e2e/tests/agents-list-ux.spec.ts @@ -345,7 +345,7 @@ for (const vp of [ const pick = page.locator('#switcher-views [data-switcher-pick="waiting"]'); await expect(pick).toContainText('Waiting on you'); await press(pick); - await expect(page.locator('#switcher-toggle')).toHaveText('Waiting'); + await expect(page.locator('#switcher-toggle').locator('[data-switcher-icon]:visible')).toHaveAttribute('data-switcher-icon', 'waiting'); const viewRow = page.locator('#session-switcher [data-switcher-list="waiting"]'); await expect(viewRow).toBeVisible(); const pills = (await viewRow.locator('a[data-switcher-entry]').evaluateAll((els) => els.map((e) => e.getAttribute('href') || ''))).map(key); diff --git a/e2e/tests/agents-switcher.spec.ts b/e2e/tests/agents-switcher.spec.ts index 27fec2e2..5e07e23d 100644 --- a/e2e/tests/agents-switcher.spec.ts +++ b/e2e/tests/agents-switcher.spec.ts @@ -161,13 +161,13 @@ for (const vp of [ for (const src of sh.sources) expect(src, `a shift's source is the switcher: ${JSON.stringify(cls.shifts)}`).not.toMatch(/session-switcher|switcher-/); } await expectNoSidewaysScroll(page); - await expect(toggle(page)).toHaveText('Recent'); + await expect(toggle(page).locator('[data-switcher-icon]:visible')).toHaveAttribute('data-switcher-icon', 'recent'); // All holds every entry, the page's own drawn current (one, not a link). await toggle(page).click(); await expect(menu(page)).toBeVisible(); await menu(page).locator('[data-switcher-pick="all"]').click(); await expect(menu(page)).toBeHidden(); - await expect(toggle(page)).toHaveText('All'); + await expect(toggle(page).locator('[data-switcher-icon]:visible')).toHaveAttribute('data-switcher-icon', 'all'); await expect(row(page, 'all')).toBeVisible(); const n = await listed(page); await expect(row(page, 'all').locator('[data-switcher-entry]')).toHaveCount(n); @@ -339,28 +339,40 @@ for (const vp of [ // THE PIN ON A SESSION'S BAR (operator ask: "a way to pin the current // session, the current agent, from within the rendered view"): on a - // window's Rendered view the bar's pin toggle pins THAT window — the Pins + // window's Rendered view the bar's ⋯ menu's Pin pins THAT window — the Pins // view and its count follow at once, a reload keeps it, another session's // bar lists it in Pins — and unpins it again. (With the Agents list's card // toggles retired, this is the one way to pin: open it, then pin.) - test('the bar\'s pin toggle pins and unpins the page\'s own window; the Pins view follows at once', async ({ page }) => { + test('the bar\'s ⋯ menu pins and unpins the page\'s own window; the Pins view follows at once', async ({ page }) => { test.setTimeout(90_000); await ready(page); await unpin(page, 'tundra', '@6'); const view = viewOf('tundra', '@6'); await page.goto(view); const pin = page.locator('#switcher-pin'); + const more = page.locator('#switcher-more-btn'); + await expect(more).toBeVisible(); + await expect(more).toHaveAccessibleName('More for this session'); + await expect(pin, 'the pin is in the ⋯ menu, not the bar').toBeHidden(); + const mb = (await more.boundingBox())!, bb = (await bar(page).boundingBox())!; + expect([Math.round(mb.width), Math.round(mb.height)], 'a 44px target').toEqual([44, 44]); + expect(mb.y >= bb.y - 0.5 && mb.y + mb.height <= bb.y + bb.height + 0.5, 'inside the bar').toBe(true); + await (vp.use.hasTouch ? more.tap() : more.click()); + await expect(page.locator('#switcher-more')).toBeVisible(); + await expect(more).toHaveAttribute('aria-expanded', 'true'); await expect(pin).toBeVisible(); - await expect(pin).toHaveAttribute('aria-pressed', 'false'); + // Measured once its open animation (a scale) has finished. + await page.locator('#switcher-more').evaluate((m) => Promise.all(m.getAnimations().map((a) => a.finished))); + await expect(pin).toHaveAttribute('data-pinned', 'false'); await expect(pin).toHaveAccessibleName('Pin this session'); - const pb = (await pin.boundingBox())!, bb = (await bar(page).boundingBox())!; - expect([Math.round(pb.width), Math.round(pb.height)], 'a 44px target').toEqual([44, 44]); - expect(pb.y >= bb.y - 0.5 && pb.y + pb.height <= bb.y + bb.height + 0.5, 'inside the bar').toBe(true); + expect(Math.round((await pin.boundingBox())!.height), 'a 44px-tall item').toBeGreaterThanOrEqual(44); await expect(menu(page).locator('[data-switcher-count="pins"]')).toHaveText('0'); try { await (vp.use.hasTouch ? pin.tap() : pin.click()); - await expect(page.locator('#switcher-pin')).toHaveAttribute('aria-pressed', 'true'); - await expect(page.locator('#switcher-pin')).toHaveAccessibleName('Unpin this session'); + await expect(page.locator('#switcher-more'), 'the menu closes once used').toBeHidden(); + await expect(page.locator('#switcher-pin')).toHaveAttribute('data-pinned', 'true'); + // Its words (the menu is closed: a hidden item has no accessible name). + await expect(page.locator('#switcher-pin')).toHaveText('Unpin this session'); // The Pins view and its count, at once, with no reload: the page's own // window, drawn current. await expect(menu(page).locator('[data-switcher-count="pins"]')).toHaveText('1'); @@ -368,14 +380,15 @@ for (const vp of [ await expect(page.locator('[id="switcher-list-pins"]'), 'no nested copy of the row').toHaveCount(1); // Kept: a reload draws it pressed, and another session's bar lists it. await page.reload(); - await expect(page.locator('#switcher-pin')).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('#switcher-pin')).toHaveAttribute('data-pinned', 'true'); await page.goto(MANGO); await expect(row(page, 'pins').locator('a[data-switcher-entry]')).toHaveText(['tundra:1']); - await expect(page.locator('#switcher-pin'), "mango's own toggle is not pressed").toHaveAttribute('aria-pressed', 'false'); - // And unpinned from the same toggle. + await expect(page.locator('#switcher-pin'), "mango's own item says Pin").toHaveAttribute('data-pinned', 'false'); + // And unpinned from the same menu. await page.goto(view); + await page.locator('#switcher-more-btn').click(); await page.locator('#switcher-pin').click(); - await expect(page.locator('#switcher-pin')).toHaveAttribute('aria-pressed', 'false'); + await expect(page.locator('#switcher-pin')).toHaveAttribute('data-pinned', 'false'); await expect(menu(page).locator('[data-switcher-count="pins"]')).toHaveText('0'); await expectNoSidewaysScroll(page); } finally { @@ -452,7 +465,7 @@ for (const vp of [ // Pin the page's own window: it moves to the front of All (pins lead // it), and Pins gains it. await page.evaluate(() => (document.getElementById('switcher-pin') as HTMLElement).click()); - await expect(page.locator('#switcher-pin')).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('#switcher-pin')).toHaveAttribute('data-pinned', 'true'); await expect.poll(async () => (await order())[0], { message: 'the swap reordered All' }).toBe('current'); expect(before[0], 'control: the page\'s own pill was not first before').not.toBe('current'); // No nesting, no duplicate ids. diff --git a/e2e/tests/cls-long-transcript.spec.ts b/e2e/tests/cls-long-transcript.spec.ts index 235a89f1..0702c86d 100644 --- a/e2e/tests/cls-long-transcript.spec.ts +++ b/e2e/tests/cls-long-transcript.spec.ts @@ -267,8 +267,9 @@ test.describe('a 200-message agent transcript', () => { const stalls: { label: string; at: (html: string) => number; held?: string }[] = [ { label: 'before the layout call', at: (html: string) => html.indexOf('
{ const n = html.indexOf('id="session-switcher"'); return n < 0 ? -1 : html.indexOf('>', n) + 1; } }, - // Inside the switcher's toggle, between its icon and its label. - { label: "inside the switcher's toggle", at: (html: string) => html.indexOf(' html.indexOf(', then 102px) made deterministic. { label: 'inside the composer', at: (html: string) => { const n = html.indexOf('', n) + 1; } }, diff --git a/e2e/tests/composer-switcher.spec.ts b/e2e/tests/composer-switcher.spec.ts index 5127443e..c0172150 100644 --- a/e2e/tests/composer-switcher.spec.ts +++ b/e2e/tests/composer-switcher.spec.ts @@ -158,12 +158,12 @@ test('after a boosted navigation and Back, the chip, Hide, a new ask and the vie // The view menu still works. const toggle = page.locator('#switcher-toggle'); - await expect(toggle).toHaveText('Recent'); + await expect(toggle.locator('[data-switcher-icon]:visible')).toHaveAttribute('data-switcher-icon', 'recent'); await pickView(page, 'pins'); - await expect(toggle).toHaveText('Pins'); + await expect(toggle.locator('[data-switcher-icon]:visible')).toHaveAttribute('data-switcher-icon', 'pins'); await expect(row(page, 'pins')).toBeVisible(); await pickView(page, 'recent'); - await expect(toggle).toHaveText('Recent'); + await expect(toggle.locator('[data-switcher-icon]:visible')).toHaveAttribute('data-switcher-icon', 'recent'); // The chip reopens; Hide hides again. await expect(chip(page)).toHaveText('1 waiting'); await chip(page).click(); @@ -243,14 +243,15 @@ test('the switcher below the input: Recent without this page, Pins, All with it expect(Math.abs(b.y + b.height - (form.y + form.height)), 'in the room the form reserves at its bottom').toBeLessThanOrEqual(1); expect(b.y + b.height, 'and on screen').toBeLessThanOrEqual(page.viewportSize()!.height + 1); - // Every control in the bar is a 44px target. + // Every control in the bar is a 44px target. (The ⋯ menu's item is in a + // closed popover here: zero-sized, so skipped.) const small = await bar(page).locator('a, button').evaluateAll((els) => els.filter((e) => { const r = e.getBoundingClientRect(); return r.width > 0 && (r.height < 44 || r.width < 44); }).map((e) => e.outerHTML.slice(0, 80))); expect(small, 'controls under 44px in the bar').toEqual([]); // Recent: the visits, newest first, without this page. const toggle = page.locator('#switcher-toggle'); - await expect(toggle).toHaveText('Recent'); + await expect(toggle.locator('[data-switcher-icon]:visible')).toHaveAttribute('data-switcher-icon', 'recent'); await expect(row(page, 'recent')).toBeVisible(); const recent = await hrefs(page, 'recent'); expect(recent.slice(0, 2), 'newest first').toEqual(['/agents/release-helper', '/agents/notes-tidy']); @@ -258,14 +259,14 @@ test('the switcher below the input: Recent without this page, Pins, All with it // Pins. await pickView(page, 'pins'); - await expect(toggle).toHaveText('Pins'); + await expect(toggle.locator('[data-switcher-icon]:visible')).toHaveAttribute('data-switcher-icon', 'pins'); await expect(row(page, 'recent')).toBeHidden(); await expect(row(page, 'pins')).toBeVisible(); expect(await hrefs(page, 'pins')).toEqual(['/agents/release-helper']); // All: every agent, this one drawn as current (not a link). await pickView(page, 'all'); - await expect(toggle).toHaveText('All'); + await expect(toggle.locator('[data-switcher-icon]:visible')).toHaveAttribute('data-switcher-icon', 'all'); const all = row(page, 'all'); await expect(all).toBeVisible(); await expect(all.locator('[data-switcher-current]')).toHaveText('docs-sweeper'); @@ -275,12 +276,12 @@ test('the switcher below the input: Recent without this page, Pins, All with it // Kept: the next page comes up in All, drawn so before it is painted. await openAgent(page, 'notes-tidy'); - await expect(toggle).toHaveText('All'); + await expect(toggle.locator('[data-switcher-icon]:visible')).toHaveAttribute('data-switcher-icon', 'all'); await expect(row(page, 'all')).toBeVisible(); await expect(row(page, 'recent')).toBeHidden(); // Back to Recent from the menu. await pickView(page, 'recent'); - await expect(toggle).toHaveText('Recent'); + await expect(toggle.locator('[data-switcher-icon]:visible')).toHaveAttribute('data-switcher-icon', 'recent'); // A tap is a navigation to that session. await Promise.all([page.waitForURL((u) => u.pathname === '/agents/release-helper'), row(page, 'recent').locator('a[href^="/agents/release-helper?"], a[href="/agents/release-helper"]').first().click()]); @@ -309,6 +310,95 @@ test('at 390px the bar never widens the page, whatever it holds', async ({ page } }); +// THE COMPACT BAR at a phone's size (the operator asked for icons in place +// of the view labels, the pin in a three-dots menu, and less padding so more +// of the session pills show): the pills' row gets the width, the view button +// shows the view's icon and changes it with the view, and the ⋯ menu pins the +// session. The Recent row's clientWidth on this page at 390px, measured: 174px +// on the base (main before this change: a 72px labelled view button, 44px +// search and pin, 8px gaps, in the pane's 358px), 226px here (three 44×44 +// icon buttons, no gaps). 210 is clear of the base and below this. +test.describe('phone 390×844, the compact bar', () => { + test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true }); + test('the pills get the width, the view button\'s icon follows the view, and the ⋯ menu pins the session into Pins', async ({ page }) => { + await page.addInitScript(() => { try { if (!sessionStorage.getItem('cmp-cleared')) { localStorage.removeItem('muster.switcher.view'); sessionStorage.setItem('cmp-cleared', '1'); } } catch (e) {} }); + await openAgent(page, 'docs-sweeper'); + const id = await agentID(page, 'docs-sweeper'); + await pin(page, id, false); + await page.reload(); + await expect(bar(page)).toBeVisible(); + + // More pill width: the shown row's clientWidth. + const rowW = await row(page, 'recent').evaluate((el) => el.clientWidth); + expect(rowW, 'the pills\' row at 390px (174px on the base, 226px here)').toBeGreaterThanOrEqual(210); + // Every icon button: a 44×44 target (operator decision), inside the bar; the bar inside the page. + for (const sel of ['#switcher-toggle', '#switcher-search-open', '#switcher-more-btn']) { + const b = (await page.locator(sel).boundingBox())!; + expect([Math.round(b.width), Math.round(b.height)], `${sel} is 44×44`).toEqual([44, 44]); + } + const bb = (await bar(page).boundingBox())!; + expect(bb.x + bb.width, 'the bar ends inside the viewport').toBeLessThanOrEqual(390 + 0.5); + + // The view button: an icon, no words, named for the view; it changes with + // the view and the button does not move or resize. + const toggle = page.locator('#switcher-toggle'); + const shown = toggle.locator('[data-switcher-icon]:visible'); + await expect(toggle).toHaveText(''); + await expect(shown).toHaveAttribute('data-switcher-icon', 'recent'); + await expect(toggle).toHaveAccessibleName('Showing Recent sessions; choose a view'); + const tb = (await toggle.boundingBox())!; + for (const [key, name] of [['all', 'All'], ['waiting', 'Waiting'], ['pins', 'Pins']]) { + await toggle.tap(); + await expect(page.locator(`#switcher-views [data-switcher-pick="${key}"] [data-switcher-menu-icon="${key}"]`), 'the menu draws the icon beside the words').toBeVisible(); + await page.locator(`#switcher-views [data-switcher-pick="${key}"]`).tap(); + await expect(page.locator('#switcher-views')).toBeHidden(); + await expect(shown).toHaveCount(1); + await expect(shown).toHaveAttribute('data-switcher-icon', key); + await expect(toggle).toHaveAccessibleName(`Showing ${name} sessions; choose a view`); + await expect(toggle).toHaveAttribute('title', `Showing ${name} sessions; choose a view`); + expect(await toggle.boundingBox(), `the button stays put in ${key}`).toEqual(tb); + } + // Kept across a load, drawn before paint: Pins' icon, not Recent's. + await page.reload(); + await expect(shown).toHaveAttribute('data-switcher-icon', 'pins'); + const curPin = row(page, 'pins').locator('[data-switcher-current]'); + await expect(curPin, 'control: this session is not pinned yet').toHaveCount(0); + const count = page.locator('#switcher-views [data-switcher-count="pins"]'); + const n0 = Number(await count.textContent()); + + // The ⋯ menu: opened by a tap, above its button and on screen; Pin pins + // this session, the menu closes, and the Pins view holds it at once. + const more = page.locator('#switcher-more-btn'); + const menu = page.locator('#switcher-more'); + const item = page.locator('#switcher-pin'); + await expect(item, 'no pin in the bar itself').toBeHidden(); + await more.tap(); + await expect(menu).toBeVisible(); + await expect(more).toHaveAttribute('aria-expanded', 'true'); + // Measured once its open animation (a rise and a scale) has finished. + await menu.evaluate((m) => Promise.all(m.getAnimations().map((a) => a.finished))); + const mb = (await menu.boundingBox())!, ob = (await more.boundingBox())!; + expect(mb.y + mb.height, 'the menu opens above the ⋯').toBeLessThanOrEqual(ob.y + 0.5); + expect(mb.x >= 0 && mb.x + mb.width <= 390 + 0.5, 'the menu is on screen').toBe(true); + await expect(item).toHaveAccessibleName('Pin this session'); + pinned = id; + await item.tap(); + await expect(menu, 'the menu closes once used').toBeHidden(); + await expect(item).toHaveAttribute('data-pinned', 'true'); + await expect(curPin, 'the Pins view holds this session').toHaveText('docs-sweeper'); + await expect(curPin).toBeVisible(); + await expect(count).toHaveText(String(n0 + 1)); + // And Unpin from the same menu. + await more.tap(); + await expect(item).toHaveAccessibleName('Unpin this session'); + await item.tap(); + await expect(item).toHaveAttribute('data-pinned', 'false'); + await expect(curPin).toHaveCount(0); + await expect(count).toHaveText(String(n0)); + await page.evaluate(() => { try { localStorage.removeItem('muster.switcher.view'); } catch (e) {} }); + }); +}); + // LAYOUT SHIFT: the agent page with the bar, loaded in each view (the stored // choice applied before paint) and with a hidden ask restored, at both sizes. // diff --git a/e2e/tests/host-icons.spec.ts b/e2e/tests/host-icons.spec.ts index 195b1138..d9c01112 100644 --- a/e2e/tests/host-icons.spec.ts +++ b/e2e/tests/host-icons.spec.ts @@ -187,16 +187,16 @@ test.describe('scroll positions', () => { const visited = new URL(r.url()).pathname + new URL(r.url()).search; expect(before, 'control: the visited window was not in the Recent row yet').not.toContain(visited); const pin = page.locator('#switcher-pin'); - const was = await pin.getAttribute('aria-pressed'); + const was = await pin.getAttribute('data-pinned'); try { await page.evaluate(() => (document.getElementById('switcher-pin') as HTMLElement).click()); - await expect(pin).not.toHaveAttribute('aria-pressed', was!); + await expect(pin).not.toHaveAttribute('data-pinned', was!); await expect.poll(async () => (await hrefs())[0], 'the swap put the visited window first in the Recent row').toBe(visited); await page.waitForTimeout(300); expect((await pos(page)).x, 'the row kept its offset').toBe(200); } finally { - if ((await pin.getAttribute('aria-pressed')) !== was) await page.evaluate(() => (document.getElementById('switcher-pin') as HTMLElement).click()); - await expect(pin).toHaveAttribute('aria-pressed', was!); + if ((await pin.getAttribute('data-pinned')) !== was) await page.evaluate(() => (document.getElementById('switcher-pin') as HTMLElement).click()); + await expect(pin).toHaveAttribute('data-pinned', was!); } }); }); diff --git a/e2e/tests/pins.spec.ts b/e2e/tests/pins.spec.ts index e10c30e7..9f3da1a8 100644 --- a/e2e/tests/pins.spec.ts +++ b/e2e/tests/pins.spec.ts @@ -16,7 +16,8 @@ import { clsInit, readCls, settle } from '../lib/cls'; // The operator asked "Add 'pinned' agents so they appear first in list". The // Agents list page — whose cards carried a pin toggle and drew the pinned ones // first — is retired: a session is pinned from its own page, by the session -// switcher's pin toggle (#switcher-pin: "open it, then pin"), and the pinned +// switcher's ⋯ menu (#switcher-more-btn, its item #switcher-pin: "open it, +// then pin"), and the pinned // entries are the bar's Pins view and lead its All view. The host gives two // sessions SYNTHETIC codenames (quince, sable) through its labels file. The // entries pinned are the LEAST recently active (the two older windows, a @@ -94,7 +95,10 @@ async function login(page: Page) { signedIn = await page.context().cookies(origin); } +// pinToggle is the ⋯ menu's Pin / Unpin item (hidden until the ⋯ opens); +// moreBtn the ⋯ that opens it. const pinToggle = (page: Page) => page.locator('#switcher-pin'); +const moreBtn = (page: Page) => page.locator('#session-switcher #switcher-more-btn'); const viewRow = (page: Page, key: string) => page.locator(`#session-switcher [data-switcher-list="${key}"]`); // rowHrefs is a bar view's entries as their links ("current" for the page's // own, which is not one), each agent link without its ?session= query. @@ -121,15 +125,22 @@ async function open(page: Page, url: string) { await rig.waitListed(page, 'pinq'); await page.addInitScript(() => { try { localStorage.setItem('muster.switcher.view', 'all'); } catch (e) {} }); await page.goto(origin + url); - await expect(pinToggle(page)).toBeVisible({ timeout: 20_000 }); + await expect(moreBtn(page)).toBeVisible({ timeout: 20_000 }); + await expect(pinToggle(page)).toBeAttached(); } -// pinHere taps the page's own pin toggle and waits until it says pinned (or not). +// pinHere opens the bar's ⋯ menu, taps the page's own Pin (or Unpin) in it and +// waits until it says pinned (or not) and the menu has closed. async function pinHere(page: Page, on: boolean, touch: boolean) { const t = pinToggle(page); - await expect(t).toHaveAttribute('aria-pressed', on ? 'false' : 'true'); + await expect(t).toHaveAttribute('data-pinned', on ? 'false' : 'true'); + await expect(t, 'the pin is in the ⋯ menu, not the bar').toBeHidden(); + await (touch ? moreBtn(page).tap() : moreBtn(page).click()); + await expect(t).toBeVisible(); + await expect(t).toHaveAccessibleName(on ? 'Pin this session' : 'Unpin this session'); await (touch ? t.tap() : t.click()); - await expect(t).toHaveAttribute('aria-pressed', on ? 'true' : 'false'); + await expect(t).toHaveAttribute('data-pinned', on ? 'true' : 'false'); + await expect(page.locator('#switcher-more'), 'the menu closes once used').toBeHidden(); } // noOverflow: the page never scrolls sideways. @@ -161,7 +172,9 @@ for (const vp of [ // Pin through the real control, on each one's own page: open it, then pin. for (const h of [agentPath, sableHref, quinceHref]) { await open(page, h); - expect((await pinToggle(page).boundingBox())!.width, 'the toggle is a 44px target').toBeGreaterThanOrEqual(44); + const mb = (await moreBtn(page).boundingBox())!; + expect(mb.width, 'the ⋯ is a 44px target').toBeGreaterThanOrEqual(44); + expect(mb.height, 'the ⋯ is a 44px target').toBeGreaterThanOrEqual(44); await pinHere(page, true, touch); } // On another session's page: Pins holds the three, and All leads with @@ -184,8 +197,8 @@ for (const vp of [ await Promise.all([page.waitForURL((u) => u.pathname + u.search === quinceHref), touch ? quince.tap() : quince.click()]); expect(new URL(page.url()).searchParams.get('session')).toBe('pinq'); - // UNPIN from the same toggle, on each page. - await expect(pinToggle(page)).toHaveAttribute('aria-pressed', 'true'); + // UNPIN from the same menu, on each page. + await expect(pinToggle(page)).toHaveAttribute('data-pinned', 'true'); await pinHere(page, false, touch); for (const h of [sableHref, agentPath]) { await open(page, h); @@ -227,7 +240,7 @@ for (const vp of [ await page.keyboard.press('Escape'); // Still pinned: its toggle, and the Pins view. await page.reload(); - await expect(pinToggle(page), 'still pinned').toHaveAttribute('aria-pressed', 'true'); + await expect(pinToggle(page), 'still pinned').toHaveAttribute('data-pinned', 'true'); await expect(viewRow(page, 'pins').locator('[data-switcher-current]')).toHaveCount(1); } finally { if (id) await pinVia(page, false, { kind: 'agent', agent_id: id }); @@ -259,25 +272,31 @@ for (const vp of [ // 🔴 A SHELL WINDOW CAN STILL BE PINNED. Its card's toggle pinned it while // the Agents list lived; with the list retired the one way to pin is the - // toggle on the session's own page ("open it, then pin"). A plain shell - // window's only page is its terminal, which draws the session switcher at - // its foot as every Terminal view does — so its pin toggle is the bar's, - // as on a Rendered view (the control), and the header has none. It pins + // bar's ⋯ menu on the session's own page ("open it, then pin"). A plain + // shell window's only page is its terminal, which draws the session + // switcher at its foot as every Terminal view does — so its pin is in the + // bar's ⋯ menu, as on a Rendered view (the control), and the header has + // none. It pins // and unpins, and the Pins view follows: on this page at once (the // answer's out-of-band rows), and on another session's page. - test("a shell window's terminal page carries the pin toggle in its bar, as a Rendered view does", async ({ page }) => { + test("a shell window's terminal page carries the pin in its bar's ⋯ menu, as a Rendered view does", async ({ page }) => { await open(page, sableHref); - await expect(page.locator('#session-switcher #switcher-pin'), "control: the Rendered view's bar has the toggle").toBeVisible(); + await expect(page.locator('#session-switcher #switcher-more #switcher-pin'), "control: the Rendered view's ⋯ menu has the pin").toBeAttached(); await page.goto(origin + shellHref); await expect(page.locator('#term-host-note, [aria-label="Terminal input"]').first(), 'the terminal page is drawn').toBeAttached(); - const pin = page.locator('#session-switcher #switcher-pin'); - await expect(pin, "the terminal bar's pin toggle").toBeVisible(); + const pin = page.locator('#session-switcher #switcher-more #switcher-pin'); + await expect(moreBtn(page), "the terminal bar's ⋯").toBeVisible(); await expect(page.locator('header #switcher-pin'), 'the header has no pin of its own').toHaveCount(0); - await expect(page.locator('#switcher-pin'), 'one pin toggle on the page').toHaveCount(1); + await expect(page.locator('#switcher-pin'), 'one pin on the page').toHaveCount(1); await expect(page.locator('#view-rendered, #view-terminal'), 'a terminal-only window has no view switch').toHaveCount(0); - await expect(pin).toHaveAttribute('aria-pressed', 'false'); + await expect(pin).toHaveAttribute('data-pinned', 'false'); + // The bar spans the screen here (pinned at the column's foot): its + // shown row's width, all of what the compact bar gives the pills. + const rowW = await page.locator('#session-switcher [data-switcher-list]:not([hidden])').evaluate((el) => el.clientWidth); + if (vp.use.viewport.width === 390) expect(rowW, 'the pills\' row on a 390px Terminal view (182px on the base, 258px here)').toBeGreaterThanOrEqual(240); + await moreBtn(page).click(); await pin.click(); - await expect(page.locator('#session-switcher #switcher-pin')).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('#session-switcher #switcher-pin')).toHaveAttribute('data-pinned', 'true'); // The answer's rows, out of band: this page's own Pins row holds it now. await expect(page.locator('#session-switcher [data-switcher-list="pins"] [data-switcher-current]'), "this page's Pins row follows").toHaveCount(1); try { @@ -285,8 +304,9 @@ for (const vp of [ await expect(viewRow(page, 'pins').locator(`a[href="${shellHref.replace(/&/g, '&')}"], a[href="${shellHref}"]`).first(), 'the Pins view holds the shell window').toBeAttached(); } finally { await page.goto(origin + shellHref); + await moreBtn(page).click(); await page.locator('#session-switcher #switcher-pin').click(); - await expect(page.locator('#session-switcher #switcher-pin')).toHaveAttribute('aria-pressed', 'false'); + await expect(page.locator('#session-switcher #switcher-pin')).toHaveAttribute('data-pinned', 'false'); } }); diff --git a/e2e/tests/switcher-search.spec.ts b/e2e/tests/switcher-search.spec.ts index 7135f7cc..a0b360e1 100644 --- a/e2e/tests/switcher-search.spec.ts +++ b/e2e/tests/switcher-search.spec.ts @@ -126,7 +126,7 @@ for (const vp of [ await ready(page); await page.addInitScript(() => { try { if (!sessionStorage.getItem('sws-cleared')) { localStorage.removeItem('muster.switcher.view'); sessionStorage.setItem('sws-cleared', '1'); } } catch (e) {} }); await openView(page); - await expect(toggle(page)).toHaveText('Recent'); + await expect(toggle(page).locator('[data-switcher-icon]:visible')).toHaveAttribute('data-switcher-icon', 'recent'); await expect(toggle(page)).toHaveAttribute('aria-haspopup', 'menu'); await expect(toggle(page)).toHaveAttribute('aria-expanded', 'false'); await expect(menu(page)).toBeHidden(); @@ -162,12 +162,12 @@ for (const vp of [ // A pick: the view is shown, the menu closes, and it is kept. await (vp.w === 390 ? menu(page).locator('[data-switcher-pick="all"]').tap() : menu(page).locator('[data-switcher-pick="all"]').click()); await expect(menu(page)).toBeHidden(); - await expect(toggle(page)).toHaveText('All'); + await expect(toggle(page).locator('[data-switcher-icon]:visible')).toHaveAttribute('data-switcher-icon', 'all'); await expect(page.locator('[data-switcher-list="all"]')).toBeVisible(); await expect(page.locator('[data-switcher-list="recent"]')).toBeHidden(); expect(await page.evaluate(() => localStorage.getItem('muster.switcher.view'))).toBe('all'); await page.reload(); - await expect(toggle(page)).toHaveText('All'); + await expect(toggle(page).locator('[data-switcher-icon]:visible')).toHaveAttribute('data-switcher-icon', 'all'); await expect(page.locator('[data-switcher-list="all"]')).toBeVisible(); // The keyboard: ArrowUp on the button opens with the shown view focused; @@ -184,7 +184,7 @@ for (const vp of [ await expect(menu(page).locator('[data-switcher-pick="pins"]')).toBeFocused(); await page.keyboard.press('Enter'); await expect(menu(page)).toBeHidden(); - await expect(toggle(page)).toHaveText('Pins'); + await expect(toggle(page).locator('[data-switcher-icon]:visible')).toHaveAttribute('data-switcher-icon', 'pins'); await expect(toggle(page)).toBeFocused(); await page.keyboard.press('ArrowDown'); await expect(menu(page)).toBeVisible(); @@ -192,7 +192,7 @@ for (const vp of [ await expect(menu(page)).toBeHidden(); await expect(toggle(page)).toHaveAttribute('aria-expanded', 'false'); await expect(toggle(page)).toBeFocused(); - await expect(toggle(page)).toHaveText('Pins'); + await expect(toggle(page).locator('[data-switcher-icon]:visible')).toHaveAttribute('data-switcher-icon', 'pins'); // A tap outside closes it, and picks nothing. await toggle(page).click(); @@ -200,7 +200,7 @@ for (const vp of [ const log = (await page.locator('#mv-log').boundingBox())!; await page.mouse.click(log.x + log.width / 2, log.y + Math.min(60, log.height / 2)); await expect(menu(page)).toBeHidden(); - await expect(toggle(page)).toHaveText('Pins'); + await expect(toggle(page).locator('[data-switcher-icon]:visible')).toHaveAttribute('data-switcher-icon', 'pins'); // Reduced motion: it opens at once, no transition. await page.emulateMedia({ reducedMotion: 'reduce' }); diff --git a/e2e/tests/terminal.spec.ts b/e2e/tests/terminal.spec.ts index 68002d52..d36bcc80 100644 --- a/e2e/tests/terminal.spec.ts +++ b/e2e/tests/terminal.spec.ts @@ -273,7 +273,7 @@ test.describe('the switcher at 390×844', () => { await expect(all).toBeVisible(); await expect(all.locator('[data-switcher-current]'), 'the page\'s own agent, current').toHaveAttribute('data-switcher-code', name); await expect(bar.locator('[data-switcher-list="recent"] [data-switcher-code="' + name + '"]'), 'not in Recent').toHaveCount(0); - await expect(bar.locator('#switcher-pin'), 'the bar\'s pin').toHaveCount(1); + await expect(bar.locator('#switcher-more #switcher-pin'), 'the bar\'s pin, in its ⋯ menu').toHaveCount(1); const cls = await readCls(page); expect(cls.total, `layout shift ${JSON.stringify(cls.shifts)}`).toBeLessThan(0.01); diff --git a/e2e/tests/uploads.spec.ts b/e2e/tests/uploads.spec.ts index 0afe48f1..c888c049 100644 --- a/e2e/tests/uploads.spec.ts +++ b/e2e/tests/uploads.spec.ts @@ -389,7 +389,7 @@ for (const vp of [ // view menu the bar's left button opens. await page.locator('#switcher-toggle').click(); await page.locator('#switcher-views [data-switcher-pick="all"]').click(); - await expect(page.locator('#switcher-toggle')).toHaveText('All'); + await expect(page.locator('#switcher-toggle').locator('[data-switcher-icon]:visible')).toHaveAttribute('data-switcher-icon', 'all'); await expect(bar.locator('[data-switcher-list="all"] [data-switcher-current]')).toHaveAttribute('data-switcher-entry', 'mirror'); } finally { await ctx.close(); diff --git a/internal/api/pins_test.go b/internal/api/pins_test.go index 540dc6cb..18f76f5f 100644 --- a/internal/api/pins_test.go +++ b/internal/api/pins_test.go @@ -377,9 +377,9 @@ func TestTheSwitcherPinToggleAnswersWithItselfAndTheBar(t *testing.T) { t.Fatal("agent 7 was not pinned") } if !strings.HasPrefix(body, `Unpin this session`) || !strings.Contains(body, `name="back" value="/agents/idle-newest"`) || !strings.Contains(body, `name="agent_id" value="7"`) { - t.Errorf("the answer is not the toggle, pressed, posting Unpin for agent 7:\n%s", body) + t.Errorf("the answer is not the ⋯ menu's pin item, pinned, posting Unpin for agent 7:\n%s", body) } pinsRow := regexp.MustCompile(`
(.*?)
(\d+)`).FindStringSubmatch(body) if pinsRow == nil || !strings.Contains(pinsRow[1], `aria-current="page"`) || pinsRow[2] != "1" { @@ -388,7 +388,7 @@ func TestTheSwitcherPinToggleAnswersWithItselfAndTheBar(t *testing.T) { rec = pinPost(t, s, h, "/agents/pins/delete", agent7, true) body = rec.Body.String() - if mp.pinFor(authz.OperatorUserID).Agents[7] || !strings.Contains(body, `aria-pressed="false" aria-label="Pin this session"`) || + if mp.pinFor(authz.OperatorUserID).Agents[7] || !strings.Contains(body, `role="menuitem" data-pinned="false"`) || !strings.Contains(body, `Pin this session`) || !strings.Contains(body, `0`) { t.Errorf("unpin did not answer the unpressed toggle and an empty Pins count:\n%s", body) } @@ -441,7 +441,7 @@ func TestAPinFromATerminalViewAnswersWithTheBarsRows(t *testing.T) { t.Run(c.name, func(t *testing.T) { rec := pinPost(t, s, h, "/agents/pins", c.form, true) body := rec.Body.String() - if rec.Code != http.StatusOK || !strings.Contains(body, `id="switcher-pin" type="submit" aria-pressed="true"`) { + if rec.Code != http.StatusOK || !strings.Contains(body, `id="switcher-pin" type="submit" role="menuitem" data-pinned="true"`) { t.Fatalf("the pin = %d, not the pressed toggle:\n%s", rec.Code, body) } if !strings.Contains(body, `name="back" value="`+strings.ReplaceAll(c.form.Get("back"), "&", "&")+`"`) { diff --git a/internal/ui/pins.go b/internal/ui/pins.go index 2a3d83e0..20b11d88 100644 --- a/internal/ui/pins.go +++ b/internal/ui/pins.go @@ -5,8 +5,8 @@ import ( ) // The pin routes (internal/api pins.go). The one toggle that posts here is the -// session switcher's (SwitcherPinToggle): a form with hx-post, answered with -// the toggle in its new state and the bar's rows out of band. It also carries +// session switcher's (SwitcherPinToggle, the item of the bar's ⋯ menu): a +// form with hx-post, answered with the item in its new state and the bar's rows out of band. It also carries // method/action, and without JavaScript the server sends the browser back to // the session's page. const ( @@ -30,5 +30,5 @@ func mirrorPinFields(m MirrorCardView) [][2]string { // ⚠ A PIN IS SET AND CLEARED FROM THE SESSION SWITCHER ONLY. The retired Agents // list drew pinned entries first and its cards carried a pin toggle; that page // is gone. A pinned entry is still ordered first in the switcher (orderList) -// and shown in its Pins view, and the bar's toggle (switcher_pin.go) pins or +// and shown in its Pins view, and the bar's ⋯ menu (switcher_pin.go) pins or // unpins the page's own session. diff --git a/internal/ui/switcher.go b/internal/ui/switcher.go index 9d5d1404..4fcfebf3 100644 --- a/internal/ui/switcher.go +++ b/internal/ui/switcher.go @@ -23,7 +23,11 @@ import ( // depend on sending. // // FOUR VIEWS, picked from a menu the button on the bar's left opens (it -// animates open UPWARD, the bar being at the screen's bottom — switcherViewMenu): +// animates open UPWARD, the bar being at the screen's bottom — switcherViewMenu). +// The button draws only the shown view's ICON (the operator asked for icons +// in place of the views' text labels) — a clock, a bell, a pin, a grid — +// named for a screen reader and on hover by its aria-label and title; the menu +// lists each view's icon beside its words: // // - Recent: the sessions the viewer last OPENED — the server-side, // cross-device list internal/recents keeps (recents.go), newest first. @@ -59,14 +63,22 @@ import ( // text field) opens the command palette (palette.go): the same box from either // button, finding any session the All view holds. // -// ON A SESSION'S PAGE THE BAR ALSO HAS A PIN (switcherPinToggle), beside the -// search: it pins or unpins the page's own session — an agent, or the mirrored -// window — in the viewer's pins (internal/pins), the set the Pins view shows. +// ON A SESSION'S PAGE THE BAR ALSO HAS A ⋯ MENU (switcherMoreButton, at its +// right end, after the search) holding Pin / Unpin (SwitcherPinToggle): it +// pins or unpins the page's own session — an agent, or the mirrored window — +// in the viewer's pins (internal/pins), the set the Pins view shows. A bar +// with no page session draws no ⋯ (the pin is all it holds). +// +// 🔴 THE BAR IS TIGHT (switcherIconButtonClass, switcherPinnedPad, +// switcherPillClass): icon buttons keep their 44×44 targets, but the bar has +// no inset of its own beyond the safe area, no gaps between its controls, and +// small pill padding — so the row of pills, what the bar is for, gets the +// width. // // The bar draws no "+": the task-less dispatch sheet's "+", which the retired // Agents list's docked bar carried, is the sidebar's (navDeployButton). // -// 🔴 THE MENU AND THE PALETTE ARE POPOVERS (the top layer), so opening either +// 🔴 THE MENUS AND THE PALETTE ARE POPOVERS (the top layer), so opening any // moves nothing on the page: no layout shift, and no clipping by the // composer's box. // @@ -75,8 +87,9 @@ import ( // filled), its row is h-11 whatever it holds (an entry, an empty-state line), // and a view switch only toggles which row is shown. The viewer's choice of // view is a per-device convenience kept in localStorage; the inline script -// right after the bar applies it before the bar is painted, so a page that -// loads in "All" never shows "Recent" first. +// right after the bar applies it — the row, and the view button's icon — +// before the bar is painted, so a page that loads in "All" never shows +// "Recent" (or its clock) first. // // 🔴 IT SCROLLS SIDEWAYS INSIDE ITSELF (each row is overflow-x-auto, min-w-0), // so thirty long names never widen the page. @@ -102,16 +115,28 @@ const ( SwitcherAll = "all" ) -// switcherViews is the menu's views, in order: each view's key, its button -// label (short: the button is a fixed 4.5rem), its menu item's words, and what -// its row says when it holds nothing. -var switcherViews = []struct{ key, label, menu, empty string }{ - {SwitcherRecent, "Recent", "Recent", "No other recent sessions"}, - {SwitcherWaiting, "Waiting", "Waiting on you", "Nothing is waiting on you"}, - {SwitcherPins, "Pins", "Pins", "Nothing pinned"}, - {SwitcherAll, "All", "All", "No other sessions"}, +// switcherViews is the menu's views, in order: each view's key, its short name +// (what the view button's accessible name and title say, and its icon's +// label — the button draws only the icon), its icon (switcherIcon*), its +// menu item's words, and what its row says when it holds nothing. +var switcherViews = []struct{ key, name, icon, menu, empty string }{ + {SwitcherRecent, "Recent", switcherIconClock, "Recent", "No other recent sessions"}, + {SwitcherWaiting, "Waiting", switcherIconBell, "Waiting on you", "Nothing is waiting on you"}, + {SwitcherPins, "Pins", switcherIconPin, "Pins", "Nothing pinned"}, + {SwitcherAll, "All", switcherIconGrid, "All", "No other sessions"}, } +// The views' icons, each a whole 16px inline SVG, decorative (aria-hidden): +// what names the view is the element around it (switcherBar's icon span, the +// menu item's words). A clock for Recent, a bell for Waiting, the pin the +// pins use for Pins (pins.go), a grid of four for All. +const ( + switcherIconClock = `` + switcherIconBell = `` + switcherIconPin = pinIcon + switcherIconGrid = `` +) + // switcherRecentMax is how many entries Recent shows. const switcherRecentMax = 8 @@ -364,13 +389,13 @@ func (s *Switcher) view(key string) []SwitcherItem { } // switcherBar is the bar: the view button, one row per view (Recent shown, the -// others hidden until the script picks them), and the search button — then -// the view menu and the search palette, both popovers. pinned: the bar spans -// the screen and is the last thing above its edge — the last row of the -// Rendered view's pinned composer, or of a Terminal view's column (in flow, at -// the column's foot) — so it is inset like a composer's rows and padded by the -// safe-area inset; otherwise (the agent page, inside its already-inset pane) -// it lines up with the form above it. +// others hidden until the script picks them), the search button and, on a +// session's page, the ⋯ — then the view menu and the ⋯ menu, both popovers. +// pinned: the bar spans the screen and is the last thing above its edge — the +// last row of the Rendered view's pinned composer, or of a Terminal view's +// column (in flow, at the column's foot) — so it is inset (switcherPinnedPad) +// and padded by the safe-area insets; otherwise (the agent page, inside its +// already-inset pane) it lines up with the form above it. // Nothing when s is nil. func switcherBar(s *Switcher, pinned bool) g.Node { if s == nil { @@ -400,16 +425,17 @@ func switcherBar(s *Switcher, pinned bool) g.Node { // also land with the