diff --git a/surfsense_web/atoms/chat/hitl-edit-panel.atom.ts b/surfsense_web/atoms/chat/hitl-edit-panel.atom.ts deleted file mode 100644 index 384854185..000000000 --- a/surfsense_web/atoms/chat/hitl-edit-panel.atom.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { atom } from "jotai"; -import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom"; - -export interface ExtraField { - label: string; - key: string; - value: string; - type: "text" | "email" | "emails" | "datetime-local" | "textarea"; -} - -interface HitlEditPanelState { - isOpen: boolean; - title: string; - content: string; - toolName: string; - contentFormat?: "markdown" | "html"; - extraFields?: ExtraField[]; - onSave: - | ((title: string, content: string, extraFieldValues?: Record) => void) - | null; - onClose: (() => void) | null; -} - -const initialState: HitlEditPanelState = { - isOpen: false, - title: "", - content: "", - toolName: "", - contentFormat: undefined, - extraFields: undefined, - onSave: null, - onClose: null, -}; - -export const hitlEditPanelAtom = atom(initialState); - -const preHitlCollapsedAtom = atom(null); - -export const openHitlEditPanelAtom = atom( - null, - ( - get, - set, - payload: { - title: string; - content: string; - toolName: string; - contentFormat?: "markdown" | "html"; - extraFields?: ExtraField[]; - onSave: (title: string, content: string, extraFieldValues?: Record) => void; - onClose?: () => void; - } - ) => { - if (!get(hitlEditPanelAtom).isOpen) { - set(preHitlCollapsedAtom, get(rightPanelCollapsedAtom)); - } - set(hitlEditPanelAtom, { - isOpen: true, - title: payload.title, - content: payload.content, - toolName: payload.toolName, - contentFormat: payload.contentFormat, - extraFields: payload.extraFields, - onSave: payload.onSave, - onClose: payload.onClose ?? null, - }); - set(rightPanelTabAtom, "hitl-edit"); - set(rightPanelCollapsedAtom, false); - } -); - -export const closeHitlEditPanelAtom = atom(null, (get, set) => { - const current = get(hitlEditPanelAtom); - current.onClose?.(); - set(hitlEditPanelAtom, initialState); - set(rightPanelTabAtom, "sources"); - const prev = get(preHitlCollapsedAtom); - if (prev !== null) { - set(rightPanelCollapsedAtom, prev); - set(preHitlCollapsedAtom, null); - } -}); diff --git a/surfsense_web/components/assistant-ui/thinking-steps.tsx b/surfsense_web/components/assistant-ui/thinking-steps.tsx deleted file mode 100644 index 46d33a9fa..000000000 --- a/surfsense_web/components/assistant-ui/thinking-steps.tsx +++ /dev/null @@ -1,433 +0,0 @@ -import { - makeAssistantDataUI, - type ToolCallMessagePartComponent, - useAuiState, -} from "@assistant-ui/react"; -import { ChevronRightIcon } from "lucide-react"; -import type { FC } from "react"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { TOOLS_BY_NAME, TOOLS_FALLBACK } from "@/components/assistant-ui/assistant-message"; -import { ChainOfThoughtItem } from "@/components/prompt-kit/chain-of-thought"; -import { TextShimmerLoader } from "@/components/prompt-kit/loader"; -import { HitlRenderTargetProvider, isInterruptResult } from "@/lib/hitl"; -import { cn } from "@/lib/utils"; - -export interface ThinkingStep { - id: string; - title: string; - items: string[]; - status: "pending" | "in_progress" | "completed"; - /** - * Optional relay metadata forwarded from ``data-thinking-step`` SSE - * (e.g. ``spanId`` set by ``AgentEventRelayState.span_metadata_if_active``). - * Steps under an open delegating ``task`` carry ``metadata.spanId`` and are - * grouped under the preceding parent (``task`` step) as indented children. - */ - metadata?: Record; -} - -/** - * Per-step info joined from the assistant message ``tool-call`` parts via - * the shared ``metadata.thinkingStepId`` correlation - * (set on the server in ``AgentEventRelayState.tool_activity_metadata``). - * - * Carries enough of the part to: - * - identify the opening ``task`` step and substitute the subagent display - * name on the parent header (uses ``toolName`` and ``args``); - * - render the matching tool component inline under the step row when the - * card's result is an HITL interrupt (uses ``toolCallId``, ``argsText``, - * ``result``, ``langchainToolCallId``). - */ -interface StepToolInfo { - toolCallId: string; - toolName: string; - args: Record; - argsText?: string; - result?: unknown; - langchainToolCallId?: string; -} - -export type ThinkingStepToolInfoMap = ReadonlyMap; - -/** - * Build ``thinkingStepId → StepToolInfo`` from message content. Used to - * - identify the opening ``task`` step (parent header, never indents) without - * relying on the human-readable title; - * - render the parent's display title from ``args.subagent_type`` instead of - * the generic "Task" copy; - * - mount the matching tool-call card inline under a step row when the - * result is an HITL interrupt (see ``TimelineHitlCard``). - */ -export function buildThinkingStepToolInfo( - content: readonly unknown[] | undefined -): ThinkingStepToolInfoMap { - const m = new Map(); - if (!content) return m; - for (const part of content) { - if (!part || typeof part !== "object") continue; - const o = part as { - type?: string; - toolCallId?: string; - toolName?: string; - args?: Record; - argsText?: string; - result?: unknown; - langchainToolCallId?: string; - metadata?: Record; - }; - if (o.type !== "tool-call" || !o.toolName || !o.toolCallId) continue; - const tid = o.metadata?.thinkingStepId; - if (typeof tid === "string" && tid.trim().length > 0) { - m.set(tid, { - toolCallId: o.toolCallId, - toolName: o.toolName, - args: o.args ?? {}, - argsText: o.argsText, - result: o.result, - langchainToolCallId: o.langchainToolCallId, - }); - } - } - return m; -} - -function asNonEmptyString(v: unknown): string | undefined { - return typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined; -} - -function titleCaseSubagent(raw: string): string { - // "notion" → "Notion", "doc_research" → "Doc Research". - return raw - .split(/[\s_-]+/) - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -/** - * Display title for a step. For the opening ``task`` step we substitute the - * subagent type from the matching tool-call args (e.g. ``"Notion"`` instead of - * the generic ``"Task"``). Falls back to the step's own title if the tool-call - * hasn't streamed in yet. - */ -function resolveDisplayTitle(step: ThinkingStep, info: StepToolInfo | undefined): string { - if (info?.toolName === "task") { - const subagent = asNonEmptyString(info.args?.subagent_type); - if (subagent) return titleCaseSubagent(subagent); - } - return step.title; -} - -function isDelegatedChild(step: ThinkingStep, info: StepToolInfo | undefined): boolean { - const sid = asNonEmptyString(step.metadata?.spanId); - if (!sid) return false; - // The opening ``task`` step also carries ``spanId`` (it owns the span) but - // must render as the parent header. Prefer the joined ``toolName`` (set by - // ``buildThinkingStepToolInfo`` from ``tool-call.metadata.thinkingStepId``). - // Fall back to the title heuristic when no tool-call is matched — happens - // for messages persisted before ``thinkingStepId`` shipped, and briefly - // during streaming if the ``tool-input-start`` frame hasn't been processed - // yet for some reason. - if (info) return info.toolName !== "task"; - return step.title !== "Task"; -} - -interface StepGroup { - parent: ThinkingStep; - children: ThinkingStep[]; -} - -/** - * Group consecutive delegated child steps under the preceding parent step. - * If the very first step is a child (no parent yet seen), it's promoted to a - * parent so it still renders — defensive only, real flows always start with a - * parent step. - */ -const EMPTY_STEP_TOOL_INFO: ThinkingStepToolInfoMap = new Map(); - -function groupSteps( - steps: readonly ThinkingStep[], - stepToolInfo: ThinkingStepToolInfoMap -): StepGroup[] { - const groups: StepGroup[] = []; - for (const step of steps) { - if (isDelegatedChild(step, stepToolInfo.get(step.id)) && groups.length > 0) { - groups[groups.length - 1].children.push(step); - } else { - groups.push({ parent: step, children: [] }); - } - } - return groups; -} - -const StepBody: FC<{ - step: ThinkingStep; - status: "pending" | "in_progress" | "completed"; - displayTitle: string; -}> = ({ step, status, displayTitle }) => ( -
-
- {displayTitle} -
- - {step.items && step.items.length > 0 && ( -
- {step.items.map((item) => ( - - {item} - - ))} -
- )} -
-); - -/** - * Mount the same tool-call UI used in the message body, but inside the - * chain-of-thought timeline. The body copy returns ``null`` (see - * ``withHitlInTimeline`` in ``lib/hitl/render-target``), so the card - * effectively moves from the body to the timeline for the lifetime of the - * interrupt (pending → processing → complete / rejected). - * - * ``metadata`` is intentionally omitted from the props we forward — the - * step row already provides any indentation it needs, so we don't want - * ``withDelegationSpanIndent`` to add a second indent + border on top. - * - * ``status`` is a placeholder (HITL UIs read only ``args`` + ``result``) - * so we don't need to mirror assistant-ui's runtime status object here. - */ -const TimelineHitlCard: FC<{ info: StepToolInfo }> = ({ info }) => { - const Comp = - (TOOLS_BY_NAME as Record)[info.toolName] ?? - TOOLS_FALLBACK; - const props = { - toolCallId: info.toolCallId, - toolName: info.toolName, - args: info.args, - argsText: info.argsText, - result: info.result, - langchainToolCallId: info.langchainToolCallId, - status: { type: "complete" } as const, - }; - return ( - - {/* biome-ignore lint/suspicious/noExplicitAny: ToolCallMessagePartProps requires - runtime-only fields (addResult, resume, MessagePartState) we don't have when - re-rendering manually; HITL components only read args + result. */} - - - ); -}; - -function hitlInterruptInfo(info: StepToolInfo | undefined): StepToolInfo | undefined { - return info && isInterruptResult(info.result) ? info : undefined; -} - -/** - * Chain of thought display component - single collapsible dropdown design. - * - * ``stepToolInfo`` joins each step (by ``thinkingStepId``) to its ``tool-call`` - * part so we can: - * - replace the generic ``"Task"`` title with the real subagent name - * (``args.subagent_type``) on the parent header; - * - decide parent-vs-child purely from the matched ``toolName`` instead of - * relying on the displayed title. - */ -export const ThinkingStepsDisplay: FC<{ - steps: ThinkingStep[]; - isThreadRunning?: boolean; - stepToolInfo?: ThinkingStepToolInfoMap; -}> = ({ steps, isThreadRunning = true, stepToolInfo }) => { - const toolInfo = stepToolInfo ?? EMPTY_STEP_TOOL_INFO; - const getEffectiveStatus = useCallback( - (step: ThinkingStep): "pending" | "in_progress" | "completed" => { - if (step.status === "in_progress" && !isThreadRunning) { - return "completed"; - } - return step.status; - }, - [isThreadRunning] - ); - - const inProgressStep = steps.find((s) => getEffectiveStatus(s) === "in_progress"); - const inProgressDisplayTitle = inProgressStep - ? resolveDisplayTitle(inProgressStep, toolInfo.get(inProgressStep.id)) - : undefined; - const allCompleted = - steps.length > 0 && - !isThreadRunning && - steps.every((s) => getEffectiveStatus(s) === "completed"); - const isProcessing = isThreadRunning && !allCompleted; - const [isOpen, setIsOpen] = useState(() => isProcessing); - - useEffect(() => { - if (isProcessing) { - setIsOpen(true); - return; - } - - if (allCompleted) { - setIsOpen(false); - } - }, [allCompleted, isProcessing]); - - const groups = useMemo(() => groupSteps(steps, toolInfo), [steps, toolInfo]); - - if (steps.length === 0) return null; - - const getHeaderText = () => { - if (allCompleted) { - return "Reviewed"; - } - if (inProgressDisplayTitle) { - return inProgressDisplayTitle; - } - if (isProcessing) { - return "Processing"; - } - return "Reviewed"; - }; - - return ( -
-
- - -
-
-
- {groups.map((group, groupIndex) => { - const isLastGroup = groupIndex === groups.length - 1; - const parentStatus = getEffectiveStatus(group.parent); - const parentInfo = toolInfo.get(group.parent.id); - const parentTitle = resolveDisplayTitle(group.parent, parentInfo); - const hasChildren = group.children.length > 0; - // Parent dots are connected by a vertical line that runs through - // any indented children (their column has no dot, so the line - // passes cleanly behind them) and overshoots by ~15px to reach - // the next group's dot center (top-[15px]). - const showParentLine = !isLastGroup; - - return ( -
-
- {showParentLine && ( -
- )} -
- {parentStatus === "in_progress" ? ( - - - - - ) : ( - - )} -
-
- -
- - - {(() => { - const hitl = hitlInterruptInfo(parentInfo); - return hitl ? : null; - })()} - - {hasChildren && ( -
- {group.children.map((child) => { - const childInfo = toolInfo.get(child.id); - const childHitl = hitlInterruptInfo(childInfo); - return ( -
- - {childHitl && } -
- ); - })} -
- )} -
-
- ); - })} -
-
-
-
-
- ); -}; - -/** - * assistant-ui data UI component that renders thinking steps from message content. - * Registered globally via makeAssistantDataUI — renders inside MessagePrimitive.Parts - * at the position of the data part in the content array. - */ -function ThinkingStepsDataRenderer({ data }: { name: string; data: unknown }) { - const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); - const isLastMessage = useAuiState(({ message }) => message?.isLast ?? false); - const isMessageStreaming = isThreadRunning && isLastMessage; - const content = useAuiState(({ message }) => message?.content); - - const stepToolInfo = useMemo( - () => buildThinkingStepToolInfo(Array.isArray(content) ? content : undefined), - [content] - ); - - const steps = (data as { steps: ThinkingStep[] } | null)?.steps ?? []; - if (steps.length === 0) return null; - - return ( -
- -
- ); -} - -export const ThinkingStepsDataUI = makeAssistantDataUI({ - name: "thinking-steps", - render: ThinkingStepsDataRenderer, -}); diff --git a/surfsense_web/components/assistant-ui/tool-fallback.tsx b/surfsense_web/components/assistant-ui/tool-fallback.tsx deleted file mode 100644 index ec93b1018..000000000 --- a/surfsense_web/components/assistant-ui/tool-fallback.tsx +++ /dev/null @@ -1,532 +0,0 @@ -import { type ToolCallMessagePartComponent, useAuiState } from "@assistant-ui/react"; -import { useQueryClient } from "@tanstack/react-query"; -import { useAtomValue } from "jotai"; -import { CheckIcon, ChevronDownIcon, RotateCcw, XCircleIcon } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom"; -import { NestedScroll } from "@/components/assistant-ui/nested-scroll"; -import { - DoomLoopApprovalToolUI, - isDoomLoopInterrupt, -} from "@/components/tool-ui/doom-loop-approval"; -import { GenericHitlApprovalToolUI } from "@/components/tool-ui/generic-hitl-approval"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { Separator } from "@/components/ui/separator"; -import { Spinner } from "@/components/ui/spinner"; -import { getToolDisplayName } from "@/contracts/enums/toolIcons"; -import { markActionRevertedInCache, useAgentActionsQuery } from "@/hooks/use-agent-actions-query"; -import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service"; -import { - DELEGATION_SPAN_INDENT_CLASS, - shouldIndentToolCallForDelegationSpan, -} from "@/lib/chat/delegation-span-indent"; -import { AppError } from "@/lib/error"; -import { isInterruptResult } from "@/lib/hitl"; -import { cn } from "@/lib/utils"; - -/** - * Inline Revert button rendered on a tool card when the matching - * ``AgentActionLog`` row is reversible and hasn't been reverted yet. - * - * Reads from the unified ``useAgentActionsQuery`` cache — the SAME - * react-query cache the agent-actions sheet consumes. SSE events - * (``data-action-log`` / ``data-action-log-updated``) and - * ``POST /threads/{id}/revert/{id}`` responses both flow through the - * cache via ``setQueryData`` helpers, so the card and the sheet stay - * in lockstep on every code path: page reload, navigation, live - * stream, post-stream reversibility flip, and explicit revert clicks. - * - * Match key (in priority order): - * 1. ``a.tool_call_id === toolCallId`` — direct hit when the model - * streamed ``tool_call_chunks`` so the card id matches the LangChain id. - * 2. ``a.tool_call_id === langchainToolCallId`` — synthetic card id is - * ``call_`` and the LangChain id is backfilled by - * ``tool-output-available``. - * 3. ``(chat_turn_id, tool_name, position-within-turn)`` — fallback - * for cards whose synthetic id is ``call_`` AND whose - * ``langchainToolCallId`` never got backfilled (provider emitted - * the tool_call as a single payload with no chunks AND streaming - * pre-dated the ``tool-output-available langchainToolCallId`` - * backfill, e.g. older threads). Reads the parent message's - * ``chatTurnId`` and ``content`` via ``useAuiState`` so we can - * match position-by-tool-name within the turn against the - * action_log rows the server returned in ``created_at`` order. - */ -function ToolCardRevertButton({ - toolCallId, - toolName, - langchainToolCallId, -}: { - toolCallId: string; - toolName: string; - langchainToolCallId?: string; -}) { - const session = useAtomValue(chatSessionStateAtom); - const threadId = session?.threadId ?? null; - const queryClient = useQueryClient(); - const { findByToolCallId, findByChatTurnAndTool } = useAgentActionsQuery(threadId); - - // Parent message metadata, read via the narrowest possible - // selectors so this card doesn't re-render on every text-delta of - // every other part in the same message during streaming. - // - // IMPORTANT — ``useAuiState`` re-renders the component whenever the - // returned slice's identity changes. Returning ``message?.content`` - // (an array) would re-render on every token because the runtime - // rebuilds the parts array. Returning a PRIMITIVE (the position - // number) lets ``useAuiState``'s ``Object.is`` check short-circuit - // when the position hasn't actually moved — which is the common - // case during text streaming, when only ``text``/``reasoning`` - // parts are mutating and the same-toolName tool-call ordering is - // stable. (See Vercel React rule ``rerender-defer-reads``.) - const chatTurnId = useAuiState(({ message }) => { - const meta = message?.metadata as { custom?: { chatTurnId?: string } } | undefined; - return meta?.custom?.chatTurnId ?? null; - }); - const positionInTurn = useAuiState(({ message }) => { - const content = message?.content; - if (!Array.isArray(content)) return -1; - let n = -1; - for (const part of content) { - if ( - part && - typeof part === "object" && - (part as { type?: string }).type === "tool-call" && - (part as { toolName?: string }).toolName === toolName - ) { - n += 1; - if ((part as { toolCallId?: string }).toolCallId === toolCallId) return n; - } - } - return -1; - }); - - const action = useMemo(() => { - // Tier 1 + 2: O(1) Map-backed direct id match. Covers - // Indexed chunk streams and any stream that backfilled - // ``langchainToolCallId`` via ``tool-output-available``. - const direct = findByToolCallId(toolCallId) ?? findByToolCallId(langchainToolCallId); - if (direct) return direct; - // Tier 3: position-within-turn fallback. Only kicks in when the - // card has a synthetic ``call_`` id AND no - // ``langchainToolCallId`` was ever backfilled — i.e. the tool - // was emitted as a single non-chunked payload AND streaming - // pre-dated the on_tool_end backfill. - if (!chatTurnId || positionInTurn < 0) return null; - const turnSameTool = findByChatTurnAndTool(chatTurnId, toolName); - return turnSameTool[positionInTurn] ?? null; - }, [ - findByToolCallId, - findByChatTurnAndTool, - toolCallId, - langchainToolCallId, - chatTurnId, - toolName, - positionInTurn, - ]); - - const [isReverting, setIsReverting] = useState(false); - const [confirmOpen, setConfirmOpen] = useState(false); - - if (!action) return null; - if (!action.reversible) return null; - if (action.reverted_by_action_id !== null && action.reverted_by_action_id !== undefined) - return null; - if (action.is_revert_action) return null; - if (action.error !== null && action.error !== undefined) return null; - if (!threadId) return null; - - const handleRevert = async () => { - setIsReverting(true); - try { - const response = await agentActionsApiService.revert(threadId, action.id); - markActionRevertedInCache(queryClient, threadId, action.id, response.new_action_id ?? null); - toast.success(response.message || "Action reverted."); - } catch (err) { - // 503 means revert is gated off on this deployment — hide the - // button silently rather than nagging the user. Any other error - // is surfaced as a toast so the operator can investigate. - if (err instanceof AppError && err.status === 503) { - return; - } - const message = - err instanceof AppError - ? err.message - : err instanceof Error - ? err.message - : "Failed to revert action."; - toast.error(message); - } finally { - setIsReverting(false); - setConfirmOpen(false); - } - }; - - return ( - - - - - - - Revert this action? - - This will undo{" "} - {getToolDisplayName(action.tool_name)} and add a - new entry to the history. Your chat is preserved — only the changes the agent made to - your knowledge base or connected apps will be rolled back where possible. - - - - Cancel - { - e.preventDefault(); - handleRevert(); - }} - disabled={isReverting} - className="gap-1.5" - > - {isReverting && } - Revert - - - - - ); -} - -/** - * Compact tool-call card. - * - * shadcn composition note: we intentionally use ``Card`` as a visual - * frame WITHOUT ``CardHeader / CardContent``. The full composition's - * ``p-6`` padding doesn't fit a compact collapsible header that IS the - * trigger; using ``Card`` alone preserves the rounded border, shadow, - * and ``bg-card`` token (semantic colors) without forcing a layout - * that doesn't fit. All status colors use semantic tokens — no manual - * dark-mode overrides, no raw hex. - */ -const DefaultToolFallbackInner: ToolCallMessagePartComponent = (props) => { - const { toolCallId, toolName, argsText, result, status } = props; - // ``langchainToolCallId`` is a SurfSense-specific extension the - // streaming pipeline attaches to the tool-call content part so - // the Revert button can resolve its ``AgentActionLog`` row even - // when only the LC id is known. assistant-ui's - // ``ToolCallMessagePartProps`` doesn't list it, but the runtime - // spreads ``{...part}`` so the prop reaches us at runtime. - const langchainToolCallId = (props as { langchainToolCallId?: string }).langchainToolCallId; - - const isCancelled = status?.type === "incomplete" && status.reason === "cancelled"; - const isError = status?.type === "incomplete" && status.reason === "error"; - const isRunning = status?.type === "running" || status?.type === "requires-action"; - - /* - Per-card expansion state. Initial value is ``isRunning`` so a - card streaming in mounts already-expanded (no flash of - collapsed → expanded on first paint), while a card loaded from - history (status="complete") mounts collapsed. The useEffect - below keeps this in lockstep with this card's own ``isRunning`` - when it transitions: false → true auto-expands (e.g. a tool - that re-runs after edit), true → false auto-collapses once the - tool finishes. Because the dep is per-card ``isRunning`` and - not the chat-level streaming flag, sibling cards on the same - assistant turn each manage their own expansion independently. - Once ``isRunning`` is false the user controls expansion via - ``onOpenChange``. - */ - const [isExpanded, setIsExpanded] = useState(isRunning); - useEffect(() => { - setIsExpanded(isRunning); - }, [isRunning]); - const errorData = status?.type === "incomplete" ? status.error : undefined; - const serializedError = useMemo( - () => (errorData && typeof errorData !== "string" ? JSON.stringify(errorData) : null), - [errorData] - ); - - const serializedResult = useMemo( - () => - result !== undefined && typeof result !== "string" ? JSON.stringify(result, null, 2) : null, - [result] - ); - - const cancelledReason = - isCancelled && status.error - ? typeof status.error === "string" - ? status.error - : serializedError - : null; - const errorReason = - isError && status.error - ? typeof status.error === "string" - ? status.error - : serializedError - : null; - - const displayName = getToolDisplayName(toolName); - const subtitle = errorReason ?? cancelledReason; - - return ( - - {/* - ``group`` lets the chevron (rendered as a sibling of the - main trigger button) read the Collapsible Root's - ``data-[state=open]`` for rotation. The Collapsible is - fully controlled via ``isExpanded`` — the useEffect - above syncs it to ``isRunning`` so the card auto-opens - while a tool streams in and auto-collapses once it - finishes. We deliberately DON'T pass ``disabled`` so - both triggers stay clickable; ``onOpenChange`` is wired - to a setter that no-ops while ``isRunning`` (see - ``handleOpenChange`` below) which keeps the card pinned - open mid-stream without losing keyboard / pointer - affordance the moment streaming ends. - */} - { - // Block manual collapse while the tool is still - // streaming — otherwise a stray click on either - // trigger would close the card and hide the live - // ``argsText`` panel mid-run. After streaming the - // user has full control again. - if (isRunning) return; - setIsExpanded(next); - }} - > - {/* - Header row: main trigger on the left (icon + title - col), Revert + chevron-trigger on the right as - siblings of the main trigger. The chevron is wrapped - in its OWN ``CollapsibleTrigger`` (Radix supports - multiple triggers per Root) so clicking the chevron - toggles the same state as clicking the title row. - The Revert button stays a separate AlertDialog - trigger and stops propagation in its onClick so it - doesn't toggle the collapsible while opening the - confirm dialog. Keeping these as flat siblings — - rather than nesting Revert / chevron inside the - title trigger — avoids invalid HTML - (button-in-button) and lets the Revert button - render in BOTH the collapsed and expanded states. - */} -
- - - - - {/* - Right-side controls. The Revert button is - visible whenever the matching action is - reversible — including the collapsed state — - but ``ToolCardRevertButton`` itself returns - ``null`` while a tool is still running because - no action-log row exists yet, so it doesn't - need an explicit ``isRunning`` gate here. - */} -
- - - - -
-
- - {/* - CollapsibleContent body — auto-open while streaming - (see ``open`` prop above) so the live ``argsText`` - streams into the Inputs panel directly, no need for - a separate "Live input" panel. Native - ``overflow-auto`` instead of ``ScrollArea`` because - Radix's Viewport can let content bleed past - ``max-h-*`` in dynamic flex layouts. ``min-w-0`` on - the column wrappers guarantees ``break-all`` wraps - correctly within the bounded ``max-w-lg`` Card. - */} - - -
- {(argsText || isRunning) && ( -
-

Inputs

- - {argsText ? ( -
-											{argsText}
-										
- ) : ( - // Bridges the brief gap between - // ``tool-input-start`` (creates the - // card, ``argsText`` undefined) and - // the first ``tool-input-delta``. -

- Waiting for input… -

- )} -
-
- )} - {!isCancelled && result !== undefined && ( - <> - -
-

Result

- -
-											{typeof result === "string" ? result : serializedResult}
-										
-
-
- - )} -
-
-
-
- ); -}; - -/** - * Wrap any tool-call UI so cards under an active delegating ``task`` span indent. - * Applied to named tool components as well as ``ToolFallback`` — only ``ToolFallback`` - * would miss delegated tools otherwise. - */ -export function withDelegationSpanIndent( - Component: ToolCallMessagePartComponent -): ToolCallMessagePartComponent { - const Wrapped: ToolCallMessagePartComponent = (props) => { - const metadata = (props as { metadata?: Record }).metadata; - const indent = shouldIndentToolCallForDelegationSpan(props.toolName, metadata); - const inner = ; - return indent ?
{inner}
: inner; - }; - Wrapped.displayName = `withDelegationSpanIndent(${Component.displayName ?? Component.name ?? "ToolUI"})`; - return Wrapped; -} - -export const ToolFallback: ToolCallMessagePartComponent = (props) => { - if (isInterruptResult(props.result)) { - if (isDoomLoopInterrupt(props.result)) { - return ; - } - return ; - } - return ; -}; diff --git a/surfsense_web/components/hitl-bundle-pager/index.ts b/surfsense_web/components/hitl-bundle-pager/index.ts deleted file mode 100644 index ce434d224..000000000 --- a/surfsense_web/components/hitl-bundle-pager/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { PagerChrome } from "./pager-chrome"; -export { withBundleStep } from "./with-bundle-step"; diff --git a/surfsense_web/components/hitl-bundle-pager/pager-chrome.tsx b/surfsense_web/components/hitl-bundle-pager/pager-chrome.tsx deleted file mode 100644 index 77d75fb6d..000000000 --- a/surfsense_web/components/hitl-bundle-pager/pager-chrome.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; - -import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { useHitlBundle } from "@/lib/hitl"; - -/** - * Prev/next nav and Submit for the current step of an active HITL bundle. - * Submission is gated on every action_request having a staged decision. - */ -export function PagerChrome() { - const bundle = useHitlBundle(); - if (!bundle) return null; - - const total = bundle.toolCallIds.length; - const step = bundle.currentStep; - const allStaged = bundle.stagedCount === total; - - return ( -
- - - {step + 1} / {total} - - · - - {bundle.stagedCount} of {total} decided - - -
- -
-
- ); -} diff --git a/surfsense_web/components/hitl-bundle-pager/with-bundle-step.tsx b/surfsense_web/components/hitl-bundle-pager/with-bundle-step.tsx deleted file mode 100644 index 64ac801fb..000000000 --- a/surfsense_web/components/hitl-bundle-pager/with-bundle-step.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import type { ToolCallMessagePartProps } from "@assistant-ui/react"; -import type { ComponentType } from "react"; -import { ToolCallIdProvider, useHitlBundle } from "@/lib/hitl"; -import { PagerChrome } from "./pager-chrome"; - -/** - * Wrap a tool-ui card so that, when a multi-card HITL bundle is active: - * - cards belonging to the bundle but not the current step render ``null``; - * - the current-step card renders normally and is followed by ``PagerChrome``. - * - * Cards stay completely unchanged — the wrapper provides the - * ``ToolCallIdContext`` that ``useHitlDecision`` reads to stage decisions - * against the right ``toolCallId`` instead of firing the global event. - */ -export function withBundleStep

