feat(editor): Reorder AI Assistant tabs by dragging (#39664)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaakko Husso 2026-09-30 09:27:57 +00:00 committed by GitHub
parent 506b99456f
commit 2f4bbfa7d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 633 additions and 1 deletions

View File

@ -962,6 +962,7 @@ function handleNewThreadClick() {
:project-id="thread.projectId"
@close-tab="preview.closeTab"
@open-tab="preview.openTab"
@reorder-tab="preview.reorderTab"
/>
<div :class="$style.previewContent">
<InstanceAiWorkflowPreview

View File

@ -1378,6 +1378,54 @@ describe('useCanvasPreview', () => {
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');

View File

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

View File

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

View File

@ -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<HTMLElement>('[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) {
<ContextMenuTrigger as-child>
<!-- The close button cannot sit inside the trigger button, so both share a wrapper. -->
<div
:class="[$style.tab, { [$style.tabActive]: tab.id === activeTabId }]"
:class="[
$style.tab,
{
[$style.tabActive]: tab.id === activeTabId,
[$style.tabDragging]: tab.id === tabDrag.draggedTabId.value,
},
]"
:data-tab-item-id="tab.id"
@pointerdown="tabDrag.onPointerDown(tab.id, $event)"
@mouseenter="showTabHoverCard(tab, $event)"
@mouseleave="handleTabMouseLeave"
@contextmenu="hideTabHoverCard"
@ -338,6 +359,7 @@ async function handleCopyLink(tab: ArtifactTab) {
variant="ghost"
size="xsmall"
:class="$style.closeButton"
v-bind="{ [TAB_DRAG_IGNORE_ATTRIBUTE]: '' }"
:aria-label="
i18n.baseText('instanceAi.previewTabBar.closeTab', {
interpolate: { name: tabName(tab) },
@ -541,6 +563,12 @@ async function handleCopyLink(tab: ArtifactTab) {
--tab--background: light-dark(var(--color--neutral-150), var(--color--neutral-800));
}
// The drag composable moves the tabs through inline styles.
&.tabDragging {
z-index: 1;
cursor: grabbing;
}
// Show the close button on hover and while it has keyboard focus.
&:hover .closeSlot,
.closeSlot:focus-within {

View File

@ -187,6 +187,19 @@ export function useOpenArtifactTabs({
);
}
/** Move an open tab to a new position in the tab order. */
function moveTab(tabId: string, toIndex: number) {
const tabs = [...openTabs.value];
const fromIndex = tabs.findIndex((tab) => tab.id === tabId);
if (fromIndex === -1) return;
const target = Math.max(0, Math.min(toIndex, tabs.length - 1));
if (target === fromIndex) return;
const [moved] = tabs.splice(fromIndex, 1);
tabs.splice(target, 0, moved);
layout.value = { tabs: tabs.map(toStoredTab), closedTabs: currentLayout().closedTabs };
}
// --- Storage ---
let saveTimer: ReturnType<typeof setTimeout> | undefined;
@ -267,6 +280,7 @@ export function useOpenArtifactTabs({
closeTab,
reopenTab,
openTab,
moveTab,
saveTabs,
};
}

View File

@ -0,0 +1,206 @@
import { onScopeDispose, ref } from 'vue';
// The pointer must move this far sideways before a press becomes a drag, so a
// click still selects the tab.
const DRAG_THRESHOLD_PX = 4;
const SLIDE_TRANSITION = 'transform 150ms ease';
/** Marks an element inside a tab, such as the close button, that does not start a drag. */
export const TAB_DRAG_IGNORE_ATTRIBUTE = 'data-tab-drag-ignore';
interface TabBox {
element: HTMLElement;
id: string;
left: number;
width: number;
}
interface DragState {
tabId: string;
pointerId: number;
startX: number;
boxes: TabBox[];
fromIndex: number;
targetIndex: number;
}
const center = (box: TabBox) => box.left + box.width / 2;
/**
* Drag tabs sideways to reorder them, like browser tabs. The dragged tab
* follows the pointer and the other tabs move aside. The new order applies
* when the pointer is released.
*
* The tab positions are written to the element styles once per frame, not
* through reactive state, so a drag does not render the tab bar again.
*/
export function useTabDragReorder({
getTabElements,
onReorder,
onDragStart,
}: {
/** The tab elements in display order. Each has a `data-tab-item-id` attribute. */
getTabElements: () => HTMLElement[];
onReorder: (tabId: string, toIndex: number) => void;
onDragStart?: () => void;
}) {
const draggedTabId = ref<string>();
let state: DragState | null = null;
let frame: number | undefined;
let pendingDeltaX = 0;
function measure(): TabBox[] {
return getTabElements().map((element) => {
const rect = element.getBoundingClientRect();
return { element, id: element.dataset.tabItemId ?? '', left: rect.left, width: rect.width };
});
}
function update(deltaX: number) {
if (!state) return;
const { boxes, fromIndex } = state;
const dragged = boxes[fromIndex];
const first = boxes[0];
const last = boxes[boxes.length - 1];
// Keep the dragged tab inside the row of tabs.
const minDelta = first.left - dragged.left;
const maxDelta = last.left + last.width - (dragged.left + dragged.width);
const delta = Math.max(minDelta, Math.min(deltaX, maxDelta));
const draggedLeft = dragged.left + delta;
const draggedRight = draggedLeft + dragged.width;
// The leading edge of the dragged tab passes a tab at its center. A center
// check could not pass a first or last tab that is narrower than the dragged one.
let targetIndex = fromIndex;
for (let i = fromIndex + 1; i < boxes.length; i++) {
if (draggedRight > center(boxes[i])) targetIndex = i;
}
for (let i = fromIndex - 1; i >= 0; i--) {
if (draggedLeft < center(boxes[i])) targetIndex = i;
}
state.targetIndex = targetIndex;
// A tab that moves aside moves by the dragged tab's width plus the gap.
const gap = boxes.length > 1 ? boxes[1].left - (boxes[0].left + boxes[0].width) : 0;
const step = dragged.width + gap;
boxes.forEach((box, i) => {
let offset = 0;
if (i === fromIndex) offset = delta;
else if (i > fromIndex && i <= targetIndex) offset = -step;
else if (i < fromIndex && i >= targetIndex) offset = step;
box.element.style.transform = offset ? `translateX(${offset}px)` : '';
});
}
function startDrag() {
if (!state) return;
draggedTabId.value = state.tabId;
state.boxes.forEach((box, i) => {
// The other tabs slide aside. The dragged tab follows the pointer at once.
box.element.style.transition = i === state?.fromIndex ? 'none' : SLIDE_TRANSITION;
box.element.style.willChange = 'transform';
});
onDragStart?.();
}
function onPointerMove(event: PointerEvent) {
if (!state || event.pointerId !== state.pointerId) return;
const deltaX = event.clientX - state.startX;
if (draggedTabId.value === undefined) {
if (Math.abs(deltaX) < DRAG_THRESHOLD_PX) return;
startDrag();
}
event.preventDefault();
// Pointer events can arrive faster than the screen refreshes, so draw once per frame.
pendingDeltaX = deltaX;
frame ??= requestAnimationFrame(() => {
frame = undefined;
update(pendingDeltaX);
});
}
function stop(commit: boolean) {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerCancel);
window.removeEventListener('keydown', onKeyDown, true);
if (frame !== undefined) {
cancelAnimationFrame(frame);
frame = undefined;
}
const finished = state;
const wasDragging = draggedTabId.value !== undefined;
state = null;
draggedTabId.value = undefined;
// Clear the transition together with the offsets, so the tabs land in
// their new places at once instead of sliding back first.
finished?.boxes.forEach(({ element }) => {
element.style.transform = '';
element.style.transition = '';
element.style.willChange = '';
});
if (commit && wasDragging && finished && finished.targetIndex !== finished.fromIndex) {
commitDrop(finished);
}
}
// The tabs can change while the pointer is down, for example when the agent
// opens an artifact. So drop next to the tab the pointer passed, found in the
// current tabs, instead of at a position from the start of the drag.
function commitDrop({ tabId, boxes, targetIndex }: DragState) {
const currentIds = getTabElements().map((element) => element.dataset.tabItemId);
const toIndex = currentIds.indexOf(boxes[targetIndex].id);
const fromIndex = currentIds.indexOf(tabId);
if (toIndex === -1 || fromIndex === -1 || toIndex === fromIndex) return;
onReorder(tabId, toIndex);
}
function onPointerUp(event: PointerEvent) {
if (!state || event.pointerId !== state.pointerId) return;
// Apply the last position that has not been drawn yet, so the drop matches the pointer.
if (frame !== undefined) update(pendingDeltaX);
stop(true);
}
function onPointerCancel(event: PointerEvent) {
if (state && event.pointerId === state.pointerId) stop(false);
}
function onKeyDown(event: KeyboardEvent) {
if (event.key !== 'Escape' || draggedTabId.value === undefined) return;
event.preventDefault();
event.stopPropagation();
stop(false);
}
function onPointerDown(tabId: string, event: PointerEvent) {
// Touch drags scroll the tab row instead.
if (event.button !== 0 || event.pointerType === 'touch' || state) return;
if (event.target instanceof Element && event.target.closest(`[${TAB_DRAG_IGNORE_ATTRIBUTE}]`)) {
return;
}
const boxes = measure();
const fromIndex = boxes.findIndex((box) => box.id === tabId);
if (fromIndex === -1 || boxes.length < 2) return;
state = {
tabId,
pointerId: event.pointerId,
startX: event.clientX,
boxes,
fromIndex,
targetIndex: fromIndex,
};
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerCancel);
window.addEventListener('keydown', onKeyDown, true);
}
onScopeDispose(() => stop(false));
return { draggedTabId, onPointerDown };
}

View File

@ -281,6 +281,11 @@ export function useCanvasPreview({ thread, initialAgentId, tabsStorage }: UseCan
selectTab(tab.id);
}
function reorderTab(tabId: string, toIndex: number) {
tabs.moveTab(tabId, toIndex);
tabs.saveTabs(activeTabId.value);
}
function closeTab(tabId: string) {
const nextTabId = tabs.closeTab(tabId);
if (activeTabId.value === tabId) {
@ -589,6 +594,7 @@ export function useCanvasPreview({ thread, initialAgentId, tabsStorage }: UseCan
workflowRefreshKey,
selectTab,
openTab,
reorderTab,
closeTab,
closePreview,
openWorkflowPreview,