diff --git a/surfsense_web/components/tool-ui/update-linear-issue.tsx b/surfsense_web/components/tool-ui/update-linear-issue.tsx new file mode 100644 index 000000000..4bef27829 --- /dev/null +++ b/surfsense_web/components/tool-ui/update-linear-issue.tsx @@ -0,0 +1,769 @@ +"use client"; + +import { makeAssistantToolUI } from "@assistant-ui/react"; +import { + AlertTriangleIcon, + CheckIcon, + InfoIcon, + Loader2Icon, + PencilIcon, + XIcon, +} from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; + +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; + }>; + 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; +} + +type UpdateLinearIssueResult = InterruptResult | SuccessResult | ErrorResult | NotFoundResult; + +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 ApprovalCard({ + interruptData, + onDecision, +}: { + interruptData: InterruptResult; + onDecision: (decision: { + type: "approve" | "reject" | "edit"; + message?: string; + edited_action?: { name: string; args: Record }; + }) => 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 [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>( + interruptData.__decided__ ?? null + ); + const [isEditing, setIsEditing] = useState(false); + const [editedTitle, setEditedTitle] = useState( + actionArgs.new_title ? String(actionArgs.new_title) : "" + ); + const [editedDescription, setEditedDescription] = useState( + actionArgs.new_description ? String(actionArgs.new_description) : "" + ); + const [selectedStateId, setSelectedStateId] = useState( + actionArgs.new_state_id ? String(actionArgs.new_state_id) : "__none__" + ); + const [selectedAssigneeId, setSelectedAssigneeId] = useState( + actionArgs.new_assignee_id ? String(actionArgs.new_assignee_id) : "__none__" + ); + const [selectedPriority, setSelectedPriority] = useState( + actionArgs.new_priority != null ? String(actionArgs.new_priority) : "__none__" + ); + const [selectedLabelIds, setSelectedLabelIds] = useState( + Array.isArray(actionArgs.new_label_ids) ? (actionArgs.new_label_ids as string[]) : [] + ); + + 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[]; + } + + function buildFinalArgs() { + return { + issue_id: issue?.id, + document_id: issue?.document_id, + connector_id: context?.workspace?.id, + new_title: editedTitle || null, + new_description: editedDescription || null, + new_state_id: selectedStateId === "__none__" ? null : selectedStateId, + new_assignee_id: selectedAssigneeId === "__none__" ? null : selectedAssigneeId, + new_priority: selectedPriority === "__none__" ? null : Number(selectedPriority), + new_label_ids: selectedLabelIds, + }; + } + + 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; + + return ( +
+ {/* Header */} +
+
+ +
+
+

Update Linear Issue

+

+ {isEditing ? "You can edit the arguments below" : "Requires your approval to proceed"} +

+
+
+ + {/* Context section — workspace + current issue (read-only) */} + {!decided && ( +
+ {context?.error ? ( +

{context.error}

+ ) : ( + <> + {context?.workspace && ( +
+
Linear Account
+
+ {context.workspace.organization_name} +
+
+ )} + + {issue && ( +
+
Current Issue
+
+
+ {issue.identifier}: {issue.title} +
+
+ {issue.current_state && ( + + {issue.current_state.name} + + )} + {issue.current_assignee && {issue.current_assignee.name}} + {priorities.find((p) => p.priority === issue.priority) && ( + {priorities.find((p) => p.priority === issue.priority)?.label} + )} +
+ {issue.current_labels && issue.current_labels.length > 0 && ( +
+ {issue.current_labels.map((label) => ( + + {label.name} + + ))} +
+ )} + {issue.url && ( + + Open in Linear ↗ + + )} +
+
+ )} + + )} +
+ )} + + {/* Display mode — proposed changes */} + {!isEditing && ( +
+ {hasProposedChanges ? ( + <> + {actionArgs.new_title && ( +
+

New Title

+

{String(actionArgs.new_title)}

+
+ )} + {actionArgs.new_description && ( +
+

New Description

+

+ {String(actionArgs.new_description)} +

+
+ )} + {proposedStateName && ( +
+

New State

+

{proposedStateName}

+
+ )} + {proposedAssigneeName && ( +
+

New Assignee

+

{proposedAssigneeName}

+
+ )} + {proposedPriorityLabel && ( +
+

New Priority

+

{proposedPriorityLabel}

+
+ )} + {proposedLabelObjects.length > 0 && ( +
+

New Labels

+
+ {proposedLabelObjects.map((label) => ( + + {label.name} + + ))} +
+
+ )} + + ) : ( +

No changes proposed

+ )} +
+ )} + + {/* Edit mode */} + {isEditing && !decided && ( +
+
+ + setEditedTitle(e.target.value)} + placeholder="Leave empty to keep current title" + /> +
+ +
+ +