mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
fix(web-shell): block shortcuts behind cell dialog
This commit is contained in:
parent
1a99094d68
commit
0f2a025a86
5 changed files with 190 additions and 51 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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("https://example.com")</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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
13
packages/web-shell/client/interactionBlockContext.ts
Normal file
13
packages/web-shell/client/interactionBlockContext.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue