diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 0522638c..6325461e 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -1553,7 +1553,8 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose return ( - + {/* Marks transcript AND composer as one chat, so Cmd+A from the composer can still find the conversation (ENG-231). */} + {!embedded && ( (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'); +}); diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/selectAllTarget.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/selectAllTarget.ts index cdbe83f6..fbe79e1c 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/selectAllTarget.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/selectAllTarget.ts @@ -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'