mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-14 16:19:17 +03:00
feat(webui): allow dragging panes out of groups
This commit is contained in:
@@ -76,6 +76,7 @@ const INITIAL_VISIBLE_SESSIONS = 160;
|
|||||||
const VISIBLE_SESSIONS_INCREMENT = 160;
|
const VISIBLE_SESSIONS_INCREMENT = 160;
|
||||||
const ACTION_MENU_CONTENT_CLASS = "w-[11rem] min-w-[11rem] whitespace-nowrap";
|
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 COLLAPSED_PANE_GROUPS_STORAGE_KEY = "nanobot-webui.collapsed-pane-groups.v1";
|
||||||
|
const DETACH_PANE_DROP_TARGET = "__sidebar-standalone__";
|
||||||
|
|
||||||
interface SidebarActionMenuController {
|
interface SidebarActionMenuController {
|
||||||
openId: string | null;
|
openId: string | null;
|
||||||
@@ -178,6 +179,25 @@ function droppablePaneKey(
|
|||||||
return paneKey;
|
return paneKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detachablePaneSource(
|
||||||
|
dataTransfer: DataTransfer,
|
||||||
|
groups: Record<string, SidebarPaneGroup>,
|
||||||
|
): { 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 {
|
interface ChatListProps {
|
||||||
sessions: ChatSummary[];
|
sessions: ChatSummary[];
|
||||||
temporarySessions?: ChatSummary[];
|
temporarySessions?: ChatSummary[];
|
||||||
@@ -550,7 +570,63 @@ export const ChatList = memo(function ChatList({
|
|||||||
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
|
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
|
||||||
<div
|
<div
|
||||||
data-chat-list-content
|
data-chat-list-content
|
||||||
className="relative min-w-0 space-y-3 px-2 py-1.5"
|
data-pane-detach-target={
|
||||||
|
paneDropTarget === DETACH_PANE_DROP_TARGET ? "true" : undefined
|
||||||
|
}
|
||||||
|
onDragEnter={(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);
|
||||||
|
}}
|
||||||
|
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 ? (
|
{temporarySessions.length > 0 ? (
|
||||||
<TemporaryChatSection
|
<TemporaryChatSection
|
||||||
|
|||||||
@@ -321,6 +321,73 @@ describe("ChatList", () => {
|
|||||||
expect(targetGroup).not.toHaveAttribute("data-pane-drop-target");
|
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(
|
||||||
|
<ChatList
|
||||||
|
sessions={[
|
||||||
|
session({ chatId: "root", title: "Root topic" }),
|
||||||
|
session({ chatId: "solo", title: "Solo topic" }),
|
||||||
|
]}
|
||||||
|
activeKey="websocket:root"
|
||||||
|
paneGroups={{
|
||||||
|
"websocket:root": {
|
||||||
|
tabKey: "tab:root",
|
||||||
|
title: "Root group",
|
||||||
|
activePaneKey: "websocket:root",
|
||||||
|
visible: true,
|
||||||
|
panes: [
|
||||||
|
{ key: "websocket:root", chatId: "root", title: "Root topic" },
|
||||||
|
{ key: "websocket:child", chatId: "child", title: "Research pane" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"websocket:solo": {
|
||||||
|
tabKey: "tab:solo",
|
||||||
|
title: "Solo topic",
|
||||||
|
activePaneKey: "websocket:solo",
|
||||||
|
visible: false,
|
||||||
|
panes: [{ key: "websocket:solo", chatId: "solo", title: "Solo topic" }],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onDetachPane={onDetachPane}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onRequestDelete={vi.fn()}
|
||||||
|
onTogglePin={vi.fn()}
|
||||||
|
onRequestRename={vi.fn()}
|
||||||
|
onToggleArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
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 () => {
|
it("shows every tab's pane membership in a sidebar tab group", async () => {
|
||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
const onSelectPane = vi.fn();
|
const onSelectPane = vi.fn();
|
||||||
|
|||||||
Reference in New Issue
Block a user