1import {
2 replyComposer,
3 showEmailBox,
4 toggleEmailBox,
5} from "@/pages/ticket/modalStates";
6import { capture } from "@/telemetry";
7import { __ } from "@/translation";
8import { RenderedSavedReply } from "@/types";
9import { createListResource, createResource, toast } from "frappe-ui";
10import { nextTick } from "vue";
11import {
12 CONTEXT_WEIGHT,
13 FLAT_OPTION_WEIGHT,
14 GROUP,
15 type Command,
16} from "./paletteTypes";
17import {
18 mostUsedFirst,
19 topSavedReplies,
20 withUse,
21 withoutReply,
22 type SavedReplyUsage,
23} from "./savedReplyRanking";
24import { userStorage } from "@/composables/userStorage";
25
26import LucideMessageSquareQuote from "~icons/lucide/message-square-quote";
27
28interface SavedReplyRow {
29 name: string;
30 title: string;
31}
32
33/** What it takes to earn a root-level row, rather than guessing at one. */
34const FLAT_MIN_USES = 5;
35const FLAT_MAX_ROWS = 3;
36
37// A bare noun leading with "Saved": the prefix match keeps this row above the
38// individual replies on a "saved" query (pinned in fuzzyScore.check.ts).
39const GROUP_TITLE = "Saved Replies";
40const SUBTITLE = "Saved Reply";
41
42/** Drills into saved replies, then opens the composer pre-filled — never
43 * send-on-select; the agent must see what goes to a customer. */
44export function savedReplyCommands(ticketId: string): Command[] {
45 return [
46 {
47 id: "ticket-saved-reply",
48 title: __(GROUP_TITLE),
49 group: GROUP.ticket,
50 weight: CONTEXT_WEIGHT,
51 icon: LucideMessageSquareQuote,
52 // "reply" singular is not a substring of "replies".
53 keywords: "reply template canned macro snippet",
54 children: () => savedReplyChildren(ticketId),
55 },
56 ...topSavedReplyCommands(ticketId),
57 ];
58}
59
60const savedReplies = createListResource({
61 doctype: "OD Saved Reply",
62 fields: ["name", "title"],
63 // Unfiltered by scope: the server already limits this to what the agent may use.
64 orderBy: "modified desc",
65 pageLength: 999,
66});
67
68/** Inside this level the query filters replies only — no ticket/command collisions. */
69async function savedReplyChildren(ticketId: string): Promise<Command[]> {
70 if (!savedReplies.data) await savedReplies.reload();
71 const replies = (savedReplies.data ?? []) as SavedReplyRow[];
72 return mostUsedFirst(replies, usage.value).map((reply) => ({
73 id: `saved-reply-${reply.name}`,
74 title: reply.title,
75 group: GROUP_TITLE,
76 icon: LucideMessageSquareQuote,
77 perform: () => applySavedReply(ticketId, reply.name, reply.title),
78 }));
79}
80
81/** Most-used replies as root rows, titled from the usage record — no fetch,
82 * nothing to block the root build. */
83function topSavedReplyCommands(ticketId: string): Command[] {
84 return topSavedReplies(usage.value, FLAT_MIN_USES, FLAT_MAX_ROWS).map(
85 (reply) => ({
86 id: `saved-reply-flat-${reply.name}`,
87 title: reply.title,
88 // Subtitle, not title prefix: a bare reply title at root reads as a ticket.
89 subtitle: __(SUBTITLE),
90 group: "Frequent replies",
91 icon: LucideMessageSquareQuote,
92 hideWhenEmpty: true,
93 weight: FLAT_OPTION_WEIGHT,
94 perform: () => applySavedReply(ticketId, reply.name, reply.title),
95 })
96 );
97}
98
99const renderSavedReply = createResource({
100 url: "opendesk.api.saved_replies.get_rendered_saved_reply",
101});
102
103async function applySavedReply(
104 ticketId: string,
105 replyId: string,
106 title: string
107): Promise<void> {
108 let reply: RenderedSavedReply;
109 try {
110 reply = await renderSavedReply.submit({
111 saved_reply_id: replyId,
112 ticket_id: ticketId,
113 });
114 } catch {
115 // A reply deleted after it earned a flat row would otherwise fail forever.
116 forgetSavedReply(replyId);
117 toast.error(__("Could not load {0}", title));
118 return;
119 }
120
121 // Toggle would *close* an already-open composer, discarding a draft.
122 if (!showEmailBox.value) toggleEmailBox();
123 await nextTick(); // v-show has to reveal the editor before it can take focus
124
125 const insert = replyComposer.value;
126 if (!insert) {
127 toast.error(__("Could not open the reply box"));
128 return;
129 }
130 insert(reply);
131 recordSavedReplyUse(replyId, title);
132 capture("saved_reply_applied", { data: { source: "command_palette" } });
133}
134
135// --- per-agent usage -----------------------------------------------------
136
137const usage = userStorage<SavedReplyUsage>("od_saved_reply_uses", {});
138
139/** Also called by the composer's selector modal, so promotion counts both surfaces. */
140export function recordSavedReplyUse(replyId: string, title: string): void {
141 usage.value = withUse(usage.value, replyId, title);
142}
143
144function forgetSavedReply(replyId: string): void {
145 usage.value = withoutReply(usage.value, replyId);
146}
147