Persist last visited explorer tab (#50557)

## Context

Saves the last visited explorer tab via `useDashboardHistory`, such that
landing back on `/explorer` will open the last visited page. Similar
behaviour to Table Editor and SQL Editor

Would also be useful when going between the SQL Editor and Explorer to
bring snippets over

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Explorer now remembers and restores the last visited query, chat, or
notebook tab.
* Automatically returns to the Explorer home screen when a saved tab is
unavailable.
* Displays a loading state while the last visited tab is being restored.

* **Bug Fixes**
  * Closing deleted chat tabs now clears their saved history.

* **Tests**
* Added coverage for Explorer tab restoration, loading states, and stale
history cleanup.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-09-21 16:34:50 +08:00
1 parent f8257f2146
commit ad30c04e0e
9 files changed
+357 -25

No files matched your search

@@ -21,7 +21,8 @@ export const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => {
const isLoggedIn = useIsLoggedIn()
const isUserMFAEnabled = useIsMFAEnabled()
const { setLastVisitedSnippet, setLastVisitedTable } = useDashboardHistory()
const { setLastVisitedSnippet, setLastVisitedTable, setLastVisitedExplorerTab } =
useDashboardHistory()
const { lastVisitedOrganization, setLastVisitedOrganization } = useLastVisitedOrganization()
const DEFAULT_HOME = IS_PLATFORM
@@ -97,10 +98,22 @@ export const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => {
setLastVisitedSnippet(id)
} else if (router.pathname.endsWith('/editor/[id]')) {
setLastVisitedTable(id)
} else if (router.pathname.endsWith('/explorer/notebook/[id]')) {
setLastVisitedExplorerTab({ type: 'notebook', id })
} else if (router.pathname.endsWith('/explorer/query/[id]')) {
setLastVisitedExplorerTab({ type: 'query', id })
} else if (router.pathname.endsWith('/explorer/chat/[id]')) {
setLastVisitedExplorerTab({ type: 'chat', id })
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref, id])
}, [
ref,
id,
router.pathname,
setLastVisitedSnippet,
setLastVisitedTable,
setLastVisitedExplorerTab,
])
useEffect(() => {
if (organization) {
@@ -8,6 +8,7 @@ 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 { useDashboardHistory } from '@/hooks/misc/useDashboardHistory'
import { useAiAssistantState, useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
@@ -20,6 +21,7 @@ export const ExplorerChatTab = () => {
const aiAssistantState = useAiAssistantState()
const { createChat, openChat } = useCreateChat()
const { activeSidebar } = useSidebarManagerSnapshot()
const { setLastVisitedExplorerTab } = useDashboardHistory()
const chat = id ? aiAssistant.chats[id] : undefined
const chatInstance = id ? aiAssistant.chatInstances[id] : undefined
const tabId = id ? createTabId('chat', { id }) : undefined
@@ -37,7 +39,7 @@ export const ExplorerChatTab = () => {
id: tabId,
router,
editor: 'explorer',
onClearDashboardHistory: () => {},
onClearDashboardHistory: () => setLastVisitedExplorerTab(undefined),
})
})
@@ -23,7 +23,7 @@ export const ExplorerHomeTab = () => {
return <ExplorerHomeContent />
}
const ExplorerHomeLoading = () => (
export const ExplorerHomeLoading = () => (
<div
role="status"
aria-label="Opening Explorer"
@@ -35,7 +35,7 @@ export const useLoadNotebook = ({ id, projectRef }: { id?: string; projectRef?:
const hasLoadedNotebook =
isCurrentProjectNotebook && currentNotebook?.notebook.content !== undefined
const { data, error, isError } = useNotebookQuery(
const { data, error, isError, isLoading } = useNotebookQuery(
{ projectRef, id },
{
retry: false,
@@ -82,7 +82,7 @@ export const useLoadNotebook = ({ id, projectRef }: { id?: string; projectRef?:
mergeNotebook()
}, [projectRef, id, data, isNotFound, !!profile, !!project])
return { isNotFound: isNotFound && !isCurrentProjectNotebook }
return { isNotFound: isNotFound && !isCurrentProjectNotebook, isLoading }
}
export const useCreateNotebook = () => {
+5 -1
View File
@@ -52,7 +52,8 @@ export const EditorTabs = ({
}: EditorTabsProps) => {
const { ref } = useParams()
const router = useRouter()
const { setLastVisitedSnippet, setLastVisitedTable } = useDashboardHistory()
const { setLastVisitedSnippet, setLastVisitedTable, setLastVisitedExplorerTab } =
useDashboardHistory()
const editor = useEditorType()
const tabs = useTabsStateSnapshot()
@@ -97,6 +98,9 @@ export const EditorTabs = ({
if (editor === 'sql') {
setLastVisitedSnippet(undefined)
}
if (editor === 'explorer') {
setLastVisitedExplorerTab(undefined)
}
}
// Runs `performClose` immediately unless one of the tabs' registered close
@@ -64,4 +64,41 @@ describe('useDashboardHistory', () => {
expect(result.current.history.editor).toBe('table-1')
})
})
describe('setLastVisitedExplorerTab', () => {
it('stores the last visited Explorer tab', async () => {
const { result } = await renderDashboardHistory()
act(() => result.current.setLastVisitedExplorerTab({ type: 'notebook', id: 'notebook-1' }))
await waitFor(() =>
expect(result.current.history.explorer).toEqual({ type: 'notebook', id: 'notebook-1' })
)
})
it('clears the last visited Explorer tab when called with undefined', async () => {
const { result } = await renderDashboardHistory()
act(() => result.current.setLastVisitedExplorerTab({ type: 'chat', id: 'chat-1' }))
await waitFor(() => expect(result.current.history.explorer).toBeDefined())
act(() => result.current.setLastVisitedExplorerTab(undefined))
await waitFor(() => expect(result.current.history.explorer).toBeUndefined())
})
it('does not touch the SQL or table editor history', async () => {
const { result } = await renderDashboardHistory()
act(() => result.current.setLastVisitedSnippet('snippet-a'))
await waitFor(() => expect(result.current.history.sql).toBe('snippet-a'))
act(() => result.current.setLastVisitedExplorerTab({ type: 'query', id: 'query-1' }))
await waitFor(() =>
expect(result.current.history.explorer).toEqual({ type: 'query', id: 'query-1' })
)
expect(result.current.history.sql).toBe('snippet-a')
})
})
})
+34 -15
View File
@@ -1,9 +1,11 @@
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { useCallback } from 'react'
import { useLocalStorageQuery } from './useLocalStorage'
type DashboardHistory = { editor?: string; sql?: string }
const DEFAULT_HISTORY = { editor: undefined, sql: undefined }
type ExplorerHistoryEntry = { type: 'notebook' | 'query' | 'chat'; id: string }
type DashboardHistory = { editor?: string; sql?: string; explorer?: ExplorerHistoryEntry }
const DEFAULT_HISTORY = { editor: undefined, sql: undefined, explorer: undefined }
export const useDashboardHistory = () => {
// [Joshen] History should always refer to the project that the user is currently on
@@ -14,30 +16,47 @@ export const useDashboardHistory = () => {
DEFAULT_HISTORY
)
const setLastVisitedTable = (id?: string) => {
setHistory({ ...history, editor: id })
}
const setLastVisitedTable = useCallback(
(id?: string) => {
setHistory((current) => ({ ...current, editor: id }))
},
[setHistory]
)
const setLastVisitedSnippet = (id?: string) => {
setHistory({ ...history, sql: id })
}
const setLastVisitedSnippet = useCallback(
(id?: string) => {
setHistory((current) => ({ ...current, sql: id }))
},
[setHistory]
)
const setLastVisitedExplorerTab = useCallback(
(entry?: ExplorerHistoryEntry) => {
setHistory((current) => ({ ...current, explorer: entry }))
},
[setHistory]
)
/**
* Purge the last-visited snippet when it's one of the deleted snippets, so that
* navigating back to the SQL editor doesn't resurrect a deleted snippet.
*/
const clearSnippetsFromHistory = (ids: string[]) => {
setHistory((current) =>
current.sql !== undefined && ids.includes(current.sql)
? { ...current, sql: undefined }
: current
)
}
const clearSnippetsFromHistory = useCallback(
(ids: string[]) => {
setHistory((current) =>
current.sql !== undefined && ids.includes(current.sql)
? { ...current, sql: undefined }
: current
)
},
[setHistory]
)
return {
history,
setLastVisitedTable,
setLastVisitedSnippet,
setLastVisitedExplorerTab,
clearSnippetsFromHistory,
isHistoryLoaded: isSuccess,
}
@@ -1,20 +1,103 @@
import { useParams } from 'common'
import { useRouter } from 'next/router'
import { useEffect, useEffectEvent } from 'react'
import { ExplorerHomeTab } from '@/components/interfaces/Explorer/ExplorerHomeTab'
import {
ExplorerHomeLoading,
ExplorerHomeTab,
} from '@/components/interfaces/Explorer/ExplorerHomeTab'
import { useLoadNotebook } from '@/components/interfaces/Explorer/hooks'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { ExplorerLayout } from '@/components/layouts/ExplorerLayout/ExplorerLayout'
import { useDashboardHistory } from '@/hooks/misc/useDashboardHistory'
import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
import { explorerQueryState } from '@/state/explorer-query'
import { EXPLORER_HOME_TAB, useTabsStateSnapshot } from '@/state/tabs'
import type { NextPageWithLayout } from '@/types'
const ProjectExplorerPage: NextPageWithLayout = () => {
const router = useRouter()
const { ref: projectRef } = useParams()
const tabs = useTabsStateSnapshot()
const aiAssistant = useAiAssistantStateSnapshot()
const { history, isHistoryLoaded, setLastVisitedExplorerTab } = useDashboardHistory()
const lastVisited = history.explorer
const lastVisitedNotebookId = lastVisited?.type === 'notebook' ? lastVisited.id : undefined
const { isNotFound: isNotebookNotFound, isLoading: isNotebookLoading } = useLoadNotebook({
id: lastVisitedNotebookId,
projectRef,
})
const activateHomeTab = useEffectEvent(() => {
tabs.activatePinnedTab(EXPLORER_HOME_TAB)
})
useEffect(() => activateHomeTab(), [])
// Redirects to the last opened Explorer tab if it still exists, otherwise falls
// back to (and stays on) the Home tab. Existence is checked per tab type: query
// drafts and chats are local and resolve synchronously, notebooks require the
// server round trip in `useLoadNotebook` above to resolve first.
const goToLastVisitedTab = useEffectEvent(() => {
if (!projectRef) return
if (!lastVisited) {
activateHomeTab()
return
}
if (lastVisited.type === 'query') {
const exists = explorerQueryState.restoreDraft({ id: lastVisited.id, projectRef })
if (exists) {
router.replace(`/project/${projectRef}/explorer/query/${lastVisited.id}`)
} else {
setLastVisitedExplorerTab(undefined)
activateHomeTab()
}
return
}
if (lastVisited.type === 'chat') {
if (!aiAssistant.isInitialized) return
if (aiAssistant.chats[lastVisited.id]) {
router.replace(`/project/${projectRef}/explorer/chat/${lastVisited.id}`)
} else {
setLastVisitedExplorerTab(undefined)
activateHomeTab()
}
return
}
// notebook
if (isNotebookLoading) return
if (isNotebookNotFound) {
setLastVisitedExplorerTab(undefined)
activateHomeTab()
} else {
router.replace(`/project/${projectRef}/explorer/notebook/${lastVisited.id}`)
}
})
useEffect(() => {
if (isHistoryLoaded) goToLastVisitedTab()
}, [
isHistoryLoaded,
projectRef,
lastVisited?.type,
lastVisited?.id,
aiAssistant.isInitialized,
isNotebookLoading,
isNotebookNotFound,
])
const isCheckingLastTab =
!projectRef ||
!isHistoryLoaded ||
(lastVisited?.type === 'chat' && !aiAssistant.isInitialized) ||
(lastVisited?.type === 'notebook' && isNotebookLoading)
if (isCheckingLastTab) return <ExplorerHomeLoading />
return <ExplorerHomeTab />
}
@@ -0,0 +1,174 @@
import { screen } from '@testing-library/react'
import mockRouter from 'next-router-mock'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ProjectExplorerPage from '@/pages/project/[ref]/explorer/index'
import { customRender } from '@/tests/lib/custom-render'
// `common`'s useParams and `next/router` are globally mocked in vitestSetup.ts
// to `{ ref: 'default' }` and `next-router-mock` respectively.
const mocks = vi.hoisted(() => ({
activatePinnedTab: vi.fn(),
assistantSnapshot: vi.fn(),
restoreDraft: vi.fn(),
setLastVisitedExplorerTab: vi.fn(),
useDashboardHistory: vi.fn(),
useLoadNotebook: vi.fn(),
}))
vi.mock('@/hooks/misc/useDashboardHistory', () => ({
useDashboardHistory: () => mocks.useDashboardHistory(),
}))
vi.mock('@/components/interfaces/Explorer/hooks', () => ({
useLoadNotebook: () => mocks.useLoadNotebook(),
}))
vi.mock('@/components/interfaces/Explorer/ExplorerHomeTab', () => ({
ExplorerHomeTab: () => <div>Explorer home</div>,
ExplorerHomeLoading: () => <div>Checking last tab</div>,
}))
vi.mock('@/state/explorer-query', () => ({
explorerQueryState: { restoreDraft: (...args: unknown[]) => mocks.restoreDraft(...args) },
}))
vi.mock('@/state/ai-assistant-state', () => ({
useAiAssistantStateSnapshot: () => mocks.assistantSnapshot(),
}))
vi.mock('@/state/tabs', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/state/tabs')>()
return {
...actual,
useTabsStateSnapshot: () => ({ activatePinnedTab: mocks.activatePinnedTab }),
}
})
const mockHistory = (explorer?: { type: 'notebook' | 'query' | 'chat'; id: string }) => {
mocks.useDashboardHistory.mockReturnValue({
history: { explorer },
isHistoryLoaded: true,
setLastVisitedExplorerTab: mocks.setLastVisitedExplorerTab,
})
}
describe('ProjectExplorerPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRouter.setCurrentUrl('/project/default/explorer')
vi.spyOn(mockRouter, 'replace')
mocks.useLoadNotebook.mockReturnValue({ isNotFound: false, isLoading: false })
mocks.assistantSnapshot.mockReturnValue({ isInitialized: true, chats: {} })
})
it('shows the checking state until dashboard history has loaded', () => {
mocks.useDashboardHistory.mockReturnValue({
history: {},
isHistoryLoaded: false,
setLastVisitedExplorerTab: mocks.setLastVisitedExplorerTab,
})
customRender(<ProjectExplorerPage dehydratedState={undefined} />)
expect(mocks.activatePinnedTab).not.toHaveBeenCalled()
expect(screen.getByText('Checking last tab')).toBeInTheDocument()
})
it('activates the Home tab when there is no last visited Explorer tab', () => {
mockHistory(undefined)
customRender(<ProjectExplorerPage dehydratedState={undefined} />)
expect(mocks.activatePinnedTab).toHaveBeenCalled()
expect(mockRouter.replace).not.toHaveBeenCalled()
expect(screen.getByText('Explorer home')).toBeInTheDocument()
})
it('redirects to the last visited query tab when its draft still exists', () => {
mocks.restoreDraft.mockReturnValue(true)
mockHistory({ type: 'query', id: 'query-1' })
customRender(<ProjectExplorerPage dehydratedState={undefined} />)
expect(mocks.restoreDraft).toHaveBeenCalledWith({ id: 'query-1', projectRef: 'default' })
expect(mockRouter.replace).toHaveBeenCalledWith('/project/default/explorer/query/query-1')
expect(mocks.activatePinnedTab).not.toHaveBeenCalled()
})
it('falls back to Home and forgets the entry when the query draft is gone', () => {
mocks.restoreDraft.mockReturnValue(false)
mockHistory({ type: 'query', id: 'query-1' })
customRender(<ProjectExplorerPage dehydratedState={undefined} />)
expect(mockRouter.replace).not.toHaveBeenCalled()
expect(mocks.setLastVisitedExplorerTab).toHaveBeenCalledWith(undefined)
expect(mocks.activatePinnedTab).toHaveBeenCalled()
})
it('redirects to the last visited chat tab when the chat still exists', () => {
mocks.assistantSnapshot.mockReturnValue({
isInitialized: true,
chats: { 'chat-1': { id: 'chat-1', name: 'Investigate' } },
})
mockHistory({ type: 'chat', id: 'chat-1' })
customRender(<ProjectExplorerPage dehydratedState={undefined} />)
expect(mockRouter.replace).toHaveBeenCalledWith('/project/default/explorer/chat/chat-1')
})
it('waits for chat hydration before deciding', () => {
mocks.assistantSnapshot.mockReturnValue({ isInitialized: false, chats: {} })
mockHistory({ type: 'chat', id: 'chat-1' })
customRender(<ProjectExplorerPage dehydratedState={undefined} />)
expect(mockRouter.replace).not.toHaveBeenCalled()
expect(mocks.activatePinnedTab).not.toHaveBeenCalled()
expect(screen.getByText('Checking last tab')).toBeInTheDocument()
})
it('falls back to Home when the last visited chat was deleted', () => {
mocks.assistantSnapshot.mockReturnValue({ isInitialized: true, chats: {} })
mockHistory({ type: 'chat', id: 'chat-1' })
customRender(<ProjectExplorerPage dehydratedState={undefined} />)
expect(mockRouter.replace).not.toHaveBeenCalled()
expect(mocks.setLastVisitedExplorerTab).toHaveBeenCalledWith(undefined)
expect(mocks.activatePinnedTab).toHaveBeenCalled()
})
it('shows the checking state while the last visited notebook is still loading', () => {
mocks.useLoadNotebook.mockReturnValue({ isNotFound: false, isLoading: true })
mockHistory({ type: 'notebook', id: 'notebook-1' })
customRender(<ProjectExplorerPage dehydratedState={undefined} />)
expect(mockRouter.replace).not.toHaveBeenCalled()
expect(screen.getByText('Checking last tab')).toBeInTheDocument()
})
it('redirects to the last visited notebook once it is confirmed to exist', () => {
mocks.useLoadNotebook.mockReturnValue({ isNotFound: false, isLoading: false })
mockHistory({ type: 'notebook', id: 'notebook-1' })
customRender(<ProjectExplorerPage dehydratedState={undefined} />)
expect(mockRouter.replace).toHaveBeenCalledWith('/project/default/explorer/notebook/notebook-1')
})
it('falls back to Home when the last visited notebook no longer exists', () => {
mocks.useLoadNotebook.mockReturnValue({ isNotFound: true, isLoading: false })
mockHistory({ type: 'notebook', id: 'notebook-1' })
customRender(<ProjectExplorerPage dehydratedState={undefined} />)
expect(mockRouter.replace).not.toHaveBeenCalled()
expect(mocks.setLastVisitedExplorerTab).toHaveBeenCalledWith(undefined)
expect(mocks.activatePinnedTab).toHaveBeenCalled()
})
})