diff --git a/harness-tests/core/chat-entry-search.test.ts b/harness-tests/core/chat-entry-search.test.ts index 901605437..23489e499 100644 --- a/harness-tests/core/chat-entry-search.test.ts +++ b/harness-tests/core/chat-entry-search.test.ts @@ -2,46 +2,60 @@ import { expect, it, vi } from 'vitest' import { validateWorkspaceSearch } from '../../src/chat/core/navigation' const entry = vi.hoisted(() => { - const beforeLoads: ((context: { - search: ReturnType - }) => Promise)[] = [] - return { beforeLoads } + const components: (() => unknown)[] = [] + return { components, navigate: vi.fn(), setQueryData: vi.fn() } +}) +const search = validateWorkspaceSearch({ + settings: 'appearance', + sort: 'activity', + code: 'private', }) vi.mock('@tanstack/react-router', () => ({ - createFileRoute: - () => - (options: { beforeLoad: Parameters[0] }) => { - entry.beforeLoads.push(options.beforeLoad) - return options - }, - redirect: (options: Record) => options, + createFileRoute: () => (options: { component: () => unknown }) => { + entry.components.push(options.component) + return { options, useSearch: () => search } + }, + useNavigate: () => entry.navigate, })) -vi.mock('../../src/utils/auth.functions', () => ({ - getCurrentUser: async () => ({ userId: 'shared-user' }), +vi.mock('react', async (importOriginal) => ({ + ...(await importOriginal()), + useEffect: (effect: () => void) => effect(), })) -vi.mock('../../src/chat/workspace.functions', () => ({ - loadChatWorkspace: async () => ({ - workspace: { id: 'personal:shared-user' }, - assistantId: 'assistant:shared-user', - conversationId: 'conversation', +vi.mock('@tanstack/react-query', async (importOriginal) => ({ + ...(await importOriginal()), + useQuery: () => ({ + data: { + workspace: { id: 'personal:shared-user', ownerId: 'shared-user' }, + assistantId: 'assistant:shared-user', + conversationId: 'conversation', + }, }), + useQueryClient: () => ({ setQueryData: entry.setQueryData }), +})) +vi.mock('../../src/chat/workspace.functions', () => ({ + loadChatWorkspace: vi.fn(), + resolveChatRoute: vi.fn(), })) -it('preserves validated navigation while opening the authenticated personal assistant', async () => { - await import('../../src/routes/chat.index') - expect(entry.beforeLoads).toHaveLength(1) - const search = validateWorkspaceSearch({ - settings: 'appearance', - sort: 'activity', - code: 'private', - }) - await expect(entry.beforeLoads[0]({ search })).rejects.toEqual({ +it('preserves validated navigation while opening the personal assistant without a blocking route loader', async () => { + const { Route } = await import('../../src/routes/chat.index') + expect(Route.options.beforeLoad).toBeUndefined() + entry.components[0]() + expect(entry.navigate).toHaveBeenCalledWith({ to: '/chat/c/$conversationId', - params: { - conversationId: 'conversation', - }, + params: { conversationId: 'conversation' }, search: { ...search, conversation: undefined }, + replace: true, }) + expect(entry.setQueryData).toHaveBeenCalledWith( + ['chat-identity', 'conversation'], + { + conversationId: 'conversation', + botId: 'assistant:shared-user', + workspaceId: 'personal:shared-user', + userId: 'shared-user', + }, + ) expect(search).not.toHaveProperty('code') expect(search.settings).toBe('appearance') }) diff --git a/harness-tests/core/chat-runtime-headers.test.ts b/harness-tests/core/chat-runtime-headers.test.ts index 52381549f..8c5fb0293 100644 --- a/harness-tests/core/chat-runtime-headers.test.ts +++ b/harness-tests/core/chat-runtime-headers.test.ts @@ -1,9 +1,13 @@ -vi.mock('../../src/chat/access.functions', () => ({ getChatAccess: vi.fn() })) +const access = vi.hoisted(() => vi.fn()) +vi.mock('../../src/chat/access.functions', () => ({ getChatAccess: access })) import { expect, it, vi } from 'vitest' const captured = vi.hoisted(() => { const headers: (() => Record)[] = [] - return { headers } + const beforeLoads: ((input: { + cause: 'enter' | 'stay' | 'preload' + }) => Promise)[] = [] + return { headers, beforeLoads } }) vi.mock('../../src/chat/components/ChatShell', () => ({ ChatShell: () => null, @@ -13,8 +17,15 @@ vi.mock('@tanstack/react-start', () => ({ })) vi.mock('@tanstack/react-router', () => ({ createFileRoute: - () => (options: { headers: () => Record }) => { + () => + (options: { + headers: () => Record + beforeLoad: (input: { + cause: 'enter' | 'stay' | 'preload' + }) => Promise + }) => { captured.headers.push(options.headers) + captured.beforeLoads.push(options.beforeLoad) return options }, })) @@ -29,3 +40,12 @@ it('isolates authenticated chat for browser execution without caching private do 'Cloudflare-CDN-Cache-Control': 'no-store', }) }) + +it('checks access on entry without blocking navigation within the chat shell', async () => { + await import('../../src/routes/chat') + access.mockResolvedValue({ unlocked: true }) + await captured.beforeLoads[0]({ cause: 'enter' }) + await captured.beforeLoads[0]({ cause: 'stay' }) + await captured.beforeLoads[0]({ cause: 'stay' }) + expect(access).toHaveBeenCalledOnce() +}) diff --git a/harness-tests/core/conversation-route-cache.test.ts b/harness-tests/core/conversation-route-cache.test.ts new file mode 100644 index 000000000..9c62e990a --- /dev/null +++ b/harness-tests/core/conversation-route-cache.test.ts @@ -0,0 +1,88 @@ +import { expect, it, vi } from 'vitest' +import { QueryClient } from '@tanstack/react-query' +import type { WorkspaceBot } from '../../src/chat/core/bot-workspace' +vi.mock('../../src/chat/workspace.functions', () => ({ + resolveChatRoute: vi.fn(), +})) +import { chatIdentityQuery } from '../../src/chat/components/chatIdentityQuery' +import { + conversationRouteQuery, + conversationRouteFreshMs, +} from '../../src/chat/components/conversationRouteQuery' +const bot: WorkspaceBot = { + id: 'bot', + workspace_id: 'workspace', + parent_id: null, + name: 'Chat', + purpose: '', + created_at: 0, + mainConversationId: 'conversation', + version: 0, + archived_at: null, + deleted_at: null, + updated_at: 0, + pinned: false, + section_id: null, + position: 0, + tags: [], +} +const identity = { + userId: 'viewer', + workspaceId: 'workspace', + botId: 'bot', + conversationId: 'conversation', +} +it('uses the sidebar prefetch when opening its canonical conversation, and rechecks stale history', async () => { + const queries = new QueryClient() + const request = vi.fn().mockResolvedValue({ identity, messages: [] }) + const input = { + queries, + request, + workspaceId: 'workspace', + bot, + userId: 'viewer', + } + const prefetched = await queries.fetchQuery(conversationRouteQuery(input)) + const opened = conversationRouteQuery({ + ...input, + conversationId: 'conversation', + }) + expect(await queries.fetchQuery(opened)).toBe(prefetched) + expect(request).toHaveBeenCalledExactlyOnceWith( + 'conversations/conversation/history', + ) + expect( + queries.getQueryData(chatIdentityQuery('conversation').queryKey), + ).toEqual(identity) + queries.setQueryData(opened.queryKey, prefetched, { + updatedAt: Date.now() - conversationRouteFreshMs - 1, + }) + await queries.fetchQuery(opened) + expect(request).toHaveBeenCalledTimes(2) + queries.clear() +}) +it('never seeds navigation or history from a different account’s response', async () => { + const queries = new QueryClient() + const request = vi.fn().mockResolvedValue({ + identity: { ...identity, userId: 'other' }, + messages: [], + }) + await expect( + queries.fetchQuery( + conversationRouteQuery({ + queries, + request, + workspaceId: 'workspace', + bot, + userId: 'viewer', + }), + ), + ).rejects.toThrow('account and workspace') + expect( + queries.getQueryData(chatIdentityQuery('conversation').queryKey), + ).toBeUndefined() + expect( + queries.getQueryCache().findAll({ queryKey: ['history'] }), + ).toHaveLength(0) + queries.clear() +}) diff --git a/harness-tests/pending-runtime/chat-workspace-open.test.ts b/harness-tests/pending-runtime/chat-workspace-open.test.ts new file mode 100644 index 000000000..39209438a --- /dev/null +++ b/harness-tests/pending-runtime/chat-workspace-open.test.ts @@ -0,0 +1,53 @@ +import { afterAll, beforeAll, expect, it } from 'vitest' +import process from 'node:process' +import postgres from 'postgres' +import { openPersonalChatWorkspace } from '../../src/chat/workspace.server' + +const url = new URL(process.env.DATABASE_URL ?? '') +if ( + !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || + !url.pathname.startsWith('/tanchat_test') +) { + throw new Error( + 'Workspace-open tests require a migrated local tanchat_test database.', + ) +} +const sql = postgres(url.href, { max: 1 }) +const userId = crypto.randomUUID() +beforeAll(async () => { + await sql`INSERT INTO users(id) VALUES(${userId})` +}) +afterAll(async () => { + await sql`DELETE FROM users WHERE id=${userId}` + await sql.end() +}) +it('opens concurrently, preserves existing settings on warm opens, and repairs incomplete initialization', async () => { + const opened = await Promise.all( + Array.from({ length: 8 }, () => openPersonalChatWorkspace(userId)), + ) + const conversationIds = new Set() + for (const result of opened) conversationIds.add(result.conversationId) + expect(conversationIds.size).toBe(1) + const first = opened[0] + await sql`UPDATE chat_workspaces SET name='Existing workspace' WHERE id=${first.workspace.id}` + await sql`UPDATE chat_bots SET name='Existing assistant' WHERE id=${first.assistantId}` + const warm = await openPersonalChatWorkspace(userId) + expect(warm.workspace.name).toBe('Existing workspace') + for (const bot of warm.bots) { + if (bot.id === warm.assistantId) expect(bot.name).toBe('Existing assistant') + } + expect(warm.conversationId).toBe(first.conversationId) + expect( + await sql`SELECT user_id FROM chat_account_onboarding WHERE user_id=${userId}`, + ).toHaveLength(1) + await sql`DELETE FROM chat_account_onboarding WHERE user_id=${userId}` + const repaired = await openPersonalChatWorkspace(userId) + expect(repaired.conversationId).toBe(first.conversationId) + expect(repaired.workspace.name).toBe('Existing workspace') + expect( + await sql`SELECT user_id FROM chat_account_onboarding WHERE user_id=${userId}`, + ).toHaveLength(1) + expect( + await sql`SELECT id FROM chat_conversations WHERE user_id=${userId}`, + ).toHaveLength(1) +}) diff --git a/src/chat/components/App.tsx b/src/chat/components/App.tsx index e49734d81..7b1893101 100644 --- a/src/chat/components/App.tsx +++ b/src/chat/components/App.tsx @@ -11,6 +11,7 @@ import { useDebugDetails } from './useDebugDetails' import { TabPanels } from './ui/TabPanels' import { Button } from './ui/Button' import { Menu } from '@base-ui/react/menu' +import { conversationLocation } from '../core/conversation-destination' import type { TurnOutcome } from '../core/message-navigation' import { SelectField } from './SelectField' import { deferredPanel } from './deferredPanel' @@ -23,6 +24,8 @@ import { DraftBot } from './DraftBot' import { Dialog } from './Dialog' import { ConversationView } from './ConversationView' import { conversationRouteQuery } from './conversationRouteQuery' +import { chatIdentityQuery, rememberChatIdentity } from './chatIdentityQuery' +import { LoadingState } from './ui/LoadingState' import { ConversationDetailsToggle } from './ConversationDetailsCard' import { ConversationNavigator, @@ -51,7 +54,7 @@ import { Bookmark, Copy, } from 'lucide-react' -import { useNavigate, useParams, useRouteContext } from '@tanstack/react-router' +import { useNavigate, useParams } from '@tanstack/react-router' import { WorkspaceApiProvider, useWorkspaceApi, ApiError } from './WorkspaceApi' import { @@ -233,14 +236,26 @@ const names: Record = { } export function App() { const params = useParams({ strict: false }) - const { chatIdentity } = useRouteContext({ strict: false }) - const workspaceId = chatIdentity?.workspaceId ?? params.workspaceId + const identity = useQuery({ + ...chatIdentityQuery(params.conversationId ?? ''), + enabled: !!params.conversationId, + }) + if (params.conversationId && !identity.data) { + if (identity.error) + return ( +
+ {identity.error.message} +
+ ) + return Opening conversation… + } + const workspaceId = identity.data?.workspaceId ?? params.workspaceId return ( ) @@ -510,9 +525,21 @@ function WorkspaceApp({ const activityQuery = workspaceState.activityQuery const selectBot = (id: string) => { if (!data) return + const bot = + data.bots.find((candidate) => candidate.id === id) ?? + (data.personalAssistant?.id === id ? data.personalAssistant : undefined) + const identity = bot + ? rememberChatIdentity(queryClient, bot, data.user.id) + : undefined void navigate({ - to: '/chat/b/$botId', - params: { botId: id }, + ...conversationLocation( + { + workspaceId: identity?.workspaceId ?? data.workspace.id, + botId: id, + conversationId: identity?.conversationId, + }, + search, + ), search: { ...(id === botId && !search.draft && !search.conversation ? search @@ -566,9 +593,20 @@ function WorkspaceApp({ useEffect(() => { if (!data || botId || search.draft || params.homeSection) return if (workspaceId && data.personalAssistant) { + const identity = rememberChatIdentity( + queryClient, + data.personalAssistant, + data.user.id, + ) void navigate({ - to: '/chat/b/$botId', - params: { botId: data.personalAssistant.id }, + ...conversationLocation( + { + workspaceId: data.personalAssistant.workspace_id, + botId: data.personalAssistant.id, + conversationId: identity?.conversationId, + }, + search, + ), search: { ...search, conversation: undefined }, replace: true, }) @@ -585,7 +623,15 @@ function WorkspaceApp({ replace: true, }) } - }, [data, botId, workspaceId, navigate, search.draft, params.homeSection]) + }, [ + data, + botId, + workspaceId, + navigate, + search, + params.homeSection, + queryClient, + ]) if (bootstrap.isPending && !data) return if ( (workspaceError instanceof ApiError && workspaceError.status === 401) || @@ -656,16 +702,7 @@ function WorkspaceApp({ setMobile(false) if (id === 'chat') { if (data.personalAssistant) { - void navigate({ - to: '/chat/b/$botId', - params: { botId: data.personalAssistant.id }, - search: { - ...search, - view: 'bots', - sort: 'position', - group: 'section', - }, - }) + selectBot(data.personalAssistant.id) } else createDraft(null) } else { void navigate({ @@ -954,14 +991,7 @@ function WorkspaceApp({ onChanged={refresh} onOpen={() => { setMobile(false) - if (data.workspace.id === `personal:${data.user.id}`) - selectBot(data.personalAssistant!.id) - else - void navigate({ - to: '/chat/b/$botId', - params: { botId: data.personalAssistant!.id }, - search: defaultWorkspaceSearch, - }) + selectBot(data.personalAssistant!.id) }} /> )} @@ -997,6 +1027,7 @@ function WorkspaceApp({ }} onPrefetch={(bot) => { if (bot.deleted_at !== null) return + rememberChatIdentity(queryClient, bot, data.user.id) void queryClient.prefetchQuery( conversationRouteQuery({ queries: queryClient, diff --git a/src/chat/components/chatIdentityQuery.ts b/src/chat/components/chatIdentityQuery.ts new file mode 100644 index 000000000..19e0e428e --- /dev/null +++ b/src/chat/components/chatIdentityQuery.ts @@ -0,0 +1,32 @@ +import { queryOptions, type QueryClient } from '@tanstack/react-query' +import { resolveChatRoute } from '../workspace.functions' +import type { WorkspaceBot } from '../core/bot-workspace' + +export function chatIdentityQuery(conversationId: string) { + return queryOptions({ + queryKey: ['chat-identity', conversationId], + queryFn: () => resolveChatRoute({ data: { conversationId } }), + staleTime: Infinity, + refetchOnWindowFocus: false, + retry: false, + }) +} + +export function rememberChatIdentity( + queries: QueryClient, + bot: WorkspaceBot, + userId: string, +) { + if (!bot.mainConversationId) return + const identity = { + conversationId: bot.mainConversationId, + botId: bot.id, + workspaceId: bot.workspace_id, + userId, + } + queries.setQueryData( + chatIdentityQuery(identity.conversationId).queryKey, + identity, + ) + return identity +} diff --git a/src/chat/components/conversationRouteQuery.ts b/src/chat/components/conversationRouteQuery.ts index 609423e4f..8548149b4 100644 --- a/src/chat/components/conversationRouteQuery.ts +++ b/src/chat/components/conversationRouteQuery.ts @@ -1,3 +1,4 @@ +import { chatIdentityQuery } from './chatIdentityQuery' import type { QueryClient } from '@tanstack/react-query' import type { History } from './App' import type { WorkspaceBot } from '../core/bot-workspace' @@ -25,18 +26,19 @@ export function conversationRouteQuery({ conversationId?: string userId: string }) { + const requestedConversationId = conversationId ?? bot.mainConversationId return { queryKey: [ 'conversation-route', workspaceId, bot.id, - conversationId, + requestedConversationId, userId, ] as const, queryFn: async () => { const snapshot = await request( - conversationId - ? `conversations/${encodeURIComponent(conversationId)}/history` + requestedConversationId + ? `conversations/${encodeURIComponent(requestedConversationId)}/history` : `bots/${encodeURIComponent(bot.id)}/history`, ) const destination = conversationDestination( @@ -47,7 +49,16 @@ export function conversationRouteQuery({ mainConversationId: bot.mainConversationId, }, snapshot.identity, - conversationId, + requestedConversationId, + ) + queries.setQueryData( + chatIdentityQuery(destination.conversationId).queryKey, + { + conversationId: destination.conversationId, + botId: destination.botId, + workspaceId: destination.workspaceId, + userId: destination.userId, + }, ) queries.setQueryData( conversationQueryKey(destination, 'history'), @@ -56,7 +67,7 @@ export function conversationRouteQuery({ return { snapshot, destination } }, staleTime: conversationRouteFreshMs, - refetchOnMount: 'always' as const, + refetchOnMount: true, refetchOnWindowFocus: false, retry: false, } diff --git a/src/chat/components/useWorkspaceSearch.ts b/src/chat/components/useWorkspaceSearch.ts index 6399afb32..d7b2ca3da 100644 --- a/src/chat/components/useWorkspaceSearch.ts +++ b/src/chat/components/useWorkspaceSearch.ts @@ -1,11 +1,19 @@ +import { useMemo } from 'react' import { useParams, useSearch } from '@tanstack/react-router' import { validateWorkspaceSearch } from '../core/navigation' export function useWorkspaceSearch() { const params = useParams({ strict: false }) - return validateWorkspaceSearch({ - ...useSearch({ strict: false }), - ...(params.conversationId ? { conversation: params.conversationId } : {}), - ...(params.messageId ? { message: params.messageId } : {}), - }) + const search = useSearch({ strict: false }) + return useMemo( + () => + validateWorkspaceSearch({ + ...search, + ...(params.conversationId + ? { conversation: params.conversationId } + : {}), + ...(params.messageId ? { message: params.messageId } : {}), + }), + [search, params.conversationId, params.messageId], + ) } diff --git a/src/chat/workspace.server.ts b/src/chat/workspace.server.ts index 66073634a..d3554d9c2 100644 --- a/src/chat/workspace.server.ts +++ b/src/chat/workspace.server.ts @@ -15,6 +15,68 @@ export async function openPersonalChatWorkspace(userId: string) { const workspaceId = `personal:${userId}` const assistantId = `assistant:${userId}` const conversationId = `chat:${assistantId}:${userId}` + const [existing] = await db + .select({ workspace: chatWorkspaces }) + .from(chatWorkspaces) + .innerJoin( + chatMemberships, + and( + eq(chatMemberships.workspaceId, chatWorkspaces.id), + eq(chatMemberships.userId, userId), + eq(chatMemberships.role, 'owner'), + ), + ) + .innerJoin(chatAccountOnboarding, eq(chatAccountOnboarding.userId, userId)) + .innerJoin( + chatBots, + and( + eq(chatBots.id, assistantId), + eq(chatBots.workspaceId, chatWorkspaces.id), + ), + ) + .innerJoin( + chatConversations, + and( + eq(chatConversations.id, conversationId), + eq(chatConversations.botId, chatBots.id), + eq(chatConversations.userId, userId), + ), + ) + .innerJoin( + chatConversationMains, + and( + eq(chatConversationMains.conversationId, chatConversations.id), + eq(chatConversationMains.botId, chatBots.id), + eq(chatConversationMains.userId, userId), + ), + ) + .where( + and( + eq(chatWorkspaces.id, workspaceId), + eq(chatWorkspaces.ownerId, userId), + ), + ) + if (existing) { + const bots = await db + .select() + .from(chatBots) + .where( + and( + eq(chatBots.workspaceId, workspaceId), + isNull(chatBots.archivedAt), + isNull(chatBots.deletedAt), + ), + ) + return { + workspace: { + ...existing.workspace, + policy: chatPolicySchema.parse(existing.workspace.policy), + }, + bots, + assistantId, + conversationId, + } + } return db.transaction(async (tx) => { await tx .insert(chatWorkspaces) diff --git a/src/routes/chat.b.$botId.tsx b/src/routes/chat.b.$botId.tsx index fb1189a9d..34908dd86 100644 --- a/src/routes/chat.b.$botId.tsx +++ b/src/routes/chat.b.$botId.tsx @@ -1,16 +1,44 @@ -import { createFileRoute, redirect } from '@tanstack/react-router' +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' import { resolveChatRoute } from '~/chat/workspace.functions' import { validateWorkspaceSearch } from '~/chat/core/navigation' +import { chatIdentityQuery } from '~/chat/components/chatIdentityQuery' +import { LoadingState } from '~/chat/components/ui/LoadingState' export const Route = createFileRoute('/chat/b/$botId')({ validateSearch: validateWorkspaceSearch, - beforeLoad: async ({ params, search }) => { - const identity = await resolveChatRoute({ data: { botId: params.botId } }) - throw redirect({ + component: BotChatEntry, +}) +function BotChatEntry() { + const { botId } = Route.useParams() + const search = Route.useSearch() + const navigate = useNavigate() + const queries = useQueryClient() + const identity = useQuery({ + queryKey: ['chat-bot-identity', botId], + queryFn: () => resolveChatRoute({ data: { botId } }), + staleTime: Infinity, + retry: false, + }) + useEffect(() => { + if (!identity.data) return + queries.setQueryData( + chatIdentityQuery(identity.data.conversationId).queryKey, + identity.data, + ) + void navigate({ to: '/chat/c/$conversationId', - params: { conversationId: identity.conversationId }, + params: { conversationId: identity.data.conversationId }, search: { ...search, conversation: undefined }, replace: true, }) - }, -}) + }, [identity.data, navigate, queries, search]) + if (identity.error) + return ( +
+ {identity.error.message} +
+ ) + return Opening conversation… +} diff --git a/src/routes/chat.c.$conversationId.tsx b/src/routes/chat.c.$conversationId.tsx index c33a750ff..19c1e3718 100644 --- a/src/routes/chat.c.$conversationId.tsx +++ b/src/routes/chat.c.$conversationId.tsx @@ -1,6 +1,5 @@ import { createFileRoute, stripSearchParams } from '@tanstack/react-router' import { App } from '~/chat/components/App' -import { resolveChatRoute } from '~/chat/workspace.functions' import { defaultWorkspaceSearch, validateWorkspaceSearch, @@ -9,10 +8,5 @@ import { export const Route = createFileRoute('/chat/c/$conversationId')({ validateSearch: validateWorkspaceSearch, search: { middlewares: [stripSearchParams(defaultWorkspaceSearch)] }, - beforeLoad: async ({ params }) => ({ - chatIdentity: await resolveChatRoute({ - data: { conversationId: params.conversationId }, - }), - }), component: App, }) diff --git a/src/routes/chat.index.tsx b/src/routes/chat.index.tsx index e34659ee9..21bd10b80 100644 --- a/src/routes/chat.index.tsx +++ b/src/routes/chat.index.tsx @@ -1,25 +1,51 @@ import { createFileRoute, - redirect, + useNavigate, type SearchSchemaInput, } from '@tanstack/react-router' -import { getCurrentUser } from '~/utils/auth.functions' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' import { loadChatWorkspace } from '~/chat/workspace.functions' import { validateWorkspaceSearch } from '~/chat/core/navigation' +import { chatIdentityQuery } from '~/chat/components/chatIdentityQuery' +import { LoadingState } from '~/chat/components/ui/LoadingState' export const Route = createFileRoute('/chat/')({ validateSearch: (search: Record & SearchSchemaInput) => validateWorkspaceSearch(search), - beforeLoad: async ({ search }) => { - const user = await getCurrentUser() - if (!user) throw redirect({ to: '/login', search: { returnTo: '/chat' } }) - const workspace = await loadChatWorkspace() - throw redirect({ + component: ChatEntry, +}) +function ChatEntry() { + const search = Route.useSearch() + const navigate = useNavigate() + const queries = useQueryClient() + const workspace = useQuery({ + queryKey: ['personal-chat-workspace'], + queryFn: () => loadChatWorkspace(), + staleTime: Infinity, + retry: false, + }) + useEffect(() => { + if (!workspace.data) return + const data = workspace.data + queries.setQueryData(chatIdentityQuery(data.conversationId).queryKey, { + conversationId: data.conversationId, + botId: data.assistantId, + workspaceId: data.workspace.id, + userId: data.workspace.ownerId, + }) + void navigate({ to: '/chat/c/$conversationId', - params: { - conversationId: search.conversation ?? workspace.conversationId, - }, + params: { conversationId: search.conversation ?? data.conversationId }, search: { ...search, conversation: undefined }, + replace: true, }) - }, -}) + }, [workspace.data, queries, navigate, search]) + if (workspace.error) + return ( +
+ {workspace.error.message} +
+ ) + return Opening TanChat… +} diff --git a/src/routes/chat.tsx b/src/routes/chat.tsx index 89010ce78..eade2280a 100644 --- a/src/routes/chat.tsx +++ b/src/routes/chat.tsx @@ -16,12 +16,15 @@ const initialAppearance = createServerFn({ method: 'GET' }).handler( export const Route = createFileRoute('/chat')({ staticData: { showNavbar: false }, - beforeLoad: async () => { + beforeLoad: async ({ cause }) => { + if (cause === 'stay') return const access = await getChatAccess() if (!access?.unlocked) throw redirect({ to: '/chat-access', search: { invite: undefined } }) }, - loader: () => initialAppearance(), + loader: () => (typeof window === 'undefined' ? initialAppearance() : null), + staleTime: Infinity, + shouldReload: false, headers: () => ({ ...webContainerHeaders, 'Cache-Control': 'private, no-store',