diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3049789a..c27fa170 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased]
### 🚀 Added
+- Sidebar: drag-to-reorder workspace projects with dnd-kit, persisted sort order, and migration backfill for existing databases. (#87)
- Workspace canvas: arrange all / arrange canvas / arrange in space actions. (#42)
- Workspace canvas: Arrange By menu (scope, ordering, space sizing, magnetic snapping). (#42)
- Workspace canvas: live magnetic snap guides for node dragging, aligned to the 24px canvas rhythm and enabled by default. (#42)
diff --git a/src/app/renderer/shell/AppShell.tsx b/src/app/renderer/shell/AppShell.tsx
index 01801ff4..2be5cfd3 100644
--- a/src/app/renderer/shell/AppShell.tsx
+++ b/src/app/renderer/shell/AppShell.tsx
@@ -27,7 +27,6 @@ import { useFloatingMessage } from './hooks/useFloatingMessage'
import { useWorkspaceStateHandlers } from './hooks/useWorkspaceStateHandlers'
import { useAppUpdates } from './hooks/useAppUpdates'
import { useWhatsNew } from './hooks/useWhatsNew'
-import type { ProjectContextMenuState } from './types'
import { useAppStore } from './store/useAppStore'
import { removeWorkspace } from './utils/removeWorkspace'
import { WhatsNewDialog } from './components/WhatsNewDialog'
@@ -285,6 +284,21 @@ export default function App(): React.JSX.Element {
store.setProjectContextMenu(null)
}, [])
+ const handleReorderWorkspaces = useCallback(
+ (activeId: string, overId: string): void => {
+ const store = useAppStore.getState()
+ store.reorderWorkspaces(activeId, overId)
+ requestPersistFlush()
+ },
+ [requestPersistFlush],
+ )
+
+ const handleOpenSettings = useCallback((): void => {
+ setIsFocusNodeTargetZoomPreviewing(false)
+ closeControlCenter()
+ setIsSettingsOpen(true)
+ }, [closeControlCenter, setIsSettingsOpen])
+
return (
<>
{
- toggleControlCenter()
- }}
- onToggleCommandCenter={() => {
- toggleCommandCenter()
- }}
- onOpenSettings={() => {
- setIsFocusNodeTargetZoomPreviewing(false)
- closeControlCenter()
- setIsSettingsOpen(true)
- }}
- onCheckForUpdates={() => {
- void checkForUpdates()
- }}
- onDownloadUpdate={() => {
- void downloadUpdate()
- }}
- onInstallUpdate={() => {
- void installUpdate()
- }}
+ onToggleControlCenter={toggleControlCenter}
+ onToggleCommandCenter={toggleCommandCenter}
+ onOpenSettings={handleOpenSettings}
+ onCheckForUpdates={checkForUpdates}
+ onDownloadUpdate={downloadUpdate}
+ onInstallUpdate={installUpdate}
/>
{isPrimarySidebarCollapsed ? null : (
@@ -333,18 +333,11 @@ export default function App(): React.JSX.Element {
activeProviderLabel={activeProviderLabel}
activeProviderModel={activeProviderModel}
persistNotice={persistNotice}
- onAddWorkspace={() => {
- void handleAddWorkspace()
- }}
- onSelectWorkspace={workspaceId => {
- handleSelectWorkspace(workspaceId)
- }}
- onOpenProjectContextMenu={(state: ProjectContextMenuState) => {
- setProjectContextMenu(state)
- }}
- onSelectAgentNode={(workspaceId, nodeId) => {
- handleSelectAgentNode(workspaceId, nodeId)
- }}
+ onAddWorkspace={handleAddWorkspace}
+ onSelectWorkspace={handleSelectWorkspace}
+ onOpenProjectContextMenu={setProjectContextMenu}
+ onSelectAgentNode={handleSelectAgentNode}
+ onReorderWorkspaces={handleReorderWorkspaces}
/>
)}
@@ -361,9 +354,7 @@ export default function App(): React.JSX.Element {
!isSpaceArchivesOpen &&
projectDeleteConfirmation === null
}
- onAddWorkspace={() => {
- void handleAddWorkspace()
- }}
+ onAddWorkspace={handleAddWorkspace}
onShowMessage={handleShowMessage}
onRequestPersistFlush={requestPersistFlush}
onAppendSpaceArchiveRecord={handleWorkspaceSpaceArchiveRecordAppend}
@@ -378,9 +369,7 @@ export default function App(): React.JSX.Element {
isOpen={isWorkspaceSearchOpen}
activeWorkspace={activeWorkspace}
onClose={closeWorkspaceSearch}
- onSelectSpace={spaceId => {
- handleWorkspaceActiveSpaceChange(spaceId)
- }}
+ onSelectSpace={handleWorkspaceActiveSpaceChange}
panelWidth={agentSettings.workspaceSearchPanelWidth}
onPanelWidthChange={nextWidth => {
setAgentSettings(prev => ({
@@ -403,11 +392,7 @@ export default function App(): React.JSX.Element {
isControlCenterOpen={isControlCenterOpen}
onCloseControlCenter={closeControlCenter}
onMinimapVisibilityChange={handleWorkspaceMinimapVisibilityChange}
- onOpenSettings={() => {
- setIsFocusNodeTargetZoomPreviewing(false)
- closeControlCenter()
- setIsSettingsOpen(true)
- }}
+ onOpenSettings={handleOpenSettings}
/>
{
- closeCommandCenter()
- }}
- onOpenSettings={() => {
- setIsFocusNodeTargetZoomPreviewing(false)
- setIsSettingsOpen(true)
- }}
- onOpenSpaceArchives={() => {
- openSpaceArchives()
- }}
+ onClose={closeCommandCenter}
+ onOpenSettings={handleOpenSettings}
+ onOpenSpaceArchives={openSpaceArchives}
onTogglePrimarySidebar={() => {
setAgentSettings(prev => ({
...prev,
isPrimarySidebarCollapsed: !prev.isPrimarySidebarCollapsed,
}))
}}
- onAddWorkspace={() => {
- void handleAddWorkspace()
- }}
- onSelectWorkspace={workspaceId => {
- handleSelectWorkspace(workspaceId)
- }}
- onSelectSpace={spaceId => {
- handleWorkspaceActiveSpaceChange(spaceId)
- }}
+ onAddWorkspace={handleAddWorkspace}
+ onSelectWorkspace={handleSelectWorkspace}
+ onSelectSpace={handleWorkspaceActiveSpaceChange}
/>
{
- handleRequestRemoveProject(workspaceId)
- }}
+ onRequestRemove={handleRequestRemoveProject}
/>
) : null}
@@ -480,23 +450,15 @@ export default function App(): React.JSX.Element {
updateState={updateState}
modelCatalogByProvider={providerModelCatalog}
workspaces={workspaces}
- onWorkspaceWorktreesRootChange={(id, root) => {
- handleAnyWorkspaceWorktreesRootChange(id, root)
- }}
+ onWorkspaceWorktreesRootChange={handleAnyWorkspaceWorktreesRootChange}
isFocusNodeTargetZoomPreviewing={isFocusNodeTargetZoomPreviewing}
onFocusNodeTargetZoomPreviewChange={setIsFocusNodeTargetZoomPreviewing}
onChange={next => {
setAgentSettings(next)
}}
- onCheckForUpdates={() => {
- void checkForUpdates()
- }}
- onDownloadUpdate={() => {
- void downloadUpdate()
- }}
- onInstallUpdate={() => {
- void installUpdate()
- }}
+ onCheckForUpdates={checkForUpdates}
+ onDownloadUpdate={downloadUpdate}
+ onInstallUpdate={installUpdate}
onClose={() => {
flushPersistNow()
setIsFocusNodeTargetZoomPreviewing(false)
@@ -512,9 +474,7 @@ export default function App(): React.JSX.Element {
isLoading={whatsNew.isLoading}
error={whatsNew.error}
compareUrl={whatsNew.compareUrl}
- onClose={() => {
- whatsNew.close()
- }}
+ onClose={whatsNew.close}
/>
>
)
diff --git a/src/app/renderer/shell/components/Sidebar.spec.tsx b/src/app/renderer/shell/components/Sidebar.spec.tsx
new file mode 100644
index 00000000..d90639d1
--- /dev/null
+++ b/src/app/renderer/shell/components/Sidebar.spec.tsx
@@ -0,0 +1,210 @@
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ DEFAULT_WORKSPACE_MINIMAP_VISIBLE,
+ DEFAULT_WORKSPACE_VIEWPORT,
+ type WorkspaceState,
+} from '@contexts/workspace/presentation/renderer/types'
+import { Sidebar } from './Sidebar'
+
+const dndState = vi.hoisted(() => ({
+ draggingId: null as string | null,
+ onDragStart: null as ((event: { active: { id: string } }) => void) | null,
+ onDragEnd: null as
+ | ((event: { active: { id: string }; over: { id: string } | null }) => void)
+ | null,
+}))
+
+vi.mock('@dnd-kit/core', () => ({
+ DndContext: ({
+ children,
+ onDragStart,
+ onDragEnd,
+ }: {
+ children: React.ReactNode
+ onDragStart?: (event: { active: { id: string } }) => void
+ onDragEnd?: (event: { active: { id: string }; over: { id: string } | null }) => void
+ }) => {
+ dndState.onDragStart = onDragStart ?? null
+ dndState.onDragEnd = onDragEnd ?? null
+ return <>{children}>
+ },
+ DragOverlay: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ PointerSensor: vi.fn(),
+ closestCenter: vi.fn(),
+ useSensor: vi.fn((_sensor: unknown, options?: unknown) => ({ options })),
+ useSensors: vi.fn((...sensors: unknown[]) => sensors),
+}))
+
+vi.mock('@dnd-kit/sortable', () => ({
+ SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ useSortable: ({ id }: { id: string }) => ({
+ attributes: { 'data-sortable-id': id },
+ listeners: { 'data-drag-listener': 'true' },
+ setNodeRef: () => undefined,
+ transform: null,
+ transition: undefined,
+ isDragging: dndState.draggingId === id,
+ }),
+ verticalListSortingStrategy: vi.fn(),
+}))
+
+vi.mock('@dnd-kit/utilities', () => ({
+ CSS: {
+ Transform: {
+ toString: () => undefined,
+ },
+ },
+}))
+
+function createWorkspace(id: string, options?: { hasAgent?: boolean }): WorkspaceState {
+ return {
+ id,
+ name: id,
+ path: `/tmp/${id}`,
+ worktreesRoot: '',
+ nodes: options?.hasAgent
+ ? [
+ {
+ id: `${id}-agent`,
+ position: { x: 0, y: 0 },
+ width: 320,
+ height: 240,
+ data: {
+ sessionId: `${id}-session`,
+ title: `${id} agent`,
+ width: 320,
+ height: 240,
+ kind: 'agent',
+ status: 'running',
+ startedAt: '2026-03-29T10:00:00.000Z',
+ endedAt: null,
+ exitCode: null,
+ lastError: null,
+ scrollback: null,
+ executionDirectory: `/tmp/${id}`,
+ expectedDirectory: `/tmp/${id}`,
+ agent: {
+ provider: 'codex',
+ prompt: 'ship it',
+ model: 'gpt-5.2-codex',
+ effectiveModel: 'gpt-5.2-codex',
+ launchMode: 'new',
+ resumeSessionId: null,
+ executionDirectory: `/tmp/${id}`,
+ expectedDirectory: `/tmp/${id}`,
+ directoryMode: 'workspace',
+ customDirectory: null,
+ shouldCreateDirectory: false,
+ taskId: null,
+ },
+ task: null,
+ note: null,
+ image: null,
+ document: null,
+ },
+ type: 'default',
+ measured: { width: 320, height: 240 },
+ selected: false,
+ dragging: false,
+ deletable: true,
+ },
+ ]
+ : [],
+ viewport: DEFAULT_WORKSPACE_VIEWPORT,
+ isMinimapVisible: DEFAULT_WORKSPACE_MINIMAP_VISIBLE,
+ spaces: [],
+ activeSpaceId: null,
+ spaceArchiveRecords: [],
+ }
+}
+
+describe('Sidebar', () => {
+ beforeEach(() => {
+ dndState.draggingId = null
+ dndState.onDragStart = null
+ dndState.onDragEnd = null
+ })
+
+ it('renders a drag overlay, dims the dragged item, and reorders on drag end', () => {
+ const onReorderWorkspaces = vi.fn()
+ const { container } = render(
+ undefined}
+ onSelectWorkspace={() => undefined}
+ onOpenProjectContextMenu={() => undefined}
+ onSelectAgentNode={() => undefined}
+ onReorderWorkspaces={onReorderWorkspaces}
+ />,
+ )
+
+ act(() => {
+ dndState.draggingId = 'workspace-b'
+ dndState.onDragStart?.({ active: { id: 'workspace-b' } })
+ })
+
+ const overlayElement = container.querySelector('.workspace-item--drag-overlay')
+ expect(overlayElement).not.toBeNull()
+ expect(overlayElement?.textContent).toContain('workspace-b')
+
+ const draggedGroup = screen.getByTitle('/tmp/workspace-b').closest('.workspace-item-group')
+ expect(draggedGroup).not.toBeNull()
+ expect((draggedGroup as HTMLElement).style.opacity).toBe('0.4')
+
+ act(() => {
+ dndState.draggingId = null
+ dndState.onDragEnd?.({
+ active: { id: 'workspace-b' },
+ over: { id: 'workspace-a' },
+ })
+ })
+
+ expect(onReorderWorkspaces).toHaveBeenCalledWith('workspace-b', 'workspace-a')
+ expect(container.querySelector('.workspace-item--drag-overlay')).toBeNull()
+ })
+
+ it('keeps workspace clicks, context menus, and nested agent clicks working', () => {
+ const onSelectWorkspace = vi.fn()
+ const onOpenProjectContextMenu = vi.fn()
+ const onSelectAgentNode = vi.fn()
+
+ render(
+ undefined}
+ onSelectWorkspace={onSelectWorkspace}
+ onOpenProjectContextMenu={onOpenProjectContextMenu}
+ onSelectAgentNode={onSelectAgentNode}
+ onReorderWorkspaces={() => undefined}
+ />,
+ )
+
+ const workspaceButton = screen.getByTitle('/tmp/workspace-a')
+ const agentButton = screen.getByTestId('workspace-agent-item-workspace-a-workspace-a-agent')
+
+ expect(workspaceButton.getAttribute('data-drag-listener')).toBe('true')
+ expect(agentButton.getAttribute('data-drag-listener')).toBeNull()
+
+ fireEvent.click(workspaceButton)
+ expect(onSelectWorkspace).toHaveBeenCalledWith('workspace-a')
+
+ fireEvent.contextMenu(workspaceButton, { clientX: 120, clientY: 220 })
+ expect(onOpenProjectContextMenu).toHaveBeenCalledWith({
+ workspaceId: 'workspace-a',
+ x: 120,
+ y: 220,
+ })
+
+ fireEvent.click(agentButton)
+ expect(onSelectAgentNode).toHaveBeenCalledWith('workspace-a', 'workspace-a-agent')
+ })
+})
diff --git a/src/app/renderer/shell/components/Sidebar.tsx b/src/app/renderer/shell/components/Sidebar.tsx
index dd0a9117..6d35f9bd 100644
--- a/src/app/renderer/shell/components/Sidebar.tsx
+++ b/src/app/renderer/shell/components/Sidebar.tsx
@@ -1,5 +1,17 @@
-import React from 'react'
-import { useTranslation } from '@app/renderer/i18n'
+import React, { useCallback, useState } from 'react'
+import {
+ DndContext,
+ DragOverlay,
+ PointerSensor,
+ closestCenter,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+ type DragStartEvent,
+} from '@dnd-kit/core'
+import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
+import { CSS } from '@dnd-kit/utilities'
+import { useTranslation, type TranslateFn } from '@app/renderer/i18n'
import { AGENT_PROVIDER_LABEL } from '@contexts/settings/domain/agentSettings'
import type { PersistNotice, ProjectContextMenuState } from '../types'
import { toRelativeTime } from '../utils/format'
@@ -10,7 +22,26 @@ import type {
type SidebarAgentStatus = 'working' | 'standby'
-type SidebarStatusTone = 'working' | 'standby'
+type SidebarProps = {
+ workspaces: WorkspaceState[]
+ activeWorkspaceId: string | null
+ activeProviderLabel: string
+ activeProviderModel: string
+ persistNotice: PersistNotice | null
+ onAddWorkspace: () => void
+ onSelectWorkspace: (workspaceId: string) => void
+ onOpenProjectContextMenu: (state: ProjectContextMenuState) => void
+ onSelectAgentNode: (workspaceId: string, nodeId: string) => void
+ onReorderWorkspaces: (activeId: string, overId: string) => void
+}
+
+type SortableWorkspaceItemProps = {
+ workspace: WorkspaceState
+ isActive: boolean
+ onSelectWorkspace: (workspaceId: string) => void
+ onOpenProjectContextMenu: (state: ProjectContextMenuState) => void
+ onSelectAgentNode: (workspaceId: string, nodeId: string) => void
+}
function resolveSidebarAgentStatus(runtimeStatus: TerminalNodeData['status']): SidebarAgentStatus {
if (runtimeStatus === 'running' || runtimeStatus === 'restoring') {
@@ -20,6 +51,200 @@ function resolveSidebarAgentStatus(runtimeStatus: TerminalNodeData['status']): S
return 'standby'
}
+function getWorkspaceAgents(workspace: WorkspaceState) {
+ return workspace.nodes
+ .filter(node => node.data.kind === 'agent')
+ .sort((left, right) => {
+ const leftTime = left.data.startedAt ? Date.parse(left.data.startedAt) : 0
+ const rightTime = right.data.startedAt ? Date.parse(right.data.startedAt) : 0
+ return rightTime - leftTime
+ })
+}
+
+function getWorkspaceMetaText(workspace: WorkspaceState, t: TranslateFn): string {
+ let terminalCount = 0
+ let agentCount = 0
+ let taskCount = 0
+
+ for (const node of workspace.nodes) {
+ if (node.data.kind === 'terminal') {
+ terminalCount += 1
+ } else if (node.data.kind === 'agent') {
+ agentCount += 1
+ } else if (node.data.kind === 'task') {
+ taskCount += 1
+ }
+ }
+
+ return [
+ t('sidebar.terminals', { count: terminalCount }),
+ t('sidebar.agents', { count: agentCount }),
+ t('sidebar.tasks', { count: taskCount }),
+ ].join(' · ')
+}
+
+function resolveLinkedTaskTitle(workspace: WorkspaceState, nodeId: string, taskId: string | null) {
+ const linkedTaskNode =
+ (taskId
+ ? (workspace.nodes.find(
+ candidate =>
+ candidate.id === taskId && candidate.data.kind === 'task' && candidate.data.task,
+ ) ?? null)
+ : null) ??
+ workspace.nodes.find(
+ candidate =>
+ candidate.data.kind === 'task' && candidate.data.task?.linkedAgentNodeId === nodeId,
+ ) ??
+ null
+
+ return linkedTaskNode && linkedTaskNode.data.kind === 'task' ? linkedTaskNode.data.title : null
+}
+
+function WorkspaceItemContent({
+ workspace,
+ metaText,
+}: {
+ workspace: WorkspaceState
+ metaText: string
+}): React.JSX.Element {
+ return (
+ <>
+ {workspace.name}
+ {workspace.path}
+ {metaText}
+ >
+ )
+}
+
+function WorkspaceAgentItems({
+ workspace,
+ onSelectAgentNode,
+}: {
+ workspace: WorkspaceState
+ onSelectAgentNode: (workspaceId: string, nodeId: string) => void
+}): React.JSX.Element | null {
+ const { t } = useTranslation()
+ const workspaceAgents = getWorkspaceAgents(workspace)
+
+ if (workspaceAgents.length === 0) {
+ return null
+ }
+
+ return (
+
+ {workspaceAgents.map(node => {
+ const provider = node.data.agent?.provider
+ const providerText = provider
+ ? AGENT_PROVIDER_LABEL[provider]
+ : t('sidebar.fallbackAgentLabel')
+ const sidebarAgentStatus = resolveSidebarAgentStatus(node.data.status)
+ const sidebarAgentStatusTone = sidebarAgentStatus
+ const startedText = toRelativeTime(node.data.startedAt)
+ const sidebarAgentStatusText =
+ sidebarAgentStatus === 'working'
+ ? t('sidebar.status.working')
+ : t('sidebar.status.standby')
+ const taskTitle = resolveLinkedTaskTitle(
+ workspace,
+ node.id,
+ node.data.agent?.taskId ?? null,
+ )
+
+ return (
+
+ )
+ })}
+
+ )
+}
+
+function SortableWorkspaceItem({
+ workspace,
+ isActive,
+ onSelectWorkspace,
+ onOpenProjectContextMenu,
+ onSelectAgentNode,
+}: SortableWorkspaceItemProps): React.JSX.Element {
+ const { t } = useTranslation()
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+ id: workspace.id,
+ })
+ const style: React.CSSProperties = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.4 : 1,
+ }
+ const metaText = getWorkspaceMetaText(workspace, t)
+
+ return (
+
+
+
+
+
+ )
+}
+
+function WorkspaceItemOverlay({ workspace }: { workspace: WorkspaceState }): React.JSX.Element {
+ const { t } = useTranslation()
+
+ return (
+
+ )
+}
+
export function Sidebar({
workspaces,
activeWorkspaceId,
@@ -30,18 +255,47 @@ export function Sidebar({
onSelectWorkspace,
onOpenProjectContextMenu,
onSelectAgentNode,
-}: {
- workspaces: WorkspaceState[]
- activeWorkspaceId: string | null
- activeProviderLabel: string
- activeProviderModel: string
- persistNotice: PersistNotice | null
- onAddWorkspace: () => void
- onSelectWorkspace: (workspaceId: string) => void
- onOpenProjectContextMenu: (state: ProjectContextMenuState) => void
- onSelectAgentNode: (workspaceId: string, nodeId: string) => void
-}): React.JSX.Element {
+ onReorderWorkspaces,
+}: SidebarProps): React.JSX.Element {
const { t } = useTranslation()
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: { distance: 8 },
+ }),
+ )
+ const [activeId, setActiveId] = useState(null)
+
+ const handleDragStart = useCallback((event: DragStartEvent): void => {
+ setActiveId(String(event.active.id))
+ }, [])
+
+ const handleDragCancel = useCallback((): void => {
+ setActiveId(null)
+ }, [])
+
+ const handleDragEnd = useCallback(
+ (event: DragEndEvent): void => {
+ const nextActiveId = String(event.active.id)
+ const nextOverId = event.over?.id
+
+ setActiveId(null)
+
+ if (nextOverId === null || nextOverId === undefined) {
+ return
+ }
+
+ const overId = String(nextOverId)
+ if (overId === nextActiveId) {
+ return
+ }
+
+ onReorderWorkspaces(nextActiveId, overId)
+ },
+ [onReorderWorkspaces],
+ )
+
+ const activeWorkspace =
+ activeId === null ? null : (workspaces.find(workspace => workspace.id === activeId) ?? null)
return (
)
diff --git a/src/app/renderer/shell/hooks/useAddWorkspaceAction.spec.tsx b/src/app/renderer/shell/hooks/useAddWorkspaceAction.spec.tsx
new file mode 100644
index 00000000..b329f8b6
--- /dev/null
+++ b/src/app/renderer/shell/hooks/useAddWorkspaceAction.spec.tsx
@@ -0,0 +1,82 @@
+import React from 'react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import {
+ DEFAULT_WORKSPACE_MINIMAP_VISIBLE,
+ DEFAULT_WORKSPACE_VIEWPORT,
+ type WorkspaceState,
+} from '@contexts/workspace/presentation/renderer/types'
+import { useAppStore } from '../store/useAppStore'
+import { useAddWorkspaceAction } from './useAddWorkspaceAction'
+
+const initialState = useAppStore.getInitialState()
+
+function createWorkspace(id: string): WorkspaceState {
+ return {
+ id,
+ name: id,
+ path: `/tmp/${id}`,
+ worktreesRoot: '',
+ nodes: [],
+ viewport: DEFAULT_WORKSPACE_VIEWPORT,
+ isMinimapVisible: DEFAULT_WORKSPACE_MINIMAP_VISIBLE,
+ spaces: [],
+ activeSpaceId: null,
+ spaceArchiveRecords: [],
+ }
+}
+
+function HookHost(): React.JSX.Element {
+ const addWorkspace = useAddWorkspaceAction()
+
+ return (
+
+ )
+}
+
+afterEach(() => {
+ useAppStore.setState(initialState, true)
+ vi.restoreAllMocks()
+})
+
+describe('useAddWorkspaceAction', () => {
+ it('appends a newly selected workspace to the end of the list', async () => {
+ const selectedWorkspace = {
+ id: 'workspace-3',
+ name: 'workspace-3',
+ path: '/tmp/workspace-3',
+ }
+
+ useAppStore.setState(
+ {
+ workspaces: [createWorkspace('workspace-1'), createWorkspace('workspace-2')],
+ activeWorkspaceId: 'workspace-1',
+ },
+ false,
+ )
+
+ Object.defineProperty(window, 'opencoveApi', {
+ configurable: true,
+ value: {
+ workspace: {
+ selectDirectory: vi.fn(async () => selectedWorkspace),
+ },
+ },
+ })
+
+ render()
+ fireEvent.click(screen.getByRole('button', { name: 'Add workspace' }))
+
+ await waitFor(() => {
+ expect(useAppStore.getState().workspaces.map(workspace => workspace.id)).toEqual([
+ 'workspace-1',
+ 'workspace-2',
+ 'workspace-3',
+ ])
+ })
+
+ expect(useAppStore.getState().activeWorkspaceId).toBe('workspace-3')
+ })
+})
diff --git a/src/app/renderer/shell/store/useAppStore.spec.ts b/src/app/renderer/shell/store/useAppStore.spec.ts
new file mode 100644
index 00000000..3d5312ee
--- /dev/null
+++ b/src/app/renderer/shell/store/useAppStore.spec.ts
@@ -0,0 +1,110 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ DEFAULT_WORKSPACE_MINIMAP_VISIBLE,
+ DEFAULT_WORKSPACE_VIEWPORT,
+ type WorkspaceState,
+} from '@contexts/workspace/presentation/renderer/types'
+import { useAppStore, type AppStoreState } from './useAppStore'
+
+type ReorderWorkspacesAction = (activeId: string, overId: string) => void
+
+const initialState = useAppStore.getInitialState()
+
+function createWorkspace(id: string): WorkspaceState {
+ return {
+ id,
+ name: id,
+ path: `/tmp/${id}`,
+ worktreesRoot: '',
+ nodes: [],
+ viewport: DEFAULT_WORKSPACE_VIEWPORT,
+ isMinimapVisible: DEFAULT_WORKSPACE_MINIMAP_VISIBLE,
+ spaces: [],
+ activeSpaceId: null,
+ spaceArchiveRecords: [],
+ }
+}
+
+afterEach(() => {
+ useAppStore.setState(initialState, true)
+})
+
+describe('useAppStore', () => {
+ it('reorders workspaces when dragging one item over another', () => {
+ useAppStore.setState(
+ {
+ workspaces: [
+ createWorkspace('workspace-1'),
+ createWorkspace('workspace-2'),
+ createWorkspace('workspace-3'),
+ ],
+ },
+ false,
+ )
+
+ const state = useAppStore.getState() as AppStoreState & {
+ reorderWorkspaces?: ReorderWorkspacesAction
+ }
+
+ expect(state.reorderWorkspaces).toBeTypeOf('function')
+
+ state.reorderWorkspaces?.('workspace-3', 'workspace-1')
+
+ expect(useAppStore.getState().workspaces.map(workspace => workspace.id)).toEqual([
+ 'workspace-3',
+ 'workspace-1',
+ 'workspace-2',
+ ])
+ })
+
+ it('ignores reorder requests when either workspace id is missing', () => {
+ useAppStore.setState(
+ {
+ workspaces: [
+ createWorkspace('workspace-1'),
+ createWorkspace('workspace-2'),
+ createWorkspace('workspace-3'),
+ ],
+ },
+ false,
+ )
+
+ const state = useAppStore.getState() as AppStoreState & {
+ reorderWorkspaces?: ReorderWorkspacesAction
+ }
+
+ state.reorderWorkspaces?.('workspace-missing', 'workspace-1')
+ state.reorderWorkspaces?.('workspace-1', 'workspace-missing')
+
+ expect(useAppStore.getState().workspaces.map(workspace => workspace.id)).toEqual([
+ 'workspace-1',
+ 'workspace-2',
+ 'workspace-3',
+ ])
+ })
+
+ it('treats dragging a workspace onto itself as a no-op', () => {
+ useAppStore.setState(
+ {
+ workspaces: [
+ createWorkspace('workspace-1'),
+ createWorkspace('workspace-2'),
+ createWorkspace('workspace-3'),
+ ],
+ },
+ false,
+ )
+
+ const state = useAppStore.getState() as AppStoreState & {
+ reorderWorkspaces?: ReorderWorkspacesAction
+ }
+
+ state.reorderWorkspaces?.('workspace-2', 'workspace-2')
+
+ expect(useAppStore.getState().workspaces.map(workspace => workspace.id)).toEqual([
+ 'workspace-1',
+ 'workspace-2',
+ 'workspace-3',
+ ])
+ })
+})
diff --git a/src/app/renderer/shell/store/useAppStore.ts b/src/app/renderer/shell/store/useAppStore.ts
index ea8d5201..885e4fcb 100644
--- a/src/app/renderer/shell/store/useAppStore.ts
+++ b/src/app/renderer/shell/store/useAppStore.ts
@@ -1,4 +1,5 @@
import { create } from 'zustand'
+import { arrayMove } from '@dnd-kit/sortable'
import { DEFAULT_AGENT_SETTINGS, type AgentSettings } from '@contexts/settings/domain/agentSettings'
import type { WorkspaceState } from '@contexts/workspace/presentation/renderer/types'
import type {
@@ -36,6 +37,7 @@ export interface AppStoreState {
setIsSettingsOpen: (action: SetStateAction) => void
setFocusRequest: (action: SetStateAction) => void
setPersistNotice: (action: SetStateAction) => void
+ reorderWorkspaces: (activeId: string, overId: string) => void
}
export const useAppStore = create(set => ({
@@ -69,4 +71,15 @@ export const useAppStore = create(set => ({
set(state => ({ focusRequest: applySetStateAction(state.focusRequest, action) })),
setPersistNotice: action =>
set(state => ({ persistNotice: applySetStateAction(state.persistNotice, action) })),
+ reorderWorkspaces: (activeId, overId) =>
+ set(state => {
+ const oldIndex = state.workspaces.findIndex(workspace => workspace.id === activeId)
+ const newIndex = state.workspaces.findIndex(workspace => workspace.id === overId)
+
+ if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) {
+ return state
+ }
+
+ return { workspaces: arrayMove(state.workspaces, oldIndex, newIndex) }
+ }),
}))
diff --git a/src/app/renderer/styles/workspace-sidebar.css b/src/app/renderer/styles/workspace-sidebar.css
index 494a9420..1efa030b 100644
--- a/src/app/renderer/styles/workspace-sidebar.css
+++ b/src/app/renderer/styles/workspace-sidebar.css
@@ -154,6 +154,10 @@
gap: 6px;
}
+.workspace-item-group--drag-overlay {
+ pointer-events: none;
+}
+
.workspace-sidebar__empty {
font-size: calc(13px * var(--cove-ui-font-scale));
color: var(--cove-text-faint);
@@ -191,6 +195,15 @@
box-shadow: 0 0 0 1px rgba(94, 156, 255, 0.2);
}
+.workspace-item--drag-overlay {
+ background: color-mix(in srgb, var(--cove-surface) 78%, transparent);
+ border-color: rgba(94, 156, 255, 0.28);
+ box-shadow:
+ 0 12px 24px rgba(15, 23, 42, 0.12),
+ 0 0 0 1px rgba(94, 156, 255, 0.14);
+ opacity: 0.86;
+}
+
.workspace-item__name {
font-size: calc(14px * var(--cove-ui-font-scale));
font-weight: 500;
diff --git a/src/platform/persistence/sqlite/migrate.ts b/src/platform/persistence/sqlite/migrate.ts
index 97ea2e9c..02b7fb0e 100644
--- a/src/platform/persistence/sqlite/migrate.ts
+++ b/src/platform/persistence/sqlite/migrate.ts
@@ -39,7 +39,8 @@ function createTables(db: Database.Database): void {
viewport_y REAL NOT NULL,
viewport_zoom REAL NOT NULL,
is_minimap_visible INTEGER NOT NULL,
- active_space_id TEXT
+ active_space_id TEXT,
+ sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS nodes (
@@ -134,9 +135,9 @@ function hasTableColumn(db: Database.Database, tableName: string, columnName: st
function ensureTableColumn(
db: Database.Database,
options: { tableName: string; columnName: string; definitionSql: string },
-): void {
+): boolean {
if (hasTableColumn(db, options.tableName, options.columnName)) {
- return
+ return false
}
db.exec(
@@ -144,6 +145,25 @@ function ensureTableColumn(
options.columnName,
)} ${options.definitionSql}`,
)
+
+ return true
+}
+
+function backfillWorkspaceSortOrder(db: Database.Database): void {
+ const allZero = db
+ .prepare('SELECT COUNT(*) as cnt FROM workspaces WHERE sort_order != 0')
+ .get() as {
+ cnt: number
+ }
+ if (allZero.cnt > 0) {
+ return
+ }
+
+ const rows = db.prepare('SELECT id FROM workspaces ORDER BY rowid').all() as { id: string }[]
+ const update = db.prepare('UPDATE workspaces SET sort_order = ? WHERE id = ?')
+ rows.forEach((row, index) => {
+ update.run(index, row.id)
+ })
}
function ensureCurrentSchema(db: Database.Database): void {
@@ -161,6 +181,16 @@ function ensureCurrentSchema(db: Database.Database): void {
definitionSql: `TEXT NOT NULL DEFAULT '[]'`,
})
+ const addedWorkspaceSortOrder = ensureTableColumn(db, {
+ tableName: 'workspaces',
+ columnName: 'sort_order',
+ definitionSql: 'INTEGER NOT NULL DEFAULT 0',
+ })
+
+ if (addedWorkspaceSortOrder) {
+ backfillWorkspaceSortOrder(db)
+ }
+
ensureTableColumn(db, {
tableName: 'nodes',
columnName: 'label_color_override',
@@ -192,6 +222,7 @@ export function migrate(db: Database.Database): void {
const normalized = normalizePersistedAppState(parsed)
if (normalized) {
writeNormalizedAppState(db, normalized)
+ backfillWorkspaceSortOrder(db)
writeNormalizedScrollbacks(db, normalized)
}
}
diff --git a/src/platform/persistence/sqlite/read.ts b/src/platform/persistence/sqlite/read.ts
index 56e7811d..f521d52c 100644
--- a/src/platform/persistence/sqlite/read.ts
+++ b/src/platform/persistence/sqlite/read.ts
@@ -47,7 +47,7 @@ export function readAppStateFromDb(db: BetterSQLite3Database): NormalizedPersist
const settingsValue =
typeof settingsRow?.value === 'string' ? safeJsonParse(settingsRow.value) : {}
- const workspaceRows = db.select().from(workspaces).all()
+ const workspaceRows = db.select().from(workspaces).orderBy(workspaces.sortOrder).all()
const nodeRows = db.select().from(nodes).all()
const spaceRows = db.select().from(spaces).all()
const spaceNodeRows = db.select().from(spaceNodes).all()
diff --git a/src/platform/persistence/sqlite/schema.ts b/src/platform/persistence/sqlite/schema.ts
index 90a13caa..115c0978 100644
--- a/src/platform/persistence/sqlite/schema.ts
+++ b/src/platform/persistence/sqlite/schema.ts
@@ -24,6 +24,7 @@ export const workspaces = sqliteTable('workspaces', {
viewportZoom: real('viewport_zoom').notNull(),
isMinimapVisible: integer('is_minimap_visible', { mode: 'boolean' }).notNull(),
activeSpaceId: text('active_space_id'),
+ sortOrder: integer('sort_order').notNull().default(0),
})
export const nodes = sqliteTable('nodes', {
diff --git a/src/platform/persistence/sqlite/write.ts b/src/platform/persistence/sqlite/write.ts
index c072e583..1bcfecff 100644
--- a/src/platform/persistence/sqlite/write.ts
+++ b/src/platform/persistence/sqlite/write.ts
@@ -28,9 +28,9 @@ export function writeNormalizedAppState(
INSERT INTO workspaces (
id, name, path, worktrees_root, pull_request_base_branch_options_json, space_archive_records_json,
viewport_x, viewport_y, viewport_zoom,
- is_minimap_visible, active_space_id
+ is_minimap_visible, active_space_id, sort_order
)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
@@ -77,7 +77,8 @@ export function writeNormalizedAppState(
upsertSettings.run(safeJsonStringify(state.settings ?? {}))
- for (const workspace of state.workspaces) {
+ for (let sortOrder = 0; sortOrder < state.workspaces.length; sortOrder += 1) {
+ const workspace = state.workspaces[sortOrder]
insertWorkspace.run(
workspace.id,
workspace.name,
@@ -90,6 +91,7 @@ export function writeNormalizedAppState(
workspace.viewport.zoom,
workspace.isMinimapVisible ? 1 : 0,
workspace.activeSpaceId,
+ sortOrder,
)
for (const node of workspace.nodes) {
diff --git a/tests/contract/platform/persistenceRead.spec.ts b/tests/contract/platform/persistenceRead.spec.ts
new file mode 100644
index 00000000..ac2cccc2
--- /dev/null
+++ b/tests/contract/platform/persistenceRead.spec.ts
@@ -0,0 +1,137 @@
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
+import { describe, expect, it } from 'vitest'
+import { readAppStateFromDb } from '../../../src/platform/persistence/sqlite/read'
+import {
+ appMeta,
+ appSettings,
+ nodes,
+ spaceNodes,
+ spaces,
+ workspaces,
+} from '../../../src/platform/persistence/sqlite/schema'
+
+type MetaRow = { key: 'format_version' | 'active_workspace_id'; value: string }
+type SettingsRow = { id: number; value: string }
+type WorkspaceRow = {
+ id: string
+ name: string
+ path: string
+ worktreesRoot: string
+ pullRequestBaseBranchOptionsJson: string
+ spaceArchiveRecordsJson: string
+ viewportX: number
+ viewportY: number
+ viewportZoom: number
+ isMinimapVisible: boolean
+ activeSpaceId: string | null
+ sortOrder: number
+}
+
+function createReadDb(options: {
+ metaRows: MetaRow[]
+ settingsRow: SettingsRow | undefined
+ workspaceRows: WorkspaceRow[]
+}): BetterSQLite3Database {
+ const db = {
+ select(selection?: unknown) {
+ return {
+ from(table: unknown) {
+ if (table === appMeta) {
+ return {
+ all: () => options.metaRows,
+ }
+ }
+
+ if (table === appSettings) {
+ return {
+ where: (_predicate: unknown) => ({
+ get: () => {
+ if (!options.settingsRow) {
+ return undefined
+ }
+
+ return selection
+ ? {
+ value: options.settingsRow.value,
+ }
+ : options.settingsRow
+ },
+ }),
+ }
+ }
+
+ if (table === workspaces) {
+ return {
+ all: () => options.workspaceRows,
+ orderBy: (_column: unknown) => ({
+ all: () =>
+ [...options.workspaceRows].sort(
+ (left, right) => left.sortOrder - right.sortOrder,
+ ),
+ }),
+ }
+ }
+
+ if (table === nodes || table === spaces || table === spaceNodes) {
+ return {
+ all: () => [],
+ }
+ }
+
+ throw new Error('Unexpected table')
+ },
+ }
+ },
+ }
+
+ return db as BetterSQLite3Database
+}
+
+describe('sqlite persistence read', () => {
+ it('loads workspaces in ascending sort_order', () => {
+ const db = createReadDb({
+ metaRows: [
+ { key: 'format_version', value: '1' },
+ { key: 'active_workspace_id', value: 'workspace-1' },
+ ],
+ settingsRow: { id: 1, value: '{}' },
+ workspaceRows: [
+ {
+ id: 'workspace-2',
+ name: 'Workspace 2',
+ path: '/tmp/workspace-2',
+ worktreesRoot: '/tmp/worktrees',
+ pullRequestBaseBranchOptionsJson: '[]',
+ spaceArchiveRecordsJson: '[]',
+ viewportX: 0,
+ viewportY: 0,
+ viewportZoom: 1,
+ isMinimapVisible: true,
+ activeSpaceId: null,
+ sortOrder: 2,
+ },
+ {
+ id: 'workspace-1',
+ name: 'Workspace 1',
+ path: '/tmp/workspace-1',
+ worktreesRoot: '/tmp/worktrees',
+ pullRequestBaseBranchOptionsJson: '[]',
+ spaceArchiveRecordsJson: '[]',
+ viewportX: 0,
+ viewportY: 0,
+ viewportZoom: 1,
+ isMinimapVisible: true,
+ activeSpaceId: null,
+ sortOrder: 1,
+ },
+ ],
+ })
+
+ const appState = readAppStateFromDb(db)
+
+ expect(appState?.workspaces.map(workspace => workspace.id)).toEqual([
+ 'workspace-1',
+ 'workspace-2',
+ ])
+ })
+})
diff --git a/tests/contract/platform/persistenceStore.spec.ts b/tests/contract/platform/persistenceStore.spec.ts
index 37bd0575..1b1a9463 100644
--- a/tests/contract/platform/persistenceStore.spec.ts
+++ b/tests/contract/platform/persistenceStore.spec.ts
@@ -9,6 +9,7 @@ type MockDbState = {
userVersion: number
tables: Map
openAttempts: number
+ workspaceRows: Array<{ id: string; sortOrder: number }>
failOnFirstOpen?: boolean
}
@@ -27,6 +28,7 @@ const CURRENT_SCHEMA_COLUMNS = {
'viewport_zoom',
'is_minimap_visible',
'active_space_id',
+ 'sort_order',
],
nodes: [
'id',
@@ -134,6 +136,7 @@ function createMockDbState(
userVersion: options.userVersion ?? 0,
tables: options.version2Schema ? createVersion2Tables() : new Map(),
openAttempts: 0,
+ workspaceRows: [],
...(options.failOnFirstOpen ? { failOnFirstOpen: true } : {}),
}
}
@@ -215,6 +218,10 @@ function createMockDatabaseModule(mockDbByPath: Map) {
}
}
+ if (sql === 'SELECT COUNT(*) as cnt FROM workspaces WHERE sort_order != 0') {
+ return { all: () => [], get: () => ({ cnt: 1 }), run: () => undefined }
+ }
+
const insertMatch = sql.match(
/INSERT INTO\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([\s\S]*?)\)\s*VALUES/i,
)
@@ -227,25 +234,38 @@ function createMockDatabaseModule(mockDbByPath: Map) {
return {
all: () => [],
get: () => undefined,
- run: () => {
+ run: (...params: unknown[]) => {
const tableColumns = this.state.tables.get(tableName) ?? []
for (const column of columns) {
if (!tableColumns.includes(column)) {
throw new Error(`table ${tableName} has no column named ${column}`)
}
}
- },
- }
- }
- if (sql.includes('SELECT value FROM kv WHERE key = ?')) {
- return {
- all: () => [],
- get: () => undefined,
- run: () => undefined,
+ if (tableName !== 'workspaces') {
+ return
+ }
+
+ const idIndex = columns.indexOf('id')
+ if (idIndex < 0) {
+ throw new Error('workspace insert missing id column')
+ }
+
+ const id = params[idIndex]
+ if (typeof id !== 'string') {
+ throw new Error('workspace insert missing id value')
+ }
+
+ const sortOrderIndex = columns.indexOf('sort_order')
+ const sortOrderParam = sortOrderIndex >= 0 ? params[sortOrderIndex] : 0
+ if (typeof sortOrderParam !== 'number') {
+ throw new Error('workspace insert sort_order must be numeric')
+ }
+
+ this.state.workspaceRows.push({ id, sortOrder: sortOrderParam })
+ },
}
}
-
return {
all: () => [],
get: () => undefined,
@@ -279,6 +299,64 @@ describe('PersistenceStore', () => {
tempDir = ''
})
+ it(
+ 'writes workspace sort_order from the in-memory array order',
+ async () => {
+ tempDir = await mkdtemp(join(tmpdir(), 'cove-persist-'))
+ const dbPath = join(tempDir, 'opencove.db')
+ const mockDbByPath = new Map()
+ vi.doMock('better-sqlite3', () => ({ default: createMockDatabaseModule(mockDbByPath) }))
+
+ const { createPersistenceStore } =
+ await import('../../../src/platform/persistence/sqlite/PersistenceStore')
+
+ const store = await createPersistenceStore({ dbPath })
+
+ const result = await store.writeAppState({
+ formatVersion: 1,
+ activeWorkspaceId: 'ws-2',
+ workspaces: [
+ {
+ id: 'ws-2',
+ name: 'Workspace 2',
+ path: '/tmp/ws-2',
+ worktreesRoot: '/tmp',
+ pullRequestBaseBranchOptions: [],
+ spaceArchiveRecords: [],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ isMinimapVisible: false,
+ activeSpaceId: null,
+ nodes: [],
+ spaces: [],
+ },
+ {
+ id: 'ws-1',
+ name: 'Workspace 1',
+ path: '/tmp/ws-1',
+ worktreesRoot: '/tmp',
+ pullRequestBaseBranchOptions: [],
+ spaceArchiveRecords: [],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ isMinimapVisible: false,
+ activeSpaceId: null,
+ nodes: [],
+ spaces: [],
+ },
+ ],
+ settings: {},
+ })
+
+ expect(result).toMatchObject({ ok: true, level: 'full' })
+ expect(mockDbByPath.get(dbPath)?.workspaceRows).toEqual([
+ { id: 'ws-2', sortOrder: 0 },
+ { id: 'ws-1', sortOrder: 1 },
+ ])
+
+ store.dispose()
+ },
+ PERSISTENCE_STORE_TEST_TIMEOUT_MS,
+ )
+
it(
'creates a backup when migrating an existing db file',
async () => {
diff --git a/tests/contract/platform/persistenceStoreSortOrderMigration.spec.ts b/tests/contract/platform/persistenceStoreSortOrderMigration.spec.ts
new file mode 100644
index 00000000..138118a9
--- /dev/null
+++ b/tests/contract/platform/persistenceStoreSortOrderMigration.spec.ts
@@ -0,0 +1,499 @@
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+const PERSISTENCE_STORE_TEST_TIMEOUT_MS = 20_000
+
+type MockDbState = {
+ userVersion: number
+ tables: Map
+ workspaceRows: Array<{ id: string; sortOrder: number }>
+ legacyWorkspaceStateRaw?: string
+ openAttempts: number
+}
+
+const CURRENT_SCHEMA_COLUMNS = {
+ app_meta: ['key', 'value'],
+ app_settings: ['id', 'value'],
+ workspaces: [
+ 'id',
+ 'name',
+ 'path',
+ 'worktrees_root',
+ 'pull_request_base_branch_options_json',
+ 'space_archive_records_json',
+ 'viewport_x',
+ 'viewport_y',
+ 'viewport_zoom',
+ 'is_minimap_visible',
+ 'active_space_id',
+ 'sort_order',
+ ],
+ nodes: [
+ 'id',
+ 'workspace_id',
+ 'title',
+ 'title_pinned_by_user',
+ 'position_x',
+ 'position_y',
+ 'width',
+ 'height',
+ 'kind',
+ 'label_color_override',
+ 'status',
+ 'started_at',
+ 'ended_at',
+ 'exit_code',
+ 'last_error',
+ 'execution_directory',
+ 'expected_directory',
+ 'agent_json',
+ 'task_json',
+ ],
+ workspace_spaces: [
+ 'id',
+ 'workspace_id',
+ 'name',
+ 'directory_path',
+ 'label_color',
+ 'rect_x',
+ 'rect_y',
+ 'rect_width',
+ 'rect_height',
+ ],
+ workspace_space_nodes: ['space_id', 'node_id', 'sort_order'],
+ node_scrollback: ['node_id', 'scrollback', 'updated_at'],
+} as const
+
+function createMockDbState(
+ options: {
+ userVersion?: number
+ version2Schema?: boolean
+ workspaceRows?: Array<{
+ id: string
+ sortOrder?: number
+ }>
+ legacyWorkspaceStateRaw?: string
+ } = {},
+): MockDbState {
+ return {
+ userVersion: options.userVersion ?? 0,
+ tables: options.version2Schema
+ ? new Map([
+ ['app_meta', [...CURRENT_SCHEMA_COLUMNS.app_meta]],
+ ['app_settings', [...CURRENT_SCHEMA_COLUMNS.app_settings]],
+ [
+ 'workspaces',
+ [
+ 'id',
+ 'name',
+ 'path',
+ 'worktrees_root',
+ 'viewport_x',
+ 'viewport_y',
+ 'viewport_zoom',
+ 'is_minimap_visible',
+ 'active_space_id',
+ ],
+ ],
+ [
+ 'nodes',
+ [
+ 'id',
+ 'workspace_id',
+ 'title',
+ 'title_pinned_by_user',
+ 'position_x',
+ 'position_y',
+ 'width',
+ 'height',
+ 'kind',
+ 'status',
+ 'started_at',
+ 'ended_at',
+ 'exit_code',
+ 'last_error',
+ 'execution_directory',
+ 'expected_directory',
+ 'agent_json',
+ 'task_json',
+ ],
+ ],
+ [
+ 'workspace_spaces',
+ [
+ 'id',
+ 'workspace_id',
+ 'name',
+ 'directory_path',
+ 'rect_x',
+ 'rect_y',
+ 'rect_width',
+ 'rect_height',
+ ],
+ ],
+ ['workspace_space_nodes', [...CURRENT_SCHEMA_COLUMNS.workspace_space_nodes]],
+ ['node_scrollback', [...CURRENT_SCHEMA_COLUMNS.node_scrollback]],
+ ])
+ : new Map(),
+ workspaceRows: (options.workspaceRows ?? []).map(row => ({
+ id: row.id,
+ sortOrder: row.sortOrder ?? 0,
+ })),
+ ...(typeof options.legacyWorkspaceStateRaw === 'string'
+ ? { legacyWorkspaceStateRaw: options.legacyWorkspaceStateRaw }
+ : {}),
+ openAttempts: 0,
+ }
+}
+
+function createMockDatabaseModule(mockDbByPath: Map) {
+ return class MockDatabase {
+ private readonly state: MockDbState
+
+ public constructor(private readonly path: string) {
+ const existing = mockDbByPath.get(path)
+ if (!existing) {
+ throw new Error(`Missing mock database state for ${path}`)
+ }
+
+ existing.openAttempts += 1
+ this.state = existing
+ }
+
+ public pragma(query: string, options?: { simple?: boolean }): unknown {
+ if (query === 'user_version' && options?.simple === true) {
+ return this.state.userVersion
+ }
+
+ const match = query.match(/^user_version\s*=\s*(\d+)$/)
+ if (match) {
+ this.state.userVersion = Number(match[1])
+ }
+
+ return undefined
+ }
+
+ public exec(sql: string): void {
+ for (const [tableName, columns] of Object.entries(CURRENT_SCHEMA_COLUMNS)) {
+ if (
+ sql.includes(`CREATE TABLE IF NOT EXISTS ${tableName}`) &&
+ !this.state.tables.has(tableName)
+ ) {
+ this.state.tables.set(tableName, [...columns])
+ }
+ }
+
+ const alterRegex =
+ /ALTER TABLE\s+("?)([A-Za-z_][A-Za-z0-9_]*)\1\s+ADD COLUMN\s+("?)([A-Za-z_][A-Za-z0-9_]*)\3/gi
+ for (const match of sql.matchAll(alterRegex)) {
+ const tableName = match[2]
+ const columnName = match[4]
+ const existingColumns = this.state.tables.get(tableName) ?? []
+ if (!existingColumns.includes(columnName)) {
+ existingColumns.push(columnName)
+ this.state.tables.set(tableName, existingColumns)
+ }
+ }
+ }
+
+ public prepare(sql: string): {
+ all: () => unknown[]
+ get: (...params: unknown[]) => unknown
+ run: (...params: unknown[]) => void
+ } {
+ const tableInfoMatch = sql.match(/PRAGMA table_info\("?([A-Za-z_][A-Za-z0-9_]*)"?\)/i)
+ if (tableInfoMatch) {
+ const tableName = tableInfoMatch[1]
+ return {
+ all: () =>
+ (this.state.tables.get(tableName) ?? []).map(name => ({
+ name,
+ })),
+ get: () => undefined,
+ run: () => undefined,
+ }
+ }
+
+ if (sql === 'SELECT COUNT(*) as cnt FROM workspaces WHERE sort_order != 0') {
+ return {
+ all: () => [],
+ get: () => ({
+ cnt: this.state.workspaceRows.filter(row => row.sortOrder !== 0).length,
+ }),
+ run: () => undefined,
+ }
+ }
+
+ if (sql === 'SELECT id FROM workspaces ORDER BY rowid') {
+ return {
+ all: () => this.state.workspaceRows.map(row => ({ id: row.id })),
+ get: () => undefined,
+ run: () => undefined,
+ }
+ }
+
+ if (sql === 'UPDATE workspaces SET sort_order = ? WHERE id = ?') {
+ return {
+ all: () => [],
+ get: () => undefined,
+ run: (...params: unknown[]) => {
+ const [sortOrder, id] = params
+ if (typeof sortOrder !== 'number' || typeof id !== 'string') {
+ throw new Error('Invalid workspace sort_order backfill parameters')
+ }
+
+ const row = this.state.workspaceRows.find(workspaceRow => workspaceRow.id === id)
+ if (!row) {
+ throw new Error(`Unknown workspace row: ${id}`)
+ }
+
+ row.sortOrder = sortOrder
+ },
+ }
+ }
+
+ if (sql.includes('SELECT value FROM kv WHERE key = ?')) {
+ return {
+ all: () => [],
+ get: () =>
+ typeof this.state.legacyWorkspaceStateRaw === 'string'
+ ? { value: this.state.legacyWorkspaceStateRaw }
+ : undefined,
+ run: () => undefined,
+ }
+ }
+
+ const insertMatch = sql.match(
+ /INSERT INTO\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([\s\S]*?)\)\s*VALUES/i,
+ )
+ if (insertMatch) {
+ const tableName = insertMatch[1]
+ const columns = insertMatch[2]
+ .split(',')
+ .map(column => column.replace(/\s+/g, ' ').trim())
+ .filter(column => column.length > 0)
+ return {
+ all: () => [],
+ get: () => undefined,
+ run: (...params: unknown[]) => {
+ const tableColumns = this.state.tables.get(tableName) ?? []
+ for (const column of columns) {
+ if (!tableColumns.includes(column)) {
+ throw new Error(`table ${tableName} has no column named ${column}`)
+ }
+ }
+
+ if (tableName !== 'workspaces') {
+ return
+ }
+
+ const idIndex = columns.indexOf('id')
+ if (idIndex < 0) {
+ throw new Error('workspace insert missing id column')
+ }
+
+ const id = params[idIndex]
+ if (typeof id !== 'string') {
+ throw new Error('workspace insert missing id value')
+ }
+
+ const sortOrderIndex = columns.indexOf('sort_order')
+ const sortOrderParam = sortOrderIndex >= 0 ? params[sortOrderIndex] : 0
+ if (typeof sortOrderParam !== 'number') {
+ throw new Error('workspace insert sort_order must be numeric')
+ }
+
+ this.state.workspaceRows.push({ id, sortOrder: sortOrderParam })
+ },
+ }
+ }
+
+ return {
+ all: () => [],
+ get: () => undefined,
+ run: () => undefined,
+ }
+ }
+
+ public transaction(
+ fn: (...args: TArgs) => TResult,
+ ): (...args: TArgs) => TResult {
+ return (...args: TArgs) => fn(...args)
+ }
+
+ public close(): void {}
+ }
+}
+
+describe('PersistenceStore sort order migration', () => {
+ let tempDir = ''
+
+ afterEach(async () => {
+ vi.resetModules()
+ vi.clearAllMocks()
+
+ if (!tempDir) {
+ return
+ }
+
+ await rm(tempDir, { recursive: true, force: true })
+ tempDir = ''
+ })
+
+ it(
+ 'repairs the workspaces schema and backfills sort_order in legacy rowid order',
+ async () => {
+ tempDir = await mkdtemp(join(tmpdir(), 'cove-persist-sort-order-'))
+ const dbPath = join(tempDir, 'opencove.db')
+ const mockDbByPath = new Map([
+ [
+ dbPath,
+ createMockDbState({
+ userVersion: 5,
+ version2Schema: true,
+ workspaceRows: [
+ { id: 'ws-2', sortOrder: 0 },
+ { id: 'ws-4', sortOrder: 0 },
+ { id: 'ws-1', sortOrder: 0 },
+ ],
+ }),
+ ],
+ ])
+
+ vi.doMock('better-sqlite3', () => ({ default: createMockDatabaseModule(mockDbByPath) }))
+
+ const { createPersistenceStore } =
+ await import('../../../src/platform/persistence/sqlite/PersistenceStore')
+
+ const store = await createPersistenceStore({ dbPath })
+ expect(store.consumeRecovery()).toBeNull()
+ store.dispose()
+
+ expect(mockDbByPath.get(dbPath)?.tables.get('workspaces')).toContain('sort_order')
+ expect(mockDbByPath.get(dbPath)?.workspaceRows).toEqual([
+ { id: 'ws-2', sortOrder: 0 },
+ { id: 'ws-4', sortOrder: 1 },
+ { id: 'ws-1', sortOrder: 2 },
+ ])
+ },
+ PERSISTENCE_STORE_TEST_TIMEOUT_MS,
+ )
+
+ it(
+ 'does not backfill workspace sort_order when the column already exists',
+ async () => {
+ tempDir = await mkdtemp(join(tmpdir(), 'cove-persist-sort-order-'))
+ const dbPath = join(tempDir, 'opencove.db')
+ const mockDbByPath = new Map([
+ [
+ dbPath,
+ createMockDbState({
+ userVersion: 5,
+ workspaceRows: [
+ { id: 'ws-2', sortOrder: 0 },
+ { id: 'ws-4', sortOrder: 0 },
+ { id: 'ws-1', sortOrder: 0 },
+ ],
+ }),
+ ],
+ ])
+
+ vi.doMock('better-sqlite3', () => ({ default: createMockDatabaseModule(mockDbByPath) }))
+
+ const { createPersistenceStore } =
+ await import('../../../src/platform/persistence/sqlite/PersistenceStore')
+
+ const store = await createPersistenceStore({ dbPath })
+ expect(store.consumeRecovery()).toBeNull()
+ store.dispose()
+
+ expect(mockDbByPath.get(dbPath)?.workspaceRows).toEqual([
+ { id: 'ws-2', sortOrder: 0 },
+ { id: 'ws-4', sortOrder: 0 },
+ { id: 'ws-1', sortOrder: 0 },
+ ])
+ },
+ PERSISTENCE_STORE_TEST_TIMEOUT_MS,
+ )
+
+ it(
+ 'backfills workspace sort_order after migrating legacy v1 kv state',
+ async () => {
+ tempDir = await mkdtemp(join(tmpdir(), 'cove-persist-sort-order-'))
+ const dbPath = join(tempDir, 'opencove.db')
+ const mockDbByPath = new Map([
+ [
+ dbPath,
+ createMockDbState({
+ userVersion: 1,
+ legacyWorkspaceStateRaw: JSON.stringify({
+ formatVersion: 1,
+ activeWorkspaceId: 'ws-2',
+ workspaces: [
+ {
+ id: 'ws-2',
+ name: 'Workspace 2',
+ path: '/tmp/ws-2',
+ worktreesRoot: '/tmp',
+ pullRequestBaseBranchOptions: [],
+ spaceArchiveRecords: [],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ isMinimapVisible: false,
+ activeSpaceId: null,
+ nodes: [],
+ spaces: [],
+ },
+ {
+ id: 'ws-4',
+ name: 'Workspace 4',
+ path: '/tmp/ws-4',
+ worktreesRoot: '/tmp',
+ pullRequestBaseBranchOptions: [],
+ spaceArchiveRecords: [],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ isMinimapVisible: false,
+ activeSpaceId: null,
+ nodes: [],
+ spaces: [],
+ },
+ {
+ id: 'ws-1',
+ name: 'Workspace 1',
+ path: '/tmp/ws-1',
+ worktreesRoot: '/tmp',
+ pullRequestBaseBranchOptions: [],
+ spaceArchiveRecords: [],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ isMinimapVisible: false,
+ activeSpaceId: null,
+ nodes: [],
+ spaces: [],
+ },
+ ],
+ settings: {},
+ }),
+ }),
+ ],
+ ])
+
+ vi.doMock('better-sqlite3', () => ({ default: createMockDatabaseModule(mockDbByPath) }))
+
+ const { createPersistenceStore } =
+ await import('../../../src/platform/persistence/sqlite/PersistenceStore')
+
+ const store = await createPersistenceStore({ dbPath })
+ expect(store.consumeRecovery()).toBeNull()
+ store.dispose()
+
+ expect(mockDbByPath.get(dbPath)?.userVersion).toBe(5)
+ expect(mockDbByPath.get(dbPath)?.workspaceRows).toEqual([
+ { id: 'ws-2', sortOrder: 0 },
+ { id: 'ws-4', sortOrder: 1 },
+ { id: 'ws-1', sortOrder: 2 },
+ ])
+ },
+ PERSISTENCE_STORE_TEST_TIMEOUT_MS,
+ )
+})
diff --git a/tests/e2e/workspace-canvas.sidebar-drag-reorder.spec.ts b/tests/e2e/workspace-canvas.sidebar-drag-reorder.spec.ts
new file mode 100644
index 00000000..ca998339
--- /dev/null
+++ b/tests/e2e/workspace-canvas.sidebar-drag-reorder.spec.ts
@@ -0,0 +1,165 @@
+import { expect, test } from '@playwright/test'
+import {
+ dragLocatorTo,
+ launchApp,
+ seedWorkspaceState,
+ testWorkspacePath,
+} from './workspace-canvas.helpers'
+
+test.describe('Workspace Canvas - Sidebar Drag Reorder', () => {
+ test('reorders workspaces by dragging and persists the new order', async () => {
+ const { electronApp, window } = await launchApp()
+
+ try {
+ await seedWorkspaceState(window, {
+ activeWorkspaceId: 'workspace-drag-a',
+ workspaces: [
+ {
+ id: 'workspace-drag-a',
+ name: 'Project Alpha',
+ path: testWorkspacePath,
+ nodes: [],
+ },
+ {
+ id: 'workspace-drag-b',
+ name: 'Project Beta',
+ path: `${testWorkspacePath}-b`,
+ nodes: [],
+ },
+ {
+ id: 'workspace-drag-c',
+ name: 'Project Gamma',
+ path: `${testWorkspacePath}-c`,
+ nodes: [],
+ },
+ ],
+ })
+
+ const workspaceNames = window.locator('.workspace-item__name')
+ await expect(workspaceNames).toHaveCount(3)
+ await expect(workspaceNames.nth(0)).toHaveText('Project Alpha')
+ await expect(workspaceNames.nth(1)).toHaveText('Project Beta')
+ await expect(workspaceNames.nth(2)).toHaveText('Project Gamma')
+
+ // Drag "Project Alpha" (first) down to "Project Gamma" (third) position
+ const firstItem = window
+ .locator('.workspace-item')
+ .filter({ hasText: 'Project Alpha' })
+ .first()
+ const thirdItem = window
+ .locator('.workspace-item')
+ .filter({ hasText: 'Project Gamma' })
+ .first()
+
+ await dragLocatorTo(window, firstItem, thirdItem)
+
+ // Verify new order in DOM — Alpha should have moved down
+ await expect
+ .poll(
+ async () => {
+ const names = await workspaceNames.allTextContents()
+ return names
+ },
+ { timeout: 5_000 },
+ )
+ .toEqual(['Project Beta', 'Project Gamma', 'Project Alpha'])
+
+ // Verify persistence — reload and check order is maintained
+ await expect
+ .poll(
+ async () => {
+ const raw = await window.evaluate(async () => {
+ return await window.opencoveApi.persistence.readWorkspaceStateRaw()
+ })
+ if (!raw) {
+ return null
+ }
+
+ const parsed = JSON.parse(raw) as {
+ workspaces?: Array<{ name?: string }>
+ }
+
+ return (parsed.workspaces ?? []).map(workspace => workspace.name)
+ },
+ { timeout: 10_000 },
+ )
+ .toEqual(['Project Beta', 'Project Gamma', 'Project Alpha'])
+ } finally {
+ await electronApp.close()
+ }
+ })
+
+ test('click still selects workspace after drag setup', async () => {
+ const { electronApp, window } = await launchApp()
+
+ try {
+ await seedWorkspaceState(window, {
+ activeWorkspaceId: 'workspace-click-a',
+ workspaces: [
+ {
+ id: 'workspace-click-a',
+ name: 'Click Alpha',
+ path: testWorkspacePath,
+ nodes: [],
+ },
+ {
+ id: 'workspace-click-b',
+ name: 'Click Beta',
+ path: `${testWorkspacePath}-b`,
+ nodes: [],
+ },
+ ],
+ })
+
+ const activeItem = window.locator('.workspace-item.workspace-item--active')
+ await expect(activeItem).toContainText('Click Alpha')
+
+ // Click the second workspace — should select it, not start a drag
+ const secondItem = window.locator('.workspace-item').filter({ hasText: 'Click Beta' }).first()
+ await secondItem.click()
+
+ await expect(activeItem).toContainText('Click Beta')
+ } finally {
+ await electronApp.close()
+ }
+ })
+
+ test('right-click context menu still works on sortable items', async () => {
+ const { electronApp, window } = await launchApp()
+
+ try {
+ await seedWorkspaceState(window, {
+ activeWorkspaceId: 'workspace-ctx-a',
+ workspaces: [
+ {
+ id: 'workspace-ctx-a',
+ name: 'Context Alpha',
+ path: testWorkspacePath,
+ nodes: [],
+ },
+ {
+ id: 'workspace-ctx-b',
+ name: 'Context Beta',
+ path: `${testWorkspacePath}-b`,
+ nodes: [],
+ },
+ ],
+ })
+
+ const secondItem = window
+ .locator('.workspace-item')
+ .filter({ hasText: 'Context Beta' })
+ .first()
+ await expect(secondItem).toBeVisible()
+
+ await secondItem.click({ button: 'right' })
+
+ const removeButton = window.locator(
+ '[data-testid="workspace-project-remove-workspace-ctx-b"]',
+ )
+ await expect(removeButton).toBeVisible()
+ } finally {
+ await electronApp.close()
+ }
+ })
+})