Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/sim/app/o/[organizationId]/home/organization-home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 <HomeFallback />
if (isRestoredChatEntry || !isClient || !session?.user?.id) return <HomeFallback />
return (
<OrganizationHomeContent
key={`${session.user.id}:${organization.id}:${props.chatId ?? 'new'}`}
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/home/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ import { persistImportedWorkflow } from '@/lib/workflows/operations/import-expor
import { ChatResourcePanel } from '@/app/workspace/[workspaceId]/home/components/chat-resource-panel'
import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
import { SuggestedActions } from '@/app/workspace/[workspaceId]/home/components/suggested-actions'
import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'
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 { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary'
import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
Expand Down Expand Up @@ -58,6 +60,8 @@ interface HomeProps {
}

export function Home(props: HomeProps) {
const isRestoredChatEntry = useRestoredChatEntry({ chatId: props.chatId })
if (isRestoredChatEntry) return <HomeFallback />
return (
<PermissionAccessBoundary configKey='hideCopilot'>
<HomeContent {...props} />
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<StrictMode>
<Surface {...props} />
</StrictMode>
)
)
}

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()
})
})
Original file line number Diff line number Diff line change
@@ -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/<id>` 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
}
99 changes: 99 additions & 0 deletions apps/sim/hooks/queries/mothership-chat-effort.test.tsx
Original file line number Diff line number Diff line change
@@ -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>) => 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<void>

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(
<QueryClientProvider client={new QueryClient()}>
<ToastProvider>
<EffortPicker
onReady={(next) => {
pick = next
}}
/>
</ToastProvider>
</QueryClientProvider>
)
})
})

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<Response>(), Promise.withResolvers<Response>()]
for (const save of saves) vi.mocked(fetch).mockReturnValueOnce(save.promise)

let outcomes: Promise<void>[] = []
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)
})
})
7 changes: 5 additions & 2 deletions apps/sim/hooks/queries/mothership-chats.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { toast } from '@sim/emcn'
import { toError } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
import {
Expand Down Expand Up @@ -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<MothershipChatHistory>(
Expand Down
19 changes: 11 additions & 8 deletions apps/sim/stores/mothership-effort/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ interface MothershipEffortState {
chatEfforts: Record<string, ChatEffortPick>
/** 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
Expand All @@ -55,7 +58,7 @@ function withModelSelection(
export const useMothershipEffortStore = create<MothershipEffortState>()(
devtools(
persist(
(set) => ({
(set, get) => ({
...initialState,
setFastMode: (fastMode) =>
set((state) => withModelSelection({ ...state.modelSelection, fastMode })),
Expand All @@ -67,11 +70,11 @@ export const useMothershipEffortStore = create<MothershipEffortState>()(
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) => ({
Expand Down
Loading