polish sidebar navigation and project icons (#8896)
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
@@ -6,5 +6,6 @@ pub mod git;
|
||||
pub mod git_changes;
|
||||
pub mod model_setup;
|
||||
pub mod path_resolver;
|
||||
pub mod project_icons;
|
||||
pub mod projects;
|
||||
pub mod system;
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const MAX_ICON_CANDIDATES: usize = 18;
|
||||
const MAX_PROJECT_ICON_BYTES: u64 = 512 * 1024;
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectIconCandidate {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub icon: String,
|
||||
pub source_dir: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectIconData {
|
||||
pub icon: String,
|
||||
}
|
||||
|
||||
struct ScoredProjectIconPath {
|
||||
score: i32,
|
||||
path: PathBuf,
|
||||
path_string: String,
|
||||
label: String,
|
||||
source_dir: String,
|
||||
group_key: String,
|
||||
}
|
||||
|
||||
fn is_project_icon_extension(path: &Path) -> bool {
|
||||
matches!(
|
||||
path.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_ascii_lowercase())
|
||||
.as_deref(),
|
||||
Some("svg" | "png" | "ico" | "jpg" | "jpeg" | "webp")
|
||||
)
|
||||
}
|
||||
|
||||
fn is_ignored_icon_search_dir(root: &Path, path: &Path) -> bool {
|
||||
let relative_parent = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new(""));
|
||||
|
||||
relative_parent.components().any(|component| {
|
||||
let name = component.as_os_str().to_string_lossy().to_ascii_lowercase();
|
||||
matches!(
|
||||
name.as_str(),
|
||||
"node_modules" | "target" | "dist" | "build" | ".git" | ".next" | ".turbo"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_generated_icon_variant(path: &Path) -> bool {
|
||||
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
let normalized = file_name.to_ascii_lowercase();
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
let mostly_size_token = stem
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_digit() || matches!(c, 'x' | '@' | '-' | '_'));
|
||||
|
||||
normalized.starts_with("appicon-")
|
||||
|| normalized.starts_with("square")
|
||||
|| normalized.starts_with("storelogo")
|
||||
|| normalized.contains("template")
|
||||
|| normalized.contains("@2x")
|
||||
|| normalized.contains("@3x")
|
||||
|| mostly_size_token
|
||||
|| stem
|
||||
.strip_prefix("icon-")
|
||||
.is_some_and(|suffix| suffix.chars().all(|c| c.is_ascii_digit()))
|
||||
|| stem
|
||||
.strip_prefix("icon@")
|
||||
.is_some_and(|suffix| suffix.ends_with('x'))
|
||||
}
|
||||
|
||||
fn is_likely_project_icon(path: &Path) -> bool {
|
||||
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
let normalized = file_name.to_ascii_lowercase();
|
||||
normalized == "favicon.ico"
|
||||
|| normalized == "favicon.svg"
|
||||
|| normalized == "favicon.png"
|
||||
|| normalized.starts_with("apple-touch-icon")
|
||||
|| normalized.starts_with("mstile-")
|
||||
|| normalized.contains("logo")
|
||||
|| normalized.contains("brand")
|
||||
|| normalized.contains("wordmark")
|
||||
|| normalized.contains("app-icon")
|
||||
|| normalized.contains("appicon")
|
||||
|| normalized.contains("icon")
|
||||
}
|
||||
|
||||
fn project_icon_score(root: &Path, path: &Path) -> i32 {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
let mut score = 100;
|
||||
if file_name.starts_with("favicon") {
|
||||
score -= 35;
|
||||
}
|
||||
if file_name.contains("logo") {
|
||||
score -= 30;
|
||||
}
|
||||
if file_name.contains("brand") || file_name.contains("wordmark") {
|
||||
score -= 25;
|
||||
}
|
||||
if relative.starts_with("public/")
|
||||
|| relative.starts_with("static/")
|
||||
|| relative.starts_with("assets/")
|
||||
|| relative.starts_with("src/assets/")
|
||||
|| relative.starts_with("src/images/")
|
||||
{
|
||||
score -= 20;
|
||||
}
|
||||
score + relative.matches('/').count() as i32
|
||||
}
|
||||
|
||||
fn project_icon_group_key(path: &Path) -> String {
|
||||
let file_stem = path
|
||||
.file_stem()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
let normalized = file_stem
|
||||
.replace("goose-logo", "logo")
|
||||
.replace("logo-codename-goose", "logo")
|
||||
.replace("codename-goose", "logo");
|
||||
|
||||
if normalized.contains("favicon") {
|
||||
"favicon".to_string()
|
||||
} else if normalized.contains("wordmark") {
|
||||
"wordmark".to_string()
|
||||
} else if normalized.contains("brand") {
|
||||
"brand".to_string()
|
||||
} else if normalized.contains("logo") {
|
||||
"logo".to_string()
|
||||
} else if normalized.contains("app-icon") || normalized.contains("appicon") {
|
||||
"app-icon".to_string()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
fn project_icon_root_key(root: &Path) -> String {
|
||||
root.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn project_icon_candidate_group_key(root: &Path, path: &Path) -> String {
|
||||
format!(
|
||||
"{}:{}",
|
||||
project_icon_root_key(root),
|
||||
project_icon_group_key(path)
|
||||
)
|
||||
}
|
||||
|
||||
fn read_project_icon_data_url(path: &Path) -> Result<String, String> {
|
||||
let metadata = fs::metadata(path).map_err(|e| format!("Failed to inspect icon: {}", e))?;
|
||||
if !metadata.is_file() {
|
||||
return Err("Icon path is not a file".to_string());
|
||||
}
|
||||
if metadata.len() > MAX_PROJECT_ICON_BYTES {
|
||||
return Err("Icon file is too large".to_string());
|
||||
}
|
||||
|
||||
let mime = mime_guess::from_path(path)
|
||||
.first_or_octet_stream()
|
||||
.essence_str()
|
||||
.to_string();
|
||||
if !matches!(
|
||||
mime.as_str(),
|
||||
"image/svg+xml"
|
||||
| "image/png"
|
||||
| "image/x-icon"
|
||||
| "image/vnd.microsoft.icon"
|
||||
| "image/jpeg"
|
||||
| "image/webp"
|
||||
) {
|
||||
return Err("Icon file type is not supported".to_string());
|
||||
}
|
||||
|
||||
let bytes = fs::read(path).map_err(|e| format!("Failed to read icon: {}", e))?;
|
||||
Ok(format!(
|
||||
"data:{};base64,{}",
|
||||
mime,
|
||||
general_purpose::STANDARD.encode(bytes)
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn scan_project_icons(working_dirs: Vec<String>) -> Result<Vec<ProjectIconCandidate>, String> {
|
||||
let mut candidates: Vec<ScoredProjectIconPath> = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for dir in working_dirs {
|
||||
let root = PathBuf::from(dir.trim());
|
||||
if !root.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let source_dir = root
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("project")
|
||||
.to_string();
|
||||
|
||||
let walker = ignore::WalkBuilder::new(&root)
|
||||
.max_depth(Some(6))
|
||||
.standard_filters(true)
|
||||
.build();
|
||||
|
||||
for entry in walker.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file()
|
||||
|| is_ignored_icon_search_dir(&root, path)
|
||||
|| is_generated_icon_variant(path)
|
||||
|| !is_project_icon_extension(path)
|
||||
|| !is_likely_project_icon(path)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let path_string = path.to_string_lossy().into_owned();
|
||||
if !seen.insert(path_string.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let relative = path.strip_prefix(&root).unwrap_or(path);
|
||||
let label = relative.to_string_lossy().into_owned();
|
||||
let score = project_icon_score(&root, path);
|
||||
let group_key = project_icon_candidate_group_key(&root, path);
|
||||
candidates.push(ScoredProjectIconPath {
|
||||
score,
|
||||
path: path.to_path_buf(),
|
||||
path_string,
|
||||
label,
|
||||
source_dir: source_dir.clone(),
|
||||
group_key,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort_by(|a, b| a.score.cmp(&b.score).then_with(|| a.label.cmp(&b.label)));
|
||||
|
||||
let mut seen_groups = HashSet::new();
|
||||
let mut icons = Vec::new();
|
||||
for candidate in candidates {
|
||||
if icons.len() >= MAX_ICON_CANDIDATES {
|
||||
break;
|
||||
}
|
||||
if seen_groups.contains(&candidate.group_key) {
|
||||
continue;
|
||||
}
|
||||
let icon = match read_project_icon_data_url(&candidate.path) {
|
||||
Ok(icon) => icon,
|
||||
Err(_) => continue,
|
||||
};
|
||||
seen_groups.insert(candidate.group_key);
|
||||
icons.push(ProjectIconCandidate {
|
||||
id: candidate.path_string.clone(),
|
||||
label: candidate.label,
|
||||
icon,
|
||||
source_dir: candidate.source_dir,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(icons)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn read_project_icon(path: String) -> Result<ProjectIconData, String> {
|
||||
let path = PathBuf::from(path.trim());
|
||||
if !is_project_icon_extension(&path) {
|
||||
return Err("Icon file type is not supported".to_string());
|
||||
}
|
||||
let icon = read_project_icon_data_url(&path)?;
|
||||
Ok(ProjectIconData { icon })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ignored_icon_search_dirs_do_not_match_root_ancestors() {
|
||||
let root = Path::new("/Users/alice/build/myapp");
|
||||
let icon = root.join("public/logo.svg");
|
||||
|
||||
assert!(!is_ignored_icon_search_dir(root, &icon));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_icon_search_dirs_match_descendant_dirs() {
|
||||
let root = Path::new("/Users/alice/projects/myapp");
|
||||
let icon = root.join("dist/logo.svg");
|
||||
|
||||
assert!(is_ignored_icon_search_dir(root, &icon));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_icon_group_keys_distinguish_roots_with_same_basename() {
|
||||
let first_root = Path::new("/work/client");
|
||||
let second_root = Path::new("/archive/client");
|
||||
let first_icon = first_root.join("public/logo.svg");
|
||||
let second_icon = second_root.join("public/logo.svg");
|
||||
|
||||
assert_ne!(
|
||||
project_icon_candidate_group_key(first_root, &first_icon),
|
||||
project_icon_candidate_group_key(second_root, &second_icon)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,8 @@ pub fn run() {
|
||||
commands::projects::list_archived_projects,
|
||||
commands::projects::archive_project,
|
||||
commands::projects::restore_project,
|
||||
commands::project_icons::scan_project_icons,
|
||||
commands::project_icons::read_project_icon,
|
||||
commands::doctor::run_doctor,
|
||||
commands::doctor::run_doctor_fix,
|
||||
commands::git::get_git_state,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Sidebar } from "@/features/sidebar/ui/Sidebar";
|
||||
import { StatusBar } from "@/features/status/ui/StatusBar";
|
||||
import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog";
|
||||
import { archiveProject } from "@/features/projects/api/projects";
|
||||
import type { ProjectInfo } from "@/features/projects/api/projects";
|
||||
@@ -152,17 +151,9 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||
}
|
||||
}, [activeSessionId, activeView]);
|
||||
|
||||
const isHome = activeView === "home";
|
||||
|
||||
const activeSession = activeSessionId
|
||||
? sessionStore.getSession(activeSessionId)
|
||||
: undefined;
|
||||
const modelName =
|
||||
activeView === "chat" ? activeSession?.modelName : undefined;
|
||||
const tokenCount =
|
||||
activeView === "chat" && activeSessionId
|
||||
? chatStore.getSessionRuntime(activeSessionId).tokenState.totalTokens
|
||||
: 0;
|
||||
const homeSession = homeSessionId
|
||||
? sessionStore.getSession(homeSessionId)
|
||||
: undefined;
|
||||
@@ -646,28 +637,9 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [clearActiveSession, sessionStore]);
|
||||
|
||||
const editingProjectProp = useMemo(
|
||||
() =>
|
||||
editingProject
|
||||
? {
|
||||
id: editingProject.id,
|
||||
name: editingProject.name,
|
||||
description: editingProject.description,
|
||||
prompt: editingProject.prompt,
|
||||
icon: editingProject.icon,
|
||||
color: editingProject.color,
|
||||
preferredProvider: editingProject.preferredProvider,
|
||||
preferredModel: editingProject.preferredModel,
|
||||
workingDirs: editingProject.workingDirs,
|
||||
useWorktrees: editingProject.useWorktrees,
|
||||
}
|
||||
: undefined,
|
||||
[editingProject],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground">
|
||||
<TopBar onSettingsClick={() => openSettings()} />
|
||||
<TopBar />
|
||||
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<div
|
||||
@@ -684,6 +656,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||
width={sidebarWidth}
|
||||
isResizing={isResizing}
|
||||
onCollapse={toggleSidebar}
|
||||
onSettingsClick={() => openSettings()}
|
||||
onNavigate={handleNavigate}
|
||||
onNewChatInProject={handleNewChatInProject}
|
||||
onNewChat={() => {
|
||||
@@ -710,7 +683,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||
<div
|
||||
onMouseDown={handleResizeStart}
|
||||
onDoubleClick={handleResizeDoubleClick}
|
||||
className="flex-shrink-0 w-2 h-full cursor-col-resize group flex items-center justify-center"
|
||||
className="flex-shrink-0 w-4 h-full cursor-col-resize group flex items-center justify-center"
|
||||
>
|
||||
<div className="w-px h-8 rounded-full bg-transparent group-hover:bg-border transition-colors" />
|
||||
</div>
|
||||
@@ -735,18 +708,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ease-in-out ${
|
||||
isHome ? "max-h-0 opacity-0" : "max-h-8 opacity-100"
|
||||
}`}
|
||||
>
|
||||
<StatusBar
|
||||
modelName={modelName}
|
||||
sessionId={activeSessionId ?? undefined}
|
||||
tokenCount={tokenCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{settingsOpen && (
|
||||
<SettingsModal
|
||||
initialSection={settingsInitialSection}
|
||||
@@ -769,7 +730,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||
setCreateProjectInitialWorkingDir(null);
|
||||
}}
|
||||
initialWorkingDir={createProjectInitialWorkingDir}
|
||||
editingProject={editingProjectProp}
|
||||
editingProject={editingProject ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { User } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
interface TopBarProps {
|
||||
onSettingsClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TopBar({ onSettingsClick, className }: TopBarProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
export function TopBar({ className }: TopBarProps) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
@@ -20,17 +14,6 @@ export function TopBar({ onSettingsClick, className }: TopBarProps) {
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="min-w-0 flex-1" />
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={onSettingsClick}
|
||||
className="bg-accent text-muted-foreground hover:bg-accent/80"
|
||||
title={t("title")}
|
||||
>
|
||||
<User className="size-3.5" />
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ export function useChatSessionController({
|
||||
id: projectInfo.id,
|
||||
name: projectInfo.name,
|
||||
workingDirs: projectInfo.workingDirs,
|
||||
icon: projectInfo.icon,
|
||||
color: projectInfo.color,
|
||||
})),
|
||||
[projects],
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface ProjectOption {
|
||||
id: string;
|
||||
name: string;
|
||||
workingDirs: string[];
|
||||
icon?: string | null;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,11 @@ export function ChatContextPanel({
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setOpen(activeSessionId, !isOpen)}
|
||||
className={
|
||||
isOpen
|
||||
? "text-muted-foreground transition-opacity duration-150 hover:text-foreground"
|
||||
: "h-9 w-11 rounded-sm border border-border bg-background/80 text-muted-foreground shadow-sm backdrop-blur-sm transition-opacity duration-150 hover:bg-accent/50 hover:text-foreground"
|
||||
}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
|
||||
@@ -32,23 +32,11 @@ import { formatProviderLabel } from "@/shared/ui/icons/ProviderIcons";
|
||||
import { getCatalogEntry } from "@/features/providers/providerCatalog";
|
||||
import { supportsContextCompactionControls } from "../lib/autoCompact";
|
||||
import { requestOpenSettings } from "@/features/settings/lib/settingsEvents";
|
||||
import { ProjectSelectorIcon } from "./ProjectSelectorIcon";
|
||||
|
||||
const NO_PROJECT_VALUE = "__no_project__";
|
||||
const CREATE_PROJECT_VALUE = "__create_project__";
|
||||
|
||||
function ProjectDot({ color }: { color?: string | null }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"inline-block size-2 rounded-full",
|
||||
color ? "" : "bg-muted-foreground/40",
|
||||
)}
|
||||
style={color ? { backgroundColor: color } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChatInputToolbarProps {
|
||||
selectedPersonaId: string | null;
|
||||
// Provider
|
||||
@@ -245,7 +233,7 @@ export function ChatInputToolbar({
|
||||
value={selectedProjectId ?? NO_PROJECT_VALUE}
|
||||
triggerLabel={projectLabel}
|
||||
triggerTitle={projectTitle}
|
||||
icon={<ProjectDot color={selectedProject?.color} />}
|
||||
icon={<ProjectSelectorIcon icon={selectedProject?.icon} />}
|
||||
triggerVariant="toolbar"
|
||||
triggerSize="sm"
|
||||
menuLabel={t("toolbar.chooseProject")}
|
||||
@@ -257,7 +245,7 @@ export function ChatInputToolbar({
|
||||
value: NO_PROJECT_VALUE,
|
||||
label: t("toolbar.noProject"),
|
||||
description: t("toolbar.generalChatWithoutProject"),
|
||||
icon: <ProjectDot />,
|
||||
icon: <ProjectSelectorIcon />,
|
||||
},
|
||||
...availableProjects.map((project) => ({
|
||||
value: project.id,
|
||||
@@ -265,7 +253,7 @@ export function ChatInputToolbar({
|
||||
description: project.workingDirs.length
|
||||
? project.workingDirs.join(", ")
|
||||
: undefined,
|
||||
icon: <ProjectDot color={project.color} />,
|
||||
icon: <ProjectSelectorIcon icon={project.icon} />,
|
||||
})),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ProjectIcon } from "@/features/projects/ui/ProjectIcon";
|
||||
|
||||
export function ProjectSelectorIcon({ icon }: { icon?: string | null }) {
|
||||
if (!icon) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block size-2 rounded-full bg-muted-foreground/40"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectIcon
|
||||
icon={icon}
|
||||
className="size-3.5"
|
||||
imageClassName="size-3.5 rounded-[3px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -18,10 +18,31 @@ export interface ProjectInfo {
|
||||
artifactsDir: string;
|
||||
}
|
||||
|
||||
export interface ProjectIconCandidate {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
sourceDir: string;
|
||||
}
|
||||
|
||||
export interface ProjectIconData {
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export async function listProjects(): Promise<ProjectInfo[]> {
|
||||
return invoke("list_projects");
|
||||
}
|
||||
|
||||
export async function scanProjectIcons(
|
||||
workingDirs: string[],
|
||||
): Promise<ProjectIconCandidate[]> {
|
||||
return invoke("scan_project_icons", { workingDirs });
|
||||
}
|
||||
|
||||
export async function readProjectIcon(path: string): Promise<ProjectIconData> {
|
||||
return invoke("read_project_icon", { path });
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
name: string,
|
||||
description: string,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { readProjectIcon, scanProjectIcons } from "../../api/projects";
|
||||
import { DEFAULT_PROJECT_ICON } from "../../lib/projectIcons";
|
||||
import { useProjectIconSelection } from "../useProjectIconSelection";
|
||||
|
||||
vi.mock("../../api/projects", () => ({
|
||||
scanProjectIcons: vi.fn().mockResolvedValue([]),
|
||||
readProjectIcon: vi.fn().mockResolvedValue({
|
||||
icon: "data:image/png;base64,aWNvbg==",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
open: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
describe("useProjectIconSelection", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(scanProjectIcons).mockResolvedValue([]);
|
||||
vi.mocked(readProjectIcon).mockResolvedValue({
|
||||
icon: "data:image/png;base64,aWNvbg==",
|
||||
});
|
||||
vi.mocked(open).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("scans project working dirs after a short debounce", async () => {
|
||||
vi.mocked(scanProjectIcons).mockResolvedValueOnce([
|
||||
{
|
||||
id: "/repo/public/logo.svg",
|
||||
label: "public/logo.svg",
|
||||
icon: "data:image/svg+xml;base64,bG9nbw==",
|
||||
sourceDir: "repo",
|
||||
},
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useProjectIconSelection({
|
||||
isOpen: true,
|
||||
prompt: "include: /repo",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.iconScanPending).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
expect(scanProjectIcons).toHaveBeenCalledWith(["/repo"]);
|
||||
expect(result.current.iconCandidates).toHaveLength(1);
|
||||
expect(result.current.iconScanPending).toBe(false);
|
||||
});
|
||||
|
||||
it("clears scanned candidates when the dialog is closed", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useProjectIconSelection({
|
||||
isOpen: false,
|
||||
prompt: "include: /repo",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.iconCandidates).toEqual([]);
|
||||
expect(result.current.iconScanPending).toBe(false);
|
||||
expect(scanProjectIcons).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resets and chooses icons while clearing icon errors", async () => {
|
||||
vi.mocked(open).mockResolvedValueOnce("/tmp/logo.png");
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useProjectIconSelection({
|
||||
isOpen: true,
|
||||
prompt: "",
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.chooseIcon("tabler:code");
|
||||
});
|
||||
expect(result.current.icon).toBe("tabler:code");
|
||||
|
||||
await act(async () => {
|
||||
await result.current.chooseCustomIcon({
|
||||
title: "Custom icon",
|
||||
filterName: "Images",
|
||||
});
|
||||
});
|
||||
|
||||
expect(open).toHaveBeenCalledWith({
|
||||
directory: false,
|
||||
multiple: false,
|
||||
title: "Custom icon",
|
||||
filters: [
|
||||
{
|
||||
name: "Images",
|
||||
extensions: ["svg", "png", "ico", "jpg", "jpeg", "webp"],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(readProjectIcon).toHaveBeenCalledWith("/tmp/logo.png");
|
||||
expect(result.current.icon).toBe("data:image/png;base64,aWNvbg==");
|
||||
|
||||
act(() => {
|
||||
result.current.resetIcon(null);
|
||||
});
|
||||
expect(result.current.icon).toBe(DEFAULT_PROJECT_ICON);
|
||||
});
|
||||
|
||||
it("surfaces custom icon upload errors", async () => {
|
||||
vi.mocked(open).mockResolvedValueOnce("/tmp/large-icon.png");
|
||||
vi.mocked(readProjectIcon).mockRejectedValueOnce("Icon file is too large");
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useProjectIconSelection({
|
||||
isOpen: true,
|
||||
prompt: "",
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.chooseCustomIcon({
|
||||
title: "Custom icon",
|
||||
filterName: "Images",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.iconError).toBe("Icon file is too large");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
readProjectIcon,
|
||||
scanProjectIcons,
|
||||
type ProjectIconCandidate,
|
||||
} from "../api/projects";
|
||||
import {
|
||||
DEFAULT_PROJECT_ICON,
|
||||
normalizeProjectIcon,
|
||||
} from "../lib/projectIcons";
|
||||
import { parseEditorText } from "../lib/projectPromptText";
|
||||
|
||||
interface ChooseCustomProjectIconOptions {
|
||||
title: string;
|
||||
filterName: string;
|
||||
}
|
||||
|
||||
export function useProjectIconSelection({
|
||||
isOpen,
|
||||
prompt,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
prompt: string;
|
||||
}) {
|
||||
const [icon, setIcon] = useState(DEFAULT_PROJECT_ICON);
|
||||
const [iconError, setIconError] = useState<string | null>(null);
|
||||
const [iconCandidates, setIconCandidates] = useState<ProjectIconCandidate[]>(
|
||||
[],
|
||||
);
|
||||
const [iconScanPending, setIconScanPending] = useState(false);
|
||||
|
||||
const scannedWorkingDirKey = useMemo(
|
||||
() => parseEditorText(prompt).workingDirs.join("\n"),
|
||||
[prompt],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const workingDirs = scannedWorkingDirKey.split("\n").filter(Boolean);
|
||||
if (!isOpen || workingDirs.length === 0) {
|
||||
setIconCandidates([]);
|
||||
setIconScanPending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
setIconScanPending(true);
|
||||
const timeout = window.setTimeout(() => {
|
||||
scanProjectIcons(workingDirs)
|
||||
.then((candidates) => {
|
||||
if (active) {
|
||||
setIconCandidates(candidates);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setIconCandidates([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setIconScanPending(false);
|
||||
}
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [isOpen, scannedWorkingDirKey]);
|
||||
|
||||
const resetIcon = useCallback((nextIcon?: string | null) => {
|
||||
setIcon(normalizeProjectIcon(nextIcon));
|
||||
setIconError(null);
|
||||
}, []);
|
||||
|
||||
const chooseIcon = useCallback((nextIcon: string) => {
|
||||
setIcon(nextIcon);
|
||||
setIconError(null);
|
||||
}, []);
|
||||
|
||||
const chooseCustomIcon = useCallback(
|
||||
async ({ title, filterName }: ChooseCustomProjectIconOptions) => {
|
||||
try {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({
|
||||
directory: false,
|
||||
multiple: false,
|
||||
title,
|
||||
filters: [
|
||||
{
|
||||
name: filterName,
|
||||
extensions: ["svg", "png", "ico", "jpg", "jpeg", "webp"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected && typeof selected === "string") {
|
||||
const iconData = await readProjectIcon(selected);
|
||||
setIcon(iconData.icon);
|
||||
setIconError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setIconError(String(err));
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
icon,
|
||||
iconCandidates,
|
||||
iconScanPending,
|
||||
iconError,
|
||||
chooseIcon,
|
||||
chooseCustomIcon,
|
||||
resetIcon,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_PROJECT_ICON,
|
||||
fileProjectIconValue,
|
||||
isFileProjectIcon,
|
||||
isImageProjectIcon,
|
||||
normalizeProjectIcon,
|
||||
} from "./projectIcons";
|
||||
|
||||
describe("projectIcons", () => {
|
||||
it("normalizes empty and legacy folder icons to the default preset", () => {
|
||||
expect(normalizeProjectIcon(null)).toBe(DEFAULT_PROJECT_ICON);
|
||||
expect(normalizeProjectIcon(undefined)).toBe(DEFAULT_PROJECT_ICON);
|
||||
expect(normalizeProjectIcon("\u{1F4C1}")).toBe(DEFAULT_PROJECT_ICON);
|
||||
});
|
||||
|
||||
it("preserves explicit icon values", () => {
|
||||
expect(normalizeProjectIcon("tabler:code")).toBe("tabler:code");
|
||||
expect(normalizeProjectIcon("data:image/png;base64,aWNvbg==")).toBe(
|
||||
"data:image/png;base64,aWNvbg==",
|
||||
);
|
||||
});
|
||||
|
||||
it("identifies file and image-backed icon values", () => {
|
||||
const fileIcon = fileProjectIconValue("/tmp/logo.svg");
|
||||
|
||||
expect(fileIcon).toBe("file:/tmp/logo.svg");
|
||||
expect(isFileProjectIcon(fileIcon)).toBe(true);
|
||||
expect(isImageProjectIcon(fileIcon)).toBe(true);
|
||||
expect(isImageProjectIcon("data:image/svg+xml;base64,aWNvbg==")).toBe(true);
|
||||
expect(isImageProjectIcon(DEFAULT_PROJECT_ICON)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
export const DEFAULT_PROJECT_ICON = "tabler:folder-code";
|
||||
|
||||
export const PROJECT_TABLER_ICONS = [
|
||||
{
|
||||
value: DEFAULT_PROJECT_ICON,
|
||||
labelKey: "dialog.iconPresets.folderCode",
|
||||
},
|
||||
{ value: "tabler:code", labelKey: "dialog.iconPresets.code" },
|
||||
{
|
||||
value: "tabler:git-branch",
|
||||
labelKey: "dialog.iconPresets.gitBranch",
|
||||
},
|
||||
{
|
||||
value: "tabler:brand-github",
|
||||
labelKey: "dialog.iconPresets.github",
|
||||
},
|
||||
{
|
||||
value: "tabler:terminal",
|
||||
labelKey: "dialog.iconPresets.terminal",
|
||||
},
|
||||
{
|
||||
value: "tabler:server",
|
||||
labelKey: "dialog.iconPresets.server",
|
||||
},
|
||||
{
|
||||
value: "tabler:database",
|
||||
labelKey: "dialog.iconPresets.database",
|
||||
},
|
||||
{ value: "tabler:api", labelKey: "dialog.iconPresets.api" },
|
||||
{
|
||||
value: "tabler:app-window",
|
||||
labelKey: "dialog.iconPresets.app",
|
||||
},
|
||||
{
|
||||
value: "tabler:components",
|
||||
labelKey: "dialog.iconPresets.components",
|
||||
},
|
||||
{
|
||||
value: "tabler:package",
|
||||
labelKey: "dialog.iconPresets.package",
|
||||
},
|
||||
{
|
||||
value: "tabler:world",
|
||||
labelKey: "dialog.iconPresets.website",
|
||||
},
|
||||
{ value: "tabler:book", labelKey: "dialog.iconPresets.docs" },
|
||||
{
|
||||
value: "tabler:palette",
|
||||
labelKey: "dialog.iconPresets.design",
|
||||
},
|
||||
{ value: "tabler:brain", labelKey: "dialog.iconPresets.ai" },
|
||||
{
|
||||
value: "tabler:bolt",
|
||||
labelKey: "dialog.iconPresets.automation",
|
||||
},
|
||||
{
|
||||
value: "tabler:rocket",
|
||||
labelKey: "dialog.iconPresets.launch",
|
||||
},
|
||||
{
|
||||
value: "tabler:settings",
|
||||
labelKey: "dialog.iconPresets.settings",
|
||||
},
|
||||
] satisfies Array<{
|
||||
value: string;
|
||||
labelKey: string;
|
||||
}>;
|
||||
|
||||
export function normalizeProjectIcon(icon: string | null | undefined): string {
|
||||
if (!icon || icon === "\u{1F4C1}") {
|
||||
return DEFAULT_PROJECT_ICON;
|
||||
}
|
||||
|
||||
return icon;
|
||||
}
|
||||
|
||||
export function isFileProjectIcon(icon: string): boolean {
|
||||
return icon.startsWith("file:");
|
||||
}
|
||||
|
||||
export function isImageProjectIcon(icon: string): boolean {
|
||||
return icon.startsWith("data:image/") || isFileProjectIcon(icon);
|
||||
}
|
||||
|
||||
export function fileProjectIconValue(path: string): string {
|
||||
return `file:${path}`;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, type FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { IconFolderOpen } from "@tabler/icons-react";
|
||||
import { getHomeDir } from "@/shared/api/system";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
type ProjectInfo,
|
||||
} from "../api/projects";
|
||||
import { discoverAcpProviders, type AcpProvider } from "@/shared/api/acp";
|
||||
import { useProjectIconSelection } from "../hooks/useProjectIconSelection";
|
||||
import {
|
||||
buildEditorText,
|
||||
hasEquivalentWorkingDir,
|
||||
@@ -34,23 +34,10 @@ import {
|
||||
parseEditorText,
|
||||
} from "../lib/projectPromptText";
|
||||
import { PromptEditor } from "./PromptEditor";
|
||||
import { DEFAULT_PROJECT_ICON } from "../lib/projectIcons";
|
||||
import { ProjectIconPicker } from "./ProjectIconPicker";
|
||||
|
||||
const COLOR_OPTIONS = [
|
||||
"#64748b",
|
||||
"#ef4444",
|
||||
"#f97316",
|
||||
"#f59e0b",
|
||||
"#22c55e",
|
||||
"#10b981",
|
||||
"#14b8a6",
|
||||
"#06b6d4",
|
||||
"#3b82f6",
|
||||
"#6366f1",
|
||||
"#8b5cf6",
|
||||
"#a855f7",
|
||||
"#ec4899",
|
||||
"#f43f5e",
|
||||
];
|
||||
const DEFAULT_PROJECT_COLOR = "#64748b";
|
||||
|
||||
function getDefaultProjectName(path: string | null | undefined): string {
|
||||
const trimmed = path?.trim();
|
||||
@@ -68,18 +55,7 @@ interface CreateProjectDialogProps {
|
||||
onClose: () => void;
|
||||
onCreated: (project: ProjectInfo) => void;
|
||||
initialWorkingDir?: string | null;
|
||||
editingProject?: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
preferredProvider: string | null;
|
||||
preferredModel: string | null;
|
||||
workingDirs: string[];
|
||||
useWorktrees: boolean;
|
||||
};
|
||||
editingProject?: ProjectInfo;
|
||||
}
|
||||
|
||||
export function CreateProjectDialog({
|
||||
@@ -92,8 +68,16 @@ export function CreateProjectDialog({
|
||||
const { t } = useTranslation(["projects", "common"]);
|
||||
const [name, setName] = useState("");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const icon = "\u{1F4C1}";
|
||||
const [color, setColor] = useState(COLOR_OPTIONS[0]);
|
||||
const {
|
||||
icon,
|
||||
iconCandidates,
|
||||
iconScanPending,
|
||||
iconError,
|
||||
chooseIcon,
|
||||
chooseCustomIcon,
|
||||
resetIcon,
|
||||
} = useProjectIconSelection({ isOpen, prompt });
|
||||
const [color, setColor] = useState(DEFAULT_PROJECT_COLOR);
|
||||
const [preferredProvider, setPreferredProvider] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -135,6 +119,13 @@ export function CreateProjectDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleChooseCustomIcon = async () => {
|
||||
await chooseCustomIcon({
|
||||
title: t("dialog.customIconDialogTitle"),
|
||||
filterName: t("dialog.iconFileFilter"),
|
||||
});
|
||||
};
|
||||
|
||||
// Pre-fill fields when the dialog opens or when the project identity changes,
|
||||
// but NOT on every parent re-render (which would reset user edits mid-typing).
|
||||
const prevOpenRef = useRef(false);
|
||||
@@ -154,6 +145,7 @@ export function CreateProjectDialog({
|
||||
setPrompt(
|
||||
buildEditorText(editingProject.workingDirs, editingProject.prompt),
|
||||
);
|
||||
resetIcon(editingProject.icon);
|
||||
setColor(editingProject.color);
|
||||
setPreferredProvider(editingProject.preferredProvider ?? null);
|
||||
setUseWorktrees(editingProject.useWorktrees);
|
||||
@@ -166,26 +158,28 @@ export function CreateProjectDialog({
|
||||
"",
|
||||
),
|
||||
);
|
||||
setColor(COLOR_OPTIONS[0]);
|
||||
resetIcon(DEFAULT_PROJECT_ICON);
|
||||
setColor(DEFAULT_PROJECT_COLOR);
|
||||
setPreferredProvider(null);
|
||||
setUseWorktrees(false);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen, editingProject, initialWorkingDir]);
|
||||
}, [isOpen, editingProject, initialWorkingDir, resetIcon]);
|
||||
|
||||
const canSave = name.trim().length > 0 && !saving;
|
||||
|
||||
const handleClose = () => {
|
||||
setName("");
|
||||
setPrompt("");
|
||||
setColor(COLOR_OPTIONS[0]);
|
||||
resetIcon(DEFAULT_PROJECT_ICON);
|
||||
setColor(DEFAULT_PROJECT_COLOR);
|
||||
setPreferredProvider(null);
|
||||
setUseWorktrees(false);
|
||||
setError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
const handleSave = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canSave) return;
|
||||
setSaving(true);
|
||||
@@ -257,7 +251,6 @@ export function CreateProjectDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
{t("dialog.instructions")}
|
||||
@@ -275,34 +268,19 @@ export function CreateProjectDialog({
|
||||
onClick={handleAddDirectory}
|
||||
className="mt-1.5"
|
||||
>
|
||||
<FolderOpen className="size-3.5" />
|
||||
<IconFolderOpen className="size-3.5" />
|
||||
{t("dialog.addDirectory")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Color */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
{t("dialog.color")}
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{COLOR_OPTIONS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
className={cn(
|
||||
"h-6 w-6 rounded-full border-2 transition-transform",
|
||||
color === c
|
||||
? "border-foreground scale-110"
|
||||
: "border-transparent hover:scale-105",
|
||||
)}
|
||||
style={{ backgroundColor: c }}
|
||||
aria-label={t("dialog.colorAria", { color: c })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ProjectIconPicker
|
||||
icon={icon}
|
||||
iconCandidates={iconCandidates}
|
||||
iconScanPending={iconScanPending}
|
||||
error={iconError}
|
||||
onChooseIcon={chooseIcon}
|
||||
onChooseCustomIcon={handleChooseCustomIcon}
|
||||
/>
|
||||
|
||||
{/* Provider */}
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { useState, type ComponentType } from "react";
|
||||
import {
|
||||
IconApi,
|
||||
IconAppWindow,
|
||||
IconBolt,
|
||||
IconBook,
|
||||
IconBrain,
|
||||
IconBrandGithub,
|
||||
IconCode,
|
||||
IconComponents,
|
||||
IconDatabase,
|
||||
IconFolder,
|
||||
IconFolderCode,
|
||||
IconGitBranch,
|
||||
IconPackage,
|
||||
IconPalette,
|
||||
IconRocket,
|
||||
IconServer,
|
||||
IconSettings,
|
||||
IconTerminal2,
|
||||
IconWorld,
|
||||
} from "@tabler/icons-react";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
DEFAULT_PROJECT_ICON,
|
||||
isFileProjectIcon,
|
||||
isImageProjectIcon,
|
||||
normalizeProjectIcon,
|
||||
} from "../lib/projectIcons";
|
||||
|
||||
type TablerIconComponent = ComponentType<{
|
||||
className?: string;
|
||||
stroke?: number;
|
||||
}>;
|
||||
|
||||
const tablerIconsByValue = new Map<string, TablerIconComponent>([
|
||||
[DEFAULT_PROJECT_ICON, IconFolderCode],
|
||||
["tabler:code", IconCode],
|
||||
["tabler:git-branch", IconGitBranch],
|
||||
["tabler:brand-github", IconBrandGithub],
|
||||
["tabler:terminal", IconTerminal2],
|
||||
["tabler:server", IconServer],
|
||||
["tabler:database", IconDatabase],
|
||||
["tabler:api", IconApi],
|
||||
["tabler:app-window", IconAppWindow],
|
||||
["tabler:components", IconComponents],
|
||||
["tabler:package", IconPackage],
|
||||
["tabler:world", IconWorld],
|
||||
["tabler:book", IconBook],
|
||||
["tabler:palette", IconPalette],
|
||||
["tabler:brain", IconBrain],
|
||||
["tabler:bolt", IconBolt],
|
||||
["tabler:rocket", IconRocket],
|
||||
["tabler:settings", IconSettings],
|
||||
]);
|
||||
|
||||
export function ProjectIcon({
|
||||
icon,
|
||||
className,
|
||||
imageClassName,
|
||||
}: {
|
||||
icon: string | null | undefined;
|
||||
className?: string;
|
||||
imageClassName?: string;
|
||||
}) {
|
||||
const normalizedIcon = normalizeProjectIcon(icon);
|
||||
const [failedImageIcon, setFailedImageIcon] = useState<string | null>(null);
|
||||
const imageFailed = failedImageIcon === normalizedIcon;
|
||||
|
||||
if (isImageProjectIcon(normalizedIcon) && !imageFailed) {
|
||||
const path = isFileProjectIcon(normalizedIcon)
|
||||
? normalizedIcon.slice("file:".length)
|
||||
: normalizedIcon;
|
||||
const src =
|
||||
isFileProjectIcon(normalizedIcon) &&
|
||||
typeof window !== "undefined" &&
|
||||
window.__TAURI_INTERNALS__
|
||||
? convertFileSrc(path)
|
||||
: path;
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className={cn("size-4 rounded-[3px] object-contain", imageClassName)}
|
||||
onError={() => setFailedImageIcon(normalizedIcon)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = tablerIconsByValue.get(normalizedIcon) ?? IconFolder;
|
||||
|
||||
return <Icon className={cn("size-4", className)} stroke={1.8} />;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { IconLoader2, IconUpload } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import type { ProjectIconCandidate } from "../api/projects";
|
||||
import { PROJECT_TABLER_ICONS, isImageProjectIcon } from "../lib/projectIcons";
|
||||
import { ProjectIcon } from "./ProjectIcon";
|
||||
|
||||
interface ProjectIconPickerProps {
|
||||
icon: string;
|
||||
iconCandidates: ProjectIconCandidate[];
|
||||
iconScanPending: boolean;
|
||||
error?: string | null;
|
||||
onChooseIcon: (icon: string) => void;
|
||||
onChooseCustomIcon: () => void;
|
||||
}
|
||||
|
||||
export function ProjectIconPicker({
|
||||
icon,
|
||||
iconCandidates,
|
||||
iconScanPending,
|
||||
error,
|
||||
onChooseIcon,
|
||||
onChooseCustomIcon,
|
||||
}: ProjectIconPickerProps) {
|
||||
const { t } = useTranslation("projects");
|
||||
const selectedCustomIcon =
|
||||
isImageProjectIcon(icon) &&
|
||||
!iconCandidates.some((candidate) => candidate.icon === icon);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("dialog.icon")}
|
||||
</span>
|
||||
{iconScanPending && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<IconLoader2 className="size-3 animate-spin" />
|
||||
{t("dialog.scanningIcons")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-36 overflow-y-auto rounded-md border border-border bg-muted/20 p-2">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(2.5rem,1fr))] justify-items-center gap-2">
|
||||
{iconCandidates.map((candidate) => (
|
||||
<button
|
||||
key={candidate.id}
|
||||
type="button"
|
||||
onClick={() => onChooseIcon(candidate.icon)}
|
||||
className={cn(
|
||||
"flex size-9 items-center justify-center rounded-md border bg-background transition-colors hover:bg-muted",
|
||||
icon === candidate.icon
|
||||
? "border-foreground"
|
||||
: "border-border-soft",
|
||||
)}
|
||||
title={t("dialog.iconCandidateTitle", {
|
||||
sourceDir: candidate.sourceDir,
|
||||
label: candidate.label,
|
||||
})}
|
||||
aria-label={t("dialog.iconAria", { icon: candidate.label })}
|
||||
>
|
||||
<ProjectIcon
|
||||
icon={candidate.icon}
|
||||
imageClassName="size-5 rounded-[4px]"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{PROJECT_TABLER_ICONS.map((tablerIcon) => {
|
||||
const label = t(tablerIcon.labelKey);
|
||||
return (
|
||||
<button
|
||||
key={tablerIcon.value}
|
||||
type="button"
|
||||
onClick={() => onChooseIcon(tablerIcon.value)}
|
||||
className={cn(
|
||||
"flex size-9 items-center justify-center rounded-md border bg-background text-foreground transition-colors hover:bg-muted",
|
||||
icon === tablerIcon.value
|
||||
? "border-foreground"
|
||||
: "border-border-soft",
|
||||
)}
|
||||
title={label}
|
||||
aria-label={t("dialog.iconAria", { icon: label })}
|
||||
>
|
||||
<ProjectIcon icon={tablerIcon.value} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChooseCustomIcon}
|
||||
className={cn(
|
||||
"col-span-2 flex h-9 min-w-[88px] items-center justify-center gap-1.5 rounded-md border bg-background px-3 text-xs text-foreground transition-colors hover:bg-muted",
|
||||
selectedCustomIcon ? "border-foreground" : "border-border-soft",
|
||||
)}
|
||||
title={
|
||||
selectedCustomIcon
|
||||
? t("dialog.customIcon")
|
||||
: t("dialog.uploadIcon")
|
||||
}
|
||||
aria-label={t("dialog.customIcon")}
|
||||
>
|
||||
{selectedCustomIcon ? (
|
||||
<ProjectIcon icon={icon} imageClassName="size-4 rounded-[3px]" />
|
||||
) : (
|
||||
<IconUpload className="size-3.5" />
|
||||
)}
|
||||
<span>{t("dialog.uploadIcon")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import { CreateProjectDialog } from "./CreateProjectDialog";
|
||||
import { ProjectIcon } from "./ProjectIcon";
|
||||
import { deleteProject, type ProjectInfo } from "../api/projects";
|
||||
import { useProjectStore } from "../stores/projectStore";
|
||||
|
||||
@@ -88,21 +89,9 @@ export function ProjectsView({ onStartChat }: ProjectsViewProps) {
|
||||
const fetchProjects = useProjectStore((s) => s.fetchProjects);
|
||||
const [search, setSearch] = useState("");
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState<
|
||||
| {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
preferredProvider: string | null;
|
||||
preferredModel: string | null;
|
||||
workingDirs: string[];
|
||||
useWorktrees: boolean;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
const [editingProject, setEditingProject] = useState<ProjectInfo | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deletingProject, setDeletingProject] = useState<ProjectInfo | null>(
|
||||
@@ -141,18 +130,7 @@ export function ProjectsView({ onStartChat }: ProjectsViewProps) {
|
||||
};
|
||||
|
||||
const handleEdit = (project: ProjectInfo) => {
|
||||
setEditingProject({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
prompt: project.prompt,
|
||||
icon: project.icon,
|
||||
color: project.color,
|
||||
preferredProvider: project.preferredProvider,
|
||||
preferredModel: project.preferredModel,
|
||||
workingDirs: project.workingDirs,
|
||||
useWorktrees: project.useWorktrees,
|
||||
});
|
||||
setEditingProject(project);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -215,9 +193,10 @@ export function ProjectsView({ onStartChat }: ProjectsViewProps) {
|
||||
className="flex items-start justify-between gap-3 rounded-lg border border-border px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1 flex items-start gap-3">
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-full mt-1.5 shrink-0"
|
||||
style={{ backgroundColor: project.color }}
|
||||
<ProjectIcon
|
||||
icon={project.icon}
|
||||
className="mt-0.5 size-4 shrink-0 text-foreground"
|
||||
imageClassName="mt-0.5 size-4 shrink-0 rounded-[4px]"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{project.name}</p>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { readProjectIcon, type ProjectInfo } from "../../api/projects";
|
||||
import { CreateProjectDialog } from "../CreateProjectDialog";
|
||||
|
||||
// ── ResizeObserver polyfill (needed by Radix Select in jsdom) ────────
|
||||
@@ -29,7 +31,7 @@ vi.mock("../../api/projects", () => ({
|
||||
name: "Test",
|
||||
description: "",
|
||||
prompt: "",
|
||||
icon: "\u{1F4C1}",
|
||||
icon: "tabler:folder-code",
|
||||
color: "#64748b",
|
||||
preferredProvider: null,
|
||||
preferredModel: null,
|
||||
@@ -45,7 +47,7 @@ vi.mock("../../api/projects", () => ({
|
||||
name: "Updated",
|
||||
description: "",
|
||||
prompt: "",
|
||||
icon: "\u{1F4C1}",
|
||||
icon: "tabler:folder-code",
|
||||
color: "#ef4444",
|
||||
preferredProvider: null,
|
||||
preferredModel: null,
|
||||
@@ -56,6 +58,10 @@ vi.mock("../../api/projects", () => ({
|
||||
createdAt: "2024-01-01",
|
||||
updatedAt: "2024-01-01",
|
||||
}),
|
||||
scanProjectIcons: vi.fn().mockResolvedValue([]),
|
||||
readProjectIcon: vi.fn().mockResolvedValue({
|
||||
icon: "data:image/png;base64,aWNvbg==",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
@@ -84,18 +90,23 @@ vi.mock("../PromptEditor", () => ({
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function makeEditingProject(overrides: Record<string, unknown> = {}) {
|
||||
function makeEditingProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
||||
return {
|
||||
id: "proj-1",
|
||||
name: "My Project",
|
||||
description: "A test project",
|
||||
prompt: "Do the thing",
|
||||
icon: "\u{1F4C1}",
|
||||
icon: "tabler:folder-code",
|
||||
color: "#ef4444",
|
||||
preferredProvider: null,
|
||||
preferredModel: null,
|
||||
workingDirs: ["/home/user/code"],
|
||||
useWorktrees: false,
|
||||
order: 0,
|
||||
archivedAt: null,
|
||||
createdAt: "2024-01-01",
|
||||
updatedAt: "2024-01-01",
|
||||
artifactsDir: "/home/user/code/.goose",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -111,6 +122,10 @@ const defaultProps = {
|
||||
describe("CreateProjectDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(open).mockResolvedValue(null);
|
||||
vi.mocked(readProjectIcon).mockResolvedValue({
|
||||
icon: "data:image/png;base64,aWNvbg==",
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form populates on open ──────────────────────────────────────────
|
||||
@@ -170,8 +185,8 @@ describe("CreateProjectDialog", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("selects the correct color from editingProject", () => {
|
||||
const editingProject = makeEditingProject({ color: "#ef4444" });
|
||||
it("selects the correct icon from editingProject", () => {
|
||||
const editingProject = makeEditingProject({ icon: "tabler:code" });
|
||||
|
||||
render(
|
||||
<CreateProjectDialog
|
||||
@@ -181,11 +196,24 @@ describe("CreateProjectDialog", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const colorButton = screen.getByRole("button", {
|
||||
name: "Color #ef4444",
|
||||
const iconButton = screen.getByRole("button", {
|
||||
name: "Icon Code",
|
||||
});
|
||||
// The selected color has border-foreground in its class
|
||||
expect(colorButton.className).toContain("border-foreground");
|
||||
expect(iconButton.className).toContain("border-foreground");
|
||||
});
|
||||
|
||||
it("shows custom icon upload errors", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(open).mockResolvedValueOnce("/tmp/large-icon.png");
|
||||
vi.mocked(readProjectIcon).mockRejectedValueOnce(
|
||||
"Icon file is too large",
|
||||
);
|
||||
|
||||
render(<CreateProjectDialog {...defaultProps} isOpen={true} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Custom icon" }));
|
||||
|
||||
expect(await screen.findByText("Icon file is too large")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,9 +296,9 @@ describe("CreateProjectDialog", () => {
|
||||
expect(promptEditor).toHaveValue("New instructions");
|
||||
});
|
||||
|
||||
it("preserves changed color when editingProject reference changes but dialog stays open", async () => {
|
||||
it("preserves changed icon when editingProject reference changes but dialog stays open", async () => {
|
||||
const user = userEvent.setup();
|
||||
const editingProject1 = makeEditingProject({ color: "#ef4444" });
|
||||
const editingProject1 = makeEditingProject({ icon: "tabler:code" });
|
||||
|
||||
const { rerender } = render(
|
||||
<CreateProjectDialog
|
||||
@@ -280,22 +308,20 @@ describe("CreateProjectDialog", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify initial color is selected
|
||||
const redButton = screen.getByRole("button", {
|
||||
name: "Color #ef4444",
|
||||
const codeButton = screen.getByRole("button", {
|
||||
name: "Icon Code",
|
||||
});
|
||||
expect(redButton.className).toContain("border-foreground");
|
||||
expect(codeButton.className).toContain("border-foreground");
|
||||
|
||||
// User clicks a different color
|
||||
const blueButton = screen.getByRole("button", {
|
||||
name: "Color #3b82f6",
|
||||
const terminalButton = screen.getByRole("button", {
|
||||
name: "Icon Terminal",
|
||||
});
|
||||
await user.click(blueButton);
|
||||
expect(blueButton.className).toContain("border-foreground");
|
||||
expect(redButton.className).not.toContain("border-foreground");
|
||||
await user.click(terminalButton);
|
||||
expect(terminalButton.className).toContain("border-foreground");
|
||||
expect(codeButton.className).not.toContain("border-foreground");
|
||||
|
||||
// Re-render with new reference, same values
|
||||
const editingProject2 = makeEditingProject({ color: "#ef4444" });
|
||||
const editingProject2 = makeEditingProject({ icon: "tabler:code" });
|
||||
|
||||
rerender(
|
||||
<CreateProjectDialog
|
||||
@@ -305,9 +331,8 @@ describe("CreateProjectDialog", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// The user-selected blue color should be preserved, not reset to red
|
||||
expect(blueButton.className).toContain("border-foreground");
|
||||
expect(redButton.className).not.toContain("border-foreground");
|
||||
expect(terminalButton.className).toContain("border-foreground");
|
||||
expect(codeButton.className).not.toContain("border-foreground");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -317,7 +342,7 @@ describe("CreateProjectDialog", () => {
|
||||
it("re-populates fields when dialog closes and reopens with a different project", async () => {
|
||||
const project1 = makeEditingProject({
|
||||
name: "Project Alpha",
|
||||
color: "#ef4444",
|
||||
icon: "tabler:code",
|
||||
prompt: "Alpha instructions",
|
||||
workingDirs: ["/alpha"],
|
||||
});
|
||||
@@ -347,7 +372,7 @@ describe("CreateProjectDialog", () => {
|
||||
// Reopen with a different project
|
||||
const project2 = makeEditingProject({
|
||||
name: "Project Beta",
|
||||
color: "#3b82f6",
|
||||
icon: "tabler:database",
|
||||
prompt: "Beta instructions",
|
||||
workingDirs: ["/beta"],
|
||||
});
|
||||
@@ -367,10 +392,10 @@ describe("CreateProjectDialog", () => {
|
||||
const promptEditor = screen.getByTestId("prompt-editor");
|
||||
expect(promptEditor).toHaveValue("Beta instructions\n\ninclude: /beta");
|
||||
|
||||
const blueButton = screen.getByRole("button", {
|
||||
name: "Color #3b82f6",
|
||||
const databaseButton = screen.getByRole("button", {
|
||||
name: "Icon Database",
|
||||
});
|
||||
expect(blueButton.className).toContain("border-foreground");
|
||||
expect(databaseButton.className).toContain("border-foreground");
|
||||
});
|
||||
|
||||
it("re-populates with same project data after close and reopen (discards user edits)", async () => {
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
deleteProject,
|
||||
type ProjectInfo,
|
||||
} from "@/features/projects/api/projects";
|
||||
import { ProjectIcon } from "@/features/projects/ui/ProjectIcon";
|
||||
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
|
||||
import { useProjectStore } from "@/features/projects/stores/projectStore";
|
||||
import { getDisplaySessionTitle } from "@/features/chat/lib/sessionTitle";
|
||||
@@ -270,9 +271,10 @@ export function SettingsModal({
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: project.color }}
|
||||
<ProjectIcon
|
||||
icon={project.icon}
|
||||
className="size-4 shrink-0 text-foreground"
|
||||
imageClassName="size-4 shrink-0 rounded-[4px]"
|
||||
/>
|
||||
<span className="text-sm truncate">
|
||||
{project.name}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IconHistory,
|
||||
IconHome,
|
||||
IconLayoutSidebar,
|
||||
IconLayoutSidebarFilled,
|
||||
IconRobotFace,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconStack,
|
||||
} from "@tabler/icons-react";
|
||||
import { BookOpen, Bot, History, Home, Search } from "lucide-react";
|
||||
import { getDisplaySessionTitle } from "@/features/chat/lib/sessionTitle";
|
||||
import { GooseIcon } from "@/shared/ui/icons/GooseIcon";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
@@ -21,14 +26,15 @@ import { useProjectStore } from "@/features/projects/stores/projectStore";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { useSessionSearch } from "@/features/sessions/hooks/useSessionSearch";
|
||||
import { SidebarProjectsSection } from "./SidebarProjectsSection";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { SidebarSearchResults } from "./SidebarSearchResults";
|
||||
import { useSidebarHighlight } from "./useSidebarHighlight";
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
width?: number;
|
||||
isResizing?: boolean;
|
||||
onCollapse: () => void;
|
||||
onSettingsClick?: () => void;
|
||||
onNewChatInProject?: (projectId: string) => void;
|
||||
onNewChat?: () => void;
|
||||
onCreateProject?: () => void;
|
||||
@@ -58,6 +64,7 @@ export function Sidebar({
|
||||
width = 240,
|
||||
isResizing = false,
|
||||
onCollapse,
|
||||
onSettingsClick,
|
||||
onNewChatInProject,
|
||||
onNewChat,
|
||||
onCreateProject,
|
||||
@@ -75,7 +82,7 @@ export function Sidebar({
|
||||
className,
|
||||
projects,
|
||||
}: SidebarProps) {
|
||||
const { t, i18n } = useTranslation(["sidebar", "common"]);
|
||||
const { t, i18n } = useTranslation(["sidebar", "common", "settings"]);
|
||||
const [expanded, setExpanded] = useState(!collapsed);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const prevCollapsed = useRef(collapsed);
|
||||
@@ -118,16 +125,19 @@ export function Sidebar({
|
||||
const labelTransition = "transition-[opacity,width] duration-300 ease-out";
|
||||
const labelVisible = expanded && !collapsed;
|
||||
const defaultTitle = t("common:session.defaultTitle");
|
||||
const navItems: readonly { id: AppView; label: string; icon: typeof Bot }[] =
|
||||
[
|
||||
{ id: "agents", label: t("navigation.agents"), icon: Bot },
|
||||
{ id: "skills", label: t("navigation.skills"), icon: BookOpen },
|
||||
{
|
||||
id: "session-history",
|
||||
label: t("navigation.sessionHistory"),
|
||||
icon: History,
|
||||
},
|
||||
];
|
||||
const navItems: readonly {
|
||||
id: AppView;
|
||||
label: string;
|
||||
icon: typeof IconRobotFace;
|
||||
}[] = [
|
||||
{ id: "agents", label: t("navigation.agents"), icon: IconRobotFace },
|
||||
{ id: "skills", label: t("navigation.skills"), icon: IconStack },
|
||||
{
|
||||
id: "session-history",
|
||||
label: t("navigation.sessionHistory"),
|
||||
icon: IconHistory,
|
||||
},
|
||||
];
|
||||
|
||||
const MAX_RECENTS = 20;
|
||||
const validProjectIds = new Set(projects.map((project) => project.id));
|
||||
@@ -248,48 +258,6 @@ export function Sidebar({
|
||||
const toggleProject = (projectId: string) =>
|
||||
setExpandedProjects((prev) => ({ ...prev, [projectId]: !prev[projectId] }));
|
||||
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
const homeRef = useRef<HTMLButtonElement>(null);
|
||||
const navItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
|
||||
const {
|
||||
currentRect,
|
||||
isHovering,
|
||||
isResizing: isHighlightResizing,
|
||||
onItemMouseEnter,
|
||||
onNavMouseLeave,
|
||||
updateActiveRect,
|
||||
} = useSidebarHighlight(navRef);
|
||||
|
||||
const activeProjectId =
|
||||
activeSessionId && activeView === "chat"
|
||||
? (sessions.find((s) => s.id === activeSessionId)?.projectId ?? null)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSessionId && activeView === "chat") return;
|
||||
if (activeView === "home") {
|
||||
updateActiveRect(homeRef.current);
|
||||
} else if (activeView && navItemRefs.current[activeView]) {
|
||||
updateActiveRect(navItemRefs.current[activeView]);
|
||||
} else {
|
||||
updateActiveRect(null);
|
||||
}
|
||||
}, [activeSessionId, activeView, updateActiveRect]);
|
||||
|
||||
const activeSessionRefCallback = useCallback(
|
||||
(el: HTMLElement | null) => {
|
||||
if (activeSessionId && el) updateActiveRect(el);
|
||||
},
|
||||
[activeSessionId, updateActiveRect],
|
||||
);
|
||||
const activeProjectRefCallback = useCallback(
|
||||
(el: HTMLElement | null) => {
|
||||
if (activeProjectId && el) updateActiveRect(el);
|
||||
},
|
||||
[activeProjectId, updateActiveRect],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -299,7 +267,7 @@ export function Sidebar({
|
||||
)}
|
||||
style={{ width: collapsed ? 54 : width }}
|
||||
>
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-background [--muted-foreground:var(--text-subtle)]">
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-background">
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 pt-3",
|
||||
@@ -319,7 +287,7 @@ export function Sidebar({
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onCollapse}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
className="text-foreground hover:text-foreground"
|
||||
aria-label={t("actions.collapse")}
|
||||
title={t("actions.collapse")}
|
||||
>
|
||||
@@ -329,217 +297,195 @@ export function Sidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
ref={navRef}
|
||||
className="relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-1.5 py-1 pt-1.5 scrollbar-none"
|
||||
onMouseLeave={onNavMouseLeave}
|
||||
>
|
||||
{currentRect && !collapsed && (
|
||||
<div
|
||||
className="absolute left-1.5 right-1.5 rounded-lg bg-accent/50 pointer-events-none z-0"
|
||||
style={{
|
||||
top: currentRect.top,
|
||||
height: currentRect.height,
|
||||
transition:
|
||||
isHovering || isHighlightResizing
|
||||
? "top 0ms, height 0ms"
|
||||
: "top 200ms ease, height 200ms ease, opacity 200ms ease",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 space-y-0.5">
|
||||
{collapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
title={t("actions.expand")}
|
||||
className="flex w-full items-center gap-2.5 rounded-md p-3 text-[13px] text-muted-foreground transition-colors duration-200 hover:text-foreground"
|
||||
aria-label={t("actions.expand")}
|
||||
>
|
||||
<IconLayoutSidebar className="size-4 flex-shrink-0" />
|
||||
<span className="sr-only">{t("actions.expand")}</span>
|
||||
</button>
|
||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||
<nav
|
||||
className={cn(
|
||||
"relative h-full overflow-y-auto overflow-x-hidden px-1.5 py-1 pt-1 scrollbar-none",
|
||||
collapsed ? "pb-16" : "pb-[72px]",
|
||||
)}
|
||||
>
|
||||
<div className="relative z-10 space-y-0.5">
|
||||
{collapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
title={t("actions.expand")}
|
||||
className="flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-sm text-foreground transition-colors duration-200 hover:text-foreground"
|
||||
aria-label={t("actions.expand")}
|
||||
>
|
||||
<IconLayoutSidebar className="size-4 flex-shrink-0" />
|
||||
<span className="sr-only">{t("actions.expand")}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mb-4 flex items-center w-full rounded-md transition-all duration-300 ease-out",
|
||||
collapsed
|
||||
? "justify-center p-3 text-muted-foreground"
|
||||
: "gap-2 border border-border px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-transparent",
|
||||
)}
|
||||
>
|
||||
<Search className="size-3.5 flex-shrink-0 text-placeholder" />
|
||||
{!collapsed && (
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
enterKeyHint="search"
|
||||
value={sidebarSearch.query}
|
||||
onChange={(e) => sidebarSearch.setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void sidebarSearch.search();
|
||||
}
|
||||
}}
|
||||
placeholder={t("search.placeholder")}
|
||||
className={cn(
|
||||
"focus-override appearance-none bg-transparent border-none text-xs flex-1 min-w-0 placeholder:text-placeholder outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
labelTransition,
|
||||
labelVisible
|
||||
? "opacity-100 w-auto"
|
||||
: "opacity-0 w-0 overflow-hidden",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
ref={homeRef}
|
||||
type="button"
|
||||
data-testid="nav-home"
|
||||
onClick={() => onNavigate?.("home")}
|
||||
onMouseEnter={onItemMouseEnter}
|
||||
title={collapsed ? t("navigation.home") : undefined}
|
||||
aria-label={t("navigation.home")}
|
||||
className={cn(
|
||||
"flex items-center w-full text-[13px] transition-colors duration-200 rounded-md",
|
||||
"gap-2.5 p-3",
|
||||
activeView === "home"
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Home className="size-4 flex-shrink-0" />
|
||||
<span
|
||||
<div
|
||||
className={cn(
|
||||
"whitespace-nowrap",
|
||||
labelTransition,
|
||||
labelVisible
|
||||
? "opacity-100 w-auto"
|
||||
: "opacity-0 w-0 overflow-hidden",
|
||||
"mb-3 flex items-center w-full rounded-md transition-all duration-300 ease-out",
|
||||
collapsed
|
||||
? "justify-center p-3 text-foreground"
|
||||
: "gap-2 border border-border px-2.5 py-1.5 text-xs text-foreground hover:text-foreground hover:bg-transparent",
|
||||
)}
|
||||
>
|
||||
{t("navigation.home")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{navItems.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeView === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
ref={(el) => {
|
||||
navItemRefs.current[item.id] = el;
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => onNavigate?.(item.id)}
|
||||
onMouseEnter={onItemMouseEnter}
|
||||
title={collapsed ? item.label : undefined}
|
||||
className={cn(
|
||||
"flex items-center w-full text-[13px] transition-colors duration-200 rounded-md",
|
||||
"gap-2.5 p-3",
|
||||
isActive
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
style={{
|
||||
transitionDelay:
|
||||
!collapsed && expanded ? `${index * 30}ms` : "0ms",
|
||||
}}
|
||||
>
|
||||
<Icon className="size-4 flex-shrink-0" />
|
||||
<span
|
||||
<IconSearch className="size-3.5 flex-shrink-0 text-placeholder" />
|
||||
{!collapsed && (
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
enterKeyHint="search"
|
||||
value={sidebarSearch.query}
|
||||
onChange={(e) => sidebarSearch.setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void sidebarSearch.search();
|
||||
}
|
||||
}}
|
||||
placeholder={t("search.placeholder")}
|
||||
className={cn(
|
||||
"whitespace-nowrap",
|
||||
"focus-override appearance-none bg-transparent border-none text-xs flex-1 min-w-0 placeholder:text-placeholder outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
labelTransition,
|
||||
labelVisible
|
||||
? "opacity-100 w-auto"
|
||||
: "opacity-0 w-0 overflow-hidden",
|
||||
)}
|
||||
style={{
|
||||
transitionDelay: labelVisible
|
||||
? `${index * 30 + 60}ms`
|
||||
: "0ms",
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!collapsed &&
|
||||
(sidebarSearch.submittedQuery ? (
|
||||
<div className="relative z-10 space-y-2">
|
||||
{sidebarSearch.error && (
|
||||
<p className="px-1 text-xs text-danger">
|
||||
{t("search.error")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{sidebarSearch.isSearching &&
|
||||
sidebarSearch.results.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{t("search.searching")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!sidebarSearch.isSearching ||
|
||||
sidebarSearch.results.length > 0) && (
|
||||
<SidebarSearchResults
|
||||
results={sidebarSearch.results}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelectResult={(sessionId, messageId) => {
|
||||
if (messageId) {
|
||||
onSelectSearchResult?.(
|
||||
sessionId,
|
||||
messageId,
|
||||
sidebarSearch.submittedQuery,
|
||||
);
|
||||
return;
|
||||
}
|
||||
onSelectSession?.(sessionId);
|
||||
}}
|
||||
getPersonaName={sidebarResolvers.getPersonaName}
|
||||
getProjectName={sidebarResolvers.getProjectName}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<SidebarProjectsSection
|
||||
projects={projects}
|
||||
projectSessions={projectSessions}
|
||||
expandedProjects={expandedProjects}
|
||||
toggleProject={toggleProject}
|
||||
|
||||
<SidebarNavItem
|
||||
testId="nav-home"
|
||||
icon={IconHome}
|
||||
label={t("navigation.home")}
|
||||
collapsed={collapsed}
|
||||
labelTransition={labelTransition}
|
||||
labelVisible={labelVisible}
|
||||
activeSessionId={activeSessionId}
|
||||
activeProjectId={activeProjectId}
|
||||
onNavigate={onNavigate}
|
||||
onSelectSession={onSelectSession}
|
||||
onNewChatInProject={onNewChatInProject}
|
||||
onNewChat={onNewChat}
|
||||
onCreateProject={onCreateProject}
|
||||
onEditProject={onEditProject}
|
||||
onArchiveProject={onArchiveProject}
|
||||
onArchiveChat={onArchiveChat}
|
||||
onRenameChat={onRenameChat}
|
||||
onMoveToProject={onMoveToProject}
|
||||
onReorderProject={onReorderProject}
|
||||
onItemMouseEnter={onItemMouseEnter}
|
||||
activeSessionRefCallback={activeSessionRefCallback}
|
||||
activeProjectRefCallback={activeProjectRefCallback}
|
||||
isActive={activeView === "home"}
|
||||
onClick={() => onNavigate?.("home")}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{navItems.map((item, index) => {
|
||||
const isActive = activeView === item.id;
|
||||
return (
|
||||
<SidebarNavItem
|
||||
key={item.id}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
collapsed={collapsed}
|
||||
labelTransition={labelTransition}
|
||||
labelVisible={labelVisible}
|
||||
isActive={isActive}
|
||||
onClick={() => onNavigate?.(item.id)}
|
||||
itemTransitionDelay={
|
||||
!collapsed && expanded ? `${index * 30}ms` : "0ms"
|
||||
}
|
||||
labelTransitionDelay={
|
||||
labelVisible ? `${index * 30 + 60}ms` : "0ms"
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!collapsed &&
|
||||
(sidebarSearch.submittedQuery ? (
|
||||
<div className="relative z-10 space-y-2">
|
||||
{sidebarSearch.error && (
|
||||
<p className="px-1 text-xs text-danger">
|
||||
{t("search.error")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{sidebarSearch.isSearching &&
|
||||
sidebarSearch.results.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{t("search.searching")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!sidebarSearch.isSearching ||
|
||||
sidebarSearch.results.length > 0) && (
|
||||
<SidebarSearchResults
|
||||
results={sidebarSearch.results}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelectResult={(sessionId, messageId) => {
|
||||
if (messageId) {
|
||||
onSelectSearchResult?.(
|
||||
sessionId,
|
||||
messageId,
|
||||
sidebarSearch.submittedQuery,
|
||||
);
|
||||
return;
|
||||
}
|
||||
onSelectSession?.(sessionId);
|
||||
}}
|
||||
getPersonaName={sidebarResolvers.getPersonaName}
|
||||
getProjectName={sidebarResolvers.getProjectName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<SidebarProjectsSection
|
||||
projects={projects}
|
||||
projectSessions={projectSessions}
|
||||
expandedProjects={expandedProjects}
|
||||
toggleProject={toggleProject}
|
||||
collapsed={collapsed}
|
||||
labelTransition={labelTransition}
|
||||
labelVisible={labelVisible}
|
||||
activeSessionId={activeSessionId}
|
||||
onNavigate={onNavigate}
|
||||
onSelectSession={onSelectSession}
|
||||
onNewChatInProject={onNewChatInProject}
|
||||
onNewChat={onNewChat}
|
||||
onCreateProject={onCreateProject}
|
||||
onEditProject={onEditProject}
|
||||
onArchiveProject={onArchiveProject}
|
||||
onArchiveChat={onArchiveChat}
|
||||
onRenameChat={onRenameChat}
|
||||
onMoveToProject={onMoveToProject}
|
||||
onReorderProject={onReorderProject}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 bottom-0 z-20 bg-background",
|
||||
"px-1.5 py-1.5",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size={collapsed ? "icon-sm" : "default"}
|
||||
onClick={onSettingsClick}
|
||||
className={cn(
|
||||
"h-10 w-full rounded-md bg-transparent text-foreground hover:bg-transparent hover:text-foreground active:bg-transparent",
|
||||
collapsed
|
||||
? "justify-center p-3"
|
||||
: "justify-start gap-2.5 px-3 py-2.5",
|
||||
)}
|
||||
title={t("settings:title")}
|
||||
aria-label={t("settings:title")}
|
||||
>
|
||||
<IconSettings className="size-4 flex-shrink-0" />
|
||||
{!collapsed && (
|
||||
<span
|
||||
className={cn(
|
||||
"whitespace-nowrap text-sm",
|
||||
labelTransition,
|
||||
labelVisible
|
||||
? "opacity-100 w-auto"
|
||||
: "opacity-0 w-0 overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{t("settings:title")}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,9 +18,9 @@ import { Input } from "@/shared/ui/input";
|
||||
import { SessionActivityIndicator } from "@/shared/ui/SessionActivityIndicator";
|
||||
|
||||
const INACTIVE_CHAT_ROW_CLASS =
|
||||
"text-muted-foreground hover:bg-transparent hover:text-foreground group-hover:text-foreground";
|
||||
"text-foreground hover:bg-background-alt hover:text-foreground";
|
||||
const ACTIVE_CHAT_ROW_CLASS =
|
||||
"font-medium text-foreground hover:bg-transparent hover:text-foreground";
|
||||
"bg-background-alt font-normal text-foreground hover:bg-background-alt hover:text-foreground";
|
||||
|
||||
interface SidebarChatRowProps {
|
||||
id: string;
|
||||
@@ -29,11 +29,10 @@ interface SidebarChatRowProps {
|
||||
isRunning?: boolean;
|
||||
hasUnread?: boolean;
|
||||
className?: string;
|
||||
nested?: boolean;
|
||||
onSelect?: (id: string) => void;
|
||||
onRename?: (id: string, nextTitle: string) => void;
|
||||
onArchive?: (id: string) => void;
|
||||
onMouseEnter?: (e: React.MouseEvent<HTMLElement>) => void;
|
||||
activeRef?: (el: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
export function SidebarChatRow({
|
||||
@@ -43,18 +42,16 @@ export function SidebarChatRow({
|
||||
isRunning = false,
|
||||
hasUnread = false,
|
||||
className,
|
||||
nested = false,
|
||||
onSelect,
|
||||
onRename,
|
||||
onArchive,
|
||||
onMouseEnter,
|
||||
activeRef,
|
||||
}: SidebarChatRowProps) {
|
||||
const { t } = useTranslation(["sidebar", "common"]);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const displayTitle = getDisplaySessionTitle(
|
||||
title,
|
||||
t("common:session.defaultTitle"),
|
||||
@@ -76,20 +73,6 @@ export function SidebarChatRow({
|
||||
inputRef.current?.select();
|
||||
}, [editing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeRef || !isActive || !rowRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (rowRef.current) {
|
||||
activeRef(rowRef.current);
|
||||
}
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [activeRef, isActive]);
|
||||
|
||||
const startRename = () => {
|
||||
setDraftTitle(editableTitle);
|
||||
setMenuOpen(false);
|
||||
@@ -142,7 +125,7 @@ export function SidebarChatRow({
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
className="flex-1 min-w-0 px-3 text-[13px] font-light"
|
||||
className="flex-1 min-w-0 px-3 text-sm font-normal"
|
||||
style={{ height: 32 }}
|
||||
/>
|
||||
</div>
|
||||
@@ -150,9 +133,8 @@ export function SidebarChatRow({
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: wrapper div for hover detection, interactive content is the inner Button
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: wrapper handles drag and context menu, interactive content is the inner Button
|
||||
<div
|
||||
ref={rowRef}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("text/x-session-id", id);
|
||||
@@ -165,11 +147,11 @@ export function SidebarChatRow({
|
||||
setMenuOpen(true);
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex items-center group rounded-md transition-colors duration-200 active:cursor-grabbing",
|
||||
"relative flex items-center group/chat-row rounded-md transition-colors duration-200 hover:bg-background-alt focus-within:bg-background-alt active:cursor-grabbing",
|
||||
(isActive || menuOpen) && "bg-background-alt",
|
||||
dragging && "opacity-40 bg-accent/30",
|
||||
className,
|
||||
)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -183,11 +165,12 @@ export function SidebarChatRow({
|
||||
}}
|
||||
title={t("actions.renameHint")}
|
||||
className={cn(
|
||||
"flex-1 min-w-0 justify-start gap-2 rounded-md pl-3 pr-8 py-2 text-[13px] font-light active:cursor-grabbing",
|
||||
"flex-1 min-w-0 justify-start gap-2 rounded-md pr-8 py-2 text-sm font-normal active:cursor-grabbing",
|
||||
nested ? "pl-9" : "pl-3",
|
||||
isActive ? ACTIVE_CHAT_ROW_CLASS : INACTIVE_CHAT_ROW_CLASS,
|
||||
)}
|
||||
>
|
||||
{showActivityIndicator && (
|
||||
{showActivityIndicator && !nested && (
|
||||
<span className="flex h-3 w-3 shrink-0 items-center justify-center">
|
||||
<SessionActivityIndicator
|
||||
isRunning={isRunning}
|
||||
@@ -199,6 +182,14 @@ export function SidebarChatRow({
|
||||
{displayTitle}
|
||||
</span>
|
||||
</Button>
|
||||
{showActivityIndicator && nested && (
|
||||
<SessionActivityIndicator
|
||||
isRunning={isRunning}
|
||||
hasUnread={hasUnread}
|
||||
variant="overlay"
|
||||
className="left-4 right-auto top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
)}
|
||||
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -209,13 +200,13 @@ export function SidebarChatRow({
|
||||
aria-label={t("menu.optionsFor", { label: displayTitle })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"absolute right-1 size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
||||
"absolute right-1 size-6 rounded-md",
|
||||
menuOpen
|
||||
? "visible opacity-100"
|
||||
: "invisible group-hover:visible opacity-0 group-hover:opacity-100",
|
||||
: "invisible group-hover/chat-row:visible opacity-0 group-hover/chat-row:opacity-100",
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={4}>
|
||||
|
||||
@@ -14,20 +14,26 @@ import {
|
||||
|
||||
interface SidebarItemMenuProps {
|
||||
label: string;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onEdit?: () => void;
|
||||
onArchive?: () => void;
|
||||
}
|
||||
|
||||
export function SidebarItemMenu({
|
||||
label,
|
||||
onOpenChange,
|
||||
onEdit,
|
||||
onArchive,
|
||||
}: SidebarItemMenuProps) {
|
||||
const { t } = useTranslation(["sidebar", "common"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
onOpenChange?.(nextOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -42,7 +48,7 @@ export function SidebarItemMenu({
|
||||
: "invisible group-hover:visible group-focus-within:visible opacity-0 group-hover:opacity-100 group-focus-within:opacity-100",
|
||||
)}
|
||||
>
|
||||
<IconDots className="size-3.5" />
|
||||
<IconDots className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={4}>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
interface SidebarNavItemProps {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
collapsed: boolean;
|
||||
labelTransition: string;
|
||||
labelVisible: boolean;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
testId?: string;
|
||||
itemTransitionDelay?: string;
|
||||
labelTransitionDelay?: string;
|
||||
}
|
||||
|
||||
export function SidebarNavItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
collapsed,
|
||||
labelTransition,
|
||||
labelVisible,
|
||||
isActive,
|
||||
onClick,
|
||||
testId,
|
||||
itemTransitionDelay,
|
||||
labelTransitionDelay,
|
||||
}: SidebarNavItemProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
title={collapsed ? label : undefined}
|
||||
aria-label={label}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center w-full text-sm transition-colors duration-200 rounded-md",
|
||||
"gap-2.5 px-3 py-1.5",
|
||||
isActive
|
||||
? "bg-background-alt font-normal text-foreground"
|
||||
: "font-normal text-foreground hover:bg-background-alt hover:text-foreground",
|
||||
)}
|
||||
style={{ transitionDelay: itemTransitionDelay }}
|
||||
>
|
||||
<Icon className="size-4 flex-shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
"whitespace-nowrap",
|
||||
labelTransition,
|
||||
labelVisible ? "opacity-100 w-auto" : "opacity-0 w-0 overflow-hidden",
|
||||
)}
|
||||
style={{ transitionDelay: labelTransitionDelay }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useState } from "react";
|
||||
import type { AppView } from "@/app/AppShell";
|
||||
import type { ProjectInfo } from "@/features/projects/api/projects";
|
||||
import { ProjectIcon } from "@/features/projects/ui/ProjectIcon";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { SidebarProjectSection } from "./SidebarProjectSection";
|
||||
|
||||
interface TabInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
projectId?: string;
|
||||
isRunning?: boolean;
|
||||
hasUnread?: boolean;
|
||||
}
|
||||
|
||||
export function SidebarProjectList({
|
||||
projects,
|
||||
projectSessionsByProject,
|
||||
expandedProjects,
|
||||
toggleProject,
|
||||
collapsed,
|
||||
activeSessionId,
|
||||
onNavigate,
|
||||
onSelectSession,
|
||||
onNewChatInProject,
|
||||
onEditProject,
|
||||
onArchiveProject,
|
||||
onArchiveChat,
|
||||
onRenameChat,
|
||||
onMoveToProject,
|
||||
onReorderProject,
|
||||
}: {
|
||||
projects: ProjectInfo[];
|
||||
projectSessionsByProject: Record<string, TabInfo[]>;
|
||||
expandedProjects: Record<string, boolean>;
|
||||
toggleProject: (projectId: string) => void;
|
||||
collapsed: boolean;
|
||||
activeSessionId?: string | null;
|
||||
onNavigate?: (view: AppView) => void;
|
||||
onSelectSession?: (sessionId: string) => void;
|
||||
onNewChatInProject?: (projectId: string) => void;
|
||||
onEditProject?: (projectId: string) => void;
|
||||
onArchiveProject?: (projectId: string) => void;
|
||||
onArchiveChat?: (sessionId: string) => void;
|
||||
onRenameChat?: (sessionId: string, nextTitle: string) => void;
|
||||
onMoveToProject?: (sessionId: string, projectId: string | null) => void;
|
||||
onReorderProject?: (fromId: string, toId: string) => void;
|
||||
}) {
|
||||
const [draggedProjectId, setDraggedProjectId] = useState<string | null>(null);
|
||||
const [dropTargetProjectId, setDropTargetProjectId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{projects.map((project) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
key={project.id}
|
||||
title={project.name}
|
||||
onClick={() => onNavigate?.("projects")}
|
||||
className="rounded-lg text-foreground hover:bg-transparent hover:text-foreground"
|
||||
>
|
||||
<ProjectIcon
|
||||
icon={project.icon}
|
||||
className="size-4"
|
||||
imageClassName="size-4 rounded-[4px]"
|
||||
/>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{projects.map((project) => (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop reorder target
|
||||
<div
|
||||
key={project.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
if (e.dataTransfer.types.includes("text/x-session-id")) return;
|
||||
e.dataTransfer.setData("text/x-project-id", project.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
setDraggedProjectId(project.id);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes("text/x-project-id")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (project.id !== draggedProjectId) {
|
||||
setDropTargetProjectId(project.id);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setDropTargetProjectId((prev) =>
|
||||
prev === project.id ? null : prev,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
const fromId = e.dataTransfer.getData("text/x-project-id");
|
||||
if (fromId && fromId !== project.id) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onReorderProject?.(fromId, project.id);
|
||||
}
|
||||
setDraggedProjectId(null);
|
||||
setDropTargetProjectId(null);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDraggedProjectId(null);
|
||||
setDropTargetProjectId(null);
|
||||
}}
|
||||
className={cn(
|
||||
"relative",
|
||||
draggedProjectId === project.id && "opacity-40",
|
||||
)}
|
||||
>
|
||||
{dropTargetProjectId === project.id &&
|
||||
draggedProjectId !== project.id && (
|
||||
<div className="absolute top-0 left-3 right-3 h-0.5 rounded-full bg-foreground" />
|
||||
)}
|
||||
<SidebarProjectSection
|
||||
project={project}
|
||||
projectChats={projectSessionsByProject[project.id] ?? []}
|
||||
isExpanded={expandedProjects[project.id] ?? false}
|
||||
toggleProject={toggleProject}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelectSession={onSelectSession}
|
||||
onNewChatInProject={onNewChatInProject}
|
||||
onNavigate={onNavigate}
|
||||
onEditProject={onEditProject}
|
||||
onArchiveProject={onArchiveProject}
|
||||
onArchiveChat={onArchiveChat}
|
||||
onRenameChat={onRenameChat}
|
||||
onMoveToProject={onMoveToProject}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useCallback, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconEdit,
|
||||
} from "@tabler/icons-react";
|
||||
import type { AppView } from "@/app/AppShell";
|
||||
import type { ProjectInfo } from "@/features/projects/api/projects";
|
||||
import { ProjectIcon } from "@/features/projects/ui/ProjectIcon";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { SidebarChatRow } from "./SidebarChatRow";
|
||||
import { SidebarItemMenu } from "./SidebarItemMenu";
|
||||
|
||||
const MAX_VISIBLE_CHATS = 5;
|
||||
const PROJECT_ROW_TEXT_CLASS =
|
||||
"text-foreground hover:bg-transparent hover:text-foreground";
|
||||
|
||||
interface TabInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
projectId?: string;
|
||||
isRunning?: boolean;
|
||||
hasUnread?: boolean;
|
||||
}
|
||||
|
||||
export function SidebarProjectSection({
|
||||
project,
|
||||
projectChats,
|
||||
isExpanded,
|
||||
toggleProject,
|
||||
activeSessionId,
|
||||
onSelectSession,
|
||||
onNewChatInProject,
|
||||
onNavigate,
|
||||
onEditProject,
|
||||
onArchiveProject,
|
||||
onArchiveChat,
|
||||
onRenameChat,
|
||||
onMoveToProject,
|
||||
}: {
|
||||
project: ProjectInfo;
|
||||
projectChats: TabInfo[];
|
||||
isExpanded: boolean;
|
||||
toggleProject: (projectId: string) => void;
|
||||
activeSessionId?: string | null;
|
||||
onSelectSession?: (sessionId: string) => void;
|
||||
onNewChatInProject?: (projectId: string) => void;
|
||||
onNavigate?: (view: AppView) => void;
|
||||
onEditProject?: (projectId: string) => void;
|
||||
onArchiveProject?: (projectId: string) => void;
|
||||
onArchiveChat?: (sessionId: string) => void;
|
||||
onRenameChat?: (sessionId: string, nextTitle: string) => void;
|
||||
onMoveToProject?: (sessionId: string, projectId: string | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["sidebar", "common"]);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
if (e.dataTransfer.types.includes("text/x-session-id")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOver(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setDragOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const sessionId = e.dataTransfer.getData("text/x-session-id");
|
||||
if (sessionId) {
|
||||
onMoveToProject?.(sessionId, project.id);
|
||||
if (!isExpanded) toggleProject(project.id);
|
||||
}
|
||||
},
|
||||
[onMoveToProject, project.id, isExpanded, toggleProject],
|
||||
);
|
||||
const visibleChats = projectChats.slice(
|
||||
0,
|
||||
showAll ? undefined : MAX_VISIBLE_CHATS,
|
||||
);
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: drop target for drag-and-drop
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center group rounded-md transition-colors duration-200 hover:bg-background-alt focus-within:bg-background-alt",
|
||||
menuOpen && "bg-background-alt",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleProject(project.id)}
|
||||
className={cn(
|
||||
"flex-1 min-w-0 justify-start gap-2 rounded-md px-3 py-2 text-sm font-normal",
|
||||
PROJECT_ROW_TEXT_CLASS,
|
||||
)}
|
||||
>
|
||||
<span className="relative flex h-4 w-4 flex-shrink-0 items-center justify-center text-foreground">
|
||||
<span className="absolute group-hover:opacity-0">
|
||||
<ProjectIcon
|
||||
icon={project.icon}
|
||||
className="size-3.5"
|
||||
imageClassName="size-3.5 rounded-[3px]"
|
||||
/>
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<IconChevronDown className="absolute size-3 opacity-0 group-hover:opacity-100" />
|
||||
) : (
|
||||
<IconChevronRight className="absolute size-3 opacity-0 group-hover:opacity-100" />
|
||||
)}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate text-left">
|
||||
{project.name}
|
||||
</span>
|
||||
</Button>
|
||||
<SidebarItemMenu
|
||||
label={project.name}
|
||||
onOpenChange={setMenuOpen}
|
||||
onEdit={() => onEditProject?.(project.id)}
|
||||
onArchive={() => onArchiveProject?.(project.id)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNewChatInProject?.(project.id);
|
||||
}}
|
||||
title={t("actions.newChatInProject")}
|
||||
className={cn(
|
||||
"mr-1 size-6 flex-shrink-0 rounded-md",
|
||||
menuOpen
|
||||
? "visible opacity-100"
|
||||
: "invisible opacity-0 group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100",
|
||||
)}
|
||||
>
|
||||
<IconEdit className="size-4" />
|
||||
</Button>
|
||||
|
||||
{dragOver && (
|
||||
<div className="absolute bottom-0 left-3 right-3 h-px bg-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{visibleChats.map((session) => {
|
||||
const isActive = activeSessionId === session.id;
|
||||
return (
|
||||
<SidebarChatRow
|
||||
key={session.id}
|
||||
id={session.id}
|
||||
title={session.title}
|
||||
isActive={isActive}
|
||||
isRunning={session.isRunning ?? false}
|
||||
hasUnread={session.hasUnread ?? false}
|
||||
nested
|
||||
onSelect={onSelectSession}
|
||||
onRename={onRenameChat}
|
||||
onArchive={onArchiveChat}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{projectChats.length > MAX_VISIBLE_CHATS && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
if (showAll) {
|
||||
setShowAll(false);
|
||||
} else {
|
||||
if (projectChats.length > 8) {
|
||||
onNavigate?.("projects");
|
||||
} else {
|
||||
setShowAll(true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="h-auto w-full justify-start gap-1.5 rounded-md py-1 pl-8 pr-3 text-[11px] text-foreground hover:text-foreground"
|
||||
>
|
||||
{showAll ? (
|
||||
<>
|
||||
<IconChevronDown className="size-3" />
|
||||
{t("showLess")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconChevronRight className="size-3" />
|
||||
{projectChats.length > 8
|
||||
? t("viewAllChats", {
|
||||
count: projectChats.length,
|
||||
displayCount: projectChats.length,
|
||||
})
|
||||
: t("moreChats", {
|
||||
count: projectChats.length - MAX_VISIBLE_CHATS,
|
||||
displayCount: projectChats.length - MAX_VISIBLE_CHATS,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,10 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconLibraryPlusFilled,
|
||||
IconMessage,
|
||||
IconPlus,
|
||||
} from "@tabler/icons-react";
|
||||
import { getDisplaySessionTitle } from "@/features/chat/lib/sessionTitle";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import type { AppView } from "@/app/AppShell";
|
||||
import type { ProjectInfo } from "@/features/projects/api/projects";
|
||||
import { SessionActivityIndicator } from "@/shared/ui/SessionActivityIndicator";
|
||||
import { SidebarItemMenu } from "./SidebarItemMenu";
|
||||
import { SidebarChatRow } from "./SidebarChatRow";
|
||||
|
||||
const MAX_VISIBLE_CHATS = 5;
|
||||
const PROJECT_ROW_TEXT_CLASS =
|
||||
"text-muted-foreground hover:bg-transparent hover:text-foreground group-hover:text-foreground";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { SidebarProjectList } from "./SidebarProjectList";
|
||||
import { SidebarRecentsSection } from "./SidebarRecentsSection";
|
||||
|
||||
interface TabInfo {
|
||||
id: string;
|
||||
@@ -39,7 +25,6 @@ interface SidebarProjectsSectionProps {
|
||||
labelTransition: string;
|
||||
labelVisible: boolean;
|
||||
activeSessionId?: string | null;
|
||||
activeProjectId?: string | null;
|
||||
onNavigate?: (view: AppView) => void;
|
||||
onSelectSession?: (sessionId: string) => void;
|
||||
onNewChatInProject?: (projectId: string) => void;
|
||||
@@ -51,212 +36,6 @@ interface SidebarProjectsSectionProps {
|
||||
onRenameChat?: (sessionId: string, nextTitle: string) => void;
|
||||
onMoveToProject?: (sessionId: string, projectId: string | null) => void;
|
||||
onReorderProject?: (fromId: string, toId: string) => void;
|
||||
onItemMouseEnter?: (e: React.MouseEvent<HTMLElement>) => void;
|
||||
activeSessionRefCallback?: (el: HTMLElement | null) => void;
|
||||
activeProjectRefCallback?: (el: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
function ProjectSection({
|
||||
project,
|
||||
projectChats,
|
||||
isExpanded,
|
||||
toggleProject,
|
||||
activeSessionId,
|
||||
activeProjectId,
|
||||
onSelectSession,
|
||||
onNewChatInProject,
|
||||
onNavigate,
|
||||
onEditProject,
|
||||
onArchiveProject,
|
||||
onArchiveChat,
|
||||
onRenameChat,
|
||||
onMoveToProject,
|
||||
onItemMouseEnter,
|
||||
activeSessionRefCallback,
|
||||
activeProjectRefCallback,
|
||||
}: {
|
||||
project: ProjectInfo;
|
||||
projectChats: TabInfo[];
|
||||
isExpanded: boolean;
|
||||
toggleProject: (projectId: string) => void;
|
||||
activeSessionId?: string | null;
|
||||
activeProjectId?: string | null;
|
||||
onSelectSession?: (sessionId: string) => void;
|
||||
onNewChatInProject?: (projectId: string) => void;
|
||||
onNavigate?: (view: AppView) => void;
|
||||
onEditProject?: (projectId: string) => void;
|
||||
onArchiveProject?: (projectId: string) => void;
|
||||
onArchiveChat?: (sessionId: string) => void;
|
||||
onRenameChat?: (sessionId: string, nextTitle: string) => void;
|
||||
onMoveToProject?: (sessionId: string, projectId: string | null) => void;
|
||||
onItemMouseEnter?: (e: React.MouseEvent<HTMLElement>) => void;
|
||||
activeSessionRefCallback?: (el: HTMLElement | null) => void;
|
||||
activeProjectRefCallback?: (el: HTMLElement | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["sidebar", "common"]);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (e.dataTransfer.types.includes("text/x-session-id")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOver(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setDragOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const sessionId = e.dataTransfer.getData("text/x-session-id");
|
||||
if (sessionId) {
|
||||
onMoveToProject?.(sessionId, project.id);
|
||||
if (!isExpanded) toggleProject(project.id);
|
||||
}
|
||||
},
|
||||
[onMoveToProject, project.id, isExpanded, toggleProject],
|
||||
);
|
||||
const visibleChats = projectChats.slice(
|
||||
0,
|
||||
showAll ? undefined : MAX_VISIBLE_CHATS,
|
||||
);
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: drop target for drag-and-drop
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div
|
||||
ref={
|
||||
activeProjectId === project.id ? activeProjectRefCallback : undefined
|
||||
}
|
||||
className="relative flex items-center group rounded-md transition-colors duration-200"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleProject(project.id)}
|
||||
onMouseEnter={onItemMouseEnter}
|
||||
className={cn(
|
||||
"flex-1 min-w-0 justify-start gap-2 rounded-md px-3 py-2 text-[13px] font-light",
|
||||
activeProjectId === project.id
|
||||
? "font-medium text-foreground hover:bg-transparent hover:text-foreground group-hover:text-foreground"
|
||||
: PROJECT_ROW_TEXT_CLASS,
|
||||
)}
|
||||
>
|
||||
<span className="relative flex h-3 w-3 flex-shrink-0 items-center justify-center">
|
||||
<span
|
||||
className="absolute inline-block h-2 w-2 rounded-full transition-opacity duration-150 group-hover:opacity-0"
|
||||
style={{ backgroundColor: project.color }}
|
||||
/>
|
||||
{isExpanded ? (
|
||||
<IconChevronDown className="absolute h-3 w-3 opacity-0 transition-opacity duration-150 group-hover:opacity-100" />
|
||||
) : (
|
||||
<IconChevronRight className="absolute h-3 w-3 opacity-0 transition-opacity duration-150 group-hover:opacity-100" />
|
||||
)}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate text-left">
|
||||
{project.name}
|
||||
</span>
|
||||
</Button>
|
||||
<SidebarItemMenu
|
||||
label={project.name}
|
||||
onEdit={() => onEditProject?.(project.id)}
|
||||
onArchive={() => onArchiveProject?.(project.id)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNewChatInProject?.(project.id);
|
||||
}}
|
||||
title={t("actions.newChatInProject")}
|
||||
className="mr-1 size-6 flex-shrink-0 rounded-md text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<IconPlus className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
{dragOver && (
|
||||
<div className="absolute bottom-0 left-3 right-3 h-px bg-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{visibleChats.map((session) => {
|
||||
const isActive = activeSessionId === session.id;
|
||||
return (
|
||||
<SidebarChatRow
|
||||
key={session.id}
|
||||
id={session.id}
|
||||
title={session.title}
|
||||
isActive={isActive}
|
||||
isRunning={session.isRunning ?? false}
|
||||
hasUnread={session.hasUnread ?? false}
|
||||
className="pl-5"
|
||||
onSelect={onSelectSession}
|
||||
onRename={onRenameChat}
|
||||
onArchive={onArchiveChat}
|
||||
onMouseEnter={onItemMouseEnter}
|
||||
activeRef={isActive ? activeSessionRefCallback : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{projectChats.length > MAX_VISIBLE_CHATS && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
if (showAll) {
|
||||
setShowAll(false);
|
||||
} else {
|
||||
if (projectChats.length > 8) {
|
||||
onNavigate?.("projects");
|
||||
} else {
|
||||
setShowAll(true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="h-auto w-full justify-start gap-1.5 rounded-md py-1 pl-8 pr-3 text-[11px] text-muted-foreground hover:text-muted-foreground"
|
||||
>
|
||||
{showAll ? (
|
||||
<>
|
||||
<IconChevronDown className="size-3" />
|
||||
{t("showLess")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconChevronRight className="size-3" />
|
||||
{projectChats.length > 8
|
||||
? t("viewAllChats", {
|
||||
count: projectChats.length,
|
||||
displayCount: projectChats.length,
|
||||
})
|
||||
: t("moreChats", {
|
||||
count: projectChats.length - MAX_VISIBLE_CHATS,
|
||||
displayCount: projectChats.length - MAX_VISIBLE_CHATS,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarProjectsSection({
|
||||
@@ -268,7 +47,6 @@ export function SidebarProjectsSection({
|
||||
labelTransition,
|
||||
labelVisible,
|
||||
activeSessionId,
|
||||
activeProjectId,
|
||||
onNavigate,
|
||||
onSelectSession,
|
||||
onNewChatInProject,
|
||||
@@ -280,43 +58,8 @@ export function SidebarProjectsSection({
|
||||
onRenameChat,
|
||||
onMoveToProject,
|
||||
onReorderProject,
|
||||
onItemMouseEnter,
|
||||
activeSessionRefCallback,
|
||||
activeProjectRefCallback,
|
||||
}: SidebarProjectsSectionProps) {
|
||||
const { t } = useTranslation(["sidebar", "common"]);
|
||||
const [recentsDragOver, setRecentsDragOver] = useState(false);
|
||||
const [draggedProjectId, setDraggedProjectId] = useState<string | null>(null);
|
||||
const [dropTargetProjectId, setDropTargetProjectId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleRecentsDragOver = useCallback((e: React.DragEvent) => {
|
||||
const hasSession = e.dataTransfer.types.includes("text/x-session-id");
|
||||
if (hasSession) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setRecentsDragOver(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRecentsDragLeave = useCallback((e: React.DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setRecentsDragOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRecentsDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setRecentsDragOver(false);
|
||||
const sessionId = e.dataTransfer.getData("text/x-session-id");
|
||||
if (sessionId) {
|
||||
onMoveToProject?.(sessionId, null);
|
||||
}
|
||||
},
|
||||
[onMoveToProject],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -332,13 +75,13 @@ export function SidebarProjectsSection({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center transition-all duration-300",
|
||||
collapsed ? "px-0 pt-0 pb-1 justify-center" : "pt-4 pb-1",
|
||||
"group/projects-header flex items-center transition-all duration-300",
|
||||
collapsed ? "px-0 pt-0 pb-1 justify-center" : "pt-5 pb-1.5",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-light uppercase tracking-wider text-muted-foreground flex-1 pl-3",
|
||||
"text-[12px] font-normal text-muted-foreground/80 flex-1 pl-3",
|
||||
labelTransition,
|
||||
labelVisible
|
||||
? "opacity-100 w-auto"
|
||||
@@ -351,211 +94,49 @@ export function SidebarProjectsSection({
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
size="xs"
|
||||
onClick={onCreateProject}
|
||||
title={t("actions.newProject")}
|
||||
className={cn(
|
||||
"mr-1 size-6 flex-shrink-0 rounded-md text-muted-foreground hover:text-foreground",
|
||||
"opacity-0 group-hover:opacity-100 group-focus-within:opacity-100",
|
||||
"mr-1 h-6 flex-shrink-0 rounded-full bg-muted px-2 text-[11px] text-foreground opacity-0 transition-opacity duration-150 ease-out hover:bg-muted/80 hover:text-foreground",
|
||||
"pointer-events-none group-hover/projects-header:pointer-events-auto group-hover/projects-header:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100",
|
||||
)}
|
||||
>
|
||||
<IconLibraryPlusFilled className="size-3.5" />
|
||||
{t("actions.newProject")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{collapsed ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{projects.map((project) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
key={project.id}
|
||||
title={project.name}
|
||||
onClick={() => onNavigate?.("projects")}
|
||||
className="rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||
>
|
||||
<span
|
||||
className="inline-block size-2.5 rounded-full"
|
||||
style={{ backgroundColor: project.color }}
|
||||
/>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{projects.map((project) => (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop reorder target
|
||||
<div
|
||||
key={project.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
// Skip if a child chat row already initiated a session drag
|
||||
if (e.dataTransfer.types.includes("text/x-session-id")) return;
|
||||
e.dataTransfer.setData("text/x-project-id", project.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
setDraggedProjectId(project.id);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes("text/x-project-id")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (project.id !== draggedProjectId) {
|
||||
setDropTargetProjectId(project.id);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setDropTargetProjectId((prev) =>
|
||||
prev === project.id ? null : prev,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
const fromId = e.dataTransfer.getData("text/x-project-id");
|
||||
if (fromId && fromId !== project.id) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onReorderProject?.(fromId, project.id);
|
||||
}
|
||||
setDraggedProjectId(null);
|
||||
setDropTargetProjectId(null);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDraggedProjectId(null);
|
||||
setDropTargetProjectId(null);
|
||||
}}
|
||||
className={cn(
|
||||
"relative",
|
||||
draggedProjectId === project.id && "opacity-40",
|
||||
)}
|
||||
>
|
||||
{dropTargetProjectId === project.id &&
|
||||
draggedProjectId !== project.id && (
|
||||
<div className="absolute top-0 left-3 right-3 h-0.5 rounded-full bg-foreground" />
|
||||
)}
|
||||
<ProjectSection
|
||||
project={project}
|
||||
projectChats={projectSessions.byProject[project.id] ?? []}
|
||||
isExpanded={expandedProjects[project.id] ?? false}
|
||||
toggleProject={toggleProject}
|
||||
activeSessionId={activeSessionId}
|
||||
activeProjectId={activeProjectId}
|
||||
onSelectSession={onSelectSession}
|
||||
onNewChatInProject={onNewChatInProject}
|
||||
onNavigate={onNavigate}
|
||||
onEditProject={onEditProject}
|
||||
onArchiveProject={onArchiveProject}
|
||||
onArchiveChat={onArchiveChat}
|
||||
onRenameChat={onRenameChat}
|
||||
onMoveToProject={onMoveToProject}
|
||||
onItemMouseEnter={onItemMouseEnter}
|
||||
activeSessionRefCallback={activeSessionRefCallback}
|
||||
activeProjectRefCallback={activeProjectRefCallback}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<SidebarProjectList
|
||||
projects={projects}
|
||||
projectSessionsByProject={projectSessions.byProject}
|
||||
expandedProjects={expandedProjects}
|
||||
toggleProject={toggleProject}
|
||||
collapsed={collapsed}
|
||||
activeSessionId={activeSessionId}
|
||||
onNavigate={onNavigate}
|
||||
onSelectSession={onSelectSession}
|
||||
onNewChatInProject={onNewChatInProject}
|
||||
onEditProject={onEditProject}
|
||||
onArchiveProject={onArchiveProject}
|
||||
onArchiveChat={onArchiveChat}
|
||||
onRenameChat={onRenameChat}
|
||||
onMoveToProject={onMoveToProject}
|
||||
onReorderProject={onReorderProject}
|
||||
/>
|
||||
|
||||
{/* --- RECENTS — always rendered as a drop target so chats can be unassigned from projects --- */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: drop target for drag-and-drop */}
|
||||
<div
|
||||
onDragOver={handleRecentsDragOver}
|
||||
onDragLeave={handleRecentsDragLeave}
|
||||
onDrop={handleRecentsDrop}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative group flex items-center transition-all duration-300",
|
||||
collapsed ? "px-0 pt-0 pb-1 justify-center" : "pt-4 pb-1",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-light uppercase tracking-wider text-muted-foreground flex-1 pl-3",
|
||||
labelTransition,
|
||||
labelVisible
|
||||
? "opacity-100 w-auto"
|
||||
: "opacity-0 w-0 overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{t("sections.recents")}
|
||||
</span>
|
||||
{!collapsed && onNewChat && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={onNewChat}
|
||||
aria-label={t("actions.newChat")}
|
||||
title={t("actions.newChat")}
|
||||
className="mr-1 size-6 flex-shrink-0 rounded-md text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<IconPlus className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{recentsDragOver && (
|
||||
<div className="absolute bottom-0 left-3 right-3 h-px bg-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{projectSessions.standalone.length > 0 &&
|
||||
(collapsed ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{projectSessions.standalone.map((session) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
key={session.id}
|
||||
title={getDisplaySessionTitle(
|
||||
session.title,
|
||||
t("common:session.defaultTitle"),
|
||||
)}
|
||||
onClick={() => onSelectSession?.(session.id)}
|
||||
className={cn(
|
||||
"relative rounded-lg",
|
||||
activeSessionId === session.id
|
||||
? "bg-accent/70 text-foreground hover:bg-accent/70"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<IconMessage className="size-4" />
|
||||
<SessionActivityIndicator
|
||||
isRunning={session.isRunning}
|
||||
hasUnread={session.hasUnread}
|
||||
variant="overlay"
|
||||
/>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{projectSessions.standalone.map((session) => {
|
||||
const isActive = activeSessionId === session.id;
|
||||
return (
|
||||
<SidebarChatRow
|
||||
key={session.id}
|
||||
id={session.id}
|
||||
title={session.title}
|
||||
isActive={isActive}
|
||||
isRunning={session.isRunning ?? false}
|
||||
hasUnread={session.hasUnread ?? false}
|
||||
onSelect={onSelectSession}
|
||||
onRename={onRenameChat}
|
||||
onArchive={onArchiveChat}
|
||||
onMouseEnter={onItemMouseEnter}
|
||||
activeRef={isActive ? activeSessionRefCallback : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<SidebarRecentsSection
|
||||
sessions={projectSessions.standalone}
|
||||
collapsed={collapsed}
|
||||
labelTransition={labelTransition}
|
||||
labelVisible={labelVisible}
|
||||
activeSessionId={activeSessionId}
|
||||
onNewChat={onNewChat}
|
||||
onSelectSession={onSelectSession}
|
||||
onArchiveChat={onArchiveChat}
|
||||
onRenameChat={onRenameChat}
|
||||
onMoveToProject={onMoveToProject}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useCallback, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconMessage } from "@tabler/icons-react";
|
||||
import { getDisplaySessionTitle } from "@/features/chat/lib/sessionTitle";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { SessionActivityIndicator } from "@/shared/ui/SessionActivityIndicator";
|
||||
import { SidebarChatRow } from "./SidebarChatRow";
|
||||
|
||||
interface TabInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
projectId?: string;
|
||||
isRunning?: boolean;
|
||||
hasUnread?: boolean;
|
||||
}
|
||||
|
||||
export function SidebarRecentsSection({
|
||||
sessions,
|
||||
collapsed,
|
||||
labelTransition,
|
||||
labelVisible,
|
||||
activeSessionId,
|
||||
onNewChat,
|
||||
onSelectSession,
|
||||
onArchiveChat,
|
||||
onRenameChat,
|
||||
onMoveToProject,
|
||||
}: {
|
||||
sessions: TabInfo[];
|
||||
collapsed: boolean;
|
||||
labelTransition: string;
|
||||
labelVisible: boolean;
|
||||
activeSessionId?: string | null;
|
||||
onNewChat?: () => void;
|
||||
onSelectSession?: (sessionId: string) => void;
|
||||
onArchiveChat?: (sessionId: string) => void;
|
||||
onRenameChat?: (sessionId: string, nextTitle: string) => void;
|
||||
onMoveToProject?: (sessionId: string, projectId: string | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["sidebar", "common"]);
|
||||
const [recentsDragOver, setRecentsDragOver] = useState(false);
|
||||
|
||||
const handleRecentsDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
const hasSession = e.dataTransfer.types.includes("text/x-session-id");
|
||||
if (hasSession) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setRecentsDragOver(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRecentsDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setRecentsDragOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRecentsDrop = useCallback(
|
||||
(e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setRecentsDragOver(false);
|
||||
const sessionId = e.dataTransfer.getData("text/x-session-id");
|
||||
if (sessionId) {
|
||||
onMoveToProject?.(sessionId, null);
|
||||
}
|
||||
},
|
||||
[onMoveToProject],
|
||||
);
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: drop target for drag-and-drop
|
||||
<div
|
||||
onDragOver={handleRecentsDragOver}
|
||||
onDragLeave={handleRecentsDragLeave}
|
||||
onDrop={handleRecentsDrop}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative group/chats-header flex items-center transition-all duration-300",
|
||||
collapsed ? "px-0 pt-0 pb-1 justify-center" : "pt-5 pb-1.5",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[12px] font-normal text-muted-foreground/80 flex-1 pl-3",
|
||||
labelTransition,
|
||||
labelVisible
|
||||
? "opacity-100 w-auto"
|
||||
: "opacity-0 w-0 overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{t("sections.recents")}
|
||||
</span>
|
||||
{!collapsed && onNewChat && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={onNewChat}
|
||||
aria-label={t("actions.newChat")}
|
||||
title={t("actions.newChat")}
|
||||
className={cn(
|
||||
"mr-1 h-6 flex-shrink-0 rounded-full bg-muted px-2 text-[11px] text-foreground opacity-0 transition-opacity duration-150 ease-out hover:bg-muted/80 hover:text-foreground",
|
||||
"pointer-events-none group-hover/chats-header:pointer-events-auto group-hover/chats-header:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100",
|
||||
)}
|
||||
>
|
||||
{t("actions.newChat")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{recentsDragOver && (
|
||||
<div className="absolute bottom-0 left-3 right-3 h-px bg-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sessions.length > 0 &&
|
||||
(collapsed ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{sessions.map((session) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
key={session.id}
|
||||
title={getDisplaySessionTitle(
|
||||
session.title,
|
||||
t("common:session.defaultTitle"),
|
||||
)}
|
||||
onClick={() => onSelectSession?.(session.id)}
|
||||
className={cn(
|
||||
"relative rounded-lg",
|
||||
activeSessionId === session.id
|
||||
? "bg-transparent text-foreground hover:bg-transparent"
|
||||
: "text-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<IconMessage className="size-4" />
|
||||
<SessionActivityIndicator
|
||||
isRunning={session.isRunning}
|
||||
hasUnread={session.hasUnread}
|
||||
variant="overlay"
|
||||
/>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{sessions.map((session) => {
|
||||
const isActive = activeSessionId === session.id;
|
||||
return (
|
||||
<SidebarChatRow
|
||||
key={session.id}
|
||||
id={session.id}
|
||||
title={session.title}
|
||||
isActive={isActive}
|
||||
isRunning={session.isRunning ?? false}
|
||||
hasUnread={session.hasUnread ?? false}
|
||||
onSelect={onSelectSession}
|
||||
onRename={onRenameChat}
|
||||
onArchive={onArchiveChat}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface HighlightRect {
|
||||
top: number;
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export function useSidebarHighlight(
|
||||
navRef: React.RefObject<HTMLElement | null>,
|
||||
) {
|
||||
const [hoveredRect, setHoveredRect] = useState<HighlightRect | null>(null);
|
||||
const [activeRect, setActiveRect] = useState<HighlightRect | null>(null);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const activeElRef = useRef<HTMLElement | null>(null);
|
||||
const resizeTimerRef = useRef(0);
|
||||
|
||||
const measureElement = useCallback(
|
||||
(el: HTMLElement): HighlightRect | null => {
|
||||
const nav = navRef.current;
|
||||
if (!nav || !el) return null;
|
||||
const navRect = nav.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
return {
|
||||
top: elRect.top - navRect.top + nav.scrollTop,
|
||||
height: elRect.height,
|
||||
width: elRect.width,
|
||||
};
|
||||
},
|
||||
[navRef],
|
||||
);
|
||||
|
||||
// Re-measure the active element whenever the nav subtree changes
|
||||
// (project expand/collapse, list re-sort, filtering, show-more, etc.).
|
||||
useEffect(() => {
|
||||
const nav = navRef.current;
|
||||
if (!nav) return;
|
||||
|
||||
let rafId = 0;
|
||||
const remeasure = () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const el = activeElRef.current;
|
||||
if (!el) return;
|
||||
const rect = measureElement(el);
|
||||
if (rect) setActiveRect(rect);
|
||||
});
|
||||
};
|
||||
|
||||
const mutationObserver = new MutationObserver(remeasure);
|
||||
mutationObserver.observe(nav, { childList: true, subtree: true });
|
||||
|
||||
// Also re-measure when the nav resizes (e.g. sidebar expand/collapse
|
||||
// transitions change item positions even without DOM mutations).
|
||||
// Suppress the frame's transition while resizing so it snaps to position
|
||||
// instead of sliding from the old (collapsed-layout) coordinates.
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
setIsResizing(true);
|
||||
clearTimeout(resizeTimerRef.current);
|
||||
resizeTimerRef.current = window.setTimeout(
|
||||
() => setIsResizing(false),
|
||||
400,
|
||||
);
|
||||
remeasure();
|
||||
});
|
||||
resizeObserver.observe(nav);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
clearTimeout(resizeTimerRef.current);
|
||||
mutationObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [navRef, measureElement]);
|
||||
|
||||
const onItemMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
setIsHovering(true);
|
||||
const rect = measureElement(e.currentTarget);
|
||||
if (rect) setHoveredRect(rect);
|
||||
},
|
||||
[measureElement],
|
||||
);
|
||||
|
||||
const onNavMouseLeave = useCallback(() => {
|
||||
setIsHovering(false);
|
||||
setHoveredRect(null);
|
||||
}, []);
|
||||
|
||||
const updateActiveRect = useCallback(
|
||||
(el: HTMLElement | null) => {
|
||||
activeElRef.current = el;
|
||||
if (el) {
|
||||
const rect = measureElement(el);
|
||||
if (rect) setActiveRect(rect);
|
||||
} else {
|
||||
setActiveRect(null);
|
||||
}
|
||||
},
|
||||
[measureElement],
|
||||
);
|
||||
|
||||
const currentRect = isHovering && hoveredRect ? hoveredRect : activeRect;
|
||||
|
||||
return {
|
||||
currentRect,
|
||||
isHovering,
|
||||
isResizing,
|
||||
onItemMouseEnter,
|
||||
onNavMouseLeave,
|
||||
updateActiveRect,
|
||||
};
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bot, Copy, Check } from "lucide-react";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { useLocaleFormatting } from "@/shared/i18n";
|
||||
|
||||
interface StatusBarProps {
|
||||
modelName?: string;
|
||||
sessionId?: string;
|
||||
tokenCount?: number;
|
||||
}
|
||||
|
||||
export function StatusBar({
|
||||
modelName,
|
||||
sessionId,
|
||||
tokenCount = 0,
|
||||
}: StatusBarProps) {
|
||||
const { t } = useTranslation("status");
|
||||
const { formatNumber } = useLocaleFormatting();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopySessionId = () => {
|
||||
if (!sessionId) return;
|
||||
navigator.clipboard.writeText(sessionId);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-6 w-full items-center justify-between",
|
||||
"bg-background/80 px-3 text-xs text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<Bot className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-muted-foreground">
|
||||
{modelName ?? t("noModel")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{sessionId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleCopySessionId}
|
||||
className="h-auto gap-1 rounded px-1 py-0.5 text-muted-foreground hover:text-muted-foreground"
|
||||
title={t("sessionTitle", { id: sessionId })}
|
||||
>
|
||||
<span className="font-mono">{sessionId.slice(0, 8)}</span>
|
||||
{copied ? (
|
||||
<Check className="size-2.5" />
|
||||
) : (
|
||||
<Copy className="size-2.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{tokenCount > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
{t("tokens", {
|
||||
count: tokenCount,
|
||||
displayCount: formatNumber(tokenCount),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,6 @@ export const TRANSLATION_NAMESPACES = [
|
||||
"settings",
|
||||
"skills",
|
||||
"sidebar",
|
||||
"status",
|
||||
"sessions",
|
||||
] as const;
|
||||
export const LOCALE_STORAGE_KEY = "goose:locale";
|
||||
|
||||
@@ -36,7 +36,6 @@ const localeResourceLoaders = {
|
||||
settings: () => import("./locales/en/settings.json"),
|
||||
skills: () => import("./locales/en/skills.json"),
|
||||
sidebar: () => import("./locales/en/sidebar.json"),
|
||||
status: () => import("./locales/en/status.json"),
|
||||
sessions: () => import("./locales/en/sessions.json"),
|
||||
},
|
||||
es: {
|
||||
@@ -48,7 +47,6 @@ const localeResourceLoaders = {
|
||||
settings: () => import("./locales/es/settings.json"),
|
||||
skills: () => import("./locales/es/skills.json"),
|
||||
sidebar: () => import("./locales/es/sidebar.json"),
|
||||
status: () => import("./locales/es/status.json"),
|
||||
sessions: () => import("./locales/es/sessions.json"),
|
||||
},
|
||||
} as const satisfies Record<
|
||||
|
||||
@@ -6,7 +6,33 @@
|
||||
"colorAria": "Color {{color}}",
|
||||
"createProject": "Create Project",
|
||||
"creating": "Creating...",
|
||||
"customIcon": "Custom icon",
|
||||
"customIconDialogTitle": "Select Icon",
|
||||
"editTitle": "Edit Project",
|
||||
"icon": "Icon",
|
||||
"iconAria": "Icon {{icon}}",
|
||||
"iconCandidateTitle": "{{sourceDir}}: {{label}}",
|
||||
"iconFileFilter": "Image files",
|
||||
"iconPresets": {
|
||||
"ai": "AI",
|
||||
"api": "API",
|
||||
"app": "App",
|
||||
"automation": "Automation",
|
||||
"code": "Code",
|
||||
"components": "Components",
|
||||
"database": "Database",
|
||||
"design": "Design",
|
||||
"docs": "Docs",
|
||||
"folderCode": "Folder code",
|
||||
"gitBranch": "Git branch",
|
||||
"github": "GitHub",
|
||||
"launch": "Launch",
|
||||
"package": "Package",
|
||||
"server": "Server",
|
||||
"settings": "Settings",
|
||||
"terminal": "Terminal",
|
||||
"website": "Website"
|
||||
},
|
||||
"instructions": "Instructions",
|
||||
"instructionsPlaceholder": "System prompt or context for agents working in this project...",
|
||||
"name": "Name",
|
||||
@@ -15,6 +41,8 @@
|
||||
"noneUseDefault": "None (use default)",
|
||||
"provider": "Provider",
|
||||
"saving": "Saving...",
|
||||
"scanningIcons": "Scanning...",
|
||||
"uploadIcon": "Upload",
|
||||
"useWorktrees": "Use git worktrees for branch isolation"
|
||||
},
|
||||
"view": {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"sections": {
|
||||
"projects": "Projects",
|
||||
"recents": "Recents"
|
||||
"recents": "Chats"
|
||||
},
|
||||
"showLess": "Show less",
|
||||
"viewAllChats_one": "View all {{displayCount}} chat",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"noModel": "No model",
|
||||
"sessionTitle": "Session: {{id}}",
|
||||
"tokens_one": "{{displayCount}} token",
|
||||
"tokens_other": "{{displayCount}} tokens"
|
||||
}
|
||||
@@ -6,7 +6,33 @@
|
||||
"colorAria": "Color {{color}}",
|
||||
"createProject": "Crear proyecto",
|
||||
"creating": "Creando...",
|
||||
"customIcon": "Icono personalizado",
|
||||
"customIconDialogTitle": "Seleccionar icono",
|
||||
"editTitle": "Editar proyecto",
|
||||
"icon": "Icono",
|
||||
"iconAria": "Icono {{icon}}",
|
||||
"iconCandidateTitle": "{{sourceDir}}: {{label}}",
|
||||
"iconFileFilter": "Archivos de imagen",
|
||||
"iconPresets": {
|
||||
"ai": "IA",
|
||||
"api": "API",
|
||||
"app": "App",
|
||||
"automation": "Automatización",
|
||||
"code": "Código",
|
||||
"components": "Componentes",
|
||||
"database": "Base de datos",
|
||||
"design": "Diseño",
|
||||
"docs": "Documentación",
|
||||
"folderCode": "Carpeta de código",
|
||||
"gitBranch": "Rama de Git",
|
||||
"github": "GitHub",
|
||||
"launch": "Lanzamiento",
|
||||
"package": "Paquete",
|
||||
"server": "Servidor",
|
||||
"settings": "Ajustes",
|
||||
"terminal": "Terminal",
|
||||
"website": "Sitio web"
|
||||
},
|
||||
"instructions": "Instrucciones",
|
||||
"instructionsPlaceholder": "Prompt del sistema o contexto para agentes que trabajan en este proyecto...",
|
||||
"name": "Nombre",
|
||||
@@ -15,6 +41,8 @@
|
||||
"noneUseDefault": "Ninguno (usar predeterminado)",
|
||||
"provider": "Proveedor",
|
||||
"saving": "Guardando...",
|
||||
"scanningIcons": "Escaneando...",
|
||||
"uploadIcon": "Subir",
|
||||
"useWorktrees": "Usar git worktrees para aislar ramas"
|
||||
},
|
||||
"view": {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"sections": {
|
||||
"projects": "Proyectos",
|
||||
"recents": "Recientes"
|
||||
"recents": "Chats"
|
||||
},
|
||||
"showLess": "Mostrar menos",
|
||||
"viewAllChats_one": "Ver {{displayCount}} chat",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"noModel": "Sin modelo",
|
||||
"sessionTitle": "Sesión: {{id}}",
|
||||
"tokens_one": "{{displayCount}} token",
|
||||
"tokens_other": "{{displayCount}} tokens"
|
||||
}
|
||||
@@ -717,6 +717,8 @@ body,
|
||||
@apply border-border;
|
||||
}
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
font-variation-settings: normal;
|
||||
}
|
||||
|
||||
@@ -68,25 +68,25 @@ const buttonVariants = cva(
|
||||
variant: "ghost",
|
||||
size: "icon-xs",
|
||||
className:
|
||||
"bg-transparent text-muted-foreground hover:bg-transparent hover:text-foreground active:bg-transparent",
|
||||
"bg-transparent text-muted-foreground hover:bg-transparent hover:text-foreground active:bg-transparent data-[state=open]:text-foreground aria-expanded:text-foreground",
|
||||
},
|
||||
{
|
||||
variant: "ghost",
|
||||
size: "icon-sm",
|
||||
className:
|
||||
"bg-transparent text-muted-foreground hover:bg-transparent hover:text-foreground active:bg-transparent",
|
||||
"bg-transparent text-muted-foreground hover:bg-transparent hover:text-foreground active:bg-transparent data-[state=open]:text-foreground aria-expanded:text-foreground",
|
||||
},
|
||||
{
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
className:
|
||||
"bg-transparent text-muted-foreground hover:bg-transparent hover:text-foreground active:bg-transparent",
|
||||
"bg-transparent text-muted-foreground hover:bg-transparent hover:text-foreground active:bg-transparent data-[state=open]:text-foreground aria-expanded:text-foreground",
|
||||
},
|
||||
{
|
||||
variant: "ghost",
|
||||
size: "icon-lg",
|
||||
className:
|
||||
"bg-transparent text-muted-foreground hover:bg-transparent hover:text-foreground active:bg-transparent",
|
||||
"bg-transparent text-muted-foreground hover:bg-transparent hover:text-foreground active:bg-transparent data-[state=open]:text-foreground aria-expanded:text-foreground",
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { test, expect, waitForHome } from "./fixtures/tauri-mock";
|
||||
|
||||
async function clickNewChatInProject(
|
||||
page: Parameters<typeof waitForHome>[0],
|
||||
projectName: string,
|
||||
) {
|
||||
const projectButton = page.getByRole("button", {
|
||||
name: projectName,
|
||||
exact: true,
|
||||
});
|
||||
await projectButton.hover();
|
||||
await projectButton
|
||||
.locator("xpath=..")
|
||||
.getByTitle("New chat in project")
|
||||
.click();
|
||||
}
|
||||
|
||||
test.describe("Draft persistence", () => {
|
||||
test("home screen draft persists across navigation", async ({
|
||||
tauriMocked: page,
|
||||
@@ -51,7 +66,7 @@ test.describe("Draft persistence", () => {
|
||||
|
||||
// Create a new chat in project Alpha
|
||||
await page.getByRole("button", { name: "Alpha" }).click();
|
||||
await page.getByTitle("New chat in project").first().click();
|
||||
await clickNewChatInProject(page, "Alpha");
|
||||
|
||||
// Type a draft
|
||||
const chatInput = page.getByLabel("Chat message input");
|
||||
@@ -65,11 +80,11 @@ test.describe("Draft persistence", () => {
|
||||
await waitForHome(page);
|
||||
|
||||
await page.getByRole("button", { name: "Beta" }).click();
|
||||
await page.getByTitle("New chat in project").last().click();
|
||||
await clickNewChatInProject(page, "Beta");
|
||||
await expect(page.getByLabel("Chat message input")).toHaveValue("");
|
||||
|
||||
// Go back to project Alpha's draft via its + button
|
||||
await page.getByTitle("New chat in project").first().click();
|
||||
await clickNewChatInProject(page, "Alpha");
|
||||
|
||||
// Draft should be restored
|
||||
await expect(page.getByLabel("Chat message input")).toHaveValue(
|
||||
@@ -87,7 +102,7 @@ test.describe("Draft persistence", () => {
|
||||
await page.getByRole("button", { name: "Alpha" }).click();
|
||||
|
||||
// Click the "+" for Alpha to create a new chat
|
||||
await page.getByTitle("New chat in project").first().click();
|
||||
await clickNewChatInProject(page, "Alpha");
|
||||
|
||||
// We should be in chat view now
|
||||
const chatInput = page.getByLabel("Chat message input");
|
||||
@@ -95,7 +110,9 @@ test.describe("Draft persistence", () => {
|
||||
|
||||
// The draft session should NOT appear in the sidebar
|
||||
const sidebar = page.locator("nav");
|
||||
await expect(sidebar.getByText("New Chat")).not.toBeVisible();
|
||||
await expect(
|
||||
sidebar.getByRole("button", { name: "New Chat", exact: true }),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("empty draft cleans up when navigating away", async ({
|
||||
@@ -106,7 +123,7 @@ test.describe("Draft persistence", () => {
|
||||
|
||||
// Open project and create a new chat
|
||||
await page.getByRole("button", { name: "Alpha" }).click();
|
||||
await page.getByTitle("New chat in project").first().click();
|
||||
await clickNewChatInProject(page, "Alpha");
|
||||
|
||||
// Don't type anything — leave it empty
|
||||
await expect(page.getByLabel("Chat message input")).toBeVisible();
|
||||
@@ -116,7 +133,7 @@ test.describe("Draft persistence", () => {
|
||||
await waitForHome(page);
|
||||
|
||||
// Create another new chat in the same project
|
||||
await page.getByTitle("New chat in project").first().click();
|
||||
await clickNewChatInProject(page, "Alpha");
|
||||
|
||||
// Should get a fresh chat (the old empty one was cleaned up)
|
||||
await expect(page.getByLabel("Chat message input")).toHaveValue("");
|
||||
@@ -130,7 +147,7 @@ test.describe("Draft persistence", () => {
|
||||
|
||||
// Open project and create a new chat
|
||||
await page.getByRole("button", { name: "Alpha" }).click();
|
||||
await page.getByTitle("New chat in project").first().click();
|
||||
await clickNewChatInProject(page, "Alpha");
|
||||
|
||||
// Type a draft
|
||||
const chatInput = page.getByLabel("Chat message input");
|
||||
@@ -140,7 +157,7 @@ test.describe("Draft persistence", () => {
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Click new chat in the same project again
|
||||
await page.getByTitle("New chat in project").first().click();
|
||||
await clickNewChatInProject(page, "Alpha");
|
||||
|
||||
// Should reuse the existing draft instead of creating a new one
|
||||
await expect(page.getByLabel("Chat message input")).toHaveValue(
|
||||
|
||||
Reference in New Issue
Block a user