diff --git a/ui/goose2/src-tauri/src/commands/mod.rs b/ui/goose2/src-tauri/src/commands/mod.rs index 7de2490a..fec8ccac 100644 --- a/ui/goose2/src-tauri/src/commands/mod.rs +++ b/ui/goose2/src-tauri/src/commands/mod.rs @@ -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; diff --git a/ui/goose2/src-tauri/src/commands/project_icons.rs b/ui/goose2/src-tauri/src/commands/project_icons.rs new file mode 100644 index 00000000..803cce98 --- /dev/null +++ b/ui/goose2/src-tauri/src/commands/project_icons.rs @@ -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 { + 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) -> Result, String> { + let mut candidates: Vec = 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 { + 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) + ); + } +} diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index fff92e60..92184145 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -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, diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index 6fa9312e..cc8cddb4 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -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 (
- openSettings()} /> +
openSettings()} onNavigate={handleNavigate} onNewChatInProject={handleNewChatInProject} onNewChat={() => { @@ -710,7 +683,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
@@ -735,18 +708,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
-
- -
- {settingsOpen && (
); diff --git a/ui/goose2/src/app/ui/TopBar.tsx b/ui/goose2/src/app/ui/TopBar.tsx index 4618ff75..7e7f57d2 100644 --- a/ui/goose2/src/app/ui/TopBar.tsx +++ b/ui/goose2/src/app/ui/TopBar.tsx @@ -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 (
- -
); } diff --git a/ui/goose2/src/features/chat/hooks/useChatSessionController.ts b/ui/goose2/src/features/chat/hooks/useChatSessionController.ts index 9db7e11c..dc8f629d 100644 --- a/ui/goose2/src/features/chat/hooks/useChatSessionController.ts +++ b/ui/goose2/src/features/chat/hooks/useChatSessionController.ts @@ -135,6 +135,7 @@ export function useChatSessionController({ id: projectInfo.id, name: projectInfo.name, workingDirs: projectInfo.workingDirs, + icon: projectInfo.icon, color: projectInfo.color, })), [projects], diff --git a/ui/goose2/src/features/chat/types.ts b/ui/goose2/src/features/chat/types.ts index 1e35c460..287e4883 100644 --- a/ui/goose2/src/features/chat/types.ts +++ b/ui/goose2/src/features/chat/types.ts @@ -18,6 +18,7 @@ export interface ProjectOption { id: string; name: string; workingDirs: string[]; + icon?: string | null; color?: string | null; } diff --git a/ui/goose2/src/features/chat/ui/ChatContextPanel.tsx b/ui/goose2/src/features/chat/ui/ChatContextPanel.tsx index 242dcbb6..916e8d31 100644 --- a/ui/goose2/src/features/chat/ui/ChatContextPanel.tsx +++ b/ui/goose2/src/features/chat/ui/ChatContextPanel.tsx @@ -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} > diff --git a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx index 1670e951..26250037 100644 --- a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx @@ -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 ( -
- {/* Instructions */}
- {/* Color */} -
- -
- {COLOR_OPTIONS.map((c) => ( -
-
+ {/* Provider */}
diff --git a/ui/goose2/src/features/projects/ui/ProjectIcon.tsx b/ui/goose2/src/features/projects/ui/ProjectIcon.tsx new file mode 100644 index 00000000..51f5c198 --- /dev/null +++ b/ui/goose2/src/features/projects/ui/ProjectIcon.tsx @@ -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([ + [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(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 ( + setFailedImageIcon(normalizedIcon)} + /> + ); + } + + const Icon = tablerIconsByValue.get(normalizedIcon) ?? IconFolder; + + return ; +} diff --git a/ui/goose2/src/features/projects/ui/ProjectIconPicker.tsx b/ui/goose2/src/features/projects/ui/ProjectIconPicker.tsx new file mode 100644 index 00000000..ee4eca40 --- /dev/null +++ b/ui/goose2/src/features/projects/ui/ProjectIconPicker.tsx @@ -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 ( +
+
+ + {t("dialog.icon")} + + {iconScanPending && ( + + + {t("dialog.scanningIcons")} + + )} +
+
+
+ {iconCandidates.map((candidate) => ( + + ))} + {PROJECT_TABLER_ICONS.map((tablerIcon) => { + const label = t(tablerIcon.labelKey); + return ( + + ); + })} + +
+
+ {error &&

{error}

} +
+ ); +} diff --git a/ui/goose2/src/features/projects/ui/ProjectsView.tsx b/ui/goose2/src/features/projects/ui/ProjectsView.tsx index 3f48c5aa..debe5e8f 100644 --- a/ui/goose2/src/features/projects/ui/ProjectsView.tsx +++ b/ui/goose2/src/features/projects/ui/ProjectsView.tsx @@ -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( + undefined, + ); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [deletingProject, setDeletingProject] = useState( @@ -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" >
-

{project.name}

diff --git a/ui/goose2/src/features/projects/ui/__tests__/CreateProjectDialog.test.tsx b/ui/goose2/src/features/projects/ui/__tests__/CreateProjectDialog.test.tsx index b414add6..17b72059 100644 --- a/ui/goose2/src/features/projects/ui/__tests__/CreateProjectDialog.test.tsx +++ b/ui/goose2/src/features/projects/ui/__tests__/CreateProjectDialog.test.tsx @@ -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 = {}) { +function makeEditingProject(overrides: Partial = {}): 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( { />, ); - 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(); + + 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( { />, ); - // 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( { />, ); - // 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 () => { diff --git a/ui/goose2/src/features/settings/ui/SettingsModal.tsx b/ui/goose2/src/features/settings/ui/SettingsModal.tsx index 0544f0f6..85e0c341 100644 --- a/ui/goose2/src/features/settings/ui/SettingsModal.tsx +++ b/ui/goose2/src/features/settings/ui/SettingsModal.tsx @@ -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" >
- {project.name} diff --git a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx index 41b8bdc8..f4c56ea2 100644 --- a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx +++ b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx @@ -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(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(null); - const homeRef = useRef(null); - const navItemRefs = useRef>({}); - - 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 (
-
+
@@ -329,217 +297,195 @@ export function Sidebar({
- + +
+ +
+
); diff --git a/ui/goose2/src/features/sidebar/ui/SidebarChatRow.tsx b/ui/goose2/src/features/sidebar/ui/SidebarChatRow.tsx index 5e4b0f6f..78a4de66 100644 --- a/ui/goose2/src/features/sidebar/ui/SidebarChatRow.tsx +++ b/ui/goose2/src/features/sidebar/ui/SidebarChatRow.tsx @@ -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) => 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(null); - const rowRef = useRef(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 }} />
@@ -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
{ 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} > + {showActivityIndicator && nested && ( + + )} @@ -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", )} > - + diff --git a/ui/goose2/src/features/sidebar/ui/SidebarItemMenu.tsx b/ui/goose2/src/features/sidebar/ui/SidebarItemMenu.tsx index cfae89e5..393b23ed 100644 --- a/ui/goose2/src/features/sidebar/ui/SidebarItemMenu.tsx +++ b/ui/goose2/src/features/sidebar/ui/SidebarItemMenu.tsx @@ -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 ( - + diff --git a/ui/goose2/src/features/sidebar/ui/SidebarNavItem.tsx b/ui/goose2/src/features/sidebar/ui/SidebarNavItem.tsx new file mode 100644 index 00000000..70b70016 --- /dev/null +++ b/ui/goose2/src/features/sidebar/ui/SidebarNavItem.tsx @@ -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 ( + + ); +} diff --git a/ui/goose2/src/features/sidebar/ui/SidebarProjectList.tsx b/ui/goose2/src/features/sidebar/ui/SidebarProjectList.tsx new file mode 100644 index 00000000..fbb49734 --- /dev/null +++ b/ui/goose2/src/features/sidebar/ui/SidebarProjectList.tsx @@ -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; + expandedProjects: Record; + 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(null); + const [dropTargetProjectId, setDropTargetProjectId] = useState( + null, + ); + + if (collapsed) { + return ( +
+ {projects.map((project) => ( + + ))} +
+ ); + } + + return ( +
+ {projects.map((project) => ( + // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop reorder target +
{ + 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 && ( +
+ )} + +
+ ))} +
+ ); +} diff --git a/ui/goose2/src/features/sidebar/ui/SidebarProjectSection.tsx b/ui/goose2/src/features/sidebar/ui/SidebarProjectSection.tsx new file mode 100644 index 00000000..3036f067 --- /dev/null +++ b/ui/goose2/src/features/sidebar/ui/SidebarProjectSection.tsx @@ -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) => { + if (e.dataTransfer.types.includes("text/x-session-id")) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragOver(true); + } + }, []); + + const handleDragLeave = useCallback((e: DragEvent) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setDragOver(false); + } + }, []); + + const handleDrop = useCallback( + (e: 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 +
+
+ + onEditProject?.(project.id)} + onArchive={() => onArchiveProject?.(project.id)} + /> + + + {dragOver && ( +
+ )} +
+ + {isExpanded && ( +
+ {visibleChats.map((session) => { + const isActive = activeSessionId === session.id; + return ( + + ); + })} + {projectChats.length > MAX_VISIBLE_CHATS && ( + + )} +
+ )} +
+ ); +} diff --git a/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx b/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx index be880b98..39d5517a 100644 --- a/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx +++ b/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx @@ -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) => 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) => 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 -
-
- - onEditProject?.(project.id)} - onArchive={() => onArchiveProject?.(project.id)} - /> - - - {dragOver && ( -
- )} -
- - {isExpanded && ( -
- {visibleChats.map((session) => { - const isActive = activeSessionId === session.id; - return ( - - ); - })} - {projectChats.length > MAX_VISIBLE_CHATS && ( - - )} -
- )} -
- ); } 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(null); - const [dropTargetProjectId, setDropTargetProjectId] = useState( - 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 (
- + {t("actions.newProject")} )}
- {collapsed ? ( -
- {projects.map((project) => ( - - ))} -
- ) : ( -
- {projects.map((project) => ( - // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop reorder target -
{ - // 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 && ( -
- )} - -
- ))} -
- )} + - {/* --- 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 */} -
-
- - {t("sections.recents")} - - {!collapsed && onNewChat && ( - - )} - - {recentsDragOver && ( -
- )} -
- - {projectSessions.standalone.length > 0 && - (collapsed ? ( -
- {projectSessions.standalone.map((session) => ( - - ))} -
- ) : ( -
- {projectSessions.standalone.map((session) => { - const isActive = activeSessionId === session.id; - return ( - - ); - })} -
- ))} -
+
); } diff --git a/ui/goose2/src/features/sidebar/ui/SidebarRecentsSection.tsx b/ui/goose2/src/features/sidebar/ui/SidebarRecentsSection.tsx new file mode 100644 index 00000000..e965d890 --- /dev/null +++ b/ui/goose2/src/features/sidebar/ui/SidebarRecentsSection.tsx @@ -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) => { + 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) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setRecentsDragOver(false); + } + }, []); + + const handleRecentsDrop = useCallback( + (e: DragEvent) => { + 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 +
+
+ + {t("sections.recents")} + + {!collapsed && onNewChat && ( + + )} + + {recentsDragOver && ( +
+ )} +
+ + {sessions.length > 0 && + (collapsed ? ( +
+ {sessions.map((session) => ( + + ))} +
+ ) : ( +
+ {sessions.map((session) => { + const isActive = activeSessionId === session.id; + return ( + + ); + })} +
+ ))} +
+ ); +} diff --git a/ui/goose2/src/features/sidebar/ui/useSidebarHighlight.ts b/ui/goose2/src/features/sidebar/ui/useSidebarHighlight.ts deleted file mode 100644 index 4f8a67b9..00000000 --- a/ui/goose2/src/features/sidebar/ui/useSidebarHighlight.ts +++ /dev/null @@ -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, -) { - const [hoveredRect, setHoveredRect] = useState(null); - const [activeRect, setActiveRect] = useState(null); - const [isHovering, setIsHovering] = useState(false); - const [isResizing, setIsResizing] = useState(false); - const activeElRef = useRef(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) => { - 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, - }; -} diff --git a/ui/goose2/src/features/status/ui/StatusBar.tsx b/ui/goose2/src/features/status/ui/StatusBar.tsx deleted file mode 100644 index 08dcd77c..00000000 --- a/ui/goose2/src/features/status/ui/StatusBar.tsx +++ /dev/null @@ -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 ( -
-
-
- - - {modelName ?? t("noModel")} - -
-
- -
- {sessionId && ( - - )} - {tokenCount > 0 && ( - - {t("tokens", { - count: tokenCount, - displayCount: formatNumber(tokenCount), - })} - - )} -
-
- ); -} diff --git a/ui/goose2/src/shared/i18n/constants.ts b/ui/goose2/src/shared/i18n/constants.ts index 0ae42867..fd41c205 100644 --- a/ui/goose2/src/shared/i18n/constants.ts +++ b/ui/goose2/src/shared/i18n/constants.ts @@ -10,7 +10,6 @@ export const TRANSLATION_NAMESPACES = [ "settings", "skills", "sidebar", - "status", "sessions", ] as const; export const LOCALE_STORAGE_KEY = "goose:locale"; diff --git a/ui/goose2/src/shared/i18n/i18n.ts b/ui/goose2/src/shared/i18n/i18n.ts index 1145723f..85430a44 100644 --- a/ui/goose2/src/shared/i18n/i18n.ts +++ b/ui/goose2/src/shared/i18n/i18n.ts @@ -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< diff --git a/ui/goose2/src/shared/i18n/locales/en/projects.json b/ui/goose2/src/shared/i18n/locales/en/projects.json index 7224f667..a63f7abf 100644 --- a/ui/goose2/src/shared/i18n/locales/en/projects.json +++ b/ui/goose2/src/shared/i18n/locales/en/projects.json @@ -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": { diff --git a/ui/goose2/src/shared/i18n/locales/en/sidebar.json b/ui/goose2/src/shared/i18n/locales/en/sidebar.json index 114b0ce6..b1e0bda5 100644 --- a/ui/goose2/src/shared/i18n/locales/en/sidebar.json +++ b/ui/goose2/src/shared/i18n/locales/en/sidebar.json @@ -23,7 +23,7 @@ }, "sections": { "projects": "Projects", - "recents": "Recents" + "recents": "Chats" }, "showLess": "Show less", "viewAllChats_one": "View all {{displayCount}} chat", diff --git a/ui/goose2/src/shared/i18n/locales/en/status.json b/ui/goose2/src/shared/i18n/locales/en/status.json deleted file mode 100644 index 489223ff..00000000 --- a/ui/goose2/src/shared/i18n/locales/en/status.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "noModel": "No model", - "sessionTitle": "Session: {{id}}", - "tokens_one": "{{displayCount}} token", - "tokens_other": "{{displayCount}} tokens" -} diff --git a/ui/goose2/src/shared/i18n/locales/es/projects.json b/ui/goose2/src/shared/i18n/locales/es/projects.json index 36fc75f5..9ac493a5 100644 --- a/ui/goose2/src/shared/i18n/locales/es/projects.json +++ b/ui/goose2/src/shared/i18n/locales/es/projects.json @@ -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": { diff --git a/ui/goose2/src/shared/i18n/locales/es/sidebar.json b/ui/goose2/src/shared/i18n/locales/es/sidebar.json index 9bd9008e..ccf5f9d0 100644 --- a/ui/goose2/src/shared/i18n/locales/es/sidebar.json +++ b/ui/goose2/src/shared/i18n/locales/es/sidebar.json @@ -23,7 +23,7 @@ }, "sections": { "projects": "Proyectos", - "recents": "Recientes" + "recents": "Chats" }, "showLess": "Mostrar menos", "viewAllChats_one": "Ver {{displayCount}} chat", diff --git a/ui/goose2/src/shared/i18n/locales/es/status.json b/ui/goose2/src/shared/i18n/locales/es/status.json deleted file mode 100644 index 02bfca09..00000000 --- a/ui/goose2/src/shared/i18n/locales/es/status.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "noModel": "Sin modelo", - "sessionTitle": "Sesión: {{id}}", - "tokens_one": "{{displayCount}} token", - "tokens_other": "{{displayCount}} tokens" -} diff --git a/ui/goose2/src/shared/styles/globals.css b/ui/goose2/src/shared/styles/globals.css index 868046a4..100912a2 100644 --- a/ui/goose2/src/shared/styles/globals.css +++ b/ui/goose2/src/shared/styles/globals.css @@ -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; } diff --git a/ui/goose2/src/shared/ui/button.tsx b/ui/goose2/src/shared/ui/button.tsx index 0e6d7548..471af3d4 100644 --- a/ui/goose2/src/shared/ui/button.tsx +++ b/ui/goose2/src/shared/ui/button.tsx @@ -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: { diff --git a/ui/goose2/tests/e2e/drafts.spec.ts b/ui/goose2/tests/e2e/drafts.spec.ts index 22da7d2b..57e8ab29 100644 --- a/ui/goose2/tests/e2e/drafts.spec.ts +++ b/ui/goose2/tests/e2e/drafts.spec.ts @@ -1,5 +1,20 @@ import { test, expect, waitForHome } from "./fixtures/tauri-mock"; +async function clickNewChatInProject( + page: Parameters[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(