feat(ai): mode toggle UI and add AI support for folder & collection (#8385)

This commit is contained in:
naman-bruno
2026-06-26 18:41:21 +05:30
committed by GitHub
parent 33e8f5ca4a
commit 30b4512983
9 changed files with 204 additions and 104 deletions

View File

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

View File

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

View File

@@ -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 }) => (
<Wrapper data-testid="empty-app-state">
<div className="empty-app-inner">
<IconApps size={32} strokeWidth={1.25} />
<IconAppWindow size={32} strokeWidth={1.25} />
<div className="empty-app-title">{title}</div>
{hint ? <div className="empty-app-hint">{hint}</div> : null}
</div>

View File

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

View File

@@ -16,7 +16,7 @@ import {
IconFileCode,
IconFileOff,
IconCode,
IconApps,
IconAppWindow,
IconTransform,
IconStars
} from '@tabler/icons';
@@ -651,61 +651,65 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
</div>
<div className="flex flex-grow gap-1.5 items-center justify-end">
{isAiEnabled && (
<ToolHint text="AI Assistant" toolhintId="AiAssistantToolhintId" place="bottom">
<ActionIcon
onClick={() => dispatch(toggleAiSidebar())}
aria-label="AI Assistant"
size="sm"
data-testid="ai-assistant"
className={isAiSidebarOpen ? 'active' : ''}
>
<IconStars size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
)}
{!isScratchCollection && (
<>
{isHttpRequestActive && (
<ToolHint text="Switch view mode" toolhintId="ViewModeToggleToolhintId" place="bottom">
<div className="mode-toggle" data-testid="view-mode-toggle">
<div className="mode-toggle" data-testid="view-mode-toggle">
<ToolHint text="Request" toolhintId="ViewModeRequestToolhintId" place="bottom">
<button
type="button"
data-testid="view-mode-request"
aria-label="Request view"
className={`mode-btn ${!appEnabled && !collection.fileMode ? 'active' : ''}`}
onClick={() => {
if (collection.fileMode) handleFileModeClick();
if (appEnabled) handleToggleAppMode(false);
}}
title="Request"
>
<IconCode size={16} strokeWidth={1.5} />
</button>
</ToolHint>
<ToolHint text="App" toolhintId="ViewModeAppToolhintId" place="bottom">
<button
type="button"
data-testid="view-mode-app"
aria-label="App view"
className={`mode-btn ${appEnabled && !collection.fileMode ? 'active' : ''}`}
onClick={() => {
if (collection.fileMode) handleFileModeClick();
if (!appEnabled) handleToggleAppMode(true);
}}
title="App"
>
<IconApps size={16} strokeWidth={1.5} />
<IconAppWindow size={16} strokeWidth={1.5} />
</button>
</ToolHint>
<ToolHint text="File" toolhintId="ViewModeFileToolhintId" place="bottom">
<button
type="button"
data-testid="view-mode-file"
aria-label="File view"
className={`mode-btn ${collection.fileMode ? 'active' : ''}`}
onClick={() => {
if (appEnabled) handleToggleAppMode(false);
if (!collection.fileMode) handleFileModeClick();
}}
title="File"
>
<IconFileCode size={16} strokeWidth={1.5} />
</button>
</div>
</ToolHint>
</div>
)}
{isAiEnabled && (
<ToolHint text="AI Assistant" toolhintId="AiAssistantToolhintId" place="bottom">
<ActionIcon
onClick={() => dispatch(toggleAiSidebar())}
aria-label="AI Assistant"
size="sm"
data-testid="ai-assistant"
className={isAiSidebarOpen ? 'active' : ''}
>
<IconStars size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
)}
{collection.format === 'bru' && !migratePillDismissed && (

View File

@@ -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' ? (
<span className="tab-method flex items-center" aria-label="App">
<IconApps size={14} strokeWidth={1.5} />
<IconAppWindow size={14} strokeWidth={1.5} />
</span>
) : (
<span className="tab-method uppercase" style={{ color: getMethodColor(method) }}>

View File

@@ -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 <IconApps className="w-fit mr-2" size={16} strokeWidth={1.5} />;
return <IconAppWindow className="w-fit mr-2" size={16} strokeWidth={1.5} />;
}
return <RequestMethod item={item} />;

View File

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

View File

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