返回 DeepSeek-Reasonix
BrowserPanel.tsx
根目录 / desktop / frontend / src / components / BrowserPanel.tsx
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
253 lines Plain Text