From d5e1e296d3e777c27d42f58cc56469df231a576f Mon Sep 17 00:00:00 2001 From: deadwavewave Date: Tue, 7 Jul 2026 14:28:21 +0800 Subject: [PATCH 1/8] feat: improve sidebar item controls --- src/app/renderer/i18n/locales/en.shell.ts | 12 + src/app/renderer/i18n/locales/zh-CN.shell.ts | 12 + src/app/renderer/shell/AppShell.tsx | 8 +- .../components/ProjectContextMenu.spec.tsx | 10 + .../components/ProjectContextMenu.state.ts | 24 ++ .../shell/components/ProjectContextMenu.tsx | 73 +++- .../renderer/shell/components/ProjectIcon.tsx | 51 +++ src/app/renderer/shell/components/Sidebar.tsx | 78 +++- .../shell/components/SidebarAgentItems.tsx | 225 +++++++----- .../renderer/shell/components/SidebarDnd.ts | 70 ++++ .../shell/components/SidebarWorkspaceItem.tsx | 76 +++- .../hooks/useAppShellWorkspaceActions.ts | 27 +- .../shell/hooks/useHydrateAppState.helpers.ts | 1 + .../mergeWorkspaceStateForSync.equality.ts | 1 + .../workerSync/mergeWorkspaceStateForSync.ts | 2 + src/app/renderer/shell/store/useAppStore.ts | 81 ++++- src/app/renderer/shell/utils/sidebarAgents.ts | 23 ++ .../shell/utils/sidebarReorder.spec.ts | 165 +++++++++ .../renderer/shell/utils/sidebarReorder.ts | 142 ++++++++ .../renderer/shell/utils/sidebarTree.spec.ts | 23 +- .../renderer/styles/workspace-agent-item.css | 12 +- .../styles/workspace-context-menu.css | 42 +++ src/app/renderer/styles/workspace-sidebar.css | 18 +- .../styles/workspace-sidebar.rail.css | 4 +- .../styles/workspace-sidebar.surface.css | 10 +- .../workspace/presentation/renderer/types.ts | 5 + .../renderer/utils/nodeTransform.ts | 1 + .../renderer/utils/persistence/ensure.ts | 6 + .../renderer/utils/persistence/normalize.ts | 6 + .../utils/persistence/toPersistedState.ts | 7 + src/platform/persistence/sqlite/constants.ts | 2 +- src/platform/persistence/sqlite/migrate.ts | 14 + src/platform/persistence/sqlite/normalize.ts | 18 +- src/platform/persistence/sqlite/read.ts | 8 + src/platform/persistence/sqlite/schema.ts | 2 + src/platform/persistence/sqlite/write.ts | 10 +- src/shared/sync/mergePersistedAppStates.ts | 28 +- src/shared/types/projectIcon.ts | 20 ++ .../persistenceInstalledUpgradeSupport.ts | 64 +++- .../contract/platform/persistenceRead.spec.ts | 100 +++++- .../platform/persistenceSchemaColumns.ts | 2 + .../persistenceStore.migrations.spec.ts | 6 +- .../platform/persistenceStore.spec.ts | 72 ++++ ...persistenceStoreSortOrderMigration.spec.ts | 5 +- .../platform/persistenceStoreTestSupport.ts | 38 ++ tests/e2e/workspace-canvas.helpers.ts | 2 + ...ace-canvas.sidebar-drag-boundaries.spec.ts | 339 ++++++++++++++++++ ...kspace-canvas.sidebar-drag-reorder.spec.ts | 242 +++++++++++++ ...reOrRevivePreparation.agentRuntime.spec.ts | 2 + ...repareOrRevivePreparation.geometry.spec.ts | 2 + 50 files changed, 2025 insertions(+), 166 deletions(-) create mode 100644 src/app/renderer/shell/components/ProjectIcon.tsx create mode 100644 src/app/renderer/shell/components/SidebarDnd.ts create mode 100644 src/app/renderer/shell/utils/sidebarReorder.spec.ts create mode 100644 src/app/renderer/shell/utils/sidebarReorder.ts create mode 100644 src/shared/types/projectIcon.ts create mode 100644 tests/e2e/workspace-canvas.sidebar-drag-boundaries.spec.ts 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') - - return ( - - ) - })} + {agentItems.map(item => ( + + ))}
) + + if (!groupId) { + return content + } + + return ( + createAgentSortableId(workspaceId, groupId, item.node.id))} + strategy={verticalListSortingStrategy} + > + {content} + + ) } diff --git a/src/app/renderer/shell/components/SidebarDnd.ts b/src/app/renderer/shell/components/SidebarDnd.ts new file mode 100644 index 000000000..544c4fe3e --- /dev/null +++ b/src/app/renderer/shell/components/SidebarDnd.ts @@ -0,0 +1,70 @@ +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 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..9f78b435c 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 } from './SidebarDnd' import { SidebarDisclosureIcon } from './SidebarDisclosureIcon' +import { ProjectIcon } from './ProjectIcon' type SortableWorkspaceItemProps = { tree: SidebarProjectTreeModel @@ -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,36 @@ 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, + 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' : ''}` return ( -
+
{ event.preventDefault() if (!group.space) { @@ -190,6 +218,7 @@ function SpaceGroup({
1 ? group.id : null} agentItems={group.agents} onSelectAgentNode={onSelectAgentNode} onOpenProjectContextMenu={onOpenProjectContextMenu} @@ -215,6 +244,10 @@ export function SortableWorkspaceItem({ const { workspace } = tree const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: workspace.id, + data: { + kind: 'project', + workspaceId: workspace.id, + }, }) const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), @@ -223,6 +256,9 @@ 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}
diff --git a/src/app/renderer/shell/hooks/useAppShellWorkspaceActions.ts b/src/app/renderer/shell/hooks/useAppShellWorkspaceActions.ts index 87bc39fa7..28743625f 100644 --- a/src/app/renderer/shell/hooks/useAppShellWorkspaceActions.ts +++ b/src/app/renderer/shell/hooks/useAppShellWorkspaceActions.ts @@ -176,8 +176,29 @@ export function useAppShellWorkspaceActions({ const handleReorderWorkspaces = useCallback( (activeId: string, overId: string): void => { const store = useAppStore.getState() - store.reorderWorkspaces(activeId, overId) - requestPersistFlush() + if (store.reorderWorkspaces(activeId, overId)) { + requestPersistFlush() + } + }, + [requestPersistFlush], + ) + + const handleReorderWorkspaceRootSpaces = useCallback( + (workspaceId: string, activeSpaceId: string, overSpaceId: string): void => { + const store = useAppStore.getState() + if (store.reorderWorkspaceRootSpaces(workspaceId, activeSpaceId, overSpaceId)) { + requestPersistFlush() + } + }, + [requestPersistFlush], + ) + + const handleReorderWorkspaceSidebarAgents = useCallback( + (workspaceId: string, activeNodeId: string, overNodeId: string): void => { + const store = useAppStore.getState() + if (store.reorderWorkspaceSidebarAgents(workspaceId, activeNodeId, overNodeId)) { + requestPersistFlush() + } }, [requestPersistFlush], ) @@ -191,5 +212,7 @@ export function useAppShellWorkspaceActions({ handleRequestManageProjectMounts, handleRequestOpenProjectInFileManager, handleReorderWorkspaces, + handleReorderWorkspaceRootSpaces, + handleReorderWorkspaceSidebarAgents, } } diff --git a/src/app/renderer/shell/hooks/useHydrateAppState.helpers.ts b/src/app/renderer/shell/hooks/useHydrateAppState.helpers.ts index 0fe74b05d..ca70e2cde 100644 --- a/src/app/renderer/shell/hooks/useHydrateAppState.helpers.ts +++ b/src/app/renderer/shell/hooks/useHydrateAppState.helpers.ts @@ -61,6 +61,7 @@ export function toShellWorkspaceState( return { id: workspace.id, name: workspace.name, + iconId: workspace.iconId ?? null, path: workspace.path, worktreesRoot: workspace.worktreesRoot, pullRequestBaseBranchOptions: workspace.pullRequestBaseBranchOptions ?? [], diff --git a/src/app/renderer/shell/hooks/workerSync/mergeWorkspaceStateForSync.equality.ts b/src/app/renderer/shell/hooks/workerSync/mergeWorkspaceStateForSync.equality.ts index b5821c1ea..e64c6e3c4 100644 --- a/src/app/renderer/shell/hooks/workerSync/mergeWorkspaceStateForSync.equality.ts +++ b/src/app/renderer/shell/hooks/workerSync/mergeWorkspaceStateForSync.equality.ts @@ -119,6 +119,7 @@ function isNodeDataEquivalent(persisted: TerminalNodeData, existing: TerminalNod persisted.profileId !== existing.profileId || persisted.runtimeKind !== existing.runtimeKind || (persisted.labelColorOverride ?? null) !== (existing.labelColorOverride ?? null) || + (persisted.sidebarSortOrder ?? null) !== (existing.sidebarSortOrder ?? null) || persisted.status !== existing.status || persisted.startedAt !== existing.startedAt || persisted.endedAt !== existing.endedAt || diff --git a/src/app/renderer/shell/hooks/workerSync/mergeWorkspaceStateForSync.ts b/src/app/renderer/shell/hooks/workerSync/mergeWorkspaceStateForSync.ts index 6289f452f..fb5786333 100644 --- a/src/app/renderer/shell/hooks/workerSync/mergeWorkspaceStateForSync.ts +++ b/src/app/renderer/shell/hooks/workerSync/mergeWorkspaceStateForSync.ts @@ -257,6 +257,7 @@ export function toShellWorkspaceStateForSync( const nextWorkspace: WorkspaceState = { id: workspace.id, name: workspace.name, + iconId: workspace.iconId ?? null, path: workspace.path, worktreesRoot: workspace.worktreesRoot, pullRequestBaseBranchOptions, @@ -272,6 +273,7 @@ export function toShellWorkspaceStateForSync( if ( existingWorkspace && existingWorkspace.name === nextWorkspace.name && + (existingWorkspace.iconId ?? null) === (nextWorkspace.iconId ?? null) && existingWorkspace.path === nextWorkspace.path && existingWorkspace.worktreesRoot === nextWorkspace.worktreesRoot && areStringArraysEqual( diff --git a/src/app/renderer/shell/store/useAppStore.ts b/src/app/renderer/shell/store/useAppStore.ts index be0a3cc2b..4a0c637cb 100644 --- a/src/app/renderer/shell/store/useAppStore.ts +++ b/src/app/renderer/shell/store/useAppStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand' -import { arrayMove } from '@dnd-kit/sortable' import { DEFAULT_AGENT_SETTINGS, type AgentSettings } from '@contexts/settings/domain/agentSettings' +import type { ProjectIconId } from '@shared/types/projectIcon' import type { SettingsPageId } from '@contexts/settings/presentation/renderer/SettingsPanel.shared' import type { WorkspaceState } from '@contexts/workspace/presentation/renderer/types' import type { @@ -10,6 +10,11 @@ import type { ProjectDeleteConfirmationState, ProjectMountManagerState, } from '../types' +import { + reorderWorkspaceList, + reorderWorkspaceRootSpaces, + reorderWorkspaceSidebarAgents, +} from '../utils/sidebarReorder' type SetStateAction = T | ((prev: T) => T) @@ -45,7 +50,18 @@ export interface AppStoreState { setSettingsOpenPageId: (action: SetStateAction) => void setFocusRequest: (action: SetStateAction) => void setPersistNotice: (action: SetStateAction) => void - reorderWorkspaces: (activeId: string, overId: string) => void + reorderWorkspaces: (activeId: string, overId: string) => boolean + reorderWorkspaceRootSpaces: ( + workspaceId: string, + activeSpaceId: string, + overSpaceId: string, + ) => boolean + reorderWorkspaceSidebarAgents: ( + workspaceId: string, + activeNodeId: string, + overNodeId: string, + ) => boolean + setProjectIconId: (workspaceId: string, iconId: ProjectIconId | null) => boolean } export const useAppStore = create(set => ({ @@ -92,15 +108,60 @@ export const useAppStore = create(set => ({ set(state => ({ focusRequest: applySetStateAction(state.focusRequest, action) })), setPersistNotice: action => set(state => ({ persistNotice: applySetStateAction(state.persistNotice, action) })), - reorderWorkspaces: (activeId, overId) => + reorderWorkspaces: (activeId, overId) => { + let changed = false set(state => { - const oldIndex = state.workspaces.findIndex(workspace => workspace.id === activeId) - const newIndex = state.workspaces.findIndex(workspace => workspace.id === overId) + const workspaces = reorderWorkspaceList(state.workspaces, activeId, overId) + changed = workspaces !== state.workspaces + return changed ? { workspaces } : state + }) + return changed + }, + reorderWorkspaceRootSpaces: (workspaceId, activeSpaceId, overSpaceId) => { + let changed = false + set(state => { + const workspaces = reorderWorkspaceRootSpaces( + state.workspaces, + workspaceId, + activeSpaceId, + overSpaceId, + ) + changed = workspaces !== state.workspaces + return changed ? { workspaces } : state + }) + return changed + }, + reorderWorkspaceSidebarAgents: (workspaceId, activeNodeId, overNodeId) => { + let changed = false + set(state => { + const workspaces = reorderWorkspaceSidebarAgents( + state.workspaces, + workspaceId, + activeNodeId, + overNodeId, + ) + changed = workspaces !== state.workspaces + return changed ? { workspaces } : state + }) + return changed + }, + setProjectIconId: (workspaceId, iconId) => { + let changed = false + set(state => { + const workspaces = state.workspaces.map(workspace => { + if (workspace.id !== workspaceId || (workspace.iconId ?? null) === iconId) { + return workspace + } - if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { - return state - } + changed = true + return { + ...workspace, + iconId, + } + }) - return { workspaces: arrayMove(state.workspaces, oldIndex, newIndex) } - }), + return changed ? { workspaces } : state + }) + return changed + }, })) diff --git a/src/app/renderer/shell/utils/sidebarAgents.ts b/src/app/renderer/shell/utils/sidebarAgents.ts index 4ae65703c..1b6693c8c 100644 --- a/src/app/renderer/shell/utils/sidebarAgents.ts +++ b/src/app/renderer/shell/utils/sidebarAgents.ts @@ -38,6 +38,29 @@ export function getWorkspaceAgents(workspace: WorkspaceState): Array node.data.kind === 'agent') .sort((left, right) => { + const leftSortOrder = + typeof left.data.sidebarSortOrder === 'number' && + Number.isFinite(left.data.sidebarSortOrder) + ? Math.floor(left.data.sidebarSortOrder) + : null + const rightSortOrder = + typeof right.data.sidebarSortOrder === 'number' && + Number.isFinite(right.data.sidebarSortOrder) + ? Math.floor(right.data.sidebarSortOrder) + : null + + if (leftSortOrder !== null || rightSortOrder !== null) { + if (leftSortOrder === null) { + return 1 + } + if (rightSortOrder === null) { + return -1 + } + if (leftSortOrder !== rightSortOrder) { + return leftSortOrder - rightSortOrder + } + } + 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 diff --git a/src/app/renderer/shell/utils/sidebarReorder.spec.ts b/src/app/renderer/shell/utils/sidebarReorder.spec.ts new file mode 100644 index 000000000..6ea939dad --- /dev/null +++ b/src/app/renderer/shell/utils/sidebarReorder.spec.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest' +import type { Node } from '@xyflow/react' +import { + DEFAULT_WORKSPACE_MINIMAP_VISIBLE, + DEFAULT_WORKSPACE_VIEWPORT, + type TerminalNodeData, + type WorkspaceState, +} from '@contexts/workspace/presentation/renderer/types' +import { reorderWorkspaceRootSpaces, reorderWorkspaceSidebarAgents } from './sidebarReorder' + +function createAgentNode(id: string, sortOrder?: number): Node { + return { + id, + position: { x: 0, y: 0 }, + width: 320, + height: 240, + data: { + sessionId: `${id}-session`, + title: `codex · ${id}`, + 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/project-a', + expectedDirectory: '/tmp/project-a', + sidebarSortOrder: sortOrder, + agent: { + provider: 'codex', + prompt: 'ship it', + model: 'gpt-5.2-codex', + effectiveModel: 'gpt-5.2-codex', + launchMode: 'new', + resumeSessionId: null, + executionDirectory: '/tmp/project-a', + expectedDirectory: '/tmp/project-a', + directoryMode: 'workspace', + customDirectory: null, + shouldCreateDirectory: false, + taskId: null, + }, + task: null, + note: null, + role: null, + image: null, + document: null, + website: null, + }, + type: 'default', + measured: { width: 320, height: 240 }, + selected: false, + dragging: false, + deletable: true, + } +} + +function createWorkspace(): WorkspaceState { + return { + id: 'project-a', + name: 'Project A', + path: '/tmp/project-a', + worktreesRoot: '', + nodes: [createAgentNode('agent-a', 0), createAgentNode('agent-b', 1)], + viewport: DEFAULT_WORKSPACE_VIEWPORT, + isMinimapVisible: DEFAULT_WORKSPACE_MINIMAP_VISIBLE, + spaces: [ + { + id: 'space-a', + name: 'Space A', + directoryPath: '/tmp/project-a/a', + targetMountId: null, + labelColor: 'blue', + sortOrder: 0, + nodeIds: ['agent-a'], + rect: null, + }, + { + id: 'space-b', + name: 'Space B', + directoryPath: '/tmp/project-a/b', + targetMountId: null, + labelColor: 'green', + sortOrder: 1, + nodeIds: ['agent-b'], + rect: null, + }, + ], + activeSpaceId: null, + spaceArchiveRecords: [], + } +} + +describe('sidebarReorder', () => { + it('updates root space sortOrder without changing node ownership', () => { + const [workspace] = reorderWorkspaceRootSpaces( + [createWorkspace()], + 'project-a', + 'space-a', + 'space-b', + ) + + expect(workspace?.spaces.map(space => [space.id, space.sortOrder])).toEqual([ + ['space-a', 1], + ['space-b', 0], + ]) + expect(workspace?.spaces.map(space => [space.id, space.nodeIds])).toEqual([ + ['space-a', ['agent-a']], + ['space-b', ['agent-b']], + ]) + }) + + it('ignores root space reorder attempts against child spaces', () => { + const workspace = createWorkspace() + const source = { + ...workspace, + spaces: [ + ...workspace.spaces, + { + id: 'space-child', + name: 'Space Child', + directoryPath: '/tmp/project-a/a/child', + targetMountId: null, + parentSpaceId: 'space-a', + labelColor: 'purple' as const, + sortOrder: 0, + nodeIds: [], + rect: null, + }, + ], + } + + const next = reorderWorkspaceRootSpaces([source], 'project-a', 'space-a', 'space-child') + + expect(next).toEqual([source]) + }) + + it('reorders agents only within the same sidebar group', () => { + const source = createWorkspace() + source.spaces = [ + { ...source.spaces[0]!, nodeIds: ['agent-a', 'agent-b'] }, + { ...source.spaces[1]!, nodeIds: [] }, + ] + const [workspace] = reorderWorkspaceSidebarAgents([source], 'project-a', 'agent-a', 'agent-b') + + expect(workspace?.nodes.map(node => [node.id, node.data.sidebarSortOrder])).toEqual([ + ['agent-a', 1], + ['agent-b', 0], + ]) + expect(workspace?.spaces.map(space => [space.id, space.nodeIds])).toEqual([ + ['space-a', ['agent-a', 'agent-b']], + ['space-b', []], + ]) + }) + + it('ignores agent reorder across different sidebar groups', () => { + const workspace = createWorkspace() + const next = reorderWorkspaceSidebarAgents([workspace], 'project-a', 'agent-a', 'agent-b') + + expect(next).toEqual([workspace]) + }) +}) diff --git a/src/app/renderer/shell/utils/sidebarReorder.ts b/src/app/renderer/shell/utils/sidebarReorder.ts new file mode 100644 index 000000000..7f0fa8c17 --- /dev/null +++ b/src/app/renderer/shell/utils/sidebarReorder.ts @@ -0,0 +1,142 @@ +import { arrayMove } from '@dnd-kit/sortable' +import type { WorkspaceState } from '@contexts/workspace/presentation/renderer/types' +import { buildSidebarProjectTree, SIDEBAR_UNASSIGNED_SPACE_GROUP_ID } from './sidebarTree' + +export function reorderWorkspaceList( + workspaces: WorkspaceState[], + activeWorkspaceId: string, + overWorkspaceId: string, +): WorkspaceState[] { + const oldIndex = workspaces.findIndex(workspace => workspace.id === activeWorkspaceId) + const newIndex = workspaces.findIndex(workspace => workspace.id === overWorkspaceId) + + if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { + return workspaces + } + + return arrayMove(workspaces, oldIndex, newIndex) +} + +export function reorderWorkspaceRootSpaces( + workspaces: WorkspaceState[], + workspaceId: string, + activeSpaceId: string, + overSpaceId: string, +): WorkspaceState[] { + const workspace = workspaces.find(candidate => candidate.id === workspaceId) ?? null + if (!workspace || activeSpaceId === overSpaceId) { + return workspaces + } + + const tree = buildSidebarProjectTree(workspace) + const rootSpaceIds = tree.spaceGroups.map(group => group.id) + const oldIndex = rootSpaceIds.indexOf(activeSpaceId) + const newIndex = rootSpaceIds.indexOf(overSpaceId) + + if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { + return workspaces + } + + const nextRootSpaceIds = arrayMove(rootSpaceIds, oldIndex, newIndex) + const sortOrderBySpaceId = new Map(nextRootSpaceIds.map((id, index) => [id, index] as const)) + + let changed = false + const nextSpaces = workspace.spaces.map(space => { + const nextSortOrder = sortOrderBySpaceId.get(space.id) + if (nextSortOrder === undefined || space.sortOrder === nextSortOrder) { + return space + } + + changed = true + return { + ...space, + sortOrder: nextSortOrder, + } + }) + + if (!changed) { + return workspaces + } + + return workspaces.map(candidate => + candidate.id === workspaceId ? { ...candidate, spaces: nextSpaces } : candidate, + ) +} + +function findSidebarAgentGroup( + workspace: WorkspaceState, + nodeId: string, +): { groupId: string; nodeIds: string[] } | null { + const tree = buildSidebarProjectTree(workspace) + const groups = tree.projectRootGroup + ? [...tree.spaceGroups, tree.projectRootGroup] + : tree.spaceGroups + + for (const group of groups) { + const nodeIds = group.agents.map(agent => agent.node.id) + if (nodeIds.includes(nodeId)) { + return { + groupId: group.kind === 'project-root' ? SIDEBAR_UNASSIGNED_SPACE_GROUP_ID : group.id, + nodeIds, + } + } + } + + return null +} + +export function reorderWorkspaceSidebarAgents( + workspaces: WorkspaceState[], + workspaceId: string, + activeNodeId: string, + overNodeId: string, +): WorkspaceState[] { + const workspace = workspaces.find(candidate => candidate.id === workspaceId) ?? null + if (!workspace || activeNodeId === overNodeId) { + return workspaces + } + + const activeGroup = findSidebarAgentGroup(workspace, activeNodeId) + const overGroup = findSidebarAgentGroup(workspace, overNodeId) + if (!activeGroup || !overGroup || activeGroup.groupId !== overGroup.groupId) { + return workspaces + } + + const oldIndex = activeGroup.nodeIds.indexOf(activeNodeId) + const newIndex = activeGroup.nodeIds.indexOf(overNodeId) + if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { + return workspaces + } + + const nextNodeIds = arrayMove(activeGroup.nodeIds, oldIndex, newIndex) + const sortOrderByNodeId = new Map(nextNodeIds.map((id, index) => [id, index] as const)) + + let changed = false + const nodes = workspace.nodes.map(node => { + const nextSortOrder = sortOrderByNodeId.get(node.id) + if ( + nextSortOrder === undefined || + node.data.kind !== 'agent' || + node.data.sidebarSortOrder === nextSortOrder + ) { + return node + } + + changed = true + return { + ...node, + data: { + ...node.data, + sidebarSortOrder: nextSortOrder, + }, + } + }) + + if (!changed) { + return workspaces + } + + return workspaces.map(candidate => + candidate.id === workspaceId ? { ...candidate, nodes } : candidate, + ) +} diff --git a/src/app/renderer/shell/utils/sidebarTree.spec.ts b/src/app/renderer/shell/utils/sidebarTree.spec.ts index ead52045e..e08b3cbc7 100644 --- a/src/app/renderer/shell/utils/sidebarTree.spec.ts +++ b/src/app/renderer/shell/utils/sidebarTree.spec.ts @@ -8,7 +8,11 @@ import { import type { Node } from '@xyflow/react' import { buildSidebarProjectTree, SIDEBAR_UNASSIGNED_SPACE_GROUP_ID } from './sidebarTree' -function createAgentNode(id: string, startedAt: string): Node { +function createAgentNode( + id: string, + startedAt: string, + sidebarSortOrder?: number, +): Node { return { id, position: { x: 0, y: 0 }, @@ -28,6 +32,7 @@ function createAgentNode(id: string, startedAt: string): Node scrollback: null, executionDirectory: null, expectedDirectory: null, + sidebarSortOrder, agent: { provider: 'codex', prompt: 'ship it', @@ -119,4 +124,20 @@ describe('buildSidebarProjectTree', () => { expect(tree.projectRootGroup?.id).toBe(SIDEBAR_UNASSIGNED_SPACE_GROUP_ID) expect(tree.projectRootGroup?.agents.map(agent => agent.node.id)).toEqual(['agent-root']) }) + + it('uses explicit sidebar agent order before startedAt fallback', () => { + const workspace = createWorkspace() + workspace.nodes = [ + createAgentNode('agent-old', '2026-03-29T10:00:00.000Z', 0), + createAgentNode('agent-new', '2026-03-29T10:03:00.000Z', 1), + ] + workspace.spaces = [] + + const tree = buildSidebarProjectTree(workspace) + + expect(tree.projectRootGroup?.agents.map(agent => agent.node.id)).toEqual([ + 'agent-old', + 'agent-new', + ]) + }) }) diff --git a/src/app/renderer/styles/workspace-agent-item.css b/src/app/renderer/styles/workspace-agent-item.css index 770728347..2b03c3215 100644 --- a/src/app/renderer/styles/workspace-agent-item.css +++ b/src/app/renderer/styles/workspace-agent-item.css @@ -136,10 +136,10 @@ button.workspace-agent-item:focus-visible { .workspace-agent-item--sidebar[data-cove-label-color] { --workspace-agent-item-background: color-mix( in srgb, - var(--cove-label-color) 12%, - var(--cove-field) 88% + var(--cove-label-color) 10%, + var(--cove-surface-strong) 90% ); - --workspace-agent-item-border-color: color-mix(in srgb, var(--cove-label-color) 32%, transparent); + --workspace-agent-item-border-color: color-mix(in srgb, var(--cove-label-color) 28%, transparent); --workspace-agent-item-surface-shadow: inset 0 0 0 1px color-mix(in srgb, var(--cove-label-color) 10%, transparent); } @@ -147,10 +147,10 @@ button.workspace-agent-item:focus-visible { .workspace-agent-item--sidebar[data-cove-label-color]:hover { --workspace-agent-item-background: color-mix( in srgb, - var(--cove-label-color) 18%, - var(--cove-field) 82% + var(--cove-label-color) 14%, + var(--cove-surface-strong) 86% ); - --workspace-agent-item-border-color: color-mix(in srgb, var(--cove-label-color) 42%, transparent); + --workspace-agent-item-border-color: color-mix(in srgb, var(--cove-label-color) 36%, transparent); } .workspace-agent-item__body { diff --git a/src/app/renderer/styles/workspace-context-menu.css b/src/app/renderer/styles/workspace-context-menu.css index d9d9ee7c5..2a3e4620a 100644 --- a/src/app/renderer/styles/workspace-context-menu.css +++ b/src/app/renderer/styles/workspace-context-menu.css @@ -173,6 +173,47 @@ padding: 4px 6px 6px; } +.workspace-project-context-menu__project-icon-section { + border-top: 1px solid var(--cove-border-subtle); + border-bottom: 1px solid var(--cove-border-subtle); + padding: 4px 6px 6px; +} + +.workspace-project-context-menu__project-icon-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 3px; +} + +.workspace-project-context-menu button.workspace-project-context-menu__project-icon-button { + position: relative; + width: 30px !important; + height: 28px !important; + min-width: 30px; + max-width: 30px; + max-height: 28px; + padding: 0 !important; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.workspace-project-context-menu__project-icon-button--selected { + background: var(--cove-surface-hover); + border-color: var(--cove-border-subtle); +} + +.workspace-project-context-menu__project-icon { + width: 14px; + height: 14px; + color: var(--cove-text-muted); +} + +.workspace-project-context-menu__project-icon-button--selected + .workspace-project-context-menu__project-icon { + color: var(--cove-text); +} + .workspace-project-context-menu__color-space { display: flex; align-items: center; @@ -219,6 +260,7 @@ } .workspace-project-context-menu__color-check, +.workspace-project-context-menu__icon-check, .workspace-space-action-menu__color-check { position: absolute; left: 50%; diff --git a/src/app/renderer/styles/workspace-sidebar.css b/src/app/renderer/styles/workspace-sidebar.css index d90fb0cca..7c60915ae 100644 --- a/src/app/renderer/styles/workspace-sidebar.css +++ b/src/app/renderer/styles/workspace-sidebar.css @@ -348,7 +348,7 @@ color: var(--cove-text-muted); border-radius: 7px; height: var(--workspace-sidebar-tree-item-size); - padding: 0 6px; + padding: 0 8px; display: flex; justify-content: space-between; align-items: center; @@ -368,24 +368,24 @@ .workspace-space-group--space > .workspace-space-item { color: var(--cove-text); - border-color: color-mix(in srgb, var(--workspace-space-accent) 58%, transparent); - background: color-mix(in srgb, var(--workspace-space-accent) 82%, var(--cove-field) 18%); + border-color: color-mix(in srgb, var(--workspace-space-accent) 46%, transparent); + background: color-mix(in srgb, var(--workspace-space-accent) 38%, var(--cove-surface-strong) 62%); } .workspace-space-group--space > .workspace-space-item:hover { - background: color-mix(in srgb, var(--workspace-space-accent) 88%, var(--cove-field) 12%); + background: color-mix(in srgb, var(--workspace-space-accent) 46%, var(--cove-surface-strong) 54%); } .workspace-space-item[data-cove-label-color], .workspace-space-group[data-cove-label-color] > .workspace-space-item { color: var(--cove-text); - border-color: color-mix(in srgb, var(--workspace-space-accent) 58%, transparent); - background: color-mix(in srgb, var(--workspace-space-accent) 82%, var(--cove-field) 18%); + border-color: color-mix(in srgb, var(--workspace-space-accent) 46%, transparent); + background: color-mix(in srgb, var(--workspace-space-accent) 38%, var(--cove-surface-strong) 62%); } .workspace-space-item[data-cove-label-color]:hover, .workspace-space-group[data-cove-label-color] > .workspace-space-item:hover { - background: color-mix(in srgb, var(--workspace-space-accent) 88%, var(--cove-field) 12%); + background: color-mix(in srgb, var(--workspace-space-accent) 46%, var(--cove-surface-strong) 54%); } .workspace-space-item:focus-visible { @@ -395,8 +395,8 @@ .workspace-space-item--active { color: var(--cove-text); - border-color: color-mix(in srgb, var(--workspace-space-accent) 58%, transparent); - background: color-mix(in srgb, var(--workspace-space-accent) 82%, var(--cove-field) 18%); + border-color: color-mix(in srgb, var(--workspace-space-accent) 46%, transparent); + background: color-mix(in srgb, var(--workspace-space-accent) 38%, var(--cove-surface-strong) 62%); } .workspace-space-item__name { diff --git a/src/app/renderer/styles/workspace-sidebar.rail.css b/src/app/renderer/styles/workspace-sidebar.rail.css index 21406ed51..64d897753 100644 --- a/src/app/renderer/styles/workspace-sidebar.rail.css +++ b/src/app/renderer/styles/workspace-sidebar.rail.css @@ -93,7 +93,7 @@ .workspace-sidebar .workspace-space-item__name { position: relative; flex: 0 0 auto; - width: max(0px, calc(var(--workspace-space-item-surface-width) - 68px)); + width: max(0px, calc(var(--workspace-space-item-surface-width) - 72px)); margin-left: 32px; -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 14px), transparent 100%); mask-image: linear-gradient(to right, #000 calc(100% - 14px), transparent 100%); @@ -133,7 +133,7 @@ .workspace-sidebar .workspace-space-item__toggle-spacer { position: absolute; top: 50%; - left: calc(var(--workspace-space-item-surface-width) - 27.5px); + left: max(1px, calc(var(--workspace-space-item-surface-width) - 32px)); z-index: 1; transform: translateY(-50%); transition: left 0.22s ease; diff --git a/src/app/renderer/styles/workspace-sidebar.surface.css b/src/app/renderer/styles/workspace-sidebar.surface.css index 273d200a1..7c2f57696 100644 --- a/src/app/renderer/styles/workspace-sidebar.surface.css +++ b/src/app/renderer/styles/workspace-sidebar.surface.css @@ -44,12 +44,12 @@ .workspace-space-item--active { --workspace-space-item-background: color-mix( in srgb, - var(--workspace-space-accent) 82%, - var(--cove-field) 18% + var(--workspace-space-accent) 38%, + var(--cove-surface-strong) 62% ); --workspace-space-item-border-color: color-mix( in srgb, - var(--workspace-space-accent) 58%, + var(--workspace-space-accent) 46%, transparent ); background: transparent; @@ -61,8 +61,8 @@ .workspace-space-group[data-cove-label-color] > .workspace-space-item:hover { --workspace-space-item-background: color-mix( in srgb, - var(--workspace-space-accent) 88%, - var(--cove-field) 12% + var(--workspace-space-accent) 46%, + var(--cove-surface-strong) 54% ); background: transparent; } diff --git a/src/contexts/workspace/presentation/renderer/types.ts b/src/contexts/workspace/presentation/renderer/types.ts index 9fd08d4c1..6e0d2c853 100644 --- a/src/contexts/workspace/presentation/renderer/types.ts +++ b/src/contexts/workspace/presentation/renderer/types.ts @@ -2,6 +2,7 @@ import type { Node } from '@xyflow/react' import type { AgentRuntimeStatus } from '@contexts/agent/domain/types' import type { AgentSettings, AgentProvider } from '@contexts/settings/domain/agentSettings' import type { LabelColor, NodeLabelColorOverride } from '@shared/types/labelColor' +import type { ProjectIconId } from '@shared/types/projectIcon' import type { SpaceBoundary } from '@shared/types/spaceBoundary' import type { CanvasImageMimeType, @@ -141,6 +142,7 @@ export interface TerminalNodeData { terminalGeometry?: TerminalPtyGeometry | null terminalProviderHint?: AgentProvider | null labelColorOverride?: NodeLabelColorOverride + sidebarSortOrder?: number title: string titlePinnedByUser?: boolean width: number @@ -166,6 +168,7 @@ export interface TerminalNodeData { export interface WorkspaceState { id: string name: string + iconId?: ProjectIconId | null path: string worktreesRoot: string pullRequestBaseBranchOptions?: string[] @@ -181,6 +184,7 @@ export interface WorkspaceState { export interface PersistedWorkspaceState { id: string name: string + iconId?: ProjectIconId | null path: string worktreesRoot: string pullRequestBaseBranchOptions?: string[] @@ -334,6 +338,7 @@ export interface PersistedTerminalNode { terminalGeometry?: TerminalPtyGeometry | null terminalProviderHint?: AgentProvider | null labelColorOverride?: NodeLabelColorOverride + sidebarSortOrder?: number status: AgentRuntimeStatus | null startedAt: string | null endedAt: string | null diff --git a/src/contexts/workspace/presentation/renderer/utils/nodeTransform.ts b/src/contexts/workspace/presentation/renderer/utils/nodeTransform.ts index a06309810..62d6b1d3d 100644 --- a/src/contexts/workspace/presentation/renderer/utils/nodeTransform.ts +++ b/src/contexts/workspace/presentation/renderer/utils/nodeTransform.ts @@ -161,6 +161,7 @@ export function toRuntimeNodes(workspace: PersistedWorkspaceState): Node const id = record.id const name = record.name + const iconId = normalizeProjectIconId(record.iconId) const path = record.path const worktreesRoot = normalizeOptionalString(record.worktreesRoot) ?? '' const pullRequestBaseBranchOptions = normalizePullRequestBaseBranchOptions( @@ -464,6 +469,7 @@ export function ensurePersistedWorkspace(workspace: unknown): PersistedWorkspace return { id, name, + iconId, path, worktreesRoot, pullRequestBaseBranchOptions, diff --git a/src/contexts/workspace/presentation/renderer/utils/persistence/normalize.ts b/src/contexts/workspace/presentation/renderer/utils/persistence/normalize.ts index a926b48d6..ae018dd8e 100644 --- a/src/contexts/workspace/presentation/renderer/utils/persistence/normalize.ts +++ b/src/contexts/workspace/presentation/renderer/utils/persistence/normalize.ts @@ -295,3 +295,9 @@ export function normalizeWorkspaceSpaceNodeIds(value: unknown): string[] { ), ] } + +export function normalizeOptionalSortOrder(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : undefined +} diff --git a/src/contexts/workspace/presentation/renderer/utils/persistence/toPersistedState.ts b/src/contexts/workspace/presentation/renderer/utils/persistence/toPersistedState.ts index b9e1e706c..b9127fe6b 100644 --- a/src/contexts/workspace/presentation/renderer/utils/persistence/toPersistedState.ts +++ b/src/contexts/workspace/presentation/renderer/utils/persistence/toPersistedState.ts @@ -5,6 +5,7 @@ import { PERSISTED_APP_STATE_FORMAT_VERSION } from './constants' import { normalizeEnvironmentVariables, normalizeOptionalString, + normalizeOptionalSortOrder, normalizePullRequestBaseBranchOptions, normalizeTerminalGeometry, normalizeWorkspaceSpaceNodeIds, @@ -12,6 +13,7 @@ import { normalizeWorkspaceViewport, } from './normalize' import { normalizeLabelColor, normalizeNodeLabelColorOverride } from '@shared/types/labelColor' +import { normalizeProjectIconId } from '@shared/types/projectIcon' import { normalizeSpaceBoundary } from '@shared/types/spaceBoundary' export function toPersistedState( @@ -25,6 +27,7 @@ export function toPersistedState( workspaces: workspaces.map(workspace => ({ id: workspace.id, name: workspace.name, + iconId: normalizeProjectIconId(workspace.iconId), path: workspace.path, worktreesRoot: normalizeOptionalString(workspace.worktreesRoot) ?? '', pullRequestBaseBranchOptions: normalizePullRequestBaseBranchOptions( @@ -78,6 +81,10 @@ export function toPersistedState( runtimeKind: node.data.runtimeKind, terminalGeometry: normalizeTerminalGeometry(node.data.terminalGeometry), labelColorOverride: normalizeNodeLabelColorOverride(node.data.labelColorOverride), + sidebarSortOrder: + node.data.kind === 'agent' + ? normalizeOptionalSortOrder(node.data.sidebarSortOrder) + : undefined, status: node.data.status, startedAt: node.data.startedAt, endedAt: node.data.endedAt, diff --git a/src/platform/persistence/sqlite/constants.ts b/src/platform/persistence/sqlite/constants.ts index 8376767c2..af3811c63 100644 --- a/src/platform/persistence/sqlite/constants.ts +++ b/src/platform/persistence/sqlite/constants.ts @@ -1,4 +1,4 @@ -export const DB_SCHEMA_VERSION = 10 +export const DB_SCHEMA_VERSION = 11 export const LEGACY_WORKSPACE_STATE_KEY = 'workspace-state-raw' diff --git a/src/platform/persistence/sqlite/migrate.ts b/src/platform/persistence/sqlite/migrate.ts index 3acb14098..f14901f73 100644 --- a/src/platform/persistence/sqlite/migrate.ts +++ b/src/platform/persistence/sqlite/migrate.ts @@ -31,6 +31,7 @@ function createTables(db: Database.Database): void { CREATE TABLE IF NOT EXISTS workspaces ( id TEXT PRIMARY KEY, name TEXT NOT NULL, + icon_id TEXT, path TEXT NOT NULL, worktrees_root TEXT NOT NULL, pull_request_base_branch_options_json TEXT NOT NULL DEFAULT '[]', @@ -60,6 +61,7 @@ function createTables(db: Database.Database): void { terminal_geometry_json TEXT, terminal_provider_hint TEXT, label_color_override TEXT, + sidebar_sort_order INTEGER, status TEXT, started_at TEXT, ended_at TEXT, @@ -264,6 +266,12 @@ function ensureCurrentSchema(db: Database.Database): void { definitionSql: `TEXT NOT NULL DEFAULT '[]'`, }) + ensureTableColumn(db, { + tableName: 'workspaces', + columnName: 'icon_id', + definitionSql: 'TEXT', + }) + ensureTableColumn(db, { tableName: 'workspaces', columnName: 'environment_variables_json', @@ -292,6 +300,12 @@ function ensureCurrentSchema(db: Database.Database): void { definitionSql: 'TEXT', }) + ensureTableColumn(db, { + tableName: 'nodes', + columnName: 'sidebar_sort_order', + definitionSql: 'INTEGER', + }) + ensureTableColumn(db, { tableName: 'nodes', columnName: 'profile_id', diff --git a/src/platform/persistence/sqlite/normalize.ts b/src/platform/persistence/sqlite/normalize.ts index 8d056e08a..30b970b28 100644 --- a/src/platform/persistence/sqlite/normalize.ts +++ b/src/platform/persistence/sqlite/normalize.ts @@ -4,6 +4,7 @@ import { normalizeLabelColor, normalizeNodeLabelColorOverride, } from '../../../shared/types/labelColor' +import { normalizeProjectIconId, type ProjectIconId } from '../../../shared/types/projectIcon' import { normalizeSpaceBoundary, type SpaceBoundary } from '../../../shared/types/spaceBoundary' function isRecord(value: unknown): value is Record { @@ -31,6 +32,14 @@ function normalizeNullableString(value: unknown): string | null { return value === null ? null : typeof value === 'string' ? value : null } +function normalizeOptionalSortOrder(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null + } + + return Math.max(0, Math.floor(value)) +} + function normalizeBoolean(value: unknown, fallback: boolean): boolean { return typeof value === 'boolean' ? value : fallback } @@ -95,6 +104,7 @@ export type NormalizedPersistedNode = { terminalGeometry: NormalizedTerminalGeometry | null terminalProviderHint?: string | null labelColorOverride: NodeLabelColorOverride + sidebarSortOrder: number | null status: string | null startedAt: string | null endedAt: string | null @@ -123,6 +133,7 @@ export type NormalizedPersistedSpace = { export type NormalizedPersistedWorkspace = { id: string name: string + iconId: ProjectIconId | null path: string worktreesRoot: string pullRequestBaseBranchOptions: string[] @@ -300,6 +311,8 @@ export function normalizePersistedAppState(value: unknown): NormalizedPersistedA const sessionIdRaw = typeof node.sessionId === 'string' ? node.sessionId.trim() : '' const sessionId = sessionIdRaw.length > 0 ? sessionIdRaw : null + const kind = normalizeString(node.kind, 'terminal') + normalizedNodes.push({ id: nodeId, sessionId, @@ -308,12 +321,14 @@ export function normalizePersistedAppState(value: unknown): NormalizedPersistedA position, width: normalizeFiniteNumber(node.width, 0), height: normalizeFiniteNumber(node.height, 0), - kind: normalizeString(node.kind, 'terminal'), + kind, profileId: normalizeOptionalString(node.profileId), runtimeKind: normalizeOptionalString(node.runtimeKind), terminalGeometry: normalizeTerminalGeometry(node.terminalGeometry), terminalProviderHint: normalizeOptionalString(node.terminalProviderHint), labelColorOverride: normalizeNodeLabelColorOverride(node.labelColorOverride), + sidebarSortOrder: + kind === 'agent' ? normalizeOptionalSortOrder(node.sidebarSortOrder) : null, status: typeof node.status === 'string' ? node.status : null, startedAt: typeof node.startedAt === 'string' ? node.startedAt : null, endedAt: typeof node.endedAt === 'string' ? node.endedAt : null, @@ -363,6 +378,7 @@ export function normalizePersistedAppState(value: unknown): NormalizedPersistedA normalizedWorkspaces.push({ id, name: normalizeString(workspace.name), + iconId: normalizeProjectIconId(workspace.iconId), path: normalizeString(workspace.path), worktreesRoot: normalizeString(workspace.worktreesRoot), pullRequestBaseBranchOptions: normalizeStringArray(workspace.pullRequestBaseBranchOptions), diff --git a/src/platform/persistence/sqlite/read.ts b/src/platform/persistence/sqlite/read.ts index 67a541cde..9a3728a9a 100644 --- a/src/platform/persistence/sqlite/read.ts +++ b/src/platform/persistence/sqlite/read.ts @@ -5,6 +5,7 @@ import { normalizeLabelColor, normalizeNodeLabelColorOverride, } from '../../../shared/types/labelColor' +import { normalizeProjectIconId } from '../../../shared/types/projectIcon' import { normalizeSpaceBoundary } from '../../../shared/types/spaceBoundary' import { normalizeTerminalGeometry } from './normalize' import { @@ -139,6 +140,12 @@ export function readAppStateFromDb(db: BetterSQLite3Database): NormalizedPersist ? node.terminalProviderHint : null, labelColorOverride: normalizeNodeLabelColorOverride(node.labelColorOverride), + sidebarSortOrder: + node.kind === 'agent' && + typeof node.sidebarSortOrder === 'number' && + Number.isFinite(node.sidebarSortOrder) + ? Math.max(0, Math.floor(node.sidebarSortOrder)) + : null, status: node.status, startedAt: node.startedAt, endedAt: node.endedAt, @@ -197,6 +204,7 @@ export function readAppStateFromDb(db: BetterSQLite3Database): NormalizedPersist return { id: workspace.id, name: workspace.name, + iconId: normalizeProjectIconId(workspace.iconId), path: workspace.path, worktreesRoot: workspace.worktreesRoot, pullRequestBaseBranchOptions: normalizeStringArray( diff --git a/src/platform/persistence/sqlite/schema.ts b/src/platform/persistence/sqlite/schema.ts index 2ef1dae1d..269c24769 100644 --- a/src/platform/persistence/sqlite/schema.ts +++ b/src/platform/persistence/sqlite/schema.ts @@ -23,6 +23,7 @@ export const appSettings = sqliteTable('app_settings', { export const workspaces = sqliteTable('workspaces', { id: text('id').primaryKey(), name: text('name').notNull(), + iconId: text('icon_id'), path: text('path').notNull(), worktreesRoot: text('worktrees_root').notNull(), pullRequestBaseBranchOptionsJson: text('pull_request_base_branch_options_json').notNull(), @@ -52,6 +53,7 @@ export const nodes = sqliteTable('nodes', { terminalGeometryJson: text('terminal_geometry_json'), terminalProviderHint: text('terminal_provider_hint'), labelColorOverride: text('label_color_override'), + sidebarSortOrder: integer('sidebar_sort_order'), status: text('status'), startedAt: text('started_at'), endedAt: text('ended_at'), diff --git a/src/platform/persistence/sqlite/write.ts b/src/platform/persistence/sqlite/write.ts index ac6e446e1..ff03ab633 100644 --- a/src/platform/persistence/sqlite/write.ts +++ b/src/platform/persistence/sqlite/write.ts @@ -35,12 +35,12 @@ export function writeNormalizedAppState( const insertWorkspace = db.prepare( ` INSERT INTO workspaces ( - id, name, path, worktrees_root, pull_request_base_branch_options_json, environment_variables_json, + id, name, icon_id, path, worktrees_root, pull_request_base_branch_options_json, environment_variables_json, space_archive_records_json, viewport_x, viewport_y, viewport_zoom, is_minimap_visible, active_space_id, sort_order ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) @@ -50,10 +50,10 @@ export function writeNormalizedAppState( id, workspace_id, session_id, title, title_pinned_by_user, position_x, position_y, width, height, kind, profile_id, runtime_kind, terminal_geometry_json, terminal_provider_hint, label_color_override, - status, started_at, ended_at, exit_code, last_error, + sidebar_sort_order, status, started_at, ended_at, exit_code, last_error, execution_directory, expected_directory, agent_json, task_json ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) @@ -104,6 +104,7 @@ export function writeNormalizedAppState( insertWorkspace.run( workspace.id, workspace.name, + workspace.iconId, workspace.path, workspace.worktreesRoot, safeJsonStringify(workspace.pullRequestBaseBranchOptions), @@ -134,6 +135,7 @@ export function writeNormalizedAppState( node.terminalGeometry ? safeJsonStringify(node.terminalGeometry) : null, node.terminalProviderHint ?? null, node.labelColorOverride, + node.sidebarSortOrder, node.status, node.startedAt, node.endedAt, diff --git a/src/shared/sync/mergePersistedAppStates.ts b/src/shared/sync/mergePersistedAppStates.ts index 9cc804e70..a3635f20b 100644 --- a/src/shared/sync/mergePersistedAppStates.ts +++ b/src/shared/sync/mergePersistedAppStates.ts @@ -98,6 +98,20 @@ function mergeNodes( return left.x === right.x && left.y === right.y } + function withSidebarSortOrder( + node: PersistedTerminalNode, + sidebarSortOrder: number | null, + ): PersistedTerminalNode { + const next = { ...node } + if (sidebarSortOrder === null) { + delete next.sidebarSortOrder + return next + } + + next.sidebarSortOrder = sidebarSortOrder + return next + } + for (const baseNode of baseNodes) { if (deletedNodeIds.has(baseNode.id)) { seen.add(baseNode.id) @@ -152,9 +166,15 @@ function mergeNodes( linkedAgentNodeId: mergedLinkedAgentNodeId, } })() + const sidebarSortOrder = mergeSnapshotField( + baseNode.sidebarSortOrder ?? null, + localNode.sidebarSortOrder ?? null, + snapshotNode.sidebarSortOrder ?? null, + (left, right) => left === right, + ) merged.push({ - ...localNode, + ...withSidebarSortOrder(localNode, sidebarSortOrder), position: mergeSnapshotField( baseNode.position, localNode.position, @@ -419,6 +439,12 @@ function mergeWorkspaces( return { ...base, ...local, + iconId: mergeSnapshotField( + base.iconId ?? null, + local.iconId ?? null, + baseSnapshotWorkspace ? (baseSnapshotWorkspace.iconId ?? null) : undefined, + (left, right) => left === right, + ), nodes, spaces: mergeSpaces({ baseSpaces: base.spaces, diff --git a/src/shared/types/projectIcon.ts b/src/shared/types/projectIcon.ts new file mode 100644 index 000000000..3a70b3718 --- /dev/null +++ b/src/shared/types/projectIcon.ts @@ -0,0 +1,20 @@ +export const PROJECT_ICON_IDS = [ + 'code', + 'terminal', + 'database', + 'globe', + 'package', + 'bot', + 'briefcase', + 'book-open', +] as const + +export type ProjectIconId = (typeof PROJECT_ICON_IDS)[number] + +export function isProjectIconId(value: unknown): value is ProjectIconId { + return PROJECT_ICON_IDS.includes(value as ProjectIconId) +} + +export function normalizeProjectIconId(value: unknown): ProjectIconId | null { + return isProjectIconId(value) ? value : null +} diff --git a/tests/contract/platform/persistenceInstalledUpgradeSupport.ts b/tests/contract/platform/persistenceInstalledUpgradeSupport.ts index 1b1bd70cc..4721764eb 100644 --- a/tests/contract/platform/persistenceInstalledUpgradeSupport.ts +++ b/tests/contract/platform/persistenceInstalledUpgradeSupport.ts @@ -2,7 +2,7 @@ import { expect } from 'vitest' import { DB_SCHEMA_VERSION } from '../../../src/platform/persistence/sqlite/constants' import { CURRENT_SCHEMA_COLUMNS } from './persistenceSchemaColumns' -export const SUPPORTED_INSTALLED_UPGRADE_SOURCE_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const +export const SUPPORTED_INSTALLED_UPGRADE_SOURCE_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const export type InstalledUpgradeSourceVersion = (typeof SUPPORTED_INSTALLED_UPGRADE_SOURCE_VERSIONS)[number] @@ -45,6 +45,7 @@ type SchemaPreset = 'legacy-kv' | 'v2-core' | 'v5-mounted' | 'v8-installed' const COLUMNS_ADDED_AFTER_VERSION: Record> = { 2: { workspaces: [ + 'icon_id', 'pull_request_base_branch_options_json', 'environment_variables_json', 'space_archive_records_json', @@ -57,36 +58,79 @@ const COLUMNS_ADDED_AFTER_VERSION: Record> = { 'terminal_geometry_json', 'terminal_provider_hint', 'label_color_override', + 'sidebar_sort_order', ], workspace_spaces: ['target_mount_id', 'parent_space_id', 'boundary_json', 'sort_order'], }, 3: { - workspaces: ['environment_variables_json', 'space_archive_records_json', 'sort_order'], - nodes: ['profile_id', 'runtime_kind', 'terminal_geometry_json', 'terminal_provider_hint'], + workspaces: [ + 'icon_id', + 'environment_variables_json', + 'space_archive_records_json', + 'sort_order', + ], + nodes: [ + 'profile_id', + 'runtime_kind', + 'terminal_geometry_json', + 'terminal_provider_hint', + 'sidebar_sort_order', + ], workspace_spaces: ['target_mount_id', 'parent_space_id', 'boundary_json', 'sort_order'], }, 4: { - workspaces: ['environment_variables_json', 'space_archive_records_json', 'sort_order'], - nodes: ['terminal_geometry_json', 'terminal_provider_hint'], + workspaces: [ + 'icon_id', + 'environment_variables_json', + 'space_archive_records_json', + 'sort_order', + ], + nodes: ['terminal_geometry_json', 'terminal_provider_hint', 'sidebar_sort_order'], workspace_spaces: ['target_mount_id', 'parent_space_id', 'boundary_json', 'sort_order'], }, 5: { - workspaces: ['environment_variables_json', 'space_archive_records_json', 'sort_order'], - nodes: ['terminal_geometry_json', 'terminal_provider_hint'], + workspaces: [ + 'icon_id', + 'environment_variables_json', + 'space_archive_records_json', + 'sort_order', + ], + nodes: ['terminal_geometry_json', 'terminal_provider_hint', 'sidebar_sort_order'], workspace_spaces: ['parent_space_id', 'boundary_json', 'sort_order'], }, 6: { - workspaces: ['environment_variables_json', 'space_archive_records_json', 'sort_order'], + workspaces: [ + 'icon_id', + 'environment_variables_json', + 'space_archive_records_json', + 'sort_order', + ], + nodes: ['sidebar_sort_order'], workspace_spaces: ['parent_space_id', 'boundary_json', 'sort_order'], }, 7: { - workspaces: ['environment_variables_json', 'space_archive_records_json', 'sort_order'], + workspaces: [ + 'icon_id', + 'environment_variables_json', + 'space_archive_records_json', + 'sort_order', + ], + nodes: ['sidebar_sort_order'], workspace_spaces: ['parent_space_id', 'boundary_json', 'sort_order'], }, 8: { - workspaces: ['environment_variables_json'], + workspaces: ['icon_id', 'environment_variables_json'], + nodes: ['sidebar_sort_order'], workspace_spaces: ['parent_space_id', 'boundary_json', 'sort_order'], }, + 9: { + workspaces: ['icon_id'], + nodes: ['sidebar_sort_order'], + }, + 10: { + workspaces: ['icon_id'], + nodes: ['sidebar_sort_order'], + }, } function currentTablesWithout(omissions: Record): Map { diff --git a/tests/contract/platform/persistenceRead.spec.ts b/tests/contract/platform/persistenceRead.spec.ts index ac2cccc22..3239814e3 100644 --- a/tests/contract/platform/persistenceRead.spec.ts +++ b/tests/contract/platform/persistenceRead.spec.ts @@ -15,9 +15,11 @@ type SettingsRow = { id: number; value: string } type WorkspaceRow = { id: string name: string + iconId?: string | null path: string worktreesRoot: string pullRequestBaseBranchOptionsJson: string + environmentVariablesJson?: string spaceArchiveRecordsJson: string viewportX: number viewportY: number @@ -26,11 +28,39 @@ type WorkspaceRow = { activeSpaceId: string | null sortOrder: number } +type NodeRow = { + id: string + workspaceId: string + sessionId: string | null + title: string + titlePinnedByUser: number + positionX: number + positionY: number + width: number + height: number + kind: string + profileId: string | null + runtimeKind: string | null + terminalGeometryJson: string | null + terminalProviderHint: string | null + labelColorOverride: string | null + sidebarSortOrder?: number | null + status: string | null + startedAt: string | null + endedAt: string | null + exitCode: number | null + lastError: string | null + executionDirectory: string | null + expectedDirectory: string | null + agentJson: string | null + taskJson: string | null +} function createReadDb(options: { metaRows: MetaRow[] settingsRow: SettingsRow | undefined workspaceRows: WorkspaceRow[] + nodeRows?: NodeRow[] }): BetterSQLite3Database { const db = { select(selection?: unknown) { @@ -72,7 +102,13 @@ function createReadDb(options: { } } - if (table === nodes || table === spaces || table === spaceNodes) { + if (table === nodes) { + return { + all: () => options.nodeRows ?? [], + } + } + + if (table === spaces || table === spaceNodes) { return { all: () => [], } @@ -134,4 +170,66 @@ describe('sqlite persistence read', () => { 'workspace-2', ]) }) + + it('loads project icons and agent sidebar order fields', () => { + const db = createReadDb({ + metaRows: [ + { key: 'format_version', value: '1' }, + { key: 'active_workspace_id', value: 'workspace-1' }, + ], + settingsRow: { id: 1, value: '{}' }, + workspaceRows: [ + { + id: 'workspace-1', + name: 'Workspace 1', + iconId: 'code', + path: '/tmp/workspace-1', + worktreesRoot: '/tmp/worktrees', + pullRequestBaseBranchOptionsJson: '[]', + environmentVariablesJson: '{}', + spaceArchiveRecordsJson: '[]', + viewportX: 0, + viewportY: 0, + viewportZoom: 1, + isMinimapVisible: true, + activeSpaceId: null, + sortOrder: 1, + }, + ], + nodeRows: [ + { + id: 'agent-1', + workspaceId: 'workspace-1', + sessionId: 'session-1', + title: 'Agent 1', + titlePinnedByUser: 0, + positionX: 0, + positionY: 0, + width: 640, + height: 420, + kind: 'agent', + profileId: null, + runtimeKind: null, + terminalGeometryJson: null, + terminalProviderHint: null, + labelColorOverride: null, + sidebarSortOrder: 7, + status: 'running', + startedAt: '2026-01-01T00:00:00.000Z', + endedAt: null, + exitCode: null, + lastError: null, + executionDirectory: '/tmp/workspace-1', + expectedDirectory: '/tmp/workspace-1', + agentJson: '{"provider":"codex"}', + taskJson: null, + }, + ], + }) + + const appState = readAppStateFromDb(db) + + expect(appState?.workspaces[0]?.iconId).toBe('code') + expect(appState?.workspaces[0]?.nodes[0]?.sidebarSortOrder).toBe(7) + }) }) diff --git a/tests/contract/platform/persistenceSchemaColumns.ts b/tests/contract/platform/persistenceSchemaColumns.ts index 4ba883284..a975706e3 100644 --- a/tests/contract/platform/persistenceSchemaColumns.ts +++ b/tests/contract/platform/persistenceSchemaColumns.ts @@ -4,6 +4,7 @@ export const CURRENT_SCHEMA_COLUMNS = { workspaces: [ 'id', 'name', + 'icon_id', 'path', 'worktrees_root', 'pull_request_base_branch_options_json', @@ -32,6 +33,7 @@ export const CURRENT_SCHEMA_COLUMNS = { 'terminal_geometry_json', 'terminal_provider_hint', 'label_color_override', + 'sidebar_sort_order', 'status', 'started_at', 'ended_at', diff --git a/tests/contract/platform/persistenceStore.migrations.spec.ts b/tests/contract/platform/persistenceStore.migrations.spec.ts index 93ac2e1e9..9fd5523b2 100644 --- a/tests/contract/platform/persistenceStore.migrations.spec.ts +++ b/tests/contract/platform/persistenceStore.migrations.spec.ts @@ -19,9 +19,11 @@ const CURRENT_SCHEMA_COLUMNS = { workspaces: [ 'id', 'name', + 'icon_id', 'path', 'worktrees_root', 'pull_request_base_branch_options_json', + 'environment_variables_json', 'space_archive_records_json', 'viewport_x', 'viewport_y', @@ -43,8 +45,10 @@ const CURRENT_SCHEMA_COLUMNS = { 'kind', 'profile_id', 'runtime_kind', + 'terminal_geometry_json', 'terminal_provider_hint', 'label_color_override', + 'sidebar_sort_order', 'status', 'started_at', 'ended_at', @@ -419,7 +423,7 @@ describe('PersistenceStore (migrations)', () => { store.dispose() const migratedState = mockDbByPath.get(dbPath) - expect(migratedState?.userVersion).toBe(10) + expect(migratedState?.userVersion).toBe(11) expect(migratedState?.tables.get('nodes')).toContain('label_color_override') expect(migratedState?.tables.get('nodes')).toContain('session_id') expect(migratedState?.tables.get('nodes')).toContain('profile_id') diff --git a/tests/contract/platform/persistenceStore.spec.ts b/tests/contract/platform/persistenceStore.spec.ts index 8e4a8745f..0e64f6b52 100644 --- a/tests/contract/platform/persistenceStore.spec.ts +++ b/tests/contract/platform/persistenceStore.spec.ts @@ -83,6 +83,78 @@ describe('PersistenceStore', () => { PERSISTENCE_STORE_TEST_TIMEOUT_MS, ) + it( + 'persists project icons and agent sidebar sort order through sqlite columns', + 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-1', + workspaces: [ + { + id: 'ws-1', + name: 'Workspace 1', + iconId: 'code', + path: '/tmp/ws-1', + worktreesRoot: '/tmp', + pullRequestBaseBranchOptions: [], + environmentVariables: {}, + spaceArchiveRecords: [], + viewport: { x: 0, y: 0, zoom: 1 }, + isMinimapVisible: false, + activeSpaceId: null, + nodes: [ + { + id: 'agent-1', + sessionId: 'session-1', + title: 'Agent 1', + titlePinnedByUser: false, + position: { x: 0, y: 0 }, + width: 640, + height: 420, + kind: 'agent', + profileId: null, + runtimeKind: null, + terminalGeometry: null, + terminalProviderHint: null, + labelColorOverride: null, + sidebarSortOrder: 3, + status: 'running', + startedAt: '2026-01-01T00:00:00.000Z', + endedAt: null, + exitCode: null, + lastError: null, + executionDirectory: '/tmp/ws-1', + expectedDirectory: '/tmp/ws-1', + agent: { provider: 'codex' }, + task: null, + scrollback: null, + }, + ], + spaces: [], + }, + ], + settings: {}, + }) + + expect(result).toMatchObject({ ok: true, level: 'full' }) + expect(mockDbByPath.get(dbPath)?.workspaceIconRows).toEqual([{ id: 'ws-1', iconId: 'code' }]) + expect(mockDbByPath.get(dbPath)?.nodeRows).toEqual([{ id: 'agent-1', sidebarSortOrder: 3 }]) + + 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 index fd9c531d5..7f3088410 100644 --- a/tests/contract/platform/persistenceStoreSortOrderMigration.spec.ts +++ b/tests/contract/platform/persistenceStoreSortOrderMigration.spec.ts @@ -19,6 +19,7 @@ const CURRENT_SCHEMA_COLUMNS = { workspaces: [ 'id', 'name', + 'icon_id', 'path', 'worktrees_root', 'pull_request_base_branch_options_json', @@ -32,7 +33,7 @@ const CURRENT_SCHEMA_COLUMNS = { 'sort_order', ], // prettier-ignore - nodes: ['id', 'workspace_id', 'session_id', 'title', 'title_pinned_by_user', 'position_x', 'position_y', 'width', 'height', 'kind', 'profile_id', 'runtime_kind', 'terminal_provider_hint', 'label_color_override', 'status', 'started_at', 'ended_at', 'exit_code', 'last_error', 'execution_directory', 'expected_directory', 'agent_json', 'task_json'], + nodes: ['id', 'workspace_id', 'session_id', 'title', 'title_pinned_by_user', 'position_x', 'position_y', 'width', 'height', 'kind', 'profile_id', 'runtime_kind', 'terminal_geometry_json', 'terminal_provider_hint', 'label_color_override', 'sidebar_sort_order', 'status', 'started_at', 'ended_at', 'exit_code', 'last_error', 'execution_directory', 'expected_directory', 'agent_json', 'task_json'], // prettier-ignore workspace_spaces: ['id', 'workspace_id', 'name', 'directory_path', 'target_mount_id', 'parent_space_id', 'boundary_json', 'sort_order', 'label_color', 'rect_x', 'rect_y', 'rect_width', 'rect_height'], workspace_space_nodes: ['space_id', 'node_id', 'sort_order'], @@ -471,7 +472,7 @@ describe('PersistenceStore sort order migration', () => { expect(store.consumeRecovery()).toBeNull() store.dispose() - expect(mockDbByPath.get(dbPath)?.userVersion).toBe(10) + expect(mockDbByPath.get(dbPath)?.userVersion).toBe(11) expect(mockDbByPath.get(dbPath)?.workspaceRows).toEqual([ { id: 'ws-2', sortOrder: 0 }, { id: 'ws-4', sortOrder: 1 }, diff --git a/tests/contract/platform/persistenceStoreTestSupport.ts b/tests/contract/platform/persistenceStoreTestSupport.ts index 0795c9d2c..ddfb9d5c8 100644 --- a/tests/contract/platform/persistenceStoreTestSupport.ts +++ b/tests/contract/platform/persistenceStoreTestSupport.ts @@ -15,6 +15,8 @@ export type MockDbState = { tables: Map openAttempts: number workspaceRows: Array<{ id: string; sortOrder: number }> + workspaceIconRows: Array<{ id: string; iconId: string | null }> + nodeRows: Array<{ id: string; sidebarSortOrder: number | null }> currentState: unknown | null failOnFirstOpen?: boolean } @@ -91,6 +93,8 @@ export function createMockDbState( tables: options.version2Schema ? createVersion2Tables() : new Map(), openAttempts: 0, workspaceRows: [], + workspaceIconRows: [], + nodeRows: [], currentState: options.currentState ?? null, ...(options.failOnFirstOpen ? { failOnFirstOpen: true } : {}), } @@ -214,6 +218,7 @@ export function createMockDatabaseModule(mockDbByPath: Map) return { id: typeof candidate.id === 'string' ? candidate.id : `workspace-${index}`, name: typeof candidate.name === 'string' ? candidate.name : '', + iconId: typeof candidate.iconId === 'string' ? candidate.iconId : null, path: typeof candidate.path === 'string' ? candidate.path : '', worktreesRoot: typeof candidate.worktreesRoot === 'string' ? candidate.worktreesRoot : '', @@ -344,6 +349,31 @@ export function createMockDatabaseModule(mockDbByPath: Map) } } + if (tableName === 'nodes') { + const idIndex = columns.indexOf('id') + if (idIndex < 0) { + throw new Error('node insert missing id column') + } + + const id = params[idIndex] + if (typeof id !== 'string') { + throw new Error('node insert missing id value') + } + + const sidebarSortOrderIndex = columns.indexOf('sidebar_sort_order') + const sidebarSortOrderParam = + sidebarSortOrderIndex >= 0 ? params[sidebarSortOrderIndex] : null + if (sidebarSortOrderParam !== null && typeof sidebarSortOrderParam !== 'number') { + throw new Error('node insert sidebar_sort_order must be numeric or null') + } + + this.state.nodeRows.push({ + id, + sidebarSortOrder: sidebarSortOrderParam, + }) + return + } + if (tableName !== 'workspaces') { return } @@ -365,6 +395,14 @@ export function createMockDatabaseModule(mockDbByPath: Map) } this.state.workspaceRows.push({ id, sortOrder: sortOrderParam }) + + const iconIdIndex = columns.indexOf('icon_id') + const iconIdParam = iconIdIndex >= 0 ? params[iconIdIndex] : null + if (iconIdParam !== null && typeof iconIdParam !== 'string') { + throw new Error('workspace insert icon_id must be text or null') + } + + this.state.workspaceIconRows.push({ id, iconId: iconIdParam }) }, } } diff --git a/tests/e2e/workspace-canvas.helpers.ts b/tests/e2e/workspace-canvas.helpers.ts index acf3c7d7c..ba5176f97 100644 --- a/tests/e2e/workspace-canvas.helpers.ts +++ b/tests/e2e/workspace-canvas.helpers.ts @@ -85,6 +85,7 @@ export interface SeedNode { lastError?: string | null scrollback?: string | null labelColorOverride?: string | null + sidebarSortOrder?: number executionDirectory?: string | null expectedDirectory?: string | null agent?: SeedAgentData | null @@ -94,6 +95,7 @@ export interface SeedNode { export interface SeedWorkspace { id: string name: string + iconId?: string | null path: string nodes: SeedNode[] spaces?: Array<{ diff --git a/tests/e2e/workspace-canvas.sidebar-drag-boundaries.spec.ts b/tests/e2e/workspace-canvas.sidebar-drag-boundaries.spec.ts new file mode 100644 index 000000000..e30671208 --- /dev/null +++ b/tests/e2e/workspace-canvas.sidebar-drag-boundaries.spec.ts @@ -0,0 +1,339 @@ +import { expect, test, type Locator, type Page } from '@playwright/test' +import { + dragMouse, + launchApp, + readLocatorClientRect, + seedWorkspaceState, + testWorkspacePath, +} from './workspace-canvas.helpers' + +function createAgentNode(id: string, title: string, sidebarSortOrder: number) { + return { + id, + title: `codex · ${title}`, + position: { x: 120 + sidebarSortOrder * 40, y: 120 + sidebarSortOrder * 40 }, + width: 520, + height: 320, + kind: 'agent' as const, + status: 'running' as const, + startedAt: `2026-02-09T0${sidebarSortOrder + 1}:00:00.000Z`, + endedAt: null, + exitCode: null, + lastError: null, + sidebarSortOrder, + agent: { + provider: 'codex' as const, + prompt: title, + model: 'gpt-5.2-codex', + effectiveModel: 'gpt-5.2-codex', + launchMode: 'new' as const, + resumeSessionId: null, + executionDirectory: testWorkspacePath, + expectedDirectory: testWorkspacePath, + directoryMode: 'workspace' as const, + customDirectory: null, + shouldCreateDirectory: false, + }, + } +} + +async function dragBelowActivationDistance(window: Page, source: Locator): Promise { + const rect = await readLocatorClientRect(source) + const start = { + x: rect.x + Math.min(rect.width / 2, 40), + y: rect.y + rect.height / 2, + } + + await dragMouse(window, { + start, + end: { x: start.x, y: start.y + 6 }, + steps: 3, + settleAfterPressMs: 20, + settleBeforeReleaseMs: 20, + settleAfterReleaseMs: 160, + }) +} + +async function dragMeasuredTo(window: Page, source: Locator, target: Locator): Promise { + const sourceRect = await readLocatorClientRect(source) + const targetRect = await readLocatorClientRect(target) + + await dragMouse(window, { + start: { + x: sourceRect.x + Math.min(sourceRect.width / 2, 40), + y: sourceRect.y + sourceRect.height / 2, + }, + end: { + x: targetRect.x + Math.min(targetRect.width / 2, 40), + y: targetRect.y + targetRect.height / 2, + }, + steps: 18, + settleAfterPressMs: 48, + settleBeforeReleaseMs: 80, + settleAfterReleaseMs: 180, + }) +} + +async function readTestIds(window: Page, selector: string): Promise { + return await window + .locator(selector) + .evaluateAll(elements => + elements + .map(element => element.getAttribute('data-testid') ?? '') + .filter(testId => testId.length > 0), + ) +} + +test.describe('Workspace Canvas - Sidebar Drag Boundaries', () => { + test('keeps project, space, and agent order stable below the activation distance', async () => { + const { electronApp, window } = await launchApp() + + try { + await seedWorkspaceState(window, { + activeWorkspaceId: 'workspace-threshold-a', + workspaces: [ + { + id: 'workspace-threshold-a', + name: 'Threshold Alpha', + path: testWorkspacePath, + nodes: [ + createAgentNode('agent-threshold-a', 'Agent Threshold Alpha', 0), + createAgentNode('agent-threshold-b', 'Agent Threshold Beta', 1), + ], + spaces: [ + { + id: 'space-threshold-a', + name: 'Space Threshold Alpha', + directoryPath: testWorkspacePath, + sortOrder: 0, + labelColor: 'blue', + nodeIds: [], + }, + { + id: 'space-threshold-b', + name: 'Space Threshold Beta', + directoryPath: testWorkspacePath, + sortOrder: 1, + labelColor: 'green', + nodeIds: [], + }, + ], + }, + { + id: 'workspace-threshold-b', + name: 'Threshold Beta', + path: `${testWorkspacePath}-b`, + nodes: [], + }, + { + id: 'workspace-threshold-c', + name: 'Threshold Gamma', + path: `${testWorkspacePath}-c`, + nodes: [], + }, + ], + }) + + const workspaceNames = window.locator('.workspace-item__name') + const agentTitles = window.locator('.workspace-agent-item__title') + const spaceSelector = + '[data-testid^="workspace-space-item-workspace-threshold-a-space-threshold"]' + + await expect(workspaceNames).toHaveText([ + 'Threshold Alpha', + 'Threshold Beta', + 'Threshold Gamma', + ]) + await expect(agentTitles).toHaveText(['Agent Threshold Alpha', 'Agent Threshold Beta']) + expect(await readTestIds(window, spaceSelector)).toEqual([ + 'workspace-space-item-workspace-threshold-a-space-threshold-a', + 'workspace-space-item-workspace-threshold-a-space-threshold-b', + ]) + + await dragBelowActivationDistance( + window, + window.locator('[data-testid="workspace-item-workspace-threshold-b"]'), + ) + await dragBelowActivationDistance( + window, + window.locator( + '[data-testid="workspace-space-item-workspace-threshold-a-space-threshold-a"]', + ), + ) + await dragBelowActivationDistance( + window, + window.locator( + '[data-testid="workspace-agent-item-workspace-threshold-a-agent-threshold-a"]', + ), + ) + + await window.waitForTimeout(250) + await expect(workspaceNames).toHaveText([ + 'Threshold Alpha', + 'Threshold Beta', + 'Threshold Gamma', + ]) + await expect(agentTitles).toHaveText(['Agent Threshold Alpha', 'Agent Threshold Beta']) + expect(await readTestIds(window, spaceSelector)).toEqual([ + 'workspace-space-item-workspace-threshold-a-space-threshold-a', + 'workspace-space-item-workspace-threshold-a-space-threshold-b', + ]) + } finally { + await electronApp.close() + } + }) + + test('keeps root space order stable when dragging across project boundaries', async () => { + const { electronApp, window } = await launchApp() + + try { + await seedWorkspaceState(window, { + activeWorkspaceId: 'workspace-space-boundary-a', + workspaces: [ + { + id: 'workspace-space-boundary-a', + name: 'Space Boundary A', + path: testWorkspacePath, + nodes: [], + spaces: [ + { + id: 'space-boundary-a1', + name: 'Space Boundary A1', + directoryPath: testWorkspacePath, + sortOrder: 0, + labelColor: 'blue', + nodeIds: [], + }, + { + id: 'space-boundary-a2', + name: 'Space Boundary A2', + directoryPath: testWorkspacePath, + sortOrder: 1, + labelColor: 'green', + nodeIds: [], + }, + ], + }, + { + id: 'workspace-space-boundary-b', + name: 'Space Boundary B', + path: `${testWorkspacePath}-b`, + nodes: [], + spaces: [ + { + id: 'space-boundary-b1', + name: 'Space Boundary B1', + directoryPath: `${testWorkspacePath}-b`, + sortOrder: 0, + labelColor: 'purple', + nodeIds: [], + }, + { + id: 'space-boundary-b2', + name: 'Space Boundary B2', + directoryPath: `${testWorkspacePath}-b`, + sortOrder: 1, + labelColor: 'yellow', + nodeIds: [], + }, + ], + }, + ], + }) + + const projectASelector = + '[data-testid^="workspace-space-item-workspace-space-boundary-a-space-boundary-a"]' + const projectBSelector = + '[data-testid^="workspace-space-item-workspace-space-boundary-b-space-boundary-b"]' + + await dragMeasuredTo( + window, + window.locator( + '[data-testid="workspace-space-item-workspace-space-boundary-a-space-boundary-a1"]', + ), + window.locator( + '[data-testid="workspace-space-item-workspace-space-boundary-b-space-boundary-b2"]', + ), + ) + + expect(await readTestIds(window, projectASelector)).toEqual([ + 'workspace-space-item-workspace-space-boundary-a-space-boundary-a1', + 'workspace-space-item-workspace-space-boundary-a-space-boundary-a2', + ]) + expect(await readTestIds(window, projectBSelector)).toEqual([ + 'workspace-space-item-workspace-space-boundary-b-space-boundary-b1', + 'workspace-space-item-workspace-space-boundary-b-space-boundary-b2', + ]) + } finally { + await electronApp.close() + } + }) + + test('keeps agent order stable when dragging across space groups', async () => { + const { electronApp, window } = await launchApp() + + try { + await seedWorkspaceState(window, { + activeWorkspaceId: 'workspace-agent-boundary', + workspaces: [ + { + id: 'workspace-agent-boundary', + name: 'Agent Boundary', + path: testWorkspacePath, + nodes: [ + createAgentNode('agent-boundary-a1', 'Agent Boundary A1', 0), + createAgentNode('agent-boundary-a2', 'Agent Boundary A2', 1), + createAgentNode('agent-boundary-b1', 'Agent Boundary B1', 2), + createAgentNode('agent-boundary-b2', 'Agent Boundary B2', 3), + ], + spaces: [ + { + id: 'space-agent-boundary-a', + name: 'Agent Space A', + directoryPath: testWorkspacePath, + sortOrder: 0, + labelColor: 'blue', + nodeIds: ['agent-boundary-a1', 'agent-boundary-a2'], + }, + { + id: 'space-agent-boundary-b', + name: 'Agent Space B', + directoryPath: testWorkspacePath, + sortOrder: 1, + labelColor: 'green', + nodeIds: ['agent-boundary-b1', 'agent-boundary-b2'], + }, + ], + }, + ], + }) + + const agentTitles = window.locator('.workspace-agent-item__title') + await expect(agentTitles).toHaveText([ + 'Agent Boundary A1', + 'Agent Boundary A2', + 'Agent Boundary B1', + 'Agent Boundary B2', + ]) + + await dragMeasuredTo( + window, + window.locator( + '[data-testid="workspace-agent-item-workspace-agent-boundary-agent-boundary-a1"]', + ), + window.locator( + '[data-testid="workspace-agent-item-workspace-agent-boundary-agent-boundary-b2"]', + ), + ) + + await expect(agentTitles).toHaveText([ + 'Agent Boundary A1', + 'Agent Boundary A2', + 'Agent Boundary B1', + 'Agent Boundary B2', + ]) + } finally { + await electronApp.close() + } + }) +}) diff --git a/tests/e2e/workspace-canvas.sidebar-drag-reorder.spec.ts b/tests/e2e/workspace-canvas.sidebar-drag-reorder.spec.ts index 09a7d075b..6c9db01e3 100644 --- a/tests/e2e/workspace-canvas.sidebar-drag-reorder.spec.ts +++ b/tests/e2e/workspace-canvas.sidebar-drag-reorder.spec.ts @@ -7,7 +7,85 @@ import { testWorkspacePath, } from './workspace-canvas.helpers' +function createAgentNode(id: string, title: string, sidebarSortOrder: number) { + return { + id, + title: `codex · ${title}`, + position: { x: 120 + sidebarSortOrder * 40, y: 120 + sidebarSortOrder * 40 }, + width: 520, + height: 320, + kind: 'agent' as const, + status: 'running' as const, + startedAt: `2026-02-09T0${sidebarSortOrder + 1}:00:00.000Z`, + endedAt: null, + exitCode: null, + lastError: null, + sidebarSortOrder, + agent: { + provider: 'codex' as const, + prompt: title, + model: 'gpt-5.2-codex', + effectiveModel: 'gpt-5.2-codex', + launchMode: 'new' as const, + resumeSessionId: null, + executionDirectory: testWorkspacePath, + expectedDirectory: testWorkspacePath, + directoryMode: 'workspace' as const, + customDirectory: null, + shouldCreateDirectory: false, + }, + } +} + test.describe('Workspace Canvas - Sidebar Drag Reorder', () => { + test('changes project icon from the context menu and persists it', async () => { + const { electronApp, window } = await launchApp() + + try { + await seedWorkspaceState(window, { + activeWorkspaceId: 'workspace-icon-a', + workspaces: [ + { + id: 'workspace-icon-a', + name: 'Icon Alpha', + path: testWorkspacePath, + nodes: [], + }, + ], + }) + + const projectItem = window.locator('[data-testid="workspace-item-workspace-icon-a"]') + await expect(projectItem.locator('.workspace-item__folder-icon')).toHaveAttribute( + 'data-cove-project-icon-id', + 'default', + ) + + await projectItem.click({ button: 'right' }) + await window.locator('[data-testid="workspace-project-context-menu-icon-code"]').click() + + await expect(projectItem.locator('.workspace-item__folder-icon')).toHaveAttribute( + 'data-cove-project-icon-id', + 'code', + ) + await expect + .poll( + async () => { + const raw = await window.evaluate(async () => { + return await window.opencoveApi.persistence.readWorkspaceStateRaw() + }) + const parsed = raw + ? (JSON.parse(raw) as { workspaces?: Array<{ iconId?: string }> }) + : null + return parsed?.workspaces?.[0]?.iconId ?? null + }, + { timeout: 15_000 }, + ) + .toBe('code') + } finally { + await electronApp.close() + } + }) + test('reorders workspaces by dragging and persists the new order', async () => { const { electronApp, window } = await launchApp() @@ -106,6 +184,170 @@ test.describe('Workspace Canvas - Sidebar Drag Reorder', () => { } }) + test('reorders root spaces within a project and persists sort order', async () => { + const { electronApp, window } = await launchApp() + + try { + await seedWorkspaceState(window, { + activeWorkspaceId: 'workspace-space-drag', + workspaces: [ + { + id: 'workspace-space-drag', + name: 'Space Drag Project', + path: testWorkspacePath, + nodes: [], + spaces: [ + { + id: 'space-alpha', + name: 'Space Alpha', + directoryPath: testWorkspacePath, + sortOrder: 0, + labelColor: 'blue', + nodeIds: [], + }, + { + id: 'space-beta', + name: 'Space Beta', + directoryPath: testWorkspacePath, + sortOrder: 1, + labelColor: 'green', + nodeIds: [], + }, + { + id: 'space-gamma', + name: 'Space Gamma', + directoryPath: testWorkspacePath, + sortOrder: 2, + labelColor: 'purple', + nodeIds: [], + }, + ], + }, + ], + }) + + const spaceNames = window.locator('.workspace-space-item__name') + await expect(spaceNames).toHaveCount(3) + await expect(spaceNames.nth(0)).toHaveText('Space Alpha') + + const firstSpace = window.locator('.workspace-space-item').filter({ hasText: 'Space Alpha' }) + const thirdSpace = window.locator('.workspace-space-item').filter({ hasText: 'Space Gamma' }) + const firstRect = await readLocatorClientRect(firstSpace) + const thirdRect = await readLocatorClientRect(thirdSpace) + + await dragMouse(window, { + start: { + x: firstRect.x + Math.min(firstRect.width / 2, 40), + y: firstRect.y + firstRect.height / 2, + }, + end: { + x: thirdRect.x + Math.min(thirdRect.width / 2, 40), + y: thirdRect.y + Math.max(thirdRect.height - 8, thirdRect.height * 0.7), + }, + steps: 18, + settleAfterPressMs: 48, + settleBeforeReleaseMs: 80, + settleAfterReleaseMs: 80, + }) + + await expect + .poll(async () => await spaceNames.allTextContents(), { timeout: 8_000 }) + .toEqual(['Space Beta', 'Space Gamma', 'Space Alpha']) + await expect + .poll( + async () => { + const raw = await window.evaluate(async () => { + return await window.opencoveApi.persistence.readWorkspaceStateRaw() + }) + const parsed = raw + ? (JSON.parse(raw) as { + workspaces?: Array<{ spaces?: Array<{ name?: string; sortOrder?: number }> }> + }) + : null + return [...(parsed?.workspaces?.[0]?.spaces ?? [])] + .sort((left, right) => (left.sortOrder ?? 0) - (right.sortOrder ?? 0)) + .map(space => space.name) + }, + { timeout: 15_000 }, + ) + .toEqual(['Space Beta', 'Space Gamma', 'Space Alpha']) + } finally { + await electronApp.close() + } + }) + + test('reorders agents within the same sidebar group and persists sort order', async () => { + const { electronApp, window } = await launchApp() + + try { + await seedWorkspaceState(window, { + activeWorkspaceId: 'workspace-agent-drag', + workspaces: [ + { + id: 'workspace-agent-drag', + name: 'Agent Drag Project', + path: testWorkspacePath, + nodes: [ + createAgentNode('agent-alpha', 'Agent Alpha', 0), + createAgentNode('agent-beta', 'Agent Beta', 1), + createAgentNode('agent-gamma', 'Agent Gamma', 2), + ], + }, + ], + }) + + const agentTitles = window.locator('.workspace-agent-item__title') + await expect(agentTitles).toHaveCount(3) + await expect(agentTitles.nth(0)).toHaveText('Agent Alpha') + + const firstAgent = window.locator('.workspace-agent-item').filter({ hasText: 'Agent Alpha' }) + const thirdAgent = window.locator('.workspace-agent-item').filter({ hasText: 'Agent Gamma' }) + const firstRect = await readLocatorClientRect(firstAgent) + const thirdRect = await readLocatorClientRect(thirdAgent) + + await dragMouse(window, { + start: { + x: firstRect.x + Math.min(firstRect.width / 2, 40), + y: firstRect.y + firstRect.height / 2, + }, + end: { + x: thirdRect.x + Math.min(thirdRect.width / 2, 40), + y: thirdRect.y + Math.max(thirdRect.height - 8, thirdRect.height * 0.7), + }, + steps: 18, + settleAfterPressMs: 48, + settleBeforeReleaseMs: 80, + settleAfterReleaseMs: 80, + }) + + await expect + .poll(async () => await agentTitles.allTextContents(), { timeout: 8_000 }) + .toEqual(['Agent Beta', 'Agent Gamma', 'Agent Alpha']) + await expect + .poll( + async () => { + const raw = await window.evaluate(async () => { + return await window.opencoveApi.persistence.readWorkspaceStateRaw() + }) + const parsed = raw + ? (JSON.parse(raw) as { + workspaces?: Array<{ + nodes?: Array<{ title?: string; sidebarSortOrder?: number }> + }> + }) + : null + return [...(parsed?.workspaces?.[0]?.nodes ?? [])] + .sort((left, right) => (left.sidebarSortOrder ?? 0) - (right.sidebarSortOrder ?? 0)) + .map(node => node.title?.replace('codex · ', '')) + }, + { timeout: 15_000 }, + ) + .toEqual(['Agent Beta', 'Agent Gamma', 'Agent Alpha']) + } finally { + await electronApp.close() + } + }) + test('click still selects workspace after drag setup', async () => { const { electronApp, window } = await launchApp() diff --git a/tests/unit/app/sessionPrepareOrRevivePreparation.agentRuntime.spec.ts b/tests/unit/app/sessionPrepareOrRevivePreparation.agentRuntime.spec.ts index 20fbd87dc..59bb753b1 100644 --- a/tests/unit/app/sessionPrepareOrRevivePreparation.agentRuntime.spec.ts +++ b/tests/unit/app/sessionPrepareOrRevivePreparation.agentRuntime.spec.ts @@ -41,6 +41,7 @@ function createAgentNode( terminalGeometry: { cols: 80, rows: 24 }, terminalProviderHint: null, labelColorOverride: null, + sidebarSortOrder: null, status: 'running', startedAt: '2026-05-01T00:00:00.000Z', endedAt: null, @@ -59,6 +60,7 @@ function createWorkspace(space: NormalizedPersistedSpace): NormalizedPersistedWo return { id: 'workspace-1', name: 'repo', + iconId: null, path: 'C:\\repo', worktreesRoot: '', pullRequestBaseBranchOptions: [], diff --git a/tests/unit/app/sessionPrepareOrRevivePreparation.geometry.spec.ts b/tests/unit/app/sessionPrepareOrRevivePreparation.geometry.spec.ts index a5f6eb5bb..f270e8635 100644 --- a/tests/unit/app/sessionPrepareOrRevivePreparation.geometry.spec.ts +++ b/tests/unit/app/sessionPrepareOrRevivePreparation.geometry.spec.ts @@ -42,6 +42,7 @@ function createNode(overrides: Partial): NormalizedPers terminalGeometry: null, terminalProviderHint: null, labelColorOverride: null, + sidebarSortOrder: null, status: 'running', startedAt: null, endedAt: null, @@ -62,6 +63,7 @@ function createWorkspace( return { id: 'workspace-1', name: 'repo', + iconId: null, path: '/tmp/workspace', worktreesRoot: '', pullRequestBaseBranchOptions: [], From 9e6dd79cdcd2f5075a7c51330657d1218fb19b64 Mon Sep 17 00:00:00 2001 From: deadwavewave Date: Tue, 7 Jul 2026 18:57:24 +0800 Subject: [PATCH 2/8] fix: balance sidebar item geometry --- src/app/renderer/styles/base.css | 2 +- .../renderer/styles/workspace-agent-item.css | 13 +- src/app/renderer/styles/workspace-sidebar.css | 9 +- .../styles/workspace-sidebar.rail.css | 42 ++- .../styles/workspace-sidebar.surface.css | 6 +- ...eader.primary-sidebar-animation.helpers.ts | 16 +- ...p-header.primary-sidebar-animation.spec.ts | 16 +- ...er.primary-sidebar-hover-animation.spec.ts | 3 +- .../app-header.primary-sidebar-toggle.spec.ts | 30 +- .../workspace-canvas.sidebar-spacing.spec.ts | 262 ++++++++++++++++++ 10 files changed, 363 insertions(+), 36 deletions(-) create mode 100644 tests/e2e/workspace-canvas.sidebar-spacing.spec.ts diff --git a/src/app/renderer/styles/base.css b/src/app/renderer/styles/base.css index 6960cd34e..45a76a665 100644 --- a/src/app/renderer/styles/base.css +++ b/src/app/renderer/styles/base.css @@ -355,7 +355,7 @@ body { .app-shell { --cove-primary-sidebar-expanded-width: 280px; - --cove-primary-sidebar-rail-width: 72px; + --cove-primary-sidebar-rail-width: 52px; --cove-primary-sidebar-width: var(--cove-primary-sidebar-expanded-width); display: grid; grid-template-columns: var(--cove-primary-sidebar-width) minmax(0, 1fr); diff --git a/src/app/renderer/styles/workspace-agent-item.css b/src/app/renderer/styles/workspace-agent-item.css index 2b03c3215..1e2304493 100644 --- a/src/app/renderer/styles/workspace-agent-item.css +++ b/src/app/renderer/styles/workspace-agent-item.css @@ -82,7 +82,8 @@ button.workspace-agent-item:focus-visible { --workspace-agent-item-background: transparent; --workspace-agent-item-border-color: transparent; --workspace-agent-item-surface-shadow: none; - --workspace-agent-item-surface-width: calc(100% + 2px); + --workspace-agent-item-surface-left: 0px; + --workspace-agent-item-surface-width: calc(100% - 2px); --workspace-agent-item-surface-height: calc(100% + 2px); position: relative; flex-direction: row; @@ -90,15 +91,20 @@ button.workspace-agent-item:focus-visible { gap: 0; height: var(--workspace-sidebar-tree-item-size, 32px); min-height: var(--workspace-sidebar-tree-item-size, 32px); - padding: 0 6px; + padding: 0 6px 0 1px; background: transparent; border-color: transparent; + transition: + padding-left 0.22s ease, + background-color 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; } .workspace-agent-item--sidebar::before { content: ''; position: absolute; - left: -1px; + left: var(--workspace-agent-item-surface-left); top: -1px; z-index: 0; box-sizing: border-box; @@ -110,6 +116,7 @@ button.workspace-agent-item:focus-visible { box-shadow: var(--workspace-agent-item-surface-shadow); pointer-events: none; transition: + left 0.22s ease, width 0.22s ease, height 0.22s ease, background-color 0.15s ease, diff --git a/src/app/renderer/styles/workspace-sidebar.css b/src/app/renderer/styles/workspace-sidebar.css index 7c60915ae..b8f13fb93 100644 --- a/src/app/renderer/styles/workspace-sidebar.css +++ b/src/app/renderer/styles/workspace-sidebar.css @@ -324,7 +324,7 @@ flex-direction: column; gap: 3px; min-width: 0; - padding-left: 10px; + padding-left: 4px; position: relative; --workspace-space-accent: var(--cove-label-color, var(--cove-accent)); } @@ -332,12 +332,15 @@ .workspace-space-group--branched::before { content: ''; position: absolute; - left: 4px; - top: 0; + left: -2px; + top: calc(var(--workspace-sidebar-tree-item-size) + 3px); bottom: 0; width: 2px; border-radius: 999px; background: color-mix(in srgb, var(--workspace-space-accent) 82%, transparent); + transition: + left 0.22s ease, + background-color 0.15s ease; } .workspace-space-item { diff --git a/src/app/renderer/styles/workspace-sidebar.rail.css b/src/app/renderer/styles/workspace-sidebar.rail.css index 64d897753..906fd924d 100644 --- a/src/app/renderer/styles/workspace-sidebar.rail.css +++ b/src/app/renderer/styles/workspace-sidebar.rail.css @@ -26,7 +26,7 @@ } .workspace-sidebar .workspace-sidebar__toolbar-button--pin { - margin-left: 12px; + margin-left: 2px; } .workspace-sidebar .workspace-item, @@ -44,8 +44,11 @@ } .workspace-sidebar .workspace-item__folder-icon { - margin-left: 17px; + margin-left: 6px; margin-right: 12px; + transition: + margin-left 0.22s ease, + color 0.15s ease; } .workspace-sidebar .workspace-item__name { @@ -88,6 +91,9 @@ opacity: 1; pointer-events: none; transform: translate(-50%, -50%) scale(1); + transition: + left 0.22s ease, + opacity 0.1s ease; } .workspace-sidebar .workspace-space-item__name { @@ -110,7 +116,7 @@ .workspace-sidebar .workspace-space-item--has-toggle .workspace-space-item__name { position: absolute; - left: 14px; + left: 9px; top: 50%; width: max(0px, calc(var(--workspace-space-item-surface-width) - 56px)); margin-left: 0; @@ -133,7 +139,12 @@ .workspace-sidebar .workspace-space-item__toggle-spacer { position: absolute; top: 50%; - left: max(1px, calc(var(--workspace-space-item-surface-width) - 32px)); + left: max( + 1px, + calc( + var(--workspace-space-item-surface-left) + var(--workspace-space-item-surface-width) - 28px + ) + ); z-index: 1; transform: translateY(-50%); transition: left 0.22s ease; @@ -150,11 +161,26 @@ } .workspace-sidebar--rail .workspace-space-item { - --workspace-space-item-surface-width: var(--workspace-rail-item-size); - --workspace-space-item-surface-height: var(--workspace-rail-item-size); + --workspace-space-item-surface-left: -2px; + --workspace-space-item-surface-width: 24px; + --workspace-space-item-surface-height: 24px; +} + +.workspace-sidebar--rail .workspace-space-group--branched::before { + left: -2px; +} + +.workspace-sidebar--rail .workspace-space-item__rail-icon { + left: calc(var(--workspace-space-item-surface-left) + 12px); +} + +.workspace-sidebar--rail .workspace-space-item__toggle, +.workspace-sidebar--rail .workspace-space-item__toggle-spacer { + left: var(--workspace-space-item-surface-left); } .workspace-sidebar--rail .workspace-agent-item--sidebar { - --workspace-agent-item-surface-width: var(--workspace-rail-item-size); - --workspace-agent-item-surface-height: var(--workspace-rail-item-size); + --workspace-agent-item-surface-left: -2px; + --workspace-agent-item-surface-width: 24px; + --workspace-agent-item-surface-height: 24px; } diff --git a/src/app/renderer/styles/workspace-sidebar.surface.css b/src/app/renderer/styles/workspace-sidebar.surface.css index 7c2f57696..7a907be75 100644 --- a/src/app/renderer/styles/workspace-sidebar.surface.css +++ b/src/app/renderer/styles/workspace-sidebar.surface.css @@ -1,7 +1,8 @@ .workspace-space-item { --workspace-space-item-background: transparent; --workspace-space-item-border-color: transparent; - --workspace-space-item-surface-width: calc(100% + 2px); + --workspace-space-item-surface-left: 0px; + --workspace-space-item-surface-width: calc(100% - 2px); --workspace-space-item-surface-height: calc(100% + 2px); background: transparent; border-color: transparent; @@ -11,7 +12,7 @@ .workspace-space-item::before { content: ''; position: absolute; - left: -1px; + left: var(--workspace-space-item-surface-left); top: -1px; z-index: 0; box-sizing: border-box; @@ -22,6 +23,7 @@ background: var(--workspace-space-item-background); pointer-events: none; transition: + left 0.22s ease, width 0.22s ease, height 0.22s ease, background-color 0.15s ease, diff --git a/tests/e2e/app-header.primary-sidebar-animation.helpers.ts b/tests/e2e/app-header.primary-sidebar-animation.helpers.ts index a2dfa980e..669fa2243 100644 --- a/tests/e2e/app-header.primary-sidebar-animation.helpers.ts +++ b/tests/e2e/app-header.primary-sidebar-animation.helpers.ts @@ -26,9 +26,12 @@ export type SidebarAnimationSample = { spaceRailIconViewportCenterY: number spaceRailIconCenterFromSidebarLeft: number spaceRailSurfaceOpacity: number + spaceRailSurfaceLeft: number + spaceRailSurfaceLeftFromSidebarLeft: number spaceRailSurfaceWidth: number spaceRailSurfaceHeight: number spaceRailSurfaceRight: number + spaceRailSurfaceRightInset: number spaceItemBackground: string spaceItemBorderColor: string spaceItemWidth: number @@ -109,6 +112,7 @@ export const sampleSidebarToggle = async ( const projectToggleStyle = window.getComputedStyle(projectToggle) const spaceRailIconStyle = window.getComputedStyle(spacePrimaryControl) const spaceRailSurfaceStyle = window.getComputedStyle(spaceItem, '::before') + const spaceRailSurfaceLeft = spaceItemRect.x + Number.parseFloat(spaceRailSurfaceStyle.left) const spaceItemStyle = window.getComputedStyle(spaceItem) const spaceNameStyle = window.getComputedStyle(spaceName) const spaceToggleStyle = window.getComputedStyle(spaceToggle) @@ -162,10 +166,20 @@ export const sampleSidebarToggle = async ( (spaceRailIconRect.x + spaceRailIconRect.width / 2 - sidebarRect.x).toFixed(3), ), spaceRailSurfaceOpacity: Number.parseFloat(spaceRailSurfaceStyle.opacity), + spaceRailSurfaceLeft: Number(spaceRailSurfaceLeft.toFixed(3)), + spaceRailSurfaceLeftFromSidebarLeft: Number( + (spaceRailSurfaceLeft - sidebarRect.x).toFixed(3), + ), spaceRailSurfaceWidth: Number.parseFloat(spaceRailSurfaceStyle.width), spaceRailSurfaceHeight: Number.parseFloat(spaceRailSurfaceStyle.height), spaceRailSurfaceRight: Number( - (spaceItemRect.x - 1 + Number.parseFloat(spaceRailSurfaceStyle.width)).toFixed(3), + (spaceRailSurfaceLeft + Number.parseFloat(spaceRailSurfaceStyle.width)).toFixed(3), + ), + spaceRailSurfaceRightInset: Number( + ( + sidebarRect.right - + (spaceRailSurfaceLeft + Number.parseFloat(spaceRailSurfaceStyle.width)) + ).toFixed(3), ), spaceItemBackground: spaceItemStyle.backgroundColor, spaceItemBorderColor: spaceItemStyle.borderColor, diff --git a/tests/e2e/app-header.primary-sidebar-animation.spec.ts b/tests/e2e/app-header.primary-sidebar-animation.spec.ts index 6b144de4b..8779a567c 100644 --- a/tests/e2e/app-header.primary-sidebar-animation.spec.ts +++ b/tests/e2e/app-header.primary-sidebar-animation.spec.ts @@ -150,13 +150,14 @@ const expectContinuousSidebarAnimation = ( expect(finalSample.projectToggleVisibleWidth).toBeLessThanOrEqual(1) expect(finalSample.spaceNameVisibleWidth).toBeLessThanOrEqual(1) expect(finalSample.spaceNameOpacity).toBeLessThanOrEqual(0.1) + expectClose(finalSample.projectIconCenterFromSidebarLeft, finalSample.width / 2, 1) expectClose( finalSample.spaceRailSurfaceRight - finalSample.spaceRailSurfaceWidth / 2, finalSample.spaceRailIconViewportCenterX, 1, ) - expectClose(finalSample.spaceRailSurfaceWidth, finalSample.spaceItemHeight, 0.5) - expectClose(finalSample.spaceRailSurfaceHeight, finalSample.spaceItemHeight, 0.5) + expectClose(finalSample.spaceRailSurfaceWidth, 24, 0.5) + expectClose(finalSample.spaceRailSurfaceHeight, 24, 0.5) } expect( maxStep( @@ -188,6 +189,7 @@ const expectContinuousSidebarAnimation = ( expect(result.before.projectToggleVisibleWidth).toBeLessThanOrEqual(1) expect(result.before.spaceNameVisibleWidth).toBeLessThanOrEqual(1) expect(result.before.spaceNameOpacity).toBeLessThanOrEqual(0.1) + expectClose(result.before.projectIconCenterFromSidebarLeft, result.before.width / 2, 1) expectClose( result.before.spaceRailSurfaceRight - result.before.spaceRailSurfaceWidth / 2, result.before.spaceRailIconViewportCenterX, @@ -271,8 +273,8 @@ test.describe('Primary Sidebar Animation', () => { expect(collapsedFinal.sidebarTransition).toBe('idle') expect(collapsedFinal.spaceRailIconOpacity).toBeGreaterThanOrEqual(0.95) expect(collapsedFinal.spaceItemWidth).toBeGreaterThan(100) - expectClose(collapsedFinal.spaceRailSurfaceWidth, collapsedFinal.spaceItemHeight, 0.5) - expectClose(collapsedFinal.spaceRailSurfaceHeight, collapsedFinal.spaceItemHeight, 0.5) + expectClose(collapsedFinal.spaceRailSurfaceWidth, 24, 0.5) + expectClose(collapsedFinal.spaceRailSurfaceHeight, 24, 0.5) expect(collapsedFinal.projectNameVisibleWidth).toBeLessThanOrEqual(1) expect(collapsedFinal.projectNameOpacity).toBeLessThanOrEqual(0.1) expect(collapsedFinal.spaceNameVisibleWidth).toBeLessThanOrEqual(1) @@ -296,7 +298,11 @@ test.describe('Primary Sidebar Animation', () => { expect(expandedFinal.spaceItemWidth).toBeGreaterThan(100) expect(expandedFinal.spaceRailSurfaceOpacity).toBeGreaterThanOrEqual(0.95) expect(expandedFinal.spaceRailSurfaceWidth).toBeGreaterThan(100) - expectClose(expandedFinal.spaceRailSurfaceWidth, expandedFinal.spaceItemWidth, 0.5) + expectClose( + expandedFinal.spaceRailSurfaceLeftFromSidebarLeft, + expandedFinal.spaceRailSurfaceRightInset, + 1, + ) expectClose(expandedFinal.spaceRailSurfaceHeight, expandedFinal.spaceItemHeight, 0.5) expect(expandedFinal.spaceNameOpacity).toBeGreaterThanOrEqual(0.95) expect(expandedFinal.spaceNameVisibleWidth).toBeGreaterThan(20) diff --git a/tests/e2e/app-header.primary-sidebar-hover-animation.spec.ts b/tests/e2e/app-header.primary-sidebar-hover-animation.spec.ts index 35ddf5b42..5705f3272 100644 --- a/tests/e2e/app-header.primary-sidebar-hover-animation.spec.ts +++ b/tests/e2e/app-header.primary-sidebar-hover-animation.spec.ts @@ -57,6 +57,7 @@ const sampleHoverPeek = async (page: Page): Promise => { const switchAllRect = switchAll.getBoundingClientRect() const hiddenRailIconStyle = window.getComputedStyle(hiddenRailIcon) const surfaceStyle = window.getComputedStyle(spaceItem, '::before') + const surfaceLeft = itemRect.left + Number.parseFloat(surfaceStyle.left) const surfaceWidth = Number.parseFloat(surfaceStyle.width) const visibleInSidebar = (rect: DOMRect): number => Number( @@ -76,7 +77,7 @@ const sampleHoverPeek = async (page: Page): Promise => { iconCenterX: Number((iconRect.x + iconRect.width / 2).toFixed(3)), hiddenRailIconOpacity: Number.parseFloat(hiddenRailIconStyle.opacity), surfaceWidth, - surfaceRight: Number((itemRect.left - 1 + surfaceWidth).toFixed(3)), + surfaceRight: Number((surfaceLeft + surfaceWidth).toFixed(3)), spaceToggleRight: Number(toggleRect.right.toFixed(3)), spaceToggleVisibleWidth: visibleInSidebar(toggleRect), switchAllVisibleWidth: visibleInSidebar(switchAllRect), diff --git a/tests/e2e/app-header.primary-sidebar-toggle.spec.ts b/tests/e2e/app-header.primary-sidebar-toggle.spec.ts index 8cffbada8..6a88bf722 100644 --- a/tests/e2e/app-header.primary-sidebar-toggle.spec.ts +++ b/tests/e2e/app-header.primary-sidebar-toggle.spec.ts @@ -75,13 +75,16 @@ const readSidebarToggleVisuals = async (page: Page) => return -1 } - const style = window.getComputedStyle(group, '::before') - return ( - item.getBoundingClientRect().left - - group.getBoundingClientRect().left - - Number.parseFloat(style.left) - - Number.parseFloat(style.width) - ) + const branchStyle = window.getComputedStyle(group, '::before') + const itemSurfaceStyle = window.getComputedStyle(item, '::before') + const groupRect = group.getBoundingClientRect() + const itemRect = item.getBoundingClientRect() + const branchRight = + groupRect.left + + Number.parseFloat(branchStyle.left) + + Number.parseFloat(branchStyle.width) + const surfaceLeft = itemRect.left + Number.parseFloat(itemSurfaceStyle.left) + return surfaceLeft - branchRight } const activeSpace = document.querySelector('.workspace-space-item--active') const provider = document.querySelector(`${secondaryAgent} .workspace-agent-item__provider`) @@ -369,16 +372,16 @@ test.describe('Primary Sidebar Pin', () => { expect(peekVisuals.spaceChevron).toBeCloseTo(railVisuals.spaceRailIcon, 0) expect(railVisuals.spaceVisibleWidth).toBeGreaterThanOrEqual(railVisuals.spaceHeight) expect(railVisuals.spaceVisibleWidth).toBeLessThanOrEqual(52) - expect(railVisuals.spaceSurfaceWidth).toBeCloseTo(railVisuals.spaceHeight, 0) - expect(railVisuals.spaceSurfaceHeight).toBeCloseTo(railVisuals.spaceHeight, 0) + expect(railVisuals.spaceSurfaceWidth).toBeCloseTo(24, 0) + expect(railVisuals.spaceSurfaceHeight).toBeCloseTo(24, 0) expect(peekVisuals.spaceSurfaceWidth).toBeGreaterThan(100) expect(peekVisuals.spaceSurfaceHeight).toBeCloseTo(railVisuals.spaceHeight, 0) expect(peekVisuals.spaceHeight).toBeCloseTo(railVisuals.spaceHeight, 0) expect(peekVisuals.spaceIconCount).toBe(0) expect(peekVisuals.agentIcon).toBeCloseTo(railVisuals.agentIcon, 0) expect(peekVisuals.agentHeight).toBeCloseTo(railVisuals.agentHeight, 0) - expect(railVisuals.agentSurfaceWidth).toBeCloseTo(railVisuals.agentHeight, 0) - expect(railVisuals.agentSurfaceHeight).toBeCloseTo(railVisuals.agentHeight, 0) + expect(railVisuals.agentSurfaceWidth).toBeCloseTo(24, 0) + expect(railVisuals.agentSurfaceHeight).toBeCloseTo(24, 0) expect(peekVisuals.agentSurfaceWidth).toBeGreaterThan(100) expect(peekVisuals.agentSurfaceHeight).toBeCloseTo(railVisuals.agentHeight, 0) expect(railVisuals.agentStatusLineCount).toBe(0) @@ -401,7 +404,10 @@ test.describe('Primary Sidebar Pin', () => { expect(peekVisuals.defaultSpaceSurfaceBackground).not.toBe('rgba(0, 0, 0, 0)') expect(railVisuals.inactiveBranchBackground).not.toBe('rgba(0, 0, 0, 0)') expect(peekVisuals.inactiveBranchBackground).toBe(railVisuals.inactiveBranchBackground) - expect(peekVisuals.inactiveBranchGap).toBeCloseTo(railVisuals.inactiveBranchGap, 0) + expect(railVisuals.inactiveBranchGap).toBeGreaterThanOrEqual(2) + expect(railVisuals.inactiveBranchGap).toBeLessThanOrEqual(3) + expect(peekVisuals.inactiveBranchGap).toBeGreaterThanOrEqual(4) + expect(peekVisuals.inactiveBranchGap).toBeLessThanOrEqual(5) expect(railVisuals.defaultBranchBackground).not.toBe('rgba(0, 0, 0, 0)') expect(peekVisuals.defaultBranchBackground).toBe(railVisuals.defaultBranchBackground) expect( diff --git a/tests/e2e/workspace-canvas.sidebar-spacing.spec.ts b/tests/e2e/workspace-canvas.sidebar-spacing.spec.ts new file mode 100644 index 000000000..c5c86ab6b --- /dev/null +++ b/tests/e2e/workspace-canvas.sidebar-spacing.spec.ts @@ -0,0 +1,262 @@ +import { expect, test, type Page } from '@playwright/test' +import { launchApp, seedWorkspaceState, testWorkspacePath } from './workspace-canvas.helpers' +import { createRailAgent } from './sidebar-test-fixtures' + +type SidebarRowGeometry = { + sidebarWidth: number + pinButtonCenter: number + projectGroupLeftInset: number + projectGroupRightInset: number + projectGroupCenter: number + projectIconCenter: number + projectToggleCenter: number + spaceSurfaceProjectLeftGap: number + spaceSurfaceProjectRightGap: number + spaceBranchLeftInset: number + spaceBranchRightInset: number + spaceBranchGap: number + spaceBranchTopGap: number + spaceSurfaceLeftInset: number + spaceSurfaceRightInset: number + spaceSurfaceGutterDelta: number + spaceSurfaceWidth: number + spaceSurfaceCenter: number + spaceNameLeft: number + spaceToggleCenter: number + spaceRailIconCenter: number + agentSurfaceLeftInset: number + agentSurfaceRightInset: number + agentSurfaceGutterDelta: number + agentSurfaceWidth: number + agentSurfaceCenter: number + agentSurfaceProjectLeftGap: number + agentSurfaceProjectRightGap: number + agentProviderCenter: number +} + +const expectDistanceWithin = (actual: number, expected: number, tolerance: number) => { + expect(Math.abs(actual - expected)).toBeLessThanOrEqual(tolerance) +} + +const readSidebarRowGeometry = async ( + page: Page, + workspaceId: string, + spaceId: string, + agentId: string, +): Promise => { + return await page.evaluate( + ({ activeWorkspaceId, activeSpaceId, activeAgentId }) => { + const sidebar = document.querySelector('.workspace-sidebar') + const pinButton = document.querySelector('[data-testid="workspace-sidebar-pin"]') + const projectItem = document.querySelector( + `[data-testid="workspace-item-${activeWorkspaceId}"]`, + ) + const projectGroup = projectItem?.closest('.workspace-item-group') + const projectIcon = projectItem?.querySelector('.workspace-item__folder-icon') + const projectToggle = projectItem?.querySelector('.workspace-item__tree-toggle') + const spaceItem = document.querySelector( + `[data-testid="workspace-space-item-${activeWorkspaceId}-${activeSpaceId}"]`, + ) + const spaceGroup = spaceItem?.closest('.workspace-space-group') + const spaceName = spaceItem?.querySelector('.workspace-space-item__name') + const spaceToggle = spaceItem?.querySelector('.workspace-space-item__toggle') + const spaceRailIcon = spaceItem?.querySelector('.workspace-space-item__rail-icon') + const agentItem = document.querySelector( + `[data-testid="workspace-agent-item-${activeWorkspaceId}-${activeAgentId}"]`, + ) + const agentProvider = agentItem?.querySelector('.workspace-agent-item__provider') + + if ( + !(sidebar instanceof HTMLElement) || + !(pinButton instanceof HTMLElement) || + !(projectItem instanceof HTMLElement) || + !(projectGroup instanceof HTMLElement) || + !(projectIcon instanceof SVGElement) || + !(projectToggle instanceof HTMLElement) || + !(spaceItem instanceof HTMLElement) || + !(spaceGroup instanceof HTMLElement) || + !(spaceName instanceof HTMLElement) || + !(spaceToggle instanceof HTMLElement) || + !(spaceRailIcon instanceof HTMLElement) || + !(agentItem instanceof HTMLElement) || + !(agentProvider instanceof HTMLElement) + ) { + throw new Error('Sidebar row geometry target not available') + } + + const round = (value: number) => Number(value.toFixed(3)) + const centerFromSidebarLeft = (element: Element): number => { + const sidebarRect = sidebar.getBoundingClientRect() + const rect = element.getBoundingClientRect() + return round(rect.left + rect.width / 2 - sidebarRect.left) + } + const surfaceMetrics = (element: HTMLElement, pseudoElement: '::before') => { + const sidebarRect = sidebar.getBoundingClientRect() + const itemRect = element.getBoundingClientRect() + const surfaceStyle = window.getComputedStyle(element, pseudoElement) + const surfaceLeft = itemRect.left + Number.parseFloat(surfaceStyle.left) + const surfaceWidth = Number.parseFloat(surfaceStyle.width) + const surfaceRight = surfaceLeft + surfaceWidth + const leftInset = surfaceLeft - sidebarRect.left + const rightInset = sidebarRect.right - surfaceRight + + return { + leftInset: round(leftInset), + rightInset: round(rightInset), + gutterDelta: round(Math.abs(leftInset - rightInset)), + width: round(surfaceWidth), + center: round(surfaceLeft + surfaceWidth / 2 - sidebarRect.left), + } + } + + const sidebarRect = sidebar.getBoundingClientRect() + const projectGroupRect = projectGroup.getBoundingClientRect() + const spaceRect = spaceItem.getBoundingClientRect() + const spaceSurface = surfaceMetrics(spaceItem, '::before') + const agentSurface = surfaceMetrics(agentItem, '::before') + const spaceGroupRect = spaceGroup.getBoundingClientRect() + const branchStyle = window.getComputedStyle(spaceGroup, '::before') + const branchTop = spaceGroupRect.top + Number.parseFloat(branchStyle.top) + const branchLeft = spaceGroupRect.left + Number.parseFloat(branchStyle.left) + const branchRight = branchLeft + Number.parseFloat(branchStyle.width) + + return { + sidebarWidth: round(sidebarRect.width), + pinButtonCenter: centerFromSidebarLeft(pinButton), + projectGroupLeftInset: round(projectGroupRect.left - sidebarRect.left), + projectGroupRightInset: round(sidebarRect.right - projectGroupRect.right), + projectGroupCenter: round( + projectGroupRect.left + projectGroupRect.width / 2 - sidebarRect.left, + ), + projectIconCenter: centerFromSidebarLeft(projectIcon), + projectToggleCenter: centerFromSidebarLeft(projectToggle), + spaceSurfaceProjectLeftGap: round( + spaceSurface.leftInset - (projectGroupRect.left - sidebarRect.left), + ), + spaceSurfaceProjectRightGap: round( + spaceSurface.rightInset - (sidebarRect.right - projectGroupRect.right), + ), + spaceBranchLeftInset: round(branchLeft - sidebarRect.left), + spaceBranchRightInset: round(branchRight - sidebarRect.left), + spaceBranchGap: round(spaceSurface.leftInset - (branchRight - sidebarRect.left)), + spaceBranchTopGap: round(branchTop - spaceRect.bottom), + spaceSurfaceLeftInset: spaceSurface.leftInset, + spaceSurfaceRightInset: spaceSurface.rightInset, + spaceSurfaceGutterDelta: spaceSurface.gutterDelta, + spaceSurfaceWidth: spaceSurface.width, + spaceSurfaceCenter: spaceSurface.center, + spaceNameLeft: round(spaceName.getBoundingClientRect().left - sidebarRect.left), + spaceToggleCenter: centerFromSidebarLeft(spaceToggle), + spaceRailIconCenter: centerFromSidebarLeft(spaceRailIcon), + agentSurfaceLeftInset: agentSurface.leftInset, + agentSurfaceRightInset: agentSurface.rightInset, + agentSurfaceGutterDelta: agentSurface.gutterDelta, + agentSurfaceWidth: agentSurface.width, + agentSurfaceCenter: agentSurface.center, + agentSurfaceProjectLeftGap: round( + agentSurface.leftInset - (projectGroupRect.left - sidebarRect.left), + ), + agentSurfaceProjectRightGap: round( + agentSurface.rightInset - (sidebarRect.right - projectGroupRect.right), + ), + agentProviderCenter: centerFromSidebarLeft(agentProvider), + } + }, + { activeWorkspaceId: workspaceId, activeSpaceId: spaceId, activeAgentId: agentId }, + ) +} + +test.describe('Workspace Canvas - Sidebar Row Geometry', () => { + test('keeps project, space, and agent geometry balanced in docked and rail modes', async () => { + const { electronApp, window } = await launchApp() + const workspaceId = 'workspace-sidebar-spacing' + const spaceId = 'space-sidebar-spacing' + const agentId = 'agent-sidebar-spacing' + + try { + await seedWorkspaceState(window, { + activeWorkspaceId: workspaceId, + workspaces: [ + { + id: workspaceId, + name: 'Sidebar spacing', + path: testWorkspacePath, + nodes: [ + createRailAgent( + agentId, + 'Sidebar spacing agent', + 0, + 'Measure sidebar spacing', + '2026-03-29T10:00:00.000Z', + ), + ], + spaces: [ + { + id: spaceId, + name: 'Spacing', + directoryPath: testWorkspacePath, + labelColor: 'blue', + nodeIds: ['agent-sidebar-spacing'], + }, + ], + activeSpaceId: spaceId, + }, + ], + }) + + const sidebar = window.locator('.workspace-sidebar') + await expect(sidebar).toHaveClass(/workspace-sidebar--docked/) + + const docked = await readSidebarRowGeometry(window, workspaceId, spaceId, agentId) + + await window.locator('[data-testid="workspace-sidebar-pin"]').click() + await expect(sidebar).toHaveClass(/workspace-sidebar--rail/) + await window.waitForTimeout(300) + + const rail = await readSidebarRowGeometry(window, workspaceId, spaceId, agentId) + + expectDistanceWithin(docked.projectGroupLeftInset, docked.projectIconCenter / 2, 4) + expect(docked.spaceSurfaceProjectLeftGap).toBeGreaterThanOrEqual(5) + expect(docked.spaceSurfaceProjectLeftGap).toBeLessThanOrEqual(7) + expect(docked.spaceSurfaceProjectRightGap).toBeGreaterThanOrEqual(5) + expect(docked.spaceSurfaceProjectRightGap).toBeLessThanOrEqual(7) + expect(docked.spaceSurfaceGutterDelta).toBeLessThanOrEqual(1) + expect(docked.spaceSurfaceCenter).toBeCloseTo(docked.sidebarWidth / 2, 0) + expect(docked.agentSurfaceProjectLeftGap).toBeGreaterThanOrEqual(5) + expect(docked.agentSurfaceProjectLeftGap).toBeLessThanOrEqual(7) + expect(docked.agentSurfaceProjectRightGap).toBeGreaterThanOrEqual(5) + expect(docked.agentSurfaceProjectRightGap).toBeLessThanOrEqual(7) + expect(docked.agentSurfaceGutterDelta).toBeLessThanOrEqual(1) + expect(docked.agentSurfaceCenter).toBeCloseTo(docked.sidebarWidth / 2, 0) + expectDistanceWithin(docked.agentSurfaceWidth, docked.spaceSurfaceWidth, 1) + expect(docked.spaceBranchLeftInset).toBeGreaterThanOrEqual(docked.projectGroupLeftInset) + expect(docked.spaceBranchGap).toBeGreaterThanOrEqual(4) + expect(docked.spaceBranchGap).toBeLessThanOrEqual(5) + expect(docked.spaceBranchTopGap).toBeGreaterThanOrEqual(2) + expect(docked.spaceBranchTopGap).toBeLessThanOrEqual(4) + expectDistanceWithin(docked.spaceNameLeft, docked.projectIconCenter, 1) + expectDistanceWithin(docked.agentProviderCenter, docked.projectIconCenter, 1) + expectDistanceWithin(docked.spaceToggleCenter, docked.projectToggleCenter, 1) + + expectDistanceWithin(rail.sidebarWidth, 52, 1) + expectDistanceWithin(rail.spaceSurfaceWidth, 24, 1) + expect(rail.spaceSurfaceGutterDelta).toBeLessThanOrEqual(1) + expect(rail.spaceSurfaceCenter).toBeCloseTo(rail.sidebarWidth / 2, 0) + expect(rail.spaceBranchLeftInset).toBeGreaterThanOrEqual(rail.projectGroupLeftInset) + expect(rail.spaceBranchGap).toBeGreaterThanOrEqual(2) + expect(rail.spaceBranchGap).toBeLessThanOrEqual(3) + expect(rail.spaceBranchTopGap).toBeGreaterThanOrEqual(2) + expect(rail.spaceBranchTopGap).toBeLessThanOrEqual(4) + expect(rail.agentSurfaceGutterDelta).toBeLessThanOrEqual(1) + expect(rail.agentSurfaceCenter).toBeCloseTo(rail.sidebarWidth / 2, 0) + expectDistanceWithin(rail.agentSurfaceWidth, rail.spaceSurfaceWidth, 1) + expectDistanceWithin(rail.projectIconCenter, rail.sidebarWidth / 2, 1) + expectDistanceWithin(rail.pinButtonCenter, rail.sidebarWidth / 2, 1) + expectDistanceWithin(rail.spaceToggleCenter, rail.sidebarWidth / 2, 1) + expectDistanceWithin(rail.spaceRailIconCenter, rail.sidebarWidth / 2, 1) + expectDistanceWithin(rail.agentProviderCenter, rail.sidebarWidth / 2, 1) + } finally { + await electronApp.close() + } + }) +}) From c8081fb15524d206bfb64b6131cdb7abd57d9008 Mon Sep 17 00:00:00 2001 From: deadwavewave Date: Tue, 7 Jul 2026 18:59:46 +0800 Subject: [PATCH 3/8] fix: pad sidebar project frame --- src/app/renderer/styles/workspace-sidebar.css | 2 +- tests/e2e/workspace-canvas.sidebar-spacing.spec.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/app/renderer/styles/workspace-sidebar.css b/src/app/renderer/styles/workspace-sidebar.css index b8f13fb93..623db1ca2 100644 --- a/src/app/renderer/styles/workspace-sidebar.css +++ b/src/app/renderer/styles/workspace-sidebar.css @@ -173,7 +173,7 @@ gap: 3px; border: 1px solid transparent; border-radius: 10px; - padding: 1px; + padding: 3px 1px; transition: background-color 0.15s ease, border-color 0.15s ease, diff --git a/tests/e2e/workspace-canvas.sidebar-spacing.spec.ts b/tests/e2e/workspace-canvas.sidebar-spacing.spec.ts index c5c86ab6b..dc268c618 100644 --- a/tests/e2e/workspace-canvas.sidebar-spacing.spec.ts +++ b/tests/e2e/workspace-canvas.sidebar-spacing.spec.ts @@ -8,6 +8,7 @@ type SidebarRowGeometry = { projectGroupLeftInset: number projectGroupRightInset: number projectGroupCenter: number + projectContentTopInset: number projectIconCenter: number projectToggleCenter: number spaceSurfaceProjectLeftGap: number @@ -16,6 +17,7 @@ type SidebarRowGeometry = { spaceBranchRightInset: number spaceBranchGap: number spaceBranchTopGap: number + spaceBranchBottomGap: number spaceSurfaceLeftInset: number spaceSurfaceRightInset: number spaceSurfaceGutterDelta: number @@ -111,6 +113,7 @@ const readSidebarRowGeometry = async ( const sidebarRect = sidebar.getBoundingClientRect() const projectGroupRect = projectGroup.getBoundingClientRect() + const projectItemRect = projectItem.getBoundingClientRect() const spaceRect = spaceItem.getBoundingClientRect() const spaceSurface = surfaceMetrics(spaceItem, '::before') const agentSurface = surfaceMetrics(agentItem, '::before') @@ -128,6 +131,7 @@ const readSidebarRowGeometry = async ( projectGroupCenter: round( projectGroupRect.left + projectGroupRect.width / 2 - sidebarRect.left, ), + projectContentTopInset: round(projectItemRect.top - projectGroupRect.top), projectIconCenter: centerFromSidebarLeft(projectIcon), projectToggleCenter: centerFromSidebarLeft(projectToggle), spaceSurfaceProjectLeftGap: round( @@ -140,6 +144,7 @@ const readSidebarRowGeometry = async ( spaceBranchRightInset: round(branchRight - sidebarRect.left), spaceBranchGap: round(spaceSurface.leftInset - (branchRight - sidebarRect.left)), spaceBranchTopGap: round(branchTop - spaceRect.bottom), + spaceBranchBottomGap: round(projectGroupRect.bottom - spaceGroupRect.bottom), spaceSurfaceLeftInset: spaceSurface.leftInset, spaceSurfaceRightInset: spaceSurface.rightInset, spaceSurfaceGutterDelta: spaceSurface.gutterDelta, @@ -216,6 +221,8 @@ test.describe('Workspace Canvas - Sidebar Row Geometry', () => { const rail = await readSidebarRowGeometry(window, workspaceId, spaceId, agentId) expectDistanceWithin(docked.projectGroupLeftInset, docked.projectIconCenter / 2, 4) + expect(docked.projectContentTopInset).toBeGreaterThanOrEqual(3) + expect(docked.projectContentTopInset).toBeLessThanOrEqual(5) expect(docked.spaceSurfaceProjectLeftGap).toBeGreaterThanOrEqual(5) expect(docked.spaceSurfaceProjectLeftGap).toBeLessThanOrEqual(7) expect(docked.spaceSurfaceProjectRightGap).toBeGreaterThanOrEqual(5) @@ -234,6 +241,8 @@ test.describe('Workspace Canvas - Sidebar Row Geometry', () => { expect(docked.spaceBranchGap).toBeLessThanOrEqual(5) expect(docked.spaceBranchTopGap).toBeGreaterThanOrEqual(2) expect(docked.spaceBranchTopGap).toBeLessThanOrEqual(4) + expect(docked.spaceBranchBottomGap).toBeGreaterThanOrEqual(2) + expect(docked.spaceBranchBottomGap).toBeLessThanOrEqual(4) expectDistanceWithin(docked.spaceNameLeft, docked.projectIconCenter, 1) expectDistanceWithin(docked.agentProviderCenter, docked.projectIconCenter, 1) expectDistanceWithin(docked.spaceToggleCenter, docked.projectToggleCenter, 1) @@ -247,6 +256,8 @@ test.describe('Workspace Canvas - Sidebar Row Geometry', () => { expect(rail.spaceBranchGap).toBeLessThanOrEqual(3) expect(rail.spaceBranchTopGap).toBeGreaterThanOrEqual(2) expect(rail.spaceBranchTopGap).toBeLessThanOrEqual(4) + expect(rail.spaceBranchBottomGap).toBeGreaterThanOrEqual(2) + expect(rail.spaceBranchBottomGap).toBeLessThanOrEqual(4) expect(rail.agentSurfaceGutterDelta).toBeLessThanOrEqual(1) expect(rail.agentSurfaceCenter).toBeCloseTo(rail.sidebarWidth / 2, 0) expectDistanceWithin(rail.agentSurfaceWidth, rail.spaceSurfaceWidth, 1) From 5777410456d5f8c1d8f9f0ca4fb25c3764fc294a Mon Sep 17 00:00:00 2001 From: deadwavewave Date: Tue, 7 Jul 2026 20:34:19 +0800 Subject: [PATCH 4/8] fix: align sidebar item surfaces --- .../renderer/styles/workspace-agent-item.css | 4 +-- src/app/renderer/styles/workspace-sidebar.css | 3 +- .../styles/workspace-sidebar.rail.css | 4 +-- .../styles/workspace-sidebar.surface.css | 4 +-- ...p-header.primary-sidebar-animation.spec.ts | 2 +- .../app-header.primary-sidebar-toggle.spec.ts | 8 ++--- .../workspace-canvas.sidebar-spacing.spec.ts | 33 +++++++++++++++---- 7 files changed, 39 insertions(+), 19 deletions(-) diff --git a/src/app/renderer/styles/workspace-agent-item.css b/src/app/renderer/styles/workspace-agent-item.css index 1e2304493..375fb26ac 100644 --- a/src/app/renderer/styles/workspace-agent-item.css +++ b/src/app/renderer/styles/workspace-agent-item.css @@ -84,7 +84,7 @@ button.workspace-agent-item:focus-visible { --workspace-agent-item-surface-shadow: none; --workspace-agent-item-surface-left: 0px; --workspace-agent-item-surface-width: calc(100% - 2px); - --workspace-agent-item-surface-height: calc(100% + 2px); + --workspace-agent-item-surface-height: var(--workspace-sidebar-tree-surface-size, 24px); position: relative; flex-direction: row; align-items: center; @@ -105,7 +105,7 @@ button.workspace-agent-item:focus-visible { content: ''; position: absolute; left: var(--workspace-agent-item-surface-left); - top: -1px; + top: calc((100% - var(--workspace-agent-item-surface-height)) / 2); z-index: 0; box-sizing: border-box; width: var(--workspace-agent-item-surface-width); diff --git a/src/app/renderer/styles/workspace-sidebar.css b/src/app/renderer/styles/workspace-sidebar.css index 623db1ca2..c1d45e1e9 100644 --- a/src/app/renderer/styles/workspace-sidebar.css +++ b/src/app/renderer/styles/workspace-sidebar.css @@ -3,6 +3,7 @@ --workspace-sidebar-agent-icon-size: 16px; --workspace-sidebar-chevron-icon-size: 11px; --workspace-sidebar-tree-item-size: 28px; + --workspace-sidebar-tree-surface-size: 24px; --workspace-space-accent: var(--cove-accent); grid-row: 2; width: 100%; @@ -173,7 +174,7 @@ gap: 3px; border: 1px solid transparent; border-radius: 10px; - padding: 3px 1px; + padding: 5px 1px; transition: background-color 0.15s ease, border-color 0.15s ease, diff --git a/src/app/renderer/styles/workspace-sidebar.rail.css b/src/app/renderer/styles/workspace-sidebar.rail.css index 906fd924d..fd92708de 100644 --- a/src/app/renderer/styles/workspace-sidebar.rail.css +++ b/src/app/renderer/styles/workspace-sidebar.rail.css @@ -163,7 +163,7 @@ .workspace-sidebar--rail .workspace-space-item { --workspace-space-item-surface-left: -2px; --workspace-space-item-surface-width: 24px; - --workspace-space-item-surface-height: 24px; + --workspace-space-item-surface-height: var(--workspace-sidebar-tree-surface-size); } .workspace-sidebar--rail .workspace-space-group--branched::before { @@ -182,5 +182,5 @@ .workspace-sidebar--rail .workspace-agent-item--sidebar { --workspace-agent-item-surface-left: -2px; --workspace-agent-item-surface-width: 24px; - --workspace-agent-item-surface-height: 24px; + --workspace-agent-item-surface-height: var(--workspace-sidebar-tree-surface-size); } diff --git a/src/app/renderer/styles/workspace-sidebar.surface.css b/src/app/renderer/styles/workspace-sidebar.surface.css index 7a907be75..1e6597fff 100644 --- a/src/app/renderer/styles/workspace-sidebar.surface.css +++ b/src/app/renderer/styles/workspace-sidebar.surface.css @@ -3,7 +3,7 @@ --workspace-space-item-border-color: transparent; --workspace-space-item-surface-left: 0px; --workspace-space-item-surface-width: calc(100% - 2px); - --workspace-space-item-surface-height: calc(100% + 2px); + --workspace-space-item-surface-height: var(--workspace-sidebar-tree-surface-size); background: transparent; border-color: transparent; transition: color 0.15s ease; @@ -13,7 +13,7 @@ content: ''; position: absolute; left: var(--workspace-space-item-surface-left); - top: -1px; + top: calc((100% - var(--workspace-space-item-surface-height)) / 2); z-index: 0; box-sizing: border-box; width: var(--workspace-space-item-surface-width); diff --git a/tests/e2e/app-header.primary-sidebar-animation.spec.ts b/tests/e2e/app-header.primary-sidebar-animation.spec.ts index 8779a567c..b5bfb83b2 100644 --- a/tests/e2e/app-header.primary-sidebar-animation.spec.ts +++ b/tests/e2e/app-header.primary-sidebar-animation.spec.ts @@ -303,7 +303,7 @@ test.describe('Primary Sidebar Animation', () => { expandedFinal.spaceRailSurfaceRightInset, 1, ) - expectClose(expandedFinal.spaceRailSurfaceHeight, expandedFinal.spaceItemHeight, 0.5) + expectClose(expandedFinal.spaceRailSurfaceHeight, collapsedFinal.spaceRailSurfaceHeight, 0.5) expect(expandedFinal.spaceNameOpacity).toBeGreaterThanOrEqual(0.95) expect(expandedFinal.spaceNameVisibleWidth).toBeGreaterThan(20) expect(expandedFinal.spaceNameWidth).toBeGreaterThan(20) diff --git a/tests/e2e/app-header.primary-sidebar-toggle.spec.ts b/tests/e2e/app-header.primary-sidebar-toggle.spec.ts index 6a88bf722..8b68a8a83 100644 --- a/tests/e2e/app-header.primary-sidebar-toggle.spec.ts +++ b/tests/e2e/app-header.primary-sidebar-toggle.spec.ts @@ -373,17 +373,17 @@ test.describe('Primary Sidebar Pin', () => { expect(railVisuals.spaceVisibleWidth).toBeGreaterThanOrEqual(railVisuals.spaceHeight) expect(railVisuals.spaceVisibleWidth).toBeLessThanOrEqual(52) expect(railVisuals.spaceSurfaceWidth).toBeCloseTo(24, 0) - expect(railVisuals.spaceSurfaceHeight).toBeCloseTo(24, 0) + expect(railVisuals.spaceSurfaceHeight).toBeCloseTo(railVisuals.spaceSurfaceWidth, 0) expect(peekVisuals.spaceSurfaceWidth).toBeGreaterThan(100) - expect(peekVisuals.spaceSurfaceHeight).toBeCloseTo(railVisuals.spaceHeight, 0) + expect(peekVisuals.spaceSurfaceHeight).toBeCloseTo(railVisuals.spaceSurfaceHeight, 0) expect(peekVisuals.spaceHeight).toBeCloseTo(railVisuals.spaceHeight, 0) expect(peekVisuals.spaceIconCount).toBe(0) expect(peekVisuals.agentIcon).toBeCloseTo(railVisuals.agentIcon, 0) expect(peekVisuals.agentHeight).toBeCloseTo(railVisuals.agentHeight, 0) expect(railVisuals.agentSurfaceWidth).toBeCloseTo(24, 0) - expect(railVisuals.agentSurfaceHeight).toBeCloseTo(24, 0) + expect(railVisuals.agentSurfaceHeight).toBeCloseTo(railVisuals.agentSurfaceWidth, 0) expect(peekVisuals.agentSurfaceWidth).toBeGreaterThan(100) - expect(peekVisuals.agentSurfaceHeight).toBeCloseTo(railVisuals.agentHeight, 0) + expect(peekVisuals.agentSurfaceHeight).toBeCloseTo(railVisuals.agentSurfaceHeight, 0) expect(railVisuals.agentStatusLineCount).toBe(0) expect(peekVisuals.agentStatusLineCount).toBe(0) expect(railVisuals.agentRing).not.toBe('none') diff --git a/tests/e2e/workspace-canvas.sidebar-spacing.spec.ts b/tests/e2e/workspace-canvas.sidebar-spacing.spec.ts index dc268c618..f47dc4ff9 100644 --- a/tests/e2e/workspace-canvas.sidebar-spacing.spec.ts +++ b/tests/e2e/workspace-canvas.sidebar-spacing.spec.ts @@ -11,6 +11,7 @@ type SidebarRowGeometry = { projectContentTopInset: number projectIconCenter: number projectToggleCenter: number + projectToggleIconCenter: number spaceSurfaceProjectLeftGap: number spaceSurfaceProjectRightGap: number spaceBranchLeftInset: number @@ -22,14 +23,17 @@ type SidebarRowGeometry = { spaceSurfaceRightInset: number spaceSurfaceGutterDelta: number spaceSurfaceWidth: number + spaceSurfaceHeight: number spaceSurfaceCenter: number spaceNameLeft: number spaceToggleCenter: number + spaceToggleIconCenter: number spaceRailIconCenter: number agentSurfaceLeftInset: number agentSurfaceRightInset: number agentSurfaceGutterDelta: number agentSurfaceWidth: number + agentSurfaceHeight: number agentSurfaceCenter: number agentSurfaceProjectLeftGap: number agentSurfaceProjectRightGap: number @@ -56,12 +60,14 @@ const readSidebarRowGeometry = async ( const projectGroup = projectItem?.closest('.workspace-item-group') const projectIcon = projectItem?.querySelector('.workspace-item__folder-icon') const projectToggle = projectItem?.querySelector('.workspace-item__tree-toggle') + const projectToggleIcon = projectToggle?.querySelector('.workspace-tree-triangle') const spaceItem = document.querySelector( `[data-testid="workspace-space-item-${activeWorkspaceId}-${activeSpaceId}"]`, ) const spaceGroup = spaceItem?.closest('.workspace-space-group') const spaceName = spaceItem?.querySelector('.workspace-space-item__name') const spaceToggle = spaceItem?.querySelector('.workspace-space-item__toggle') + const spaceToggleIcon = spaceToggle?.querySelector('.workspace-tree-triangle') const spaceRailIcon = spaceItem?.querySelector('.workspace-space-item__rail-icon') const agentItem = document.querySelector( `[data-testid="workspace-agent-item-${activeWorkspaceId}-${activeAgentId}"]`, @@ -75,10 +81,12 @@ const readSidebarRowGeometry = async ( !(projectGroup instanceof HTMLElement) || !(projectIcon instanceof SVGElement) || !(projectToggle instanceof HTMLElement) || + !(projectToggleIcon instanceof HTMLElement) || !(spaceItem instanceof HTMLElement) || !(spaceGroup instanceof HTMLElement) || !(spaceName instanceof HTMLElement) || !(spaceToggle instanceof HTMLElement) || + !(spaceToggleIcon instanceof HTMLElement) || !(spaceRailIcon instanceof HTMLElement) || !(agentItem instanceof HTMLElement) || !(agentProvider instanceof HTMLElement) @@ -107,6 +115,7 @@ const readSidebarRowGeometry = async ( rightInset: round(rightInset), gutterDelta: round(Math.abs(leftInset - rightInset)), width: round(surfaceWidth), + height: round(Number.parseFloat(surfaceStyle.height)), center: round(surfaceLeft + surfaceWidth / 2 - sidebarRect.left), } } @@ -134,6 +143,7 @@ const readSidebarRowGeometry = async ( projectContentTopInset: round(projectItemRect.top - projectGroupRect.top), projectIconCenter: centerFromSidebarLeft(projectIcon), projectToggleCenter: centerFromSidebarLeft(projectToggle), + projectToggleIconCenter: centerFromSidebarLeft(projectToggleIcon), spaceSurfaceProjectLeftGap: round( spaceSurface.leftInset - (projectGroupRect.left - sidebarRect.left), ), @@ -149,14 +159,17 @@ const readSidebarRowGeometry = async ( spaceSurfaceRightInset: spaceSurface.rightInset, spaceSurfaceGutterDelta: spaceSurface.gutterDelta, spaceSurfaceWidth: spaceSurface.width, + spaceSurfaceHeight: spaceSurface.height, spaceSurfaceCenter: spaceSurface.center, spaceNameLeft: round(spaceName.getBoundingClientRect().left - sidebarRect.left), spaceToggleCenter: centerFromSidebarLeft(spaceToggle), + spaceToggleIconCenter: centerFromSidebarLeft(spaceToggleIcon), spaceRailIconCenter: centerFromSidebarLeft(spaceRailIcon), agentSurfaceLeftInset: agentSurface.leftInset, agentSurfaceRightInset: agentSurface.rightInset, agentSurfaceGutterDelta: agentSurface.gutterDelta, agentSurfaceWidth: agentSurface.width, + agentSurfaceHeight: agentSurface.height, agentSurfaceCenter: agentSurface.center, agentSurfaceProjectLeftGap: round( agentSurface.leftInset - (projectGroupRect.left - sidebarRect.left), @@ -219,10 +232,9 @@ test.describe('Workspace Canvas - Sidebar Row Geometry', () => { await window.waitForTimeout(300) const rail = await readSidebarRowGeometry(window, workspaceId, spaceId, agentId) - expectDistanceWithin(docked.projectGroupLeftInset, docked.projectIconCenter / 2, 4) - expect(docked.projectContentTopInset).toBeGreaterThanOrEqual(3) - expect(docked.projectContentTopInset).toBeLessThanOrEqual(5) + expect(docked.projectContentTopInset).toBeGreaterThanOrEqual(5) + expect(docked.projectContentTopInset).toBeLessThanOrEqual(7) expect(docked.spaceSurfaceProjectLeftGap).toBeGreaterThanOrEqual(5) expect(docked.spaceSurfaceProjectLeftGap).toBeLessThanOrEqual(7) expect(docked.spaceSurfaceProjectRightGap).toBeGreaterThanOrEqual(5) @@ -236,19 +248,23 @@ test.describe('Workspace Canvas - Sidebar Row Geometry', () => { expect(docked.agentSurfaceGutterDelta).toBeLessThanOrEqual(1) expect(docked.agentSurfaceCenter).toBeCloseTo(docked.sidebarWidth / 2, 0) expectDistanceWithin(docked.agentSurfaceWidth, docked.spaceSurfaceWidth, 1) + expectDistanceWithin(docked.agentSurfaceHeight, docked.spaceSurfaceHeight, 0.5) expect(docked.spaceBranchLeftInset).toBeGreaterThanOrEqual(docked.projectGroupLeftInset) expect(docked.spaceBranchGap).toBeGreaterThanOrEqual(4) expect(docked.spaceBranchGap).toBeLessThanOrEqual(5) expect(docked.spaceBranchTopGap).toBeGreaterThanOrEqual(2) expect(docked.spaceBranchTopGap).toBeLessThanOrEqual(4) - expect(docked.spaceBranchBottomGap).toBeGreaterThanOrEqual(2) - expect(docked.spaceBranchBottomGap).toBeLessThanOrEqual(4) + expect(docked.spaceBranchBottomGap).toBeGreaterThanOrEqual(4) + expect(docked.spaceBranchBottomGap).toBeLessThanOrEqual(6) expectDistanceWithin(docked.spaceNameLeft, docked.projectIconCenter, 1) expectDistanceWithin(docked.agentProviderCenter, docked.projectIconCenter, 1) expectDistanceWithin(docked.spaceToggleCenter, docked.projectToggleCenter, 1) + expectDistanceWithin(docked.spaceToggleIconCenter, docked.projectToggleIconCenter, 1) expectDistanceWithin(rail.sidebarWidth, 52, 1) expectDistanceWithin(rail.spaceSurfaceWidth, 24, 1) + expectDistanceWithin(rail.spaceSurfaceHeight, docked.spaceSurfaceHeight, 0.5) + expectDistanceWithin(rail.spaceSurfaceHeight, rail.spaceSurfaceWidth, 0.5) expect(rail.spaceSurfaceGutterDelta).toBeLessThanOrEqual(1) expect(rail.spaceSurfaceCenter).toBeCloseTo(rail.sidebarWidth / 2, 0) expect(rail.spaceBranchLeftInset).toBeGreaterThanOrEqual(rail.projectGroupLeftInset) @@ -256,14 +272,17 @@ test.describe('Workspace Canvas - Sidebar Row Geometry', () => { expect(rail.spaceBranchGap).toBeLessThanOrEqual(3) expect(rail.spaceBranchTopGap).toBeGreaterThanOrEqual(2) expect(rail.spaceBranchTopGap).toBeLessThanOrEqual(4) - expect(rail.spaceBranchBottomGap).toBeGreaterThanOrEqual(2) - expect(rail.spaceBranchBottomGap).toBeLessThanOrEqual(4) + expect(rail.spaceBranchBottomGap).toBeGreaterThanOrEqual(4) + expect(rail.spaceBranchBottomGap).toBeLessThanOrEqual(6) expect(rail.agentSurfaceGutterDelta).toBeLessThanOrEqual(1) expect(rail.agentSurfaceCenter).toBeCloseTo(rail.sidebarWidth / 2, 0) expectDistanceWithin(rail.agentSurfaceWidth, rail.spaceSurfaceWidth, 1) + expectDistanceWithin(rail.agentSurfaceHeight, docked.agentSurfaceHeight, 0.5) + expectDistanceWithin(rail.agentSurfaceHeight, rail.agentSurfaceWidth, 0.5) expectDistanceWithin(rail.projectIconCenter, rail.sidebarWidth / 2, 1) expectDistanceWithin(rail.pinButtonCenter, rail.sidebarWidth / 2, 1) expectDistanceWithin(rail.spaceToggleCenter, rail.sidebarWidth / 2, 1) + expectDistanceWithin(rail.spaceToggleIconCenter, rail.sidebarWidth / 2, 1) expectDistanceWithin(rail.spaceRailIconCenter, rail.sidebarWidth / 2, 1) expectDistanceWithin(rail.agentProviderCenter, rail.sidebarWidth / 2, 1) } finally { From 78ca164a6c2ebf03645df63a6575cd2ff1e17257 Mon Sep 17 00:00:00 2001 From: deadwavewave Date: Tue, 7 Jul 2026 21:23:13 +0800 Subject: [PATCH 5/8] fix: stabilize sidebar surface anchors --- .../renderer/styles/workspace-agent-item.css | 4 ++-- .../styles/workspace-sidebar.rail.css | 2 -- .../styles/workspace-sidebar.surface.css | 4 ++-- ...p-header.primary-sidebar-animation.spec.ts | 8 ++++++- .../app-header.primary-sidebar-toggle.spec.ts | 4 ++-- .../workspace-canvas.sidebar-spacing.spec.ts | 22 ++++++++++--------- 6 files changed, 25 insertions(+), 19 deletions(-) diff --git a/src/app/renderer/styles/workspace-agent-item.css b/src/app/renderer/styles/workspace-agent-item.css index 375fb26ac..694c7423b 100644 --- a/src/app/renderer/styles/workspace-agent-item.css +++ b/src/app/renderer/styles/workspace-agent-item.css @@ -82,8 +82,8 @@ button.workspace-agent-item:focus-visible { --workspace-agent-item-background: transparent; --workspace-agent-item-border-color: transparent; --workspace-agent-item-surface-shadow: none; - --workspace-agent-item-surface-left: 0px; - --workspace-agent-item-surface-width: calc(100% - 2px); + --workspace-agent-item-surface-left: -2px; + --workspace-agent-item-surface-width: 100%; --workspace-agent-item-surface-height: var(--workspace-sidebar-tree-surface-size, 24px); position: relative; flex-direction: row; diff --git a/src/app/renderer/styles/workspace-sidebar.rail.css b/src/app/renderer/styles/workspace-sidebar.rail.css index fd92708de..9aa1e6653 100644 --- a/src/app/renderer/styles/workspace-sidebar.rail.css +++ b/src/app/renderer/styles/workspace-sidebar.rail.css @@ -161,7 +161,6 @@ } .workspace-sidebar--rail .workspace-space-item { - --workspace-space-item-surface-left: -2px; --workspace-space-item-surface-width: 24px; --workspace-space-item-surface-height: var(--workspace-sidebar-tree-surface-size); } @@ -180,7 +179,6 @@ } .workspace-sidebar--rail .workspace-agent-item--sidebar { - --workspace-agent-item-surface-left: -2px; --workspace-agent-item-surface-width: 24px; --workspace-agent-item-surface-height: var(--workspace-sidebar-tree-surface-size); } diff --git a/src/app/renderer/styles/workspace-sidebar.surface.css b/src/app/renderer/styles/workspace-sidebar.surface.css index 1e6597fff..de98a65fa 100644 --- a/src/app/renderer/styles/workspace-sidebar.surface.css +++ b/src/app/renderer/styles/workspace-sidebar.surface.css @@ -1,8 +1,8 @@ .workspace-space-item { --workspace-space-item-background: transparent; --workspace-space-item-border-color: transparent; - --workspace-space-item-surface-left: 0px; - --workspace-space-item-surface-width: calc(100% - 2px); + --workspace-space-item-surface-left: -2px; + --workspace-space-item-surface-width: 100%; --workspace-space-item-surface-height: var(--workspace-sidebar-tree-surface-size); background: transparent; border-color: transparent; diff --git a/tests/e2e/app-header.primary-sidebar-animation.spec.ts b/tests/e2e/app-header.primary-sidebar-animation.spec.ts index b5bfb83b2..b5858e1a5 100644 --- a/tests/e2e/app-header.primary-sidebar-animation.spec.ts +++ b/tests/e2e/app-header.primary-sidebar-animation.spec.ts @@ -83,6 +83,7 @@ const expectContinuousSidebarAnimation = ( ) expectClose(firstTransition.spaceItemViewportLeft, result.before.spaceItemViewportLeft, 1) expectClose(firstTransition.spaceItemViewportTop, result.before.spaceItemViewportTop, 1) + expectClose(firstTransition.spaceRailSurfaceLeft, result.before.spaceRailSurfaceLeft, 1) expectStableRange(transitionSamples, sample => sample.paddingLeft, 0.1) expectStableRange(transitionSamples, sample => sample.pinButtonViewportCenterX) expectStableRange(transitionSamples, sample => sample.pinButtonViewportCenterY) @@ -92,6 +93,7 @@ const expectContinuousSidebarAnimation = ( expectStableRange(transitionSamples, sample => sample.spaceRailIconViewportCenterY) expectStableRange(transitionSamples, sample => sample.spaceItemViewportLeft) expectStableRange(transitionSamples, sample => sample.spaceItemViewportTop) + expectStableRange(transitionSamples, sample => sample.spaceRailSurfaceLeft) expectStableRange(transitionSamples, sample => sample.spaceNameViewportLeft) expect( transitionSamples @@ -145,6 +147,7 @@ const expectContinuousSidebarAnimation = ( ) expectClose(finalSample.spaceItemViewportLeft, lastTransition.spaceItemViewportLeft, 1) expectClose(finalSample.spaceItemViewportTop, lastTransition.spaceItemViewportTop, 1) + expectClose(finalSample.spaceRailSurfaceLeft, lastTransition.spaceRailSurfaceLeft, 1) expect(finalSample.projectNameVisibleWidth).toBeLessThanOrEqual(1) expect(finalSample.projectNameOpacity).toBeLessThanOrEqual(0.1) expect(finalSample.projectToggleVisibleWidth).toBeLessThanOrEqual(1) @@ -219,6 +222,9 @@ const expectContinuousSidebarAnimation = ( sample.spaceRailSurfaceWidth < sample.spaceItemWidth - 2, ), ).toBe(true) + if (finalSample) { + expectClose(finalSample.spaceRailSurfaceLeft, lastTransition.spaceRailSurfaceLeft, 1) + } } test.describe('Primary Sidebar Animation', () => { @@ -299,7 +305,7 @@ test.describe('Primary Sidebar Animation', () => { expect(expandedFinal.spaceRailSurfaceOpacity).toBeGreaterThanOrEqual(0.95) expect(expandedFinal.spaceRailSurfaceWidth).toBeGreaterThan(100) expectClose( - expandedFinal.spaceRailSurfaceLeftFromSidebarLeft, + expandedFinal.spaceRailSurfaceLeftFromSidebarLeft + 2, expandedFinal.spaceRailSurfaceRightInset, 1, ) diff --git a/tests/e2e/app-header.primary-sidebar-toggle.spec.ts b/tests/e2e/app-header.primary-sidebar-toggle.spec.ts index 8b68a8a83..9523c98f9 100644 --- a/tests/e2e/app-header.primary-sidebar-toggle.spec.ts +++ b/tests/e2e/app-header.primary-sidebar-toggle.spec.ts @@ -406,8 +406,8 @@ test.describe('Primary Sidebar Pin', () => { expect(peekVisuals.inactiveBranchBackground).toBe(railVisuals.inactiveBranchBackground) expect(railVisuals.inactiveBranchGap).toBeGreaterThanOrEqual(2) expect(railVisuals.inactiveBranchGap).toBeLessThanOrEqual(3) - expect(peekVisuals.inactiveBranchGap).toBeGreaterThanOrEqual(4) - expect(peekVisuals.inactiveBranchGap).toBeLessThanOrEqual(5) + expect(peekVisuals.inactiveBranchGap).toBeGreaterThanOrEqual(2) + expect(peekVisuals.inactiveBranchGap).toBeLessThanOrEqual(3) expect(railVisuals.defaultBranchBackground).not.toBe('rgba(0, 0, 0, 0)') expect(peekVisuals.defaultBranchBackground).toBe(railVisuals.defaultBranchBackground) expect( diff --git a/tests/e2e/workspace-canvas.sidebar-spacing.spec.ts b/tests/e2e/workspace-canvas.sidebar-spacing.spec.ts index f47dc4ff9..548372dfc 100644 --- a/tests/e2e/workspace-canvas.sidebar-spacing.spec.ts +++ b/tests/e2e/workspace-canvas.sidebar-spacing.spec.ts @@ -235,23 +235,23 @@ test.describe('Workspace Canvas - Sidebar Row Geometry', () => { expectDistanceWithin(docked.projectGroupLeftInset, docked.projectIconCenter / 2, 4) expect(docked.projectContentTopInset).toBeGreaterThanOrEqual(5) expect(docked.projectContentTopInset).toBeLessThanOrEqual(7) - expect(docked.spaceSurfaceProjectLeftGap).toBeGreaterThanOrEqual(5) - expect(docked.spaceSurfaceProjectLeftGap).toBeLessThanOrEqual(7) + expect(docked.spaceSurfaceProjectLeftGap).toBeGreaterThanOrEqual(3) + expect(docked.spaceSurfaceProjectLeftGap).toBeLessThanOrEqual(5) expect(docked.spaceSurfaceProjectRightGap).toBeGreaterThanOrEqual(5) expect(docked.spaceSurfaceProjectRightGap).toBeLessThanOrEqual(7) - expect(docked.spaceSurfaceGutterDelta).toBeLessThanOrEqual(1) - expect(docked.spaceSurfaceCenter).toBeCloseTo(docked.sidebarWidth / 2, 0) - expect(docked.agentSurfaceProjectLeftGap).toBeGreaterThanOrEqual(5) - expect(docked.agentSurfaceProjectLeftGap).toBeLessThanOrEqual(7) + expect(docked.spaceSurfaceGutterDelta).toBeLessThanOrEqual(2) + expectDistanceWithin(docked.spaceSurfaceCenter, docked.sidebarWidth / 2, 1) + expect(docked.agentSurfaceProjectLeftGap).toBeGreaterThanOrEqual(3) + expect(docked.agentSurfaceProjectLeftGap).toBeLessThanOrEqual(5) expect(docked.agentSurfaceProjectRightGap).toBeGreaterThanOrEqual(5) expect(docked.agentSurfaceProjectRightGap).toBeLessThanOrEqual(7) - expect(docked.agentSurfaceGutterDelta).toBeLessThanOrEqual(1) - expect(docked.agentSurfaceCenter).toBeCloseTo(docked.sidebarWidth / 2, 0) + expect(docked.agentSurfaceGutterDelta).toBeLessThanOrEqual(2) + expectDistanceWithin(docked.agentSurfaceCenter, docked.sidebarWidth / 2, 1) expectDistanceWithin(docked.agentSurfaceWidth, docked.spaceSurfaceWidth, 1) expectDistanceWithin(docked.agentSurfaceHeight, docked.spaceSurfaceHeight, 0.5) expect(docked.spaceBranchLeftInset).toBeGreaterThanOrEqual(docked.projectGroupLeftInset) - expect(docked.spaceBranchGap).toBeGreaterThanOrEqual(4) - expect(docked.spaceBranchGap).toBeLessThanOrEqual(5) + expect(docked.spaceBranchGap).toBeGreaterThanOrEqual(2) + expect(docked.spaceBranchGap).toBeLessThanOrEqual(3) expect(docked.spaceBranchTopGap).toBeGreaterThanOrEqual(2) expect(docked.spaceBranchTopGap).toBeLessThanOrEqual(4) expect(docked.spaceBranchBottomGap).toBeGreaterThanOrEqual(4) @@ -263,6 +263,7 @@ test.describe('Workspace Canvas - Sidebar Row Geometry', () => { expectDistanceWithin(rail.sidebarWidth, 52, 1) expectDistanceWithin(rail.spaceSurfaceWidth, 24, 1) + expectDistanceWithin(rail.spaceSurfaceLeftInset, docked.spaceSurfaceLeftInset, 1) expectDistanceWithin(rail.spaceSurfaceHeight, docked.spaceSurfaceHeight, 0.5) expectDistanceWithin(rail.spaceSurfaceHeight, rail.spaceSurfaceWidth, 0.5) expect(rail.spaceSurfaceGutterDelta).toBeLessThanOrEqual(1) @@ -277,6 +278,7 @@ test.describe('Workspace Canvas - Sidebar Row Geometry', () => { expect(rail.agentSurfaceGutterDelta).toBeLessThanOrEqual(1) expect(rail.agentSurfaceCenter).toBeCloseTo(rail.sidebarWidth / 2, 0) expectDistanceWithin(rail.agentSurfaceWidth, rail.spaceSurfaceWidth, 1) + expectDistanceWithin(rail.agentSurfaceLeftInset, docked.agentSurfaceLeftInset, 1) expectDistanceWithin(rail.agentSurfaceHeight, docked.agentSurfaceHeight, 0.5) expectDistanceWithin(rail.agentSurfaceHeight, rail.agentSurfaceWidth, 0.5) expectDistanceWithin(rail.projectIconCenter, rail.sidebarWidth / 2, 1) From e9dc2c6196eb6e4a3cd7ba904ccb27ef2d2a5bca Mon Sep 17 00:00:00 2001 From: deadwavewave Date: Tue, 7 Jul 2026 21:41:48 +0800 Subject: [PATCH 6/8] test: stabilize sidebar animation sampling --- ...eader.primary-sidebar-animation.helpers.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/e2e/app-header.primary-sidebar-animation.helpers.ts b/tests/e2e/app-header.primary-sidebar-animation.helpers.ts index 669fa2243..fdabb5294 100644 --- a/tests/e2e/app-header.primary-sidebar-animation.helpers.ts +++ b/tests/e2e/app-header.primary-sidebar-animation.helpers.ts @@ -201,7 +201,38 @@ export const sampleSidebarToggle = async ( const startClassName = sidebar.className const before = readSample() + const transitionStart = new Promise(resolve => { + const resolveIfStarted = (): boolean => { + if ((sidebar.dataset.coveSidebarTransition ?? 'idle') !== 'idle') { + resolve() + return true + } + return false + } + + if (resolveIfStarted()) { + return + } + + const observer = new MutationObserver(() => { + if (!resolveIfStarted()) { + return + } + observer.disconnect() + }) + + observer.observe(sidebar, { + attributes: true, + attributeFilter: ['class', 'data-cove-sidebar-transition'], + }) + + window.setTimeout(() => { + observer.disconnect() + resolve() + }, 120) + }) toggleButton.click() + await transitionStart return await new Promise(resolve => { const sampleCount = 30 From 2a1d30a345dccbb4a38f615b3d2f3d59ddd76221 Mon Sep 17 00:00:00 2001 From: deadwavewave Date: Wed, 8 Jul 2026 11:05:23 +0800 Subject: [PATCH 7/8] fix: stabilize sidebar sortable drag --- .../shell/components/Sidebar.scroll.spec.tsx | 2 + .../shell/components/Sidebar.spec.tsx | 6 +- src/app/renderer/shell/components/Sidebar.tsx | 80 +++++- .../shell/components/SidebarAgentItems.tsx | 127 +++++--- .../shell/components/SidebarDnd.spec.ts | 60 ++++ .../renderer/shell/components/SidebarDnd.ts | 64 +++++ .../shell/components/SidebarWorkspaceItem.tsx | 157 +++++++--- src/app/renderer/styles.css | 1 + .../renderer/styles/workspace-agent-item.css | 18 ++ .../renderer/styles/workspace-sidebar.dnd.css | 16 ++ .../styles/workspace-sidebar.surface.css | 14 + ...rkspace-canvas.sidebar-drag-motion.spec.ts | 272 ++++++++++++++++++ 12 files changed, 728 insertions(+), 89 deletions(-) create mode 100644 src/app/renderer/shell/components/SidebarDnd.spec.ts create mode 100644 src/app/renderer/styles/workspace-sidebar.dnd.css create mode 100644 tests/e2e/workspace-canvas.sidebar-drag-motion.spec.ts diff --git a/src/app/renderer/shell/components/Sidebar.scroll.spec.tsx b/src/app/renderer/shell/components/Sidebar.scroll.spec.tsx index d6798c91f..b00dc11da 100644 --- a/src/app/renderer/shell/components/Sidebar.scroll.spec.tsx +++ b/src/app/renderer/shell/components/Sidebar.scroll.spec.tsx @@ -11,8 +11,10 @@ import { Sidebar } from './Sidebar' vi.mock('@dnd-kit/core', () => ({ DndContext: ({ children }: { children: ReactNode }) => <>{children}, DragOverlay: ({ children }: { children: ReactNode }) => <>{children}, + MeasuringStrategy: { Always: 0 }, PointerSensor: vi.fn(), closestCenter: vi.fn(), + pointerWithin: vi.fn(() => []), useSensor: vi.fn((_sensor: unknown, options?: unknown) => ({ options })), useSensors: vi.fn((...sensors: unknown[]) => sensors), })) diff --git a/src/app/renderer/shell/components/Sidebar.spec.tsx b/src/app/renderer/shell/components/Sidebar.spec.tsx index 9c8f44e99..013283b85 100644 --- a/src/app/renderer/shell/components/Sidebar.spec.tsx +++ b/src/app/renderer/shell/components/Sidebar.spec.tsx @@ -7,7 +7,6 @@ import { 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, @@ -15,7 +14,6 @@ const dndState = vi.hoisted(() => ({ | ((event: { active: { id: string }; over: { id: string } | null }) => void) | null, })) - vi.mock('@dnd-kit/core', () => ({ DndContext: ({ children, @@ -31,12 +29,13 @@ vi.mock('@dnd-kit/core', () => ({ return <>{children} }, DragOverlay: ({ children }: { children: React.ReactNode }) => <>{children}, + MeasuringStrategy: { Always: 0 }, PointerSensor: vi.fn(), closestCenter: vi.fn(), + pointerWithin: 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 }) => ({ @@ -49,7 +48,6 @@ vi.mock('@dnd-kit/sortable', () => ({ }), verticalListSortingStrategy: vi.fn(), })) - vi.mock('@dnd-kit/utilities', () => ({ CSS: { Transform: { diff --git a/src/app/renderer/shell/components/Sidebar.tsx b/src/app/renderer/shell/components/Sidebar.tsx index 0d37f9b99..1da8b8e54 100644 --- a/src/app/renderer/shell/components/Sidebar.tsx +++ b/src/app/renderer/shell/components/Sidebar.tsx @@ -2,8 +2,8 @@ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from import { DndContext, DragOverlay, + MeasuringStrategy, PointerSensor, - closestCenter, useSensor, useSensors, type DragEndEvent, @@ -15,8 +15,20 @@ import type { PersistNotice, ProjectContextMenuState } from '../types' import type { WorkspaceState } from '@contexts/workspace/presentation/renderer/types' import { SidebarToolbar } from './SidebarToolbar' import { buildSidebarProjectTree } from '../utils/sidebarTree' -import { SortableWorkspaceItem, WorkspaceItemOverlay } from './SidebarWorkspaceItem' -import { readSidebarDragItemData, type SidebarDragItemData } from './SidebarDnd' +import { SidebarAgentItemOverlay } from './SidebarAgentItems' +import { + getTreeChildGroups, + SortableWorkspaceItem, + SpaceItemOverlay, + WorkspaceItemOverlay, +} from './SidebarWorkspaceItem' +import { + canReorderSidebarDragItems, + readSidebarDragItemData, + sidebarCollisionDetection, + sidebarDropAnimation, + type SidebarDragItemData, +} from './SidebarDnd' import { useSidebarListScroll } from './useSidebarListScroll' export type SidebarVariant = 'docked' | 'rail' | 'peek' @@ -51,6 +63,11 @@ type SidebarProps = { onPointerLeave?: React.PointerEventHandler } +type ActiveSidebarDragItem = { + id: string + data: SidebarDragItemData +} + export function Sidebar({ variant = 'docked', isPinned = variant !== 'rail', @@ -76,7 +93,7 @@ export function Sidebar({ activationConstraint: { distance: 8 }, }), ) - const [activeId, setActiveId] = useState(null) + const [activeDragItem, setActiveDragItem] = useState(null) const [collapsedWorkspaceIds, setCollapsedWorkspaceIds] = useState>({}) const [collapsedSpaceGroupIds, setCollapsedSpaceGroupIds] = useState>({}) const [sidebarTransition, setSidebarTransition] = useState(null) @@ -107,7 +124,7 @@ export function Sidebar({ const id = String(event.active.id) const activeData = (event.active as { data?: { current?: unknown } }).data?.current const itemData = resolveDragData(id, activeData) - setActiveId(itemData?.kind === 'project' ? itemData.workspaceId : id) + setActiveDragItem(itemData ? { id, data: itemData } : null) }, [resolveDragData], ) @@ -117,7 +134,7 @@ export function Sidebar({ const nextActiveId = String(event.active.id) const nextOverId = event.over?.id - setActiveId(null) + setActiveDragItem(null) if (nextOverId === null || nextOverId === undefined) { return @@ -132,7 +149,7 @@ export function Sidebar({ const overEventData = (event.over as { data?: { current?: unknown } } | null)?.data?.current const activeData = resolveDragData(nextActiveId, activeEventData) const overData = resolveDragData(overId, overEventData) - if (!activeData || !overData || activeData.kind !== overData.kind) { + if (!activeData || !overData || !canReorderSidebarDragItems(activeData, overData)) { return } @@ -181,8 +198,32 @@ export function Sidebar({ })) }, []) - const activeTree = - activeId === null ? null : (trees.find(tree => tree.workspace.id === activeId) ?? null) + const activeDragData = activeDragItem?.data ?? null + const activeDragWorkspaceId = activeDragData?.workspaceId ?? null + const activeDragTree = + activeDragWorkspaceId !== null + ? (trees.find(tree => tree.workspace.id === activeDragWorkspaceId) ?? null) + : null + const activeTree = activeDragData?.kind === 'project' ? activeDragTree : null + const activeSpaceGroup = + activeDragData?.kind === 'space' + ? (activeDragTree?.spaceGroups.find(group => group.id === activeDragData.spaceId) ?? null) + : null + const activeAgentGroup = + activeDragData?.kind === 'agent' + ? activeDragTree + ? (getTreeChildGroups(activeDragTree).find(group => group.id === activeDragData.groupId) ?? + null) + : null + : null + const activeAgentItem = + activeDragData?.kind === 'agent' + ? (activeAgentGroup?.agents.find(agent => agent.node.id === activeDragData.nodeId) ?? null) + : null + const activeSpaceGroupKey = + activeSpaceGroup && activeDragWorkspaceId + ? `${activeDragWorkspaceId}:${activeSpaceGroup.id}` + : null useLayoutEffect(() => { const previousVariant = previousVariantRef.current if (previousVariant === variant) { @@ -259,9 +300,14 @@ export function Sidebar({ ) : ( setActiveId(null)} + onDragCancel={() => setActiveDragItem(null)} onDragEnd={handleDragEnd} > - + {activeTree ? : null} + {activeSpaceGroup ? ( + + ) : null} + {activeAgentItem ? : null} )} diff --git a/src/app/renderer/shell/components/SidebarAgentItems.tsx b/src/app/renderer/shell/components/SidebarAgentItems.tsx index 2921de749..ad58cbbea 100644 --- a/src/app/renderer/shell/components/SidebarAgentItems.tsx +++ b/src/app/renderer/shell/components/SidebarAgentItems.tsx @@ -7,7 +7,58 @@ import { AgentProviderIcon } from '@app/renderer/components/AgentProviderIcon' import { toRelativeTime } from '../utils/format' import type { SidebarAgentItemModel } from '../utils/sidebarAgents' import type { ProjectContextMenuState } from '../types' -import { createAgentSortableId } from './SidebarDnd' +import { createAgentSortableId, sidebarSortableTransition } from './SidebarDnd' + +function SidebarAgentItemContent({ + item, + showOwningSpacePill, +}: { + 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 ( + + + + {provider ? ( + + ) : null} + {sidebarAgentStatusText} + + + {displayTitle} + {showOwningSpacePill && owningSpace ? ( + + {owningSpace.name} + + ) : null} + + + + {sidebarAgentStatusText} + + + ) +} function SidebarAgentItem({ workspaceId, @@ -35,6 +86,7 @@ function SidebarAgentItem({ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: sortableId, disabled: groupId === null, + transition: sidebarSortableTransition, data: groupId ? { kind: 'agent', @@ -55,7 +107,9 @@ function SidebarAgentItem({ ref={setNodeRef} style={style} type="button" - className="workspace-agent-item workspace-agent-item--nested workspace-agent-item--sidebar" + className={`workspace-agent-item workspace-agent-item--nested workspace-agent-item--sidebar${ + isDragging ? ' workspace-agent-item--dragging' : '' + }`} data-testid={`workspace-agent-item-${workspaceId}-${node.id}`} data-cove-label-color={effectiveLabelColor ?? undefined} title={[ @@ -86,38 +140,7 @@ function SidebarAgentItem({ {...(groupId ? attributes : {})} {...(groupId ? listeners : {})} > - - - - {provider ? ( - - ) : null} - {sidebarAgentStatusText} - - - {displayTitle} - {showOwningSpacePill && owningSpace ? ( - - {owningSpace.name} - - ) : null} - - - - {sidebarAgentStatusText} - - + ) } @@ -170,3 +193,39 @@ export function SidebarAgentItems({ ) } + +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 index 544c4fe3e..8ca5991b1 100644 --- a/src/app/renderer/shell/components/SidebarDnd.ts +++ b/src/app/renderer/shell/components/SidebarDnd.ts @@ -1,3 +1,5 @@ +import { closestCenter, pointerWithin, type CollisionDetection } from '@dnd-kit/core' + export type SidebarDragItemData = | { kind: 'project' @@ -27,6 +29,68 @@ export function createAgentSortableId( 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 diff --git a/src/app/renderer/shell/components/SidebarWorkspaceItem.tsx b/src/app/renderer/shell/components/SidebarWorkspaceItem.tsx index 9f78b435c..73aa273e7 100644 --- a/src/app/renderer/shell/components/SidebarWorkspaceItem.tsx +++ b/src/app/renderer/shell/components/SidebarWorkspaceItem.tsx @@ -5,7 +5,7 @@ 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 } from './SidebarDnd' +import { createSpaceSortableId, sidebarSortableTransition } from './SidebarDnd' import { SidebarDisclosureIcon } from './SidebarDisclosureIcon' import { ProjectIcon } from './ProjectIcon' @@ -22,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 } @@ -103,6 +103,7 @@ function SpaceGroup({ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: sortableId, disabled: isProjectRoot, + transition: sidebarSortableTransition, data: !isProjectRoot ? { kind: 'space', @@ -118,7 +119,9 @@ function SpaceGroup({ } 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 (
-
{hasAgents && isExpanded ? ( @@ -244,6 +218,7 @@ 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, @@ -265,7 +240,9 @@ export function SortableWorkspaceItem({ ref={setNodeRef} style={style} {...attributes} - className={`workspace-item-group${isActive ? ' workspace-item-group--active' : ''}`} + className={`workspace-item-group${isActive ? ' workspace-item-group--active' : ''}${ + isDragging ? ' workspace-item-group--dragging' : '' + }`} >
void +}): React.JSX.Element { + const { t } = useTranslation() + + return ( + <> +