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
125 changes: 115 additions & 10 deletions crates/daemon/assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8402,13 +8402,23 @@ <h2 id="serviceViewTitle"></h2>
// toggle. Switching between terminal sessions never did that and worked
// fine, so match it — the cached xterm is revealed and the child repaints
// on the geometry claim.
const focusTerm = shouldFocusTerminalAfterSessionSwitch();
await enterTerminalMode(id, {
focusTerminal: shouldFocusTerminalAfterSessionSwitch(),
focusTerminal: focusTerm,
});
// A deliberate view toggle must leave a typing destination (issue #1074).
// On touch with the soft keyboard hidden we skip xterm focus, so land on
// the mobile composer instead of leaving the caret on <body>.
if (!focusTerm) {
focusActiveSurface({ preferComposerOnTouch: true });
}
} else {
rememberActiveTerminalState();
enterChatMode();
await loadTranscript(id);
// Chat has no enter-time focus path of its own; put the caret in the
// composer so the user can type immediately after the toggle.
focusActiveSurface({ preferComposerOnTouch: true });
}
setComposerEnabled(!!(state.ws && state.ws.readyState === 1 && state.currentId));
reportView();
Expand Down Expand Up @@ -17719,6 +17729,8 @@ <h2 id="serviceViewTitle"></h2>
const newSessionCreateBtn = $("newSessionCreate");

async function openNewSessionDialog() {
// Capture before anything in this dialog takes the caret (issue #1074).
const invoker = captureOverlayInvoker();
// Populate the harness dropdown from `harness.list`. Filtered to
// available harnesses so the user can't pick one that the daemon
// would reject.
Expand All @@ -17729,6 +17741,7 @@ <h2 id="serviceViewTitle"></h2>
} catch (e) {
newSessionErrorEl.textContent = `harness.list failed: ${e.message}`;
newSessionErrorEl.hidden = false;
newSessionSheetEl._overlayInvoker = invoker;
newSessionSheetEl.hidden = false;
return;
}
Expand All @@ -17747,16 +17760,21 @@ <h2 id="serviceViewTitle"></h2>
const cur = state.sessions.find((s) => s.id === state.currentId);
newSessionCwdEl.value = (cur && cur.cwd) || "";
newSessionPromptEl.value = "";
newSessionSheetEl._overlayInvoker = invoker;
newSessionSheetEl.hidden = false;
// Defer focus so the slide-up animation doesn't fight the
// keyboard-summon path.
setTimeout(() => newSessionHarnessEl.focus(), 200);
}

