refactor(webui): separate sidebar tabs from panes

This commit is contained in:
chengyongru
2026-08-11 11:45:57 +08:00
parent 40ad6cb44c
commit 32c5a10947
21 changed files with 1042 additions and 341 deletions
+11 -2
View File
@@ -2336,9 +2336,18 @@ function Shell({
setWorkbenchState((current) => promoteWorkbenchPane(current, tabKey, paneKey));
}, []);
const onAttachWorkbenchPane = useCallback((paneKey: string, tabKey: string) => {
const onAttachWorkbenchPane = useCallback((
paneKey: string,
tabKey: string,
beforePaneKey?: string | null,
) => {
if (paneKey === tabKey) return;
setWorkbenchState((current) => attachWorkbenchPane(current, tabKey, paneKey));
setWorkbenchState((current) => attachWorkbenchPane(
current,
tabKey,
paneKey,
beforePaneKey,
));
if (activeKey === paneKey) {
navigate({
view: "chat",
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -53,7 +53,11 @@ interface SidebarProps {
onPromotePane?: (tabKey: string, paneKey: string) => void;
attachableTabKeys?: string[];
paneAcceptingTabKeys?: string[];
onAttachPane?: (paneKey: string, tabKey: string) => void;
onAttachPane?: (
paneKey: string,
tabKey: string,
beforePaneKey?: string | null,
) => void;
onReorderSessions: (keys: string[]) => void;
onToggleGroup: (groupId: string) => void;
onRequestRenameProject: (projectKey: string, label: string) => void;
+81
View File
@@ -0,0 +1,81 @@
import type { DraggedPane } from "@/lib/session-drag";
export interface PaneDropSlot {
beforePaneKey: string | null;
tabKey: string;
}
export interface PaneTabDragState {
height: number;
item: DraggedPane;
origin: "pane" | "tab";
slot: PaneDropSlot | null;
}
export interface PaneTabDragLayout {
offsets: Map<string, number>;
slotIndex: number;
}
export function samePaneDropSlot(
current: PaneDropSlot | null,
next: PaneDropSlot | null,
): boolean {
return current?.tabKey === next?.tabKey
&& current?.beforePaneKey === next?.beforePaneKey;
}
export function paneDropSlotForRow(
tabKey: string,
paneKeys: string[],
draggedPaneKey: string,
targetPaneKey: string,
edge: "before" | "after",
): PaneDropSlot {
const remaining = paneKeys.filter((key) => key !== draggedPaneKey);
const targetIndex = remaining.indexOf(targetPaneKey);
const insertionIndex = targetIndex < 0
? remaining.length
: targetIndex + (edge === "after" ? 1 : 0);
return {
tabKey,
beforePaneKey: remaining[insertionIndex] ?? null,
};
}
export function paneTabDragLayout(
paneKeys: string[],
tabKey: string,
drag: PaneTabDragState | null,
): PaneTabDragLayout {
const offsets = new Map<string, number>();
if (!drag || drag.height <= 0) {
return { offsets, slotIndex: -1 };
}
const distance = drag.height + 2;
const sourceIndex = paneKeys.indexOf(drag.item.paneKey);
if (
sourceIndex < 0
|| drag.item.sourceTabKey !== tabKey
|| drag.slot?.tabKey !== tabKey
) {
return { offsets, slotIndex: -1 };
}
const remaining = paneKeys.filter((key) => key !== drag.item.paneKey);
const requestedIndex = drag.slot.beforePaneKey
? remaining.indexOf(drag.slot.beforePaneKey)
: remaining.length;
const slotIndex = requestedIndex < 0 ? remaining.length : requestedIndex;
if (sourceIndex < slotIndex) {
for (let index = sourceIndex + 1; index <= slotIndex; index += 1) {
offsets.set(paneKeys[index], -distance);
}
} else if (sourceIndex > slotIndex) {
for (let index = slotIndex; index < sourceIndex; index += 1) {
offsets.set(paneKeys[index], distance);
}
}
return { offsets, slotIndex };
}
@@ -39,6 +39,19 @@ function uniqueKeys(value: unknown): string[] {
));
}
function insertPaneBefore(
paneKeys: string[],
paneKey: string,
beforePaneKey?: string | null,
): string[] {
const next = paneKeys.filter((key) => key !== paneKey);
const requestedIndex = beforePaneKey && beforePaneKey !== paneKey
? next.indexOf(beforePaneKey)
: -1;
next.splice(requestedIndex < 0 ? next.length : requestedIndex, 0, paneKey);
return next;
}
function normalizeTab(value: unknown, tabKey: string): WorkbenchTabState {
const candidate = value && typeof value === "object"
? value as Partial<WorkbenchTabState>
@@ -170,6 +183,7 @@ export function attachWorkbenchPane(
state: WorkbenchState,
targetTabKey: string,
paneKey: string,
beforePaneKey?: string | null,
): WorkbenchState {
if (!targetTabKey || !paneKey || targetTabKey === paneKey) return state;
@@ -179,7 +193,19 @@ export function attachWorkbenchPane(
const sourceTabKey = sourceEntry?.[0];
const sourceTab = sourceEntry?.[1];
if (sourceTabKey === targetTabKey) {
return focusWorkbenchPane(state, targetTabKey, paneKey);
if (beforePaneKey === undefined) {
return focusWorkbenchPane(state, targetTabKey, paneKey);
}
if (!sourceTab) return state;
const paneKeys = insertPaneBefore(sourceTab.paneKeys, paneKey, beforePaneKey);
if (paneKeys.every((key, index) => key === sourceTab.paneKeys[index])) return state;
return {
version: 2,
tabs: {
...state.tabs,
[targetTabKey]: { ...sourceTab, paneKeys },
},
};
}
if (sourceTabKey === paneKey && sourceTab && sourceTab.paneKeys.length > 1) {
return state;
@@ -210,13 +236,12 @@ export function attachWorkbenchPane(
}
const targetTab = tabs[targetTabKey] ?? defaultWorkbenchTab(targetTabKey);
tabs[targetTabKey] = targetTab.paneKeys.includes(paneKey)
? { ...targetTab, activePaneKey: paneKey }
: {
...targetTab,
paneKeys: [...targetTab.paneKeys, paneKey],
activePaneKey: paneKey,
};
const paneKeys = insertPaneBefore(targetTab.paneKeys, paneKey, beforePaneKey);
tabs[targetTabKey] = {
...targetTab,
paneKeys,
activePaneKey: paneKey,
};
return { version: 2, tabs };
}
+2
View File
@@ -40,6 +40,7 @@
--radius: 0.4375rem;
--sidebar: 40 8% 96.8%;
--sidebar-foreground: 0 0% 3.9%;
--sidebar-selected: 40 1% 89.4%;
--sidebar-accent: 0 0% 95.8%;
--sidebar-accent-foreground: 0 0% 9%;
--sidebar-border: 40 8% 90.5%;
@@ -77,6 +78,7 @@
--temporary-border: 27 96% 61%;
--sidebar: var(--card);
--sidebar-foreground: 0 0% 98%;
--sidebar-selected: 0 0% 29.8%;
--sidebar-accent: var(--background);
--sidebar-accent-foreground: 0 0% 98%;
--sidebar-border: var(--border);
+4 -1
View File
@@ -1409,14 +1409,17 @@
"workbench": {
"aria": "Conversation workbench",
"panes": "Panes",
"tabAria": "Tab: {{title}}",
"panesInTab": "Panes in {{title}}",
"collapseTabGroup": "Collapse panes in {{title}}",
"expandTabGroup": "Expand panes in {{title}}",
"dropPane": "Move {{pane}} into {{tab}}",
"moveToTab": "Move to tab",
"layout": "Pane layout",
"addPane": "Add pane",
"promotePane": "Make {{title}} the primary pane",
"paneActions": "{{title}} pane actions",
"detachPane": "Move {{title}} to its own topic",
"detachPane": "Move {{title}} to a new tab",
"composerAria": "Message {{title}}",
"layouts": {
"columns": "Columns",
+4 -1
View File
@@ -1396,14 +1396,17 @@
"workbench": {
"aria": "Área de conversaciones",
"panes": "Paneles",
"tabAria": "Pestaña: {{title}}",
"panesInTab": "Paneles de {{title}}",
"collapseTabGroup": "Contraer los paneles de {{title}}",
"expandTabGroup": "Expandir los paneles de {{title}}",
"dropPane": "Mover {{pane}} a {{tab}}",
"moveToTab": "Mover a una pestaña",
"layout": "Diseño de paneles",
"addPane": "Añadir panel",
"promotePane": "Convertir {{title}} en el panel principal",
"paneActions": "Acciones del panel {{title}}",
"detachPane": "Mover {{title}} a su propio tema",
"detachPane": "Mover {{title}} a una pestaña nueva",
"composerAria": "Mensaje para {{title}}",
"layouts": {
"columns": "Columnas",
+4 -1
View File
@@ -1395,14 +1395,17 @@
"workbench": {
"aria": "Espace de conversations",
"panes": "Volets",
"tabAria": "Onglet : {{title}}",
"panesInTab": "Volets dans {{title}}",
"collapseTabGroup": "Réduire les volets de {{title}}",
"expandTabGroup": "Développer les volets de {{title}}",
"dropPane": "Déplacer {{pane}} dans {{tab}}",
"moveToTab": "Déplacer vers un onglet",
"layout": "Disposition des volets",
"addPane": "Ajouter un volet",
"promotePane": "Définir {{title}} comme volet principal",
"paneActions": "Actions du volet {{title}}",
"detachPane": "Déplacer {{title}} vers son propre sujet",
"detachPane": "Déplacer {{title}} vers un nouvel onglet",
"composerAria": "Message à {{title}}",
"layouts": {
"columns": "Colonnes",
+4 -1
View File
@@ -1395,14 +1395,17 @@
"workbench": {
"aria": "Ruang kerja percakapan",
"panes": "Panel",
"tabAria": "Tab: {{title}}",
"panesInTab": "Panel di {{title}}",
"collapseTabGroup": "Ciutkan panel di {{title}}",
"expandTabGroup": "Luaskan panel di {{title}}",
"dropPane": "Pindahkan {{pane}} ke {{tab}}",
"moveToTab": "Pindahkan ke tab",
"layout": "Tata letak panel",
"addPane": "Tambah panel",
"promotePane": "Jadikan {{title}} panel utama",
"paneActions": "Tindakan panel {{title}}",
"detachPane": "Pindahkan {{title}} ke topik tersendiri",
"detachPane": "Pindahkan {{title}} ke tab baru",
"composerAria": "Pesan untuk {{title}}",
"layouts": {
"columns": "Kolom",
+4 -1
View File
@@ -1395,14 +1395,17 @@
"workbench": {
"aria": "会話ワークベンチ",
"panes": "ペイン",
"tabAria": "タブ:{{title}}",
"panesInTab": "{{title}} のペイン",
"collapseTabGroup": "{{title}} のペインを折りたたむ",
"expandTabGroup": "{{title}} のペインを展開する",
"dropPane": "{{pane}} を {{tab}} に移動",
"moveToTab": "タブへ移動",
"layout": "ペインレイアウト",
"addPane": "ペインを追加",
"promotePane": "{{title}} をメインペインにする",
"paneActions": "{{title}} ペインの操作",
"detachPane": "{{title}} を独立したトピックに移動",
"detachPane": "{{title}} を新しいタブに移動",
"composerAria": "{{title}} へのメッセージ",
"layouts": {
"columns": "列",
+4 -1
View File
@@ -1395,14 +1395,17 @@
"workbench": {
"aria": "대화 워크벤치",
"panes": "창",
"tabAria": "탭: {{title}}",
"panesInTab": "{{title}}의 창",
"collapseTabGroup": "{{title}}의 창 접기",
"expandTabGroup": "{{title}}의 창 펼치기",
"dropPane": "{{pane}}을(를) {{tab}}으로 이동",
"moveToTab": "탭으로 이동",
"layout": "창 레이아웃",
"addPane": "창 추가",
"promotePane": "{{title}}을(를) 기본 창으로 설정",
"paneActions": "{{title}} 창 작업",
"detachPane": "{{title}}을(를) 별도 주제로 이동",
"detachPane": "{{title}}을(를) 새 탭으로 이동",
"composerAria": "{{title}}에 메시지 보내기",
"layouts": {
"columns": "열",
+4 -1
View File
@@ -1409,14 +1409,17 @@
"workbench": {
"aria": "Área de conversas",
"panes": "Painéis",
"tabAria": "Aba: {{title}}",
"panesInTab": "Painéis em {{title}}",
"collapseTabGroup": "Recolher os painéis em {{title}}",
"expandTabGroup": "Expandir os painéis em {{title}}",
"dropPane": "Mover {{pane}} para {{tab}}",
"moveToTab": "Mover para uma aba",
"layout": "Layout de painéis",
"addPane": "Adicionar painel",
"promotePane": "Tornar {{title}} o painel principal",
"paneActions": "Ações do painel {{title}}",
"detachPane": "Mover {{title}} para seu próprio tópico",
"detachPane": "Mover {{title}} para uma nova aba",
"composerAria": "Mensagem para {{title}}",
"layouts": {
"columns": "Colunas",
+4 -1
View File
@@ -1395,14 +1395,17 @@
"workbench": {
"aria": "Không gian hội thoại",
"panes": "Khung",
"tabAria": "Thẻ: {{title}}",
"panesInTab": "Các khung trong {{title}}",
"collapseTabGroup": "Thu gọn các khung trong {{title}}",
"expandTabGroup": "Mở rộng các khung trong {{title}}",
"dropPane": "Di chuyển {{pane}} vào {{tab}}",
"moveToTab": "Di chuyển vào thẻ",
"layout": "Bố cục khung",
"addPane": "Thêm khung",
"promotePane": "Đặt {{title}} làm khung chính",
"paneActions": "Thao tác cho khung {{title}}",
"detachPane": "Chuyển {{title}} thành chủ đề riêng",
"detachPane": "Chuyển {{title}} sang thẻ mới",
"composerAria": "Nhắn tin cho {{title}}",
"layouts": {
"columns": "Cột",
+4 -1
View File
@@ -1409,14 +1409,17 @@
"workbench": {
"aria": "会话工作台",
"panes": "窗格",
"tabAria": "标签页:{{title}}",
"panesInTab": "{{title}} 中的窗格",
"collapseTabGroup": "折叠 {{title}} 中的窗格",
"expandTabGroup": "展开 {{title}} 中的窗格",
"dropPane": "将 {{pane}} 移入 {{tab}}",
"moveToTab": "移动到标签页",
"layout": "窗格布局",
"addPane": "添加窗格",
"promotePane": "将 {{title}} 设为主窗格",
"paneActions": "{{title}} 窗格操作",
"detachPane": "将 {{title}} 移至独立主题",
"detachPane": "将 {{title}} 移至新标签页",
"composerAria": "向 {{title}} 发送消息",
"layouts": {
"columns": "列布局",
+4 -1
View File
@@ -1395,14 +1395,17 @@
"workbench": {
"aria": "對話工作台",
"panes": "窗格",
"tabAria": "標籤頁:{{title}}",
"panesInTab": "{{title}} 中的窗格",
"collapseTabGroup": "收合 {{title}} 中的窗格",
"expandTabGroup": "展開 {{title}} 中的窗格",
"dropPane": "將 {{pane}} 移入 {{tab}}",
"moveToTab": "移動到分頁",
"layout": "窗格佈局",
"addPane": "新增窗格",
"promotePane": "將 {{title}} 設為主窗格",
"paneActions": "{{title}} 窗格操作",
"detachPane": "將 {{title}} 移至獨立主題",
"detachPane": "將 {{title}} 移至新標籤頁",
"composerAria": "傳送訊息給 {{title}}",
"layouts": {
"columns": "欄佈局",
+1 -1
View File
@@ -3115,7 +3115,7 @@ describe("App layout", () => {
name: "New topic pane actions",
}), { button: 0, ctrlKey: false });
fireEvent.click(screen.getByRole("menuitem", {
name: "Move New topic to its own topic",
name: "Move New topic to a new tab",
}));
await waitFor(() => expect(screen.getByTestId("pane-grid").children).toHaveLength(1));
expect(within(sidebar).getAllByRole("button", { name: "New topic" })).toHaveLength(2);
+266 -89
View File
@@ -72,6 +72,7 @@ describe("ChatList", () => {
const dataTransfer = {
effectAllowed: "",
setData: vi.fn(),
setDragImage: vi.fn(),
};
render(
<ChatList
@@ -99,7 +100,14 @@ describe("ChatList", () => {
SESSION_DRAG_TYPE,
"websocket:reference",
);
expect(dataTransfer.setDragImage).toHaveBeenCalled();
expect(document.querySelector("[data-pane-drag-overlay]"))
.toHaveTextContent("Reference chat");
expect(reference.closest("li")).not.toHaveClass("opacity-0");
expect(document.querySelector("[data-tab-drag-placeholder]"))
.not.toBeInTheDocument();
fireEvent.dragEnd(reference, { dataTransfer });
expect(document.querySelector("[data-pane-drag-overlay]")).not.toBeInTheDocument();
});
it("reorders topics with a displaced-neighbor preview instead of an insertion line", () => {
@@ -182,7 +190,7 @@ describe("ChatList", () => {
expect(text.indexOf("Charlie")).toBeLessThan(text.indexOf("Alpha"));
});
it("shows every tab's pane membership in the sidebar tree", async () => {
it("shows every tab's pane membership in a sidebar tab group", async () => {
const onSelect = vi.fn();
const onSelectPane = vi.fn();
const onDetachPane = vi.fn();
@@ -236,7 +244,8 @@ describe("ChatList", () => {
expect(child.closest("[data-sidebar-pane]"))
.toHaveAttribute("data-sidebar-pane", "websocket:child");
expect(child).toHaveAttribute("aria-current", "true");
const targetTabRow = screen.getByRole("button", { name: "Target tab" }).closest("li")!;
const targetTabRow = screen.getByRole("button", { name: "Tab: Target tab" })
.closest("li")!;
const targetChild = within(targetTabRow).getByRole("button", {
name: "Target research",
});
@@ -255,6 +264,10 @@ describe("ChatList", () => {
expect(onSelectPane).toHaveBeenCalledWith("websocket:root", "websocket:root");
expect(onSelect).not.toHaveBeenCalled();
onSelectPane.mockClear();
fireEvent.click(screen.getByRole("button", { name: "Tab: Root topic" }));
expect(onSelectPane).not.toHaveBeenCalled();
fireEvent.pointerDown(screen.getByRole("button", {
name: "Research pane pane actions",
}), { button: 0, ctrlKey: false });
@@ -267,25 +280,34 @@ describe("ChatList", () => {
name: "Research pane pane actions",
}), { button: 0, ctrlKey: false });
fireEvent.click(await screen.findByRole("menuitem", {
name: "Move Research pane to its own topic",
name: "Move Research pane to a new tab",
}));
expect(onDetachPane).toHaveBeenCalledWith("websocket:root", "websocket:child");
fireEvent.pointerDown(screen.getByRole("button", {
name: "Root topic pane actions",
}), { button: 0, ctrlKey: false });
expect(screen.queryByRole("menuitem", { name: "Move to tab" }))
.not.toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: "Move Root topic to a new tab" }))
.not.toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" });
const dataTransfer = {
effectAllowed: "",
dropEffect: "",
setData: vi.fn(),
};
onAttachPane.mockClear();
fireEvent.dragStart(child, { dataTransfer });
expect(child.closest("li")).toHaveAttribute("data-pane-dragging", "true");
expect(child.closest("li")).not.toHaveClass("opacity-0");
const targetTab = screen.getByRole("button", { name: "Target tab" });
const targetTab = screen.getByRole("button", { name: "Tab: Target tab" });
dragOverAt(targetTab.closest("li")!, 0, dataTransfer);
expect(targetTab.closest("li"))
.toHaveAttribute("data-tab-attach-target", "true");
expect(within(targetTab.closest("li")!).getByRole("status", {
name: "Move Research pane into Target tab",
})).toHaveTextContent("Research pane");
.not.toHaveAttribute("data-tab-attach-target");
expect(targetTab.closest("li")!.querySelector("[data-pane-snap-slot]"))
.not.toBeInTheDocument();
dropAt(targetTab.closest("li")!, 0, dataTransfer);
expect(dataTransfer.setData).toHaveBeenCalledWith(
PANE_DRAG_TYPE,
@@ -294,7 +316,217 @@ describe("ChatList", () => {
sourceTabKey: "websocket:root",
}),
);
expect(onAttachPane).toHaveBeenCalledWith("websocket:child", "websocket:target");
expect(onAttachPane).not.toHaveBeenCalled();
fireEvent.dragEnd(child, { dataTransfer });
});
it("collapses a multi-pane tab into one Chrome-style group header", () => {
render(
<ChatList
sessions={[session({ chatId: "root", title: "Root topic" })]}
activeKey="websocket:root"
paneGroups={{
"websocket:root": {
topicKey: "websocket:root",
activePaneKey: "websocket:child",
panes: [
{ key: "websocket:root", chatId: "root", title: "Root topic" },
{ key: "websocket:child", chatId: "child", title: "Research pane" },
],
},
}}
onSelect={vi.fn()}
onSelectPane={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
const tabGroup = screen.getByRole("button", { name: "Tab: Root topic" })
.closest("[data-sidebar-tab-group]")!;
expect(tabGroup).toHaveAttribute("data-sidebar-tab-group", "true");
expect(within(tabGroup).getByRole("list", { name: "Panes in Root topic" }))
.toBeInTheDocument();
expect(within(tabGroup).getByRole("button", { name: "Research pane" }))
.toHaveAttribute("aria-current", "true");
expect(within(tabGroup).getByRole("button", { name: "Root topic" }))
.not.toHaveAttribute("aria-current");
expect(tabGroup).not.toHaveTextContent("2/4");
const collapse = within(tabGroup).getByRole("button", {
name: "Collapse panes in Root topic",
});
expect(collapse).toHaveAttribute("aria-expanded", "true");
fireEvent.click(collapse);
expect(tabGroup).toHaveAttribute("data-pane-group-collapsed", "true");
expect(within(tabGroup).queryByRole("button", { name: "Research pane" }))
.not.toBeInTheDocument();
expect(within(tabGroup).getByRole("button", {
name: "Expand panes in Root topic",
})).toHaveAttribute("aria-expanded", "false");
expect(within(tabGroup).getByRole("button", { name: "Tab: Root topic" })
.closest("[data-sidebar-tab]"))
.toHaveClass("bg-sidebar-selected");
fireEvent.click(within(tabGroup).getByRole("button", {
name: "Expand panes in Root topic",
}));
expect(within(tabGroup).getByRole("button", { name: "Research pane" }))
.toBeInTheDocument();
expect(within(tabGroup).getByRole("button", { name: "Root topic" }))
.toBeInTheDocument();
});
it("keeps the Pane opaque without exposing a slot in another tab", () => {
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect({
left: 0,
top: 0,
width: 240,
height: 28,
}));
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
callback(0);
return 1;
});
const dataTransfer = {
effectAllowed: "",
dropEffect: "",
setData: vi.fn(),
setDragImage: vi.fn(),
};
const onAttachPane = vi.fn();
render(
<ChatList
sessions={[
session({ chatId: "root", title: "Root topic" }),
session({ chatId: "target", title: "Target tab" }),
]}
activeKey="websocket:root"
paneGroups={{
"websocket:root": {
topicKey: "websocket:root",
activePaneKey: "websocket:child",
panes: [
{ key: "websocket:root", chatId: "root", title: "Root topic" },
{ key: "websocket:child", chatId: "child", title: "Research pane" },
],
},
}}
paneAcceptingTabKeys={["websocket:target"]}
onAttachPane={onAttachPane}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
const pane = screen.getByRole("button", { name: "Research pane" });
fireEvent.dragStart(pane, { clientX: 40, clientY: 40, dataTransfer });
expect(dataTransfer.setDragImage).toHaveBeenCalled();
const dragOver = createEvent.dragOver(
screen.getByRole("button", { name: "Target tab" }).closest("li")!,
{ dataTransfer },
);
Object.defineProperties(dragOver, {
clientX: { value: 160 },
clientY: { value: 120 },
});
fireEvent(screen.getByRole("button", { name: "Target tab" }).closest("li")!, dragOver);
const paneRow = pane.closest("li")!;
const overlay = document.querySelector<HTMLElement>("[data-pane-drag-overlay]")!;
expect(overlay).toHaveStyle({
opacity: "1",
height: "28px",
transform: "translate3d(40px, 106px, 0)",
visibility: "visible",
width: "240px",
});
expect(overlay).toHaveTextContent("Research pane");
expect(overlay).toHaveClass(
"!bg-sidebar-selected",
"!shadow-none",
);
expect(overlay).toHaveStyle({ boxShadow: "none" });
expect(document.querySelector("[data-pane-snap-slot]")).not.toBeInTheDocument();
expect(paneRow.querySelector("[data-sidebar-pane]"))
.toHaveClass("!bg-transparent", "!text-transparent", "!shadow-none");
expect(paneRow.style.transform).toBe("");
expect(paneRow).not.toHaveClass("opacity-0");
dataTransfer.dropEffect = "none";
fireEvent.dragEnd(pane, { clientX: 160, clientY: 120, dataTransfer });
expect(onAttachPane).not.toHaveBeenCalled();
expect(document.querySelector("[data-pane-drag-overlay]")).not.toBeInTheDocument();
});
it("repels sibling Panes and snaps the dragged Pane into the selected slot", () => {
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect({
left: 0,
top: 0,
width: 240,
height: 28,
}));
const onAttachPane = vi.fn();
const dataTransfer = {
effectAllowed: "",
dropEffect: "",
setData: vi.fn(),
setDragImage: vi.fn(),
};
render(
<ChatList
sessions={[session({ chatId: "root", title: "Root topic" })]}
activeKey="websocket:root"
paneGroups={{
"websocket:root": {
topicKey: "websocket:root",
activePaneKey: "websocket:first",
panes: [
{ key: "websocket:root", chatId: "root", title: "Root topic" },
{ key: "websocket:first", chatId: "first", title: "First pane" },
{ key: "websocket:second", chatId: "second", title: "Second pane" },
{ key: "websocket:third", chatId: "third", title: "Third pane" },
],
},
}}
onAttachPane={onAttachPane}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
const first = screen.getByRole("button", { name: "First pane" });
const secondRow = screen.getByRole("button", { name: "Second pane" }).closest("li")!;
fireEvent.dragStart(first, { clientX: 40, clientY: 14, dataTransfer });
dragOverAt(secondRow, 20, dataTransfer);
expect(secondRow).toHaveAttribute("data-pane-displaced", "true");
expect(secondRow).toHaveStyle("transform: translateY(-30px)");
expect((first.closest("li") as HTMLElement).style.transform).toBe("");
const snapSlot = screen.getByRole("list", { name: "Panes in Root topic" })
.querySelector("[data-pane-snap-slot]")!;
expect(snapSlot).toHaveStyle("height: 28px; transform: translateY(60px)");
expect(snapSlot).toHaveClass("absolute", "bg-transparent");
expect(first.closest("li")).not.toHaveClass("opacity-0");
dropAt(snapSlot, 20, dataTransfer);
expect(onAttachPane).toHaveBeenCalledWith(
"websocket:first",
"websocket:root",
"websocket:third",
);
});
it("selects a whole tab or individual panes for one bulk delete", async () => {
@@ -330,6 +562,8 @@ describe("ChatList", () => {
}), { button: 0, ctrlKey: false });
fireEvent.click(await screen.findByRole("menuitem", { name: "Select" }));
expect(screen.getByRole("button", { name: "Tab: Root topic" }))
.toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Root topic" }))
.toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Research pane" }))
@@ -350,7 +584,7 @@ describe("ChatList", () => {
expect(screen.queryByTestId("delete-selection-bar")).not.toBeInTheDocument();
});
it("reattaches a one-pane tab through the center of another tab", () => {
it("reorders one-pane tabs instead of attaching them through drag", () => {
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect({
left: 0,
top: 0,
@@ -392,16 +626,16 @@ describe("ChatList", () => {
.toHaveAttribute("data-session-dragging", "true");
const target = screen.getByRole("button", { name: "Target tab" }).closest("li")!;
dragOverAt(target, 16, dataTransfer);
expect(target).toHaveAttribute("data-tab-attach-target", "true");
expect(document.querySelector("[data-session-displaced='true']"))
.not.toBeInTheDocument();
expect(target).not.toHaveAttribute("data-tab-attach-target");
expect(target).toHaveAttribute("data-session-displaced", "true");
expect(document.querySelector("[data-pane-snap-slot]")).not.toBeInTheDocument();
dropAt(target, 16, dataTransfer);
expect(onAttachPane).toHaveBeenCalledWith(
"websocket:detached",
expect(onAttachPane).not.toHaveBeenCalled();
expect(onReorderSessions).toHaveBeenCalledWith([
"websocket:target",
);
expect(onReorderSessions).not.toHaveBeenCalled();
"websocket:detached",
]);
});
it("shows temporary chats separately and lets the user reopen or close them", async () => {
@@ -611,40 +845,7 @@ describe("ChatList", () => {
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
});
it("positions one background highlight and resets it across hidden targets", () => {
let revealFrame: FrameRequestCallback | null = null;
let resizeObserverCallback: ResizeObserverCallback | null = null;
let activeTargetVisible = true;
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeObserverCallback = callback;
}
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal("ResizeObserver", MockResizeObserver);
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
revealFrame = callback;
return 1;
});
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function (this: HTMLElement) {
if (this.hasAttribute("data-chat-list-content")) {
return rect({ left: 0, top: 0, width: 300, height: 200 });
}
if (this.getAttribute("data-chat-row") === "websocket:active") {
return activeTargetVisible
? rect({ left: 8, top: 12, width: 284, height: 32 })
: rect({ left: 0, top: 0, width: 0, height: 0 });
}
if (this.getAttribute("data-chat-row") === "websocket:inactive") {
return rect({ left: 8, top: 48, width: 284, height: 40 });
}
return rect({ left: 0, top: 0, width: 0, height: 0 });
},
);
it("switches row-owned tab highlights without a moving selection surface", () => {
const props = {
sessions: [
session({ chatId: "active", title: "Active topic" }),
@@ -664,45 +865,16 @@ describe("ChatList", () => {
/>,
);
const highlight = screen.getByTestId("sessions-selection-highlight");
expect(highlight).toHaveClass(
"bg-sidebar-foreground/[0.055]",
"transition-[transform,width,height]",
"motion-reduce:transition-none",
);
expect(screen.queryByTestId("sessions-selection-highlight-surface"))
.not.toBeInTheDocument();
expect(resizeObserverCallback).not.toBeNull();
const activeButton = screen.getByTitle("Active topic");
const inactiveButton = screen.getByTitle("Inactive topic");
expect(activeButton).toHaveAttribute("aria-current", "page");
expect(activeButton.parentElement).toHaveClass("transition-[color]");
expect(activeButton.parentElement).not.toHaveClass("transition-colors");
expect(activeButton.parentElement).not.toHaveClass(
"bg-sidebar-accent",
"shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
expect(activeButton.closest("[data-sidebar-tab]")).toHaveClass(
"bg-sidebar-selected",
);
expect(highlight).toHaveClass(
"transition-[transform,width,height]",
"motion-reduce:transition-none",
expect(inactiveButton.closest("[data-sidebar-tab]")).not.toHaveClass(
"bg-sidebar-selected",
);
expect(highlight).toHaveStyle(
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none",
);
revealFrame?.(0);
expect(highlight.style.transitionProperty).toBe("");
activeTargetVisible = false;
resizeObserverCallback?.([], {} as ResizeObserver);
expect(highlight).toHaveStyle("opacity: 0");
activeTargetVisible = true;
resizeObserverCallback?.([], {} as ResizeObserver);
expect(highlight).toHaveStyle(
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none",
);
revealFrame?.(0);
expect(screen.queryByTestId("sessions-selection-highlight")).not.toBeInTheDocument();
rerender(
<ChatList
@@ -713,12 +885,17 @@ describe("ChatList", () => {
expect(screen.getByTitle("Active topic")).not.toHaveAttribute("aria-current");
expect(screen.getByTitle("Inactive topic")).toHaveAttribute("aria-current", "page");
expect(highlight).toHaveStyle(
"width: 284px; height: 40px; transform: translate3d(8px, 48px, 0)",
expect(screen.getByTitle("Active topic").closest("[data-sidebar-tab]")).not.toHaveClass(
"bg-sidebar-selected",
);
expect(screen.getByTitle("Inactive topic").closest("[data-sidebar-tab]")).toHaveClass(
"bg-sidebar-selected",
);
rerender(<ChatList {...props} activeKey={null} />);
expect(highlight).toHaveStyle("opacity: 0");
expect(screen.getByTitle("Inactive topic").closest("[data-sidebar-tab]")).not.toHaveClass(
"bg-sidebar-selected",
);
});
it("can collapse a project group and keeps project rename separate from chat titles", async () => {
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
paneDropSlotForRow,
paneTabDragLayout,
samePaneDropSlot,
type PaneTabDragState,
} from "@/components/pane-tab-drag";
function drag(overrides: Partial<PaneTabDragState> = {}): PaneTabDragState {
return {
origin: "pane",
item: { paneKey: "pane-a", sourceTabKey: "tab-a" },
height: 32,
slot: null,
...overrides,
};
}
describe("Pane tab drag state", () => {
it("turns a pointer edge into one stable insertion slot", () => {
const before = paneDropSlotForRow(
"tab-a",
["pane-a", "pane-b", "pane-c"],
"pane-a",
"pane-b",
"before",
);
const after = paneDropSlotForRow(
"tab-a",
["pane-a", "pane-b", "pane-c"],
"pane-a",
"pane-b",
"after",
);
expect(before).toEqual({ tabKey: "tab-a", beforePaneKey: "pane-b" });
expect(after).toEqual({ tabKey: "tab-a", beforePaneKey: "pane-c" });
expect(samePaneDropSlot(after, { ...after })).toBe(true);
});
it("moves the dragged slot and repels siblings inside one tab", () => {
const layout = paneTabDragLayout(
["pane-a", "pane-b", "pane-c"],
"tab-a",
drag({ slot: { tabKey: "tab-a", beforePaneKey: "pane-c" } }),
);
expect(layout.slotIndex).toBe(1);
expect(Object.fromEntries(layout.offsets)).toEqual({
"pane-b": -34,
});
});
it("does not expose a slot in another tab", () => {
const layout = paneTabDragLayout(
["pane-x", "pane-y"],
"tab-b",
drag({ slot: { tabKey: "tab-b", beforePaneKey: "pane-y" } }),
);
expect(layout.slotIndex).toBe(-1);
expect(Object.fromEntries(layout.offsets)).toEqual({});
});
});
+28
View File
@@ -104,6 +104,34 @@ describe("workbench model", () => {
]);
});
it("places a moved pane into an exact tab slot", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a");
state = addWorkbenchPane(state, "topic-a", "pane-b");
state = addWorkbenchPane(state, "topic-a", "pane-c");
state = attachWorkbenchPane(state, "topic-a", "pane-c", "pane-a");
expect(workbenchTab(state, "topic-a").paneKeys).toEqual([
"topic-a",
"pane-c",
"pane-a",
"pane-b",
]);
state = ensureWorkbenchTab(state, "topic-b");
state = addWorkbenchPane(state, "topic-b", "pane-d");
state = attachWorkbenchPane(state, "topic-b", "pane-a", "pane-d");
expect(workbenchTab(state, "topic-a").paneKeys).toEqual([
"topic-a",
"pane-c",
"pane-b",
]);
expect(workbenchTab(state, "topic-b").paneKeys).toEqual([
"topic-b",
"pane-a",
"pane-d",
]);
});
it("does not collapse a multi-pane tab into another tab", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a");
state = ensureWorkbenchTab(state, "topic-b");
+1
View File
@@ -89,6 +89,7 @@ export default {
sidebar: {
DEFAULT: "hsl(var(--sidebar))",
foreground: "hsl(var(--sidebar-foreground))",
selected: "hsl(var(--sidebar-selected))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",