mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
fix(editor): UI tweaks for instance AI components (#28155)
This commit is contained in:
parent
5e60272632
commit
aa6c322059
9 changed files with 48 additions and 143 deletions
|
|
@ -337,7 +337,7 @@ useKeybindings({
|
|||
[$style.sideMenuCollapsed]: isCollapsed,
|
||||
}"
|
||||
:width="sidebarWidth"
|
||||
:style="{ width: `${sidebarWidth}px` }"
|
||||
:style="isCollapsed ? {} : { width: `${sidebarWidth}px` }"
|
||||
:supported-directions="['right']"
|
||||
:min-width="200"
|
||||
:max-width="500"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useSidebarLayout } from './useSidebarLayout';
|
||||
|
||||
// Mock UI Store
|
||||
const mockUIStore = {
|
||||
sidebarMenuCollapsed: false as boolean | null,
|
||||
sidebarWidth: 200,
|
||||
toggleSidebarMenuCollapse: vi.fn(),
|
||||
};
|
||||
|
||||
|
|
@ -12,47 +12,28 @@ vi.mock('../stores/ui.store', () => ({
|
|||
useUIStore: () => mockUIStore,
|
||||
}));
|
||||
|
||||
// Mock useLocalStorage
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useLocalStorage: vi.fn((_key: string, defaultValue: number) => ref(defaultValue)),
|
||||
}));
|
||||
|
||||
// Mock constants
|
||||
vi.mock('../constants', () => ({
|
||||
LOCAL_STORAGE_SIDEBAR_WIDTH: 'sidebarWidth',
|
||||
}));
|
||||
|
||||
describe('useSidebarLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset UI store state
|
||||
mockUIStore.sidebarMenuCollapsed = false;
|
||||
mockUIStore.sidebarWidth = 200;
|
||||
});
|
||||
|
||||
describe('initial setup', () => {
|
||||
it('should initialize with correct default width when not collapsed', () => {
|
||||
mockUIStore.sidebarMenuCollapsed = false;
|
||||
it('should return sidebarWidth from store', () => {
|
||||
mockUIStore.sidebarWidth = 350;
|
||||
|
||||
const { sidebarWidth } = useSidebarLayout();
|
||||
|
||||
expect(sidebarWidth.value).toBe(300);
|
||||
});
|
||||
|
||||
it('should initialize with correct default width when collapsed', () => {
|
||||
mockUIStore.sidebarMenuCollapsed = true;
|
||||
|
||||
const { sidebarWidth } = useSidebarLayout();
|
||||
|
||||
expect(sidebarWidth.value).toBe(42);
|
||||
expect(sidebarWidth.value).toBe(350);
|
||||
});
|
||||
|
||||
it('should default to expanded (not collapsed) when sidebarMenuCollapsed is null', () => {
|
||||
mockUIStore.sidebarMenuCollapsed = null;
|
||||
|
||||
const { isCollapsed, sidebarWidth } = useSidebarLayout();
|
||||
const { isCollapsed } = useSidebarLayout();
|
||||
|
||||
expect(isCollapsed.value).toBe(false);
|
||||
expect(sidebarWidth.value).toBe(300);
|
||||
});
|
||||
|
||||
it('should return computed isCollapsed from store', () => {
|
||||
|
|
@ -78,24 +59,6 @@ describe('useSidebarLayout', () => {
|
|||
|
||||
expect(mockUIStore.toggleSidebarMenuCollapse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should set width to 200 when expanding (not collapsed after toggle)', () => {
|
||||
mockUIStore.sidebarMenuCollapsed = false; // Will be true after toggle
|
||||
const { toggleCollapse, sidebarWidth } = useSidebarLayout();
|
||||
|
||||
toggleCollapse();
|
||||
|
||||
expect(sidebarWidth.value).toBe(200);
|
||||
});
|
||||
|
||||
it('should set width to 42 when collapsing (collapsed after toggle)', () => {
|
||||
mockUIStore.sidebarMenuCollapsed = true; // Will be false after toggle
|
||||
const { toggleCollapse, sidebarWidth } = useSidebarLayout();
|
||||
|
||||
toggleCollapse();
|
||||
|
||||
expect(sidebarWidth.value).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resize state management', () => {
|
||||
|
|
@ -110,11 +73,9 @@ describe('useSidebarLayout', () => {
|
|||
it('should set isResizing to false when resize ends', () => {
|
||||
const { onResizeStart, onResizeEnd, isResizing } = useSidebarLayout();
|
||||
|
||||
// Start resize first
|
||||
onResizeStart();
|
||||
expect(isResizing.value).toBe(true);
|
||||
|
||||
// End resize
|
||||
onResizeEnd();
|
||||
expect(isResizing.value).toBe(false);
|
||||
});
|
||||
|
|
@ -132,12 +93,12 @@ describe('useSidebarLayout', () => {
|
|||
|
||||
it('should not resize when collapsed and dragging right below threshold', () => {
|
||||
mockUIStore.sidebarMenuCollapsed = true;
|
||||
mockUIStore.sidebarWidth = 42;
|
||||
const { onResize, sidebarWidth } = useSidebarLayout();
|
||||
const originalWidth = sidebarWidth.value;
|
||||
|
||||
onResize({ width: 250, x: 80 }); // x < 100
|
||||
|
||||
expect(sidebarWidth.value).toBe(originalWidth);
|
||||
expect(sidebarWidth.value).toBe(42);
|
||||
expect(mockUIStore.toggleSidebarMenuCollapse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -161,12 +122,12 @@ describe('useSidebarLayout', () => {
|
|||
|
||||
it('should not update width when collapsed', () => {
|
||||
mockUIStore.sidebarMenuCollapsed = true;
|
||||
mockUIStore.sidebarWidth = 42;
|
||||
const { onResize, sidebarWidth } = useSidebarLayout();
|
||||
const originalWidth = sidebarWidth.value;
|
||||
|
||||
onResize({ width: 350, x: 80 }); // Below threshold, should not expand
|
||||
onResize({ width: 350, x: 80 }); // Below threshold
|
||||
|
||||
expect(sidebarWidth.value).toBe(originalWidth);
|
||||
expect(sidebarWidth.value).toBe(42);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,20 +1,13 @@
|
|||
import { computed, ref } from 'vue';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { computed, ref, toRef } from 'vue';
|
||||
import { useUIStore } from '../stores/ui.store';
|
||||
import { LOCAL_STORAGE_SIDEBAR_WIDTH } from '../constants';
|
||||
|
||||
export function useSidebarLayout() {
|
||||
const uiStore = useUIStore();
|
||||
const isCollapsed = computed(() => uiStore.sidebarMenuCollapsed ?? false);
|
||||
const sidebarWidth = useLocalStorage(LOCAL_STORAGE_SIDEBAR_WIDTH, isCollapsed.value ? 42 : 300);
|
||||
const sidebarWidth = toRef(uiStore, 'sidebarWidth');
|
||||
|
||||
const toggleCollapse = () => {
|
||||
uiStore.toggleSidebarMenuCollapse();
|
||||
if (!isCollapsed.value) {
|
||||
sidebarWidth.value = 200;
|
||||
} else {
|
||||
sidebarWidth.value = 42;
|
||||
}
|
||||
};
|
||||
|
||||
const isResizing = ref(false);
|
||||
|
|
|
|||
|
|
@ -1,21 +1,37 @@
|
|||
<script lang="ts" setup>
|
||||
import { onMounted, onBeforeUnmount } from 'vue';
|
||||
import BaseLayout from './BaseLayout.vue';
|
||||
import AppSidebar from '@/app/components/app/AppSidebar.vue';
|
||||
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
|
||||
import { useSidebarLayout } from '@/app/composables/useSidebarLayout';
|
||||
|
||||
const pushConnectionStore = usePushConnectionStore();
|
||||
const { isCollapsed, toggleCollapse } = useSidebarLayout();
|
||||
|
||||
// Auto-collapse sidebar when entering instance-ai, restore on exit
|
||||
let wasCollapsed = isCollapsed.value;
|
||||
|
||||
onMounted(() => {
|
||||
pushConnectionStore.pushConnect();
|
||||
wasCollapsed = isCollapsed.value;
|
||||
if (!wasCollapsed) {
|
||||
toggleCollapse();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
pushConnectionStore.pushDisconnect();
|
||||
if (isCollapsed.value !== wasCollapsed) {
|
||||
toggleCollapse();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseLayout>
|
||||
<template #sidebar>
|
||||
<AppSidebar />
|
||||
</template>
|
||||
<RouterView />
|
||||
</BaseLayout>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -284,6 +284,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||
write: (v) => String(v),
|
||||
},
|
||||
});
|
||||
const sidebarWidth = useLocalStorage('N8N_SIDEBAR_WIDTH', 200);
|
||||
const currentView = ref<string>('');
|
||||
const stateIsDirty = ref<boolean>(false);
|
||||
// This tracks only structural changes without metadata (name or tags)
|
||||
|
|
@ -745,6 +746,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||
nodeViewInitialized,
|
||||
addFirstStepOnLoad,
|
||||
sidebarMenuCollapsed,
|
||||
sidebarWidth,
|
||||
theme: computed(() => theme.value),
|
||||
modalsById,
|
||||
currentView,
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const isHovered = useElementHover(triggerRef);
|
|||
|
||||
<style lang="scss" module>
|
||||
.reasoningTrigger {
|
||||
color: var(--color--text--tint-2);
|
||||
color: var(--text-color--subtler);
|
||||
}
|
||||
|
||||
.reasoningContent {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
<script lang="ts" setup>
|
||||
import type { InstanceAiAgentNode } from '@n8n/api-types';
|
||||
import { N8nCallout, N8nIcon, N8nIconButton } from '@n8n/design-system';
|
||||
import { N8nCallout, N8nIcon } from '@n8n/design-system';
|
||||
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useInstanceAiStore } from '../instanceAi.store';
|
||||
import SubagentStepTimeline from './SubagentStepTimeline.vue';
|
||||
import TimelineStepButton from './TimelineStepButton.vue';
|
||||
|
||||
|
|
@ -11,8 +10,6 @@ const props = defineProps<{
|
|||
agentNode: InstanceAiAgentNode;
|
||||
}>();
|
||||
|
||||
const instanceAiStore = useInstanceAiStore();
|
||||
|
||||
const isExpanded = ref(false);
|
||||
|
||||
const isActive = computed(() => props.agentNode.status === 'active');
|
||||
|
|
@ -22,10 +19,6 @@ const sectionTitle = computed(
|
|||
() => props.agentNode.subtitle ?? props.agentNode.role ?? 'Working...',
|
||||
);
|
||||
|
||||
function handleStop() {
|
||||
instanceAiStore.amendAgent(props.agentNode.agentId, props.agentNode.role, props.agentNode.taskId);
|
||||
}
|
||||
|
||||
// Auto-collapse when agent completes (keep collapsed by default for peek preview)
|
||||
watch(
|
||||
() => props.agentNode.status,
|
||||
|
|
@ -41,30 +34,20 @@ watch(
|
|||
<!-- eslint-disable vue/no-multiple-template-root -->
|
||||
<!-- Collapsible timeline -->
|
||||
<CollapsibleRoot v-slot="{ open: isOpen }" v-model:open="isExpanded">
|
||||
<div :class="$style.triggerWrapper">
|
||||
<CollapsibleTrigger as-child>
|
||||
<TimelineStepButton size="medium">
|
||||
<template #icon="{ isHovered }">
|
||||
<template v-if="!isHovered && isActive">
|
||||
<N8nIcon icon="spinner" color="primary" size="small" transform-origin="center" spin />
|
||||
</template>
|
||||
<template v-else>
|
||||
<N8nIcon v-if="!isOpen" icon="chevron-right" size="small" />
|
||||
<N8nIcon v-else icon="chevron-down" size="small" />
|
||||
</template>
|
||||
<CollapsibleTrigger as-child>
|
||||
<TimelineStepButton size="medium">
|
||||
<template #icon="{ isHovered }">
|
||||
<template v-if="!isHovered && isActive">
|
||||
<N8nIcon icon="spinner" color="primary" size="small" transform-origin="center" spin />
|
||||
</template>
|
||||
<span :class="{ [$style.shimmer]: isActive }">{{ sectionTitle }}</span>
|
||||
</TimelineStepButton>
|
||||
</CollapsibleTrigger>
|
||||
<N8nIconButton
|
||||
v-if="isActive"
|
||||
:class="$style.stopButton"
|
||||
icon="square"
|
||||
size="small"
|
||||
variant="destructive"
|
||||
@click.stop="handleStop"
|
||||
/>
|
||||
</div>
|
||||
<template v-else>
|
||||
<N8nIcon v-if="!isOpen" icon="chevron-right" size="small" />
|
||||
<N8nIcon v-else icon="chevron-down" size="small" />
|
||||
</template>
|
||||
</template>
|
||||
<span :class="{ [$style.shimmer]: isActive }">{{ sectionTitle }}</span>
|
||||
</TimelineStepButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent :class="$style.content">
|
||||
<SubagentStepTimeline :agent-node="props.agentNode" />
|
||||
</CollapsibleContent>
|
||||
|
|
@ -76,18 +59,6 @@ watch(
|
|||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.triggerWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stopButton {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-left: var(--spacing--2xs);
|
||||
border-left: var(--border);
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ function handleClick(e: MouseEvent) {
|
|||
}
|
||||
|
||||
.metadata {
|
||||
color: var(--color--text--tint-2);
|
||||
color: var(--text-color--subtler);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts" setup>
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import { getRelativeDate } from '@/features/ai/chatHub/chat.utils';
|
||||
import { N8nActionDropdown, N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system';
|
||||
import type { ActionDropdownItem } from '@n8n/design-system/types';
|
||||
|
|
@ -56,10 +55,6 @@ const groupedThreads = computed(() => {
|
|||
});
|
||||
});
|
||||
|
||||
function handleBack() {
|
||||
void router.push({ name: VIEWS.HOMEPAGE });
|
||||
}
|
||||
|
||||
function handleNewThread() {
|
||||
const threadId = store.newThread();
|
||||
void router.push({ name: INSTANCE_AI_THREAD_VIEW, params: { threadId } });
|
||||
|
|
@ -109,14 +104,6 @@ function handleThreadAction(action: string, threadId: string) {
|
|||
|
||||
<template>
|
||||
<div :class="$style.container" data-test-id="instance-ai-thread-list">
|
||||
<!-- Back button -->
|
||||
<button :class="$style.backButton" @click="handleBack">
|
||||
<N8nIcon icon="chevron-left" size="small" />
|
||||
{{ i18n.baseText('instanceAi.sidebar.back') }}
|
||||
</button>
|
||||
|
||||
<div :class="$style.separator" />
|
||||
|
||||
<!-- New chat button -->
|
||||
<button
|
||||
:class="$style.newChatButton"
|
||||
|
|
@ -202,31 +189,6 @@ function handleThreadAction(action: string, threadId: string) {
|
|||
background: var(--color--background--light-2);
|
||||
}
|
||||
|
||||
.backButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--3xs);
|
||||
padding: var(--spacing--xs) var(--spacing--sm);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size--sm);
|
||||
color: var(--color--text);
|
||||
outline: none;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
color: var(--color--text) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
border-bottom: var(--border);
|
||||
margin: 0 var(--spacing--sm);
|
||||
}
|
||||
|
||||
.newChatButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
Loading…
Reference in a new issue