mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 18:49:20 +02:00
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:
parent
94a3220de8
commit
202743d8a3
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { inject } from 'vue';
|
|||
*/
|
||||
export interface SidebarState {
|
||||
collapsed: Ref<boolean>;
|
||||
width?: Ref<number>;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user