diff --git a/packages/bruno-app/src/components/AiChatSidebar/StyledWrapper.js b/packages/bruno-app/src/components/AiChatSidebar/StyledWrapper.js index 58a87bc7a..ef752a46f 100644 --- a/packages/bruno-app/src/components/AiChatSidebar/StyledWrapper.js +++ b/packages/bruno-app/src/components/AiChatSidebar/StyledWrapper.js @@ -52,6 +52,10 @@ const StyledWrapper = styled.div` &.method-patch { color: ${(props) => props.theme.request.methods.patch}; } &.method-options { color: ${(props) => props.theme.request.methods.options}; } &.method-head { color: ${(props) => props.theme.request.methods.head}; } + &.method-grpc { color: ${(props) => props.theme.request.grpc}; } + &.method-ws { color: ${(props) => props.theme.request.ws}; } + &.method-gql { color: ${(props) => props.theme.request.gql}; } + &.method-app { color: ${(props) => props.theme.brand}; } } .header-title { diff --git a/packages/bruno-app/src/components/AiChatSidebar/index.js b/packages/bruno-app/src/components/AiChatSidebar/index.js index bfeed187a..5394892bd 100644 --- a/packages/bruno-app/src/components/AiChatSidebar/index.js +++ b/packages/bruno-app/src/components/AiChatSidebar/index.js @@ -33,9 +33,17 @@ import { updateRequestTests, updateRequestScript, updateResponseScript, - updateRequestDocs + updateRequestDocs, + updateFolderRequestScript, + updateFolderResponseScript, + updateFolderTests, + updateFolderDocs, + updateCollectionRequestScript, + updateCollectionResponseScript, + updateCollectionTests, + updateCollectionDocs } from 'providers/ReduxStore/slices/collections'; -import { findItemInCollection } from 'utils/collections'; +import { findItemInCollection, isItemAFolder, isItemARequest } from 'utils/collections'; import { getAiStatus } from 'utils/ai'; import StyledWrapper from './StyledWrapper'; @@ -182,6 +190,20 @@ const AiChatSidebar = ({ collection }) => { const focusedTab = find(tabs, (t) => t.uid === activeTabUid); const activeItem = focusedTab && collection ? findItemInCollection(collection, activeTabUid) : null; + const aiContext = useMemo(() => { + if (!focusedTab || !collection) return null; + if (activeItem && (isItemARequest(activeItem) || activeItem.type === 'app')) { + return { kind: 'request', item: activeItem, pathname: activeItem.pathname || '', name: activeItem.name || 'Untitled' }; + } + if (activeItem && isItemAFolder(activeItem)) { + return { kind: 'folder', folder: activeItem, pathname: activeItem.pathname || '', name: activeItem.name || 'Untitled' }; + } + // Anything else (collection-settings, runner, variables, openapi-sync, + // .js files in File Mode …) falls back to the collection root so the AI + // button always opens a useful chat instead of a no-op. + return { kind: 'collection', pathname: collection.pathname || '', name: collection.name || 'Untitled Collection' }; + }, [focusedTab, collection, activeItem]); + const currentChat = allChats[activeTabUid] || { messages: [], isLoading: false, error: null, historyList: [] }; const { messages, isLoading, error, historyList, conversationId } = currentChat; @@ -209,37 +231,69 @@ const AiChatSidebar = ({ collection }) => { try { localStorage.setItem(SELECTED_MODEL_LS_KEY, AUTO_MODEL_ID); } catch {} }, [availableModels, selectedModel]); - const requestName = activeItem?.name || 'Untitled'; - const requestMethod = activeItem?.draft - ? get(activeItem, 'draft.request.method', 'GET') - : get(activeItem, 'request.method', 'GET'); + const requestName = aiContext?.name || activeItem?.name || 'Untitled'; + const requestMethod = useMemo(() => { + if (aiContext?.kind === 'folder') return 'FOLDER'; + if (aiContext?.kind === 'collection') return 'ROOT'; + if (!activeItem) return 'GET'; + if (activeItem.type === 'grpc-request') return 'GRPC'; + if (activeItem.type === 'ws-request') return 'WS'; + if (activeItem.type === 'graphql-request') return 'GQL'; + if (activeItem.type === 'app') return 'APP'; + const appOn = activeItem.draft + ? get(activeItem, 'draft.app.enabled', false) + : get(activeItem, 'app.enabled', false); + if (appOn) return 'APP'; + return activeItem.draft + ? get(activeItem, 'draft.request.method', 'GET') + : get(activeItem, 'request.method', 'GET'); + }, [aiContext?.kind, activeItem]); + // contentType drives the AI prompt, the diff target, and which entry of + // allContent the backend treats as "active". For requests it follows the + // request-pane tab. For folders / collections we read the settings sub-tab + // (and the inner pre/post script split for the Script sub-tab). const requestPaneTab = focusedTab?.requestPaneTab; + const scriptPaneTab = focusedTab?.scriptPaneTab; const contentType = useMemo(() => { + if (aiContext?.kind === 'folder') { + const sub = collection?.folderLevelSettingsSelectedTab?.[aiContext.folder.uid]; + if (sub === 'test') return 'tests'; + if (sub === 'docs') return 'docs'; + if (sub === 'script') return scriptPaneTab === 'post-response' ? 'post-response' : 'pre-request'; + return 'pre-request'; + } + if (aiContext?.kind === 'collection') { + const sub = collection?.settingsSelectedTab; + if (sub === 'tests') return 'tests'; + if (sub === 'overview') return 'docs'; + if (sub === 'script') return scriptPaneTab === 'post-response' ? 'post-response' : 'pre-request'; + return 'pre-request'; + } switch (requestPaneTab) { case 'tests': return 'tests'; - case 'script': return 'pre-request'; + case 'script': return scriptPaneTab === 'post-response' ? 'post-response' : 'pre-request'; case 'docs': return 'docs'; default: return 'app'; } - }, [requestPaneTab]); + }, [aiContext, collection?.folderLevelSettingsSelectedTab, collection?.settingsSelectedTab, requestPaneTab, scriptPaneTab]); - // Bind the chat to the active item's pathname so the history list reflects - // this specific request and persistence keys stay stable across sessions. - // Restoring the most recent conversation happens once per tab — if the - // user explicitly starts a new chat, we don't auto-replace it. + // Bind the chat to the active context's pathname so the history list + // reflects this specific request/folder/collection and persistence keys stay + // stable across sessions. Restoring the most recent conversation happens + // once per tab — if the user explicitly starts a new chat, we don't + // auto-replace it. const restoredOnceRef = useRef({}); useEffect(() => { - if (!isOpen || !activeItem || !collection) return; - const pathname = activeItem.pathname || ''; + if (!isOpen || !aiContext || !collection) return; dispatch(setChatBinding({ tabUid: activeTabUid, - pathname, + pathname: aiContext.pathname, collectionUid: collection.uid, contentType })); dispatch(refreshChatHistory(activeTabUid)); - }, [isOpen, activeItem?.pathname, collection?.uid, activeTabUid, contentType, dispatch]); + }, [isOpen, aiContext?.pathname, collection?.uid, activeTabUid, contentType, dispatch]); // First-open restore: if this tab has no conversation yet and there's a // saved one for the same file, load the most recent. @@ -254,42 +308,73 @@ const AiChatSidebar = ({ collection }) => { }, [isOpen, activeTabUid, currentChat.conversationId, currentChat.messages?.length, historyList, dispatch]); const allContent = useMemo(() => { - if (!activeItem) return {}; - const draft = activeItem.draft; - const draftAppCode = get(activeItem, 'draft.app.code'); + if (!aiContext) return {}; + if (aiContext.kind === 'request') { + const item = aiContext.item; + const draft = item.draft; + const draftAppCode = get(item, 'draft.app.code'); + return { + 'app': draftAppCode != null ? draftAppCode : get(item, 'app.code', ''), + 'tests': draft ? get(draft, 'request.tests', '') : get(item, 'request.tests', ''), + 'pre-request': draft ? get(draft, 'request.script.req', '') : get(item, 'request.script.req', ''), + 'post-response': draft ? get(draft, 'request.script.res', '') : get(item, 'request.script.res', ''), + 'docs': draft ? get(draft, 'request.docs', '') : get(item, 'request.docs', '') + }; + } + if (aiContext.kind === 'folder') { + const folder = aiContext.folder; + const root = folder.draft || folder.root || {}; + return { + 'tests': get(root, 'request.tests', ''), + 'pre-request': get(root, 'request.script.req', ''), + 'post-response': get(root, 'request.script.res', ''), + 'docs': get(root, 'docs', '') + }; + } + // collection + const root = collection?.draft?.root || collection?.root || {}; return { - 'app': draftAppCode != null ? draftAppCode : get(activeItem, 'app.code', ''), - 'tests': draft ? get(draft, 'request.tests', '') : get(activeItem, 'request.tests', ''), - 'pre-request': draft ? get(draft, 'request.script.req', '') : get(activeItem, 'request.script.req', ''), - 'post-response': draft ? get(draft, 'request.script.res', '') : get(activeItem, 'request.script.res', ''), - 'docs': draft ? get(draft, 'request.docs', '') : get(activeItem, 'request.docs', '') + 'tests': get(root, 'request.tests', ''), + 'pre-request': get(root, 'request.script.req', ''), + 'post-response': get(root, 'request.script.res', ''), + 'docs': get(root, 'docs', '') }; - }, [activeItem]); + }, [aiContext, collection?.draft?.root, collection?.root]); const currentContent = allContent[contentType] || ''; + // requestContext (URL/method/headers/response shape) only makes sense for + // HTTP-style request items. Folder, collection, and App chats skip it — + // App items live under kind: 'request' but have no URL/method to surface. const requestContext = useMemo(() => { - if (!activeItem) return null; - const draft = activeItem.draft; + if (aiContext?.kind !== 'request' || !isItemARequest(aiContext.item)) return null; + const item = aiContext.item; + const draft = item.draft; return { - url: draft ? get(activeItem, 'draft.request.url', '') : get(activeItem, 'request.url', ''), - method: draft ? get(activeItem, 'draft.request.method', '') : get(activeItem, 'request.method', ''), - headers: draft ? get(activeItem, 'draft.request.headers', []) : get(activeItem, 'request.headers', []), - params: draft ? get(activeItem, 'draft.request.params', []) : get(activeItem, 'request.params', []), - body: draft ? get(activeItem, 'draft.request.body', null) : get(activeItem, 'request.body', null), - docs: draft ? get(activeItem, 'draft.request.docs', null) : get(activeItem, 'request.docs', null), - responseStatus: get(activeItem, 'response.status', null), - responseData: get(activeItem, 'response.data', null) + url: draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', ''), + method: draft ? get(item, 'draft.request.method', '') : get(item, 'request.method', ''), + headers: draft ? get(item, 'draft.request.headers', []) : get(item, 'request.headers', []), + params: draft ? get(item, 'draft.request.params', []) : get(item, 'request.params', []), + body: draft ? get(item, 'draft.request.body', null) : get(item, 'request.body', null), + docs: draft ? get(item, 'draft.request.docs', null) : get(item, 'request.docs', null), + responseStatus: get(item, 'response.status', null), + responseData: get(item, 'response.data', null) }; - }, [activeItem]); + }, [aiContext]); const chatsWithMessages = useMemo(() => { if (!collection) return []; return Object.entries(allChats) .filter(([, chat]) => chat.messages?.length > 0) .map(([tabUid, chat]) => { + if (tabUid === collection.uid) { + return { id: tabUid, name: collection.name || 'Untitled Collection', method: 'ROOT', messageCount: chat.messages.length }; + } const item = findItemInCollection(collection, tabUid); if (!item) return null; + if (isItemAFolder(item)) { + return { id: tabUid, name: item.name || 'Untitled', method: 'FOLDER', messageCount: chat.messages.length }; + } const method = item.draft ? get(item, 'draft.request.method', 'GET') : get(item, 'request.method', 'GET'); @@ -375,7 +460,7 @@ const AiChatSidebar = ({ collection }) => { }; const handleApplyCode = (code, originalCode, messageIndex, msgContentType, writeIndex) => { - if (!activeItem || code == null) return; + if (!aiContext || code == null) return; const targetType = msgContentType || contentType; // Bail if the live buffer has drifted from what the AI based the diff on. @@ -386,24 +471,35 @@ const AiChatSidebar = ({ collection }) => { return; } - const payload = { itemUid: activeItem.uid, collectionUid: collection.uid }; - - switch (targetType) { - case 'tests': - dispatch(updateRequestTests({ ...payload, tests: code })); - break; - case 'pre-request': - dispatch(updateRequestScript({ ...payload, script: code })); - break; - case 'post-response': - dispatch(updateResponseScript({ ...payload, script: code })); - break; - case 'docs': - dispatch(updateRequestDocs({ ...payload, docs: code })); - break; - default: - dispatch(updateAppCode({ ...payload, code })); - break; + if (aiContext.kind === 'request') { + const payload = { itemUid: aiContext.item.uid, collectionUid: collection.uid }; + switch (targetType) { + case 'tests': dispatch(updateRequestTests({ ...payload, tests: code })); break; + case 'pre-request': dispatch(updateRequestScript({ ...payload, script: code })); break; + case 'post-response': dispatch(updateResponseScript({ ...payload, script: code })); break; + case 'docs': dispatch(updateRequestDocs({ ...payload, docs: code })); break; + default: dispatch(updateAppCode({ ...payload, code })); break; + } + } else if (aiContext.kind === 'folder') { + const payload = { folderUid: aiContext.folder.uid, collectionUid: collection.uid }; + switch (targetType) { + case 'tests': dispatch(updateFolderTests({ ...payload, tests: code })); break; + case 'pre-request': dispatch(updateFolderRequestScript({ ...payload, script: code })); break; + case 'post-response': dispatch(updateFolderResponseScript({ ...payload, script: code })); break; + case 'docs': dispatch(updateFolderDocs({ ...payload, docs: code })); break; + // Folders / collections have no 'app' equivalent. Bail rather than + // marking the diff accepted when nothing was dispatched. + default: return; + } + } else { + const payload = { collectionUid: collection.uid }; + switch (targetType) { + case 'tests': dispatch(updateCollectionTests({ ...payload, tests: code })); break; + case 'pre-request': dispatch(updateCollectionRequestScript({ ...payload, script: code })); break; + case 'post-response': dispatch(updateCollectionResponseScript({ ...payload, script: code })); break; + case 'docs': dispatch(updateCollectionDocs({ ...payload, docs: code })); break; + default: return; + } } dispatch(setMessageCodeStatus({ @@ -630,7 +726,7 @@ const AiChatSidebar = ({ collection }) => { }; if (!isOpen) return null; - if (!activeItem) return null; + if (!aiContext) return null; const placeholders = PLACEHOLDER_BY_TYPE[contentType] || PLACEHOLDER_BY_TYPE.app; const placeholder = currentContent ? placeholders.filled : placeholders.empty; diff --git a/packages/bruno-app/src/components/AppView/EmptyAppState.js b/packages/bruno-app/src/components/AppView/EmptyAppState.js index 4df795f8e..cc9d267a8 100644 --- a/packages/bruno-app/src/components/AppView/EmptyAppState.js +++ b/packages/bruno-app/src/components/AppView/EmptyAppState.js @@ -1,6 +1,6 @@ import React from 'react'; import styled from 'styled-components'; -import { IconApps } from '@tabler/icons'; +import { IconAppWindow } from '@tabler/icons'; const Wrapper = styled.div` flex: 1 1 0; @@ -38,7 +38,7 @@ const Wrapper = styled.div` const EmptyAppState = ({ title = 'No app yet', hint }) => (
- +
{title}
{hint ?
{hint}
: null}
diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js index e2947d265..6a204fd4d 100644 --- a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js @@ -217,39 +217,35 @@ const StyledWrapper = styled.div` } .mode-toggle { - display: flex; - align-items: center; - height: 24px; - border: 1px solid ${(props) => props.theme.input.border}; + display: inline-flex; + align-items: stretch; + padding: 2px; + gap: 2px; + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; border-radius: ${(props) => props.theme.border.radius.base}; - overflow: hidden; .mode-btn { display: flex; align-items: center; justify-content: center; - width: 28px; - height: 100%; + width: 26px; padding: 0; - border: none; - border-right: 1px solid ${(props) => props.theme.input.border}; + border: 1px solid transparent; background: transparent; color: ${(props) => props.theme.colors.text.muted}; + border-radius: ${(props) => props.theme.border.radius.sm}; cursor: pointer; - transition: background 0.15s ease, color 0.15s ease; - - &:last-child { - border-right: none; - } + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; &:hover:not(.active) { - background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; color: ${(props) => props.theme.text}; } &.active { - background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; - color: ${(props) => props.theme.primary.text}; + background: ${(props) => props.theme.bg}; + color: ${(props) => props.theme.text}; + border-color: ${(props) => props.theme.input.border}; + box-shadow: ${(props) => props.theme.shadow.sm}; } } } diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js index 7bdaf7988..6bb022906 100644 --- a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js @@ -16,7 +16,7 @@ import { IconFileCode, IconFileOff, IconCode, - IconApps, + IconAppWindow, IconTransform, IconStars } from '@tabler/icons'; @@ -651,61 +651,65 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
- {isAiEnabled && ( - - dispatch(toggleAiSidebar())} - aria-label="AI Assistant" - size="sm" - data-testid="ai-assistant" - className={isAiSidebarOpen ? 'active' : ''} - > - - - - )} {!isScratchCollection && ( <> {isHttpRequestActive && ( - -
+
+ + + + + -
+ +
+ )} + {isAiEnabled && ( + + dispatch(toggleAiSidebar())} + aria-label="AI Assistant" + size="sm" + data-testid="ai-assistant" + className={isAiSidebarOpen ? 'active' : ''} + > + + )} {collection.format === 'bru' && !migratePillDismissed && ( diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index f3619f49a..8a4f515f1 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -16,7 +16,7 @@ import ConfirmCloseEnvironment from 'components/Environments/ConfirmCloseEnviron import RequestTabNotFound from './RequestTabNotFound'; import RequestTabLoading from './RequestTabLoading'; import SpecialTab from './SpecialTab'; -import { IconApps } from '@tabler/icons'; +import { IconAppWindow } from '@tabler/icons'; import StyledWrapper from './StyledWrapper'; import MenuDropdown from 'ui/MenuDropdown'; import CloneCollectionItem from 'components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index'; @@ -583,7 +583,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi > {item.type === 'app' ? ( - + ) : ( diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js index 251b1fb82..6d586e4dc 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js @@ -1,5 +1,5 @@ import RequestMethod from '../RequestMethod'; -import { IconLoader2, IconAlertTriangle, IconAlertCircle, IconApps } from '@tabler/icons'; +import { IconLoader2, IconAlertTriangle, IconAlertCircle, IconAppWindow } from '@tabler/icons'; import StyledWrapper from './StyledWrapper'; const CollectionItemIcon = ({ item }) => { @@ -16,7 +16,7 @@ const CollectionItemIcon = ({ item }) => { } if (item?.type === 'app') { - return ; + return ; } return ; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js index 0ca06312b..53f6bc764 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js @@ -19,7 +19,7 @@ import { IconSettings, IconInfoCircle, IconTerminal2, - IconApps + IconAppWindow } from '@tabler/icons'; import { useSelector, useDispatch } from 'react-redux'; import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; @@ -357,7 +357,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) }, { id: 'new-app', - leftSection: IconApps, + leftSection: IconAppWindow, label: 'New App', onClick: () => setNewAppModalOpen(true) }, diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js index 8674a8c02..702a6dda9 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -22,7 +22,7 @@ import { IconFolder, IconBook, IconFileArrowRight, - IconApps + IconAppWindow } from '@tabler/icons'; import OpenAPISyncIcon from 'components/Icons/OpenAPISync'; import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections'; @@ -368,7 +368,7 @@ const Collection = ({ collection, searchText }) => { }, { id: 'new-app', - leftSection: IconApps, + leftSection: IconAppWindow, label: 'New App', onClick: () => { ensureCollectionIsMounted();