fix: prevent blank browser reloads and clear stale errors
Require a committed page URL before enabling reload. Clear sidebar action errors when leaving or closing a panel, and ignore late failures from its previous scope. Add regression coverage and document browser error lifecycle and validation results. Release note: 修复空白浏览器页刷新时报错、关闭浏览器后错误提示仍残留的问题;切换或关闭面板后,旧操作的延迟失败不再重新显示。
This commit is contained in:
@@ -122,6 +122,7 @@ const currentProject: AssistantProject = {
|
||||
}
|
||||
|
||||
function sidebarElement({
|
||||
open = true,
|
||||
tab = 'tasks',
|
||||
approvals = [],
|
||||
artifacts = [],
|
||||
@@ -144,6 +145,7 @@ function sidebarElement({
|
||||
onStopLoadingBrowser,
|
||||
onTabChange = vi.fn()
|
||||
}: {
|
||||
open?: boolean
|
||||
tab?: AssistantSidebarTab
|
||||
approvals?: PendingSidebarApproval[]
|
||||
artifacts?: SidebarArtifact[]
|
||||
@@ -206,7 +208,7 @@ function sidebarElement({
|
||||
onStopLoadingBrowser={onStopLoadingBrowser}
|
||||
onOpenTask={vi.fn()}
|
||||
onTabChange={onTabChange}
|
||||
open
|
||||
open={open}
|
||||
restoreFocusRef={restoreFocusRef}
|
||||
tab={tab}
|
||||
workspaceProjectId={workspaceProjectId}
|
||||
@@ -822,6 +824,112 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('requires a committed URL to reload a fresh active browser tab, but still allows Stop', async () => {
|
||||
const conversationId = 'blank-browser'
|
||||
const state: BrowserLiveState = {
|
||||
...browserSummary(conversationId, firstBrowserTabId, true),
|
||||
sessionActive: true
|
||||
}
|
||||
const onReloadBrowser = vi.fn(async () => undefined)
|
||||
const onStopLoadingBrowser = vi.fn(async () => undefined)
|
||||
const props = {
|
||||
tab: 'browser' as const,
|
||||
activeConversationId: conversationId,
|
||||
onReloadBrowser,
|
||||
onStopLoadingBrowser
|
||||
}
|
||||
const withState = (browserState: BrowserLiveState) => sidebarElement({
|
||||
...props, browserStates: { [conversationId]: { [firstBrowserTabId]: browserState } }
|
||||
})
|
||||
const view = render(withState(state))
|
||||
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce())
|
||||
expect(screen.getByRole('button', { name: '刷新' })).toBeDisabled()
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '浏览器地址' }), {
|
||||
target: { value: 'https://draft.example/' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
|
||||
expect(screen.getByRole('button', { name: '刷新' })).toBeDisabled()
|
||||
expect(onReloadBrowser).not.toHaveBeenCalled()
|
||||
|
||||
view.rerender(withState({ ...state, status: 'loading', isLoading: true }))
|
||||
expect(screen.getByRole('button', { name: '停止加载' })).toBeEnabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止加载' }))
|
||||
await waitFor(() => expect(onStopLoadingBrowser).toHaveBeenCalledWith(conversationId, firstBrowserTabId))
|
||||
|
||||
view.rerender(withState({ ...state, url: 'https://committed.example/' }))
|
||||
expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
|
||||
await waitFor(() => expect(onReloadBrowser).toHaveBeenCalledWith(conversationId, firstBrowserTabId))
|
||||
})
|
||||
|
||||
it.each(['close', 'switch', 'collapse'] as const)(
|
||||
'clears a browser action error after %s without restoring it on return',
|
||||
async (transition) => {
|
||||
const props = {
|
||||
tab: 'browser' as const,
|
||||
activeConversationId: 'error-browser',
|
||||
onNavigateBrowser: vi.fn().mockRejectedValue(new Error('Navigation failed'))
|
||||
}
|
||||
const view = render(sidebarElement(props))
|
||||
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce())
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '浏览器地址' }), {
|
||||
target: { value: 'https://example.com/' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '前往' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Navigation failed')
|
||||
|
||||
if (transition === 'close') {
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭浏览器 · error-browser' }))
|
||||
await waitFor(() => expect(screen.queryByRole('tab', { name: '浏览器 · error-browser' })).not.toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' }))
|
||||
fireEvent.click(screen.getByText('浏览器', { selector: 'strong' }).closest('button')!)
|
||||
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2))
|
||||
} else if (transition === 'switch') {
|
||||
fireEvent.click(screen.getByRole('tab', { name: '工作区' }))
|
||||
expect(screen.queryByText('Navigation failed')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '浏览器 · error-browser' }))
|
||||
} else {
|
||||
view.rerender(sidebarElement({ ...props, open: false }))
|
||||
view.rerender(sidebarElement(props))
|
||||
}
|
||||
expect(screen.queryByText('Navigation failed')).not.toBeInTheDocument()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['close', 'switch', 'collapse'] as const)(
|
||||
'ignores a late browser action failure after %s',
|
||||
async (transition) => {
|
||||
let rejectNavigation!: (reason: Error) => void
|
||||
const props = {
|
||||
tab: 'browser' as const,
|
||||
activeConversationId: 'late-browser',
|
||||
onNavigateBrowser: vi.fn(() => new Promise<void>((_resolve, reject) => {
|
||||
rejectNavigation = reject
|
||||
}))
|
||||
}
|
||||
const view = render(sidebarElement(props))
|
||||
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce())
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '浏览器地址' }), {
|
||||
target: { value: 'https://example.com/' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '前往' }))
|
||||
await waitFor(() => expect(props.onNavigateBrowser).toHaveBeenCalledOnce())
|
||||
|
||||
if (transition === 'close') {
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭浏览器 · late-browser' }))
|
||||
await waitFor(() => expect(screen.queryByRole('tab', { name: '浏览器 · late-browser' })).not.toBeInTheDocument())
|
||||
} else if (transition === 'switch') {
|
||||
fireEvent.click(screen.getByRole('tab', { name: '工作区' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '浏览器 · late-browser' }))
|
||||
} else {
|
||||
view.rerender(sidebarElement({ ...props, open: false }))
|
||||
}
|
||||
await act(async () => rejectNavigation(new Error('Late navigation failed')))
|
||||
if (transition === 'collapse') view.rerender(sidebarElement(props))
|
||||
expect(screen.queryByText('Late navigation failed')).not.toBeInTheDocument()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps browser A selected and usable after switching to B, and binds a new browser to B', async () => {
|
||||
const onNavigateBrowser = vi.fn(async () => undefined)
|
||||
const onBackBrowser = vi.fn(async () => undefined)
|
||||
@@ -1204,8 +1312,36 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '浏览器 · conversation-a' })
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭浏览器 · conversation-a' }))
|
||||
await waitFor(() => expect(screen.queryByRole('tab', { name: '浏览器 · conversation-a' })).not.toBeInTheDocument())
|
||||
expect(screen.queryByText('Close denied')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['create', 'close'] as const)(
|
||||
'ignores a late browser %s failure after switching panels',
|
||||
async (operation) => {
|
||||
let rejectOperation!: (reason: Error) => void
|
||||
const pending = new Promise<never>((_resolve, reject) => {
|
||||
rejectOperation = reject
|
||||
})
|
||||
if (operation === 'create') browserApi.createTab.mockReturnValueOnce(pending)
|
||||
else browserApi.closeTab.mockReturnValueOnce(pending)
|
||||
renderSidebar({ tab: 'browser', activeConversationId: 'late-browser' })
|
||||
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce())
|
||||
if (operation === 'close') {
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭浏览器 · late-browser' }))
|
||||
await waitFor(() => expect(browserApi.closeTab).toHaveBeenCalledOnce())
|
||||
}
|
||||
fireEvent.click(screen.getByRole('tab', { name: '工作区' }))
|
||||
await act(async () => rejectOperation(new Error('Late browser failure')))
|
||||
expect(screen.queryByText('Late browser failure')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '浏览器 · late-browser' }))
|
||||
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(operation === 'create' ? 2 : 1))
|
||||
expect(screen.queryByText('Late browser failure')).not.toBeInTheDocument()
|
||||
}
|
||||
)
|
||||
|
||||
it('rebinds a released tab before retrying navigation and can close a released instance', async () => {
|
||||
const conversationId = 'retry-browser'
|
||||
const onNavigateBrowser = vi.fn(async () => undefined)
|
||||
|
||||
@@ -321,6 +321,7 @@ function BrowserToolbar({
|
||||
const refreshDisabled =
|
||||
operationBlocked ||
|
||||
!sessionActive ||
|
||||
!authoritativeUrl ||
|
||||
isLoading
|
||||
const goDisabled =
|
||||
actionPending ||
|
||||
@@ -803,7 +804,23 @@ export function RightAssistantSidebar({
|
||||
const [taskScope, setTaskScope] = useState<WorkbarTaskScope>(
|
||||
initialLayout?.taskScope ?? 'current-project'
|
||||
)
|
||||
const [actionError, setActionError] = useState('')
|
||||
const [actionErrorState, setActionErrorState] = useState({
|
||||
scope: { instanceId: activeWorkbarInstanceId, open },
|
||||
message: ''
|
||||
})
|
||||
const actionErrorScope = actionErrorState.scope
|
||||
const actionError = actionErrorState.message
|
||||
if (actionErrorScope.instanceId !== activeWorkbarInstanceId || actionErrorScope.open !== open) {
|
||||
setActionErrorState({ scope: { instanceId: activeWorkbarInstanceId, open }, message: '' })
|
||||
}
|
||||
const setActionError = useCallback((message: string): void => {
|
||||
// Old callbacks cannot restore an error after leaving and returning to a panel.
|
||||
setActionErrorState((current) =>
|
||||
current.scope === actionErrorScope && actionErrorScope.open
|
||||
? { ...current, message }
|
||||
: current
|
||||
)
|
||||
}, [actionErrorScope])
|
||||
const [terminalSessionIds, setTerminalSessionIds] = useState<
|
||||
Record<string, string>
|
||||
>({})
|
||||
@@ -1271,7 +1288,7 @@ export function RightAssistantSidebar({
|
||||
tabId = binding?.tabId
|
||||
conversationId = binding?.conversationId
|
||||
} catch (reason) {
|
||||
setActionError(
|
||||
if (instance.id === activeWorkbarInstanceId) setActionError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: t('sidebar.errors.closeBrowser')
|
||||
@@ -1283,7 +1300,7 @@ export function RightAssistantSidebar({
|
||||
try {
|
||||
await window.goodbuddy.browser.closeTab?.({ conversationId, tabId })
|
||||
} catch (reason) {
|
||||
setActionError(
|
||||
if (instance.id === activeWorkbarInstanceId) setActionError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: t('sidebar.errors.closeBrowser')
|
||||
@@ -1326,6 +1343,8 @@ export function RightAssistantSidebar({
|
||||
return true
|
||||
},
|
||||
[
|
||||
activeWorkbarInstanceId,
|
||||
setActionError,
|
||||
removeWorkbarInstance,
|
||||
localizedAppDefinitions,
|
||||
terminalAdapter,
|
||||
@@ -1654,7 +1673,7 @@ export function RightAssistantSidebar({
|
||||
: t('sidebar.errors.browserControlUnavailable')
|
||||
)
|
||||
})
|
||||
}, [activeWorkbarInstance, ensureBrowserTab, hasUnboundRequestBrowser, open, t])
|
||||
}, [activeWorkbarInstance, ensureBrowserTab, hasUnboundRequestBrowser, open, setActionError, t])
|
||||
|
||||
return (
|
||||
<aside
|
||||
|
||||
Reference in New Issue
Block a user