Merge pull request #1357 from CREDO23/feature/multi-agent

[Feature] Multi-agent chat: hierarchical timeline, live subagent streaming, and inline HITL approvals
This commit is contained in:
Rohan Verma 2026-05-09 16:13:04 -07:00 committed by GitHub
commit 28a02a9143
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
232 changed files with 9014 additions and 4055 deletions

View file

@ -43,13 +43,14 @@ import {
type EditMessageDialogChoice,
} from "@/components/assistant-ui/edit-message-dialog";
import { StepSeparatorDataUI } from "@/components/assistant-ui/step-separator";
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
import { Thread } from "@/components/assistant-ui/thread";
import {
createTokenUsageStore,
type TokenUsageData,
TokenUsageProvider,
} from "@/components/assistant-ui/token-usage-context";
import { type HitlDecision, PendingInterruptProvider } from "@/features/chat-messages/hitl";
import { TimelineDataUI } from "@/features/chat-messages/timeline";
import {
applyActionLogSse,
applyActionLogUpdatedSse,
@ -63,7 +64,10 @@ import { documentsApiService } from "@/lib/apis/documents-api.service";
import { getBearerToken } from "@/lib/auth-utils";
import { type ChatFlow, classifyChatError } from "@/lib/chat/chat-error-classifier";
import { tagPreAcceptSendFailure, toHttpResponseError } from "@/lib/chat/chat-request-errors";
import { convertToThreadMessage } from "@/lib/chat/message-utils";
import {
convertToThreadMessage,
reconcileInterruptedAssistantMessages,
} from "@/lib/chat/message-utils";
import {
isPodcastGenerating,
looksLikePodcastRequest,
@ -107,7 +111,6 @@ import {
type NewChatUserImagePayload,
} from "@/lib/chat/user-turn-api-parts";
import { NotFoundError } from "@/lib/error";
import { type BundleSubmit, HitlBundleProvider } from "@/lib/hitl";
import {
trackChatBlocked,
trackChatCreated,
@ -126,7 +129,7 @@ const MobileEditorPanel = dynamic(
);
const MobileHitlEditPanel = dynamic(
() =>
import("@/components/hitl-edit-panel/hitl-edit-panel").then((m) => ({
import("@/features/chat-messages/hitl").then((m) => ({
default: m.MobileHitlEditPanel,
})),
{ ssr: false }
@ -395,7 +398,7 @@ export default function NewChatPage() {
const memberById = new Map(membersData?.map((m) => [m.user_id, m]) ?? []);
const prevById = new Map(prev.map((m) => [m.id, m]));
return syncedMessages.map((msg) => {
return reconcileInterruptedAssistantMessages(syncedMessages).map((msg) => {
const member = msg.author_id ? (memberById.get(msg.author_id) ?? null) : null;
// Preserve existing author info if member lookup fails (e.g., cloned chats)
@ -622,7 +625,9 @@ export default function NewChatPage() {
setCurrentThread(threadData);
if (messagesResponse.messages && messagesResponse.messages.length > 0) {
const loadedMessages = messagesResponse.messages.map(convertToThreadMessage);
const loadedMessages = reconcileInterruptedAssistantMessages(
messagesResponse.messages
).map(convertToThreadMessage);
setMessages(loadedMessages);
for (const msg of messagesResponse.messages) {
@ -1388,6 +1393,8 @@ export default function NewChatPage() {
const existingMsg = messages.find((m) => m.id === assistantMsgId);
if (existingMsg && Array.isArray(existingMsg.content)) {
// See ``ContentPartsState.suppressStepSeparators`` doc.
contentPartsState.suppressStepSeparators = true;
for (const part of existingMsg.content) {
if (typeof part === "object" && part !== null) {
const p = part as Record<string, unknown>;
@ -1402,15 +1409,19 @@ export default function NewChatPage() {
toolName: String(p.toolName),
args: (p.args as Record<string, unknown>) ?? {},
result: p.result as unknown,
// Restore argsText so persisted pretty-printed
// JSON survives reloads (assistant-ui prefers
// supplied argsText over JSON.stringify(args)).
// langchainToolCallId restoration also fixes a
// pre-existing dropped-id bug on resume.
// argsText: assistant-ui prefers it over
// JSON.stringify(args), so restoring it keeps
// pretty-printed JSON across reloads.
...(typeof p.argsText === "string" ? { argsText: p.argsText } : {}),
...(typeof p.langchainToolCallId === "string"
? { langchainToolCallId: p.langchainToolCallId }
: {}),
// metadata: spanId / thinkingStepId drive the
// timeline's step↔tool join. Dropping these
// here orphans every rehydrated tool-call.
...(p.metadata && typeof p.metadata === "object"
? { metadata: p.metadata as Record<string, unknown> }
: {}),
});
contentPartsState.currentTextPartIndex = -1;
} else if (p.type === "data-thinking-steps") {
@ -1730,57 +1741,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,
@ -2279,7 +2239,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 } })
);
@ -2353,11 +2313,11 @@ export default function NewChatPage() {
return (
<TokenUsageProvider store={tokenUsageStore}>
<AssistantRuntimeProvider runtime={runtime}>
<ThinkingStepsDataUI />
<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">
@ -2367,7 +2327,7 @@ export default function NewChatPage() {
<MobileEditorPanel />
<MobileHitlEditPanel />
</div>
</HitlBundleProvider>
</PendingInterruptProvider>
<EditMessageDialog
open={editDialogState !== null}
onOpenChange={(open) => {

View file

@ -4,6 +4,7 @@ import {
AuiIf,
ErrorPrimitive,
MessagePrimitive,
type ToolCallMessagePartComponent,
useAui,
useAuiState,
} from "@assistant-ui/react";
@ -36,11 +37,9 @@ import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { ReasoningMessagePart } from "@/components/assistant-ui/reasoning-message-part";
import { RevertTurnButton } from "@/components/assistant-ui/revert-turn-button";
import { useTokenUsage } from "@/components/assistant-ui/token-usage-context";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
import { withBundleStep } from "@/components/hitl-bundle-pager";
import type { SerializableCitation } from "@/components/tool-ui/citation";
import {
openSafeNavigationHref,
@ -100,146 +99,6 @@ const GenerateImageToolUI = dynamic(
import("@/components/tool-ui/generate-image").then((m) => ({ default: m.GenerateImageToolUI })),
{ ssr: false }
);
const UpdateMemoryToolUI = dynamic(
() => import("@/components/tool-ui/user-memory").then((m) => ({ default: m.UpdateMemoryToolUI })),
{ ssr: false }
);
const SandboxExecuteToolUI = dynamic(
() =>
import("@/components/tool-ui/sandbox-execute").then((m) => ({
default: m.SandboxExecuteToolUI,
})),
{ ssr: false }
);
const CreateNotionPageToolUI = dynamic(
() => import("@/components/tool-ui/notion").then((m) => ({ default: m.CreateNotionPageToolUI })),
{ ssr: false }
);
const UpdateNotionPageToolUI = dynamic(
() => import("@/components/tool-ui/notion").then((m) => ({ default: m.UpdateNotionPageToolUI })),
{ ssr: false }
);
const DeleteNotionPageToolUI = dynamic(
() => import("@/components/tool-ui/notion").then((m) => ({ default: m.DeleteNotionPageToolUI })),
{ ssr: false }
);
const CreateLinearIssueToolUI = dynamic(
() => import("@/components/tool-ui/linear").then((m) => ({ default: m.CreateLinearIssueToolUI })),
{ ssr: false }
);
const UpdateLinearIssueToolUI = dynamic(
() => import("@/components/tool-ui/linear").then((m) => ({ default: m.UpdateLinearIssueToolUI })),
{ ssr: false }
);
const DeleteLinearIssueToolUI = dynamic(
() => import("@/components/tool-ui/linear").then((m) => ({ default: m.DeleteLinearIssueToolUI })),
{ ssr: false }
);
const CreateGoogleDriveFileToolUI = dynamic(
() =>
import("@/components/tool-ui/google-drive").then((m) => ({
default: m.CreateGoogleDriveFileToolUI,
})),
{ ssr: false }
);
const DeleteGoogleDriveFileToolUI = dynamic(
() =>
import("@/components/tool-ui/google-drive").then((m) => ({
default: m.DeleteGoogleDriveFileToolUI,
})),
{ ssr: false }
);
const CreateOneDriveFileToolUI = dynamic(
() =>
import("@/components/tool-ui/onedrive").then((m) => ({ default: m.CreateOneDriveFileToolUI })),
{ ssr: false }
);
const DeleteOneDriveFileToolUI = dynamic(
() =>
import("@/components/tool-ui/onedrive").then((m) => ({ default: m.DeleteOneDriveFileToolUI })),
{ ssr: false }
);
const CreateDropboxFileToolUI = dynamic(
() =>
import("@/components/tool-ui/dropbox").then((m) => ({ default: m.CreateDropboxFileToolUI })),
{ ssr: false }
);
const DeleteDropboxFileToolUI = dynamic(
() =>
import("@/components/tool-ui/dropbox").then((m) => ({ default: m.DeleteDropboxFileToolUI })),
{ ssr: false }
);
const CreateCalendarEventToolUI = dynamic(
() =>
import("@/components/tool-ui/google-calendar").then((m) => ({
default: m.CreateCalendarEventToolUI,
})),
{ ssr: false }
);
const UpdateCalendarEventToolUI = dynamic(
() =>
import("@/components/tool-ui/google-calendar").then((m) => ({
default: m.UpdateCalendarEventToolUI,
})),
{ ssr: false }
);
const DeleteCalendarEventToolUI = dynamic(
() =>
import("@/components/tool-ui/google-calendar").then((m) => ({
default: m.DeleteCalendarEventToolUI,
})),
{ ssr: false }
);
const CreateGmailDraftToolUI = dynamic(
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.CreateGmailDraftToolUI })),
{ ssr: false }
);
const UpdateGmailDraftToolUI = dynamic(
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.UpdateGmailDraftToolUI })),
{ ssr: false }
);
const SendGmailEmailToolUI = dynamic(
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.SendGmailEmailToolUI })),
{ ssr: false }
);
const TrashGmailEmailToolUI = dynamic(
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.TrashGmailEmailToolUI })),
{ ssr: false }
);
const CreateJiraIssueToolUI = dynamic(
() => import("@/components/tool-ui/jira").then((m) => ({ default: m.CreateJiraIssueToolUI })),
{ ssr: false }
);
const UpdateJiraIssueToolUI = dynamic(
() => import("@/components/tool-ui/jira").then((m) => ({ default: m.UpdateJiraIssueToolUI })),
{ ssr: false }
);
const DeleteJiraIssueToolUI = dynamic(
() => import("@/components/tool-ui/jira").then((m) => ({ default: m.DeleteJiraIssueToolUI })),
{ ssr: false }
);
const CreateConfluencePageToolUI = dynamic(
() =>
import("@/components/tool-ui/confluence").then((m) => ({
default: m.CreateConfluencePageToolUI,
})),
{ ssr: false }
);
const UpdateConfluencePageToolUI = dynamic(
() =>
import("@/components/tool-ui/confluence").then((m) => ({
default: m.UpdateConfluencePageToolUI,
})),
{ ssr: false }
);
const DeleteConfluencePageToolUI = dynamic(
() =>
import("@/components/tool-ui/confluence").then((m) => ({
default: m.DeleteConfluencePageToolUI,
})),
{ ssr: false }
);
function extractDomain(url: string): string | undefined {
try {
return new URL(url).hostname.replace(/^www\./, "");
@ -503,50 +362,26 @@ const MessageInfoDropdown: FC = () => {
);
};
// Wrap each tool-ui card with ``withBundleStep`` so multi-card HITL bundles
// page through them and stage decisions instead of firing one resume per card.
const TOOLS_BY_NAME = {
generate_report: withBundleStep(GenerateReportToolUI),
generate_resume: withBundleStep(GenerateResumeToolUI),
generate_podcast: withBundleStep(GeneratePodcastToolUI),
generate_video_presentation: withBundleStep(GenerateVideoPresentationToolUI),
display_image: withBundleStep(GenerateImageToolUI),
generate_image: withBundleStep(GenerateImageToolUI),
update_memory: withBundleStep(UpdateMemoryToolUI),
execute: withBundleStep(SandboxExecuteToolUI),
execute_code: withBundleStep(SandboxExecuteToolUI),
create_notion_page: withBundleStep(CreateNotionPageToolUI),
update_notion_page: withBundleStep(UpdateNotionPageToolUI),
delete_notion_page: withBundleStep(DeleteNotionPageToolUI),
create_linear_issue: withBundleStep(CreateLinearIssueToolUI),
update_linear_issue: withBundleStep(UpdateLinearIssueToolUI),
delete_linear_issue: withBundleStep(DeleteLinearIssueToolUI),
create_google_drive_file: withBundleStep(CreateGoogleDriveFileToolUI),
delete_google_drive_file: withBundleStep(DeleteGoogleDriveFileToolUI),
create_onedrive_file: withBundleStep(CreateOneDriveFileToolUI),
delete_onedrive_file: withBundleStep(DeleteOneDriveFileToolUI),
create_dropbox_file: withBundleStep(CreateDropboxFileToolUI),
delete_dropbox_file: withBundleStep(DeleteDropboxFileToolUI),
create_calendar_event: withBundleStep(CreateCalendarEventToolUI),
update_calendar_event: withBundleStep(UpdateCalendarEventToolUI),
delete_calendar_event: withBundleStep(DeleteCalendarEventToolUI),
create_gmail_draft: withBundleStep(CreateGmailDraftToolUI),
update_gmail_draft: withBundleStep(UpdateGmailDraftToolUI),
send_gmail_email: withBundleStep(SendGmailEmailToolUI),
trash_gmail_email: withBundleStep(TrashGmailEmailToolUI),
create_jira_issue: withBundleStep(CreateJiraIssueToolUI),
update_jira_issue: withBundleStep(UpdateJiraIssueToolUI),
delete_jira_issue: withBundleStep(DeleteJiraIssueToolUI),
create_confluence_page: withBundleStep(CreateConfluencePageToolUI),
update_confluence_page: withBundleStep(UpdateConfluencePageToolUI),
delete_confluence_page: withBundleStep(DeleteConfluencePageToolUI),
web_search: () => null,
link_preview: () => null,
multi_link_preview: () => null,
scrape_webpage: () => null,
/**
* Tools rendered in the message BODY value-add deliverables only.
*
* Process tools (connector CRUD, sandbox execute, memory updates,
* etc.) are NOT here; they render in the timeline via the slice's
* tool registry (see ``features/chat-messages/timeline``). The body
* opts out of every other tool by registering ``NullBodyTool`` as the
* fallback any tool name not in this map renders nothing in the
* body and is picked up by the timeline instead.
*/
const BODY_TOOLS = {
generate_report: GenerateReportToolUI,
generate_resume: GenerateResumeToolUI,
generate_podcast: GeneratePodcastToolUI,
generate_video_presentation: GenerateVideoPresentationToolUI,
display_image: GenerateImageToolUI,
generate_image: GenerateImageToolUI,
} as const;
const TOOLS_FALLBACK = withBundleStep(ToolFallback);
const NullBodyTool: ToolCallMessagePartComponent = () => null;
const AssistantMessageInner: FC = () => {
const isMobile = !useMediaQuery("(min-width: 768px)");
@ -559,8 +394,8 @@ const AssistantMessageInner: FC = () => {
Text: MarkdownText,
Reasoning: ReasoningMessagePart,
tools: {
by_name: TOOLS_BY_NAME,
Fallback: TOOLS_FALLBACK,
by_name: BODY_TOOLS,
Fallback: NullBodyTool,
},
}}
/>

View file

@ -7,8 +7,8 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { cn } from "@/lib/utils";
/**
* Renders the structured `reasoning` part emitted by the backend's
* stream-parity v2 path (A1).
* Renders the structured `reasoning` part emitted by the backend stream
* (typed reasoning deltas from the chat model).
*
* Behaviour mirrors the existing `ThinkingStepsDisplay`:
* - collapsed by default;

View file

@ -1,175 +0,0 @@
import { makeAssistantDataUI, useAuiState } from "@assistant-ui/react";
import { ChevronRightIcon } from "lucide-react";
import type { FC } from "react";
import { useCallback, useEffect, useState } from "react";
import { ChainOfThoughtItem } from "@/components/prompt-kit/chain-of-thought";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { cn } from "@/lib/utils";
export interface ThinkingStep {
id: string;
title: string;
items: string[];
status: "pending" | "in_progress" | "completed";
}
/**
* Chain of thought display component - single collapsible dropdown design
*/
export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolean }> = ({
steps,
isThreadRunning = true,
}) => {
const getEffectiveStatus = useCallback(
(step: ThinkingStep): "pending" | "in_progress" | "completed" => {
if (step.status === "in_progress" && !isThreadRunning) {
return "completed";
}
return step.status;
},
[isThreadRunning]
);
const inProgressStep = steps.find((s) => getEffectiveStatus(s) === "in_progress");
const allCompleted =
steps.length > 0 &&
!isThreadRunning &&
steps.every((s) => getEffectiveStatus(s) === "completed");
const isProcessing = isThreadRunning && !allCompleted;
const [isOpen, setIsOpen] = useState(() => isProcessing);
useEffect(() => {
if (isProcessing) {
setIsOpen(true);
return;
}
if (allCompleted) {
setIsOpen(false);
}
}, [allCompleted, isProcessing]);
if (steps.length === 0) return null;
const getHeaderText = () => {
if (allCompleted) {
return "Reviewed";
}
if (inProgressStep) {
return inProgressStep.title;
}
if (isProcessing) {
return "Processing";
}
return "Reviewed";
};
return (
<div className="mx-auto w-full max-w-(--thread-max-width) px-2 py-2">
<div className="rounded-lg">
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className={cn(
"flex w-full items-center gap-1.5 text-left text-sm transition-colors",
"text-muted-foreground hover:text-foreground"
)}
>
{isProcessing ? (
<TextShimmerLoader text={getHeaderText()} size="sm" />
) : (
<span>{getHeaderText()}</span>
)}
<ChevronRightIcon
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-90")}
/>
</button>
<div
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-out",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
<div className="mt-3 pl-1">
{steps.map((step, index) => {
const effectiveStatus = getEffectiveStatus(step);
const isLast = index === steps.length - 1;
return (
<div key={step.id} className="relative flex gap-3">
<div className="relative flex flex-col items-center w-2">
{!isLast && (
<div className="absolute left-1/2 top-[15px] -bottom-[7px] w-px -translate-x-1/2 bg-muted-foreground/30" />
)}
<div className="relative z-10 mt-[7px] flex shrink-0 items-center justify-center">
{effectiveStatus === "in_progress" ? (
<span className="relative flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary/60" />
<span className="relative inline-flex size-2 rounded-full bg-primary" />
</span>
) : (
<span className="size-2 rounded-full bg-muted-foreground/30" />
)}
</div>
</div>
<div className="flex-1 min-w-0 pb-4">
<div
className={cn(
"text-sm leading-5",
effectiveStatus === "in_progress" && "text-foreground font-medium",
effectiveStatus === "completed" && "text-muted-foreground",
effectiveStatus === "pending" && "text-muted-foreground/60"
)}
>
{step.title}
</div>
{step.items && step.items.length > 0 && (
<div className="mt-1 space-y-0.5">
{step.items.map((item) => (
<ChainOfThoughtItem key={`${step.id}-${item}`} className="text-xs">
{item}
</ChainOfThoughtItem>
))}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
);
};
/**
* assistant-ui data UI component that renders thinking steps from message content.
* Registered globally via makeAssistantDataUI renders inside MessagePrimitive.Parts
* at the position of the data part in the content array.
*/
function ThinkingStepsDataRenderer({ data }: { name: string; data: unknown }) {
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
const isLastMessage = useAuiState(({ message }) => message?.isLast ?? false);
const isMessageStreaming = isThreadRunning && isLastMessage;
const steps = (data as { steps: ThinkingStep[] } | null)?.steps ?? [];
if (steps.length === 0) return null;
return (
<div className="mb-3 -mx-2 leading-normal">
<ThinkingStepsDisplay steps={steps} isThreadRunning={isMessageStreaming} />
</div>
);
}
export const ThinkingStepsDataUI = makeAssistantDataUI({
name: "thinking-steps",
render: ThinkingStepsDataRenderer,
});

View file

@ -1,512 +0,0 @@
import { type ToolCallMessagePartComponent, useAuiState } from "@assistant-ui/react";
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { CheckIcon, ChevronDownIcon, RotateCcw, XCircleIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import { NestedScroll } from "@/components/assistant-ui/nested-scroll";
import {
DoomLoopApprovalToolUI,
isDoomLoopInterrupt,
} from "@/components/tool-ui/doom-loop-approval";
import { GenericHitlApprovalToolUI } from "@/components/tool-ui/generic-hitl-approval";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
import { markActionRevertedInCache, useAgentActionsQuery } from "@/hooks/use-agent-actions-query";
import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
import { AppError } from "@/lib/error";
import { isInterruptResult } from "@/lib/hitl";
import { cn } from "@/lib/utils";
/**
* Inline Revert button rendered on a tool card when the matching
* ``AgentActionLog`` row is reversible and hasn't been reverted yet.
*
* Reads from the unified ``useAgentActionsQuery`` cache the SAME
* react-query cache the agent-actions sheet consumes. SSE events
* (``data-action-log`` / ``data-action-log-updated``) and
* ``POST /threads/{id}/revert/{id}`` responses both flow through the
* cache via ``setQueryData`` helpers, so the card and the sheet stay
* in lockstep on every code path: page reload, navigation, live
* stream, post-stream reversibility flip, and explicit revert clicks.
*
* Match key (in priority order):
* 1. ``a.tool_call_id === toolCallId`` direct hit in parity_v2 when
* the model streamed ``tool_call_chunks`` so the card's synthetic
* id IS the LangChain id.
* 2. ``a.tool_call_id === langchainToolCallId`` legacy mode (or
* parity_v2 with provider-side chunk emission) where the card's
* synthetic id is ``call_<run_id>`` and the LangChain id is
* backfilled onto the part by ``tool-output-available``.
* 3. ``(chat_turn_id, tool_name, position-within-turn)`` fallback
* for cards whose synthetic id is ``call_<run_id>`` AND whose
* ``langchainToolCallId`` never got backfilled (provider emitted
* the tool_call as a single payload with no chunks AND streaming
* pre-dated the ``tool-output-available langchainToolCallId``
* backfill, e.g. older threads). Reads the parent message's
* ``chatTurnId`` and ``content`` via ``useAuiState`` so we can
* match position-by-tool-name within the turn against the
* action_log rows the server returned in ``created_at`` order.
*/
function ToolCardRevertButton({
toolCallId,
toolName,
langchainToolCallId,
}: {
toolCallId: string;
toolName: string;
langchainToolCallId?: string;
}) {
const session = useAtomValue(chatSessionStateAtom);
const threadId = session?.threadId ?? null;
const queryClient = useQueryClient();
const { findByToolCallId, findByChatTurnAndTool } = useAgentActionsQuery(threadId);
// Parent message metadata, read via the narrowest possible
// selectors so this card doesn't re-render on every text-delta of
// every other part in the same message during streaming.
//
// IMPORTANT — ``useAuiState`` re-renders the component whenever the
// returned slice's identity changes. Returning ``message?.content``
// (an array) would re-render on every token because the runtime
// rebuilds the parts array. Returning a PRIMITIVE (the position
// number) lets ``useAuiState``'s ``Object.is`` check short-circuit
// when the position hasn't actually moved — which is the common
// case during text streaming, when only ``text``/``reasoning``
// parts are mutating and the same-toolName tool-call ordering is
// stable. (See Vercel React rule ``rerender-defer-reads``.)
const chatTurnId = useAuiState(({ message }) => {
const meta = message?.metadata as { custom?: { chatTurnId?: string } } | undefined;
return meta?.custom?.chatTurnId ?? null;
});
const positionInTurn = useAuiState(({ message }) => {
const content = message?.content;
if (!Array.isArray(content)) return -1;
let n = -1;
for (const part of content) {
if (
part &&
typeof part === "object" &&
(part as { type?: string }).type === "tool-call" &&
(part as { toolName?: string }).toolName === toolName
) {
n += 1;
if ((part as { toolCallId?: string }).toolCallId === toolCallId) return n;
}
}
return -1;
});
const action = useMemo(() => {
// Tier 1 + 2: O(1) Map-backed direct id match. Covers
// ~all parity_v2 streams and any legacy stream that backfilled
// ``langchainToolCallId`` via ``tool-output-available``.
const direct = findByToolCallId(toolCallId) ?? findByToolCallId(langchainToolCallId);
if (direct) return direct;
// Tier 3: position-within-turn fallback. Only kicks in when the
// card has a synthetic ``call_<run_id>`` id AND no
// ``langchainToolCallId`` was ever backfilled — i.e. the tool
// was emitted as a single non-chunked payload AND streaming
// pre-dated the on_tool_end backfill.
if (!chatTurnId || positionInTurn < 0) return null;
const turnSameTool = findByChatTurnAndTool(chatTurnId, toolName);
return turnSameTool[positionInTurn] ?? null;
}, [
findByToolCallId,
findByChatTurnAndTool,
toolCallId,
langchainToolCallId,
chatTurnId,
toolName,
positionInTurn,
]);
const [isReverting, setIsReverting] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
if (!action) return null;
if (!action.reversible) return null;
if (action.reverted_by_action_id !== null && action.reverted_by_action_id !== undefined)
return null;
if (action.is_revert_action) return null;
if (action.error !== null && action.error !== undefined) return null;
if (!threadId) return null;
const handleRevert = async () => {
setIsReverting(true);
try {
const response = await agentActionsApiService.revert(threadId, action.id);
markActionRevertedInCache(queryClient, threadId, action.id, response.new_action_id ?? null);
toast.success(response.message || "Action reverted.");
} catch (err) {
// 503 means revert is gated off on this deployment — hide the
// button silently rather than nagging the user. Any other error
// is surfaced as a toast so the operator can investigate.
if (err instanceof AppError && err.status === 503) {
return;
}
const message =
err instanceof AppError
? err.message
: err instanceof Error
? err.message
: "Failed to revert action.";
toast.error(message);
} finally {
setIsReverting(false);
setConfirmOpen(false);
}
};
return (
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="outline"
className="gap-1.5"
onClick={(e) => {
e.stopPropagation();
setConfirmOpen(true);
}}
disabled={isReverting}
>
{isReverting ? (
// Spinner's typed props don't accept ``data-icon`` and
// it renders an <output>, not an <svg>, so Button's
// auto-sizing rule doesn't apply. Bare spinner +
// Button's gap handle layout.
<Spinner size="xs" />
) : (
<RotateCcw data-icon="inline-start" />
)}
Revert
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revert this action?</AlertDialogTitle>
<AlertDialogDescription>
This will undo{" "}
<span className="font-medium">{getToolDisplayName(action.tool_name)}</span> and add a
new entry to the history. Your chat is preserved only the changes the agent made to
your knowledge base or connected apps will be rolled back where possible.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isReverting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleRevert();
}}
disabled={isReverting}
className="gap-1.5"
>
{isReverting && <Spinner size="xs" />}
Revert
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
/**
* Compact tool-call card.
*
* shadcn composition note: we intentionally use ``Card`` as a visual
* frame WITHOUT ``CardHeader / CardContent``. The full composition's
* ``p-6`` padding doesn't fit a compact collapsible header that IS the
* trigger; using ``Card`` alone preserves the rounded border, shadow,
* and ``bg-card`` token (semantic colors) without forcing a layout
* that doesn't fit. All status colors use semantic tokens no manual
* dark-mode overrides, no raw hex.
*/
const DefaultToolFallbackInner: ToolCallMessagePartComponent = (props) => {
const { toolCallId, toolName, argsText, result, status } = props;
// ``langchainToolCallId`` is a SurfSense-specific extension the
// streaming pipeline attaches to the tool-call content part so
// the Revert button can resolve its ``AgentActionLog`` row even
// when only the LC id is known. assistant-ui's
// ``ToolCallMessagePartProps`` doesn't list it, but the runtime
// spreads ``{...part}`` so the prop reaches us at runtime.
const langchainToolCallId = (props as { langchainToolCallId?: string }).langchainToolCallId;
const isCancelled = status?.type === "incomplete" && status.reason === "cancelled";
const isError = status?.type === "incomplete" && status.reason === "error";
const isRunning = status?.type === "running" || status?.type === "requires-action";
/*
Per-card expansion state. Initial value is ``isRunning`` so a
card streaming in mounts already-expanded (no flash of
collapsed expanded on first paint), while a card loaded from
history (status="complete") mounts collapsed. The useEffect
below keeps this in lockstep with this card's own ``isRunning``
when it transitions: false true auto-expands (e.g. a tool
that re-runs after edit), true false auto-collapses once the
tool finishes. Because the dep is per-card ``isRunning`` and
not the chat-level streaming flag, sibling cards on the same
assistant turn each manage their own expansion independently.
Once ``isRunning`` is false the user controls expansion via
``onOpenChange``.
*/
const [isExpanded, setIsExpanded] = useState(isRunning);
useEffect(() => {
setIsExpanded(isRunning);
}, [isRunning]);
const errorData = status?.type === "incomplete" ? status.error : undefined;
const serializedError = useMemo(
() => (errorData && typeof errorData !== "string" ? JSON.stringify(errorData) : null),
[errorData]
);
const serializedResult = useMemo(
() =>
result !== undefined && typeof result !== "string" ? JSON.stringify(result, null, 2) : null,
[result]
);
const cancelledReason =
isCancelled && status.error
? typeof status.error === "string"
? status.error
: serializedError
: null;
const errorReason =
isError && status.error
? typeof status.error === "string"
? status.error
: serializedError
: null;
const displayName = getToolDisplayName(toolName);
const subtitle = errorReason ?? cancelledReason;
return (
<Card
className={cn(
"my-4 max-w-lg overflow-hidden",
isCancelled && "opacity-60",
isError && "border-destructive/30"
)}
>
{/*
``group`` lets the chevron (rendered as a sibling of the
main trigger button) read the Collapsible Root's
``data-[state=open]`` for rotation. The Collapsible is
fully controlled via ``isExpanded`` the useEffect
above syncs it to ``isRunning`` so the card auto-opens
while a tool streams in and auto-collapses once it
finishes. We deliberately DON'T pass ``disabled`` so
both triggers stay clickable; ``onOpenChange`` is wired
to a setter that no-ops while ``isRunning`` (see
``handleOpenChange`` below) which keeps the card pinned
open mid-stream without losing keyboard / pointer
affordance the moment streaming ends.
*/}
<Collapsible
className="group"
open={isExpanded}
onOpenChange={(next) => {
// Block manual collapse while the tool is still
// streaming — otherwise a stray click on either
// trigger would close the card and hide the live
// ``argsText`` panel mid-run. After streaming the
// user has full control again.
if (isRunning) return;
setIsExpanded(next);
}}
>
{/*
Header row: main trigger on the left (icon + title
col), Revert + chevron-trigger on the right as
siblings of the main trigger. The chevron is wrapped
in its OWN ``CollapsibleTrigger`` (Radix supports
multiple triggers per Root) so clicking the chevron
toggles the same state as clicking the title row.
The Revert button stays a separate AlertDialog
trigger and stops propagation in its onClick so it
doesn't toggle the collapsible while opening the
confirm dialog. Keeping these as flat siblings
rather than nesting Revert / chevron inside the
title trigger avoids invalid HTML
(button-in-button) and lets the Revert button
render in BOTH the collapsed and expanded states.
*/}
<div className="flex items-stretch transition-colors hover:bg-muted/50">
<CollapsibleTrigger asChild>
<button
type="button"
className={cn(
"flex flex-1 min-w-0 items-center gap-3 py-4 pl-5 pr-2 text-left",
// Inset ring — Card's ``overflow-hidden`` would
// clip an ``offset-2`` ring; ``ring-inset``
// paints inside the button box.
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
"disabled:cursor-default"
)}
>
<div
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-lg",
isError ? "bg-destructive/10" : isCancelled ? "bg-muted" : "bg-primary/10"
)}
>
{isError ? (
<XCircleIcon className="size-4 text-destructive" />
) : isCancelled ? (
<XCircleIcon className="size-4 text-muted-foreground" />
) : isRunning ? (
<Spinner size="sm" className="text-primary" />
) : (
<CheckIcon className="size-4 text-primary" />
)}
</div>
<div className="flex flex-1 min-w-0 flex-col gap-0.5">
<div className="flex items-center gap-2">
<p
className={cn(
"text-sm font-semibold truncate",
isCancelled && "text-muted-foreground line-through",
isError && "text-destructive"
)}
>
{displayName}
</p>
{isRunning && <Badge variant="secondary">Running</Badge>}
{isError && <Badge variant="destructive">Failed</Badge>}
{isCancelled && <Badge variant="outline">Cancelled</Badge>}
</div>
{subtitle && (
<p
className={cn(
"text-xs truncate",
isError ? "text-destructive/80" : "text-muted-foreground"
)}
>
{subtitle}
</p>
)}
</div>
</button>
</CollapsibleTrigger>
{/*
Right-side controls. The Revert button is
visible whenever the matching action is
reversible including the collapsed state
but ``ToolCardRevertButton`` itself returns
``null`` while a tool is still running because
no action-log row exists yet, so it doesn't
need an explicit ``isRunning`` gate here.
*/}
<div className="flex shrink-0 items-center gap-2 pl-2 pr-5">
<ToolCardRevertButton
toolCallId={toolCallId}
toolName={toolName}
langchainToolCallId={langchainToolCallId}
/>
<CollapsibleTrigger asChild>
<button
type="button"
aria-label={isExpanded ? "Collapse details" : "Expand details"}
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md",
"text-muted-foreground hover:bg-muted hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
"disabled:cursor-default"
)}
>
<ChevronDownIcon
className={cn(
"size-4 transition-transform duration-200",
"group-data-[state=open]:rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
</div>
</div>
{/*
CollapsibleContent body auto-open while streaming
(see ``open`` prop above) so the live ``argsText``
streams into the Inputs panel directly, no need for
a separate "Live input" panel. Native
``overflow-auto`` instead of ``ScrollArea`` because
Radix's Viewport can let content bleed past
``max-h-*`` in dynamic flex layouts. ``min-w-0`` on
the column wrappers guarantees ``break-all`` wraps
correctly within the bounded ``max-w-lg`` Card.
*/}
<CollapsibleContent>
<Separator />
<div className="flex flex-col gap-3 px-5 py-3">
{(argsText || isRunning) && (
<div className="flex flex-col gap-1 min-w-0">
<p className="text-xs font-medium text-muted-foreground">Inputs</p>
<NestedScroll className="max-h-48 overflow-auto rounded-md bg-muted/40">
{argsText ? (
<pre className="px-3 py-2 text-xs text-foreground/80 whitespace-pre-wrap break-all font-mono">
{argsText}
</pre>
) : (
// Bridges the brief gap between
// ``tool-input-start`` (creates the
// card, ``argsText`` undefined) and
// the first ``tool-input-delta``.
<p className="px-3 py-2 text-xs italic text-muted-foreground">
Waiting for input
</p>
)}
</NestedScroll>
</div>
)}
{!isCancelled && result !== undefined && (
<>
<Separator />
<div className="flex flex-col gap-1 min-w-0">
<p className="text-xs font-medium text-muted-foreground">Result</p>
<NestedScroll className="max-h-64 overflow-auto rounded-md bg-muted/40">
<pre className="px-3 py-2 text-xs text-foreground/80 whitespace-pre-wrap break-all font-mono">
{typeof result === "string" ? result : serializedResult}
</pre>
</NestedScroll>
</div>
</>
)}
</div>
</CollapsibleContent>
</Collapsible>
</Card>
);
};
export const ToolFallback: ToolCallMessagePartComponent = (props) => {
if (isInterruptResult(props.result)) {
if (isDoomLoopInterrupt(props.result)) {
return <DoomLoopApprovalToolUI {...props} />;
}
return <GenericHitlApprovalToolUI {...props} />;
}
return <DefaultToolFallbackInner {...props} />;
};

View file

@ -10,13 +10,13 @@ import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile";
import { ShieldCheck } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { StepSeparatorDataUI } from "@/components/assistant-ui/step-separator";
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
import {
createTokenUsageStore,
type TokenUsageData,
TokenUsageProvider,
} from "@/components/assistant-ui/token-usage-context";
import { useAnonymousMode } from "@/contexts/anonymous-mode";
import { TimelineDataUI } from "@/features/chat-messages/timeline";
import {
addStepSeparator,
addToolCall,
@ -228,7 +228,8 @@ export function FreeChatPage() {
parsed.toolName,
{},
false,
parsed.langchainToolCallId
parsed.langchainToolCallId,
parsed.metadata
);
forceFlush();
break;
@ -245,6 +246,7 @@ export function FreeChatPage() {
args: parsed.input || {},
argsText: finalArgsText,
langchainToolCallId: parsed.langchainToolCallId,
metadata: parsed.metadata,
});
} else {
addToolCall(
@ -254,7 +256,8 @@ export function FreeChatPage() {
parsed.toolName,
parsed.input || {},
false,
parsed.langchainToolCallId
parsed.langchainToolCallId,
parsed.metadata
);
updateToolCall(contentPartsState, parsed.toolCallId, {
argsText: finalArgsText,
@ -268,6 +271,7 @@ export function FreeChatPage() {
updateToolCall(contentPartsState, parsed.toolCallId, {
result: parsed.output,
langchainToolCallId: parsed.langchainToolCallId,
metadata: parsed.metadata,
});
forceFlush();
break;
@ -469,7 +473,7 @@ export function FreeChatPage() {
return (
<TokenUsageProvider store={tokenUsageStore}>
<AssistantRuntimeProvider runtime={runtime}>
<ThinkingStepsDataUI />
<TimelineDataUI />
<StepSeparatorDataUI />
<div className="flex h-full flex-col overflow-hidden">
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-4">

View file

@ -1,2 +0,0 @@
export { PagerChrome } from "./pager-chrome";
export { withBundleStep } from "./with-bundle-step";

View file

@ -1,61 +0,0 @@
"use client";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useHitlBundle } from "@/lib/hitl";
/**
* Prev/next nav and Submit for the current step of an active HITL bundle.
* Submission is gated on every action_request having a staged decision.
*/
export function PagerChrome() {
const bundle = useHitlBundle();
if (!bundle) return null;
const total = bundle.toolCallIds.length;
const step = bundle.currentStep;
const allStaged = bundle.stagedCount === total;
return (
<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,37 +0,0 @@
"use client";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import type { ComponentType } from "react";
import { ToolCallIdProvider, useHitlBundle } from "@/lib/hitl";
import { PagerChrome } from "./pager-chrome";
/**
* Wrap a tool-ui card so that, when a multi-card HITL bundle is active:
* - cards belonging to the bundle but not the current step render ``null``;
* - the current-step card renders normally and is followed by ``PagerChrome``.
*
* Cards stay completely unchanged the wrapper provides the
* ``ToolCallIdContext`` that ``useHitlDecision`` reads to stage decisions
* against the right ``toolCallId`` instead of firing the global event.
*/
export function withBundleStep<P extends ToolCallMessagePartProps<any, any>>(
Component: ComponentType<P>
): ComponentType<P> {
function BundleStepWrapped(props: P) {
const bundle = useHitlBundle();
const toolCallId = props.toolCallId;
const inBundle = bundle?.isInBundle(toolCallId) ?? false;
const isStep = bundle?.isCurrentStep(toolCallId) ?? false;
if (bundle && inBundle && !isStep) return null;
return (
<ToolCallIdProvider toolCallId={toolCallId}>
<Component {...props} />
{bundle && isStep ? <PagerChrome /> : null}
</ToolCallIdProvider>
);
}
BundleStepWrapped.displayName = `withBundleStep(${Component.displayName ?? Component.name ?? "ToolUI"})`;
return BundleStepWrapped as ComponentType<P>;
}

View file

@ -1,405 +0,0 @@
"use client";
import { format } from "date-fns";
import { TagInput, type Tag as TagType } from "emblor";
import { useAtomValue, useSetAtom } from "jotai";
import { CalendarIcon, XIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { useMediaQuery } from "@/hooks/use-media-query";
const PlateEditor = dynamic(
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
{ ssr: false, loading: () => <Skeleton className="h-64 w-full" /> }
);
function parseEmailsToTags(value: string): TagType[] {
if (!value.trim()) return [];
return value
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((email, i) => ({ id: `${Date.now()}-${i}`, text: email }));
}
function tagsToEmailString(tags: TagType[]): string {
return tags.map((t) => t.text).join(", ");
}
function EmailsTagField({
id,
value,
onChange,
placeholder,
}: {
id: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) {
const [tags, setTags] = useState<TagType[]>(() => parseEmailsToTags(value));
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const isInitialMount = useRef(true);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
onChangeRef.current(tagsToEmailString(tags));
}, [tags]);
const handleSetTags = useCallback((newTags: TagType[] | ((prev: TagType[]) => TagType[])) => {
setTags((prev) => (typeof newTags === "function" ? newTags(prev) : newTags));
}, []);
const handleAddTag = useCallback((text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
setTags((prev) => {
if (prev.some((tag) => tag.text === trimmed)) return prev;
const newTag: TagType = { id: Date.now().toString(), text: trimmed };
return [...prev, newTag];
});
}, []);
return (
<TagInput
id={id}
tags={tags}
setTags={handleSetTags}
placeholder={placeholder ?? "Add email"}
onAddTag={handleAddTag}
styleClasses={{
inlineTagsContainer:
"border border-input rounded-md bg-transparent shadow-xs transition-[color,box-shadow] outline-none focus-within:border-ring p-1 gap-1",
input:
"w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7 text-foreground placeholder:text-muted-foreground bg-transparent text-sm md:text-sm",
tag: {
body: "h-7 relative bg-accent dark:bg-muted/60 border-0 hover:bg-accent/80 dark:hover:bg-muted rounded-md font-medium text-xs text-foreground/80 ps-2 pe-7 flex",
closeButton:
"absolute -inset-y-px -end-px p-0 rounded-e-md flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-foreground hover:text-foreground",
},
}}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
/>
);
}
function parseDateTimeValue(value: string): { date: Date | undefined; time: string } {
if (!value) return { date: undefined, time: "09:00" };
try {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return { date: undefined, time: "09:00" };
return {
date: d,
time: format(d, "HH:mm"),
};
} catch {
return { date: undefined, time: "09:00" };
}
}
function buildLocalDateTimeString(date: Date | undefined, time: string): string {
if (!date) return "";
const [hours, minutes] = time.split(":").map(Number);
const combined = new Date(date);
combined.setHours(hours ?? 9, minutes ?? 0, 0, 0);
const y = combined.getFullYear();
const m = String(combined.getMonth() + 1).padStart(2, "0");
const d = String(combined.getDate()).padStart(2, "0");
const h = String(combined.getHours()).padStart(2, "0");
const min = String(combined.getMinutes()).padStart(2, "0");
return `${y}-${m}-${d}T${h}:${min}:00`;
}
function DateTimePickerField({
id,
value,
onChange,
}: {
id: string;
value: string;
onChange: (value: string) => void;
}) {
const parsed = useMemo(() => parseDateTimeValue(value), [value]);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(parsed.date);
const [time, setTime] = useState(parsed.time);
const [open, setOpen] = useState(false);
const handleDateSelect = useCallback(
(day: Date | undefined) => {
setSelectedDate(day);
onChange(buildLocalDateTimeString(day, time));
setOpen(false);
},
[time, onChange]
);
const handleTimeChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = e.target.value;
setTime(newTime);
onChange(buildLocalDateTimeString(selectedDate, newTime));
},
[selectedDate, onChange]
);
const displayLabel = selectedDate
? `${format(selectedDate, "MMM d, yyyy")} at ${time}`
: "Pick date & time";
return (
<div className="flex gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
id={id}
type="button"
className="flex-1 flex items-center gap-2 h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring"
>
<CalendarIcon className="size-3.5 text-muted-foreground shrink-0" />
<span className={selectedDate ? "text-foreground" : "text-muted-foreground"}>
{displayLabel}
</span>
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selectedDate}
onSelect={handleDateSelect}
defaultMonth={selectedDate}
/>
</PopoverContent>
</Popover>
<Input
type="time"
value={time}
onChange={handleTimeChange}
className="w-[120px] text-sm shrink-0 appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
);
}
export function HitlEditPanelContent({
title: initialTitle,
content: initialContent,
contentFormat,
extraFields,
onSave,
onClose,
showCloseButton = true,
}: {
title: string;
content: string;
toolName: string;
contentFormat?: "markdown" | "html";
extraFields?: ExtraField[];
onSave: (title: string, content: string, extraFieldValues?: Record<string, string>) => void;
onClose?: () => void;
showCloseButton?: boolean;
}) {
const [editedTitle, setEditedTitle] = useState(initialTitle);
const contentRef = useRef(initialContent);
const [isSaving, setIsSaving] = useState(false);
const [extraFieldValues, setExtraFieldValues] = useState<Record<string, string>>(() => {
if (!extraFields) return {};
const initial: Record<string, string> = {};
for (const field of extraFields) {
initial[field.key] = field.value;
}
return initial;
});
const handleContentChange = useCallback((content: string) => {
contentRef.current = content;
}, []);
const handleExtraFieldChange = useCallback((key: string, value: string) => {
setExtraFieldValues((prev) => ({ ...prev, [key]: value }));
}, []);
const handleSave = useCallback(() => {
if (!editedTitle.trim()) return;
setIsSaving(true);
const extras = extraFields && extraFields.length > 0 ? extraFieldValues : undefined;
onSave(editedTitle, contentRef.current, extras);
onClose?.();
}, [editedTitle, onSave, onClose, extraFields, extraFieldValues]);
return (
<>
<div className="flex items-center gap-2 px-4 py-2 shrink-0 border-b">
<input
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
placeholder="Untitled"
className="flex-1 min-w-0 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
aria-label="Page title"
/>
{onClose && showCloseButton && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close panel</span>
</Button>
)}
</div>
{extraFields && extraFields.length > 0 && (
<div className="flex flex-col gap-3 px-4 py-3 border-b">
{extraFields.map((field) => (
<div key={field.key} className="flex flex-col gap-1.5">
<Label
htmlFor={`extra-field-${field.key}`}
className="text-xs font-medium text-muted-foreground"
>
{field.label}
</Label>
{field.type === "emails" ? (
<EmailsTagField
id={`extra-field-${field.key}`}
value={extraFieldValues[field.key] ?? ""}
onChange={(v) => handleExtraFieldChange(field.key, v)}
placeholder={`Add ${field.label.toLowerCase()}`}
/>
) : field.type === "datetime-local" ? (
<DateTimePickerField
id={`extra-field-${field.key}`}
value={extraFieldValues[field.key] ?? ""}
onChange={(v) => handleExtraFieldChange(field.key, v)}
/>
) : field.type === "textarea" ? (
<Textarea
id={`extra-field-${field.key}`}
value={extraFieldValues[field.key] ?? ""}
onChange={(e) => handleExtraFieldChange(field.key, e.target.value)}
className="text-sm min-h-[60px]"
/>
) : (
<Input
id={`extra-field-${field.key}`}
type={field.type}
value={extraFieldValues[field.key] ?? ""}
onChange={(e) => handleExtraFieldChange(field.key, e.target.value)}
className="text-sm"
/>
)}
</div>
))}
</div>
)}
<div className="flex-1 overflow-hidden">
<PlateEditor
{...(contentFormat === "html"
? { html: initialContent, onHtmlChange: handleContentChange }
: { markdown: initialContent, onMarkdownChange: handleContentChange })}
readOnly={false}
preset="full"
placeholder="Start writing..."
editorVariant="default"
defaultEditing
onSave={handleSave}
hasUnsavedChanges
isSaving={isSaving}
className="[&_[role=toolbar]]:!bg-sidebar"
/>
</div>
</>
);
}
function DesktopHitlEditPanel() {
const panelState = useAtomValue(hitlEditPanelAtom);
const closePanel = useSetAtom(closeHitlEditPanelAtom);
if (!panelState.isOpen || !panelState.onSave) return null;
return (
<div className="flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-sidebar text-sidebar-foreground animate-in slide-in-from-right-4 duration-300 ease-out">
<HitlEditPanelContent
title={panelState.title}
content={panelState.content}
toolName={panelState.toolName}
contentFormat={panelState.contentFormat}
extraFields={panelState.extraFields}
onSave={panelState.onSave}
onClose={closePanel}
/>
</div>
);
}
function MobileHitlEditDrawer() {
const panelState = useAtomValue(hitlEditPanelAtom);
const closePanel = useSetAtom(closeHitlEditPanelAtom);
if (!panelState.onSave) return null;
return (
<Drawer
open={panelState.isOpen}
onOpenChange={(open) => {
if (!open) closePanel();
}}
shouldScaleBackground={false}
>
<DrawerContent
className="h-[90vh] max-h-[90vh] z-80 bg-sidebar overflow-hidden"
overlayClassName="z-80"
>
<DrawerHandle />
<DrawerTitle className="sr-only">Edit {panelState.toolName}</DrawerTitle>
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
<HitlEditPanelContent
title={panelState.title}
content={panelState.content}
toolName={panelState.toolName}
contentFormat={panelState.contentFormat}
extraFields={panelState.extraFields}
onSave={panelState.onSave}
onClose={closePanel}
showCloseButton={false}
/>
</div>
</DrawerContent>
</Drawer>
);
}
export function HitlEditPanel() {
const panelState = useAtomValue(hitlEditPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
if (!panelState.isOpen) return null;
if (isDesktop) {
return <DesktopHitlEditPanel />;
}
return <MobileHitlEditDrawer />;
}
export function MobileHitlEditPanel() {
const panelState = useAtomValue(hitlEditPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
if (isDesktop || !panelState.isOpen) return null;
return <MobileHitlEditDrawer />;
}

View file

@ -4,7 +4,6 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { PanelRight } from "lucide-react";
import dynamic from "next/dynamic";
import { startTransition, useEffect } from "react";
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { citationPanelAtom, closeCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
@ -12,6 +11,7 @@ import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-pan
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/features/chat-messages/hitl";
import { DocumentsSidebar } from "../sidebar";
const EditorPanelContent = dynamic(
@ -32,7 +32,7 @@ const CitationPanelContent = dynamic(
const HitlEditPanelContent = dynamic(
() =>
import("@/components/hitl-edit-panel/hitl-edit-panel").then((m) => ({
import("@/features/chat-messages/hitl").then((m) => ({
default: m.HitlEditPanelContent,
})),
{ ssr: false, loading: () => null }

View file

@ -2,10 +2,10 @@
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { StepSeparatorDataUI } from "@/components/assistant-ui/step-separator";
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
import { Navbar } from "@/components/homepage/navbar";
import { ReportPanel } from "@/components/report-panel/report-panel";
import { Spinner } from "@/components/ui/spinner";
import { TimelineDataUI } from "@/features/chat-messages/timeline";
import { usePublicChat } from "@/hooks/use-public-chat";
import { usePublicChatRuntime } from "@/hooks/use-public-chat-runtime";
import { PublicChatFooter } from "./public-chat-footer";
@ -41,7 +41,7 @@ export function PublicChatView({ shareToken }: PublicChatViewProps) {
<main className="min-h-screen bg-main-panel text-foreground overflow-x-hidden">
<Navbar scrolledBgClassName={navbarScrolledBg} />
<AssistantRuntimeProvider runtime={runtime}>
<ThinkingStepsDataUI />
<TimelineDataUI />
<StepSeparatorDataUI />
<div className="flex h-screen pt-16 overflow-hidden">
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">

View file

@ -5,6 +5,7 @@ import {
AuiIf,
MessagePrimitive,
ThreadPrimitive,
type ToolCallMessagePartComponent,
useAuiState,
} from "@assistant-ui/react";
import { CheckIcon, CopyIcon } from "lucide-react";
@ -14,7 +15,6 @@ import { type FC, type ReactNode, useState } from "react";
import { CitationMetadataProvider } from "@/components/assistant-ui/citation-metadata-context";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { ReasoningMessagePart } from "@/components/assistant-ui/reasoning-message-part";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { GenerateImageToolUI } from "@/components/tool-ui/generate-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
@ -29,6 +29,8 @@ const GenerateVideoPresentationToolUI = dynamic(
{ ssr: false }
);
const NullToolUi: ToolCallMessagePartComponent = () => null;
interface PublicThreadProps {
footer?: ReactNode;
}
@ -168,12 +170,8 @@ const PublicAssistantMessage: FC = () => {
generate_video_presentation: GenerateVideoPresentationToolUI,
display_image: GenerateImageToolUI,
generate_image: GenerateImageToolUI,
web_search: () => null,
link_preview: () => null,
multi_link_preview: () => null,
scrape_webpage: () => null,
},
Fallback: ToolFallback,
Fallback: NullToolUi,
},
}}
/>

View file

@ -4,7 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
@ -15,9 +14,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface ConfluenceAccount {
id: number;

View file

@ -6,9 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
type DeleteConfluencePageInterruptContext = {
account?: {

View file

@ -4,13 +4,16 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
type UpdateConfluencePageInterruptContext = {
account?: {

View file

@ -4,7 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, FileIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
@ -15,9 +14,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface DropboxAccount {
id: number;

View file

@ -6,9 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
interface DropboxAccount {
id: number;

View file

@ -4,8 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pencil, UserIcon, UsersIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
@ -16,9 +14,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { ExtraField, HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface GmailAccount {
id: number;

View file

@ -4,8 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, MailIcon, Pencil, UserIcon, UsersIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
@ -16,9 +14,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { ExtraField, HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface GmailAccount {
id: number;

View file

@ -6,9 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
interface GmailAccount {
id: number;

View file

@ -4,14 +4,16 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, MailIcon, Pencil, UserIcon, UsersIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { ExtraField, HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface GmailAccount {
id: number;

View file

@ -11,8 +11,6 @@ import {
UsersIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
@ -23,9 +21,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { ExtraField, HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface GoogleCalendarAccount {
id: number;

View file

@ -6,9 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
interface GoogleCalendarAccount {
id: number;

View file

@ -11,14 +11,16 @@ import {
UsersIcon,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { ExtraField, HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface GoogleCalendarAccount {
id: number;

View file

@ -4,7 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, FileIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
@ -15,9 +14,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface GoogleDriveAccount {
id: number;

View file

@ -6,9 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
interface GoogleDriveAccount {
id: number;

View file

@ -4,7 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
@ -15,9 +14,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface JiraAccount {
id: number;

View file

@ -6,9 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
interface JiraAccount {
id: number;

View file

@ -4,7 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Badge } from "@/components/ui/badge";
@ -16,9 +15,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface JiraIssue {
issue_id: string;

View file

@ -4,7 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Badge } from "@/components/ui/badge";
@ -17,9 +16,13 @@ import {
SelectValue,
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface LinearLabel {
id: string;

View file

@ -6,9 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
type LinearDeleteIssueContext = {
workspace?: { id: number; organization_name: string };

View file

@ -4,7 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Badge } from "@/components/ui/badge";
@ -17,9 +16,13 @@ import {
SelectValue,
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface LinearLabel {
id: string;

View file

@ -4,7 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
@ -15,9 +14,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
type NotionCreatePageContext = {
accounts?: Array<{

View file

@ -6,9 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
type NotionDeletePageContext = {
account?: {

View file

@ -4,13 +4,16 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
type NotionUpdatePageContext = {
account?: {

View file

@ -4,7 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, FileIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
@ -15,9 +14,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface OneDriveAccount {
id: number;

View file

@ -6,9 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
interface OneDriveAccount {
id: number;

View file

@ -1,6 +1,5 @@
"use client";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CornerDownLeftIcon, OctagonAlert } from "lucide-react";
import { useCallback, useEffect, useMemo } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
@ -8,9 +7,10 @@ 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 { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult, PerToolApprovalCard } from "../types";
import { isInterruptResult } from "../types";
import { useHitlDecision } from "../use-hitl-decision";
import { useHitlPhase } from "../use-hitl-phase";
/**
* Specialized HITL card for ``DoomLoopMiddleware`` interrupts. The
@ -22,7 +22,7 @@ import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
* model: the agent is stuck repeating itself, not asking permission
* for a destructive action.
*/
function DoomLoopCard({
function DoomLoopCardView({
toolName,
args,
interruptData,
@ -161,27 +161,31 @@ function DoomLoopCard({
);
}
export const DoomLoopApprovalToolUI: ToolCallMessagePartComponent = ({
toolName,
args,
result,
}) => {
const { dispatch } = useHitlDecision();
if (!result || !isInterruptResult(result)) return null;
return (
<DoomLoopCard
toolName={toolName}
args={args as Record<string, unknown>}
interruptData={result}
onDecision={(decision) => dispatch([decision])}
/>
);
};
/**
* Discriminator: returns true iff the result is a ``permission_ask``
* interrupt with ``context.permission === "doom_loop"``. The fallback
* uses this BEFORE mounting an approval card to choose between
* ``DoomLoopApproval`` and ``GenericHitlApproval``.
*/
export function isDoomLoopInterrupt(result: unknown): boolean {
if (!isInterruptResult(result)) return false;
const ctx = (result.context ?? {}) as Record<string, unknown>;
return ctx.permission === "doom_loop";
}
/**
* Specialized doom-loop approval mounted by ``FallbackToolBody`` when
* ``isDoomLoopInterrupt(result)`` is true. Caller is responsible for
* the discrimination; this card receives a known ``InterruptResult``.
*/
export const DoomLoopApproval: PerToolApprovalCard = ({ toolName, args, result }) => {
const { dispatch } = useHitlDecision();
return (
<DoomLoopCardView
toolName={toolName}
args={args}
interruptData={result}
onDecision={(decision) => dispatch([decision])}
/>
);
};

View file

@ -1,6 +1,5 @@
"use client";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@ -9,10 +8,10 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult, PerToolApprovalCard } from "../types";
import { useHitlDecision } from "../use-hitl-decision";
import { useHitlPhase } from "../use-hitl-phase";
function ParamEditor({
params,
@ -63,7 +62,7 @@ function ParamEditor({
);
}
function GenericApprovalCard({
function GenericApprovalCardView({
toolName,
args,
interruptData,
@ -137,7 +136,6 @@ function GenericApprovalCard({
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div>
<p className="text-sm font-semibold text-foreground">
@ -177,7 +175,6 @@ function GenericApprovalCard({
)}
</div>
{/* Description */}
{toolDescription && phase === "pending" && (
<>
<div className="mx-5 h-px bg-border/50" />
@ -207,7 +204,6 @@ function GenericApprovalCard({
</>
)}
{/* Action buttons */}
{phase === "pending" && (
<>
<div className="mx-5 h-px bg-border/50" />
@ -243,19 +239,21 @@ function GenericApprovalCard({
);
}
export const GenericHitlApprovalToolUI: ToolCallMessagePartComponent = ({
toolName,
args,
result,
}) => {
/**
* Default approval card mounted by ``FallbackToolBody`` for unknown HITL
* tools. Per-tool integrations may also import and compose this card on
* top of their own framing.
*
* Caller (the fallback) is responsible for the ``isInterruptResult``
* guard; this card receives a known ``InterruptResult`` and skips the
* defensive runtime check.
*/
export const GenericHitlApproval: PerToolApprovalCard = ({ toolName, args, result }) => {
const { dispatch } = useHitlDecision();
if (!result || !isInterruptResult(result)) return null;
return (
<GenericApprovalCard
<GenericApprovalCardView
toolName={toolName}
args={args as Record<string, unknown>}
args={args}
interruptData={result}
onDecision={(decision) => dispatch([decision])}
/>

View file

@ -0,0 +1,2 @@
export { DoomLoopApproval, isDoomLoopInterrupt } from "./doom-loop-approval";
export { GenericHitlApproval } from "./generic-approval";

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

@ -0,0 +1,203 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { XIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { useCallback, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { Skeleton } from "@/components/ui/skeleton";
import { useMediaQuery } from "@/hooks/use-media-query";
import { closeHitlEditPanelAtom, type ExtraField, hitlEditPanelAtom } from "./edit-panel.atom";
import { ExtraFieldsSection } from "./fields";
const PlateEditor = dynamic(
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
{ ssr: false, loading: () => <Skeleton className="h-64 w-full" /> }
);
/**
* The actual editable form. Controlled by atom data via the
* Desktop/Mobile shells below; isolated from layout so the same form
* renders identically in either container.
*/
export function HitlEditPanelContent({
title: initialTitle,
content: initialContent,
contentFormat,
extraFields,
onSave,
onClose,
showCloseButton = true,
}: {
title: string;
content: string;
toolName: string;
contentFormat?: "markdown" | "html";
extraFields?: ExtraField[];
onSave: (title: string, content: string, extraFieldValues?: Record<string, string>) => void;
onClose?: () => void;
showCloseButton?: boolean;
}) {
const [editedTitle, setEditedTitle] = useState(initialTitle);
const contentRef = useRef(initialContent);
const [isSaving, setIsSaving] = useState(false);
const [extraFieldValues, setExtraFieldValues] = useState<Record<string, string>>(() => {
if (!extraFields) return {};
const initial: Record<string, string> = {};
for (const field of extraFields) {
initial[field.key] = field.value;
}
return initial;
});
const handleContentChange = useCallback((content: string) => {
contentRef.current = content;
}, []);
const handleExtraFieldChange = useCallback((key: string, value: string) => {
setExtraFieldValues((prev) => ({ ...prev, [key]: value }));
}, []);
const handleSave = useCallback(() => {
if (!editedTitle.trim()) return;
setIsSaving(true);
const extras = extraFields && extraFields.length > 0 ? extraFieldValues : undefined;
onSave(editedTitle, contentRef.current, extras);
onClose?.();
}, [editedTitle, onSave, onClose, extraFields, extraFieldValues]);
return (
<>
<div className="flex items-center gap-2 px-4 py-2 shrink-0 border-b">
<input
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
placeholder="Untitled"
className="flex-1 min-w-0 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
aria-label="Page title"
/>
{onClose && showCloseButton && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close panel</span>
</Button>
)}
</div>
{extraFields && extraFields.length > 0 && (
<ExtraFieldsSection
fields={extraFields}
values={extraFieldValues}
onFieldChange={handleExtraFieldChange}
/>
)}
<div className="flex-1 overflow-hidden">
<PlateEditor
{...(contentFormat === "html"
? { html: initialContent, onHtmlChange: handleContentChange }
: { markdown: initialContent, onMarkdownChange: handleContentChange })}
readOnly={false}
preset="full"
placeholder="Start writing..."
editorVariant="default"
defaultEditing
onSave={handleSave}
hasUnsavedChanges
isSaving={isSaving}
className="[&_[role=toolbar]]:!bg-sidebar"
/>
</div>
</>
);
}
function DesktopHitlEditPanel() {
const panelState = useAtomValue(hitlEditPanelAtom);
const closePanel = useSetAtom(closeHitlEditPanelAtom);
if (!panelState.isOpen || !panelState.onSave) return null;
return (
<div className="flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-sidebar text-sidebar-foreground animate-in slide-in-from-right-4 duration-300 ease-out">
<HitlEditPanelContent
title={panelState.title}
content={panelState.content}
toolName={panelState.toolName}
contentFormat={panelState.contentFormat}
extraFields={panelState.extraFields}
onSave={panelState.onSave}
onClose={closePanel}
/>
</div>
);
}
function MobileHitlEditDrawer() {
const panelState = useAtomValue(hitlEditPanelAtom);
const closePanel = useSetAtom(closeHitlEditPanelAtom);
if (!panelState.onSave) return null;
return (
<Drawer
open={panelState.isOpen}
onOpenChange={(open) => {
if (!open) closePanel();
}}
shouldScaleBackground={false}
>
<DrawerContent
className="h-[90vh] max-h-[90vh] z-80 bg-sidebar overflow-hidden"
overlayClassName="z-80"
>
<DrawerHandle />
<DrawerTitle className="sr-only">Edit {panelState.toolName}</DrawerTitle>
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
<HitlEditPanelContent
title={panelState.title}
content={panelState.content}
toolName={panelState.toolName}
contentFormat={panelState.contentFormat}
extraFields={panelState.extraFields}
onSave={panelState.onSave}
onClose={closePanel}
showCloseButton={false}
/>
</div>
</DrawerContent>
</Drawer>
);
}
/**
* Entry point mounted by the right-panel layout. Renders the desktop
* panel on lg+ and the mobile drawer below; both share state via the
* ``hitlEditPanelAtom``.
*/
export function HitlEditPanel() {
const panelState = useAtomValue(hitlEditPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
if (!panelState.isOpen) return null;
if (isDesktop) {
return <DesktopHitlEditPanel />;
}
return <MobileHitlEditDrawer />;
}
/**
* Entry point mounted by chat pages so the mobile drawer can render
* outside the desktop right-panel container.
*/
export function MobileHitlEditPanel() {
const panelState = useAtomValue(hitlEditPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
if (isDesktop || !panelState.isOpen) return null;
return <MobileHitlEditDrawer />;
}

View file

@ -0,0 +1,112 @@
"use client";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import type React from "react";
import { useCallback, useMemo, useState } from "react";
import { Calendar } from "@/components/ui/calendar";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
function parseDateTimeValue(value: string): { date: Date | undefined; time: string } {
if (!value) return { date: undefined, time: "09:00" };
try {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return { date: undefined, time: "09:00" };
return {
date: d,
time: format(d, "HH:mm"),
};
} catch {
return { date: undefined, time: "09:00" };
}
}
function buildLocalDateTimeString(date: Date | undefined, time: string): string {
if (!date) return "";
const [hours, minutes] = time.split(":").map(Number);
const combined = new Date(date);
combined.setHours(hours ?? 9, minutes ?? 0, 0, 0);
const y = combined.getFullYear();
const m = String(combined.getMonth() + 1).padStart(2, "0");
const d = String(combined.getDate()).padStart(2, "0");
const h = String(combined.getHours()).padStart(2, "0");
const min = String(combined.getMinutes()).padStart(2, "0");
return `${y}-${m}-${d}T${h}:${min}:00`;
}
/**
* Calendar popover + 24h time input. Emits a local ISO-like string
* (``YYYY-MM-DDThh:mm:00``) on every change. Value is parsed back into
* date + time on every render so the picker stays in sync with
* controlled props.
*/
export function DateTimePickerField({
id,
value,
onChange,
}: {
id: string;
value: string;
onChange: (value: string) => void;
}) {
const parsed = useMemo(() => parseDateTimeValue(value), [value]);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(parsed.date);
const [time, setTime] = useState(parsed.time);
const [open, setOpen] = useState(false);
const handleDateSelect = useCallback(
(day: Date | undefined) => {
setSelectedDate(day);
onChange(buildLocalDateTimeString(day, time));
setOpen(false);
},
[time, onChange]
);
const handleTimeChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = e.target.value;
setTime(newTime);
onChange(buildLocalDateTimeString(selectedDate, newTime));
},
[selectedDate, onChange]
);
const displayLabel = selectedDate
? `${format(selectedDate, "MMM d, yyyy")} at ${time}`
: "Pick date & time";
return (
<div className="flex gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
id={id}
type="button"
className="flex-1 flex items-center gap-2 h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring"
>
<CalendarIcon className="size-3.5 text-muted-foreground shrink-0" />
<span className={selectedDate ? "text-foreground" : "text-muted-foreground"}>
{displayLabel}
</span>
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selectedDate}
onSelect={handleDateSelect}
defaultMonth={selectedDate}
/>
</PopoverContent>
</Popover>
<Input
type="time"
value={time}
onChange={handleTimeChange}
className="w-[120px] text-sm shrink-0 appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
);
}

View file

@ -0,0 +1,86 @@
"use client";
import { TagInput, type Tag as TagType } from "emblor";
import { useCallback, useEffect, useRef, useState } from "react";
function parseEmailsToTags(value: string): TagType[] {
if (!value.trim()) return [];
return value
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((email, i) => ({ id: `${Date.now()}-${i}`, text: email }));
}
function tagsToEmailString(tags: TagType[]): string {
return tags.map((t) => t.text).join(", ");
}
/**
* Comma-separated email field rendered as a tag input. Internal tag
* state is the source of truth; comma-string is propagated to the
* caller via ``onChange`` whenever tags change (skipping the initial
* mount to avoid spurious updates).
*/
export function EmailsTagField({
id,
value,
onChange,
placeholder,
}: {
id: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) {
const [tags, setTags] = useState<TagType[]>(() => parseEmailsToTags(value));
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const isInitialMount = useRef(true);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
onChangeRef.current(tagsToEmailString(tags));
}, [tags]);
const handleSetTags = useCallback((newTags: TagType[] | ((prev: TagType[]) => TagType[])) => {
setTags((prev) => (typeof newTags === "function" ? newTags(prev) : newTags));
}, []);
const handleAddTag = useCallback((text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
setTags((prev) => {
if (prev.some((tag) => tag.text === trimmed)) return prev;
const newTag: TagType = { id: Date.now().toString(), text: trimmed };
return [...prev, newTag];
});
}, []);
return (
<TagInput
id={id}
tags={tags}
setTags={handleSetTags}
placeholder={placeholder ?? "Add email"}
onAddTag={handleAddTag}
styleClasses={{
inlineTagsContainer:
"border border-input rounded-md bg-transparent shadow-xs transition-[color,box-shadow] outline-none focus-within:border-ring p-1 gap-1",
input:
"w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7 text-foreground placeholder:text-muted-foreground bg-transparent text-sm md:text-sm",
tag: {
body: "h-7 relative bg-accent dark:bg-muted/60 border-0 hover:bg-accent/80 dark:hover:bg-muted rounded-md font-medium text-xs text-foreground/80 ps-2 pe-7 flex",
closeButton:
"absolute -inset-y-px -end-px p-0 rounded-e-md flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-foreground hover:text-foreground",
},
}}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
/>
);
}

View file

@ -0,0 +1,74 @@
"use client";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { ExtraField } from "../edit-panel.atom";
import { DateTimePickerField } from "./calendar-field";
import { EmailsTagField } from "./email-tags-field";
/**
* Renders ``ExtraField[]`` as a labelled vertical stack. Picks the
* input control from ``field.type``; unknown types fall back to a
* plain ``<Input type={field.type} />`` (covers "text" and "email").
*
* Pure presentational component owns no state, just maps values to
* controls and propagates changes through ``onFieldChange(key, value)``.
*/
export function ExtraFieldsSection({
fields,
values,
onFieldChange,
}: {
fields: ExtraField[];
values: Record<string, string>;
onFieldChange: (key: string, value: string) => void;
}) {
if (fields.length === 0) return null;
return (
<div className="flex flex-col gap-3 px-4 py-3 border-b">
{fields.map((field) => {
const fieldId = `extra-field-${field.key}`;
const currentValue = values[field.key] ?? "";
return (
<div key={field.key} className="flex flex-col gap-1.5">
<Label htmlFor={fieldId} className="text-xs font-medium text-muted-foreground">
{field.label}
</Label>
{field.type === "emails" ? (
<EmailsTagField
id={fieldId}
value={currentValue}
onChange={(v) => onFieldChange(field.key, v)}
placeholder={`Add ${field.label.toLowerCase()}`}
/>
) : field.type === "datetime-local" ? (
<DateTimePickerField
id={fieldId}
value={currentValue}
onChange={(v) => onFieldChange(field.key, v)}
/>
) : field.type === "textarea" ? (
<Textarea
id={fieldId}
value={currentValue}
onChange={(e) => onFieldChange(field.key, e.target.value)}
className="text-sm min-h-[60px]"
/>
) : (
<Input
id={fieldId}
type={field.type}
value={currentValue}
onChange={(e) => onFieldChange(field.key, e.target.value)}
className="text-sm"
/>
)}
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,3 @@
export { DateTimePickerField } from "./calendar-field";
export { EmailsTagField } from "./email-tags-field";
export { ExtraFieldsSection } from "./extra-fields";

View file

@ -0,0 +1,7 @@
export { HitlEditPanel, HitlEditPanelContent, MobileHitlEditPanel } from "./edit-panel";
export type { ExtraField } from "./edit-panel.atom";
export {
closeHitlEditPanelAtom,
hitlEditPanelAtom,
openHitlEditPanelAtom,
} from "./edit-panel.atom";

View file

@ -0,0 +1,31 @@
export {
type HitlApprovalAPI,
HitlApprovalCard,
PendingInterruptProvider,
type PendingInterruptState,
type PendingInterruptValue,
useHitlApproval,
usePendingInterrupt,
} from "./approval";
export { DoomLoopApproval, GenericHitlApproval, isDoomLoopInterrupt } from "./approval-cards";
export {
closeHitlEditPanelAtom,
type ExtraField,
HitlEditPanel,
HitlEditPanelContent,
hitlEditPanelAtom,
MobileHitlEditPanel,
openHitlEditPanelAtom,
} from "./edit-panel";
export type {
HitlDecision,
HitlPhase,
InterruptActionRequest,
InterruptResult,
InterruptReviewConfig,
PerToolApprovalCard,
PerToolApprovalCardProps,
} from "./types";
export { isInterruptResult } from "./types";
export { useHitlDecision } from "./use-hitl-decision";
export { useHitlPhase } from "./use-hitl-phase";

View file

@ -1,9 +1,4 @@
/**
* Shared types for Human-in-the-Loop (HITL) approval across all tools.
*
* Every tool-ui component that handles interrupts should import from here
* instead of defining its own `InterruptResult` / `isInterruptResult`.
*/
import type { ReactNode } from "react";
export interface InterruptActionRequest {
name: string;
@ -43,3 +38,22 @@ export interface HitlDecision {
args: Record<string, unknown>;
};
}
export type HitlPhase = "pending" | "processing" | "complete" | "rejected";
export interface PerToolApprovalCardProps {
toolName: string;
toolCallId: string;
args: Record<string, unknown>;
result: InterruptResult;
}
/**
* 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

@ -0,0 +1,32 @@
import { useCallback } from "react";
import { useHitlApproval } from "./approval/approval-context";
import type { HitlDecision } from "./types";
/**
* 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 approval = useHitlApproval();
const dispatch = useCallback(
(decisions: HitlDecision[]) => {
if (approval && decisions.length > 0) {
if (decisions.length > 1 && process.env.NODE_ENV !== "production") {
console.warn(
"[hitl] dispatch received %d decisions inside an approval card; only [0] will be staged",
decisions.length
);
}
approval.stage(decisions[0]);
return;
}
window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } }));
},
[approval]
);
return { dispatch };
}

View file

@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from "react";
export type HitlPhase = "pending" | "processing" | "complete" | "rejected";
import type { HitlPhase } from "./types";
interface HitlInterruptLike {
__decided__?: string | null;
@ -11,13 +10,20 @@ const MINIMUM_SHIMMER_MS = 500;
const FALLBACK_TIMEOUT_MS = 30_000;
/**
* State machine for HITL approval card phases.
* Local UI state machine for a HITL approval card.
*
* Phases:
* pending waiting for user decision (show buttons)
* processing user approved/edited, waiting for backend (shimmer)
* complete backend responded with __completed__ (done text)
* rejected user rejected (cancelled text)
* Phase transitions:
* pending user has not yet decided (show approve/edit/reject buttons)
* processing user clicked; awaiting backend confirmation (shimmer)
* complete backend acknowledged via __completed__ (or fallback timeout)
* rejected user explicitly rejected (terminal state, no backend wait)
*
* Initial phase is derived from the current ``__decided__`` /
* ``__completed__`` markers on the result, so cards rehydrate
* correctly from persisted history.
*
* NOT shared across cards. Each approval card calls ``useHitlPhase``
* once with its own interrupt result.
*/
export function useHitlPhase(interruptData: HitlInterruptLike): {
phase: HitlPhase;
@ -32,7 +38,6 @@ export function useHitlPhase(interruptData: HitlInterruptLike): {
const shimmerStartRef = useRef<number | null>(null);
// processing → complete when __completed__ arrives (with min shimmer duration)
useEffect(() => {
if (phase !== "processing") return;
if (!interruptData.__completed__) return;
@ -44,7 +49,6 @@ export function useHitlPhase(interruptData: HitlInterruptLike): {
return () => clearTimeout(timer);
}, [phase, interruptData.__completed__]);
// Fallback: processing → complete after 30s even if __completed__ never arrives
useEffect(() => {
if (phase !== "processing") return;
const fallback = setTimeout(() => setPhase("complete"), FALLBACK_TIMEOUT_MS);

View file

@ -0,0 +1,249 @@
import type { ItemStatus, ReasoningItem, TimelineItem, ToolCallItem } from "./types";
/**
* 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;
title: string;
items: string[];
status: "pending" | "in_progress" | "completed";
metadata?: Record<string, unknown>;
}
/** Narrowed tool-call shape; the assistant-ui content type is wider. */
interface ToolCallPart {
type: "tool-call";
toolCallId: string;
toolName: string;
args?: Record<string, unknown>;
argsText?: string;
result?: unknown;
langchainToolCallId?: string;
metadata?: Record<string, unknown>;
}
function isToolCallPart(part: unknown): part is ToolCallPart {
if (!part || typeof part !== "object") return false;
const o = part as { type?: unknown; toolCallId?: unknown; toolName?: unknown };
return (
o.type === "tool-call" && typeof o.toolCallId === "string" && typeof o.toolName === "string"
);
}
function asNonEmptyString(v: unknown): string | undefined {
return typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
}
/**
* 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 ``reconcileInterruptedAssistantMessages``
* folds the OLD row into the resume row upstream).
*
* 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";
if (typeof result === "object" && result !== null) {
const r = result as { __interrupt__?: unknown; __decided__?: unknown };
if (r.__interrupt__ === true && r.__decided__ === "reject") return "cancelled";
}
return "completed";
}
function mapStepStatus(status: ThinkingStepInput["status"]): ItemStatus {
if (status === "in_progress") return "running";
return status;
}
/**
* 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 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);
}
}
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);
if (joined && isPendingHitlInterrupt(joined.result)) {
consumedToolCallIds.add(joined.toolCallId);
continue;
}
if (joined) {
consumedToolCallIds.add(joined.toolCallId);
const item: ToolCallItem = {
kind: "tool-call",
id: step.id,
status: mapStepStatus(step.status),
items: step.items.length > 0 ? step.items : undefined,
spanId: stepSpanId ?? asNonEmptyString(joined.metadata?.spanId),
toolName: joined.toolName,
toolCallId: joined.toolCallId,
args: joined.args ?? {},
argsText: joined.argsText,
result: joined.result,
langchainToolCallId: joined.langchainToolCallId,
thinkingStepId: step.id,
};
items.push(item);
continue;
}
const reasoning: ReasoningItem = {
kind: "reasoning",
id: step.id,
status: mapStepStatus(step.status),
items: step.items.length > 0 ? step.items : undefined,
spanId: stepSpanId,
title: step.title,
};
items.push(reasoning);
}
if (content) {
for (const part of content) {
if (!isToolCallPart(part)) continue;
if (consumedToolCallIds.has(part.toolCallId)) continue;
if (superseded.has(part.toolCallId)) continue;
if (isPendingHitlInterrupt(part.result)) continue;
const orphan: ToolCallItem = {
kind: "tool-call",
id: part.toolCallId,
status: deriveToolCallStatus(part.result),
spanId: asNonEmptyString(part.metadata?.spanId),
toolName: part.toolName,
toolCallId: part.toolCallId,
args: part.args ?? {},
argsText: part.argsText,
result: part.result,
langchainToolCallId: part.langchainToolCallId,
};
items.push(orphan);
}
}
return items;
}

View file

@ -0,0 +1,57 @@
"use client";
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.
*
* 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 ?? [],
[data]
);
const items = useMemo(
() => buildTimeline(steps, Array.isArray(content) ? content : undefined),
[steps, content]
);
if (items.length === 0 && !pendingForThisMessage) return null;
return (
<div className="mb-3 -mx-2 leading-normal">
<PendingInterruptProvider pendingInterrupt={pendingForThisMessage} onSubmit={onSubmit}>
<Timeline items={items} isThreadRunning={isMessageStreaming} />
</PendingInterruptProvider>
</div>
);
}
/** Registers under ``thinking-steps`` so consumers swap the import only. */
export const TimelineDataUI = makeAssistantDataUI({
name: "thinking-steps",
render: TimelineDataRenderer,
});

View file

@ -0,0 +1,47 @@
import type { TimelineGroup, TimelineItem } from "./types";
/**
* Group delegated child items under their owning ``task`` parent.
*
* 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.
*
* 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 || !spanOwners.has(sid)) {
groups.push({ parent: item, children: [] });
continue;
}
const existing = spanParent.get(sid);
if (existing) {
existing.children.push(item);
continue;
}
const group: TimelineGroup = { parent: item, children: [] };
groups.push(group);
spanParent.set(sid, group);
}
return groups;
}

View file

@ -0,0 +1,16 @@
/**
* Public surface of the ``timeline/`` slice.
*
* Consumers (assistant-message, public-thread, free-chat-page, etc.)
* import ONLY from this barrel. Internal modules ``items/``,
* ``tool-registry/``, ``timeline-group-row``, ``build-timeline``,
* ``grouping``, ``subagent-rename`` are intentionally NOT
* re-exported. Adding consumers? Talk to the architecture doc first
* (see §6 layering rules).
*/
export type { ThinkingStepInput } from "./build-timeline";
export { TimelineDataUI } from "./data-renderer";
export { Timeline } from "./timeline";
export type { TimelineToolComponent, TimelineToolProps } from "./tool-registry/types";
export type { ItemStatus, ReasoningItem, TimelineGroup, TimelineItem, ToolCallItem } from "./types";

View file

@ -0,0 +1,3 @@
export { ItemHeader } from "./item-header";
export { ReasoningItem } from "./reasoning-item";
export { ToolCallItem } from "./tool-call-item";

View file

@ -0,0 +1,52 @@
import type { FC } from "react";
import { ChainOfThoughtItem } from "@/components/prompt-kit/chain-of-thought";
import { cn } from "@/lib/utils";
import type { ItemStatus } from "../types";
/**
* The title row + sub-bullets shared by every timeline item kind. The
* timeline's chrome (status dot, indent, vertical line) renders to the
* left; this fills the right column.
*
* Status-aware text styling matches the legacy ``StepBody`` semantics:
* running emphasised (font-medium foreground)
* completed muted
* pending muted/60
* error destructive
* cancelled strikethrough muted
*
* Sub-bullets render via ``ChainOfThoughtItem`` (reused from
* ``components/prompt-kit/chain-of-thought``) same component the
* legacy ``StepBody`` used.
*/
export const ItemHeader: FC<{
title: string;
status: ItemStatus;
items?: readonly string[];
itemKey: string;
}> = ({ title, status, items, itemKey }) => (
<div className="min-w-0">
<div
className={cn(
"text-sm leading-5",
status === "running" && "text-foreground font-medium",
status === "completed" && "text-muted-foreground",
status === "pending" && "text-muted-foreground/60",
status === "error" && "text-destructive",
status === "cancelled" && "text-muted-foreground line-through"
)}
>
{title}
</div>
{items && items.length > 0 && (
<div className="mt-1 space-y-0.5">
{items.map((item) => (
<ChainOfThoughtItem key={`${itemKey}-${item}`} className="text-xs">
{item}
</ChainOfThoughtItem>
))}
</div>
)}
</div>
);

View file

@ -0,0 +1,15 @@
import type { FC } from "react";
import type { ReasoningItem as ReasoningItemModel } from "../types";
import { ItemHeader } from "./item-header";
/**
* Renders a ``kind: "reasoning"`` row pure agent narration with no
* tool component beneath it. Just the shared header.
*
* Native ``<think>`` blocks (model-level reasoning) are NOT rendered
* here they live in the body via assistant-ui's ``Reasoning``
* component.
*/
export const ReasoningItem: FC<{ item: ReasoningItemModel }> = ({ item }) => (
<ItemHeader title={item.title} status={item.status} items={item.items} itemKey={item.id} />
);

View file

@ -0,0 +1,26 @@
"use client";
import type { FC } from "react";
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
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 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 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} />
<Body {...props} />
</>
);
};

View file

@ -0,0 +1,47 @@
import type { TimelineItem, ToolCallItem } from "./types";
function asNonEmptyString(v: unknown): string | undefined {
return typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
}
/**
* Title-case a subagent identifier:
* "notion" "Notion"
* "doc_research" "Doc Research"
* "ux-review" "Ux Review"
*/
export function titleCaseSubagent(raw: string): string {
return raw
.split(/[\s_-]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
/**
* Display title for a tool-call item. For the ``task`` delegation
* primitive, substitute ``args.subagent_type`` (e.g. "Notion" instead
* of the generic "Task" label). Returns ``undefined`` if no rename
* applies caller falls back to ``getToolDisplayName(toolName)``.
*/
export function resolveSubagentTitle(item: ToolCallItem): string | undefined {
if (item.toolName !== "task") return undefined;
const subagent = asNonEmptyString(item.args?.subagent_type);
return subagent ? titleCaseSubagent(subagent) : undefined;
}
/**
* Unified title resolver for any timeline item. Reasoning items use
* their own ``title``; tool-call items try the subagent rename first,
* then fall back to the resolver passed in (typically
* ``getToolDisplayName``).
*
* Pure: no React, no I/O. Trivially testable.
*/
export function resolveItemTitle(
item: TimelineItem,
getToolDisplayName: (toolName: string) => string
): string {
if (item.kind === "reasoning") return item.title;
return resolveSubagentTitle(item) ?? getToolDisplayName(item.toolName);
}

View file

@ -0,0 +1,68 @@
"use client";
import type { FC } from "react";
import { cn } from "@/lib/utils";
import { ReasoningItem, ToolCallItem } from "./items";
import type { ItemStatus, TimelineGroup, TimelineItem } from "./types";
function renderItem(item: TimelineItem) {
if (item.kind === "reasoning") return <ReasoningItem item={item} />;
return <ToolCallItem item={item} />;
}
/**
* Single group row in the timeline tree: status dot + connector line in
* the gutter, parent item content + indented children in the body.
*
* The connector line overshoots by ~15px to land on the next group's
* dot center; the line passes BEHIND any indented children (whose
* column has no dot of its own) for a clean tree look.
*/
export const TimelineGroupRow: FC<{
group: TimelineGroup;
parentStatus: ItemStatus;
showParentLine: boolean;
}> = ({ group, parentStatus, showParentLine }) => {
const hasChildren = group.children.length > 0;
return (
<div className="relative flex gap-3">
<div className="relative flex flex-col items-center w-2 self-stretch">
{showParentLine && (
<div className="absolute left-1/2 top-[15px] -bottom-[15px] w-px -translate-x-1/2 bg-muted-foreground/30" />
)}
<div className="relative z-10 mt-[7px] flex shrink-0 items-center justify-center">
{parentStatus === "running" ? (
<span className="relative flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary/60" />
<span className="relative inline-flex size-2 rounded-full bg-primary" />
</span>
) : (
<span
className={cn(
"size-2 rounded-full",
parentStatus === "error"
? "bg-destructive"
: parentStatus === "cancelled"
? "bg-muted-foreground/40"
: "bg-muted-foreground/30"
)}
/>
)}
</div>
</div>
<div className="flex-1 min-w-0 pb-4">
{renderItem(group.parent)}
{hasChildren && (
<div className="mt-2 ml-3 space-y-2">
{group.children.map((child) => (
<div key={child.id}>{renderItem(child)}</div>
))}
</div>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,149 @@
"use client";
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 { HitlApprovalCard, usePendingInterrupt } from "@/features/chat-messages/hitl";
import { cn } from "@/lib/utils";
import { groupItems } from "./grouping";
import { resolveItemTitle } from "./subagent-rename";
import { TimelineGroupRow } from "./timeline-group-row";
import type { ItemStatus, TimelineItem } from "./types";
/**
* 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";
return status;
}
/**
* 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 pendingValue = usePendingInterrupt();
const pendingInterrupt = pendingValue?.pendingInterrupt ?? null;
const onSubmit = pendingValue?.onSubmit;
const hasPending = pendingInterrupt !== null;
// 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) => ({
...it,
status: effectiveStatus(it.status, isThreadRunning),
})),
[items, isThreadRunning]
);
const inProgressItem = useMemo(
() => effectiveItems.find((it) => it.status === "running"),
[effectiveItems]
);
const inProgressTitle = useMemo(
() => (inProgressItem ? resolveItemTitle(inProgressItem, getToolDisplayName) : undefined),
[inProgressItem]
);
// "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 &&
!hasPending &&
effectiveItems.every(
(it) => it.status === "completed" || it.status === "cancelled" || it.status === "error"
),
[effectiveItems, isThreadRunning, hasPending]
);
const isProcessing = (isThreadRunning || hasPending) && !allSettled;
const [isOpen, setIsOpen] = useState(() => isProcessing);
useEffect(() => {
if (isProcessing) {
setIsOpen(true);
return;
}
if (allSettled) {
setIsOpen(false);
}
}, [allSettled, isProcessing]);
const groups = useMemo(() => groupItems(effectiveItems), [effectiveItems]);
if (effectiveItems.length === 0 && !hasPending) return null;
const headerText = (() => {
if (allSettled) return "Reviewed";
if (hasPending) return "Awaiting your decision";
if (inProgressTitle) return inProgressTitle;
if (isProcessing) return "Processing";
return "Reviewed";
})();
return (
<div className="mx-auto w-full max-w-(--thread-max-width) px-2 py-2">
<div className="rounded-lg">
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className={cn(
"flex w-full items-center gap-1.5 text-left text-sm transition-colors",
"text-muted-foreground hover:text-foreground"
)}
>
{isProcessing ? (
<TextShimmerLoader text={headerText} size="sm" />
) : (
<span>{headerText}</span>
)}
<ChevronRightIcon
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-90")}
/>
</button>
<div
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-out",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
<div className="mt-3 pl-1">
{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>
</div>
</div>
);
};

View file

@ -0,0 +1,23 @@
import type { ToolCallItem } from "../types";
import type { TimelineToolProps } from "./types";
/**
* Lossless mapping ``ToolCallItem TimelineToolProps``. Pure;
* extracts only the fields tool components actually consume.
*
* ``id``, ``kind``, ``items``, ``spanId``, ``thinkingStepId`` are
* intentionally dropped they're timeline-internal concerns (React
* key, dispatch, indentation, back-correlation) that tool components
* have no reason to see.
*/
export function adaptItemToProps(item: ToolCallItem): TimelineToolProps {
return {
toolCallId: item.toolCallId,
toolName: item.toolName,
args: item.args,
argsText: item.argsText,
result: item.result,
langchainToolCallId: item.langchainToolCallId,
status: item.status,
};
}

View file

@ -0,0 +1,216 @@
"use client";
import { CheckIcon, ChevronDownIcon, XCircleIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { NestedScroll } from "@/components/assistant-ui/nested-scroll";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
import { cn } from "@/lib/utils";
import type { TimelineToolComponent } from "../types";
import { ToolCardRevertButton } from "./revert-button";
/**
* Best-effort error/cancellation reason from a tool result. Used as
* the card subtitle when ``status`` is "error" or "cancelled". Returns
* ``null`` if no usable text can be extracted.
*
* Tries: plain string ``result.error`` ``result.message``
* stringified result. Per-tool components own richer error UIs; this
* is the generic fallback's coarse summary.
*/
function deriveResultMessage(result: unknown): string | null {
if (result == null) return null;
if (typeof result === "string") return result;
if (typeof result !== "object") return null;
const r = result as { error?: unknown; message?: unknown };
if (typeof r.error === "string") return r.error;
if (typeof r.message === "string") return r.message;
try {
return JSON.stringify(result);
} catch {
return null;
}
}
/**
* Compact tool-call card. Used by ``FallbackToolBody`` for unregistered
* tools whose result is not an HITL interrupt.
*
* shadcn composition note: ``Card`` is used as a visual frame WITHOUT
* ``CardHeader``/``CardContent`` the full composition's ``p-6``
* doesn't fit a compact collapsible header that IS the trigger.
*
* Per-card expansion auto-syncs to ``isRunning`` (auto-expand on
* stream start, auto-collapse on completion); manual toggle takes over
* once streaming ends.
*/
export const DefaultFallbackCard: TimelineToolComponent = ({
toolCallId,
toolName,
argsText,
result,
status,
langchainToolCallId,
}) => {
const isCancelled = status === "cancelled";
const isError = status === "error";
const isRunning = status === "running";
const [isExpanded, setIsExpanded] = useState(isRunning);
useEffect(() => {
setIsExpanded(isRunning);
}, [isRunning]);
const serializedResult = useMemo(
() =>
result !== undefined && typeof result !== "string" ? JSON.stringify(result, null, 2) : null,
[result]
);
const subtitle = useMemo(
() => (isError || isCancelled ? deriveResultMessage(result) : null),
[isError, isCancelled, result]
);
const displayName = getToolDisplayName(toolName);
return (
<Card
className={cn(
"my-4 max-w-lg overflow-hidden",
isCancelled && "opacity-60",
isError && "border-destructive/30"
)}
>
<Collapsible
className="group"
open={isExpanded}
onOpenChange={(next) => {
if (isRunning) return;
setIsExpanded(next);
}}
>
<div className="flex items-stretch transition-colors hover:bg-muted/50">
<CollapsibleTrigger asChild>
<button
type="button"
className={cn(
"flex flex-1 min-w-0 items-center gap-3 py-4 pl-5 pr-2 text-left",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
"disabled:cursor-default"
)}
>
<div
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-lg",
isError ? "bg-destructive/10" : isCancelled ? "bg-muted" : "bg-primary/10"
)}
>
{isError ? (
<XCircleIcon className="size-4 text-destructive" />
) : isCancelled ? (
<XCircleIcon className="size-4 text-muted-foreground" />
) : isRunning ? (
<Spinner size="sm" className="text-primary" />
) : (
<CheckIcon className="size-4 text-primary" />
)}
</div>
<div className="flex flex-1 min-w-0 flex-col gap-0.5">
<div className="flex items-center gap-2">
<p
className={cn(
"text-sm font-semibold truncate",
isCancelled && "text-muted-foreground line-through",
isError && "text-destructive"
)}
>
{displayName}
</p>
{isRunning && <Badge variant="secondary">Running</Badge>}
{isError && <Badge variant="destructive">Failed</Badge>}
{isCancelled && <Badge variant="outline">Cancelled</Badge>}
</div>
{subtitle && (
<p
className={cn(
"text-xs truncate",
isError ? "text-destructive/80" : "text-muted-foreground"
)}
>
{subtitle}
</p>
)}
</div>
</button>
</CollapsibleTrigger>
<div className="flex shrink-0 items-center gap-2 pl-2 pr-5">
<ToolCardRevertButton
toolCallId={toolCallId}
toolName={toolName}
langchainToolCallId={langchainToolCallId}
/>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={isExpanded ? "Collapse details" : "Expand details"}
className="size-7 shrink-0"
>
<ChevronDownIcon
className={cn(
"size-4 transition-transform duration-200",
"group-data-[state=open]:rotate-180"
)}
/>
</Button>
</CollapsibleTrigger>
</div>
</div>
<CollapsibleContent>
<Separator />
<div className="flex flex-col gap-3 px-5 py-3">
{(argsText || isRunning) && (
<div className="flex flex-col gap-1 min-w-0">
<p className="text-xs font-medium text-muted-foreground">Inputs</p>
<NestedScroll className="max-h-48 overflow-auto rounded-md bg-muted/40">
{argsText ? (
<pre className="px-3 py-2 text-xs text-foreground/80 whitespace-pre-wrap break-all font-mono">
{argsText}
</pre>
) : (
<p className="px-3 py-2 text-xs italic text-muted-foreground">
Waiting for input
</p>
)}
</NestedScroll>
</div>
)}
{!isCancelled && result !== undefined && (
<>
<Separator />
<div className="flex flex-col gap-1 min-w-0">
<p className="text-xs font-medium text-muted-foreground">Result</p>
<NestedScroll className="max-h-64 overflow-auto rounded-md bg-muted/40">
<pre className="px-3 py-2 text-xs text-foreground/80 whitespace-pre-wrap break-all font-mono">
{typeof result === "string" ? result : serializedResult}
</pre>
</NestedScroll>
</div>
</>
)}
</div>
</CollapsibleContent>
</Collapsible>
</Card>
);
};

View file

@ -0,0 +1,41 @@
"use client";
import {
DoomLoopApproval,
GenericHitlApproval,
type InterruptResult,
isDoomLoopInterrupt,
isInterruptResult,
} from "@/features/chat-messages/hitl";
import type { TimelineToolComponent } from "../types";
import { DefaultFallbackCard } from "./default-fallback-card";
/**
* Mounted by the timeline for any tool name not in the registry. The
* fallback owns the inner discrimination between HITL approval cards
* and the default visual card:
*
* isInterruptResult(result) isDoomLoopInterrupt DoomLoopApproval
* otherwise GenericHitlApproval
* else DefaultFallbackCard
*
* This is the ONLY place ``isInterruptResult`` is checked for unknown
* tools. Per-tool components in ``components/tool-ui/*`` perform their
* own internal discrimination over richer result shapes; the fallback
* only knows the two top-level branches.
*/
export const FallbackToolBody: TimelineToolComponent = (props) => {
if (isInterruptResult(props.result)) {
const approvalProps = {
toolCallId: props.toolCallId,
toolName: props.toolName,
args: props.args,
result: props.result as InterruptResult,
};
if (isDoomLoopInterrupt(props.result)) {
return <DoomLoopApproval {...approvalProps} />;
}
return <GenericHitlApproval {...approvalProps} />;
}
return <DefaultFallbackCard {...props} />;
};

View file

@ -0,0 +1 @@
export { FallbackToolBody } from "./fallback-tool-body";

View file

@ -0,0 +1,137 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { RotateCcw } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
import { markActionRevertedInCache } from "@/hooks/use-agent-actions-query";
import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
import { AppError } from "@/lib/error";
import { useToolAction } from "./use-tool-action";
/**
* Inline Revert button rendered on a default-fallback tool card when
* the matching ``AgentActionLog`` row is reversible and hasn't been
* reverted yet.
*
* Renders ``null`` (silent) in any of these cases:
* - no matching action row (still streaming, or never logged)
* - action not reversible
* - already reverted (``reverted_by_action_id`` set)
* - this card IS itself a revert action
* - tool errored
* - no thread context
*
* 503 from the revert API means the deployment has revert gated off;
* we hide the failure silently rather than nag the user. Other errors
* surface as toasts.
*/
export function ToolCardRevertButton({
toolCallId,
toolName,
langchainToolCallId,
}: {
toolCallId: string;
toolName: string;
langchainToolCallId?: string;
}) {
const queryClient = useQueryClient();
const { threadId, action } = useToolAction({
toolCallId,
toolName,
langchainToolCallId,
});
const [isReverting, setIsReverting] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
if (!action) return null;
if (!action.reversible) return null;
if (action.reverted_by_action_id !== null && action.reverted_by_action_id !== undefined)
return null;
if (action.is_revert_action) return null;
if (action.error !== null && action.error !== undefined) return null;
if (!threadId) return null;
const handleRevert = async () => {
setIsReverting(true);
try {
const response = await agentActionsApiService.revert(threadId, action.id);
markActionRevertedInCache(queryClient, threadId, action.id, response.new_action_id ?? null);
toast.success(response.message || "Action reverted.");
} catch (err) {
if (err instanceof AppError && err.status === 503) {
return;
}
const message =
err instanceof AppError
? err.message
: err instanceof Error
? err.message
: "Failed to revert action.";
toast.error(message);
} finally {
setIsReverting(false);
setConfirmOpen(false);
}
};
return (
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="outline"
className="gap-1.5"
onClick={(e) => {
e.stopPropagation();
setConfirmOpen(true);
}}
disabled={isReverting}
>
{isReverting ? <Spinner size="xs" /> : <RotateCcw data-icon="inline-start" />}
Revert
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revert this action?</AlertDialogTitle>
<AlertDialogDescription>
This will undo{" "}
<span className="font-medium">{getToolDisplayName(action.tool_name)}</span> and add a
new entry to the history. Your chat is preserved only the changes the agent made to
your knowledge base or connected apps will be rolled back where possible.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isReverting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleRevert();
}}
disabled={isReverting}
className="gap-1.5"
>
{isReverting && <Spinner size="xs" />}
Revert
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View file

@ -0,0 +1,90 @@
"use client";
import { useAuiState } from "@assistant-ui/react";
import { useAtomValue } from "jotai";
import { useMemo } from "react";
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import { useAgentActionsQuery } from "@/hooks/use-agent-actions-query";
/**
* Resolve the ``AgentActionLog`` row for a given tool-call card. Tries
* three lookup strategies, in priority order, against the unified
* ``useAgentActionsQuery`` cache (the same react-query cache the
* agent-actions sheet consumes keeps the card and the sheet in
* lockstep across reload, navigation, live stream, post-stream
* reversibility flips, and explicit revert clicks).
*
* **Tier 1+2 direct id match (O(1) Map):**
* - ``a.tool_call_id === toolCallId`` hits when the model streamed
* ``tool_call_chunks`` so the card id matches the LangChain id.
* - ``a.tool_call_id === langchainToolCallId`` synthetic card id
* is ``call_<run_id>`` and the LangChain id was backfilled by
* ``tool-output-available``.
*
* **Tier 3 position-within-turn fallback:** only kicks in when the
* card has a synthetic ``call_<run_id>`` id AND no
* ``langchainToolCallId`` was ever backfilled (tool emitted as a
* single non-chunked payload AND streaming pre-dated the
* ``on_tool_end`` backfill, e.g. older threads).
*
* Returns ``null`` if no row matches OR if there's no thread context.
*
* Performance note: ``useAuiState`` returns a PRIMITIVE
* (``positionInTurn`` is a number; ``chatTurnId`` is a string) so the
* hook's ``Object.is`` short-circuit prevents re-renders on every
* text-delta of every other part in the same message during streaming.
* (See Vercel React rule ``rerender-defer-reads``.)
*/
export function useToolAction({
toolCallId,
toolName,
langchainToolCallId,
}: {
toolCallId: string;
toolName: string;
langchainToolCallId?: string;
}) {
const session = useAtomValue(chatSessionStateAtom);
const threadId = session?.threadId ?? null;
const { findByToolCallId, findByChatTurnAndTool } = useAgentActionsQuery(threadId);
const chatTurnId = useAuiState(({ message }) => {
const meta = message?.metadata as { custom?: { chatTurnId?: string } } | undefined;
return meta?.custom?.chatTurnId ?? null;
});
const positionInTurn = useAuiState(({ message }) => {
const content = message?.content;
if (!Array.isArray(content)) return -1;
let n = -1;
for (const part of content) {
if (
part &&
typeof part === "object" &&
(part as { type?: string }).type === "tool-call" &&
(part as { toolName?: string }).toolName === toolName
) {
n += 1;
if ((part as { toolCallId?: string }).toolCallId === toolCallId) return n;
}
}
return -1;
});
const action = useMemo(() => {
const direct = findByToolCallId(toolCallId) ?? findByToolCallId(langchainToolCallId);
if (direct) return direct;
if (!chatTurnId || positionInTurn < 0) return null;
const turnSameTool = findByChatTurnAndTool(chatTurnId, toolName);
return turnSameTool[positionInTurn] ?? null;
}, [
findByToolCallId,
findByChatTurnAndTool,
toolCallId,
langchainToolCallId,
chatTurnId,
toolName,
positionInTurn,
]);
return { threadId, action };
}

View file

@ -0,0 +1,4 @@
export { adaptItemToProps } from "./adapt-props";
export { FallbackToolBody } from "./fallback";
export { getToolComponent, TIMELINE_TOOL_NAMES } from "./registry";
export type { TimelineToolComponent, TimelineToolProps } from "./types";

View file

@ -0,0 +1,229 @@
"use client";
import dynamic from "next/dynamic";
import type { TimelineToolComponent } from "./types";
// Dynamic imports keep the per-tool UI bundles out of the main chunk —
// each component only loads when an assistant turn references it. Mirrors
// the existing ``components/assistant-ui/assistant-message.tsx`` pattern.
//
// Phase A note: the imported components are still typed as
// ``ToolCallMessagePartComponent`` from assistant-ui; the cast at the
// bottom of this file bridges the contract until the cutover commit
// retypes them to ``TimelineToolComponent``. The cast is a structural
// no-op — every consumed prop overlaps.
const UpdateMemoryToolUI = dynamic(
() => import("@/components/tool-ui/user-memory").then((m) => ({ default: m.UpdateMemoryToolUI })),
{ ssr: false }
);
const SandboxExecuteToolUI = dynamic(
() =>
import("@/components/tool-ui/sandbox-execute").then((m) => ({
default: m.SandboxExecuteToolUI,
})),
{ ssr: false }
);
const CreateNotionPageToolUI = dynamic(
() => import("@/components/tool-ui/notion").then((m) => ({ default: m.CreateNotionPageToolUI })),
{ ssr: false }
);
const UpdateNotionPageToolUI = dynamic(
() => import("@/components/tool-ui/notion").then((m) => ({ default: m.UpdateNotionPageToolUI })),
{ ssr: false }
);
const DeleteNotionPageToolUI = dynamic(
() => import("@/components/tool-ui/notion").then((m) => ({ default: m.DeleteNotionPageToolUI })),
{ ssr: false }
);
const CreateLinearIssueToolUI = dynamic(
() => import("@/components/tool-ui/linear").then((m) => ({ default: m.CreateLinearIssueToolUI })),
{ ssr: false }
);
const UpdateLinearIssueToolUI = dynamic(
() => import("@/components/tool-ui/linear").then((m) => ({ default: m.UpdateLinearIssueToolUI })),
{ ssr: false }
);
const DeleteLinearIssueToolUI = dynamic(
() => import("@/components/tool-ui/linear").then((m) => ({ default: m.DeleteLinearIssueToolUI })),
{ ssr: false }
);
const CreateGoogleDriveFileToolUI = dynamic(
() =>
import("@/components/tool-ui/google-drive").then((m) => ({
default: m.CreateGoogleDriveFileToolUI,
})),
{ ssr: false }
);
const DeleteGoogleDriveFileToolUI = dynamic(
() =>
import("@/components/tool-ui/google-drive").then((m) => ({
default: m.DeleteGoogleDriveFileToolUI,
})),
{ ssr: false }
);
const CreateOneDriveFileToolUI = dynamic(
() =>
import("@/components/tool-ui/onedrive").then((m) => ({ default: m.CreateOneDriveFileToolUI })),
{ ssr: false }
);
const DeleteOneDriveFileToolUI = dynamic(
() =>
import("@/components/tool-ui/onedrive").then((m) => ({ default: m.DeleteOneDriveFileToolUI })),
{ ssr: false }
);
const CreateDropboxFileToolUI = dynamic(
() =>
import("@/components/tool-ui/dropbox").then((m) => ({ default: m.CreateDropboxFileToolUI })),
{ ssr: false }
);
const DeleteDropboxFileToolUI = dynamic(
() =>
import("@/components/tool-ui/dropbox").then((m) => ({ default: m.DeleteDropboxFileToolUI })),
{ ssr: false }
);
const CreateCalendarEventToolUI = dynamic(
() =>
import("@/components/tool-ui/google-calendar").then((m) => ({
default: m.CreateCalendarEventToolUI,
})),
{ ssr: false }
);
const UpdateCalendarEventToolUI = dynamic(
() =>
import("@/components/tool-ui/google-calendar").then((m) => ({
default: m.UpdateCalendarEventToolUI,
})),
{ ssr: false }
);
const DeleteCalendarEventToolUI = dynamic(
() =>
import("@/components/tool-ui/google-calendar").then((m) => ({
default: m.DeleteCalendarEventToolUI,
})),
{ ssr: false }
);
const CreateGmailDraftToolUI = dynamic(
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.CreateGmailDraftToolUI })),
{ ssr: false }
);
const UpdateGmailDraftToolUI = dynamic(
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.UpdateGmailDraftToolUI })),
{ ssr: false }
);
const SendGmailEmailToolUI = dynamic(
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.SendGmailEmailToolUI })),
{ ssr: false }
);
const TrashGmailEmailToolUI = dynamic(
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.TrashGmailEmailToolUI })),
{ ssr: false }
);
const CreateJiraIssueToolUI = dynamic(
() => import("@/components/tool-ui/jira").then((m) => ({ default: m.CreateJiraIssueToolUI })),
{ ssr: false }
);
const UpdateJiraIssueToolUI = dynamic(
() => import("@/components/tool-ui/jira").then((m) => ({ default: m.UpdateJiraIssueToolUI })),
{ ssr: false }
);
const DeleteJiraIssueToolUI = dynamic(
() => import("@/components/tool-ui/jira").then((m) => ({ default: m.DeleteJiraIssueToolUI })),
{ ssr: false }
);
const CreateConfluencePageToolUI = dynamic(
() =>
import("@/components/tool-ui/confluence").then((m) => ({
default: m.CreateConfluencePageToolUI,
})),
{ ssr: false }
);
const UpdateConfluencePageToolUI = dynamic(
() =>
import("@/components/tool-ui/confluence").then((m) => ({
default: m.UpdateConfluencePageToolUI,
})),
{ ssr: false }
);
const DeleteConfluencePageToolUI = dynamic(
() =>
import("@/components/tool-ui/confluence").then((m) => ({
default: m.DeleteConfluencePageToolUI,
})),
{ ssr: false }
);
/**
* Headers-only tools the timeline shows their ``ItemHeader`` (title +
* sub-bullets) but mounts no tool body beneath. Two reasons to use
* this:
* - **Structural primitives** (``task``): the row IS the parent of a
* delegation span; its job is to label the group. Children render
* as their own indented entries.
* - **Suppressed connectors** (``web_search``, ``link_preview``,
* ``multi_link_preview``, ``scrape_webpage``): citations they
* produce render inline in markdown; a separate card would be
* redundant noise.
*/
const NullTimelineBody: TimelineToolComponent = () => null;
/**
* The timeline's tool-name component map. Mounted by
* ``timeline/items/tool-call-item.tsx`` via ``getToolComponent(name)``.
*
* Includes only "process" tools (connector CRUD, sandbox execute,
* memory updates) and the 4 invisible tools mapped to a null component.
* Deliverables (``generate_report``, ``generate_resume``,
* ``generate_podcast``, ``generate_video_presentation``,
* ``display_image``, ``generate_image``) live in ``BODY_TOOLS`` in
* ``assistant-message.tsx`` they're product, not process.
*
* Tools NOT in this map fall through to ``FallbackToolBody`` (which
* itself dispatches between HITL approval cards and
* ``DefaultFallbackCard`` based on result discrimination).
*/
const TOOLS_BY_NAME = {
task: NullTimelineBody,
update_memory: UpdateMemoryToolUI,
execute: SandboxExecuteToolUI,
execute_code: SandboxExecuteToolUI,
create_notion_page: CreateNotionPageToolUI,
update_notion_page: UpdateNotionPageToolUI,
delete_notion_page: DeleteNotionPageToolUI,
create_linear_issue: CreateLinearIssueToolUI,
update_linear_issue: UpdateLinearIssueToolUI,
delete_linear_issue: DeleteLinearIssueToolUI,
create_google_drive_file: CreateGoogleDriveFileToolUI,
delete_google_drive_file: DeleteGoogleDriveFileToolUI,
create_onedrive_file: CreateOneDriveFileToolUI,
delete_onedrive_file: DeleteOneDriveFileToolUI,
create_dropbox_file: CreateDropboxFileToolUI,
delete_dropbox_file: DeleteDropboxFileToolUI,
create_calendar_event: CreateCalendarEventToolUI,
update_calendar_event: UpdateCalendarEventToolUI,
delete_calendar_event: DeleteCalendarEventToolUI,
create_gmail_draft: CreateGmailDraftToolUI,
update_gmail_draft: UpdateGmailDraftToolUI,
send_gmail_email: SendGmailEmailToolUI,
trash_gmail_email: TrashGmailEmailToolUI,
create_jira_issue: CreateJiraIssueToolUI,
update_jira_issue: UpdateJiraIssueToolUI,
delete_jira_issue: DeleteJiraIssueToolUI,
create_confluence_page: CreateConfluencePageToolUI,
update_confluence_page: UpdateConfluencePageToolUI,
delete_confluence_page: DeleteConfluencePageToolUI,
web_search: NullTimelineBody,
link_preview: NullTimelineBody,
multi_link_preview: NullTimelineBody,
scrape_webpage: NullTimelineBody,
} as unknown as Record<string, TimelineToolComponent>;
/**
* Lookup a tool component by name. Returns ``undefined`` for unknown
* tools so the caller can mount ``FallbackToolBody`` instead.
*/
export function getToolComponent(toolName: string): TimelineToolComponent | undefined {
return TOOLS_BY_NAME[toolName];
}
export const TIMELINE_TOOL_NAMES = Object.keys(TOOLS_BY_NAME) as readonly string[];

View file

@ -0,0 +1,37 @@
import type { ReactNode } from "react";
import type { ItemStatus } from "../types";
/**
* The exact prop subset the timeline supplies when mounting a tool
* component. A strict subset of assistant-ui's
* ``ToolCallMessagePartProps`` only the fields we actually have when
* rendering manually from a ``ToolCallItem``.
*
* Notably absent vs. assistant-ui:
* - ``addResult`` / ``resume`` (runtime-only, not available to us)
* - The complex ``status: ToolCallMessagePartState["status"]`` object
* (replaced by our simple ``ItemStatus`` enum)
* - ``messageId`` and other parent-message context (not needed by any
* of the 15 HITL-aware tool-ui components today)
*/
export interface TimelineToolProps {
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
argsText?: string;
result?: unknown;
langchainToolCallId?: string;
status: ItemStatus;
}
/**
* Contract for every tool component mounted by the timeline. The 15
* existing HITL-aware ``components/tool-ui/*`` files retype to this
* during the cutover commit (a mechanical rename from
* ``ToolCallMessagePartComponent`` ``TimelineToolComponent``).
*
* Components are expected to perform internal discrimination on
* ``result`` to pick a view (interrupt approval card; success
* result card; etc.) see §2.2 of the architecture doc.
*/
export type TimelineToolComponent = (props: TimelineToolProps) => ReactNode;

View file

@ -0,0 +1,84 @@
/**
* Coarse status used by the timeline's chrome (the colored dot, the
* "in progress" pulse). NOT consulted by per-tool components those
* own their own visual state machines (e.g. ``useHitlPhase`` for HITL
* approval cards).
*
* - ``pending`` known but not started yet (rare; usually only seen briefly during streaming)
* - ``running`` currently executing (assistant-ui ``in_progress``)
* - ``completed`` finished without error
* - ``cancelled`` user rejected (HITL ``__decided__: "reject"``)
* - ``error`` threw or returned an error result
*/
export type ItemStatus = "pending" | "running" | "completed" | "cancelled" | "error";
interface BaseItem {
/**
* Stable React key for the timeline. When a thinking-step row is joined
* with a tool-call part (via ``metadata.thinkingStepId``), this is the
* thinking-step ID preserves identity across rehydration. For
* tool-calls with no joined step, this is the ``toolCallId``.
*/
id: string;
status: ItemStatus;
/**
* Optional sub-bullets shown beneath the row's title. Forwarded
* verbatim from ``ThinkingStep.items`` when the timeline item was
* built from a thinking-step row.
*/
items?: string[];
/**
* Groups items into the delegation tree. All items emitted while a
* delegating ``task`` is open carry the same ``spanId``; the ``task``
* step itself owns the span (see ``grouping.ts``).
*/
spanId?: string;
}
/**
* Pure agent narration (e.g. "Reviewing the request", "Planning"). NOT
* a model-level ``<think>`` block those are rendered in the BODY by
* the assistant-ui ``Reasoning`` component. This kind covers thinking-
* step rows that are NOT linked to a tool call.
*/
export interface ReasoningItem extends BaseItem {
kind: "reasoning";
title: string;
}
/**
* A tool invocation. Per-tool components (mounted by the timeline's
* tool-registry) discriminate the ``result`` shape internally to pick
* a view (interrupt approval card; success result card; etc.).
*
* The timeline does NOT inspect ``result`` beyond deriving ``status``.
*/
export interface ToolCallItem extends BaseItem {
kind: "tool-call";
toolName: string;
/** The actual tool-call ID — passed to per-tool components (e.g. for the Revert button). */
toolCallId: string;
args: Record<string, unknown>;
argsText?: string;
result?: unknown;
langchainToolCallId?: string;
/**
* Set when the tool-call was joined with a thinking-step row via
* ``metadata.thinkingStepId``. In that case ``id`` is the
* thinking-step ID, not the ``toolCallId``.
*/
thinkingStepId?: string;
}
export type TimelineItem = ReasoningItem | ToolCallItem;
/**
* The output shape of the grouping pass. Each group is a parent item
* (typically a delegating ``task`` tool-call) plus the items emitted
* inside its span. Items with no ``spanId`` become parents with no
* children.
*/
export interface TimelineGroup {
parent: TimelineItem;
children: TimelineItem[];
}

View file

@ -1,9 +1,187 @@
import type { ThreadMessageLike } from "@assistant-ui/react";
import type { MessageRecord } from "./thread-persistence";
/** Minimal shape used by the interrupt/resume reconciler. */
interface AbortableMessage {
id: number;
role: string;
content: unknown;
turn_id?: string | null;
}
function isAssistant(msg: AbortableMessage): boolean {
return msg.role.toLowerCase() === "assistant";
}
/** True when the row carries at least one tool-call with ``state: "aborted"``. */
function hasAbortedToolCall(msg: AbortableMessage): boolean {
if (!isAssistant(msg) || !Array.isArray(msg.content)) return false;
for (const part of msg.content) {
if (typeof part !== "object" || part === null) continue;
if ((part as { type?: string }).type !== "tool-call") continue;
if ((part as { state?: unknown }).state === "aborted") return true;
}
return false;
}
/**
* Convert backend message to assistant-ui ThreadMessageLike format.
* Migrates legacy `thinking-steps` parts to `data-thinking-steps` (assistant-ui data parts).
* True when EVERY tool-call on the row is aborted. The row is then a
* frozen interrupt frame with no salvageable activity safe to drop
* outright.
*/
function isFullyAbortedAssistantMessage(msg: AbortableMessage): boolean {
if (!isAssistant(msg) || !Array.isArray(msg.content)) return false;
let hasToolCalls = false;
for (const part of msg.content) {
if (typeof part !== "object" || part === null) continue;
if ((part as { type?: string }).type !== "tool-call") continue;
hasToolCalls = true;
if ((part as { state?: unknown }).state !== "aborted") return false;
}
return hasToolCalls;
}
/**
* Locate the resume row that supersedes ``messages[idx]``. The
* ``stream_resume_chat`` flow allocates a fresh ``turn_id`` so we
* can't pair on it; conversational adjacency (assistant assistant
* with no user row between) is the unique signature. Skips already-
* dropped indices so chained interrupt-resumes still pair cleanly.
*/
function findResumeSuccessorIdx<T extends AbortableMessage>(
messages: readonly T[],
idx: number,
dropped: ReadonlySet<number>
): number | null {
for (let i = idx + 1; i < messages.length; i++) {
if (dropped.has(i)) continue;
const role = messages[i].role.toLowerCase();
if (role === "user") return null;
if (role === "assistant") return i;
}
return null;
}
/** Read ``data.steps`` from either ``data-thinking-steps`` (modern) or ``thinking-steps`` (legacy). */
function extractStepsFromPart(part: unknown): unknown[] | null {
if (typeof part !== "object" || part === null) return null;
const p = part as { type?: unknown; data?: unknown; steps?: unknown };
if (p.type === "data-thinking-steps") {
const data = p.data as { steps?: unknown } | undefined;
return Array.isArray(data?.steps) ? data.steps : [];
}
if (p.type === "thinking-steps") {
return Array.isArray(p.steps) ? (p.steps as unknown[]) : [];
}
return null;
}
/** Split a content array into (combined steps, all other parts in order). */
function partitionContent(content: unknown): { steps: unknown[]; others: unknown[] } {
if (!Array.isArray(content)) return { steps: [], others: [] };
const steps: unknown[] = [];
const others: unknown[] = [];
for (const part of content) {
const partSteps = extractStepsFromPart(part);
if (partSteps !== null) {
steps.push(...partSteps);
continue;
}
others.push(part);
}
return { steps, others };
}
/**
* Fold an interrupt-frame row's content into its resume successor so
* the user sees one assistant turn instead of two stacked bubbles.
* Successor's metadata wins (id, created_at, turn_id, token_usage,
* author) that's the row the per-turn revert button keys to.
*
* Order: combined ``data-thinking-steps`` (older steps then newer) at
* index 0, followed by older's other parts in order, then newer's. The
* older row's aborted ``task`` wrapper is preserved so the rejected
* attempt remains visible alongside the successful retry; both spans
* survive and ``groupItems`` renders them as sibling task branches in
* one timeline.
*/
function mergeInterruptedIntoResume<T extends AbortableMessage>(older: T, newer: T): T {
const olderParts = partitionContent(older.content);
const newerParts = partitionContent(newer.content);
const mergedSteps = [...olderParts.steps, ...newerParts.steps];
const mergedContent: unknown[] = [];
if (mergedSteps.length > 0) {
mergedContent.push({ type: "data-thinking-steps", data: { steps: mergedSteps } });
}
mergedContent.push(...olderParts.others, ...newerParts.others);
return { ...newer, content: mergedContent };
}
/**
* Reconcile interrupt-frame and resume rows so the UI shows one
* assistant turn per user turn even when the backend persists them as
* separate ``new_chat_messages`` rows.
*
* Two cases, both keyed on conversational adjacency (assistant
* assistant with no user row between):
*
* - **Fully aborted older row** (every tool-call ``state: "aborted"``,
* no salvageable activity) drop the older row.
* - **Partially aborted older row** (mixed completed + aborted, e.g.
* inner subagent tools ran before the interrupt) fold its content
* into the successor. Successor metadata wins.
*
* Never-resumed aborts (user navigated away mid-decision) survive so
* the user still sees what happened.
*
* Pure: returns a new array with new merged objects when needed.
* Caller passes messages in chronological order.
*/
export function reconcileInterruptedAssistantMessages<T extends AbortableMessage>(
messages: readonly T[]
): T[] {
const dropped = new Set<number>();
const mergeInto = new Map<number, number[]>();
for (let i = 0; i < messages.length; i++) {
if (dropped.has(i)) continue;
const msg = messages[i];
if (!hasAbortedToolCall(msg)) continue;
const successorIdx = findResumeSuccessorIdx(messages, i, dropped);
if (successorIdx === null) continue;
dropped.add(i);
if (!isFullyAbortedAssistantMessage(msg)) {
const list = mergeInto.get(successorIdx) ?? [];
list.push(i);
mergeInto.set(successorIdx, list);
}
}
const result: T[] = [];
for (let i = 0; i < messages.length; i++) {
if (dropped.has(i)) continue;
const olderIdxs = mergeInto.get(i);
if (olderIdxs && olderIdxs.length > 0) {
let merged = messages[i];
for (const olderIdx of olderIdxs) {
merged = mergeInterruptedIntoResume(messages[olderIdx], merged);
}
result.push(merged);
continue;
}
result.push(messages[i]);
}
return result;
}
/**
* Convert a backend ``MessageRecord`` to assistant-ui's
* ``ThreadMessageLike``. Also migrates legacy ``thinking-steps`` parts
* to ``data-thinking-steps``.
*/
export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
let content: ThreadMessageLike["content"];
@ -24,9 +202,10 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
"type" in part &&
(part as { type: string }).type === "thinking-steps"
) {
const steps = (part as unknown as { steps?: unknown[] }).steps;
return {
type: "data-thinking-steps",
data: { steps: (part as { steps: unknown[] }).steps ?? [] },
data: { steps: Array.isArray(steps) ? steps : [] },
};
}
return part;
@ -50,9 +229,8 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
},
}),
...(msg.token_usage && { usage: msg.token_usage }),
// Surface ``chat_turn_id`` so the assistant message
// footer can scope its "Revert turn" button to just
// this turn's actions. Null on legacy rows.
// Surfaced for the assistant footer's per-turn
// "Revert turn" button. Null on legacy rows.
...(msg.turn_id && { chatTurnId: msg.turn_id }),
},
}

View file

@ -112,7 +112,8 @@ export function processSharedStreamEvent(
parsed.toolName,
{},
false,
parsed.langchainToolCallId
parsed.langchainToolCallId,
parsed.metadata
);
forceFlush();
return true;
@ -131,6 +132,7 @@ export function processSharedStreamEvent(
args: parsed.input || {},
argsText: finalArgsText,
langchainToolCallId: parsed.langchainToolCallId,
metadata: parsed.metadata,
});
} else {
addToolCall(
@ -140,7 +142,8 @@ export function processSharedStreamEvent(
parsed.toolName,
parsed.input || {},
false,
parsed.langchainToolCallId
parsed.langchainToolCallId,
parsed.metadata
);
// addToolCall doesn't accept argsText today; backfill via
// updateToolCall so the new card renders pretty-printed JSON.
@ -156,6 +159,7 @@ export function processSharedStreamEvent(
updateToolCall(contentPartsState, parsed.toolCallId, {
result: parsed.output,
langchainToolCallId: parsed.langchainToolCallId,
metadata: parsed.metadata,
});
markInterruptsCompleted(contentParts);
context.onToolOutputAvailable?.(parsed, { contentPartsState, toolCallIndices });

View file

@ -5,6 +5,11 @@ export interface ThinkingStepData {
title: string;
status: "pending" | "in_progress" | "completed";
items: string[];
/**
* Optional relay fields from ``data-thinking-step`` when present on the wire
* (e.g. ``spanId``). Populated in a later slice; equality helpers ignore until wired.
*/
metadata?: Record<string, unknown>;
}
export type ContentPart =
@ -42,6 +47,11 @@ export type ContentPart =
* ``data-action-log`` events.
*/
langchainToolCallId?: string;
/**
* Relay correlation from tool SSE (e.g. ``spanId``, ``thinkingStepId``).
* Merged by ``mergeToolPartMetadata`` when events carry ``metadata``.
*/
metadata?: Record<string, unknown>;
}
| {
type: "data-thinking-steps";
@ -63,6 +73,18 @@ export interface ContentPartsState {
currentTextPartIndex: number;
currentReasoningPartIndex: number;
toolCallIndices: Map<string, number>;
/**
* Set by the resume flow's rehydration to suppress
* ``data-step-separator`` for the rest of this turn. Without it,
* the resume stream's first ``start-step`` fires
* ``addStepSeparator`` while rehydrated OLD content already makes
* ``hasContent`` true a divider lands between OLD and NEW
* content with no semantic value (OLD content is folded by
* ``buildTimeline`` + ``reconcileInterruptedAssistantMessages``,
* persisted state carries no separator, so the line vanishes on
* reload).
*/
suppressStepSeparators?: boolean;
}
function areThinkingStepsEqual(current: ThinkingStepData[], next: ThinkingStepData[]): boolean {
@ -224,7 +246,9 @@ export function addStepSeparator(state: ContentPartsState): void {
// non-step content (so the FIRST step of a turn doesn't
// generate a leading separator) and when the previous part isn't
// itself a separator (defensive against duplicate `start-step`
// events).
// events). Also skipped during a resume turn (see
// ``suppressStepSeparators`` on ``ContentPartsState``).
if (state.suppressStepSeparators) return;
const hasContent = state.contentParts.some(
(p) => p.type === "text" || p.type === "reasoning" || p.type === "tool-call"
);
@ -252,6 +276,23 @@ function _toolPasses(gate: ToolUIGate, toolName: string): boolean {
return gate === "all" || gate.has(toolName);
}
/**
* Shallow-merge relay ``metadata`` into a tool-call part (SSE content part).
* Keys already set on ``into`` are left unchanged so chunk vs canonical tool
* events cannot reorder or overwrite ``spanId`` / ``thinkingStepId``.
* Matches server ``AssistantContentBuilder`` merge semantics.
*/
function mergeToolPartMetadata(
into: Record<string, unknown>,
incoming: Record<string, unknown> | undefined
): void {
if (!incoming) return;
for (const [k, v] of Object.entries(incoming)) {
if (k === "__proto__" || k === "constructor") continue;
if (!(k in into)) into[k] = v;
}
}
export function addToolCall(
state: ContentPartsState,
toolsWithUI: ToolUIGate,
@ -259,15 +300,19 @@ export function addToolCall(
toolName: string,
args: Record<string, unknown>,
force = false,
langchainToolCallId?: string
langchainToolCallId?: string,
metadata?: Record<string, unknown>
): void {
if (force || _toolPasses(toolsWithUI, toolName)) {
const relayMeta: Record<string, unknown> = {};
mergeToolPartMetadata(relayMeta, metadata);
state.contentParts.push({
type: "tool-call",
toolCallId,
toolName,
args,
...(langchainToolCallId ? { langchainToolCallId } : {}),
...(Object.keys(relayMeta).length > 0 ? { metadata: relayMeta } : {}),
});
state.toolCallIndices.set(toolCallId, state.contentParts.length - 1);
state.currentTextPartIndex = -1;
@ -304,6 +349,7 @@ export function updateToolCall(
argsText?: string;
result?: unknown;
langchainToolCallId?: string;
metadata?: Record<string, unknown>;
}
): void {
const index = state.toolCallIndices.get(toolCallId);
@ -323,6 +369,11 @@ export function updateToolCall(
if (update.langchainToolCallId && !tc.langchainToolCallId) {
tc.langchainToolCallId = update.langchainToolCallId;
}
if (update.metadata && Object.keys(update.metadata).length > 0) {
const md = (tc.metadata ?? {}) as Record<string, unknown>;
mergeToolPartMetadata(md, update.metadata);
tc.metadata = md;
}
}
}
@ -416,14 +467,15 @@ export type SSEEvent =
toolName: string;
/** Authoritative LangChain ``tool_call.id``. Optional. */
langchainToolCallId?: string;
/** Optional JSON object from tool SSE (same keys as persisted tool-call metadata). */
metadata?: Record<string, unknown>;
}
| {
/**
* Live tool-call argument delta. Concatenated into
* ``argsText`` on the matching ``tool-call`` content part
* by ``appendToolInputDelta``. parity_v2 only the legacy
* code path emits ``tool-input-available`` without prior
* deltas.
* by ``appendToolInputDelta``. Some providers emit
* ``tool-input-available`` without prior deltas.
*/
type: "tool-input-delta";
toolCallId: string;
@ -435,6 +487,7 @@ export type SSEEvent =
toolName: string;
input: Record<string, unknown>;
langchainToolCallId?: string;
metadata?: Record<string, unknown>;
}
| {
type: "tool-output-available";
@ -444,6 +497,7 @@ export type SSEEvent =
* ``ToolMessage.tool_call_id`` at on_tool_end. Backfills cards
* that didn't get the id at tool-input-start time. */
langchainToolCallId?: string;
metadata?: Record<string, unknown>;
}
| { type: "data-thinking-step"; data: ThinkingStepData }
| { type: "data-thread-title-update"; data: { threadId: number; title: string } }

View file

@ -1,153 +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;
}
/**
* Activates only when ``toolCallIds`` has 2+ entries; single-card interrupts
* keep their direct ``window`` dispatch path so N=1 UX is unchanged.
*/
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("|");
// Derived-state-from-props: reset staging + step when the bundle changes.
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;
});
// Mirror the staged decision onto the card immediately so prev/next
// nav doesn't re-show approve/reject buttons for already-decided cards.
// Submit's ``hitl-decision`` event re-applies these (no-op) and runs
// the actual resume.
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,16 +0,0 @@
export {
type BundleSubmit,
type HitlBundleAPI,
HitlBundleProvider,
ToolCallIdProvider,
useHitlBundle,
useToolCallIdContext,
} from "./bundle-context";
export type {
HitlDecision,
InterruptActionRequest,
InterruptResult,
InterruptReviewConfig,
} from "./types";
export { isInterruptResult } from "./types";
export { useHitlDecision } from "./use-hitl-decision";

View file

@ -1,41 +0,0 @@
/**
* Shared hook for dispatching HITL decisions.
*
* Tool-ui cards always call ``dispatch([decision])``. When a multi-card bundle
* is active (``HitlBundleProvider``), the dispatch is intercepted and staged
* against this card's ``toolCallId`` so the orchestrator can submit one
* ordered N-decision payload. With no bundle active (N=1 path), it falls back
* to the legacy ``window`` event the host listens for in ``page.tsx``.
*/
import { useCallback } from "react";
import { useHitlBundle, useToolCallIdContext } from "./bundle-context";
import type { HitlDecision } from "./types";
export function useHitlDecision() {
const bundle = useHitlBundle();
const toolCallId = useToolCallIdContext();
const dispatch = useCallback(
(decisions: HitlDecision[]) => {
if (bundle && toolCallId && bundle.isInBundle(toolCallId) && decisions.length > 0) {
if (decisions.length > 1 && process.env.NODE_ENV !== "production") {
// Tool-ui cards stage one decision per call; a multi-decision
// dispatch into an active bundle would silently drop tail entries.
// eslint-disable-next-line no-console
console.warn(
"[hitl] dispatch received %d decisions inside an active bundle; only [0] will be staged for %s",
decisions.length,
toolCallId
);
}
bundle.stage(toolCallId, decisions[0]);
return;
}
window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } }));
},
[bundle, toolCallId]
);
return { dispatch };
}