Sidebar polish: search copy, dividers, project reorder, fix DnD (#8568)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
tulsi
2026-04-15 16:26:03 -07:00
committed by GitHub
parent 0ec73c80f3
commit 8b2c6e0129
13 changed files with 142 additions and 38 deletions
+7 -2
View File
@@ -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:
@@ -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<ProjectInfo, String> {
let (dir, info) = find_project_by_id(&id)?;
+1
View File
@@ -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,
+1 -1
View File
@@ -22,7 +22,7 @@
"visible": false,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"dragDropEnabled": true,
"dragDropEnabled": false,
"trafficLightPosition": { "x": 12, "y": 22 }
}
],
+1
View File
@@ -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}
@@ -88,6 +88,12 @@ export async function archiveProject(id: string): Promise<void> {
return invoke("archive_project", { id });
}
export async function reorderProjects(
order: [string, number][],
): Promise<void> {
return invoke("reorder_projects", { order });
}
export async function restoreProject(id: string): Promise<void> {
return invoke("restore_project", { id });
}
@@ -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<ProjectInfo>;
removeProject: (id: string) => Promise<void>;
reorderProjects: (fromId: string, toId: string) => void;
setActiveProject: (id: string | null) => void;
getActiveProject: () => ProjectInfo | null;
}
@@ -152,6 +154,28 @@ export const useProjectStore = create<ProjectState>((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: () => {
@@ -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 && (
<>
<div className="relative z-10 my-2 -mx-1.5 bg-border h-px" />
{sidebarSearch.submittedQuery ? (
<div className="relative z-10 space-y-2">
{sidebarSearch.error && (
@@ -550,6 +550,7 @@ export function Sidebar({
onArchiveChat={onArchiveChat}
onRenameChat={onRenameChat}
onMoveToProject={onMoveToProject}
onReorderProject={onReorderProject}
onItemMouseEnter={onItemMouseEnter}
activeSessionRefCallback={activeSessionRefCallback}
activeProjectRefCallback={activeProjectRefCallback}
@@ -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<HTMLElement>) => 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<string | null>(null);
const [dropTargetProjectId, setDropTargetProjectId] = useState<string | null>(
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({
<div
className={cn(
"group flex items-center transition-all duration-300",
collapsed ? "px-0 pt-0 pb-1 justify-center" : "pt-2 pb-1",
collapsed ? "px-0 pt-0 pb-1 justify-center" : "pt-4 pb-1",
)}
>
<span
@@ -378,26 +386,76 @@ export function SidebarProjectsSection({
) : (
<div className="space-y-0.5">
{projects.map((project) => (
<ProjectSection
// biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop reorder target
<div
key={project.id}
project={project}
projectChats={projectSessions.byProject[project.id] ?? []}
isExpanded={expandedProjects[project.id] ?? false}
toggleProject={toggleProject}
activeSessionId={activeSessionId}
activeProjectId={activeProjectId}
onSelectSession={onSelectSession}
onNewChatInProject={onNewChatInProject}
onNavigate={onNavigate}
onEditProject={onEditProject}
onArchiveProject={onArchiveProject}
onArchiveChat={onArchiveChat}
onRenameChat={onRenameChat}
onMoveToProject={onMoveToProject}
onItemMouseEnter={onItemMouseEnter}
activeSessionRefCallback={activeSessionRefCallback}
activeProjectRefCallback={activeProjectRefCallback}
/>
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 && (
<div className="absolute top-0 left-3 right-3 h-0.5 rounded-full bg-foreground" />
)}
<ProjectSection
project={project}
projectChats={projectSessions.byProject[project.id] ?? []}
isExpanded={expandedProjects[project.id] ?? false}
toggleProject={toggleProject}
activeSessionId={activeSessionId}
activeProjectId={activeProjectId}
onSelectSession={onSelectSession}
onNewChatInProject={onNewChatInProject}
onNavigate={onNavigate}
onEditProject={onEditProject}
onArchiveProject={onArchiveProject}
onArchiveChat={onArchiveChat}
onRenameChat={onRenameChat}
onMoveToProject={onMoveToProject}
onItemMouseEnter={onItemMouseEnter}
activeSessionRefCallback={activeSessionRefCallback}
activeProjectRefCallback={activeProjectRefCallback}
/>
</div>
))}
</div>
)}
@@ -409,16 +467,10 @@ export function SidebarProjectsSection({
onDragLeave={handleRecentsDragLeave}
onDrop={handleRecentsDrop}
>
<div
className={cn(
"my-2 -mx-1.5 bg-border transition-all duration-300",
collapsed ? "w-5 mx-auto h-px" : "h-px",
)}
/>
<div
className={cn(
"relative group flex items-center transition-all duration-300",
collapsed ? "px-0 pt-0 pb-1 justify-center" : "pt-2 pb-1",
collapsed ? "px-0 pt-0 pb-1 justify-center" : "pt-4 pb-1",
)}
>
<span
@@ -19,7 +19,7 @@
"emptyTitle": "No sessions yet",
"searchArchivedPlaceholder": "Search archived sessions...",
"searchError": "Message search failed. Showing title, persona, and project matches only.",
"searchPlaceholder": "Search sessions by title, persona, project, or message content...",
"searchPlaceholder": "Search conversations",
"searching": "Searching sessions...",
"subtitle": "Browse and search past sessions",
"toggleArchived": "Archived",
@@ -18,7 +18,7 @@
},
"search": {
"error": "Message search failed. Showing metadata matches only.",
"placeholder": "Search chats by title, persona, project, or message...",
"placeholder": "Search conversations",
"searching": "Searching chats..."
},
"sections": {
@@ -19,7 +19,7 @@
"emptyTitle": "Aún no hay sesiones",
"searchArchivedPlaceholder": "Buscar sesiones archivadas...",
"searchError": "La búsqueda de mensajes falló. Mostrando solo coincidencias por título, persona y proyecto.",
"searchPlaceholder": "Busca sesiones por título, persona, proyecto o contenido del mensaje...",
"searchPlaceholder": "Buscar conversaciones",
"searching": "Buscando sesiones...",
"subtitle": "Explora y busca sesiones anteriores",
"toggleArchived": "Archivadas",
@@ -18,7 +18,7 @@
},
"search": {
"error": "La búsqueda de mensajes falló. Mostrando solo coincidencias de metadatos.",
"placeholder": "Busca chats por título, persona, proyecto o mensaje...",
"placeholder": "Buscar conversaciones",
"searching": "Buscando chats..."
},
"sections": {