feat(sidebar): add per-folder color swatch with picker and neutral conversation rail

- Add `color` column to folder table with migration backfill and hash-based assignment on folder creation
- Expose `update_folder_color` via Tauri command and `/update_folder_color` HTTP route
- Render a color swatch before each folder name in the sidebar header; offer a 10-color palette (9 hues plus a theme-aware foreground sentinel) through the folder context menu
- Show the folder header "new conversation" button only on hover
- Drop the expanded-state tint on folder name and count badge; use a fixed neutral rail color for conversation items
This commit is contained in:
xintaofei
2026-04-23 23:02:58 +08:00
parent b7eeeb0be4
commit 1eeb5041a8
23 changed files with 300 additions and 31 deletions

View File

@@ -567,6 +567,19 @@ pub async fn reorder_folders(
.map_err(AppCommandError::from) .map_err(AppCommandError::from)
} }
#[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn update_folder_color(
db: tauri::State<'_, AppDatabase>,
folder_id: i32,
color: String,
) -> Result<crate::models::FolderDetail, AppCommandError> {
folder_service::update_folder_color(&db.conn, folder_id, &color)
.await
.map_err(AppCommandError::from)?
.ok_or_else(|| AppCommandError::not_found("Folder not found"))
}
#[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)

View File

@@ -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 sort_order: i32, pub sort_order: i32,
pub color: String,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -0,0 +1,60 @@
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::Color)
.string()
.not_null()
.default("#22c55e"),
)
.to_owned(),
)
.await?;
// Backfill existing rows with a palette color chosen by (id - 1) mod N,
// so existing workspaces get visually distinct swatches after migration.
let conn = manager.get_connection();
let sql = "UPDATE folder SET color = CASE ((id - 1) % 9) \
WHEN 0 THEN '#ef4444' \
WHEN 1 THEN '#f97316' \
WHEN 2 THEN '#eab308' \
WHEN 3 THEN '#84cc16' \
WHEN 4 THEN '#22c55e' \
WHEN 5 THEN '#06b6d4' \
WHEN 6 THEN '#8b5cf6' \
WHEN 7 THEN '#d946ef' \
WHEN 8 THEN '#ec4899' \
END";
conn.execute(Statement::from_string(DbBackend::Sqlite, sql.to_string()))
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Folder::Table)
.drop_column(Folder::Color)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum Folder {
Table,
Color,
}

View File

@@ -12,6 +12,7 @@ mod m20260406_000001_agent_setting_model_provider;
mod m20260420_000001_opened_tabs; mod m20260420_000001_opened_tabs;
mod m20260422_000001_folder_sort_order; mod m20260422_000001_folder_sort_order;
mod m20260423_000001_drop_folder_parent_branch; mod m20260423_000001_drop_folder_parent_branch;
mod m20260424_000001_folder_color;
pub struct Migrator; pub struct Migrator;
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -30,6 +31,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260420_000001_opened_tabs::Migration), Box::new(m20260420_000001_opened_tabs::Migration),
Box::new(m20260422_000001_folder_sort_order::Migration), Box::new(m20260422_000001_folder_sort_order::Migration),
Box::new(m20260423_000001_drop_folder_parent_branch::Migration), Box::new(m20260423_000001_drop_folder_parent_branch::Migration),
Box::new(m20260424_000001_folder_color::Migration),
] ]
} }
} }

View File

