diff --git a/frontend/src/components/AIChatPanel.message-boundary.test.tsx b/frontend/src/components/AIChatPanel.message-boundary.test.tsx index 182f983..595dfc9 100644 --- a/frontend/src/components/AIChatPanel.message-boundary.test.tsx +++ b/frontend/src/components/AIChatPanel.message-boundary.test.tsx @@ -3,18 +3,21 @@ import { readFileSync } from 'node:fs'; const source = readFileSync(new URL('./AIChatPanel.tsx', import.meta.url), 'utf8'); const boundarySource = readFileSync(new URL('./ai/AIMessageRenderBoundary.tsx', import.meta.url), 'utf8'); +const conversationViewSource = readFileSync(new URL('./ai/AIChatPanelConversationView.tsx', import.meta.url), 'utf8'); +const derivedStateSource = readFileSync(new URL('./ai/aiChatPanelDerivedState.ts', import.meta.url), 'utf8'); const systemContextSource = readFileSync(new URL('./ai/aiSystemContextMessages.ts', import.meta.url), 'utf8'); const runtimeSource = readFileSync(new URL('../utils/aiChatRuntime.ts', import.meta.url), 'utf8'); describe('AIChatPanel message render isolation', () => { it('keeps per-message render failures scoped to the broken bubble', () => { - expect(source).toContain("import AIMessageRenderBoundary from './ai/AIMessageRenderBoundary';"); + expect(source).toContain("import AIChatPanelConversationView from './ai/AIChatPanelConversationView';"); expect(boundarySource).toContain('class AIMessageRenderBoundary extends React.Component'); expect(source).toContain('[AI Message Render Error]'); + expect(conversationViewSource).toContain("import AIMessageRenderBoundary from './AIMessageRenderBoundary';"); expect(boundarySource).toContain('这条 AI 消息渲染失败,已自动隔离'); expect(source).toContain('__gonaviLastAIMessageRenderError'); - expect(source).toContain(' { @@ -55,7 +58,9 @@ describe('AIChatPanel message render isolation', () => { expect(source).toContain('const orderedAISessions = useMemo('); expect(source).toContain('right.updatedAt - left.updatedAt'); expect(source).toContain('const panelHistorySessions = useMemo('); - expect(source).toContain('orderedAISessions.slice(0, 8)'); + expect(source).toContain('buildAIChatInlineHistorySessions(orderedAISessions)'); + expect(derivedStateSource).toContain('export const buildAIChatInlineHistorySessions'); + expect(derivedStateSource).toContain('sessions.slice(0, limit)'); expect(source).toContain('sessions={panelHistorySessions}'); }); }); diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 58b0fcf..d4c1dbd 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -13,16 +13,12 @@ import type { JVMAIPlanContext, JVMDiagnosticPlanContext, } from '../types'; -import { DownOutlined } from '@ant-design/icons'; import './AIChatPanel.css'; import { AIChatHeader } from './ai/AIChatHeader'; -import { AIChatWelcome } from './ai/AIChatWelcome'; -import { AIMessageBubble } from './ai/AIMessageBubble'; import { AIChatInput } from './ai/AIChatInput'; import { AIHistoryDrawer } from './ai/AIHistoryDrawer'; -import AIMessageRenderBoundary from './ai/AIMessageRenderBoundary'; -import AIChatPanelModeContent, { type AIChatInsightItem } from './ai/AIChatPanelModeContent'; +import AIChatPanelConversationView from './ai/AIChatPanelConversationView'; import type { AIComposerNotice, AIComposerNoticeAction } from '../utils/aiComposerNotice'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { @@ -42,6 +38,14 @@ import { executeLocalAIToolCall, type AIToolContextEntry, } from './ai/aiLocalToolExecutor'; +import { + buildAIChatInlineHistorySessions, + buildAIChatInsights, + calculateAIContextUsageChars, + collectAIChatContextTableNames, + inferAIChatConnectionContext, + resolveAIChatPanelMode, +} from './ai/aiChatPanelDerivedState'; import { buildAIChatReadinessSnapshot } from './ai/aiChatReadiness'; import { buildAISystemContextMessages } from './ai/aiSystemContextMessages'; @@ -1247,32 +1251,15 @@ export const AIChatPanel: React.FC = ({ }; }, [isResizing, isV2Ui, onWidthChange]); - // 回推幽灵上下文:基于 get_tables 记录进行表级精确匹配(useMemo 缓存,避免每帧重算) - const { inferredConnectionId, inferredDbName } = useMemo(() => { - let connId = activeContext?.connectionId; - let dbName = activeContext?.dbName; - - if (!connId || !dbName) { - const allMsgText = messages.map(m => m.content || '').join(' '); - let bestMatch: { connectionId: string; dbName: string } | null = null; - let bestScore = 0; - for (const entry of toolContextMapRef.current.values()) { - let score = 0; - for (const table of entry.tables) { - if (allMsgText.includes(table)) score++; - } - if (score > bestScore) { - bestScore = score; - bestMatch = { connectionId: entry.connectionId, dbName: entry.dbName }; - } - } - if (bestMatch) { - if (!connId) connId = bestMatch.connectionId; - if (!dbName) dbName = bestMatch.dbName; - } - } - return { inferredConnectionId: connId, inferredDbName: dbName }; - }, [activeContext?.connectionId, activeContext?.dbName, messages.length]); + const { inferredConnectionId, inferredDbName } = useMemo( + () => inferAIChatConnectionContext({ + activeConnectionId: activeContext?.connectionId, + activeDbName: activeContext?.dbName, + messages, + toolContextEntries: toolContextMapRef.current.values(), + }), + [activeContext?.connectionId, activeContext?.dbName, messages], + ); // useMemo 缓存:避免内联闭包击穿子组件 memo const handleDeleteMessage = useCallback((id: string) => deleteAIChatMessage(sid, id), [sid, deleteAIChatMessage]); @@ -1294,51 +1281,33 @@ export const AIChatPanel: React.FC = ({ const connection = connections.find(c => c.id === inferredConnectionId); return connection ? buildRpcConnectionConfig(connection.config) : undefined; }, [inferredConnectionId, connections]); - const contextUsageChars = useMemo(() => - messages.reduce((sum, m) => sum + (m.content?.length || 0) + (m.reasoning_content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0), - [messages]); - const contextTableNames = useMemo(() => { - const ck = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default'; - return (aiContexts[ck] || []).map(c => `${c.dbName}.${c.tableName}`); - }, [activeContext?.connectionId, activeContext?.dbName, aiContexts]); - const aiInsights = useMemo(() => { - const recentLogs = sqlLogs.slice(0, 24); - const slowest = recentLogs - .filter((log) => log.status === 'success') - .sort((a, b) => b.duration - a.duration)[0]; - const errors = recentLogs.filter((log) => log.status === 'error'); - const writeCount = recentLogs.filter((log) => /\b(INSERT|UPDATE|DELETE|ALTER|DROP|CREATE)\b/i.test(log.sql)).length; - const contextCount = contextTableNames.length; - return [ - { - tone: 'info', - title: contextCount > 0 ? `已关联 ${contextCount} 张表` : '尚未关联表结构', - body: contextCount > 0 - ? `当前对话会带上 ${contextTableNames.slice(0, 3).join('、')}${contextCount > 3 ? ' 等表' : ''} 的结构上下文。` - : '在表页打开 AI 后会自动关联当前表,也可以在输入框上方手动添加上下文。', - }, - { - tone: slowest && slowest.duration > 1000 ? 'warn' : 'accent', - title: slowest ? `最近最慢查询 ${Math.round(slowest.duration).toLocaleString()}ms` : '暂无查询耗时样本', - body: slowest ? slowest.sql.slice(0, 140) : '执行查询后这里会显示可用于优化分析的 SQL 线索。', - }, - { - tone: errors.length > 0 ? 'warn' : 'info', - title: errors.length > 0 ? `${errors.length} 条最近查询失败` : '最近查询状态正常', - body: errors[0]?.message || (recentLogs.length > 0 ? `已记录 ${recentLogs.length} 条最近 SQL,可直接让 AI 解释或优化。` : '暂无 SQL 日志。'), - }, - { - tone: writeCount > 0 ? 'warn' : 'accent', - title: writeCount > 0 ? `检测到 ${writeCount} 条写操作` : '当前以只读分析为主', - body: writeCount > 0 ? '涉及写入的 SQL 建议先生成预览与回滚语句,再执行提交。' : 'AI 默认优先解释、生成 SELECT、分析 Schema 与优化索引。', - }, - ]; - }, [contextTableNames, sqlLogs]); + const contextUsageChars = useMemo( + () => calculateAIContextUsageChars(messages), + [messages], + ); + const contextTableNames = useMemo( + () => collectAIChatContextTableNames({ + aiContexts, + activeConnectionId: activeContext?.connectionId, + activeDbName: activeContext?.dbName, + }), + [activeContext?.connectionId, activeContext?.dbName, aiContexts], + ); + const aiInsights = useMemo( + () => buildAIChatInsights({ + contextTableNames, + sqlLogs, + }), + [contextTableNames, sqlLogs], + ); const panelHistorySessions = useMemo( - () => orderedAISessions.slice(0, 8), + () => buildAIChatInlineHistorySessions(orderedAISessions), [orderedAISessions], ); - const effectivePanelMode = isV2Ui ? activePanelMode : 'chat'; + const effectivePanelMode = useMemo( + () => resolveAIChatPanelMode(isV2Ui, activePanelMode), + [activePanelMode, isV2Ui], + ); return (
@@ -1392,88 +1361,44 @@ export const AIChatPanel: React.FC = ({ }} /> -
- {effectivePanelMode === 'chat' && ( - messages.length === 0 ? ( - { - setInput(prompt); - if (autoSend) { - // Use setTimeout to let setInput render, then trigger send - setTimeout(() => { - const el = textareaRef.current; - if (el) el.focus(); - // Dispatch a synthetic enter to trigger handleSend - // Simpler: just call handleSend directly with the prompt - }, 50); - } - }} - contextTableNames={contextTableNames} - isV2Ui={isV2Ui} - /> - ) : ( - messages.map(msg => ( - - - - )) - ) - )} - - { - setAIActiveSessionId(sessionId); - setActivePanelMode('chat'); - }} - /> - - -
-
- - {showScrollBottom && ( -
{ e.currentTarget.style.transform = 'scale(1.1)'; e.currentTarget.style.background = darkMode ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.1)'; }} - onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)'; }} - > - -
- )} + { + setInput(prompt); + if (autoSend) { + window.setTimeout(() => { + textareaRef.current?.focus(); + }, 50); + } + }} + onSelectSession={(sessionId) => { + setAIActiveSessionId(sessionId); + setActivePanelMode('chat'); + }} + onEditMessage={handleEditMessage} + onRetryMessage={handleRetryMessage} + onDeleteMessage={handleDeleteMessage} + onMessageRenderError={handleMessageRenderError} + onScrollBottom={scrollToMessagesBottom} + /> { + it('renders the welcome state when the chat mode has no messages', () => { + const markup = renderToStaticMarkup( + ()} + onScrollMessages={() => {}} + onQuickAction={() => {}} + onSelectSession={() => {}} + onEditMessage={() => {}} + onRetryMessage={() => {}} + onDeleteMessage={() => {}} + onMessageRenderError={() => {}} + onScrollBottom={() => {}} + />, + ); + + expect(markup).toContain('你好,我是 GoNavi AI'); + expect(markup).toContain('已自动关联'); + expect(markup).toContain('生成 SQL'); + }); + + it('renders inline history mode content and the scroll-bottom affordance', () => { + const markup = renderToStaticMarkup( + ()} + onScrollMessages={() => {}} + onQuickAction={() => {}} + onSelectSession={() => {}} + onEditMessage={() => {}} + onRetryMessage={() => {}} + onDeleteMessage={() => {}} + onMessageRenderError={() => {}} + onScrollBottom={() => {}} + />, + ); + + expect(markup).toContain('gn-v2-ai-history-card is-active'); + expect(markup).toContain('当前会话'); + expect(markup).toContain('旧会话'); + expect(markup).toContain('down'); + }); +}); diff --git a/frontend/src/components/ai/AIChatPanelConversationView.tsx b/frontend/src/components/ai/AIChatPanelConversationView.tsx new file mode 100644 index 0000000..771d076 --- /dev/null +++ b/frontend/src/components/ai/AIChatPanelConversationView.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { DownOutlined } from '@ant-design/icons'; + +import type { RpcConnectionConfig } from '../../utils/connectionRpcConfig'; +import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; +import type { AIChatMessage } from '../../types'; +import { AIChatWelcome } from './AIChatWelcome'; +import { AIMessageBubble } from './AIMessageBubble'; +import AIMessageRenderBoundary from './AIMessageRenderBoundary'; +import AIChatPanelModeContent, { + type AIChatInlineHistorySession, + type AIChatInsightItem, + type AIChatPanelMode, +} from './AIChatPanelModeContent'; + +interface AIChatPanelConversationViewProps { + mode: AIChatPanelMode; + messages: AIChatMessage[]; + darkMode: boolean; + overlayTheme: OverlayWorkbenchTheme; + textColor: string; + mutedColor: string; + quickActionBg: string; + quickActionBorder: string; + showScrollBottom: boolean; + contextTableNames: string[]; + isV2Ui: boolean; + insights: AIChatInsightItem[]; + sessions: AIChatInlineHistorySession[]; + activeSessionId: string; + activeConnectionId?: string; + activeConnectionConfig?: RpcConnectionConfig; + activeDbName?: string; + messagesEndRef: React.Ref; + onScrollMessages: (event: React.UIEvent) => void; + onQuickAction: (prompt: string, autoSend?: boolean) => void; + onSelectSession: (sessionId: string) => void; + onEditMessage: (message: AIChatMessage) => void; + onRetryMessage: (message: AIChatMessage) => void; + onDeleteMessage: (id: string) => void; + onMessageRenderError: (error: Error, errorInfo: React.ErrorInfo, message: AIChatMessage) => void; + onScrollBottom: () => void; +} + +const AIChatPanelConversationView: React.FC = ({ + mode, + messages, + darkMode, + overlayTheme, + textColor, + mutedColor, + quickActionBg, + quickActionBorder, + showScrollBottom, + contextTableNames, + isV2Ui, + insights, + sessions, + activeSessionId, + activeConnectionId, + activeConnectionConfig, + activeDbName, + messagesEndRef, + onScrollMessages, + onQuickAction, + onSelectSession, + onEditMessage, + onRetryMessage, + onDeleteMessage, + onMessageRenderError, + onScrollBottom, +}) => ( + <> +
+ {mode === 'chat' && ( + messages.length === 0 ? ( + + ) : ( + messages.map((message) => ( + + + + )) + ) + )} + + + +
+
+ + {showScrollBottom && ( +
{ + event.currentTarget.style.transform = 'scale(1.1)'; + event.currentTarget.style.background = darkMode ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.1)'; + }} + onMouseLeave={(event) => { + event.currentTarget.style.transform = 'scale(1)'; + event.currentTarget.style.background = darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)'; + }} + > + +
+ )} + +); + +export default AIChatPanelConversationView; diff --git a/frontend/src/components/ai/aiChatPanelDerivedState.test.ts b/frontend/src/components/ai/aiChatPanelDerivedState.test.ts new file mode 100644 index 0000000..269d54d --- /dev/null +++ b/frontend/src/components/ai/aiChatPanelDerivedState.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildAIChatInlineHistorySessions, + buildAIChatInsights, + calculateAIContextUsageChars, + collectAIChatContextTableNames, + inferAIChatConnectionContext, + resolveAIChatPanelMode, +} from './aiChatPanelDerivedState'; + +describe('aiChatPanelDerivedState', () => { + it('falls back to tool context matches when the active context is incomplete', () => { + const result = inferAIChatConnectionContext({ + activeConnectionId: '', + activeDbName: '', + messages: [ + { id: '1', role: 'user', content: '帮我看看 orders 和 order_items 的问题', timestamp: 1 }, + ], + toolContextEntries: [ + { connectionId: 'conn-customers', dbName: 'crm', tables: ['customers'] }, + { connectionId: 'conn-orders', dbName: 'sales', tables: ['orders', 'order_items'] }, + ], + }); + + expect(result).toEqual({ + inferredConnectionId: 'conn-orders', + inferredDbName: 'sales', + }); + }); + + it('builds insight cards from recent sql logs and linked table contexts', () => { + const insights = buildAIChatInsights({ + contextTableNames: ['sales.orders', 'sales.order_items', 'sales.customers', 'sales.payments'], + sqlLogs: [ + { + id: 'log-1', + timestamp: 1, + sql: 'SELECT * FROM orders', + status: 'success', + duration: 1520, + }, + { + id: 'log-2', + timestamp: 2, + sql: 'UPDATE orders SET status = 1', + status: 'error', + duration: 120, + message: 'Deadlock found', + }, + ], + }); + + expect(insights[0]).toMatchObject({ + tone: 'info', + title: '已关联 4 张表', + }); + expect(insights[0].body).toContain('sales.orders、sales.order_items、sales.customers'); + expect(insights[1]).toMatchObject({ + tone: 'warn', + title: '最近最慢查询 1,520ms', + }); + expect(insights[2]).toMatchObject({ + tone: 'warn', + title: '1 条最近查询失败', + body: 'Deadlock found', + }); + expect(insights[3]).toMatchObject({ + tone: 'warn', + title: '检测到 1 条写操作', + }); + }); + + it('collects context table names, usage chars, panel mode, and inline history sessions', () => { + expect(collectAIChatContextTableNames({ + aiContexts: { + 'conn-1:analytics': [ + { dbName: 'analytics', tableName: 'orders', ddl: 'create table orders (...)' }, + { dbName: 'analytics', tableName: 'events', ddl: 'create table events (...)' }, + ], + }, + activeConnectionId: 'conn-1', + activeDbName: 'analytics', + })).toEqual(['analytics.orders', 'analytics.events']); + + expect(calculateAIContextUsageChars([ + { + id: 'msg-1', + role: 'assistant', + content: 'abc', + reasoning_content: 'xy', + tool_calls: [{ id: 'tool-1', type: 'function', function: { name: 'inspect', arguments: '{}' } }], + timestamp: 1, + }, + ])).toBeGreaterThan(5); + + expect(buildAIChatInlineHistorySessions([ + { id: '1', title: 'one', updatedAt: 1 }, + { id: '2', title: 'two', updatedAt: 2 }, + { id: '3', title: 'three', updatedAt: 3 }, + ], 2)).toEqual([ + { id: '1', title: 'one', updatedAt: 1 }, + { id: '2', title: 'two', updatedAt: 2 }, + ]); + + expect(resolveAIChatPanelMode(true, 'history')).toBe('history'); + expect(resolveAIChatPanelMode(false, 'history')).toBe('chat'); + }); +}); diff --git a/frontend/src/components/ai/aiChatPanelDerivedState.ts b/frontend/src/components/ai/aiChatPanelDerivedState.ts new file mode 100644 index 0000000..586be67 --- /dev/null +++ b/frontend/src/components/ai/aiChatPanelDerivedState.ts @@ -0,0 +1,132 @@ +import type { SqlLog } from '../../store'; +import type { AIChatMessage, AIContextItem } from '../../types'; +import type { AIToolContextEntry } from './aiLocalToolExecutor'; +import type { AIChatInlineHistorySession, AIChatInsightItem, AIChatPanelMode } from './AIChatPanelModeContent'; + +interface InferAIChatConnectionContextArgs { + activeConnectionId?: string; + activeDbName?: string; + messages: AIChatMessage[]; + toolContextEntries: Iterable; +} + +interface CollectAIChatContextTableNamesArgs { + aiContexts: Record; + activeConnectionId?: string; + activeDbName?: string; +} + +interface BuildAIChatInsightsArgs { + contextTableNames: string[]; + sqlLogs: SqlLog[]; +} + +export const inferAIChatConnectionContext = ({ + activeConnectionId, + activeDbName, + messages, + toolContextEntries, +}: InferAIChatConnectionContextArgs) => { + let inferredConnectionId = activeConnectionId; + let inferredDbName = activeDbName; + + if (!inferredConnectionId || !inferredDbName) { + const allMsgText = messages.map((item) => item.content || '').join(' '); + let bestMatch: { connectionId: string; dbName: string } | null = null; + let bestScore = 0; + + for (const entry of toolContextEntries) { + let score = 0; + for (const table of entry.tables) { + if (allMsgText.includes(table)) { + score += 1; + } + } + if (score > bestScore) { + bestScore = score; + bestMatch = { connectionId: entry.connectionId, dbName: entry.dbName }; + } + } + + if (bestMatch) { + if (!inferredConnectionId) { + inferredConnectionId = bestMatch.connectionId; + } + if (!inferredDbName) { + inferredDbName = bestMatch.dbName; + } + } + } + + return { + inferredConnectionId, + inferredDbName, + }; +}; + +export const calculateAIContextUsageChars = (messages: AIChatMessage[]) => + messages.reduce( + (sum, item) => + sum + + (item.content?.length || 0) + + (item.reasoning_content?.length || 0) + + JSON.stringify(item.tool_calls || []).length, + 0, + ); + +export const collectAIChatContextTableNames = ({ + aiContexts, + activeConnectionId, + activeDbName, +}: CollectAIChatContextTableNamesArgs) => { + const contextKey = activeConnectionId ? `${activeConnectionId}:${activeDbName || ''}` : 'default'; + return (aiContexts[contextKey] || []).map((item) => `${item.dbName}.${item.tableName}`); +}; + +export const buildAIChatInsights = ({ + contextTableNames, + sqlLogs, +}: BuildAIChatInsightsArgs): AIChatInsightItem[] => { + const recentLogs = sqlLogs.slice(0, 24); + const slowest = recentLogs + .filter((log) => log.status === 'success') + .sort((left, right) => right.duration - left.duration)[0]; + const errors = recentLogs.filter((log) => log.status === 'error'); + const writeCount = recentLogs.filter((log) => /\b(INSERT|UPDATE|DELETE|ALTER|DROP|CREATE)\b/i.test(log.sql)).length; + const contextCount = contextTableNames.length; + + return [ + { + tone: 'info', + title: contextCount > 0 ? `已关联 ${contextCount} 张表` : '尚未关联表结构', + body: contextCount > 0 + ? `当前对话会带上 ${contextTableNames.slice(0, 3).join('、')}${contextCount > 3 ? ' 等表' : ''} 的结构上下文。` + : '在表页打开 AI 后会自动关联当前表,也可以在输入框上方手动添加上下文。', + }, + { + tone: slowest && slowest.duration > 1000 ? 'warn' : 'accent', + title: slowest ? `最近最慢查询 ${Math.round(slowest.duration).toLocaleString()}ms` : '暂无查询耗时样本', + body: slowest ? slowest.sql.slice(0, 140) : '执行查询后这里会显示可用于优化分析的 SQL 线索。', + }, + { + tone: errors.length > 0 ? 'warn' : 'info', + title: errors.length > 0 ? `${errors.length} 条最近查询失败` : '最近查询状态正常', + body: errors[0]?.message || (recentLogs.length > 0 ? `已记录 ${recentLogs.length} 条最近 SQL,可直接让 AI 解释或优化。` : '暂无 SQL 日志。'), + }, + { + tone: writeCount > 0 ? 'warn' : 'accent', + title: writeCount > 0 ? `检测到 ${writeCount} 条写操作` : '当前以只读分析为主', + body: writeCount > 0 ? '涉及写入的 SQL 建议先生成预览与回滚语句,再执行提交。' : 'AI 默认优先解释、生成 SELECT、分析 Schema 与优化索引。', + }, + ]; +}; + +export const buildAIChatInlineHistorySessions = ( + sessions: AIChatInlineHistorySession[], + limit = 8, +) => sessions.slice(0, limit); + +export const resolveAIChatPanelMode = ( + isV2Ui: boolean, + activePanelMode: AIChatPanelMode, +): AIChatPanelMode => (isV2Ui ? activePanelMode : 'chat');