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; }