feat: Add a dedicated home for agents and admins

This commit is contained in:
Pranav
2026-04-29 16:05:27 -07:00
parent d634ced4e9
commit 3d7567bf23
10 changed files with 1481 additions and 46 deletions
@@ -4,6 +4,7 @@ import { provideSidebarContext, useSidebarResize } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
@@ -40,6 +41,16 @@ const { accountScopedRoute, isOnChatwootCloud } = useAccount();
const store = useStore();
const searchShortcut = useKbd([`$mod`, 'k']);
const { t } = useI18n();
const { uiSettings } = useUISettings();
const isHomeDashboardEnabled = computed(
() => !!uiSettings.value?.home_dashboard_enabled
);
const unreadInboxCount = useMapGetter('notifications/getUnreadCount');
const unreadInboxCountLabel = computed(() => {
const count = unreadInboxCount.value;
if (!count) return '';
return count < 100 ? String(count) : '99+';
});
const isACustomBrandedInstance = useMapGetter(
'globalConfig/isACustomBrandedInstance'
@@ -212,16 +223,28 @@ const reportRoutes = computed(() => newReportRoutes());
const menuItems = computed(() => {
return [
{
name: 'Inbox',
label: t('SIDEBAR.INBOX'),
icon: 'i-lucide-inbox',
to: accountScopedRoute('inbox_view'),
activeOn: ['inbox_view', 'inbox_view_conversation'],
getterKeys: {
count: 'notifications/getUnreadCount',
},
},
...(isHomeDashboardEnabled.value
? [
{
name: 'Home',
label: t('SIDEBAR.HOME'),
icon: 'i-lucide-gauge',
to: accountScopedRoute('home_dashboard'),
activeOn: ['home_dashboard', 'home_dashboard_admin'],
},
]
: [
{
name: 'Inbox',
label: t('SIDEBAR.INBOX'),
icon: 'i-lucide-inbox',
to: accountScopedRoute('inbox_view'),
activeOn: ['inbox_view', 'inbox_view_conversation'],
getterKeys: {
count: 'notifications/getUnreadCount',
},
},
]),
{
name: 'Conversation',
label: t('SIDEBAR.CONVERSATIONS'),
@@ -858,6 +881,20 @@ const menuItems = computed(() => {
:is-collapsed="isEffectivelyCollapsed"
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
/>
<RouterLink
v-if="isHomeDashboardEnabled"
:to="accountScopedRoute('inbox_view')"
:title="t('SIDEBAR.INBOX')"
class="size-8 rounded-lg hover:bg-n-alpha-1 flex-shrink-0 grid place-content-center relative text-n-slate-11"
>
<span class="i-lucide-bell size-4" />
<span
v-if="unreadInboxCountLabel"
class="min-h-2 min-w-2 p-0.5 px-1 bg-n-ruby-9 rounded-lg absolute -top-1 -right-1.5 grid place-items-center text-[9px] leading-none text-n-ruby-3"
>
{{ unreadInboxCountLabel }}
</span>
</RouterLink>
</div>
</section>
<!-- Resize Handle (desktop only) -->
@@ -1,7 +1,7 @@
{
"INBOX": {
"LIST": {
"TITLE": "My Inbox",
"TITLE": "Notifications",
"DISPLAY_DROPDOWN": "Display",
"LOADING": "Fetching notifications",
"404": "There are no active notifications in this group.",
@@ -58,6 +58,10 @@
"UPDATE_SUCCESS": "Your Language settings have been updated successfully",
"UPDATE_ERROR": "There is an error while updating the language settings, please try again",
"USE_ACCOUNT_DEFAULT": "Use account default"
},
"HOME_DASHBOARD": {
"TITLE": "Home dashboard",
"NOTE": "Show a Home dashboard with team workload, KPIs, and a Captain prompt as your landing page. Hides the My Inbox sidebar item."
}
},
"MESSAGE_SIGNATURE_SECTION": {
@@ -228,6 +232,128 @@
}
}
},
"HOME_DASHBOARD_PAGE": {
"ROLE_SWITCH": {
"AGENT": "Agent",
"ADMIN": "Admin"
},
"GREETING": {
"MORNING": "Good morning",
"AFTERNOON": "Good afternoon",
"EVENING": "Good evening",
"LATE": "Working late",
"FALLBACK_NAME": "there",
"GREETING_LINE": "{greeting}, ",
"NAME_LINE": "{name}."
},
"TEAM_SUMMARY": {
"PREFIX": "Your team has ",
"OPEN_CONVERSATIONS": "{count} open conversations",
"MID": ", ",
"NEARING_SLA": "{count} nearing SLA",
"AND": ", and ",
"AGENTS_ONLINE": "{count} agents online",
"PERIOD": "."
},
"AGENT_SUMMARY": {
"PREFIX": "You have ",
"OPEN_CONVERSATIONS": "{count} open conversations",
"MID": ", ",
"UNATTENDED": "{count} unattended",
"AND": ", and ",
"NEARING_SLA": "{count} close to SLA breach",
"PERIOD": "."
},
"KPI": {
"OPEN_CONVERSATIONS": "Open conversations",
"AVG_FIRST_RESPONSE": "Avg first response",
"FIRST_RESPONSE": "First response",
"RESOLVED_TODAY": "Resolved today",
"NOTIFICATIONS": "Notifications",
"SLA_BREACHES_TODAY": "SLA breaches today",
"TEAM_CSAT_TODAY": "Team CSAT today",
"SEE_ALL": "See all"
},
"AI": {
"SHORTCUT": "⌘K"
},
"TEAM_WORKLOAD": {
"TITLE": "Team workload",
"SORT_BY": "Sort by",
"HEADERS": {
"AGENT": "Agent",
"WORKLOAD": "Workload",
"UNATTENDED_OPEN": "Unattended / Open",
"FRT": "FRT",
"CSAT": "CSAT"
},
"OFF_SHIFT": "Off shift",
"CAPACITY": "{percent}% capacity",
"YOU": "YOU"
},
"ATTENTION": {
"TITLE": "Needs your attention",
"SUBTITLE": "3 items, surfaced by Captain"
},
"SUGGESTIONS_ADMIN": {
"OVERLOADED": "Who's overloaded right now?",
"HIGH_VOLUME": "What's driving the {count} open conversations today?",
"CSAT_OUTLIERS": "Show CSAT outliers this week",
"TEAM_PERFORMANCE": "Summarize team performance today",
"COACH_AGENT": "Draft a coaching note for {name}",
"QUIET_DAY": "What did the team accomplish today?",
"FIND_PATTERNS": "Trending customer questions"
},
"SUGGESTIONS_AGENT": {
"SUMMARIZE_QUEUE": "Summarize my queue",
"TRIAGE_UNATTENDED": "Help me triage my {count} unattended",
"NEAR_SLA": "Which of my conversations are close to SLA breach?",
"URGENT": "What's most urgent right now?",
"SUMMARIZE_CONVERSATION": "Summarize my conversation with {name}",
"CATCH_UP": "Catch me up on what I missed",
"SLOW_DAY": "What can I work on?"
},
"AI_PLACEHOLDERS_ADMIN": {
"DEFAULT": "Ask Captain about your team…",
"SUMMARIZE": "Summarize the three most urgent tickets…",
"DRAFT_REPLY": "Draft a refund reply for Aria Chen…",
"WHY_CSAT": "Why did CSAT slip today?",
"FIND_TICKETS": "Find tickets mentioning rate limit this week…"
},
"AI_PLACEHOLDERS_AGENT": {
"DEFAULT": "Ask anything about your queue…",
"SUMMARIZE": "Summarize the three most urgent tickets…",
"DRAFT_REPLY": "Draft a refund reply for Aria Chen…",
"WHY_CSAT": "Why did CSAT slip today?",
"FIND_TICKETS": "Find tickets mentioning rate limit this week…"
},
"SORT_OPTIONS_ADMIN": {
"CAPACITY": "Capacity",
"FIRST_RESPONSE": "First response",
"CSAT_TODAY": "CSAT today",
"RESOLVED_TODAY": "Resolved today"
},
"SORT_OPTIONS_AGENT": {
"PRIORITY": "Priority",
"SLA_BREACH": "SLA breach",
"LATEST": "Latest activity",
"OLDEST": "Oldest first",
"CUSTOMER": "Customer name"
},
"TOP_CONVERSATIONS": {
"TITLE": "Top conversations",
"SLA_LEFT": "{time} left",
"EMPTY": "Nothing in your queue.",
"LOADING": "Loading…"
},
"COPILOT_THREAD": {
"BACK": "Back to home",
"SEND": "Send"
},
"RECENT_THREADS": {
"TITLE": "Recent threads"
}
},
"SIDEBAR_ITEMS": {
"CHANGE_AVAILABILITY_STATUS": "Change",
"CHANGE_ACCOUNTS": "Switch account",
@@ -5,6 +5,7 @@ import { routes as contactRoutes } from './contacts/routes';
import { routes as companyRoutes } from './companies/routes';
import { routes as notificationRoutes } from './notifications/routes';
import { routes as inboxRoutes } from './inbox/routes';
import { routes as homeRoutes } from './home/routes';
import { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes';
import campaignsRoutes from './campaigns/campaigns.routes';
@@ -21,6 +22,7 @@ export default {
component: AppContainer,
children: [
...captainRoutes,
...homeRoutes,
...inboxRoutes,
...conversation.routes,
...settings.routes,
@@ -0,0 +1,192 @@
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAccount } from 'dashboard/composables/useAccount';
import CopilotMessagesAPI from 'dashboard/api/captain/copilotMessages';
import CopilotAgentMessage from 'dashboard/components-next/copilot/CopilotAgentMessage.vue';
import CopilotAssistantMessage from 'dashboard/components-next/copilot/CopilotAssistantMessage.vue';
import CopilotThinkingGroup from 'dashboard/components-next/copilot/CopilotThinkingGroup.vue';
import CopilotLoader from 'dashboard/components-next/copilot/CopilotLoader.vue';
import CopilotInput from 'dashboard/components-next/copilot/CopilotInput.vue';
import ToggleCopilotAssistant from 'dashboard/components-next/copilot/ToggleCopilotAssistant.vue';
const props = defineProps({
threadId: {
type: [String, Number],
required: true,
},
});
const store = useStore();
const router = useRouter();
const { uiSettings, updateUISettings } = useUISettings();
const { accountScopedRoute } = useAccount();
const assistants = useMapGetter('captainAssistants/getRecords');
const messages = computed(() =>
store.getters['copilotMessages/getMessagesByThreadId'](props.threadId)
);
const activeAssistant = computed(() => {
const preferredId = uiSettings.value.preferred_captain_assistant_id;
if (preferredId) {
const found = assistants.value.find(a => a.id === preferredId);
if (found) return found;
}
return assistants.value[0];
});
const setAssistant = async assistant => {
await updateUISettings({
preferred_captain_assistant_id: assistant.id,
});
};
const sendMessage = async message => {
try {
await store.dispatch('copilotMessages/create', {
assistant_id: activeAssistant.value?.id,
threadId: props.threadId,
message,
});
} catch (error) {
useAlert(error.message);
}
};
const goHome = () => {
router.push(accountScopedRoute('home_dashboard'));
};
const groupedMessages = computed(() => {
const result = [];
let thinkingGroup = [];
messages.value.forEach(message => {
if (message.message_type === 'assistant_thinking') {
thinkingGroup.push(message);
} else {
if (thinkingGroup.length) {
result.push({
id: thinkingGroup[0].id,
message_type: 'thinking_group',
messages: thinkingGroup,
});
thinkingGroup = [];
}
result.push(message);
}
});
if (thinkingGroup.length) {
result.push({
id: thinkingGroup[0].id,
message_type: 'thinking_group',
messages: thinkingGroup,
});
}
return result;
});
const isLastMessageFromAssistant = computed(() => {
const last = groupedMessages.value[groupedMessages.value.length - 1];
return last?.message_type === 'assistant';
});
const chatContainer = ref(null);
const scrollToBottom = async () => {
await nextTick();
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
}
};
watch(() => messages.value, scrollToBottom, { deep: true });
onMounted(async () => {
store.dispatch('captainAssistants/get');
try {
const { data } = await CopilotMessagesAPI.get(props.threadId);
const payload = data?.payload || [];
payload.forEach(message => {
store.dispatch('copilotMessages/upsert', message);
});
scrollToBottom();
} catch {
// best-effort fetch; rely on websocket if API errors
}
});
</script>
<template>
<section class="flex flex-col w-full h-full bg-n-surface-1 text-n-slate-12">
<div
class="flex items-center gap-2 px-6 py-3 border-b border-n-weak flex-shrink-0"
>
<button
type="button"
class="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md text-[13px] font-medium text-n-slate-11 hover:text-n-slate-12 hover:bg-n-alpha-2 transition-colors bg-transparent border-0 cursor-pointer"
@click="goHome"
>
<span class="i-lucide-chevron-left size-4" />
{{ $t('HOME_DASHBOARD_PAGE.COPILOT_THREAD.BACK') }}
</button>
</div>
<div
ref="chatContainer"
class="flex-1 min-h-0 w-full overflow-y-auto text-sm leading-6 tracking-tight"
>
<div class="w-full max-w-[820px] mx-auto px-6 py-6">
<div
v-if="groupedMessages.length"
class="space-y-6 flex flex-col w-full"
>
<template v-for="(item, index) in groupedMessages" :key="item.id">
<CopilotAgentMessage
v-if="item.message_type === 'user'"
:message="item.message"
/>
<CopilotAssistantMessage
v-else-if="item.message_type === 'assistant'"
:message="item.message"
:is-last-message="index === groupedMessages.length - 1"
conversation-inbox-type=""
/>
<CopilotThinkingGroup
v-else
:messages="item.messages"
:default-collapsed="isLastMessageFromAssistant"
/>
</template>
<CopilotLoader v-if="!isLastMessageFromAssistant" />
</div>
<div
v-else
class="flex items-center justify-center h-full text-sm text-n-slate-10"
>
{{ $t('HOME_DASHBOARD_PAGE.TOP_CONVERSATIONS.LOADING') }}
</div>
</div>
</div>
<div class="w-full flex-shrink-0">
<div class="w-full max-w-[820px] mx-auto px-6 pt-px pb-4">
<div class="flex items-center justify-between gap-2 mb-1">
<ToggleCopilotAssistant
v-if="assistants.length > 1"
:assistants="assistants"
:active-assistant="activeAssistant || {}"
@set-assistant="setAssistant"
/>
<div v-else />
</div>
<CopilotInput
v-if="assistants.length"
class="w-full"
@send="sendMessage"
/>
</div>
</div>
</section>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
import { frontendURL } from 'dashboard/helper/URLHelper';
import { ROLES } from 'dashboard/constants/permissions.js';
import HomeDashboard from './HomeDashboard.vue';
import HomeCopilotThread from './HomeCopilotThread.vue';
export const routes = [
{
path: frontendURL('accounts/:accountId/home'),
name: 'home_dashboard',
component: HomeDashboard,
meta: {
permissions: ROLES,
},
},
{
path: frontendURL('accounts/:accountId/home/admin'),
name: 'home_dashboard_admin',
component: HomeDashboard,
meta: {
permissions: ['administrator'],
},
},
{
path: frontendURL('accounts/:accountId/home/copilot/:threadId'),
name: 'home_copilot_thread',
component: HomeCopilotThread,
props: true,
meta: {
permissions: ROLES,
},
},
];
@@ -13,6 +13,7 @@ import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
import FontSize from './FontSize.vue';
import UserLanguageSelect from './UserLanguageSelect.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import HotKeyCard from './HotKeyCard.vue';
import ChangePassword from './ChangePassword.vue';
import NotificationPreferences from './NotificationPreferences.vue';
@@ -43,13 +44,16 @@ export default {
AccessToken,
MfaSettingsCard,
BaseSettingsHeader,
SettingsToggleSection,
},
setup() {
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
const { uiSettings, isEditorHotKeyEnabled, updateUISettings } =
useUISettings();
const { currentFontSize, updateFontSize } = useFontSize();
const { replaceInstallationName } = useBranding();
return {
uiSettings,
currentFontSize,
updateFontSize,
isEditorHotKeyEnabled,
@@ -103,6 +107,14 @@ export default {
isMfaEnabled() {
return parseBoolean(window.chatwootConfig?.isMfaEnabled);
},
homeDashboardEnabled: {
get() {
return !!this.uiSettings?.home_dashboard_enabled;
},
set(value) {
this.updateUISettings({ home_dashboard_enabled: value });
},
},
},
mounted() {
if (this.currentUserId) {
@@ -248,6 +260,16 @@ export default {
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.NOTE')
"
/>
<SettingsToggleSection
v-model="homeDashboardEnabled"
class="w-full"
:header="
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.HOME_DASHBOARD.TITLE')
"
:description="
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.HOME_DASHBOARD.NOTE')
"
/>
</div>
</SectionLayout>
<SectionLayout
+3 -1
View File
@@ -39,7 +39,9 @@ export const validateAuthenticateRoutePermission = async (to, next) => {
isActive;
if (to.name === 'no_accounts' || !to.name) {
const target = needsOnboarding ? 'onboarding' : 'dashboard';
const homeDashboardEnabled = !!user?.ui_settings?.home_dashboard_enabled;
const landingTarget = homeDashboardEnabled ? 'home' : 'dashboard';
const target = needsOnboarding ? 'onboarding' : landingTarget;
return next(frontendURL(`accounts/${routeAccountId}/${target}`));
}