diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.tsx index f6b150b2049..f0dc31e6986 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.tsx @@ -35,6 +35,7 @@ import { useChatResourcePanel, useResourcePanelController, } from '@/app/workspace/[workspaceId]/home/hooks/use-resource-panel' +import { useRestoredChatEntry } from '@/app/workspace/[workspaceId]/home/hooks/use-restored-chat-entry' import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref' import { searchFiltersFromParams } from '@/app/workspace/[workspaceId]/home/search-params' import type { @@ -66,9 +67,10 @@ export function OrganizationHome(props: OrganizationHomeProps) { const { organization, searchAccess, canBuild, mothershipAvailable } = useOrganizationContext() const { data: session } = useSession() const isClient = useSyncExternalStore(subscribeToClient, clientSnapshot, serverSnapshot) + const isRestoredChatEntry = useRestoredChatEntry({ chatId: props.chatId }) if (!mothershipAvailable || (!canBuild && !searchAccess.memberScoped)) return null /** Preferences are browser-persisted and keyed by user; never paint a guessed mode first. */ - if (!isClient || !session?.user?.id) return + if (isRestoredChatEntry || !isClient || !session?.user?.id) return return ( return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-restored-chat-entry.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-restored-chat-entry.test.tsx new file mode 100644 index 00000000000..1438dee0313 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-restored-chat-entry.test.tsx @@ -0,0 +1,83 @@ +/** @vitest-environment jsdom */ + +import { act, StrictMode } from 'react' +import { nextNavigationMock, nextNavigationMockFns } from '@sim/testing/mocks/next-navigation.mock' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('next/navigation', () => nextNavigationMock) + +import { useRestoredChatEntry } from '@/app/workspace/[workspaceId]/home/hooks/use-restored-chat-entry' + +interface SurfaceProps { + chatId?: string +} + +let isRestoredEntry: boolean | undefined +function Surface({ chatId }: SurfaceProps) { + isRestoredEntry = useRestoredChatEntry({ chatId }) + return null +} + +let root: Root +function at(url: string) { + const { pathname, search } = new URL(url, 'http://localhost') + nextNavigationMockFns.mockUsePathname.mockReturnValue(pathname) + nextNavigationMockFns.mockUseSearchParams.mockReturnValue(new URLSearchParams(search)) +} +function render(props: SurfaceProps = {}) { + act(() => + root.render( + + + + ) + ) +} + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + isRestoredEntry = undefined + root = createRoot(document.createElement('div')) +}) + +afterEach(() => { + act(() => root.unmount()) +}) + +describe('useRestoredChatEntry', () => { + it('hands a new-chat surface restored at a chat URL to the router, query included', () => { + at('/workspace/w/chat/c?resource=x') + render() + + expect(isRestoredEntry).toBe(true) + expect(nextNavigationMockFns.router.replace).toHaveBeenLastCalledWith( + '/workspace/w/chat/c?resource=x', + { scroll: false } + ) + }) + + it('keeps rendering the surface that moved its own URL to the chat during a turn', () => { + at('/workspace/w/home') + render() + at('/workspace/w/chat/c') + render() + + expect(isRestoredEntry).toBe(false) + expect(nextNavigationMockFns.router.replace).not.toHaveBeenCalled() + }) + + it('leaves a chat surface and the home URL alone', () => { + at('/workspace/w/chat/c') + render({ chatId: 'c' }) + expect(isRestoredEntry).toBe(false) + + act(() => root.unmount()) + root = createRoot(document.createElement('div')) + at('/o/o/home') + render() + expect(isRestoredEntry).toBe(false) + + expect(nextNavigationMockFns.router.replace).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-restored-chat-entry.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-restored-chat-entry.ts new file mode 100644 index 00000000000..01f6cfb1312 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-restored-chat-entry.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from 'react' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' + +const CHAT_PATH = /^\/(?:workspace|o)\/[^/]+\/chat\/[^/]+$/ + +interface UseRestoredChatEntryProps { + /** The chat the surface was opened for; the new-chat surface has none. */ + chatId: string | undefined +} + +/** + * Hands a restored new-chat history entry back to the router. + * + * A new chat moves its URL from the home route to `/chat/` in place, through + * `history.replaceState`, so the turn streaming on that surface stays mounted. Next keeps + * the home route's tree in that history entry, so Back or Forward to it mounts the home + * route at the chat's URL. Replacing the entry through the router resolves the chat route + * and stores its tree, so later visits to the entry render the chat directly. Only the URL + * at mount counts: the surface that moved its own URL keeps rendering. + * + * @returns Whether this mount is a restored entry; the caller renders its fallback until + * the chat route replaces it. + */ +export function useRestoredChatEntry({ chatId }: UseRestoredChatEntryProps): boolean { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + const [restoredChatUrl] = useState(() => { + if (chatId || !CHAT_PATH.test(pathname)) return null + const search = searchParams.toString() + return search ? `${pathname}?${search}` : pathname + }) + + useEffect(() => { + if (restoredChatUrl) router.replace(restoredChatUrl, { scroll: false }) + }, [restoredChatUrl, router]) + + return restoredChatUrl !== null +} diff --git a/apps/sim/hooks/queries/mothership-chat-effort.test.tsx b/apps/sim/hooks/queries/mothership-chat-effort.test.tsx new file mode 100644 index 00000000000..9609fd2afbe --- /dev/null +++ b/apps/sim/hooks/queries/mothership-chat-effort.test.tsx @@ -0,0 +1,99 @@ +/** @vitest-environment jsdom */ + +import { act, useEffect } from 'react' +import { jsonResponse } from '@sim/testing/helpers/http' +import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock' +import { sleep } from '@sim/utils/helpers' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('next/navigation', () => nextNavigationMock) + +import { ToastProvider } from '@sim/emcn' +import type { MothershipEffort } from '@/lib/mothership/model-options' +import { useSetMothershipChatEffort } from '@/hooks/queries/mothership-chats' +import { useMothershipEffortStore } from '@/stores/mothership-effort/store' + +const FAILURE_NOTICE = "Couldn't change reasoning effort" + +interface EffortPickerProps { + onReady: (pick: (effort: MothershipEffort) => Promise) => void +} + +function EffortPicker({ onReady }: EffortPickerProps) { + const { mutateAsync } = useSetMothershipChatEffort('chat-1') + useEffect( + () => onReady((effort) => mutateAsync(effort).catch(() => undefined)), + [onReady, mutateAsync] + ) + return null +} + +let root: Root +let pick: (effort: MothershipEffort) => Promise + +beforeEach(async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + vi.stubGlobal('fetch', vi.fn()) + useMothershipEffortStore.getState().reset() + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root.render( + + + { + pick = next + }} + /> + + + ) + }) +}) + +afterEach(() => { + act(() => root.unmount()) + document.body.innerHTML = '' +}) + +describe('chat effort save failures', () => { + it('tells the user when a failed save rolls their pick back', async () => { + vi.mocked(fetch).mockResolvedValueOnce(new Response('save failed', { status: 500 })) + + await act(() => pick('xhigh')) + + expect(useMothershipEffortStore.getState().chatEfforts['chat-1']).toBeUndefined() + expect(document.body.textContent).toContain(FAILURE_NOTICE) + }) + + it('stays quiet when a newer pick already replaced the one that failed', async () => { + const saves = [Promise.withResolvers(), Promise.withResolvers()] + for (const save of saves) vi.mocked(fetch).mockReturnValueOnce(save.promise) + + let outcomes: Promise[] = [] + await act(async () => { + outcomes = [pick('low'), pick('high')] + await sleep(1) + }) + await act(async () => { + saves[0].resolve(new Response('save failed', { status: 500 })) + saves[1].resolve(jsonResponse({ success: true })) + await Promise.all(outcomes) + }) + + expect(useMothershipEffortStore.getState().chatEfforts['chat-1']?.effort).toBe('high') + expect(document.body.textContent).not.toContain(FAILURE_NOTICE) + }) +}) diff --git a/apps/sim/hooks/queries/mothership-chats.ts b/apps/sim/hooks/queries/mothership-chats.ts index 5bcdc5966f4..ef196a6464e 100644 --- a/apps/sim/hooks/queries/mothership-chats.ts +++ b/apps/sim/hooks/queries/mothership-chats.ts @@ -1,3 +1,4 @@ +import { toast } from '@sim/emcn' import { toError } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' import { @@ -663,8 +664,10 @@ function chatEffortMutationOptions(queryClient: QueryClient, chatId: string | un return { pick: useMothershipEffortStore.getState().setChatEffort(chatId, effort) } }, onError: (_error, _effort, context) => { - if (chatId && context) - useMothershipEffortStore.getState().dropChatEffort(chatId, context.pick) + if (!chatId || !context) return + if (useMothershipEffortStore.getState().dropChatEffort(chatId, context.pick)) { + toast.error("Couldn't change reasoning effort") + } }, onSuccess: (_data, effort) => { queryClient.setQueryData( diff --git a/apps/sim/stores/mothership-effort/store.ts b/apps/sim/stores/mothership-effort/store.ts index 0ec4b53977b..bf60d53618b 100644 --- a/apps/sim/stores/mothership-effort/store.ts +++ b/apps/sim/stores/mothership-effort/store.ts @@ -27,8 +27,11 @@ interface MothershipEffortState { chatEfforts: Record /** Records a pick and returns its token for {@link MothershipEffortState.dropChatEffort}. */ setChatEffort: (chatId: string, effort: MothershipEffort) => number - /** Drops a pick whose save failed, unless a newer pick replaced it, even one of the same value. */ - dropChatEffort: (chatId: string, pick: number) => void + /** + * Drops a pick whose save failed, unless a newer pick replaced it, even one of the same value. + * Returns whether it dropped the pick, which rolls the chat back to its saved effort. + */ + dropChatEffort: (chatId: string, pick: number) => boolean /** Moves the new-chat pick onto the chat its first send created. */ adoptNewChatEffort: (chatId: string, effort: MothershipEffort) => void reset: () => void @@ -55,7 +58,7 @@ function withModelSelection( export const useMothershipEffortStore = create()( devtools( persist( - (set) => ({ + (set, get) => ({ ...initialState, setFastMode: (fastMode) => set((state) => withModelSelection({ ...state.modelSelection, fastMode })), @@ -67,11 +70,11 @@ export const useMothershipEffortStore = create()( set((state) => ({ chatEfforts: { ...state.chatEfforts, [chatId]: { effort, pick } } })) return pick }, - dropChatEffort: (chatId, pick) => - set((state) => { - if (state.chatEfforts[chatId]?.pick !== pick) return state - return { chatEfforts: omit(state.chatEfforts, [chatId]) } - }), + dropChatEffort: (chatId, pick) => { + if (get().chatEfforts[chatId]?.pick !== pick) return false + set((state) => ({ chatEfforts: omit(state.chatEfforts, [chatId]) })) + return true + }, adoptNewChatEffort: (chatId, effort) => { const pick = ++lastChatEffortPick set((state) => ({