From 2e132513be830199b2594b31ee47c46c6df81f9e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Sat, 9 May 2026 21:44:54 +0200 Subject: [PATCH] chat: unify HITL approval UX behind a single paginated card and harden timeline supersede. --- .../connectors/jira/tools/__init__.py | 12 +- .../subagents/connectors/jira/tools/index.py | 24 +- .../connectors/linear/tools/__init__.py | 12 +- .../connectors/linear/tools/create_issue.py | 248 ---------------- .../connectors/linear/tools/delete_issue.py | 245 ---------------- .../connectors/linear/tools/index.py | 24 +- .../new-chat/[[...chat_id]]/page.tsx | 63 +---- .../approval-cards/doom-loop-approval.tsx | 4 +- .../hitl/approval-cards/generic-approval.tsx | 4 +- .../hitl/approval/approval-context.tsx | 28 ++ .../hitl/approval/hitl-approval-card.tsx | 267 ++++++++++++++++++ .../chat-messages/hitl/approval/index.ts | 9 + .../approval/pending-interrupt-context.tsx | 44 +++ .../hitl/bundle/bundle-context.tsx | 157 ---------- .../chat-messages/hitl/bundle/index.ts | 8 - .../hitl/bundle/pager-chrome.tsx | 65 ----- .../features/chat-messages/hitl/index.ts | 22 +- .../features/chat-messages/hitl/types.ts | 12 +- .../chat-messages/hitl/use-hitl-decision.ts | 35 +-- .../chat-messages/timeline/build-timeline.ts | 242 ++++++++-------- .../chat-messages/timeline/data-renderer.tsx | 36 +-- .../chat-messages/timeline/grouping.ts | 36 +-- .../timeline/items/tool-call-item.tsx | 32 +-- .../chat-messages/timeline/timeline.tsx | 130 +++------ .../features/chat-messages/timeline/types.ts | 2 +- 25 files changed, 604 insertions(+), 1157 deletions(-) delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/create_issue.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/delete_issue.py create mode 100644 surfsense_web/features/chat-messages/hitl/approval/approval-context.tsx create mode 100644 surfsense_web/features/chat-messages/hitl/approval/hitl-approval-card.tsx create mode 100644 surfsense_web/features/chat-messages/hitl/approval/index.ts create mode 100644 surfsense_web/features/chat-messages/hitl/approval/pending-interrupt-context.tsx delete mode 100644 surfsense_web/features/chat-messages/hitl/bundle/bundle-context.tsx delete mode 100644 surfsense_web/features/chat-messages/hitl/bundle/index.ts delete mode 100644 surfsense_web/features/chat-messages/hitl/bundle/pager-chrome.tsx diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/__init__.py index 768738118..dc721013a 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/__init__.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/__init__.py @@ -1,11 +1,3 @@ -"""Jira tools for creating, updating, and deleting issues.""" +"""Jira route: native tool factories are empty; MCP supplies tools when configured.""" -from .create_issue import create_create_jira_issue_tool -from .delete_issue import create_delete_jira_issue_tool -from .update_issue import create_update_jira_issue_tool - -__all__ = [ - "create_create_jira_issue_tool", - "create_delete_jira_issue_tool", - "create_update_jira_issue_tool", -] +__all__: list[str] = [] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/index.py index 342f120be..08b0e005e 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/index.py @@ -6,29 +6,9 @@ from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) -from .create_issue import create_create_jira_issue_tool -from .delete_issue import create_delete_jira_issue_tool -from .update_issue import create_update_jira_issue_tool - def load_tools( *, dependencies: dict[str, Any] | None = None, **kwargs: Any ) -> ToolsPermissions: - d = {**(dependencies or {}), **kwargs} - common = { - "db_session": d["db_session"], - "search_space_id": d["search_space_id"], - "user_id": d["user_id"], - "connector_id": d.get("connector_id"), - } - create = create_create_jira_issue_tool(**common) - update = create_update_jira_issue_tool(**common) - delete = create_delete_jira_issue_tool(**common) - return { - "allow": [], - "ask": [ - {"name": getattr(create, "name", "") or "", "tool": create}, - {"name": getattr(update, "name", "") or "", "tool": update}, - {"name": getattr(delete, "name", "") or "", "tool": delete}, - ], - } + _ = {**(dependencies or {}), **kwargs} + return {"allow": [], "ask": []} diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/__init__.py index 31acf1e2a..5b464a9df 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/__init__.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/__init__.py @@ -1,11 +1,3 @@ -"""Linear tools for creating, updating, and deleting issues.""" +"""Linear route: native tool factories are empty; MCP supplies tools when configured.""" -from .create_issue import create_create_linear_issue_tool -from .delete_issue import create_delete_linear_issue_tool -from .update_issue import create_update_linear_issue_tool - -__all__ = [ - "create_create_linear_issue_tool", - "create_delete_linear_issue_tool", - "create_update_linear_issue_tool", -] +__all__: list[str] = [] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/create_issue.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/create_issue.py deleted file mode 100644 index ff254e133..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/create_issue.py +++ /dev/null @@ -1,248 +0,0 @@ -import logging -from typing import Any - -from langchain_core.tools import tool -from sqlalchemy.ext.asyncio import AsyncSession - -from app.agents.new_chat.tools.hitl import request_approval -from app.connectors.linear_connector import LinearAPIError, LinearConnector -from app.services.linear import LinearToolMetadataService - -logger = logging.getLogger(__name__) - - -def create_create_linear_issue_tool( - db_session: AsyncSession | None = None, - search_space_id: int | None = None, - user_id: str | None = None, - connector_id: int | None = None, -): - """ - Factory function to create the create_linear_issue tool. - - Args: - db_session: Database session for accessing the Linear connector - search_space_id: Search space ID to find the Linear connector - user_id: User ID for fetching user-specific context - connector_id: Optional specific connector ID (if known) - - Returns: - Configured create_linear_issue tool - """ - - @tool - async def create_linear_issue( - title: str, - description: str | None = None, - ) -> dict[str, Any]: - """Create a new issue in Linear. - - Use this tool when the user explicitly asks to create, add, or file - a new issue / ticket / task in Linear. The user MUST describe the issue - before you call this tool. If the request is vague, ask what the issue - should be about. Never call this tool without a clear topic from the user. - - Args: - title: Short, descriptive issue title. Infer from the user's request. - description: Optional markdown body for the issue. Generate from context. - - Returns: - Dictionary with: - - status: "success", "rejected", or "error" - - issue_id: Linear issue UUID (if success) - - identifier: Human-readable ID like "ENG-42" (if success) - - url: URL to the created issue (if success) - - message: Result message - - IMPORTANT: If status is "rejected", the user explicitly declined the action. - Respond with a brief acknowledgment (e.g., "Understood, I won't create the issue.") - and move on. Do NOT retry, troubleshoot, or suggest alternatives. - - Examples: - - "Create a Linear issue for the login bug" - - "File a ticket about the payment timeout problem" - - "Add an issue for the broken search feature" - """ - logger.info(f"create_linear_issue called: title='{title}'") - - if db_session is None or search_space_id is None or user_id is None: - logger.error( - "Linear tool not properly configured - missing required parameters" - ) - return { - "status": "error", - "message": "Linear tool not properly configured. Please contact support.", - } - - try: - metadata_service = LinearToolMetadataService(db_session) - context = await metadata_service.get_creation_context( - search_space_id, user_id - ) - - if "error" in context: - logger.error(f"Failed to fetch creation context: {context['error']}") - return {"status": "error", "message": context["error"]} - - workspaces = context.get("workspaces", []) - if workspaces and all(w.get("auth_expired") for w in workspaces): - logger.warning("All Linear accounts have expired authentication") - return { - "status": "auth_error", - "message": "All connected Linear accounts need re-authentication. Please re-authenticate in your connector settings.", - "connector_type": "linear", - } - - logger.info(f"Requesting approval for creating Linear issue: '{title}'") - result = request_approval( - action_type="linear_issue_creation", - tool_name="create_linear_issue", - params={ - "title": title, - "description": description, - "team_id": None, - "state_id": None, - "assignee_id": None, - "priority": None, - "label_ids": [], - "connector_id": connector_id, - }, - context=context, - ) - - if result.rejected: - logger.info("Linear issue creation rejected by user") - return { - "status": "rejected", - "message": "User declined. Do not retry or suggest alternatives.", - } - - final_title = result.params.get("title", title) - final_description = result.params.get("description", description) - final_team_id = result.params.get("team_id") - final_state_id = result.params.get("state_id") - final_assignee_id = result.params.get("assignee_id") - final_priority = result.params.get("priority") - final_label_ids = result.params.get("label_ids") or [] - final_connector_id = result.params.get("connector_id", connector_id) - - if not final_title or not final_title.strip(): - logger.error("Title is empty or contains only whitespace") - return {"status": "error", "message": "Issue title cannot be empty."} - if not final_team_id: - return { - "status": "error", - "message": "A team must be selected to create an issue.", - } - - from sqlalchemy.future import select - - from app.db import SearchSourceConnector, SearchSourceConnectorType - - actual_connector_id = final_connector_id - if actual_connector_id is None: - result = await db_session.execute( - select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == search_space_id, - SearchSourceConnector.user_id == user_id, - SearchSourceConnector.connector_type - == SearchSourceConnectorType.LINEAR_CONNECTOR, - ) - ) - connector = result.scalars().first() - if not connector: - return { - "status": "error", - "message": "No Linear connector found. Please connect Linear in your workspace settings.", - } - actual_connector_id = connector.id - logger.info(f"Found Linear connector: id={actual_connector_id}") - else: - result = await db_session.execute( - select(SearchSourceConnector).filter( - SearchSourceConnector.id == actual_connector_id, - SearchSourceConnector.search_space_id == search_space_id, - SearchSourceConnector.user_id == user_id, - SearchSourceConnector.connector_type - == SearchSourceConnectorType.LINEAR_CONNECTOR, - ) - ) - connector = result.scalars().first() - if not connector: - return { - "status": "error", - "message": "Selected Linear connector is invalid or has been disconnected.", - } - logger.info(f"Validated Linear connector: id={actual_connector_id}") - - logger.info( - f"Creating Linear issue with final params: title='{final_title}'" - ) - linear_client = LinearConnector( - session=db_session, connector_id=actual_connector_id - ) - result = await linear_client.create_issue( - team_id=final_team_id, - title=final_title, - description=final_description, - state_id=final_state_id, - assignee_id=final_assignee_id, - priority=final_priority, - label_ids=final_label_ids if final_label_ids else None, - ) - - if result.get("status") == "error": - logger.error(f"Failed to create Linear issue: {result.get('message')}") - return {"status": "error", "message": result.get("message")} - - logger.info( - f"Linear issue created: {result.get('identifier')} - {result.get('title')}" - ) - - kb_message_suffix = "" - try: - from app.services.linear import LinearKBSyncService - - kb_service = LinearKBSyncService(db_session) - kb_result = await kb_service.sync_after_create( - issue_id=result.get("id"), - issue_identifier=result.get("identifier", ""), - issue_title=result.get("title", final_title), - issue_url=result.get("url"), - description=final_description, - connector_id=actual_connector_id, - search_space_id=search_space_id, - user_id=user_id, - ) - if kb_result["status"] == "success": - kb_message_suffix = " Your knowledge base has also been updated." - else: - kb_message_suffix = " This issue will be added to your knowledge base in the next scheduled sync." - except Exception as kb_err: - logger.warning(f"KB sync after create failed: {kb_err}") - kb_message_suffix = " This issue will be added to your knowledge base in the next scheduled sync." - - return { - "status": "success", - "issue_id": result.get("id"), - "identifier": result.get("identifier"), - "url": result.get("url"), - "message": (result.get("message", "") + kb_message_suffix), - } - - except Exception as e: - from langgraph.errors import GraphInterrupt - - if isinstance(e, GraphInterrupt): - raise - - logger.error(f"Error creating Linear issue: {e}", exc_info=True) - if isinstance(e, ValueError | LinearAPIError): - message = str(e) - else: - message = ( - "Something went wrong while creating the issue. Please try again." - ) - return {"status": "error", "message": message} - - return create_linear_issue diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/delete_issue.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/delete_issue.py deleted file mode 100644 index 29ef0cdf2..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/delete_issue.py +++ /dev/null @@ -1,245 +0,0 @@ -import logging -from typing import Any - -from langchain_core.tools import tool -from sqlalchemy.ext.asyncio import AsyncSession - -from app.agents.new_chat.tools.hitl import request_approval -from app.connectors.linear_connector import LinearAPIError, LinearConnector -from app.services.linear import LinearToolMetadataService - -logger = logging.getLogger(__name__) - - -def create_delete_linear_issue_tool( - db_session: AsyncSession | None = None, - search_space_id: int | None = None, - user_id: str | None = None, - connector_id: int | None = None, -): - """ - Factory function to create the delete_linear_issue tool. - - Args: - db_session: Database session for accessing the Linear connector - search_space_id: Search space ID to find the Linear connector - user_id: User ID for finding the correct Linear connector - connector_id: Optional specific connector ID (if known) - - Returns: - Configured delete_linear_issue tool - """ - - @tool - async def delete_linear_issue( - issue_ref: str, - delete_from_kb: bool = False, - ) -> dict[str, Any]: - """Archive (delete) a Linear issue. - - Use this tool when the user asks to delete, remove, or archive a Linear issue. - Note that Linear archives issues rather than permanently deleting them - (they can be restored from the archive). - - - Args: - issue_ref: The issue to delete. Can be the issue title (e.g. "Fix login bug"), - the identifier (e.g. "ENG-42"), or the full document title - (e.g. "ENG-42: Fix login bug"). - delete_from_kb: Whether to also remove the issue from the knowledge base. - Default is False. Set to True to remove from both Linear - and the knowledge base. - - Returns: - Dictionary with: - - status: "success", "rejected", "not_found", or "error" - - identifier: Human-readable ID like "ENG-42" (if success) - - message: Success or error message - - deleted_from_kb: Whether the issue was also removed from the knowledge base (if success) - - IMPORTANT: - - If status is "rejected", the user explicitly declined the action. - Respond with a brief acknowledgment (e.g., "Understood, I won't delete the issue.") - and move on. Do NOT ask for alternatives or troubleshoot. - - If status is "not_found", inform the user conversationally using the exact message - provided. Do NOT treat this as an error. Simply relay the message and ask the user - to verify the issue title or identifier, or check if it has been indexed. - Examples: - - "Delete the 'Fix login bug' Linear issue" - - "Archive ENG-42" - - "Remove the 'Old payment flow' issue from Linear" - """ - logger.info( - f"delete_linear_issue called: issue_ref='{issue_ref}', delete_from_kb={delete_from_kb}" - ) - - if db_session is None or search_space_id is None or user_id is None: - logger.error( - "Linear tool not properly configured - missing required parameters" - ) - return { - "status": "error", - "message": "Linear tool not properly configured. Please contact support.", - } - - try: - metadata_service = LinearToolMetadataService(db_session) - context = await metadata_service.get_delete_context( - search_space_id, user_id, issue_ref - ) - - if "error" in context: - error_msg = context["error"] - if context.get("auth_expired"): - logger.warning(f"Auth expired for delete context: {error_msg}") - return { - "status": "auth_error", - "message": error_msg, - "connector_id": context.get("connector_id"), - "connector_type": "linear", - } - if "not found" in error_msg.lower(): - logger.warning(f"Issue not found: {error_msg}") - return {"status": "not_found", "message": error_msg} - else: - logger.error(f"Failed to fetch delete context: {error_msg}") - return {"status": "error", "message": error_msg} - - issue_id = context["issue"]["id"] - issue_identifier = context["issue"].get("identifier", "") - document_id = context["issue"]["document_id"] - connector_id_from_context = context.get("workspace", {}).get("id") - - logger.info( - f"Requesting approval for deleting Linear issue: '{issue_ref}' " - f"(id={issue_id}, delete_from_kb={delete_from_kb})" - ) - result = request_approval( - action_type="linear_issue_deletion", - tool_name="delete_linear_issue", - params={ - "issue_id": issue_id, - "connector_id": connector_id_from_context, - "delete_from_kb": delete_from_kb, - }, - context=context, - ) - - if result.rejected: - logger.info("Linear issue deletion rejected by user") - return { - "status": "rejected", - "message": "User declined. Do not retry or suggest alternatives.", - } - - final_issue_id = result.params.get("issue_id", issue_id) - final_connector_id = result.params.get( - "connector_id", connector_id_from_context - ) - final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb) - - logger.info( - f"Deleting Linear issue with final params: issue_id={final_issue_id}, " - f"connector_id={final_connector_id}, delete_from_kb={final_delete_from_kb}" - ) - - from sqlalchemy.future import select - - from app.db import SearchSourceConnector, SearchSourceConnectorType - - if final_connector_id: - result = await db_session.execute( - select(SearchSourceConnector).filter( - SearchSourceConnector.id == final_connector_id, - SearchSourceConnector.search_space_id == search_space_id, - SearchSourceConnector.user_id == user_id, - SearchSourceConnector.connector_type - == SearchSourceConnectorType.LINEAR_CONNECTOR, - ) - ) - connector = result.scalars().first() - if not connector: - logger.error( - f"Invalid connector_id={final_connector_id} for search_space_id={search_space_id}" - ) - return { - "status": "error", - "message": "Selected Linear connector is invalid or has been disconnected.", - } - actual_connector_id = connector.id - logger.info(f"Validated Linear connector: id={actual_connector_id}") - else: - logger.error("No connector found for this issue") - return { - "status": "error", - "message": "No connector found for this issue.", - } - - linear_client = LinearConnector( - session=db_session, connector_id=actual_connector_id - ) - - result = await linear_client.archive_issue(issue_id=final_issue_id) - - logger.info( - f"archive_issue result: {result.get('status')} - {result.get('message', '')}" - ) - - deleted_from_kb = False - if ( - result.get("status") == "success" - and final_delete_from_kb - and document_id - ): - try: - from app.db import Document - - doc_result = await db_session.execute( - select(Document).filter(Document.id == document_id) - ) - document = doc_result.scalars().first() - if document: - await db_session.delete(document) - await db_session.commit() - deleted_from_kb = True - logger.info( - f"Deleted document {document_id} from knowledge base" - ) - else: - logger.warning(f"Document {document_id} not found in KB") - except Exception as e: - logger.error(f"Failed to delete document from KB: {e}") - await db_session.rollback() - result["warning"] = ( - f"Issue archived in Linear, but failed to remove from knowledge base: {e!s}" - ) - - if result.get("status") == "success": - result["deleted_from_kb"] = deleted_from_kb - if issue_identifier: - result["message"] = ( - f"Issue {issue_identifier} archived successfully." - ) - if deleted_from_kb: - result["message"] = ( - f"{result.get('message', '')} Also removed from the knowledge base." - ) - - return result - - except Exception as e: - from langgraph.errors import GraphInterrupt - - if isinstance(e, GraphInterrupt): - raise - - logger.error(f"Error deleting Linear issue: {e}", exc_info=True) - if isinstance(e, ValueError | LinearAPIError): - message = str(e) - else: - message = ( - "Something went wrong while deleting the issue. Please try again." - ) - return {"status": "error", "message": message} - - return delete_linear_issue diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/index.py index f1ee49964..08b0e005e 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/index.py @@ -6,29 +6,9 @@ from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) -from .create_issue import create_create_linear_issue_tool -from .delete_issue import create_delete_linear_issue_tool -from .update_issue import create_update_linear_issue_tool - def load_tools( *, dependencies: dict[str, Any] | None = None, **kwargs: Any ) -> ToolsPermissions: - d = {**(dependencies or {}), **kwargs} - common = { - "db_session": d["db_session"], - "search_space_id": d["search_space_id"], - "user_id": d["user_id"], - "connector_id": d.get("connector_id"), - } - create = create_create_linear_issue_tool(**common) - update = create_update_linear_issue_tool(**common) - delete = create_delete_linear_issue_tool(**common) - return { - "allow": [], - "ask": [ - {"name": getattr(create, "name", "") or "", "tool": create}, - {"name": getattr(update, "name", "") or "", "tool": update}, - {"name": getattr(delete, "name", "") or "", "tool": delete}, - ], - } + _ = {**(dependencies or {}), **kwargs} + return {"allow": [], "ask": []} diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 64bfda7d0..76f48bc92 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -49,7 +49,7 @@ import { type TokenUsageData, TokenUsageProvider, } from "@/components/assistant-ui/token-usage-context"; -import { type BundleSubmit, HitlBundleProvider } from "@/features/chat-messages/hitl"; +import { type HitlDecision, PendingInterruptProvider } from "@/features/chat-messages/hitl"; import { TimelineDataUI } from "@/features/chat-messages/timeline"; import { applyActionLogSse, @@ -1738,57 +1738,6 @@ export default function NewChatPage() { return () => window.removeEventListener("hitl-decision", handler); }, [handleResume, pendingInterrupt]); - // Mirror staged bundle decisions onto the cards visually so prev/next nav - // reflects past choices instead of re-prompting. Submit's ``hitl-decision`` - // handler still runs the actual resume. - useEffect(() => { - const handler = (e: Event) => { - const detail = (e as CustomEvent).detail as { - toolCallId: string; - decision: { - type: string; - message?: string; - edited_action?: { name: string; args: Record }; - }; - }; - if (!detail?.toolCallId || !detail?.decision || !pendingInterrupt) return; - setMessages((prev) => - prev.map((m) => { - if (m.id !== pendingInterrupt.assistantMsgId) return m; - const parts = m.content as unknown as Array>; - const newContent = parts.map((part) => { - if (part.toolCallId !== detail.toolCallId) return part; - if (part.type !== "tool-call") return part; - if (typeof part.result !== "object" || part.result === null) return part; - if (!("__interrupt__" in (part.result as Record))) return part; - const decided = detail.decision.type as "approve" | "reject" | "edit"; - if (decided === "edit" && detail.decision.edited_action) { - return { - ...part, - args: detail.decision.edited_action.args, - argsText: JSON.stringify(detail.decision.edited_action.args, null, 2), - result: { - ...(part.result as Record), - __decided__: decided, - }, - }; - } - return { - ...part, - result: { - ...(part.result as Record), - __decided__: decided, - }, - }; - }); - return { ...m, content: newContent as unknown as ThreadMessageLike["content"] }; - }) - ); - }; - window.addEventListener("hitl-stage", handler); - return () => window.removeEventListener("hitl-stage", handler); - }, [pendingInterrupt]); - // Convert message (pass through since already in correct format) const convertMessage = useCallback( (message: ThreadMessageLike): ThreadMessageLike => message, @@ -2287,7 +2236,7 @@ export default function NewChatPage() { [handleRegenerate, messages, agentActionItems] ); - const handleBundleSubmit = useCallback((orderedDecisions) => { + const handleApprovalSubmit = useCallback((orderedDecisions: HitlDecision[]) => { window.dispatchEvent( new CustomEvent("hitl-decision", { detail: { decisions: orderedDecisions } }) ); @@ -2363,9 +2312,9 @@ export default function NewChatPage() { -
@@ -2375,7 +2324,7 @@ export default function NewChatPage() {
- + { diff --git a/surfsense_web/features/chat-messages/hitl/approval-cards/doom-loop-approval.tsx b/surfsense_web/features/chat-messages/hitl/approval-cards/doom-loop-approval.tsx index 5b2b0e385..1b7cf645d 100644 --- a/surfsense_web/features/chat-messages/hitl/approval-cards/doom-loop-approval.tsx +++ b/surfsense_web/features/chat-messages/hitl/approval-cards/doom-loop-approval.tsx @@ -7,7 +7,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; -import type { HitlApprovalCard, HitlDecision, InterruptResult } from "../types"; +import type { HitlDecision, InterruptResult, PerToolApprovalCard } from "../types"; import { isInterruptResult } from "../types"; import { useHitlDecision } from "../use-hitl-decision"; import { useHitlPhase } from "../use-hitl-phase"; @@ -178,7 +178,7 @@ export function isDoomLoopInterrupt(result: unknown): boolean { * ``isDoomLoopInterrupt(result)`` is true. Caller is responsible for * the discrimination; this card receives a known ``InterruptResult``. */ -export const DoomLoopApproval: HitlApprovalCard = ({ toolName, args, result }) => { +export const DoomLoopApproval: PerToolApprovalCard = ({ toolName, args, result }) => { const { dispatch } = useHitlDecision(); return ( { +export const GenericHitlApproval: PerToolApprovalCard = ({ toolName, args, result }) => { const { dispatch } = useHitlDecision(); return ( ; + stage: (decision: HitlDecision) => void; + next: () => void; + prev: () => void; + goToStep: (i: number) => void; + canAdvance: boolean; + canSubmit: boolean; +} + +export const HitlApprovalContext = createContext(null); + +export function useHitlApproval(): HitlApprovalAPI | null { + return useContext(HitlApprovalContext); +} diff --git a/surfsense_web/features/chat-messages/hitl/approval/hitl-approval-card.tsx b/surfsense_web/features/chat-messages/hitl/approval/hitl-approval-card.tsx new file mode 100644 index 000000000..8aa8c7499 --- /dev/null +++ b/surfsense_web/features/chat-messages/hitl/approval/hitl-approval-card.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { type FC, useCallback, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { getToolDisplayName } from "@/contracts/enums/toolIcons"; +import { + FallbackToolBody, + getToolComponent, + type TimelineToolProps, +} from "@/features/chat-messages/timeline/tool-registry"; +import type { + HitlDecision, + InterruptActionRequest, + InterruptResult, + InterruptReviewConfig, +} from "../types"; +import { type HitlApprovalAPI, HitlApprovalContext } from "./approval-context"; +import type { PendingInterruptState } from "./pending-interrupt-context"; + +/** + * Narrow the bundle interrupt to the active step so per-tool bodies + * see the same single-action shape they're written against. Mirrors + * any staged decision onto ``__decided__`` (and edited args onto + * ``args``) so revisiting a decided step via Prev shows the past + * choice instead of pristine Approve/Reject buttons. + */ +function sliceForStep( + interruptData: Record, + action: InterruptActionRequest, + reviewConfig: InterruptReviewConfig | undefined, + stagedDecision: HitlDecision | undefined +): InterruptResult { + const baseAction = + stagedDecision?.type === "edit" && stagedDecision.edited_action + ? { ...action, args: stagedDecision.edited_action.args } + : action; + + const sliced: InterruptResult = { + ...(interruptData as Partial), + __interrupt__: true, + action_requests: [baseAction], + review_configs: reviewConfig ? [reviewConfig] : [], + } as InterruptResult; + + if (stagedDecision) { + (sliced as unknown as Record).__decided__ = stagedDecision.type; + } + + return sliced; +} + +/** + * Single chrome for every HITL approval flow. Branches on + * ``action_requests.length``: 1 → per-tool body alone with auto- + * submit on first decision; ≥2 → per-tool body + inline pager + + * Submit-decisions (fires only once every step has a decision). + * Decisions are positional to match the resume protocol. + */ +export const HitlApprovalCard: FC<{ + pendingInterrupt: PendingInterruptState; + onSubmit: (decisions: HitlDecision[]) => void; +}> = ({ pendingInterrupt, onSubmit }) => { + const interruptData = pendingInterrupt.interruptData as InterruptResult & Record; + const actionRequests = (interruptData.action_requests ?? []) as InterruptActionRequest[]; + const reviewConfigs = (interruptData.review_configs ?? []) as InterruptReviewConfig[]; + const total = actionRequests.length; + const isMulti = total >= 2; + + const [currentStep, setCurrentStep] = useState(0); + const [decisions, setDecisions] = useState<(HitlDecision | undefined)[]>(() => + Array.from({ length: total }, () => undefined) + ); + + // Reset on a new interrupt-request while still mounted (rapid + // back-to-back resumes), otherwise stale decisions would leak. + const [prevActionsRef, setPrevActionsRef] = useState(actionRequests); + if (prevActionsRef !== actionRequests) { + setPrevActionsRef(actionRequests); + setCurrentStep(0); + setDecisions(Array.from({ length: total }, () => undefined)); + } + + const submitFromDecisions = useCallback( + (next: (HitlDecision | undefined)[]) => { + if (next.length !== total) return; + if (next.some((d) => d === undefined)) return; + onSubmit(next as HitlDecision[]); + }, + [onSubmit, total] + ); + + const stage = useCallback( + (decision: HitlDecision) => { + // Compute next array outside the setter so the side effect + // (auto-submit / step advance) runs once under StrictMode. + const updated = decisions.slice(); + updated[currentStep] = decision; + setDecisions(updated); + + if (!isMulti) { + submitFromDecisions(updated); + return; + } + + // Skip to the next undecided step rather than +1 so users + // who jumped via Prev don't get pulled back to a decided + // step. + let target = currentStep; + for (let i = currentStep + 1; i < updated.length; i++) { + if (updated[i] === undefined) { + target = i; + break; + } + } + if (target !== currentStep) setCurrentStep(target); + }, + [currentStep, decisions, isMulti, submitFromDecisions] + ); + + const next = useCallback(() => { + setCurrentStep((s) => Math.min(s + 1, Math.max(0, total - 1))); + }, [total]); + const prev = useCallback(() => { + setCurrentStep((s) => Math.max(s - 1, 0)); + }, []); + const goToStep = useCallback( + (i: number) => { + if (i < 0 || i >= total) return; + setCurrentStep(i); + }, + [total] + ); + const submit = useCallback(() => { + submitFromDecisions(decisions); + }, [decisions, submitFromDecisions]); + + const stagedCount = useMemo(() => decisions.filter((d) => d !== undefined).length, [decisions]); + const canSubmit = stagedCount === total && total > 0; + const canAdvance = decisions[currentStep] !== undefined; + + const api = useMemo( + () => ({ + total, + currentStep, + decisions, + stage, + next, + prev, + goToStep, + canAdvance, + canSubmit, + }), + [total, currentStep, decisions, stage, next, prev, goToStep, canAdvance, canSubmit] + ); + + if (total === 0) return null; + + const action = actionRequests[currentStep]; + const reviewConfig = reviewConfigs[currentStep]; + const stagedDecision = decisions[currentStep]; + const sliced = sliceForStep(interruptData, action, reviewConfig, stagedDecision); + + const Body = getToolComponent(action.name) ?? FallbackToolBody; + const bodyProps: TimelineToolProps = { + // Per-step key remounts the body on navigation so per-tool + // internal state (useHitlPhase, edit drafts) doesn't bleed + // between steps. + toolCallId: pendingInterrupt.bundleToolCallIds[currentStep] ?? `step-${currentStep}`, + toolName: action.name, + args: (sliced.action_requests[0]?.args ?? {}) as Record, + argsText: undefined, + result: sliced, + langchainToolCallId: undefined, + status: stagedDecision ? "completed" : "running", + }; + + return ( + +
+
+ +
+ {isMulti && ( + + )} +
+
+ ); +}; + +const PagerBar: FC<{ + currentStep: number; + total: number; + stagedCount: number; + canAdvance: boolean; + canSubmit: boolean; + actionName: string; + onPrev: () => void; + onNext: () => void; + onSubmit: () => void; +}> = ({ + currentStep, + total, + stagedCount, + canAdvance, + canSubmit, + actionName, + onPrev, + onNext, + onSubmit, +}) => ( +
+ + + {currentStep + 1} / {total} + + · + + {stagedCount} of {total} decided + + + + {getToolDisplayName(actionName)} + +
+ +
+
+); diff --git a/surfsense_web/features/chat-messages/hitl/approval/index.ts b/surfsense_web/features/chat-messages/hitl/approval/index.ts new file mode 100644 index 000000000..cfe6ba080 --- /dev/null +++ b/surfsense_web/features/chat-messages/hitl/approval/index.ts @@ -0,0 +1,9 @@ +export type { HitlApprovalAPI } from "./approval-context"; +export { HitlApprovalContext, useHitlApproval } from "./approval-context"; +export { HitlApprovalCard } from "./hitl-approval-card"; +export { + PendingInterruptProvider, + type PendingInterruptState, + type PendingInterruptValue, + usePendingInterrupt, +} from "./pending-interrupt-context"; diff --git a/surfsense_web/features/chat-messages/hitl/approval/pending-interrupt-context.tsx b/surfsense_web/features/chat-messages/hitl/approval/pending-interrupt-context.tsx new file mode 100644 index 000000000..2c193d952 --- /dev/null +++ b/surfsense_web/features/chat-messages/hitl/approval/pending-interrupt-context.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { createContext, type ReactNode, useContext } from "react"; +import type { HitlDecision } from "../types"; + +/** Snapshot of one in-flight HITL interrupt; ``null`` when nothing is pending. */ +export interface PendingInterruptState { + threadId: number; + assistantMsgId: string; + interruptData: Record; + bundleToolCallIds: string[]; +} + +export interface PendingInterruptValue { + pendingInterrupt: PendingInterruptState | null; + onSubmit: (decisions: HitlDecision[]) => void; +} + +const PendingInterruptContext = createContext(null); + +/** + * Bridges page-level interrupt state to the Timeline, which is mounted + * by assistant-ui and can't be prop-drilled. Mount once at the chat + * page root. + */ +export function PendingInterruptProvider({ + pendingInterrupt, + onSubmit, + children, +}: { + pendingInterrupt: PendingInterruptState | null; + onSubmit: (decisions: HitlDecision[]) => void; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function usePendingInterrupt(): PendingInterruptValue | null { + return useContext(PendingInterruptContext); +} diff --git a/surfsense_web/features/chat-messages/hitl/bundle/bundle-context.tsx b/surfsense_web/features/chat-messages/hitl/bundle/bundle-context.tsx deleted file mode 100644 index 72e9359f0..000000000 --- a/surfsense_web/features/chat-messages/hitl/bundle/bundle-context.tsx +++ /dev/null @@ -1,157 +0,0 @@ -"use client"; - -import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react"; -import type { HitlDecision } from "../types"; - -export type BundleSubmit = (orderedDecisions: HitlDecision[]) => void; - -export interface HitlBundleAPI { - toolCallIds: readonly string[]; - currentStep: number; - stagedCount: number; - isInBundle: (toolCallId: string) => boolean; - isCurrentStep: (toolCallId: string) => boolean; - getStaged: (toolCallId: string) => HitlDecision | undefined; - stage: (toolCallId: string, decision: HitlDecision) => void; - goToStep: (i: number) => void; - next: () => void; - prev: () => void; - submit: () => void; -} - -const HitlBundleContext = createContext(null); -const ToolCallIdContext = createContext(null); - -export function useHitlBundle(): HitlBundleAPI | null { - return useContext(HitlBundleContext); -} - -export function useToolCallIdContext(): string | null { - return useContext(ToolCallIdContext); -} - -export function ToolCallIdProvider({ - toolCallId, - children, -}: { - toolCallId: string; - children: ReactNode; -}) { - return {children}; -} - -interface HitlBundleProviderProps { - toolCallIds: readonly string[] | null; - onSubmit: BundleSubmit; - children: ReactNode; -} - -/** - * Coordinates N pending HITL decisions into ONE ordered submission. - * - * Active only when ``toolCallIds`` has 2+ entries (parallel interrupts); - * single-card interrupts bypass the bundle entirely (``useHitlDecision`` - * fires the ``hitl-decision`` window event directly). - * - * Pager UX: ``tool-call-item.tsx`` reads ``isInBundle`` + ``isCurrentStep`` - * to render only the current-step card; ``timeline.tsx`` mounts - * ```` once when this Provider is active. Submission is - * user-initiated via the pager's "Submit decisions" button (calls - * ``submit()``); not auto. - */ -export function HitlBundleProvider({ toolCallIds, onSubmit, children }: HitlBundleProviderProps) { - const active = toolCallIds !== null && toolCallIds.length >= 2; - const ids = useMemo(() => (active ? [...toolCallIds] : []), [active, toolCallIds]); - const bundleKey = ids.join("|"); - - const [prevBundleKey, setPrevBundleKey] = useState(bundleKey); - const [staged, setStaged] = useState>(() => new Map()); - const [currentStep, setCurrentStep] = useState(0); - if (bundleKey !== prevBundleKey) { - setPrevBundleKey(bundleKey); - setStaged(new Map()); - setCurrentStep(0); - } - - const isInBundle = useCallback((tcId: string) => ids.includes(tcId), [ids]); - const isCurrentStep = useCallback( - (tcId: string) => active === true && ids[currentStep] === tcId, - [active, ids, currentStep] - ); - const getStaged = useCallback((tcId: string) => staged.get(tcId), [staged]); - const stage = useCallback( - (tcId: string, decision: HitlDecision) => { - if (!active || !ids.includes(tcId)) return; - setStaged((prev) => { - const next = new Map(prev); - next.set(tcId, decision); - return next; - }); - window.dispatchEvent( - new CustomEvent("hitl-stage", { detail: { toolCallId: tcId, decision } }) - ); - const idx = ids.indexOf(tcId); - if (idx >= 0 && idx < ids.length - 1) { - setCurrentStep(idx + 1); - } - }, - [active, ids] - ); - const goToStep = useCallback( - (i: number) => { - if (i < 0 || i >= ids.length) return; - setCurrentStep(i); - }, - [ids.length] - ); - const next = useCallback(() => { - setCurrentStep((s) => Math.min(s + 1, Math.max(0, ids.length - 1))); - }, [ids.length]); - const prev = useCallback(() => { - setCurrentStep((s) => Math.max(s - 1, 0)); - }, []); - - const submit = useCallback(() => { - if (!active) return; - if (staged.size !== ids.length) return; - const ordered: HitlDecision[] = []; - for (const tcId of ids) { - const d = staged.get(tcId); - if (!d) return; - ordered.push(d); - } - onSubmit(ordered); - }, [active, ids, staged, onSubmit]); - - const value = useMemo(() => { - if (!active) return null; - return { - toolCallIds: ids, - currentStep, - stagedCount: staged.size, - isInBundle, - isCurrentStep, - getStaged, - stage, - goToStep, - next, - prev, - submit, - }; - }, [ - active, - ids, - currentStep, - staged, - isInBundle, - isCurrentStep, - getStaged, - stage, - goToStep, - next, - prev, - submit, - ]); - - return {children}; -} diff --git a/surfsense_web/features/chat-messages/hitl/bundle/index.ts b/surfsense_web/features/chat-messages/hitl/bundle/index.ts deleted file mode 100644 index a97282a28..000000000 --- a/surfsense_web/features/chat-messages/hitl/bundle/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type { BundleSubmit, HitlBundleAPI } from "./bundle-context"; -export { - HitlBundleProvider, - ToolCallIdProvider, - useHitlBundle, - useToolCallIdContext, -} from "./bundle-context"; -export { PagerChrome } from "./pager-chrome"; diff --git a/surfsense_web/features/chat-messages/hitl/bundle/pager-chrome.tsx b/surfsense_web/features/chat-messages/hitl/bundle/pager-chrome.tsx deleted file mode 100644 index fa8333fe8..000000000 --- a/surfsense_web/features/chat-messages/hitl/bundle/pager-chrome.tsx +++ /dev/null @@ -1,65 +0,0 @@ -"use client"; - -import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { useHitlBundle } from "./bundle-context"; - -/** - * 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. - * - * Mounted ONCE by ``timeline.tsx`` when the bundle is active. Does NOT - * wrap individual cards. Reads bundle state via ``useHitlBundle()``; - * renders nothing when no bundle is active. - */ -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/features/chat-messages/hitl/index.ts b/surfsense_web/features/chat-messages/hitl/index.ts index 13c37898f..50cc8ad69 100644 --- a/surfsense_web/features/chat-messages/hitl/index.ts +++ b/surfsense_web/features/chat-messages/hitl/index.ts @@ -1,13 +1,13 @@ -export { DoomLoopApproval, GenericHitlApproval, isDoomLoopInterrupt } from "./approval-cards"; export { - type BundleSubmit, - type HitlBundleAPI, - HitlBundleProvider, - PagerChrome, - ToolCallIdProvider, - useHitlBundle, - useToolCallIdContext, -} from "./bundle"; + type HitlApprovalAPI, + HitlApprovalCard, + PendingInterruptProvider, + type PendingInterruptState, + type PendingInterruptValue, + useHitlApproval, + usePendingInterrupt, +} from "./approval"; +export { DoomLoopApproval, GenericHitlApproval, isDoomLoopInterrupt } from "./approval-cards"; export { closeHitlEditPanelAtom, type ExtraField, @@ -18,13 +18,13 @@ export { openHitlEditPanelAtom, } from "./edit-panel"; export type { - HitlApprovalCard, - HitlApprovalCardProps, HitlDecision, HitlPhase, InterruptActionRequest, InterruptResult, InterruptReviewConfig, + PerToolApprovalCard, + PerToolApprovalCardProps, } from "./types"; export { isInterruptResult } from "./types"; export { useHitlDecision } from "./use-hitl-decision"; diff --git a/surfsense_web/features/chat-messages/hitl/types.ts b/surfsense_web/features/chat-messages/hitl/types.ts index bcde7abf4..03f00ba9d 100644 --- a/surfsense_web/features/chat-messages/hitl/types.ts +++ b/surfsense_web/features/chat-messages/hitl/types.ts @@ -41,11 +41,19 @@ export interface HitlDecision { export type HitlPhase = "pending" | "processing" | "complete" | "rejected"; -export interface HitlApprovalCardProps { +export interface PerToolApprovalCardProps { toolName: string; toolCallId: string; args: Record; result: InterruptResult; } -export type HitlApprovalCard = (props: HitlApprovalCardProps) => ReactNode; +/** + * Type signature for per-tool fallback approval cards (e.g. + * ``GenericHitlApproval``, ``DoomLoopApproval``) mounted by + * ``FallbackToolBody`` for unregistered HITL tools. + * + * Distinct from ``HitlApprovalCard`` (the high-level multi/single + * chrome) — this is the per-tool body that the chrome wraps. + */ +export type PerToolApprovalCard = (props: PerToolApprovalCardProps) => ReactNode; diff --git a/surfsense_web/features/chat-messages/hitl/use-hitl-decision.ts b/surfsense_web/features/chat-messages/hitl/use-hitl-decision.ts index 10d02c982..f35be46a3 100644 --- a/surfsense_web/features/chat-messages/hitl/use-hitl-decision.ts +++ b/surfsense_web/features/chat-messages/hitl/use-hitl-decision.ts @@ -1,44 +1,31 @@ import { useCallback } from "react"; -import { useHitlBundle, useToolCallIdContext } from "./bundle/bundle-context"; +import { useHitlApproval } from "./approval/approval-context"; import type { HitlDecision } from "./types"; /** - * Dispatches a HITL decision from inside an approval card. - * - * Behavior: - * - **Bundle active** (N≥2 parallel interrupts) AND this card's - * ``toolCallId`` is in the bundle: stage the (single) decision - * against this ``toolCallId`` so the bundle can submit one ordered - * N-payload when every card has decided. Multi-decision dispatches - * in this path are a programming error: only ``decisions[0]`` is - * staged; a dev warning fires for the rest. - * - **Otherwise (N=1 or no bundle):** dispatch the ``hitl-decision`` - * window event directly with the full ``decisions`` array. The host - * page's listener calls ``runtime.resume`` with the same array. - * - * Cards always call ``dispatch([decision])`` and don't need to know - * which path they're on. + * Per-tool components always call ``dispatch([decision])``. We route + * through ``HitlApprovalContext`` when mounted inside an approval + * card (so multi-approval can stage and pager-navigate), and fall + * back to the ``hitl-decision`` window event for standalone callers. */ export function useHitlDecision() { - const bundle = useHitlBundle(); - const toolCallId = useToolCallIdContext(); + const approval = useHitlApproval(); const dispatch = useCallback( (decisions: HitlDecision[]) => { - if (bundle && toolCallId && bundle.isInBundle(toolCallId) && decisions.length > 0) { + if (approval && decisions.length > 0) { if (decisions.length > 1 && process.env.NODE_ENV !== "production") { console.warn( - "[hitl] dispatch received %d decisions inside an active bundle; only [0] will be staged for %s", - decisions.length, - toolCallId + "[hitl] dispatch received %d decisions inside an approval card; only [0] will be staged", + decisions.length ); } - bundle.stage(toolCallId, decisions[0]); + approval.stage(decisions[0]); return; } window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } })); }, - [bundle, toolCallId] + [approval] ); return { dispatch }; diff --git a/surfsense_web/features/chat-messages/timeline/build-timeline.ts b/surfsense_web/features/chat-messages/timeline/build-timeline.ts index 7c78dfb7b..20ae6d596 100644 --- a/surfsense_web/features/chat-messages/timeline/build-timeline.ts +++ b/surfsense_web/features/chat-messages/timeline/build-timeline.ts @@ -1,9 +1,9 @@ import type { ItemStatus, ReasoningItem, TimelineItem, ToolCallItem } from "./types"; /** - * The thinking-step shape produced by the streaming pipeline (see - * ``data-thinking-step`` SSE events). Kept structural here so this - * builder doesn't depend on the legacy ``thinking-steps.tsx`` file. + * Structural shape of the relay's ``data-thinking-step`` payload. + * Declared here (not imported) so the builder stays free of the + * legacy ``thinking-steps.tsx`` dependency. */ export interface ThinkingStepInput { id: string; @@ -13,12 +13,7 @@ export interface ThinkingStepInput { metadata?: Record; } -/** - * The minimum tool-call-part shape we read from message content. We - * accept ``unknown[]`` and structurally narrow per part — the assistant- - * ui content type has many shapes, but only ``tool-call`` parts matter - * here. - */ +/** Narrowed tool-call shape; the assistant-ui content type is wider. */ interface ToolCallPart { type: "tool-call"; toolCallId: string; @@ -43,15 +38,101 @@ function asNonEmptyString(v: unknown): string | undefined { } /** - * Derive coarse status for a tool-call from its result shape. Used - * when the tool-call has no joined thinking step (orphan path). + * True iff THIS tool-call is the actual interrupt request (carries an + * ``action_requests[]``), not just a parent ``task`` wrapper that + * inherited the propagated ``__interrupt__`` flag. Pending requests + * are hidden so ``HitlApprovalCard`` owns the pending UX; the + * ``length > 0`` guard keeps parent task wrappers visible so their + * children stay indented under the delegation span. + */ +function isPendingHitlInterrupt(result: unknown): boolean { + if (typeof result !== "object" || result === null) return false; + const r = result as { + __interrupt__?: unknown; + __decided__?: unknown; + action_requests?: unknown; + }; + return ( + r.__interrupt__ === true && + r.__decided__ === undefined && + Array.isArray(r.action_requests) && + r.action_requests.length > 0 + ); +} + +/** + * Stable interrupt signal across pre/post decision: the resume flow + * spreads the original result and only adds ``__decided__``, so + * ``__interrupt__`` alone is the right key. + */ +function hasInterruptMarker(result: unknown): boolean { + if (typeof result !== "object" || result === null) return false; + return (result as { __interrupt__?: unknown }).__interrupt__ === true; +} + +interface ToolCallSlim { + toolName: string; + toolCallId: string; + result?: unknown; + spanId?: string; +} + +/** + * During the live-resume window the in-memory message holds BOTH the + * OLD interrupt-frame parts AND the freshly-streamed resume parts in + * a new ``task`` scope. Without this filter we'd render both until + * the next reload (where ``filterSupersededAbortedMessages`` drops + * the OLD row upstream). * - * - HITL ``__decided__: "reject"`` → ``cancelled`` - * - Has any result → ``completed`` - * - No result yet → ``running`` - * - * The per-tool component picks its own visual state from the result; - * this is only the timeline chrome's coarse signal. + * A tool-call is "interrupt-affected" when it either carries + * ``__interrupt__`` directly or sits in a span that contains one. An + * affected call is superseded iff a later same-name call in a + * different scope exists. The conservative branch (no successor) + * preserves rejects that ended the run with no replacement. + */ +function collectSupersededToolCallIds(content: readonly unknown[]): Set { + const slims: ToolCallSlim[] = []; + for (const part of content) { + if (!isToolCallPart(part)) continue; + slims.push({ + toolName: part.toolName, + toolCallId: part.toolCallId, + result: part.result, + spanId: asNonEmptyString(part.metadata?.spanId), + }); + } + + const interruptedSpans = new Set(); + for (const tc of slims) { + if (!hasInterruptMarker(tc.result)) continue; + if (tc.spanId) interruptedSpans.add(tc.spanId); + } + + const superseded = new Set(); + for (let i = 0; i < slims.length; i++) { + const tc = slims[i]; + const inInterruptedSpan = tc.spanId !== undefined && interruptedSpans.has(tc.spanId); + const isDirectInterrupt = hasInterruptMarker(tc.result); + if (!inInterruptedSpan && !isDirectInterrupt) continue; + + for (let j = i + 1; j < slims.length; j++) { + // Both-undefined counts as different scopes so standalone + // HITL tools (no delegation) get caught. + const sameSpan = tc.spanId !== undefined && slims[j].spanId === tc.spanId; + if (slims[j].toolName === tc.toolName && !sameSpan) { + superseded.add(tc.toolCallId); + break; + } + } + } + + return superseded; +} + +/** + * Coarse status for orphan tool-calls (no joined thinking step). The + * per-tool body picks its own visual state from ``result``; this + * only feeds the chrome dot/header. */ function deriveToolCallStatus(result: unknown): ItemStatus { if (!result) return "running"; @@ -68,119 +149,30 @@ function mapStepStatus(status: ThinkingStepInput["status"]): ItemStatus { } /** - * True when a tool-call's result carries an HITL interrupt. Catches - * both pre-decision (``__interrupt__: true``) and post-decision - * (``__interrupt__: true, __decided__: …``) states — the resume - * flow's decision-application spreads the original result and only - * adds ``__decided__``, so ``__interrupt__`` alone is the stable - * signal. - */ -function isInterruptInResult(result: unknown): boolean { - if (typeof result !== "object" || result === null) return false; - return (result as { __interrupt__?: unknown }).__interrupt__ === true; -} - -/** - * Build the set of tool-call ids that have been superseded by the - * resume stream's continuation. - * - * The challenge: during the live resume window, the in-memory message - * holds BOTH the rehydrated interrupt-frame parts (the OLD ``task`` + - * its inner ``update_notion_page`` whose result has ``__decided__``) - * AND the freshly-streamed resume parts (a NEW ``task`` + a NEW - * ``update_notion_page`` with the actual success result). We need to - * drop the entire OLD delegation chain so only the NEW one renders. - * - * Two-stage detection: - * - * 1. **Identify "interrupted spans"** — any spanId that contains at - * least one tool-call whose ``result.__interrupt__`` is true. This - * captures both the inner decided tool and its outer ``task`` - * wrapper (which itself has no result but shares the spanId). - * Without this the wrapper survives as an orphan parent — the - * stray "Notion" row we saw post-approve. - * - * 2. **Mark a tool-call as superseded** when (a) it sits in an - * interrupted span OR carries the interrupt marker directly, AND - * (b) a later tool-call with the same ``toolName`` in a DIFFERENT - * span exists. The "different span" guard prevents self-supersession - * within the same delegation episode. - * - * Mirrors the message-level rule in - * ``filterSupersededAbortedMessages`` but at the part level — same - * data-shape problem (interrupt frame + resume continuation cohabiting - * one in-memory message) one level down. - * - * Conservative: an interrupted tool-call with NO later same-named - * different-span successor stays (e.g. a reject that ended the run, a - * never-resumed decision). - */ -function collectSupersededToolCallIds(content: readonly unknown[]): Set { - const toolCallParts: ToolCallPart[] = []; - for (const part of content) { - if (isToolCallPart(part)) toolCallParts.push(part); - } - - const interruptedSpans = new Set(); - for (const part of toolCallParts) { - if (!isInterruptInResult(part.result)) continue; - const sid = asNonEmptyString(part.metadata?.spanId); - if (sid) interruptedSpans.add(sid); - } - - const superseded = new Set(); - for (let i = 0; i < toolCallParts.length; i++) { - const part = toolCallParts[i]; - const sid = asNonEmptyString(part.metadata?.spanId); - const inInterruptedSpan = sid !== undefined && interruptedSpans.has(sid); - const isDirectInterrupt = isInterruptInResult(part.result); - if (!inInterruptedSpan && !isDirectInterrupt) continue; - - for (let j = i + 1; j < toolCallParts.length; j++) { - const jsid = asNonEmptyString(toolCallParts[j].metadata?.spanId); - // Both-undefined counts as "different scopes" so standalone - // HITL tools (no delegation, no spanId) get caught. Naive - // ``jsid !== sid`` misses them since ``undefined !== - // undefined`` is false. - const sameSpan = sid !== undefined && jsid === sid; - if (toolCallParts[j].toolName === part.toolName && !sameSpan) { - superseded.add(part.toolCallId); - break; - } - } - } - - return superseded; -} - -/** - * Build the timeline's flat ``TimelineItem[]`` from thinking steps + - * message content tool-calls. - * - * 1. Index tool-call parts by ``metadata.thinkingStepId`` (O(1) join). - * 2. Walk thinking steps in order. Joined → ``ToolCallItem``; - * unjoined → ``ReasoningItem``. - * 3. Append unjoined tool-calls as orphan ``ToolCallItem``s (legacy - * history pre-``thinkingStepId``). - * - * Pure: no React, no I/O. ``result`` is forwarded verbatim — per-tool - * components own its discrimination. ``isThreadRunning`` lives in - * ``timeline.tsx`` as a runtime override. + * Pure builder: thinking steps + message content → ``TimelineItem[]``. + * Joins tool-calls to thinking steps via ``metadata.thinkingStepId``, + * appends unjoined tool-calls as orphans, drops superseded + * interrupt-frame parts and pending HITL requests (those are owned + * by ``HitlApprovalCard``). ``result`` is forwarded verbatim so + * per-tool bodies can discriminate. */ export function buildTimeline( thinkingSteps: readonly ThinkingStepInput[], content: readonly unknown[] | undefined ): TimelineItem[] { const toolByStepId = new Map(); + const supersededStepIds = new Set(); const consumedToolCallIds = new Set(); - const supersededToolCallIds = content - ? collectSupersededToolCallIds(content) - : new Set(); + const superseded = content ? collectSupersededToolCallIds(content) : new Set(); if (content) { for (const part of content) { if (!isToolCallPart(part)) continue; const tid = asNonEmptyString(part.metadata?.thinkingStepId); + if (superseded.has(part.toolCallId)) { + if (tid) supersededStepIds.add(tid); + continue; + } if (tid) toolByStepId.set(tid, part); } } @@ -188,15 +180,14 @@ export function buildTimeline( const items: TimelineItem[] = []; for (const step of thinkingSteps) { + // Drop the step alongside its superseded tool-call, otherwise + // it'd render as an orphan reasoning row with the OLD title. + if (supersededStepIds.has(step.id)) continue; + const stepSpanId = asNonEmptyString(step.metadata?.spanId); const joined = toolByStepId.get(step.id); - // Drop the step entirely when it joins a superseded tool-call: - // the resume stream has emitted a fresh same-named tool-call - // (with its own thinking step) that takes over the row. - // Without this, the timeline shows two "Notion → Update - // Notion page" groups during the live resume window. - if (joined && supersededToolCallIds.has(joined.toolCallId)) { + if (joined && isPendingHitlInterrupt(joined.result)) { consumedToolCallIds.add(joined.toolCallId); continue; } @@ -236,7 +227,8 @@ export function buildTimeline( for (const part of content) { if (!isToolCallPart(part)) continue; if (consumedToolCallIds.has(part.toolCallId)) continue; - if (supersededToolCallIds.has(part.toolCallId)) continue; + if (superseded.has(part.toolCallId)) continue; + if (isPendingHitlInterrupt(part.result)) continue; const orphan: ToolCallItem = { kind: "tool-call", id: part.toolCallId, diff --git a/surfsense_web/features/chat-messages/timeline/data-renderer.tsx b/surfsense_web/features/chat-messages/timeline/data-renderer.tsx index 4ae160b84..861e35fd2 100644 --- a/surfsense_web/features/chat-messages/timeline/data-renderer.tsx +++ b/surfsense_web/features/chat-messages/timeline/data-renderer.tsx @@ -2,25 +2,32 @@ import { makeAssistantDataUI, useAuiState } from "@assistant-ui/react"; import { useMemo } from "react"; +import { PendingInterruptProvider, usePendingInterrupt } from "@/features/chat-messages/hitl"; import { buildTimeline, type ThinkingStepInput } from "./build-timeline"; import { Timeline } from "./timeline"; +const noopSubmit = () => {}; + /** - * assistant-ui data UI for the ``thinking-steps`` data-part. Receives - * the relay's step array as ``data``, reads message ``content`` via - * ``useAuiState``, builds the unified ``TimelineItem[]`` once - * (``buildTimeline`` is pure), and renders the ``Timeline``. + * assistant-ui data UI for the ``thinking-steps`` data-part. * - * ``isMessageStreaming`` is the AND of thread-running + this-message- - * is-last; that flag drives the ``isThreadRunning`` runtime override - * in ``Timeline`` (stale "running" → "completed" once the thread - * stops). Mirrors the legacy ``ThinkingStepsDataRenderer`` semantics. + * Re-scopes the global ``PendingInterruptProvider`` per message: the + * approval card only mounts under the assistant message that owns + * the interrupt (otherwise every message in scrollback would render + * its own card). */ function TimelineDataRenderer({ 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 messageId = useAuiState(({ message }) => message?.id); + const pendingValue = usePendingInterrupt(); + const pendingForThisMessage = + pendingValue?.pendingInterrupt && pendingValue.pendingInterrupt.assistantMsgId === messageId + ? pendingValue.pendingInterrupt + : null; + const onSubmit = pendingValue?.onSubmit ?? noopSubmit; const steps = useMemo( () => (data as { steps: ThinkingStepInput[] } | null)?.steps ?? [], @@ -32,21 +39,18 @@ function TimelineDataRenderer({ data }: { name: string; data: unknown }) { [steps, content] ); - if (items.length === 0) return null; + if (items.length === 0 && !pendingForThisMessage) return null; return (
- + + +
); } -/** - * Drop-in replacement for the legacy ``ThinkingStepsDataUI``. Same - * registration name (``thinking-steps``) so consumers (assistant- - * message.tsx, public-thread.tsx, free-chat-page.tsx, etc.) just swap - * the import — no SSE relay changes, no message format changes. - */ +/** Registers under ``thinking-steps`` so consumers swap the import only. */ export const TimelineDataUI = makeAssistantDataUI({ name: "thinking-steps", render: TimelineDataRenderer, diff --git a/surfsense_web/features/chat-messages/timeline/grouping.ts b/surfsense_web/features/chat-messages/timeline/grouping.ts index 1a4dfebcc..478ec4b7b 100644 --- a/surfsense_web/features/chat-messages/timeline/grouping.ts +++ b/surfsense_web/features/chat-messages/timeline/grouping.ts @@ -1,33 +1,33 @@ import type { TimelineGroup, TimelineItem } from "./types"; /** - * Group consecutive delegated child items under their parent. + * Group delegated child items under their owning ``task`` parent. * - * The contract: the parent of a span is the FIRST item carrying that - * ``spanId``. Subsequent items with the same ``spanId`` are children. - * Items with no ``spanId`` are their own parent (no children). + * Backend invariant: ``metadata.spanId`` is set only while a ``task`` + * tool is open, so every non-task item with ``spanId = X`` shares it + * with the ``task`` that owns the span. We promote that task to the + * group header. * - * For ``task`` delegations specifically, the ``task`` tool-call IS the - * span owner — its ``spanId`` is set on the call itself, and child - * items emitted while the subagent is running carry the same ``spanId``. - * The ``task`` item must therefore become the parent header, NOT a - * child of itself. This is achieved by treating the FIRST occurrence - * of any ``spanId`` as the parent; downstream items with the same - * ``spanId`` are children. - * - * Defensive: if the very first item of a stream is a child of a span - * we haven't seen the parent for yet, it's promoted to a parent so it - * still renders. Real flows always emit the parent ``task`` first. - * - * Pure function. No React, no side effects. Trivially testable. + * The owner-missing branch defends against the live-resume window + * where the OLD ``task`` wrapper can be superseded while its + * children briefly survive — without it, grouping would promote + * the first orphan child to parent and visually nest its siblings + * under it. */ export function groupItems(items: readonly TimelineItem[]): TimelineGroup[] { + const spanOwners = new Set(); + for (const item of items) { + if (item.kind === "tool-call" && item.toolName === "task" && item.spanId) { + spanOwners.add(item.spanId); + } + } + const groups: TimelineGroup[] = []; const spanParent = new Map(); for (const item of items) { const sid = item.spanId; - if (!sid) { + if (!sid || !spanOwners.has(sid)) { groups.push({ parent: item, children: [] }); continue; } diff --git a/surfsense_web/features/chat-messages/timeline/items/tool-call-item.tsx b/surfsense_web/features/chat-messages/timeline/items/tool-call-item.tsx index 1848f0c5c..4f96c8bd5 100644 --- a/surfsense_web/features/chat-messages/timeline/items/tool-call-item.tsx +++ b/surfsense_web/features/chat-messages/timeline/items/tool-call-item.tsx @@ -2,49 +2,25 @@ import type { FC } from "react"; import { getToolDisplayName } from "@/contracts/enums/toolIcons"; -import { ToolCallIdProvider, useHitlBundle } from "@/features/chat-messages/hitl"; import { resolveItemTitle } from "../subagent-rename"; import { adaptItemToProps, FallbackToolBody, getToolComponent } from "../tool-registry"; import type { ToolCallItem as ToolCallItemModel } from "../types"; import { ItemHeader } from "./item-header"; /** - * Renders a ``kind: "tool-call"`` row: ``ItemHeader`` (title + items) - * plus the resolved tool body underneath. - * - * Tool body is selected from the registry; unknown names fall through - * to ``FallbackToolBody`` (which itself dispatches between HITL - * approval cards and the default visual card based on result shape). - * - * Multi-approval bundle behaviour: when the HITL bundle is active, all - * cards EXCEPT the current step are hidden so the user is paged - * through them one at a time. Hiding is local to this row — the header - * and the timeline chrome around it are unaffected (the row collapses - * to its header only). The bundle's ``PagerChrome`` is mounted once - * at the end of the timeline by ``timeline.tsx``. - * - * Every tool body is wrapped in ``ToolCallIdProvider`` so - * ``useHitlDecision`` (called inside HITL approval cards) can read the - * tool-call id from context and stage decisions in the bundle. + * Renders a tool-call row. Pending HITL interrupts are filtered + * upstream in ``buildTimeline`` (owned by ``HitlApprovalCard``); this + * component only sees running / completed / errored / decided rows. */ export const ToolCallItem: FC<{ item: ToolCallItemModel }> = ({ item }) => { - const bundle = useHitlBundle(); - const hideForBundle = - bundle?.isInBundle(item.toolCallId) === true && !bundle.isCurrentStep(item.toolCallId); - const title = resolveItemTitle(item, getToolDisplayName); - const Body = getToolComponent(item.toolName) ?? FallbackToolBody; const props = adaptItemToProps(item); return ( <> - {!hideForBundle && ( - - - - )} + ); }; diff --git a/surfsense_web/features/chat-messages/timeline/timeline.tsx b/surfsense_web/features/chat-messages/timeline/timeline.tsx index cdabbb67a..f51034733 100644 --- a/surfsense_web/features/chat-messages/timeline/timeline.tsx +++ b/surfsense_web/features/chat-messages/timeline/timeline.tsx @@ -4,7 +4,7 @@ import { ChevronRightIcon } from "lucide-react"; import { type FC, useEffect, useMemo, useState } from "react"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { getToolDisplayName } from "@/contracts/enums/toolIcons"; -import { PagerChrome, useHitlBundle } from "@/features/chat-messages/hitl"; +import { HitlApprovalCard, usePendingInterrupt } from "@/features/chat-messages/hitl"; import { cn } from "@/lib/utils"; import { groupItems } from "./grouping"; import { resolveItemTitle } from "./subagent-rename"; @@ -12,10 +12,9 @@ import { TimelineGroupRow } from "./timeline-group-row"; import type { ItemStatus, TimelineItem } from "./types"; /** - * Override coarse status when the thread isn't running anymore: a - * stale "running" must read as "completed" so the chrome stops - * pulsing. Mirrors the legacy ``getEffectiveStatus`` from - * ``thinking-steps.tsx``. + * Force a stale "running" to read as "completed" once the thread + * stops, so the chrome doesn't keep pulsing forever after a stream + * is aborted or disconnected. */ function effectiveStatus(status: ItemStatus, isThreadRunning: boolean): ItemStatus { if (status === "running" && !isThreadRunning) return "completed"; @@ -23,54 +22,23 @@ function effectiveStatus(status: ItemStatus, isThreadRunning: boolean): ItemStat } /** - * True when a tool-call's result is an HITL interrupt the user has - * NOT decided on yet. The backend marks the step as ``completed`` - * (the tool DID complete — it returned an interrupt as its result), - * which would normally collapse the timeline. This predicate lets the - * chrome treat "waiting on user" as still-in-progress. - * - * Decided interrupts (``__decided__`` set to "approve"/"reject"/ - * "edit") count as completed for chrome purposes — the resume stream - * will take it from there. - */ -function isPendingInterrupt(result: unknown): boolean { - if (typeof result !== "object" || result === null) return false; - const r = result as { __interrupt__?: unknown; __decided__?: unknown }; - return r.__interrupt__ === true && r.__decided__ === undefined; -} - -/** - * The chain-of-thought timeline. The "process" surface in the - * `body | timeline` split — owns chrome (collapsible header, tree - * dots/lines, indent, group iteration) and dispatches to per-kind - * items for the actual content. - * - * Rendering responsibilities (kept here, not on items): - * - Outer max-width container. - * - Collapsible header with state-aware label ("Reviewed" / - * "Processing" / current step title) and shimmer. - * - Open/close state derived from ``isThreadRunning`` + completion. - * - Status dot + vertical connector line per group (delegates the - * inner row to ``TimelineGroupRow``). - * - Mounting ``PagerChrome`` once at the bottom when the HITL bundle - * is active (multi-approval coordination — see - * ``hitl/bundle/bundle-context.tsx``). - * - * Pure consumption of ``TimelineItem[]`` — does NOT call - * ``buildTimeline`` itself. The data-renderer adapter does that and - * passes the items in. + * The "process" surface in the body | timeline split. Pure consumer + * of ``TimelineItem[]`` — owns the collapsible chrome and tree + * indent only. Pending HITL interrupts mount ``HitlApprovalCard`` at + * the bottom; the card owns its own decision/pager state. */ export const Timeline: FC<{ items: readonly TimelineItem[]; isThreadRunning?: boolean; }> = ({ items, isThreadRunning = true }) => { - const bundle = useHitlBundle(); + const pendingValue = usePendingInterrupt(); + const pendingInterrupt = pendingValue?.pendingInterrupt ?? null; + const onSubmit = pendingValue?.onSubmit; + const hasPending = pendingInterrupt !== null; - // Apply the runtime ``isThreadRunning`` override to every item once, - // up-front, so downstream code (grouping, group rows, item headers, - // status dot, all children) sees the corrected coarse status without - // having to thread a callback through. ``buildTimeline`` stays pure; - // the override is purely a render-time concern that lives here. + // Apply the override here so downstream (grouping, headers, dots) + // sees the corrected status without threading a callback. Keeps + // ``buildTimeline`` pure. const effectiveItems = useMemo( () => items.map((it) => ({ @@ -89,29 +57,20 @@ export const Timeline: FC<{ [inProgressItem] ); - // Detect a tool-call that's parked on an HITL interrupt the user hasn't - // decided yet. Treated as "still in progress" by the chrome so the - // timeline doesn't auto-collapse on the user mid-decision (the LangGraph - // thread paused, but the agent's work is conceptually unfinished). - const pendingInterruptItem = useMemo( - () => effectiveItems.find((it) => it.kind === "tool-call" && isPendingInterrupt(it.result)), - [effectiveItems] - ); - const pendingInterruptTitle = useMemo( - () => - pendingInterruptItem ? resolveItemTitle(pendingInterruptItem, getToolDisplayName) : undefined, - [pendingInterruptItem] - ); - - const allCompleted = useMemo( + // "Settled" includes cancelled/errored, not just completed — + // rejecting an interrupt leaves items in ``cancelled`` and the + // timeline still needs to auto-collapse. + const allSettled = useMemo( () => effectiveItems.length > 0 && !isThreadRunning && - !pendingInterruptItem && - effectiveItems.every((it) => it.status === "completed"), - [effectiveItems, isThreadRunning, pendingInterruptItem] + !hasPending && + effectiveItems.every( + (it) => it.status === "completed" || it.status === "cancelled" || it.status === "error" + ), + [effectiveItems, isThreadRunning, hasPending] ); - const isProcessing = (isThreadRunning || !!pendingInterruptItem) && !allCompleted; + const isProcessing = (isThreadRunning || hasPending) && !allSettled; const [isOpen, setIsOpen] = useState(() => isProcessing); useEffect(() => { @@ -119,22 +78,19 @@ export const Timeline: FC<{ setIsOpen(true); return; } - if (allCompleted) { + if (allSettled) { setIsOpen(false); } - }, [allCompleted, isProcessing]); + }, [allSettled, isProcessing]); const groups = useMemo(() => groupItems(effectiveItems), [effectiveItems]); - if (effectiveItems.length === 0) return null; + if (effectiveItems.length === 0 && !hasPending) return null; const headerText = (() => { - if (allCompleted) return "Reviewed"; + if (allSettled) return "Reviewed"; + if (hasPending) return "Awaiting your decision"; if (inProgressTitle) return inProgressTitle; - // Pending HITL: prefer the tool's own name so the user knows WHICH - // approval is gating progress (e.g. "Update Notion page") rather - // than a generic "Awaiting approval" label. - if (pendingInterruptTitle) return pendingInterruptTitle; if (isProcessing) return "Processing"; return "Reviewed"; })(); @@ -168,16 +124,22 @@ export const Timeline: FC<{ >
- {groups.map((group, groupIndex) => ( - - ))} - - {bundle && } + {groups.map((group, idx) => { + const showLine = idx < groups.length - 1 || hasPending; + return ( + + ); + })} + {pendingInterrupt && onSubmit && ( +
+ +
+ )}
diff --git a/surfsense_web/features/chat-messages/timeline/types.ts b/surfsense_web/features/chat-messages/timeline/types.ts index 37bd0fbc3..d32496b5e 100644 --- a/surfsense_web/features/chat-messages/timeline/types.ts +++ b/surfsense_web/features/chat-messages/timeline/types.ts @@ -56,7 +56,7 @@ export interface ReasoningItem extends BaseItem { export interface ToolCallItem extends BaseItem { kind: "tool-call"; toolName: string; - /** The actual tool-call ID — used by HITL (bundle membership, ``ToolCallIdProvider``). */ + /** The actual tool-call ID — passed to per-tool components (e.g. for the Revert button). */ toolCallId: string; args: Record; argsText?: string;