SurfSense/surfsense_web/components/tool-ui/jira/delete-jira-issue.tsx
Anish Sarkar 71f4f77f26 refactor: unify interrupt handling in various tools
Refactored the context types for create, delete, and update functionalities across multiple tools including Confluence, Dropbox, Gmail, Google Calendar, Jira, Linear, Notion, and OneDrive to utilize a consistent type definition. This change enhances code clarity and maintains uniformity in handling user approvals by integrating the useHitlDecision hook for decision dispatching.
2026-04-13 20:25:49 +05:30

407 lines
12 KiB
TypeScript

"use client";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { CornerDownLeftIcon } from "lucide-react";
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 { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface JiraAccount {
id: number;
name: string;
base_url: string;
auth_expired?: boolean;
}
interface JiraIssue {
issue_id: string;
issue_identifier: string;
issue_title: string;
state?: string;
document_id?: number;
}
type DeleteJiraIssueInterruptContext = {
account?: JiraAccount;
issue?: JiraIssue;
error?: string;
}
interface SuccessResult {
status: "success";
deleted_from_kb?: boolean;
message?: string;
}
interface ErrorResult {
status: "error";
message: string;
}
interface NotFoundResult {
status: "not_found";
message: string;
}
interface WarningResult {
status: "success";
warning: string;
message?: string;
}
interface AuthErrorResult {
status: "auth_error";
message: string;
connector_id?: number;
connector_type: string;
}
interface InsufficientPermissionsResult {
status: "insufficient_permissions";
connector_id: number;
message: string;
}
type DeleteJiraIssueResult =
| InterruptResult<DeleteJiraIssueInterruptContext>
| SuccessResult
| ErrorResult
| NotFoundResult
| WarningResult
| AuthErrorResult
| InsufficientPermissionsResult;
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as ErrorResult).status === "error"
);
}
function isNotFoundResult(result: unknown): result is NotFoundResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as NotFoundResult).status === "not_found"
);
}
function isWarningResult(result: unknown): result is WarningResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as WarningResult).status === "success" &&
"warning" in result &&
typeof (result as WarningResult).warning === "string"
);
}
function isAuthErrorResult(result: unknown): result is AuthErrorResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as AuthErrorResult).status === "auth_error"
);
}
function isInsufficientPermissionsResult(result: unknown): result is InsufficientPermissionsResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as InsufficientPermissionsResult).status === "insufficient_permissions"
);
}
function ApprovalCard({
interruptData,
onDecision,
}: {
interruptData: InterruptResult<DeleteJiraIssueInterruptContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false);
const context = interruptData.context;
const account = context?.account;
const issue = context?.issue;
const handleApprove = useCallback(() => {
if (phase !== "pending") return;
setProcessing();
onDecision({
type: "approve",
edited_action: {
name: interruptData.action_requests[0].name,
args: {
issue_id: issue?.issue_id,
connector_id: account?.id,
delete_from_kb: deleteFromKb,
},
},
});
}, [phase, setProcessing, onDecision, interruptData, issue?.issue_id, account?.id, deleteFromKb]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
handleApprove();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [handleApprove]);
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"
? "Jira Issue Deletion Rejected"
: phase === "processing" || phase === "complete"
? "Jira Issue Deletion Approved"
: "Delete Jira Issue"}
</p>
{phase === "processing" ? (
<TextShimmerLoader text="Deleting issue" size="sm" />
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">Issue deleted</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">Issue deletion was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
</p>
)}
</div>
</div>
{/* Context section — account + issue info */}
{phase !== "rejected" && context && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none">
{context.error ? (
<p className="text-sm text-destructive">{context.error}</p>
) : (
<>
{account && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Jira Account</p>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
{account.name}
</div>
</div>
)}
{issue && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Issue to Delete</p>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm space-y-1">
<div className="font-medium">
{issue.issue_identifier}: {issue.issue_title}
</div>
{issue.state && (
<div className="text-xs text-muted-foreground">{issue.state}</div>
)}
</div>
</div>
)}
</>
)}
</div>
</>
)}
{/* delete_from_kb toggle */}
{phase === "pending" && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 select-none">
<div className="flex items-center gap-2.5">
<Checkbox
id="jira-delete-from-kb"
checked={deleteFromKb}
onCheckedChange={(v) => setDeleteFromKb(v === true)}
className="shrink-0"
/>
<label htmlFor="jira-delete-from-kb" className="flex-1 cursor-pointer">
<span className="text-sm text-foreground">Also remove from knowledge base</span>
<p className="text-xs text-muted-foreground mt-0.5">
This will permanently delete the issue from your knowledge base (cannot be undone)
</p>
</label>
</div>
</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">
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
<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>
);
}
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">Jira authentication expired</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function InsufficientPermissionsCard({ result }: { result: InsufficientPermissionsResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Additional Jira permissions required
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">Failed to delete Jira issue</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function NotFoundCard({ result }: { result: NotFoundResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">Issue not found</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function WarningCard({ result }: { result: WarningResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="flex items-start gap-3 border-b px-5 py-4">
<p className="text-sm font-medium text-amber-600 dark:text-amber-500">Partial success</p>
</div>
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.warning}</p>
</div>
</div>
);
}
function SuccessCard({ result }: { result: SuccessResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-foreground">
{result.message || "Jira issue deleted successfully"}
</p>
</div>
{result.deleted_from_kb && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 text-xs">
<span className="text-green-600 dark:text-green-500">
Also removed from knowledge base
</span>
</div>
</>
)}
</div>
);
}
export const DeleteJiraIssueToolUI = ({
result,
}: ToolCallMessagePartProps<
{ issue_title_or_key: string; delete_from_kb?: boolean },
DeleteJiraIssueResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
interruptData={result as InterruptResult<DeleteJiraIssueInterruptContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}
if (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as { status: string }).status === "rejected"
) {
return null;
}
if (isNotFoundResult(result)) return <NotFoundCard result={result} />;
if (isInsufficientPermissionsResult(result))
return <InsufficientPermissionsCard result={result} />;
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
if (isWarningResult(result)) return <WarningCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />;
};