mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
chore(editor): Allow narrower Assistant chat panels (#37861)
Co-authored-by: r00gm <raul00gm@gmail.com>
This commit is contained in:
parent
ab38a17bd4
commit
6acdf815ea
|
|
@ -25,6 +25,10 @@ export const LOCAL_STORAGE_CHAT_HUB_STATIC_SIDEBAR = (userId: string) =>
|
|||
`${userId}_N8N_CHAT_HUB_STATIC_SIDEBAR`;
|
||||
export const LOCAL_STORAGE_CHAT_HUB_HAD_CONVERSATION_BEFORE = (userId: string) =>
|
||||
`${userId}_N8N_CHAT_HUB_HAD_CONVERSATION_BEFORE`;
|
||||
export const LOCAL_STORAGE_INSTANCE_AI_ARTIFACT_PREVIEW_OPEN = (threadId: string) =>
|
||||
`N8N_INSTANCE_AI_ARTIFACT_PREVIEW_OPEN:${threadId}`;
|
||||
export const LOCAL_STORAGE_INSTANCE_AI_CHAT_PANEL_WIDTH_RATIO =
|
||||
'N8N_INSTANCE_AI_CHAT_PANEL_WIDTH_RATIO';
|
||||
export const LOCAL_STORAGE_SIDEBAR_WIDTH = 'N8N_SIDEBAR_WIDTH';
|
||||
export const LOCAL_STORAGE_BROWSER_NOTIFICATION_METADATA = 'N8N_BROWSER_NOTIFICATION_METADATA';
|
||||
export const LOCAL_STORAGE_FLOATING_CHAT_WINDOW = 'N8N_FLOATING_CHAT_WINDOW';
|
||||
|
|
|
|||
|
|
@ -21,7 +21,15 @@ import {
|
|||
N8nTooltip,
|
||||
TOOLTIP_DELAY_MS,
|
||||
} from '@n8n/design-system';
|
||||
import { onClickOutside, useElementSize, useScroll, useWindowSize } from '@vueuse/core';
|
||||
import {
|
||||
StorageSerializers,
|
||||
onClickOutside,
|
||||
useDebounceFn,
|
||||
useElementSize,
|
||||
useLocalStorage,
|
||||
useScroll,
|
||||
useWindowSize,
|
||||
} from '@vueuse/core';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type {
|
||||
InstanceAiAgentAttachment,
|
||||
|
|
@ -29,11 +37,17 @@ import type {
|
|||
InstanceAiHandoffContext,
|
||||
} from '@n8n/api-types';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import {
|
||||
DEBOUNCE_TIME,
|
||||
LOCAL_STORAGE_INSTANCE_AI_ARTIFACT_PREVIEW_OPEN,
|
||||
LOCAL_STORAGE_INSTANCE_AI_CHAT_PANEL_WIDTH_RATIO,
|
||||
} from '@/app/constants';
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
|
||||
import { COLLAPSED_MAIN_SIDEBAR_WIDTH, useSidebarLayout } from '@/app/composables/useSidebarLayout';
|
||||
// Experiment cleanup: remove with openWorkflowInAssistant.
|
||||
import { useOpenWorkflowInAssistantStore } from '@/experiments/openWorkflowInAssistant/stores/openWorkflowInAssistant.store';
|
||||
import { getDebounceTime } from '@n8n/composables/useDebounce';
|
||||
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
||||
import { TELEMETRY_EVENT } from '@n8n/telemetry';
|
||||
import { countAttachedNodes } from './utils/buildNodesAttachment';
|
||||
|
|
@ -284,11 +298,22 @@ watch(
|
|||
);
|
||||
|
||||
// --- Canvas / data table preview ---
|
||||
// null = no preference yet, so the first artifact still opens the preview.
|
||||
// Sync flush keeps a thread switch from exposing the old thread's value for a tick.
|
||||
const persistedArtifactPreviewOpen = useLocalStorage<boolean | null>(
|
||||
() => LOCAL_STORAGE_INSTANCE_AI_ARTIFACT_PREVIEW_OPEN(props.threadId),
|
||||
null,
|
||||
{ serializer: StorageSerializers.boolean, writeDefaults: false, flush: 'sync' },
|
||||
);
|
||||
const preview = useCanvasPreview({
|
||||
thread,
|
||||
threadId: () => props.threadId,
|
||||
initialAgentId: () =>
|
||||
getAgentBuilderTargetFromThreadMetadata(store.getThreadMetadata(props.threadId))?.agentId,
|
||||
previewOpenState: () => persistedArtifactPreviewOpen.value ?? undefined,
|
||||
onPreviewOpenChange: (open) => {
|
||||
persistedArtifactPreviewOpen.value = open;
|
||||
},
|
||||
});
|
||||
const activeAgentPreviewSessionId = computed(() => {
|
||||
const context = pendingComposerContext.value;
|
||||
|
|
@ -445,10 +470,16 @@ const shouldSuppressContentLayoutTransitions = computed(
|
|||
() => !isPreviewPanelTransitionEnabled.value,
|
||||
);
|
||||
const artifactsPanelSlotRef = useTemplateRef<HTMLElement>('artifactsPanelSlot');
|
||||
const preferredPreviewPanelWidth = ref(Math.round(threadAreaWidth.value / 2));
|
||||
const isResizingPreview = ref(false);
|
||||
const isThreadAreaResizing = ref(false);
|
||||
const isPreviewExpanded = ref(false);
|
||||
const isAgentPreviewDockOpen = ref(false);
|
||||
const MIN_SPLIT_PANEL_WIDTH = 400;
|
||||
const DEFAULT_CHAT_PANEL_CONTENT_WIDTH = 800;
|
||||
// Share of the thread area, so the split survives window and sidebar resizes. -1 = no preference yet.
|
||||
const chatPanelWidthRatio = useLocalStorage(LOCAL_STORAGE_INSTANCE_AI_CHAT_PANEL_WIDTH_RATIO, -1, {
|
||||
writeDefaults: false,
|
||||
});
|
||||
|
||||
watch(preview.activeTabId, (activeTabId, previousActiveTabId) => {
|
||||
if (activeTabId !== previousActiveTabId) {
|
||||
|
|
@ -456,11 +487,31 @@ watch(preview.activeTabId, (activeTabId, previousActiveTabId) => {
|
|||
}
|
||||
});
|
||||
|
||||
const previewMaxWidth = computed(() => Math.round(threadAreaWidth.value * 0.7));
|
||||
// Preserve the default or manually selected width while temporarily
|
||||
// constraining it to the available space.
|
||||
const previewPanelWidth = computed(() =>
|
||||
Math.min(preferredPreviewPanelWidth.value, previewMaxWidth.value),
|
||||
// Below two panel minimums the limits meet at half, so both panels share the space evenly.
|
||||
const halfThreadAreaWidth = computed(() => Math.round(threadAreaWidth.value / 2));
|
||||
const previewMinWidth = computed(() => Math.min(MIN_SPLIT_PANEL_WIDTH, halfThreadAreaWidth.value));
|
||||
const previewMaxWidth = computed(() =>
|
||||
Math.max(threadAreaWidth.value - MIN_SPLIT_PANEL_WIDTH, halfThreadAreaWidth.value),
|
||||
);
|
||||
const previewPanelWidth = computed(() => {
|
||||
const ratio = chatPanelWidthRatio.value;
|
||||
const chatPanelWidth =
|
||||
ratio >= 0 && ratio <= 1 ? threadAreaWidth.value * ratio : DEFAULT_CHAT_PANEL_CONTENT_WIDTH;
|
||||
return Math.round(
|
||||
Math.min(
|
||||
Math.max(threadAreaWidth.value - chatPanelWidth, previewMinWidth.value),
|
||||
previewMaxWidth.value,
|
||||
),
|
||||
);
|
||||
});
|
||||
const isPreviewResizeEnabled = computed(
|
||||
() => !isPreviewExpanded.value && previewMinWidth.value < previewMaxWidth.value,
|
||||
);
|
||||
const shouldAnimatePreviewLayout = computed(
|
||||
() =>
|
||||
isPreviewPanelTransitionEnabled.value &&
|
||||
!isResizingPreview.value &&
|
||||
!isThreadAreaResizing.value,
|
||||
);
|
||||
const AGENT_PREVIEW_CHAT_MIN_WIDTH = 320;
|
||||
const AGENT_PREVIEW_CHAT_PREFERRED_WIDTH = 480;
|
||||
|
|
@ -497,7 +548,9 @@ function handleAgentPreviewDockOpenChange(open: boolean) {
|
|||
}
|
||||
|
||||
function handlePreviewResize({ width }: { width: number }) {
|
||||
preferredPreviewPanelWidth.value = width;
|
||||
// The wrapper clamps the width, so an unchanged value means the drag hit a limit: keep the stored ratio.
|
||||
if (Math.round(width) === previewPanelWidth.value) return;
|
||||
chatPanelWidthRatio.value = (threadAreaWidth.value - width) / threadAreaWidth.value;
|
||||
}
|
||||
|
||||
function handlePreviewPanelAfterEnter() {
|
||||
|
|
@ -520,23 +573,34 @@ watch(
|
|||
isPreviewPanelTransitioning.value = isPreviewPanelTransitionEnabled.value;
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
isArtifactsPanelRevealed.value = false;
|
||||
preferredPreviewPanelWidth.value = previewMaxWidth.value;
|
||||
} else {
|
||||
if (!visible) {
|
||||
isAgentPreviewDockOpen.value = false;
|
||||
} else {
|
||||
isArtifactsPanelRevealed.value = false;
|
||||
}
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
|
||||
// Late-initialize if the panel became visible before the ResizeObserver
|
||||
// reported the container size (otherwise the panel would render at 0px).
|
||||
watch(threadAreaWidth, (width) => {
|
||||
if (width > 0 && preferredPreviewPanelWidth.value === 0 && preview.isPreviewVisible.value) {
|
||||
preferredPreviewPanelWidth.value = previewMaxWidth.value;
|
||||
}
|
||||
});
|
||||
const finishThreadAreaResize = useDebounceFn(() => {
|
||||
isThreadAreaResizing.value = false;
|
||||
}, getDebounceTime(DEBOUNCE_TIME.UI.RESIZE));
|
||||
|
||||
watch(
|
||||
threadAreaWidth,
|
||||
(width, previousWidth) => {
|
||||
if (
|
||||
typeof previousWidth === 'number' &&
|
||||
previousWidth > 0 &&
|
||||
width !== previousWidth &&
|
||||
preview.isPreviewVisible.value
|
||||
) {
|
||||
isThreadAreaResizing.value = true;
|
||||
void finishThreadAreaResize();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(isArtifactsPanelInLayout, (isInLayout) => {
|
||||
isArtifactsPanelRevealed.value = false;
|
||||
|
|
@ -1100,10 +1164,10 @@ async function dismissComposerContextChip() {
|
|||
:class="[
|
||||
$style.chatArea,
|
||||
{
|
||||
[$style.agentPreviewLayoutTransition]: isPreviewPanelTransitionEnabled,
|
||||
[$style.agentPreviewLayoutTransition]: shouldAnimatePreviewLayout,
|
||||
},
|
||||
]"
|
||||
:data-layout-animated="isPreviewPanelTransitionEnabled"
|
||||
:data-layout-animated="shouldAnimatePreviewLayout"
|
||||
data-test-id="instance-ai-builder-chat"
|
||||
>
|
||||
<div :class="$style.builderChatHeader" data-test-id="instance-ai-builder-chat-header">
|
||||
|
|
@ -1363,8 +1427,7 @@ async function dismissComposerContextChip() {
|
|||
$style.canvasArea,
|
||||
{
|
||||
[$style.canvasAreaExpanded]: isPreviewExpanded,
|
||||
[$style.agentPreviewLayoutTransition]:
|
||||
isPreviewPanelTransitionEnabled && !isResizingPreview,
|
||||
[$style.agentPreviewLayoutTransition]: shouldAnimatePreviewLayout,
|
||||
},
|
||||
]"
|
||||
:style="agentPreviewPanelStyle"
|
||||
|
|
@ -1373,10 +1436,10 @@ async function dismissComposerContextChip() {
|
|||
>
|
||||
<N8nResizeWrapper
|
||||
:width="previewPanelWidth"
|
||||
:min-width="400"
|
||||
:min-width="previewMinWidth"
|
||||
:max-width="previewMaxWidth"
|
||||
:supported-directions="['left']"
|
||||
:is-resizing-enabled="!isPreviewExpanded"
|
||||
:is-resizing-enabled="isPreviewResizeEnabled"
|
||||
:grid-size="8"
|
||||
@resize="handlePreviewResize"
|
||||
@resizestart="isResizingPreview = true"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
|
|||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { SidebarStateKey } from '../instanceAiLayout';
|
||||
import { NEW_CONVERSATION_TITLE } from '../constants';
|
||||
import {
|
||||
LOCAL_STORAGE_INSTANCE_AI_ARTIFACT_PREVIEW_OPEN,
|
||||
LOCAL_STORAGE_INSTANCE_AI_CHAT_PANEL_WIDTH_RATIO,
|
||||
} from '@/app/constants';
|
||||
import type { WorkflowFailuresReport } from '../components/InstanceAiWorkflowPreview.vue';
|
||||
import type {
|
||||
FrontendModuleSettings,
|
||||
|
|
@ -1520,7 +1524,7 @@ describe('InstanceAiThreadView', () => {
|
|||
expect(thread.loadHistoricalMessages).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('opens the artifacts panel from the header toggle when too narrow for pinned artifacts', async () => {
|
||||
it('opens artifacts when narrow and restores them in the pinned layout', async () => {
|
||||
mockWindowSizeState.width.value = 900;
|
||||
thread.messages = [
|
||||
{
|
||||
|
|
@ -1549,6 +1553,12 @@ describe('InstanceAiThreadView', () => {
|
|||
await user.click(getByTestId('instance-ai-content-area'));
|
||||
|
||||
expect(queryByTestId('instance-ai-artifacts-sidebar-slot')).not.toBeInTheDocument();
|
||||
|
||||
mockWindowSizeState.width.value = 1700;
|
||||
await vi.waitFor(() => {
|
||||
expect(getByTestId('instance-ai-artifacts-sidebar-slot')).toBeInTheDocument();
|
||||
});
|
||||
expect(store.updateThreadMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the artifacts panel toggle available when the panel is in the layout', async () => {
|
||||
|
|
@ -1581,6 +1591,18 @@ describe('InstanceAiThreadView', () => {
|
|||
expect(getByTestId('instance-ai-artifacts-sidebar-slot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('restores an open preview when the artifact has no message attachment', async () => {
|
||||
thread.producedArtifacts = new Map([
|
||||
['workflow-1', { type: 'workflow', id: 'workflow-1', name: 'Lead enrichment workflow' }],
|
||||
]) as typeof thread.producedArtifacts;
|
||||
localStorage.setItem(LOCAL_STORAGE_INSTANCE_AI_ARTIFACT_PREVIEW_OPEN('thread-1'), 'true');
|
||||
|
||||
const { findByTestId } = renderView({ props: { threadId: 'thread-1' } });
|
||||
|
||||
expect(await findByTestId('instance-ai-workflow-preview-stub')).toBeInTheDocument();
|
||||
expect(await findByTestId('instance-ai-preview-panel')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the agent artifact preview when an agent is created', async () => {
|
||||
thread.producedArtifacts = new Map([
|
||||
['agent-1', { type: 'agent', id: 'agent-1', projectId: 'proj-1', name: 'SEO Auditor' }],
|
||||
|
|
@ -1649,8 +1671,8 @@ describe('InstanceAiThreadView', () => {
|
|||
const threadArea = getByTestId('instance-ai-thread-area');
|
||||
const header = getByTestId('instance-ai-builder-chat-header');
|
||||
const content = getByTestId('instance-ai-content-area');
|
||||
expect(previewPanel.style.width).toBe('600px');
|
||||
expect(previewPanel.style.getPropertyValue('--agent-preview-chat-column-width')).toBe('300px');
|
||||
expect(previewPanel.style.width).toBe('400px');
|
||||
expect(previewPanel.style.getPropertyValue('--agent-preview-chat-column-width')).toBe('200px');
|
||||
await vi.waitFor(() => {
|
||||
expect(previewPanel).toHaveClass('agentPreviewLayoutTransition');
|
||||
});
|
||||
|
|
@ -1658,20 +1680,20 @@ describe('InstanceAiThreadView', () => {
|
|||
await user.click(getByTestId('instance-ai-agent-preview-open-dock'));
|
||||
|
||||
expect(threadArea).toHaveClass('agentPreviewDockOpen');
|
||||
expect(previewPanel.style.width).toBe('600px');
|
||||
expect(previewPanel.style.getPropertyValue('--agent-preview-chat-column-width')).toBe('300px');
|
||||
expect(previewPanel.style.width).toBe('400px');
|
||||
expect(previewPanel.style.getPropertyValue('--agent-preview-chat-column-width')).toBe('200px');
|
||||
expect(queryByTestId('resize-handle')).toBeInTheDocument();
|
||||
|
||||
await user.click(getByTestId('instance-ai-agent-preview-close-dock'));
|
||||
|
||||
expect(threadArea).not.toHaveClass('agentPreviewDockOpen');
|
||||
expect(previewPanel.style.width).toBe('600px');
|
||||
expect(previewPanel.style.width).toBe('400px');
|
||||
expect(queryByTestId('resize-handle')).toBeInTheDocument();
|
||||
|
||||
await fireEvent.mouseDown(getByTestId('resize-handle'), { clientX: 0 });
|
||||
|
||||
expect(previewPanel).not.toHaveClass('agentPreviewLayoutTransition');
|
||||
await fireEvent.mouseMove(window, { clientX: 120 });
|
||||
await fireEvent.mouseMove(window, { clientX: -80 });
|
||||
expect(previewPanel.style.width).toBe('480px');
|
||||
expect(previewPanel.style.getPropertyValue('--agent-preview-chat-column-width')).toBe('240px');
|
||||
|
||||
|
|
@ -1680,6 +1702,9 @@ describe('InstanceAiThreadView', () => {
|
|||
await vi.waitFor(() => {
|
||||
expect(previewPanel).toHaveClass('agentPreviewLayoutTransition');
|
||||
});
|
||||
expect(localStorageState.store.get(LOCAL_STORAGE_INSTANCE_AI_CHAT_PANEL_WIDTH_RATIO)).toBe(
|
||||
'0.6',
|
||||
);
|
||||
|
||||
await user.click(getByTestId('instance-ai-agent-preview-open-dock'));
|
||||
|
||||
|
|
@ -1701,26 +1726,54 @@ describe('InstanceAiThreadView', () => {
|
|||
const { getByTestId } = await renderAgentArtifact({ threadAreaWidth: 1200 });
|
||||
const previewPanel = getByTestId('instance-ai-preview-panel');
|
||||
|
||||
expect(previewPanel.style.width).toBe('600px');
|
||||
expect(previewPanel.style.width).toBe('400px');
|
||||
|
||||
mockThreadAreaSizeState.width.value = 800;
|
||||
await vi.waitFor(() => expect(previewPanel.style.width).toBe('560px'));
|
||||
await vi.waitFor(() => expect(previewPanel.style.width).toBe('400px'));
|
||||
|
||||
mockThreadAreaSizeState.width.value = 1200;
|
||||
await vi.waitFor(() => expect(previewPanel.style.width).toBe('600px'));
|
||||
await vi.waitFor(() => expect(previewPanel.style.width).toBe('400px'));
|
||||
|
||||
await fireEvent.mouseDown(getByTestId('resize-handle'), { clientX: 0 });
|
||||
await fireEvent.mouseMove(window, { clientX: 120 });
|
||||
await fireEvent.mouseMove(window, { clientX: -80 });
|
||||
await fireEvent.mouseUp(window);
|
||||
expect(previewPanel.style.width).toBe('480px');
|
||||
|
||||
mockThreadAreaSizeState.width.value = 600;
|
||||
await vi.waitFor(() => expect(previewPanel.style.width).toBe('420px'));
|
||||
await vi.waitFor(() => expect(previewPanel.style.width).toBe('300px'));
|
||||
|
||||
mockThreadAreaSizeState.width.value = 1200;
|
||||
await vi.waitFor(() => expect(previewPanel.style.width).toBe('480px'));
|
||||
});
|
||||
|
||||
it('keeps the default ratio when dragging against the minimum width', async () => {
|
||||
const { getByTestId } = await renderAgentArtifact({ threadAreaWidth: 1200 });
|
||||
const previewPanel = getByTestId('instance-ai-preview-panel');
|
||||
|
||||
expect(previewPanel.style.width).toBe('400px');
|
||||
|
||||
await fireEvent.mouseDown(getByTestId('resize-handle'), { clientX: 0 });
|
||||
await fireEvent.mouseMove(window, { clientX: 120 });
|
||||
await fireEvent.mouseUp(window);
|
||||
|
||||
mockThreadAreaSizeState.width.value = 1600;
|
||||
await vi.waitFor(() => expect(previewPanel.style.width).toBe('800px'));
|
||||
});
|
||||
|
||||
it('restores the stored chat panel width ratio', async () => {
|
||||
localStorageState.store.set(LOCAL_STORAGE_INSTANCE_AI_CHAT_PANEL_WIDTH_RATIO, '0.6');
|
||||
const { getByTestId } = await renderAgentArtifact({ threadAreaWidth: 1200 });
|
||||
|
||||
expect(getByTestId('instance-ai-preview-panel').style.width).toBe('480px');
|
||||
});
|
||||
|
||||
it('falls back to the default chat panel width when the stored ratio is not a number', async () => {
|
||||
localStorageState.store.set(LOCAL_STORAGE_INSTANCE_AI_CHAT_PANEL_WIDTH_RATIO, 'null');
|
||||
const { getByTestId } = await renderAgentArtifact({ threadAreaWidth: 1200 });
|
||||
|
||||
expect(getByTestId('instance-ai-preview-panel').style.width).toBe('400px');
|
||||
});
|
||||
|
||||
it('keeps expanded preview state independent from the agent dock', async () => {
|
||||
const { getByTestId, user } = await renderAgentArtifact();
|
||||
const previewPanel = getByTestId('instance-ai-preview-panel');
|
||||
|
|
@ -1859,7 +1912,7 @@ describe('InstanceAiThreadView', () => {
|
|||
expect(getByTestId('instance-ai-thread-area')).not.toHaveClass('agentPreviewDockOpen');
|
||||
});
|
||||
|
||||
it('keeps the new-agent artifact accessible when closed and restores it after refresh', async () => {
|
||||
it('keeps the new-agent artifact accessible and restores the closed preview after refresh', async () => {
|
||||
mockWindowSizeState.width.value = 1700;
|
||||
thread.producedArtifacts = new Map([
|
||||
[
|
||||
|
|
@ -1889,6 +1942,9 @@ describe('InstanceAiThreadView', () => {
|
|||
expect(firstRender.queryByTestId('instance-ai-agent-preview-stub')).not.toBeInTheDocument();
|
||||
expect(firstRender.getByTestId('instance-ai-artifacts-sidebar-slot')).toBeInTheDocument();
|
||||
expect(firstRender.getByTestId('instance-ai-artifacts-panel-toggle')).toBeInTheDocument();
|
||||
expect(localStorage.getItem(LOCAL_STORAGE_INSTANCE_AI_ARTIFACT_PREVIEW_OPEN('thread-1'))).toBe(
|
||||
'false',
|
||||
);
|
||||
|
||||
store.threads = [
|
||||
{
|
||||
|
|
@ -1916,10 +1972,10 @@ describe('InstanceAiThreadView', () => {
|
|||
|
||||
firstRender.unmount();
|
||||
const refreshedRender = renderView({ props: { threadId: 'thread-1' } });
|
||||
const restoredPreview = await refreshedRender.findByTestId('instance-ai-agent-preview-stub');
|
||||
|
||||
expect(restoredPreview).toHaveAttribute('data-agent-id', 'agent-1');
|
||||
expect(await refreshedRender.findByTestId('instance-ai-preview-panel')).toBeVisible();
|
||||
expect(refreshedRender.queryByTestId('instance-ai-agent-preview-stub')).not.toBeInTheDocument();
|
||||
expect(await refreshedRender.findByTestId('instance-ai-preview-panel')).not.toBeVisible();
|
||||
expect(refreshedRender.getByTestId('instance-ai-artifacts-sidebar-slot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Fix with AI card', () => {
|
||||
|
|
|
|||
|
|
@ -132,7 +132,11 @@ function createMockRoute(threadId = 'thread-1') {
|
|||
// Test helper — create composable + flush
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function setup(options?: { threadOverrides?: Partial<MockThread> }) {
|
||||
function setup(options?: {
|
||||
threadOverrides?: Partial<MockThread>;
|
||||
initialAgentId?: () => string | undefined;
|
||||
previewOpenState?: () => boolean | undefined;
|
||||
}) {
|
||||
const thread = createMockThread();
|
||||
if (options?.threadOverrides) Object.assign(thread, options.threadOverrides);
|
||||
const route = createMockRoute();
|
||||
|
|
@ -141,6 +145,8 @@ function setup(options?: { threadOverrides?: Partial<MockThread> }) {
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
thread: thread as any,
|
||||
threadId: () => route.params.threadId,
|
||||
initialAgentId: options?.initialAgentId,
|
||||
previewOpenState: options?.previewOpenState,
|
||||
});
|
||||
|
||||
return { ...result, thread, route };
|
||||
|
|
@ -1106,6 +1112,31 @@ describe('useCanvasPreview', () => {
|
|||
expect(ctx.activeTabId.value).toBe('agent-1');
|
||||
expect(ctx.isPreviewVisible.value).toBe(true);
|
||||
});
|
||||
|
||||
test('restores the attached agent instead of an earlier helper workflow', async () => {
|
||||
const ctx = setup({ previewOpenState: () => true });
|
||||
ctx.thread.isHydratingThread = true;
|
||||
registerWorkflow(ctx.thread, 'workflow-1', 'Helper workflow');
|
||||
registerAgent(ctx.thread, 'agent-1', 'Support Agent', 'proj-1');
|
||||
ctx.thread.messages = [
|
||||
makeMessage({
|
||||
role: 'user',
|
||||
attachments: [
|
||||
{
|
||||
type: 'agent',
|
||||
id: 'agent-1',
|
||||
name: 'Support Agent',
|
||||
projectId: 'proj-1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
ctx.thread.isHydratingThread = false;
|
||||
await nextTick();
|
||||
|
||||
expect(ctx.activeTabId.value).toBe('agent-1');
|
||||
expect(ctx.activeAgentId.value).toBe('agent-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPreviewVisible', () => {
|
||||
|
|
|
|||
|
|
@ -38,12 +38,33 @@ interface UseCanvasPreviewOptions {
|
|||
thread: ThreadRuntime;
|
||||
threadId: () => string;
|
||||
initialAgentId?: () => string | undefined;
|
||||
previewOpenState?: () => boolean | undefined;
|
||||
onPreviewOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function useCanvasPreview({ thread, initialAgentId }: UseCanvasPreviewOptions) {
|
||||
export function useCanvasPreview({
|
||||
thread,
|
||||
initialAgentId,
|
||||
previewOpenState,
|
||||
onPreviewOpenChange,
|
||||
}: UseCanvasPreviewOptions) {
|
||||
// --- Tab state ---
|
||||
const activeTabId = ref<string>();
|
||||
const isPreviewOpen = ref(false);
|
||||
const isPreviewOpen = ref(previewOpenState?.() ?? false);
|
||||
|
||||
function setPreviewOpen(open: boolean, persist = true) {
|
||||
if (isPreviewOpen.value === open) return;
|
||||
isPreviewOpen.value = open;
|
||||
if (persist) onPreviewOpenChange?.(open);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => previewOpenState?.(),
|
||||
(open) => {
|
||||
if (typeof open === 'boolean') setPreviewOpen(open, false);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const buildingArtifactIds = useBuildingArtifactIds(thread);
|
||||
|
||||
|
|
@ -152,7 +173,23 @@ export function useCanvasPreview({ thread, initialAgentId }: UseCanvasPreviewOpt
|
|||
(id) => {
|
||||
if (!id || activeTabId.value !== undefined) return;
|
||||
activeTabId.value = id;
|
||||
isPreviewOpen.value = true;
|
||||
if (previewOpenState?.() !== false) setPreviewOpen(true, false);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
[
|
||||
() => previewOpenState?.(),
|
||||
allArtifactTabs,
|
||||
() => thread.isHydratingThread,
|
||||
initialArtifactId,
|
||||
],
|
||||
([open, tabs, isHydrating, initialId]) => {
|
||||
if (isHydrating) return;
|
||||
if (open === true && activeTabId.value === undefined && tabs[0]) {
|
||||
activeTabId.value = tabs.some((tab) => tab.id === initialId) ? initialId : tabs[0].id;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
|
@ -161,11 +198,11 @@ export function useCanvasPreview({ thread, initialAgentId }: UseCanvasPreviewOpt
|
|||
|
||||
function selectTab(tabId: string) {
|
||||
activeTabId.value = tabId;
|
||||
isPreviewOpen.value = true;
|
||||
setPreviewOpen(true);
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
isPreviewOpen.value = false;
|
||||
setPreviewOpen(false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -176,7 +213,7 @@ export function useCanvasPreview({ thread, initialAgentId }: UseCanvasPreviewOpt
|
|||
function openWorkflowPreview(workflowId: string): boolean {
|
||||
if (activeTabId.value === workflowId && isPreviewOpen.value) return false;
|
||||
activeTabId.value = workflowId;
|
||||
isPreviewOpen.value = true;
|
||||
setPreviewOpen(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -188,7 +225,7 @@ export function useCanvasPreview({ thread, initialAgentId }: UseCanvasPreviewOpt
|
|||
function openDataTablePreview(dataTableId: string, _projectId: string): boolean {
|
||||
if (activeTabId.value === dataTableId && isPreviewOpen.value) return false;
|
||||
activeTabId.value = dataTableId;
|
||||
isPreviewOpen.value = true;
|
||||
setPreviewOpen(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -200,7 +237,7 @@ export function useCanvasPreview({ thread, initialAgentId }: UseCanvasPreviewOpt
|
|||
function openAgentPreview(agentId: string, _projectId: string): boolean {
|
||||
if (activeTabId.value === agentId && isPreviewOpen.value) return false;
|
||||
activeTabId.value = agentId;
|
||||
isPreviewOpen.value = true;
|
||||
setPreviewOpen(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -247,7 +284,7 @@ export function useCanvasPreview({ thread, initialAgentId }: UseCanvasPreviewOpt
|
|||
if (thread.isHydratingThread) return;
|
||||
|
||||
activeTabId.value = latestBuildResult.value.workflowId;
|
||||
isPreviewOpen.value = true;
|
||||
setPreviewOpen(true);
|
||||
workflowRefreshKey.value++;
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
|
|
@ -278,7 +315,7 @@ export function useCanvasPreview({ thread, initialAgentId }: UseCanvasPreviewOpt
|
|||
if (thread.isHydratingThread) return;
|
||||
|
||||
activeTabId.value = latestBuilderTarget.value.workflowId;
|
||||
isPreviewOpen.value = true;
|
||||
setPreviewOpen(true);
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
|
|
@ -307,7 +344,7 @@ export function useCanvasPreview({ thread, initialAgentId }: UseCanvasPreviewOpt
|
|||
if (thread.isHydratingThread) return;
|
||||
|
||||
activeTabId.value = latestAgentBuilderTarget.value.targetAgentId;
|
||||
isPreviewOpen.value = true;
|
||||
setPreviewOpen(true);
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
|
|
@ -367,7 +404,7 @@ export function useCanvasPreview({ thread, initialAgentId }: UseCanvasPreviewOpt
|
|||
const targetId = latestUpdateResult.value.workflowId;
|
||||
|
||||
activeTabId.value = targetId;
|
||||
isPreviewOpen.value = true;
|
||||
setPreviewOpen(true);
|
||||
workflowRefreshKey.value++;
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
|
|
@ -393,7 +430,7 @@ export function useCanvasPreview({ thread, initialAgentId }: UseCanvasPreviewOpt
|
|||
if (thread.isHydratingThread) return;
|
||||
|
||||
activeTabId.value = latestDataTableResult.value.dataTableId;
|
||||
isPreviewOpen.value = true;
|
||||
setPreviewOpen(true);
|
||||
dataTableRefreshKey.value++;
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
|
|
@ -417,7 +454,7 @@ export function useCanvasPreview({ thread, initialAgentId }: UseCanvasPreviewOpt
|
|||
const remaining = allArtifactTabs.value.filter((t) => t.id !== deletedId);
|
||||
activeTabId.value = remaining.length > 0 ? remaining[0].id : undefined;
|
||||
if (!activeTabId.value) {
|
||||
isPreviewOpen.value = false;
|
||||
setPreviewOpen(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user