| 1 | import { ErrorMessage } from "./ErrorMessage"; |
| 2 | import { ArrowLeft, ArrowRight, Bug, Code2, Compass, Download, ExternalLink, Hand, Plus, RotateCw, TriangleAlert, X, ZoomIn, ZoomOut } from "lucide-react"; |
| 3 | import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type RefObject } from "react"; |
| 4 | |
| 5 | import { zoomPercent } from "../lib/browserAddress"; |
| 6 | import type { BrowserDownloadView, BrowserTabView } from "../lib/browserHost"; |
| 7 | import { useBrowserCopy, type BrowserCopy } from "../lib/browserPanelCopy"; |
| 8 | import { selectActiveTab, selectAddress, useBrowserPanelStore } from "../lib/browserPanelStore"; |
| 9 | import { desktopHost } from "../lib/desktopHost"; |
| 10 | import { useI18n, useT } from "../lib/i18n"; |
| 11 | import { useToast } from "../lib/toast"; |
| 12 | import { useBrowserSurfaceLayout } from "../lib/useBrowserSurfaceLayout"; |
| 13 | import { openResource, performResourceAction, refreshBrowserResource } from "../lib/fileNavigationCommands"; |
| 14 | import { useFileBrowserPreview } from "../lib/fileBrowserPreviewBindings"; |
| 15 | import { BrowserResponsiveControls } from "./BrowserResponsiveControls"; |
| 16 | import { BrowserEvidenceControls } from "./BrowserEvidenceControls"; |
| 17 | |
| 18 | const DOWNLOAD_STRIP_LIMIT = 5; |
| 19 | |
| 20 | // The dock tab's label bypasses the shared dictionaries because both locale |
| 21 | // chunks sit on their bundle ratchet (check-bundle-budget.mjs). |
| 22 | const DOCK_TAB_LABEL: Record<string, string> = { zh: "浏览器", "zh-TW": "瀏覽器" }; |
| 23 | |
| 24 | /** Browser entry in the workspace dock's tab bar; mirrors the DockTab markup. */ |
| 25 | export function BrowserDockTab({ active, onSelect }: { active: boolean; onSelect: () => void }) { |
| 26 | const { locale } = useI18n(); |
| 27 | return ( |
| 28 | <button type="button" role="tab" aria-selected={active} className={`workbench-dock__tab${active ? " workbench-dock__tab--active" : ""}`} onClick={onSelect}> |
| 29 | <Compass size={13} /><span className="workbench-dock__tab-label">{DOCK_TAB_LABEL[locale] ?? "Browser"}</span> |
| 30 | </button> |
| 31 | ); |
| 32 | } |
| 33 | |
| 34 | export function BrowserPanel({ taskId }: { taskId: string | undefined }) { |
| 35 | const copy = useBrowserCopy(); |
| 36 | const chinese = useI18n().locale !== "en"; |
| 37 | const operationLabels: Record<string, string> = chinese |
| 38 | ? { queued: "等待浏览器", preparing: "准备页面", reading: "AI 正在读取", interacting: "AI 正在操作", capturing: "AI 正在截图", picking: "请选择页面元素,Esc 退出", completed: "浏览器操作完成" } |
| 39 | : { queued: "Waiting for browser", preparing: "Preparing page", reading: "AI is reading", interacting: "AI is interacting", capturing: "AI is capturing", picking: "Select an element; Esc to exit", completed: "Browser operation completed" }; |
| 40 | const t = useT(); |
| 41 | const { showToast } = useToast(); |
| 42 | const host = desktopHost().browser; |
| 43 | const tabs = useBrowserPanelStore((state) => state.shown); |
| 44 | const activeTab = useBrowserPanelStore(selectActiveTab); |
| 45 | const address = useBrowserPanelStore(selectAddress); |
| 46 | const downloads = useBrowserPanelStore((state) => state.downloads); |
| 47 | const activeFilePreview = useFileBrowserPreview(activeTab); |
| 48 | const addressRef = useRef<HTMLInputElement>(null); |
| 49 | const [surface, setSurface] = useState<HTMLDivElement | null>(null); |
| 50 | const notifyRef = useRef((message: string) => showToast(copy.actionFailed(message), "error")); |
| 51 | notifyRef.current = (message) => showToast(copy.actionFailed(message), "error"); |
| 52 | |
| 53 | useEffect(() => { |
| 54 | if (!host) return; |
| 55 | return useBrowserPanelStore.getState().attach(host, (message) => notifyRef.current(message)); |
| 56 | }, [host]); |
| 57 | useEffect(() => useBrowserPanelStore.getState().setTaskId(taskId), [taskId]); |
| 58 | useEffect(() => { |
| 59 | useBrowserPanelStore.getState().setVisible(true); |
| 60 | return () => useBrowserPanelStore.getState().setVisible(false); |
| 61 | }, []); |
| 62 | useBrowserSurfaceLayout(host, surface); |
| 63 | |
| 64 | const focusAddress = useCallback(() => { |
| 65 | addressRef.current?.focus(); |
| 66 | addressRef.current?.select(); |
| 67 | }, []); |
| 68 | const onPanelKeyDown = (event: KeyboardEvent<HTMLElement>) => { |
| 69 | if ((event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === "l") { |
| 70 | event.preventDefault(); |
| 71 | focusAddress(); |
| 72 | } |
| 73 | }; |
| 74 | const store = useBrowserPanelStore.getState; |
| 75 | const openFromDraft = async () => { |
| 76 | if (!(await store().openDraft())) focusAddress(); |
| 77 | }; |
| 78 | const reloadActive = async () => { |
| 79 | if (!activeTab) return; |
| 80 | if (activeFilePreview) { |
| 81 | const outcome = await refreshBrowserResource(activeFilePreview.ref); |
| 82 | if (outcome.status === "failed") showToast(copy.actionFailed(outcome.error.message), "error"); |
| 83 | return; |
| 84 | } |
| 85 | await store().navigate(activeTab.id, { action: "reload" }); |
| 86 | }; |
| 87 | const runFileAction = async (action: "source" | "open-native") => { |
| 88 | if (!activeFilePreview) return; |
| 89 | const outcome = action === "source" |
| 90 | ? await openResource(activeFilePreview.ref, { view: "source" }) |
| 91 | : await performResourceAction(activeFilePreview.ref, action); |
| 92 | if (outcome.status === "failed") showToast(copy.actionFailed(outcome.error.message), "error"); |
| 93 | }; |
| 94 | const addressBar = <AddressBar copy={copy} value={address} inputRef={addressRef} tabId={activeTab?.id ?? null} />; |
| 95 | |
| 96 | return ( |
| 97 | <section className="browser-panel" aria-label={copy.panel} onKeyDown={onPanelKeyDown}> |
| 98 | {tabs.length > 0 && ( |
| 99 | <div className="browser-panel__tabs" role="tablist" aria-label={copy.tabs}> |
| 100 | {tabs.map((tab) => ( |
| 101 | <BrowserTab key={tab.id} tab={tab} active={tab.id === activeTab?.id} copy={copy} /> |
| 102 | ))} |
| 103 | <button type="button" className="browser-panel__icon-btn browser-panel__new-tab" aria-label={copy.newTab} onClick={() => void openFromDraft()}> |
| 104 | <Plus size={14} /> |
| 105 | </button> |
| 106 | </div> |
| 107 | )} |
| 108 | {tabs.length > 0 && ( |
| 109 | <div className="browser-panel__toolbar"> |
| 110 | <button type="button" className="browser-panel__icon-btn" aria-label={copy.back} disabled={!activeTab?.canGoBack} |
| 111 | onClick={() => activeTab && void store().navigate(activeTab.id, { action: "back" })}><ArrowLeft size={14} /></button> |
| 112 | <button type="button" className="browser-panel__icon-btn" aria-label={copy.forward} disabled={!activeTab?.canGoForward} |
| 113 | onClick={() => activeTab && void store().navigate(activeTab.id, { action: "forward" })}><ArrowRight size={14} /></button> |
| 114 | {activeTab?.loading |
| 115 | ? <button type="button" className="browser-panel__icon-btn" aria-label={copy.stop} onClick={() => void store().navigate(activeTab.id, { action: "stop" })}><X size={14} /></button> |
| 116 | : <button type="button" className="browser-panel__icon-btn" aria-label={copy.reload} disabled={!activeTab} |
| 117 | onClick={() => void reloadActive()}><RotateCw size={14} /></button>} |
| 118 | {addressBar} |
| 119 | {activeTab?.mode === "agent" && ( |
| 120 | <button type="button" className="btn btn--small" onClick={() => void store().takeover(activeTab.id)}>{copy.takeControl}</button> |
| 121 | )} |
| 122 | {activeFilePreview && <> |
| 123 | <button type="button" className="browser-panel__icon-btn" aria-label={t("present.source")} |
| 124 | onClick={() => void runFileAction("source")}><Code2 size={14} /></button> |
| 125 | <button type="button" className="browser-panel__icon-btn" aria-label={t("present.openNative")} |
| 126 | onClick={() => void runFileAction("open-native")}><ExternalLink size={14} /></button> |
| 127 | </>} |
| 128 | <button type="button" className="browser-panel__icon-btn" aria-label={copy.zoomOut} disabled={!activeTab || Boolean(activeTab.viewport)} |
| 129 | onClick={() => activeTab && void store().zoom(activeTab.id, -1)}><ZoomOut size={14} /></button> |
| 130 | <button type="button" className="browser-panel__zoom" aria-label={copy.zoomReset(zoomPercent(activeTab?.zoom ?? 1))} disabled={!activeTab || Boolean(activeTab.viewport)} |
| 131 | onClick={() => activeTab && void store().zoom(activeTab.id, 0)}>{zoomPercent(activeTab?.zoom ?? 1)}%</button> |
| 132 | <button type="button" className="browser-panel__icon-btn" aria-label={copy.zoomIn} disabled={!activeTab || Boolean(activeTab.viewport)} |
| 133 | onClick={() => activeTab && void store().zoom(activeTab.id, 1)}><ZoomIn size={14} /></button> |
| 134 | <button type="button" className="browser-panel__icon-btn" aria-label={copy.devTools} disabled={!activeTab} |
| 135 | onClick={() => activeTab && void store().toggleDevTools(activeTab.id)}><Bug size={14} /></button> |
| 136 | </div> |
| 137 | )} |
| 138 | {activeTab && <BrowserResponsiveControls tab={activeTab} />} |
| 139 | {activeTab && <BrowserEvidenceControls key={activeTab.id} tabId={activeTab.id} />} |
| 140 | {activeTab?.operation && <div className="browser-panel__takeover" role="status"> |
| 141 | <span>{activeTab.operation.phase === "failed" ? activeTab.operation.message : operationLabels[activeTab.operation.phase] ?? activeTab.operation.phase}</span> |
| 142 | {activeTab.operation.phase === "failed" && <> |
| 143 | <button type="button" className="btn btn--small" onClick={() => void host?.activate(activeTab.id)}>{chinese ? "显示页面" : "Show page"}</button> |
| 144 | <button type="button" className="btn btn--small" title={activeTab.operation.id} onClick={() => { void navigator.clipboard.writeText(activeTab.operation!.id); }}>{chinese ? "复制诊断编号" : "Copy diagnostic ID"}</button> |
| 145 | </>} |
| 146 | </div>} |
| 147 | {activeTab?.mode === "human" && ( |
| 148 | <div className="browser-panel__takeover" role="status"> |
| 149 | <Hand size={14} aria-hidden="true" /> |
| 150 | <span>{copy.takeover}</span> |
| 151 | <button type="button" className="btn btn--small" disabled={activeTab.operation?.phase === "picking"} onClick={() => void store().resume(activeTab.id)}>{copy.resume}</button> |
| 152 | </div> |
| 153 | )} |
| 154 | <div className="browser-panel__content"> |
| 155 | {tabs.length === 0 ? ( |
| 156 | <div className="browser-panel__empty"> |
| 157 | <Compass size={28} aria-hidden="true" /> |
| 158 | <p className="browser-panel__empty-title">{copy.emptyTitle}</p> |
| 159 | <p className="browser-panel__empty-hint">{copy.emptyHint}</p> |
| 160 | {addressBar} |
| 161 | </div> |
| 162 | ) : activeTab?.error ? ( |
| 163 | <div className="browser-panel__error" role="alert"> |
| 164 | <TriangleAlert size={22} aria-hidden="true" /> |
| 165 | <p className="browser-panel__error-title">{copy.errorTitle}</p> |
| 166 | <p className="browser-panel__error-detail"><ErrorMessage error={copy.errorDetail(activeTab.error.code, activeTab.error.description)} /></p> |
| 167 | <code className="browser-panel__error-url">{activeTab.url}</code> |
| 168 | <button type="button" className="btn btn--small" onClick={() => void store().navigate(activeTab.id, { action: "reload" })}>{copy.retry}</button> |
| 169 | </div> |
| 170 | ) : ( |
| 171 | <div ref={setSurface} data-browser-surface="" className="browser-panel__surface" /> |
| 172 | )} |
| 173 | </div> |
| 174 | {downloads.length > 0 && <DownloadStrip downloads={downloads} copy={copy} />} |
| 175 | </section> |
| 176 | ); |
| 177 | } |
| 178 | |
| 179 | function AddressBar({ copy, value, inputRef, tabId }: { copy: BrowserCopy; value: string; inputRef: RefObject<HTMLInputElement | null>; tabId: string | null }) { |
| 180 | const store = useBrowserPanelStore.getState; |
| 181 | return ( |
| 182 | <input |
| 183 | ref={inputRef} |
| 184 | className="browser-panel__address" |
| 185 | type="text" |
| 186 | aria-label={copy.address} |
| 187 | placeholder={copy.addressPlaceholder} |
| 188 | spellCheck={false} |
| 189 | autoComplete="off" |
| 190 | value={value} |
| 191 | onChange={(event) => store().setDraft(tabId, event.target.value)} |
| 192 | onFocus={(event) => event.currentTarget.select()} |
| 193 | onKeyDown={(event) => { |
| 194 | if (event.key === "Enter") { |
| 195 | event.preventDefault(); |
| 196 | void store().submitAddress(); |
| 197 | } else if (event.key === "Escape") { |
| 198 | store().clearDraft(tabId); |
| 199 | event.currentTarget.blur(); |
| 200 | } |
| 201 | }} |
| 202 | /> |
| 203 | ); |
| 204 | } |
| 205 | |
| 206 | function BrowserTab({ tab, active, copy }: { tab: BrowserTabView; active: boolean; copy: BrowserCopy }) { |
| 207 | const title = tab.title || tab.url || copy.untitled; |
| 208 | const store = useBrowserPanelStore.getState; |
| 209 | return ( |
| 210 | <div className={`browser-tab${active ? " browser-tab--active" : ""}`}> |
| 211 | <button type="button" role="tab" aria-selected={active} className="browser-tab__select" title={tab.url} onClick={() => store().activate(tab.id)}> |
| 212 | {tab.loading && <span className="browser-tab__spinner" role="img" aria-label={copy.loading} />} |
| 213 | <span className="browser-tab__title">{title}</span> |
| 214 | {tab.temporary && <span className="browser-tab__badge">{copy.temporary}</span>} |
| 215 | </button> |
| 216 | <button type="button" className="browser-tab__close" aria-label={copy.closeTab(title)} onClick={() => void store().close(tab.id)}> |
| 217 | <X size={12} /> |
| 218 | </button> |
| 219 | </div> |
| 220 | ); |
| 221 | } |
| 222 | |
| 223 | function downloadProgress(download: BrowserDownloadView, copy: BrowserCopy): string { |
| 224 | if (download.state !== "progressing") return copy.downloadState[download.state]; |
| 225 | if (download.total > 0) return `${Math.min(100, Math.round((download.received / download.total) * 100))}%`; |
| 226 | return copy.downloadState.progressing; |
| 227 | } |
| 228 | |
| 229 | function DownloadStrip({ downloads, copy }: { downloads: BrowserDownloadView[]; copy: BrowserCopy }) { |
| 230 | return ( |
| 231 | <div className="browser-panel__downloads" aria-label={copy.downloads}> |
| 232 | <div className="browser-panel__downloads-head"> |
| 233 | <Download size={12} aria-hidden="true" /> |
| 234 | <span>{copy.downloads}</span> |
| 235 | <button type="button" className="browser-panel__icon-btn" aria-label={copy.clearDownloads} onClick={() => useBrowserPanelStore.getState().clearDownloads()}> |
| 236 | <X size={12} /> |
| 237 | </button> |
| 238 | </div> |
| 239 | <ul className="browser-panel__download-list"> |
| 240 | {downloads.slice(0, DOWNLOAD_STRIP_LIMIT).map((download) => ( |
| 241 | <li key={download.id} className={`browser-download browser-download--${download.state}`} title={download.path}> |
| 242 | <span className="browser-download__name">{download.filename}</span> |
| 243 | <span className="browser-download__state">{downloadProgress(download, copy)}</span> |
| 244 | {download.state === "progressing" && ( |
| 245 | <progress className="browser-download__bar" max={download.total > 0 ? download.total : undefined} value={download.total > 0 ? download.received : undefined} aria-label={download.filename} /> |
| 246 | )} |
| 247 | </li> |
| 248 | ))} |
| 249 | </ul> |
| 250 | </div> |
| 251 | ); |
| 252 | } |
| 253 |