@@ -10,6 +10,35 @@ use crate::db::error::DbError;
use crate::models::agent::AgentType; use crate::models::agent::AgentType;
use crate::models::{FolderDetail, FolderHistoryEntry}; use crate::models::{FolderDetail, FolderHistoryEntry};
/// Palette kept in sync with the frontend swatch picker. Changes here must be
/// mirrored in `src/components/conversations/sidebar-conversation-list.tsx`.
/// `"foreground"` is a theme-aware sentinel the frontend resolves to
/// `var(--sidebar-foreground)`.
pub const FOLDER_COLOR_PALETTE: &[&str] = &[
"#ef4444",
"#f97316",
"#eab308",
"#84cc16",
"#22c55e",
"#06b6d4",
"#8b5cf6",
"#d946ef",
"#ec4899",
"foreground",
];
fn pick_folder_color(folder_id: i32, folder_name: &str) -> String {
let mut name_hash: u32 = 0;
for c in folder_name.chars() {
name_hash = name_hash.wrapping_mul(31).wrapping_add(c as u32);
}
let combined = (folder_id as u32)
.wrapping_mul(2654435761)
.wrapping_add(name_hash);
let idx = (combined as usize) % FOLDER_COLOR_PALETTE.len();
FOLDER_COLOR_PALETTE[idx].to_string()
}
fn to_entry(m: folder::Model) -> FolderHistoryEntry { fn to_entry(m: folder::Model) -> FolderHistoryEntry {
FolderHistoryEntry { FolderHistoryEntry {
id: m.id, id: m.id,
@@ -34,6 +63,7 @@ fn to_detail(m: folder::Model) -> FolderDetail {
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, sort_order: m.sort_order,
color: m.color,
} }
} }
@@ -81,7 +111,7 @@ pub async fn add_folder(
.unwrap_or(0); .unwrap_or(0);
let active = folder::ActiveModel { let active = folder::ActiveModel {
id: NotSet, id: NotSet,
name: Set(name), name: Set(name.clone()),
path: Set(path.to_string()), path: Set(path.to_string()),
git_branch: Set(None), git_branch: Set(None),
default_agent_type: Set(None), default_agent_type: Set(None),
@@ -91,13 +121,42 @@ pub async fn add_folder(
deleted_at: Set(None), deleted_at: Set(None),
is_open: Set(true), is_open: Set(true),
sort_order: Set(max_order + 1), sort_order: Set(max_order + 1),
// Temporary placeholder — we overwrite below with a hash derived
// from the final auto-assigned id so each new folder gets a
// deterministic, well-distributed palette color.
color: Set(FOLDER_COLOR_PALETTE[0].to_string()),
}; };
active.insert(conn).await? let inserted = active.insert(conn).await?;
let assigned = pick_folder_color(inserted.id, &name);
let mut active = inserted.into_active_model();
active.color = Set(assigned);
active.update(conn).await?
}; };
Ok(to_entry(model)) Ok(to_entry(model))
} }
pub async fn update_folder_color(
conn: &DatabaseConnection,
folder_id: i32,
color: &str,
) -> Result<Option<FolderDetail>, DbError> {
let row = folder::Entity::find_by_id(folder_id)
.filter(folder::Column::DeletedAt.is_null())
.one(conn)
.await?;
let Some(row) = row else {
return Ok(None);
};
let mut active = row.into_active_model();
active.color = Set(color.to_string());
active.updated_at = Set(Utc::now());
let updated = active.update(conn).await?;
Ok(Some(to_detail(updated)))
}
pub async fn list_folders(conn: &DatabaseConnection) -> Result<Vec<FolderHistoryEntry>, DbError> { pub async fn list_folders(conn: &DatabaseConnection) -> Result<Vec<FolderHistoryEntry>, DbError> {
let rows = folder::Entity::find() let rows = folder::Entity::find()
.filter(folder::Column::DeletedAt.is_null()) .filter(folder::Column::DeletedAt.is_null())

View File

@@ -221,6 +221,7 @@ mod tauri_app {
folders::open_folder_by_id, folders::open_folder_by_id,
folders::remove_folder_from_workspace, folders::remove_folder_from_workspace,
folders::reorder_folders, folders::reorder_folders,
folders::update_folder_color,
folders::add_folder_to_history, folders::add_folder_to_history,
folders::remove_folder_from_history, folders::remove_folder_from_history,
folders::create_folder_directory, folders::create_folder_directory,

View File

@@ -20,6 +20,7 @@ pub struct FolderDetail {
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, pub sort_order: i32,
pub color: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -140,6 +140,25 @@ pub async fn reorder_folders(
Ok(Json(())) Ok(Json(()))
} }
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateFolderColorParams {
pub folder_id: i32,
pub color: String,
}
pub async fn update_folder_color(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<UpdateFolderColorParams>,
) -> Result<Json<FolderDetail>, AppCommandError> {
let db = &state.db;
let folder = folder_service::update_folder_color(&db.conn, params.folder_id, &params.color)
.await
.map_err(AppCommandError::from)?
.ok_or_else(|| AppCommandError::not_found("Folder not found"))?;
Ok(Json(folder))
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PathParams { pub struct PathParams {

View File

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

View File

@@ -125,10 +125,7 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
<span <span
aria-hidden aria-hidden
className={cn( className={cn(
"pointer-events-none absolute z-0", "pointer-events-none absolute z-0 bg-sidebar-border"
isOpenInTab
? "bg-sidebar-primary/85"
: "bg-sidebar-primary/30"
)} )}
style={{ style={{
top: "-0.0625rem", top: "-0.0625rem",

View File

@@ -22,6 +22,7 @@ import {
GitBranch, GitBranch,
ListChecks, ListChecks,
Loader2, Loader2,
Palette,
Plus, Plus,
Rocket, Rocket,
XCircle, XCircle,
@@ -36,6 +37,7 @@ import {
openProjectBootWindow, openProjectBootWindow,
updateConversationTitle, updateConversationTitle,
updateConversationStatus, updateConversationStatus,
updateFolderColor,
deleteConversation, deleteConversation,
} from "@/lib/api" } from "@/lib/api"
import { isDesktop, openFileDialog } from "@/lib/platform" import { isDesktop, openFileDialog } from "@/lib/platform"
@@ -58,6 +60,9 @@ import {
ContextMenuContent, ContextMenuContent,
ContextMenuItem, ContextMenuItem,
ContextMenuSeparator, ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
} from "@/components/ui/context-menu" } from "@/components/ui/context-menu"
import { import {
AlertDialog, AlertDialog,
@@ -106,6 +111,31 @@ function compareByCreatedAtDesc(
return right.id - left.id return right.id - left.id
} }
// Sentinel stored in the DB that resolves to the current sidebar foreground
// color — the swatch then always reads as the folder name does, across themes.
const FOREGROUND_SWATCH = "foreground"
// Kept in sync with Rust-side `FOLDER_COLOR_PALETTE` in
// `src-tauri/src/db/service/folder_service.rs`. Nine well-separated hues
// spanning the color wheel (skipping the blue band that reads as muddy),
// plus a theme-aware neutral that tracks the sidebar text color.
const FOLDER_SWATCH_PALETTE = [
"#ef4444", // red
"#f97316", // orange
"#eab308", // yellow
"#84cc16", // lime
"#22c55e", // green
"#06b6d4", // cyan
"#8b5cf6", // violet
"#d946ef", // fuchsia
"#ec4899", // pink
FOREGROUND_SWATCH,
] as const
function resolveSwatchColor(swatch: string): string {
return swatch === FOREGROUND_SWATCH ? "var(--sidebar-foreground)" : swatch
}
function formatRelative(iso: string): string { function formatRelative(iso: string): string {
const ts = parseTimestamp(iso) const ts = parseTimestamp(iso)
if (!ts) return "" if (!ts) return ""
@@ -129,11 +159,13 @@ const FolderHeader = memo(function FolderHeader({
count, count,
expanded, expanded,
importing, importing,
color,
onToggle, onToggle,
onRemoveFromWorkspace, onRemoveFromWorkspace,
onNewConversation, onNewConversation,
onImport, onImport,
onManageConversations, onManageConversations,
onChangeColor,
isDragging, isDragging,
t, t,
}: { }: {
@@ -142,11 +174,13 @@ const FolderHeader = memo(function FolderHeader({
count: number count: number
expanded: boolean expanded: boolean
importing: boolean importing: boolean
color: string
onToggle: (folderId: number) => void onToggle: (folderId: number) => void
onRemoveFromWorkspace: (folderId: number) => void onRemoveFromWorkspace: (folderId: number) => void
onNewConversation: (folderId: number) => void onNewConversation: (folderId: number) => void
onImport: (folderId: number) => void onImport: (folderId: number) => void
onManageConversations: (folderId: number) => void onManageConversations: (folderId: number) => void
onChangeColor: (folderId: number, color: string) => void
isDragging?: boolean isDragging?: boolean
t: ReturnType<typeof useTranslations> t: ReturnType<typeof useTranslations>
}) { }) {
@@ -156,7 +190,7 @@ const FolderHeader = memo(function FolderHeader({
<div className={cn("relative h-[2rem]", isDragging && "opacity-60")}> <div className={cn("relative h-[2rem]", isDragging && "opacity-60")}>
<div <div
className={cn( className={cn(
"flex h-[1.9375rem] w-full items-center", "group flex h-[1.9375rem] w-full items-center",
"rounded-full", "rounded-full",
"transition-colors duration-150", "transition-colors duration-150",
isDragging isDragging
@@ -193,12 +227,15 @@ const FolderHeader = memo(function FolderHeader({
<ChevronRight className="h-[0.6875rem] w-[0.6875rem]" /> <ChevronRight className="h-[0.6875rem] w-[0.6875rem]" />
)} )}
</span> </span>
<div className="flex min-w-0 flex-1 items-center gap-[0.375rem]"> <div className="flex min-w-0 flex-1 items-center gap-[0.5rem]">
<span
aria-hidden
className="inline-block h-[0.5rem] w-[0.5rem] shrink-0 rounded-[0.125rem]"
style={{ backgroundColor: resolveSwatchColor(color) }}
/>
<span <span
className={cn( className={cn(
"min-w-0 flex-shrink truncate text-left text-[0.875rem] font-semibold tracking-[-0.00625rem]", "min-w-0 flex-shrink truncate text-left text-[0.875rem] font-semibold tracking-[-0.00625rem]"
"transition-colors duration-150",
expanded && "text-sidebar-primary"
)} )}
> >
{folderName} {folderName}
@@ -208,10 +245,7 @@ const FolderHeader = memo(function FolderHeader({
"inline-flex shrink-0 items-center justify-center", "inline-flex shrink-0 items-center justify-center",
"h-[0.9375rem] min-w-[1rem] rounded-[0.3125rem] px-[0.25rem]", "h-[0.9375rem] min-w-[1rem] rounded-[0.3125rem] px-[0.25rem]",
"text-[0.625rem] font-semibold leading-none tabular-nums", "text-[0.625rem] font-semibold leading-none tabular-nums",
"transition-colors duration-150", "bg-[color-mix(in_oklab,var(--sidebar-accent),var(--sidebar-foreground)_6%)] text-muted-foreground/80"
expanded
? "bg-sidebar-primary/20 text-sidebar-primary"
: "bg-[color-mix(in_oklab,var(--sidebar-accent),var(--sidebar-foreground)_6%)] text-muted-foreground/80"
)} )}
> >
{count} {count}
@@ -229,10 +263,8 @@ const FolderHeader = memo(function FolderHeader({
className={cn( className={cn(
"mr-[0.25rem] flex h-[1.25rem] w-[1.25rem] shrink-0 items-center justify-center", "mr-[0.25rem] flex h-[1.25rem] w-[1.25rem] shrink-0 items-center justify-center",
"rounded-[0.25rem] cursor-pointer outline-none text-muted-foreground/80", "rounded-[0.25rem] cursor-pointer outline-none text-muted-foreground/80",
"transition-colors duration-150", "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
expanded "transition-opacity duration-150 hover:text-sidebar-foreground"
? "hover:text-sidebar-primary"
: "hover:text-sidebar-foreground"
)} )}
> >
<Plus className="h-[0.75rem] w-[0.75rem]" /> <Plus className="h-[0.75rem] w-[0.75rem]" />
@@ -257,6 +289,35 @@ const FolderHeader = memo(function FolderHeader({
<ListChecks className="h-4 w-4" /> <ListChecks className="h-4 w-4" />
{t("folderHeaderMenu.manageConversations")} {t("folderHeaderMenu.manageConversations")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>
<Palette className="h-4 w-4" />
{t("folderHeaderMenu.changeColor")}
</ContextMenuSubTrigger>
<ContextMenuSubContent className="min-w-[9rem] p-2">
<div className="grid grid-cols-10 gap-1">
{FOLDER_SWATCH_PALETTE.map((swatch) => {
const active = swatch.toLowerCase() === color.toLowerCase()
return (
<button
key={swatch}
type="button"
title={swatch}
aria-label={swatch}
onClick={() => onChangeColor(folderId, swatch)}
className={cn(
"h-[1.125rem] w-[1.125rem] cursor-pointer rounded-[0.25rem]",
"outline-none ring-offset-1 ring-offset-popover",
"transition-[box-shadow,transform] duration-100 hover:scale-110",
active && "ring-2 ring-foreground/60"
)}
style={{ backgroundColor: resolveSwatchColor(swatch) }}
/>
)
})}
</div>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
variant="destructive" variant="destructive"
@@ -281,11 +342,13 @@ interface FolderGroupItemProps {
sortMode: SidebarSortMode sortMode: SidebarSortMode
selectedConversation: { id: number; agentType: string } | null selectedConversation: { id: number; agentType: string } | null
openTabConversationKeys: Set<string> openTabConversationKeys: Set<string>
color: string
onToggle: (folderId: number) => void onToggle: (folderId: number) => void
onRemoveFromWorkspace: (folderId: number) => void onRemoveFromWorkspace: (folderId: number) => void
onNewConversationForFolder: (folderId: number) => void onNewConversationForFolder: (folderId: number) => void
onImport: (folderId: number) => void onImport: (folderId: number) => void
onManageConversations: (folderId: number) => void onManageConversations: (folderId: number) => void
onChangeColor: (folderId: number, color: string) => void
onSelect: (id: number, agentType: string) => void onSelect: (id: number, agentType: string) => void
onDoubleClick: (id: number, agentType: string) => void onDoubleClick: (id: number, agentType: string) => void
onRename: (id: number, newTitle: string) => Promise<void> onRename: (id: number, newTitle: string) => Promise<void>
@@ -311,11 +374,13 @@ function FolderGroupItem({
sortMode, sortMode,
selectedConversation, selectedConversation,
openTabConversationKeys, openTabConversationKeys,
color,
onToggle, onToggle,
onRemoveFromWorkspace, onRemoveFromWorkspace,
onNewConversationForFolder, onNewConversationForFolder,
onImport, onImport,
onManageConversations, onManageConversations,
onChangeColor,
onSelect, onSelect,
onDoubleClick, onDoubleClick,
onRename, onRename,
@@ -379,11 +444,13 @@ function FolderGroupItem({
count={conversations.length} count={conversations.length}
expanded={expanded} expanded={expanded}
importing={importing} importing={importing}
color={color}
onToggle={handleToggle} onToggle={handleToggle}
onRemoveFromWorkspace={onRemoveFromWorkspace} onRemoveFromWorkspace={onRemoveFromWorkspace}
onNewConversation={onNewConversationForFolder} onNewConversation={onNewConversationForFolder}
onImport={onImport} onImport={onImport}
onManageConversations={onManageConversations} onManageConversations={onManageConversations}
onChangeColor={onChangeColor}
isDragging={dragging} isDragging={dragging}
t={t} t={t}
/> />
@@ -449,6 +516,7 @@ export function SidebarConversationList({
removeFolderFromWorkspace, removeFolderFromWorkspace,
reorderFolders, reorderFolders,
openFolder, openFolder,
refreshFolder,
} = useAppWorkspace() } = useAppWorkspace()
const refreshing = loading const refreshing = loading
const { activeFolder } = useActiveFolder() const { activeFolder } = useActiveFolder()
@@ -464,8 +532,9 @@ export function SidebarConversationList({
const { addTask, updateTask } = useTaskContext() const { addTask, updateTask } = useTaskContext()
const folderIndex = useMemo(() => { const folderIndex = useMemo(() => {
const map = new Map<number, { name: string; path: string }>() const map = new Map<number, { name: string; path: string; color: string }>()
for (const f of allFolders) map.set(f.id, { name: f.name, path: f.path }) for (const f of allFolders)
map.set(f.id, { name: f.name, path: f.path, color: f.color })
return map return map
}, [allFolders]) }, [allFolders])
@@ -513,6 +582,19 @@ export function SidebarConversationList({
setFolderExpanded(loadFolderExpanded()) setFolderExpanded(loadFolderExpanded())
}, []) }, [])
const handleChangeFolderColor = useCallback(
async (folderId: number, color: string) => {
try {
await updateFolderColor(folderId, color)
await refreshFolder(folderId)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
toast.error(t("toasts.changeFolderColorFailed", { message: msg }))
}
},
[refreshFolder, t]
)
const scrollRootRef = useRef<OverlayScrollbarsComponentRef>(null) const scrollRootRef = useRef<OverlayScrollbarsComponentRef>(null)
const scrollToActiveRef = useRef<() => void>(() => {}) const scrollToActiveRef = useRef<() => void>(() => {})
const pendingScrollRef = useRef(false) const pendingScrollRef = useRef(false)
@@ -961,6 +1043,7 @@ export function SidebarConversationList({
sortMode={sortMode} sortMode={sortMode}
selectedConversation={selectedConversation} selectedConversation={selectedConversation}
openTabConversationKeys={openTabConversationKeys} openTabConversationKeys={openTabConversationKeys}
color={folderIndex.get(folderId)?.color ?? "#22c55e"}
onToggle={toggleFolder} onToggle={toggleFolder}
onRemoveFromWorkspace={handleRemoveFolder} onRemoveFromWorkspace={handleRemoveFolder}
onNewConversationForFolder={ onNewConversationForFolder={
@@ -968,6 +1051,7 @@ export function SidebarConversationList({
} }
onImport={handleImportForFolder} onImport={handleImportForFolder}
onManageConversations={handleManageConversations} onManageConversations={handleManageConversations}
onChangeColor={handleChangeFolderColor}
onSelect={handleSelect} onSelect={handleSelect}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
onRename={handleRename} onRename={handleRename}

View File

@@ -782,7 +782,8 @@
"folderRemoved": "تمت إزالة المجلد {name}", "folderRemoved": "تمت إزالة المجلد {name}",
"openFolderFailed": "فشل فتح المجلد", "openFolderFailed": "فشل فتح المجلد",
"removeFolderFailed": "فشل إزالة المجلد: {message}", "removeFolderFailed": "فشل إزالة المجلد: {message}",
"reorderFoldersFailed": "فشل إعادة ترتيب المجلدات: {message}" "reorderFoldersFailed": "فشل إعادة ترتيب المجلدات: {message}",
"changeFolderColorFailed": "فشل تغيير اللون: {message}"
}, },
"statsLabel": "{folders} مجلدات · {convos} محادثة", "statsLabel": "{folders} مجلدات · {convos} محادثة",
"reorderHandle": "اسحب لإعادة الترتيب", "reorderHandle": "اسحب لإعادة الترتيب",
@@ -802,6 +803,7 @@
"removeFolderConfirmDescription": "إزالة \"{name}\" من مساحة العمل؟ سيتم إغلاق علامات التبويب والمحطات المرتبطة.", "removeFolderConfirmDescription": "إزالة \"{name}\" من مساحة العمل؟ سيتم إغلاق علامات التبويب والمحطات المرتبطة.",
"folderHeaderMenu": { "folderHeaderMenu": {
"manageConversations": "إدارة المحادثات…", "manageConversations": "إدارة المحادثات…",
"changeColor": "تغيير اللون",
"removeFromWorkspace": "إزالة من مساحة العمل" "removeFromWorkspace": "إزالة من مساحة العمل"
}, },
"manageConversations": { "manageConversations": {

View File

@@ -782,7 +782,8 @@
"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}" "reorderFoldersFailed": "Ordner konnten nicht neu sortiert werden: {message}",
"changeFolderColorFailed": "Farbe konnte nicht geändert werden: {message}"
}, },
"statsLabel": "{folders} Ordner · {convos} Konversationen", "statsLabel": "{folders} Ordner · {convos} Konversationen",
"reorderHandle": "Zum Neuordnen ziehen", "reorderHandle": "Zum Neuordnen ziehen",
@@ -802,6 +803,7 @@
"removeFolderConfirmDescription": "\"{name}\" aus dem Arbeitsbereich entfernen? Zugehörige Tabs und Terminals werden geschlossen.", "removeFolderConfirmDescription": "\"{name}\" aus dem Arbeitsbereich entfernen? Zugehörige Tabs und Terminals werden geschlossen.",
"folderHeaderMenu": { "folderHeaderMenu": {
"manageConversations": "Konversationen verwalten…", "manageConversations": "Konversationen verwalten…",
"changeColor": "Farbe ändern",
"removeFromWorkspace": "Aus Arbeitsbereich entfernen" "removeFromWorkspace": "Aus Arbeitsbereich entfernen"
}, },
"manageConversations": { "manageConversations": {

View File

@@ -782,7 +782,8 @@
"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}" "reorderFoldersFailed": "Failed to reorder folders: {message}",
"changeFolderColorFailed": "Failed to change folder color: {message}"
}, },
"statsLabel": "{folders} folders · {convos} conversations", "statsLabel": "{folders} folders · {convos} conversations",
"reorderHandle": "Drag to reorder", "reorderHandle": "Drag to reorder",
@@ -802,6 +803,7 @@
"removeFolderConfirmDescription": "Remove \"{name}\" from the workspace? Its tabs and terminals will close.", "removeFolderConfirmDescription": "Remove \"{name}\" from the workspace? Its tabs and terminals will close.",
"folderHeaderMenu": { "folderHeaderMenu": {
"manageConversations": "Manage conversations…", "manageConversations": "Manage conversations…",
"changeColor": "Change color",
"removeFromWorkspace": "Remove from workspace" "removeFromWorkspace": "Remove from workspace"
}, },
"manageConversations": { "manageConversations": {

View File

@@ -782,7 +782,8 @@
"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}" "reorderFoldersFailed": "Error al reordenar carpetas: {message}",
"changeFolderColorFailed": "Error al cambiar el color: {message}"
}, },
"statsLabel": "{folders} carpetas · {convos} conversaciones", "statsLabel": "{folders} carpetas · {convos} conversaciones",
"reorderHandle": "Arrastrar para reordenar", "reorderHandle": "Arrastrar para reordenar",
@@ -802,6 +803,7 @@
"removeFolderConfirmDescription": "¿Eliminar \"{name}\" del espacio de trabajo? Sus pestañas y terminales se cerrarán.", "removeFolderConfirmDescription": "¿Eliminar \"{name}\" del espacio de trabajo? Sus pestañas y terminales se cerrarán.",
"folderHeaderMenu": { "folderHeaderMenu": {
"manageConversations": "Gestionar conversaciones…", "manageConversations": "Gestionar conversaciones…",
"changeColor": "Cambiar color",
"removeFromWorkspace": "Quitar del espacio de trabajo" "removeFromWorkspace": "Quitar del espacio de trabajo"
}, },
"manageConversations": { "manageConversations": {

View File

@@ -782,7 +782,8 @@
"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}" "reorderFoldersFailed": "Échec du réordonnancement des dossiers : {message}",
"changeFolderColorFailed": "Échec du changement de couleur : {message}"
}, },
"statsLabel": "{folders} dossiers · {convos} conversations", "statsLabel": "{folders} dossiers · {convos} conversations",
"reorderHandle": "Glisser pour réorganiser", "reorderHandle": "Glisser pour réorganiser",
@@ -802,6 +803,7 @@
"removeFolderConfirmDescription": "Retirer \"{name}\" de l'espace de travail ? Les onglets et terminaux associés seront fermés.", "removeFolderConfirmDescription": "Retirer \"{name}\" de l'espace de travail ? Les onglets et terminaux associés seront fermés.",
"folderHeaderMenu": { "folderHeaderMenu": {
"manageConversations": "Gérer les conversations…", "manageConversations": "Gérer les conversations…",
"changeColor": "Changer la couleur",
"removeFromWorkspace": "Retirer de l'espace de travail" "removeFromWorkspace": "Retirer de l'espace de travail"
}, },
"manageConversations": { "manageConversations": {

View File

@@ -782,7 +782,8 @@
"folderRemoved": "フォルダ {name} を削除しました", "folderRemoved": "フォルダ {name} を削除しました",
"openFolderFailed": "フォルダを開けませんでした", "openFolderFailed": "フォルダを開けませんでした",
"removeFolderFailed": "フォルダの削除に失敗しました: {message}", "removeFolderFailed": "フォルダの削除に失敗しました: {message}",
"reorderFoldersFailed": "フォルダの並べ替えに失敗しました: {message}" "reorderFoldersFailed": "フォルダの並べ替えに失敗しました: {message}",
"changeFolderColorFailed": "色の変更に失敗しました: {message}"
}, },
"statsLabel": "{folders} フォルダ · {convos} 会話", "statsLabel": "{folders} フォルダ · {convos} 会話",
"reorderHandle": "ドラッグして並べ替え", "reorderHandle": "ドラッグして並べ替え",
@@ -802,6 +803,7 @@
"removeFolderConfirmDescription": "\"{name}\" をワークスペースから削除しますか?関連するタブとターミナルが閉じられます。", "removeFolderConfirmDescription": "\"{name}\" をワークスペースから削除しますか?関連するタブとターミナルが閉じられます。",
"folderHeaderMenu": { "folderHeaderMenu": {
"manageConversations": "会話の管理…", "manageConversations": "会話の管理…",
"changeColor": "色を変更",
"removeFromWorkspace": "ワークスペースから削除" "removeFromWorkspace": "ワークスペースから削除"
}, },
"manageConversations": { "manageConversations": {

View File

@@ -782,7 +782,8 @@
"folderRemoved": "폴더 {name}을(를) 제거했습니다", "folderRemoved": "폴더 {name}을(를) 제거했습니다",
"openFolderFailed": "폴더를 열 수 없습니다", "openFolderFailed": "폴더를 열 수 없습니다",
"removeFolderFailed": "폴더 제거 실패: {message}", "removeFolderFailed": "폴더 제거 실패: {message}",
"reorderFoldersFailed": "폴더 순서 변경 실패: {message}" "reorderFoldersFailed": "폴더 순서 변경 실패: {message}",
"changeFolderColorFailed": "색상 변경 실패: {message}"
}, },
"statsLabel": "{folders}개 폴더 · {convos}개 대화", "statsLabel": "{folders}개 폴더 · {convos}개 대화",
"reorderHandle": "드래그하여 순서 변경", "reorderHandle": "드래그하여 순서 변경",
@@ -802,6 +803,7 @@
"removeFolderConfirmDescription": "워크스페이스에서 \"{name}\"을(를) 제거하시겠습니까? 관련 탭과 터미널이 닫힙니다.", "removeFolderConfirmDescription": "워크스페이스에서 \"{name}\"을(를) 제거하시겠습니까? 관련 탭과 터미널이 닫힙니다.",
"folderHeaderMenu": { "folderHeaderMenu": {
"manageConversations": "대화 관리…", "manageConversations": "대화 관리…",
"changeColor": "색상 변경",
"removeFromWorkspace": "워크스페이스에서 제거" "removeFromWorkspace": "워크스페이스에서 제거"
}, },
"manageConversations": { "manageConversations": {

View File

@@ -782,7 +782,8 @@
"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}" "reorderFoldersFailed": "Falha ao reordenar pastas: {message}",
"changeFolderColorFailed": "Falha ao alterar a cor: {message}"
}, },
"statsLabel": "{folders} pastas · {convos} conversas", "statsLabel": "{folders} pastas · {convos} conversas",
"reorderHandle": "Arraste para reordenar", "reorderHandle": "Arraste para reordenar",
@@ -802,6 +803,7 @@
"removeFolderConfirmDescription": "Remover \"{name}\" do espaço de trabalho? As abas e terminais relacionados serão fechados.", "removeFolderConfirmDescription": "Remover \"{name}\" do espaço de trabalho? As abas e terminais relacionados serão fechados.",
"folderHeaderMenu": { "folderHeaderMenu": {
"manageConversations": "Gerenciar conversas…", "manageConversations": "Gerenciar conversas…",
"changeColor": "Alterar cor",
"removeFromWorkspace": "Remover do espaço de trabalho" "removeFromWorkspace": "Remover do espaço de trabalho"
}, },
"manageConversations": { "manageConversations": {

View File

@@ -782,7 +782,8 @@
"folderRemoved": "已移除文件夹 {name}", "folderRemoved": "已移除文件夹 {name}",
"openFolderFailed": "打开文件夹失败", "openFolderFailed": "打开文件夹失败",
"removeFolderFailed": "移除文件夹失败:{message}", "removeFolderFailed": "移除文件夹失败:{message}",
"reorderFoldersFailed": "重新排序文件夹失败:{message}" "reorderFoldersFailed": "重新排序文件夹失败:{message}",
"changeFolderColorFailed": "修改颜色失败:{message}"
}, },
"statsLabel": "{folders} 个文件夹 · {convos} 个会话", "statsLabel": "{folders} 个文件夹 · {convos} 个会话",
"reorderHandle": "拖拽排序", "reorderHandle": "拖拽排序",
@@ -802,6 +803,7 @@
"removeFolderConfirmDescription": "从工作区移除 \"{name}\"?其相关 Tab 与终端将会关闭。", "removeFolderConfirmDescription": "从工作区移除 \"{name}\"?其相关 Tab 与终端将会关闭。",
"folderHeaderMenu": { "folderHeaderMenu": {
"manageConversations": "会话管理…", "manageConversations": "会话管理…",
"changeColor": "修改颜色",
"removeFromWorkspace": "从工作区移除" "removeFromWorkspace": "从工作区移除"
}, },
"manageConversations": { "manageConversations": {

View File

@@ -782,7 +782,8 @@
"folderRemoved": "已移除資料夾 {name}", "folderRemoved": "已移除資料夾 {name}",
"openFolderFailed": "開啟資料夾失敗", "openFolderFailed": "開啟資料夾失敗",
"removeFolderFailed": "移除資料夾失敗:{message}", "removeFolderFailed": "移除資料夾失敗:{message}",
"reorderFoldersFailed": "重新排序資料夾失敗:{message}" "reorderFoldersFailed": "重新排序資料夾失敗:{message}",
"changeFolderColorFailed": "修改顏色失敗:{message}"
}, },
"statsLabel": "{folders} 個資料夾 · {convos} 個對話", "statsLabel": "{folders} 個資料夾 · {convos} 個對話",
"reorderHandle": "拖拽排序", "reorderHandle": "拖拽排序",
@@ -802,6 +803,7 @@
"removeFolderConfirmDescription": "從工作區移除 \"{name}\"?相關分頁與終端機將會關閉。", "removeFolderConfirmDescription": "從工作區移除 \"{name}\"?相關分頁與終端機將會關閉。",
"folderHeaderMenu": { "folderHeaderMenu": {
"manageConversations": "會話管理…", "manageConversations": "會話管理…",
"changeColor": "修改顏色",
"removeFromWorkspace": "從工作區移除" "removeFromWorkspace": "從工作區移除"
}, },
"manageConversations": { "manageConversations": {

View File

@@ -644,6 +644,13 @@ export async function reorderFolders(ids: number[]): Promise<void> {
return getTransport().call("reorder_folders", { ids }) return getTransport().call("reorder_folders", { ids })
} }
export async function updateFolderColor(
folderId: number,
color: string
): Promise<FolderDetail> {
return getTransport().call("update_folder_color", { folderId, color })
}
export async function importLocalConversations( export async function importLocalConversations(
folderId: number folderId: number
): Promise<ImportResult> { ): Promise<ImportResult> {

View File

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