// The session header bar — the slim canvas top strip's contents on a session // route. Split out of the session shell as a PURE, presentational component: it takes // plain session data + callbacks and renders the two-line identity block // (breadcrumb → title) on the left and the action cluster on the right // (provider+model·effort·speed, sandbox, pin, connection, status, panel). // The live children that need their own hooks — the sandbox switcher and the // codex account indicator — arrive as slots so the whole bar can be rendered // (and screenshotted) in isolation. `CanvasTopStrip` in session-shell owns the // data wiring and passes the real slots. import { t } from "../../lib/console-text"; import { OPEN_WORKSTREAM_CONTROL_EVENT } from "../../../workstream-control-event"; import { SessionStatus as SessionStatusBadge, SESSION_STATUS_META, StatusDot, } from "../../../components/session-status"; import { sessionDisplayStatus, type SessionDisplayStatus } from "../../../session-status-model"; import { ModelMark } from "../../../components/model-mark"; import { modelDisplayName } from "@opengeni/sdk/model-display"; import type { SessionEventsConnectionState } from "../../../hooks/use-session-events"; import type { SessionSummary } from "@opengeni/sdk"; import { SiteOriginLink } from "../session/site-origin-link"; import { ArchiveIcon, ArchiveRestoreIcon, BellIcon, BellOffIcon, CalendarClockIcon, CalendarOffIcon, GaugeIcon, LinkIcon, LockIcon, MoreHorizontalIcon, PanelRightCloseIcon, PanelRightOpenIcon, PauseIcon, PencilIcon, PinIcon, SearchIcon, ShieldCheckIcon, ShieldOffIcon, SmartphoneIcon, } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { BillingClassMark, type BillingClass } from "../billing-class-mark"; import { ConnectionPill } from "../common"; import { GrantSessionAdminAccessDialog, SessionAdminAccessIndicator, type SessionAdminAccessControl, } from "../session/session-admin-access"; import { SessionAncestryBreadcrumb } from "../session/subagents"; import { Button } from "../../../ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuMeta, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, } from "../../../ui/dropdown-menu"; import { useActionShortcut } from "../../lib/actions/actions-provider"; import { useChatOperations } from "../../lib/actions/chat-operations"; import { RENAME_CURRENT_CHAT_EVENT } from "../../lib/actions/events"; import { Skeleton } from "../../../ui/skeleton"; import { requestConversationFind } from "../../lib/conversation-find-event"; import { currentPhoneAppPlatform, openInAppUrl } from "../../lib/open-in-app"; import { sessionControlPaused, sessionHasUpcomingSchedule, sessionInputWait, } from "../../lib/session-rail"; import { useSessionStartup } from "../../../lib/session-startup"; import { isCodexProductModel } from "../../lib/session-model"; import { SESSION_TITLE_MAX_LENGTH, sessionDisplayTitle, useInlineRename, type InlineRename, } from "../../lib/session-rename"; import { pinLiveAnnouncement } from "../../lib/pin-live-announcement"; import type { SessionRepliesMute } from "../../lib/inbox"; import { labelEffort, type IntelligenceEffort } from "../../lib/session-tools"; import type { LatencyMode, Session } from "../../types"; import { usePage } from "../../lib/navigation"; import { useUiEventTarget } from "../../lib/ui-events"; export function SessionHeader({ session, ancestors, onOpenSchedule, lineageLoading, lineageError, connectionState, status, keyAuthRequired, onForgetAccessKey, inspectorOpen, onToggleInspector, onRename, onPin, onArchive, repliesMute, adminAccess, onOpenUsageLimit, sandboxSlot, codexSlot, accessSlot, leading, lastStartedModel, lastStartedReasoningEffort, lastStartedLatencyMode, billingClass, modelLabel, modelLogoUrl, policyLoading, }: { session: Session; /** Root-to-direct-parent order. */ ancestors: SessionSummary[]; /** * Present only when a scheduled task started this session. Opens that task on * the Schedules page so the reader can see or edit what put this here. */ onOpenSchedule?: ((() => void) | null) | undefined; lineageLoading?: boolean | undefined; lineageError?: Error | null | undefined; connectionState: SessionEventsConnectionState; status: Session["status"]; keyAuthRequired: boolean; onForgetAccessKey: () => void; inspectorOpen: boolean; /** Absent: the surface shows no session panel, so no toggle. */ onToggleInspector?: (() => void) | undefined; onRename: (workspaceId: string, sessionId: string, title: string) => Promise; onPin: (session: Session, pinned: boolean) => Promise; /** Archives or restores this root session. Absent hides the action. */ onArchive?: ((session: Session, archived: boolean) => Promise) | undefined; /** * The signed-in person's mute on this session's replies. Absent hides the * action (sub-agents, people without an inbox, servers without it). */ repliesMute?: SessionRepliesMute | null | undefined; /** * This session's admin access, when the viewer can see it on or give it. * Absent hides the shield and the menu action. */ adminAccess?: SessionAdminAccessControl | null | undefined; /** * Opens this chat's usage limit (Agent tab > Usage). Absent hides the menu * item, for servers without usage limits. */ onOpenUsageLimit?: ((() => void) | null) | undefined; /** The "Run on " control — a live component in production. */ sandboxSlot?: ReactNode | undefined; /** * Replaces the static provider mark when the session is on Codex — same icon, * clickable for account status/switch. Absent for host-credit sessions. */ codexSlot?: ReactNode | undefined; /** Compact session access state/menu, lazy-mounted by the route shell. */ accessSlot?: ReactNode | undefined; /** Leading control (the mobile hamburger); absent on desktop. */ leading?: ReactNode | undefined; /** * Model·effort·latency from the newest turn that durably emitted `turn.started`. * Historical — does not follow the composer next-turn picker. Falls back to * session creation defaults before any turn has admitted. */ lastStartedModel?: string | undefined; lastStartedReasoningEffort?: IntelligenceEffort | undefined; lastStartedLatencyMode?: LatencyMode | undefined; /** Billing rail: Codex account chip, or the mark for makers without a logo. */ billingClass?: BillingClass | undefined; /** Product model label (e.g. GPT-5.6 Luna). */ modelLabel?: string | undefined; modelLogoUrl?: string | undefined; /** * True while last-started policy and/or model catalog are still resolving. * Avoids flashing session defaults (wrong provider) before admitted truth. */ policyLoading?: boolean | undefined; }) { const uiEvents = useUiEventTarget("document"); const page = usePage(); const waiting = sessionInputWait({ ...session, status }); // A paused or finished schedule still links to its settings, marked inactive. const upcomingSchedule = sessionHasUpcomingSchedule(session); // A session the runtime could not start reads as stuck, not "Waiting on you". const displayStatus = sessionDisplayStatus({ status, admissionBlock: session.admissionBlock, }); const startup = useSessionStartup({ ...session, status }); const startupLabel = startup === "starting" ? "Starting" : startup === "delayed" || startup === "retrying" ? "Waiting to start" : undefined; const modelId = lastStartedModel?.trim() || session.model; const resolvedBilling: BillingClass = billingClass ?? (isCodexProductModel(modelId) ? "codex_subscription" : "opengeni_credits"); const resolvedModel = modelLabel?.trim() || modelDisplayName(modelId); const displayEffort: IntelligenceEffort = lastStartedReasoningEffort ?? session.reasoningEffort; const displayLatency: LatencyMode = lastStartedLatencyMode ?? session.latencyMode; // Codex → clickable account chip. Other rails → static provider icon only // (never invent a text "Opengeni"/"BYOK" word). Don't key off `codexSlot != null`. const isCodexRail = resolvedBilling === "codex_subscription"; const policyBits = [ resolvedModel, labelEffort(displayEffort), displayLatency === "fast" ? "Fast" : displayLatency === "ultrafast" ? "Ultrafast" : displayLatency === "priority" ? "Priority" : null, ].filter((bit): bit is string => Boolean(bit)); const rename = useInlineRename(session, onRename); const pin = useSessionPinToggle(session, onPin); const canArchive = Boolean(onArchive) && session.parentSessionId === null; const [grantAdminOpen, setGrantAdminOpen] = useState(false); // Menu actions that move focus (rename input, find field) run after the menu // has closed, so its focus restoration cannot steal focus back to the trigger. const afterMenuClose = useRef<(() => void) | null>(null); const chatOps = useChatOperations(); const findKeys = useActionShortcut("chat.find"); const copyLinkKeys = useActionShortcut("chat.copyLink"); const pinKeys = useActionShortcut("chat.pin"); const archiveKeys = useActionShortcut("chat.archive"); const panelKeys = useActionShortcut("view.workspacePanel"); // "Rename" from the ⌘K menu or the desktop menu bar edits this title. const startRenaming = rename.startEditing; useEffect(() => { const start = () => startRenaming(); uiEvents.addEventListener(RENAME_CURRENT_CHAT_EVENT, start); return () => uiEvents.removeEventListener(RENAME_CURRENT_CHAT_EVENT, start); }, [uiEvents, startRenaming]); const providerControl = isCodexRail ? ( (codexSlot ?? ( )) ) : ( // The model maker's logo; the payment rail only when the maker has none. } /> ); return ( // An elevated band, not just canvas-with-a-hairline: reading as a real top // bar was the light-theme fix — a near-white header on a near-white canvas // needs its own surface + a crisp divider to look intentional (and it lifts // the dark bar a touch above the canvas too).
{leading} {/* The title takes every pixel the actions leave; it truncates last. */}
{/* Child sessions link back to the manager that spawned them, and a session links to its current schedules. */}
{onOpenSchedule ? ( ) : null}
{/* Below lg the lifecycle is a dot beside the title; the full badge and connection pill take over from lg. */} {accessSlot} {adminAccess ? ( setGrantAdminOpen(true)} /> ) : null}
{/* Provider + model · effort · speed stay one cluster on the action side. */}
{policyLoading ? ( ) : ( {providerControl} {policyBits.map((bit, index) => ( {index > 0 ? " · " : null} {bit} ))} )}
{/* Below lg, machine, find and pin live in the "…" menu. */} {sandboxSlot} {pin.announcement}
{/* Admission vs lifecycle are different axes, but showing both when control is Active (Running + Active) is redundant noise. When paused, admission is the headline — hide lifecycle so we don't imply the session is still "Running"/"Idle" under a pause gate. */} {!sessionControlPaused(session) ? ( waiting ? ( Waiting ) : ( ) ) : ( )}
Connection {connectionState}. {keyAuthRequired ? ( ) : null} {onToggleInspector ? ( ) : null} { const action = afterMenuClose.current; if (!action) return; afterMenuClose.current = null; event.preventDefault(); action(); }} > { afterMenuClose.current = () => requestConversationFind(uiEvents); }} > Find {findKeys ? {findKeys.label} : null} { afterMenuClose.current = rename.startEditing; }} > Rename void chatOps.copyLink(session)} > Copy link {copyLinkKeys ? ( {copyLinkKeys.label} ) : null} pin.toggle()} > {session.pinned ? "Unpin" : "Pin"} {pinKeys ? {pinKeys.label} : null} {currentPhoneAppPlatform() ? ( { page.assign(openInAppUrl(page.href())); }} > Open in app ) : null} {sandboxSlot ? (
{sandboxSlot}
) : null} {repliesMute ? ( repliesMute.toggle()}> {repliesMute.muted ? : } {repliesMute.muted ? "Unmute replies" : "Mute replies"} ) : null} {onOpenUsageLimit ? ( { afterMenuClose.current = onOpenUsageLimit; }} > Usage limit ) : null} {adminAccess?.state.active && adminAccess.state.canRevoke ? ( adminAccess.revoke()}> Turn off admin access ) : adminAccess && !adminAccess.state.active && adminAccess.state.canGrant ? ( { afterMenuClose.current = () => setGrantAdminOpen(true); }} > Give admin access… ) : null} {canArchive ? ( <> void onArchive?.(session, !session.archived)} > {session.archived ? : } {session.archived ? "Restore" : "Archive"} {session.archived ? ( Archived ) : archiveKeys ? ( {archiveKeys.label} ) : null} ) : null}
{adminAccess ? ( ) : null}
); } /** Small-screen status: a dot beside the title, named for assistive tech. */ function CompactSessionStatus({ paused, waiting, status, label, }: { paused: boolean; waiting: boolean; status: SessionDisplayStatus; label?: string | undefined; }) { if (paused) { return ( Paused ); } if (waiting) { return ( Waiting ); } const name = label ?? SESSION_STATUS_META(t)[status].label; return ( {name} ); } /** Pause-only header chip (Active is not shown — lifecycle status covers “go”). */ function WorkstreamControlIndicator({ session }: { session: Session }) { const control = session.effectiveControl; if (control.state !== "paused" || session.status === "cancelled") { return null; } const blocker = control.primaryBlocker; const label = blocker?.kind === "workspace" ? "Workspace paused" : control.directState === "paused" || blocker?.sessionId === session.id ? "Paused here" : `Paused by ${blocker?.displayName ?? "parent"}`; return ( ); } /** Pin toggle shared by the desktop pin button and the phone "…" menu item. */ function useSessionPinToggle( session: Session, onPin: (session: Session, pinned: boolean) => Promise, ) { const [busy, setBusy] = useState(false); const [announcement, setAnnouncement] = useState(""); const announcementSequence = useRef(0); const announce = useCallback((message: string) => { announcementSequence.current += 1; setAnnouncement(pinLiveAnnouncement(message, announcementSequence.current)); }, []); const toggle = useCallback(() => { if (busy) return; const nextPinned = !session.pinned; setBusy(true); void onPin(session, nextPinned) .then((updated) => { announce( updated ? `Session ${nextPinned ? "pinned" : "unpinned"}.` : `Session was not ${nextPinned ? "pinned" : "unpinned"}.`, ); }) .catch(() => { announce(`Session was not ${nextPinned ? "pinned" : "unpinned"}.`); }) .finally(() => setBusy(false)); }, [announce, busy, onPin, session]); return { busy, toggle, announcement }; } /** * The session header title — display by default, click the title (or the * hover pencil / "…" > Rename) to rename inline. Prefers the durable session.title * (agent- or user-set), falling back to the initial message / "Untitled * session" exactly like the rail list. Enter saves, Esc cancels, blur saves; an * empty/unchanged value is a no-op cancel. The live title (context.session) * flows in through the session.title_set SSE event the react useSession hook * applies, so cross-client renames and agent titling reflect here without a * reload. The shared `useInlineRename` hook keeps this behaviour identical to * the rail row's rename. */ function SessionTitleEditor(props: { session: Session; rename: InlineRename }) { const display = sessionDisplayTitle(props.session); const rename = props.rename; if (rename.editing) { return ( // A calm in-place edit: same size and position as the display title, a // soft surface tint + hairline instead of a loud focus ring. The global // focus-ring rule is what painted the old blue box; opting out here keeps // the rename feeling like editing the text, not filling in a form field. rename.setDraft(event.target.value)} onBlur={() => void rename.commit()} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void rename.commit(); } else if (event.key === "Escape") { event.preventDefault(); rename.cancel(); } }} maxLength={SESSION_TITLE_MAX_LENGTH} aria-label="Session title" dir="auto" className="-mx-1.5 w-full truncate rounded-md bg-surface-2/70 px-1.5 text-[15px] font-semibold leading-6 tracking-[-0.01em] outline-hidden ring-1 ring-border-strong focus:outline-hidden focus-visible:outline-hidden" style={{ outline: "none" }} /> ); } return (
{/* The pencil earns its pixels only when relevant: hidden at rest and revealed on hover/focus. Touch screens rename by tapping the title (or "…" > Rename), so the title keeps the row's width. */}
); }