diff --git a/CHANGELOG.md b/CHANGELOG.md index cfd01495f..d4cea492f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,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 +- 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) - Dev: keep the launching terminal interactive after stopping `pnpm dev` with Ctrl+C by making the dev wrapper own child-process shutdown. (#286) diff --git a/src/app/renderer/i18n/locales/en.shell.ts b/src/app/renderer/i18n/locales/en.shell.ts index a38259084..98cecbf9e 100644 --- a/src/app/renderer/i18n/locales/en.shell.ts +++ b/src/app/renderer/i18n/locales/en.shell.ts @@ -3,6 +3,18 @@ export const enShell = { rename: 'Rename', save: 'Save', cancel: 'Cancel', + projectIcon: 'Project icon', + defaultProjectIcon: 'Default folder', + icons: { + code: 'Code', + terminal: 'Terminal', + database: 'Database', + globe: 'Globe', + package: 'Package', + bot: 'Bot', + briefcase: 'Briefcase', + 'book-open': 'Book', + }, manageMounts: 'Manage locations…', openInExplorer: 'Open in Explorer', openInFinder: 'Open in Finder', diff --git a/src/app/renderer/i18n/locales/zh-CN.shell.ts b/src/app/renderer/i18n/locales/zh-CN.shell.ts index ad741a560..a8115887d 100644 --- a/src/app/renderer/i18n/locales/zh-CN.shell.ts +++ b/src/app/renderer/i18n/locales/zh-CN.shell.ts @@ -3,6 +3,18 @@ export const zhCNShell = { rename: '重命名', save: '保存', cancel: '取消', + projectIcon: '项目图标', + defaultProjectIcon: '默认文件夹', + icons: { + code: '代码', + terminal: '终端', + database: '数据库', + globe: '网络', + package: '包', + bot: 'Bot', + briefcase: '公文包', + 'book-open': '文档', + }, manageMounts: '管理位置…', openInExplorer: '在资源管理器中打开', openInFinder: '在 Finder 中打开', diff --git a/src/app/renderer/shell/AppShell.tsx b/src/app/renderer/shell/AppShell.tsx index 31ec2b2ae..96092f802 100644 --- a/src/app/renderer/shell/AppShell.tsx +++ b/src/app/renderer/shell/AppShell.tsx @@ -288,6 +288,8 @@ export default function App(): React.JSX.Element { handleRequestManageProjectMounts, handleRequestOpenProjectInFileManager, handleReorderWorkspaces, + handleReorderWorkspaceRootSpaces, + handleReorderWorkspaceSidebarAgents, } = useAppShellWorkspaceActions({ requestPersistFlush, t, showMessage: handleShowMessage }) useProjectContextMenuDismiss({ @@ -359,6 +361,8 @@ export default function App(): React.JSX.Element { onOpenProjectContextMenu={setProjectContextMenu} onSelectAgentNode={handleSelectAgentNode} onReorderWorkspaces={handleReorderWorkspaces} + onReorderWorkspaceRootSpaces={handleReorderWorkspaceRootSpaces} + onReorderWorkspaceSidebarAgents={handleReorderWorkspaceSidebarAgents} onPointerEnter={handleSidebarPointerEnter} onPointerLeave={handleSidebarPointerLeave} /> @@ -441,9 +445,7 @@ export default function App(): React.JSX.Element { onDeleteSpaceArchiveRecord={handleWorkspaceSpaceArchiveRecordRemove} onCloseSpaceArchives={closeSpaceArchives} isAddProjectWizardOpen={isAddProjectWizardOpen} - onCloseAddProjectWizard={() => { - closeAddProjectWizard() - }} + onCloseAddProjectWizard={closeAddProjectWizard} projectContextMenu={projectContextMenu} projectMountManager={projectMountManager} onCloseProjectMountManager={() => { diff --git a/src/app/renderer/shell/components/ProjectContextMenu.spec.tsx b/src/app/renderer/shell/components/ProjectContextMenu.spec.tsx index ba2d486cc..c0f5a5d66 100644 --- a/src/app/renderer/shell/components/ProjectContextMenu.spec.tsx +++ b/src/app/renderer/shell/components/ProjectContextMenu.spec.tsx @@ -119,6 +119,16 @@ describe('ProjectContextMenu', () => { expect(useAppStore.getState().workspaces[0]?.name).toBe('Project Renamed') }) + it('updates a project icon from the context menu', () => { + renderMenu({ kind: 'project', workspaceId: 'workspace-a' }) + + fireEvent.click(screen.getByTestId('workspace-project-context-menu-icon-code')) + expect(useAppStore.getState().workspaces[0]?.iconId).toBe('code') + + fireEvent.click(screen.getByTestId('workspace-project-context-menu-icon-default')) + expect(useAppStore.getState().workspaces[0]?.iconId).toBeNull() + }) + it('renames a space target', () => { renderMenu({ kind: 'space', workspaceId: 'workspace-a', spaceId: 'space-a' }) diff --git a/src/app/renderer/shell/components/ProjectContextMenu.state.ts b/src/app/renderer/shell/components/ProjectContextMenu.state.ts index f09a71194..f9b9ab32c 100644 --- a/src/app/renderer/shell/components/ProjectContextMenu.state.ts +++ b/src/app/renderer/shell/components/ProjectContextMenu.state.ts @@ -1,4 +1,5 @@ import type { LabelColor, NodeLabelColorOverride } from '@shared/types/labelColor' +import { normalizeProjectIconId, type ProjectIconId } from '@shared/types/projectIcon' import { buildAgentNodeTitle, stripAgentProviderPrefix, @@ -137,6 +138,29 @@ export function resolveTargetLabelColor( return node.data.labelColorOverride ?? null } +export function resolveTargetProjectIconId( + workspaces: WorkspaceState[], + target: ProjectContextMenuTarget, +): ProjectIconId | null { + if (target.kind !== 'project') { + return null + } + + const workspace = workspaces.find(candidate => candidate.id === target.workspaceId) ?? null + return normalizeProjectIconId(workspace?.iconId) +} + +export function setTargetProjectIconId( + target: ProjectContextMenuTarget, + iconId: ProjectIconId | null, +): void { + if (target.kind !== 'project') { + return + } + + useAppStore.getState().setProjectIconId(target.workspaceId, iconId) +} + export function setTargetLabelColor( target: ProjectContextMenuTarget, labelColor: LabelColor | NodeLabelColorOverride | null, diff --git a/src/app/renderer/shell/components/ProjectContextMenu.tsx b/src/app/renderer/shell/components/ProjectContextMenu.tsx index f9b984f15..f4d7943c5 100644 --- a/src/app/renderer/shell/components/ProjectContextMenu.tsx +++ b/src/app/renderer/shell/components/ProjectContextMenu.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' -import { Check, Copy, FolderOpen, FolderX, HardDrive, Pencil, X } from 'lucide-react' +import { Check, Copy, Folder, FolderOpen, FolderX, HardDrive, Pencil, X } from 'lucide-react' import { useTranslation } from '@app/renderer/i18n' import { ViewportMenuSurface } from '@app/renderer/components/ViewportMenuSurface' import { @@ -7,14 +7,18 @@ import { type LabelColor, type NodeLabelColorOverride, } from '@shared/types/labelColor' +import { PROJECT_ICON_IDS, type ProjectIconId } from '@shared/types/projectIcon' import type { WorkspaceState } from '@contexts/workspace/presentation/renderer/types' import type { ProjectContextMenuTarget } from '../types' import { useAppStore } from '../store/useAppStore' +import { getProjectIconComponent } from './ProjectIcon' import { renameTarget, + resolveTargetProjectIconId, resolveTargetLabelColor, resolveTargetName, resolveTargetSpacePath, + setTargetProjectIconId, setTargetLabelColor, } from './ProjectContextMenu.state' @@ -58,6 +62,7 @@ export function ProjectContextMenu({ const isProjectTarget = resolvedTarget.kind === 'project' const isSpaceTarget = resolvedTarget.kind === 'space' const supportsLabelColor = resolvedTarget.kind !== 'project' + const currentProjectIconId = resolveTargetProjectIconId(workspaces, resolvedTarget) const currentLabelColor = resolveTargetLabelColor(workspaces, resolvedTarget) const estimatedWidth = isRenaming ? 236 @@ -73,9 +78,9 @@ export function ProjectContextMenu({ : isSpaceTarget ? 174 : canOpenInFileManager && isProjectTarget - ? 148 + ? 218 : isProjectTarget - ? 112 + ? 182 : resolvedTarget.kind === 'agent' ? 124 : 102 @@ -120,6 +125,9 @@ export function ProjectContextMenu({ const commitLabelColor = (labelColor: LabelColor | NodeLabelColorOverride | null): void => { setTargetLabelColor(resolvedTarget, labelColor) } + const commitProjectIcon = (iconId: ProjectIconId | null): void => { + setTargetProjectIconId(resolvedTarget, iconId) + } const copySpacePath = (): void => { const path = resolveTargetSpacePath(workspaces, resolvedTarget) useAppStore.getState().setProjectContextMenu(null) @@ -227,6 +235,64 @@ export function ProjectContextMenu({ ) } + const renderProjectIconButton = ({ + id, + iconId, + label, + }: { + id: string + iconId: ProjectIconId | null + label: string + }): React.JSX.Element => { + const Icon = iconId ? (getProjectIconComponent(iconId) ?? Folder) : Folder + const selected = currentProjectIconId === iconId + + return ( + + ) + } + const renderProjectIconRow = (): React.JSX.Element | null => { + if (!isProjectTarget) { + return null + } + + return ( +
+
+ {t('projectContextMenu.projectIcon')} +
+
+ {renderProjectIconButton({ + id: 'default', + iconId: null, + label: t('projectContextMenu.defaultProjectIcon'), + })} + {PROJECT_ICON_IDS.map(iconId => + renderProjectIconButton({ + id: iconId, + iconId, + label: t(`projectContextMenu.icons.${iconId}`), + }), + )} +
+
+ ) + } return ( {t('projectContextMenu.rename')} )} + {!isRenaming && isProjectTarget ? renderProjectIconRow() : null} {!isRenaming && isProjectTarget ? ( + ) +} export function SidebarAgentItems({ workspaceId, + groupId = null, agentItems, onSelectAgentNode, onOpenProjectContextMenu, showOwningSpacePill = false, }: { workspaceId: string + groupId?: string | null agentItems: SidebarAgentItemModel[] onSelectAgentNode: (workspaceId: string, nodeId: string) => void onOpenProjectContextMenu: (state: ProjectContextMenuState) => void showOwningSpacePill?: boolean }): React.JSX.Element | null { - const { t } = useTranslation() - if (agentItems.length === 0) { return null } - return ( + const content = (
- {agentItems.map(({ node, displayTitle, effectiveLabelColor, owningSpace, status }) => { - const provider = node.data.agent?.provider - const providerText = provider - ? AGENT_PROVIDER_LABEL[provider] - : t('sidebar.fallbackAgentLabel') - const startedText = toRelativeTime(node.data.startedAt) - const sidebarAgentStatusText = - status === 'working' ? t('sidebar.status.working') : t('sidebar.status.standby') + {agentItems.map(item => ( + + ))} +
+ ) - return ( - - ) - })} + if (!groupId) { + return content + } + + return ( + createAgentSortableId(workspaceId, groupId, item.node.id))} + strategy={verticalListSortingStrategy} + > + {content} + + ) +} + +export function SidebarAgentItemOverlay({ + item, + showOwningSpacePill = false, +}: { + item: SidebarAgentItemModel + showOwningSpacePill?: boolean +}): React.JSX.Element { + const { t } = useTranslation() + const { node, displayTitle, effectiveLabelColor, owningSpace, status } = item + const provider = node.data.agent?.provider + const providerText = provider ? AGENT_PROVIDER_LABEL[provider] : t('sidebar.fallbackAgentLabel') + const startedText = toRelativeTime(node.data.startedAt) + const sidebarAgentStatusText = + status === 'working' ? t('sidebar.status.working') : t('sidebar.status.standby') + + return ( +
+
) } diff --git a/src/app/renderer/shell/components/SidebarDnd.spec.ts b/src/app/renderer/shell/components/SidebarDnd.spec.ts new file mode 100644 index 000000000..29c1b0933 --- /dev/null +++ b/src/app/renderer/shell/components/SidebarDnd.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { + canReorderSidebarDragItems, + readSidebarDragItemData, + type SidebarDragItemData, +} from './SidebarDnd' + +describe('SidebarDnd', () => { + it('keeps sortable item movement inside the allowed sidebar scope', () => { + const projectA = { kind: 'project', workspaceId: 'workspace-a' } satisfies SidebarDragItemData + const projectB = { kind: 'project', workspaceId: 'workspace-b' } satisfies SidebarDragItemData + const spaceA1 = { + kind: 'space', + workspaceId: 'workspace-a', + spaceId: 'space-1', + } satisfies SidebarDragItemData + const spaceA2 = { + kind: 'space', + workspaceId: 'workspace-a', + spaceId: 'space-2', + } satisfies SidebarDragItemData + const spaceB = { + kind: 'space', + workspaceId: 'workspace-b', + spaceId: 'space-1', + } satisfies SidebarDragItemData + const agentA1 = { + kind: 'agent', + workspaceId: 'workspace-a', + groupId: 'group-a', + nodeId: 'agent-1', + } satisfies SidebarDragItemData + const agentA2 = { + kind: 'agent', + workspaceId: 'workspace-a', + groupId: 'group-a', + nodeId: 'agent-2', + } satisfies SidebarDragItemData + const agentOtherGroup = { + kind: 'agent', + workspaceId: 'workspace-a', + groupId: 'group-b', + nodeId: 'agent-3', + } satisfies SidebarDragItemData + + expect(canReorderSidebarDragItems(projectA, projectB)).toBe(true) + expect(canReorderSidebarDragItems(spaceA1, spaceA2)).toBe(true) + expect(canReorderSidebarDragItems(agentA1, agentA2)).toBe(true) + + expect(canReorderSidebarDragItems(projectA, spaceA1)).toBe(false) + expect(canReorderSidebarDragItems(spaceA1, spaceB)).toBe(false) + expect(canReorderSidebarDragItems(agentA1, agentOtherGroup)).toBe(false) + }) + + it('rejects malformed drag payloads', () => { + expect(readSidebarDragItemData(null)).toBeNull() + expect(readSidebarDragItemData({ kind: 'space', workspaceId: 'workspace-a' })).toBeNull() + expect(readSidebarDragItemData({ kind: 'agent', workspaceId: 'workspace-a' })).toBeNull() + }) +}) diff --git a/src/app/renderer/shell/components/SidebarDnd.ts b/src/app/renderer/shell/components/SidebarDnd.ts new file mode 100644 index 000000000..8ca5991b1 --- /dev/null +++ b/src/app/renderer/shell/components/SidebarDnd.ts @@ -0,0 +1,134 @@ +import { closestCenter, pointerWithin, type CollisionDetection } from '@dnd-kit/core' + +export type SidebarDragItemData = + | { + kind: 'project' + workspaceId: string + } + | { + kind: 'space' + workspaceId: string + spaceId: string + } + | { + kind: 'agent' + workspaceId: string + groupId: string + nodeId: string + } + +export function createSpaceSortableId(workspaceId: string, spaceId: string): string { + return `sidebar-space:${workspaceId}:${spaceId}` +} + +export function createAgentSortableId( + workspaceId: string, + groupId: string, + nodeId: string, +): string { + return `sidebar-agent:${workspaceId}:${groupId}:${nodeId}` +} + +export const sidebarSortableTransition = { + duration: 180, + easing: 'cubic-bezier(0.2, 0, 0, 1)', +} + +export const sidebarDropAnimation = { + duration: 180, + easing: 'cubic-bezier(0.2, 0, 0, 1)', +} + +export function canReorderSidebarDragItems( + activeData: SidebarDragItemData, + overData: SidebarDragItemData, +): boolean { + if (activeData.kind !== overData.kind) { + return false + } + + if (activeData.kind === 'project' && overData.kind === 'project') { + return true + } + + if (activeData.kind === 'space' && overData.kind === 'space') { + return activeData.workspaceId === overData.workspaceId + } + + return ( + activeData.kind === 'agent' && + overData.kind === 'agent' && + activeData.workspaceId === overData.workspaceId && + activeData.groupId === overData.groupId + ) +} + +export const sidebarCollisionDetection: CollisionDetection = args => { + const activeData = readSidebarDragItemData(args.active.data.current) + if (!activeData) { + return closestCenter(args) + } + + const droppableContainers = args.droppableContainers.filter(container => { + const overData = readSidebarDragItemData(container.data.current) + return overData ? canReorderSidebarDragItems(activeData, overData) : false + }) + + if (droppableContainers.length === 0) { + return [] + } + + const scopedArgs = { + ...args, + droppableContainers, + } + + const pointerCollisions = pointerWithin(scopedArgs) + if (pointerCollisions.length > 0 || args.pointerCoordinates) { + return pointerCollisions + } + + return closestCenter(scopedArgs) +} + +export function readSidebarDragItemData(value: unknown): SidebarDragItemData | null { + if (!value || typeof value !== 'object') { + return null + } + + const record = value as Record + if (record.kind === 'project' && typeof record.workspaceId === 'string') { + return { + kind: 'project', + workspaceId: record.workspaceId, + } + } + + if ( + record.kind === 'space' && + typeof record.workspaceId === 'string' && + typeof record.spaceId === 'string' + ) { + return { + kind: 'space', + workspaceId: record.workspaceId, + spaceId: record.spaceId, + } + } + + if ( + record.kind === 'agent' && + typeof record.workspaceId === 'string' && + typeof record.groupId === 'string' && + typeof record.nodeId === 'string' + ) { + return { + kind: 'agent', + workspaceId: record.workspaceId, + groupId: record.groupId, + nodeId: record.nodeId, + } + } + + return null +} diff --git a/src/app/renderer/shell/components/SidebarWorkspaceItem.tsx b/src/app/renderer/shell/components/SidebarWorkspaceItem.tsx index 08ad802b0..73aa273e7 100644 --- a/src/app/renderer/shell/components/SidebarWorkspaceItem.tsx +++ b/src/app/renderer/shell/components/SidebarWorkspaceItem.tsx @@ -1,12 +1,13 @@ import React from 'react' -import { Folder, FolderOpen } from 'lucide-react' -import { useSortable } from '@dnd-kit/sortable' +import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { useTranslation } from '@app/renderer/i18n' import type { ProjectContextMenuState } from '../types' import { type SidebarProjectTreeModel, type SidebarSpaceGroupModel } from '../utils/sidebarTree' import { SidebarAgentItems } from './SidebarAgentItems' +import { createSpaceSortableId, sidebarSortableTransition } from './SidebarDnd' import { SidebarDisclosureIcon } from './SidebarDisclosureIcon' +import { ProjectIcon } from './ProjectIcon' type SortableWorkspaceItemProps = { tree: SidebarProjectTreeModel @@ -21,7 +22,7 @@ type SortableWorkspaceItemProps = { onSelectAgentNode: (workspaceId: string, nodeId: string) => void } -function getTreeChildGroups(tree: SidebarProjectTreeModel): SidebarSpaceGroupModel[] { +export function getTreeChildGroups(tree: SidebarProjectTreeModel): SidebarSpaceGroupModel[] { return tree.projectRootGroup ? [...tree.spaceGroups, tree.projectRootGroup] : tree.spaceGroups } @@ -37,12 +38,15 @@ function WorkspaceItemContent({ onToggleProject: (workspaceId: string) => void }): React.JSX.Element { const { t } = useTranslation() - const FolderIcon = hasChildren && isExpanded ? FolderOpen : Folder return ( <> - {hasChildren ? ( @@ -95,13 +99,39 @@ function SpaceGroup({ const label = isProjectRoot ? t('sidebar.projectRoot') : group.name const groupKey = `${workspaceId}:${group.id}` const hasAgents = group.agents.length > 0 + const sortableId = isProjectRoot ? groupKey : createSpaceSortableId(workspaceId, group.id) + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: sortableId, + disabled: isProjectRoot, + transition: sidebarSortableTransition, + data: !isProjectRoot + ? { + kind: 'space', + workspaceId, + spaceId: group.id, + } + : undefined, + }) + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : 1, + } const groupClassName = `workspace-space-group ${ isProjectRoot ? 'workspace-space-group--root' : 'workspace-space-group--space' - }${hasAgents && isExpanded ? ' workspace-space-group--branched' : ''}` + }${hasAgents && isExpanded ? ' workspace-space-group--branched' : ''}${ + isDragging ? ' workspace-space-group--dragging' : '' + }` return ( -
+
{ event.preventDefault() if (!group.space) { @@ -149,47 +180,19 @@ function SpaceGroup({ onSelectWorkspace(workspaceId) }} > -
{hasAgents && isExpanded ? (
1 ? group.id : null} agentItems={group.agents} onSelectAgentNode={onSelectAgentNode} onOpenProjectContextMenu={onOpenProjectContextMenu} @@ -215,6 +218,11 @@ export function SortableWorkspaceItem({ const { workspace } = tree const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: workspace.id, + transition: sidebarSortableTransition, + data: { + kind: 'project', + workspaceId: workspace.id, + }, }) const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), @@ -223,13 +231,18 @@ export function SortableWorkspaceItem({ } const childGroups = getTreeChildGroups(tree) const hasChildren = childGroups.length > 0 + const rootSpaceSortableIds = tree.spaceGroups.map(group => + createSpaceSortableId(workspace.id, group.id), + ) return (
- {childGroups.map(group => ( - - ))} + + {childGroups.map(group => ( + + ))} +
) : null}
) } +function SpaceItemContent({ + label, + hasAgents, + isExpanded, + isOverlay = false, + onToggleSpaceGroup, +}: { + label: string + hasAgents: boolean + isExpanded: boolean + isOverlay?: boolean + onToggleSpaceGroup?: () => void +}): React.JSX.Element { + const { t } = useTranslation() + + return ( + <> +