Summaries in the ChatList

This commit is contained in:
Pranav
2026-01-29 09:56:29 -08:00
parent a32565d72b
commit 137f0e726b
8 changed files with 349 additions and 31 deletions
@@ -1,6 +1,7 @@
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
@@ -14,6 +15,8 @@ import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
import ConversationSummary from './ConversationSummary.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
activeLabel: { type: String, default: '' },
@@ -46,8 +49,10 @@ const emit = defineEmits([
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const hovered = ref(false);
const summaryRef = ref(null);
const showContextMenu = ref(false);
const contextMenu = ref({
x: null,
@@ -344,12 +349,15 @@ const deleteConversation = () => {
class="absolute flex flex-col ltr:right-3 rtl:left-3"
:class="showMetaSection ? 'top-8' : 'top-4'"
>
<span class="ml-auto font-normal leading-4 text-xxs">
<TimeAgo
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
/>
</span>
<div class="flex items-center gap-1 ml-auto">
<ConversationSummary ref="summaryRef" :conversation-id="chat.id" />
<span class="font-normal leading-4 text-xxs">
<TimeAgo
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
/>
</span>
</div>
<span
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
:class="hasUnread ? 'block' : 'hidden'"
@@ -366,6 +374,26 @@ const deleteConversation = () => {
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template>
</CardLabels>
<!-- Expanded Summary Section -->
<div
v-if="summaryRef?.isExpanded"
class="mt-2 mx-2 mb-1 p-3 bg-n-alpha-1 dark:bg-n-alpha-2 rounded-lg"
>
<div v-if="summaryRef?.isLoading" class="flex items-center gap-2">
<Spinner :size="16" class="text-n-slate-10" />
<span class="text-xs text-n-slate-11">
{{ t('CHAT_LIST.SUMMARY.LOADING') }}
</span>
</div>
<div v-else-if="summaryRef?.error" class="text-xs text-n-ruby-11">
{{ summaryRef.error }}
</div>
<div
v-else-if="summaryRef?.formattedSummary"
class="text-xs text-n-slate-11 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:my-0.5 [&_p]:my-1 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_strong]:text-n-slate-12"
v-html="summaryRef.formattedSummary"
/>
</div>
</div>
<ContextMenu
v-if="showContextMenu"
@@ -0,0 +1,90 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import TasksAPI from 'dashboard/api/captain/tasks';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const { t } = useI18n();
const { isCloudFeatureEnabled } = useAccount();
const { formatMessage } = useMessageFormatter();
const isExpanded = ref(false);
const isLoading = ref(false);
const summary = ref('');
const error = ref('');
const captainTasksEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
});
const formattedSummary = computed(() => {
return summary.value ? formatMessage(summary.value) : '';
});
const fetchSummary = async () => {
isLoading.value = true;
error.value = '';
try {
const result = await TasksAPI.summarize(props.conversationId);
const {
data: { message: generatedMessage },
} = result;
summary.value = generatedMessage || '';
} catch (e) {
if (e.name !== 'AbortError' && e.name !== 'CanceledError') {
error.value = e.response?.data?.error || t('CHAT_LIST.SUMMARY.ERROR');
}
} finally {
isLoading.value = false;
}
};
const toggleSummary = async () => {
if (isExpanded.value) {
isExpanded.value = false;
return;
}
isExpanded.value = true;
if (!summary.value && !error.value) {
await fetchSummary();
}
};
const onButtonClick = e => {
e.stopPropagation();
toggleSummary();
};
defineExpose({
isExpanded,
isLoading,
summary,
error,
formattedSummary,
captainTasksEnabled,
});
</script>
<template>
<Button
v-if="captainTasksEnabled"
icon="i-material-symbols-auto-awesome"
slate
ghost
xs
:title="t('CHAT_LIST.SUMMARY.TITLE')"
class="opacity-0 group-hover:opacity-100 transition-opacity"
:class="{ '!opacity-100': isExpanded }"
@click="onButtonClick"
/>
</template>
@@ -2,6 +2,7 @@ import { computed } from 'vue';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'conversation_summary' },
{ name: 'conversation_actions' },
{ name: 'macros' },
{ name: 'conversation_info' },
@@ -137,6 +137,11 @@
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
"SHOW_QUOTED_TEXT": "Show Quoted Text",
"MESSAGE_READ": "Read",
"SENDING": "Sending"
"SENDING": "Sending",
"SUMMARY": {
"TITLE": "Generate Summary",
"LOADING": "Generating summary...",
"ERROR": "Failed to generate summary"
}
}
}
@@ -347,6 +347,7 @@
"CONVERSATION_ACTIONS": "Conversation Actions",
"CONVERSATION_LABELS": "Conversation Labels",
"CONVERSATION_INFO": "Conversation Information",
"CONVERSATION_SUMMARY": "AI Summary",
"CONTACT_NOTES": "Contact Notes",
"CONTACT_ATTRIBUTES": "Contact Attributes",
"PREVIOUS_CONVERSATION": "Previous Conversations",
@@ -354,6 +355,14 @@
"LINEAR_ISSUES": "Linked Linear Issues",
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SUMMARY": {
"DESCRIPTION": "Generate an AI-powered summary of this conversation",
"GENERATE": "Generate Summary",
"REGENERATE": "Regenerate",
"RETRY": "Retry",
"ERROR": "Failed to generate summary",
"EMPTY": "No summary available"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
"ERROR": "Error loading orders",
@@ -13,6 +13,7 @@ import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
import ContactConversations from './ContactConversations.vue';
import ConversationAction from './ConversationAction.vue';
import ConversationParticipant from './ConversationParticipant.vue';
import ConversationSummary from './ConversationSummary.vue';
import ContactInfo from './contact/ContactInfo.vue';
import ContactNotes from './contact/ContactNotes.vue';
import ConversationInfo from './ConversationInfo.vue';
@@ -44,6 +45,7 @@ const {
const dragging = ref(false);
const conversationSidebarItems = ref([]);
const summaryRef = ref(null);
const shopifyIntegration = useFunctionGetter(
'integrations/getIntegration',
@@ -60,6 +62,10 @@ const isLinearFeatureEnabled = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.LINEAR)
);
const isCaptainTasksEnabled = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS)
);
const linearIntegration = useFunctionGetter(
'integrations/getIntegration',
'linear'
@@ -150,7 +156,31 @@ onMounted(() => {
>
<template #item="{ element }">
<div
v-if="element.name === 'conversation_actions'"
v-if="
element.name === 'conversation_summary' && isCaptainTasksEnabled
"
>
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONVERSATION_SUMMARY')"
:is-open="isContactSidebarItemOpen('is_conv_summary_open')"
compact
@toggle="
value => {
toggleSidebarUIState('is_conv_summary_open', value);
if (value && summaryRef) {
summaryRef.fetchSummary();
}
}
"
>
<ConversationSummary
ref="summaryRef"
:conversation-id="conversationId"
/>
</AccordionItem>
</div>
<div
v-else-if="element.name === 'conversation_actions'"
class="conversation--actions"
>
<AccordionItem
@@ -0,0 +1,150 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import TasksAPI from 'dashboard/api/captain/tasks';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const { t } = useI18n();
const { isCloudFeatureEnabled } = useAccount();
const { formatMessage } = useMessageFormatter();
const isLoading = ref(false);
const summary = ref('');
const error = ref('');
const hasFetched = ref(false);
const captainTasksEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
});
const formattedSummary = computed(() => {
return summary.value ? formatMessage(summary.value) : '';
});
const fetchSummary = async () => {
if (!captainTasksEnabled.value || hasFetched.value) return;
isLoading.value = true;
error.value = '';
hasFetched.value = true;
try {
const result = await TasksAPI.summarize(props.conversationId);
const {
data: { message: generatedMessage },
} = result;
summary.value = generatedMessage || '';
} catch (e) {
if (e.name !== 'AbortError' && e.name !== 'CanceledError') {
error.value =
e.response?.data?.error || t('CONVERSATION_SIDEBAR.SUMMARY.ERROR');
}
} finally {
isLoading.value = false;
}
};
const refetch = () => {
hasFetched.value = false;
summary.value = '';
error.value = '';
fetchSummary();
};
watch(
() => props.conversationId,
() => {
hasFetched.value = false;
summary.value = '';
error.value = '';
}
);
defineExpose({
fetchSummary,
captainTasksEnabled,
});
</script>
<template>
<div v-if="captainTasksEnabled" class="p-3">
<div v-if="isLoading" class="flex items-center justify-center py-4">
<Spinner :size="20" class="text-n-slate-10" />
</div>
<div v-else-if="error" class="text-sm text-n-ruby-11">
{{ error }}
<Button
:label="t('CONVERSATION_SIDEBAR.SUMMARY.RETRY')"
size="sm"
variant="link"
class="ml-2"
@click="refetch"
/>
</div>
<div v-else-if="!hasFetched" class="flex flex-col items-center gap-3 py-2">
<p class="text-sm text-n-slate-11 text-center mb-0">
{{ t('CONVERSATION_SIDEBAR.SUMMARY.DESCRIPTION') }}
</p>
<Button
:label="t('CONVERSATION_SIDEBAR.SUMMARY.GENERATE')"
icon="i-material-symbols-auto-awesome"
size="sm"
@click="fetchSummary"
/>
</div>
<div
v-else-if="summary"
class="summary-content text-sm text-n-slate-11 animate-fade-in [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:my-1 [&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_strong]:text-n-slate-12"
v-html="formattedSummary"
/>
<div v-else class="text-sm text-n-slate-11 py-2">
{{ t('CONVERSATION_SIDEBAR.SUMMARY.EMPTY') }}
</div>
<div
v-if="hasFetched && !isLoading && !error"
class="mt-3 pt-3 border-t border-n-weak"
>
<Button
:label="t('CONVERSATION_SIDEBAR.SUMMARY.REGENERATE')"
icon="i-lucide-refresh-cw"
size="sm"
variant="faded"
color="slate"
@click="refetch"
/>
</div>
</div>
</template>
<style scoped>
.animate-fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>