mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-14 16:19:17 +03:00
feat(webui): allow dragging topics into groups
This commit is contained in:
@@ -63,7 +63,12 @@ import {
|
|||||||
type ChatGroupLabels,
|
type ChatGroupLabels,
|
||||||
} from "@/lib/chat-groups";
|
} from "@/lib/chat-groups";
|
||||||
import { deriveTemporaryChatTitle } from "@/lib/temporary-chat";
|
import { deriveTemporaryChatTitle } from "@/lib/temporary-chat";
|
||||||
import { clearDraggedSession, writeDraggedSession } from "@/lib/session-drag";
|
import {
|
||||||
|
clearDraggedSession,
|
||||||
|
hasDraggedSession,
|
||||||
|
readDraggedSession,
|
||||||
|
writeDraggedSession,
|
||||||
|
} from "@/lib/session-drag";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
||||||
|
|
||||||
@@ -162,6 +167,17 @@ export interface SidebarDeleteItem {
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function droppablePaneKey(
|
||||||
|
dataTransfer: DataTransfer,
|
||||||
|
group: SidebarPaneGroup,
|
||||||
|
): string | null {
|
||||||
|
if (group.panes.length >= MAX_WORKBENCH_PANES
|
||||||
|
|| !hasDraggedSession(dataTransfer)) return null;
|
||||||
|
const paneKey = readDraggedSession(dataTransfer);
|
||||||
|
if (!paneKey || group.panes.some((pane) => pane.key === paneKey)) return null;
|
||||||
|
return paneKey;
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatListProps {
|
interface ChatListProps {
|
||||||
sessions: ChatSummary[];
|
sessions: ChatSummary[];
|
||||||
temporarySessions?: ChatSummary[];
|
temporarySessions?: ChatSummary[];
|
||||||
@@ -256,11 +272,22 @@ export const ChatList = memo(function ChatList({
|
|||||||
const [collapsedPaneGroups, setCollapsedPaneGroups] = useState<Set<string>>(
|
const [collapsedPaneGroups, setCollapsedPaneGroups] = useState<Set<string>>(
|
||||||
readCollapsedPaneGroups,
|
readCollapsedPaneGroups,
|
||||||
);
|
);
|
||||||
|
const [paneDropTarget, setPaneDropTarget] = useState<string | null>(null);
|
||||||
const [deleteSelectionMode, setDeleteSelectionMode] = useState(false);
|
const [deleteSelectionMode, setDeleteSelectionMode] = useState(false);
|
||||||
const [openActionMenuId, setOpenActionMenuId] = useState<string | null>(null);
|
const [openActionMenuId, setOpenActionMenuId] = useState<string | null>(null);
|
||||||
const [selectedDeleteKeys, setSelectedDeleteKeys] = useState<Set<string>>(
|
const [selectedDeleteKeys, setSelectedDeleteKeys] = useState<Set<string>>(
|
||||||
() => new Set(),
|
() => new Set(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const clearPaneDropTarget = () => setPaneDropTarget(null);
|
||||||
|
window.addEventListener("dragend", clearPaneDropTarget);
|
||||||
|
window.addEventListener("drop", clearPaneDropTarget);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("dragend", clearPaneDropTarget);
|
||||||
|
window.removeEventListener("drop", clearPaneDropTarget);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
const deleteItemsByKey = useMemo(() => {
|
const deleteItemsByKey = useMemo(() => {
|
||||||
const items = new Map<string, SidebarDeleteItem>();
|
const items = new Map<string, SidebarDeleteItem>();
|
||||||
for (const group of Object.values(paneGroups)) {
|
for (const group of Object.values(paneGroups)) {
|
||||||
@@ -633,15 +660,59 @@ export const ChatList = memo(function ChatList({
|
|||||||
}}
|
}}
|
||||||
data-sidebar-tab-group="true"
|
data-sidebar-tab-group="true"
|
||||||
data-pane-group-collapsed={paneGroupCollapsed ? "true" : undefined}
|
data-pane-group-collapsed={paneGroupCollapsed ? "true" : undefined}
|
||||||
|
data-pane-drop-target={
|
||||||
|
paneDropTarget === resolvedPaneGroup.tabKey ? "true" : undefined
|
||||||
|
}
|
||||||
className="relative my-1.5 min-w-0"
|
className="relative my-1.5 min-w-0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-workbench-tab-surface
|
data-workbench-tab-surface
|
||||||
|
onDragEnter={(event) => {
|
||||||
|
const paneKey = onAttachPane && !deleteSelectionMode
|
||||||
|
? droppablePaneKey(event.dataTransfer, resolvedPaneGroup)
|
||||||
|
: null;
|
||||||
|
if (!paneKey) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
setPaneDropTarget(resolvedPaneGroup.tabKey);
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
const paneKey = onAttachPane && !deleteSelectionMode
|
||||||
|
? droppablePaneKey(event.dataTransfer, resolvedPaneGroup)
|
||||||
|
: null;
|
||||||
|
if (!paneKey) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
setPaneDropTarget(resolvedPaneGroup.tabKey);
|
||||||
|
}}
|
||||||
|
onDragLeave={(event) => {
|
||||||
|
const nextTarget = event.relatedTarget;
|
||||||
|
if (nextTarget instanceof Node
|
||||||
|
&& event.currentTarget.contains(nextTarget)) return;
|
||||||
|
setPaneDropTarget((current) => (
|
||||||
|
current === resolvedPaneGroup.tabKey ? null : current
|
||||||
|
));
|
||||||
|
}}
|
||||||
|
onDrop={(event) => {
|
||||||
|
const paneKey = onAttachPane && !deleteSelectionMode
|
||||||
|
? droppablePaneKey(event.dataTransfer, resolvedPaneGroup)
|
||||||
|
: null;
|
||||||
|
if (!paneKey || !onAttachPane) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setPaneDropTarget(null);
|
||||||
|
clearDraggedSession();
|
||||||
|
onAttachPane(paneKey, resolvedPaneGroup.tabKey);
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-0",
|
"min-w-0 transition-[background-color,box-shadow]",
|
||||||
projectMode && "-ms-0.5",
|
projectMode && "-ms-0.5",
|
||||||
deleteSelectionMode && (tabSelected || tabPartiallySelected)
|
deleteSelectionMode && (tabSelected || tabPartiallySelected)
|
||||||
&& "ring-1 ring-inset ring-sidebar-foreground/25",
|
&& "ring-1 ring-inset ring-sidebar-foreground/25",
|
||||||
|
paneDropTarget === resolvedPaneGroup.tabKey
|
||||||
|
&& "bg-primary/[0.09] ring-2 ring-inset ring-primary/35 dark:bg-primary/[0.14]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<WorkbenchTabHeader
|
<WorkbenchTabHeader
|
||||||
|
|||||||
@@ -238,6 +238,89 @@ describe("ChatList", () => {
|
|||||||
expect(onAttachPane).toHaveBeenCalledWith("websocket:solo", "tab:fine");
|
expect(onAttachPane).toHaveBeenCalledWith("websocket:solo", "tab:fine");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("moves a dragged topic directly into a visible group", () => {
|
||||||
|
const onAttachPane = vi.fn();
|
||||||
|
render(
|
||||||
|
<ChatList
|
||||||
|
sessions={[
|
||||||
|
session({ chatId: "solo", title: "Solo topic" }),
|
||||||
|
session({ chatId: "target", title: "Target group" }),
|
||||||
|
session({ chatId: "full", title: "Full group" }),
|
||||||
|
]}
|
||||||
|
activeKey="websocket:target"
|
||||||
|
paneGroups={{
|
||||||
|
"websocket:solo": {
|
||||||
|
tabKey: "tab:solo",
|
||||||
|
title: "Solo topic",
|
||||||
|
activePaneKey: "websocket:solo",
|
||||||
|
visible: false,
|
||||||
|
panes: [{ key: "websocket:solo", chatId: "solo", title: "Solo topic" }],
|
||||||
|
},
|
||||||
|
"websocket:target": {
|
||||||
|
tabKey: "tab:target",
|
||||||
|
title: "Target group",
|
||||||
|
activePaneKey: "websocket:target",
|
||||||
|
visible: true,
|
||||||
|
panes: [{ key: "websocket:target", chatId: "target", title: "Target pane" }],
|
||||||
|
},
|
||||||
|
"websocket:full": {
|
||||||
|
tabKey: "tab:full",
|
||||||
|
title: "Full group",
|
||||||
|
activePaneKey: "websocket:full-1",
|
||||||
|
visible: true,
|
||||||
|
panes: [1, 2, 3, 4].map((index) => ({
|
||||||
|
key: `websocket:full-${index}`,
|
||||||
|
chatId: `full-${index}`,
|
||||||
|
title: `Full pane ${index}`,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onAttachPane={onAttachPane}
|
||||||
|
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: "Solo topic" });
|
||||||
|
const targetGroup = screen.getByRole("button", { name: "Tab: Target group" })
|
||||||
|
.closest("[data-sidebar-tab-group]")!;
|
||||||
|
const targetSurface = targetGroup.querySelector("[data-workbench-tab-surface]")!;
|
||||||
|
const fullGroup = screen.getByRole("button", { name: "Tab: Full group" })
|
||||||
|
.closest("[data-sidebar-tab-group]")!;
|
||||||
|
const fullSurface = fullGroup.querySelector("[data-workbench-tab-surface]")!;
|
||||||
|
|
||||||
|
fireEvent.dragStart(source, { dataTransfer });
|
||||||
|
fireEvent.dragEnter(fullSurface, { dataTransfer });
|
||||||
|
fireEvent.dragOver(fullSurface, { dataTransfer });
|
||||||
|
fireEvent.drop(fullSurface, { dataTransfer });
|
||||||
|
|
||||||
|
expect(fullGroup).not.toHaveAttribute("data-pane-drop-target");
|
||||||
|
expect(onAttachPane).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.dragEnter(targetSurface, { dataTransfer });
|
||||||
|
fireEvent.dragOver(targetSurface, { dataTransfer });
|
||||||
|
|
||||||
|
expect(targetGroup).toHaveAttribute("data-pane-drop-target", "true");
|
||||||
|
expect(targetSurface).toHaveClass("ring-2", "ring-primary/35");
|
||||||
|
|
||||||
|
fireEvent.drop(targetSurface, { dataTransfer });
|
||||||
|
|
||||||
|
expect(onAttachPane).toHaveBeenCalledOnce();
|
||||||
|
expect(onAttachPane).toHaveBeenCalledWith("websocket:solo", "tab:target");
|
||||||
|
expect(targetGroup).not.toHaveAttribute("data-pane-drop-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