polish sidebar navigation and project icons (#8896)

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-04-29 11:14:27 -07:00
committed by GitHub
parent b4c08793a9
commit 5fcdf31ad5
42 changed files with 2047 additions and 1166 deletions
+1
View File
@@ -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)
);
}
}
+2
View File
@@ -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,
+5 -44
View File
@@ -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 -18
View File
@@ -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],
+1
View File
@@ -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}
+197 -251
View File
@@ -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>
);
}
-1
View File
@@ -10,7 +10,6 @@ export const TRANSLATION_NAMESPACES = [
"settings",
"skills",
"sidebar",
"status",
"sessions",
] as const;
export const LOCALE_STORAGE_KEY = "goose:locale";
-2
View File
@@ -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"
}
+2
View File
@@ -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;
}
+4 -4
View File
@@ -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: {
+26 -9
View File
@@ -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(