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
146 changes: 134 additions & 12 deletions crates/daemon/assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3859,7 +3859,7 @@
aria-label="new session">+ new session</button>
</div>
<span class="session-list-sr-label">sessions</span>
<aside class="session-list" id="sessionList">
<aside class="session-list" id="sessionList" role="tree" aria-label="sessions">
<div class="empty">connecting…</div>
</aside>
<!--
Expand Down Expand Up @@ -7600,7 +7600,12 @@ <h2 id="serviceViewTitle"></h2>

function focusListRow(row) {
if (!row) return;
row.setAttribute("tabindex", "-1");
// Roving tabindex: the row that holds the list caret is the list's single
// Tab stop, so leaving and re-Tabbing returns to where the user was.
for (const other of listFocusRows()) {
if (other !== row) other.setAttribute("tabindex", "-1");
}
row.setAttribute("tabindex", "0");
row.focus();
row.scrollIntoView({ block: "nearest" });
}
Expand All @@ -7612,6 +7617,24 @@ <h2 id="serviceViewTitle"></h2>
: null;
}

/** Keep exactly one list row in the tab order (issue #1075): the row that
* holds the list caret when focus is inside the list, else the selected
* row, else the first row. Tab from outside therefore reaches the list
* exactly once, and the arrow keys take over from there. Runs after every
* re-render because rebuilt rows come back without a tabindex. */
function syncListTabStops() {
const rows = listFocusRows();
if (!rows.length) return;
const focused = listFocusedRow();
const entry =
(focused && rows.includes(focused) ? focused : null) ||
sessionListEl.querySelector(".item.active") ||
rows[0];
for (const row of rows) {
row.setAttribute("tabindex", row === entry ? "0" : "-1");
}
}

/** Re-find a list row after a re-render and put the caret back on it. */
function refocusListRow(rowKey, isService = false) {
if (!rowKey) return;
Expand Down Expand Up @@ -7935,8 +7958,70 @@ <h2 id="serviceViewTitle"></h2>
});
}

/** Build a row descriptor: a stable key plus the outer class and inner
* HTML. renderSessions() diffs class and inner separately so a pure
/** Annotate the flat `orderedItems()` list with tree coordinates for
* aria-level / aria-posinset / aria-setsize. Items arrive in DFS order;
* a sibling run is a maximal stretch of rows at one depth uninterrupted
* by a shallower row — the same grouping the indentation shows visually.
* (Group headers, services, and the operator carry no `depth` and count
* as depth 0.) */
function annotateTreePositions(items) {
const stack = []; // stack[d] = the sibling run currently open at depth d
const settle = (downTo) => {
while (stack.length > downTo) {
const run = stack.pop();
run.forEach((it, i) => {
it.posinset = i + 1;
it.setsize = run.length;
});
}
};
for (const it of items) {
const depth = it.depth ?? 0;
settle(depth + 1);
while (stack.length <= depth) stack.push([]);
stack[depth].push(it);
it.level = depth + 1;
}
settle(0);
return items;
}

/** The row attributes renderSessions() owns. Listed exhaustively so a row
* that changes shape across renders (e.g. loses its children and with them
* aria-expanded) has the stale attribute removed, not left behind. */
const ROW_ARIA_ATTRS = Object.freeze([
"role",
"aria-selected",
"aria-expanded",
"aria-level",
"aria-posinset",
"aria-setsize",
]);

function rowTreePositionAria(item) {
const aria = {};
if (item.level) aria["aria-level"] = String(item.level);
if (item.posinset) aria["aria-posinset"] = String(item.posinset);
if (item.setsize) aria["aria-setsize"] = String(item.setsize);
return aria;
}

function applyRowAria(node, aria) {
for (const name of ROW_ARIA_ATTRS) {
const v = aria ? aria[name] : undefined;
if (v == null) node.removeAttribute(name);
else node.setAttribute(name, v);
}
}

function rowAriaSignature(aria) {
return ROW_ARIA_ATTRS.map((name) => (aria && aria[name]) ?? "").join("|");
}

