import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useSessionGoal } from '@/hooks/useSessionGoal'; import { useSessionGoalArmStore } from '@/stores/useSessionGoalArmStore'; import { SESSION_GOAL_OBJECTIVE_CHAR_LIMIT } from '@/lib/sessionGoalMetadata'; import { sessionGoalStatusColor } from '@/lib/sessionGoalPresentation'; import { SessionGoalDialog } from '@/components/chat/SessionGoalDialog'; import { isVSCodeRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; interface SessionGoalButtonProps { sessionId: string | null; directory?: string; /** Session draft is open — the goal arms for the session the draft creates. */ draftOpen?: boolean; footerIconButtonClass: string; iconSizeClass: string; withTooltip?: boolean; } // Composer target button — the goal switch. With no live goal one tap arms // goal mode (the next sent prompt becomes the objective; works on drafts // too) and a second tap disarms. While a goal is live the target stays lit // (info while running, success when complete, error when blocked / out of // budget) and tapping opens the manage dialog. export const SessionGoalButton: React.FC = React.memo(({ sessionId, directory, draftOpen = false, footerIconButtonClass, iconSizeClass, withTooltip = false, }) => { const { t } = useI18n(); const { goal, enabled } = useSessionGoal(sessionId ?? '', directory); const armed = useSessionGoalArmStore((state) => state.armed); const setArmed = useSessionGoalArmStore((state) => state.setArmed); const [dialogOpen, setDialogOpen] = React.useState(false); // The goal loop runs in the web server; the VS Code extension only renders // goal state. Arming a goal there would create one nothing drives, so the // entry point is hidden entirely. if (isVSCodeRuntime() || !enabled || (!sessionId && !draftOpen)) { return null; } // A settled goal no longer drives the loop — the button goes back to being // an arm switch, while still tinting with the outcome color. const liveGoal = goal && goal.status !== 'complete' ? goal : null; const isEngaged = armed || Boolean(liveGoal); // One mapping for every goal surface. This button used to carry its own, // which painted `paused` the same info colour as `active` — so a paused goal // was indistinguishable from a running one — and `blocked` as an error rather // than a warning. `armed` is not a goal status, so it keeps its own case. const iconColor = goal ? sessionGoalStatusColor[goal.status] : (armed ? 'var(--status-info)' : undefined); const label = goal ? t('chat.goal.button.manageAria') : (armed ? t('chat.goal.button.disarmAria') : t('chat.goal.button.armAria')); // Any existing goal (live or completed) opens the manage dialog — a // completed goal must be removed there before a new one can be armed. const handleClick = () => { if (goal) { setDialogOpen(true); return; } setArmed(!armed); }; const button = ( ); return ( <> {withTooltip ? ( {button} {label} ) : button} {sessionId ? ( ) : null} ); }); SessionGoalButton.displayName = 'SessionGoalButton'; interface SessionGoalObjectiveCounterProps { /** Current composer text length — the armed message becomes the objective. */ length: number; } // Tiny hot-path leaf next to the target button: while goal mode is armed the // typed message becomes the objective, which the server clamps to 2000 // chars — surface that limit during typing instead of truncating silently. // Renders null when not armed, so normal typing shows nothing. export const SessionGoalObjectiveCounter: React.FC = React.memo(({ length }) => { const { t } = useI18n(); const armed = useSessionGoalArmStore((state) => state.armed); if (!armed || length === 0) { return null; } const over = length > SESSION_GOAL_OBJECTIVE_CHAR_LIMIT; return ( {length}/{SESSION_GOAL_OBJECTIVE_CHAR_LIMIT} ); }); SessionGoalObjectiveCounter.displayName = 'SessionGoalObjectiveCounter';