function closeNewSessionDialog() {
function closeNewSessionDialog(opts = {}) {
const restore = opts.restore !== false;
const invoker = newSessionSheetEl._overlayInvoker;
newSessionSheetEl._overlayInvoker = null;
newSessionSheetEl.hidden = true;
newSessionErrorEl.hidden = true;
newSessionCreateBtn.disabled = false;
if (restore) restoreOverlayFocus(invoker);
}

function currentTerminalSize() {
Expand Down Expand Up @@ -17805,12 +17823,17 @@ <h2 id="serviceViewTitle"></h2>
}
try {
const res = await rpc("session.create", params);
closeNewSessionDialog();
// Don't bounce focus back to the + button — the new session is the
// surface the user is about to type into (issue #1074).
closeNewSessionDialog({ restore: false });
// session/state notifications will repopulate the list; also
// auto-select the new session so the user sees it immediately.
if (res && res.session_id) {
await refreshSessions();
selectSession(res.session_id);
await selectSession(res.session_id);
focusActiveSurface({ preferComposerOnTouch: true });
} else {
restoreOverlayFocus(newSessionBtn);
}
} catch (e) {
newSessionCreateBtn.disabled = false;
Expand Down Expand Up @@ -17840,6 +17863,8 @@ <h2 id="serviceViewTitle"></h2>
function openRenameDialog(sessionId) {
const s = state.sessions.find((x) => x.id === sessionId);
if (!s) return;
// Capture before the deferred input focus takes the caret (issue #1074).
renameSheetEl._overlayInvoker = captureOverlayInvoker();
renameFormEl.dataset.sessionId = sessionId;
renameTitleEl.value = s.title || "";
renameErrorEl.hidden = true;
Expand All @@ -17852,10 +17877,13 @@ <h2 id="serviceViewTitle"></h2>
}

function closeRenameDialog() {
const invoker = renameSheetEl._overlayInvoker;
renameSheetEl._overlayInvoker = null;
renameSheetEl.hidden = true;
renameErrorEl.hidden = true;
renameSaveBtn.disabled = false;
delete renameFormEl.dataset.sessionId;
restoreOverlayFocus(invoker);
}

async function submitRename(ev) {
Expand Down Expand Up @@ -17914,15 +17942,24 @@ <h2 id="serviceViewTitle"></h2>
function openCloseSessionDialog(sessionId) {
const s = state.sessions.find((x) => x.id === sessionId);
if (!s) return;
closeSessionSheetEl._overlayInvoker = captureOverlayInvoker();
closeSessionSheetEl.dataset.sessionId = sessionId;
const label = sessionDisplayTitle(s) || sessionId.slice(0, 8);
closeSessionLabelEl.textContent = `"${label}"`;
closeSessionSheetEl.hidden = false;
// Land caret inside the sheet so Escape / Tab stay in the dialog, and so
// close always has a focus transition to restore from (issue #1074).
setTimeout(() => {
try { closeSessionCancelBtn.focus(); } catch (_) {}
}, 0);
}

function closeCloseSessionDialog() {
const invoker = closeSessionSheetEl._overlayInvoker;
closeSessionSheetEl._overlayInvoker = null;
closeSessionSheetEl.hidden = true;
delete closeSessionSheetEl.dataset.sessionId;
restoreOverlayFocus(invoker);
}

closeSessionCancelBtn.addEventListener("click", closeCloseSessionDialog);
Expand Down Expand Up @@ -18047,11 +18084,21 @@ <h2 id="serviceViewTitle"></h2>
const settingsSheetEl = $("settingsSheet");

function openSettingsSheet() {
settingsSheetEl._overlayInvoker = captureOverlayInvoker();
settingsSheetEl.hidden = false;
setTimeout(() => {
const closeBtn = $("settingsClose");
if (closeBtn) {
try { closeBtn.focus(); } catch (_) {}
}
}, 0);
}

function closeSettingsSheet() {
const invoker = settingsSheetEl._overlayInvoker;
settingsSheetEl._overlayInvoker = null;
settingsSheetEl.hidden = true;
restoreOverlayFocus(invoker);
}

miniUsageGraphEl.addEventListener("click", openSettingsSheet);
Expand Down Expand Up @@ -18769,18 +18816,76 @@ <h2 id="serviceViewTitle"></h2>
}
}

/** Put the caret back where typing should land for the current view. */
function focusActiveSurface() {
/**
* Put the caret back where typing should land for the current view.
*
* `preferComposerOnTouch`: when restoring after an overlay dismiss (issue
* #1074), touch layouts prefer the composer over xterm so the user gets a
* visible focus ring / soft keyboard rather than a silent helper-textarea
* focus that looks like the caret vanished.
*/
function focusActiveSurface(opts = {}) {
if (state.mode === "playbook") {
playbookInputEl.focus();
try { playbookInputEl.focus(); } catch (_) {}
return;
}
if (state.mode === "terminal") {
if (opts.preferComposerOnTouch && isLikelyTouchDevice() && composerEl && !composerEl.hidden) {
try { inputEl.focus({ preventScroll: true }); } catch (_) {}
return;
}
const handle = state.terminalById.get(state.currentId);
if (handle) handle.term.focus();
return;
if (handle && handle.term) {
try { handle.term.focus(); } catch (_) {}
return;
}
}
inputEl.focus();
try { inputEl.focus({ preventScroll: true }); } catch (_) {}
}

/**
* Overlay focus restore (issue #1074 / spec 0189).
*
* Every dismissible sheet records who had focus when it opened. On close we
* put the caret back on that invoker when it is still focusable in the page;
* otherwise we land on the active surface so the next keystroke is not eaten
* by `<body>`.
*/
function captureOverlayInvoker() {
const active = document.activeElement;
if (!active || active === document.body || active === document.documentElement) {
return null;
}
return active;
}

function overlayInvokerIsRestorable(el) {
if (!el || !el.isConnected || typeof el.focus !== "function") return false;
if (el.disabled || el.getAttribute("aria-disabled") === "true") return false;
// A node inside a [hidden] ancestor (closed menu, closed sheet) is not a
// useful restore target — the browser would bounce focus to <body>.
if (el.closest("[hidden]")) return false;
try {
const style = window.getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden") return false;
} catch (_) {
/* computed style is best-effort */
}
return true;
}

function restoreOverlayFocus(invoker) {
try {
if (overlayInvokerIsRestorable(invoker)) {
invoker.focus({ preventScroll: true });
if (document.activeElement === invoker || invoker.contains(document.activeElement)) {
return;
}
}
} catch (_) {
/* fall through to the active surface */
}
focusActiveSurface({ preferComposerOnTouch: true });
}

/** Scroll whichever surface the current view is showing. */
Expand Down
122 changes: 122 additions & 0 deletions crates/e2e/tests/web_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1858,6 +1858,128 @@ async fn web_client_loads_and_websocket_connects() {
"clicking an already-focused terminal split must still claim PTY geometry"
);

// Overlay dismiss must restore the invoker (or the active surface) so the
// next keystroke never lands on <body> (issue #1074 / spec 0189).
let overlay_focus: serde_json::Value = page
.evaluate(
r#"
(async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const activeTag = () => {
const a = document.activeElement;
if (!a || a === document.body) return 'BODY';
return a.id || a.tagName;
};

// Ensure a current session so rename/close-session open paths work.
if (!state.currentId) {
state.sessions = state.sessions || [];
if (!state.sessions.some((s) => s.id === 's-focus-overlay')) {
state.sessions.push({
id: 's-focus-overlay',
title: 'Focus Overlay',
has_pty: true,
mode: 'interactive',
});
}
state.currentId = 's-focus-overlay';
}

// --- Rename: open from a live invoker, cancel → invoker ---
// (titleRenameBtn may be [hidden] depending on session actions,
// so use a dedicated button we control.)
const renameInvoker = document.createElement('button');
renameInvoker.id = 'renameInvokerProbe';
renameInvoker.type = 'button';
renameInvoker.textContent = 'rename-probe';
document.body.appendChild(renameInvoker);
renameInvoker.focus();
openRenameDialog(state.currentId);
await sleep(250);
const renameFocusedWhileOpen = document.activeElement === renameTitleEl;
closeRenameDialog();
await sleep(0);
const afterRenameCancel = activeTag();
const afterRenameIsBody = document.activeElement === document.body;
renameInvoker.remove();

// --- Settings: open from badge, close → invoker ---
// Canvas is focusable via tabindex set by the badge paint path.
miniUsageGraphEl.setAttribute('tabindex', '0');
miniUsageGraphEl.focus();
openSettingsSheet();
await sleep(20);
closeSettingsSheet();
await sleep(0);
const afterSettings = activeTag();
const afterSettingsIsBody = document.activeElement === document.body;

// --- Close-session: cancel → not body ---
openCloseSessionDialog(state.currentId);
await sleep(20);
closeCloseSessionDialog();
await sleep(0);
const afterCloseSessionIsBody = document.activeElement === document.body;

// --- restoreOverlayFocus falls back when invoker is gone ---
const ghost = document.createElement('button');
ghost.id = 'ghostInvoker';
document.body.appendChild(ghost);
ghost.focus();
openRenameDialog(state.currentId);
await sleep(250);
ghost.remove();
closeRenameDialog();
await sleep(0);
const afterGhostGoneIsBody = document.activeElement === document.body;
const afterGhostGoneTag = activeTag();

return {
renameFocusedWhileOpen,
afterRenameCancel,
afterRenameIsBody,
afterSettings,
afterSettingsIsBody,
afterCloseSessionIsBody,
afterGhostGoneIsBody,
afterGhostGoneTag,
};
})()
"#,
)
.await
.expect("evaluate overlay focus restore")
.into_value::<serde_json::Value>()
.expect("json object");
assert_eq!(
overlay_focus["renameFocusedWhileOpen"], true,
"rename sheet should move focus into its input: {overlay_focus:?}"
);
assert_eq!(
overlay_focus["afterRenameIsBody"], false,
"closing rename must not leave focus on <body>: {overlay_focus:?}"
);
assert_eq!(
overlay_focus["afterRenameCancel"], "renameInvokerProbe",
"closing rename should restore the invoker button: {overlay_focus:?}"
);
assert_eq!(
overlay_focus["afterSettingsIsBody"], false,
"closing settings must not leave focus on <body>: {overlay_focus:?}"
);
assert_eq!(
overlay_focus["afterSettings"], "miniUsageGraph",
"closing settings should restore the usage badge: {overlay_focus:?}"
);
assert_eq!(
overlay_focus["afterCloseSessionIsBody"], false,
"closing close-session must not leave focus on <body>: {overlay_focus:?}"
);
assert_eq!(
overlay_focus["afterGhostGoneIsBody"], false,
"when the invoker is gone, focus must fall back to a surface, not <body>: {overlay_focus:?}"
);

// xterm emits child-requested mouse reports and automatic terminal
// protocol replies through the same onData path as keystrokes. Plain
// pointer motion and protocol replies must stay passive; a drag is
Expand Down
Loading
Loading