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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/shell-storage-routing.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 12 additions & 10 deletions packages/trueforge-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
42 changes: 29 additions & 13 deletions packages/trueforge-ui/docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,16 @@ router should leave it off (the default).
<TrueForgeUI server={server} layout="sidebar" withRouter />
```

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
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 18 additions & 6 deletions packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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`. */
Expand Down Expand Up @@ -326,11 +331,15 @@ export function TrueForgeUIShell(props: TrueForgeUIShellProps) {
initialSettingsOpen={initialSettingsOpen}
/>
</Suspense>
) : null}
) : (
<Suspense fallback={null}>
<ShellStorageRouteSync activeRemoteId={activeRemoteId} initialSettingsOpen={initialSettingsOpen} />
</Suspense>
)}
<ChatProviderFromShell
server={server}
onError={onError}
onRemoteIdChange={resolvedRoutes != null ? handleRemoteIdChange : undefined}
onRemoteIdChange={handleRemoteIdChange}
{...providerRest}
>
{layoutTree}
Expand All @@ -341,15 +350,18 @@ export function TrueForgeUIShell(props: TrueForgeUIShellProps) {
const visibilityTree =
layout === 'widget' ? <WidgetVisibilityProvider>{shellTree}</WidgetVisibilityProvider> : shellTree;

const withLocation =
resolvedRoutes == null ? <ShellLocationProvider>{visibilityTree}</ShellLocationProvider> : visibilityTree;

return (
<SlotsProvider overrides={overrides} theme={theme}>
<CurrentUserProvider currentUser={currentUser}>
<CustomActionRenderersProvider renderers={customActionRenderers}>
<ServerProvider server={server}>
{resolvedRoutes != null ? (
<ResolvedRoutesProvider routes={resolvedRoutes}>{visibilityTree}</ResolvedRoutesProvider>
<ResolvedRoutesProvider routes={resolvedRoutes}>{withLocation}</ResolvedRoutesProvider>
) : (
visibilityTree
withLocation
)}
</ServerProvider>
</CustomActionRenderersProvider>
Expand Down
38 changes: 32 additions & 6 deletions packages/trueforge-ui/src/hooks/useSessionShareSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useCallback, useSyncExternalStore } from 'react';

import { useOptionalShellLocationStore } from '../routing/ShellLocationContext.js';
import {
readSessionShareSearch,
replaceSessionShareSearch,
Expand All @@ -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 };
}
15 changes: 13 additions & 2 deletions packages/trueforge-ui/src/routing/LibrarySessionShareBoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,34 @@ 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);
return;
}
if (share.agentId == null) return;
openLibraryAgent(share.agentId);
}, [openLibraryAgent, sessionsEnabled, setSessionsOpen]);
}, [locationStore, openLibraryAgent, sessionsEnabled, setSessionsOpen]);

return null;
}
25 changes: 25 additions & 0 deletions packages/trueforge-ui/src/routing/ShellLocationContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
'use client';

import { createContext, useContext, useMemo, type ReactNode } from 'react';

import { createShellLocationStore, type ShellLocationStore } from './shellLocationStore.js';

const ShellLocationContext = createContext<ShellLocationStore | null>(null);

/** Present only when the shell runs without `withRouter` (sessionStorage-backed location). */
export function ShellLocationProvider({ children }: { children: ReactNode }) {
const store = useMemo(() => createShellLocationStore(), []);
return <ShellLocationContext.Provider value={store}>{children}</ShellLocationContext.Provider>;
}

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