diff --git a/ui/goose2/scripts/check-file-sizes.mjs b/ui/goose2/scripts/check-file-sizes.mjs index 0c45b763..03869a5e 100644 --- a/ui/goose2/scripts/check-file-sizes.mjs +++ b/ui/goose2/scripts/check-file-sizes.mjs @@ -6,9 +6,9 @@ const DEFAULT_LIMIT = 500; // Add narrowly scoped exceptions here with justification const EXCEPTIONS = { "src/features/sidebar/ui/SidebarProjectsSection.tsx": { - limit: 560, + limit: 570, justification: - "Drag-and-drop handlers plus activeProjectId highlight for draft-in-project sessions.", + "Drag-and-drop handlers for session-to-project moves and project reorder, plus activeProjectId highlight.", }, "src/features/chat/ui/ChatView.tsx": { limit: 535, @@ -50,6 +50,11 @@ const EXCEPTIONS = { justification: "ACP-backed session overlay persistence, draft migration, and sidebar-facing session merge logic live together for now.", }, + "src-tauri/src/commands/projects.rs": { + limit: 520, + justification: + "Project CRUD plus reorder_projects command for sidebar drag-and-drop ordering.", + }, "src-tauri/src/services/acp/manager/dispatcher.rs": { limit: 540, justification: diff --git a/ui/goose2/src-tauri/src/commands/projects.rs b/ui/goose2/src-tauri/src/commands/projects.rs index 64ff0ca7..31c129c1 100644 --- a/ui/goose2/src-tauri/src/commands/projects.rs +++ b/ui/goose2/src-tauri/src/commands/projects.rs @@ -384,6 +384,20 @@ pub fn delete_project(id: String) -> Result<(), String> { Ok(()) } +#[tauri::command] +pub fn reorder_projects(order: Vec<(String, i32)>) -> Result<(), String> { + for (id, new_order) in order { + let (dir, mut stored) = find_project_by_id(&id)?; + stored.order = new_order; + let project_path = dir.join("project.json"); + let json = serde_json::to_string_pretty(&stored) + .map_err(|e| format!("Failed to serialize project: {}", e))?; + fs::write(&project_path, json) + .map_err(|e| format!("Failed to write project.json: {}", e))?; + } + Ok(()) +} + #[tauri::command] pub fn get_project(id: String) -> Result { let (dir, info) = find_project_by_id(&id)?; diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index 5a474178..ca82c7c2 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -73,6 +73,7 @@ pub fn run() { commands::projects::update_project, commands::projects::delete_project, commands::projects::get_project, + commands::projects::reorder_projects, commands::projects::list_archived_projects, commands::projects::archive_project, commands::projects::restore_project, diff --git a/ui/goose2/src-tauri/tauri.conf.json b/ui/goose2/src-tauri/tauri.conf.json index 2ea07b55..f958b67c 100644 --- a/ui/goose2/src-tauri/tauri.conf.json +++ b/ui/goose2/src-tauri/tauri.conf.json @@ -22,7 +22,7 @@ "visible": false, "titleBarStyle": "Overlay", "hiddenTitle": true, - "dragDropEnabled": true, + "dragDropEnabled": false, "trafficLightPosition": { "x": 12, "y": 22 } } ], diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index b7b9d8ca..28624ff7 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -553,6 +553,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { onArchiveChat={handleArchiveChat} onRenameChat={handleRenameChat} onMoveToProject={handleMoveToProject} + onReorderProject={projectStore.reorderProjects} onSelectSession={handleSelectSession} onSelectSearchResult={handleSelectSearchResult} activeView={activeView} diff --git a/ui/goose2/src/features/projects/api/projects.ts b/ui/goose2/src/features/projects/api/projects.ts index 57955ad5..405ca70a 100644 --- a/ui/goose2/src/features/projects/api/projects.ts +++ b/ui/goose2/src/features/projects/api/projects.ts @@ -88,6 +88,12 @@ export async function archiveProject(id: string): Promise { return invoke("archive_project", { id }); } +export async function reorderProjects( + order: [string, number][], +): Promise { + return invoke("reorder_projects", { order }); +} + export async function restoreProject(id: string): Promise { return invoke("restore_project", { id }); } diff --git a/ui/goose2/src/features/projects/stores/projectStore.ts b/ui/goose2/src/features/projects/stores/projectStore.ts index f9af8285..931b7a02 100644 --- a/ui/goose2/src/features/projects/stores/projectStore.ts +++ b/ui/goose2/src/features/projects/stores/projectStore.ts @@ -4,6 +4,7 @@ import { createProject, updateProject, deleteProject, + reorderProjects as apiReorderProjects, type ProjectInfo, } from "../api/projects"; @@ -64,6 +65,7 @@ interface ProjectState { useWorktrees: boolean, ) => Promise; removeProject: (id: string) => Promise; + reorderProjects: (fromId: string, toId: string) => void; setActiveProject: (id: string | null) => void; getActiveProject: () => ProjectInfo | null; } @@ -152,6 +154,28 @@ export const useProjectStore = create((set, get) => ({ persistProjects(get().projects); }, + reorderProjects: (fromId, toId) => { + set((state) => { + const projects = [...state.projects]; + const fromIndex = projects.findIndex((p) => p.id === fromId); + const toIndex = projects.findIndex((p) => p.id === toId); + if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) + return state; + const [moved] = projects.splice(fromIndex, 1); + // When dragging down, removing the source shifts the target index + const insertAt = fromIndex < toIndex ? toIndex - 1 : toIndex; + projects.splice(insertAt, 0, moved); + // Update order fields so views sorting by .order stay consistent + for (let i = 0; i < projects.length; i++) { + projects[i] = { ...projects[i], order: i }; + } + return { projects }; + }); + const projects = get().projects; + persistProjects(projects); + void apiReorderProjects(projects.map((p, i) => [p.id, i])); + }, + setActiveProject: (id) => set({ activeProjectId: id }), getActiveProject: () => { diff --git a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx index f1d4f0ae..8cba65c4 100644 --- a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx +++ b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx @@ -34,6 +34,7 @@ interface SidebarProps { onArchiveChat?: (sessionId: string) => void; onRenameChat?: (sessionId: string, nextTitle: string) => void; onMoveToProject?: (sessionId: string, projectId: string | null) => void; + onReorderProject?: (fromId: string, toId: string) => void; onNavigate?: (view: AppView) => void; onSelectSession?: (sessionId: string) => void; onSelectSearchResult?: ( @@ -62,6 +63,7 @@ export function Sidebar({ onArchiveChat, onRenameChat, onMoveToProject, + onReorderProject, onNavigate, onSelectSession, onSelectSearchResult, @@ -491,8 +493,6 @@ export function Sidebar({ {!collapsed && ( <> -
- {sidebarSearch.submittedQuery ? (
{sidebarSearch.error && ( @@ -550,6 +550,7 @@ export function Sidebar({ onArchiveChat={onArchiveChat} onRenameChat={onRenameChat} onMoveToProject={onMoveToProject} + onReorderProject={onReorderProject} onItemMouseEnter={onItemMouseEnter} activeSessionRefCallback={activeSessionRefCallback} activeProjectRefCallback={activeProjectRefCallback} diff --git a/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx b/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx index 47e223e6..be880b98 100644 --- a/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx +++ b/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx @@ -50,6 +50,7 @@ interface SidebarProjectsSectionProps { onArchiveChat?: (sessionId: string) => void; onRenameChat?: (sessionId: string, nextTitle: string) => void; onMoveToProject?: (sessionId: string, projectId: string | null) => void; + onReorderProject?: (fromId: string, toId: string) => void; onItemMouseEnter?: (e: React.MouseEvent) => void; activeSessionRefCallback?: (el: HTMLElement | null) => void; activeProjectRefCallback?: (el: HTMLElement | null) => void; @@ -117,9 +118,10 @@ function ProjectSection({ const sessionId = e.dataTransfer.getData("text/x-session-id"); if (sessionId) { onMoveToProject?.(sessionId, project.id); + if (!isExpanded) toggleProject(project.id); } }, - [onMoveToProject, project.id], + [onMoveToProject, project.id, isExpanded, toggleProject], ); const visibleChats = projectChats.slice( 0, @@ -277,15 +279,21 @@ export function SidebarProjectsSection({ onArchiveChat, onRenameChat, onMoveToProject, + onReorderProject, onItemMouseEnter, activeSessionRefCallback, activeProjectRefCallback, }: SidebarProjectsSectionProps) { const { t } = useTranslation(["sidebar", "common"]); const [recentsDragOver, setRecentsDragOver] = useState(false); + const [draggedProjectId, setDraggedProjectId] = useState(null); + const [dropTargetProjectId, setDropTargetProjectId] = useState( + null, + ); const handleRecentsDragOver = useCallback((e: React.DragEvent) => { - if (e.dataTransfer.types.includes("text/x-session-id")) { + const hasSession = e.dataTransfer.types.includes("text/x-session-id"); + if (hasSession) { e.preventDefault(); e.dataTransfer.dropEffect = "move"; setRecentsDragOver(true); @@ -325,7 +333,7 @@ export function SidebarProjectsSection({
{projects.map((project) => ( - + draggable + onDragStart={(e) => { + // Skip if a child chat row already initiated a session drag + if (e.dataTransfer.types.includes("text/x-session-id")) return; + e.dataTransfer.setData("text/x-project-id", project.id); + e.dataTransfer.effectAllowed = "move"; + setDraggedProjectId(project.id); + }} + onDragOver={(e) => { + if (e.dataTransfer.types.includes("text/x-project-id")) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (project.id !== draggedProjectId) { + setDropTargetProjectId(project.id); + } + } + }} + onDragLeave={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setDropTargetProjectId((prev) => + prev === project.id ? null : prev, + ); + } + }} + onDrop={(e) => { + const fromId = e.dataTransfer.getData("text/x-project-id"); + if (fromId && fromId !== project.id) { + e.preventDefault(); + e.stopPropagation(); + onReorderProject?.(fromId, project.id); + } + setDraggedProjectId(null); + setDropTargetProjectId(null); + }} + onDragEnd={() => { + setDraggedProjectId(null); + setDropTargetProjectId(null); + }} + className={cn( + "relative", + draggedProjectId === project.id && "opacity-40", + )} + > + {dropTargetProjectId === project.id && + draggedProjectId !== project.id && ( +
+ )} + +
))}
)} @@ -409,16 +467,10 @@ export function SidebarProjectsSection({ onDragLeave={handleRecentsDragLeave} onDrop={handleRecentsDrop} > -