From 4266ef2099b6615585294c2a83951293ca00353e Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:17:30 +0900 Subject: [PATCH] feat(webui): allow dragging panes out of groups --- webui/src/components/ChatList.tsx | 78 +++++++++++++++++++++++++++++- webui/src/tests/chat-list.test.tsx | 67 +++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index 422340bcc..2d178244e 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -76,6 +76,7 @@ const INITIAL_VISIBLE_SESSIONS = 160; const VISIBLE_SESSIONS_INCREMENT = 160; const ACTION_MENU_CONTENT_CLASS = "w-[11rem] min-w-[11rem] whitespace-nowrap"; const COLLAPSED_PANE_GROUPS_STORAGE_KEY = "nanobot-webui.collapsed-pane-groups.v1"; +const DETACH_PANE_DROP_TARGET = "__sidebar-standalone__"; interface SidebarActionMenuController { openId: string | null; @@ -178,6 +179,25 @@ function droppablePaneKey( return paneKey; } +function detachablePaneSource( + dataTransfer: DataTransfer, + groups: Record, +): { paneKey: string; tabKey: string } | null { + if (!hasDraggedSession(dataTransfer)) return null; + const paneKey = readDraggedSession(dataTransfer); + if (!paneKey) return null; + const source = Object.values(groups).find((group) => ( + (group.visible ?? group.panes.length > 1) + && group.panes.some((pane) => pane.key === paneKey) + )); + return source ? { paneKey, tabKey: source.tabKey } : null; +} + +function isWorkbenchTabSurface(target: EventTarget | null): boolean { + return target instanceof Element + && target.closest("[data-workbench-tab-surface]") !== null; +} + interface ChatListProps { sessions: ChatSummary[]; temporarySessions?: ChatSummary[]; @@ -550,7 +570,63 @@ export const ChatList = memo(function ChatList({
{ + if (isWorkbenchTabSurface(event.target)) { + setPaneDropTarget((current) => ( + current === DETACH_PANE_DROP_TARGET ? null : current + )); + return; + } + const source = onDetachPane && !deleteSelectionMode + ? detachablePaneSource(event.dataTransfer, paneGroups) + : null; + if (!source) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + setPaneDropTarget(DETACH_PANE_DROP_TARGET); + }} + onDragOver={(event) => { + if (isWorkbenchTabSurface(event.target)) { + setPaneDropTarget((current) => ( + current === DETACH_PANE_DROP_TARGET ? null : current + )); + return; + } + const source = onDetachPane && !deleteSelectionMode + ? detachablePaneSource(event.dataTransfer, paneGroups) + : null; + if (!source) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + setPaneDropTarget(DETACH_PANE_DROP_TARGET); + }} + onDragLeave={(event) => { + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; + setPaneDropTarget((current) => ( + current === DETACH_PANE_DROP_TARGET ? null : current + )); + }} + onDrop={(event) => { + if (isWorkbenchTabSurface(event.target)) return; + const source = onDetachPane && !deleteSelectionMode + ? detachablePaneSource(event.dataTransfer, paneGroups) + : null; + if (!source || !onDetachPane) return; + event.preventDefault(); + setPaneDropTarget(null); + clearDraggedSession(); + onDetachPane(source.tabKey, source.paneKey); + }} + className={cn( + "relative min-w-0 space-y-3 rounded-panel px-2 py-1.5", + "transition-[background-color,box-shadow]", + paneDropTarget === DETACH_PANE_DROP_TARGET + && "bg-primary/[0.05] ring-1 ring-inset ring-primary/25", + )} > {temporarySessions.length > 0 ? ( { expect(targetGroup).not.toHaveAttribute("data-pane-drop-target"); }); + it("detaches a grouped pane when it is dropped back into the standalone list", () => { + const onDetachPane = vi.fn(); + render( + , + ); + + const values = new Map(); + const dataTransfer = { + effectAllowed: "none", + dropEffect: "none", + setData: vi.fn((type: string, value: string) => values.set(type, value)), + getData: vi.fn((type: string) => values.get(type) ?? ""), + types: [SESSION_DRAG_TYPE], + } as unknown as DataTransfer; + const source = screen.getByRole("button", { name: "Research pane" }); + const sourceSurface = screen.getByRole("button", { name: "Tab: Root topic" }) + .closest("[data-workbench-tab-surface]")!; + const standaloneList = document.querySelector("[data-chat-list-content]")!; + + fireEvent.dragStart(source, { dataTransfer }); + fireEvent.dragEnter(sourceSurface, { dataTransfer }); + fireEvent.drop(sourceSurface, { dataTransfer }); + expect(onDetachPane).not.toHaveBeenCalled(); + + fireEvent.dragStart(source, { dataTransfer }); + fireEvent.dragEnter(standaloneList, { dataTransfer }); + fireEvent.dragOver(standaloneList, { dataTransfer }); + expect(standaloneList).toHaveAttribute("data-pane-detach-target", "true"); + expect(standaloneList).toHaveClass("ring-1", "ring-primary/25"); + + fireEvent.drop(standaloneList, { dataTransfer }); + expect(onDetachPane).toHaveBeenCalledOnce(); + expect(onDetachPane).toHaveBeenCalledWith("tab:root", "websocket:child"); + expect(standaloneList).not.toHaveAttribute("data-pane-detach-target"); + }); + it("shows every tab's pane membership in a sidebar tab group", async () => { const onSelect = vi.fn(); const onSelectPane = vi.fn();