feat(sidebar): reorder folder groups by drag and persist sort order

This commit is contained in:
xintaofei
2026-04-23 00:42:58 +08:00
parent 2dbdaa9c74
commit f1ce7179ea
25 changed files with 519 additions and 182 deletions

View File

@@ -589,6 +589,17 @@ pub async fn remove_folder_from_workspace(
.map_err(AppCommandError::from)
}
#[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn reorder_folders(
db: tauri::State<'_, AppDatabase>,
ids: Vec<i32>,
) -> Result<(), AppCommandError> {
folder_service::reorder_folders(&db.conn, ids)
.await
.map_err(AppCommandError::from)
}
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn create_folder_directory(path: String) -> Result<(), AppCommandError> {
std::fs::create_dir_all(&path).map_err(AppCommandError::io)

View File

@@ -16,6 +16,7 @@ pub struct Model {
pub deleted_at: Option<DateTimeUtc>,
pub is_open: bool,
pub parent_branch: Option<String>,
pub sort_order: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -0,0 +1,72 @@
use sea_orm_migration::prelude::*;
use sea_orm_migration::sea_orm::{ConnectionTrait, DbBackend, Statement};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Folder::Table)
.add_column(
ColumnDef::new(Folder::SortOrder)
.integer()
.not_null()
.default(0),
)
.to_owned(),
)
.await?;
// Backfill sort_order by current last_opened_at DESC so existing users
// see the same order after migration. We use a correlated subquery that
// works on SQLite without ROW_NUMBER/window functions.
let conn = manager.get_connection();
let sql = "UPDATE folder SET sort_order = (\
SELECT COUNT(*) FROM folder AS inner_f \
WHERE inner_f.last_opened_at > folder.last_opened_at \
OR (inner_f.last_opened_at = folder.last_opened_at AND inner_f.id < folder.id) \
) + 1";
conn.execute(Statement::from_string(DbBackend::Sqlite, sql.to_string()))
.await?;
manager
.create_index(
Index::create()
.name("idx_folder_sort_order")
.table(Folder::Table)
.col(Folder::SortOrder)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_index(
Index::drop()
.name("idx_folder_sort_order")
.table(Folder::Table)
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Folder::Table)
.drop_column(Folder::SortOrder)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum Folder {
Table,
SortOrder,
}

View File

@@ -10,6 +10,7 @@ mod m20260401_000001_chat_channel_sender_context;
mod m20260404_000001_model_provider;
mod m20260406_000001_agent_setting_model_provider;
mod m20260420_000001_opened_tabs;
mod m20260422_000001_folder_sort_order;
pub struct Migrator;
#[async_trait::async_trait]
@@ -26,6 +27,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260404_000001_model_provider::Migration),
Box::new(m20260406_000001_agent_setting_model_provider::Migration),
Box::new(m20260420_000001_opened_tabs::Migration),
Box::new(m20260422_000001_folder_sort_order::Migration),
]
}
}

View File

