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) => ({