/** Build a row descriptor: a stable key plus the outer class, inner HTML,
* and ARIA attributes (issue #1075: the list is a `tree` — groups nest
* sessions, sessions nest subagents/forks — so rows are `treeitem`s).
* renderSessions() diffs class, inner, and aria separately so a pure
* selection change (class only) never rebuilds a row's status element. */
function sessionRowDescriptor(item) {
const depthClass = " depth-" + Math.min(item.depth ?? 0, 4);
Expand All @@ -7947,6 +8032,11 @@ <h2 id="serviceViewTitle"></h2>
datasetKey: "archivedSection",
datasetVal: item.section,
className: "archived-row" + depthClass,
aria: {
role: "treeitem",
"aria-expanded": item.expanded ? "true" : "false",
...rowTreePositionAria(item),
},
inner:
`<span class="archived-glyph">${glyph}</span>` +
`<span class="archived-label">${item.count} archived</span>`,
Expand All @@ -7960,6 +8050,11 @@ <h2 id="serviceViewTitle"></h2>
datasetKey: "groupId",
datasetVal: g.id,
className: "group-header",
aria: {
role: "treeitem",
"aria-expanded": g.collapsed ? "false" : "true",
...rowTreePositionAria(item),
},
inner:
`<span class="group-glyph">${glyph}</span>` +
`<span class="group-name">${escape(g.name || "(unnamed)")}</span>` +
Expand All @@ -7976,6 +8071,11 @@ <h2 id="serviceViewTitle"></h2>
datasetKey: "serviceName",
datasetVal: service.name,
className: "item is-service" + active + paused,
aria: {
role: "treeitem",
"aria-selected": active ? "true" : "false",
...rowTreePositionAria(item),
},
inner:
`<div class="title-row">` +
`<span class="disclosure"></span>` +
Expand All @@ -7996,6 +8096,11 @@ <h2 id="serviceViewTitle"></h2>
datasetKey: "id",
datasetVal: s.id,
className: "item is-operator" + active,
aria: {
role: "treeitem",
"aria-selected": active ? "true" : "false",
...rowTreePositionAria(item),
},
inner:
`<div class="title-row">` +
`<span class="disclosure"></span>` +
Expand Down Expand Up @@ -8027,6 +8132,16 @@ <h2 id="serviceViewTitle"></h2>
key: "s:" + s.id,
datasetKey: "id",
datasetVal: s.id,
aria: {
role: "treeitem",
"aria-selected": active ? "true" : "false",
// Only rows that actually have children advertise expandability; a
// leaf with aria-expanded would announce as a collapsed empty branch.
...(item.hasChildren
? { "aria-expanded": item.childrenExpanded ? "true" : "false" }
: null),
...rowTreePositionAria(item),
},
className:
"item" + depthClass + active +
(s.kind === "subagent" ? " is-subagent" : "") +
Expand Down Expand Up @@ -8057,7 +8172,7 @@ <h2 id="serviceViewTitle"></h2>
}
bindSessionListClicks();
bindSessionListDrag();
const items = orderedItems();
const items = annotateTreePositions(orderedItems());
if (!items.length) {
const only = sessionListEl.firstElementChild;
if (!only || sessionListEl.children.length !== 1 || !only.classList.contains("empty")) {
Expand Down Expand Up @@ -8100,11 +8215,17 @@ <h2 id="serviceViewTitle"></h2>
node.innerHTML = d.inner;
node.__isig = d.inner;
}
const asig = rowAriaSignature(d.aria);
if (node.__asig !== asig) {
applyRowAria(node, d.aria);
node.__asig = asig;
}
const ref = prev ? prev.nextSibling : sessionListEl.firstChild;
if (ref !== node) sessionListEl.insertBefore(node, ref);
prev = node;
}
for (const node of existing.values()) node.remove();
syncListTabStops();
if (state.currentServiceName) renderServiceView();
refreshSessionActions();
renderLineageSection();
Expand Down Expand Up @@ -18960,14 +19081,15 @@ <h2 id="serviceViewTitle"></h2>
return;
}
if (!isSessionListVisible()) setSessionListVisible(true);
// Enter through the roving Tab stop, i.e. wherever the list caret last
// was — the same row a Tab press would land on. (The old query looked
// for `.item.selected`, a class no row ever carries, so entry always
// fell back to the first session row.)
const selected =
sessionListEl.querySelector(".item.selected") || sessionListEl.querySelector(".item[data-id]");
if (selected) {
selected.setAttribute("tabindex", "-1");
selected.focus();
} else {
sessionListEl.focus();
}
sessionListEl.querySelector('[tabindex="0"]') ||
sessionListEl.querySelector(".item.active") ||
sessionListEl.querySelector(".item[data-id]");
if (selected) focusListRow(selected);
}

