diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue
index c70a0aa4e20..4b042c1b55c 100644
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue
@@ -962,6 +962,7 @@ function handleNewThreadClick() {
:project-id="thread.projectId"
@close-tab="preview.closeTab"
@open-tab="preview.openTab"
+ @reorder-tab="preview.reorderTab"
/>
{
expect(ctx.activeTabId.value).toBe('wf-1');
});
+ test('reorders the tabs and keeps the active tab', () => {
+ const ctx = setup();
+ registerWorkflow(ctx.thread, 'wf-1');
+ registerWorkflow(ctx.thread, 'wf-2');
+ registerWorkflow(ctx.thread, 'wf-3');
+ ctx.selectTab('wf-2');
+
+ ctx.reorderTab('wf-3', 0);
+
+ expect(ctx.openTabs.value.map((tab) => tab.id)).toEqual(['wf-3', 'wf-1', 'wf-2']);
+ expect(ctx.activeTabId.value).toBe('wf-2');
+ });
+
+ test('saves the new order and the active tab after a reorder', async () => {
+ vi.useFakeTimers();
+ try {
+ const save = vi.fn().mockResolvedValue(undefined);
+ const ctx = setup({
+ tabsStorage: { load: vi.fn().mockResolvedValue(null), save },
+ });
+ await flushPromises();
+ registerWorkflow(ctx.thread, 'wf-1');
+ registerWorkflow(ctx.thread, 'wf-2');
+ registerWorkflow(ctx.thread, 'wf-3');
+ ctx.selectTab('wf-2');
+ // Let the save from the selection finish, so only the reorder can save below.
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_TIME.API.AUTOSAVE);
+ save.mockClear();
+
+ ctx.reorderTab('wf-3', 0);
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_TIME.API.AUTOSAVE);
+
+ expect(save).toHaveBeenCalledTimes(1);
+ expect(save).toHaveBeenCalledWith({
+ tabs: [
+ { type: 'workflow', id: 'wf-3', name: 'Workflow wf-3' },
+ { type: 'workflow', id: 'wf-1', name: 'Workflow wf-1' },
+ { type: 'workflow', id: 'wf-2', name: 'Workflow wf-2' },
+ ],
+ closedTabs: [],
+ activeTab: { type: 'workflow', id: 'wf-2' },
+ previewOpen: true,
+ });
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
test('shows a resource picked from the project in a new tab', () => {
const ctx = setup();
registerWorkflow(ctx.thread, 'wf-1');
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useOpenArtifactTabs.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useOpenArtifactTabs.test.ts
index 94e09f7acad..be26f088247 100644
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useOpenArtifactTabs.test.ts
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useOpenArtifactTabs.test.ts
@@ -215,6 +215,23 @@ describe('useOpenArtifactTabs', () => {
expect(tabs.openTabs.value.map((tab) => tab.id)).toEqual(saved.tabs.map((tab) => tab.id));
});
+ it('moves a tab to a new position and keeps the closed tabs', () => {
+ const { tabs } = setup([
+ workflowTab('wf-1'),
+ workflowTab('wf-2'),
+ workflowTab('wf-3'),
+ workflowTab('wf-4'),
+ ]);
+ tabs.closeTab('wf-4');
+
+ tabs.moveTab('wf-3', 0);
+ expect(ids(tabs.openTabs.value)).toEqual(['wf-3', 'wf-1', 'wf-2']);
+
+ tabs.moveTab('wf-3', 99);
+ expect(ids(tabs.openTabs.value)).toEqual(['wf-1', 'wf-2', 'wf-3']);
+ expect(tabs.reopenTab('wf-4')).toBe(true);
+ });
+
it('waits for the save delay and sends one save for many changes', async () => {
const { storage, save, finishLoad } = createStorage();
const { tabs } = setup([workflowTab('wf-1'), workflowTab('wf-2')], storage);
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useTabDragReorder.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useTabDragReorder.test.ts
new file mode 100644
index 00000000000..13f420a6f34
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useTabDragReorder.test.ts
@@ -0,0 +1,312 @@
+import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
+import { effectScope, type EffectScope } from 'vue';
+import { fireEvent } from '@testing-library/vue';
+import { TAB_DRAG_IGNORE_ATTRIBUTE, useTabDragReorder } from '../composables/useTabDragReorder';
+
+const GAP = 4;
+const pointer = { pointerId: 1, pointerType: 'mouse' };
+
+// jsdom has no layout, so each tab gets a fixed box: [id, width].
+function createTabs(layout: Array<[string, number]>) {
+ let left = 0;
+ return layout.map(([id, width]) => {
+ const element = document.createElement('div');
+ element.dataset.tabItemId = id;
+ const box = { left, width };
+ element.getBoundingClientRect = () =>
+ ({ left: box.left, width: box.width, right: box.left + box.width }) as DOMRect;
+ left += width + GAP;
+ return element;
+ });
+}
+
+describe('useTabDragReorder', () => {
+ let scope: EffectScope;
+ let frameCallbacks: FrameRequestCallback[] = [];
+
+ // Positions update once per animation frame; tests draw a frame on demand.
+ function flushFrame() {
+ const callbacks = frameCallbacks;
+ frameCallbacks = [];
+ callbacks.forEach((callback) => callback(0));
+ }
+
+ beforeEach(() => {
+ frameCallbacks = [];
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
+ frameCallbacks.push(callback);
+ return frameCallbacks.length;
+ });
+ vi.stubGlobal('cancelAnimationFrame', () => {
+ frameCallbacks = [];
+ });
+ });
+
+ function setup(layout: Array<[string, number]>) {
+ const elements = createTabs(layout);
+ // Attached, so events on a tab bubble up to the window like in the app.
+ elements.forEach((element) => document.body.appendChild(element));
+ const onReorder = vi.fn();
+ const onDragStart = vi.fn();
+ scope = effectScope();
+ const drag = scope.run(() =>
+ useTabDragReorder({ getTabElements: () => elements, onReorder, onDragStart }),
+ )!;
+ // The tab bar binds each tab's pointerdown to the composable the same way.
+ elements.forEach((element) =>
+ element.addEventListener('pointerdown', (event) =>
+ drag.onPointerDown(element.dataset.tabItemId ?? '', event),
+ ),
+ );
+
+ function press(
+ tabId: string,
+ clientX: number,
+ init: { button?: number; pointerType?: string; target?: Element } = {},
+ ) {
+ const tab = elements.find((element) => element.dataset.tabItemId === tabId);
+ const { target = tab, ...eventInit } = init;
+ if (target) fireEvent.pointerDown(target, { clientX, ...pointer, ...eventInit });
+ }
+ const move = (clientX: number) => {
+ fireEvent.pointerMove(window, { clientX, ...pointer });
+ flushFrame();
+ };
+ const transforms = () =>
+ Object.fromEntries(
+ elements.map((element) => [element.dataset.tabItemId, element.style.transform]),
+ );
+ const release = () => fireEvent.pointerUp(window, pointer);
+
+ return { ...drag, elements, onReorder, onDragStart, press, move, release, transforms };
+ }
+
+ afterEach(() => {
+ scope?.stop();
+ vi.unstubAllGlobals();
+ document.body.innerHTML = '';
+ });
+
+ it('does not start a drag for a small movement', () => {
+ const ctx = setup([
+ ['a', 100],
+ ['b', 100],
+ ]);
+
+ ctx.press('a', 50);
+ ctx.move(53);
+ ctx.release();
+
+ expect(ctx.onDragStart).not.toHaveBeenCalled();
+ expect(ctx.onReorder).not.toHaveBeenCalled();
+ });
+
+ it('moves the other tabs aside and drops the tab after a tab it passes to the right', () => {
+ const ctx = setup([
+ ['a', 100],
+ ['b', 100],
+ ['c', 100],
+ ]);
+
+ ctx.press('a', 50);
+ ctx.move(110);
+
+ expect(ctx.onDragStart).toHaveBeenCalledTimes(1);
+ expect(ctx.draggedTabId.value).toBe('a');
+ expect(ctx.transforms()).toEqual({ a: 'translateX(60px)', b: 'translateX(-104px)', c: '' });
+
+ ctx.release();
+
+ expect(ctx.onReorder).toHaveBeenCalledWith('a', 1);
+ expect(ctx.draggedTabId.value).toBeUndefined();
+ expect(ctx.transforms()).toEqual({ a: '', b: '', c: '' });
+ expect(ctx.elements.map((element) => element.style.transition)).toEqual(['', '', '']);
+ });
+
+ it('moves the tabs once per frame, however many pointer moves arrive', () => {
+ const ctx = setup([
+ ['a', 100],
+ ['b', 100],
+ ]);
+ ctx.press('a', 50);
+
+ fireEvent.pointerMove(window, { clientX: 60, ...pointer });
+ fireEvent.pointerMove(window, { clientX: 70, ...pointer });
+ expect(frameCallbacks).toHaveLength(1);
+ expect(ctx.transforms().a).toBe('');
+
+ flushFrame();
+ expect(ctx.transforms().a).toBe('translateX(20px)');
+ });
+
+ it('drops at the last pointer position when the pointer is released before the next frame', () => {
+ const ctx = setup([
+ ['a', 100],
+ ['b', 100],
+ ]);
+ ctx.press('a', 50);
+
+ fireEvent.pointerMove(window, { clientX: 200, ...pointer });
+ ctx.release();
+
+ expect(ctx.onReorder).toHaveBeenCalledWith('a', 1);
+ });
+
+ it('drops the tab before a tab it passes to the left', () => {
+ const ctx = setup([
+ ['a', 100],
+ ['b', 100],
+ ['c', 100],
+ ]);
+
+ ctx.press('c', 250);
+ ctx.move(140);
+ ctx.release();
+
+ expect(ctx.onReorder).toHaveBeenCalledWith('c', 1);
+ });
+
+ it('moves the second tab to the first place when the first tab is narrower', () => {
+ const ctx = setup([
+ ['narrow', 60],
+ ['wide', 200],
+ ['other', 100],
+ ]);
+
+ // Drag well past the start of the row. The tab stops at the first tab's left edge.
+ ctx.press('wide', 150);
+ ctx.move(-200);
+
+ expect(ctx.transforms().wide).toBe('translateX(-64px)');
+ ctx.release();
+
+ expect(ctx.onReorder).toHaveBeenCalledWith('wide', 0);
+ });
+
+ it('moves a tab to the last place when the last tab is narrower', () => {
+ const ctx = setup([
+ ['wide', 200],
+ ['middle', 100],
+ ['narrow', 60],
+ ]);
+
+ ctx.press('wide', 100);
+ ctx.move(1000);
+ ctx.release();
+
+ expect(ctx.onReorder).toHaveBeenCalledWith('wide', 2);
+ });
+
+ describe('when the tabs change during a drag', () => {
+ it('drops next to the passed tab when a tab opens before it', () => {
+ const ctx = setup([
+ ['a', 100],
+ ['b', 100],
+ ['c', 100],
+ ]);
+ ctx.press('a', 50);
+ ctx.move(160);
+
+ // The agent opens a tab at the start while the pointer is down.
+ const [opened] = createTabs([['new', 100]]);
+ ctx.elements.unshift(opened);
+ ctx.release();
+
+ // "a" passed "b", so it lands after "b": index 2 in [new, a, b, c].
+ expect(ctx.onReorder).toHaveBeenCalledWith('a', 2);
+ });
+
+ it('does not reorder when the passed tab closes', () => {
+ const ctx = setup([
+ ['a', 100],
+ ['b', 100],
+ ['c', 100],
+ ]);
+ ctx.press('a', 50);
+ ctx.move(160);
+
+ ctx.elements.splice(1, 1);
+ ctx.release();
+
+ expect(ctx.onReorder).not.toHaveBeenCalled();
+ });
+
+ it('does not reorder when the dragged tab closes', () => {
+ const ctx = setup([
+ ['a', 100],
+ ['b', 100],
+ ]);
+ ctx.press('a', 50);
+ ctx.move(160);
+
+ ctx.elements.splice(0, 1);
+ ctx.release();
+
+ expect(ctx.onReorder).not.toHaveBeenCalled();
+ });
+ });
+
+ it('does not reorder when the tab is dropped in its own place', () => {
+ const ctx = setup([
+ ['a', 100],
+ ['b', 100],
+ ]);
+
+ ctx.press('a', 50);
+ ctx.move(70);
+ ctx.release();
+
+ expect(ctx.onDragStart).toHaveBeenCalled();
+ expect(ctx.onReorder).not.toHaveBeenCalled();
+ });
+
+ it('cancels the drag when Escape is pressed', () => {
+ const ctx = setup([
+ ['a', 100],
+ ['b', 100],
+ ]);
+
+ ctx.press('a', 50);
+ ctx.move(200);
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+ ctx.release();
+
+ expect(ctx.onReorder).not.toHaveBeenCalled();
+ expect(ctx.draggedTabId.value).toBeUndefined();
+ expect(ctx.transforms()).toEqual({ a: '', b: '' });
+ });
+
+ it('does not start a drag from the close button', () => {
+ const ctx = setup([
+ ['a', 100],
+ ['b', 100],
+ ]);
+ const closeButton = document.createElement('button');
+ closeButton.setAttribute(TAB_DRAG_IGNORE_ATTRIBUTE, '');
+ ctx.elements[0].appendChild(closeButton);
+
+ ctx.press('a', 90, { target: closeButton });
+ ctx.move(200);
+ ctx.release();
+
+ expect(ctx.onDragStart).not.toHaveBeenCalled();
+ expect(ctx.onReorder).not.toHaveBeenCalled();
+ });
+
+ it('does not start a drag from touch or from a secondary button', () => {
+ const ctx = setup([
+ ['a', 100],
+ ['b', 100],
+ ]);
+
+ ctx.press('a', 50, { pointerType: 'touch' });
+ ctx.move(200);
+ ctx.release();
+ ctx.press('a', 50, { button: 2 });
+ ctx.move(200);
+ ctx.release();
+
+ expect(ctx.onDragStart).not.toHaveBeenCalled();
+ expect(ctx.onReorder).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiPreviewTabBar.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiPreviewTabBar.vue
index 263b885b0c1..3db96388d10 100644
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiPreviewTabBar.vue
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiPreviewTabBar.vue
@@ -27,6 +27,7 @@ import { DEBOUNCE_TIME, HOVER_DELAY } from '@/app/constants/durations';
import type { ArtifactTab } from '../useCanvasPreview';
import { hasTabSummary, useArtifactTabSummaries } from '../useArtifactTabSummaries';
import { useProjectResourceSearch } from '../composables/useProjectResourceSearch';
+import { TAB_DRAG_IGNORE_ATTRIBUTE, useTabDragReorder } from '../composables/useTabDragReorder';
// Experiment cleanup: remove with openWorkflowInAssistant.
import ManualEditorButton from '@/experiments/openWorkflowInAssistant/components/ManualEditorButton.vue';
@@ -54,6 +55,7 @@ const emit = defineEmits<{
toggleExpanded: [];
closeTab: [tabId: string];
openTab: [tab: ArtifactTab];
+ reorderTab: [tabId: string, toIndex: number];
}>();
const i18n = useI18n();
@@ -208,6 +210,7 @@ let isPointerOnTab = false;
function showTabHoverCard(tab: ArtifactTab, event: MouseEvent) {
if (!(event.currentTarget instanceof HTMLElement)) return;
+ if (tabDrag.draggedTabId.value !== undefined) return;
const target = { tabId: tab.id, reference: event.currentTarget };
isPointerOnTab = true;
stopCloseTimer();
@@ -248,6 +251,17 @@ function handleHoverCardOpenChange(open: boolean) {
else if (!isPointerOnTab) scheduleHideTabHoverCard();
}
+// --- Reordering ---
+
+const tabDrag = useTabDragReorder({
+ getTabElements: () => {
+ const tabList = getTabListElement();
+ return tabList ? Array.from(tabList.querySelectorAll('[data-tab-item-id]')) : [];
+ },
+ onReorder: (tabId, toIndex) => emit('reorderTab', tabId, toIndex),
+ onDragStart: () => hideTabHoverCard(),
+});
+
// --- New tab picker ---
const isPickerOpen = ref(false);
@@ -307,8 +321,15 @@ async function handleCopyLink(tab: ArtifactTab) {