mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-10 19:57:44 +02:00
[eric] canvas: Cmd+A from an empty composer selects that chat, not nothing (ENG-231)
This commit is contained in:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user