import { useCallback, useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Flag, Loader2, Pause, Play, Pencil, Trash2 } from "lucide-react"; import type { RunnerGoalAction, RunnerGoalActionRequest, RunnerGoalProjection, } from "@paperclipai/shared"; import { issuesApi } from "@/api/issues"; import { useCompanyLiveEvent } from "@/context/LiveUpdatesProvider"; import { queryKeys } from "@/lib/queryKeys"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Textarea } from "@/components/ui/textarea"; import type { RunnerGoalComposerCommand } from "./TaskChatComposer"; const PENDING_LABELS: Record = { starting: "Starting", editing: "Saving", replacing: "Replacing", pausing: "Pausing after current turn", resuming: "Resuming", clearing: "Clearing", continuing: "Continuing in a new run", }; function requestId() { return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `goal_${Date.now()}_${Math.random().toString(36).slice(2)}`; } function formatDuration(seconds: number) { if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); return `${hours}h ${minutes % 60}m`; } function formatTokens(tokens: number) { return tokens >= 1_000 ? `${(tokens / 1_000).toFixed(tokens >= 10_000 ? 0 : 1)}k` : String(tokens); } export function useRunnerGoalControl(issueId: string | null, agentId: string | null) { const queryClient = useQueryClient(); const [expanded, setExpanded] = useState(false); const [dialog, setDialog] = useState<{ action: "edit" | "replace"; objective: string; revision: number; } | null>(null); const [actionError, setActionError] = useState(null); useEffect(() => { setExpanded(false); setDialog(null); setActionError(null); }, [issueId, agentId]); const key = queryKeys.issues.runnerGoal(issueId ?? "__none__", agentId); const query = useQuery({ queryKey: key, queryFn: () => issuesApi.getRunnerGoal(issueId!, agentId), enabled: Boolean(issueId), refetchOnReconnect: true, refetchOnWindowFocus: true, refetchInterval: (state) => state.state.data?.goal?.status === "active" ? 30_000 : false, }); const mutation = useMutation({ mutationFn: (request: RunnerGoalActionRequest) => issuesApi.actOnRunnerGoal(issueId!, request), onSuccess: (accepted) => { queryClient.setQueryData(key, accepted.projection); setExpanded(accepted.projection.goal != null); }, }); useCompanyLiveEvent((event) => { if (event.type !== "agent.session.goal.changed") return; const next = event.payload as unknown as RunnerGoalProjection; if (next.issueId !== issueId || (agentId && next.agentId !== agentId)) return; const current = queryClient.getQueryData(key); if (current && next.revision > current.revision + 1) { void query.refetch(); return; } if (!current || next.revision >= current.revision) queryClient.setQueryData(key, next); }); const executeAction = useCallback(async ( action: RunnerGoalAction, objective?: string, confirmReplace = false, expectedRevision?: number, ) => { setActionError(null); try { const current = query.data ?? (await query.refetch()).data; if (!current?.agentId) throw new Error(current?.capability.reason ?? "Select an agent to use /goal."); if (current.capability.availability !== "available") { throw new Error(current.capability.reason ?? "Session goals are unsupported by this agent."); } await mutation.mutateAsync({ requestId: requestId(), agentId: current.agentId, expectedRevision: expectedRevision ?? current.revision, action, ...(objective ? { objective } : {}), ...(action === "replace" ? { confirmReplace } : {}), }); } catch (error) { setActionError(error instanceof Error ? error.message : "The goal action could not be applied."); throw error; } }, [mutation, query]); const edit = useCallback(async () => { const current = query.data ?? (await query.refetch()).data; if (!current?.goal) throw new Error("There is no current session goal to edit."); mutation.reset(); setActionError(null); setExpanded(true); setDialog({ action: "edit", objective: current.goal.objective, revision: current.revision }); }, [query, mutation]); const executeComposerCommand = useCallback(async (command: RunnerGoalComposerCommand) => { if (command.action === "focus") { const current = query.data ?? (await query.refetch()).data; if (!current?.goal && !current?.pendingAction) { throw new Error("Add an objective after /goal to start a goal."); } setExpanded(true); return; } if (command.action === "edit") { await edit(); return; } if (command.action === "create") { const current = query.data ?? (await query.refetch()).data; const unfinished = current?.goal && current.goal.status !== "complete"; if (unfinished) { mutation.reset(); setActionError(null); setExpanded(true); setDialog({ action: "replace", objective: command.objective, revision: current.revision }); } else { await executeAction("create", command.objective); } return; } await executeAction(command.action); }, [edit, executeAction, query, mutation]); const submitDialog = async () => { if (!dialog || mutation.isPending) return; const objective = dialog.objective.trim(); if (!objective || objective.length > 4_000) return; try { await executeAction(dialog.action, objective, dialog.action === "replace", dialog.revision); setDialog(null); } catch { // Keep the objective and the inline error available for correction. } }; return { ...query, expanded, setExpanded, dialog, setDialog, submitDialog, actionError, mutation, executeAction, edit, executeComposerCommand, }; } export type RunnerGoalControl = ReturnType; export function RunnerGoalWidget({ control }: { control: RunnerGoalControl }) { const projection = control.data; const goal = projection?.goal ?? null; const capability = projection?.capability; const can = (action: "set" | "pause" | "resume" | "clear") => capability?.availability === "available" && capability.actions.includes(action); const resumable = goal && ["paused", "blocked", "limited", "usage_limited"].includes(goal.status); const pendingLabel = projection?.pendingAction ? PENDING_LABELS[projection.pendingAction] : null; const mutationError = control.actionError ?? (control.mutation?.error instanceof Error ? control.mutation.error.message : control.mutation?.error ? "The goal action could not be applied." : null); // Expansion controls the objective's detail, not whether an empty card exists. // In particular, a cleared goal must disappear even after it was expanded. if (!goal && !projection?.pendingAction && !control.dialog && !mutationError) return null; return (
Session goal {goal ? ( {goal.status.replaceAll("_", " ")} ) : null} {goal?.workingNow ? ( Working now ) : null} {pendingLabel ? ( {pendingLabel} ) : null}
{goal ? (

{goal.objective}

) : (

{capability?.reason ?? "Type /goal followed by an objective to pursue work across turns."}

)} {goal ? (
{formatDuration(goal.elapsedSeconds)} {capability?.usageReporting ? ( {formatTokens(goal.tokensUsed)} tokens {goal.tokenBudget ? ` / ${formatTokens(goal.tokenBudget)}` : ""} ) : null} {goal.iterations > 0 ? {goal.iterations} iterations : null} {goal.lastReason ? {goal.lastReason} : null}
) : null}
{goal && can("set") ? ( ) : null} {goal?.status === "active" && can("pause") ? ( ) : null} {resumable && can("resume") ? ( ) : null} {goal && can("clear") ? ( ) : null}
{mutationError ? (

{mutationError}

) : null} { if (!open && !control.mutation?.isPending) control.setDialog(null); }}>
{ event.preventDefault(); void control.submitDialog(); }}> {control.dialog?.action === "replace" ? "Replace session goal?" : "Edit session goal"} {control.dialog?.action === "replace" ? "This clears the unfinished goal and starts a new goal with the objective below." : "Update the objective without clearing the goal's progress."}