From 76fdb449f68ca581d7bc2ab52925318e5eb826b2 Mon Sep 17 00:00:00 2001 From: Jack <72348727+Jack-GitHub12@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:30:26 -0600 Subject: [PATCH 1/2] fix(viewer): Enter and Space press the focused control, and a redraw keeps focus A page or list shortcut no longer takes a key the focused element uses itself: Enter on a button, link or summary, Space on a button, and the scroll keys in a step detail that has more to scroll. Before, Enter on a filter chip opened the selected session, Enter on a button inside an open row folded the row, and Space on a replay button started Play. A redraw that removes the focused control moves focus to the control now in its place, else to its pane or the page, instead of leaving it on the body. --- CHANGELOG.md | 1 + docs/guides/explore.md | 3 + packages/coding.sh/src/viewer/ui/index.html | 78 +++++-- packages/coding.sh/test/viewer/browser.ts | 32 +++ .../test/viewer/keyboard-browser.test.ts | 190 ++++++++++++++++++ .../coding.sh/test/viewer/ui-fixes.test.ts | 2 +- 6 files changed, 287 insertions(+), 19 deletions(-) create mode 100644 packages/coding.sh/test/viewer/keyboard-browser.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d0c7ba07..dcf150e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ grouped by area, with links to the docs for the details. - **Serve.** `serve` honors the `[proxy]` section of `--config`, the collector refuses a host other than loopback, and the proxy rejects bad upstream URLs. [Production](https://github.com/jackulau/coding.sh/blob/main/docs/PRODUCTION.md) - **SDK.** `require()` of the TypeScript SDK works, and both SDKs warn once on a bad collector URL. [SDK](https://github.com/jackulau/coding.sh/blob/main/docs/reference/sdk.md) - **Viewer.** The live header refreshes in place, empty fields no longer show "null", server-rendered rows match the page's, and an unknown session id costs one request. [Explore](https://github.com/jackulau/coding.sh/blob/main/docs/guides/explore.md) +- **Viewer.** Enter presses the focused button, filter chip or link, and Space the focused button, also inside an open list row. Before, they opened the selected session, folded the row or started the replay. The arrow keys scroll the step detail when it has focus, and a redraw moves focus to the rebuilt control or its pane instead of dropping it. [Explore](https://github.com/jackulau/coding.sh/blob/main/docs/guides/explore.md#open-the-viewer) - **Packaging.** The published package.json no longer has `source` export conditions that point at files the tarball does not hold. `smoke.sh` runs under a throwaway home, and the GitHub Action has an `annotate` input. [Release](https://github.com/jackulau/coding.sh/blob/main/docs/RELEASE.md) - **Site.** The docs sidebar and the phone menu open on the current page, and the phone menu fits the screen. A terminal block no longer touches the paragraph above it, wide code blocks scroll by keyboard, code copy selects the code when the browser blocks the clipboard, and search results open without a redirect. [Docs](https://coding.sh/docs) - **Site.** Changelog notes link docs to their page on the site instead of showing raw Markdown or going to GitHub, and the changelog counts only shipped releases. [Changelog](https://coding.sh/changelog) diff --git a/docs/guides/explore.md b/docs/guides/explore.md index 09f124e4..ed534be2 100644 --- a/docs/guides/explore.md +++ b/docs/guides/explore.md @@ -145,6 +145,9 @@ token for this run of `ui`; the viewer needs it. step in its session. - **Replay** (R on a session). Plays the session back one step at a time. See [Replay a session](https://coding.sh/docs/use-cases/replay-agent-session). +- **Keys.** ? lists every shortcut. j and k move through the + list from anywhere but a text field. A focused button keeps Enter and Space, a focused link + keeps Enter, and the arrow keys scroll the step detail when it has focus and more to show. ## Follow a running session diff --git a/packages/coding.sh/src/viewer/ui/index.html b/packages/coding.sh/src/viewer/ui/index.html index 882306b6..e4a02d20 100644 --- a/packages/coding.sh/src/viewer/ui/index.html +++ b/packages/coding.sh/src/viewer/ui/index.html @@ -1590,8 +1590,33 @@ } /** Empty `el`, then fill it like h() does: null, undefined and false are skipped (the DOM's own replaceChildren would write them as text). */ function swap(el, ...children) { - el.replaceChildren(); - return append(el, children); + return keepFocus(el, () => { el.replaceChildren(); return append(el, children); }); +} +/** + * Redraw inside `root` without dropping keyboard focus onto . When the redraw removes the focused + * control, focus goes to the control now in its place (a rebuilt chip or button), else to the nearest + * focusable container. + */ +function keepFocus(root, redraw) { + const was = document.activeElement; + if (!was || was === root || !root.contains(was)) return redraw(); + const path = []; + for (let n = was; n !== root; n = n.parentNode) path.unshift([...n.parentNode.children].indexOf(n)); + const out = redraw(); + if (was.isConnected) return out; + let now = root; + for (const i of path) now = now && now.children[i]; + // A hidden or disabled control refuses focus: the container is next. + for (const el of [now && now.tagName === was.tagName ? now : null, root.closest("[tabindex]")]) { + if (!el) continue; + el.focus({ preventScroll: true }); + if (document.activeElement === el) break; + } + return out; +} +/** Remove a control that has done its job (a "show more" button), keeping focus as keepFocus does. */ +function drop(el) { + if (el.parentNode) keepFocus(el.parentNode, () => el.remove()); } const SVG_NS = "http://www.w3.org/2000/svg"; /** SVG element; attributes are numbers and class names only, never links. */ @@ -1941,6 +1966,20 @@ function smoothScroll() { return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth"; } +/** + * A key the focused element uses itself, so no page shortcut takes it: Enter on a button, link or + * summary, Space on a button or summary, and the scroll keys in a pane that has more to scroll. + */ +const SCROLL_KEYS = ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End"]; +function ownKey(e) { + const t = e.target; + if (!t || !t.closest) return false; + if (e.key === "Enter") return !!t.closest("button, a[href], summary"); + if (e.key === " ") return !!t.closest("button, summary"); + if (!SCROLL_KEYS.includes(e.key)) return false; + const pane = t.closest("[role=region][tabindex]"); + return !!pane && pane.scrollHeight > pane.clientHeight; +} function typing(e) { const t = e.target; // A slider (the replay scrubber) takes the page's keys: they do the same thing there. @@ -1987,9 +2026,10 @@ function setView(node, focus, keys) { if (cleanup) { cleanup(); cleanup = null; } closeOverlay(); + // Another page: focus goes to the page itself, not to whichever control sits where the old one was. + if (focus || ((!keys || keys.scope !== viewKeys.scope) && main.contains(document.activeElement))) main.focus({ preventScroll: true }); viewKeys = keys || { scope: "none", actions: {} }; swap(main, node); - if (focus) main.focus({ preventScroll: true }); } function parseHash() { const raw = location.hash.replace(/^#\/?/, ""); @@ -2107,6 +2147,7 @@ } chordAt = 0; if (e.key === "?") { e.preventDefault(); openHelp(); return; } + if (ownKey(e)) return; // A mode (replay) can sit on top of its view: its keys first, then the view's. for (const scope of viewKeys.fallback ? [viewKeys.scope, viewKeys.fallback] : [viewKeys.scope]) { for (const k of KEYMAP) { @@ -2130,7 +2171,7 @@ if (overlay.hidden) return; overlay.hidden = true; swap(overlay); - if (overlayReturn && overlayReturn.isConnected) overlayReturn.focus({ preventScroll: true }); + (overlayReturn && overlayReturn.isConnected ? overlayReturn : main).focus({ preventScroll: true }); overlayReturn = null; } // Keyboard-opened and used many times a day: no open or close animation. @@ -3086,7 +3127,7 @@ return state === "out" ? chip : h("div", { class: "facet-row" }, chip, h("button", { class: "not-btn", type: "button", tabindex: "-1", "aria-label": "Exclude " + name, onclick: () => pick(v, true) }, "not")); }), - rows.length > FACET_LIMIT ? h("button", { class: "more-link", type: "button", onclick: () => { if (open) list.showAll.delete(key); else list.showAll.add(key); renderSessions(view); } }, + rows.length > FACET_LIMIT ? h("button", { class: "more-link", type: "button", onclick: () => { if (open) list.showAll.delete(key); else list.showAll.add(key); renderSessions(view); $(`[data-filter="${key}"] .more-link`, view)?.focus({ preventScroll: true }); } }, open ? "Show fewer" : "Show " + fmtNum(rows.length - FACET_LIMIT) + " more") : null); return h("section", { class: "facet", role: "group", "aria-label": label + " filter", "data-filter": key, "data-collapsed": String(collapsed) }, head, items); }; @@ -3304,7 +3345,7 @@ lb.addEventListener("pointerleave", () => sessionIntent.leave()); lb.addEventListener("pointerdown", (e) => { const id = rowSid(e); if (id) sessionIntent.press(id); }); lb.addEventListener("keydown", (e) => { - if (e.key === "Enter" || e.key === " ") { + if ((e.key === "Enter" || e.key === " ") && !ownKey(e)) { e.preventDefault(); e.stopPropagation(); const r = list.rows[list.active]; @@ -3430,7 +3471,7 @@ list.events.forEach((ev, i) => lb.append(eventRowEl(ev, i))); lb.setAttribute("aria-activedescendant", "opt-" + list.active); lb.addEventListener("keydown", (e) => { - if (e.key === "Enter" || e.key === " ") { + if ((e.key === "Enter" || e.key === " ") && !ownKey(e)) { e.preventDefault(); e.stopPropagation(); const ev = list.events[list.active]; @@ -3862,7 +3903,7 @@ const eventsPath = "/api/sessions/" + encodeURIComponent(id) + "/events"; const viaHit = fromHit === id; fromHit = ""; - const replaceBody = (node) => { view.lastChild.replaceWith(h("div", { class: "wrap" }, node)); }; + const replaceBody = (node) => keepFocus(view, () => { view.lastChild.replaceWith(h("div", { class: "wrap" }, node)); }); let session, events, labels, children, parent = null; const sessionPath = "/api/sessions/" + encodeURIComponent(id); // The session record is asked for once and first: an id that is not in the index is a 404 here and @@ -5257,7 +5298,7 @@ const full = JSON.stringify(rel ? relPath(v) : v); if (full.length <= 400) { s.textContent = full; return s; } s.textContent = full.slice(0, 300) + "…"; - const more = h("button", { class: "more", type: "button", onclick: () => { s.textContent = full; more.remove(); } }, "+" + fmtNum(full.length - 300) + " chars"); + const more = h("button", { class: "more", type: "button", onclick: () => { s.textContent = full; drop(more); } }, "+" + fmtNum(full.length - 300) + " chars"); return h("span", null, s, " ", more); } if (typeof v === "number") return h("span", { class: "n" }, String(v)); @@ -5286,7 +5327,7 @@ const next = entries.slice(shown, shown + 1000); shown += next.length; more.before(...next.map(([ck, cv]) => jsonNode(ck, cv, depth + 1, rel))); - if (shown >= entries.length) more.remove(); else more.textContent = "show " + fmtNum(entries.length - shown) + " more"; + if (shown >= entries.length) drop(more); else more.textContent = "show " + fmtNum(entries.length - shown) + " more"; }); d.append(h("div", { class: "kv" }, more)); } @@ -5471,7 +5512,7 @@ const show = (k) => swap(ul, ...files.slice(0, k).map((f) => h("li", { class: "truncate", title: f }, relPath(f)))); show(200); const wrap = h("div", { class: "section" }, h("p", { class: "meta" }, plural(files.length, "file")), ul); - if (files.length > 200) wrap.append(h("button", { class: "btn btn-sm show-all", type: "button", onclick: (e) => { show(files.length); e.currentTarget.remove(); } }, "Show all " + fmtNum(files.length))); + if (files.length > 200) wrap.append(h("button", { class: "btn btn-sm show-all", type: "button", onclick: (e) => { const b = e.currentTarget; show(files.length); drop(b); } }, "Show all " + fmtNum(files.length))); return wrap; } function todoList(todos) { @@ -5592,7 +5633,7 @@ h("button", { class: "btn btn-sm", type: "button", onclick: () => load(true) }, "Load all"))); } swap(body, ...nodes); - loadBtn.remove(); + drop(loadBtn); bar.append(h("span", { class: "meta" }, fmtBytes(size || text.length))); } catch (e) { loadBtn.disabled = false; @@ -5665,7 +5706,7 @@ nodes.push(section("system prompt diff", diffGrid(d.system.lines.map((l) => l.op === "+" ? { t: "add", b: b++, text: l.text } : l.op === "-" ? { t: "del", a: a++, text: l.text } : l.text === "…" ? { t: "hunk", text: "…" } : { t: "ctx", a: a++, b: b++, text: l.text })))); } swap(body, ...nodes); - btn.remove(); + drop(btn); } catch (e) { btn.disabled = false; swap(body, h("p", { class: "note" }, e.status === 404 ? "This is the first model call of its session." : e.message)); @@ -5998,6 +6039,7 @@ if (items.length) { lb.setAttribute("aria-activedescendant", "exp-0"); lb.addEventListener("keydown", (ev) => { + if (ownKey(ev)) return; if (ev.key === "Enter") { ev.preventDefault(); ev.stopPropagation(); location.hash = expHref(items[active].id); } else if (ev.key === " ") { ev.preventDefault(); ev.stopPropagation(); toggleSel(active); } }); @@ -6017,7 +6059,7 @@ h("div", { class: "stack" }, harness.map((c) => c.node()))); } if (tasks.items.length) body.append(tasksPanel(tasks.items)); - view.lastChild.replaceWith(body); + keepFocus(view, () => view.lastChild.replaceWith(body)); keys.actions = items.length ? { down: () => { setActive(active + 1, true); lb.focus({ preventScroll: true }); }, @@ -6250,7 +6292,7 @@ det.hidden = !det.hidden; row.setAttribute("aria-expanded", String(!det.hidden)); } - lb.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); toggle(active); } }); + lb.addEventListener("keydown", (e) => { if ((e.key === "Enter" || e.key === " ") && !ownKey(e)) { e.preventDefault(); e.stopPropagation(); toggle(active); } }); view.append( h("div", { class: "case-tools" }, h("div", { class: "grp" }, h("h2", null, "Cases"), caseMeta), h("div", { class: "grp" }, chips, search)), h("div", { class: "panel table case-table" }, @@ -6451,7 +6493,7 @@ det.hidden = !det.hidden; row.setAttribute("aria-expanded", String(!det.hidden)); } - lb.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); toggle(active); } }); + lb.addEventListener("keydown", (e) => { if ((e.key === "Enter" || e.key === " ") && !ownKey(e)) { e.preventDefault(); e.stopPropagation(); toggle(active); } }); append(view, [ h("div", { class: "case-tools" }, h("h2", null, "Cases"), chips), h("div", { class: "panel table cmp-table" }, @@ -7241,7 +7283,7 @@ if (scroll) cards[i].scrollIntoView({ block: "nearest" }); } const lb = h("div", { class: "stack bench-list", role: "listbox", tabindex: "0", "aria-label": "Benchmarks. j and k to move, Enter to open.", "aria-activedescendant": "bench-0" }, cards); - lb.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); location.hash = benchHref(items[active].name); } }); + lb.addEventListener("keydown", (e) => { if (e.key === "Enter" && !ownKey(e)) { e.preventDefault(); e.stopPropagation(); location.hash = benchHref(items[active].name); } }); swap(slot, lb); keys.actions = { down: () => { setActive(active + 1, true); lb.focus({ preventScroll: true }); }, @@ -7531,7 +7573,7 @@ det.hidden = !det.hidden; row.setAttribute("aria-expanded", String(!det.hidden)); } - flb.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); toggleF(factive); } }); + flb.addEventListener("keydown", (e) => { if ((e.key === "Enter" || e.key === " ") && !ownKey(e)) { e.preventDefault(); e.stopPropagation(); toggleF(factive); } }); renderFails(); // ---- run details diff --git a/packages/coding.sh/test/viewer/browser.ts b/packages/coding.sh/test/viewer/browser.ts index 3f9b0cce..26415d81 100644 --- a/packages/coding.sh/test/viewer/browser.ts +++ b/packages/coding.sh/test/viewer/browser.ts @@ -37,6 +37,22 @@ export function findChrome(env: NodeJS.ProcessEnv = process.env, platform: NodeJ /** Longest a DevTools call may wait for its answer; a page eval that awaits a slow promise stays well under it. */ const SEND_TIMEOUT_MS = 30_000; +/** Keys Page.press knows beyond single characters: the DOM code, the virtual key code, and the text a key types (none for a navigation key). */ +const NAMED_KEYS: Record = { + Enter: { code: "Enter", vk: 13, text: "\r" }, + " ": { code: "Space", vk: 32, text: " " }, + Tab: { code: "Tab", vk: 9 }, + Escape: { code: "Escape", vk: 27 }, + ArrowLeft: { code: "ArrowLeft", vk: 37 }, + ArrowUp: { code: "ArrowUp", vk: 38 }, + ArrowRight: { code: "ArrowRight", vk: 39 }, + ArrowDown: { code: "ArrowDown", vk: 40 }, + PageUp: { code: "PageUp", vk: 33 }, + PageDown: { code: "PageDown", vk: 34 }, + End: { code: "End", vk: 35 }, + Home: { code: "Home", vk: 36 }, +}; + type Pending = { resolve: (v: any) => void; reject: (e: Error) => void }; class Cdp { @@ -109,6 +125,11 @@ export interface Page { /** Poll an expression until it is truthy; returns its value. */ waitFor(expr: string, what?: string, timeoutMs?: number): Promise; viewport(width: number, height: number, mobile?: boolean): Promise; + /** + * Press and release a key as a person would ("Enter", " ", "ArrowDown", "j"). Chrome runs the key's + * default action (a button's click, a scroll), which a KeyboardEvent dispatched from the page never does. + */ + press(key: string): Promise; /** Uncaught exceptions and console.error calls on this page so far. */ errors: string[]; /** A raw DevTools call on this page (Input.dispatchMouseEvent, Network.emulateNetworkConditions, ...). */ @@ -210,6 +231,17 @@ export async function launchBrowser(chrome: string): Promise { await send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: mobile ? 2 : 1, mobile }); await send("Emulation.setTouchEmulationEnabled", { enabled: mobile }); }, + async press(key) { + const named = NAMED_KEYS[key]; + const code = named ? named.code : /^[a-z]$/i.test(key) ? "Key" + key.toUpperCase() : ""; + const vk = named ? named.vk : key.toUpperCase().charCodeAt(0); + const text = named ? named.text : key; + const base = { key, code, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk }; + // A headless page is not the focused window until told so, and an unfocused page gets no keys. + await send("Emulation.setFocusEmulationEnabled", { enabled: true }); + await send("Input.dispatchKeyEvent", text === undefined ? { type: "rawKeyDown", ...base } : { type: "keyDown", ...base, text }); + await send("Input.dispatchKeyEvent", { type: "keyUp", ...base }); + }, async close() { off(); await cdp.send("Target.closeTarget", { targetId }).catch(() => {}); diff --git a/packages/coding.sh/test/viewer/keyboard-browser.test.ts b/packages/coding.sh/test/viewer/keyboard-browser.test.ts new file mode 100644 index 00000000..b4be45a3 --- /dev/null +++ b/packages/coding.sh/test/viewer/keyboard-browser.test.ts @@ -0,0 +1,190 @@ +// Keyboard and focus in the viewer, with real key presses in headless Chrome: a focused control keeps +// its own keys (Enter and Space press it, arrows scroll a scrolling pane) while the page's shortcuts +// take the rest, and a re-render never leaves focus on . +import assert from "node:assert/strict"; +import { after, before, describe, test } from "node:test"; +import type { AgentEvent } from "../../src/core/types.ts"; +import { startViewer, type Viewer } from "../../src/viewer/server.ts"; +import { findChrome, launchBrowser, type Browser, type Page } from "./browser.ts"; +import { FakeBlobs, FakeIndex, event, experimentResult, session } from "./fakes.ts"; + +const chrome = findChrome(); +if (!chrome && process.env.GITHUB_ACTIONS) throw new Error("no Chrome found on CI; set CODING_SH_TEST_CHROME"); +const T0 = 1_762_400_000_000; +const LONG = "5a1d2c3b-0f4e-4a6b-8c7d-9e0f1a2b3c4d"; +const RP = "7f3c1e2a-5b6d-4e8f-9a0b-1c2d3e4f5a6b"; + +describe("viewer keyboard and focus in a browser", { skip: chrome ? false : "no Chrome/Chromium found (set CODING_SH_TEST_CHROME)" }, () => { + const index = new FakeIndex(); + let v: Viewer; + let browser: Browser; + let page: Page | undefined; + const at = (id: string, n: number, over: Partial) => ({ ...event(id, n), ts: T0 + n * 1000, cwd: "/work/demo", ...over }); + + before(async () => { + await index.upsertSession(session(LONG, { startedAt: T0, endedAt: T0 + 60_000, title: "Long output", stats: { events: 4, turns: 1, toolCalls: 1, toolErrors: 0, filesChanged: 0 } })); + await index.putEvents([ + at(LONG, 1, { kind: "user.message", payload: { text: "list the files" } }), + at(LONG, 2, { kind: "tool.start", payload: { tool: "Bash", toolUseId: "b1", input: { command: "ls -R" } } }), + at(LONG, 3, { kind: "tool.end", durationMs: 900, payload: { tool: "Bash", toolUseId: "b1", ok: true, output: Array.from({ length: 600 }, (_, i) => `src/file-${i}.ts`).join("\n") } }), + at(LONG, 4, { kind: "assistant.message", payload: { text: "Listed." } }), + ]); + await index.upsertSession(session(RP, { startedAt: T0 - 60_000, endedAt: T0, title: "Replay me", repo: { root: "/work/demo", head: "a41c9e2b00" }, stats: { events: 8, turns: 1, toolCalls: 3, toolErrors: 1, filesChanged: 1 } })); + await index.putEvents([ + at(RP, 1, { kind: "user.message", payload: { text: "write a.txt then test" } }), + at(RP, 2, { kind: "tool.start", payload: { tool: "Write", toolUseId: "w1", input: { file_path: "/work/demo/a.txt", content: "one\ntwo\n" } } }), + at(RP, 3, { kind: "tool.end", payload: { tool: "Write", toolUseId: "w1", ok: true } }), + at(RP, 4, { kind: "tool.start", payload: { tool: "Edit", toolUseId: "e1", input: { file_path: "/work/demo/a.txt", old_string: "two", new_string: "TWO" } } }), + at(RP, 5, { kind: "tool.end", payload: { tool: "Edit", toolUseId: "e1", ok: true } }), + at(RP, 6, { kind: "tool.start", payload: { tool: "Bash", toolUseId: "b1", input: { command: "npm test" } } }), + at(RP, 7, { kind: "tool.end", durationMs: 1200, payload: { tool: "Bash", toolUseId: "b1", ok: false, exitCode: 1, output: "1 failing" } }), + at(RP, 8, { kind: "assistant.message", payload: { text: "The test fails." } }), + ]); + const exp = experimentResult("exp-prod-smoke"); + // The failing case's output is long enough to clip, so its open row holds a "Show all" button. + exp.runs[1]!.output = Array.from({ length: 600 }, (_, i) => `line ${i}`).join("\n"); + v = await startViewer({ + index, + blobs: new FakeBlobs(), + port: 0, + updateStatus: async () => ({ disabled: "test" }) as any, + experiments: { listExperiments: async () => [exp], getExperiment: async (id) => (id === exp.id ? exp : undefined) }, + }); + browser = await launchBrowser(chrome!); + }); + after(async () => { + await page?.close(); + await browser?.close(); + await v?.close(); + }); + + async function open(hash: string, height = 900): Promise { + await page?.close(); + page = await browser.newPage(); + await page.viewport(1280, height); + await page.goto(v.url + hash); + return page; + } + const focused = "(() => { const a = document.activeElement; return a === document.body ? 'body' : a.id ? '#' + a.id : a.tagName.toLowerCase() + (a.className ? '.' + String(a.className).split(' ')[0] : ''); })()"; + const focusOn = (p: Page, expr: string) => p.eval(`(() => { const el = ${expr}; el.focus(); return document.activeElement === el; })()`); + + test("Enter and Space on a focused filter chip press the chip; they do not open a session", async () => { + const p = await open("#/"); + const chip = "document.querySelector('[data-filter=agent] .chip')"; + await p.waitFor(`${chip} && document.querySelector('[role=option]')`, "the list and its filters"); + assert.ok(await focusOn(p, chip)); + await p.press("Enter"); + await p.waitFor(`${chip}.getAttribute('aria-pressed') === 'true'`, "Enter picks the filter"); + assert.doesNotMatch(await p.eval("location.hash"), /^#\/s\//); + assert.equal(await p.eval(`document.activeElement === ${chip}`), true, "the chip keeps focus"); + await p.press(" "); + await p.waitFor(`${chip}.getAttribute('aria-pressed') === 'false'`, "Space takes it back out"); + assert.doesNotMatch(await p.eval("location.hash"), /^#\/s\//); + // The list's own keys still work from a button: j is not a key a button uses. + await p.press("j"); + assert.equal(await p.eval("document.activeElement.getAttribute('role')"), "listbox"); + assert.deepEqual(p.errors, []); + }); + + test("Enter on the Events tab follows the link, and focus lands on the page, not on ", async () => { + const p = await open("#/"); + const tab = "document.querySelector('.view-tabs a[href^=\"#/events\"]')"; + await p.waitFor(`${tab} && document.querySelector('[role=option]')`, "the tabs"); + assert.ok(await focusOn(p, tab)); + await p.press("Enter"); + await p.waitFor("location.hash.startsWith('#/events')", "the Events tab"); + await p.waitFor("document.querySelector('.view-tabs a[aria-current=page]')?.textContent.startsWith('Events')", "the Events rows"); + assert.equal(await p.eval(focused), "#main"); + }); + + test("in the replay, Space on a focused button presses that button instead of Play", async () => { + const p = await open("#/s/" + RP + "?step=2"); + await p.waitFor("document.querySelector('.rp-bar') && !document.querySelector('.rp-bar').hidden && /^2 \\/ /.test(document.querySelector('.rp-pos').textContent)", "the replay at step 2"); + assert.ok(await focusOn(p, "document.querySelector('.rp-bar [aria-label=\"Next step\"]')")); + await p.press(" "); + await p.waitFor("/^3 \\/ /.test(document.querySelector('.rp-pos').textContent)", "Space on Next steps once"); + assert.equal(await p.eval("document.querySelector('.rp-play').textContent"), "Play", "and does not start playing"); + // A rebuilt control keeps focus: the speed buttons are redrawn on every change. + assert.ok(await focusOn(p, "document.querySelectorAll('.rp-ctl .seg button')[1]")); + await p.press("Enter"); + await p.waitFor("document.querySelectorAll('.rp-ctl .seg button')[1].getAttribute('aria-pressed') === 'true'", "4x speed"); + assert.equal(await p.eval("document.activeElement === document.querySelectorAll('.rp-ctl .seg button')[1]"), true, "the speed button keeps focus"); + // Away from a button, Space is still Play. + await p.eval("document.querySelector('#steps').focus()"); + await p.press(" "); + await p.waitFor("document.querySelector('.rp-play').textContent === 'Pause'", "Space plays"); + assert.deepEqual(p.errors, []); + }); + + test("arrow keys scroll the focused step detail when it overflows; j still moves to the next step", async () => { + const p = await open("#/s/" + LONG, 600); + await p.waitFor("document.querySelectorAll('#steps .st').length >= 2", "steps"); + await p.eval("[...document.querySelectorAll('#steps .st')].find((t) => /ls -R/.test(t.textContent)).click()"); + const pane = "document.querySelector('#event-pane')"; + await p.waitFor(`${pane}.scrollHeight > ${pane}.clientHeight + 100`, "a step detail taller than its pane"); + const selected = "document.querySelector('#steps .st[aria-selected=true]').id"; + const step: string = await p.eval(selected); + assert.ok(await focusOn(p, pane)); + for (let i = 0; i < 4; i++) await p.press("ArrowDown"); + await p.waitFor(`${pane}.scrollTop > 0`, "the pane scrolls"); + assert.equal(await p.eval(selected), step, "the step stays"); + assert.equal(await p.eval(focused), "#event-pane"); + await p.press("j"); + await p.waitFor(`${selected} !== ${JSON.stringify(step)}`, "j moves on"); + assert.deepEqual(p.errors, []); + }); + + test("a case filter chip keeps focus when the chips are redrawn", async () => { + const p = await open("#/experiments/exp-prod-smoke"); + const all = "document.querySelector('[data-case-filter=all]')"; + await p.waitFor(all, "case filters"); + assert.ok(await focusOn(p, all)); + await p.press("Enter"); + await p.waitFor(`${all}.getAttribute('aria-pressed') === 'true'`, "all cases"); + assert.equal(await p.eval(`document.activeElement === ${all}`), true); + assert.equal(await p.eval("document.querySelectorAll('[role=option]').length"), 2); + }); + + test("Enter on a button inside an open case row presses the button; the row stays open", async () => { + const p = await open("#/experiments/exp-prod-smoke"); + const lb = "document.querySelector('.case-table [role=listbox]')"; + await p.waitFor(`${lb} && ${lb}.querySelector('[role=option]')`, "cases"); + assert.ok(await focusOn(p, lb)); + await p.press("Enter"); + const all = `${lb}.querySelector('[aria-expanded=true] .show-all')`; + await p.waitFor(all, "the open row's clipped output"); + assert.ok(await focusOn(p, all)); + await p.press("Enter"); + await p.waitFor(`${lb}.querySelector('[aria-expanded=true]') && !${all}`, "the whole output, row still open"); + assert.ok((await p.eval(`${lb}.querySelector("[aria-expanded=true] pre").textContent.split("\\n").length`)) > 420, "more than the clipped 400 lines"); + assert.notEqual(await p.eval(focused), "body"); + assert.deepEqual(p.errors, []); + }); + + test("an Explore control keeps focus when its change redraws the page", async () => { + const p = await open("#/explore"); + const week = "[...document.querySelectorAll('.mx-controls [aria-label=Bucket] button')].find((b) => b.textContent === 'Week')"; + await p.waitFor(week, "the Explore controls"); + assert.ok(await focusOn(p, week)); + await p.press("Enter"); + await p.waitFor(`/per=week/.test(location.hash) && ${week}.getAttribute('aria-pressed') === 'true'`, "weekly buckets"); + assert.equal(await p.eval(`document.activeElement === ${week}`), true, "the Week button keeps focus"); + const by = "document.querySelector('.mx-controls select')"; + await p.eval(`(() => { const s = ${by}; s.focus(); s.value = 'model'; s.dispatchEvent(new Event('change', { bubbles: true })); })()`); + await p.waitFor(`/by=model/.test(location.hash) && ${by}.value === 'model'`, "split by model"); + assert.equal(await p.eval(`document.activeElement === ${by}`), true, "the select keeps focus, so the next arrow key changes it again"); + assert.deepEqual(p.errors, []); + }); + + test("a button that removes itself hands focus to its pane, not to ", async () => { + const p = await open("#/s/" + LONG, 600); + await p.waitFor("document.querySelectorAll('#steps .st').length >= 2", "steps"); + await p.eval("[...document.querySelectorAll('#steps .st')].find((t) => /ls -R/.test(t.textContent)).click()"); + const all = "document.querySelector('#event-pane .show-all')"; + await p.waitFor(all, "a clipped output with Show all"); + assert.ok(await focusOn(p, all)); + await p.press("Enter"); + await p.waitFor(`!${all}`, "the whole output"); + assert.equal(await p.eval(focused), "#event-pane"); + }); +}); diff --git a/packages/coding.sh/test/viewer/ui-fixes.test.ts b/packages/coding.sh/test/viewer/ui-fixes.test.ts index c8ea6dfb..3a7b4b4b 100644 --- a/packages/coding.sh/test/viewer/ui-fixes.test.ts +++ b/packages/coding.sh/test/viewer/ui-fixes.test.ts @@ -73,7 +73,7 @@ test("the page builds DOM through one helper that skips null, not the DOM's own // replaceChildren(null) writes the text "null"; swap() fills like h() does and skips it. const uses = html.match(/replaceChildren\(/g) ?? []; assert.equal(uses.length, 1, "only swap() itself calls replaceChildren"); - assert.match(html, /function swap\(el, \.\.\.children\) \{\s*el\.replaceChildren\(\);\s*return append\(el, children\);/); + assert.match(html, /function swap\(el, \.\.\.children\) \{\s*return keepFocus\(el, \(\) => \{ el\.replaceChildren\(\); return append\(el, children\); \}\);/); }); /** The top-level arguments of the call whose "(" ends just before `i`. */ From 50275aea335cf1b795e5a5f4f643325435f443b2 Mon Sep 17 00:00:00 2001 From: Jack <72348727+Jack-GitHub12@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:01:49 -0600 Subject: [PATCH 2/2] docs(explore): the keys bullet and its changelog line fit the site's word budgets --- CHANGELOG.md | 2 +- docs/guides/explore.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcf150e6..8f6b9c9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,7 +62,7 @@ grouped by area, with links to the docs for the details. - **Serve.** `serve` honors the `[proxy]` section of `--config`, the collector refuses a host other than loopback, and the proxy rejects bad upstream URLs. [Production](https://github.com/jackulau/coding.sh/blob/main/docs/PRODUCTION.md) - **SDK.** `require()` of the TypeScript SDK works, and both SDKs warn once on a bad collector URL. [SDK](https://github.com/jackulau/coding.sh/blob/main/docs/reference/sdk.md) - **Viewer.** The live header refreshes in place, empty fields no longer show "null", server-rendered rows match the page's, and an unknown session id costs one request. [Explore](https://github.com/jackulau/coding.sh/blob/main/docs/guides/explore.md) -- **Viewer.** Enter presses the focused button, filter chip or link, and Space the focused button, also inside an open list row. Before, they opened the selected session, folded the row or started the replay. The arrow keys scroll the step detail when it has focus, and a redraw moves focus to the rebuilt control or its pane instead of dropping it. [Explore](https://github.com/jackulau/coding.sh/blob/main/docs/guides/explore.md#open-the-viewer) +- **Viewer.** Enter presses the focused button, filter chip or link, and Space the focused button, also inside an open list row. Before, they opened the selected session, folded the row or started the replay. The arrow keys scroll the focused step detail, and a redraw moves focus to the rebuilt control or its pane instead of dropping it. [Explore](https://github.com/jackulau/coding.sh/blob/main/docs/guides/explore.md#open-the-viewer) - **Packaging.** The published package.json no longer has `source` export conditions that point at files the tarball does not hold. `smoke.sh` runs under a throwaway home, and the GitHub Action has an `annotate` input. [Release](https://github.com/jackulau/coding.sh/blob/main/docs/RELEASE.md) - **Site.** The docs sidebar and the phone menu open on the current page, and the phone menu fits the screen. A terminal block no longer touches the paragraph above it, wide code blocks scroll by keyboard, code copy selects the code when the browser blocks the clipboard, and search results open without a redirect. [Docs](https://coding.sh/docs) - **Site.** Changelog notes link docs to their page on the site instead of showing raw Markdown or going to GitHub, and the changelog counts only shipped releases. [Changelog](https://coding.sh/changelog) diff --git a/docs/guides/explore.md b/docs/guides/explore.md index ed534be2..cbcbebb5 100644 --- a/docs/guides/explore.md +++ b/docs/guides/explore.md @@ -146,8 +146,8 @@ token for this run of `ui`; the viewer needs it. - **Replay** (R on a session). Plays the session back one step at a time. See [Replay a session](https://coding.sh/docs/use-cases/replay-agent-session). - **Keys.** ? lists every shortcut. j and k move through the - list from anywhere but a text field. A focused button keeps Enter and Space, a focused link - keeps Enter, and the arrow keys scroll the step detail when it has focus and more to show. + list outside a text field. A focused button keeps Enter and Space, a focused link keeps Enter, + and the arrow keys scroll the focused step detail. ## Follow a running session