chat: unify HITL approval UX behind a single paginated card and harden timeline supersede.

This commit is contained in:
CREDO23 2026-05-09 21:44:54 +02:00
parent 89e4953800
commit 2e132513be
25 changed files with 604 additions and 1157 deletions

View file

@ -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] = []

View file

@ -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": []}

View file

@ -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] = []

View file

@ -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

View file

@ -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

View file

@ -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": []}

View file

@ -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) => {

View file

@ -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

View file

@ -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

View file

@ -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);
}

View file

@ -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>
);

View file

@ -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";

View file

@ -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);
}

View file

@ -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>;
}

View file

@ -1,8 +0,0 @@
export type { BundleSubmit, HitlBundleAPI } from "./bundle-context";
export {
HitlBundleProvider,
ToolCallIdProvider,
useHitlBundle,
useToolCallIdContext,
} from "./bundle-context";
export { PagerChrome } from "./pager-chrome";

View file

@ -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>
);
}

View file

@ -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";

View file

@ -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;

View file

@ -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** (N2 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 };

View file

@ -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,

View file

@ -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,

View file

@ -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;
}

View file

@ -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} />
</>
);
};

View file

@ -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>

View file

@ -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;