diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/WrapperRow.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperRow.tsx index b79a47aa27503..f64438c7d7d9d 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/WrapperRow.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperRow.tsx @@ -8,7 +8,16 @@ import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import type { FDW } from 'data/fdw/fdws-query' import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' -import { Badge, Sheet, SheetContent, TableCell, TableRow } from 'ui' +import { + Badge, + Sheet, + SheetContent, + TableCell, + TableRow, + Tooltip, + TooltipContent, + TooltipTrigger, +} from 'ui' import { INTEGRATIONS } from '../Landing/Integrations.constants' import DeleteWrapperModal from './DeleteWrapperModal' import { EditWrapperSheet } from './EditWrapperSheet' @@ -74,7 +83,12 @@ const WrapperRow = ({ wrapper }: WrapperRowProps) => {
{integration.icon({ className: 'p-0' })}
- {target}{' '} + + {target} + + {target} + + { - {table.schema}.{table.table_name} + + + {table.schema}.{table.table_name} + + + {table.schema}.{table.table_name} + + diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx index ad98e71ee94b9..b189896edd748 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -42,6 +42,7 @@ const MemoizedMessage = memo( message, isLoading, onResults, + onDelete, onEdit, isAfterEditedMessage, isBeingEdited, @@ -58,6 +59,7 @@ const MemoizedMessage = memo( resultId?: string results: any[] }) => void + onDelete: (id: string) => void onEdit: (id: string) => void isAfterEditedMessage: boolean isBeingEdited: boolean @@ -70,6 +72,7 @@ const MemoizedMessage = memo( readOnly={message.role === 'user'} isLoading={isLoading} onResults={onResults} + onDelete={onDelete} onEdit={onEdit} isAfterEditedMessage={isAfterEditedMessage} isBeingEdited={isBeingEdited} @@ -105,7 +108,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { ) const inputRef = useRef(null) - const { ref: scrollContainerRef, isSticky, scrollToEnd } = useAutoScroll() + const { ref: scrollContainerRef, isSticky, scrollToEnd, setIsSticky } = useAutoScroll() const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel() const showMetadataWarning = @@ -119,6 +122,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const [value, setValue] = useState(snap.initialInput || '') const [isConfirmOptInModalOpen, setIsConfirmOptInModalOpen] = useState(false) const [editingMessageId, setEditingMessageId] = useState(null) + const [isResubmitting, setIsResubmitting] = useState(false) const { data: check, isSuccess } = useCheckOpenAIKeyQuery() const isApiKeySet = IS_PLATFORM || !!check?.hasKey @@ -232,7 +236,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { onFinish: handleChatFinish, }) - const isChatLoading = chatStatus === 'submitted' || chatStatus === 'streaming' + const isChatLoading = chatStatus === 'submitted' || chatStatus === 'streaming' || isResubmitting const updateMessage = useCallback( ({ @@ -249,6 +253,22 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { [snap] ) + const deleteMessageFromHere = useCallback( + (messageId: string) => { + // Find the message index in current chatMessages + const messageIndex = chatMessages.findIndex((msg) => msg.id === messageId) + if (messageIndex === -1) return + + if (isChatLoading) stop() + + snap.deleteMessagesAfter(messageId, { includeSelf: true }) + + const updatedMessages = chatMessages.slice(0, messageIndex) + setMessages(updatedMessages) + }, + [snap, setMessages, chatMessages, isChatLoading, stop] + ) + const editMessage = useCallback( (messageId: string) => { const messageIndex = chatMessages.findIndex((msg) => msg.id === messageId) @@ -266,9 +286,16 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { .join('') ?? '' setValue(textContent) - if (inputRef.current) { - inputRef.current.focus() - } + setTimeout(() => { + if (inputRef.current) { + inputRef?.current?.focus() + + // [Joshen] This is just to make the cursor go to the end of the text when focusing + const val = inputRef.current.value + inputRef.current.value = '' + inputRef.current.value = val + } + }, 100) }, [chatMessages, setValue] ) @@ -292,6 +319,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { message={message} isLoading={isChatLoading && message.id === chatMessages[chatMessages.length - 1].id} onResults={updateMessage} + onDelete={deleteMessageFromHere} onEdit={editMessage} isAfterEditedMessage={isAfterEditedMessage} isBeingEdited={isBeingEdited} @@ -299,7 +327,15 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { /> ) }), - [chatMessages, isChatLoading, updateMessage, editMessage, editingMessageId, cancelEdit] + [ + chatMessages, + isChatLoading, + updateMessage, + deleteMessageFromHere, + editMessage, + cancelEdit, + editingMessageId, + ] ) const hasMessages = chatMessages.length > 0 @@ -308,12 +344,9 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const sendMessageToAssistant = (finalContent: string) => { if (editingMessageId) { // Handling when the user is in edit mode - const messageIndex = chatMessages.findIndex((msg) => msg.id === editingMessageId) - if (messageIndex === -1) return - - snap.deleteMessagesAfter(editingMessageId, { includeSelf: true }) - const updatedMessages = chatMessages.slice(0, messageIndex) - setMessages(updatedMessages) + // delete the message(s) from the chat just like the delete button + setIsResubmitting(true) + deleteMessageFromHere(editingMessageId) setEditingMessageId(null) } @@ -355,6 +388,14 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { setEditingMessageId(null) } + useEffect(() => { + // Keep "Thinking" visible while stopping and resubmitting during edit + // Only clear once the new response actually starts streaming (or errors) + if (isResubmitting && (chatStatus === 'streaming' || !!error)) { + setIsResubmitting(false) + } + }, [isResubmitting, chatStatus, error]) + // Update scroll behavior for new messages useEffect(() => { if (!isChatLoading) { @@ -381,6 +422,10 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [snap.open, isInSQLEditor, snippetContent]) + useEffect(() => { + if (chatMessages.length === 0 && !isSticky) setIsSticky(true) + }, [chatMessages, isSticky, setIsSticky]) + return ( { 'z-20 [&>form>textarea]:text-base [&>form>textarea]:md:text-sm [&>form>textarea]:border-1 [&>form>textarea]:rounded-md [&>form>textarea]:!outline-none [&>form>textarea]:!ring-offset-0 [&>form>textarea]:!ring-0' )} loading={isChatLoading} - disabled={!isApiKeySet || disablePrompts || isChatLoading} + isEditing={!!editingMessageId} + disabled={!isApiKeySet || disablePrompts || (isChatLoading && !editingMessageId)} placeholder={ hasMessages ? 'Ask a follow up question...' diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx index 58c954f9a091d..edf949772a2b4 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx @@ -41,6 +41,8 @@ export interface FormProps { snippetsClassName?: string /* Additional class name for the form wrapper */ className?: string + /* If currently editing an existing message */ + isEditing?: boolean } const AssistantChatFormComponent = forwardRef( @@ -59,6 +61,7 @@ const AssistantChatFormComponent = forwardRef( snippetsClassName, includeSnippetsInMessage = false, className, + isEditing = false, ...props }, ref @@ -68,7 +71,7 @@ const AssistantChatFormComponent = forwardRef( const handleSubmit = (event?: FormEvent) => { if (event) event.preventDefault() - if (!value || loading) return + if (!value || (loading && !isEditing)) return let finalMessage = value if (includeSnippetsInMessage && sqlSnippets && sqlSnippets.length > 0) { @@ -107,8 +110,8 @@ const AssistantChatFormComponent = forwardRef( /> )} void + onCancel: () => void +} + +export const DeleteMessageConfirmModal = ({ + visible, + onConfirm, + onCancel, +}: DeleteMessageConfirmModalProps) => { + const onOpenChange = (open: boolean) => { + if (!open) onCancel() + } + + return ( + + + + Delete Message + + + + + +

+ Are you sure you want to delete this message and all subsequent messages? This action + cannot be undone. +

+
+ + + + + +
+
+ ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.tsx index 3a295e2d6ce25..c30bb9776a6a5 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.tsx @@ -1,9 +1,10 @@ import { UIMessage as VercelMessage } from '@ai-sdk/react' -import { Loader2, Pencil } from 'lucide-react' -import { createContext, PropsWithChildren, ReactNode, useMemo } from 'react' +import { Loader2, Pencil, Trash2 } from 'lucide-react' +import { createContext, PropsWithChildren, ReactNode, useMemo, useState } from 'react' import ReactMarkdown from 'react-markdown' import { Components } from 'react-markdown/lib/ast-to-react' import remarkGfm from 'remark-gfm' +import { toast } from 'sonner' import { ProfileImage } from 'components/ui/ProfileImage' import { useProfile } from 'lib/profile' @@ -11,6 +12,7 @@ import { cn, markdownComponents, WarningIcon } from 'ui' import { ButtonTooltip } from '../ButtonTooltip' import { EdgeFunctionBlock } from '../EdgeFunctionBlock/EdgeFunctionBlock' import { DisplayBlockRenderer } from './DisplayBlockRenderer' +import { DeleteMessageConfirmModal } from './DeleteMessageConfirmModal' import { Heading3, Hyperlink, @@ -51,6 +53,7 @@ interface MessageProps { resultId?: string results: any[] }) => void + onDelete: (id: string) => void onEdit: (id: string) => void isAfterEditedMessage: boolean isBeingEdited: boolean @@ -65,12 +68,14 @@ export const Message = function Message({ action = null, variant = 'default', onResults, + onDelete, onEdit, isAfterEditedMessage = false, isBeingEdited = false, onCancelEdit, }: PropsWithChildren) { const { profile } = useProfile() + const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false) const allMarkdownComponents: Partial = useMemo( () => ({ ...markdownComponents, @@ -237,30 +242,54 @@ export const Message = function Message({ Assistant is thinking... )} - {/* Action button - only show for user messages on hover */} -
+ {/* Action buttons - only show for user messages on hover */} +
{message.role === 'user' && ( - } - onClick={isBeingEdited || isAfterEditedMessage ? onCancelEdit : () => onEdit(id)} - className="text-foreground-light hover:text-foreground p-1 rounded" - aria-label={ - isBeingEdited || isAfterEditedMessage ? 'Cancel editing' : 'Edit message' - } - tooltip={{ - content: { - side: 'bottom', - text: - isBeingEdited || isAfterEditedMessage ? 'Cancel editing' : 'Edit message', - }, - }} - /> + <> + } + onClick={ + isBeingEdited || isAfterEditedMessage ? onCancelEdit : () => onEdit(id) + } + className="text-foreground-light hover:text-foreground p-1 rounded" + aria-label={ + isBeingEdited || isAfterEditedMessage ? 'Cancel editing' : 'Edit message' + } + tooltip={{ + content: { + side: 'bottom', + text: + isBeingEdited || isAfterEditedMessage ? 'Cancel editing' : 'Edit message', + }, + }} + /> + + } + tooltip={{ content: { side: 'bottom', text: 'Delete message' } }} + onClick={() => setShowDeleteConfirmModal(true)} + className="text-foreground-light hover:text-foreground p-1 rounded" + title="Delete message" + aria-label="Delete message" + /> + )}
+ + { + onDelete(id) + setShowDeleteConfirmModal(false) + toast.success('Message deleted successfully') + }} + onCancel={() => setShowDeleteConfirmModal(false)} + /> ) } diff --git a/apps/studio/components/ui/AIAssistantPanel/hooks.ts b/apps/studio/components/ui/AIAssistantPanel/hooks.ts index 507c0708e5572..9d5c9b231ae0f 100644 --- a/apps/studio/components/ui/AIAssistantPanel/hooks.ts +++ b/apps/studio/components/ui/AIAssistantPanel/hooks.ts @@ -69,5 +69,5 @@ export function useAutoScroll({ enabled = true }: UseAutoScrollProps = {}) { } }, [container, enabled, scrollToEnd]) - return { ref, isSticky, scrollToEnd } + return { ref, isSticky, scrollToEnd, setIsSticky } } diff --git a/apps/studio/csp.js b/apps/studio/csp.js index 69108c1e06e09..c184c789d86d8 100644 --- a/apps/studio/csp.js +++ b/apps/studio/csp.js @@ -31,6 +31,8 @@ const isDevOrStaging = process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' || process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' +const NIMBUS_STAGING_PROJECTS_URL = 'https://*.supabase-nimbus-projects.com' +const NIMBUS_STAGING_PROJECTS_URL_WS = 'wss://*.supabase-nimbus-projects.com' const SUPABASE_STAGING_PROJECTS_URL = 'https://*.supabase.red' const SUPABASE_STAGING_PROJECTS_URL_WS = 'wss://*.supabase.red' const SUPABASE_COM_URL = 'https://supabase.com' @@ -126,6 +128,8 @@ module.exports.getCSP = function getCSP() { ? [ SUPABASE_STAGING_PROJECTS_URL, SUPABASE_STAGING_PROJECTS_URL_WS, + NIMBUS_STAGING_PROJECTS_URL, + NIMBUS_STAGING_PROJECTS_URL_WS, VERCEL_LIVE_URL, SUPABASE_DOCS_PROJECT_URL, SUPABASE_CONTENT_API_URL, @@ -140,7 +144,9 @@ module.exports.getCSP = function getCSP() { `blob:`, `data:`, ...IMG_SRC_URLS, - ...(isDevOrStaging ? [SUPABASE_STAGING_PROJECTS_URL, VERCEL_URL] : []), + ...(isDevOrStaging + ? [SUPABASE_STAGING_PROJECTS_URL, NIMBUS_STAGING_PROJECTS_URL, VERCEL_URL] + : []), ].join(' ') const scriptSrcDirective = [ diff --git a/apps/studio/instrumentation-client.ts b/apps/studio/instrumentation-client.ts index 1c07a5b3c93d4..e1543a0e1507f 100644 --- a/apps/studio/instrumentation-client.ts +++ b/apps/studio/instrumentation-client.ts @@ -152,3 +152,6 @@ function standardiseRouterUrl(url: string) { return finalUrl } + +// This export will instrument router navigations, and is only relevant if you enable tracing. +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart diff --git a/turbo.json b/turbo.json index 0409cabc4c9ae..cefe8e664e628 100644 --- a/turbo.json +++ b/turbo.json @@ -62,6 +62,7 @@ "NEXT_PUBLIC_GOTRUE_URL", "NEXT_PUBLIC_VERCEL_BRANCH_URL", "NEXT_PUBLIC_GOOGLE_MAPS_KEY", + "NEXT_RUNTIME", "NODE_ENV", "SUPABASE_URL", // These envs are used in the packages