diff --git a/.changeset/unable-to-load-session-pane.md b/.changeset/unable-to-load-session-pane.md new file mode 100644 index 000000000..8e2b27486 --- /dev/null +++ b/.changeset/unable-to-load-session-pane.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge-ui': patch +--- + +Show a full-pane "Unable to load session" for inaccessible session deep links and sessions-browser details instead of toasting and redirecting away. diff --git a/packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx b/packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx index b1797e46e..2ceeb6192 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx +++ b/packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx @@ -17,7 +17,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 { getErrorMessage } from '../../utils/getErrorMessage.js'; import { sessionTimeRangeFromCreatedAt } from '../../utils/sessionShareUrl.js'; import { EmptyScreen } from '../EmptyScreen.js'; import { cn } from '../lib/cn.js'; @@ -84,6 +84,7 @@ export function AgentSessions({ const [detailSession, setDetailSession] = useState(); const [detailLoading, setDetailLoading] = useState(false); const [detailFailed, setDetailFailed] = useState(false); + const [detailErrorMessage, setDetailErrorMessage] = useState('This session could not be loaded.'); const [pendingDelete, setPendingDelete] = useState(null); const canDeleteSession = typeof chatServer.deleteSession === 'function'; @@ -191,6 +192,7 @@ export function AgentSessions({ setDetailEvents(undefined); setDetailSession(undefined); setDetailFailed(false); + setDetailErrorMessage('This session could not be loaded.'); setDetailLoading(false); return; } @@ -198,6 +200,7 @@ export function AgentSessions({ let cancelled = false; setDetailLoading(true); setDetailFailed(false); + setDetailErrorMessage('This session could not be loaded.'); setDetailEvents(undefined); setDetailSession(undefined); @@ -219,14 +222,7 @@ export function AgentSessions({ }) .catch((error: unknown) => { if (cancelled) return; - reportSessionAccessError({ - error, - ...(toaster != null ? { showError: toaster.showError } : {}), - }); - if (detailOnly) { - onCloseDetail?.(); - return; - } + setDetailErrorMessage(getErrorMessage(error, 'This session could not be loaded.')); setDetailFailed(true); }) .finally(() => { @@ -236,7 +232,7 @@ export function AgentSessions({ return () => { cancelled = true; }; - }, [chatServer, detailOnly, onCloseDetail, refreshKey, selectedSessionId, sessionsServer, toaster]); + }, [chatServer, refreshKey, selectedSessionId, sessionsServer]); useEffect(() => { onLoadingChange?.(listLoading || detailLoading); }, [detailLoading, listLoading, onLoadingChange]); @@ -321,9 +317,7 @@ export function AgentSessions({ Select a session to view details ) : detailFailed ? ( -
- Session details could not be loaded. -
+ ) : ( <> + ); + } return } />; } diff --git a/packages/trueforge-ui/src/containers/ThreadListContainer.tsx b/packages/trueforge-ui/src/containers/ThreadListContainer.tsx index 3a395082c..1dc34d1ba 100644 --- a/packages/trueforge-ui/src/containers/ThreadListContainer.tsx +++ b/packages/trueforge-ui/src/containers/ThreadListContainer.tsx @@ -268,7 +268,8 @@ function ThreadListItemRow({ pendingSessionId: shell?.pendingSessionId, }); - if ((sameImmutable || sameMutable) && remoteId != null) { + // An access-error pane must go through openHistorySession so the error clears. + if ((sameImmutable || sameMutable) && remoteId != null && shell?.sessionAccessError == null) { void Promise.resolve(aui.threads().switchToThread(id)).catch(() => undefined); return; } diff --git a/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx b/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx index fd610dc4d..ec0c5858a 100644 --- a/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx +++ b/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx @@ -183,7 +183,8 @@ function ChatProviderFromShell({ /** When routing, reports the active thread's remote id up to `ShellRouteSync`. */ onRemoteIdChange?: (id: string | undefined) => void; } & Omit) { - const { mode, runtimeKey, historyAgentFilter, listSessionsAgentId, pendingSessionId } = useShellMode(); + const { mode, runtimeKey, historyAgentFilter, listSessionsAgentId, pendingSessionId, sessionAccessError } = + useShellMode(); const isCreateAgent = mode.status === 'active' && mode.isMutable && mode.isCreateAgent; @@ -238,6 +239,13 @@ function ChatProviderFromShell({ defaultAgentSpec: { model: { name: 'openai-main/gpt-4.1' } }, }; } + // Access failures keep a pending id for the URL but must not bind named runtime to it. + if (sessionAccessError != null) { + return { + mode: 'draft', + defaultAgentSpec: draftDefaultAgentSpec, + }; + } // Runtime still uses named|draft; shell mutability maps onto that adapter. if (!mode.isMutable) { const agentName = mode.agentName ?? mode.agentId ?? pendingSessionId; @@ -254,7 +262,7 @@ function ChatProviderFromShell({ mode: 'draft', defaultAgentSpec: draftDefaultAgentSpec, }; - }, [mode, draftDefaultAgentSpec, pendingSessionId]); + }, [mode, draftDefaultAgentSpec, pendingSessionId, sessionAccessError]); return ( @@ -264,7 +272,7 @@ function ChatProviderFromShell({ server={runtimeServer} agent={agent} listSessionsAgentId={listSessionsAgentId} - initialSessionId={pendingSessionId ?? hostInitialSessionId} + initialSessionId={sessionAccessError != null ? undefined : (pendingSessionId ?? hostInitialSessionId)} > @@ -324,9 +332,9 @@ 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. Analytics wraps the same tree so chrome + chat share one sink. + // Outer toaster for sessions browser / chrome; nested ToasterProvider inside + // TrueForgeChatProvider stays for hosts that mount chat alone. Analytics wraps + // the same tree so chrome + chat share one sink. const shellTree = ( @@ -338,7 +346,6 @@ export function TrueForgeUIShell(props: TrueForgeUIShellProps) { routes={resolvedRoutes} activeRemoteId={activeRemoteId} initialSettingsOpen={initialSettingsOpen} - onError={onError} /> ) : null} diff --git a/packages/trueforge-ui/src/routing/HistorySessionSwitchBridge.tsx b/packages/trueforge-ui/src/routing/HistorySessionSwitchBridge.tsx index 679d1de77..6130bba41 100644 --- a/packages/trueforge-ui/src/routing/HistorySessionSwitchBridge.tsx +++ b/packages/trueforge-ui/src/routing/HistorySessionSwitchBridge.tsx @@ -13,9 +13,9 @@ export function HistorySessionSwitchBridge() { const pendingSessionEpoch = shell?.pendingSessionEpoch ?? 0; useEffect(() => { - if (pendingSessionId == null) return; + if (pendingSessionId == null || shell?.sessionAccessError != null) return; void Promise.resolve(aui.threads().switchToThread(pendingSessionId)).catch(() => undefined); - }, [aui, pendingSessionId, pendingSessionEpoch]); + }, [aui, pendingSessionId, pendingSessionEpoch, shell?.sessionAccessError]); return null; } diff --git a/packages/trueforge-ui/src/routing/ShellRouteSync.tsx b/packages/trueforge-ui/src/routing/ShellRouteSync.tsx index dcecc9a68..fb67f7cea 100644 --- a/packages/trueforge-ui/src/routing/ShellRouteSync.tsx +++ b/packages/trueforge-ui/src/routing/ShellRouteSync.tsx @@ -1,11 +1,10 @@ 'use client'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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, @@ -17,12 +16,12 @@ import { } from '../server/ServerContext.js'; import { libraryAgentId, useShellMode } from '../server/ShellModeContext.js'; import { toEffectiveRoutes } from '../server/serverChrome.js'; +import { getErrorMessage } from '../utils/getErrorMessage.js'; import { readHistoryAgentSearch, 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'; @@ -44,12 +43,10 @@ export function ShellRouteSync({ routes, activeRemoteId, initialSettingsOpen, - onError, }: { routes: ResolvedRoutes; activeRemoteId: string | undefined; initialSettingsOpen: boolean; - onError?: (error: unknown) => void; }) { const shell = useShellMode(); const server = useOptionalServer(); @@ -59,7 +56,6 @@ export function ShellRouteSync({ const sandboxEnvironments = useOptionalSandboxEnvironmentServer(); 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. @@ -89,6 +85,7 @@ export function ShellRouteSync({ environmentsOpen: shell.environmentsOpen, pendingSessionId: shell.pendingSessionId, activeRemoteId, + sessionAccessError: shell.sessionAccessError, mode: shell.mode, agentConfigMode: shell.agentConfigMode, }; @@ -103,9 +100,15 @@ export function ShellRouteSync({ const appliedUrlPlaceRef = useRef(null); const bootedRef = useRef(false); const bootPlaceRef = useRef(null); + // Bumped when a boot session load is abandoned, so shell->URL sync runs again. + const [shellSyncNonce, setShellSyncNonce] = useState(0); const bootHistoryAgentRef = useRef(null); // Latest session id the URL asked for, so slower lookups cannot bind over it. const requestedSessionRef = useRef(null); + // Changes when the user starts a new chat/agent or opens another session. + const chatIdentity = `${shell.runtimeKey}\0${shell.pendingSessionEpoch}`; + const chatIdentityRef = useRef(chatIdentity); + chatIdentityRef.current = chatIdentity; const requestedHistoryAgentRef = useRef(null); // Boot owns the first URL; the ongoing effects skip their initial commit so // they do not fight boot with the stale first-render place. @@ -116,7 +119,7 @@ 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). + * Access denied / missing session: keep the URL and show a full-pane error. */ const goToRoot = useCallback(() => { shell.setSettingsOpen(false); @@ -137,6 +140,16 @@ export function ShellRouteSync({ } }, [shell]); + const releaseAbandonedBootSession = useCallback((sessionId: string) => { + // Boot waits until the derived place matches the deep link. If that load is + // superseded or abandoned, clear the wait so later shell→URL updates can run. + const bootPlace = bootPlaceRef.current; + if (bootPlace?.type === 'session' && bootPlace.sessionId === sessionId) { + bootPlaceRef.current = null; + setShellSyncNonce(nonce => nonce + 1); + } + }, []); + const openSession = useCallback( (sessionId: string) => { requestedSessionRef.current = sessionId; @@ -144,10 +157,14 @@ export function ShellRouteSync({ shell.openHistorySession({ sessionId }); return; } + const requestedChatIdentity = chatIdentityRef.current; void server .getSession({ sessionId }) .then(session => { - if (requestedSessionRef.current !== sessionId) return; + if (requestedSessionRef.current !== sessionId) { + releaseAbandonedBootSession(sessionId); + return; + } shell.openHistorySession({ sessionId, isMutable: session.isMutable, @@ -156,19 +173,22 @@ export function ShellRouteSync({ }); }) .catch((error: unknown) => { - if (requestedSessionRef.current !== sessionId) return; - 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 } : {}), + if (requestedSessionRef.current !== sessionId) { + releaseAbandonedBootSession(sessionId); + return; + } + // The user already left (New Chat, agent, another session): do not pull them back. + if (chatIdentityRef.current !== requestedChatIdentity) { + releaseAbandonedBootSession(sessionId); + return; + } + shell.openInaccessibleSession({ + sessionId, + message: getErrorMessage(error, 'This session could not be loaded.'), }); - goToRoot(); }); }, - [goToRoot, onError, server, shell, toaster], + [releaseAbandonedBootSession, server, shell], ); const applyHistoryAgentSearch = useCallback( @@ -384,7 +404,7 @@ export function ShellRouteSync({ navigate({ pathname: target, search: targetSearch, hash: location.hash }, { replace }); // location.pathname intentionally excluded: only react to shell-derived place changes. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [placeKey, routeGatesKey, shell.historyAgentFilter?.agentName, shell.historyAgentFilter?.intent]); + }, [placeKey, routeGatesKey, shellSyncNonce, shell.historyAgentFilter?.agentName, shell.historyAgentFilter?.intent]); // URL -> shell: apply on genuine location changes (Back/Forward, manual edits). useEffect(() => { diff --git a/packages/trueforge-ui/src/routing/derivePlace.ts b/packages/trueforge-ui/src/routing/derivePlace.ts index 3f31b136a..e7b903257 100644 --- a/packages/trueforge-ui/src/routing/derivePlace.ts +++ b/packages/trueforge-ui/src/routing/derivePlace.ts @@ -6,10 +6,14 @@ import type { RoutePlace, ShellSnapshot } from './types.js'; * SingleAgent canonicalizes to `root` so `/` and `/agents/{name}` alias. */ export function deriveChatPlace(snapshot: ShellSnapshot): RoutePlace { - // The live thread wins: reusing a mutable shell switches thread without a - // remount, leaving `pendingSessionId` on the session the shell last opened. - // It only leads while a requested session has not reported its thread yet. - const sessionId = snapshot.activeRemoteId ?? snapshot.pendingSessionId; + // Access-error panes keep `pendingSessionId` for the deep link. Prefer it over + // a prior thread's `activeRemoteId` so shell→URL does not navigate away. + // Otherwise the live thread wins: reusing a mutable shell switches thread + // without a remount, leaving `pendingSessionId` on the session last opened. + const sessionId = + snapshot.sessionAccessError != null + ? snapshot.pendingSessionId + : (snapshot.activeRemoteId ?? snapshot.pendingSessionId); if (sessionId != null) return { type: 'session', sessionId }; const { mode, agentConfigMode } = snapshot; diff --git a/packages/trueforge-ui/src/routing/types.ts b/packages/trueforge-ui/src/routing/types.ts index 9d7031cc7..efbc55205 100644 --- a/packages/trueforge-ui/src/routing/types.ts +++ b/packages/trueforge-ui/src/routing/types.ts @@ -79,6 +79,8 @@ export type ShellSnapshot = { pendingSessionId?: string; /** Remote id of the active thread once a fresh chat is persisted. */ activeRemoteId?: string; + /** When set, prefer pendingSessionId over a stale activeRemoteId for URL sync. */ + sessionAccessError?: string | null; mode: ShellMode; agentConfigMode: AgentConfig['mode']; }; diff --git a/packages/trueforge-ui/src/server/ShellModeContext.tsx b/packages/trueforge-ui/src/server/ShellModeContext.tsx index ed46390f7..24e0b33d3 100644 --- a/packages/trueforge-ui/src/server/ShellModeContext.tsx +++ b/packages/trueforge-ui/src/server/ShellModeContext.tsx @@ -154,6 +154,13 @@ type ShellModeContextValue = { isMutable?: boolean; isCreateAgent?: boolean; }) => void; + /** + * Keep `/sessions/:id` in place and surface a full-pane load failure + * (no toast / redirect). Cleared when opening another session or draft. + */ + openInaccessibleSession: (req: { sessionId: string; message: string }) => void; + /** Backend/access message when the current pending session could not be opened. */ + sessionAccessError: string | null; /** Reset current chat; no-op when idle. */ clearChat: () => void; /** Return library-only mode to its idle landing; no-op in other modes. */ @@ -278,6 +285,7 @@ export function ShellModeProvider({ const [historyAgentFilter, setHistoryAgentFilter] = useState(null); const [pendingSessionId, setPendingSessionId] = useState(undefined); const [pendingSessionEpoch, setPendingSessionEpoch] = useState(0); + const [sessionAccessError, setSessionAccessError] = useState(null); const settingsEnabled = isSettingsChromeEnabled({ catalog, capabilities }); const settingsOpen = settingsEnabled && settingsOpenState; const sessionsEnabled = isSessionsChromeEnabled({ sessions: sessionsServer }); @@ -491,6 +499,7 @@ export function ShellModeProvider({ setSessionsOpen(false); setSchedulesOpen(false); setEnvironmentsOpen(false); + setSessionAccessError(null); setPendingSessionId(undefined); const isCreateAgent = resolveMutableIsCreateAgent(req); const kind: DraftPreferenceKind = isCreateAgent ? 'agent' : 'chat'; @@ -517,6 +526,7 @@ export function ShellModeProvider({ setSessionsOpen(false); setSchedulesOpen(false); setEnvironmentsOpen(false); + setSessionAccessError(null); setPendingSessionId(undefined); setAgentConfigOpenState(false); setMode({ @@ -612,6 +622,7 @@ export function ShellModeProvider({ setSessionsOpen(false); setSchedulesOpen(false); setEnvironmentsOpen(false); + setSessionAccessError(null); setAgentConfigOpenState(true); return; } @@ -722,6 +733,7 @@ export function ShellModeProvider({ setSessionsOpen(false); setSchedulesOpen(false); setEnvironmentsOpen(false); + setSessionAccessError(null); setPendingSessionId(sessionId); setPendingSessionEpoch(n => n + 1); if (isMutable) { @@ -757,8 +769,42 @@ export function ShellModeProvider({ ], ); + const openInaccessibleSession = useCallback( + ({ sessionId, message }: { sessionId: string; message: string }) => { + setSettingsOpen(false); + setLibraryOpenState(false); + setLibraryAgentId(null); + setSessionsOpen(false); + setSchedulesOpen(false); + setEnvironmentsOpen(false); + setAgentConfigOpenState(false); + setSessionAccessError(message); + setPendingSessionId(sessionId); + setPendingSessionEpoch(n => n + 1); + // Keep chrome non-idle so layouts render the thread slot (gated by sessionAccessError). + if (isComposerEnabled) { + setMode({ + status: 'active', + isMutable: true, + isCreateAgent: false, + agentSpec: chatSeedRef.current, + locked: false, + }); + return; + } + setMode({ + status: 'active', + isMutable: false, + isCreateAgent: false, + locked: false, + }); + }, + [isComposerEnabled, setSettingsOpen, setSessionsOpen, setSchedulesOpen, setEnvironmentsOpen], + ); + const clearChat = useCallback(() => { if (effectiveMode.status === 'idle') return; + setSessionAccessError(null); setPendingSessionId(undefined); if (effectiveMode.isMutable) { // Preserve Edit / builder vs chat intent. New Chat must use the latest @@ -791,6 +837,7 @@ export function ShellModeProvider({ const openLibraryHome = useCallback(() => { // Only library-only mode has an idle landing; other modes root via openDraft / clearChat. if (!isLibraryEnabled || isComposerEnabled) return; + setSessionAccessError(null); setPendingSessionId(undefined); setSettingsOpen(false); setLibraryOpenState(false); @@ -842,6 +889,8 @@ export function ShellModeProvider({ resetAgentBuilder, rememberDraftSpec, openHistorySession, + openInaccessibleSession, + sessionAccessError, clearChat, openLibraryHome, runtimeKey, @@ -888,6 +937,8 @@ export function ShellModeProvider({ resetAgentBuilder, rememberDraftSpec, openHistorySession, + openInaccessibleSession, + sessionAccessError, clearChat, openLibraryHome, runtimeKey, diff --git a/packages/trueforge-ui/src/utils/sessionAccessError.ts b/packages/trueforge-ui/src/utils/sessionAccessError.ts deleted file mode 100644 index bd7cfa60b..000000000 --- a/packages/trueforge-ui/src/utils/sessionAccessError.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * 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 0adcba070..232e7423f 100644 --- a/packages/trueforge-ui/test/atoms/SessionsPage.test.tsx +++ b/packages/trueforge-ui/test/atoms/SessionsPage.test.tsx @@ -250,7 +250,7 @@ describe('SessionsPage', () => { expect(screen.getByText('us-west-2')).toBeInTheDocument(); }); - it('toasts and keeps the inline failure when a non-share detail load is forbidden', async () => { + it('shows a full-pane Unable to load session 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, @@ -266,8 +266,8 @@ describe('SessionsPage', () => { }), }); - 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(await screen.findByRole('heading', { name: 'Unable to load session' })).toBeInTheDocument(); + expect(screen.getByText('Only the session creator can access this session')).toBeInTheDocument(); expect(window.location.search).toContain('sessionId=sess-1'); }); diff --git a/packages/trueforge-ui/test/routing/ShellRouteSync.test.tsx b/packages/trueforge-ui/test/routing/ShellRouteSync.test.tsx index 3a06a4618..30fe54c5b 100644 --- a/packages/trueforge-ui/test/routing/ShellRouteSync.test.tsx +++ b/packages/trueforge-ui/test/routing/ShellRouteSync.test.tsx @@ -1,8 +1,8 @@ // @vitest-environment jsdom 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, vi } from 'vitest'; +import { MemoryRouter, useLocation, useNavigate, type NavigateFunction } from 'react-router-dom'; +import { describe, expect, it } from 'vitest'; import { resolveRoutesConfig } from '@/routing/paths.js'; import { ShellRouteSync } from '@/routing/ShellRouteSync.js'; @@ -24,6 +24,7 @@ let shell: Shell; let pathname = ''; let search = ''; let setRemoteId: (id: string | undefined) => void = () => undefined; +let navigateApp: NavigateFunction = () => undefined; function CaptureShell() { shell = useShellMode(); @@ -32,8 +33,10 @@ function CaptureShell() { function CaptureLocation() { const location = useLocation(); + const navigate = useNavigate(); pathname = location.pathname; search = location.search; + navigateApp = navigate; useEffect(() => { pathname = location.pathname; search = location.search; @@ -108,7 +111,6 @@ function Harness({ includeSchedules = false, includeEnvironments = false, getSession, - onError, }: { agentConfig?: AgentConfig; initialRemoteId?: string; @@ -127,7 +129,6 @@ function Harness({ updatedAt: string; agentName?: string; }>; - onError?: (error: unknown) => void; }) { const [remoteId, setId] = useState(initialRemoteId); setRemoteId = setId; @@ -144,12 +145,7 @@ function Harness({ - + ); @@ -158,6 +154,7 @@ function Harness({ function renderSync(opts: { initialEntries?: string[]; agentConfig?: AgentConfig; + initialRemoteId?: string; initialSettingsOpen?: boolean; settingsEnabled?: boolean; capabilitiesFail?: boolean; @@ -173,13 +170,13 @@ function renderSync(opts: { updatedAt: string; agentName?: string; }>; - onError?: (error: unknown) => void; strict?: boolean; }) { const tree = ( ); @@ -202,8 +198,7 @@ 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(); + it('keeps the session URL and surfaces a pane error when a deep link is forbidden', async () => { const forbidden = Object.assign(new Error('Only the session creator can access this session'), { statusCode: 403, }); @@ -212,38 +207,153 @@ describe('ShellRouteSync', () => { getSession: async () => { throw forbidden; }, - onError, }); await waitFor(() => { - expect(onError).toHaveBeenCalledOnce(); + expect(shell.sessionAccessError).toBe('Only the session creator can access this session'); + }); + expect(shell.pendingSessionId).toBe('forbidden'); + expect(pathname).toBe('/sessions/forbidden'); + }); + + it('keeps the inaccessible deep-link URL even when a prior thread remote id is still live', async () => { + renderSync({ + initialEntries: ['/sessions/prior'], + initialRemoteId: 'prior', + getSession: async ({ sessionId }) => { + if (sessionId === 'forbidden') { + throw Object.assign(new Error('Only the session creator can access this session'), { statusCode: 403 }); + } + return { + id: sessionId, + title: 'Session', + isMutable: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }; + }, + }); + // Prior thread is already live via activeRemoteId, so boot skips openSession. + await waitFor(() => expect(pathname).toBe('/sessions/prior')); + + act(() => { + navigateApp('/sessions/forbidden'); }); - expect(onError).toHaveBeenCalledWith(forbidden); + await waitFor(() => { - expect(pathname).toBe('/'); + expect(shell.sessionAccessError).toBe('Only the session creator can access this session'); }); - expect(shell.pendingSessionId).toBeUndefined(); + expect(shell.pendingSessionId).toBe('forbidden'); + // Must not snap back to the prior live thread while the error pane is showing. + await waitFor(() => expect(pathname).toBe('/sessions/forbidden')); + await act(async () => { + await Promise.resolve(); + }); + expect(pathname).toBe('/sessions/forbidden'); + }); + + it('releases the boot URL wait when a superseded session load fails', async () => { + const resolvers = new Map< + string, + { + resolve: (value: { + id: string; + title: string; + isMutable: boolean; + createdAt: string; + updatedAt: string; + }) => void; + reject: (error: unknown) => void; + } + >(); + renderSync({ + initialEntries: ['/sessions/boot'], + getSession: ({ sessionId }) => + new Promise((resolve, reject) => { + resolvers.set(sessionId, { resolve, reject }); + }), + }); + await waitFor(() => expect(resolvers.has('boot')).toBe(true)); + + // URL→shell opens another session while boot is still in flight (requestedSessionRef moves). + act(() => { + navigateApp('/sessions/other'); + }); + await waitFor(() => expect(resolvers.has('other')).toBe(true)); + + await act(async () => { + resolvers + .get('boot') + ?.reject(Object.assign(new Error('Only the session creator can access this session'), { statusCode: 403 })); + await Promise.resolve(); + }); + expect(shell.sessionAccessError).toBeNull(); + + await act(async () => { + resolvers.get('other')?.resolve({ + id: 'other', + title: 'Other', + isMutable: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }); + await Promise.resolve(); + }); + await waitFor(() => expect(shell.pendingSessionId).toBe('other')); + + // Without releasing the abandoned boot wait, New Chat could not update the URL. + act(() => { + shell.openDraft(); + }); + await waitFor(() => expect(pathname).toBe('/')); }); - it('toasts and redirects to New Chat when a session deep link is not found', async () => { - const onError = vi.fn(); + it('keeps the session URL and surfaces a pane error when a deep link is not found', async () => { 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(shell.sessionAccessError).toBe('Session not found: missing'); }); - expect(onError).toHaveBeenCalledWith(notFound); - await waitFor(() => { - expect(pathname).toBe('/'); + expect(shell.pendingSessionId).toBe('missing'); + expect(pathname).toBe('/sessions/missing'); + }); + + it('ignores a late access failure after the user starts a New Chat', async () => { + let rejectLoad: ((error: unknown) => void) | undefined; + renderSync({ + initialEntries: ['/sessions/slow'], + getSession: () => + new Promise((_, reject) => { + rejectLoad = reject; + }), }); + await waitFor(() => expect(rejectLoad).toBeDefined()); + const loadingRuntimeKey = shell.runtimeKey; + + act(() => { + shell.openDraft(); + }); + expect(shell.runtimeKey).not.toBe(loadingRuntimeKey); + + await act(async () => { + rejectLoad?.(Object.assign(new Error('Only the session creator can access this session'), { statusCode: 403 })); + await Promise.resolve(); + }); + + expect(shell.sessionAccessError).toBeNull(); expect(shell.pendingSessionId).toBeUndefined(); + await waitFor(() => expect(pathname).toBe('/')); + + act(() => { + shell.openHistorySession({ sessionId: 'later', isMutable: true }); + }); + await waitFor(() => expect(pathname).toBe('/sessions/later')); }); it('applies an agent deep link on boot and resolves its history filter id', async () => { diff --git a/packages/trueforge-ui/test/routing/derivePlace.test.ts b/packages/trueforge-ui/test/routing/derivePlace.test.ts index e827f6887..c449208ca 100644 --- a/packages/trueforge-ui/test/routing/derivePlace.test.ts +++ b/packages/trueforge-ui/test/routing/derivePlace.test.ts @@ -82,6 +82,18 @@ describe('derivePlace', () => { }); }); + it('access-error panes keep the pending deep-link id over a stale activeRemoteId', () => { + expect( + derivePlace( + snap({ + pendingSessionId: 'forbidden', + activeRemoteId: 'prior-thread', + sessionAccessError: 'Only the session creator can access this session', + }), + ), + ).toEqual({ type: 'session', sessionId: 'forbidden' }); + }); + it('agent builder moves to the session place after its session persists', () => { expect( deriveChatPlace( diff --git a/packages/trueforge-ui/test/routing/sessionRouting.test.tsx b/packages/trueforge-ui/test/routing/sessionRouting.test.tsx index bf5a7d9c9..14c032e04 100644 --- a/packages/trueforge-ui/test/routing/sessionRouting.test.tsx +++ b/packages/trueforge-ui/test/routing/sessionRouting.test.tsx @@ -16,9 +16,13 @@ import { ThreadListContainer } from '@/containers/ThreadListContainer.js'; import { resolveRoutesConfig } from '@/routing/paths.js'; import { RemoteIdRouteBridge } from '@/routing/RemoteIdRouteBridge.js'; import { ShellRouteSync } from '@/routing/ShellRouteSync.js'; -import { ShellModeProvider } from '@/server/ShellModeContext.js'; +import { ServerProvider } from '@/server/ServerContext.js'; +import { ShellModeProvider, useShellMode } from '@/server/ShellModeContext.js'; +import type { AgentUIServer } from '@/server/types.js'; import { SlotsProvider } from '@/theme/SlotsProvider.js'; +import { createMockAgentUIServer } from '../server/mockServer.js'; + const routes = resolveRoutesConfig(); let pathname = ''; @@ -60,20 +64,28 @@ function RuntimeHarness({ threadList, children }: { threadList: ExternalStoreThr return {children}; } +function SessionAccessErrorProbe() { + const { sessionAccessError } = useShellMode(); + return {sessionAccessError ?? ''}; +} + /** Mirrors production: sync outside the chat runtime, remote-id bridge inside it. */ function Harness({ threads, onSwitchToThread, + server, }: { threads: NonNullable; onSwitchToThread?: (id: string) => void; + server?: AgentUIServer; }) { const [activeRemoteId, setActiveRemoteId] = useState(undefined); const [threadId, setThreadId] = useState('thread-draft'); - return ( + const tree = ( + ); + return server == null ? tree : {tree}; } describe('selecting a session updates the URL', () => { @@ -220,4 +233,43 @@ describe('selecting a session updates the URL', () => { expect(search).toBe('?tenant=acme'); expect(hash).toBe('#top'); }); + + it('clears the access-error pane when picking another history row', async () => { + const forbidden = Object.assign(new Error('Only the session creator can access this session'), { + statusCode: 403, + }); + render( + + + { + throw forbidden; + }, + })} + threads={[ + { status: 'regular', id: 'thread-draft', title: 'New Chat' }, + { status: 'regular', id: 'thread-2', remoteId: 'session-2', title: 'Second' }, + ]} + /> + , + ); + + await waitFor(() => { + expect(screen.getByTestId('session-access-error')).toHaveTextContent( + 'Only the session creator can access this session', + ); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Second' })); + }); + + await waitFor(() => { + expect(screen.getByTestId('session-access-error')).toBeEmptyDOMElement(); + }); + await waitFor(() => { + expect(pathname).toBe('/sessions/session-2'); + }); + }); }); diff --git a/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx b/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx index ed28d9924..06406353d 100644 --- a/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx +++ b/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx @@ -220,7 +220,7 @@ describe('withRouter end to end', () => { expect(listSessions).toHaveBeenCalled(); }); - it('toasts and redirects to the sessions list when a shared session is forbidden', async () => { + it('keeps the shared session URL and shows Unable to load session when 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, @@ -245,12 +245,10 @@ describe('withRouter end to end', () => { />, ); - 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(); + expect(await screen.findByRole('heading', { name: 'Unable to load session' })).toBeInTheDocument(); + expect(screen.getByText('Only the session creator can access this session')).toBeInTheDocument(); + expect(window.location.pathname).toBe('/sessions/share/session-forbidden'); + expect(screen.queryByRole('button', { name: 'Last 30 days' })).not.toBeInTheDocument(); }); it('applies a /sessions/:id deep link on boot', async () => { diff --git a/packages/trueforge-ui/test/utils/sessionAccessError.test.ts b/packages/trueforge-ui/test/utils/sessionAccessError.test.ts deleted file mode 100644 index 7a231d59f..000000000 --- a/packages/trueforge-ui/test/utils/sessionAccessError.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -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(); - }); -});