From 85db94762c69c58dab377b3728eee622c7a3a463 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 14 Aug 2026 11:19:42 +1000 Subject: [PATCH 1/7] feat: add routed assistant chat tabs to Explorer (#49031) image ## Summary - add routed Explorer chat pages backed by explicit assistant chat IDs - wait for persisted assistant state before creating chats so cold-load creation is not overwritten - register chat tabs and keep tab labels, navigation, close behavior, and missing-chat cleanup in sync - create and branch Explorer chats without changing the assistant sidebar selection - support both Next.js and TanStack Router paths This is PR 2 of 3 and is stacked on #48973. Review and merge #48973 first. The Explorer discovery, toolbar, and cross-surface entry points follow in #49032. This PR focuses purely on setting up chat tab types, routes and assistant conversation. ## To Test - Create a new assistant chat via the assistant sidebar, send a message etc - Copy the chat id - Visit /explorer/chat/[id] - Verify chat shows up, you can send more messages, chat is synced across tab and sidebar - Close the tab ## Test plan - `mise exec node@22 -- pnpm --dir apps/studio exec tsc --noEmit` - focused Vitest suite: 4 files / 30 tests covering assistant hydration, chat creation, routed chat rendering, and tab lifecycle - ESLint on changed TypeScript files - Prettier check on changed source files ## Summary by CodeRabbit * **New Features** * Added Explorer chat pages with support for opening, selecting, and branching chats. * Added chat tabs, stable navigation, chat icons, and fallback behavior when tabs are closed. * Added chat creation that waits for assistant state to finish loading. * **Bug Fixes** * Removed tabs for deleted or unavailable chats. * Improved editor tab navigation and history clearing behavior. * **Tests** * Added coverage for chat routing, tab management, chat creation, and assistant-state loading. --------- Co-authored-by: Claude Opus 5 --- apps/studio/TANSTACK_MIGRATION.md | 1 + .../interfaces/Explorer/ChatEditor.tsx | 99 ++++++++++++++ .../Explorer/__tests__/ChatEditor.test.tsx | 122 ++++++++++++++++++ .../Explorer/__tests__/hooks.test.tsx | 111 ++++++++++++++++ .../components/interfaces/Explorer/hooks.ts | 51 ++++++++ apps/studio/components/layouts/Tabs/Tabs.tsx | 6 +- apps/studio/components/ui/EntityTypeIcon.tsx | 15 ++- .../project/[ref]/explorer/chat/[id].tsx | 14 ++ apps/studio/routeTree.gen.ts | 22 ++++ .../routes/project/$ref/explorer/chat/$id.tsx | 11 ++ apps/studio/state/ai-assistant-state.test.ts | 47 ++++++- apps/studio/state/ai-assistant-state.tsx | 22 ++++ apps/studio/state/tabs.test.ts | 48 ++++++- apps/studio/state/tabs.tsx | 13 +- 14 files changed, 575 insertions(+), 7 deletions(-) create mode 100644 apps/studio/components/interfaces/Explorer/ChatEditor.tsx create mode 100644 apps/studio/components/interfaces/Explorer/__tests__/ChatEditor.test.tsx create mode 100644 apps/studio/components/interfaces/Explorer/__tests__/hooks.test.tsx create mode 100644 apps/studio/pages/project/[ref]/explorer/chat/[id].tsx create mode 100644 apps/studio/routes/project/$ref/explorer/chat/$id.tsx 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..e7859c2ee5351 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/ChatEditor.tsx @@ -0,0 +1,99 @@ +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 { 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} + /> + ) +} 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/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/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.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..4352931d2447c 100644 --- a/apps/studio/state/ai-assistant-state.tsx +++ b/apps/studio/state/ai-assistant-state.tsx @@ -375,6 +375,7 @@ export const createAiAssistantState = (): AiAssistantState => { chatInstances: {}, pendingSpanIds: {}, messageSpanIds: {}, + isInitialized: false, setContext: (context: Partial) => { state.context = { ...state.context, ...context } @@ -382,6 +383,7 @@ export const createAiAssistantState = (): AiAssistantState => { resetAiAssistantPanel: () => { Object.assign(state, createInitialAiAssistantData()) + state.isInitialized = false }, setModel: (model: AssistantModel) => { @@ -675,6 +677,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 +708,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 +737,7 @@ export const AiAssistantStateContextProvider = ({ children }: PropsWithChildren) // 4. Ensure an active chat exists and handle URL overrides ensureActiveChatOrInitialize(state) + state.isInitialized = true } loadAndInitializeState() @@ -792,6 +797,23 @@ export const useAiAssistantStateSnapshot = (options?: Parameters => { + 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/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 '' } From fd8d21313537be67195781916375abf347112c66 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 14 Aug 2026 11:19:43 +1000 Subject: [PATCH 2/7] feat: add Explorer chat discovery and controls (#49032) image ## Summary - add an Explorer-specific chat toolbar with rename, chat ID, permission, and branching controls - list searchable non-support chats in Explorer with reactive updates and safe rehydrated-date sorting - add chat creation entry points to Explorer home and navigation menus - route chat recent items correctly and show chat icons across shared tab surfaces - make the assistant sidebar expand action open the active chat in Explorer This is PR 3 of 3 and is stacked on #49031. Review #48973 first, then #49031, then this PR. Compared with its base, this PR contains only discovery, toolbar, and cross-surface integration work. ## To Test - visit /explorer - Create a new chat either via home tab chat input OR the chats sidebar - Validate chats show up in sidebar - Validate chat conversation works - Close the chat tab and open a chat via sidebar - Change permission settings via the chat tab toolbar and verify it persists ## Test plan - `mise exec node@22 -- pnpm --dir apps/studio exec tsc --noEmit` - focused Vitest suite: 4 files / 5 tests covering reactive chat lists, navigation filtering and sorting, recent-item routing, and sidebar handoff - ESLint on changed TypeScript files - Prettier check on changed source files ## Summary by CodeRabbit - **New Features** - Added chat creation from the Explorer home, navigation, and new-tab menu. - Added an Explorer chat toolbar with editable names, ID copying, permissions, shortcuts, and metadata warnings. - Added searchable chat history with sorting, active-chat highlighting, and clear empty states. - Chats can now open directly in Explorer from the AI Assistant panel. - Recent items now link correctly to chats and notebooks. - **Bug Fixes** - Improved chat list updates after creation, deletion, and restored sessions. - Added safer handling for chats without update timestamps. - **Tests** - Expanded coverage for chat navigation, creation, metadata warnings, shortcuts, and recent-item links. --------- Co-authored-by: Claude Opus 5 --- .../interfaces/Explorer/ChatEditor.tsx | 4 + .../Explorer/ExplorerChatToolbar.tsx | 115 ++++++++++++++++++ .../interfaces/Explorer/ExplorerHome.tsx | 7 +- .../ExplorerLayout.constants.tsx | 4 +- .../layouts/ExplorerLayout/ExplorerLayout.tsx | 9 +- .../ExplorerLayout/ExplorerNavChats.test.tsx | 73 +++++++++++ .../ExplorerLayout/ExplorerNavChats.tsx | 50 +++++++- .../layouts/Tabs/RecentItems.test.ts | 33 +++++ .../components/layouts/Tabs/RecentItems.tsx | 41 ++++--- .../AIAssistantHeader.test.tsx | 56 +++++++++ .../ui/AIAssistantPanel/AIAssistantHeader.tsx | 69 ++++------- .../AIAssistantMetadataWarning.tsx | 58 +++++++++ .../state/ai-assistant-state.hooks.test.tsx | 77 ++++++++++++ apps/studio/state/ai-assistant-state.tsx | 19 ++- apps/studio/state/shortcuts/registry.ts | 2 +- 15 files changed, 543 insertions(+), 74 deletions(-) create mode 100644 apps/studio/components/interfaces/Explorer/ExplorerChatToolbar.tsx create mode 100644 apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.test.tsx create mode 100644 apps/studio/components/layouts/Tabs/RecentItems.test.ts create mode 100644 apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.test.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/AIAssistantMetadataWarning.tsx create mode 100644 apps/studio/state/ai-assistant-state.hooks.test.tsx diff --git a/apps/studio/components/interfaces/Explorer/ChatEditor.tsx b/apps/studio/components/interfaces/Explorer/ChatEditor.tsx index e7859c2ee5351..bd0e21e38514f 100644 --- a/apps/studio/components/interfaces/Explorer/ChatEditor.tsx +++ b/apps/studio/components/interfaces/Explorer/ChatEditor.tsx @@ -4,6 +4,7 @@ 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' @@ -94,6 +95,9 @@ export const ChatEditor = () => { onNewChat={() => 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/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/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/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.tsx b/apps/studio/state/ai-assistant-state.tsx index 4352931d2447c..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' @@ -797,6 +804,16 @@ 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 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, }, From 9d3a1a36e4437d2e61e6bc3749a1dde1f052a97a Mon Sep 17 00:00:00 2001 From: Aleksi Immonen Date: Fri, 14 Aug 2026 05:55:43 +0300 Subject: [PATCH 3/7] fix: correct AI tools link on docs homepage (#48895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - The "AI tools" card in the "Explore more" section of the docs homepage links to `/guides/ai` (the AI & Vectors / pgvector page) instead of `/guides/ai-tools` (the MCP, plugins, and coding agent page) - The card description says "Develop with Supabase AI-first using plugins, MCP, and skills" which matches `/guides/ai-tools`, not `/guides/ai` - Changed `href: '/guides/ai'` to `href: '/guides/ai-tools'` for the AI tools card only ## Test plan - [ ] Visit supabase.com/docs and click "AI tools" in the Explore more section - [ ] Confirm it lands on /guides/ai-tools (MCP, plugins, coding agents) - [ ] Confirm the "AI & Vectors" card still correctly links to /guides/ai 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Bug Fixes** * Updated the “AI tools” link on the documentation homepage to direct visitors to the correct guide. --- apps/docs/app/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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', From c30437a58acb12af77befe5bf8940546db9b26d3 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:55:49 +0800 Subject: [PATCH 4/7] fix: use shared favicon metadata so the tab icon isn't blurry on hi-dpi (#48770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Requested by **Matt Rossman, Ali Waseem** · [Slack thread](https://supabase.slack.com/archives/C0161K73J1J/p1785960993618839?thread_ts=1785960993.618839&cid=C0161K73J1J)_ ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Bug fix. ## What is the current behavior? The Supabase logo in the browser tab looks blurry on high-DPI displays on supabase.com, but sharp on the dashboard. Same logo, same asset files — only the marketing site looks soft. Separately, `genFaviconData()` points one of its `` tags at `favicon-128x128.png`, a file that no app in the repo ships. That is a live 404 on docs, learn and ui-library today — and on design-system, which hardcodes its own copy of the same icon list. ## What is the new behavior? The tab icon is sharp on both, and the 404 is gone everywhere. ## Additional context **How.** `apps/www/app/layout.tsx` hardcoded a Next.js `metadata.icons` block that pointed `icon`, `shortcut` and `apple` all at `/favicon/favicon.ico`. That `.ico` contains a single 16x16 layer, so on a 2x display the browser has no 32px candidate to choose and upscales the 16x16 — hence the blur. It only affects App Router routes, which now includes the homepage, `/blog`, `/pricing` and the product pages; www's remaining Pages Router routes already went through the shared component and were fine. www was not using the shared `genFaviconData()` helper from `common/MetaFavicons/app-router`, which docs, learn and ui-library all do. Swapping it in makes www advertise the same 16/32/48/96/128/180/196 PNG ladder the dashboard does, so the browser picks the 32px PNG on a 2x display. The argument is `''` because www serves from the site root (`basePath: ''` in `next.config.mjs`). **Second, related change.** `packages/common/MetaFavicons/app-router.ts` referenced `favicon-128x128.png`; the asset is `favicon-128.png` in every app's `public/favicon/` (the pages-router variant of the helper already had it right). Fixed to match. Without this, wiring www up to the helper would have added a fourth app to the existing 404. **Third, related change.** `apps/design-system/app/layout.tsx` had its own inline copy of `genFaviconData` — byte-identical to the shared one except that it still pointed at `favicon-128x128.png`, so fixing the shared helper alone would have left design-system 404ing. Replaced the 91-line inline copy with the shared import, passing the app's existing `BASE_PATH` (which mirrors `basePath` in its `next.config.mjs`) the same way docs, learn and ui-library do. That removes the last hardcoded icon list among the App Router apps, so the filename can't drift back out of sync. No favicon image assets were added or changed — every file the helper references already exists in both `apps/www/public/favicon/` and `apps/design-system/public/favicon/`. **Possible follow-up.** `favicon.ico` itself is single-layer 16x16 in both www and studio (byte-identical files). Regenerating it as a multi-resolution ICO with 16/32/48 layers would help any consumer that only reads the `.ico` — bookmark bars, some browser surfaces, and notably supabase.com/evals, which is a rewrite to a separate Vercel app and so won't pick up this layout change, but does resolve root-relative icon hrefs against www's `public/`. Left out here because it touches studio's assets too and is a separate call. --- _Generated by [Claude Code](https://claude.ai/code/session_01F2AZs625JxKASYVAj8LWYq)_ --------- Co-authored-by: Claude --- apps/design-system/app/layout.tsx | 95 +--------------------- apps/www/app/layout.tsx | 8 +- packages/common/MetaFavicons/app-router.ts | 2 +- 3 files changed, 6 insertions(+), 99 deletions(-) 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/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', }, From ececf6c003a4185bc64c210fcd1ad6e2ac6472f5 Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 14 Aug 2026 12:10:19 +0900 Subject: [PATCH 5/7] docs: Update Devin Desktop Supabase plugin guides (#49048) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? - Windsurf has been renamed to Devin Desktop. This PR updates public-facing mentions of Windsurf to Devin Desktop - Update MCP installation instruction to match the current behavior. ## Summary by CodeRabbit * **Documentation** * Updated supported environment guidance to reference Devin Desktop instead of Windsurf. * Updated the MCP configuration path for Devin Desktop. * Removed outdated Windsurf-specific setup instructions and transport limitations. * Refreshed related MCP client labeling and setup guidance for clarity and consistency across the documentation and configuration experience. --------- Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com> --- .../content/guides/ai-tools/ai-prompts.mdx | 2 +- .../data/content-listings/ai-tools.data.ts | 2 +- .../img/icons/agent-devin-icon-light.svg | 3 ++ .../public/img/icons/agent-devin-icon.svg | 3 ++ .../McpUrlBuilder/assets/devin-icon-dark.svg | 3 ++ .../src/McpUrlBuilder/assets/devin-icon.svg | 3 ++ .../src/McpUrlBuilder/clients.data.ts | 6 ++-- .../McpUrlBuilder/clients.instructions.md.tsx | 31 ------------------- .../src/McpUrlBuilder/utils/mcpIconAssets.ts | 3 ++ 9 files changed, 20 insertions(+), 36 deletions(-) create mode 100644 apps/docs/public/img/icons/agent-devin-icon-light.svg create mode 100644 apps/docs/public/img/icons/agent-devin-icon.svg create mode 100644 packages/ui-patterns/src/McpUrlBuilder/assets/devin-icon-dark.svg create mode 100644 packages/ui-patterns/src/McpUrlBuilder/assets/devin-icon.svg 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/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/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 }, From 514f53a94400d5d3b6eb7036a5b199899fb93717 Mon Sep 17 00:00:00 2001 From: Ayaan Gazali Date: Thu, 13 Aug 2026 20:18:11 -0700 Subject: [PATCH 6/7] docs: point explain and rpc reference links at their current pages (#48655) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Docs fix (broken links). ## What is the current behavior? Three links in two troubleshooting entries point at `/docs/reference/javascript/explain`, which returns 404. That slug is not in the docs sitemap any more. Two of the three are not explain links at all. In `fixing-520-errors-in-the-database-rest-api-Ur5-B2.mdx` the link text is "RPCs" and "RPC" and the query string asks for `example=call-a-postgres-function-with-arguments`, so both were meant to point at the `rpc` reference. The third, in `understanding-postgresql-explain-output-Un9dqX.mdx`, really is about explain: the text is "EXPLAIN" and it asks for `example=get-execution-plan-with-analyze-and-verbose`. ## What is the new behavior? - the two "RPC" links now point at `/docs/reference/javascript/rpc` - the "EXPLAIN" link now points at `/docs/reference/javascript/using-modifiers-explain` Both destinations return 200. The `queryGroups` and `example` query strings are carried over unchanged, I only changed the slug. ## Additional context Files: - `apps/docs/content/troubleshooting/fixing-520-errors-in-the-database-rest-api-Ur5-B2.mdx` (2 links, to `rpc`) - `apps/docs/content/troubleshooting/understanding-postgresql-explain-output-Un9dqX.mdx` (1 link, to `using-modifiers-explain`) What I verified: `/docs/reference/javascript/explain` returns 404, and both `/docs/reference/javascript/rpc` and `/docs/reference/javascript/using-modifiers-explain` return 200 and appear in the sitemap. After the change there are no `javascript/explain?` references left in `apps/docs/content`. What I could not verify, so I am flagging it rather than claiming it: I could not confirm server side that the `example=` ids still exist on the destination pages, because the reference pages appear to build their example selectors client side and the ids are not in the fetched HTML. I kept each existing `example=` value as it was, on the basis that an unmatched example parameter just leaves the default selection rather than breaking the page, which is still better than the current 404. If you know those example ids have been renamed too, tell me and I will update them in the same PR. This was the one case I deliberately left out of #48568, where I said the intended target looked ambiguous. Looking at it again, the link text and the example parameter agree with each other in all three cases, so the mapping is clearer than I first thought. Gates run locally: `test:prettier` passes repo wide and the docs vitest suite passes (22 files, 169 tests, 1 file and 2 tests skipped). I did not run a build: `pnpm build` needs `DOCS_GITHUB_APP_PRIVATE_KEY` for the docs `build:federated-content` step, which I do not have, and it fails before Next compiles. Freshman contributor here, working through these with Claude Code's help and checking each URL myself. Happy to change any of the targets if you would rather they went elsewhere. ## Summary by CodeRabbit * **Documentation** * Updated database REST API troubleshooting links for RPC guidance. * Corrected the Supabase JavaScript EXPLAIN documentation link. Co-authored-by: Pamela Chia --- .../fixing-520-errors-in-the-database-rest-api-Ur5-B2.mdx | 4 ++-- .../understanding-postgresql-explain-output-Un9dqX.mdx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 From 5627d01183402f65416813f7a847647c11d90e93 Mon Sep 17 00:00:00 2001 From: dancer13 Date: Fri, 14 Aug 2026 05:23:57 +0200 Subject: [PATCH 7/7] docs: Update tab reference in project setup documentation (#48451) Tab naming has changed ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. * YES/NO ## What kind of change does this PR introduce? * docs update ## What is the current behavior? * Tab section referred do NOT exist anymore ## What is the new behavior? image ## Summary by CodeRabbit * **Documentation** * Updated the User Management Starter quickstart navigation instructions to use **Reference > Examples** in the Dashboard. Co-authored-by: Pamela Chia --- apps/docs/content/_partials/project_setup.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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**.