diff --git a/.changeset/shell-storage-routing.md b/.changeset/shell-storage-routing.md deleted file mode 100644 index 5bc752ee8..000000000 --- a/.changeset/shell-storage-routing.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@truefoundry/trueforge-ui': minor ---- - -When `withRouter` is off, shell navigation (places + per-route search params) persists in sessionStorage and no longer writes share params to the host URL. Pasted share links still win on boot, then are consumed. diff --git a/packages/trueforge-ui/README.md b/packages/trueforge-ui/README.md index a81347a49..d74ca22ca 100644 --- a/packages/trueforge-ui/README.md +++ b/packages/trueforge-ui/README.md @@ -214,18 +214,16 @@ export default function App() { /> ``` -| Prop | Type | Required | Description | -| ------------------ | -------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `server` | `TrueForgeServerConfig` | ✅ | Built-in config (`truefoundry` / `trueforge`) **or** a ready `AgentUIServer`. | -| `layout` | `LayoutProp` | ✅ | Built-in layout string **or** a custom React component. | -| `agentConfig` | `AgentConfig` | — | Shell mode: SingleAgent / AgentLibrary / AgentComposer / AgentLibraryWithComposer. Defaults to library + composer. | -| `theme` | `ThemeConfig` | — | Preset, mode, tokens, brand, icons, **content `classNames`** (see [Theming](#theming)). | -| `overrides` | `SlotOverrides` | — | Map of slot overrides (see [Overriding components](#overriding-components)). | -| `className` | `string` | — | Applied to the layout root. | -| `initialSessionId` | `string` | — | Resume a specific session. | -| `withRouter` | `boolean` | — | Sync shell navigation to the browser URL via react-router. When off (default), the same places persist in sessionStorage without mutating the host URL. See [URL routing](./docs/customization.md#url-routing-withrouter). | -| `routes` | `RoutesConfig` | — | Path customization; only honored when `withRouter`. | -| `onError` | `(error: unknown) => void` | — | Host error hook (runtime + server init). | +| Prop | Type | Required | Description | +| ------------------ | -------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------ | +| `server` | `TrueForgeServerConfig` | ✅ | Built-in config (`truefoundry` / `trueforge`) **or** a ready `AgentUIServer`. | +| `layout` | `LayoutProp` | ✅ | Built-in layout string **or** a custom React component. | +| `agentConfig` | `AgentConfig` | — | Shell mode: SingleAgent / AgentLibrary / AgentComposer / AgentLibraryWithComposer. Defaults to library + composer. | +| `theme` | `ThemeConfig` | — | Preset, mode, tokens, brand, icons, **content `classNames`** (see [Theming](#theming)). | +| `overrides` | `SlotOverrides` | — | Map of slot overrides (see [Overriding components](#overriding-components)). | +| `className` | `string` | — | Applied to the layout root. | +| `initialSessionId` | `string` | — | Resume a specific session. | +| `onError` | `(error: unknown) => void` | — | Host error hook (runtime + server init). | Later sections use `server` as a `TrueForgeServerConfig` (usually `type: "truefoundry"`). For a host-built port, pass the `AgentUIServer` directly. diff --git a/packages/trueforge-ui/docs/customization.md b/packages/trueforge-ui/docs/customization.md index 20140ee2a..2bfba623f 100644 --- a/packages/trueforge-ui/docs/customization.md +++ b/packages/trueforge-ui/docs/customization.md @@ -93,16 +93,7 @@ router should leave it off (the default). ``` -When `withRouter` is off, the same places and per-route search params are -persisted in `sessionStorage` (key `tfy-aui-shell-location`) instead of the -browser URL. Reloads restore the last place (settings, library agent + tab, -session, sessions browser filters, etc.). The package does **not** mutate the -host URL for ongoing navigation. Pasted share links (`?view=sessions`, -`?agentId=`, `?sessionId=`, …) are still honored on boot — they win over the -stored location, then are stripped from the real URL so a later reload restores -from storage. - -Places mirrored to the URL (or to sessionStorage when `withRouter` is off): +Places mirrored to the URL: - `/` — new chat / library landing (mode-dependent) - `/build-agent` — new agent builder @@ -136,32 +127,25 @@ to keep that place overlay-only with no URL: Custom `agent` / `session` / `libraryAgent` templates must keep their `:param` segment, or the place can be written to the URL but not read back. -Shell state stays the source of truth; the router (or sessionStorage store) -mirrors it. Combining `withRouter` with `initialSessionId` is not supported — -the URL wins. Without `withRouter`, a stored location similarly wins over -`initialSessionId` after the first visit. +Shell state stays the source of truth; the router mirrors it. Combining +`withRouter` with `initialSessionId` is not supported — the URL wins. Notes on behaviour: -- Hashes and host-owned query keys are preserved across navigation when - `withRouter` is on. Session keys (`sessionId`, `agentId`, `tab`, `view`, - `s_tw`, `s_sts`, `s_ets`) are removed when the destination does not own them, - preventing stale filters or selections from leaking into unrelated routes. - The same ownership rules apply to the sessionStorage search string when - `withRouter` is off. +- Hashes and host-owned query keys are preserved across navigation. Session + keys (`sessionId`, `agentId`, `tab`, `view`, `s_tw`, `s_sts`, `s_ets`) are + removed when the destination does not own them, preventing stale filters or + selections from leaking into unrelated routes. - A copied library session link is `?agentId=&sessionId=` on the current page (plus `/library/:agentId` when `withRouter`). Opening it lands on that agent's Sessions tab. Clicking an agent in the library writes `?tab=overview` so a leftover chat `sessionId` does not open Sessions. The same query works - when `withRouter` is off (consumed into sessionStorage on boot, then stripped - from the host URL). -- The all-user Sessions page is `/sessions` when `withRouter` is on. When it is - off, the stored path is still `/sessions` (with `view` / time-range query in - sessionStorage); a pasted `?view=sessions` share link opens the same page on - boot. Agent and time filters live in the query (`agentId`, `s_tw` for a - relative window, or `s_sts`/`s_ets` for an absolute range). Opening a session - pins `s_sts`/`s_ets` around `created_at` (±5 min) so a refresh still finds - that row on page 1 without scrolling the list. + when `withRouter` is off. +- The all-user Sessions page is `/sessions` when `withRouter` is on, or + `?view=sessions` when it is off. Agent and time filters live in the query + (`agentId`, `s_tw` for a relative window, or `s_sts`/`s_ets` for an absolute + range). Opening a session pins `s_sts`/`s_ets` around `created_at` (±5 min) + so a refresh still finds that row on page 1 without scrolling the list. - A `/sessions/:sessionId` link is resolved through `getSession` so the chat opens with its own agent binding and mutability rather than as a new draft. - `/build-agent` is used for a fresh builder; after its draft session persists, diff --git a/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx b/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx index e1058dc6c..ca8309399 100644 --- a/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx +++ b/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx @@ -16,7 +16,6 @@ import { HistorySessionSwitchBridge } from '../routing/HistorySessionSwitchBridg import { LibrarySessionShareBoot } from '../routing/LibrarySessionShareBoot.js'; import { RemoteIdRouteBridge } from '../routing/RemoteIdRouteBridge.js'; import { ResolvedRoutesProvider } from '../routing/ResolvedRoutesContext.js'; -import { ShellLocationProvider } from '../routing/ShellLocationContext.js'; import type { ResolvedRoutes, RoutesConfig } from '../routing/types.js'; import { CustomActionRenderersProvider, type CustomActionRenderers } from '../server/CustomActionRenderersContext.js'; import { ServerProvider } from '../server/ServerContext.js'; @@ -35,9 +34,6 @@ const DrawerLayout = lazy(() => import('../layouts/DrawerLayout.js').then(m => ( const DockLayout = lazy(() => import('../layouts/DockLayout.js').then(m => ({ default: m.DockLayout }))); const WidgetLayout = lazy(() => import('../layouts/WidgetLayout.js').then(m => ({ default: m.WidgetLayout }))); const ShellRouteSync = lazy(() => import('../routing/ShellRouteSync.js').then(m => ({ default: m.ShellRouteSync }))); -const ShellStorageRouteSync = lazy(() => - import('../routing/ShellStorageRouteSync.js').then(m => ({ default: m.ShellStorageRouteSync })), -); export type ChatLayout = 'sidebar' | 'drawer' | 'dock' | 'widget'; @@ -66,10 +62,9 @@ export type TrueForgeUIProps = { */ customActionRenderers?: CustomActionRenderers; /** - * Sync shell navigation to the browser URL via react-router. + * Sync shell navigation to the browser URL via react-router (opt-in). * Requires `react-router-dom` in the host. Leave off for dock/widget embeds - * and hosts that own their own router — navigation then persists in sessionStorage - * without mutating the host URL. Defaults to `false`. + * and hosts that own their own router. Defaults to `false`. */ withRouter?: boolean; /** URL path customization; only honored when `withRouter`. */ @@ -331,15 +326,11 @@ export function TrueForgeUIShell(props: TrueForgeUIShellProps) { initialSettingsOpen={initialSettingsOpen} /> - ) : ( - - - - )} + ) : null} {layoutTree} @@ -350,18 +341,15 @@ export function TrueForgeUIShell(props: TrueForgeUIShellProps) { const visibilityTree = layout === 'widget' ? {shellTree} : shellTree; - const withLocation = - resolvedRoutes == null ? {visibilityTree} : visibilityTree; - return ( {resolvedRoutes != null ? ( - {withLocation} + {visibilityTree} ) : ( - withLocation + visibilityTree )} diff --git a/packages/trueforge-ui/src/hooks/useSessionShareSearch.ts b/packages/trueforge-ui/src/hooks/useSessionShareSearch.ts index 24eb610d6..acbcbea51 100644 --- a/packages/trueforge-ui/src/hooks/useSessionShareSearch.ts +++ b/packages/trueforge-ui/src/hooks/useSessionShareSearch.ts @@ -2,7 +2,6 @@ import { useCallback, useSyncExternalStore } from 'react'; -import { useOptionalShellLocationStore } from '../routing/ShellLocationContext.js'; import { readSessionShareSearch, replaceSessionShareSearch, @@ -28,42 +27,17 @@ function getServerShareSearchSnapshot(): string { return ''; } -function subscribeNoop(): () => void { - return () => {}; -} - -function getEmptySearchSnapshot(): string { - return ''; -} - /** - * Session share query. Uses the sessionStorage-backed location when `withRouter` - * is off; otherwise reads/writes `window.location` so it works with or without the router. + * Session share query on `window.location`. + * Does not use react-router, so it works with `withRouter` on or off. */ export function useSessionShareSearch(): SessionShareSearch & { updateShareSearch: (next: SessionShareWrite) => void; } { - const locationStore = useOptionalShellLocationStore(); - - const windowSearch = useSyncExternalStore(subscribeShareSearch, getShareSearchSnapshot, getServerShareSearchSnapshot); - const storeSearch = useSyncExternalStore( - locationStore?.subscribe ?? subscribeNoop, - locationStore != null ? () => locationStore.getLocation().search : getEmptySearchSnapshot, - getEmptySearchSnapshot, - ); - - const search = locationStore != null ? storeSearch : windowSearch; - - const updateShareSearch = useCallback( - (next: SessionShareWrite) => { - if (locationStore != null) { - locationStore.updateSearch(next); - return; - } - replaceSessionShareSearch(next); - }, - [locationStore], - ); + const search = useSyncExternalStore(subscribeShareSearch, getShareSearchSnapshot, getServerShareSearchSnapshot); + const updateShareSearch = useCallback((next: SessionShareWrite) => { + replaceSessionShareSearch(next); + }, []); return { ...readSessionShareSearch(search), updateShareSearch }; } diff --git a/packages/trueforge-ui/src/routing/LibrarySessionShareBoot.tsx b/packages/trueforge-ui/src/routing/LibrarySessionShareBoot.tsx index be5388038..0f2e9ce54 100644 --- a/packages/trueforge-ui/src/routing/LibrarySessionShareBoot.tsx +++ b/packages/trueforge-ui/src/routing/LibrarySessionShareBoot.tsx @@ -6,26 +6,15 @@ import { useOptionalAgentSessionsServer } from '../server/ServerContext.js'; import { useShellMode } from '../server/ShellModeContext.js'; import { isSessionsChromeEnabled } from '../server/serverChrome.js'; import { readSessionShareSearch } from '../utils/sessionShareUrl.js'; -import { useOptionalShellLocationStore } from './ShellLocationContext.js'; -/** - * Open the sessions browser or a library agent from the share query. - * With `withRouter`, the real URL keeps the share params. - * Without it, share params are already consumed into sessionStorage at store - * creation — this only applies shell overlays when the host URL still carries them - * (e.g. tests that set search after mount). - */ +/** Open the sessions browser or a library agent from the share query — with or without `withRouter`. */ export function LibrarySessionShareBoot() { const { openLibraryAgent, setSessionsOpen } = useShellMode(); const sessions = useOptionalAgentSessionsServer(); const sessionsEnabled = isSessionsChromeEnabled({ sessions }); - const locationStore = useOptionalShellLocationStore(); useEffect(() => { if (!sessionsEnabled) return; - // Storage mode: share links are applied when the location store is created - // (pathname + search seeded, window stripped). ShellStorageRouteSync boots from that. - if (locationStore != null) return; const share = readSessionShareSearch(window.location.search); if (share.view === 'sessions') { setSessionsOpen(true); @@ -33,7 +22,7 @@ export function LibrarySessionShareBoot() { } if (share.agentId == null) return; openLibraryAgent(share.agentId); - }, [locationStore, openLibraryAgent, sessionsEnabled, setSessionsOpen]); + }, [openLibraryAgent, sessionsEnabled, setSessionsOpen]); return null; } diff --git a/packages/trueforge-ui/src/routing/ShellLocationContext.tsx b/packages/trueforge-ui/src/routing/ShellLocationContext.tsx deleted file mode 100644 index 210da2773..000000000 --- a/packages/trueforge-ui/src/routing/ShellLocationContext.tsx +++ /dev/null @@ -1,25 +0,0 @@ -'use client'; - -import { createContext, useContext, useMemo, type ReactNode } from 'react'; - -import { createShellLocationStore, type ShellLocationStore } from './shellLocationStore.js'; - -const ShellLocationContext = createContext(null); - -/** Present only when the shell runs without `withRouter` (sessionStorage-backed location). */ -export function ShellLocationProvider({ children }: { children: ReactNode }) { - const store = useMemo(() => createShellLocationStore(), []); - return {children}; -} - -export function useOptionalShellLocationStore(): ShellLocationStore | null { - return useContext(ShellLocationContext); -} - -export function useShellLocationStore(): ShellLocationStore { - const store = useContext(ShellLocationContext); - if (store == null) { - throw new Error('useShellLocationStore requires ShellLocationProvider'); - } - return store; -} diff --git a/packages/trueforge-ui/src/routing/ShellRouteSync.tsx b/packages/trueforge-ui/src/routing/ShellRouteSync.tsx index b7f0984b9..2f64790a7 100644 --- a/packages/trueforge-ui/src/routing/ShellRouteSync.tsx +++ b/packages/trueforge-ui/src/routing/ShellRouteSync.tsx @@ -1,13 +1,36 @@ 'use client'; -import { useCallback } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { ShellRouteSyncCore, type ReadWindowLocation, type ShellRouteNavigate } from './ShellRouteSyncCore.js'; -import type { ResolvedRoutes } from './types.js'; +import { sessionIsCreateAgent } from '../atoms/lib/sessionCreateAgent.js'; +import { findAgentByName } from '../atoms/lib/useSearchAgentsList.js'; +import { + useOptionalAgentSessionsServer, + useOptionalCatalogServer, + useOptionalScheduleServer, + useOptionalServer, + useServerCapabilities, + useServerCapabilitiesSettled, +} from '../server/ServerContext.js'; +import { libraryAgentId, useShellMode } from '../server/ShellModeContext.js'; +import { toEffectiveRoutes } from '../server/serverChrome.js'; +import { + readHistoryAgentSearch, + updateHistoryAgentSearch, + type HistoryAgentSearch, +} from '../utils/historyAgentSearch.js'; +import { deriveChatPlace, derivePlace } from './derivePlace.js'; +import { buildPath, matchLocation, placesEqual, sanitizeSearchForPlace } from './paths.js'; +import type { ResolvedRoutes, RoutePlace, ShellSnapshot } from './types.js'; + +// Filter intent follows chat history across chat/session URLs, but must not leak into unrelated surfaces. +function placeOwnsHistoryAgentSearch(place: RoutePlace): boolean { + return place.type === 'root' || place.type === 'agent' || place.type === 'session'; +} /** - * Single bidirectional bridge between shell state and the browser URL. Mounted under + * Single bidirectional bridge between shell state and the URL. Mounted under * `ShellModeProvider` but outside the keyed chat runtime so boot applies once. */ export function ShellRouteSync({ @@ -19,28 +42,366 @@ export function ShellRouteSync({ activeRemoteId: string | undefined; initialSettingsOpen: boolean; }) { - const routerNavigate = useNavigate(); + const shell = useShellMode(); + const server = useOptionalServer(); + const catalog = useOptionalCatalogServer(); + const sessions = useOptionalAgentSessionsServer(); + const schedules = useOptionalScheduleServer(); + const capabilities = useServerCapabilities(); + const capabilitiesSettled = useServerCapabilitiesSettled(); + const navigate = useNavigate(); const location = useLocation(); + // Same gates as sidebar chrome: missing optional ports unregister their paths. + const effectiveRoutes = useMemo( + () => toEffectiveRoutes({ routes, catalog, capabilities, sessions, schedules }), + [routes, catalog, capabilities, sessions, schedules], + ); + const settingsChromeEnabled = effectiveRoutes.settings != null; + // Gate identity only — avoid re-syncing when capabilities object identity churns + // without changing which paths are registered (would clobber window share query). + const routeGatesKey = [ + effectiveRoutes.settings, + effectiveRoutes.sessionsBrowser, + effectiveRoutes.libraryAgent, + effectiveRoutes.schedules, + ].join('\0'); + + const snapshot: ShellSnapshot = { + settingsOpen: shell.settingsOpen, + libraryOpen: shell.libraryOpen, + sessionsOpen: shell.sessionsOpen, + libraryAgentId: shell.libraryAgentId, + schedulesOpen: shell.schedulesOpen, + pendingSessionId: shell.pendingSessionId, + activeRemoteId, + mode: shell.mode, + agentConfigMode: shell.agentConfigMode, + }; + + const place = derivePlace(snapshot); + const placeKey = JSON.stringify(place); + + // Guards. `selfNavPathRef` marks a path we navigated to ourselves so the + // URL->shell effect does not re-apply it. `prevPlaceRef` powers push/replace. + const selfNavPathRef = useRef(null); + const prevPlaceRef = useRef(null); + const appliedUrlPlaceRef = useRef(null); + const bootedRef = useRef(false); + const bootPlaceRef = useRef(null); + const bootHistoryAgentRef = useRef(null); + // Latest session id the URL asked for, so slower lookups cannot bind over it. + const requestedSessionRef = useRef(null); + const requestedHistoryAgentRef = useRef(null); + // Boot owns the first URL; the ongoing effects skip their initial commit so + // they do not fight boot with the stale first-render place. + const shellSyncStartedRef = useRef(false); + const urlSyncStartedRef = useRef(false); + + /** + * A URL carries only the id, so ask the server whether it names a mutable + * draft or an agent chat; guessing "mutable" opens an agent session as a + * blank draft. `requestedSessionRef` drops replies a later place superseded. + */ + const openSession = useCallback( + (sessionId: string) => { + requestedSessionRef.current = sessionId; + if (server == null) { + shell.openHistorySession({ sessionId }); + return; + } + void server + .getSession({ sessionId }) + .then(session => { + if (requestedSessionRef.current !== sessionId) return; + shell.openHistorySession({ + sessionId, + isMutable: session.isMutable, + isCreateAgent: sessionIsCreateAgent(session), + ...(session.agentName != null ? { agentName: session.agentName } : {}), + }); + }) + .catch(() => { + if (requestedSessionRef.current !== sessionId) return; + // Unreachable session: bind by id alone rather than stranding the shell. + shell.openHistorySession({ sessionId }); + }); + }, + [server, shell], + ); + + const applyHistoryAgentSearch = useCallback( + (next: HistoryAgentSearch | null) => { + if (next == null) { + requestedHistoryAgentRef.current = null; + bootHistoryAgentRef.current = null; + shell.setHistoryAgentFilter(null); + return; + } + + const requestKey = `${next.intent}\0${next.agentName}`; + const current = shell.historyAgentFilter; + if (current?.intent === next.intent && current.agentName === next.agentName && current.agentId != null) { + requestedHistoryAgentRef.current = null; + return; + } - const navigate = useCallback( - (to, options) => { - routerNavigate(to, options); + requestedHistoryAgentRef.current = requestKey; + shell.setHistoryAgentFilter(next); + if (server == null) return; + + void findAgentByName({ server, agentName: next.agentName }) + .then(agent => { + if (requestedHistoryAgentRef.current !== requestKey) return; + if (agent == null) { + requestedHistoryAgentRef.current = null; + bootHistoryAgentRef.current = null; + shell.setHistoryAgentFilter(null); + return; + } + requestedHistoryAgentRef.current = null; + shell.setHistoryAgentFilter({ + agentId: libraryAgentId(agent), + agentName: agent.name, + intent: next.intent, + }); + }) + .catch(() => { + if (requestedHistoryAgentRef.current !== requestKey) return; + requestedHistoryAgentRef.current = null; + bootHistoryAgentRef.current = null; + shell.setHistoryAgentFilter(null); + }); }, - [routerNavigate], + [server, shell], ); - const readWindowLocation = useCallback(() => { - return { pathname: window.location.pathname, search: window.location.search }; - }, []); - - return ( - + const openAgent = useCallback( + (agentName: string) => { + shell.selectLibraryAgent({ isMutable: false, agentName }); + applyHistoryAgentSearch({ intent: 'try-agent', agentName }); + }, + [applyHistoryAgentSearch, shell], ); + + const applyPlace = useCallback( + (target: RoutePlace) => { + switch (target.type) { + case 'settings': + shell.setSettingsOpen(true); + return; + case 'library': + shell.setLibraryOpen(true); + return; + case 'sessionsBrowser': + shell.setSessionsOpen(true); + return; + case 'libraryAgent': + shell.openLibraryAgent(target.agentId); + return; + case 'schedules': + shell.setSchedulesOpen(true); + return; + case 'buildAgent': + shell.openAgentBuilder(); + return; + case 'session': + shell.setLibraryOpen(false); + if (shell.pendingSessionId === target.sessionId || activeRemoteId === target.sessionId) return; + openSession(target.sessionId); + return; + case 'agent': + shell.setLibraryOpen(false); + openAgent(target.agentName); + return; + case 'root': + shell.setSettingsOpen(false); + shell.setLibraryOpen(false); + shell.setSchedulesOpen(false); + switch (shell.agentConfigMode) { + case 'AgentLibrary': + shell.openLibraryHome(); + return; + case 'AgentComposer': + case 'AgentLibraryWithComposer': + shell.openDraft(); + return; + case 'SingleAgent': + shell.clearChat(); + return; + } + } + }, + [shell, activeRemoteId, openAgent, openSession], + ); + + // Boot: URL wins, except an explicit `initialSettingsOpen` overlay. Boot is the + // sole authority for the first commit and sets the final URL + `prevPlaceRef`. + useEffect(() => { + if (bootedRef.current) return; + const configuredUrlPlace = matchLocation({ + pathname: location.pathname, + search: location.search, + routes, + }); + if (!capabilitiesSettled && configuredUrlPlace?.type === 'settings') return; + bootedRef.current = true; + + const urlPlace = matchLocation({ + pathname: location.pathname, + search: location.search, + routes: effectiveRoutes, + }) ?? { type: 'root' }; + const historyAgentSearch: HistoryAgentSearch | null = + urlPlace.type === 'agent' + ? { intent: 'try-agent', agentName: urlPlace.agentName } + : placeOwnsHistoryAgentSearch(urlPlace) + ? readHistoryAgentSearch(location.search) + : null; + bootHistoryAgentRef.current = historyAgentSearch; + appliedUrlPlaceRef.current = urlPlace; + const settingsOnBoot = settingsChromeEnabled && (initialSettingsOpen || urlPlace.type === 'settings'); + + if (urlPlace.type === 'settings') { + if (settingsChromeEnabled) shell.setSettingsOpen(true); + } else if (urlPlace.type === 'library') { + shell.setLibraryOpen(true); + } else if (urlPlace.type === 'sessionsBrowser') { + shell.setSessionsOpen(true); + } else if (urlPlace.type === 'libraryAgent') { + shell.openLibraryAgent(urlPlace.agentId); + } else if (urlPlace.type === 'schedules') { + shell.setSchedulesOpen(true); + } else { + const chatPlace = deriveChatPlace(snapshot); + if (!placesEqual(chatPlace, urlPlace)) applyPlace(urlPlace); + if (settingsOnBoot) shell.setSettingsOpen(true); + } + if (urlPlace.type !== 'agent' && historyAgentSearch != null) { + applyHistoryAgentSearch(historyAgentSearch); + } else if (!placeOwnsHistoryAgentSearch(urlPlace)) { + applyHistoryAgentSearch(null); + } + + const desiredPlace: RoutePlace = settingsOnBoot ? { type: 'settings' } : urlPlace; + bootPlaceRef.current = placesEqual(place, desiredPlace) ? null : desiredPlace; + const desiredPath = buildPath(desiredPlace, effectiveRoutes); + const desiredSearch = updateHistoryAgentSearch( + sanitizeSearchForPlace(desiredPlace, location.search), + historyAgentSearch, + ); + prevPlaceRef.current = desiredPlace; + if (desiredPath != null && (desiredPath !== location.pathname || desiredSearch !== location.search)) { + selfNavPathRef.current = desiredPath !== location.pathname ? desiredPath : null; + navigate({ pathname: desiredPath, search: desiredSearch, hash: location.hash }, { replace: true }); + } + // Boot runs once after any capability-dependent Settings destination resolves. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [capabilitiesSettled]); + + // Shell -> URL: mirror the derived place. Skip the first commit (boot owns it). + useEffect(() => { + if (!bootedRef.current) return; + if (!shellSyncStartedRef.current) { + shellSyncStartedRef.current = true; + return; + } + const bootPlace = bootPlaceRef.current; + if (bootPlace != null) { + if (!placesEqual(place, bootPlace)) return; + bootPlaceRef.current = null; + } + const target = buildPath(place, effectiveRoutes); + if (target == null) return; // place has no configured URL (e.g. settings disabled) + const basename = effectiveRoutes.basename.endsWith('/') + ? effectiveRoutes.basename.slice(0, -1) + : effectiveRoutes.basename; + const browserPathname = `${basename}${location.pathname}` || '/'; + const latestSearch = window.location.pathname === browserPathname ? window.location.search : location.search; + const ownsHistoryAgentSearch = placeOwnsHistoryAgentSearch(place); + const historyAgentSearch = ownsHistoryAgentSearch + ? shell.historyAgentFilter == null + ? bootHistoryAgentRef.current + : { + intent: shell.historyAgentFilter.intent, + agentName: shell.historyAgentFilter.agentName, + } + : null; + if (!ownsHistoryAgentSearch && shell.historyAgentFilter != null) { + requestedHistoryAgentRef.current = null; + shell.setHistoryAgentFilter(null); + } + if (shell.historyAgentFilter != null) bootHistoryAgentRef.current = null; + const targetSearch = updateHistoryAgentSearch(sanitizeSearchForPlace(place, latestSearch), historyAgentSearch); + + const prev = prevPlaceRef.current; + prevPlaceRef.current = place; + + if (target === location.pathname && targetSearch === location.search) return; + + // Replace when a fresh chat just acquired its session id (same place, new id). + const replace = + place.type === 'session' && shell.pendingSessionId == null && prev != null && prev.type !== 'session'; + + selfNavPathRef.current = target !== location.pathname ? target : null; + // Query keys owned by other shell places are removed; host keys and hash survive. + navigate({ pathname: target, search: targetSearch, hash: location.hash }, { replace }); + // location.pathname intentionally excluded: only react to shell-derived place changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [placeKey, routeGatesKey, shell.historyAgentFilter?.agentName, shell.historyAgentFilter?.intent]); + + // URL -> shell: apply on genuine location changes (Back/Forward, manual edits). + useEffect(() => { + if (!bootedRef.current) return; + if (!urlSyncStartedRef.current) { + urlSyncStartedRef.current = true; + return; + } + if (selfNavPathRef.current === location.pathname) { + selfNavPathRef.current = null; + appliedUrlPlaceRef.current = place; + return; + } + const urlPlace = matchLocation({ + pathname: location.pathname, + search: location.search, + routes: effectiveRoutes, + }); + if (urlPlace == null) { + // Unknown path: normalize to root. + const rootPath = effectiveRoutes.root; + const rootSearch = sanitizeSearchForPlace({ type: 'root' }, location.search); + selfNavPathRef.current = rootPath; + navigate({ pathname: rootPath, search: rootSearch, hash: location.hash }, { replace: true }); + applyPlace({ type: 'root' }); + applyHistoryAgentSearch(readHistoryAgentSearch(rootSearch)); + return; + } + const previousUrlPlace = appliedUrlPlaceRef.current; + appliedUrlPlaceRef.current = urlPlace; + if (previousUrlPlace == null || !placesEqual(previousUrlPlace, urlPlace)) { + if (urlPlace.type !== 'settings' && shell.settingsOpen) { + // Leaving settings via Back to a chat place. + shell.setSettingsOpen(false); + } + if (urlPlace.type !== 'library' && urlPlace.type !== 'libraryAgent' && shell.libraryOpen) { + shell.setLibraryOpen(false); + } + if (urlPlace.type !== 'sessionsBrowser' && shell.sessionsOpen) { + shell.setSessionsOpen(false); + } + if (urlPlace.type !== 'schedules' && shell.schedulesOpen) { + // Leaving schedules via Back to a chat place. + shell.setSchedulesOpen(false); + } + applyPlace(urlPlace); + } + if (urlPlace.type !== 'agent' && placeOwnsHistoryAgentSearch(urlPlace)) { + applyHistoryAgentSearch(readHistoryAgentSearch(location.search)); + } else if (!placeOwnsHistoryAgentSearch(urlPlace)) { + applyHistoryAgentSearch(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location.pathname, location.search]); + + return null; } diff --git a/packages/trueforge-ui/src/routing/ShellRouteSyncCore.tsx b/packages/trueforge-ui/src/routing/ShellRouteSyncCore.tsx deleted file mode 100644 index c73c16b4a..000000000 --- a/packages/trueforge-ui/src/routing/ShellRouteSyncCore.tsx +++ /dev/null @@ -1,426 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useMemo, useRef } from 'react'; - -import { sessionIsCreateAgent } from '../atoms/lib/sessionCreateAgent.js'; -import { findAgentByName } from '../atoms/lib/useSearchAgentsList.js'; -import { - useOptionalAgentSessionsServer, - useOptionalCatalogServer, - useOptionalScheduleServer, - useOptionalServer, - useServerCapabilities, - useServerCapabilitiesSettled, -} from '../server/ServerContext.js'; -import { libraryAgentId, useShellMode } from '../server/ShellModeContext.js'; -import { toEffectiveRoutes } from '../server/serverChrome.js'; -import { - readHistoryAgentSearch, - updateHistoryAgentSearch, - type HistoryAgentSearch, -} from '../utils/historyAgentSearch.js'; -import { deriveChatPlace, derivePlace } from './derivePlace.js'; -import { buildPath, matchLocation, placesEqual, sanitizeSearchForPlace } from './paths.js'; -import type { ResolvedRoutes, RoutePlace, ShellSnapshot } from './types.js'; - -export type ShellRouteLocation = { - pathname: string; - search: string; - hash: string; -}; - -export type ShellRouteNavigate = ( - to: { pathname: string; search: string; hash: string }, - options?: { replace?: boolean }, -) => void; - -/** Browser location for picking up direct `window` share-param writes (router mode only). */ -export type ReadWindowLocation = () => { pathname: string; search: string } | null; - -// Filter intent follows chat history across chat/session URLs, but must not leak into unrelated surfaces. -function placeOwnsHistoryAgentSearch(place: RoutePlace): boolean { - return place.type === 'root' || place.type === 'agent' || place.type === 'session'; -} - -/** - * Router-free bidirectional bridge between shell state and a location. - * Browser and sessionStorage wrappers supply `location` / `navigate`. - */ -export function ShellRouteSyncCore({ - routes, - activeRemoteId, - initialSettingsOpen, - location, - navigate, - readWindowLocation, -}: { - routes: ResolvedRoutes; - activeRemoteId: string | undefined; - initialSettingsOpen: boolean; - location: ShellRouteLocation; - navigate: ShellRouteNavigate; - readWindowLocation?: ReadWindowLocation; -}) { - const shell = useShellMode(); - const server = useOptionalServer(); - const catalog = useOptionalCatalogServer(); - const sessions = useOptionalAgentSessionsServer(); - const schedules = useOptionalScheduleServer(); - const capabilities = useServerCapabilities(); - const capabilitiesSettled = useServerCapabilitiesSettled(); - // Same gates as sidebar chrome: missing optional ports unregister their paths. - const effectiveRoutes = useMemo( - () => toEffectiveRoutes({ routes, catalog, capabilities, sessions, schedules }), - [routes, catalog, capabilities, sessions, schedules], - ); - const settingsChromeEnabled = effectiveRoutes.settings != null; - // Gate identity only — avoid re-syncing when capabilities object identity churns - // without changing which paths are registered (would clobber window share query). - const routeGatesKey = [ - effectiveRoutes.settings, - effectiveRoutes.sessionsBrowser, - effectiveRoutes.libraryAgent, - effectiveRoutes.schedules, - ].join('\0'); - - const snapshot: ShellSnapshot = { - settingsOpen: shell.settingsOpen, - libraryOpen: shell.libraryOpen, - sessionsOpen: shell.sessionsOpen, - libraryAgentId: shell.libraryAgentId, - schedulesOpen: shell.schedulesOpen, - pendingSessionId: shell.pendingSessionId, - activeRemoteId, - mode: shell.mode, - agentConfigMode: shell.agentConfigMode, - }; - - const place = derivePlace(snapshot); - const placeKey = JSON.stringify(place); - - // Guards. `selfNavPathRef` marks a path we navigated to ourselves so the - // location->shell effect does not re-apply it. `prevPlaceRef` powers push/replace. - const selfNavPathRef = useRef(null); - const prevPlaceRef = useRef(null); - const appliedUrlPlaceRef = useRef(null); - const bootedRef = useRef(false); - const bootPlaceRef = useRef(null); - const bootHistoryAgentRef = useRef(null); - // Latest session id the location asked for, so slower lookups cannot bind over it. - const requestedSessionRef = useRef(null); - const requestedHistoryAgentRef = useRef(null); - // Boot owns the first location; the ongoing effects skip their initial commit so - // they do not fight boot with the stale first-render place. - const shellSyncStartedRef = useRef(false); - const urlSyncStartedRef = useRef(false); - - /** - * A location carries only the id, so ask the server whether it names a mutable - * draft or an agent chat; guessing "mutable" opens an agent session as a - * blank draft. `requestedSessionRef` drops replies a later place superseded. - */ - const openSession = useCallback( - (sessionId: string) => { - requestedSessionRef.current = sessionId; - if (server == null) { - shell.openHistorySession({ sessionId }); - return; - } - void server - .getSession({ sessionId }) - .then(session => { - if (requestedSessionRef.current !== sessionId) return; - shell.openHistorySession({ - sessionId, - isMutable: session.isMutable, - isCreateAgent: sessionIsCreateAgent(session), - ...(session.agentName != null ? { agentName: session.agentName } : {}), - }); - }) - .catch(() => { - if (requestedSessionRef.current !== sessionId) return; - // Unreachable session: bind by id alone rather than stranding the shell. - shell.openHistorySession({ sessionId }); - }); - }, - [server, shell], - ); - - const applyHistoryAgentSearch = useCallback( - (next: HistoryAgentSearch | null) => { - if (next == null) { - requestedHistoryAgentRef.current = null; - bootHistoryAgentRef.current = null; - shell.setHistoryAgentFilter(null); - return; - } - - const requestKey = `${next.intent}\0${next.agentName}`; - const current = shell.historyAgentFilter; - if (current?.intent === next.intent && current.agentName === next.agentName && current.agentId != null) { - requestedHistoryAgentRef.current = null; - return; - } - - requestedHistoryAgentRef.current = requestKey; - shell.setHistoryAgentFilter(next); - if (server == null) return; - - void findAgentByName({ server, agentName: next.agentName }) - .then(agent => { - if (requestedHistoryAgentRef.current !== requestKey) return; - if (agent == null) { - requestedHistoryAgentRef.current = null; - bootHistoryAgentRef.current = null; - shell.setHistoryAgentFilter(null); - return; - } - requestedHistoryAgentRef.current = null; - shell.setHistoryAgentFilter({ - agentId: libraryAgentId(agent), - agentName: agent.name, - intent: next.intent, - }); - }) - .catch(() => { - if (requestedHistoryAgentRef.current !== requestKey) return; - requestedHistoryAgentRef.current = null; - bootHistoryAgentRef.current = null; - shell.setHistoryAgentFilter(null); - }); - }, - [server, shell], - ); - - const openAgent = useCallback( - (agentName: string) => { - shell.selectLibraryAgent({ isMutable: false, agentName }); - applyHistoryAgentSearch({ intent: 'try-agent', agentName }); - }, - [applyHistoryAgentSearch, shell], - ); - - const applyPlace = useCallback( - (target: RoutePlace) => { - switch (target.type) { - case 'settings': - shell.setSettingsOpen(true); - return; - case 'library': - shell.setLibraryOpen(true); - return; - case 'sessionsBrowser': - shell.setSessionsOpen(true); - return; - case 'libraryAgent': - shell.openLibraryAgent(target.agentId); - return; - case 'schedules': - shell.setSchedulesOpen(true); - return; - case 'buildAgent': - shell.openAgentBuilder(); - return; - case 'session': - shell.setLibraryOpen(false); - if (shell.pendingSessionId === target.sessionId || activeRemoteId === target.sessionId) return; - openSession(target.sessionId); - return; - case 'agent': - shell.setLibraryOpen(false); - openAgent(target.agentName); - return; - case 'root': - shell.setSettingsOpen(false); - shell.setLibraryOpen(false); - shell.setSchedulesOpen(false); - switch (shell.agentConfigMode) { - case 'AgentLibrary': - shell.openLibraryHome(); - return; - case 'AgentComposer': - case 'AgentLibraryWithComposer': - shell.openDraft(); - return; - case 'SingleAgent': - shell.clearChat(); - return; - } - } - }, - [shell, activeRemoteId, openAgent, openSession], - ); - - // Boot: location wins, except an explicit `initialSettingsOpen` overlay. Boot is the - // sole authority for the first commit and sets the final location + `prevPlaceRef`. - useEffect(() => { - if (bootedRef.current) return; - const configuredUrlPlace = matchLocation({ - pathname: location.pathname, - search: location.search, - routes, - }); - if (!capabilitiesSettled && configuredUrlPlace?.type === 'settings') return; - bootedRef.current = true; - - const urlPlace = matchLocation({ - pathname: location.pathname, - search: location.search, - routes: effectiveRoutes, - }) ?? { type: 'root' }; - const historyAgentSearch: HistoryAgentSearch | null = - urlPlace.type === 'agent' - ? { intent: 'try-agent', agentName: urlPlace.agentName } - : placeOwnsHistoryAgentSearch(urlPlace) - ? readHistoryAgentSearch(location.search) - : null; - bootHistoryAgentRef.current = historyAgentSearch; - appliedUrlPlaceRef.current = urlPlace; - const settingsOnBoot = settingsChromeEnabled && (initialSettingsOpen || urlPlace.type === 'settings'); - - if (urlPlace.type === 'settings') { - if (settingsChromeEnabled) shell.setSettingsOpen(true); - } else if (urlPlace.type === 'library') { - shell.setLibraryOpen(true); - } else if (urlPlace.type === 'sessionsBrowser') { - shell.setSessionsOpen(true); - } else if (urlPlace.type === 'libraryAgent') { - shell.openLibraryAgent(urlPlace.agentId); - } else if (urlPlace.type === 'schedules') { - shell.setSchedulesOpen(true); - } else { - const chatPlace = deriveChatPlace(snapshot); - if (!placesEqual(chatPlace, urlPlace)) applyPlace(urlPlace); - if (settingsOnBoot) shell.setSettingsOpen(true); - } - if (urlPlace.type !== 'agent' && historyAgentSearch != null) { - applyHistoryAgentSearch(historyAgentSearch); - } else if (!placeOwnsHistoryAgentSearch(urlPlace)) { - applyHistoryAgentSearch(null); - } - - const desiredPlace: RoutePlace = settingsOnBoot ? { type: 'settings' } : urlPlace; - bootPlaceRef.current = placesEqual(place, desiredPlace) ? null : desiredPlace; - const desiredPath = buildPath(desiredPlace, effectiveRoutes); - const desiredSearch = updateHistoryAgentSearch( - sanitizeSearchForPlace(desiredPlace, location.search), - historyAgentSearch, - ); - prevPlaceRef.current = desiredPlace; - if (desiredPath != null && (desiredPath !== location.pathname || desiredSearch !== location.search)) { - selfNavPathRef.current = desiredPath !== location.pathname ? desiredPath : null; - navigate({ pathname: desiredPath, search: desiredSearch, hash: location.hash }, { replace: true }); - } - // Boot runs once after any capability-dependent Settings destination resolves. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [capabilitiesSettled]); - - // Shell -> location: mirror the derived place. Skip the first commit (boot owns it). - useEffect(() => { - if (!bootedRef.current) return; - if (!shellSyncStartedRef.current) { - shellSyncStartedRef.current = true; - return; - } - const bootPlace = bootPlaceRef.current; - if (bootPlace != null) { - if (!placesEqual(place, bootPlace)) return; - bootPlaceRef.current = null; - } - const target = buildPath(place, effectiveRoutes); - if (target == null) return; // place has no configured path (e.g. settings disabled) - const basename = effectiveRoutes.basename.endsWith('/') - ? effectiveRoutes.basename.slice(0, -1) - : effectiveRoutes.basename; - const browserPathname = `${basename}${location.pathname}` || '/'; - const windowLoc = readWindowLocation?.() ?? null; - const latestSearch = - windowLoc != null && windowLoc.pathname === browserPathname ? windowLoc.search : location.search; - const ownsHistoryAgentSearch = placeOwnsHistoryAgentSearch(place); - const historyAgentSearch = ownsHistoryAgentSearch - ? shell.historyAgentFilter == null - ? bootHistoryAgentRef.current - : { - intent: shell.historyAgentFilter.intent, - agentName: shell.historyAgentFilter.agentName, - } - : null; - if (!ownsHistoryAgentSearch && shell.historyAgentFilter != null) { - requestedHistoryAgentRef.current = null; - shell.setHistoryAgentFilter(null); - } - if (shell.historyAgentFilter != null) bootHistoryAgentRef.current = null; - const targetSearch = updateHistoryAgentSearch(sanitizeSearchForPlace(place, latestSearch), historyAgentSearch); - - const prev = prevPlaceRef.current; - prevPlaceRef.current = place; - - if (target === location.pathname && targetSearch === location.search) return; - - // Replace when a fresh chat just acquired its session id (same place, new id). - const replace = - place.type === 'session' && shell.pendingSessionId == null && prev != null && prev.type !== 'session'; - - selfNavPathRef.current = target !== location.pathname ? target : null; - // Query keys owned by other shell places are removed; host keys and hash survive. - navigate({ pathname: target, search: targetSearch, hash: location.hash }, { replace }); - // location.pathname intentionally excluded: only react to shell-derived place changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [placeKey, routeGatesKey, shell.historyAgentFilter?.agentName, shell.historyAgentFilter?.intent]); - - // Location -> shell: apply on genuine location changes (Back/Forward, store updates). - useEffect(() => { - if (!bootedRef.current) return; - if (!urlSyncStartedRef.current) { - urlSyncStartedRef.current = true; - return; - } - if (selfNavPathRef.current === location.pathname) { - selfNavPathRef.current = null; - appliedUrlPlaceRef.current = place; - return; - } - const urlPlace = matchLocation({ - pathname: location.pathname, - search: location.search, - routes: effectiveRoutes, - }); - if (urlPlace == null) { - // Unknown path: normalize to root. - const rootPath = effectiveRoutes.root; - const rootSearch = sanitizeSearchForPlace({ type: 'root' }, location.search); - selfNavPathRef.current = rootPath; - navigate({ pathname: rootPath, search: rootSearch, hash: location.hash }, { replace: true }); - applyPlace({ type: 'root' }); - applyHistoryAgentSearch(readHistoryAgentSearch(rootSearch)); - return; - } - const previousUrlPlace = appliedUrlPlaceRef.current; - appliedUrlPlaceRef.current = urlPlace; - if (previousUrlPlace == null || !placesEqual(previousUrlPlace, urlPlace)) { - if (urlPlace.type !== 'settings' && shell.settingsOpen) { - // Leaving settings via Back to a chat place. - shell.setSettingsOpen(false); - } - if (urlPlace.type !== 'library' && urlPlace.type !== 'libraryAgent' && shell.libraryOpen) { - shell.setLibraryOpen(false); - } - if (urlPlace.type !== 'sessionsBrowser' && shell.sessionsOpen) { - shell.setSessionsOpen(false); - } - if (urlPlace.type !== 'schedules' && shell.schedulesOpen) { - // Leaving schedules via Back to a chat place. - shell.setSchedulesOpen(false); - } - applyPlace(urlPlace); - } - if (urlPlace.type !== 'agent' && placeOwnsHistoryAgentSearch(urlPlace)) { - applyHistoryAgentSearch(readHistoryAgentSearch(location.search)); - } else if (!placeOwnsHistoryAgentSearch(urlPlace)) { - applyHistoryAgentSearch(null); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [location.pathname, location.search]); - - return null; -} diff --git a/packages/trueforge-ui/src/routing/ShellStorageRouteSync.tsx b/packages/trueforge-ui/src/routing/ShellStorageRouteSync.tsx deleted file mode 100644 index 3448ca86f..000000000 --- a/packages/trueforge-ui/src/routing/ShellStorageRouteSync.tsx +++ /dev/null @@ -1,40 +0,0 @@ -'use client'; - -import { useCallback, useMemo, useSyncExternalStore } from 'react'; - -import { resolveRoutesConfig } from './paths.js'; -import { useShellLocationStore } from './ShellLocationContext.js'; -import { ShellRouteSyncCore, type ShellRouteNavigate } from './ShellRouteSyncCore.js'; - -/** - * Bidirectional bridge between shell state and a sessionStorage-backed location. - * Mounted when `withRouter` is off so navigation survives reloads without touching the host URL. - */ -export function ShellStorageRouteSync({ - activeRemoteId, - initialSettingsOpen, -}: { - activeRemoteId: string | undefined; - initialSettingsOpen: boolean; -}) { - const store = useShellLocationStore(); - const routes = useMemo(() => resolveRoutesConfig(undefined), []); - const location = useSyncExternalStore(store.subscribe, store.getLocation, store.getLocation); - - const navigate = useCallback( - (to, options) => { - store.navigate(to, options); - }, - [store], - ); - - return ( - - ); -} diff --git a/packages/trueforge-ui/src/routing/shellLocationStore.ts b/packages/trueforge-ui/src/routing/shellLocationStore.ts deleted file mode 100644 index b61a4a19e..000000000 --- a/packages/trueforge-ui/src/routing/shellLocationStore.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { - readSessionShareSearch, - writeSessionShareSearch, - type SessionShareSearch, - type SessionShareWrite, -} from '../utils/sessionShareUrl.js'; -import { resolveRoutesConfig } from './paths.js'; -import type { ShellRouteLocation } from './ShellRouteSyncCore.js'; - -export const SHELL_LOCATION_STORAGE_KEY = 'tfy-aui-shell-location'; - -type StoredShellLocation = { - version: 1; - pathname: string; - search: string; -}; - -export type ShellLocationStore = { - getLocation: () => ShellRouteLocation; - navigate: (to: { pathname: string; search: string; hash?: string }, options?: { replace?: boolean }) => void; - subscribe: (listener: () => void) => () => void; - updateSearch: (next: SessionShareWrite) => string; -}; - -const DEFAULT_LOCATION: ShellRouteLocation = { pathname: '/', search: '', hash: '' }; - -function normalizeSearch(search: string): string { - if (search.length === 0) return ''; - return search.startsWith('?') ? search : `?${search}`; -} - -function hasShareParams(share: SessionShareSearch): boolean { - return ( - share.view != null || - share.agentId != null || - share.sessionId != null || - share.tab != null || - share.timeRange != null - ); -} - -function readStoredLocation(): ShellRouteLocation | null { - try { - const raw = sessionStorage.getItem(SHELL_LOCATION_STORAGE_KEY); - if (raw == null || raw.length === 0) return null; - const parsed: unknown = JSON.parse(raw); - if ( - parsed == null || - typeof parsed !== 'object' || - !('version' in parsed) || - (parsed as { version: unknown }).version !== 1 || - !('pathname' in parsed) || - typeof (parsed as { pathname: unknown }).pathname !== 'string' || - !('search' in parsed) || - typeof (parsed as { search: unknown }).search !== 'string' - ) { - return null; - } - const stored = parsed as StoredShellLocation; - return { - pathname: stored.pathname.length > 0 ? stored.pathname : '/', - search: normalizeSearch(stored.search), - hash: '', - }; - } catch { - return null; - } -} - -function persistLocation(location: ShellRouteLocation): void { - try { - const payload: StoredShellLocation = { - version: 1, - pathname: location.pathname, - search: location.search, - }; - sessionStorage.setItem(SHELL_LOCATION_STORAGE_KEY, JSON.stringify(payload)); - } catch { - // sessionStorage may throw in sandboxed iframes / private mode — stay in-memory. - } -} - -/** Strip share query keys from the real window URL (consumption only; not state storage). */ -export function stripSessionShareSearchFromWindow(): void { - if (typeof window === 'undefined') return; - const url = new URL(window.location.href); - writeSessionShareSearch(url.searchParams, { - sessionId: null, - agentId: null, - tab: null, - view: null, - timeRange: null, - }); - const next = `${url.pathname}${url.search}${url.hash}`; - const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; - if (next === current) return; - window.history.replaceState(window.history.state, '', url); -} - -/** - * Apply a pasted share link over a stored location: pathname + share search win, - * then the real window URL is stripped so a later reload restores from storage. - */ -function applyWindowShareOverLocation(location: ShellRouteLocation): ShellRouteLocation { - if (typeof window === 'undefined') return location; - const share = readSessionShareSearch(window.location.search); - if (!hasShareParams(share)) return location; - - const defaults = resolveRoutesConfig(undefined); - let pathname = location.pathname; - if (share.view === 'sessions' && defaults.sessionsBrowser != null) { - pathname = defaults.sessionsBrowser; - } else if (share.agentId != null && defaults.libraryAgent != null) { - pathname = defaults.libraryAgent.replace(':agentId', encodeURIComponent(share.agentId)); - } - - const params = new URLSearchParams(location.search.startsWith('?') ? location.search.slice(1) : location.search); - writeSessionShareSearch(params, { - sessionId: share.sessionId, - agentId: share.agentId, - tab: share.tab, - view: share.view, - timeRange: share.timeRange, - }); - const search = params.toString(); - stripSessionShareSearchFromWindow(); - return { - pathname, - search: search.length > 0 ? `?${search}` : '', - hash: '', - }; -} - -export function createShellLocationStore(): ShellLocationStore { - let location: ShellRouteLocation = applyWindowShareOverLocation(readStoredLocation() ?? { ...DEFAULT_LOCATION }); - persistLocation(location); - - const listeners = new Set<() => void>(); - - const notify = () => { - for (const listener of listeners) listener(); - }; - - const setLocation = (next: ShellRouteLocation) => { - const normalized: ShellRouteLocation = { - pathname: next.pathname.length > 0 ? next.pathname : '/', - search: normalizeSearch(next.search), - hash: next.hash ?? '', - }; - if ( - normalized.pathname === location.pathname && - normalized.search === location.search && - normalized.hash === location.hash - ) { - return; - } - location = normalized; - persistLocation(location); - notify(); - }; - - return { - getLocation: () => location, - navigate: (to, _options) => { - setLocation({ - pathname: to.pathname, - search: to.search, - hash: to.hash ?? '', - }); - }, - subscribe: listener => { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - updateSearch: next => { - const params = new URLSearchParams(location.search.startsWith('?') ? location.search.slice(1) : location.search); - writeSessionShareSearch(params, next); - const search = params.toString(); - setLocation({ - pathname: location.pathname, - search: search.length > 0 ? `?${search}` : '', - hash: location.hash, - }); - return location.search; - }, - }; -} - -/** Clear persisted shell location — tests only. */ -export function clearShellLocationStorage(): void { - try { - sessionStorage.removeItem(SHELL_LOCATION_STORAGE_KEY); - } catch { - // ignore - } -} diff --git a/packages/trueforge-ui/src/server/ShellModeContext.tsx b/packages/trueforge-ui/src/server/ShellModeContext.tsx index 806a649f6..ad5c41b2c 100644 --- a/packages/trueforge-ui/src/server/ShellModeContext.tsx +++ b/packages/trueforge-ui/src/server/ShellModeContext.tsx @@ -2,7 +2,6 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; -import { useOptionalShellLocationStore } from '../routing/ShellLocationContext.js'; import type { HistoryAgentIntent } from '../utils/historyAgentSearch.js'; import { replaceSessionShareSearch } from '../utils/sessionShareUrl.js'; import { @@ -223,7 +222,6 @@ export function ShellModeProvider({ const refreshCapabilities = useOptionalRefreshServerCapabilities(); const sessionsServer = useOptionalAgentSessionsServer(); const scheduleServer = useOptionalScheduleServer(); - const locationStore = useOptionalShellLocationStore(); const chatSeedRef = useRef( readDraftSpecPreferences('chat') ?? selectDraftSpecPreferences(mutableSeedFromConfig(agentConfig), 'chat'), ); @@ -265,14 +263,12 @@ export function ShellModeProvider({ setLibraryOpenState(false); setLibraryAgentId(null); setSchedulesOpenState(false); - } else if (locationStore != null) { - locationStore.updateSearch({ view: null }); } else { replaceSessionShareSearch({ view: null }); } setSessionsOpenState(sessionsEnabled && open); }, - [locationStore, sessionsEnabled], + [sessionsEnabled], ); const setLibraryOpen = useCallback( (open: boolean) => { diff --git a/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx b/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx index 2c7332cbb..d338d07c6 100644 --- a/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx +++ b/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx @@ -2,7 +2,7 @@ import type { CatalogServer } from '@/server/types.js'; import { useExternalStoreRuntime, type ThreadMessageLike } from '@assistant-ui/react'; import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; import { createMockAgentUIServer, createMockCatalog } from '../server/mockServer.js'; vi.mock('@truefoundry/assistant-ui-runtime', () => ({ @@ -71,7 +71,6 @@ import { SidebarLayout } from '@/layouts/SidebarLayout.js'; import { StackChatPanel } from '@/layouts/StackChatPanel.js'; import { WidgetLayout } from '@/layouts/WidgetLayout.js'; import { WidgetVisibilityProvider } from '@/layouts/WidgetVisibilityContext.js'; -import { clearShellLocationStorage } from '@/routing/shellLocationStore.js'; import { ServerProvider } from '@/server/ServerContext.js'; import { ShellModeProvider, useShellMode } from '@/server/ShellModeContext.js'; import { SlotsProvider } from '@/theme/SlotsProvider.js'; @@ -98,16 +97,6 @@ beforeAll(() => { }; }); -beforeEach(() => { - clearShellLocationStorage(); - window.history.replaceState(null, '', '/'); -}); - -afterEach(() => { - clearShellLocationStorage(); - window.history.replaceState(null, '', '/'); -}); - function mobileMatchMedia(query: string): MediaQueryList { return { matches: query === '(max-width: 767px)', diff --git a/packages/trueforge-ui/test/routing/ShellStorageRouteSync.test.tsx b/packages/trueforge-ui/test/routing/ShellStorageRouteSync.test.tsx deleted file mode 100644 index bbc8657f9..000000000 --- a/packages/trueforge-ui/test/routing/ShellStorageRouteSync.test.tsx +++ /dev/null @@ -1,232 +0,0 @@ -// @vitest-environment jsdom -import { act, render, waitFor } from '@testing-library/react'; -import { useEffect, useState, type ReactNode } from 'react'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { ShellLocationProvider, useShellLocationStore } from '@/routing/ShellLocationContext.js'; -import { clearShellLocationStorage, SHELL_LOCATION_STORAGE_KEY } from '@/routing/shellLocationStore.js'; -import { ShellStorageRouteSync } from '@/routing/ShellStorageRouteSync.js'; -import { ServerProvider } from '@/server/ServerContext.js'; -import { ShellModeProvider, useShellMode, type AgentConfig } from '@/server/ShellModeContext.js'; -import { - createMockAgentSessionsServer, - createMockAgentUIServer, - createMockCatalog, - createMockScheduleServer, -} from '../server/mockServer.js'; - -type Shell = ReturnType; - -let shell: Shell; -let pathname = ''; -let search = ''; -let setRemoteId: (id: string | undefined) => void = () => undefined; - -function CaptureShell() { - shell = useShellMode(); - return null; -} - -function CaptureLocation() { - const store = useShellLocationStore(); - const location = store.getLocation(); - pathname = location.pathname; - search = location.search; - useEffect(() => { - return store.subscribe(() => { - const next = store.getLocation(); - pathname = next.pathname; - search = next.search; - }); - }, [store]); - return null; -} - -function SettingsCatalogProvider({ - children, - settingsEnabled = true, - includeCatalog = true, - includeSessions = true, - includeSchedules = false, -}: { - children: ReactNode; - settingsEnabled?: boolean; - includeCatalog?: boolean; - includeSessions?: boolean; - includeSchedules?: boolean; -}) { - const server = createMockAgentUIServer({ - ...(includeCatalog ? { catalog: createMockCatalog() } : {}), - ...(includeSessions ? { sessions: createMockAgentSessionsServer() } : {}), - ...(includeSchedules ? { schedules: createMockScheduleServer() } : {}), - getCapabilities: async () => ({ - data: { - sandbox: { enabled: true }, - skill: { enabled: true }, - settings: { enabled: settingsEnabled }, - }, - }), - getSession: async ({ sessionId }) => ({ - id: sessionId, - title: 'Session', - isMutable: true, - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - }), - searchAgents: async () => [{ name: 'helper', agentId: 'helper-id' }], - }); - return {children}; -} - -function Harness({ - agentConfig, - initialRemoteId, - initialSettingsOpen = false, - settingsEnabled = true, - includeCatalog = true, - includeSessions = true, - includeSchedules = false, -}: { - agentConfig?: AgentConfig; - initialRemoteId?: string; - initialSettingsOpen?: boolean; - settingsEnabled?: boolean; - includeCatalog?: boolean; - includeSessions?: boolean; - includeSchedules?: boolean; -}) { - const [remoteId, setId] = useState(initialRemoteId); - setRemoteId = setId; - return ( - - - - - - - - - - ); -} - -function seedStorage(pathnameValue: string, searchValue = '') { - sessionStorage.setItem( - SHELL_LOCATION_STORAGE_KEY, - JSON.stringify({ version: 1, pathname: pathnameValue, search: searchValue }), - ); -} - -beforeEach(() => { - clearShellLocationStorage(); - window.history.replaceState(null, '', '/'); - pathname = ''; - search = ''; -}); - -afterEach(() => { - clearShellLocationStorage(); - window.history.replaceState(null, '', '/'); -}); - -describe('ShellStorageRouteSync', () => { - it('restores a session place from sessionStorage on boot', async () => { - seedStorage('/sessions/abc'); - render(); - await waitFor(() => expect(shell.pendingSessionId).toBe('abc')); - expect(pathname).toBe('/sessions/abc'); - expect(window.location.pathname).toBe('/'); - }); - - it('restores settings from sessionStorage on boot', async () => { - seedStorage('/settings'); - render(); - await waitFor(() => expect(shell.settingsOpen).toBe(true)); - expect(pathname).toBe('/settings'); - }); - - it('restores a library agent with tab search from sessionStorage', async () => { - seedStorage('/library/agent-1', '?tab=sessions&agentId=agent-1'); - render(); - await waitFor(() => expect(shell.libraryAgentId).toBe('agent-1')); - expect(shell.libraryOpen).toBe(true); - expect(pathname).toBe('/library/agent-1'); - expect(search).toBe('?tab=sessions&agentId=agent-1'); - }); - - it('restores the sessions browser with a time-window search', async () => { - seedStorage('/sessions', '?view=sessions&s_tw=86400000'); - render(); - await waitFor(() => expect(shell.sessionsOpen).toBe(true)); - expect(pathname).toBe('/sessions'); - expect(search).toContain('s_tw=86400000'); - }); - - it('persists shell navigation across remount', async () => { - const { unmount } = render(); - await waitFor(() => expect(pathname).toBe('/')); - act(() => shell.setSettingsOpen(true)); - await waitFor(() => expect(pathname).toBe('/settings')); - unmount(); - - render(); - await waitFor(() => expect(shell.settingsOpen).toBe(true)); - expect(pathname).toBe('/settings'); - }); - - it('mirrors an acquired session id into the stored location', async () => { - render(); - await waitFor(() => expect(pathname).toBe('/')); - act(() => setRemoteId('session-from-chat')); - await waitFor(() => expect(pathname).toBe('/sessions/session-from-chat')); - const stored = JSON.parse(sessionStorage.getItem(SHELL_LOCATION_STORAGE_KEY) ?? '{}') as { - pathname?: string; - }; - expect(stored.pathname).toBe('/sessions/session-from-chat'); - }); - - it('lets a pasted share link win over stored state and strips the window URL', async () => { - seedStorage('/settings'); - window.history.replaceState(null, '', '/?view=sessions&s_tw=3600000'); - render(); - await waitFor(() => expect(shell.sessionsOpen).toBe(true)); - expect(shell.settingsOpen).toBe(false); - expect(pathname).toBe('/sessions'); - expect(window.location.search).toBe(''); - expect(search).toContain('view=sessions'); - }); - - it('lets a pasted agent share link open the library agent over stored root', async () => { - seedStorage('/'); - window.history.replaceState(null, '', '/?agentId=shared-agent&sessionId=sess-9&tab=sessions'); - render(); - await waitFor(() => expect(shell.libraryAgentId).toBe('shared-agent')); - expect(pathname).toBe('/library/shared-agent'); - expect(window.location.search).toBe(''); - expect(search).toContain('agentId=shared-agent'); - expect(search).toContain('sessionId=sess-9'); - }); - - it('falls back to root when sessionStorage is corrupted', async () => { - sessionStorage.setItem(SHELL_LOCATION_STORAGE_KEY, '{not-json'); - render(); - await waitFor(() => expect(pathname).toBe('/')); - expect(shell.pendingSessionId).toBeUndefined(); - }); - - it('clears view from the store when sessions close without touching the window URL', async () => { - seedStorage('/sessions', '?view=sessions'); - window.history.replaceState(null, '', '/host-page?keep=1'); - render(); - await waitFor(() => expect(shell.sessionsOpen).toBe(true)); - act(() => shell.setSessionsOpen(false)); - await waitFor(() => expect(shell.sessionsOpen).toBe(false)); - expect(window.location.search).toBe('?keep=1'); - expect(search).not.toContain('view=sessions'); - }); -}); diff --git a/packages/trueforge-ui/test/server/ShellModeContext.test.tsx b/packages/trueforge-ui/test/server/ShellModeContext.test.tsx index 0c6d8eab6..882f9d284 100644 --- a/packages/trueforge-ui/test/server/ShellModeContext.test.tsx +++ b/packages/trueforge-ui/test/server/ShellModeContext.test.tsx @@ -3,8 +3,6 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import type { ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ShellLocationProvider, useShellLocationStore } from '@/routing/ShellLocationContext.js'; -import { clearShellLocationStorage } from '@/routing/shellLocationStore.js'; import { AGENT_DRAFT_SPEC_PREFERENCES_STORAGE_KEY, CHAT_DRAFT_SPEC_PREFERENCES_STORAGE_KEY, @@ -16,22 +14,6 @@ import { ShellModeProvider, useOptionalShellMode, useShellMode, type AgentConfig import type { AgentUIServer } from '@/server/types.js'; import { createMockAgentSessionsServer, createMockAgentUIServer, createMockCatalog } from './mockServer.js'; -function wrapWithLocationStore(agentConfig?: AgentConfig) { - const server = createMockAgentUIServer({ - catalog: createMockCatalog(), - sessions: createMockAgentSessionsServer(), - }); - return function Wrapper({ children }: { children: ReactNode }) { - return ( - - - {children} - - - ); - }; -} - function wrap(agentConfig?: AgentConfig, initialSettingsOpen?: boolean) { const server = createMockAgentUIServer({ catalog: createMockCatalog(), @@ -87,8 +69,6 @@ describe('ShellModeProvider', () => { window.localStorage.removeItem(DRAFT_SPEC_PREFERENCES_STORAGE_KEY); window.localStorage.removeItem(CHAT_DRAFT_SPEC_PREFERENCES_STORAGE_KEY); window.localStorage.removeItem(AGENT_DRAFT_SPEC_PREFERENCES_STORAGE_KEY); - clearShellLocationStorage(); - window.history.replaceState(null, '', '/'); }); it('requires a provider for useShellMode', () => { @@ -318,30 +298,6 @@ describe('ShellModeProvider', () => { expect(result.current.sessionsOpen).toBe(false); }); - it('clears view via the location store when sessions close under ShellLocationProvider', () => { - window.history.replaceState(null, '', '/host?keep=1'); - const { result } = renderHook( - () => ({ - shell: useShellMode(), - store: useShellLocationStore(), - }), - { wrapper: wrapWithLocationStore() }, - ); - - act(() => { - result.current.store.navigate({ pathname: '/sessions', search: '?view=sessions' }); - result.current.shell.setSessionsOpen(true); - }); - expect(result.current.shell.sessionsOpen).toBe(true); - expect(result.current.store.getLocation().search).toContain('view=sessions'); - - act(() => result.current.shell.setSessionsOpen(false)); - expect(result.current.shell.sessionsOpen).toBe(false); - expect(result.current.store.getLocation().search).not.toContain('view=sessions'); - // Host URL is untouched in storage mode. - expect(window.location.search).toBe('?keep=1'); - }); - it('defaults to AgentLibraryWithComposer (mutable + library)', () => { const { result } = renderHook(() => useShellMode(), { wrapper: wrap() }); expect(result.current.mode).toMatchObject({ status: 'active', isMutable: true, locked: false });