From 1ceea0797e715c965004a2f8bba8aca48d837955 Mon Sep 17 00:00:00 2001 From: naman-bruno Date: Thu, 25 Jun 2026 18:21:56 +0530 Subject: [PATCH] feat(ai): add AI chat sidebar with code block and diff view components (#8358) --- packages/bruno-app/package.json | 1 + .../AiChatSidebar/AssistantCodeBlock.js | 46 + .../AiChatSidebar/DiffView/StyledWrapper.js | 298 +++++++ .../AiChatSidebar/DiffView/index.js | 210 +++++ .../components/AiChatSidebar/StyledWrapper.js | 827 ++++++++++++++++++ .../src/components/AiChatSidebar/constants.js | 54 ++ .../src/components/AiChatSidebar/index.js | 758 ++++++++++++++++ .../src/components/AiChatSidebar/utils.js | 63 ++ .../src/components/RequestTabPanel/index.js | 52 ++ .../RequestTabs/CollectionHeader/index.js | 225 ++--- packages/bruno-app/src/pages/Bruno/index.js | 17 + .../src/providers/ReduxStore/index.js | 4 +- .../src/providers/ReduxStore/slices/chat.js | 402 +++++++++ packages/bruno-app/src/utils/ai/chat-store.js | 141 +++ .../bruno-electron/src/ipc/ai/chat-prompts.js | 192 ++++ packages/bruno-electron/src/ipc/ai/chat.js | 472 ++++++++++ packages/bruno-electron/src/ipc/ai/index.js | 8 + 17 files changed, 3666 insertions(+), 104 deletions(-) create mode 100644 packages/bruno-app/src/components/AiChatSidebar/AssistantCodeBlock.js create mode 100644 packages/bruno-app/src/components/AiChatSidebar/DiffView/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/AiChatSidebar/DiffView/index.js create mode 100644 packages/bruno-app/src/components/AiChatSidebar/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/AiChatSidebar/constants.js create mode 100644 packages/bruno-app/src/components/AiChatSidebar/index.js create mode 100644 packages/bruno-app/src/components/AiChatSidebar/utils.js create mode 100644 packages/bruno-app/src/providers/ReduxStore/slices/chat.js create mode 100644 packages/bruno-app/src/utils/ai/chat-store.js create mode 100644 packages/bruno-electron/src/ipc/ai/chat-prompts.js create mode 100644 packages/bruno-electron/src/ipc/ai/chat.js 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 && ( +
+
!
+
{error}
+
+ )} +
+
+ +
+ {availableModels.length === 0 ? ( +
+ No AI models available. Configure a provider and enable models in Preferences > AI. +
+ ) : ( +
+