mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-11 16:52:38 +02:00
chat: unify HITL approval UX behind a single paginated card and harden timeline supersede.
This commit is contained in:
parent
89e4953800
commit
2e132513be
25 changed files with 604 additions and 1157 deletions
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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": []}
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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": []}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> };
|
||||
};
|
||||
};
|
||||
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<Record<string, unknown>>;
|
||||
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<string, unknown>))) 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<string, unknown>),
|
||||
__decided__: decided,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...part,
|
||||
result: {
|
||||
...(part.result as Record<string, unknown>),
|
||||
__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<BundleSubmit>((orderedDecisions) => {
|
||||
const handleApprovalSubmit = useCallback((orderedDecisions: HitlDecision[]) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions: orderedDecisions } })
|
||||
);
|
||||
|
|
@ -2363,9 +2312,9 @@ export default function NewChatPage() {
|
|||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<TimelineDataUI />
|
||||
<StepSeparatorDataUI />
|
||||
<HitlBundleProvider
|
||||
toolCallIds={pendingInterrupt?.bundleToolCallIds ?? null}
|
||||
onSubmit={handleBundleSubmit}
|
||||
<PendingInterruptProvider
|
||||
pendingInterrupt={pendingInterrupt}
|
||||
onSubmit={handleApprovalSubmit}
|
||||
>
|
||||
<div key={searchSpaceId} className="flex h-full overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
|
|
@ -2375,7 +2324,7 @@ export default function NewChatPage() {
|
|||
<MobileEditorPanel />
|
||||
<MobileHitlEditPanel />
|
||||
</div>
|
||||
</HitlBundleProvider>
|
||||
</PendingInterruptProvider>
|
||||
<EditMessageDialog
|
||||
open={editDialogState !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<DoomLoopCardView
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
|
||||
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
||||
import type { HitlApprovalCard, HitlDecision, InterruptResult } from "../types";
|
||||
import type { HitlDecision, InterruptResult, PerToolApprovalCard } from "../types";
|
||||
import { useHitlDecision } from "../use-hitl-decision";
|
||||
import { useHitlPhase } from "../use-hitl-phase";
|
||||
|
||||
|
|
@ -248,7 +248,7 @@ function GenericApprovalCardView({
|
|||
* guard; this card receives a known ``InterruptResult`` and skips the
|
||||
* defensive runtime check.
|
||||
*/
|
||||
export const GenericHitlApproval: HitlApprovalCard = ({ toolName, args, result }) => {
|
||||
export const GenericHitlApproval: PerToolApprovalCard = ({ toolName, args, result }) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
return (
|
||||
<GenericApprovalCardView
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import type { HitlDecision } from "../types";
|
||||
|
||||
/**
|
||||
* Decisions are keyed by step index (not toolCallId) because the
|
||||
* resume protocol is positional — backend pairs ``decisions[i]`` with
|
||||
* ``action_requests[i]``. ``stage`` always targets the active step,
|
||||
* so per-tool bodies stay tcId-agnostic.
|
||||
*/
|
||||
export interface HitlApprovalAPI {
|
||||
total: number;
|
||||
currentStep: number;
|
||||
decisions: ReadonlyArray<HitlDecision | undefined>;
|
||||
stage: (decision: HitlDecision) => void;
|
||||
next: () => void;
|
||||
prev: () => void;
|
||||
goToStep: (i: number) => void;
|
||||
canAdvance: boolean;
|
||||
canSubmit: boolean;
|
||||
}
|
||||
|
||||
export const HitlApprovalContext = createContext<HitlApprovalAPI | null>(null);
|
||||
|
||||
export function useHitlApproval(): HitlApprovalAPI | null {
|
||||
return useContext(HitlApprovalContext);
|
||||
}
|
||||
|
|
@ -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<string, unknown>,
|
||||
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<InterruptResult>),
|
||||
__interrupt__: true,
|
||||
action_requests: [baseAction],
|
||||
review_configs: reviewConfig ? [reviewConfig] : [],
|
||||
} as InterruptResult;
|
||||
|
||||
if (stagedDecision) {
|
||||
(sliced as unknown as Record<string, unknown>).__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<string, unknown>;
|
||||
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<HitlApprovalAPI>(
|
||||
() => ({
|
||||
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<string, unknown>,
|
||||
argsText: undefined,
|
||||
result: sliced,
|
||||
langchainToolCallId: undefined,
|
||||
status: stagedDecision ? "completed" : "running",
|
||||
};
|
||||
|
||||
return (
|
||||
<HitlApprovalContext.Provider value={api}>
|
||||
<div className="space-y-2">
|
||||
<div key={`approval-step-${currentStep}`}>
|
||||
<Body {...bodyProps} />
|
||||
</div>
|
||||
{isMulti && (
|
||||
<PagerBar
|
||||
currentStep={currentStep}
|
||||
total={total}
|
||||
stagedCount={stagedCount}
|
||||
canAdvance={canAdvance}
|
||||
canSubmit={canSubmit}
|
||||
actionName={action.name}
|
||||
onPrev={prev}
|
||||
onNext={next}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</HitlApprovalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
}) => (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-2 py-1.5 text-sm">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onPrev}
|
||||
disabled={currentStep === 0}
|
||||
aria-label="Previous approval"
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="font-medium tabular-nums">
|
||||
{currentStep + 1} / {total}
|
||||
</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="text-muted-foreground">
|
||||
{stagedCount} of {total} decided
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onNext}
|
||||
disabled={!canAdvance || currentStep >= total - 1}
|
||||
aria-label="Next approval"
|
||||
title={!canAdvance ? "Decide on this action first" : undefined}
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="ml-2 truncate text-xs text-muted-foreground" title={actionName}>
|
||||
{getToolDisplayName(actionName)}
|
||||
</span>
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={onSubmit}
|
||||
disabled={!canSubmit}
|
||||
title={canSubmit ? "Submit decisions" : "Decide every action first"}
|
||||
>
|
||||
Submit decisions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -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";
|
||||
|
|
@ -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<string, unknown>;
|
||||
bundleToolCallIds: string[];
|
||||
}
|
||||
|
||||
export interface PendingInterruptValue {
|
||||
pendingInterrupt: PendingInterruptState | null;
|
||||
onSubmit: (decisions: HitlDecision[]) => void;
|
||||
}
|
||||
|
||||
const PendingInterruptContext = createContext<PendingInterruptValue | null>(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 (
|
||||
<PendingInterruptContext.Provider value={{ pendingInterrupt, onSubmit }}>
|
||||
{children}
|
||||
</PendingInterruptContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePendingInterrupt(): PendingInterruptValue | null {
|
||||
return useContext(PendingInterruptContext);
|
||||
}
|
||||
|
|
@ -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<HitlBundleAPI | null>(null);
|
||||
const ToolCallIdContext = createContext<string | null>(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 <ToolCallIdContext.Provider value={toolCallId}>{children}</ToolCallIdContext.Provider>;
|
||||
}
|
||||
|
||||
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
|
||||
* ``<PagerChrome />`` 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<Map<string, HitlDecision>>(() => 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<HitlBundleAPI | null>(() => {
|
||||
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 <HitlBundleContext.Provider value={value}>{children}</HitlBundleContext.Provider>;
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
export type { BundleSubmit, HitlBundleAPI } from "./bundle-context";
|
||||
export {
|
||||
HitlBundleProvider,
|
||||
ToolCallIdProvider,
|
||||
useHitlBundle,
|
||||
useToolCallIdContext,
|
||||
} from "./bundle-context";
|
||||
export { PagerChrome } from "./pager-chrome";
|
||||
|
|
@ -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 (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md border border-border bg-muted/40 p-2 text-sm">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={bundle.prev}
|
||||
disabled={step === 0}
|
||||
aria-label="Previous approval"
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="font-medium tabular-nums">
|
||||
{step + 1} / {total}
|
||||
</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="text-muted-foreground">
|
||||
{bundle.stagedCount} of {total} decided
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={bundle.next}
|
||||
disabled={step >= total - 1}
|
||||
aria-label="Next approval"
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={bundle.submit}
|
||||
disabled={!allStaged}
|
||||
title={allStaged ? "Submit decisions" : "Decide every action first"}
|
||||
>
|
||||
Submit decisions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string> {
|
||||
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<string>();
|
||||
for (const tc of slims) {
|
||||
if (!hasInterruptMarker(tc.result)) continue;
|
||||
if (tc.spanId) interruptedSpans.add(tc.spanId);
|
||||
}
|
||||
|
||||
const superseded = new Set<string>();
|
||||
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<string> {
|
||||
const toolCallParts: ToolCallPart[] = [];
|
||||
for (const part of content) {
|
||||
if (isToolCallPart(part)) toolCallParts.push(part);
|
||||
}
|
||||
|
||||
const interruptedSpans = new Set<string>();
|
||||
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<string>();
|
||||
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<string, ToolCallPart>();
|
||||
const supersededStepIds = new Set<string>();
|
||||
const consumedToolCallIds = new Set<string>();
|
||||
const supersededToolCallIds = content
|
||||
? collectSupersededToolCallIds(content)
|
||||
: new Set<string>();
|
||||
const superseded = content ? collectSupersededToolCallIds(content) : new Set<string>();
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<ThinkingStepInput[]>(
|
||||
() => (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 (
|
||||
<div className="mb-3 -mx-2 leading-normal">
|
||||
<Timeline items={items} isThreadRunning={isMessageStreaming} />
|
||||
<PendingInterruptProvider pendingInterrupt={pendingForThisMessage} onSubmit={onSubmit}>
|
||||
<Timeline items={items} isThreadRunning={isMessageStreaming} />
|
||||
</PendingInterruptProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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<string, TimelineGroup>();
|
||||
|
||||
for (const item of items) {
|
||||
const sid = item.spanId;
|
||||
if (!sid) {
|
||||
if (!sid || !spanOwners.has(sid)) {
|
||||
groups.push({ parent: item, children: [] });
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<ItemHeader title={title} status={item.status} items={item.items} itemKey={item.id} />
|
||||
{!hideForBundle && (
|
||||
<ToolCallIdProvider toolCallId={item.toolCallId}>
|
||||
<Body {...props} />
|
||||
</ToolCallIdProvider>
|
||||
)}
|
||||
<Body {...props} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<TimelineItem[]>(
|
||||
() =>
|
||||
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<{
|
|||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="mt-3 pl-1">
|
||||
{groups.map((group, groupIndex) => (
|
||||
<TimelineGroupRow
|
||||
key={group.parent.id}
|
||||
group={group}
|
||||
parentStatus={group.parent.status}
|
||||
showParentLine={groupIndex < groups.length - 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{bundle && <PagerChrome />}
|
||||
{groups.map((group, idx) => {
|
||||
const showLine = idx < groups.length - 1 || hasPending;
|
||||
return (
|
||||
<TimelineGroupRow
|
||||
key={group.parent.id}
|
||||
group={group}
|
||||
parentStatus={group.parent.status}
|
||||
showParentLine={showLine}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{pendingInterrupt && onSubmit && (
|
||||
<div className="pl-5">
|
||||
<HitlApprovalCard pendingInterrupt={pendingInterrupt} onSubmit={onSubmit} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
argsText?: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue