/** * Session dock > Agent > Usage: this chat's usage limit. The main task (root) * owns one limit for itself and all its agents; a sub-agent shows the main * task's limit read-only with a link to it. Saving never resumes work: a goal * paused at the limit keeps its Resume action, and a chat without a goal * continues on the next message. */ import { useOpenGeniText } from "../../../messages/runtime"; import { DEFAULT_SESSION_USAGE_LIMIT_LABELS, summarizeSessionUsageLimit } from "../../../usage"; import { formatSessionUsageMicros, type SessionUsageLimit, } from "@opengeni/sdk/session-usage-limits"; import { useEffect, useId, useRef, useState } from "react"; import { toast } from "../../lib/toast"; import { Button } from "../../../ui/button"; import { Field, TextInput } from "../../../ui/field"; import { HelpLink, HelpTip, InlineHelp } from "../../../ui/inline-help"; import { SegmentedControl } from "../../../ui/segmented-control"; import { SettingRow } from "../../../ui/setting-row"; import { Skeleton } from "../../../ui/skeleton"; import { StatusDot } from "../../../ui/status-dot"; import { useAppContext } from "../../app-context"; import { hasWorkspacePermission } from "../../lib/permissions"; import { classifySessionUsageLimitSaveError, isOutcomeUnknown, parseSessionUsageLimitDraft, sessionUsageLimitCapability, sessionUsageLimitInputValue, useSessionUsageLimit, type SessionUsageLimitDraft, type SessionUsageLimitSaveFailure, } from "../../lib/session-usage-limit"; import { cn } from "../../lib/utils"; import type { Session } from "../../types"; const DISABLED_REASON = "New limits are turned off here. You can still remove a limit."; type EditBase = { version: number; limitMicros: number | null }; type PendingSave = { operationId: string; limitMicros: number | null; expectedVersion: number }; /** Waits before re-sending a save whose outcome is unknown (same operation). */ export const SESSION_USAGE_LIMIT_UNKNOWN_RETRY_MS = [500, 1_500] as const; function limitText(limitMicros: number | null): string { return limitMicros === null ? "no limit" : formatSessionUsageMicros(limitMicros); } export function sessionHref(workspaceId: string, sessionId: string): string { return `/workspaces/${workspaceId}/sessions/${sessionId}`; } export function SessionUsageLimitSection(props: { session: Session; /** A new value (header menu, composer notice) opens the editor and focuses it. */ focusRequest?: number | undefined; /** The goal is paused at the limit: after a save, say Resume is next. */ goalPausedAtLimit?: boolean | undefined; }) { const { session } = props; const t = useOpenGeniText(); const LABELS = DEFAULT_SESSION_USAGE_LIMIT_LABELS(t); const context = useAppContext(); const capability = sessionUsageLimitCapability(context.clientConfig); const canControl = hasWorkspacePermission( context.accessContext, session.workspaceId, "sessions:control", ); const limit = useSessionUsageLimit({ client: context.client, workspaceId: session.workspaceId, sessionId: session.id, initial: session.usageLimit, enabled: capability !== "absent", }); const { usage, refresh } = limit; // The chat route reads it; read once here when nothing has been read yet. useEffect(() => { if (capability !== "absent" && !usage) void refresh(); // oxlint-disable-next-line react-hooks/exhaustive-deps -- only when the task changes }, [capability, session.id]); const child = Boolean(session.parentSessionId) || usage?.inherited === true; const rootSessionId = usage?.rootSessionId ?? null; const editable = capability !== "absent" && canControl && !child && usage !== null; // A refresh failed after a good read: keep the last known amount (and any // reached state), labelled as possibly out of date. Never $0. const stale = usage !== null && limit.error !== null; const summary = summarizeSessionUsageLimit(t, usage, undefined, { stale }); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState({ kind: "none" }); const [saving, setSaving] = useState(false); const [failure, setFailure] = useState(null); const [touched, setTouched] = useState(false); // What the person started editing from. Save sends this version, never one // a background refresh advanced meanwhile, so someone else's change in the // meantime is a conflict, not a silent overwrite. const [editBase, setEditBase] = useState(null); // A save whose response was lost: an exact retry reuses its operationId so // it can never apply twice; any other request gets a new one. const pendingSave = useRef(null); const changeButton = useRef(null); const editorRef = useRef(null); const amountInput = useRef(null); const rootRef = useRef(null); const statusId = useId(); const openEditor = () => { if (usage) setEditBase({ version: usage.version, limitMicros: usage.limitMicros }); pendingSave.current = null; setDraft( usage?.limitMicros === null || usage?.limitMicros === undefined ? { kind: "none" } : { kind: "limit", text: sessionUsageLimitInputValue(usage.limitMicros) }, ); setFailure(null); setTouched(false); setEditing(true); }; const closeEditor = () => { setEditing(false); setFailure(null); requestAnimationFrame(() => changeButton.current?.focus({ preventScroll: true })); }; // Header menu / composer notice: open the editor here and put focus in it. const focusRequest = props.focusRequest ?? 0; const handledFocus = useRef(0); // Focus after the render that shows the editor, not in the same frame as opening it. const [focusNonce, setFocusNonce] = useState(0); useEffect(() => { if (!focusRequest || handledFocus.current === focusRequest) return; if (capability === "absent") return; if (editable === false && usage === null && !limit.error) return; // wait for the read handledFocus.current = focusRequest; if (editable && !editing) openEditor(); setFocusNonce((current) => current + 1); // oxlint-disable-next-line react-hooks/exhaustive-deps -- run per request }, [focusRequest, editable, usage === null, limit.error]); useEffect(() => { if (!focusNonce) return; const frame = requestAnimationFrame(() => { rootRef.current?.scrollIntoView({ block: "nearest" }); const target = editorRef.current?.querySelector( '[role="radio"][aria-checked="true"], [data-state="on"]', ) ?? changeButton.current ?? rootRef.current?.querySelector("a, button"); target?.focus({ preventScroll: true }); }); return () => cancelAnimationFrame(frame); }, [focusNonce]); useEffect(() => { if (editing && draft.kind === "limit" && touched) amountInput.current?.focus(); // oxlint-disable-next-line react-hooks/exhaustive-deps -- focus when "Pause at" is chosen }, [draft.kind]); if (capability === "absent") return null; const parsed = parseSessionUsageLimitDraft(draft); const draftMicros = parsed.ok ? parsed.limitMicros : undefined; const unchanged = parsed.ok && draftMicros === (usage?.limitMicros ?? null); const settingBlocked = capability === "disabled" && draft.kind === "limit"; const amountError = draft.kind === "limit" && !parsed.ok && (touched || draft.text.trim()) ? parsed.error : null; async function save() { if (!usage || saving || !parsed.ok || settingBlocked) return; const base = editBase ?? { version: usage.version, limitMicros: usage.limitMicros }; const request = { limitMicros: parsed.limitMicros, expectedVersion: base.version }; const previous = pendingSave.current; const operationId = previous && previous.limitMicros === request.limitMicros && previous.expectedVersion === request.expectedVersion ? previous.operationId : crypto.randomUUID(); pendingSave.current = { ...request, operationId }; setSaving(true); setFailure(null); try { let committed: SessionUsageLimit | null = null; for (let attempt = 0; committed === null; attempt += 1) { try { committed = await context.client.setSessionUsageLimit(session.workspaceId, session.id, { ...request, operationId, }); } catch (error) { // The response was lost: re-send the same operation, which the // server applies at most once, until it answers. const retryIn = SESSION_USAGE_LIMIT_UNKNOWN_RETRY_MS[attempt]; if (!isOutcomeUnknown(error) || retryIn === undefined) throw error; await new Promise((resolve) => setTimeout(resolve, retryIn)); } } pendingSave.current = null; limit.apply(committed); closeEditor(); const title = committed.limitMicros === null ? "Usage limit removed" : `Usage limit set to ${formatSessionUsageMicros(committed.limitMicros)}`; toast.success( title, props.goalPausedAtLimit ? { description: "Resume the goal to continue." } : undefined, ); } catch (error) { const classified = classifySessionUsageLimitSaveError(error); // Still unknown: keep the operation so Save again is the same request. if (classified.kind !== "unknown") pendingSave.current = null; if (classified.kind === "conflict") { // Someone else saved meanwhile. Show theirs and keep this draft; the // next Save is a deliberate choice to replace it. const latest = await refresh(); if (latest && latest.version !== base.version) { setEditBase({ version: latest.version, limitMicros: latest.limitMicros }); setFailure({ ...classified, message: `Someone changed this limit to ${limitText(latest.limitMicros)} while you were editing. Save again to use your amount instead.`, }); } else { setFailure(classified); } } else { setFailure(classified); if (classified.kind === "preparing") void refresh(); } } finally { setSaving(false); } } const limitLine = usage === null ? null : usage.limitMicros === null ? LABELS.noLimit : LABELS.pauseAt(formatSessionUsageMicros(usage.limitMicros)); // With a limit and known usage the amount leads ("$12.34 of $50.00"); // otherwise the limit leads and the usage (or why it's unknown) follows. const amountLeads = summary.state === "within" || summary.state === "near" || summary.state === "reached"; const primaryLine = amountLeads ? summary.amountText : limitLine; const secondaryLine = amountLeads ? null : summary.amountText; const value = usage === null ? ( limit.error ? ( void refresh()} className="text-xs"> Try again } > Couldn't read this chat's usage limit. ) : ( ) ) : (

{primaryLine} {summary.state === "reached" ? ( Reached ) : null}

{summary.barFraction !== null ? ( ); return (
{LABELS.title} {LABELS.notYourBill} } description={LABELS.explanation} control={ editable && !editing ? ( ) : undefined } />
{value}
{editing && usage ? (
{failure ? (

{failure.message}{" "} {failure.kind === "not_root" && failure.rootSessionId ? ( {LABELS.openMainTask} ) : null}

) : null} { setFailure(null); setTouched(true); setDraft((current) => kind === "none" ? { kind: "none" } : current.kind === "limit" ? current : { kind: "limit", text: usage.limitMicros === null ? "" : sessionUsageLimitInputValue(usage.limitMicros), }, ); }} options={[ { value: "none", label: LABELS.noLimit }, { value: "limit", label: "Pause at", disabled: capability === "disabled", disabledReason: capability === "disabled" ? DISABLED_REASON : undefined, }, ]} /> {draft.kind === "limit" ? (
{ setFailure(null); setDraft({ kind: "limit", text: event.target.value }); }} onBlur={() => setTouched(true)} onKeyDown={(event) => { if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.preventDefault(); setTouched(true); void save(); } else if (event.key === "Escape") { event.preventDefault(); closeEditor(); } }} />
) : null}
) : null}
); }