fix(web-shell): block shortcuts behind cell dialog

This commit is contained in:
jifeng.zjd 2026-07-09 11:13:11 +08:00
parent 1a99094d68
commit 0f2a025a86
5 changed files with 190 additions and 51 deletions

View file

@ -94,6 +94,7 @@ const {
},
mockStore: {
dispatch: vi.fn(),
reset: vi.fn(),
appendLocalUserMessage: vi.fn(),
appendLocalAssistantMessage: vi.fn(),
},
@ -149,8 +150,8 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
useWorkspaceEventSignals: () => ({ extensionsVersion: 0 }),
}));
vi.mock('@qwen-code/sdk/daemon', async (importOriginal) => ({
...(await importOriginal<typeof import('@qwen-code/sdk/daemon')>()),
vi.mock('@qwen-code/sdk/daemon', () => ({
DAEMON_GOAL_STATUS_SENTINEL_PREFIX: 'qwen-goal-status:',
isDaemonTurnError: () => false,
}));
@ -215,6 +216,27 @@ vi.mock('./components/ChatEditor', async () => {
vi.mock('./components/MessageList', async () => {
const React = await import('react');
const { useInteractionBlocker } = await import('./interactionBlockContext');
function InteractionBlockerProbe() {
const registerInteractionBlocker = useInteractionBlocker();
const releaseRef = React.useRef<(() => void) | null>(null);
return React.createElement(
'button',
{
'data-testid': 'interaction-blocker',
onClick: () => {
if (releaseRef.current) {
releaseRef.current();
releaseRef.current = null;
} else {
releaseRef.current = registerInteractionBlocker();
}
},
type: 'button',
},
releaseRef.current ? 'release blocker' : 'register blocker',
);
}
return {
MessageList: React.forwardRef(function MessageList(
props: { showRetryHint?: boolean; onRetryClick?: () => void },
@ -224,6 +246,7 @@ vi.mock('./components/MessageList', async () => {
return React.createElement(
'div',
{ 'data-testid': 'messages' },
React.createElement(InteractionBlockerProbe),
props.showRetryHint
? React.createElement(
'button',
@ -570,6 +593,8 @@ beforeEach(() => {
mockSessionActions.sendShellCommand.mockResolvedValue(undefined);
mockSessionActions.getStats.mockResolvedValue({});
mockSessionActions.loadSession.mockResolvedValue(undefined);
mockStore.reset.mockClear();
mockStore.dispatch.mockClear();
mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({ skills: [] });
mockWorkspaceActions.loadProviders.mockResolvedValue({ current: null });
mockWorkspaceActions.loadPreflight.mockResolvedValue(null);
@ -1876,6 +1901,43 @@ describe('App session callbacks', () => {
expect(testState.latestChatEditorProps?.dialogOpen).toBe(true);
});
it('blocks app-level shortcuts while an external modal is registered', async () => {
const { container } = renderApp();
await flush();
expect(testState.latestChatEditorProps?.dialogOpen).toBe(false);
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="interaction-blocker"]')
?.click();
await Promise.resolve();
});
expect(testState.latestChatEditorProps?.dialogOpen).toBe(true);
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
ctrlKey: true,
key: 'l',
}),
);
window.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
ctrlKey: true,
key: 'y',
}),
);
});
expect(mockStore.reset).not.toHaveBeenCalled();
expect(mockStore.dispatch).not.toHaveBeenCalled();
});
it('restores composer focus after an approval resolves following a panel auto-close', async () => {
// Regression: on panel auto-close the editor focus is intentionally skipped
// (the approval owns the keyboard); when the approval later resolves with no

View file

@ -160,6 +160,7 @@ import {
type TodoSnapshotDiff,
} from './utils/todos';
import { ThemeProvider } from './themeContext';
import { InteractionBlockContext } from './interactionBlockContext';
import {
WebShellThemeId,
THEME_SETTING_KEY,
@ -1517,6 +1518,17 @@ export function App({
const [showAuthDialog, setShowAuthDialog] = useState(false);
const [memoryRefreshSignal, setMemoryRefreshSignal] = useState(0);
const [memoryAddSignal, setMemoryAddSignal] = useState(0);
const [externalInteractionBlockCount, setExternalInteractionBlockCount] =
useState(0);
const registerInteractionBlocker = useCallback(() => {
let released = false;
setExternalInteractionBlockCount((count) => count + 1);
return () => {
if (released) return;
released = true;
setExternalInteractionBlockCount((count) => Math.max(0, count - 1));
};
}, []);
// Refresh commands when extensions change (install/uninstall/update).
const workspaceEventSignals = useWorkspaceEventSignals();
@ -1802,6 +1814,7 @@ export function App({
agentsDialogMode !== null ||
showMemoryDialog ||
showAuthDialog ||
externalInteractionBlockCount > 0 ||
// The Settings / Daemon Status panel replaces the chat surface, so — like a
// modal — it must suppress chat-only global shortcuts (Ctrl+L/O/Y, the
// Shift+Tab mode cycle, the btw hotkey). Escape is intercepted earlier and
@ -4934,54 +4947,58 @@ export function App({
timeline={todoTimeline}
details={todoDetails}
>
<div
className={[
styles.content,
showFloatingTodos ||
displayMessages.length > 0 ||
pendingApproval
? styles.contentHasMessages
: undefined,
]
.filter(Boolean)
.join(' ')}
<InteractionBlockContext.Provider
value={registerInteractionBlocker}
>
<MessageList
ref={messageListRef}
messages={displayMessages}
pendingApproval={pendingToolApproval}
onShowContextDetail={handleShowContextDetail}
loadingTranscript={connection.loadingTranscript}
catchingUp={connection.catchingUp}
isResponding={streamingState !== 'idle'}
activeTurnStartedAt={activeTurnStartedAt}
workspaceCwd={connection.workspaceCwd || ''}
hideSessionTimeline={
effectiveChatWidthMode === 'wide'
}
showRetryHint={showRetryHint}
onRetryClick={handleRetry}
onBranchSession={handleBranchCurrentSession}
welcomeHeader={
isChatEmptyState ? welcomeHeader : undefined
}
tailContent={undefined}
tailKey={undefined}
onCanScrollToBottomChange={
handleCanScrollToBottomChange
}
virtualScrollThreshold={virtualScrollThreshold}
/>
{btwMessage?.role === 'btw' && (
<div className={styles.btwPanel}>
<BtwMessage
question={btwMessage.question}
answer={btwMessage.answer}
isPending={btwMessage.isPending}
/>
</div>
)}
</div>
<div
className={[
styles.content,
showFloatingTodos ||
displayMessages.length > 0 ||
pendingApproval
? styles.contentHasMessages
: undefined,
]
.filter(Boolean)
.join(' ')}
>
<MessageList
ref={messageListRef}
messages={displayMessages}
pendingApproval={pendingToolApproval}
onShowContextDetail={handleShowContextDetail}
loadingTranscript={connection.loadingTranscript}
catchingUp={connection.catchingUp}
isResponding={streamingState !== 'idle'}
activeTurnStartedAt={activeTurnStartedAt}
workspaceCwd={connection.workspaceCwd || ''}
hideSessionTimeline={
effectiveChatWidthMode === 'wide'
}
showRetryHint={showRetryHint}
onRetryClick={handleRetry}
onBranchSession={handleBranchCurrentSession}
welcomeHeader={
isChatEmptyState ? welcomeHeader : undefined
}
tailContent={undefined}
tailKey={undefined}
onCanScrollToBottomChange={
handleCanScrollToBottomChange
}
virtualScrollThreshold={virtualScrollThreshold}
/>
{btwMessage?.role === 'btw' && (
<div className={styles.btwPanel}>
<BtwMessage
question={btwMessage.question}
answer={btwMessage.answer}
isPending={btwMessage.isPending}
/>
</div>
)}
</div>
</InteractionBlockContext.Provider>
</TodoContextsProvider>
</CompactModeContext.Provider>

View file

@ -726,6 +726,33 @@ describe('EnhancedMarkdownTable', () => {
expect(document.body.textContent).toContain('Copied!');
});
it('sanitizes the current cell value copied from the dialog', async () => {
const writeText = mockClipboard();
const container = renderTableContent([
<thead key="head">
<tr>
<th>Formula</th>
</tr>
</thead>,
<tbody key="body">
<tr>
<td>=IMPORTXML(&quot;https://example.com&quot;)</td>
</tr>
</tbody>,
]);
doubleClick(dataCell(container, 0, 0));
await act(async () => {
textButton(document.body, 'Copy').click();
await Promise.resolve();
await Promise.resolve();
});
expect(writeText).toHaveBeenCalledWith(
'\'=IMPORTXML("https://example.com")',
);
});
it('keeps the cell value dialog in sync with table updates', async () => {
const writeText = mockClipboard();
const container = document.createElement('div');
@ -756,10 +783,17 @@ describe('EnhancedMarkdownTable', () => {
render('Alpha');
doubleClick(dataCell(container, 0, 0));
expect(cellDialog()?.textContent).toContain('Alpha');
await act(async () => {
textButton(document.body, 'Copy').click();
await Promise.resolve();
await Promise.resolve();
});
expect(cellDialog()?.textContent).toContain('Copied!');
render('Beta');
expect(cellDialog()?.textContent).not.toContain('Alpha');
expect(cellDialog()?.textContent).toContain('Beta');
expect(cellDialog()?.textContent).not.toContain('Copied!');
await act(async () => {
textButton(document.body, 'Copy').click();

View file

@ -20,6 +20,7 @@ import {
} from 'react';
import { createPortal } from 'react-dom';
import { useI18n } from '../../i18n';
import { useInteractionBlocker } from '../../interactionBlockContext';
import { useTheme, WebShellThemeId } from '../../themeContext';
import styles from './EnhancedMarkdownTable.module.css';
@ -1172,6 +1173,7 @@ export function EnhancedTable({
}) {
const { t } = useI18n();
const theme = useTheme();
const registerInteractionBlocker = useInteractionBlocker();
const tableId = useId();
const [sort, setSort] = useState<SortState | null>(null);
const [filters, setFilters] = useState<Record<number, ColumnFilter>>({});
@ -1498,6 +1500,7 @@ export function EnhancedTable({
const row = visibleRows.find((item) => item.key === cellDialog.rowKey);
return row?.cells[cellDialog.columnIndex] ?? null;
}, [cellDialog, visibleRows]);
const currentCellDialogText = currentCellDialogCell?.text;
const cellDialogThemeClass =
theme === WebShellThemeId.Light ? styles.themeLight : styles.themeDark;
@ -1565,6 +1568,16 @@ export function EnhancedTable({
}
}, [cellDialog, detailRowKey, visibleRows]);
useEffect(() => {
if (!cellDialog) return;
return registerInteractionBlocker();
}, [cellDialog, registerInteractionBlocker]);
useEffect(() => {
if (!cellDialog) return;
resetCopiedCellDialog();
}, [cellDialog, currentCellDialogText, resetCopiedCellDialog]);
useEffect(() => {
if (!cellDialog) return;
const dialog = cellDialogRef.current;
@ -1744,10 +1757,10 @@ export function EnhancedTable({
};
const copyCellDialogValue = () => {
if (currentCellDialogCell?.text == null || !navigator.clipboard) return;
if (currentCellDialogText == null || !navigator.clipboard) return;
const copyGeneration = copiedCellDialogGenRef.current;
void navigator.clipboard
.writeText(currentCellDialogCell.text)
.writeText(sanitizeForClipboard(currentCellDialogText))
.then(() => {
if (!mountedRef.current) return;
if (copiedCellDialogGenRef.current !== copyGeneration) return;

View file

@ -0,0 +1,13 @@
import { createContext, useContext } from 'react';
export type RegisterInteractionBlocker = () => () => void;
const noopRelease = () => {};
const noopRegisterInteractionBlocker = () => noopRelease;
export const InteractionBlockContext =
createContext<RegisterInteractionBlocker>(noopRegisterInteractionBlocker);
export function useInteractionBlocker(): RegisterInteractionBlocker {
return useContext(InteractionBlockContext);
}