import { t } from "../../lib/console-text"; import type { MachineView } from "../../../machines"; import { DEFAULT_SESSION_USAGE_LIMIT_LABELS } from "../../../usage"; import type { NewSessionSelectionHistory, Rig } from "@opengeni/sdk"; import { BoxIcon, GaugeIcon, InfinityIcon, LaptopIcon, LockIcon, MessageSquareIcon, ServerIcon, UsersIcon, } from "lucide-react"; import { useEffect, useId, useRef, type ReactNode } from "react"; import { Button } from "../../../ui/button"; import { ComposerMenuHeader } from "../../../ui/composer-menu"; import { DropdownMenuItem } from "../../../ui/dropdown-menu"; import { RadioGroup, RadioRow, useFocusCheckedRow, type MenuBodyPresentation, } from "./composer-menu-radio"; export type { MenuBodyPresentation } from "./composer-menu-radio"; import { HelpTip } from "../../../ui/inline-help"; import { Input } from "../../../ui/input"; import { Notice } from "../../../ui/notice"; import { MENU_LABEL_CLASS, MENU_NOTE_CLASS, MENU_SEPARATOR_CLASS } from "../../../ui/menu-styles"; import { isMachineComputeSelectable } from "../../lib/machine-selectability"; import { rememberedMachineFolder, workspaceDefaultRigOptionLabel, type SessionDraft, } from "../../lib/session-create"; import { parseSessionUsageLimitDraft, type SessionUsageLimitDraft, } from "../../lib/session-usage-limit"; import { cn } from "../../lib/utils"; /* * Per-session settings of a new chat, as drill-ins of the composer's "+" menu: * where it runs (managed sandbox and its environment, or a connected machine * and its folder) and who can see it. The composer bar keeps only +, voice, * the model and Send. * * Inside the "+" dropdown the rows are Radix radio items, so arrow keys reach * them (a Radix menu traps Tab and roves only over its own items). When the * "+" menu presents a drill-in as a dialog, there is no menu to rove in, so * the rows are plain radio buttons that Tab reaches. */ function machineStateMeta(machine: MachineView): string { if (machine.state === "offline") return "Offline"; if (machine.state === "reconnecting") return "Reconnecting"; if (!isMachineComputeSelectable(machine.state)) return "Unavailable"; return machine.os ? `${machine.os}/${machine.arch}` : ""; } export type RunsOnChoices = { draft: SessionDraft; machines: MachineView[]; rigs: Rig[]; workspaceDefaultRigId: string | null; /** The deployment runs sessions only on connected machines. */ selfhostedPrimary: boolean; /** * The deployment has no managed sandbox backend. A chat that does not run on * a connected machine has no computer at all, so the choice reads as chat * only and Sandbox Environments do not apply. */ noManagedSandbox?: boolean | undefined; fleetLoadFailed: boolean; selectedChannelId: string | null; selectionHistory: NewSessionSelectionHistory; }; /** Whether "Runs on" has anything to choose; otherwise the + menu omits it. */ export function hasRunsOnChoices( choices: Pick< RunsOnChoices, "machines" | "rigs" | "selfhostedPrimary" | "noManagedSandbox" | "fleetLoadFailed" | "draft" >, ): boolean { return ( choices.draft.compute.kind === "machine" || choices.selfhostedPrimary || choices.fleetLoadFailed || choices.machines.length > 0 || (!choices.noManagedSandbox && choices.rigs.length > 0) ); } /** The right-aligned value of the "Runs on" row: environment or machine. */ export function runsOnSummary(choices: RunsOnChoices): string { const { draft } = choices; if (draft.compute.kind === "machine") { const sandboxId = draft.compute.sandboxId; return ( choices.machines.find((machine) => machine.sandboxId === sandboxId)?.name ?? (sandboxId ? "Machine unavailable" : "Choose a machine") ); } if (choices.noManagedSandbox) return NO_COMPUTER_LABEL; if (draft.rigId) { return choices.rigs.find((rig) => rig.id === draft.rigId)?.name ?? "Managed sandbox"; } const fallback = choices.rigs.find((rig) => rig.id === choices.workspaceDefaultRigId); return fallback ? fallback.name : "Managed sandbox"; } /** * Why "Runs on" keeps Send disabled, if it does. The choice lives behind "+", * so the page says what is missing and where to fix it. */ export type RunsOnAttention = | "fleet-load-failed" | "connect-machine" | "pick-machine" | "machine-unavailable" | null; export function runsOnAttention( choices: Pick & { fleetLoading: boolean; }, ): RunsOnAttention { // A failed load can hide the machine this chat should run on: always say so. if (choices.fleetLoadFailed) return "fleet-load-failed"; if (choices.fleetLoading) return null; const compute = choices.draft.compute; if (compute.kind !== "machine") return null; if (compute.sandboxId !== null) return choices.machines.some( (item) => item.sandboxId === compute.sandboxId && isMachineComputeSelectable(item.state), ) ? null : "machine-unavailable"; return choices.machines.length === 0 ? "connect-machine" : "pick-machine"; } /** A short note under the new-chat composer for what {@link runsOnAttention} found. */ export function RunsOnNotice(props: { attention: RunsOnAttention; machines: MachineView[]; disabled?: boolean | undefined; onRetryMachines: () => void; /** Where to connect a machine (a link to the Machines page). */ connectAction?: ReactNode | undefined; }) { if (!props.attention) return null; if (props.attention === "fleet-load-failed") { return ( Retry } > Couldn't load your connected machines. ); } if (props.attention === "machine-unavailable") return ( Machine unavailable. Reconnect it or choose another under + > Runs on. ); if (props.attention === "connect-machine") { return ( Chats here run on a connected machine. Connect one to send. ); } const anyAvailable = props.machines.some((machine) => isMachineComputeSelectable(machine.state)); return ( {anyAvailable ? "Pick a machine under + > Runs on to send." : "Your connected machines are offline. Pick one under + > Runs on once it's back."} ); } const SANDBOX_VALUE = "sandbox"; const NO_COMPUTER_LABEL = "No computer"; const machineValue = (sandboxId: string) => `machine:${sandboxId}`; /** * The checked "Sandbox environment" row. An empty rigId follows the workspace * default, and so does a restored draft that names the default explicitly: * the default isn't listed again, so its row is "Workspace default". */ export function checkedRigValue(draft: SessionDraft, workspaceDefaultRigId: string | null): string { if (!draft.rigId) return ""; return draft.rigId === workspaceDefaultRigId ? "" : draft.rigId; } export function RunsOnMenuBody( props: RunsOnChoices & { leading?: ReactNode | undefined; presentation?: MenuBodyPresentation | undefined; disabled: boolean; onChange: (draft: SessionDraft) => void; onComputeChange: (draft: SessionDraft) => void; onRetryMachines: () => void; }, ) { const { draft } = props; const compute = draft.compute; const presentation = props.presentation ?? "menu"; const bodyRef = useRef(null); useFocusCheckedRow(bodyRef, presentation); const customPathRowRef = useRef(null); const customPathInputRef = useRef(null); const focusCustomPath = useRef(false); useEffect(() => { if (focusCustomPath.current && customPathInputRef.current) { focusCustomPath.current = false; customPathInputRef.current.focus({ preventScroll: true }); } }, [compute]); const personalRigs = props.rigs.filter((rig) => rig.scope === "user"); const workspaceRigs = props.rigs.filter((rig) => rig.scope !== "user"); const showWhere = compute.kind === "machine" || props.selfhostedPrimary || props.machines.length > 0 || props.fleetLoadFailed; const whereValue = compute.kind === "sandbox" ? props.selfhostedPrimary ? null : SANDBOX_VALUE : compute.sandboxId ? machineValue(compute.sandboxId) : null; const selectSandbox = () => { if (compute.kind === "sandbox") return; props.onComputeChange({ ...draft, compute: { kind: "sandbox", backend: "" }, }); }; const selectMachine = (machine: MachineView) => { if (compute.kind === "machine" && compute.sandboxId === machine.sandboxId) return; props.onComputeChange({ ...draft, compute: { kind: "machine", sandboxId: machine.sandboxId, folder: rememberedMachineFolder( props.selectionHistory, props.selectedChannelId, machine.sandboxId, ), }, }); }; const rigRow = (rig: Rig) => ( props.onChange({ ...draft, rigId: rig.id })} /> ); const retry = presentation === "menu" ? ( { event.preventDefault(); props.onRetryMachines(); }} > Try again ) : ( ); return ( <>
{showWhere ? (

Where

{props.selfhostedPrimary ? null : ( : } label={props.noManagedSandbox ? NO_COMPUTER_LABEL : "Managed sandbox"} meta={props.noManagedSandbox ? "Chat only" : "Set up for you"} onSelect={selectSandbox} /> )} {props.machines.map((machine) => ( : } label={machine.name} meta={machineStateMeta(machine)} onSelect={() => selectMachine(machine)} /> ))} {props.fleetLoadFailed ? (
Couldn't load your connected machines. {retry}
) : props.machines.length === 0 && props.selfhostedPrimary ? (

Connect a machine to run sessions on it.

) : null}
) : null} {compute.kind === "sandbox" && !props.noManagedSandbox && props.rigs.length > 0 ? ( {showWhere ?
: null}

Sandbox environment

props.onChange({ ...draft, rigId: "" })} /> {workspaceRigs.filter((rig) => rig.id !== props.workspaceDefaultRigId).map(rigRow)} {personalRigs.length > 0 ? ( <>

Only me

{personalRigs.map(rigRow)} ) : null} ) : null} {compute.kind === "machine" && compute.sandboxId ? (

Folder

props.onComputeChange({ ...draft, compute: { ...compute, folder: { kind: "root" } }, }) } /> { customPathRowRef.current = node; }} disabled={props.disabled} label="Custom path" onSelect={() => { focusCustomPath.current = true; props.onComputeChange({ ...draft, compute: { ...compute, folder: { kind: "path", path: compute.folder.kind === "path" ? compute.folder.path : "", }, }, }); }} /> {compute.folder.kind === "path" ? (
props.onComputeChange({ ...draft, compute: { ...compute, folder: { kind: "path", path: event.target.value }, }, }) } onKeyDown={(event) => { // Text editing must not trigger menu typeahead or roving focus. event.stopPropagation(); if ( presentation === "menu" && !event.nativeEvent.isComposing && (event.key === "Tab" || event.key === "Enter") ) { event.preventDefault(); customPathRowRef.current?.focus({ preventScroll: true }); } }} placeholder="/home/me/repos/project or packages/runtime" aria-label="Custom working directory" className="h-8 text-sm" />
) : null}

Uses this machine's checkout, git sign-in and environment. Repositories and variable sets aren't added.

) : null}
); } /** Whether "Visibility" is a real choice here (Only me is allowed). */ export function hasVisibilityChoice(props: { personalWorkspace: boolean; canCreatePrivate: boolean; }): boolean { return !props.personalWorkspace && props.canCreatePrivate; } export function visibilitySummary(value: "private" | "workspace"): string { return value === "private" ? "Only me" : "Workspace"; } export function VisibilityMenuBody(props: { leading?: ReactNode | undefined; presentation?: MenuBodyPresentation | undefined; value: "private" | "workspace"; disabled: boolean; onChange: (visibility: "private" | "workspace") => void; }) { const presentation = props.presentation ?? "menu"; const bodyRef = useRef(null); useFocusCheckedRow(bodyRef, presentation); return ( <>
} label="Workspace" meta="People in this workspace" onSelect={() => props.onChange("workspace")} /> } label="Only me" meta="Just you" onSelect={() => props.onChange("private")} />
); } /** * The new chat's usage limit: none (the default), or pause at a dollar amount * of API-equivalent model usage. Local to this composer, never remembered * across chats; sent on create only when the server offers limits. * * It holds a text field, so "+" always opens it as a dialog (a menu may only * contain menu items): a radio group and a labelled input with ordinary Tab * order, Escape to close and focus back on "+". */ export function UsageLimitMenuBody(props: { leading?: ReactNode | undefined; /** Accepted for the shared drill-in contract; this body is always a dialog. */ presentation?: MenuBodyPresentation | undefined; value: SessionUsageLimitDraft; disabled: boolean; /** A Send was blocked by this amount: show "enter an amount" even when empty. */ showRequiredError?: boolean | undefined; onChange: (value: SessionUsageLimitDraft) => void; }) { const bodyRef = useRef(null); const inputRef = useRef(null); const focusInput = useRef(false); const inputId = useId(); const errorId = `${inputId}-error`; const helpId = `${inputId}-help`; const value = props.value; // Open on the checked choice (the dialog would otherwise focus Back). useEffect(() => { const body = bodyRef.current; if (!body || body.contains(document.activeElement)) return; body .querySelector('[role="radio"][aria-checked="true"]:not(:disabled)') ?.focus({ preventScroll: true }); }, []); useEffect(() => { if (focusInput.current && inputRef.current) { focusInput.current = false; inputRef.current.focus({ preventScroll: true }); } }, [value.kind]); const parsed = parseSessionUsageLimitDraft(value); const error = value.kind === "limit" && !parsed.ok && (value.text.trim() || props.showRequiredError) ? parsed.error : null; const labels = DEFAULT_SESSION_USAGE_LIMIT_LABELS(t); return ( <>
} label={labels.noLimit} onSelect={() => props.onChange({ kind: "none" })} /> } label="Pause at an amount" onSelect={() => { if (value.kind === "limit") { inputRef.current?.focus({ preventScroll: true }); return; } focusInput.current = true; props.onChange({ kind: "limit", text: "" }); }} /> {value.kind === "limit" ? (
props.onChange({ kind: "limit", text: event.target.value })} onKeyDown={(event) => { // The value applies as typed; Enter must not submit anything. if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.preventDefault(); } }} className="h-8 pl-6 text-sm pointer-coarse:h-11" />
{error ? (

{error}

) : null}
) : null}

{labels.explanation} {labels.notYourBill}

); }