fix(ui): make right panel drag reorder work

Replace the native HTML drag handlers on right-panel tabs and Status sections with the same solid-dnd sortable primitives used by the main instance tabs. This keeps direct drag-to-reorder interaction reliable across the panel customization UI while preserving the existing persisted order model.

Validation: npm exec --no -- tsx --test packages/ui/src/components/instance/shell/right-panel/registry.test.ts; npm run typecheck --workspace @codenomad/ui; git diff --check.
This commit is contained in:
Pascal André 2026-07-25 13:04:25 +02:00
parent 137b8c00ab
commit 6bcc52567d
No known key found for this signature in database
3 changed files with 124 additions and 84 deletions

View file

@ -12,6 +12,14 @@ import {
} from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2"
import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client"
import {
DragDropProvider,
DragDropSensors,
SortableProvider,
closestCenter,
createSortable,
type DragEvent as SolidDndDragEvent,
} from "@thisbeyond/solid-dnd"
import IconButton from "@suid/material/IconButton"
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
import PushPinIcon from "@suid/icons-material/PushPin"
@ -82,6 +90,32 @@ function RightPanelTabFallback() {
return <div class="flex-1 min-h-0" />
}
interface SortableRightPanelTabProps {
tab: RightPanelTabModule
active: boolean
label: string
dragTitle: string
onSelect: () => void
}
const SortableRightPanelTab: Component<SortableRightPanelTabProps> = (props) => {
const sortable = createSortable(props.tab.id)
return (
<div ref={sortable} class={`right-panel-tab-draggable ${sortable.isActiveDraggable ? "right-panel-tab-draggable-active" : ""}`}>
<button
type="button"
role="tab"
class={`right-panel-tab ${props.active ? "right-panel-tab-active" : "right-panel-tab-inactive"}`}
aria-selected={props.active}
title={props.dragTitle}
onClick={props.onSelect}
>
<span class="tab-label">{props.label}</span>
</button>
</div>
)
}
interface RightPanelProps {
t: (key: string, vars?: Record<string, any>) => string
@ -118,7 +152,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
const [rightPanelCustomization, setRightPanelCustomization] = createSignal<RightPanelCustomization>(
parseRightPanelCustomization(readClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY)),
)
const [draggedTabId, setDraggedTabId] = createSignal<string | null>(null)
const [browserPath, setBrowserPath] = createSignal(".")
const [browserEntries, setBrowserEntries] = createSignal<FileNode[] | null>(null)
@ -689,8 +722,10 @@ const RightPanel: Component<RightPanelProps> = (props) => {
}))
}
const tabClass = (tab: RightPanelTab) =>
`right-panel-tab ${rightPanelTab() === tab ? "right-panel-tab-active" : "right-panel-tab-inactive"}`
const handleTabDragEnd = ({ draggable, droppable }: SolidDndDragEvent) => {
if (!droppable) return
moveTab(String(draggable.id), String(droppable.id))
}
const rightPanelModules = createMemo<RightPanelModule[]>(() => [
{
@ -883,38 +918,23 @@ const RightPanel: Component<RightPanelProps> = (props) => {
<div class="tab-scroll">
<div class="tab-strip">
<div class="tab-strip-tabs" role="tablist" aria-label={props.t("instanceShell.rightPanel.tabs.ariaLabel")}>
<For each={visibleRightPanelTabs()}>
{(tab) => (
<button
type="button"
role="tab"
class={tabClass(tab.id)}
aria-selected={rightPanelTab() === tab.id}
title={props.t("instanceShell.rightPanel.customize.dragToReorder")}
onClick={() => setRightPanelTab(tab.id)}
draggable
onDragStart={(event) => {
setDraggedTabId(tab.id)
event.dataTransfer?.setData("text/plain", tab.id)
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"
}}
onDragOver={(event) => {
if (!draggedTabId() || draggedTabId() === tab.id) return
event.preventDefault()
if (event.dataTransfer) event.dataTransfer.dropEffect = "move"
}}
onDrop={(event) => {
event.preventDefault()
const sourceId = event.dataTransfer?.getData("text/plain") || draggedTabId()
if (sourceId) moveTab(sourceId, tab.id)
setDraggedTabId(null)
}}
onDragEnd={() => setDraggedTabId(null)}
>
<span class="tab-label">{props.t(tab.labelKey)}</span>
</button>
)}
</For>
<DragDropProvider collisionDetector={closestCenter} onDragEnd={handleTabDragEnd}>
<DragDropSensors>
<SortableProvider ids={visibleRightPanelTabs().map((tab) => tab.id)}>
<For each={visibleRightPanelTabs()}>
{(tab) => (
<SortableRightPanelTab
tab={tab}
active={rightPanelTab() === tab.id}
label={props.t(tab.labelKey)}
dragTitle={props.t("instanceShell.rightPanel.customize.dragToReorder")}
onSelect={() => setRightPanelTab(tab.id)}
/>
)}
</For>
</SortableProvider>
</DragDropSensors>
</DragDropProvider>
</div>
<div class="tab-strip-spacer" />

View file

@ -1,5 +1,13 @@
import { For, Show, createMemo, createSignal, type Accessor, type Component } from "solid-js"
import { For, Show, createMemo, type Accessor, type Component } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2"
import {
DragDropProvider,
DragDropSensors,
SortableProvider,
closestCenter,
createSortable,
type DragEvent as SolidDndDragEvent,
} from "@thisbeyond/solid-dnd"
import { Accordion } from "@kobalte/core"
import { Tooltip } from "@kobalte/core/tooltip"
import Switch from "@suid/material/Switch"
@ -41,9 +49,42 @@ interface StatusTabProps {
extraSections?: readonly RightPanelSectionModule[]
}
interface SortableStatusSectionProps {
section: RightPanelSectionModule
expanded: boolean
t: (key: string, vars?: Record<string, any>) => string
}
const SortableStatusSection: Component<SortableStatusSectionProps> = (props) => {
const sortable = createSortable(props.section.id)
return (
<div ref={sortable} class={`right-panel-section-draggable ${sortable.isActiveDraggable ? "right-panel-section-draggable-active" : ""}`}>
<Accordion.Item value={props.section.id} class="right-panel-accordion-item">
<Accordion.Header class="right-panel-accordion-header-row">
<Accordion.Trigger class="right-panel-accordion-trigger">
<span class="section-left">
<GripVertical class="h-3.5 w-3.5 text-tertiary" aria-hidden="true" />
<span class="section-label">{props.t(props.section.labelKey)}</span>
</span>
<ChevronDown class={`right-panel-accordion-chevron ${props.expanded ? "right-panel-accordion-chevron-expanded" : ""}`} />
</Accordion.Trigger>
<Tooltip openDelay={200} gutter={4} placement="top">
<Tooltip.Trigger as="button" type="button" class="section-info-trigger" aria-label={props.t(props.section.tooltipKey)}>
<Info class="section-info-icon" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content class="section-info-tooltip">{props.t(props.section.tooltipKey)}</Tooltip.Content>
</Tooltip.Portal>
</Tooltip>
</Accordion.Header>
<Accordion.Content class="right-panel-accordion-content">{props.section.render()}</Accordion.Content>
</Accordion.Item>
</div>
)
}
const StatusTab: Component<StatusTabProps> = (props) => {
const isSectionExpanded = (id: string) => props.expandedItems().includes(id)
const [draggedSectionId, setDraggedSectionId] = createSignal<string | null>(null)
const renderYoloModeSection = () => {
const session = props.activeSession()
@ -259,6 +300,11 @@ const StatusTab: Component<StatusTabProps> = (props) => {
props.onCustomizationChange((current) => ({ ...current, statusSectionOrder: next }))
}
const handleSectionDragEnd = ({ draggable, droppable }: SolidDndDragEvent) => {
if (!droppable) return
moveSection(String(draggable.id), String(droppable.id))
}
return (
<div class="status-tab-container">
<Show when={props.activeSession()}>
@ -274,53 +320,15 @@ const StatusTab: Component<StatusTabProps> = (props) => {
value={props.expandedItems()}
onChange={props.onExpandedItemsChange}
>
<For each={statusSections()}>
{(section) => (
<Accordion.Item
value={section.id}
class="right-panel-accordion-item"
draggable
onDragStart={(event) => {
setDraggedSectionId(section.id)
event.dataTransfer?.setData("text/plain", section.id)
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"
}}
onDragOver={(event) => {
if (!draggedSectionId() || draggedSectionId() === section.id) return
event.preventDefault()
if (event.dataTransfer) event.dataTransfer.dropEffect = "move"
}}
onDrop={(event) => {
event.preventDefault()
const sourceId = event.dataTransfer?.getData("text/plain") || draggedSectionId()
if (sourceId) moveSection(sourceId, section.id)
setDraggedSectionId(null)
}}
onDragEnd={() => setDraggedSectionId(null)}
>
<Accordion.Header class="right-panel-accordion-header-row">
<Accordion.Trigger class="right-panel-accordion-trigger">
<span class="section-left">
<GripVertical class="h-3.5 w-3.5 text-tertiary" aria-hidden="true" />
<span class="section-label">{props.t(section.labelKey)}</span>
</span>
<ChevronDown
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
/>
</Accordion.Trigger>
<Tooltip openDelay={200} gutter={4} placement="top">
<Tooltip.Trigger as="button" type="button" class="section-info-trigger" aria-label={props.t(section.tooltipKey)}>
<Info class="section-info-icon" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content class="section-info-tooltip">{props.t(section.tooltipKey)}</Tooltip.Content>
</Tooltip.Portal>
</Tooltip>
</Accordion.Header>
<Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content>
</Accordion.Item>
)}
</For>
<DragDropProvider collisionDetector={closestCenter} onDragEnd={handleSectionDragEnd}>
<DragDropSensors>
<SortableProvider ids={statusSections().map((section) => section.id)}>
<For each={statusSections()}>
{(section) => <SortableStatusSection section={section} expanded={isSectionExpanded(section.id)} t={props.t} />}
</For>
</SortableProvider>
</DragDropSensors>
</DragDropProvider>
</Accordion.Root>
</div>
)

View file

@ -47,6 +47,14 @@
z-index: 1;
}
.right-panel-tab-draggable {
display: inline-flex;
}
.right-panel-tab-draggable-active {
opacity: 0.75;
}
.right-panel-tab:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
@ -769,6 +777,10 @@
min-height: 0;
}
.right-panel-section-draggable-active {
opacity: 0.75;
}
.right-panel-customization-popover {
position: absolute;
top: 2.75rem;