diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx
index 2d897e8b14e..7695b58f998 100644
--- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx
+++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx
@@ -5,6 +5,7 @@ import { LOCAL_STORAGE_KEYS } from 'lib/constants'
import { EMPTY_OBJ } from 'lib/void'
import { APISidePanelPreview } from './APISidePanelPreview'
import { CLSPreview } from './CLSPreview'
+import InlineEditorPreview from './InlineEditorPreview'
export const FEATURE_PREVIEWS = [
{
@@ -21,6 +22,13 @@ export const FEATURE_PREVIEWS = [
discussionsUrl: 'https://github.com/orgs/supabase/discussions/20295',
isNew: false,
},
+ {
+ key: LOCAL_STORAGE_KEYS.UI_PREVIEW_INLINE_EDITOR,
+ name: 'Inline SQL Editor',
+ content: ,
+ discussionsUrl: 'https://github.com/orgs/supabase/discussions/33690',
+ isNew: true,
+ },
]
type FeaturePreviewContextType = {
@@ -77,3 +85,8 @@ export const useIsColumnLevelPrivilegesEnabled = () => {
const { flags } = useFeaturePreviewContext()
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]
}
+
+export const useIsInlineEditorEnabled = () => {
+ const { flags } = useFeaturePreviewContext()
+ return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_INLINE_EDITOR]
+}
diff --git a/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx
new file mode 100644
index 00000000000..24671a64cac
--- /dev/null
+++ b/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx
@@ -0,0 +1,33 @@
+import Image from 'next/image'
+
+import { BASE_PATH } from 'lib/constants'
+import { Admonition } from 'ui-patterns'
+
+const InlineEditorPreview = () => {
+ return (
+
+
+
+ Access an inline SQL editor where you can write and run queries wherever you are in the
+ dashboard. Use the inline Assistant to generate or modify queries without leaving the
+ editor.
+
+
+ With the inline editor enabled, editing policies, triggers and database functions will all
+ be done using the editor.
+
+
+ You can access the inline editor by clicking the code editor icon in the top right corner of
+ your dashboard.
+
+
+ )
+}
+
+export default InlineEditorPreview
diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx
index b36d37dead4..d67bd4af8db 100644
--- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx
+++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx
@@ -5,7 +5,6 @@ import { MutableRefObject, useEffect, useRef } from 'react'
import { cn } from 'ui'
import { Markdown } from 'components/interfaces/Markdown'
-import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { formatSql } from 'lib/formatSql'
// [Joshen] Is there a way we can just have one single MonacoEditor component that's shared across the dashboard?
@@ -50,7 +49,6 @@ export const RLSCodeEditor = ({
}: RLSCodeEditorProps) => {
const hasValue = useRef()
const monaco = useMonaco()
- const { project } = useProjectContext()
const placeholderId = `monaco-placeholder-${id}`
const options: editor.IStandaloneEditorConstructionOptions = {
diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils.ts b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils.ts
index 09c9b82e3c9..dcbcbc3a7c3 100644
--- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils.ts
+++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils.ts
@@ -12,7 +12,7 @@ export const generatePolicyCreateSQL = (policy: PostgresPolicy) => {
}
return `
-CREATE POLICY "${policy.name}"
+ALTER POLICY "${policy.name}"
ON "${policy.schema}"."${policy.table}"
AS ${policy.action}
FOR ${policy.command}
diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx
index 77204da34d0..ccd2a5fd7ae 100644
--- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx
+++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx
@@ -117,7 +117,7 @@ const PolicyTableRowHeader = ({
onClick={() => {
setAiAssistantPanel({
open: true,
- initialInput: `Create a new policy for the ${table.schema} schema on the ${table.name} table that ...`,
+ initialInput: `Create and name a new policy for the ${table.schema} schema on the ${table.name} table that ...`,
})
}}
tooltip={{
diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx
index ee072d51856..eca9d8a0a25 100644
--- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx
+++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx
@@ -139,7 +139,13 @@ const TriggerList = ({
} />
- editTrigger(x)}>
+ {
+ const sql = generateTriggerCreateSQL(x)
+ editTrigger(x)
+ }}
+ >
Edit trigger
diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.utils.ts b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.utils.ts
index 81f67c6d71d..5d4fcb50ea9 100644
--- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.utils.ts
+++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.utils.ts
@@ -18,7 +18,11 @@ export const generateTriggerCreateSQL = (trigger: PostgresTrigger) => {
const events = trigger.events.join(' OR ')
const args = trigger.function_args.length > 0 ? `(${trigger.function_args.join(', ')})` : '()'
+ // Note: CREATE OR REPLACE is not supported for triggers
+ // We need to drop the existing trigger first if we want to replace it
let sql = `
+DROP TRIGGER IF EXISTS "${trigger.name}" ON "${trigger.schema}"."${trigger.table}";
+
CREATE TRIGGER "${trigger.name}"
${trigger.activation} ${events}
ON "${trigger.schema}"."${trigger.table}"
diff --git a/apps/studio/components/interfaces/SQLEditor/AskAIWidget.tsx b/apps/studio/components/interfaces/SQLEditor/AskAIWidget.tsx
index 7030c9e2561..d44429b232e 100644
--- a/apps/studio/components/interfaces/SQLEditor/AskAIWidget.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/AskAIWidget.tsx
@@ -1,7 +1,6 @@
-import { detectOS } from 'lib/helpers'
-import { ArrowDownLeft, Loader2, Wand } from 'lucide-react'
-import { useCallback } from 'react'
-import { Input, Button } from 'ui'
+import { ChangeEvent, KeyboardEvent, useCallback, useEffect, useRef } from 'react'
+
+import { Button, ExpandingTextArea } from 'ui'
interface AskAIWidgetProps {
value: string
@@ -9,6 +8,7 @@ interface AskAIWidgetProps {
onSubmit: (prompt: string) => void
onAccept?: () => void
onReject?: () => void
+ onCancel?: () => void
isDiffVisible: boolean
isLoading?: boolean
}
@@ -19,42 +19,54 @@ export const AskAIWidget = ({
onSubmit,
onAccept,
onReject,
+ onCancel,
isDiffVisible,
isLoading = false,
}: AskAIWidgetProps) => {
- const os = detectOS()
+ const textAreaRef = useRef(null)
- // Auto focus input
- const inputRef = useCallback((input: HTMLInputElement | null) => {
+ useEffect(() => {
setTimeout(() => {
- input?.focus()
- }, 0)
+ textAreaRef.current?.focus()
+ textAreaRef.current?.setSelectionRange(value.length, value.length)
+ }, 100)
}, [])
- const handleSubmit = () => {
+ const handleSubmit = useCallback(() => {
if (value.trim() && !isLoading) {
onSubmit(value)
}
- }
+ }, [value, isLoading, onSubmit])
+
+ const handleChange = useCallback(
+ (e: ChangeEvent) => {
+ onChange(e.target.value)
+ },
+ [onChange]
+ )
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
+ e.preventDefault()
+ handleSubmit()
+ }
+ },
+ [handleSubmit]
+ )
return (
-
onChange(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
- handleSubmit()
- }
- }}
+ onChange={handleChange}
+ onKeyDown={handleKeyDown}
disabled={isLoading}
/>
- {isDiffVisible && (
+ {isDiffVisible ? (
Esc
+ ) : (
+
+
+ {isLoading ? 'Generating...' : 'Generate'}{' '}
+ {!isLoading && Enter }
+
+
+ Cancel Esc
+
+
)}
)
diff --git a/apps/studio/components/interfaces/SQLEditor/InlineWidget.tsx b/apps/studio/components/interfaces/SQLEditor/InlineWidget.tsx
index 76201df71b9..7e3d2cd663a 100644
--- a/apps/studio/components/interfaces/SQLEditor/InlineWidget.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/InlineWidget.tsx
@@ -1,5 +1,5 @@
import { editor } from 'monaco-editor'
-import { PropsWithChildren, useEffect, useMemo } from 'react'
+import { PropsWithChildren, useEffect, useMemo, useRef } from 'react'
import { createPortal } from 'react-dom'
export interface InlineWidgetProps {
@@ -45,62 +45,83 @@ const InlineWidget = ({
}: PropsWithChildren) => {
const lineNumber = beforeLineNumber ?? afterLineNumber
const key = `${id}-${lineNumber.toString()}`
-
const containerElement = useMemo(() => document.createElement('div'), [])
+ const zoneIdRef = useRef()
+ const viewZoneRef = useRef<{
+ top: number
+ height: number
+ heightInLines: number
+ }>({ top: 0, height: 0, heightInLines: heightInLines })
// Get the appropriate editor instance for diff editor
const targetEditor = 'getModifiedEditor' in editor ? editor.getModifiedEditor() : editor
- useEffect(() => {
- let zoneId: string
- let viewZoneTop = 0
- let viewZoneHeight = 0
+ const recalculateLayout = () => {
+ const layoutInfo = targetEditor.getLayoutInfo()
+ if (!layoutInfo) {
+ return
+ }
+
+ containerElement.style.left = `${layoutInfo.contentLeft}px`
+ containerElement.style.top = `${viewZoneRef.current.top}px`
+ containerElement.style.width = `${layoutInfo.width - layoutInfo.contentLeft - 20}px`
+ containerElement.style.height = `${viewZoneRef.current.height}px`
+ }
+
+ const createViewZone = () => {
+ targetEditor.changeViewZones((accessor) => {
+ // Remove existing zone if it exists
+ if (zoneIdRef.current) {
+ accessor.removeZone(zoneIdRef.current)
+ }
+
+ // Create new zone with current height
+ zoneIdRef.current = accessor.addZone({
+ afterLineNumber: beforeLineNumber ?? afterLineNumber,
+ heightInLines: viewZoneRef.current.heightInLines,
+ domNode: document.createElement('div'),
+ onDomNodeTop: (top) => {
+ viewZoneRef.current.top = top
+ recalculateLayout()
+ },
+ onComputedHeight: (height) => {
+ viewZoneRef.current.height = height
+ recalculateLayout()
+ },
+ })
+ })
+ }
+
+ // Initial setup of view zone and overlay widget
+ useEffect(() => {
const overlayWidget: editor.IOverlayWidget = {
getId: () => id,
getDomNode: () => containerElement,
getPosition: () => null,
}
- const recalculateLayout = () => {
- const layoutInfo = targetEditor.getLayoutInfo()
-
- if (!layoutInfo) {
- return
- }
-
- containerElement.style.left = `${layoutInfo.contentLeft}px`
- containerElement.style.top = `${viewZoneTop}px`
- containerElement.style.width = `${layoutInfo.width - layoutInfo.contentLeft}px`
- containerElement.style.height = `${viewZoneHeight}px`
- }
-
- targetEditor.changeViewZones((accessor) => {
- zoneId = accessor.addZone({
- afterLineNumber: beforeLineNumber ?? afterLineNumber,
- heightInLines,
- domNode: document.createElement('div'),
- onDomNodeTop: (top) => {
- viewZoneTop = top
- recalculateLayout()
- },
- onComputedHeight: (height) => {
- viewZoneHeight = height
- recalculateLayout()
- },
- })
-
- targetEditor.addOverlayWidget(overlayWidget)
- })
+ createViewZone()
+ targetEditor.addOverlayWidget(overlayWidget)
// Remove the view zone & overlay widget on unmount
return () => {
targetEditor.changeViewZones((accessor) => {
- accessor.removeZone(zoneId)
+ if (zoneIdRef.current) {
+ accessor.removeZone(zoneIdRef.current)
+ }
targetEditor.removeOverlayWidget(overlayWidget)
})
}
- }, [targetEditor, id, beforeLineNumber, afterLineNumber, heightInLines, containerElement])
+ }, [targetEditor, id, beforeLineNumber, afterLineNumber]) // Note: heightInLines removed from deps
+
+ // Update view zone height when heightInLines changes
+ useEffect(() => {
+ if (heightInLines !== viewZoneRef.current.heightInLines) {
+ viewZoneRef.current.heightInLines = heightInLines
+ createViewZone()
+ }
+ }, [heightInLines])
return createPortal(children, containerElement, key)
}
diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx
index 8c9c6aeb680..62b818680c8 100644
--- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx
@@ -49,9 +49,8 @@ import {
cn,
} from 'ui'
import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils'
-import { AskAIWidget } from './AskAIWidget'
+import ResizableAIWidget from 'components/ui/AIEditor/ResizableAIWidget'
import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
-import InlineWidget from './InlineWidget'
import { RunQueryWarningModal } from './RunQueryWarningModal'
import {
ROWS_PER_PAGE_OPTIONS,
@@ -687,29 +686,26 @@ export const SQLEditor = () => {
}}
/>
{showWidget && (
-
- {
- handlePrompt(prompt, {
- beforeSelection: promptState.beforeSelection,
- selection: promptState.selection || defaultSqlDiff.modified,
- afterSelection: promptState.afterSelection,
- })
- }}
- value={promptInput}
- onChange={setPromptInput}
- onAccept={acceptAiHandler}
- onReject={discardAiHandler}
- isDiffVisible={true}
- isLoading={isCompletionLoading}
- />
-
+ value={promptInput}
+ onChange={setPromptInput}
+ onSubmit={(prompt: string) => {
+ handlePrompt(prompt, {
+ beforeSelection: promptState.beforeSelection,
+ selection: promptState.selection || defaultSqlDiff.modified,
+ afterSelection: promptState.afterSelection,
+ })
+ }}
+ onAccept={acceptAiHandler}
+ onReject={discardAiHandler}
+ onCancel={resetPrompt}
+ isDiffVisible={true}
+ isLoading={isCompletionLoading}
+ startLineNumber={Math.max(0, promptState.startLineNumber)}
+ endLineNumber={promptState.endLineNumber}
+ />
)}
)}
@@ -741,27 +737,24 @@ export const SQLEditor = () => {
}}
/>
{editorRef.current && promptState.isOpen && !isDiffOpen && (
-
- {
- handlePrompt(prompt, {
- beforeSelection: promptState.beforeSelection,
- selection: promptState.selection,
- afterSelection: promptState.afterSelection,
- })
- }}
- isDiffVisible={false}
- isLoading={isCompletionLoading}
- />
-
+ value={promptInput}
+ onChange={setPromptInput}
+ onSubmit={(prompt: string) => {
+ handlePrompt(prompt, {
+ beforeSelection: promptState.beforeSelection,
+ selection: promptState.selection,
+ afterSelection: promptState.afterSelection,
+ })
+ }}
+ onCancel={resetPrompt}
+ isDiffVisible={false}
+ isLoading={isCompletionLoading}
+ startLineNumber={Math.max(0, promptState.startLineNumber)}
+ endLineNumber={promptState.endLineNumber}
+ />
)}
{!promptState.isOpen && !editorRef.current?.getValue() && (
diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/RunButton.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/RunButton.tsx
new file mode 100644
index 00000000000..26bac02eec1
--- /dev/null
+++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/RunButton.tsx
@@ -0,0 +1,47 @@
+import { detectOS } from 'lib/helpers'
+import { Command, CornerDownLeft, Loader2 } from 'lucide-react'
+import { Button } from 'ui'
+
+interface SqlRunButtonProps {
+ isDisabled?: boolean
+ isExecuting?: boolean
+ hasSelection?: boolean
+ className?: string
+ onClick: () => void
+}
+
+export const SqlRunButton = ({
+ isDisabled = false,
+ isExecuting = false,
+ hasSelection = false,
+ className,
+ onClick,
+}: SqlRunButtonProps) => {
+ const os = detectOS()
+
+ return (
+
+ ) : (
+
+ {os === 'macos' ? (
+
+ ) : (
+
CTRL
+ )}
+
+
+ )
+ }
+ className={className}
+ >
+ {hasSelection ? 'Run selected' : 'Run'}
+
+ )
+}
diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx
index fb07121d2a3..0affcf3395b 100644
--- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx
@@ -1,21 +1,10 @@
-import {
- AlignLeft,
- Check,
- Command,
- CornerDownLeft,
- Heart,
- Keyboard,
- Loader2,
- MoreVertical,
-} from 'lucide-react'
+import { AlignLeft, Check, Heart, Keyboard, MoreVertical } from 'lucide-react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { RoleImpersonationPopover } from 'components/interfaces/RoleImpersonationSelector'
import DatabaseSelector from 'components/ui/DatabaseSelector'
-import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
-import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants'
import { detectOS } from 'lib/helpers'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
@@ -31,6 +20,7 @@ import {
TooltipTrigger,
cn,
} from 'ui'
+import { SqlRunButton } from './RunButton'
import SavingIndicator from './SavingIndicator'
export type UtilityActionsProps = {
@@ -53,9 +43,6 @@ const UtilityActions = ({
const os = detectOS()
const { ref } = useParams()
const snapV2 = useSqlEditorV2StateSnapshot()
- const org = useSelectedOrganization()
-
- const { mutate: sendEvent } = useSendEventMutation()
const [isAiOpen] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.SQL_EDITOR_AI_OPEN, true)
const [intellisenseEnabled, setIntellisenseEnabled] = useLocalStorageQuery(
@@ -195,29 +182,12 @@ const UtilityActions = ({
onSelectId={onSelectDatabase}
/>
-
- ) : (
-
- {os === 'macos' ? (
-
- ) : (
-
CTRL
- )}
-
-
- )
- }
+
- {hasSelection ? 'Run selected' : 'Run'}
-
+ onClick={executeQuery}
+ />
diff --git a/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx b/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx
new file mode 100644
index 00000000000..71ab33dbeda
--- /dev/null
+++ b/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx
@@ -0,0 +1,23 @@
+import { useAppStateSnapshot } from 'state/app-state'
+import { Button } from 'ui'
+import { SqlEditor } from 'icons'
+
+const InlineEditorButton = () => {
+ const { setEditorPanel, editorPanel } = useAppStateSnapshot()
+
+ return (
+ {
+ setEditorPanel({ open: !editorPanel.open })
+ }}
+ >
+
+
+ )
+}
+
+export default InlineEditorButton
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx
index 9b06985679c..7d3d26add7a 100644
--- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx
@@ -3,10 +3,12 @@ import { useRouter } from 'next/router'
import { ReactNode, useMemo } from 'react'
import { useParams } from 'common'
+import { useIsInlineEditorEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import Connect from 'components/interfaces/Connect/Connect'
import AssistantButton from 'components/layouts/AppLayout/AssistantButton'
import BranchDropdown from 'components/layouts/AppLayout/BranchDropdown'
import EnableBranchingButton from 'components/layouts/AppLayout/EnableBranchingButton/EnableBranchingButton'
+import InlineEditorButton from 'components/layouts/AppLayout/InlineEditorButton'
import OrganizationDropdown from 'components/layouts/AppLayout/OrganizationDropdown'
import ProjectDropdown from 'components/layouts/AppLayout/ProjectDropdown'
import { getResourcesExceededLimitsOrg } from 'components/ui/OveragesBanner/OveragesBanner.utils'
@@ -21,6 +23,7 @@ import BreadcrumbsView from './BreadcrumbsView'
import { FeedbackDropdown } from './FeedbackDropdown'
import HelpPopover from './HelpPopover'
import NotificationsPopoverV2 from './NotificationsPopoverV2/NotificationsPopover'
+
const LayoutHeaderDivider = () => (
{!!projectRef && (
-
-
+
+ {isInlineEditorEnabled && (
+
+
+
+ )}
+
)}
diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx
index 1e83cb17376..09d1703ad90 100644
--- a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx
@@ -1,6 +1,7 @@
import { useParams } from 'common'
import ProjectAPIDocs from 'components/interfaces/ProjectAPIDocs/ProjectAPIDocs'
import { AIAssistantPanel } from 'components/ui/AIAssistantPanel/AIAssistantPanel'
+import { EditorPanel } from 'components/ui/EditorPanel/EditorPanel'
import AISettingsModal from 'components/ui/AISettingsModal'
import { Loading } from 'components/ui/Loading'
import { ResourceExhaustionWarningBanner } from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner'
@@ -83,8 +84,13 @@ const ProjectLayout = forwardRef
- {isClient && aiAssistantPanel.open && (
+ {isClient && (aiAssistantPanel.open || editorPanel.open) && (
<>
-
+ {aiAssistantPanel.open && }
+ {editorPanel.open && }
>
)}
diff --git a/apps/studio/components/ui/AIEditor/ResizableAIWidget.tsx b/apps/studio/components/ui/AIEditor/ResizableAIWidget.tsx
new file mode 100644
index 00000000000..21f49183481
--- /dev/null
+++ b/apps/studio/components/ui/AIEditor/ResizableAIWidget.tsx
@@ -0,0 +1,88 @@
+import { editor as monacoEditor } from 'monaco-editor'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import InlineWidget from 'components/interfaces/SQLEditor/InlineWidget'
+import { AskAIWidget } from 'components/interfaces/SQLEditor/AskAIWidget'
+
+interface ResizableAIWidgetProps {
+ editor: monacoEditor.IStandaloneCodeEditor | monacoEditor.IStandaloneDiffEditor
+ id: string
+ value: string
+ onChange: (value: string) => void
+ onSubmit: (prompt: string) => void
+ onAccept?: () => void
+ onReject?: () => void
+ onCancel?: () => void
+ isDiffVisible: boolean
+ isLoading?: boolean
+ startLineNumber: number
+ endLineNumber: number
+}
+
+const LINE_HEIGHT = 20 // height of each line in pixels
+const MIN_LINES = 3 // minimum number of lines to show
+
+const ResizableAIWidget = ({
+ editor,
+ id,
+ value,
+ onChange,
+ onSubmit,
+ onAccept,
+ onReject,
+ onCancel,
+ isDiffVisible,
+ isLoading = false,
+ startLineNumber,
+ endLineNumber,
+}: ResizableAIWidgetProps) => {
+ const containerRef = useRef(null)
+ const [heightInLines, setHeightInLines] = useState(MIN_LINES)
+
+ const updateHeight = useCallback(() => {
+ if (containerRef.current) {
+ const height = containerRef.current.offsetHeight
+ const newHeightInLines = Math.max(MIN_LINES, Math.ceil(height / LINE_HEIGHT))
+ setHeightInLines(newHeightInLines)
+ }
+ }, [])
+
+ useEffect(() => {
+ // Update height on value change
+ updateHeight()
+
+ // Set up resize observer to track height changes
+ const resizeObserver = new ResizeObserver(updateHeight)
+ if (containerRef.current) {
+ resizeObserver.observe(containerRef.current)
+ }
+
+ return () => {
+ resizeObserver.disconnect()
+ }
+ }, [updateHeight])
+
+ return (
+
+
+
+ )
+}
+
+export default ResizableAIWidget
diff --git a/apps/studio/components/ui/AIEditor/index.tsx b/apps/studio/components/ui/AIEditor/index.tsx
new file mode 100644
index 00000000000..67917a2a45a
--- /dev/null
+++ b/apps/studio/components/ui/AIEditor/index.tsx
@@ -0,0 +1,352 @@
+import Editor, { DiffEditor, Monaco, OnMount } from '@monaco-editor/react'
+import { useCompletion } from 'ai/react'
+import { AnimatePresence, motion } from 'framer-motion'
+import { Command } from 'lucide-react'
+import { editor as monacoEditor } from 'monaco-editor'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { toast } from 'sonner'
+
+import { constructHeaders } from 'data/fetchers'
+import { detectOS } from 'lib/helpers'
+import ResizableAIWidget from './ResizableAIWidget'
+
+interface AIEditorProps {
+ id?: string
+ language?: string
+ value?: string
+ defaultValue?: string
+ aiEndpoint?: string
+ aiMetadata?: {
+ projectRef?: string
+ connectionString?: string
+ includeSchemaMetadata?: boolean
+ }
+ initialPrompt?: string
+ readOnly?: boolean
+ className?: string
+ options?: monacoEditor.IStandaloneEditorConstructionOptions
+ onChange?: (value: string) => void
+ executeQuery?: () => void
+}
+
+// [Joshen] This has overlap with components/interfaces/SQLEditor/MonacoEditor
+// Can we try to de-dupe accordingly? Perhaps the SQL Editor could use this AIEditor
+// We have a tendency to create multiple versions of the monaco editor like RLSCodeEditor
+// so hoping to prevent that from snowballing
+const AIEditor = ({
+ language = 'javascript',
+ value,
+ defaultValue = '',
+ aiEndpoint,
+ aiMetadata,
+ initialPrompt,
+ readOnly = false,
+ className = '',
+ options = {},
+ onChange,
+ executeQuery,
+}: AIEditorProps) => {
+ const os = detectOS()
+ const editorRef = useRef(null)
+ const diffEditorRef = useRef(null)
+
+ const executeQueryRef = useRef(executeQuery)
+ executeQueryRef.current = executeQuery
+
+ const [currentValue, setCurrentValue] = useState(value || defaultValue)
+ const [isDiffMode, setIsDiffMode] = useState(false)
+ const [isDiffEditorMounted, setIsDiffEditorMounted] = useState(false)
+ const [diffValue, setDiffValue] = useState({ original: '', modified: '' })
+ const [promptState, setPromptState] = useState({
+ isOpen: Boolean(initialPrompt),
+ selection: '',
+ beforeSelection: '',
+ afterSelection: '',
+ startLineNumber: 0,
+ endLineNumber: 0,
+ })
+ const [promptInput, setPromptInput] = useState(initialPrompt || '')
+
+ const {
+ complete,
+ completion,
+ isLoading: isCompletionLoading,
+ setCompletion,
+ } = useCompletion({
+ api: aiEndpoint || '',
+ body: aiMetadata,
+ onResponse: (response) => {
+ if (!response.ok) throw new Error('Failed to generate completion')
+ },
+ onError: (error) => {
+ toast.error(`Failed to generate: ${error.message}`)
+ },
+ })
+
+ const handleReset = useCallback(() => {
+ setCompletion('')
+ setIsDiffMode(false)
+ setPromptState((prev) => ({ ...prev, isOpen: false }))
+ setPromptInput('')
+ editorRef.current?.focus()
+ }, [setCompletion])
+
+ const handleAcceptDiff = useCallback(() => {
+ if (diffValue.modified) {
+ const newValue = diffValue.modified
+ setCurrentValue(newValue)
+ onChange?.(newValue)
+ handleReset()
+ }
+ }, [diffValue.modified, onChange, handleReset])
+
+ const handleRejectDiff = () => {
+ handleReset()
+ }
+
+ const handleEditorOnMount: OnMount = (
+ editor: monacoEditor.IStandaloneCodeEditor,
+ monaco: Monaco
+ ) => {
+ editorRef.current = editor
+ // Set prompt state to open if promptInput exists
+ if (promptInput) {
+ const model = editor.getModel()
+ if (model) {
+ const lineCount = model.getLineCount()
+ setPromptState({
+ isOpen: true,
+ selection: model.getValue(),
+ beforeSelection: '',
+ afterSelection: '',
+ startLineNumber: 1,
+ endLineNumber: lineCount,
+ })
+ }
+ }
+
+ if (!!executeQueryRef.current) {
+ editor.addAction({
+ id: 'run-query',
+ label: 'Run Query',
+ keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter],
+ contextMenuGroupId: 'operation',
+ contextMenuOrder: 0,
+ run: () => executeQueryRef.current?.(),
+ })
+ }
+
+ editor.addAction({
+ id: 'generate-ai',
+ label: 'Generate with AI',
+ keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK],
+ run: () => {
+ const selection = editor.getSelection()
+ const model = editor.getModel()
+ if (!model || !selection) return
+
+ const allLines = model.getLinesContent()
+ const startLineIndex = selection.startLineNumber - 1
+ const endLineIndex = selection.endLineNumber
+
+ const beforeSelection = allLines.slice(0, startLineIndex).join('\n') + '\n'
+ const selectedText = allLines.slice(startLineIndex, endLineIndex).join('\n')
+ const afterSelection = '\n' + allLines.slice(endLineIndex).join('\n')
+
+ setPromptState({
+ isOpen: true,
+ selection: selectedText,
+ beforeSelection,
+ afterSelection,
+ startLineNumber: selection?.startLineNumber ?? 0,
+ endLineNumber: selection?.endLineNumber ?? 0,
+ })
+ },
+ })
+ }
+
+ const handlePrompt = async (
+ prompt: string,
+ context: {
+ beforeSelection: string
+ selection: string
+ afterSelection: string
+ }
+ ) => {
+ try {
+ setPromptState((prev) => ({
+ ...prev,
+ selection: context.selection,
+ beforeSelection: context.beforeSelection,
+ afterSelection: context.afterSelection,
+ }))
+
+ const headerData = await constructHeaders()
+ await complete(prompt, {
+ headers: { Authorization: headerData.get('Authorization') ?? '' },
+ body: {
+ ...aiMetadata,
+ completionMetadata: {
+ textBeforeCursor: context.beforeSelection,
+ textAfterCursor: context.afterSelection,
+ language,
+ prompt,
+ selection: context.selection,
+ },
+ },
+ })
+ } catch (error) {
+ setPromptState((prev) => ({ ...prev, isOpen: false }))
+ }
+ }
+
+ const defaultOptions: monacoEditor.IStandaloneEditorConstructionOptions = {
+ tabSize: 2,
+ fontSize: 13,
+ readOnly,
+ minimap: { enabled: false },
+ wordWrap: 'on',
+ lineNumbers: 'on',
+ folding: false,
+ padding: { top: 4 },
+ lineNumbersMinChars: 3,
+ ...options,
+ }
+
+ useEffect(() => {
+ setCurrentValue(value || defaultValue)
+ }, [value, defaultValue])
+
+ useEffect(() => {
+ if (!isDiffMode) {
+ setIsDiffEditorMounted(false)
+ }
+ }, [isDiffMode])
+
+ useEffect(() => {
+ if (!completion) {
+ setIsDiffMode(false)
+ return
+ }
+
+ const original =
+ promptState.beforeSelection + promptState.selection + promptState.afterSelection
+ const modified = promptState.beforeSelection + completion + promptState.afterSelection
+
+ setDiffValue({ original, modified })
+ setIsDiffMode(true)
+ }, [completion, promptState.beforeSelection, promptState.selection, promptState.afterSelection])
+
+ useEffect(() => {
+ const handleKeyboard = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ handleReset()
+ } else if (
+ event.key === 'Enter' &&
+ (os === 'macos' ? event.metaKey : event.ctrlKey) &&
+ isDiffMode
+ ) {
+ event.preventDefault()
+ handleAcceptDiff()
+ }
+ }
+
+ window.addEventListener('keydown', handleKeyboard)
+ return () => window.removeEventListener('keydown', handleKeyboard)
+ }, [os, isDiffMode, handleAcceptDiff, handleReset])
+
+ return (
+
+ {isDiffMode ? (
+
+ {
+ diffEditorRef.current = editor
+ setIsDiffEditorMounted(true)
+ }}
+ options={{
+ ...defaultOptions,
+ renderSideBySide: false,
+ }}
+ />
+ {isDiffEditorMounted && (
+ {
+ handlePrompt(prompt, {
+ beforeSelection: promptState.beforeSelection,
+ selection: promptState.selection || diffValue.modified,
+ afterSelection: promptState.afterSelection,
+ })
+ }}
+ onAccept={handleAcceptDiff}
+ onReject={handleRejectDiff}
+ onCancel={handleReset}
+ isDiffVisible={true}
+ isLoading={isCompletionLoading}
+ startLineNumber={Math.max(0, promptState.startLineNumber)}
+ endLineNumber={promptState.endLineNumber}
+ />
+ )}
+
+ ) : (
+
+
{
+ const newValue = value || ''
+ setCurrentValue(newValue)
+ onChange?.(newValue)
+ }}
+ onMount={handleEditorOnMount}
+ className={className}
+ />
+ {promptState.isOpen && editorRef.current && (
+ {
+ handlePrompt(prompt, {
+ beforeSelection: promptState.beforeSelection,
+ selection: promptState.selection,
+ afterSelection: promptState.afterSelection,
+ })
+ }}
+ onCancel={handleReset}
+ isDiffVisible={false}
+ isLoading={isCompletionLoading}
+ startLineNumber={Math.max(0, promptState.startLineNumber)}
+ endLineNumber={promptState.endLineNumber}
+ />
+ )}
+
+ {!promptState.isOpen && !currentValue && aiEndpoint && (
+
+ Hit {os === 'macos' ? : `CTRL+`}K to edit with the Assistant
+
+ )}
+
+
+ )}
+
+ )
+}
+
+export default AIEditor
diff --git a/apps/studio/components/ui/CodeEditor/CodeEditor.tsx b/apps/studio/components/ui/CodeEditor/CodeEditor.tsx
index 97420bf87c2..9b5a8eb2c1a 100644
--- a/apps/studio/components/ui/CodeEditor/CodeEditor.tsx
+++ b/apps/studio/components/ui/CodeEditor/CodeEditor.tsx
@@ -22,7 +22,7 @@ const DEFAULT_ACTIONS = {
interface CodeEditorProps {
id: string
- language: 'pgsql' | 'json' | 'html' | undefined
+ language: 'pgsql' | 'json' | 'html' | 'typescript' | undefined
autofocus?: boolean
defaultValue?: string
isReadOnly?: boolean
diff --git a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx
new file mode 100644
index 00000000000..e5a8daae887
--- /dev/null
+++ b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx
@@ -0,0 +1,396 @@
+import { debounce } from 'lodash'
+import { Book, Save, X } from 'lucide-react'
+import { useCallback, useEffect, useState } from 'react'
+import { toast } from 'sonner'
+
+import { useParams } from 'common'
+import {
+ createSqlSnippetSkeletonV2,
+ suffixWithLimit,
+} from 'components/interfaces/SQLEditor/SQLEditor.utils'
+import Results from 'components/interfaces/SQLEditor/UtilityPanel/Results'
+import { SqlRunButton } from 'components/interfaces/SQLEditor/UtilityPanel/RunButton'
+import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation'
+import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
+import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi'
+import { useSelectedProject } from 'hooks/misc/useSelectedProject'
+import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
+import { uuidv4 } from 'lib/helpers'
+import { useProfile } from 'lib/profile'
+import { useAppStateSnapshot } from 'state/app-state'
+import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
+import { AiIconAnimation, Button, cn, Input_Shadcn_, SQL_ICON } from 'ui'
+import { Admonition } from 'ui-patterns'
+import { containsUnknownFunction, isReadOnlySelect } from '../AIAssistantPanel/AIAssistant.utils'
+import AIEditor from '../AIEditor'
+import { ButtonTooltip } from '../ButtonTooltip'
+import { InlineLink } from '../InlineLink'
+import SqlWarningAdmonition from '../SqlWarningAdmonition'
+
+interface EditorPanelProps {
+ onChange?: (value: string) => void
+}
+
+export const EditorPanel = ({ onChange }: EditorPanelProps) => {
+ const { ref } = useParams()
+ const project = useSelectedProject()
+ const { editorPanel, setEditorPanel, setAiAssistantPanel } = useAppStateSnapshot()
+ const { profile } = useProfile()
+ const snapV2 = useSqlEditorV2StateSnapshot()
+ const { mutateAsync: generateSqlTitle } = useSqlTitleGenerateMutation()
+ const isOptedInToAI = useOrgOptedIntoAi()
+ const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM
+
+ const [isSaving, setIsSaving] = useState(false)
+ const [error, setError] = useState()
+ const [results, setResults] = useState(undefined)
+ const [showWarning, setShowWarning] = useState<'hasWriteOperation' | 'hasUnknownFunctions'>()
+ const [currentValue, setCurrentValue] = useState(editorPanel.initialValue || '')
+ const [showTemplates, setShowTemplates] = useState(false)
+ const [templateSearch, setTemplateSearch] = useState('')
+ const [savedCode, setSavedCode] = useState('')
+ const [isPreviewingTemplate, setIsPreviewingTemplate] = useState(false)
+ const [showResults, setShowResults] = useState(true)
+
+ const errorHeader = error?.formattedError?.split('\n')?.filter((x: string) => x.length > 0)?.[0]
+ const errorContent =
+ error?.formattedError
+ ?.split('\n')
+ ?.filter((x: string) => x.length > 0)
+ ?.slice(1) ?? []
+
+ const { mutate: executeSql, isLoading: isExecuting } = useExecuteSqlMutation({
+ onSuccess: async (res) => {
+ setResults(res.result)
+ },
+ onError: (error) => {
+ setError(error)
+ setResults([])
+ },
+ })
+
+ const handleChat = () => {
+ setAiAssistantPanel({
+ open: true,
+ sqlSnippets: currentValue ? [currentValue] : [],
+ initialInput: 'Help me understand and improve this SQL query...',
+ suggestions: {
+ title:
+ 'I can help you understand and improve your SQL query. Here are a few example prompts to get you started:',
+ prompts: [
+ 'Explain what this query does...',
+ 'Help me optimize this query...',
+ 'Show me how to add more conditions...',
+ 'Help me join this with another table...',
+ ],
+ },
+ })
+ }
+
+ const onExecuteSql = (skipValidation = false) => {
+ setError(undefined)
+ setShowWarning(undefined)
+
+ if (currentValue.length === 0) return
+
+ if (!skipValidation) {
+ const isReadOnlySelectSQL = isReadOnlySelect(currentValue)
+ if (!isReadOnlySelectSQL) {
+ const hasUnknownFunctions = containsUnknownFunction(currentValue)
+ setShowWarning(hasUnknownFunctions ? 'hasUnknownFunctions' : 'hasWriteOperation')
+ return
+ }
+ }
+
+ if (editorPanel.onSave) {
+ editorPanel.onSave(currentValue)
+ }
+
+ executeSql({
+ sql: suffixWithLimit(currentValue, 100),
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ handleError: (error) => {
+ throw error
+ },
+ contextualInvalidation: true,
+ })
+ }
+
+ const handleChange = (value: string) => {
+ setCurrentValue(value)
+ onChange?.(value)
+ }
+
+ const onSelectTemplate = (content: string) => {
+ handleChange(content)
+ setSavedCode(content)
+ setShowTemplates(false)
+ }
+
+ // Create a debounced version of the revert code function
+ const debouncedRevertCode = useCallback(
+ debounce(() => {
+ setIsPreviewingTemplate(false)
+ handleChange(savedCode)
+ }, 300),
+ [savedCode]
+ )
+
+ const handleTemplateMouseEnter = (templateContent: string) => {
+ // Cancel any pending revert
+ debouncedRevertCode.cancel()
+
+ if (!isPreviewingTemplate) {
+ setSavedCode(currentValue)
+ }
+ setIsPreviewingTemplate(true)
+ handleChange(templateContent)
+ }
+
+ const handleTemplateMouseLeave = () => {
+ if (isPreviewingTemplate) {
+ debouncedRevertCode()
+ }
+ }
+
+ // Cleanup debounce on unmount
+ useEffect(() => {
+ return () => {
+ debouncedRevertCode.cancel()
+ }
+ }, [debouncedRevertCode])
+
+ useEffect(() => {
+ if (editorPanel.initialValue !== undefined && editorPanel.initialValue !== currentValue) {
+ setCurrentValue(editorPanel.initialValue)
+ }
+ }, [editorPanel.initialValue])
+
+ useEffect(() => {
+ if (editorPanel.initialValue !== currentValue) {
+ setEditorPanel({
+ initialValue: currentValue,
+ })
+ }
+ }, [currentValue, setEditorPanel])
+
+ return (
+
+
+
SQL Editor
+
+
}
+ >
+ Chat
+
+
}
+ onClick={async () => {
+ if (!ref) return console.error('Project ref is required')
+ if (!project) return console.error('Project is required')
+ if (!profile) return console.error('Profile is required')
+
+ try {
+ setIsSaving(true)
+ const { title: name } = await generateSqlTitle({ sql: currentValue })
+ const snippet = createSqlSnippetSkeletonV2({
+ id: uuidv4(),
+ name,
+ sql: currentValue,
+ owner_id: profile.id,
+ project_id: project.id,
+ })
+ snapV2.addSnippet({ projectRef: ref, snippet })
+ snapV2.addNeedsSaving(snippet.id)
+ toast.success(
+
+ Saved snippet! View it{' '}
+ here
+
+ )
+ } catch (error: any) {
+ toast.error(`Failed to create new query: ${error.message}`)
+ } finally {
+ setIsSaving(false)
+ }
+ }}
+ />
+
+
setEditorPanel({ open: false })}
+ icon={ }
+ />
+
+
+
+
+
+
+ {error !== undefined && (
+
+
+ {errorContent.length > 0 ? (
+ errorContent.map((errorText: string, i: number) => (
+
+ {errorText}
+
+ ))
+ ) : (
+ {error.error}
+ )}
+
+ }
+ />
+
+ )}
+
+ {showWarning && (
+
setShowWarning(undefined)}
+ onConfirm={() => {
+ setShowWarning(undefined)
+ onExecuteSql(true)
+ }}
+ />
+ )}
+
+ {results !== undefined && results.length > 0 && (
+
+ {showResults && (
+
+
+
+ )}
+
+
+ {results.length} rows{results.length >= 100 && ` (Limited to only 100 rows)`}
+
+ setShowResults((prev) => !prev)}
+ >
+ {showResults ? 'Hide Results' : 'Show Results'}
+
+
+
+ )}
+ {results !== undefined && results.length === 0 && (
+
+
+ Success. No rows returned.
+
+
+ )}
+
+ {showTemplates && editorPanel.templates && (
+
+
+ setTemplateSearch(e.target.value)}
+ />
+
+
+ {editorPanel.templates
+ ?.filter((template) => {
+ const searchLower = templateSearch.toLowerCase()
+ return (
+ template.name.toLowerCase().includes(searchLower) ||
+ template.description.toLowerCase().includes(searchLower)
+ )
+ })
+ ?.map((template, i) => (
+
onSelectTemplate(template.content)}
+ onMouseEnter={() => handleTemplateMouseEnter(template.content)}
+ onMouseLeave={handleTemplateMouseLeave}
+ >
+
+
+
{template.name}
+
{template.description}
+
+
+ ))}
+
+
+ )}
+
+ setShowTemplates(!showTemplates)}
+ icon={ }
+ >
+ {showTemplates ? 'Templates' : 'Templates'}
+
+
+
+
+
+ )
+}
+
+export default EditorPanel
diff --git a/apps/studio/components/ui/QueryBlock/EditQueryButton.tsx b/apps/studio/components/ui/QueryBlock/EditQueryButton.tsx
index 28f6c6cae98..af75ce53b02 100644
--- a/apps/studio/components/ui/QueryBlock/EditQueryButton.tsx
+++ b/apps/studio/components/ui/QueryBlock/EditQueryButton.tsx
@@ -10,6 +10,7 @@ import Link from 'next/link'
import { ComponentProps } from 'react'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import {
+ cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -18,21 +19,32 @@ import {
} from 'ui'
import { ButtonTooltip } from '../ButtonTooltip'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
+import { useAppStateSnapshot } from 'state/app-state'
+import { useIsInlineEditorEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
interface EditQueryButtonProps {
id?: string
title: string
sql?: string
+ className?: string
+ type?: 'default' | 'text'
}
-export const EditQueryButton = ({ id, sql, title }: EditQueryButtonProps) => {
+export const EditQueryButton = ({
+ id,
+ sql,
+ title,
+ className,
+ type = 'text',
+}: EditQueryButtonProps) => {
const router = useRouter()
const { ref } = useParams()
const { newQuery } = useNewQuery()
const sqlEditorSnap = useSqlEditorV2StateSnapshot()
-
+ const { setEditorPanel } = useAppStateSnapshot()
const isInSQLEditor = router.pathname.includes('/sql')
const isInNewSnippet = router.pathname.endsWith('/sql')
+ const isInlineEditorEnabled = useIsInlineEditorEnabled()
const tooltip: { content: ComponentProps & { text: string } } = {
content: { side: 'bottom', text: 'Edit in SQL Editor' },
}
@@ -54,9 +66,9 @@ export const EditQueryButton = ({ id, sql, title }: EditQueryButtonProps) => {
return (
}
tooltip={tooltip}
>
@@ -67,12 +79,19 @@ export const EditQueryButton = ({ id, sql, title }: EditQueryButtonProps) => {
return !isInSQLEditor || isInNewSnippet ? (
}
onClick={() => {
- handleEditInSQLEditor()
+ if (isInlineEditorEnabled) {
+ setEditorPanel({
+ open: true,
+ initialValue: sql,
+ })
+ } else {
+ handleEditInSQLEditor()
+ }
sendEvent({
action: TelemetryActions.ASSISTANT_EDIT_IN_SQL_EDITOR_CLICKED,
properties: {
@@ -88,10 +107,10 @@ export const EditQueryButton = ({ id, sql, title }: EditQueryButtonProps) => {
}
tooltip={!!sql ? tooltip : { content: { side: 'bottom', text: undefined } }}
/>
diff --git a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx
index 8a69fb168a2..3ce07d91e18 100644
--- a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx
+++ b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx
@@ -28,6 +28,7 @@ import { BlockViewConfiguration } from './BlockViewConfiguration'
import { EditQueryButton } from './EditQueryButton'
import { ParametersPopover } from './ParametersPopover'
import { getCumulativeResults } from './QueryBlock.utils'
+import SqlWarningAdmonition from '../SqlWarningAdmonition'
export const DEFAULT_CHART_CONFIG: ChartConfig = {
type: 'bar',
@@ -282,51 +283,25 @@ export const QueryBlock = ({
}
>
{!!showWarning && (
-
-
- {showWarning === 'hasWriteOperation'
- ? 'This query contains write operations.'
- : 'This query involves running a function.'}{' '}
- Are you sure you want to execute it?
-
-
- Make sure you are not accidentally removing something important.
-
-
- setShowWarning(undefined)}
- >
- Cancel
-
- {
- // [Joshen] This is for when we introduced the concept of parameters into our reports
- // const processedSql = processParameterizedSql(sql!, combinedParameterValues)
- if (sql) {
- setShowWarning(undefined)
- execute({
- projectRef: ref,
- connectionString: project?.connectionString,
- sql,
- })
- onRunQuery?.('mutation')
- }
- }}
- >
- Run
-
-
-
+ setShowWarning(undefined)}
+ onConfirm={() => {
+ // [Joshen] This is for when we introduced the concept of parameters into our reports
+ // const processedSql = processParameterizedSql(sql!, combinedParameterValues)
+ if (sql) {
+ setShowWarning(undefined)
+ execute({
+ projectRef: ref,
+ connectionString: project?.connectionString,
+ sql,
+ })
+ onRunQuery?.('mutation')
+ }
+ }}
+ disabled={!sql}
+ />
)}
{isExecuting && queryResult === undefined && (
diff --git a/apps/studio/components/ui/SqlWarningAdmonition.tsx b/apps/studio/components/ui/SqlWarningAdmonition.tsx
new file mode 100644
index 00000000000..bf5ef1421da
--- /dev/null
+++ b/apps/studio/components/ui/SqlWarningAdmonition.tsx
@@ -0,0 +1,51 @@
+import { Button } from 'ui'
+import { Admonition } from 'ui-patterns'
+
+export interface SqlWarningAdmonitionProps {
+ warningType: 'hasWriteOperation' | 'hasUnknownFunctions'
+ onCancel: () => void
+ onConfirm: () => void
+ disabled?: boolean
+ className?: string
+}
+
+const SqlWarningAdmonition = ({
+ warningType,
+ onCancel,
+ onConfirm,
+ disabled = false,
+ className,
+}: SqlWarningAdmonitionProps) => {
+ return (
+
+
+ {warningType === 'hasWriteOperation'
+ ? 'This query contains write operations.'
+ : 'This query involves running a function.'}{' '}
+ Are you sure you want to execute it?
+
+
+ Make sure you are not accidentally removing something important.
+
+
+
+ Cancel
+
+
+ Run
+
+
+
+ )
+}
+
+export default SqlWarningAdmonition
diff --git a/apps/studio/lib/constants/index.ts b/apps/studio/lib/constants/index.ts
index 5f37947e323..de927eebe22 100644
--- a/apps/studio/lib/constants/index.ts
+++ b/apps/studio/lib/constants/index.ts
@@ -41,10 +41,12 @@ export const USAGE_APPROACHING_THRESHOLD = 0.75
export const LOCAL_STORAGE_KEYS = {
AI_ASSISTANT_STATE: 'supabase-ai-assistant-state',
+ EDITOR_PANEL_STATE: 'supabase-editor-panel-state',
UI_PREVIEW_NAVIGATION_LAYOUT: 'supabase-ui-preview-nav-layout',
UI_PREVIEW_API_SIDE_PANEL: 'supabase-ui-api-side-panel',
UI_PREVIEW_CLS: 'supabase-ui-cls',
+ UI_PREVIEW_INLINE_EDITOR: 'supabase-ui-preview-inline-editor',
UI_ONBOARDING_NEW_PAGE_SHOWN: 'supabase-ui-onboarding-new-page-shown',
SQL_SCRATCH_PAD_BANNER_ACKNOWLEDGED: 'supabase-sql-scratch-pad-banner-acknowledged',
diff --git a/apps/studio/pages/api/ai/sql/complete.ts b/apps/studio/pages/api/ai/sql/complete.ts
index 29d6d63bf49..50ef9b1910e 100644
--- a/apps/studio/pages/api/ai/sql/complete.ts
+++ b/apps/studio/pages/api/ai/sql/complete.ts
@@ -68,6 +68,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
# You generate and debug SQL
The generated SQL (must be valid SQL), and must adhere to the following:
+ - Always retrieve public schema information first
- Always use double apostrophe in SQL strings (eg. 'Night''s watch')
- Always use semicolons
- Use vector(384) data type for any embedding/vector related query
@@ -96,8 +97,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
# You write row level security policies.
- Your purpose is to generate a policy with the constraints given by the user.
- - First, use getSchema to retrieve more information about a schema or schemas that will contain policies, usually the public schema.
+ Your purpose is to generate a policy with the constraints given by the user using the getRlsKnowledge tool.
+ - First, use getSchemaTables to retrieve more information about a schema or schemas that will contain policies, usually the public schema.
- Then retrieve existing RLS policies and guidelines on how to write policies using the getRlsKnowledge tool .
- Then write new policies or update existing policies based on the prompt
- When asked to suggest policies, either alter existing policies or add new ones to the public schema.
@@ -126,10 +127,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
Instructions:
1. Only modify the selected text based on this prompt: ${prompt}
- 2. Your response should be ONLY the modified selection text, nothing else. Remove selected text if needed.
- 3. Do not wrap in code blocks or markdown
- 4. You can respond with one word or multiple words
- 5. Ensure the modified text flows naturally within the current line
+ 2. Get schema tables information using the getSchemaTables tool
+ 3. Get existing RLS policies and guidelines on how to write policies using the getRlsKnowledge tool
+ 4. Write new policies or update existing policies based on the prompt
+ 5. Your response should be ONLY the modified selection text, nothing else. Remove selected text if needed.
+ 6. Do not wrap in code blocks or markdown
+ 7. You can respond with one word or multiple words
+ 8. Ensure the modified text flows naturally within the current line
6. Avoid duplicating SQL keywords (SELECT, FROM, WHERE, etc) when considering the full statement
7. If there is no surrounding context (before or after), make sure your response is a complete valid SQL statement that can be run and resolves the prompt.
diff --git a/apps/studio/pages/api/ai/sql/generate-v3.ts b/apps/studio/pages/api/ai/sql/generate-v3.ts
index d0b280c4e4a..71187593fe6 100644
--- a/apps/studio/pages/api/ai/sql/generate-v3.ts
+++ b/apps/studio/pages/api/ai/sql/generate-v3.ts
@@ -105,7 +105,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
# You write row level security policies.
Your purpose is to generate a policy with the constraints given by the user.
- - First, use getSchema to retrieve more information about a schema or schemas that will contain policies, usually the public schema.
+ - First, use getSchemaTables to retrieve more information about a schema or schemas that will contain policies, usually the public schema.
- Then retrieve existing RLS policies and guidelines on how to write policies using the getRlsKnowledge tool .
- Then write new policies or update existing policies based on the prompt
- When asked to suggest policies, either alter existing policies or add new ones to the public schema.
diff --git a/apps/studio/pages/api/ai/sql/tools.ts b/apps/studio/pages/api/ai/sql/tools.ts
index a0da432e704..8473a7198ff 100644
--- a/apps/studio/pages/api/ai/sql/tools.ts
+++ b/apps/studio/pages/api/ai/sql/tools.ts
@@ -28,7 +28,7 @@ export const getTools = ({
}
return {
- getSchema: tool({
+ getSchemaTables: tool({
description: 'Get more information about one or more schemas',
parameters: z.object({
schemas: z.array(z.string()).describe('The schema names to get the definitions for'),
@@ -85,6 +85,7 @@ export const getTools = ({
{
projectRef,
connectionString,
+ schema: schemas?.join(','),
},
undefined,
headers
diff --git a/apps/studio/pages/project/[ref]/auth/policies.tsx b/apps/studio/pages/project/[ref]/auth/policies.tsx
index 4e52ee86078..4d42fee486c 100644
--- a/apps/studio/pages/project/[ref]/auth/policies.tsx
+++ b/apps/studio/pages/project/[ref]/auth/policies.tsx
@@ -6,6 +6,8 @@ import { useState } from 'react'
import { PolicyEditorPanel } from 'components/interfaces/Auth/Policies/PolicyEditorPanel'
import Policies from 'components/interfaces/Auth/Policies/Policies'
+import { generatePolicyCreateSQL } from 'components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils'
+import { getGeneralPolicyTemplates } from 'components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants'
import AuthLayout from 'components/layouts/AuthLayout/AuthLayout'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import AlertError from 'components/ui/AlertError'
@@ -22,6 +24,8 @@ import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import type { NextPageWithLayout } from 'types'
import { Input } from 'ui'
import DefaultLayout from 'components/layouts/DefaultLayout'
+import { useIsInlineEditorEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
+import { useAppStateSnapshot } from 'state/app-state'
/**
* Filter tables by table name and policy name
@@ -65,6 +69,8 @@ const AuthPoliciesPage: NextPageWithLayout = () => {
}>()
const { schema = 'public', search: searchString = '' } = params
const { project } = useProjectContext()
+ const { setEditorPanel } = useAppStateSnapshot()
+ const isInlineEditorEnabled = useIsInlineEditorEnabled()
const [selectedTable, setSelectedTable] = useState()
const [showPolicyAiEditor, setShowPolicyAiEditor] = useState(false)
@@ -147,12 +153,45 @@ const AuthPoliciesPage: NextPageWithLayout = () => {
hasTables={tables.length > 0}
isLocked={isLocked}
onSelectCreatePolicy={(table: string) => {
- setSelectedTable(table)
- setShowPolicyAiEditor(true)
+ if (isInlineEditorEnabled) {
+ setEditorPanel({
+ open: true,
+ initialValue: `create policy "replace_with_policy_name"
+ on ${schema}.${table}
+ for select
+ to authenticated
+ using (
+ true -- Write your policy condition here
+);`,
+ label: `Create new RLS policy on "${table}"`,
+ saveLabel: 'Create policy',
+ initialPrompt: `Create and name a entirely new RLS policy for the "${table}" table in the ${schema} schema. The policy should...`,
+ })
+ } else {
+ setSelectedTable(table)
+ setShowPolicyAiEditor(true)
+ }
}}
onSelectEditPolicy={(policy) => {
- setSelectedPolicyToEdit(policy)
- setShowPolicyAiEditor(true)
+ if (isInlineEditorEnabled) {
+ const sql = generatePolicyCreateSQL(policy)
+ const templates = getGeneralPolicyTemplates(policy.schema, policy.table)
+ setEditorPanel({
+ open: true,
+ initialValue: sql,
+ label: `Edit policy "${policy.name}"`,
+ saveLabel: 'Update policy',
+ templates: templates.map((template) => ({
+ name: template.templateName,
+ description: template.description,
+ content: template.statement,
+ })),
+ initialPrompt: `Update the policy with name "${policy.name}" in the ${policy.schema} schema on the ${policy.table} table. It should...`,
+ })
+ } else {
+ setSelectedPolicyToEdit(policy)
+ setShowPolicyAiEditor(true)
+ }
}}
/>
)}
diff --git a/apps/studio/pages/project/[ref]/database/functions.tsx b/apps/studio/pages/project/[ref]/database/functions.tsx
index 0a4b183bc82..c51ff1136e6 100644
--- a/apps/studio/pages/project/[ref]/database/functions.tsx
+++ b/apps/studio/pages/project/[ref]/database/functions.tsx
@@ -11,23 +11,54 @@ import { DatabaseFunction } from 'data/database-functions/database-functions-que
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import type { NextPageWithLayout } from 'types'
import DefaultLayout from 'components/layouts/DefaultLayout'
+import { useIsInlineEditorEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
+import { useAppStateSnapshot } from 'state/app-state'
const FunctionsPage: NextPageWithLayout = () => {
const [selectedFunction, setSelectedFunction] = useState()
const [showCreateFunctionForm, setShowCreateFunctionForm] = useState(false)
const [showDeleteFunctionForm, setShowDeleteFunctionForm] = useState(false)
+ const { setEditorPanel } = useAppStateSnapshot()
+ const isInlineEditorEnabled = useIsInlineEditorEnabled()
const canReadFunctions = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'functions')
const isPermissionsLoaded = usePermissionsLoaded()
const createFunction = () => {
- setSelectedFunction(undefined)
- setShowCreateFunctionForm(true)
+ if (isInlineEditorEnabled) {
+ setEditorPanel({
+ open: true,
+ initialValue: `create function function_name()
+returns void
+language plpgsql
+as $$
+begin
+ -- Write your function logic here
+end;
+$$;`,
+ label: 'Create new database function',
+ saveLabel: 'Create function',
+ initialPrompt: 'Create a new database function that...',
+ })
+ } else {
+ setSelectedFunction(undefined)
+ setShowCreateFunctionForm(true)
+ }
}
- const editFunction = (fn: any) => {
- setSelectedFunction(fn)
- setShowCreateFunctionForm(true)
+ const editFunction = (fn: DatabaseFunction) => {
+ if (isInlineEditorEnabled) {
+ setEditorPanel({
+ open: true,
+ initialValue: fn.complete_statement,
+ label: `Edit function "${fn.name}"`,
+ saveLabel: 'Update function',
+ initialPrompt: `Update the database function "${fn.name}" to...`,
+ })
+ } else {
+ setSelectedFunction(fn)
+ setShowCreateFunctionForm(true)
+ }
}
const deleteFunction = (fn: any) => {
diff --git a/apps/studio/pages/project/[ref]/database/triggers.tsx b/apps/studio/pages/project/[ref]/database/triggers.tsx
index ecb8a6e03f8..8d331386990 100644
--- a/apps/studio/pages/project/[ref]/database/triggers.tsx
+++ b/apps/studio/pages/project/[ref]/database/triggers.tsx
@@ -3,6 +3,7 @@ import { useState } from 'react'
import { CreateTrigger, DeleteTrigger } from 'components/interfaces/Database'
import TriggersList from 'components/interfaces/Database/Triggers/TriggersList/TriggersList'
+import { generateTriggerCreateSQL } from 'components/interfaces/Database/Triggers/TriggersList/TriggerList.utils'
import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import { FormHeader } from 'components/ui/Forms/FormHeader'
@@ -10,23 +11,51 @@ import NoPermission from 'components/ui/NoPermission'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import type { NextPageWithLayout } from 'types'
import DefaultLayout from 'components/layouts/DefaultLayout'
+import { useIsInlineEditorEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
+import { useAppStateSnapshot } from 'state/app-state'
const TriggersPage: NextPageWithLayout = () => {
const [selectedTrigger, setSelectedTrigger] = useState()
const [showCreateTriggerForm, setShowCreateTriggerForm] = useState(false)
const [showDeleteTriggerForm, setShowDeleteTriggerForm] = useState(false)
+ const { setEditorPanel } = useAppStateSnapshot()
+ const isInlineEditorEnabled = useIsInlineEditorEnabled()
const canReadTriggers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers')
const isPermissionsLoaded = usePermissionsLoaded()
const createTrigger = () => {
- setSelectedTrigger(undefined)
- setShowCreateTriggerForm(true)
+ if (isInlineEditorEnabled) {
+ setEditorPanel({
+ open: true,
+ initialValue: `create trigger trigger_name
+after insert or update or delete on table_name
+for each row
+execute function function_name();`,
+ label: 'Create new database trigger',
+ saveLabel: 'Create trigger',
+ initialPrompt: 'Create a new database trigger that...',
+ })
+ } else {
+ setSelectedTrigger(undefined)
+ setShowCreateTriggerForm(true)
+ }
}
const editTrigger = (trigger: any) => {
- setSelectedTrigger(trigger)
- setShowCreateTriggerForm(true)
+ if (isInlineEditorEnabled) {
+ const sql = generateTriggerCreateSQL(trigger)
+ setEditorPanel({
+ open: true,
+ initialValue: sql,
+ label: `Edit trigger "${trigger.name}"`,
+ saveLabel: 'Update trigger',
+ initialPrompt: `Update the database trigger "${trigger.name}" to...`,
+ })
+ } else {
+ setSelectedTrigger(trigger)
+ setShowCreateTriggerForm(true)
+ }
}
const deleteTrigger = (trigger: any) => {
diff --git a/apps/studio/public/img/previews/inline-editor-preview.png b/apps/studio/public/img/previews/inline-editor-preview.png
new file mode 100644
index 00000000000..13e749e9770
Binary files /dev/null and b/apps/studio/public/img/previews/inline-editor-preview.png differ
diff --git a/apps/studio/state/app-state.ts b/apps/studio/state/app-state.ts
index 57cc6d73c90..1dedf42b7f0 100644
--- a/apps/studio/state/app-state.ts
+++ b/apps/studio/state/app-state.ts
@@ -1,8 +1,9 @@
import type { Message as MessageType } from 'ai/react'
import { LOCAL_STORAGE_KEYS as COMMON_LOCAL_STORAGE_KEYS } from 'common'
-import { SupportedAssistantEntities } from 'components/ui/AIAssistantPanel/AIAssistant.types'
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
+import { SupportedAssistantEntities } from 'components/ui/AIAssistantPanel/AIAssistant.types'
import { proxy, snapshot, subscribe, useSnapshot } from 'valtio'
+import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries'
export type CommonDatabaseEntity = {
id: number
@@ -16,6 +17,12 @@ export type SuggestionsType = {
prompts?: string[]
}
+export type Template = {
+ name: string
+ description: string
+ content: string
+}
+
type AiAssistantPanelType = {
open: boolean
messages: MessageType[]
@@ -30,6 +37,17 @@ type AiAssistantPanelType = {
tables: { schema: string; name: string }[]
}
+type EditorPanelType = {
+ open: boolean
+ initialValue?: string
+ label?: string
+ saveLabel?: string
+ onSave?: (value: string) => void
+ functionName?: string
+ templates?: Template[]
+ initialPrompt?: string
+}
+
type DashboardHistoryType = {
sql?: string
editor?: string
@@ -47,6 +65,19 @@ const INITIAL_AI_ASSISTANT: AiAssistantPanelType = {
tables: [],
}
+const INITIAL_EDITOR_PANEL: EditorPanelType = {
+ open: false,
+ initialValue: '',
+ label: '',
+ saveLabel: '',
+ initialPrompt: '',
+ templates: SQL_TEMPLATES.filter((template) => template.type === 'template').map((template) => ({
+ name: template.title,
+ description: template.description,
+ content: template.sql,
+ })),
+}
+
const EMPTY_DASHBOARD_HISTORY: DashboardHistoryType = {
sql: undefined,
editor: undefined,
@@ -56,6 +87,7 @@ const getInitialState = () => {
if (typeof window === 'undefined') {
return {
aiAssistantPanel: INITIAL_AI_ASSISTANT,
+ editorPanel: INITIAL_EDITOR_PANEL,
dashboardHistory: EMPTY_DASHBOARD_HISTORY,
activeDocsSection: ['introduction'],
docsLanguage: 'js',
@@ -74,11 +106,13 @@ const getInitialState = () => {
}
const stored = localStorage.getItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE)
+ const storedEditor = localStorage.getItem(LOCAL_STORAGE_KEYS.EDITOR_PANEL_STATE)
const urlParams = new URLSearchParams(window.location.search)
const aiAssistantPanelOpenParam = urlParams.get('aiAssistantPanelOpen')
let parsedAiAssistant = INITIAL_AI_ASSISTANT
+ let parsedEditorPanel = INITIAL_EDITOR_PANEL
try {
if (stored) {
@@ -89,6 +123,9 @@ const getInitialState = () => {
return value
})
}
+ if (storedEditor) {
+ parsedEditorPanel = JSON.parse(storedEditor)
+ }
} catch {
// Ignore parsing errors
}
@@ -101,6 +138,7 @@ const getInitialState = () => {
? aiAssistantPanelOpenParam === 'true'
: parsedAiAssistant.open,
},
+ editorPanel: parsedEditorPanel,
dashboardHistory: EMPTY_DASHBOARD_HISTORY,
activeDocsSection: ['introduction'],
docsLanguage: 'js',
@@ -206,8 +244,12 @@ export const appState = proxy({
},
setAiAssistantPanel: (value: Partial) => {
- const hasEntityChanged = value.entity?.id !== appState.aiAssistantPanel.entity?.id
+ // Close Editor panel if AI Assistant panel is being opened
+ if (value.open && appState.editorPanel.open) {
+ appState.editorPanel.open = false
+ }
+ const hasEntityChanged = value.entity?.id !== appState.aiAssistantPanel.entity?.id
appState.aiAssistantPanel = {
...appState.aiAssistantPanel,
content: hasEntityChanged ? '' : appState.aiAssistantPanel.content,
@@ -227,21 +269,49 @@ export const appState = proxy({
appState.ongoingQueriesPanelOpen = value
},
+ setEditorPanel: (value: Partial) => {
+ // Close AI Assistant panel if editor panel is being opened
+ if (value.open && appState.aiAssistantPanel.open) {
+ appState.aiAssistantPanel.open = false
+ }
+
+ // Reset templates to initial if initialValue is empty
+ if (value.initialValue === '') {
+ value.templates = INITIAL_EDITOR_PANEL.templates
+ }
+
+ if (!value.open) {
+ value.initialPrompt = INITIAL_EDITOR_PANEL.initialPrompt
+ }
+
+ appState.editorPanel = {
+ ...appState.editorPanel,
+ ...value,
+ }
+ },
+
mobileMenuOpen: false,
setMobileMenuOpen: (value: boolean) => {
appState.mobileMenuOpen = value
},
})
-// Set up localStorage subscription
+// Set up localStorage subscriptions
if (typeof window !== 'undefined') {
subscribe(appState, () => {
- const state = {
+ // Save AI assistant state with limited message history
+ const aiAssistantState = {
...appState.aiAssistantPanel,
// limit to 20 messages so as to not overflow the context window
messages: appState.aiAssistantPanel.messages?.slice(-20),
}
- localStorage.setItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE, JSON.stringify(state))
+ localStorage.setItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE, JSON.stringify(aiAssistantState))
+
+ // Save editor panel state
+ localStorage.setItem(
+ LOCAL_STORAGE_KEYS.EDITOR_PANEL_STATE,
+ JSON.stringify(appState.editorPanel)
+ )
})
}
diff --git a/packages/ui/src/components/ExpandingTextArea/index.tsx b/packages/ui/src/components/ExpandingTextArea/index.tsx
index 8184f243e2e..8236886d4db 100644
--- a/packages/ui/src/components/ExpandingTextArea/index.tsx
+++ b/packages/ui/src/components/ExpandingTextArea/index.tsx
@@ -1,6 +1,6 @@
'use client'
-import React, { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
+import React, { forwardRef, useImperativeHandle, useRef } from 'react'
import { cn } from '../../lib/utils'
import { TextArea } from '../shadcn/ui/text-area'
@@ -14,31 +14,34 @@ export interface ExpandingTextAreaProps extends React.TextareaHTMLAttributes(
({ className, value, ...props }, ref) => {
- const textAreaRef = useRef(null)
+ const internalRef = useRef(null)
- // Expose the ref to the parent component
- useImperativeHandle(ref, () => textAreaRef.current!)
- /**
- * This effect is used to resize the textarea based on the content
- */
- useEffect(() => {
- if (textAreaRef) {
- if (textAreaRef.current && !value) {
- textAreaRef.current.style.height = '40px'
- } else if (textAreaRef && textAreaRef.current) {
- textAreaRef.current.style.height = 'auto'
- const newHeight = textAreaRef.current.scrollHeight + 'px'
- textAreaRef.current.style.height = newHeight
- }
+ useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement, [])
+
+ const updateTextAreaHeight = (element: HTMLTextAreaElement | null) => {
+ if (!element) return
+
+ // Update the height
+ if (!value) {
+ element.style.height = 'auto'
+ element.style.minHeight = '36px'
+ } else {
+ element.style.height = 'auto'
+ element.style.height = element.scrollHeight + 'px'
}
- }, [value, textAreaRef])
+ }
return (