@@ -1,8 +1,8 @@
use chrono::Utc;
use sea_orm::DatabaseConnection;
use sea_orm::{
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter,
QueryOrder, Set,
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait,
IntoActiveModel, QueryFilter, QueryOrder, Set, Statement,
};
use crate::db::entities::folder;
@@ -34,6 +34,7 @@ fn to_detail(m: folder::Model) -> FolderDetail {
parent_branch: m.parent_branch,
default_agent_type,
last_opened_at: m.last_opened_at,
sort_order: m.sort_order,
}
}
@@ -73,6 +74,12 @@ pub async fn add_folder(
active.is_open = Set(true);
active.update(conn).await?
} else {
let max_order = folder::Entity::find()
.order_by_desc(folder::Column::SortOrder)
.one(conn)
.await?
.map(|m| m.sort_order)
.unwrap_or(0);
let active = folder::ActiveModel {
id: NotSet,
name: Set(name),
@@ -85,6 +92,7 @@ pub async fn add_folder(
updated_at: Set(now),
deleted_at: Set(None),
is_open: Set(true),
sort_order: Set(max_order + 1),
};
active.insert(conn).await?
};
@@ -170,6 +178,7 @@ pub async fn list_open_folder_details(
let rows = folder::Entity::find()
.filter(folder::Column::DeletedAt.is_null())
.filter(folder::Column::IsOpen.eq(true))
.order_by_asc(folder::Column::SortOrder)
.order_by_desc(folder::Column::LastOpenedAt)
.all(conn)
.await?;
@@ -182,9 +191,41 @@ pub async fn list_all_folder_details(
) -> Result<Vec<FolderDetail>, DbError> {
let rows = folder::Entity::find()
.filter(folder::Column::DeletedAt.is_null())
.order_by_asc(folder::Column::SortOrder)
.order_by_desc(folder::Column::LastOpenedAt)
.all(conn)
.await?;
Ok(rows.into_iter().map(to_detail).collect())
}
pub async fn reorder_folders(
conn: &DatabaseConnection,
ids: Vec<i32>,
) -> Result<(), DbError> {
if ids.is_empty() {
return Ok(());
}
let now = Utc::now();
let now_str = now.format("%Y-%m-%d %H:%M:%S %:z").to_string();
let case_expr = ids
.iter()
.enumerate()
.map(|(idx, id)| format!("WHEN {} THEN {}", id, idx + 1))
.collect::<Vec<_>>()
.join(" ");
let id_list = ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"UPDATE folder SET sort_order = CASE id {case_expr} END, updated_at = '{now_str}' WHERE id IN ({id_list})"
);
conn.execute(Statement::from_string(DbBackend::Sqlite, sql))
.await?;
Ok(())
}

View File

@@ -223,6 +223,7 @@ mod tauri_app {
folders::open_folder,
folders::open_folder_by_id,
folders::remove_folder_from_workspace,
folders::reorder_folders,
folders::add_folder_to_history,
folders::set_folder_parent_branch,
folders::remove_folder_from_history,

View File

@@ -20,6 +20,7 @@ pub struct FolderDetail {
pub parent_branch: Option<String>,
pub default_agent_type: Option<AgentType>,
pub last_opened_at: DateTime<Utc>,
pub sort_order: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -123,6 +123,23 @@ pub async fn remove_folder_from_workspace(
Ok(Json(()))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReorderFoldersParams {
pub ids: Vec<i32>,
}
pub async fn reorder_folders(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<ReorderFoldersParams>,
) -> Result<Json<()>, AppCommandError> {
let db = &state.db;
folder_service::reorder_folders(&db.conn, params.ids)
.await
.map_err(AppCommandError::from)?;
Ok(Json(()))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PathParams {

View File

@@ -106,6 +106,10 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
"/remove_folder_from_workspace",
post(handlers::folders::remove_folder_from_workspace),
)
.route(
"/reorder_folders",
post(handlers::folders::reorder_folders),
)
.route(
"/add_folder_to_history",
post(handlers::folders::add_folder_to_history),

View File

@@ -131,7 +131,10 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
<>
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="relative h-[2rem]">
<div
className="relative h-[2rem]"
data-conv-key={`${conversation.agent_type}:${conversation.id}`}
>
<button
data-conversation-id={conversation.id}
onClick={handleClick}

View File

@@ -12,7 +12,8 @@ import {
} from "react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Virtualizer, type VirtualizerHandle } from "virtua"
import { Reorder } from "motion/react"
import type { OverlayScrollbarsComponentRef } from "overlayscrollbars-react"
import {
ChevronDown,
ChevronRight,
@@ -122,18 +123,6 @@ function formatRelative(iso: string): string {
return `${y}y`
}
type FlatItem =
| {
type: "folder_header"
folderId: number
folderName: string
count: number
expanded: boolean
}
| { type: "conversation"; conversation: DbConversationSummary }
const CARD_HEIGHT_REM = 2
const FolderHeader = memo(function FolderHeader({
folderId,
folderName,
@@ -145,6 +134,7 @@ const FolderHeader = memo(function FolderHeader({
onNewConversation,
onImport,
onManageConversations,
isDragging,
t,
}: {
folderId: number
@@ -157,26 +147,30 @@ const FolderHeader = memo(function FolderHeader({
onNewConversation: (folderId: number) => void
onImport: (folderId: number) => void
onManageConversations: (folderId: number) => void
isDragging?: boolean
t: ReturnType<typeof useTranslations>
}) {
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="relative h-[2rem]">
<div className={cn("relative h-[2rem]", isDragging && "opacity-60")}>
<div
className={cn(
"flex h-[1.9375rem] w-full items-center",
"rounded-full",
"transition-colors duration-150",
"hover:bg-[color-mix(in_oklab,var(--sidebar-accent),var(--sidebar-foreground)_2%)]"
isDragging
? "cursor-grabbing"
: "cursor-grab hover:bg-[color-mix(in_oklab,var(--sidebar-accent),var(--sidebar-foreground)_2%)]"
)}
>
<button
data-folder-id={folderId}
onClick={() => onToggle(folderId)}
className={cn(
"flex h-full min-w-0 flex-1 items-center gap-[0.5rem] px-2 cursor-pointer outline-none",
"text-sidebar-foreground"
"flex h-full min-w-0 flex-1 items-center gap-[0.5rem] px-2 outline-none",
"text-sidebar-foreground",
isDragging ? "cursor-grabbing" : "cursor-grab"
)}
>
<span
@@ -267,6 +261,152 @@ const FolderHeader = memo(function FolderHeader({
)
})
interface FolderGroupItemProps {
folderId: number
folderName: string
conversations: DbConversationSummary[]
expanded: boolean
importing: boolean
reordering: boolean
dragging: boolean
sortMode: SidebarSortMode
selectedConversation: { id: number; agentType: string } | null
openTabConversationKeys: Set<string>
onToggle: (folderId: number) => void
onRemoveFromWorkspace: (folderId: number) => void
onNewConversationForFolder: (folderId: number) => void
onImport: (folderId: number) => void
onManageConversations: (folderId: number) => void
onSelect: (id: number, agentType: string) => void
onDoubleClick: (id: number, agentType: string) => void
onRename: (id: number, newTitle: string) => Promise<void>
onDelete: (id: number, agentType: string) => Promise<void>
onStatusChange: (id: number, status: ConversationStatus) => Promise<void>
onNewConversation: () => void
onDragStart: (folderId: number) => void
onDragEnd: () => void
stackIndex: number
t: ReturnType<typeof useTranslations>
}
const DRAGGING_Z_INDEX = 10_000
function FolderGroupItem({
folderId,
folderName,
conversations,
expanded,
importing,
reordering,
dragging,
sortMode,
selectedConversation,
openTabConversationKeys,
onToggle,
onRemoveFromWorkspace,
onNewConversationForFolder,
onImport,
onManageConversations,
onSelect,
onDoubleClick,
onRename,
onDelete,
onStatusChange,
onNewConversation,
onDragStart,
onDragEnd,
stackIndex,
t,
}: FolderGroupItemProps) {
const justDraggedRef = useRef(false)
const handleToggle = useCallback(
(id: number) => {
if (justDraggedRef.current) {
justDraggedRef.current = false
return
}
onToggle(id)
},
[onToggle]
)
const handleDragStart = useCallback(() => {
justDraggedRef.current = true
onDragStart(folderId)
}, [folderId, onDragStart])
// Wrap Reorder.Item in a plain div that owns the zIndex. Framer's Reorder.Item
// internally overrides `style.zIndex` (forces 1 while dragging, "unset" at rest),
// so any zIndex set directly on the Item is discarded. `isolation: isolate`
// forces a real stacking context on each wrapper so earlier folders' sticky
// headers always paint above later folders' conversation rows when scrolled.
return (
<div
className="relative"
style={{
isolation: "isolate",
zIndex: dragging ? DRAGGING_Z_INDEX : stackIndex,
}}
>
<Reorder.Item
as="div"
value={folderId}
drag={reordering ? false : "y"}
dragMomentum={false}
layout="position"
onDragStart={handleDragStart}
onDragEnd={onDragEnd}
>
<div
className={cn(
"sticky top-0 z-20 bg-sidebar",
dragging && "shadow-sm"
)}
>
<FolderHeader
folderId={folderId}
folderName={folderName}
count={conversations.length}
expanded={expanded}
importing={importing}
onToggle={handleToggle}
onRemoveFromWorkspace={onRemoveFromWorkspace}
onNewConversation={onNewConversationForFolder}
onImport={onImport}
onManageConversations={onManageConversations}
isDragging={dragging}
t={t}
/>
</div>
{expanded &&
conversations.map((conv) => (
<SidebarConversationCard
key={`conv-${conv.agent_type}-${conv.id}`}
conversation={conv}
isSelected={
selectedConversation?.agentType === conv.agent_type &&
selectedConversation?.id === conv.id
}
isOpenInTab={openTabConversationKeys.has(
`${conv.agent_type}:${conv.id}`
)}
timeLabel={formatRelative(
sortMode === "updated" ? conv.updated_at : conv.created_at
)}
onSelect={onSelect}
onDoubleClick={onDoubleClick}
onRename={onRename}
onDelete={onDelete}
onStatusChange={onStatusChange}
onNewConversation={onNewConversation}
/>
))}
</Reorder.Item>
</div>
)
}
export interface SidebarConversationListHandle {
scrollToActive: () => void
expandAll: () => void
@@ -288,15 +428,7 @@ export function SidebarConversationList({
const t = useTranslations("Folder.sidebar")
const tCommon = useTranslations("Folder.common")
const tFolderDropdown = useTranslations("Folder.folderNameDropdown")
const { zoomLevel } = useZoomLevel()
const safeZoomLevel =
typeof zoomLevel === "number" && Number.isFinite(zoomLevel) && zoomLevel > 0
? zoomLevel
: 100
const cardHeightPx = Math.max(
1,
Math.round((CARD_HEIGHT_REM * 16 * safeZoomLevel) / 100)
)
useZoomLevel()
const {
folders,
allFolders,
@@ -306,6 +438,7 @@ export function SidebarConversationList({
refreshConversations,
updateConversationLocal,
removeFolderFromWorkspace,
reorderFolders,
openFolder,
} = useAppWorkspace()
const refreshing = loading
@@ -350,7 +483,6 @@ export function SidebarConversationList({
const [folderExpanded, setFolderExpanded] = useState<Record<number, boolean>>(
{}
)
const [scrollOffset, setScrollOffset] = useState(0)
const [removeConfirm, setRemoveConfirm] = useState<{
folderId: number
folderName: string
@@ -361,6 +493,10 @@ export function SidebarConversationList({
} | null>(null)
const [cloneOpen, setCloneOpen] = useState(false)
const [browserOpen, setBrowserOpen] = useState(false)
const [dragging, setDragging] = useState<number | null>(null)
const [reordering, setReordering] = useState(false)
const [dragOrder, setDragOrder] = useState<number[] | null>(null)
const pendingOrderRef = useRef<number[] | null>(null)
useEffect(() => {
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
@@ -368,9 +504,9 @@ export function SidebarConversationList({
setFolderExpanded(loadFolderExpanded())
}, [])
const scrollRootRef = useRef<OverlayScrollbarsComponentRef>(null)
const scrollToActiveRef = useRef<() => void>(() => {})
const pendingScrollRef = useRef(false)
const virtualizerRef = useRef<VirtualizerHandle>(null)
const filteredConversations = useMemo(() => {
if (showCompleted) return conversations
@@ -393,6 +529,28 @@ export function SidebarConversationList({
}, [filteredConversations, sortMode])
const orderedFolderIds = useMemo(() => {
const folderIdSet = new Set(folders.map((f) => f.id))
// During drag we honour the optimistic order so sibling folders shift live
// as the user hovers over slots. We still filter/append against the source
// of truth so newly-added or -removed folders don't disappear mid-drag.
if (dragOrder) {
const seen = new Set<number>()
const ids: number[] = []
for (const id of dragOrder) {
if (folderIdSet.has(id) && !seen.has(id)) {
seen.add(id)
ids.push(id)
}
}
for (const f of folders) {
if (!seen.has(f.id)) {
seen.add(f.id)
ids.push(f.id)
}
}
return ids
}
const seen = new Set<number>()
const ids: number[] = []
for (const f of folders) {
@@ -402,69 +560,7 @@ export function SidebarConversationList({
}
}
return ids
}, [folders])
const flatItems = useMemo<FlatItem[]>(() => {
const items: FlatItem[] = []
for (const folderId of orderedFolderIds) {
const list = byFolder.get(folderId) ?? []
const folderName = folderIndex.get(folderId)?.name ?? String(folderId)
const expanded = folderExpanded[folderId] ?? true
items.push({
type: "folder_header",
folderId,
folderName,
count: list.length,
expanded,
})
if (!expanded) continue
for (const conv of list) {
items.push({ type: "conversation", conversation: conv })
}
}
return items
}, [orderedFolderIds, byFolder, folderIndex, folderExpanded])
const stickyState = useMemo<{
folder: Extract<FlatItem, { type: "folder_header" }> | null
pushOffset: number
}>(() => {
if (flatItems.length === 0 || cardHeightPx <= 0) {
return { folder: null, pushOffset: 0 }
}
const rawStart = Math.floor(scrollOffset / cardHeightPx)
const startIdx = Math.max(
0,
Math.min(flatItems.length - 1, Number.isFinite(rawStart) ? rawStart : 0)
)
let folderIdx = -1
for (let i = startIdx; i >= 0; i--) {
if (flatItems[i]?.type === "folder_header") {
folderIdx = i
break
}
}
if (folderIdx < 0) {
return { folder: null, pushOffset: 0 }
}
const folder = flatItems[folderIdx] as Extract<
FlatItem,
{ type: "folder_header" }
>
let pushOffset = 0
for (let i = folderIdx + 1; i < flatItems.length; i++) {
if (flatItems[i].type === "folder_header") {
const nextRelativeY = i * cardHeightPx - scrollOffset
if (nextRelativeY < cardHeightPx) {
pushOffset = Math.min(0, nextRelativeY - cardHeightPx)
}
break
}
}
return { folder, pushOffset }
}, [scrollOffset, flatItems, cardHeightPx])
const stickyFolderItem = stickyState.folder
}, [folders, dragOrder])
useImperativeHandle(ref, () => ({
scrollToActive() {
@@ -506,17 +602,12 @@ export function SidebarConversationList({
pendingScrollRef.current = true
return
}
const index = flatItems.findIndex(
(item) =>
item.type === "conversation" &&
item.conversation.id === targetId &&
item.conversation.agent_type === targetAgent
)
if (index >= 0) {
virtualizerRef.current?.scrollToIndex(index, {
align: "center",
smooth: true,
})
const root = scrollRootRef.current?.getElement()
if (!root) return
const selector = `[data-conv-key="${targetAgent}:${targetId}"]`
const el = root.querySelector(selector)
if (el instanceof HTMLElement) {
el.scrollIntoView({ block: "center", behavior: "smooth" })
}
}
@@ -524,7 +615,7 @@ export function SidebarConversationList({
pendingScrollRef.current = false
scrollToActiveRef.current()
}
}, [selectedConversation, flatItems, conversations, folderExpanded])
}, [selectedConversation, conversations, folderExpanded])
const toggleFolder = useCallback((folderId: number) => {
setFolderExpanded((prev) => {
@@ -679,6 +770,49 @@ export function SidebarConversationList({
[importing, addTask, updateTask, refreshConversations, t]
)
const persistReorder = useCallback(
async (order: number[]) => {
if (order.length === 0) return
setReordering(true)
try {
await reorderFolders(order)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
toast.error(t("toasts.reorderFoldersFailed", { message: msg }))
} finally {
setReordering(false)
}
},
[reorderFolders, t]
)
const handleReorder = useCallback((nextIds: number[]) => {
pendingOrderRef.current = nextIds
setDragOrder(nextIds)
}, [])
const handleDragStart = useCallback((folderId: number) => {
setDragging(folderId)
}, [])
const handleDragEnd = useCallback(async () => {
setDragging(null)
const order = pendingOrderRef.current
pendingOrderRef.current = null
if (!order) {
setDragOrder(null)
return
}
try {
await persistReorder(order)
} finally {
// Clear the optimistic override once the workspace context's folders
// have absorbed the new order (or on failure, the rollback in the
// context restores the original order).
setDragOrder(null)
}
}, [persistReorder])
const handleOpenFolderAction = useCallback(async () => {
if (isDesktop()) {
try {
@@ -772,90 +906,68 @@ export function SidebarConversationList({
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex-1 min-h-0 relative">
{stickyFolderItem && (
<div
className="absolute left-0 right-0 z-10"
style={{ top: `${Math.round(stickyState.pushOffset)}px` }}
>
<div
aria-hidden
className="absolute inset-0 right-[0.25rem] bg-sidebar"
/>
<div className="relative px-1">
<FolderHeader
key={`sticky-${stickyFolderItem.folderId}`}
folderId={stickyFolderItem.folderId}
folderName={stickyFolderItem.folderName}
count={stickyFolderItem.count}
expanded={stickyFolderItem.expanded}
importing={importing}
onToggle={toggleFolder}
onRemoveFromWorkspace={handleRemoveFolder}
onNewConversation={handleNewConversationForFolder}
onImport={handleImportForFolder}
onManageConversations={handleManageConversations}
t={t}
/>
</div>
</div>
)}
<ScrollArea
ref={scrollRootRef}
className={cn(
"h-full min-h-0 px-1 pb-[1.25rem]",
"[overflow-anchor:none]"
)}
>
<Virtualizer
ref={virtualizerRef}
itemSize={cardHeightPx}
onScroll={setScrollOffset}
<Reorder.Group
as="div"
axis="y"
values={orderedFolderIds}
onReorder={handleReorder}
className="flex flex-col"
>
{flatItems.map((item) => {
if (item.type === "folder_header") {
{orderedFolderIds.map((folderId, index) => {
const folderName =
folderIndex.get(folderId)?.name ?? String(folderId)
const convs = byFolder.get(folderId) ?? []
const expanded = folderExpanded[folderId] ?? true
const convsWithKey = convs.map((conv) => ({
...conv,
}))
// Earlier folders get a higher stacking index so their
// sticky headers paint above later folders' conversation
// cards when scrolled. Framer's `layout` prop sets
// `will-change: transform`, which would otherwise trap
// each sticky inside its own Reorder.Item.
const stackIndex = orderedFolderIds.length - index
return (
<FolderHeader
key={`folder-${item.folderId}`}
folderId={item.folderId}
folderName={item.folderName}
count={item.count}
expanded={item.expanded}
<FolderGroupItem
key={folderId}
folderId={folderId}
folderName={folderName}
conversations={convsWithKey}
expanded={expanded}
importing={importing}
reordering={reordering}
dragging={dragging === folderId}
sortMode={sortMode}
selectedConversation={selectedConversation}
openTabConversationKeys={openTabConversationKeys}
onToggle={toggleFolder}
onRemoveFromWorkspace={handleRemoveFolder}
onNewConversation={handleNewConversationForFolder}
onNewConversationForFolder={
handleNewConversationForFolder
}
onImport={handleImportForFolder}
onManageConversations={handleManageConversations}
t={t}
/>
)
}
const conv = item.conversation
return (
<SidebarConversationCard
key={`conv-${conv.id}`}
conversation={conv}
isSelected={
selectedConversation?.agentType === conv.agent_type &&
selectedConversation?.id === conv.id
}
isOpenInTab={openTabConversationKeys.has(
`${conv.agent_type}:${conv.id}`
)}
timeLabel={formatRelative(
sortMode === "updated"
? conv.updated_at
: conv.created_at
)}
onSelect={handleSelect}
onDoubleClick={handleDoubleClick}
onRename={handleRename}
onDelete={handleDelete}
onStatusChange={handleStatusChange}
onNewConversation={handleNewConversation}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
stackIndex={stackIndex}
t={t}
/>
)
})}
</Virtualizer>
</Reorder.Group>
</ScrollArea>
</div>
</ContextMenuTrigger>

View File

@@ -18,6 +18,7 @@ import {
openFolder as apiOpenFolder,
openFolderById as apiOpenFolderById,
removeFolderFromWorkspace as apiRemoveFolderFromWorkspace,
reorderFolders as apiReorderFolders,
getFolder as apiGetFolder,
} from "@/lib/api"
import { toErrorMessage } from "@/lib/app-error"
@@ -51,6 +52,7 @@ interface AppWorkspaceContextValue {
openFolder: (path: string) => Promise<FolderDetail>
addFolderToWorkspaceById: (folderId: number) => Promise<FolderDetail>
removeFolderFromWorkspace: (folderId: number) => Promise<void>
reorderFolders: (ids: number[]) => Promise<void>
refreshFolder: (id: number) => Promise<void>
stats: AgentStats | null
@@ -264,6 +266,45 @@ export function AppWorkspaceProvider({ children }: AppWorkspaceProviderProps) {
[refreshConversations]
)
const reorderFolders = useCallback(async (ids: number[]) => {
let prevFoldersSnapshot: FolderDetail[] | null = null
let prevAllFoldersSnapshot: FolderDetail[] | null = null
const reorderByIds = (prev: FolderDetail[]) => {
const byId = new Map(prev.map((f) => [f.id, f]))
const next: FolderDetail[] = []
ids.forEach((id, idx) => {
const folder = byId.get(id)
if (folder) {
next.push({ ...folder, sort_order: idx + 1 })
byId.delete(id)
}
})
// Keep folders not included in `ids` at the end, preserving relative order.
for (const f of prev) {
if (byId.has(f.id)) next.push(f)
}
return next
}
setFolders((prev) => {
prevFoldersSnapshot = prev
return reorderByIds(prev)
})
setAllFolders((prev) => {
prevAllFoldersSnapshot = prev
return reorderByIds(prev)
})
try {
await apiReorderFolders(ids)
} catch (err) {
if (prevFoldersSnapshot) setFolders(prevFoldersSnapshot)
if (prevAllFoldersSnapshot) setAllFolders(prevAllFoldersSnapshot)
throw err
}
}, [])
const refreshFolder = useCallback(async (id: number) => {
try {
const detail = await apiGetFolder(id)
@@ -347,6 +388,7 @@ export function AppWorkspaceProvider({ children }: AppWorkspaceProviderProps) {
openFolder,
addFolderToWorkspaceById,
removeFolderFromWorkspace,
reorderFolders,
refreshFolder,
stats,
activeFolderId,
@@ -369,6 +411,7 @@ export function AppWorkspaceProvider({ children }: AppWorkspaceProviderProps) {
openFolder,
addFolderToWorkspaceById,
removeFolderFromWorkspace,
reorderFolders,
refreshFolder,
stats,
activeFolderId,

View File

@@ -781,9 +781,11 @@
"folderOpened": "تم فتح المجلد {name}",
"folderRemoved": "تمت إزالة المجلد {name}",
"openFolderFailed": "فشل فتح المجلد",
"removeFolderFailed": "فشل إزالة المجلد: {message}"
"removeFolderFailed": "فشل إزالة المجلد: {message}",
"reorderFoldersFailed": "فشل إعادة ترتيب المجلدات: {message}"
},
"statsLabel": "{folders} مجلدات · {convos} محادثة",
"reorderHandle": "اسحب لإعادة الترتيب",
"openFolder": "فتح مجلد",
"searchPlaceholder": "بحث عن محادثات...",
"showCompleted": "عرض المحادثات المكتملة",

View File

@@ -781,9 +781,11 @@
"folderOpened": "Ordner {name} geöffnet",
"folderRemoved": "Ordner {name} entfernt",
"openFolderFailed": "Ordner konnte nicht geöffnet werden",
"removeFolderFailed": "Ordner konnte nicht entfernt werden: {message}"
"removeFolderFailed": "Ordner konnte nicht entfernt werden: {message}",
"reorderFoldersFailed": "Ordner konnten nicht neu sortiert werden: {message}"
},
"statsLabel": "{folders} Ordner · {convos} Konversationen",
"reorderHandle": "Zum Neuordnen ziehen",
"openFolder": "Ordner öffnen",
"searchPlaceholder": "Konversationen suchen...",
"showCompleted": "Abgeschlossene Konversationen anzeigen",

View File

@@ -781,9 +781,11 @@
"folderOpened": "Opened folder {name}",
"folderRemoved": "Removed folder {name}",
"openFolderFailed": "Failed to open folder",
"removeFolderFailed": "Failed to remove folder: {message}"
"removeFolderFailed": "Failed to remove folder: {message}",
"reorderFoldersFailed": "Failed to reorder folders: {message}"
},
"statsLabel": "{folders} folders · {convos} conversations",
"reorderHandle": "Drag to reorder",
"openFolder": "Open Folder",
"searchPlaceholder": "Search conversations...",
"showCompleted": "Show completed conversations",

View File

@@ -781,9 +781,11 @@
"folderOpened": "Carpeta {name} abierta",
"folderRemoved": "Carpeta {name} eliminada",
"openFolderFailed": "Error al abrir carpeta",
"removeFolderFailed": "Error al eliminar carpeta: {message}"
"removeFolderFailed": "Error al eliminar carpeta: {message}",
"reorderFoldersFailed": "Error al reordenar carpetas: {message}"
},
"statsLabel": "{folders} carpetas · {convos} conversaciones",
"reorderHandle": "Arrastrar para reordenar",
"openFolder": "Abrir carpeta",
"searchPlaceholder": "Buscar conversaciones...",
"showCompleted": "Mostrar conversaciones completadas",

View File

@@ -781,9 +781,11 @@
"folderOpened": "Dossier {name} ouvert",
"folderRemoved": "Dossier {name} retiré",
"openFolderFailed": "Échec de l'ouverture du dossier",
"removeFolderFailed": "Échec de la suppression du dossier : {message}"
"removeFolderFailed": "Échec de la suppression du dossier : {message}",
"reorderFoldersFailed": "Échec du réordonnancement des dossiers : {message}"
},
"statsLabel": "{folders} dossiers · {convos} conversations",
"reorderHandle": "Glisser pour réorganiser",
"openFolder": "Ouvrir le dossier",
"searchPlaceholder": "Rechercher des conversations...",
"showCompleted": "Afficher les conversations terminées",

View File

@@ -781,9 +781,11 @@
"folderOpened": "フォルダ {name} を開きました",
"folderRemoved": "フォルダ {name} を削除しました",
"openFolderFailed": "フォルダを開けませんでした",
"removeFolderFailed": "フォルダの削除に失敗しました: {message}"
"removeFolderFailed": "フォルダの削除に失敗しました: {message}",
"reorderFoldersFailed": "フォルダの並べ替えに失敗しました: {message}"
},
"statsLabel": "{folders} フォルダ · {convos} 会話",
"reorderHandle": "ドラッグして並べ替え",
"openFolder": "フォルダを開く",
"searchPlaceholder": "会話を検索...",
"showCompleted": "完了した会話を表示",

View File

@@ -781,9 +781,11 @@
"folderOpened": "폴더 {name}을(를) 열었습니다",
"folderRemoved": "폴더 {name}을(를) 제거했습니다",
"openFolderFailed": "폴더를 열 수 없습니다",
"removeFolderFailed": "폴더 제거 실패: {message}"
"removeFolderFailed": "폴더 제거 실패: {message}",
"reorderFoldersFailed": "폴더 순서 변경 실패: {message}"
},
"statsLabel": "{folders}개 폴더 · {convos}개 대화",
"reorderHandle": "드래그하여 순서 변경",
"openFolder": "폴더 열기",
"searchPlaceholder": "대화 검색...",
"showCompleted": "완료된 대화 표시",

View File

@@ -781,9 +781,11 @@
"folderOpened": "Pasta {name} aberta",
"folderRemoved": "Pasta {name} removida",
"openFolderFailed": "Falha ao abrir pasta",
"removeFolderFailed": "Falha ao remover pasta: {message}"
"removeFolderFailed": "Falha ao remover pasta: {message}",
"reorderFoldersFailed": "Falha ao reordenar pastas: {message}"
},
"statsLabel": "{folders} pastas · {convos} conversas",
"reorderHandle": "Arraste para reordenar",
"openFolder": "Abrir pasta",
"searchPlaceholder": "Buscar conversas...",
"showCompleted": "Mostrar conversas concluídas",

View File

@@ -781,9 +781,11 @@
"folderOpened": "已打开文件夹 {name}",
"folderRemoved": "已移除文件夹 {name}",
"openFolderFailed": "打开文件夹失败",
"removeFolderFailed": "移除文件夹失败:{message}"
"removeFolderFailed": "移除文件夹失败:{message}",
"reorderFoldersFailed": "重新排序文件夹失败:{message}"
},
"statsLabel": "{folders} 个文件夹 · {convos} 个会话",
"reorderHandle": "拖拽排序",
"openFolder": "打开文件夹",
"searchPlaceholder": "搜索会话...",
"showCompleted": "显示已完成会话",

View File

@@ -781,9 +781,11 @@
"folderOpened": "已開啟資料夾 {name}",
"folderRemoved": "已移除資料夾 {name}",
"openFolderFailed": "開啟資料夾失敗",
"removeFolderFailed": "移除資料夾失敗:{message}"
"removeFolderFailed": "移除資料夾失敗:{message}",
"reorderFoldersFailed": "重新排序資料夾失敗:{message}"
},
"statsLabel": "{folders} 個資料夾 · {convos} 個對話",
"reorderHandle": "拖拽排序",
"openFolder": "開啟資料夾",
"searchPlaceholder": "搜尋對話...",
"showCompleted": "顯示已完成對話",

View File

@@ -640,6 +640,10 @@ export async function removeFolderFromWorkspace(
return getTransport().call("remove_folder_from_workspace", { folderId })
}
export async function reorderFolders(ids: number[]): Promise<void> {
return getTransport().call("reorder_folders", { ids })
}
export async function importLocalConversations(
folderId: number
): Promise<ImportResult> {

View File

@@ -508,6 +508,10 @@ export async function removeFolderFromWorkspace(
return invoke("remove_folder_from_workspace", { folderId })
}
export async function reorderFolders(ids: number[]): Promise<void> {
return invoke("reorder_folders", { ids })
}
export async function importLocalConversations(
folderId: number
): Promise<ImportResult> {

View File

@@ -163,6 +163,7 @@ export interface FolderDetail {
parent_branch: string | null
default_agent_type: AgentType | null
last_opened_at: string
sort_order: number
}
export interface OpenedTab {