refactor: unify interrupt handling in Linear and Notion tools

Refactored the create, update, and delete functionalities in Linear and Notion tools to utilize a consistent InterruptResult interface with specific context types. This change enhances code clarity and maintains uniformity in handling user approvals by integrating the useHitlDecision hook for decision dispatching.
This commit is contained in:
Anish Sarkar 2026-04-13 20:22:47 +05:30
parent e6065b6793
commit 5169d3d56c
6 changed files with 129 additions and 301 deletions

View file

@ -18,6 +18,8 @@ import {
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface LinearLabel {
id: string;
@ -64,23 +66,9 @@ interface LinearWorkspace {
auth_expired?: boolean;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
context?: {
workspaces?: LinearWorkspace[];
error?: string;
};
interface LinearCreateIssueContext {
workspaces?: LinearWorkspace[];
error?: string;
}
interface SuccessResult {
@ -103,16 +91,7 @@ interface AuthErrorResult {
connector_type: string;
}
type CreateLinearIssueResult = InterruptResult | SuccessResult | ErrorResult | AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
type CreateLinearIssueResult = InterruptResult<LinearCreateIssueContext> | SuccessResult | ErrorResult | AuthErrorResult;
function isErrorResult(result: unknown): result is ErrorResult {
return (
@ -138,12 +117,8 @@ function ApprovalCard({
onDecision,
}: {
args: { title: string; description?: string };
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<LinearCreateIssueContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -609,18 +584,16 @@ export const CreateLinearIssueToolUI = ({
args,
result,
}: ToolCallMessagePartProps<{ title: string; description?: string }, CreateLinearIssueResult>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
interruptData={result as InterruptResult<LinearCreateIssueContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -7,32 +7,20 @@ 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 { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "reject">;
}>;
interrupt_type?: string;
context?: {
workspace?: { id: number; organization_name: string };
issue?: {
id: string;
identifier: string;
title: string;
state?: string;
document_id?: number;
indexed_at?: string;
};
error?: string;
interface LinearDeleteIssueContext {
workspace?: { id: number; organization_name: string };
issue?: {
id: string;
identifier: string;
title: string;
state?: string;
document_id?: number;
indexed_at?: string;
};
error?: string;
}
interface SuccessResult {
@ -65,22 +53,13 @@ interface AuthErrorResult {
}
type DeleteLinearIssueResult =
| InterruptResult
| InterruptResult<LinearDeleteIssueContext>
| SuccessResult
| ErrorResult
| NotFoundResult
| WarningResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -123,12 +102,8 @@ function ApprovalCard({
interruptData,
onDecision,
}: {
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<LinearDeleteIssueContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -366,18 +341,15 @@ export const DeleteLinearIssueToolUI = ({
{ issue_ref: string; delete_from_kb?: boolean },
DeleteLinearIssueResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
interruptData={result}
onDecision={(decision) => {
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
interruptData={result as InterruptResult<LinearDeleteIssueContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -18,6 +18,8 @@ import {
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface LinearLabel {
id: string;
@ -45,45 +47,31 @@ interface LinearPriority {
label: string;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
context?: {
workspace?: { id: number; organization_name: string };
priorities?: LinearPriority[];
issue?: {
id: string;
identifier: string;
title: string;
description?: string;
priority: number;
url: string;
current_state?: LinearState;
current_assignee?: { id: string; name: string; email: string } | null;
current_labels?: LinearLabel[];
team_id: string;
document_id: number;
};
team?: {
id: string;
name: string;
key: string;
states: LinearState[];
members: LinearMember[];
labels: LinearLabel[];
};
error?: string;
interface LinearUpdateIssueContext {
workspace?: { id: number; organization_name: string };
priorities?: LinearPriority[];
issue?: {
id: string;
identifier: string;
title: string;
description?: string;
priority: number;
url: string;
current_state?: LinearState;
current_assignee?: { id: string; name: string; email: string } | null;
current_labels?: LinearLabel[];
team_id: string;
document_id: number;
};
team?: {
id: string;
name: string;
key: string;
states: LinearState[];
members: LinearMember[];
labels: LinearLabel[];
};
error?: string;
}
interface SuccessResult {
@ -111,21 +99,12 @@ interface AuthErrorResult {
}
type UpdateLinearIssueResult =
| InterruptResult
| InterruptResult<LinearUpdateIssueContext>
| SuccessResult
| ErrorResult
| NotFoundResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -167,12 +146,8 @@ function ApprovalCard({
new_priority?: number;
new_label_names?: string[];
};
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<LinearUpdateIssueContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
@ -752,18 +727,16 @@ export const UpdateLinearIssueToolUI = ({
},
UpdateLinearIssueResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
interruptData={result as InterruptResult<LinearUpdateIssueContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -16,41 +16,27 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
interface NotionCreatePageContext {
accounts?: Array<{
id: number;
name: string;
args: Record<string, unknown>;
description?: string;
workspace_id: string | null;
workspace_name: string;
workspace_icon: string;
auth_expired?: boolean;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
message?: string;
context?: {
accounts?: Array<{
id: number;
name: string;
workspace_id: string | null;
workspace_name: string;
workspace_icon: string;
auth_expired?: boolean;
}>;
parent_pages?: Record<
number,
Array<{
page_id: string;
title: string;
document_id: number;
}>
>;
error?: string;
};
parent_pages?: Record<
number,
Array<{
page_id: string;
title: string;
document_id: number;
}>
>;
error?: string;
}
interface SuccessResult {
@ -75,16 +61,7 @@ interface AuthErrorResult {
connector_type: string;
}
type CreateNotionPageResult = InterruptResult | SuccessResult | ErrorResult | AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
type CreateNotionPageResult = InterruptResult<NotionCreatePageContext> | SuccessResult | ErrorResult | AuthErrorResult;
function isAuthErrorResult(result: unknown): result is AuthErrorResult {
return (
@ -110,12 +87,8 @@ function ApprovalCard({
onDecision,
}: {
args: Record<string, unknown>;
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<NotionCreatePageContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -449,19 +422,16 @@ export const CreateNotionPageToolUI = ({
args,
result,
}: ToolCallMessagePartProps<{ title: string; content: string }, CreateNotionPageResult>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
interruptData={result as InterruptResult<NotionCreatePageContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -7,36 +7,22 @@ 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 { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{
interface NotionDeletePageContext {
account?: {
id: number;
name: string;
args: Record<string, unknown>;
description?: string;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "reject">;
}>;
interrupt_type?: string;
message?: string;
context?: {
account?: {
id: number;
name: string;
workspace_id: string | null;
workspace_name: string;
workspace_icon: string;
};
page_id?: string;
current_title?: string;
document_id?: number;
indexed_at?: string;
error?: string;
workspace_id: string | null;
workspace_name: string;
workspace_icon: string;
};
page_id?: string;
current_title?: string;
document_id?: number;
indexed_at?: string;
error?: string;
}
interface SuccessResult {
@ -73,22 +59,13 @@ interface AuthErrorResult {
}
type DeleteNotionPageResult =
| InterruptResult
| InterruptResult<NotionDeletePageContext>
| SuccessResult
| ErrorResult
| InfoResult
| WarningResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -131,12 +108,8 @@ function ApprovalCard({
interruptData,
onDecision,
}: {
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<NotionDeletePageContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -378,18 +351,15 @@ export const DeleteNotionPageToolUI = ({
{ page_title: string; delete_from_kb?: boolean },
DeleteNotionPageResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
interruptData={result}
onDecision={(decision) => {
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
interruptData={result as InterruptResult<NotionDeletePageContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -9,36 +9,22 @@ 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 { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
interface NotionUpdatePageContext {
account?: {
id: number;
name: string;
args: Record<string, unknown>;
description?: string;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
message?: string;
context?: {
account?: {
id: number;
name: string;
workspace_id: string | null;
workspace_name: string;
workspace_icon: string;
};
page_id?: string;
current_title?: string;
document_id?: number;
indexed_at?: string;
error?: string;
workspace_id: string | null;
workspace_name: string;
workspace_icon: string;
};
page_id?: string;
current_title?: string;
document_id?: number;
indexed_at?: string;
error?: string;
}
interface SuccessResult {
@ -69,21 +55,12 @@ interface AuthErrorResult {
}
type UpdateNotionPageResult =
| InterruptResult
| InterruptResult<NotionUpdatePageContext>
| SuccessResult
| ErrorResult
| InfoResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -117,12 +94,8 @@ function ApprovalCard({
onDecision,
}: {
args: Record<string, unknown>;
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<NotionUpdatePageContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -399,19 +372,16 @@ export const UpdateNotionPageToolUI = ({
args,
result,
}: ToolCallMessagePartProps<{ page_title: string; content: string }, UpdateNotionPageResult>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
interruptData={result as InterruptResult<NotionUpdatePageContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}