From 5d67e8fd5f6b8d3a884d0aa5d692b750400455a3 Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Wed, 25 Feb 2026 11:58:00 -0600 Subject: [PATCH 01/18] feat: enhance session status indicators and AskUserQuestion handling - Introduced `SessionStatusDot` and `AgentStatusIndicator` components for improved visual representation of session and agent statuses. - Updated `ProjectSessionDetailPage` to utilize new components, replacing the previous badge implementation. - Enhanced `MessagesTab` to handle pending answers for `AskUserQuestion` tool responses, ensuring user input is sent correctly. - Modified `StreamMessage` to render `AskUserQuestionMessage` for interactive question handling. - Updated `ToolMessage` to generate summaries for `AskUserQuestion` tool calls, improving user feedback. This update improves the user experience by providing clearer status indicators and better handling of interactive questions in the chat interface. --- .../[name]/sessions/[sessionName]/page.tsx | 28 +- .../src/components/agent-status-indicator.tsx | 106 ++++++++ .../src/components/session-status-dot.tsx | 57 ++++ .../src/components/session/MessagesTab.tsx | 24 ++ .../src/components/ui/ask-user-question.tsx | 250 ++++++++++++++++++ .../src/components/ui/stream-message.tsx | 21 +- .../src/components/ui/tool-message.tsx | 11 + .../workspace-sections/sessions-section.tsx | 9 +- .../frontend/src/hooks/use-agent-status.ts | 85 ++++++ .../frontend/src/types/agentic-session.ts | 25 ++ .../ag_ui_claude_sdk/adapter.py | 31 ++- .../ambient_runner/bridges/claude/bridge.py | 10 + 12 files changed, 637 insertions(+), 20 deletions(-) create mode 100644 components/frontend/src/components/agent-status-indicator.tsx create mode 100644 components/frontend/src/components/session-status-dot.tsx create mode 100644 components/frontend/src/components/ui/ask-user-question.tsx create mode 100644 components/frontend/src/hooks/use-agent-status.ts diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx index b0f4dad0e..631c9cd2e 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx @@ -67,6 +67,9 @@ import { import Link from "next/link"; import { SessionHeader } from "./session-header"; import { getPhaseColor } from "@/utils/session-helpers"; +import { SessionStatusDot } from "@/components/session-status-dot"; +import { AgentStatusIndicator } from "@/components/agent-status-indicator"; +import { useAgentStatus } from "@/hooks/use-agent-status"; // Extracted components import { AddContextModal } from "./components/modals/add-context-modal"; @@ -775,6 +778,13 @@ export default function ProjectSessionDetailPage({ handleWorkflowChange(workflowId); }; + // Derive agent-level status from session data and messages + const agentStatus = useAgentStatus( + session?.status?.phase || "Pending", + isRunActive, + aguiStream.state.messages as unknown as Array, + ); + // Phase 1: convert committed messages + streaming tool cards into display format. // Does NOT depend on currentMessage / currentReasoning so it skips the full // O(n) traversal during text-streaming deltas (the most frequent event type). @@ -1479,13 +1489,8 @@ export default function ProjectSessionDetailPage({ {session.spec.displayName || session.metadata.name} - - {session.status?.phase || "Pending"} - + + {agentName && ( {agentName} / {session.spec.llmSettings.model} @@ -1519,13 +1524,8 @@ export default function ProjectSessionDetailPage({ {session.spec.displayName || session.metadata.name} - - {session.status?.phase || "Pending"} - + + diff --git a/components/frontend/src/components/agent-status-indicator.tsx b/components/frontend/src/components/agent-status-indicator.tsx new file mode 100644 index 000000000..84843eba3 --- /dev/null +++ b/components/frontend/src/components/agent-status-indicator.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import { + Loader2, + CheckCircle2, + XCircle, + Circle, + HelpCircle, +} from "lucide-react"; +import type { AgentStatus } from "@/types/agentic-session"; + +type AgentStatusIndicatorProps = { + status: AgentStatus; + compact?: boolean; + className?: string; +}; + +export function AgentStatusIndicator({ + status, + compact = false, + className, +}: AgentStatusIndicatorProps) { + switch (status) { + case "working": + return ( +
+ + {!compact && ( + + Working + + )} +
+ ); + + case "waiting_input": + return ( + + + {compact ? "Input" : "Needs Input"} + + ); + + case "completed": + return ( +
+ + {!compact && ( + + Completed + + )} +
+ ); + + case "failed": + return ( +
+ + {!compact && ( + + Failed + + )} +
+ ); + + case "idle": + return ( +
+ + {!compact && ( + Idle + )} +
+ ); + } +} diff --git a/components/frontend/src/components/session-status-dot.tsx b/components/frontend/src/components/session-status-dot.tsx new file mode 100644 index 000000000..38113c4d2 --- /dev/null +++ b/components/frontend/src/components/session-status-dot.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { AgenticSessionPhase } from "@/types/agentic-session"; + +type SessionStatusDotProps = { + phase: AgenticSessionPhase | string; + className?: string; +}; + +const DOT_COLORS: Record = { + Running: "bg-blue-500", + Completed: "bg-gray-400", + Stopped: "bg-gray-400", + Failed: "bg-red-500", + Pending: "bg-orange-400", + Creating: "bg-orange-400", + Stopping: "bg-orange-400", +}; + +const DOT_ANIMATIONS: Record = { + Running: "animate-pulse", + Creating: "animate-pulse", + Stopping: "animate-pulse", +}; + +export function SessionStatusDot({ phase, className }: SessionStatusDotProps) { + const color = DOT_COLORS[phase] || "bg-gray-400"; + const animation = DOT_ANIMATIONS[phase] || ""; + + return ( + + + + + + +

Session: {phase}

+
+
+
+ ); +} diff --git a/components/frontend/src/components/session/MessagesTab.tsx b/components/frontend/src/components/session/MessagesTab.tsx index 0cbb1f490..15111be53 100644 --- a/components/frontend/src/components/session/MessagesTab.tsx +++ b/components/frontend/src/components/session/MessagesTab.tsx @@ -56,6 +56,17 @@ const MessagesTab: React.FC = ({ session, streamMessages, chat const [showSystemMessages, setShowSystemMessages] = useState(false); const [waitingDotCount, setWaitingDotCount] = useState(0); + // Pending answer ref for AskUserQuestion tool responses. + // When set, the next render triggers a send. + const pendingAnswerRef = useRef(null); + + // Autocomplete state + const [autocompleteOpen, setAutocompleteOpen] = useState(false); + const [autocompleteType, setAutocompleteType] = useState<'agent' | 'command' | null>(null); + const [autocompleteFilter, setAutocompleteFilter] = useState(''); + const [autocompleteTriggerPos, setAutocompleteTriggerPos] = useState(0); + const [autocompleteSelectedIndex, setAutocompleteSelectedIndex] = useState(0); + const messagesContainerRef = useRef(null); const [isAtBottom, setIsAtBottom] = useState(true); // How many messages (counting from the end) are currently rendered. @@ -140,6 +151,15 @@ const MessagesTab: React.FC = ({ session, streamMessages, chat scrollToBottom(); }, []); + // Send pending AskUserQuestion answer once chatInput is updated + useEffect(() => { + if (pendingAnswerRef.current !== null && chatInput === pendingAnswerRef.current) { + pendingAnswerRef.current = null; + handleSendChat(); + } + }, [chatInput]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { const unsentCount = queuedMessages.filter(m => !m.sentAt).length; if (unsentCount === 0) return; @@ -213,6 +233,10 @@ const MessagesTab: React.FC = ({ session, streamMessages, chat isNewest={idx === visibleMessages.length - 1} onGoToResults={onGoToResults} agentName={agentName} + onSubmitAnswer={(answer) => { + pendingAnswerRef.current = answer; + setChatInput(answer); + }} /> ))} diff --git a/components/frontend/src/components/ui/ask-user-question.tsx b/components/frontend/src/components/ui/ask-user-question.tsx new file mode 100644 index 000000000..e9d9b5eab --- /dev/null +++ b/components/frontend/src/components/ui/ask-user-question.tsx @@ -0,0 +1,250 @@ +"use client"; + +import React, { useState } from "react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { HelpCircle, CheckCircle2, Send } from "lucide-react"; +import { formatTimestamp } from "@/lib/format-timestamp"; +import type { + ToolUseBlock, + ToolResultBlock, + AskUserQuestionItem, + AskUserQuestionInput, +} from "@/types/agentic-session"; + +export type AskUserQuestionMessageProps = { + toolUseBlock: ToolUseBlock; + resultBlock?: ToolResultBlock; + timestamp?: string; + onSubmitAnswer?: (formattedAnswer: string) => void; +}; + +function parseQuestions(input: Record): AskUserQuestionItem[] { + const raw = input as unknown as AskUserQuestionInput; + if (raw?.questions && Array.isArray(raw.questions)) { + return raw.questions; + } + return []; +} + +function isAnswered(resultBlock?: ToolResultBlock): boolean { + if (!resultBlock) return false; + const content = resultBlock.content; + if (!content) return false; + if (typeof content === "string" && content.trim() === "") return false; + return true; +} + +export const AskUserQuestionMessage: React.FC = ({ + toolUseBlock, + resultBlock, + timestamp, + onSubmitAnswer, +}) => { + const questions = parseQuestions(toolUseBlock.input); + const answered = isAnswered(resultBlock); + const formattedTime = formatTimestamp(timestamp); + + // State: map from question text to selected label(s) + const [selections, setSelections] = useState>({}); + + const handleSingleSelect = (questionText: string, label: string) => { + if (answered) return; + setSelections((prev) => ({ ...prev, [questionText]: label })); + }; + + const handleMultiSelect = (questionText: string, label: string, checked: boolean) => { + if (answered) return; + setSelections((prev) => { + const current = (prev[questionText] as string[]) || []; + if (checked) { + return { ...prev, [questionText]: [...current, label] }; + } + return { ...prev, [questionText]: current.filter((l) => l !== label) }; + }); + }; + + const allQuestionsAnswered = questions.every((q) => { + const sel = selections[q.question]; + if (!sel) return false; + if (Array.isArray(sel)) return sel.length > 0; + return sel.length > 0; + }); + + const handleSubmit = () => { + if (!onSubmitAnswer || !allQuestionsAnswered) return; + + // Format as a readable answer message + const parts = questions.map((q) => { + const sel = selections[q.question]; + const answer = Array.isArray(sel) ? sel.join(", ") : sel; + if (questions.length === 1) return answer; + return `${q.header || q.question}: ${answer}`; + }); + + onSubmitAnswer(parts.join("\n")); + }; + + if (questions.length === 0) { + return null; + } + + return ( +
+
+ {/* Avatar */} +
+
+ +
+
+ + {/* Content */} +
+ {formattedTime && ( +
+ {formattedTime} +
+ )} + + + + {/* Header */} +
+ {answered ? ( + + ) : ( + + )} + + {answered ? "Question Answered" : "Input Needed"} + +
+ + {/* Questions */} + {questions.map((q, qIdx) => ( +
+ {q.header && ( +
+ {q.header} +
+ )} +

{q.question}

+ + {/* Options */} + {q.multiSelect ? ( + /* Multi-select: Checkboxes */ +
+ {q.options.map((opt) => { + const currentSel = (selections[q.question] as string[]) || []; + const isSelected = currentSel.includes(opt.label); + + return ( +
+ + handleMultiSelect(q.question, opt.label, checked === true) + } + disabled={answered} + /> +
+ + {opt.description && ( +

+ {opt.description} +

+ )} +
+
+ ); + })} +
+ ) : ( + /* Single-select: Buttons */ +
+ {q.options.map((opt) => { + const isSelected = selections[q.question] === opt.label; + + return ( + + ); + })} +
+ )} + + {/* Show descriptions for single-select options below the buttons */} + {!q.multiSelect && q.options.some((o) => o.description) && ( +
+ {q.options.map((opt) => { + if (!opt.description) return null; + const isSelected = selections[q.question] === opt.label; + return ( +

+ {opt.label}:{" "} + {opt.description} +

+ ); + })} +
+ )} +
+ ))} + + {/* Submit button */} + {!answered && onSubmitAnswer && ( +
+ +
+ )} +
+
+
+
+
+ ); +}; + +AskUserQuestionMessage.displayName = "AskUserQuestionMessage"; diff --git a/components/frontend/src/components/ui/stream-message.tsx b/components/frontend/src/components/ui/stream-message.tsx index bcd38db13..06105a44a 100644 --- a/components/frontend/src/components/ui/stream-message.tsx +++ b/components/frontend/src/components/ui/stream-message.tsx @@ -4,6 +4,7 @@ import React from "react"; import { MessageObject, ToolUseMessages, HierarchicalToolMessage } from "@/types/agentic-session"; import { LoadingDots, Message } from "@/components/ui/message"; import { ToolMessage } from "@/components/ui/tool-message"; +import { AskUserQuestionMessage } from "@/components/ui/ask-user-question"; import { ThinkingMessage } from "@/components/ui/thinking-message"; import { SystemMessage } from "@/components/ui/system-message"; import { Button } from "@/components/ui/button"; @@ -12,11 +13,17 @@ import { FeedbackButtons } from "@/components/feedback"; export type StreamMessageProps = { message: (MessageObject | ToolUseMessages | HierarchicalToolMessage) & { streaming?: boolean }; onGoToResults?: () => void; + onSubmitAnswer?: (formattedAnswer: string) => void; plainCard?: boolean; isNewest?: boolean; agentName?: string; }; +function isAskUserQuestionTool(name: string): boolean { + const normalized = name.toLowerCase().replace(/[^a-z]/g, ""); + return normalized === "askuserquestion"; +} + const getRandomAgentMessage = () => { const messages = [ "The agents are working together on your request...", @@ -33,11 +40,23 @@ const getRandomAgentMessage = () => { return messages[Math.floor(Math.random() * messages.length)]; }; -export const StreamMessage: React.FC = ({ message, onGoToResults, plainCard=false, isNewest=false, agentName }) => { +export const StreamMessage: React.FC = ({ message, onGoToResults, onSubmitAnswer, plainCard=false, isNewest=false, agentName }) => { const isToolUsePair = (m: MessageObject | ToolUseMessages | HierarchicalToolMessage): m is ToolUseMessages | HierarchicalToolMessage => m != null && typeof m === "object" && "toolUseBlock" in m && "resultBlock" in m; if (isToolUsePair(message)) { + // Render AskUserQuestion with a custom interactive component + if (isAskUserQuestionTool(message.toolUseBlock.name)) { + return ( + + ); + } + // Check if this is a hierarchical message with children const hierarchical = message as HierarchicalToolMessage; return ( diff --git a/components/frontend/src/components/ui/tool-message.tsx b/components/frontend/src/components/ui/tool-message.tsx index 8136ddbae..512fd259b 100644 --- a/components/frontend/src/components/ui/tool-message.tsx +++ b/components/frontend/src/components/ui/tool-message.tsx @@ -308,6 +308,17 @@ const extractTextFromResultContent = (content: unknown): string => { const generateToolSummary = (toolName: string, input?: Record): string => { if (!input || Object.keys(input).length === 0) return formatToolName(toolName); + // AskUserQuestion - show first question text + if (toolName.toLowerCase().replace(/[^a-z]/g, "") === "askuserquestion") { + const questions = input.questions as Array<{ question: string }> | undefined; + if (questions?.length) { + const suffix = questions.length > 1 ? ` (+${questions.length - 1} more)` : ""; + return `Asking: "${questions[0].question}"${suffix}`; + } + return "Asking a question"; + } + + // WebSearch - show query if (toolName.toLowerCase().includes("websearch") || toolName.toLowerCase().includes("web_search")) { const query = input.query as string | undefined; diff --git a/components/frontend/src/components/workspace-sections/sessions-section.tsx b/components/frontend/src/components/workspace-sections/sessions-section.tsx index 2ffaa78f5..ce70c5d3a 100644 --- a/components/frontend/src/components/workspace-sections/sessions-section.tsx +++ b/components/frontend/src/components/workspace-sections/sessions-section.tsx @@ -22,7 +22,9 @@ import { } from '@/components/ui/pagination'; import { getPageNumbers } from '@/lib/pagination'; import { EmptyState } from '@/components/empty-state'; -import { SessionPhaseBadge } from '@/components/status-badge'; +import { SessionStatusDot } from '@/components/session-status-dot'; +import { AgentStatusIndicator } from '@/components/agent-status-indicator'; +import { deriveAgentStatusFromPhase } from '@/hooks/use-agent-status'; import { CreateSessionDialog } from '@/components/create-session-dialog'; import { EditSessionNameDialog } from '@/components/edit-session-name-dialog'; @@ -326,7 +328,10 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { - +
+ + +
diff --git a/components/frontend/src/hooks/use-agent-status.ts b/components/frontend/src/hooks/use-agent-status.ts new file mode 100644 index 000000000..e8527ce58 --- /dev/null +++ b/components/frontend/src/hooks/use-agent-status.ts @@ -0,0 +1,85 @@ +import { useMemo } from "react"; +import type { + AgenticSessionPhase, + AgentStatus, + MessageObject, + ToolUseMessages, +} from "@/types/agentic-session"; + +function isAskUserQuestionTool(name: string): boolean { + const normalized = name.toLowerCase().replace(/[^a-z]/g, ""); + return normalized === "askuserquestion"; +} + +/** + * Derive agent status from session data and message stream. + * + * For the session detail page where the full message stream is available, + * this provides accurate status including `waiting_input` detection. + */ +export function useAgentStatus( + phase: AgenticSessionPhase | string, + isRunActive: boolean, + messages: Array, +): AgentStatus { + return useMemo(() => { + // Terminal states from session phase + if (phase === "Completed") return "completed"; + if (phase === "Failed") return "failed"; + if (phase === "Stopped") return "idle"; + + // Non-running phases + if (phase !== "Running") return "idle"; + + // Check if the last tool call is an unanswered AskUserQuestion + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + + // Skip non-tool messages + if (!("toolUseBlock" in msg)) continue; + + const toolMsg = msg as ToolUseMessages; + if (isAskUserQuestionTool(toolMsg.toolUseBlock.name)) { + // Check if it has a result (answered) + const hasResult = + toolMsg.resultBlock?.content !== undefined && + toolMsg.resultBlock?.content !== null && + toolMsg.resultBlock?.content !== ""; + if (!hasResult) { + return "waiting_input"; + } + } + + // Only check the most recent tool call + break; + } + + // Active processing + if (isRunActive) return "working"; + + // Running but idle between turns + return "idle"; + }, [phase, isRunActive, messages]); +} + +/** + * Derive a simplified agent status from session phase alone. + * + * Used in the session list where per-session message streams are not available. + */ +export function deriveAgentStatusFromPhase( + phase: AgenticSessionPhase | string, +): AgentStatus { + switch (phase) { + case "Running": + return "working"; + case "Completed": + return "completed"; + case "Failed": + return "failed"; + case "Stopped": + return "idle"; + default: + return "idle"; + } +} diff --git a/components/frontend/src/types/agentic-session.ts b/components/frontend/src/types/agentic-session.ts index 813684e91..866cefb80 100644 --- a/components/frontend/src/types/agentic-session.ts +++ b/components/frontend/src/types/agentic-session.ts @@ -1,5 +1,30 @@ export type AgenticSessionPhase = "Pending" | "Creating" | "Running" | "Stopping" | "Stopped" | "Completed" | "Failed"; +// Agent status (derived from message stream, distinct from session phase) +export type AgentStatus = + | "working" // Actively processing + | "waiting_input" // AskUserQuestion pending, needs human response + | "completed" // Task finished successfully + | "failed" // Task errored + | "idle"; // Session running, agent between turns + +// AskUserQuestion tool types (Claude Agent SDK built-in) +export type AskUserQuestionOption = { + label: string; + description?: string; +}; + +export type AskUserQuestionItem = { + question: string; + header?: string; + options: AskUserQuestionOption[]; + multiSelect?: boolean; +}; + +export type AskUserQuestionInput = { + questions: AskUserQuestionItem[]; +}; + export type LLMSettings = { model: string; temperature: number; diff --git a/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py b/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py index 127729a52..5d296580c 100644 --- a/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py +++ b/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py @@ -70,6 +70,12 @@ emit_system_message_events, ) +# Built-in Claude tools that should halt the stream like frontend tools. +# These are HITL (human-in-the-loop) tools that require user input before +# the agent can continue. The adapter treats them identically to frontend +# tools registered via ``input_data.tools``. +BUILTIN_FRONTEND_TOOLS: set[str] = {"AskUserQuestion"} + logger = logging.getLogger(__name__) @@ -227,6 +233,19 @@ def __init__( # Current state tracking per run (for state management) self._current_state: Optional[Any] = None + # Whether the last run halted due to a frontend tool (caller should interrupt) + self._halted: bool = False + + @property + def halted(self) -> bool: + """Whether the last run halted due to a frontend tool. + + When ``True`` the caller should interrupt the underlying SDK client + to prevent it from auto-approving the halted tool call with a + placeholder result. + """ + return self._halted + async def run( self, input_data: RunAgentInput, @@ -252,8 +271,9 @@ async def run( thread_id = input_data.thread_id or str(uuid.uuid4()) run_id = input_data.run_id or str(uuid.uuid4()) - # Clear result data from any previous run + # Clear result data and halt flag from any previous run self._last_result_data = None + self._halted = False # Initialize state tracking for this run self._current_state = input_data.state @@ -836,9 +856,12 @@ def flush_pending_msg(): ) # Check if this is a frontend tool (using unprefixed name for comparison) - # Frontend tools should halt the stream so client can execute handler + # Frontend tools should halt the stream so client can execute handler. + # Also halt for built-in HITL tools (e.g. AskUserQuestion) that + # require user input before the agent can continue. is_frontend_tool = ( current_tool_display_name in frontend_tool_names + or current_tool_display_name in BUILTIN_FRONTEND_TOOLS ) if is_frontend_tool: @@ -869,8 +892,10 @@ def flush_pending_msg(): ) # NOTE: interrupt is the caller's responsibility - # (e.g. worker.interrupt() from the platform layer) + # (e.g. worker.interrupt() from the platform layer). + # Check adapter.halted after the stream ends. + self._halted = True halt_event_stream = True # Continue consuming remaining events for cleanup continue diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py index 36da70cbd..ef4893f8b 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py @@ -121,6 +121,16 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[BaseEvent]: async for event in wrapped_stream: yield event + # If the adapter halted (frontend tool or built-in HITL tool like + # AskUserQuestion), interrupt the worker to prevent the SDK from + # auto-approving the tool call with a placeholder result. + if self._adapter.halted: + logger.info( + f"Adapter halted for thread={thread_id}, " + "interrupting worker to await user input" + ) + await worker.interrupt() + self._first_run = False async def interrupt(self, thread_id: Optional[str] = None) -> None: From 615dd0aa0b16c4903171768f7088006608d30993 Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Thu, 26 Feb 2026 08:13:29 -0600 Subject: [PATCH 02/18] refactor: update session handling and agent status management - Removed `package-lock.json` to streamline dependency management. - Enhanced `AgenticSessionStatus` to include `agentStatus`, reflecting the current state of the agent. - Updated `sessions.go` to parse and set `agentStatus` based on incoming status updates. - Improved `agui_proxy.go` to manage `agentStatus` updates for various session events, ensuring accurate real-time status representation. - Modified frontend components to handle `agentStatus` and integrate new functionalities for `AskUserQuestion` responses, improving user interaction and feedback. This update enhances the overall session management and user experience by providing clearer status indicators and better handling of interactive questions. --- .../src/components/ui/ask-user-question.tsx | 343 +++++++++++++++ .../src/components/ui/radio-group.tsx | 44 ++ .../frontend/src/hooks/agui/types.ts | 49 +++ .../frontend/src/hooks/use-agui-stream.ts | 323 ++++++++++++++ components/backend/handlers/sessions.go | 4 + components/backend/types/session.go | 1 + components/backend/websocket/agui_proxy.go | 100 ++++- .../[name]/sessions/[sessionName]/page.tsx | 19 + .../src/components/session/MessagesTab.tsx | 19 +- .../src/components/ui/ask-user-question.tsx | 407 +++++++++++------- .../src/components/ui/stream-message.tsx | 1 + .../workspace-sections/sessions-section.tsx | 13 +- components/frontend/src/hooks/agui/types.ts | 2 +- .../frontend/src/hooks/use-agui-stream.ts | 3 +- .../src/services/queries/use-sessions.ts | 26 ++ .../frontend/src/types/agentic-session.ts | 1 + components/frontend/src/types/api/sessions.ts | 1 + .../base/crds/agenticsessions-crd.yaml | 7 + components/package-lock.json | 6 - .../ambient_runner/platform/prompts.py | 12 + 20 files changed, 1196 insertions(+), 185 deletions(-) create mode 100644 .claude/worktrees/human-in-the-loop/components/frontend/src/components/ui/ask-user-question.tsx create mode 100644 .claude/worktrees/human-in-the-loop/components/frontend/src/components/ui/radio-group.tsx create mode 100644 .claude/worktrees/human-in-the-loop/components/frontend/src/hooks/agui/types.ts create mode 100644 .claude/worktrees/human-in-the-loop/components/frontend/src/hooks/use-agui-stream.ts delete mode 100644 components/package-lock.json diff --git a/.claude/worktrees/human-in-the-loop/components/frontend/src/components/ui/ask-user-question.tsx b/.claude/worktrees/human-in-the-loop/components/frontend/src/components/ui/ask-user-question.tsx new file mode 100644 index 000000000..4632cc4fb --- /dev/null +++ b/.claude/worktrees/human-in-the-loop/components/frontend/src/components/ui/ask-user-question.tsx @@ -0,0 +1,343 @@ +"use client"; + +import React, { useState } from "react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { HelpCircle, CheckCircle2, Send, ChevronRight } from "lucide-react"; +import { formatTimestamp } from "@/lib/format-timestamp"; +import type { + ToolUseBlock, + ToolResultBlock, + AskUserQuestionItem, + AskUserQuestionInput, +} from "@/types/agentic-session"; + +export type AskUserQuestionMessageProps = { + toolUseBlock: ToolUseBlock; + resultBlock?: ToolResultBlock; + timestamp?: string; + onSubmitAnswer?: (formattedAnswer: string) => void; + isNewest?: boolean; +}; + +function parseQuestions(input: Record): AskUserQuestionItem[] { + const raw = input as unknown as AskUserQuestionInput; + if (raw?.questions && Array.isArray(raw.questions)) { + return raw.questions; + } + return []; +} + +function hasResult(resultBlock?: ToolResultBlock): boolean { + if (!resultBlock) return false; + const content = resultBlock.content; + if (!content) return false; + if (typeof content === "string" && content.trim() === "") return false; + return true; +} + +export const AskUserQuestionMessage: React.FC = ({ + toolUseBlock, + resultBlock, + timestamp, + onSubmitAnswer, + isNewest = false, +}) => { + const questions = parseQuestions(toolUseBlock.input); + const alreadyAnswered = hasResult(resultBlock); + const formattedTime = formatTimestamp(timestamp); + const isMultiQuestion = questions.length > 1; + + // Only interactive when newest message AND not already answered/submitted + const [submitted, setSubmitted] = useState(false); + const disabled = alreadyAnswered || submitted || !isNewest; + + // Active tab index (for multi-question tabbed view) + const [activeTab, setActiveTab] = useState(0); + + // Selections: map from question text → selected label(s) or freeform string + const [selections, setSelections] = useState>({}); + // Track which questions use freeform "Other" input + const [usingOther, setUsingOther] = useState>({}); + // Freeform text inputs + const [otherText, setOtherText] = useState>({}); + + const handleSingleSelect = (questionText: string, label: string) => { + if (disabled) return; + setUsingOther((prev) => ({ ...prev, [questionText]: false })); + setSelections((prev) => ({ ...prev, [questionText]: label })); + // Auto-advance to next tab + if (isMultiQuestion && activeTab < questions.length - 1) { + setTimeout(() => setActiveTab((t) => t + 1), 250); + } + }; + + const handleOtherToggle = (questionText: string) => { + if (disabled) return; + setUsingOther((prev) => ({ ...prev, [questionText]: true })); + setSelections((prev) => ({ ...prev, [questionText]: otherText[questionText] || "" })); + }; + + const handleOtherTextChange = (questionText: string, text: string) => { + if (disabled) return; + setOtherText((prev) => ({ ...prev, [questionText]: text })); + setSelections((prev) => ({ ...prev, [questionText]: text })); + }; + + const handleMultiSelect = (questionText: string, label: string, checked: boolean) => { + if (disabled) return; + setSelections((prev) => { + const current = (prev[questionText] as string[]) || []; + if (checked) return { ...prev, [questionText]: [...current, label] }; + return { ...prev, [questionText]: current.filter((l) => l !== label) }; + }); + }; + + const isQuestionAnswered = (q: AskUserQuestionItem): boolean => { + const sel = selections[q.question]; + if (!sel) return false; + if (Array.isArray(sel)) return sel.length > 0; + return sel.length > 0; + }; + + const allQuestionsAnswered = questions.every(isQuestionAnswered); + + const handleSubmit = () => { + if (!onSubmitAnswer || !allQuestionsAnswered || disabled) return; + + // Build the structured response matching Claude SDK format + const answers: Record = {}; + for (const q of questions) { + const sel = selections[q.question]; + answers[q.question] = Array.isArray(sel) ? sel.join(", ") : (sel as string); + } + + // Send as JSON matching the AskUserQuestion response format + const response = JSON.stringify({ questions, answers }); + onSubmitAnswer(response); + setSubmitted(true); + }; + + if (questions.length === 0) return null; + + const currentQuestion = questions[activeTab] || questions[0]; + + const renderQuestionOptions = (q: AskUserQuestionItem, qIdx: number) => { + const isOther = usingOther[q.question]; + + if (q.multiSelect) { + return ( +
+ {q.options.map((opt) => { + const currentSel = (selections[q.question] as string[]) || []; + const isSelected = currentSel.includes(opt.label); + return ( + + ); + })} +
+ ); + } + + // Single-select: Radio buttons + Other + return ( +
+ handleSingleSelect(q.question, val)} + disabled={disabled} + className="gap-1" + > + {q.options.map((opt) => { + const isSelected = !isOther && selections[q.question] === opt.label; + return ( + + ); + })} + + + {/* Other / freeform option */} +