Stabilize HITL bundle UX and resume.

This commit is contained in:
CREDO23 2026-05-04 23:58:53 +02:00
parent 972650909c
commit 0af2c28a8d
12 changed files with 553 additions and 184 deletions

View file

@ -40,6 +40,7 @@ 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,
@ -484,6 +485,51 @@ 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,
} as const;
const TOOLS_FALLBACK = withBundleStep(ToolFallback);
const AssistantMessageInner: FC = () => {
const isMobile = !useMediaQuery("(min-width: 768px)");
@ -495,47 +541,8 @@ const AssistantMessageInner: FC = () => {
Text: MarkdownText,
Reasoning: ReasoningMessagePart,
tools: {
by_name: {
generate_report: GenerateReportToolUI,
generate_resume: GenerateResumeToolUI,
generate_podcast: GeneratePodcastToolUI,
generate_video_presentation: GenerateVideoPresentationToolUI,
display_image: GenerateImageToolUI,
generate_image: GenerateImageToolUI,
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: () => null,
link_preview: () => null,
multi_link_preview: () => null,
scrape_webpage: () => null,
},
Fallback: ToolFallback,
by_name: TOOLS_BY_NAME,
Fallback: TOOLS_FALLBACK,
},
}}
/>

View file

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

View file

@ -0,0 +1,61 @@
"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

@ -0,0 +1,37 @@
"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>;
}