diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 678d881a8a..2837a221b0 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -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()), +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('[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 diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 1c0df7a9d3..d539bd8030 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -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} > -
0 || - pendingApproval - ? styles.contentHasMessages - : undefined, - ] - .filter(Boolean) - .join(' ')} + - - {btwMessage?.role === 'btw' && ( -
- -
- )} -
+
0 || + pendingApproval + ? styles.contentHasMessages + : undefined, + ] + .filter(Boolean) + .join(' ')} + > + + {btwMessage?.role === 'btw' && ( +
+ +
+ )} +
+ diff --git a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.test.tsx b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.test.tsx index fad0ebc643..de67786478 100644 --- a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.test.tsx +++ b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.test.tsx @@ -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([ + + + Formula + + , + + + =IMPORTXML("https://example.com") + + , + ]); + + 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(); diff --git a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx index 65f87e8ed1..6c61db0126 100644 --- a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx +++ b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx @@ -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(null); const [filters, setFilters] = useState>({}); @@ -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; diff --git a/packages/web-shell/client/interactionBlockContext.ts b/packages/web-shell/client/interactionBlockContext.ts new file mode 100644 index 0000000000..53a84ca186 --- /dev/null +++ b/packages/web-shell/client/interactionBlockContext.ts @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react'; + +export type RegisterInteractionBlocker = () => () => void; + +const noopRelease = () => {}; +const noopRegisterInteractionBlocker = () => noopRelease; + +export const InteractionBlockContext = + createContext(noopRegisterInteractionBlocker); + +export function useInteractionBlocker(): RegisterInteractionBlocker { + return useContext(InteractionBlockContext); +}