feat(sidebar): reorder folder groups by drag and persist sort order
This commit is contained in:
@@ -589,6 +589,17 @@ pub async fn remove_folder_from_workspace(
|
|||||||
.map_err(AppCommandError::from)
|
.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)]
|
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
||||||
pub async fn create_folder_directory(path: String) -> Result<(), AppCommandError> {
|
pub async fn create_folder_directory(path: String) -> Result<(), AppCommandError> {
|
||||||
std::fs::create_dir_all(&path).map_err(AppCommandError::io)
|
std::fs::create_dir_all(&path).map_err(AppCommandError::io)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub struct Model {
|
|||||||
pub deleted_at: Option<DateTimeUtc>,
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
pub is_open: bool,
|
pub is_open: bool,
|
||||||
pub parent_branch: Option<String>,
|
pub parent_branch: Option<String>,
|
||||||
|
pub sort_order: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ mod m20260401_000001_chat_channel_sender_context;
|
|||||||
mod m20260404_000001_model_provider;
|
mod m20260404_000001_model_provider;
|
||||||
mod m20260406_000001_agent_setting_model_provider;
|
mod m20260406_000001_agent_setting_model_provider;
|
||||||
mod m20260420_000001_opened_tabs;
|
mod m20260420_000001_opened_tabs;
|
||||||
|
mod m20260422_000001_folder_sort_order;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -26,6 +27,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260404_000001_model_provider::Migration),
|
Box::new(m20260404_000001_model_provider::Migration),
|
||||||
Box::new(m20260406_000001_agent_setting_model_provider::Migration),
|
Box::new(m20260406_000001_agent_setting_model_provider::Migration),
|
||||||
Box::new(m20260420_000001_opened_tabs::Migration),
|
Box::new(m20260420_000001_opened_tabs::Migration),
|
||||||
|
Box::new(m20260422_000001_folder_sort_order::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use sea_orm::DatabaseConnection;
|
use sea_orm::DatabaseConnection;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter,
|
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait,
|
||||||
QueryOrder, Set,
|
IntoActiveModel, QueryFilter, QueryOrder, Set, Statement,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::db::entities::folder;
|
use crate::db::entities::folder;
|
||||||
@@ -34,6 +34,7 @@ fn to_detail(m: folder::Model) -> FolderDetail {
|
|||||||
parent_branch: m.parent_branch,
|
parent_branch: m.parent_branch,
|
||||||
default_agent_type,
|
default_agent_type,
|
||||||
last_opened_at: m.last_opened_at,
|
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.is_open = Set(true);
|
||||||
active.update(conn).await?
|
active.update(conn).await?
|
||||||
} else {
|
} 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 {
|
let active = folder::ActiveModel {
|
||||||
id: NotSet,
|
id: NotSet,
|
||||||
name: Set(name),
|
name: Set(name),
|
||||||
@@ -85,6 +92,7 @@ pub async fn add_folder(
|
|||||||
updated_at: Set(now),
|
updated_at: Set(now),
|
||||||
deleted_at: Set(None),
|
deleted_at: Set(None),
|
||||||
is_open: Set(true),
|
is_open: Set(true),
|
||||||
|
sort_order: Set(max_order + 1),
|
||||||
};
|
};
|
||||||
active.insert(conn).await?
|
active.insert(conn).await?
|
||||||
};
|
};
|
||||||
@@ -170,6 +178,7 @@ pub async fn list_open_folder_details(
|
|||||||
let rows = folder::Entity::find()
|
let rows = folder::Entity::find()
|
||||||
.filter(folder::Column::DeletedAt.is_null())
|
.filter(folder::Column::DeletedAt.is_null())
|
||||||
.filter(folder::Column::IsOpen.eq(true))
|
.filter(folder::Column::IsOpen.eq(true))
|
||||||
|
.order_by_asc(folder::Column::SortOrder)
|
||||||
.order_by_desc(folder::Column::LastOpenedAt)
|
.order_by_desc(folder::Column::LastOpenedAt)
|
||||||
.all(conn)
|
.all(conn)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -182,9 +191,41 @@ pub async fn list_all_folder_details(
|
|||||||
) -> Result<Vec<FolderDetail>, DbError> {
|
) -> Result<Vec<FolderDetail>, DbError> {
|
||||||
let rows = folder::Entity::find()
|
let rows = folder::Entity::find()
|
||||||
.filter(folder::Column::DeletedAt.is_null())
|
.filter(folder::Column::DeletedAt.is_null())
|
||||||
|
.order_by_asc(folder::Column::SortOrder)
|
||||||
.order_by_desc(folder::Column::LastOpenedAt)
|
.order_by_desc(folder::Column::LastOpenedAt)
|
||||||
.all(conn)
|
.all(conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(rows.into_iter().map(to_detail).collect())
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ mod tauri_app {
|
|||||||
folders::open_folder,
|
folders::open_folder,
|
||||||
folders::open_folder_by_id,
|
folders::open_folder_by_id,
|
||||||
folders::remove_folder_from_workspace,
|
folders::remove_folder_from_workspace,
|
||||||
|
folders::reorder_folders,
|
||||||
folders::add_folder_to_history,
|
folders::add_folder_to_history,
|
||||||
folders::set_folder_parent_branch,
|
folders::set_folder_parent_branch,
|
||||||
folders::remove_folder_from_history,
|
folders::remove_folder_from_history,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub struct FolderDetail {
|
|||||||
pub parent_branch: Option<String>,
|
pub parent_branch: Option<String>,
|
||||||
pub default_agent_type: Option<AgentType>,
|
pub default_agent_type: Option<AgentType>,
|
||||||
pub last_opened_at: DateTime<Utc>,
|
pub last_opened_at: DateTime<Utc>,
|
||||||
|
pub sort_order: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -123,6 +123,23 @@ pub async fn remove_folder_from_workspace(
|
|||||||
Ok(Json(()))
|
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)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PathParams {
|
pub struct PathParams {
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
|
|||||||
"/remove_folder_from_workspace",
|
"/remove_folder_from_workspace",
|
||||||
post(handlers::folders::remove_folder_from_workspace),
|
post(handlers::folders::remove_folder_from_workspace),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/reorder_folders",
|
||||||
|
post(handlers::folders::reorder_folders),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/add_folder_to_history",
|
"/add_folder_to_history",
|
||||||
post(handlers::folders::add_folder_to_history),
|
post(handlers::folders::add_folder_to_history),
|
||||||
|
|||||||
@@ -131,7 +131,10 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
|
|||||||
<>
|
<>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div className="relative h-[2rem]">
|
<div
|
||||||
|
className="relative h-[2rem]"
|
||||||
|
data-conv-key={`${conversation.agent_type}:${conversation.id}`}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
data-conversation-id={conversation.id}
|
data-conversation-id={conversation.id}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
} from "react"
|
} from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Virtualizer, type VirtualizerHandle } from "virtua"
|
import { Reorder } from "motion/react"
|
||||||
|
import type { OverlayScrollbarsComponentRef } from "overlayscrollbars-react"
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -122,18 +123,6 @@ function formatRelative(iso: string): string {
|
|||||||
return `${y}y`
|
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({
|
const FolderHeader = memo(function FolderHeader({
|
||||||
folderId,
|
folderId,
|
||||||
folderName,
|
folderName,
|
||||||
@@ -145,6 +134,7 @@ const FolderHeader = memo(function FolderHeader({
|
|||||||
onNewConversation,
|
onNewConversation,
|
||||||
onImport,
|
onImport,
|
||||||
onManageConversations,
|
onManageConversations,
|
||||||
|
isDragging,
|
||||||
t,
|
t,
|
||||||
}: {
|
}: {
|
||||||
folderId: number
|
folderId: number
|
||||||
@@ -157,26 +147,30 @@ const FolderHeader = memo(function FolderHeader({
|
|||||||
onNewConversation: (folderId: number) => void
|
onNewConversation: (folderId: number) => void
|
||||||
onImport: (folderId: number) => void
|
onImport: (folderId: number) => void
|
||||||
onManageConversations: (folderId: number) => void
|
onManageConversations: (folderId: number) => void
|
||||||
|
isDragging?: boolean
|
||||||
t: ReturnType<typeof useTranslations>
|
t: ReturnType<typeof useTranslations>
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div className="relative h-[2rem]">
|
<div className={cn("relative h-[2rem]", isDragging && "opacity-60")}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-[1.9375rem] w-full items-center",
|
"flex h-[1.9375rem] w-full items-center",
|
||||||
"rounded-full",
|
"rounded-full",
|
||||||
"transition-colors duration-150",
|
"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
|
<button
|
||||||
data-folder-id={folderId}
|
data-folder-id={folderId}
|
||||||
onClick={() => onToggle(folderId)}
|
onClick={() => onToggle(folderId)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full min-w-0 flex-1 items-center gap-[0.5rem] px-2 cursor-pointer outline-none",
|
"flex h-full min-w-0 flex-1 items-center gap-[0.5rem] px-2 outline-none",
|
||||||
"text-sidebar-foreground"
|
"text-sidebar-foreground",
|
||||||
|
isDragging ? "cursor-grabbing" : "cursor-grab"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<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 {
|
export interface SidebarConversationListHandle {
|
||||||
scrollToActive: () => void
|
scrollToActive: () => void
|
||||||
expandAll: () => void
|
expandAll: () => void
|
||||||
@@ -288,15 +428,7 @@ export function SidebarConversationList({
|
|||||||
const t = useTranslations("Folder.sidebar")
|
const t = useTranslations("Folder.sidebar")
|
||||||
const tCommon = useTranslations("Folder.common")
|
const tCommon = useTranslations("Folder.common")
|
||||||
const tFolderDropdown = useTranslations("Folder.folderNameDropdown")
|
const tFolderDropdown = useTranslations("Folder.folderNameDropdown")
|
||||||
const { zoomLevel } = useZoomLevel()
|
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)
|
|
||||||
)
|
|
||||||
const {
|
const {
|
||||||
folders,
|
folders,
|
||||||
allFolders,
|
allFolders,
|
||||||
@@ -306,6 +438,7 @@ export function SidebarConversationList({
|
|||||||
refreshConversations,
|
refreshConversations,
|
||||||
updateConversationLocal,
|
updateConversationLocal,
|
||||||
removeFolderFromWorkspace,
|
removeFolderFromWorkspace,
|
||||||
|
reorderFolders,
|
||||||
openFolder,
|
openFolder,
|
||||||
} = useAppWorkspace()
|
} = useAppWorkspace()
|
||||||
const refreshing = loading
|
const refreshing = loading
|
||||||
@@ -350,7 +483,6 @@ export function SidebarConversationList({
|
|||||||
const [folderExpanded, setFolderExpanded] = useState<Record<number, boolean>>(
|
const [folderExpanded, setFolderExpanded] = useState<Record<number, boolean>>(
|
||||||
{}
|
{}
|
||||||
)
|
)
|
||||||
const [scrollOffset, setScrollOffset] = useState(0)
|
|
||||||
const [removeConfirm, setRemoveConfirm] = useState<{
|
const [removeConfirm, setRemoveConfirm] = useState<{
|
||||||
folderId: number
|
folderId: number
|
||||||
folderName: string
|
folderName: string
|
||||||
@@ -361,6 +493,10 @@ export function SidebarConversationList({
|
|||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [cloneOpen, setCloneOpen] = useState(false)
|
const [cloneOpen, setCloneOpen] = useState(false)
|
||||||
const [browserOpen, setBrowserOpen] = 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(() => {
|
useEffect(() => {
|
||||||
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
|
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
|
||||||
@@ -368,9 +504,9 @@ export function SidebarConversationList({
|
|||||||
setFolderExpanded(loadFolderExpanded())
|
setFolderExpanded(loadFolderExpanded())
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const scrollRootRef = useRef<OverlayScrollbarsComponentRef>(null)
|
||||||
const scrollToActiveRef = useRef<() => void>(() => {})
|
const scrollToActiveRef = useRef<() => void>(() => {})
|
||||||
const pendingScrollRef = useRef(false)
|
const pendingScrollRef = useRef(false)
|
||||||
const virtualizerRef = useRef<VirtualizerHandle>(null)
|
|
||||||
|
|
||||||
const filteredConversations = useMemo(() => {
|
const filteredConversations = useMemo(() => {
|
||||||
if (showCompleted) return conversations
|
if (showCompleted) return conversations
|
||||||
@@ -393,6 +529,28 @@ export function SidebarConversationList({
|
|||||||
}, [filteredConversations, sortMode])
|
}, [filteredConversations, sortMode])
|
||||||
|
|
||||||
const orderedFolderIds = useMemo(() => {
|
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 seen = new Set<number>()
|
||||||
const ids: number[] = []
|
const ids: number[] = []
|
||||||
for (const f of folders) {
|
for (const f of folders) {
|
||||||
@@ -402,69 +560,7 @@ export function SidebarConversationList({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ids
|
return ids
|
||||||
}, [folders])
|
}, [folders, dragOrder])
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
scrollToActive() {
|
scrollToActive() {
|
||||||
@@ -506,17 +602,12 @@ export function SidebarConversationList({
|
|||||||
pendingScrollRef.current = true
|
pendingScrollRef.current = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const index = flatItems.findIndex(
|
const root = scrollRootRef.current?.getElement()
|
||||||
(item) =>
|
if (!root) return
|
||||||
item.type === "conversation" &&
|
const selector = `[data-conv-key="${targetAgent}:${targetId}"]`
|
||||||
item.conversation.id === targetId &&
|
const el = root.querySelector(selector)
|
||||||
item.conversation.agent_type === targetAgent
|
if (el instanceof HTMLElement) {
|
||||||
)
|
el.scrollIntoView({ block: "center", behavior: "smooth" })
|
||||||
if (index >= 0) {
|
|
||||||
virtualizerRef.current?.scrollToIndex(index, {
|
|
||||||
align: "center",
|
|
||||||
smooth: true,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,7 +615,7 @@ export function SidebarConversationList({
|
|||||||
pendingScrollRef.current = false
|
pendingScrollRef.current = false
|
||||||
scrollToActiveRef.current()
|
scrollToActiveRef.current()
|
||||||
}
|
}
|
||||||
}, [selectedConversation, flatItems, conversations, folderExpanded])
|
}, [selectedConversation, conversations, folderExpanded])
|
||||||
|
|
||||||
const toggleFolder = useCallback((folderId: number) => {
|
const toggleFolder = useCallback((folderId: number) => {
|
||||||
setFolderExpanded((prev) => {
|
setFolderExpanded((prev) => {
|
||||||
@@ -679,6 +770,49 @@ export function SidebarConversationList({
|
|||||||
[importing, addTask, updateTask, refreshConversations, t]
|
[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 () => {
|
const handleOpenFolderAction = useCallback(async () => {
|
||||||
if (isDesktop()) {
|
if (isDesktop()) {
|
||||||
try {
|
try {
|
||||||
@@ -772,90 +906,68 @@ export function SidebarConversationList({
|
|||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div className="flex-1 min-h-0 relative">
|
<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
|
<ScrollArea
|
||||||
|
ref={scrollRootRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full min-h-0 px-1 pb-[1.25rem]",
|
"h-full min-h-0 px-1 pb-[1.25rem]",
|
||||||
"[overflow-anchor:none]"
|
"[overflow-anchor:none]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Virtualizer
|
<Reorder.Group
|
||||||
ref={virtualizerRef}
|
as="div"
|
||||||
itemSize={cardHeightPx}
|
axis="y"
|
||||||
onScroll={setScrollOffset}
|
values={orderedFolderIds}
|
||||||
|
onReorder={handleReorder}
|
||||||
|
className="flex flex-col"
|
||||||
>
|
>
|
||||||
{flatItems.map((item) => {
|
{orderedFolderIds.map((folderId, index) => {
|
||||||
if (item.type === "folder_header") {
|
const folderName =
|
||||||
return (
|
folderIndex.get(folderId)?.name ?? String(folderId)
|
||||||
<FolderHeader
|
const convs = byFolder.get(folderId) ?? []
|
||||||
key={`folder-${item.folderId}`}
|
const expanded = folderExpanded[folderId] ?? true
|
||||||
folderId={item.folderId}
|
const convsWithKey = convs.map((conv) => ({
|
||||||
folderName={item.folderName}
|
...conv,
|
||||||
count={item.count}
|
}))
|
||||||
expanded={item.expanded}
|
// Earlier folders get a higher stacking index so their
|
||||||
importing={importing}
|
// sticky headers paint above later folders' conversation
|
||||||
onToggle={toggleFolder}
|
// cards when scrolled. Framer's `layout` prop sets
|
||||||
onRemoveFromWorkspace={handleRemoveFolder}
|
// `will-change: transform`, which would otherwise trap
|
||||||
onNewConversation={handleNewConversationForFolder}
|
// each sticky inside its own Reorder.Item.
|
||||||
onImport={handleImportForFolder}
|
const stackIndex = orderedFolderIds.length - index
|
||||||
onManageConversations={handleManageConversations}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const conv = item.conversation
|
|
||||||
return (
|
return (
|
||||||
<SidebarConversationCard
|
<FolderGroupItem
|
||||||
key={`conv-${conv.id}`}
|
key={folderId}
|
||||||
conversation={conv}
|
folderId={folderId}
|
||||||
isSelected={
|
folderName={folderName}
|
||||||
selectedConversation?.agentType === conv.agent_type &&
|
conversations={convsWithKey}
|
||||||
selectedConversation?.id === conv.id
|
expanded={expanded}
|
||||||
|
importing={importing}
|
||||||
|
reordering={reordering}
|
||||||
|
dragging={dragging === folderId}
|
||||||
|
sortMode={sortMode}
|
||||||
|
selectedConversation={selectedConversation}
|
||||||
|
openTabConversationKeys={openTabConversationKeys}
|
||||||
|
onToggle={toggleFolder}
|
||||||
|
onRemoveFromWorkspace={handleRemoveFolder}
|
||||||
|
onNewConversationForFolder={
|
||||||
|
handleNewConversationForFolder
|
||||||
}
|
}
|
||||||
isOpenInTab={openTabConversationKeys.has(
|
onImport={handleImportForFolder}
|
||||||
`${conv.agent_type}:${conv.id}`
|
onManageConversations={handleManageConversations}
|
||||||
)}
|
|
||||||
timeLabel={formatRelative(
|
|
||||||
sortMode === "updated"
|
|
||||||
? conv.updated_at
|
|
||||||
: conv.created_at
|
|
||||||
)}
|
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
onRename={handleRename}
|
onRename={handleRename}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
onNewConversation={handleNewConversation}
|
onNewConversation={handleNewConversation}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
stackIndex={stackIndex}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</Virtualizer>
|
</Reorder.Group>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
openFolder as apiOpenFolder,
|
openFolder as apiOpenFolder,
|
||||||
openFolderById as apiOpenFolderById,
|
openFolderById as apiOpenFolderById,
|
||||||
removeFolderFromWorkspace as apiRemoveFolderFromWorkspace,
|
removeFolderFromWorkspace as apiRemoveFolderFromWorkspace,
|
||||||
|
reorderFolders as apiReorderFolders,
|
||||||
getFolder as apiGetFolder,
|
getFolder as apiGetFolder,
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
import { toErrorMessage } from "@/lib/app-error"
|
import { toErrorMessage } from "@/lib/app-error"
|
||||||
@@ -51,6 +52,7 @@ interface AppWorkspaceContextValue {
|
|||||||
openFolder: (path: string) => Promise<FolderDetail>
|
openFolder: (path: string) => Promise<FolderDetail>
|
||||||
addFolderToWorkspaceById: (folderId: number) => Promise<FolderDetail>
|
addFolderToWorkspaceById: (folderId: number) => Promise<FolderDetail>
|
||||||
removeFolderFromWorkspace: (folderId: number) => Promise<void>
|
removeFolderFromWorkspace: (folderId: number) => Promise<void>
|
||||||
|
reorderFolders: (ids: number[]) => Promise<void>
|
||||||
refreshFolder: (id: number) => Promise<void>
|
refreshFolder: (id: number) => Promise<void>
|
||||||
|
|
||||||
stats: AgentStats | null
|
stats: AgentStats | null
|
||||||
@@ -264,6 +266,45 @@ export function AppWorkspaceProvider({ children }: AppWorkspaceProviderProps) {
|
|||||||
[refreshConversations]
|
[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) => {
|
const refreshFolder = useCallback(async (id: number) => {
|
||||||
try {
|
try {
|
||||||
const detail = await apiGetFolder(id)
|
const detail = await apiGetFolder(id)
|
||||||
@@ -347,6 +388,7 @@ export function AppWorkspaceProvider({ children }: AppWorkspaceProviderProps) {
|
|||||||
openFolder,
|
openFolder,
|
||||||
addFolderToWorkspaceById,
|
addFolderToWorkspaceById,
|
||||||
removeFolderFromWorkspace,
|
removeFolderFromWorkspace,
|
||||||
|
reorderFolders,
|
||||||
refreshFolder,
|
refreshFolder,
|
||||||
stats,
|
stats,
|
||||||
activeFolderId,
|
activeFolderId,
|
||||||
@@ -369,6 +411,7 @@ export function AppWorkspaceProvider({ children }: AppWorkspaceProviderProps) {
|
|||||||
openFolder,
|
openFolder,
|
||||||
addFolderToWorkspaceById,
|
addFolderToWorkspaceById,
|
||||||
removeFolderFromWorkspace,
|
removeFolderFromWorkspace,
|
||||||
|
reorderFolders,
|
||||||
refreshFolder,
|
refreshFolder,
|
||||||
stats,
|
stats,
|
||||||
activeFolderId,
|
activeFolderId,
|
||||||
|
|||||||
@@ -781,9 +781,11 @@
|
|||||||
"folderOpened": "تم فتح المجلد {name}",
|
"folderOpened": "تم فتح المجلد {name}",
|
||||||
"folderRemoved": "تمت إزالة المجلد {name}",
|
"folderRemoved": "تمت إزالة المجلد {name}",
|
||||||
"openFolderFailed": "فشل فتح المجلد",
|
"openFolderFailed": "فشل فتح المجلد",
|
||||||
"removeFolderFailed": "فشل إزالة المجلد: {message}"
|
"removeFolderFailed": "فشل إزالة المجلد: {message}",
|
||||||
|
"reorderFoldersFailed": "فشل إعادة ترتيب المجلدات: {message}"
|
||||||
},
|
},
|
||||||
"statsLabel": "{folders} مجلدات · {convos} محادثة",
|
"statsLabel": "{folders} مجلدات · {convos} محادثة",
|
||||||
|
"reorderHandle": "اسحب لإعادة الترتيب",
|
||||||
"openFolder": "فتح مجلد",
|
"openFolder": "فتح مجلد",
|
||||||
"searchPlaceholder": "بحث عن محادثات...",
|
"searchPlaceholder": "بحث عن محادثات...",
|
||||||
"showCompleted": "عرض المحادثات المكتملة",
|
"showCompleted": "عرض المحادثات المكتملة",
|
||||||
|
|||||||
@@ -781,9 +781,11 @@
|
|||||||
"folderOpened": "Ordner {name} geöffnet",
|
"folderOpened": "Ordner {name} geöffnet",
|
||||||
"folderRemoved": "Ordner {name} entfernt",
|
"folderRemoved": "Ordner {name} entfernt",
|
||||||
"openFolderFailed": "Ordner konnte nicht geöffnet werden",
|
"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",
|
"statsLabel": "{folders} Ordner · {convos} Konversationen",
|
||||||
|
"reorderHandle": "Zum Neuordnen ziehen",
|
||||||
"openFolder": "Ordner öffnen",
|
"openFolder": "Ordner öffnen",
|
||||||
"searchPlaceholder": "Konversationen suchen...",
|
"searchPlaceholder": "Konversationen suchen...",
|
||||||
"showCompleted": "Abgeschlossene Konversationen anzeigen",
|
"showCompleted": "Abgeschlossene Konversationen anzeigen",
|
||||||
|
|||||||
@@ -781,9 +781,11 @@
|
|||||||
"folderOpened": "Opened folder {name}",
|
"folderOpened": "Opened folder {name}",
|
||||||
"folderRemoved": "Removed folder {name}",
|
"folderRemoved": "Removed folder {name}",
|
||||||
"openFolderFailed": "Failed to open folder",
|
"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",
|
"statsLabel": "{folders} folders · {convos} conversations",
|
||||||
|
"reorderHandle": "Drag to reorder",
|
||||||
"openFolder": "Open Folder",
|
"openFolder": "Open Folder",
|
||||||
"searchPlaceholder": "Search conversations...",
|
"searchPlaceholder": "Search conversations...",
|
||||||
"showCompleted": "Show completed conversations",
|
"showCompleted": "Show completed conversations",
|
||||||
|
|||||||
@@ -781,9 +781,11 @@
|
|||||||
"folderOpened": "Carpeta {name} abierta",
|
"folderOpened": "Carpeta {name} abierta",
|
||||||
"folderRemoved": "Carpeta {name} eliminada",
|
"folderRemoved": "Carpeta {name} eliminada",
|
||||||
"openFolderFailed": "Error al abrir carpeta",
|
"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",
|
"statsLabel": "{folders} carpetas · {convos} conversaciones",
|
||||||
|
"reorderHandle": "Arrastrar para reordenar",
|
||||||
"openFolder": "Abrir carpeta",
|
"openFolder": "Abrir carpeta",
|
||||||
"searchPlaceholder": "Buscar conversaciones...",
|
"searchPlaceholder": "Buscar conversaciones...",
|
||||||
"showCompleted": "Mostrar conversaciones completadas",
|
"showCompleted": "Mostrar conversaciones completadas",
|
||||||
|
|||||||
@@ -781,9 +781,11 @@
|
|||||||
"folderOpened": "Dossier {name} ouvert",
|
"folderOpened": "Dossier {name} ouvert",
|
||||||
"folderRemoved": "Dossier {name} retiré",
|
"folderRemoved": "Dossier {name} retiré",
|
||||||
"openFolderFailed": "Échec de l'ouverture du dossier",
|
"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",
|
"statsLabel": "{folders} dossiers · {convos} conversations",
|
||||||
|
"reorderHandle": "Glisser pour réorganiser",
|
||||||
"openFolder": "Ouvrir le dossier",
|
"openFolder": "Ouvrir le dossier",
|
||||||
"searchPlaceholder": "Rechercher des conversations...",
|
"searchPlaceholder": "Rechercher des conversations...",
|
||||||
"showCompleted": "Afficher les conversations terminées",
|
"showCompleted": "Afficher les conversations terminées",
|
||||||
|
|||||||
@@ -781,9 +781,11 @@
|
|||||||
"folderOpened": "フォルダ {name} を開きました",
|
"folderOpened": "フォルダ {name} を開きました",
|
||||||
"folderRemoved": "フォルダ {name} を削除しました",
|
"folderRemoved": "フォルダ {name} を削除しました",
|
||||||
"openFolderFailed": "フォルダを開けませんでした",
|
"openFolderFailed": "フォルダを開けませんでした",
|
||||||
"removeFolderFailed": "フォルダの削除に失敗しました: {message}"
|
"removeFolderFailed": "フォルダの削除に失敗しました: {message}",
|
||||||
|
"reorderFoldersFailed": "フォルダの並べ替えに失敗しました: {message}"
|
||||||
},
|
},
|
||||||
"statsLabel": "{folders} フォルダ · {convos} 会話",
|
"statsLabel": "{folders} フォルダ · {convos} 会話",
|
||||||
|
"reorderHandle": "ドラッグして並べ替え",
|
||||||
"openFolder": "フォルダを開く",
|
"openFolder": "フォルダを開く",
|
||||||
"searchPlaceholder": "会話を検索...",
|
"searchPlaceholder": "会話を検索...",
|
||||||
"showCompleted": "完了した会話を表示",
|
"showCompleted": "完了した会話を表示",
|
||||||
|
|||||||
@@ -781,9 +781,11 @@
|
|||||||
"folderOpened": "폴더 {name}을(를) 열었습니다",
|
"folderOpened": "폴더 {name}을(를) 열었습니다",
|
||||||
"folderRemoved": "폴더 {name}을(를) 제거했습니다",
|
"folderRemoved": "폴더 {name}을(를) 제거했습니다",
|
||||||
"openFolderFailed": "폴더를 열 수 없습니다",
|
"openFolderFailed": "폴더를 열 수 없습니다",
|
||||||
"removeFolderFailed": "폴더 제거 실패: {message}"
|
"removeFolderFailed": "폴더 제거 실패: {message}",
|
||||||
|
"reorderFoldersFailed": "폴더 순서 변경 실패: {message}"
|
||||||
},
|
},
|
||||||
"statsLabel": "{folders}개 폴더 · {convos}개 대화",
|
"statsLabel": "{folders}개 폴더 · {convos}개 대화",
|
||||||
|
"reorderHandle": "드래그하여 순서 변경",
|
||||||
"openFolder": "폴더 열기",
|
"openFolder": "폴더 열기",
|
||||||
"searchPlaceholder": "대화 검색...",
|
"searchPlaceholder": "대화 검색...",
|
||||||
"showCompleted": "완료된 대화 표시",
|
"showCompleted": "완료된 대화 표시",
|
||||||
|
|||||||
@@ -781,9 +781,11 @@
|
|||||||
"folderOpened": "Pasta {name} aberta",
|
"folderOpened": "Pasta {name} aberta",
|
||||||
"folderRemoved": "Pasta {name} removida",
|
"folderRemoved": "Pasta {name} removida",
|
||||||
"openFolderFailed": "Falha ao abrir pasta",
|
"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",
|
"statsLabel": "{folders} pastas · {convos} conversas",
|
||||||
|
"reorderHandle": "Arraste para reordenar",
|
||||||
"openFolder": "Abrir pasta",
|
"openFolder": "Abrir pasta",
|
||||||
"searchPlaceholder": "Buscar conversas...",
|
"searchPlaceholder": "Buscar conversas...",
|
||||||
"showCompleted": "Mostrar conversas concluídas",
|
"showCompleted": "Mostrar conversas concluídas",
|
||||||
|
|||||||
@@ -781,9 +781,11 @@
|
|||||||
"folderOpened": "已打开文件夹 {name}",
|
"folderOpened": "已打开文件夹 {name}",
|
||||||
"folderRemoved": "已移除文件夹 {name}",
|
"folderRemoved": "已移除文件夹 {name}",
|
||||||
"openFolderFailed": "打开文件夹失败",
|
"openFolderFailed": "打开文件夹失败",
|
||||||
"removeFolderFailed": "移除文件夹失败:{message}"
|
"removeFolderFailed": "移除文件夹失败:{message}",
|
||||||
|
"reorderFoldersFailed": "重新排序文件夹失败:{message}"
|
||||||
},
|
},
|
||||||
"statsLabel": "{folders} 个文件夹 · {convos} 个会话",
|
"statsLabel": "{folders} 个文件夹 · {convos} 个会话",
|
||||||
|
"reorderHandle": "拖拽排序",
|
||||||
"openFolder": "打开文件夹",
|
"openFolder": "打开文件夹",
|
||||||
"searchPlaceholder": "搜索会话...",
|
"searchPlaceholder": "搜索会话...",
|
||||||
"showCompleted": "显示已完成会话",
|
"showCompleted": "显示已完成会话",
|
||||||
|
|||||||
@@ -781,9 +781,11 @@
|
|||||||
"folderOpened": "已開啟資料夾 {name}",
|
"folderOpened": "已開啟資料夾 {name}",
|
||||||
"folderRemoved": "已移除資料夾 {name}",
|
"folderRemoved": "已移除資料夾 {name}",
|
||||||
"openFolderFailed": "開啟資料夾失敗",
|
"openFolderFailed": "開啟資料夾失敗",
|
||||||
"removeFolderFailed": "移除資料夾失敗:{message}"
|
"removeFolderFailed": "移除資料夾失敗:{message}",
|
||||||
|
"reorderFoldersFailed": "重新排序資料夾失敗:{message}"
|
||||||
},
|
},
|
||||||
"statsLabel": "{folders} 個資料夾 · {convos} 個對話",
|
"statsLabel": "{folders} 個資料夾 · {convos} 個對話",
|
||||||
|
"reorderHandle": "拖拽排序",
|
||||||
"openFolder": "開啟資料夾",
|
"openFolder": "開啟資料夾",
|
||||||
"searchPlaceholder": "搜尋對話...",
|
"searchPlaceholder": "搜尋對話...",
|
||||||
"showCompleted": "顯示已完成對話",
|
"showCompleted": "顯示已完成對話",
|
||||||
|
|||||||
@@ -640,6 +640,10 @@ export async function removeFolderFromWorkspace(
|
|||||||
return getTransport().call("remove_folder_from_workspace", { folderId })
|
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(
|
export async function importLocalConversations(
|
||||||
folderId: number
|
folderId: number
|
||||||
): Promise<ImportResult> {
|
): Promise<ImportResult> {
|
||||||
|
|||||||
@@ -508,6 +508,10 @@ export async function removeFolderFromWorkspace(
|
|||||||
return invoke("remove_folder_from_workspace", { folderId })
|
return invoke("remove_folder_from_workspace", { folderId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reorderFolders(ids: number[]): Promise<void> {
|
||||||
|
return invoke("reorder_folders", { ids })
|
||||||
|
}
|
||||||
|
|
||||||
export async function importLocalConversations(
|
export async function importLocalConversations(
|
||||||
folderId: number
|
folderId: number
|
||||||
): Promise<ImportResult> {
|
): Promise<ImportResult> {
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ export interface FolderDetail {
|
|||||||
parent_branch: string | null
|
parent_branch: string | null
|
||||||
default_agent_type: AgentType | null
|
default_agent_type: AgentType | null
|
||||||
last_opened_at: string
|
last_opened_at: string
|
||||||
|
sort_order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenedTab {
|
export interface OpenedTab {
|
||||||
|
|||||||
Reference in New Issue
Block a user