Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions docs/mirroring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion e2e/tests/agents-list-ux.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
43 changes: 28 additions & 15 deletions e2e/tests/agents-switcher.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -339,43 +339,56 @@ 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');
await expect(row(page, 'pins').locator('[data-switcher-current]')).toHaveText('tundra:1');
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 {
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions e2e/tests/cls-long-transcript.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<form id="chat-form"') },
{ label: 'inside the session switcher', at: (html: string) => { 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('<span data-switcher-label') },
// Inside the switcher's toggle, between its first view icon (Recent's)
// and the others.
{ label: "inside the switcher's toggle", at: (html: string) => html.indexOf('<span data-switcher-icon="waiting"') },
// Inside #chat-form, straight after its opening tag: CI's frame (the
// form 58px with only its first <input>, then 102px) made deterministic.
{ label: 'inside the composer', at: (html: string) => { const n = html.indexOf('<form id="chat-form"'); return n < 0 ? -1 : html.indexOf('>', n) + 1; } },
Expand Down
Loading
Loading