fix(editor): UI tweaks for instance AI components (#28155)

This commit is contained in:
Raúl Gómez Morales 2026-04-09 09:48:39 +02:00 committed by GitHub
parent 5e60272632
commit aa6c322059
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 48 additions and 143 deletions

View file

@ -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"

View file

@ -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);
});
});
});

View file

@ -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);

View file

@ -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>

View file

@ -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,

View file

@ -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 {

View file

@ -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);

View file

@ -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;

View file

@ -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;