diff --git a/.changeset/share-session-permission-dialog.md b/.changeset/share-session-permission-dialog.md new file mode 100644 index 000000000..4993ea84d --- /dev/null +++ b/.changeset/share-session-permission-dialog.md @@ -0,0 +1,6 @@ +--- +"@truefoundry/trueforge-ui": minor +"@truefoundry/trueforge-assistant-ui-runtime": patch +--- + +Share sessions with tenant members (popover, `shared` PATCH, share routes), toast and redirect on forbidden/missing deep links, and stop the New Chat → named-history max-update-depth loop. diff --git a/packages/assistant-ui-runtime/src/server/types.ts b/packages/assistant-ui-runtime/src/server/types.ts index da137743f..7a9c04d63 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 and its turns/events 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 and its turns/events by id. */ + shared?: boolean; } // --------------------------------------------------------------------------- @@ -1179,6 +1183,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/assistant-ui-runtime/src/useTrueForgeAgentRuntime.ts b/packages/assistant-ui-runtime/src/useTrueForgeAgentRuntime.ts index a7b10e71a..23a29b534 100644 --- a/packages/assistant-ui-runtime/src/useTrueForgeAgentRuntime.ts +++ b/packages/assistant-ui-runtime/src/useTrueForgeAgentRuntime.ts @@ -35,6 +35,8 @@ import type { UseTrueForgeAgentRuntimeOptions } from './types.js'; import { resolveTrueForgeAgentRuntimeOptions } from './types.js'; import { useTrueForgeAgentMessages } from './useTrueForgeAgentMessages.js'; +const EMPTY_NAMED_DEFAULT_SPEC: AgentSpec = { model: { name: '' } }; + /** * Wraps the mode-specific adapter behind a stable object so assistant-ui does * not treat a draft/named mode switch as an adapter change (which would reset @@ -61,6 +63,12 @@ function useTrueForgeAgentRuntimeImpl( const { server, agent, adapters, onError, ...sharedOptions } = options; const draftBridgeRef = useRef(agent.mode === 'draft' ? createDraftSessionBridge(server) : null); + if (agent.mode === 'draft' && draftBridgeRef.current == null) { + draftBridgeRef.current = createDraftSessionBridge(server); + } + // Named sessions keep the same runtimeKey as New Chat; do not keep the draft + // bridge armed or a fresh `{ model: { name: '' } }` will retrigger spec sync. + const draftBridge = agent.mode === 'draft' ? draftBridgeRef.current : null; const draftSessionId = useAuiState(state => agent.mode === 'draft' ? (state.threadListItem.remoteId ?? undefined) : undefined, @@ -73,8 +81,8 @@ function useTrueForgeAgentRuntimeImpl( const draftSpec = useDraftAgentSpec({ draftSessionId, - draftBridge: draftBridgeRef.current, - defaultAgentSpec: agent.mode === 'draft' ? agent.defaultAgentSpec : { model: { name: '' } }, + draftBridge, + defaultAgentSpec: agent.mode === 'draft' ? agent.defaultAgentSpec : EMPTY_NAMED_DEFAULT_SPEC, onAgentSpecChange: agent.mode === 'draft' ? agent.onAgentSpecChange : undefined, onError, }); diff --git a/packages/trueforge-ui/docs/customization.md b/packages/trueforge-ui/docs/customization.md index 2bfba623f..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`, @@ -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/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/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 new file mode 100644 index 000000000..4fe9d48cb --- /dev/null +++ b/packages/trueforge-ui/src/atoms/ShareChatButton.tsx @@ -0,0 +1,35 @@ +'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; + + const trigger = ( + + + Share + + ); + + if (!canManageSession) { + return {trigger}; + } + + return ; +} + +declare module '../theme/SlotsProvider.js' { + interface AtomSlots { + ShareChatButton: typeof ShareChatButton; + } +} diff --git a/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx b/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx new file mode 100644 index 000000000..9c95e3874 --- /dev/null +++ b/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx @@ -0,0 +1,156 @@ +'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-120 gap-5 rounded-[0.75rem] p-6 shadow-xs" + > +
+ +

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/ThreadShell.tsx b/packages/trueforge-ui/src/atoms/ThreadShell.tsx index 5dd73a9c0..31d283ff0 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,30 @@ 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; + /** Override the conversation content width without changing the scroll root width. */ + contentMaxWidth?: string; }; export const ThreadViewportShell = forwardRef( - ({ className, isEmpty, children, ...rest }, ref) => ( + ({ className, isEmpty, scrollable = true, contentMaxWidth, children, ...rest }, ref) => (
{children}
diff --git a/packages/trueforge-ui/src/atoms/agent-details/AgentSessionDetailHeader.tsx b/packages/trueforge-ui/src/atoms/agent-details/AgentSessionDetailHeader.tsx index d470ac83e..b3f75cc16 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/AgentSessionDetailHeader.tsx +++ b/packages/trueforge-ui/src/atoms/agent-details/AgentSessionDetailHeader.tsx @@ -1,65 +1,49 @@ 'use client'; -import { useEffect, useState } from 'react'; - import { Icon } from '../../icons/Icon.js'; import { useSlot } from '../../theme/SlotsProvider.js'; -import { buildAgentSessionShareUrl } from '../../utils/sessionShareUrl.js'; import { auiButtonClass } from '../lib/buttonClasses.js'; import { cn } from '../lib/cn.js'; import { Button } from '../primitives/Button.js'; -import { LightTooltip } from '../primitives/Tooltip.js'; import type { AgentSessionDetailHeaderProps } from './types.js'; export { buildAgentSessionShareUrl } from '../../utils/sessionShareUrl.js'; +function SessionsShareTrigger({ disabled }: { disabled?: boolean }) { + return ( + + ); +} + export function AgentSessionDetailHeader({ title, sessionId, - agentId, - createdAt, - view, onClose, resumeHref, onResume, resumeLabel, canResume = true, + canShare = true, }: AgentSessionDetailHeaderProps) { - const [copied, setCopied] = useState(false); const PermissionGuard = useSlot('PermissionGuard'); - - useEffect(() => { - if (!copied) return undefined; - const timer = window.setTimeout(() => setCopied(false), 2000); - return () => window.clearTimeout(timer); - }, [copied]); - - const copySessionLink = async () => { - try { - await navigator.clipboard.writeText(buildAgentSessionShareUrl({ sessionId, agentId, createdAt, view })); - setCopied(true); - } catch { - // Clipboard may be unavailable; ignore. - } - }; + const ShareSessionDialog = useSlot('ShareSessionDialog'); return (

{title}

{sessionId} - - void copySessionLink()} - > - - -
+ {canShare ? ( + } /> + ) : ( + + + + )} {resumeLabel != null && resumeHref != null && canResume ? ( { + cancelled = true; + }; + } setListLoading(true); setListLoadingMore(false); setListLoadMoreFailed(false); @@ -127,7 +142,7 @@ export function AgentSessions({ 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. @@ -189,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); @@ -206,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; @@ -220,6 +244,10 @@ export function AgentSessions({ const clearSelectedSession = () => { if (selectedSessionId == null) return; + if (detailOnly) { + onCloseDetail?.(); + return; + } updateShareSearch({ sessionId: null }); }; @@ -272,7 +300,47 @@ export function AgentSessions({ const resumeProps = resumeHref != null ? { resumeHref, resumeLabel } : shell != null ? { onResume: handleResume, resumeLabel } : {}; - const selectedCreatedAt = detailSession?.createdAt ?? selectedEntry?.createdAt; + + 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). @@ -379,41 +447,7 @@ export function AgentSessions({ -
- {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 c2bbf28ea..816966c18 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx +++ b/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx @@ -4,6 +4,7 @@ import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { useSessionShareSearch } from '../../hooks/useSessionShareSearch.js'; import { useOptionalAgentSessionsServer } from '../../server/ServerContext.js'; +import { useShellMode } from '../../server/ShellModeContext.js'; import { useSlot } from '../../theme/SlotsProvider.js'; import { defaultSessionTimeRange, @@ -17,9 +18,11 @@ import { Skeleton } from '../primitives/Skeleton.js'; export function SessionsPage() { const sessionsServer = useOptionalAgentSessionsServer(); + const shell = useShellMode(); const { updateShareSearch } = useSessionShareSearch(); const AgentSessions = useSlot('AgentSessions'); const AgentSessionsFilters = useSlot('AgentSessionsFilters'); + const sharedSessionId = shell.sharedSessionId; const [agentFilter, setAgentFilter] = useState( () => readSessionShareSearch(window.location.search).agentId, @@ -29,6 +32,7 @@ export function SessionsPage() { ); useEffect(() => { + if (sharedSessionId != null) return; const share = readSessionShareSearch(window.location.search); updateShareSearch({ view: 'sessions', @@ -65,18 +69,20 @@ export function SessionsPage() { { - setAgentFilter(nextAgentId); - updateShareSearch({ agentId: nextAgentId, sessionId: null, view: 'sessions' }); - }} - onTimeRangeChange={nextRange => { - setTimeRange(nextRange); - updateShareSearch({ timeRange: nextRange, sessionId: null, view: 'sessions' }); - }} - /> + sharedSessionId == null ? ( + { + setAgentFilter(nextAgentId); + updateShareSearch({ agentId: nextAgentId, sessionId: null, view: 'sessions' }); + }} + onTimeRangeChange={nextRange => { + setTimeRange(nextRange); + updateShareSearch({ timeRange: nextRange, sessionId: null, view: 'sessions' }); + }} + /> + ) : null } />
@@ -94,8 +100,16 @@ export function SessionsPage() { agentId={agentFilter ?? undefined} startTimestamp={new Date(resolved.startTs).toISOString()} endTimestamp={new Date(resolved.endTs).toISOString()} - shareView="sessions" - {...(showLoadRecentSessions ? { onLoadRecentSessions: loadRecentSessions } : {})} + {...(sharedSessionId == null + ? { + shareView: 'sessions' as const, + ...(showLoadRecentSessions ? { onLoadRecentSessions: loadRecentSessions } : {}), + } + : { + 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 1cc8af7e7..752c98241 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; /** Restores the rolling recent-session window from a URL-loaded session. */ onLoadRecentSessions?: () => void; }; @@ -43,9 +47,6 @@ export type AgentSessionListRowProps = { export type AgentSessionDetailHeaderProps = { title: string; sessionId: string; - agentId?: string; - createdAt?: string; - view?: 'sessions' | null; onClose: () => void; /** * When set with `resumeLabel`, shows Resume Chat / Resume Agent building as a @@ -58,6 +59,8 @@ export type AgentSessionDetailHeaderProps = { resumeLabel?: string; /** Whether the current user may resume this session. */ canResume?: boolean; + /** Whether the current user may share this session. Defaults to true. */ + canShare?: boolean; }; export type AgentSessionTurnHeaderProps = { diff --git a/packages/trueforge-ui/src/atoms/primitives/DropdownMenu.tsx b/packages/trueforge-ui/src/atoms/primitives/DropdownMenu.tsx index 631ad2b5e..6d83da954 100644 --- a/packages/trueforge-ui/src/atoms/primitives/DropdownMenu.tsx +++ b/packages/trueforge-ui/src/atoms/primitives/DropdownMenu.tsx @@ -17,6 +17,8 @@ export type DropdownMenuProps = { onOpenChange?: (open: boolean) => 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..a81a91658 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,28 +148,36 @@ 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; - setPos( - tooltipEl - ? clampCenteredTooltip({ - ...next, - width: tooltipEl.offsetWidth, - height: tooltipEl.offsetHeight, - side, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight, - }) - : next, - ); + if (tooltipEl == null) { + setPos(next); + return; + } + const size = { + ...next, + width: tooltipEl.offsetWidth, + height: tooltipEl.offsetHeight, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + }; + setPos(isVerticalSide(side) ? clampCenteredTooltip({ ...size, side }) : clampEdgeTooltip({ ...size, side })); }; useLayoutEffect(() => { @@ -184,7 +241,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 +270,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/containers/AgentSessionTimelineContainer.tsx b/packages/trueforge-ui/src/containers/AgentSessionTimelineContainer.tsx index 433d30fa7..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'); @@ -207,14 +213,23 @@ export function AgentSessionTimelineContainer({ sessionId, events, listMetrics } } return ( -
-
+
+
+
+
- +
{turnViews.map(turn => ( ; + // 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/hooks/useCopySharedSessionLink.ts b/packages/trueforge-ui/src/hooks/useCopySharedSessionLink.ts new file mode 100644 index 000000000..a0adcacf4 --- /dev/null +++ b/packages/trueforge-ui/src/hooks/useCopySharedSessionLink.ts @@ -0,0 +1,32 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; + +import { buildSharedSessionHref } from '../routing/paths.js'; +import { useOptionalResolvedRoutes } from '../routing/ResolvedRoutesContext.js'; + +export function useCopySharedSessionLink(sessionId: string | null | undefined): { + copied: boolean; + copySharedSessionLink: () => 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/hooks/useShareSessionDialog.ts b/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts new file mode 100644 index 000000000..6d2856bbc --- /dev/null +++ b/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts @@ -0,0 +1,86 @@ +'use client'; + +import { useCallback, useState } from 'react'; + +import { useToasterOptional } from '../containers/ToasterContainer.js'; +import { buildSharedSessionHref } from '../routing/paths.js'; +import { useOptionalResolvedRoutes } from '../routing/ResolvedRoutesContext.js'; +import { useOptionalServer } from '../server/ServerContext.js'; +import { useCopySharedSessionLink } from './useCopySharedSessionLink.js'; +import { useResourcePermissions } from './useResourcePermissions.js'; + +export type SessionSharePermission = 'private' | 'tenant'; + +export function useShareSessionDialog(sessionId: string | null | undefined): { + permission: SessionSharePermission; + canManage: boolean; + loading: boolean; + shareUrl: string; + copied: boolean; + tenantId: string | undefined; + load: () => 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) => { + if (!canManage || next === permission) return; + 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); + } + }, + [canManage, 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 2df5d850a..8716faa43 100644 --- a/packages/trueforge-ui/src/index.ts +++ b/packages/trueforge-ui/src/index.ts @@ -292,6 +292,9 @@ export { sessionIsCreateAgent, } 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/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 ? ( } /> + + + + , + ); + 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('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(); + + 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/atoms/ThreadShell.test.tsx b/packages/trueforge-ui/test/atoms/ThreadShell.test.tsx index cbac3f538..7f1875ea1 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,19 @@ 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'); + }); + + it('supports a wider content surface without changing the default', () => { + render(); + + expect(screen.getByTestId('viewport').firstElementChild).toHaveStyle({ maxWidth: '60rem' }); + }); }); describe('ThreadComposerAreaShell', () => { 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( 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/containers/TrueForgeUI.test.tsx b/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx index 777e7b68b..6564a6d5f 100644 --- a/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx +++ b/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx @@ -778,6 +778,10 @@ describe('layout slot overrides', () => { return ; } + 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..2fac93335 100644 --- a/packages/trueforge-ui/test/publicUiExports.test.ts +++ b/packages/trueforge-ui/test/publicUiExports.test.ts @@ -117,6 +117,8 @@ const expectedRuntimeExports: Array = [ 'ServerProvider', 'SessionsBrowserButton', 'SessionsPage', + 'ShareChatButton', + 'ShareSessionDialog', 'ShellActionsActionSlot', 'ShellModeProvider', 'SideDrawer', 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/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/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..d8f1f4325 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,10 +108,23 @@ function ThreadListHost() { ); } +function SessionsSurface() { + const shell = useShellMode(); + return shell.sessionsOpen ? : ; +} + 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={() => } @@ -119,6 +134,100 @@ function renderApp() { } describe('withRouter end to end', () => { + 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: ({ 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' })); + await waitFor(() => { + expect(window.location.pathname).toBe('/sessions'); + }); + expect(await screen.findByRole('button', { name: 'Last 30 days' })).toBeInTheDocument(); + 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/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/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(); + }); +}); 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,