[eric] canvas: Cmd+A from an empty composer selects that chat, not nothing (ENG-231)

This commit is contained in:
ciregenz
2026-08-13 09:50:36 -07:00
parent a3c07f129e
commit 05cc86d08b
3 changed files with 63 additions and 2 deletions
@@ -1553,7 +1553,8 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
return (
<Box sx={{ display: 'flex', height: '100%', ...(fullscreenWash && { background: fullscreenWash }) }}>
<ContextDrawer />
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, overflow: 'hidden', ...(fullscreenChat && { maxWidth: FULLSCREEN_READING_MAX_W, width: '100%', mx: 'auto' }) }}>
{/* Marks transcript AND composer as one chat, so Cmd+A from the composer can still find the conversation (ENG-231). */}
<Box data-chat-root sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, overflow: 'hidden', ...(fullscreenChat && { maxWidth: FULLSCREEN_READING_MAX_W, width: '100%', mx: 'auto' }) }}>
{!embedded && (
<Box
sx={{
@@ -59,3 +59,40 @@ test('every scope is reachable, so the decision is not secretly one-armed', () =
]);
assert.equal(seen.size, 3, `only reached ${[...seen].join(', ')}`);
});
// --- Eric, 2026-08-13: "Cmd+A in a fullscreen chat seems to do nothing." ---
//
// The first cut sent every text field to native select-all. A fullscreen chat AUTOFOCUSES its
// composer and the composer sits OUTSIDE [data-chat-transcript], so focus was in an empty text box,
// native select-all selected nothing, and the shortcut looked dead. That is the common path, not a
// corner: the user has to click into the transcript first to get the documented behaviour.
/** A composer: a text field beside its transcript, both under one [data-chat-root]. */
function composer(value: string, transcript: HTMLElement | null): Element {
const root = {
querySelector: (sel: string) => (sel.includes('data-chat-transcript') ? transcript : null),
};
return {
tagName: 'TEXTAREA',
isContentEditable: false,
value,
closest: (sel: string) => (sel.includes('data-chat-root') ? root : null),
} as unknown as Element;
}
test('an EMPTY composer scopes Cmd+A to its own chat instead of doing nothing', () => {
const d = selectAllTarget(composer('', TRANSCRIPT));
assert.equal(d.scope, 'transcript', 'an empty composer left the shortcut dead in a fullscreen chat');
assert.equal(d.transcript, TRANSCRIPT, 'selected the wrong chat, or none');
});
test('a composer WITH a draft keeps native select-all, so typing is never hijacked', () => {
const d = selectAllTarget(composer('half a message', TRANSCRIPT));
assert.equal(d.scope, 'native', 'stole Cmd+A from a user who was mid-sentence');
assert.equal(d.transcript, null);
});
test('an empty text field with no chat around it stays native', () => {
const d = selectAllTarget(composer('', null));
assert.equal(d.scope, 'native', 'a search box outside any chat must not select a transcript');
});
@@ -18,6 +18,7 @@ export interface SelectAllDecision {
}
const P_TRANSCRIPT_ATTR = 'data-chat-transcript';
const P_CHAT_ROOT_ATTR = 'data-chat-root';
function p_isTextField(el: Element | null): boolean {
if (!el) return false;
@@ -25,11 +26,33 @@ function p_isTextField(el: Element | null): boolean {
return tag === 'INPUT' || tag === 'TEXTAREA' || (el as HTMLElement).isContentEditable === true;
}
function p_hasText(el: Element): boolean {
const node = el as HTMLInputElement;
if (typeof node.value === 'string') return node.value.length > 0;
return ((el as HTMLElement).textContent || '').length > 0;
}
/** The transcript belonging to the same chat as `el`, even when `el` is the composer beside it. */
function p_siblingTranscript(el: Element): HTMLElement | null {
if (typeof el.closest !== 'function') return null;
const root = el.closest(`[${P_CHAT_ROOT_ATTR}]`);
return root ? (root.querySelector(`[${P_TRANSCRIPT_ATTR}]`) as HTMLElement | null) : null;
}
/**
* @param active the focused element, normally document.activeElement or the key event target
*/
export function selectAllTarget(active: Element | null): SelectAllDecision {
if (p_isTextField(active)) return { scope: 'native', transcript: null };
if (p_isTextField(active)) {
// A composer with a draft in it owns Cmd+A, same as any text box. An EMPTY one has nothing to
// select, so the browser's select-all is a no-op and the user just sees nothing happen. In a
// chat that is exactly the moment they meant "select this conversation", and a fullscreen chat
// autofocuses its composer, so this was the common case rather than the corner one.
if (p_hasText(active as Element)) return { scope: 'native', transcript: null };
const sibling = p_siblingTranscript(active as Element);
if (sibling) return { scope: 'transcript', transcript: sibling };
return { scope: 'native', transcript: null };
}
// closest() walks through the card's own wrappers, so clicking any part of a chat still counts
// as being "in" that chat rather than on the canvas behind it.
const transcript = active && typeof active.closest === 'function'