| 1 | import { ArrowLeft, ArrowRight, Bug, Compass, Download, Hand, Plus, RotateCw, TriangleAlert, X, ZoomIn, ZoomOut } from "lucide-react"; |
| 2 | import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type RefObject } from "react"; |
| 3 | |
| 4 | import { zoomPercent } from "../lib/browserAddress"; |
| 5 | import type { BrowserDownloadView, BrowserTabView } from "../lib/browserHost"; |
| 6 | import { useBrowserCopy, type BrowserCopy } from "../lib/browserPanelCopy"; |
| 7 | import { selectActiveTab, selectAddress, useBrowserPanelStore } from "../lib/browserPanelStore"; |
| 8 | import { desktopHost } from "../lib/desktopHost"; |
| 9 | import { useI18n } from "../lib/i18n"; |
| 10 | import { useToast } from "../lib/toast"; |
| 11 | import { useBrowserSurfaceLayout } from "../lib/useBrowserSurfaceLayout"; |
| 12 | |
| 13 | const DOWNLOAD_STRIP_LIMIT = 5; |
| 14 | |
| 15 | // The dock tab's label bypasses the shared dictionaries because both locale |
| 16 | // chunks sit on their bundle ratchet (check-bundle-budget.mjs). |
| 17 | const DOCK_TAB_LABEL: Record<string, string> = { zh: "浏览器", "zh-TW": "瀏覽器" }; |
| 18 | |
| 19 | /** Browser entry in the workspace dock's tab bar; mirrors the DockTab markup. */ |
| 20 | export function BrowserDockTab({ active, onSelect }: { active: boolean; onSelect: () => void }) { |
| 21 | const { locale } = useI18n(); |
| 22 | return ( |
| 23 | <button type="button" role="tab" aria-selected={active} className={`workbench-dock__tab${active ? " workbench-dock__tab--active" : ""}`} onClick={onSelect}> |
| 24 | <Compass size={13} /><span className="workbench-dock__tab-label">{DOCK_TAB_LABEL[locale] ?? "Browser"}</span> |
| 25 | </button> |
| 26 | ); |
| 27 | } |
| 28 | |
| 29 | export function BrowserPanel({ taskId }: { taskId: string | undefined }) { |
| 30 | const copy = useBrowserCopy(); |
| 31 | const { showToast } = useToast(); |
| 32 | const host = desktopHost().browser; |
| 33 | const tabs = useBrowserPanelStore((state) => state.shown); |
| 34 | const activeTab = useBrowserPanelStore(selectActiveTab); |
| 35 | const address = useBrowserPanelStore(selectAddress); |
| 36 | const downloads = useBrowserPanelStore((state) => state.downloads); |
| 37 | const addressRef = useRef<HTMLInputElement>(null); |
| 38 | const [surface, setSurface] = useState<HTMLDivElement | null>(null); |
| 39 | const notifyRef = useRef((message: string) => showToast(copy.actionFailed(message), "error")); |
| 40 | notifyRef.current = (message) => showToast(copy.actionFailed(message), "error"); |
| 41 | |
| 42 | useEffect(() => { |
| 43 | if (!host) return; |
| 44 | return useBrowserPanelStore.getState().attach(host, (message) => notifyRef.current(message)); |
| 45 | }, [host]); |
| 46 | useEffect(() => useBrowserPanelStore.getState().setTaskId(taskId), [taskId]); |
| 47 | useEffect(() => { |
| 48 | useBrowserPanelStore.getState().setVisible(true); |
| 49 | return () => useBrowserPanelStore.getState().setVisible(false); |
| 50 | }, []); |
| 51 | useBrowserSurfaceLayout(host, surface); |
| 52 | |
| 53 | const focusAddress = useCallback(() => { |
| 54 | addressRef.current?.focus(); |
| 55 | addressRef.current?.select(); |
| 56 | }, []); |
| 57 | const onPanelKeyDown = (event: KeyboardEvent<HTMLElement>) => { |
| 58 | if ((event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === "l") { |
| 59 | event.preventDefault(); |
| 60 | focusAddress(); |
| 61 | } |
| 62 | }; |
| 63 | const store = useBrowserPanelStore.getState; |
| 64 | const openFromDraft = async () => { |
| 65 | if (!(await store().openDraft())) focusAddress(); |
| 66 | }; |
| 67 | const addressBar = <AddressBar copy={copy} value={address} inputRef={addressRef} tabId={activeTab?.id ?? null} />; |
| 68 | |
| 69 | return ( |
| 70 | <section className="browser-panel" aria-label={copy.panel} onKeyDown={onPanelKeyDown}> |
| 71 | {tabs.length > 0 && ( |
| 72 | <div className="browser-panel__tabs" role="tablist" aria-label={copy.tabs}> |
| 73 | {tabs.map((tab) => ( |
| 74 | <BrowserTab key={tab.id} tab={tab} active={tab.id === activeTab?.id} copy={copy} /> |
| 75 | ))} |
| 76 | <button type="button" className="browser-panel__icon-btn browser-panel__new-tab" aria-label={copy.newTab} onClick={() => void openFromDraft()}> |
| 77 | <Plus size={14} /> |
| 78 | </button> |
| 79 | </div> |
| 80 | )} |
| 81 | {tabs.length > 0 && ( |
| 82 | <div className="browser-panel__toolbar"> |
| 83 | <button type="button" className="browser-panel__icon-btn" aria-label={copy.back} disabled={!activeTab?.canGoBack} |
| 84 | onClick={() => activeTab && void store().navigate(activeTab.id, { action: "back" })}><ArrowLeft size={14} /></button> |
| 85 | <button type="button" className="browser-panel__icon-btn" aria-label={copy.forward} disabled={!activeTab?.canGoForward} |
| 86 | onClick={() => activeTab && void store().navigate(activeTab.id, { action: "forward" })}><ArrowRight size={14} /></button> |
| 87 | {activeTab?.loading |
| 88 | ? <button type="button" className="browser-panel__icon-btn" aria-label={copy.stop} onClick={() => void store().navigate(activeTab.id, { action: "stop" })}><X size={14} /></button> |
| 89 | : <button type="button" className="browser-panel__icon-btn" aria-label={copy.reload} disabled={!activeTab} |
| 90 | onClick={() => activeTab && void store().navigate(activeTab.id, { action: "reload" })}><RotateCw size={14} /></button>} |
| 91 | {addressBar} |
| 92 | {activeTab?.mode === "agent" && ( |
| 93 | <button type="button" className="btn btn--small" onClick={() => void store().takeover(activeTab.id)}>{copy.takeControl}</button> |
| 94 | )} |
| 95 | <button type="button" className="browser-panel__icon-btn" aria-label={copy.zoomOut} disabled={!activeTab} |
| 96 | onClick={() => activeTab && void store().zoom(activeTab.id, -1)}><ZoomOut size={14} /></button> |
| 97 | <button type="button" className="browser-panel__zoom" aria-label={copy.zoomReset(zoomPercent(activeTab?.zoom ?? 1))} disabled={!activeTab} |
| 98 | onClick={() => activeTab && void store().zoom(activeTab.id, 0)}>{zoomPercent(activeTab?.zoom ?? 1)}%</button> |
| 99 | <button type="button" className="browser-panel__icon-btn" aria-label={copy.zoomIn} disabled={!activeTab} |
| 100 | onClick={() => activeTab && void store().zoom(activeTab.id, 1)}><ZoomIn size={14} /></button> |
| 101 | <button type="button" className="browser-panel__icon-btn" aria-label={copy.devTools} disabled={!activeTab} |
| 102 | onClick={() => activeTab && void store().toggleDevTools(activeTab.id)}><Bug size={14} /></button> |
| 103 | </div> |
| 104 | )} |
| 105 | {activeTab?.mode === "human" && ( |
| 106 | <div className="browser-panel__takeover" role="status"> |
| 107 | <Hand size={14} aria-hidden="true" /> |
| 108 | <span>{copy.takeover}</span> |
| 109 | <button type="button" className="btn btn--small" onClick={() => void store().resume(activeTab.id)}>{copy.resume}</button> |
| 110 | </div> |
| 111 | )} |
| 112 | <div className="browser-panel__content"> |
| 113 | {tabs.length === 0 ? ( |
| 114 | <div className="browser-panel__empty"> |
| 115 | <Compass size={28} aria-hidden="true" /> |
| 116 | <p className="browser-panel__empty-title">{copy.emptyTitle}</p> |
| 117 | <p className="browser-panel__empty-hint">{copy.emptyHint}</p> |
| 118 | {addressBar} |
| 119 | </div> |
| 120 | ) : activeTab?.error ? ( |
| 121 | <div className="browser-panel__error" role="alert"> |
| 122 | <TriangleAlert size={22} aria-hidden="true" /> |
| 123 | <p className="browser-panel__error-title">{copy.errorTitle}</p> |
| 124 | <p className="browser-panel__error-detail">{copy.errorDetail(activeTab.error.code, activeTab.error.description)}</p> |
| 125 | <code className="browser-panel__error-url">{activeTab.url}</code> |
| 126 | <button type="button" className="btn btn--small" onClick={() => void store().navigate(activeTab.id, { action: "reload" })}>{copy.retry}</button> |
| 127 | </div> |
| 128 | ) : ( |
| 129 | <div ref={setSurface} data-browser-surface="" className="browser-panel__surface" /> |
| 130 | )} |
| 131 | </div> |
| 132 | {downloads.length > 0 && <DownloadStrip downloads={downloads} copy={copy} />} |
| 133 | </section> |
| 134 | ); |
| 135 | } |
| 136 | |
| 137 | function AddressBar({ copy, value, inputRef, tabId }: { copy: BrowserCopy; value: string; inputRef: RefObject<HTMLInputElement | null>; tabId: string | null }) { |
| 138 | const store = useBrowserPanelStore.getState; |
| 139 | return ( |
| 140 | <input |
| 141 | ref={inputRef} |
| 142 | className="browser-panel__address" |
| 143 | type="text" |
| 144 | aria-label={copy.address} |
| 145 | placeholder={copy.addressPlaceholder} |
| 146 | spellCheck={false} |
| 147 | autoComplete="off" |
| 148 | value={value} |
| 149 | onChange={(event) => store().setDraft(tabId, event.target.value)} |
| 150 | onFocus={(event) => event.currentTarget.select()} |
| 151 | onKeyDown={(event) => { |
| 152 | if (event.key === "Enter") { |
| 153 | event.preventDefault(); |
| 154 | void store().submitAddress(); |
| 155 | } else if (event.key === "Escape") { |
| 156 | store().clearDraft(tabId); |
| 157 | event.currentTarget.blur(); |
| 158 | } |
| 159 | }} |
| 160 | /> |
| 161 | ); |
| 162 | } |
| 163 | |
| 164 | function BrowserTab({ tab, active, copy }: { tab: BrowserTabView; active: boolean; copy: BrowserCopy }) { |
| 165 | const title = tab.title || tab.url || copy.untitled; |
| 166 | const store = useBrowserPanelStore.getState; |
| 167 | return ( |
| 168 | <div className={`browser-tab${active ? " browser-tab--active" : ""}`}> |
| 169 | <button type="button" role="tab" aria-selected={active} className="browser-tab__select" title={tab.url} onClick={() => store().activate(tab.id)}> |
| 170 | {tab.loading && <span className="browser-tab__spinner" role="img" aria-label={copy.loading} />} |
| 171 | <span className="browser-tab__title">{title}</span> |
| 172 | {tab.temporary && <span className="browser-tab__badge">{copy.temporary}</span>} |
| 173 | </button> |
| 174 | <button type="button" className="browser-tab__close" aria-label={copy.closeTab(title)} onClick={() => void store().close(tab.id)}> |
| 175 | <X size={12} /> |
| 176 | </button> |
| 177 | </div> |
| 178 | ); |
| 179 | } |
| 180 | |
| 181 | function downloadProgress(download: BrowserDownloadView, copy: BrowserCopy): string { |
| 182 | if (download.state !== "progressing") return copy.downloadState[download.state]; |
| 183 | if (download.total > 0) return `${Math.min(100, Math.round((download.received / download.total) * 100))}%`; |
| 184 | return copy.downloadState.progressing; |
| 185 | } |
| 186 | |
| 187 | function DownloadStrip({ downloads, copy }: { downloads: BrowserDownloadView[]; copy: BrowserCopy }) { |
| 188 | return ( |
| 189 | <div className="browser-panel__downloads" aria-label={copy.downloads}> |
| 190 | <div className="browser-panel__downloads-head"> |
| 191 | <Download size={12} aria-hidden="true" /> |
| 192 | <span>{copy.downloads}</span> |
| 193 | <button type="button" className="browser-panel__icon-btn" aria-label={copy.clearDownloads} onClick={() => useBrowserPanelStore.getState().clearDownloads()}> |
| 194 | <X size={12} /> |
| 195 | </button> |
| 196 | </div> |
| 197 | <ul className="browser-panel__download-list"> |
| 198 | {downloads.slice(0, DOWNLOAD_STRIP_LIMIT).map((download) => ( |
| 199 | <li key={download.id} className={`browser-download browser-download--${download.state}`} title={download.path}> |
| 200 | <span className="browser-download__name">{download.filename}</span> |
| 201 | <span className="browser-download__state">{downloadProgress(download, copy)}</span> |
| 202 | {download.state === "progressing" && ( |
| 203 | <progress className="browser-download__bar" max={download.total > 0 ? download.total : undefined} value={download.total > 0 ? download.received : undefined} aria-label={download.filename} /> |
| 204 | )} |
| 205 | </li> |
| 206 | ))} |
| 207 | </ul> |
| 208 | </div> |
| 209 | ); |
| 210 | } |
| 211 |