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 ? (