diff --git a/packages/bruno-app/package.json b/packages/bruno-app/package.json
index 1c2b9fbe2..5c8186183 100644
--- a/packages/bruno-app/package.json
+++ b/packages/bruno-app/package.json
@@ -27,6 +27,7 @@
"codemirror": "5.65.2",
"codemirror-graphql": "2.1.1",
"cookie": "0.7.1",
+ "diff": "^5.2.0",
"diff2html": "^3.4.47",
"dompurify": "^3.2.4",
"escape-html": "^1.0.3",
diff --git a/packages/bruno-app/src/components/AiChatSidebar/AssistantCodeBlock.js b/packages/bruno-app/src/components/AiChatSidebar/AssistantCodeBlock.js
new file mode 100644
index 000000000..c2c7cb0f8
--- /dev/null
+++ b/packages/bruno-app/src/components/AiChatSidebar/AssistantCodeBlock.js
@@ -0,0 +1,46 @@
+import { useState, useRef, useEffect } from 'react';
+import { IconCopy, IconCheck } from '@tabler/icons';
+
+const AssistantCodeBlock = ({ content, language, isOpen, isStreaming, isLast }) => {
+ const [isCopied, setIsCopied] = useState(false);
+ const preRef = useRef(null);
+
+ useEffect(() => {
+ if (isStreaming && isOpen && preRef.current) {
+ preRef.current.scrollTop = preRef.current.scrollHeight;
+ }
+ }, [content, isStreaming, isOpen]);
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(content);
+ setIsCopied(true);
+ setTimeout(() => setIsCopied(false), 1500);
+ } catch (err) {
+ console.error('Failed to copy:', err);
+ }
+ };
+
+ return (
+
+
+
+ {language || 'code'}
+ {isOpen && }
+
+
+
+
+
+ {content}
+ {isStreaming && isLast && |}
+
+
+
+ );
+};
+
+export default AssistantCodeBlock;
diff --git a/packages/bruno-app/src/components/AiChatSidebar/DiffView/StyledWrapper.js b/packages/bruno-app/src/components/AiChatSidebar/DiffView/StyledWrapper.js
new file mode 100644
index 000000000..53520d879
--- /dev/null
+++ b/packages/bruno-app/src/components/AiChatSidebar/DiffView/StyledWrapper.js
@@ -0,0 +1,298 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ margin-top: 8px;
+ border-radius: ${(props) => props.theme.border.radius.base};
+ overflow: hidden;
+ border: 1px solid ${(props) => props.theme.border.border1};
+ background: ${(props) => props.theme.codemirror.bg};
+
+ &.accepted {
+ border-color: ${(props) => props.theme.colors.text.green};
+ }
+
+ &.rejected {
+ opacity: 0.5;
+ }
+
+ .diff-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 10px;
+ background: ${(props) => props.theme.background.mantle};
+ border-bottom: 1px solid ${(props) => props.theme.border.border1};
+ gap: 8px;
+ flex-wrap: nowrap;
+ }
+
+ .diff-title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ font-weight: 500;
+ color: ${(props) => props.theme.colors.text.muted};
+ flex-shrink: 0;
+
+ .diff-icon {
+ color: ${(props) => props.theme.brand};
+ display: flex;
+ align-items: center;
+ }
+ }
+
+ .diff-content-type {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ padding: 1px 6px;
+ border-radius: 3px;
+ background: ${(props) => props.theme.background.surface0};
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ .diff-stats {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+ font-weight: 500;
+
+ .stat {
+ padding: 1px 5px;
+ border-radius: 4px;
+ }
+ .additions {
+ background: ${(props) => props.theme.status.success.background};
+ color: ${(props) => props.theme.colors.text.green};
+ }
+ .deletions {
+ background: ${(props) => props.theme.status.danger.background};
+ color: ${(props) => props.theme.colors.text.danger};
+ }
+ }
+
+ .diff-actions {
+ display: flex;
+ gap: 6px;
+ flex-shrink: 0;
+ margin-left: auto;
+ }
+
+ .diff-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ padding: 4px 8px;
+ font-size: 11px;
+ font-weight: 500;
+ border: 1px solid transparent;
+ border-radius: ${(props) => props.theme.border.radius.base};
+ cursor: pointer;
+ white-space: nowrap;
+
+ &.accept {
+ background: ${(props) => props.theme.brand};
+ color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')};
+
+ &:hover:not(:disabled) {
+ opacity: 0.9;
+ }
+
+ &:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+ }
+
+ &.reject {
+ background: transparent;
+ color: ${(props) => props.theme.colors.text.muted};
+ border-color: ${(props) => props.theme.border.border1};
+
+ &:hover {
+ background: ${(props) => props.theme.status.danger.background};
+ color: ${(props) => props.theme.colors.text.danger};
+ border-color: ${(props) => props.theme.status.danger.background};
+ }
+ }
+ }
+
+ .status-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 8px;
+ font-size: 11px;
+ border-radius: ${(props) => props.theme.border.radius.base};
+ font-weight: 500;
+
+ &.accepted {
+ background: ${(props) => props.theme.status.success.background};
+ color: ${(props) => props.theme.colors.text.green};
+ }
+
+ &.rejected {
+ background: ${(props) => props.theme.status.danger.background};
+ color: ${(props) => props.theme.colors.text.danger};
+ }
+ }
+
+ .diff-warning {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ font-size: 11px;
+ border-bottom: 1px solid ${(props) => props.theme.border.border1};
+
+ &.warn {
+ background: ${(props) => props.theme.status.warning.background};
+ color: ${(props) => props.theme.status.warning.text};
+ }
+
+ &.error {
+ background: ${(props) => props.theme.status.danger.background};
+ color: ${(props) => props.theme.colors.text.danger};
+ }
+ }
+
+ .diff-toggle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ padding: 4px 8px;
+ font-size: 11px;
+ color: ${(props) => props.theme.colors.text.muted};
+ background: transparent;
+ border: none;
+ border-top: 1px solid ${(props) => props.theme.border.border1};
+ cursor: pointer;
+ width: 100%;
+
+ &:hover {
+ background: ${(props) => props.theme.background.surface0};
+ color: ${(props) => props.theme.text};
+ }
+ }
+
+ .diff-content {
+ max-height: 300px;
+ overflow: auto;
+ font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
+ font-size: 11px;
+ line-height: 1.5;
+
+ &::-webkit-scrollbar {
+ width: 4px;
+ height: 4px;
+ }
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: ${(props) => props.theme.border.border1};
+ border-radius: 2px;
+ }
+ }
+
+ .diff-line {
+ padding: 0 8px 0 4px;
+ white-space: pre;
+ display: flex;
+ min-height: 18px;
+ line-height: 18px;
+
+ .line-number {
+ width: 24px;
+ text-align: right;
+ padding-right: 8px;
+ color: ${(props) => props.theme.colors.text.muted};
+ user-select: none;
+ flex-shrink: 0;
+ opacity: 0.5;
+ }
+
+ .line-prefix {
+ width: 12px;
+ flex-shrink: 0;
+ }
+
+ .line-content {
+ flex: 1;
+ overflow-x: auto;
+ }
+
+ &.added {
+ background: ${(props) => props.theme.status.success.background};
+ .line-content { color: ${(props) => props.theme.colors.text.green}; }
+ .line-prefix { color: ${(props) => props.theme.colors.text.green}; font-weight: 600; }
+ }
+
+ &.removed {
+ background: ${(props) => props.theme.status.danger.background};
+ .line-content { color: ${(props) => props.theme.colors.text.danger}; }
+ .line-prefix { color: ${(props) => props.theme.colors.text.danger}; font-weight: 600; }
+ }
+
+ &.unchanged {
+ .line-content { color: ${(props) => props.theme.colors.text.muted}; }
+ .line-prefix { opacity: 0; }
+ }
+ }
+
+ .expand-marker {
+ display: flex;
+ align-items: center;
+ padding: 0 8px 0 4px;
+ min-height: 22px;
+ background: ${(props) => props.theme.background.mantle};
+
+ .expand-gutter {
+ width: 24px;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ padding-right: 4px;
+ }
+
+ .expand-buttons {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ }
+
+ .expand-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 11px;
+ padding: 0;
+ background: transparent;
+ border: none;
+ color: ${(props) => props.theme.colors.text.muted};
+ cursor: pointer;
+ opacity: 0.6;
+
+ &:hover {
+ color: ${(props) => props.theme.text};
+ opacity: 1;
+ }
+ }
+
+ .expand-line {
+ flex: 1;
+ height: 1px;
+ background: ${(props) => props.theme.border.border1};
+ margin-left: 8px;
+ }
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/AiChatSidebar/DiffView/index.js b/packages/bruno-app/src/components/AiChatSidebar/DiffView/index.js
new file mode 100644
index 000000000..e10100064
--- /dev/null
+++ b/packages/bruno-app/src/components/AiChatSidebar/DiffView/index.js
@@ -0,0 +1,210 @@
+import React, { useMemo, useState } from 'react';
+import { diffLines } from 'diff';
+import { IconCheck, IconX, IconCode, IconChevronDown, IconChevronUp } from '@tabler/icons';
+import StyledWrapper from './StyledWrapper';
+
+const CONTEXT_LINES = 2;
+const EXPAND_CHUNK_SIZE = 20;
+
+const DiffView = ({ originalCode, newCode, onAccept, onReject, status, contentTypeLabel, warning, disableAccept }) => {
+ const [isExpanded, setIsExpanded] = useState(true);
+ const [expandedFromTop, setExpandedFromTop] = useState({});
+ const [expandedFromBottom, setExpandedFromBottom] = useState({});
+
+ const diffResult = useMemo(() => {
+ const changes = diffLines(originalCode || '', newCode || '');
+ let additions = 0;
+ let deletions = 0;
+ let lineNumber = 1;
+
+ const lines = changes.flatMap((part) => {
+ const partLines = part.value.split('\n');
+ if (partLines[partLines.length - 1] === '') partLines.pop();
+
+ return partLines.map((line) => {
+ const entry = { content: line, lineNumber: null };
+ if (part.added) {
+ additions += 1;
+ entry.type = 'added';
+ entry.lineNumber = lineNumber++;
+ } else if (part.removed) {
+ deletions += 1;
+ entry.type = 'removed';
+ } else {
+ entry.type = 'unchanged';
+ entry.lineNumber = lineNumber++;
+ }
+ return entry;
+ });
+ });
+
+ return { lines, additions, deletions };
+ }, [originalCode, newCode]);
+
+ const hunks = useMemo(() => {
+ const { lines } = diffResult;
+ if (lines.length === 0) return [];
+
+ const changedIndices = new Set();
+ lines.forEach((line, idx) => {
+ if (line.type === 'added' || line.type === 'removed') changedIndices.add(idx);
+ });
+
+ const visibleIndices = new Set();
+ changedIndices.forEach((idx) => {
+ for (let i = Math.max(0, idx - CONTEXT_LINES); i <= Math.min(lines.length - 1, idx + CONTEXT_LINES); i++) {
+ visibleIndices.add(i);
+ }
+ });
+
+ const result = [];
+ let i = 0;
+ while (i < lines.length) {
+ if (visibleIndices.has(i)) {
+ result.push({ type: 'line', data: lines[i], index: i });
+ i += 1;
+ } else {
+ const start = i;
+ while (i < lines.length && !visibleIndices.has(i)) i += 1;
+ result.push({
+ type: 'collapsed',
+ startIndex: start,
+ count: i - start,
+ lines: lines.slice(start, i)
+ });
+ }
+ }
+ return result;
+ }, [diffResult]);
+
+ const expandUp = (startIndex, totalLines) => {
+ setExpandedFromTop((prev) => {
+ const current = prev[startIndex] || 0;
+ const bottomExpanded = expandedFromBottom[startIndex] || 0;
+ const remaining = totalLines - current - bottomExpanded;
+ return { ...prev, [startIndex]: Math.min(current + EXPAND_CHUNK_SIZE, current + remaining) };
+ });
+ };
+
+ const expandDown = (startIndex, totalLines) => {
+ setExpandedFromBottom((prev) => {
+ const current = prev[startIndex] || 0;
+ const topExpanded = expandedFromTop[startIndex] || 0;
+ const remaining = totalLines - topExpanded - current;
+ return { ...prev, [startIndex]: Math.min(current + EXPAND_CHUNK_SIZE, current + remaining) };
+ });
+ };
+
+ if (diffResult.additions === 0 && diffResult.deletions === 0) return null;
+
+ const renderActions = () => {
+ if (status === 'accepted') {
+ return (
+
+ Applied
+
+ );
+ }
+ if (status === 'rejected') {
+ return (
+
+ Dismissed
+
+ );
+ }
+ return (
+
+
+
+
+ );
+ };
+
+ const renderLine = (line, key) => (
+
+ {line.type !== 'removed' ? line.lineNumber : ''}
+ {line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '}
+ {line.content || ' '}
+
+ );
+
+ const renderHunks = () =>
+ hunks.map((hunk, idx) => {
+ if (hunk.type === 'line') return renderLine(hunk.data, `line-${hunk.index}`);
+
+ const topCount = expandedFromTop[hunk.startIndex] || 0;
+ const bottomCount = expandedFromBottom[hunk.startIndex] || 0;
+ const remainingCount = hunk.count - topCount - bottomCount;
+
+ const topLines = hunk.lines.slice(0, topCount);
+ const bottomLines = hunk.lines.slice(hunk.count - bottomCount);
+ const isAtTop = idx === 0;
+ const isAtBottom = idx === hunks.length - 1;
+
+ return (
+
+ {topLines.map((line, lineIdx) => renderLine(line, `top-${hunk.startIndex}-${lineIdx}`))}
+
+ {remainingCount > 0 && (
+
+
+
+ {!isAtTop && (
+
+ )}
+ {!isAtBottom && (
+
+ )}
+
+
+
+
+ )}
+
+ {bottomLines.map((line, lineIdx) => renderLine(line, `bottom-${hunk.startIndex}-${lineIdx}`))}
+
+ );
+ });
+
+ return (
+
+
+
+
+ {contentTypeLabel &&
{contentTypeLabel}}
+
+ +{diffResult.additions}
+ -{diffResult.deletions}
+
+
+ {renderActions()}
+
+
+ {warning && (
+
+ {warning}
+
+ )}
+
+ {isExpanded && {renderHunks()}
}
+
+
+
+ );
+};
+
+export default DiffView;
diff --git a/packages/bruno-app/src/components/AiChatSidebar/StyledWrapper.js b/packages/bruno-app/src/components/AiChatSidebar/StyledWrapper.js
new file mode 100644
index 000000000..58a87bc7a
--- /dev/null
+++ b/packages/bruno-app/src/components/AiChatSidebar/StyledWrapper.js
@@ -0,0 +1,827 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ flex-shrink: 0;
+ height: 100%;
+
+ .ai-sidebar {
+ width: 420px;
+ height: 100%;
+ background: ${(props) => props.theme.bg};
+ border-left: 1px solid ${(props) => props.theme.border.border1};
+ display: flex;
+ flex-direction: column;
+ }
+
+ .ai-sidebar-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ border-bottom: 1px solid ${(props) => props.theme.border.border1};
+
+ .header-left {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ }
+
+ .header-icon {
+ color: ${(props) => props.theme.brand};
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ }
+
+ .header-method {
+ font-size: 9px;
+ font-weight: 600;
+ text-transform: uppercase;
+ padding: 2px 6px;
+ border-radius: 4px;
+ flex-shrink: 0;
+ background: ${(props) => props.theme.background.surface0};
+ display: flex;
+ align-items: center;
+
+ &.method-get { color: ${(props) => props.theme.request.methods.get}; }
+ &.method-post { color: ${(props) => props.theme.request.methods.post}; }
+ &.method-put { color: ${(props) => props.theme.request.methods.put}; }
+ &.method-delete { color: ${(props) => props.theme.request.methods.delete}; }
+ &.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}; }
+ }
+
+ .header-title {
+ font-size: 13px;
+ color: ${(props) => props.theme.text};
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: flex;
+ align-items: center;
+ }
+
+ .chat-switcher-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2px;
+ background: transparent;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ color: ${(props) => props.theme.colors.text.muted};
+ flex-shrink: 0;
+
+ &:hover {
+ background: ${(props) => props.theme.background.surface0};
+ color: ${(props) => props.theme.text};
+ }
+ }
+
+ .header-actions {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ }
+
+ .history-wrap {
+ position: relative;
+ }
+
+ .icon-btn {
+ position: relative;
+ padding: 6px;
+ background: transparent;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ color: ${(props) => props.theme.colors.text.muted};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &:hover {
+ background: ${(props) => props.theme.background.surface0};
+ color: ${(props) => props.theme.text};
+ }
+
+ &.is-active {
+ background: ${(props) => props.theme.background.surface0};
+ color: ${(props) => props.theme.text};
+ }
+
+ &:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+
+ &.close-btn:hover {
+ background: ${(props) => props.theme.status.danger.background};
+ color: ${(props) => props.theme.colors.text.danger};
+ }
+
+ }
+ }
+
+ .history-popover {
+ position: absolute;
+ top: calc(100% + 6px);
+ right: 0;
+ z-index: 20;
+ width: 300px;
+ max-height: 320px;
+ overflow-y: auto;
+ background: ${(props) => props.theme.bg};
+ border: 1px solid ${(props) => props.theme.border.border1};
+ border-radius: 8px;
+ box-shadow: ${(props) => props.theme.shadow.md};
+ padding: 4px;
+
+ &::-webkit-scrollbar {
+ width: 4px;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: ${(props) => props.theme.scrollbar.color};
+ border-radius: 2px;
+ }
+
+ &__empty {
+ padding: 16px;
+ text-align: center;
+ font-size: 11px;
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ &__item {
+ display: flex;
+ align-items: stretch;
+ gap: 2px;
+ border-radius: 4px;
+
+ &:hover {
+ background: ${(props) => props.theme.background.surface0};
+ }
+
+ &.is-active {
+ background: ${(props) => props.theme.background.surface0};
+ }
+ }
+
+ &__title {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+ padding: 6px 8px;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ text-align: left;
+ color: ${(props) => props.theme.text};
+ }
+
+ &__title-text {
+ display: block;
+ width: 100%;
+ font-size: 12px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ &__meta {
+ font-size: 10px;
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ &__delete {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 8px;
+ background: transparent;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ color: ${(props) => props.theme.colors.text.muted};
+
+ &:hover {
+ background: ${(props) => props.theme.status.danger.background};
+ color: ${(props) => props.theme.colors.text.danger};
+ }
+ }
+ }
+
+ .ai-sidebar-messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+
+ &::-webkit-scrollbar {
+ width: 4px;
+ }
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: ${(props) => props.theme.scrollbar.color};
+ border-radius: 2px;
+ }
+ }
+
+ .empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ padding: 24px 16px;
+ animation: fadeIn 0.3s ease;
+
+ .empty-icon {
+ width: 40px;
+ height: 40px;
+ border-radius: 10px;
+ background: ${(props) => props.theme.brand};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')};
+ margin-bottom: 12px;
+ }
+
+ h3 {
+ font-size: 14px;
+ font-weight: 600;
+ margin: 0 0 4px 0;
+ color: ${(props) => props.theme.text};
+ }
+
+ > p {
+ color: ${(props) => props.theme.colors.text.muted};
+ font-size: 12px;
+ margin: 0 0 16px 0;
+ line-height: 1.4;
+ }
+
+ .suggestions-title {
+ font-size: 11px;
+ color: ${(props) => props.theme.colors.text.muted};
+ margin: 0 0 8px 0;
+ font-weight: 500;
+ }
+
+ .suggestion-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ justify-content: center;
+ }
+
+ .suggestion-chip {
+ padding: 5px 10px;
+ background: ${(props) => props.theme.background.surface0};
+ border: 1px solid ${(props) => props.theme.border.border1};
+ border-radius: 12px;
+ font-size: 11px;
+ color: ${(props) => props.theme.text};
+ cursor: pointer;
+
+ &:hover {
+ border-color: ${(props) => props.theme.brand};
+ color: ${(props) => props.theme.brand};
+ }
+ }
+ }
+
+ .message {
+ animation: slideIn 0.25s ease;
+
+ &.user .message-content {
+ background: ${(props) => props.theme.background.mantle};
+ border: 1px solid ${(props) => props.theme.border.border1};
+ border-radius: 8px;
+ padding: 8px 12px;
+ font-size: 13px;
+ line-height: 1.4;
+ color: ${(props) => props.theme.text};
+ }
+
+ &.assistant .message-content {
+ color: ${(props) => props.theme.text};
+ }
+ }
+
+ .message-status {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ margin-bottom: 6px;
+ color: ${(props) => props.theme.colors.text.muted};
+
+ &__spinner {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ border: 2px solid ${(props) => props.theme.brand};
+ border-top-color: transparent;
+ animation: spin 0.9s linear infinite;
+ flex-shrink: 0;
+ }
+ }
+
+ .tool-activity-log {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ margin: 6px 0;
+ padding: 4px 0;
+
+ &.completed {
+ opacity: 0.7;
+ }
+ }
+
+ .tool-activity-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ color: ${(props) => props.theme.colors.text.muted};
+ line-height: 1.6;
+ padding: 1px 0;
+
+ .tool-activity-indicator {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 14px;
+ height: 14px;
+ flex-shrink: 0;
+ }
+
+ &.done .tool-activity-indicator {
+ color: ${(props) => props.theme.colors.text.green};
+ }
+
+ &.active {
+ color: ${(props) => props.theme.text};
+
+ .tool-activity-indicator {
+ color: ${(props) => props.theme.brand};
+ }
+ }
+
+ .tool-activity-spinner {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ border: 1.5px solid ${(props) => props.theme.brand};
+ border-top-color: transparent;
+ animation: spin 0.9s linear infinite;
+ display: block;
+ }
+ }
+
+ .message-cancelled {
+ margin-top: 8px;
+ font-size: 11px;
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ .assistant-code-block {
+ border: 1px solid ${(props) => props.theme.border.border1};
+ border-radius: 8px;
+ background: ${(props) => props.theme.codemirror.bg};
+ overflow: hidden;
+ margin: 8px 0;
+
+ &__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 6px 8px;
+ border-bottom: 1px solid ${(props) => props.theme.border.border1};
+ }
+
+ &__meta {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 10px;
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ &__lang {
+ text-transform: lowercase;
+ }
+
+ &__spinner {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ border: 2px solid ${(props) => props.theme.brand};
+ border-top-color: transparent;
+ animation: spin 0.9s linear infinite;
+ flex-shrink: 0;
+ }
+
+ &__btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 6px;
+ border: 1px solid ${(props) => props.theme.border.border1};
+ border-radius: 4px;
+ background: ${(props) => props.theme.background.mantle};
+ font-size: 10px;
+ font-weight: 500;
+ color: ${(props) => props.theme.text};
+ cursor: pointer;
+
+ &:hover {
+ border-color: ${(props) => props.theme.brand};
+ color: ${(props) => props.theme.brand};
+ }
+ }
+
+ &__body {
+ margin: 0;
+ padding: 10px 12px;
+ overflow: auto;
+ max-height: 240px;
+ font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
+ font-size: 11px;
+ line-height: 1.5;
+ white-space: pre;
+ }
+
+ .cursor {
+ display: inline-block;
+ animation: blink 1s infinite;
+ color: ${(props) => props.theme.brand};
+ margin-left: 1px;
+ }
+ }
+
+ .prose.markdown-body {
+ font-size: 13px;
+ line-height: 1.5;
+
+ .cursor {
+ display: inline-block;
+ animation: blink 1s infinite;
+ color: ${(props) => props.theme.brand};
+ margin-left: 1px;
+ }
+
+ p {
+ margin: 0 0 8px 0;
+ font-size: 13px;
+ &:last-child { margin-bottom: 0; }
+ }
+
+ h1, h2, h3, h4, h5, h6 {
+ margin: 10px 0 6px 0;
+ font-weight: 600;
+ line-height: 1.3;
+ &:first-child { margin-top: 0; }
+ }
+
+ h1 { font-size: 1.3em; }
+ h2 { font-size: 1.2em; }
+ h3 { font-size: 1.1em; }
+
+ ul, ol {
+ margin: 6px 0;
+ padding-left: 16px;
+ }
+
+ li {
+ margin: 4px 0;
+ font-size: 13px;
+ }
+
+ code {
+ background: ${(props) => props.theme.codemirror.bg};
+ padding: 2px 5px;
+ border-radius: 4px;
+ font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
+ font-size: 0.9em;
+ }
+
+ pre, .code-block {
+ background: ${(props) => props.theme.codemirror.bg};
+ padding: 10px 12px;
+ border-radius: 6px;
+ overflow-x: auto;
+ margin: 8px 0;
+ border: 1px solid ${(props) => props.theme.border.border1};
+
+ code {
+ background: none;
+ padding: 0;
+ font-size: 11px;
+ line-height: 1.5;
+ }
+ }
+
+ blockquote {
+ border-left: 2px solid ${(props) => props.theme.brand};
+ margin: 8px 0;
+ padding: 4px 0 4px 10px;
+ color: ${(props) => props.theme.colors.text.muted};
+ background: ${(props) => props.theme.background.surface0};
+ border-radius: 0 4px 4px 0;
+ }
+
+ a {
+ color: ${(props) => props.theme.textLink};
+ text-decoration: none;
+ &:hover { text-decoration: underline; }
+ }
+
+ strong { font-weight: 600; }
+ em { font-style: italic; }
+
+ hr {
+ border: none;
+ border-top: 1px solid ${(props) => props.theme.border.border1};
+ margin: 10px 0;
+ }
+
+ table {
+ border-collapse: collapse;
+ width: 100%;
+ margin: 8px 0;
+ font-size: 12px;
+ }
+
+ th, td {
+ border: 1px solid ${(props) => props.theme.border.border1};
+ padding: 6px 8px;
+ text-align: left;
+ }
+
+ th {
+ background: ${(props) => props.theme.codemirror.bg};
+ font-weight: 600;
+ }
+ }
+
+ .processing-indicator {
+ padding: 8px 10px;
+ background: ${(props) => props.theme.background.surface0};
+ border: 1px solid ${(props) => props.theme.border.border1};
+ border-radius: 8px;
+ animation: slideIn 0.2s ease;
+
+ .processing-content {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 6px;
+ }
+
+ .processing-icon {
+ width: 20px;
+ height: 20px;
+ border-radius: 4px;
+ background: ${(props) => props.theme.background.surface1};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: ${(props) => props.theme.brand};
+ }
+
+ .processing-label {
+ font-size: 12px;
+ font-weight: 500;
+ color: ${(props) => props.theme.text};
+ }
+
+ .processing-dots {
+ display: flex;
+ gap: 3px;
+ margin-left: 2px;
+
+ span {
+ width: 3px;
+ height: 3px;
+ background: ${(props) => props.theme.brand};
+ border-radius: 50%;
+ animation: dotBounce 1.4s infinite ease-in-out both;
+
+ &:nth-child(1) { animation-delay: -0.32s; }
+ &:nth-child(2) { animation-delay: -0.16s; }
+ }
+ }
+
+ .processing-bar {
+ height: 2px;
+ background: ${(props) => props.theme.border.border1};
+ border-radius: 1px;
+ overflow: hidden;
+
+ .processing-bar-fill {
+ height: 100%;
+ width: 30%;
+ background: ${(props) => props.theme.brand};
+ border-radius: 1px;
+ animation: progressSlide 1.5s infinite ease-in-out;
+ }
+ }
+ }
+
+ .error-message {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 8px 10px;
+ background: ${(props) => props.theme.status.danger.background};
+ border: 1px solid ${(props) => props.theme.status.danger.border};
+ border-radius: 6px;
+
+ .error-icon {
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: ${(props) => props.theme.colors.text.danger};
+ color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 700;
+ font-size: 11px;
+ flex-shrink: 0;
+ }
+
+ .error-text {
+ color: ${(props) => props.theme.colors.text.danger};
+ font-size: 12px;
+ line-height: 1.4;
+ }
+ }
+
+ .ai-sidebar-input {
+ padding: 12px;
+ border-top: 1px solid ${(props) => props.theme.border.border1};
+
+ .no-models-warning {
+ padding: 10px 12px;
+ font-size: 12px;
+ color: ${(props) => props.theme.colors.text.muted};
+ background: ${(props) => props.theme.input.bg};
+ border: 1px dashed ${(props) => props.theme.border.border1};
+ border-radius: 6px;
+ text-align: center;
+ line-height: 1.4;
+ }
+
+ .input-container {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 8px;
+ background: ${(props) => props.theme.bg};
+ border: 1px solid ${(props) => props.theme.border.border1};
+ border-radius: 8px;
+
+ &:focus-within {
+ border-color: ${(props) => props.theme.brand};
+ }
+ }
+
+ textarea {
+ width: 100%;
+ padding: 0;
+ margin: 4px 0;
+ border: none;
+ background: transparent;
+ color: ${(props) => props.theme.text};
+ font-size: 13px;
+ font-family: inherit;
+ line-height: 1.4;
+ resize: none;
+ outline: none;
+ max-height: 100px;
+
+ &::placeholder {
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ &:disabled {
+ opacity: 0.6;
+ }
+ }
+
+ .input-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .model-selector {
+ position: relative;
+ }
+
+ .model-btn {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 6px 4px 8px;
+ background: transparent;
+ border: 1px solid ${(props) => props.theme.border.border1};
+ border-radius: ${(props) => props.theme.border.radius.base};
+ font-size: 11px;
+ font-weight: 500;
+ color: ${(props) => props.theme.text};
+ cursor: pointer;
+
+ svg:first-child {
+ color: ${(props) => props.theme.brand};
+ }
+
+ &:hover {
+ border-color: ${(props) => props.theme.border.border2};
+ }
+ }
+
+ .send-btn, .stop-btn {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 8px;
+ border: none;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 500;
+ cursor: pointer;
+ }
+
+ .send-btn {
+ background: ${(props) => props.theme.brand};
+ color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')};
+
+ &:hover {
+ opacity: 0.9;
+ }
+
+ &:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+ }
+
+ .stop-btn {
+ background: ${(props) => props.theme.colors.text.danger};
+ color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')};
+
+ &:hover {
+ opacity: 0.9;
+ }
+ }
+ }
+
+ @keyframes fadeIn {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
+
+ @keyframes slideIn {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
+
+ @keyframes blink {
+ 0%, 50% { opacity: 1; }
+ 51%, 100% { opacity: 0; }
+ }
+
+ @keyframes dotBounce {
+ 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
+ 40% { transform: scale(1); opacity: 1; }
+ }
+
+ @keyframes progressSlide {
+ 0% { transform: translateX(-100%); }
+ 100% { transform: translateX(400%); }
+ }
+
+ @keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/AiChatSidebar/constants.js b/packages/bruno-app/src/components/AiChatSidebar/constants.js
new file mode 100644
index 000000000..2da906bef
--- /dev/null
+++ b/packages/bruno-app/src/components/AiChatSidebar/constants.js
@@ -0,0 +1,54 @@
+export const PROCESSING_STAGES = [
+ { id: 'sending', label: 'Sending request', icon: 'send' },
+ { id: 'thinking', label: 'AI is thinking', icon: 'sparkles' },
+ { id: 'generating', label: 'Generating response', icon: 'wand' },
+ { id: 'applying', label: 'Preparing changes', icon: 'code' }
+];
+
+export const CONTENT_TYPE_LABELS = {
+ 'app': 'App',
+ 'tests': 'Tests',
+ 'pre-request': 'Script',
+ 'post-response': 'Script',
+ 'docs': 'Docs'
+};
+
+export const SUGGESTIONS_BY_TYPE = {
+ 'app': [
+ { label: 'Create a form for this request', prompt: 'Create a simple form to send this request' },
+ { label: 'Add a loading spinner', prompt: 'Add a loading spinner while the request is pending' },
+ { label: 'Show response in a table', prompt: 'Display the response data in a table' },
+ { label: 'Add error handling', prompt: 'Add error handling with user-friendly messages' }
+ ],
+ 'tests': [
+ { label: 'Generate basic tests', prompt: 'Generate tests for status code, response body, and headers' },
+ { label: 'Test response structure', prompt: 'Write tests to validate the response body structure and data types' },
+ { label: 'Test error cases', prompt: 'Write tests for common error scenarios' },
+ { label: 'Test response time', prompt: 'Add a test to verify response time is acceptable' }
+ ],
+ 'pre-request': [
+ { label: 'Add authentication', prompt: 'Add authorization header from environment variable' },
+ { label: 'Set dynamic variables', prompt: 'Set dynamic request variables like timestamp or unique ID' },
+ { label: 'Conditional logic', prompt: 'Add conditional logic to modify the request based on environment' }
+ ],
+ 'post-response': [
+ { label: 'Extract to variables', prompt: 'Extract data from response and save to environment variables' },
+ { label: 'Store auth token', prompt: 'Extract auth token from response and save for future requests' },
+ { label: 'Log response', prompt: 'Log response status and body for debugging' },
+ { label: 'Transform response', prompt: 'Transform and process the response data' }
+ ],
+ 'docs': [
+ { label: 'Generate full docs', prompt: 'Generate comprehensive API documentation for this endpoint' },
+ { label: 'Document parameters', prompt: 'Document all request parameters, headers, and body' },
+ { label: 'Add examples', prompt: 'Add request and response examples' },
+ { label: 'Document errors', prompt: 'Document common error responses and status codes' }
+ ]
+};
+
+export const PLACEHOLDER_BY_TYPE = {
+ 'tests': { empty: 'Describe the tests you want...', filled: 'Ask to modify or add tests...' },
+ 'pre-request': { empty: 'Describe the script you want...', filled: 'Ask to modify the script...' },
+ 'post-response': { empty: 'Describe the script you want...', filled: 'Ask to modify the script...' },
+ 'docs': { empty: 'Describe the documentation...', filled: 'Ask to update the docs...' },
+ 'app': { empty: 'Describe the app you want to create...', filled: 'Ask to modify your app...' }
+};
diff --git a/packages/bruno-app/src/components/AiChatSidebar/index.js b/packages/bruno-app/src/components/AiChatSidebar/index.js
new file mode 100644
index 000000000..bfeed187a
--- /dev/null
+++ b/packages/bruno-app/src/components/AiChatSidebar/index.js
@@ -0,0 +1,758 @@
+import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import {
+ IconX,
+ IconPlayerStop,
+ IconCheck,
+ IconCode,
+ IconWand,
+ IconStars,
+ IconCornerDownLeft,
+ IconChevronDown,
+ IconHistory,
+ IconPlus,
+ IconTrash
+} from '@tabler/icons';
+import get from 'lodash/get';
+import find from 'lodash/find';
+import MenuDropdown from 'ui/MenuDropdown';
+import { focusTab } from 'providers/ReduxStore/slices/tabs';
+import {
+ closeAiSidebar,
+ sendAiMessage,
+ stopAiStream,
+ setChatBinding,
+ startNewConversation,
+ refreshChatHistory,
+ openConversation,
+ removeConversation,
+ setMessageCodeStatus
+} from 'providers/ReduxStore/slices/chat';
+import {
+ updateAppCode,
+ updateRequestTests,
+ updateRequestScript,
+ updateResponseScript,
+ updateRequestDocs
+} from 'providers/ReduxStore/slices/collections';
+import { findItemInCollection } from 'utils/collections';
+import { getAiStatus } from 'utils/ai';
+
+import StyledWrapper from './StyledWrapper';
+import DiffView from './DiffView';
+import AssistantCodeBlock from './AssistantCodeBlock';
+import { PROCESSING_STAGES, CONTENT_TYPE_LABELS, SUGGESTIONS_BY_TYPE, PLACEHOLDER_BY_TYPE } from './constants';
+import { renderMarkdown, parseMessageSegments } from './utils';
+
+const SELECTED_MODEL_LS_KEY = 'bruno.ai.chat.selectedModel';
+const AUTO_MODEL_ID = '';
+
+const ToolActivityGroup = ({ activities }) => {
+ if (!activities?.length) return null;
+ const allDone = activities.every((a) => a.done);
+ return (
+
+ {activities.map((activity, i) => (
+
+
+ {activity.done ? : }
+
+ {activity.label}{!activity.done ? '…' : ''}
+
+ ))}
+
+ );
+};
+
+const buildMessageTimeline = (cleanedContent, activities) => {
+ if (!activities?.length) {
+ return cleanedContent ? [{ type: 'text', content: cleanedContent }] : [];
+ }
+ if (!cleanedContent) return [{ type: 'tools', activities }];
+
+ const groups = [];
+ for (const activity of activities) {
+ const offset = Math.min(activity.textOffset || 0, cleanedContent.length);
+ const last = groups[groups.length - 1];
+ if (last && last.offset === offset) last.activities.push(activity);
+ else groups.push({ offset, activities: [activity] });
+ }
+
+ const parts = [];
+ let cursor = 0;
+ for (const group of groups) {
+ if (group.offset > cursor) {
+ parts.push({ type: 'text', content: cleanedContent.substring(cursor, group.offset) });
+ }
+ parts.push({ type: 'tools', activities: group.activities });
+ cursor = Math.max(cursor, group.offset);
+ }
+ if (cursor < cleanedContent.length) {
+ parts.push({ type: 'text', content: cleanedContent.substring(cursor) });
+ }
+ return parts;
+};
+
+const formatRelativeTime = (timestamp) => {
+ if (!timestamp) return '';
+ const diff = Date.now() - timestamp;
+ const minute = 60 * 1000;
+ const hour = 60 * minute;
+ const day = 24 * hour;
+ if (diff < minute) return 'just now';
+ if (diff < hour) return `${Math.floor(diff / minute)}m ago`;
+ if (diff < day) return `${Math.floor(diff / hour)}h ago`;
+ if (diff < 7 * day) return `${Math.floor(diff / day)}d ago`;
+ return new Date(timestamp).toLocaleDateString();
+};
+
+const HistoryPopover = ({ items, activeId, onPick, onDelete, onClose }) => {
+ const popoverRef = useRef(null);
+
+ useEffect(() => {
+ const handleClick = (e) => {
+ if (popoverRef.current && !popoverRef.current.contains(e.target)) {
+ onClose();
+ }
+ };
+ const handleKey = (e) => {
+ if (e.key === 'Escape') onClose();
+ };
+ document.addEventListener('mousedown', handleClick);
+ document.addEventListener('keydown', handleKey);
+ return () => {
+ document.removeEventListener('mousedown', handleClick);
+ document.removeEventListener('keydown', handleKey);
+ };
+ }, [onClose]);
+
+ return (
+
+ {items.length === 0 ? (
+
No past conversations
+ ) : (
+ items.map((item) => (
+
+
+
+
+ ))
+ )}
+
+ );
+};
+
+const AiChatSidebar = ({ collection }) => {
+ const dispatch = useDispatch();
+ const [input, setInput] = useState('');
+ 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 messagesEndRef = useRef(null);
+ const messagesContainerRef = useRef(null);
+ const isNearBottomRef = useRef(true);
+ const textareaRef = useRef(null);
+
+ const isOpen = useSelector((state) => state.chat.isOpen);
+ const allChats = useSelector((state) => state.chat.chats);
+ const tabs = useSelector((state) => state.tabs.tabs);
+ const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
+ const preferences = useSelector((state) => state.app.preferences);
+ const aiEnabled = get(preferences, 'ai.enabled', false);
+
+ const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
+ const activeItem = focusedTab && collection ? findItemInCollection(collection, activeTabUid) : null;
+
+ const currentChat = allChats[activeTabUid] || { messages: [], isLoading: false, error: null, historyList: [] };
+ const { messages, isLoading, error, historyList, conversationId } = currentChat;
+
+ useEffect(() => {
+ if (!isOpen || !aiEnabled) return;
+ let cancelled = false;
+ getAiStatus()
+ .then((status) => {
+ if (cancelled) return;
+ setAvailableModels(status?.availableModels || []);
+ })
+ .catch(() => {
+ if (!cancelled) setAvailableModels([]);
+ });
+ return () => { cancelled = true; };
+ }, [isOpen, aiEnabled, preferences?.ai]);
+
+ // Auto = empty string. We don't auto-correct to the first model — let the
+ // backend pick, so users get smart defaults that adapt as providers change.
+ useEffect(() => {
+ if (selectedModel === AUTO_MODEL_ID) return;
+ if (availableModels.length === 0) return;
+ if (availableModels.some((m) => m.id === selectedModel)) return;
+ setSelectedModel(AUTO_MODEL_ID);
+ 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 requestPaneTab = focusedTab?.requestPaneTab;
+ const contentType = useMemo(() => {
+ switch (requestPaneTab) {
+ case 'tests': return 'tests';
+ case 'script': return 'pre-request';
+ case 'docs': return 'docs';
+ default: return 'app';
+ }
+ }, [requestPaneTab]);
+
+ // 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.
+ const restoredOnceRef = useRef({});
+ useEffect(() => {
+ if (!isOpen || !activeItem || !collection) return;
+ const pathname = activeItem.pathname || '';
+ dispatch(setChatBinding({
+ tabUid: activeTabUid,
+ pathname,
+ collectionUid: collection.uid,
+ contentType
+ }));
+ dispatch(refreshChatHistory(activeTabUid));
+ }, [isOpen, activeItem?.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.
+ useEffect(() => {
+ if (!isOpen || !activeTabUid) return;
+ if (restoredOnceRef.current[activeTabUid]) return;
+ if (currentChat.conversationId) return;
+ if (currentChat.messages?.length > 0) return;
+ if (!historyList || historyList.length === 0) return;
+ restoredOnceRef.current[activeTabUid] = true;
+ dispatch(openConversation(activeTabUid, historyList[0].id));
+ }, [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');
+ 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', '')
+ };
+ }, [activeItem]);
+
+ const currentContent = allContent[contentType] || '';
+
+ const requestContext = useMemo(() => {
+ if (!activeItem) return null;
+ const draft = activeItem.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)
+ };
+ }, [activeItem]);
+
+ const chatsWithMessages = useMemo(() => {
+ if (!collection) return [];
+ return Object.entries(allChats)
+ .filter(([, chat]) => chat.messages?.length > 0)
+ .map(([tabUid, chat]) => {
+ const item = findItemInCollection(collection, tabUid);
+ if (!item) return null;
+ const method = item.draft
+ ? get(item, 'draft.request.method', 'GET')
+ : get(item, 'request.method', 'GET');
+ return {
+ id: tabUid,
+ name: item.name || 'Untitled',
+ method,
+ messageCount: chat.messages.length
+ };
+ })
+ .filter(Boolean);
+ }, [allChats, collection]);
+
+ const scrollToBottom = useCallback((behavior = 'smooth') => {
+ messagesEndRef.current?.scrollIntoView({ behavior });
+ }, []);
+
+ const handleMessagesScroll = useCallback(() => {
+ const el = messagesContainerRef.current;
+ if (!el) return;
+ isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
+ }, []);
+
+ useEffect(() => {
+ if (!isNearBottomRef.current) return;
+ const behavior = messages.some((m) => m.isStreaming) ? 'auto' : 'smooth';
+ scrollToBottom(behavior);
+ }, [messages, scrollToBottom]);
+
+ useEffect(() => {
+ if (isOpen) textareaRef.current?.focus();
+ }, [isOpen]);
+
+ useEffect(() => {
+ if (!isLoading) {
+ setProcessingStage(null);
+ return;
+ }
+ const last = messages[messages.length - 1];
+ if (last?.isStreaming && last.content) setProcessingStage('generating');
+ else if (last?.isStreaming) setProcessingStage('thinking');
+ else setProcessingStage('sending');
+ }, [isLoading, messages]);
+
+ const handleTextareaChange = (e) => {
+ setInput(e.target.value);
+ const el = textareaRef.current;
+ if (el) {
+ el.style.height = 'auto';
+ el.style.height = Math.min(el.scrollHeight, 150) + 'px';
+ }
+ };
+
+ const handleSubmit = async (e) => {
+ e?.preventDefault();
+ if (!input.trim() || isLoading || availableModels.length === 0) return;
+
+ const text = input.trim();
+ setInput('');
+ setProcessingStage('sending');
+ if (textareaRef.current) textareaRef.current.style.height = 'auto';
+
+ try {
+ await dispatch(sendAiMessage(activeTabUid, text, allContent, requestContext, selectedModel, contentType));
+ setProcessingStage('applying');
+ setTimeout(() => setProcessingStage(null), 500);
+ } catch (err) {
+ console.error('Failed to send AI message:', err);
+ setProcessingStage(null);
+ }
+ };
+
+ const handleKeyDown = (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit();
+ }
+ };
+
+ const handleStop = () => {
+ dispatch(stopAiStream(activeTabUid));
+ setProcessingStage(null);
+ };
+
+ const handleApplyCode = (code, originalCode, messageIndex, msgContentType, writeIndex) => {
+ if (!activeItem || code == null) return;
+ const targetType = msgContentType || contentType;
+
+ // Bail if the live buffer has drifted from what the AI based the diff on.
+ // The DiffView already disables the button in this case, but guarding here
+ // too means the keyboard / programmatic path can't blow away local edits.
+ const liveContent = allContent[targetType] || '';
+ if (originalCode != null && liveContent !== originalCode) {
+ 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;
+ }
+
+ dispatch(setMessageCodeStatus({
+ tabUid: activeTabUid,
+ messageIndex,
+ status: 'accepted',
+ writeIndex
+ }));
+ };
+
+ const handleRejectCode = (messageIndex, writeIndex) => {
+ dispatch(setMessageCodeStatus({
+ tabUid: activeTabUid,
+ messageIndex,
+ status: 'rejected',
+ writeIndex
+ }));
+ };
+
+ const handleNewChat = () => {
+ setHistoryOpen(false);
+ restoredOnceRef.current[activeTabUid] = true; // suppress restore
+ dispatch(startNewConversation({ tabUid: activeTabUid, contentType }));
+ textareaRef.current?.focus();
+ };
+
+ const handlePickConversation = (id) => {
+ setHistoryOpen(false);
+ restoredOnceRef.current[activeTabUid] = true;
+ dispatch(openConversation(activeTabUid, id));
+ };
+
+ const handleDeleteConversation = (id) => {
+ dispatch(removeConversation(activeTabUid, id));
+ };
+
+ const handleClose = () => dispatch(closeAiSidebar());
+ const handleSwitchChat = (tabUid) => dispatch(focusTab({ uid: tabUid }));
+
+ const handleSuggestionClick = (suggestion) => {
+ setInput(suggestion);
+ textareaRef.current?.focus();
+ };
+
+ const handleModelSelect = (modelId) => {
+ setSelectedModel(modelId);
+ try { localStorage.setItem(SELECTED_MODEL_LS_KEY, modelId); } catch {}
+ };
+
+ const selectedModelLabel = useMemo(() => {
+ if (selectedModel === AUTO_MODEL_ID) return 'Auto';
+ return availableModels.find((m) => m.id === selectedModel)?.label || 'Auto';
+ }, [availableModels, selectedModel]);
+
+ const ModelSelectorTrigger = forwardRef((props, ref) => (
+
+
+ {selectedModelLabel}
+
+
+ ));
+ ModelSelectorTrigger.displayName = 'ModelSelectorTrigger';
+
+ const modelMenuItems = useMemo(
+ () => [
+ { id: AUTO_MODEL_ID, label: 'Auto', onClick: () => handleModelSelect(AUTO_MODEL_ID) },
+ ...availableModels.map((model) => ({
+ id: model.id,
+ label: model.label,
+ onClick: () => handleModelSelect(model.id)
+ }))
+ ],
+ [availableModels]
+ );
+
+ const hasActiveStream = messages.some((m) => m.isStreaming);
+
+ const renderProcessingIndicator = () => {
+ if (!processingStage || processingStage === 'thinking' || hasActiveStream) return null;
+ const stage = PROCESSING_STAGES.find((s) => s.id === processingStage) || PROCESSING_STAGES[0];
+ return (
+
+
+
+ {stage.icon === 'sparkles' && }
+ {stage.icon === 'wand' && }
+ {stage.icon === 'code' && }
+ {stage.icon === 'send' && }
+
+
{stage.label}
+
+
+
+
+ );
+ };
+
+ const renderMessage = (msg, index) => {
+ const isUser = msg.role === 'user';
+ const isStreaming = msg.isStreaming;
+ const activities = msg.toolActivity || [];
+ const hasPendingTool = activities.some((a) => !a.done);
+ const content = msg.content || '';
+
+ const showThinking = isStreaming && !content && activities.length === 0;
+ const showWorking = isStreaming && activities.length > 0 && !hasPendingTool;
+ const timeline = buildMessageTimeline(content, activities);
+
+ return (
+
+
+ {isUser ? content : (
+ <>
+ {showThinking && (
+
+
+ Thinking…
+
+ )}
+
+ {timeline.map((part, partIndex) => {
+ if (part.type === 'tools') {
+ return
;
+ }
+ const segments = parseMessageSegments(part.content);
+ const isLastTextPart = !timeline.slice(partIndex + 1).some((p) => p.type === 'text');
+ return (
+
+ {segments.map((segment, segIndex) => {
+ const isLastSegment = isLastTextPart && segIndex === segments.length - 1;
+ if (segment.type === 'code') {
+ return (
+
+ );
+ }
+ return (
+
+
+ {isStreaming && isLastSegment &&
|}
+
+ );
+ })}
+
+ );
+ })}
+
+ {showWorking && (
+
+
+ Working…
+
+ )}
+
+ {!isStreaming && msg.writes?.length > 0 && msg.writes.map((write, writeIdx) => {
+ if (write.content === write.originalContent) return null;
+ const liveContent = allContent[write.type] || '';
+ const isStale = liveContent !== write.originalContent;
+ const notRead = !write.wasRead;
+ return (
+
handleApplyCode(write.content, write.originalContent, index, write.type, writeIdx)}
+ onReject={() => handleRejectCode(index, writeIdx)}
+ status={write.status}
+ />
+ );
+ })}
+
+ {!isStreaming && !msg.writes && msg.code && msg.originalCode && msg.code !== msg.originalCode && (
+ handleApplyCode(msg.code, msg.originalCode, index, msg.contentType)}
+ onReject={() => handleRejectCode(index)}
+ status={msg.codeStatus}
+ />
+ )}
+
+ {!isStreaming && msg.cancelled && (
+ Cancelled
+ )}
+ >
+ )}
+
+
+ );
+ };
+
+ const renderEmptyState = () => {
+ const suggestions = SUGGESTIONS_BY_TYPE[contentType] || SUGGESTIONS_BY_TYPE.app;
+ return (
+
+
+
AI Assistant
+
Ask me to generate or modify code, tests, scripts, and docs.
+
+
Try asking:
+
+ {suggestions.map((s, i) => (
+
+ ))}
+
+
+
+ );
+ };
+
+ if (!isOpen) return null;
+ if (!activeItem) return null;
+
+ const placeholders = PLACEHOLDER_BY_TYPE[contentType] || PLACEHOLDER_BY_TYPE.app;
+ const placeholder = currentContent ? placeholders.filled : placeholders.empty;
+ const historyCount = historyList?.length || 0;
+
+ return (
+
+
+
+
+
+ {requestMethod}
+ {requestName}
+ {chatsWithMessages.length > 1 && (
+ ({
+ id: chat.id,
+ label: `${chat.method} · ${chat.name}`,
+ onClick: () => handleSwitchChat(chat.id)
+ }))}
+ placement="bottom-start"
+ selectedItemId={activeTabUid}
+ >
+
+
+ )}
+
+
+
+
+
+ {historyOpen && (
+ setHistoryOpen(false)}
+ />
+ )}
+
+
+
+
+
+
+ {messages.length === 0 ? renderEmptyState() : (
+ <>
+ {messages.map(renderMessage)}
+ {renderProcessingIndicator()}
+ >
+ )}
+ {error && (
+
+ )}
+
+
+
+
+ {availableModels.length === 0 ? (
+
+ No AI models available. Configure a provider and enable models in Preferences > AI.
+
+ ) : (
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+
+
+ );
+};
+
+export default AiChatSidebar;
diff --git a/packages/bruno-app/src/components/AiChatSidebar/utils.js b/packages/bruno-app/src/components/AiChatSidebar/utils.js
new file mode 100644
index 000000000..e3520a694
--- /dev/null
+++ b/packages/bruno-app/src/components/AiChatSidebar/utils.js
@@ -0,0 +1,63 @@
+import MarkdownIt from 'markdown-it';
+
+const SAFE_LANG = /^[a-z0-9_+#.-]+$/i;
+const safeLanguage = (lang) => (lang && SAFE_LANG.test(lang) ? lang : 'text');
+
+const md = new MarkdownIt({
+ html: false,
+ breaks: true,
+ linkify: true,
+ highlight: (str, lang) =>
+ `${md.utils.escapeHtml(str)}
`
+});
+
+export const renderMarkdown = (content) => md.render(content || '');
+
+export const parseMessageSegments = (content = '') => {
+ if (!content) return [];
+
+ const segments = [];
+ let cursor = 0;
+ let inCode = false;
+ let language = '';
+
+ while (cursor <= content.length) {
+ const fenceIndex = content.indexOf('```', cursor);
+
+ if (fenceIndex === -1) {
+ const chunk = content.slice(cursor);
+ if (inCode || chunk) {
+ segments.push({
+ type: inCode ? 'code' : 'text',
+ content: chunk,
+ language,
+ isOpen: inCode
+ });
+ }
+ break;
+ }
+
+ if (!inCode) {
+ const textChunk = content.slice(cursor, fenceIndex);
+ if (textChunk) {
+ segments.push({ type: 'text', content: textChunk });
+ }
+ const fenceEnd = fenceIndex + 3;
+ const lineEnd = content.indexOf('\n', fenceEnd);
+ language = (lineEnd === -1 ? content.slice(fenceEnd) : content.slice(fenceEnd, lineEnd)).trim();
+ inCode = true;
+ cursor = lineEnd === -1 ? content.length : lineEnd + 1;
+ } else {
+ const codeChunk = content.slice(cursor, fenceIndex);
+ if (codeChunk.trim()) {
+ segments.push({ type: 'code', content: codeChunk, language, isOpen: false });
+ }
+ inCode = false;
+ language = '';
+ cursor = fenceIndex + 3;
+ if (content[cursor] === '\n') cursor += 1;
+ }
+ }
+
+ return segments.filter((seg) => seg.content && seg.content.trim());
+};
diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js
index 458464ce1..1d07c7225 100644
--- a/packages/bruno-app/src/components/RequestTabPanel/index.js
+++ b/packages/bruno-app/src/components/RequestTabPanel/index.js
@@ -295,6 +295,58 @@ const RequestTabPanel = () => {
};
}, [handleMouseUp, handleMouseMove]);
+ // Clamp leftPaneWidth when the main section shrinks (AI sidebar opens, or
+ // the window narrows). Without this the stored pixel width can exceed the
+ // available container, the section scrolls horizontally, and the response
+ // pane is pushed off-screen.
+ //
+ // Important: we ONLY react to genuine shrinks vs the last stable width. The
+ // initial observation and any growth are ignored. During mount Windows can
+ // emit a few transient narrow sizes (often 0) before layout settles — if
+ // we treated those as shrinks we'd lock leftPaneWidth at the transient value
+ // and never recover, which made several CodeMirror-driven tests flaky on
+ // Windows CI while passing on Linux.
+ const leftPaneWidthRef = useRef(leftPaneWidth);
+ useEffect(() => { leftPaneWidthRef.current = leftPaneWidth; }, [leftPaneWidth]);
+
+ useEffect(() => {
+ const el = mainSectionRef.current;
+ if (!el || isVerticalLayout) return;
+
+ let lastWidth = null;
+ let frame = null;
+ const observer = new ResizeObserver((entries) => {
+ if (frame) return;
+ frame = requestAnimationFrame(() => {
+ frame = null;
+ const width = entries[0]?.contentRect?.width || el.getBoundingClientRect().width;
+ if (!width) return;
+
+ // Skip the first observation (initial layout) and any non-shrink — we
+ // only clamp on real reductions in available width.
+ if (lastWidth === null || width >= lastWidth) {
+ lastWidth = width;
+ return;
+ }
+ lastWidth = width;
+
+ const maxLeft = width - MIN_RIGHT_PANE_WIDTH;
+ if (leftPaneWidthRef.current > maxLeft) {
+ // Floor at MIN_LEFT_PANE_WIDTH even if maxLeft is smaller — losing
+ // a few px from the response is preferable to collapsing the
+ // request pane to zero.
+ setLeftPaneWidth(Math.max(MIN_LEFT_PANE_WIDTH, maxLeft));
+ }
+ });
+ });
+
+ observer.observe(el);
+ return () => {
+ observer.disconnect();
+ if (frame) cancelAnimationFrame(frame);
+ };
+ }, [setLeftPaneWidth, isVerticalLayout]);
+
useEffect(() => {
if (!isVerticalLayout) return;
if (responsePaneCollapsed) return;
diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js
index 6746e77ee..7bdaf7988 100644
--- a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js
+++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js
@@ -17,13 +17,15 @@ import {
IconFileOff,
IconCode,
IconApps,
- IconTransform
+ IconTransform,
+ IconStars
} from '@tabler/icons';
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction, confirmWorkspaceCreation, cancelWorkspaceCreation } from 'providers/ReduxStore/slices/workspaces/actions';
import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces';
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { toggleCollectionFileMode, toggleAppMode } from 'providers/ReduxStore/slices/collections';
+import { toggleAiSidebar } from 'providers/ReduxStore/slices/chat';
import MigrateToYmlModal from 'components/CollectionSettings/Overview/Migration/MigrateToYmlModal';
import { findItemInCollection, findItemInCollectionByPathname } from 'utils/collections';
import find from 'lodash/find';
@@ -65,6 +67,9 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
const collections = useSelector((state) => state.collections.collections);
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
+ const preferences = useSelector((state) => state.app.preferences);
+ const isAiEnabled = get(preferences, 'ai.enabled', false);
+ const isAiSidebarOpen = useSelector((state) => state.chat.isOpen);
// Get the current active workspace
const currentWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
@@ -645,111 +650,125 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
)}
- {/* Right side: Actions (only for regular collections) */}
- {!isScratchCollection && (
-
- {isHttpRequestActive && (
-
-
-
-
-
-
-
- )}
- {collection.format === 'bru' && !migratePillDismissed && (
-
+ {isAiEnabled && (
+
+ dispatch(toggleAiSidebar())}
+ aria-label="AI Assistant"
+ size="sm"
+ data-testid="ai-assistant"
+ className={isAiSidebarOpen ? 'active' : ''}
>
-
-
-
- )}
- {/* OpenAPI Sync - standalone only when configured and beta enabled */}
- {hasOpenApiSyncConfigured && (
-
-
-
- {(hasOpenApiUpdates || hasOpenApiError) && (
-
- )}
-
-
- )}
- {/* Runner - always visible */}
-
-
-
+
- {/* JS Sandbox Mode - always visible */}
-
- {/* Overflow menu */}
-
-
-
-
-
- {/* Environment Selector - always visible */}
-
-
-
-
- )}
+ )}
+ {!isScratchCollection && (
+ <>
+ {isHttpRequestActive && (
+
+
+
+
+
+
+
+ )}
+ {collection.format === 'bru' && !migratePillDismissed && (
+
+
+
+
+ )}
+ {/* OpenAPI Sync - standalone only when configured and beta enabled */}
+ {hasOpenApiSyncConfigured && (
+
+
+
+ {(hasOpenApiUpdates || hasOpenApiError) && (
+
+ )}
+
+
+ )}
+ {/* Runner - always visible */}
+
+
+
+
+
+ {/* JS Sandbox Mode - always visible */}
+
+ {/* Overflow menu */}
+
+
+
+
+
+ {/* Environment Selector - always visible */}
+
+
+
+ >
+ )}
+
{showMigrateModal && (
state.app.showManageWorkspacePage);
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
const saveTransientRequestModals = useSelector((state) => state.collections.saveTransientRequestModals);
+
+ // AI sidebar mounts here so it spans the full request-pane height. It reads
+ // the active collection via the active tab so the sidebar follows tab switches.
+ // The selector returns null while the sidebar is closed so the page doesn't
+ // 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 activeCollection = useSelector((state) => {
+ if (!state.chat.isOpen) return null;
+ const activeTab = state.tabs.tabs.find((t) => t.uid === state.tabs.activeTabUid);
+ if (!activeTab) return null;
+ return state.collections.collections.find((c) => c.uid === activeTab.collectionUid) || null;
+ });
const mainSectionRef = useRef(null);
const [showRosettaBanner, setShowRosettaBanner] = useState(false);
@@ -152,6 +166,9 @@ export default function Main() {
>
)}
+ {isAiSidebarOpen && activeCollection && !showApiSpecPage && !showManageWorkspacePage && (
+
+ )}
diff --git a/packages/bruno-app/src/providers/ReduxStore/index.js b/packages/bruno-app/src/providers/ReduxStore/index.js
index a367acbd4..e7eaa4811 100644
--- a/packages/bruno-app/src/providers/ReduxStore/index.js
+++ b/packages/bruno-app/src/providers/ReduxStore/index.js
@@ -11,6 +11,7 @@ import performanceReducer from './slices/performance';
import workspacesReducer from './slices/workspaces';
import apiSpecReducer from './slices/apiSpec';
import openapiSyncReducer from './slices/openapi-sync';
+import chatReducer from './slices/chat';
import { draftDetectMiddleware } from './middlewares/draft/middleware';
import { autosaveMiddleware } from './middlewares/autosave/middleware';
import { snapshotMiddleware } from './middlewares/snapshot/middleware';
@@ -35,7 +36,8 @@ export const store = configureStore({
performance: performanceReducer,
workspaces: workspacesReducer,
apiSpec: apiSpecReducer,
- openapiSync: openapiSyncReducer
+ openapiSync: openapiSyncReducer,
+ chat: chatReducer
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(middleware)
});
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/chat.js b/packages/bruno-app/src/providers/ReduxStore/slices/chat.js
new file mode 100644
index 000000000..12ebed611
--- /dev/null
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/chat.js
@@ -0,0 +1,402 @@
+import { createSlice } from '@reduxjs/toolkit';
+import { closeTabs } from './tabs';
+import {
+ newConversationId,
+ saveConversation,
+ listConversationsForPath,
+ loadConversation,
+ deleteConversation
+} from 'utils/ai/chat-store';
+
+const initialState = {
+ isOpen: false,
+ chats: {}
+};
+
+const ensureChat = (state, tabUid) => {
+ if (!state.chats[tabUid]) {
+ state.chats[tabUid] = {
+ conversationId: null,
+ pathname: '',
+ collectionUid: '',
+ contentType: 'app',
+ messages: [],
+ isLoading: false,
+ error: null,
+ currentRequestId: null,
+ // createdAt is stamped on first message in addAiMessage / startNewConversation
+ // and preserved through openConversation so subsequent saves don't rewrite it.
+ createdAt: null,
+ historyList: []
+ };
+ }
+ return state.chats[tabUid];
+};
+
+export const chatSlice = createSlice({
+ name: 'chat',
+ initialState,
+ reducers: {
+ toggleAiSidebar: (state) => {
+ state.isOpen = !state.isOpen;
+ },
+ openAiSidebar: (state) => {
+ state.isOpen = true;
+ },
+ closeAiSidebar: (state) => {
+ state.isOpen = false;
+ },
+ setChatBinding: (state, action) => {
+ const { tabUid, pathname, collectionUid, contentType } = action.payload;
+ const chat = ensureChat(state, tabUid);
+ chat.pathname = pathname || '';
+ chat.collectionUid = collectionUid || '';
+ if (contentType) chat.contentType = contentType;
+ },
+ startNewConversation: (state, action) => {
+ const { tabUid, contentType, createdAt } = action.payload;
+ const chat = ensureChat(state, tabUid);
+ chat.conversationId = newConversationId();
+ chat.messages = [];
+ chat.error = null;
+ chat.createdAt = typeof createdAt === 'number' ? createdAt : null;
+ if (contentType) chat.contentType = contentType;
+ },
+ addAiMessage: (state, action) => {
+ const { tabUid, message } = action.payload;
+ const chat = ensureChat(state, tabUid);
+ if (!chat.conversationId) chat.conversationId = newConversationId();
+ if (!chat.createdAt) chat.createdAt = action.payload.timestamp || null;
+ chat.messages.push(message);
+ },
+ setAiLoading: (state, action) => {
+ const { tabUid, isLoading } = action.payload;
+ ensureChat(state, tabUid).isLoading = isLoading;
+ },
+ setCurrentRequestId: (state, action) => {
+ const { tabUid, requestId } = action.payload;
+ ensureChat(state, tabUid).currentRequestId = requestId;
+ },
+ setAiError: (state, action) => {
+ const { tabUid, error } = action.payload;
+ ensureChat(state, tabUid).error = error;
+ },
+ updateAiStreamingMessage: (state, action) => {
+ const { tabUid, content } = action.payload;
+ const chat = state.chats[tabUid];
+ const last = chat?.messages[chat.messages.length - 1];
+ if (last?.role === 'assistant' && last.isStreaming) {
+ last.content = content;
+ }
+ },
+ addAiToolActivity: (state, action) => {
+ const { tabUid, toolName, label } = action.payload;
+ const chat = state.chats[tabUid];
+ const last = chat?.messages[chat.messages.length - 1];
+ if (last?.role === 'assistant' && last.isStreaming) {
+ if (!last.toolActivity) last.toolActivity = [];
+ last.toolActivity.push({
+ toolName,
+ label,
+ done: false,
+ textOffset: last.content?.length || 0
+ });
+ }
+ },
+ markAiToolActivityDone: (state, action) => {
+ const { tabUid } = action.payload;
+ const chat = state.chats[tabUid];
+ const last = chat?.messages[chat.messages.length - 1];
+ if (last?.role === 'assistant' && last.toolActivity) {
+ for (let i = last.toolActivity.length - 1; i >= 0; i--) {
+ if (!last.toolActivity[i].done) {
+ last.toolActivity[i].done = true;
+ break;
+ }
+ }
+ }
+ },
+ finalizeAiStreamingMessage: (state, action) => {
+ const { tabUid, content, code, originalCode, contentType, writes, cancelled } = action.payload;
+ const chat = state.chats[tabUid];
+ const last = chat?.messages[chat.messages.length - 1];
+ if (last?.role === 'assistant') {
+ last.content = content;
+ last.code = code;
+ last.originalCode = originalCode;
+ last.contentType = contentType || 'app';
+ last.writes = writes || null;
+ last.isStreaming = false;
+ last.cancelled = Boolean(cancelled);
+ }
+ },
+ markAiMessageCodeStatus: (state, action) => {
+ const { tabUid, messageIndex, status, writeIndex } = action.payload;
+ const message = state.chats[tabUid]?.messages[messageIndex];
+ if (message?.role !== 'assistant') return;
+ if (writeIndex !== undefined && message.writes?.[writeIndex]) {
+ message.writes[writeIndex].status = status;
+ } else {
+ message.codeStatus = status;
+ }
+ },
+ setChatHistoryList: (state, action) => {
+ const { tabUid, historyList } = action.payload;
+ const chat = ensureChat(state, tabUid);
+ chat.historyList = Array.isArray(historyList) ? historyList : [];
+ },
+ replaceChatMessages: (state, action) => {
+ const { tabUid, conversationId, messages, contentType, createdAt } = action.payload;
+ const chat = ensureChat(state, tabUid);
+ chat.conversationId = conversationId;
+ chat.messages = messages || [];
+ chat.error = null;
+ chat.createdAt = typeof createdAt === 'number' ? createdAt : null;
+ if (contentType) chat.contentType = contentType;
+ }
+ },
+ extraReducers: (builder) => {
+ builder.addCase(closeTabs, (state, action) => {
+ const tabUids = action.payload.tabUids || [];
+ tabUids.forEach((uid) => { delete state.chats[uid]; });
+ });
+ }
+});
+
+export const {
+ toggleAiSidebar,
+ openAiSidebar,
+ closeAiSidebar,
+ setChatBinding,
+ startNewConversation,
+ addAiMessage,
+ setAiLoading,
+ setCurrentRequestId,
+ setAiError,
+ updateAiStreamingMessage,
+ addAiToolActivity,
+ markAiToolActivityDone,
+ finalizeAiStreamingMessage,
+ markAiMessageCodeStatus,
+ setChatHistoryList,
+ replaceChatMessages
+} = chatSlice.actions;
+
+const persistChat = async (chat) => {
+ if (!chat?.conversationId || !chat.pathname) return;
+ return saveConversation({
+ id: chat.conversationId,
+ pathname: chat.pathname,
+ collectionUid: chat.collectionUid,
+ contentType: chat.contentType,
+ messages: chat.messages,
+ createdAt: chat.createdAt
+ });
+};
+
+/** Refresh the cached history list for the active tab from IndexedDB. */
+export const refreshChatHistory = (tabUid) => async (dispatch, getState) => {
+ const chat = getState().chat.chats[tabUid];
+ if (!chat?.pathname) {
+ dispatch(setChatHistoryList({ tabUid, historyList: [] }));
+ return;
+ }
+ const list = await listConversationsForPath(chat.pathname);
+ dispatch(setChatHistoryList({ tabUid, historyList: list }));
+};
+
+/** Load a saved conversation into the active tab. */
+export const openConversation = (tabUid, conversationId) => async (dispatch) => {
+ const record = await loadConversation(conversationId);
+ if (!record) return;
+ dispatch(replaceChatMessages({
+ tabUid,
+ conversationId: record.id,
+ messages: record.messages || [],
+ contentType: record.contentType,
+ createdAt: record.createdAt
+ }));
+};
+
+/** Delete a saved conversation. If it's the active one, also start fresh. */
+export const removeConversation = (tabUid, conversationId) => async (dispatch, getState) => {
+ await deleteConversation(conversationId);
+ const chat = getState().chat.chats[tabUid];
+ if (chat?.conversationId === conversationId) {
+ dispatch(startNewConversation({ tabUid, contentType: chat.contentType }));
+ }
+ await dispatch(refreshChatHistory(tabUid));
+};
+
+/** Save the current conversation immediately. */
+export const persistCurrentConversation = (tabUid) => async (_dispatch, getState) => {
+ const chat = getState().chat.chats[tabUid];
+ if (chat) await persistChat(chat);
+};
+
+export const sendAiMessage = (
+ tabUid,
+ userMessage,
+ allContent,
+ requestContext,
+ model,
+ contentType = 'app'
+) => async (dispatch, getState) => {
+ const { ipcRenderer } = window;
+
+ // Reject overlapping sends for the same tab. The slice tracks one
+ // currentRequestId per tab and chunk/tool reducers mutate the last
+ // assistant message, so a concurrent send would interleave into the same
+ // streaming entry and only the latest stop would target a controller.
+ const existingChat = getState().chat.chats[tabUid];
+ if (existingChat?.currentRequestId || existingChat?.isLoading) {
+ return;
+ }
+
+ const now = Date.now();
+ const requestId = `${tabUid}-${now}`;
+
+ const existing = existingChat?.messages || [];
+ const priorMessages = existing
+ .filter((m) => !m.isStreaming)
+ .map((m) => ({ role: m.role, content: m.content }));
+
+ dispatch(addAiMessage({ tabUid, message: { role: 'user', content: userMessage }, timestamp: now }));
+ dispatch(addAiMessage({ tabUid, message: { role: 'assistant', content: '', isStreaming: true }, timestamp: now }));
+ dispatch(setAiLoading({ tabUid, isLoading: true }));
+ dispatch(setCurrentRequestId({ tabUid, requestId }));
+ dispatch(setAiError({ tabUid, error: null }));
+
+ return new Promise((resolve, reject) => {
+ const handleChunk = (data) => {
+ if (data.requestId !== requestId) return;
+ dispatch(updateAiStreamingMessage({ tabUid, content: data.fullText }));
+ };
+
+ const handleToolActivity = (data) => {
+ if (data.requestId !== requestId) return;
+ dispatch(addAiToolActivity({ tabUid, toolName: data.toolName, label: data.label }));
+ };
+
+ const handleToolDone = (data) => {
+ if (data.requestId !== requestId) return;
+ dispatch(markAiToolActivityDone({ tabUid }));
+ };
+
+ const finishLifecycle = async (final) => {
+ dispatch(finalizeAiStreamingMessage(final));
+ dispatch(setAiLoading({ tabUid, isLoading: false }));
+ dispatch(setCurrentRequestId({ tabUid, requestId: null }));
+ cleanup();
+ // Persist after the reducer has applied so we capture the final state.
+ await dispatch(persistCurrentConversation(tabUid));
+ await dispatch(refreshChatHistory(tabUid));
+ };
+
+ const handleComplete = async (data) => {
+ if (data.requestId !== requestId) return;
+ let resolvedType;
+ let resolvedOriginalCode;
+ if (data.writes && data.writes.length > 0) {
+ const primary = data.writes[data.writes.length - 1];
+ resolvedType = primary.type;
+ resolvedOriginalCode = primary.originalContent;
+ } else {
+ resolvedType = data.contentType || contentType;
+ resolvedOriginalCode = typeof allContent === 'object'
+ ? (allContent[resolvedType] || '')
+ : allContent;
+ }
+ await finishLifecycle({
+ tabUid,
+ content: data.message,
+ code: data.code,
+ originalCode: resolvedOriginalCode,
+ contentType: resolvedType,
+ writes: data.writes || null
+ });
+ resolve();
+ };
+
+ const handleStopped = async (data) => {
+ if (data.requestId !== requestId) return;
+ const original = typeof allContent === 'object' ? (allContent[contentType] || '') : allContent;
+ await finishLifecycle({
+ tabUid,
+ content: data.message,
+ code: null,
+ originalCode: original,
+ contentType,
+ cancelled: true
+ });
+ resolve();
+ };
+
+ const handleError = (data) => {
+ if (data.requestId !== requestId) return;
+ // Finalize the streaming assistant placeholder so the UI doesn't stay
+ // stuck in "Thinking…", the error itself surfaces via setAiError.
+ const original = typeof allContent === 'object' ? (allContent[contentType] || '') : allContent;
+ dispatch(finalizeAiStreamingMessage({
+ tabUid,
+ content: '',
+ code: null,
+ originalCode: original,
+ contentType,
+ cancelled: true
+ }));
+ dispatch(setAiError({ tabUid, error: data.error }));
+ dispatch(setAiLoading({ tabUid, isLoading: false }));
+ dispatch(setCurrentRequestId({ tabUid, requestId: null }));
+ cleanup();
+ reject(new Error(data.error));
+ };
+
+ const unsubs = [
+ ipcRenderer.on('main:ai-chat-chunk', handleChunk),
+ ipcRenderer.on('main:ai-chat-tool-activity', handleToolActivity),
+ ipcRenderer.on('main:ai-chat-tool-done', handleToolDone),
+ ipcRenderer.on('main:ai-chat-complete', handleComplete),
+ ipcRenderer.on('main:ai-chat-stopped', handleStopped),
+ ipcRenderer.on('main:ai-chat-error', handleError)
+ ];
+ const cleanup = () => unsubs.forEach((u) => u && u());
+
+ const messages = [
+ ...priorMessages,
+ { role: 'user', content: userMessage }
+ ];
+
+ const normalizedContent = typeof allContent === 'object'
+ ? allContent
+ : { [contentType]: allContent };
+
+ ipcRenderer.send('renderer:ai-chat-stream', {
+ messages,
+ allContent: normalizedContent,
+ contentType,
+ requestContext,
+ requestId,
+ model
+ });
+ });
+};
+
+export const stopAiStream = (tabUid) => (_dispatch, getState) => {
+ const { ipcRenderer } = window;
+ const requestId = getState().chat.chats[tabUid]?.currentRequestId;
+ if (requestId) {
+ ipcRenderer.send('renderer:ai-chat-stop', { requestId });
+ }
+};
+
+/**
+ * Update the accept/reject status of a diff and persist the change so the
+ * status sticks across restarts.
+ */
+export const setMessageCodeStatus = (params) => async (dispatch) => {
+ dispatch(markAiMessageCodeStatus(params));
+ await dispatch(persistCurrentConversation(params.tabUid));
+};
+
+export default chatSlice.reducer;
diff --git a/packages/bruno-app/src/utils/ai/chat-store.js b/packages/bruno-app/src/utils/ai/chat-store.js
new file mode 100644
index 000000000..3e563b025
--- /dev/null
+++ b/packages/bruno-app/src/utils/ai/chat-store.js
@@ -0,0 +1,141 @@
+import { openDB } from 'idb';
+
+const DB_NAME = 'bruno-ai-chats';
+const STORE = 'conversations';
+const DB_VERSION = 1;
+
+let dbPromise = null;
+
+const getDb = () => {
+ if (typeof indexedDB === 'undefined') return null;
+ if (!dbPromise) {
+ dbPromise = openDB(DB_NAME, DB_VERSION, {
+ upgrade(db) {
+ if (!db.objectStoreNames.contains(STORE)) {
+ const store = db.createObjectStore(STORE, { keyPath: 'id' });
+ store.createIndex('pathname', 'pathname');
+ store.createIndex('updatedAt', 'updatedAt');
+ }
+ }
+ }).catch((err) => {
+ console.warn('[AI] Failed to open chat history DB:', err);
+ dbPromise = null;
+ return null;
+ });
+ }
+ return dbPromise;
+};
+
+/** Generate a stable conversation id without depending on uuid utilities. */
+export const newConversationId = () =>
+ `chat-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+
+/** Trimmed title built from the first user message. Empty if none. */
+export const deriveTitle = (messages) => {
+ const firstUser = messages.find((m) => m.role === 'user' && (m.content || '').trim());
+ if (!firstUser) return '';
+ const text = firstUser.content.trim().replace(/\s+/g, ' ');
+ return text.length > 80 ? `${text.slice(0, 80)}…` : text;
+};
+
+/**
+ * Strip transient streaming state before persisting. We don't want to write
+ * partial assistant messages or in-flight tool spinners to disk.
+ */
+const sanitizeMessage = (msg) => {
+ const out = {
+ role: msg.role,
+ content: msg.content || ''
+ };
+ if (msg.code) out.code = msg.code;
+ if (msg.originalCode != null) out.originalCode = msg.originalCode;
+ if (msg.contentType) out.contentType = msg.contentType;
+ if (msg.codeStatus) out.codeStatus = msg.codeStatus;
+ if (msg.cancelled) out.cancelled = true;
+ if (msg.writes) {
+ out.writes = msg.writes.map((w) => ({
+ type: w.type,
+ content: w.content,
+ originalContent: w.originalContent,
+ wasRead: w.wasRead,
+ status: w.status
+ }));
+ }
+ return out;
+};
+
+export const saveConversation = async (conversation) => {
+ const db = await getDb();
+ if (!db) return null;
+
+ const record = {
+ id: conversation.id,
+ pathname: conversation.pathname || '',
+ collectionUid: conversation.collectionUid || '',
+ title: deriveTitle(conversation.messages || []),
+ contentType: conversation.contentType || 'app',
+ messages: (conversation.messages || [])
+ .filter((m) => !m.isStreaming)
+ .map(sanitizeMessage),
+ createdAt: conversation.createdAt || Date.now(),
+ updatedAt: Date.now()
+ };
+
+ if (!record.messages.length) return null;
+
+ try {
+ await db.put(STORE, record);
+ return record;
+ } catch (err) {
+ console.warn('[AI] Failed to save conversation:', err);
+ return null;
+ }
+};
+
+export const listConversationsForPath = async (pathname) => {
+ if (!pathname) return [];
+ const db = await getDb();
+ if (!db) return [];
+
+ try {
+ const all = await db.getAllFromIndex(STORE, 'pathname', pathname);
+ return all
+ .map((c) => ({
+ id: c.id,
+ title: c.title || '(untitled)',
+ contentType: c.contentType,
+ messageCount: c.messages?.length || 0,
+ createdAt: c.createdAt,
+ updatedAt: c.updatedAt
+ }))
+ .sort((a, b) => b.updatedAt - a.updatedAt);
+ } catch (err) {
+ console.warn('[AI] Failed to list conversations:', err);
+ return [];
+ }
+};
+
+export const loadConversation = async (id) => {
+ if (!id) return null;
+ const db = await getDb();
+ if (!db) return null;
+ try {
+ return (await db.get(STORE, id)) || null;
+ } catch (err) {
+ console.warn('[AI] Failed to load conversation:', err);
+ return null;
+ }
+};
+
+export const deleteConversation = async (id) => {
+ if (!id) return false;
+ const db = await getDb();
+ if (!db) return false;
+ try {
+ await db.delete(STORE, id);
+ return true;
+ } catch (err) {
+ console.warn('[AI] Failed to delete conversation:', err);
+ return false;
+ }
+};
diff --git a/packages/bruno-electron/src/ipc/ai/chat-prompts.js b/packages/bruno-electron/src/ipc/ai/chat-prompts.js
new file mode 100644
index 000000000..a4aa77804
--- /dev/null
+++ b/packages/bruno-electron/src/ipc/ai/chat-prompts.js
@@ -0,0 +1,192 @@
+const BRUNO_API_REFERENCE = `
+## BRUNO API REFERENCE
+
+### bru — Variables
+\`\`\`javascript
+bru.getEnvVar(key) / bru.setEnvVar(key, value) / bru.setEnvVar(key, value, { persist: true })
+bru.hasEnvVar(key) / bru.deleteEnvVar(key) / bru.getEnvName()
+bru.getGlobalEnvVar(key) / bru.setGlobalEnvVar(key, value)
+bru.getVar(key) / bru.setVar(key, value) / bru.hasVar(key) / bru.deleteVar(key)
+bru.getCollectionVar(key) / bru.getFolderVar(key) / bru.getRequestVar(key)
+bru.getSecretVar(key) / bru.getProcessEnv(key)
+\`\`\`
+
+### bru — Utilities & Runner
+\`\`\`javascript
+bru.cwd() / bru.getCollectionName() / bru.interpolate(strOrObj) / await bru.sleep(ms)
+bru.visualize(htmlString) / bru.utils.minifyJson(json) / bru.utils.minifyXml(xml)
+bru.setNextRequest(name) / bru.runner.skipRequest() / bru.runner.stopExecution()
+const response = await bru.sendRequest({ url, method, headers, body })
+await bru.runRequest(itemPathname)
+\`\`\`
+
+### req — Request
+\`\`\`javascript
+req.url, req.method, req.headers, req.body, req.timeout, req.name, req.tags
+req.getUrl() / req.setUrl(url) / req.getMethod() / req.setMethod(method)
+req.getHeaders() / req.setHeaders(headers) / req.getHeader(name) / req.setHeader(name, value)
+req.getBody() / req.setBody(data) / req.getTimeout() / req.setTimeout(ms)
+req.getAuthMode() / req.disableParsingResponseJson()
+\`\`\`
+
+### res — Response (only available in post-response and tests)
+\`\`\`javascript
+res.status, res.statusText, res.headers, res.body, res.responseTime, res.url
+res.getStatus() / res.getStatusText() / res.getHeaders() / res.getHeader(name)
+res.getBody() / res.setBody(data) / res.getResponseTime() / res.getSize()
+res('data.user.name') // query JSON body by path
+\`\`\`
+
+### Chai assertions (tests only)
+\`\`\`javascript
+expect(x).to.equal(y) / .eql(y) / .be.a('string') / .have.property('p')
+expect(x).to.include(y) / .have.lengthOf(n) / .be.true / .false / .null
+expect(x).to.be.above(n) / .below(n) / .match(/regex/) / .exist / .be.empty
+\`\`\`
+`;
+
+const SYSTEM_PROMPTS = {
+ 'tests': `You are an AI assistant that helps users write tests for API requests in Bruno API client.
+
+${BRUNO_API_REFERENCE}
+
+## TEST BLOCK FORMAT
+\`\`\`javascript
+test("status code is 200", function() {
+ expect(res.getStatus()).to.equal(200);
+});
+\`\`\`
+
+## RULES
+1. Generate tests using \`test()\` blocks with \`expect()\` assertions
+2. Use the available objects: \`res\`, \`req\`, \`bru\`, \`test\`, \`expect\`
+3. Call read_response() to learn the response SHAPE before writing assertions — its output redacts real values to placeholders like \`\` / \`\`. Use it to pick correct paths and value types, then write assertions on type, existence, or structure. Don't compare against the placeholder strings, and don't invent specific values unless the user provided them.
+4. Write the COMPLETE test file when using write_content`,
+
+ 'pre-request': `You are an AI assistant that helps users write pre-request scripts for API requests in Bruno API client.
+
+${BRUNO_API_REFERENCE}
+
+## CONTEXT
+Pre-request scripts run BEFORE the HTTP request is sent. Available objects: \`bru\`, \`req\`. The \`res\` object is NOT available.
+
+## RULES
+1. NEVER use \`res\` — it does not exist in pre-request context
+2. NEVER use \`test()\` or \`expect()\` — those are only for tests
+3. Write the COMPLETE script when using write_content`,
+
+ 'post-response': `You are an AI assistant that helps users write post-response scripts for API requests in Bruno API client.
+
+${BRUNO_API_REFERENCE}
+
+## CONTEXT
+Post-response scripts run AFTER the response is received, before tests. Available objects: \`bru\`, \`req\`, \`res\`.
+
+## RULES
+1. Use \`res\` to access response data — read real values at runtime, never hard-code them
+2. NEVER use \`test()\` or \`expect()\` — those are only for the tests editor
+3. Call read_response() to learn the response SHAPE (keys + types) when you need to know which paths exist. The view is redacted — real values are replaced by placeholders. Use it to pick correct paths, then read actual values via \`res.getBody()\` / \`res('path')\` in generated code.
+4. Write the COMPLETE script when using write_content`,
+
+ 'docs': `You are an AI assistant that helps users write API documentation in Bruno API client.
+
+## STRUCTURE
+- Description, parameters, request body, expected responses, examples, notes
+
+## RULES
+1. Use markdown format
+2. Be concise but thorough
+3. Use the request context (URL, method, headers, body, params) for accurate docs
+4. Write the COMPLETE documentation when using write_content`,
+
+ 'app': `You are an AI assistant that helps users build small in-Bruno apps tied to an HTTP request.
+
+An app is a single HTML/CSS/JS document rendered inside Bruno. It can:
+- Call \`ctx.sendRequest({ variables })\` to execute the current request
+- Read \`ctx.response\` for the last response (and subscribe via \`ctx.onResponseUpdate\`)
+- Use \`ctx.variables\` and \`ctx.setRuntimeVariable(key, value)\`
+- List or run other requests with \`ctx.listRequests()\` / \`ctx.runRequest(pathname)\`
+
+## RULES
+1. Generate a single self-contained HTML document (inline styles and scripts are fine — no external CDN)
+2. Keep the UI clean, readable, and accessible — neutral styling, no heavy gradients
+3. Write the COMPLETE document when using write_content`
+};
+
+const TOOL_INSTRUCTIONS = `
+## How to respond
+
+For greetings, questions, explanations, or anything that does NOT require changing code — just reply with text. Do NOT call any tools.
+
+Only use tools when the user asks you to create, edit, or modify code/scripts/tests/docs, or when you need to inspect the API response.
+
+## How to modify content (only when the user asks for changes)
+
+1. Call read_content(type) to get the current content
+2. Call write_content(type, content) with the COMPLETE updated content
+3. Explain what you changed in plain text
+
+Do NOT output code changes as plain text or markdown code blocks. Use the tools instead.
+
+## How to access response data
+
+For user privacy, response bodies are NEVER shown to you with real values. Both the context summary and read_response() return a redacted view: keys, array structure, and value types only — primitive values are replaced by placeholders like \`\`, \`\`, \`\`, \`\`. The shape, keys, and types are accurate.
+
+This means:
+- Use the redacted shape to discover correct property paths and value types.
+- Write code that READS from the response at runtime (e.g. \`res.getBody()\`, \`res('path.to.field')\`) — never hard-code the placeholder strings as if they were real values.
+- For tests, prefer assertions on type, existence, or shape (\`.to.be.a('string')\`, \`.to.exist\`, \`.to.have.property('id')\`) over exact-value assertions, unless the user gives the expected value themselves.
+
+### Tool details
+- read_content(type): reads a section. type ∈ { 'app', 'tests', 'pre-request', 'post-response', 'docs' }. MUST be called before write_content for the same type.
+- write_content(type, content): writes complete new content. The content must be the ENTIRE file, not a diff. read_content must be called first for the same type.
+- read_response(): returns the redacted shape (keys + types) of the last response body. No parameters. Use it to learn paths and types — not to read actual values.
+
+### Rules
+- ALWAYS call read_content before write_content for the same type
+- write_content must contain the ENTIRE file content, not just changed lines
+- You may modify multiple content types by reading and writing each one
+- When writing tests or post-response scripts, call read_response() to learn the response SHAPE; generate code that reads real values at runtime, do not invent or hard-code them
+`;
+
+const CONTENT_TYPES = ['app', 'tests', 'pre-request', 'post-response', 'docs'];
+
+const TOOL_LABELS = {
+ read_content: {
+ 'app': 'Reading app code',
+ 'tests': 'Reading tests',
+ 'pre-request': 'Reading pre-request script',
+ 'post-response': 'Reading post-response script',
+ 'docs': 'Reading documentation'
+ },
+ write_content: {
+ 'app': 'Writing app code',
+ 'tests': 'Writing tests',
+ 'pre-request': 'Writing pre-request script',
+ 'post-response': 'Writing post-response script',
+ 'docs': 'Writing documentation'
+ },
+ read_response: { default: 'Reading response data' }
+};
+
+const buildSystemPrompt = (contentType, hasMultipleContent) => {
+ const base = SYSTEM_PROMPTS[contentType] || SYSTEM_PROMPTS.app;
+ const hint = `\nThe user's active tab is '${contentType || 'app'}' — use that as the type for read_content / write_content unless they specify otherwise.`;
+ let prompt = TOOL_INSTRUCTIONS + hint + '\n\n' + base;
+ if (hasMultipleContent) {
+ prompt += '\n\nNote: The user may ask you to modify other content types too (app, tests, pre-request, post-response, docs). The context message shows all available content.';
+ }
+ return prompt;
+};
+
+const resolveContentType = (requested, fallback) => {
+ if (requested && CONTENT_TYPES.includes(requested)) return requested;
+ return fallback;
+};
+
+module.exports = {
+ CONTENT_TYPES,
+ TOOL_LABELS,
+ buildSystemPrompt,
+ resolveContentType
+};
diff --git a/packages/bruno-electron/src/ipc/ai/chat.js b/packages/bruno-electron/src/ipc/ai/chat.js
new file mode 100644
index 000000000..e713ed0fe
--- /dev/null
+++ b/packages/bruno-electron/src/ipc/ai/chat.js
@@ -0,0 +1,472 @@
+const { ipcMain } = require('electron');
+const { streamText, stepCountIs } = require('ai');
+const { z } = require('zod');
+const { CONTENT_TYPES, TOOL_LABELS, buildSystemPrompt, resolveContentType } = require('./chat-prompts');
+
+const activeStreams = new Map();
+
+const CONTENT_LABELS = {
+ 'app': 'App Code',
+ 'tests': 'Test Code',
+ 'pre-request': 'Pre-Request Script',
+ 'post-response': 'Post-Response Script',
+ 'docs': 'Documentation'
+};
+
+// Replace every primitive value with a type-name placeholder so the model
+// sees the response *shape* without any real data. Customer responses can
+// contain PII / secrets / tokens — we keep keys, types, and array structure
+// intact so the AI can write correct property paths and assertions, but
+// strip the values themselves. The AI is told these are placeholders so it
+// doesn't hard-code them into generated code.
+const REDACTED_TRUNCATED = '';
+const REDACTED_NULL = '';
+const REDACTED_BY_TYPE = {
+ string: '',
+ number: '',
+ boolean: '',
+ bigint: ''
+};
+
+const redactResponseValues = (data, depth = 0, maxDepth = 6) => {
+ if (data === null) return REDACTED_NULL;
+ if (data === undefined) return REDACTED_NULL;
+ if (depth >= maxDepth) return REDACTED_TRUNCATED;
+
+ if (Array.isArray(data)) {
+ if (data.length === 0) return [];
+ // Cap sample size — long arrays only need a few items to convey shape.
+ const sampleSize = Math.min(data.length, 3);
+ const out = data.slice(0, sampleSize).map((item) => redactResponseValues(item, depth + 1, maxDepth));
+ if (data.length > sampleSize) out.push(`<${data.length - sampleSize} more items>`);
+ return out;
+ }
+
+ if (typeof data === 'object') {
+ const keys = Object.keys(data);
+ const out = {};
+ for (const key of keys.slice(0, 30)) {
+ out[key] = redactResponseValues(data[key], depth + 1, maxDepth);
+ }
+ if (keys.length > 30) out['...'] = `<${keys.length - 30} more keys>`;
+ return out;
+ }
+
+ return REDACTED_BY_TYPE[typeof data] || '';
+};
+
+const REDACTION_NOTICE
+ = 'Values are placeholders (``, ``, …). The shape, keys, and types are accurate but no real data is shown. Reference fields by path in generated code — do not hard-code these placeholders as literal values.';
+
+const SENSITIVE_HEADER_PATTERNS = [
+ /^authorization$/i,
+ /^proxy-authorization$/i,
+ /^cookie$/i,
+ /^set-cookie$/i,
+ /^x-api-key$/i,
+ /^x-auth-token$/i,
+ /^x-access-token$/i,
+ /^x-csrf-token$/i,
+ /api[_-]?key/i,
+ /access[_-]?token/i,
+ /auth[_-]?token/i,
+ /secret/i,
+ /password/i
+];
+
+const isSensitiveName = (name) => {
+ if (!name) return false;
+ return SENSITIVE_HEADER_PATTERNS.some((re) => re.test(name));
+};
+
+const maskValue = (name, value) => (isSensitiveName(name) ? '' : value);
+
+const formatRequestContext = (ctx) => {
+ if (!ctx) return '';
+ const parts = [];
+
+ if (ctx.url || ctx.method) {
+ parts.push(`**Request:** ${ctx.method || 'GET'} ${ctx.url || ''}`);
+ }
+
+ const headers = (ctx.headers || []).filter((h) => h.enabled);
+ if (headers.length > 0) {
+ parts.push(`**Headers:**\n${headers.map((h) => ` ${h.name}: ${maskValue(h.name, h.value)}`).join('\n')}`);
+ }
+
+ const params = (ctx.params || []).filter((p) => p.enabled);
+ const query = params.filter((p) => p.type === 'query');
+ const pathParams = params.filter((p) => p.type === 'path');
+ if (query.length > 0) {
+ parts.push(`**Query Parameters:**\n${query.map((p) => ` ${p.name}: ${maskValue(p.name, p.value)}`).join('\n')}`);
+ }
+ if (pathParams.length > 0) {
+ parts.push(`**Path Parameters:**\n${pathParams.map((p) => ` ${p.name}: ${maskValue(p.name, p.value)}`).join('\n')}`);
+ }
+
+ if (ctx.body && ctx.body.mode && ctx.body.mode !== 'none') {
+ let content = '';
+ switch (ctx.body.mode) {
+ case 'json': content = ctx.body.json || ''; break;
+ case 'text': content = ctx.body.text || ''; break;
+ case 'xml': content = ctx.body.xml || ''; break;
+ case 'sparql': content = ctx.body.sparql || ''; break;
+ case 'formUrlEncoded': {
+ const items = (ctx.body.formUrlEncoded || []).filter((p) => p.enabled);
+ content = items.map((p) => ` ${p.name}: ${maskValue(p.name, p.value)}`).join('\n');
+ break;
+ }
+ case 'multipartForm': {
+ const items = (ctx.body.multipartForm || []).filter((p) => p.enabled);
+ content = items.map((p) => ` ${p.name}: ${p.type === 'file' ? '[file]' : maskValue(p.name, p.value)}`).join('\n');
+ break;
+ }
+ case 'graphql':
+ content = ctx.body.graphql?.query || '';
+ if (ctx.body.graphql?.variables) {
+ content += `\n\nVariables:\n${ctx.body.graphql.variables}`;
+ }
+ break;
+ default:
+ content = '';
+ }
+ if (content) {
+ parts.push(`**Body (${ctx.body.mode}):**\n\`\`\`\n${content}\n\`\`\``);
+ }
+ }
+
+ if (ctx.responseStatus) {
+ parts.push(`**Last Response Status:** ${ctx.responseStatus}`);
+ }
+ if (ctx.responseData) {
+ try {
+ const parsed = typeof ctx.responseData === 'string' ? JSON.parse(ctx.responseData) : ctx.responseData;
+ const redacted = redactResponseValues(parsed);
+ if (redacted != null) {
+ parts.push(`**Response Shape (values redacted — ${REDACTION_NOTICE}):**\n\`\`\`json\n${JSON.stringify(redacted, null, 2)}\n\`\`\``);
+ }
+ } catch {
+ if (typeof ctx.responseData === 'string' && ctx.responseData.trim()) {
+ parts.push(`**Response:** non-JSON, ${ctx.responseData.length} chars (call read_response() for a redacted view)`);
+ }
+ }
+ }
+
+ if (ctx.docs && ctx.docs.trim()) {
+ parts.push(`**Documentation:**\n${ctx.docs.trim()}`);
+ }
+
+ return parts.join('\n\n');
+};
+
+const buildContextMessage = (contentType, allContent, requestContext) => {
+ const parts = [];
+ const ctx = formatRequestContext(requestContext);
+ if (ctx) {
+ parts.push(`HTTP Request Context:\n${ctx}`);
+ }
+
+ const activeLabel = CONTENT_LABELS[contentType] || 'Code';
+ const activeContent = allContent[contentType] || '';
+ if (activeContent.trim()) {
+ parts.push(`Current ${activeLabel} (active tab — snapshot only; use read_content('${contentType}') to get the latest version before writing):\n\`\`\`\n${activeContent}\n\`\`\``);
+ } else {
+ parts.push(`The ${activeLabel} (active tab) is currently empty. Use read_content('${contentType}') before writing new content.`);
+ }
+
+ const others = Object.entries(allContent)
+ .filter(([type, content]) => type !== contentType && content && content.trim());
+ if (others.length > 0) {
+ const summary = others
+ .map(([type, content]) => `${CONTENT_LABELS[type] || type}:\n\`\`\`\n${content}\n\`\`\``)
+ .join('\n\n');
+ parts.push(`Other content in this request:\n${summary}`);
+ }
+
+ return parts.join('\n\n');
+};
+
+// Defensive fallback: if the model returns a markdown code block instead of
+// calling write_content, extract the fenced code so the UI still has something
+// to diff against. The tool path is the primary route.
+const extractFencedCode = (text) => {
+ if (!text) return null;
+ const fenced = text.match(/```(?:[\w-]+)?\s*\n([\s\S]*?)```/);
+ return fenced ? fenced[1].trim() : null;
+};
+
+const READ_PARAMS = z.object({
+ type: z.string().describe('Section to read. One of: \'app\', \'tests\', \'pre-request\', \'post-response\', \'docs\'.')
+});
+const WRITE_PARAMS = z.object({
+ type: z.string().describe('Section to write. One of: \'app\', \'tests\', \'pre-request\', \'post-response\', \'docs\'.'),
+ content: z.string().describe('The complete new content for the section.')
+});
+const READ_RESPONSE_PARAMS = z.object({});
+
+const registerChatIpc = ({ mainWindow, resolveModel, pickDefaultModelId, isAiEnabled }) => {
+ ipcMain.on('renderer:ai-chat-stop', (_event, { requestId } = {}) => {
+ const controller = activeStreams.get(requestId);
+ if (controller) {
+ controller.abort();
+ activeStreams.delete(requestId);
+ }
+ });
+
+ ipcMain.on('renderer:ai-chat-stream', async (_event, payload) => {
+ const { messages, allContent, contentType, requestContext, requestId, model: modelId } = payload || {};
+
+ const send = (channel, data) => {
+ if (mainWindow?.webContents && !mainWindow.webContents.isDestroyed()) {
+ mainWindow.webContents.send(channel, data);
+ }
+ };
+
+ // Validate payload shape upfront. Without this, a missing or wrong-typed
+ // `messages` would throw out of the handler at `messages.map(...)` below,
+ // bypassing the try/catch and never emitting `main:ai-chat-error` — the
+ // renderer would then sit waiting on a stream that will never arrive.
+ if (!requestId || typeof requestId !== 'string') {
+ console.error('[AI] ai-chat-stream missing/invalid requestId, dropping payload');
+ return;
+ }
+ if (!Array.isArray(messages)) {
+ send('main:ai-chat-error', { requestId, error: 'Invalid request: messages must be an array' });
+ return;
+ }
+
+ if (!isAiEnabled()) {
+ send('main:ai-chat-error', { requestId, error: 'AI features are disabled. Enable them in Preferences > AI.' });
+ return;
+ }
+
+ // Empty / 'auto' signals "let the backend pick" — resolves to the user's
+ // configured default model, falling back to the first available.
+ let effectiveModelId = modelId;
+ if (!effectiveModelId || effectiveModelId === 'auto') {
+ effectiveModelId = pickDefaultModelId();
+ if (!effectiveModelId) {
+ send('main:ai-chat-error', { requestId, error: 'No AI model available. Configure a provider in Preferences > AI.' });
+ return;
+ }
+ }
+
+ let model;
+ try {
+ model = resolveModel(effectiveModelId);
+ } catch (err) {
+ send('main:ai-chat-error', { requestId, error: err.message });
+ return;
+ }
+
+ const normalizedContent = allContent || {};
+ const effectiveType = contentType || 'app';
+ const hasMultiple = Object.values(normalizedContent).filter((c) => c && c.trim()).length > 1;
+
+ const readState = {};
+ const writeResults = [];
+
+ const tools = {
+ read_content: {
+ description: 'Read the current content of a section. MUST be called before write_content for the same type.',
+ inputSchema: READ_PARAMS,
+ execute: async ({ type }) => {
+ const resolved = resolveContentType(type, effectiveType);
+ const content = normalizedContent[resolved] || '';
+ readState[resolved] = content;
+ return content || `(empty — no existing content for '${resolved}')`;
+ }
+ },
+ write_content: {
+ description: 'Write complete updated content to a section. MUST call read_content for the same type first. The content parameter must be the COMPLETE file content, not a diff.',
+ inputSchema: WRITE_PARAMS,
+ execute: async ({ type, content }) => {
+ const resolved = resolveContentType(type, effectiveType);
+ if (!(resolved in readState)) {
+ // Tolerate models that skip read_content. We still record the
+ // original snapshot so the diff renders correctly, but the UI
+ // surfaces a warning when wasRead === false.
+ readState[resolved] = normalizedContent[resolved] || '';
+ writeResults.push({
+ type: resolved,
+ content,
+ originalContent: readState[resolved],
+ wasRead: false
+ });
+ } else {
+ writeResults.push({
+ type: resolved,
+ content,
+ originalContent: readState[resolved],
+ wasRead: true
+ });
+ }
+ return 'Success: Changes prepared for user review. The user will see a diff and can accept or reject your changes.';
+ }
+ },
+ read_response: {
+ description: 'Read the redacted shape of the response body from the last API request execution. Returns keys, array structure, and value types (as ``, ``, etc.) — actual values are stripped for user privacy. Use it to learn property paths and types when writing tests, scripts, or assertions; do not treat the placeholders as real values.',
+ inputSchema: READ_RESPONSE_PARAMS,
+ execute: async () => {
+ const status = requestContext?.responseStatus;
+ const data = requestContext?.responseData;
+ if (!status && !data) {
+ return '(No response available — the request has not been executed yet. The user needs to run the request first.)';
+ }
+
+ const parts = [];
+ if (status) parts.push(`Status: ${status}`);
+
+ if (data !== undefined && data !== null) {
+ // Try to parse JSON so we can redact structurally. Non-JSON
+ // payloads only get a type/length summary, we won't echo their
+ // contents either, since they may contain sensitive text.
+ let parsed = data;
+ let parsedOk = false;
+ if (typeof data === 'string') {
+ try {
+ parsed = JSON.parse(data); parsedOk = true;
+ } catch { parsedOk = false; }
+ } else if (typeof data === 'object') {
+ parsedOk = true;
+ }
+
+ if (parsedOk) {
+ const redacted = redactResponseValues(parsed);
+ parts.push(`Response Body (redacted shape):\n\`\`\`json\n${JSON.stringify(redacted, null, 2)}\n\`\`\``);
+ parts.push(`Note: ${REDACTION_NOTICE}`);
+ } else if (typeof data === 'string') {
+ parts.push(`Response Body: non-JSON text payload, ${data.length} chars (contents withheld for user privacy)`);
+ } else {
+ parts.push('Response Body: opaque value (contents withheld for user privacy)');
+ }
+ }
+
+ return parts.join('\n') || '(empty response)';
+ }
+ }
+ };
+
+ const allMessages = [
+ { role: 'user', content: buildContextMessage(effectiveType, normalizedContent, requestContext) },
+ ...messages.map((m) => ({ role: m.role, content: m.content }))
+ ];
+
+ const controller = new AbortController();
+ activeStreams.set(requestId, controller);
+ let fullText = '';
+
+ const finishWithWrites = () => {
+ const primary = writeResults[writeResults.length - 1];
+ send('main:ai-chat-complete', {
+ requestId,
+ message: fullText || 'Here are the proposed changes:',
+ code: primary.content,
+ contentType: primary.type,
+ writes: writeResults.map((w) => ({
+ type: w.type,
+ content: w.content,
+ originalContent: w.originalContent,
+ wasRead: w.wasRead
+ }))
+ });
+ };
+
+ try {
+ const result = streamText({
+ model,
+ system: buildSystemPrompt(effectiveType, hasMultiple),
+ messages: allMessages,
+ tools,
+ stopWhen: stepCountIs(5),
+ toolChoice: 'auto',
+ abortSignal: controller.signal
+ });
+
+ for await (const part of result.fullStream) {
+ if (controller.signal.aborted) break;
+ switch (part.type) {
+ case 'text-delta': {
+ fullText += part.text;
+ send('main:ai-chat-chunk', { requestId, chunk: part.text, fullText });
+ break;
+ }
+ case 'tool-call': {
+ const input = part.input || {};
+ const toolType = input.type || effectiveType;
+ const label = TOOL_LABELS[part.toolName]?.[toolType]
+ || TOOL_LABELS[part.toolName]?.default
+ || `Running ${part.toolName}`;
+ send('main:ai-chat-tool-activity', {
+ requestId,
+ toolName: part.toolName,
+ toolArgs: input,
+ label
+ });
+ break;
+ }
+ case 'tool-result': {
+ send('main:ai-chat-tool-done', { requestId, toolName: part.toolName });
+ break;
+ }
+ default:
+ break;
+ }
+ }
+
+ activeStreams.delete(requestId);
+
+ if (controller.signal.aborted) {
+ send('main:ai-chat-stopped', { requestId, message: fullText });
+ return;
+ }
+
+ if (writeResults.length > 0) {
+ finishWithWrites();
+ return;
+ }
+
+ if (fullText.trim()) {
+ const fallback = extractFencedCode(fullText);
+ send('main:ai-chat-complete', {
+ requestId,
+ message: fullText,
+ code: fallback,
+ contentType: effectiveType
+ });
+ return;
+ }
+
+ send('main:ai-chat-complete', {
+ requestId,
+ message: 'I wasn\'t able to generate a response. Could you try rephrasing your request?',
+ code: null,
+ contentType: effectiveType
+ });
+ } catch (error) {
+ activeStreams.delete(requestId);
+
+ if (error?.name === 'AbortError' || controller.signal.aborted) {
+ send('main:ai-chat-stopped', { requestId, message: fullText });
+ return;
+ }
+
+ // The AI SDK may surface a stream error after the model successfully
+ // emitted tool calls. Treat partial writes as the result so the user
+ // doesn't lose them.
+ if (writeResults.length > 0) {
+ console.warn(`[AI] Stream error after successful writes (${error.message}), surfacing writes`);
+ finishWithWrites();
+ return;
+ }
+
+ console.error('[AI] Chat stream error:', error);
+ send('main:ai-chat-error', {
+ requestId,
+ error: error?.message || 'Failed to get AI response'
+ });
+ }
+ });
+};
+
+module.exports = registerChatIpc;
diff --git a/packages/bruno-electron/src/ipc/ai/index.js b/packages/bruno-electron/src/ipc/ai/index.js
index 75c6a5c19..78216dd7a 100644
--- a/packages/bruno-electron/src/ipc/ai/index.js
+++ b/packages/bruno-electron/src/ipc/ai/index.js
@@ -14,6 +14,7 @@ const {
providerLabel
} = require('./providers');
const { SCRIPT_PROMPTS, SCRIPT_TYPES, buildScriptUserPrompt, stripCodeFences } = require('./script-prompts');
+const registerChatIpc = require('./chat');
const activeStreams = new Map();
@@ -263,6 +264,13 @@ const registerAiIpc = (mainWindow) => {
activeStreams.delete(streamId);
}
});
+
+ registerChatIpc({
+ mainWindow,
+ resolveModel,
+ pickDefaultModelId,
+ isAiEnabled: isEnabled
+ });
};
module.exports = registerAiIpc;