/**
Expand Down
125 changes: 125 additions & 0 deletions crates/e2e/tests/web_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3040,6 +3040,131 @@ async fn web_client_loads_and_websocket_connects() {
"visible row text should not spell out the status: {status_icons:?}"
);

// Issue #1075: the session list is an ARIA tree — rows are treeitems
// carrying selection, expansion, and tree-coordinate state, and exactly
// one row (the selected one) is the list's Tab entry point.
let list_aria: serde_json::Value = page
.evaluate(
r#"
(() => {
const saved = {
currentId: state.currentId,
sessions: state.sessions,
groups: state.groups,
services: state.services,
};
try {
state.currentId = 's-child';
state.services = [{ name: 'proxy', harness: 'shell', position: 0 }];
state.groups = [
{ id: 'g-open', name: 'Open', collapsed: false, position: 0 },
{ id: 'g-shut', name: 'Shut', collapsed: true, position: 1 },
];
state.sessions = [
{ id: 's-parent', title: 'Parent', harness: 'shell', state: 'running', kind: 'user', position: 0 },
{ id: 's-child', title: 'Child', harness: 'smith', state: 'running', kind: 'subagent', parent_session_id: 's-parent', position: 0 },
{ id: 's-solo', title: 'Solo', harness: 'shell', state: 'running', kind: 'user', position: 1 },
{ id: 's-member', title: 'Member', harness: 'shell', state: 'running', kind: 'user', group_id: 'g-open', position: 0 },
];
renderSessions();
const list = document.getElementById('sessionList');
const attrsOf = (el) => el ? {
role: el.getAttribute('role'),
selected: el.getAttribute('aria-selected'),
expanded: el.getAttribute('aria-expanded'),
level: el.getAttribute('aria-level'),
pos: el.getAttribute('aria-posinset'),
size: el.getAttribute('aria-setsize'),
tab: el.getAttribute('tabindex'),
} : null;
return {
listRole: list.getAttribute('role'),
listLabel: list.getAttribute('aria-label'),
service: attrsOf(list.querySelector('[data-service-name="proxy"]')),
parent: attrsOf(list.querySelector('[data-id="s-parent"]')),
child: attrsOf(list.querySelector('[data-id="s-child"]')),
solo: attrsOf(list.querySelector('[data-id="s-solo"]')),
openGroup: attrsOf(list.querySelector('[data-group-id="g-open"]')),
shutGroup: attrsOf(list.querySelector('[data-group-id="g-shut"]')),
member: attrsOf(list.querySelector('[data-id="s-member"]')),
tabStops: list.querySelectorAll('[tabindex="0"]').length,
entryId: list.querySelector('[tabindex="0"]')?.dataset.id || '',
};
} finally {
state.currentId = saved.currentId;
state.sessions = saved.sessions;
state.groups = saved.groups;
state.services = saved.services;
renderSessions();
}
})()
"#,
)
.await
.expect("evaluate session list aria")
.into_value::<serde_json::Value>()
.expect("json object");
assert_eq!(list_aria["listRole"], "tree");
assert_eq!(list_aria["listLabel"], "sessions");
// Top level (level 1) is service + two top sessions + two group headers.
assert_eq!(
list_aria["service"],
serde_json::json!({
"role": "treeitem", "selected": "false", "expanded": null,
"level": "1", "pos": "1", "size": "5", "tab": "-1"
}),
"service row aria: {list_aria:?}"
);
assert_eq!(
list_aria["parent"],
serde_json::json!({
"role": "treeitem", "selected": "false", "expanded": "true",
"level": "1", "pos": "2", "size": "5", "tab": "-1"
}),
"session with expanded subagent children: {list_aria:?}"
);
// The selected subagent row: nested one level down, selected, and the
// list's single Tab stop.
assert_eq!(
list_aria["child"],
serde_json::json!({
"role": "treeitem", "selected": "true", "expanded": null,
"level": "2", "pos": "1", "size": "1", "tab": "0"
}),
"selected subagent row aria: {list_aria:?}"
);
assert_eq!(
list_aria["solo"]["expanded"],
serde_json::Value::Null,
"a leaf session must not advertise expandability: {list_aria:?}"
);
assert_eq!(
list_aria["openGroup"],
serde_json::json!({
"role": "treeitem", "selected": null, "expanded": "true",
"level": "1", "pos": "4", "size": "5", "tab": "-1"
}),
"expanded group header aria: {list_aria:?}"
);
assert_eq!(
list_aria["shutGroup"]["expanded"],
"false",
"collapsed group header must announce collapsed: {list_aria:?}"
);
assert_eq!(
list_aria["member"],
serde_json::json!({
"role": "treeitem", "selected": "false", "expanded": null,
"level": "2", "pos": "1", "size": "1", "tab": "-1"
}),
"group member row aria: {list_aria:?}"
);
assert_eq!(
list_aria["tabStops"], 1,
"exactly one row is the roving Tab entry point: {list_aria:?}"
);
assert_eq!(list_aria["entryId"], "s-child");

// Issue #75: pasted image/file clipboard items and very large text
// are uploaded to the daemon as session attachments, and the prompt
// receives a compact [#file:...] reference instead of raw bytes/text.
Expand Down
Loading