import React from 'react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Textarea } from '@/components/ui/textarea'; import { NumberInput } from '@/components/ui/number-input'; import { toast } from '@/components/ui'; import { Checkbox } from '@/components/ui/checkbox'; import { useGoalObjectiveContent, useSessionGoal } from '@/hooks/useSessionGoal'; import { formatGoalTokens, SESSION_GOAL_OBJECTIVE_CHAR_LIMIT, } from '@/lib/sessionGoalMetadata'; import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation'; import { clearSessionGoal, setSessionGoal } from '@/lib/sessionGoalActions'; import { useI18n } from '@/lib/i18n'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { useUIStore } from '@/stores/useUIStore'; interface SessionGoalDialogProps { open: boolean; onOpenChange: (open: boolean) => void; sessionId: string; directory?: string; } // Create/manage dialog for the session goal: objective + optional token // budget on creation; status, usage, latest audit note and lifecycle actions // (pause/resume/complete/clear) once a goal exists. export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }: SessionGoalDialogProps) { const { t } = useI18n(); const isMobile = useUIStore((state) => state.isMobile); const { goal } = useSessionGoal(sessionId, directory); const objectiveContent = useGoalObjectiveContent(sessionId, goal); const [objective, setObjective] = React.useState(''); const [budgetEnabled, setBudgetEnabled] = React.useState(false); const [tokenBudget, setTokenBudget] = React.useState(200_000); const [busy, setBusy] = React.useState(false); React.useEffect(() => { if (!open) return; setObjective(goal?.objectiveFile ? (objectiveContent ?? '') : (goal?.objective ?? '')); setBudgetEnabled(Boolean(goal?.tokenBudget)); setTokenBudget(goal?.tokenBudget ?? 200_000); // Seed the form only when the dialog opens; live goal updates while it is // open must not clobber the user's edits. // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); // File-backed objectives fetch async โ€” the content usually lands right // after the dialog opens. Late-seed the textarea only while it is still // untouched so a slow fetch never clobbers the user's typing. React.useEffect(() => { if (!open || !goal?.objectiveFile || objectiveContent === null) return; setObjective((current) => (current === '' ? objectiveContent : current)); }, [open, goal?.objectiveFile, objectiveContent]); const run = React.useCallback(async (action: () => Promise, closeAfter: boolean) => { setBusy(true); try { await action(); if (closeAfter) onOpenChange(false); } catch (error) { console.warn('[session-goal] action failed:', error); toast.error(t('chat.goal.toast.actionFailed')); } finally { setBusy(false); } }, [onOpenChange, t]); const trimmedObjective = objective.trim(); const savedObjective = goal?.objectiveFile ? (objectiveContent ?? '') : (goal?.objective ?? ''); const objectiveChanged = trimmedObjective !== savedObjective; const budgetValue = budgetEnabled ? tokenBudget : null; const budgetChanged = budgetValue !== (goal?.tokenBudget ?? null); // A completed goal is read-only: remove it and arm a new one instead of // "saving" over the outcome (re-saving used to spawn a fresh active goal // that the auditor instantly re-completed โ€” a confusing status flash). const isCompleted = goal?.status === 'complete'; const canSave = !isCompleted && trimmedObjective.length > 0 && (!goal || objectiveChanged || budgetChanged); const handleSave = () => run( () => setSessionGoal(sessionId, directory, { objective: trimmedObjective, tokenBudget: budgetValue }, goal), true, ); const title = goal ? t('chat.goal.dialog.titleManage') : t('chat.goal.dialog.titleCreate'); const body = (
{goal && (
{goal.note ? (

{goal.note}

) : null} {/* Only failure states carry a reason worth reading; outcomes like "verified by audit" are noise next to the status dot. */} {goal.statusReason && (goal.status === 'blocked' || goal.status === 'budgetLimited') ? (

{goal.statusReason}

) : null} {goal.evaluationProviderID || goal.evaluationModelID ? (
{t('chat.goal.dialog.evaluationModelLabel')} {[goal.evaluationProviderID, goal.evaluationModelID].filter(Boolean).join('/')}
) : null}
)} {isCompleted ? (

{objectiveContent ?? goal.objective}

) : ( <>
{t('chat.goal.dialog.objectiveLabel')} {objective.length}/{SESSION_GOAL_OBJECTIVE_CHAR_LIMIT}