import { t } from "../lib/console-text";
import { Fragment } from "react";
import {
createSessionRetainedScreenshotLoader,
createWorkspaceRetainedArtifactLoader,
createWorkspaceRetainedVideoLoader,
} from "../../timeline";
import { isModelUnavailableSubmissionError } from "../../lib/format";
import { retainedImageId } from "../../components/markdown";
import { useConnectionAccounts } from "../components/capabilities/use-connection-accounts";
import { sessionAuthRecommendation } from "../../session-auth-recommendation";
import {
attachSessionCapability,
completeSessionCapabilityOAuth,
} from "../components/capabilities/attach-session-capability";
import { loadSessionFeedback } from "../../session-feedback-model";
import { turnRatingsFromFeedback } from "../../session-feedback-model";
import { PersonalResourceAttachmentSurface } from "../components/personal-resource-attachment-surface";
import { useReadSessionInbox } from "../lib/inbox";
import { useWorkspaceMachines } from "../lib/use-workspace-machines";
import { getComposerSendBlocker } from "../lib/composer-send-blocking";
import { isEditableArtifactKind } from "../lib/artifact-catalog";
import type { NativeConnectRequest } from "../components/capabilities/native-connect-setup";
import { FailureRecoveryBoundary } from "../components/session/failure-recovery-boundary";
import { useMessageSenderRenderer } from "../components/session/message-senders";
import {
createFailedSessionRetry,
type FailedSessionRetryInput,
} from "../lib/failed-session-retry";
import { useSessionStartupTimeline } from "../../lib/session-startup-timeline";
import { failedSessionCopy } from "../lib/failed-session-copy";
import { coveredModelChoice, creditRefusalGap, freeCreditGap } from "../lib/free-credit-gap";
import { isPaymentRequiredError } from "../lib/model-access";
import { timelineShowsFailure } from "../lib/timeline-failure-owner";
import { sessionDisplayTitle } from "../lib/session-rename";
import { useStreamHealthTelemetry } from "../lib/stream-health";
import { markIntegrationConnectRedirect } from "../lib/integration-connect-redirect";
import { noteTurnFailureAction } from "../lib/turn-failure-actions";
import { observeSessionTurnEvents } from "../lib/analytics-observer";
import { needsSandboxRecoveryCheck } from "../lib/sandbox-failure";
import {
admissionRecheckControl,
admissionControlNeedsRefresh,
recheckSessionAdmission,
SessionAdmissionNotice,
} from "../components/session/session-admission-notice";
import {
sessionAdmissionNeedsDifferentMachine,
sessionAdmissionReason,
} from "../../session-admission-model";
import {
connectorSelectionUpdate,
followWorkspaceConnectorPolicy,
sessionConnectorPolicyIsCustomized,
} from "../lib/composer-connectors";
// The session view — live timeline plus one compact prompt queue above the
// composer. Enter queues and Cmd/Ctrl+Enter steers; failed sessions stay
// honest (reason + retry history) and revivable from the same composer.
import { LightboxProvider } from "../../timeline";
import { sessionAdmissionBlocked, sessionDisplayStatus } from "../../session-status-model";
import { useSessionSecrets } from "../../hooks/use-vaults";
import type { WorkspaceTab } from "../../components/workspace-dock";
import {
SessionSecretsMenuBody,
sessionSecretsSummary,
} from "../components/secrets/session-secrets-panel";
import { MACHINES_SESSION_POLL_MS } from "../../machines";
import {
ApprovalSurface,
ToolActionReviewDetails,
MessageTimeline,
ToolReviewHistoryProvider,
SessionChrome,
KnowledgeActivityProvider,
LearningRefusalActionsProvider,
type LearningRefusalPlace,
type TimelineSearchTarget,
} from "../../session-ui";
import type { SessionSearchRoute } from "../lib/session-search-route";
import { OPEN_CONVERSATION_FIND_EVENT } from "../lib/conversation-find-event";
import {
OPEN_SESSION_USAGE_LIMIT_EVENT,
goalPausedAtUsageLimit,
lineageHasRunningSession,
sessionUsageLimitCapability,
usageLimitRefusalFromState,
useSessionUsageLimit,
} from "../lib/session-usage-limit";
import { SessionUsageLimitNotice } from "../../usage";
import { isSessionUsageLimitSubmissionError } from "../../lib/format";
import { OpenGeniSessionUsageLimitReachedError } from "@opengeni/sdk/browser";
import { SESSION_USAGE_LIMIT_GOAL_PAUSED_REASON } from "@opengeni/sdk/session-usage-limits";
import { expireArtifactCatalog } from "../lib/artifact-catalog-cache";
import { useArtifactCatalogMutationInvalidation } from "../lib/use-artifact-catalog-mutation-invalidation";
import {
creditExhaustedFromEvents,
conversationTimeline,
projectPendingApprovals,
useComposer,
useFileAttachments,
useGoal,
useHumanInputRequests,
useSession,
useSessionEvents,
useSessionLineage,
useTurnQueue,
type AgentMessageItem,
type AuthNeededItem,
type PendingApproval,
type OlderHistoryLoader,
type TimelineItem,
type UserMessageItem,
} from "../../session";
import { sessionAgentsSignal } from "../../session-agents-model";
import { useNavigate, usePage, useRouterState } from "../lib/navigation";
import {
BotIcon,
BugIcon,
Loader2Icon,
MenuIcon,
MessagesSquareIcon,
PanelsTopLeftIcon,
} from "lucide-react";
import {
createElement,
lazy,
Suspense,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { toast } from "../lib/toast";
import { useAppApi } from "../lib/app-api";
import { useSessionFailureToasts } from "../lib/session-failure-toasts";
import { apiErrorFacts, userErrorText } from "../../ui/api-error";
import { ConsoleComposer } from "../components/Composer";
import { CONSOLE_TIMELINE_ALLOWANCE_LABELS } from "../lib/allowance-labels";
import { WorkspaceComposerPlus as ComposerMobilePlus } from "../components/workspace-composer-plus";
import {
SessionRunsOnMenuBody,
SessionMachinePickerAction,
useSessionRunsOn,
} from "../components/session/sandbox-switcher";
import {
currentSessionMachineAccess,
SessionMachineAccessNotice,
} from "../components/session/session-machine-access-notice";
import { LoadingPanel, ProblemPanel } from "../components/common";
import { useHostMessageContext } from "../lib/app-host";
import { useSessionUiFeature } from "../lib/session-ui-features";
import { useSessionOpening } from "../lib/session-opening";
import { creationHandoffReconciled } from "../lib/session-creation-handoff";
import { FollowUpRepositoryMenuBody } from "../components/follow-up-repository-picker";
import { MarkdownText } from "../components/markdown";
import { ModelPicker, type SessionToolSelection } from "../components/pickers";
import {
TerminalSessionArchive,
TerminalSessionBanner,
UserMessageBody,
} from "../components/session/banners";
import {
ReadOnlySessionNotice,
sessionReadOnlyArchive,
} from "../components/session/session-retention";
import { useRail } from "../components/rail/rail-context";
import { sessionComputeLabel } from "../../sandbox-label-model";
import { useBackgroundAttentionTitle } from "../lib/background-attention-title";
import { ChatViewportFileDropTarget } from "../components/session/chat-viewport-file-drop-target";
import { SessionWorkspace } from "../components/session/sandbox-workspace";
import { ArtifactLinkBoundary } from "../components/session/artifact-link-boundary";
import { Button } from "../../ui/button";
import { EmptyState } from "../../ui/empty-state";
import { useAppContext } from "../app-context";
import { useBrowserAccountBridgeBlocker } from "../lib/browser-account-bridge";
import type { SessionArtifactSourceCache } from "../lib/session-artifact-discovery";
import type {
SessionEditableArtifactSummary,
SessionEditableArtifactsStatus,
} from "../components/session/editable-artifacts-workspace";
import {
normalizeProviderDomain,
oauthConnectionOwnership,
oauthConnectionRef,
catalogConnectionAccountSelection,
} from "../lib/capabilities";
import { startMcpOAuthWithTimeout } from "../lib/mcp-oauth";
import { useSessionRouteMemory } from "../lib/last-session-memory";
import { hasAccountPermission, hasWorkspacePermission } from "../lib/permissions";
import { secretSaveTargets } from "../lib/secrets-ui";
import { chatLearningScope } from "../lib/chat-learning-scope";
import { currentModelRecovery } from "../lib/model-recovery";
import { ModelRecoveryNotice } from "../components/session/model-recovery-notice";
import { isPersonalWorkspace } from "../lib/managed-self-context";
import {
isTerminalSessionStatus,
projectSessionTimeline,
summarizeSessionFailure,
} from "../lib/events";
import {
EMPTY_COMPOSER_LAUNCH,
composerLaunchSearchAfterPolicyApply,
composerLaunchSearchKey,
type ComposerLaunchSearch,
} from "../lib/composer-launch";
import { connectableSubscriptions, isDeploymentFreeModel } from "../lib/deployment-free-model";
import {
effortOptionsForModel,
findPickerRow,
runnableLatencyModesForModel,
} from "../lib/model-policy";
import { sessionTimelineEmptyStateCopy } from "../lib/session-empty-state";
import {
sessionModelMissingFromCatalog,
unavailableModelName,
unavailableModelReplacement,
} from "../lib/unavailable-session-model";
import { UnavailableModelNotice } from "../components/session/unavailable-model-notice";
import {
consumeSessionComposerFocusIntent,
FOCUS_SESSION_COMPOSER_EVENT,
focusSessionComposerWhenReady,
sessionComposerFocusIntentIsEligible,
type SessionComposerFocusIntent,
} from "../lib/session-focus";
import {
applySessionAttentionProjection,
updateLocalSessionDeliveryAttention,
notifySessionAttentionChanged,
sessionAttentionReadThroughSequence,
sessionReadProjectionKey,
shouldAcknowledgeActiveSession,
shouldProjectActiveSessionRead,
} from "../lib/session-attention";
import {
mergeSessionContextProjection,
mergeSessionDetailReadProjection,
} from "../lib/session-pins";
import {
readSessionDockNavigation,
sessionDockLayoutStorageId,
updateSessionDockNavigation,
} from "../lib/session-dock-preferences";
import { consoleLinkResolver } from "../lib/session-artifact-navigation";
import {
clientFirstPartyMcpToolPolicy,
firstPartySessionToolOptionsFor,
sessionPolicyPickerIds,
} from "../lib/session-tools";
import { resolveWorkspaceBusySend, stableJson } from "@opengeni/contracts";
import { useFollowUpRepositories } from "../lib/use-follow-up-repositories";
import type { ChatSendContext } from "../components/capabilities/session-github-repositories";
import { githubAppConnectRequest } from "../lib/github-app-connect";
import {
clearGitHubInstallRequest,
recordGitHubInstallRequest,
} from "../lib/github-install-request";
import {
useFixedRigScope,
usePersonalResourceAttachment,
} from "../lib/use-personal-resource-attachment";
import { personalMachineUseIntent } from "../lib/personal-resource-attachments";
import { isPersonalMachine, moveChatToMachine } from "../lib/move-chat-to-machine";
import type { MachineView } from "../../machines";
import { useWorkspaceModelCatalog } from "../lib/use-workspace-model-catalog";
import type {
LineageNode,
SessionRealtimeModel,
UpdateSessionToolPolicyRequest,
} from "@opengeni/sdk";
import type { ConnectionMetadata, Session, SessionEvent } from "../types";
import { useUiEventTarget } from "../lib/ui-events";
import { FORK_CURRENT_CHAT_EVENT } from "../lib/actions/events";
import { usePortalContainer } from "../../ui/portal-container";
const InlineChatArtifact = lazy(() =>
import("../components/artifacts/retained-file-preview").then((module) => ({
default: module.InlineChatArtifact,
})),
);
const ChatInteractiveBlock = lazy(() =>
import("../components/artifacts/chat-interactive-block").then((module) => ({
default: module.ChatInteractiveBlock,
})),
);
const HumanInputSurface = lazy(() => import("../components/session/human-input"));
const SessionSkillReviews = lazy(() =>
import("../components/session/session-skill-reviews").then((module) => ({
default: module.SessionSkillReviews,
})),
);
const SessionCommands = lazy(() =>
import("../../session-ui").then((module) => ({ default: module.SessionCommands })),
);
const SessionCapabilityCard = lazy(async () => ({
default: (await import("../components/capabilities/session-capability-card"))
.SessionCapabilityCard,
}));
const NativeConnectSetup = lazy(() =>
import("../components/capabilities/native-connect-setup").then((module) => ({
default: module.NativeConnectSetup,
})),
);
const LazySessionWaitStatus = lazy(() =>
import("../components/session/session-wait-status").then((module) => ({
default: module.SessionWaitStatus,
})),
);
const MessageForkDialog = lazy(() =>
import("../components/session/session-tenancy-control").then((module) => ({
default: module.SessionTenancyRouteControl,
})),
);
const MessageActions = lazy(() => import("../components/session/message-actions"));
const ConversationFind = lazy(() => import("../components/session/conversation-find"));
const EMPTY_SEARCH_TARGET: SessionSearchRoute = {};
const SubagentTree = lazy(() =>
import("../components/session/subagents").then((module) => ({ default: module.SubagentTree })),
);
const LazyFailedSessionBanner = lazy(() =>
import("../components/session/failed-session-banner").then((module) => ({
default: module.FailedSessionBanner,
})),
);
// Only a refused Send shows it, so it stays out of the direct session bundle.
const LazyCreditRefusalNotice = lazy(() =>
import("../components/credits/credit-refusal-notice").then((module) => ({
default: module.CreditRefusalNotice,
})),
);
const LazyAgentConfigurationPanel = lazy(() =>
import("../components/session/agent-configuration-panel").then(({ AgentConfigurationPanel }) => ({
default: AgentConfigurationPanel,
})),
);
const LazySessionInspector = lazy(() =>
import("../components/session/inspector").then(({ SessionInspector }) => ({
default: SessionInspector,
})),
);
const LazySessionEditableArtifactsWorkspace = lazy(() =>
import("../components/session/editable-artifacts-workspace").then(
({ SessionEditableArtifactsWorkspace }) => ({
default: SessionEditableArtifactsWorkspace,
}),
),
);
const LazyCodexRealtimeControl = lazy(() =>
import("../../realtime").then(({ SessionRealtimeControl }) => ({
default: SessionRealtimeControl,
})),
);
const LazySessionRouteAuxiliary = lazy(
() => import("../components/session/session-tenancy-control"),
);
export function SessionRoute({
workspaceId,
sessionId,
launch = EMPTY_COMPOSER_LAUNCH,
realtimeAutostartModel,
searchTarget = EMPTY_SEARCH_TARGET,
}: {
workspaceId: string;
sessionId: string;
launch?: ComposerLaunchSearch | undefined;
realtimeAutostartModel?: SessionRealtimeModel | undefined;
searchTarget?: SessionSearchRoute | undefined;
}) {
const uiEvents = useUiEventTarget("document");
const dockEnabled = useSessionUiFeature("dock");
useReadSessionInbox(sessionId);
const context = useAppContext();
const rail = useRail();
const navigate = useNavigate();
const page = usePage();
const consumeRealtimeAutostart = useCallback(() => {
void navigate({
to: "/workspaces/$workspaceId/sessions/$sessionId",
params: { workspaceId, sessionId },
search: {},
replace: true,
});
}, [navigate, sessionId, workspaceId]);
// The session-search origin only labels the navigation that opened the find
// bar. Drop it once the bar closes so a reload or shared link starts plain.
const hasSearchOrigin = searchTarget.searchOrigin === "session-search";
const consumeSearchOrigin = useCallback(() => {
if (!hasSearchOrigin) return;
void navigate({
to: "/workspaces/$workspaceId/sessions/$sessionId",
params: { workspaceId, sessionId },
search: ({ searchOrigin: _searchOrigin, ...rest }) => rest,
replace: true,
});
}, [hasSearchOrigin, navigate, sessionId, workspaceId]);
// Session record + live event log via @opengeni/react. Fresh opens load a
// bounded tail, then stream live events with resume-by-sequence.
const {
events,
timeline: eventTimeline,
sessionStatus,
sessionStatusSequence,
connectionState,
initialLoading,
initialHistoryReady,
hasOlder,
loadingOlder,
loadOlder,
hasNewer,
loadingNewer,
loadNewer,
loadingOldest,
loadOldest,
jumpToLatest,
jumpToSequence,
error: streamError,
} = useSessionEvents(sessionId);
// Consented funnel telemetry: a session this page started reached its first
// completed turn. Only event types are inspected.
useEffect(() => observeSessionTurnEvents(sessionId, events), [events, sessionId]);
const sessionDetailReadOwner = useRef({});
const beginSessionDetailRead = useCallback(
() =>
context.sessionChannelProjectionAuthority.beginDetailRead(sessionDetailReadOwner.current, {
id: sessionId,
workspaceId,
}),
[context.sessionChannelProjectionAuthority, sessionId, workspaceId],
);
const {
session: fetchedSession,
error: loadError,
readRevision: sessionReadRevision,
readGeneration: sessionReadGeneration,
refresh: refreshSession,
} = useSession(sessionId, {
events,
beginRead: beginSessionDetailRead,
});
// "Back to sessions" from Settings reopens this session (see last-session-memory).
const restoredSessionId = useRouterState({
select: (state) => state.location.state.restoredSessionId,
});
const leaveRestoredSession = useCallback(() => {
void navigate({
to: "/workspaces/$workspaceId/sessions",
params: { workspaceId },
replace: true,
});
}, [navigate, workspaceId]);
const leavingRestoredSession = useSessionRouteMemory({
subjectId: context.accessContext.subjectId,
workspaceId,
sessionId,
openedSessionId: fetchedSession?.id ?? null,
loadError,
restoredSessionId,
leave: leaveRestoredSession,
});
useEffect(
() => () =>
context.sessionChannelProjectionAuthority.finishDetailReads(sessionDetailReadOwner.current),
[context.sessionChannelProjectionAuthority, sessionId, workspaceId],
);
useEffect(() => {
if (loadError) {
context.sessionChannelProjectionAuthority.finishDetailReads(sessionDetailReadOwner.current);
}
}, [context.sessionChannelProjectionAuthority, loadError]);
const creationHandoff =
context.sessionCreationHandoff?.session.id === sessionId && context.session?.id === sessionId
? context.sessionCreationHandoff
: null;
const pendingCreationHandoff = creationHandoffReconciled(creationHandoff, events)
? null
: creationHandoff;
// Queue + goal share the timeline's event stream — one SSE connection total.
const queue = useTurnQueue(sessionId, { events });
const goal = useGoal(sessionId, { events });
const humanInput = useHumanInputRequests(sessionId, { events });
const sessionSeed = fetchedSession ?? creationHandoff?.session ?? null;
const session = useMemo(
() =>
sessionSeed
? {
...sessionSeed,
// Old idle events must not overwrite a fresh queued/claimed detail read.
status:
(sessionStatusSequence ?? 0) > sessionSeed.lastSequence
? (sessionStatus ?? sessionSeed.status)
: sessionSeed.status,
effectiveControl: queue.effectiveControl ?? sessionSeed.effectiveControl,
}
: null,
[queue.effectiveControl, sessionSeed, sessionStatus, sessionStatusSequence],
);
// Background-tab cue: mark the title when this open session settles for the user.
useBackgroundAttentionTitle(sessionId, session?.status ?? null);
// Dispatch retries update their durable ledger without timeline events. Read
// that evidence only while this visible session is queued, with no overlapping
// requests, so a moving retry schedule cannot masquerade as active execution.
const waitingForDispatch =
session?.status === "queued" &&
session.activeTurnId === null &&
session.effectiveControl.state === "active";
useEffect(() => {
if (!waitingForDispatch) return;
let stopped = false;
let timer: ReturnType;
const refresh = async () => {
try {
if (document.visibilityState === "visible") await refreshSession();
} finally {
if (!stopped) timer = setTimeout(() => void refresh(), 15_000);
}
};
timer = setTimeout(() => void refresh(), 15_000);
return () => {
stopped = true;
clearTimeout(timer);
};
}, [waitingForDispatch, sessionId, refreshSession]);
// /clear-view: a LOCAL, this-device-only collapse of the transcript. It hides
// every event at or before the sequence seen when the operator ran it; the
// server log is untouched and newer events (higher sequence) keep streaming
// in. Reset when the session identity changes so a new session starts clean.
// null = never cleared (distinct from "cleared at sequence 0"): clearing an
// empty stream still latches, so the initial-message fallback is suppressed
// and any later events stay hidden up to the cleared sequence.
const [viewClearedAfter, setViewClearedAfter] = useState(null);
useEffect(() => {
setViewClearedAfter(null);
}, [sessionId]);
const clearView = useCallback(() => {
const latestSequence = events.reduce((max, event) => Math.max(max, event.sequence), 0);
setViewClearedAfter(latestSequence);
}, [events]);
const visibleEvents = useMemo(
() =>
viewClearedAfter !== null
? events.filter((event) => event.sequence > viewClearedAfter)
: events,
[events, viewClearedAfter],
);
const { opened, hasObservedHistory } = useSessionOpening(
`${workspaceId}:${sessionId}`,
Boolean(session && (initialHistoryReady || pendingCreationHandoff)),
events.length > 0,
);
const timeline = useMemo(() => {
if (!session) {
return [];
}
// While the tail window is still being fetched, render nothing rather than
// projectSessionTimeline's initial-message fallback — on a large session
// that fallback painted the GENESIS message at the top for the whole fetch
// (user-reported). The fallback is only for genuinely-empty NEW sessions,
// i.e. after the load settles with no events. Once real history was seen,
// clearing the window for a reload (including failure) is never genesis.
if (
(!opened || initialLoading || hasObservedHistory) &&
visibleEvents.length === 0 &&
!pendingCreationHandoff
) {
return [];
}
const projected = projectSessionTimeline(
session,
visibleEvents,
pendingCreationHandoff?.clientEventId,
viewClearedAfter === null ? eventTimeline : undefined,
);
// projectSessionTimeline falls back to the session's initial message when
// the projection is empty; after a clear-view that fallback would resurrect
// the very first message, so suppress it once the view has been cleared.
return viewClearedAfter !== null && visibleEvents.length === 0 ? [] : projected;
}, [
pendingCreationHandoff,
session,
visibleEvents,
viewClearedAfter,
opened,
initialLoading,
hasObservedHistory,
eventTimeline,
]);
// Only approvals still awaiting a decision: the durable log replays every
// historical `session.requiresAction`, so subtract decisions and finished
// turns instead of rendering decided approvals as live buttons forever.
const approvals = useMemo(() => projectPendingApprovals(events), [events]);
// Credit death is sneaky: the engine can end the turn as a NOMINALLY
// completed one (segmentLimit budget_exhausted), leaving the session idle and
// healthy-looking. Track the terminal credit state from the last turn-end so
// the banner shows for idle-but-broke sessions too, not only failed ones.
const creditExhausted = useMemo(() => creditExhaustedFromEvents(events), [events]);
const failure = useMemo(
() =>
session && (session.status === "failed" || creditExhausted)
? summarizeSessionFailure(
events,
session.status,
session.failureDiagnostics,
session.lastSequence,
)
: null,
[events, session, creditExhausted],
);
// Keep the workspace header (title, status badge, connection pill) in sync.
const {
client,
captureWorkspaceInvocation,
ownsWorkspaceInvocation,
setSession: setContextSession,
setConnectionState: setContextConnectionState,
sessionEventFeedStore,
} = context;
const acknowledgedProjectionRef = useRef(null);
const confirmedProjectionRef = useRef(null);
const retriedProjectionRef = useRef(null);
const [foreground, setForeground] = useState(() => ({
documentVisible: document.visibilityState === "visible",
windowFocused: document.hasFocus(),
}));
const [attentionRetryRevision, setAttentionRetryRevision] = useState(0);
const reconciledSessionRead = useRef<{
sessionId: string;
revision: number;
} | null>(null);
useEffect(() => {
if (!fetchedSession || sessionReadRevision === 0) return;
if (
reconciledSessionRead.current?.sessionId === sessionId &&
reconciledSessionRead.current.revision === sessionReadRevision
) {
return;
}
reconciledSessionRead.current = {
sessionId,
revision: sessionReadRevision,
};
const accepted = context.sessionChannelProjectionAuthority.recordRead(
fetchedSession,
sessionReadGeneration,
);
setContextSession((current) =>
mergeSessionDetailReadProjection(
current,
fetchedSession,
context.sessionChannelProjectionAuthority,
sessionReadGeneration,
accepted,
),
);
}, [
context.sessionChannelProjectionAuthority,
fetchedSession,
sessionId,
sessionReadGeneration,
sessionReadRevision,
setContextSession,
]);
useEffect(() => {
setContextSession((current) =>
mergeSessionContextProjection(
current,
session,
context.sessionChannelProjectionAuthority,
"live",
),
);
}, [context.sessionChannelProjectionAuthority, session, setContextSession]);
useEffect(() => {
const reconcileForeground = () => {
setForeground({
documentVisible: document.visibilityState === "visible",
windowFocused: document.hasFocus(),
});
};
window.addEventListener("focus", reconcileForeground);
window.addEventListener("blur", reconcileForeground);
document.addEventListener("visibilitychange", reconcileForeground);
return () => {
window.removeEventListener("focus", reconcileForeground);
window.removeEventListener("blur", reconcileForeground);
document.removeEventListener("visibilitychange", reconcileForeground);
};
}, []);
const projectSessionAttention = useCallback(
(projection: Parameters[0]) => {
notifySessionAttentionChanged(projection);
setContextSession((current) =>
current?.id === projection.id
? applySessionAttentionProjection(current, projection)
: current,
);
},
[setContextSession],
);
// Raw token/progress batches do not create unread attention. Acknowledge
// completed output or an actionable boundary, rather than every stream flush.
const attentionThroughSequence = useMemo(
() => sessionAttentionReadThroughSequence(events),
[events],
);
const readThroughSequence = Math.max(session?.lastSequence ?? 0, attentionThroughSequence);
const activeReadProjectionKey = session
? sessionReadProjectionKey(session.id, readThroughSequence)
: null;
const routeUnreadProjection = useMemo(
() =>
session
? {
...session,
unread: session.unread || readThroughSequence > session.lastSequence,
}
: null,
[readThroughSequence, session],
);
useLayoutEffect(() => {
if (!session || !activeReadProjectionKey) return;
if (
confirmedProjectionRef.current === activeReadProjectionKey ||
!shouldProjectActiveSessionRead({
activeSessionId: sessionId,
workspaceId,
session: routeUnreadProjection,
...foreground,
})
) {
return;
}
const optimisticProjection = {
id: session.id,
workspaceId: session.workspaceId,
unread: false,
...(session.attentionVersion !== undefined
? { attentionVersion: session.attentionVersion }
: {}),
lastSequence: readThroughSequence,
};
projectSessionAttention(optimisticProjection);
}, [
activeReadProjectionKey,
foreground,
projectSessionAttention,
readThroughSequence,
routeUnreadProjection,
session,
sessionId,
workspaceId,
]);
const acknowledgementSessionId = session?.id ?? null;
const acknowledgementEligible = shouldAcknowledgeActiveSession({
activeSessionId: sessionId,
workspaceId,
session: routeUnreadProjection,
...foreground,
});
// Same-value list/focus projections must not invalidate an in-flight retry.
useEffect(() => {
const projectionKey = activeReadProjectionKey;
if (
!acknowledgementSessionId ||
!projectionKey ||
acknowledgedProjectionRef.current === projectionKey ||
!acknowledgementEligible
) {
return;
}
let active = true;
const acceptedTransition = captureWorkspaceInvocation(workspaceId);
if (!acceptedTransition) return;
acknowledgedProjectionRef.current = projectionKey;
void client
.updateSessionAttention(workspaceId, acknowledgementSessionId, {
unread: false,
acknowledgedThroughSequence: readThroughSequence,
})
.then((updated) => {
if (!ownsWorkspaceInvocation(workspaceId, acceptedTransition)) return;
confirmedProjectionRef.current = projectionKey;
// The rail owns separately polled page objects. Keep this exact
// frontier result there so a stale list poll cannot resurrect or
// prematurely clear the unread dot.
projectSessionAttention(updated);
})
.catch(() => {
// Retry one transient failure for this exact frontier. Keeping the
// receipt after the second failure prevents a permanent 4xx/5xx from
// becoming an unbounded request and log loop.
if (
active &&
ownsWorkspaceInvocation(workspaceId, acceptedTransition) &&
acknowledgedProjectionRef.current === projectionKey &&
retriedProjectionRef.current !== projectionKey
) {
retriedProjectionRef.current = projectionKey;
acknowledgedProjectionRef.current = null;
setAttentionRetryRevision((revision) => revision + 1);
return;
}
// A transient failure must never resurrect a dot the user already
// cleared. Release the attempt receipt so a later focus/navigation
// can retry this exact frontier; a reload still reads durable truth
// if both attempts failed.
if (acknowledgedProjectionRef.current === projectionKey) {
acknowledgedProjectionRef.current = null;
}
});
return () => {
active = false;
};
}, [
acknowledgementEligible,
acknowledgementSessionId,
attentionRetryRevision,
activeReadProjectionKey,
captureWorkspaceInvocation,
client,
ownsWorkspaceInvocation,
projectSessionAttention,
readThroughSequence,
workspaceId,
]);
useEffect(() => {
setContextConnectionState(connectionState);
}, [connectionState, setContextConnectionState]);
// Content-free operational signal; see lib/stream-health.ts.
useStreamHealthTelemetry("session", connectionState);
useEffect(() => {
sessionEventFeedStore.set({ sessionId, events });
}, [events, sessionId, sessionEventFeedStore]);
useEffect(
() => () => {
setContextSession(null);
setContextConnectionState("idle");
if (sessionEventFeedStore.getSnapshot()?.sessionId === sessionId) {
sessionEventFeedStore.set(null);
}
},
[sessionId, sessionEventFeedStore, setContextConnectionState, setContextSession],
);
useSessionFailureToasts(loadError, streamError);
// A reconnect OAuth round-trip lands back here (the reconnect card set
// returnPath to this session). The connection is refreshed server-side, but
// the original tool call was settled as an error and is never replayed. Strip
// the params and tell the user to start a new turn.
const oauthReturnHandled = useRef(false);
const capabilityClient = context.client;
const capabilityCatalog = context.workspaceCapabilityCatalog;
const capabilityCatalogReady = context.workspaceMcpCatalogReady;
const refreshCapabilityCatalog = context.refreshWorkspaceMcpServers;
useEffect(() => {
if (oauthReturnHandled.current || !capabilityCatalogReady) {
return;
}
const params = page.readQuery();
const outcome = params.get("integration_oauth");
if (!outcome) {
return;
}
oauthReturnHandled.current = true;
page.replaceQuery(new URLSearchParams());
const capabilityId = params.get("capability_auth");
if (outcome !== "success") {
// The failure copy loads only on this path, keeping it out of the route.
void import("../lib/oauth-callback-messages").then(({ mcpOAuthCallbackFailureMessage }) =>
toast.error("Reconnect failed", {
description: mcpOAuthCallbackFailureMessage(params.get("stage"), params.get("reason")),
}),
);
return;
}
if (!capabilityId) {
toast.success("Connection restored", {
description: "The earlier tool call wasn't replayed. Send a new message to try again.",
});
return;
}
void (async () => {
const item = capabilityCatalog.find((candidate) => candidate.id === capabilityId);
const connectionId = params.get("connectionId");
const providerDomain = params.get("providerDomain");
const ownership = oauthConnectionOwnership(params.get("ownership"));
if (!item || item.kind !== "mcp" || !connectionId || !providerDomain || !ownership) {
throw new Error("The authorized capability could not be resolved from the live catalog.");
}
await capabilityClient.enableCapability(workspaceId, item.id, {
connectionRef: oauthConnectionRef(
ownership,
connectionId,
providerDomain,
catalogConnectionAccountSelection(item),
),
});
await refreshCapabilityCatalog(workspaceId);
const connected = (await capabilityClient.listCapabilities(workspaceId)).items.find(
(candidate) => candidate.id === item.id,
);
if (!connected?.enabled)
throw new Error(
"The connection was authorized, but enabling its tools could not be verified.",
);
if (connected.connectionRef?.subjectScope === "subject") {
toast.success(`${item.name} connected`, {
description:
"Review its connection card to allow your personal account in this conversation.",
});
return;
}
await attachSessionCapability(t, capabilityClient, workspaceId, sessionId, connected);
toast.success(`${item.name} connected`, {
description: "It is available to new tool calls in this session.",
});
})().catch((error) => {
toast.error("Connection succeeded, but setup needs attention", {
description: userErrorText(error),
});
});
}, [
page,
capabilityCatalog,
capabilityCatalogReady,
capabilityClient,
refreshCapabilityCatalog,
workspaceId,
sessionId,
]);
const nativeReturnHandled = useRef(false);
useEffect(() => {
if (nativeReturnHandled.current || !capabilityCatalogReady) return;
const params = page.readQuery();
const social = params.get("social_oauth");
const fiken = params.get("fiken");
if (!social && !fiken) return;
nativeReturnHandled.current = true;
page.replaceQuery(new URLSearchParams());
if (social === "success" || fiken === "connected") {
const capabilityId = params.get("capability_auth");
void (async () => {
await refreshCapabilityCatalog(workspaceId);
await completeSessionCapabilityOAuth(
t,
capabilityClient,
workspaceId,
sessionId,
capabilityId,
);
toast.success("Connection setup completed", {
description: "It is available to new tool calls in this session.",
});
})().catch((error) =>
toast.error("Connection authorized, but setup needs attention", {
description: userErrorText(error),
}),
);
} else {
toast.error("Connection wasn't completed", {
description: "You can retry from the connection card.",
});
}
}, [
page,
capabilityCatalogReady,
capabilityClient,
refreshCapabilityCatalog,
workspaceId,
sessionId,
]);
const githubReturnHandled = useRef(false);
useEffect(() => {
if (githubReturnHandled.current) return;
const params = page.readQuery();
const outcome = params.get("github");
if (outcome !== "connected" && outcome !== "requested") return;
githubReturnHandled.current = true;
page.replaceQuery(new URLSearchParams());
if (outcome === "requested") {
// A non-owner asked their GitHub organization owners to approve the app.
recordGitHubInstallRequest(workspaceId);
toast.success("Request sent to your GitHub organization owners", {
description:
"After an owner approves, an owner of the organization connects it here. Your GitHub card updates on its own.",
});
return;
}
clearGitHubInstallRequest(workspaceId);
toast.success("GitHub connected", {
description: "Repository access is now available to new tool calls in this session.",
});
}, [page, workspaceId]);
// Start the recovery flow for a lapsed connection surfaced inline in the
// timeline. OAuth connections reconnect in place (reuse the connectionId) and
// return to this session; api-key ones can't OAuth, so hand off to credential
// re-entry on the capabilities sheet for that provider. Throwing bubbles a
// calm inline error on the reconnect card.
const reconnectTransport = useMemo(() => context.client.connectTransport(), [context.client]);
const [reconnectRequest, setReconnectRequest] = useState(null);
// Workspace GitHub App setup from the follow-up repository menu uses this
// route-level Connect dialog: the menu closes when GitHub's authorization
// popup takes focus, which would unmount a dialog hosted inside it.
const connectGitHubApp = useCallback(
() =>
setReconnectRequest(githubAppConnectRequest(workspaceId, reconnectTransport, page.href())),
[page, reconnectTransport, workspaceId],
);
const onReconnect = useCallback(
async (item: AuthNeededItem) => {
if (item.authoritySource === "host") {
if (!item.authorizationUrl) {
throw new Error(
"This connection is managed by the embedding host and has no recovery link.",
);
}
page.assign(item.authorizationUrl);
return;
}
if (item.connectionId) {
const { findConnectRecoveryAccount } = await import("@opengeni/connect");
const account = findConnectRecoveryAccount(
await reconnectTransport.accounts(workspaceId),
item.connectionId,
);
if (account) {
setReconnectRequest({
scope: { workspaceId, transport: reconnectTransport },
providerId: account.providerId,
ownership: account.ownership,
reconnectAccountId: account.id,
displayName: account.label,
returnUrl: page.href(),
idempotencyKey: crypto.randomUUID(),
});
return;
}
}
if (item.capability) {
const returnPath = `${new URL(page.href()).pathname}?capability_auth=${encodeURIComponent(item.capability.id)}`;
if (item.capability.id === "api:github-app") {
const status = await context.client.getGitHubApp(workspaceId, {
returnPath,
});
if (status.status === "bound") {
toast.success("GitHub is already connected");
return;
}
if (!status.linkUrl) {
throw new Error(
status.configured
? "Your account cannot manage this workspace's GitHub connection."
: "GitHub is not configured on this deployment.",
);
}
await markIntegrationConnectRedirect("github", "app_install");
page.assign(status.linkUrl);
return;
}
if (item.capability.id === "mcp:codex_apps") {
page.assign(`/workspaces/${encodeURIComponent(workspaceId)}/settings`);
return;
}
const catalogItem = context.workspaceCapabilityCatalog.find(
(candidate) => candidate.id === item.capability?.id,
);
const canInstall =
hasWorkspacePermission(context.accessContext, workspaceId, "workspace:admin") &&
hasWorkspacePermission(context.accessContext, workspaceId, "connections:write");
const mcpUrl = catalogItem?.mcpUrl ?? catalogItem?.endpointUrl ?? null;
if (
canInstall &&
catalogItem?.kind === "mcp" &&
catalogItem.authKind === "oauth2" &&
mcpUrl
) {
const response = await startMcpOAuthWithTimeout(context.client, workspaceId, {
mcpUrl,
...(catalogItem.providerDomain ? { providerDomain: catalogItem.providerDomain } : {}),
ownership: "workspace",
returnPath,
});
if (!response.authorizationUrl) {
throw new Error("The provider did not return an authorization link.");
}
await markIntegrationConnectRedirect({ domain: mcpUrl }, "oauth");
page.assign(response.authorizationUrl);
return;
}
page.assign(
`/workspaces/${encodeURIComponent(workspaceId)}/capabilities?suggested_capability=${encodeURIComponent(item.capability.id)}`,
);
return;
}
if (item.serverId === "codex_apps") {
// Codex Apps is authorized by the designated workspace subscription,
// not by the generic connection broker. Send the user to the existing
// Codex subscription control instead of starting a meaningless OAuth
// flow for chatgpt.com.
page.assign(`/workspaces/${encodeURIComponent(workspaceId)}/settings`);
return;
}
const connections = await context.client
.listConnections(workspaceId)
.catch(() => [] as ConnectionMetadata[]);
const connection = item.connectionId
? (connections.find((candidate) => candidate.id === item.connectionId) ?? null)
: null;
if (connection?.kind === "api_key") {
page.assign(
`/workspaces/${encodeURIComponent(workspaceId)}/capabilities?reconnect_domain=${encodeURIComponent(item.providerDomain)}`,
);
return;
}
const pageUrl = new URL(page.href());
const returnPath = `${pageUrl.pathname}${pageUrl.search}`;
const response = await context.client.startConnectionOAuth(workspaceId, {
providerDomain: item.providerDomain,
...(item.connectionId ? { connectionId: item.connectionId } : {}),
...(item.resource ? { resource: item.resource } : {}),
returnPath,
});
if (!response.authorizationUrl) {
throw new Error("The provider did not return an authorization link.");
}
await markIntegrationConnectRedirect({ domain: item.providerDomain }, "oauth");
page.assign(response.authorizationUrl);
},
[
page,
context.accessContext,
context.client,
context.workspaceCapabilityCatalog,
workspaceId,
reconnectTransport,
],
);
// The workspace shell already needs the capability catalog for session tool
// policy. Reuse that authoritative read for timeline logos instead of
// downloading the same large catalog again from the session route.
const providerLogos = useMemo(() => {
const logos = new Map();
for (const capability of context.workspaceCapabilityCatalog) {
const domain = capability.providerDomain ?? capability.connectionRef?.providerDomain ?? null;
const url = context.client.catalogAssetUrl(capability.logoAssetPath);
if (domain && url) {
const key = normalizeProviderDomain(domain);
if (!logos.has(key)) logos.set(key, url);
}
}
return logos;
}, [context.client, context.workspaceCapabilityCatalog]);
const resolveProviderLogo = useCallback(
(domain: string) => providerLogos.get(normalizeProviderDomain(domain)) ?? null,
[providerLogos],
);
// One lineage read feeds the single composer-anchored agents surface. Events
// refresh it instantly on spawn/worker-completion, and a 30s poll ensures the pill's
// "running" count doesn't go stale on CHILD-side status changes that emit no
// event on this parent's feed. Must sit above the loading/error early-returns
// — it's a hook, so it has to run unconditionally on every render.
const lineage = useSessionLineage(sessionId, {
events,
pollIntervalMs: 30_000,
});
const agentNodes = lineage.lineage?.children ?? [];
// The same lineage read names the agents the timeline spawns, messages, and
// hears from (parent and descendants), following renames.
const lineageTitles = useMemo(() => {
const titles = new Map();
for (const ancestor of lineage.lineage?.ancestors ?? []) {
titles.set(ancestor.id, sessionDisplayTitle(ancestor));
}
const visit = (nodes: readonly LineageNode[]) => {
for (const node of nodes) {
titles.set(node.session.id, sessionDisplayTitle(node.session));
visit(node.children);
}
};
visit(lineage.lineage?.children ?? []);
return titles;
}, [lineage.lineage]);
const resolveSessionTitle = useCallback(
(agentSessionId: string) => lineageTitles.get(agentSessionId) ?? null,
[lineageTitles],
);
const sandboxFileRequestSeq = useRef(0);
const [sandboxFileRequest, setSandboxFileRequest] = useState<{
path: string;
line?: number | undefined;
requestId: number;
} | null>(null);
useEffect(() => {
setSandboxFileRequest(null);
}, [sessionId]);
const setInspectorOpen = context.setInspectorOpen;
// Composer + > Chat settings opens the dock's Agent tab at its Agent learning;
// the header menu and the usage limit notice open it at Usage limit.
const agentSettingsRequestSeq = useRef(0);
const [agentSettingsRequest, setAgentSettingsRequest] = useState<{
sessionId: string;
requestId: number;
target: "learning" | "usage-limit";
} | null>(null);
const openAgentSettings = useCallback(() => {
setAgentSettingsRequest({
sessionId,
requestId: ++agentSettingsRequestSeq.current,
target: "learning",
});
setInspectorOpen(true);
}, [sessionId, setInspectorOpen]);
const openUsageLimit = useCallback(() => {
setAgentSettingsRequest({
sessionId,
requestId: ++agentSettingsRequestSeq.current,
target: "usage-limit",
});
setInspectorOpen(true);
}, [sessionId, setInspectorOpen]);
useEffect(() => {
uiEvents.addEventListener(OPEN_SESSION_USAGE_LIMIT_EVENT, openUsageLimit);
return () => uiEvents.removeEventListener(OPEN_SESSION_USAGE_LIMIT_EVENT, openUsageLimit);
}, [openUsageLimit, uiEvents]);
const openSandboxFile = useCallback(
(path: string, line?: number) => {
setSandboxFileRequest({
path,
line,
requestId: ++sandboxFileRequestSeq.current,
});
setInspectorOpen(true);
},
[setInspectorOpen],
);
// Keep the same pending canvas through detail and the first history read.
// A freshly sent creation handoff already has visible conversation truth.
// Never paint the genesis message or mount a second loading treatment first.
if (!session || !opened) {
if (leavingRestoredSession) return ;
if (!session && loadError) {
return (
}>
void refreshSession()}
/>
);
}
return (
void jumpToLatest()}>
Retry conversation
}
/>
) : (
)
}
onReloadSession={refreshSession}
dockCollapsed={!context.inspectorOpen}
onDockCollapsedChange={(collapsed) => context.setInspectorOpen(!collapsed)}
openFileRequest={sandboxFileRequest}
onOpenNavigation={() => {
context.setInspectorOpen(false);
rail.setDrawerOpen(true);
}}
/>
);
}
const chatPane = (
void navigate({
to: "/workspaces/$workspaceId/sessions/$sessionId",
params: { workspaceId, sessionId: nextSessionId },
})
}
onMemoryClick={(memoryId) =>
void navigate({
to: "/workspaces/$workspaceId/memory",
params: { workspaceId },
search: { memory: memoryId },
})
}
onLearningRefusalOpen={(place) => {
switch (place.kind) {
case "skill":
// The Skills page opens one Skill from its raw `open` parameter.
void navigate({
href: `/workspaces/${encodeURIComponent(workspaceId)}/plugins?section=skills&open=${encodeURIComponent(`skill:${place.skillId}`)}`,
});
return;
case "skills":
void navigate({
to: "/workspaces/$workspaceId/plugins",
params: { workspaceId },
search: { section: "skills" },
});
return;
case "instructions":
void navigate({
to: "/workspaces/$workspaceId/state",
params: { workspaceId },
search: { view: "instructions" },
});
return;
case "knowledge":
void navigate({ to: "/workspaces/$workspaceId/state", params: { workspaceId } });
return;
case "learning-settings":
void navigate({
to: "/workspaces/$workspaceId/settings",
params: { workspaceId },
search: { section: "learning" },
});
}
}}
onNewSession={() =>
void navigate({
to: "/workspaces/$workspaceId/sessions",
params: { workspaceId },
})
}
onApprove={(approvalId) => approve(approvalId, "approve")}
onReject={(approvalId) => approve(approvalId, "reject")}
onReconnect={onReconnect}
onConnectGitHubApp={connectGitHubApp}
resolveProviderLogo={resolveProviderLogo}
onReloadSession={refreshSession}
onOpenSandboxFile={openSandboxFile}
// Both open the session panel's Agent tab; without the panel, neither shows.
onOpenAgentSettings={dockEnabled ? openAgentSettings : undefined}
onOpenUsageLimit={dockEnabled ? openUsageLimit : undefined}
connectionState={connectionState}
/>
);
return (
{reconnectRequest && (
}>
setReconnectRequest(null)}
onComplete={() => {
setReconnectRequest(null);
if (reconnectRequest.providerId === "github-app") {
toast.success("GitHub connected");
void context.refreshGitHub(workspaceId, undefined, { sync: true });
return;
}
toast.success("Connection updated", {
description: "New tool calls can use the updated connection.",
});
}}
/>
)}
context.setInspectorOpen(!collapsed)}
openFileRequest={sandboxFileRequest}
openAgentSettingsRequest={
agentSettingsRequest?.sessionId === sessionId ? agentSettingsRequest : null
}
onOpenNavigation={() => {
context.setInspectorOpen(false);
rail.setDrawerOpen(true);
}}
/>
);
async function approve(approvalId: string, decision: "approve" | "reject") {
try {
await context.client.sendApprovalDecision(workspaceId, sessionId, {
approvalId,
decision,
});
} catch (error) {
toast.error("Couldn't submit the decision", {
description: userErrorText(error),
});
throw error instanceof Error ? error : new Error(String(error));
}
}
}
/** The embedding page's per-message context, as a send extra. */
function withHostMessageContext(messageContext: (() => string | undefined) | undefined): {
modelContext?: string;
} {
const value = messageContext?.();
return value ? { modelContext: value } : {};
}
/** The latest agent-settings change, and whether a turn has started since. */
function lastAgentChange(events: readonly SessionEvent[]): { at: string; pending: boolean } | null {
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index]!;
if (event.type !== "session.agent.updated") continue;
const pending = !events.slice(index + 1).some((later) => later.type === "turn.started");
return { at: event.occurredAt, pending };
}
return null;
}
/**
* The resizable Workspace dock: chat on the left, a collapsible/maximizable dock
* on the right with the capability-gated sandbox surfaces (Files |
* Terminal | Desktop) + Debug. Replaces the old fixed 390px aside.
*/
type SessionDockProps = {
workspaceId: string;
sessionId: string;
session: Session | null;
events: SessionEvent[];
connectionState: ReturnType["connectionState"];
primary: React.ReactNode;
onReloadSession: () => Promise;
dockCollapsed: boolean;
onDockCollapsedChange: (collapsed: boolean) => void;
onOpenNavigation: () => void;
openFileRequest?:
| {
path: string;
line?: number | null | undefined;
requestId: number;
}
| null
| undefined;
/** Open the Agent tab at Agent learning or Usage limit. A new requestId reopens it. */
openAgentSettingsRequest?:
| { requestId: number; target: "learning" | "usage-limit" }
| null
| undefined;
};
/** The chat with the session panel beside it, or the chat alone when the surface has no panel. */
function SessionDock(props: SessionDockProps) {
if (!useSessionUiFeature("dock")) {
return (
{props.primary}
);
}
return ;
}
function SessionDockPanel(props: SessionDockProps) {
const context = useAppContext();
// One request sequence for every tab the host opens (an artifact, the Agent
// tab): the dock ignores a requestId it has already handled.
const tabRequestSeq = useRef(0);
const [tabRequest, setTabRequest] = useState<{
sessionId: string;
tab: string;
requestId: number;
} | null>(null);
const agentSettingsRequestId = props.openAgentSettingsRequest?.requestId ?? null;
const agentSettingsTarget = props.openAgentSettingsRequest?.target ?? "learning";
const [agentLearningFocus, setAgentLearningFocus] = useState(0);
const [usageLimitFocus, setUsageLimitFocus] = useState(0);
useEffect(() => {
if (agentSettingsRequestId === null) return;
setTabRequest({
sessionId: props.sessionId,
tab: "agent",
requestId: ++tabRequestSeq.current,
});
if (agentSettingsTarget === "usage-limit") setUsageLimitFocus((value) => value + 1);
else setAgentLearningFocus((value) => value + 1);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- the target travels with its request
}, [agentSettingsRequestId, props.sessionId]);
const goalPausedAtLimit = useMemo(() => goalPausedAtUsageLimit(props.events), [props.events]);
const currentTabRequest = tabRequest?.sessionId === props.sessionId ? tabRequest : null;
const dockLayoutStorageId = sessionDockLayoutStorageId(
context.accessContext.subjectId,
props.sessionId,
);
const dockNavigation = useMemo(
() => readSessionDockNavigation(dockLayoutStorageId),
[dockLayoutStorageId],
);
const rememberArtifact = useCallback(
(artifactId: string | null) => updateSessionDockNavigation(dockLayoutStorageId, { artifactId }),
[dockLayoutStorageId],
);
// The workbench (Changes | Files | Terminal | Desktop + machine chip) lives in
// the package now; the app injects durable artifacts and Debug around it.
// Heavy editor/runtime code stays lazy until the user opens the tab.
const artifactRefresh = useMemo(() => {
for (let index = props.events.length - 1; index >= 0; index -= 1) {
const event = props.events[index];
if (
event &&
(event.type === "agent.toolCall.output" ||
event.type === "turn.completed" ||
event.type === "turn.failed" ||
event.type === "turn.cancelled")
) {
// Tool output re-reads the chat and only sub-sessions with new
// activity; the end of a turn re-reads everything once.
return { sequence: event.sequence, complete: event.type !== "agent.toolCall.output" };
}
}
return { sequence: 0, complete: true };
}, [props.events]);
const artifactRefreshSequence = artifactRefresh.sequence;
// Tool output may have published a file or Site: let the Artifacts page show
// its cached rows on the next visit but refetch them.
useEffect(() => {
if (artifactRefreshSequence) expireArtifactCatalog(context.client, props.workspaceId);
}, [artifactRefreshSequence, context.client, props.workspaceId]);
const artifactState = useSessionEditableArtifactSummaries({
workspaceId: props.workspaceId,
sessionId: props.sessionId,
refreshSequence: artifactRefreshSequence,
completeRefresh: artifactRefresh.complete,
});
const expireAfterArtifactMutation = useArtifactCatalogMutationInvalidation(
context.client,
props.workspaceId,
expireArtifactCatalog,
);
const [artifactRequest, setArtifactRequest] = useState<{
sessionId: string;
artifactId: string;
artifactKind?: SessionEditableArtifactSummary["modality"] | undefined;
requestId: number;
tab: string;
} | null>(null);
const currentArtifactRequest =
artifactRequest?.sessionId === props.sessionId ? artifactRequest : null;
const artifactTabRequestId = currentArtifactRequest?.requestId ?? null;
useEffect(() => {
if (artifactTabRequestId === null) return;
setTabRequest({
sessionId: props.sessionId,
tab: "artifacts",
requestId: ++tabRequestSeq.current,
});
}, [artifactTabRequestId, props.sessionId]);
const artifactSummaries = [...artifactState.artifacts];
// A just-published artifact may be linked before discovery refresh completes, or
// belong to another session in this workspace. The viewer still authorizes its read.
if (
currentArtifactRequest &&
!artifactSummaries.some(
(item) =>
item.id === currentArtifactRequest.artifactId &&
(!currentArtifactRequest.artifactKind ||
item.modality === currentArtifactRequest.artifactKind ||
(currentArtifactRequest.artifactKind === "file" && item.modality === "image")),
)
) {
artifactSummaries.push({
id: currentArtifactRequest.artifactId,
modality: currentArtifactRequest.artifactKind ?? "site",
title:
currentArtifactRequest.artifactKind === "image"
? "Image"
: currentArtifactRequest.artifactKind === "file"
? "File"
: "Site",
});
}
const trailingTabs: WorkspaceTab[] = [
{
id: "artifacts",
label: "Artifacts",
icon: ,
...(artifactSummaries.length > 0
? {
badge: (
{artifactSummaries.length}
),
}
: {}),
content: (
}>
{
await context.client.updateArtifactPin(
props.workspaceId,
item.kind,
item.id,
pinned,
);
artifactState.applyPin(item.kind, item.id, pinned);
expireAfterArtifactMutation();
artifactState.retry();
}
: undefined
}
/>
),
},
];
if (props.session) {
trailingTabs.push({
id: "agent",
label: "Agent",
icon: ,
content: (
}>
),
});
}
if (props.session) {
trailingTabs.push({
id: "debug",
label: "Debug",
icon: ,
content: (
}>
),
});
}
return (
{
// Editable artifacts require their discovered modality; unlisted editor
// links retain the normal full-page destination.
if (
target.editable &&
!artifactSummaries.some(
(item) => item.id === target.id && isEditableArtifactKind(item.modality),
)
)
return false;
setArtifactRequest((previous) => ({
sessionId: props.sessionId,
artifactId: target.id,
artifactKind:
target.kind === "file"
? (artifactSummaries.find(
(item) =>
item.id === target.id &&
(item.modality === "file" || item.modality === "image"),
)?.modality ?? "file")
: target.editable
? artifactSummaries.find(
(item) => item.id === target.id && isEditableArtifactKind(item.modality),
)?.modality
: "site",
requestId: (previous?.requestId ?? 0) + 1,
tab: "artifacts",
}));
return true;
}}
>
{props.primary}
}
openTabRequest={currentTabRequest}
trailingTabs={trailingTabs}
collapsed={props.dockCollapsed}
onCollapsedChange={props.onDockCollapsedChange}
{...(props.openFileRequest ? { openFileRequest: props.openFileRequest } : {})}
mobileLeadingControl={
}
/>
);
}
function useSessionEditableArtifactSummaries(input: {
workspaceId: string;
sessionId: string;
refreshSequence: number;
/** Re-read every sub-session instead of only those with new activity. */
completeRefresh: boolean;
}): Readonly<{
artifacts: readonly SessionEditableArtifactSummary[];
status: SessionEditableArtifactsStatus;
retry: () => void;
applyPin: (kind: SessionEditableArtifactSummary["modality"], id: string, pinned: boolean) => void;
}> {
const context = useAppContext();
const authorityKey = `${input.workspaceId}:${input.sessionId}:${context.accessKeyVersion}`;
const [retrySequence, setRetrySequence] = useState(0);
const sourceCache = useRef<{
key: string;
client: typeof context.client;
retrySequence: number;
cache: SessionArtifactSourceCache;
} | null>(null);
const [loaded, setLoaded] = useState<{
key: string;
client: typeof context.client;
status: SessionEditableArtifactsStatus;
artifacts: readonly SessionEditableArtifactSummary[];
} | null>(null);
useEffect(() => {
let current = true;
if (
input.completeRefresh ||
sourceCache.current?.key !== authorityKey ||
sourceCache.current.client !== context.client ||
sourceCache.current.retrySequence !== retrySequence
)
sourceCache.current = {
key: authorityKey,
client: context.client,
retrySequence,
cache: new Map(),
};
const cache = sourceCache.current.cache;
setLoaded((previous) =>
previous?.key === authorityKey &&
previous.client === context.client &&
previous.status === "ready"
? previous
: {
key: authorityKey,
client: context.client,
status: "loading",
artifacts:
previous?.key === authorityKey && previous.client === context.client
? previous.artifacts
: [],
},
);
void import("../lib/session-artifact-discovery")
.then(async ({ discoverSessionArtifacts }) => {
const reconcile = await discoverSessionArtifacts(
input.workspaceId,
input.sessionId,
() => current,
context.client,
cache,
);
if (current) {
setLoaded((previous) => ({
key: authorityKey,
client: context.client,
...reconcile(
previous?.key === authorityKey && previous.client === context.client
? previous.artifacts
: [],
),
}));
}
})
.catch(() => {
if (!current) return;
setLoaded((previous) => ({
key: authorityKey,
client: context.client,
status: "error",
artifacts:
previous?.key === authorityKey && previous.client === context.client
? previous.artifacts
: [],
}));
});
return () => {
// The stale-result fence is sufficient for this bounded metadata GET.
// Aborting Chrome fetch during React StrictMode cleanup surfaced an
// unhandled AbortError from the SDK/fetch boundary on every mount.
current = false;
};
}, [
authorityKey,
context.client,
input.completeRefresh,
input.refreshSequence,
input.sessionId,
input.workspaceId,
retrySequence,
]);
const retry = useCallback(() => setRetrySequence((value) => value + 1), []);
const applyPin = useCallback(
(kind: SessionEditableArtifactSummary["modality"], id: string, pinned: boolean) => {
setLoaded((previous) => {
if (previous?.key !== authorityKey || previous.client !== context.client) return previous;
return {
...previous,
artifacts: previous.artifacts.map((artifact) =>
artifact.modality === kind && artifact.id === id && artifact.catalogItem
? { ...artifact, catalogItem: { ...artifact.catalogItem, pinned } }
: artifact,
),
};
});
},
[authorityKey, context.client],
);
return loaded?.key === authorityKey && loaded.client === context.client
? { artifacts: loaded.artifacts, status: loaded.status, retry, applyPin }
: { artifacts: [], status: "loading", retry, applyPin };
}
function SessionChatPane(props: {
session: Session;
/** Keep the raw detail snapshot: the general route projection prefers queue control. */
admissionSessionControl: Session["effectiveControl"];
events: SessionEvent[];
timeline: TimelineItem[];
searchTarget: SessionSearchRoute;
onJumpToSequence: (sequence: number, options?: { signal?: AbortSignal }) => Promise;
initialLoading: boolean;
historyReloadFailed: boolean;
launch?: ComposerLaunchSearch | undefined;
realtimeAutostartModel?: SessionRealtimeModel | undefined;
onRealtimeAutostartConsumed: () => void;
onSearchOriginConsumed: () => void;
approvals: PendingApproval[];
humanInput: ReturnType;
failure: ReturnType | null;
/** The last turn ended budget_exhausted — the workspace is out of credits. */
creditExhausted: boolean;
goal: ReturnType;
queue: ReturnType;
/** Spawned-worker lineage children — feeds SessionChrome agents segment. */
agentNodes: LineageNode[];
hasOlder: boolean;
loadingOlder: boolean;
onLoadOlder: OlderHistoryLoader;
hasNewer: boolean;
loadingNewer: boolean;
onLoadNewer: () => Promise;
loadingOldest: boolean;
onJumpToStart: () => Promise;
onJumpToLatest: () => Promise;
/** Reset the local timeline view (the /clear-view command target). */
onClearView: () => void;
onOpenSession: (sessionId: string) => void;
/** Current titles for the agents this session spawns, messages, and hears from. */
resolveSessionTitle: (sessionId: string) => string | null;
/** Deep-link a timeline memory step to its first-class workspace Memory record. */
onMemoryClick: (memoryId: string) => void;
/** Opens where a person can make a refused Skill, instruction, or Knowledge change. */
onLearningRefusalOpen: (place: LearningRefusalPlace) => void;
onNewSession: () => void;
onApprove: (approvalId: string) => Promise;
onReject: (approvalId: string) => Promise;
onReconnect: (item: AuthNeededItem) => void | Promise;
/** Opens workspace GitHub App setup in the route-level Connect dialog. */
onConnectGitHubApp: () => void;
resolveProviderLogo: (providerDomain: string) => string | null;
onReloadSession: () => Promise;
onOpenSandboxFile: (path: string, line?: number) => void;
/** Composer + > Chat settings: open this chat's Agent tab. */
onOpenAgentSettings: (() => void) | undefined;
/** The usage limit notice's Change limit: open the Agent tab at Usage limit. */
onOpenUsageLimit: (() => void) | undefined;
connectionState: ReturnType["connectionState"];
}) {
const findEvents = useUiEventTarget("document");
const findShortcutScope = usePortalContainer();
const context = useAppContext();
const api = useAppApi();
const page = usePage();
const hostMessageContext = useHostMessageContext();
// Live voice starts only from the current build: a tab opened before a
// deploy reloads onto it once and resumes voice via `?realtime=`.
const realtimeClient = useMemo(
() => api.voiceRealtimeClient(context.client, () => page.href()),
[api, context.client, page],
);
// Opened on a moment (an inbox item, a notification): when it is older than
// the loaded tail, load the window around it; the timeline then lands on it.
const momentSequence = props.searchTarget.find ? undefined : props.searchTarget.at;
const loadedMoment = useRef(null);
const { onJumpToSequence, initialLoading: historyLoading } = props;
const firstLoadedSequence = props.events[0]?.sequence;
useEffect(() => {
if (!momentSequence || historyLoading || loadedMoment.current === momentSequence) return;
loadedMoment.current = momentSequence;
if (firstLoadedSequence !== undefined && firstLoadedSequence > momentSequence) {
void onJumpToSequence(momentSequence);
}
}, [momentSequence, historyLoading, firstLoadedSequence, onJumpToSequence]);
const [findOpen, setFindOpen] = useState(!!props.searchTarget.find);
const [findMounted, setFindMounted] = useState(!!props.searchTarget.find);
const [findFocusRevision, setFindFocusRevision] = useState(0);
const [activeSearchTarget, setActiveSearchTarget] = useState(null);
// Find opens from the session header (or Ctrl/Cmd+F); closing returns focus
// to whatever opened it.
const findReturnFocus = useRef(null);
const openFind = useCallback(() => {
const active = document.activeElement;
if (active instanceof HTMLElement && active !== document.body) findReturnFocus.current = active;
setFindMounted(true);
setFindOpen(true);
setFindFocusRevision((value) => value + 1);
}, []);
// The back link follows the URL's session-search origin, which closing the
// bar removes, so Ctrl/Cmd+F and the Find button then open a plain bar.
const { onSearchOriginConsumed } = props;
const closeFind = useCallback(() => {
setFindOpen(false);
setActiveSearchTarget(null);
onSearchOriginConsumed();
requestAnimationFrame(() => {
// Back to whatever opened Find, else the header's Find button when shown.
const opener = findReturnFocus.current;
findReturnFocus.current = null;
const trigger = document.querySelector("[data-conversation-find-trigger]");
const target = opener?.isConnected
? opener
: trigger && trigger.getClientRects().length > 0
? trigger
: null;
target?.focus({ preventScroll: true });
});
}, [onSearchOriginConsumed]);
useEffect(() => {
findEvents.addEventListener(OPEN_CONVERSATION_FIND_EVENT, openFind);
return () => findEvents.removeEventListener(OPEN_CONVERSATION_FIND_EVENT, openFind);
}, [findEvents, openFind]);
useEffect(() => {
if (props.searchTarget.find) openFind();
}, [
props.searchTarget.find,
props.searchTarget.matchSequence,
props.searchTarget.matchOffset,
openFind,
]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (
!(event.ctrlKey || event.metaKey) ||
event.altKey ||
event.shiftKey ||
event.key.toLowerCase() !== "f"
)
return;
const target = event.target;
// Dialogs and embedded editors own their own keyboard search behavior.
if (
target instanceof Element &&
target.closest('[role="dialog"], .cm-editor, .xterm, [data-native-find]')
)
return;
event.preventDefault();
openFind();
};
// An embedded app takes the shortcut only while focus is inside it, so
// the host page keeps its own find.
const scope = findShortcutScope ?? window;
scope.addEventListener("keydown", onKeyDown as EventListener);
return () => scope.removeEventListener("keydown", onKeyDown as EventListener);
}, [findShortcutScope, openFind]);
const fundingRevision = useMemo(() => {
for (let index = props.events.length - 1; index >= 0; index -= 1) {
const event = props.events[index]!;
if (event.type === "agent.model.usage") return event.sequence;
}
return 0;
}, [props.events]);
const modelCatalog = useWorkspaceModelCatalog(
props.session.workspaceId,
`${props.session.id}:${props.session.status}:${fundingRevision}`,
);
const fleet = useWorkspaceMachines({
sessionId: props.session.id,
pollIntervalMs: MACHINES_SESSION_POLL_MS,
});
const computeLabel = sessionComputeLabel(t, fleet.machines);
const loadRetainedScreenshot = useMemo(
() =>
createSessionRetainedScreenshotLoader(
context.client,
props.session.workspaceId,
props.session.id,
),
[context.client, props.session.id, props.session.workspaceId],
);
const loadRetainedArtifact = useMemo(
() => createWorkspaceRetainedArtifactLoader(context.client, props.session.workspaceId),
[context.client, props.session.workspaceId],
);
const loadVideoArtifactPlayback = useMemo(
() => createWorkspaceRetainedVideoLoader(context.client, props.session.workspaceId),
[context.client, props.session.workspaceId],
);
const cancelled = isTerminalSessionStatus(props.session.status);
// Moved to long-term storage after a long idle period: readable, not continuable.
const readOnlyArchive = sessionReadOnlyArchive(props.session);
const terminal = cancelled || readOnlyArchive !== null;
const composerRegionRef = useRef(null);
const [composerFocusSignal, setComposerFocusSignal] = useState(0);
const focusEvents = useUiEventTarget("window");
const [modelPickerSession, setModelPickerSession] = useState(null);
// The API's authoritative "model is not available" refusal for this session,
// which also covers connection-owned models the catalog cannot judge.
const [refusedModel, setRefusedModel] = useState<{ sessionId: string; model: string } | null>(
null,
);
// The API's credit refusal of a Send: no remaining credit pays for that model.
// It stays above the composer until a message is accepted or it is dismissed.
const [creditRefusal, setCreditRefusal] = useState<{
sessionId: string;
model: string;
message: string;
} | null>(null);
useEffect(() => {
const onFocusRequest = (event: Event) => {
const detail = (event as CustomEvent).detail;
if (
detail?.workspaceId === props.session.workspaceId &&
detail.sessionId === props.session.id
) {
setComposerFocusSignal(detail.nonce);
}
};
focusEvents.addEventListener(FOCUS_SESSION_COMPOSER_EVENT, onFocusRequest);
return () => focusEvents.removeEventListener(FOCUS_SESSION_COMPOSER_EVENT, onFocusRequest);
}, [focusEvents, props.session.id, props.session.workspaceId]);
// Opening a chat (a sidebar click, ⌘K, Control+Tab, a link or a reload)
// puts the cursor in its composer, since typing is what comes next. Without
// an explicit intent, only the first effect run for a chat may do this, and
// only while nothing else has focus.
const loadFocusSessionRef = useRef(null);
// The wait outlives effect re-runs (status, approvals) but not the chat.
const composerFocusWaitRef = useRef<{ sessionId: string; cancel: () => void } | null>(null);
useEffect(
() => () => {
composerFocusWaitRef.current?.cancel();
composerFocusWaitRef.current = null;
},
[],
);
useEffect(() => {
if (
composerFocusWaitRef.current &&
composerFocusWaitRef.current.sessionId !== props.session.id
) {
composerFocusWaitRef.current.cancel();
composerFocusWaitRef.current = null;
}
const intent = consumeSessionComposerFocusIntent(
focusEvents,
props.session.workspaceId,
props.session.id,
);
const firstRunForChat = loadFocusSessionRef.current !== props.session.id;
loadFocusSessionRef.current = props.session.id;
const source = intent?.source ?? (firstRunForChat ? "load" : null);
if (!source) return;
if (
!sessionComposerFocusIntentIsEligible({
viewportWidth: globalThis.innerWidth,
coarsePointer: globalThis.matchMedia?.("(pointer: coarse)").matches ?? false,
terminal,
requiresAction: props.session.status === "requires_action",
pendingHumanInput: props.humanInput.requests.length > 0,
pendingApproval: props.approvals.length > 0,
})
) {
return;
}
composerFocusWaitRef.current?.cancel();
composerFocusWaitRef.current = {
sessionId: props.session.id,
cancel: focusSessionComposerWhenReady({
textarea: () => composerRegionRef.current?.querySelector("textarea"),
sessionId: props.session.id,
source,
}),
};
}, [
composerFocusSignal,
focusEvents,
props.approvals.length,
props.humanInput.requests.length,
props.session.id,
props.session.status,
props.session.workspaceId,
terminal,
]);
const agentsSignal = useMemo(() => sessionAgentsSignal(t, props.agentNodes), [props.agentNodes]);
const codexConnected = modelCatalog.models.some(
(candidate) =>
candidate.provider === "codex-subscription" &&
candidate.credentialReadiness.status === "ready",
);
// Soft-hide dictate while realtime voice owns the mic (model + mutes stay on the bar).
const [voiceActive, setVoiceActive] = useState(false);
const onVoiceActiveChange = useCallback((active: boolean) => {
setVoiceActive(active);
}, []);
// Per-approval decision state: an in-flight decision disables both buttons for
// that approval and shows progress; a settled one can never double-submit even
// if the strip lingers for a beat before the status flips.
const [selectedApprovalId, setSelectedApprovalId] = useState(null);
const [reviewDetails, setReviewDetails] = useState<{
review: import("@opengeni/sdk").ToolActionReview;
path: string;
origin?: string | undefined;
} | null>(null);
const loadToolReview = useCallback(
(approval: PendingApproval) =>
context.client.getToolActionReview(props.session.workspaceId, props.session.id, approval.id),
[context.client, props.session.workspaceId, props.session.id],
);
const loadRecordedToolReview = useCallback(
(id: string) =>
context.client.getToolActionReview(props.session.workspaceId, props.session.id, id),
[context.client, props.session.workspaceId, props.session.id],
);
const viewToolReviewDetails = useCallback(
(review: import("@opengeni/sdk").ToolActionReview, path: string) =>
setReviewDetails({
review,
path,
origin:
document.activeElement
?.closest("[data-review-origin]")
?.getAttribute("data-review-origin") ?? undefined,
}),
[],
);
const loadToolReviewDetails = useCallback(
(review: import("@opengeni/sdk").ToolActionReview, path: string, offset: number) =>
context.client.getToolReviewDetails(props.session.workspaceId, props.session.id, review.id, {
actionDigest: review.actionDigest,
path,
offset,
}),
[context.client, props.session.workspaceId, props.session.id],
);
const [approvalPending, setApprovalPending] = useState>({});
const [approvalSettled, setApprovalSettled] = useState>({});
// Decision state is scoped to ONE requires_action pause. Once the session
// resumes, both maps reset — otherwise a later approval that reuses an id
// (including the index-fallback ids) would render permanently disabled, and
// long sessions would accumulate stale entries.
useEffect(() => {
if (props.session.status !== "requires_action") {
setApprovalPending((current) => (Object.keys(current).length ? {} : current));
setApprovalSettled((current) => (Object.keys(current).length ? {} : current));
}
}, [props.session.status]);
const decideApproval = useCallback(
async (approvalId: string, decision: "approve" | "reject") => {
if (approvalPending[approvalId] || approvalSettled[approvalId]) {
return;
}
setApprovalPending((current) => ({ ...current, [approvalId]: decision }));
// A failure propagates so the approval surface releases its fence and
// the buttons stay live for a retry.
try {
await (decision === "approve" ? props.onApprove(approvalId) : props.onReject(approvalId));
setApprovalSettled((current) => ({
...current,
[approvalId]: decision,
}));
} finally {
setApprovalPending((current) => {
const next = { ...current };
delete next[approvalId];
return next;
});
}
},
[approvalPending, approvalSettled, props],
);
// Workspace-scoped: the provider (mounted on the workspace route) supplies
// the workspaceId, so the hook needs no positional argument.
const attachments = useFileAttachments({
scope:
props.session.tenancy?.visibility === "private" ||
props.session.memoryScope === "user" ||
context.workspaces.find((w) => w.id === props.session.workspaceId)?.kind === "personal"
? "personal"
: "workspace",
});
const repositories = useFollowUpRepositories(props.session, props.onConnectGitHubApp);
const firstPartyToolOptions = firstPartySessionToolOptionsFor(
clientFirstPartyMcpToolPolicy(context.clientConfig).allowed,
);
const selectableSessionMcpServers = context.toolMcpServers;
const selectableToolIds = useMemo(
() => selectableSessionMcpServers.map((server) => server.id),
[selectableSessionMcpServers],
);
const policyToolIds = useMemo(
() => sessionPolicyPickerIds(props.session, selectableToolIds, context.workspaceDefaultToolIds),
[context.workspaceDefaultToolIds, props.session, selectableToolIds],
);
const [durableToolSelection, setDurableToolSelection] = useState(() => ({
mcpServerIds: new Set(policyToolIds),
firstPartyToolIds: new Set(props.session.firstPartyMcpTools),
}));
const [durableToolsSnapshot, setDurableToolsSnapshot] = useState(props.session);
const durableToolsSaveInFlight = useRef(false);
const [durableToolsHydrated, setDurableToolsHydrated] = useState(false);
const durableToolsSessionId = useRef(props.session.id);
const [durableToolsSaving, setDurableToolsSaving] = useState(false);
const [durableToolsError, setDurableToolsError] = useState(null);
const [connectorCustomizingOverride, setConnectorCustomizingOverride] = useState(
null,
);
// "+" > Runs on: the compute this chat runs on and the machines it can move to.
const runsOn = useSessionRunsOn(props.session.id, props.session.sandboxBackend);
const machineAccess = currentSessionMachineAccess(
runsOn.fleet.sessionMachineAccess,
props.session.activeSandboxId,
);
const connectionAccounts = useConnectionAccounts(
context.client,
{
id: props.session.id,
workspaceId: props.session.workspaceId,
selectedIds: [...durableToolSelection.mcpServerIds],
},
context.workspaceCapabilityCatalog,
context.accessContext === null
? null
: hasWorkspacePermission(
context.accessContext,
props.session.workspaceId,
"connections:read",
),
);
// A conversation card's human attach is an ordinary Send. It reads the
// composer's policy, control and account choices at click time.
const chatSendContext = useRef({
blocked: null,
awaitingHuman: false,
extras: {},
});
const readChatSendContext = useCallback(() => chatSendContext.current, []);
// "+" > Runs on: moving onto the person's own machine is their consent to use
// it here, so it goes through the same move as the machine card (repoint, then
// a message that carries that consent). Other machines only repoint.
const runsOnAttach = runsOn.fleet.attach;
const runsOnPreviousSandboxId = runsOn.fleet.activeSandboxId;
// While the chat runs on the person's own machine (only its owner sees it in
// their list), each message renews their consent for this chat, so a chat
// whose move message never landed recovers on the next ordinary Send. The
// session's own pointer must agree, so a move the agent or another tab made
// since the last machine poll doesn't send consent for a machine it left.
const runsOnActiveMachineConsent =
runsOn.activeMachine &&
isPersonalMachine(runsOn.activeMachine) &&
runsOn.activeMachine.sandboxId === props.session.activeSandboxId
? personalMachineUseIntent(props.session.tenancy)
: undefined;
const runsOnCanMessage =
context.accessContext === null ||
context.accessContext === undefined ||
hasWorkspacePermission(context.accessContext, props.session.workspaceId, "sessions:control");
const reloadSessionAfterMachineChange = props.onReloadSession;
const moveRunsOn = useCallback(
async (machine: MachineView): Promise => {
if (!isPersonalMachine(machine)) {
if (!(await runsOnAttach(machine.sandboxId)))
return "The machine change could not be confirmed. Try refreshing the machine list.";
await reloadSessionAfterMachineChange();
return null;
}
const result = await moveChatToMachine({
machine,
chat: readChatSendContext(),
canMessage: runsOnCanMessage,
previousSandboxId: runsOnPreviousSandboxId,
attach: runsOnAttach,
sendMessage: (message) =>
context.client.sendMessage(props.session.workspaceId, props.session.id, message),
});
if (result.moved) await reloadSessionAfterMachineChange();
return result.moved
? null
: (result.reason ??
"The machine change could not be confirmed. Refresh the machine list and try again.");
},
[
context.client,
props.session.id,
props.session.workspaceId,
reloadSessionAfterMachineChange,
readChatSendContext,
runsOnAttach,
runsOnCanMessage,
runsOnPreviousSandboxId,
],
);
// Session reads return a new resources array; keep the identity while the
// contents match so timeline renderers keyed on it do not reset.
const sessionResourcesKey = stableJson(props.session.resources);
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by content
const stableSessionResources = useMemo(() => props.session.resources, [sessionResourcesKey]);
const reloadSessionAfterSetup = props.onReloadSession;
const refreshConnectionAccounts = connectionAccounts.refresh;
const afterConnectionSetup = useCallback(async () => {
await reloadSessionAfterSetup();
await refreshConnectionAccounts();
}, [reloadSessionAfterSetup, refreshConnectionAccounts]);
const renderAuthNeeded = useCallback(
(item: AuthNeededItem) => {
const recommendation = item.setupRequest
? item
: sessionAuthRecommendation(t, item, context.workspaceCapabilityCatalog);
return recommendation ? (
Loading connection card…
}
>
) : undefined;
},
[
context.workspaceCapabilityCatalog,
props.session.id,
props.session.workspaceId,
props.session.tenancy?.visibility,
props.session.tenancy?.authorityEpoch,
stableSessionResources,
readChatSendContext,
afterConnectionSetup,
],
);
const navigate = useNavigate();
const launch = props.launch ?? EMPTY_COMPOSER_LAUNCH;
const launchModel = launch.model;
const launchEffort = launch.effort;
const launchLatency = launch.latency;
const launchRealtime = launch.realtime;
const launchKey = composerLaunchSearchKey(launch);
const appliedLaunchKeyRef = useRef(null);
useEffect(() => {
if (durableToolsSessionId.current !== props.session.id) {
durableToolsSessionId.current = props.session.id;
setDurableToolsSnapshot(props.session);
setDurableToolsHydrated(false);
setConnectorCustomizingOverride(null);
return;
}
if (!context.workspaceMcpCatalogReady) {
setDurableToolsHydrated(false);
return;
}
if (durableToolsSaving) return;
// A PATCH response can be newer than the parent query. Never let stale
// props restore a connector the user just excluded.
const snapshot =
props.session.toolPolicyVersion > durableToolsSnapshot.toolPolicyVersion
? props.session
: durableToolsSnapshot;
if (snapshot !== durableToolsSnapshot) setDurableToolsSnapshot(snapshot);
const nextMcpIds = sessionPolicyPickerIds(
snapshot,
selectableToolIds,
context.workspaceDefaultToolIds,
);
const nextFirstPartyIds = new Set(snapshot.firstPartyMcpTools);
setDurableToolSelection((current) =>
current.mcpServerIds.size === nextMcpIds.size &&
[...nextMcpIds].every((id) => current.mcpServerIds.has(id)) &&
current.firstPartyToolIds.size === nextFirstPartyIds.size &&
[...nextFirstPartyIds].every((id) => current.firstPartyToolIds.has(id))
? current
: { mcpServerIds: nextMcpIds, firstPartyToolIds: nextFirstPartyIds },
);
setDurableToolsHydrated(true);
}, [
context.workspaceMcpCatalogReady,
context.workspaceDefaultToolIds,
selectableToolIds,
durableToolsSaving,
durableToolsSnapshot,
props.session,
]);
const connectorCustomizing =
connectorCustomizingOverride ?? sessionConnectorPolicyIsCustomized(durableToolsSnapshot);
const applyDurableToolPolicy = useCallback(
async (request: UpdateSessionToolPolicyRequest, optimistic?: SessionToolSelection) => {
if (durableToolsSaveInFlight.current) return;
durableToolsSaveInFlight.current = true;
const targetSessionId = props.session.id;
if (optimistic) setDurableToolSelection(optimistic);
setDurableToolsSaving(true);
setDurableToolsError(null);
try {
const updated = await context.client.updateSessionToolPolicy(
props.session.workspaceId,
targetSessionId,
request,
);
if (durableToolsSessionId.current !== targetSessionId) return;
setDurableToolSelection({
mcpServerIds: sessionPolicyPickerIds(
updated,
selectableToolIds,
context.workspaceDefaultToolIds,
),
firstPartyToolIds: new Set(updated.firstPartyMcpTools),
});
setDurableToolsSnapshot(updated);
setConnectorCustomizingOverride((current) => (current === true ? true : null));
} catch (error) {
const message = userErrorText(error);
setDurableToolsError(message);
toast.error("Couldn't save session tools", { description: message });
try {
const refreshed = await context.client.getSession(
props.session.workspaceId,
props.session.id,
);
if (durableToolsSessionId.current !== targetSessionId) return;
setDurableToolSelection({
mcpServerIds: sessionPolicyPickerIds(
refreshed,
selectableToolIds,
context.workspaceDefaultToolIds,
),
firstPartyToolIds: new Set(refreshed.firstPartyMcpTools),
});
setDurableToolsSnapshot(refreshed);
setConnectorCustomizingOverride((current) => (current === true ? true : null));
} catch {
if (durableToolsSessionId.current === targetSessionId) {
setDurableToolSelection({
mcpServerIds: sessionPolicyPickerIds(
durableToolsSnapshot,
selectableToolIds,
context.workspaceDefaultToolIds,
),
firstPartyToolIds: new Set(durableToolsSnapshot.firstPartyMcpTools),
});
}
}
} finally {
durableToolsSaveInFlight.current = false;
setDurableToolsSaving(false);
}
},
[
context.client,
context.workspaceDefaultToolIds,
durableToolsSnapshot,
props.session.id,
props.session.workspaceId,
selectableToolIds,
],
);
const saveDurableToolPolicy = useCallback(
async (next: SessionToolSelection) => {
await applyDurableToolPolicy(
connectorSelectionUpdate(
durableToolsSnapshot,
durableToolSelection.mcpServerIds,
next.mcpServerIds,
context.workspaceDefaultToolIds,
),
{
mcpServerIds: new Set(next.mcpServerIds),
firstPartyToolIds: new Set(next.firstPartyToolIds),
},
);
},
[
applyDurableToolPolicy,
context.workspaceDefaultToolIds,
durableToolsSnapshot,
durableToolSelection,
],
);
const composerPolicyValidRef = useRef(false);
const workspace =
context.workspaces.find((candidate) => candidate.id === props.session.workspaceId) ?? null;
const loadSkillReview = useCallback(
(reference: NonNullable) =>
context.client.readWorkspaceSkill(
props.session.workspaceId,
reference.skillId,
reference.revisionId,
),
// A browser-account switch must discard the previous actor's loaded preview
// even when the SDK client instance and workspace remain unchanged.
// eslint-disable-next-line react-hooks/exhaustive-deps
[context.client, props.session.workspaceId, context.accessContext.subjectId],
);
const fixedResourceCatalogEnabled = props.session.sandboxBackend !== "selfhosted";
const fixedRigScope = useFixedRigScope(
context.client,
workspace?.id ?? null,
props.session.rigId,
fixedResourceCatalogEnabled &&
hasWorkspacePermission(context.accessContext, props.session.workspaceId, "rigs:use"),
);
const personalAttachment = usePersonalResourceAttachment({
client: context.client,
authMode: context.clientConfig.auth.mode,
authSession: context.authSession,
accessSubjectId: context.accessContext.subjectId,
accessContext: context.accessContext,
managedSelfContext: context.managedSelfContext,
workspace,
session: props.session,
enabled: props.session.sandboxBackend !== "selfhosted",
fixed: {
rigId: props.session.rigId,
rigScope: fixedRigScope,
connectedMachine: null,
},
personalWorkspaceTarget: isPersonalWorkspace(workspace, context.managedSelfContext),
onReloadSession: props.onReloadSession,
});
const composerSendBlocker = () =>
getComposerSendBlocker({
uploadPending: attachments.hasUnresolved,
repositoryError: repositories.error,
policyValid: composerPolicyValidRef.current,
personalDecision:
personalAttachment.requiresDecision || connectionAccounts.requiresAccountChoice,
machineAccessDenied: machineAccess !== null && !machineAccess.isOwner,
personalLoading:
durableToolsSaving ||
durableToolsSaveInFlight.current ||
!durableToolsHydrated ||
personalAttachment.loading ||
personalAttachment.refreshing ||
connectionAccounts.loading ||
connectionAccounts.error !== null,
});
const composer = useComposer(props.session.id, {
events: props.events,
sendExtras: () => ({
...withHostMessageContext(hostMessageContext),
resources: [...attachments.readyResources, ...repositories.pendingResources],
connectionAccounts: [
...connectionAccounts.selections,
...(repositories.pendingResources.some(
(resource) =>
resource.kind === "repository" && resource.connectionType === "github_personal",
) && context.personalGitHubAuthority
? [context.personalGitHubAuthority]
: []),
],
...(personalAttachment.intent
? { personalResourceAttachment: personalAttachment.intent }
: runsOnActiveMachineConsent
? { personalResourceAttachment: runsOnActiveMachineConsent }
: {}),
}),
sendBlocked: () => composerSendBlocker() !== null,
effectiveControl: props.queue.effectiveControl ?? props.session.effectiveControl,
sendDestination: () =>
props.session.activeTurnId !== null || props.queue.queue.length > 0 ? "queue" : "chat",
busySend: resolveWorkspaceBusySend(workspace?.settings),
// The console always offers the other choice beside Send.
offerQueue: true,
// Ordinary Send is acknowledged locally. Clear only resources captured in
// that immutable optimistic operation; later additions belong to the next
// draft, while retry keeps the original resource refs in the failed bubble.
onSubmitted: (_text, input) => {
noteTurnFailureAction("send_message");
attachments.removeReadyFiles(
(input.resources ?? []).flatMap((resource) =>
resource.kind === "file" ? [resource.fileId] : [],
),
);
repositories.commitSent(input.resources ?? []);
},
// Steer and recovered sends do not pass through onSubmitted. Keep the
// host-owned upload/repository queue in sync once those inputs are
// accepted too; the immutable input snapshot preserves later additions.
onSent: (_text, input) => {
setCreditRefusal(null);
attachments.removeReadyFiles(
(input.resources ?? []).flatMap((resource) =>
resource.kind === "file" ? [resource.fileId] : [],
),
);
repositories.commitSent(input.resources ?? []);
personalAttachment.onAccepted(input);
},
onDeliveryError: (error, input, delivery) => {
personalAttachment.onDeliveryError(error, input, delivery);
connectionAccounts.onSelectionError(error);
// Retrying cannot help: re-read the catalog so the composer offers a
// replacement, and open the picker so the next step is visible.
if (isModelUnavailableSubmissionError(error)) {
const refused = refusedModelId(error, input);
if (refused) setRefusedModel({ sessionId: props.session.id, model: refused });
void modelCatalog.refresh();
setModelPickerSession(props.session.id);
}
// Re-read funding so the notice and picker name what the credit covers now.
if (isPaymentRequiredError(error)) {
setCreditRefusal({
sessionId: props.session.id,
model: refusedModelId(error, input) ?? props.session.model,
message: error.message,
});
void modelCatalog.refresh();
}
},
});
useBrowserAccountBridgeBlocker(`session-composer:${props.session.id}`, () => {
if (attachments.hasUnresolved) {
return {
id: "ignored",
label: "A file upload is not settled",
detail: "Wait for the upload or remove it before changing accounts.",
};
}
if (composer.sending || composer.draftSaving || durableToolsSaving) {
return {
id: "ignored",
label: "A session mutation is still running",
detail: "Wait for the current save or send to finish.",
};
}
return composer.hasDraftContent()
? {
id: "ignored",
label: "This session has an unsent draft",
detail: "Continuing clears the account-bound composer state.",
}
: null;
});
chatSendContext.current = {
blocked: isTerminalSessionStatus(props.session.status)
? "This chat has ended. Start a new chat to use a repository."
: sessionReadOnlyArchive(props.session)
? "This chat is read-only. Start a new chat to use a repository."
: connectionAccounts.requiresAccountChoice
? "Choose an account for this chat's connected tools in the composer first."
: personalAttachment.requiresDecision
? "Finish the personal access choice in the composer first."
: null,
awaitingHuman:
props.session.status === "requires_action" && !sessionAdmissionBlocked(props.session),
personalMachineIntent: personalMachineUseIntent(props.session.tenancy),
extras: {
...(composer.policy ?? {}),
...((props.queue.effectiveControl ?? props.session.effectiveControl)?.controlEtag
? {
controlEtag: (props.queue.effectiveControl ?? props.session.effectiveControl)!
.controlEtag,
}
: {}),
...(connectionAccounts.selections.length > 0
? { connectionAccounts: connectionAccounts.selections }
: {}),
},
};
const composerPolicy = composer.policy;
const [retryOperation, setRetryOperation] = useState(null);
const pendingRetryInput =
retryOperation?.failureEventId === props.failure?.failureEventId ? retryOperation : null;
// A failure before a logical turn exists cannot preserve an original intent.
// Prefer detail evidence; paged timeline absence is not positive eligibility.
const failureTurnId =
props.session.failureDiagnostics?.eventId === props.failure?.failureEventId
? props.session.failureDiagnostics?.turnId
: props.events.find((event) => event.id === props.failure?.failureEventId)?.turnId;
const retryHasRetainedTurn = Boolean(failureTurnId);
const retryFailedSession = useMemo(
() =>
createFailedSessionRetry(
(input) => context.client.retrySession(props.session.workspaceId, props.session.id, input),
setRetryOperation,
),
[context.client, props.session.workspaceId, props.session.id],
);
const composerDraftLoading = composer.draftLoading;
const setComposerModel = composer.setModel;
const setComposerReasoningEffort = composer.setReasoningEffort;
const setComposerLatencyMode = composer.setLatencyMode;
const hasComposerPolicy = composerPolicy !== null;
const modelPickerDisabled =
composer.sending || composer.draftLoading || !hasComposerPolicy || Boolean(pendingRetryInput);
const canChooseRecoveryModel = !modelPickerDisabled;
// Free credit can still cover other models after this model's credit ran out.
const creditGap = props.creditExhausted
? freeCreditGap(modelCatalog.rows, props.session.model, {
selectedModel: composerPolicy?.model,
preferredModel: modelCatalog.defaultSelection?.model,
})
: null;
// Shown while the banner chunk loads or if it fails. On the free model the
// generic daily-limit line then gives way to the free-model copy; that brief
// text change keeps the free-model copy out of the direct session bundle.
const failureFallback = props.failure ? (
{
failedSessionCopy(
props.failure,
props.creditExhausted && !props.failure.structuralSandboxFailure,
Boolean(composerPolicy && composerPolicy.model !== props.session.model),
canChooseRecoveryModel && !props.failure.structuralSandboxFailure,
creditGap,
).reason
}
) : null;
const model = composerPolicy?.model ?? props.session.model;
const reasoningEffort = composerPolicy?.reasoningEffort ?? props.session.reasoningEffort;
const latencyMode = composerPolicy?.latencyMode ?? props.session.latencyMode;
const selectedPolicyRow = findPickerRow(modelCatalog.rows, model);
// The banner's one-click switch: the same model, effort and speed coercion as the picker.
const chooseCoveredModel = (id: string) => {
const row = findPickerRow(modelCatalog.rows, id);
const choice =
row && !modelPickerDisabled ? coveredModelChoice(row, reasoningEffort, latencyMode) : null;
if (!choice) return;
setComposerModel(choice.model);
setComposerReasoningEffort(choice.effort);
if (choice.latencyMode !== null) setComposerLatencyMode(choice.latencyMode);
};
const matchesFrozenSessionPolicy = Boolean(
composerPolicy &&
composerPolicy.model === props.session.model &&
composerPolicy.reasoningEffort === props.session.reasoningEffort &&
composerPolicy.latencyMode === props.session.latencyMode,
);
const catalogComboValid = Boolean(
selectedPolicyRow?.selectable &&
(props.session.codexCompactionMode !== "remote_v2" ||
selectedPolicyRow.catalog.source === "codex") &&
effortOptionsForModel(selectedPolicyRow.catalog).includes(reasoningEffort) &&
(latencyMode === "standard" ||
runnableLatencyModesForModel(selectedPolicyRow.catalog).includes(latencyMode)),
);
// A model the catalog no longer lists (retired or removed) is refused by the
// API, so the frozen session policy stops counting as sendable for it.
const refusedSessionModel =
refusedModel?.sessionId === props.session.id ? refusedModel.model : null;
const composerModelUnavailable =
model === refusedSessionModel ||
sessionModelMissingFromCatalog({
model,
models: modelCatalog.models,
error: modelCatalog.error,
});
const sessionModelUnavailable =
props.session.model === refusedSessionModel ||
sessionModelMissingFromCatalog({
model: props.session.model,
models: modelCatalog.models,
error: modelCatalog.error,
});
// Only someone who can send may have the composer's model replaced: the
// replacement is saved to their durable draft.
const canControlSession = Boolean(
context.accessContext.workspaceGrants
.find((grant) => grant.workspaceId === props.session.workspaceId)
?.permissions.includes("sessions:control"),
);
const composerPolicyValid = Boolean(
composerPolicy &&
(catalogComboValid || (matchesFrozenSessionPolicy && !composerModelUnavailable)),
);
const noRunnableModel =
!modelCatalog.loading &&
modelCatalog.rows.length > 0 &&
!modelCatalog.rows.some((row) => row.selectable);
const composerPolicyError =
composerPolicy && !modelCatalog.loading && !composerPolicyValid && !noRunnableModel
? composerModelUnavailable
? "This model is no longer available. Select a different model to continue."
: "Choose a model, reasoning level, and speed supported by this session."
: null;
composerPolicyValidRef.current = composerPolicyValid;
// Preselect a runnable model for the next message when the composer still
// names an unavailable one. Accepted turns and history keep their frozen
// model; the notice above the input names the replacement.
const unavailableReplacement = useMemo(
() =>
composerModelUnavailable || sessionModelUnavailable
? unavailableModelReplacement({
rows: modelCatalog.rows,
defaultSelection: modelCatalog.defaultSelection,
latencyMode,
codexOnly: props.session.codexCompactionMode === "remote_v2",
})
: null,
[
composerModelUnavailable,
sessionModelUnavailable,
modelCatalog.rows,
modelCatalog.defaultSelection,
latencyMode,
props.session.codexCompactionMode,
],
);
useEffect(() => {
if (!composerModelUnavailable || !unavailableReplacement) return;
if (composerDraftLoading || !hasComposerPolicy || modelPickerDisabled || terminal) return;
// A failed session keeps its model until the person chooses: the
// failure banner's Retry re-runs that turn with the composer's model.
if (!canControlSession || props.failure) return;
setComposerModel(unavailableReplacement.model);
setComposerReasoningEffort(unavailableReplacement.reasoningEffort);
if (unavailableReplacement.latencyMode) {
setComposerLatencyMode(unavailableReplacement.latencyMode);
}
}, [
canControlSession,
props.failure,
composerDraftLoading,
composerModelUnavailable,
hasComposerPolicy,
modelPickerDisabled,
setComposerLatencyMode,
setComposerModel,
setComposerReasoningEffort,
terminal,
unavailableReplacement,
]);
const unavailableModelNotice =
(sessionModelUnavailable || composerModelUnavailable) && !terminal ? (
) : null;
useEffect(() => {
if (
!launchKey ||
appliedLaunchKeyRef.current === launchKey ||
composerDraftLoading ||
!hasComposerPolicy
) {
return;
}
const hasPolicy = Boolean(launchModel || launchEffort || launchLatency);
appliedLaunchKeyRef.current = launchKey;
if (!hasPolicy) return;
if (launchModel) setComposerModel(launchModel);
if (launchEffort) setComposerReasoningEffort(launchEffort);
if (launchLatency) setComposerLatencyMode(launchLatency);
void navigate({
to: "/workspaces/$workspaceId/sessions/$sessionId",
params: {
workspaceId: props.session.workspaceId,
sessionId: props.session.id,
},
search: composerLaunchSearchAfterPolicyApply({
model: launchModel,
effort: launchEffort,
latency: launchLatency,
realtime: launchRealtime,
}),
replace: true,
});
}, [
composerDraftLoading,
hasComposerPolicy,
launchEffort,
launchKey,
launchLatency,
launchModel,
launchRealtime,
navigate,
props.session.id,
props.session.workspaceId,
setComposerLatencyMode,
setComposerModel,
setComposerReasoningEffort,
]);
const acceptedClientEventIds = useMemo(
() =>
new Set(
props.events
.filter((event) => event.type === "user.message" && event.clientEventId)
.map((event) => event.clientEventId as string),
),
[props.events],
);
const {
optimisticMessages,
retryOptimisticMessage,
restoreOptimisticMessage,
removeOptimisticMessage,
} = composer;
const failedOptimisticMessageCount = (optimisticMessages ?? []).filter(
(message) => message.state === "failed" && !acceptedClientEventIds.has(message.clientEventId),
).length;
useEffect(() => {
updateLocalSessionDeliveryAttention({
workspaceId: props.session.workspaceId,
sessionId: props.session.id,
failedMessageCount: failedOptimisticMessageCount,
});
}, [failedOptimisticMessageCount, props.session.id, props.session.workspaceId]);
// The task's usage limit: one live read shared with the Agent tab, and the
// calm composer notice near and at the limit. Enforcement is server-side;
// this only keeps the view fresh.
const usageLimitCapability = sessionUsageLimitCapability(context.clientConfig);
const usageLimitTreeActive =
props.session.status === "running" || lineageHasRunningSession(props.agentNodes);
const usageLimit = useSessionUsageLimit({
client: context.client,
workspaceId: props.session.workspaceId,
sessionId: props.session.id,
initial: props.session.usageLimit,
enabled: usageLimitCapability !== "absent",
live: {
events: props.events,
active: usageLimitTreeActive,
watch: props.agentNodes.length > 0,
connectionState: props.connectionState,
},
});
// A Send refused at the limit keeps its message (as a failed bubble with
// Restore, never Retry). Reread the limit; until that answers, show reached.
const refusedUsageLimitSendIds = (optimisticMessages ?? [])
.filter(
(message) =>
message.state === "failed" &&
message.error !== undefined &&
!acceptedClientEventIds.has(message.clientEventId) &&
isSessionUsageLimitSubmissionError(new Error(message.error)),
)
.map((message) => message.clientEventId)
.join(",");
const composerUsageLimitError =
composer.error instanceof OpenGeniSessionUsageLimitReachedError ? composer.error : null;
const [usageLimitRefusalPending, setUsageLimitRefusalPending] = useState(false);
const refreshUsageLimit = usageLimit.refresh;
useEffect(() => {
if (!refusedUsageLimitSendIds && !composerUsageLimitError) return;
setUsageLimitRefusalPending(true);
void refreshUsageLimit().finally(() => setUsageLimitRefusalPending(false));
}, [composerUsageLimitError, refreshUsageLimit, refusedUsageLimitSendIds]);
const usageLimitRefusal = usageLimitRefusalFromState({
pending: usageLimitRefusalPending,
error: composerUsageLimitError,
usage: usageLimit.usage,
});
const usageLimitChild =
Boolean(props.session.parentSessionId) || usageLimit.usage?.inherited === true;
const usageLimitRootId = usageLimit.usage?.rootSessionId ?? null;
const usageLimitNotice =
usageLimitCapability !== "absent" && !terminal ? (
) : null;
const timelineWithOptimisticSends = useMemo(() => {
return conversationTimeline(
props.timeline,
{
queue: props.queue.queue,
snapshot: props.queue.snapshot,
acceptedSteers: props.queue.acceptedSteers,
},
{
optimisticMessages,
retryOptimisticMessage,
restoreOptimisticMessage,
removeOptimisticMessage,
},
);
}, [
optimisticMessages,
removeOptimisticMessage,
props.queue.acceptedSteers,
props.queue.queue,
props.queue.snapshot,
props.timeline,
retryOptimisticMessage,
restoreOptimisticMessage,
]);
const repositoryPickerProps = repositories.pickerProps(terminal || composer.sending);
const admissionControl = admissionRecheckControl(
props.admissionSessionControl,
props.queue.effectiveControl,
composer.effectiveControl,
);
const admissionRefreshRequired = admissionControlNeedsRefresh(
admissionControl,
props.admissionSessionControl,
props.queue.effectiveControl,
composer.effectiveControl,
);
const timelineWithStartup = useSessionStartupTimeline(timelineWithOptimisticSends, {
session: { ...props.session, effectiveControl: admissionControl },
events: props.events,
optimisticMessages,
hasNewer: props.hasNewer,
queue: props.queue.snapshot,
});
const timelineEmptyStateCopy = sessionTimelineEmptyStateCopy(
sessionDisplayStatus(props.session),
(props.queue.effectiveControl ?? props.session.effectiveControl).state === "paused",
);
// Slash-command palette context: the operator controls (/goal, /clear,
// /compact, /help) act on THIS session. Permissions come from the workspace
// grant so the palette hides commands the operator can't run.
const workspacePermissions = useMemo(
() =>
context.accessContext.workspaceGrants.find(
(grant) => grant.workspaceId === props.session.workspaceId,
)?.permissions ?? [],
[context.accessContext.workspaceGrants, props.session.workspaceId],
);
const workspaceAccountId = context.workspaces.find(
(candidate) => candidate.id === props.session.workspaceId,
)?.accountId;
// The chat's Secrets row: hidden when its agent can't use secrets.
const sessionSecrets = useSessionSecrets(props.session.id, {
enabled: workspacePermissions.includes("secrets:list"),
});
const secretsView = sessionSecrets.secrets;
const renderMessageSender = useMessageSenderRenderer(
context.client,
props.session.workspaceId,
context.accessContext.subjectId,
);
const commandContext = useMemo(
() => ({
client: context.client,
workspaceId: props.session.workspaceId,
sessionId: props.session.id,
status: props.session.status,
permissions: workspacePermissions,
}),
[
context.client,
props.session.workspaceId,
props.session.id,
props.session.status,
workspacePermissions,
],
);
const [forkEventId, setForkEventId] = useState(null);
const [turnRatings, setTurnRatings] = useState>({});
const mayRate = hasWorkspacePermission(
context.accessContext,
props.session.workspaceId,
"sessions:create",
);
useEffect(() => {
setTurnRatings({});
setForkEventId(null);
if (!mayRate) return;
return loadSessionFeedback(
context.client,
props.session.workspaceId,
props.session.id,
({ feedback }) => {
const ratings = turnRatingsFromFeedback(feedback);
setTurnRatings((saved) => ({ ...ratings, ...saved }));
},
);
}, [
context.client,
props.session.workspaceId,
props.session.id,
mayRate,
context.accessContext.subjectId,
]);
const mayForkMessage =
context.clientConfig.auth.mode === "managedSession" &&
context.authSession !== null &&
Boolean(props.session.tenancy) &&
mayRate;
// "Fork chat" from the ⌘K menu or the desktop menu bar forks from the latest
// message, as the fork button under that message does.
const forkRequests = useUiEventTarget("document");
const eventsRef = useRef(props.events);
eventsRef.current = props.events;
useEffect(() => {
if (!mayForkMessage) return;
const fork = () => {
const events = eventsRef.current;
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index]!;
if (event.type === "agent.message.completed" || event.type === "user.message") {
setForkEventId(event.id);
return;
}
}
};
forkRequests.addEventListener(FORK_CURRENT_CHAT_EVENT, fork);
return () => forkRequests.removeEventListener(FORK_CURRENT_CHAT_EVENT, fork);
}, [forkRequests, mayForkMessage]);
const onMessageRated = useCallback((turnId: string, sentiment: "positive" | "negative") => {
setTurnRatings((saved) => ({ ...saved, [turnId]: sentiment }));
}, []);
const renderMessageActions = useCallback(
(item: AgentMessageItem | UserMessageItem) => (
),
[
context.client,
props.session.workspaceId,
props.session.id,
mayRate,
mayForkMessage,
turnRatings,
onMessageRated,
],
);
const renderImage = useCallback(
(image: { src: string; alt: string }) => {
const artifactId = retainedImageId(image.src);
return artifactId ? (
Loading image…}>
) : null;
},
[props.session.workspaceId],
);
const renderInteractiveBlock = useCallback(
(block: { kind: "html" | "site"; content: string }) => (
Loading preview…}>
),
[props.session.workspaceId],
);
const renderMessageText = useCallback(
(
text: string,
item: AgentMessageItem | UserMessageItem,
renderContext: { searchTarget: TimelineSearchTarget | null },
) => {
if (item.kind === "user-message") {
return (
);
}
return (
`/workspaces/${props.session.workspaceId}/artifacts/files/${id}`}
text={text}
searchTarget={renderContext.searchTarget}
softLineBreaks
streaming={item.kind === "agent-message" && item.streaming}
onSandboxFile={props.onOpenSandboxFile}
renderInteractiveBlock={renderInteractiveBlock}
renderImage={renderImage}
/>
);
},
[
props.onOpenSandboxFile,
props.session.id,
props.session.workspaceId,
renderInteractiveBlock,
renderImage,
],
);
const canBuyCredits =
context.clientConfig.billingMode === "stripe" &&
Boolean(workspaceAccountId) &&
hasAccountPermission(context.accessContext, workspaceAccountId ?? "", "billing:manage");
const canConnectModel = hasWorkspacePermission(
context.accessContext,
props.session.workspaceId,
"connections:write",
);
const failureRecovery =
!props.hasNewer &&
props.failure &&
(props.session.status === "failed" ||
(props.creditExhausted && props.session.status === "idle")) ? (
{
if (
composer.hasDraftContent() ||
composerSendBlocker() ||
!props.failure?.failureEventId ||
!retryHasRetainedTurn ||
!composerPolicy ||
!workspacePermissions.includes("sessions:control") ||
admissionControl.state === "paused"
)
return false;
try {
return await retryFailedSession(props.failure.failureEventId, composerPolicy);
} finally {
await Promise.allSettled([props.onReloadSession(), props.queue.refresh()]);
}
},
retryBlocker: !workspacePermissions.includes("sessions:control")
? "permission"
: admissionControl.state === "paused"
? "paused"
: composer.hasDraftContent()
? "draft"
: failedOptimisticMessageCount > 0
? "unsent"
: (optimisticMessages ?? []).some(
(message) => !acceptedClientEventIds.has(message.clientEventId),
)
? "delivery"
: props.session.activeTurnId !== null || props.queue.queue.length > 0
? "queued"
: composer.sending ||
composer.draftLoading ||
!hasComposerPolicy ||
!retryHasRetainedTurn ||
!props.failure.failureEventId
? "loading"
: null,
}}
/>
) : null;
// A refused Send names its model, the models the credit still covers, and the
// next step above the composer. The credit-stop banner already says this.
const sessionCreditRefusal =
creditRefusal?.sessionId === props.session.id &&
!terminal &&
!(failureRecovery && props.creditExhausted)
? creditRefusal
: null;
// The plain refusal text shows while the notice chunk loads or if it fails.
const creditRefusalFallback = sessionCreditRefusal ? (
{apiErrorFacts(sessionCreditRefusal.message).serverMessage ?? sessionCreditRefusal.message}
) : null;
const creditRefusalNotice = sessionCreditRefusal ? (
setCreditRefusal(null)}
/>
) : null;
const modelRecovery = props.hasNewer
? null
: currentModelRecovery({ ...props.session, effectiveControl: admissionControl }, props.events);
const reviewDetailPane = reviewDetails ? (
{
const { review, path, origin } = reviewDetails;
setReviewDetails(null);
requestAnimationFrame(() => {
const selector = `[data-approval-id="${CSS.escape(review.id)}"]${origin ? `[data-review-origin="${CSS.escape(origin)}"]` : ""} button[data-review-path="${CSS.escape(path)}"]`;
document.querySelector(selector)?.focus();
});
}}
/>
) : null;
return createElement(
Fragment,
null,
reviewDetailPane,
createElement(
"div",
{ className: reviewDetails ? "hidden" : "contents", inert: reviewDetails ? true : undefined },
createElement(
LightboxProvider,
null,
{findMounted ? (
) : null}
{cancelled ? (
) : readOnlyArchive ? (
) : null}
{
<>
composer.send(
`Retry searchable source preparation for attached file ${fileId}. Use knowledge_retain_file and report its result.`,
)
}
retryDisabled={
composer.hasDraftContent() ||
composer.sending ||
composer.draftLoading ||
!hasComposerPolicy
}
>
{/* Recovery follows the failed request, only in the latest history window.
Credit exhaustion also surfaces on idle sessions. */}
{failureRecovery}
{props.approvals.length > 0 &&
props.session.status === "requires_action" && (
decideApproval(approval.id, "approve")}
onReject={(approval) => decideApproval(approval.id, "reject")}
/>
)}
{props.humanInput.requests.length > 0 &&
props.session.status === "requires_action" ? (
}>
{
// A failed send resolves null. Reject so the card
// keeps what was typed (including a secret) to retry.
if (!(await props.humanInput.respond(requestId, response))) {
throw new Error("Your answer wasn't sent. Try again.");
}
}}
/>
) : null}
>
}
turnSummary={{ rolling: true }}
key={props.session.id}
className="h-full"
items={timelineWithStartup}
searchTarget={activeSearchTarget}
focusSequence={momentSequence}
events={props.events}
status={props.session.status}
computeLabel={computeLabel}
renderMessageText={renderMessageText}
renderMessageActions={renderMessageActions}
onAnnotate={composer.addAnnotation}
draftAnnotations={composer.annotations}
onDraftAnnotationSelect={composer.requestAnnotationReview}
onOpenSession={props.onOpenSession}
resolveSessionTitle={props.resolveSessionTitle}
onMemoryClick={props.onMemoryClick}
onReconnect={props.onReconnect}
renderAuthNeeded={renderAuthNeeded}
resolveProviderLogo={props.resolveProviderLogo}
loadRetainedScreenshot={loadRetainedScreenshot}
loadRetainedArtifact={loadRetainedArtifact}
loadVideoArtifactPlayback={loadVideoArtifactPlayback}
hasOlder={props.hasOlder}
loadingOlder={props.loadingOlder}
onLoadOlder={props.onLoadOlder}
hasNewer={props.hasNewer}
loadingNewer={props.loadingNewer}
onLoadNewer={props.onLoadNewer}
loadingOldest={props.loadingOldest}
onJumpToStart={async () => {
await props.onJumpToStart();
}}
onJumpToLatest={props.onJumpToLatest}
emptyState={
// Clear view hides history, not the retained failure or retry operation.
failureRecovery ??
(props.queue.stoppingPreviousAttempt ? (
}
title={
props.queue.effectiveControl?.state === "paused"
? "Stopping current work"
: "Stopping previous work"
}
description={
props.queue.effectiveControl?.state === "paused"
? "Waiting for the current command to stop safely. Queued work stays saved."
: "Your direction is saved. It starts after the previous command stops safely."
}
/>
) : props.initialLoading ? (
) : props.historyReloadFailed ? (
void props.onJumpToLatest()}
>
Retry conversation
}
/>
) : (
) : (
)
}
title={timelineEmptyStateCopy.title}
description={timelineEmptyStateCopy.description}
/>
))
}
/>
>
}
{forkEventId ? (
setForkEventId(null)}
/>
) : null}
{modelRecovery ? : null}
{props.session.inputWait &&
props.session.status === "idle" &&
admissionControl.state === "active" ? (
) : null}
{/* Compact session chrome above the composer — incoming, queue, goal,
and agents as one dock. Hides entirely when there are no signals. */}
}
canControl={workspacePermissions.includes("sessions:control")}
paused={admissionControl.state === "paused"}
refreshRequired={admissionRefreshRequired}
busy={
composer.resuming || composer.pausing || composer.sending || props.queue.mutating
}
onRecheck={() =>
recheckSessionAdmission({
control: admissionControl,
refreshOnly: admissionRefreshRequired,
resume: (control) =>
context.client.resumeSession(props.session.workspaceId, props.session.id, {
clientEventId: crypto.randomUUID(),
expectedControlEtag: control.controlEtag,
}),
refresh: [props.onReloadSession, props.queue.refresh],
})
}
/>
}>
}
agentsSignal={agentsSignal}
agentsPanel={
props.agentNodes.length > 0 ? (
}>
) : null
}
/>
{/* A read-only chat can't continue: its notice offers a new chat instead. */}
`/workspaces/${props.session.workspaceId}/sessions/${sessionId}`,
}}
disabled={terminal}
header={
unavailableModelNotice ||
usageLimitNotice ||
creditRefusalNotice ||
(machineAccess && !machineAccess.isOwner) ? (
<>
{!sessionAdmissionNeedsDifferentMachine(
sessionAdmissionReason(props.session),
machineAccess,
) ? (
}
/>
) : null}
{unavailableModelNotice}
{usageLimitNotice}
{creditRefusalNotice ? (
{creditRefusalNotice}
) : null}
>
) : null
}
commandContext={commandContext}
onClearView={props.onClearView}
fileUploadsEnabled={context.clientConfig.fileUploads.enabled === true}
transcriptionSuppressed={voiceActive}
controlsLeading={
<>
void connectionAccounts.refresh(),
disabled:
terminal ||
composer.sending ||
durableToolsSaving ||
!durableToolsHydrated,
},
}}
chatSettings={
props.onOpenAgentSettings
? {
workspaceId: props.session.workspaceId,
sessionId: props.session.id,
scope: chatLearningScope(
props.session,
isPersonalWorkspace(workspace, context.managedSelfContext),
),
canEdit: workspacePermissions.includes("sessions:control"),
onOpen: props.onOpenAgentSettings,
}
: undefined
}
workspaceId={props.session.workspaceId}
disabled={terminal || composer.sending}
fileUploadsEnabled={context.clientConfig.fileUploads.enabled === true}
servers={selectableSessionMcpServers}
firstPartyTools={firstPartyToolOptions}
selection={durableToolSelection}
toolsSaving={durableToolsSaving}
toolsDisabled={
composer.sending || terminal || durableToolsSaving || !durableToolsHydrated
}
connectorCustomizing={connectorCustomizing}
onConnectorCustomizingChange={(next) => {
setConnectorCustomizingOverride(next);
if (next) return;
connectionAccounts.resetEmptyChoices();
void applyDurableToolPolicy(
followWorkspaceConnectorPolicy(durableToolsSnapshot),
);
}}
onToolSelectionChange={(next) => void saveDurableToolPolicy(next)}
{...(secretsView?.capabilityEnabled
? {
secrets: {
summary: sessionSecretsSummary(
secretsView.policy,
secretsView.reachableVaults,
),
panel: (
void sessionSecrets.refresh()}
onPolicyChange={async (policy) => {
await sessionSecrets.api.setSessionVaultPolicy(
props.session.id,
policy,
);
await sessionSecrets.refresh();
}}
onSaveTo={async (item, target) => {
await sessionSecrets.api.moveItem(item.vaultId, item.id, {
targetVaultId: target.id,
mode: "copy",
});
toast.success(`Saved ${item.name} to ${target.name}`);
}}
onManage={() =>
void navigate({
to: "/workspaces/$workspaceId/secrets",
params: { workspaceId: props.session.workspaceId },
})
}
/>
),
},
}
: {})}
repositories={{
selectedCount: repositories.selectionCount,
disabled: terminal || composer.sending,
panel: ,
}}
{...(runsOn.hasChoices ||
props.session.rigId ||
(runsOn.activeMachine && !runsOn.activeMachine.isSessionGroup)
? {
runsOn: {
summary: runsOn.activeName,
panel: (
),
},
}
: {})}
/>
>
}
actions={
!terminal ? (
) : null
}
placeholder={
props.session.status === "cancelled"
? "This session was cancelled."
: props.creditExhausted &&
(props.session.status === "failed" || props.session.status === "idle")
? // "Send a message to revive" is a dead end without credits —
// the reply turn dies the same budget death. With free
// credit left for other models, a covered model revives it.
creditGap
? creditGap.selectionUsable
? "Send a follow-up…"
: "Your free credits don't cover this model — choose another model or add credits."
: "Out of Opengeni credits — add credits to continue."
: "Send a follow-up…"
}
controls={
file.status !== "failed" && file.contentType.startsWith("image/"),
)}
open={modelPickerSession === props.session.id && !pendingRetryInput}
onOpenChange={(open) => {
setModelPickerSession(open ? props.session.id : null);
if (open) void modelCatalog.refresh();
}}
rows={modelCatalog.rows}
model={model}
effort={reasoningEffort}
latencyMode={latencyMode}
disabled={modelPickerDisabled}
loading={modelCatalog.loading || composer.draftLoading}
error={modelCatalog.error ?? composerPolicyError}
sessionKey={props.session.id}
menuSide="top"
connectModelsHref={`/workspaces/${encodeURIComponent(props.session.workspaceId)}/settings?section=models`}
codexOnly={props.session.codexCompactionMode === "remote_v2"}
onModelChange={composer.setModel}
onEffortChange={composer.setReasoningEffort}
onLatencyModeChange={composer.setLatencyMode}
/>
{durableToolsError ? (
{durableToolsError}
) : null}
}
/>
,
),
),
);
}
/** The model a "model is not available" refusal named: the API detail, else the attempted input. */
function refusedModelId(error: Error, input: unknown): string | null {
const details = (error as { details?: Record | undefined }).details;
if (typeof details?.modelId === "string" && details.modelId) return details.modelId;
const attempted = (input as { model?: unknown } | null)?.model;
return typeof attempted === "string" && attempted ? attempted : null;
}