diff --git a/packages/ui/src/__tests__/composer-draft-caret-focus.test.tsx b/packages/ui/src/__tests__/composer-draft-caret-focus.test.tsx
index b9c76193a5..c1e6ccd43d 100644
--- a/packages/ui/src/__tests__/composer-draft-caret-focus.test.tsx
+++ b/packages/ui/src/__tests__/composer-draft-caret-focus.test.tsx
@@ -86,11 +86,17 @@ interface AimedRange {
container: Node | null;
offset: number;
collapsed: boolean;
+ startContainer: Node | null;
+ startOffset: number;
+ endContainer: Node | null;
+ endOffset: number;
+ commonAncestorContainer: Node | null;
}
function harness() {
const { document, window } = parseHTML('
');
window.getComputedStyle = () => computedStyle();
+ const animationFrames: FrameRequestCallback[] = [];
const setAttribute = window.Element.prototype.setAttribute;
window.Element.prototype.setAttribute = function normalized(name: string, value: string) {
return setAttribute.call(this, name === 'contentEditable' ? 'contenteditable' : name, value);
@@ -102,26 +108,84 @@ function harness() {
const range: AimedRange & {
selectNodeContents(node: Node): void;
collapse(toStart: boolean): void;
+ setStart(node: Node, offset: number): void;
+ setEnd(node: Node, offset: number): void;
+ cloneRange(): Range;
} = {
container: null,
offset: 0,
collapsed: false,
+ startContainer: null,
+ startOffset: 0,
+ endContainer: null,
+ endOffset: 0,
+ commonAncestorContainer: null,
selectNodeContents(node) {
range.container = node;
range.offset = node.childNodes.length;
+ range.startContainer = node;
+ range.startOffset = 0;
+ range.endContainer = node;
+ range.endOffset = node.childNodes.length;
+ range.commonAncestorContainer = node;
},
collapse(toStart) {
range.offset = toStart ? 0 : range.offset;
+ if (range.startContainer !== null) {
+ const node = toStart ? range.startContainer : range.endContainer;
+ const offset = toStart ? range.startOffset : range.endOffset;
+ range.startContainer = node;
+ range.startOffset = offset;
+ range.endContainer = node;
+ range.endOffset = offset;
+ range.container = node;
+ range.commonAncestorContainer = node;
+ }
range.collapsed = true;
},
+ setStart(node, offset) {
+ range.startContainer = node;
+ range.startOffset = offset;
+ range.container = node;
+ range.offset = offset;
+ range.commonAncestorContainer = node;
+ },
+ setEnd(node, offset) {
+ range.endContainer = node;
+ range.endOffset = offset;
+ range.commonAncestorContainer = node;
+ range.collapsed =
+ range.startContainer === range.endContainer && range.startOffset === range.endOffset;
+ },
+ cloneRange() {
+ const clone = document.createRange() as unknown as typeof range;
+ clone.container = range.container;
+ clone.offset = range.offset;
+ clone.collapsed = range.collapsed;
+ clone.startContainer = range.startContainer;
+ clone.startOffset = range.startOffset;
+ clone.endContainer = range.endContainer;
+ clone.endOffset = range.endOffset;
+ clone.commonAncestorContainer = range.commonAncestorContainer;
+ return clone as unknown as Range;
+ },
};
return range as unknown as Range;
};
let active: Element | null = null;
const selected: AimedRange[] = [];
const selection = {
+ get rangeCount(): number {
+ return selected.length;
+ },
+ get isCollapsed(): boolean {
+ return selected.at(-1)?.collapsed ?? true;
+ },
get anchorNode(): Node | null {
- return selected.at(-1)?.container ?? null;
+ return selected.at(-1)?.startContainer ?? null;
+ },
+ getRangeAt(index: number): Range {
+ return selected[index] as unknown as Range;
},
removeAllRanges() {
selected.length = 0;
@@ -132,24 +196,44 @@ function harness() {
// The whole point: a selection inside a `contenteditable` focuses it,
// whoever held focus before. Without this the harness would let a caret
// placed on a blurred editor look free.
- const container = aimed.container as Element & { closest?: Element['closest'] };
- active = container?.closest?.('[contenteditable="true"]') ?? active;
+ const container = aimed.container as Node & { closest?: Element['closest']; parentElement?: Element | null };
+ const element = container?.closest
+ ? container
+ : container?.parentElement;
+ active = element?.closest?.('[contenteditable="true"]') ?? active;
},
};
document.getSelection = () => selection as unknown as Selection;
window.getSelection = () => selection as unknown as Selection;
Object.defineProperty(document, 'activeElement', { configurable: true, get: () => active });
+ const focus = window.HTMLElement.prototype.focus;
+ window.HTMLElement.prototype.focus = function focusElement() {
+ active = this;
+ this.dispatchEvent(new window.Event('focusin', { bubbles: true }));
+ };
+ restoreDom.push(() => {
+ window.HTMLElement.prototype.focus = focus;
+ });
Object.assign(globalThis, {
document,
window,
+ Node: window.Node,
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
- requestAnimationFrame: () => 1,
+ requestAnimationFrame: (callback: FrameRequestCallback) => {
+ animationFrames.push(callback);
+ return animationFrames.length;
+ },
cancelAnimationFrame() {},
IS_REACT_ACT_ENVIRONMENT: true,
});
+ window.requestAnimationFrame = (callback: FrameRequestCallback) => {
+ animationFrames.push(callback);
+ return animationFrames.length;
+ };
+ window.cancelAnimationFrame = () => undefined;
const container = document.querySelector('#root');
assert.ok(container);
- const root = createRoot(container);
+ const root = createRoot(container as unknown as Element);
mountedRoots.push(root);
return {
/** The live selection: what `removeAllRanges` cleared and `addRange` added. */
@@ -181,6 +265,27 @@ function harness() {
element.dispatchEvent(new window.Event('pointerdown', { bubbles: true }));
});
},
+ async click(element: HTMLElement) {
+ await act(() => {
+ element.dispatchEvent(new window.Event('click', { bubbles: true }));
+ });
+ },
+ async flushAnimationFrames() {
+ const callbacks = animationFrames.splice(0);
+ await act(() => {
+ for (const callback of callbacks) callback(0);
+ });
+ },
+ async setCaret(offset: number) {
+ const editable = this.editable();
+ const text = editable.firstChild as Node | null;
+ assert.ok(text, 'the composer has no text node');
+ const range = document.createRange();
+ range.setStart(text as Node, offset);
+ range.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ },
async render(props: Parameters[0]) {
await act(() => {
root.render(
@@ -212,7 +317,7 @@ function withDraft(key: string, draft: string) {
/** The end of the content, which is where every restored draft owes its caret. */
function assertCaretAtEnd(selected: readonly AimedRange[], editable: HTMLElement): void {
const caret = selected.at(-1);
- assert.ok(caret, 'the composer placed no caret');
+ if (!caret) throw new Error('the composer placed no caret');
assert.equal(caret.collapsed, true, 'the caret must be a collapsed selection');
assert.equal(caret.container, editable);
assert.equal(caret.offset, editable.childNodes.length);
@@ -263,3 +368,87 @@ test('a session swap leaves focus on the row that caused it', async () => {
'the restored caret took focus out from under the row the user activated',
);
});
+
+test('changing thinking level keeps the draft caret', async () => {
+ const dom = harness();
+ const props = {
+ ...withDraft('session-a', 'draft in the middle'),
+ draftKey: 'session-a',
+ activeSession: {
+ id: 'session-a',
+ llmConnectionId: 'connection-a',
+ llmConnectionSlug: 'connection-a',
+ model: 'model-a',
+ } as never,
+ activeThinkingLevels: ['low'] as never,
+ activeThinkingLevel: undefined,
+ onThinkingLevelChange: () => undefined,
+ };
+ await dom.render(props);
+ await dom.focus(dom.editable());
+ await dom.setCaret(6);
+
+ const selector = document.querySelector('.maka-thinking-level-selector');
+ assert.ok(selector, 'the thinking-level selector did not render');
+ await dom.pointerDown(selector as HTMLElement);
+ await dom.click(selector as HTMLElement);
+
+ const options = [...document.querySelectorAll('[role="menuitemradio"]')];
+ const low = options.find((option) => option.textContent?.includes('Low'));
+ assert.ok(low, 'the thinking-level menu did not render the low option');
+ // A browser can collapse a contenteditable selection when focus moves into
+ // the menu. The production code must restore the range captured before that
+ // interaction rather than accepting the collapsed start position.
+ await dom.setCaret(0);
+ await dom.click(low as HTMLElement);
+ await dom.render({ ...props, activeThinkingLevel: 'low' as never });
+ await dom.flushAnimationFrames();
+ document.querySelector('[role="menu"]')?.remove();
+
+ const caret = dom.selected.at(-1);
+ if (!caret) throw new Error('the thinking-level change removed the draft selection');
+ assert.equal(caret.startContainer, dom.editable().firstChild);
+ assert.equal(caret.startOffset, 6);
+});
+
+test('cancelling a thinking menu recaptures a caret moved before the next choice', async () => {
+ const dom = harness();
+ const props = {
+ ...withDraft('session-a', 'draft in the middle'),
+ draftKey: 'session-a',
+ activeSession: {
+ id: 'session-a',
+ llmConnectionId: 'connection-a',
+ llmConnectionSlug: 'connection-a',
+ model: 'model-a',
+ } as never,
+ activeThinkingLevels: ['low'] as never,
+ activeThinkingLevel: undefined,
+ onThinkingLevelChange: () => undefined,
+ };
+ await dom.render(props);
+ await dom.focus(dom.editable());
+ await dom.setCaret(6);
+
+ const selector = document.querySelector('.maka-thinking-level-selector');
+ assert.ok(selector, 'the thinking-level selector did not render');
+ await dom.pointerDown(selector as HTMLElement);
+ await dom.click(selector as HTMLElement);
+ await dom.click(dom.outside());
+
+ await dom.setCaret(2);
+ await dom.pointerDown(selector as HTMLElement);
+ await dom.click(selector as HTMLElement);
+ const low = [...document.querySelectorAll('[role="menuitemradio"]')].find((option) =>
+ option.textContent?.includes('Low'),
+ );
+ assert.ok(low, 'the thinking-level menu did not render the low option');
+ await dom.click(low as HTMLElement);
+ await dom.render({ ...props, activeThinkingLevel: 'low' as never });
+ await dom.flushAnimationFrames();
+
+ const caret = dom.selected.at(-1);
+ if (!caret) throw new Error('the thinking-level change removed the draft selection');
+ assert.equal(caret.startContainer, dom.editable().firstChild);
+ assert.equal(caret.startOffset, 2);
+});
diff --git a/packages/ui/src/chat-model-switcher.tsx b/packages/ui/src/chat-model-switcher.tsx
index 6a6918ce72..446609fd37 100644
--- a/packages/ui/src/chat-model-switcher.tsx
+++ b/packages/ui/src/chat-model-switcher.tsx
@@ -164,6 +164,7 @@ function ModelMenuItems(props: {
export function ThinkingLevelSelector(props: {
levels: readonly ThinkingLevel[];
current?: ThinkingLevel;
+ onOpenChange?(open: boolean): void;
onChange?(level: ThinkingLevel | undefined): void | Promise;
disabled?: boolean;
/** Why the control is locked (mid-turn etc.); replaces the action tooltip so the reason is discoverable, matching the model switcher beside it. */
@@ -189,6 +190,7 @@ export function ThinkingLevelSelector(props: {
placement="above"
hasChevron={false}
className="maka-composer-quiet-menu"
+ onOpenChange={props.onOpenChange}
button={{
label: currentLabel,
variant: 'ghost',
diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx
index 9ba7b50206..6e9163125a 100644
--- a/packages/ui/src/composer.tsx
+++ b/packages/ui/src/composer.tsx
@@ -514,9 +514,113 @@ export const Composer = forwardRef<
const inputHandleRef = useRef(null);
/** ChatComposerInput's root, from which the editable node is resolved. */
const inputRootRef = useRef(null);
+ /** Selection to restore after a toolbar control changes composer settings. */
+ const thinkingSelectionRef = useRef<{ range: Range; value: string } | null>(null);
+ /** Distinguishes a menu close after selection from cancel/light dismiss. */
+ const thinkingRestorePendingRef = useRef(false);
+ /** Suppresses the trigger focus returned by a cancelled menu close. */
+ const suppressThinkingTriggerFocusRef = useRef(false);
function editableNode(): HTMLElement | null {
return inputRootRef.current?.querySelector('[contenteditable="true"]') ?? null;
}
+ function rememberThinkingSelection() {
+ if (thinkingSelectionRef.current) return;
+ const editable = editableNode();
+ const selection = document.getSelection();
+ if (!editable || !selection || selection.rangeCount === 0) return;
+ const range = selection.getRangeAt(0);
+ if (!editable.contains(range.commonAncestorContainer)) return;
+ thinkingSelectionRef.current = {
+ range: range.cloneRange(),
+ value: inputHandleRef.current?.getValue() ?? editable.textContent ?? '',
+ };
+ }
+ function restoreThinkingSelection() {
+ const pending = thinkingSelectionRef.current;
+ thinkingSelectionRef.current = null;
+ if (!pending) return;
+ const editable = editableNode();
+ const currentValue = editable ? inputHandleRef.current?.getValue() ?? editable.textContent ?? '' : '';
+ if (
+ !editable ||
+ currentValue !== pending.value ||
+ !editable.contains(pending.range.startContainer) ||
+ !editable.contains(pending.range.endContainer)
+ ) return;
+ editable.focus();
+ const selection = document.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(pending.range);
+ }
+ function handleThinkingMenuOpenChange(open: boolean) {
+ if (open) {
+ suppressThinkingTriggerFocusRef.current = false;
+ // A cancelled menu must never seed the next interaction. Pointer opens
+ // may already have captured the range before the trigger takes focus.
+ if (!thinkingSelectionRef.current) rememberThinkingSelection();
+ return;
+ }
+ suppressThinkingTriggerFocusRef.current = true;
+ if (!thinkingRestorePendingRef.current) thinkingSelectionRef.current = null;
+ }
+ function changeThinkingLevel(
+ level: import('@maka/core/model-thinking').ThinkingLevel | undefined,
+ onChange?: (next: import('@maka/core/model-thinking').ThinkingLevel | undefined) => void | Promise,
+ ) {
+ if (!thinkingSelectionRef.current) rememberThinkingSelection();
+ const hasSelection = thinkingSelectionRef.current !== null;
+ thinkingRestorePendingRef.current = hasSelection;
+ const result = onChange?.(level);
+ if (hasSelection) {
+ window.requestAnimationFrame(() => {
+ restoreThinkingSelection();
+ thinkingRestorePendingRef.current = false;
+ });
+ }
+ return result;
+ }
+ useEffect(() => {
+ const form = formRef.current;
+ if (!form) return undefined;
+ const rememberForThinkingControl = (event: Event) => {
+ const target = event.target as Element | null;
+ const selector = target?.closest?.('.maka-thinking-level-selector');
+ if (selector) {
+ if (
+ event.type === 'pointerdown' &&
+ selector.getAttribute('aria-disabled') !== 'true' &&
+ !(selector as HTMLButtonElement).disabled
+ ) {
+ suppressThinkingTriggerFocusRef.current = false;
+ rememberThinkingSelection();
+ } else if (event.type === 'keydown') {
+ const key = (event as unknown as globalThis.KeyboardEvent).key;
+ if (key === 'Enter' || key === ' ' || key === 'ArrowDown') {
+ suppressThinkingTriggerFocusRef.current = false;
+ }
+ } else if (
+ event.type === 'focusin' &&
+ !suppressThinkingTriggerFocusRef.current
+ ) {
+ rememberThinkingSelection();
+ }
+ } else if (target?.closest?.('[contenteditable="true"]')) {
+ // The queued restore can focus the editor before its rAF runs.
+ if (!thinkingRestorePendingRef.current) {
+ thinkingSelectionRef.current = null;
+ thinkingRestorePendingRef.current = false;
+ }
+ }
+ };
+ form.addEventListener('pointerdown', rememberForThinkingControl, true);
+ form.addEventListener('focusin', rememberForThinkingControl, true);
+ form.addEventListener('keydown', rememberForThinkingControl, true);
+ return () => {
+ form.removeEventListener('pointerdown', rememberForThinkingControl, true);
+ form.removeEventListener('focusin', rememberForThinkingControl, true);
+ form.removeEventListener('keydown', rememberForThinkingControl, true);
+ };
+ }, []);
const [dragActive, setDragActive] = useState(false);
const [sendPending, setSendPending] = useState(false);
const [modelPickerOpen, setModelPickerOpen] = useState(false);
@@ -2133,7 +2237,8 @@ export const Composer = forwardRef<
changeThinkingLevel(level, props.onThinkingLevelChange)}
disabled={!modelSwitchAvailability.available}
disabledReason={thinkingSwitcherDisabledReason}
/>
@@ -2141,7 +2246,8 @@ export const Composer = forwardRef<
changeThinkingLevel(level, props.onNewChatThinkingLevelChange)}
/>
)}
{props.contextUsage ? : null}