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
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ describe('composer morph scope', () => {
expect(modelSelector).toContain('<PopoverTrigger asChild>{trigger}</PopoverTrigger>');
});

it('composer 选完模型后把焦点送回输入框;设置页不传 restoreFocusTarget', () => {
expect(chatInput).toContain('restoreFocusTarget={composerSuggestionFocusTarget}');
expect(modelSelector).toContain('restoreFocusTarget?: () => HTMLElement | null');
expect(modelSelector).toContain('onMouseDown={morphEnabled ? (event) => event.preventDefault() : undefined}');
expect(settingsModel).not.toContain('restoreFocusTarget');
expect(subagentModel).not.toContain('restoreFocusTarget');
expect(createWorker).not.toContain('restoreFocusTarget');
});

it('PermissionSelector / ExtraDirsButton 仅 composer 使用,恒走脱身上浮 morph(无 opt-in、无 Radix 回退)', () => {
expect(permissionSelector).not.toContain('useMorphPopover');
expect(extraDirsButton).not.toContain('useMorphPopover');
Expand Down
155 changes: 155 additions & 0 deletions apps/desktop/src/renderer/__tests__/morphPopover.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* 故本文件按新语义重写(取代旧 ghost/left-定位/focus-first-action/scroll-close 契约):
* - 打开聚焦 [data-morph-autofocus] → 首个 input → 面板容器(不抢焦到首个按钮,§14.2)
* - 关闭仅键盘(Esc)归还 trigger 焦点;鼠标关闭(选项/outside)不回焦(防误弹 trigger tooltip)
* - 传了 restoreFocusTarget 时,指针关闭立刻回该目标(选完模型能接着打字);
* Esc 仍回 trigger(§14.2);其它控件已接走则不抢
* - outside pointerdown 关闭,但嵌套 Radix portal(data-radix-popper-content-wrapper)内不算 outside
* - trigger chip 全程可见(不隐藏),再点即关(toggle)
* - 无 ghost 幽灵层
Expand Down Expand Up @@ -227,6 +229,159 @@ describe('MorphPopover interaction contract', () => {
expect(document.activeElement).not.toBe(trigger);
});

it('restoreFocusTarget:鼠标选完后把焦点送回指定输入,而不是 trigger 或 body', async () => {
function RestoreHomeHarness() {
const [open, setOpen] = useState(false);
const composerRef = useRef<HTMLTextAreaElement>(null);
return (
<>
<textarea ref={composerRef} aria-label="Composer" />
<MorphPopover
open={open}
onOpenChange={setOpen}
restoreFocusTarget={() => composerRef.current}
trigger={
<button type="button" onClick={() => setOpen((current) => !current)}>
Toggle
</button>
}
>
<button type="button" onClick={() => setOpen(false)}>
Select model
</button>
</MorphPopover>
</>
);
}

render(<RestoreHomeHarness />);
fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
const action = await screen.findByRole('button', { name: 'Select model' });
action.focus();
fireEvent.pointerDown(action);
fireEvent.click(action);

await waitFor(() => expect(screen.queryByRole('button', { name: 'Select model' })).toBeNull());
expect(document.activeElement).toBe(screen.getByLabelText('Composer'));
});

it('restoreFocusTarget:鼠标选完在收合动画开始时就把焦点送回输入,不等卸掉', async () => {
setReducedMotion(false);
function RestoreHomeImmediateHarness() {
const [open, setOpen] = useState(false);
const composerRef = useRef<HTMLTextAreaElement>(null);
return (
<>
<textarea ref={composerRef} aria-label="Composer" />
<MorphPopover
open={open}
onOpenChange={setOpen}
restoreFocusTarget={() => composerRef.current}
panelAriaLabel="Immediate restore panel"
trigger={
<button type="button" onClick={() => setOpen((current) => !current)}>
Toggle
</button>
}
>
<button type="button" onClick={() => setOpen(false)}>
Select model
</button>
</MorphPopover>
</>
);
}

render(<RestoreHomeImmediateHarness />);
fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
const action = await screen.findByRole('button', { name: 'Select model' });
action.focus();
fireEvent.pointerDown(action);
fireEvent.click(action);

await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('Composer')));
expect(screen.getByRole('group', { name: 'Immediate restore panel' })).toBeTruthy();
});

it('restoreFocusTarget:Esc 关闭仍回 trigger,不改 §14.2 键盘归还', async () => {
function RestoreHomeEscHarness() {
const [open, setOpen] = useState(false);
const composerRef = useRef<HTMLTextAreaElement>(null);
return (
<>
<textarea ref={composerRef} aria-label="Composer" />
<MorphPopover
open={open}
onOpenChange={setOpen}
restoreFocusTarget={() => composerRef.current}
panelAriaLabel="Restore home panel"
trigger={
<button type="button" onClick={() => setOpen(true)}>
Toggle
</button>
}
>
<button type="button">First action</button>
</MorphPopover>
</>
);
}

render(<RestoreHomeEscHarness />);
const trigger = screen.getByRole('button', { name: 'Toggle' });
fireEvent.click(trigger);
await screen.findByRole('group', { name: 'Restore home panel' });
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() =>
expect(screen.queryByRole('group', { name: 'Restore home panel' })).toBeNull(),
);
expect(document.activeElement).toBe(trigger);
});

