mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-13 01:32:40 +02:00
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:
commit
28a02a9143
232 changed files with 9014 additions and 4055 deletions
|
|
@ -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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
export { PagerChrome } from "./pager-chrome";
|
||||
export { withBundleStep } from "./with-bundle-step";
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -1,187 +0,0 @@
|
|||
"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";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Specialized HITL card for ``DoomLoopMiddleware`` interrupts. The
|
||||
* backend signals these by setting ``context.permission === "doom_loop"``
|
||||
* on the ``permission_ask`` interrupt.
|
||||
*
|
||||
* The card replaces the generic "approve/reject" framing with a
|
||||
* "continue/stop" affordance that better matches the user's mental
|
||||
* model: the agent is stuck repeating itself, not asking permission
|
||||
* for a destructive action.
|
||||
*/
|
||||
function DoomLoopCard({
|
||||
toolName,
|
||||
args,
|
||||
interruptData,
|
||||
onDecision,
|
||||
}: {
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
|
||||
const context = (interruptData.context ?? {}) as Record<string, unknown>;
|
||||
const threshold = typeof context.threshold === "number" ? context.threshold : 3;
|
||||
const stuckTool = (typeof context.tool === "string" && context.tool) || toolName;
|
||||
const recentSignatures = Array.isArray(context.recent_signatures)
|
||||
? (context.recent_signatures as string[])
|
||||
: [];
|
||||
const displayName = stuckTool.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
const argPreview = useMemo(() => {
|
||||
if (!args || Object.keys(args).length === 0) return null;
|
||||
try {
|
||||
const json = JSON.stringify(args, null, 2);
|
||||
return json.length > 600 ? `${json.slice(0, 600)}…` : json;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [args]);
|
||||
|
||||
const handleContinue = useCallback(() => {
|
||||
if (phase !== "pending") return;
|
||||
setProcessing();
|
||||
onDecision({ type: "approve" });
|
||||
}, [phase, setProcessing, onDecision]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (phase !== "pending") return;
|
||||
setRejected();
|
||||
onDecision({ type: "reject", message: "Doom loop: user requested stop." });
|
||||
}, [phase, setRejected, onDecision]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (phase !== "pending") return;
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleStop();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [phase, handleStop]);
|
||||
|
||||
const isResolved = phase === "complete" || phase === "rejected";
|
||||
|
||||
return (
|
||||
<Alert variant={phase === "rejected" ? "default" : "destructive"} className="my-4 max-w-lg">
|
||||
<OctagonAlert className="size-4" />
|
||||
<AlertTitle className="flex items-center gap-2">
|
||||
<span>
|
||||
{phase === "rejected"
|
||||
? "Stopped"
|
||||
: phase === "processing"
|
||||
? "Continuing…"
|
||||
: phase === "complete"
|
||||
? "Continued"
|
||||
: "I might be stuck"}
|
||||
</span>
|
||||
{!isResolved && (
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
doom-loop
|
||||
</Badge>
|
||||
)}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-3">
|
||||
{phase === "processing" ? (
|
||||
<TextShimmerLoader text="Resuming…" size="sm" />
|
||||
) : phase === "rejected" ? (
|
||||
<p className="text-xs">
|
||||
I stopped retrying <span className="font-medium">{displayName}</span> as you asked.
|
||||
</p>
|
||||
) : phase === "complete" ? (
|
||||
<p className="text-xs">
|
||||
Continuing to call <span className="font-medium">{displayName}</span> as you asked.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs">
|
||||
I called <span className="font-medium">{displayName}</span> {threshold} times in a row
|
||||
with similar arguments. Should I keep going or stop and rethink?
|
||||
</p>
|
||||
)}
|
||||
|
||||
{argPreview && phase === "pending" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Last arguments
|
||||
</p>
|
||||
<pre className="max-h-32 overflow-auto rounded-md bg-muted/50 p-2 text-[11px] text-foreground/80">
|
||||
{argPreview}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{recentSignatures.length > 0 && phase === "pending" && (
|
||||
<details className="text-[11px] text-muted-foreground">
|
||||
<summary className="cursor-pointer select-none">
|
||||
Show repeated signatures ({recentSignatures.length})
|
||||
</summary>
|
||||
<ul className="mt-1 ml-4 list-disc">
|
||||
{recentSignatures.map((sig) => (
|
||||
<li key={sig} className="font-mono break-all">
|
||||
{sig}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{phase === "pending" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="rounded-lg gap-1.5" onClick={handleStop}>
|
||||
Stop and rethink
|
||||
<CornerDownLeftIcon className="size-3 opacity-60" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleContinue}>
|
||||
Continue anyway
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
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])}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export function isDoomLoopInterrupt(result: unknown): boolean {
|
||||
if (!isInterruptResult(result)) return false;
|
||||
const ctx = (result.context ?? {}) as Record<string, unknown>;
|
||||
return ctx.permission === "doom_loop";
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,263 +0,0 @@
|
|||
"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";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
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";
|
||||
|
||||
function ParamEditor({
|
||||
params,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
params: Record<string, unknown>;
|
||||
onChange: (updated: Record<string, unknown>) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const entries = Object.entries(params);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{entries.map(([key, value]) => {
|
||||
const strValue = value == null ? "" : String(value);
|
||||
const isLong = strValue.length > 120;
|
||||
const fieldId = `hitl-param-${key}`;
|
||||
|
||||
return (
|
||||
<div key={key} className="space-y-1">
|
||||
<label htmlFor={fieldId} className="text-xs font-medium text-muted-foreground">
|
||||
{key}
|
||||
</label>
|
||||
{isLong ? (
|
||||
<Textarea
|
||||
id={fieldId}
|
||||
value={strValue}
|
||||
disabled={disabled}
|
||||
rows={3}
|
||||
onChange={(e) => onChange({ ...params, [key]: e.target.value })}
|
||||
className="text-xs"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={fieldId}
|
||||
value={strValue}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ ...params, [key]: e.target.value })}
|
||||
className="text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GenericApprovalCard({
|
||||
toolName,
|
||||
args,
|
||||
interruptData,
|
||||
onDecision,
|
||||
}: {
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [editedParams, setEditedParams] = useState<Record<string, unknown>>(args);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const displayName = getToolDisplayName(toolName);
|
||||
|
||||
const mcpServer = interruptData.context?.mcp_server as string | undefined;
|
||||
const toolDescription = interruptData.context?.tool_description as string | undefined;
|
||||
const mcpConnectorId = interruptData.context?.mcp_connector_id as number | undefined;
|
||||
const isMCPTool = mcpConnectorId != null;
|
||||
|
||||
const reviewConfig = interruptData.review_configs?.[0];
|
||||
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
|
||||
const canEdit = allowedDecisions.includes("edit");
|
||||
|
||||
const hasChanged = useMemo(() => {
|
||||
return JSON.stringify(editedParams) !== JSON.stringify(args);
|
||||
}, [editedParams, args]);
|
||||
|
||||
const handleApprove = useCallback(() => {
|
||||
if (phase !== "pending") return;
|
||||
const isEdited = isEditing && hasChanged;
|
||||
setProcessing();
|
||||
onDecision({
|
||||
type: isEdited ? "edit" : "approve",
|
||||
edited_action: isEdited
|
||||
? { name: interruptData.action_requests[0]?.name ?? toolName, args: editedParams }
|
||||
: undefined,
|
||||
});
|
||||
}, [
|
||||
phase,
|
||||
setProcessing,
|
||||
isEditing,
|
||||
hasChanged,
|
||||
onDecision,
|
||||
interruptData,
|
||||
toolName,
|
||||
editedParams,
|
||||
]);
|
||||
|
||||
const handleAlwaysAllow = useCallback(() => {
|
||||
if (phase !== "pending" || !isMCPTool) return;
|
||||
setProcessing();
|
||||
onDecision({ type: "approve" });
|
||||
connectorsApiService.trustMCPTool(mcpConnectorId, toolName).catch(() => {
|
||||
toast.error(
|
||||
"Failed to save 'Always Allow' preference. The tool will still require approval next time."
|
||||
);
|
||||
});
|
||||
}, [phase, setProcessing, onDecision, isMCPTool, mcpConnectorId, toolName]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && phase === "pending") {
|
||||
handleApprove();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [handleApprove, phase]);
|
||||
|
||||
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">
|
||||
{phase === "rejected"
|
||||
? `${displayName} — Rejected`
|
||||
: phase === "processing" || phase === "complete"
|
||||
? `${displayName} — Approved`
|
||||
: displayName}
|
||||
</p>
|
||||
{phase === "processing" ? (
|
||||
<TextShimmerLoader text="Executing..." size="sm" />
|
||||
) : phase === "complete" ? (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Action completed</p>
|
||||
) : phase === "rejected" ? (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Action was cancelled</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Requires your approval to proceed
|
||||
</p>
|
||||
)}
|
||||
{mcpServer && (
|
||||
<p className="text-[10px] text-muted-foreground/70 mt-1">
|
||||
via <span className="font-medium">{mcpServer}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{phase === "pending" && canEdit && !isEditing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-lg text-muted-foreground -mt-1 -mr-2"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{toolDescription && phase === "pending" && (
|
||||
<>
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<div className="px-5 py-3">
|
||||
<p className="text-xs text-muted-foreground">{toolDescription}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{Object.keys(args).length > 0 && (
|
||||
<>
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<div className="px-5 py-4 space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Inputs</p>
|
||||
{phase === "pending" && isEditing ? (
|
||||
<ParamEditor
|
||||
params={editedParams}
|
||||
onChange={setEditedParams}
|
||||
disabled={phase !== "pending"}
|
||||
/>
|
||||
) : (
|
||||
<pre className="text-xs text-foreground/80 whitespace-pre-wrap break-all bg-muted/50 rounded-lg p-3">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
{phase === "pending" && (
|
||||
<>
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<div className="px-5 py-4 flex items-center gap-2 select-none">
|
||||
{allowedDecisions.includes("approve") && (
|
||||
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>
|
||||
{isEditing && hasChanged ? "Approve with edits" : "Approve"}
|
||||
<CornerDownLeftIcon className="size-3 opacity-60" />
|
||||
</Button>
|
||||
)}
|
||||
{isMCPTool && (
|
||||
<Button size="sm" className="rounded-lg" onClick={handleAlwaysAllow}>
|
||||
Always Allow
|
||||
</Button>
|
||||
)}
|
||||
{allowedDecisions.includes("reject") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-lg text-muted-foreground"
|
||||
onClick={() => {
|
||||
setRejected();
|
||||
onDecision({ type: "reject", message: "User rejected the action." });
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const GenericHitlApprovalToolUI: ToolCallMessagePartComponent = ({
|
||||
toolName,
|
||||
args,
|
||||
result,
|
||||
}) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result || !isInterruptResult(result)) return null;
|
||||
|
||||
return (
|
||||
<GenericApprovalCard
|
||||
toolName={toolName}
|
||||
args={args as Record<string, unknown>}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue