diff --git a/apps/design-system/app/layout.tsx b/apps/design-system/app/layout.tsx index e1e5c9ca5d6a3..de7f2407868e3 100644 --- a/apps/design-system/app/layout.tsx +++ b/apps/design-system/app/layout.tsx @@ -3,6 +3,8 @@ import '@/styles/globals.css' import type { Metadata, Viewport } from 'next' +import { genFaviconData } from 'common/MetaFavicons/app-router' + import { Providers } from './Providers' import { Toaster } from './toaster' import { inter, manrope, sourceCodePro } from '@/lib/fonts' @@ -11,99 +13,6 @@ const className = `${inter.variable} ${manrope.variable} ${sourceCodePro.variabl const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || '/design-system' -const genFaviconData = (basePath: string): Metadata['icons'] => ({ - icon: { - url: `${basePath}/favicon/favicon.ico`, - type: 'image/x-icon', - }, - shortcut: `${basePath}/favicon/favicon.ico`, - apple: `${basePath}/favicon/favicon.ico`, - other: [ - { - rel: 'apple-touch-icon-precomposed', - url: `${basePath}/favicon/apple-icon-57x57.png`, - sizes: '57x57', - }, - { - rel: 'apple-touch-icon-precomposed', - url: `${basePath}/favicon/apple-icon-60x60.png`, - sizes: '60x60', - }, - { - rel: 'apple-touch-icon-precomposed', - url: `${basePath}/favicon/apple-icon-72x72.png`, - sizes: '72x72', - }, - { - rel: 'apple-touch-icon-precomposed', - url: `${basePath}/favicon/apple-icon-76x76.png`, - sizes: '76x76', - }, - { - rel: 'apple-touch-icon-precomposed', - url: `${basePath}/favicon/apple-icon-114x114.png`, - sizes: '114x114', - }, - { - rel: 'apple-touch-icon-precomposed', - url: `${basePath}/favicon/apple-icon-120x120.png`, - sizes: '120x120', - }, - { - rel: 'apple-touch-icon-precomposed', - url: `${basePath}/favicon/apple-icon-144x144.png`, - sizes: '144x144', - }, - { - rel: 'apple-touch-icon-precomposed', - url: `${basePath}/favicon/apple-icon-152x152.png`, - sizes: '152x152', - }, - { - rel: 'icon', - url: `${basePath}/favicon/favicon-16x16.png`, - type: 'image/png', - sizes: '16x16', - }, - { - rel: 'icon', - url: `${basePath}/favicon/favicon-32x32.png`, - type: 'image/png', - sizes: '32x32', - }, - { - rel: 'icon', - url: `${basePath}/favicon/favicon-48x48.png`, - type: 'image/png', - sizes: '48x48', - }, - { - rel: 'icon', - url: `${basePath}/favicon/favicon-96x96.png`, - type: 'image/png', - sizes: '96x96', - }, - { - rel: 'icon', - url: `${basePath}/favicon/favicon-128x128.png`, - type: 'image/png', - sizes: '128x128', - }, - { - rel: 'icon', - url: `${basePath}/favicon/favicon-180x180.png`, - type: 'image/png', - sizes: '180x180', - }, - { - rel: 'icon', - url: `${basePath}/favicon/favicon-196x196.png`, - type: 'image/png', - sizes: '196x196', - }, - ], -}) - export const metadata: Metadata = { applicationName: 'Supabase Design System', title: 'Supabase Design System', diff --git a/apps/docs/app/page.tsx b/apps/docs/app/page.tsx index 2a66ed19c3544..452dad1311080 100644 --- a/apps/docs/app/page.tsx +++ b/apps/docs/app/page.tsx @@ -177,7 +177,7 @@ const additionalResources = [ title: 'AI tools', description: 'Develop with Supabase AI-first using plugins, MCP, and skills.', icon: 'ai-tools', - href: '/guides/ai', + href: '/guides/ai-tools', }, { title: 'Platform guides', diff --git a/apps/docs/content/_partials/project_setup.mdx b/apps/docs/content/_partials/project_setup.mdx index d05a5122270a6..0017e7aa2dbee 100644 --- a/apps/docs/content/_partials/project_setup.mdx +++ b/apps/docs/content/_partials/project_setup.mdx @@ -22,7 +22,7 @@ Now set up the database schema. You can use the "User Management Starter" quicks 1. Go to the [SQL Editor](/dashboard/project/_/sql) page in the Dashboard. -2. Click **User Management Starter** under the **Community > Quickstarts** tab. +2. Click **User Management Starter** under the **Reference > Examples** tab. 3. Click **Run**. diff --git a/apps/docs/content/guides/ai-tools/ai-prompts.mdx b/apps/docs/content/guides/ai-tools/ai-prompts.mdx index fa2dd7bcfaf0a..3b7d7da14fb36 100644 --- a/apps/docs/content/guides/ai-tools/ai-prompts.mdx +++ b/apps/docs/content/guides/ai-tools/ai-prompts.mdx @@ -26,4 +26,4 @@ You can load these prompts into various tools. Here are common options and where | JetBrains IDEs | `guidelines.md` | [Customize guidelines](https://www.jetbrains.com/help/junie/customize-guidelines.html) | | Gemini CLI | `GEMINI.md` | [Gemini CLI codelab](https://codelabs.developers.google.com/gemini-cli-hands-on) | | VS Code | `.instructions.md` | Configure `.instructions.md` | -| Windsurf | `guidelines.md` | Configure `guidelines.md` | +| Devin Desktop | `guidelines.md` | Configure `guidelines.md` | diff --git a/apps/docs/content/troubleshooting/fixing-520-errors-in-the-database-rest-api-Ur5-B2.mdx b/apps/docs/content/troubleshooting/fixing-520-errors-in-the-database-rest-api-Ur5-B2.mdx index 571c2161caaea..4746752efd3ab 100644 --- a/apps/docs/content/troubleshooting/fixing-520-errors-in-the-database-rest-api-Ur5-B2.mdx +++ b/apps/docs/content/troubleshooting/fixing-520-errors-in-the-database-rest-api-Ur5-B2.mdx @@ -34,7 +34,7 @@ const { data, error } = await supabase .not('id', 'in', '(5,6,7,8,9,...10,000)') ``` -To circumvent this issue, you must use [RPCs](/docs/reference/javascript/explain?queryGroups=example&example=call-a-postgres-function-with-arguments). They are database functions that you can call from the API. Instead of including a query's structure within the URL or header, they move it into the request's payload. +To circumvent this issue, you must use [RPCs](/docs/reference/javascript/rpc?queryGroups=example&example=call-a-postgres-function-with-arguments). They are database functions that you can call from the API. Instead of including a query's structure within the URL or header, they move it into the request's payload. Here is a basic example of a [database function](/docs/guides/database/functions) @@ -50,7 +50,7 @@ end; $$; ``` -The [RPC](/docs/reference/javascript/explain?queryGroups=example&example=call-a-postgres-function-with-arguments) can then call the function with an array that contains more than 16KB of data +The [RPC](/docs/reference/javascript/rpc?queryGroups=example&example=call-a-postgres-function-with-arguments) can then call the function with an array that contains more than 16KB of data ```javascript const { data, error } = await supabase.rpc('example', { id: ['e2f34fb9-bbf9-4649-9b2f-09ec56e67a42', ...900 more UUIDs] }) diff --git a/apps/docs/content/troubleshooting/understanding-postgresql-explain-output-Un9dqX.mdx b/apps/docs/content/troubleshooting/understanding-postgresql-explain-output-Un9dqX.mdx index f10c66b88b13a..4da53c8da05a4 100644 --- a/apps/docs/content/troubleshooting/understanding-postgresql-explain-output-Un9dqX.mdx +++ b/apps/docs/content/troubleshooting/understanding-postgresql-explain-output-Un9dqX.mdx @@ -30,7 +30,7 @@ The Postgres EXPLAIN command shows the execution plan of a SQL query. This plan **Using EXPLAIN with supabase-js Library** 1. Follow the [Performance Debugging Guide](/docs/guides/database/debugging-performance) to enable the functionality on your project. -2. Once debugging is enabled, you can use the [EXPLAIN](/docs/reference/javascript/explain?queryGroups=example&example=get-execution-plan-with-analyze-and-verbose) function in your application code. Here's how to use it +2. Once debugging is enabled, you can use the [EXPLAIN](/docs/reference/javascript/using-modifiers-explain?queryGroups=example&example=get-execution-plan-with-analyze-and-verbose) function in your application code. Here's how to use it ``` const { data, error } = await supabase diff --git a/apps/docs/data/content-listings/ai-tools.data.ts b/apps/docs/data/content-listings/ai-tools.data.ts index 090da11e0b335..6b4bf347d78d5 100644 --- a/apps/docs/data/content-listings/ai-tools.data.ts +++ b/apps/docs/data/content-listings/ai-tools.data.ts @@ -20,7 +20,7 @@ const ICON_ASSETS: Record = { kimi: { icon: '/docs/img/icons/agent-kimi-icon', hasLightIcon: true }, vscode: { icon: '/docs/img/icons/agent-vscode-icon', hasLightIcon: false }, antigravity: { icon: '/docs/img/icons/agent-antigravity-icon', hasLightIcon: false }, - windsurf: { icon: '/docs/img/icons/agent-windsurf-icon', hasLightIcon: true }, + windsurf: { icon: '/docs/img/icons/agent-devin-icon', hasLightIcon: true }, goose: { icon: '/docs/img/icons/agent-goose-icon', hasLightIcon: true }, factory: { icon: '/docs/img/icons/agent-factory-icon', hasLightIcon: true }, opencode: { icon: '/docs/img/icons/agent-opencode-icon', hasLightIcon: true }, diff --git a/apps/docs/public/img/icons/agent-devin-icon-light.svg b/apps/docs/public/img/icons/agent-devin-icon-light.svg new file mode 100644 index 0000000000000..63ee0af812c63 --- /dev/null +++ b/apps/docs/public/img/icons/agent-devin-icon-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/docs/public/img/icons/agent-devin-icon.svg b/apps/docs/public/img/icons/agent-devin-icon.svg new file mode 100644 index 0000000000000..99451235d42fd --- /dev/null +++ b/apps/docs/public/img/icons/agent-devin-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/studio/TANSTACK_MIGRATION.md b/apps/studio/TANSTACK_MIGRATION.md index 3253c7a0a4983..e9f5d3bb0b608 100644 --- a/apps/studio/TANSTACK_MIGRATION.md +++ b/apps/studio/TANSTACK_MIGRATION.md @@ -326,6 +326,7 @@ These are the layout-only TanStack files. Most hold a single product layout comp - [x] A `routes/project/$ref/explorer/index.tsx` ← `pages/project/[ref]/explorer/index.tsx` - [x] A `routes/project/$ref/explorer/notebook/$id.tsx` ← `pages/project/[ref]/explorer/notebook/[id].tsx` +- [x] A `routes/project/$ref/explorer/chat/$id.tsx` ← `pages/project/[ref]/explorer/chat/[id].tsx` ### Auth shell — `/sign-in`, `/sign-up`, etc. diff --git a/apps/studio/components/interfaces/Explorer/ChatEditor.tsx b/apps/studio/components/interfaces/Explorer/ChatEditor.tsx new file mode 100644 index 0000000000000..bd0e21e38514f --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/ChatEditor.tsx @@ -0,0 +1,103 @@ +import { useParams } from 'common' +import { Loader2, MessageSquare } from 'lucide-react' +import { useRouter } from 'next/router' +import { useEffect, useEffectEvent } from 'react' +import { Button } from 'ui' + +import { ExplorerChatToolbar } from './ExplorerChatToolbar' +import { useCreateChat } from './hooks' +import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { AssistantChat } from '@/components/ui/AIAssistantPanel/AssistantChat' +import { useAiAssistantState, useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' +import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' +import { createTabId, useTabsStateSnapshot } from '@/state/tabs' + +export const ChatEditor = () => { + const { id, ref } = useParams() + const router = useRouter() + const tabs = useTabsStateSnapshot() + const aiAssistant = useAiAssistantStateSnapshot() + const aiAssistantState = useAiAssistantState() + const { createChat, openChat } = useCreateChat() + const { activeSidebar } = useSidebarManagerSnapshot() + const chat = id ? aiAssistant.chats[id] : undefined + const chatInstance = id ? aiAssistant.chatInstances[id] : undefined + const tabId = id ? createTabId('chat', { id }) : undefined + const shortcutsEnabled = activeSidebar?.id !== SIDEBAR_KEYS.AI_ASSISTANT + + const syncChatTab = useEffectEvent(() => { + if (!id || !chat) return + + aiAssistantState.ensureChatInstance(id) + const nextTabId = createTabId('chat', { id }) + tabs.addTab({ + id: nextTabId, + type: 'chat', + label: chat.name, + metadata: { chatId: id }, + isPreview: false, + }) + tabs.updateTab(nextTabId, { label: chat.name }) + }) + + const removeDeletedChatTab = useEffectEvent(() => { + if (!tabId || !tabs.openTabs.includes(tabId)) return + + tabs.handleTabClose({ + id: tabId, + router, + editor: 'explorer', + onClearDashboardHistory: () => {}, + }) + }) + + useEffect(() => syncChatTab(), [id, chat?.name]) + + useEffect(() => { + if (aiAssistant.isInitialized && id && !chat) removeDeletedChatTab() + }, [aiAssistant.isInitialized, id, chat]) + + if (!aiAssistant.isInitialized || (chat && !chatInstance)) { + return ( +
+ +
+ ) + } + + if (!id || !chat) { + return ( +
+ +
+

Chat not found

+

+ This chat may have been deleted or is no longer available. +

+
+ +
+ ) + } + + const handleBranchChat = (messageId: string) => { + const branchId = aiAssistantState.createBranch(id, messageId) + if (branchId) openChat(branchId) + } + + return ( + createChat()} + onSelectChat={openChat} + onBranchChat={handleBranchChat} + renderHeader={(headerProps) => ( + + )} + /> + ) +} diff --git a/apps/studio/components/interfaces/Explorer/ExplorerChatToolbar.tsx b/apps/studio/components/interfaces/Explorer/ExplorerChatToolbar.tsx new file mode 100644 index 0000000000000..0444012d883ca --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/ExplorerChatToolbar.tsx @@ -0,0 +1,115 @@ +import { Clipboard, MessageSquare, MoreVertical, Settings } from 'lucide-react' +import { useState } from 'react' +import { toast } from 'sonner' +import { + copyToClipboard, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from 'ui' + +import { + ExplorerToolbar, + ExplorerToolbarAction, + ExplorerToolbarActions, + ExplorerToolbarIcon, + ExplorerToolbarTitle, +} from './ExplorerToolbar' +import { AIAssistantMetadataWarning } from '@/components/ui/AIAssistantPanel/AIAssistantMetadataWarning' +import type { AssistantChatHeaderProps } from '@/components/ui/AIAssistantPanel/AssistantChat' +import { ShortcutPills } from '@/components/ui/ShortcutTooltip' +import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' +import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useShortcut } from '@/state/shortcuts/useShortcut' + +interface ExplorerChatToolbarProps extends AssistantChatHeaderProps { + chatId: string + shortcutsEnabled: boolean +} + +export const ExplorerChatToolbar = ({ + chatId, + shortcutsEnabled, + isChatLoading, + showMetadataWarning, + updatedOptInSinceMCP, + isHipaaProjectDisallowed, + aiOptInLevel, +}: ExplorerChatToolbarProps) => { + const snap = useAiAssistantStateSnapshot() + const chat = snap.chats[chatId] + const [isOptInModalOpen, setIsOptInModalOpen] = useState(false) + + const handleCopyChatId = () => { + copyToClipboard(chatId, () => toast.success(`Copied chat ID for ${chat?.name}`)) + } + + const handleSaveName = (name: string) => { + if (name.trim()) snap.renameChat(chatId, name.trim()) + } + + useShortcut(SHORTCUT_IDS.AI_ASSISTANT_COPY_CHAT_ID, handleCopyChatId, { + enabled: shortcutsEnabled && !isChatLoading, + }) + useShortcut(SHORTCUT_IDS.AI_ASSISTANT_OPEN_PERMISSIONS, () => setIsOptInModalOpen(true), { + enabled: shortcutsEnabled && !isChatLoading, + }) + + return ( +
+ + + + + {chat?.name ?? ''} + + + + } + disabled={isChatLoading} + /> + + + +
+ + Copy chat ID +
+ +
+ + setIsOptInModalOpen(true)} + > +
+ + Permission settings +
+ +
+
+
+
+
+ +
+ ) +} diff --git a/apps/studio/components/interfaces/Explorer/ExplorerHome.tsx b/apps/studio/components/interfaces/Explorer/ExplorerHome.tsx index 786f4ad6fd220..ed15f4b7f7e5e 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerHome.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerHome.tsx @@ -1,14 +1,15 @@ import { MessageCirclePlus, NotebookText, SquareCode } from 'lucide-react' import { useState } from 'react' -import { useCreateNotebook, useCreateQuery } from './hooks' +import { useCreateChat, useCreateNotebook, useCreateQuery } from './hooks' import { ActionCard } from '@/components/layouts/Tabs/ActionCard' import { AssistantChatForm } from '@/components/ui/AIAssistantPanel/AssistantChatForm' -import { AssistantModel } from '@/state/ai-assistant-state' +import type { AssistantModel } from '@/state/ai-assistant-state' export const ExplorerHome = () => { const { createNotebook } = useCreateNotebook() const { createQuery } = useCreateQuery() + const { createChat } = useCreateChat() const [value, setValue] = useState('') const [selectedModel, setSelectedModal] = useState('gpt-5.4-nano') @@ -34,7 +35,7 @@ export const ExplorerHome = () => { onValueChange={(e) => setValue(e.target.value)} selectedModel={selectedModel} onSelectModel={setSelectedModal} - onSubmit={() => {}} + onSubmit={(message) => createChat({ initialMessage: message, model: selectedModel })} />
diff --git a/apps/studio/components/interfaces/Explorer/__tests__/ChatEditor.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/ChatEditor.test.tsx new file mode 100644 index 0000000000000..1aa2d95956d47 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/__tests__/ChatEditor.test.tsx @@ -0,0 +1,122 @@ +import { fireEvent, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { ChatEditor } from '../ChatEditor' +import { customRender } from '@/tests/lib/custom-render' + +const mocks = vi.hoisted(() => ({ + addTab: vi.fn(), + createBranch: vi.fn(), + createChat: vi.fn(), + ensureChatInstance: vi.fn(), + handleTabClose: vi.fn(), + openChat: vi.fn(), + push: vi.fn(), + updateTab: vi.fn(), + useParams: vi.fn(), + assistantSnapshot: vi.fn(), +})) + +vi.mock('common', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useParams: () => mocks.useParams() } +}) + +vi.mock('next/router', () => ({ + useRouter: () => ({ query: { ref: 'default' }, push: mocks.push }), +})) + +vi.mock('@/components/interfaces/Explorer/hooks', () => ({ + useCreateChat: () => ({ createChat: mocks.createChat, openChat: mocks.openChat }), +})) + +vi.mock('@/state/tabs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTabsStateSnapshot: () => ({ + addTab: mocks.addTab, + handleTabClose: mocks.handleTabClose, + openTabs: ['chat-chat-1'], + updateTab: mocks.updateTab, + }), + } +}) + +vi.mock('@/state/ai-assistant-state', () => ({ + useAiAssistantStateSnapshot: () => mocks.assistantSnapshot(), + useAiAssistantState: () => ({ + createBranch: mocks.createBranch, + ensureChatInstance: mocks.ensureChatInstance, + }), +})) + +vi.mock('@/state/sidebar-manager-state', () => ({ + useSidebarManagerSnapshot: () => ({ activeSidebar: undefined }), +})) + +vi.mock('@/components/ui/AIAssistantPanel/AssistantChat', () => ({ + AssistantChat: ({ + chatId, + onSelectChat, + }: { + chatId: string + onSelectChat: (id: string) => void + }) => ( + + ), +})) + +describe('ChatEditor', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.useParams.mockReturnValue({ ref: 'default', id: 'chat-1' }) + mocks.assistantSnapshot.mockReturnValue({ + isInitialized: true, + chats: { 'chat-1': { id: 'chat-1', name: 'Investigate errors' } }, + chatInstances: { 'chat-1': {} }, + }) + }) + + it('renders and registers the routed chat without changing sidebar selection', () => { + customRender() + + expect(mocks.ensureChatInstance).toHaveBeenCalledWith('chat-1') + expect(screen.getByRole('button', { name: 'Assistant' })).toHaveAttribute( + 'data-chat-id', + 'chat-1' + ) + expect(mocks.addTab).toHaveBeenCalledWith({ + id: 'chat-chat-1', + type: 'chat', + label: 'Investigate errors', + metadata: { chatId: 'chat-1' }, + isPreview: false, + }) + }) + + it('routes shared chat navigation through Explorer', () => { + customRender() + + fireEvent.click(screen.getByRole('button', { name: 'Assistant' })) + + expect(mocks.openChat).toHaveBeenCalledWith('chat-2') + }) + + it('removes an orphaned tab only after chat hydration completes', () => { + mocks.assistantSnapshot.mockReturnValue({ + isInitialized: true, + chats: {}, + chatInstances: {}, + }) + + customRender() + + expect(screen.getByRole('heading', { name: 'Chat not found' })).toBeVisible() + expect(mocks.handleTabClose).toHaveBeenCalledWith( + expect.objectContaining({ id: 'chat-chat-1', editor: 'explorer' }) + ) + }) +}) diff --git a/apps/studio/components/interfaces/Explorer/__tests__/hooks.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/hooks.test.tsx new file mode 100644 index 0000000000000..321dbd4c94db2 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/__tests__/hooks.test.tsx @@ -0,0 +1,111 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useCreateChat } from '../hooks' + +const { + mockCreateChat, + mockPush, + mockSelectChat, + mockSetContext, + mockSetModel, + mockWhenInitialized, +} = vi.hoisted(() => ({ + mockCreateChat: vi.fn(() => 'chat-2'), + mockPush: vi.fn(), + mockSelectChat: vi.fn(), + mockSetContext: vi.fn(), + mockSetModel: vi.fn(), + mockWhenInitialized: vi.fn(() => Promise.resolve()), +})) + +vi.mock('next/router', () => ({ useRouter: () => ({ push: mockPush }) })) +vi.mock('@/hooks/misc/useSelectedProject', () => ({ + useSelectedProjectQuery: () => ({ + data: { ref: 'default', connectionString: 'postgres://example' }, + }), +})) +vi.mock('@/hooks/misc/useSelectedOrganization', () => ({ + useSelectedOrganizationQuery: () => ({ data: { slug: 'acme' } }), +})) +vi.mock('@/state/ai-assistant-state', () => ({ + useAiAssistantState: () => ({ + createChat: mockCreateChat, + selectChat: mockSelectChat, + setContext: mockSetContext, + setModel: mockSetModel, + }), + whenAiAssistantInitialized: () => mockWhenInitialized(), +})) + +describe('useCreateChat', () => { + beforeEach(() => { + vi.clearAllMocks() + mockWhenInitialized.mockImplementation(() => Promise.resolve()) + }) + + it('creates and opens Explorer chats without changing the sidebar selection', async () => { + const { result } = renderHook(() => useCreateChat()) + + await act(async () => { + await result.current.createChat({ + name: 'Investigate errors', + initialMessage: 'What happened?', + model: 'gpt-5.4-nano', + }) + }) + + expect(mockSetContext).toHaveBeenCalledWith({ + projectRef: 'default', + orgSlug: 'acme', + connectionString: 'postgres://example', + }) + expect(mockCreateChat).toHaveBeenCalledWith({ + name: 'Investigate errors', + initialMessage: 'What happened?', + }) + expect(mockSetModel).toHaveBeenCalledWith('gpt-5.4-nano') + expect(mockPush).toHaveBeenCalledWith('/project/default/explorer/chat/chat-2') + expect(mockSelectChat).not.toHaveBeenCalled() + + act(() => result.current.openChat('chat-1')) + + expect(mockPush).toHaveBeenLastCalledWith('/project/default/explorer/chat/chat-1') + expect(mockSelectChat).not.toHaveBeenCalled() + }) + + // Hydration replaces the chat map and the model wholesale, so a chat created mid-load would be + // dropped the moment the persisted state lands + it('waits for the assistant state to hydrate before creating the chat', async () => { + let resolveHydration = () => {} + mockWhenInitialized.mockImplementation( + () => + new Promise((resolve) => { + resolveHydration = resolve + }) + ) + + const { result } = renderHook(() => useCreateChat()) + + let created: Promise | undefined + await act(async () => { + created = result.current.createChat({ name: 'Investigate errors', model: 'gpt-5.4-nano' }) + }) + + expect(mockCreateChat).not.toHaveBeenCalled() + expect(mockSetModel).not.toHaveBeenCalled() + expect(mockPush).not.toHaveBeenCalled() + + await act(async () => { + resolveHydration() + await created + }) + + expect(mockCreateChat).toHaveBeenCalledWith({ + name: 'Investigate errors', + initialMessage: undefined, + }) + expect(mockSetModel).toHaveBeenCalledWith('gpt-5.4-nano') + expect(mockPush).toHaveBeenCalledWith('/project/default/explorer/chat/chat-2') + }) +}) diff --git a/apps/studio/components/interfaces/Explorer/hooks.ts b/apps/studio/components/interfaces/Explorer/hooks.ts index 506236d2cf26b..61449136ffa86 100644 --- a/apps/studio/components/interfaces/Explorer/hooks.ts +++ b/apps/studio/components/interfaces/Explorer/hooks.ts @@ -1,9 +1,12 @@ import { useRouter } from 'next/router' import { createMarkdownCellSkeleton, createQueryCellSkeleton } from './utils' +import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { generateUuid } from '@/lib/api/snippets.browser' import { useProfile } from '@/lib/profile' +import type { AssistantModel } from '@/state/ai-assistant-state' +import { useAiAssistantState, whenAiAssistantInitialized } from '@/state/ai-assistant-state' import { useExplorerQueryStateSnapshot } from '@/state/explorer-query' import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' import { type Notebook } from '@/state/notebooks/types' @@ -73,6 +76,54 @@ This is a sample paragraph to demonstrate the Markdown cells return { createNotebook } } +export const useCreateChat = () => { + const router = useRouter() + const { data: project } = useSelectedProjectQuery() + const { data: organization } = useSelectedOrganizationQuery() + const aiAssistantState = useAiAssistantState() + + const openChat = (id: string) => { + if (!project) { + console.error('Project is required') + return undefined + } + + router.push(`/project/${project.ref}/explorer/chat/${id}`) + } + + const createChat = async ({ + name, + initialMessage, + model, + }: { + name?: string + initialMessage?: string + model?: AssistantModel + } = {}) => { + if (!project) { + console.error('Project is required') + return undefined + } + + // Hydration replaces the chat map and the selected model wholesale, so wait it out before + // creating anything — otherwise the new chat is dropped as soon as the persisted state lands. + await whenAiAssistantInitialized(aiAssistantState) + + aiAssistantState.setContext({ + projectRef: project.ref, + orgSlug: organization?.slug, + connectionString: project.connectionString ?? '', + }) + if (model) aiAssistantState.setModel(model) + + const id = aiAssistantState.createChat({ name, initialMessage }) + router.push(`/project/${project.ref}/explorer/chat/${id}`) + return id + } + + return { createChat, openChat } +} + export const useCreateQuery = () => { const router = useRouter() const { data: project } = useSelectedProjectQuery() diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.constants.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.constants.tsx index fc195984fc1f7..70bdf43bf6fe3 100644 --- a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.constants.tsx +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.constants.tsx @@ -4,7 +4,7 @@ import { type ComponentType, type PropsWithChildren } from 'react' import { Button, cn } from 'ui' import { InnerSideBarFilters, InnerSideBarFilterSearchInput } from 'ui-patterns/InnerSideMenu' -import { useCreateNotebook } from '@/components/interfaces/Explorer/hooks' +import { useCreateChat, useCreateNotebook } from '@/components/interfaces/Explorer/hooks' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' export type ExplorerResourceType = 'notebook' | 'chat' @@ -52,6 +52,7 @@ export const ExplorerNavResourceWrapper = ({ onBack: () => void }>) => { const { createNotebook } = useCreateNotebook() + const { createChat } = useCreateChat() const searchPlaceholder = EXPLORER_SECTIONS.find((x) => x.type === type)?.searchPlaceholder return ( @@ -94,6 +95,7 @@ export const ExplorerNavResourceWrapper = ({ tooltip={{ content: { side: 'bottom', text: `New ${type}` } }} onClick={() => { if (type === 'notebook') createNotebook() + if (type === 'chat') createChat() }} /> diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx index 0e797ba0b8ac9..ce1fc5158f33d 100644 --- a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx @@ -17,7 +17,11 @@ import { ExplorerNavChats } from './ExplorerNavChats' import { ExplorerNavHome } from './ExplorerNavHome' import { ExplorerNavNotebooks } from './ExplorerNavNotebooks' import { ExplorerQueryTabCoordinator } from '@/components/interfaces/Explorer/ExplorerQueryTabCoordinator' -import { useCreateNotebook, useCreateQuery } from '@/components/interfaces/Explorer/hooks' +import { + useCreateChat, + useCreateNotebook, + useCreateQuery, +} from '@/components/interfaces/Explorer/hooks' import { editorEntityTypes, EXPLORER_HOME_TAB, @@ -119,6 +123,7 @@ const HomeTabButton = () => { const NewTabButton = () => { const { createNotebook } = useCreateNotebook() const { createQuery } = useCreateQuery() + const { createChat } = useCreateChat() return ( @@ -146,7 +151,7 @@ const NewTabButton = () => { New notebook - + createChat()}> New chat diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.test.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.test.tsx new file mode 100644 index 0000000000000..9678159e2cca6 --- /dev/null +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.test.tsx @@ -0,0 +1,73 @@ +import { fireEvent, screen } from '@testing-library/react' +import type { PropsWithChildren } from 'react' +import { describe, expect, it, vi } from 'vitest' + +import { ExplorerNavChats } from './ExplorerNavChats' +import { customRender } from '@/tests/lib/custom-render' + +vi.mock('common', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useParams: () => ({ id: 'recent-chat' }) } +}) + +vi.mock('next/router', () => ({ + useRouter: () => ({ pathname: '/project/[ref]/explorer/chat/[id]' }), +})) + +vi.mock('./ExplorerLayout.constants', () => ({ + ExplorerNavResourceWrapper: ({ + children, + search, + setSearch, + }: PropsWithChildren<{ search: string; setSearch: (value: string) => void }>) => ( +
+ setSearch(event.target.value)} + /> + {children} +
+ ), + rowClassName: (isActive: boolean) => (isActive ? 'active' : 'inactive'), +})) + +vi.mock('@/components/interfaces/Explorer/hooks', () => ({ + useCreateChat: () => ({ openChat: vi.fn() }), +})) + +vi.mock('@/state/ai-assistant-state', () => ({ + useAiAssistantChatList: () => [ + { id: 'old-chat', name: 'Older investigation', updatedAt: new Date('2026-01-01') }, + { id: 'missing-date', name: 'Rehydrated chat' }, + { id: 'recent-chat', name: 'Recent investigation', updatedAt: new Date('2026-02-01') }, + { + id: 'support', + name: 'Support conversation', + updatedAt: new Date('2026-03-01'), + supportMetadata: { isSupportChat: true }, + }, + ], +})) + +describe('ExplorerNavChats', () => { + it('filters support chats, safely sorts rehydrated chats, and marks the route active', () => { + customRender() + + const chatButtons = screen.getAllByRole('button') + expect(chatButtons.map((button) => button.textContent)).toEqual([ + 'Recent investigation', + 'Older investigation', + 'Rehydrated chat', + ]) + expect(chatButtons[0]).toHaveClass('active') + expect(screen.queryByText('Support conversation')).not.toBeInTheDocument() + + fireEvent.change(screen.getByRole('textbox', { name: 'Search chats' }), { + target: { value: 'rehydrated' }, + }) + + expect(screen.getByRole('button')).toHaveTextContent('Rehydrated chat') + expect(screen.queryByText('Recent investigation')).not.toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.tsx index 7bef897932489..1c5b84a4448bc 100644 --- a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.tsx +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.tsx @@ -1,16 +1,60 @@ +import { useParams } from 'common' +import { MessageSquare } from 'lucide-react' +import { useRouter } from 'next/router' import { useState } from 'react' +import { cn } from 'ui' -import { ExplorerNavResourceWrapper } from './ExplorerLayout.constants' +import { ExplorerNavResourceWrapper, rowClassName } from './ExplorerLayout.constants' +import { useCreateChat } from '@/components/interfaces/Explorer/hooks' +import type { ChatSession } from '@/state/ai-assistant-state' +import { useAiAssistantChatList } from '@/state/ai-assistant-state' + +const getVisibleChats = (chats: ChatSession[], search: string): ChatSession[] => { + const normalizedSearch = search.trim().toLowerCase() + + return chats + .filter((chat) => !chat.supportMetadata?.isSupportChat) + .filter((chat) => !normalizedSearch || chat.name.toLowerCase().includes(normalizedSearch)) + .sort((a, b) => (b.updatedAt?.getTime() ?? 0) - (a.updatedAt?.getTime() ?? 0)) +} export const ExplorerNavChats = ({ onBack }: { onBack: () => void }) => { const [search, setSearch] = useState('') + const router = useRouter() + const { id } = useParams() + const { openChat } = useCreateChat() + const chatList = useAiAssistantChatList() - // [Joshen] Eventually will have data fetching for notebooks via useAiAssistantState + const chats = getVisibleChats(chatList, search) return (
-

No chats created yet

+ {chats.length === 0 ? ( +

+ {search ? 'No chats found' : 'No chats created yet'} +

+ ) : ( + chats.map((chat) => { + const isActive = router.pathname.includes('/explorer/chat/') && id === chat.id + + return ( + + ) + }) + )}
) diff --git a/apps/studio/components/layouts/Tabs/RecentItems.test.ts b/apps/studio/components/layouts/Tabs/RecentItems.test.ts new file mode 100644 index 0000000000000..f5dadc7bcfdb7 --- /dev/null +++ b/apps/studio/components/layouts/Tabs/RecentItems.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' + +import { getRecentItemHref } from './RecentItems' + +describe('getRecentItemHref', () => { + it('builds chat and notebook Explorer URLs from their IDs', () => { + expect( + getRecentItemHref( + { + id: 'chat-chat-1', + type: 'chat', + label: 'Chat', + timestamp: 1, + metadata: { chatId: 'chat-1' }, + }, + 'default' + ) + ).toBe('/project/default/explorer/chat/chat-1') + + expect( + getRecentItemHref( + { + id: 'notebook-notebook-1', + type: 'notebook', + label: 'Notebook', + timestamp: 1, + metadata: { notebookId: 'notebook-1' }, + }, + 'default' + ) + ).toBe('/project/default/explorer/notebook/notebook-1') + }) +}) diff --git a/apps/studio/components/layouts/Tabs/RecentItems.tsx b/apps/studio/components/layouts/Tabs/RecentItems.tsx index 2bae3f19acb8b..3808329cf5394 100644 --- a/apps/studio/components/layouts/Tabs/RecentItems.tsx +++ b/apps/studio/components/layouts/Tabs/RecentItems.tsx @@ -7,7 +7,30 @@ import { useEditorType } from '../editors/EditorsLayout.hooks' import { buildTableEditorUrl } from '@/components/grid/SupabaseGrid.utils' import { EntityTypeIcon } from '@/components/ui/EntityTypeIcon' import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' -import { editorEntityTypes, useTabsStateSnapshot } from '@/state/tabs' +import { editorEntityTypes, useTabsStateSnapshot, type RecentItem } from '@/state/tabs' + +export function getRecentItemHref(item: RecentItem, projectRef: string) { + switch (item.type) { + case 'sql': + return `/project/${projectRef}/sql/${item.metadata?.sqlId}` + case 'notebook': + return `/project/${projectRef}/explorer/notebook/${item.metadata?.notebookId}` + case 'chat': + return `/project/${projectRef}/explorer/chat/${item.metadata?.chatId}` + case 'r': + case 'v': + case 'm': + case 'f': + case 'p': + return buildTableEditorUrl({ + projectRef, + tableId: item.metadata?.tableId!, + schema: item.metadata?.schema, + }) + default: + return `/project/${projectRef}/explorer/${item.type}/${item.metadata?.schema}/${item.metadata?.name}` + } +} export function RecentItems() { const { ref } = useParams() @@ -53,21 +76,7 @@ export function RecentItems() { transition={{ delay: index * 0.012, duration: 0.15 }} >
diff --git a/apps/studio/components/layouts/Tabs/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx index 734071d8879b9..7191cbe0ac529 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.tsx +++ b/apps/studio/components/layouts/Tabs/Tabs.tsx @@ -93,7 +93,8 @@ export const EditorTabs = ({ const onClearDashboardHistory = () => { if (editor === 'table') { setLastVisitedTable(undefined) - } else if (editor === 'sql') { + } + if (editor === 'sql') { setLastVisitedSnippet(undefined) } } @@ -138,7 +139,8 @@ export const EditorTabs = ({ closeWithConfirmation(tabsToClose, () => { tabs.closeTabs(tabsToClose) onClearDashboardHistory() - tabs.handleTabNavigation(tabId, router) + + if (tabs.activeTab !== tabId) tabs.handleTabNavigation(tabId, router) }) } } diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.test.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.test.tsx new file mode 100644 index 0000000000000..415bb8e8d79cb --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { AIAssistantHeader } from './AIAssistantHeader' +import { customRender } from '@/tests/lib/custom-render' + +const { openChat } = vi.hoisted(() => ({ openChat: vi.fn() })) + +vi.mock('@/state/ai-assistant-state', () => ({ + useAiAssistantStateSnapshot: () => ({ + activeChatId: 'chat-1', + activeChat: { name: 'Investigate errors' }, + renameChat: vi.fn(), + }), +})) + +vi.mock('@/components/interfaces/Explorer/hooks', () => ({ + useCreateChat: () => ({ openChat }), +})) + +vi.mock('@/state/shortcuts/useShortcut', () => ({ useShortcut: vi.fn() })) + +vi.mock('./AIAssistantChatSelector', () => ({ + AIAssistantChatSelector: () => ( + + ), +})) + +vi.mock('./AIAssistantMetadataWarning', () => ({ + AIAssistantMetadataWarning: () => null, +})) + +const defaultProps = { + isChatLoading: false, + onNewChat: vi.fn(), + onCloseAssistant: vi.fn(), + showMetadataWarning: false, + updatedOptInSinceMCP: true, + isHipaaProjectDisallowed: false, + aiOptInLevel: 'full', +} + +describe('AIAssistantHeader', () => { + it('opens the active chat in Explorer and closes the sidebar', () => { + const onCloseAssistant = vi.fn() + customRender() + + fireEvent.click(screen.getByRole('button', { name: 'Open in Explorer' })) + + expect(openChat).toHaveBeenCalledWith('chat-1') + expect(onCloseAssistant).toHaveBeenCalledOnce() + expect(screen.queryByRole('button', { name: 'Minimize' })).not.toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.tsx index c2f7294590ac5..82546031fa9c8 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.tsx @@ -3,7 +3,6 @@ import { Edit, Maximize, MessageCirclePlus, - Minimize, MoreVertical, Settings, X, @@ -20,16 +19,15 @@ import { DropdownMenuTrigger, Input, } from 'ui' -import { Admonition } from 'ui-patterns/Admonition' import { ButtonTooltip } from '../ButtonTooltip' import { ShortcutPills, ShortcutTooltip } from '../ShortcutTooltip' import { AIAssistantChatSelector } from './AIAssistantChatSelector' -import { AIOptInModal } from './AIOptInModal' +import { AIAssistantMetadataWarning } from './AIAssistantMetadataWarning' +import { useCreateChat } from '@/components/interfaces/Explorer/hooks' import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' -import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' interface AIAssistantHeaderProps { isChatLoading: boolean @@ -53,11 +51,17 @@ export const AIAssistantHeader = ({ aiOptInLevel, }: AIAssistantHeaderProps) => { const snap = useAiAssistantStateSnapshot() - const { isMaximised, toggleMaximise } = useSidebarManagerSnapshot() + const { openChat } = useCreateChat() const [value, setValue] = useState(snap.activeChat?.name) const [isEditingName, setIsEditingName] = useState(false) const [isOptInModalOpen, setIsOptInModalOpen] = useState(false) + const handleOpenInExplorer = () => { + if (!snap.activeChatId) return + openChat(snap.activeChatId) + onCloseAssistant() + } + const handleCopyChatId = () => { copyToClipboard(snap.activeChatId ?? '', () => { toast.success(`Copied chat ID for ${snap.activeChat?.name}`) @@ -96,7 +100,7 @@ export const AIAssistantHeader = ({ enabled: shortcutsEnabled && !isChatLoading, }) - useShortcut(SHORTCUT_IDS.AI_ASSISTANT_MAXIMIZE, toggleMaximise, { + useShortcut(SHORTCUT_IDS.AI_ASSISTANT_MAXIMIZE, handleOpenInExplorer, { enabled: shortcutsEnabled && !isChatLoading, }) @@ -149,15 +153,15 @@ export const AIAssistantHeader = ({
- {showMetadataWarning && ( - - {!isHipaaProjectDisallowed && ( - - )} - - )} - setIsOptInModalOpen(false)} /> + ) } diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistantMetadataWarning.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistantMetadataWarning.tsx new file mode 100644 index 0000000000000..7e5c25ac00345 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistantMetadataWarning.tsx @@ -0,0 +1,58 @@ +import { Button } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' + +import { AIOptInModal } from './AIOptInModal' + +interface AIAssistantMetadataWarningProps { + visible: boolean + onVisibleChange: (visible: boolean) => void + showMetadataWarning: boolean + updatedOptInSinceMCP: boolean + isHipaaProjectDisallowed: boolean + aiOptInLevel: 'disabled' | 'schema' | 'full' | string | undefined +} + +export const AIAssistantMetadataWarning = ({ + visible, + onVisibleChange, + showMetadataWarning, + updatedOptInSinceMCP, + isHipaaProjectDisallowed, + aiOptInLevel, +}: AIAssistantMetadataWarningProps) => ( + <> + {showMetadataWarning && ( + + {!isHipaaProjectDisallowed && ( + + )} + + )} + onVisibleChange(false)} /> + +) diff --git a/apps/studio/components/ui/EntityTypeIcon.tsx b/apps/studio/components/ui/EntityTypeIcon.tsx index 755e3d0ba8788..6e4b826c17f13 100644 --- a/apps/studio/components/ui/EntityTypeIcon.tsx +++ b/apps/studio/components/ui/EntityTypeIcon.tsx @@ -1,4 +1,12 @@ -import { Eye, GitBranch, NotebookText, ScrollText, SquareCode, Table2 } from 'lucide-react' +import { + Eye, + GitBranch, + MessageSquare, + NotebookText, + ScrollText, + SquareCode, + Table2, +} from 'lucide-react' import { cn, SQL_ICON } from 'ui' import type { SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource' @@ -37,6 +45,7 @@ interface EntityTypeIconProps { | 'p' | 'notebook' | 'query' + | 'chat' | 'explorer-home' size?: number strokeWidth?: number @@ -122,6 +131,10 @@ export const EntityTypeIcon = ({ return } + if (type === 'chat') { + return + } + return (
+ +ChatPage.getLayout = (page) => ( + + {page} + +) + +export default ChatPage diff --git a/apps/studio/routeTree.gen.ts b/apps/studio/routeTree.gen.ts index 57160fc4e2441..182686af6c98c 100644 --- a/apps/studio/routeTree.gen.ts +++ b/apps/studio/routeTree.gen.ts @@ -231,6 +231,7 @@ import { Route as ProjectRefFunctionsFunctionSlugDetailsRouteImport } from './ro import { Route as ProjectRefFunctionsFunctionSlugCodeRouteImport } from './routes/project/$ref/functions/$functionSlug/code' import { Route as ProjectRefExplorerQueryIdRouteImport } from './routes/project/$ref/explorer/query/$id' import { Route as ProjectRefExplorerNotebookIdRouteImport } from './routes/project/$ref/explorer/notebook/$id' +import { Route as ProjectRefExplorerChatIdRouteImport } from './routes/project/$ref/explorer/chat/$id' import { Route as ProjectRefDatabaseTriggersEventRouteImport } from './routes/project/$ref/database/triggers/event' import { Route as ProjectRefDatabaseTriggersDataRouteImport } from './routes/project/$ref/database/triggers/data' import { Route as ProjectRefDatabaseTablesIdRouteImport } from './routes/project/$ref/database/tables/$id' @@ -1529,6 +1530,12 @@ const ProjectRefExplorerNotebookIdRoute = path: '/notebook/$id', getParentRoute: () => ProjectRefExplorerRoute, } as any) +const ProjectRefExplorerChatIdRoute = + ProjectRefExplorerChatIdRouteImport.update({ + id: '/chat/$id', + path: '/chat/$id', + getParentRoute: () => ProjectRefExplorerRoute, + } as any) const ProjectRefDatabaseTriggersEventRoute = ProjectRefDatabaseTriggersEventRouteImport.update({ id: '/event', @@ -2289,6 +2296,7 @@ export interface FileRoutesByFullPath { '/project/$ref/database/tables/$id': typeof ProjectRefDatabaseTablesIdRoute '/project/$ref/database/triggers/data': typeof ProjectRefDatabaseTriggersDataRoute '/project/$ref/database/triggers/event': typeof ProjectRefDatabaseTriggersEventRoute + '/project/$ref/explorer/chat/$id': typeof ProjectRefExplorerChatIdRoute '/project/$ref/explorer/notebook/$id': typeof ProjectRefExplorerNotebookIdRoute '/project/$ref/explorer/query/$id': typeof ProjectRefExplorerQueryIdRoute '/project/$ref/functions/$functionSlug/code': typeof ProjectRefFunctionsFunctionSlugCodeRoute @@ -2587,6 +2595,7 @@ export interface FileRoutesByTo { '/project/$ref/database/tables/$id': typeof ProjectRefDatabaseTablesIdRoute '/project/$ref/database/triggers/data': typeof ProjectRefDatabaseTriggersDataRoute '/project/$ref/database/triggers/event': typeof ProjectRefDatabaseTriggersEventRoute + '/project/$ref/explorer/chat/$id': typeof ProjectRefExplorerChatIdRoute '/project/$ref/explorer/notebook/$id': typeof ProjectRefExplorerNotebookIdRoute '/project/$ref/explorer/query/$id': typeof ProjectRefExplorerQueryIdRoute '/project/$ref/functions/$functionSlug/code': typeof ProjectRefFunctionsFunctionSlugCodeRoute @@ -2901,6 +2910,7 @@ export interface FileRoutesById { '/project/$ref/database/tables/$id': typeof ProjectRefDatabaseTablesIdRoute '/project/$ref/database/triggers/data': typeof ProjectRefDatabaseTriggersDataRoute '/project/$ref/database/triggers/event': typeof ProjectRefDatabaseTriggersEventRoute + '/project/$ref/explorer/chat/$id': typeof ProjectRefExplorerChatIdRoute '/project/$ref/explorer/notebook/$id': typeof ProjectRefExplorerNotebookIdRoute '/project/$ref/explorer/query/$id': typeof ProjectRefExplorerQueryIdRoute '/project/$ref/functions/$functionSlug/code': typeof ProjectRefFunctionsFunctionSlugCodeRoute @@ -3214,6 +3224,7 @@ export interface FileRouteTypes { | '/project/$ref/database/tables/$id' | '/project/$ref/database/triggers/data' | '/project/$ref/database/triggers/event' + | '/project/$ref/explorer/chat/$id' | '/project/$ref/explorer/notebook/$id' | '/project/$ref/explorer/query/$id' | '/project/$ref/functions/$functionSlug/code' @@ -3512,6 +3523,7 @@ export interface FileRouteTypes { | '/project/$ref/database/tables/$id' | '/project/$ref/database/triggers/data' | '/project/$ref/database/triggers/event' + | '/project/$ref/explorer/chat/$id' | '/project/$ref/explorer/notebook/$id' | '/project/$ref/explorer/query/$id' | '/project/$ref/functions/$functionSlug/code' @@ -3825,6 +3837,7 @@ export interface FileRouteTypes { | '/project/$ref/database/tables/$id' | '/project/$ref/database/triggers/data' | '/project/$ref/database/triggers/event' + | '/project/$ref/explorer/chat/$id' | '/project/$ref/explorer/notebook/$id' | '/project/$ref/explorer/query/$id' | '/project/$ref/functions/$functionSlug/code' @@ -5588,6 +5601,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectRefExplorerNotebookIdRouteImport parentRoute: typeof ProjectRefExplorerRoute } + '/project/$ref/explorer/chat/$id': { + id: '/project/$ref/explorer/chat/$id' + path: '/chat/$id' + fullPath: '/project/$ref/explorer/chat/$id' + preLoaderRoute: typeof ProjectRefExplorerChatIdRouteImport + parentRoute: typeof ProjectRefExplorerRoute + } '/project/$ref/database/triggers/event': { id: '/project/$ref/database/triggers/event' path: '/event' @@ -6536,12 +6556,14 @@ const ProjectRefEditorRouteWithChildren = interface ProjectRefExplorerRouteChildren { ProjectRefExplorerIndexRoute: typeof ProjectRefExplorerIndexRoute + ProjectRefExplorerChatIdRoute: typeof ProjectRefExplorerChatIdRoute ProjectRefExplorerNotebookIdRoute: typeof ProjectRefExplorerNotebookIdRoute ProjectRefExplorerQueryIdRoute: typeof ProjectRefExplorerQueryIdRoute } const ProjectRefExplorerRouteChildren: ProjectRefExplorerRouteChildren = { ProjectRefExplorerIndexRoute: ProjectRefExplorerIndexRoute, + ProjectRefExplorerChatIdRoute: ProjectRefExplorerChatIdRoute, ProjectRefExplorerNotebookIdRoute: ProjectRefExplorerNotebookIdRoute, ProjectRefExplorerQueryIdRoute: ProjectRefExplorerQueryIdRoute, } diff --git a/apps/studio/routes/project/$ref/explorer/chat/$id.tsx b/apps/studio/routes/project/$ref/explorer/chat/$id.tsx new file mode 100644 index 0000000000000..5ebd59a8e4285 --- /dev/null +++ b/apps/studio/routes/project/$ref/explorer/chat/$id.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router' + +import { ChatEditor } from '@/components/interfaces/Explorer/ChatEditor' + +export const Route = createFileRoute('/project/$ref/explorer/chat/$id')({ + component: ProjectExplorerChatRoute, +}) + +function ProjectExplorerChatRoute() { + return +} diff --git a/apps/studio/state/ai-assistant-state.hooks.test.tsx b/apps/studio/state/ai-assistant-state.hooks.test.tsx new file mode 100644 index 0000000000000..ed0e827ad3f5f --- /dev/null +++ b/apps/studio/state/ai-assistant-state.hooks.test.tsx @@ -0,0 +1,77 @@ +import { act, render, screen } from '@testing-library/react' +import type { PropsWithChildren } from 'react' +import { describe, expect, it } from 'vitest' + +import { + AiAssistantStateContext, + createAiAssistantState, + useAiAssistantChatList, + type AiAssistantState, +} from './ai-assistant-state' + +const ChatList = () => { + const chats = useAiAssistantChatList() + + return ( +
    + {chats.map((chat) => ( +
  • {chat.name}
  • + ))} +
+ ) +} + +const renderChatList = (state: AiAssistantState) => { + const Wrapper = ({ children }: PropsWithChildren) => ( + {children} + ) + + return render(, { wrapper: Wrapper }) +} + +describe('useAiAssistantChatList', () => { + // createChat, createBranch, deleteChat and loadPersistedState all replace state.chats wholesale, + // so subscribing to the object itself goes stale after the first replacement + it('rerenders when a chat is created after the first render', async () => { + const state = createAiAssistantState() + renderChatList(state) + + expect(screen.queryByText('Explorer chat')).not.toBeInTheDocument() + + await act(async () => { + state.createChat({ name: 'Explorer chat' }) + }) + + expect(await screen.findByText('Explorer chat')).toBeInTheDocument() + }) + + it('rerenders when chats are replaced by hydration and again when a chat is deleted', async () => { + const state = createAiAssistantState() + renderChatList(state) + + await act(async () => { + state.loadPersistedState({ + projectRef: 'default', + activeChatId: 'persisted-chat', + model: state.model, + chats: { + 'persisted-chat': { + id: 'persisted-chat', + name: 'Persisted chat', + messages: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + }) + }) + + expect(await screen.findByText('Persisted chat')).toBeInTheDocument() + + await act(async () => { + state.deleteChat('persisted-chat') + }) + + expect(screen.queryByText('Persisted chat')).not.toBeInTheDocument() + }) +}) diff --git a/apps/studio/state/ai-assistant-state.test.ts b/apps/studio/state/ai-assistant-state.test.ts index d4670010878f5..10dca9ca78d12 100644 --- a/apps/studio/state/ai-assistant-state.test.ts +++ b/apps/studio/state/ai-assistant-state.test.ts @@ -1,7 +1,11 @@ import { proxy, ref } from 'valtio/vanilla' import { describe, expect, it } from 'vitest' -import { createAiAssistantState, sanitizeForCloning } from './ai-assistant-state' +import { + createAiAssistantState, + sanitizeForCloning, + whenAiAssistantInitialized, +} from './ai-assistant-state' describe('AI assistant chat message sync', () => { // FE-3954: syncing the live array into valtio corrupted it with Proxies, breaking structuredClone in addToolApprovalResponse @@ -123,3 +127,44 @@ describe('AI assistant chat surface isolation', () => { expect(state.chatInstances[chatId].messages).toEqual(state.chats[chatId].messages) }) }) + +describe('whenAiAssistantInitialized', () => { + it('resolves immediately once the state has hydrated', async () => { + const state = createAiAssistantState() + state.isInitialized = true + + await expect(whenAiAssistantInitialized(state)).resolves.toBeUndefined() + }) + + // loadPersistedState replaces state.chats wholesale, so a chat created before hydration is lost + it('defers chat creation until hydration lands so the new chat survives', async () => { + const state = createAiAssistantState() + + const pending = whenAiAssistantInitialized(state) + const droppedChatId = state.createChat({ name: 'Created too early' }) + + state.loadPersistedState({ + projectRef: 'default', + activeChatId: 'persisted-chat', + model: state.model, + chats: { + 'persisted-chat': { + id: 'persisted-chat', + name: 'Persisted chat', + messages: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + }) + state.isInitialized = true + + expect(state.chats[droppedChatId]).toBeUndefined() + + await pending + const chatId = state.createChat({ name: 'Created after hydration' }) + + expect(state.chats[chatId]?.name).toBe('Created after hydration') + expect(state.chats['persisted-chat']).toBeDefined() + }) +}) diff --git a/apps/studio/state/ai-assistant-state.tsx b/apps/studio/state/ai-assistant-state.tsx index 8e0413bbc46a6..85eff5ae1a900 100644 --- a/apps/studio/state/ai-assistant-state.tsx +++ b/apps/studio/state/ai-assistant-state.tsx @@ -3,7 +3,14 @@ import { DefaultChatTransport, lastAssistantMessageIsCompleteWithApprovalRespons import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common' import { DBSchema, IDBPDatabase, openDB } from 'idb' import { debounce } from 'lodash' -import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' +import { + createContext, + PropsWithChildren, + useContext, + useEffect, + useReducer, + useState, +} from 'react' import { v4 as uuidv4 } from 'uuid' import { proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio' @@ -375,6 +382,7 @@ export const createAiAssistantState = (): AiAssistantState => { chatInstances: {}, pendingSpanIds: {}, messageSpanIds: {}, + isInitialized: false, setContext: (context: Partial) => { state.context = { ...state.context, ...context } @@ -382,6 +390,7 @@ export const createAiAssistantState = (): AiAssistantState => { resetAiAssistantPanel: () => { Object.assign(state, createInitialAiAssistantData()) + state.isInitialized = false }, setModel: (model: AssistantModel) => { @@ -675,6 +684,7 @@ export type AiAssistantState = AiAssistantData & { chatInstances: Record> pendingSpanIds: Record messageSpanIds: Record + isInitialized: boolean setContext: (context: Partial) => void setModel: (model: AssistantModel) => void createChat: (options?: CreateChatOptions) => string @@ -705,6 +715,7 @@ export const AiAssistantStateContextProvider = ({ children }: PropsWithChildren) // Effect to load state from IndexedDB on mount or projectRef change useEffect(() => { let isMounted = true + state.isInitialized = false async function loadAndInitializeState() { if (!project?.ref || typeof window === 'undefined') { @@ -733,6 +744,7 @@ export const AiAssistantStateContextProvider = ({ children }: PropsWithChildren) // 4. Ensure an active chat exists and handle URL overrides ensureActiveChatOrInitialize(state) + state.isInitialized = true } loadAndInitializeState() @@ -792,6 +804,33 @@ export const useAiAssistantStateSnapshot = (options?: Parameters { + const state = useContext(AiAssistantStateContext) + const [, rerender] = useReducer((count) => count + 1, 0) + // Subscribe to the parent, not `state.chats` — createChat, createBranch, deleteChat and + // loadPersistedState all replace `state.chats` wholesale, which would leave a subscription + // to the old object silently stale. + useEffect(() => subscribe(state, rerender), [state]) + return Object.values(state.chats) +} + +/** + * Resolves once the assistant state has hydrated from storage. `loadPersistedState` replaces + * `state.chats` wholesale, so anything that adds a chat has to wait for hydration or the new + * chat is dropped the moment the persisted state lands. + */ +export const whenAiAssistantInitialized = (state: AiAssistantState): Promise => { + if (state.isInitialized) return Promise.resolve() + + return new Promise((resolve) => { + const unsubscribe = subscribe(state, () => { + if (!state.isInitialized) return + unsubscribe() + resolve() + }) + }) +} + export const useAiAssistantState = () => { const state = useContext(AiAssistantStateContext) return state diff --git a/apps/studio/state/shortcuts/registry.ts b/apps/studio/state/shortcuts/registry.ts index f5fbdad2e6e7d..e2260bfb42690 100644 --- a/apps/studio/state/shortcuts/registry.ts +++ b/apps/studio/state/shortcuts/registry.ts @@ -266,7 +266,7 @@ export const SHORTCUT_DEFINITIONS: Record = { }, [SHORTCUT_IDS.AI_ASSISTANT_MAXIMIZE]: { id: SHORTCUT_IDS.AI_ASSISTANT_MAXIMIZE, - label: 'Maximize assistant', + label: 'Open chat in Explorer', sequence: ['A', '='], showInSettings: false, }, diff --git a/apps/studio/state/tabs.test.ts b/apps/studio/state/tabs.test.ts index b5545269d0914..c333833ca6006 100644 --- a/apps/studio/state/tabs.test.ts +++ b/apps/studio/state/tabs.test.ts @@ -1,7 +1,7 @@ import type { NextRouter } from 'next/router' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createTabsState, type Tab } from './tabs' +import { createTabId, createTabsState, type Tab } from './tabs' import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' const fakeRouter = () => ({ query: { ref: 'default' }, push: vi.fn() }) as unknown as NextRouter @@ -14,6 +14,52 @@ const sqlTab = (id: string): Tab => ({ metadata: { sqlId: id }, }) +describe('Explorer chat tabs', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('creates stable chat tab ids and navigates to the chat route', () => { + const store = createTabsState('default') + const router = fakeRouter() + const id = createTabId('chat', { id: 'assistant-chat-id' }) + + store.addTab({ + id, + type: 'chat', + label: 'Investigate errors', + metadata: { chatId: 'assistant-chat-id' }, + isPreview: false, + }) + store.handleTabNavigation(id, router) + + expect(id).toBe('chat-assistant-chat-id') + expect(router.push).toHaveBeenCalledWith('/project/default/explorer/chat/assistant-chat-id') + }) + + it('returns to Explorer home when the final chat tab closes', () => { + const store = createTabsState('default') + const router = fakeRouter() + const id = createTabId('chat', { id: 'assistant-chat-id' }) + + store.addTab({ + id, + type: 'chat', + label: 'Investigate errors', + metadata: { chatId: 'assistant-chat-id' }, + isPreview: false, + }) + store.handleTabClose({ + id, + router, + editor: 'explorer', + onClearDashboardHistory: () => {}, + }) + + expect(router.push).toHaveBeenCalledWith('/project/default/explorer') + }) +}) + describe('tabs recent items', () => { beforeEach(() => { localStorage.clear() diff --git a/apps/studio/state/tabs.tsx b/apps/studio/state/tabs.tsx index fbef013a92722..bc1a2cbfcb342 100644 --- a/apps/studio/state/tabs.tsx +++ b/apps/studio/state/tabs.tsx @@ -19,10 +19,10 @@ import type { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' export const editorEntityTypes = { table: ['r', 'v', 'm', 'f', 'p'], sql: ['sql'], - explorer: ['notebook', 'query'], + explorer: ['notebook', 'query', 'chat'], } -export type TabType = ENTITY_TYPE | 'sql' | 'notebook' | 'query' | 'explorer-home' +export type TabType = ENTITY_TYPE | 'sql' | 'notebook' | 'query' | 'chat' | 'explorer-home' /** Fixed id for Explorer's pinned, non-closable Home tab. */ export const EXPLORER_HOME_TAB_ID = 'explorer-home' @@ -45,6 +45,7 @@ type CreateTabIdParams = { sql: { id: string } notebook: { id: string } query: { id: string } + chat: { id: string } schema: { schema: string } view: never function: never @@ -63,6 +64,7 @@ export interface Tab { sqlId?: string notebookId?: string queryId?: string + chatId?: string scrollTop?: number /** * For SQL tabs, which backend the snippet queries (`'database'` | `'logs'`), @@ -134,6 +136,7 @@ export interface RecentItem { sqlId?: string notebookId?: string queryId?: string + chatId?: string sqlSource?: SqlSnippetSource } } @@ -438,6 +441,9 @@ export function createTabsState(projectRef: string) { case 'query': router.push(`/project/${router.query.ref}/explorer/query/${tab.metadata?.queryId}`) break + case 'chat': + router.push(`/project/${router.query.ref}/explorer/chat/${tab.metadata?.chatId}`) + break case 'explorer-home': router.push(`/project/${router.query.ref}/explorer`) break @@ -576,6 +582,7 @@ export function createTabsState(projectRef: string) { break case 'notebook': case 'query': + case 'chat': router.push(`/project/${router.query.ref}/explorer`) break case 'r': @@ -702,6 +709,8 @@ export function createTabId(type: T, params: CreateTabIdParam return `notebook-${(params as CreateTabIdParams['notebook']).id}` case 'query': return `query-${(params as CreateTabIdParams['query']).id}` + case 'chat': + return `chat-${(params as CreateTabIdParams['chat']).id}` default: return '' } diff --git a/apps/www/app/layout.tsx b/apps/www/app/layout.tsx index 33ef4139ee07b..9fae90684bbc2 100644 --- a/apps/www/app/layout.tsx +++ b/apps/www/app/layout.tsx @@ -4,6 +4,7 @@ import '../styles/globals.css' import '../pages/launch-week/launchWeek.css' import { inter, manrope, sourceCodePro } from '~/lib/fonts' +import { genFaviconData } from 'common/MetaFavicons/app-router' import type { Metadata, Viewport } from 'next' import Providers from './providers' @@ -32,11 +33,8 @@ export const metadata: Metadata = { site: '@supabase', card: 'summary_large_image', }, - icons: { - icon: '/favicon/favicon.ico', - shortcut: '/favicon/favicon.ico', - apple: '/favicon/favicon.ico', - }, + // www serves from the site root (`basePath: ''` in next.config.mjs) + icons: genFaviconData(''), } export const viewport: Viewport = { diff --git a/packages/common/MetaFavicons/app-router.ts b/packages/common/MetaFavicons/app-router.ts index dbded7b8324d9..078229427e962 100644 --- a/packages/common/MetaFavicons/app-router.ts +++ b/packages/common/MetaFavicons/app-router.ts @@ -74,7 +74,7 @@ const genFaviconData = (basePath: string): Metadata['icons'] => ({ }, { rel: 'icon', - url: `${basePath}/favicon/favicon-128x128.png`, + url: `${basePath}/favicon/favicon-128.png`, type: 'image/png', sizes: '128x128', }, diff --git a/packages/ui-patterns/src/McpUrlBuilder/assets/devin-icon-dark.svg b/packages/ui-patterns/src/McpUrlBuilder/assets/devin-icon-dark.svg new file mode 100644 index 0000000000000..99451235d42fd --- /dev/null +++ b/packages/ui-patterns/src/McpUrlBuilder/assets/devin-icon-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui-patterns/src/McpUrlBuilder/assets/devin-icon.svg b/packages/ui-patterns/src/McpUrlBuilder/assets/devin-icon.svg new file mode 100644 index 0000000000000..63ee0af812c63 --- /dev/null +++ b/packages/ui-patterns/src/McpUrlBuilder/assets/devin-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui-patterns/src/McpUrlBuilder/clients.data.ts b/packages/ui-patterns/src/McpUrlBuilder/clients.data.ts index 92e1378a46b41..b6b60aae141f8 100644 --- a/packages/ui-patterns/src/McpUrlBuilder/clients.data.ts +++ b/packages/ui-patterns/src/McpUrlBuilder/clients.data.ts @@ -224,10 +224,10 @@ export const MCP_CLIENT_DATA: McpClientData[] = [ }, { key: 'windsurf', - label: 'Windsurf', - icon: 'windsurf', + label: 'Devin Desktop', + icon: 'devin', hasDistinctDarkIcon: true, - configFile: '~/.codeium/windsurf/mcp_config.json', + configFile: '~/.config/devin/mcp_config.json', externalDocsUrl: '', transformConfig: (config): WindsurfMcpConfig => { return { diff --git a/packages/ui-patterns/src/McpUrlBuilder/clients.instructions.md.tsx b/packages/ui-patterns/src/McpUrlBuilder/clients.instructions.md.tsx index 4b03205e60872..d768b97f66028 100644 --- a/packages/ui-patterns/src/McpUrlBuilder/clients.instructions.md.tsx +++ b/packages/ui-patterns/src/McpUrlBuilder/clients.instructions.md.tsx @@ -188,37 +188,6 @@ export const MCP_CLIENT_INSTRUCTIONS: Record = { ), }, - windsurf: { - primary: () => ( -
- - Ensure you are running Windsurf version or higher. - -
- ), - alternate: () => ( - - Windsurf does not currently support remote MCP servers over HTTP transport. You need to use - the mcp-remote package as a proxy. - - ), - }, - warp: { - alternate: () => ( - <> - - Warp supports remote MCP servers natively, so no local proxy is needed. You can also add - the server from the UI: open the MCP servers page (Settings >{' '} - Agents > MCP Servers, or search for MCP in the - command palette), click + Add, and paste the same JSON. - - - After adding the server, Warp opens a browser window to complete the Supabase OAuth flow - and stores the credentials securely on your device. - - - ), - }, goose: { primary: ({ url }) => ( <> diff --git a/packages/ui-patterns/src/McpUrlBuilder/utils/mcpIconAssets.ts b/packages/ui-patterns/src/McpUrlBuilder/utils/mcpIconAssets.ts index 38d5738a32a3a..9b9ea2d02dd49 100644 --- a/packages/ui-patterns/src/McpUrlBuilder/utils/mcpIconAssets.ts +++ b/packages/ui-patterns/src/McpUrlBuilder/utils/mcpIconAssets.ts @@ -6,6 +6,8 @@ import copilotDarkIcon from '../assets/copilot-icon-dark.svg' import copilotIcon from '../assets/copilot-icon.svg' import cursorDarkIcon from '../assets/cursor-icon-dark.svg' import cursorIcon from '../assets/cursor-icon.svg' +import devinDarkIcon from '../assets/devin-icon-dark.svg' +import devinIcon from '../assets/devin-icon.svg' import factoryDarkIcon from '../assets/factory-icon-dark.svg' import factoryIcon from '../assets/factory-icon.svg' import geminiCliIcon from '../assets/gemini-cli-icon.svg' @@ -38,6 +40,7 @@ const MCP_CLIENT_ICON_ASSETS = { claude: { light: claudeIcon, dark: claudeIcon }, copilot: { light: copilotIcon, dark: copilotDarkIcon }, cursor: { light: cursorIcon, dark: cursorDarkIcon }, + devin: { light: devinIcon, dark: devinDarkIcon }, factory: { light: factoryIcon, dark: factoryDarkIcon }, 'gemini-cli': { light: geminiCliIcon, dark: geminiCliIcon }, goose: { light: gooseIcon, dark: gooseDarkIcon },