1import {
2 agentPortalSidebarOptions,
3 showShortcutsModal,
4} from "@/components/layouts/layoutSettings";
5import {
6 setActiveSettingsTab,
7 tabs as settingsTabs,
8 showSettingsModal,
9} from "@/components/Settings/settingsModal";
10import { router } from "@/router";
11import { useAgentStatusStore } from "@/stores/agentStatus";
12import { useAuthStore } from "@/stores/auth";
13import { useTelephonyStore } from "@/stores/telephony";
14import { parseColor } from "@/stores/ticketStatus";
15import { __ } from "@/translation";
16import { useColorScheme } from "frappe-ui";
17import { GROUP, type Command } from "./paletteTypes";
18import { recentTickets } from "./recentTickets";
19import { ticketListCommands } from "./ticketListCommands";
20
21import LucideActivity from "~icons/lucide/activity";
22import LucideClock from "~icons/lucide/clock";
23import LucideFileSearch from "~icons/lucide/file-search";
24import LucideKeyboard from "~icons/lucide/keyboard";
25import LucideLogOut from "~icons/lucide/log-out";
26import LucideMoon from "~icons/lucide/moon";
27import LucidePlus from "~icons/lucide/plus";
28import LucideSettings from "~icons/lucide/settings";
29import LucideSun from "~icons/lucide/sun";
30import LucideTicket from "~icons/lucide/ticket";
31
32/** Extra root rows per route. Values stay lazy: they read page-published state. */
33const routeCommands: Record<string, () => Command[]> = {
34 TicketsAgent: ticketListCommands,
35};
36
37/** The global list. The open ticket's actions live in the context chip, not here. */
38export function buildRootCommands(query: string): Command[] {
39 const route = router.currentRoute.value;
40 return [
41 ...ticketJumpCommand(query),
42 ...(routeCommands[String(route.name ?? "")]?.() ?? []),
43 ...(query ? [] : recentTicketCommands()),
44 ...navigateCommands(),
45 ...createCommands(),
46 ...accountCommands(),
47 ];
48}
49
50/** "#234" jumps straight to that ticket — the one prefix the palette already taught. */
51function ticketJumpCommand(query: string): Command[] {
52 const ticketId = query.trim().match(/^#?(\d+)$/)?.[1];
53 if (!ticketId) return [];
54 return [
55 {
56 id: `jump-${ticketId}`,
57 title: __("Go to ticket #{0}", [ticketId]),
58 group: GROUP.navigate,
59 icon: LucideTicket,
60 rank: 2000, // an explicit id beats any fuzzy match
61 perform: () => router.push({ name: "TicketAgent", params: { ticketId } }),
62 },
63 ];
64}
65
66// --- recents -------------------------------------------------------------
67export function recentTicketCommands(): Command[] {
68 const openTicketId = String(router.currentRoute.value.params.ticketId ?? "");
69 return recentTickets.value
70 .filter((ticket) => ticket.name !== openTicketId)
71 .map((ticket) => ({
72 id: `recent-ticket-${ticket.name}`,
73 title: ticket.subject,
74 subtitle: `#${ticket.name}`,
75 group: GROUP.recent,
76 icon: LucideClock,
77 perform: () =>
78 router.push({ name: "TicketAgent", params: { ticketId: ticket.name } }),
79 }));
80}
81
82// --- navigation, create, account -----------------------------------------
83
84/** The sidebar's own nav list, so labels and icons can't drift from it. */
85function navigateCommands(): Command[] {
86 const callingEnabled = useTelephonyStore().isCallingEnabled;
87 return agentPortalSidebarOptions
88 .filter((option) => callingEnabled || option.label !== __("Call Logs"))
89 .map((option) => ({
90 id: `go-${option.to}`,
91 title: __(option.label),
92 group: GROUP.navigate,
93 icon: option.icon,
94 weight: 0.7, // nav links are noise once the user has typed something real
95 perform: () => router.push({ name: option.to }),
96 }));
97}
98
99function createCommands(): Command[] {
100 return [
101 {
102 id: "new-ticket",
103 title: __("New ticket"),
104 group: GROUP.create,
105 icon: LucidePlus,
106 keywords: "create raise add",
107 perform: () => router.push({ name: "TicketAgentNew" }),
108 },
109 {
110 id: "new-article",
111 title: __("New article"),
112 group: GROUP.create,
113 icon: LucidePlus,
114 keywords: "create knowledge base kb",
115 perform: () => router.push({ name: "NewArticle", params: { id: "new" } }),
116 },
117 ];
118}
119
120function accountCommands(): Command[] {
121 const { toggleColorScheme, colorScheme } = useColorScheme();
122 return [
123 {
124 id: "toggle-theme",
125 title: __("Toggle theme"),
126 group: GROUP.account,
127 icon: colorScheme.value === "dark" ? LucideSun : LucideMoon,
128 keywords: "dark light appearance",
129 perform: () => toggleColorScheme(),
130 },
131 {
132 id: "availability",
133 title: __("Set availability"),
134 group: GROUP.account,
135 icon: LucideActivity,
136 // Live status names too, so site-defined values ("Lunch") match.
137 keywords:
138 "my status presence online offline away busy " +
139 useAgentStatusStore().statusOptions.join(" "),
140 children: () => availabilityChildren(),
141 },
142 {
143 id: "settings",
144 title: __("Settings"),
145 group: GROUP.account,
146 icon: LucideSettings,
147 hint: "Mod+,",
148 keywords: "preferences email agents teams sla telephony erpnext",
149 children: () => settingsChildren(),
150 },
151 {
152 id: "shortcuts",
153 title: __("Keyboard shortcuts"),
154 group: GROUP.account,
155 icon: LucideKeyboard,
156 hint: "Mod+/",
157 keywords: "keys hotkeys bindings help",
158 perform: () => (showShortcutsModal.value = true),
159 },
160 {
161 id: "logout",
162 title: __("Log out"),
163 group: GROUP.account,
164 icon: LucideLogOut,
165 keywords: "sign out exit",
166 perform: () => useAuthStore().logout(),
167 },
168 ];
169}
170
171/** One row per settings tab, from the already permission-filtered `tabs` computed. */
172function settingsChildren(): Command[] {
173 return settingsTabs.value.flatMap((section) =>
174 section.items.map((item) => ({
175 id: `settings-${item.label}`,
176 title: item.label,
177 // Keep the settings modal's own section headers as palette groups.
178 group: section.label,
179 // Profile's icon is a prebuilt Avatar VNode, which `:is` can't render.
180 icon: isVNode(item.icon) ? LucideSettings : item.icon,
181 perform: () => openSettingsTab(item.label),
182 }))
183 );
184}
185
186function isVNode(value: unknown): boolean {
187 return Boolean(value && (value as { __v_isVNode?: boolean }).__v_isVNode);
188}
189
190function openSettingsTab(label: string): void {
191 setActiveSettingsTab(label as Parameters<typeof setActiveSettingsTab>[0]);
192 showSettingsModal.value = true;
193}
194
195async function availabilityChildren(): Promise<Command[]> {
196 const store = useAgentStatusStore();
197 // The level snapshots this result; a cold store would freeze it empty.
198 await store.statuses.list.promise;
199 return (store.statusOptions ?? []).map((status: string) => ({
200 id: `availability-${status}`,
201 title: __(status),
202 group: "Set availability",
203 dotClass: parseColor(store.getStatus(status)?.color ?? "Gray"),
204 checked: status === store.myStatus,
205 perform: () => store.setMyStatus(status),
206 }));
207}
208
209/** Never a dead end: the typed text carries into whatever the user picks. */
210export function buildFallbackCommands(query: string): Command[] {
211 const commands: Command[] = [
212 {
213 id: "fallback-search",
214 title: __('Search for "{0}"', [query]),
215 titleSuffix: __("(advanced search)"),
216 group: "",
217 icon: LucideFileSearch,
218 perform: () => router.push({ name: "SearchAgent", query: { q: query } }),
219 },
220 ];
221 // "#123" is someone reaching for a ticket, not naming a new one.
222 if (!query.startsWith("#")) {
223 commands.push({
224 id: "fallback-create",
225 title: __('Create ticket "{0}"', [query]),
226 group: "",
227 icon: LucidePlus,
228 perform: () =>
229 router.push({ name: "TicketAgentNew", query: { subject: query } }),
230 });
231 }
232 return commands;
233}
234