feat(editor): Improve Instance AI side panels (no-changelog) (#30081)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mutdmour <4711238+mutdmour@users.noreply.github.com>
This commit is contained in:
Tuukka Kantola 2026-05-13 11:01:36 +02:00 committed by GitHub
parent 94a3220de8
commit 202743d8a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1118 additions and 265 deletions

View File

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

View File

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

View File

@ -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<string | undefined>(() => {
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<HTMLElement>('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<HTMLElement>('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<HTMLElement>('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;
"
/>
<N8nIconButton
v-if="!preview.isPreviewVisible.value"
icon="panel-right"
variant="ghost"
size="small"
icon-size="large"
@click="showArtifactsPanel = !showArtifactsPanel"
/>
<N8nTooltip
:content="artifactsPreviewToggleLabel"
placement="bottom"
:show-after="TOOLTIP_DELAY_MS"
>
<Transition name="preview-toggle-opacity" :css="isPreviewPanelTransitionEnabled">
<N8nIconButton
v-if="!preview.isPreviewVisible.value"
icon="panel-right"
variant="ghost"
size="small"
icon-size="large"
data-test-id="instance-ai-artifacts-preview-toggle"
:aria-label="artifactsPreviewToggleLabel"
:aria-pressed="preview.isPreviewVisible.value"
:disabled="!hasPreviewTabs"
@click="toggleArtifactsPreview"
/>
</Transition>
</N8nTooltip>
</template>
</InstanceAiViewHeader>
<!-- Content area: chat + artifacts side by side below header -->
<div :class="$style.contentArea">
<div
:class="[
$style.contentArea,
{
[$style.contentAreaWithPinnedArtifacts]: reserveArtifactsPanelLayout,
},
{ [$style.contentAreaWithoutLayoutTransitions]: shouldSuppressContentLayoutTransitions },
]"
:data-layout-transitions-enabled="isPreviewPanelTransitionEnabled"
data-test-id="instance-ai-content-area"
>
<div :class="$style.chatContent">
<N8nScrollArea :class="$style.scrollArea">
<div
@ -343,8 +557,8 @@ function handleStop() {
/>
</TransitionGroup>
<!-- Builder sub-agents are extracted from their parent assistant
messages and rendered here so they always sit at the bottom
of the conversation. -->
messages and rendered here so they always sit at the bottom
of the conversation. -->
<div v-if="builderAgents.length" :class="$style.builderAgents">
<AgentSection
v-for="builder in builderAgents"
@ -404,7 +618,36 @@ function handleStop() {
</div>
<!-- Artifacts panel (below header, beside chat) -->
<InstanceAiArtifactsPanel v-if="showArtifactsPanel && !preview.isPreviewVisible.value" />
<div
v-if="showArtifactsPanelEdge"
:class="$style.artifactsPanelEdge"
role="button"
tabindex="0"
:aria-label="i18n.baseText('instanceAi.artifactsPanel.showPanel')"
data-test-id="instance-ai-artifacts-sidebar-edge"
@click="revealArtifactsPanel"
@mouseenter="revealArtifactsPanel"
@focusin="revealArtifactsPanel"
@keydown.enter.prevent="revealArtifactsPanel"
@keydown.space.prevent="revealArtifactsPanel"
/>
<Transition :name="artifactsPanelTransitionName" :css="isArtifactsPanelTransitionEnabled">
<div
v-if="showArtifactsPanel"
:class="$style.artifactsPanelSlot"
data-test-id="instance-ai-artifacts-sidebar-slot"
@mouseenter="revealArtifactsPanel"
@mouseleave="hideArtifactsPanel()"
@focusin="revealArtifactsPanel"
@focusout="hideArtifactsPanel"
>
<InstanceAiArtifactsPanel
:is-pinned="isArtifactsPanelEffectivelyPinned"
:is-pinning-available="isArtifactsPanelPinningAvailable"
@toggle-pinned="toggleArtifactsPanelPinned"
/>
</div>
</Transition>
<!-- Overlay panels -->
<InstanceAiDebugPanel
@ -418,64 +661,97 @@ function handleStop() {
</div>
<!-- Resizable preview panel (workflow OR datatable) -->
<N8nResizeWrapper
v-show="preview.isPreviewVisible.value"
:class="$style.canvasArea"
:width="previewPanelWidth"
:style="{ width: `${previewPanelWidth}px` }"
:min-width="400"
:max-width="previewMaxWidth"
:supported-directions="['left']"
:is-resizing-enabled="true"
:grid-size="8"
:outset="true"
@resize="handlePreviewResize"
@resizestart="isResizingPreview = true"
@resizeend="isResizingPreview = false"
<Transition
name="preview-panel-slide"
:css="isPreviewPanelTransitionEnabled"
@after-enter="handlePreviewPanelAfterEnter"
@after-leave="handlePreviewPanelAfterLeave"
>
<TabsRoot
v-model="preview.activeTabId.value"
orientation="horizontal"
:class="$style.previewPanel"
<div
v-show="preview.isPreviewVisible.value"
:class="[$style.canvasArea, { [$style.canvasAreaExpanded]: isPreviewExpanded }]"
:style="previewPanelStyle"
:data-expanded="isPreviewExpanded"
data-test-id="instance-ai-preview-panel"
>
<InstanceAiPreviewTabBar
:tabs="preview.allArtifactTabs.value"
:active-tab-id="preview.activeTabId.value"
@close="preview.closePreview()"
/>
<!-- Hoisted above the tab v-for so the iframe survives tab switches; tabs swap
workflows via openWorkflow postMessage instead of remounting. -->
<div :class="$style.previewContent">
<InstanceAiWorkflowPreview
ref="workflowPreview"
:class="[
$style.previewSlot,
{ [$style.previewSlotHidden]: !!preview.activeDataTableId.value },
]"
:workflow-id="preview.activeWorkflowId.value"
:refresh-key="preview.workflowRefreshKey.value"
@iframe-ready="eventRelay.handleIframeReady"
@workflow-loaded="eventRelay.handleWorkflowLoaded"
/>
<InstanceAiDataTablePreview
v-if="preview.activeDataTableId.value"
:class="$style.previewSlot"
:data-table-id="preview.activeDataTableId.value"
:project-id="preview.activeDataTableProjectId.value"
:refresh-key="preview.dataTableRefreshKey.value"
/>
</div>
</TabsRoot>
</N8nResizeWrapper>
<N8nResizeWrapper
:width="previewPanelWidth"
:min-width="400"
:max-width="previewMaxWidth"
:supported-directions="['left']"
:is-resizing-enabled="!isPreviewExpanded"
:grid-size="8"
:outset="true"
@resize="handlePreviewResize"
@resizestart="isResizingPreview = true"
@resizeend="isResizingPreview = false"
>
<TabsRoot
v-model="preview.activeTabId.value"
orientation="horizontal"
:class="$style.previewPanel"
>
<InstanceAiPreviewTabBar
:tabs="preview.allArtifactTabs.value"
:active-tab-id="preview.activeTabId.value"
:is-expanded="isPreviewExpanded"
:preview-toggle-label="artifactsPreviewToggleLabel"
@toggle-preview="toggleArtifactsPreview"
@toggle-expanded="togglePreviewExpanded"
/>
<!-- Hoisted above the tab v-for so the iframe survives tab switches; tabs swap
workflows via openWorkflow postMessage instead of remounting. -->
<div :class="$style.previewContent">
<InstanceAiWorkflowPreview
ref="workflowPreview"
:class="[
$style.previewSlot,
{ [$style.previewSlotHidden]: !!preview.activeDataTableId.value },
]"
:workflow-id="preview.activeWorkflowId.value"
:refresh-key="preview.workflowRefreshKey.value"
@iframe-ready="eventRelay.handleIframeReady"
@workflow-loaded="eventRelay.handleWorkflowLoaded"
/>
<InstanceAiDataTablePreview
v-if="preview.activeDataTableId.value"
:class="$style.previewSlot"
:data-table-id="preview.activeDataTableId.value"
:project-id="preview.activeDataTableProjectId.value"
:refresh-key="preview.dataTableRefreshKey.value"
/>
</div>
</TabsRoot>
</N8nResizeWrapper>
</div>
</Transition>
</div>
</template>
<style lang="scss" module>
@property --instance-ai-artifacts-layout-width {
syntax: '<length>';
inherits: true;
initial-value: 0;
}
.threadArea {
--instance-ai-artifacts-panel-width: 280px;
--instance-ai-panel-transition-duration: calc(var(--duration--snappy) + 80ms);
--instance-ai-panel-transition-easing: var(--easing--ease-in-out);
flex: 1;
display: flex;
min-width: 0;
overflow: hidden;
position: relative;
z-index: 0;
// Drop the stacking context while the workflow preview iframe NDV is
// fullscreen so its `z-index` can escape and paint above the sidebar.
&:has([data-test-id='workflow-preview-iframe'][data-ndv-open]) {
z-index: auto;
}
}
.chatArea {
@ -519,6 +795,14 @@ function handleStop() {
}
}
.canvasAreaExpanded {
position: absolute;
inset: 0;
z-index: 4;
border-left: none;
background-color: var(--color--background--light-2);
}
.headerTitle {
overflow: hidden;
text-overflow: ellipsis;
@ -536,10 +820,41 @@ function handleStop() {
}
.contentArea {
--instance-ai-artifacts-layout-width: 0;
display: flex;
flex: 1;
min-height: 0;
position: relative;
transition: --instance-ai-artifacts-layout-width var(--instance-ai-panel-transition-duration)
var(--instance-ai-panel-transition-easing);
}
.artifactsPanelEdge {
position: absolute;
top: 0;
right: 0;
bottom: 0;
z-index: 3;
width: var(--spacing--xl);
cursor: default;
outline: none;
&:focus-visible {
box-shadow: inset calc(-1 * var(--spacing--5xs)) 0 0 var(--color--primary);
}
}
.artifactsPanelSlot {
position: absolute;
top: 0;
right: 0;
bottom: 0;
z-index: 4;
width: var(--instance-ai-artifacts-panel-width);
min-width: var(--instance-ai-artifacts-panel-width);
display: flex;
overflow: hidden;
}
.chatContent {
@ -557,12 +872,28 @@ function handleStop() {
}
.messageList {
width: calc(100% - var(--instance-ai-artifacts-layout-width));
max-width: 800px;
margin: 0 auto;
padding: var(--spacing--sm) var(--spacing--lg);
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
transform: translateX(calc(var(--instance-ai-artifacts-layout-width) / -2));
}
.contentAreaWithPinnedArtifacts {
--instance-ai-artifacts-layout-width: var(--instance-ai-artifacts-panel-width);
}
.contentAreaWithoutLayoutTransitions {
transition: none;
.messageList,
.scrollButtonContainer,
.inputConstraint {
transition: none;
}
}
.builderAgents {
@ -580,6 +911,7 @@ function handleStop() {
justify-content: center;
pointer-events: none;
z-index: 3;
transform: translateX(calc(var(--instance-ai-artifacts-layout-width) / -2));
}
.scrollToBottomButton {
@ -610,8 +942,19 @@ function handleStop() {
}
.inputConstraint {
width: calc(100% - var(--instance-ai-artifacts-layout-width));
max-width: 750px;
margin: 0 auto;
transform: translateX(calc(var(--instance-ai-artifacts-layout-width) / -2));
}
@media (prefers-reduced-motion: reduce) {
.contentArea,
.messageList,
.scrollButtonContainer,
.inputConstraint {
transition: none;
}
}
.previewPanel {
@ -638,6 +981,8 @@ function handleStop() {
</style>
<style lang="scss">
@use '@n8n/design-system/css/mixins/motion';
.message-slide-enter-from {
opacity: 0;
transform: translateY(8px);
@ -656,4 +1001,126 @@ function handleStop() {
.fade-leave-active {
transition: opacity 0.2s ease;
}
.preview-panel-slide-enter-active,
.preview-panel-slide-leave-active {
--preview-panel-slide-easing: var(--easing--ease-in-out);
transition:
width var(--instance-ai-panel-transition-duration, var(--duration--snappy))
var(--preview-panel-slide-easing),
min-width var(--instance-ai-panel-transition-duration, var(--duration--snappy))
var(--preview-panel-slide-easing),
opacity var(--instance-ai-panel-transition-duration, var(--duration--snappy))
var(--preview-panel-slide-easing);
overflow: hidden;
will-change: width, min-width, opacity, transform;
@media (prefers-reduced-motion: reduce) {
transition: none;
will-change: auto;
}
}
.preview-panel-slide-enter-active {
--animation--fade-in-right--easing: var(--preview-panel-slide-easing);
--animation--fade-in-right--duration: var(
--instance-ai-panel-transition-duration,
var(--duration--snappy)
);
--animation--fade-in-right--translate: var(--spacing--sm);
@include motion.fade-in-right;
}
.preview-panel-slide-leave-active {
--animation--fade-out-right--easing: var(--preview-panel-slide-easing);
--animation--fade-out-right--duration: var(
--instance-ai-panel-transition-duration,
var(--duration--snappy)
);
--animation--fade-out-right--translate: var(--spacing--sm);
@include motion.fade-out-right;
}
.preview-panel-slide-enter-from,
.preview-panel-slide-leave-to {
width: 0 !important;
min-width: 0 !important;
opacity: 0;
}
.preview-toggle-opacity-enter-active,
.preview-toggle-opacity-leave-active {
transition: opacity var(--instance-ai-panel-transition-duration, var(--duration--snappy)) linear;
will-change: opacity;
@media (prefers-reduced-motion: reduce) {
transition: none;
will-change: auto;
}
}
.preview-toggle-opacity-enter-from,
.preview-toggle-opacity-leave-to {
opacity: 0;
}
.preview-toggle-opacity-leave-active {
pointer-events: none;
}
.artifacts-panel-fade-enter-active,
.artifacts-panel-fade-leave-active {
--artifacts-panel-slide-enter-easing: var(--easing--ease-out);
--artifacts-panel-slide-exit-easing: var(--easing--ease-in);
--animation--fade-in-right--duration: var(
--instance-ai-panel-transition-duration,
var(--duration--snappy)
);
--animation--fade-in-right--easing: var(--artifacts-panel-slide-enter-easing);
--animation--fade-in-right--translate: 100%;
--animation--fade-out-right--duration: var(
--instance-ai-panel-transition-duration,
var(--duration--snappy)
);
--animation--fade-out-right--easing: var(--artifacts-panel-slide-exit-easing);
--animation--fade-out-right--translate: 100%;
will-change: opacity, transform;
@media (prefers-reduced-motion: reduce) {
will-change: auto;
}
}
.artifacts-panel-preview-enter-active,
.artifacts-panel-preview-leave-active {
transition: opacity var(--instance-ai-panel-transition-duration, var(--duration--snappy)) linear;
will-change: opacity;
@media (prefers-reduced-motion: reduce) {
will-change: auto;
}
}
.artifacts-panel-preview-enter-from,
.artifacts-panel-preview-leave-to {
opacity: 0;
}
.artifacts-panel-fade-enter-active {
@include motion.fade-in-right;
}
.artifacts-panel-fade-leave-active {
@include motion.fade-out-right;
pointer-events: none;
}
.artifacts-panel-preview-leave-active {
pointer-events: none;
}
</style>

View File

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

View File

@ -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<string, ResourceEntry>(),
});
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<string, ResourceEntry>();
});
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<string, ResourceEntry>([
[
'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');
});
});

View File

@ -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<HTMLButtonElement>(
'[data-test-id="instance-ai-preview-close"]',
const expandButton = container.querySelector<HTMLButtonElement>(
'[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<HTMLButtonElement>(
'[data-test-id="instance-ai-preview-expand-toggle"]',
);
expect(collapseButton).not.toBeNull();
expect(collapseButton).toHaveAttribute('aria-label', 'Collapse panel');
});
});

View File

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

View File

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

View File

@ -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) {
</script>
<template>
<div v-if="isVisible" :class="[$style.section, $style.card]">
<div v-if="isVisible" :class="$style.section">
<div :class="$style.header">
<N8nHeading tag="h3" size="small" bold>
<N8nHeading tag="h3" size="small" :class="$style.sectionTitle">
{{ i18n.baseText('instanceAi.connections.title') }}
</N8nHeading>
<N8nDropdownMenu
v-if="hasAddableConnection"
:items="addItems"
:activator-icon="{ type: 'icon', value: 'plus' }"
placement="bottom-end"
data-test-id="instance-ai-connections-add"
@select="openModal"
/>
<div v-if="hasAddableConnection" :class="$style.headerActions">
<N8nDropdownMenu
:items="addItems"
:activator-icon="{ type: 'icon', value: 'plus' }"
placement="bottom-end"
:portal-target="props.dropdownPortalTarget"
data-test-id="instance-ai-connections-add"
@select="openModal"
/>
</div>
</div>
<div v-if="connections.length > 0" :class="$style.list">
@ -114,6 +119,7 @@ async function handleRemove(type: ConnectionType) {
:icon="ICON_MAP[conn.type]"
:status="conn.status"
:actions="getRowActions(conn.type, conn.status)"
:dropdown-portal-target="props.dropdownPortalTarget"
@connect="openModal(conn.type)"
@disconnect="handleDisconnect(conn.type)"
@open-settings="openModal(conn.type)"
@ -138,15 +144,20 @@ async function handleRemove(type: ConnectionType) {
<style lang="scss" module>
.section {
position: relative;
display: flex;
flex-direction: column;
}
padding: var(--spacing--2xs);
padding-top: var(--spacing--sm);
.card {
border: var(--border);
border-radius: var(--radius--lg);
padding: var(--spacing--sm);
background: var(--color--background--light-2);
&::before {
content: '';
position: absolute;
top: 0;
left: var(--spacing--sm);
right: var(--spacing--sm);
border-top: var(--border);
}
}
.header {
@ -155,6 +166,17 @@ async function handleRemove(type: ConnectionType) {
justify-content: space-between;
gap: var(--spacing--2xs);
margin-bottom: var(--spacing--2xs);
padding: 0 var(--spacing--2xs);
}
.sectionTitle {
color: var(--text-color--subtle);
}
.headerActions {
display: flex;
align-items: center;
gap: var(--spacing--4xs);
}
.list {

View File

@ -1,6 +1,12 @@
<script lang="ts" setup>
import { computed, inject } from 'vue';
import { N8nHeading, N8nIcon } from '@n8n/design-system';
import { computed, inject, ref } from 'vue';
import {
N8nHeading,
N8nIcon,
N8nIconButton,
N8nTooltip,
TOOLTIP_DELAY_MS,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useThread } from '../instanceAi.store';
import type { TaskItem } from '@n8n/api-types';
@ -8,8 +14,16 @@ import type { IconName } from '@n8n/design-system';
import type { ResourceEntry } from '../useResourceRegistry';
import ConnectionsCard from './ConnectionsCard.vue';
const props = withDefaults(defineProps<{ isPinned?: boolean; isPinningAvailable?: boolean }>(), {
isPinned: true,
isPinningAvailable: true,
});
const emit = defineEmits<{ togglePinned: [] }>();
const i18n = useI18n();
const thread = useThread();
const panelRef = ref<HTMLElement>();
const openPreview = inject<((id: string) => void) | undefined>('openWorkflowPreview', undefined);
const openDataTablePreview = inject<((id: string, projectId: string) => void) | undefined>(
'openDataTablePreview',
@ -17,39 +31,32 @@ const openDataTablePreview = inject<((id: string, projectId: string) => void) |
);
function handleArtifactClick(artifact: ResourceEntry, e: MouseEvent) {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
if (artifact.type === 'workflow' && artifact.id) {
if (e.metaKey || e.ctrlKey) {
window.open(`/workflow/${artifact.id}`, '_blank');
return;
}
openPreview?.(artifact.id);
if (!openPreview) return;
e.preventDefault();
openPreview(artifact.id);
} else if (artifact.type === 'data-table' && artifact.id) {
if (e.metaKey || e.ctrlKey) {
window.open('/data-tables', '_blank');
return;
}
if (artifact.projectId) {
openDataTablePreview?.(artifact.id, artifact.projectId);
}
if (!artifact.projectId || !openDataTablePreview) return;
e.preventDefault();
openDataTablePreview(artifact.id, artifact.projectId);
}
}
// --- Tasks ---
const tasks = computed(() => thread.currentTasks);
const doneCount = computed(() => {
if (!tasks.value) return 0;
return tasks.value.tasks.filter((t) => t.status === 'done').length;
});
const visibleTasks = computed(() => tasks.value?.tasks ?? []);
const hasTasks = computed(() => visibleTasks.value.length > 0);
const statusIconMap: Record<
TaskItem['status'],
{ icon: string; spin: boolean; className: string }
{ icon: IconName; spin: boolean; className: string }
> = {
todo: { icon: 'circle', spin: false, className: 'todoIcon' },
in_progress: { icon: 'spinner', spin: true, className: 'inProgressIcon' },
done: { icon: 'check', spin: false, className: 'doneIcon' },
failed: { icon: 'x-circle', spin: false, className: 'failedIcon' },
failed: { icon: 'circle-x', spin: false, className: 'failedIcon' },
cancelled: { icon: 'ban', spin: false, className: 'cancelledIcon' },
};
@ -68,80 +75,122 @@ const artifactIconMap: Record<string, IconName> = {
workflow: 'workflow',
'data-table': 'table',
};
function artifactHref(artifact: ResourceEntry) {
if (artifact.type === 'workflow') return `/workflow/${artifact.id}`;
if (artifact.type === 'data-table') {
return artifact.projectId
? `/projects/${artifact.projectId}/datatables/${artifact.id}`
: '/data-tables';
}
return '#';
}
function openArtifactLabel(name: string) {
return i18n.baseText('instanceAi.artifactsPanel.openArtifact', { interpolate: { name } });
}
const pinButtonLabel = computed(() =>
i18n.baseText(
props.isPinned ? 'instanceAi.artifactsPanel.unpinPanel' : 'instanceAi.artifactsPanel.pinPanel',
),
);
</script>
<template>
<div :class="$style.panel">
<!-- Artifacts section -->
<div :class="[$style.section, $style.card]">
<N8nHeading :class="$style.sectionTitle" tag="h3" size="small" bold>
{{ i18n.baseText('instanceAi.artifactsPanel.title') }}
</N8nHeading>
<div v-if="artifacts.length > 0" :class="$style.artifactList">
<div
v-for="artifact in artifacts"
:key="artifact.id"
:class="[$style.artifactRow, artifact.archived && $style.artifactRowArchived]"
@click="handleArtifactClick(artifact, $event)"
>
<span :class="$style.artifactIconWrap">
<N8nIcon
:icon="artifactIconMap[artifact.type] ?? 'file'"
size="large"
:class="$style.artifactIcon"
<aside ref="panelRef" :class="$style.panel" data-test-id="instance-ai-artifacts-sidebar">
<div :class="$style.group" data-test-id="instance-ai-artifacts-sidebar-group">
<!-- Artifacts section -->
<div :class="$style.section">
<div :class="$style.sectionHeader">
<N8nHeading tag="h3" size="small" :class="$style.sectionTitle">
{{ i18n.baseText('instanceAi.artifactsPanel.title') }}
</N8nHeading>
<N8nTooltip
v-if="props.isPinningAvailable"
:content="pinButtonLabel"
placement="left"
:show-after="TOOLTIP_DELAY_MS"
>
<N8nIconButton
icon="pin"
variant="ghost"
size="small"
icon-size="medium"
:aria-label="pinButtonLabel"
:aria-pressed="props.isPinned"
:class="[$style.pinButton, { [$style.pinButtonPinned]: props.isPinned }]"
data-test-id="instance-ai-artifacts-sidebar-pin"
@click="emit('togglePinned')"
/>
</span>
<span :class="$style.artifactName">{{ artifact.name }}</span>
<span v-if="artifact.archived" :class="$style.archivedBadge">
{{ i18n.baseText('instanceAi.artifactsPanel.archived') }}
</span>
</N8nTooltip>
</div>
<div v-if="artifacts.length > 0" :class="$style.artifactList">
<a
v-for="artifact in artifacts"
:key="artifact.id"
:href="artifactHref(artifact)"
:class="[$style.artifactRow, artifact.archived && $style.artifactRowArchived]"
:title="artifact.name"
:aria-label="openArtifactLabel(artifact.name)"
@click="handleArtifactClick(artifact, $event)"
>
<span :class="$style.artifactIconWrap">
<N8nIcon
:icon="artifactIconMap[artifact.type] ?? 'file'"
size="large"
:class="$style.artifactIcon"
/>
</span>
<span :class="$style.artifactName">{{ artifact.name }}</span>
<span v-if="artifact.archived" :class="$style.archivedBadge">
{{ i18n.baseText('instanceAi.artifactsPanel.archived') }}
</span>
</a>
</div>
<div v-else :class="$style.emptyState">
<span>{{ i18n.baseText('instanceAi.artifactsPanel.noArtifacts') }}</span>
</div>
</div>
<div v-else :class="$style.emptyState">
<div :class="$style.emptyIcons">
<N8nIcon icon="workflow" :size="30" :class="$style.emptyIcon" />
<N8nIcon icon="table" :size="30" :class="$style.emptyIcon" />
<!-- Tasks section -->
<div v-if="hasTasks" :class="$style.section">
<div :class="$style.sectionHeader">
<N8nHeading tag="h3" size="small" :class="$style.sectionTitle">
{{ i18n.baseText('instanceAi.artifactsPanel.tasks') }}
</N8nHeading>
</div>
<div :class="$style.taskList">
<div
v-for="task in visibleTasks"
:key="task.id"
:class="[
$style.task,
task.status === 'done' ? $style.doneTask : '',
task.status === 'failed' ? $style.failedTask : '',
task.status === 'cancelled' ? $style.cancelledTask : '',
]"
>
<N8nIcon
:icon="statusIconMap[task.status].icon"
:class="$style[statusIconMap[task.status].className]"
:spin="statusIconMap[task.status].spin"
size="medium"
/>
<span :class="$style.taskDescription" :title="task.description">{{
task.description
}}</span>
</div>
</div>
<span>{{ i18n.baseText('instanceAi.artifactsPanel.noArtifacts') }}</span>
</div>
<!-- Connections section -->
<ConnectionsCard :dropdown-portal-target="panelRef" />
</div>
<!-- Tasks section -->
<div v-if="tasks" :class="[$style.section, $style.card]">
<N8nHeading :class="$style.sectionTitle" tag="h3" size="small" bold>
{{ i18n.baseText('instanceAi.artifactsPanel.tasks') }}
<span :class="$style.progress">{{ doneCount }}/{{ tasks.tasks.length }}</span>
</N8nHeading>
<div :class="$style.taskList">
<div
v-for="task in tasks.tasks"
:key="task.id"
:class="[
$style.task,
task.status === 'done' ? $style.doneTask : '',
task.status === 'failed' ? $style.failedTask : '',
task.status === 'cancelled' ? $style.cancelledTask : '',
]"
>
<N8nIcon
:icon="statusIconMap[task.status].icon as IconName"
:class="$style[statusIconMap[task.status].className]"
:spin="statusIconMap[task.status].spin"
size="medium"
/>
<span :class="$style.taskDescription" :title="task.description">{{
task.description
}}</span>
</div>
</div>
</div>
<!-- Connections section -->
<ConnectionsCard />
</div>
</aside>
</template>
<style lang="scss" module>
@ -150,34 +199,67 @@ const artifactIconMap: Record<string, IconName> = {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
padding: var(--spacing--sm);
padding: 0 var(--spacing--sm) var(--spacing--sm);
overflow-y: auto;
}
.group {
border: var(--border);
border-radius: var(--radius--xl);
background: var(--color--background--light-3);
box-shadow: var(--shadow--xs);
overflow: hidden;
}
.section {
position: relative;
display: flex;
flex-direction: column;
padding: var(--spacing--2xs);
& + & {
padding-top: var(--spacing--sm);
}
& + &::before {
content: '';
position: absolute;
top: 0;
left: var(--spacing--sm);
right: var(--spacing--sm);
border-top: var(--border);
}
}
.card {
border: var(--border);
border-radius: var(--radius--lg);
padding: var(--spacing--sm);
background: var(--color--background--light-2);
}
.sectionTitle {
.sectionHeader {
margin-bottom: var(--spacing--2xs);
padding: 0 var(--spacing--2xs);
display: flex;
align-items: baseline;
align-items: center;
justify-content: space-between;
gap: var(--spacing--3xs);
}
.progress {
font-size: var(--font-size--2xs);
.sectionTitle {
color: var(--text-color--subtle);
}
.pinButton {
color: var(--color--text--tint-1);
font-weight: var(--font-weight--regular);
&:hover,
&:focus-visible {
color: var(--color--text--shade-1);
}
}
.pinButtonPinned {
color: var(--color--text--shade-1);
:deep(svg),
:deep(path) {
fill: currentColor;
}
}
/* Artifact list */
@ -189,22 +271,23 @@ const artifactIconMap: Record<string, IconName> = {
.artifactRow {
display: flex;
align-items: center;
gap: var(--spacing--xs);
padding: var(--spacing--2xs) 0;
gap: var(--spacing--2xs);
padding: var(--spacing--2xs);
cursor: pointer;
border-radius: var(--radius);
transition: background-color 0.2s ease;
color: var(--color--text);
text-decoration: none;
transition: background-color var(--animation--duration--snappy) var(--animation--easing);
&:hover {
background: var(--color--foreground--tint-2);
&:hover,
&:focus-visible {
background: var(--background--hover);
outline: none;
text-decoration: none;
}
.artifactName {
color: var(--color--primary);
}
.artifactIcon {
color: var(--color--primary);
}
&:visited {
color: var(--color--text);
}
}
@ -212,9 +295,6 @@ const artifactIconMap: Record<string, IconName> = {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--spacing--4xs);
background: var(--color--foreground--tint-1);
border-radius: var(--radius);
flex-shrink: 0;
}
@ -224,6 +304,7 @@ const artifactIconMap: Record<string, IconName> = {
.artifactName {
font-size: var(--font-size--sm);
line-height: var(--line-height--lg);
color: var(--color--text--shade-1);
overflow: hidden;
text-overflow: ellipsis;
@ -260,18 +341,6 @@ const artifactIconMap: Record<string, IconName> = {
color: var(--color--text--tint-1);
}
.emptyIcons {
display: flex;
gap: var(--spacing--2xs);
}
.emptyIcon {
color: var(--color--text--tint-1);
padding: var(--spacing--4xs);
background: var(--color--foreground--tint-1);
border-radius: var(--radius--lg);
}
/* Task list */
.taskList {
display: flex;
@ -282,7 +351,8 @@ const artifactIconMap: Record<string, IconName> = {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding: var(--spacing--2xs) 0;
padding: var(--spacing--3xs) 0;
padding-left: var(--spacing--2xs);
font-size: var(--font-size--sm);
line-height: var(--line-height--lg);
}

View File

@ -11,33 +11,82 @@ import {
TabsList,
TabsTrigger,
} from 'reka-ui';
import { nextTick, watch } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import { useClipboard } from '@/app/composables/useClipboard';
import { useToast } from '@/app/composables/useToast';
import type { ArtifactTab } from '../useCanvasPreview';
const props = defineProps<{
tabs: ArtifactTab[];
activeTabId?: string;
}>();
const props = withDefaults(
defineProps<{
tabs: ArtifactTab[];
activeTabId?: string;
isExpanded?: boolean;
previewToggleLabel?: string;
}>(),
{
isExpanded: false,
previewToggleLabel: undefined,
},
);
const emit = defineEmits<{
close: [];
togglePreview: [];
toggleExpanded: [];
}>();
const i18n = useI18n();
const clipboard = useClipboard();
const toast = useToast();
const tabListRef = ref<HTMLElement | null>(null);
const sizeToggleLabel = computed(() =>
i18n.baseText(
props.isExpanded ? 'instanceAi.previewTabBar.collapse' : 'instanceAi.previewTabBar.expand',
),
);
function getTabListElement() {
const tabList = tabListRef.value;
if (tabList instanceof HTMLElement) return tabList;
return (tabList as { $el?: HTMLElement } | null)?.$el ?? null;
}
function scrollTabIntoView(tabId: string) {
const tabList = getTabListElement();
if (!tabList) return;
const activeTab = Array.from(tabList.querySelectorAll<HTMLElement>('[data-tab-id]')).find(
(tab) => tab.dataset.tabId === tabId,
);
if (!activeTab) return;
const tabLeft = activeTab.offsetLeft;
const tabRight = tabLeft + activeTab.offsetWidth;
const visibleLeft = tabList.scrollLeft;
const visibleRight = visibleLeft + tabList.clientWidth;
const nextScrollLeft =
tabLeft < visibleLeft
? tabLeft
: tabRight > visibleRight
? tabRight - tabList.clientWidth
: undefined;
if (nextScrollLeft === undefined) return;
if (typeof tabList.scrollTo === 'function') {
tabList.scrollTo({ left: nextScrollLeft, behavior: 'smooth' });
} else {
tabList.scrollLeft = nextScrollLeft;
}
}
// Bring the active tab into view when the selection changes (e.g. auto-switch
// on execution). scrollIntoView walks up to the nearest scroll container.
// on execution), without scrolling any outer app containers.
watch(
() => props.activeTabId,
(tabId) => {
if (!tabId) return;
void nextTick(() => {
const el = document.querySelector<HTMLElement>(`[data-tab-id="${tabId}"]`);
el?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
scrollTabIntoView(tabId);
});
},
);
@ -68,15 +117,18 @@ async function handleCopyLink(tab: ArtifactTab) {
<template>
<div :class="$style.header">
<N8nIconButton
icon="chevrons-right"
v-if="previewToggleLabel"
icon="panel-right"
variant="ghost"
size="medium"
:aria-label="i18n.baseText('instanceAi.previewTabBar.collapse')"
:title="i18n.baseText('instanceAi.previewTabBar.collapse')"
data-test-id="instance-ai-preview-close"
@click="emit('close')"
:aria-label="previewToggleLabel"
:title="previewToggleLabel"
:aria-pressed="true"
data-test-id="instance-ai-artifacts-preview-toggle"
@click="emit('togglePreview')"
/>
<TabsList
ref="tabListRef"
:aria-label="i18n.baseText('instanceAi.artifactsPanel.title')"
:class="$style.tabList"
>
@ -104,6 +156,15 @@ async function handleCopyLink(tab: ArtifactTab) {
</ContextMenuPortal>
</ContextMenuRoot>
</TabsList>
<N8nIconButton
:icon="isExpanded ? 'minimize-2' : 'maximize-2'"
variant="ghost"
size="medium"
:aria-label="sizeToggleLabel"
:title="sizeToggleLabel"
data-test-id="instance-ai-preview-expand-toggle"
@click="emit('toggleExpanded')"
/>
</div>
</template>
@ -139,16 +200,18 @@ async function handleCopyLink(tab: ArtifactTab) {
.header {
flex-shrink: 0;
height: 44px;
display: flex;
align-items: center;
gap: var(--spacing--4xs);
padding-left: var(--spacing--4xs);
padding: 0 var(--spacing--3xs) 0 var(--spacing--4xs);
border-bottom: var(--border);
}
.tabList {
flex: 1 1 0;
min-width: 0;
height: 100%;
display: flex;
overflow-x: auto;
scrollbar-width: none;
@ -176,7 +239,7 @@ async function handleCopyLink(tab: ArtifactTab) {
background-color: transparent;
border: none;
font-size: var(--font-size--2xs);
padding: var(--spacing--sm) var(--spacing--xs);
padding: 0 var(--spacing--xs);
cursor: pointer;
&:hover {

View File

@ -1,27 +1,20 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { N8nCallout, N8nIconButton, N8nTooltip, TOOLTIP_DELAY_MS } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { useInstanceAiStore } from '../instanceAi.store';
import { useSidebarState } from '../instanceAiLayout';
import { INSTANCE_AI_SETTINGS_VIEW } from '../constants';
import CreditsSettingsDropdown from '@/features/ai/assistant/components/Agent/CreditsSettingsDropdown.vue';
const store = useInstanceAiStore();
const sourceControlStore = useSourceControlStore();
const i18n = useI18n();
const router = useRouter();
const sidebar = useSidebarState();
const { goToUpgrade } = usePageRedirectionHelper();
const isReadOnlyEnvironment = computed(() => sourceControlStore.preferences.branchReadOnly);
function goToSettings() {
void router.push({ name: INSTANCE_AI_SETTINGS_VIEW });
}
</script>
<template>
@ -54,14 +47,6 @@ function goToSettings() {
:is-low-credits="store.isLowCredits"
@upgrade-click="goToUpgrade('instance-ai', 'upgrade-instance-ai')"
/>
<N8nIconButton
icon="cog"
variant="ghost"
size="small"
icon-size="large"
data-test-id="instance-ai-settings-button"
@click="goToSettings"
/>
<slot name="actions" />
</div>
</div>

View File

@ -8,6 +8,7 @@ import { inject } from 'vue';
*/
export interface SidebarState {
collapsed: Ref<boolean>;
width?: Ref<number>;
toggle: () => void;
}

View File

@ -0,0 +1,34 @@
import { nextTick, ref } from 'vue';
export function useTransitionGate(options: { isBlocked?: () => boolean } = {}) {
const isEnabled = ref(false);
let renderToken = 0;
function enableAfterStableRender() {
const currentRenderToken = ++renderToken;
void nextTick(() => {
void nextTick(() => {
if (currentRenderToken === renderToken && !(options.isBlocked?.() ?? false)) {
isEnabled.value = true;
}
});
});
}
function suppress() {
renderToken += 1;
isEnabled.value = false;
}
function suppressUntilStableRender() {
isEnabled.value = false;
enableAfterStableRender();
}
return {
isEnabled,
enableAfterStableRender,
suppress,
suppressUntilStableRender,
};
}

View File

@ -167,8 +167,12 @@ export class InstanceAiPage extends BasePage {
return this.getPreviewIframe().locator('[data-test-id="canvas-node-status-success"]');
}
getPreviewCloseButton(): Locator {
return this.container.getByTestId('instance-ai-preview-close');
getPreviewToggleButton(): Locator {
return this.getPreviewPanel().getByTestId('instance-ai-artifacts-preview-toggle');
}
getPreviewPanel(): Locator {
return this.container.getByTestId('instance-ai-preview-panel');
}
getPreviewIframeLocator(): Locator {
@ -185,6 +189,12 @@ export class InstanceAiPage extends BasePage {
);
}
async openPreviewNodeByName(nodeName: string): Promise<void> {
const node = this.getPreviewNodeByName(nodeName);
await node.waitFor({ state: 'visible', timeout: 10_000 });
await node.dblclick();
}
getPreviewExecuteNodeButton(nodeName: string): Locator {
return this.getPreviewNodeByName(nodeName).getByRole('button', { name: 'Execute step' });
}

View File

@ -41,8 +41,8 @@ test.describe(
timeout: 30_000,
});
// Close the preview first
await n8n.instanceAi.getPreviewCloseButton().click();
// Hide the preview first
await n8n.instanceAi.getPreviewToggleButton().click();
await expect(n8n.instanceAi.getPreviewIframeLocator()).toBeHidden();
// Click the artifact card to re-open the preview

View File

@ -107,8 +107,7 @@ test.describe(
});
// Double-click a node to open NDV
const setNode = n8n.instanceAi.getPreviewNodeByName('ndv output test');
await setNode.dblclick();
await n8n.instanceAi.openPreviewNodeByName('ndv output test');
// The NDV output panel should be visible with execution data
await expect(n8n.instanceAi.getPreviewNdvOutputPanel()).toBeVisible({

View File

@ -83,8 +83,8 @@ test.describe(
timeout: 120_000,
});
// Close the preview
await n8n.instanceAi.getPreviewCloseButton().click();
// Hide the preview
await n8n.instanceAi.getPreviewToggleButton().click();
// Preview iframe should no longer be visible
await expect(n8n.instanceAi.getPreviewIframeLocator()).toBeHidden();