fix: keep browser overlays and knowledge controls usable
Native browser pages could cover application dialogs and overlapping menus or notifications. Suspend only the viewport while covered and restore the same active page afterward, including nested dialogs and terminal prompts. Keep knowledge source controls at their full height, constrain long library cards and titles, and update automatic names when switching remote targets without overwriting edited or saved names. Restore packaged release-note loading now that the history exceeds its previous size limit. Add focused regressions and real Electron UI evidence. Clean up temporary Harness fixture directories at test completion instead of accumulating them. Release note: 修复内嵌浏览器遮挡弹窗和浮层、知识库来源选项被压缩及长名称显示异常;切换远端知识库时自动名称正确更新,并恢复更新说明显示。
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import {
|
||||
DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
@@ -33,6 +33,7 @@ async function fixture() {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-launcher-'))
|
||||
)
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const workspace = join(root, 'workspace')
|
||||
const dshHome = join(root, 'home')
|
||||
const hostPath = join(
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import {
|
||||
createBoundedNdJsonStream,
|
||||
installHarnessDiagnosticGuard,
|
||||
startControlledDeepSeekHarnessHost
|
||||
} from './deepseek-harness-host'
|
||||
import { vi } from 'vitest'
|
||||
import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import type {
|
||||
Agent,
|
||||
CreateAgentOptions
|
||||
@@ -65,6 +65,7 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-host-'))
|
||||
)
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const inbound = new TransformStream<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>
|
||||
@@ -125,6 +126,7 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-alias-'))
|
||||
)
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const alias = join(root, '..', basename(root))
|
||||
const inbound = new TransformStream<
|
||||
Record<string, unknown>,
|
||||
@@ -157,6 +159,7 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-extensions-'))
|
||||
)
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const brokenEntrypoint = join(root, 'broken.mjs')
|
||||
await writeFile(
|
||||
brokenEntrypoint,
|
||||
@@ -202,6 +205,7 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-skill-'))
|
||||
)
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const skillDirectory = join(root, 'web-3d-game')
|
||||
await mkdir(skillDirectory)
|
||||
await writeFile(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
@@ -70,6 +70,22 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('ReleaseNotesService', () => {
|
||||
it('reads and acknowledges the actual packaged release history', async () => {
|
||||
const { version } = JSON.parse(
|
||||
await readFile(join(process.cwd(), 'package.json'), 'utf8')
|
||||
) as { version: string }
|
||||
const { filePath, service } = await createService(version)
|
||||
await writeFile(
|
||||
filePath,
|
||||
await readFile(join(process.cwd(), 'resources', 'release-notes.json'))
|
||||
)
|
||||
await expect(service.getPending()).resolves.toMatchObject({
|
||||
currentVersion: version,
|
||||
releases: [{ version }]
|
||||
})
|
||||
await service.acknowledge({ version })
|
||||
await expect(service.getPending()).resolves.toMatchObject({ releases: [] })
|
||||
})
|
||||
it('shows only the current release on a fresh installation', async () => {
|
||||
const { service } = await createService('0.8.18')
|
||||
|
||||
@@ -135,7 +151,7 @@ describe('ReleaseNotesService', () => {
|
||||
|
||||
it('rejects an oversized release-notes resource with a bounded read', async () => {
|
||||
const { filePath, service } = await createService('0.8.18')
|
||||
await writeFile(filePath, ' '.repeat(128 * 1024 + 1), 'utf8')
|
||||
await writeFile(filePath, ' '.repeat(1024 * 1024 + 1), 'utf8')
|
||||
|
||||
await expect(service.getPending()).rejects.toThrow(
|
||||
'Release notes exceed the size limit'
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import type { ApplicationSettingsStore } from './application-settings-store'
|
||||
import { compareStrictSemVer } from './version-checker'
|
||||
|
||||
const maximumReleaseNotesBytes = 128 * 1024
|
||||
const maximumReleaseNotesBytes = 1024 * 1024
|
||||
|
||||
export class ReleaseNotesService {
|
||||
private releases?: ReleaseNote[]
|
||||
|
||||
@@ -32,6 +32,21 @@ function selectInstance(index = 0): void {
|
||||
}
|
||||
|
||||
describe('external knowledge workflows', () => {
|
||||
it('updates an automatic name when selecting another library but preserves an edited name', async () => {
|
||||
bridge.externalCatalogList.mockResolvedValueOnce({
|
||||
items: [{ id: 'remote-1', name: 'Handbook' }, { id: 'remote-2', name: 'Second library' }],
|
||||
hasMore: false
|
||||
})
|
||||
render(<ExternalBindingForm provider="dify" instances={[instance]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
selectInstance()
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Handbook/ }))
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Handbook')
|
||||
fireEvent.click(screen.getByRole('button', { name: /Second library/ }))
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Second library')
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'My chosen name' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Handbook/ }))
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('My chosen name')
|
||||
})
|
||||
it('navigates the rich instance menu, skips disabled items and restores focus', async () => {
|
||||
render(<ExternalBindingForm provider="dify" instances={[instance, { ...instance, id: 'disabled', name: 'Disabled service', enabled: false }, { ...instance, id: 'other', name: 'Other service' }]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
const trigger = screen.getByRole('button', { name: 'External instance' })
|
||||
@@ -97,6 +112,7 @@ describe('external knowledge workflows', () => {
|
||||
it('keeps external tabs and filters from shrinking in the scrolling workspace', () => {
|
||||
const css = readFileSync('src/renderer/src/external-knowledge.css', 'utf8')
|
||||
expect(css).toMatch(/\.external-knowledge > \.page-tabs\s*\{[^}]*flex: 0 0 auto/)
|
||||
expect(css).toMatch(/\.external-knowledge > header\s*\{[^}]*overflow-wrap: anywhere/)
|
||||
expect(css).toMatch(/\.external-knowledge__filters\s*\{[^}]*flex: 0 0 auto/)
|
||||
expect(css).toMatch(/\.external-knowledge-modal__surface\s*\{[^}]*width: min\(560px, 100%\)/)
|
||||
expect(css).toMatch(/\.external-knowledge__footer\s*\{[^}]*position: sticky/)
|
||||
|
||||
@@ -187,6 +187,7 @@ export function ExternalBindingForm({ provider, instances, libraries, library, o
|
||||
const [instanceId, setInstanceId] = useState(binding?.instanceId ?? '')
|
||||
const [remoteId, setRemoteId] = useState(binding?.remoteKnowledgeBaseId ?? '')
|
||||
const [name, setName] = useState(library?.name ?? '')
|
||||
const [nameEdited, setNameEdited] = useState(Boolean(library))
|
||||
const [detail, setDetail] = useState<ExternalKnowledgeCatalogItem>()
|
||||
const [common, setCommon] = useState(binding?.commonConfig ?? commonDefaults)
|
||||
const [config, setConfig] = useState(binding?.providerConfig ?? externalProviderDefaults(provider))
|
||||
@@ -265,10 +266,10 @@ export function ExternalBindingForm({ provider, instances, libraries, library, o
|
||||
<div className="external-knowledge__columns">
|
||||
<section className="external-knowledge__fields">
|
||||
<h3>{t('external.source')}</h3>
|
||||
<InstanceSelect instances={instances.filter(item => item.provider === provider)} value={instanceId} onChange={value => { invalidate(); setDetail(undefined); setInstanceId(value); setRemoteId(''); setConfig(externalProviderDefaults(provider)); setPage(1); setParentId(null); setSearch('') }} />
|
||||
<InstanceSelect instances={instances.filter(item => item.provider === provider)} value={instanceId} onChange={value => { invalidate(); setDetail(undefined); setInstanceId(value); setRemoteId(''); if (!nameEdited) setName(''); setConfig(externalProviderDefaults(provider)); setPage(1); setParentId(null); setSearch('') }} />
|
||||
<button type="button" className="secondary-button" onClick={onManage}>{t('external.manage')}</button>
|
||||
<SegmentedControl ariaLabel={t('external.remote')} value={manual ? 'manual' : 'catalog'} options={[{ value: 'catalog', label: t('external.browse') }, { value: 'manual', label: t('external.manual') }]} onChange={value => setManual(value === 'manual')} />
|
||||
{manual ? <Field label={t('external.remoteId')}><input maxLength={512} value={remoteId} disabled={busy} aria-invalid={duplicate || undefined} aria-describedby={duplicate ? `${errorId}-duplicate` : undefined} onChange={event => { invalidate(); setDetail(undefined); setRemoteId(event.currentTarget.value) }} /></Field> : <>
|
||||
{manual ? <Field label={t('external.remoteId')}><input maxLength={512} value={remoteId} disabled={busy} aria-invalid={duplicate || undefined} aria-describedby={duplicate ? `${errorId}-duplicate` : undefined} onChange={event => { invalidate(); setDetail(undefined); setRemoteId(event.currentTarget.value); if (!nameEdited) setName('') }} /></Field> : <>
|
||||
<Field label={t('external.search')}><input type="search" maxLength={512} value={search} onChange={event => { setSearch(event.currentTarget.value); setPage(1) }} /></Field>
|
||||
<div className="external-knowledge__actions"><button type="button" className="secondary-button" aria-label={t('external.refresh')} title={t('external.refresh')} onClick={() => { invalidate(); setRefresh(value => value + 1) }}><RefreshCw size={15} /></button>
|
||||
{parentId && <button type="button" className="secondary-button" onClick={() => { setParentId(null); setPage(1) }}>{t('external.root')}</button>}</div>
|
||||
@@ -280,7 +281,7 @@ export function ExternalBindingForm({ provider, instances, libraries, library, o
|
||||
const bound = libraries.some(candidate => candidate.id !== library?.id && candidate.external?.instanceId === instanceId && candidate.external.remoteKnowledgeBaseId === item.id)
|
||||
return <button type="button" className="external-knowledge__target" key={item.id} aria-pressed={item.kind === 'folder' ? undefined : remoteId === item.id} disabled={bound || busy} onClick={() => {
|
||||
if (item.kind === 'folder') { setParentId(item.id); setPage(1); return }
|
||||
invalidate(); setRemoteId(item.id); setDetail(item); if (!name) setName(item.name)
|
||||
invalidate(); setRemoteId(item.id); setDetail(item); if (!nameEdited) setName(item.name)
|
||||
}}>{item.kind === 'folder' ? <FolderOpen size={15} /> : <Database size={15} />}<span><strong>{item.name}</strong><small>{item.id}{bound ? ` · ${t('external.alreadyBound')}` : ''}</small></span></button>
|
||||
})}
|
||||
<div className="external-knowledge__actions"><button type="button" className="secondary-button" disabled={page === 1} onClick={() => setPage(value => value - 1)}>{t('external.previous')}</button><span>{page}</span><button type="button" className="secondary-button" disabled={!catalog.hasMore} onClick={() => setPage(value => value + 1)}>{t('external.next')}</button></div>
|
||||
@@ -288,7 +289,7 @@ export function ExternalBindingForm({ provider, instances, libraries, library, o
|
||||
</>}
|
||||
{remoteId && <p className="external-knowledge__identifier">{remoteName}<br />{remoteId}</p>}
|
||||
{duplicate && <p role="alert" id={`${errorId}-duplicate`}>{t('external.alreadyBound')}</p>}
|
||||
<Field label={t('fields.name')}><input required maxLength={512} value={name} onChange={event => setName(event.currentTarget.value)} /></Field>
|
||||
<Field label={t('fields.name')}><input required maxLength={512} value={name} onChange={event => { setName(event.currentTarget.value); setNameEdited(true) }} /></Field>
|
||||
<Field label={t('external.query')}><textarea required maxLength={4000} value={query} onChange={event => { invalidate(); setQuery(event.currentTarget.value) }} /></Field>
|
||||
</section>
|
||||
<ExternalConfig common={common} config={config} detail={detail} onCommon={value => { invalidate(); setCommon(value) }} onConfig={value => { invalidate(); setConfig(value) }} />
|
||||
|
||||
@@ -1472,6 +1472,99 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
expect(browserA).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('releases the native browser for portalled modals until the last modal closes', async () => {
|
||||
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 500, height: 400, left: 900, right: 1300, top: 100,
|
||||
width: 400, x: 900, y: 100, toJSON: () => ({})
|
||||
})
|
||||
const modal = document.createElement('section')
|
||||
modal.setAttribute('role', 'dialog')
|
||||
modal.setAttribute('aria-modal', 'true')
|
||||
const nested = modal.cloneNode() as HTMLElement
|
||||
try {
|
||||
renderSidebar({ tab: 'browser', activeConversationId: 'modal-browser' })
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ conversationId: 'modal-browser', bounds: expect.any(Object) })
|
||||
))
|
||||
const lease = browserApi.setViewport.mock.calls.at(-1)![0].leaseToken
|
||||
document.body.append(modal)
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith({ leaseToken: lease }))
|
||||
document.body.append(nested)
|
||||
modal.remove()
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) })
|
||||
expect(browserApi.setViewport).toHaveBeenLastCalledWith({ leaseToken: lease })
|
||||
nested.remove()
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ conversationId: 'modal-browser', leaseToken: lease, bounds: expect.any(Object) })
|
||||
))
|
||||
expect(browserApi.closeTab).not.toHaveBeenCalled()
|
||||
expect(browserApi.createTab).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
modal.remove()
|
||||
nested.remove()
|
||||
bounds.mockRestore()
|
||||
}
|
||||
})
|
||||
it.each(['menu', 'dialog', '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)
|
||||
popup.hidden = true
|
||||
let overlapping = true
|
||||
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
const left = this === popup && !overlapping ? 100 : 900
|
||||
return {
|
||||
bottom: 500, height: 400, left, right: left + 400, top: 100,
|
||||
width: 400, x: left, y: 100, toJSON: () => ({})
|
||||
}
|
||||
})
|
||||
try {
|
||||
document.body.append(popup)
|
||||
renderSidebar({ tab: 'browser', activeConversationId: 'popup-browser' })
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ conversationId: 'popup-browser', bounds: expect.any(Object) })
|
||||
))
|
||||
const lease = browserApi.setViewport.mock.calls.at(-1)![0].leaseToken
|
||||
popup.hidden = false
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith({ leaseToken: lease }))
|
||||
overlapping = false
|
||||
fireEvent(window, new Event('resize'))
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ conversationId: 'popup-browser', bounds: expect.any(Object) })
|
||||
))
|
||||
overlapping = true
|
||||
fireEvent(window, new Event('resize'))
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith({ leaseToken: lease }))
|
||||
popup.hidden = true
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ conversationId: 'popup-browser', bounds: expect.any(Object) })
|
||||
))
|
||||
} finally {
|
||||
popup.remove()
|
||||
bounds.mockRestore()
|
||||
}
|
||||
})
|
||||
it('does not acquire a native view behind an already open modal or restore an inactive tab', async () => {
|
||||
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 500, height: 400, left: 900, right: 1300, top: 100,
|
||||
width: 400, x: 900, y: 100, toJSON: () => ({})
|
||||
})
|
||||
const modal = document.createElement('section')
|
||||
modal.setAttribute('aria-modal', 'true')
|
||||
try {
|
||||
document.body.append(modal)
|
||||
renderSidebar({ tab: 'browser', activeConversationId: 'covered-browser' })
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenCalled())
|
||||
expect(browserApi.setViewport.mock.calls.every(([request]) => !('bounds' in request))).toBe(true)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '工作区' }))
|
||||
modal.remove()
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) })
|
||||
expect(browserApi.setViewport.mock.calls.every(([request]) => !('bounds' in request))).toBe(true)
|
||||
} finally {
|
||||
modal.remove()
|
||||
bounds.mockRestore()
|
||||
}
|
||||
})
|
||||
it('releases the native browser viewport while confirming another terminal close', async () => {
|
||||
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 500, height: 400, left: 900, right: 1300, top: 100,
|
||||
|
||||
@@ -43,6 +43,10 @@ import type {
|
||||
} from '../../shared/contracts'
|
||||
import { WorkspaceFilesPanel } from './WorkspaceFilesPanel'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import {
|
||||
browserOverlaySelector,
|
||||
isBrowserViewportOccluded
|
||||
} from './browser-viewport-occlusion'
|
||||
import {
|
||||
findTaskSchedule,
|
||||
TaskScheduleActions
|
||||
@@ -504,10 +508,11 @@ function BrowserViewport({
|
||||
let animationFrame: number | undefined
|
||||
let lastRequest = ''
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
const observedOverlays = new Set<Element>()
|
||||
const panel = host.closest<HTMLElement>('[role="tabpanel"]')
|
||||
const syncViewport = (): void => {
|
||||
if (animationFrame !== undefined) {
|
||||
cancelAnimationFrame(animationFrame)
|
||||
return
|
||||
}
|
||||
animationFrame = requestAnimationFrame(() => {
|
||||
animationFrame = undefined
|
||||
@@ -517,7 +522,8 @@ function BrowserViewport({
|
||||
Boolean(conversationId) &&
|
||||
panel?.hidden !== true &&
|
||||
rect.width >= 1 &&
|
||||
rect.height >= 1
|
||||
rect.height >= 1 &&
|
||||
!isBrowserViewportOccluded(host, rect)
|
||||
const bounds = isVisible
|
||||
? {
|
||||
x: Math.max(0, Math.round(rect.left)),
|
||||
@@ -545,6 +551,7 @@ function BrowserViewport({
|
||||
if (panel?.hidden !== false) {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = undefined
|
||||
observedOverlays.clear()
|
||||
syncViewport()
|
||||
return
|
||||
}
|
||||
@@ -552,17 +559,34 @@ function BrowserViewport({
|
||||
resizeObserver = new ResizeObserver(syncViewport)
|
||||
resizeObserver.observe(host)
|
||||
}
|
||||
if (resizeObserver) {
|
||||
const overlays = new Set(document.querySelectorAll(browserOverlaySelector))
|
||||
for (const overlay of observedOverlays) {
|
||||
if (!overlays.has(overlay)) {
|
||||
resizeObserver.unobserve(overlay)
|
||||
observedOverlays.delete(overlay)
|
||||
}
|
||||
}
|
||||
for (const overlay of overlays) {
|
||||
if (!observedOverlays.has(overlay)) {
|
||||
resizeObserver.observe(overlay)
|
||||
observedOverlays.add(overlay)
|
||||
}
|
||||
}
|
||||
}
|
||||
syncViewport()
|
||||
}
|
||||
const mutationObserver = new MutationObserver(updateObservation)
|
||||
if (panel) {
|
||||
mutationObserver.observe(panel, {
|
||||
attributes: true,
|
||||
attributeFilter: ['hidden']
|
||||
})
|
||||
}
|
||||
mutationObserver.observe(document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['hidden', 'aria-modal', 'role', 'class', 'style', 'open']
|
||||
})
|
||||
window.addEventListener('resize', syncViewport)
|
||||
window.addEventListener('scroll', syncViewport, true)
|
||||
document.addEventListener('transitionend', syncViewport, true)
|
||||
document.addEventListener('animationend', syncViewport, true)
|
||||
updateObservation()
|
||||
return () => {
|
||||
if (animationFrame !== undefined) {
|
||||
@@ -572,6 +596,8 @@ function BrowserViewport({
|
||||
mutationObserver.disconnect()
|
||||
window.removeEventListener('resize', syncViewport)
|
||||
window.removeEventListener('scroll', syncViewport, true)
|
||||
document.removeEventListener('transitionend', syncViewport, true)
|
||||
document.removeEventListener('animationend', syncViewport, true)
|
||||
void browserApi.setViewport({ leaseToken }).catch(() => undefined)
|
||||
}
|
||||
}, [conversationId, tabId, visible])
|
||||
@@ -2397,7 +2423,7 @@ export function RightAssistantSidebar({
|
||||
: undefined
|
||||
}
|
||||
tabId={browserTabIds[instance.id]}
|
||||
visible={open && instance.id === activeWorkbarInstanceId && !terminalCloseConfirmation}
|
||||
visible={open && instance.id === activeWorkbarInstanceId}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -111,6 +111,27 @@ describe('WorkspacePrimitives', () => {
|
||||
expect(platformFeaturesTabs).toBeDefined()
|
||||
expect(platformFeaturesTabs).toMatch(/flex:\s*0 0 auto;/u)
|
||||
})
|
||||
it('keeps shared segmented controls from collapsing beside scrollable content', () => {
|
||||
const segmentedStyles = stylesheet.match(
|
||||
/\.segmented-control,\s*\.page-tabs--segmented\s*\{(?<rules>[^}]*)\}/u
|
||||
)?.groups?.rules
|
||||
expect(segmentedStyles).toBeDefined()
|
||||
expect(segmentedStyles).toMatch(/flex:\s*0 0 auto;/u)
|
||||
})
|
||||
it('bounds knowledge library cards before truncating long names', () => {
|
||||
expect(stylesheet).toMatch(
|
||||
/\.knowledge-workspace__library-list\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\);/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.knowledge-workspace__library-button\s*\{[^}]*min-width:\s*0;/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.knowledge-workspace__library-meta\s*\{[^}]*overflow-wrap:\s*anywhere;/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.knowledge-workspace__library-identity > svg\s*\{[^}]*flex:\s*0 0 auto;/u
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps Capabilities and tools tabs visible in scrollable Settings', () => {
|
||||
const capabilitiesTabs = stylesheet.match(
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { isBrowserViewportOccluded } from './browser-viewport-occlusion'
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function setup(attributes: Record<string, string>, overlaps = true) {
|
||||
const host = document.createElement('div')
|
||||
const parent = document.createElement('div')
|
||||
const overlay = document.createElement('section')
|
||||
for (const [name, value] of Object.entries(attributes)) {
|
||||
overlay.setAttribute(name, value)
|
||||
}
|
||||
parent.append(overlay)
|
||||
document.body.append(host, parent)
|
||||
const viewport = new DOMRect(600, 100, 400, 500)
|
||||
vi.spyOn(overlay, 'getBoundingClientRect').mockReturnValue(
|
||||
new DOMRect(overlaps ? 700 : 10, 200, 200, 100)
|
||||
)
|
||||
return { host, parent, overlay, viewport }
|
||||
}
|
||||
|
||||
describe('native browser overlay detection', () => {
|
||||
it('blocks an application modal even when its surface does not overlap the page', () => {
|
||||
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 => {
|
||||
const { host, viewport } = setup({ role })
|
||||
expect(isBrowserViewportOccluded(host, viewport)).toBe(true)
|
||||
})
|
||||
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
|
||||
if (kind === 'display') overlay.style.display = 'none'
|
||||
if (kind === 'visibility') overlay.style.visibility = 'hidden'
|
||||
if (kind === 'ancestor-display') parent.style.display = 'none'
|
||||
expect(isBrowserViewportOccluded(host, viewport)).toBe(false)
|
||||
})
|
||||
it('does not treat inline statuses and error messages as overlays', () => {
|
||||
const { host, viewport } = setup({ role: 'alert' })
|
||||
expect(isBrowserViewportOccluded(host, viewport)).toBe(false)
|
||||
})
|
||||
it('does not let a surrounding non-modal container hide its own browser', () => {
|
||||
const { host, overlay, viewport } = setup({ role: 'dialog' })
|
||||
overlay.append(host)
|
||||
expect(isBrowserViewportOccluded(host, viewport)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
export const browserOverlaySelector = [
|
||||
'[aria-modal="true"]',
|
||||
'[role="dialog"]',
|
||||
'[role="alertdialog"]',
|
||||
'[role="menu"]',
|
||||
'.app-notification'
|
||||
].join(', ')
|
||||
|
||||
export function isBrowserViewportOccluded(
|
||||
host: HTMLElement,
|
||||
viewport: DOMRect
|
||||
): boolean {
|
||||
// WebContentsView is above the renderer, regardless of CSS z-index.
|
||||
// Release its viewport, not its session, while renderer overlays need it.
|
||||
return Array.from(
|
||||
host.ownerDocument.querySelectorAll<HTMLElement>(browserOverlaySelector)
|
||||
).some((overlay) => {
|
||||
if (overlay.contains(host) || overlay.closest('[hidden]')) return false
|
||||
const rect = overlay.getBoundingClientRect()
|
||||
if (rect.width < 1 || rect.height < 1) return false
|
||||
const style = getComputedStyle(overlay)
|
||||
if (style.visibility === 'hidden' || style.visibility === 'collapse') {
|
||||
return false
|
||||
}
|
||||
for (let element: HTMLElement | null = overlay; element; element = element.parentElement) {
|
||||
if (getComputedStyle(element).display === 'none') return false
|
||||
}
|
||||
return overlay.getAttribute('aria-modal') === 'true' || (
|
||||
rect.left < viewport.right &&
|
||||
rect.right > viewport.left &&
|
||||
rect.top < viewport.bottom &&
|
||||
rect.bottom > viewport.top
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -41,6 +41,7 @@
|
||||
.external-knowledge dt { color: var(--text-secondary); }
|
||||
.external-knowledge dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.external-knowledge > header h2 { margin: 0 0 var(--space-2); font-size: var(--font-section-title); }
|
||||
.external-knowledge > header { overflow-wrap: anywhere; }
|
||||
.external-knowledge > .page-tabs { flex: 0 0 auto; }
|
||||
.external-knowledge__fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.external-knowledge__filters { display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-3); flex: 0 0 auto; }
|
||||
|
||||
@@ -12731,6 +12731,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
.segmented-control,
|
||||
.page-tabs--segmented {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--border-control);
|
||||
@@ -13125,6 +13126,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
|
||||
.knowledge-workspace__library-list {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
gap: 7px;
|
||||
@@ -13133,6 +13135,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
|
||||
.knowledge-workspace__library-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 11px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-control);
|
||||
@@ -13155,13 +13158,18 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
.knowledge-workspace__library-identity strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.knowledge-workspace__library-identity > svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.knowledge-workspace__library-meta {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
margin-top: 5px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-body);
|
||||
|
||||
Reference in New Issue
Block a user