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 });