@@ -63,7 +63,13 @@
-
+
+
+
diff --git a/frontend/apps/main/src/features/command/MacroPicker.vue b/frontend/apps/main/src/features/command/MacroPicker.vue
new file mode 100644
index 00000000..76895ba7
--- /dev/null
+++ b/frontend/apps/main/src/features/command/MacroPicker.vue
@@ -0,0 +1,190 @@
+
+
+
+
+
+
+
+
+ {{ macro.label }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('command.replyPreview') }}
+
+
+
+
+
+
+ {{ $t('globals.terms.action', 2) }}
+
+
+
+
+
+
+
{{ actionLabel(action) }}
+
+
+
+
+
+
+ {{ $t('command.selectAMacro') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/apps/main/src/features/command/SnoozeDatePicker.vue b/frontend/apps/main/src/features/command/SnoozeDatePicker.vue
new file mode 100644
index 00000000..0d9d8ad7
--- /dev/null
+++ b/frontend/apps/main/src/features/command/SnoozeDatePicker.vue
@@ -0,0 +1,96 @@
+
+
+
+
+
diff --git a/frontend/apps/main/src/features/command/providers/useAccountCommands.js b/frontend/apps/main/src/features/command/providers/useAccountCommands.js
new file mode 100644
index 00000000..02b00e60
--- /dev/null
+++ b/frontend/apps/main/src/features/command/providers/useAccountCommands.js
@@ -0,0 +1,79 @@
+import { computed } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { useColorMode } from '@vueuse/core'
+import {
+ CircleCheck,
+ Clock,
+ Keyboard,
+ LogOut,
+ Moon,
+ Sun,
+ UserRoundCog
+} from 'lucide-vue-next'
+import { useUserStore } from '@main/stores/user'
+import { useKeyboardShortcutsDialog } from '@main/composables/useKeyboardShortcutsDialog'
+import { SECTIONS } from '../sections'
+
+const AWAY_STATUSES = ['away_manual', 'away_and_reassigning']
+
+export function useAccountCommands() {
+ const { t } = useI18n()
+ const mode = useColorMode()
+ const userStore = useUserStore()
+ const shortcutsDialog = useKeyboardShortcutsDialog()
+
+ return computed(() => {
+ const status = userStore.user.availability_status
+ const isAway = AWAY_STATUSES.includes(status)
+ const isReassigning = status === 'away_and_reassigning'
+ const isDark = mode.value === 'dark'
+
+ return [
+ {
+ id: 'account.theme',
+ label: t(isDark ? 'command.switchToLightMode' : 'command.switchToDarkMode'),
+ keywords: [t('navigation.darkMode'), 'theme'],
+ section: SECTIONS.ACCOUNT,
+ icon: isDark ? Sun : Moon,
+ run: () => {
+ mode.value = isDark ? 'light' : 'dark'
+ }
+ },
+ {
+ id: 'account.availability',
+ label: t(isAway ? 'command.setOnline' : 'command.setAway'),
+ keywords: [t('navigation.away'), t('globals.terms.online'), t('globals.terms.status')],
+ section: SECTIONS.ACCOUNT,
+ icon: isAway ? CircleCheck : Clock,
+ run: () => userStore.updateUserAvailability(isAway ? 'online' : 'away_manual')
+ },
+ {
+ id: 'account.reassign',
+ label: t(isReassigning ? 'command.disableReassignReplies' : 'command.enableReassignReplies'),
+ keywords: [t('navigation.reassignReplies'), t('navigation.away')],
+ section: SECTIONS.ACCOUNT,
+ icon: UserRoundCog,
+ run: () =>
+ userStore.updateUserAvailability(isReassigning ? 'away_manual' : 'away_and_reassigning')
+ },
+ {
+ id: 'account.shortcuts',
+ label: t('navigation.keyboardShortcuts'),
+ section: SECTIONS.ACCOUNT,
+ icon: Keyboard,
+ shortcut: ['Ctrl', '/'],
+ run: shortcutsDialog.show
+ },
+ {
+ id: 'account.logout',
+ label: t('navigation.logout'),
+ keywords: ['sign out'],
+ section: SECTIONS.ACCOUNT,
+ icon: LogOut,
+ run: () => {
+ window.location.href = '/logout'
+ }
+ }
+ ]
+ })
+}
diff --git a/frontend/apps/main/src/features/command/providers/useBulkCommands.js b/frontend/apps/main/src/features/command/providers/useBulkCommands.js
new file mode 100644
index 00000000..6d0f9540
--- /dev/null
+++ b/frontend/apps/main/src/features/command/providers/useBulkCommands.js
@@ -0,0 +1,121 @@
+import { computed } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { CircleDot, Tag, TagIcon, UserPlus, Users, UsersRound, X } from 'lucide-vue-next'
+import { useConversationStore } from '@main/stores/conversation'
+import { useUsersStore } from '@main/stores/users'
+import { useTeamStore } from '@main/stores/team'
+import { useTagStore } from '@main/stores/tag'
+import { useBulkActions } from '@main/composables/useBulkActions'
+import { permissions as perms } from '@main/constants/permissions'
+import { SECTIONS } from '../sections'
+
+export function useBulkCommands() {
+ const { t } = useI18n()
+ const conversationStore = useConversationStore()
+ const usersStore = useUsersStore()
+ const teamStore = useTeamStore()
+ const tagStore = useTagStore()
+ const { bulkAssign, bulkAddTag, bulkUpdateStatus } = useBulkActions()
+
+ const section = SECTIONS.BULK
+
+ return computed(() => {
+ if (conversationStore.selectedCount === 0) return []
+ return [
+ {
+ id: 'bulk.status',
+ label: t('actions.setStatus'),
+ keywords: [t('globals.terms.status')],
+ section,
+ icon: CircleDot,
+ permission: perms.CONVERSATIONS_UPDATE_STATUS,
+ group: true
+ },
+ ...conversationStore.statusOptionsNoSnooze.map((status) => ({
+ id: `bulk.status.${status.value}`,
+ label: status.label,
+ parent: 'bulk.status',
+ icon: CircleDot,
+ run: () => bulkUpdateStatus(status.label)
+ })),
+ {
+ id: 'bulk.assign-agent',
+ label: t('actions.assignAgent'),
+ keywords: [t('globals.terms.agent')],
+ section,
+ icon: UserPlus,
+ permission: perms.CONVERSATIONS_UPDATE_USER_ASSIGNEE,
+ group: true,
+ placeholder: t('command.searchAgents'),
+ search: async (term) => [
+ {
+ id: 'bulk.assign-agent.none',
+ label: t('globals.terms.none'),
+ parent: 'bulk.assign-agent',
+ icon: X,
+ run: () => bulkAssign('user', 'none')
+ },
+ ...(await usersStore.searchUsers(term)).map((agent) => ({
+ id: `bulk.assign-agent.${agent.value}`,
+ label: agent.label,
+ parent: 'bulk.assign-agent',
+ icon: UserPlus,
+ run: () => bulkAssign('user', agent.value)
+ }))
+ ]
+ },
+ {
+ id: 'bulk.assign-team',
+ label: t('actions.assignTeam'),
+ keywords: [t('globals.terms.team')],
+ section,
+ icon: Users,
+ permission: perms.CONVERSATIONS_UPDATE_TEAM_ASSIGNEE,
+ group: true,
+ placeholder: t('command.searchTeams'),
+ search: async (term) => [
+ {
+ id: 'bulk.assign-team.none',
+ label: t('globals.terms.none'),
+ parent: 'bulk.assign-team',
+ icon: X,
+ run: () => bulkAssign('team', 'none')
+ },
+ ...(await teamStore.searchTeams(term)).map((team) => ({
+ id: `bulk.assign-team.${team.value}`,
+ label: team.label,
+ parent: 'bulk.assign-team',
+ icon: UsersRound,
+ run: () => bulkAssign('team', team.value)
+ }))
+ ]
+ },
+ {
+ id: 'bulk.add-tag',
+ label: t('actions.addTags'),
+ keywords: [t('globals.terms.tag')],
+ section,
+ icon: Tag,
+ permission: perms.CONVERSATIONS_UPDATE_TAGS,
+ group: true,
+ placeholder: t('command.searchTags'),
+ search: async (term) =>
+ (await tagStore.searchTagNames(term)).map((tag) => ({
+ id: `bulk.add-tag.${tag.value}`,
+ label: tag.label,
+ parent: 'bulk.add-tag',
+ icon: TagIcon,
+ run: () => bulkAddTag(tag.value)
+ }))
+ },
+ {
+ id: 'bulk.clear',
+ label: t('conversation.bulkActions.clearSelection'),
+ keywords: ['deselect'],
+ section,
+ icon: X,
+ run: () => conversationStore.clearSelection()
+ }
+ ]
+ })
+}
diff --git a/frontend/apps/main/src/features/command/providers/useContactCommands.js b/frontend/apps/main/src/features/command/providers/useContactCommands.js
new file mode 100644
index 00000000..3ad0b4ac
--- /dev/null
+++ b/frontend/apps/main/src/features/command/providers/useContactCommands.js
@@ -0,0 +1,79 @@
+import { computed } from 'vue'
+import { useRoute } from 'vue-router'
+import { useI18n } from 'vue-i18n'
+import {
+ Download,
+ MessageSquarePlus,
+ ShieldCheck,
+ ShieldOff,
+ StickyNote,
+ Trash2
+} from 'lucide-vue-next'
+import { useContactStore } from '@main/stores/contact'
+import { useEmitter } from '@main/composables/useEmitter'
+import { EMITTER_EVENTS, CONTACT_ACTIONS } from '@main/constants/emitterEvents'
+import { permissions as perms } from '@main/constants/permissions'
+import { SECTIONS } from '../sections'
+
+export function useContactCommands() {
+ const route = useRoute()
+ const { t } = useI18n()
+ const emitter = useEmitter()
+ const contactStore = useContactStore()
+
+ const section = SECTIONS.CONTACT
+ const contactAction = (action) => () => emitter.emit(EMITTER_EVENTS.CONTACT_ACTION, action)
+
+ return computed(() => {
+ const contact = contactStore.current
+ if (route.name !== 'contact-detail' || !contact) return []
+ return [
+ {
+ id: 'contact.start-conversation',
+ label: t('command.startConversationWithContact'),
+ keywords: [t('conversation.newConversation'), t('globals.messages.create')],
+ section,
+ icon: MessageSquarePlus,
+ permission: perms.CONVERSATIONS_WRITE,
+ run: () => emitter.emit(EMITTER_EVENTS.OPEN_CREATE_CONVERSATION, { contact })
+ },
+ {
+ id: 'contact.add-note',
+ label: t('contact.addNote'),
+ keywords: [t('globals.terms.note')],
+ section,
+ icon: StickyNote,
+ permission: perms.CONTACT_NOTES_WRITE,
+ run: contactAction(CONTACT_ACTIONS.ADD_NOTE)
+ },
+ {
+ id: 'contact.toggle-block',
+ label: t(contact.enabled ? 'contact.blockContact' : 'contact.unblockContact'),
+ keywords: [t('globals.messages.block'), t('globals.messages.unblock')],
+ section,
+ icon: contact.enabled ? ShieldOff : ShieldCheck,
+ permission: perms.CONTACTS_BLOCK,
+ run: contactAction(CONTACT_ACTIONS.TOGGLE_BLOCK)
+ },
+ {
+ id: 'contact.export',
+ label: t('globals.messages.exportData'),
+ keywords: ['download', 'json'],
+ section,
+ icon: Download,
+ permission: perms.CONTACTS_EXPORT,
+ run: contactAction(CONTACT_ACTIONS.EXPORT)
+ },
+ {
+ id: 'contact.delete',
+ label: t('contact.deleteContact'),
+ keywords: [t('globals.messages.delete')],
+ section,
+ icon: Trash2,
+ permission: perms.CONTACTS_DELETE,
+ destructive: true,
+ run: contactAction(CONTACT_ACTIONS.DELETE)
+ }
+ ]
+ })
+}
diff --git a/frontend/apps/main/src/features/command/providers/useConversationCommands.js b/frontend/apps/main/src/features/command/providers/useConversationCommands.js
new file mode 100644
index 00000000..b1f6ba39
--- /dev/null
+++ b/frontend/apps/main/src/features/command/providers/useConversationCommands.js
@@ -0,0 +1,438 @@
+import { computed } from 'vue'
+import { useRouter } from 'vue-router'
+import { useI18n } from 'vue-i18n'
+import {
+ CalendarClock,
+ CheckCircle2,
+ CircleDot,
+ Contact,
+ Download,
+ Flag,
+ Link2,
+ MailOpen,
+ MessageSquare,
+ PenLine,
+ RotateCcw,
+ Sparkles,
+ StickyNote,
+ Tag,
+ TagIcon,
+ UserCheck,
+ UserPlus,
+ UserX,
+ Users,
+ UsersRound,
+ Zap
+} from 'lucide-vue-next'
+import { useConversationStore } from '@main/stores/conversation'
+import { useUserStore } from '@main/stores/user'
+import { useUsersStore } from '@main/stores/users'
+import { useTeamStore } from '@main/stores/team'
+import { useTagStore } from '@main/stores/tag'
+import { useEmitter } from '@main/composables/useEmitter'
+import { EMITTER_EVENTS, CONVERSATION_ACTIONS } from '@main/constants/emitterEvents'
+import { permissions as perms } from '@main/constants/permissions'
+import { CONVERSATION_DEFAULT_STATUSES, TAG_ACTION } from '@main/constants/conversation'
+import { MACROS_COMMAND } from '../useCommandPalette'
+import { SECTIONS } from '../sections'
+
+export const SNOOZE_COMMAND = 'conv.snooze'
+export const SNOOZE_CUSTOM_COMMAND = 'conv.snooze.custom'
+
+const SNOOZE_PRESETS = [
+ { minutes: 60, unitKey: 'globals.terms.hour', count: 1 },
+ { minutes: 180, unitKey: 'globals.terms.hour', count: 3 },
+ { minutes: 360, unitKey: 'globals.terms.hour', count: 6 },
+ { minutes: 720, unitKey: 'globals.terms.hour', count: 12 },
+ { minutes: 1440, unitKey: 'globals.terms.day', count: 1 },
+ { minutes: 2880, unitKey: 'globals.terms.day', count: 2 },
+ { minutes: 4320, unitKey: 'globals.terms.day', count: 3 },
+ { minutes: 10080, unitKey: 'globals.terms.week', count: 1 }
+]
+
+const REOPENABLE_STATUSES = [
+ CONVERSATION_DEFAULT_STATUSES.RESOLVED,
+ CONVERSATION_DEFAULT_STATUSES.CLOSED,
+ CONVERSATION_DEFAULT_STATUSES.SNOOZED
+]
+
+export const formatSnoozeDuration = (minutes) =>
+ minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h`
+
+export function useConversationCommands({ openSnoozeDatePicker }) {
+ const router = useRouter()
+ const { t } = useI18n()
+ const emitter = useEmitter()
+ const conversationStore = useConversationStore()
+ const userStore = useUserStore()
+ const usersStore = useUsersStore()
+ const teamStore = useTeamStore()
+ const tagStore = useTagStore()
+
+ const section = SECTIONS.CONVERSATION
+
+ const snoozeCommands = () => [
+ {
+ id: SNOOZE_COMMAND,
+ label: t('globals.terms.snooze'),
+ section,
+ icon: CalendarClock,
+ permission: perms.CONVERSATIONS_UPDATE_STATUS,
+ group: true,
+ shortcut: ['Alt', 'Z'],
+ placeholder: t('command.snoozeFor')
+ },
+ ...SNOOZE_PRESETS.map(({ minutes, unitKey, count }) => ({
+ id: `conv.snooze.${minutes}`,
+ label: `${count} ${t(unitKey, count)}`,
+ parent: SNOOZE_COMMAND,
+ icon: CalendarClock,
+ run: () => conversationStore.snoozeConversation(formatSnoozeDuration(minutes))
+ })),
+ {
+ id: SNOOZE_CUSTOM_COMMAND,
+ label: t('globals.messages.pickDateAndTime'),
+ parent: SNOOZE_COMMAND,
+ icon: CalendarClock,
+ run: openSnoozeDatePicker
+ }
+ ]
+
+ const statusCommands = (conv) => {
+ const commands = []
+ const current = conv.status
+ if (current !== CONVERSATION_DEFAULT_STATUSES.RESOLVED) {
+ commands.push({
+ id: 'conv.resolve',
+ label: t('globals.terms.resolve'),
+ keywords: ['close', 'done'],
+ section,
+ icon: CheckCircle2,
+ permission: perms.CONVERSATIONS_UPDATE_STATUS,
+ shortcut: ['Alt', 'E'],
+ run: () => conversationStore.updateStatus(CONVERSATION_DEFAULT_STATUSES.RESOLVED)
+ })
+ }
+ if (REOPENABLE_STATUSES.includes(current)) {
+ commands.push({
+ id: 'conv.reopen',
+ label: t('globals.terms.reopen'),
+ keywords: [t('globals.terms.open')],
+ section,
+ icon: RotateCcw,
+ permission: perms.CONVERSATIONS_UPDATE_STATUS,
+ shortcut: ['Alt', 'O'],
+ run: () => conversationStore.updateStatus(CONVERSATION_DEFAULT_STATUSES.OPEN)
+ })
+ }
+ commands.push(...snoozeCommands())
+ commands.push({
+ id: 'conv.status',
+ label: t('actions.setStatus'),
+ keywords: [t('globals.terms.status')],
+ section,
+ icon: CircleDot,
+ permission: perms.CONVERSATIONS_UPDATE_STATUS,
+ group: true
+ })
+ for (const status of conversationStore.statusOptions) {
+ if (status.label === current) continue
+ const isSnooze = status.label === CONVERSATION_DEFAULT_STATUSES.SNOOZED
+ commands.push({
+ id: `conv.status.${status.value}`,
+ label: status.label,
+ parent: 'conv.status',
+ icon: isSnooze ? CalendarClock : CircleDot,
+ navigateTo: isSnooze ? SNOOZE_COMMAND : undefined,
+ run: isSnooze ? undefined : () => conversationStore.updateStatus(status.label)
+ })
+ }
+ return commands
+ }
+
+ const priorityCommands = (conv) => [
+ {
+ id: 'conv.priority',
+ label: t('actions.setPriority'),
+ keywords: [t('globals.terms.priority')],
+ section,
+ icon: Flag,
+ permission: perms.CONVERSATIONS_UPDATE_PRIORITY,
+ group: true,
+ shortcut: ['Alt', 'P']
+ },
+ ...conversationStore.priorityOptions
+ .filter((priority) => priority.label !== conv.priority)
+ .map((priority) => ({
+ id: `conv.priority.${priority.value}`,
+ label: priority.label,
+ parent: 'conv.priority',
+ icon: Flag,
+ run: () => {
+ conv.priority = priority.label
+ conv.priority_id = priority.value
+ return conversationStore.updatePriority(priority.label)
+ }
+ }))
+ ]
+
+ const assignAgent = (id) => {
+ conversationStore.current.assigned_user_id = id
+ return conversationStore.updateAssignee('user', { assignee_id: parseInt(id) })
+ }
+
+ const agentCommands = (conv) => {
+ const commands = []
+ if (conv.assigned_user_id !== userStore.userID) {
+ commands.push({
+ id: 'conv.assign-me',
+ label: t('conversation.assignSelfAction'),
+ keywords: ['self', 'me'],
+ section,
+ icon: UserCheck,
+ permission: perms.CONVERSATIONS_UPDATE_USER_ASSIGNEE,
+ run: () => assignAgent(userStore.userID)
+ })
+ }
+ commands.push({
+ id: 'conv.assign-agent',
+ label: t('actions.assignAgent'),
+ keywords: [t('globals.terms.agent')],
+ section,
+ icon: UserPlus,
+ permission: perms.CONVERSATIONS_UPDATE_USER_ASSIGNEE,
+ group: true,
+ shortcut: ['Alt', 'A'],
+ placeholder: t('command.searchAgents'),
+ search: async (term) =>
+ (await usersStore.searchUsers(term))
+ .filter((agent) => String(agent.value) !== String(conv.assigned_user_id))
+ .map((agent) => ({
+ id: `conv.assign-agent.${agent.value}`,
+ label: agent.label,
+ parent: 'conv.assign-agent',
+ icon: UserPlus,
+ run: () => assignAgent(agent.value)
+ }))
+ })
+ if (conv.assigned_user_id) {
+ commands.push({
+ id: 'conv.unassign-agent',
+ label: t('command.unassignAgent'),
+ keywords: [t('globals.terms.agent'), 'remove'],
+ section,
+ icon: UserX,
+ permission: perms.CONVERSATIONS_UPDATE_USER_ASSIGNEE,
+ run: () => conversationStore.removeAssignee('user')
+ })
+ }
+ return commands
+ }
+
+ const teamCommands = (conv) => {
+ const commands = [
+ {
+ id: 'conv.assign-team',
+ label: t('actions.assignTeam'),
+ keywords: [t('globals.terms.team')],
+ section,
+ icon: Users,
+ permission: perms.CONVERSATIONS_UPDATE_TEAM_ASSIGNEE,
+ group: true,
+ placeholder: t('command.searchTeams'),
+ search: async (term) =>
+ (await teamStore.searchTeams(term))
+ .filter((team) => String(team.value) !== String(conv.assigned_team_id))
+ .map((team) => ({
+ id: `conv.assign-team.${team.value}`,
+ label: team.label,
+ parent: 'conv.assign-team',
+ icon: UsersRound,
+ run: () =>
+ conversationStore.updateAssignee('team', { assignee_id: parseInt(team.value) })
+ }))
+ },
+ {
+ id: 'conv.suggest-tags',
+ label: t('conversation.sidebar.suggestTags'),
+ keywords: ['ai', t('globals.terms.tag')],
+ section,
+ icon: Sparkles,
+ permission: perms.CONVERSATIONS_UPDATE_TAGS,
+ run: () =>
+ emitter.emit(EMITTER_EVENTS.CONVERSATION_ACTION, CONVERSATION_ACTIONS.SUGGEST_TAGS)
+ }
+ ]
+ if (conv.assigned_team_id) {
+ commands.push({
+ id: 'conv.unassign-team',
+ label: t('command.unassignTeam'),
+ keywords: [t('globals.terms.team'), 'remove'],
+ section,
+ icon: UserX,
+ permission: perms.CONVERSATIONS_UPDATE_TEAM_ASSIGNEE,
+ run: () => conversationStore.removeAssignee('team')
+ })
+ }
+ return commands
+ }
+
+ const tagCommands = (conv) => {
+ const currentTags = conv.tags || []
+ const commands = [
+ {
+ id: 'conv.add-tag',
+ label: t('actions.addTags'),
+ keywords: [t('globals.terms.tag')],
+ section,
+ icon: Tag,
+ permission: perms.CONVERSATIONS_UPDATE_TAGS,
+ group: true,
+ placeholder: t('command.searchTags'),
+ search: async (term) =>
+ (await tagStore.searchTagNames(term))
+ .filter((tag) => !currentTags.includes(tag.value))
+ .map((tag) => ({
+ id: `conv.add-tag.${tag.value}`,
+ label: tag.label,
+ parent: 'conv.add-tag',
+ icon: TagIcon,
+ run: () =>
+ conversationStore.updateConversationTags(conv.uuid, TAG_ACTION.ADD, [tag.value])
+ }))
+ }
+ ]
+ if (currentTags.length) {
+ commands.push(
+ {
+ id: 'conv.remove-tag',
+ label: t('actions.removeTags'),
+ keywords: [t('globals.terms.tag')],
+ section,
+ icon: Tag,
+ permission: perms.CONVERSATIONS_UPDATE_TAGS,
+ group: true
+ },
+ ...currentTags.map((tag) => ({
+ id: `conv.remove-tag.${tag}`,
+ label: tag,
+ parent: 'conv.remove-tag',
+ icon: TagIcon,
+ run: () => conversationStore.updateConversationTags(conv.uuid, TAG_ACTION.REMOVE, [tag])
+ }))
+ )
+ }
+ return commands
+ }
+
+ const composeCommands = () => [
+ {
+ id: 'conv.reply',
+ label: t('command.switchToReply'),
+ keywords: [t('globals.terms.reply')],
+ section,
+ icon: MessageSquare,
+ permission: perms.MESSAGES_WRITE,
+ shortcut: ['Alt', 'R'],
+ run: () => emitter.emit(EMITTER_EVENTS.REPLY_BOX_SET_TYPE, 'reply')
+ },
+ {
+ id: 'conv.private-note',
+ label: t('command.switchToPrivateNote'),
+ keywords: [t('globals.terms.privateNote'), t('globals.terms.note')],
+ section,
+ icon: StickyNote,
+ permission: perms.MESSAGES_WRITE_PRIVATE,
+ shortcut: ['Alt', 'N'],
+ run: () => emitter.emit(EMITTER_EVENTS.REPLY_BOX_SET_TYPE, 'private_note')
+ },
+ {
+ id: 'conv.focus-reply',
+ label: t('command.focusReplyBox'),
+ keywords: [t('globals.terms.reply'), 'editor', 'compose'],
+ section,
+ icon: PenLine,
+ permission: [perms.MESSAGES_WRITE, perms.MESSAGES_WRITE_PRIVATE],
+ run: () => emitter.emit(EMITTER_EVENTS.REPLY_BOX_FOCUS)
+ }
+ ]
+
+ const copyLink = async () => {
+ await navigator.clipboard.writeText(window.location.href)
+ emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { description: t('globals.messages.copied') })
+ }
+
+ const miscCommands = (conv) => [
+ {
+ id: 'conv.macro',
+ label: t('actions.applyMacro'),
+ keywords: [t('globals.terms.macro')],
+ section,
+ icon: Zap,
+ shortcut: ['Ctrl', 'M'],
+ navigateTo: MACROS_COMMAND
+ },
+ {
+ id: 'conv.mark-unread',
+ label: t('globals.messages.markAsUnread'),
+ keywords: ['unread'],
+ section,
+ icon: MailOpen,
+ run: () => conversationStore.markAsUnread(conv.uuid)
+ },
+ {
+ id: 'conv.copy-link',
+ label: t('globals.messages.copyLink'),
+ keywords: [t('globals.terms.copy'), t('globals.terms.link'), 'url'],
+ section,
+ icon: Link2,
+ run: copyLink
+ },
+ {
+ id: 'conv.transcript',
+ label: t('conversation.downloadTranscript'),
+ keywords: ['export'],
+ section,
+ icon: Download,
+ run: () =>
+ emitter.emit(EMITTER_EVENTS.CONVERSATION_ACTION, CONVERSATION_ACTIONS.DOWNLOAD_TRANSCRIPT)
+ },
+ {
+ id: 'conv.summarize',
+ label: t('conversation.summarize'),
+ keywords: ['ai', t('globals.terms.summary')],
+ section,
+ icon: Sparkles,
+ permission: perms.MESSAGES_WRITE_PRIVATE,
+ run: () => emitter.emit(EMITTER_EVENTS.CONVERSATION_ACTION, CONVERSATION_ACTIONS.SUMMARIZE)
+ },
+ ...(conv.contact_id
+ ? [
+ {
+ id: 'conv.open-contact',
+ label: t('command.openContact'),
+ keywords: [t('globals.terms.contact')],
+ section,
+ icon: Contact,
+ permission: perms.CONTACTS_READ,
+ run: () => router.push({ name: 'contact-detail', params: { id: conv.contact_id } })
+ }
+ ]
+ : [])
+ ]
+
+ return computed(() => {
+ if (!conversationStore.isConversationOpen) return []
+ const conv = conversationStore.current
+ if (!conv) return []
+ return [
+ ...statusCommands(conv),
+ ...priorityCommands(conv),
+ ...agentCommands(conv),
+ ...teamCommands(conv),
+ ...tagCommands(conv),
+ ...miscCommands(conv),
+ ...composeCommands()
+ ]
+ })
+}
diff --git a/frontend/apps/main/src/features/command/providers/useCreateCommands.js b/frontend/apps/main/src/features/command/providers/useCreateCommands.js
new file mode 100644
index 00000000..f55d5e00
--- /dev/null
+++ b/frontend/apps/main/src/features/command/providers/useCreateCommands.js
@@ -0,0 +1,53 @@
+import { computed } from 'vue'
+import { useRouter } from 'vue-router'
+import { useI18n } from 'vue-i18n'
+import { Eye, MessageSquarePlus } from 'lucide-vue-next'
+import { navIconMap } from '@main/constants/navIcons'
+import { adminNavItems } from '@main/constants/navigation'
+import { permissions } from '@main/constants/permissions'
+import { useEmitter } from '@main/composables/useEmitter'
+import { EMITTER_EVENTS } from '@main/constants/emitterEvents'
+import { SECTIONS } from '../sections'
+
+export function useCreateCommands() {
+ const router = useRouter()
+ const { t } = useI18n()
+ const emitter = useEmitter()
+
+ const adminCreates = () =>
+ adminNavItems
+ .flatMap((group) => group.children)
+ .filter((item) => item.createRouteName)
+ .map((item) => ({
+ id: `create.${item.createRouteName}`,
+ label: t(router.resolve({ name: item.createRouteName }).meta.titleKey),
+ keywords: [t('globals.messages.create'), t('globals.terms.admin')],
+ section: SECTIONS.CREATE,
+ icon: navIconMap[item.icon],
+ permission: item.permission,
+ run: () => router.push({ name: item.createRouteName })
+ }))
+
+ return computed(() => [
+ {
+ id: 'create.conversation',
+ label: t('conversation.newConversation'),
+ keywords: [t('globals.messages.create')],
+ section: SECTIONS.ACTIONS,
+ icon: MessageSquarePlus,
+ permission: permissions.CONVERSATIONS_WRITE,
+ shortcut: ['Alt', 'C'],
+ run: () => emitter.emit(EMITTER_EVENTS.OPEN_CREATE_CONVERSATION, {})
+ },
+ {
+ id: 'create.view',
+ label: t('command.newView'),
+ keywords: [t('globals.messages.create'), t('globals.terms.view')],
+ section: SECTIONS.ACTIONS,
+ icon: Eye,
+ permission: permissions.VIEW_MANAGE,
+ run: () => emitter.emit(EMITTER_EVENTS.OPEN_VIEW_FORM)
+ },
+ ...adminCreates()
+ ])
+}
diff --git a/frontend/apps/main/src/features/command/providers/useEntitySearch.js b/frontend/apps/main/src/features/command/providers/useEntitySearch.js
new file mode 100644
index 00000000..1550d8ff
--- /dev/null
+++ b/frontend/apps/main/src/features/command/providers/useEntitySearch.js
@@ -0,0 +1,53 @@
+import { useRouter } from 'vue-router'
+import { Contact, MessageSquare } from 'lucide-vue-next'
+import { useUserStore } from '@main/stores/user'
+import { permissions as perms } from '@main/constants/permissions'
+import api from '@main/api'
+import { SECTIONS } from '../sections'
+
+export const ENTITY_SEARCH_MIN_LENGTH = 3
+
+const contactLabel = (contact) =>
+ [contact.first_name, contact.last_name].filter(Boolean).join(' ') || contact.email
+
+// Root-level searches that turn typed text into matching records, alongside the static commands.
+export function useEntitySearch() {
+ const router = useRouter()
+ const userStore = useUserStore()
+
+ const searchContacts = async (term) => {
+ if (!userStore.can(perms.CONTACTS_READ_ALL)) return []
+ const response = await api.searchContacts({ query: term })
+ return (response.data.data || []).map((contact) => ({
+ id: `search.contact.${contact.id}`,
+ label: contactLabel(contact),
+ hint: contact.email,
+ section: SECTIONS.CONTACT_RESULTS,
+ icon: Contact,
+ run: () => router.push({ name: 'contact-detail', params: { id: contact.id } })
+ }))
+ }
+
+ const searchConversations = async (term) => {
+ const response = await api.searchConversations({ query: term })
+ return (response.data.data || []).map((conversation) => ({
+ id: `search.conversation.${conversation.uuid}`,
+ label: conversation.subject || `#${conversation.reference_number}`,
+ hint: `#${conversation.reference_number} · ${conversation.status}`,
+ section: SECTIONS.CONVERSATION_RESULTS,
+ icon: MessageSquare,
+ run: () =>
+ router.push({
+ name: 'inbox-conversation',
+ params: { type: 'assigned', uuid: conversation.uuid }
+ })
+ }))
+ }
+
+ const search = async (term) => {
+ const results = await Promise.allSettled([searchContacts(term), searchConversations(term)])
+ return results.flatMap((result) => (result.status === 'fulfilled' ? result.value : []))
+ }
+
+ return { search }
+}
diff --git a/frontend/apps/main/src/features/command/providers/useListCommands.js b/frontend/apps/main/src/features/command/providers/useListCommands.js
new file mode 100644
index 00000000..8f3529df
--- /dev/null
+++ b/frontend/apps/main/src/features/command/providers/useListCommands.js
@@ -0,0 +1,88 @@
+import { computed } from 'vue'
+import { useRoute } from 'vue-router'
+import { useI18n } from 'vue-i18n'
+import { ArrowUpDown, CheckSquare, CircleDot, Filter } from 'lucide-vue-next'
+import { useConversationStore } from '@main/stores/conversation'
+import { useBulkActionPermissions } from '@main/composables/useBulkActionPermissions'
+import { SECTIONS } from '../sections'
+
+const SORT_FIELDS = [
+ 'oldest',
+ 'newest',
+ 'started_first',
+ 'started_last',
+ 'waiting_longest',
+ 'next_sla_target',
+ 'priority_first'
+]
+
+export const isConversationListRoute = (route) =>
+ route.path.startsWith('/inboxes') && route.name !== 'search'
+
+export function useListCommands() {
+ const route = useRoute()
+ const { t } = useI18n()
+ const conversationStore = useConversationStore()
+ const { canBulkAct } = useBulkActionPermissions()
+
+ const section = SECTIONS.LIST
+
+ return computed(() => {
+ if (!isConversationListRoute(route)) return []
+ const commands = []
+
+ // Views are pre-filtered, the list header hides the status filter there too.
+ if (!route.params.viewID) {
+ commands.push(
+ {
+ id: 'list.status',
+ label: t('command.filterByStatus'),
+ keywords: [t('globals.terms.filter'), t('globals.terms.status')],
+ section,
+ icon: Filter,
+ group: true
+ },
+ ...conversationStore.statusOptions
+ .filter((status) => status.label !== conversationStore.getListStatus)
+ .map((status) => ({
+ id: `list.status.${status.value}`,
+ label: status.label,
+ parent: 'list.status',
+ icon: CircleDot,
+ run: () => conversationStore.setListStatus(status.label)
+ }))
+ )
+ }
+
+ commands.push(
+ {
+ id: 'list.sort',
+ label: t('globals.messages.sortBy'),
+ keywords: ['order'],
+ section,
+ icon: ArrowUpDown,
+ group: true
+ },
+ ...SORT_FIELDS.map((field) => ({
+ id: `list.sort.${field}`,
+ label: t(conversationStore.sortFieldI18nKeys[field]),
+ parent: 'list.sort',
+ icon: ArrowUpDown,
+ run: () => conversationStore.setListSortField(field)
+ }))
+ )
+
+ if (canBulkAct.value && conversationStore.conversationsList.length) {
+ commands.push({
+ id: 'list.select-all',
+ label: t('conversation.bulkActions.selectAll'),
+ keywords: ['bulk'],
+ section,
+ icon: CheckSquare,
+ run: () => conversationStore.selectAll()
+ })
+ }
+
+ return commands
+ })
+}
diff --git a/frontend/apps/main/src/features/command/providers/useNavigationCommands.js b/frontend/apps/main/src/features/command/providers/useNavigationCommands.js
new file mode 100644
index 00000000..aecb288c
--- /dev/null
+++ b/frontend/apps/main/src/features/command/providers/useNavigationCommands.js
@@ -0,0 +1,135 @@
+import { computed } from 'vue'
+import { useRouter } from 'vue-router'
+import { useI18n } from 'vue-i18n'
+import {
+ AtSign,
+ BookUser,
+ CircleUser,
+ Eye,
+ Inbox,
+ Layers,
+ Search,
+ UserX,
+ Users
+} from 'lucide-vue-next'
+import { navIconMap } from '@main/constants/navIcons'
+import { adminNavItems, reportsNavItems } from '@main/constants/navigation'
+import { permissions } from '@main/constants/permissions'
+import { useUserStore } from '@main/stores/user'
+import { useViewStore } from '@main/stores/view'
+import { useSharedViewStore } from '@main/stores/sharedView'
+import { useInboxNavigation } from '@main/composables/useInboxNavigation'
+import { SECTIONS } from '../sections'
+
+const INBOX_TYPES = [
+ { type: 'assigned', labelKey: 'conversation.myInbox', icon: Inbox },
+ { type: 'mentioned', labelKey: 'conversation.mentions', icon: AtSign },
+ { type: 'unassigned', labelKey: 'globals.terms.unassigned', icon: UserX },
+ { type: 'all', labelKey: 'globals.messages.all', icon: Layers }
+]
+
+export function useNavigationCommands() {
+ const router = useRouter()
+ const { t } = useI18n()
+ const userStore = useUserStore()
+ const viewStore = useViewStore()
+ const sharedViewStore = useSharedViewStore()
+ const { navigateToInbox, navigateToTeamInbox, navigateToViewInbox } = useInboxNavigation()
+
+ const goTo = (route) => () => router.push(route)
+
+ const navItemLabel = (item) => t(item.titleKey, item.isTitleKeyPlural ? 2 : 1)
+
+ const adminCommands = () =>
+ adminNavItems.flatMap((group) =>
+ group.children.map((item) => ({
+ id: `goto.admin.${item.href}`,
+ label: navItemLabel(item),
+ hint: navItemLabel(group),
+ keywords: [navItemLabel(group), t('globals.terms.admin')],
+ section: SECTIONS.GOTO,
+ icon: navIconMap[item.icon],
+ permission: item.permission,
+ run: goTo(item.href)
+ }))
+ )
+
+ const reportCommands = () =>
+ reportsNavItems.map((item) => ({
+ id: `goto.reports.${item.href}`,
+ label: navItemLabel(item),
+ hint: t('globals.terms.report', 2),
+ keywords: [t('globals.terms.report', 2)],
+ section: SECTIONS.GOTO,
+ icon: navIconMap[item.icon],
+ permission: item.permission,
+ run: goTo(item.href)
+ }))
+
+ return computed(() => [
+ ...INBOX_TYPES.map(({ type, labelKey, icon }) => ({
+ id: `goto.inbox.${type}`,
+ label: t(labelKey),
+ hint: t('globals.terms.inbox', 2),
+ keywords: [t('globals.terms.inbox', 2)],
+ section: SECTIONS.GOTO,
+ icon,
+ run: () => navigateToInbox(type)
+ })),
+ ...userStore.teams.map((team) => ({
+ id: `goto.team.${team.id}`,
+ label: team.name,
+ hint: t('globals.terms.teamInbox'),
+ keywords: [t('globals.terms.teamInbox'), t('globals.terms.team')],
+ section: SECTIONS.GOTO,
+ icon: Users,
+ run: () => navigateToTeamInbox(team.id)
+ })),
+ ...viewStore.views.map((view) => ({
+ id: `goto.view.${view.id}`,
+ label: view.name,
+ hint: t('globals.terms.view'),
+ keywords: [t('globals.terms.view')],
+ section: SECTIONS.GOTO,
+ icon: Eye,
+ permission: permissions.VIEW_MANAGE,
+ run: () => navigateToViewInbox(view.id)
+ })),
+ ...sharedViewStore.sharedViewList.map((view) => ({
+ id: `goto.shared-view.${view.id}`,
+ label: view.name,
+ hint: t('globals.terms.sharedView'),
+ keywords: [t('globals.terms.sharedView'), t('globals.terms.view')],
+ section: SECTIONS.GOTO,
+ icon: Eye,
+ run: () => navigateToViewInbox(view.id)
+ })),
+ {
+ id: 'goto.search',
+ label: t('conversation.search'),
+ section: SECTIONS.GOTO,
+ icon: Search,
+ run: goTo({ name: 'search' })
+ },
+ {
+ id: 'goto.contacts',
+ label: t('contact.allContacts'),
+ keywords: [t('globals.terms.contact', 2)],
+ section: SECTIONS.GOTO,
+ icon: BookUser,
+ permission: permissions.CONTACTS_READ_ALL,
+ run: goTo({ name: 'contacts' })
+ },
+ ...reportCommands(),
+ ...adminCommands(),
+ {
+ id: 'goto.profile',
+ label: t('globals.terms.profile'),
+ hint: t('globals.terms.account'),
+ keywords: [t('globals.terms.account')],
+ section: SECTIONS.GOTO,
+ icon: CircleUser,
+ run: goTo({ name: 'profile' })
+ }
+ ])
+}
diff --git a/frontend/apps/main/src/features/command/sections.js b/frontend/apps/main/src/features/command/sections.js
new file mode 100644
index 00000000..027eb777
--- /dev/null
+++ b/frontend/apps/main/src/features/command/sections.js
@@ -0,0 +1,45 @@
+export const SECTIONS = {
+ BULK: 'bulk',
+ CONVERSATION: 'conversation',
+ CONTACT: 'contact',
+ LIST: 'list',
+ ACTIONS: 'actions',
+ CREATE: 'create',
+ GOTO: 'goto',
+ ACCOUNT: 'account',
+ CONTACT_RESULTS: 'contact-results',
+ CONVERSATION_RESULTS: 'conversation-results'
+}
+
+// Contextual sections come first, global ones last.
+export const SECTION_ORDER = [
+ SECTIONS.BULK,
+ SECTIONS.CONVERSATION,
+ SECTIONS.CONTACT,
+ SECTIONS.LIST,
+ SECTIONS.ACTIONS,
+ SECTIONS.CREATE,
+ SECTIONS.GOTO,
+ SECTIONS.ACCOUNT,
+ SECTIONS.CONTACT_RESULTS,
+ SECTIONS.CONVERSATION_RESULTS
+]
+
+export const SECTION_LABEL_KEYS = {
+ [SECTIONS.BULK]: 'command.section.selectedConversations',
+ [SECTIONS.CONVERSATION]: 'globals.terms.conversation',
+ [SECTIONS.CONTACT]: 'globals.terms.contact',
+ [SECTIONS.LIST]: 'command.section.list',
+ [SECTIONS.ACTIONS]: 'globals.terms.action',
+ [SECTIONS.CREATE]: 'globals.messages.create',
+ [SECTIONS.GOTO]: 'command.section.goTo',
+ [SECTIONS.ACCOUNT]: 'globals.terms.account',
+ [SECTIONS.CONTACT_RESULTS]: 'globals.terms.contact',
+ [SECTIONS.CONVERSATION_RESULTS]: 'globals.terms.conversation'
+}
+
+export const SECTION_LABEL_PLURAL = new Set([
+ SECTIONS.ACTIONS,
+ SECTIONS.CONTACT_RESULTS,
+ SECTIONS.CONVERSATION_RESULTS
+])
diff --git a/frontend/apps/main/src/features/command/useCommandPalette.js b/frontend/apps/main/src/features/command/useCommandPalette.js
new file mode 100644
index 00000000..066b7b43
--- /dev/null
+++ b/frontend/apps/main/src/features/command/useCommandPalette.js
@@ -0,0 +1,51 @@
+import { ref } from 'vue'
+import { MACRO_CONTEXT } from '@main/constants/conversation'
+
+export const MACROS_COMMAND = 'macros'
+
+const open = ref(false)
+const parent = ref(null)
+const searchTerm = ref('')
+const macroContext = ref(MACRO_CONTEXT.REPLY)
+
+export function useCommandPalette() {
+ const openPalette = ({ parent: target = null } = {}) => {
+ parent.value = target
+ searchTerm.value = ''
+ open.value = true
+ }
+
+ const closePalette = () => {
+ open.value = false
+ }
+
+ const togglePalette = () => {
+ if (open.value) closePalette()
+ else openPalette({ parent: defaultParent() })
+ }
+
+ const setParent = (target) => {
+ parent.value = target
+ searchTerm.value = ''
+ }
+
+ // Ctrl+K inside the new-conversation dialog goes straight to macros.
+ const defaultParent = () =>
+ macroContext.value === MACRO_CONTEXT.NEW_CONVERSATION ? MACROS_COMMAND : null
+
+ const setMacroContext = (context) => {
+ macroContext.value = context
+ }
+
+ return {
+ open,
+ parent,
+ searchTerm,
+ macroContext,
+ openPalette,
+ closePalette,
+ togglePalette,
+ setParent,
+ setMacroContext
+ }
+}
diff --git a/frontend/apps/main/src/features/command/useCommandRegistry.js b/frontend/apps/main/src/features/command/useCommandRegistry.js
new file mode 100644
index 00000000..5826c05c
--- /dev/null
+++ b/frontend/apps/main/src/features/command/useCommandRegistry.js
@@ -0,0 +1,52 @@
+import { computed } from 'vue'
+import { useUserStore } from '@main/stores/user'
+
+export const normalizeSearchText = (text) =>
+ String(text || '')
+ .toLowerCase()
+ .replace(/\s+/g, ' ')
+ .trim()
+
+export const commandMatches = (command, term) => {
+ const needle = normalizeSearchText(term)
+ if (!needle) return true
+ const haystack = normalizeSearchText([command.label, ...(command.keywords || [])].join(' '))
+ return needle.split(' ').every((word) => haystack.includes(word))
+}
+
+export function useCommandRegistry(providers) {
+ const userStore = useUserStore()
+
+ const allowed = (command) => {
+ if (!command.permission) return true
+ const required = Array.isArray(command.permission) ? command.permission : [command.permission]
+ return required.some((permission) => userStore.can(permission))
+ }
+
+ // Children inherit their parent's visibility.
+ const commands = computed(() => {
+ const all = providers.flatMap((provider) => provider.value)
+ const byId = new Map(all.map((command) => [command.id, command]))
+ const visible = new Map()
+ const isVisible = (command) => {
+ if (visible.has(command.id)) return visible.get(command.id)
+ let result = allowed(command)
+ if (result && command.parent) {
+ const parent = byId.get(command.parent)
+ result = Boolean(parent) && isVisible(parent)
+ }
+ visible.set(command.id, result)
+ return result
+ }
+ return all.filter(isVisible)
+ })
+
+ const byId = computed(() => new Map(commands.value.map((command) => [command.id, command])))
+
+ const get = (id) => byId.value.get(id) || null
+
+ const childrenOf = (parentId) =>
+ commands.value.filter((command) => (command.parent || null) === (parentId || null))
+
+ return { commands, get, childrenOf }
+}
diff --git a/frontend/apps/main/src/features/command/useCommandRegistry.test.js b/frontend/apps/main/src/features/command/useCommandRegistry.test.js
new file mode 100644
index 00000000..5ed3a96c
--- /dev/null
+++ b/frontend/apps/main/src/features/command/useCommandRegistry.test.js
@@ -0,0 +1,79 @@
+import { describe, it, expect, vi } from 'vitest'
+import { computed, ref } from 'vue'
+
+const userMock = vi.hoisted(() => ({ allowed: [] }))
+
+vi.mock('@main/stores/user', () => ({
+ useUserStore: () => ({
+ can: (permission) => userMock.allowed.includes(permission)
+ })
+}))
+
+import { useCommandRegistry, commandMatches } from '@main/features/command/useCommandRegistry'
+
+const command = (id, overrides = {}) => ({ id, label: id, ...overrides })
+
+describe('useCommandRegistry', () => {
+ it('drops commands whose permission the user lacks', () => {
+ userMock.allowed = ['a:read']
+ const registry = useCommandRegistry([
+ computed(() => [
+ command('open'),
+ command('allowed', { permission: 'a:read' }),
+ command('denied', { permission: 'b:write' }),
+ command('any-of', { permission: ['b:write', 'a:read'] })
+ ])
+ ])
+ expect(registry.commands.value.map((c) => c.id)).toEqual(['open', 'allowed', 'any-of'])
+ })
+
+ it('hides children whose parent is hidden, even without their own permission', () => {
+ userMock.allowed = []
+ const registry = useCommandRegistry([
+ computed(() => [
+ command('group', { permission: 'x:manage', group: true }),
+ command('child', { parent: 'group' }),
+ command('orphan', { parent: 'missing' })
+ ])
+ ])
+ expect(registry.commands.value).toEqual([])
+ expect(registry.childrenOf('group')).toEqual([])
+ })
+
+ it('lists root commands and children separately', () => {
+ userMock.allowed = []
+ const registry = useCommandRegistry([
+ computed(() => [command('root'), command('group', { group: true }), command('child', { parent: 'group' })])
+ ])
+ expect(registry.childrenOf(null).map((c) => c.id)).toEqual(['root', 'group'])
+ expect(registry.childrenOf('group').map((c) => c.id)).toEqual(['child'])
+ expect(registry.get('child').parent).toBe('group')
+ expect(registry.get('nope')).toBeNull()
+ })
+
+ it('recomputes when a provider changes', () => {
+ userMock.allowed = []
+ const source = ref([command('one')])
+ const registry = useCommandRegistry([computed(() => source.value)])
+ expect(registry.commands.value).toHaveLength(1)
+ source.value = [command('one'), command('two')]
+ expect(registry.commands.value).toHaveLength(2)
+ })
+})
+
+describe('commandMatches', () => {
+ const resolve = command('resolve', { label: 'Resolve', keywords: ['close', 'done'] })
+
+ it('matches label and keywords case-insensitively', () => {
+ expect(commandMatches(resolve, 'RES')).toBe(true)
+ expect(commandMatches(resolve, 'close')).toBe(true)
+ expect(commandMatches(resolve, 'reopen')).toBe(false)
+ })
+
+ it('requires every typed word to match', () => {
+ const agents = command('agents', { label: 'Agents', keywords: ['Teammates'] })
+ expect(commandMatches(agents, 'team agent')).toBe(true)
+ expect(commandMatches(agents, 'team inbox')).toBe(false)
+ expect(commandMatches(agents, ' ')).toBe(true)
+ })
+})
diff --git a/frontend/apps/main/src/features/command/useGlobalShortcuts.js b/frontend/apps/main/src/features/command/useGlobalShortcuts.js
new file mode 100644
index 00000000..6329ed53
--- /dev/null
+++ b/frontend/apps/main/src/features/command/useGlobalShortcuts.js
@@ -0,0 +1,117 @@
+import { onMounted, onUnmounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { useConversationStore } from '@main/stores/conversation'
+import { useUserStore } from '@main/stores/user'
+import { useEmitter } from '@main/composables/useEmitter'
+import { useKeyboardShortcutsDialog } from '@main/composables/useKeyboardShortcutsDialog'
+import { useBulkActionPermissions } from '@main/composables/useBulkActionPermissions'
+import { EMITTER_EVENTS } from '@main/constants/emitterEvents'
+import { permissions as perms } from '@main/constants/permissions'
+import { CONVERSATION_DEFAULT_STATUSES, MACRO_CONTEXT } from '@main/constants/conversation'
+import { MACROS_COMMAND, useCommandPalette } from './useCommandPalette'
+import { SNOOZE_COMMAND } from './providers/useConversationCommands'
+
+const isMod = (event) => event.ctrlKey || event.metaKey
+const onlyAlt = (event) => event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey
+const dialogIsOpen = () => Boolean(document.querySelector('[role="dialog"][data-state="open"]'))
+
+// Alt combos work while typing in the editor, so no shortcut depends on where focus is.
+export function useGlobalShortcuts() {
+ const route = useRoute()
+ const router = useRouter()
+ const emitter = useEmitter()
+ const conversationStore = useConversationStore()
+ const userStore = useUserStore()
+ const palette = useCommandPalette()
+ const shortcutsDialog = useKeyboardShortcutsDialog()
+ const { canBulkAct } = useBulkActionPermissions()
+
+ const conversationOpen = () => conversationStore.isConversationOpen && conversationStore.current
+
+ const openMacros = () => {
+ const inNewConversation = palette.macroContext.value === MACRO_CONTEXT.NEW_CONVERSATION
+ if (!inNewConversation && !conversationOpen()) return false
+ palette.openPalette({ parent: MACROS_COMMAND })
+ return true
+ }
+
+ const openGroup = (parent, permission) => () => {
+ if (!conversationOpen() || !userStore.can(permission)) return false
+ palette.openPalette({ parent })
+ return true
+ }
+
+ const stepConversation = (step) => () => {
+ if (!conversationOpen() || !String(route.name).endsWith('-conversation')) return false
+ const list = conversationStore.conversationsList
+ const index = list.findIndex((c) => c.uuid === route.params.uuid)
+ if (index === -1) return false
+ const next = list[index + step]
+ if (!next) return false
+ router.push({ name: route.name, params: { ...route.params, uuid: next.uuid } })
+ return true
+ }
+
+ const setStatus = (status) => () => {
+ if (!conversationOpen() || !userStore.can(perms.CONVERSATIONS_UPDATE_STATUS)) return false
+ if (conversationStore.current.status !== status) conversationStore.updateStatus(status)
+ return true
+ }
+
+ const compose = (type, permission) => () => {
+ if (!conversationOpen() || !userStore.can(permission)) return false
+ emitter.emit(EMITTER_EVENTS.REPLY_BOX_SET_TYPE, type)
+ emitter.emit(EMITTER_EVENTS.REPLY_BOX_FOCUS)
+ return true
+ }
+
+ const toggleSelectCurrent = () => {
+ if (!conversationOpen() || !canBulkAct.value) return false
+ conversationStore.toggleSelect(conversationStore.current.uuid, false)
+ return true
+ }
+
+ const newConversation = () => {
+ if (!userStore.can(perms.CONVERSATIONS_WRITE)) return false
+ emitter.emit(EMITTER_EVENTS.OPEN_CREATE_CONVERSATION, {})
+ return true
+ }
+
+ const showShortcuts = () => {
+ palette.closePalette()
+ shortcutsDialog.show()
+ return true
+ }
+
+ // Keyed by event.code: on macOS Option+letter changes event.key to a symbol.
+ const altKeys = {
+ KeyJ: stepConversation(-1),
+ KeyK: stepConversation(1),
+ KeyZ: openGroup(SNOOZE_COMMAND, perms.CONVERSATIONS_UPDATE_STATUS),
+ KeyP: openGroup('conv.priority', perms.CONVERSATIONS_UPDATE_PRIORITY),
+ KeyA: openGroup('conv.assign-agent', perms.CONVERSATIONS_UPDATE_USER_ASSIGNEE),
+ KeyR: compose('reply', perms.MESSAGES_WRITE),
+ KeyN: compose('private_note', perms.MESSAGES_WRITE_PRIVATE),
+ KeyX: toggleSelectCurrent,
+ KeyC: newConversation,
+ KeyE: setStatus(CONVERSATION_DEFAULT_STATUSES.RESOLVED),
+ KeyO: setStatus(CONVERSATION_DEFAULT_STATUSES.OPEN)
+ }
+
+ const onKeydown = (event) => {
+ if (event.isComposing || event.repeat) return
+ let handled = false
+ if (isMod(event) && !event.shiftKey && !event.altKey) {
+ const key = event.key.toLowerCase()
+ if (key === 'k') handled = (palette.togglePalette(), true)
+ else if (key === 'm') handled = openMacros()
+ else if (event.key === '/') handled = showShortcuts()
+ } else if (onlyAlt(event) && altKeys[event.code] && !dialogIsOpen()) {
+ handled = altKeys[event.code]()
+ }
+ if (handled) event.preventDefault()
+ }
+
+ onMounted(() => window.addEventListener('keydown', onKeydown))
+ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
+}
diff --git a/frontend/apps/main/src/features/contact/ContactNotes.vue b/frontend/apps/main/src/features/contact/ContactNotes.vue
index 5b539227..382aa3ba 100644
--- a/frontend/apps/main/src/features/contact/ContactNotes.vue
+++ b/frontend/apps/main/src/features/contact/ContactNotes.vue
@@ -182,7 +182,7 @@ const notesCache = new Map()
diff --git a/frontend/apps/main/src/features/conversation/CreateConversation.vue b/frontend/apps/main/src/features/conversation/CreateConversation.vue
index d6673a3e..5dcfc232 100644
--- a/frontend/apps/main/src/features/conversation/CreateConversation.vue
+++ b/frontend/apps/main/src/features/conversation/CreateConversation.vue
@@ -289,11 +289,16 @@ import { IdCard } from 'lucide-vue-next'
import api from '@/api'
import { hasPendingInlineUpload } from '@main/composables/useInlineImageUpload'
import { useIsComposerCramped } from '@main/composables/useIsComposerCramped'
+import { useCommandPalette } from '@/features/command/useCommandPalette'
const dialogOpen = defineModel({
required: false,
default: () => false
})
+const props = defineProps({
+ initialContact: { type: Object, default: null }
+})
+const palette = useCommandPalette()
const inboxStore = useInboxStore()
const { t } = useI18n()
@@ -353,19 +358,14 @@ onUnmounted(() => {
clearMediaFiles()
conversationStore.resetMacro(MACRO_CONTEXT.NEW_CONVERSATION)
macroStore.setCurrentView(previousMacroView)
- emitter.emit(EMITTER_EVENTS.SET_NESTED_COMMAND, {
- command: null,
- open: false
- })
+ palette.setMacroContext(MACRO_CONTEXT.REPLY)
})
onMounted(() => {
previousMacroView = macroStore.currentView
macroStore.setCurrentView('starting_conversation')
- emitter.emit(EMITTER_EVENTS.SET_NESTED_COMMAND, {
- command: 'apply-macro-to-new-conversation',
- open: false
- })
+ palette.setMacroContext(MACRO_CONTEXT.NEW_CONVERSATION)
+ if (props.initialContact?.email) selectContact(props.initialContact)
nextTick(() => {
emailInputRef.value?.$el?.focus()
})
diff --git a/frontend/apps/main/src/features/conversation/ReplyBox.vue b/frontend/apps/main/src/features/conversation/ReplyBox.vue
index 2df3b897..be7bd981 100644
--- a/frontend/apps/main/src/features/conversation/ReplyBox.vue
+++ b/frontend/apps/main/src/features/conversation/ReplyBox.vue
@@ -298,12 +298,23 @@ const handleCopilotInsertReply = (html) => {
htmlContent.value = html
}
+const setMessageTypeFromPalette = (type) => {
+ if (isGenerating.value || !isAllowedMessageType(type)) return
+ messageType.value = type
+}
+
+const focusFromPalette = () => replyBoxContentRef.value?.focus()
+
onMounted(() => {
emitter.on(EMITTER_EVENTS.COPILOT_INSERT_REPLY, handleCopilotInsertReply)
+ emitter.on(EMITTER_EVENTS.REPLY_BOX_SET_TYPE, setMessageTypeFromPalette)
+ emitter.on(EMITTER_EVENTS.REPLY_BOX_FOCUS, focusFromPalette)
})
onUnmounted(() => {
emitter.off(EMITTER_EVENTS.COPILOT_INSERT_REPLY, handleCopilotInsertReply)
+ emitter.off(EMITTER_EVENTS.REPLY_BOX_SET_TYPE, setMessageTypeFromPalette)
+ emitter.off(EMITTER_EVENTS.REPLY_BOX_FOCUS, focusFromPalette)
})
/**
diff --git a/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue b/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue
index b28f8f8c..7d15ff58 100644
--- a/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue
+++ b/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue
@@ -102,6 +102,7 @@
@send="handleSend"
@mentionsChanged="handleMentionsChanged"
@filesDropped="handleFilesDropped"
+ @toggleMessageType="toggleMessageType"
/>
@@ -219,6 +220,11 @@ const handleMentionsChanged = (newMentions) => {
mentions.value = newMentions
}
+const toggleMessageType = () => {
+ if (props.isGenerating || !props.canSendReply || !props.canSendPrivateNote) return
+ messageType.value = messageType.value === 'private_note' ? 'reply' : 'private_note'
+}
+
const props = defineProps({
isFullscreen: {
type: Boolean,
diff --git a/frontend/apps/main/src/features/conversation/list/ConversationBulkActionToolbar.vue b/frontend/apps/main/src/features/conversation/list/ConversationBulkActionToolbar.vue
index 65711c02..ed893f75 100644
--- a/frontend/apps/main/src/features/conversation/list/ConversationBulkActionToolbar.vue
+++ b/frontend/apps/main/src/features/conversation/list/ConversationBulkActionToolbar.vue
@@ -115,7 +115,6 @@
diff --git a/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBar.vue b/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBar.vue
index 64843c4e..754a23c7 100644
--- a/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBar.vue
+++ b/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBar.vue
@@ -152,7 +152,7 @@
+
+
diff --git a/frontend/apps/main/src/components/editor/editorExtensions.js b/frontend/apps/main/src/components/editor/editorExtensions.js
index 82ce364c..6b4c024b 100644
--- a/frontend/apps/main/src/components/editor/editorExtensions.js
+++ b/frontend/apps/main/src/components/editor/editorExtensions.js
@@ -17,6 +17,8 @@ import { Callout } from './extensions/Callout'
import { Details, DetailsSummary, DetailsContent } from './extensions/Collapsible'
import { TrailingNode } from './extensions/TrailingNode'
import mentionSuggestion from './mentionSuggestion'
+import ticketReferenceSuggestion from './ticketReferenceSuggestion'
+import { TicketReference } from './ticketReferenceExtension'
const lowlight = createLowlight(codeGrammars)
@@ -135,13 +137,16 @@ const sharedExtensions = ({
CustomLink
]
-export function buildConversationExtensions({ getPlaceholder }) {
+export function buildConversationExtensions({
+ getPlaceholder
+}) {
return [
...sharedExtensions({ getPlaceholder }),
CustomMention.configure({
HTMLAttributes: { class: 'ld-mention' },
suggestion: mentionSuggestion
}),
+ TicketReference.configure({ suggestion: ticketReferenceSuggestion }),
CustomTable.configure({ resizable: false }),
TableRow,
CustomTableCell,
diff --git a/frontend/apps/main/src/components/editor/ticketReference.js b/frontend/apps/main/src/components/editor/ticketReference.js
new file mode 100644
index 00000000..c963fd7c
--- /dev/null
+++ b/frontend/apps/main/src/components/editor/ticketReference.js
@@ -0,0 +1,23 @@
+import api from '@main/api'
+
+export const MIN_REFERENCE_QUERY_LENGTH = 3
+
+export const conversationReferenceHref = (uuid) => {
+ const path = `/inboxes/all/conversation/${uuid}`
+ const origin = typeof window !== 'undefined' ? window.location.origin : ''
+ return origin ? `${origin}${path}` : path
+}
+
+export const getTicketSuggestions = async (query, search = api.searchConversations) => {
+ const reference = query.trim()
+ if (reference.length < MIN_REFERENCE_QUERY_LENGTH) return []
+
+ const response = await search({ query: reference })
+ return (response.data?.data || []).map((conversation) => ({
+ id: conversation.uuid,
+ label: conversation.reference_number,
+ subject: conversation.subject,
+ status: conversation.status,
+ href: conversationReferenceHref(conversation.uuid)
+ }))
+}
diff --git a/frontend/apps/main/src/components/editor/ticketReference.test.js b/frontend/apps/main/src/components/editor/ticketReference.test.js
new file mode 100644
index 00000000..216bdf67
--- /dev/null
+++ b/frontend/apps/main/src/components/editor/ticketReference.test.js
@@ -0,0 +1,40 @@
+import { describe, expect, it, vi } from 'vitest'
+import { conversationReferenceHref, getTicketSuggestions } from './ticketReference'
+
+describe('ticket reference suggestions', () => {
+ it('does not search until three characters are entered', async () => {
+ const search = vi.fn()
+ await expect(getTicketSuggestions('10', search)).resolves.toEqual([])
+ expect(search).not.toHaveBeenCalled()
+ })
+
+ it('maps a conversation result to a link suggestion', async () => {
+ const search = vi
+ .fn()
+ .mockResolvedValue({
+ data: {
+ data: [
+ {
+ uuid: 'conversation-uuid',
+ reference_number: '108',
+ subject: 'Payment failed',
+ status: 'Open'
+ }
+ ]
+ }
+ })
+ await expect(getTicketSuggestions('108', search)).resolves.toEqual([
+ {
+ id: 'conversation-uuid',
+ label: '108',
+ subject: 'Payment failed',
+ status: 'Open',
+ href: '/inboxes/all/conversation/conversation-uuid'
+ }
+ ])
+ })
+
+ it('builds a stable conversation link', () => {
+ expect(conversationReferenceHref('abc')).toBe('/inboxes/all/conversation/abc')
+ })
+})
diff --git a/frontend/apps/main/src/components/editor/ticketReferenceExtension.js b/frontend/apps/main/src/components/editor/ticketReferenceExtension.js
new file mode 100644
index 00000000..5d0bfec7
--- /dev/null
+++ b/frontend/apps/main/src/components/editor/ticketReferenceExtension.js
@@ -0,0 +1,27 @@
+import Mention from '@tiptap/extension-mention'
+
+export const TicketReference = Mention.extend({
+ name: 'ticketReference',
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ href: { default: null, parseHTML: (element) => element.getAttribute('href') },
+ type: {
+ default: 'ticket-reference',
+ parseHTML: (element) => element.getAttribute('data-type') || 'ticket-reference',
+ renderHTML: (attributes) => ({ 'data-type': attributes.type })
+ }
+ }
+ },
+ parseHTML: () => [{ tag: 'a.ld-ticket-reference' }],
+ renderText: ({ node }) => `#${node.attrs.label}`,
+ renderHTML: ({ node, HTMLAttributes }) => [
+ 'a',
+ {
+ ...HTMLAttributes,
+ href: node.attrs.href,
+ class: [HTMLAttributes.class, 'ld-ticket-reference'].filter(Boolean).join(' ')
+ },
+ `#${node.attrs.label}`
+ ]
+})
diff --git a/frontend/apps/main/src/components/editor/ticketReferenceExtension.test.js b/frontend/apps/main/src/components/editor/ticketReferenceExtension.test.js
new file mode 100644
index 00000000..a851decf
--- /dev/null
+++ b/frontend/apps/main/src/components/editor/ticketReferenceExtension.test.js
@@ -0,0 +1,25 @@
+// @vitest-environment jsdom
+
+import { describe, expect, it } from 'vitest'
+import StarterKit from '@tiptap/starter-kit'
+import { Editor } from '@tiptap/vue-3'
+import { TicketReference } from './ticketReferenceExtension'
+
+describe('ticket reference editor extension', () => {
+ it('round-trips a ticket reference through HTML', () => {
+ const content =
+ '