mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-08 22:45:25 +00:00
feat(ai): implement AI chat sidebar popout and sidebar resizing (#8509)
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { closeAiSidebar, dockAiChat } from 'providers/ReduxStore/slices/chat';
|
||||
import PopoutWindow from '../PopoutWindow';
|
||||
import AiChatSidebar from '../index';
|
||||
|
||||
const AiChatPopout = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleClose = useCallback(({ blocked } = {}) => {
|
||||
// Closing the OS window closes the assistant (like undocked devtools).
|
||||
// If window.open was blocked, fall back to the docked sidebar instead.
|
||||
dispatch(blocked ? dockAiChat() : closeAiSidebar());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<PopoutWindow title="AI Assistant" onClose={handleClose}>
|
||||
<AiChatSidebar collection={collection} variant="popout" />
|
||||
</PopoutWindow>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiChatPopout;
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { StyleSheetManager } from 'styled-components';
|
||||
|
||||
// Must match the frameName prefix allow-listed in bruno-electron's
|
||||
// setWindowOpenHandler, any other window.open is denied there. Each open
|
||||
// gets a unique suffix so a remount (e.g. StrictMode's dev double-effect)
|
||||
// never receives a handle to the previous, already-closing named window.
|
||||
export const AI_POPOUT_WINDOW_NAME_PREFIX = 'bruno-ai-assistant';
|
||||
let popoutSeq = 0;
|
||||
|
||||
// Copy every stylesheet already loaded in the main window into the popout
|
||||
// document. We read cssRules instead of cloning the <style> nodes because
|
||||
// styled-components in production injects rules through the CSSOM, leaving
|
||||
// the backing <style> tags empty.
|
||||
const copyStyles = (sourceDoc, targetDoc) => {
|
||||
for (const sheet of Array.from(sourceDoc.styleSheets)) {
|
||||
try {
|
||||
const css = Array.from(sheet.cssRules)
|
||||
.map((rule) => rule.cssText)
|
||||
.join('\n');
|
||||
const styleEl = targetDoc.createElement('style');
|
||||
styleEl.textContent = css;
|
||||
targetDoc.head.appendChild(styleEl);
|
||||
} catch (err) {
|
||||
// cssRules throws for sheets Chromium treats as cross-origin (e.g.
|
||||
// file:// <link>s in packaged builds) fall back to a link with the
|
||||
// resolved absolute URL, since about:blank can't resolve relative ones.
|
||||
if (sheet.href) {
|
||||
const linkEl = targetDoc.createElement('link');
|
||||
linkEl.rel = 'stylesheet';
|
||||
linkEl.href = sheet.href;
|
||||
targetDoc.head.appendChild(linkEl);
|
||||
} else if (sheet.ownerNode) {
|
||||
targetDoc.head.appendChild(targetDoc.importNode(sheet.ownerNode, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const PopoutWindow = ({ title, width = 480, height = 640, onClose, children }) => {
|
||||
const [container, setContainer] = useState(null);
|
||||
const closedRef = useRef(false);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
const win = window.open('', `${AI_POPOUT_WINDOW_NAME_PREFIX}-${++popoutSeq}`, `popup=yes,width=${width},height=${height}`);
|
||||
if (!win) {
|
||||
onCloseRef.current?.({ blocked: true });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const doc = win.document;
|
||||
doc.title = title;
|
||||
copyStyles(document, doc);
|
||||
// Mirror the theme classes ('light'/'dark' live on <html>) so any
|
||||
// selectors keyed on them keep working in the popout and keep them in
|
||||
// sync when the user switches theme while the popout is open.
|
||||
const syncThemeClasses = () => {
|
||||
doc.documentElement.className = document.documentElement.className;
|
||||
doc.body.className = document.body.className;
|
||||
};
|
||||
syncThemeClasses();
|
||||
const themeObserver = new MutationObserver(syncThemeClasses);
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
||||
doc.documentElement.style.height = '100%';
|
||||
doc.body.style.height = '100%';
|
||||
doc.body.style.margin = '0';
|
||||
// Safety net if a stylesheet couldn't be copied: inherit the app's base
|
||||
// typography and background so the popout never renders unstyled-white.
|
||||
const baseStyle = window.getComputedStyle(document.body);
|
||||
doc.body.style.fontFamily = baseStyle.fontFamily;
|
||||
doc.body.style.fontSize = baseStyle.fontSize;
|
||||
doc.body.style.color = baseStyle.color;
|
||||
doc.body.style.backgroundColor = baseStyle.backgroundColor;
|
||||
|
||||
const mount = doc.createElement('div');
|
||||
mount.style.height = '100%';
|
||||
doc.body.appendChild(mount);
|
||||
|
||||
// Detect the user closing the OS window by polling win.closed. Don't use
|
||||
// pagehide/unload — Electron fires them on the initial about:blank
|
||||
// document right after window.open, which would close the chat instantly.
|
||||
const pollTimer = setInterval(() => {
|
||||
if (!win.closed) return;
|
||||
clearInterval(pollTimer);
|
||||
if (closedRef.current) return;
|
||||
closedRef.current = true;
|
||||
onCloseRef.current?.({ blocked: false });
|
||||
}, 300);
|
||||
|
||||
// Closing an already-user-closed window throws "IPC method called after
|
||||
// context was released" in Electron — always check/catch.
|
||||
const safeCloseChild = () => {
|
||||
closedRef.current = true;
|
||||
try {
|
||||
if (!win.closed) win.close();
|
||||
} catch (err) {
|
||||
// window context already gone
|
||||
}
|
||||
};
|
||||
|
||||
// If the main window reloads, React cleanup never runs — close the child
|
||||
// explicitly so it isn't orphaned.
|
||||
window.addEventListener('beforeunload', safeCloseChild);
|
||||
|
||||
win.focus();
|
||||
setContainer(mount);
|
||||
|
||||
return () => {
|
||||
themeObserver.disconnect();
|
||||
clearInterval(pollTimer);
|
||||
window.removeEventListener('beforeunload', safeCloseChild);
|
||||
safeCloseChild();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!container) return null;
|
||||
|
||||
return createPortal(
|
||||
<StyleSheetManager target={container.ownerDocument.head}>{children}</StyleSheetManager>,
|
||||
container
|
||||
);
|
||||
};
|
||||
|
||||
export default PopoutWindow;
|
||||
@@ -3,16 +3,53 @@ import styled from 'styled-components';
|
||||
const StyledWrapper = styled.div`
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
.ai-sidebar {
|
||||
width: 420px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: ${(props) => props.theme.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
border-left: 1px solid ${(props) => props.theme.border.border1};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ai-sidebar-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: col-resize;
|
||||
z-index: 5;
|
||||
|
||||
.drag-border {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
border-left: solid 1px transparent;
|
||||
}
|
||||
|
||||
&:hover .drag-border {
|
||||
border-left-color: ${(props) => props.theme.sidebar.dragbar.border};
|
||||
}
|
||||
}
|
||||
|
||||
&.popout .ai-sidebar {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
&.popout .ai-sidebar-header {
|
||||
-webkit-app-region: drag;
|
||||
|
||||
button,
|
||||
.history-popover {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
IconChevronDown,
|
||||
IconHistory,
|
||||
IconPlus,
|
||||
IconTrash
|
||||
IconTrash,
|
||||
IconExternalLink,
|
||||
IconLayoutSidebarRightExpand
|
||||
} from '@tabler/icons';
|
||||
import IconSparkles from 'components/Icons/IconSparkles';
|
||||
import get from 'lodash/get';
|
||||
@@ -19,6 +21,8 @@ import MenuDropdown from 'ui/MenuDropdown';
|
||||
import { focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import {
|
||||
closeAiSidebar,
|
||||
popOutAiChat,
|
||||
dockAiChat,
|
||||
sendAiMessage,
|
||||
stopAiStream,
|
||||
setChatBinding,
|
||||
@@ -43,6 +47,7 @@ import {
|
||||
updateCollectionTests,
|
||||
updateCollectionDocs
|
||||
} from 'providers/ReduxStore/slices/collections';
|
||||
import { updateIsDragging } from 'providers/ReduxStore/slices/app';
|
||||
import { findItemInCollection, findItemInCollectionByPathname, isItemAFolder, isItemARequest } from 'utils/collections';
|
||||
import { buildAiVariablesPayload, getAiStatus } from 'utils/ai';
|
||||
|
||||
@@ -55,6 +60,19 @@ import { renderMarkdown, parseMessageSegments } from './utils';
|
||||
const SELECTED_MODEL_LS_KEY = 'bruno.ai.chat.selectedModel';
|
||||
const AUTO_MODEL_ID = '';
|
||||
|
||||
const SIDEBAR_WIDTH_LS_KEY = 'bruno.ai.chat.sidebarWidth';
|
||||
const DEFAULT_SIDEBAR_WIDTH = 420;
|
||||
const MIN_SIDEBAR_WIDTH = 340;
|
||||
const MAX_SIDEBAR_WIDTH = 720;
|
||||
|
||||
const clampSidebarWidth = (value) =>
|
||||
Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, value));
|
||||
|
||||
// The docked sidebar and the popout are different subtrees, so switching
|
||||
// between them remounts this component. Keep the unsent draft here so it
|
||||
// survives the pop-out/dock transition.
|
||||
let draftInputCache = '';
|
||||
|
||||
const ToolActivityGroup = ({ activities }) => {
|
||||
if (!activities?.length) return null;
|
||||
const allDone = activities.every((a) => a.done);
|
||||
@@ -126,11 +144,14 @@ const HistoryPopover = ({ items, activeId, onPick, onDelete, onClose }) => {
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
document.addEventListener('keydown', handleKey);
|
||||
// ownerDocument in the popped-out window the popover
|
||||
// lives in a different document than the one this module closed over.
|
||||
const doc = popoverRef.current?.ownerDocument || document;
|
||||
doc.addEventListener('mousedown', handleClick);
|
||||
doc.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
doc.removeEventListener('mousedown', handleClick);
|
||||
doc.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
@@ -166,15 +187,28 @@ const HistoryPopover = ({ items, activeId, onPick, onDelete, onClose }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const AiChatSidebar = ({ collection }) => {
|
||||
const AiChatSidebar = ({ collection, variant = 'sidebar' }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [input, setInput] = useState('');
|
||||
const isPopout = variant === 'popout';
|
||||
const [input, _setInput] = useState(() => draftInputCache);
|
||||
const setInput = useCallback((value) => {
|
||||
draftInputCache = value;
|
||||
_setInput(value);
|
||||
}, []);
|
||||
const [processingStage, setProcessingStage] = useState(null);
|
||||
const [availableModels, setAvailableModels] = useState([]);
|
||||
const [selectedModel, setSelectedModel] = useState(() => {
|
||||
try { return localStorage.getItem(SELECTED_MODEL_LS_KEY) ?? AUTO_MODEL_ID; } catch { return AUTO_MODEL_ID; }
|
||||
});
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
try {
|
||||
const stored = parseInt(localStorage.getItem(SIDEBAR_WIDTH_LS_KEY), 10);
|
||||
if (!Number.isNaN(stored)) return clampSidebarWidth(stored);
|
||||
} catch {}
|
||||
return DEFAULT_SIDEBAR_WIDTH;
|
||||
});
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const messagesEndRef = useRef(null);
|
||||
const messagesContainerRef = useRef(null);
|
||||
const isNearBottomRef = useRef(true);
|
||||
@@ -424,6 +458,45 @@ const AiChatSidebar = ({ collection }) => {
|
||||
if (isOpen) textareaRef.current?.focus();
|
||||
}, [isOpen]);
|
||||
|
||||
// Re-measure the textarea on mount when a draft was restored from the
|
||||
// module cache (pop-out/dock remount) so it isn't stuck at one row.
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (el && el.value) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 150) + 'px';
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizing) return;
|
||||
const handleMouseMove = (e) => {
|
||||
e.preventDefault();
|
||||
setSidebarWidth(clampSidebarWidth(window.innerWidth - e.clientX));
|
||||
};
|
||||
const handleMouseUp = (e) => {
|
||||
e.preventDefault();
|
||||
setResizing(false);
|
||||
dispatch(updateIsDragging({ isDragging: false }));
|
||||
setSidebarWidth((width) => {
|
||||
try { localStorage.setItem(SIDEBAR_WIDTH_LS_KEY, String(width)); } catch {}
|
||||
return width;
|
||||
});
|
||||
};
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [resizing, dispatch]);
|
||||
|
||||
const handleResizeStart = (e) => {
|
||||
e.preventDefault();
|
||||
setResizing(true);
|
||||
dispatch(updateIsDragging({ isDragging: true }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setProcessingStage(null);
|
||||
@@ -553,6 +626,7 @@ const AiChatSidebar = ({ collection }) => {
|
||||
};
|
||||
|
||||
const handleClose = () => dispatch(closeAiSidebar());
|
||||
const handleTogglePopout = () => dispatch(isPopout ? dockAiChat() : popOutAiChat());
|
||||
const handleSwitchChat = (tabUid) => dispatch(focusTab({ uid: tabUid }));
|
||||
|
||||
const handleSuggestionClick = (suggestion) => {
|
||||
@@ -749,7 +823,22 @@ const AiChatSidebar = ({ collection }) => {
|
||||
const historyCount = historyList?.length || 0;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<StyledWrapper
|
||||
className={isPopout ? 'popout' : ''}
|
||||
style={isPopout ? undefined : { width: sidebarWidth }}
|
||||
>
|
||||
{!isPopout && (
|
||||
<div
|
||||
className="ai-sidebar-resize-handle"
|
||||
data-testid="ai-sidebar-resize-handle"
|
||||
onMouseDown={handleResizeStart}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize AI sidebar"
|
||||
>
|
||||
<div className="drag-border" />
|
||||
</div>
|
||||
)}
|
||||
<div className="ai-sidebar">
|
||||
<div className="ai-sidebar-header">
|
||||
<div className="header-left">
|
||||
@@ -800,6 +889,14 @@ const AiChatSidebar = ({ collection }) => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={handleTogglePopout}
|
||||
title={isPopout ? 'Dock to sidebar' : 'Open in new window'}
|
||||
data-testid="ai-popout-toggle"
|
||||
>
|
||||
{isPopout ? <IconLayoutSidebarRightExpand size={14} /> : <IconExternalLink size={14} />}
|
||||
</button>
|
||||
<button className="icon-btn close-btn" onClick={handleClose} title="Close">
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
|
||||
@@ -58,6 +58,12 @@ const EXPAND_EDGE_THRESHOLD = 100;
|
||||
// Minimum response pane height to show placeholder content on click-expand
|
||||
const RESPONSE_EXPAND_MIN_HEIGHT = 300;
|
||||
|
||||
// Tabs whose response pane we auto-collapsed when the AI sidebar docked.
|
||||
// Module-level because the panel remounts per tab (key={activeTabUid}) — a
|
||||
// tab is restored here only once the sidebar is gone AND the user didn't
|
||||
// expand it manually in the meantime.
|
||||
const aiAutoCollapsedTabs = new Set();
|
||||
|
||||
const RequestTabPanel = () => {
|
||||
const dispatch = useDispatch();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
@@ -70,6 +76,7 @@ const RequestTabPanel = () => {
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
|
||||
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
|
||||
const isAiSidebarDocked = useSelector((state) => state.chat.isOpen && !state.chat.isPoppedOut);
|
||||
|
||||
const isRequestTab = focusedTab && ['request', 'http-request', 'grpc-request', 'ws-request', 'graphql-request'].includes(focusedTab.type);
|
||||
useKeybinding('sendRequest', (e) => {
|
||||
@@ -356,6 +363,20 @@ const RequestTabPanel = () => {
|
||||
};
|
||||
}, [setLeftPaneWidth, isVerticalLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVerticalLayout) return;
|
||||
if (isAiSidebarDocked) {
|
||||
if (responsePaneCollapsedRef.current) return;
|
||||
aiAutoCollapsedTabs.add(activeTabUid);
|
||||
collapseResponseRef.current();
|
||||
} else if (aiAutoCollapsedTabs.has(activeTabUid)) {
|
||||
aiAutoCollapsedTabs.delete(activeTabUid);
|
||||
if (responsePaneCollapsedRef.current) {
|
||||
expandResponseRef.current();
|
||||
}
|
||||
}
|
||||
}, [isAiSidebarDocked, isVerticalLayout, activeTabUid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVerticalLayout) return;
|
||||
if (responsePaneCollapsed) return;
|
||||
|
||||
@@ -5,6 +5,7 @@ import RequestTabs from 'components/RequestTabs';
|
||||
import RequestTabPanel from 'components/RequestTabPanel';
|
||||
import AppPreviewKeepAlive from 'components/AppPreviewKeepAlive';
|
||||
import AiChatSidebar from 'components/AiChatSidebar';
|
||||
import AiChatPopout from 'components/AiChatSidebar/Popout';
|
||||
import Sidebar from 'components/Sidebar';
|
||||
import StatusBar from 'components/StatusBar';
|
||||
import AppTitleBar from 'components/AppTitleBar';
|
||||
@@ -93,6 +94,7 @@ export default function Main() {
|
||||
// re-render on every tabs/collections change — important on Windows where
|
||||
// extra re-renders during initial layout were destabilising CodeMirror.
|
||||
const isAiSidebarOpen = useSelector((state) => state.chat.isOpen);
|
||||
const isAiPoppedOut = useSelector((state) => state.chat.isPoppedOut);
|
||||
const activeCollection = useSelector((state) => {
|
||||
if (!state.chat.isOpen) return null;
|
||||
const activeTab = state.tabs.tabs.find((t) => t.uid === state.tabs.activeTabUid);
|
||||
@@ -173,7 +175,10 @@ export default function Main() {
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
{isAiSidebarOpen && activeCollection && !showApiSpecPage && !showManageWorkspacePage && (
|
||||
{isAiSidebarOpen && activeCollection && isAiPoppedOut && (
|
||||
<AiChatPopout collection={activeCollection} />
|
||||
)}
|
||||
{isAiSidebarOpen && activeCollection && !isAiPoppedOut && !showApiSpecPage && !showManageWorkspacePage && (
|
||||
<AiChatSidebar collection={activeCollection} />
|
||||
)}
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
|
||||
const initialState = {
|
||||
isOpen: false,
|
||||
isPoppedOut: false,
|
||||
chats: {}
|
||||
};
|
||||
|
||||
@@ -39,12 +40,20 @@ export const chatSlice = createSlice({
|
||||
reducers: {
|
||||
toggleAiSidebar: (state) => {
|
||||
state.isOpen = !state.isOpen;
|
||||
if (!state.isOpen) state.isPoppedOut = false;
|
||||
},
|
||||
openAiSidebar: (state) => {
|
||||
state.isOpen = true;
|
||||
},
|
||||
closeAiSidebar: (state) => {
|
||||
state.isOpen = false;
|
||||
state.isPoppedOut = false;
|
||||
},
|
||||
popOutAiChat: (state) => {
|
||||
state.isPoppedOut = true;
|
||||
},
|
||||
dockAiChat: (state) => {
|
||||
state.isPoppedOut = false;
|
||||
},
|
||||
setChatBinding: (state, action) => {
|
||||
const { tabUid, pathname, collectionUid, contentType } = action.payload;
|
||||
@@ -167,6 +176,8 @@ export const {
|
||||
toggleAiSidebar,
|
||||
openAiSidebar,
|
||||
closeAiSidebar,
|
||||
popOutAiChat,
|
||||
dockAiChat,
|
||||
setChatBinding,
|
||||
startNewConversation,
|
||||
addAiMessage,
|
||||
|
||||
@@ -15,7 +15,7 @@ if (isDev) {
|
||||
}
|
||||
|
||||
const { format } = require('url');
|
||||
const { BrowserWindow, app, session, Menu, globalShortcut, ipcMain, nativeTheme } = require('electron');
|
||||
const { BrowserWindow, app, session, Menu, globalShortcut, ipcMain, nativeTheme, shell } = require('electron');
|
||||
const { setContentSecurityPolicy } = require('electron-util');
|
||||
|
||||
if (isDev && process.env.ELECTRON_USER_DATA_PATH) {
|
||||
@@ -426,7 +426,27 @@ app.on('ready', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
const AI_POPOUT_FRAME_PREFIX = 'bruno-ai-assistant';
|
||||
const isAiPopoutFrame = (frameName) => Boolean(frameName && frameName.startsWith(AI_POPOUT_FRAME_PREFIX));
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url, frameName }) => {
|
||||
if (isAiPopoutFrame(frameName) && (!url || url === 'about:blank')) {
|
||||
return {
|
||||
action: 'allow',
|
||||
overrideBrowserWindowOptions: {
|
||||
width: 480,
|
||||
height: 640,
|
||||
minWidth: 400,
|
||||
minHeight: 480,
|
||||
backgroundColor: themeBg,
|
||||
icon: path.join(__dirname, 'about', '256x256.png'),
|
||||
autoHideMenuBar: true,
|
||||
// No OS title bar, the chat header is the drag region and carries
|
||||
// its own close/dock controls.
|
||||
frame: false
|
||||
}
|
||||
};
|
||||
}
|
||||
try {
|
||||
const { protocol } = new URL(url);
|
||||
if (['https:', 'http:'].includes(protocol)) {
|
||||
@@ -438,6 +458,27 @@ app.on('ready', async () => {
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('did-create-window', (childWindow, { frameName }) => {
|
||||
if (!isAiPopoutFrame(frameName)) return;
|
||||
// Links inside AI responses open in the default browser and must never
|
||||
// navigate the popout document itself (that would tear down the portal).
|
||||
const openExternally = (url) => {
|
||||
if (/^https?:\/\//.test(url)) {
|
||||
shell.openExternal(url).catch((err) => {
|
||||
console.error('Failed to open external URL from AI popout:', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
childWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
openExternally(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
childWindow.webContents.on('will-navigate', (event, url) => {
|
||||
event.preventDefault();
|
||||
openExternally(url);
|
||||
});
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('did-finish-load', async () => {
|
||||
try {
|
||||
let ogSend = mainWindow.webContents.send;
|
||||
|
||||
Reference in New Issue
Block a user