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:
mesalogo
2026-09-01 08:26:57 +08:00
parent 27055693bf
commit 5127f7d045
22 changed files with 2007 additions and 213 deletions
+335 -7
View File
@@ -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
View File
@@ -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
+294 -58
View File
@@ -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
View File
@@ -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,