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:
@@ -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)?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"visible": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"dragDropEnabled": true,
|
||||
"dragDropEnabled": false,
|
||||
"trafficLightPosition": { "x": 12, "y": 22 }
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user