diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 1f9e3820ebc..8eb1e45ffd2 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -5407,10 +5407,17 @@ "instanceAi.error.title": "Something went wrong", "instanceAi.error.technicalDetails": "Technical details", "instanceAi.artifactsPanel.title": "Artifacts", + "instanceAi.artifactsPanel.showPreview": "Show artifacts preview", + "instanceAi.artifactsPanel.hidePreview": "Hide artifacts preview", + "instanceAi.artifactsPanel.showPanel": "Show lists panel", + "instanceAi.artifactsPanel.pinPanel": "Pin panel", + "instanceAi.artifactsPanel.unpinPanel": "Unpin panel", "instanceAi.artifactsPanel.noArtifacts": "No artifacts yet", - "instanceAi.artifactsPanel.tasks": "Tasks", + "instanceAi.artifactsPanel.openArtifact": "Open {name}", + "instanceAi.artifactsPanel.tasks": "To-do list", "instanceAi.artifactsPanel.openWorkflow": "Open", "instanceAi.artifactsPanel.archived": "Archived", + "instanceAi.previewTabBar.expand": "Expand panel", "instanceAi.previewTabBar.collapse": "Collapse panel", "instanceAi.previewTabBar.openInEditor": "Open in editor", "instanceAi.previewTabBar.openWorkflowInEditor": "Open workflow in editor", diff --git a/packages/frontend/editor-ui/src/app/composables/useSidebarLayout.ts b/packages/frontend/editor-ui/src/app/composables/useSidebarLayout.ts index b4ae97c85a3..c0b05da50cd 100644 --- a/packages/frontend/editor-ui/src/app/composables/useSidebarLayout.ts +++ b/packages/frontend/editor-ui/src/app/composables/useSidebarLayout.ts @@ -1,6 +1,9 @@ import { computed, ref, toRef } from 'vue'; import { useUIStore } from '../stores/ui.store'; +// Matches `$sidebar-width` in `app/css/_variables.scss`. +export const COLLAPSED_MAIN_SIDEBAR_WIDTH = 42; + export function useSidebarLayout() { const uiStore = useUIStore(); const isCollapsed = computed(() => uiStore.sidebarMenuCollapsed ?? false); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue index a3c98af4282..613b1534f4a 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue @@ -17,18 +17,23 @@ import { N8nResizeWrapper, N8nScrollArea, N8nText, + N8nTooltip, + TOOLTIP_DELAY_MS, } from '@n8n/design-system'; -import { useElementSize, useScroll } from '@vueuse/core'; +import { useElementSize, useScroll, useSessionStorage, useWindowSize } from '@vueuse/core'; import { useI18n } from '@n8n/i18n'; import type { InstanceAiAttachment } from '@n8n/api-types'; import { useRootStore } from '@n8n/stores/useRootStore'; import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper'; +import { COLLAPSED_MAIN_SIDEBAR_WIDTH, useSidebarLayout } from '@/app/composables/useSidebarLayout'; import { provideThread, useInstanceAiStore } from './instanceAi.store'; import { useCanvasPreview } from './useCanvasPreview'; import { useEventRelay } from './useEventRelay'; import { useExecutionPushEvents } from './useExecutionPushEvents'; import { useCreditWarningBanner } from './composables/useCreditWarningBanner'; +import { useTransitionGate } from './useTransitionGate'; import { INSTANCE_AI_VIEW, NEW_CONVERSATION_TITLE } from './constants'; +import { useSidebarState } from './instanceAiLayout'; import InstanceAiMessage from './components/InstanceAiMessage.vue'; import InstanceAiInput from './components/InstanceAiInput.vue'; import InstanceAiDebugPanel from './components/InstanceAiDebugPanel.vue'; @@ -56,6 +61,9 @@ const i18n = useI18n(); const router = useRouter(); const { goToUpgrade } = usePageRedirectionHelper(); const creditBanner = useCreditWarningBanner(isLowCredits); +const sidebar = useSidebarState(); +const { width: windowWidth } = useWindowSize(); +const { isCollapsed: isMainSidebarCollapsed, sidebarWidth: mainSidebarWidth } = useSidebarLayout(); // Running builders render in a dedicated bottom section of the conversation. // Once a builder finishes it falls out of this list and AgentTimeline renders @@ -76,7 +84,7 @@ const executionTracking = useExecutionPushEvents(); // the "New conversation" → real title flash when resuming a recent thread. const currentThreadTitle = computed(() => { const threadSummary = store.threads.find((t) => t.id === props.threadId); - if (threadSummary && threadSummary.title && threadSummary.title !== NEW_CONVERSATION_TITLE) { + if (threadSummary?.title && threadSummary.title !== NEW_CONVERSATION_TITLE) { return threadSummary.title; } const firstUserMsg = thread.messages.find((m) => m.role === 'user'); @@ -97,18 +105,139 @@ provide('openWorkflowPreview', preview.openWorkflowPreview); provide('openDataTablePreview', preview.openDataTablePreview); // --- Side panels --- -const showArtifactsPanel = ref(true); const showDebugPanel = ref(false); const isDebugEnabled = computed(() => localStorage.getItem('instanceAi.debugMode') === 'true'); +const hasPreviewTabs = computed(() => preview.allArtifactTabs.value.length > 0); +const isArtifactsPanelPinned = useSessionStorage('instanceAi.artifactsPanelPinned', true); +const isArtifactsPanelRevealed = ref(false); +const DEFAULT_INSTANCE_AI_SIDEBAR_WIDTH = 260; +const MIN_AVAILABLE_WIDTH_FOR_PINNED_ARTIFACTS_PANEL = 900; +const artifactsPanelTransitionGate = useTransitionGate({ + isBlocked: () => thread.isHydratingThread, +}); +const previewPanelTransitionGate = useTransitionGate({ + isBlocked: () => thread.isHydratingThread, +}); +const isArtifactsPanelTransitionEnabled = artifactsPanelTransitionGate.isEnabled; +const isPreviewPanelTransitionEnabled = previewPanelTransitionGate.isEnabled; +const isPreviewPanelTransitioning = ref(false); +const artifactsPreviewToggleLabel = computed(() => + i18n.baseText( + preview.isPreviewVisible.value + ? 'instanceAi.artifactsPanel.hidePreview' + : 'instanceAi.artifactsPanel.showPreview', + ), +); +const artifactsPanelTransitionName = computed(() => + isPreviewPanelTransitioning.value ? 'artifacts-panel-preview' : 'artifacts-panel-fade', +); + +function toggleArtifactsPreview() { + if (preview.isPreviewVisible.value) { + preview.closePreview(); + return; + } + + const firstTab = preview.allArtifactTabs.value[0]; + if (firstTab) { + preview.selectTab(firstTab.id); + } +} + +function revealArtifactsPanel() { + if ( + !canShowArtifactsPanel.value || + isArtifactsPanelEffectivelyPinned.value || + preview.isPreviewVisible.value + ) { + return; + } + isArtifactsPanelRevealed.value = true; +} + +function hideArtifactsPanel(event?: FocusEvent) { + if (isArtifactsPanelEffectivelyPinned.value) return; + if ( + event?.currentTarget instanceof HTMLElement && + event.relatedTarget instanceof Node && + event.currentTarget.contains(event.relatedTarget) + ) { + return; + } + isArtifactsPanelRevealed.value = false; +} + +function toggleArtifactsPanelPinned() { + if (!isArtifactsPanelPinningAvailable.value) return; + + const nextPinned = !isArtifactsPanelPinned.value; + isArtifactsPanelPinned.value = nextPinned; + isArtifactsPanelRevealed.value = !nextPinned; +} + +function enablePanelTransitionsAfterStableRender() { + artifactsPanelTransitionGate.enableAfterStableRender(); + previewPanelTransitionGate.enableAfterStableRender(); +} + +function suppressPanelTransitionsUntilStableRender() { + artifactsPanelTransitionGate.suppressUntilStableRender(); + previewPanelTransitionGate.suppressUntilStableRender(); +} // --- Preview panel resize (when canvas is visible) --- -// Cap the preview at 50% of the *available* thread area, not the full window — -// with the layout sidebar open the chat side would otherwise get less than 50%. +// Cap the preview at 50% of the available thread area so the chat retains at +// least the other half when side panels or app layout chrome are visible. const threadAreaRef = useTemplateRef('threadArea'); const { width: threadAreaWidth } = useElementSize(threadAreaRef); +const mainSidebarOccupiedWidth = computed(() => + isMainSidebarCollapsed.value ? COLLAPSED_MAIN_SIDEBAR_WIDTH : (mainSidebarWidth.value ?? 0), +); +const instanceAiSidebarOccupiedWidth = computed(() => + sidebar.collapsed.value ? 0 : (sidebar.width?.value ?? DEFAULT_INSTANCE_AI_SIDEBAR_WIDTH), +); +const availableWidthForPinnedArtifactsPanel = computed( + () => windowWidth.value - mainSidebarOccupiedWidth.value - instanceAiSidebarOccupiedWidth.value, +); +const isArtifactsPanelPinningAvailable = computed( + () => + availableWidthForPinnedArtifactsPanel.value >= MIN_AVAILABLE_WIDTH_FOR_PINNED_ARTIFACTS_PANEL, +); +const isArtifactsPanelEffectivelyPinned = computed( + () => isArtifactsPanelPinningAvailable.value && isArtifactsPanelPinned.value, +); +const canShowArtifactsPanel = computed( + () => thread.hasMessages || (Boolean(props.threadId) && thread.isHydratingThread), +); +const showArtifactsPanelEdge = computed( + () => + canShowArtifactsPanel.value && + !preview.isPreviewVisible.value && + !isArtifactsPanelEffectivelyPinned.value, +); +const showArtifactsPanel = computed( + () => + canShowArtifactsPanel.value && + !preview.isPreviewVisible.value && + (isArtifactsPanelEffectivelyPinned.value || isArtifactsPanelRevealed.value), +); +const reserveArtifactsPanelLayout = computed( + () => showArtifactsPanel.value && isArtifactsPanelEffectivelyPinned.value, +); +const shouldSuppressContentLayoutTransitions = computed( + () => !isPreviewPanelTransitionEnabled.value, +); const previewPanelWidth = ref(0); const isResizingPreview = ref(false); +const isPreviewExpanded = ref(false); const previewMaxWidth = computed(() => Math.round(threadAreaWidth.value / 2)); +const previewPanelStyle = computed(() => + isPreviewExpanded.value ? undefined : { width: `${previewPanelWidth.value}px` }, +); + +function togglePreviewExpanded() { + isPreviewExpanded.value = !isPreviewExpanded.value; +} // Clamp preview width when the available area shrinks (sidebar open, window // resize, etc.) @@ -122,13 +251,29 @@ function handlePreviewResize({ width }: { width: number }) { previewPanelWidth.value = width; } -// Re-compute default width when preview opens so it starts at 50% of the -// currently-available thread area. -watch(preview.isPreviewVisible, (visible) => { - if (visible) { - previewPanelWidth.value = Math.round(threadAreaWidth.value / 2); - } -}); +function handlePreviewPanelAfterEnter() { + isPreviewPanelTransitioning.value = false; +} + +function handlePreviewPanelAfterLeave() { + isPreviewPanelTransitioning.value = false; + isPreviewExpanded.value = false; +} + +watch( + preview.isPreviewVisible, + (visible, wasVisible) => { + if (visible !== wasVisible) { + isPreviewPanelTransitioning.value = isPreviewPanelTransitionEnabled.value; + } + + if (visible) { + isArtifactsPanelRevealed.value = false; + previewPanelWidth.value = Math.round(threadAreaWidth.value / 2); + } + }, + { flush: 'sync' }, +); // Late-initialize if the panel became visible before the ResizeObserver // reported the container size (otherwise the panel would render at 0px). @@ -138,6 +283,39 @@ watch(threadAreaWidth, (width) => { } }); +watch(isArtifactsPanelPinningAvailable, (isAvailable) => { + if (!isAvailable) { + isArtifactsPanelRevealed.value = false; + } +}); + +watch(canShowArtifactsPanel, (canShow) => { + if (!canShow) { + isArtifactsPanelRevealed.value = false; + } +}); + +watch( + () => props.threadId, + (threadId, previousThreadId) => { + if (threadId !== previousThreadId) { + suppressPanelTransitionsUntilStableRender(); + } + }, +); + +watch( + () => thread.isHydratingThread, + (isHydrating) => { + if (isHydrating) { + artifactsPanelTransitionGate.suppress(); + previewPanelTransitionGate.suppress(); + return; + } + suppressPanelTransitionsUntilStableRender(); + }, +); + // --- Scroll management --- const scrollableRef = useTemplateRef('scrollable'); // The actual scroll container is the reka-ui viewport inside N8nScrollArea, @@ -200,6 +378,19 @@ watch(chatInputRef, (el) => { } }); +// Reset scroll state when switching threads so new content auto-scrolls. +watch( + () => props.threadId, + (threadId, previousThreadId) => { + if (threadId !== previousThreadId) { + userScrolledUp.value = false; + void nextTick(() => { + chatInputRef.value?.focus(); + }); + } + }, +); + // --- Floating input dynamic padding --- const inputContainerRef = useTemplateRef('inputContainer'); const inputAreaHeight = ref(120); @@ -249,6 +440,7 @@ async function syncRouteToStore() { } onMounted(() => { + enablePanelTransitionsAfterStableRender(); void syncRouteToStore(); void nextTick(() => chatInputRef.value?.focus()); }); @@ -315,19 +507,41 @@ function handleStop() { store.debugMode = showDebugPanel; " /> - + + + + + -
+
+ messages and rendered here so they always sit at the bottom + of the conversation. -->
- +
+ +
+ +
+
- - - - -
- - -
-
-
+ + + + +
+ + +
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue index 3b31e8a5ba1..bc3d5346d92 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue @@ -40,6 +40,7 @@ function handleSidebarResize({ width }: { width: number }) { provide(SidebarStateKey, { collapsed: sidebarCollapsed, + width: sidebarWidth, toggle: toggleSidebarCollapse, }); @@ -128,7 +129,7 @@ onUnmounted(() => { display: flex; height: 100%; width: 100%; - min-width: 900px; + min-width: 0; overflow: hidden; position: relative; z-index: 0; diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiArtifactsPanel.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiArtifactsPanel.test.ts new file mode 100644 index 00000000000..e433e2d38a3 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiArtifactsPanel.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fireEvent } from '@testing-library/vue'; +import { defineComponent, h, nextTick, reactive } from 'vue'; +import { createComponentRenderer } from '@/__tests__/render'; +import type { TaskList } from '@n8n/api-types'; +import type { ResourceEntry } from '../useResourceRegistry'; +import InstanceAiArtifactsPanel from '../components/InstanceAiArtifactsPanel.vue'; + +const storeState = reactive({ + currentTasks: undefined as TaskList | undefined, + producedArtifacts: new Map(), +}); + +vi.mock('../instanceAi.store', () => ({ + useThread: vi.fn(() => storeState), +})); + +const renderComponent = createComponentRenderer(InstanceAiArtifactsPanel, { + global: { + stubs: { + ConnectionsCard: defineComponent({ + props: { + dropdownPortalTarget: { type: HTMLElement, required: false }, + }, + setup(props) { + return () => + h('section', { + 'data-test-id': 'connections-card', + 'data-portal-target-tag': props.dropdownPortalTarget?.tagName ?? '', + }); + }, + }), + }, + }, +}); + +describe('InstanceAiArtifactsPanel', () => { + beforeEach(() => { + storeState.currentTasks = undefined; + storeState.producedArtifacts = new Map(); + }); + + it('keeps empty artifacts and connections sections visible without an empty tasks section', () => { + const { getByText, getByTestId, queryByText } = renderComponent(); + + expect(getByTestId('instance-ai-artifacts-sidebar')).toBeInTheDocument(); + expect(getByTestId('instance-ai-artifacts-sidebar-group')).toBeInTheDocument(); + expect(getByTestId('instance-ai-artifacts-sidebar-pin')).toHaveAttribute( + 'aria-label', + 'Unpin panel', + ); + expect(getByTestId('instance-ai-artifacts-sidebar-pin')).toHaveAttribute( + 'aria-pressed', + 'true', + ); + expect(getByText('No artifacts yet')).toBeInTheDocument(); + expect(queryByText('To-do list')).not.toBeInTheDocument(); + expect(queryByText('No tasks yet')).not.toBeInTheDocument(); + expect(getByTestId('connections-card')).toBeInTheDocument(); + }); + + it('anchors connection menus inside the panel', async () => { + const { getByTestId } = renderComponent(); + + await nextTick(); + + expect(getByTestId('connections-card')).toHaveAttribute('data-portal-target-tag', 'ASIDE'); + }); + + it('emits when the pin button is clicked and reflects the unpinned label', async () => { + const { emitted, getByTestId } = renderComponent({ props: { isPinned: false } }); + const pinButton = getByTestId('instance-ai-artifacts-sidebar-pin'); + + expect(pinButton).toHaveAttribute('aria-label', 'Pin panel'); + expect(pinButton).toHaveAttribute('aria-pressed', 'false'); + + await fireEvent.click(pinButton); + + expect(emitted('togglePinned')).toHaveLength(1); + }); + + it('hides the pin button when pinning is unavailable', () => { + const { queryByTestId } = renderComponent({ props: { isPinningAvailable: false } }); + + expect(queryByTestId('instance-ai-artifacts-sidebar-pin')).not.toBeInTheDocument(); + }); + + it('opens artifacts in preview and shows tasks without progress counts', async () => { + const openWorkflowPreview = vi.fn(); + storeState.producedArtifacts = new Map([ + [ + 'wf-1', + { + type: 'workflow', + id: 'wf-1', + name: 'Sales follow-up workflow', + }, + ], + ]); + storeState.currentTasks = { + tasks: [ + { id: 'task-1', description: 'Build the workflow', status: 'done' }, + { id: 'task-2', description: 'Review the workflow', status: 'in_progress' }, + ], + }; + + const { getByRole, getByText, queryByText } = renderComponent({ + global: { + provide: { + openWorkflowPreview, + }, + }, + }); + + const artifactLink = getByRole('link', { name: 'Open Sales follow-up workflow' }); + expect(artifactLink).toHaveAttribute('href', '/workflow/wf-1'); + expect(getByText('To-do list')).toBeInTheDocument(); + expect(getByText('Build the workflow')).toBeInTheDocument(); + expect(queryByText('1/2')).not.toBeInTheDocument(); + + await fireEvent.click(artifactLink); + + expect(openWorkflowPreview).toHaveBeenCalledWith('wf-1'); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiPreviewTabBar.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiPreviewTabBar.test.ts index 650fde789aa..6b91dd2aaf3 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiPreviewTabBar.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiPreviewTabBar.test.ts @@ -36,15 +36,20 @@ const Wrapper = defineComponent({ props: { tabs: { type: Array as () => ArtifactTab[], required: true }, activeTabId: { type: String, default: undefined }, + isExpanded: { type: Boolean, default: false }, + previewToggleLabel: { type: String, default: undefined }, }, - emits: ['close'], + emits: ['togglePreview', 'toggleExpanded'], setup(props, { emit }) { return () => h(TabsRoot, { modelValue: props.activeTabId }, () => h(InstanceAiPreviewTabBar, { tabs: props.tabs, activeTabId: props.activeTabId, - onClose: () => emit('close'), + isExpanded: props.isExpanded, + previewToggleLabel: props.previewToggleLabel, + onTogglePreview: () => emit('togglePreview'), + onToggleExpanded: () => emit('toggleExpanded'), }), ); }, @@ -83,17 +88,49 @@ describe('InstanceAiPreviewTabBar', () => { expect(inactive?.getAttribute('data-state')).toBe('inactive'); }); - it('emits close when the collapse button is clicked', async () => { + it('emits toggleExpanded when the expand button is clicked', async () => { const { container, emitted } = renderComponent({ props: { tabs: [workflowTab], activeTabId: 'wf-1' }, }); - const collapseButton = container.querySelector( - '[data-test-id="instance-ai-preview-close"]', + const expandButton = container.querySelector( + '[data-test-id="instance-ai-preview-expand-toggle"]', ); - expect(collapseButton).not.toBeNull(); - await fireEvent.click(collapseButton!); + expect(expandButton).not.toBeNull(); + expect(expandButton).toHaveAttribute('aria-label', 'Expand panel'); + await fireEvent.click(expandButton!); - expect(emitted().close).toBeTruthy(); + expect(emitted().toggleExpanded).toBeTruthy(); + }); + + it('emits togglePreview when the preview toggle is clicked', async () => { + const { getByTestId, emitted } = renderComponent({ + props: { + tabs: [workflowTab], + activeTabId: 'wf-1', + previewToggleLabel: 'Hide artifacts preview', + }, + }); + + const toggleButton = getByTestId('instance-ai-artifacts-preview-toggle'); + expect(toggleButton).toHaveAttribute('aria-label', 'Hide artifacts preview'); + expect(toggleButton).toHaveAttribute('aria-pressed', 'true'); + + await fireEvent.click(toggleButton); + + expect(emitted().togglePreview).toBeTruthy(); + }); + + it('labels the size toggle as collapse when the panel is expanded', () => { + const { container } = renderComponent({ + props: { tabs: [workflowTab], activeTabId: 'wf-1', isExpanded: true }, + }); + + const collapseButton = container.querySelector( + '[data-test-id="instance-ai-preview-expand-toggle"]', + ); + + expect(collapseButton).not.toBeNull(); + expect(collapseButton).toHaveAttribute('aria-label', 'Collapse panel'); }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiThreadView.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiThreadView.test.ts index 75f49df77cc..17abb2b9093 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiThreadView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiThreadView.test.ts @@ -9,6 +9,10 @@ import { useInstanceAiStore, type ThreadRuntime } from '../instanceAi.store'; import { usePushConnectionStore } from '@/app/stores/pushConnection.store'; import { SidebarStateKey } from '../instanceAiLayout'; +const mockWindowSizeState = vi.hoisted(() => ({ + width: { value: 1200 }, +})); + vi.mock('@/app/composables/usePageRedirectionHelper', () => ({ usePageRedirectionHelper: () => ({ goToUpgrade: vi.fn() }), })); @@ -30,7 +34,7 @@ vi.mock('vue-router', async (importOriginal) => ({ vi.mock('@vueuse/core', async (importOriginal) => ({ ...(await importOriginal()), useScroll: () => ({ arrivedState: { bottom: true } }), - useWindowSize: () => ({ width: ref(1200) }), + useWindowSize: () => ({ width: mockWindowSizeState.width }), })); const InstanceAiInputStub = defineComponent({ @@ -73,6 +77,7 @@ describe('InstanceAiThreadView', () => { thread = { id: 'thread-1', messages: [], + hasMessages: false, sseState: 'connected', isStreaming: false, isSendingMessage: false, @@ -106,6 +111,7 @@ describe('InstanceAiThreadView', () => { updatedAt: '2026-04-01T00:00:00.000Z', }, ] as typeof store.threads; + mockWindowSizeState.width.value = 1200; // `useExecutionPushEvents` (consumed by ThreadView) registers a push // listener and stores the returned removeListener; it gets invoked on @@ -166,4 +172,25 @@ describe('InstanceAiThreadView', () => { }); expect(thread.loadHistoricalMessages).toHaveBeenCalledWith(); }); + + it('uses edge reveal when the viewport is too narrow for pinned artifacts', async () => { + mockWindowSizeState.width.value = 900; + thread.messages = [ + { + id: 'msg-1', + role: 'assistant', + content: 'already loaded', + isStreaming: false, + createdAt: '2026-04-01T00:00:00.000Z', + }, + ] as typeof thread.messages; + Object.defineProperty(thread, 'hasMessages', { value: true, configurable: true }); + + const { getByTestId, queryByTestId } = renderView({ props: { threadId: 'thread-1' } }); + + await vi.waitFor(() => { + expect(getByTestId('instance-ai-artifacts-sidebar-edge')).toBeInTheDocument(); + }); + expect(queryByTestId('instance-ai-artifacts-sidebar-slot')).not.toBeInTheDocument(); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionRow.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionRow.vue index 94fa513ba95..676fe40cfae 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionRow.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionRow.vue @@ -13,6 +13,7 @@ const props = defineProps<{ icon: IconName; status: ConnectionStatus; actions: RowAction[]; + dropdownPortalTarget?: HTMLElement; }>(); const emit = defineEmits<{ @@ -77,6 +78,7 @@ function handleSelect(action: RowAction) { v-if="menuItems.length > 0" :items="menuItems" placement="bottom-end" + :portal-target="dropdownPortalTarget" @select="handleSelect" />
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionsCard.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionsCard.vue index cfb4efeac22..d08c5aafe56 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionsCard.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionsCard.vue @@ -19,8 +19,11 @@ const i18n = useI18n(); const uiStore = useUIStore(); const store = useInstanceAiSettingsStore(); -const connections = computed(() => store.connections); +const props = defineProps<{ + dropdownPortalTarget?: HTMLElement; +}>(); +const connections = computed(() => store.connections); const isVisible = computed( () => !store.isLocalGatewayDisabledByAdmin && @@ -90,19 +93,21 @@ async function handleRemove(type: ConnectionType) {