feat(editor): Update Chat sidebar to match v2 styles (#23267)

This commit is contained in:
Jaakko Husso
2025-12-17 17:16:39 +02:00
committed by GitHub
parent 44ff6a734f
commit 9bebbab3db
28 changed files with 975 additions and 729 deletions
@@ -72,6 +72,7 @@ import IconLucideCircle from '~icons/lucide/circle';
import IconLucideCircleAlert from '~icons/lucide/circle-alert';
import IconLucideCircleCheck from '~icons/lucide/circle-check';
import IconLucideCircleDot from '~icons/lucide/circle-dot';
import IconLucideCircleEllipsis from '~icons/lucide/circle-ellipsis';
import IconLucideCircleHelp from '~icons/lucide/circle-help';
import IconLucideCircleMinus from '~icons/lucide/circle-minus';
import IconLucideCirclePause from '~icons/lucide/circle-pause';
@@ -151,6 +152,7 @@ import IconLucideMaximize2 from '~icons/lucide/maximize-2';
import IconLucideMenu from '~icons/lucide/menu';
import IconLucideMessageCircle from '~icons/lucide/message-circle';
import IconLucideMessageSquare from '~icons/lucide/message-square';
import IconLucideMessageSquarePlus from '~icons/lucide/message-square-plus';
import IconLucideMessagesSquare from '~icons/lucide/messages-square';
import IconLucideMic from '~icons/lucide/mic';
import IconLucideMilestone from '~icons/lucide/milestone';
@@ -518,6 +520,7 @@ export const updatedIconSet = {
'circle-alert': IconLucideCircleAlert,
'circle-check': IconLucideCircleCheck,
'circle-dot': IconLucideCircleDot,
'circle-ellipsis': IconLucideCircleEllipsis,
'circle-help': IconLucideCircleHelp,
'circle-minus': IconLucideCircleMinus,
'circle-pause': IconLucideCirclePause,
@@ -597,6 +600,7 @@ export const updatedIconSet = {
menu: IconLucideMenu,
'message-circle': IconLucideMessageCircle,
'message-square': IconLucideMessageSquare,
'message-square-plus': IconLucideMessageSquarePlus,
'messages-square': IconLucideMessagesSquare,
mic: IconLucideMic,
milestone: IconLucideMilestone,
@@ -60,6 +60,16 @@ withSecondaryIconTooltip.args = {
},
};
export const withBetaTag = template.bind({});
withBetaTag.args = {
item: {
id: 'workflows',
icon: 'home',
label: 'Workflows',
beta: true,
},
};
export const compact = template.bind({});
compact.args = {
item: {
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import type { IMenuItem } from '@n8n/design-system/types';
import BetaTag from '../BetaTag/BetaTag.vue';
import N8nIcon from '../N8nIcon';
import type { IconName } from '../N8nIcon/icons';
import N8nRoute from '../N8nRoute';
@@ -66,7 +67,7 @@ const iconColor = computed(() => {
:id="item.id"
:to="to"
role="menuitem"
:class="[$style.menuItem, { [$style.active]: active }]"
:class="[$style.menuItem, { [$style.active]: active, [$style.compact]: compact }]"
:aria-label="props.ariaLabel"
data-test-id="menu-item"
@click="emit('click')"
@@ -83,7 +84,12 @@ const iconColor = computed(() => {
>
<N8nIcon v-else-if="icon" :color="iconColor" :icon="icon" />
</div>
<N8nText v-if="!compact" :class="$style.menuItemText">{{ item.label }}</N8nText>
<div :class="$style.menuItemLabel">
<N8nText v-if="!compact" :class="$style.menuItemText" color="text-dark">
{{ item.label }}
</N8nText>
<BetaTag v-if="!compact && item.beta" />
</div>
<N8nIcon v-if="item.children && !compact" icon="chevron-right" color="text-light" />
</N8nRoute>
</N8nTooltip>
@@ -125,6 +131,10 @@ const iconColor = computed(() => {
background-color: var(--color--background--light-1);
color: var(--color--text--shade-1);
}
&.compact {
gap: 0;
}
}
.menuItem:focus-visible {
@@ -136,7 +146,6 @@ const iconColor = computed(() => {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex: 1;
line-height: var(--font-size--lg);
min-width: 0;
}
@@ -176,4 +185,13 @@ const iconColor = computed(() => {
color: var(--color--text--shade-1);
}
}
.menuItemLabel {
display: flex;
align-items: center;
flex-direction: row;
gap: var(--spacing--3xs);
flex: 1;
min-width: 0;
}
</style>
@@ -36,6 +36,7 @@ export type IMenuItem = {
disabled?: boolean;
notification?: boolean;
size?: 'medium' | 'small';
beta?: boolean;
};
export interface ICustomMenuItem {
@@ -456,6 +456,7 @@
"chatHub.sidebar.link.newChat": "New chat",
"chatHub.sidebar.link.workflowAgents": "Workflow agents",
"chatHub.sidebar.link.personalAgents": "Personal agents",
"chatHub.sidebar.loadMoreSessions": "Load more",
"chatEmbed.infoTip.description": "Add chat to external applications using the n8n chat package.",
"chatEmbed.infoTip.link": "More info",
"chatEmbed.title": "Embed Chat in your website",
@@ -3737,7 +3738,7 @@
"projects.menu.title": "Projects",
"projects.menu.personal": "Personal",
"projects.menu.addFirstProject": "Add project",
"projects.menu.chat": "Chat (Beta)",
"projects.menu.chat": "Chat",
"projects.settings": "Project settings",
"projects.settings.info": "Project info",
"projects.settings.newProjectName": "My project",
@@ -0,0 +1,231 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useCloudPlanStore } from '@/app/stores/cloudPlan.store';
import {
N8nMenuItem,
N8nPopover,
N8nText,
isCustomMenuItem,
type IMenuItem,
type IMenuElement,
} from '@n8n/design-system';
import { RELEASE_NOTES_URL } from '@/app/constants';
import BecomeTemplateCreatorCta from '@/app/components/BecomeTemplateCreatorCta/BecomeTemplateCreatorCta.vue';
import { useVersionsStore } from '@/app/stores/versions.store';
import VersionUpdateCTA from '@/app/components/VersionUpdateCTA.vue';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useI18n } from '@n8n/i18n';
defineProps<{
items: IMenuItem[];
isCollapsed: boolean;
}>();
const emit = defineEmits<{
select: [key: string];
logout: [];
}>();
const cloudPlanStore = useCloudPlanStore();
const versionsStore = useVersionsStore();
const usersStore = useUsersStore();
const i18n = useI18n();
const whatsNewItems = computed<{ available: boolean; children: IMenuElement[] }>(() => ({
available: versionsStore.hasVersionUpdates || versionsStore.whatsNewArticles.length > 0,
children: [
...versionsStore.whatsNewArticles.map(
(article) =>
({
id: `whats-new-article-${article.id}`,
label: article.title,
size: 'small',
customIconSize: 'small',
icon: {
type: 'emoji',
value: '•',
color: !versionsStore.isWhatsNewArticleRead(article.id) ? 'primary' : 'text-light',
},
}) satisfies IMenuItem,
),
{
id: 'full-changelog',
icon: 'external-link',
label: i18n.baseText('mainSidebar.whatsNew.fullChangelog'),
link: {
href: RELEASE_NOTES_URL,
target: '_blank',
},
size: 'small',
customIconSize: 'small',
},
{
id: 'version-upgrade-cta',
component: VersionUpdateCTA,
available: versionsStore.hasVersionUpdates && usersStore.canUserUpdateVersion,
props: {
tooltipText: !usersStore.canUserUpdateVersion
? i18n.baseText('whatsNew.updateNudgeTooltip')
: undefined,
},
},
],
}));
const userIsTrialing = computed(() => cloudPlanStore.userIsTrialing);
function handleSelect(key: string) {
emit('select', key);
}
function onLogout() {
emit('logout');
}
</script>
<template>
<div
:class="{
[$style.bottomMenu]: true,
[$style.collapsed]: isCollapsed,
}"
>
<div :class="$style.bottomMenuItems">
<template v-for="item in items" :key="item.id">
<!-- Help popover -->
<N8nPopover
v-if="item.children && item.id === 'help'"
key="help"
side="right"
align="end"
:side-offset="12"
>
<template #content>
<div :class="$style.popover">
<BecomeTemplateCreatorCta v-if="!isCollapsed && !userIsTrialing" />
<template v-for="child in item.children" :key="child.id">
<component
:is="child.component"
v-if="isCustomMenuItem(child)"
v-bind="child.props"
/>
<N8nMenuItem v-else :item="child" @click="() => handleSelect(child.id)" />
</template>
<template v-if="whatsNewItems.available">
<N8nText bold size="small" :class="$style.popoverTitle" color="text-light"
>What's new</N8nText
>
<template v-for="child in whatsNewItems.children" :key="child.id">
<component
:is="child.component"
v-if="isCustomMenuItem(child)"
v-bind="child.props"
/>
<N8nMenuItem v-else :item="child" @click="() => handleSelect(child.id)" />
</template>
</template>
</div>
</template>
<template #trigger>
<N8nMenuItem
:data-test-id="`main-sidebar-${item.id}`"
:item="item"
:compact="isCollapsed"
@click="() => handleSelect(item.id)"
/>
</template>
</N8nPopover>
<!-- Settings popover -->
<N8nPopover
v-else-if="item.children && item.id === 'settings'"
key="settings"
side="right"
align="end"
:side-offset="12"
>
<template #content>
<div :class="$style.popover">
<template v-for="child in item.children" :key="child.id">
<component
:is="child.component"
v-if="isCustomMenuItem(child)"
v-bind="child.props"
/>
<N8nMenuItem v-else :item="child" @click="() => handleSelect(child.id)" />
</template>
<span :class="$style.divider" />
<N8nMenuItem
:data-test-id="'main-sidebar-log-out'"
:item="{ id: 'sign-out', label: 'Sign out', icon: 'door-open' }"
@click="onLogout"
/>
</div>
</template>
<template #trigger>
<N8nMenuItem
:data-test-id="`main-sidebar-${item.id}`"
:item="item"
:compact="isCollapsed"
@click="() => handleSelect(item.id)"
/>
</template>
</N8nPopover>
<!-- Items without children -->
<N8nMenuItem
v-else
:data-test-id="`main-sidebar-${item.id}`"
:item="item"
:compact="isCollapsed"
@click="() => handleSelect(item.id)"
/>
</template>
</div>
</div>
</template>
<style lang="scss" module>
.bottomMenu {
display: flex;
flex-direction: column;
margin-top: auto;
&.collapsed {
border-top: var(--border);
}
}
.bottomMenuItems {
padding: var(--spacing--3xs);
}
.popover {
padding: var(--spacing--4xs);
min-width: 260px;
border-radius: var(--radius);
background-color: var(--menu--color--background, var(--color--background--light-2));
}
.popoverTitle {
display: block;
margin-bottom: var(--spacing--3xs);
padding-left: var(--spacing--3xs);
margin-top: var(--spacing--xs);
}
.divider {
display: block;
width: 100%;
padding-top: var(--spacing--3xs);
border-bottom: var(--border);
margin-bottom: var(--spacing--3xs);
background-color: var(--color--border);
}
@media screen and (max-height: 470px) {
:global(#help) {
display: none;
}
}
</style>
@@ -6,7 +6,6 @@ import { defaultSettings } from '@/__tests__/defaults';
import MainSidebar from '@/app/components/MainSidebar.vue';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { useVersionsStore } from '@/app/stores/versions.store';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useTemplatesStore } from '@/features/workflows/templates/templates.store';
@@ -27,7 +26,6 @@ vi.mock('vue-router', () => ({
let renderComponent: ReturnType<typeof createComponentRenderer>;
let settingsStore: MockedStore<typeof useSettingsStore>;
let uiStore: MockedStore<typeof useUIStore>;
let sourceControlStore: MockedStore<typeof useSourceControlStore>;
let versionsStore: MockedStore<typeof useVersionsStore>;
let usersStore: MockedStore<typeof useUsersStore>;
let templatesStore: MockedStore<typeof useTemplatesStore>;
@@ -54,7 +52,6 @@ describe('MainSidebar', () => {
});
settingsStore = mockedStore(useSettingsStore);
uiStore = mockedStore(useUIStore);
sourceControlStore = mockedStore(useSourceControlStore);
versionsStore = mockedStore(useVersionsStore);
usersStore = mockedStore(useUsersStore);
templatesStore = mockedStore(useTemplatesStore);
@@ -83,23 +80,6 @@ describe('MainSidebar', () => {
expect(() => renderComponent()).not.toThrow();
});
test.each([
[false, true, true],
[true, false, false],
[true, true, false],
[false, false, false],
])(
'should render readonly tooltip when is opened %s and the environment is readonly %s',
(sidebarMenuCollapsed, branchReadOnly, shouldRender) => {
uiStore.sidebarMenuCollapsed = sidebarMenuCollapsed;
sourceControlStore.preferences.branchReadOnly = branchReadOnly;
const { queryByTestId } = renderComponent();
expect(queryByTestId('read-only-env-icon') !== null).toBe(shouldRender);
},
);
describe('Version Update CTA', () => {
it('should not render version update CTA when hasVersionUpdates is false', () => {
versionsStore.hasVersionUpdates = false;
@@ -1,67 +1,43 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, nextTick, type Ref } from 'vue';
import { onClickOutside, type VueInstance } from '@vueuse/core';
import { computed, onBeforeUnmount, onMounted, ref, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from '@n8n/i18n';
import {
N8nNavigationDropdown,
N8nTooltip,
N8nLink,
N8nIconButton,
N8nMenuItem,
isCustomMenuItem,
N8nLogo,
N8nPopover,
N8nScrollArea,
N8nText,
N8nIcon,
N8nButton,
N8nResizeWrapper,
} from '@n8n/design-system';
import type { IMenuItem, IMenuElement } from '@n8n/design-system';
import { N8nScrollArea, N8nResizeWrapper, type IMenuItem } from '@n8n/design-system';
import {
ABOUT_MODAL_KEY,
EXPERIMENT_TEMPLATE_RECO_V2_KEY,
EXPERIMENT_TEMPLATE_RECO_V3_KEY,
RELEASE_NOTES_URL,
VIEWS,
WHATS_NEW_MODAL_KEY,
EXPERIMENT_TEMPLATES_DATA_QUALITY_KEY,
} from '@/app/constants';
import { EXTERNAL_LINKS } from '@/app/constants/externalLinks';
import { hasPermission } from '@/app/utils/rbac/permissions';
import { useCloudPlanStore } from '@/app/stores/cloudPlan.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useCloudPlanStore } from '@/app/stores/cloudPlan.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useTemplatesStore } from '@/features/workflows/templates/templates.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useVersionsStore } from '@/app/stores/versions.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useBugReporting } from '@/app/composables/useBugReporting';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { useGlobalEntityCreation } from '@/app/composables/useGlobalEntityCreation';
import { useBecomeTemplateCreatorStore } from '@/app/components/BecomeTemplateCreatorCta/becomeTemplateCreatorStore';
import BecomeTemplateCreatorCta from '@/app/components/BecomeTemplateCreatorCta/BecomeTemplateCreatorCta.vue';
import VersionUpdateCTA from '@/app/components/VersionUpdateCTA.vue';
import { TemplateClickSource, trackTemplatesClick } from '@/experiments/utils';
import { I18nT } from 'vue-i18n';
import { useKeybindings } from '@/app/composables/useKeybindings';
import { useCalloutHelpers } from '@/app/composables/useCalloutHelpers';
import { useSidebarLayout } from '@/app/composables/useSidebarLayout';
import { useSettingsItems } from '@/app/composables/useSettingsItems';
import { usePersonalizedTemplatesV2Store } from '@/experiments/templateRecoV2/stores/templateRecoV2.store';
import { usePersonalizedTemplatesV3Store } from '@/experiments/personalizedTemplatesV3/stores/personalizedTemplatesV3.store';
import { useTemplatesDataQualityStore } from '@/experiments/templatesDataQuality/stores/templatesDataQuality.store';
import { useKeybindings } from '@/app/composables/useKeybindings';
import { useCalloutHelpers } from '@/app/composables/useCalloutHelpers';
import MainSidebarHeader from '@/app/components/MainSidebarHeader.vue';
import BottomMenu from '@/app/components/BottomMenu.vue';
import MainSidebarSourceControl from '@/app/components/MainSidebarSourceControl.vue';
import MainSidebarTrialUpgrade from '@/app/components/MainSidebarTrialUpgrade.vue';
import ProjectNavigation from '@/features/collaboration/projects/components/ProjectNavigation.vue';
import KeyboardShortcutTooltip from './KeyboardShortcutTooltip.vue';
import { useCommandBar } from '@/features/shared/commandBar/composables/useCommandBar';
import MainSidebarSourceControl from './MainSidebarSourceControl.vue';
import MainSidebarTrialUpgrade from './MainSidebarTrialUpgrade.vue';
import TemplateTooltip from '@/experiments/personalizedTemplatesV3/components/TemplateTooltip.vue';
import { useSidebarLayout } from '../composables/useSidebarLayout';
import { useSettingsItems } from '../composables/useSettingsItems';
import { useRouter } from 'vue-router';
import { TemplateClickSource, trackTemplatesClick } from '@/experiments/utils';
const becomeTemplateCreatorStore = useBecomeTemplateCreatorStore();
const cloudPlanStore = useCloudPlanStore();
@@ -69,10 +45,8 @@ const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const templatesStore = useTemplatesStore();
const uiStore = useUIStore();
const usersStore = useUsersStore();
const versionsStore = useVersionsStore();
const workflowsStore = useWorkflowsStore();
const sourceControlStore = useSourceControlStore();
const personalizedTemplatesV2Store = usePersonalizedTemplatesV2Store();
const personalizedTemplatesV3Store = usePersonalizedTemplatesV3Store();
const templatesDataQualityStore = useTemplatesDataQualityStore();
@@ -86,13 +60,6 @@ const calloutHelpers = useCalloutHelpers();
const { isCollapsed, sidebarWidth, onResizeStart, onResize, onResizeEnd, toggleCollapse } =
useSidebarLayout();
useKeybindings({
ctrl_alt_o: () => handleSelect('about'),
['bracketleft']: () => toggleCollapse(),
});
const { isEnabled: isCommandBarEnabled } = useCommandBar();
const { settingsItems } = useSettingsItems();
// Component data
@@ -254,51 +221,6 @@ const visibleMenuItems = computed<IMenuItem[]>(() =>
mainMenuItems.value.filter((item) => item.available !== false),
);
const whatsNewItems = computed<{ available: boolean; children: IMenuElement[] }>(() => ({
available: versionsStore.hasVersionUpdates || versionsStore.whatsNewArticles.length > 0,
children: [
...versionsStore.whatsNewArticles.map(
(article) =>
({
id: `whats-new-article-${article.id}`,
label: article.title,
size: 'small',
customIconSize: 'small',
icon: {
type: 'emoji',
value: '•',
color: !versionsStore.isWhatsNewArticleRead(article.id) ? 'primary' : 'text-light',
},
}) satisfies IMenuItem,
),
{
id: 'full-changelog',
icon: 'external-link',
label: i18n.baseText('mainSidebar.whatsNew.fullChangelog'),
link: {
href: RELEASE_NOTES_URL,
target: '_blank',
},
size: 'small',
customIconSize: 'small',
},
{
id: 'version-upgrade-cta',
component: VersionUpdateCTA,
available: versionsStore.hasVersionUpdates && usersStore.canUserUpdateVersion,
props: {
tooltipText: !usersStore.canUserUpdateVersion
? i18n.baseText('whatsNew.updateNudgeTooltip')
: undefined,
},
},
],
}));
const createBtn = ref<InstanceType<typeof N8nNavigationDropdown>>();
const userIsTrialing = computed(() => cloudPlanStore.userIsTrialing);
onMounted(() => {
basePath.value = rootStore.baseUrl;
@@ -372,6 +294,7 @@ const handleSelect = (key: string) => {
}
case 'insights':
telemetry.track('User clicked insights link from side menu');
break;
default:
if (key.startsWith('whats-new-article-')) {
const articleId = Number(key.replace('whats-new-article-', ''));
@@ -391,23 +314,14 @@ const handleSelect = (key: string) => {
}
};
const {
menu,
handleSelect: handleMenuSelect,
createProjectAppendSlotName,
createWorkflowsAppendSlotName,
createCredentialsAppendSlotName,
projectsLimitReachedMessage,
upgradeLabel,
hasPermissionToCreateProjects,
} = useGlobalEntityCreation();
onClickOutside(createBtn as Ref<VueInstance>, () => {
createBtn.value?.close();
});
const onLogout = () => {
void router.push({ name: VIEWS.SIGNOUT });
};
useKeybindings({
ctrl_alt_o: () => handleSelect('about'),
['bracketleft']: () => toggleCollapse(),
});
</script>
<template>
@@ -427,234 +341,23 @@ const onLogout = () => {
@resize="onResize"
@resizeend="onResizeEnd"
>
<div :class="$style.header">
<N8nLogo
v-if="!isCollapsed"
:class="$style.logo"
size="small"
:collapsed="isCollapsed"
:release-channel="settingsStore.settings.releaseChannel"
>
<N8nTooltip
v-if="sourceControlStore.preferences.branchReadOnly && !isCollapsed"
placement="bottom"
>
<template #content>
<I18nT keypath="readOnlyEnv.tooltip" scope="global">
<template #link>
<N8nLink
to="https://docs.n8n.io/source-control-environments/setup/#step-4-connect-n8n-and-configure-your-instance"
size="small"
>
{{ i18n.baseText('readOnlyEnv.tooltip.link') }}
</N8nLink>
</template>
</I18nT>
</template>
<N8nIcon
data-test-id="read-only-env-icon"
icon="lock"
:class="$style.readOnlyEnvironmentIcon"
/>
</N8nTooltip>
</N8nLogo>
<KeyboardShortcutTooltip
:placement="isCollapsed ? 'right' : 'bottom'"
:label="
isCollapsed
? i18n.baseText('mainSidebar.state.expand')
: i18n.baseText('mainSidebar.state.collapse')
"
show-after="500"
:shortcut="{ keys: ['['] }"
>
<N8nIconButton
id="toggle-sidebar-button"
size="small"
type="highlight"
icon="panel-left"
icon-size="large"
aria-label="Toggle sidebar"
@click="toggleCollapse"
/>
</KeyboardShortcutTooltip>
<N8nNavigationDropdown
ref="createBtn"
data-test-id="universal-add"
:menu="menu"
@select="handleMenuSelect"
>
<N8nIconButton
size="small"
type="highlight"
icon="plus"
icon-size="large"
aria-label="Add new item"
/>
<template #[createWorkflowsAppendSlotName]>
<N8nTooltip
v-if="sourceControlStore.preferences.branchReadOnly"
placement="right"
:content="i18n.baseText('readOnlyEnv.cantAdd.workflow')"
>
<N8nIcon style="margin-left: auto; margin-right: 5px" icon="lock" size="xsmall" />
</N8nTooltip>
</template>
<template #[createCredentialsAppendSlotName]>
<N8nTooltip
v-if="sourceControlStore.preferences.branchReadOnly"
placement="right"
:content="i18n.baseText('readOnlyEnv.cantAdd.credential')"
>
<N8nIcon style="margin-left: auto; margin-right: 5px" icon="lock" size="xsmall" />
</N8nTooltip>
</template>
<template #[createProjectAppendSlotName]="{ item }">
<N8nTooltip
v-if="sourceControlStore.preferences.branchReadOnly"
placement="right"
:content="i18n.baseText('readOnlyEnv.cantAdd.project')"
>
<N8nIcon style="margin-left: auto; margin-right: 5px" icon="lock" size="xsmall" />
</N8nTooltip>
<N8nTooltip
v-else-if="item.disabled"
placement="right"
:content="projectsLimitReachedMessage"
>
<N8nIcon
v-if="!hasPermissionToCreateProjects"
style="margin-left: auto; margin-right: 5px"
icon="lock"
size="xsmall"
/>
<N8nButton
v-else
:size="'mini'"
style="margin-left: auto"
type="tertiary"
@click="handleMenuSelect(item.id)"
>
{{ upgradeLabel }}
</N8nButton>
</N8nTooltip>
</template>
</N8nNavigationDropdown>
<KeyboardShortcutTooltip
v-if="isCommandBarEnabled"
:placement="isCollapsed ? 'right' : 'bottom'"
show-after="500"
:label="i18n.baseText('nodeView.openCommandBar')"
:shortcut="{ keys: ['k'], metaKey: true }"
>
<N8nIconButton
size="small"
type="highlight"
icon="search"
icon-size="large"
aria-label="Open command palette"
@click="openCommandBar"
/>
</KeyboardShortcutTooltip>
</div>
<MainSidebarHeader
:is-collapsed="isCollapsed"
@collapse="toggleCollapse"
@open-command-bar="openCommandBar"
/>
<N8nScrollArea as-child>
<div :class="$style.scrollArea">
<ProjectNavigation
:collapsed="isCollapsed"
:plan-name="cloudPlanStore.currentPlanData?.displayName"
/>
<div :class="$style.bottomMenu">
<div :class="$style.bottomMenuItems">
<template v-for="item in visibleMenuItems" :key="item.id">
<!-- Help popover -->
<N8nPopover
v-if="item.children && item.id === 'help'"
key="help"
side="right"
align="end"
:side-offset="12"
>
<template #content>
<div :class="$style.popover">
<BecomeTemplateCreatorCta v-if="!isCollapsed && !userIsTrialing" />
<template v-for="child in item.children" :key="child.id">
<component
:is="child.component"
v-if="isCustomMenuItem(child)"
v-bind="child.props"
/>
<N8nMenuItem v-else :item="child" @click="() => handleSelect(child.id)" />
</template>
<template v-if="whatsNewItems.available">
<N8nText bold size="small" :class="$style.popoverTitle" color="text-light"
>What's new</N8nText
>
<template v-for="child in whatsNewItems.children" :key="child.id">
<component
:is="child.component"
v-if="isCustomMenuItem(child)"
v-bind="child.props"
/>
<N8nMenuItem v-else :item="child" @click="() => handleSelect(child.id)" />
</template>
</template>
</div>
</template>
<template #trigger>
<N8nMenuItem
:data-test-id="`main-sidebar-${item.id}`"
:item="item"
:compact="isCollapsed"
@click="() => handleSelect(item.id)"
/>
</template>
</N8nPopover>
<!-- Settings popover -->
<N8nPopover
v-else-if="item.children && item.id === 'settings'"
key="settings"
side="right"
align="end"
:side-offset="12"
>
<template #content>
<div :class="$style.popover">
<template v-for="child in item.children" :key="child.id">
<component
:is="child.component"
v-if="isCustomMenuItem(child)"
v-bind="child.props"
/>
<N8nMenuItem v-else :item="child" @click="() => handleSelect(child.id)" />
</template>
<span :class="$style.divider" />
<N8nMenuItem
:data-test-id="'main-sidebar-log-out'"
:item="{ id: 'sign-out', label: 'Sign out', icon: 'door-open' }"
@click="onLogout"
/>
</div>
</template>
<template #trigger>
<N8nMenuItem
:data-test-id="`main-sidebar-${item.id}`"
:item="item"
:compact="isCollapsed"
@click="() => handleSelect(item.id)"
/>
</template>
</N8nPopover>
<!-- Items without children -->
<N8nMenuItem
v-else
:data-test-id="`main-sidebar-${item.id}`"
:item="item"
:compact="isCollapsed"
@click="() => handleSelect(item.id)"
/>
</template>
</div>
</div>
<BottomMenu
:items="visibleMenuItems"
:is-collapsed="isCollapsed"
@logout="onLogout"
@select="handleSelect"
/>
</div>
</N8nScrollArea>
<MainSidebarSourceControl :is-collapsed="isCollapsed" />
@@ -672,93 +375,15 @@ const onLogout = () => {
border-right: var(--border);
background-color: var(--menu--color--background, var(--color--background--light-2));
.header {
display: flex;
align-items: center;
padding: var(--spacing--2xs) var(--spacing--3xs);
margin-bottom: var(--spacing--2xs);
justify-content: space-between;
gap: var(--spacing--4xs);
img {
position: relative;
left: 1px;
height: 20px;
margin-right: auto;
}
}
&.sideMenuCollapsed {
width: $sidebar-width;
min-width: auto;
.header {
flex-direction: column;
border-bottom: var(--border);
}
}
}
.logo {
margin-right: auto;
}
.scrollArea {
height: 100%;
display: flex;
flex-direction: column;
}
.bottomMenu {
display: flex;
flex-direction: column;
margin-top: auto;
}
.sideMenuCollapsed .bottomMenu {
border-top: var(--border);
}
.bottomMenuItems {
padding: var(--spacing--3xs);
}
.popover {
padding: var(--spacing--4xs);
min-width: 260px;
border-radius: var(--radius);
background-color: var(--menu--color--background, var(--color--background--light-2));
}
.divider {
display: block;
width: 100%;
padding-top: var(--spacing--3xs);
border-bottom: var(--border);
margin-bottom: var(--spacing--3xs);
background-color: var(--color--border);
}
.popoverTitle {
display: block;
margin-bottom: var(--spacing--3xs);
padding-left: var(--spacing--3xs);
margin-top: var(--spacing--xs);
}
@media screen and (max-height: 470px) {
:global(#help) {
display: none;
}
}
.readOnlyEnvironmentIcon {
display: inline-block;
color: white;
background-color: var(--color--warning);
align-self: center;
padding: 2px;
border-radius: var(--radius--sm);
margin: 7px 12px 0 5px;
}
</style>
@@ -0,0 +1,143 @@
import { reactive } from 'vue';
import { fireEvent } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { type MockedStore, mockedStore } from '@/__tests__/utils';
import { defaultSettings } from '@/__tests__/defaults';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import MainSidebarHeader from './MainSidebarHeader.vue';
import { COMMAND_BAR_EXPERIMENT } from '../constants';
vi.mock('vue-router', () => ({
useRouter: () => ({
resolve: vi.fn(() => ({ meta: {} })),
}),
useRoute: () => reactive({}),
RouterLink: {
template: '<a><slot /></a>',
},
}));
vi.mock('@/app/stores/posthog.store', () => ({
usePostHog: () => ({
isVariantEnabled: vi.fn().mockImplementation((name) => name === COMMAND_BAR_EXPERIMENT.name),
}),
}));
let settingsStore: MockedStore<typeof useSettingsStore>;
let sourceControlStore: MockedStore<typeof useSourceControlStore>;
let pinia: ReturnType<typeof createTestingPinia>;
describe('MainSidebarHeader', () => {
beforeEach(() => {
pinia = createTestingPinia();
settingsStore = mockedStore(useSettingsStore);
sourceControlStore = mockedStore(useSourceControlStore);
settingsStore.settings = defaultSettings;
});
it('renders header without error', () => {
expect(() =>
createComponentRenderer(MainSidebarHeader, {
pinia,
props: { isCollapsed: false },
})(),
).not.toThrow();
});
it('renders collapsed header without error', () => {
expect(() =>
createComponentRenderer(MainSidebarHeader, {
pinia,
props: { isCollapsed: true },
})(),
).not.toThrow();
});
test.each([
[false, true, true],
[true, false, false],
[true, true, false],
[false, false, false],
])(
'should render readonly tooltip when is collapsed %s and the environment is readonly %s',
(isCollapsed, branchReadOnly, shouldRender) => {
sourceControlStore.preferences.branchReadOnly = branchReadOnly;
const { queryByTestId } = createComponentRenderer(MainSidebarHeader, {
pinia,
props: { isCollapsed },
})();
expect(queryByTestId('read-only-env-icon') !== null).toBe(shouldRender);
},
);
it('should render beta icon when not collapsed and isBeta is true', () => {
const { queryByTestId } = createComponentRenderer(MainSidebarHeader, {
pinia,
props: { isCollapsed: false, isBeta: true },
})();
expect(queryByTestId('beta-icon') !== null).toBe(true);
});
it('renders the logo link only when not collapsed', async () => {
const { container, rerender } = createComponentRenderer(MainSidebarHeader, {
pinia,
props: { isCollapsed: false, hideCreate: true },
})();
expect(container.querySelector('a')).toBeInTheDocument();
await rerender({ isCollapsed: true, hideCreate: true });
expect(container.querySelector('a')).not.toBeInTheDocument();
});
it('displays create button', () => {
const { queryByTestId } = createComponentRenderer(MainSidebarHeader, {
pinia,
props: { isCollapsed: false },
})();
expect(queryByTestId('universal-add')).toBeInTheDocument();
});
it('hides create button when hideCreate is true', () => {
const { queryByTestId } = createComponentRenderer(MainSidebarHeader, {
pinia,
props: { isCollapsed: false, hideCreate: true },
})();
expect(queryByTestId('universal-add')).not.toBeInTheDocument();
});
it('emits "collapse" when toggle sidebar is clicked', async () => {
const { getByRole, emitted } = createComponentRenderer(MainSidebarHeader, {
pinia,
props: { isCollapsed: false, hideCreate: true },
})();
await fireEvent.click(getByRole('button', { name: 'Toggle sidebar' }));
expect(emitted('collapse')).toHaveLength(1);
});
it('emits "openCommandBar" with the click event when command bar button is clicked', async () => {
const { getByRole, emitted } = createComponentRenderer(MainSidebarHeader, {
pinia,
props: { isCollapsed: false, hideCreate: true },
})();
await fireEvent.click(getByRole('button', { name: 'Open command palette' }));
const emits = emitted('openCommandBar');
expect(emits).toHaveLength(1);
const [[event]] = emits as Array<[Event]>;
expect(event).toBeInstanceOf(MouseEvent);
expect((event as MouseEvent).type).toBe('click');
});
});
@@ -0,0 +1,256 @@
<script lang="ts" setup>
import { onClickOutside, type VueInstance } from '@vueuse/core';
import { ref, type Ref } from 'vue';
import { I18nT } from 'vue-i18n';
import { RouterLink } from 'vue-router';
import {
N8nButton,
N8nLogo,
N8nTooltip,
N8nLink,
N8nIcon,
N8nIconButton,
N8nNavigationDropdown,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { VIEWS } from '@/app/constants';
import { useCommandBar } from '@/features/shared/commandBar/composables/useCommandBar';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import KeyboardShortcutTooltip from '@/app/components/KeyboardShortcutTooltip.vue';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useGlobalEntityCreation } from '@/app/composables/useGlobalEntityCreation';
import BetaTag from '@n8n/design-system/components/BetaTag/BetaTag.vue';
defineProps<{
isCollapsed: boolean;
isBeta?: boolean;
hideCreate?: boolean;
}>();
const emit = defineEmits<{
collapse: [];
openCommandBar: [event: MouseEvent];
}>();
const { isEnabled: isCommandBarEnabled } = useCommandBar();
const i18n = useI18n();
const sourceControlStore = useSourceControlStore();
const settingsStore = useSettingsStore();
const createBtn = ref<InstanceType<typeof N8nNavigationDropdown>>();
onClickOutside(createBtn as Ref<VueInstance>, () => {
createBtn.value?.close();
});
function toggleCollapse() {
emit('collapse');
}
function openCommandBar(event: MouseEvent) {
emit('openCommandBar', event);
}
const {
menu,
handleSelect: handleMenuSelect,
createProjectAppendSlotName,
createWorkflowsAppendSlotName,
createCredentialsAppendSlotName,
projectsLimitReachedMessage,
upgradeLabel,
hasPermissionToCreateProjects,
} = useGlobalEntityCreation();
</script>
<template>
<div
:class="{
[$style.header]: true,
[$style.collapsed]: isCollapsed,
}"
>
<RouterLink v-if="!isCollapsed" :to="{ name: VIEWS.HOMEPAGE }" :class="$style.logo">
<N8nLogo
size="small"
:collapsed="isCollapsed"
:release-channel="settingsStore.settings.releaseChannel"
>
<BetaTag v-if="isBeta" :class="$style.beta" data-test-id="beta-icon" />
<N8nTooltip
v-if="sourceControlStore.preferences.branchReadOnly && !isCollapsed"
placement="bottom"
>
<template #content>
<I18nT keypath="readOnlyEnv.tooltip" scope="global">
<template #link>
<N8nLink
to="https://docs.n8n.io/source-control-environments/setup/#step-4-connect-n8n-and-configure-your-instance"
size="small"
>
{{ i18n.baseText('readOnlyEnv.tooltip.link') }}
</N8nLink>
</template>
</I18nT>
</template>
<N8nIcon
data-test-id="read-only-env-icon"
icon="lock"
:class="$style.readOnlyEnvironmentIcon"
/>
</N8nTooltip>
</N8nLogo>
</RouterLink>
<N8nNavigationDropdown
v-if="!hideCreate"
ref="createBtn"
data-test-id="universal-add"
:menu="menu"
@select="handleMenuSelect"
>
<N8nIconButton
size="small"
type="highlight"
icon="plus"
icon-size="large"
aria-label="Add new item"
/>
<template #[createWorkflowsAppendSlotName]>
<N8nTooltip
v-if="sourceControlStore.preferences.branchReadOnly"
placement="right"
:content="i18n.baseText('readOnlyEnv.cantAdd.workflow')"
>
<N8nIcon :class="$style.iconButton" icon="lock" size="xsmall" />
</N8nTooltip>
</template>
<template #[createCredentialsAppendSlotName]>
<N8nTooltip
v-if="sourceControlStore.preferences.branchReadOnly"
placement="right"
:content="i18n.baseText('readOnlyEnv.cantAdd.credential')"
>
<N8nIcon :class="$style.iconButton" icon="lock" size="xsmall" />
</N8nTooltip>
</template>
<template #[createProjectAppendSlotName]="{ item }">
<N8nTooltip
v-if="sourceControlStore.preferences.branchReadOnly"
placement="right"
:content="i18n.baseText('readOnlyEnv.cantAdd.project')"
>
<N8nIcon :class="$style.iconButton" icon="lock" size="xsmall" />
</N8nTooltip>
<N8nTooltip
v-else-if="item.disabled"
placement="right"
:content="projectsLimitReachedMessage"
>
<N8nIcon
v-if="!hasPermissionToCreateProjects"
:class="$style.iconButton"
icon="lock"
size="xsmall"
/>
<N8nButton
v-else
:size="'mini'"
:class="$style.upgradeButton"
type="tertiary"
@click="handleMenuSelect(item.id)"
>
{{ upgradeLabel }}
</N8nButton>
</N8nTooltip>
</template>
</N8nNavigationDropdown>
<KeyboardShortcutTooltip
v-if="isCommandBarEnabled"
:placement="isCollapsed ? 'right' : 'bottom'"
:show-after="500"
:label="i18n.baseText('nodeView.openCommandBar')"
:shortcut="{ keys: ['k'], metaKey: true }"
>
<N8nIconButton
size="small"
type="highlight"
icon="search"
icon-size="large"
aria-label="Open command palette"
@click="openCommandBar"
/>
</KeyboardShortcutTooltip>
<KeyboardShortcutTooltip
:placement="isCollapsed ? 'right' : 'bottom'"
:label="
isCollapsed
? i18n.baseText('mainSidebar.state.expand')
: i18n.baseText('mainSidebar.state.collapse')
"
:show-after="500"
:shortcut="{ keys: ['['] }"
>
<N8nIconButton
id="toggle-sidebar-button"
size="small"
type="highlight"
icon="panel-left"
icon-size="large"
aria-label="Toggle sidebar"
@click="toggleCollapse"
/>
</KeyboardShortcutTooltip>
</div>
</template>
<style lang="scss" module>
.header {
display: flex;
align-items: center;
padding: var(--spacing--2xs) var(--spacing--3xs);
margin-bottom: var(--spacing--2xs);
justify-content: space-between;
gap: var(--spacing--4xs);
img {
position: relative;
left: 1px;
height: 20px;
margin-right: auto;
}
&.collapsed {
flex-direction: column;
border-bottom: var(--border);
}
}
.logo {
margin-right: auto;
}
.beta {
margin-top: var(--spacing--3xs);
margin-left: var(--spacing--3xs);
}
.readOnlyEnvironmentIcon {
display: inline-block;
color: white;
background-color: var(--color--warning);
align-self: center;
padding: 2px;
border-radius: var(--radius--sm);
margin: 7px 12px 0 5px;
}
.iconButton {
margin-left: auto;
margin-right: 5px;
}
.upgradeButton {
margin-left: auto;
}
</style>
@@ -31,7 +31,6 @@ export const WORKFLOW_EXTRACTION_NAME_MODAL_KEY = 'workflowExtractionName';
export const WHATS_NEW_MODAL_KEY = 'whatsNew';
export const WORKFLOW_DIFF_MODAL_KEY = 'workflowDiff';
export const PRE_BUILT_AGENTS_MODAL_KEY = 'preBuiltAgents';
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';
export const EXPERIMENT_TEMPLATES_DATA_QUALITY_KEY = 'templatesDataQuality';
@@ -16,7 +16,6 @@ import type { ChatAgentFilter } from '@/features/ai/chatHub/chat.types';
import { useMediaQuery } from '@vueuse/core';
import { AGENT_EDITOR_MODAL_KEY, MOBILE_MEDIA_QUERY } from '@/features/ai/chatHub/constants';
import ChatLayout from '@/features/ai/chatHub/components/ChatLayout.vue';
import ChatSidebarOpener from '@/features/ai/chatHub/components/ChatSidebarOpener.vue';
import SkeletonAgentCard from '@/features/ai/chatHub/components/SkeletonAgentCard.vue';
import { useI18n } from '@n8n/i18n';
@@ -134,7 +133,6 @@ watch(
</template>
</div>
</div>
<ChatSidebarOpener :class="$style.menuButton" />
</ChatLayout>
</template>
@@ -152,17 +150,6 @@ watch(
position: relative;
}
.menuButton {
position: absolute;
top: 0;
left: 0;
margin: var(--spacing--sm);
.isMobileDevice & {
margin: var(--spacing--2xs);
}
}
.header {
display: flex;
justify-content: space-between;
@@ -604,7 +604,6 @@ function onFilesDropped(files: File[]) {
:selected-model="selectedModel"
:credentials="credentialsByProvider"
:ready-to-show-model-selector="isNewSession || !!currentConversation"
:is-new-session="isNewSession"
@select-model="handleSelectModel"
@edit-custom-agent="handleEditAgent"
@create-custom-agent="openNewAgentCreator"
@@ -14,7 +14,6 @@ import { useMediaQuery } from '@vueuse/core';
import { MOBILE_MEDIA_QUERY } from '@/features/ai/chatHub/constants';
import { useRouter } from 'vue-router';
import ChatLayout from '@/features/ai/chatHub/components/ChatLayout.vue';
import ChatSidebarOpener from '@/features/ai/chatHub/components/ChatSidebarOpener.vue';
import SkeletonAgentCard from '@/features/ai/chatHub/components/SkeletonAgentCard.vue';
import { useI18n } from '@n8n/i18n';
@@ -96,7 +95,6 @@ watch(
/>
</div>
</div>
<ChatSidebarOpener :class="$style.menuButton" />
</ChatLayout>
</template>
@@ -16,8 +16,8 @@ import type {
ChatHubUpdateConversationRequest,
EnrichedStructuredChunk,
ChatHubLLMProvider,
ChatProviderSettingsDto,
} from '@n8n/api-types';
import type { ChatProviderSettingsDto } from '@n8n/api-types';
// Workflows stream data as newline separated JSON objects (jsonl)
const STREAM_SEPARATOR = '\n';
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia';
import { CHAT_STORE } from './constants';
import { CHAT_STORE, CHAT_SESSIONS_PAGE_SIZE } from './constants';
import { computed, ref } from 'vue';
import { v4 as uuidv4 } from 'uuid';
import { useI18n } from '@n8n/i18n';
@@ -322,7 +322,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
try {
const cursor = reset ? undefined : (sessions.value?.nextCursor ?? undefined);
const [response] = await Promise.all([
fetchSessionsApi(rootStore.restApiContext, 40, cursor),
fetchSessionsApi(rootStore.restApiContext, CHAT_SESSIONS_PAGE_SIZE, cursor),
new Promise((resolve) => setTimeout(resolve, options.minLoadingTime ?? 0)),
]);
@@ -147,11 +147,11 @@ watch(
watch(
[isOpened, isLoadingAgent, nameInputRef],
async ([opened, isLoading, name]) => {
([opened, isLoading, nameInput]) => {
if (opened && !isLoading) {
// autofocus attribute doesn't work in modal
// https://github.com/element-plus/element-plus/issues/15250
name?.focus();
nameInput?.focus();
}
},
{ immediate: true, flush: 'post' },
@@ -308,8 +308,8 @@ function onSelectTools() {
:button-tooltip="i18n.baseText('chatHub.agent.editor.iconPicker.button.tooltip')"
/>
<N8nInput
ref="nameInput"
id="agent-name"
ref="nameInput"
v-model="name"
:placeholder="i18n.baseText('chatHub.agent.editor.name.placeholder')"
:maxlength="128"
@@ -28,30 +28,42 @@ const i18n = useI18n();
<template #content>{{
agent?.name || i18n.baseText('chatHub.agent.unavailableAgent')
}}</template>
<span v-if="agent?.icon?.type === 'emoji'" :class="[$style.emoji, $style[size]]">
{{ agent.icon.value }}
</span>
<N8nIcon
v-else-if="!agent || !isLlmProviderModel(agent.model)"
:color="size === 'sm' ? 'text-base' : 'text-light'"
:class="[$style.n8nIcon, $style[size]]"
:icon="
(agent?.icon?.value ??
(agent?.model.provider === 'n8n' ? workflowAgentDefaultIcon : personalAgentDefaultIcon)
.value) as IconName
"
:size="size === 'lg' ? 'xxlarge' : size === 'sm' ? 'large' : 'xlarge'"
/>
<CredentialIcon
v-else
:class="[$style.credentialsIcon, { [$style.isReady]: isCredentialsIconReady }]"
:credential-type-name="PROVIDER_CREDENTIAL_TYPE_MAP[agent.model.provider]"
:size="size === 'sm' ? 16 : size === 'lg' ? 40 : 20"
/>
<div :class="$style.container">
<span v-if="agent?.icon?.type === 'emoji'" :class="[$style.emoji, $style[size]]">
{{ agent.icon.value }}
</span>
<N8nIcon
v-else-if="!agent || !isLlmProviderModel(agent.model)"
:color="size === 'sm' ? 'text-base' : 'text-light'"
:class="[$style.n8nIcon, $style[size]]"
:icon="
(agent?.icon?.value ??
(agent?.model.provider === 'n8n' ? workflowAgentDefaultIcon : personalAgentDefaultIcon)
.value) as IconName
"
:size="size === 'lg' ? 'xxlarge' : size === 'sm' ? 'large' : 'xlarge'"
/>
<CredentialIcon
v-else
:class="[$style.credentialsIcon, { [$style.isReady]: isCredentialsIconReady }]"
:credential-type-name="PROVIDER_CREDENTIAL_TYPE_MAP[agent.model.provider]"
:size="size === 'sm' ? 16 : size === 'lg' ? 40 : 20"
/>
</div>
</N8nTooltip>
</template>
<style lang="scss" module>
.container {
position: relative;
width: var(--spacing--lg);
height: var(--spacing--lg);
min-width: var(--spacing--lg);
display: flex;
align-items: center;
justify-content: center;
}
.n8nIcon {
outline: none;
@@ -1,10 +1,7 @@
<script setup lang="ts">
import { hasPermission } from '@/app/utils/rbac/permissions';
import type { CredentialsMap } from '@/features/ai/chatHub/chat.types';
import ChatSidebarOpener from '@/features/ai/chatHub/components/ChatSidebarOpener.vue';
import ModelSelector from '@/features/ai/chatHub/components/ModelSelector.vue';
import { useChatHubSidebarState } from '@/features/ai/chatHub/composables/useChatHubSidebarState';
import { CHAT_VIEW } from '@/features/ai/chatHub/constants';
import type {
ChatHubConversationModel,
ChatHubLLMProvider,
@@ -12,14 +9,12 @@ import type {
ChatModelDto,
ChatSessionId,
} from '@n8n/api-types';
import { N8nButton, N8nIconButton } from '@n8n/design-system';
import { N8nButton } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed, useTemplateRef, watch, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useChatStore } from '../chat.store';
const { isNewSession, selectedModel, credentials, readyToShowModelSelector } = defineProps<{
isNewSession: boolean;
const { selectedModel, credentials, readyToShowModelSelector } = defineProps<{
selectedModel: ChatModelDto | null;
credentials: CredentialsMap | null;
readyToShowModelSelector: boolean;
@@ -34,8 +29,6 @@ const emit = defineEmits<{
openWorkflow: [workflowId: string];
}>();
const sidebar = useChatHubSidebarState();
const router = useRouter();
const modelSelectorRef = useTemplateRef('modelSelectorRef');
const i18n = useI18n();
const chatStore = useChatStore();
@@ -59,12 +52,6 @@ function onModelChange(selection: ChatHubConversationModel) {
emit('selectModel', selection);
}
function onNewChat() {
sidebar.toggleOpen(false);
void router.push({ name: CHAT_VIEW, force: true });
}
// Update agents when credentials are updated
watch(
() => credentials,
@@ -91,18 +78,6 @@ defineExpose({
<template>
<div :class="$style.component">
<div :class="$style.grow">
<ChatSidebarOpener :class="$style.menuButton" />
<N8nIconButton
v-if="!sidebar.isStatic.value"
:class="$style.menuButton"
type="secondary"
icon="square-pen"
text
icon-size="large"
:aria-label="i18n.baseText('chatHub.chat.header.button.newChat')"
:disabled="isNewSession"
@click="onNewChat"
/>
<ModelSelector
v-if="readyToShowModelSelector"
ref="modelSelectorRef"
@@ -10,10 +10,11 @@ import type { ActionDropdownItem } from '@n8n/design-system/types';
import { useI18n } from '@n8n/i18n';
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
const { session, isRenaming, active } = defineProps<{
const { session, isRenaming, active, compact } = defineProps<{
session: ChatHubSessionDto;
isRenaming: boolean;
active: boolean;
compact: boolean;
}>();
const emit = defineEmits<{
@@ -103,15 +104,17 @@ watch(
<ChatSidebarLink
:to="{ name: CHAT_CONVERSATION_VIEW, params: { id: session.id } }"
:active="active"
:compact="compact"
:menu-items="dropdownItems"
:label="session.title"
:label="session.agentName"
:title="session.title"
@action-select="handleActionSelect"
>
<template v-if="isRenaming" #default>
<N8nInput
size="small"
ref="input"
v-model="editedLabel"
size="large"
@blur="handleBlur"
@keydown="handleKeyDown"
/>
@@ -1,76 +1,114 @@
<script lang="ts" setup>
import ModalDrawer from '@/app/components/ModalDrawer.vue';
import { CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY } from '@/app/constants';
import { nextTick, computed } from 'vue';
import { useI18n } from '@n8n/i18n';
import { useRouter } from 'vue-router';
import { VIEWS } from '@/app/constants';
import { type IMenuItem, N8nResizeWrapper } from '@n8n/design-system';
import { useSettingsItems } from '@/app/composables/useSettingsItems';
import { useKeybindings } from '@/app/composables/useKeybindings';
import { useSidebarLayout } from '@/app/composables/useSidebarLayout';
import { N8nScrollArea } from '@n8n/design-system';
import BottomMenu from '@/app/components/BottomMenu.vue';
import MainSidebarHeader from '@/app/components/MainSidebarHeader.vue';
import ChatSidebarContent from '@/features/ai/chatHub/components/ChatSidebarContent.vue';
import { useChatHubSidebarState } from '@/features/ai/chatHub/composables/useChatHubSidebarState';
import { MOBILE_MEDIA_QUERY } from '@/features/ai/chatHub/constants';
import { useUIStore } from '@/app/stores/ui.store';
import { useEventListener, useMediaQuery } from '@vueuse/core';
import { onBeforeUnmount, watch } from 'vue';
import { useRoute } from 'vue-router';
const uiStore = useUIStore();
const isMobileDevice = useMediaQuery(MOBILE_MEDIA_QUERY);
const route = useRoute();
const sidebar = useChatHubSidebarState();
const i18n = useI18n();
const router = useRouter();
const EDGE_TRIGGER_DISTANCE = 10; // pixels from left edge to trigger sidebar
const { isCollapsed, sidebarWidth, onResizeStart, onResize, onResizeEnd, toggleCollapse } =
useSidebarLayout();
watch(
() => route.fullPath,
() => uiStore.closeModal(CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY),
function openCommandBar(event: MouseEvent) {
event.stopPropagation();
void nextTick(() => {
const keyboardEvent = new KeyboardEvent('keydown', {
key: 'k',
code: 'KeyK',
metaKey: true,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(keyboardEvent);
});
}
const { settingsItems } = useSettingsItems();
const mainMenuItems = computed<IMenuItem[]>(() => [
{
id: 'settings',
label: i18n.baseText('mainSidebar.settings'),
icon: 'settings',
available: true,
children: settingsItems.value,
},
]);
const visibleMenuItems = computed<IMenuItem[]>(() =>
mainMenuItems.value.filter((item) => item.available !== false),
);
useEventListener(window, 'mousemove', (event: MouseEvent) => {
if (sidebar.isCollapsed.value && event.clientX <= EDGE_TRIGGER_DISTANCE) {
sidebar.toggleOpen(true);
}
useKeybindings({
['bracketleft']: () => toggleCollapse(),
});
onBeforeUnmount(() => {
uiStore.closeModal(CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY);
});
const onLogout = () => {
void router.push({ name: VIEWS.SIGNOUT });
};
</script>
<template>
<ChatSidebarContent
v-if="sidebar.isStatic.value"
:class="$style.static"
:is-mobile-device="isMobileDevice"
/>
<ModalDrawer
v-else
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"
<N8nResizeWrapper
id="side-menu"
:class="{
[$style.sideMenu]: true,
[$style.sideMenuCollapsed]: isCollapsed,
}"
:width="sidebarWidth"
:style="{ width: `${sidebarWidth}px` }"
:supported-directions="['right']"
:min-width="200"
:max-width="500"
:grid-size="8"
@resizestart="onResizeStart"
@resize="onResize"
@resizeend="onResizeEnd"
>
<template #content>
<ChatSidebarContent :class="$style.inDrawer" :is-mobile-device="isMobileDevice" />
</template>
</ModalDrawer>
<MainSidebarHeader
hide-create
is-beta
:is-collapsed="isCollapsed"
@collapse="toggleCollapse"
@open-command-bar="openCommandBar"
/>
<N8nScrollArea as-child>
<div :class="$style.scrollArea">
<ChatSidebarContent :is-collapsed="isCollapsed" />
<BottomMenu :items="visibleMenuItems" :is-collapsed="isCollapsed" @logout="onLogout" />
</div>
</N8nScrollArea>
</N8nResizeWrapper>
</template>
<style lang="scss" module>
.drawer {
& :global(.el-drawer__header) {
padding: 0;
margin: 0;
.sideMenu {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
border-right: var(--border);
background-color: var(--menu--color--background, var(--color--background--light-2));
&.sideMenuCollapsed {
width: $sidebar-width;
min-width: auto;
}
}
.inDrawer,
.static {
.scrollArea {
height: 100%;
background-color: var(--color--background--light-2);
}
.static {
width: 240px;
position: relative;
overflow: auto;
margin-right: calc(-1 * var(--spacing--4xs));
display: flex;
flex-direction: column;
}
</style>
@@ -1,40 +1,32 @@
<script setup lang="ts">
import MainSidebarUserArea from '@/app/components/MainSidebarUserArea.vue';
import { useMessage } from '@/app/composables/useMessage';
import { useToast } from '@/app/composables/useToast';
import { MODAL_CONFIRM, VIEWS } from '@/app/constants';
import { MODAL_CONFIRM } from '@/app/constants';
import { useChatStore } from '@/features/ai/chatHub/chat.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { groupConversationsByDate } from '@/features/ai/chatHub/chat.utils';
import ChatSidebarLink from '@/features/ai/chatHub/components/ChatSidebarLink.vue';
import { useChatHubSidebarState } from '@/features/ai/chatHub/composables/useChatHubSidebarState';
import {
CHAT_VIEW,
CHAT_WORKFLOW_AGENTS_VIEW,
CHAT_PERSONAL_AGENTS_VIEW,
} from '@/features/ai/chatHub/constants';
import { useSettingsStore } from '@/app/stores/settings.store';
import { N8nIconButton, N8nScrollArea, N8nText } from '@n8n/design-system';
import Logo from '@n8n/design-system/components/N8nLogo';
import BetaTag from '@n8n/design-system/components/BetaTag/BetaTag.vue';
import { type IMenuItem, N8nMenuItem, N8nScrollArea, N8nText } from '@n8n/design-system';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useIntersectionObserver } from '@vueuse/core';
import ChatSessionMenuItem from './ChatSessionMenuItem.vue';
import SkeletonMenuItem from './SkeletonMenuItem.vue';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { type ChatHubSessionDto } from '@n8n/api-types';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useI18n } from '@n8n/i18n';
import KeyboardShortcutTooltip from '@/app/components/KeyboardShortcutTooltip.vue';
defineProps<{ isMobileDevice: boolean }>();
defineProps<{ isCollapsed: boolean }>();
const route = useRoute();
const router = useRouter();
const chatStore = useChatStore();
const toast = useToast();
const message = useMessage();
const sidebar = useChatHubSidebarState();
const settingsStore = useSettingsStore();
const credentialsStore = useCredentialsStore();
const telemetry = useTelemetry();
const readyToShowSessions = computed(
@@ -43,7 +35,6 @@ const readyToShowSessions = computed(
const i18n = useI18n();
const renamingSessionId = ref<string>();
const loadMoreTrigger = ref<HTMLElement | null>(null);
const currentSessionId = computed(() =>
typeof route.params.id === 'string' ? route.params.id : undefined,
@@ -63,6 +54,40 @@ const groupedConversations = computed(() =>
),
);
const newChat = computed<IMenuItem>(() => ({
id: 'new-chat',
label: i18n.baseText('chatHub.sidebar.link.newChat'),
icon: 'plus',
route: {
to: {
name: CHAT_VIEW,
force: true, // to focus input again when the user is already in CHAT_VIEW
},
},
}));
const personalAgents = computed<IMenuItem>(() => ({
id: 'personal-agents',
label: i18n.baseText('chatHub.sidebar.link.personalAgents'),
icon: 'message-square',
route: {
to: {
name: CHAT_PERSONAL_AGENTS_VIEW,
},
},
}));
const workflowAgents = computed<IMenuItem>(() => ({
id: 'workflow-agents',
label: i18n.baseText('chatHub.sidebar.link.workflowAgents'),
icon: 'robot',
route: {
to: {
name: CHAT_WORKFLOW_AGENTS_VIEW,
},
},
}));
function handleStartRename(sessionId: string) {
renamingSessionId.value = sessionId;
}
@@ -108,19 +133,8 @@ async function handleDeleteSession(sessionId: string) {
function handleNewChatClick() {
telemetry.track('User clicked new chat button', {});
sidebar.toggleOpen(false);
}
useIntersectionObserver(
loadMoreTrigger,
([{ isIntersecting }]) => {
if (isIntersecting) {
void chatStore.fetchMoreSessions({ minLoadingTime: 250 });
}
},
{ threshold: 0.1 },
);
onMounted(() => {
if (!chatStore.sessionsReady) {
void chatStore.fetchSessions(true, { minLoadingTime: 250 });
@@ -129,74 +143,62 @@ onMounted(() => {
</script>
<template>
<div :class="[$style.component, { [$style.isMobileDevice]: isMobileDevice }]">
<div :class="$style.header">
<RouterLink :to="{ name: VIEWS.HOMEPAGE }">
<div :class="$style.logoContainer">
<Logo
:class="$style.logo"
size="small"
:collapsed="false"
:release-channel="settingsStore.settings.releaseChannel"
/>
<BetaTag />
</div>
</RouterLink>
<N8nIconButton
v-if="sidebar.canBeStatic.value"
:title="i18n.baseText('chatHub.sidebar.button.toggle')"
icon="panel-left"
type="tertiary"
text
size="small"
icon-size="large"
@click="sidebar.toggleStatic()"
/>
</div>
<div :class="$style.links">
<ChatSidebarLink
:to="{
name: CHAT_VIEW,
force: true, // to focus input again when the user is already in CHAT_VIEW
}"
<div :class="$style.component">
<div
:class="{
[$style.links]: true,
[$style.collapsed]: isCollapsed,
}"
>
<KeyboardShortcutTooltip
placement="right"
:label="i18n.baseText('chatHub.sidebar.link.newChat')"
icon="square-pen"
:active="route.name === CHAT_VIEW"
@click="handleNewChatClick"
/>
<ChatSidebarLink
:to="{ name: CHAT_PERSONAL_AGENTS_VIEW }"
:label="i18n.baseText('chatHub.sidebar.link.personalAgents')"
icon="message-square"
:show-after="500"
:shortcut="{ keys: ['o'], metaKey: true, shiftKey: true }"
>
<N8nMenuItem
:item="newChat"
:compact="isCollapsed"
:active="route.name === CHAT_VIEW"
@click="handleNewChatClick"
/>
</KeyboardShortcutTooltip>
<N8nMenuItem
:item="personalAgents"
:compact="isCollapsed"
:active="route.name === CHAT_PERSONAL_AGENTS_VIEW"
@click="sidebar.toggleOpen(false)"
/>
<ChatSidebarLink
:to="{ name: CHAT_WORKFLOW_AGENTS_VIEW }"
:label="i18n.baseText('chatHub.sidebar.link.workflowAgents')"
icon="robot"
<N8nMenuItem
:item="workflowAgents"
:compact="isCollapsed"
:active="route.name === CHAT_WORKFLOW_AGENTS_VIEW"
@click="sidebar.toggleOpen(false)"
/>
</div>
<N8nScrollArea as-child type="scroll">
<div :class="$style.historySections">
<div :class="[$style.historySections, { [$style.collapsed]: isCollapsed }]">
<div v-if="!readyToShowSessions" :class="$style.group">
<SkeletonMenuItem v-for="i in 10" :key="`loading-${i}`" />
</div>
<div
v-else
v-for="(group, index) in groupedConversations"
v-else
:key="group.group"
:class="$style.group"
>
<N8nText :class="$style.groupHeader" size="small" bold color="text-light">
<N8nText
v-if="!isCollapsed"
:class="$style.groupHeader"
size="small"
bold
color="text-light"
>
{{ group.group }}
</N8nText>
<ChatSessionMenuItem
v-for="session in group.sessions"
:key="session.id"
:session="session"
:compact="isCollapsed"
:active="currentSessionId === session.id"
:is-renaming="renamingSessionId === session.id"
@start-rename="handleStartRename"
@@ -204,15 +206,27 @@ onMounted(() => {
@confirm-rename="handleConfirmRename"
@delete="handleDeleteSession"
/>
<N8nMenuItem
v-if="
index === groupedConversations.length - 1 &&
chatStore.sessions.hasMore &&
!chatStore.sessionsLoading
"
:item="{
id: 'load-more-sessions',
label: i18n.baseText('chatHub.sidebar.loadMoreSessions'),
icon: 'circle-ellipsis',
}"
:compact="isCollapsed"
@click="() => chatStore.fetchMoreSessions({ minLoadingTime: 250 })"
/>
<template v-if="index === groupedConversations.length - 1 && chatStore.sessionsLoading">
<SkeletonMenuItem v-for="i in 10" :key="i" />
</template>
</div>
<div ref="loadMoreTrigger" :class="$style.loadMoreTrigger"></div>
</div>
</N8nScrollArea>
<MainSidebarUserArea :fully-expanded="true" :is-collapsed="false" />
</div>
</template>
@@ -227,11 +241,6 @@ onMounted(() => {
display: flex;
flex-direction: column;
align-items: stretch;
padding-block: var(--spacing--4xs);
&.isMobileDevice {
padding-block: 0;
}
}
.header {
@@ -253,36 +262,35 @@ onMounted(() => {
.links {
display: flex;
flex-direction: column;
padding: 0 var(--spacing--xs) var(--spacing--sm) var(--spacing--xs);
padding: 0 var(--spacing--3xs) var(--spacing--2xs);
gap: 1px;
&.collapsed {
border-bottom: var(--border);
}
}
.historySections {
display: flex;
flex-direction: column;
padding: 0 var(--spacing--xs) var(--spacing--sm) var(--spacing--xs);
gap: var(--spacing--xs);
padding: var(--spacing--2xs) var(--spacing--3xs) var(--spacing--2xs);
gap: var(--spacing--sm);
.isMobileDevice & {
gap: var(--spacing--sm);
&.collapsed {
gap: 0;
}
}
.group {
display: flex;
flex-direction: column;
gap: 1px;
gap: 2px;
}
.groupHeader {
padding: 0 var(--spacing--4xs) var(--spacing--3xs) var(--spacing--4xs);
}
.loadMoreTrigger {
height: 1px;
width: 100%;
}
.loading,
.empty {
padding: var(--spacing--xs);
@@ -8,14 +8,18 @@ const {
active = false,
to,
label,
title,
menuItems = [],
icon,
compact,
} = defineProps<{
active?: boolean;
to: RouteLocationRaw;
label: string;
title: string;
menuItems?: Array<ActionDropdownItem<T>>;
icon?: IconName;
compact?: boolean;
}>();
const emit = defineEmits<{
@@ -35,17 +39,20 @@ defineSlots<{
<template v-else>
<RouterLink
:to="to"
:class="$style.menuItemLink"
:title="label"
:class="[$style.menuItemLink, { [$style.compact]: compact }]"
:title="title"
@click="emit('click', $event)"
>
<slot name="icon">
<N8nIcon v-if="icon" size="large" :icon="icon" />
<N8nIcon v-if="icon" size="large" :icon="icon" :class="$style.menuItemIcon" />
</slot>
<N8nText :class="$style.label">{{ label }}</N8nText>
<div v-if="!compact" :class="$style.textContainer">
<N8nText :class="$style.label" size="small" color="text-light">{{ label }}</N8nText>
<N8nText :class="$style.title" size="medium" color="text-dark">{{ title }}</N8nText>
</div>
</RouterLink>
<N8nActionDropdown
v-if="menuItems.length > 0"
v-if="!compact && menuItems.length > 0"
:items="menuItems"
:class="$style.actionDropdown"
placement="bottom-start"
@@ -76,15 +83,15 @@ defineSlots<{
&:has([aria-expanded='true']),
&.active,
&:hover {
background-color: var(--color--foreground);
background-color: var(--color--background--light-1);
}
}
.menuItemLink {
display: flex;
align-items: center;
padding: var(--spacing--3xs);
gap: var(--spacing--3xs);
padding: var(--spacing--4xs);
gap: var(--spacing--4xs);
cursor: pointer;
color: var(--color--text);
min-width: 0;
@@ -92,17 +99,36 @@ defineSlots<{
text-decoration: none;
outline: none;
&.compact {
margin-left: -1px;
}
&:active {
color: var(--color--text--shade-1);
}
}
.textContainer {
display: flex;
flex-direction: column;
min-width: 0;
}
.label {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex: 1;
line-height: var(--font-size--lg);
line-height: var(--line-height--xl);
min-width: 0;
}
.title {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex: 1;
line-height: 20px;
min-width: 0;
}
@@ -1,20 +0,0 @@
<script setup lang="ts">
import { useChatHubSidebarState } from '@/features/ai/chatHub/composables/useChatHubSidebarState';
import { N8nIconButton } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
const sidebar = useChatHubSidebarState();
const i18n = useI18n();
</script>
<template>
<N8nIconButton
v-if="!sidebar.isStatic.value"
type="secondary"
:icon="sidebar.canBeStatic.value ? 'panel-left' : 'menu'"
text
icon-size="large"
:aria-label="i18n.baseText('chatHub.sidebar.button.toggle')"
@click="sidebar.toggleStatic(true)"
/>
</template>
@@ -1,50 +0,0 @@
import {
CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY,
LOCAL_STORAGE_CHAT_HUB_STATIC_SIDEBAR,
} from '@/app/constants';
import { useUIStore } from '@/app/stores/ui.store';
import { useLocalStorage, useMediaQuery } from '@vueuse/core';
import { computed } from 'vue';
import { MOBILE_MEDIA_QUERY } from '../constants';
import { useUsersStore } from '@/features/settings/users/users.store';
export function useChatHubSidebarState() {
const isMobileDevice = useMediaQuery(MOBILE_MEDIA_QUERY);
const uiStore = useUIStore();
const usersStore = useUsersStore();
const isStatic = useLocalStorage(
LOCAL_STORAGE_CHAT_HUB_STATIC_SIDEBAR(usersStore.currentUserId ?? 'anonymous'),
!isMobileDevice.value,
{ writeDefaults: false },
);
const canBeStatic = computed(() => !isMobileDevice.value);
function toggleOpen(value?: boolean) {
const isOpen = !!uiStore.isModalActiveById[CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY];
const newValue = value ?? !isOpen;
if (newValue) {
uiStore.openModal(CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY);
} else {
uiStore.closeModal(CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY);
}
}
function toggleStatic(value?: boolean) {
const newValue = value ?? !isStatic.value;
isStatic.value = newValue;
toggleOpen(newValue);
}
return {
canBeStatic,
isStatic: computed(() => canBeStatic.value && isStatic.value),
isCollapsed: computed(
() =>
!isStatic.value && uiStore.isModalActiveById[CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY] !== true,
),
toggleOpen,
toggleStatic,
};
}
@@ -8,6 +8,7 @@ export const CHAT_PERSONAL_AGENTS_VIEW = 'chat-personal-agents';
export const CHAT_SETTINGS_VIEW = 'chat-settings';
export const CHAT_STORE = 'chatStore';
export const CHAT_SESSIONS_PAGE_SIZE = 18;
export const providerDisplayNames: Record<ChatHubProvider, string> = {
openai: 'OpenAI',
@@ -95,6 +95,7 @@ const chat = computed<IMenuItem>(() => ({
label: locale.baseText('projects.menu.chat'),
position: 'bottom',
route: { to: { name: CHAT_VIEW } },
beta: true,
}));
async function onSourceControlPull() {
@@ -212,7 +212,7 @@ export function useChatHubCommands(options: {
icon: {
component: N8nIcon,
props: {
icon: 'square-pen',
icon: 'plus',
},
},
keywords: [
@@ -230,7 +230,7 @@ export function useChatHubCommands(options: {
icon: {
component: N8nIcon,
props: {
icon: 'robot',
icon: 'message-square-plus',
},
},
keywords: [
@@ -249,7 +249,7 @@ export function useChatHubCommands(options: {
icon: {
component: N8nIcon,
props: {
icon: 'layers',
icon: 'message-square',
color: 'text-light',
},
},