Skip to content

Commit 88720d7

Browse files
committed
feat(home): migrate ?resource deep-link to nuqs (URL as source of truth)
Replace the banned window.history.replaceState effect on the home/Chat surface with a nuqs useQueryState('resource') binding. The URL is now the single source of truth for the selected resource. - Add co-located home/search-params.ts (resource param, history: replace) - useChat accepts a controlled activeResourceState binding; home passes the nuqs-backed tuple. The workflow editor copilot keeps internal useState so its resource selection stays out of the URL (editor carve-out) - Preserve the old effect's url.hash='' fragment strip in the binding setter (fragment-only rewrite, not a param mutation) - Drop initialResourceId SSR prop from both page entries (nuqs reads the URL on mount; no dual source) and wrap Home in Suspense for useSearchParams
1 parent ebcad6b commit 88720d7

6 files changed

Lines changed: 130 additions & 43 deletions

File tree

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { Suspense } from 'react'
12
import type { Metadata } from 'next'
23
import { getSession } from '@/lib/auth'
34
import { Home } from '@/app/workspace/[workspaceId]/home/home'
5+
import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'
46

57
export const metadata: Metadata = {
68
title: 'Chat',
@@ -11,22 +13,18 @@ interface ChatPageProps {
1113
workspaceId: string
1214
chatId: string
1315
}>
14-
searchParams: Promise<{ resource?: string }>
1516
}
1617