it('restoreFocusTarget:焦点已被其它控件接走时不抢回', async () => {
function RestoreHomeHandoffHarness() {
const [open, setOpen] = useState(false);
const composerRef = useRef<HTMLTextAreaElement>(null);
const destinationRef = useRef<HTMLButtonElement>(null);
return (
<>
<textarea ref={composerRef} aria-label="Composer" />
<MorphPopover
open={open}
onOpenChange={setOpen}
restoreFocusTarget={() => composerRef.current}
trigger={
<button type="button" onClick={() => setOpen(true)}>
Toggle
</button>
}
>
<button
type="button"
onClick={() => {
destinationRef.current?.focus();
setOpen(false);
}}
>
Continue elsewhere
</button>
</MorphPopover>
<button ref={destinationRef} type="button">
Destination
</button>
</>
);
}

render(<RestoreHomeHandoffHarness />);
fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
fireEvent.click(await screen.findByRole('button', { name: 'Continue elsewhere' }));
await waitFor(() =>
expect(screen.queryByRole('button', { name: 'Continue elsewhere' })).toBeNull(),
);
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Destination' }));
});

it('动作延迟打开下一层交互面时不在收合结束后抢回 trigger', async () => {
setReducedMotion(false);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4857,7 +4857,10 @@ export function ChatInput({
},
[composerMutationLocked, editor, setSyntheticAtAnchor, trigger],
);
const composerSuggestionFocusTarget = useCallback(() => editor?.view.dom ?? null, [editor]);
const composerSuggestionFocusTarget = useCallback(() => {
if (!editor || editor.isDestroyed) return null;
return editor.view.dom;
}, [editor]);

// ── Send / Stop wiring ─────────────────────────────────────────────
const dispatchSendInFlightKeysRef = useRef(new Set<string>());
Expand Down Expand Up @@ -8285,6 +8288,7 @@ export function ChatInput({
// composer 工具条(含新建对话框 create-agent)统一走脱身上浮 morph;
// settings/CreateWorker 不传该 prop → Radix 回退,不 morph。
useMorphPopover
restoreFocusTarget={composerSuggestionFocusTarget}
Comment thread
dashhuang marked this conversation as resolved.
/>
</div>
<div
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,11 @@ interface ModelSelectorProps {
visualVariant?: 'default' | 'create-agent';
/** 仅普通 composer 显式开启 chip → panel 容器形变;设置页/worker 等维持 Radix。 */
useMorphPopover?: boolean;
/**
* 指针关闭后的回焦目标(composer 输入框)。选完模型 / 点空白后立刻送回输入框;
* Esc 仍回 pill(§14.2)。设置页等非 composer 入口不传。
*/
restoreFocusTarget?: () => HTMLElement | null;
/** Popover 弹出方向,默认 "top"(底部工具栏向上弹),dialog 内嵌场景传 "bottom"。 */
popoverSide?: 'top' | 'bottom';
/**
Expand Down Expand Up @@ -3093,6 +3098,7 @@ export function ModelSelector({
triggerVariant = 'toolbar',
visualVariant = 'default',
useMorphPopover = false,
restoreFocusTarget,
popoverSide = 'top',
maxVisibleModelRows,
configurationEnabled = true,
Expand Down Expand Up @@ -3529,6 +3535,9 @@ export function ModelSelector({
<button
type="button"
disabled={switching || disabled}
// 阻 mousedown 抢焦点 —— 否则点 pill 会先把光标从输入框挪走,选完模型后
// 还要再点一次才能接着打字。键盘 Tab 仍可正常 focus。
onMouseDown={morphEnabled ? (event) => event.preventDefault() : undefined}
onClick={morphEnabled ? () => handleOpenChange(!openRef.current) : undefined}
aria-expanded={open && !disabled}
aria-haspopup="listbox"
Expand Down Expand Up @@ -3865,6 +3874,7 @@ export function ModelSelector({
stickyWidth
stickyWidthKey={pickerLayout}
panelAriaLabel={ariaLabel}
{...(restoreFocusTarget ? { restoreFocusTarget } : {})}
trigger={trigger}
>
{content}
Expand Down
56 changes: 39 additions & 17 deletions apps/desktop/src/renderer/components/ui/morph-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ import { cn } from '@/lib/utils';
* Chromium "ResizeObserver loop" 告警)。
* - prefers-reduced-motion 降级为直切(红线 a);焦点/Esc/outside-click 语义
* 与 §14.2 相同(红线 d):打开聚焦 [data-morph-autofocus] → 首个 input →
* 面板容器,关闭后焦点归还 trigger。
* 面板容器,关闭后焦点归还 trigger。composer 工具条可另传 restoreFocusTarget:
* 仅指针关闭(选完模型 / 点空白)在收合一开始就把焦点送回输入框;Esc 等键盘关闭
* 仍回 trigger(§14.2)。其它控件已接走焦点时仍不抢。
* - Esc 分层:面板内嵌套的 Radix 浮层(role=dialog,如模型行 effort 子面板)
* 开着时先让内层关。必须挂 capture —— keydown 是 discrete 事件,Radix 的
* capture 处理器关层后 React 同步 flush DOM 移除,bubble 阶段已看不到 dialog;
Expand Down Expand Up @@ -137,6 +139,26 @@ interface MorphPopoverProps {
panelAriaLabel?: string;
/** 打开完成后的外部焦点目标;未提供时按面板内默认规则聚焦。 */
autoFocusTarget?: () => HTMLElement | null;
/**
* 指针关闭时的回焦目标。composer 工具条用它在选完模型 / 点空白后立刻把焦点
* 送回输入框(收合动画一开始就回,不等卸掉)。键盘关闭(Esc)仍回 trigger,
* 遵守 §14.2。未提供时指针关闭不回焦。目标已被其它控件接走时仍不抢。
*/
restoreFocusTarget?: () => HTMLElement | null;
}

/** 焦点是否已被面板 / trigger 之外的控件接走(body 不算)。 */
function isFocusClaimedElsewhere(
active: Element | null,
panel: HTMLElement,
wrap: HTMLElement,
): boolean {
return (
active instanceof Node &&
active !== document.body &&
!panel.contains(active) &&
!wrap.contains(active)
);
}

/** 是否处于 reduced-motion(SSR/jsdom 无 matchMedia 时按 false) */
Expand Down Expand Up @@ -176,6 +198,7 @@ export function MorphPopover({
wrapperClassName,
panelAriaLabel,
autoFocusTarget,
restoreFocusTarget,
}: MorphPopoverProps) {
// mounted 独立于 open:关闭时先播收合动画,动画完再卸载 portal
const [mounted, setMounted] = useState(false);
Expand Down Expand Up @@ -410,9 +433,9 @@ export function MorphPopover({
// pointerdown 走 capture 先触发关闭,浏览器把焦点移交给被点控件的默认动作发生在
// 事件派发结束之后 —— 同步快照会误判"焦点还在面板内",动画完抢回 trigger,偷走
// 用户刚点的控件焦点(codex P2)。setTimeout(0) 落在默认聚焦之后:此刻焦点仍在
// 面板 / trigger 内 = 键盘关闭(Enter/Space 选项、Esc、点 trigger 收起)→ 归还
// trigger;已被外部控件 / body 接走(点空白、动作交接)→ 不抢回,避免点空白
// 关闭后凭空冒 trigger 的 tooltip
// 面板 / trigger 内 = 键盘关闭(Enter/Space 选项、Esc)→ 收合结束后归还 trigger;
// 指针关闭(选模型 / 点空白)inert 后焦点掉到 body → 立刻送回 restoreFocusTarget
// (不等 240ms 卸掉,否则选完接着打的字会丢);已被外部控件接走 → 不抢
let ownedFocusAtClose = false;
focusSnapTimerRef.current = setTimeout(() => {
focusSnapTimerRef.current = null;
Expand All @@ -424,23 +447,21 @@ export function MorphPopover({
// pointer-events-none 只挡鼠标不挡 Tab,键盘用户 Esc/选完后立刻 Tab 会摸进
// 隐形面板里的按钮/选项(codex P2)。inert 把收合余辉整体移出 tab order 与辅助树。
panel.inert = true;
if (ownedFocusAtClose) return;
if (isFocusClaimedElsewhere(document.activeElement, panel, wrap)) return;
const preferredHome = restoreFocusTarget?.();
if (preferredHome instanceof HTMLElement && preferredHome.isConnected) {
preferredHome.focus({ preventScroll: true });
}
}, 0);
closeTimerRef.current = setTimeout(
() => {
setMounted(false);
if (ownedFocusAtClose) {
const active = document.activeElement;
const focusClaimedElsewhere =
active instanceof Node &&
active !== document.body &&
!panel.contains(active) &&
!wrap.contains(active);
if (!focusClaimedElsewhere) {
wrap.querySelector<HTMLElement>('button, [tabindex]')?.focus({
preventScroll: true,
});
}
}
if (!ownedFocusAtClose) return;
if (isFocusClaimedElsewhere(document.activeElement, panel, wrap)) return;
wrap.querySelector<HTMLElement>('button, [tabindex]')?.focus({
preventScroll: true,
});
},
reducedClose ? 0 : MORPH_MS + 20,
);
Expand All @@ -461,6 +482,7 @@ export function MorphPopover({
endBorderColor,
syncPanelToContent,
autoFocusTarget,
restoreFocusTarget,
]);

/** 打开稳定后跟随内容尺寸变化(搜索过滤 / Edit 面板展宽),同曲线平滑过渡 */
Expand Down