feat(editor): Chat: show conversation history in drawer on mobile device (no-changelog) (#20661)

This commit is contained in:
Suguru Inoue 2025-10-13 15:32:42 +02:00 committed by GitHub
parent dbe9f9f923
commit fd3caae509
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 925 additions and 691 deletions

View File

@ -239,7 +239,7 @@ defineExpose({
.subMenuTitle {
display: inline-flex;
align-items: center;
gap: var(--spacing-2xs);
gap: var(--spacing--2xs);
}
.submenu__icon {

View File

@ -1,6 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, nextTick, type Ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { computed, onBeforeUnmount, onMounted, ref, nextTick, type Ref, useTemplateRef } from 'vue';
import { onClickOutside, type VueInstance } from '@vueuse/core';
import { useI18n } from '@n8n/i18n';
@ -14,7 +13,6 @@ import {
N8nLogo,
N8nPopoverReka,
N8nScrollArea,
N8nAvatar,
N8nText,
N8nIcon,
N8nButton,
@ -43,7 +41,6 @@ import { useSourceControlStore } from '@/features/sourceControl.ee/sourceControl
import { useDebounce } from '@/composables/useDebounce';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { useTelemetry } from '@/composables/useTelemetry';
import { useUserHelpers } from '@/composables/useUserHelpers';
import { useBugReporting } from '@/composables/useBugReporting';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
@ -59,6 +56,7 @@ import { useKeybindings } from '@/composables/useKeybindings';
import { useCalloutHelpers } from '@/composables/useCalloutHelpers';
import ProjectNavigation from '@/features/projects/components/ProjectNavigation.vue';
import MainSidebarSourceControl from './MainSidebarSourceControl.vue';
import MainSidebarUserArea from '@/components/MainSidebarUserArea.vue';
import { usePostHog } from '@/stores/posthog.store';
const becomeTemplateCreatorStore = useBecomeTemplateCreatorStore();
@ -77,8 +75,6 @@ const personalizedTemplatesV3Store = usePersonalizedTemplatesV3Store();
const { callDebounced } = useDebounce();
const externalHooks = useExternalHooks();
const i18n = useI18n();
const route = useRoute();
const router = useRouter();
const telemetry = useTelemetry();
const pageRedirectionHelper = usePageRedirectionHelper();
const { getReportingURL } = useBugReporting();
@ -88,26 +84,13 @@ const posthogStore = usePostHog();
useKeybindings({
ctrl_alt_o: () => handleSelect('about'),
});
useUserHelpers(router, route);
// Template refs
const user = ref<Element | null>(null);
const user = useTemplateRef('user');
// Component data
const basePath = ref('');
const fullyExpanded = ref(false);
const userMenuItems = ref<IMenuItem[]>([
{
id: 'settings',
icon: 'settings',
label: i18n.baseText('settings'),
},
{
id: 'logout',
icon: 'door-open',
label: i18n.baseText('auth.signout'),
},
]);
const showWhatsNewNotification = computed(
() =>
@ -342,9 +325,9 @@ const userIsTrialing = computed(() => cloudPlanStore.userIsTrialing);
onMounted(async () => {
window.addEventListener('resize', onResize);
basePath.value = rootStore.baseUrl;
if (user.value) {
if (user.value?.$el) {
void externalHooks.run('mainSidebar.mounted', {
userRef: user.value,
userRef: user.value.$el,
});
}
@ -365,23 +348,6 @@ const trackHelpItemClick = (itemType: string) => {
});
};
const onUserActionToggle = (action: string) => {
switch (action) {
case 'logout':
onLogout();
break;
case 'settings':
void router.push({ name: VIEWS.SETTINGS });
break;
default:
break;
}
};
const onLogout = () => {
void router.push({ name: VIEWS.SIGNOUT });
};
const toggleCollapse = () => {
uiStore.toggleSidebarMenuCollapse();
// When expanding, delay showing some element to ensure smooth animation
@ -640,54 +606,12 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
</N8nScrollArea>
<MainSidebarSourceControl :is-collapsed="isCollapsed" />
<div v-if="showUserArea">
<div ref="user" :class="$style.userArea">
<N8nPopoverReka side="right" align="end" :side-offset="16">
<template #content>
<div :class="$style.popover">
<N8nMenuItem
v-for="action in userMenuItems"
:key="action.id"
:item="action"
:data-test-id="`user-menu-item-${action.id}`"
@click="() => onUserActionToggle(action.id)"
/>
</div>
</template>
<template #trigger>
<div :class="$style.userAreaInner">
<div class="ml-3xs" data-test-id="main-sidebar-user-menu">
<!-- This dropdown is only enabled when sidebar is collapsed -->
<div :class="{ [$style.avatar]: true, ['clickable']: isCollapsed }">
<N8nAvatar
:first-name="usersStore.currentUser?.firstName"
:last-name="usersStore.currentUser?.lastName"
size="small"
/>
</div>
</div>
<div
:class="{
['ml-2xs']: true,
[$style.userName]: true,
[$style.expanded]: fullyExpanded,
}"
>
<N8nText size="small" color="text-dark">{{
usersStore.currentUser?.fullName
}}</N8nText>
</div>
<div
data-test-id="user-menu"
:class="{ [$style.userActions]: true, [$style.expanded]: fullyExpanded }"
>
<N8nIconButton icon="ellipsis" text square type="tertiary" />
</div>
</div>
</template>
</N8nPopoverReka>
</div>
</div>
<MainSidebarUserArea
v-if="showUserArea"
ref="user"
:fully-expanded="fullyExpanded"
:is-collapsed="isCollapsed"
/>
<TemplateTooltip />
</div>
@ -773,44 +697,6 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
margin-bottom: var(--spacing--3xs);
}
.userArea {
display: flex;
padding: var(--spacing--xs);
align-items: center;
border-top: var(--border-width) var(--border-style) var(--color--foreground);
.userName {
display: none;
overflow: hidden;
width: 100px;
white-space: nowrap;
text-overflow: ellipsis;
&.expanded {
display: initial;
}
span {
overflow: hidden;
text-overflow: ellipsis;
}
}
.userActions {
display: none;
&.expanded {
display: initial;
}
}
}
.userAreaInner {
display: flex;
align-items: center;
width: 100%;
}
@media screen and (max-height: 470px) {
:global(#help) {
display: none;

View File

@ -0,0 +1,147 @@
<script setup lang="ts">
import { VIEWS } from '@/constants';
import { useUsersStore } from '@/stores/users.store';
import {
type IMenuItem,
N8nAvatar,
N8nIconButton,
N8nMenuItem,
N8nPopoverReka,
N8nText,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
defineProps<{ fullyExpanded: boolean; isCollapsed: boolean }>();
const i18n = useI18n();
const router = useRouter();
const usersStore = useUsersStore();
const userMenuItems = ref<IMenuItem[]>([
{
id: 'settings',
icon: 'settings',
label: i18n.baseText('settings'),
},
{
id: 'logout',
icon: 'door-open',
label: i18n.baseText('auth.signout'),
},
]);
const onLogout = () => {
void router.push({ name: VIEWS.SIGNOUT });
};
const onUserActionToggle = (action: string) => {
switch (action) {
case 'logout':
onLogout();
break;
case 'settings':
void router.push({ name: VIEWS.SETTINGS });
break;
default:
break;
}
};
</script>
<template>
<div ref="user" :class="$style.userArea">
<N8nPopoverReka side="right" align="end" :side-offset="16">
<template #content>
<div :class="$style.popover">
<N8nMenuItem
v-for="action in userMenuItems"
:key="action.id"
:item="action"
:data-test-id="`user-menu-item-${action.id}`"
@click="() => onUserActionToggle(action.id)"
/>
</div>
</template>
<template #trigger>
<div :class="$style.userAreaInner">
<div class="ml-3xs" data-test-id="main-sidebar-user-menu">
<!-- This dropdown is only enabled when sidebar is collapsed -->
<div :class="{ ['clickable']: isCollapsed }">
<N8nAvatar
:first-name="usersStore.currentUser?.firstName"
:last-name="usersStore.currentUser?.lastName"
size="small"
/>
</div>
</div>
<div
:class="{
['ml-2xs']: true,
[$style.userName]: true,
[$style.expanded]: fullyExpanded,
}"
>
<N8nText size="small" color="text-dark">
{{ usersStore.currentUser?.fullName }}
</N8nText>
</div>
<div
data-test-id="user-menu"
:class="{ [$style.userActions]: true, [$style.expanded]: fullyExpanded }"
>
<N8nIconButton icon="ellipsis" text square type="tertiary" />
</div>
</div>
</template>
</N8nPopoverReka>
</div>
</template>
<style lang="scss" module>
.userArea {
display: flex;
padding: var(--spacing--xs);
align-items: center;
border-top: var(--border-width) var(--border-style) var(--color--foreground);
.userName {
flex-grow: 1;
flex-shrink: 1;
display: none;
overflow: hidden;
width: 100px;
white-space: nowrap;
text-overflow: ellipsis;
&.expanded {
display: initial;
}
span {
overflow: hidden;
text-overflow: ellipsis;
}
}
.userActions {
display: none;
&.expanded {
display: initial;
}
}
}
.userAreaInner {
display: flex;
align-items: center;
width: 100%;
}
.popover {
padding: var(--spacing--xs);
min-width: 200px;
}
</style>

View File

@ -3,6 +3,7 @@ import { useUIStore } from '@/stores/ui.store';
import { onBeforeUnmount, onMounted } from 'vue';
import type { EventBus } from '@n8n/utils/event-bus';
import { ElDrawer } from 'element-plus';
const props = withDefaults(
defineProps<{
name: string;
@ -13,12 +14,14 @@ const props = withDefaults(
width: string;
wrapperClosable?: boolean;
closeOnClickModal?: boolean;
showClose?: boolean;
zIndex?: number;
}>(),
{
modal: true,
wrapperClosable: true,
closeOnClickModal: false,
showClose: true,
},
);
@ -74,13 +77,14 @@ onBeforeUnmount(() => {
<template>
<ElDrawer
:direction="direction"
:model-value="uiStore.modalsById[name].open"
:model-value="uiStore.modalsById[name]?.open ?? false"
:size="width"
:before-close="close"
:modal="modal"
:wrapper-closable="wrapperClosable"
:close-on-click-modal="closeOnClickModal"
:z-index="zIndex"
:show-close="showClose"
>
<template #header>
<slot name="header" />

View File

@ -6,7 +6,7 @@ import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import { hasPermission } from '@/utils/rbac/permissions';
import { useRoute, useRouter } from 'vue-router';
import { useRouter } from 'vue-router';
import { useI18n } from '@n8n/i18n';
import { N8nIcon, N8nLink, N8nMenuItem, N8nText, type IMenuItem } from '@n8n/design-system';
@ -15,10 +15,9 @@ const emit = defineEmits<{
}>();
const router = useRouter();
const route = useRoute();
const i18n = useI18n();
const { canUserAccessRouteByName } = useUserHelpers(router, route);
const { canUserAccessRouteByName } = useUserHelpers(router);
const rootStore = useRootStore();
const settingsStore = useSettingsStore();

View File

@ -1,18 +1,14 @@
import type { RouteLocation, RouteLocationNormalizedLoaded, Router } from 'vue-router';
import { hasPermission } from '@/utils/rbac/permissions';
import type { PermissionTypeOptions } from '@/types/rbac';
import { hasPermission } from '@/utils/rbac/permissions';
import type { RouteLocation, Router } from 'vue-router';
export function useUserHelpers(router: Router, route: RouteLocationNormalizedLoaded) {
export function useUserHelpers(router: Router) {
const canUserAccessRouteByName = (name: string) => {
const resolvedRoute = router.resolve({ name });
return canUserAccessRoute(resolvedRoute);
};
const canUserAccessCurrentRoute = () => {
return canUserAccessRoute(route);
};
const canUserAccessRoute = (route: RouteLocation) => {
const middleware = route.meta?.middleware;
const middlewareOptions = route.meta?.middlewareOptions;
@ -26,7 +22,5 @@ export function useUserHelpers(router: Router, route: RouteLocationNormalizedLoa
return {
canUserAccessRouteByName,
canUserAccessCurrentRoute,
canUserAccessRoute,
};
}

View File

@ -89,6 +89,7 @@ export const WHATS_NEW_MODAL_KEY = 'whatsNew';
export const WORKFLOW_DIFF_MODAL_KEY = 'workflowDiff';
export const PRE_BUILT_AGENTS_MODAL_KEY = 'preBuiltAgents';
export const VARIABLE_MODAL_KEY = 'variableModal';
export const CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY = 'chatHubSideMenuDrawer';
export const EXPERIMENT_TEMPLATE_RECO_V2_KEY = 'templateRecoV2';
export const EXPERIMENT_TEMPLATE_RECO_V3_KEY = 'templateRecoV3';
@ -508,8 +509,10 @@ export const LOCAL_STORAGE_FOCUS_PANEL = 'N8N_FOCUS_PANEL';
export const LOCAL_STORAGE_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS =
'N8N_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS';
export const LOCAL_STORAGE_RUN_DATA_WORKER = 'N8N_RUN_DATA_WORKER';
export const LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL = 'N8N_CHAT_HUB_SELECTED_MODEL';
export const LOCAL_STORAGE_CHAT_HUB_CREDENTIALS = 'N8N_CHAT_HUB_CREDENTIALS';
export const LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL = (userId: string) =>
`${userId}_N8N_CHAT_HUB_SELECTED_MODEL`;
export const LOCAL_STORAGE_CHAT_HUB_CREDENTIALS = (userId: string) =>
`${userId}_N8N_CHAT_HUB_CREDENTIALS`;
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
export const COMMUNITY_PLUS_DOCS_URL =

View File

@ -2,23 +2,15 @@
import { ref, computed, watch, nextTick, onMounted, useTemplateRef } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { v4 as uuidv4 } from 'uuid';
import hljs from 'highlight.js/lib/core';
import { N8nHeading, N8nIcon, N8nText, N8nScrollArea } from '@n8n/design-system';
import PageViewLayout from '@/components/layouts/PageViewLayout.vue';
import { N8nIcon, N8nScrollArea, N8nIconButton } from '@n8n/design-system';
import ModelSelector from './components/ModelSelector.vue';
import CredentialSelectorModal from './components/CredentialSelectorModal.vue';
import { useChatStore } from './chat.store';
import { useUsersStore } from '@/stores/users.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useUIStore } from '@/stores/ui.store';
import {
credentialsMapSchema,
type ChatMessage,
type CredentialsMap,
type Suggestion,
} from './chat.types';
import { credentialsMapSchema, type CredentialsMap, type Suggestion } from './chat.types';
import {
chatHubConversationModelSchema,
type ChatHubProvider,
@ -26,28 +18,35 @@ import {
type ChatHubConversationModel,
chatHubProviderSchema,
} from '@n8n/api-types';
import VueMarkdown from 'vue-markdown-render';
import markdownLink from 'markdown-it-link-attributes';
import type MarkdownIt from 'markdown-it';
import { useLocalStorage } from '@vueuse/core';
import { useLocalStorage, useMediaQuery } from '@vueuse/core';
import {
LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL,
LOCAL_STORAGE_CHAT_HUB_CREDENTIALS,
CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY,
} from '@/constants';
import { CHAT_CONVERSATION_VIEW, CHAT_VIEW, SUGGESTIONS } from '@/features/chatHub/constants';
import {
CHAT_CONVERSATION_VIEW,
CHAT_VIEW,
MOBILE_MEDIA_QUERY,
} from '@/features/chatHub/constants';
import { findOneFromModelsResponse } from '@/features/chatHub/chat.utils';
import { useToast } from '@/composables/useToast';
import ChatMessage from '@/features/chatHub/components/ChatMessage.vue';
import ChatPrompt from '@/features/chatHub/components/ChatPrompt.vue';
import ChatTypingIndicator from '@/features/chatHub/components/ChatTypingIndicator.vue';
import ChatStarter from '@/features/chatHub/components/ChatStarter.vue';
import { useUsersStore } from '@/stores/users.store';
const router = useRouter();
const route = useRoute();
const usersStore = useUsersStore();
const chatStore = useChatStore();
const userStore = useUsersStore();
const credentialsStore = useCredentialsStore();
const uiStore = useUIStore();
const toast = useToast();
const isMobileDevice = useMediaQuery(MOBILE_MEDIA_QUERY);
const inputRef = useTemplateRef('inputRef');
const message = ref('');
const sessionId = computed<string>(() =>
typeof route.params.id === 'string' ? route.params.id : uuidv4(),
);
@ -56,7 +55,7 @@ const messagesRef = ref<HTMLDivElement | null>(null);
const scrollAreaRef = ref<InstanceType<typeof N8nScrollArea>>();
const credentialSelectorProvider = ref<ChatHubProvider | null>(null);
const selectedModel = useLocalStorage<ChatHubConversationModel | null>(
LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL,
LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL(usersStore.currentUserId ?? 'anonymous'),
null,
{
writeDefaults: false,
@ -75,7 +74,7 @@ const selectedModel = useLocalStorage<ChatHubConversationModel | null>(
);
const selectedCredentials = useLocalStorage<CredentialsMap>(
LOCAL_STORAGE_CHAT_HUB_CREDENTIALS,
LOCAL_STORAGE_CHAT_HUB_CREDENTIALS(usersStore.currentUserId ?? 'anonymous'),
{},
{
writeDefaults: false,
@ -227,8 +226,8 @@ function onCreateNewCredential(provider: ChatHubProvider) {
uiStore.openNewCredential(PROVIDER_CREDENTIAL_TYPE_MAP[provider]);
}
function onSubmit() {
if (!message.value.trim() || chatStore.isResponding || !selectedModel.value) {
function onSubmit(message: string) {
if (!message.trim() || chatStore.isResponding || !selectedModel.value) {
return;
}
@ -238,14 +237,14 @@ function onSubmit() {
return;
}
chatStore.askAI(sessionId.value, message.value, selectedModel.value, {
chatStore.askAI(sessionId.value, message, selectedModel.value, {
[PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.value.provider]]: {
id: credentialsId,
name: '',
},
});
message.value = '';
inputRef.value?.setText('');
if (isNewSession.value) {
void router.push({ name: CHAT_CONVERSATION_VIEW, params: { id: sessionId.value } });
@ -253,50 +252,37 @@ function onSubmit() {
}
function onSuggestionClick(s: Suggestion) {
message.value = `${s.title} ${s.subtitle}`;
inputRef.value?.setText(`${s.title} ${s.subtitle}`);
}
function onAttach() {}
function onMic() {}
function messageText(msg: ChatMessage) {
return msg.type === 'message' ? msg.text : `**Error:** ${msg.content}`;
}
const markdownOptions = {
highlight(str: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value;
} catch {}
}
return ''; // use external default escaping
},
};
const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
vueMarkdownItInstance.use(markdownLink, {
attrs: {
target: '_blank',
rel: 'noopener',
},
});
};
</script>
<template>
<PageViewLayout>
<ModelSelector
:class="$style.modelSelector"
:models="chatStore.models ?? null"
:selected-model="selectedModel"
:disabled="chatStore.isResponding"
:credentials-name="credentialsName"
@change="onModelChange"
@configure="onConfigure"
/>
<N8nScrollArea
ref="scrollAreaRef"
type="hover"
:enable-vertical-scroll="true"
:enable-horizontal-scroll="false"
:viewport-class="$style.scrollViewport"
as-child
:class="{ [$style.hasMessages]: hasMessages, [$style.isMobileDevice]: isMobileDevice }"
>
<div :class="$style.floating">
<N8nIconButton
v-if="isMobileDevice"
type="secondary"
icon="menu"
@click="uiStore.openModal(CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY)"
/>
<ModelSelector
:models="chatStore.models ?? null"
:selected-model="selectedModel"
:disabled="chatStore.isResponding"
:credentials-name="credentialsName"
@change="onModelChange"
@configure="onConfigure"
/>
</div>
<CredentialSelectorModal
v-if="credentialSelectorProvider"
:key="credentialSelectorProvider"
@ -305,180 +291,51 @@ const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
@select="onCredentialSelected"
@create-new="onCreateNewCredential"
/>
<div
:class="{
[$style.content]: true,
[$style.centered]: !hasMessages,
}"
>
<section
:class="{
[$style.section]: true,
[$style.fullHeight]: hasMessages,
}"
>
<div v-if="!hasMessages" :class="$style.header">
<N8nHeading tag="h2" bold size="xlarge">
{{
`Good morning, ${userStore.currentUser?.firstName ?? userStore.currentUser?.fullName ?? 'User'}!`
}}
</N8nHeading>
</div>
<div v-if="!hasMessages" :class="$style.suggestions">
<button
v-for="s in SUGGESTIONS"
:key="s.title"
type="button"
:class="$style.card"
@click="onSuggestionClick(s)"
>
<div :class="$style.cardIcon" aria-hidden="true">
<N8nText size="xlarge">{{ s.icon }}</N8nText>
</div>
<div :class="$style.cardText">
<N8nText bold color="text-dark">{{ s.title }}</N8nText>
<N8nText color="text-base">{{ s.subtitle }}</N8nText>
</div>
</button>
</div>
<div :class="$style.scrollable">
<ChatStarter
v-if="!hasMessages"
:class="$style.starter"
:is-mobile-device="isMobileDevice"
@select="onSuggestionClick"
/>
<!-- Chat thread -->
<template v-else>
<div :class="$style.threadContainer">
<N8nScrollArea
ref="scrollAreaRef"
type="hover"
:enable-vertical-scroll="true"
:enable-horizontal-scroll="false"
:class="$style.threadWrap"
>
<div ref="messagesRef" :class="$style.thread" role="log" aria-live="polite">
<div
v-for="m in chatMessages"
:key="m.id"
:class="[
$style.message,
m.role === 'user' ? $style.user : $style.assistant,
m.type === 'error' && $style.error,
]"
>
<div :class="$style.avatar">
<N8nIcon
:icon="m.role === 'user' ? 'user' : 'sparkles'"
width="20"
height="20"
/>
</div>
<div
:class="{
[$style.chatMessage]: true,
[$style.chatMessageFromUser]: m.role === 'user',
[$style.chatMessageFromAssistant]: m.role === 'assistant',
}"
>
<VueMarkdown
:class="$style.chatMessageMarkdown"
:source="messageText(m)"
:options="markdownOptions"
:plugins="[linksNewTabPlugin]"
/>
</div>
</div>
<div v-else ref="messagesRef" role="log" aria-live="polite" :class="$style.messageList">
<ChatMessage v-for="m in chatMessages" :key="m.id" :message="m" :compact="isMobileDevice" />
<!-- Typing indicator while streaming -->
<div v-if="chatStore.isResponding" :class="[$style.message, $style.assistant]">
<div :class="$style.avatar">
<N8nIcon icon="sparkles" width="20" height="20" />
</div>
<div :class="$style.bubble">
<span :class="$style.typing"><i></i><i></i><i></i></span>
</div>
</div>
</div>
</N8nScrollArea>
<div v-if="chatStore.isResponding" :class="[$style.message, $style.assistant]">
<div :class="$style.avatar">
<N8nIcon icon="sparkles" width="20" height="20" />
</div>
</template>
<!-- Prompt -->
<form :class="$style.prompt" @submit.prevent="onSubmit">
<div :class="$style.inputWrap">
<input
ref="inputRef"
v-model="message"
:class="$style.input"
type="text"
:placeholder="inputPlaceholder"
autocomplete="off"
autofocus
:disabled="chatStore.isResponding"
/>
<div :class="$style.actions">
<button
:class="$style.iconBtn"
type="button"
title="Attach"
:disabled="chatStore.isResponding"
@click="onAttach"
>
<N8nIcon icon="paperclip" width="20" height="20" />
</button>
<button
:class="$style.iconBtn"
type="button"
title="Voice"
:disabled="chatStore.isResponding"
@click="onMic"
>
<N8nIcon icon="mic" width="20" height="20" />
</button>
<button
:class="$style.sendBtn"
type="submit"
:disabled="chatStore.isResponding || !message.trim()"
>
<span v-if="!chatStore.isResponding">Send</span>
<span v-else></span>
</button>
</div>
<div :class="$style.bubble">
<ChatTypingIndicator v-if="chatStore.isResponding" />
</div>
<N8nText :class="$style.disclaimer" color="text-light" size="small">
AI may make mistakes. Check important info.
<br />
{{ sessionId }}
</N8nText>
</form>
</section>
</div>
</div>
<div :class="$style.promptContainer">
<ChatPrompt
ref="inputRef"
:class="$style.prompt"
:placeholder="inputPlaceholder"
:disabled="chatStore.isResponding"
:session-id="sessionId"
@submit="onSubmit"
/>
</div>
</div>
</PageViewLayout>
</N8nScrollArea>
</template>
<style lang="scss" module>
.content {
.scrollable {
width: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
gap: var(--spacing--md);
padding-bottom: var(--spacing--lg);
height: 100%;
min-height: 0;
}
.centered {
align-items: stretch;
justify-content: center;
}
.section {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing--lg);
}
.fullHeight {
flex: 1;
min-height: 0;
gap: var(--spacing--2xl);
}
.header {
@ -487,243 +344,63 @@ const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
align-items: center;
}
/* Suggestions */
.suggestions {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: var(--spacing--md);
width: min(960px, 90%);
margin-top: var(--spacing--md);
}
@media (max-width: 800px) {
.suggestions {
grid-template-columns: 1fr;
}
}
.card {
display: flex;
align-items: flex-start;
gap: var(--spacing--sm);
padding: var(--spacing--md);
border: 1px solid var(--color--foreground);
background: var(--color--background);
border-radius: var(--radius--lg);
text-align: left;
cursor: pointer;
transition:
transform 0.06s ease,
background 0.06s ease,
border-color 0.06s ease;
}
.card:hover {
border-color: var(--color--primary);
background: rgba(124, 58, 237, 0.04);
}
.cardIcon {
height: 100%;
display: flex;
align-items: center;
}
.cardText {
display: grid;
gap: 2px;
}
.threadWrap {
flex: 1;
height: 100%;
min-height: 0;
}
.threadContainer {
width: min(960px, 90%);
flex: 1;
min-height: 0;
display: flex;
}
.thread {
padding: var(--spacing--md);
background: var(--color--background--light-2);
}
.message {
display: grid;
grid-template-columns: 28px 1fr;
gap: var(--spacing--sm);
margin-bottom: var(--spacing--md);
}
.avatar {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--color--background--light-3);
color: var(--color--text--tint-1);
}
.chatMessage {
display: block;
position: relative;
max-width: fit-content;
padding: var(--spacing--md);
border-radius: var(--radius--lg);
&.chatMessageFromAssistant {
background-color: var(--color--background);
}
&.chatMessageFromUser {
background-color: var(--color--background--shade-1);
}
> .chatMessageMarkdown {
display: block;
box-sizing: border-box;
font-size: inherit;
> *:first-child {
margin-top: 0;
}
> *:last-child {
margin-bottom: 0;
}
p {
margin: var(--spacing--xs) 0;
}
pre {
font-family: inherit;
font-size: inherit;
margin: 0;
white-space: pre-wrap;
box-sizing: border-box;
padding: var(--chat--spacing);
background: var(--chat--message--pre--background);
border-radius: var(--chat--border-radius);
}
.starter {
.isMobileDevice & {
padding-top: 100px;
padding-bottom: 200px;
}
}
/* Typing indicator */
.typing {
display: inline-flex;
gap: 6px;
}
.typing i {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.35;
animation: blink 1.2s infinite;
}
.typing i:nth-child(2) {
animation-delay: 0.2s;
}
.typing i:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes blink {
0%,
80%,
100% {
opacity: 0.35;
transform: translateY(0);
}
40% {
opacity: 1;
transform: translateY(-2px);
}
}
/* Prompt */
.prompt {
display: grid;
place-items: center;
.messageList {
width: 100%;
margin-top: var(--spacing--md);
}
.inputWrap {
position: relative;
max-width: 55rem;
min-height: 100vh;
align-self: center;
display: flex;
align-items: center;
width: min(720px, 50vw);
min-width: 320px;
flex-direction: column;
gap: var(--spacing--md);
padding-top: 100px;
padding-bottom: 200px;
padding-inline: 64px;
& input:disabled {
cursor: not-allowed;
.isMobileDevice & {
padding-inline: var(--spacing--md);
}
}
.input {
flex: 1;
font: inherit;
padding: 14px 112px 14px 14px;
border: 1px solid var(--color--foreground);
background: var(--color--background--light-2);
color: var(--color--text--shade-1);
border-radius: 16px;
outline: none;
}
.input:focus {
border-color: var(--color--primary);
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.15);
.promptContainer {
display: flex;
justify-content: center;
.isMobileDevice &,
.hasMessages & {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
padding-block: var(--spacing--md);
background: linear-gradient(transparent 0%, var(--color--background--light-2) 30%);
}
}
/* Right-side actions */
.actions {
.prompt {
width: 100%;
max-width: 55rem;
padding-inline: 64px;
.isMobileDevice & {
padding-inline: var(--spacing--md);
}
}
.floating {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
padding: var(--spacing--xs);
top: 0;
left: 0;
z-index: 100;
display: flex;
align-items: center;
gap: 6px;
}
.iconBtn {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 10px;
border: none;
background: transparent;
color: var(--color--text--tint-1);
cursor: pointer;
}
.iconBtn:hover {
background: rgba(0, 0, 0, 0.04);
color: var(--color--text--shade-1);
}
.sendBtn {
height: 32px;
padding: 0 10px;
border-radius: 10px;
border: none;
background: var(--color--primary);
color: #fff;
font-weight: 600;
cursor: pointer;
}
.sendBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.disclaimer {
margin-top: var(--spacing--xs);
color: var(--color--text--tint-2);
text-align: center;
}
.modelSelector {
position: absolute;
top: var(--spacing-xs);
left: var(--spacing-xs);
z-index: 100;
gap: var(--spacing--xs);
}
</style>

View File

@ -0,0 +1,125 @@
<script setup lang="ts">
import type { ChatMessage } from '@/features/chatHub/chat.types';
import { N8nIcon } from '@n8n/design-system';
import VueMarkdown from 'vue-markdown-render';
import hljs from 'highlight.js/lib/core';
import markdownLink from 'markdown-it-link-attributes';
import type MarkdownIt from 'markdown-it';
const { message, compact } = defineProps<{ message: ChatMessage; compact: boolean }>();
function messageText(msg: ChatMessage) {
return msg.type === 'message' ? msg.text : `**Error:** ${msg.content}`;
}
const markdownOptions = {
highlight(str: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value;
} catch {}
}
return ''; // use external default escaping
},
};
const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
vueMarkdownItInstance.use(markdownLink, {
attrs: {
target: '_blank',
rel: 'noopener',
},
});
};
</script>
<template>
<div
:class="[
$style.message,
message.role === 'user' ? $style.user : $style.assistant,
{
[$style.compact]: compact,
},
]"
>
<div :class="$style.avatar">
<N8nIcon :icon="message.role === 'user' ? 'user' : 'sparkles'" width="20" height="20" />
</div>
<div :class="$style.chatMessage">
<VueMarkdown
:class="[$style.chatMessageMarkdown, 'chat-message-markdown']"
:source="messageText(message)"
:options="markdownOptions"
:plugins="[linksNewTabPlugin]"
/>
</div>
</div>
</template>
<style lang="scss" module>
.message {
position: relative;
}
.avatar {
position: absolute;
right: 100%;
margin-right: var(--spacing--xs);
top: 0;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--color--background--light-3);
color: var(--color--text--tint-1);
.compact & {
position: static;
margin-bottom: var(--spacing--xs);
}
}
.chatMessage {
display: block;
position: relative;
max-width: fit-content;
.user & {
padding: var(--spacing--md);
border-radius: var(--radius--lg);
background-color: var(--color--background);
}
> .chatMessageMarkdown {
display: block;
box-sizing: border-box;
font-size: inherit;
> *:first-child {
margin-top: 0;
}
> *:last-child {
margin-bottom: 0;
}
p {
margin: var(--spacing--xs) 0;
}
pre {
font-family: inherit;
font-size: inherit;
margin: 0;
white-space: pre-wrap;
box-sizing: border-box;
padding: var(--chat--spacing);
background: var(--chat--message--pre--background);
border-radius: var(--chat--border-radius);
}
}
}
</style>

View File

@ -0,0 +1,159 @@
<script setup lang="ts">
import { N8nIcon, N8nText } from '@n8n/design-system';
import { ref, useTemplateRef } from 'vue';
const { disabled, sessionId } = defineProps<{
placeholder: string;
disabled: boolean;
sessionId: string;
}>();
const emit = defineEmits<{
submit: [string];
}>();
const inputRef = useTemplateRef('inputRef');
const message = ref('');
function onAttach() {}
function onMic() {}
defineExpose({
focus: () => inputRef.value?.focus(),
setText: (text: string) => {
message.value = text;
},
});
</script>
<template>
<form :class="$style.prompt" @submit.prevent="emit('submit', message)">
<div :class="$style.inputWrap">
<input
ref="inputRef"
v-model="message"
:class="$style.input"
type="text"
:placeholder="placeholder"
autocomplete="off"
autofocus
:disabled="disabled"
/>
<div :class="$style.actions">
<button
:class="$style.iconBtn"
type="button"
title="Attach"
:disabled="disabled"
@click="onAttach"
>
<N8nIcon icon="paperclip" width="20" height="20" />
</button>
<button
:class="$style.iconBtn"
type="button"
title="Voice"
:disabled="disabled"
@click="onMic"
>
<N8nIcon icon="mic" width="20" height="20" />
</button>
<button :class="$style.sendBtn" type="submit" :disabled="disabled || !message.trim()">
<span v-if="!disabled">Send</span>
<span v-else></span>
</button>
</div>
</div>
<N8nText :class="$style.disclaimer" color="text-light" size="small">
AI may make mistakes. Check important info.
<br />
{{ sessionId }}
</N8nText>
</form>
</template>
<style lang="scss" module>
.prompt {
display: grid;
place-items: center;
}
.inputWrap {
position: relative;
display: flex;
align-items: center;
width: 100%;
& input:disabled {
cursor: not-allowed;
}
}
.input {
flex: 1;
font: inherit;
padding: 14px 112px 14px 14px;
border: 1px solid var(--color--foreground);
background: var(--color--background--light-2);
color: var(--color--text--shade-1);
border-radius: 16px;
outline: none;
}
.input:focus {
border-color: var(--color--primary);
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.15);
}
/* Right-side actions */
.actions {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 6px;
}
.iconBtn {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 10px;
border: none;
background: transparent;
color: var(--color--text--tint-1);
cursor: pointer;
}
.iconBtn:hover {
background: rgba(0, 0, 0, 0.04);
color: var(--color--text--shade-1);
}
.sendBtn {
height: 32px;
padding: 0 10px;
border-radius: 10px;
border: none;
background: var(--color--primary);
color: #fff;
font-weight: 600;
cursor: pointer;
}
.sendBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.disclaimer {
margin-top: var(--spacing--xs);
color: var(--color--text--tint-2);
text-align: center;
}
</style>

View File

@ -1,117 +1,61 @@
<script lang="ts" setup>
import { computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { CHAT_CONVERSATION_VIEW, CHAT_VIEW } from '@/features/chatHub/constants';
import { N8nIcon, N8nIconButton, N8nMenuItem, N8nText } from '@n8n/design-system';
import { useChatStore } from '@/features/chatHub/chat.store';
import { groupConversationsByDate } from '@/features/chatHub/chat.utils';
import { VIEWS } from '@/constants';
import ModalDrawer from '@/components/ModalDrawer.vue';
import { CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY } from '@/constants';
import ChatSidebarContent from '@/features/chatHub/components/ChatSidebarContent.vue';
import { MOBILE_MEDIA_QUERY } from '@/features/chatHub/constants';
import { useUIStore } from '@/stores/ui.store';
import { useMediaQuery } from '@vueuse/core';
import { onBeforeUnmount, watch } from 'vue';
import { useRoute } from 'vue-router';
const router = useRouter();
const uiStore = useUIStore();
const isMobileDevice = useMediaQuery(MOBILE_MEDIA_QUERY);
const route = useRoute();
const currentSessionId = computed(() =>
typeof route.params.id === 'string' ? route.params.id : undefined,
watch(
() => route.fullPath,
() => uiStore.closeModal(CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY),
);
const chatStore = useChatStore();
onMounted(async () => {
await chatStore.fetchSessions();
onBeforeUnmount(() => {
uiStore.closeModal(CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY);
});
const groupedConversations = computed(() => groupConversationsByDate(chatStore.sessions));
function onReturn() {
void router.push({ name: VIEWS.HOMEPAGE });
}
function onNewChat() {
void router.push({
name: CHAT_VIEW,
force: true, // to focus input again when the user is already in CHAT_VIEW
});
}
</script>
<template>
<div :class="['side-menu', $style.container]">
<div :class="$style.header">
<div :class="$style.returnButton" @click="onReturn">
<i>
<N8nIcon icon="arrow-left" />
</i>
<N8nText bold>Chat</N8nText>
</div>
<N8nIconButton title="New chat" icon="square-pen" type="tertiary" text @click="onNewChat" />
</div>
<div :class="$style.items">
<div v-for="group in groupedConversations" :key="group.group" :class="$style.group">
<N8nText :class="$style.groupHeader" size="xsmall" bold color="text-light">
{{ group.group }}
</N8nText>
<N8nMenuItem
v-for="session in group.sessions"
:key="session.id"
:active="currentSessionId === session.id"
:item="{
id: session.id,
icon: 'message-circle',
label: session.label,
route: { to: { name: CHAT_CONVERSATION_VIEW, params: { id: session.id } } },
}"
/>
</div>
</div>
</div>
<ModalDrawer
v-if="isMobileDevice"
direction="ltr"
width="min(240px, 80vw)"
:name="CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY"
:class="$style.drawer"
:close-on-click-modal="true"
:show-close="false"
>
<template #content>
<ChatSidebarContent :class="$style.inDrawer" :is-mobile-device="isMobileDevice" />
</template>
</ModalDrawer>
<ChatSidebarContent v-else :class="$style.static" :is-mobile-device="isMobileDevice" />
</template>
<style lang="scss" module>
.container {
width: 200px;
height: 100%;
background-color: var(--color--background--light-3);
border-right: var(--border-base);
position: relative;
overflow: auto;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-xs);
gap: var(--spacing-2xs);
}
.returnButton {
cursor: pointer;
display: flex;
gap: var(--spacing-3xs);
align-items: center;
flex: 1;
&:hover {
color: var(--color--primary);
.drawer {
& :global(.el-drawer__header) {
padding: 0;
}
}
.items {
display: flex;
flex-direction: column;
padding: 0 var(--spacing-3xs);
gap: var(--spacing-xs);
.inDrawer,
.static {
height: 100%;
}
.group {
display: flex;
flex-direction: column;
}
.groupHeader {
padding: 0 var(--spacing-3xs) var(--spacing-3xs) var(--spacing-3xs);
}
.loading,
.empty {
padding: var(--spacing-xs);
text-align: center;
.static {
width: 200px;
background-color: var(--color--background--light-3);
border-right: var(--border);
position: relative;
overflow: auto;
}
</style>

View File

@ -0,0 +1,139 @@
<script setup lang="ts">
import MainSidebarUserArea from '@/components/MainSidebarUserArea.vue';
import { CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY, VIEWS } from '@/constants';
import { useChatStore } from '@/features/chatHub/chat.store';
import { groupConversationsByDate } from '@/features/chatHub/chat.utils';
import { CHAT_CONVERSATION_VIEW, CHAT_VIEW } from '@/features/chatHub/constants';
import { useUIStore } from '@/stores/ui.store';
import { N8nIcon, N8nIconButton, N8nMenuItem, N8nScrollArea, N8nText } from '@n8n/design-system';
import { computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
defineProps<{ isMobileDevice: boolean }>();
const route = useRoute();
const router = useRouter();
const chatStore = useChatStore();
const uiStore = useUIStore();
const currentSessionId = computed(() =>
typeof route.params.id === 'string' ? route.params.id : undefined,
);
const groupedConversations = computed(() => groupConversationsByDate(chatStore.sessions));
function onReturn() {
uiStore.closeModal(CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY);
void router.push({ name: VIEWS.HOMEPAGE });
}
function onNewChat() {
uiStore.closeModal(CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY);
void router.push({
name: CHAT_VIEW,
force: true, // to focus input again when the user is already in CHAT_VIEW
});
}
onMounted(async () => {
await chatStore.fetchSessions();
});
</script>
<template>
<div :class="[$style.component, { [$style.isMobileDevice]: isMobileDevice }]">
<div :class="$style.header">
<div :class="$style.returnButton" role="button" @click="onReturn">
<N8nIcon icon="arrow-left" />
<N8nText bold>Chat</N8nText>
</div>
<N8nIconButton
title="New chat"
icon="square-pen"
type="tertiary"
text
:size="isMobileDevice ? 'large' : 'medium'"
@click="onNewChat"
/>
</div>
<N8nScrollArea as-child>
<div :class="$style.items">
<div v-for="group in groupedConversations" :key="group.group" :class="$style.group">
<N8nText :class="$style.groupHeader" size="small" bold color="text-light">
{{ group.group }}
</N8nText>
<N8nMenuItem
v-for="session in group.sessions"
:key="session.id"
:active="currentSessionId === session.id"
:item="{
id: session.id,
icon: 'message-circle',
label: session.label,
route: { to: { name: CHAT_CONVERSATION_VIEW, params: { id: session.id } } },
}"
/>
</div>
</div>
</N8nScrollArea>
<MainSidebarUserArea :fully-expanded="true" :is-collapsed="false" />
</div>
</template>
<style lang="scss" module>
.component {
display: flex;
flex-direction: column;
align-items: stretch;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing--xs);
gap: var(--spacing--2xs);
}
.returnButton {
cursor: pointer;
flex-grow: 1;
flex-shrink: 1;
display: flex;
gap: var(--spacing--3xs);
align-items: center;
flex: 1;
&:hover {
color: var(--color--primary);
}
}
.items {
display: flex;
flex-direction: column;
padding: 0 var(--spacing--3xs);
gap: var(--spacing--xs);
.isMobileDevice & {
gap: var(--spacing--sm);
}
}
.group {
display: flex;
flex-direction: column;
}
.groupHeader {
padding: 0 var(--spacing--3xs) var(--spacing--3xs) var(--spacing--3xs);
}
.loading,
.empty {
padding: var(--spacing--xs);
text-align: center;
}
</style>

View File

@ -0,0 +1,106 @@
<script setup lang="ts">
import type { Suggestion } from '@/features/chatHub/chat.types';
import { SUGGESTIONS } from '@/features/chatHub/constants';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { N8nHeading, N8nText } from '@n8n/design-system';
import Logo from '@n8n/design-system/components/N8nLogo/Logo.vue';
import { computed } from 'vue';
defineProps<{ isMobileDevice: boolean }>();
const emit = defineEmits<{
select: [Suggestion];
}>();
const userStore = useUsersStore();
const settingsStore = useSettingsStore();
const greetings = computed(
() =>
`Good morning, ${userStore.currentUser?.firstName ?? userStore.currentUser?.fullName ?? 'User'}!`,
);
</script>
<template>
<div :class="[$style.starter, { [$style.isMobileDevice]: isMobileDevice }]">
<Logo size="large" :release-channel="settingsStore.settings.releaseChannel" />
<div :class="$style.header">
<N8nHeading tag="h2" bold size="xlarge">
{{ greetings }}
</N8nHeading>
</div>
<div :class="$style.suggestions">
<button
v-for="suggestion in SUGGESTIONS"
:key="suggestion.title"
type="button"
:class="$style.card"
@click="emit('select', suggestion)"
>
<div :class="$style.cardIcon" aria-hidden="true">
<N8nText size="xlarge">{{ suggestion.icon }}</N8nText>
</div>
<div :class="$style.cardText">
<N8nText bold color="text-dark">{{ suggestion.title }}</N8nText>
<N8nText color="text-base">{{ suggestion.subtitle }}</N8nText>
</div>
</button>
</div>
</div>
</template>
<style lang="scss" module>
.starter {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing--2xl);
}
.suggestions {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: var(--spacing--md);
width: min(960px, 90%);
.isMobileDevice & {
grid-template-columns: 1fr;
}
}
.card {
display: flex;
align-items: flex-start;
gap: var(--spacing--sm);
padding: var(--spacing--md);
border: 1px solid var(--color--foreground);
background: var(--color--background);
border-radius: var(--radius--lg);
text-align: left;
cursor: pointer;
transition:
transform 0.06s ease,
background 0.06s ease,
border-color 0.06s ease;
}
.card:hover {
border-color: var(--color--primary);
background: rgba(124, 58, 237, 0.04);
}
.cardIcon {
height: 100%;
display: flex;
align-items: center;
}
.cardText {
display: grid;
gap: 2px;
}
</style>

View File

@ -0,0 +1,40 @@
<template>
<span :class="$style.typing"><i></i><i></i><i></i></span>
</template>
<style lang="scss" module>
.typing {
display: inline-flex;
gap: 6px;
}
.typing i {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.35;
animation: blink 1.2s infinite;
}
.typing i:nth-child(2) {
animation-delay: 0.2s;
}
.typing i:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes blink {
0%,
80%,
100% {
opacity: 0.35;
transform: translateY(0);
}
40% {
opacity: 1;
transform: translateY(-2px);
}
}
</style>

View File

@ -130,7 +130,7 @@ function onCancel() {
.header {
display: flex;
gap: var(--spacing-2xs);
gap: var(--spacing--2xs);
align-items: center;
}

View File

@ -98,7 +98,9 @@ function onSelect(id: string) {
:class="$style.icon"
/>
<div :class="$style.selected">
{{ selectedLabel }}
<div>
{{ selectedLabel }}
</div>
<N8nText v-if="credentialsName" size="xsmall" color="text-light">
{{ credentialsName }}
</N8nText>
@ -120,6 +122,13 @@ function onSelect(id: string) {
flex-direction: column;
align-items: start;
gap: var(--spacing--4xs);
max-width: 200px;
& > div {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
}
.icon {

View File

@ -35,3 +35,5 @@ export const providerDisplayNames: Record<ChatHubProvider, string> = {
anthropic: 'Anthropic',
google: 'Google',
};
export const MOBILE_MEDIA_QUERY = '(max-width: 768px)';