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('