>( - Component: ComponentType

-): ComponentType

{ - function BundleStepWrapped(props: P) { - const bundle = useHitlBundle(); - const toolCallId = props.toolCallId; - const inBundle = bundle?.isInBundle(toolCallId) ?? false; - const isStep = bundle?.isCurrentStep(toolCallId) ?? false; - - if (bundle && inBundle && !isStep) return null; - - return ( - - - {bundle && isStep ? : null} - - ); - } - BundleStepWrapped.displayName = `withBundleStep(${Component.displayName ?? Component.name ?? "ToolUI"})`; - return BundleStepWrapped as ComponentType

; -} diff --git a/surfsense_web/components/hitl-edit-panel/hitl-edit-panel.tsx b/surfsense_web/components/hitl-edit-panel/hitl-edit-panel.tsx deleted file mode 100644 index b33392f38..000000000 --- a/surfsense_web/components/hitl-edit-panel/hitl-edit-panel.tsx +++ /dev/null @@ -1,405 +0,0 @@ -"use client"; - -import { format } from "date-fns"; -import { TagInput, type Tag as TagType } from "emblor"; -import { useAtomValue, useSetAtom } from "jotai"; -import { CalendarIcon, XIcon } from "lucide-react"; -import dynamic from "next/dynamic"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom"; -import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom"; -import { Button } from "@/components/ui/button"; -import { Calendar } from "@/components/ui/calendar"; -import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Textarea } from "@/components/ui/textarea"; -import { useMediaQuery } from "@/hooks/use-media-query"; - -const PlateEditor = dynamic( - () => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })), - { ssr: false, loading: () => } -); - -function parseEmailsToTags(value: string): TagType[] { - if (!value.trim()) return []; - return value - .split(",") - .map((s) => s.trim()) - .filter(Boolean) - .map((email, i) => ({ id: `${Date.now()}-${i}`, text: email })); -} - -function tagsToEmailString(tags: TagType[]): string { - return tags.map((t) => t.text).join(", "); -} - -function EmailsTagField({ - id, - value, - onChange, - placeholder, -}: { - id: string; - value: string; - onChange: (value: string) => void; - placeholder?: string; -}) { - const [tags, setTags] = useState(() => parseEmailsToTags(value)); - const [activeTagIndex, setActiveTagIndex] = useState(null); - const isInitialMount = useRef(true); - const onChangeRef = useRef(onChange); - onChangeRef.current = onChange; - - useEffect(() => { - if (isInitialMount.current) { - isInitialMount.current = false; - return; - } - onChangeRef.current(tagsToEmailString(tags)); - }, [tags]); - - const handleSetTags = useCallback((newTags: TagType[] | ((prev: TagType[]) => TagType[])) => { - setTags((prev) => (typeof newTags === "function" ? newTags(prev) : newTags)); - }, []); - - const handleAddTag = useCallback((text: string) => { - const trimmed = text.trim(); - if (!trimmed) return; - setTags((prev) => { - if (prev.some((tag) => tag.text === trimmed)) return prev; - const newTag: TagType = { id: Date.now().toString(), text: trimmed }; - return [...prev, newTag]; - }); - }, []); - - return ( - - ); -} - -function parseDateTimeValue(value: string): { date: Date | undefined; time: string } { - if (!value) return { date: undefined, time: "09:00" }; - try { - const d = new Date(value); - if (Number.isNaN(d.getTime())) return { date: undefined, time: "09:00" }; - return { - date: d, - time: format(d, "HH:mm"), - }; - } catch { - return { date: undefined, time: "09:00" }; - } -} - -function buildLocalDateTimeString(date: Date | undefined, time: string): string { - if (!date) return ""; - const [hours, minutes] = time.split(":").map(Number); - const combined = new Date(date); - combined.setHours(hours ?? 9, minutes ?? 0, 0, 0); - const y = combined.getFullYear(); - const m = String(combined.getMonth() + 1).padStart(2, "0"); - const d = String(combined.getDate()).padStart(2, "0"); - const h = String(combined.getHours()).padStart(2, "0"); - const min = String(combined.getMinutes()).padStart(2, "0"); - return `${y}-${m}-${d}T${h}:${min}:00`; -} - -function DateTimePickerField({ - id, - value, - onChange, -}: { - id: string; - value: string; - onChange: (value: string) => void; -}) { - const parsed = useMemo(() => parseDateTimeValue(value), [value]); - const [selectedDate, setSelectedDate] = useState(parsed.date); - const [time, setTime] = useState(parsed.time); - const [open, setOpen] = useState(false); - - const handleDateSelect = useCallback( - (day: Date | undefined) => { - setSelectedDate(day); - onChange(buildLocalDateTimeString(day, time)); - setOpen(false); - }, - [time, onChange] - ); - - const handleTimeChange = useCallback( - (e: React.ChangeEvent) => { - const newTime = e.target.value; - setTime(newTime); - onChange(buildLocalDateTimeString(selectedDate, newTime)); - }, - [selectedDate, onChange] - ); - - const displayLabel = selectedDate - ? `${format(selectedDate, "MMM d, yyyy")} at ${time}` - : "Pick date & time"; - - return ( -

- - - - - - - - - -
- ); -} - -export function HitlEditPanelContent({ - title: initialTitle, - content: initialContent, - contentFormat, - extraFields, - onSave, - onClose, - showCloseButton = true, -}: { - title: string; - content: string; - toolName: string; - contentFormat?: "markdown" | "html"; - extraFields?: ExtraField[]; - onSave: (title: string, content: string, extraFieldValues?: Record) => void; - onClose?: () => void; - showCloseButton?: boolean; -}) { - const [editedTitle, setEditedTitle] = useState(initialTitle); - const contentRef = useRef(initialContent); - const [isSaving, setIsSaving] = useState(false); - const [extraFieldValues, setExtraFieldValues] = useState>(() => { - if (!extraFields) return {}; - const initial: Record = {}; - for (const field of extraFields) { - initial[field.key] = field.value; - } - return initial; - }); - - const handleContentChange = useCallback((content: string) => { - contentRef.current = content; - }, []); - - const handleExtraFieldChange = useCallback((key: string, value: string) => { - setExtraFieldValues((prev) => ({ ...prev, [key]: value })); - }, []); - - const handleSave = useCallback(() => { - if (!editedTitle.trim()) return; - setIsSaving(true); - const extras = extraFields && extraFields.length > 0 ? extraFieldValues : undefined; - onSave(editedTitle, contentRef.current, extras); - onClose?.(); - }, [editedTitle, onSave, onClose, extraFields, extraFieldValues]); - - return ( - <> -
- setEditedTitle(e.target.value)} - placeholder="Untitled" - className="flex-1 min-w-0 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground" - aria-label="Page title" - /> - {onClose && showCloseButton && ( - - )} -
- - {extraFields && extraFields.length > 0 && ( -
- {extraFields.map((field) => ( -
- - {field.type === "emails" ? ( - handleExtraFieldChange(field.key, v)} - placeholder={`Add ${field.label.toLowerCase()}`} - /> - ) : field.type === "datetime-local" ? ( - handleExtraFieldChange(field.key, v)} - /> - ) : field.type === "textarea" ? ( -