From 6b1036ff2c09c47d1d0f0877dcd9ce5a2bd500ee Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 10 Jul 2026 17:17:21 +0100 Subject: [PATCH 1/3] feat/design new preview panel for session page --- electron/main/index.ts | 8 + electron/main/webview.ts | 152 +++++- electron/preload/index.ts | 30 + src/components/Folder/FilePreview.tsx | 6 +- src/components/Folder/index.tsx | 99 ++-- src/components/Session/HeaderBox/index.tsx | 38 +- src/components/Session/PreviewPanel/index.tsx | 512 ++++++++++++++++++ src/components/Session/index.tsx | 165 +++--- src/lib/browserUrl.ts | 81 +++ src/store/pageTabStore.ts | 246 ++++++++- src/types/electron.d.ts | 32 ++ .../components/Session/PreviewPanel.test.tsx | 186 +++++++ test/unit/electron/main/webview.test.ts | 183 +++++++ test/unit/lib/browserUrl.test.ts | 48 ++ test/unit/store/pageTabStore.preview.test.ts | 121 +++++ 15 files changed, 1754 insertions(+), 153 deletions(-) create mode 100644 src/components/Session/PreviewPanel/index.tsx create mode 100644 src/lib/browserUrl.ts create mode 100644 test/unit/components/Session/PreviewPanel.test.tsx create mode 100644 test/unit/electron/main/webview.test.ts create mode 100644 test/unit/lib/browserUrl.test.ts create mode 100644 test/unit/store/pageTabStore.preview.test.ts diff --git a/electron/main/index.ts b/electron/main/index.ts index 2bd8bf51a..b80eaacfc 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -2009,6 +2009,14 @@ function registerIpcHandlers() { { name: 'set-size', method: 'setSize' }, { name: 'get-show-webview', method: 'getShowWebview' }, { name: 'webview-destroy', method: 'destroyWebview' }, + { name: 'navigate-webview', method: 'navigateWebview' }, + { name: 'go-back-webview', method: 'goBackWebview' }, + { name: 'go-forward-webview', method: 'goForwardWebview' }, + { name: 'reload-webview', method: 'reloadWebview' }, + { + name: 'get-preview-webview-navigation-state', + method: 'getPreviewWebviewNavigationState', + }, ]; webviewHandlers.forEach(({ name, method }) => { diff --git a/electron/main/webview.ts b/electron/main/webview.ts index 1575cf0bb..453330323 100644 --- a/electron/main/webview.ts +++ b/electron/main/webview.ts @@ -30,6 +30,28 @@ interface Size { height: number; } +export interface PreviewWebviewNavigationState { + url: string; + title: string; + isLoading: boolean; + canGoBack: boolean; + canGoForward: boolean; +} + +const PREVIEW_WEBVIEW_PREFIX = 'session-preview:'; + +export const isPreviewWebviewId = (id: string) => + id.startsWith(PREVIEW_WEBVIEW_PREFIX); + +function isSupportedWebUrl(input: string): boolean { + try { + const parsed = new URL(input); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + export class WebViewManager { private webViews = new Map(); private win: BrowserWindow | null = null; @@ -55,10 +77,35 @@ export class WebViewManager { this.win = window; } + private getPreviewNavigationState( + webViewInfo: WebViewInfo + ): PreviewWebviewNavigationState { + const contents = webViewInfo.view.webContents; + return { + url: contents.getURL(), + title: contents.getTitle(), + isLoading: contents.isLoading(), + canGoBack: contents.navigationHistory.canGoBack(), + canGoForward: contents.navigationHistory.canGoForward(), + }; + } + + private emitPreviewNavigationState(webViewInfo: WebViewInfo) { + if (!isPreviewWebviewId(webViewInfo.id)) return; + if (!this.win || this.win.isDestroyed()) return; + if (webViewInfo.view.webContents.isDestroyed()) return; + this.win.webContents.send( + 'preview-webview-state-changed', + webViewInfo.id, + this.getPreviewNavigationState(webViewInfo) + ); + } + // Remove automatic IPC handler registration from constructor // IPC handlers should be registered once in the main process public async captureWebview(webviewId: string) { + if (isPreviewWebviewId(webviewId)) return null; const webViewInfo = this.webViews.get(webviewId); if (!webViewInfo) return null; @@ -119,7 +166,7 @@ export class WebViewManager { public getActiveWebview() { const activeWebviews = Array.from(this.webViews.values()).filter( - (webview) => webview.isActive + (webview) => webview.isActive && !isPreviewWebviewId(webview.id) ); return activeWebviews.map((webview) => webview.id); @@ -277,7 +324,20 @@ export class WebViewManager { // win?.webContents.send("url-updated", url); // }); + if (isPreviewWebviewId(id)) { + const notifyPreviewState = () => + this.emitPreviewNavigationState(webViewInfo); + view.webContents.on('did-start-loading', notifyPreviewState); + view.webContents.on('did-stop-loading', notifyPreviewState); + view.webContents.on('page-title-updated', notifyPreviewState); + } + view.webContents.on('did-navigate-in-page', (event, url) => { + if (isPreviewWebviewId(id)) { + webViewInfo.currentUrl = url; + this.emitPreviewNavigationState(webViewInfo); + return; + } if ( webViewInfo.isActive && webViewInfo.isShow && @@ -296,6 +356,10 @@ export class WebViewManager { webViewInfo.isActive = true; } console.log(`Webview ${id} navigated to: ${navigationUrl}`); + if (isPreviewWebviewId(id)) { + this.emitPreviewNavigationState(webViewInfo); + return; + } if ( webViewInfo.isActive && webViewInfo.isShow && @@ -308,7 +372,9 @@ export class WebViewManager { } webViewInfo.view.setBounds(this.getHiddenBounds(id, 1920, 1080)); const activeSize = this.getActiveWebview().length; - const allSize = Array.from(this.webViews.values()).length; + const allSize = Array.from(this.webViews.values()).filter( + (webview) => !isPreviewWebviewId(webview.id) + ).length; const inactiveSize = allSize - activeSize; // Clean up inactive webviews if too many @@ -346,6 +412,9 @@ export class WebViewManager { }); view.webContents.setWindowOpenHandler(({ url }) => { + if (isPreviewWebviewId(id) && !isSupportedWebUrl(url)) { + return { action: 'deny' }; + } view.webContents.loadURL(url); return { action: 'deny' }; @@ -361,6 +430,76 @@ export class WebViewManager { } } + public async navigateWebview(id: string, url: string) { + if (!isPreviewWebviewId(id)) { + return { success: false, error: 'Navigation is limited to preview tabs' }; + } + if (!isSupportedWebUrl(url)) { + return { + success: false, + error: 'Only HTTP and HTTPS URLs are supported', + }; + } + const webViewInfo = this.webViews.get(id); + if (!webViewInfo) { + return { success: false, error: `Webview with id ${id} not found` }; + } + webViewInfo.isActive = true; + await webViewInfo.view.webContents.loadURL(url); + webViewInfo.currentUrl = url; + this.emitPreviewNavigationState(webViewInfo); + return { success: true }; + } + + public goBackWebview(id: string) { + const webViewInfo = this.webViews.get(id); + if (!webViewInfo || !isPreviewWebviewId(id)) { + return { + success: false, + error: `Preview webview with id ${id} not found`, + }; + } + if (webViewInfo.view.webContents.navigationHistory.canGoBack()) { + webViewInfo.view.webContents.navigationHistory.goBack(); + } + this.emitPreviewNavigationState(webViewInfo); + return { success: true }; + } + + public goForwardWebview(id: string) { + const webViewInfo = this.webViews.get(id); + if (!webViewInfo || !isPreviewWebviewId(id)) { + return { + success: false, + error: `Preview webview with id ${id} not found`, + }; + } + if (webViewInfo.view.webContents.navigationHistory.canGoForward()) { + webViewInfo.view.webContents.navigationHistory.goForward(); + } + this.emitPreviewNavigationState(webViewInfo); + return { success: true }; + } + + public reloadWebview(id: string) { + const webViewInfo = this.webViews.get(id); + if (!webViewInfo || !isPreviewWebviewId(id)) { + return { + success: false, + error: `Preview webview with id ${id} not found`, + }; + } + webViewInfo.view.webContents.reload(); + this.emitPreviewNavigationState(webViewInfo); + return { success: true }; + } + + public getPreviewWebviewNavigationState(id: string) { + const webViewInfo = this.webViews.get(id); + if (!webViewInfo || !isPreviewWebviewId(id)) return null; + return this.getPreviewNavigationState(webViewInfo); + } + public changeViewSize(id: string, size: Size) { try { const webViewInfo = this.webViews.get(id); @@ -429,7 +568,9 @@ export class WebViewManager { } const currentUrl = webViewInfo.view.webContents.getURL(); - this.win?.webContents.send('url-updated', currentUrl); + if (!isPreviewWebviewId(id)) { + this.win?.webContents.send('url-updated', currentUrl); + } webViewInfo.isShow = true; this.changeViewSize(id, this.size); console.log('showWebview', id, this.size); @@ -441,10 +582,12 @@ export class WebViewManager { webViewInfo.view.webContents.setBackgroundThrottling(false); } - if (this.win && !this.win.isDestroyed()) { + if (!isPreviewWebviewId(id) && this.win && !this.win.isDestroyed()) { this.win.webContents.send('webview-show', id); } + this.emitPreviewNavigationState(webViewInfo); + return { success: true }; } public getShowWebview() { @@ -504,6 +647,7 @@ export class WebViewManager { const inactiveWebviews = Array.from(this.webViews.entries()) .filter( ([_id, info]) => + !isPreviewWebviewId(info.id) && !info.isActive && !info.isShow && info.currentUrl === 'about:blank?use=0' diff --git a/electron/preload/index.ts b/electron/preload/index.ts index a68179297..eab826b27 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -75,6 +75,36 @@ contextBridge.exposeInMainWorld('electronAPI', { getShowWebview: () => ipcRenderer.invoke('get-show-webview'), webviewDestroy: (webviewId: string) => ipcRenderer.invoke('webview-destroy', webviewId), + navigateWebview: (webviewId: string, url: string) => + ipcRenderer.invoke('navigate-webview', webviewId, url), + goBackWebview: (webviewId: string) => + ipcRenderer.invoke('go-back-webview', webviewId), + goForwardWebview: (webviewId: string) => + ipcRenderer.invoke('go-forward-webview', webviewId), + reloadWebview: (webviewId: string) => + ipcRenderer.invoke('reload-webview', webviewId), + getPreviewWebviewNavigationState: (webviewId: string) => + ipcRenderer.invoke('get-preview-webview-navigation-state', webviewId), + onPreviewWebviewStateChanged: ( + callback: ( + webviewId: string, + state: { + url: string; + title: string; + isLoading: boolean; + canGoBack: boolean; + canGoForward: boolean; + } + ) => void + ) => { + const channel = 'preview-webview-state-changed'; + const listener = (event: any, webviewId: string, state: any) => + callback(webviewId, state); + ipcRenderer.on(channel, listener); + return () => { + ipcRenderer.off(channel, listener); + }; + }, exportLog: () => ipcRenderer.invoke('export-log'), getDiagnosticsInfo: () => ipcRenderer.invoke('get-diagnostics-info'), exportDiagnosticsZip: (payload: { description: string; steps?: string }) => diff --git a/src/components/Folder/FilePreview.tsx b/src/components/Folder/FilePreview.tsx index ea2dda645..a5c656fa5 100644 --- a/src/components/Folder/FilePreview.tsx +++ b/src/components/Folder/FilePreview.tsx @@ -32,6 +32,8 @@ export interface FilePreviewProps { file: FileInfo | null; /** Outer surface background class (project page uses default-default). */ surfaceClassName?: string; + /** Remove the standalone rounded/margin frame when hosted in a tab shell. */ + embedded?: boolean; /** Sibling project files, used by the HTML renderer to resolve local assets. */ projectFiles?: FileInfo[]; /** Close the preview column. */ @@ -51,6 +53,7 @@ export interface FilePreviewProps { export function FilePreview({ file, surfaceClassName = 'bg-ds-bg-neutral-default-default', + embedded = false, projectFiles = [], onClose, onJumpToContext, @@ -248,11 +251,12 @@ export function FilePreview({ } projectFiles={projectFiles} surfaceClassName={surfaceClassName} + embedded={embedded} onRevealFile={handleRevealFile} onDownloadFile={handleDownloadFile} onToggleSourceCode={handleToggleSourceCode} emptyState={ -
+

{t('chat.no-file-selected', { diff --git a/src/components/Folder/index.tsx b/src/components/Folder/index.tsx index ef97bb254..6fa00506a 100644 --- a/src/components/Folder/index.tsx +++ b/src/components/Folder/index.tsx @@ -627,14 +627,14 @@ export const FileTree: React.FC = ({ onSelectFile(fileInfo); } }} - className={`mb-1 min-w-0 gap-2 rounded-lg px-2 py-1.5 hover:bg-ds-bg-neutral-subtle-hover flex w-full flex-row items-center justify-start text-left transition-colors ${ + className={`mb-1 flex w-full min-w-0 flex-row items-center justify-start gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-ds-bg-neutral-subtle-hover ${ isRowSelected ? 'bg-ds-bg-neutral-default-default text-ds-text-neutral-default-default' - : 'text-ds-text-neutral-muted-default bg-transparent' + : 'bg-transparent text-ds-text-neutral-muted-default' }`} > {child.isFolder ? ( - + {isExpanded ? ( ) : ( @@ -653,13 +653,13 @@ export const FileTree: React.FC = ({ ) )} - + {child.name} {hasNested ? ( -

+
{/* header */} -
-
+
+
-
-
- +
+
+ setFileSearchQuery(e.target.value)} placeholder={t('chat.search')} - className="h-7 rounded-lg border-ds-border-neutral-subtle-default py-0 pl-7 pr-2 text-sm focus:ring-ds-ring-brand-default-focus w-full border border-solid leading-none focus:ring-2 focus:ring-offset-0 focus:outline-none" + className="h-7 w-full rounded-lg border border-solid border-ds-border-neutral-subtle-default py-0 pl-7 pr-2 text-sm leading-none focus:outline-none focus:ring-2 focus:ring-ds-ring-brand-default-focus focus:ring-offset-0" aria-label={t('chat.search')} />
@@ -1670,18 +1670,18 @@ export default function Folder({ data: _data }: { data?: Agent }) { handleOpenInIDE('system')} - className="bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover cursor-pointer" + className="cursor-pointer bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover" > {t('chat.open-in-file-manager')} handleOpenInIDE('cursor')} - className="bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover cursor-pointer" + className="cursor-pointer bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover" > handleOpenInIDE('vscode')} - className="bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover cursor-pointer" + className="cursor-pointer bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover" >
-
+
{/* sidebar */} {isFileSidebarOpen ? ( -
-
+
+
-
+
-
+
); } @@ -1912,7 +1912,7 @@ function AudioLoader({ selectedFile }: { selectedFile: FileInfo }) { }, [selectedFile]); return ( -
+

{selectedFile.name}

@@ -2802,7 +2802,7 @@ function HtmlRenderer({ if (selectedFile.content && !processedHtml) { return (
-
+
); } @@ -2819,7 +2819,7 @@ function HtmlRenderer({ {/* Content area with zoom */}
{/* head */} {selectedFile && ( -
+
-
+
- {/* Right: project total token count + file preview toggle */} -
-
+ {/* Right: project total token count + unified preview toggle */} +
+
{t('chat.token-total-label')}{' '}
- +
diff --git a/src/components/Session/PreviewPanel/index.tsx b/src/components/Session/PreviewPanel/index.tsx new file mode 100644 index 000000000..97657519e --- /dev/null +++ b/src/components/Session/PreviewPanel/index.tsx @@ -0,0 +1,512 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { FilePreview } from '@/components/Folder/FilePreview'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { TooltipSimple } from '@/components/ui/tooltip'; +import { useHost } from '@/host'; +import { normalizeBrowserUrl } from '@/lib/browserUrl'; +import { cn } from '@/lib/utils'; +import { + type SessionBrowserNavigationState, + type SessionBrowserTab, + usePageTabStore, +} from '@/store/pageTabStore'; +import { + ArrowLeft, + ArrowRight, + ExternalLink, + FileText, + Globe, + Plus, + RefreshCw, + X, +} from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +const BLANK_URL = 'about:blank?use=0'; + +function browserTitle(state: SessionBrowserNavigationState): string { + const explicitTitle = state.title.trim(); + if (explicitTitle) return explicitTitle; + if (!state.url || state.url.startsWith('about:')) return 'New tab'; + try { + return new URL(state.url).hostname || 'New tab'; + } catch { + return 'New tab'; + } +} + +export interface PreviewPanelProps { + onJumpToContext?: (file: FileInfo | null) => void; +} + +export function PreviewPanel({ onJumpToContext }: PreviewPanelProps) { + const { t } = useTranslation(); + const host = useHost(); + const tabs = usePageTabStore((state) => state.sessionPreviewTabs); + const activeTabId = usePageTabStore( + (state) => state.activeSessionPreviewTabId + ); + const addBrowserPreviewTab = usePageTabStore( + (state) => state.addBrowserPreviewTab + ); + const addFilePreviewTab = usePageTabStore((state) => state.addFilePreviewTab); + const selectSessionPreviewTab = usePageTabStore( + (state) => state.selectSessionPreviewTab + ); + const closeSessionPreviewTab = usePageTabStore( + (state) => state.closeSessionPreviewTab + ); + const updateBrowserPreviewTab = usePageTabStore( + (state) => state.updateBrowserPreviewTab + ); + + const activeTab = useMemo( + () => tabs.find((tab) => tab.id === activeTabId) ?? tabs[0] ?? null, + [activeTabId, tabs] + ); + const browserTabs = useMemo( + () => + tabs.filter((tab): tab is SessionBrowserTab => tab.type === 'browser'), + [tabs] + ); + const browserWebviewIds = browserTabs.map((tab) => tab.webviewId).join('|'); + const activeBrowserTab = activeTab?.type === 'browser' ? activeTab : null; + const activeBrowserWebviewId = activeBrowserTab?.webviewId ?? ''; + const isDesktop = Boolean(host?.electronAPI?.navigateWebview); + const browserContainerRef = useRef(null); + const activeBrowserUrl = activeBrowserTab?.url ?? ''; + const [addressInput, setAddressInput] = useState(activeBrowserUrl); + const [addressError, setAddressError] = useState(null); + + const syncBounds = useCallback( + (webviewId: string) => { + if (!isDesktop || !browserContainerRef.current) return; + const rect = browserContainerRef.current.getBoundingClientRect(); + void host?.electronAPI?.changeViewSize?.(webviewId, { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }); + }, + [host, isDesktop] + ); + + const showBrowserTab = useCallback( + async (tab: SessionBrowserTab) => { + if (!isDesktop || !tab.url) return; + await host?.electronAPI?.showWebview?.(tab.webviewId); + syncBounds(tab.webviewId); + }, + [host, isDesktop, syncBounds] + ); + + const navigateTo = useCallback( + async (tab: SessionBrowserTab, rawUrl: string) => { + const normalized = normalizeBrowserUrl(rawUrl); + if (!normalized.ok) { + setAddressError(normalized.error); + return; + } + + setAddressError(null); + setAddressInput(normalized.url); + updateBrowserPreviewTab(tab.id, { url: normalized.url }); + + if (!isDesktop) { + window.open(normalized.url, '_blank', 'noopener,noreferrer'); + return; + } + + await host?.electronAPI?.createWebView?.(tab.webviewId, BLANK_URL); + const result = await host?.electronAPI?.navigateWebview?.( + tab.webviewId, + normalized.url + ); + if (result?.success === false) { + setAddressError(result.error || 'Unable to open this URL'); + return; + } + await host?.electronAPI?.showWebview?.(tab.webviewId); + syncBounds(tab.webviewId); + }, + [host, isDesktop, syncBounds, updateBrowserPreviewTab] + ); + + const openExternal = useCallback((rawUrl: string) => { + const normalized = normalizeBrowserUrl(rawUrl); + if (!normalized.ok) { + setAddressError(normalized.error); + return; + } + setAddressError(null); + window.open(normalized.url, '_blank', 'noopener,noreferrer'); + }, []); + + useEffect(() => { + setAddressInput(activeBrowserUrl); + setAddressError(null); + }, [activeBrowserUrl, activeTab?.id]); + + useEffect(() => { + if (!isDesktop) return; + + const state = usePageTabStore.getState(); + const currentActiveTab = state.sessionPreviewTabs.find( + (tab) => tab.id === state.activeSessionPreviewTabId + ); + const currentBrowserTabs = state.sessionPreviewTabs.filter( + (tab): tab is SessionBrowserTab => tab.type === 'browser' + ); + + currentBrowserTabs.forEach((tab) => { + if (tab.id !== currentActiveTab?.id) { + void host?.electronAPI?.hideWebView?.(tab.webviewId); + } + }); + + if (currentActiveTab?.type !== 'browser') return; + + let cancelled = false; + const activate = async () => { + const result = await host?.electronAPI?.createWebView?.( + currentActiveTab.webviewId, + BLANK_URL + ); + if (cancelled || !currentActiveTab.url) return; + if (result?.success) { + await host?.electronAPI?.navigateWebview?.( + currentActiveTab.webviewId, + currentActiveTab.url + ); + } + if (!cancelled) await showBrowserTab(currentActiveTab); + }; + void activate(); + + return () => { + cancelled = true; + void host?.electronAPI?.hideWebView?.(currentActiveTab.webviewId); + }; + }, [ + activeBrowserTab?.id, + browserWebviewIds, + host, + isDesktop, + showBrowserTab, + ]); + + useEffect(() => { + if (!isDesktop || !activeBrowserUrl || !activeBrowserWebviewId) return; + const container = browserContainerRef.current; + if (!container) return; + const observer = new ResizeObserver(() => + syncBounds(activeBrowserWebviewId) + ); + observer.observe(container); + syncBounds(activeBrowserWebviewId); + return () => observer.disconnect(); + }, [activeBrowserUrl, activeBrowserWebviewId, isDesktop, syncBounds]); + + useEffect(() => { + if (!isDesktop) return; + return host?.electronAPI?.onPreviewWebviewStateChanged?.( + (webviewId: string, state: SessionBrowserNavigationState) => { + const tab = usePageTabStore + .getState() + .sessionPreviewTabs.find( + (candidate) => + candidate.type === 'browser' && candidate.webviewId === webviewId + ); + if (!tab) return; + const url = state.url.startsWith('about:') ? '' : state.url; + updateBrowserPreviewTab(tab.id, { + title: browserTitle(state), + url, + navigation: { ...state, url }, + }); + } + ); + }, [host, isDesktop, updateBrowserPreviewTab]); + + const handleCloseTab = useCallback( + (tabId: string) => { + const tab = tabs.find((candidate) => candidate.id === tabId); + if (tab?.type === 'browser') { + void host?.electronAPI?.webviewDestroy?.(tab.webviewId); + } + closeSessionPreviewTab(tabId); + }, + [closeSessionPreviewTab, host, tabs] + ); + + if (!activeTab) return null; + + const nav = activeTab.type === 'browser' ? activeTab.navigation : undefined; + + return ( +
+
+
+ {tabs.map((tab) => { + const selected = tab.id === activeTab.id; + return ( +
+ + +
+ ); + })} +
+ + + + + + + + {t('layout.add-browser-tab', { + defaultValue: 'New browser', + })} + + + + {t('layout.add-file-tab', { defaultValue: 'New file' })} + + + +
+ +
+ {activeTab.type === 'browser' ? ( +
+
+ + + + + + + + + +
{ + event.preventDefault(); + void navigateTo(activeTab, addressInput); + }} + > + { + setAddressInput(event.target.value); + if (addressError) setAddressError(null); + }} + placeholder={t('layout.browser-url-placeholder', { + defaultValue: 'Enter a URL', + })} + aria-label={t('layout.browser-url-placeholder', { + defaultValue: 'Enter a URL', + })} + aria-invalid={Boolean(addressError)} + className={cn( + 'placeholder:text-input-label-default/10 h-[28px] w-full min-w-0 rounded-xl border-none bg-ds-bg-neutral-subtle-default px-3 text-body-sm text-ds-text-neutral-default-default outline-none transition-colors', + 'hover:bg-ds-bg-neutral-subtle-default hover:ring-1 hover:ring-ds-ring-neutral-strong-default hover:ring-offset-0', + 'focus:bg-ds-bg-neutral-subtle-default focus:ring-1 focus:ring-ds-ring-brand-default-focus focus:ring-offset-0', + addressError + ? 'border-ds-border-status-error-default-default' + : 'border-ds-border-neutral-default-default' + )} + /> +
+ + + +
+ {addressError ? ( +

+ {addressError} +

+ ) : null} +
+ {!activeTab.url ? ( +
+ {t('layout.browser-blank', { + defaultValue: 'Enter a URL to start browsing.', + })} +
+ ) : !isDesktop ? ( +
+ {t('layout.browser-desktop-only', { + defaultValue: + 'Embedded browsing is available in the desktop app. This URL opened in your system browser.', + })} +
+ ) : null} +
+
+ ) : ( + + )} +
+
+ ); +} + +export default PreviewPanel; diff --git a/src/components/Session/index.tsx b/src/components/Session/index.tsx index 018124ed7..526670c56 100644 --- a/src/components/Session/index.tsx +++ b/src/components/Session/index.tsx @@ -13,11 +13,12 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import ChatBox from '@/components/ChatBox'; -import { FilePreview } from '@/components/Folder/FilePreview'; import { HeaderBox } from '@/components/Session/HeaderBox'; +import { PreviewPanel } from '@/components/Session/PreviewPanel'; import Workspace from '@/components/Workspace'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; import { useSelectedProjectTurn } from '@/hooks/useSelectedProjectTurn'; +import { useHost } from '@/host'; import { inferSessionModeFromTask } from '@/lib/sessionMode'; import { cn } from '@/lib/utils'; import { usePageTabStore } from '@/store/pageTabStore'; @@ -28,6 +29,7 @@ import { SessionMode, type SessionModeType, } from '@/types/constants'; +import { AnimatePresence, motion } from 'framer-motion'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { SessionSidePanel } from './SessionSidePanel'; import { @@ -35,16 +37,13 @@ import { SESSION_SIDE_PANEL_FOLDED_OUTER_CLASS, } from './sessionSidePanelLayout'; -/** - * When the inline preview is open, the chat column is pinned to its max width - * (so the chat flow keeps its comfortable reading width) and the preview fills - * the rest. The chat content itself is capped at 600px; 680 leaves side gutters. - */ +/** Maximum width the resizable chat column can reclaim while display is open. */ const CHAT_PRIORITY_WIDTH = 680; /** Smallest the chat column may be dragged to. */ const CHAT_MIN_WIDTH = 360; /** Keep at least this much room for the preview when the chat is widened. */ const PREVIEW_MIN_WIDTH = 320; +const DISPLAY_PANEL_EASE: [number, number, number, number] = [0.16, 1, 0.3, 1]; /** * Active Project: header + chat (left) and a mode-dependent side panel (right). @@ -58,11 +57,12 @@ interface SessionProps { export default function Session({ isNewProject = false }: SessionProps) { const { chatStore, projectStore } = useChatStoreAdapter(); + const host = useHost(); const activeWorkspaceTab = usePageTabStore((s) => s.activeWorkspaceTab); const setActiveWorkspaceTab = usePageTabStore((s) => s.setActiveWorkspaceTab); - const filePreviewOpen = usePageTabStore((s) => s.filePreviewOpen); - const filePreviewFile = usePageTabStore((s) => s.filePreviewFile); - const closeFilePreview = usePageTabStore((s) => s.closeFilePreview); + const previewOpen = usePageTabStore((s) => s.sessionPreviewOpen); + const closeSessionPreview = usePageTabStore((s) => s.closeSessionPreview); + const resetSessionPreview = usePageTabStore((s) => s.resetSessionPreview); const activeProjectId = projectStore.activeProjectId; const isHistoryLoadingActiveProject = useProjectRuntimeStore((s) => activeProjectId @@ -229,45 +229,59 @@ export default function Session({ isNewProject = false }: SessionProps) { setIsSidePanelVisible((prev) => !prev); }, []); - // Inline preview column sizing. The chat column is width-controlled so it can - // animate between full-width (closed) and its max width (open); the preview is - // flex-1 and fills the remaining space ("full width"), absorbing the extra - // room freed by the side-panel fold. Dragging the divider resizes the chat. + // Chat/display sizing. When display opens the chat collapses to its minimum; + // display takes the remaining room before the independently folded side panel. const chatRowRef = useRef(null); const [chatWidth, setChatWidth] = useState(CHAT_PRIORITY_WIDTH); const [isResizingPreview, setIsResizingPreview] = useState(false); + const prevProjectIdRef = useRef(activeProjectId); + + useEffect(() => { + const previousProjectId = prevProjectIdRef.current; + if (previousProjectId && previousProjectId !== activeProjectId) { + usePageTabStore + .getState() + .sessionPreviewTabs.filter((tab) => tab.type === 'browser') + .forEach((tab) => { + void host?.electronAPI?.webviewDestroy?.(tab.webviewId); + }); + resetSessionPreview(); + } + prevProjectIdRef.current = activeProjectId; + }, [activeProjectId, host, resetSessionPreview]); - // Opening the inline file preview auto-folds the session side panel so the - // preview has room, and resets the chat back to its priority max width. + // Opening display auto-folds the session side panel and collapses chat. useEffect(() => { - if (filePreviewOpen) { + if (previewOpen) { setIsSidePanelVisible(false); - const rowWidth = chatRowRef.current?.getBoundingClientRect().width ?? 0; - const maxChat = rowWidth - ? Math.max(CHAT_MIN_WIDTH, rowWidth - PREVIEW_MIN_WIDTH) - : CHAT_PRIORITY_WIDTH; - setChatWidth(Math.min(CHAT_PRIORITY_WIDTH, maxChat)); + setChatWidth(CHAT_MIN_WIDTH); } - }, [filePreviewOpen]); + }, [previewOpen]); // The preview is a project-page concern; close it when leaving that tab or // switching projects so it never lingers over an unrelated view. useEffect(() => { if (activeWorkspaceTab !== 'project') { - closeFilePreview(); + closeSessionPreview(); } - }, [activeWorkspaceTab, activeProjectId, closeFilePreview]); + }, [activeWorkspaceTab, closeSessionPreview]); const handlePreviewResizeStart = useCallback( (e: React.PointerEvent) => { e.preventDefault(); const rowWidth = chatRowRef.current?.getBoundingClientRect().width ?? window.innerWidth; + const sidePanelWidth = + document.getElementById('session-side-panel')?.getBoundingClientRect() + .width ?? 0; // Chat never exceeds its priority max width, and always leaves room for - // the preview's minimum width. + // display's minimum width plus the independent session panel. const maxChat = Math.max( CHAT_MIN_WIDTH, - Math.min(CHAT_PRIORITY_WIDTH, rowWidth - PREVIEW_MIN_WIDTH) + Math.min( + CHAT_PRIORITY_WIDTH, + rowWidth - sidePanelWidth - PREVIEW_MIN_WIDTH + ) ); const startX = e.clientX; const startWidth = chatWidth; @@ -303,9 +317,9 @@ export default function Session({ isNewProject = false }: SessionProps) { setActiveWorkspaceTab('inbox', { clearInboxForProjectId: activeProjectId ?? null, }); - closeFilePreview(); + closeSessionPreview(); }, - [selectedTurn, setActiveWorkspaceTab, activeProjectId, closeFilePreview] + [selectedTurn, setActiveWorkspaceTab, activeProjectId, closeSessionPreview] ); const toggleExpandedOverlay = useCallback(() => { @@ -336,10 +350,10 @@ export default function Session({ isNewProject = false }: SessionProps) { if (isNewProject) { return ( -
-
+
+
-
+
-
+
+ {/* Chat content: owns the project header and folds when display opens. */} +
{chatStore.activeTaskId && hasAnyMessages && ( )} -
-
+ +
+
+ + + {previewOpen && ( + - -
- {filePreviewOpen && (
- )} -
- -
-
-
+ + {/* Display content: middle column between chat and session. */} +
+ {activeProjectId ? ( + + ) : null} +
+ + )} +
0) { + const afterColon = trimmed.slice(colonIndex + 1); + if (afterColon.startsWith('//')) { + if (!/^https?:\/\//i.test(trimmed)) { + return { ok: false, error: 'Only HTTP and HTTPS URLs are supported' }; + } + } else if (/^[a-z]/i.test(afterColon)) { + return { ok: false, error: 'Only HTTP and HTTPS URLs are supported' }; + } + } + + let candidate = trimmed; + if (!/^https?:\/\//i.test(candidate)) { + candidate = `${looksLikeLocalHost(candidate) ? 'http' : 'https'}://${candidate}`; + } + + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + return { ok: false, error: 'Invalid URL' }; + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { ok: false, error: 'Only HTTP and HTTPS URLs are supported' }; + } + + return { ok: true, url: parsed.href }; +} + +/** Returns canonical href when valid, otherwise null. */ +export function canonicalizeBrowserUrl(input: string): string | null { + const result = normalizeBrowserUrl(input); + if (!result.ok) return null; + + try { + const parsed = new URL(result.url); + if (parsed.pathname !== '/' && parsed.pathname.endsWith('/')) { + parsed.pathname = parsed.pathname.slice(0, -1); + } + return parsed.href; + } catch { + return result.url; + } +} diff --git a/src/store/pageTabStore.ts b/src/store/pageTabStore.ts index c04c8612f..9851c9485 100644 --- a/src/store/pageTabStore.ts +++ b/src/store/pageTabStore.ts @@ -32,6 +32,75 @@ export const WorkspaceTab = { export type WorkspaceTabId = (typeof WorkspaceTab)[keyof typeof WorkspaceTab]; +export interface SessionBrowserNavigationState { + url: string; + title: string; + isLoading: boolean; + canGoBack: boolean; + canGoForward: boolean; +} + +export interface SessionBrowserTab { + id: string; + type: 'browser'; + title: string; + url: string; + webviewId: string; + navigation: SessionBrowserNavigationState; +} + +export interface SessionFileTab { + id: string; + type: 'file'; + title: string; + file: FileInfo | null; +} + +export type SessionPreviewTab = SessionBrowserTab | SessionFileTab; + +let sessionPreviewTabSequence = 0; + +function nextSessionPreviewTabId(type: SessionPreviewTab['type']): string { + sessionPreviewTabSequence += 1; + return `${type}-${sessionPreviewTabSequence}`; +} + +function createBrowserPreviewTab(): SessionBrowserTab { + const id = nextSessionPreviewTabId('browser'); + return { + id, + type: 'browser', + title: 'New tab', + url: '', + webviewId: `session-preview:${id}`, + navigation: { + url: '', + title: '', + isLoading: false, + canGoBack: false, + canGoForward: false, + }, + }; +} + +function createFilePreviewTab(file: FileInfo | null = null): SessionFileTab { + return { + id: nextSessionPreviewTabId('file'), + type: 'file', + title: file?.name || 'Open file', + file, + }; +} + +function createInitialSessionPreviewTabs(): { + tabs: SessionPreviewTab[]; + activeTabId: string; +} { + const browserTab = createBrowserPreviewTab(); + const fileTab = createFilePreviewTab(); + return { tabs: [browserTab, fileTab], activeTabId: browserTab.id }; +} + interface PageTabState { activeTab: 'tasks' | 'trigger'; setActiveTab: (tab: 'tasks' | 'trigger') => void; @@ -135,17 +204,26 @@ interface PageTabState { request: { projectId: string; taskId: string } | null ) => void; - // ── Inline file preview (project page) ─────────────────────────────────── - /** Whether the inline file preview column is open beside the chat content. */ - filePreviewOpen: boolean; - /** File currently shown in the inline preview, or null for the empty state. */ - filePreviewFile: FileInfo | null; - /** Open the preview column. Pass a file to show it, or null for the empty state. */ + // ── Inline session preview (project page) ───────────────────────────────── + sessionPreviewOpen: boolean; + sessionPreviewTabs: SessionPreviewTab[]; + activeSessionPreviewTabId: string | null; + /** Toggle the unified browser/file preview panel. */ + toggleSessionPreview: () => void; + /** Add and activate a blank browser tab. */ + addBrowserPreviewTab: () => void; + /** Add and activate an empty file tab. */ + addFilePreviewTab: () => void; + /** Open a file in a deduplicated file tab. */ openFilePreview: (file?: FileInfo | null) => void; - /** Close the preview column (keeps the last file for a subsequent re-open). */ - closeFilePreview: () => void; - /** Toggle the preview column open/closed without changing the selected file. */ - toggleFilePreview: () => void; + selectSessionPreviewTab: (tabId: string) => void; + closeSessionPreviewTab: (tabId: string) => void; + updateBrowserPreviewTab: ( + tabId: string, + patch: Partial> + ) => void; + closeSessionPreview: () => void; + resetSessionPreview: () => void; } export const usePageTabStore = create()( @@ -334,16 +412,150 @@ export const usePageTabStore = create()( setScrollToTurnRequest: (request) => set({ scrollToTurnRequest: request }), - filePreviewOpen: false, - filePreviewFile: null, + sessionPreviewOpen: false, + sessionPreviewTabs: [], + activeSessionPreviewTabId: null, + toggleSessionPreview: () => + set((state) => { + if (state.sessionPreviewOpen) { + return { sessionPreviewOpen: false }; + } + if (state.sessionPreviewTabs.length > 0) { + return { sessionPreviewOpen: true }; + } + const initial = createInitialSessionPreviewTabs(); + return { + sessionPreviewOpen: true, + sessionPreviewTabs: initial.tabs, + activeSessionPreviewTabId: initial.activeTabId, + }; + }), + addBrowserPreviewTab: () => + set((state) => { + const tab = createBrowserPreviewTab(); + return { + sessionPreviewOpen: true, + sessionPreviewTabs: [...state.sessionPreviewTabs, tab], + activeSessionPreviewTabId: tab.id, + }; + }), + addFilePreviewTab: () => + set((state) => { + const tab = createFilePreviewTab(); + return { + sessionPreviewOpen: true, + sessionPreviewTabs: [...state.sessionPreviewTabs, tab], + activeSessionPreviewTabId: tab.id, + }; + }), openFilePreview: (file) => + set((state) => { + const targetFile = file ?? null; + const initial = + state.sessionPreviewTabs.length === 0 + ? createInitialSessionPreviewTabs() + : null; + const previewTabs = initial?.tabs ?? state.sessionPreviewTabs; + const matchingTab = targetFile + ? previewTabs.find( + (tab) => + tab.type === 'file' && tab.file?.path === targetFile.path + ) + : previewTabs.find( + (tab) => tab.type === 'file' && tab.file === null + ); + if (matchingTab) { + return { + sessionPreviewOpen: true, + sessionPreviewTabs: previewTabs, + activeSessionPreviewTabId: matchingTab.id, + }; + } + + const emptyFileTabIndex = targetFile + ? (() => { + const activeIndex = previewTabs.findIndex( + (tab) => + tab.id === state.activeSessionPreviewTabId && + tab.type === 'file' && + tab.file === null + ); + return activeIndex >= 0 + ? activeIndex + : previewTabs.findIndex( + (tab) => tab.type === 'file' && tab.file === null + ); + })() + : -1; + if (emptyFileTabIndex >= 0) { + const tabs = [...previewTabs]; + const current = tabs[emptyFileTabIndex] as SessionFileTab; + const replacement: SessionFileTab = { + ...current, + title: targetFile?.name || 'Open file', + file: targetFile, + }; + tabs[emptyFileTabIndex] = replacement; + return { + sessionPreviewOpen: true, + sessionPreviewTabs: tabs, + activeSessionPreviewTabId: replacement.id, + }; + } + + const tab = createFilePreviewTab(targetFile); + return { + sessionPreviewOpen: true, + sessionPreviewTabs: [...previewTabs, tab], + activeSessionPreviewTabId: tab.id, + }; + }), + selectSessionPreviewTab: (tabId) => + set((state) => + state.sessionPreviewTabs.some((tab) => tab.id === tabId) + ? { activeSessionPreviewTabId: tabId } + : state + ), + closeSessionPreviewTab: (tabId) => + set((state) => { + const closingIndex = state.sessionPreviewTabs.findIndex( + (tab) => tab.id === tabId + ); + if (closingIndex < 0) return state; + const tabs = state.sessionPreviewTabs.filter( + (tab) => tab.id !== tabId + ); + if (tabs.length === 0) { + return { + sessionPreviewOpen: false, + sessionPreviewTabs: [], + activeSessionPreviewTabId: null, + }; + } + if (state.activeSessionPreviewTabId !== tabId) { + return { sessionPreviewTabs: tabs }; + } + const nextTab = tabs[Math.min(closingIndex, tabs.length - 1)]; + return { + sessionPreviewTabs: tabs, + activeSessionPreviewTabId: nextTab.id, + }; + }), + updateBrowserPreviewTab: (tabId, patch) => set((state) => ({ - filePreviewOpen: true, - filePreviewFile: file === undefined ? state.filePreviewFile : file, + sessionPreviewTabs: state.sessionPreviewTabs.map((tab) => + tab.id === tabId && tab.type === 'browser' + ? { ...tab, ...patch } + : tab + ), })), - closeFilePreview: () => set({ filePreviewOpen: false }), - toggleFilePreview: () => - set((state) => ({ filePreviewOpen: !state.filePreviewOpen })), + closeSessionPreview: () => set({ sessionPreviewOpen: false }), + resetSessionPreview: () => + set({ + sessionPreviewOpen: false, + sessionPreviewTabs: [], + activeSessionPreviewTabId: null, + }), }), { name: 'eigent-page-tab', diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 26d8015a7..bf3ac189c 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -63,6 +63,38 @@ interface ElectronAPI { hideAllWebview: () => Promise; getShowWebview: () => Promise; webviewDestroy: (webviewId: string) => Promise; + navigateWebview: ( + webviewId: string, + url: string + ) => Promise<{ success: boolean; error?: string }>; + goBackWebview: ( + webviewId: string + ) => Promise<{ success: boolean; error?: string }>; + goForwardWebview: ( + webviewId: string + ) => Promise<{ success: boolean; error?: string }>; + reloadWebview: ( + webviewId: string + ) => Promise<{ success: boolean; error?: string }>; + getPreviewWebviewNavigationState: (webviewId: string) => Promise<{ + url: string; + title: string; + isLoading: boolean; + canGoBack: boolean; + canGoForward: boolean; + } | null>; + onPreviewWebviewStateChanged: ( + callback: ( + webviewId: string, + state: { + url: string; + title: string; + isLoading: boolean; + canGoBack: boolean; + canGoForward: boolean; + } + ) => void + ) => () => void; exportLog: () => Promise; getDiagnosticsInfo: () => Promise<{ version: string; diff --git a/test/unit/components/Session/PreviewPanel.test.tsx b/test/unit/components/Session/PreviewPanel.test.tsx new file mode 100644 index 000000000..bb7d1dc1f --- /dev/null +++ b/test/unit/components/Session/PreviewPanel.test.tsx @@ -0,0 +1,186 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { PreviewPanel } from '@/components/Session/PreviewPanel'; +import { HostProvider } from '@/host'; +import { usePageTabStore } from '@/store/pageTabStore'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/components/Folder/FilePreview', () => ({ + FilePreview: ({ file }: { file: FileInfo | null }) => ( +
{file?.name || 'No file selected'}
+ ), +})); + +const createWebView = vi.fn().mockResolvedValue({ success: true }); +const showWebview = vi.fn().mockResolvedValue({ success: true }); +const hideWebView = vi.fn().mockResolvedValue({ success: true }); +const changeViewSize = vi.fn().mockResolvedValue({ success: true }); +const webviewDestroy = vi.fn().mockResolvedValue({ success: true }); +const navigateWebview = vi.fn().mockResolvedValue({ success: true }); +const goBackWebview = vi.fn().mockResolvedValue({ success: true }); +const goForwardWebview = vi.fn().mockResolvedValue({ success: true }); +const reloadWebview = vi.fn().mockResolvedValue({ success: true }); +let navigationListener: + | (( + webviewId: string, + state: { + url: string; + title: string; + isLoading: boolean; + canGoBack: boolean; + canGoForward: boolean; + } + ) => void) + | undefined; +const onPreviewWebviewStateChanged = vi.fn((listener) => { + navigationListener = listener; + return vi.fn(); +}); + +const host = { + ipcRenderer: null, + electronAPI: { + createWebView, + showWebview, + hideWebView, + changeViewSize, + webviewDestroy, + navigateWebview, + goBackWebview, + goForwardWebview, + reloadWebview, + onPreviewWebviewStateChanged, + }, +}; + +function renderPanel() { + return render( + + + + ); +} + +describe('PreviewPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + navigationListener = undefined; + usePageTabStore.setState({ + sessionPreviewOpen: false, + sessionPreviewTabs: [], + activeSessionPreviewTabId: null, + }); + usePageTabStore.getState().toggleSessionPreview(); + }); + + it('renders tabs and adds browser or file tabs from the add menu', async () => { + const user = userEvent.setup(); + renderPanel(); + + expect(screen.getByRole('tab', { name: 'New tab' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Open file' })).toBeInTheDocument(); + + await user.click( + screen.getByRole('button', { name: 'layout.add-preview-tab' }) + ); + await user.click( + await screen.findByRole('menuitem', { name: 'layout.add-browser-tab' }) + ); + + expect(screen.getAllByRole('tab', { name: 'New tab' })).toHaveLength(2); + expect(screen.getAllByRole('tab', { name: 'New tab' })[1]).toHaveAttribute( + 'aria-selected', + 'true' + ); + + await user.click( + screen.getByRole('button', { name: 'layout.add-preview-tab' }) + ); + await user.click( + await screen.findByRole('menuitem', { name: 'layout.add-file-tab' }) + ); + + expect(screen.getAllByRole('tab', { name: 'Open file' })).toHaveLength(2); + }); + + it('shows the current file and reuses its tab by path', () => { + const file = { name: 'notes.md', path: '/tmp/notes.md' } as FileInfo; + usePageTabStore.getState().openFilePreview(file); + usePageTabStore.getState().openFilePreview({ ...file }); + renderPanel(); + + expect(screen.getByTestId('file-preview')).toHaveTextContent('notes.md'); + expect(screen.getAllByRole('tab', { name: 'notes.md' })).toHaveLength(1); + }); + + it('normalizes and navigates to URLs through the existing webview API', async () => { + const user = userEvent.setup(); + renderPanel(); + const address = screen.getByRole('textbox', { + name: 'layout.browser-url-placeholder', + }); + + await user.type(address, 'example.com/docs{Enter}'); + + await waitFor(() => + expect(navigateWebview).toHaveBeenCalledWith( + expect.stringMatching(/^session-preview:/), + 'https://example.com/docs' + ) + ); + expect(showWebview).toHaveBeenCalled(); + }); + + it('updates the browser tab title from native navigation state', async () => { + renderPanel(); + const browserTab = usePageTabStore + .getState() + .sessionPreviewTabs.find((tab) => tab.type === 'browser'); + expect(browserTab?.type).toBe('browser'); + + act(() => { + navigationListener?.(browserTab!.webviewId, { + url: 'https://example.com/report', + title: 'Quarterly report', + isLoading: false, + canGoBack: true, + canGoForward: false, + }); + }); + + expect( + await screen.findByRole('tab', { name: 'Quarterly report' }) + ).toBeInTheDocument(); + }); + + it('destroys a preview-owned webview when its tab closes', async () => { + const user = userEvent.setup(); + renderPanel(); + const browserTab = usePageTabStore + .getState() + .sessionPreviewTabs.find((tab) => tab.type === 'browser'); + + await user.click( + screen.getAllByRole('button', { name: 'layout.close-preview-tab' })[0] + ); + + expect(webviewDestroy).toHaveBeenCalledWith(browserTab!.webviewId); + expect( + screen.queryByRole('tab', { name: 'New tab' }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/test/unit/electron/main/webview.test.ts b/test/unit/electron/main/webview.test.ts new file mode 100644 index 000000000..5c03b4c6f --- /dev/null +++ b/test/unit/electron/main/webview.test.ts @@ -0,0 +1,183 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const electronMocks = vi.hoisted(() => { + class MockWebContents { + private listeners = new Map void>>(); + private url = 'about:blank?use=0'; + private title = ''; + audioMuted = false; + executeJavaScript = vi.fn(); + setBackgroundThrottling = vi.fn(); + setWindowOpenHandler = vi.fn(); + close = vi.fn(); + isDestroyed = vi.fn(() => false); + isLoading = vi.fn(() => false); + getURL = vi.fn(() => this.url); + getTitle = vi.fn(() => this.title); + removeAllListeners = vi.fn(); + session = { clearCache: vi.fn() }; + debugger = { + isAttached: vi.fn(() => false), + attach: vi.fn(), + detach: vi.fn(), + sendCommand: vi.fn(async () => ({ data: 'preview' })), + }; + navigationHistory = { + canGoBack: vi.fn(() => true), + canGoForward: vi.fn(() => false), + goBack: vi.fn(), + goForward: vi.fn(), + }; + + on = vi.fn((event: string, listener: (...args: any[]) => void) => { + const listeners = this.listeners.get(event) ?? []; + listeners.push(listener); + this.listeners.set(event, listeners); + }); + + private emit(event: string, ...args: any[]) { + this.listeners.get(event)?.forEach((listener) => listener({}, ...args)); + } + + loadURL = vi.fn(async (url: string) => { + this.emit('did-start-loading'); + this.url = url; + this.title = url.startsWith('http') ? 'Example page' : ''; + this.emit('did-navigate', url); + this.emit('page-title-updated', this.title); + this.emit('did-stop-loading'); + this.emit('did-finish-load'); + }); + + capturePage = vi.fn(async () => ({ + toJPEG: vi.fn(() => Buffer.from('image')), + })); + reload = vi.fn(); + } + + class MockWebContentsView { + webContents = new MockWebContents(); + setBounds = vi.fn(); + setBorderRadius = vi.fn(); + } + + return { + MockWebContentsView, + views: [] as InstanceType[], + }; +}); + +vi.mock('electron', () => ({ + BrowserWindow: class {}, + WebContentsView: class extends electronMocks.MockWebContentsView { + constructor() { + super(); + electronMocks.views.push(this); + } + }, +})); + +import { + isPreviewWebviewId, + WebViewManager, +} from '../../../../electron/main/webview'; + +describe('WebViewManager session preview views', () => { + const windowMock = { + isDestroyed: vi.fn(() => false), + webContents: { send: vi.fn() }, + contentView: { + addChildView: vi.fn(), + removeChildView: vi.fn(), + }, + }; + + beforeEach(() => { + electronMocks.views.length = 0; + vi.clearAllMocks(); + }); + + it('validates and navigates preview-owned views without exposing them as agent views', async () => { + const manager = new WebViewManager(windowMock as any); + const id = 'session-preview:browser-1'; + + expect(isPreviewWebviewId(id)).toBe(true); + await manager.createWebview(id); + expect( + await manager.navigateWebview(id, 'file:///tmp/report.html') + ).toEqual({ + success: false, + error: 'Only HTTP and HTTPS URLs are supported', + }); + + await expect( + manager.navigateWebview(id, 'https://example.com') + ).resolves.toEqual({ + success: true, + }); + expect(manager.getActiveWebview()).toEqual([]); + expect(await manager.captureWebview(id)).toBeNull(); + expect(windowMock.webContents.send).toHaveBeenCalledWith( + 'preview-webview-state-changed', + id, + expect.objectContaining({ + url: 'https://example.com', + title: 'Example page', + }) + ); + }); + + it('supports preview navigation controls without emitting agent show events', async () => { + const manager = new WebViewManager(windowMock as any); + const id = 'session-preview:browser-2'; + await manager.createWebview(id); + await manager.navigateWebview(id, 'https://example.com'); + + manager.goBackWebview(id); + manager.goForwardWebview(id); + manager.reloadWebview(id); + await manager.showWebview(id); + + const contents = electronMocks.views[0].webContents; + expect(contents.navigationHistory.goBack).toHaveBeenCalled(); + expect(contents.navigationHistory.goForward).not.toHaveBeenCalled(); + expect(contents.reload).toHaveBeenCalled(); + expect(windowMock.webContents.send).not.toHaveBeenCalledWith( + 'webview-show', + id + ); + expect(manager.getPreviewWebviewNavigationState(id)).toMatchObject({ + url: 'https://example.com', + title: 'Example page', + canGoBack: true, + canGoForward: false, + }); + }); + + it('leaves agent webview capture behavior intact', async () => { + const manager = new WebViewManager(windowMock as any); + await manager.createWebview('1'); + + expect(await manager.navigateWebview('1', 'https://example.com')).toEqual({ + success: false, + error: 'Navigation is limited to preview tabs', + }); + expect(await manager.captureWebview('1')).toBe( + 'data:image/jpeg;base64,preview' + ); + }); +}); diff --git a/test/unit/lib/browserUrl.test.ts b/test/unit/lib/browserUrl.test.ts new file mode 100644 index 000000000..a861f9b67 --- /dev/null +++ b/test/unit/lib/browserUrl.test.ts @@ -0,0 +1,48 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { normalizeBrowserUrl } from '@/lib/browserUrl'; +import { describe, expect, it } from 'vitest'; + +describe('normalizeBrowserUrl', () => { + it('normalizes bare hostnames to HTTPS', () => { + const result = normalizeBrowserUrl('example.com/path'); + expect(result).toEqual({ + ok: true, + url: 'https://example.com/path', + }); + }); + + it('normalizes localhost and IP destinations to HTTP', () => { + expect(normalizeBrowserUrl('localhost:3000')).toEqual({ + ok: true, + url: 'http://localhost:3000/', + }); + expect(normalizeBrowserUrl('127.0.0.1:8080/docs')).toEqual({ + ok: true, + url: 'http://127.0.0.1:8080/docs', + }); + }); + + it('rejects unsupported protocols', () => { + expect(normalizeBrowserUrl('javascript:alert(1)')).toEqual({ + ok: false, + error: 'Only HTTP and HTTPS URLs are supported', + }); + expect(normalizeBrowserUrl('file:///etc/passwd')).toEqual({ + ok: false, + error: 'Only HTTP and HTTPS URLs are supported', + }); + }); +}); diff --git a/test/unit/store/pageTabStore.preview.test.ts b/test/unit/store/pageTabStore.preview.test.ts new file mode 100644 index 000000000..b14666cb7 --- /dev/null +++ b/test/unit/store/pageTabStore.preview.test.ts @@ -0,0 +1,121 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { usePageTabStore } from '@/store/pageTabStore'; +import { beforeEach, describe, expect, it } from 'vitest'; + +describe('pageTabStore session preview', () => { + beforeEach(() => { + usePageTabStore.setState({ + sessionPreviewOpen: false, + sessionPreviewTabs: [], + activeSessionPreviewTabId: null, + }); + }); + + it('seeds browser and file tabs when the unified preview opens', () => { + const store = usePageTabStore.getState(); + store.toggleSessionPreview(); + + const state = usePageTabStore.getState(); + expect(state.sessionPreviewOpen).toBe(true); + expect(state.sessionPreviewTabs.map((tab) => tab.type)).toEqual([ + 'browser', + 'file', + ]); + expect(state.sessionPreviewTabs.map((tab) => tab.title)).toEqual([ + 'New tab', + 'Open file', + ]); + expect(state.activeSessionPreviewTabId).toBe( + state.sessionPreviewTabs[0].id + ); + }); + + it('adds and selects blank browser and file tabs', () => { + const store = usePageTabStore.getState(); + store.toggleSessionPreview(); + store.addBrowserPreviewTab(); + + let state = usePageTabStore.getState(); + expect(state.sessionPreviewTabs).toHaveLength(3); + let active = state.sessionPreviewTabs.find( + (tab) => tab.id === state.activeSessionPreviewTabId + ); + expect(active).toMatchObject({ + type: 'browser', + title: 'New tab', + url: '', + }); + + store.addFilePreviewTab(); + state = usePageTabStore.getState(); + active = state.sessionPreviewTabs.find( + (tab) => tab.id === state.activeSessionPreviewTabId + ); + expect(active).toMatchObject({ + type: 'file', + title: 'Open file', + file: null, + }); + }); + + it('reuses the empty file tab and deduplicates files by path', () => { + const store = usePageTabStore.getState(); + store.toggleSessionPreview(); + const file = { name: 'doc.txt', path: '/tmp/doc.txt' } as FileInfo; + store.openFilePreview(file); + store.openFilePreview({ ...file }); + + const state = usePageTabStore.getState(); + const fileTabs = state.sessionPreviewTabs.filter( + (tab) => tab.type === 'file' + ); + expect(fileTabs).toHaveLength(1); + expect(fileTabs[0]).toMatchObject({ title: 'doc.txt', file }); + expect(state.activeSessionPreviewTabId).toBe(fileTabs[0].id); + }); + + it('selects a neighboring tab and closes the panel after the final tab', () => { + const store = usePageTabStore.getState(); + store.toggleSessionPreview(); + const [browserTab, fileTab] = usePageTabStore.getState().sessionPreviewTabs; + + store.closeSessionPreviewTab(browserTab.id); + expect(usePageTabStore.getState().activeSessionPreviewTabId).toBe( + fileTab.id + ); + + store.closeSessionPreviewTab(fileTab.id); + expect(usePageTabStore.getState()).toMatchObject({ + sessionPreviewOpen: false, + sessionPreviewTabs: [], + activeSessionPreviewTabId: null, + }); + }); + + it('closes without discarding tabs and resets project-scoped state', () => { + const store = usePageTabStore.getState(); + store.toggleSessionPreview(); + store.closeSessionPreview(); + expect(usePageTabStore.getState().sessionPreviewTabs).toHaveLength(2); + + store.resetSessionPreview(); + expect(usePageTabStore.getState()).toMatchObject({ + sessionPreviewOpen: false, + sessionPreviewTabs: [], + activeSessionPreviewTabId: null, + }); + }); +}); From cc20e46f06898d27044c29a03770a8ffe524fa26 Mon Sep 17 00:00:00 2001 From: Douglas Date: Sat, 11 Jul 2026 03:08:40 +0100 Subject: [PATCH 2/3] Design new structure for preview panel --- electron/main/index.ts | 21 +- electron/main/webview.ts | 152 +---- electron/preload/index.ts | 30 - src/components/Session/PreviewPanel/index.tsx | 617 +++++------------- .../Session/PreviewPanel/tabKinds.tsx | 96 +++ .../Session/PreviewPanel/tabs/CanvasTab.tsx | 91 +++ .../Session/PreviewPanel/tabs/ChooserTab.tsx | 84 +++ .../Session/PreviewPanel/tabs/FileTab.tsx | 35 + .../Session/PreviewPanel/tabs/ReviewTab.tsx | 25 + .../Session/PreviewPanel/tabs/TerminalTab.tsx | 38 ++ .../PreviewPanel/tabs/browser/BrowserTab.tsx | 267 ++++++++ .../tabs/browser/PreviewBrowserLayer.tsx | 225 +++++++ .../tabs/browser/webviewRegistry.ts | 58 ++ src/components/Session/index.tsx | 42 +- src/pages/Workspace.tsx | 19 +- src/store/pageTabStore.ts | 446 ++++++++++--- src/types/electron.d.ts | 32 - .../Session/PreviewBrowserLayer.test.tsx | 186 ++++++ .../components/Session/PreviewPanel.test.tsx | 227 ++++--- test/unit/electron/main/webview.test.ts | 183 ------ test/unit/store/pageTabStore.preview.test.ts | 130 +++- tsconfig.node.tsbuildinfo | 1 + 22 files changed, 1905 insertions(+), 1100 deletions(-) create mode 100644 src/components/Session/PreviewPanel/tabKinds.tsx create mode 100644 src/components/Session/PreviewPanel/tabs/CanvasTab.tsx create mode 100644 src/components/Session/PreviewPanel/tabs/ChooserTab.tsx create mode 100644 src/components/Session/PreviewPanel/tabs/FileTab.tsx create mode 100644 src/components/Session/PreviewPanel/tabs/ReviewTab.tsx create mode 100644 src/components/Session/PreviewPanel/tabs/TerminalTab.tsx create mode 100644 src/components/Session/PreviewPanel/tabs/browser/BrowserTab.tsx create mode 100644 src/components/Session/PreviewPanel/tabs/browser/PreviewBrowserLayer.tsx create mode 100644 src/components/Session/PreviewPanel/tabs/browser/webviewRegistry.ts create mode 100644 test/unit/components/Session/PreviewBrowserLayer.test.tsx delete mode 100644 test/unit/electron/main/webview.test.ts create mode 100644 tsconfig.node.tsbuildinfo diff --git a/electron/main/index.ts b/electron/main/index.ts index b80eaacfc..8cb580ed7 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -2009,14 +2009,6 @@ function registerIpcHandlers() { { name: 'set-size', method: 'setSize' }, { name: 'get-show-webview', method: 'getShowWebview' }, { name: 'webview-destroy', method: 'destroyWebview' }, - { name: 'navigate-webview', method: 'navigateWebview' }, - { name: 'go-back-webview', method: 'goBackWebview' }, - { name: 'go-forward-webview', method: 'goForwardWebview' }, - { name: 'reload-webview', method: 'reloadWebview' }, - { - name: 'get-preview-webview-navigation-state', - method: 'getPreviewWebviewNavigationState', - }, ]; webviewHandlers.forEach(({ name, method }) => { @@ -2387,6 +2379,19 @@ async function createWindow() { }, }); + // Renderer guests (session preview browser): route window.open / + // target=_blank into the same guest instead of spawning popup windows, and + // only allow web URLs. The guests are sandboxed tags declared in the + // renderer; this is the only main-process involvement they need. + win.webContents.on('did-attach-webview', (_event, contents) => { + contents.setWindowOpenHandler(({ url }) => { + if (/^https?:\/\//i.test(url)) { + void contents.loadURL(url); + } + return { action: 'deny' }; + }); + }); + if (process.platform === 'darwin') { win.once('ready-to-show', () => { if (win && !win.isDestroyed()) { diff --git a/electron/main/webview.ts b/electron/main/webview.ts index 453330323..1575cf0bb 100644 --- a/electron/main/webview.ts +++ b/electron/main/webview.ts @@ -30,28 +30,6 @@ interface Size { height: number; } -export interface PreviewWebviewNavigationState { - url: string; - title: string; - isLoading: boolean; - canGoBack: boolean; - canGoForward: boolean; -} - -const PREVIEW_WEBVIEW_PREFIX = 'session-preview:'; - -export const isPreviewWebviewId = (id: string) => - id.startsWith(PREVIEW_WEBVIEW_PREFIX); - -function isSupportedWebUrl(input: string): boolean { - try { - const parsed = new URL(input); - return parsed.protocol === 'http:' || parsed.protocol === 'https:'; - } catch { - return false; - } -} - export class WebViewManager { private webViews = new Map(); private win: BrowserWindow | null = null; @@ -77,35 +55,10 @@ export class WebViewManager { this.win = window; } - private getPreviewNavigationState( - webViewInfo: WebViewInfo - ): PreviewWebviewNavigationState { - const contents = webViewInfo.view.webContents; - return { - url: contents.getURL(), - title: contents.getTitle(), - isLoading: contents.isLoading(), - canGoBack: contents.navigationHistory.canGoBack(), - canGoForward: contents.navigationHistory.canGoForward(), - }; - } - - private emitPreviewNavigationState(webViewInfo: WebViewInfo) { - if (!isPreviewWebviewId(webViewInfo.id)) return; - if (!this.win || this.win.isDestroyed()) return; - if (webViewInfo.view.webContents.isDestroyed()) return; - this.win.webContents.send( - 'preview-webview-state-changed', - webViewInfo.id, - this.getPreviewNavigationState(webViewInfo) - ); - } - // Remove automatic IPC handler registration from constructor // IPC handlers should be registered once in the main process public async captureWebview(webviewId: string) { - if (isPreviewWebviewId(webviewId)) return null; const webViewInfo = this.webViews.get(webviewId); if (!webViewInfo) return null; @@ -166,7 +119,7 @@ export class WebViewManager { public getActiveWebview() { const activeWebviews = Array.from(this.webViews.values()).filter( - (webview) => webview.isActive && !isPreviewWebviewId(webview.id) + (webview) => webview.isActive ); return activeWebviews.map((webview) => webview.id); @@ -324,20 +277,7 @@ export class WebViewManager { // win?.webContents.send("url-updated", url); // }); - if (isPreviewWebviewId(id)) { - const notifyPreviewState = () => - this.emitPreviewNavigationState(webViewInfo); - view.webContents.on('did-start-loading', notifyPreviewState); - view.webContents.on('did-stop-loading', notifyPreviewState); - view.webContents.on('page-title-updated', notifyPreviewState); - } - view.webContents.on('did-navigate-in-page', (event, url) => { - if (isPreviewWebviewId(id)) { - webViewInfo.currentUrl = url; - this.emitPreviewNavigationState(webViewInfo); - return; - } if ( webViewInfo.isActive && webViewInfo.isShow && @@ -356,10 +296,6 @@ export class WebViewManager { webViewInfo.isActive = true; } console.log(`Webview ${id} navigated to: ${navigationUrl}`); - if (isPreviewWebviewId(id)) { - this.emitPreviewNavigationState(webViewInfo); - return; - } if ( webViewInfo.isActive && webViewInfo.isShow && @@ -372,9 +308,7 @@ export class WebViewManager { } webViewInfo.view.setBounds(this.getHiddenBounds(id, 1920, 1080)); const activeSize = this.getActiveWebview().length; - const allSize = Array.from(this.webViews.values()).filter( - (webview) => !isPreviewWebviewId(webview.id) - ).length; + const allSize = Array.from(this.webViews.values()).length; const inactiveSize = allSize - activeSize; // Clean up inactive webviews if too many @@ -412,9 +346,6 @@ export class WebViewManager { }); view.webContents.setWindowOpenHandler(({ url }) => { - if (isPreviewWebviewId(id) && !isSupportedWebUrl(url)) { - return { action: 'deny' }; - } view.webContents.loadURL(url); return { action: 'deny' }; @@ -430,76 +361,6 @@ export class WebViewManager { } } - public async navigateWebview(id: string, url: string) { - if (!isPreviewWebviewId(id)) { - return { success: false, error: 'Navigation is limited to preview tabs' }; - } - if (!isSupportedWebUrl(url)) { - return { - success: false, - error: 'Only HTTP and HTTPS URLs are supported', - }; - } - const webViewInfo = this.webViews.get(id); - if (!webViewInfo) { - return { success: false, error: `Webview with id ${id} not found` }; - } - webViewInfo.isActive = true; - await webViewInfo.view.webContents.loadURL(url); - webViewInfo.currentUrl = url; - this.emitPreviewNavigationState(webViewInfo); - return { success: true }; - } - - public goBackWebview(id: string) { - const webViewInfo = this.webViews.get(id); - if (!webViewInfo || !isPreviewWebviewId(id)) { - return { - success: false, - error: `Preview webview with id ${id} not found`, - }; - } - if (webViewInfo.view.webContents.navigationHistory.canGoBack()) { - webViewInfo.view.webContents.navigationHistory.goBack(); - } - this.emitPreviewNavigationState(webViewInfo); - return { success: true }; - } - - public goForwardWebview(id: string) { - const webViewInfo = this.webViews.get(id); - if (!webViewInfo || !isPreviewWebviewId(id)) { - return { - success: false, - error: `Preview webview with id ${id} not found`, - }; - } - if (webViewInfo.view.webContents.navigationHistory.canGoForward()) { - webViewInfo.view.webContents.navigationHistory.goForward(); - } - this.emitPreviewNavigationState(webViewInfo); - return { success: true }; - } - - public reloadWebview(id: string) { - const webViewInfo = this.webViews.get(id); - if (!webViewInfo || !isPreviewWebviewId(id)) { - return { - success: false, - error: `Preview webview with id ${id} not found`, - }; - } - webViewInfo.view.webContents.reload(); - this.emitPreviewNavigationState(webViewInfo); - return { success: true }; - } - - public getPreviewWebviewNavigationState(id: string) { - const webViewInfo = this.webViews.get(id); - if (!webViewInfo || !isPreviewWebviewId(id)) return null; - return this.getPreviewNavigationState(webViewInfo); - } - public changeViewSize(id: string, size: Size) { try { const webViewInfo = this.webViews.get(id); @@ -568,9 +429,7 @@ export class WebViewManager { } const currentUrl = webViewInfo.view.webContents.getURL(); - if (!isPreviewWebviewId(id)) { - this.win?.webContents.send('url-updated', currentUrl); - } + this.win?.webContents.send('url-updated', currentUrl); webViewInfo.isShow = true; this.changeViewSize(id, this.size); console.log('showWebview', id, this.size); @@ -582,12 +441,10 @@ export class WebViewManager { webViewInfo.view.webContents.setBackgroundThrottling(false); } - if (!isPreviewWebviewId(id) && this.win && !this.win.isDestroyed()) { + if (this.win && !this.win.isDestroyed()) { this.win.webContents.send('webview-show', id); } - this.emitPreviewNavigationState(webViewInfo); - return { success: true }; } public getShowWebview() { @@ -647,7 +504,6 @@ export class WebViewManager { const inactiveWebviews = Array.from(this.webViews.entries()) .filter( ([_id, info]) => - !isPreviewWebviewId(info.id) && !info.isActive && !info.isShow && info.currentUrl === 'about:blank?use=0' diff --git a/electron/preload/index.ts b/electron/preload/index.ts index eab826b27..a68179297 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -75,36 +75,6 @@ contextBridge.exposeInMainWorld('electronAPI', { getShowWebview: () => ipcRenderer.invoke('get-show-webview'), webviewDestroy: (webviewId: string) => ipcRenderer.invoke('webview-destroy', webviewId), - navigateWebview: (webviewId: string, url: string) => - ipcRenderer.invoke('navigate-webview', webviewId, url), - goBackWebview: (webviewId: string) => - ipcRenderer.invoke('go-back-webview', webviewId), - goForwardWebview: (webviewId: string) => - ipcRenderer.invoke('go-forward-webview', webviewId), - reloadWebview: (webviewId: string) => - ipcRenderer.invoke('reload-webview', webviewId), - getPreviewWebviewNavigationState: (webviewId: string) => - ipcRenderer.invoke('get-preview-webview-navigation-state', webviewId), - onPreviewWebviewStateChanged: ( - callback: ( - webviewId: string, - state: { - url: string; - title: string; - isLoading: boolean; - canGoBack: boolean; - canGoForward: boolean; - } - ) => void - ) => { - const channel = 'preview-webview-state-changed'; - const listener = (event: any, webviewId: string, state: any) => - callback(webviewId, state); - ipcRenderer.on(channel, listener); - return () => { - ipcRenderer.off(channel, listener); - }; - }, exportLog: () => ipcRenderer.invoke('export-log'), getDiagnosticsInfo: () => ipcRenderer.invoke('get-diagnostics-info'), exportDiagnosticsZip: (payload: { description: string; steps?: string }) => diff --git a/src/components/Session/PreviewPanel/index.tsx b/src/components/Session/PreviewPanel/index.tsx index 97657519e..edf9f485b 100644 --- a/src/components/Session/PreviewPanel/index.tsx +++ b/src/components/Session/PreviewPanel/index.tsx @@ -12,53 +12,38 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import { FilePreview } from '@/components/Folder/FilePreview'; import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; import { TooltipSimple } from '@/components/ui/tooltip'; import { useHost } from '@/host'; -import { normalizeBrowserUrl } from '@/lib/browserUrl'; import { cn } from '@/lib/utils'; -import { - type SessionBrowserNavigationState, - type SessionBrowserTab, - usePageTabStore, -} from '@/store/pageTabStore'; -import { - ArrowLeft, - ArrowRight, - ExternalLink, - FileText, - Globe, - Plus, - RefreshCw, - X, -} from 'lucide-react'; +import { usePageTabStore } from '@/store/pageTabStore'; +import { Plus, X } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; - -const BLANK_URL = 'about:blank?use=0'; - -function browserTitle(state: SessionBrowserNavigationState): string { - const explicitTitle = state.title.trim(); - if (explicitTitle) return explicitTitle; - if (!state.url || state.url.startsWith('about:')) return 'New tab'; - try { - return new URL(state.url).hostname || 'New tab'; - } catch { - return 'New tab'; - } -} +import { previewTabIcon } from './tabKinds'; +import { BrowserTab } from './tabs/browser/BrowserTab'; +import { CanvasTab } from './tabs/CanvasTab'; +import { ChooserTab } from './tabs/ChooserTab'; +import { FileTab } from './tabs/FileTab'; +import { ReviewTab } from './tabs/ReviewTab'; +import { TerminalTab } from './tabs/TerminalTab'; + +// Tabs render at a comfortable default width and shrink evenly as more are +// added, down to a minimum that keeps the title/close affordance legible. +// Once every tab is at its minimum the tab list scrolls horizontally. +const TAB_DEFAULT_WIDTH = 176; +const TAB_MIN_WIDTH = 92; export interface PreviewPanelProps { onJumpToContext?: (file: FileInfo | null) => void; } +/** + * Unified preview panel: a tab strip plus a content router that dispatches to + * one component per tab kind (chooser / browser / file / review / terminal / + * canvas). Embedded browsers live in the always-mounted PreviewBrowserLayer; + * this panel only renders their chrome via BrowserTab. + */ export function PreviewPanel({ onJumpToContext }: PreviewPanelProps) { const { t } = useTranslation(); const host = useHost(); @@ -66,444 +51,190 @@ export function PreviewPanel({ onJumpToContext }: PreviewPanelProps) { const activeTabId = usePageTabStore( (state) => state.activeSessionPreviewTabId ); - const addBrowserPreviewTab = usePageTabStore( - (state) => state.addBrowserPreviewTab + const addChooserPreviewTab = usePageTabStore( + (state) => state.addChooserPreviewTab + ); + const choosePreviewTabType = usePageTabStore( + (state) => state.choosePreviewTabType ); - const addFilePreviewTab = usePageTabStore((state) => state.addFilePreviewTab); const selectSessionPreviewTab = usePageTabStore( (state) => state.selectSessionPreviewTab ); const closeSessionPreviewTab = usePageTabStore( (state) => state.closeSessionPreviewTab ); - const updateBrowserPreviewTab = usePageTabStore( - (state) => state.updateBrowserPreviewTab - ); const activeTab = useMemo( () => tabs.find((tab) => tab.id === activeTabId) ?? tabs[0] ?? null, [activeTabId, tabs] ); - const browserTabs = useMemo( - () => - tabs.filter((tab): tab is SessionBrowserTab => tab.type === 'browser'), - [tabs] - ); - const browserWebviewIds = browserTabs.map((tab) => tab.webviewId).join('|'); - const activeBrowserTab = activeTab?.type === 'browser' ? activeTab : null; - const activeBrowserWebviewId = activeBrowserTab?.webviewId ?? ''; - const isDesktop = Boolean(host?.electronAPI?.navigateWebview); - const browserContainerRef = useRef(null); - const activeBrowserUrl = activeBrowserTab?.url ?? ''; - const [addressInput, setAddressInput] = useState(activeBrowserUrl); - const [addressError, setAddressError] = useState(null); - - const syncBounds = useCallback( - (webviewId: string) => { - if (!isDesktop || !browserContainerRef.current) return; - const rect = browserContainerRef.current.getBoundingClientRect(); - void host?.electronAPI?.changeViewSize?.(webviewId, { - x: rect.left, - y: rect.top, - width: rect.width, - height: rect.height, - }); - }, - [host, isDesktop] - ); - - const showBrowserTab = useCallback( - async (tab: SessionBrowserTab) => { - if (!isDesktop || !tab.url) return; - await host?.electronAPI?.showWebview?.(tab.webviewId); - syncBounds(tab.webviewId); - }, - [host, isDesktop, syncBounds] - ); - - const navigateTo = useCallback( - async (tab: SessionBrowserTab, rawUrl: string) => { - const normalized = normalizeBrowserUrl(rawUrl); - if (!normalized.ok) { - setAddressError(normalized.error); - return; - } - - setAddressError(null); - setAddressInput(normalized.url); - updateBrowserPreviewTab(tab.id, { url: normalized.url }); - - if (!isDesktop) { - window.open(normalized.url, '_blank', 'noopener,noreferrer'); - return; - } - - await host?.electronAPI?.createWebView?.(tab.webviewId, BLANK_URL); - const result = await host?.electronAPI?.navigateWebview?.( - tab.webviewId, - normalized.url - ); - if (result?.success === false) { - setAddressError(result.error || 'Unable to open this URL'); - return; - } - await host?.electronAPI?.showWebview?.(tab.webviewId); - syncBounds(tab.webviewId); - }, - [host, isDesktop, syncBounds, updateBrowserPreviewTab] - ); - - const openExternal = useCallback((rawUrl: string) => { - const normalized = normalizeBrowserUrl(rawUrl); - if (!normalized.ok) { - setAddressError(normalized.error); - return; - } - setAddressError(null); - window.open(normalized.url, '_blank', 'noopener,noreferrer'); + // Embedded browsing relies on the desktop host's tag; on the web + // the panel still works but URLs open in a regular browser tab. + const isDesktop = Boolean(host?.electronAPI); + const tabListRef = useRef(null); + const [tabOverflow, setTabOverflow] = useState({ start: false, end: false }); + + const updateTabOverflow = useCallback(() => { + const el = tabListRef.current; + if (!el) return; + const { scrollLeft, scrollWidth, clientWidth } = el; + setTabOverflow({ + start: scrollLeft > 1, + end: scrollLeft + clientWidth < scrollWidth - 1, + }); }, []); useEffect(() => { - setAddressInput(activeBrowserUrl); - setAddressError(null); - }, [activeBrowserUrl, activeTab?.id]); - - useEffect(() => { - if (!isDesktop) return; - - const state = usePageTabStore.getState(); - const currentActiveTab = state.sessionPreviewTabs.find( - (tab) => tab.id === state.activeSessionPreviewTabId - ); - const currentBrowserTabs = state.sessionPreviewTabs.filter( - (tab): tab is SessionBrowserTab => tab.type === 'browser' - ); - - currentBrowserTabs.forEach((tab) => { - if (tab.id !== currentActiveTab?.id) { - void host?.electronAPI?.hideWebView?.(tab.webviewId); - } - }); - - if (currentActiveTab?.type !== 'browser') return; - - let cancelled = false; - const activate = async () => { - const result = await host?.electronAPI?.createWebView?.( - currentActiveTab.webviewId, - BLANK_URL - ); - if (cancelled || !currentActiveTab.url) return; - if (result?.success) { - await host?.electronAPI?.navigateWebview?.( - currentActiveTab.webviewId, - currentActiveTab.url - ); - } - if (!cancelled) await showBrowserTab(currentActiveTab); - }; - void activate(); - + const el = tabListRef.current; + if (!el) return; + updateTabOverflow(); + const observer = new ResizeObserver(updateTabOverflow); + observer.observe(el); + el.addEventListener('scroll', updateTabOverflow, { passive: true }); return () => { - cancelled = true; - void host?.electronAPI?.hideWebView?.(currentActiveTab.webviewId); + observer.disconnect(); + el.removeEventListener('scroll', updateTabOverflow); }; - }, [ - activeBrowserTab?.id, - browserWebviewIds, - host, - isDesktop, - showBrowserTab, - ]); - - useEffect(() => { - if (!isDesktop || !activeBrowserUrl || !activeBrowserWebviewId) return; - const container = browserContainerRef.current; - if (!container) return; - const observer = new ResizeObserver(() => - syncBounds(activeBrowserWebviewId) - ); - observer.observe(container); - syncBounds(activeBrowserWebviewId); - return () => observer.disconnect(); - }, [activeBrowserUrl, activeBrowserWebviewId, isDesktop, syncBounds]); - - useEffect(() => { - if (!isDesktop) return; - return host?.electronAPI?.onPreviewWebviewStateChanged?.( - (webviewId: string, state: SessionBrowserNavigationState) => { - const tab = usePageTabStore - .getState() - .sessionPreviewTabs.find( - (candidate) => - candidate.type === 'browser' && candidate.webviewId === webviewId - ); - if (!tab) return; - const url = state.url.startsWith('about:') ? '' : state.url; - updateBrowserPreviewTab(tab.id, { - title: browserTitle(state), - url, - navigation: { ...state, url }, - }); - } - ); - }, [host, isDesktop, updateBrowserPreviewTab]); - - const handleCloseTab = useCallback( - (tabId: string) => { - const tab = tabs.find((candidate) => candidate.id === tabId); - if (tab?.type === 'browser') { - void host?.electronAPI?.webviewDestroy?.(tab.webviewId); - } - closeSessionPreviewTab(tabId); - }, - [closeSessionPreviewTab, host, tabs] - ); + }, [updateTabOverflow, tabs.length]); if (!activeTab) return null; - const nav = activeTab.type === 'browser' ? activeTab.navigation : undefined; + const renderActiveContent = () => { + switch (activeTab.type) { + case 'chooser': + return ( + choosePreviewTabType(activeTab.id, kind)} + /> + ); + case 'browser': + // Keyed so each browser tab keeps its own address state. + return ( + + ); + case 'file': + return ; + case 'review': + return ; + case 'terminal': + return ; + case 'canvas': + return ; + default: + return null; + } + }; return (
-
-
- {tabs.map((tab) => { - const selected = tab.id === activeTab.id; - return ( -
- - -
- ); - })} -
- - - - - - - - {t('layout.add-browser-tab', { - defaultValue: 'New browser', - })} - - - - {t('layout.add-file-tab', { defaultValue: 'New file' })} - - - -
- -
- {activeTab.type === 'browser' ? ( -
-
- - - - - - - - - -
{ - event.preventDefault(); - void navigateTo(activeTab, addressInput); - }} - > - { - setAddressInput(event.target.value); - if (addressError) setAddressError(null); - }} - placeholder={t('layout.browser-url-placeholder', { - defaultValue: 'Enter a URL', - })} - aria-label={t('layout.browser-url-placeholder', { - defaultValue: 'Enter a URL', - })} - aria-invalid={Boolean(addressError)} - className={cn( - 'placeholder:text-input-label-default/10 h-[28px] w-full min-w-0 rounded-xl border-none bg-ds-bg-neutral-subtle-default px-3 text-body-sm text-ds-text-neutral-default-default outline-none transition-colors', - 'hover:bg-ds-bg-neutral-subtle-default hover:ring-1 hover:ring-ds-ring-neutral-strong-default hover:ring-offset-0', - 'focus:bg-ds-bg-neutral-subtle-default focus:ring-1 focus:ring-ds-ring-brand-default-focus focus:ring-offset-0', - addressError - ? 'border-ds-border-status-error-default-default' - : 'border-ds-border-neutral-default-default' - )} - /> -
- - - -
- {addressError ? ( -

- {addressError} -

- ) : null} -
- {!activeTab.url ? ( -
- {t('layout.browser-blank', { - defaultValue: 'Enter a URL to start browsing.', - })} -
- ) : !isDesktop ? ( -
- {t('layout.browser-desktop-only', { - defaultValue: - 'Embedded browsing is available in the desktop app. This URL opened in your system browser.', - })} + +
- ) : null} -
+ ); + })}
- ) : ( - - )} +
+
+ + + +
+ +
+ {renderActiveContent()}
); diff --git a/src/components/Session/PreviewPanel/tabKinds.tsx b/src/components/Session/PreviewPanel/tabKinds.tsx new file mode 100644 index 000000000..ef1dc6040 --- /dev/null +++ b/src/components/Session/PreviewPanel/tabKinds.tsx @@ -0,0 +1,96 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import type { PreviewTabKind, SessionPreviewTab } from '@/store/pageTabStore'; +import { + ClipboardCheck, + FileText, + Globe, + type LucideIcon, + PanelsTopLeft, + Shapes, + SquareTerminal, +} from 'lucide-react'; + +export interface PreviewKindMeta { + kind: PreviewTabKind; + icon: LucideIcon; + /** i18n key + fallback used for the tab title and chooser row label. */ + labelKey: string; + defaultLabel: string; + /** One-line description shown in the chooser. */ + descriptionKey: string; + defaultDescription: string; +} + +/** + * The content kinds the chooser offers, in display order. Single source of + * truth for icon + copy so the tab strip and chooser never drift. + */ +export const PREVIEW_TAB_KINDS: PreviewKindMeta[] = [ + { + kind: 'browser', + icon: Globe, + labelKey: 'layout.preview-kind-browser', + defaultLabel: 'Browser', + descriptionKey: 'layout.preview-kind-browser-desc', + defaultDescription: 'Open and navigate web pages in an embedded browser.', + }, + { + kind: 'file', + icon: FileText, + labelKey: 'layout.preview-kind-file', + defaultLabel: 'Files', + descriptionKey: 'layout.preview-kind-file-desc', + defaultDescription: 'Preview files produced or referenced in this session.', + }, + { + kind: 'review', + icon: ClipboardCheck, + labelKey: 'layout.preview-kind-review', + defaultLabel: 'Review', + descriptionKey: 'layout.preview-kind-review-desc', + defaultDescription: 'Review changes and diffs before applying them.', + }, + { + kind: 'terminal', + icon: SquareTerminal, + labelKey: 'layout.preview-kind-terminal', + defaultLabel: 'Terminal', + descriptionKey: 'layout.preview-kind-terminal-desc', + defaultDescription: 'Inspect command output from the running task.', + }, + { + kind: 'canvas', + icon: Shapes, + labelKey: 'layout.preview-kind-canvas', + defaultLabel: 'Canvas', + descriptionKey: 'layout.preview-kind-canvas-desc', + defaultDescription: 'Sketch, connect, and arrange ideas on a free canvas.', + }, +]; + +const KIND_ICONS: Record = { + chooser: PanelsTopLeft, + browser: Globe, + file: FileText, + review: ClipboardCheck, + terminal: SquareTerminal, + canvas: Shapes, +}; + +/** Icon for any tab (including the chooser) — used by the tab strip. */ +export function previewTabIcon(type: SessionPreviewTab['type']): LucideIcon { + return KIND_ICONS[type]; +} diff --git a/src/components/Session/PreviewPanel/tabs/CanvasTab.tsx b/src/components/Session/PreviewPanel/tabs/CanvasTab.tsx new file mode 100644 index 000000000..ab991730b --- /dev/null +++ b/src/components/Session/PreviewPanel/tabs/CanvasTab.tsx @@ -0,0 +1,91 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { + addEdge, + Background, + BackgroundVariant, + type Connection, + Controls, + type Edge, + MiniMap, + type Node, + ReactFlow, + ReactFlowProvider, + useEdgesState, + useNodesState, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import { useCallback } from 'react'; + +const INITIAL_NODES: Node[] = [ + { + id: 'start', + type: 'input', + position: { x: 0, y: 40 }, + data: { label: 'Start' }, + }, + { + id: 'idea', + position: { x: 220, y: 160 }, + data: { label: 'Idea' }, + }, +]; + +const INITIAL_EDGES: Edge[] = [ + { id: 'start-idea', source: 'start', target: 'idea' }, +]; + +function CanvasFlow() { + const [nodes, , onNodesChange] = useNodesState(INITIAL_NODES); + const [edges, setEdges, onEdgesChange] = useEdgesState(INITIAL_EDGES); + + const onConnect = useCallback( + (connection: Connection) => setEdges((eds) => addEdge(connection, eds)), + [setEdges] + ); + + return ( + + + + + + ); +} + +/** + * Free-form React Flow canvas. Each canvas tab keeps its own flow state in its + * own ReactFlowProvider so it stays isolated from the workspace workflow graph. + */ +export function CanvasTab() { + return ( +
+ + + +
+ ); +} + +export default CanvasTab; diff --git a/src/components/Session/PreviewPanel/tabs/ChooserTab.tsx b/src/components/Session/PreviewPanel/tabs/ChooserTab.tsx new file mode 100644 index 000000000..ee438d9df --- /dev/null +++ b/src/components/Session/PreviewPanel/tabs/ChooserTab.tsx @@ -0,0 +1,84 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { cn } from '@/lib/utils'; +import type { PreviewTabKind } from '@/store/pageTabStore'; +import { ChevronRight } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { PREVIEW_TAB_KINDS } from '../tabKinds'; + +export interface ChooserTabProps { + /** Open the given content kind (replaces this chooser tab in place). */ + onChoose: (kind: PreviewTabKind) => void; +} + +/** + * The default starter tab. Lists every content kind as a vertical row; picking + * one turns this tab into that kind via the store's `choosePreviewTabType`. + */ +export function ChooserTab({ onChoose }: ChooserTabProps) { + const { t } = useTranslation(); + + return ( +
+
+

+ {t('layout.preview-chooser-title', { + defaultValue: 'Open a new view', + })} +

+
+ {PREVIEW_TAB_KINDS.map( + ({ + kind, + icon: Icon, + labelKey, + defaultLabel, + descriptionKey, + defaultDescription, + }) => ( + + ) + )} +
+
+
+ ); +} + +export default ChooserTab; diff --git a/src/components/Session/PreviewPanel/tabs/FileTab.tsx b/src/components/Session/PreviewPanel/tabs/FileTab.tsx new file mode 100644 index 000000000..64180af72 --- /dev/null +++ b/src/components/Session/PreviewPanel/tabs/FileTab.tsx @@ -0,0 +1,35 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { FilePreview } from '@/components/Folder/FilePreview'; +import type { SessionFileTab } from '@/store/pageTabStore'; + +export interface FileTabProps { + tab: SessionFileTab; + onJumpToContext?: (file: FileInfo | null) => void; +} + +/** File preview surface for one file tab. */ +export function FileTab({ tab, onJumpToContext }: FileTabProps) { + return ( + + ); +} + +export default FileTab; diff --git a/src/components/Session/PreviewPanel/tabs/ReviewTab.tsx b/src/components/Session/PreviewPanel/tabs/ReviewTab.tsx new file mode 100644 index 000000000..654089cf9 --- /dev/null +++ b/src/components/Session/PreviewPanel/tabs/ReviewTab.tsx @@ -0,0 +1,25 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +/** + * Review surface — intentionally blank for now. Content (diff/change review) + * will be added later; this reserves the tab type and its container. + */ +export function ReviewTab() { + return ( +
+ ); +} + +export default ReviewTab; diff --git a/src/components/Session/PreviewPanel/tabs/TerminalTab.tsx b/src/components/Session/PreviewPanel/tabs/TerminalTab.tsx new file mode 100644 index 000000000..276450f91 --- /dev/null +++ b/src/components/Session/PreviewPanel/tabs/TerminalTab.tsx @@ -0,0 +1,38 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { useTranslation } from 'react-i18next'; + +/** + * Terminal surface. Rendered as a terminal-styled placeholder for now; the + * container and tab type are in place so a live PTY / agent terminal (the + * existing xterm `Terminal` component) can be dropped in later. + */ +export function TerminalTab() { + const { t } = useTranslation(); + return ( +
+
+
Eigent:~$
+
+ {t('layout.preview-terminal-placeholder', { + defaultValue: 'Terminal output will appear here.', + })} +
+
+
+ ); +} + +export default TerminalTab; diff --git a/src/components/Session/PreviewPanel/tabs/browser/BrowserTab.tsx b/src/components/Session/PreviewPanel/tabs/browser/BrowserTab.tsx new file mode 100644 index 000000000..85b2aa56a --- /dev/null +++ b/src/components/Session/PreviewPanel/tabs/browser/BrowserTab.tsx @@ -0,0 +1,267 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { Button } from '@/components/ui/button'; +import { TooltipSimple } from '@/components/ui/tooltip'; +import { normalizeBrowserUrl } from '@/lib/browserUrl'; +import { cn } from '@/lib/utils'; +import { type SessionBrowserTab, usePageTabStore } from '@/store/pageTabStore'; +import { ArrowLeft, ArrowRight, ExternalLink, RefreshCw } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getPreviewWebview } from './webviewRegistry'; + +export interface BrowserTabProps { + tab: SessionBrowserTab; + /** Desktop host embeds a real ; web falls back to opening tabs. */ + isDesktop: boolean; +} + +/** + * Browser chrome (toolbar + address bar) for one browser tab. The page itself + * is a `` guest owned by PreviewBrowserLayer; this component publishes + * the rect the guest should fill via `previewBrowserViewport` and drives + * navigation through the webview registry. Keyed by tab id upstream so each + * browser tab keeps its own address state. + */ +export function BrowserTab({ tab, isDesktop }: BrowserTabProps) { + const { t } = useTranslation(); + const updateBrowserPreviewTab = usePageTabStore( + (state) => state.updateBrowserPreviewTab + ); + const setPreviewBrowserViewport = usePageTabStore( + (state) => state.setPreviewBrowserViewport + ); + + const containerRef = useRef(null); + const [addressInput, setAddressInput] = useState(tab.url); + const [addressError, setAddressError] = useState(null); + const nav = tab.navigation; + + useEffect(() => { + setAddressInput(tab.url); + setAddressError(null); + }, [tab.url, tab.id]); + + const navigateTo = useCallback( + async (rawUrl: string) => { + const normalized = normalizeBrowserUrl(rawUrl); + if (!normalized.ok) { + setAddressError(normalized.error); + return; + } + setAddressError(null); + setAddressInput(normalized.url); + + if (!isDesktop) { + // Web host: no embedded view — open in a regular browser tab instead. + updateBrowserPreviewTab(tab.id, { url: normalized.url }); + window.open(normalized.url, '_blank', 'noopener,noreferrer'); + return; + } + + const element = getPreviewWebview(tab.webviewId); + if (element?.loadURL) { + // Guest already mounted: navigate it in place (keeps history). + try { + await element.loadURL(normalized.url); + } catch (error) { + setAddressError( + error instanceof Error ? error.message : 'Unable to open this URL' + ); + } + return; + } + // No guest yet (blank tab): setting the URL mounts one in the layer. + updateBrowserPreviewTab(tab.id, { url: normalized.url }); + }, + [isDesktop, tab.id, tab.webviewId, updateBrowserPreviewTab] + ); + + const openExternal = useCallback((rawUrl: string) => { + const normalized = normalizeBrowserUrl(rawUrl); + if (!normalized.ok) { + setAddressError(normalized.error); + return; + } + setAddressError(null); + window.open(normalized.url, '_blank', 'noopener,noreferrer'); + }, []); + + // Publish this container's rect so the layer can position the guest over it. + // Cleared on unmount (switching tabs / closing) so guests park, not float. + useEffect(() => { + if (!isDesktop) return; + const container = containerRef.current; + if (!container) { + setPreviewBrowserViewport(null); + return; + } + const publish = () => { + const rect = container.getBoundingClientRect(); + setPreviewBrowserViewport({ + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }); + }; + publish(); + const observer = new ResizeObserver(publish); + observer.observe(container); + window.addEventListener('resize', publish); + return () => { + observer.disconnect(); + window.removeEventListener('resize', publish); + setPreviewBrowserViewport(null); + }; + }, [isDesktop, tab.id, setPreviewBrowserViewport]); + + const element = isDesktop ? getPreviewWebview(tab.webviewId) : undefined; + + return ( +
+
+ + + + + + + + + +
{ + event.preventDefault(); + void navigateTo(addressInput); + }} + > + { + setAddressInput(event.target.value); + if (addressError) setAddressError(null); + }} + placeholder={t('layout.browser-url-placeholder', { + defaultValue: 'Enter a URL', + })} + aria-label={t('layout.browser-url-placeholder', { + defaultValue: 'Enter a URL', + })} + aria-invalid={Boolean(addressError)} + className={cn( + 'placeholder:text-input-label-default/10 h-[28px] w-full min-w-0 rounded-xl border-none bg-ds-bg-neutral-subtle-default px-3 text-body-sm text-ds-text-neutral-default-default outline-none transition-colors', + 'hover:bg-ds-bg-neutral-subtle-default hover:ring-1 hover:ring-ds-ring-neutral-strong-default hover:ring-offset-0', + 'focus:bg-ds-bg-neutral-subtle-default focus:ring-1 focus:ring-ds-ring-brand-default-focus focus:ring-offset-0', + addressError + ? 'border-ds-border-status-error-default-default' + : 'border-ds-border-neutral-default-default' + )} + /> +
+ + + +
+ {addressError ? ( +

+ {addressError} +

+ ) : null} + {/* The guest is positioned over this container by + PreviewBrowserLayer via the published viewport rect. */} +
+ {!tab.url ? ( +
+ {t('layout.browser-blank', { + defaultValue: 'Enter a URL to start browsing.', + })} +
+ ) : !isDesktop ? ( +
+ {t('layout.browser-desktop-only', { + defaultValue: + 'Embedded browsing is available in the desktop app. This URL opened in your system browser.', + })} +
+ ) : null} +
+
+ ); +} + +export default BrowserTab; diff --git a/src/components/Session/PreviewPanel/tabs/browser/PreviewBrowserLayer.tsx b/src/components/Session/PreviewPanel/tabs/browser/PreviewBrowserLayer.tsx new file mode 100644 index 000000000..111d06d50 --- /dev/null +++ b/src/components/Session/PreviewPanel/tabs/browser/PreviewBrowserLayer.tsx @@ -0,0 +1,225 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { useHost } from '@/host'; +import { + type PreviewBrowserViewport, + type SessionBrowserNavigationState, + type SessionBrowserTab, + usePageTabStore, +} from '@/store/pageTabStore'; +import { useEffect, useRef, useState } from 'react'; +import { + type PreviewWebviewElement, + registerPreviewWebview, + unregisterPreviewWebview, +} from './webviewRegistry'; + +/** + * Hosts the preview panel's embedded browsers as Electron `` tags. + * + * Why a separate, always-mounted layer instead of rendering inside the panel: + * - `` is a DOM element, so dropdowns/dialogs overlay it naturally + * (unlike a native WebContentsView, which paints above the whole document). + * - A `` reloads whenever it is detached or reparented. Keeping the + * elements here — positioned over the panel via `previewBrowserViewport`, + * parked offscreen when not visible — preserves each guest's page state and + * full back/forward history across panel close, tab hops, and project + * switches for the lifetime of the workspace page. + * + * Mount once (in the workspace page). Renders nothing on the web host. + */ + +/** Above page content, below portaled overlays (menus z-50, tooltips z-100). */ +const GUEST_Z_INDEX = 30; +/** Matches the preview panel's rounded-xl browser container. */ +const GUEST_RADIUS = 12; + +const PARKED_STYLE: React.CSSProperties = { + position: 'fixed', + left: -10_000, + top: 0, + width: 1024, + height: 640, + visibility: 'hidden', + pointerEvents: 'none', +}; + +function visibleStyle(viewport: PreviewBrowserViewport): React.CSSProperties { + return { + position: 'fixed', + left: viewport.x, + top: viewport.y, + width: viewport.width, + height: viewport.height, + zIndex: GUEST_Z_INDEX, + borderRadius: GUEST_RADIUS, + overflow: 'hidden', + }; +} + +function browserTitle(state: SessionBrowserNavigationState): string { + const explicitTitle = state.title.trim(); + if (explicitTitle) return explicitTitle; + if (!state.url || state.url.startsWith('about:')) return 'New tab'; + try { + return new URL(state.url).hostname || 'New tab'; + } catch { + return 'New tab'; + } +} + +const GUEST_EVENTS = [ + 'did-navigate', + 'did-navigate-in-page', + 'did-start-loading', + 'did-stop-loading', + 'page-title-updated', + 'dom-ready', +] as const; + +interface PreviewGuestProps { + projectId: string; + tab: SessionBrowserTab; + visible: boolean; + viewport: PreviewBrowserViewport | null; +} + +function PreviewGuest({ + projectId, + tab, + visible, + viewport, +}: PreviewGuestProps) { + const containerRef = useRef(null); + // The guest reloads if its src attribute changes, so freeze the mount-time + // URL; later navigation happens on the element (loadURL / link clicks) and + // flows back into the store through the events below. + const initialUrlRef = useRef(tab.url); + const tabIdRef = useRef(tab.id); + useEffect(() => { + tabIdRef.current = tab.id; + }, [tab.id]); + + useEffect(() => { + const container = containerRef.current; + if (!container || !initialUrlRef.current) return; + + const element = document.createElement('webview') as PreviewWebviewElement; + element.setAttribute('src', initialUrlRef.current); + element.setAttribute('partition', 'persist:session-preview'); + // Popups are redirected into the same guest by the main process + // (did-attach-webview → setWindowOpenHandler), so target=_blank links work. + element.setAttribute('allowpopups', 'true'); + element.style.display = 'flex'; + element.style.width = '100%'; + element.style.height = '100%'; + + const notify = () => { + // Guest methods throw until the webview is attached; treat as no state. + let state: SessionBrowserNavigationState; + try { + state = { + url: element.getURL?.() ?? '', + title: element.getTitle?.() ?? '', + isLoading: element.isLoading?.() ?? false, + canGoBack: element.canGoBack?.() ?? false, + canGoForward: element.canGoForward?.() ?? false, + }; + } catch { + return; + } + const url = state.url.startsWith('about:') ? '' : state.url; + usePageTabStore + .getState() + .updateBrowserPreviewTabIn(projectId, tabIdRef.current, { + title: browserTitle(state), + navigation: { ...state, url }, + // Guests mount only while the tab has a URL, so never write an + // empty one back (early events can fire before a location exists). + ...(url ? { url } : {}), + }); + }; + + GUEST_EVENTS.forEach((event) => element.addEventListener(event, notify)); + container.appendChild(element); + registerPreviewWebview(tab.webviewId, element); + + return () => { + GUEST_EVENTS.forEach((event) => + element.removeEventListener(event, notify) + ); + unregisterPreviewWebview(tab.webviewId); + element.remove(); + }; + }, [projectId, tab.webviewId]); + + return ( +
+ ); +} + +export function PreviewBrowserLayer() { + const host = useHost(); + const byProject = usePageTabStore((s) => s.sessionPreviewByProject); + const scopeProjectId = usePageTabStore((s) => s.sessionPreviewProjectId); + const viewport = usePageTabStore((s) => s.previewBrowserViewport); + + // Persisted slices may reference many projects; only mount guests for + // projects the user has actually visited this run so startup stays light. + const [activatedProjects, setActivatedProjects] = useState>( + () => new Set() + ); + useEffect(() => { + if (!scopeProjectId) return; + setActivatedProjects((current) => { + if (current.has(scopeProjectId)) return current; + const next = new Set(current); + next.add(scopeProjectId); + return next; + }); + }, [scopeProjectId]); + + // Embedded browsing needs the desktop host's tag. + if (!host?.electronAPI) return null; + + const guests: React.ReactNode[] = []; + for (const [projectId, slice] of Object.entries(byProject)) { + if (!activatedProjects.has(projectId)) continue; + for (const tab of slice.tabs) { + if (tab.type !== 'browser' || !tab.url) continue; + const visible = + projectId === scopeProjectId && + slice.open && + slice.activeTabId === tab.id; + guests.push( + + ); + } + } + + return <>{guests}; +} + +export default PreviewBrowserLayer; diff --git a/src/components/Session/PreviewPanel/tabs/browser/webviewRegistry.ts b/src/components/Session/PreviewPanel/tabs/browser/webviewRegistry.ts new file mode 100644 index 000000000..9c280f348 --- /dev/null +++ b/src/components/Session/PreviewPanel/tabs/browser/webviewRegistry.ts @@ -0,0 +1,58 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +/** + * Minimal surface of Electron's `` tag that the preview uses. Typed + * locally (instead of importing electron types) so the renderer stays + * host-agnostic: on the web the element never mounts and none of this runs. + * All methods are optional — they only exist once the guest is attached. + */ +export interface PreviewWebviewElement extends HTMLElement { + src?: string; + loadURL?: (url: string) => Promise; + getURL?: () => string; + getTitle?: () => string; + isLoading?: () => boolean; + canGoBack?: () => boolean; + canGoForward?: () => boolean; + goBack?: () => void; + goForward?: () => void; + reload?: () => void; + stop?: () => void; +} + +/** + * Live `` elements keyed by the store's webviewId. The browser layer + * registers elements as they mount; the preview panel's toolbar and address + * bar drive navigation through this without owning the elements (which must + * outlive the panel so guests keep their history). + */ +const registry = new Map(); + +export function registerPreviewWebview( + webviewId: string, + element: PreviewWebviewElement +): void { + registry.set(webviewId, element); +} + +export function unregisterPreviewWebview(webviewId: string): void { + registry.delete(webviewId); +} + +export function getPreviewWebview( + webviewId: string +): PreviewWebviewElement | undefined { + return registry.get(webviewId); +} diff --git a/src/components/Session/index.tsx b/src/components/Session/index.tsx index 526670c56..7b2eca2a6 100644 --- a/src/components/Session/index.tsx +++ b/src/components/Session/index.tsx @@ -18,7 +18,6 @@ import { PreviewPanel } from '@/components/Session/PreviewPanel'; import Workspace from '@/components/Workspace'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; import { useSelectedProjectTurn } from '@/hooks/useSelectedProjectTurn'; -import { useHost } from '@/host'; import { inferSessionModeFromTask } from '@/lib/sessionMode'; import { cn } from '@/lib/utils'; import { usePageTabStore } from '@/store/pageTabStore'; @@ -57,12 +56,21 @@ interface SessionProps { export default function Session({ isNewProject = false }: SessionProps) { const { chatStore, projectStore } = useChatStoreAdapter(); - const host = useHost(); const activeWorkspaceTab = usePageTabStore((s) => s.activeWorkspaceTab); const setActiveWorkspaceTab = usePageTabStore((s) => s.setActiveWorkspaceTab); - const previewOpen = usePageTabStore((s) => s.sessionPreviewOpen); const closeSessionPreview = usePageTabStore((s) => s.closeSessionPreview); - const resetSessionPreview = usePageTabStore((s) => s.resetSessionPreview); + const setSessionPreviewProject = usePageTabStore( + (s) => s.setSessionPreviewProject + ); + const sessionPreviewProjectId = usePageTabStore( + (s) => s.sessionPreviewProjectId + ); + // Only trust the preview mirror once it points at this project — on switch + // the scope effect below lags the first render by one frame, and rendering + // the stale slice would flash (and re-show) the previous project's browser. + const previewOpen = + usePageTabStore((s) => s.sessionPreviewOpen) && + sessionPreviewProjectId === (projectStore.activeProjectId ?? null); const activeProjectId = projectStore.activeProjectId; const isHistoryLoadingActiveProject = useProjectRuntimeStore((s) => activeProjectId @@ -234,21 +242,13 @@ export default function Session({ isNewProject = false }: SessionProps) { const chatRowRef = useRef(null); const [chatWidth, setChatWidth] = useState(CHAT_PRIORITY_WIDTH); const [isResizingPreview, setIsResizingPreview] = useState(false); - const prevProjectIdRef = useRef(activeProjectId); + // Point the preview store at this project. Its saved tabs (and, within this + // app run, the live webviews behind them) are restored on switch-back — + // webviews are intentionally NOT destroyed here so history survives. useEffect(() => { - const previousProjectId = prevProjectIdRef.current; - if (previousProjectId && previousProjectId !== activeProjectId) { - usePageTabStore - .getState() - .sessionPreviewTabs.filter((tab) => tab.type === 'browser') - .forEach((tab) => { - void host?.electronAPI?.webviewDestroy?.(tab.webviewId); - }); - resetSessionPreview(); - } - prevProjectIdRef.current = activeProjectId; - }, [activeProjectId, host, resetSessionPreview]); + setSessionPreviewProject(activeProjectId ?? null); + }, [activeProjectId, setSessionPreviewProject]); // Opening display auto-folds the session side panel and collapses chat. useEffect(() => { @@ -258,14 +258,6 @@ export default function Session({ isNewProject = false }: SessionProps) { } }, [previewOpen]); - // The preview is a project-page concern; close it when leaving that tab or - // switching projects so it never lingers over an unrelated view. - useEffect(() => { - if (activeWorkspaceTab !== 'project') { - closeSessionPreview(); - } - }, [activeWorkspaceTab, closeSessionPreview]); - const handlePreviewResizeStart = useCallback( (e: React.PointerEvent) => { e.preventDefault(); diff --git a/src/pages/Workspace.tsx b/src/pages/Workspace.tsx index 239478fde..ec50e3729 100644 --- a/src/pages/Workspace.tsx +++ b/src/pages/Workspace.tsx @@ -57,6 +57,7 @@ import { } from '../components/Trigger/Triggers'; import Session from '@/components/Session'; +import { PreviewBrowserLayer } from '@/components/Session/PreviewPanel/tabs/browser/PreviewBrowserLayer'; import { ResizableHandle, ResizablePanel, @@ -752,16 +753,16 @@ export default function WorkspacePage() { return ( -
+
@@ -789,7 +790,7 @@ export default function WorkspacePage() {
{renderActiveWorkspaceTab()} @@ -799,6 +800,10 @@ export default function WorkspacePage() {
+ {/* Always mounted: hosts preview guests so their pages and + history survive panel close, workspace-tab hops, and project + switches. Renders nothing on the web host. */} +
); diff --git a/src/store/pageTabStore.ts b/src/store/pageTabStore.ts index 9851c9485..39f59580b 100644 --- a/src/store/pageTabStore.ts +++ b/src/store/pageTabStore.ts @@ -56,23 +56,89 @@ export interface SessionFileTab { file: FileInfo | null; } -export type SessionPreviewTab = SessionBrowserTab | SessionFileTab; +/** Blank starter tab that lets the user pick which content type to open. */ +export interface SessionChooserTab { + id: string; + type: 'chooser'; + title: string; +} + +/** Code/diff review surface (content wired up later). */ +export interface SessionReviewTab { + id: string; + type: 'review'; + title: string; +} + +export interface SessionTerminalTab { + id: string; + type: 'terminal'; + title: string; +} + +/** Free-form React Flow canvas. */ +export interface SessionCanvasTab { + id: string; + type: 'canvas'; + title: string; +} + +export type SessionPreviewTab = + | SessionChooserTab + | SessionBrowserTab + | SessionFileTab + | SessionReviewTab + | SessionTerminalTab + | SessionCanvasTab; + +/** + * Content types the chooser can open. `chooser` is intentionally excluded — + * it is the picker itself, not a destination. + */ +export type PreviewTabKind = Exclude; + +export interface PreviewBrowserViewport { + x: number; + y: number; + width: number; + height: number; +} + +/** + * Per-project preview panel state. Keyed by project id so switching sessions + * restores the tabs (and, within an app run, the live webviews behind them). + */ +export interface SessionPreviewSlice { + open: boolean; + tabs: SessionPreviewTab[]; + activeTabId: string | null; +} + +const EMPTY_SESSION_PREVIEW: SessionPreviewSlice = { + open: false, + tabs: [], + activeTabId: null, +}; let sessionPreviewTabSequence = 0; +// Random per-run seed so ids never collide with tabs restored from persistence. +const sessionPreviewTabIdSeed = Math.random().toString(36).slice(2, 8); function nextSessionPreviewTabId(type: SessionPreviewTab['type']): string { sessionPreviewTabSequence += 1; - return `${type}-${sessionPreviewTabSequence}`; + return `${type}-${sessionPreviewTabIdSeed}-${sessionPreviewTabSequence}`; } -function createBrowserPreviewTab(): SessionBrowserTab { +function createBrowserPreviewTab(projectId: string | null): SessionBrowserTab { const id = nextSessionPreviewTabId('browser'); return { id, type: 'browser', title: 'New tab', url: '', - webviewId: `session-preview:${id}`, + // Project-scoped so each session keeps its own native webviews (and their + // navigation history) alive while the app runs. + webviewId: `session-preview:${projectId ?? 'global'}:${id}`, navigation: { url: '', title: '', @@ -92,13 +158,82 @@ function createFilePreviewTab(file: FileInfo | null = null): SessionFileTab { }; } +function createChooserPreviewTab(): SessionChooserTab { + return { + id: nextSessionPreviewTabId('chooser'), + type: 'chooser', + title: 'New tab', + }; +} + +/** Build a fresh tab of the requested kind. Browser tabs need the project id. */ +function createPreviewTabOfKind( + kind: PreviewTabKind, + projectId: string | null +): SessionPreviewTab { + switch (kind) { + case 'browser': + return createBrowserPreviewTab(projectId); + case 'file': + return createFilePreviewTab(); + case 'review': + return { + id: nextSessionPreviewTabId('review'), + type: 'review', + title: 'Review', + }; + case 'terminal': + return { + id: nextSessionPreviewTabId('terminal'), + type: 'terminal', + title: 'Terminal', + }; + case 'canvas': + return { + id: nextSessionPreviewTabId('canvas'), + type: 'canvas', + title: 'Canvas', + }; + } +} + function createInitialSessionPreviewTabs(): { tabs: SessionPreviewTab[]; activeTabId: string; } { - const browserTab = createBrowserPreviewTab(); - const fileTab = createFilePreviewTab(); - return { tabs: [browserTab, fileTab], activeTabId: browserTab.id }; + // Open onto the chooser so the user picks what the first tab becomes. + const chooser = createChooserPreviewTab(); + return { tabs: [chooser], activeTabId: chooser.id }; +} + +/** + * Strip runtime-only navigation state before persisting: after an app restart + * the native webview (and its history) is gone, so only url/title survive. + */ +function sanitizeSessionPreviewForPersist( + slices: Record +): Record { + const result: Record = {}; + for (const [projectId, slice] of Object.entries(slices)) { + result[projectId] = { + ...slice, + tabs: slice.tabs.map((tab) => + tab.type === 'browser' + ? { + ...tab, + navigation: { + url: tab.url, + title: tab.title, + isLoading: false, + canGoBack: false, + canGoForward: false, + }, + } + : tab + ), + }; + } + return result; } interface PageTabState { @@ -205,16 +340,36 @@ interface PageTabState { ) => void; // ── Inline session preview (project page) ───────────────────────────────── + /** + * Project whose preview slice is currently mirrored into the flat + * `sessionPreview*` fields below. Set by the Session page on mount/switch. + */ + sessionPreviewProjectId: string | null; + /** Preview panel state per project — persisted so sessions restore. */ + sessionPreviewByProject: Record; + /** Point the preview mirror at a project, restoring its saved slice. */ + setSessionPreviewProject: (projectId: string | null) => void; sessionPreviewOpen: boolean; sessionPreviewTabs: SessionPreviewTab[]; activeSessionPreviewTabId: string | null; - /** Toggle the unified browser/file preview panel. */ + /** + * Window-fixed rect the active embedded browser should occupy, published by + * the preview panel while a browser tab is visible. `null` parks all guests. + * The always-mounted PreviewBrowserLayer positions `` elements from + * this so guests (and their history) survive panel close / project switch. + */ + previewBrowserViewport: PreviewBrowserViewport | null; + setPreviewBrowserViewport: (rect: PreviewBrowserViewport | null) => void; + /** Toggle the unified preview panel (opens onto the chooser tab). */ toggleSessionPreview: () => void; - /** Add and activate a blank browser tab. */ - addBrowserPreviewTab: () => void; - /** Add and activate an empty file tab. */ - addFilePreviewTab: () => void; - /** Open a file in a deduplicated file tab. */ + /** Add and activate a blank chooser tab (the "+" button). */ + addChooserPreviewTab: () => void; + /** + * Turn a tab (typically the chooser) into the chosen content kind, in place. + * Falls back to appending if the target tab no longer exists. + */ + choosePreviewTabType: (tabId: string, kind: PreviewTabKind) => void; + /** Open a file in a deduplicated file tab (reuses a blank starter tab). */ openFilePreview: (file?: FileInfo | null) => void; selectSessionPreviewTab: (tabId: string) => void; closeSessionPreviewTab: (tabId: string) => void; @@ -222,10 +377,55 @@ interface PageTabState { tabId: string, patch: Partial> ) => void; + /** + * Same as updateBrowserPreviewTab but addressed to an explicit project — + * used by the browser layer, whose guests emit navigation events even for + * projects that are not the current preview scope. + */ + updateBrowserPreviewTabIn: ( + projectId: string, + tabId: string, + patch: Partial> + ) => void; closeSessionPreview: () => void; resetSessionPreview: () => void; } +type SetPageTabState = ( + partial: + | Partial + | ((state: PageTabState) => Partial | PageTabState) +) => void; + +/** + * Apply a preview mutation to both the flat mirror fields (what components + * read) and the per-project record (what persists / restores). Return `null` + * from the updater to bail without changes. + */ +function setSessionPreviewSlice( + set: SetPageTabState, + updater: (state: PageTabState) => SessionPreviewSlice | null +) { + set((state) => { + const slice = updater(state); + if (!slice) return state; + const projectId = state.sessionPreviewProjectId; + return { + sessionPreviewOpen: slice.open, + sessionPreviewTabs: slice.tabs, + activeSessionPreviewTabId: slice.activeTabId, + ...(projectId + ? { + sessionPreviewByProject: { + ...state.sessionPreviewByProject, + [projectId]: slice, + }, + } + : {}), + }; + }); +} + export const usePageTabStore = create()( persist( (set) => ({ @@ -412,50 +612,81 @@ export const usePageTabStore = create()( setScrollToTurnRequest: (request) => set({ scrollToTurnRequest: request }), + sessionPreviewProjectId: null, + sessionPreviewByProject: {}, sessionPreviewOpen: false, sessionPreviewTabs: [], activeSessionPreviewTabId: null, - toggleSessionPreview: () => + previewBrowserViewport: null, + setPreviewBrowserViewport: (rect) => + set({ previewBrowserViewport: rect }), + setSessionPreviewProject: (projectId) => set((state) => { + if (state.sessionPreviewProjectId === projectId) return state; + const slice = + (projectId ? state.sessionPreviewByProject[projectId] : null) ?? + EMPTY_SESSION_PREVIEW; + return { + sessionPreviewProjectId: projectId, + sessionPreviewOpen: slice.open, + sessionPreviewTabs: slice.tabs, + activeSessionPreviewTabId: slice.activeTabId, + }; + }), + toggleSessionPreview: () => + setSessionPreviewSlice(set, (state) => { if (state.sessionPreviewOpen) { - return { sessionPreviewOpen: false }; + return { + open: false, + tabs: state.sessionPreviewTabs, + activeTabId: state.activeSessionPreviewTabId, + }; } if (state.sessionPreviewTabs.length > 0) { - return { sessionPreviewOpen: true }; + return { + open: true, + tabs: state.sessionPreviewTabs, + activeTabId: state.activeSessionPreviewTabId, + }; } const initial = createInitialSessionPreviewTabs(); return { - sessionPreviewOpen: true, - sessionPreviewTabs: initial.tabs, - activeSessionPreviewTabId: initial.activeTabId, + open: true, + tabs: initial.tabs, + activeTabId: initial.activeTabId, }; }), - addBrowserPreviewTab: () => - set((state) => { - const tab = createBrowserPreviewTab(); + addChooserPreviewTab: () => + setSessionPreviewSlice(set, (state) => { + const tab = createChooserPreviewTab(); return { - sessionPreviewOpen: true, - sessionPreviewTabs: [...state.sessionPreviewTabs, tab], - activeSessionPreviewTabId: tab.id, + open: true, + tabs: [...state.sessionPreviewTabs, tab], + activeTabId: tab.id, }; }), - addFilePreviewTab: () => - set((state) => { - const tab = createFilePreviewTab(); - return { - sessionPreviewOpen: true, - sessionPreviewTabs: [...state.sessionPreviewTabs, tab], - activeSessionPreviewTabId: tab.id, - }; + choosePreviewTabType: (tabId, kind) => + setSessionPreviewSlice(set, (state) => { + const tab = createPreviewTabOfKind( + kind, + state.sessionPreviewProjectId + ); + const index = state.sessionPreviewTabs.findIndex( + (candidate) => candidate.id === tabId + ); + const tabs = [...state.sessionPreviewTabs]; + if (index >= 0) { + // Replace the chooser in place so the tab keeps its position. + tabs[index] = tab; + } else { + tabs.push(tab); + } + return { open: true, tabs, activeTabId: tab.id }; }), openFilePreview: (file) => - set((state) => { + setSessionPreviewSlice(set, (state) => { const targetFile = file ?? null; - const initial = - state.sessionPreviewTabs.length === 0 - ? createInitialSessionPreviewTabs() - : null; - const previewTabs = initial?.tabs ?? state.sessionPreviewTabs; + const previewTabs = state.sessionPreviewTabs; const matchingTab = targetFile ? previewTabs.find( (tab) => @@ -466,96 +697,120 @@ export const usePageTabStore = create()( ); if (matchingTab) { return { - sessionPreviewOpen: true, - sessionPreviewTabs: previewTabs, - activeSessionPreviewTabId: matchingTab.id, + open: true, + tabs: previewTabs, + activeTabId: matchingTab.id, }; } - const emptyFileTabIndex = targetFile - ? (() => { - const activeIndex = previewTabs.findIndex( - (tab) => - tab.id === state.activeSessionPreviewTabId && - tab.type === 'file' && - tab.file === null - ); - return activeIndex >= 0 - ? activeIndex - : previewTabs.findIndex( - (tab) => tab.type === 'file' && tab.file === null - ); - })() - : -1; - if (emptyFileTabIndex >= 0) { + // Reuse a "blank" starter tab (empty file, or the chooser) in place — + // preferring the active one — so opening a file doesn't pile up tabs. + const isReusable = (tab: SessionPreviewTab) => + tab.type === 'chooser' || + (tab.type === 'file' && tab.file === null); + const reuseIndex = (() => { + const activeIndex = previewTabs.findIndex( + (tab) => + tab.id === state.activeSessionPreviewTabId && isReusable(tab) + ); + return activeIndex >= 0 + ? activeIndex + : previewTabs.findIndex(isReusable); + })(); + if (reuseIndex >= 0) { const tabs = [...previewTabs]; - const current = tabs[emptyFileTabIndex] as SessionFileTab; - const replacement: SessionFileTab = { - ...current, - title: targetFile?.name || 'Open file', - file: targetFile, - }; - tabs[emptyFileTabIndex] = replacement; - return { - sessionPreviewOpen: true, - sessionPreviewTabs: tabs, - activeSessionPreviewTabId: replacement.id, - }; + tabs[reuseIndex] = createFilePreviewTab(targetFile); + return { open: true, tabs, activeTabId: tabs[reuseIndex].id }; } const tab = createFilePreviewTab(targetFile); return { - sessionPreviewOpen: true, - sessionPreviewTabs: [...previewTabs, tab], - activeSessionPreviewTabId: tab.id, + open: true, + tabs: [...previewTabs, tab], + activeTabId: tab.id, }; }), selectSessionPreviewTab: (tabId) => - set((state) => + setSessionPreviewSlice(set, (state) => state.sessionPreviewTabs.some((tab) => tab.id === tabId) - ? { activeSessionPreviewTabId: tabId } - : state + ? { + open: state.sessionPreviewOpen, + tabs: state.sessionPreviewTabs, + activeTabId: tabId, + } + : null ), closeSessionPreviewTab: (tabId) => - set((state) => { + setSessionPreviewSlice(set, (state) => { const closingIndex = state.sessionPreviewTabs.findIndex( (tab) => tab.id === tabId ); - if (closingIndex < 0) return state; + if (closingIndex < 0) return null; const tabs = state.sessionPreviewTabs.filter( (tab) => tab.id !== tabId ); if (tabs.length === 0) { - return { - sessionPreviewOpen: false, - sessionPreviewTabs: [], - activeSessionPreviewTabId: null, - }; + return { open: false, tabs: [], activeTabId: null }; } if (state.activeSessionPreviewTabId !== tabId) { - return { sessionPreviewTabs: tabs }; + return { + open: state.sessionPreviewOpen, + tabs, + activeTabId: state.activeSessionPreviewTabId, + }; } const nextTab = tabs[Math.min(closingIndex, tabs.length - 1)]; return { - sessionPreviewTabs: tabs, - activeSessionPreviewTabId: nextTab.id, + open: state.sessionPreviewOpen, + tabs, + activeTabId: nextTab.id, }; }), updateBrowserPreviewTab: (tabId, patch) => - set((state) => ({ - sessionPreviewTabs: state.sessionPreviewTabs.map((tab) => + setSessionPreviewSlice(set, (state) => ({ + open: state.sessionPreviewOpen, + tabs: state.sessionPreviewTabs.map((tab) => tab.id === tabId && tab.type === 'browser' ? { ...tab, ...patch } : tab ), + activeTabId: state.activeSessionPreviewTabId, })), - closeSessionPreview: () => set({ sessionPreviewOpen: false }), - resetSessionPreview: () => - set({ - sessionPreviewOpen: false, - sessionPreviewTabs: [], - activeSessionPreviewTabId: null, + updateBrowserPreviewTabIn: (projectId, tabId, patch) => + set((state) => { + const slice = state.sessionPreviewByProject[projectId]; + if (!slice) return state; + const nextSlice: SessionPreviewSlice = { + ...slice, + tabs: slice.tabs.map((tab) => + tab.id === tabId && tab.type === 'browser' + ? { ...tab, ...patch } + : tab + ), + }; + return { + sessionPreviewByProject: { + ...state.sessionPreviewByProject, + [projectId]: nextSlice, + }, + // Keep the flat mirror in sync when this project is the scope. + ...(state.sessionPreviewProjectId === projectId + ? { sessionPreviewTabs: nextSlice.tabs } + : {}), + }; }), + closeSessionPreview: () => + setSessionPreviewSlice(set, (state) => ({ + open: false, + tabs: state.sessionPreviewTabs, + activeTabId: state.activeSessionPreviewTabId, + })), + resetSessionPreview: () => + setSessionPreviewSlice(set, () => ({ + open: false, + tabs: [], + activeTabId: null, + })), }), { name: 'eigent-page-tab', @@ -578,6 +833,9 @@ export const usePageTabStore = create()( projectSidebarFolded: state.projectSidebarFolded, customAgentFolderPathByProjectId: state.customAgentFolderPathByProjectId, + sessionPreviewByProject: sanitizeSessionPreviewForPersist( + state.sessionPreviewByProject + ), }), } ) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index bf3ac189c..26d8015a7 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -63,38 +63,6 @@ interface ElectronAPI { hideAllWebview: () => Promise; getShowWebview: () => Promise; webviewDestroy: (webviewId: string) => Promise; - navigateWebview: ( - webviewId: string, - url: string - ) => Promise<{ success: boolean; error?: string }>; - goBackWebview: ( - webviewId: string - ) => Promise<{ success: boolean; error?: string }>; - goForwardWebview: ( - webviewId: string - ) => Promise<{ success: boolean; error?: string }>; - reloadWebview: ( - webviewId: string - ) => Promise<{ success: boolean; error?: string }>; - getPreviewWebviewNavigationState: (webviewId: string) => Promise<{ - url: string; - title: string; - isLoading: boolean; - canGoBack: boolean; - canGoForward: boolean; - } | null>; - onPreviewWebviewStateChanged: ( - callback: ( - webviewId: string, - state: { - url: string; - title: string; - isLoading: boolean; - canGoBack: boolean; - canGoForward: boolean; - } - ) => void - ) => () => void; exportLog: () => Promise; getDiagnosticsInfo: () => Promise<{ version: string; diff --git a/test/unit/components/Session/PreviewBrowserLayer.test.tsx b/test/unit/components/Session/PreviewBrowserLayer.test.tsx new file mode 100644 index 000000000..36569a0cf --- /dev/null +++ b/test/unit/components/Session/PreviewBrowserLayer.test.tsx @@ -0,0 +1,186 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { PreviewBrowserLayer } from '@/components/Session/PreviewPanel/tabs/browser/PreviewBrowserLayer'; +import { getPreviewWebview } from '@/components/Session/PreviewPanel/tabs/browser/webviewRegistry'; +import { HostProvider } from '@/host'; +import { usePageTabStore } from '@/store/pageTabStore'; +import { act, render } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; + +const host = { ipcRenderer: null, electronAPI: {} }; + +function renderLayer() { + return render( + + + + ); +} + +function getBrowserTab() { + const tab = usePageTabStore + .getState() + .sessionPreviewTabs.find((candidate) => candidate.type === 'browser'); + if (!tab || tab.type !== 'browser') throw new Error('missing browser tab'); + return tab; +} + +function guestContainer(webviewId: string): HTMLElement | null { + return document.querySelector(`[data-preview-webview-id="${webviewId}"]`); +} + +describe('PreviewBrowserLayer', () => { + beforeEach(() => { + usePageTabStore.setState({ + sessionPreviewProjectId: null, + sessionPreviewByProject: {}, + sessionPreviewOpen: false, + sessionPreviewTabs: [], + activeSessionPreviewTabId: null, + previewBrowserViewport: null, + }); + usePageTabStore.getState().setSessionPreviewProject('project-a'); + usePageTabStore.getState().toggleSessionPreview(); + // Turn the seeded chooser tab into a browser tab for these tests. + const chooserId = usePageTabStore.getState().sessionPreviewTabs[0].id; + usePageTabStore.getState().choosePreviewTabType(chooserId, 'browser'); + }); + + it('mounts a guest once a browser tab has a URL', () => { + renderLayer(); + const tab = getBrowserTab(); + expect(guestContainer(tab.webviewId)).toBeNull(); + + act(() => { + usePageTabStore + .getState() + .updateBrowserPreviewTab(tab.id, { url: 'https://example.com/' }); + }); + + const container = guestContainer(tab.webviewId); + expect(container).not.toBeNull(); + const webview = container!.querySelector('webview'); + expect(webview).not.toBeNull(); + expect(webview!.getAttribute('src')).toBe('https://example.com/'); + expect(webview!.getAttribute('partition')).toBe('persist:session-preview'); + expect(getPreviewWebview(tab.webviewId)).toBe(webview); + }); + + it('positions the active guest over the published viewport and parks it when closed', () => { + renderLayer(); + const tab = getBrowserTab(); + act(() => { + usePageTabStore + .getState() + .updateBrowserPreviewTab(tab.id, { url: 'https://example.com/' }); + usePageTabStore + .getState() + .setPreviewBrowserViewport({ x: 100, y: 50, width: 640, height: 480 }); + }); + + const container = guestContainer(tab.webviewId)!; + expect(container.style.left).toBe('100px'); + expect(container.style.width).toBe('640px'); + expect(container.style.visibility).not.toBe('hidden'); + + // Closing the panel parks the guest (kept alive, hidden) — no unmount. + act(() => { + usePageTabStore.getState().closeSessionPreview(); + }); + const parked = guestContainer(tab.webviewId)!; + expect(parked).not.toBeNull(); + expect(parked.style.visibility).toBe('hidden'); + expect(parked.querySelector('webview')).not.toBeNull(); + }); + + it('keeps guests of other projects parked and restores them on switch-back', () => { + renderLayer(); + const tabA = getBrowserTab(); + act(() => { + usePageTabStore + .getState() + .updateBrowserPreviewTab(tabA.id, { url: 'https://a.example.com/' }); + usePageTabStore + .getState() + .setPreviewBrowserViewport({ x: 0, y: 0, width: 800, height: 600 }); + }); + expect(guestContainer(tabA.webviewId)!.style.visibility).not.toBe('hidden'); + + act(() => { + usePageTabStore.getState().setSessionPreviewProject('project-b'); + }); + // Project A's guest stays mounted (history preserved) but parked. + expect(guestContainer(tabA.webviewId)).not.toBeNull(); + expect(guestContainer(tabA.webviewId)!.style.visibility).toBe('hidden'); + + act(() => { + usePageTabStore.getState().setSessionPreviewProject('project-a'); + }); + expect(guestContainer(tabA.webviewId)!.style.visibility).not.toBe('hidden'); + }); + + it('feeds guest navigation events back into the per-project store slice', () => { + renderLayer(); + const tab = getBrowserTab(); + act(() => { + usePageTabStore + .getState() + .updateBrowserPreviewTab(tab.id, { url: 'https://example.com/' }); + }); + + const webview = guestContainer(tab.webviewId)!.querySelector( + 'webview' + ) as HTMLElement & Record; + webview.getURL = () => 'https://example.com/report'; + webview.getTitle = () => 'Quarterly report'; + webview.isLoading = () => false; + webview.canGoBack = () => true; + webview.canGoForward = () => false; + + act(() => { + webview.dispatchEvent(new Event('did-navigate')); + }); + + const updated = getBrowserTab(); + expect(updated.title).toBe('Quarterly report'); + expect(updated.url).toBe('https://example.com/report'); + expect(updated.navigation).toMatchObject({ + url: 'https://example.com/report', + canGoBack: true, + canGoForward: false, + }); + // Mirror and per-project record stay in sync. + expect( + usePageTabStore.getState().sessionPreviewByProject['project-a'].tabs + ).toContainEqual(updated); + }); + + it('destroys the guest only when its tab is closed', () => { + renderLayer(); + const tab = getBrowserTab(); + act(() => { + usePageTabStore + .getState() + .updateBrowserPreviewTab(tab.id, { url: 'https://example.com/' }); + }); + expect(guestContainer(tab.webviewId)).not.toBeNull(); + + act(() => { + usePageTabStore.getState().closeSessionPreviewTab(tab.id); + }); + expect(guestContainer(tab.webviewId)).toBeNull(); + expect(getPreviewWebview(tab.webviewId)).toBeUndefined(); + }); +}); diff --git a/test/unit/components/Session/PreviewPanel.test.tsx b/test/unit/components/Session/PreviewPanel.test.tsx index bb7d1dc1f..777df7da3 100644 --- a/test/unit/components/Session/PreviewPanel.test.tsx +++ b/test/unit/components/Session/PreviewPanel.test.tsx @@ -13,9 +13,13 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { PreviewPanel } from '@/components/Session/PreviewPanel'; +import { + registerPreviewWebview, + unregisterPreviewWebview, +} from '@/components/Session/PreviewPanel/tabs/browser/webviewRegistry'; import { HostProvider } from '@/host'; import { usePageTabStore } from '@/store/pageTabStore'; -import { act, render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -25,47 +29,15 @@ vi.mock('@/components/Folder/FilePreview', () => ({ ), })); -const createWebView = vi.fn().mockResolvedValue({ success: true }); -const showWebview = vi.fn().mockResolvedValue({ success: true }); -const hideWebView = vi.fn().mockResolvedValue({ success: true }); -const changeViewSize = vi.fn().mockResolvedValue({ success: true }); -const webviewDestroy = vi.fn().mockResolvedValue({ success: true }); -const navigateWebview = vi.fn().mockResolvedValue({ success: true }); -const goBackWebview = vi.fn().mockResolvedValue({ success: true }); -const goForwardWebview = vi.fn().mockResolvedValue({ success: true }); -const reloadWebview = vi.fn().mockResolvedValue({ success: true }); -let navigationListener: - | (( - webviewId: string, - state: { - url: string; - title: string; - isLoading: boolean; - canGoBack: boolean; - canGoForward: boolean; - } - ) => void) - | undefined; -const onPreviewWebviewStateChanged = vi.fn((listener) => { - navigationListener = listener; - return vi.fn(); -}); +// React Flow needs layout APIs jsdom lacks; the canvas tab is exercised +// elsewhere, so stub it to keep this suite focused on the router/tab strip. +vi.mock('@/components/Session/PreviewPanel/tabs/CanvasTab', () => ({ + CanvasTab: () =>
, +})); -const host = { - ipcRenderer: null, - electronAPI: { - createWebView, - showWebview, - hideWebView, - changeViewSize, - webviewDestroy, - navigateWebview, - goBackWebview, - goForwardWebview, - reloadWebview, - onPreviewWebviewStateChanged, - }, -}; +// The desktop host is detected by electronAPI presence; embedded browsing +// itself is -tag based and driven through the webview registry. +const host = { ipcRenderer: null, electronAPI: {} }; function renderPanel() { return render( @@ -75,49 +47,55 @@ function renderPanel() { ); } +function activeType() { + const state = usePageTabStore.getState(); + return state.sessionPreviewTabs.find( + (tab) => tab.id === state.activeSessionPreviewTabId + )?.type; +} + describe('PreviewPanel', () => { beforeEach(() => { - vi.clearAllMocks(); - navigationListener = undefined; usePageTabStore.setState({ + sessionPreviewProjectId: null, + sessionPreviewByProject: {}, sessionPreviewOpen: false, sessionPreviewTabs: [], activeSessionPreviewTabId: null, + previewBrowserViewport: null, }); + usePageTabStore.getState().setSessionPreviewProject('project-test'); usePageTabStore.getState().toggleSessionPreview(); }); - it('renders tabs and adds browser or file tabs from the add menu', async () => { - const user = userEvent.setup(); + it('opens on the chooser tab listing every content kind', () => { renderPanel(); - expect(screen.getByRole('tab', { name: 'New tab' })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'Open file' })).toBeInTheDocument(); - - await user.click( - screen.getByRole('button', { name: 'layout.add-preview-tab' }) - ); - await user.click( - await screen.findByRole('menuitem', { name: 'layout.add-browser-tab' }) - ); + // Five vertical options (test i18n echoes the key, not the label). + for (const kind of ['browser', 'file', 'review', 'terminal', 'canvas']) { + expect( + screen.getByRole('button', { + name: new RegExp(`preview-kind-${kind}\\b`), + }) + ).toBeInTheDocument(); + } + }); - expect(screen.getAllByRole('tab', { name: 'New tab' })).toHaveLength(2); - expect(screen.getAllByRole('tab', { name: 'New tab' })[1]).toHaveAttribute( - 'aria-selected', - 'true' - ); + it('picking a chooser option turns the tab into that content kind', async () => { + const user = userEvent.setup(); + renderPanel(); await user.click( - screen.getByRole('button', { name: 'layout.add-preview-tab' }) - ); - await user.click( - await screen.findByRole('menuitem', { name: 'layout.add-file-tab' }) + screen.getByRole('button', { name: /preview-kind-browser\b/ }) ); - - expect(screen.getAllByRole('tab', { name: 'Open file' })).toHaveLength(2); + expect(activeType()).toBe('browser'); + // Address bar of the browser tab is now shown. + expect( + screen.getByRole('textbox', { name: 'layout.browser-url-placeholder' }) + ).toBeInTheDocument(); }); - it('shows the current file and reuses its tab by path', () => { + it('routes to the file tab and reuses its tab by path', () => { const file = { name: 'notes.md', path: '/tmp/notes.md' } as FileInfo; usePageTabStore.getState().openFilePreview(file); usePageTabStore.getState().openFilePreview({ ...file }); @@ -127,60 +105,99 @@ describe('PreviewPanel', () => { expect(screen.getAllByRole('tab', { name: 'notes.md' })).toHaveLength(1); }); - it('normalizes and navigates to URLs through the existing webview API', async () => { + it('routes review, terminal, and canvas tabs to their surfaces', () => { + const store = usePageTabStore.getState(); + const chooserId = usePageTabStore.getState().sessionPreviewTabs[0].id; + act(() => store.choosePreviewTabType(chooserId, 'canvas')); + const { rerender } = renderPanel(); + expect(screen.getByTestId('canvas-tab')).toBeInTheDocument(); + + act(() => + store.choosePreviewTabType( + usePageTabStore.getState().activeSessionPreviewTabId!, + 'terminal' + ) + ); + rerender( + + + + ); + expect(screen.getByText('Eigent:~$')).toBeInTheDocument(); + }); + + it('the + button adds a new chooser tab', async () => { const user = userEvent.setup(); renderPanel(); - const address = screen.getByRole('textbox', { - name: 'layout.browser-url-placeholder', - }); - - await user.type(address, 'example.com/docs{Enter}'); - await waitFor(() => - expect(navigateWebview).toHaveBeenCalledWith( - expect.stringMatching(/^session-preview:/), - 'https://example.com/docs' - ) + await user.click( + screen.getByRole('button', { name: 'layout.add-preview-tab' }) ); - expect(showWebview).toHaveBeenCalled(); + expect(screen.getAllByRole('tab', { name: 'New tab' })).toHaveLength(2); + expect(activeType()).toBe('chooser'); }); - it('updates the browser tab title from native navigation state', async () => { - renderPanel(); + it('drives back/forward/reload on the registered guest element', async () => { + const user = userEvent.setup(); + const store = usePageTabStore.getState(); + const chooserId = usePageTabStore.getState().sessionPreviewTabs[0].id; + act(() => store.choosePreviewTabType(chooserId, 'browser')); const browserTab = usePageTabStore .getState() - .sessionPreviewTabs.find((tab) => tab.type === 'browser'); - expect(browserTab?.type).toBe('browser'); - - act(() => { - navigationListener?.(browserTab!.webviewId, { - url: 'https://example.com/report', - title: 'Quarterly report', - isLoading: false, - canGoBack: true, - canGoForward: false, - }); - }); - - expect( - await screen.findByRole('tab', { name: 'Quarterly report' }) - ).toBeInTheDocument(); + .sessionPreviewTabs.find((tab) => tab.type === 'browser')!; + act(() => + store.updateBrowserPreviewTab(browserTab.id, { + url: 'https://example.com/a', + navigation: { + url: 'https://example.com/a', + title: 'A', + isLoading: false, + canGoBack: true, + canGoForward: true, + }, + }) + ); + const goBack = vi.fn(); + const goForward = vi.fn(); + const reload = vi.fn(); + registerPreviewWebview(browserTab.webviewId, { + goBack, + goForward, + reload, + } as unknown as HTMLElement & { goBack: typeof goBack }); + + try { + renderPanel(); + await user.click( + screen.getByRole('button', { name: 'layout.browser-back' }) + ); + await user.click( + screen.getByRole('button', { name: 'layout.browser-forward' }) + ); + await user.click( + screen.getByRole('button', { name: 'layout.browser-reload' }) + ); + + expect(goBack).toHaveBeenCalled(); + expect(goForward).toHaveBeenCalled(); + expect(reload).toHaveBeenCalled(); + } finally { + unregisterPreviewWebview(browserTab.webviewId); + } }); - it('destroys a preview-owned webview when its tab closes', async () => { + it('closing the final tab closes the panel', async () => { const user = userEvent.setup(); renderPanel(); - const browserTab = usePageTabStore - .getState() - .sessionPreviewTabs.find((tab) => tab.type === 'browser'); await user.click( - screen.getAllByRole('button', { name: 'layout.close-preview-tab' })[0] + screen.getByRole('button', { name: 'layout.close-preview-tab' }) ); - expect(webviewDestroy).toHaveBeenCalledWith(browserTab!.webviewId); - expect( - screen.queryByRole('tab', { name: 'New tab' }) - ).not.toBeInTheDocument(); + expect(usePageTabStore.getState()).toMatchObject({ + sessionPreviewOpen: false, + sessionPreviewTabs: [], + activeSessionPreviewTabId: null, + }); }); }); diff --git a/test/unit/electron/main/webview.test.ts b/test/unit/electron/main/webview.test.ts deleted file mode 100644 index 5c03b4c6f..000000000 --- a/test/unit/electron/main/webview.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= - -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const electronMocks = vi.hoisted(() => { - class MockWebContents { - private listeners = new Map void>>(); - private url = 'about:blank?use=0'; - private title = ''; - audioMuted = false; - executeJavaScript = vi.fn(); - setBackgroundThrottling = vi.fn(); - setWindowOpenHandler = vi.fn(); - close = vi.fn(); - isDestroyed = vi.fn(() => false); - isLoading = vi.fn(() => false); - getURL = vi.fn(() => this.url); - getTitle = vi.fn(() => this.title); - removeAllListeners = vi.fn(); - session = { clearCache: vi.fn() }; - debugger = { - isAttached: vi.fn(() => false), - attach: vi.fn(), - detach: vi.fn(), - sendCommand: vi.fn(async () => ({ data: 'preview' })), - }; - navigationHistory = { - canGoBack: vi.fn(() => true), - canGoForward: vi.fn(() => false), - goBack: vi.fn(), - goForward: vi.fn(), - }; - - on = vi.fn((event: string, listener: (...args: any[]) => void) => { - const listeners = this.listeners.get(event) ?? []; - listeners.push(listener); - this.listeners.set(event, listeners); - }); - - private emit(event: string, ...args: any[]) { - this.listeners.get(event)?.forEach((listener) => listener({}, ...args)); - } - - loadURL = vi.fn(async (url: string) => { - this.emit('did-start-loading'); - this.url = url; - this.title = url.startsWith('http') ? 'Example page' : ''; - this.emit('did-navigate', url); - this.emit('page-title-updated', this.title); - this.emit('did-stop-loading'); - this.emit('did-finish-load'); - }); - - capturePage = vi.fn(async () => ({ - toJPEG: vi.fn(() => Buffer.from('image')), - })); - reload = vi.fn(); - } - - class MockWebContentsView { - webContents = new MockWebContents(); - setBounds = vi.fn(); - setBorderRadius = vi.fn(); - } - - return { - MockWebContentsView, - views: [] as InstanceType[], - }; -}); - -vi.mock('electron', () => ({ - BrowserWindow: class {}, - WebContentsView: class extends electronMocks.MockWebContentsView { - constructor() { - super(); - electronMocks.views.push(this); - } - }, -})); - -import { - isPreviewWebviewId, - WebViewManager, -} from '../../../../electron/main/webview'; - -describe('WebViewManager session preview views', () => { - const windowMock = { - isDestroyed: vi.fn(() => false), - webContents: { send: vi.fn() }, - contentView: { - addChildView: vi.fn(), - removeChildView: vi.fn(), - }, - }; - - beforeEach(() => { - electronMocks.views.length = 0; - vi.clearAllMocks(); - }); - - it('validates and navigates preview-owned views without exposing them as agent views', async () => { - const manager = new WebViewManager(windowMock as any); - const id = 'session-preview:browser-1'; - - expect(isPreviewWebviewId(id)).toBe(true); - await manager.createWebview(id); - expect( - await manager.navigateWebview(id, 'file:///tmp/report.html') - ).toEqual({ - success: false, - error: 'Only HTTP and HTTPS URLs are supported', - }); - - await expect( - manager.navigateWebview(id, 'https://example.com') - ).resolves.toEqual({ - success: true, - }); - expect(manager.getActiveWebview()).toEqual([]); - expect(await manager.captureWebview(id)).toBeNull(); - expect(windowMock.webContents.send).toHaveBeenCalledWith( - 'preview-webview-state-changed', - id, - expect.objectContaining({ - url: 'https://example.com', - title: 'Example page', - }) - ); - }); - - it('supports preview navigation controls without emitting agent show events', async () => { - const manager = new WebViewManager(windowMock as any); - const id = 'session-preview:browser-2'; - await manager.createWebview(id); - await manager.navigateWebview(id, 'https://example.com'); - - manager.goBackWebview(id); - manager.goForwardWebview(id); - manager.reloadWebview(id); - await manager.showWebview(id); - - const contents = electronMocks.views[0].webContents; - expect(contents.navigationHistory.goBack).toHaveBeenCalled(); - expect(contents.navigationHistory.goForward).not.toHaveBeenCalled(); - expect(contents.reload).toHaveBeenCalled(); - expect(windowMock.webContents.send).not.toHaveBeenCalledWith( - 'webview-show', - id - ); - expect(manager.getPreviewWebviewNavigationState(id)).toMatchObject({ - url: 'https://example.com', - title: 'Example page', - canGoBack: true, - canGoForward: false, - }); - }); - - it('leaves agent webview capture behavior intact', async () => { - const manager = new WebViewManager(windowMock as any); - await manager.createWebview('1'); - - expect(await manager.navigateWebview('1', 'https://example.com')).toEqual({ - success: false, - error: 'Navigation is limited to preview tabs', - }); - expect(await manager.captureWebview('1')).toBe( - 'data:image/jpeg;base64,preview' - ); - }); -}); diff --git a/test/unit/store/pageTabStore.preview.test.ts b/test/unit/store/pageTabStore.preview.test.ts index b14666cb7..4adf4b1eb 100644 --- a/test/unit/store/pageTabStore.preview.test.ts +++ b/test/unit/store/pageTabStore.preview.test.ts @@ -18,62 +18,94 @@ import { beforeEach, describe, expect, it } from 'vitest'; describe('pageTabStore session preview', () => { beforeEach(() => { usePageTabStore.setState({ + sessionPreviewProjectId: null, + sessionPreviewByProject: {}, sessionPreviewOpen: false, sessionPreviewTabs: [], activeSessionPreviewTabId: null, }); }); - it('seeds browser and file tabs when the unified preview opens', () => { + it('opens onto a single chooser tab', () => { const store = usePageTabStore.getState(); store.toggleSessionPreview(); const state = usePageTabStore.getState(); expect(state.sessionPreviewOpen).toBe(true); expect(state.sessionPreviewTabs.map((tab) => tab.type)).toEqual([ - 'browser', - 'file', - ]); - expect(state.sessionPreviewTabs.map((tab) => tab.title)).toEqual([ - 'New tab', - 'Open file', + 'chooser', ]); expect(state.activeSessionPreviewTabId).toBe( state.sessionPreviewTabs[0].id ); }); - it('adds and selects blank browser and file tabs', () => { + it('turns the chooser into the chosen content kind in place', () => { const store = usePageTabStore.getState(); + store.setSessionPreviewProject('project-a'); store.toggleSessionPreview(); - store.addBrowserPreviewTab(); + const chooserId = usePageTabStore.getState().sessionPreviewTabs[0].id; + store.choosePreviewTabType(chooserId, 'browser'); let state = usePageTabStore.getState(); - expect(state.sessionPreviewTabs).toHaveLength(3); - let active = state.sessionPreviewTabs.find( - (tab) => tab.id === state.activeSessionPreviewTabId + expect(state.sessionPreviewTabs).toHaveLength(1); + const browser = state.sessionPreviewTabs[0]; + expect(browser.type).toBe('browser'); + expect(state.activeSessionPreviewTabId).toBe(browser.id); + expect(browser.type === 'browser' && browser.webviewId).toContain( + 'session-preview:project-a:' ); - expect(active).toMatchObject({ - type: 'browser', - title: 'New tab', - url: '', - }); - store.addFilePreviewTab(); + // A new chooser (via "+") can become other kinds too. + store.addChooserPreviewTab(); + const newChooserId = usePageTabStore.getState().activeSessionPreviewTabId!; + store.choosePreviewTabType(newChooserId, 'canvas'); state = usePageTabStore.getState(); - active = state.sessionPreviewTabs.find( - (tab) => tab.id === state.activeSessionPreviewTabId - ); - expect(active).toMatchObject({ + expect(state.sessionPreviewTabs.map((tab) => tab.type)).toEqual([ + 'browser', + 'canvas', + ]); + }); + + it('exposes every content kind via choosePreviewTabType', () => { + const store = usePageTabStore.getState(); + store.toggleSessionPreview(); + (['file', 'review', 'terminal', 'canvas'] as const).forEach((kind) => { + store.addChooserPreviewTab(); + const id = usePageTabStore.getState().activeSessionPreviewTabId!; + store.choosePreviewTabType(id, kind); + const active = usePageTabStore + .getState() + .sessionPreviewTabs.find( + (tab) => + tab.id === usePageTabStore.getState().activeSessionPreviewTabId + ); + expect(active?.type).toBe(kind); + }); + }); + + it('reuses the chooser tab when a file is opened', () => { + const store = usePageTabStore.getState(); + store.toggleSessionPreview(); + const file = { name: 'doc.txt', path: '/tmp/doc.txt' } as FileInfo; + store.openFilePreview(file); + + const state = usePageTabStore.getState(); + // Chooser replaced in place — no extra tab piled up. + expect(state.sessionPreviewTabs).toHaveLength(1); + expect(state.sessionPreviewTabs[0]).toMatchObject({ type: 'file', - title: 'Open file', - file: null, + title: 'doc.txt', + file, }); }); it('reuses the empty file tab and deduplicates files by path', () => { const store = usePageTabStore.getState(); store.toggleSessionPreview(); + const chooserId = usePageTabStore.getState().sessionPreviewTabs[0].id; + store.choosePreviewTabType(chooserId, 'file'); + const file = { name: 'doc.txt', path: '/tmp/doc.txt' } as FileInfo; store.openFilePreview(file); store.openFilePreview({ ...file }); @@ -90,6 +122,13 @@ describe('pageTabStore session preview', () => { it('selects a neighboring tab and closes the panel after the final tab', () => { const store = usePageTabStore.getState(); store.toggleSessionPreview(); + const chooserId = usePageTabStore.getState().sessionPreviewTabs[0].id; + store.choosePreviewTabType(chooserId, 'browser'); + store.addChooserPreviewTab(); + store.choosePreviewTabType( + usePageTabStore.getState().activeSessionPreviewTabId!, + 'file' + ); const [browserTab, fileTab] = usePageTabStore.getState().sessionPreviewTabs; store.closeSessionPreviewTab(browserTab.id); @@ -109,7 +148,7 @@ describe('pageTabStore session preview', () => { const store = usePageTabStore.getState(); store.toggleSessionPreview(); store.closeSessionPreview(); - expect(usePageTabStore.getState().sessionPreviewTabs).toHaveLength(2); + expect(usePageTabStore.getState().sessionPreviewTabs).toHaveLength(1); store.resetSessionPreview(); expect(usePageTabStore.getState()).toMatchObject({ @@ -118,4 +157,45 @@ describe('pageTabStore session preview', () => { activeSessionPreviewTabId: null, }); }); + + it('keeps preview state per project and restores it on switch-back', () => { + const store = usePageTabStore.getState(); + store.setSessionPreviewProject('project-a'); + store.toggleSessionPreview(); + const projectATabs = usePageTabStore.getState().sessionPreviewTabs; + expect(projectATabs).toHaveLength(1); + + // Switching projects swaps in the other project's (empty) slice… + store.setSessionPreviewProject('project-b'); + expect(usePageTabStore.getState()).toMatchObject({ + sessionPreviewOpen: false, + sessionPreviewTabs: [], + activeSessionPreviewTabId: null, + }); + + // …and switching back restores tabs, active tab, and the open flag. + store.setSessionPreviewProject('project-a'); + const restored = usePageTabStore.getState(); + expect(restored.sessionPreviewOpen).toBe(true); + expect(restored.sessionPreviewTabs).toEqual(projectATabs); + expect(restored.activeSessionPreviewTabId).toBe(projectATabs[0].id); + }); + + it('records mutations into the per-project slice for persistence', () => { + const store = usePageTabStore.getState(); + store.setSessionPreviewProject('project-a'); + store.toggleSessionPreview(); + const file = { name: 'doc.txt', path: '/tmp/doc.txt' } as FileInfo; + store.openFilePreview(file); + + const slice = + usePageTabStore.getState().sessionPreviewByProject['project-a']; + expect(slice.open).toBe(true); + expect( + slice.tabs.filter((tab) => tab.type === 'file' && tab.file !== null) + ).toHaveLength(1); + expect(slice.activeTabId).toBe( + usePageTabStore.getState().activeSessionPreviewTabId + ); + }); }); diff --git a/tsconfig.node.tsbuildinfo b/tsconfig.node.tsbuildinfo new file mode 100644 index 000000000..1cf56e527 --- /dev/null +++ b/tsconfig.node.tsbuildinfo @@ -0,0 +1 @@ +{"fileNames":["./node_modules/typescript/lib/lib.d.ts","./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.webworker.importscripts.d.ts","./node_modules/typescript/lib/lib.scripthost.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.esnext.float16.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/node/compatibility/iterators.d.ts","./node_modules/@types/node/globals.typedarray.d.ts","./node_modules/@types/node/buffer.buffer.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/web-globals/abortcontroller.d.ts","./node_modules/@types/node/web-globals/blob.d.ts","./node_modules/@types/node/web-globals/console.d.ts","./node_modules/@types/node/web-globals/crypto.d.ts","./node_modules/@types/node/web-globals/domexception.d.ts","./node_modules/@types/node/web-globals/encoding.d.ts","./node_modules/@types/node/web-globals/events.d.ts","./node_modules/buffer/index.d.ts","./node_modules/undici-types/utility.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client-stats.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/h2c-client.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-call-history.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/snapshot-agent.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/undici-types/retry-handler.d.ts","./node_modules/undici-types/retry-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/cache-interceptor.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/util.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/eventsource.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/web-globals/fetch.d.ts","./node_modules/@types/node/web-globals/importmeta.d.ts","./node_modules/@types/node/web-globals/messaging.d.ts","./node_modules/@types/node/web-globals/navigator.d.ts","./node_modules/@types/node/web-globals/performance.d.ts","./node_modules/@types/node/web-globals/storage.d.ts","./node_modules/@types/node/web-globals/streams.d.ts","./node_modules/@types/node/web-globals/timers.d.ts","./node_modules/@types/node/web-globals/url.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.d.ts","./node_modules/@types/node/inspector.generated.d.ts","./node_modules/@types/node/inspector/promises.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/path/posix.d.ts","./node_modules/@types/node/path/win32.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/quic.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/sea.d.ts","./node_modules/@types/node/sqlite.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/test/reporters.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/util/types.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/@types/estree/index.d.ts","./node_modules/rollup/dist/rollup.d.ts","./node_modules/vite/types/hmrpayload.d.ts","./node_modules/vite/types/customevent.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/types.d-agj9qkwt.d.ts","./node_modules/vite/node_modules/esbuild/lib/main.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/vite/dist/node/runtime.d.ts","./node_modules/vite/types/importglob.d.ts","./node_modules/vite/types/metadata.d.ts","./node_modules/vite/dist/node/index.d.ts","./node_modules/@babel/types/lib/index.d.ts","./node_modules/@types/babel__generator/index.d.ts","./node_modules/@babel/parser/typings/babel-parser.d.ts","./node_modules/@types/babel__template/index.d.ts","./node_modules/@types/babel__traverse/index.d.ts","./node_modules/@types/babel__core/index.d.ts","./node_modules/@vitejs/plugin-react/dist/index.d.ts","./node_modules/vite-plugin-electron/dist/utils.d.ts","./node_modules/vite-plugin-electron/dist/index.d.ts","./node_modules/esbuild/lib/main.d.ts","./node_modules/vite-plugin-electron-renderer/dist/index.d.ts","./node_modules/vite-plugin-electron/dist/simple.d.ts","./node_modules/vite-plugin-electron/simple.d.ts","./package.json","./vite.config.ts","./vite.config.web.ts","./src/style/tokens/base.color.json","./src/style/tokens/contracts/default.base.json","./src/style/tokens/contracts/default.dark.json","./src/style/tokens/contracts/default.light.json","./src/lib/themetokens/colormath.ts","./src/lib/themetokens/dtcg.ts","./src/lib/themetokens/types.ts","./src/lib/themetokens/catalog.ts","./src/style/tokens/component.color.json","./src/style/tokens/semantic.color.json","./src/lib/themetokens/naming.ts","./src/lib/themetokens/engine.ts","./src/lib/themetokens/verifier.ts","./scripts/verify-theme-tokens.ts","./node_modules/@types/readdir-glob/index.d.ts","./node_modules/@types/archiver/index.d.ts","./node_modules/@types/aria-query/index.d.ts","./node_modules/keyv/src/index.d.ts","./node_modules/@types/http-cache-semantics/index.d.ts","./node_modules/@types/responselike/index.d.ts","./node_modules/@types/cacheable-request/index.d.ts","./node_modules/@types/deep-eql/index.d.ts","./node_modules/assertion-error/index.d.ts","./node_modules/@types/chai/index.d.ts","./node_modules/@types/color-name/index.d.ts","./node_modules/@types/color-convert/conversions.d.ts","./node_modules/@types/color-convert/route.d.ts","./node_modules/@types/color-convert/index.d.ts","./node_modules/@types/color/index.d.ts","./node_modules/@types/cookie/index.d.ts","./node_modules/@types/d3-color/index.d.ts","./node_modules/@types/d3-selection/index.d.ts","./node_modules/@types/d3-drag/index.d.ts","./node_modules/@types/d3-interpolate/index.d.ts","./node_modules/@types/d3-transition/index.d.ts","./node_modules/@types/d3-zoom/index.d.ts","./node_modules/@types/ms/index.d.ts","./node_modules/@types/debug/index.d.ts","./node_modules/@types/doctrine/index.d.ts","./node_modules/@types/trusted-types/lib/index.d.ts","./node_modules/@types/trusted-types/index.d.ts","./node_modules/@types/dompurify/index.d.ts","./node_modules/@types/estree-jsx/index.d.ts","./node_modules/@types/fs-extra/index.d.ts","./node_modules/@types/unist/index.d.ts","./node_modules/@types/hast/index.d.ts","./node_modules/@types/js-cookie/index.d.ts","./node_modules/@types/json-schema/index.d.ts","./node_modules/@types/keyv/index.d.ts","./node_modules/@types/lodash/common/common.d.ts","./node_modules/@types/lodash/common/array.d.ts","./node_modules/@types/lodash/common/collection.d.ts","./node_modules/@types/lodash/common/date.d.ts","./node_modules/@types/lodash/common/function.d.ts","./node_modules/@types/lodash/common/lang.d.ts","./node_modules/@types/lodash/common/math.d.ts","./node_modules/@types/lodash/common/number.d.ts","./node_modules/@types/lodash/common/object.d.ts","./node_modules/@types/lodash/common/seq.d.ts","./node_modules/@types/lodash/common/string.d.ts","./node_modules/@types/lodash/common/util.d.ts","./node_modules/@types/lodash/index.d.ts","./node_modules/@types/lodash-es/add.d.ts","./node_modules/@types/lodash-es/after.d.ts","./node_modules/@types/lodash-es/ary.d.ts","./node_modules/@types/lodash-es/assign.d.ts","./node_modules/@types/lodash-es/assignin.d.ts","./node_modules/@types/lodash-es/assigninwith.d.ts","./node_modules/@types/lodash-es/assignwith.d.ts","./node_modules/@types/lodash-es/at.d.ts","./node_modules/@types/lodash-es/attempt.d.ts","./node_modules/@types/lodash-es/before.d.ts","./node_modules/@types/lodash-es/bind.d.ts","./node_modules/@types/lodash-es/bindall.d.ts","./node_modules/@types/lodash-es/bindkey.d.ts","./node_modules/@types/lodash-es/camelcase.d.ts","./node_modules/@types/lodash-es/capitalize.d.ts","./node_modules/@types/lodash-es/castarray.d.ts","./node_modules/@types/lodash-es/ceil.d.ts","./node_modules/@types/lodash-es/chain.d.ts","./node_modules/@types/lodash-es/chunk.d.ts","./node_modules/@types/lodash-es/clamp.d.ts","./node_modules/@types/lodash-es/clone.d.ts","./node_modules/@types/lodash-es/clonedeep.d.ts","./node_modules/@types/lodash-es/clonedeepwith.d.ts","./node_modules/@types/lodash-es/clonewith.d.ts","./node_modules/@types/lodash-es/compact.d.ts","./node_modules/@types/lodash-es/concat.d.ts","./node_modules/@types/lodash-es/cond.d.ts","./node_modules/@types/lodash-es/conforms.d.ts","./node_modules/@types/lodash-es/conformsto.d.ts","./node_modules/@types/lodash-es/constant.d.ts","./node_modules/@types/lodash-es/countby.d.ts","./node_modules/@types/lodash-es/create.d.ts","./node_modules/@types/lodash-es/curry.d.ts","./node_modules/@types/lodash-es/curryright.d.ts","./node_modules/@types/lodash-es/debounce.d.ts","./node_modules/@types/lodash-es/deburr.d.ts","./node_modules/@types/lodash-es/defaults.d.ts","./node_modules/@types/lodash-es/defaultsdeep.d.ts","./node_modules/@types/lodash-es/defaultto.d.ts","./node_modules/@types/lodash-es/defer.d.ts","./node_modules/@types/lodash-es/delay.d.ts","./node_modules/@types/lodash-es/difference.d.ts","./node_modules/@types/lodash-es/differenceby.d.ts","./node_modules/@types/lodash-es/differencewith.d.ts","./node_modules/@types/lodash-es/divide.d.ts","./node_modules/@types/lodash-es/drop.d.ts","./node_modules/@types/lodash-es/dropright.d.ts","./node_modules/@types/lodash-es/droprightwhile.d.ts","./node_modules/@types/lodash-es/dropwhile.d.ts","./node_modules/@types/lodash-es/each.d.ts","./node_modules/@types/lodash-es/eachright.d.ts","./node_modules/@types/lodash-es/endswith.d.ts","./node_modules/@types/lodash-es/entries.d.ts","./node_modules/@types/lodash-es/entriesin.d.ts","./node_modules/@types/lodash-es/eq.d.ts","./node_modules/@types/lodash-es/escape.d.ts","./node_modules/@types/lodash-es/escaperegexp.d.ts","./node_modules/@types/lodash-es/every.d.ts","./node_modules/@types/lodash-es/extend.d.ts","./node_modules/@types/lodash-es/extendwith.d.ts","./node_modules/@types/lodash-es/fill.d.ts","./node_modules/@types/lodash-es/filter.d.ts","./node_modules/@types/lodash-es/find.d.ts","./node_modules/@types/lodash-es/findindex.d.ts","./node_modules/@types/lodash-es/findkey.d.ts","./node_modules/@types/lodash-es/findlast.d.ts","./node_modules/@types/lodash-es/findlastindex.d.ts","./node_modules/@types/lodash-es/findlastkey.d.ts","./node_modules/@types/lodash-es/first.d.ts","./node_modules/@types/lodash-es/flatmap.d.ts","./node_modules/@types/lodash-es/flatmapdeep.d.ts","./node_modules/@types/lodash-es/flatmapdepth.d.ts","./node_modules/@types/lodash-es/flatten.d.ts","./node_modules/@types/lodash-es/flattendeep.d.ts","./node_modules/@types/lodash-es/flattendepth.d.ts","./node_modules/@types/lodash-es/flip.d.ts","./node_modules/@types/lodash-es/floor.d.ts","./node_modules/@types/lodash-es/flow.d.ts","./node_modules/@types/lodash-es/flowright.d.ts","./node_modules/@types/lodash-es/foreach.d.ts","./node_modules/@types/lodash-es/foreachright.d.ts","./node_modules/@types/lodash-es/forin.d.ts","./node_modules/@types/lodash-es/forinright.d.ts","./node_modules/@types/lodash-es/forown.d.ts","./node_modules/@types/lodash-es/forownright.d.ts","./node_modules/@types/lodash-es/frompairs.d.ts","./node_modules/@types/lodash-es/functions.d.ts","./node_modules/@types/lodash-es/functionsin.d.ts","./node_modules/@types/lodash-es/get.d.ts","./node_modules/@types/lodash-es/groupby.d.ts","./node_modules/@types/lodash-es/gt.d.ts","./node_modules/@types/lodash-es/gte.d.ts","./node_modules/@types/lodash-es/has.d.ts","./node_modules/@types/lodash-es/hasin.d.ts","./node_modules/@types/lodash-es/head.d.ts","./node_modules/@types/lodash-es/identity.d.ts","./node_modules/@types/lodash-es/includes.d.ts","./node_modules/@types/lodash-es/indexof.d.ts","./node_modules/@types/lodash-es/initial.d.ts","./node_modules/@types/lodash-es/inrange.d.ts","./node_modules/@types/lodash-es/intersection.d.ts","./node_modules/@types/lodash-es/intersectionby.d.ts","./node_modules/@types/lodash-es/intersectionwith.d.ts","./node_modules/@types/lodash-es/invert.d.ts","./node_modules/@types/lodash-es/invertby.d.ts","./node_modules/@types/lodash-es/invoke.d.ts","./node_modules/@types/lodash-es/invokemap.d.ts","./node_modules/@types/lodash-es/isarguments.d.ts","./node_modules/@types/lodash-es/isarray.d.ts","./node_modules/@types/lodash-es/isarraybuffer.d.ts","./node_modules/@types/lodash-es/isarraylike.d.ts","./node_modules/@types/lodash-es/isarraylikeobject.d.ts","./node_modules/@types/lodash-es/isboolean.d.ts","./node_modules/@types/lodash-es/isbuffer.d.ts","./node_modules/@types/lodash-es/isdate.d.ts","./node_modules/@types/lodash-es/iselement.d.ts","./node_modules/@types/lodash-es/isempty.d.ts","./node_modules/@types/lodash-es/isequal.d.ts","./node_modules/@types/lodash-es/isequalwith.d.ts","./node_modules/@types/lodash-es/iserror.d.ts","./node_modules/@types/lodash-es/isfinite.d.ts","./node_modules/@types/lodash-es/isfunction.d.ts","./node_modules/@types/lodash-es/isinteger.d.ts","./node_modules/@types/lodash-es/islength.d.ts","./node_modules/@types/lodash-es/ismap.d.ts","./node_modules/@types/lodash-es/ismatch.d.ts","./node_modules/@types/lodash-es/ismatchwith.d.ts","./node_modules/@types/lodash-es/isnan.d.ts","./node_modules/@types/lodash-es/isnative.d.ts","./node_modules/@types/lodash-es/isnil.d.ts","./node_modules/@types/lodash-es/isnull.d.ts","./node_modules/@types/lodash-es/isnumber.d.ts","./node_modules/@types/lodash-es/isobject.d.ts","./node_modules/@types/lodash-es/isobjectlike.d.ts","./node_modules/@types/lodash-es/isplainobject.d.ts","./node_modules/@types/lodash-es/isregexp.d.ts","./node_modules/@types/lodash-es/issafeinteger.d.ts","./node_modules/@types/lodash-es/isset.d.ts","./node_modules/@types/lodash-es/isstring.d.ts","./node_modules/@types/lodash-es/issymbol.d.ts","./node_modules/@types/lodash-es/istypedarray.d.ts","./node_modules/@types/lodash-es/isundefined.d.ts","./node_modules/@types/lodash-es/isweakmap.d.ts","./node_modules/@types/lodash-es/isweakset.d.ts","./node_modules/@types/lodash-es/iteratee.d.ts","./node_modules/@types/lodash-es/join.d.ts","./node_modules/@types/lodash-es/kebabcase.d.ts","./node_modules/@types/lodash-es/keyby.d.ts","./node_modules/@types/lodash-es/keys.d.ts","./node_modules/@types/lodash-es/keysin.d.ts","./node_modules/@types/lodash-es/last.d.ts","./node_modules/@types/lodash-es/lastindexof.d.ts","./node_modules/@types/lodash-es/lowercase.d.ts","./node_modules/@types/lodash-es/lowerfirst.d.ts","./node_modules/@types/lodash-es/lt.d.ts","./node_modules/@types/lodash-es/lte.d.ts","./node_modules/@types/lodash-es/map.d.ts","./node_modules/@types/lodash-es/mapkeys.d.ts","./node_modules/@types/lodash-es/mapvalues.d.ts","./node_modules/@types/lodash-es/matches.d.ts","./node_modules/@types/lodash-es/matchesproperty.d.ts","./node_modules/@types/lodash-es/max.d.ts","./node_modules/@types/lodash-es/maxby.d.ts","./node_modules/@types/lodash-es/mean.d.ts","./node_modules/@types/lodash-es/meanby.d.ts","./node_modules/@types/lodash-es/memoize.d.ts","./node_modules/@types/lodash-es/merge.d.ts","./node_modules/@types/lodash-es/mergewith.d.ts","./node_modules/@types/lodash-es/method.d.ts","./node_modules/@types/lodash-es/methodof.d.ts","./node_modules/@types/lodash-es/min.d.ts","./node_modules/@types/lodash-es/minby.d.ts","./node_modules/@types/lodash-es/mixin.d.ts","./node_modules/@types/lodash-es/multiply.d.ts","./node_modules/@types/lodash-es/negate.d.ts","./node_modules/@types/lodash-es/noop.d.ts","./node_modules/@types/lodash-es/now.d.ts","./node_modules/@types/lodash-es/nth.d.ts","./node_modules/@types/lodash-es/ntharg.d.ts","./node_modules/@types/lodash-es/omit.d.ts","./node_modules/@types/lodash-es/omitby.d.ts","./node_modules/@types/lodash-es/once.d.ts","./node_modules/@types/lodash-es/orderby.d.ts","./node_modules/@types/lodash-es/over.d.ts","./node_modules/@types/lodash-es/overargs.d.ts","./node_modules/@types/lodash-es/overevery.d.ts","./node_modules/@types/lodash-es/oversome.d.ts","./node_modules/@types/lodash-es/pad.d.ts","./node_modules/@types/lodash-es/padend.d.ts","./node_modules/@types/lodash-es/padstart.d.ts","./node_modules/@types/lodash-es/parseint.d.ts","./node_modules/@types/lodash-es/partial.d.ts","./node_modules/@types/lodash-es/partialright.d.ts","./node_modules/@types/lodash-es/partition.d.ts","./node_modules/@types/lodash-es/pick.d.ts","./node_modules/@types/lodash-es/pickby.d.ts","./node_modules/@types/lodash-es/property.d.ts","./node_modules/@types/lodash-es/propertyof.d.ts","./node_modules/@types/lodash-es/pull.d.ts","./node_modules/@types/lodash-es/pullall.d.ts","./node_modules/@types/lodash-es/pullallby.d.ts","./node_modules/@types/lodash-es/pullallwith.d.ts","./node_modules/@types/lodash-es/pullat.d.ts","./node_modules/@types/lodash-es/random.d.ts","./node_modules/@types/lodash-es/range.d.ts","./node_modules/@types/lodash-es/rangeright.d.ts","./node_modules/@types/lodash-es/rearg.d.ts","./node_modules/@types/lodash-es/reduce.d.ts","./node_modules/@types/lodash-es/reduceright.d.ts","./node_modules/@types/lodash-es/reject.d.ts","./node_modules/@types/lodash-es/remove.d.ts","./node_modules/@types/lodash-es/repeat.d.ts","./node_modules/@types/lodash-es/replace.d.ts","./node_modules/@types/lodash-es/rest.d.ts","./node_modules/@types/lodash-es/result.d.ts","./node_modules/@types/lodash-es/reverse.d.ts","./node_modules/@types/lodash-es/round.d.ts","./node_modules/@types/lodash-es/sample.d.ts","./node_modules/@types/lodash-es/samplesize.d.ts","./node_modules/@types/lodash-es/set.d.ts","./node_modules/@types/lodash-es/setwith.d.ts","./node_modules/@types/lodash-es/shuffle.d.ts","./node_modules/@types/lodash-es/size.d.ts","./node_modules/@types/lodash-es/slice.d.ts","./node_modules/@types/lodash-es/snakecase.d.ts","./node_modules/@types/lodash-es/some.d.ts","./node_modules/@types/lodash-es/sortby.d.ts","./node_modules/@types/lodash-es/sortedindex.d.ts","./node_modules/@types/lodash-es/sortedindexby.d.ts","./node_modules/@types/lodash-es/sortedindexof.d.ts","./node_modules/@types/lodash-es/sortedlastindex.d.ts","./node_modules/@types/lodash-es/sortedlastindexby.d.ts","./node_modules/@types/lodash-es/sortedlastindexof.d.ts","./node_modules/@types/lodash-es/sorteduniq.d.ts","./node_modules/@types/lodash-es/sorteduniqby.d.ts","./node_modules/@types/lodash-es/split.d.ts","./node_modules/@types/lodash-es/spread.d.ts","./node_modules/@types/lodash-es/startcase.d.ts","./node_modules/@types/lodash-es/startswith.d.ts","./node_modules/@types/lodash-es/stubarray.d.ts","./node_modules/@types/lodash-es/stubfalse.d.ts","./node_modules/@types/lodash-es/stubobject.d.ts","./node_modules/@types/lodash-es/stubstring.d.ts","./node_modules/@types/lodash-es/stubtrue.d.ts","./node_modules/@types/lodash-es/subtract.d.ts","./node_modules/@types/lodash-es/sum.d.ts","./node_modules/@types/lodash-es/sumby.d.ts","./node_modules/@types/lodash-es/tail.d.ts","./node_modules/@types/lodash-es/take.d.ts","./node_modules/@types/lodash-es/takeright.d.ts","./node_modules/@types/lodash-es/takerightwhile.d.ts","./node_modules/@types/lodash-es/takewhile.d.ts","./node_modules/@types/lodash-es/tap.d.ts","./node_modules/@types/lodash-es/template.d.ts","./node_modules/@types/lodash-es/templatesettings.d.ts","./node_modules/@types/lodash-es/throttle.d.ts","./node_modules/@types/lodash-es/thru.d.ts","./node_modules/@types/lodash-es/times.d.ts","./node_modules/@types/lodash-es/toarray.d.ts","./node_modules/@types/lodash-es/tofinite.d.ts","./node_modules/@types/lodash-es/tointeger.d.ts","./node_modules/@types/lodash-es/tolength.d.ts","./node_modules/@types/lodash-es/tolower.d.ts","./node_modules/@types/lodash-es/tonumber.d.ts","./node_modules/@types/lodash-es/topairs.d.ts","./node_modules/@types/lodash-es/topairsin.d.ts","./node_modules/@types/lodash-es/topath.d.ts","./node_modules/@types/lodash-es/toplainobject.d.ts","./node_modules/@types/lodash-es/tosafeinteger.d.ts","./node_modules/@types/lodash-es/tostring.d.ts","./node_modules/@types/lodash-es/toupper.d.ts","./node_modules/@types/lodash-es/transform.d.ts","./node_modules/@types/lodash-es/trim.d.ts","./node_modules/@types/lodash-es/trimend.d.ts","./node_modules/@types/lodash-es/trimstart.d.ts","./node_modules/@types/lodash-es/truncate.d.ts","./node_modules/@types/lodash-es/unary.d.ts","./node_modules/@types/lodash-es/unescape.d.ts","./node_modules/@types/lodash-es/union.d.ts","./node_modules/@types/lodash-es/unionby.d.ts","./node_modules/@types/lodash-es/unionwith.d.ts","./node_modules/@types/lodash-es/uniq.d.ts","./node_modules/@types/lodash-es/uniqby.d.ts","./node_modules/@types/lodash-es/uniqueid.d.ts","./node_modules/@types/lodash-es/uniqwith.d.ts","./node_modules/@types/lodash-es/unset.d.ts","./node_modules/@types/lodash-es/unzip.d.ts","./node_modules/@types/lodash-es/unzipwith.d.ts","./node_modules/@types/lodash-es/update.d.ts","./node_modules/@types/lodash-es/updatewith.d.ts","./node_modules/@types/lodash-es/uppercase.d.ts","./node_modules/@types/lodash-es/upperfirst.d.ts","./node_modules/@types/lodash-es/values.d.ts","./node_modules/@types/lodash-es/valuesin.d.ts","./node_modules/@types/lodash-es/without.d.ts","./node_modules/@types/lodash-es/words.d.ts","./node_modules/@types/lodash-es/wrap.d.ts","./node_modules/@types/lodash-es/xor.d.ts","./node_modules/@types/lodash-es/xorby.d.ts","./node_modules/@types/lodash-es/xorwith.d.ts","./node_modules/@types/lodash-es/zip.d.ts","./node_modules/@types/lodash-es/zipobject.d.ts","./node_modules/@types/lodash-es/zipobjectdeep.d.ts","./node_modules/@types/lodash-es/zipwith.d.ts","./node_modules/@types/lodash-es/index.d.ts","./node_modules/@types/mdast/index.d.ts","./node_modules/@types/mdx/types.d.ts","./node_modules/@types/mdx/index.d.ts","./node_modules/@types/mime/mime.d.ts","./node_modules/@types/mime/index.d.ts","./node_modules/@types/minimatch/index.d.ts","./node_modules/@types/papaparse/index.d.ts","./node_modules/xmlbuilder/typings/index.d.ts","./node_modules/@types/plist/index.d.ts","./node_modules/@types/prop-types/index.d.ts","./node_modules/@types/qrcode/index.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/@types/react-avatar-editor/index.d.ts","./node_modules/@types/react-dom/index.d.ts","./node_modules/@types/resolve/index.d.ts","./node_modules/@types/symlink-or-copy/index.d.ts","./node_modules/@types/unzipper/index.d.ts","./node_modules/@types/verror/index.d.ts","./node_modules/@types/xml2js/lib/processors.d.ts","./node_modules/@types/xml2js/index.d.ts","./node_modules/@types/yauzl/index.d.ts"],"fileIdsList":[[54,117,125,129,132,134,135,136,148,204],[54,117,125,129,132,134,135,136,148],[54,117,125,129,132,134,135,136,148,153,172,234],[54,117,125,129,132,134,135,136,148,204,205,206,207,208],[54,117,125,129,132,134,135,136,148,204,206],[54,117,125,128,129,131,132,134,135,136,148,165,173,237,238,239],[54,117,125,129,132,134,135,136,148,241,242],[54,117,125,129,132,134,135,136,148,244],[54,117,125,129,132,134,135,136,148,245,246],[54,117,125,129,132,134,135,136,148,245],[54,117,125,129,132,134,135,136,148,247],[54,117,125,129,132,134,135,136,148,251,254],[54,117,125,129,132,134,135,136,148,250],[54,117,125,129,132,134,135,136,148,251,253,254],[54,117,125,129,132,134,135,136,148,256],[54,117,125,129,132,134,135,136,148,260],[54,117,125,129,132,134,135,136,148,174,175,262],[54,117,125,129,132,134,135,136,148,173],[54,117,125,129,132,134,135,136,148,264],[54,117,125,128,129,132,134,135,136,148,173],[54,117,125,129,132,134,135,136,148,281],[54,117,125,129,132,134,135,136,148,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585],[54,117,125,129,132,134,135,136,148,269,271,272,273,274,275,276,277,278,279,280,281],[54,117,125,129,132,134,135,136,148,269,270,272,273,274,275,276,277,278,279,280,281],[54,117,125,129,132,134,135,136,148,270,271,272,273,274,275,276,277,278,279,280,281],[54,117,125,129,132,134,135,136,148,269,270,271,273,274,275,276,277,278,279,280,281],[54,117,125,129,132,134,135,136,148,269,270,271,272,274,275,276,277,278,279,280,281],[54,117,125,129,132,134,135,136,148,269,270,271,272,273,275,276,277,278,279,280,281],[54,117,125,129,132,134,135,136,148,269,270,271,272,273,274,276,277,278,279,280,281],[54,117,125,129,132,134,135,136,148,269,270,271,272,273,274,275,277,278,279,280,281],[54,117,125,129,132,134,135,136,148,269,270,271,272,273,274,275,276,278,279,280,281],[54,117,125,129,132,134,135,136,148,269,270,271,272,273,274,275,276,277,279,280,281],[54,117,125,129,132,134,135,136,148,269,270,271,272,273,274,275,276,277,278,280,281],[54,117,125,129,132,134,135,136,148,269,270,271,272,273,274,275,276,277,278,279,281],[54,117,125,129,132,134,135,136,148,269,270,271,272,273,274,275,276,277,278,279,280],[54,117,125,129,132,134,135,136,148,588,589],[54,117,125,129,132,134,135,136,148,590],[54,117,125,129,132,134,135,136,148,591],[54,114,115,117,125,129,132,134,135,136,148],[54,116,117,125,129,132,134,135,136,148],[117,125,129,132,134,135,136,148],[54,117,125,129,132,134,135,136,148,156],[54,117,118,123,125,128,129,132,134,135,136,138,148,153,165],[54,117,118,119,125,128,129,132,134,135,136,148],[54,117,120,125,129,132,134,135,136,148,166],[54,117,121,122,125,129,132,134,135,136,139,148],[54,117,122,125,129,132,134,135,136,148,153,162],[54,117,123,125,128,129,132,134,135,136,138,148],[54,116,117,124,125,129,132,134,135,136,148],[54,117,125,126,129,132,134,135,136,148],[54,117,125,127,128,129,132,134,135,136,148],[54,116,117,125,128,129,132,134,135,136,148],[54,117,125,128,129,130,132,134,135,136,148,153,165],[54,117,125,128,129,130,132,134,135,136,148,153,156],[54,104,117,125,128,129,131,132,134,135,136,138,148,153,165],[54,117,125,128,129,131,132,134,135,136,138,148,153,162,165],[54,117,125,129,131,132,133,134,135,136,148,153,162,165],[52,53,54,55,56,57,58,59,60,61,62,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172],[54,117,125,128,129,132,134,135,136,148],[54,117,125,129,132,134,136,148],[54,117,125,129,132,134,135,136,137,148,165],[54,117,125,128,129,132,134,135,136,138,148,153],[54,117,125,129,132,134,135,136,139,148],[54,117,125,129,132,134,135,136,140,148],[54,117,125,128,129,132,134,135,136,143,148],[54,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172],[54,117,125,129,132,134,135,136,145,148],[54,117,125,129,132,134,135,136,146,148],[54,117,122,125,129,132,134,135,136,138,148,156],[54,117,125,128,129,132,134,135,136,148,149],[54,117,125,129,132,134,135,136,148,150,166,169],[54,117,125,128,129,132,134,135,136,148,153,155,156],[54,117,125,129,132,134,135,136,148,154,156],[54,117,125,129,132,134,135,136,148,156,166],[54,117,125,129,132,134,135,136,148,157],[54,114,117,125,129,132,134,135,136,148,153,159],[54,117,125,129,132,134,135,136,148,153,158],[54,117,125,128,129,132,134,135,136,148,160,161],[54,117,125,129,132,134,135,136,148,160,161],[54,117,122,125,129,132,134,135,136,138,148,153,162],[54,117,125,129,132,134,135,136,148,163],[54,117,125,129,132,134,135,136,138,148,164],[54,117,125,129,131,132,134,135,136,146,148,165],[54,117,125,129,132,134,135,136,148,166,167],[54,117,122,125,129,132,134,135,136,148,167],[54,117,125,129,132,134,135,136,148,153,168],[54,117,125,129,132,134,135,136,137,148,169],[54,117,125,129,132,134,135,136,148,170],[54,117,120,125,129,132,134,135,136,148],[54,117,122,125,129,132,134,135,136,148],[54,117,125,129,132,134,135,136,148,166],[54,104,117,125,129,132,134,135,136,148],[54,117,125,129,132,134,135,136,148,165],[54,117,125,129,132,134,135,136,148,171],[54,117,125,129,132,134,135,136,143,148],[54,117,125,129,132,134,135,136,148,161],[54,104,117,125,128,129,130,132,134,135,136,143,148,153,156,165,168,169,171],[54,117,125,129,132,134,135,136,148,153,172],[54,117,125,129,132,134,135,136,148,153,173],[54,117,125,129,132,134,135,136,148,173,594],[54,117,125,129,132,134,135,136,148,600],[54,117,125,129,132,134,135,136,148,596,598,599],[54,117,125,129,131,132,134,135,136,148,153,173],[54,117,125,129,132,134,135,136,148,259],[54,117,125,128,129,132,134,135,136,148,173,607],[54,117,125,128,129,132,134,135,136,148,153,173],[54,117,125,129,132,134,135,136,148,203,209],[54,117,125,129,132,134,135,136,148,196],[54,117,125,129,132,134,135,136,148,194,196],[54,117,125,129,132,134,135,136,148,185,193,194,195,197,199],[54,117,125,129,132,134,135,136,148,183],[54,117,125,129,132,134,135,136,148,186,191,196,199],[54,117,125,129,132,134,135,136,148,182,199],[54,117,125,129,132,134,135,136,148,186,187,190,191,192,199],[54,117,125,129,132,134,135,136,148,186,187,188,190,191,199],[54,117,125,129,132,134,135,136,148,183,184,185,186,187,191,192,193,195,196,197,199],[54,117,125,129,132,134,135,136,148,181,183,184,185,186,187,188,190,191,192,193,194,195,196,197,198],[54,117,125,129,132,134,135,136,148,181,199],[54,117,125,129,132,134,135,136,148,186,188,189,191,192,199],[54,117,125,129,132,134,135,136,148,190,199],[54,117,125,129,132,134,135,136,148,191,192,196,199],[54,117,125,129,132,134,135,136,148,184,194],[54,70,73,76,77,117,125,129,132,134,135,136,148,165],[54,73,117,125,129,132,134,135,136,148,153,165],[54,73,77,117,125,129,132,134,135,136,148,165],[54,117,125,129,132,134,135,136,148,153],[54,67,117,125,129,132,134,135,136,148],[54,71,117,125,129,132,134,135,136,148],[54,69,70,73,117,125,129,132,134,135,136,148,165],[54,117,125,129,132,134,135,136,138,148,162],[54,67,117,125,129,132,134,135,136,148,173],[54,69,73,117,125,129,132,134,135,136,138,148,165],[54,64,65,66,68,72,117,125,128,129,132,134,135,136,148,153,165],[54,73,81,89,117,125,129,132,134,135,136,148],[54,65,71,117,125,129,132,134,135,136,148],[54,73,98,99,117,125,129,132,134,135,136,148],[54,65,68,73,117,125,129,132,134,135,136,148,156,165,173],[54,73,117,125,129,132,134,135,136,148],[54,69,73,117,125,129,132,134,135,136,148,165],[54,64,117,125,129,132,134,135,136,148],[54,67,68,69,71,72,73,74,75,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,99,100,101,102,103,117,125,129,132,134,135,136,148],[54,73,91,94,117,125,129,132,134,135,136,148],[54,73,81,82,83,117,125,129,132,134,135,136,148],[54,71,73,82,84,117,125,129,132,134,135,136,148],[54,72,117,125,129,132,134,135,136,148],[54,65,67,73,117,125,129,132,134,135,136,148],[54,73,77,82,84,117,125,129,132,134,135,136,148],[54,77,117,125,129,132,134,135,136,148],[54,71,73,76,117,125,129,132,134,135,136,148,165],[54,65,69,73,81,117,125,129,132,134,135,136,148],[54,73,91,117,125,129,132,134,135,136,148],[54,84,117,125,129,132,134,135,136,148],[54,67,73,98,117,125,129,132,134,135,136,148,156,171,173],[54,117,125,129,132,134,135,136,148,203,213],[54,117,118,125,129,132,134,135,136,148,173,175,202,203,211],[54,117,125,129,132,134,135,136,148,175,202,203,212,214],[54,117,125,129,132,134,135,136,148,175,202,203,212],[54,117,125,129,132,134,135,136,148,215],[54,117,125,128,129,131,132,133,134,135,136,138,148,153,162,165,172,173,175,176,177,178,179,180,199,200,201,202],[54,117,125,129,132,134,135,136,148,176,177,178,179],[54,117,125,129,132,134,135,136,148,176,177,178],[54,117,125,129,132,134,135,136,148,176],[54,117,125,129,132,134,135,136,148,177],[54,117,125,129,132,134,135,136,148,175],[54,117,125,129,132,134,135,136,148,232],[54,117,125,129,132,134,135,136,148,220,221,222,223,224,225,226],[54,117,125,129,132,134,135,136,148,220,224,225,226,227,228,229,230],[54,117,125,129,132,134,135,136,148,226],[54,117,125,129,132,134,135,136,148,224,226,227,231],[54,117,118,125,129,132,134,135,136,140,148,203,210,216,217],[54,117,125,129,131,132,133,134,135,136,140,148,203,210]],"fileInfos":[{"version":"a7297ff837fcdf174a9524925966429eb8e5feecc2cc55cc06574e6b092c1eaa","impliedFormat":1},{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"80e18897e5884b6723488d4f5652167e7bb5024f946743134ecc4aa4ee731f89","affectsGlobalScope":true,"impliedFormat":1},{"version":"cd034f499c6cdca722b60c04b5b1b78e058487a7085a8e0d6fb50809947ee573","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"51ad4c928303041605b4d7ae32e0c1ee387d43a24cd6f1ebf4a2699e1076d4fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"196cb558a13d4533a5163286f30b0509ce0210e4b316c56c38d4c0fd2fb38405","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"0ccdaa19852d25ecd84eec365c3bfa16e7859cadecf6e9ca6d0dbbbee439743f","affectsGlobalScope":true,"impliedFormat":1},{"version":"438b41419b1df9f1fbe33b5e1b18f5853432be205991d1b19f5b7f351675541e","affectsGlobalScope":true,"impliedFormat":1},{"version":"096116f8fedc1765d5bd6ef360c257b4a9048e5415054b3bf3c41b07f8951b0b","affectsGlobalScope":true,"impliedFormat":1},{"version":"e5e01375c9e124a83b52ee4b3244ed1a4d214a6cfb54ac73e164a823a4a7860a","affectsGlobalScope":true,"impliedFormat":1},{"version":"f90ae2bbce1505e67f2f6502392e318f5714bae82d2d969185c4a6cecc8af2fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"4b58e207b93a8f1c88bbf2a95ddc686ac83962b13830fe8ad3f404ffc7051fb4","affectsGlobalScope":true,"impliedFormat":1},{"version":"1fefabcb2b06736a66d2904074d56268753654805e829989a46a0161cd8412c5","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"c18a99f01eb788d849ad032b31cafd49de0b19e083fe775370834c5675d7df8e","affectsGlobalScope":true,"impliedFormat":1},{"version":"5247874c2a23b9a62d178ae84f2db6a1d54e6c9a2e7e057e178cc5eea13757fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e9c23ba78aabc2e0a27033f18737a6df754067731e69dc5f52823957d60a4b6","impliedFormat":1},{"version":"cdcf9ea426ad970f96ac930cd176d5c69c6c24eebd9fc580e1572d6c6a88f62c","impliedFormat":1},{"version":"23cd712e2ce083d68afe69224587438e5914b457b8acf87073c22494d706a3d0","impliedFormat":1},{"version":"487b694c3de27ddf4ad107d4007ad304d29effccf9800c8ae23c2093638d906a","impliedFormat":1},{"version":"3a80bc85f38526ca3b08007ee80712e7bb0601df178b23fbf0bf87036fce40ce","impliedFormat":1},{"version":"ccf4552357ce3c159ef75f0f0114e80401702228f1898bdc9402214c9499e8c0","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"68834d631c8838c715f225509cfc3927913b9cc7a4870460b5b60c8dbdb99baf","impliedFormat":1},{"version":"2931540c47ee0ff8a62860e61782eb17b155615db61e36986e54645ec67f67c2","impliedFormat":1},{"version":"ccab02f3920fc75c01174c47fcf67882a11daf16baf9e81701d0a94636e94556","impliedFormat":1},{"version":"f6faf5f74e4c4cc309a6c6a6c4da02dbb840be5d3e92905a23dcd7b2b0bd1986","impliedFormat":1},{"version":"ea6bc8de8b59f90a7a3960005fd01988f98fd0784e14bc6922dde2e93305ec7d","impliedFormat":1},{"version":"36107995674b29284a115e21a0618c4c2751b32a8766dd4cb3ba740308b16d59","impliedFormat":1},{"version":"914a0ae30d96d71915fc519ccb4efbf2b62c0ddfb3a3fc6129151076bc01dc60","impliedFormat":1},{"version":"33e981bf6376e939f99bd7f89abec757c64897d33c005036b9a10d9587d80187","impliedFormat":1},{"version":"7fd1b31fd35876b0aa650811c25ec2c97a3c6387e5473eb18004bed86cdd76b6","impliedFormat":1},{"version":"b41767d372275c154c7ea6c9d5449d9a741b8ce080f640155cc88ba1763e35b3","impliedFormat":1},{"version":"3bacf516d686d08682751a3bd2519ea3b8041a164bfb4f1d35728993e70a2426","impliedFormat":1},{"version":"7fb266686238369442bd1719bc0d7edd0199da4fb8540354e1ff7f16669b4323","impliedFormat":1},{"version":"0a60a292b89ca7218b8616f78e5bbd1c96b87e048849469cccb4355e98af959a","impliedFormat":1},{"version":"0b6e25234b4eec6ed96ab138d96eb70b135690d7dd01f3dd8a8ab291c35a683a","impliedFormat":1},{"version":"9666f2f84b985b62400d2e5ab0adae9ff44de9b2a34803c2c5bd3c8325b17dc0","impliedFormat":1},{"version":"40cd35c95e9cf22cfa5bd84e96408b6fcbca55295f4ff822390abb11afbc3dca","impliedFormat":1},{"version":"b1616b8959bf557feb16369c6124a97a0e74ed6f49d1df73bb4b9ddf68acf3f3","impliedFormat":1},{"version":"5b03a034c72146b61573aab280f295b015b9168470f2df05f6080a2122f9b4df","impliedFormat":1},{"version":"40b463c6766ca1b689bfcc46d26b5e295954f32ad43e37ee6953c0a677e4ae2b","impliedFormat":1},{"version":"249b9cab7f5d628b71308c7d9bb0a808b50b091e640ba3ed6e2d0516f4a8d91d","impliedFormat":1},{"version":"80aae6afc67faa5ac0b32b5b8bc8cc9f7fa299cff15cf09cc2e11fd28c6ae29e","impliedFormat":1},{"version":"f473cd2288991ff3221165dcf73cd5d24da30391f87e85b3dd4d0450c787a391","impliedFormat":1},{"version":"499e5b055a5aba1e1998f7311a6c441a369831c70905cc565ceac93c28083d53","impliedFormat":1},{"version":"54c3e2371e3d016469ad959697fd257e5621e16296fa67082c2575d0bf8eced0","impliedFormat":1},{"version":"beb8233b2c220cfa0feea31fbe9218d89fa02faa81ef744be8dce5acb89bb1fd","impliedFormat":1},{"version":"c183b931b68ad184bc8e8372bf663f3d33304772fb482f29fb91b3c391031f3e","impliedFormat":1},{"version":"5d0375ca7310efb77e3ef18d068d53784faf62705e0ad04569597ae0e755c401","impliedFormat":1},{"version":"59af37caec41ecf7b2e76059c9672a49e682c1a2aa6f9d7dc78878f53aa284d6","impliedFormat":1},{"version":"addf417b9eb3f938fddf8d81e96393a165e4be0d4a8b6402292f9c634b1cb00d","impliedFormat":1},{"version":"48cc3ec153b50985fb95153258a710782b25975b10dd4ac8a4f3920632d10790","impliedFormat":1},{"version":"adf27937dba6af9f08a68c5b1d3fce0ca7d4b960c57e6d6c844e7d1a8e53adae","impliedFormat":1},{"version":"e1528ca65ac90f6fa0e4a247eb656b4263c470bb22d9033e466463e13395e599","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"866078923a56d026e39243b4392e282c1c63159723996fa89243140e1388a98d","impliedFormat":1},{"version":"c3f5289820990ab66b70c7fb5b63cb674001009ff84b13de40619619a9c8175f","affectsGlobalScope":true,"impliedFormat":1},{"version":"b3275d55fac10b799c9546804126239baf020d220136163f763b55a74e50e750","affectsGlobalScope":true,"impliedFormat":1},{"version":"fa68a0a3b7cb32c00e39ee3cd31f8f15b80cac97dce51b6ee7fc14a1e8deb30b","affectsGlobalScope":true,"impliedFormat":1},{"version":"1cf059eaf468efcc649f8cf6075d3cb98e9a35a0fe9c44419ec3d2f5428d7123","affectsGlobalScope":true,"impliedFormat":1},{"version":"6c36e755bced82df7fb6ce8169265d0a7bb046ab4e2cb6d0da0cb72b22033e89","affectsGlobalScope":true,"impliedFormat":1},{"version":"e7721c4f69f93c91360c26a0a84ee885997d748237ef78ef665b153e622b36c1","affectsGlobalScope":true,"impliedFormat":1},{"version":"7a93de4ff8a63bafe62ba86b89af1df0ccb5e40bb85b0c67d6bbcfdcf96bf3d4","affectsGlobalScope":true,"impliedFormat":1},{"version":"90e85f9bc549dfe2b5749b45fe734144e96cd5d04b38eae244028794e142a77e","affectsGlobalScope":true,"impliedFormat":1},{"version":"e0a5deeb610b2a50a6350bd23df6490036a1773a8a71d70f2f9549ab009e67ee","affectsGlobalScope":true,"impliedFormat":1},{"version":"435b3711465425770ed2ee2f1cf00ce071835265e0851a7dc4600ab4b007550e","impliedFormat":1},{"version":"7e49f52a159435fc8df4de9dc377ef5860732ca2dc9efec1640531d3cf5da7a3","impliedFormat":1},{"version":"dd4bde4bdc2e5394aed6855e98cf135dfdf5dd6468cad842e03116d31bbcc9bc","impliedFormat":1},{"version":"4d4e879009a84a47c05350b8dca823036ba3a29a3038efed1be76c9f81e45edf","affectsGlobalScope":true,"impliedFormat":1},{"version":"237ba5ac2a95702a114a309e39c53a5bddff5f6333b325db9764df9b34f3502b","impliedFormat":1},{"version":"9ba13b47cb450a438e3076c4a3f6afb9dc85e17eae50f26d4b2d72c0688c9251","impliedFormat":1},{"version":"b64cd4401633ea4ecadfd700ddc8323a13b63b106ac7127c1d2726f32424622c","impliedFormat":1},{"version":"37c6e5fe5715814412b43cc9b50b24c67a63c4e04e753e0d1305970d65417a60","impliedFormat":1},{"version":"1d024184fb57c58c5c91823f9d10b4915a4867b7934e89115fd0d861a9df27c8","impliedFormat":1},{"version":"ee0e4946247f842c6dd483cbb60a5e6b484fee07996e3a7bc7343dfb68a04c5d","impliedFormat":1},{"version":"ef051f42b7e0ef5ca04552f54c4552eac84099d64b6c5ad0ef4033574b6035b8","impliedFormat":1},{"version":"853a43154f1d01b0173d9cbd74063507ece57170bad7a3b68f3fa1229ad0a92f","impliedFormat":1},{"version":"56231e3c39a031bfb0afb797690b20ed4537670c93c0318b72d5180833d98b72","impliedFormat":1},{"version":"5cc7c39031bfd8b00ad58f32143d59eb6ffc24f5d41a20931269011dccd36c5e","impliedFormat":1},{"version":"b0b69c61b0f0ec8ca15db4c8c41f6e77f4cacb784d42bca948f42dea33e8757e","affectsGlobalScope":true,"impliedFormat":1},{"version":"f96a48183254c00d24575401f1a761b4ce4927d927407e7862a83e06ce5d6964","impliedFormat":1},{"version":"cc25940cfb27aa538e60d465f98bb5068d4d7d33131861ace43f04fe6947d68f","impliedFormat":1},{"version":"9dec1d75d47c23b595402f265babeac9c0f645427df7e937d69ddfa05cdddc1f","impliedFormat":1},{"version":"01ff95aa1443e3f7248974e5a771f513cb2ac158c8898f470a1792f817bee497","impliedFormat":1},{"version":"757227c8b345c57d76f7f0e3bbad7a91ffca23f1b2547cbed9e10025816c9cb7","impliedFormat":1},{"version":"42a05d8f239f74587d4926aba8cc54792eed8e8a442c7adc9b38b516642aadfe","impliedFormat":1},{"version":"5d21b58d60383cc6ab9ad3d3e265d7d25af24a2c9b506247e0e50b0a884920be","impliedFormat":1},{"version":"101f482fd48cb4c7c0468dcc6d62c843d842977aea6235644b1edd05e81fbf22","impliedFormat":1},{"version":"ae6757460f37078884b1571a3de3ebaf724d827d7e1d53626c02b3c2a408ac63","affectsGlobalScope":true,"impliedFormat":1},{"version":"9451a46a89ed209e2e08329e6cac59f89356eae79a7230f916d8cc38725407c7","impliedFormat":1},{"version":"3ef397f12387eff17f550bc484ea7c27d21d43816bbe609d495107f44b97e933","impliedFormat":1},{"version":"1023282e2ba810bc07905d3668349fbd37a26411f0c8f94a70ef3c05fe523fcf","impliedFormat":1},{"version":"b214ebcf76c51b115453f69729ee8aa7b7f8eccdae2a922b568a45c2d7ff52f7","impliedFormat":1},{"version":"429c9cdfa7d126255779efd7e6d9057ced2d69c81859bbab32073bad52e9ba76","impliedFormat":1},{"version":"e236b5eba291f51bdf32c231673e6cab81b5410850e61f51a7a524dddadc0f95","impliedFormat":1},{"version":"f7ba0e839daa0702e3ff1a1a871c0d8ea2d586ce684dd8a72c786c36a680b1d9","affectsGlobalScope":true,"impliedFormat":1},{"version":"7f2c62938251b45715fd2a9887060ec4fbc8724727029d1cbce373747252bdd7","impliedFormat":1},{"version":"e3ace08b6bbd84655d41e244677b474fd995923ffef7149ddb68af8848b60b05","impliedFormat":1},{"version":"132580b0e86c48fab152bab850fc57a4b74fe915c8958d2ccb052b809a44b61c","impliedFormat":1},{"version":"af4ab0aa8908fc9a655bb833d3bc28e117c4f0e1038c5a891546158beb25accb","impliedFormat":1},{"version":"69c9a5a9392e8564bd81116e1ed93b13205201fb44cb35a7fde8c9f9e21c4b23","impliedFormat":1},{"version":"5f8fc37f8434691ffac1bfd8fc2634647da2c0e84253ab5d2dd19a7718915b35","impliedFormat":1},{"version":"5981c2340fd8b076cae8efbae818d42c11ffc615994cb060b1cd390795f1be2b","impliedFormat":1},{"version":"f64deb26664af64dc274637343bde8d82f930c77af05a412c7d310b77207a448","impliedFormat":1},{"version":"ed4f674fc8c0c993cc7e145069ac44129e03519b910c62be206a0cc777bdc60b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0250da3eb85c99624f974e77ef355cdf86f43980251bc371475c2b397ba55bcd","impliedFormat":1},{"version":"f1c93e046fb3d9b7f8249629f4b63dc068dd839b824dd0aa39a5e68476dc9420","impliedFormat":1},{"version":"3d3a5f27ffbc06c885dd4d5f9ee20de61faf877fe2c3a7051c4825903d9a7fdc","impliedFormat":1},{"version":"12806f9f085598ef930edaf2467a5fa1789a878fba077cd27e85dc5851e11834","impliedFormat":1},{"version":"17d06eb5709839c7ce719f0c38ada6f308fb433f2cd6d8c87b35856e07400950","impliedFormat":1},{"version":"a43fe41c33d0a192a0ecaf9b92e87bef3709c9972e6d53c42c49251ccb962d69","impliedFormat":1},{"version":"a177959203c017fad3ecc4f3d96c8757a840957a4959a3ae00dab9d35961ca6c","affectsGlobalScope":true,"impliedFormat":1},{"version":"6fc727ccf9b36e257ff982ea0badeffbfc2c151802f741bddff00c6af3b784cf","impliedFormat":1},{"version":"2a00d005e3af99cd1cfa75220e60c61b04bfb6be7ca7453bfe2ef6cca37cc03c","impliedFormat":1},{"version":"4844a4c9b4b1e812b257676ed8a80b3f3be0e29bf05e742cc2ea9c3c6865e6c6","impliedFormat":1},{"version":"064878a60367e0407c42fb7ba02a2ea4d83257357dc20088e549bd4d89433e9c","impliedFormat":1},{"version":"14d4bd22d1b05824971b98f7e91b2484c90f1a684805c330476641417c3d9735","impliedFormat":1},{"version":"c3877fef8a43cd434f9728f25a97575b0eb73d92f38b5c87c840daccc3e21d97","impliedFormat":1},{"version":"b484ec11ba00e3a2235562a41898d55372ccabe607986c6fa4f4aba72093749f","impliedFormat":1},{"version":"1dbd83860e7634f9c236647f45dbc5d3c4f9eba8827d87209d6e9826fdf4dbd5","impliedFormat":1},{"version":"41ef7992c555671a8fe54db302788adefa191ded810a50329b79d20a6772d14c","impliedFormat":1},{"version":"041a7781b9127ab568d2cdcce62c58fdea7c7407f40b8c50045d7866a2727130","impliedFormat":1},{"version":"b37f83e7deea729aa9ce5593f78905afb45b7532fdff63041d374f60059e7852","impliedFormat":1},{"version":"e1cb68f3ef3a8dd7b2a9dfb3de482ed6c0f1586ba0db4e7d73c1d2147b6ffc51","impliedFormat":1},{"version":"55cdbeebe76a1fa18bbd7e7bf73350a2173926bd3085bb050cf5a5397025ee4e","impliedFormat":1},{"version":"151ff381ef9ff8da2da9b9663ebf657eac35c4c9a19183420c05728f31a6761d","impliedFormat":1},{"version":"ee70b8037ecdf0de6c04f35277f253663a536d7e38f1539d270e4e916d225a3f","affectsGlobalScope":true,"impliedFormat":1},{"version":"282f98006ed7fa9bb2cd9bdbe2524595cfc4bcd58a0bb3232e4519f2138df811","impliedFormat":1},{"version":"6222e987b58abfe92597e1273ad7233626285bc2d78409d4a7b113d81a83496b","impliedFormat":1},{"version":"cbe726263ae9a7bf32352380f7e8ab66ee25b3457137e316929269c19e18a2be","impliedFormat":1},{"version":"8b96046bf5fb0a815cba6b0880d9f97b7f3a93cf187e8dcfe8e2792e97f38f87","impliedFormat":99},{"version":"bacf2c84cf448b2cd02c717ad46c3d7fd530e0c91282888c923ad64810a4d511","affectsGlobalScope":true,"impliedFormat":1},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"8885cf05f3e2abf117590bbb951dcf6359e3e5ac462af1c901cfd24c6a6472e2","impliedFormat":1},{"version":"333caa2bfff7f06017f114de738050dd99a765c7eb16571c6d25a38c0d5365dc","impliedFormat":1},{"version":"e61df3640a38d535fd4bc9f4a53aef17c296b58dc4b6394fd576b808dd2fe5e6","impliedFormat":1},{"version":"459920181700cec8cbdf2a5faca127f3f17fd8dd9d9e577ed3f5f3af5d12a2e4","impliedFormat":1},{"version":"4719c209b9c00b579553859407a7e5dcfaa1c472994bd62aa5dd3cc0757eb077","impliedFormat":1},{"version":"7ec359bbc29b69d4063fe7dad0baaf35f1856f914db16b3f4f6e3e1bca4099fa","impliedFormat":1},{"version":"70790a7f0040993ca66ab8a07a059a0f8256e7bb57d968ae945f696cbff4ac7a","impliedFormat":1},{"version":"d1b9a81e99a0050ca7f2d98d7eedc6cda768f0eb9fa90b602e7107433e64c04c","impliedFormat":1},{"version":"a022503e75d6953d0e82c2c564508a5c7f8556fad5d7f971372d2d40479e4034","impliedFormat":1},{"version":"b215c4f0096f108020f666ffcc1f072c81e9f2f95464e894a5d5f34c5ea2a8b1","impliedFormat":1},{"version":"644491cde678bd462bb922c1d0cfab8f17d626b195ccb7f008612dc31f445d2d","impliedFormat":1},{"version":"dfe54dab1fa4961a6bcfba68c4ca955f8b5bbeb5f2ab3c915aa7adaa2eabc03a","impliedFormat":1},{"version":"1251d53755b03cde02466064260bb88fd83c30006a46395b7d9167340bc59b73","impliedFormat":1},{"version":"47865c5e695a382a916b1eedda1b6523145426e48a2eae4647e96b3b5e52024f","impliedFormat":1},{"version":"4cdf27e29feae6c7826cdd5c91751cc35559125e8304f9e7aed8faef97dcf572","impliedFormat":1},{"version":"331b8f71bfae1df25d564f5ea9ee65a0d847c4a94baa45925b6f38c55c7039bf","impliedFormat":1},{"version":"2a771d907aebf9391ac1f50e4ad37952943515eeea0dcc7e78aa08f508294668","impliedFormat":1},{"version":"0146fd6262c3fd3da51cb0254bb6b9a4e42931eb2f56329edd4c199cb9aaf804","impliedFormat":1},{"version":"82e687ebd99518bc63ea04b0c3810fb6e50aa6942decd0ca6f7a56d9b9a212a6","impliedFormat":99},{"version":"7f698624bbbb060ece7c0e51b7236520ebada74b747d7523c7df376453ed6fea","impliedFormat":1},{"version":"8f07f2b6514744ac96e51d7cb8518c0f4de319471237ea10cf688b8d0e9d0225","impliedFormat":1},{"version":"257b83faa134d971c738a6b9e4c47e59bb7b23274719d92197580dd662bfafc3","impliedFormat":99},{"version":"556ccd493ec36c7d7cb130d51be66e147b91cc1415be383d71da0f1e49f742a9","impliedFormat":1},{"version":"b6d03c9cfe2cf0ba4c673c209fcd7c46c815b2619fd2aad59fc4229aaef2ed43","impliedFormat":1},{"version":"95aba78013d782537cc5e23868e736bec5d377b918990e28ed56110e3ae8b958","impliedFormat":1},{"version":"670a76db379b27c8ff42f1ba927828a22862e2ab0b0908e38b671f0e912cc5ed","impliedFormat":1},{"version":"13b77ab19ef7aadd86a1e54f2f08ea23a6d74e102909e3c00d31f231ed040f62","impliedFormat":1},{"version":"069bebfee29864e3955378107e243508b163e77ab10de6a5ee03ae06939f0bb9","impliedFormat":1},{"version":"26e0ffceb2198feb1ef460d5d14111c69ad07d44c5a67fd4bfeb74c969aa9afb","impliedFormat":99},{"version":"d920c349abc38c151f86499ae2c16d07862cc103e8dd3c781710aabbdb667e61","impliedFormat":1},{"version":"9e1fca16eee86617c3984ff6769de80260443cb3ca14b4d7bf1b99ddfb586398","impliedFormat":1},{"version":"55bbfa2fcb7692e366773b23a0338463fc9254301414f861a3ae46ff000b5783","affectsGlobalScope":true,"impliedFormat":1},{"version":"c400678110f688feba4d6d3f93269fca02834bb3d6a1ecd99b28b3f69f7d23f4","impliedFormat":1},{"version":"0531ff3bd78d643b584e02909c5fdb86938169d818afbbef1bc3a2a768ab32ec","impliedFormat":1},{"version":"dedb427b9d3d0240fc4c7cd1109ad0763e5eba421b6943950d6793716d343490","impliedFormat":1},"610c852d22b2be52044cb5fe0bb70ae8e49facd612bf1e9d849e48ee37cdd30e","541405ce31869071868b8e673d3219cf96210759a6403fa07a1106a87e7c8812","a4dc27b32ab11df6d68010d1a4d4319f10e5a7af0c4a996d178d8a8850cbef69","a06f30a2326ace98017879129feb720dced9e59cebb75db7089b62a6bca0eac5","eb3aa796cc9d6182a201a149dc7f2abe88dc0a67c0761a7a3ed31ec40acdcbf1","e1761c7bb2c9aae36dd1a0392c424bf9049469658204bd79650039d7cb8b657b","b23115c51a5e102dc4a47936df71fb146fd7b557e78bddcc0ade93bad70becb8","3345a2df956036a09582719b2d73f6964de16666a34b723c848a156dfef090e2","3b12481ec238dce67790ec28618e8818c6853b8e9e4e3804ce55281eee087035","3622f4e420ea1e3399869344ac0a14642dc4b808a19c759ec4a6eb4e8f9a967c","12d5b5ceacf951dbaa52f6e8b044f3744b6b217a96a1e31aace998891c9f36d2","af66b3ab4257c74ea741335782fbf93730c4d45353586ea5228852c1bf613792","496af218a8c9ce9f1b98d1f4a8ed8a770a0cd6945912d326c906ca4a5d6834f4","2fd2f2cc090ccecfae1e9eb97a9fe6c2cc440b46c5b977e75d5111dc52dd711d","14460eaf384d40023564d43cc3842d4aa18e6a8977aed3add6a0d448e8a46e10","2aa51a2e027da7e8b1cc170e5ae0f33f84c9d242cae8a044c3b65fa9b1d1c563","220af1fe3e37eedcef0d014de54ab2da463f976c2848c32ff57fae03f534dbde",{"version":"7bc71d52df9d8e5cc55218d347a91b1758b38341f9cbbac0b80057aa9d93daa6","impliedFormat":1},{"version":"84606cedd02b8e7d23044f2df15b6c7cbba553d58aaf48344e4b299fce3acb05","impliedFormat":1},{"version":"ae77d81a5541a8abb938a0efedf9ac4bea36fb3a24cc28cfa11c598863aba571","impliedFormat":1},{"version":"42baf4ca38c38deaf411ea73f37bc39ff56c6e5c761a968b64ac1b25c92b5cd8","impliedFormat":1},{"version":"4f6ae308c5f2901f2988c817e1511520619e9025b9b12cc7cce2ab2e6ffed78a","impliedFormat":1},{"version":"8718fa41d7cf4aa91de4e8f164c90f88e0bf343aa92a1b9b725a9c675c64e16b","impliedFormat":1},{"version":"f992cd6cc0bcbaa4e6c810468c90f2d8595f8c6c3cf050c806397d3de8585562","impliedFormat":1},{"version":"427fe2004642504828c1476d0af4270e6ad4db6de78c0b5da3e4c5ca95052a99","impliedFormat":1},{"version":"2eeffcee5c1661ddca53353929558037b8cf305ffb86a803512982f99bcab50d","impliedFormat":99},{"version":"9afb4cb864d297e4092a79ee2871b5d3143ea14153f62ef0bb04ede25f432030","affectsGlobalScope":true,"impliedFormat":99},{"version":"4c9da7d99c94f1da3eca35c7ee44cf62569f3b69863ceed9afaaedb95a86337c","impliedFormat":1},{"version":"206fabd39297fecdcd46451a5695bbb4df96761f4818564f1ae4f3a935b8f683","impliedFormat":1},{"version":"9f5868b1ffbb19aabaf87e4f756900bb76379f9e66699a163f94de21dba16835","impliedFormat":1},{"version":"754907a05bb4c0d1777d1d98f8d66132b24f43415bbca46ae869158d711d750d","impliedFormat":1},{"version":"43c6306851a66a06e170fc898fb8a6b0a1cbfa8c32c4d7c72e6b203b7d4f99e3","impliedFormat":1},{"version":"1748c03e7a7d118f7f6648c709507971eb0d416f489958492c5ae625de445184","impliedFormat":1},{"version":"6fc1a4f64372593767a9b7b774e9b3b92bf04e8785c3f9ea98973aa9f4bbe490","impliedFormat":1},{"version":"469532350a366536390c6eb3bde6839ec5c81fe1227a6b7b6a70202954d70c40","impliedFormat":1},{"version":"54e79224429e911b5d6aeb3cf9097ec9fd0f140d5a1461bbdece3066b17c232c","impliedFormat":1},{"version":"d5895252efa27a50f134a9b580aa61f7def5ab73d0a8071f9b5bf9a317c01c2d","impliedFormat":1},{"version":"421c3f008f6ef4a5db2194d58a7b960ef6f33e94b033415649cd557be09ef619","impliedFormat":1},{"version":"57568ff84b8ba1a4f8c817141644b49252cc39ec7b899e4bfba0ec0557c910a0","impliedFormat":1},{"version":"fb893a0dfc3c9fb0f9ca93d0648694dd95f33cbad2c0f2c629f842981dfd4e2e","impliedFormat":1},{"version":"3eb11dbf3489064a47a2e1cf9d261b1f100ef0b3b50ffca6c44dd99d6dd81ac1","impliedFormat":1},{"version":"6382638cfd6a8f05ac8277689de17ba4cd46f8aacefd254a993a53fde9ddc797","impliedFormat":1},{"version":"15fe687c59d62741b4494d5e623d497d55eb38966ecf5bea7f36e48fc3fbe15e","impliedFormat":1},{"version":"2c3b8be03577c98530ef9cb1a76e2c812636a871f367e9edf4c5f3ce702b77f8","affectsGlobalScope":true,"impliedFormat":1},{"version":"ecec8f82ddf42db544c8320eddf7f09a9a788723f7473e82e903401a3d14d488","impliedFormat":1},{"version":"5d08a179b846f5ee674624b349ebebe2121c455e3a265dc93da4e8d9e89722b4","impliedFormat":1},{"version":"ed19da84b7dbf00952ad0b98ce5c194f1903bcf7c94d8103e8e0d63b271543ae","impliedFormat":1},{"version":"89121c1bf2990f5219bfd802a3e7fc557de447c62058d6af68d6b6348d64499a","impliedFormat":1},{"version":"79b4369233a12c6fa4a07301ecb7085802c98f3a77cf9ab97eee27e1656f82e6","impliedFormat":1},{"version":"117816592ad26d78651f5e8322ea571fd8d413d8d3b7d79944d27468e2636989","impliedFormat":1},{"version":"f3d8c757e148ad968f0d98697987db363070abada5f503da3c06aefd9d4248c1","impliedFormat":1},{"version":"fec943fdb3275eb6e006b35e04a8e2e99e9adf3f4b969ddf15315ac7575a93e4","impliedFormat":1},{"version":"380b919bfa0516118edaf25b99e45f855e7bc3fd75ce4163a1cfe4a666388804","impliedFormat":1},{"version":"0d89e5c4ce6e3096e64504e1fa45a8ddccf488cb5fdc1980ea09db2a451f0b91","impliedFormat":1},{"version":"fcf79300e5257a23ed3bacaa6861d7c645139c6f7ece134d15e6669447e5e6db","impliedFormat":1},{"version":"187119ff4f9553676a884e296089e131e8cc01691c546273b1d0089c3533ce42","impliedFormat":1},{"version":"aa2c18a1b5a086bbcaae10a4efba409cc95ba7287d8cf8f2591b53704fea3dea","impliedFormat":1},{"version":"5a0b15210129310cee9fa6af9200714bb4b12af4a04d890e15f34dbea1cf1852","impliedFormat":1},{"version":"0244119dbcbcf34faf3ffdae72dab1e9bc2bc9efc3c477b2240ffa94af3bca56","impliedFormat":1},{"version":"00baffbe8a2f2e4875367479489b5d43b5fc1429ecb4a4cc98cfc3009095f52a","impliedFormat":1},{"version":"a873c50d3e47c21aa09fbe1e2023d9a44efb07cc0cb8c72f418bf301b0771fd3","impliedFormat":1},{"version":"7c14ccd2eaa82619fffc1bfa877eb68a012e9fb723d07ee98db451fadb618906","impliedFormat":1},{"version":"49c36529ee09ea9ce19525af5bb84985ea8e782cb7ee8c493d9e36d027a3d019","impliedFormat":1},{"version":"df996e25faa505f85aeb294d15ebe61b399cf1d1e49959cdfaf2cc0815c203f9","impliedFormat":1},{"version":"4f6a12044ee6f458db11964153830abbc499e73d065c51c329ec97407f4b13dd","impliedFormat":1},{"version":"cf93e7b09b66e142429611c27ba2cbf330826057e3c793e1e2861e976fae3940","impliedFormat":99},{"version":"90e727d145feb03695693fdc9f165a4dc10684713ee5f6aa81e97a6086faa0f8","impliedFormat":99},{"version":"ee2c6ec73c636c9da5ab4ce9227e5197f55a57241d66ea5828f94b69a4a09a2d","impliedFormat":99},{"version":"afaf64477630c7297e3733765046c95640ab1c63f0dfb3c624691c8445bc3b08","impliedFormat":99},{"version":"5aa03223a53ad03171988820b81a6cae9647eabcebcb987d1284799de978d8e3","impliedFormat":99},{"version":"7f50c8914983009c2b940923d891e621db624ba32968a51db46e0bf480e4e1cb","impliedFormat":99},{"version":"90fc18234b7d2e19d18ac026361aaf2f49d27c98dc30d9f01e033a9c2b01c765","impliedFormat":99},{"version":"a980e4d46239f344eb4d5442b69dcf1d46bd2acac8d908574b5a507181f7e2a1","impliedFormat":99},{"version":"bbbfa4c51cdaa6e2ef7f7be3ae199b319de6b31e3b5afa7e5a2229c14bb2568a","impliedFormat":99},{"version":"bc7bfe8f48fa3067deb3b37d4b511588b01831ba123a785ea81320fe74dd9540","impliedFormat":99},{"version":"fd60c0aaf7c52115f0e7f367d794657ac18dbb257255777406829ab65ca85746","impliedFormat":99},{"version":"15c17866d58a19f4a01a125f3f511567bd1c22235b4fd77bf90c793bf28388c3","impliedFormat":99},{"version":"51301a76264b1e1b4046f803bda44307fba403183bc274fe9e7227252d7315cb","impliedFormat":99},{"version":"ddef23e8ace6c2b2ddf8d8092d30b1dd313743f7ff47b2cbb43f36c395896008","impliedFormat":99},{"version":"9e42df47111429042b5e22561849a512ad5871668097664b8fb06a11640140ac","impliedFormat":99},{"version":"391fcc749c6f94c6c4b7f017c6a6f63296c1c9ae03fa639f99337dddb9cc33fe","impliedFormat":99},{"version":"ac4706eb1fb167b19f336a93989763ab175cd7cc6227b0dcbfa6a7824c6ba59a","impliedFormat":99},{"version":"633220dc1e1a5d0ccf11d3c3e8cadc9124daf80fef468f2ff8186a2775229de3","impliedFormat":99},{"version":"6de22ad73e332e513454f0292275155d6cb77f2f695b73f0744928c4ebb3a128","impliedFormat":99},{"version":"ebe0e3c77f5114b656d857213698fade968cff1b3a681d1868f3cfdd09d63b75","impliedFormat":99},{"version":"22c27a87488a0625657b52b9750122814c2f5582cac971484cda0dcd7a46dc3b","impliedFormat":99},{"version":"7e7a817c8ec57035b2b74df8d5dbcc376a4a60ad870b27ec35463536158e1156","impliedFormat":99},{"version":"0e2061f86ca739f34feae42fd7cce27cc171788d251a587215b33eaec456e786","impliedFormat":99},{"version":"91659b2b090cadffdb593736210910508fc5b77046d4ce180b52580b14b075ec","impliedFormat":99},{"version":"d0f6c657c45faaf576ca1a1dc64484534a8dc74ada36fd57008edc1aab65a02b","impliedFormat":99},{"version":"ce0c52b1ebc023b71d3c1fe974804a2422cf1d85d4af74bb1bced36ff3bff8b5","impliedFormat":99},{"version":"9c6acb4a388887f9a5552eda68987ee5d607152163d72f123193a984c48157c9","impliedFormat":99},{"version":"90d0a9968cbb7048015736299f96a0cceb01cf583fd2e9a9edbc632ac4c81b01","impliedFormat":99},{"version":"49abec0571c941ab6f095885a76828d50498511c03bb326eec62a852e58000c5","impliedFormat":99},{"version":"8eeb4a4ff94460051173d561749539bca870422a6400108903af2fb7a1ffe3d7","impliedFormat":99},{"version":"49e39b284b87452fed1e27ac0748ba698f5a27debe05084bc5066b3ecf4ed762","impliedFormat":99},{"version":"59dcf835762f8df90fba5a3f8ba87941467604041cf127fb456543c793b71456","impliedFormat":99},{"version":"33e0c4c683dcaeb66bedf5bb6cc35798d00ac58d7f3bc82aadb50fa475781d60","impliedFormat":99},{"version":"605839abb6d150b0d83ed3712e1b3ffbeb309e382770e7754085d36bc2d84a4c","impliedFormat":99},{"version":"a862dcb740371257e3dae1ab379b0859edcb5119484f8359a5e6fb405db9e12e","impliedFormat":99},{"version":"0f0a16a0e8037c17e28f537028215e87db047eba52281bd33484d5395402f3c1","impliedFormat":99},{"version":"cf533aed4c455b526ddccbb10dae7cc77e9269c3d7862f9e5cedbd4f5c92e05e","impliedFormat":99},{"version":"f8a60ca31702a0209ef217f8f3b4b32f498813927df2304787ac968c78d8560d","impliedFormat":99},{"version":"530192961885d3ddad87bf9c4390e12689fa29ff515df57f17a57c9125fc77c3","impliedFormat":99},{"version":"165ba9e775dd769749e2177c383d24578e3b212e4774b0a72ad0f6faee103b68","impliedFormat":99},{"version":"61448f238fdfa94e5ccce1f43a7cced5e548b1ea2d957bec5259a6e719378381","impliedFormat":99},{"version":"69fa523e48131ced0a52ab1af36c3a922c5fd7a25e474d82117329fe051f5b85","impliedFormat":99},{"version":"fa10b79cd06f5dd03435e184fb05cc5f0d02713bfb4ee9d343db527501be334c","impliedFormat":99},{"version":"c6fb591e363ee4dea2b102bb721c0921485459df23a2d2171af8354cacef4bce","impliedFormat":99},{"version":"ea7e1f1097c2e61ed6e56fa04a9d7beae9d276d87ac6edb0cd39a3ee649cddfe","impliedFormat":99},{"version":"e8cf2659d87462aae9c7647e2a256ac7dcaf2a565a9681bfb49328a8a52861e8","impliedFormat":99},{"version":"7e374cb98b705d35369b3c15444ef2ff5ff983bd2fbb77a287f7e3240abf208c","impliedFormat":99},{"version":"ca75ba1519f9a426b8c512046ebbad58231d8627678d054008c93c51bc0f3fa5","impliedFormat":99},{"version":"ff63760147d7a60dcfc4ac16e40aa2696d016b9ffe27e296b43655dfa869d66b","impliedFormat":99},{"version":"4d434123b16f46b290982907a4d24675442eb651ca95a5e98e4c274be16f1220","impliedFormat":99},{"version":"57263d6ba38046e85f499f3c0ab518cfaf0a5f5d4f53bdae896d045209ab4aff","impliedFormat":99},{"version":"d3a535f2cd5d17f12b1abf0b19a64e816b90c8c10a030b58f308c0f7f2acfe2c","impliedFormat":99},{"version":"be26d49bb713c13bd737d00ae8a61aa394f0b76bc2d5a1c93c74f59402eb8db3","impliedFormat":99},{"version":"c7012003ac0c9e6c9d3a6418128ddebf6219d904095180d4502b19c42f46a186","impliedFormat":99},{"version":"d58c55750756bcf73f474344e6b4a9376e5381e4ba7d834dc352264b491423b6","impliedFormat":99},{"version":"01e2aabfabe22b4bf6d715fc54d72d32fa860a3bd1faa8974e0d672c4b565dfe","impliedFormat":99},{"version":"ba2c489bb2566c16d28f0500b3d98013917e471c40a4417c03991460cb248e88","impliedFormat":99},{"version":"39f94b619f0844c454a6f912e5d6868d0beb32752587b134c3c858b10ecd7056","impliedFormat":99},{"version":"0d2d8b0477b1cf16b34088e786e9745c3e8145bc8eea5919b700ad054e70a095","impliedFormat":99},{"version":"2a5e963b2b8f33a50bb516215ba54a20801cb379a8e9b1ae0b311e900dc7254c","impliedFormat":99},{"version":"d8307f62b55feeb5858529314761089746dce957d2b8fd919673a4985fa4342a","impliedFormat":99},{"version":"bf449ec80fc692b2703ad03e64ae007b3513ecd507dc2ab77f39be6f578e6f5c","impliedFormat":99},{"version":"f780213dd78998daf2511385dd51abf72905f709c839a9457b6ba2a55df57be7","impliedFormat":99},{"version":"2b7843e8a9a50bdf511de24350b6d429a3ee28430f5e8af7d3599b1e9aa7057f","impliedFormat":99},{"version":"05d95be6e25b4118c2eb28667e784f0b25882f6a8486147788df675c85391ab7","impliedFormat":99},{"version":"62d2721e9f2c9197c3e2e5cffeb2f76c6412121ae155153179049890011eb785","impliedFormat":99},{"version":"ff5668fb7594c02aca5e7ba7be6c238676226e450681ca96b457f4a84898b2d9","impliedFormat":99},{"version":"59fd37ea08657fef36c55ddea879eae550ffe21d7e3a1f8699314a85a30d8ae9","impliedFormat":99},{"version":"84e23663776e080e18b25052eb3459b1a0486b5b19f674d59b96347c0cb7312a","impliedFormat":99},{"version":"43e5934c7355731eec20c5a2aa7a859086f19f60a4e5fcd80e6684228f6fb767","impliedFormat":99},{"version":"a49c210c136c518a7c08325f6058fc648f59f911c41c93de2026db692bba0e47","impliedFormat":99},{"version":"1a92f93597ebc451e9ef4b158653c8d31902de5e6c8a574470ecb6da64932df4","impliedFormat":99},{"version":"256513ad066ac9898a70ca01e6fbdb3898a4e0fe408fbf70608fdc28ac1af224","impliedFormat":99},{"version":"d9835850b6cc05c21e8d85692a8071ebcf167a4382e5e39bf700c4a1e816437e","impliedFormat":99},{"version":"e5ab7190f818442e958d0322191c24c2447ddceae393c4e811e79cda6bd49836","impliedFormat":99},{"version":"91b4b77ef81466ce894f1aade7d35d3589ddd5c9981109d1dea11f55a4b807a0","impliedFormat":99},{"version":"03abb209bed94c8c893d9872639e3789f0282061c7aa6917888965e4047a8b5f","impliedFormat":99},{"version":"e97a07901de562219f5cba545b0945a1540d9663bd9abce66495721af3903eec","impliedFormat":99},{"version":"bf39ed1fdf29bc8178055ec4ff32be6725c1de9f29c252e31bdc71baf5c227e6","impliedFormat":99},{"version":"985eabf06dac7288fc355435b18641282f86107e48334a83605739a1fe82ac15","impliedFormat":99},{"version":"6112d33bcf51e3e6f6a81e419f29580e2f8e773529d53958c7c1c99728d4fb2e","impliedFormat":99},{"version":"89e9f7e87a573504acc2e7e5ad727a110b960330657d1b9a6d3526e77c83d8be","impliedFormat":99},{"version":"44bbb88abe9958c7c417e8687abf65820385191685009cc4b739c2d270cb02e9","impliedFormat":99},{"version":"ab4b506b53d2c4aec4cc00452740c540a0e6abe7778063e95c81a5cd557c19eb","impliedFormat":99},{"version":"858757bde6d615d0d1ee474c972131c6d79c37b0b61897da7fbd7110beb8af12","impliedFormat":99},{"version":"60b9dea33807b086a1b4b4b89f72d5da27ad0dd36d6436a6e306600c47438ac4","impliedFormat":99},{"version":"409c963b1166d0c1d49fdad1dfeb4de27fd2d6662d699009857de9baf43ca7c3","impliedFormat":99},{"version":"b7674ecfeb5753e965404f7b3d31eec8450857d1a23770cb867c82f264f546ab","impliedFormat":99},{"version":"c9800b9a9ad7fcdf74ed8972a5928b66f0e4ff674d55fd038a3b1c076911dcbe","impliedFormat":99},{"version":"99864433e35b24c61f8790d2224428e3b920624c01a6d26ea8b27ee1f62836bb","impliedFormat":99},{"version":"c391317b9ff8f87d28c6bfe4e50ed92e8f8bfab1bb8a03cd1fe104ff13186f83","impliedFormat":99},{"version":"42bdc3c98446fdd528e2591213f71ce6f7008fb9bb12413bd57df60d892a3fb5","impliedFormat":99},{"version":"542d2d689b58c25d39a76312ccaea2fcd10a45fb27b890e18015399c8032e2d9","impliedFormat":99},{"version":"97d1656f0a563dbb361d22b3d7c2487427b0998f347123abd1c69a4991326c96","impliedFormat":99},{"version":"d4f53ed7960c9fba8378af3fa28e3cc483d6c0b48e4a152a83ff0973d507307d","impliedFormat":99},{"version":"0665de5280d65ec32776dc55fb37128e259e60f389cde5b9803cf9e81ad23ce0","impliedFormat":99},{"version":"b6dc8fd1c6092da86725c338ca6c263d1c6dd3073046d3ec4eb2d68515062da2","impliedFormat":99},{"version":"d9198a0f01f00870653347560e10494efeca0bfa2de0988bd5d883a9d2c47edb","impliedFormat":99},{"version":"d4279865b926d7e2cfe8863b2eae270c4c035b6e923af8f9d7e6462d68679e07","impliedFormat":99},{"version":"73b6945448bb3425b764cfe7b1c4b0b56c010cc66e5f438ef320c53e469797eb","impliedFormat":99},{"version":"cf72fd8ffa5395f4f1a26be60246ec79c5a9ad201579c9ba63fd2607b5daf184","impliedFormat":99},{"version":"301a458744666096f84580a78cc3f6e8411f8bab92608cdaa33707546ca2906f","impliedFormat":99},{"version":"711e70c0916ff5f821ea208043ecd3e67ed09434b8a31d5616286802b58ebebe","impliedFormat":99},{"version":"e1f2fd9f88dd0e40c358fbf8c8f992211ab00a699e7d6823579b615b874a8453","impliedFormat":99},{"version":"17db3a9dcb2e1689ff7ace9c94fa110c88da64d69f01dc2f3cec698e4fc7e29e","impliedFormat":99},{"version":"73fb07305106bb18c2230890fcacf910fd1a7a77d93ac12ec40bc04c49ee5b8e","impliedFormat":99},{"version":"2c5f341625a45530b040d59a4bc2bc83824d258985ede10c67005be72d3e21d0","impliedFormat":99},{"version":"c4a262730d4277ecaaf6f6553dabecc84dcca8decaebbf2e16f1df8bbd996397","impliedFormat":99},{"version":"c23c533d85518f3358c55a7f19ab1a05aad290251e8bba0947bd19ea3c259467","impliedFormat":99},{"version":"5d0322a0b8cdc67b8c71e4ccaa30286b0c8453211d4c955a217ac2d3590e911f","impliedFormat":99},{"version":"f5e4032b6e4e116e7fec5b2620a2a35d0b6b8b4a1cc9b94a8e5ee76190153110","impliedFormat":99},{"version":"9ab26cb62a0e86ab7f669c311eb0c4d665457eb70a103508aa39da6ccee663da","impliedFormat":99},{"version":"5f64d1a11d8d4ce2c7ee3b72471df76b82d178a48964a14cdfdc7c5ef7276d70","impliedFormat":99},{"version":"24e2fbc48f65814e691d9377399807b9ec22cd54b51d631ba9e48ee18c5939dd","impliedFormat":99},{"version":"bfa2648b2ee90268c6b6f19e84da3176b4d46329c9ec0555d470e647d0568dfb","impliedFormat":99},{"version":"75ef3cb4e7b3583ba268a094c1bd16ce31023f2c3d1ac36e75ca65aca9721534","impliedFormat":99},{"version":"3be6b3304a81d0301838860fd3b4536c2b93390e785808a1f1a30e4135501514","impliedFormat":99},{"version":"da66c1b3e50ef9908e31ce7a281b137b2db41423c2b143c62524f97a536a53d9","impliedFormat":99},{"version":"3ada1b216e45bb9e32e30d8179a0a95870576fe949c33d9767823ccf4f4f4c97","impliedFormat":99},{"version":"1ace2885dffab849f7c98bffe3d1233260fbf07ee62cb58130167fd67a376a65","impliedFormat":99},{"version":"2126e5989c0ca5194d883cf9e9c10fe3e5224fbd3e4a4a6267677544e8be0aae","impliedFormat":99},{"version":"41a6738cf3c756af74753c5033e95c5b33dfc1f6e1287fa769a1ac4027335bf5","impliedFormat":99},{"version":"6e8630be5b0166cbc9f359b9f9e42801626d64ff1702dcb691af811149766154","impliedFormat":99},{"version":"e36b77c04e00b4a0bb4e1364f2646618a54910c27f6dc3fc558ca2ced8ca5bc5","impliedFormat":99},{"version":"2c4ea7e9f95a558f46c89726d1fedcb525ef649eb755a3d7d5055e22b80c2904","impliedFormat":99},{"version":"4875d65190e789fad05e73abd178297b386806b88b624328222d82e455c0f2e7","impliedFormat":99},{"version":"bf5302ecfaacee37c2316e33703723d62e66590093738c8921773ee30f2ecc38","impliedFormat":99},{"version":"62684064fe034d54b87f62ad416f41b98a405dee4146d0ec03b198c3634ea93c","impliedFormat":99},{"version":"be02cbdb1688c8387f8a76a9c6ed9d75d8bb794ec5b9b1d2ba3339a952a00614","impliedFormat":99},{"version":"cefaff060473a5dbf4939ee1b52eb900f215f8d6249dc7c058d6b869d599983c","impliedFormat":99},{"version":"b2797235a4c1a7442a6f326f28ffb966226c3419399dbb33634b8159af2c712f","impliedFormat":99},{"version":"164d633bbd4329794d329219fc173c3de85d5ad866d44e5b5f0fb60c140e98f2","impliedFormat":99},{"version":"b74300dd0a52eaf564b3757c07d07e1d92def4e3b8708f12eedb40033e4cafe9","impliedFormat":99},{"version":"a792f80b1e265b06dce1783992dbee2b45815a7bdc030782464b8cf982337cf2","impliedFormat":99},{"version":"8816b4b3a87d9b77f0355e616b38ed5054f993cc4c141101297f1914976a94b1","impliedFormat":99},{"version":"0f35e4da974793534c4ca1cdd9491eab6993f8cf47103dadfc048b899ed9b511","impliedFormat":99},{"version":"0ccdfcaebf297ec7b9dde20bbbc8539d5951a3d8aaa40665ca469da27f5a86e1","impliedFormat":99},{"version":"7fcb05c8ce81f05499c7b0488ae02a0a1ac6aebc78c01e9f8c42d98f7ba68140","impliedFormat":99},{"version":"81c376c9e4d227a4629c7fca9dde3bbdfa44bd5bd281aee0ed03801182368dc5","impliedFormat":99},{"version":"0f2448f95110c3714797e4c043bbc539368e9c4c33586d03ecda166aa9908843","impliedFormat":99},{"version":"b2f1a443f7f3982d7325775906b51665fe875c82a62be3528a36184852faa0bb","impliedFormat":99},{"version":"7568ff1f23363d7ee349105eb936e156d61aea8864187a4c5d85c60594b44a25","impliedFormat":99},{"version":"8c4d1d9a4eba4eac69e6da0f599a424b2689aee55a455f0b5a7f27a807e064db","impliedFormat":99},{"version":"e1beb9077c100bdd0fc8e727615f5dae2c6e1207de224569421907072f4ec885","impliedFormat":99},{"version":"3dda13836320ec71b95a68cd3d91a27118b34c05a2bfda3e7e51f1d8ca9b960b","impliedFormat":99},{"version":"fedc79cb91f2b3a14e832d7a8e3d58eb02b5d5411c843fcbdc79e35041316b36","impliedFormat":99},{"version":"99f395322ffae908dcdfbaa2624cc7a2a2cb7b0fbf1a1274aca506f7b57ebcb5","impliedFormat":99},{"version":"5e1f7c43e8d45f2222a5c61cbc88b074f4aaf1ca4b118ac6d6123c858efdcd71","impliedFormat":99},{"version":"7388273ab71cb8f22b3f25ffd8d44a37d5740077c4d87023da25575204d57872","impliedFormat":99},{"version":"0a48ceb01a0fdfc506aa20dfd8a3563edbdeaa53a8333ddf261d2ee87669ea7b","impliedFormat":99},{"version":"3182d06b874f31e8e55f91ea706c85d5f207f16273480f46438781d0bd2a46a1","impliedFormat":99},{"version":"ccd47cab635e8f71693fa4e2bbb7969f559972dae97bd5dbd1bbfee77a63b410","impliedFormat":99},{"version":"89770fa14c037f3dc3882e6c56be1c01bb495c81dec96fa29f868185d9555a5d","impliedFormat":99},{"version":"7048c397f08c54099c52e6b9d90623dc9dc6811ea142f8af3200e40d66a972e1","impliedFormat":99},{"version":"512120cd6f026ce1d3cf686c6ab5da80caa40ef92aa47466ec60ba61a48b5551","impliedFormat":99},{"version":"6cd0cb7f999f221e984157a7640e7871960131f6b221d67e4fdc2a53937c6770","impliedFormat":99},{"version":"f48b84a0884776f1bc5bf0fcf3f69832e97b97dc55d79d7557f344de900d259b","impliedFormat":99},{"version":"dca490d986411644b0f9edf6ea701016836558e8677c150dca8ad315178ec735","impliedFormat":99},{"version":"a028a04948cf98c1233166b48887dad324e8fe424a4be368a287c706d9ccd491","impliedFormat":99},{"version":"3046ed22c701f24272534b293c10cfd17b0f6a89c2ec6014c9a44a90963dfa06","impliedFormat":99},{"version":"394da10397d272f19a324c95bea7492faadf2263da157831e02ae1107bd410f5","impliedFormat":99},{"version":"0580595a99248b2d30d03f2307c50f14eb21716a55beb84dd09d240b1b087a42","impliedFormat":99},{"version":"a7da9510150f36a9bea61513b107b59a423fdff54429ad38547c7475cd390e95","impliedFormat":99},{"version":"659615f96e64361af7127645bb91f287f7b46c5d03bea7371e6e02099226d818","impliedFormat":99},{"version":"1f2a42974920476ce46bb666cd9b3c1b82b2072b66ccd0d775aa960532d78176","impliedFormat":99},{"version":"500b3ae6095cbab92d81de0b40c9129f5524d10ad955643f81fc07d726c5a667","impliedFormat":99},{"version":"a957ad4bd562be0662fb99599dbcf0e16d1631f857e5e1a83a3f3afb6c226059","impliedFormat":99},{"version":"e57a4915266a6a751c6c172e8f30f6df44a495608613e1f1c410196207da9641","impliedFormat":99},{"version":"7a12e57143b7bc5a52a41a8c4e6283a8f8d59a5e302478185fb623a7157fff5e","impliedFormat":99},{"version":"17b3426162e1d9cb0a843e8d04212aabe461d53548e671236de957ed3ae9471b","impliedFormat":99},{"version":"f38e86eb00398d63180210c5090ef6ed065004474361146573f98b3c8a96477d","impliedFormat":99},{"version":"231d9e32382d3971f58325e5a85ba283a2021243651cb650f82f87a1bf62d649","impliedFormat":99},{"version":"6532e3e87b87c95f0771611afce929b5bad9d2c94855b19b29b3246937c9840b","impliedFormat":99},{"version":"65704bbb8f0b55c73871335edd3c9cead7c9f0d4b21f64f5d22d0987c45687f0","impliedFormat":99},{"version":"787232f574af2253ac860f22a445c755d57c73a69a402823ae81ba0dfdd1ce23","impliedFormat":99},{"version":"5e63903cd5ebce02486b91647d951d61a16ad80d65f9c56581cd624f39a66007","impliedFormat":99},{"version":"bcc89a120d8f3c02411f4df6b1d989143c01369314e9b0e04794441e6b078d22","impliedFormat":99},{"version":"d17531ef42b7c76d953f63bd5c5cd927c4723e62a7e0b2badf812d5f35f784eb","impliedFormat":99},{"version":"6d4ee1a8e3a97168ea4c4cc1c68bb61a3fd77134f15c71bb9f3f63df3d26b54c","impliedFormat":99},{"version":"1eb04fea6b47b16922ed79625d90431a8b2fc7ba9d5768b255e62df0c96f1e3a","impliedFormat":99},{"version":"de0c2eece83bd81b8682f4496f558beb728263e17e74cbc4910e5c9ce7bef689","impliedFormat":99},{"version":"98866542d45306dab48ecc3ddd98ee54fa983353bc3139dfbc619df882f54d90","impliedFormat":99},{"version":"9e04c7708917af428c165f1e38536ddb2e8ecd576f55ed11a97442dc34b6b010","impliedFormat":99},{"version":"31fe6f6d02b53c1a7c34b8d8f8c87ee9b6dd4b67f158cbfff3034b4f3f69c409","impliedFormat":99},{"version":"2e1d853f84188e8e002361f4bfdd892ac31c68acaeac426a63cd4ff7abf150d0","impliedFormat":99},{"version":"666b5289ec8a01c4cc0977c62e3fd32e89a8e3fd9e97c8d8fd646f632e63c055","impliedFormat":99},{"version":"a1107bbb2b10982dba1f7958a6a5cf841e1a19d6976d0ecdc4c43269c7b0eaf2","impliedFormat":99},{"version":"07fa6122f7495331f39167ec9e4ebd990146a20f99c16c17bc0a98aa81f63b27","impliedFormat":99},{"version":"39c1483481b35c2123eaab5094a8b548a0c3f1e483ab7338102c3291f1ab18bf","impliedFormat":99},{"version":"b73e6242c13796e7d5fba225bf1c07c8ee66d31b7bb65f45be14226a9ae492d2","impliedFormat":99},{"version":"f2931608d541145d189390d6cfb74e1b1e88f73c0b9a80c4356a4daa7fa5e005","impliedFormat":99},{"version":"8684656fe3bf1425a91bd62b8b455a1c7ec18b074fd695793cfae44ae02e381a","impliedFormat":99},{"version":"ccf0b9057dd65c7fb5e237de34f706966ebc30c6d3669715ed05e76225f54fbd","impliedFormat":99},{"version":"d930f077da575e8ea761e3d644d4c6279e2d847bae2b3ea893bbd572315acc21","impliedFormat":99},{"version":"19b0616946cb615abde72c6d69049f136cc4821b784634771c1d73bec8005f73","impliedFormat":99},{"version":"553312560ad0ef97b344b653931935d6e80840c2de6ab90b8be43cbacf0d04cf","impliedFormat":99},{"version":"1225cf1910667bfd52b4daa9974197c3485f21fe631c3ce9db3b733334199faa","impliedFormat":99},{"version":"f7cb9e46bd6ab9d620d68257b525dbbbbc9b0b148adf500b819d756ebc339de0","impliedFormat":99},{"version":"e46d6c3120aca07ae8ec3189edf518c667d027478810ca67a62431a0fa545434","impliedFormat":99},{"version":"9d234b7d2f662a135d430d3190fc21074325f296273125244b2bf8328b5839a0","impliedFormat":99},{"version":"0554ef14d10acea403348c53436b1dd8d61e7c73ef5872e2fe69cc1c433b02f8","impliedFormat":99},{"version":"2f6ae5538090db60514336bd1441ca208a8fab13108cfa4b311e61eaca5ff716","impliedFormat":99},{"version":"17bf4ce505a4cff88fb56177a8f7eb48aa55c22ccc4cce3e49cc5c8ddc54b07d","impliedFormat":99},{"version":"3d735f493d7da48156b79b4d8a406bf2bbf7e3fe379210d8f7c085028143ee40","impliedFormat":99},{"version":"41de1b3ddd71bd0d9ed7ac217ca1b15b177dd731d5251cde094945c20a715d03","impliedFormat":99},{"version":"17d9c562a46c6a25bc2f317c9b06dd4e8e0368cbe9bdf89be6117aeafd577b36","impliedFormat":99},{"version":"ded799031fe18a0bb5e78be38a6ae168458ff41b6c6542392b009d2abe6a6f32","impliedFormat":99},{"version":"ed48d467a7b25ee1a2769adebc198b647a820e242c96a5f96c1e6c27a40ab131","impliedFormat":99},{"version":"b914114df05f286897a1ae85d2df39cfd98ed8da68754d73cf830159e85ddd15","impliedFormat":99},{"version":"73881e647da3c226f21e0b80e216feaf14a5541a861494c744e9fbe1c3b3a6af","impliedFormat":99},{"version":"d79e1d31b939fa99694f2d6fbdd19870147401dbb3f42214e84c011e7ec359ab","impliedFormat":99},{"version":"4f71097eae7aa37941bab39beb2e53e624321fd341c12cc1d400eb7a805691ff","impliedFormat":99},{"version":"58ebb4f21f3a90dda31a01764462aa617849fdb1b592f3a8d875c85019956aff","impliedFormat":99},{"version":"a8e8d0e6efff70f3c28d3e384f9d64530c7a7596a201e4879a7fd75c7d55cbb5","impliedFormat":99},{"version":"df5cbb80d8353bf0511a4047cc7b8434b0be12e280b6cf3de919d5a3380912c0","impliedFormat":99},{"version":"256eb0520e822b56f720962edd7807ed36abdf7ea23bcadf4a25929a3317c8cf","impliedFormat":99},{"version":"9cf2cbc9ceb5f718c1705f37ce5454f14d3b89f690d9864394963567673c1b5c","impliedFormat":99},{"version":"07d3dd790cf1e66bb6fc9806d014dd40bb2055f8d6ca3811cf0e12f92ba4cb9a","impliedFormat":99},{"version":"1f99fd62e9cff9b50c36f368caf3b9fb79fc6f6c75ca5d3c2ec4afaea08d9109","impliedFormat":99},{"version":"6558faaacba5622ef7f1fdfb843cd967af2c105469b9ff5c18a81ce85178fca7","impliedFormat":99},{"version":"34e7f17ae9395b0269cd3f2f0af10709e6dc975c5b44a36b6b70442dc5e25a38","impliedFormat":99},{"version":"a4295111b54f84c02c27e46b0855b02fad3421ae1d2d7e67ecf16cb49538280a","impliedFormat":99},{"version":"ce9746b2ceae2388b7be9fe1f009dcecbc65f0bdbc16f40c0027fab0fb848c3b","impliedFormat":99},{"version":"35ce823a59f397f0e85295387778f51467cea137d787df385be57a2099752bfb","impliedFormat":99},{"version":"2e5acd3ec67bc309e4f679a70c894f809863c33b9572a8da0b78db403edfa106","impliedFormat":99},{"version":"1872f3fcea0643d5e03b19a19d777704320f857d1be0eb4ee372681357e20c88","impliedFormat":99},{"version":"9689628941205e40dcbb2706d1833bd00ce7510d333b2ef08be24ecbf3eb1a37","impliedFormat":99},{"version":"0317a72a0b63094781476cf1d2d27585d00eb2b0ca62b5287124735912f3d048","impliedFormat":99},{"version":"6ce4c0ab3450a4fff25d60a058a25039cffd03141549589689f5a17055ad0545","impliedFormat":99},{"version":"9153ec7b0577ae77349d2c5e8c5dd57163f41853b80c4fb5ce342c7a431cbe1e","impliedFormat":99},{"version":"f490dfa4619e48edd594a36079950c9fca1230efb3a82aaf325047262ba07379","impliedFormat":99},{"version":"674f00085caff46d2cbc76fc74740fd31f49d53396804558573421e138be0c12","impliedFormat":99},{"version":"41d029194c4811f09b350a1e858143c191073007a9ee836061090ed0143ad94f","impliedFormat":99},{"version":"44a6259ffd6febd8510b9a9b13a700e1d022530d8b33663f0735dbb3bee67b3d","impliedFormat":99},{"version":"6f4322500aff8676d9b8eef7711c7166708d4a0686b792aa4b158e276ed946a7","impliedFormat":99},{"version":"e829ff9ecffa3510d3a4d2c3e4e9b54d4a4ccfef004bacbb1d6919ce3ccca01f","impliedFormat":99},{"version":"62e6fec9dbd012460b47af7e727ec4cd34345b6e4311e781f040e6b640d7f93e","impliedFormat":99},{"version":"4d180dd4d0785f2cd140bc069d56285d0121d95b53e4348feb4f62db2d7035d3","impliedFormat":99},{"version":"f1142cbba31d7f492d2e7c91d82211a8334e6642efe52b71d9a82cb95ba4e8ae","impliedFormat":99},{"version":"279cac827be5d48c0f69fe319dc38c876fdd076b66995d9779c43558552d8a50","impliedFormat":99},{"version":"a70ff3c65dc0e7213bfe0d81c072951db9f5b1e640eb66c1eaed0737879c797b","impliedFormat":99},{"version":"f75d3303c1750f4fdacd23354657eca09aae16122c344e65b8c14c570ff67df5","impliedFormat":99},{"version":"3ebae6a418229d4b303f8e0fdb14de83f39fba9f57b39d5f213398bca72137c7","impliedFormat":99},{"version":"21ba07e33265f59d52dece5ac44f933b2b464059514587e64ad5182ddf34a9b0","impliedFormat":99},{"version":"2d3d96efba00493059c460fd55e6206b0667fc2e73215c4f1a9eb559b550021f","impliedFormat":99},{"version":"d23d4a57fff5cec5607521ba3b72f372e3d735d0f6b11a4681655b0bdd0505f4","impliedFormat":99},{"version":"395c1f3da7e9c87097c8095acbb361541480bf5fd7fa92523985019fef7761dd","impliedFormat":99},{"version":"d61f3d719293c2f92a04ba73d08536940805938ecab89ac35ceabc8a48ccb648","impliedFormat":99},{"version":"ca693235a1242bcd97254f43a17592aa84af66ccb7497333ccfea54842fde648","impliedFormat":99},{"version":"cd41cf040b2e368382f2382ec9145824777233730e3965e9a7ba4523a6a4698e","impliedFormat":99},{"version":"2e7a9dba6512b0310c037a28d27330520904cf5063ca19f034b74ad280dbfe71","impliedFormat":99},{"version":"9f2a38baf702e6cb98e0392fa39d25a64c41457a827b935b366c5e0980a6a667","impliedFormat":99},{"version":"c1dc37f0e7252928f73d03b0d6b46feb26dea3d8737a531ca4c0ec4105e33120","impliedFormat":99},{"version":"25126b80243fb499517e94fc5afe5c9c5df3a0105618e33581fb5b2f2622f342","impliedFormat":99},{"version":"d332c2ddcb64012290eb14753c1b49fe3eee9ca067204efba1cf31c1ce1ee020","impliedFormat":99},{"version":"1be8da453470021f6fe936ba19ee0bfebc7cfa2406953fa56e78940467c90769","impliedFormat":99},{"version":"7c9f2d62d83f1292a183a44fb7fb1f16eb9037deb05691d307d4017ac8af850a","impliedFormat":99},{"version":"d0163ab7b0de6e23b8562af8b5b4adea4182884ca7543488f7ac2a3478f3ae6e","impliedFormat":99},{"version":"05224e15c6e51c4c6cd08c65f0766723f6b39165534b67546076c226661db691","impliedFormat":99},{"version":"a5f7158823c7700dd9fc1843a94b9edc309180c969fbfa6d591aeb0b33d3b514","impliedFormat":99},{"version":"7d30937f8cf9bb0d4b2c2a8fb56a415d7ef393f6252b24e4863f3d7b84285724","impliedFormat":99},{"version":"e04d074584483dc9c59341f9f36c7220f16eed09f7af1fa3ef9c64c26095faec","impliedFormat":99},{"version":"619697e06cbc2c77edda949a83a62047e777efacde1433e895b904fe4877c650","impliedFormat":99},{"version":"88d9a8593d2e6aee67f7b15a25bda62652c77be72b79afbee52bea61d5ffb39e","impliedFormat":99},{"version":"044d7acfc9bd1af21951e32252cf8f3a11c8b35a704169115ddcbde9fd717de2","impliedFormat":99},{"version":"a4ca8f13a91bd80e6d7a4f013b8a9e156fbf579bbec981fe724dad38719cfe01","impliedFormat":99},{"version":"5a216426a68418e37e55c7a4366bc50efc99bda9dc361eae94d7e336da96c027","impliedFormat":99},{"version":"13b65b640306755096d304e76d4a237d21103de88b474634f7ae13a2fac722d5","impliedFormat":99},{"version":"7478bd43e449d3ce4e94f3ed1105c65007b21f078b3a791ea5d2c47b30ea6962","impliedFormat":99},{"version":"601d3e8e71b7d6a24fc003aca9989a6c25fa2b3755df196fd0aaee709d190303","impliedFormat":99},{"version":"168e0850fcc94011e4477e31eca81a8a8a71e1aed66d056b7b50196b877e86c8","impliedFormat":99},{"version":"37ba82d63f5f8c6b4fc9b756f24902e47f62ea66aae07e89ace445a54190a86e","impliedFormat":99},{"version":"f5b66b855f0496bc05f1cd9ba51a6a9de3d989b24aa36f6017257f01c8b65a9f","impliedFormat":99},{"version":"823b16d378e8456fcc5503d6253c8b13659be44435151c6b9f140c4a38ec98c1","impliedFormat":99},{"version":"b58b254bf1b586222844c04b3cdec396e16c811463bf187615bb0a1584beb100","impliedFormat":99},{"version":"a367c2ccfb2460e222c5d10d304e980bd172dd668bcc02f6c2ff626e71e90d75","impliedFormat":99},{"version":"0718623262ac94b016cb0cfd8d54e4d5b7b1d3941c01d85cf95c25ec1ba5ed8d","impliedFormat":99},{"version":"d4f3c9a0bd129e9c7cbfac02b6647e34718a2b81a414d914e8bd6b76341172e0","impliedFormat":99},{"version":"824306df6196f1e0222ff775c8023d399091ada2f10f2995ce53f5e3d4aff7a4","impliedFormat":99},{"version":"84ca07a8d57f1a6ba8c0cf264180d681f7afae995631c6ca9f2b85ec6ee06c0f","impliedFormat":99},{"version":"35755e61e9f4ec82d059efdbe3d1abcccc97a8a839f1dbf2e73ac1965f266847","impliedFormat":99},{"version":"64a918a5aa97a37400ec085ffeea12a14211aa799cd34e5dc828beb1806e95bb","impliedFormat":99},{"version":"0c8f5489ba6af02a4b1d5ba280e7badd58f30dc8eb716113b679e9d7c31185e5","impliedFormat":99},{"version":"7b574ca9ae0417203cdfa621ab1585de5b90c4bc6eea77a465b2eb8b92aa5380","impliedFormat":99},{"version":"3334c03c15102700973e3e334954ac1dffb7be7704c67cc272822d5895215c93","impliedFormat":99},{"version":"aabcb169451df7f78eb43567fab877a74d134a0a6d9850aa58b38321374ab7c0","impliedFormat":99},{"version":"1b5effdd8b4e8d9897fc34ab4cd708a446bf79db4cb9a3467e4a30d55b502e14","impliedFormat":99},{"version":"d772776a7aea246fd72c5818de72c3654f556b2cf0d73b90930c9c187cc055fc","impliedFormat":99},{"version":"dbd4bd62f433f14a419e4c6130075199eb15f2812d2d8e7c9e1f297f4daac788","impliedFormat":99},{"version":"427df949f5f10c73bcc77b2999893bc66c17579ad073ee5f5270a2b30651c873","impliedFormat":99},{"version":"c4c1a5565b9b85abfa1d663ca386d959d55361e801e8d49155a14dd6ca41abe1","impliedFormat":99},{"version":"7a45a45c277686aaff716db75a8157d0458a0d854bacf072c47fee3d499d7a99","impliedFormat":99},{"version":"57005b72bce2dc26293e8924f9c6be7ee3a2c1b71028a680f329762fa4439354","impliedFormat":99},{"version":"8f53b1f97c53c3573c16d0225ee3187d22f14f01421e3c6da1a26a1aace32356","impliedFormat":99},{"version":"810fdc0e554ed7315c723b91f6fa6ef3a6859b943b4cd82879641563b0e6c390","impliedFormat":99},{"version":"87a36b177b04d23214aa4502a0011cd65079e208cd60654aefc47d0d65da68ea","impliedFormat":99},{"version":"28a1c17fcbb9e66d7193caca68bbd12115518f186d90fc729a71869f96e2c07b","impliedFormat":99},{"version":"cc2d2abbb1cc7d6453c6fee760b04a516aa425187d65e296a8aacff66a49598a","impliedFormat":99},{"version":"d2413645bc4ab9c3f3688c5281232e6538684e84b49a57d8a1a8b2e5cf9f2041","impliedFormat":99},{"version":"4e6e21a0f9718282d342e66c83b2cd9aa7cd777dfcf2abd93552da694103b3dc","impliedFormat":99},{"version":"9006cc15c3a35e49508598a51664aa34ae59fc7ab32d6cc6ea2ec68d1c39448e","impliedFormat":99},{"version":"74467b184eadee6186a17cac579938d62eceb6d89c923ae67d058e2bcded254e","impliedFormat":99},{"version":"4169b96bb6309a2619f16d17307da341758da2917ff40c615568217b14357f5e","impliedFormat":99},{"version":"4a94d6146b38050de0830019a1c6a7820c2e2b90eba1a5ee4e4ab3bc30a72036","impliedFormat":99},{"version":"48a35ece156203abf19864daa984475055bbed4dc9049d07f4462100363f1e85","impliedFormat":99},{"version":"d4a22007b481fe2a2e6bfd3a42c00cd62d41edb36d30fc4697df2692e9891fc8","impliedFormat":1},{"version":"f8a6bb79327f4a6afc63d28624654522fc80f7536efa7a617ef48200b7a5f673","impliedFormat":1},{"version":"8e0733c50eaac49b4e84954106acc144ec1a8019922d6afcde3762523a3634af","impliedFormat":1},{"version":"d2a38ad7bb4676e7fd5d058a08105d81ac232c363ee56be0b401fc277d50dbb1","impliedFormat":1},{"version":"2ac2e08e0d0ed266849cb9da521c3be170a8bc111d25eeeb668c7dbf0ac4171a","impliedFormat":1},{"version":"8841e2aa774b89bd23302dede20663306dc1b9902431ac64b24be8b8d0e3f649","impliedFormat":1},{"version":"fa8dbed00530fb4114906cd93f7fb55512c8eb9551d2f2e9796c69a4da4b594f","impliedFormat":1},{"version":"10a60d0cc51552184ceb31c27ef547eb365b918b0927b155176be3c3d5cba82c","impliedFormat":1},{"version":"1a86aff0e5cf0da881c826ada253aa5256ff0d53c2123c62cd7851559eaee9b9","impliedFormat":1},{"version":"87d9d29dbc745f182683f63187bf3d53fd8673e5fca38ad5eaab69798ed29fbc","impliedFormat":1},{"version":"006d8ff9a051d61b0887b594b1e76c73314bb1a6fe39026867418937ea2259b3","impliedFormat":1},{"version":"eb5b19b86227ace1d29ea4cf81387279d04bb34051e944bc53df69f58914b788","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"035312d4945d13efa134ae482f6dc56a1a9346f7ac3be7ccbad5741058ce87f3","affectsGlobalScope":true,"impliedFormat":1},{"version":"334c2a48d15446447c4fd648ad48312b71b87324e4fae1202ad9dc6795491fc5","impliedFormat":1},{"version":"17ed71200119e86ccef2d96b73b02ce8854b76ad6bd21b5021d4269bec527b5f","impliedFormat":1},{"version":"5aca5a3bc07d2e16b6824a76c30378d6fb1b92e915d854315e1d1bd2d00974c9","impliedFormat":1},{"version":"04dedf1ceff8514931c604d641ad3e656d50846cccd35e7652824c466e3304db","impliedFormat":1},{"version":"9ff1801b61156afc6647a4214fdfce309226b968701b72b7392dc96413d2ad9b","impliedFormat":1},{"version":"62ba45a86b9a31eb84ea03ae0b9e800a507d980c1f38dcec6528f10078cfdedd","impliedFormat":1},{"version":"c0288f54de6f544706a3150c8b579b1a975870695c4be866f727ece6a16f3976","impliedFormat":1},{"version":"f8636a916949481bc363ae24cbeb8451fa98fd2d07329e0664a46567278c9adb","impliedFormat":1},{"version":"74d5a87c3616cd5d8691059d531504403aa857e09cbaecb1c64dfb9ace0db185","impliedFormat":1}],"root":[[217,219],233],"options":{"allowSyntheticDefaultImports":true,"composite":true,"module":99},"referencedMap":[[206,1],[204,2],[235,3],[236,2],[209,4],[205,1],[207,5],[208,1],[240,6],[243,7],[245,8],[247,9],[246,10],[244,2],[248,11],[249,2],[250,2],[252,12],[253,13],[251,2],[254,12],[255,14],[257,15],[241,2],[258,2],[261,16],[262,17],[174,2],[263,18],[265,19],[238,2],[266,2],[267,2],[268,20],[282,21],[283,21],[284,21],[285,21],[286,21],[287,21],[288,21],[289,21],[290,21],[291,21],[292,21],[293,21],[294,21],[295,21],[296,21],[297,21],[298,21],[299,21],[300,21],[301,21],[302,21],[303,21],[304,21],[305,21],[306,21],[307,21],[308,21],[309,21],[310,21],[311,21],[312,21],[313,21],[314,21],[315,21],[316,21],[317,21],[318,21],[319,21],[320,21],[321,21],[322,21],[323,21],[324,21],[325,21],[326,21],[327,21],[328,21],[329,21],[330,21],[331,21],[332,21],[333,21],[334,21],[335,21],[336,21],[337,21],[338,21],[339,21],[340,21],[341,21],[342,21],[343,21],[344,21],[345,21],[346,21],[347,21],[348,21],[349,21],[350,21],[351,21],[352,21],[353,21],[354,21],[355,21],[356,21],[357,21],[358,21],[359,21],[360,21],[361,21],[362,21],[363,21],[364,21],[365,21],[366,21],[367,21],[368,21],[369,21],[370,21],[371,21],[372,21],[373,21],[374,21],[375,21],[376,21],[377,21],[378,21],[586,22],[379,21],[380,21],[381,21],[382,21],[383,21],[384,21],[385,21],[386,21],[387,21],[388,21],[389,21],[390,21],[391,21],[392,21],[393,21],[394,21],[395,21],[396,21],[397,21],[398,21],[399,21],[400,21],[401,21],[402,21],[403,21],[404,21],[405,21],[406,21],[407,21],[408,21],[409,21],[410,21],[411,21],[412,21],[413,21],[414,21],[415,21],[416,21],[417,21],[418,21],[419,21],[420,21],[421,21],[422,21],[423,21],[424,21],[425,21],[426,21],[427,21],[428,21],[429,21],[430,21],[431,21],[432,21],[433,21],[434,21],[435,21],[436,21],[437,21],[438,21],[439,21],[440,21],[441,21],[442,21],[443,21],[444,21],[445,21],[446,21],[447,21],[448,21],[449,21],[450,21],[451,21],[452,21],[453,21],[454,21],[455,21],[456,21],[457,21],[458,21],[459,21],[460,21],[461,21],[462,21],[463,21],[464,21],[465,21],[466,21],[467,21],[468,21],[469,21],[470,21],[471,21],[472,21],[473,21],[474,21],[475,21],[476,21],[477,21],[478,21],[479,21],[480,21],[481,21],[482,21],[483,21],[484,21],[485,21],[486,21],[487,21],[488,21],[489,21],[490,21],[491,21],[492,21],[493,21],[494,21],[495,21],[496,21],[497,21],[498,21],[499,21],[500,21],[501,21],[502,21],[503,21],[504,21],[505,21],[506,21],[507,21],[508,21],[509,21],[510,21],[511,21],[512,21],[513,21],[514,21],[515,21],[516,21],[517,21],[518,21],[519,21],[520,21],[521,21],[522,21],[523,21],[524,21],[525,21],[526,21],[527,21],[528,21],[529,21],[530,21],[531,21],[532,21],[533,21],[534,21],[535,21],[536,21],[537,21],[538,21],[539,21],[540,21],[541,21],[542,21],[543,21],[544,21],[545,21],[546,21],[547,21],[548,21],[549,21],[550,21],[551,21],[552,21],[553,21],[554,21],[555,21],[556,21],[557,21],[558,21],[559,21],[560,21],[561,21],[562,21],[563,21],[564,21],[565,21],[566,21],[567,21],[568,21],[569,21],[570,21],[571,21],[572,21],[573,21],[574,21],[575,21],[576,21],[577,21],[578,21],[579,21],[580,21],[581,21],[582,21],[583,21],[584,21],[585,21],[270,23],[271,24],[269,25],[272,26],[273,27],[274,28],[275,29],[276,30],[277,31],[278,32],[279,33],[280,34],[281,35],[587,19],[589,36],[588,2],[591,37],[590,38],[592,2],[256,2],[114,39],[115,39],[116,40],[54,41],[117,42],[118,43],[119,44],[52,2],[120,45],[121,46],[122,47],[123,48],[124,49],[125,50],[126,50],[127,51],[128,52],[129,53],[130,54],[55,2],[53,2],[131,55],[132,56],[133,57],[173,58],[134,59],[135,60],[136,59],[137,61],[138,62],[139,63],[140,64],[141,64],[142,64],[143,65],[144,66],[145,67],[146,68],[147,69],[148,70],[149,70],[150,71],[151,2],[152,2],[153,72],[154,73],[155,72],[156,74],[157,75],[158,76],[159,77],[160,78],[161,79],[162,80],[163,81],[164,82],[165,83],[166,84],[167,85],[168,86],[169,87],[170,88],[56,59],[57,2],[58,89],[59,90],[60,2],[61,91],[62,2],[105,92],[106,93],[107,94],[108,94],[109,95],[110,2],[111,42],[112,96],[113,93],[171,97],[172,98],[593,99],[595,100],[596,2],[597,99],[601,101],[602,101],[598,2],[600,102],[234,20],[603,2],[239,103],[604,2],[260,104],[259,2],[264,2],[605,103],[606,2],[608,105],[607,2],[609,106],[210,107],[242,2],[63,2],[599,2],[213,2],[237,59],[197,108],[195,109],[196,110],[184,111],[185,109],[192,112],[183,113],[188,114],[198,2],[189,115],[194,116],[199,117],[182,118],[190,119],[191,120],[186,121],[193,108],[187,122],[175,17],[181,2],[1,2],[50,2],[51,2],[9,2],[13,2],[12,2],[3,2],[14,2],[15,2],[16,2],[17,2],[18,2],[19,2],[20,2],[21,2],[4,2],[22,2],[23,2],[5,2],[24,2],[28,2],[25,2],[26,2],[27,2],[29,2],[30,2],[31,2],[6,2],[32,2],[33,2],[34,2],[35,2],[7,2],[39,2],[36,2],[37,2],[38,2],[40,2],[8,2],[41,2],[46,2],[47,2],[42,2],[43,2],[44,2],[45,2],[2,2],[48,2],[49,2],[11,2],[10,2],[81,123],[93,124],[79,125],[94,126],[103,127],[70,128],[71,129],[69,130],[102,18],[97,131],[101,132],[73,133],[90,134],[72,135],[100,136],[67,137],[68,131],[74,138],[75,2],[80,139],[78,138],[65,140],[104,141],[95,142],[84,143],[83,138],[85,144],[88,145],[82,146],[86,147],[98,18],[76,148],[77,149],[89,150],[66,126],[92,151],[91,138],[87,152],[96,2],[64,2],[99,153],[214,154],[212,155],[215,156],[211,157],[216,158],[203,159],[200,160],[179,161],[180,2],[177,162],[176,2],[178,163],[201,2],[202,164],[594,126],[217,2],[233,165],[227,166],[224,2],[225,2],[231,167],[230,168],[226,2],[232,169],[220,2],[228,2],[221,2],[222,2],[223,2],[229,2],[218,170],[219,171]],"semanticDiagnosticsPerFile":[[203,[{"start":551,"length":17,"code":2307,"category":1,"messageText":{"messageText":"Cannot find module 'rollup/parseAst' or its corresponding type declarations.","category":1,"code":2307,"next":[{"info":{"moduleReference":"rollup/parseAst"}}]}}]],[233,[{"start":4174,"length":6,"messageText":"Type 'Map' can only be iterated through when using the '--downlevelIteration' flag or with a '--target' of 'es2015' or higher.","category":1,"code":2802}]]],"affectedFilesPendingEmit":[233,227,224,225,231,230,226,232,218,219],"emitSignatures":[218,219,224,225,226,227,230,231,232,233],"version":"5.9.3"} \ No newline at end of file From f291a3897c65dece92e2ab2fe31ad8f9191b2f96 Mon Sep 17 00:00:00 2001 From: Douglas Date: Sun, 12 Jul 2026 03:00:28 +0100 Subject: [PATCH 3/3] update preview panel feature bug and animation --- .gitignore | 3 + electron/main/index.ts | 21 +- .../ChatBox/MessageItem/MarkDown.tsx | 19 +- .../MessageItem/UserMessageRichContent.tsx | 30 +- src/components/Session/HeaderBox/index.tsx | 10 +- src/components/Session/PreviewPanel/index.tsx | 60 +++- .../Session/PreviewPanel/tabKinds.tsx | 29 +- .../PreviewPanel/tabs/browser/BrowserTab.tsx | 36 ++- .../tabs/browser/PreviewBrowserLayer.tsx | 89 +++++- src/components/Session/index.tsx | 44 ++- src/lib/browserUrl.ts | 42 ++- src/store/pageTabStore.ts | 266 ++++++++++-------- .../Session/PreviewBrowserLayer.test.tsx | 69 +++-- .../components/Session/PreviewPanel.test.tsx | 52 ++-- test/unit/lib/browserUrl.test.ts | 33 ++- test/unit/store/pageTabStore.preview.test.ts | 170 ++++++----- tsconfig.node.tsbuildinfo | 1 - 17 files changed, 672 insertions(+), 302 deletions(-) delete mode 100644 tsconfig.node.tsbuildinfo diff --git a/.gitignore b/.gitignore index 9f284c5d8..4f103c7dd 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,6 @@ storybook-static nul backend/benchmark/jobs/ + +# TypeScript incremental build info +*.tsbuildinfo diff --git a/electron/main/index.ts b/electron/main/index.ts index 8cb580ed7..24ab059fa 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -2379,10 +2379,23 @@ async function createWindow() { }, }); - // Renderer guests (session preview browser): route window.open / - // target=_blank into the same guest instead of spawning popup windows, and - // only allow web URLs. The guests are sandboxed tags declared in the - // renderer; this is the only main-process involvement they need. + // Renderer guests (session preview browser) host arbitrary web + // content, and the host window itself runs with elevated webPreferences. + // Enforce safe guest settings at attach time so no tag attribute (even one + // forged by a compromised renderer) can grant a guest host privileges. + win.webContents.on('will-attach-webview', (event, webPreferences, params) => { + delete webPreferences.preload; + webPreferences.nodeIntegration = false; + webPreferences.contextIsolation = true; + webPreferences.webSecurity = true; + if (!/^https?:\/\//i.test(params.src ?? '')) { + event.preventDefault(); + } + }); + + // Route window.open / target=_blank into the same guest instead of spawning + // popup windows, and only allow web URLs. Together with the attach guard + // above, this is the only main-process involvement the guests need. win.webContents.on('did-attach-webview', (_event, contents) => { contents.setWindowOpenHandler(({ url }) => { if (/^https?:\/\//i.test(url)) { diff --git a/src/components/ChatBox/MessageItem/MarkDown.tsx b/src/components/ChatBox/MessageItem/MarkDown.tsx index 527431440..3c7d9c3b1 100644 --- a/src/components/ChatBox/MessageItem/MarkDown.tsx +++ b/src/components/ChatBox/MessageItem/MarkDown.tsx @@ -80,6 +80,7 @@ export const MarkDown = memo( const host = useHost(); const electronAPI = host?.electronAPI; const openFilePreview = usePageTabStore((s) => s.openFilePreview); + const openBrowserPreview = usePageTabStore((s) => s.openBrowserPreview); const [displayedContent, setDisplayedContent] = useState(''); const [html, setHtml] = useState(''); const [previewImage, setPreviewImage] = useState(null); @@ -298,6 +299,22 @@ export const MarkDown = memo( if (filePath) { openFilePreview(fileInfoFromPath(filePath)); } + return; + } + // Web links stay inside the session: open them in the preview + // browser of this project. (On the web host, where no embedded + // browser exists, fall back to a regular browser tab.) + const link = target.closest('a[href]'); + if (link) { + const href = link.getAttribute('href') ?? ''; + if (/^https?:\/\//i.test(href)) { + e.preventDefault(); + if (electronAPI) { + openBrowserPreview(href); + } else { + window.open(href, '_blank', 'noopener,noreferrer'); + } + } } }; @@ -307,7 +324,7 @@ export const MarkDown = memo( return () => { div.removeEventListener('click', handleContentClick); }; - }, [html, openFilePreview]); + }, [html, openFilePreview, openBrowserPreview, electronAPI]); return ( <> diff --git a/src/components/ChatBox/MessageItem/UserMessageRichContent.tsx b/src/components/ChatBox/MessageItem/UserMessageRichContent.tsx index acadfe03f..389971d07 100644 --- a/src/components/ChatBox/MessageItem/UserMessageRichContent.tsx +++ b/src/components/ChatBox/MessageItem/UserMessageRichContent.tsx @@ -21,6 +21,7 @@ import { tokenizeRichPlainText, } from '@/lib/richText'; import { cn } from '@/lib/utils'; +import { usePageTabStore } from '@/store/pageTabStore'; import { Fragment, type ReactNode } from 'react'; /** Same tokens as `UserMessageCard` body (13px / 20px). */ @@ -58,7 +59,13 @@ function parseContentWithTags(content: string): ContentNode[] { return nodes.length > 0 ? nodes : [{ type: 'text', value: content }]; } -function renderMessageRichSegments(text: string, keyPrefix: string): ReactNode { +function renderMessageRichSegments( + text: string, + keyPrefix: string, + /** When set, URL clicks open here (the session's preview browser) instead + * of following the anchor out of the app. */ + onOpenUrl?: (url: string) => void +): ReactNode { return tokenizeRichPlainText(text).map((seg, i) => { const key = `${keyPrefix}-${i}`; if (seg.type === 'text') { @@ -73,8 +80,14 @@ function renderMessageRichSegments(text: string, keyPrefix: string): ReactNode { href={href} target="_blank" rel="noopener noreferrer" - className="text-ds-text-information-default-default decoration-ds-border-information-default-default underline underline-offset-2" - onClick={(e) => e.stopPropagation()} + className="text-ds-text-information-default-default underline decoration-ds-border-information-default-default underline-offset-2" + onClick={(e) => { + e.stopPropagation(); + if (onOpenUrl) { + e.preventDefault(); + onOpenUrl(href); + } + }} > {seg.text} @@ -87,7 +100,7 @@ function renderMessageRichSegments(text: string, keyPrefix: string): ReactNode { @@ -115,6 +128,7 @@ export function UserMessageRichContent({ className, }: UserMessageRichContentProps) { const host = useHost(); + const openBrowserPreview = usePageTabStore((s) => s.openBrowserPreview); const contentNodes = parseContentWithTags(content); const handleOpenSkillFolder = (skillName: string) => { @@ -122,6 +136,10 @@ export function UserMessageRichContent({ host?.electronAPI?.openSkillFolder?.(skillName); }; + // Desktop: links open in this project's preview browser; on the web host + // (no embedded browser) the anchor's target=_blank fallback applies. + const handleOpenUrl = host?.electronAPI ? openBrowserPreview : undefined; + const bodyClass = variant === 'card' ? 'text-ds-text-neutral-default-default font-sans relative z-0 break-words whitespace-pre-wrap' @@ -134,7 +152,7 @@ export function UserMessageRichContent({ if (node.type === 'text') { return ( - {renderMessageRichSegments(node.value, `n${i}`)} + {renderMessageRichSegments(node.value, `n${i}`, handleOpenUrl)} ); } @@ -151,7 +169,7 @@ export function UserMessageRichContent({ }} title="Open skill folder" className={cn( - 'mx-0 rounded px-0.5 font-normal inline cursor-pointer align-baseline [font:inherit] hover:opacity-90', + 'mx-0 inline cursor-pointer rounded px-0.5 align-baseline font-normal [font:inherit] hover:opacity-90', RICH_SKILL_STYLE_CLASSES[clsIdx] )} > diff --git a/src/components/Session/HeaderBox/index.tsx b/src/components/Session/HeaderBox/index.tsx index 13f51a2b7..6ed75f998 100644 --- a/src/components/Session/HeaderBox/index.tsx +++ b/src/components/Session/HeaderBox/index.tsx @@ -19,7 +19,7 @@ import { Button } from '@/components/ui/button'; import { TooltipSimple } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; import { useAuthStore } from '@/store/authStore'; -import { usePageTabStore } from '@/store/pageTabStore'; +import { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore'; import { ArrowLeft, GalleryThumbnails } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -40,13 +40,17 @@ export function HeaderBox({ const { t } = useTranslation(); const { appearance } = useAuthStore(); const setActiveWorkspaceTab = usePageTabStore((s) => s.setActiveWorkspaceTab); - const sessionPreviewOpen = usePageTabStore((s) => s.sessionPreviewOpen); + const sessionPreviewOpen = usePageTabStore( + (s) => getSessionPreviewSlice(s).open + ); const toggleSessionPreview = usePageTabStore((s) => s.toggleSessionPreview); const tokenIcon = appearance === 'dark' ? tokenDarkIcon : tokenLightIcon; const backToWorkspaceTooltip = t('layout.back-to-workspace-tooltip', { defaultValue: 'Back to workspace', }); - const windowPreviewTooltip = t('layout.toggle-file-preview-tooltip', { + // Own key (not the old file-preview one): the control's meaning changed, so + // stale translations must not carry over. + const windowPreviewTooltip = t('layout.toggle-window-preview-tooltip', { defaultValue: 'Toggle window preview', }); diff --git a/src/components/Session/PreviewPanel/index.tsx b/src/components/Session/PreviewPanel/index.tsx index edf9f485b..306ae0fca 100644 --- a/src/components/Session/PreviewPanel/index.tsx +++ b/src/components/Session/PreviewPanel/index.tsx @@ -16,7 +16,7 @@ import { Button } from '@/components/ui/button'; import { TooltipSimple } from '@/components/ui/tooltip'; import { useHost } from '@/host'; import { cn } from '@/lib/utils'; -import { usePageTabStore } from '@/store/pageTabStore'; +import { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore'; import { Plus, X } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -36,6 +36,12 @@ const TAB_MIN_WIDTH = 92; export interface PreviewPanelProps { onJumpToContext?: (file: FileInfo | null) => void; + /** + * False while the display panel's open animation is still running. Browser + * tabs hold their fixed-position webview guest parked until it settles so + * the page doesn't pop in over the chat mid-animation. + */ + displaySettled?: boolean; } /** @@ -44,12 +50,15 @@ export interface PreviewPanelProps { * canvas). Embedded browsers live in the always-mounted PreviewBrowserLayer; * this panel only renders their chrome via BrowserTab. */ -export function PreviewPanel({ onJumpToContext }: PreviewPanelProps) { +export function PreviewPanel({ + onJumpToContext, + displaySettled = true, +}: PreviewPanelProps) { const { t } = useTranslation(); const host = useHost(); - const tabs = usePageTabStore((state) => state.sessionPreviewTabs); + const tabs = usePageTabStore((state) => getSessionPreviewSlice(state).tabs); const activeTabId = usePageTabStore( - (state) => state.activeSessionPreviewTabId + (state) => getSessionPreviewSlice(state).activeTabId ); const addChooserPreviewTab = usePageTabStore( (state) => state.addChooserPreviewTab @@ -99,6 +108,35 @@ export function PreviewPanel({ onJumpToContext }: PreviewPanelProps) { if (!activeTab) return null; + // Roving tabindex: only the selected tab is in the Tab order; Left/Right + // (and Home/End) move both selection and focus along the strip. + const handleTabListKeyDown = (event: React.KeyboardEvent) => { + const currentIndex = tabs.findIndex((tab) => tab.id === activeTab.id); + let nextIndex: number; + switch (event.key) { + case 'ArrowLeft': + nextIndex = Math.max(0, currentIndex - 1); + break; + case 'ArrowRight': + nextIndex = Math.min(tabs.length - 1, currentIndex + 1); + break; + case 'Home': + nextIndex = 0; + break; + case 'End': + nextIndex = tabs.length - 1; + break; + default: + return; + } + event.preventDefault(); + if (nextIndex === currentIndex) return; + selectSessionPreviewTab(tabs[nextIndex].id); + tabListRef.current + ?.querySelectorAll('[role="tab"]') + [nextIndex]?.focus(); + }; + const renderActiveContent = () => { switch (activeTab.type) { case 'chooser': @@ -114,6 +152,7 @@ export function PreviewPanel({ onJumpToContext }: PreviewPanelProps) { key={activeTab.id} tab={activeTab} isDesktop={isDesktop} + viewportSettled={displaySettled} /> ); case 'file': @@ -139,6 +178,7 @@ export function PreviewPanel({ onJumpToContext }: PreviewPanelProps) { aria-label={t('layout.preview-tabs', { defaultValue: 'Preview tabs', })} + onKeyDown={handleTabListKeyDown} className="scrollbar-hide flex h-7 w-full min-w-0 items-center gap-1.5 overflow-x-auto overflow-y-hidden" > {tabs.map((tab) => { @@ -153,16 +193,18 @@ export function PreviewPanel({ onJumpToContext }: PreviewPanelProps) { maxWidth: TAB_DEFAULT_WIDTH, }} className={cn( - 'group relative h-7 rounded-lg bg-ds-bg-neutral-subtle-default', - selected - ? 'bg-ds-bg-neutral-strong-default text-ds-text-neutral-default-default' - : 'text-ds-text-neutral-muted-default hover:bg-ds-bg-neutral-default-default' + // Every tab uses the selected colors; unselected tabs are + // just dimmed (40% at rest, 80% on hover) so selection + // reads as full opacity rather than a color change. + 'group relative h-7 rounded-lg bg-ds-bg-neutral-strong-default text-ds-text-neutral-default-default transition-opacity', + selected ? 'opacity-100' : 'opacity-40 hover:opacity-80' )} >