17-
export default async function ChatPage({ params, searchParams }: ChatPageProps) {
18-
const [{ chatId }, { resource }, session] = await Promise.all([
19-
params,
20-
searchParams,
21-
getSession(),
22-
])
18+
export default async function ChatPage({ params }: ChatPageProps) {
19+
const [{ chatId }, session] = await Promise.all([params, getSession()])
2320
return (
24-
<Home
25-
key={chatId}
26-
chatId={chatId}
27-
userName={session?.user?.name}
28-
userId={session?.user?.id}
29-
initialResourceId={resource ?? null}
30-
/>
21+
<Suspense fallback={<HomeFallback />}>
22+
<Home
23+
key={chatId}
24+
chatId={chatId}
25+
userName={session?.user?.name}
26+
userId={session?.user?.id}
27+
/>
28+
</Suspense>
3129
)
3230
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Suspense fallback for the home/Chat surface. `Home` reads the `?resource=`
3+
* URL param via nuqs (`useQueryState`, which uses `useSearchParams`
4+
* internally), so it must sit under a Suspense boundary. This renders the
5+
* surface background so a suspend never flashes a blank frame before the chat
6+
* mounts.
7+
*/
8+
export function HomeFallback() {
9+
return <div className='h-full bg-[var(--bg)]' />
10+
}

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
'use client'
22

3-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
3+
import {
4+
type Dispatch,
5+
type SetStateAction,
6+
useCallback,
7+
useEffect,
8+
useMemo,
9+
useRef,
10+
useState,
11+
} from 'react'
412
import { createLogger } from '@sim/logger'
513
import { useParams, useRouter } from 'next/navigation'
14+
import { useQueryState } from 'nuqs'
615
import { usePostHog } from 'posthog-js/react'
716
import { Button } from '@/components/emcn'
817
import { PanelLeft } from '@/components/emcn/icons'
@@ -25,6 +34,7 @@ import {
2534
} from '@/lib/mothership/events'
2635
import { captureEvent } from '@/lib/posthog/client'
2736
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
37+
import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params'
2838
import { useFolders } from '@/hooks/queries/folders'
2939
import {
3040
useMarkMothershipChatRead,
@@ -53,13 +63,42 @@ interface HomeProps {
5363
chatId?: string
5464
userName?: string
5565
userId?: string
56-
initialResourceId?: string | null
5766
}
5867

59-
export function Home({ chatId, userName, userId, initialResourceId = null }: HomeProps) {
68+
export function Home({ chatId, userName, userId }: HomeProps) {
6069
useOAuthReturnRouter()
6170
const { workspaceId } = useParams<{ workspaceId: string }>()
6271
const router = useRouter()
72+
// URL is the single source of truth for the selected resource. `Home` renders
73+
// client-side, so nuqs reads `?resource=` from the URL on mount — the same
74+
// value the page previously threaded through `initialResourceId` — and writes
75+
// it back with `history: 'replace'`, the previous behavior, minus the banned
76+
// `window.history.replaceState` param-mutation effect. The page wraps `Home`
77+
// in Suspense for the `useSearchParams` requirement.
78+
const [activeResourceParam, setResourceParam] = useQueryState(resourceParam.key, {
79+
...resourceParam.parser,
80+
...resourceUrlKeys,
81+
})
82+
// Strips any leftover URL fragment on selection change, preserving the old
83+
// effect's `url.hash = ''` (the only hash usage on this surface) without a
84+
// separate effect-sync mirror. This rewrites the fragment only — it never
85+
// mutates a query param via the History API.
86+
const setActiveResourceUrl = useCallback<Dispatch<SetStateAction<string | null>>>(
87+
(action) => {
88+
if (typeof window !== 'undefined' && window.location.hash) {
89+
const { pathname, search } = window.location
90+
window.history.replaceState(window.history.state, '', `${pathname}${search}`)
91+
}
92+
void setResourceParam(action)
93+
},
94+
[setResourceParam]
95+
)
96+
// Controlled binding handed to `useChat` so the URL is the sole owner of the
97+
// selection with no dual source.
98+
const activeResourceState = useMemo<[string | null, Dispatch<SetStateAction<string | null>>]>(
99+
() => [activeResourceParam, setActiveResourceUrl],
100+
[activeResourceParam, setActiveResourceUrl]
101+
)
63102
const firstName = userName?.split(' ')[0] ?? ''
64103
const { data: workspaceFiles = [] } = useWorkspaceFiles(workspaceId)
65104
const { data: workflows = [] } = useWorkflows(workspaceId)
@@ -179,7 +218,7 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom
179218
chatId,
180219
getMothershipUseChatOptions({
181220
onResourceEvent: handleResourceEvent,
182-
initialActiveResourceId: initialResourceId,
221+
activeResourceState,
183222
onRequestStarted: ({ requestId, userMessageId }) => {
184223
captureEvent(posthogRef.current, 'task_request_started', {
185224
workspace_id: workspaceId,
@@ -191,17 +230,6 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom
191230
})
192231
)
193232

194-
useEffect(() => {
195-
const url = new URL(window.location.href)
196-
if (activeResourceId) {
197-
url.searchParams.set('resource', activeResourceId)
198-
} else {
199-
url.searchParams.delete('resource')
200-
}
201-
url.hash = ''
202-
window.history.replaceState(null, '', url.toString())
203-
}, [activeResourceId])
204-
205233
useEffect(() => {
206234
wasSendingRef.current = false
207235
if (resolvedChatId) {

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
1+
import {
2+
type Dispatch,
3+
type SetStateAction,
4+
useCallback,
5+
useEffect,
6+
useMemo,
7+
useRef,
8+
useState,
9+
} from 'react'
210
import { createLogger } from '@sim/logger'
311
import { getErrorMessage, toError } from '@sim/utils/errors'
412
import { sleep } from '@sim/utils/helpers'
@@ -987,6 +995,17 @@ export interface UseChatOptions {
987995
onTitleUpdate?: () => void
988996
onStreamEnd?: (chatId: string, messages: ChatMessage[]) => void
989997
initialActiveResourceId?: string | null
998+
/**
999+
* Controlled binding for the active resource id, supplied as a
1000+
* `[value, setValue]` tuple (e.g. a URL-backed nuqs `useQueryState`). When
1001+
* provided, it is the single source of truth for the selected resource — the
1002+
* hook reads and writes it directly instead of owning the state internally,
1003+
* so no effect-sync mirror is needed. When omitted, `useChat` owns the state
1004+
* via local `useState` (seeded from `initialActiveResourceId`); this is the
1005+
* mode used by the socket-synced workflow editor copilot, whose resource
1006+
* selection intentionally stays out of the URL.
1007+
*/
1008+
activeResourceState?: [string | null, Dispatch<SetStateAction<string | null>>]
9901009
/** Fired when the server's `traceparent` response header arrives, before any stream content. */
9911010
onRequestStarted?: (info: { requestId: string; userMessageId: string }) => void
9921011
}
@@ -1006,7 +1025,11 @@ interface StopGenerationOptions {
10061025
export function getMothershipUseChatOptions(
10071026
options: Pick<
10081027
UseChatOptions,
1009-
'onResourceEvent' | 'onStreamEnd' | 'initialActiveResourceId' | 'onRequestStarted'
1028+
| 'onResourceEvent'
1029+
| 'onStreamEnd'
1030+
| 'initialActiveResourceId'
1031+
| 'activeResourceState'
1032+
| 'onRequestStarted'
10101033
> = {}
10111034
): UseChatOptions {
10121035
return {
@@ -1044,9 +1067,14 @@ export function useChat(
10441067
const [resolvedChatId, setResolvedChatId] = useState<string | undefined>(initialChatId)
10451068
const [queuedHandoffRecoveryEpoch, setQueuedHandoffRecoveryEpoch] = useState(0)
10461069
const [resources, setResources] = useState<MothershipResource[]>([])
1047-
const [activeResourceId, setActiveResourceId] = useState<string | null>(
1070+
const internalActiveResourceState = useState<string | null>(
10481071
options?.initialActiveResourceId ?? null
10491072
)
1073+
// Prefer a caller-supplied controlled binding (URL-backed nuqs on the home/Chat
1074+
// surface) so the URL is the single source of truth; fall back to internal state
1075+
// for the workflow editor copilot, which keeps resource selection out of the URL.
1076+
const [activeResourceId, setActiveResourceId] =
1077+
options?.activeResourceState ?? internalActiveResourceState
10501078
const [genericResourceData, setGenericResourceData] = useState<GenericResourceData | null>(null)
10511079
const onResourceEventRef = useRef(options?.onResourceEvent)
10521080
const revealedSimKeysRef = useRef<RevealedSimKeysByMessage>(new Map())
Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,18 @@
1+
import { Suspense } from 'react'
12
import type { Metadata } from 'next'
23
import { getSession } from '@/lib/auth'
34
import { Home } from './home'
5+
import { HomeFallback } from './home-fallback'
46

57
export const metadata: Metadata = {
68
title: 'New chat',
79
}
810

9-
interface HomePageProps {
10-
searchParams: Promise<{ resource?: string }>
11-
}
12-
13-
export default async function HomePage({ searchParams }: HomePageProps) {
14-
const [session, { resource }] = await Promise.all([getSession(), searchParams])
11+
export default async function HomePage() {
12+
const session = await getSession()
1513
return (
16-
<Home
17-
userName={session?.user?.name}
18-
userId={session?.user?.id}
19-
initialResourceId={resource ?? null}
20-
/>
14+
<Suspense fallback={<HomeFallback />}>
15+
<Home userName={session?.user?.name} userId={session?.user?.id} />
16+
</Suspense>
2117
)
2218
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { parseAsString } from 'nuqs/server'
2+
3+
/**
4+
* Co-located, typed URL query-param definition for the home/Chat surface.
5+
*
6+
* `resource` deep-links the resource panel to the selected resource. The active
7+
* resource id is the single source of truth for which resource the panel shows;
8+
* `useChat` reads and writes it through this param, and the effective selection
9+
* is derived against the loaded resource list (an unknown/stale id falls back to
10+
* the last resource). The URL key is `resource` — existing shared links depend on
11+
* it, so it must not be renamed.
12+
*/
13+
export const resourceParam = {
14+
key: 'resource',
15+
parser: parseAsString,
16+
} as const
17+
18+
/**
19+
* Selecting a resource is a filter-like view change, not back-stack navigation,
20+
* so it replaces the current history entry (matching the previous
21+
* `window.history.replaceState` behavior). `clearOnDefault` drops the key from
22+
* the URL when no resource is active.
23+
*/
24+
export const resourceUrlKeys = {
25+
history: 'replace',
26+
clearOnDefault: true,
27+
} as const

0 commit comments

Comments
 (0)