import { useEffect, useRef, useState } from 'react'; import { AlertTriangle, Braces, Check, Copy, ExternalLink, EyeOff, Network, Pause, Play, Radio, RefreshCw, Settings, ShieldCheck, X, } from 'lucide-react'; import { browser } from 'wxt/browser'; import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { HANDOFF_REASON_LABELS, waitingHandoff } from '@/features/handoff/presentation'; import { AGENT_RUNTIME_STORAGE_KEY, isStateStorageChange } from '@/protocol/storage'; import type { ActiveTabInfo, AgentRuntime, BridgeStatus, ExtensionState, PageContext } from '@/types/models'; import { errorMessage, request } from '@/platform/messaging/runtime'; import { isFloatingPanelShortcut, mergeFloatingTabUpdate } from './host-controller'; interface FloatingPanelProps { initialState: ExtensionState; initialTab?: ActiveTabInfo; initialBridge: BridgeStatus; hostChannel: string; } export function FloatingPanel({ initialState, initialTab, initialBridge, hostChannel }: FloatingPanelProps) { const [state, setState] = useState(initialState); const [bridge, setBridge] = useState(initialBridge); const [tab, setTab] = useState(initialTab); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(''); const [context, setContext] = useState(); const [runtime, setRuntime] = useState({ state: 'idle', updatedAt: Date.now(), actions: [] }); const bodyRef = useRef(null); const pendingHandoff = waitingHandoff(state.handoff); const handoff = pendingHandoff?.target.tabId === tab?.id ? pendingHandoff : undefined; useEffect(() => { void request('agent.runtime.get').then(setRuntime).catch(() => undefined); const listener = (changes: Record) => { if (isStateStorageChange(changes)) { void request('state.get').then((next) => { setState(next); }).catch(() => undefined); } if (AGENT_RUNTIME_STORAGE_KEY in changes) void request('agent.runtime.get').then(setRuntime).catch(() => undefined); }; browser.storage.onChanged.addListener(listener); return () => browser.storage.onChanged.removeListener(listener); }, []); useEffect(() => { const listener = (message: unknown) => { const input = message as { action?: string; payload?: BridgeStatus }; if (input?.action === 'bridge.status.changed' && input.payload) setBridge(input.payload); }; browser.runtime.onMessage.addListener(listener); return () => browser.runtime.onMessage.removeListener(listener); }, []); useEffect(() => { const listener = (event: MessageEvent) => { const input = event.data as { channel?: string; token?: string; type?: string; tab?: { tabId: number; title?: string; url?: string }; }; if (event.source !== window.parent || input?.channel !== 'yakit-floating-host' || input.token !== hostChannel || input.type !== 'tab.changed' || !input.tab) return; setTab((current) => mergeFloatingTabUpdate(current, input.tab!)); setContext(undefined); }; globalThis.addEventListener('message', listener); return () => globalThis.removeEventListener('message', listener); }, [hostChannel]); useEffect(() => { const listener = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null; const editable = Boolean(target?.isContentEditable || target?.closest('input, textarea, select, [contenteditable="true"]')); const shortcut = isFloatingPanelShortcut(state.floatingPanel, event, editable); if (!shortcut && event.key !== 'Escape') return; event.preventDefault(); window.parent.postMessage({ channel: 'yakit-floating-host', token: hostChannel, type: 'collapse' }, '*'); }; globalThis.addEventListener('keydown', listener); return () => globalThis.removeEventListener('keydown', listener); }, [hostChannel, state.floatingPanel]); // The content-script host owns header, drag, placement and collapse. This // document reports body height only, so those responsibilities never exist twice. useEffect(() => { const post = () => { const height = bodyRef.current?.scrollHeight || 0; window.parent.postMessage({ channel: 'yakit-floating-host', token: hostChannel, type: 'resize', height: Math.ceil(height), }, '*'); }; post(); const observer = new ResizeObserver(post); if (bodyRef.current) observer.observe(bodyRef.current); return () => observer.disconnect(); }, [hostChannel]); const run = async (task: () => Promise) => { setBusy(true); setNotice(''); try { await task(); } catch (error) { setNotice(errorMessage(error)); } finally { setBusy(false); } }; const openWorkspace = (section: string) => { const target = tab ? `?tabId=${tab.id}` : ''; window.open(browser.runtime.getURL(`/options.html${target}#${section}`), '_blank', 'noopener'); }; const hideCurrentSite = () => run(async () => { if (!tab?.url) return; const origin = new URL(tab.url).origin; const current = state.floatingPanel; const siteOrigins = current.siteMode === 'allowlist' ? current.siteOrigins.filter((item) => item !== origin) : [...new Set([...current.siteOrigins, origin])]; setState(await request('panel.update', { siteMode: current.siteMode === 'allowlist' ? 'allowlist' : 'denylist', siteOrigins, })); }); if (!state.floatingPanel.enabled) return null; return (
代理 上下文 {handoff ? : }Agent
快速切换
{state.proxyProfiles.map((profile) => ( ))}
{tab?.title || '当前页面不可访问'}{tab?.url || '仅支持 HTTP(S) 页面'}
{context &&
{context.document?.forms.length || 0} 个表单 · {context.document?.interactive.length || 0} 个交互元素
}
{bridge.state === 'connected' ? 'Yak 引擎在线' : 'Yak 引擎离线'}{bridge.message}
{handoff ?
{HANDOFF_REASON_LABELS[handoff.reason]}{handoff.message}
: <> {bridge.state === 'connected' &&
{runtime.state === 'paused' ? 'Agent 已暂停' : runtime.state === 'running' ? 'Agent 正在操作' : '浏览器实例已接入'}当前浏览器的 HTTP(S) 页面均可引用{runtime.state === 'paused' ? : }
}
实例级页面访问刷新、跳转和新标签页自动跟随,无需逐页授权
}
{notice &&
{notice}
}
); }