diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 46311b496..59dda9337 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -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", diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index c79b4a8fb..0494ec720 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -7,13 +7,12 @@ import { useRef, useState, type DragEvent, - type RefObject, } from "react"; import { Archive, ArchiveRestore, BringToFront, - CornerDownRight, + ChevronDown, Folder, ListChecks, MessageCircleDashed, @@ -41,10 +40,14 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { SIDEBAR_SELECTION_ITEM_CLASS } from "@/components/SidebarSelectionHighlight"; import { - SIDEBAR_SELECTION_ITEM_CLASS, - SidebarSelectionHighlight, -} from "@/components/SidebarSelectionHighlight"; + paneDropSlotForRow, + paneTabDragLayout, + samePaneDropSlot, + type PaneDropSlot, + type PaneTabDragState, +} from "@/components/pane-tab-drag"; import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format"; import { COLLAPSED_CHATS_VISIBLE_COUNT, @@ -63,7 +66,6 @@ import { writeDraggedSession, type DraggedPane, } from "@/lib/session-drag"; -import { MAX_WORKBENCH_PANES } from "@/components/workbench/workbench-model"; import { deriveTemporaryChatTitle } from "@/lib/temporary-chat"; import { cn } from "@/lib/utils"; import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types"; @@ -71,6 +73,7 @@ import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types"; const INITIAL_VISIBLE_SESSIONS = 160; const VISIBLE_SESSIONS_INCREMENT = 160; const ACTION_MENU_CONTENT_CLASS = "w-[8.5rem] min-w-[8.5rem]"; +const DEFAULT_PANE_ROW_HEIGHT = 32; export interface SidebarPaneGroup { topicKey: string; @@ -87,6 +90,62 @@ export interface SidebarDeleteItem { label: string; } +interface PaneDragMotion { + frame: number | null; + grabOffsetX: number; + grabOffsetY: number; + originHeight: number; + originLeft: number; + originTop: number; + originWidth: number; + overlay: HTMLElement; + pointerX: number; + pointerY: number; + snapHeight: number | null; + snapLeft: number | null; + snapTop: number | null; + snapWidth: number | null; +} + +function positionPaneDragMotion(motion: PaneDragMotion): void { + const left = motion.snapLeft ?? motion.pointerX - motion.grabOffsetX; + const top = motion.snapTop ?? motion.pointerY - motion.grabOffsetY; + motion.overlay.style.width = `${motion.snapWidth ?? motion.originWidth}px`; + motion.overlay.style.height = `${motion.snapHeight ?? motion.originHeight}px`; + motion.overlay.style.transform = `translate3d(${left - motion.originLeft}px, ${ + top - motion.originTop + }px, 0)`; +} + +function updatePaneDragSnap( + motion: PaneDragMotion, + slot: HTMLElement | null, +): void { + const rect = slot?.getBoundingClientRect(); + motion.snapHeight = rect?.height ?? null; + motion.snapLeft = rect?.left ?? null; + motion.snapTop = rect?.top ?? null; + motion.snapWidth = rect?.width ?? null; +} + +function hideNativeDragPreview(dataTransfer: DataTransfer): void { + if (typeof dataTransfer.setDragImage !== "function") return; + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + canvas.style.position = "fixed"; + canvas.style.left = "-2px"; + canvas.style.top = "-2px"; + canvas.style.pointerEvents = "none"; + document.body.append(canvas); + try { + dataTransfer.setDragImage(canvas, 0, 0); + } catch { + // Some DOM shims expose setDragImage without implementing it. + } + window.setTimeout(() => canvas.remove(), 0); +} + interface ChatListProps { sessions: ChatSummary[]; temporarySessions?: ChatSummary[]; @@ -104,7 +163,11 @@ interface ChatListProps { 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; @@ -170,27 +233,26 @@ export const ChatList = memo(function ChatList({ }: ChatListProps) { const { t } = useTranslation(); const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS); - const [draggedSessionKey, setDraggedSessionKey] = useState(null); const [sessionDropTarget, setSessionDropTarget] = useState<{ edge: "before" | "after"; key: string; } | null>(null); - const [draggedSessionHeight, setDraggedSessionHeight] = useState(0); - const [draggedPane, setDraggedPane] = useState(null); - const [tabAttachTargetKey, setTabAttachTargetKey] = useState(null); - const tabAttachTargetRef = useRef(null); + const [paneDrag, setPaneDrag] = useState(null); const tabRowRefs = useRef(new Map()); const pendingTabRectsRef = useRef | null>(null); const tabLayoutAnimationsRef = useRef(new Map()); + const paneDragMotionRef = useRef(null); + const [collapsedPaneGroups, setCollapsedPaneGroups] = useState>( + () => new Set(), + ); const [deleteSelectionMode, setDeleteSelectionMode] = useState(false); const [selectedDeleteKeys, setSelectedDeleteKeys] = useState>( () => new Set(), ); - const activeRowRef = useRef(null); - const selectedPaneGroup = activeKey ? paneGroups[activeKey] : undefined; - const selectedRowKey = selectedPaneGroup - ? selectedPaneGroup.activePaneKey - : activeKey; + const draggedSessionKey = paneDrag?.origin === "tab" + ? paneDrag.item.paneKey + : null; + const draggedSessionHeight = paneDrag?.origin === "tab" ? paneDrag.height : 0; const attachableTabs = useMemo(() => new Set(attachableTabKeys), [attachableTabKeys]); const paneAcceptingTabs = useMemo( () => new Set(paneAcceptingTabKeys), @@ -300,6 +362,18 @@ export const ChatList = memo(function ChatList({ return () => window.removeEventListener("keydown", onKeyDown); }, [deleteSelectionMode]); + useEffect(() => { + setCollapsedPaneGroups((current) => { + const next = new Set(Array.from(current).filter((key) => ( + (paneGroups[key]?.panes.length ?? 0) > 1 + ))); + if (next.size === current.size && Array.from(next).every((key) => current.has(key))) { + return current; + } + return next; + }); + }, [paneGroups]); + const measureTabRows = useCallback(() => { const rects = new Map(); for (const [key, row] of tabRowRefs.current) { @@ -308,23 +382,121 @@ export const ChatList = memo(function ChatList({ return rects; }, []); - const updateTabAttachTarget = useCallback((next: string | null) => { - if (tabAttachTargetRef.current === next) return; + const captureTabLayout = useCallback(() => { for (const animation of tabLayoutAnimationsRef.current.values()) animation.cancel(); tabLayoutAnimationsRef.current.clear(); pendingTabRectsRef.current = measureTabRows(); - tabAttachTargetRef.current = next; - setTabAttachTargetKey(next); }, [measureTabRows]); + const updatePaneDropSlot = useCallback((next: PaneDropSlot | null) => { + if (!paneDrag || samePaneDropSlot(paneDrag.slot, next)) return; + captureTabLayout(); + setPaneDrag({ ...paneDrag, slot: next }); + }, [captureTabLayout, paneDrag]); + + const togglePaneGroup = useCallback((key: string) => { + captureTabLayout(); + setCollapsedPaneGroups((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, [captureTabLayout]); + + const updatePaneDragMotion = useCallback((clientX: number, clientY: number) => { + const motion = paneDragMotionRef.current; + if (!motion || (clientX === 0 && clientY === 0)) return; + motion.pointerX = clientX; + motion.pointerY = clientY; + if (motion.frame !== null) return; + motion.frame = window.requestAnimationFrame(() => { + const current = paneDragMotionRef.current; + if (!current) return; + current.frame = null; + if (current.snapLeft !== null) { + const slot = document.querySelector("[data-pane-snap-slot]"); + if (slot) updatePaneDragSnap(current, slot); + } + positionPaneDragMotion(current); + }); + }, []); + + const beginPaneDragMotion = useCallback((event: DragEvent) => { + const element = event.currentTarget.closest("li"); + if (!element) return; + const visual = element.querySelector( + "[data-sidebar-pane], [data-sidebar-tab]", + ) ?? element; + const rect = visual.getBoundingClientRect(); + const overlay = visual.cloneNode(true) as HTMLElement; + overlay.removeAttribute("data-chat-row"); + overlay.removeAttribute("data-sidebar-pane"); + overlay.removeAttribute("data-sidebar-tab"); + overlay.setAttribute("data-pane-drag-overlay", "true"); + overlay.setAttribute("aria-hidden", "true"); + overlay.classList.add( + "!bg-sidebar-selected", + "!shadow-none", + ); + overlay.querySelectorAll("button, [tabindex]").forEach((child) => { + child.tabIndex = -1; + }); + Object.assign(overlay.style, { + position: "fixed", + left: `${rect.left}px`, + top: `${rect.top}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + margin: "0", + opacity: "1", + visibility: "visible", + pointerEvents: "none", + zIndex: "2147483647", + transform: "translate3d(0, 0, 0)", + transition: "none", + boxShadow: "none", + }); + document.body.append(overlay); + const pointerX = event.clientX || rect.left + rect.width / 2; + const pointerY = event.clientY || rect.top + rect.height / 2; + paneDragMotionRef.current = { + frame: null, + grabOffsetX: pointerX - rect.left, + grabOffsetY: pointerY - rect.top, + originHeight: rect.height, + originLeft: rect.left, + originTop: rect.top, + originWidth: rect.width, + overlay, + pointerX, + pointerY, + snapHeight: null, + snapLeft: null, + snapTop: null, + snapWidth: null, + }; + hideNativeDragPreview(event.dataTransfer); + }, []); + + const clearPaneDragMotion = useCallback(() => { + const motion = paneDragMotionRef.current; + if (!motion) return; + if (motion.frame !== null) window.cancelAnimationFrame(motion.frame); + motion.overlay.remove(); + paneDragMotionRef.current = null; + }, []); + const resetDragState = useCallback(() => { + clearPaneDragMotion(); clearDraggedSession(); - setDraggedSessionKey(null); - setDraggedPane(null); + setPaneDrag(null); setSessionDropTarget(null); - updateTabAttachTarget(null); - setDraggedSessionHeight(0); - }, [updateTabAttachTarget]); + }, [clearPaneDragMotion]); + + const finishDragState = useCallback(() => { + resetDragState(); + }, [resetDragState]); useLayoutEffect(() => { const previousRects = pendingTabRectsRef.current; @@ -357,11 +529,38 @@ export const ChatList = memo(function ChatList({ } }, { once: true }); } - }, [measureTabRows, tabAttachTargetKey]); + }, [ + collapsedPaneGroups, + measureTabRows, + paneDrag?.slot?.beforePaneKey, + paneDrag?.slot?.tabKey, + ]); + + useLayoutEffect(() => { + const motion = paneDragMotionRef.current; + if (!motion) return; + const slot = paneDrag?.slot + ? Array.from(document.querySelectorAll("[data-pane-snap-slot]")) + .find((element) => element.dataset.paneSnapTab === paneDrag.slot?.tabKey) + : null; + if (slot) { + updatePaneDragSnap(motion, slot); + const reduceMotion = typeof window.matchMedia === "function" + && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + motion.overlay.style.transition = reduceMotion + ? "none" + : "transform 140ms cubic-bezier(0.2, 0, 0, 1), width 140ms cubic-bezier(0.2, 0, 0, 1), height 140ms cubic-bezier(0.2, 0, 0, 1)"; + } else { + updatePaneDragSnap(motion, null); + motion.overlay.style.transition = "none"; + } + positionPaneDragMotion(motion); + }, [paneDrag?.slot?.beforePaneKey, paneDrag?.slot?.tabKey]); useEffect(() => () => { + clearPaneDragMotion(); for (const animation of tabLayoutAnimationsRef.current.values()) animation.cancel(); - }, []); + }, [clearPaneDragMotion]); if (loading && sessions.length === 0 && temporarySessions.length === 0) { return ( @@ -424,9 +623,6 @@ export const ChatList = memo(function ChatList({ requestDeleteKeys(Array.from(selectedDeleteKeys)); closeDeleteSelection(); }; - const draggedItemTitle = draggedPane - ? deleteItemsByKey.get(draggedPane.paneKey)?.label - : draggedSessionKey ? deleteItemsByKey.get(draggedSessionKey)?.label : undefined; const reorderSession = (targetKey: string, edge: "before" | "after") => { if (!draggedSessionKey || !canReorderSession(targetKey) || !onReorderSessions) return; const keys = groups.flatMap((group) => group.sessions.map((session) => session.key)); @@ -442,11 +638,12 @@ export const ChatList = memo(function ChatList({ }; return ( -
- updatePaneDragMotion(event.clientX, event.clientY)} + onDragOverCapture={(event) => updatePaneDragMotion(event.clientX, event.clientY)} + > +
@@ -454,7 +651,6 @@ export const ChatList = memo(function ChatList({ 1 + && collapsedPaneGroups.has(s.key); + const paneGroupExpanded = !paneGroupCollapsed; + const active = topicActive && (paneCount === 1 || paneGroupCollapsed); + const paneGroupId = `sidebar-pane-group-${s.key.replace( + /[^a-zA-Z0-9_-]/g, + "-", + )}`; const tabDeleteKeys = resolvedPaneGroup.panes.map((pane) => pane.key); const tabSelected = tabDeleteKeys.every((key) => ( selectedDeleteKeys.has(key) @@ -530,7 +734,6 @@ export const ChatList = memo(function ChatList({ const tabPartiallySelected = !tabSelected && tabDeleteKeys.some((key) => ( selectedDeleteKeys.has(key) )); - const isAttachTarget = tabAttachTargetKey === s.key; const tooltipTitle = titleOverrides[s.key]?.trim() || generatedTitle || @@ -548,6 +751,16 @@ export const ChatList = memo(function ChatList({ : updated.has(s.chatId) && !topicActive ? "updated" : null; + const tabActivityState = resolvedPaneGroup.panes.some((pane) => ( + running.has(pane.chatId) + )) + ? "running" + : resolvedPaneGroup.panes.some((pane) => ( + updated.has(pane.chatId) + && (!topicActive || pane.key !== resolvedPaneGroup.activePaneKey) + )) + ? "updated" + : activityState; return (
  • 1 ? "true" : undefined} + data-pane-group-collapsed={paneGroupCollapsed ? "true" : undefined} className={cn( - "relative min-w-0 rounded-xl transition-[transform,opacity,background-color,box-shadow] duration-200 [transition-timing-function:cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none", - draggedSessionKey === s.key && "opacity-0", - isAttachTarget - && "bg-sidebar-accent/35 shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border))]", + "relative min-w-0 rounded-[0.7rem] transition-transform duration-200 [transition-timing-function:cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none", + paneCount > 1 && "my-1", )} style={{ transform: reorderOffsets.has(s.key) @@ -570,35 +782,11 @@ export const ChatList = memo(function ChatList({ : undefined, }} onDragOver={(event) => { - const rect = event.currentTarget.getBoundingClientRect(); - const relativeY = rect.height > 0 - ? (event.clientY - rect.top) / rect.height - : 0.5; - const paneCanAttach = Boolean( - !deleteSelectionMode - && draggedPane - && draggedPane.sourceTabKey !== s.key - && paneAcceptingTabs.has(s.key) - && onAttachPane, - ); - const tabCanAttach = Boolean( - !deleteSelectionMode - && draggedSessionKey - && draggedSessionKey !== s.key - && attachableTabs.has(draggedSessionKey) - && paneAcceptingTabs.has(s.key) - && relativeY >= 0.25 - && relativeY <= 0.75 - && onAttachPane, - ); - if (paneCanAttach || tabCanAttach) { - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - setSessionDropTarget(null); - updateTabAttachTarget(s.key); - return; - } - updateTabAttachTarget(null); + updatePaneDropSlot(null); + const rect = event.currentTarget + .querySelector(":scope > [data-sidebar-tab]") + ?.getBoundingClientRect() + ?? event.currentTarget.getBoundingClientRect(); if (!canReorderSession(s.key)) return; event.preventDefault(); event.dataTransfer.dropEffect = "move"; @@ -613,18 +801,12 @@ export const ChatList = memo(function ChatList({ )); }} onDrop={(event) => { - if (tabAttachTargetKey === s.key && onAttachPane) { - const paneKey = draggedPane?.paneKey ?? draggedSessionKey; - if (paneKey) { - event.preventDefault(); - onAttachPane(paneKey, s.key); - } - resetDragState(); - return; - } if (!canReorderSession(s.key)) return; event.preventDefault(); - const rect = event.currentTarget.getBoundingClientRect(); + const rect = event.currentTarget + .querySelector(":scope > [data-sidebar-tab]") + ?.getBoundingClientRect() + ?? event.currentTarget.getBoundingClientRect(); const edge = event.clientY < rect.top + rect.height / 2 ? "before" : "after"; @@ -633,20 +815,21 @@ export const ChatList = memo(function ChatList({ }} >
    1 && !active + && "bg-sidebar-foreground/[0.05] dark:bg-white/[0.065]", active - ? "text-sidebar-accent-foreground" - : "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]", - isAttachTarget - && "bg-sidebar-accent/65 text-sidebar-accent-foreground", + ? "bg-sidebar-selected text-sidebar-accent-foreground" + : "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.075] hover:text-sidebar-foreground dark:hover:bg-white/[0.09]", deleteSelectionMode && (tabSelected || tabPartiallySelected) && "bg-sidebar-accent/55 text-sidebar-accent-foreground", + draggingTab + && "!bg-transparent !text-transparent !shadow-none [&_*]:!text-transparent", )} > - - {!deleteSelectionMode ? + {draggedSessionKey !== s.key + && (paneCount === 1 || paneGroupCollapsed) ? ( + + ) : null} + {!deleteSelectionMode && draggedSessionKey !== s.key ? ( + 1 ? "opacity-0" : "opacity-40", "hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100", "focus-visible:opacity-100", - topicActive && "opacity-100", + active && "opacity-100", )} aria-label={t("chat.actions", { title })} > @@ -766,17 +949,6 @@ export const ChatList = memo(function ChatList({ portalContainer={actionMenuPortalContainer} onCloseAutoFocus={(event) => event.preventDefault()} > - {paneGroup - && paneGroup.panes.findIndex((pane) => pane.key === s.key) > 0 - && onPromotePane ? ( - onPromotePane(s.key, s.key)}> - - {t("workbench.promotePane", { - defaultValue: "Make {{title}} the primary pane", - title, - })} - - ) : null} onTogglePin(s.key)} > @@ -825,14 +997,52 @@ export const ChatList = memo(function ChatList({ {t("chat.delete")} - : null} + + ) : null} + {!deleteSelectionMode + && draggedSessionKey !== s.key + && paneCount > 1 ? ( + + ) : null}
    - {paneCount > 1 || isAttachTarget ? ( + {paneCount > 1 && paneGroupExpanded ? ( { + updatePaneDropSlot(slot); + }} onPaneDragStart={(event, pane) => { - setDraggedPane(pane); - setDraggedSessionKey(null); + beginPaneDragMotion(event); setSessionDropTarget(null); - updateTabAttachTarget(null); - setDraggedSessionHeight( - event.currentTarget.closest("li")?.getBoundingClientRect().height - ?? event.currentTarget.getBoundingClientRect().height, - ); + const measuredHeight = event.currentTarget.closest("li") + ?.getBoundingClientRect().height + ?? event.currentTarget.getBoundingClientRect().height; + const height = measuredHeight > 0 + ? measuredHeight + : DEFAULT_PANE_ROW_HEIGHT; + setPaneDrag({ origin: "pane", item: pane, height, slot: null }); writeDraggedPane(event.dataTransfer, pane); }} - onPaneDragEnd={resetDragState} + onPaneDragEnd={finishDragState} actionMenuPortalContainer={actionMenuPortalContainer} /> ) : null} @@ -930,7 +1140,7 @@ export const ChatList = memo(function ChatList({
  • ) : null} -
    +
    ); }); @@ -951,10 +1161,12 @@ function sessionReorderOffsets( const finalIndex = targetIndex + (target.edge === "after" ? 1 : 0); if (sourceIndex < finalIndex) { + offsets.set(draggedKey, (finalIndex - sourceIndex) * draggedHeight); for (let index = sourceIndex + 1; index <= finalIndex; index += 1) { offsets.set(keys[index], -draggedHeight); } } else if (sourceIndex > finalIndex) { + offsets.set(draggedKey, (finalIndex - sourceIndex) * draggedHeight); for (let index = finalIndex; index < sourceIndex; index += 1) { offsets.set(keys[index], draggedHeight); } @@ -963,10 +1175,10 @@ function sessionReorderOffsets( } function ActivePaneRows({ + id, group, tabTitle, tabActive, - activeRowRef, running, updated, onSelectPane, @@ -980,16 +1192,16 @@ function ActivePaneRows({ selectedDeleteKeys, onToggleDeleteSelection, onBeginDeleteSelection, - dropPreview, - draggedPaneKey, + paneDrag, + onPaneDropSlotChange, onPaneDragStart, onPaneDragEnd, actionMenuPortalContainer, }: { + id: string; group: SidebarPaneGroup; tabTitle: string; tabActive: boolean; - activeRowRef: RefObject; running: ReadonlySet; updated: ReadonlySet; onSelectPane?: (tabKey: string, paneKey: string) => void; @@ -998,32 +1210,86 @@ function ActivePaneRows({ onDetachPane?: (tabKey: string, paneKey: string) => void; onPromotePane?: (tabKey: string, paneKey: string) => void; moveTargets: Array<{ key: string; title: string }>; - onAttachPane?: (paneKey: string, tabKey: string) => void; + onAttachPane?: ( + paneKey: string, + tabKey: string, + beforePaneKey?: string | null, + ) => void; deleteSelectionMode: boolean; selectedDeleteKeys: ReadonlySet; onToggleDeleteSelection: (keys: string[]) => void; onBeginDeleteSelection: (keys: string[]) => void; - dropPreview: { paneTitle: string; targetTitle: string } | null; - draggedPaneKey: string | null; + paneDrag: PaneTabDragState | null; + onPaneDropSlotChange: (slot: PaneDropSlot) => void; onPaneDragStart: (event: DragEvent, pane: DraggedPane) => void; onPaneDragEnd: () => void; actionMenuPortalContainer?: HTMLElement | null; }) { const { t } = useTranslation(); - const childPanes = group.panes.filter((pane) => pane.key !== group.topicKey); + const panes = group.panes; + const paneKeys = panes.map((pane) => pane.key); + const draggedPane = paneDrag?.item ?? null; + const ownsDraggedPane = paneDrag?.origin === "pane" + && draggedPane?.sourceTabKey === group.topicKey; + const activeDropSlot = ownsDraggedPane && paneDrag?.slot?.tabKey === group.topicKey + ? paneDrag.slot + : null; + const dragLayout = paneTabDragLayout( + paneKeys, + group.topicKey, + paneDrag, + ); + const commitPaneDrop = () => { + if (!draggedPane || !activeDropSlot || !onAttachPane) { + return; + } + onAttachPane( + draggedPane.paneKey, + group.topicKey, + activeDropSlot.beforePaneKey, + ); + onPaneDragEnd(); + }; return (
      { + if (!activeDropSlot) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }} + onDropCapture={(event) => { + if (!draggedPane || !activeDropSlot || !onAttachPane) return; + event.preventDefault(); + event.stopPropagation(); + commitPaneDrop(); + }} > - {childPanes.map((pane) => { + {activeDropSlot && paneDrag && dragLayout.slotIndex >= 0 ? ( +
    • { + if (!draggedPane || !ownsDraggedPane || draggedPane.paneKey === pane.key) { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "move"; + const rect = event.currentTarget.getBoundingClientRect(); + onPaneDropSlotChange(paneDropSlotForRow( + group.topicKey, + paneKeys, + draggedPane.paneKey, + pane.key, + event.clientY < rect.top + rect.height / 2 ? "before" : "after", + )); + }} >
      - - {!deleteSelectionMode ? + {!dragging ? : null} + {!deleteSelectionMode && !dragging ? {t("chat.rename")} - {onDetachPane ? ( + {pane.key !== group.topicKey && onDetachPane ? ( onDetachPane(group.topicKey, pane.key)}> {t("workbench.detachPane", { - defaultValue: "Move {{title}} to its own topic", + defaultValue: "Move {{title}} to a new tab", title: pane.title, })} ) : null} - {onAttachPane ? ( + {pane.key !== group.topicKey && onAttachPane ? ( onAttachPane(pane.key, targetKey)} @@ -1155,23 +1454,6 @@ function ActivePaneRows({
    • ); })} - {dropPreview ? ( -
    • -
      - - {dropPreview.paneTitle} -
      -
    • - ) : null}
    ); } @@ -1224,14 +1506,12 @@ function MoveToTabSubmenu({ function TemporaryChatSection({ sessions, activeKey, - activeRowRef, running, onSelect, onClose, }: { sessions: ChatSummary[]; activeKey: string | null; - activeRowRef: RefObject; running: ReadonlySet; onSelect: (key: string) => void; onClose?: (key: string) => void; @@ -1248,13 +1528,12 @@ function TemporaryChatSection({ return (
  • diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index 02fd3817e..52cf58f45 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -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; diff --git a/webui/src/components/pane-tab-drag.ts b/webui/src/components/pane-tab-drag.ts new file mode 100644 index 000000000..75107cacc --- /dev/null +++ b/webui/src/components/pane-tab-drag.ts @@ -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; + 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(); + 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 }; +} diff --git a/webui/src/components/workbench/workbench-model.ts b/webui/src/components/workbench/workbench-model.ts index fa486c43c..5d959d09e 100644 --- a/webui/src/components/workbench/workbench-model.ts +++ b/webui/src/components/workbench/workbench-model.ts @@ -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 @@ -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 }; } diff --git a/webui/src/globals.css b/webui/src/globals.css index b0cf216bd..f341e86ee 100644 --- a/webui/src/globals.css +++ b/webui/src/globals.css @@ -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); diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 76eee9cae..64f0c4e7d 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -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", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 6f73a6ccf..c9fa3bb02 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -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", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 6ba595432..04d1f4d36 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -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", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 28e2a9584..9be1151df 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -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", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index fae520602..b7ff364bd 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -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": "列", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 3ff8b67d8..f1d3f91a6 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -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": "열", diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index 8a769c4cc..0139b15ba 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -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", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index c70d1afbd..d9c3ba6e2 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -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", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 8581f0c0f..28b04e27a 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -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": "列布局", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 0dcbbf759..6db8e55d9 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -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": "欄佈局", diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index f6b25f568..ce37d03dd 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -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); diff --git a/webui/src/tests/chat-list.test.tsx b/webui/src/tests/chat-list.test.tsx index cef3424b6..92fd58ec3 100644 --- a/webui/src/tests/chat-list.test.tsx +++ b/webui/src/tests/chat-list.test.tsx @@ -72,6 +72,7 @@ describe("ChatList", () => { const dataTransfer = { effectAllowed: "", setData: vi.fn(), + setDragImage: vi.fn(), }; render( { 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( + , + ); + + 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( + , + ); + + 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("[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( + , + ); + + 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( { 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(); - 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 () => { diff --git a/webui/src/tests/pane-tab-drag.test.ts b/webui/src/tests/pane-tab-drag.test.ts new file mode 100644 index 000000000..aa69a21c5 --- /dev/null +++ b/webui/src/tests/pane-tab-drag.test.ts @@ -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 { + 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({}); + }); +}); diff --git a/webui/src/tests/workbench-model.test.ts b/webui/src/tests/workbench-model.test.ts index 74b3894f4..273f1f4dc 100644 --- a/webui/src/tests/workbench-model.test.ts +++ b/webui/src/tests/workbench-model.test.ts @@ -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"); diff --git a/webui/tailwind.config.js b/webui/tailwind.config.js index 27d917254..dd0387ef1 100644 --- a/webui/tailwind.config.js +++ b/webui/tailwind.config.js @@ -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))",