diff --git a/apps/studio/components/interfaces/BranchManagement/ReviewWithAI.tsx b/apps/studio/components/interfaces/BranchManagement/ReviewWithAI.tsx new file mode 100644 index 00000000000..0045cb8b350 --- /dev/null +++ b/apps/studio/components/interfaces/BranchManagement/ReviewWithAI.tsx @@ -0,0 +1,123 @@ +import { AiIconAnimation } from 'ui' +import { useProjectByRef } from 'hooks/misc/useSelectedProject' +import { useTablesQuery } from 'data/tables/tables-query' +import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' +import { Branch } from 'data/branches/branches-query' +import { tablesToSQL } from 'lib/helpers' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' + +interface ReviewWithAIProps { + currentBranch?: Branch + mainBranch?: Branch + parentProjectRef?: string + diffContent?: string + disabled?: boolean +} + +export const ReviewWithAI = ({ + currentBranch, + mainBranch, + parentProjectRef, + diffContent, + disabled = false, +}: ReviewWithAIProps) => { + const aiSnap = useAiAssistantStateSnapshot() + + // Get parent project for production schema + const parentProject = useProjectByRef(parentProjectRef) + + // Fetch production schema tables + const { data: productionTables } = useTablesQuery( + { + projectRef: parentProjectRef, + connectionString: (parentProject as any)?.connectionString, + schema: 'public', + includeColumns: true, + }, + { enabled: !!parentProjectRef && !!parentProject } + ) + + const handleReviewWithAssistant = () => { + if (!currentBranch || !mainBranch) return + + // Prepare diff content for the assistant + const sqlSnippets = [] + + // Add production schema SQL if available + if (productionTables && productionTables.length > 0) { + const productionSQL = tablesToSQL(productionTables) + if (productionSQL.trim()) { + sqlSnippets.push({ + label: 'Production Schema', + content: productionSQL, + }) + } + } + + // Add database diff content if available + if (diffContent && diffContent.trim()) { + sqlSnippets.push({ + label: 'Database Changes', + content: '-- DATABASE CHANGES:\n' + diffContent, + }) + } + + aiSnap.newChat({ + name: `Review merge: ${currentBranch.name} → ${mainBranch.name}`, + open: true, + sqlSnippets: sqlSnippets.length > 0 ? sqlSnippets : undefined, + initialInput: `Please review this merge request from branch "${currentBranch.name}" into "${mainBranch.name || 'main'}". + +I've included the current production schema as context, along with the proposed database changes. + +Analyze the changes and provide feedback on: +- Database schema changes and potential impacts on the production schema +- Migration safety and rollback considerations +- Overall code quality and best practices +- Potential breaking changes or compatibility issues +- Data integrity and constraint implications + +Please be concise with your response.`, + suggestions: { + title: `I can help you review the database schema changes from "${currentBranch.name}" to "${mainBranch.name}", here are some specific areas I can focus on:`, + prompts: [ + { + label: 'Schema Impact', + description: + 'Analyze the database schema changes and their potential impact on production...', + }, + { + label: 'Migration Safety', + description: 'Review the migration safety and rollback strategies...', + }, + { + label: 'Performance', + description: 'Analyze potential performance implications of these changes...', + }, + { + label: 'Data Integrity', + description: 'Review constraints, indexes, and data integrity implications...', + }, + ], + }, + }) + } + + return ( + + + Review with Assistant + + ) +} diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx index ae81db255ce..6be17b8c501 100644 --- a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx @@ -22,6 +22,7 @@ import { SchemaGraphLegend } from './SchemaGraphLegend' import { getGraphDataFromTables, getLayoutedElementsViaDagre } from './Schemas.utils' import { TableNode } from './SchemaTableNode' import { copyToClipboard } from 'ui' +import { tablesToSQL } from 'lib/helpers' // [Joshen] Persisting logic: Only save positions to local storage WHEN a node is moved OR when explicitly clicked to reset layout export const SchemaGraph = () => { @@ -167,59 +168,6 @@ export const SchemaGraph = () => { } } - function tablesToSQL(t: typeof tables) { - if (!Array.isArray(t)) return '' - const warning = - '-- WARNING: This schema is for context only and is not meant to be run.\n-- Table order and constraints may not be valid for execution.\n\n' - const sql = t - .map((table) => { - if (!table || !Array.isArray((table as any).columns)) return '' - - const columns = (table as { columns?: any[] }).columns ?? [] - const columnLines = columns.map((c) => { - let line = ` ${c.name} ${c.data_type}` - if (c.is_identity) { - line += ' GENERATED ALWAYS AS IDENTITY' - } - if (c.is_nullable === false) { - line += ' NOT NULL' - } - if (c.default_value !== null && c.default_value !== undefined) { - line += ` DEFAULT ${c.default_value}` - } - if (c.is_unique) { - line += ' UNIQUE' - } - if (c.check) { - line += ` CHECK (${c.check})` - } - return line - }) - - const constraints: string[] = [] - - if (Array.isArray(table.primary_keys) && table.primary_keys.length > 0) { - const pkCols = table.primary_keys.map((pk) => pk.name).join(', ') - constraints.push(` CONSTRAINT ${table.name}_pkey PRIMARY KEY (${pkCols})`) - } - - if (Array.isArray(table.relationships)) { - table.relationships.forEach((rel) => { - if (rel && rel.source_table_name === table.name) { - constraints.push( - ` CONSTRAINT ${rel.constraint_name} FOREIGN KEY (${rel.source_column_name}) REFERENCES ${rel.target_table_schema}.${rel.target_table_name}(${rel.target_column_name})` - ) - } - }) - } - - const allLines = [...columnLines, ...constraints] - return `CREATE TABLE ${table.schema}.${table.name} (\n${allLines.join(',\n')}\n);` - }) - .join('\n') - return warning + sql - } - useEffect(() => { if (isSuccessTables && isSuccessSchemas && tables.length > 0) { const schema = schemas.find((s) => s.name === selectedSchema) as PostgresSchema diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx index 04f9fde6376..3b6d8e320e0 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -30,6 +30,7 @@ import { AIAssistantChatSelector } from './AIAssistantChatSelector' import { AIOnboarding } from './AIOnboarding' import { AIOptInModal } from './AIOptInModal' import { AssistantChatForm } from './AssistantChatForm' +import { type SqlSnippet } from './AIAssistant.types' import { Message } from './Message' import { useAutoScroll } from './hooks' import type { AssistantMessageType } from 'state/ai-assistant-state' @@ -305,7 +306,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { useEffect(() => { if (snap.open && isInSQLEditor && !!snippetContent) { - snap.setSqlSnippets([snippetContent]) + snap.setSqlSnippets([{ label: 'Current Query', content: snippetContent }]) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [snap.open, isInSQLEditor, snippetContent]) @@ -483,7 +484,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { onMessageSend={sendMessageToAssistant} value={value} onValueChange={setValue} - sqlSnippets={snap.sqlSnippets as string[] | undefined} + sqlSnippets={snap.sqlSnippets as SqlSnippet[] | undefined} onRemoveSnippet={(index) => { const newSnippets = [...(snap.sqlSnippets ?? [])] newSnippets.splice(index, 1) @@ -581,7 +582,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { sendMessageToAssistant(finalMessage) scrollToEnd() }} - sqlSnippets={snap.sqlSnippets as string[] | undefined} + sqlSnippets={snap.sqlSnippets as SqlSnippet[] | undefined} onRemoveSnippet={(index) => { const newSnippets = [...(snap.sqlSnippets ?? [])] newSnippets.splice(index, 1) diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts index ea3611c4133..b87d413d76a 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts @@ -10,3 +10,5 @@ export interface AssistantSnippetProps { yAxis?: string name?: string } + +export type SqlSnippet = string | { label: string; content: string } diff --git a/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx b/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx index a8b6a505c18..d291245a00a 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx @@ -4,11 +4,12 @@ import { useRef } from 'react' import { Button, cn } from 'ui' import { AssistantChatForm } from './AssistantChatForm' +import { type SqlSnippet } from './AIAssistant.types' import { codeSnippetPrompts, defaultPrompts } from './AIAssistant.prompts' interface AIOnboardingProps { onMessageSend: (message: string) => void - sqlSnippets?: string[] + sqlSnippets?: SqlSnippet[] onRemoveSnippet?: (index: number) => void suggestions?: { title?: string diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx index 59387788362..b8ed66480cc 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx @@ -5,7 +5,8 @@ import { ArrowUp, Loader2 } from 'lucide-react' import React, { ChangeEvent, memo, useRef } from 'react' import { Button, ExpandingTextArea } from 'ui' import { cn } from 'ui/src/lib/utils' -import { SnippetRow } from './SnippetRow' +import { SnippetRow, getSnippetContent } from './SnippetRow' +import { type SqlSnippet } from './AIAssistant.types' export interface FormProps { /* The ref for the textarea, optional. Exposed for the CommandsPopover to attach events. */ @@ -28,8 +29,8 @@ export interface FormProps { onSubmit: (message: string) => void /* The placeholder of the textarea */ placeholder?: string - /* SQL snippets to display above the form */ - sqlSnippets?: string[] + /* SQL snippets to display above the form - can be strings or objects with label and content */ + sqlSnippets?: SqlSnippet[] /* Function to handle removing a SQL snippet */ onRemoveSnippet?: (index: number) => void /* Additional class name for the snippets container */ @@ -67,7 +68,7 @@ const AssistantChatFormComponent = React.forwardRef( let finalMessage = value if (includeSnippetsInMessage && sqlSnippets && sqlSnippets.length > 0) { const sqlSnippetsString = sqlSnippets - .map((snippet: string) => '```sql\n' + snippet + '\n```') + .map((snippet: SqlSnippet) => '```sql\n' + getSnippetContent(snippet) + '\n```') .join('\n') finalMessage = [value, sqlSnippetsString].filter(Boolean).join('\n\n') } diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.tsx index 5c9241c423e..7dfe236f8ec 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.tsx @@ -121,7 +121,7 @@ export const Message = function Message({ p:not(:last-child)]:!mb-2 [&>*>p:first-child]:!mt-0 [&>*>p:last-child]:!mb-0 [&>*>*>p:first-child]:!mt-0 [&>*>*>p:last-child]:!mb-0 [&>ol>li]:!pl-4', + 'prose prose-sm [&>div]:my-4 prose-h1:text-xl prose-h1:mt-6 prose-h3:no-underline prose-h3:text-base prose-h3:mb-4 prose-strong:font-medium prose-strong:text-foreground break-words [&>p:not(:last-child)]:!mb-2 [&>*>p:first-child]:!mt-0 [&>*>p:last-child]:!mb-0 [&>*>*>p:first-child]:!mt-0 [&>*>*>p:last-child]:!mb-0 [&>ol>li]:!pl-4', isUser && 'text-foreground [&>p]:font-medium' )} remarkPlugins={[remarkGfm]} diff --git a/apps/studio/components/ui/AIAssistantPanel/SnippetRow.tsx b/apps/studio/components/ui/AIAssistantPanel/SnippetRow.tsx index dbfc5b9de9a..76e1eebef4e 100644 --- a/apps/studio/components/ui/AIAssistantPanel/SnippetRow.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/SnippetRow.tsx @@ -2,9 +2,24 @@ import React from 'react' import { Button, CodeBlock } from 'ui' import { HoverCard_Shadcn_, HoverCardTrigger_Shadcn_, HoverCardContent_Shadcn_ } from 'ui' import { X } from 'lucide-react' +import { type SqlSnippet } from './AIAssistant.types' + +export const getSnippetLabel = (snippet: SqlSnippet, index: number): string => { + if (typeof snippet === 'string') { + return `Snippet ${index + 1}` + } + return snippet.label +} + +export const getSnippetContent = (snippet: SqlSnippet): string => { + if (typeof snippet === 'string') { + return snippet + } + return snippet.content +} interface SnippetRowProps { - snippets: string[] + snippets: SqlSnippet[] onRemoveSnippet?: (index: number) => void className?: string } @@ -17,15 +32,15 @@ export const SnippetRow: React.FC = ({ if (!snippets || snippets.length === 0) return null return ( -
+
{snippets.map((snippet, idx) => (
- Snippet {idx + 1} + {getSnippetLabel(snippet, idx)} {onRemoveSnippet && (