feat(ai): implement AI chat sidebar popout and sidebar resizing (#8509)

This commit is contained in:
naman-bruno
2026-07-08 14:56:44 +05:30
committed by GitHub
parent da707ea0d5
commit 2079c7a452
8 changed files with 375 additions and 12 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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,

View File

@@ -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;