mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat(editor): Update Chat sidebar to match v2 styles (#23267)
This commit is contained in:
@@ -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"
|
||||
|
||||
+32
-20
@@ -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;
|
||||
|
||||
|
||||
+2
-27
@@ -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"
|
||||
|
||||
+6
-3
@@ -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>
|
||||
|
||||
+99
-91
@@ -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>
|
||||
-50
@@ -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',
|
||||
|
||||
+1
@@ -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() {
|
||||
|
||||
+3
-3
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user