mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-27 17:56:25 +02:00
- Updated various card components across Google Drive, Linear, and Notion tools to include the `select-none` class for improved user interaction by preventing text selection. - Ensured consistent styling and behavior in loading states and error handling across different tool UIs, enhancing overall usability and visual coherence.
760 lines
23 KiB
TypeScript
760 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import { makeAssistantToolUI } from "@assistant-ui/react";
|
|
import { useSetAtom } from "jotai";
|
|
import { CornerDownLeftIcon, Pen } from "lucide-react";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
|
import { PlateEditor } from "@/components/editor/plate-editor";
|
|
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
|
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
|
|
|
interface LinearLabel {
|
|
id: string;
|
|
name: string;
|
|
color: string;
|
|
}
|
|
|
|
interface LinearState {
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
color: string;
|
|
}
|
|
|
|
interface LinearMember {
|
|
id: string;
|
|
name: string;
|
|
displayName: string;
|
|
email: string;
|
|
active: boolean;
|
|
}
|
|
|
|
interface LinearPriority {
|
|
priority: number;
|
|
label: string;
|
|
}
|
|
|
|
interface InterruptResult {
|
|
__interrupt__: true;
|
|
__decided__?: "approve" | "reject" | "edit";
|
|
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 SuccessResult {
|
|
status: "success";
|
|
identifier: string;
|
|
url: string;
|
|
message?: string;
|
|
}
|
|
|
|
interface ErrorResult {
|
|
status: "error";
|
|
message: string;
|
|
}
|
|
|
|
interface NotFoundResult {
|
|
status: "not_found";
|
|
message: string;
|
|
}
|
|
|
|
interface AuthErrorResult {
|
|
status: "auth_error";
|
|
message: string;
|
|
connector_id?: number;
|
|
connector_type: string;
|
|
}
|
|
|
|
type UpdateLinearIssueResult = InterruptResult | 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" &&
|
|
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 isAuthErrorResult(result: unknown): result is AuthErrorResult {
|
|
return (
|
|
typeof result === "object" &&
|
|
result !== null &&
|
|
"status" in result &&
|
|
(result as AuthErrorResult).status === "auth_error"
|
|
);
|
|
}
|
|
|
|
function ApprovalCard({
|
|
interruptData,
|
|
onDecision,
|
|
}: {
|
|
interruptData: InterruptResult;
|
|
onDecision: (decision: {
|
|
type: "approve" | "reject" | "edit";
|
|
message?: string;
|
|
edited_action?: { name: string; args: Record<string, unknown> };
|
|
}) => void;
|
|
}) {
|
|
const actionArgs = interruptData.action_requests[0]?.args ?? {};
|
|
const context = interruptData.context;
|
|
const team = context?.team;
|
|
const priorities = context?.priorities ?? [];
|
|
const issue = context?.issue;
|
|
|
|
const initialEditState = {
|
|
title: actionArgs.new_title ? String(actionArgs.new_title) : (issue?.title ?? ""),
|
|
description: actionArgs.new_description
|
|
? String(actionArgs.new_description)
|
|
: (issue?.description ?? ""),
|
|
stateId: actionArgs.new_state_id
|
|
? String(actionArgs.new_state_id)
|
|
: (issue?.current_state?.id ?? "__none__"),
|
|
assigneeId: actionArgs.new_assignee_id
|
|
? String(actionArgs.new_assignee_id)
|
|
: (issue?.current_assignee?.id ?? "__none__"),
|
|
priority:
|
|
actionArgs.new_priority != null
|
|
? String(actionArgs.new_priority)
|
|
: String(issue?.priority ?? 0),
|
|
labelIds: Array.isArray(actionArgs.new_label_ids)
|
|
? (actionArgs.new_label_ids as string[])
|
|
: (issue?.current_labels?.map((l) => l.id) ?? []),
|
|
};
|
|
|
|
const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>(
|
|
interruptData.__decided__ ?? null
|
|
);
|
|
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
|
const [editedArgs, setEditedArgs] = useState(initialEditState);
|
|
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
|
|
|
|
const reviewConfig = interruptData.review_configs[0];
|
|
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
|
|
const canEdit = allowedDecisions.includes("edit");
|
|
|
|
function resolveStateName(stateId: string | null) {
|
|
if (!stateId || stateId === "__none__") return null;
|
|
return team?.states.find((s) => s.id === stateId)?.name ?? stateId;
|
|
}
|
|
|
|
function resolveAssigneeName(assigneeId: string | null) {
|
|
if (!assigneeId || assigneeId === "__none__") return null;
|
|
const m = team?.members.find((m) => m.id === assigneeId);
|
|
return m ? `${m.name} (${m.email})` : assigneeId;
|
|
}
|
|
|
|
function resolvePriorityLabel(p: string | null) {
|
|
if (!p || p === "__none__") return null;
|
|
return priorities.find((pr) => String(pr.priority) === p)?.label ?? p;
|
|
}
|
|
|
|
function resolveLabelNames(ids: string[]) {
|
|
return ids.map((id) => team?.labels.find((l) => l.id === id)).filter(Boolean) as LinearLabel[];
|
|
}
|
|
|
|
const buildFinalArgs = useCallback(() => {
|
|
const labelsWereProposed = Array.isArray(actionArgs.new_label_ids);
|
|
return {
|
|
issue_id: issue?.id,
|
|
document_id: issue?.document_id,
|
|
connector_id: context?.workspace?.id,
|
|
new_title: editedArgs.title || null,
|
|
new_description: editedArgs.description || null,
|
|
new_state_id: editedArgs.stateId === "__none__" ? null : editedArgs.stateId,
|
|
new_assignee_id: editedArgs.assigneeId === "__none__" ? null : editedArgs.assigneeId,
|
|
new_priority: Number(editedArgs.priority),
|
|
new_label_ids:
|
|
labelsWereProposed || editedArgs.labelIds.length > 0 ? editedArgs.labelIds : null,
|
|
};
|
|
}, [actionArgs.new_label_ids, issue?.id, issue?.document_id, context?.workspace?.id, editedArgs]);
|
|
|
|
const proposedStateName = resolveStateName(
|
|
actionArgs.new_state_id ? String(actionArgs.new_state_id) : null
|
|
);
|
|
const proposedAssigneeName = resolveAssigneeName(
|
|
actionArgs.new_assignee_id ? String(actionArgs.new_assignee_id) : null
|
|
);
|
|
const proposedPriorityLabel = resolvePriorityLabel(
|
|
actionArgs.new_priority != null ? String(actionArgs.new_priority) : null
|
|
);
|
|
const proposedLabelObjects = resolveLabelNames(
|
|
Array.isArray(actionArgs.new_label_ids) ? (actionArgs.new_label_ids as string[]) : []
|
|
);
|
|
|
|
const hasProposedChanges =
|
|
actionArgs.new_title ||
|
|
actionArgs.new_description ||
|
|
proposedStateName ||
|
|
proposedAssigneeName ||
|
|
proposedPriorityLabel ||
|
|
proposedLabelObjects.length > 0;
|
|
|
|
const handleApprove = useCallback(() => {
|
|
if (decided || isPanelOpen) return;
|
|
if (!allowedDecisions.includes("approve")) return;
|
|
setDecided("approve");
|
|
onDecision({
|
|
type: "approve",
|
|
edited_action: {
|
|
name: interruptData.action_requests[0].name,
|
|
args: buildFinalArgs(),
|
|
},
|
|
});
|
|
}, [decided, isPanelOpen, allowedDecisions, onDecision, interruptData, buildFinalArgs]);
|
|
|
|
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]);
|
|
|
|
if (decided && decided !== "reject") return null;
|
|
|
|
return (
|
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all 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">
|
|
{decided === "reject"
|
|
? "Linear Issue Update Rejected"
|
|
: decided === "approve" || decided === "edit"
|
|
? "Linear Issue Update Approved"
|
|
: "Update Linear Issue"}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{decided === "reject"
|
|
? "Issue update was cancelled"
|
|
: decided === "edit"
|
|
? "Issue update is in progress with your changes"
|
|
: decided === "approve"
|
|
? "Issue update is in progress"
|
|
: "Requires your approval to proceed"}
|
|
</p>
|
|
</div>
|
|
{!decided && canEdit && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="rounded-lg text-muted-foreground -mt-1 -mr-2"
|
|
onClick={() => {
|
|
setIsPanelOpen(true);
|
|
openHitlEditPanel({
|
|
title: editedArgs.title,
|
|
content: editedArgs.description,
|
|
toolName: "Linear Issue",
|
|
onSave: (newTitle, newDescription) => {
|
|
setIsPanelOpen(false);
|
|
setEditedArgs((prev) => ({
|
|
...prev,
|
|
title: newTitle,
|
|
description: newDescription,
|
|
}));
|
|
setDecided("edit");
|
|
onDecision({
|
|
type: "edit",
|
|
edited_action: {
|
|
name: interruptData.action_requests[0].name,
|
|
args: {
|
|
...buildFinalArgs(),
|
|
new_title: newTitle || null,
|
|
new_description: newDescription || null,
|
|
},
|
|
},
|
|
});
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
<Pen className="size-3.5" />
|
|
Edit
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Context section — workspace + current issue (read-only) */}
|
|
{!decided && (
|
|
<>
|
|
<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>
|
|
) : (
|
|
<>
|
|
{context?.workspace && (
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">Linear Account</p>
|
|
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
|
|
{context.workspace.organization_name}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{issue && (
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">Current Issue</p>
|
|
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm space-y-1.5">
|
|
<div className="font-medium">
|
|
{issue.identifier}: {issue.title}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
|
{issue.current_state && (
|
|
<Badge
|
|
className="rounded-full border-0"
|
|
style={{
|
|
backgroundColor: `${issue.current_state.color}22`,
|
|
color: issue.current_state.color,
|
|
}}
|
|
>
|
|
{issue.current_state.name}
|
|
</Badge>
|
|
)}
|
|
{issue.current_assignee && <span>{issue.current_assignee.name}</span>}
|
|
{priorities.find((p) => p.priority === issue.priority) && (
|
|
<span>{priorities.find((p) => p.priority === issue.priority)?.label}</span>
|
|
)}
|
|
</div>
|
|
{issue.current_labels && issue.current_labels.length > 0 && (
|
|
<div className="flex flex-wrap gap-1">
|
|
{issue.current_labels.map((label) => (
|
|
<Badge
|
|
key={label.id}
|
|
className="rounded-full border-0 gap-1"
|
|
style={{
|
|
backgroundColor: `${label.color}22`,
|
|
color: label.color,
|
|
}}
|
|
>
|
|
{label.name}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
{issue.url && (
|
|
<a
|
|
href={issue.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs text-primary hover:underline"
|
|
>
|
|
Open in Linear ↗
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Editable context selects for state, assignee, priority, labels */}
|
|
{team && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">State</p>
|
|
<Select
|
|
value={editedArgs.stateId}
|
|
onValueChange={(v) => setEditedArgs({ ...editedArgs, stateId: v })}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Select state" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{team.states.map((s) => (
|
|
<SelectItem key={s.id} value={s.id}>
|
|
{s.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">Assignee</p>
|
|
<Select
|
|
value={editedArgs.assigneeId}
|
|
onValueChange={(v) => setEditedArgs({ ...editedArgs, assigneeId: v })}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Select assignee" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">Unassigned</SelectItem>
|
|
{team.members
|
|
.filter((m) => m.active)
|
|
.map((m) => (
|
|
<SelectItem key={m.id} value={m.id}>
|
|
{m.name} ({m.email})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">Priority</p>
|
|
<Select
|
|
value={editedArgs.priority}
|
|
onValueChange={(v) => setEditedArgs({ ...editedArgs, priority: v })}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Select priority" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{priorities.map((p) => (
|
|
<SelectItem key={p.priority} value={String(p.priority)}>
|
|
{p.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{team.labels.length > 0 && (
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">Labels</p>
|
|
<ToggleGroup
|
|
type="multiple"
|
|
value={editedArgs.labelIds}
|
|
onValueChange={(value) =>
|
|
setEditedArgs({ ...editedArgs, labelIds: value })
|
|
}
|
|
className="flex flex-wrap gap-1.5"
|
|
>
|
|
{team.labels.map((label) => {
|
|
const isSelected = editedArgs.labelIds.includes(label.id);
|
|
return (
|
|
<ToggleGroupItem
|
|
key={label.id}
|
|
value={label.id}
|
|
className="h-auto rounded-full border-0 px-0 py-0 shadow-none hover:bg-transparent data-[state=on]:bg-transparent"
|
|
>
|
|
<Badge
|
|
className={`cursor-pointer rounded-full gap-1 border transition-all ${
|
|
isSelected
|
|
? "font-semibold opacity-100 shadow-sm"
|
|
: "border-transparent opacity-55 hover:opacity-90"
|
|
}`}
|
|
style={{
|
|
backgroundColor: isSelected
|
|
? `${label.color}70`
|
|
: `${label.color}28`,
|
|
color: label.color,
|
|
borderColor: isSelected
|
|
? `${label.color}cc`
|
|
: "transparent",
|
|
}}
|
|
>
|
|
<span
|
|
className="size-1.5 rounded-full"
|
|
style={{ backgroundColor: label.color }}
|
|
/>
|
|
{label.name}
|
|
</Badge>
|
|
</ToggleGroupItem>
|
|
);
|
|
})}
|
|
</ToggleGroup>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Content preview — proposed changes */}
|
|
<div className="mx-5 h-px bg-border/50" />
|
|
<div className="px-5 pt-3">
|
|
{hasProposedChanges ? (
|
|
<>
|
|
{actionArgs.new_title && (
|
|
<p className="text-sm font-medium text-foreground">{String(actionArgs.new_title)}</p>
|
|
)}
|
|
{actionArgs.new_description && (
|
|
<div
|
|
className="max-h-[7rem] overflow-hidden text-sm"
|
|
style={{
|
|
maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
|
|
WebkitMaskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
|
|
}}
|
|
>
|
|
<PlateEditor
|
|
markdown={String(actionArgs.new_description)}
|
|
readOnly
|
|
preset="readonly"
|
|
editorVariant="none"
|
|
className="h-auto [&_[data-slate-editor]]:!min-h-0 [&_[data-slate-editor]>*:first-child]:!mt-0"
|
|
/>
|
|
</div>
|
|
)}
|
|
{proposedStateName && (
|
|
<div className="mt-2">
|
|
<span className="text-xs text-muted-foreground">State → </span>
|
|
<span className="text-xs font-medium">{proposedStateName}</span>
|
|
</div>
|
|
)}
|
|
{proposedAssigneeName && (
|
|
<div className="mt-1">
|
|
<span className="text-xs text-muted-foreground">Assignee → </span>
|
|
<span className="text-xs font-medium">{proposedAssigneeName}</span>
|
|
</div>
|
|
)}
|
|
{proposedPriorityLabel && (
|
|
<div className="mt-1">
|
|
<span className="text-xs text-muted-foreground">Priority → </span>
|
|
<span className="text-xs font-medium">{proposedPriorityLabel}</span>
|
|
</div>
|
|
)}
|
|
{proposedLabelObjects.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{proposedLabelObjects.map((label) => (
|
|
<Badge
|
|
key={label.id}
|
|
className="rounded-full border-0 gap-1"
|
|
style={{
|
|
backgroundColor: `${label.color}33`,
|
|
color: label.color,
|
|
}}
|
|
>
|
|
{label.name}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground italic pb-3">No changes proposed</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Action buttons - only shown when pending */}
|
|
{!decided && (
|
|
<>
|
|
<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}
|
|
>
|
|
Approve
|
|
<CornerDownLeftIcon className="size-3 opacity-60" />
|
|
</Button>
|
|
)}
|
|
{allowedDecisions.includes("reject") && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="rounded-lg text-muted-foreground"
|
|
onClick={() => {
|
|
setDecided("reject");
|
|
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">
|
|
Linear 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 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 update Linear 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 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 || "Linear issue updated successfully"}
|
|
</p>
|
|
</div>
|
|
<div className="mx-5 h-px bg-border/50" />
|
|
<div className="px-5 py-4 space-y-2 text-xs">
|
|
<div>
|
|
<span className="font-medium text-muted-foreground">Identifier: </span>
|
|
<span>{result.identifier}</span>
|
|
</div>
|
|
{result.url && (
|
|
<div>
|
|
<a
|
|
href={result.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary hover:underline"
|
|
>
|
|
Open in Linear
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const UpdateLinearIssueToolUI = makeAssistantToolUI<
|
|
{
|
|
issue_ref: string;
|
|
new_title?: string;
|
|
new_description?: string;
|
|
new_state_name?: string;
|
|
new_assignee_email?: string;
|
|
new_priority?: number;
|
|
new_label_names?: string[];
|
|
},
|
|
UpdateLinearIssueResult
|
|
>({
|
|
toolName: "update_linear_issue",
|
|
render: function UpdateLinearIssueUI({ result, status }) {
|
|
if (status.type === "running") {
|
|
return (
|
|
<div className="my-4 max-w-lg rounded-2xl border bg-muted/30 px-5 py-4 select-none">
|
|
<TextShimmerLoader text="Preparing Linear issue update..." size="sm" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!result) return null;
|
|
|
|
if (isInterruptResult(result)) {
|
|
return (
|
|
<ApprovalCard
|
|
interruptData={result}
|
|
onDecision={(decision) => {
|
|
window.dispatchEvent(
|
|
new CustomEvent("hitl-decision", { detail: { decisions: [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 (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
|
|
if (isErrorResult(result)) return <ErrorCard result={result} />;
|
|
|
|
return <SuccessCard result={result as SuccessResult} />;
|
|
},
|
|
});
|