feat: add browser workbar navigation controls
The Browser workbar previously showed the Agent-controlled page but did not let users navigate it directly. It now provides Back, Refresh or Stop Loading, an address field, and Go alongside the existing interaction and close actions. User and Agent operations share the same per-conversation browser session and serialization path. Loading, committed URL, and history state keep the toolbar accurate, while Stop Loading interrupts page navigation without closing the session or releasing Runtime context. Direct navigation continues to use the existing built-in-browser capability and URL policy. Closing the browser remains a distinct lifecycle action. Release note: 浏览器工作栏新增返回、刷新/停止加载、地址栏和前往操作;用户与 Agent 共用同一浏览器会话,停止加载不会关闭会话。
This commit is contained in:
@@ -245,6 +245,10 @@ const api: DesktopApi = {
|
||||
})
|
||||
},
|
||||
browser: {
|
||||
navigate: vi.fn(async () => {}),
|
||||
back: vi.fn(async () => {}),
|
||||
reload: vi.fn(async () => {}),
|
||||
stopLoading: vi.fn(async () => {}),
|
||||
interact: vi.fn(async () => {}),
|
||||
stop: vi.fn(async () => {}),
|
||||
onState: vi.fn((listener) => {
|
||||
@@ -963,6 +967,21 @@ function deferred<T>(): {
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function createBrowserState(
|
||||
conversationId: string,
|
||||
overrides: Partial<BrowserLiveState> = {}
|
||||
): BrowserLiveState {
|
||||
return {
|
||||
conversationId,
|
||||
status: 'ready',
|
||||
sessionActive: true,
|
||||
isLoading: false,
|
||||
canGoBack: false,
|
||||
updatedAt: Date.now(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function installRemoteProjectsSetting(enabled: boolean): {
|
||||
updateSettings: NonNullable<DesktopApi['updates']>['updateSettings']
|
||||
} {
|
||||
@@ -9571,7 +9590,140 @@ describe('App', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the live browser tab for the active conversation and can stop it', async () => {
|
||||
it('shows the browser toolbar and starts an empty session by address', async () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '浏览器' }))
|
||||
|
||||
const toolbar = screen.getByRole('form', {
|
||||
name: '浏览器工具栏'
|
||||
})
|
||||
const address = within(toolbar).getByRole('textbox', {
|
||||
name: '浏览器地址'
|
||||
})
|
||||
const go = within(toolbar).getByRole('button', { name: '前往' })
|
||||
expect(within(toolbar).getByRole('button', { name: '返回' }))
|
||||
.toBeDisabled()
|
||||
expect(within(toolbar).getByRole('button', { name: '刷新' }))
|
||||
.toBeDisabled()
|
||||
expect(within(toolbar).getByRole('button', { name: '交互' }))
|
||||
.toBeDisabled()
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '关闭浏览器' })
|
||||
).toBeDisabled()
|
||||
expect(go).toBeDisabled()
|
||||
expect(screen.getByText(/输入地址并前往可打开页面/u))
|
||||
.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(address, {
|
||||
target: { value: 'https://example.com/start' }
|
||||
})
|
||||
expect(go).toBeEnabled()
|
||||
fireEvent.click(go)
|
||||
await waitFor(() =>
|
||||
expect(api.browser.navigate).toHaveBeenLastCalledWith(
|
||||
expect.any(String),
|
||||
'https://example.com/start'
|
||||
)
|
||||
)
|
||||
expect(address).toHaveValue('https://example.com/start')
|
||||
|
||||
fireEvent.change(address, {
|
||||
target: { value: 'https://example.com/enter' }
|
||||
})
|
||||
fireEvent.keyDown(address, { key: 'Enter' })
|
||||
await waitFor(() =>
|
||||
expect(api.browser.navigate).toHaveBeenLastCalledWith(
|
||||
expect.any(String),
|
||||
'https://example.com/enter'
|
||||
)
|
||||
)
|
||||
expect(api.browser.navigate).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('suppresses duplicate browser actions while keeping stop loading available', async () => {
|
||||
const navigation = deferred<void>()
|
||||
vi.mocked(api.browser.navigate).mockReturnValueOnce(
|
||||
navigation.promise
|
||||
)
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '浏览器' }))
|
||||
|
||||
const toolbar = screen.getByRole('form', {
|
||||
name: '浏览器工具栏'
|
||||
})
|
||||
const address = within(toolbar).getByRole('textbox', {
|
||||
name: '浏览器地址'
|
||||
})
|
||||
const go = within(toolbar).getByRole('button', { name: '前往' })
|
||||
fireEvent.change(address, {
|
||||
target: { value: 'https://example.com/pending' }
|
||||
})
|
||||
fireEvent.click(go)
|
||||
fireEvent.click(go)
|
||||
fireEvent.keyDown(address, { key: 'Enter' })
|
||||
|
||||
expect(api.browser.navigate).toHaveBeenCalledOnce()
|
||||
const conversationId =
|
||||
vi.mocked(api.browser.navigate).mock.calls[0]?.[0] ?? ''
|
||||
act(() => {
|
||||
browserListener?.(createBrowserState(conversationId, {
|
||||
status: 'loading',
|
||||
isLoading: true,
|
||||
url: 'https://example.com/pending',
|
||||
updatedAt: Date.now()
|
||||
}))
|
||||
})
|
||||
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '返回' })
|
||||
).toBeDisabled()
|
||||
expect(address).toBeDisabled()
|
||||
expect(go).toBeDisabled()
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '交互' })
|
||||
).toBeDisabled()
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '关闭浏览器' })
|
||||
).toBeDisabled()
|
||||
const stopLoading = within(toolbar).getByRole('button', {
|
||||
name: '停止加载'
|
||||
})
|
||||
expect(stopLoading).toBeEnabled()
|
||||
fireEvent.click(stopLoading)
|
||||
expect(api.browser.stopLoading).toHaveBeenCalledOnce()
|
||||
|
||||
await act(async () => {
|
||||
navigation.resolve()
|
||||
await navigation.promise
|
||||
})
|
||||
const back = within(toolbar).getByRole('button', { name: '返回' })
|
||||
const backNavigation = deferred<void>()
|
||||
vi.mocked(api.browser.back).mockReturnValueOnce(
|
||||
backNavigation.promise
|
||||
)
|
||||
act(() => {
|
||||
browserListener?.(createBrowserState(conversationId, {
|
||||
canGoBack: true,
|
||||
url: 'https://example.com/pending',
|
||||
updatedAt: Date.now() + 1
|
||||
}))
|
||||
})
|
||||
expect(back).toBeEnabled()
|
||||
fireEvent.click(back)
|
||||
fireEvent.click(back)
|
||||
expect(api.browser.back).toHaveBeenCalledOnce()
|
||||
|
||||
await act(async () => {
|
||||
backNavigation.resolve()
|
||||
await backNavigation.promise
|
||||
})
|
||||
})
|
||||
|
||||
it('controls the active live browser without overwriting its address draft', async () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
@@ -9583,13 +9735,12 @@ describe('App', () => {
|
||||
expect(conversationId).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
browserListener?.({
|
||||
conversationId: conversationId ?? '',
|
||||
status: 'ready',
|
||||
browserListener?.(createBrowserState(conversationId ?? '', {
|
||||
canGoBack: false,
|
||||
url: 'https://example.com/',
|
||||
frameDataUrl: 'data:image/jpeg;base64,/9j/2Q==',
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText('助手工作栏')).toHaveClass(
|
||||
@@ -9604,14 +9755,191 @@ describe('App', () => {
|
||||
'src',
|
||||
'data:image/jpeg;base64,/9j/2Q=='
|
||||
)
|
||||
const toolbar = screen.getByRole('form', {
|
||||
name: '浏览器工具栏'
|
||||
})
|
||||
const address = within(toolbar).getByRole('textbox', {
|
||||
name: '浏览器地址'
|
||||
})
|
||||
const back = within(toolbar).getByRole('button', { name: '返回' })
|
||||
expect(address).toHaveValue('https://example.com/')
|
||||
expect(back).toBeDisabled()
|
||||
|
||||
fireEvent.focus(address)
|
||||
fireEvent.change(address, {
|
||||
target: { value: 'https://typed.example/draft' }
|
||||
})
|
||||
act(() => {
|
||||
browserListener?.(createBrowserState(conversationId ?? '', {
|
||||
canGoBack: true,
|
||||
url: 'https://example.com/redirected',
|
||||
updatedAt: Date.now() + 1
|
||||
}))
|
||||
})
|
||||
expect(address).toHaveValue('https://typed.example/draft')
|
||||
expect(
|
||||
screen.getByAltText('Agent 实时浏览器画面')
|
||||
).toHaveAttribute(
|
||||
'src',
|
||||
'data:image/jpeg;base64,/9j/2Q=='
|
||||
)
|
||||
|
||||
fireEvent.keyDown(address, { key: 'Escape' })
|
||||
expect(address).toHaveValue('https://example.com/redirected')
|
||||
expect(address).not.toHaveFocus()
|
||||
expect(back).toBeEnabled()
|
||||
fireEvent.click(back)
|
||||
await waitFor(() =>
|
||||
expect(api.browser.back).toHaveBeenCalledWith(conversationId)
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '交互' })
|
||||
within(toolbar).getByRole('button', { name: '刷新' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(api.browser.reload).toHaveBeenCalledWith(conversationId)
|
||||
)
|
||||
fireEvent.change(address, {
|
||||
target: { value: 'https://example.com/manual' }
|
||||
})
|
||||
fireEvent.click(
|
||||
within(toolbar).getByRole('button', { name: '前往' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(api.browser.navigate).toHaveBeenCalledWith(
|
||||
conversationId,
|
||||
'https://example.com/manual'
|
||||
)
|
||||
)
|
||||
expect(address).toHaveValue('https://example.com/redirected')
|
||||
act(() => {
|
||||
browserListener?.(createBrowserState(conversationId ?? '', {
|
||||
canGoBack: true,
|
||||
url: 'https://example.com/canonical/',
|
||||
updatedAt: Date.now() + 2
|
||||
}))
|
||||
})
|
||||
expect(address).toHaveValue('https://example.com/canonical/')
|
||||
fireEvent.focus(address)
|
||||
fireEvent.change(address, {
|
||||
target: { value: 'https://example.com/canonical' }
|
||||
})
|
||||
fireEvent.click(
|
||||
within(toolbar).getByRole('button', { name: '前往' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(api.browser.navigate).toHaveBeenCalledWith(
|
||||
conversationId,
|
||||
'https://example.com/canonical'
|
||||
)
|
||||
)
|
||||
expect(address).toHaveValue('https://example.com/canonical/')
|
||||
fireEvent.click(
|
||||
within(toolbar).getByRole('button', { name: '交互' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(api.browser.interact).toHaveBeenCalledWith(conversationId)
|
||||
)
|
||||
|
||||
act(() => {
|
||||
browserListener?.(createBrowserState(conversationId ?? '', {
|
||||
status: 'loading',
|
||||
isLoading: false,
|
||||
canGoBack: true,
|
||||
url: 'https://example.com/canonical/',
|
||||
updatedAt: Date.now() + 3
|
||||
}))
|
||||
})
|
||||
expect(back).toBeDisabled()
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '刷新' })
|
||||
).toBeDisabled()
|
||||
expect(address).toBeDisabled()
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '交互' })
|
||||
).toBeDisabled()
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '关闭浏览器' })
|
||||
).toBeDisabled()
|
||||
|
||||
act(() => {
|
||||
browserListener?.(createBrowserState(conversationId ?? '', {
|
||||
status: 'acting',
|
||||
canGoBack: true,
|
||||
url: 'https://example.com/canonical/',
|
||||
updatedAt: Date.now() + 4
|
||||
}))
|
||||
})
|
||||
expect(back).toBeDisabled()
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '刷新' })
|
||||
).toBeDisabled()
|
||||
expect(address).toBeDisabled()
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '前往' })
|
||||
).toBeDisabled()
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '交互' })
|
||||
).toBeDisabled()
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '关闭浏览器' })
|
||||
).toBeDisabled()
|
||||
|
||||
act(() => {
|
||||
browserListener?.(createBrowserState(conversationId ?? '', {
|
||||
status: 'acting',
|
||||
isLoading: true,
|
||||
canGoBack: true,
|
||||
url: 'https://example.com/canonical/',
|
||||
updatedAt: Date.now() + 5
|
||||
}))
|
||||
})
|
||||
expect(
|
||||
within(toolbar).queryByRole('button', { name: '刷新' })
|
||||
).not.toBeInTheDocument()
|
||||
const stopLoading = within(toolbar).getByRole('button', {
|
||||
name: '停止加载'
|
||||
})
|
||||
expect(stopLoading).toBeEnabled()
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '关闭浏览器' })
|
||||
).toBeDisabled()
|
||||
fireEvent.click(stopLoading)
|
||||
await waitFor(() =>
|
||||
expect(api.browser.stopLoading).toHaveBeenCalledWith(
|
||||
conversationId
|
||||
)
|
||||
)
|
||||
|
||||
act(() => {
|
||||
browserListener?.(createBrowserState(conversationId ?? '', {
|
||||
status: 'interactive',
|
||||
canGoBack: true,
|
||||
url: 'https://example.com/canonical/',
|
||||
updatedAt: Date.now() + 6
|
||||
}))
|
||||
})
|
||||
expect(back).toBeDisabled()
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '刷新' })
|
||||
).toBeDisabled()
|
||||
expect(address).toBeDisabled()
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '交互' })
|
||||
).toBeDisabled()
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '关闭浏览器' })
|
||||
).toBeDisabled()
|
||||
|
||||
act(() => {
|
||||
browserListener?.(createBrowserState(conversationId ?? '', {
|
||||
canGoBack: true,
|
||||
url: 'https://example.com/canonical/',
|
||||
updatedAt: Date.now() + 7
|
||||
}))
|
||||
})
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '停止浏览器' })
|
||||
within(toolbar).getByRole('button', { name: '关闭浏览器' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(api.browser.stop).toHaveBeenCalledWith(conversationId)
|
||||
|
||||
+50
-35
@@ -8203,6 +8203,25 @@ function App(): React.JSX.Element {
|
||||
? t('runtime.unavailable')
|
||||
: t('runtime.state.ready'))
|
||||
const composerContextErrorId = 'composer-context-error'
|
||||
const runBrowserCommand = async (
|
||||
command: (
|
||||
browserApi: NonNullable<typeof window.goodbuddy.browser>,
|
||||
conversationId: string
|
||||
) => Promise<void>
|
||||
): Promise<void> => {
|
||||
if (!activeId) {
|
||||
return
|
||||
}
|
||||
const browserApi = window.goodbuddy.browser
|
||||
if (!browserApi) {
|
||||
notify({
|
||||
tone: 'error',
|
||||
message: t('notices.browserControlUnavailable')
|
||||
})
|
||||
return
|
||||
}
|
||||
await command(browserApi, activeId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
@@ -10822,6 +10841,7 @@ function App(): React.JSX.Element {
|
||||
/>
|
||||
)}
|
||||
<RightAssistantSidebar
|
||||
activeConversationId={activeId}
|
||||
approvals={pendingSidebarApprovals}
|
||||
artifacts={sidebarArtifacts}
|
||||
browserState={browserStates[activeId]}
|
||||
@@ -10832,41 +10852,36 @@ function App(): React.JSX.Element {
|
||||
selectedTaskId={selectedAssistantTaskId}
|
||||
tasks={productAssistantTasks}
|
||||
projectNames={projectNames}
|
||||
onInteractBrowser={async () => {
|
||||
if (!activeId) {
|
||||
return
|
||||
}
|
||||
const browserApi = window.goodbuddy.browser
|
||||
if (!browserApi) {
|
||||
notify({
|
||||
tone: 'error',
|
||||
message: t('notices.browserControlUnavailable')
|
||||
})
|
||||
return
|
||||
}
|
||||
await browserApi.interact(activeId)
|
||||
}}
|
||||
onStopBrowser={async () => {
|
||||
if (!activeId) {
|
||||
return
|
||||
}
|
||||
const browserApi = window.goodbuddy.browser
|
||||
if (!browserApi) {
|
||||
notify({
|
||||
tone: 'error',
|
||||
message: t('notices.browserControlUnavailable')
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
await browserApi.stop(activeId)
|
||||
} catch {
|
||||
notify({
|
||||
tone: 'error',
|
||||
message: t('notices.browserStopFailed')
|
||||
})
|
||||
}
|
||||
}}
|
||||
onBackBrowser={() =>
|
||||
runBrowserCommand((browserApi, conversationId) =>
|
||||
browserApi.back(conversationId)
|
||||
)
|
||||
}
|
||||
onInteractBrowser={() =>
|
||||
runBrowserCommand((browserApi, conversationId) =>
|
||||
browserApi.interact(conversationId)
|
||||
)
|
||||
}
|
||||
onNavigateBrowser={(url) =>
|
||||
runBrowserCommand((browserApi, conversationId) =>
|
||||
browserApi.navigate(conversationId, url)
|
||||
)
|
||||
}
|
||||
onReloadBrowser={() =>
|
||||
runBrowserCommand((browserApi, conversationId) =>
|
||||
browserApi.reload(conversationId)
|
||||
)
|
||||
}
|
||||
onStopLoadingBrowser={() =>
|
||||
runBrowserCommand((browserApi, conversationId) =>
|
||||
browserApi.stopLoading(conversationId)
|
||||
)
|
||||
}
|
||||
onStopBrowser={() =>
|
||||
runBrowserCommand((browserApi, conversationId) =>
|
||||
browserApi.stop(conversationId)
|
||||
)
|
||||
}
|
||||
onImportArtifacts={async () => {
|
||||
const imported = await window.goodbuddy.artifacts.importFiles(
|
||||
activeProjectId || undefined
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
@@ -10,7 +12,9 @@ import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ShieldAlert,
|
||||
Upload
|
||||
Square,
|
||||
Upload,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
lazy,
|
||||
@@ -99,10 +103,15 @@ type RightAssistantSidebarProps = {
|
||||
selectedTaskId?: string
|
||||
workspaceChanges?: WorkspaceChanges
|
||||
workspaceProjectId?: string
|
||||
activeConversationId?: string
|
||||
browserState?: BrowserLiveState
|
||||
currentProject?: AssistantProject
|
||||
restoreFocusRef?: { current: HTMLElement | null }
|
||||
onBackBrowser?: () => Promise<void>
|
||||
onInteractBrowser: () => Promise<void>
|
||||
onNavigateBrowser?: (url: string) => Promise<void>
|
||||
onReloadBrowser?: () => Promise<void>
|
||||
onStopLoadingBrowser?: () => Promise<void>
|
||||
onStopBrowser: () => Promise<void>
|
||||
onCreateCustomTask: () => void
|
||||
onImportArtifacts: () => Promise<void>
|
||||
@@ -228,6 +237,226 @@ function measureSplitLayoutWidth(
|
||||
return measuredWidth > 0 ? measuredWidth : fallbackWidth
|
||||
}
|
||||
|
||||
function BrowserToolbar({
|
||||
activeConversationId,
|
||||
browserState,
|
||||
onBack,
|
||||
onInteract,
|
||||
onNavigate,
|
||||
onReload,
|
||||
onStop,
|
||||
onStopLoading
|
||||
}: {
|
||||
activeConversationId?: string
|
||||
browserState?: BrowserLiveState
|
||||
onBack: () => Promise<boolean>
|
||||
onInteract: () => Promise<boolean>
|
||||
onNavigate: (url: string) => Promise<boolean>
|
||||
onReload: () => Promise<boolean>
|
||||
onStop: () => Promise<boolean>
|
||||
onStopLoading: () => Promise<boolean>
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('workspace')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const actionPendingRef = useRef(false)
|
||||
const [addressDraft, setAddressDraft] = useState(
|
||||
browserState?.url ?? ''
|
||||
)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [actionPending, setActionPending] = useState(false)
|
||||
const authoritativeUrl = browserState?.url ?? ''
|
||||
const hasBrowserState = browserState !== undefined
|
||||
const displayedAddress =
|
||||
hasBrowserState && !editing && !dirty
|
||||
? authoritativeUrl
|
||||
: addressDraft
|
||||
const sessionActive = browserState?.sessionActive === true
|
||||
const isLoading = browserState?.isLoading === true
|
||||
const status = browserState?.status
|
||||
const isCreating = status === 'creating'
|
||||
const isActing = status === 'acting'
|
||||
const isInteractive = status === 'interactive'
|
||||
const operationBlocked =
|
||||
actionPending ||
|
||||
isCreating ||
|
||||
status === 'loading' ||
|
||||
isActing ||
|
||||
isInteractive
|
||||
const addressDisabled = operationBlocked || isLoading
|
||||
const backDisabled =
|
||||
operationBlocked ||
|
||||
!sessionActive ||
|
||||
browserState?.canGoBack !== true ||
|
||||
addressDisabled
|
||||
const refreshDisabled =
|
||||
operationBlocked ||
|
||||
!sessionActive ||
|
||||
isLoading
|
||||
const interactDisabled =
|
||||
operationBlocked ||
|
||||
!sessionActive ||
|
||||
isLoading ||
|
||||
(status !== 'ready' && status !== 'failed')
|
||||
const closeDisabled =
|
||||
operationBlocked ||
|
||||
!sessionActive ||
|
||||
isLoading
|
||||
const goDisabled =
|
||||
actionPending ||
|
||||
!activeConversationId ||
|
||||
addressDisabled ||
|
||||
displayedAddress.trim().length === 0
|
||||
|
||||
const runToolbarAction = async (
|
||||
action: () => Promise<boolean>
|
||||
): Promise<boolean> => {
|
||||
if (actionPendingRef.current) {
|
||||
return false
|
||||
}
|
||||
actionPendingRef.current = true
|
||||
setActionPending(true)
|
||||
try {
|
||||
return await action()
|
||||
} finally {
|
||||
actionPendingRef.current = false
|
||||
setActionPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submitAddress = async (): Promise<void> => {
|
||||
const url = displayedAddress.trim()
|
||||
if (goDisabled || !url) {
|
||||
return
|
||||
}
|
||||
if (await runToolbarAction(() => onNavigate(url))) {
|
||||
setDirty(false)
|
||||
setEditing(false)
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}
|
||||
|
||||
const refreshLabel = isLoading
|
||||
? t('sidebar.browser.toolbar.stopLoading')
|
||||
: t('sidebar.browser.toolbar.refresh')
|
||||
|
||||
return (
|
||||
<form
|
||||
aria-label={t('sidebar.browser.toolbar.ariaLabel')}
|
||||
className="assistant-sidebar__browser-toolbar"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void submitAddress()
|
||||
}}
|
||||
>
|
||||
<button
|
||||
aria-label={t('sidebar.browser.toolbar.back')}
|
||||
className="secondary-button assistant-sidebar__browser-tool-button"
|
||||
disabled={backDisabled}
|
||||
onClick={() => void runToolbarAction(onBack)}
|
||||
title={t('sidebar.browser.toolbar.back')}
|
||||
type="button"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" size={14} />
|
||||
</button>
|
||||
<button
|
||||
aria-label={refreshLabel}
|
||||
className="secondary-button assistant-sidebar__browser-tool-button"
|
||||
disabled={
|
||||
isLoading
|
||||
? !sessionActive || isInteractive
|
||||
: refreshDisabled
|
||||
}
|
||||
onClick={() =>
|
||||
void (isLoading
|
||||
? onStopLoading()
|
||||
: runToolbarAction(onReload))
|
||||
}
|
||||
title={refreshLabel}
|
||||
type="button"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Square aria-hidden="true" size={12} />
|
||||
) : (
|
||||
<RefreshCw aria-hidden="true" size={14} />
|
||||
)}
|
||||
</button>
|
||||
<label className="assistant-sidebar__browser-address">
|
||||
<span className="sr-only">
|
||||
{t('sidebar.browser.toolbar.address')}
|
||||
</span>
|
||||
<input
|
||||
aria-label={t('sidebar.browser.toolbar.address')}
|
||||
autoComplete="off"
|
||||
disabled={addressDisabled}
|
||||
onChange={(event) => {
|
||||
setAddressDraft(event.target.value)
|
||||
setDirty(true)
|
||||
}}
|
||||
onBlur={() => setEditing(false)}
|
||||
onFocus={() => {
|
||||
setAddressDraft(displayedAddress)
|
||||
setEditing(true)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void submitAddress()
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Escape') {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
setAddressDraft(authoritativeUrl)
|
||||
setDirty(false)
|
||||
setEditing(false)
|
||||
event.currentTarget.blur()
|
||||
}}
|
||||
placeholder={t('sidebar.browser.toolbar.addressPlaceholder')}
|
||||
ref={inputRef}
|
||||
spellCheck={false}
|
||||
title={t('sidebar.browser.toolbar.address')}
|
||||
type="text"
|
||||
value={displayedAddress}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
aria-label={t('sidebar.browser.toolbar.go')}
|
||||
className="secondary-button assistant-sidebar__browser-go"
|
||||
disabled={goDisabled}
|
||||
title={t('sidebar.browser.toolbar.go')}
|
||||
type="submit"
|
||||
>
|
||||
<ArrowRight aria-hidden="true" size={13} />
|
||||
<span>{t('sidebar.browser.toolbar.go')}</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label={t('sidebar.browser.interact')}
|
||||
className="secondary-button assistant-sidebar__browser-interact"
|
||||
disabled={interactDisabled}
|
||||
onClick={() => void runToolbarAction(onInteract)}
|
||||
title={t('sidebar.browser.interact')}
|
||||
type="button"
|
||||
>
|
||||
<ExternalLink aria-hidden="true" size={12} />
|
||||
<span>{t('sidebar.browser.interact')}</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label={t('sidebar.browser.close')}
|
||||
className="danger-ghost assistant-sidebar__browser-close"
|
||||
disabled={closeDisabled}
|
||||
onClick={() => void runToolbarAction(onStop)}
|
||||
title={t('sidebar.browser.close')}
|
||||
type="button"
|
||||
>
|
||||
<X aria-hidden="true" size={12} />
|
||||
<span>{t('sidebar.browser.close')}</span>
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export function RightAssistantSidebar({
|
||||
open,
|
||||
tab,
|
||||
@@ -240,10 +469,15 @@ export function RightAssistantSidebar({
|
||||
selectedTaskId,
|
||||
workspaceChanges,
|
||||
workspaceProjectId,
|
||||
activeConversationId,
|
||||
browserState,
|
||||
currentProject,
|
||||
restoreFocusRef,
|
||||
onBackBrowser = async () => {},
|
||||
onInteractBrowser,
|
||||
onNavigateBrowser = async () => {},
|
||||
onReloadBrowser = async () => {},
|
||||
onStopLoadingBrowser = async () => {},
|
||||
onStopBrowser,
|
||||
onCreateCustomTask,
|
||||
onImportArtifacts,
|
||||
@@ -817,19 +1051,22 @@ export function RightAssistantSidebar({
|
||||
})
|
||||
}
|
||||
|
||||
const runAction = (
|
||||
const runAction = async (
|
||||
action: () => Promise<void>,
|
||||
fallback: string,
|
||||
onSuccess?: () => void
|
||||
): void => {
|
||||
): Promise<boolean> => {
|
||||
setActionError('')
|
||||
void action()
|
||||
.then(onSuccess)
|
||||
.catch((reason: unknown) => {
|
||||
setActionError(
|
||||
reason instanceof Error ? reason.message : fallback
|
||||
)
|
||||
})
|
||||
try {
|
||||
await action()
|
||||
onSuccess?.()
|
||||
return true
|
||||
} catch (reason) {
|
||||
setActionError(
|
||||
reason instanceof Error ? reason.message : fallback
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -1243,7 +1480,7 @@ export function RightAssistantSidebar({
|
||||
disabled={!workspaceProjectId}
|
||||
onClick={() => {
|
||||
setWorkspaceRefreshVersion((current) => current + 1)
|
||||
runAction(
|
||||
void runAction(
|
||||
onRefreshChanges,
|
||||
t('sidebar.errors.refreshWorkspace')
|
||||
)
|
||||
@@ -1347,7 +1584,7 @@ export function RightAssistantSidebar({
|
||||
<button
|
||||
className="secondary-button assistant-sidebar__import"
|
||||
onClick={() =>
|
||||
runAction(
|
||||
void runAction(
|
||||
onImportArtifacts,
|
||||
t('sidebar.errors.importResult')
|
||||
)
|
||||
@@ -1369,7 +1606,7 @@ export function RightAssistantSidebar({
|
||||
onClick={() => {
|
||||
setSelectedArtifactId(artifact.id)
|
||||
setActionError('')
|
||||
runAction(
|
||||
void runAction(
|
||||
() => onLoadArtifact(artifact.id),
|
||||
t('sidebar.errors.loadResult')
|
||||
)
|
||||
@@ -1394,48 +1631,55 @@ export function RightAssistantSidebar({
|
||||
)}
|
||||
|
||||
{instance.appId === 'browser' && (
|
||||
<section className="assistant-sidebar__browser">
|
||||
<section
|
||||
className="assistant-sidebar__browser"
|
||||
key={activeConversationId}
|
||||
>
|
||||
<BrowserToolbar
|
||||
activeConversationId={activeConversationId}
|
||||
browserState={browserState}
|
||||
onBack={() =>
|
||||
runAction(
|
||||
onBackBrowser,
|
||||
t('sidebar.errors.backBrowser')
|
||||
)
|
||||
}
|
||||
onInteract={() =>
|
||||
runAction(
|
||||
onInteractBrowser,
|
||||
t('sidebar.errors.interactBrowser')
|
||||
)
|
||||
}
|
||||
onNavigate={(url) =>
|
||||
runAction(
|
||||
() => onNavigateBrowser(url),
|
||||
t('sidebar.errors.navigateBrowser')
|
||||
)
|
||||
}
|
||||
onReload={() =>
|
||||
runAction(
|
||||
onReloadBrowser,
|
||||
t('sidebar.errors.reloadBrowser')
|
||||
)
|
||||
}
|
||||
onStop={() =>
|
||||
runAction(
|
||||
onStopBrowser,
|
||||
t('sidebar.errors.stopBrowser')
|
||||
)
|
||||
}
|
||||
onStopLoading={() =>
|
||||
runAction(
|
||||
onStopLoadingBrowser,
|
||||
t('sidebar.errors.stopLoadingBrowser')
|
||||
)
|
||||
}
|
||||
/>
|
||||
<header>
|
||||
<span>
|
||||
<Monitor size={15} />
|
||||
<strong>{t('sidebar.browser.title')}</strong>
|
||||
</span>
|
||||
{browserState &&
|
||||
browserState.status !== 'stopped' && (
|
||||
<div className="assistant-sidebar__browser-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={
|
||||
browserState.status === 'creating' ||
|
||||
browserState.status === 'interactive'
|
||||
}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
onInteractBrowser,
|
||||
t('sidebar.errors.interactBrowser')
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<ExternalLink aria-hidden="true" size={12} />
|
||||
{browserState.status === 'interactive'
|
||||
? t('sidebar.browser.interacting')
|
||||
: t('sidebar.browser.interact')}
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() =>
|
||||
runAction(
|
||||
onStopBrowser,
|
||||
t('sidebar.errors.stopBrowser')
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t('sidebar.browser.stop')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
{!browserState ? (
|
||||
<p className="assistant-sidebar__empty">
|
||||
@@ -1471,14 +1715,6 @@ export function RightAssistantSidebar({
|
||||
t('sidebar.browser.statuses.failed')
|
||||
: t('sidebar.browser.statuses.stopped')}
|
||||
</div>
|
||||
{browserState.url && (
|
||||
<div
|
||||
className="assistant-sidebar__browser-url"
|
||||
title={browserState.url}
|
||||
>
|
||||
{browserState.url}
|
||||
</div>
|
||||
)}
|
||||
{browserState.frameDataUrl ? (
|
||||
<img
|
||||
alt={t('sidebar.browser.frameAlt')}
|
||||
|
||||
@@ -346,9 +346,18 @@ export const workspace = {
|
||||
title: 'Live browser',
|
||||
interact: 'Interact',
|
||||
interacting: 'Interacting',
|
||||
stop: 'Stop browser',
|
||||
close: 'Close browser',
|
||||
empty:
|
||||
'The live view will appear here after the Agent opens a web page.',
|
||||
'Enter an address to open a page. The same live view appears here when the Agent uses the browser.',
|
||||
toolbar: {
|
||||
ariaLabel: 'Browser toolbar',
|
||||
back: 'Back',
|
||||
refresh: 'Refresh',
|
||||
stopLoading: 'Stop loading',
|
||||
address: 'Browser address',
|
||||
addressPlaceholder: 'Enter a web address',
|
||||
go: 'Go'
|
||||
},
|
||||
statuses: {
|
||||
creating: 'Starting browser…',
|
||||
loading: 'Loading page…',
|
||||
@@ -370,8 +379,12 @@ export const workspace = {
|
||||
refreshWorkspace: 'Could not refresh workspace files',
|
||||
importResult: 'Could not import results',
|
||||
loadResult: 'Could not load the result',
|
||||
navigateBrowser: 'Could not open the web page',
|
||||
backBrowser: 'Could not go back',
|
||||
reloadBrowser: 'Could not refresh the web page',
|
||||
stopLoadingBrowser: 'Could not stop loading the page',
|
||||
interactBrowser: 'Could not open the browser interaction window',
|
||||
stopBrowser: 'Could not stop the browser'
|
||||
stopBrowser: 'Could not close the browser'
|
||||
}
|
||||
},
|
||||
task: {
|
||||
|
||||
@@ -336,8 +336,18 @@ export const workspace = {
|
||||
title: '实时浏览器',
|
||||
interact: '交互',
|
||||
interacting: '交互中',
|
||||
stop: '停止浏览器',
|
||||
empty: 'Agent 打开网页后,实时画面会显示在这里。',
|
||||
close: '关闭浏览器',
|
||||
empty:
|
||||
'输入地址并前往可打开页面;Agent 打开网页后,同一会话的实时画面也会显示在这里。',
|
||||
toolbar: {
|
||||
ariaLabel: '浏览器工具栏',
|
||||
back: '返回',
|
||||
refresh: '刷新',
|
||||
stopLoading: '停止加载',
|
||||
address: '浏览器地址',
|
||||
addressPlaceholder: '输入网址',
|
||||
go: '前往'
|
||||
},
|
||||
statuses: {
|
||||
creating: '正在启动浏览器…',
|
||||
loading: '正在加载页面…',
|
||||
@@ -359,8 +369,12 @@ export const workspace = {
|
||||
refreshWorkspace: '刷新工作区文件失败',
|
||||
importResult: '导入成果失败',
|
||||
loadResult: '加载成果失败',
|
||||
navigateBrowser: '打开网页失败',
|
||||
backBrowser: '返回上一页失败',
|
||||
reloadBrowser: '刷新网页失败',
|
||||
stopLoadingBrowser: '停止加载失败',
|
||||
interactBrowser: '打开浏览器交互窗口失败',
|
||||
stopBrowser: '停止浏览器失败'
|
||||
stopBrowser: '关闭浏览器失败'
|
||||
}
|
||||
},
|
||||
task: {
|
||||
|
||||
+68
-20
@@ -4800,22 +4800,81 @@ button > svg {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-actions {
|
||||
.assistant-sidebar__browser-toolbar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser > header .secondary-button {
|
||||
display: inline-flex;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
flex: none;
|
||||
font-size: 10px;
|
||||
.assistant-sidebar__browser-tool-button,
|
||||
.assistant-sidebar__browser-go,
|
||||
.assistant-sidebar__browser-interact,
|
||||
.assistant-sidebar__browser-close {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-tool-button {
|
||||
width: var(--control-height);
|
||||
padding: 0;
|
||||
flex: 0 0 var(--control-height);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-address {
|
||||
min-width: 112px;
|
||||
flex: 1 1 128px;
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-address input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: var(--control-height);
|
||||
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);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-address input:focus-visible {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-go,
|
||||
.assistant-sidebar__browser-interact,
|
||||
.assistant-sidebar__browser-close {
|
||||
padding: 0 var(--space-2);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-tool-button:hover:not(:disabled),
|
||||
.assistant-sidebar__browser-go:hover:not(:disabled),
|
||||
.assistant-sidebar__browser-interact:hover:not(:disabled) {
|
||||
border-color: var(--accent-selected);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-address input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-toolbar
|
||||
.assistant-sidebar__browser-close {
|
||||
padding: 0 var(--space-2);
|
||||
border-color: var(--danger-border);
|
||||
margin-left: var(--space-1);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-status {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
@@ -4832,15 +4891,6 @@ button > svg {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-url {
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-frame,
|
||||
.assistant-sidebar__browser-placeholder {
|
||||
width: 100%;
|
||||
@@ -15626,10 +15676,8 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
.assistant-sidebar__approval p,
|
||||
.assistant-sidebar__approval-actions button,
|
||||
.assistant-sidebar__badge,
|
||||
.assistant-sidebar__browser > header .secondary-button,
|
||||
.assistant-sidebar__browser-placeholder,
|
||||
.assistant-sidebar__browser-status,
|
||||
.assistant-sidebar__browser-url,
|
||||
.assistant-sidebar__diff,
|
||||
.assistant-sidebar__empty,
|
||||
.assistant-sidebar__import,
|
||||
|
||||
Reference in New Issue
Block a user