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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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)
Expand Down
3 changes: 3 additions & 0 deletions docs/guides/explore.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ token for this run of `ui`; the viewer needs it.
step in its session.
- **Replay** (<kbd>R</kbd> 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.** <kbd>?</kbd> lists every shortcut. <kbd>j</kbd> and <kbd>k</kbd> move through the
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

Expand Down
78 changes: 60 additions & 18 deletions packages/coding.sh/src/viewer/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 <body>. 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. */
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(/^#\/?/, "");
Expand Down Expand Up @@ -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) {
Expand All @@ -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.
Expand Down Expand Up @@ -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);
};
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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); }
});
Expand All @@ -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 }); },
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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 }); },
Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions packages/coding.sh/test/viewer/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { code: string; vk: number; text?: string }> = {
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 {
Expand Down Expand Up @@ -109,6 +125,11 @@ export interface Page {
/** Poll an expression until it is truthy; returns its value. */
waitFor<T = any>(expr: string, what?: string, timeoutMs?: number): Promise<T>;
viewport(width: number, height: number, mobile?: boolean): Promise<void>;
/**
* 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<void>;
/** Uncaught exceptions and console.error calls on this page so far. */
errors: string[];
/** A raw DevTools call on this page (Input.dispatchMouseEvent, Network.emulateNetworkConditions, ...). */
Expand Down Expand Up @@ -210,6 +231,17 @@ export async function launchBrowser(chrome: string): Promise<Browser> {
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(() => {});
Expand Down
Loading
Loading