Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Spaces: allow empty Spaces (no last-node warning/auto-close), add pane context menu action to create an empty Space, and allow archiving a Space without saving its history. (#171)

### 🐞 Fixed
- Workspace canvas: keep window dragging stable at Space edges and preview crowded Space expansion before release without committing transient geometry. (#300)
- Sidebar: stabilize Project, Space, and Agent drag sorting with scoped collision detection, consistent drag overlays, and measured upward-drag motion coverage. (#295)
- Sidebar: use a continuous shared-list animation for expanded/collapsed mode switches, keep the Space disclosure as one moving button, hide rail text cleanly, preserve list scroll position, add overflow edge fades, and keep label color context menus open for consecutive changes. (#292)
- Space Explorer: make image quick previews match the opened image node size and keep preview headers from covering image content. (#287)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,6 @@ export function WorkspaceCanvasInner({
onShowMessage,
hideWorktreeMismatchDropWarning: agentSettings.hideWorktreeMismatchDropWarning === true,
nodeDragPointerAnchorRef: nodeDragSession.nodeDragPointerAnchorRef,
nodeSpaceFramePreviewRef: nodeDragSession.nodeSpaceFramePreviewRef,
})
const agentSupport = workspaceCanvasHooks.useWorkspaceCanvasAgentSupport({
nodesRef: nodeStore.nodesRef,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Node } from '@xyflow/react'
import type { TerminalNodeData, WorkspaceSpaceRect } from '../../../types'

export const LIVE_DRAG_LAYOUT_MIN_DISTANCE_PX = 24
export const LIVE_DRAG_LAYOUT_MIN_INTERVAL_MS = 120
export const LIVE_DRAG_SECONDARY_MIN_DISTANCE_PX = 24
export const LIVE_DRAG_SECONDARY_MIN_INTERVAL_MS = 120

export interface DragLayoutProjectionInput {
currentNodes: Node<TerminalNodeData>[]
Expand All @@ -19,10 +19,11 @@ export interface DragLayoutProjectionResult {
nextSpaceFramePreview: ReadonlyMap<string, WorkspaceSpaceRect> | null
}

export interface LastLiveDragLayoutProjection {
export interface LastLiveDragSecondaryProjection {
anchorPoint: { x: number; y: number } | null
draggedNodeKey: string
projectedAtMs: number
targetSpaceId: string | null
}

export function readDragLayoutTimeMs(): number {
Expand Down Expand Up @@ -52,22 +53,24 @@ export function resolveDragLayoutAnchorPoint({
: null
}

export function shouldResolveLiveDragLayoutProjection({
export function shouldResolveLiveDragSecondaryProjection({
lastProjection,
draggedNodeKey,
anchorPoint,
nowMs,
targetSpaceId,
}: {
lastProjection: LastLiveDragLayoutProjection | null
lastProjection: LastLiveDragSecondaryProjection | null
draggedNodeKey: string
anchorPoint: { x: number; y: number } | null
nowMs: number
targetSpaceId: string | null
}): boolean {
if (!lastProjection) {
if (!lastProjection || lastProjection.draggedNodeKey !== draggedNodeKey) {
return true
}

if (lastProjection.draggedNodeKey !== draggedNodeKey) {
if (lastProjection.targetSpaceId !== targetSpaceId) {
return true
}

Expand All @@ -82,9 +85,12 @@ export function shouldResolveLiveDragLayoutProjection({
return false
}

if (distanceSquared >= LIVE_DRAG_LAYOUT_MIN_DISTANCE_PX * LIVE_DRAG_LAYOUT_MIN_DISTANCE_PX) {
if (
distanceSquared >=
LIVE_DRAG_SECONDARY_MIN_DISTANCE_PX * LIVE_DRAG_SECONDARY_MIN_DISTANCE_PX
) {
return true
}

return nowMs - lastProjection.projectedAtMs >= LIVE_DRAG_LAYOUT_MIN_INTERVAL_MS
return nowMs - lastProjection.projectedAtMs >= LIVE_DRAG_SECONDARY_MIN_INTERVAL_MS
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,20 @@ import {
setResolvedSpaceFramePreview,
} from './useApplyNodeChanges.helpers'
import { projectWorkspaceSpaceDominantLayout } from './useApplyNodeChanges.spaceDominant'
import { projectWorkspaceNodeDropLayout } from './useSpaceOwnership.projectDropLayout'
import {
projectWorkspaceNodeDropLayout,
projectWorkspaceNodeLiveDropLayout,
projectWorkspaceNodePrimaryDropLayout,
} from './useSpaceOwnership.projectDropLayout'
import { resolveSpaceAtPoint } from './useSpaceOwnership.drop.helpers'
import {
buildDraggedNodeKey,
readDragLayoutTimeMs,
resolveDragLayoutAnchorPoint,
shouldResolveLiveDragLayoutProjection,
shouldResolveLiveDragSecondaryProjection,
type DragLayoutProjectionInput,
type DragLayoutProjectionResult,
type LastLiveDragLayoutProjection,
type LastLiveDragSecondaryProjection,
} from './useNodeDragSession.liveProjection'
import type { WorkspaceCanvasNodeDragSession } from './useNodeDragSession.types'
export type { WorkspaceCanvasNodeDragSession } from './useNodeDragSession.types'
Expand Down Expand Up @@ -50,7 +55,7 @@ export function useWorkspaceCanvasNodeDragSession({
nodeId: string
offset: { x: number; y: number }
} | null>(null)
const nodeSpaceFramePreviewRef = useRef<ReadonlyMap<string, WorkspaceSpaceRect> | null>(null)
// Visual-only for ordinary node drops; ownership always recomputes from durable Spaces.
const [nodeSpaceFramePreview, setNodeSpaceFramePreview] = useState<ReadonlyMap<
string,
WorkspaceSpaceRect
Expand All @@ -59,61 +64,36 @@ export function useWorkspaceCanvasNodeDragSession({
const dragBaselineSpaceRectByIdRef = useRef<Map<string, WorkspaceSpaceRect> | null>(null)
const dragSpaceDominantRef = useRef(false)
const dragSpaceFramePreviewRef = useRef<ReadonlyMap<string, WorkspaceSpaceRect> | null>(null)
const committableSpaceFramePreviewRef = useRef<ReadonlyMap<string, WorkspaceSpaceRect> | null>(
null,
)
const pendingReleasePositionByIdRef = useRef<Map<string, { x: number; y: number }> | null>(null)
const latestDragLayoutProjectionInputRef = useRef<DragLayoutProjectionInput | null>(null)
const lastLiveDragLayoutProjectionRef = useRef<LastLiveDragLayoutProjection | null>(null)
const liveDragLayoutFrameRef = useRef<number | null>(null)
const lastLiveDragSecondaryProjectionRef = useRef<LastLiveDragSecondaryProjection | null>(null)

const setNodeSpaceFramePreviewState = useCallback(
(updater: React.SetStateAction<ReadonlyMap<string, WorkspaceSpaceRect> | null>) => {
setNodeSpaceFramePreview(current => {
const next =
typeof updater === 'function'
? (
updater as (
current: ReadonlyMap<string, WorkspaceSpaceRect> | null,
) => ReadonlyMap<string, WorkspaceSpaceRect> | null
)(current)
: updater

nodeSpaceFramePreviewRef.current = next
return next
})
},
(next: React.SetStateAction<ReadonlyMap<string, WorkspaceSpaceRect> | null>) =>
setNodeSpaceFramePreview(next),
[],
)

const resetLiveDragLayoutProjection = useCallback(() => {
latestDragLayoutProjectionInputRef.current = null
lastLiveDragLayoutProjectionRef.current = null
if (liveDragLayoutFrameRef.current !== null) {
window.cancelAnimationFrame?.(liveDragLayoutFrameRef.current)
liveDragLayoutFrameRef.current = null
}
}, [])

const markLiveDragLayoutProjectionFrame = useCallback(() => {
if (
liveDragLayoutFrameRef.current !== null ||
typeof window.requestAnimationFrame !== 'function'
) {
return
}

liveDragLayoutFrameRef.current = window.requestAnimationFrame(() => {
liveDragLayoutFrameRef.current = null
})
lastLiveDragSecondaryProjectionRef.current = null
}, [])

const resolveDragLayoutProjection = useCallback(
({
currentNodes,
draggedNodeIds,
desiredDraggedPositionById,
dropFlowPoint,
anchorNodeId,
anchorIsSelected,
}: DragLayoutProjectionInput): DragLayoutProjectionResult => {
(
{
currentNodes,
draggedNodeIds,
desiredDraggedPositionById,
dropFlowPoint,
anchorNodeId,
anchorIsSelected,
}: DragLayoutProjectionInput,
strategy: 'primary' | 'live' | 'final',
): DragLayoutProjectionResult => {
const baselinePositionById = dragBaselinePositionByIdRef.current
const baselineSpaceRectById = dragBaselineSpaceRectByIdRef.current
const resolvedAnchorNodeId = anchorNodeId ?? draggedNodeIds[0] ?? null
Expand Down Expand Up @@ -153,6 +133,7 @@ export function useWorkspaceCanvasNodeDragSession({
})

dragSpaceFramePreviewRef.current = projected.nextSpaceFramePreview
committableSpaceFramePreviewRef.current = projected.nextSpaceFramePreview
setResolvedSpaceFramePreview(setNodeSpaceFramePreviewState, projected.nextSpaceFramePreview)
pendingReleasePositionByIdRef.current = null

Expand All @@ -168,13 +149,22 @@ export function useWorkspaceCanvasNodeDragSession({
}
}

// Ordinary node previews are visual-only and can never be committed as a
// Space-dominant frame, even if selection changes during the drag.
committableSpaceFramePreviewRef.current = null
const baselineNodes = buildDragBaselineNodes({
nodes: currentNodes,
baselinePositionById,
shiftNodeIds: null,
})

const projected = projectWorkspaceNodeDropLayout({
const projectDropLayout =
strategy === 'primary'
? projectWorkspaceNodePrimaryDropLayout
: strategy === 'live'
? projectWorkspaceNodeLiveDropLayout
: projectWorkspaceNodeDropLayout
const projected = projectDropLayout({
nodes: baselineNodes,
spaces: spacesRef.current,
draggedNodeIds,
Expand Down Expand Up @@ -206,16 +196,22 @@ export function useWorkspaceCanvasNodeDragSession({
}
}

const nextSpaceFramePreview = hasChanged
? new Map(
projected.nextSpaces
.filter(space => Boolean(space.rect))
.map(space => [space.id, space.rect!] as const),
)
: null

dragSpaceFramePreviewRef.current = nextSpaceFramePreview
setResolvedSpaceFramePreview(setNodeSpaceFramePreviewState, nextSpaceFramePreview)
const nextSpaceFramePreview =
strategy === 'primary'
? dragSpaceFramePreviewRef.current
: hasChanged
? new Map(
projected.nextSpaces
.filter(space => Boolean(space.rect))
.map(space => [space.id, space.rect!] as const),
)
: null

if (strategy !== 'primary') {
dragSpaceFramePreviewRef.current = nextSpaceFramePreview
committableSpaceFramePreviewRef.current = null
setResolvedSpaceFramePreview(setNodeSpaceFramePreviewState, nextSpaceFramePreview)
}

return {
nextNodes,
Expand All @@ -233,6 +229,7 @@ export function useWorkspaceCanvasNodeDragSession({

const clearNodeDragProjection = useCallback(() => {
dragSpaceFramePreviewRef.current = null
committableSpaceFramePreviewRef.current = null
pendingReleasePositionByIdRef.current = null
resetLiveDragLayoutProjection()
setResolvedSnapGuides(setSnapGuides, null)
Expand All @@ -252,6 +249,7 @@ export function useWorkspaceCanvasNodeDragSession({
const selectedSpaceIdsAtStart = dragSelectedSpaceIdsRef.current ?? selectedSpaceIdsRef.current
dragSpaceDominantRef.current = selectedSpaceIdsAtStart.length > 0
dragSpaceFramePreviewRef.current = null
committableSpaceFramePreviewRef.current = null
pendingReleasePositionByIdRef.current = null
resetLiveDragLayoutProjection()
},
Expand Down Expand Up @@ -344,43 +342,55 @@ export function useWorkspaceCanvasNodeDragSession({
desiredDraggedPositionById: nextDraggedPositionById,
})
const nowMs = readDragLayoutTimeMs()
const shouldResolveProjection = shouldResolveLiveDragLayoutProjection({
lastProjection: lastLiveDragLayoutProjectionRef.current,
const draggedNodeIdSet = new Set(draggedNodeIds)
const desiredDraggedNodes = currentNodes.flatMap(node => {
if (!draggedNodeIdSet.has(node.id)) {
return []
}

const desiredPosition = nextDraggedPositionById.get(node.id)
return desiredPosition ? [{ ...node, position: desiredPosition }] : []
})
const desiredDraggedRect = unionWorkspaceNodeRects(desiredDraggedNodes)
const targetPoint =
draggedNodeIds.length === 1 &&
dropFlowPoint &&
Number.isFinite(dropFlowPoint.x) &&
Number.isFinite(dropFlowPoint.y)
? dropFlowPoint
: desiredDraggedRect
? {
x: desiredDraggedRect.x + desiredDraggedRect.width * 0.5,
y: desiredDraggedRect.y + desiredDraggedRect.height * 0.5,
}
: null
const targetSpaceId = targetPoint
? (resolveSpaceAtPoint(spacesRef.current, targetPoint)?.id ?? null)
: null
const shouldResolveSecondary = shouldResolveLiveDragSecondaryProjection({
lastProjection: lastLiveDragSecondaryProjectionRef.current,
draggedNodeKey,
anchorPoint,
nowMs,
targetSpaceId,
})

if (!shouldResolveProjection) {
return {
nextNodes: currentNodes,
nextDraggedNodePositionById: new Map(
draggedNodeIds.flatMap(nodeId => {
const next = nextDraggedPositionById.get(nodeId)
return next ? ([[nodeId, next]] as const) : []
}),
),
nextSpaceFramePreview: dragSpaceFramePreviewRef.current,
if (shouldResolveSecondary) {
lastLiveDragSecondaryProjectionRef.current = {
anchorPoint: anchorPoint ? { ...anchorPoint } : null,
draggedNodeKey,
projectedAtMs: nowMs,
targetSpaceId,
}
}

const projected = resolveDragLayoutProjection(projectionInput)
lastLiveDragLayoutProjectionRef.current = {
anchorPoint: anchorPoint ? { ...anchorPoint } : null,
draggedNodeKey,
projectedAtMs: nowMs,
}
markLiveDragLayoutProjectionFrame()

return projected
// Never skip primary: deferred peer layout must not expose raw edge-crossing positions.
return resolveDragLayoutProjection(
projectionInput,
shouldResolveSecondary ? 'live' : 'primary',
)
},
[
magneticSnappingEnabledRef,
markLiveDragLayoutProjectionFrame,
resolveDragLayoutProjection,
setSnapGuides,
spacesRef,
],
[magneticSnappingEnabledRef, resolveDragLayoutProjection, setSnapGuides, spacesRef],
)

const applyPendingReleaseProjection = useCallback(
Expand Down Expand Up @@ -412,19 +422,22 @@ export function useWorkspaceCanvasNodeDragSession({
desiredDraggedPositionById.set(nodeId, { x: position.x, y: position.y })
}

const finalProjection = resolveDragLayoutProjection({
...latestInput,
currentNodes: releaseNodes,
desiredDraggedPositionById,
})
const finalProjection = resolveDragLayoutProjection(
{
...latestInput,
currentNodes: releaseNodes,
desiredDraggedPositionById,
},
'final',
)
return finalProjection.nextNodes
},
[resolveDragLayoutProjection],
)

const endNodeDragSession = useCallback(() => {
if (dragSpaceDominantRef.current && dragSpaceFramePreviewRef.current) {
const rectOverrideById = dragSpaceFramePreviewRef.current
if (dragSpaceDominantRef.current && committableSpaceFramePreviewRef.current) {
const rectOverrideById = committableSpaceFramePreviewRef.current
const previousSpaces = spacesRef.current
let hasSpaceChange = false
const nextSpaces = previousSpaces.map(space => {
Expand Down Expand Up @@ -465,7 +478,6 @@ export function useWorkspaceCanvasNodeDragSession({
return {
nodeDragPointerAnchorRef,
nodeSpaceFramePreview,
nodeSpaceFramePreviewRef,
dragBaselinePositionByIdRef,
dragBaselineSpaceRectByIdRef,
beginNodeDragSession,
Expand Down
Loading
Loading