diff --git a/.changeset/shell-storage-routing.md b/.changeset/shell-storage-routing.md
new file mode 100644
index 000000000..5bc752ee8
--- /dev/null
+++ b/.changeset/shell-storage-routing.md
@@ -0,0 +1,5 @@
+---
+'@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 d74ca22ca..a81347a49 100644
--- a/packages/trueforge-ui/README.md
+++ b/packages/trueforge-ui/README.md
@@ -214,16 +214,18 @@ 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. |
-| `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. |
+| `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). |
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 2bfba623f..20140ee2a 100644
--- a/packages/trueforge-ui/docs/customization.md
+++ b/packages/trueforge-ui/docs/customization.md
@@ -93,7 +93,16 @@ router should leave it off (the default).
```
-Places mirrored to the URL:
+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):
- `/` — new chat / library landing (mode-dependent)
- `/build-agent` — new agent builder
@@ -127,25 +136,32 @@ 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 mirrors it. Combining
-`withRouter` with `initialSessionId` is not supported — the URL wins.
+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.
Notes on behaviour:
-- 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.
+- 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.
- 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.
-- 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.
+ 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.
- 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 ca8309399..e1058dc6c 100644
--- a/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx
+++ b/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx
@@ -16,6 +16,7 @@ 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';
@@ -34,6 +35,9 @@ 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';
@@ -62,9 +66,10 @@ export type TrueForgeUIProps = {
*/
customActionRenderers?: CustomActionRenderers;
/**
- * Sync shell navigation to the browser URL via react-router (opt-in).
+ * Sync shell navigation to the browser URL via react-router.
* Requires `react-router-dom` in the host. Leave off for dock/widget embeds
- * and hosts that own their own router. Defaults to `false`.
+ * and hosts that own their own router — navigation then persists in sessionStorage
+ * without mutating the host URL. Defaults to `false`.
*/
withRouter?: boolean;
/** URL path customization; only honored when `withRouter`. */
@@ -326,11 +331,15 @@ export function TrueForgeUIShell(props: TrueForgeUIShellProps) {
initialSettingsOpen={initialSettingsOpen}
/>
- ) : null}
+ ) : (
+
+
+
+ )}
{layoutTree}
@@ -341,15 +350,18 @@ export function TrueForgeUIShell(props: TrueForgeUIShellProps) {
const visibilityTree =
layout === 'widget' ? {shellTree} : shellTree;
+ const withLocation =
+ resolvedRoutes == null ? {visibilityTree} : visibilityTree;
+
return (
{resolvedRoutes != null ? (
- {visibilityTree}
+ {withLocation}
) : (
- visibilityTree
+ withLocation
)}
diff --git a/packages/trueforge-ui/src/hooks/useSessionShareSearch.ts b/packages/trueforge-ui/src/hooks/useSessionShareSearch.ts
index acbcbea51..24eb610d6 100644
--- a/packages/trueforge-ui/src/hooks/useSessionShareSearch.ts
+++ b/packages/trueforge-ui/src/hooks/useSessionShareSearch.ts
@@ -2,6 +2,7 @@
import { useCallback, useSyncExternalStore } from 'react';
+import { useOptionalShellLocationStore } from '../routing/ShellLocationContext.js';
import {
readSessionShareSearch,
replaceSessionShareSearch,
@@ -27,17 +28,42 @@ function getServerShareSearchSnapshot(): string {
return '';
}
+function subscribeNoop(): () => void {
+ return () => {};
+}
+
+function getEmptySearchSnapshot(): string {
+ return '';
+}
+
/**
- * Session share query on `window.location`.
- * Does not use react-router, so it works with `withRouter` on or off.
+ * 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.
*/
export function useSessionShareSearch(): SessionShareSearch & {
updateShareSearch: (next: SessionShareWrite) => void;
} {
- const search = useSyncExternalStore(subscribeShareSearch, getShareSearchSnapshot, getServerShareSearchSnapshot);
- const updateShareSearch = useCallback((next: SessionShareWrite) => {
- replaceSessionShareSearch(next);
- }, []);
+ 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],
+ );
return { ...readSessionShareSearch(search), updateShareSearch };
}
diff --git a/packages/trueforge-ui/src/routing/LibrarySessionShareBoot.tsx b/packages/trueforge-ui/src/routing/LibrarySessionShareBoot.tsx
index 0f2e9ce54..be5388038 100644
--- a/packages/trueforge-ui/src/routing/LibrarySessionShareBoot.tsx
+++ b/packages/trueforge-ui/src/routing/LibrarySessionShareBoot.tsx
@@ -6,15 +6,26 @@ 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 or without `withRouter`. */
+/**
+ * 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).
+ */
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);
@@ -22,7 +33,7 @@ export function LibrarySessionShareBoot() {
}
if (share.agentId == null) return;
openLibraryAgent(share.agentId);
- }, [openLibraryAgent, sessionsEnabled, setSessionsOpen]);
+ }, [locationStore, openLibraryAgent, sessionsEnabled, setSessionsOpen]);
return null;
}
diff --git a/packages/trueforge-ui/src/routing/ShellLocationContext.tsx b/packages/trueforge-ui/src/routing/ShellLocationContext.tsx
new file mode 100644
index 000000000..210da2773
--- /dev/null
+++ b/packages/trueforge-ui/src/routing/ShellLocationContext.tsx
@@ -0,0 +1,25 @@
+'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 2f64790a7..b7f0984b9 100644
--- a/packages/trueforge-ui/src/routing/ShellRouteSync.tsx
+++ b/packages/trueforge-ui/src/routing/ShellRouteSync.tsx
@@ -1,36 +1,13 @@
'use client';
-import { useCallback, useEffect, useMemo, useRef } from 'react';
+import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
-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';
-}
+import { ShellRouteSyncCore, type ReadWindowLocation, type ShellRouteNavigate } from './ShellRouteSyncCore.js';
+import type { ResolvedRoutes } from './types.js';
/**
- * Single bidirectional bridge between shell state and the URL. Mounted under
+ * Single bidirectional bridge between shell state and the browser URL. Mounted under
* `ShellModeProvider` but outside the keyed chat runtime so boot applies once.
*/
export function ShellRouteSync({
@@ -42,366 +19,28 @@ export function ShellRouteSync({
activeRemoteId: string | undefined;
initialSettingsOpen: boolean;
}) {
- 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 routerNavigate = 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;
- }
- 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);
- });
+ const navigate = useCallback(
+ (to, options) => {
+ routerNavigate(to, options);
},
- [server, shell],
+ [routerNavigate],
);
- const openAgent = useCallback(
- (agentName: string) => {
- shell.selectLibraryAgent({ isMutable: false, agentName });
- applyHistoryAgentSearch({ intent: 'try-agent', agentName });
- },
- [applyHistoryAgentSearch, shell],
+ const readWindowLocation = useCallback(() => {
+ return { pathname: window.location.pathname, search: window.location.search };
+ }, []);
+
+ return (
+
);
-
- 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
new file mode 100644
index 000000000..c73c16b4a
--- /dev/null
+++ b/packages/trueforge-ui/src/routing/ShellRouteSyncCore.tsx
@@ -0,0 +1,426 @@
+'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
new file mode 100644
index 000000000..3448ca86f
--- /dev/null
+++ b/packages/trueforge-ui/src/routing/ShellStorageRouteSync.tsx
@@ -0,0 +1,40 @@
+'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
new file mode 100644
index 000000000..b61a4a19e
--- /dev/null
+++ b/packages/trueforge-ui/src/routing/shellLocationStore.ts
@@ -0,0 +1,198 @@
+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 ad5c41b2c..806a649f6 100644
--- a/packages/trueforge-ui/src/server/ShellModeContext.tsx
+++ b/packages/trueforge-ui/src/server/ShellModeContext.tsx
@@ -2,6 +2,7 @@
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 {
@@ -222,6 +223,7 @@ export function ShellModeProvider({
const refreshCapabilities = useOptionalRefreshServerCapabilities();
const sessionsServer = useOptionalAgentSessionsServer();
const scheduleServer = useOptionalScheduleServer();
+ const locationStore = useOptionalShellLocationStore();
const chatSeedRef = useRef(
readDraftSpecPreferences('chat') ?? selectDraftSpecPreferences(mutableSeedFromConfig(agentConfig), 'chat'),
);
@@ -263,12 +265,14 @@ export function ShellModeProvider({
setLibraryOpenState(false);
setLibraryAgentId(null);
setSchedulesOpenState(false);
+ } else if (locationStore != null) {
+ locationStore.updateSearch({ view: null });
} else {
replaceSessionShareSearch({ view: null });
}
setSessionsOpenState(sessionsEnabled && open);
},
- [sessionsEnabled],
+ [locationStore, 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 d338d07c6..2c7332cbb 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 { beforeAll, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createMockAgentUIServer, createMockCatalog } from '../server/mockServer.js';
vi.mock('@truefoundry/assistant-ui-runtime', () => ({
@@ -71,6 +71,7 @@ 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';
@@ -97,6 +98,16 @@ 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
new file mode 100644
index 000000000..bbc8657f9
--- /dev/null
+++ b/packages/trueforge-ui/test/routing/ShellStorageRouteSync.test.tsx
@@ -0,0 +1,232 @@
+// @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 882f9d284..0c6d8eab6 100644
--- a/packages/trueforge-ui/test/server/ShellModeContext.test.tsx
+++ b/packages/trueforge-ui/test/server/ShellModeContext.test.tsx
@@ -3,6 +3,8 @@ 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,
@@ -14,6 +16,22 @@ 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(),
@@ -69,6 +87,8 @@ 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', () => {
@@ -298,6 +318,30 @@ 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 });