Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 5f07220649 fix(tabs): prevent tab drag from swallowing close-button click
A few pixels of movement between mousedown and mouseup on a top-tab close
button starts an HTML5 drag on the draggable ancestor, so click never fires.
Guard the primary button press before Chromium can promote it to dragstart.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 12:25:10 +00:00
4 changed files with 68 additions and 2 deletions

No files matched your search

+34
View File
@@ -281,6 +281,40 @@ test("duplicate double-click behavior falls back to copying when unavailable", (
assert.deepEqual(copiedSessionIds, ["session-1"]);
});
test("draggable top-tab close buttons cancel the drag gesture on mousedown", () => {
const closeButtonLabels = [
"tabs.closePluginViewAria",
"Close editor tab",
"tabs.closeSessionAria",
"tabs.closeLogViewAria",
];
for (const label of closeButtonLabels) {
const labelIndex = topTabItemsSource.indexOf(label);
assert.notEqual(labelIndex, -1, `missing close label ${label}`);
const buttonStart = topTabItemsSource.lastIndexOf("<button", labelIndex);
const buttonEnd = topTabItemsSource.indexOf(">", labelIndex);
const openingTag = topTabItemsSource.slice(buttonStart, buttonEnd);
assert.match(
openingTag,
/onMouseDown=\{handleTabCloseButtonMouseDown\}/,
`${label} close button must guard mousedown`,
);
}
assert.equal(
topTabItemsSource.match(/onMouseDown=\{handleTabCloseButtonMouseDown\}/g)?.length,
4,
);
const workspaceSource = topTabItemsSource.slice(
topTabItemsSource.indexOf("export const WorkspaceTopTab"),
topTabItemsSource.indexOf("WorkspaceTopTab.displayName"),
);
assert.equal(workspaceSource.includes("handleTabCloseButtonMouseDown"), false);
assert.equal(workspaceSource.includes("<button"), false);
});
test("session close button double click stays on the close button", () => {
let stopPropagationCount = 0;
+5 -2
View File
@@ -26,7 +26,7 @@ import { cn } from '../../lib/utils';
import { Host, TerminalSession, Workspace } from '../../types';
import { DISTRO_LOGOS, DISTRO_COLORS } from '../DistroAvatar';
import { getShellIconPath, isMonochromeShellIcon } from '../../lib/useDiscoveredShells';
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../../lib/tabInteractions';
import { handleTabCloseButtonMouseDown, handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../../lib/tabInteractions';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '../ui/context-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { SessionTabContextMenuContent } from './SessionTabContextMenuContent';
@@ -555,7 +555,7 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
: <PluginContributionIcon pluginId={tab.pluginId} icon={tab.icon} className="shrink-0" />}
<span className="truncate leading-5">{tab.title}</span>
</div>
<button onClick={close} className="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-muted" aria-label={t('tabs.closePluginViewAria', { title: tab.title })}><X size={12} /></button>
<button onMouseDown={handleTabCloseButtonMouseDown} onClick={close} className="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-muted" aria-label={t('tabs.closePluginViewAria', { title: tab.title })}><X size={12} /></button>
</div>
);
return (
@@ -704,6 +704,7 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
{isDetached && <SquareArrowOutUpRight size={11} className="shrink-0 opacity-70" />}
</div>
<button
onMouseDown={handleTabCloseButtonMouseDown}
onClick={handleClose}
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
aria-label="Close editor tab"
@@ -879,6 +880,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
<div className="flex-shrink-0">{sessionStatusDot(session.status, hasActivity)}</div>
</div>
<button
onMouseDown={handleTabCloseButtonMouseDown}
onClick={(e) => onCloseSession(session.id, e)}
onDoubleClick={stopCloseButtonDoubleClickPropagation}
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
@@ -1228,6 +1230,7 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
</span>
</div>
<button
onMouseDown={handleTabCloseButtonMouseDown}
onClick={handleClose}
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
aria-label={t('tabs.closeLogViewAria')}
+15
View File
@@ -4,6 +4,7 @@ import type React from "react";
import {
MIDDLE_MOUSE_BUTTON,
handleTabCloseButtonMouseDown,
handleTabMiddleClickClose,
handleTabMiddleMouseDown,
} from "./tabInteractions.ts";
@@ -64,3 +65,17 @@ test("handleTabMiddleMouseDown suppresses autoscroll only for the middle button"
handleTabMiddleMouseDown(left.event);
assert.equal(left.calls.preventDefault, 0);
});
test("handleTabCloseButtonMouseDown blocks ancestor drag only for the primary button", () => {
const left = makeEvent(0);
handleTabCloseButtonMouseDown(left.event);
assert.equal(left.calls.preventDefault, 1);
assert.equal(left.calls.stopPropagation, 1);
for (const button of [MIDDLE_MOUSE_BUTTON, 2]) {
const other = makeEvent(button);
handleTabCloseButtonMouseDown(other.event);
assert.equal(other.calls.preventDefault, 0, `button ${button} must not preventDefault`);
assert.equal(other.calls.stopPropagation, 0, `button ${button} must not stopPropagation`);
}
});
+14
View File
@@ -18,6 +18,20 @@ export const handleTabMiddleMouseDown = (e: React.MouseEvent): void => {
}
};
/**
* Keep a primary-button press on a tab close control from starting an HTML5
* drag on the ancestor `draggable` tab. Chromium promotes a few pixels of
* movement between mousedown and mouseup into `dragstart`, which swallows the
* click (#3886). `preventDefault` stops that drag; `stopPropagation` keeps the
* tab's own mousedown handler from seeing the press. Middle and right buttons
* are left alone so aux-click close and context menus stay unchanged.
*/
export const handleTabCloseButtonMouseDown = (e: React.MouseEvent): void => {
if (e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
};
/**
* Close a tab when it is middle-clicked. Wire to `onAuxClick`, which fires for
* a completed non-primary click. Left clicks (tab activation) and right clicks