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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/unable-to-load-session-pane.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 7 additions & 13 deletions packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -84,6 +84,7 @@ export function AgentSessions({
const [detailSession, setDetailSession] = useState<Session>();
const [detailLoading, setDetailLoading] = useState(false);
const [detailFailed, setDetailFailed] = useState(false);
const [detailErrorMessage, setDetailErrorMessage] = useState('This session could not be loaded.');
const [pendingDelete, setPendingDelete] = useState<SessionListEntry | null>(null);

const canDeleteSession = typeof chatServer.deleteSession === 'function';
Expand Down Expand Up @@ -191,13 +192,15 @@ export function AgentSessions({
setDetailEvents(undefined);
setDetailSession(undefined);
setDetailFailed(false);
setDetailErrorMessage('This session could not be loaded.');
setDetailLoading(false);
return;
}

let cancelled = false;
setDetailLoading(true);
setDetailFailed(false);
setDetailErrorMessage('This session could not be loaded.');
setDetailEvents(undefined);
setDetailSession(undefined);

Expand All @@ -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(() => {
Expand All @@ -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]);
Expand Down Expand Up @@ -321,9 +317,7 @@ export function AgentSessions({
Select a session to view details
</div>
) : detailFailed ? (
<div className="flex flex-1 items-center justify-center px-6 text-sm text-text-secondary">
Session details could not be loaded.
</div>
<EmptyScreen title="Unable to load session" description={detailErrorMessage} className="bg-primary-bg" />
) : (
<>
<AgentSessionDetailHeader
Expand Down
8 changes: 8 additions & 0 deletions packages/trueforge-ui/src/containers/Thread.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
'use client';

import { EmptyScreen } from '../atoms/EmptyScreen.js';
import { useOptionalShellMode } from '../server/ShellModeContext.js';
import { ComposerContainer } from './ComposerContainer.js';
import { ThreadContainer } from './ThreadContainer.js';

/** The full assembled thread view: message list + composer. */
export function Thread() {
const shell = useOptionalShellMode();
if (shell?.sessionAccessError != null) {
return (
<EmptyScreen title="Unable to load session" description={shell.sessionAccessError} className="bg-primary-bg" />
);
}
return <ThreadContainer composer={<ComposerContainer />} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
21 changes: 14 additions & 7 deletions packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,8 @@ function ChatProviderFromShell({
/** When routing, reports the active thread's remote id up to `ShellRouteSync`. */
onRemoteIdChange?: (id: string | undefined) => void;
} & Omit<TrueForgeChatProviderProps, 'agent' | 'agentName' | 'listSessionsAgentId' | 'children'>) {
const { mode, runtimeKey, historyAgentFilter, listSessionsAgentId, pendingSessionId } = useShellMode();
const { mode, runtimeKey, historyAgentFilter, listSessionsAgentId, pendingSessionId, sessionAccessError } =
useShellMode();

const isCreateAgent = mode.status === 'active' && mode.isMutable && mode.isCreateAgent;

Expand Down Expand Up @@ -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;
Expand All @@ -254,7 +262,7 @@ function ChatProviderFromShell({
mode: 'draft',
defaultAgentSpec: draftDefaultAgentSpec,
};
}, [mode, draftDefaultAgentSpec, pendingSessionId]);
}, [mode, draftDefaultAgentSpec, pendingSessionId, sessionAccessError]);

return (
<DraftCatalogProvider>
Expand All @@ -264,7 +272,7 @@ function ChatProviderFromShell({
server={runtimeServer}
agent={agent}
listSessionsAgentId={listSessionsAgentId}
initialSessionId={pendingSessionId ?? hostInitialSessionId}
initialSessionId={sessionAccessError != null ? undefined : (pendingSessionId ?? hostInitialSessionId)}
Comment thread
cursor[bot] marked this conversation as resolved.
>
<AgentConfigInstructionsProvider>
<DraftSpecPreferenceBridge />
Expand Down Expand Up @@ -324,9 +332,9 @@ export function TrueForgeUIShell(props: TrueForgeUIShellProps) {
const server = resolved.server;
const layoutTree = <LayoutChildren layout={layout} className={className} />;

// 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 = (
<AnalyticsProvider track={analytics?.track}>
<ToasterProvider>
Expand All @@ -338,7 +346,6 @@ export function TrueForgeUIShell(props: TrueForgeUIShellProps) {
routes={resolvedRoutes}
activeRemoteId={activeRemoteId}
initialSettingsOpen={initialSettingsOpen}
onError={onError}
/>
</Suspense>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
58 changes: 39 additions & 19 deletions packages/trueforge-ui/src/routing/ShellRouteSync.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';
Expand All @@ -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();
Expand All @@ -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.
Expand Down Expand Up @@ -89,6 +85,7 @@ export function ShellRouteSync({
environmentsOpen: shell.environmentsOpen,
pendingSessionId: shell.pendingSessionId,
activeRemoteId,
sessionAccessError: shell.sessionAccessError,
mode: shell.mode,
agentConfigMode: shell.agentConfigMode,
};
Expand All @@ -103,9 +100,15 @@ export function ShellRouteSync({
const appliedUrlPlaceRef = useRef<RoutePlace | null>(null);
const bootedRef = useRef(false);
const bootPlaceRef = useRef<RoutePlace | null>(null);
// Bumped when a boot session load is abandoned, so shell->URL sync runs again.
const [shellSyncNonce, setShellSyncNonce] = useState(0);
const bootHistoryAgentRef = useRef<HistoryAgentSearch | null>(null);
// Latest session id the URL asked for, so slower lookups cannot bind over it.
const requestedSessionRef = useRef<string | null>(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<string | null>(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.
Expand All @@ -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);
Expand All @@ -137,17 +140,31 @@ 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;
if (server == null) {
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,
Expand All @@ -156,19 +173,22 @@ export function ShellRouteSync({
});
})
.catch((error: unknown) => {
if (requestedSessionRef.current !== sessionId) return;
requestedSessionRef.current = null;
Comment thread
cursor[bot] marked this conversation as resolved.
// 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;
Comment thread
govindavashishtha marked this conversation as resolved.
}
// 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.'),
Comment thread
cursor[bot] marked this conversation as resolved.
});
goToRoot();
});
},
[goToRoot, onError, server, shell, toaster],
[releaseAbandonedBootSession, server, shell],
Comment thread
govindavashishtha marked this conversation as resolved.
);

const applyHistoryAgentSearch = useCallback(
Expand Down Expand Up @@ -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(() => {
Expand Down
12 changes: 8 additions & 4 deletions packages/trueforge-ui/src/routing/derivePlace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
govindavashishtha marked this conversation as resolved.
if (sessionId != null) return { type: 'session', sessionId };

const { mode, agentConfigMode } = snapshot;
Expand Down
2 changes: 2 additions & 0 deletions packages/trueforge-ui/src/routing/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
};
Loading
Loading