Files
claude-code-haha/desktop/src/components/workbench/WorkspaceSurface.tsx
T
程序员阿江-Relakkes 7e50c31986 fix(desktop): composite the workspace browser with the app UI (#1437)
The workspace browser was a native WebContentsView, which always paints
above the DOM. Switching to the skills or settings page left the page
floating over it, and menus over the page needed a screenshot swap that
showed up clipped and flashed white.

Pages are now <webview> guests kept in a layer inside the session panel
that is never unmounted, so other pages, menus and dialogs draw over
them like any element. The main process adopts each guest and keeps all
in-page behaviour: navigation, history, find, zoom, capture, PDF,
downloads, shortcuts and annotation.

- Guard every attach in the main window: browser partition only, start
  at about:blank, preload and sandbox pinned, reported ids validated.
- Match the native page: drop the blank history entry, keep page zoom
  independent of app zoom, allow popups so they still become tabs.
- Keep app drags working over a page and close menus on a click into it.
- Tell the side dock it is off screen when the session page is hidden.
- Remove the overlay snapshot machinery and native bounds syncing.
2026-10-04 03:24:50 +08:00

386 lines
16 KiB
TypeScript

import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { SideChatSurface } from '@/components/sideChat/SideChatSurface'
import { openSideChat } from '@/lib/workspace/openSideChat'
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useWorkspaceHeaderTarget } from '../layout/WorkspaceHeaderContext'
import { useShallow } from 'zustand/react/shallow'
import { t, useTranslation } from '../../i18n'
import { useChatStore } from '../../stores/chatStore'
import { useWorkspaceStore } from '../../stores/workspaceStore'
import { discardNavigatedBrowserSelections, handleBrowserSelectionEvent } from '../../lib/workspace/browserSelections'
import { useWorkspaceBrowserStore } from '../../stores/workspaceBrowserStore'
import { useWorkspaceContentStore } from '../../stores/workspaceContentStore'
import { openWorkspaceTarget, workspaceOpen } from '../../lib/workspace/openTarget'
import { subscribeWorkspaceBrowserEvents } from '../../lib/workspace/browserHost'
import { forgetLostWorkspaceBrowserGuest } from '../../lib/workspace/browserGuests'
import type { WorkspaceCloseScope, WorkspaceDock, WorkspaceTabKind } from '../../lib/workspace/types'
import { WorkspaceTabStrip } from './WorkspaceTabStrip'
import { WorkspaceLauncher } from './WorkspaceLauncher'
import { WorkspaceAddMenu } from './WorkspaceAddMenu'
import { WorkspaceBrowserTab } from './WorkspaceBrowserTab'
import { WorkspaceTerminalTab } from './WorkspaceTerminalTab'
import { useWorkspaceFileWatch } from '@/lib/workspace/useWorkspaceFileWatch'
import { WorkspaceFileTab } from './WorkspaceFileTab'
import { WorkspaceReviewTab } from './WorkspaceReviewTab'
export type WorkspaceSurfaceProps = {
sessionId: string
dock: WorkspaceDock
/** Where a new terminal starts, and the root the file tree reads. */
cwd: string
/** Reason the review entry is unavailable here, e.g. "not a Git repository". */
reviewUnavailableReason?: string | null
/**
* Whether this dock is on screen. The bottom dock stays mounted while hidden
* so xterm keeps its geometry, so "mounted" and "visible" are not the same
* question and the content needs to be told which one it is.
*/
visible?: boolean
}
/**
* One dock of the workspace: a mixed tab strip over the active tab's content.
*
* The strip is always present, including for a single tab — the reference does
* the same, and it is what makes "this panel holds resources, and here they
* are" true at every moment instead of only once a second thing is open.
*/
export function WorkspaceSurface({
sessionId,
dock,
cwd,
reviewUnavailableReason = null,
visible = true,
}: WorkspaceSurfaceProps) {
const t = useTranslation()
const surfaceRef = useRef<HTMLDivElement>(null)
const headerTarget = useWorkspaceHeaderTarget(surfaceRef, sessionId, dock === 'side' && visible)
const [menuOpen, setMenuOpen] = useState(false)
const [pendingClose, setPendingClose] = useState<{ sessionId: string; tabId: string; scope: WorkspaceCloseScope } | null>(null)
const menuId = useId()
const initialMenuFocus = useRef<'first' | 'last'>('first')
const pendingMenuSelection = useRef<WorkspaceTabKind | null>(null)
const menuTriggerRef = useRef<HTMLButtonElement | null>(null)
const watchError = useWorkspaceFileWatch(sessionId, dock === 'side' && visible)
const contentRef = useRef<HTMLDivElement>(null)
const tabs = useWorkspaceStore(
useShallow((state) => (state.bySession[sessionId]?.tabs ?? []).filter((tab) => tab.dock === dock)),
)
const activeTabId = useWorkspaceStore((state) =>
dock === 'side'
? state.bySession[sessionId]?.activeSideTabId ?? null
: state.bySession[sessionId]?.activeBottomTabId ?? null,
)
const canReopenClosed = useWorkspaceStore(
(state) => (state.bySession[sessionId]?.closed.length ?? 0) > 0,
)
const focus = useWorkspaceStore((state) => state.bySession[sessionId]?.focus ?? null)
// Without this the branch comparison is unreachable: the picker only offers it
// when it knows which branch to compare against.
const defaultBranchRef = useWorkspaceContentStore(
(state) => state.statusBySession[sessionId]?.branch ?? null,
)
const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? null
useEffect(() => {
// A chooser belongs to the dock and task where it was opened. A reused
// surface must show the next task's content, not the previous task's menu.
setMenuOpen(false)
setPendingClose(null)
menuTriggerRef.current = null
pendingMenuSelection.current = null
}, [dock, sessionId, visible])
const closeMenu = useCallback(() => setMenuOpen(false), [])
/*
This surface can only ever honour a request to focus its *content*. The two
toggle targets are handled by `useWorkspaceFocusReturn`, which lives in a
component that outlives the panel — hiding the workspace unmounts everything
here, so a `side-toggle` request raised by that very action could never be
consumed from inside it, and would then fire stale on the next reopen and
park focus on the button that hides the panel again.
*/
useEffect(() => {
if (!focus) return
const wanted = dock === 'side' ? 'active-side-tab' : 'active-bottom-tab'
if (focus.target !== wanted) return
const target = contentRef.current?.querySelector<HTMLElement>('[data-workspace-autofocus]') ?? contentRef.current
target?.focus({ preventScroll: true })
useWorkspaceStore.getState().consumeFocusRequest(sessionId)
}, [dock, focus, sessionId])
const handleLauncherSelect = useCallback((kind: WorkspaceTabKind) => {
setMenuOpen(false)
// A + action adds a resource; even an uncommitted browser page belongs
// to its existing tab and must not be consumed as a blank placeholder.
switch (kind) {
case 'side-chat':
void openSideChat(sessionId)
break
case 'review':
openWorkspaceTarget({ sessionId, target: { kind: 'review' } })
break
case 'terminal':
openWorkspaceTarget({
sessionId,
target: { kind: 'terminal', cwd, dock },
})
break
case 'browser':
openWorkspaceTarget({ sessionId, target: { kind: 'browser' } })
break
case 'file':
// The tree comes with the file view, so opening "Files" with nothing
// chosen yet is a tree with an empty content pane rather than a modal.
openWorkspaceTarget({
sessionId,
target: { kind: 'file', path: '' },
preview: true,
})
break
}
}, [cwd, dock, sessionId])
// Let the menu finish its own focus cleanup before opening/focusing a resource.
// Otherwise its close autofocus can pull focus out of a new address bar or tree.
useEffect(() => {
if (menuOpen || !visible) return
const kind = pendingMenuSelection.current
pendingMenuSelection.current = null
if (kind) handleLauncherSelect(kind)
}, [handleLauncherSelect, menuOpen, visible])
const selectFromMenu = useCallback((kind: WorkspaceTabKind) => {
pendingMenuSelection.current = kind
setMenuOpen(false)
}, [])
useEffect(() => {
if (!visible || dock !== 'side') return
const request = (event: Event) => {
const detail = (event as CustomEvent<{ sessionId: string; tabId: string }>).detail
if (detail?.sessionId === sessionId && tabs.some(tab => tab.id === detail.tabId && tab.kind === 'side-chat')) {
setPendingClose({ sessionId, tabId: detail.tabId, scope: 'current' })
}
}
window.addEventListener('workspace-close-request', request)
return () => window.removeEventListener('workspace-close-request', request)
}, [dock, sessionId, tabs, visible])
const requestClose = (tabId: string, scope: WorkspaceCloseScope = 'current') => {
const index = tabs.findIndex(tab => tab.id === tabId)
const affected = tabs.filter((tab, position) => scope === 'all' ||
(scope === 'current' && tab.id === tabId) || (scope === 'others' && tab.id !== tabId) ||
(scope === 'left' && position < index) || (scope === 'right' && position > index))
if (affected.some(tab => tab.kind === 'side-chat')) {
setPendingClose({ sessionId, tabId, scope })
return
}
useWorkspaceStore.getState().closeTabs(sessionId, tabId, scope)
}
const tabStrip = tabs.length > 0 ? (
<WorkspaceTabStrip
dock={dock}
placement={headerTarget ? 'window' : 'dock'}
tabs={tabs}
activeTabId={activeTabId}
canReopenClosed={canReopenClosed}
onActivate={(tabId) => {
// Choosing an existing tab is also a way of cancelling the picker.
setMenuOpen(false)
useWorkspaceStore.getState().activateTab(sessionId, tabId)
}}
onPin={(tabId) => useWorkspaceStore.getState().pinTab(sessionId, tabId)}
onClose={(tabId) => requestClose(tabId)}
onCloseScope={requestClose}
onReorder={(tabId, index) => useWorkspaceStore.getState().moveTab(sessionId, tabId, index)}
onMoveDock={(tabId, nextDock) =>
useWorkspaceStore.getState().moveTabToDock(sessionId, tabId, nextDock)}
onReopenClosed={() => useWorkspaceStore.getState().reopenClosedTab(sessionId)}
addMenuId={menuId}
addMenuOpen={menuOpen}
onAdd={(trigger, initialFocus = 'first') => {
menuTriggerRef.current = trigger
initialMenuFocus.current = initialFocus
setMenuOpen((open) => !open)
}}
/>
) : null
return (
<div
ref={surfaceRef}
data-testid={`workspace-surface-${dock}`}
aria-label={t('workspace.panelLabel')}
className="flex h-full min-h-0 w-full flex-col bg-[var(--color-surface)]"
>
{watchError ? <p role="status" className="shrink-0 px-3 py-1 text-xs text-[var(--color-text-tertiary)]">{t('workspace.files.watchFailed', { reason: watchError })}</p> : null}
{tabStrip && headerTarget ? createPortal(tabStrip, headerTarget) : tabStrip}
<div
ref={contentRef}
tabIndex={-1}
role="tabpanel"
id={`workspace-tabpanel-${dock}`}
// The strip emits this exact id on every `role="tab"`; without the
// pairing the tablist announces a control that governs nothing.
{...(activeTabId ? { 'aria-labelledby': `workspace-tab-${dock}-${activeTabId}` } : {})}
className="flex min-h-0 flex-1 flex-col outline-none"
>
{tabs.length === 0 ? (
<WorkspaceLauncher
onSelect={handleLauncherSelect}
dock={dock}
reviewUnavailableReason={reviewUnavailableReason}
/>
) : activeTab === null ? null : activeTab.kind === 'side-chat' ? (
<SideChatSurface parentSessionId={sessionId} sideChatId={activeTab.sideChatId} visible={visible} />
) : activeTab.kind === 'browser' ? (
<WorkspaceBrowserTab sessionId={sessionId} tab={activeTab} active={visible} />
) : activeTab.kind === 'terminal' ? (
<WorkspaceTerminalTab sessionId={sessionId} tab={activeTab} active={visible} />
) : activeTab.kind === 'file' ? (
<WorkspaceFileTab sessionId={sessionId} tab={activeTab} />
) : (
<WorkspaceReviewTab
active={visible}
sessionId={sessionId}
tab={activeTab}
defaultBranchRef={defaultBranchRef}
/>
)}
</div>
<ConfirmDialog open={Boolean(pendingClose && pendingClose.sessionId === sessionId && visible)}
title={t('sideChat.closeTitle')} body={t('sideChat.closeBody')}
confirmLabel={t('common.close')} cancelLabel={t('common.cancel')}
onClose={() => setPendingClose(null)} onConfirm={() => {
if (pendingClose) useWorkspaceStore.getState().closeTabs(pendingClose.sessionId, pendingClose.tabId, pendingClose.scope)
setPendingClose(null)
}} />
{menuOpen && visible ? (
<WorkspaceAddMenu
id={menuId}
anchorRef={menuTriggerRef}
dock={dock}
initialFocus={initialMenuFocus.current}
reviewUnavailableReason={reviewUnavailableReason}
onSelect={selectFromMenu}
onClose={closeMenu}
/>
) : null}
</div>
)
}
/**
* Bridge host page events into the stores, once per app.
*
* Two things have to be true at once, and the obvious implementation gets the
* second one wrong:
*
* 1. The subscription must outlive any single tab's React surface — events keep
* arriving for pages whose surface is not mounted (a background tab finishing
* a load, a download completing).
* 2. Each event belongs to the task that **owns the page**, which is not
* necessarily the task on screen. Routing by the foreground session drops
* every event for a backgrounded task's pages, and — far worse — makes a
* `window.open` from one task's page create a tab in whichever task the user
* happens to be reading.
*
* So the subscription is opened once and the owner is resolved from the page id.
*/
export function useWorkspaceBrowserEventBridge(enabled: boolean) {
useEffect(() => {
if (!enabled) return
let unsubscribe: (() => void) | undefined
let cancelled = false
void subscribeWorkspaceBrowserEvents((event) => {
const store = useWorkspaceStore.getState()
if (event.type === 'shortcut') {
window.dispatchEvent(new CustomEvent('workspace-native-shortcut', { detail: event }))
return
}
// Downloads outlive their source tab. Only their global entry may update
// after close; page-specific events must not resurrect forgotten state.
if (event.type === 'download') {
useWorkspaceBrowserStore.getState().applyEvent(event)
return
}
const owner = store.findBrowserTabOwner(event.tabId)
// A page that no longer belongs to any tab has been closed; its late
// events must not be applied to whatever took its place.
if (!owner) return
const previousNavigationId = useWorkspaceBrowserStore.getState().getPage(event.tabId).navigationId
if (!useWorkspaceBrowserStore.getState().applyEvent(event)) return
switch (event.type) {
case 'state':
if (event.navigationOutcome === 'pending' && (event.navigationId ?? 0) > previousNavigationId) {
discardNavigatedBrowserSelections(event.tabId)
}
store.updateBrowserTab(owner.sessionId, event.tabId, {
url: event.url || null,
title: event.title || null,
...(event.navigationOutcome === 'succeeded' ? { loadError: null } : {}),
})
break
case 'failed':
store.updateBrowserTab(owner.sessionId, event.tabId, {
loadError: event.errorDescription || String(event.errorCode),
})
break
case 'destroyed':
// A crashed guest still exists and reloads in place. A closed one is
// gone, so its retry has to build a new page.
if (event.reason === 'closed') forgetLostWorkspaceBrowserGuest(event.tabId)
store.updateBrowserTab(owner.sessionId, event.tabId, {
// A reason code is not a message. It reaches the error overlay, so
// it has to be a translated sentence in all five languages.
loadError: t(event.reason === 'crashed'
? 'workspace.browser.pageCrashed'
: 'workspace.browser.pageClosed'),
})
break
case 'new-window':
// A popup or `target=_blank` becomes a sibling tab in the task whose
// page opened it, and never steals focus from a different task.
workspaceOpen.browser(owner.sessionId, event.url, { background: true })
break
case 'screenshot':
// The host does the capture and emits the result; without a consumer
// the "capture screenshot" menu item did the work and threw it away.
useChatStore.getState().queueComposerPrefill(owner.sessionId, {
text: '',
mode: 'append',
attachments: [{
type: 'image',
name: `screenshot-${event.kind}.png`,
mimeType: 'image/png',
data: event.dataUrl,
}],
})
break
case 'agent':
handleBrowserSelectionEvent(owner.sessionId, event.tabId, event.message)
break
default:
break
}
}).then((dispose) => {
if (cancelled) dispose()
else unsubscribe = dispose
})
return () => {
cancelled = true
unsubscribe?.()
}
}, [enabled])
}