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 2bd8bf51a..24ab059fa 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -2379,6 +2379,32 @@ async function createWindow() { }, }); + // 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)) { + void contents.loadURL(url); + } + return { action: 'deny' }; + }); + }); + if (process.platform === 'darwin') { win.once('ready-to-show', () => { if (win && !win.isDestroyed()) { 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/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..306ae0fca --- /dev/null +++ b/src/components/Session/PreviewPanel/index.tsx @@ -0,0 +1,287 @@ +// ========= 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 { useHost } from '@/host'; +import { cn } from '@/lib/utils'; +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'; +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; + /** + * 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; +} + +/** + * 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, + displaySettled = true, +}: PreviewPanelProps) { + const { t } = useTranslation(); + const host = useHost(); + const tabs = usePageTabStore((state) => getSessionPreviewSlice(state).tabs); + const activeTabId = usePageTabStore( + (state) => getSessionPreviewSlice(state).activeTabId + ); + const addChooserPreviewTab = usePageTabStore( + (state) => state.addChooserPreviewTab + ); + const choosePreviewTabType = usePageTabStore( + (state) => state.choosePreviewTabType + ); + const selectSessionPreviewTab = usePageTabStore( + (state) => state.selectSessionPreviewTab + ); + const closeSessionPreviewTab = usePageTabStore( + (state) => state.closeSessionPreviewTab + ); + + const activeTab = useMemo( + () => tabs.find((tab) => tab.id === activeTabId) ?? tabs[0] ?? null, + [activeTabId, tabs] + ); + // 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(() => { + const el = tabListRef.current; + if (!el) return; + updateTabOverflow(); + const observer = new ResizeObserver(updateTabOverflow); + observer.observe(el); + el.addEventListener('scroll', updateTabOverflow, { passive: true }); + return () => { + observer.disconnect(); + el.removeEventListener('scroll', updateTabOverflow); + }; + }, [updateTabOverflow, tabs.length]); + + 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': + 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; + const Icon = previewTabIcon(tab.type); + return ( +
+ + +
+ ); + })} +
+
+
+
+ + + +
+ +
+ {renderActiveContent()} +
+
+ ); +} + +export default PreviewPanel; diff --git a/src/components/Session/PreviewPanel/tabKinds.tsx b/src/components/Session/PreviewPanel/tabKinds.tsx new file mode 100644 index 000000000..6f9671082 --- /dev/null +++ b/src/components/Session/PreviewPanel/tabKinds.tsx @@ -0,0 +1,77 @@ +// ========= 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. + * + * `review`, `terminal`, and `canvas` are reserved tab types (their components + * and store plumbing exist, and persisted tabs still render) but are hidden + * from the chooser until a later version ships their content. Re-add their + * entries here when that lands. + */ +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.', + }, +]; + +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..83eb8c116 --- /dev/null +++ b/src/components/Session/PreviewPanel/tabs/browser/BrowserTab.tsx @@ -0,0 +1,283 @@ +// ========= 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; + /** + * False while the display panel is still animating open. The viewport is + * only published once settled so the fixed-position guest (which the panel's + * clip-path can't clip) doesn't appear over the chat mid-animation. + */ + viewportSettled?: 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, + viewportSettled = true, +}: 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 [addressFocused, setAddressFocused] = useState(false); + const nav = tab.navigation; + + // Follow the page's URL in the address bar — but never while the user is in + // the field, so redirects/SPA navigation events can't clobber their typing. + // (Unsubmitted edits revert to the page URL on blur.) + useEffect(() => { + if (addressFocused) return; + setAddressInput(tab.url); + setAddressError(null); + }, [tab.url, addressFocused]); + + 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. + // Held back until the panel animation settles; cleared on unmount (switching + // tabs / closing) so guests park, not float. + useEffect(() => { + if (!isDesktop || !viewportSettled) 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, viewportSettled, tab.id, setPreviewBrowserViewport]); + + return ( +
+
+ + + + + + + + + +
{ + event.preventDefault(); + void navigateTo(addressInput); + }} + > + { + setAddressInput(event.target.value); + if (addressError) setAddressError(null); + }} + onFocus={() => setAddressFocused(true)} + onBlur={() => setAddressFocused(false)} + 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..a2ecebe6b --- /dev/null +++ b/src/components/Session/PreviewPanel/tabs/browser/PreviewBrowserLayer.tsx @@ -0,0 +1,310 @@ +// ========= 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; +/** Guest fade when (un)covering its viewport — softens show/park hops. */ +const GUEST_FADE_MS = 160; +/** + * Guests of projects that have been out of scope this long are destroyed to + * reclaim their renderer processes. The tab's URL is persisted, so returning + * to an evicted project simply reloads its pages (history is lost — the price + * of not keeping every visited project's Chromium processes alive forever). + */ +const IDLE_PROJECT_EVICT_MS = 10 * 60_000; +const EVICT_SWEEP_INTERVAL_MS = 60_000; + +const PARKED_STYLE: React.CSSProperties = { + position: 'fixed', + left: -10_000, + top: 0, + width: 1024, + height: 640, + visibility: 'hidden', + pointerEvents: 'none', +}; + +function visibleStyle( + viewport: PreviewBrowserViewport, + shown: boolean +): 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', + opacity: shown ? 1 : 0, + transition: `opacity ${GUEST_FADE_MS}ms ease`, + }; +} + +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]); + + // Show/park with a short opacity fade instead of an instant hop: + // 'parked' → offscreen, 'faded' → over the viewport at opacity 0, + // 'shown' → opacity 1. `lastViewport` remembers the rect the guest was + // last shown at so the fade-out happens in place even after the panel + // stops publishing a viewport (tab switch / project switch unmounts the + // publisher in the same commit that hides the guest). + const showing = visible && viewport !== null; + const [lastViewport, setLastViewport] = + useState(null); + useEffect(() => { + if (viewport) setLastViewport(viewport); + }, [viewport]); + const [phase, setPhase] = useState<'parked' | 'faded' | 'shown'>('parked'); + useEffect(() => { + if (showing) { + setPhase('faded'); + // Double rAF so the opacity-0 frame commits before the fade-in starts. + let raf2 = 0; + const raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(() => setPhase('shown')); + }); + return () => { + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + }; + } + setPhase((current) => (current === 'parked' ? 'parked' : 'faded')); + const timer = window.setTimeout(() => setPhase('parked'), GUEST_FADE_MS); + return () => window.clearTimeout(timer); + }, [showing]); + + 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]); + + const rect = viewport ?? lastViewport; + 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]); + + // Each guest is a full renderer process, so don't keep every visited + // project's guests alive forever: track when a project leaves scope and + // deactivate it (unmounting its guests) once it has idled long enough. + const idleSinceRef = useRef(new Map()); + useEffect(() => { + if (!scopeProjectId) return; + const idleSince = idleSinceRef.current; + idleSince.delete(scopeProjectId); + return () => { + idleSince.set(scopeProjectId, Date.now()); + }; + }, [scopeProjectId]); + useEffect(() => { + const timer = window.setInterval(() => { + setActivatedProjects((current) => { + let next: Set | null = null; + for (const projectId of current) { + const idleSince = idleSinceRef.current.get(projectId); + if ( + idleSince === undefined || + Date.now() - idleSince < IDLE_PROJECT_EVICT_MS + ) { + continue; + } + next ??= new Set(current); + next.delete(projectId); + idleSinceRef.current.delete(projectId); + } + return next ?? current; + }); + }, EVICT_SWEEP_INTERVAL_MS); + return () => window.clearInterval(timer); + }, []); + + // 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 018124ed7..f9924ca88 100644 --- a/src/components/Session/index.tsx +++ b/src/components/Session/index.tsx @@ -13,14 +13,14 @@ // ========= 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 { inferSessionModeFromTask } from '@/lib/sessionMode'; import { cn } from '@/lib/utils'; -import { usePageTabStore } from '@/store/pageTabStore'; +import { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore'; import { useProjectRuntimeStore } from '@/store/projectRuntimeStore'; import { useSpaceStore } from '@/store/spaceStore'; import { @@ -28,6 +28,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 +36,15 @@ 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]; +/** Display panel open/close animation duration (framer transition below). */ +const DISPLAY_PANEL_ANIMATION_MS = 300; /** * Active Project: header + chat (left) and a mode-dependent side panel (right). @@ -60,9 +60,19 @@ export default function Session({ isNewProject = false }: SessionProps) { const { chatStore, projectStore } = useChatStoreAdapter(); 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 closeSessionPreview = usePageTabStore((s) => s.closeSessionPreview); + const setSessionPreviewProject = usePageTabStore( + (s) => s.setSessionPreviewProject + ); + const sessionPreviewProjectId = usePageTabStore( + (s) => s.sessionPreviewProjectId + ); + // Only trust the preview slice once the scope points at this project — on + // switch the scope effect below lags the first render by one frame, and + // rendering the stale slice would flash the previous project's browser. + const previewOpen = + usePageTabStore((s) => getSessionPreviewSlice(s).open) && + sessionPreviewProjectId === (projectStore.activeProjectId ?? null); const activeProjectId = projectStore.activeProjectId; const isHistoryLoadingActiveProject = useProjectRuntimeStore((s) => activeProjectId @@ -229,45 +239,65 @@ 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); - // 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. + // 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(() => { + setSessionPreviewProject(activeProjectId ?? null); + }, [activeProjectId, setSessionPreviewProject]); + + // Last chat width the user dragged to; reopening display restores it instead + // of resetting to the minimum. + const userChatWidthRef = useRef(null); + + // Opening display auto-folds the session side panel and collapses chat + // (to the user's remembered width, if they resized before). 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(userChatWidthRef.current ?? 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. + // Embedded browser guests are `position: fixed` in a separate layer, so the + // panel's clip-path entrance can't clip them. Hold the guest parked until + // the entrance finishes (then it fades in over the settled rect) instead of + // letting the page pop in full-size over the chat mid-animation. + const [displaySettled, setDisplaySettled] = useState(false); useEffect(() => { - if (activeWorkspaceTab !== 'project') { - closeFilePreview(); + if (!previewOpen) { + setDisplaySettled(false); + return; } - }, [activeWorkspaceTab, activeProjectId, closeFilePreview]); + const timer = window.setTimeout( + () => setDisplaySettled(true), + DISPLAY_PANEL_ANIMATION_MS + 20 + ); + return () => window.clearTimeout(timer); + }, [previewOpen]); 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; @@ -278,6 +308,7 @@ export default function Session({ isNewProject = false }: SessionProps) { maxChat, Math.max(CHAT_MIN_WIDTH, startWidth + (ev.clientX - startX)) ); + userChatWidthRef.current = next; setChatWidth(next); }; const onUp = () => { @@ -303,9 +334,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 +367,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) && !/\s/.test(trimmed)) { + return { ok: false, error: 'Only HTTP and HTTPS URLs are supported' }; + } + } + + let candidate = trimmed; + if (!/^https?:\/\//i.test(candidate)) { + if (!looksNavigable(candidate)) { + return { + ok: true, + url: `${WEB_SEARCH_URL}${encodeURIComponent(trimmed)}`, + }; + } + candidate = `${LOOPBACK_OR_PRIVATE_HOST_PATTERN.test(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/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 c04c8612f..2ae4ee8d6 100644 --- a/src/store/pageTabStore.ts +++ b/src/store/pageTabStore.ts @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { canonicalizeBrowserUrl, normalizeBrowserUrl } from '@/lib/browserUrl'; import { create } from 'zustand'; import { persist } from 'zustand/middleware'; @@ -32,6 +33,236 @@ 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; +} + +/** 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, +}; + +/** + * The preview slice for the currently scoped project. The per-project record + * is the single source of truth; components derive their view through this + * selector (e.g. `usePageTabStore((s) => getSessionPreviewSlice(s).tabs)`) + * instead of reading mirrored flat fields, so state can never drift. + */ +export function getSessionPreviewSlice(state: { + sessionPreviewProjectId: string | null; + sessionPreviewByProject: Record; +}): SessionPreviewSlice { + const projectId = state.sessionPreviewProjectId; + return ( + (projectId ? state.sessionPreviewByProject[projectId] : undefined) ?? + EMPTY_SESSION_PREVIEW + ); +} + +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}-${sessionPreviewTabIdSeed}-${sessionPreviewTabSequence}`; +} + +function createBrowserPreviewTab(projectId: string | null): SessionBrowserTab { + const id = nextSessionPreviewTabId('browser'); + return { + id, + type: 'browser', + title: 'New tab', + url: '', + // 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: '', + 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 createChooserPreviewTab(): SessionChooserTab { + return { + id: nextSessionPreviewTabId('chooser'), + type: 'chooser', + title: 'New tab', + }; +} + +/** Placeholder tab title for a URL until the page reports its real one. */ +function browserTabTitleForUrl(url: string): string { + try { + return new URL(url).hostname || 'New tab'; + } catch { + return '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; +} { + // 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 { activeTab: 'tasks' | 'trigger'; setActiveTab: (tab: 'tasks' | 'trigger') => void; @@ -135,17 +366,96 @@ 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) ───────────────────────────────── + /** + * Project whose preview slice mutations and `getSessionPreviewSlice` reads + * target. Set by the Session page on mount/switch; while unset, preview + * mutations are dropped (there is nowhere durable to record them). + */ + sessionPreviewProjectId: string | null; + /** + * Preview panel state per project — the single source of truth, persisted + * so sessions restore. Read the scoped slice via `getSessionPreviewSlice`. + */ + sessionPreviewByProject: Record; + /** Point the preview scope at a project; its saved slice becomes current. */ + setSessionPreviewProject: (projectId: string | null) => void; + /** + * 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 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; - /** 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; + /** + * Open a URL in this project's preview browser — the default target for + * links mentioned in chat content, so they stay inside the session instead + * of jumping to the system browser. Reuses a tab already on that URL, then + * a blank starter tab (chooser or empty browser); otherwise appends. + */ + openBrowserPreview: (url: string) => void; + selectSessionPreviewTab: (tabId: string) => void; + closeSessionPreviewTab: (tabId: string) => void; + updateBrowserPreviewTab: ( + 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 the scoped project's slice. The updater receives + * the current slice; return `null` to bail without changes. No project scope → + * no-op (the Session page sets the scope before any preview UI is reachable). + */ +function setSessionPreviewSlice( + set: SetPageTabState, + updater: ( + slice: SessionPreviewSlice, + state: PageTabState + ) => SessionPreviewSlice | null +) { + set((state) => { + const projectId = state.sessionPreviewProjectId; + if (!projectId) return state; + const slice = updater(getSessionPreviewSlice(state), state); + if (!slice) return state; + return { + sessionPreviewByProject: { + ...state.sessionPreviewByProject, + [projectId]: slice, + }, + }; + }); } export const usePageTabStore = create()( @@ -334,16 +644,219 @@ export const usePageTabStore = create()( setScrollToTurnRequest: (request) => set({ scrollToTurnRequest: request }), - filePreviewOpen: false, - filePreviewFile: null, + sessionPreviewProjectId: null, + sessionPreviewByProject: {}, + previewBrowserViewport: null, + setPreviewBrowserViewport: (rect) => + set({ previewBrowserViewport: rect }), + setSessionPreviewProject: (projectId) => + set((state) => + state.sessionPreviewProjectId === projectId + ? state + : { sessionPreviewProjectId: projectId } + ), + toggleSessionPreview: () => + setSessionPreviewSlice(set, (slice) => { + if (slice.open) return { ...slice, open: false }; + if (slice.tabs.length > 0) return { ...slice, open: true }; + const initial = createInitialSessionPreviewTabs(); + return { + open: true, + tabs: initial.tabs, + activeTabId: initial.activeTabId, + }; + }), + addChooserPreviewTab: () => + setSessionPreviewSlice(set, (slice) => { + const tab = createChooserPreviewTab(); + return { + open: true, + tabs: [...slice.tabs, tab], + activeTabId: tab.id, + }; + }), + choosePreviewTabType: (tabId, kind) => + setSessionPreviewSlice(set, (slice, state) => { + const tab = createPreviewTabOfKind( + kind, + state.sessionPreviewProjectId + ); + const index = slice.tabs.findIndex( + (candidate) => candidate.id === tabId + ); + const tabs = [...slice.tabs]; + 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) => ({ - filePreviewOpen: true, - filePreviewFile: file === undefined ? state.filePreviewFile : file, + setSessionPreviewSlice(set, (slice) => { + const targetFile = file ?? null; + const previewTabs = slice.tabs; + 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 { + open: true, + tabs: previewTabs, + activeTabId: matchingTab.id, + }; + } + + // 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 === slice.activeTabId && isReusable(tab) + ); + return activeIndex >= 0 + ? activeIndex + : previewTabs.findIndex(isReusable); + })(); + if (reuseIndex >= 0) { + const tabs = [...previewTabs]; + tabs[reuseIndex] = createFilePreviewTab(targetFile); + return { open: true, tabs, activeTabId: tabs[reuseIndex].id }; + } + + const tab = createFilePreviewTab(targetFile); + return { + open: true, + tabs: [...previewTabs, tab], + activeTabId: tab.id, + }; + }), + openBrowserPreview: (url) => + setSessionPreviewSlice(set, (slice, state) => { + const normalized = normalizeBrowserUrl(url); + if (!normalized.ok) return null; + const canonical = canonicalizeBrowserUrl(normalized.url); + + // A tab already showing this URL (live page or pending load) — focus it. + const existing = slice.tabs.find( + (tab) => + tab.type === 'browser' && + canonicalizeBrowserUrl(tab.navigation.url || tab.url) === + canonical + ); + if (existing) { + return { ...slice, open: true, activeTabId: existing.id }; + } + + const title = browserTabTitleForUrl(normalized.url); + + // Reuse a blank starter tab (empty browser, or the chooser) in + // place — preferring the active one — so links don't pile up tabs. + const isReusable = (tab: SessionPreviewTab) => + tab.type === 'chooser' || (tab.type === 'browser' && !tab.url); + const reuseIndex = (() => { + const activeIndex = slice.tabs.findIndex( + (tab) => tab.id === slice.activeTabId && isReusable(tab) + ); + return activeIndex >= 0 + ? activeIndex + : slice.tabs.findIndex(isReusable); + })(); + if (reuseIndex >= 0) { + const reused = slice.tabs[reuseIndex]; + const tabs = [...slice.tabs]; + tabs[reuseIndex] = + reused.type === 'browser' + ? // Keep the tab (and its webviewId): setting the URL mounts + // its guest in the browser layer. + { + ...reused, + url: normalized.url, + title, + navigation: { ...reused.navigation, url: normalized.url }, + } + : { + ...createBrowserPreviewTab(state.sessionPreviewProjectId), + url: normalized.url, + title, + }; + return { open: true, tabs, activeTabId: tabs[reuseIndex].id }; + } + + const tab: SessionBrowserTab = { + ...createBrowserPreviewTab(state.sessionPreviewProjectId), + url: normalized.url, + title, + }; + return { + open: true, + tabs: [...slice.tabs, tab], + activeTabId: tab.id, + }; + }), + selectSessionPreviewTab: (tabId) => + setSessionPreviewSlice(set, (slice) => + slice.tabs.some((tab) => tab.id === tabId) + ? { ...slice, activeTabId: tabId } + : null + ), + closeSessionPreviewTab: (tabId) => + setSessionPreviewSlice(set, (slice) => { + const closingIndex = slice.tabs.findIndex((tab) => tab.id === tabId); + if (closingIndex < 0) return null; + const tabs = slice.tabs.filter((tab) => tab.id !== tabId); + if (tabs.length === 0) { + return { open: false, tabs: [], activeTabId: null }; + } + if (slice.activeTabId !== tabId) { + return { ...slice, tabs }; + } + const nextTab = tabs[Math.min(closingIndex, tabs.length - 1)]; + return { ...slice, tabs, activeTabId: nextTab.id }; + }), + updateBrowserPreviewTab: (tabId, patch) => + setSessionPreviewSlice(set, (slice) => ({ + ...slice, + tabs: slice.tabs.map((tab) => + tab.id === tabId && tab.type === 'browser' + ? { ...tab, ...patch } + : tab + ), + })), + updateBrowserPreviewTabIn: (projectId, tabId, patch) => + set((state) => { + const slice = state.sessionPreviewByProject[projectId]; + if (!slice) return state; + return { + sessionPreviewByProject: { + ...state.sessionPreviewByProject, + [projectId]: { + ...slice, + tabs: slice.tabs.map((tab) => + tab.id === tabId && tab.type === 'browser' + ? { ...tab, ...patch } + : tab + ), + }, + }, + }; + }), + closeSessionPreview: () => + setSessionPreviewSlice(set, (slice) => ({ ...slice, open: false })), + resetSessionPreview: () => + setSessionPreviewSlice(set, () => ({ + open: false, + tabs: [], + activeTabId: null, })), - closeFilePreview: () => set({ filePreviewOpen: false }), - toggleFilePreview: () => - set((state) => ({ filePreviewOpen: !state.filePreviewOpen })), }), { name: 'eigent-page-tab', @@ -366,6 +879,9 @@ export const usePageTabStore = create()( projectSidebarFolded: state.projectSidebarFolded, customAgentFolderPathByProjectId: state.customAgentFolderPathByProjectId, + sessionPreviewByProject: sanitizeSessionPreviewForPersist( + state.sessionPreviewByProject + ), }), } ) diff --git a/test/unit/components/Session/PreviewBrowserLayer.test.tsx b/test/unit/components/Session/PreviewBrowserLayer.test.tsx new file mode 100644 index 000000000..36b7e5837 --- /dev/null +++ b/test/unit/components/Session/PreviewBrowserLayer.test.tsx @@ -0,0 +1,219 @@ +// ========= 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 { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore'; +import { act, render, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const host = { ipcRenderer: null, electronAPI: {} }; + +function renderLayer() { + return render( + + + + ); +} + +function getBrowserTab() { + const tab = getSessionPreviewSlice(usePageTabStore.getState()).tabs.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: {}, + previewBrowserViewport: null, + }); + usePageTabStore.getState().setSessionPreviewProject('project-a'); + usePageTabStore.getState().toggleSessionPreview(); + // Turn the seeded chooser tab into a browser tab for these tests. + const chooserId = getSessionPreviewSlice(usePageTabStore.getState()).tabs[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', async () => { + 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. + // Parking happens after a short opacity fade, hence the waitFor. + act(() => { + usePageTabStore.getState().closeSessionPreview(); + }); + await waitFor(() => + expect(guestContainer(tab.webviewId)!.style.visibility).toBe('hidden') + ); + const parked = guestContainer(tab.webviewId)!; + expect(parked.querySelector('webview')).not.toBeNull(); + }); + + it('keeps guests of other projects parked and restores them on switch-back', async () => { + 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 parks after the fade. + await waitFor(() => + 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, + }); + // Events land in the per-project record (the single source of truth). + expect( + usePageTabStore.getState().sessionPreviewByProject['project-a'].tabs + ).toContainEqual(updated); + }); + + it('evicts guests of projects left out of scope beyond the idle window', () => { + vi.useFakeTimers(); + try { + renderLayer(); + const tab = getBrowserTab(); + act(() => { + usePageTabStore + .getState() + .updateBrowserPreviewTab(tab.id, { url: 'https://a.example.com/' }); + }); + expect(guestContainer(tab.webviewId)).not.toBeNull(); + + act(() => { + usePageTabStore.getState().setSessionPreviewProject('project-b'); + }); + // Still alive shortly after leaving scope… + act(() => { + vi.advanceTimersByTime(60_000); + }); + expect(guestContainer(tab.webviewId)).not.toBeNull(); + + // …but destroyed once it has idled past the eviction window. + act(() => { + vi.advanceTimersByTime(11 * 60_000); + }); + expect(guestContainer(tab.webviewId)).toBeNull(); + expect(getPreviewWebview(tab.webviewId)).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + + 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 new file mode 100644 index 000000000..4001148d0 --- /dev/null +++ b/test/unit/components/Session/PreviewPanel.test.tsx @@ -0,0 +1,207 @@ +// ========= 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 { + registerPreviewWebview, + unregisterPreviewWebview, +} from '@/components/Session/PreviewPanel/tabs/browser/webviewRegistry'; +import { HostProvider } from '@/host'; +import { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore'; +import { act, render, screen } 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'}
+ ), +})); + +// 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: () =>
, +})); + +// 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( + + + + ); +} + +function previewSlice() { + return getSessionPreviewSlice(usePageTabStore.getState()); +} + +function activeType() { + const slice = previewSlice(); + return slice.tabs.find((tab) => tab.id === slice.activeTabId)?.type; +} + +describe('PreviewPanel', () => { + beforeEach(() => { + usePageTabStore.setState({ + sessionPreviewProjectId: null, + sessionPreviewByProject: {}, + previewBrowserViewport: null, + }); + usePageTabStore.getState().setSessionPreviewProject('project-test'); + usePageTabStore.getState().toggleSessionPreview(); + }); + + it('opens on the chooser tab listing the available content kinds', () => { + renderPanel(); + expect(screen.getByRole('tab', { name: 'New tab' })).toBeInTheDocument(); + // Vertical options (test i18n echoes the key, not the label). + for (const kind of ['browser', 'file']) { + expect( + screen.getByRole('button', { + name: new RegExp(`preview-kind-${kind}\\b`), + }) + ).toBeInTheDocument(); + } + // Reserved kinds stay hidden from the chooser until a later version. + for (const kind of ['review', 'terminal', 'canvas']) { + expect( + screen.queryByRole('button', { + name: new RegExp(`preview-kind-${kind}\\b`), + }) + ).not.toBeInTheDocument(); + } + }); + + 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: /preview-kind-browser\b/ }) + ); + expect(activeType()).toBe('browser'); + // Address bar of the browser tab is now shown. + expect( + screen.getByRole('textbox', { name: 'layout.browser-url-placeholder' }) + ).toBeInTheDocument(); + }); + + 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 }); + renderPanel(); + + expect(screen.getByTestId('file-preview')).toHaveTextContent('notes.md'); + expect(screen.getAllByRole('tab', { name: 'notes.md' })).toHaveLength(1); + }); + + it('routes review, terminal, and canvas tabs to their surfaces', () => { + const store = usePageTabStore.getState(); + const chooserId = previewSlice().tabs[0].id; + act(() => store.choosePreviewTabType(chooserId, 'canvas')); + const { rerender } = renderPanel(); + expect(screen.getByTestId('canvas-tab')).toBeInTheDocument(); + + act(() => + store.choosePreviewTabType(previewSlice().activeTabId!, 'terminal') + ); + rerender( + + + + ); + expect(screen.getByText('Eigent:~$')).toBeInTheDocument(); + }); + + it('the + button adds a new chooser tab', async () => { + const user = userEvent.setup(); + renderPanel(); + + await user.click( + screen.getByRole('button', { name: 'layout.add-preview-tab' }) + ); + expect(screen.getAllByRole('tab', { name: 'New tab' })).toHaveLength(2); + expect(activeType()).toBe('chooser'); + }); + + it('drives back/forward/reload on the registered guest element', async () => { + const user = userEvent.setup(); + const store = usePageTabStore.getState(); + const chooserId = previewSlice().tabs[0].id; + act(() => store.choosePreviewTabType(chooserId, 'browser')); + const browserTab = previewSlice().tabs.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('closing the final tab closes the panel', async () => { + const user = userEvent.setup(); + renderPanel(); + + await user.click( + screen.getByRole('button', { name: 'layout.close-preview-tab' }) + ); + + expect(previewSlice()).toMatchObject({ + open: false, + tabs: [], + activeTabId: null, + }); + }); +}); diff --git a/test/unit/lib/browserUrl.test.ts b/test/unit/lib/browserUrl.test.ts new file mode 100644 index 000000000..29c20f911 --- /dev/null +++ b/test/unit/lib/browserUrl.test.ts @@ -0,0 +1,79 @@ +// ========= 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 private-network 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', + }); + expect(normalizeBrowserUrl('192.168.1.20')).toEqual({ + ok: true, + url: 'http://192.168.1.20/', + }); + expect(normalizeBrowserUrl('10.0.0.5:9000')).toEqual({ + ok: true, + url: 'http://10.0.0.5:9000/', + }); + }); + + it('defaults public IPs (like hostnames) to HTTPS', () => { + expect(normalizeBrowserUrl('8.8.8.8')).toEqual({ + ok: true, + url: 'https://8.8.8.8/', + }); + }); + + it('turns non-URL input into a web search', () => { + expect(normalizeBrowserUrl('hello world')).toEqual({ + ok: true, + url: `https://www.google.com/search?q=${encodeURIComponent('hello world')}`, + }); + expect(normalizeBrowserUrl('golang')).toEqual({ + ok: true, + url: 'https://www.google.com/search?q=golang', + }); + // Anything host-like still navigates instead of searching. + expect(normalizeBrowserUrl('intranet:8080')).toEqual({ + ok: true, + url: 'https://intranet:8080/', + }); + }); + + 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..11ad71580 --- /dev/null +++ b/test/unit/store/pageTabStore.preview.test.ts @@ -0,0 +1,221 @@ +// ========= 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 { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore'; +import { beforeEach, describe, expect, it } from 'vitest'; + +/** The preview slice for the currently scoped project. */ +function slice() { + return getSessionPreviewSlice(usePageTabStore.getState()); +} + +describe('pageTabStore session preview', () => { + beforeEach(() => { + usePageTabStore.setState({ + sessionPreviewProjectId: null, + sessionPreviewByProject: {}, + }); + usePageTabStore.getState().setSessionPreviewProject('project-a'); + }); + + it('opens onto a single chooser tab', () => { + const store = usePageTabStore.getState(); + store.toggleSessionPreview(); + + expect(slice().open).toBe(true); + expect(slice().tabs.map((tab) => tab.type)).toEqual(['chooser']); + expect(slice().activeTabId).toBe(slice().tabs[0].id); + }); + + it('drops mutations when no project is scoped', () => { + const store = usePageTabStore.getState(); + store.setSessionPreviewProject(null); + store.toggleSessionPreview(); + + expect(slice()).toMatchObject({ open: false, tabs: [] }); + expect(usePageTabStore.getState().sessionPreviewByProject).toEqual({}); + }); + + it('turns the chooser into the chosen content kind in place', () => { + const store = usePageTabStore.getState(); + store.toggleSessionPreview(); + const chooserId = slice().tabs[0].id; + + store.choosePreviewTabType(chooserId, 'browser'); + expect(slice().tabs).toHaveLength(1); + const browser = slice().tabs[0]; + expect(browser.type).toBe('browser'); + expect(slice().activeTabId).toBe(browser.id); + expect(browser.type === 'browser' && browser.webviewId).toContain( + 'session-preview:project-a:' + ); + + // A new chooser (via "+") can become other kinds too. + store.addChooserPreviewTab(); + const newChooserId = slice().activeTabId!; + store.choosePreviewTabType(newChooserId, 'canvas'); + expect(slice().tabs.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 = slice().activeTabId!; + store.choosePreviewTabType(id, kind); + const active = slice().tabs.find((tab) => tab.id === slice().activeTabId); + 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); + + // Chooser replaced in place — no extra tab piled up. + expect(slice().tabs).toHaveLength(1); + expect(slice().tabs[0]).toMatchObject({ + type: 'file', + title: 'doc.txt', + file, + }); + }); + + it('reuses the empty file tab and deduplicates files by path', () => { + const store = usePageTabStore.getState(); + store.toggleSessionPreview(); + const chooserId = slice().tabs[0].id; + store.choosePreviewTabType(chooserId, 'file'); + + const file = { name: 'doc.txt', path: '/tmp/doc.txt' } as FileInfo; + store.openFilePreview(file); + store.openFilePreview({ ...file }); + + const fileTabs = slice().tabs.filter((tab) => tab.type === 'file'); + expect(fileTabs).toHaveLength(1); + expect(fileTabs[0]).toMatchObject({ title: 'doc.txt', file }); + expect(slice().activeTabId).toBe(fileTabs[0].id); + }); + + it('opens chat links in a browser tab of the current project preview', () => { + const store = usePageTabStore.getState(); + store.openBrowserPreview('https://example.com/docs'); + + expect(slice().open).toBe(true); + expect(slice().tabs).toHaveLength(1); + expect(slice().tabs[0]).toMatchObject({ + type: 'browser', + url: 'https://example.com/docs', + title: 'example.com', + }); + expect(slice().activeTabId).toBe(slice().tabs[0].id); + + // Invalid destinations are ignored. + store.openBrowserPreview('javascript:alert(1)'); + expect(slice().tabs).toHaveLength(1); + }); + + it('reuses the chooser and deduplicates browser tabs by URL', () => { + const store = usePageTabStore.getState(); + store.toggleSessionPreview(); + store.openBrowserPreview('https://example.com/docs'); + + // Chooser replaced in place — no extra tab piled up. + expect(slice().tabs).toHaveLength(1); + expect(slice().tabs[0].type).toBe('browser'); + + // The same URL (modulo trailing slash) focuses the existing tab… + store.openBrowserPreview('https://example.com/docs/'); + expect(slice().tabs).toHaveLength(1); + + // …while a different URL opens alongside it. + store.openBrowserPreview('https://other.example.com'); + expect(slice().tabs).toHaveLength(2); + expect(slice().activeTabId).toBe(slice().tabs[1].id); + }); + + it('selects a neighboring tab and closes the panel after the final tab', () => { + const store = usePageTabStore.getState(); + store.toggleSessionPreview(); + const chooserId = slice().tabs[0].id; + store.choosePreviewTabType(chooserId, 'browser'); + store.addChooserPreviewTab(); + store.choosePreviewTabType(slice().activeTabId!, 'file'); + const [browserTab, fileTab] = slice().tabs; + + store.closeSessionPreviewTab(browserTab.id); + expect(slice().activeTabId).toBe(fileTab.id); + + store.closeSessionPreviewTab(fileTab.id); + expect(slice()).toMatchObject({ + open: false, + tabs: [], + activeTabId: null, + }); + }); + + it('closes without discarding tabs and resets project-scoped state', () => { + const store = usePageTabStore.getState(); + store.toggleSessionPreview(); + store.closeSessionPreview(); + expect(slice().open).toBe(false); + expect(slice().tabs).toHaveLength(1); + + store.resetSessionPreview(); + expect(slice()).toMatchObject({ + open: false, + tabs: [], + activeTabId: null, + }); + }); + + it('keeps preview state per project and restores it on switch-back', () => { + const store = usePageTabStore.getState(); + store.toggleSessionPreview(); + const projectATabs = slice().tabs; + expect(projectATabs).toHaveLength(1); + + // Switching projects swaps in the other project's (empty) slice… + store.setSessionPreviewProject('project-b'); + expect(slice()).toMatchObject({ + open: false, + tabs: [], + activeTabId: null, + }); + + // …and switching back restores tabs, active tab, and the open flag. + store.setSessionPreviewProject('project-a'); + expect(slice().open).toBe(true); + expect(slice().tabs).toEqual(projectATabs); + expect(slice().activeTabId).toBe(projectATabs[0].id); + }); + + it('records mutations into the per-project slice for persistence', () => { + const store = usePageTabStore.getState(); + store.toggleSessionPreview(); + const file = { name: 'doc.txt', path: '/tmp/doc.txt' } as FileInfo; + store.openFilePreview(file); + + const persisted = + usePageTabStore.getState().sessionPreviewByProject['project-a']; + expect(persisted.open).toBe(true); + expect( + persisted.tabs.filter((tab) => tab.type === 'file' && tab.file !== null) + ).toHaveLength(1); + expect(persisted.activeTabId).toBe(slice().activeTabId); + }); +});