From f09210a7b8745da569b8a706f22b47c3146e006b Mon Sep 17 00:00:00 2001 From: Harshil Vasoya Date: Wed, 23 Sep 2026 17:04:26 +0530 Subject: [PATCH 1/6] feat: allow session sharing route with query string fallback --- .changeset/session-share-route.md | 5 + packages/trueforge-ui/docs/customization.md | 5 + .../trueforge-ui/src/atoms/ThreadShell.tsx | 15 ++- .../src/atoms/agent-details/AgentSessions.tsx | 107 +++++++++++------- .../src/atoms/agent-details/SessionsPage.tsx | 79 ++++++++++--- .../src/atoms/agent-details/types.ts | 4 + .../AgentSessionTimelineContainer.tsx | 11 +- .../src/routing/LibrarySessionShareBoot.tsx | 10 +- .../src/routing/ShellRouteSync.tsx | 11 +- .../trueforge-ui/src/routing/derivePlace.ts | 3 + packages/trueforge-ui/src/routing/paths.ts | 48 +++++++- packages/trueforge-ui/src/routing/types.ts | 7 +- .../src/server/ShellModeContext.tsx | 34 +++++- .../trueforge-ui/src/server/serverChrome.ts | 1 + .../trueforge-ui/src/utils/sessionShareUrl.ts | 8 +- .../test/atoms/SessionsPage.test.tsx | 29 +++++ .../test/atoms/ThreadShell.test.tsx | 12 +- .../AgentSessionTimelineContainer.test.tsx | 9 +- .../routing/LibrarySessionShareBoot.test.tsx | 8 ++ .../test/routing/derivePlace.test.ts | 8 ++ .../trueforge-ui/test/routing/paths.test.ts | 38 +++++++ .../routing/withRouter.integration.test.tsx | 63 +++++++++++ .../test/server/serverChrome.test.ts | 2 + .../test/utils/sessionShareUrl.test.ts | 6 + 24 files changed, 450 insertions(+), 73 deletions(-) create mode 100644 .changeset/session-share-route.md diff --git a/.changeset/session-share-route.md b/.changeset/session-share-route.md new file mode 100644 index 000000000..1517661ab --- /dev/null +++ b/.changeset/session-share-route.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge-ui": minor +--- + +Add a detail-only session sharing route with a query-string fallback for hosts that manage routing. diff --git a/packages/trueforge-ui/docs/customization.md b/packages/trueforge-ui/docs/customization.md index 2bfba623f..92967a9eb 100644 --- a/packages/trueforge-ui/docs/customization.md +++ b/packages/trueforge-ui/docs/customization.md @@ -100,6 +100,7 @@ Places mirrored to the URL: - `/agents/:agentName` — immutable "Try" of a library agent - `/sessions` — all-user Sessions browser (named agents and drafts) - `/sessions/:sessionId` — a specific chat session +- `/sessions/share/:sessionId` — detail-only session view without the Sessions list or filters - `/settings` — settings overlay (closing navigates to the chat place below it) - `/library` — Agents - `/library/:agentId` — agent details. `?tab=overview|sessions|code|metrics` selects the tab (default Overview); @@ -117,6 +118,7 @@ to keep that place overlay-only with no URL: paths: { buildAgent: '/new-agent', session: '/chats/:sessionId', + sharedSession: '/sessions/share/:sessionId', libraryAgent: '/library/:agentId', settings: false, }, @@ -146,6 +148,9 @@ Notes on behaviour: (`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 detail-only shared session is `/sessions/share/:sessionId` when + `withRouter` is on. Without SDK routing, the same view uses + `?view=shared-session&sessionId=:sessionId` on the host page. - 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/atoms/ThreadShell.tsx b/packages/trueforge-ui/src/atoms/ThreadShell.tsx index 5dd73a9c0..e9b8369bb 100644 --- a/packages/trueforge-ui/src/atoms/ThreadShell.tsx +++ b/packages/trueforge-ui/src/atoms/ThreadShell.tsx @@ -2,8 +2,11 @@ import { forwardRef, type ComponentPropsWithRef, type CSSProperties } from 'reac import { cn } from './lib/cn.js'; +const THREAD_MAX_WIDTH = '44rem'; +const THREAD_CONTENT_MAX_WIDTH = `var(--thread-max-width, ${THREAD_MAX_WIDTH})`; + const THREAD_CSS_VARS: CSSProperties = { - ['--thread-max-width' as string]: '44rem', + ['--thread-max-width' as string]: THREAD_MAX_WIDTH, ['--composer-padding' as string]: '8px', }; @@ -26,24 +29,28 @@ ThreadRootShell.displayName = 'ThreadRootShell'; export type ThreadViewportShellProps = ComponentPropsWithRef<'div'> & { isEmpty?: boolean; + /** Disable the inner scroll root when an ancestor owns the combined surface scroll. */ + scrollable?: boolean; }; export const ThreadViewportShell = forwardRef( - ({ className, isEmpty, children, ...rest }, ref) => ( + ({ className, isEmpty, scrollable = true, children, ...rest }, ref) => (
{children}
diff --git a/packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx b/packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx index b951d5bb5..61791605f 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx +++ b/packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx @@ -41,13 +41,22 @@ function entrySourceType(entry: SessionListEntry): 'schedule' | undefined { return 'sourceType' in entry && Reflect.get(entry, 'sourceType') === 'schedule' ? 'schedule' : undefined; } -export function AgentSessions({ agentId, startTimestamp, endTimestamp, shareView }: AgentSessionsProps) { +export function AgentSessions({ + agentId, + startTimestamp, + endTimestamp, + shareView, + detailOnly = false, + detailSessionId, + onCloseDetail, +}: AgentSessionsProps) { const sessionsServer = useAgentSessionsServer(); const chatServer = useServer(); const toaster = useToasterOptional(); const shell = useOptionalShellMode(); const routes = useOptionalResolvedRoutes(); - const { sessionId: selectedSessionId, updateShareSearch } = useSessionShareSearch(); + const { sessionId: querySessionId, updateShareSearch } = useSessionShareSearch(); + const selectedSessionId = detailOnly ? (detailSessionId ?? null) : querySessionId; const AgentSessionListRow = useSlot('AgentSessionListRow'); const AgentSessionDetailHeader = useSlot('AgentSessionDetailHeader'); @@ -97,6 +106,16 @@ export function AgentSessions({ agentId, startTimestamp, endTimestamp, shareView let cancelled = false; loadMoreInflightRef.current = false; setNextPageToken(undefined); + if (detailOnly) { + setEntries([]); + setListLoading(false); + setListLoadingMore(false); + setListLoadMoreFailed(false); + setListFailed(false); + return () => { + cancelled = true; + }; + } setListLoading(true); setListLoadingMore(false); setListLoadMoreFailed(false); @@ -121,7 +140,7 @@ export function AgentSessions({ agentId, startTimestamp, endTimestamp, shareView return () => { cancelled = true; }; - }, [listRequest, sessionsServer]); + }, [detailOnly, listRequest, sessionsServer]); const loadMore = useCallback(async () => { // A ref, not `listLoadingMore`: the observer can fire twice before a re-render. @@ -214,6 +233,10 @@ export function AgentSessions({ agentId, startTimestamp, endTimestamp, shareView const clearSelectedSession = () => { if (selectedSessionId == null) return; + if (detailOnly) { + onCloseDetail?.(); + return; + } updateShareSearch({ sessionId: null }); }; @@ -267,6 +290,48 @@ export function AgentSessions({ agentId, startTimestamp, endTimestamp, shareView const resumeProps = resumeHref != null ? { resumeHref, resumeLabel } : shell != null ? { onResume: handleResume, resumeLabel } : {}; + const detailPanel = ( +
+ {selectedSessionId == null ? ( +
+ Select a session to view details +
+ ) : detailFailed ? ( +
+ Session details could not be loaded. +
+ ) : ( + <> + + {detailLoading || detailEvents === undefined ? ( +
+ +
+ ) : ( + + )} + + )} +
+ ); + + if (detailOnly) { + return
{detailPanel}
; + } + // Full empty only when nothing is selected — keep the detail pane for deep-linked sessionIds // (filters/time range can empty the list while share state still points at a session). if ( @@ -364,41 +429,7 @@ export function AgentSessions({ agentId, startTimestamp, endTimestamp, shareView -
- {selectedSessionId == null ? ( -
- Select a session to view details -
- ) : detailFailed ? ( -
- Session details could not be loaded. -
- ) : ( - <> - - {detailLoading || detailEvents === undefined ? ( -
- -
- ) : ( - - )} - - )} -
+ {detailPanel}
{pendingDelete != null ? ( diff --git a/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx b/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx index 931688895..d4901694b 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx +++ b/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx @@ -3,7 +3,11 @@ import { Suspense, useEffect, useMemo, useState } from 'react'; import { useSessionShareSearch } from '../../hooks/useSessionShareSearch.js'; +import { Icon } from '../../icons/Icon.js'; +import { buildSharedSessionHref } from '../../routing/paths.js'; +import { useOptionalResolvedRoutes } from '../../routing/ResolvedRoutesContext.js'; import { useOptionalAgentSessionsServer } from '../../server/ServerContext.js'; +import { useShellMode } from '../../server/ShellModeContext.js'; import { useSlot } from '../../theme/SlotsProvider.js'; import { defaultSessionTimeRange, @@ -11,14 +15,20 @@ import { resolveSessionTimeRange, type SessionTimeRange, } from '../../utils/sessionShareUrl.js'; +import { auiButtonClass } from '../lib/buttonClasses.js'; import { PageHeader } from '../PageHeader.js'; import { Skeleton } from '../primitives/Skeleton.js'; export function SessionsPage() { const sessionsServer = useOptionalAgentSessionsServer(); - const { updateShareSearch } = useSessionShareSearch(); + const shell = useShellMode(); + const routes = useOptionalResolvedRoutes(); + const { sessionId, updateShareSearch } = useSessionShareSearch(); const AgentSessions = useSlot('AgentSessions'); const AgentSessionsFilters = useSlot('AgentSessionsFilters'); + const sharedSessionId = shell.sharedSessionId; + const selectedSessionId = sharedSessionId ?? sessionId; + const [copied, setCopied] = useState(false); const [agentFilter, setAgentFilter] = useState( () => readSessionShareSearch(window.location.search).agentId, @@ -28,6 +38,7 @@ export function SessionsPage() { ); useEffect(() => { + if (sharedSessionId != null) return; const share = readSessionShareSearch(window.location.search); updateShareSearch({ view: 'sessions', @@ -37,6 +48,12 @@ export function SessionsPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (!copied) return undefined; + const timer = window.setTimeout(() => setCopied(false), 2000); + return () => window.clearTimeout(timer); + }, [copied]); + useEffect(() => { const syncFilters = () => { const share = readSessionShareSearch(window.location.search); @@ -50,24 +67,52 @@ export function SessionsPage() { // Resolve relative presets only when the filter changes. Unrelated query // updates (such as selecting a session) must not shift/refetch the list. const resolved = useMemo(() => resolveSessionTimeRange(timeRange), [timeRange]); + const copySharedSessionLink = async () => { + if (selectedSessionId == null) return; + try { + await navigator.clipboard.writeText( + buildSharedSessionHref({ + sessionId: selectedSessionId, + routes, + }), + ); + setCopied(true); + } catch { + // Clipboard access depends on the host browser and document permissions. + } + }; return (
{ - setAgentFilter(nextAgentId); - updateShareSearch({ agentId: nextAgentId, sessionId: null, view: 'sessions' }); - }} - onTimeRangeChange={nextRange => { - setTimeRange(nextRange); - updateShareSearch({ timeRange: nextRange, sessionId: null, view: 'sessions' }); - }} - /> + <> + {selectedSessionId != null ? ( + + ) : null} + {sharedSessionId == null ? ( + { + setAgentFilter(nextAgentId); + updateShareSearch({ agentId: nextAgentId, sessionId: null, view: 'sessions' }); + }} + onTimeRangeChange={nextRange => { + setTimeRange(nextRange); + updateShareSearch({ timeRange: nextRange, sessionId: null, view: 'sessions' }); + }} + /> + ) : null} + } />
@@ -85,7 +130,13 @@ export function SessionsPage() { agentId={agentFilter ?? undefined} startTimestamp={new Date(resolved.startTs).toISOString()} endTimestamp={new Date(resolved.endTs).toISOString()} - shareView="sessions" + {...(sharedSessionId == null + ? { shareView: 'sessions' as const } + : { + detailOnly: true, + detailSessionId: sharedSessionId, + onCloseDetail: shell.closeSharedSession, + })} /> )} diff --git a/packages/trueforge-ui/src/atoms/agent-details/types.ts b/packages/trueforge-ui/src/atoms/agent-details/types.ts index 04b463fed..f3391a559 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/types.ts +++ b/packages/trueforge-ui/src/atoms/agent-details/types.ts @@ -18,6 +18,10 @@ export type AgentSessionsProps = { endTimestamp?: string; /** When `sessions`, selection writes `view=sessions` and pins `s_sts`/`s_ets`. */ shareView?: 'sessions' | null; + /** Render only the selected detail pane for a shared-session URL. */ + detailOnly?: boolean; + detailSessionId?: string; + onCloseDetail?: () => void; }; export type AgentSessionListRowProps = { diff --git a/packages/trueforge-ui/src/containers/AgentSessionTimelineContainer.tsx b/packages/trueforge-ui/src/containers/AgentSessionTimelineContainer.tsx index 433d30fa7..72c325091 100644 --- a/packages/trueforge-ui/src/containers/AgentSessionTimelineContainer.tsx +++ b/packages/trueforge-ui/src/containers/AgentSessionTimelineContainer.tsx @@ -207,14 +207,19 @@ export function AgentSessionTimelineContainer({ sessionId, events, listMetrics } } return ( -
-
+
+
+
+
- +
{turnViews.map(turn => ( { if (!sessionsEnabled) return; const share = readSessionShareSearch(window.location.search); + if (share.view === SHARED_SESSION_VIEW_VALUE && share.sessionId != null) { + openSharedSession(share.sessionId); + return; + } if (share.view === 'sessions') { setSessionsOpen(true); return; } if (share.agentId == null) return; openLibraryAgent(share.agentId); - }, [openLibraryAgent, sessionsEnabled, setSessionsOpen]); + }, [openLibraryAgent, openSharedSession, sessionsEnabled, setSessionsOpen]); return null; } diff --git a/packages/trueforge-ui/src/routing/ShellRouteSync.tsx b/packages/trueforge-ui/src/routing/ShellRouteSync.tsx index 2f64790a7..61940354e 100644 --- a/packages/trueforge-ui/src/routing/ShellRouteSync.tsx +++ b/packages/trueforge-ui/src/routing/ShellRouteSync.tsx @@ -29,6 +29,10 @@ function placeOwnsHistoryAgentSearch(place: RoutePlace): boolean { return place.type === 'root' || place.type === 'agent' || place.type === 'session'; } +function isSessionsPlace(place: RoutePlace): boolean { + return place.type === 'sessionsBrowser' || place.type === 'sharedSession'; +} + /** * Single bidirectional bridge between shell state and the URL. Mounted under * `ShellModeProvider` but outside the keyed chat runtime so boot applies once. @@ -62,6 +66,7 @@ export function ShellRouteSync({ const routeGatesKey = [ effectiveRoutes.settings, effectiveRoutes.sessionsBrowser, + effectiveRoutes.sharedSession, effectiveRoutes.libraryAgent, effectiveRoutes.schedules, ].join('\0'); @@ -70,6 +75,7 @@ export function ShellRouteSync({ settingsOpen: shell.settingsOpen, libraryOpen: shell.libraryOpen, sessionsOpen: shell.sessionsOpen, + sharedSessionId: shell.sharedSessionId, libraryAgentId: shell.libraryAgentId, schedulesOpen: shell.schedulesOpen, pendingSessionId: shell.pendingSessionId, @@ -195,6 +201,9 @@ export function ShellRouteSync({ case 'sessionsBrowser': shell.setSessionsOpen(true); return; + case 'sharedSession': + shell.openSharedSession(target.sessionId); + return; case 'libraryAgent': shell.openLibraryAgent(target.agentId); return; @@ -386,7 +395,7 @@ export function ShellRouteSync({ if (urlPlace.type !== 'library' && urlPlace.type !== 'libraryAgent' && shell.libraryOpen) { shell.setLibraryOpen(false); } - if (urlPlace.type !== 'sessionsBrowser' && shell.sessionsOpen) { + if (!isSessionsPlace(urlPlace) && shell.sessionsOpen) { shell.setSessionsOpen(false); } if (urlPlace.type !== 'schedules' && shell.schedulesOpen) { diff --git a/packages/trueforge-ui/src/routing/derivePlace.ts b/packages/trueforge-ui/src/routing/derivePlace.ts index fb28c7d55..bf2e164e4 100644 --- a/packages/trueforge-ui/src/routing/derivePlace.ts +++ b/packages/trueforge-ui/src/routing/derivePlace.ts @@ -28,6 +28,9 @@ export function deriveChatPlace(snapshot: ShellSnapshot): RoutePlace { export function derivePlace(snapshot: ShellSnapshot): RoutePlace { if (snapshot.settingsOpen) return { type: 'settings' }; if (snapshot.schedulesOpen) return { type: 'schedules' }; + if (snapshot.sessionsOpen && snapshot.sharedSessionId != null) { + return { type: 'sharedSession', sessionId: snapshot.sharedSessionId }; + } if (snapshot.sessionsOpen) return { type: 'sessionsBrowser' }; if (snapshot.libraryOpen && snapshot.libraryAgentId != null) { return { type: 'libraryAgent', agentId: snapshot.libraryAgentId }; diff --git a/packages/trueforge-ui/src/routing/paths.ts b/packages/trueforge-ui/src/routing/paths.ts index 485aa03e8..32397c291 100644 --- a/packages/trueforge-ui/src/routing/paths.ts +++ b/packages/trueforge-ui/src/routing/paths.ts @@ -3,7 +3,11 @@ import { readScheduleShareSearch, writeScheduleShareSearch, } from '../utils/scheduleShareUrl.js'; -import { readSessionShareSearch, writeSessionShareSearch } from '../utils/sessionShareUrl.js'; +import { + readSessionShareSearch, + SHARED_SESSION_VIEW_VALUE, + writeSessionShareSearch, +} from '../utils/sessionShareUrl.js'; import type { ResolvedRoutes, RoutePlace, RoutesConfig } from './types.js'; const DEFAULTS = { @@ -15,6 +19,7 @@ const DEFAULTS = { buildAgent: '/build-agent', agent: '/agents/:agentName', session: '/sessions/:sessionId', + sharedSession: '/sessions/share/:sessionId', sessionsBrowser: '/sessions', } as const; @@ -42,6 +47,7 @@ export function resolveRoutesConfig(routes?: RoutesConfig): ResolvedRoutes { buildAgent: resolveOptional(paths?.buildAgent, DEFAULTS.buildAgent), agent: resolveOptional(paths?.agent, DEFAULTS.agent), session: resolveOptional(paths?.session, DEFAULTS.session), + sharedSession: resolveOptional(paths?.sharedSession, DEFAULTS.sharedSession), sessionsBrowser: resolveOptional(paths?.sessionsBrowser, DEFAULTS.sessionsBrowser), }; } @@ -79,6 +85,8 @@ export function buildPath(place: RoutePlace, routes: ResolvedRoutes): string | n return routes.agent == null ? null : fillTemplate(routes.agent, place.agentName); case 'session': return routes.session == null ? null : fillTemplate(routes.session, place.sessionId); + case 'sharedSession': + return routes.sharedSession == null ? null : fillTemplate(routes.sharedSession, place.sessionId); case 'sessionsBrowser': return routes.sessionsBrowser; } @@ -107,6 +115,36 @@ export function buildSessionResumeHref({ return url.toString(); } +/** Absolute detail-only share URL, with a query fallback when SDK routing is unavailable. */ +export function buildSharedSessionHref({ + sessionId, + routes, + href = typeof window === 'undefined' ? 'http://localhost/' : window.location.href, +}: { + sessionId: string; + routes: ResolvedRoutes | null; + href?: string; +}): string { + const place: RoutePlace = { type: 'sharedSession', sessionId }; + const sessionPath = routes == null ? null : buildPath(place, routes); + const url = new URL(href); + if (routes != null && sessionPath != null) { + const basename = routes.basename.endsWith('/') ? routes.basename.slice(0, -1) : routes.basename; + url.pathname = `${basename}${sessionPath}` || '/'; + url.search = sanitizeSearchForPlace(place, url.search); + url.hash = ''; + return url.toString(); + } + writeSessionShareSearch(url.searchParams, { + sessionId, + agentId: null, + tab: null, + view: SHARED_SESSION_VIEW_VALUE, + timeRange: null, + }); + return url.toString(); +} + /** * Remove query state owned by a different shell place while preserving host * parameters. Settings is an overlay, so it retains the underlying place state. @@ -218,6 +256,10 @@ export function matchPath(pathname: string, routes: ResolvedRoutes): RoutePlace const sessionId = matchTemplate(routes.session, segments); if (sessionId != null) return { type: 'session', sessionId }; } + if (routes.sharedSession != null) { + const sessionId = matchTemplate(routes.sharedSession, segments); + if (sessionId != null) return { type: 'sharedSession', sessionId }; + } if (normalized === routes.root) { return { type: 'root' }; } @@ -236,7 +278,8 @@ export function matchLocation({ }): RoutePlace | null { const matched = matchPath(pathname, routes); if (matched == null || matched.type !== 'root') return matched; - const { agentId } = readSessionShareSearch(search); + const { agentId, sessionId, view } = readSessionShareSearch(search); + if (view === SHARED_SESSION_VIEW_VALUE && sessionId != null) return { type: 'sharedSession', sessionId }; return agentId != null ? { type: 'libraryAgent', agentId } : matched; } @@ -245,5 +288,6 @@ export function placesEqual(a: RoutePlace, b: RoutePlace): boolean { if (a.type === 'agent' && b.type === 'agent') return a.agentName === b.agentName; if (a.type === 'libraryAgent' && b.type === 'libraryAgent') return a.agentId === b.agentId; if (a.type === 'session' && b.type === 'session') return a.sessionId === b.sessionId; + if (a.type === 'sharedSession' && b.type === 'sharedSession') return a.sessionId === b.sessionId; return true; } diff --git a/packages/trueforge-ui/src/routing/types.ts b/packages/trueforge-ui/src/routing/types.ts index ea5024005..dba41efa8 100644 --- a/packages/trueforge-ui/src/routing/types.ts +++ b/packages/trueforge-ui/src/routing/types.ts @@ -8,6 +8,7 @@ export type RoutePlace = | { type: 'root' } | { type: 'agent'; agentName: string } | { type: 'session'; sessionId: string } + | { type: 'sharedSession'; sessionId: string } | { type: 'settings' } | { type: 'library' } | { type: 'libraryAgent'; agentId: string } @@ -22,7 +23,7 @@ export type RoutePlace = export type RoutesConfig = { /** Passed to the router; not used for our own match/build (router strips it). */ basename?: string; - /** `agent` and `session` templates MUST keep their `:param` segment to stay addressable. */ + /** Parameterized templates MUST keep their `:param` segment to stay addressable. */ paths?: { /** New-chat / landing. Default `'/'`. */ root?: string; @@ -40,6 +41,8 @@ export type RoutesConfig = { agent?: string | false; /** Session deep link. `false` disables. Default `'/sessions/:sessionId'`. */ session?: string | false; + /** Detail-only session share link. `false` disables. Default `'/sessions/share/:sessionId'`. */ + sharedSession?: string | false; /** All-user sessions browser. `false` disables. Default `'/sessions'`. */ sessionsBrowser?: string | false; }; @@ -56,6 +59,7 @@ export type ResolvedRoutes = { buildAgent: string | null; agent: string | null; session: string | null; + sharedSession: string | null; sessionsBrowser: string | null; }; @@ -64,6 +68,7 @@ export type ShellSnapshot = { settingsOpen: boolean; libraryOpen: boolean; sessionsOpen: boolean; + sharedSessionId: string | null; libraryAgentId: string | null; schedulesOpen: boolean; pendingSessionId?: string; diff --git a/packages/trueforge-ui/src/server/ShellModeContext.tsx b/packages/trueforge-ui/src/server/ShellModeContext.tsx index 95ce0a133..d341efa71 100644 --- a/packages/trueforge-ui/src/server/ShellModeContext.tsx +++ b/packages/trueforge-ui/src/server/ShellModeContext.tsx @@ -102,6 +102,10 @@ type ShellModeContextValue = { /** All-user sessions browser (includes drafts). */ sessionsOpen: boolean; setSessionsOpen: (open: boolean) => void; + /** Session shown in the detail-only sharing surface. */ + sharedSessionId: string | null; + openSharedSession: (sessionId: string) => void; + closeSharedSession: () => void; openLibraryAgent: (agentId: string) => void; closeLibraryAgent: () => void; schedulesOpen: boolean; @@ -243,6 +247,7 @@ export function ShellModeProvider({ const [agentConfigOpenState, setAgentConfigOpenState] = useState(false); const [libraryOpenState, setLibraryOpenState] = useState(false); const [sessionsOpenState, setSessionsOpenState] = useState(false); + const [sharedSessionId, setSharedSessionId] = useState(null); const [libraryAgentId, setLibraryAgentId] = useState(null); const [schedulesOpenState, setSchedulesOpenState] = useState(false); const [historyAgentFilter, setHistoryAgentFilter] = useState(null); @@ -264,12 +269,33 @@ export function ShellModeProvider({ setLibraryAgentId(null); setSchedulesOpenState(false); } else { - replaceSessionShareSearch({ view: null }); + replaceSessionShareSearch({ + view: null, + ...(sharedSessionId == null ? {} : { sessionId: null }), + }); } + setSharedSessionId(null); setSessionsOpenState(sessionsEnabled && open); }, + [sessionsEnabled, sharedSessionId], + ); + const openSharedSession = useCallback( + (sessionId: string) => { + if (!sessionsEnabled) return; + setSettingsOpenState(false); + setAgentConfigOpenState(false); + setLibraryOpenState(false); + setLibraryAgentId(null); + setSchedulesOpenState(false); + setSharedSessionId(sessionId); + setSessionsOpenState(true); + }, [sessionsEnabled], ); + const closeSharedSession = useCallback(() => { + setSharedSessionId(null); + replaceSessionShareSearch({ sessionId: null, view: 'sessions' }); + }, []); const setLibraryOpen = useCallback( (open: boolean) => { if (!isLibraryEnabled) return; @@ -656,6 +682,9 @@ export function ShellModeProvider({ setLibraryOpen, sessionsOpen, setSessionsOpen, + sharedSessionId, + openSharedSession, + closeSharedSession, openLibraryAgent, closeLibraryAgent, schedulesOpen, @@ -694,6 +723,9 @@ export function ShellModeProvider({ setLibraryOpen, sessionsOpen, setSessionsOpen, + sharedSessionId, + openSharedSession, + closeSharedSession, openLibraryAgent, closeLibraryAgent, schedulesOpen, diff --git a/packages/trueforge-ui/src/server/serverChrome.ts b/packages/trueforge-ui/src/server/serverChrome.ts index 95934a6c7..9246b2638 100644 --- a/packages/trueforge-ui/src/server/serverChrome.ts +++ b/packages/trueforge-ui/src/server/serverChrome.ts @@ -52,6 +52,7 @@ export function toEffectiveRoutes({ ...routes, settings: isSettingsChromeEnabled({ catalog, capabilities }) ? routes.settings : null, sessionsBrowser: sessionsEnabled ? routes.sessionsBrowser : null, + sharedSession: sessionsEnabled ? routes.sharedSession : null, libraryAgent: sessionsEnabled ? routes.libraryAgent : null, schedules: isSchedulesChromeEnabled({ schedules }) ? routes.schedules : null, }; diff --git a/packages/trueforge-ui/src/utils/sessionShareUrl.ts b/packages/trueforge-ui/src/utils/sessionShareUrl.ts index edf4e212a..bb0bb5be7 100644 --- a/packages/trueforge-ui/src/utils/sessionShareUrl.ts +++ b/packages/trueforge-ui/src/utils/sessionShareUrl.ts @@ -3,6 +3,7 @@ export const AGENT_ID_QUERY = 'agentId'; export const AGENT_TAB_QUERY = 'tab'; export const SESSIONS_VIEW_QUERY = 'view'; export const SESSIONS_VIEW_VALUE = 'sessions'; +export const SHARED_SESSION_VIEW_VALUE = 'shared-session'; export const SESSION_START_TIME_QUERY = 's_sts'; export const SESSION_END_TIME_QUERY = 's_ets'; export const SESSION_TIME_WINDOW_QUERY = 's_tw'; @@ -12,6 +13,7 @@ export const DEFAULT_SESSION_TIME_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; export const DEFAULT_METRICS_TIME_WINDOW_MS = 24 * 60 * 60 * 1000; export type LibraryAgentTab = 'overview' | 'sessions' | 'schedules' | 'code' | 'metrics'; +export type SessionShareView = typeof SESSIONS_VIEW_VALUE | typeof SHARED_SESSION_VIEW_VALUE; export type SessionTimeRange = { startTs: number; @@ -23,7 +25,7 @@ export type SessionShareSearch = { sessionId: string | null; agentId: string | null; tab: LibraryAgentTab | null; - view: typeof SESSIONS_VIEW_VALUE | null; + view: SessionShareView | null; timeRange: SessionTimeRange | null; }; @@ -31,7 +33,7 @@ export type SessionShareWrite = { sessionId?: string | null; agentId?: string | null; tab?: LibraryAgentTab | null; - view?: typeof SESSIONS_VIEW_VALUE | null; + view?: SessionShareView | null; timeRange?: SessionTimeRange | null; }; @@ -101,7 +103,7 @@ export function readSessionShareSearch(search: string): SessionShareSearch { sessionId: nonEmpty(params.get(SESSION_ID_QUERY)), agentId: nonEmpty(params.get(AGENT_ID_QUERY)), tab: parseLibraryAgentTab(params.get(AGENT_TAB_QUERY)), - view: view === SESSIONS_VIEW_VALUE ? SESSIONS_VIEW_VALUE : null, + view: view === SESSIONS_VIEW_VALUE || view === SHARED_SESSION_VIEW_VALUE ? view : null, timeRange: startTs != null && endTs != null ? { startTs, endTs } diff --git a/packages/trueforge-ui/test/atoms/SessionsPage.test.tsx b/packages/trueforge-ui/test/atoms/SessionsPage.test.tsx index 0350033d3..ff2aced36 100644 --- a/packages/trueforge-ui/test/atoms/SessionsPage.test.tsx +++ b/packages/trueforge-ui/test/atoms/SessionsPage.test.tsx @@ -44,8 +44,15 @@ type ListSessionsRequest = { const originalShowModal = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, 'showModal'); const originalClose = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, 'close'); +const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); +let clipboardWriteText: ReturnType; beforeEach(() => { + clipboardWriteText = vi.fn(async () => undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: clipboardWriteText }, + }); Object.defineProperty(HTMLDialogElement.prototype, 'showModal', { configurable: true, value: function showModal(this: HTMLDialogElement) { @@ -74,6 +81,11 @@ afterEach(() => { } else { Object.defineProperty(HTMLDialogElement.prototype, 'close', originalClose); } + if (originalClipboard === undefined) { + Reflect.deleteProperty(navigator, 'clipboard'); + } else { + Object.defineProperty(navigator, 'clipboard', originalClipboard); + } }); function renderPage({ @@ -187,6 +199,23 @@ describe('SessionsPage', () => { expect(getSession).toHaveBeenCalledWith({ sessionId: 'sess-1' }); }); + it('copies the no-router shared-session query URL for the selected session', async () => { + window.history.replaceState(null, '', '/?view=sessions&sessionId=sess-1&agentId=agent-1&s_tw=30'); + renderPage(); + + fireEvent.click(await screen.findByRole('button', { name: 'Copy shared link' })); + + await waitFor(() => { + expect(clipboardWriteText).toHaveBeenCalledOnce(); + }); + const copied = new URL(String(clipboardWriteText.mock.calls[0]?.[0])); + expect(copied.pathname).toBe('/'); + expect(copied.searchParams.get('view')).toBe('shared-session'); + expect(copied.searchParams.get('sessionId')).toBe('sess-1'); + expect(copied.searchParams.get('agentId')).toBeNull(); + expect(copied.searchParams.get('s_tw')).toBeNull(); + }); + it('shows the custom range picker only after Custom Time Range is clicked', async () => { renderPage(); const timeButton = await screen.findByRole('button', { name: 'Last 30 days' }); diff --git a/packages/trueforge-ui/test/atoms/ThreadShell.test.tsx b/packages/trueforge-ui/test/atoms/ThreadShell.test.tsx index cbac3f538..548792282 100644 --- a/packages/trueforge-ui/test/atoms/ThreadShell.test.tsx +++ b/packages/trueforge-ui/test/atoms/ThreadShell.test.tsx @@ -7,13 +7,13 @@ import { MessageGroup, ThreadComposerAreaShell, ThreadRootShell, ThreadViewportS describe('ThreadRootShell', () => { it('merges host styles over defaults and forwards its ref and attributes', () => { const ref = createRef(); - const hostStyle = Object.assign({ color: 'red' }, { '--thread-max-width': '60rem' }); + const hostStyle = Object.assign({ color: 'red' }, { '--thread-max-width': '72rem' }); render(); const root = screen.getByTestId('thread-root'); expect(root).toBe(ref.current); expect(root).toHaveClass('aui-thread-root', 'host-thread'); - expect(root.style.getPropertyValue('--thread-max-width')).toBe('60rem'); + expect(root.style.getPropertyValue('--thread-max-width')).toBe('72rem'); // Composer surface uses --input-box-bg on the theme root; thread shell must not set --composer-bg. expect(root.style.getPropertyValue('--composer-bg')).toBe(''); expect(root.style.color).toBe('red'); @@ -33,6 +33,7 @@ describe('ThreadViewportShell', () => { // CSS smooth scroll fights assistant-ui autoScroll and causes bounce on large streams. expect(viewport.className).not.toMatch(/\bscroll-smooth\b/); expect(viewport.firstElementChild).toHaveClass('min-h-full', 'justify-center', 'pb-4'); + expect(viewport.firstElementChild).toHaveStyle({ maxWidth: 'var(--thread-max-width, 44rem)' }); expect(viewport).toHaveTextContent('Welcome'); rerender( @@ -43,6 +44,13 @@ describe('ThreadViewportShell', () => { expect(viewport.firstElementChild).toHaveClass('pb-32'); expect(viewport.firstElementChild).not.toHaveClass('justify-center'); }); + + it('can leave scrolling to a parent surface', () => { + render(); + + expect(screen.getByTestId('viewport')).toHaveClass('shrink-0', 'overflow-visible'); + expect(screen.getByTestId('viewport')).not.toHaveClass('overflow-y-auto'); + }); }); describe('ThreadComposerAreaShell', () => { diff --git a/packages/trueforge-ui/test/containers/AgentSessionTimelineContainer.test.tsx b/packages/trueforge-ui/test/containers/AgentSessionTimelineContainer.test.tsx index 059e8b29f..48332ef80 100644 --- a/packages/trueforge-ui/test/containers/AgentSessionTimelineContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/AgentSessionTimelineContainer.test.tsx @@ -158,7 +158,7 @@ describe('AgentSessionTimelineContainer', () => { const scrollIntoView = vi.fn(); HTMLElement.prototype.scrollIntoView = scrollIntoView; - render( + const { container } = render( ( @@ -179,6 +179,13 @@ describe('AgentSessionTimelineContainer', () => { expect(screen.getByText('Turn')).toBeInTheDocument(); expect(screen.getByText('Duration')).toBeInTheDocument(); expect(await screen.findAllByRole('button', { name: 'Copy' })).not.toHaveLength(0); + expect(container.querySelector('[data-slot="agent-session-scroll"]')).toHaveClass('overflow-y-auto'); + expect(container.querySelector('[data-slot="agent-session-metrics-sticky"]')).toHaveClass( + 'sticky', + 'top-0', + 'bg-primary-bg', + ); + expect(container.querySelector('[data-slot="aui_thread-viewport"]')).toHaveClass('overflow-visible'); fireEvent.click(screen.getByRole('button', { name: 'timeline turns=1' })); await waitFor(() => { expect(scrollIntoView).toHaveBeenCalled(); diff --git a/packages/trueforge-ui/test/routing/LibrarySessionShareBoot.test.tsx b/packages/trueforge-ui/test/routing/LibrarySessionShareBoot.test.tsx index 2e563881d..ce127d5e9 100644 --- a/packages/trueforge-ui/test/routing/LibrarySessionShareBoot.test.tsx +++ b/packages/trueforge-ui/test/routing/LibrarySessionShareBoot.test.tsx @@ -17,6 +17,7 @@ function Probe() {
{shell.libraryAgentId ?? 'none'} {shell.sessionsOpen ? 'sessions-open' : 'sessions-closed'} + {shell.sharedSessionId ?? 'no-shared-session'}
); } @@ -55,6 +56,13 @@ describe('LibrarySessionShareBoot', () => { expect(getByText('sessions-open')).toBeInTheDocument(); }); + it('opens shared-session detail from the query fallback without a router', () => { + window.history.replaceState(null, '', '/?view=shared-session&sessionId=sess-1'); + const { getByText } = renderBoot(); + expect(getByText('sessions-open')).toBeInTheDocument(); + expect(getByText('sess-1')).toBeInTheDocument(); + }); + it('ignores ?view=sessions when sessions port is missing', () => { window.history.replaceState(null, '', '/?view=sessions'); const { getByText } = renderBoot({ includeSessions: false }); diff --git a/packages/trueforge-ui/test/routing/derivePlace.test.ts b/packages/trueforge-ui/test/routing/derivePlace.test.ts index 01587c7e2..e8c924652 100644 --- a/packages/trueforge-ui/test/routing/derivePlace.test.ts +++ b/packages/trueforge-ui/test/routing/derivePlace.test.ts @@ -8,6 +8,7 @@ function snap(partial: Partial): ShellSnapshot { settingsOpen: false, libraryOpen: false, sessionsOpen: false, + sharedSessionId: null, libraryAgentId: null, schedulesOpen: false, mode: { status: 'idle' }, @@ -29,6 +30,13 @@ describe('derivePlace', () => { expect(derivePlace(snap({ sessionsOpen: true, pendingSessionId: 'abc' }))).toEqual({ type: 'sessionsBrowser' }); }); + it('shared session wins over the sessions browser', () => { + expect(derivePlace(snap({ sessionsOpen: true, sharedSessionId: 'shared-1' }))).toEqual({ + type: 'sharedSession', + sessionId: 'shared-1', + }); + }); + it('library agent detail wins over the library list and chat place', () => { expect(derivePlace(snap({ libraryOpen: true, libraryAgentId: 'agent-1', pendingSessionId: 'abc' }))).toEqual({ type: 'libraryAgent', diff --git a/packages/trueforge-ui/test/routing/paths.test.ts b/packages/trueforge-ui/test/routing/paths.test.ts index 9ccd59796..c317bb34d 100644 --- a/packages/trueforge-ui/test/routing/paths.test.ts +++ b/packages/trueforge-ui/test/routing/paths.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { buildPath, buildSessionResumeHref, + buildSharedSessionHref, matchLocation, matchPath, placesEqual, @@ -22,6 +23,7 @@ describe('resolveRoutesConfig', () => { buildAgent: '/build-agent', agent: '/agents/:agentName', session: '/sessions/:sessionId', + sharedSession: '/sessions/share/:sessionId', sessionsBrowser: '/sessions', }); }); @@ -36,6 +38,7 @@ describe('resolveRoutesConfig', () => { expect(resolved.settings).toBeNull(); expect(resolved.agent).toBe('/a/:agentName'); expect(resolved.session).toBe('/sessions/:sessionId'); + expect(resolved.sharedSession).toBe('/sessions/share/:sessionId'); }); it('normalizes trailing slashes but keeps root', () => { @@ -57,6 +60,7 @@ describe('buildPath', () => { expect(buildPath({ type: 'buildAgent' }, routes)).toBe('/build-agent'); expect(buildPath({ type: 'agent', agentName: 'code-helper' }, routes)).toBe('/agents/code-helper'); expect(buildPath({ type: 'session', sessionId: 'abc123' }, routes)).toBe('/sessions/abc123'); + expect(buildPath({ type: 'sharedSession', sessionId: 'abc123' }, routes)).toBe('/sessions/share/abc123'); expect(buildPath({ type: 'sessionsBrowser' }, routes)).toBe('/sessions'); }); @@ -84,6 +88,24 @@ describe('buildPath', () => { }), ).toBe('https://app.example/trueforge/sessions/sess-1?theme=dark'); }); + + it('builds routed and query-fallback shared-session hrefs', () => { + const withBasename = resolveRoutesConfig({ basename: '/trueforge' }); + expect( + buildSharedSessionHref({ + sessionId: 'sess/1', + routes: withBasename, + href: 'https://app.example/trueforge/sessions?view=sessions&theme=dark', + }), + ).toBe('https://app.example/trueforge/sessions/share/sess%2F1?theme=dark'); + expect( + buildSharedSessionHref({ + sessionId: 'sess-1', + routes: null, + href: 'https://app.example/embed?view=sessions&agentId=agent-1&s_tw=30&theme=dark', + }), + ).toBe('https://app.example/embed?view=shared-session&theme=dark&sessionId=sess-1'); + }); }); describe('sanitizeSearchForPlace', () => { @@ -93,6 +115,7 @@ describe('sanitizeSearchForPlace', () => { expect(sanitizeSearchForPlace({ type: 'library' }, sessionSearch)).toBe('?theme=dark'); expect(sanitizeSearchForPlace({ type: 'root' }, sessionSearch)).toBe('?theme=dark'); expect(sanitizeSearchForPlace({ type: 'session', sessionId: 'sess-2' }, sessionSearch)).toBe('?theme=dark'); + expect(sanitizeSearchForPlace({ type: 'sharedSession', sessionId: 'sess-2' }, sessionSearch)).toBe('?theme=dark'); }); it('keeps only the query state owned by the destination place', () => { @@ -137,6 +160,7 @@ describe('matchPath', () => { expect(matchPath('/agents/a%2Fb', routes)).toEqual({ type: 'agent', agentName: 'a/b' }); expect(matchPath('/sessions', routes)).toEqual({ type: 'sessionsBrowser' }); expect(matchPath('/sessions/xyz', routes)).toEqual({ type: 'session', sessionId: 'xyz' }); + expect(matchPath('/sessions/share/xyz', routes)).toEqual({ type: 'sharedSession', sessionId: 'xyz' }); }); it('returns null for unknown paths', () => { @@ -166,6 +190,7 @@ describe('matchPath', () => { { type: 'buildAgent' as const }, { type: 'agent' as const, agentName: 'weird name/1' }, { type: 'session' as const, sessionId: 'sess 9' }, + { type: 'sharedSession' as const, sessionId: 'sess 9' }, { type: 'sessionsBrowser' as const }, ]) { const path = buildPath(place, routes); @@ -194,6 +219,16 @@ describe('matchLocation', () => { type: 'sessionsBrowser', }); }); + + it('opens a shared session from the no-router query form on the root path', () => { + expect( + matchLocation({ + pathname: '/', + search: '?view=shared-session&sessionId=sess-1', + routes, + }), + ).toEqual({ type: 'sharedSession', sessionId: 'sess-1' }); + }); }); describe('placesEqual', () => { @@ -203,5 +238,8 @@ describe('placesEqual', () => { expect(placesEqual({ type: 'agent', agentName: 'a' }, { type: 'agent', agentName: 'b' })).toBe(false); expect(placesEqual({ type: 'libraryAgent', agentId: 'a' }, { type: 'libraryAgent', agentId: 'a' })).toBe(true); expect(placesEqual({ type: 'session', sessionId: '1' }, { type: 'root' })).toBe(false); + expect(placesEqual({ type: 'sharedSession', sessionId: '1' }, { type: 'sharedSession', sessionId: '1' })).toBe( + true, + ); }); }); diff --git a/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx b/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx index 4000f0644..f80ec7615 100644 --- a/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx +++ b/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx @@ -54,6 +54,7 @@ vi.mock('@truefoundry/trueforge-assistant-ui-runtime', () => ({ useTrueForgeUpdateAgentSpec: () => vi.fn(), })); +import { SessionsPage } from '@/atoms/agent-details/SessionsPage.js'; import { CompactLayoutProvider } from '@/atoms/lib/CompactLayoutContext.js'; import { SessionsBrowserButton } from '@/atoms/SessionsBrowserButton.js'; import type { ThreadListRowProps } from '@/atoms/ThreadListRow.js'; @@ -91,6 +92,7 @@ function ShellProbe() { return ( <>
{shell.pendingSessionId ?? 'none'}
+
{shell.sharedSessionId ?? 'none'}
{mode.status === 'idle' ? 'idle' : `${mode.isMutable ? 'mutable' : 'immutable'}:${mode.agentName ?? '-'}`}
@@ -106,6 +108,11 @@ function ThreadListHost() { ); } +function SessionsSurface() { + const shell = useShellMode(); + return shell.sessionsOpen ? : ; +} + function renderApp() { return render( { + it('opens /sessions/share/:id as shared-session detail', async () => { + window.history.replaceState(null, '', '/sessions/share/session-2'); + render( + } + />, + ); + + await waitFor(() => { + expect(screen.getByTestId('shared-session')).toHaveTextContent('session-2'); + }); + expect(screen.getByTestId('pending')).toHaveTextContent('none'); + expect(window.location.pathname).toBe('/sessions/share/session-2'); + }); + + it('renders shared-session detail without the list, resizer, or filters', async () => { + window.history.replaceState(null, '', '/sessions/share/session-2'); + const listSessions = vi.fn(async () => ({ data: [] })); + render( + ({ + id: 'session-2', + title: 'Shared session', + isMutable: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }), + sessions: createMockAgentSessionsServer({ + listSessions, + listSessionEvents: async () => ({ data: [] }), + }), + })} + agentConfig={{ mode: 'AgentLibraryWithComposer' }} + withRouter + layout={() => } + overrides={{ AgentSessionTimelineContainer: () =>
Timeline
}} + />, + ); + + expect(await screen.findByRole('heading', { name: 'Shared session' })).toBeInTheDocument(); + expect(screen.queryByRole('separator', { name: 'Resize session list' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Last 30 days' })).not.toBeInTheDocument(); + expect(listSessions).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Close session details' })); + await waitFor(() => { + expect(window.location.pathname).toBe('/sessions'); + }); + expect(await screen.findByRole('button', { name: 'Last 30 days' })).toBeInTheDocument(); + expect(listSessions).toHaveBeenCalled(); + }); + it('applies a /sessions/:id deep link on boot', async () => { window.history.replaceState(null, '', '/sessions/session-2'); renderApp(); diff --git a/packages/trueforge-ui/test/server/serverChrome.test.ts b/packages/trueforge-ui/test/server/serverChrome.test.ts index 428cc1714..195df9b34 100644 --- a/packages/trueforge-ui/test/server/serverChrome.test.ts +++ b/packages/trueforge-ui/test/server/serverChrome.test.ts @@ -59,6 +59,7 @@ describe('serverChrome', () => { }); expect(disabled.settings).toBeNull(); expect(disabled.sessionsBrowser).toBeNull(); + expect(disabled.sharedSession).toBeNull(); expect(disabled.libraryAgent).toBeNull(); expect(disabled.schedules).toBeNull(); expect(disabled.root).toBe('/'); @@ -73,6 +74,7 @@ describe('serverChrome', () => { }); expect(enabled.settings).toBe('/settings'); expect(enabled.sessionsBrowser).toBe('/sessions'); + expect(enabled.sharedSession).toBe('/sessions/share/:sessionId'); expect(enabled.libraryAgent).toBe('/library/:agentId'); expect(enabled.schedules).toBe('/schedules'); }); diff --git a/packages/trueforge-ui/test/utils/sessionShareUrl.test.ts b/packages/trueforge-ui/test/utils/sessionShareUrl.test.ts index cb8b2d10c..f8999c365 100644 --- a/packages/trueforge-ui/test/utils/sessionShareUrl.test.ts +++ b/packages/trueforge-ui/test/utils/sessionShareUrl.test.ts @@ -38,6 +38,12 @@ describe('sessionShareUrl', () => { ); }); + it('reads the detail-only shared-session view', () => { + const share = readSessionShareSearch('?view=shared-session&sessionId=sess-1'); + assert.equal(share.view, 'shared-session'); + assert.equal(share.sessionId, 'sess-1'); + }); + it('reads an absolute pinned time range', () => { assert.deepEqual(readSessionShareSearch('?s_sts=1000&s_ets=2000'), { sessionId: null, From e93796469effeb8f2f23e1ae1b52cbdb54298308 Mon Sep 17 00:00:00 2001 From: Harshil Vasoya Date: Wed, 23 Sep 2026 18:13:33 +0530 Subject: [PATCH 2/6] feat: enhance session sharing functionality with new ShareChatButton and updated ClearChatButton --- .changeset/session-share-route.md | 2 +- packages/trueforge-ui/docs/customization.md | 2 +- .../src/atoms/ClearChatButton.tsx | 8 +-- .../src/atoms/ShareChatButton.tsx | 31 ++++++++++ .../trueforge-ui/src/atoms/ThreadShell.tsx | 6 +- .../src/atoms/agent-details/AgentSessions.tsx | 1 + .../src/atoms/agent-details/SessionsPage.tsx | 26 +------- .../AgentSessionTimelineContainer.tsx | 14 ++++- .../src/hooks/useCopySharedSessionLink.ts | 32 ++++++++++ packages/trueforge-ui/src/index.ts | 1 + .../trueforge-ui/src/layouts/DrawerLayout.tsx | 6 +- .../src/layouts/SidebarLayout.tsx | 2 + .../src/layouts/StackChatPanel.tsx | 6 +- .../trueforge-ui/src/theme/defaultSlots.ts | 2 + .../test/atoms/ClearChatButton.test.tsx | 12 ++-- .../test/atoms/ShareChatButton.test.tsx | 60 +++++++++++++++++++ .../test/atoms/ThreadShell.test.tsx | 6 ++ .../test/containers/TrueForgeUI.test.tsx | 17 +++++- .../trueforge-ui/test/publicUiExports.test.ts | 1 + .../routing/withRouter.integration.test.tsx | 7 ++- 20 files changed, 196 insertions(+), 46 deletions(-) create mode 100644 packages/trueforge-ui/src/atoms/ShareChatButton.tsx create mode 100644 packages/trueforge-ui/src/hooks/useCopySharedSessionLink.ts create mode 100644 packages/trueforge-ui/test/atoms/ShareChatButton.test.tsx diff --git a/.changeset/session-share-route.md b/.changeset/session-share-route.md index 1517661ab..2bf2bb1dc 100644 --- a/.changeset/session-share-route.md +++ b/.changeset/session-share-route.md @@ -2,4 +2,4 @@ "@truefoundry/trueforge-ui": minor --- -Add a detail-only session sharing route with a query-string fallback for hosts that manage routing. +Add detail-only session sharing routes, chat-header share links, and rename the active-chat reset action to New Chat. diff --git a/packages/trueforge-ui/docs/customization.md b/packages/trueforge-ui/docs/customization.md index 92967a9eb..d0d16a1f7 100644 --- a/packages/trueforge-ui/docs/customization.md +++ b/packages/trueforge-ui/docs/customization.md @@ -65,7 +65,7 @@ Public override surface (primitives stay theme/CSS — not slots): - **Thread list:** `ThreadListShell`, `ThreadListNewButton`, `ThreadListRow`, `ThreadListRowSkeleton`, `ThreadListEmptyState`, `HistoryLoader`, `AgentsLibrary`, `AgentsLibraryButton`, `SessionsBrowserButton`, - `SaveAgentButton`, `SelectAgentEmptyState`, `ClearChatButton` + `SaveAgentButton`, `SelectAgentEmptyState`, `ClearChatButton`, `ShareChatButton` - **Agent details / sessions:** `AgentDetailsPage`, `AgentDetailsHeader`, `AgentDetailsTabs`, `AgentDetailsUnavailable`, `AgentOverview`, `AgentOverviewCard`, `AgentSessions`, `AgentSessionsFilters`, `SessionsPage`, diff --git a/packages/trueforge-ui/src/atoms/ClearChatButton.tsx b/packages/trueforge-ui/src/atoms/ClearChatButton.tsx index 20a09088d..ff5bc5783 100644 --- a/packages/trueforge-ui/src/atoms/ClearChatButton.tsx +++ b/packages/trueforge-ui/src/atoms/ClearChatButton.tsx @@ -7,7 +7,7 @@ import { useOptionalShellMode } from '../server/ShellModeContext.js'; import { useSlot } from '../theme/SlotsProvider.js'; import { auiButtonClass } from './lib/buttonClasses.js'; -// Resets the current chat / draft (Try Agent, New Chat, New Agent, Edit). +// Starts a fresh chat / draft (Try Agent, New Chat, New Agent, Edit). // Hidden while idle and on a fresh chat. export function ClearChatButton() { const shell = useOptionalShellMode(); @@ -21,14 +21,14 @@ export function ClearChatButton() { ); diff --git a/packages/trueforge-ui/src/atoms/ShareChatButton.tsx b/packages/trueforge-ui/src/atoms/ShareChatButton.tsx new file mode 100644 index 000000000..b364a1ff2 --- /dev/null +++ b/packages/trueforge-ui/src/atoms/ShareChatButton.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { useAuiState } from '../assistant-ui.js'; +import { useCopySharedSessionLink } from '../hooks/useCopySharedSessionLink.js'; +import { Icon } from '../icons/Icon.js'; +import { auiButtonClass } from './lib/buttonClasses.js'; + +export function ShareChatButton() { + const sessionId = useAuiState(state => state.threadListItem.remoteId); + const { copied, copySharedSessionLink } = useCopySharedSessionLink(sessionId); + + if (sessionId == null) return null; + + return ( + + ); +} + +declare module '../theme/SlotsProvider.js' { + interface AtomSlots { + ShareChatButton: typeof ShareChatButton; + } +} diff --git a/packages/trueforge-ui/src/atoms/ThreadShell.tsx b/packages/trueforge-ui/src/atoms/ThreadShell.tsx index e9b8369bb..31d283ff0 100644 --- a/packages/trueforge-ui/src/atoms/ThreadShell.tsx +++ b/packages/trueforge-ui/src/atoms/ThreadShell.tsx @@ -31,10 +31,12 @@ export type ThreadViewportShellProps = ComponentPropsWithRef<'div'> & { isEmpty?: boolean; /** Disable the inner scroll root when an ancestor owns the combined surface scroll. */ scrollable?: boolean; + /** Override the conversation content width without changing the scroll root width. */ + contentMaxWidth?: string; }; export const ThreadViewportShell = forwardRef( - ({ className, isEmpty, scrollable = true, children, ...rest }, ref) => ( + ({ className, isEmpty, scrollable = true, contentMaxWidth, children, ...rest }, ref) => (
{children}
diff --git a/packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx b/packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx index b2a59916b..e9f95f41a 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx +++ b/packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx @@ -323,6 +323,7 @@ export function AgentSessions({ sessionId={selectedSessionId} events={detailEvents} listMetrics={selectedEntry?.metrics} + {...(detailOnly ? { contentMaxWidth: '60rem' } : {})} /> )} diff --git a/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx b/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx index b14f5a6b5..a942889bd 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx +++ b/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx @@ -2,10 +2,9 @@ import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { useCopySharedSessionLink } from '../../hooks/useCopySharedSessionLink.js'; import { useSessionShareSearch } from '../../hooks/useSessionShareSearch.js'; import { Icon } from '../../icons/Icon.js'; -import { buildSharedSessionHref } from '../../routing/paths.js'; -import { useOptionalResolvedRoutes } from '../../routing/ResolvedRoutesContext.js'; import { useOptionalAgentSessionsServer } from '../../server/ServerContext.js'; import { useShellMode } from '../../server/ShellModeContext.js'; import { useSlot } from '../../theme/SlotsProvider.js'; @@ -23,13 +22,12 @@ import { Skeleton } from '../primitives/Skeleton.js'; export function SessionsPage() { const sessionsServer = useOptionalAgentSessionsServer(); const shell = useShellMode(); - const routes = useOptionalResolvedRoutes(); const { sessionId, updateShareSearch } = useSessionShareSearch(); const AgentSessions = useSlot('AgentSessions'); const AgentSessionsFilters = useSlot('AgentSessionsFilters'); const sharedSessionId = shell.sharedSessionId; const selectedSessionId = sharedSessionId ?? sessionId; - const [copied, setCopied] = useState(false); + const { copied, copySharedSessionLink } = useCopySharedSessionLink(selectedSessionId); const [agentFilter, setAgentFilter] = useState( () => readSessionShareSearch(window.location.search).agentId, @@ -49,12 +47,6 @@ export function SessionsPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - useEffect(() => { - if (!copied) return undefined; - const timer = window.setTimeout(() => setCopied(false), 2000); - return () => window.clearTimeout(timer); - }, [copied]); - useEffect(() => { const syncFilters = () => { const share = readSessionShareSearch(window.location.search); @@ -68,20 +60,6 @@ export function SessionsPage() { // Resolve relative presets only when the filter changes. Unrelated query // updates (such as selecting a session) must not shift/refetch the list. const resolved = useMemo(() => resolveSessionTimeRange(timeRange), [timeRange]); - const copySharedSessionLink = async () => { - if (selectedSessionId == null) return; - try { - await navigator.clipboard.writeText( - buildSharedSessionHref({ - sessionId: selectedSessionId, - routes, - }), - ); - setCopied(true); - } catch { - // Clipboard access depends on the host browser and document permissions. - } - }; const timeRangeDurationMs = timeRange.endTs - timeRange.startTs; const showLoadRecentSessions = timeRange.timeWindowMs == null && timeRangeDurationMs > 0 && timeRangeDurationMs <= 2 * SESSION_TIME_BUFFER_MS; diff --git a/packages/trueforge-ui/src/containers/AgentSessionTimelineContainer.tsx b/packages/trueforge-ui/src/containers/AgentSessionTimelineContainer.tsx index 72c325091..800826663 100644 --- a/packages/trueforge-ui/src/containers/AgentSessionTimelineContainer.tsx +++ b/packages/trueforge-ui/src/containers/AgentSessionTimelineContainer.tsx @@ -125,6 +125,7 @@ function messagesForTurn(messages: ThreadMessageLike[], turn: SessionTurnView): export type AgentSessionTimelineContainerProps = { sessionId: string; events: SessionEventItem[]; + contentMaxWidth?: string; listMetrics?: { totalTurns: number; totalCostInUsd?: number; @@ -132,7 +133,12 @@ export type AgentSessionTimelineContainerProps = { }; }; -export function AgentSessionTimelineContainer({ sessionId, events, listMetrics }: AgentSessionTimelineContainerProps) { +export function AgentSessionTimelineContainer({ + sessionId, + events, + contentMaxWidth, + listMetrics, +}: AgentSessionTimelineContainerProps) { const server = useServer(); const AgentSessionTurnHeader = useSlot('AgentSessionTurnHeader'); const AgentSessionEventTimeline = useSlot('AgentSessionEventTimeline'); @@ -219,7 +225,11 @@ export function AgentSessionTimelineContainer({ sessionId, events, listMetrics }
- +
{turnViews.map(turn => ( Promise; +} { + const routes = useOptionalResolvedRoutes(); + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!copied) return undefined; + const timer = window.setTimeout(() => setCopied(false), 2000); + return () => window.clearTimeout(timer); + }, [copied]); + + const copySharedSessionLink = useCallback(async () => { + if (sessionId == null) return; + try { + await navigator.clipboard.writeText(buildSharedSessionHref({ sessionId, routes })); + setCopied(true); + } catch { + // Clipboard access depends on the host browser and document permissions. + } + }, [routes, sessionId]); + + return { copied, copySharedSessionLink }; +} diff --git a/packages/trueforge-ui/src/index.ts b/packages/trueforge-ui/src/index.ts index 2df5d850a..9dda3750d 100644 --- a/packages/trueforge-ui/src/index.ts +++ b/packages/trueforge-ui/src/index.ts @@ -292,6 +292,7 @@ export { sessionIsCreateAgent, } from './atoms/lib/sessionCreateAgent.js'; export { SelectAgentEmptyState } from './atoms/SelectAgentEmptyState.js'; +export { ShareChatButton } from './atoms/ShareChatButton.js'; export { ShellActionsActionSlot } from './atoms/ShellActionsActionSlot.js'; export { createTrueForgeServer } from './server/createTrueForgeServer.js'; export type { CreateTrueForgeServerOptions, TrueForgeServer } from './server/createTrueForgeServer.js'; diff --git a/packages/trueforge-ui/src/layouts/DrawerLayout.tsx b/packages/trueforge-ui/src/layouts/DrawerLayout.tsx index 0eed6f105..e7be20592 100644 --- a/packages/trueforge-ui/src/layouts/DrawerLayout.tsx +++ b/packages/trueforge-ui/src/layouts/DrawerLayout.tsx @@ -12,6 +12,7 @@ import { useIsMobile } from '../atoms/lib/useIsMobile.js'; import { Spinner } from '../atoms/primitives/Spinner.js'; import { AgentConfigDrawerContainer } from '../containers/AgentConfigDrawerContainer.js'; import { Thread } from '../containers/Thread.js'; +import { useChatChromeActionsVisible } from '../hooks/useChatChromeActionsVisible.js'; import { Icon } from '../icons/Icon.js'; import { shellIsCreateAgent, useOptionalShellMode } from '../server/ShellModeContext.js'; import { useSlot } from '../theme/SlotsProvider.js'; @@ -29,6 +30,7 @@ export function DrawerLayout({ className }: { className?: string }) { const AgentDetailsPage = useSlot('AgentDetailsPage'); const AgentsLibrary = useSlot('AgentsLibrary'); const SessionsPage = useSlot('SessionsPage'); + const ShareChatButton = useSlot('ShareChatButton'); const SaveAgentButton = useSlot('SaveAgentButton'); const SelectAgentEmptyState = useSlot('SelectAgentEmptyState'); const UserAvatar = useSlot('UserAvatar'); @@ -39,6 +41,7 @@ export function DrawerLayout({ className }: { className?: string }) { const sessionsOpen = shell?.sessionsOpen === true; const schedulesOpen = shell?.schedulesOpen === true; const overlayOpen = settingsOpen || libraryOpen || sessionsOpen || schedulesOpen; + const chatChromeActionsVisible = useChatChromeActionsVisible(); const showAgentConfig = shell != null && shellIsCreateAgent(shell.mode) && !overlayOpen && (!isMobile || shell.agentConfigOpen); const showNewActions = shell?.isNewChatEnabled !== false; @@ -100,6 +103,7 @@ export function DrawerLayout({ className }: { className?: string }) { <> {!overlayOpen ? ( <> + @@ -108,7 +112,7 @@ export function DrawerLayout({ className }: { className?: string }) { {!overlayOpen ? ( <> - {showNewActions ? ( + {showNewActions && !chatChromeActionsVisible ? ( ; } + function CustomShareChat() { + return ; + } + function CustomSaveAgent() { return ; } @@ -807,12 +811,17 @@ describe('layout slot overrides', () => { ); expect(screen.getByRole('button', { name: 'custom clear' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Clear chat' })).not.toBeInTheDocument(); }); - it.each(hosts)('%s places Clear Chat immediately before Save Agent', (_name, Layout) => { + it.each(hosts)('%s places Share, New Chat, and Save Agent in order', (_name, Layout) => { render( - +
@@ -823,8 +832,10 @@ describe('layout slot overrides', () => { , ); + const shareChat = screen.getByRole('button', { name: 'custom share' }); const clearChat = screen.getByRole('button', { name: 'custom clear' }); const saveAgent = screen.getByRole('button', { name: 'custom save' }); + expect(shareChat.nextElementSibling).toBe(clearChat); expect(clearChat.nextElementSibling).toBe(saveAgent); }); diff --git a/packages/trueforge-ui/test/publicUiExports.test.ts b/packages/trueforge-ui/test/publicUiExports.test.ts index 7ed02616a..4d0670590 100644 --- a/packages/trueforge-ui/test/publicUiExports.test.ts +++ b/packages/trueforge-ui/test/publicUiExports.test.ts @@ -117,6 +117,7 @@ const expectedRuntimeExports: Array = [ 'ServerProvider', 'SessionsBrowserButton', 'SessionsPage', + 'ShareChatButton', 'ShellActionsActionSlot', 'ShellModeProvider', 'SideDrawer', diff --git a/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx b/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx index f80ec7615..e63a5d83a 100644 --- a/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx +++ b/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx @@ -165,13 +165,18 @@ describe('withRouter end to end', () => { agentConfig={{ mode: 'AgentLibraryWithComposer' }} withRouter layout={() => } - overrides={{ AgentSessionTimelineContainer: () =>
Timeline
}} + overrides={{ + AgentSessionTimelineContainer: ({ contentMaxWidth }) => ( +
{contentMaxWidth}
+ ), + }} />, ); expect(await screen.findByRole('heading', { name: 'Shared session' })).toBeInTheDocument(); expect(screen.queryByRole('separator', { name: 'Resize session list' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Last 30 days' })).not.toBeInTheDocument(); + expect(screen.getByTestId('shared-session-timeline')).toHaveTextContent('60rem'); expect(listSessions).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: 'Close session details' })); From 9935db1166f81fee011016f5624415096829cc28 Mon Sep 17 00:00:00 2001 From: Harshil Vasoya Date: Fri, 25 Sep 2026 10:36:57 +0530 Subject: [PATCH 3/6] feat: add session access error handling with toast notifications --- .changeset/session-access-denied-toast.md | 5 + .../src/atoms/agent-details/AgentSessions.tsx | 18 +++- .../src/containers/TrueForgeUIShell.tsx | 47 ++++---- .../src/routing/ShellRouteSync.tsx | 58 ++++++---- .../src/utils/sessionAccessError.ts | 16 +++ .../test/atoms/SessionsPage.test.tsx | 34 +++++- .../test/routing/ShellRouteSync.test.tsx | 101 ++++++++++++++++-- .../routing/withRouter.integration.test.tsx | 43 +++++++- .../test/utils/sessionAccessError.test.ts | 27 +++++ 9 files changed, 290 insertions(+), 59 deletions(-) create mode 100644 .changeset/session-access-denied-toast.md create mode 100644 packages/trueforge-ui/src/utils/sessionAccessError.ts create mode 100644 packages/trueforge-ui/test/utils/sessionAccessError.test.ts diff --git a/.changeset/session-access-denied-toast.md b/.changeset/session-access-denied-toast.md new file mode 100644 index 000000000..526cd0e72 --- /dev/null +++ b/.changeset/session-access-denied-toast.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge-ui": patch +--- + +Toast and redirect when a session deep link is forbidden or missing (New Chat for `/sessions/:id`, sessions list for share links). diff --git a/packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx b/packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx index e9f95f41a..b75aef6bd 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx +++ b/packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx @@ -14,6 +14,7 @@ import { useOptionalShellMode } from '../../server/ShellModeContext.js'; import type { Session, SessionEventItem, SessionListEntry } from '../../server/types.js'; import { useSlot } from '../../theme/SlotsProvider.js'; import { drainListPages } from '../../utils/drainListPages.js'; +import { reportSessionAccessError } from '../../utils/sessionAccessError.js'; import { sessionTimeRangeFromCreatedAt } from '../../utils/sessionShareUrl.js'; import { EmptyScreen } from '../EmptyScreen.js'; import { cn } from '../lib/cn.js'; @@ -203,15 +204,24 @@ export function AgentSessions({ ...(pageToken == null ? {} : { pageToken }), }), }), - chatServer.getSession({ sessionId: selectedSessionId }).catch(() => undefined), + chatServer.getSession({ sessionId: selectedSessionId }), ]) .then(([itemsNewestFirst, session]) => { if (cancelled) return; setDetailEvents([...itemsNewestFirst].reverse()); setDetailSession(session); }) - .catch(() => { - if (!cancelled) setDetailFailed(true); + .catch((error: unknown) => { + if (cancelled) return; + reportSessionAccessError({ + error, + ...(toaster != null ? { showError: toaster.showError } : {}), + }); + if (detailOnly) { + onCloseDetail?.(); + return; + } + setDetailFailed(true); }) .finally(() => { if (!cancelled) setDetailLoading(false); @@ -220,7 +230,7 @@ export function AgentSessions({ return () => { cancelled = true; }; - }, [chatServer, selectedSessionId, sessionsServer]); + }, [chatServer, detailOnly, onCloseDetail, selectedSessionId, sessionsServer, toaster]); const selectSession = (entry: SessionListEntry) => { const pinned = shareView === 'sessions' ? sessionTimeRangeFromCreatedAt(entry.createdAt) : null; diff --git a/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx b/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx index 8c7c74699..0b7043a18 100644 --- a/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx +++ b/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx @@ -29,6 +29,7 @@ import type { AgentUIServer, CreateSessionRequest } from '../server/types.js'; import { SlotsProvider, useThemeMode, type SlotOverrides } from '../theme/SlotsProvider.js'; import type { LayoutProp, ThemeConfig } from '../theme/types.js'; import { getErrorMessage } from '../utils/getErrorMessage.js'; +import { ToasterProvider } from './ToasterContainer.js'; import { TrueForgeChatProvider, type TrueForgeChatProviderProps } from './TrueForgeChatProvider.js'; import { useResolvedServer } from './useResolvedServer.js'; @@ -318,27 +319,33 @@ export function TrueForgeUIShell(props: TrueForgeUIShellProps) { const server = resolved.server; const layoutTree = ; + // Outer toaster so ShellRouteSync (sibling of chat provider) can toast access-denied + // deep links; nested ToasterProvider inside TrueForgeChatProvider stays for hosts + // that mount chat alone. const shellTree = ( - - - {resolvedRoutes != null ? ( - - - - ) : null} - - {layoutTree} - - + + + + {resolvedRoutes != null ? ( + + + + ) : null} + + {layoutTree} + + + ); // Widget visibility provider is used to control the visibility of the widget with isolated state const visibilityTree = diff --git a/packages/trueforge-ui/src/routing/ShellRouteSync.tsx b/packages/trueforge-ui/src/routing/ShellRouteSync.tsx index 61940354e..3c445fb22 100644 --- a/packages/trueforge-ui/src/routing/ShellRouteSync.tsx +++ b/packages/trueforge-ui/src/routing/ShellRouteSync.tsx @@ -5,6 +5,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { sessionIsCreateAgent } from '../atoms/lib/sessionCreateAgent.js'; import { findAgentByName } from '../atoms/lib/useSearchAgentsList.js'; +import { useToasterOptional } from '../containers/ToasterContainer.js'; import { useOptionalAgentSessionsServer, useOptionalCatalogServer, @@ -20,6 +21,7 @@ import { updateHistoryAgentSearch, type HistoryAgentSearch, } from '../utils/historyAgentSearch.js'; +import { reportSessionAccessError } from '../utils/sessionAccessError.js'; import { deriveChatPlace, derivePlace } from './derivePlace.js'; import { buildPath, matchLocation, placesEqual, sanitizeSearchForPlace } from './paths.js'; import type { ResolvedRoutes, RoutePlace, ShellSnapshot } from './types.js'; @@ -41,10 +43,12 @@ export function ShellRouteSync({ routes, activeRemoteId, initialSettingsOpen, + onError, }: { routes: ResolvedRoutes; activeRemoteId: string | undefined; initialSettingsOpen: boolean; + onError?: (error: unknown) => void; }) { const shell = useShellMode(); const server = useOptionalServer(); @@ -53,6 +57,7 @@ export function ShellRouteSync({ const schedules = useOptionalScheduleServer(); const capabilities = useServerCapabilities(); const capabilitiesSettled = useServerCapabilitiesSettled(); + const toaster = useToasterOptional(); const navigate = useNavigate(); const location = useLocation(); // Same gates as sidebar chrome: missing optional ports unregister their paths. @@ -107,7 +112,26 @@ export function ShellRouteSync({ * 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. + * Access denied / missing session: toast, then New Chat (do not bind the id). */ + const goToRoot = useCallback(() => { + 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]); + const openSession = useCallback( (sessionId: string) => { requestedSessionRef.current = sessionId; @@ -126,13 +150,20 @@ export function ShellRouteSync({ ...(session.agentName != null ? { agentName: session.agentName } : {}), }); }) - .catch(() => { + .catch((error: unknown) => { if (requestedSessionRef.current !== sessionId) return; - // Unreachable session: bind by id alone rather than stranding the shell. - shell.openHistorySession({ sessionId }); + requestedSessionRef.current = null; + // Drop boot's "wait until place matches URL" so shell→URL can leave /sessions/:id. + bootPlaceRef.current = null; + reportSessionAccessError({ + error, + ...(onError != null ? { onError } : {}), + ...(toaster != null ? { showError: toaster.showError } : {}), + }); + goToRoot(); }); }, - [server, shell], + [goToRoot, onError, server, shell, toaster], ); const applyHistoryAgentSearch = useCallback( @@ -223,24 +254,11 @@ export function ShellRouteSync({ 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; - } + goToRoot(); + return; } }, - [shell, activeRemoteId, openAgent, openSession], + [shell, activeRemoteId, goToRoot, openAgent, openSession], ); // Boot: URL wins, except an explicit `initialSettingsOpen` overlay. Boot is the diff --git a/packages/trueforge-ui/src/utils/sessionAccessError.ts b/packages/trueforge-ui/src/utils/sessionAccessError.ts new file mode 100644 index 000000000..bd7cfa60b --- /dev/null +++ b/packages/trueforge-ui/src/utils/sessionAccessError.ts @@ -0,0 +1,16 @@ +/** + * Report a failed session open. Prefer host `onError` when provided; fall back + * to toaster `showError`. The backend message is shown as-is. + */ +export function reportSessionAccessError({ + error, + onError, + showError, +}: { + error: unknown; + onError?: (error: unknown) => void; + showError?: (error: unknown) => void; +}): void { + const report = onError ?? showError; + report?.(error); +} diff --git a/packages/trueforge-ui/test/atoms/SessionsPage.test.tsx b/packages/trueforge-ui/test/atoms/SessionsPage.test.tsx index f6b910536..9ff449407 100644 --- a/packages/trueforge-ui/test/atoms/SessionsPage.test.tsx +++ b/packages/trueforge-ui/test/atoms/SessionsPage.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SessionsPage } from '@/atoms/agent-details/SessionsPage.js'; +import { ToasterProvider } from '@/containers/ToasterContainer.js'; import { ServerProvider } from '@/server/ServerContext.js'; import { ShellModeProvider } from '@/server/ShellModeContext.js'; import type { @@ -118,11 +119,13 @@ function renderPage({ }); render( - - - - - + + + + + + + , ); return { listSessions, listSessionEvents, getSession, deleteSession }; @@ -226,6 +229,27 @@ describe('SessionsPage', () => { expect(getSession).toHaveBeenCalledWith({ sessionId: 'sess-1' }); }); + it('toasts and keeps the inline failure when a non-share detail load is forbidden', async () => { + window.history.replaceState(null, '', '/?view=sessions&sessionId=sess-1'); + const forbidden = Object.assign(new Error('Only the session creator can access this session'), { + statusCode: 403, + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + renderPage({ + listSessions: vi.fn(async () => ({ data: [namedRow] })), + listSessionEvents: vi.fn(async () => { + throw forbidden; + }), + getSession: vi.fn(async () => { + throw forbidden; + }), + }); + + expect(await screen.findByText('Session details could not be loaded.')).toBeInTheDocument(); + expect(await screen.findByText('Only the session creator can access this session')).toBeInTheDocument(); + expect(window.location.search).toContain('sessionId=sess-1'); + }); + it('copies the no-router shared-session query URL for the selected session', async () => { window.history.replaceState(null, '', '/?view=sessions&sessionId=sess-1&agentId=agent-1&s_tw=30'); renderPage(); diff --git a/packages/trueforge-ui/test/routing/ShellRouteSync.test.tsx b/packages/trueforge-ui/test/routing/ShellRouteSync.test.tsx index a17b601ca..c41e16ee2 100644 --- a/packages/trueforge-ui/test/routing/ShellRouteSync.test.tsx +++ b/packages/trueforge-ui/test/routing/ShellRouteSync.test.tsx @@ -2,7 +2,7 @@ import { act, render, waitFor } from '@testing-library/react'; import { StrictMode, useEffect, useState, type ReactNode } from 'react'; import { MemoryRouter, useLocation } from 'react-router-dom'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { resolveRoutesConfig } from '@/routing/paths.js'; import { ShellRouteSync } from '@/routing/ShellRouteSync.js'; @@ -47,6 +47,7 @@ function SettingsCatalogProvider({ includeCatalog = true, includeSessions = true, includeSchedules = false, + getSession, }: { children: ReactNode; settingsEnabled?: boolean; @@ -54,6 +55,14 @@ function SettingsCatalogProvider({ includeCatalog?: boolean; includeSessions?: boolean; includeSchedules?: boolean; + getSession?: (req: { sessionId: string }) => Promise<{ + id: string; + title: string; + isMutable: boolean; + createdAt: string; + updatedAt: string; + agentName?: string; + }>; }) { const server = createMockAgentUIServer({ ...(includeCatalog ? { catalog: createMockCatalog() } : {}), @@ -69,13 +78,15 @@ function SettingsCatalogProvider({ }, }; }, - getSession: async ({ sessionId }) => ({ - id: sessionId, - title: 'Session', - isMutable: true, - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - }), + getSession: + getSession ?? + (async ({ sessionId }) => ({ + id: sessionId, + title: 'Session', + isMutable: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + })), // findAgentByName walks unfiltered pages and matches by exact name client-side. searchAgents: async () => [{ name: 'helper', agentId: 'helper-id' }], }); @@ -91,6 +102,8 @@ function Harness({ includeCatalog = true, includeSessions = true, includeSchedules = false, + getSession, + onError, }: { agentConfig?: AgentConfig; initialRemoteId?: string; @@ -100,6 +113,15 @@ function Harness({ includeCatalog?: boolean; includeSessions?: boolean; includeSchedules?: boolean; + getSession?: (req: { sessionId: string }) => Promise<{ + id: string; + title: string; + isMutable: boolean; + createdAt: string; + updatedAt: string; + agentName?: string; + }>; + onError?: (error: unknown) => void; }) { const [remoteId, setId] = useState(initialRemoteId); setRemoteId = setId; @@ -110,11 +132,17 @@ function Harness({ includeCatalog={includeCatalog} includeSessions={includeSessions} includeSchedules={includeSchedules} + getSession={getSession} > - + ); @@ -129,6 +157,15 @@ function renderSync(opts: { includeCatalog?: boolean; includeSessions?: boolean; includeSchedules?: boolean; + getSession?: (req: { sessionId: string }) => Promise<{ + id: string; + title: string; + isMutable: boolean; + createdAt: string; + updatedAt: string; + agentName?: string; + }>; + onError?: (error: unknown) => void; strict?: boolean; }) { const tree = ( @@ -141,6 +178,8 @@ function renderSync(opts: { includeCatalog={opts.includeCatalog} includeSessions={opts.includeSessions} includeSchedules={opts.includeSchedules} + getSession={opts.getSession} + onError={opts.onError} /> ); @@ -154,6 +193,50 @@ describe('ShellRouteSync', () => { expect(pathname).toBe('/sessions/abc'); }); + it('toasts and redirects to New Chat when a session deep link is forbidden', async () => { + const onError = vi.fn(); + const forbidden = Object.assign(new Error('Only the session creator can access this session'), { + statusCode: 403, + }); + renderSync({ + initialEntries: ['/sessions/forbidden'], + getSession: async () => { + throw forbidden; + }, + onError, + }); + + await waitFor(() => { + expect(onError).toHaveBeenCalledOnce(); + }); + expect(onError).toHaveBeenCalledWith(forbidden); + await waitFor(() => { + expect(pathname).toBe('/'); + }); + expect(shell.pendingSessionId).toBeUndefined(); + }); + + it('toasts and redirects to New Chat when a session deep link is not found', async () => { + const onError = vi.fn(); + const notFound = Object.assign(new Error('Session not found: missing'), { statusCode: 404 }); + renderSync({ + initialEntries: ['/sessions/missing'], + getSession: async () => { + throw notFound; + }, + onError, + }); + + await waitFor(() => { + expect(onError).toHaveBeenCalledOnce(); + }); + expect(onError).toHaveBeenCalledWith(notFound); + await waitFor(() => { + expect(pathname).toBe('/'); + }); + expect(shell.pendingSessionId).toBeUndefined(); + }); + it('applies an agent deep link on boot and resolves its history filter id', async () => { renderSync({ initialEntries: ['/agents/helper'], agentConfig: { mode: 'AgentLibrary' }, strict: true }); expect(shell.mode).toMatchObject({ status: 'active', isMutable: false, agentName: 'helper' }); diff --git a/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx b/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx index e63a5d83a..d8f1f4325 100644 --- a/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx +++ b/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx @@ -116,7 +116,15 @@ function SessionsSurface() { function renderApp() { return render( ({ + id: sessionId, + title: 'Session', + isMutable: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }), + })} agentConfig={{ mode: 'AgentLibraryWithComposer' }} withRouter layout={() => } @@ -187,6 +195,39 @@ describe('withRouter end to end', () => { expect(listSessions).toHaveBeenCalled(); }); + it('toasts and redirects to the sessions list when a shared session is forbidden', async () => { + window.history.replaceState(null, '', '/sessions/share/session-forbidden'); + const forbidden = Object.assign(new Error('Only the session creator can access this session'), { + statusCode: 403, + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + render( + { + throw forbidden; + }, + sessions: createMockAgentSessionsServer({ + listSessions: async () => ({ data: [] }), + listSessionEvents: async () => { + throw forbidden; + }, + }), + })} + agentConfig={{ mode: 'AgentLibraryWithComposer' }} + withRouter + layout={() => } + />, + ); + + expect(await screen.findByText('Only the session creator can access this session')).toBeInTheDocument(); + await waitFor(() => { + expect(window.location.pathname).toBe('/sessions'); + }); + expect(await screen.findByRole('button', { name: 'Last 30 days' })).toBeInTheDocument(); + expect(screen.queryByText('Session details could not be loaded.')).not.toBeInTheDocument(); + }); + it('applies a /sessions/:id deep link on boot', async () => { window.history.replaceState(null, '', '/sessions/session-2'); renderApp(); diff --git a/packages/trueforge-ui/test/utils/sessionAccessError.test.ts b/packages/trueforge-ui/test/utils/sessionAccessError.test.ts new file mode 100644 index 000000000..7a231d59f --- /dev/null +++ b/packages/trueforge-ui/test/utils/sessionAccessError.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { reportSessionAccessError } from '@/utils/sessionAccessError.js'; + +describe('sessionAccessError', () => { + it('reports the backend error as-is', () => { + const onError = vi.fn(); + const forbidden = Object.assign(new Error('Only the session creator can access this session'), { + statusCode: 403, + }); + reportSessionAccessError({ error: forbidden, onError }); + expect(onError).toHaveBeenCalledWith(forbidden); + }); + + it('prefers onError over showError', () => { + const onError = vi.fn(); + const showError = vi.fn(); + const notFound = Object.assign(new Error('Session not found: x'), { statusCode: 404 }); + reportSessionAccessError({ + error: notFound, + onError, + showError, + }); + expect(onError).toHaveBeenCalledWith(notFound); + expect(showError).not.toHaveBeenCalled(); + }); +}); From ba6f13bb75dc74d6b1e94113565ab4a477e807d7 Mon Sep 17 00:00:00 2001 From: Harshil Vasoya Date: Fri, 25 Sep 2026 11:46:31 +0530 Subject: [PATCH 4/6] feat: implement share session dialog for tenant permission control and share URL copying --- .changeset/share-session-permission-dialog.md | 6 + .../assistant-ui-runtime/src/server/types.ts | 6 + .../src/atoms/ShareChatButton.tsx | 24 +-- .../src/atoms/ShareSessionDialog.tsx | 164 ++++++++++++++++++ .../src/atoms/agent-details/SessionsPage.tsx | 20 +-- .../src/hooks/useShareSessionDialog.ts | 86 +++++++++ .../trueforge-ui/src/icons/IconRegistry.tsx | 6 + packages/trueforge-ui/src/index.ts | 2 + .../chatServer.ts | 16 +- .../trueforge-agent-server-adapter/index.ts | 4 + .../src/server/createTrueForgeServer.ts | 4 + .../trueforge-ui/src/theme/defaultSlots.ts | 2 + .../test/atoms/SessionsPage.test.tsx | 5 +- .../test/atoms/ShareChatButton.test.tsx | 21 ++- .../test/atoms/ShareSessionDialog.test.tsx | 114 ++++++++++++ .../trueforge-ui/test/publicUiExports.test.ts | 1 + 16 files changed, 449 insertions(+), 32 deletions(-) create mode 100644 .changeset/share-session-permission-dialog.md create mode 100644 packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx create mode 100644 packages/trueforge-ui/src/hooks/useShareSessionDialog.ts create mode 100644 packages/trueforge-ui/test/atoms/ShareSessionDialog.test.tsx diff --git a/.changeset/share-session-permission-dialog.md b/.changeset/share-session-permission-dialog.md new file mode 100644 index 000000000..261c5f382 --- /dev/null +++ b/.changeset/share-session-permission-dialog.md @@ -0,0 +1,6 @@ +--- +"@truefoundry/trueforge-assistant-ui-runtime": patch +"@truefoundry/trueforge-ui": patch +--- + +Add a share popover with tenant permission control (`shared` PATCH) and copyable share URL on chat and the sessions page. diff --git a/packages/assistant-ui-runtime/src/server/types.ts b/packages/assistant-ui-runtime/src/server/types.ts index 55328d7ef..ef4c7c540 100644 --- a/packages/assistant-ui-runtime/src/server/types.ts +++ b/packages/assistant-ui-runtime/src/server/types.ts @@ -179,6 +179,8 @@ export interface Session { agentSpec?: TSpec; /** true → mutable builder + updateSession(spec) allowed. */ isMutable: boolean; + /** When true, any subject in the tenant may read this session by id. */ + shared?: boolean; createdAt: string; updatedAt: string; } @@ -193,6 +195,8 @@ export interface UpdateSessionRequest { sessionId: string; agentSpec?: TSpec; title?: string; + /** When true, any subject in the tenant may read this session by id. */ + shared?: boolean; } // --------------------------------------------------------------------------- @@ -1177,6 +1181,8 @@ export type AgentUIServerPort< schedules?: TSchedules; metrics?: TMetrics; permissions?: TPermissions; + /** Authenticated caller identity. Used for tenant-scoped share copy. */ + getMe?: () => Promise<{ tenantId: string }>; }; /** Host-facing alias used by trueforge-ui. */ diff --git a/packages/trueforge-ui/src/atoms/ShareChatButton.tsx b/packages/trueforge-ui/src/atoms/ShareChatButton.tsx index b364a1ff2..0bc746627 100644 --- a/packages/trueforge-ui/src/atoms/ShareChatButton.tsx +++ b/packages/trueforge-ui/src/atoms/ShareChatButton.tsx @@ -1,26 +1,26 @@ 'use client'; import { useAuiState } from '../assistant-ui.js'; -import { useCopySharedSessionLink } from '../hooks/useCopySharedSessionLink.js'; import { Icon } from '../icons/Icon.js'; -import { auiButtonClass } from './lib/buttonClasses.js'; +import { useSlot } from '../theme/SlotsProvider.js'; +import { Button } from './primitives/Button.js'; export function ShareChatButton() { const sessionId = useAuiState(state => state.threadListItem.remoteId); - const { copied, copySharedSessionLink } = useCopySharedSessionLink(sessionId); + const ShareSessionDialog = useSlot('ShareSessionDialog'); if (sessionId == null) return null; return ( - + + + Share + + } + /> ); } diff --git a/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx b/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx new file mode 100644 index 000000000..93e46f053 --- /dev/null +++ b/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx @@ -0,0 +1,164 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import { useShareSessionDialog, type SessionSharePermission } from '../hooks/useShareSessionDialog.js'; +import { Icon } from '../icons/Icon.js'; +import { auiInputClass } from './lib/inputClasses.js'; +import { auiSelectOptionClass, auiSelectTriggerClass } from './lib/selectClasses.js'; +import { Button } from './primitives/Button.js'; +import { DropdownMenu } from './primitives/DropdownMenu.js'; + +export type ShareSessionDialogProps = { + sessionId: string; + trigger: ReactNode; +}; + +const SHARE_PERMISSIONS: Record = { + private: { title: 'Only you', description: 'Only you have access' }, + tenant: { title: 'Everyone within this tenant', description: 'Everyone has access' }, +}; + +const SHARE_PERMISSION_VALUES: readonly SessionSharePermission[] = ['private', 'tenant']; + +function permissionCopy({ + permission, + tenantId, +}: { + permission: SessionSharePermission; + tenantId: string | undefined; +}): { title: string; description: string } { + if (permission === 'tenant' && tenantId != null && tenantId.length > 0) { + return { title: `Everyone within ${tenantId}`, description: SHARE_PERMISSIONS.tenant.description }; + } + return SHARE_PERMISSIONS[permission]; +} + +function AccessSelector({ + permission, + tenantId, + disabled, + onChange, +}: { + permission: SessionSharePermission; + tenantId: string | undefined; + disabled: boolean; + onChange: (next: SessionSharePermission) => void; +}) { + const selected = permissionCopy({ permission, tenantId }); + + return ( + + + + + {selected.title} + {selected.description} + + + + + } + > + {SHARE_PERMISSION_VALUES.map(value => { + const copy = permissionCopy({ permission: value, tenantId }); + return ( + + ); + })} + + ); +} + +export function ShareSessionDialog({ sessionId, trigger }: ShareSessionDialogProps) { + const { + permission, + canManage, + loading, + shareUrl, + copied, + tenantId, + load, + changePermission, + copySharedSessionLink, + } = useShareSessionDialog(sessionId); + + return ( + { + if (open) void load(); + }} + className="w-100 gap-5 rounded-[0.75rem] p-6 shadow-[0_0.5rem_0.75rem_rgba(0,0,0,0.05)]" + > +
+ +

Change permissions

+
+ { + void changePermission(next); + }} + /> +
+
+ +

Share URL

+
+
+ + void copySharedSessionLink()} + > + + {copied ? 'Copied' : 'Copy'} + +
+ + ); +} + +declare module '../theme/SlotsProvider.js' { + interface AtomSlots { + ShareSessionDialog: typeof ShareSessionDialog; + } +} diff --git a/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx b/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx index a942889bd..276c3fabd 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx +++ b/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx @@ -2,7 +2,6 @@ import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; -import { useCopySharedSessionLink } from '../../hooks/useCopySharedSessionLink.js'; import { useSessionShareSearch } from '../../hooks/useSessionShareSearch.js'; import { Icon } from '../../icons/Icon.js'; import { useOptionalAgentSessionsServer } from '../../server/ServerContext.js'; @@ -25,9 +24,9 @@ export function SessionsPage() { const { sessionId, updateShareSearch } = useSessionShareSearch(); const AgentSessions = useSlot('AgentSessions'); const AgentSessionsFilters = useSlot('AgentSessionsFilters'); + const ShareSessionDialog = useSlot('ShareSessionDialog'); const sharedSessionId = shell.sharedSessionId; const selectedSessionId = sharedSessionId ?? sessionId; - const { copied, copySharedSessionLink } = useCopySharedSessionLink(selectedSessionId); const [agentFilter, setAgentFilter] = useState( () => readSessionShareSearch(window.location.search).agentId, @@ -76,14 +75,15 @@ export function SessionsPage() { end={ <> {selectedSessionId != null ? ( - + + + Share + + } + /> ) : null} {sharedSessionId == null ? ( Promise; + changePermission: (next: SessionSharePermission) => Promise; + copySharedSessionLink: () => Promise; +} { + const server = useOptionalServer(); + const toaster = useToasterOptional(); + const routes = useOptionalResolvedRoutes(); + const resourceIds = sessionId == null || sessionId.length === 0 ? [] : [sessionId]; + const { allows } = useResourcePermissions({ resourceType: 'session', resourceIds }); + const canManage = allows(sessionId, 'MANAGE'); + const { copied, copySharedSessionLink } = useCopySharedSessionLink(sessionId); + const [permission, setPermission] = useState('private'); + const [tenantId, setTenantId] = useState(); + const [loading, setLoading] = useState(false); + + const shareUrl = + sessionId == null || sessionId.length === 0 ? '' : buildSharedSessionHref({ sessionId, routes }); + + const load = useCallback(async () => { + if (sessionId == null || sessionId.length === 0 || server == null) return; + setLoading(true); + try { + const session = await server.getSession({ sessionId }); + setPermission(session.shared === true ? 'tenant' : 'private'); + if (server.getMe != null) { + try { + const me = await server.getMe(); + if (me.tenantId.length > 0) setTenantId(me.tenantId); + } catch { + // Keep the generic tenant label when identity is unavailable. + } + } + } catch (caught) { + toaster?.showError(caught); + } finally { + setLoading(false); + } + }, [server, sessionId, toaster]); + + const changePermission = useCallback( + async (next: SessionSharePermission) => { + const previous = permission; + setPermission(next); + if (sessionId == null || sessionId.length === 0 || server == null) return; + try { + await server.updateSession({ sessionId, shared: next === 'tenant' }); + } catch (caught) { + setPermission(previous); + toaster?.showError(caught); + } + }, + [permission, server, sessionId, toaster], + ); + + return { + permission, + canManage, + loading, + shareUrl, + copied, + tenantId, + load, + changePermission, + copySharedSessionLink, + }; +} diff --git a/packages/trueforge-ui/src/icons/IconRegistry.tsx b/packages/trueforge-ui/src/icons/IconRegistry.tsx index 0b779ef97..5811ce81b 100644 --- a/packages/trueforge-ui/src/icons/IconRegistry.tsx +++ b/packages/trueforge-ui/src/icons/IconRegistry.tsx @@ -59,6 +59,7 @@ import { Save, Search, Settings, + Share, Shield, ShieldCheck, SlidersHorizontal, @@ -69,6 +70,8 @@ import { Terminal, Trash2, TriangleAlert, + UserCog, + Users, Wrench, X, } from 'lucide-react'; @@ -162,6 +165,7 @@ const defaults: Record = { sun: Sun, moon: Moon, settings: Settings, + share: Share, shield: Shield, 'shield-check': ShieldCheck, sliders: SlidersHorizontal, @@ -183,6 +187,8 @@ const defaults: Record = { plug: Plug, 'list-check': ListChecks, lock: Lock, + 'user-cog': UserCog, + users: Users, lightbulb: Lightbulb, link: Link2, wrench: Wrench, diff --git a/packages/trueforge-ui/src/index.ts b/packages/trueforge-ui/src/index.ts index 9dda3750d..8716faa43 100644 --- a/packages/trueforge-ui/src/index.ts +++ b/packages/trueforge-ui/src/index.ts @@ -293,6 +293,8 @@ export { } from './atoms/lib/sessionCreateAgent.js'; export { SelectAgentEmptyState } from './atoms/SelectAgentEmptyState.js'; export { ShareChatButton } from './atoms/ShareChatButton.js'; +export { ShareSessionDialog } from './atoms/ShareSessionDialog.js'; +export type { ShareSessionDialogProps } from './atoms/ShareSessionDialog.js'; export { ShellActionsActionSlot } from './atoms/ShellActionsActionSlot.js'; export { createTrueForgeServer } from './server/createTrueForgeServer.js'; export type { CreateTrueForgeServerOptions, TrueForgeServer } from './server/createTrueForgeServer.js'; diff --git a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts index efae83936..c52d3626b 100644 --- a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts +++ b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts @@ -83,7 +83,12 @@ export function toUiAgentSpec(spec: TrueForgeApi.AgentSpec): HarnessAgentSpec { }; } +function readOptionalBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + function toUiSession(session: TrueForgeApi.Session): HarnessUiSession { + const shared = readOptionalBoolean(Reflect.get(session, 'shared')); return { id: session.id, isMutable: session.agent.type === 'inline', @@ -91,6 +96,7 @@ function toUiSession(session: TrueForgeApi.Session): HarnessUiSession { createdAt: session.createdAt, updatedAt: session.updatedAt, ...(session.title === null ? {} : { title: session.title }), + ...(shared === undefined ? {} : { shared }), // `name` is a create-time snapshot, so references whose agent predates it stay // unlabelled; `isMutable` alone keeps them out of the composer. ...(session.agent.type === 'reference' && session.agent.name !== null ? { agentName: session.agent.name } : {}), @@ -222,12 +228,16 @@ export function createHarnessChatServer( await client.sessions.update(sessionId, { title }); }, - async updateSession({ sessionId, agentSpec, title }) { + async updateSession({ sessionId, agentSpec, title, shared }) { // Named (reference) sessions reject agent updates server-side. - const response = await client.sessions.update(sessionId, { + const body: TrueForgeApi.UpdateSessionRequest = { ...(agentSpec === undefined ? {} : { agent: { spec: toHarnessAgentSpec(agentSpec) } }), ...(title === undefined ? {} : { title }), - }); + }; + if (shared !== undefined) { + Object.assign(body, { shared }); + } + const response = await client.sessions.update(sessionId, body); return toUiSession(response.data); }, diff --git a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/index.ts b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/index.ts index 80741b256..f1673c1bd 100644 --- a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/index.ts +++ b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/index.ts @@ -110,5 +110,9 @@ export function createTrueForgeAgentUIServer(options: CreateTrueForgeAgentUIServ metrics: createHarnessAgentMetricsServer({ ...clientOptions, client }), schedules: createScheduleServer({ client }), permissions: permissions ?? createHarnessPermissionsServer({ client }), + getMe: async () => { + const { data } = await client.auth.me(); + return { tenantId: data.tenantId }; + }, }); } diff --git a/packages/trueforge-ui/src/server/createTrueForgeServer.ts b/packages/trueforge-ui/src/server/createTrueForgeServer.ts index 722562e55..47d53f29c 100644 --- a/packages/trueforge-ui/src/server/createTrueForgeServer.ts +++ b/packages/trueforge-ui/src/server/createTrueForgeServer.ts @@ -52,6 +52,8 @@ export type CreateTrueForgeServerOptions< schedules?: TSchedules; /** Per-resource grants. Omit to leave actions enabled. */ permissions?: TPermissions; + /** Authenticated caller identity. Used for tenant-scoped share copy. */ + getMe?: () => Promise<{ tenantId: string }>; }; export type TrueForgeServer< @@ -74,6 +76,7 @@ export type TrueForgeServer< metrics?: TMetrics; schedules?: TSchedules; permissions?: TPermissions; + getMe?: () => Promise<{ tenantId: string }>; }; /** @@ -162,6 +165,7 @@ export function createTrueForgeServer< ...(opts.metrics != null ? { metrics: opts.metrics } : {}), ...(opts.schedules != null ? { schedules: opts.schedules } : {}), ...(opts.permissions != null ? { permissions: opts.permissions } : {}), + ...(opts.getMe != null ? { getMe: opts.getMe } : {}), }; return server; } diff --git a/packages/trueforge-ui/src/theme/defaultSlots.ts b/packages/trueforge-ui/src/theme/defaultSlots.ts index c003275ae..2f85523ca 100644 --- a/packages/trueforge-ui/src/theme/defaultSlots.ts +++ b/packages/trueforge-ui/src/theme/defaultSlots.ts @@ -81,6 +81,7 @@ import { ScrollToBottomButton } from '../atoms/ScrollToBottomButton.js'; import { SelectAgentEmptyState } from '../atoms/SelectAgentEmptyState.js'; import { SessionsBrowserButton } from '../atoms/SessionsBrowserButton.js'; import { ShareChatButton } from '../atoms/ShareChatButton.js'; +import { ShareSessionDialog } from '../atoms/ShareSessionDialog.js'; import { ShellActionsActionSlot } from '../atoms/ShellActionsActionSlot.js'; import { MessageListSkeleton } from '../atoms/Skeletons.js'; import { SubAgentCard } from '../atoms/SubAgentCard.js'; @@ -245,6 +246,7 @@ export const defaultSlots = { SelectAgentEmptyState, ClearChatButton, ShareChatButton, + ShareSessionDialog, ThreadListRowSkeleton, ThreadListEmptyState, ThreadListShell, diff --git a/packages/trueforge-ui/test/atoms/SessionsPage.test.tsx b/packages/trueforge-ui/test/atoms/SessionsPage.test.tsx index 9ff449407..dfd619d3a 100644 --- a/packages/trueforge-ui/test/atoms/SessionsPage.test.tsx +++ b/packages/trueforge-ui/test/atoms/SessionsPage.test.tsx @@ -254,7 +254,10 @@ describe('SessionsPage', () => { window.history.replaceState(null, '', '/?view=sessions&sessionId=sess-1&agentId=agent-1&s_tw=30'); renderPage(); - fireEvent.click(await screen.findByRole('button', { name: 'Copy shared link' })); + fireEvent.click(await screen.findByRole('button', { name: 'Share' })); + expect(await screen.findByText('Change permissions')).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Copy' })); await waitFor(() => { expect(clipboardWriteText).toHaveBeenCalledOnce(); diff --git a/packages/trueforge-ui/test/atoms/ShareChatButton.test.tsx b/packages/trueforge-ui/test/atoms/ShareChatButton.test.tsx index 02e430eb9..60ed7483d 100644 --- a/packages/trueforge-ui/test/atoms/ShareChatButton.test.tsx +++ b/packages/trueforge-ui/test/atoms/ShareChatButton.test.tsx @@ -12,6 +12,7 @@ vi.mock('@/assistant-ui.js', () => ({ import { ShareChatButton } from '@/atoms/ShareChatButton.js'; import { resolveRoutesConfig } from '@/routing/paths.js'; import { ResolvedRoutesProvider } from '@/routing/ResolvedRoutesContext.js'; +import { SlotsProvider } from '@/theme/SlotsProvider.js'; const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); let writeText: ReturnType; @@ -36,25 +37,33 @@ afterEach(() => { describe('ShareChatButton', () => { it('is hidden until the active chat has a persisted session id', () => { - render(); + render( + + + , + ); expect(screen.queryByRole('button', { name: 'Share' })).not.toBeInTheDocument(); }); - it('copies the routed shared-session URL', async () => { + it('opens the share popover and copies the routed shared-session URL', async () => { activeThread.remoteId = 'session-1'; render( - - - , + + + + + , ); fireEvent.click(screen.getByRole('button', { name: 'Share' })); + expect(await screen.findByText('Change permissions')).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Copy' })); await waitFor(() => { expect(writeText).toHaveBeenCalledOnce(); }); expect(new URL(String(writeText.mock.calls[0]?.[0])).pathname).toBe('/sessions/share/session-1'); - expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument(); }); }); diff --git a/packages/trueforge-ui/test/atoms/ShareSessionDialog.test.tsx b/packages/trueforge-ui/test/atoms/ShareSessionDialog.test.tsx new file mode 100644 index 000000000..7ada6bdfa --- /dev/null +++ b/packages/trueforge-ui/test/atoms/ShareSessionDialog.test.tsx @@ -0,0 +1,114 @@ +// @vitest-environment jsdom +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ShareSessionDialog } from '@/atoms/ShareSessionDialog.js'; +import { ToasterProvider } from '@/containers/ToasterContainer.js'; +import { resolveRoutesConfig } from '@/routing/paths.js'; +import { ResolvedRoutesProvider } from '@/routing/ResolvedRoutesContext.js'; +import { ServerProvider } from '@/server/ServerContext.js'; +import { SlotsProvider } from '@/theme/SlotsProvider.js'; +import { createMockAgentUIServer } from '../server/mockServer.js'; + +const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); +let writeText: ReturnType; + +beforeEach(() => { + writeText = vi.fn(async () => undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + if (originalClipboard === undefined) { + Reflect.deleteProperty(navigator, 'clipboard'); + } else { + Object.defineProperty(navigator, 'clipboard', originalClipboard); + } +}); + +function renderDialog({ + getSession = vi.fn(async () => ({ + id: 'session-1', + isMutable: true, + shared: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + })), + updateSession = vi.fn(async (req: { sessionId: string; shared?: boolean }) => ({ + id: req.sessionId, + isMutable: true, + shared: req.shared === true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + })), + getMe = vi.fn(async () => ({ tenantId: 'acme' })), +}: { + getSession?: () => Promise<{ + id: string; + isMutable: boolean; + shared?: boolean; + createdAt: string; + updatedAt: string; + }>; + updateSession?: (req: { sessionId: string; shared?: boolean }) => Promise<{ + id: string; + isMutable: boolean; + shared?: boolean; + createdAt: string; + updatedAt: string; + }>; + getMe?: () => Promise<{ tenantId: string }>; +} = {}) { + const server = createMockAgentUIServer({ getSession, updateSession, getMe }); + render( + + + + + Share} /> + + + + , + ); + return { getSession, updateSession, getMe }; +} + +async function openSharePopover() { + fireEvent.click(screen.getByRole('button', { name: 'Share' })); + expect(await screen.findByText('Change permissions')).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); +} + +describe('ShareSessionDialog', () => { + it('loads current sharing and copies the share URL', async () => { + renderDialog(); + await openSharePopover(); + + expect(await screen.findByRole('button', { name: 'Session sharing' })).toHaveTextContent('Only you'); + expect((screen.getByLabelText('Share URL') as HTMLInputElement).value).toContain('/sessions/share/session-1'); + + fireEvent.click(screen.getByRole('button', { name: 'Copy' })); + await waitFor(() => { + expect(writeText).toHaveBeenCalledOnce(); + }); + expect(new URL(String(writeText.mock.calls[0]?.[0])).pathname).toBe('/sessions/share/session-1'); + expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument(); + }); + + it('PATCHes shared when the tenant permission is selected', async () => { + const { updateSession } = renderDialog(); + await openSharePopover(); + + fireEvent.click(await screen.findByRole('button', { name: 'Session sharing' })); + fireEvent.click(await screen.findByRole('option', { name: 'Everyone within acme' })); + + await waitFor(() => { + expect(updateSession).toHaveBeenCalledWith({ sessionId: 'session-1', shared: true }); + }); + }); +}); diff --git a/packages/trueforge-ui/test/publicUiExports.test.ts b/packages/trueforge-ui/test/publicUiExports.test.ts index 4d0670590..2fac93335 100644 --- a/packages/trueforge-ui/test/publicUiExports.test.ts +++ b/packages/trueforge-ui/test/publicUiExports.test.ts @@ -118,6 +118,7 @@ const expectedRuntimeExports: Array = [ 'SessionsBrowserButton', 'SessionsPage', 'ShareChatButton', + 'ShareSessionDialog', 'ShellActionsActionSlot', 'ShellModeProvider', 'SideDrawer', From 3cfa1756c9df61c00deb7808fcba208ec3823d87 Mon Sep 17 00:00:00 2001 From: Harshil Vasoya Date: Fri, 25 Sep 2026 12:16:16 +0530 Subject: [PATCH 5/6] refactor: enhance session sharing and permission handling --- .../assistant-ui-runtime/src/server/types.ts | 4 +- .../src/atoms/PermissionGuard.tsx | 4 +- .../src/atoms/ShareChatButton.tsx | 24 +++-- .../src/atoms/ShareSessionDialog.tsx | 5 +- .../src/atoms/agent-details/SessionsPage.tsx | 30 ++++-- .../src/atoms/primitives/DropdownMenu.tsx | 9 +- .../src/atoms/primitives/Tooltip.tsx | 97 +++++++++++++++---- .../src/hooks/useShareSessionDialog.ts | 3 +- .../chatServer.ts | 11 +-- .../atoms/AgentSessionDetailHeader.test.tsx | 15 +++ .../test/atoms/PermissionGuard.test.tsx | 4 +- .../test/atoms/SessionsPage.test.tsx | 17 ++++ .../test/atoms/ShareChatButton.test.tsx | 29 ++++++ .../test/atoms/ShareSessionDialog.test.tsx | 24 +++++ .../test/atoms/primitives/Tooltip.test.tsx | 30 +++++- 15 files changed, 248 insertions(+), 58 deletions(-) diff --git a/packages/assistant-ui-runtime/src/server/types.ts b/packages/assistant-ui-runtime/src/server/types.ts index 59705ccf5..7a9c04d63 100644 --- a/packages/assistant-ui-runtime/src/server/types.ts +++ b/packages/assistant-ui-runtime/src/server/types.ts @@ -179,7 +179,7 @@ export interface Session { agentSpec?: TSpec; /** true → mutable builder + updateSession(spec) allowed. */ isMutable: boolean; - /** When true, any subject in the tenant may read this session by id. */ + /** When true, any subject in the tenant may read this session and its turns/events by id. */ shared?: boolean; createdAt: string; updatedAt: string; @@ -195,7 +195,7 @@ export interface UpdateSessionRequest { sessionId: string; agentSpec?: TSpec; title?: string; - /** When true, any subject in the tenant may read this session by id. */ + /** When true, any subject in the tenant may read this session and its turns/events by id. */ shared?: boolean; } diff --git a/packages/trueforge-ui/src/atoms/PermissionGuard.tsx b/packages/trueforge-ui/src/atoms/PermissionGuard.tsx index 86a4fa51a..4048cfca3 100644 --- a/packages/trueforge-ui/src/atoms/PermissionGuard.tsx +++ b/packages/trueforge-ui/src/atoms/PermissionGuard.tsx @@ -23,8 +23,8 @@ export function PermissionGuard({ if (allowed) return guardedChild; return ( - - + + {guardedChild} diff --git a/packages/trueforge-ui/src/atoms/ShareChatButton.tsx b/packages/trueforge-ui/src/atoms/ShareChatButton.tsx index 0bc746627..4fe9d48cb 100644 --- a/packages/trueforge-ui/src/atoms/ShareChatButton.tsx +++ b/packages/trueforge-ui/src/atoms/ShareChatButton.tsx @@ -1,27 +1,31 @@ 'use client'; import { useAuiState } from '../assistant-ui.js'; +import { useActiveSessionCanManage } from '../hooks/useResourcePermissions.js'; import { Icon } from '../icons/Icon.js'; import { useSlot } from '../theme/SlotsProvider.js'; import { Button } from './primitives/Button.js'; export function ShareChatButton() { const sessionId = useAuiState(state => state.threadListItem.remoteId); + const canManageSession = useActiveSessionCanManage(); const ShareSessionDialog = useSlot('ShareSessionDialog'); + const PermissionGuard = useSlot('PermissionGuard'); if (sessionId == null) return null; - return ( - - - Share - - } - /> + const trigger = ( + + + Share + ); + + if (!canManageSession) { + return {trigger}; + } + + return ; } declare module '../theme/SlotsProvider.js' { diff --git a/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx b/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx index 93e46f053..7f84068de 100644 --- a/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx +++ b/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx @@ -50,8 +50,9 @@ function AccessSelector({ return ( { if (open) void load(); }} - className="w-100 gap-5 rounded-[0.75rem] p-6 shadow-[0_0.5rem_0.75rem_rgba(0,0,0,0.05)]" + className="w-120 gap-5 rounded-[0.75rem] p-6 shadow-[0_0.5rem_0.75rem_rgba(0,0,0,0.05)]" >
diff --git a/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx b/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx index 276c3fabd..18a4b49fa 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx +++ b/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx @@ -2,6 +2,7 @@ import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { useResourcePermissions } from '../../hooks/useResourcePermissions.js'; import { useSessionShareSearch } from '../../hooks/useSessionShareSearch.js'; import { Icon } from '../../icons/Icon.js'; import { useOptionalAgentSessionsServer } from '../../server/ServerContext.js'; @@ -18,6 +19,15 @@ import { auiButtonClass } from '../lib/buttonClasses.js'; import { PageHeader } from '../PageHeader.js'; import { Skeleton } from '../primitives/Skeleton.js'; +function SessionsShareTrigger({ disabled }: { disabled?: boolean }) { + return ( + + ); +} + export function SessionsPage() { const sessionsServer = useOptionalAgentSessionsServer(); const shell = useShellMode(); @@ -25,8 +35,12 @@ export function SessionsPage() { const AgentSessions = useSlot('AgentSessions'); const AgentSessionsFilters = useSlot('AgentSessionsFilters'); const ShareSessionDialog = useSlot('ShareSessionDialog'); + const PermissionGuard = useSlot('PermissionGuard'); const sharedSessionId = shell.sharedSessionId; const selectedSessionId = sharedSessionId ?? sessionId; + const shareResourceIds = selectedSessionId == null ? [] : [selectedSessionId]; + const { allows } = useResourcePermissions({ resourceType: 'session', resourceIds: shareResourceIds }); + const canManageSession = allows(selectedSessionId, 'MANAGE'); const [agentFilter, setAgentFilter] = useState( () => readSessionShareSearch(window.location.search).agentId, @@ -75,15 +89,13 @@ export function SessionsPage() { end={ <> {selectedSessionId != null ? ( - - - Share - - } - /> + canManageSession ? ( + } /> + ) : ( + + + + ) ) : null} {sharedSessionId == null ? ( void; closeOnClick?: boolean; lockScroll?: boolean; + /** Capture-phase outside click. Needed when a parent menu stops mousedown bubbling. */ + captureOutsideClick?: boolean; }; // Returns true if the click was on the menu surface, not its contents or scrollbar. @@ -35,6 +37,7 @@ export function DropdownMenu({ onOpenChange, closeOnClick = true, lockScroll = false, + captureOutsideClick = false, }: DropdownMenuProps) { const [internalOpen, setInternalOpen] = useState(false); const open = controlledOpen ?? internalOpen; @@ -85,9 +88,9 @@ export function DropdownMenu({ if (menuRef.current?.contains(target)) return; setOpen(false); }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [open]); + document.addEventListener('mousedown', handler, captureOutsideClick); + return () => document.removeEventListener('mousedown', handler, captureOutsideClick); + }, [captureOutsideClick, open, setOpen]); useEffect(() => { if (!open || !lockScroll) return; diff --git a/packages/trueforge-ui/src/atoms/primitives/Tooltip.tsx b/packages/trueforge-ui/src/atoms/primitives/Tooltip.tsx index 2a968cb0b..0be08d2b5 100644 --- a/packages/trueforge-ui/src/atoms/primitives/Tooltip.tsx +++ b/packages/trueforge-ui/src/atoms/primitives/Tooltip.tsx @@ -7,6 +7,20 @@ import { cn } from '../lib/cn.js'; import { themePortalRoot } from '../lib/themePortalRoot.js'; const TOOLTIP_VIEWPORT_PAD = 8; +const TOOLTIP_GAP = 6; + +export type TooltipSide = 'top' | 'bottom' | 'left' | 'right'; + +function isVerticalSide(side: TooltipSide): side is 'top' | 'bottom' { + return side === 'top' || side === 'bottom'; +} + +function tooltipTransform(side: TooltipSide): string { + if (side === 'bottom') return 'translate(-50%, 0)'; + if (side === 'top') return 'translate(-50%, -100%)'; + if (side === 'right') return 'translate(0, -50%)'; + return 'translate(-100%, -50%)'; +} /** `left`/`top` are the desired center and top-edge (bottom) or bottom-edge (top). */ export function clampCenteredTooltip({ @@ -48,6 +62,41 @@ export function clampCenteredTooltip({ return { top: nextTop, left: nextLeft }; } +/** `left` is the inner edge (right: tooltip start; left: tooltip end). `top` is the vertical center. */ +export function clampEdgeTooltip({ + left, + top, + width, + height, + side, + viewportWidth, + viewportHeight, + pad = TOOLTIP_VIEWPORT_PAD, +}: { + left: number; + top: number; + width: number; + height: number; + side: 'left' | 'right'; + viewportWidth: number; + viewportHeight: number; + pad?: number; +}): { top: number; left: number } { + let nextTop = top; + if (height > 0) { + const half = height / 2; + nextTop = Math.min(viewportHeight - pad - half, Math.max(pad + half, top)); + } + let nextLeft = left; + if (width > 0) { + nextLeft = + side === 'right' + ? Math.min(viewportWidth - pad - width, Math.max(pad, left)) + : Math.min(viewportWidth - pad, Math.max(pad + width, left)); + } + return { top: nextTop, left: nextLeft }; +} + function hasTooltipContent(content: React.ReactNode): boolean { if (content == null || content === false) return false; if (typeof content === 'string') return content.trim().length > 0; @@ -64,7 +113,7 @@ export type TooltipProps = { children: React.ReactElement; className?: string; triggerClassName?: string; - side?: 'top' | 'bottom'; + side?: TooltipSide; dismissOnClick?: boolean; followCursor?: boolean; /** When set, tooltip is pinned to these viewport coords instead of the trigger. */ @@ -99,27 +148,39 @@ export function Tooltip({ const trigger = triggerWrapRef.current; let next: { top: number; left: number } | null = null; if (anchor != null) { - next = { top: side === 'bottom' ? anchor.top + 6 : anchor.top - 6, left: anchor.left }; - } else if (trigger) { - const rect = trigger.getBoundingClientRect(); next = { - top: side === 'bottom' ? rect.bottom + 6 : rect.top - 6, - left: followCursor && cursorXRef.current != null ? cursorXRef.current : rect.left + rect.width / 2, + top: side === 'bottom' ? anchor.top + TOOLTIP_GAP : anchor.top - TOOLTIP_GAP, + left: anchor.left, }; + } else if (trigger) { + const rect = trigger.getBoundingClientRect(); + next = isVerticalSide(side) + ? { + top: side === 'bottom' ? rect.bottom + TOOLTIP_GAP : rect.top - TOOLTIP_GAP, + left: followCursor && cursorXRef.current != null ? cursorXRef.current : rect.left + rect.width / 2, + } + : { + top: rect.top + rect.height / 2, + left: side === 'right' ? rect.right + TOOLTIP_GAP : rect.left - TOOLTIP_GAP, + }; } if (next == null) return; const tooltipEl = tooltipRef.current; + if (tooltipEl == null) { + setPos(next); + return; + } + const size = { + ...next, + width: tooltipEl.offsetWidth, + height: tooltipEl.offsetHeight, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + }; setPos( - tooltipEl - ? clampCenteredTooltip({ - ...next, - width: tooltipEl.offsetWidth, - height: tooltipEl.offsetHeight, - side, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight, - }) - : next, + isVerticalSide(side) + ? clampCenteredTooltip({ ...size, side }) + : clampEdgeTooltip({ ...size, side }), ); }; @@ -184,7 +245,7 @@ export function Tooltip({ style={{ top: pos?.top ?? 0, left: pos?.left ?? 0, - transform: side === 'bottom' ? 'translate(-50%, 0)' : 'translate(-50%, -100%)', + transform: tooltipTransform(side), visibility: pos == null ? 'hidden' : undefined, }} className={cn( @@ -213,7 +274,7 @@ export type LightTooltipProps = { className?: string; triggerClassName?: string; size?: string; - side?: 'top' | 'bottom'; + side?: TooltipSide; dismissOnClick?: boolean; followCursor?: boolean; anchor?: TooltipAnchor | null; diff --git a/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts b/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts index 80f150ce9..8d7fffc48 100644 --- a/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts +++ b/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts @@ -59,6 +59,7 @@ export function useShareSessionDialog(sessionId: string | null | undefined): { const changePermission = useCallback( async (next: SessionSharePermission) => { + if (!canManage || next === permission) return; const previous = permission; setPermission(next); if (sessionId == null || sessionId.length === 0 || server == null) return; @@ -69,7 +70,7 @@ export function useShareSessionDialog(sessionId: string | null | undefined): { toaster?.showError(caught); } }, - [permission, server, sessionId, toaster], + [canManage, permission, server, sessionId, toaster], ); return { diff --git a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts index c52d3626b..38f21f8b4 100644 --- a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts +++ b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts @@ -83,20 +83,15 @@ export function toUiAgentSpec(spec: TrueForgeApi.AgentSpec): HarnessAgentSpec { }; } -function readOptionalBoolean(value: unknown): boolean | undefined { - return typeof value === 'boolean' ? value : undefined; -} - function toUiSession(session: TrueForgeApi.Session): HarnessUiSession { - const shared = readOptionalBoolean(Reflect.get(session, 'shared')); return { id: session.id, isMutable: session.agent.type === 'inline', isCreateAgent: readSessionIsCreateAgent(session.metadata), + shared: session.shared, createdAt: session.createdAt, updatedAt: session.updatedAt, ...(session.title === null ? {} : { title: session.title }), - ...(shared === undefined ? {} : { shared }), // `name` is a create-time snapshot, so references whose agent predates it stay // unlabelled; `isMutable` alone keeps them out of the composer. ...(session.agent.type === 'reference' && session.agent.name !== null ? { agentName: session.agent.name } : {}), @@ -233,10 +228,8 @@ export function createHarnessChatServer( const body: TrueForgeApi.UpdateSessionRequest = { ...(agentSpec === undefined ? {} : { agent: { spec: toHarnessAgentSpec(agentSpec) } }), ...(title === undefined ? {} : { title }), + ...(shared === undefined ? {} : { shared }), }; - if (shared !== undefined) { - Object.assign(body, { shared }); - } const response = await client.sessions.update(sessionId, body); return toUiSession(response.data); }, diff --git a/packages/trueforge-ui/test/atoms/AgentSessionDetailHeader.test.tsx b/packages/trueforge-ui/test/atoms/AgentSessionDetailHeader.test.tsx index 026436b84..3da899bbd 100644 --- a/packages/trueforge-ui/test/atoms/AgentSessionDetailHeader.test.tsx +++ b/packages/trueforge-ui/test/atoms/AgentSessionDetailHeader.test.tsx @@ -104,4 +104,19 @@ describe('AgentSessionDetailHeader', () => { expect(screen.getByRole('link', { name: /Resume Chat/i })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Resume Chat' })).not.toBeInTheDocument(); }); + + it('disables Resume Chat when the session is read-only', () => { + render( + undefined} + resumeHref="https://app.example/sessions/sess-1" + resumeLabel="Resume Chat" + canResume={false} + />, + ); + expect(screen.getByRole('button', { name: 'Resume Chat' })).toBeDisabled(); + expect(screen.queryByRole('link', { name: /Resume Chat/i })).not.toBeInTheDocument(); + }); }); diff --git a/packages/trueforge-ui/test/atoms/PermissionGuard.test.tsx b/packages/trueforge-ui/test/atoms/PermissionGuard.test.tsx index 7306d814b..af963218c 100644 --- a/packages/trueforge-ui/test/atoms/PermissionGuard.test.tsx +++ b/packages/trueforge-ui/test/atoms/PermissionGuard.test.tsx @@ -27,6 +27,8 @@ describe('PermissionGuard', () => { const button = screen.getByRole('button', { name: 'Update' }); expect(button).toBeDisabled(); fireEvent.mouseEnter(button.parentElement ?? button); - expect(screen.getByRole('tooltip')).toHaveTextContent('Missing MANAGE permission'); + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toHaveTextContent('Missing MANAGE permission'); + expect(tooltip).toHaveStyle({ transform: 'translate(0, -50%)' }); }); }); diff --git a/packages/trueforge-ui/test/atoms/SessionsPage.test.tsx b/packages/trueforge-ui/test/atoms/SessionsPage.test.tsx index dfd619d3a..a0190ec1c 100644 --- a/packages/trueforge-ui/test/atoms/SessionsPage.test.tsx +++ b/packages/trueforge-ui/test/atoms/SessionsPage.test.tsx @@ -402,6 +402,23 @@ describe('SessionsPage', () => { expect(screen.getByText('Draft session')).toBeInTheDocument(); }); + it('disables Share without session MANAGE permission', async () => { + window.history.replaceState(null, '', '/?view=sessions&sessionId=sess-1&s_tw=30'); + renderPage({ + permissions: { + listPermissions: vi.fn(async (): Promise => ({ + data: { type: 'session', permissions: { 'sess-1': [], 'sess-draft': ['MANAGE'] } }, + })), + }, + }); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Share' })).toBeDisabled(); + }); + fireEvent.click(screen.getByRole('button', { name: 'Share' })); + expect(screen.queryByText('Change permissions')).not.toBeInTheDocument(); + }); + it('disables Delete without session DELETE permission', async () => { const deleteSession = vi.fn(async () => undefined); renderPage({ diff --git a/packages/trueforge-ui/test/atoms/ShareChatButton.test.tsx b/packages/trueforge-ui/test/atoms/ShareChatButton.test.tsx index 60ed7483d..878b892f0 100644 --- a/packages/trueforge-ui/test/atoms/ShareChatButton.test.tsx +++ b/packages/trueforge-ui/test/atoms/ShareChatButton.test.tsx @@ -10,9 +10,12 @@ vi.mock('@/assistant-ui.js', () => ({ })); import { ShareChatButton } from '@/atoms/ShareChatButton.js'; +import { ActiveSessionPermissionsProvider } from '@/hooks/useResourcePermissions.js'; import { resolveRoutesConfig } from '@/routing/paths.js'; import { ResolvedRoutesProvider } from '@/routing/ResolvedRoutesContext.js'; +import { ServerProvider } from '@/server/ServerContext.js'; import { SlotsProvider } from '@/theme/SlotsProvider.js'; +import { createMockAgentUIServer } from '../server/mockServer.js'; const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); let writeText: ReturnType; @@ -66,4 +69,30 @@ describe('ShareChatButton', () => { }); expect(new URL(String(writeText.mock.calls[0]?.[0])).pathname).toBe('/sessions/share/session-1'); }); + + it('disables Share without session MANAGE permission', async () => { + activeThread.remoteId = 'session-1'; + const server = createMockAgentUIServer({ + permissions: { + listPermissions: vi.fn(async () => ({ + data: { type: 'session', permissions: { 'session-1': [] } }, + })), + }, + }); + render( + + + + + + + , + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Share' })).toBeDisabled(); + }); + fireEvent.click(screen.getByRole('button', { name: 'Share' })); + expect(screen.queryByText('Change permissions')).not.toBeInTheDocument(); + }); }); diff --git a/packages/trueforge-ui/test/atoms/ShareSessionDialog.test.tsx b/packages/trueforge-ui/test/atoms/ShareSessionDialog.test.tsx index 7ada6bdfa..bb19e21e2 100644 --- a/packages/trueforge-ui/test/atoms/ShareSessionDialog.test.tsx +++ b/packages/trueforge-ui/test/atoms/ShareSessionDialog.test.tsx @@ -100,6 +100,30 @@ describe('ShareSessionDialog', () => { expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument(); }); + it('closes permission options on outside click without PATCHing', async () => { + const { updateSession } = renderDialog(); + await openSharePopover(); + + fireEvent.click(await screen.findByRole('button', { name: 'Session sharing' })); + expect(await screen.findByRole('option', { name: 'Everyone within acme' })).toBeInTheDocument(); + + fireEvent.mouseDown(screen.getByText('Change permissions')); + + expect(screen.queryByRole('option', { name: 'Everyone within acme' })).not.toBeInTheDocument(); + expect(updateSession).not.toHaveBeenCalled(); + }); + + it('closes the permission menu without PATCHing when the current option is clicked', async () => { + const { updateSession } = renderDialog(); + await openSharePopover(); + + fireEvent.click(await screen.findByRole('button', { name: 'Session sharing' })); + fireEvent.click(await screen.findByRole('option', { name: 'Only you' })); + + expect(screen.queryByRole('option', { name: 'Only you' })).not.toBeInTheDocument(); + expect(updateSession).not.toHaveBeenCalled(); + }); + it('PATCHes shared when the tenant permission is selected', async () => { const { updateSession } = renderDialog(); await openSharePopover(); diff --git a/packages/trueforge-ui/test/atoms/primitives/Tooltip.test.tsx b/packages/trueforge-ui/test/atoms/primitives/Tooltip.test.tsx index 5eda8814b..50a104afa 100644 --- a/packages/trueforge-ui/test/atoms/primitives/Tooltip.test.tsx +++ b/packages/trueforge-ui/test/atoms/primitives/Tooltip.test.tsx @@ -1,7 +1,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { clampCenteredTooltip, LightTooltip, Tooltip } from '@/atoms/primitives/Tooltip.js'; +import { clampCenteredTooltip, clampEdgeTooltip, LightTooltip, Tooltip } from '@/atoms/primitives/Tooltip.js'; describe('Tooltip', () => { it('shows and hides on hover while merging the child callbacks', () => { @@ -179,6 +179,34 @@ describe('Tooltip', () => { ).toEqual({ left: 108, top: 40 }); }); + it('clamps a right-side tooltip so it stays inside the viewport', () => { + expect( + clampEdgeTooltip({ + left: 860, + top: 40, + width: 200, + height: 32, + side: 'right', + viewportWidth: 900, + viewportHeight: 600, + }), + ).toEqual({ left: 692, top: 40 }); + }); + + it('opens to the right of the trigger when side is right', () => { + render( + + + , + ); + + fireEvent.mouseEnter(screen.getByRole('button', { name: 'Anchor' })); + + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toHaveTextContent('Beside tip'); + expect(tooltip).toHaveStyle({ transform: 'translate(0, -50%)' }); + }); + it('opens below the trigger when side is bottom', () => { render( From f30c5d66ab3100429b4e5f29602bd43664465ecd Mon Sep 17 00:00:00 2001 From: Harshil Vasoya Date: Fri, 25 Sep 2026 12:26:53 +0530 Subject: [PATCH 6/6] refactor: streamline code structure in ShareSessionDialog and Tooltip components --- .../src/atoms/ShareSessionDialog.tsx | 13 ++---------- .../src/atoms/primitives/Tooltip.tsx | 6 +----- .../src/hooks/useShareSessionDialog.ts | 21 +++++++++---------- 3 files changed, 13 insertions(+), 27 deletions(-) diff --git a/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx b/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx index 7f84068de..7ed37a9cb 100644 --- a/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx +++ b/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx @@ -98,17 +98,8 @@ function AccessSelector({ } export function ShareSessionDialog({ sessionId, trigger }: ShareSessionDialogProps) { - const { - permission, - canManage, - loading, - shareUrl, - copied, - tenantId, - load, - changePermission, - copySharedSessionLink, - } = useShareSessionDialog(sessionId); + const { permission, canManage, loading, shareUrl, copied, tenantId, load, changePermission, copySharedSessionLink } = + useShareSessionDialog(sessionId); return ( { diff --git a/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts b/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts index 8d7fffc48..6d2856bbc 100644 --- a/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts +++ b/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts @@ -33,23 +33,22 @@ export function useShareSessionDialog(sessionId: string | null | undefined): { const [tenantId, setTenantId] = useState(); const [loading, setLoading] = useState(false); - const shareUrl = - sessionId == null || sessionId.length === 0 ? '' : buildSharedSessionHref({ sessionId, routes }); + const shareUrl = sessionId == null || sessionId.length === 0 ? '' : buildSharedSessionHref({ sessionId, routes }); const load = useCallback(async () => { if (sessionId == null || sessionId.length === 0 || server == null) return; setLoading(true); try { - const session = await server.getSession({ sessionId }); - setPermission(session.shared === true ? 'tenant' : 'private'); - if (server.getMe != null) { - try { - const me = await server.getMe(); - if (me.tenantId.length > 0) setTenantId(me.tenantId); - } catch { - // Keep the generic tenant label when identity is unavailable. - } + const session = await server.getSession({ sessionId }); + setPermission(session.shared === true ? 'tenant' : 'private'); + if (server.getMe != null) { + try { + const me = await server.getMe(); + if (me.tenantId.length > 0) setTenantId(me.tenantId); + } catch { + // Keep the generic tenant label when identity is unavailable. } + } } catch (caught) { toaster?.showError(caught); } finally {