fix: restore consistent controls, usable overlays and narrow layouts
Resolve 15 shared-control, overlay and responsive-layout issues plus the MCP editor being hidden behind settings. Restore theme contrast, danger and disabled states, visible keyboard focus, and consistent Continue fields. Keep the workbar add button reachable and model protocol values readable in narrow containers.
Make nested MCP and image dialogs usable above their parents with focus restoration, keep project Escape handling within the active modal, release native browser views for overlapping help, and restore keyboard access and Escape in sandboxed HTML previews without relaxing sandbox permissions. Add unit and real Electron regression coverage and update design and validation documentation.
Release note: 修复深浅主题下控件边框、危险操作、禁用状态和键盘焦点显示不一致的问题。MCP 编辑器与图片预览可在父弹窗上正常操作,HTML 全屏预览支持键盘进入和 Escape 返回;窄工作栏的添加按钮保持可用,模型连接协议文字完整可读。
Validation: the full suite recorded 5309 passed, 3 failed and 83 skipped. The ProjectSwitcher initial-focus regression was then fixed, and App, ProjectSwitcher and overlay Electron tests passed all 331 cases. The other two HeartbeatCenter failures were reproduced on isolated HEAD 11facf0 and remain pre-existing. Typecheck and lint passed after the fix; staged whitespace checks passed. The full suite was not rerun after the focused fix.
Limits: Electron evidence is from Windows development runs, with fixtures for unrelated services and native occlusion throttling disabled in some drivers. No production build, packaging, macOS/Linux, remote Agent host or external MCP connection validation; skipped tests and the documented scaling and native-select coverage gaps remain.
This commit is contained in:
+27
-27
@@ -102,7 +102,7 @@ describe('terminal IPC boundary', () => {
|
||||
})
|
||||
|
||||
it('validates terminal requests and routes them through the trusted renderer owner', async () => {
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
id: 73,
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
@@ -1065,7 +1065,7 @@ describe('registerIpcHandlers update source routing', () => {
|
||||
})
|
||||
|
||||
it('uses the persisted source for checks and the download page', async () => {
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
@@ -1172,7 +1172,7 @@ describe('registerIpcHandlers model download source routing', () => {
|
||||
})
|
||||
|
||||
it('uses the persisted source and rejects stale renderer requests', async () => {
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
@@ -1421,7 +1421,7 @@ describe('registerIpcHandlers DSH runtime extensions', () => {
|
||||
})
|
||||
|
||||
it('validates extension actions, reloads the Runtime, and trusts only the renderer', async () => {
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
@@ -1568,7 +1568,7 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
})
|
||||
|
||||
it('validates narrow host requests and restricts them to the trusted renderer', async () => {
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
@@ -2302,7 +2302,7 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
setProjectArchived: vi.fn(),
|
||||
deleteProject: vi.fn()
|
||||
}
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
@@ -2667,7 +2667,7 @@ describe('registerIpcHandlers lifecycle tracking', () => {
|
||||
const workspace = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-ipc-settings-')
|
||||
)
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
send: vi.fn()
|
||||
@@ -2879,7 +2879,7 @@ describe('registerIpcHandlers lifecycle tracking', () => {
|
||||
.mockRejectedValueOnce(activationError)
|
||||
.mockRejectedValueOnce(rollbackError)
|
||||
const repairs = vi.fn()
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
send: vi.fn()
|
||||
@@ -3041,7 +3041,7 @@ describe('registerIpcHandlers lifecycle tracking', () => {
|
||||
})
|
||||
const repairs = vi.fn(() => [])
|
||||
const reportRepairs = vi.fn()
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
send: vi.fn()
|
||||
@@ -3179,7 +3179,7 @@ describe('registerIpcHandlers knowledge snapshot ontology', () => {
|
||||
tasks: []
|
||||
}))
|
||||
}
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
send: vi.fn()
|
||||
@@ -3267,7 +3267,7 @@ describe('registerIpcHandlers knowledge embedding index', () => {
|
||||
knowledgeEmbeddingApiKeyConfigured: false
|
||||
}))
|
||||
}
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
send: vi.fn()
|
||||
@@ -3361,7 +3361,7 @@ describe('registerIpcHandlers knowledge task actions', () => {
|
||||
cancelTask: vi.fn(async () => true),
|
||||
retryTask: vi.fn(async () => undefined)
|
||||
}
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
send: vi.fn()
|
||||
@@ -3432,7 +3432,7 @@ describe('registerIpcHandlers model ZIP dialogs', () => {
|
||||
})
|
||||
|
||||
it('imports and exports speech and OCR ZIPs through trusted dialogs', async () => {
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
send: vi.fn()
|
||||
@@ -3665,7 +3665,7 @@ describe('registerIpcHandlers document parsing', () => {
|
||||
listPendingConversationQueueIds: vi.fn(() => []),
|
||||
createInlineArtifact
|
||||
}
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
send: vi.fn()
|
||||
@@ -3811,7 +3811,7 @@ describe('registerIpcHandlers connection tests', () => {
|
||||
dispose: vi.fn(async () => undefined)
|
||||
}
|
||||
const getResolvedSettings = vi.fn(async () => resolvedSettings)
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
send: vi.fn()
|
||||
@@ -3934,7 +3934,7 @@ describe('registerIpcHandlers connection tests', () => {
|
||||
getStatus: vi.fn(),
|
||||
dispose: vi.fn(async () => undefined)
|
||||
}
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
send: vi.fn()
|
||||
@@ -4029,7 +4029,7 @@ describe('registerIpcHandlers Runtime config actions', () => {
|
||||
continueConfigPath: configPath
|
||||
}
|
||||
}))
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
send: vi.fn()
|
||||
@@ -4160,7 +4160,7 @@ describe('registerIpcHandlers window controls', () => {
|
||||
it('restricts custom chrome controls to the trusted main window', async () => {
|
||||
let maximized = false
|
||||
const listeners = new Map<string, () => void>()
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
send: vi.fn()
|
||||
@@ -4259,7 +4259,7 @@ describe('registerIpcHandlers workspace files', () => {
|
||||
listPendingConversationQueueIds: vi.fn(() => []),
|
||||
getProject: vi.fn(() => ({ id: projectId, rootPath }))
|
||||
}
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html')
|
||||
}
|
||||
@@ -4348,7 +4348,7 @@ describe('registerIpcHandlers workspace files', () => {
|
||||
updatedAt: '2026-08-01T00:00:00.000Z'
|
||||
}))
|
||||
}
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html')
|
||||
}
|
||||
@@ -4424,7 +4424,7 @@ describe('registerIpcHandlers token usage', () => {
|
||||
listPendingConversationQueueIds: vi.fn(() => []),
|
||||
getExecutionStatsAsync: vi.fn(async () => summary)
|
||||
}
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'), send: vi.fn()
|
||||
}
|
||||
@@ -4470,7 +4470,7 @@ describe('registerIpcHandlers token usage', () => {
|
||||
listPendingConversationQueueIds: vi.fn(() => []),
|
||||
getTokenUsageSummary: vi.fn(() => summary)
|
||||
}
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: {
|
||||
url: 'file:///goodbuddy/index.html'
|
||||
},
|
||||
@@ -4575,7 +4575,7 @@ describe('registerIpcHandlers local conversation persistence', () => {
|
||||
clear: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: {
|
||||
url: 'file:///goodbuddy/index.html'
|
||||
},
|
||||
@@ -4709,7 +4709,7 @@ describe('registerIpcHandlers local conversation persistence', () => {
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => [])
|
||||
}
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: {
|
||||
url: 'file:///goodbuddy/index.html'
|
||||
},
|
||||
@@ -4829,7 +4829,7 @@ describe('registerIpcHandlers Runtime customization', () => {
|
||||
detail: 'OpenCode manages native context'
|
||||
}
|
||||
}
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
@@ -5321,7 +5321,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
rootPath: 'C:\\ProjectWorkspace'
|
||||
}))
|
||||
}
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(),
|
||||
id: 9,
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
@@ -12728,7 +12728,7 @@ describe('registerIpcHandlers Magic Notes analysis', () => {
|
||||
runtimeFactoryMocks.createDefaultModelRuntime.mockReturnValue(
|
||||
analysisRuntime
|
||||
)
|
||||
const webContents = {
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
send: vi.fn()
|
||||
|
||||
+2
-1
@@ -3257,7 +3257,7 @@ export function registerIpcHandlers(
|
||||
false
|
||||
)
|
||||
|
||||
registerWindowIpcHandlers(registerHandler, window)
|
||||
const disposeWindowIpc = registerWindowIpcHandlers(registerHandler, window)
|
||||
|
||||
registerHandler(ipcChannels.appClearLocalData, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
@@ -7374,6 +7374,7 @@ export function registerIpcHandlers(
|
||||
registerKnowledgeIpcHandlers(registerHandler, window, knowledgeService, settingsStore)
|
||||
|
||||
return async () => {
|
||||
disposeWindowIpc()
|
||||
shuttingDown = true
|
||||
if (nativeClientCoordinator) window.webContents.removeListener('destroyed', closeNativeClients)
|
||||
await nativeClientCoordinator?.closeOwner(nativeClientOwnerId)
|
||||
|
||||
@@ -65,6 +65,7 @@ describe('production preload -> registered IPC -> SQLite canvas persistence', ()
|
||||
let api: DesktopApi['magicNotes']
|
||||
let applicationSettings: ApplicationSettingsStore
|
||||
const webContents = { id: 91, mainFrame: { url: 'file:///canvas-test/index.html' },
|
||||
on: vi.fn(), removeListener: vi.fn(),
|
||||
getURL: () => 'file:///canvas-test/index.html', isDestroyed: () => false, send: vi.fn() }
|
||||
const window = { webContents, isDestroyed: () => false, isMaximized: () => false,
|
||||
on: vi.fn(), removeListener: vi.fn() }
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { BrowserWindow, ipcMain } from 'electron'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
import { registerWindowIpcHandlers } from './window-ipc'
|
||||
|
||||
vi.mock('electron', () => ({ clipboard: {} }))
|
||||
vi.mock('./window', () => ({ showWindow: vi.fn() }))
|
||||
|
||||
it('relays only direct srcdoc Escape keydowns and removes the listener on disposal', () => {
|
||||
const mainFrame = { url: 'file:///app/index.html', parent: null }
|
||||
const contents = Object.assign(new EventEmitter(), {
|
||||
mainFrame, focusedFrame: mainFrame as { url: string; parent: unknown } | null, send: vi.fn()
|
||||
})
|
||||
let destroyed = false
|
||||
const window = { get webContents() {
|
||||
if (destroyed) throw new Error('Object has been destroyed')
|
||||
return contents
|
||||
} }
|
||||
const dispose = registerWindowIpcHandlers(vi.fn() as typeof ipcMain.handle, window as unknown as BrowserWindow)
|
||||
const input = (key = 'Escape', type = 'keyDown'): void => { contents.emit('before-input-event', {}, { key, type }) }
|
||||
input()
|
||||
contents.focusedFrame = null
|
||||
input()
|
||||
contents.focusedFrame = { url: 'https://example.com', parent: mainFrame }
|
||||
input()
|
||||
contents.focusedFrame = { url: 'about:srcdoc', parent: {} }
|
||||
input()
|
||||
contents.focusedFrame = { url: 'about:srcdoc#section', parent: mainFrame }
|
||||
input('Tab')
|
||||
input('Escape', 'keyUp')
|
||||
expect(contents.send).not.toHaveBeenCalled()
|
||||
input()
|
||||
expect(contents.send).toHaveBeenCalledExactlyOnceWith(ipcChannels.windowPreviewEscape)
|
||||
destroyed = true
|
||||
dispose()
|
||||
input()
|
||||
expect(contents.send).toHaveBeenCalledTimes(1)
|
||||
expect(contents.listenerCount('before-input-event')).toBe(0)
|
||||
})
|
||||
+12
-1
@@ -22,7 +22,17 @@ export function registerClipboardIpcHandlers(
|
||||
export function registerWindowIpcHandlers(
|
||||
registerHandler: typeof ipcMain.handle,
|
||||
window: BrowserWindow
|
||||
): void {
|
||||
): () => void {
|
||||
const contents = window.webContents
|
||||
// Sandboxed srcdoc keyboard events cannot bubble to the renderer's modal.
|
||||
const previewEscape = (_event: Electron.Event, input: Electron.Input): void => {
|
||||
const frame = contents.focusedFrame
|
||||
if (input.type === 'keyDown' && input.key === 'Escape' &&
|
||||
frame?.parent === contents.mainFrame && frame.url.startsWith('about:srcdoc')) {
|
||||
contents.send(ipcChannels.windowPreviewEscape)
|
||||
}
|
||||
}
|
||||
contents.on('before-input-event', previewEscape)
|
||||
registerHandler(ipcChannels.appShow, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
showWindow(window)
|
||||
@@ -56,4 +66,5 @@ export function registerWindowIpcHandlers(
|
||||
assertTrustedSender(event, window)
|
||||
return window.isMaximized()
|
||||
})
|
||||
return () => contents.removeListener('before-input-event', previewEscape)
|
||||
}
|
||||
|
||||
@@ -250,6 +250,11 @@ const desktopApi: DesktopApi = {
|
||||
handler
|
||||
)
|
||||
},
|
||||
onPreviewEscape: (listener) => {
|
||||
const handler = (): void => listener()
|
||||
ipcRenderer.on(ipcChannels.windowPreviewEscape, handler)
|
||||
return () => ipcRenderer.removeListener(ipcChannels.windowPreviewEscape, handler)
|
||||
},
|
||||
onBeforeQuit: (listener) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
|
||||
@@ -227,6 +227,7 @@ const api: DesktopApi & RuntimeNativeClientApi = {
|
||||
toggleMaximize: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {}),
|
||||
isMaximized: vi.fn(async () => false),
|
||||
onPreviewEscape: vi.fn(() => () => undefined),
|
||||
onMaximizedChanged: vi.fn((listener) => {
|
||||
maximizedChangedListener = listener;
|
||||
return removeMaximizedChangedListener;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import DOMPurify from 'dompurify'
|
||||
@@ -44,7 +44,7 @@ function ResultImage({ resultId, image, onLocate, label }: {
|
||||
</div>
|
||||
}
|
||||
|
||||
function SectionContent({ content, result, onImage }: {
|
||||
const SectionContent = memo(function SectionContent({ content, result, onImage }: {
|
||||
content: string; result: DocumentResult; onImage: (id: string) => void
|
||||
}): React.JSX.Element {
|
||||
const pieces = useMemo(() => content.split(/(<table\b[\s\S]*?<\/table>)/giu), [content])
|
||||
@@ -61,7 +61,7 @@ function SectionContent({ content, result, onImage }: {
|
||||
const image = images.get(id)
|
||||
return image ? <><ResultImage resultId={result.id} image={image} /><button type="button" className="secondary-button" onClick={() => onImage(id)}>第 {image.pageNumber} 页图片 · 在图片中查看</button></> : <span>图片资源不可用</span>
|
||||
} }}>{piece.replace(/<\/?(?:div|p|span|h[1-6])\b[^>]*>/giu, '\n').replace(/<br\s*\/?\s*>/giu, '\n\n')}</ReactMarkdown>)}</div>
|
||||
}
|
||||
})
|
||||
|
||||
export function DocumentResultPreview({ resultId, allowAddImages = false, conversationId, showOpenOriginal = true, imageActionsInImagesTab = false }: { resultId: string; allowAddImages?: boolean; conversationId?: string; showOpenOriginal?: boolean; imageActionsInImagesTab?: boolean }): React.JSX.Element {
|
||||
const conversationContext = useContext(DocumentConversationContext)
|
||||
@@ -109,6 +109,10 @@ export function DocumentResultPreview({ resultId, allowAddImages = false, conver
|
||||
return () => { active = false; unsubscribe() }
|
||||
}, [allowAddImages, target])
|
||||
const focusTarget = useRef<string | undefined>(undefined)
|
||||
const showImage = useCallback((id: string) => {
|
||||
focusTarget.current = `${prefix}-image-${id}`
|
||||
setTab('images')
|
||||
}, [prefix])
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.goodbuddy.documentParsing!.getResult(resultId).then(
|
||||
@@ -138,9 +142,7 @@ export function DocumentResultPreview({ resultId, allowAddImages = false, conver
|
||||
<section className="document-result-panel" role="tabpanel" id={`${prefix}-panel-${tab}`} aria-labelledby={`${prefix}-tab-${tab}`}>
|
||||
{tab === 'text' && result.sections.map((section, index) => <article key={index}>
|
||||
<h3 tabIndex={-1} id={`${prefix}-section-${index}`}>{section.locator}</h3>
|
||||
<SectionContent content={section.content} result={result} onImage={(id) => {
|
||||
focusTarget.current = `${prefix}-image-${id}`; setTab('images')
|
||||
}} />
|
||||
<SectionContent content={section.content} result={result} onImage={showImage} />
|
||||
</article>)}
|
||||
{tab === 'images' && <><p>已保存 {result.images.length} 张 · 未保存 {result.missingImages.length} 张</p>
|
||||
{!result.images.length && <p>此结果没有提取图片;原生解析器可能只提取文字。</p>}
|
||||
|
||||
@@ -24,6 +24,7 @@ const mermaidMock = vi.hoisted(() => ({
|
||||
initialize: vi.fn(),
|
||||
render: vi.fn()
|
||||
}))
|
||||
const previewEscape = vi.fn<() => void>()
|
||||
|
||||
vi.mock('mermaid', () => ({
|
||||
default: mermaidMock
|
||||
@@ -31,6 +32,12 @@ vi.mock('mermaid', () => ({
|
||||
|
||||
describe('MarkdownRenderer', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: {
|
||||
app: { onPreviewEscape: vi.fn((listener: () => void) => {
|
||||
previewEscape.mockImplementation(listener)
|
||||
return () => previewEscape.mockReset()
|
||||
}) }
|
||||
} })
|
||||
mermaidMock.render.mockResolvedValue({
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50">
|
||||
<rect id="safe-node" width="100" height="50" />
|
||||
@@ -178,18 +185,27 @@ const ready = true
|
||||
name: '关闭全屏预览'
|
||||
})
|
||||
expect(closeButton).toHaveFocus()
|
||||
act(() => previewEscape())
|
||||
expect(dialog).toBeInTheDocument()
|
||||
expect(
|
||||
document.querySelector<HTMLElement>('.app-shell')?.inert
|
||||
).toBe(true)
|
||||
|
||||
const frame = screen.getByTitle('Agent 回复 HTML 全屏静态预览')
|
||||
expect(frame).toHaveAttribute('sandbox', '')
|
||||
expect(frame).toHaveAttribute('tabindex', '0')
|
||||
expect(frame).toHaveAttribute('referrerpolicy', 'no-referrer')
|
||||
expect(frame.getAttribute('srcdoc') ?? '').toContain(
|
||||
'Full screen page'
|
||||
)
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
fireEvent.keyDown(closeButton, { key: 'Tab' })
|
||||
expect(frame).toHaveFocus()
|
||||
dialog.setAttribute('inert', '')
|
||||
act(() => previewEscape())
|
||||
expect(dialog).toBeInTheDocument()
|
||||
dialog.removeAttribute('inert')
|
||||
act(() => previewEscape())
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(viewerButton).toHaveFocus()
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ import type {
|
||||
RuntimeTarget,
|
||||
WebSearchTestResult
|
||||
} from '../../shared/capability-contracts'
|
||||
import { trapTabFocus } from './dialog-focus'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import { PageTabs } from './WorkspacePrimitives'
|
||||
|
||||
const configurableMcpTargets: RuntimeTarget[] = [
|
||||
@@ -177,10 +177,10 @@ export function McpSettingsSection({
|
||||
if (!editorOpen) {
|
||||
return
|
||||
}
|
||||
const frame = requestAnimationFrame(() =>
|
||||
editorNameRef.current?.focus()
|
||||
return activateModalFocus(
|
||||
() => editorNameRef.current,
|
||||
() => editorTriggerRef.current ?? null
|
||||
)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [editorOpen])
|
||||
|
||||
const run = async (
|
||||
@@ -388,6 +388,7 @@ export function McpSettingsSection({
|
||||
trigger: HTMLButtonElement
|
||||
): void => {
|
||||
editorTriggerRef.current = trigger
|
||||
trigger.focus()
|
||||
setError(undefined)
|
||||
setEditor(nextEditor)
|
||||
}
|
||||
@@ -396,16 +397,14 @@ export function McpSettingsSection({
|
||||
if (busy === 'save') {
|
||||
return
|
||||
}
|
||||
const trigger = editorTriggerRef.current
|
||||
editorTriggerRef.current = undefined
|
||||
setError(undefined)
|
||||
setEditor(undefined)
|
||||
requestAnimationFrame(() => trigger?.focus())
|
||||
}
|
||||
|
||||
const handleEditorKeyDown = (
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
): void => {
|
||||
event.stopPropagation()
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
closeEditor()
|
||||
@@ -1214,6 +1213,7 @@ export function McpSettingsSection({
|
||||
className="mcp-editor-backdrop"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
event.preventDefault()
|
||||
closeEditor()
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -41,7 +41,7 @@ import type {
|
||||
import type {
|
||||
RemoteProjectRecoveryState
|
||||
} from '../../shared/remote-project-recovery-contracts'
|
||||
import { trapTabFocus } from './dialog-focus'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import {
|
||||
getDefaultRuntimeSelection
|
||||
} from './runtime-selection'
|
||||
@@ -812,6 +812,15 @@ export function ProjectSwitcher({
|
||||
return () => cancelAnimationFrame(focusFrame)
|
||||
}, [directoryPickerOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogMode) return
|
||||
return activateModalFocus(
|
||||
() => dialogRef.current?.querySelector<HTMLElement>('input:not(:disabled), textarea:not(:disabled), select:not(:disabled)') ??
|
||||
dialogRef.current?.querySelector<HTMLElement>('button:not(:disabled)') ?? null,
|
||||
() => restoreFocusTarget.current === 'create' ? createButtonRef.current : projectPickerButtonRef.current
|
||||
)
|
||||
}, [dialogMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogMode) {
|
||||
if (restoreFocusTarget.current === 'create') {
|
||||
@@ -823,6 +832,7 @@ export function ProjectSwitcher({
|
||||
return
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.defaultPrevented || dialogRef.current?.closest('[inert]')) return
|
||||
if (directoryPickerOpen) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
@@ -833,6 +843,7 @@ export function ProjectSwitcher({
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape' && !archiving && !deleting) {
|
||||
event.preventDefault()
|
||||
if (remoteProjectsEnabled && remoteSaving) {
|
||||
void window.goodbuddy.projects.remote
|
||||
.cancelCurrent()
|
||||
|
||||
@@ -1989,7 +1989,7 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
bounds.mockRestore()
|
||||
}
|
||||
})
|
||||
it.each(['menu', 'dialog', 'notification'])('hides the native view only while an overlapping %s is visible', async (kind) => {
|
||||
it.each(['menu', 'dialog', 'tooltip', 'notification'])('hides the native view only while an overlapping %s is visible', async (kind) => {
|
||||
const popup = document.createElement('section')
|
||||
if (kind === 'notification') popup.className = 'app-notification'
|
||||
else popup.setAttribute('role', kind)
|
||||
|
||||
@@ -882,6 +882,7 @@ export const RuntimeCustomizationSection = forwardRef<
|
||||
aria-label={t(
|
||||
'runtime.customization.continue.ruleName'
|
||||
)}
|
||||
className="field-control"
|
||||
maxLength={
|
||||
runtimeCustomizationLimits.nameCharacters
|
||||
}
|
||||
@@ -947,6 +948,7 @@ export const RuntimeCustomizationSection = forwardRef<
|
||||
'runtime.customization.continue.ruleContent',
|
||||
{ name: rule.name }
|
||||
)}
|
||||
className="field-control"
|
||||
maxLength={
|
||||
runtimeCustomizationLimits.contentCharacters
|
||||
}
|
||||
@@ -1014,6 +1016,7 @@ export const RuntimeCustomizationSection = forwardRef<
|
||||
aria-label={t(
|
||||
'runtime.customization.continue.promptName'
|
||||
)}
|
||||
className="field-control"
|
||||
maxLength={
|
||||
runtimeCustomizationLimits.nameCharacters
|
||||
}
|
||||
@@ -1057,6 +1060,7 @@ export const RuntimeCustomizationSection = forwardRef<
|
||||
'runtime.customization.continue.promptDescription',
|
||||
{ name: prompt.name }
|
||||
)}
|
||||
className="field-control"
|
||||
maxLength={
|
||||
runtimeCustomizationLimits.descriptionCharacters
|
||||
}
|
||||
@@ -1084,6 +1088,7 @@ export const RuntimeCustomizationSection = forwardRef<
|
||||
'runtime.customization.continue.promptContent',
|
||||
{ name: prompt.name }
|
||||
)}
|
||||
className="field-control"
|
||||
maxLength={
|
||||
runtimeCustomizationLimits.contentCharacters
|
||||
}
|
||||
|
||||
@@ -5911,6 +5911,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
const addServer = await screen.findByRole('button', {
|
||||
name: '添加 Server'
|
||||
})
|
||||
const settingsBackdrop = addServer.closest<HTMLElement>('.settings-backdrop')!
|
||||
fireEvent.click(addServer)
|
||||
const dialog = screen.getByRole('dialog', {
|
||||
name: '添加 MCP Server'
|
||||
@@ -5921,6 +5922,8 @@ describe('SettingsPanel runtime files', () => {
|
||||
const saveButton = within(dialog).getByRole('button', {
|
||||
name: '保存 MCP Server'
|
||||
})
|
||||
expect(settingsBackdrop.inert).toBe(true)
|
||||
expect(dialog.parentElement?.parentElement).toBe(document.body)
|
||||
closeButton.focus()
|
||||
fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true })
|
||||
expect(saveButton).toHaveFocus()
|
||||
@@ -5961,6 +5964,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
)
|
||||
await waitFor(() => expect(addServer).toHaveFocus())
|
||||
expect(settingsBackdrop.inert).toBe(false)
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: '添加 MCP Server' })
|
||||
).not.toBeInTheDocument()
|
||||
@@ -5975,6 +5979,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
}
|
||||
fireEvent.mouseDown(backdrop)
|
||||
await waitFor(() => expect(addServer).toHaveFocus())
|
||||
expect(settingsBackdrop.inert).toBe(false)
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: '添加 MCP Server' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Code2, FileCode2, Maximize2, X } from 'lucide-react'
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import { activateModalFocus, getModalFocusableElements, trapTabFocus } from './dialog-focus'
|
||||
|
||||
const forbiddenTags = [
|
||||
'base',
|
||||
@@ -108,11 +109,18 @@ function StaticHtmlViewer({
|
||||
const hintId = useId()
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
const closeRef = useRef<HTMLButtonElement>(null)
|
||||
const frameRef = useRef<HTMLIFrameElement>(null)
|
||||
const closePreview = useEffectEvent(onClose)
|
||||
|
||||
useEffect(
|
||||
() => activateModalFocus(() => closeRef.current),
|
||||
[]
|
||||
)
|
||||
useEffect(() => window.goodbuddy.app.onPreviewEscape(() => {
|
||||
if (document.activeElement === frameRef.current && !dialogRef.current?.closest('[inert]')) {
|
||||
closePreview()
|
||||
}
|
||||
}), [])
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
@@ -131,9 +139,15 @@ function StaticHtmlViewer({
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Tab' && !event.shiftKey && document.activeElement === closeRef.current) {
|
||||
event.preventDefault()
|
||||
frameRef.current?.focus()
|
||||
return
|
||||
}
|
||||
trapTabFocus(event, dialogRef.current)
|
||||
}}
|
||||
ref={dialogRef}
|
||||
@@ -160,11 +174,20 @@ function StaticHtmlViewer({
|
||||
</button>
|
||||
</header>
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
referrerPolicy="no-referrer"
|
||||
sandbox=""
|
||||
srcDoc={documentSource}
|
||||
tabIndex={0}
|
||||
title={t('markdown.htmlViewerFrame')}
|
||||
/>
|
||||
{/* Native Tab leaving a cross-origin frame needs a focus target in this document. */}
|
||||
<span className="sr-only" tabIndex={0} data-focus-guard onFocus={() => {
|
||||
if (!dialogRef.current || !frameRef.current) return
|
||||
const targets = getModalFocusableElements(dialogRef.current)
|
||||
const next = targets[targets.indexOf(frameRef.current) + 1] ?? targets[0]
|
||||
next?.focus()
|
||||
}} />
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
|
||||
@@ -158,8 +158,9 @@ describe('WorkbarShell', () => {
|
||||
fireEvent.scroll(scroll)
|
||||
expect(previous).toBeDisabled()
|
||||
const add = screen.getByRole('button', { name: '打开工作栏应用' })
|
||||
expect(add.parentElement).toBe(strip)
|
||||
expect(list.nextElementSibling).toBe(add)
|
||||
expect(add.parentElement).toBe(row.parentElement)
|
||||
expect(row.nextElementSibling).toBe(add)
|
||||
expect(scroll).not.toContainElement(add)
|
||||
expect(row.firstElementChild).toBe(previous)
|
||||
expect(row.lastElementChild).toBe(next)
|
||||
rowWidth = 800
|
||||
|
||||
+106
-104
@@ -402,98 +402,114 @@ export function WorkbarShell({
|
||||
aria-label={t('sidebar.workbar.ariaLabel')}
|
||||
className={joinClassNames('workbar-shell', className)}
|
||||
>
|
||||
<div className="workbar-shell__tab-row" ref={tabRowRef}>
|
||||
{tabOverflow.visible && (
|
||||
<button
|
||||
aria-label={t('sidebar.workbar.scrollLeft')}
|
||||
title={t('sidebar.workbar.scrollLeft')}
|
||||
className="workbar-shell__scroll-button"
|
||||
disabled={!tabOverflow.left}
|
||||
onClick={() => scrollTabs(-1)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<div className="workbar-shell__tab-scroll" ref={tabScrollRef} onScroll={updateTabOverflow}>
|
||||
<div className="workbar-shell__tab-strip" ref={tabListRef}>
|
||||
<div
|
||||
aria-label={t('sidebar.workbar.tablist')}
|
||||
className="workbar-shell__tablist"
|
||||
role="tablist"
|
||||
>
|
||||
{instances.map((instance, index) => {
|
||||
const selected =
|
||||
!catalogOpen && instance.id === activeInstanceId
|
||||
const AppIcon = WORKBAR_APP_ICONS[instance.appId]
|
||||
const closable =
|
||||
appDefinitions.find(
|
||||
(definition) => definition.id === instance.appId
|
||||
)?.closable === true
|
||||
return (
|
||||
<div
|
||||
className={joinClassNames(
|
||||
'workbar-shell__tab-item',
|
||||
selected && 'workbar-shell__tab-item--active'
|
||||
)}
|
||||
key={instance.id}
|
||||
>
|
||||
<button
|
||||
aria-controls={panelId(instance.id)}
|
||||
aria-selected={selected}
|
||||
className={joinClassNames(
|
||||
'workbar-shell__tab',
|
||||
selected && 'workbar-shell__tab--active'
|
||||
)}
|
||||
id={tabId(instance.id)}
|
||||
onClick={() => {
|
||||
setCatalogOpen(false)
|
||||
setTargetError(null)
|
||||
onActiveInstanceChange(instance.id)
|
||||
}}
|
||||
onKeyDown={(event) =>
|
||||
handleTabKeyDown(event, index)
|
||||
}
|
||||
role="tab"
|
||||
tabIndex={selected ? 0 : -1}
|
||||
title={
|
||||
appDefinitions.find(
|
||||
(application) =>
|
||||
application.id === instance.appId
|
||||
)?.description ?? instance.title
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="workbar-shell__tab-icon"
|
||||
<div className="workbar-shell__tab-row">
|
||||
<div className="workbar-shell__tab-navigation" ref={tabRowRef}>
|
||||
{tabOverflow.visible && (
|
||||
<button
|
||||
aria-label={t('sidebar.workbar.scrollLeft')}
|
||||
title={t('sidebar.workbar.scrollLeft')}
|
||||
className="workbar-shell__scroll-button"
|
||||
disabled={!tabOverflow.left}
|
||||
onClick={() => scrollTabs(-1)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<div className="workbar-shell__tab-scroll" ref={tabScrollRef} onScroll={updateTabOverflow}>
|
||||
<div className="workbar-shell__tab-strip" ref={tabListRef}>
|
||||
<div
|
||||
aria-label={t('sidebar.workbar.tablist')}
|
||||
className="workbar-shell__tablist"
|
||||
role="tablist"
|
||||
>
|
||||
{instances.map((instance, index) => {
|
||||
const selected =
|
||||
!catalogOpen && instance.id === activeInstanceId
|
||||
const AppIcon = WORKBAR_APP_ICONS[instance.appId]
|
||||
const closable =
|
||||
appDefinitions.find(
|
||||
(definition) => definition.id === instance.appId
|
||||
)?.closable === true
|
||||
return (
|
||||
<div
|
||||
className={joinClassNames(
|
||||
'workbar-shell__tab-item',
|
||||
selected && 'workbar-shell__tab-item--active'
|
||||
)}
|
||||
key={instance.id}
|
||||
>
|
||||
<AppIcon />
|
||||
</span>
|
||||
<span className="workbar-shell__tab-label">
|
||||
{instance.title}
|
||||
</span>
|
||||
{renderTabAdornment?.(instance)}
|
||||
</button>
|
||||
{closable ? (
|
||||
<button
|
||||
aria-label={t('sidebar.workbar.close', {
|
||||
title: instance.title
|
||||
})}
|
||||
className="workbar-shell__tab-close"
|
||||
onClick={() => void handleClose(instance)}
|
||||
title={t('sidebar.workbar.close', {
|
||||
title: instance.title
|
||||
})}
|
||||
type="button"
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
aria-controls={panelId(instance.id)}
|
||||
aria-selected={selected}
|
||||
className={joinClassNames(
|
||||
'workbar-shell__tab',
|
||||
selected && 'workbar-shell__tab--active'
|
||||
)}
|
||||
id={tabId(instance.id)}
|
||||
onClick={() => {
|
||||
setCatalogOpen(false)
|
||||
setTargetError(null)
|
||||
onActiveInstanceChange(instance.id)
|
||||
}}
|
||||
onKeyDown={(event) =>
|
||||
handleTabKeyDown(event, index)
|
||||
}
|
||||
role="tab"
|
||||
tabIndex={selected ? 0 : -1}
|
||||
title={
|
||||
appDefinitions.find(
|
||||
(application) =>
|
||||
application.id === instance.appId
|
||||
)?.description ?? instance.title
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="workbar-shell__tab-icon"
|
||||
>
|
||||
<AppIcon />
|
||||
</span>
|
||||
<span className="workbar-shell__tab-label">
|
||||
{instance.title}
|
||||
</span>
|
||||
{renderTabAdornment?.(instance)}
|
||||
</button>
|
||||
{closable ? (
|
||||
<button
|
||||
aria-label={t('sidebar.workbar.close', {
|
||||
title: instance.title
|
||||
})}
|
||||
className="workbar-shell__tab-close"
|
||||
onClick={() => void handleClose(instance)}
|
||||
title={t('sidebar.workbar.close', {
|
||||
title: instance.title
|
||||
})}
|
||||
type="button"
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{tabOverflow.visible && (
|
||||
<button
|
||||
aria-label={t('sidebar.workbar.scrollRight')}
|
||||
title={t('sidebar.workbar.scrollRight')}
|
||||
className="workbar-shell__scroll-button"
|
||||
disabled={!tabOverflow.right}
|
||||
onClick={() => scrollTabs(1)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
aria-expanded={catalogOpen}
|
||||
aria-label={t('sidebar.workbar.add')}
|
||||
@@ -510,20 +526,6 @@ export function WorkbarShell({
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{tabOverflow.visible && (
|
||||
<button
|
||||
aria-label={t('sidebar.workbar.scrollRight')}
|
||||
title={t('sidebar.workbar.scrollRight')}
|
||||
className="workbar-shell__scroll-button"
|
||||
disabled={!tabOverflow.right}
|
||||
onClick={() => scrollTabs(1)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="workbar-shell__content">
|
||||
|
||||
@@ -164,13 +164,15 @@ describe('WorkspacePrimitives', () => {
|
||||
// region, otherwise the native titlebar keeps the pointer and its controls
|
||||
// stop responding to hover and clicks.
|
||||
const overlays = [
|
||||
...readdirSync(join(process.cwd(), 'src', 'renderer', 'src'))
|
||||
...readdirSync(join(process.cwd(), 'src', 'renderer', 'src'), { recursive: true, encoding: 'utf8' })
|
||||
.filter((name) => name.endsWith('.css'))
|
||||
.map((name) => readFileSync(join(process.cwd(), 'src', 'renderer', 'src', name), 'utf8'))
|
||||
.join('\n').matchAll(
|
||||
/^(\.[\w-]*(?:backdrop|modal))\s*\{([^}]*)\}/gmu
|
||||
/^\s*(\.[\w-]+)\s*\{([^}]*)\}/gmu
|
||||
)
|
||||
].filter(([, , body]) => /position:\s*fixed/u.test(body!))
|
||||
].filter(([, selector, body]) => /position:\s*fixed/u.test(body!) &&
|
||||
(/inset\s*:/u.test(body!) || /(?:backdrop|modal|dialog)$/u.test(selector!)) &&
|
||||
!/pointer-events:\s*none/u.test(body!))
|
||||
expect(overlays.length).toBeGreaterThan(10)
|
||||
for (const [, selector, body] of overlays) {
|
||||
expect(covered, `${selector} must release the drag region`).toContain(
|
||||
@@ -425,7 +427,7 @@ describe('WorkspacePrimitives', () => {
|
||||
/\.field > span\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.field input,\s*\.field textarea,\s*\.field select\s*\{[^}]*font-size:\s*var\(--font-body\);/u
|
||||
/\.field input:not\(:where\(\[type='checkbox'\], \[type='radio'\]\)\),\s*\.field textarea,\s*\.field select,\s*\.field-control\s*\{[^}]*font-size:\s*var\(--font-body\);/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.field small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
|
||||
@@ -822,6 +824,7 @@ describe('WorkspacePrimitives', () => {
|
||||
['success', 'success-subtle'],
|
||||
['danger', 'danger-subtle'],
|
||||
['text-on-accent', 'accent-solid'],
|
||||
['text-on-accent', 'accent-solid-hover'],
|
||||
['text-on-accent', 'danger-solid'],
|
||||
['text-on-accent', 'danger-solid-hover']
|
||||
] as const
|
||||
@@ -834,5 +837,8 @@ describe('WorkspacePrimitives', () => {
|
||||
expect(
|
||||
contrast(tokens['border-control']!, tokens['surface-raised']!)
|
||||
).toBeGreaterThanOrEqual(3)
|
||||
expect(
|
||||
contrast(tokens['border-control']!, tokens['surface-subtle']!)
|
||||
).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,10 +27,17 @@ describe('native browser overlay detection', () => {
|
||||
const { host, viewport } = setup({ 'aria-modal': 'true' }, false)
|
||||
expect(isBrowserViewportOccluded(host, viewport)).toBe(true)
|
||||
})
|
||||
it.each(['dialog', 'alertdialog', 'menu'])('detects overlapping %s without requiring modal focus setup', role => {
|
||||
it.each(['dialog', 'alertdialog', 'menu', 'tooltip'])('detects overlapping %s without requiring modal focus setup', role => {
|
||||
const { host, viewport } = setup({ role })
|
||||
expect(isBrowserViewportOccluded(host, viewport)).toBe(true)
|
||||
})
|
||||
it('ignores non-overlapping and hidden tooltips', () => {
|
||||
const { host, overlay, viewport } = setup({ role: 'tooltip' }, false)
|
||||
expect(isBrowserViewportOccluded(host, viewport)).toBe(false)
|
||||
vi.spyOn(overlay, 'getBoundingClientRect').mockReturnValue(viewport)
|
||||
overlay.hidden = true
|
||||
expect(isBrowserViewportOccluded(host, viewport)).toBe(false)
|
||||
})
|
||||
it.each(['hidden', 'display', 'visibility', 'ancestor-display'])('ignores a modal hidden by %s', kind => {
|
||||
const { host, parent, overlay, viewport } = setup({ 'aria-modal': 'true' })
|
||||
if (kind === 'hidden') parent.hidden = true
|
||||
|
||||
@@ -3,6 +3,7 @@ export const browserOverlaySelector = [
|
||||
'[role="dialog"]',
|
||||
'[role="alertdialog"]',
|
||||
'[role="menu"]',
|
||||
'[role="tooltip"]',
|
||||
'.app-notification'
|
||||
].join(', ')
|
||||
|
||||
|
||||
@@ -5,7 +5,13 @@ type TabKeyEvent = {
|
||||
}
|
||||
|
||||
const focusableSelector =
|
||||
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), iframe:not([tabindex="-1"]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
export function getModalFocusableElements(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>(focusableSelector))
|
||||
.filter((element) => !element.hasAttribute('data-focus-guard') &&
|
||||
!element.closest('[hidden], [inert], [aria-hidden="true"]'))
|
||||
}
|
||||
|
||||
export function trapTabFocus(
|
||||
event: TabKeyEvent,
|
||||
@@ -14,9 +20,7 @@ export function trapTabFocus(
|
||||
if (event.key !== 'Tab' || !container) {
|
||||
return
|
||||
}
|
||||
const focusable = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(focusableSelector)
|
||||
).filter((element) => !element.closest('[hidden], [inert], [aria-hidden="true"]'))
|
||||
const focusable = getModalFocusableElements(container)
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault()
|
||||
container.focus()
|
||||
|
||||
+46
-51
@@ -11,7 +11,7 @@
|
||||
--text-disabled: #718398;
|
||||
--text-on-accent: #fff;
|
||||
--border-default: #cedce7;
|
||||
--border-control: #7f95aa;
|
||||
--border-control: #748a9f;
|
||||
--border-subtle: #e2eaf1;
|
||||
--accent: #086fc7;
|
||||
--accent-solid: #0875cf;
|
||||
@@ -675,7 +675,6 @@
|
||||
.custom-task-dialog__field
|
||||
:is(input, textarea, select):focus-visible {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.custom-task-dialog__field
|
||||
@@ -3917,6 +3916,7 @@ textarea:focus-visible {
|
||||
.document-parsing-diagnostic-backdrop,
|
||||
.mcp-editor-backdrop,
|
||||
.knowledge-confirm-dialog-backdrop,
|
||||
.knowledge-citation-dialog,
|
||||
.knowledge-library-dialog-backdrop,
|
||||
.knowledge-dialog-backdrop,
|
||||
.external-knowledge-modal
|
||||
@@ -3992,7 +3992,7 @@ button > svg {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
.icon-button:hover:where(:not(:disabled, .danger-ghost)) {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
@@ -4121,6 +4121,13 @@ button > svg {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.workbar-shell__tab-navigation {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.workbar-shell__tab-scroll {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -5315,7 +5322,6 @@ button > svg {
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-control);
|
||||
outline: none;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-family-ui);
|
||||
@@ -5324,7 +5330,6 @@ button > svg {
|
||||
|
||||
.assistant-sidebar__browser-address input:focus-visible {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-go {
|
||||
@@ -7209,7 +7214,7 @@ button > svg {
|
||||
|
||||
.image-viewer-backdrop {
|
||||
position: fixed;
|
||||
z-index: 80;
|
||||
z-index: var(--z-dialog);
|
||||
display: grid;
|
||||
padding: var(--space-4);
|
||||
background: var(--overlay-backdrop);
|
||||
@@ -9942,6 +9947,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
|
||||
.model-connection-manager {
|
||||
display: grid;
|
||||
container: model-connections / inline-size;
|
||||
min-width: 0;
|
||||
align-items: start;
|
||||
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
|
||||
@@ -9988,6 +9994,16 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@container model-connections (max-width: 640px) {
|
||||
.model-connection-manager > * {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.model-connection-list > [role='list'] {
|
||||
max-height: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
.model-connection-list [role='listitem'] + [role='listitem'] {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
@@ -11260,35 +11276,38 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field input:not(:where([type='checkbox'], [type='radio'])),
|
||||
.field textarea,
|
||||
.field select {
|
||||
.field select,
|
||||
.field-control {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: 9px;
|
||||
outline: 0;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field input:not(:where([type='checkbox'], [type='radio'])):focus,
|
||||
.field textarea:focus,
|
||||
.field select:focus {
|
||||
.field select:focus,
|
||||
.field-control:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
|
||||
.field input:disabled,
|
||||
.field input:not(:where([type='checkbox'], [type='radio'])):disabled,
|
||||
.field textarea:disabled,
|
||||
.field select:disabled {
|
||||
.field select:disabled,
|
||||
.field-control:disabled {
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
.field textarea,
|
||||
textarea.field-control {
|
||||
padding-top: 9px;
|
||||
padding-bottom: 9px;
|
||||
resize: vertical;
|
||||
@@ -12247,7 +12266,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
|
||||
.mcp-editor-backdrop {
|
||||
position: fixed;
|
||||
z-index: 70;
|
||||
z-index: var(--z-dialog);
|
||||
display: grid;
|
||||
padding: var(--space-4);
|
||||
background: var(--overlay-backdrop);
|
||||
@@ -12486,8 +12505,8 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.conversation-actions button,
|
||||
.workspace-files__menu button {
|
||||
/* Menu defaults must not override shared button states. */
|
||||
:where(.conversation-actions, .workspace-files__menu) button {
|
||||
display: flex;
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
@@ -12501,8 +12520,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.conversation-actions button:hover,
|
||||
.workspace-files__menu button:hover {
|
||||
:where(.conversation-actions, .workspace-files__menu) button:where(:hover:not(:disabled)) {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
@@ -13089,7 +13107,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-tabs__tab:hover {
|
||||
.page-tabs__tab:hover:where(:not(.page-tabs__tab--active)) {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -13151,6 +13169,12 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.icon-button:disabled,
|
||||
.segmented-control__option:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
@@ -16298,7 +16322,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
--border-subtle: #20384b;
|
||||
--accent: #72c5ff;
|
||||
--accent-solid: #1469b5;
|
||||
--accent-solid-hover: #1c7dce;
|
||||
--accent-solid-hover: #1a77c5;
|
||||
--accent-hover: #9bd7ff;
|
||||
--accent-selected: #28668f;
|
||||
--accent-subtle: #123b5b;
|
||||
@@ -16366,8 +16390,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
.heartbeat-center__plans .heartbeat-settings,
|
||||
.heartbeat-settings__item,
|
||||
.capability-card,
|
||||
.quick-actions button,
|
||||
.secondary-button
|
||||
.quick-actions button
|
||||
) {
|
||||
border-color: var(--border-subtle);
|
||||
background: var(--surface-raised);
|
||||
@@ -16380,20 +16403,6 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.app-shell,
|
||||
.sidebar,
|
||||
.topbar,
|
||||
.assistant-sidebar,
|
||||
.settings-panel,
|
||||
.project-create-card,
|
||||
.workspace-panel-scroll
|
||||
) :where(input, textarea, select) {
|
||||
border-color: var(--border-default);
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.app-shell,
|
||||
.sidebar,
|
||||
@@ -16481,7 +16490,6 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.user-card,
|
||||
.icon-button,
|
||||
.assistant-sidebar__row,
|
||||
.welcome__description,
|
||||
.tool-activity,
|
||||
@@ -16489,7 +16497,6 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
.settings-tabs button,
|
||||
.credential-state,
|
||||
.capability-card p,
|
||||
.secondary-button,
|
||||
.check-field,
|
||||
.token-usage th,
|
||||
.token-usage td,
|
||||
@@ -16526,8 +16533,6 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.icon-button:hover,
|
||||
.icon-button--active,
|
||||
.user-card:hover,
|
||||
.secondary-button:hover:not(:disabled),
|
||||
.settings-tabs button[aria-selected='true']
|
||||
@@ -16948,14 +16953,6 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.model-connection-manager {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.model-connection-list > [role='list'] {
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
.capability-list--tools {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -18091,8 +18088,6 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
box-shadow: var(--shadow-dialog);
|
||||
}
|
||||
.workspace-files__menu button { min-height: 32px; justify-content: flex-start; font-size: var(--font-body); }
|
||||
.workspace-files__menu .danger-ghost { color: var(--danger); }
|
||||
.workspace-files__menu .danger-ghost:hover { color: var(--danger-strong); background: var(--danger-subtle); }
|
||||
.workspace-files__menu [role="separator"] { border-top: 1px solid var(--border-subtle); }
|
||||
.workspace-files__path { overflow-wrap: anywhere; }
|
||||
.workspace-files__directory-picker { display: grid; min-width: 0; gap: var(--space-2); }
|
||||
|
||||
@@ -1783,6 +1783,7 @@ export type DesktopApi = {
|
||||
close: () => Promise<void>
|
||||
isMaximized: () => Promise<boolean>
|
||||
onMaximizedChanged: (listener: (maximized: boolean) => void) => () => void
|
||||
onPreviewEscape: (listener: () => void) => () => void
|
||||
onBeforeQuit: (listener: () => Promise<void>) => () => void
|
||||
clearLocalData: () => Promise<void>
|
||||
onNewConversation: (listener: () => void) => () => void
|
||||
|
||||
@@ -23,6 +23,7 @@ export const ipcChannels = {
|
||||
windowClose: 'window:close',
|
||||
windowIsMaximized: 'window:is-maximized',
|
||||
windowMaximizedChanged: 'window:maximized-changed',
|
||||
windowPreviewEscape: 'window:preview-escape',
|
||||
appRendererPersistenceReady: 'app:renderer-persistence-ready',
|
||||
appRendererPersistenceRequest: 'app:renderer-persistence-request',
|
||||
appRendererPersistenceComplete: 'app:renderer-persistence-complete',
|
||||
|
||||
Reference in New Issue
Block a user