feat: add projects feature to sidebar (#15)

* feat: add projects backend and frontend foundation

- Rust backend: CRUD for projects stored at ~/.goose/projects/{slug}/project.json
- Frontend: API layer, Zustand store, CreateProjectDialog, ProjectsView
- Project model: name, description, prompt, icon, color, preferredProvider/Model, workingDir, useWorktrees
- Tab type extended with optional projectId

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: redesign sidebar with projects + chats hierarchy

Replace the "Recent" section with a goose-spence-style layout:
- Projects section with collapsible groups and nested chats
- Standalone chats section for sessions not in any project
- Remove "Projects" from top nav items (now in sidebar body)
- Extract SidebarProjectsSection to stay under file size limit
- Wire AppShell to pass project data and callbacks to Sidebar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add [+] button to PROJECTS header in sidebar

- PROJECTS section always visible (even with no projects)
- [+] button on PROJECTS header opens CreateProjectDialog
- Wire onCreateProject through Sidebar → AppShell
- CreateProjectDialog rendered in AppShell, refreshes project list on create

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: simplify project dialog and use color dots

- Remove Description field (Prompt serves as description)
- Hide Icon field (always uses default)
- Remove Preferred Model field
- Replace Provider text input with ACP provider dropdown
- Add Browse button for Working Directory (with Tauri dialog fallback)
- Show color dots instead of emoji icons in sidebar and project cards
- Show prompt preview in project cards instead of description

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: always-visible create project button and 3-dot menus

- Make [+] create project button always visible (not hover-only)
- Match button size/alignment with "New chat" buttons
- Add 3-dot menus (Edit/Archive) to project headers
- Add 3-dot menus (Edit/Archive) to chat items (nested + standalone)
- Menus appear on hover, close on outside click

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: fix alignment, edit project dialog, browse button, menu behavior

- Align [+] create project button with project row [+] buttons (px-1.5)
- Wire "Edit" on project menu to open CreateProjectDialog with project data
- Chat menus now only show "Archive" (removed Edit)
- Install tauri-plugin-dialog for native folder picker (Browse button)
- Add dialog:allow-open to Tauri capabilities
- Fix 3-dot menus staying visible: force opacity when dropdown is open
- Remove unused onEditChat prop

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: always-visible 3-dot menus and tighter header alignment

- Make 3-dot menus always visible at 60% opacity (not hover-dependent)
- Remove mr-1 from PROJECTS header [+] button to reduce right gap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: proper hover behavior for 3-dot menus and header alignment

- Restore hover show/hide for 3-dot menus with open-state override
- When dropdown is open, force trigger button visible (opacity-100)
- When closed, standard group-hover behavior applies
- Fix header [+] alignment: remove container px, use pl on text + mr-1 on button
- Both PROJECTS header [+] and project row [+] now have identical right offset

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: auto-expand project when creating a new chat in it

When the active tab changes to one inside a project, automatically
expand that project in the sidebar so the new chat is visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve pre-push lint issues

- cargo fmt: fix Rust formatting in projects.rs
- clippy: allow too_many_arguments on create_project and update_project
- biome: replace non-null assertions with local variable in Sidebar.tsx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: wire archive actions and add project ordering

- Archive project: deletes from filesystem and refreshes sidebar
- Archive chat: closes the tab and cleans up session
- Add `order` field to ProjectInfo (auto-assigned on create, preserved on update)
- Projects sorted by order instead of alphabetically
- Backwards-compatible: existing projects without order default to 0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: consistent hover show/hide for [+] and 3-dot buttons

Use invisible/visible + opacity combo for both ItemMenu trigger and
[+] new chat button. The invisible class provides reliable show/hide
while opacity handles the visual transition. Both now appear and
disappear in sync on group hover.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: soft-delete archive for projects with restore in settings

- Add archivedAt field to ProjectInfo (null = active, timestamp = archived)
- list_projects filters out archived; new list_archived_projects returns only archived
- archive_project sets archivedAt timestamp; restore_project clears it
- Archive action in sidebar now soft-deletes instead of hard-deleting
- Settings modal shows "Archived Projects" section with Restore and Delete buttons
- Delete permanently option available for true removal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: move archived projects to dedicated Projects tab in settings

Add a "Projects" tab to the settings modal with the FolderKanban icon.
Archived projects section moved from General to its own tab for
cleaner organization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add confirmation dialog for permanent project deletion

Clicking "Delete" on an archived project in Settings now shows a
confirmation dialog before permanently removing the project.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-03-30 18:10:39 -07:00
committed by GitHub
parent e2fa4e8619
commit a1893bb184
21 changed files with 2373 additions and 162 deletions
+1
View File
@@ -28,6 +28,7 @@
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
+10
View File
@@ -29,6 +29,9 @@ importers:
'@tauri-apps/api':
specifier: ^2
version: 2.10.1
'@tauri-apps/plugin-dialog':
specifier: ^2.6.0
version: 2.6.0
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -1053,6 +1056,9 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-dialog@2.6.0':
resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@@ -3082,6 +3088,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
'@tauri-apps/plugin-dialog@2.6.0':
dependencies:
'@tauri-apps/api': 2.10.1
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.0
+65
View File
@@ -1555,6 +1555,7 @@ dependencies = [
"serde_yaml",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-window-state",
"tokio",
@@ -3360,6 +3361,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -4303,6 +4328,46 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
dependencies = [
"anyhow",
"dunce",
"glob",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 0.9.12+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.3"
+1
View File
@@ -18,6 +18,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tauri-plugin-window-state = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -11,6 +11,7 @@
"core:window:allow-close",
"opener:default",
"window-state:allow-restore-state",
"window-state:allow-save-window-state"
"window-state:allow-save-window-state",
"dialog:allow-open"
]
}
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:window:allow-show","core:window:allow-close","opener:default","window-state:allow-restore-state","window-state:allow-save-window-state"]}}
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:window:allow-show","core:window:allow-close","opener:default","window-state:allow-restore-state","window-state:allow-save-window-state","dialog:allow-open"]}}
@@ -2312,6 +2312,72 @@
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope."
},
{
"description": "Enables the confirm command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope."
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope."
},
{
"description": "Denies the confirm command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope."
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
@@ -2312,6 +2312,72 @@
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope."
},
{
"description": "Enables the confirm command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope."
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope."
},
{
"description": "Denies the confirm command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope."
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
+1
View File
@@ -1,6 +1,7 @@
pub mod acp;
pub mod agents;
pub mod chat;
pub mod projects;
pub mod sessions;
pub mod sidecar;
pub mod skills;
@@ -0,0 +1,378 @@
use std::fs;
use std::path::PathBuf;
fn projects_dir() -> Result<PathBuf, String> {
let home = dirs::home_dir().ok_or("Could not determine home directory")?;
Ok(home.join(".goose").join("projects"))
}
fn generate_id() -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let mut hasher = DefaultHasher::new();
nanos.hash(&mut hasher);
std::process::id().hash(&mut hasher);
let h1 = hasher.finish();
// Hash again with a different seed for more bits
h1.hash(&mut hasher);
let h2 = hasher.finish();
format!(
"{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
(h1 >> 32) as u32,
(h1 >> 16) as u16,
h1 as u16,
(h2 >> 48) as u16,
h2 & 0xffffffffffff
)
}
fn now_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
millis.to_string()
}
fn slugify(name: &str) -> String {
let slug: String = name
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-");
if slug.is_empty() {
"project".to_string()
} else {
slug
}
}
/// Scan all project directories and find the one whose project.json has the given id.
/// Returns (dir_path, ProjectInfo).
fn find_project_by_id(id: &str) -> Result<(PathBuf, ProjectInfo), String> {
let base = projects_dir()?;
if !base.exists() {
return Err(format!("Project with id \"{}\" not found", id));
}
let entries = fs::read_dir(&base).map_err(|e| format!("Failed to read projects dir: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let project_json = path.join("project.json");
if !project_json.exists() {
continue;
}
let raw = match fs::read_to_string(&project_json) {
Ok(r) => r,
Err(_) => continue,
};
let info: ProjectInfo = match serde_json::from_str(&raw) {
Ok(i) => i,
Err(_) => continue,
};
if info.id == id {
return Ok((path, info));
}
}
Err(format!("Project with id \"{}\" not found", id))
}
#[derive(serde::Serialize, serde::Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ProjectInfo {
pub id: String,
pub name: String,
pub description: String,
pub prompt: String,
pub icon: String,
pub color: String,
pub preferred_provider: Option<String>,
pub preferred_model: Option<String>,
pub working_dir: Option<String>,
pub use_worktrees: bool,
#[serde(default)]
pub order: i32,
#[serde(default)]
pub archived_at: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[tauri::command]
pub fn list_projects() -> Result<Vec<ProjectInfo>, String> {
let dir = projects_dir()?;
if !dir.exists() {
return Ok(vec![]);
}
let mut projects = Vec::new();
let entries = fs::read_dir(&dir).map_err(|e| format!("Failed to read projects dir: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let project_json = path.join("project.json");
if !project_json.exists() {
continue;
}
let raw = match fs::read_to_string(&project_json) {
Ok(r) => r,
Err(_) => continue,
};
let info: ProjectInfo = match serde_json::from_str(&raw) {
Ok(i) => i,
Err(_) => continue,
};
projects.push(info);
}
projects.sort_by_key(|p| p.order);
projects.retain(|p| p.archived_at.is_none());
Ok(projects)
}
#[tauri::command]
pub fn list_archived_projects() -> Result<Vec<ProjectInfo>, String> {
let dir = projects_dir()?;
if !dir.exists() {
return Ok(vec![]);
}
let mut projects = Vec::new();
let entries = fs::read_dir(&dir).map_err(|e| format!("Failed to read projects dir: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let project_json = path.join("project.json");
if !project_json.exists() {
continue;
}
let raw = fs::read_to_string(&project_json).unwrap_or_default();
if let Ok(info) = serde_json::from_str::<ProjectInfo>(&raw) {
if info.archived_at.is_some() {
projects.push(info);
}
}
}
projects.sort_by_key(|p| p.order);
Ok(projects)
}
#[allow(clippy::too_many_arguments)]
#[tauri::command]
pub fn create_project(
name: String,
description: String,
prompt: String,
icon: String,
color: String,
preferred_provider: Option<String>,
preferred_model: Option<String>,
working_dir: Option<String>,
use_worktrees: bool,
) -> Result<ProjectInfo, String> {
if name.trim().is_empty() {
return Err("Project name must not be empty".to_string());
}
let base = projects_dir()?;
let slug = slugify(&name);
// Determine final directory name, avoiding collisions
let mut dir_name = slug.clone();
if base.join(&dir_name).exists() {
let mut counter = 2u32;
loop {
dir_name = format!("{}-{}", slug, counter);
if !base.join(&dir_name).exists() {
break;
}
counter += 1;
}
}
let existing_count = if base.exists() {
fs::read_dir(&base)
.map(|entries| entries.flatten().filter(|e| e.path().is_dir()).count())
.unwrap_or(0)
} else {
0
};
let dir = base.join(&dir_name);
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create project directory: {}", e))?;
let now = now_timestamp();
let info = ProjectInfo {
id: generate_id(),
name,
description,
prompt,
icon,
color,
preferred_provider,
preferred_model,
working_dir,
use_worktrees,
order: existing_count as i32,
archived_at: None,
created_at: now.clone(),
updated_at: now,
};
let project_path = dir.join("project.json");
let json = serde_json::to_string_pretty(&info)
.map_err(|e| format!("Failed to serialize project: {}", e))?;
fs::write(&project_path, json).map_err(|e| format!("Failed to write project.json: {}", e))?;
Ok(info)
}
#[allow(clippy::too_many_arguments)]
#[tauri::command]
pub fn update_project(
id: String,
name: String,
description: String,
prompt: String,
icon: String,
color: String,
preferred_provider: Option<String>,
preferred_model: Option<String>,
working_dir: Option<String>,
use_worktrees: bool,
) -> Result<ProjectInfo, String> {
if name.trim().is_empty() {
return Err("Project name must not be empty".to_string());
}
let (dir, existing) = find_project_by_id(&id)?;
let info = ProjectInfo {
id: existing.id,
name,
description,
prompt,
icon,
color,
preferred_provider,
preferred_model,
working_dir,
use_worktrees,
order: existing.order,
archived_at: existing.archived_at,
created_at: existing.created_at,
updated_at: now_timestamp(),
};
let project_path = dir.join("project.json");
let json = serde_json::to_string_pretty(&info)
.map_err(|e| format!("Failed to serialize project: {}", e))?;
fs::write(&project_path, json).map_err(|e| format!("Failed to write project.json: {}", e))?;
Ok(info)
}
#[tauri::command]
pub fn delete_project(id: String) -> Result<(), String> {
let (dir, _) = find_project_by_id(&id)?;
fs::remove_dir_all(&dir).map_err(|e| format!("Failed to delete project: {}", e))?;
Ok(())
}
#[tauri::command]
pub fn get_project(id: String) -> Result<ProjectInfo, String> {
let (_, info) = find_project_by_id(&id)?;
Ok(info)
}
#[tauri::command]
pub fn archive_project(id: String) -> Result<(), String> {
let base = projects_dir()?;
if !base.exists() {
return Err("Projects directory not found".to_string());
}
let entries = fs::read_dir(&base).map_err(|e| format!("Failed to read projects dir: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let project_json = path.join("project.json");
if !project_json.exists() {
continue;
}
let raw = fs::read_to_string(&project_json).unwrap_or_default();
if let Ok(mut info) = serde_json::from_str::<ProjectInfo>(&raw) {
if info.id == id {
info.archived_at = Some(now_timestamp());
let json = serde_json::to_string_pretty(&info)
.map_err(|e| format!("Failed to serialize: {}", e))?;
fs::write(&project_json, json).map_err(|e| format!("Failed to write: {}", e))?;
return Ok(());
}
}
}
Err(format!("Project with id \"{}\" not found", id))
}
#[tauri::command]
pub fn restore_project(id: String) -> Result<(), String> {
let base = projects_dir()?;
if !base.exists() {
return Err("Projects directory not found".to_string());
}
let entries = fs::read_dir(&base).map_err(|e| format!("Failed to read projects dir: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let project_json = path.join("project.json");
if !project_json.exists() {
continue;
}
let raw = fs::read_to_string(&project_json).unwrap_or_default();
if let Ok(mut info) = serde_json::from_str::<ProjectInfo>(&raw) {
if info.id == id {
info.archived_at = None;
let json = serde_json::to_string_pretty(&info)
.map_err(|e| format!("Failed to serialize: {}", e))?;
fs::write(&project_json, json).map_err(|e| format!("Failed to write: {}", e))?;
return Ok(());
}
}
}
Err(format!("Project with id \"{}\" not found", id))
}
+9
View File
@@ -20,6 +20,7 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(
tauri_plugin_window_state::Builder::default()
.with_state_flags(StateFlags::all() & !StateFlags::VISIBLE)
@@ -58,6 +59,14 @@ pub fn run() {
commands::skills::update_skill,
commands::skills::export_skill,
commands::skills::import_skills,
commands::projects::list_projects,
commands::projects::create_project,
commands::projects::update_project,
commands::projects::delete_project,
commands::projects::get_project,
commands::projects::list_archived_projects,
commands::projects::archive_project,
commands::projects::restore_project,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
+143 -3
View File
@@ -6,12 +6,17 @@ import { HomeScreen } from "@/features/home/ui/HomeScreen";
import { ChatView } from "@/features/chat/ui/ChatView";
import { SkillsView } from "@/features/skills/ui/SkillsView";
import { AgentsView } from "@/features/agents/ui/AgentsView";
import { ProjectsView } from "@/features/projects/ui/ProjectsView";
import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog";
import { archiveProject } from "@/features/projects/api/projects";
import type { ProjectInfo } from "@/features/projects/api/projects";
import { SettingsModal } from "@/features/settings/ui/SettingsModal";
import { useChatStore } from "@/features/chat/stores/chatStore";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { useProjectStore } from "@/features/projects/stores/projectStore";
import type { Tab } from "@/features/tabs/types";
export type AppView = "home" | "chat" | "skills" | "agents";
export type AppView = "home" | "chat" | "skills" | "agents" | "projects";
const SIDEBAR_WIDTH = 240;
const SIDEBAR_COLLAPSED_WIDTH = 48;
@@ -19,12 +24,21 @@ const SIDEBAR_COLLAPSED_WIDTH = 48;
export function AppShell({ children }: { children?: React.ReactNode }) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [createProjectOpen, setCreateProjectOpen] = useState(false);
const [editingProject, setEditingProject] = useState<ProjectInfo | null>(
null,
);
const [tabs, setTabs] = useState<Tab[]>([]);
const [activeTabId, setActiveTabId] = useState<string | null>(null);
const [activeView, setActiveView] = useState<AppView>("home");
const chatStore = useChatStore();
const agentStore = useAgentStore();
const projectStore = useProjectStore();
useEffect(() => {
projectStore.fetchProjects();
}, [projectStore.fetchProjects]);
const isHome = activeTabId === null && activeView === "home";
const activeTab = tabs.find((t) => t.id === activeTabId) ?? null;
@@ -38,12 +52,18 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
: ("disconnected" as const);
const createNewTab = useCallback(
(title = "New Chat") => {
(title = "New Chat", project?: ProjectInfo) => {
const id = crypto.randomUUID();
const sessionId = crypto.randomUUID();
const agentId = agentStore.activeAgentId ?? undefined;
const tab: Tab = { id, title, sessionId, agentId };
const tab: Tab = {
id,
title,
sessionId,
agentId,
projectId: project?.id,
};
setTabs((prev) => [...prev, tab]);
setActiveTabId(id);
setActiveView("chat");
@@ -51,11 +71,92 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
// Set the active session in chatStore
chatStore.setActiveSession(sessionId);
// Inject project context as a system message if starting from a project
if (project?.prompt) {
chatStore.addMessage(sessionId, {
id: crypto.randomUUID(),
role: "system",
created: Date.now(),
content: [
{
type: "systemNotification",
notificationType: "info",
text: `Project: ${project.name}\n\n${project.prompt}`,
},
],
metadata: { userVisible: true, agentVisible: true },
});
}
return tab;
},
[chatStore, agentStore.activeAgentId],
);
const handleStartChatFromProject = useCallback(
(project: ProjectInfo) => {
createNewTab(project.name, project);
},
[createNewTab],
);
const handleNewChatInProject = useCallback(
(projectId: string) => {
const project = projectStore.projects.find((p) => p.id === projectId);
if (project) {
createNewTab(project.name, project);
}
},
[createNewTab, projectStore.projects],
);
const handleArchiveProject = useCallback(
async (projectId: string) => {
try {
await archiveProject(projectId);
projectStore.fetchProjects();
} catch {
// best-effort
}
},
[projectStore.fetchProjects],
);
const handleArchiveChat = useCallback(
(tabId: string) => {
setTabs((prev) => {
const closingTab = prev.find((t) => t.id === tabId);
const next = prev.filter((t) => t.id !== tabId);
if (closingTab) {
chatStore.cleanupSession(closingTab.sessionId);
}
if (activeTabId === tabId) {
const nextTab = next[0] ?? null;
setActiveTabId(nextTab?.id ?? null);
if (nextTab) {
chatStore.setActiveSession(nextTab.sessionId);
setActiveView("chat");
} else {
setActiveView("home");
}
}
return next;
});
},
[activeTabId, chatStore],
);
const handleEditProject = useCallback(
(projectId: string) => {
const project = projectStore.projects.find((p) => p.id === projectId);
if (project) {
setEditingProject(project);
setCreateProjectOpen(true);
}
},
[projectStore.projects],
);
const handleNewTab = useCallback(() => {
createNewTab();
}, [createNewTab]);
@@ -138,6 +239,8 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
return <SkillsView />;
case "agents":
return <AgentsView />;
case "projects":
return <ProjectsView onStartChat={handleStartChatFromProject} />;
case "chat":
return activeTab ? (
<ChatView sessionId={activeTab.sessionId} />
@@ -184,7 +287,16 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
onSettingsClick={() => setSettingsOpen(true)}
onNavigate={handleNavigate}
onNewChat={handleNewTab}
onNewChatInProject={handleNewChatInProject}
onCreateProject={() => setCreateProjectOpen(true)}
onEditProject={handleEditProject}
onArchiveProject={handleArchiveProject}
onArchiveChat={handleArchiveChat}
onSelectTab={handleTabSelect}
activeView={activeView}
activeTabId={activeTabId}
projects={projectStore.projects}
tabs={tabs}
className="h-full shadow-xl rounded-xl"
/>
</div>
@@ -210,6 +322,34 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
{/* Settings modal */}
{settingsOpen && <SettingsModal onClose={() => setSettingsOpen(false)} />}
{/* Create project dialog */}
<CreateProjectDialog
isOpen={createProjectOpen}
onClose={() => {
setCreateProjectOpen(false);
setEditingProject(null);
}}
onCreated={() => {
projectStore.fetchProjects();
}}
editingProject={
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,
workingDir: editingProject.workingDir,
useWorktrees: editingProject.useWorktrees,
}
: undefined
}
/>
</div>
);
}
@@ -0,0 +1,92 @@
import { invoke } from "@tauri-apps/api/core";
export interface ProjectInfo {
id: string;
name: string;
description: string;
prompt: string;
icon: string;
color: string;
preferredProvider: string | null;
preferredModel: string | null;
workingDir: string | null;
useWorktrees: boolean;
order: number;
archivedAt: string | null;
createdAt: string;
updatedAt: string;
}
export async function listProjects(): Promise<ProjectInfo[]> {
return invoke("list_projects");
}
export async function createProject(
name: string,
description: string,
prompt: string,
icon: string,
color: string,
preferredProvider: string | null,
preferredModel: string | null,
workingDir: string | null,
useWorktrees: boolean,
): Promise<ProjectInfo> {
return invoke("create_project", {
name,
description,
prompt,
icon,
color,
preferredProvider,
preferredModel,
workingDir,
useWorktrees,
});
}
export async function updateProject(
id: string,
name: string,
description: string,
prompt: string,
icon: string,
color: string,
preferredProvider: string | null,
preferredModel: string | null,
workingDir: string | null,
useWorktrees: boolean,
): Promise<ProjectInfo> {
return invoke("update_project", {
id,
name,
description,
prompt,
icon,
color,
preferredProvider,
preferredModel,
workingDir,
useWorktrees,
});
}
export async function deleteProject(id: string): Promise<void> {
return invoke("delete_project", { id });
}
export async function getProject(id: string): Promise<ProjectInfo> {
return invoke("get_project", { id });
}
export async function listArchivedProjects(): Promise<ProjectInfo[]> {
return invoke("list_archived_projects");
}
export async function archiveProject(id: string): Promise<void> {
return invoke("archive_project", { id });
}
export async function restoreProject(id: string): Promise<void> {
return invoke("restore_project", { id });
}
@@ -0,0 +1,131 @@
import { create } from "zustand";
import {
listProjects,
createProject,
updateProject,
deleteProject,
type ProjectInfo,
} from "../api/projects";
interface ProjectState {
projects: ProjectInfo[];
loading: boolean;
activeProjectId: string | null;
// Actions
fetchProjects: () => Promise<void>;
addProject: (
name: string,
description: string,
prompt: string,
icon: string,
color: string,
preferredProvider: string | null,
preferredModel: string | null,
workingDir: string | null,
useWorktrees: boolean,
) => Promise<ProjectInfo>;
editProject: (
id: string,
name: string,
description: string,
prompt: string,
icon: string,
color: string,
preferredProvider: string | null,
preferredModel: string | null,
workingDir: string | null,
useWorktrees: boolean,
) => Promise<ProjectInfo>;
removeProject: (id: string) => Promise<void>;
setActiveProject: (id: string | null) => void;
getActiveProject: () => ProjectInfo | null;
}
export const useProjectStore = create<ProjectState>((set, get) => ({
projects: [],
loading: false,
activeProjectId: null,
fetchProjects: async () => {
set({ loading: true });
try {
const projects = await listProjects();
set({ projects, loading: false });
} catch {
set({ projects: [], loading: false });
}
},
addProject: async (
name,
description,
prompt,
icon,
color,
preferredProvider,
preferredModel,
workingDir,
useWorktrees,
) => {
const project = await createProject(
name,
description,
prompt,
icon,
color,
preferredProvider,
preferredModel,
workingDir,
useWorktrees,
);
set((state) => ({ projects: [...state.projects, project] }));
return project;
},
editProject: async (
id,
name,
description,
prompt,
icon,
color,
preferredProvider,
preferredModel,
workingDir,
useWorktrees,
) => {
const project = await updateProject(
id,
name,
description,
prompt,
icon,
color,
preferredProvider,
preferredModel,
workingDir,
useWorktrees,
);
set((state) => ({
projects: state.projects.map((p) => (p.id === id ? project : p)),
}));
return project;
},
removeProject: async (id) => {
await deleteProject(id);
set((state) => ({
projects: state.projects.filter((p) => p.id !== id),
activeProjectId:
state.activeProjectId === id ? null : state.activeProjectId,
}));
},
setActiveProject: (id) => set({ activeProjectId: id }),
getActiveProject: () => {
const { projects, activeProjectId } = get();
return projects.find((p) => p.id === activeProjectId) ?? null;
},
}));
@@ -0,0 +1,359 @@
import { useState, useEffect } from "react";
import { X, FolderOpen } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { createProject, updateProject } from "../api/projects";
import { discoverAcpProviders, type AcpProvider } from "@/shared/api/acp";
const COLOR_OPTIONS = [
"#64748b",
"#ef4444",
"#f97316",
"#f59e0b",
"#22c55e",
"#10b981",
"#14b8a6",
"#06b6d4",
"#3b82f6",
"#6366f1",
"#8b5cf6",
"#a855f7",
"#ec4899",
"#f43f5e",
];
interface CreateProjectDialogProps {
isOpen: boolean;
onClose: () => void;
onCreated: () => void;
editingProject?: {
id: string;
name: string;
description: string;
prompt: string;
icon: string;
color: string;
preferredProvider: string | null;
preferredModel: string | null;
workingDir: string | null;
useWorktrees: boolean;
};
}
export function CreateProjectDialog({
isOpen,
onClose,
onCreated,
editingProject,
}: CreateProjectDialogProps) {
const [name, setName] = useState("");
const [prompt, setPrompt] = useState("");
const icon = "\u{1F4C1}";
const [color, setColor] = useState(COLOR_OPTIONS[0]);
const [preferredProvider, setPreferredProvider] = useState<string | null>(
null,
);
const preferredModel: string | null = null;
const [workingDir, setWorkingDir] = useState<string | null>(null);
const [useWorktrees, setUseWorktrees] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [acpProviders, setAcpProviders] = useState<AcpProvider[]>([]);
const isEditing = !!editingProject;
useEffect(() => {
discoverAcpProviders()
.then(setAcpProviders)
.catch(() => setAcpProviders([]));
}, []);
const handleBrowseFolder = async () => {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
directory: true,
multiple: false,
title: "Select Working Directory",
});
if (selected && typeof selected === "string") {
setWorkingDir(selected);
}
} catch {
// Dialog plugin not available — user can type manually
}
};
// Pre-fill fields when editing, reset to defaults for new
useEffect(() => {
if (isOpen && editingProject) {
setName(editingProject.name);
setPrompt(editingProject.prompt);
setColor(editingProject.color);
setPreferredProvider(editingProject.preferredProvider ?? null);
setWorkingDir(editingProject.workingDir ?? null);
setUseWorktrees(editingProject.useWorktrees);
setError(null);
} else if (isOpen) {
setName("");
setPrompt("");
setColor(COLOR_OPTIONS[0]);
setPreferredProvider(null);
setWorkingDir(null);
setUseWorktrees(false);
setError(null);
}
}, [isOpen, editingProject]);
const canSave = name.trim().length > 0 && !saving;
const handleClose = () => {
setName("");
setPrompt("");
setColor(COLOR_OPTIONS[0]);
setPreferredProvider(null);
setWorkingDir(null);
setUseWorktrees(false);
setError(null);
onClose();
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!canSave) return;
setSaving(true);
setError(null);
try {
if (isEditing) {
await updateProject(
editingProject.id,
name.trim(),
"",
prompt,
icon,
color,
preferredProvider || null,
preferredModel,
workingDir?.trim() || null,
useWorktrees,
);
} else {
await createProject(
name.trim(),
"",
prompt,
icon,
color,
preferredProvider || null,
preferredModel,
workingDir?.trim() || null,
useWorktrees,
);
}
onCreated();
onClose();
} catch (err) {
setError(String(err));
} finally {
setSaving(false);
}
};
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label={isEditing ? "Edit Project" : "New Project"}
className="fixed inset-0 z-50 flex items-center justify-center"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 motion-safe:animate-in motion-safe:fade-in"
onClick={handleClose}
aria-hidden="true"
/>
{/* Panel */}
<div
className={cn(
"relative z-10 w-full max-w-lg rounded-xl border border-border bg-background shadow-xl",
"max-h-[85vh] flex flex-col",
"motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-95",
)}
>
{/* Header */}
<div className="shrink-0 flex items-center justify-between border-b border-border px-5 py-4">
<h2 className="text-sm font-semibold">
{isEditing ? "Edit Project" : "New Project"}
</h2>
<button
type="button"
aria-label="Close"
onClick={handleClose}
className="rounded-md p-1 text-foreground-secondary hover:bg-background-secondary transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Scrollable content */}
<form
id="project-form"
onSubmit={handleSave}
className="min-h-0 flex-1 overflow-y-auto space-y-4 p-5"
>
{/* Name */}
<label className="block space-y-1">
<span className="text-xs font-medium text-foreground-secondary">
Name <span className="text-foreground-danger">*</span>
</span>
<input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setError(null);
}}
placeholder="My Project"
className={cn(
"w-full rounded-lg border border-border bg-background-secondary px-3 py-2 text-sm",
"placeholder:text-foreground-secondary/40",
"focus:outline-none focus:ring-1 focus:ring-ring transition-colors",
)}
/>
</label>
{/* Prompt */}
<label className="block space-y-1">
<span className="text-xs font-medium text-foreground-secondary">
Prompt
</span>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={8}
placeholder="System prompt or context for agents working in this project..."
className={cn(
"w-full resize-y rounded-lg border border-border bg-background-secondary px-3 py-2 text-xs font-mono leading-relaxed",
"placeholder:text-foreground-secondary/40",
"focus:outline-none focus:ring-1 focus:ring-ring transition-colors",
)}
/>
</label>
{/* Color */}
<div className="block space-y-1">
<span className="text-xs font-medium text-foreground-secondary">
Color
</span>
<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={`Color ${c}`}
/>
))}
</div>
</div>
{/* Provider */}
<label className="block space-y-1.5">
<span className="text-xs font-medium text-foreground-secondary">
Provider
</span>
<select
value={preferredProvider ?? ""}
onChange={(e) => setPreferredProvider(e.target.value || null)}
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring"
>
<option value="">None (use default)</option>
{acpProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</select>
</label>
{/* Working Directory */}
<label className="block space-y-1.5">
<span className="text-xs font-medium text-foreground-secondary">
Working Directory
</span>
<div className="flex gap-2">
<input
type="text"
value={workingDir ?? ""}
onChange={(e) => setWorkingDir(e.target.value || null)}
placeholder="/path/to/project"
className="flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-foreground-tertiary focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring"
/>
<button
type="button"
onClick={handleBrowseFolder}
className="flex items-center gap-1.5 px-3 py-2 text-xs font-medium rounded-md border border-border hover:bg-background-tertiary transition-colors"
>
<FolderOpen className="w-3.5 h-3.5" />
Browse
</button>
</div>
</label>
{/* Use Worktrees */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={useWorktrees}
onChange={(e) => setUseWorktrees(e.target.checked)}
className="h-4 w-4 rounded border-border accent-foreground"
/>
<span className="text-xs font-medium text-foreground-secondary">
Use git worktrees for branch isolation
</span>
</label>
{/* Error */}
{error && <p className="text-xs text-foreground-danger">{error}</p>}
</form>
{/* Footer */}
<div className="shrink-0 border-t border-border px-5 py-4 flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClose}
disabled={saving}
>
Cancel
</Button>
<Button
type="submit"
form="project-form"
size="sm"
disabled={!canSave}
>
{saving
? isEditing
? "Saving..."
: "Creating..."
: isEditing
? "Save Changes"
: "Create Project"}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,348 @@
import { useState, useEffect, useCallback, useRef } from "react";
import {
MessageSquare,
Plus,
Trash2,
MoreHorizontal,
Pencil,
FolderKanban,
} from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { SearchBar } from "@/shared/ui/SearchBar";
import { CreateProjectDialog } from "./CreateProjectDialog";
import { listProjects, deleteProject, type ProjectInfo } from "../api/projects";
function ProjectCardMenu({
project,
onStartChat,
onEdit,
onDelete,
}: {
project: ProjectInfo;
onStartChat?: (project: ProjectInfo) => void;
onEdit: (project: ProjectInfo) => void;
onDelete: (project: ProjectInfo) => void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menuOpen) return;
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [menuOpen]);
return (
<div ref={menuRef} className="relative shrink-0">
<button
type="button"
aria-label={`Options for ${project.name}`}
aria-haspopup="true"
aria-expanded={menuOpen}
onClick={() => setMenuOpen((prev) => !prev)}
className={cn(
"rounded-md p-1 text-foreground-secondary/60 transition-opacity",
"hover:bg-background-secondary hover:text-foreground",
)}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
{menuOpen && (
<div
role="menu"
className="absolute right-0 top-full z-10 mt-1 w-36 rounded-lg border border-border bg-background py-1 shadow-lg"
>
{onStartChat && (
<button
type="button"
role="menuitem"
onClick={() => {
setMenuOpen(false);
onStartChat(project);
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-background-secondary transition-colors"
>
<MessageSquare className="h-3.5 w-3.5" />
Start Chat
</button>
)}
<button
type="button"
role="menuitem"
onClick={() => {
setMenuOpen(false);
onEdit(project);
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-background-secondary transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
Edit
</button>
<button
type="button"
role="menuitem"
onClick={() => {
setMenuOpen(false);
onDelete(project);
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-foreground-danger hover:bg-background-secondary transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
</div>
)}
</div>
);
}
interface ProjectsViewProps {
onStartChat?: (project: ProjectInfo) => void;
}
export function ProjectsView({ onStartChat }: ProjectsViewProps) {
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;
workingDir: string | null;
useWorktrees: boolean;
}
| undefined
>(undefined);
const [projects, setProjects] = useState<ProjectInfo[]>([]);
const [loading, setLoading] = useState(true);
const [deletingProject, setDeletingProject] = useState<ProjectInfo | null>(
null,
);
const loadProjects = useCallback(async () => {
try {
const result = await listProjects();
setProjects(result);
} catch {
// projects may not exist yet
setProjects([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadProjects();
}, [loadProjects]);
const handleDelete = (project: ProjectInfo) => {
setDeletingProject(project);
};
const handleConfirmDeleteProject = async () => {
if (!deletingProject) return;
try {
await deleteProject(deletingProject.id);
await loadProjects();
} catch {
// best-effort
}
setDeletingProject(null);
};
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,
workingDir: project.workingDir,
useWorktrees: project.useWorktrees,
});
setDialogOpen(true);
};
const handleDialogClose = () => {
setDialogOpen(false);
setEditingProject(undefined);
};
const handleNewProject = () => {
setEditingProject(undefined);
setDialogOpen(true);
};
const filtered = projects.filter(
(p) =>
p.name.toLowerCase().includes(search.toLowerCase()) ||
p.description.toLowerCase().includes(search.toLowerCase()),
);
return (
<div className="flex flex-1 flex-col h-full min-h-0">
<div className="flex-1 overflow-y-auto min-h-0">
<div className="max-w-5xl mx-auto w-full px-6 py-8 space-y-5 page-transition">
{/* Header */}
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">Projects</h1>
<p className="text-xs text-foreground-secondary">
Organize your work into focused project contexts
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleNewProject}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-border hover:bg-background-tertiary transition-colors"
>
<Plus className="w-3.5 h-3.5" />
New Project
</button>
</div>
</div>
{/* Search */}
<SearchBar
value={search}
onChange={setSearch}
placeholder="Search projects by name or description..."
/>
{/* Projects list */}
{!loading && filtered.length > 0 && (
<div className="space-y-2">
{filtered.map((project) => (
<div
key={project.id}
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 }}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{project.name}</p>
{project.prompt && (
<p className="text-xs text-foreground-secondary mt-0.5 line-clamp-2">
{project.prompt}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<ProjectCardMenu
project={project}
onStartChat={onStartChat}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</div>
</div>
))}
{/* New Project card */}
<button
type="button"
onClick={handleNewProject}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-border px-4 py-3 transition-colors hover:border-foreground-secondary/40 hover:bg-background-secondary/50"
>
<Plus className="h-4 w-4 text-foreground-secondary" />
<span className="text-sm text-foreground-secondary">
New Project
</span>
</button>
</div>
)}
{/* Empty state */}
{!loading && filtered.length === 0 && (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-foreground-secondary rounded-lg border border-dashed border-transparent">
<FolderKanban className="h-10 w-10 opacity-30" />
<div className="text-center">
<p className="text-sm font-medium">
{projects.length === 0
? "No projects yet"
: "No matching projects"}
</p>
<p className="text-xs text-foreground-secondary/60 mt-1">
{projects.length === 0
? "Create a project to organize your work."
: "Try a different search term."}
</p>
</div>
{projects.length === 0 && (
<button
type="button"
onClick={handleNewProject}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-border hover:bg-background-tertiary transition-colors mt-2"
>
<Plus className="w-3.5 h-3.5" />
New Project
</button>
)}
</div>
)}
</div>
</div>
{/* Create / Edit dialog */}
<CreateProjectDialog
isOpen={dialogOpen}
onClose={handleDialogClose}
onCreated={loadProjects}
editingProject={editingProject}
/>
{/* Delete confirmation dialog */}
{deletingProject && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setDeletingProject(null)}
aria-hidden="true"
/>
<div className="relative z-10 w-full max-w-sm rounded-xl border border-border bg-background p-6 shadow-xl space-y-4">
<h3 className="text-sm font-semibold">Delete project?</h3>
<p className="text-sm text-foreground-secondary">
Are you sure you want to delete &quot;{deletingProject.name}
&quot;? This cannot be undone.
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setDeletingProject(null)}
className="px-3 py-1.5 text-xs font-medium rounded-md hover:bg-background-secondary transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleConfirmDeleteProject}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-background-danger text-foreground-inverse shadow-sm hover:bg-background-danger/90 transition-colors"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -1,11 +1,18 @@
import { useState, useEffect } from "react";
import { cn } from "@/shared/lib/cn";
import { Palette, Settings2, Info, X } from "lucide-react";
import { Palette, Settings2, FolderKanban, Info, X } from "lucide-react";
import { AppearanceSettings } from "./AppearanceSettings";
import {
listArchivedProjects,
restoreProject,
deleteProject,
type ProjectInfo,
} from "@/features/projects/api/projects";
const NAV_ITEMS = [
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "general", label: "General", icon: Settings2 },
{ id: "projects", label: "Projects", icon: FolderKanban },
{ id: "about", label: "About", icon: Info },
] as const;
@@ -19,6 +26,11 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
const [activeSection, setActiveSection] = useState<SectionId>("appearance");
const [isLoaded, setIsLoaded] = useState(false);
const [isTransitioning, setIsTransitioning] = useState(false);
const [archivedProjects, setArchivedProjects] = useState<ProjectInfo[]>([]);
const [loadingArchived, setLoadingArchived] = useState(true);
const [deletingProject, setDeletingProject] = useState<ProjectInfo | null>(
null,
);
// Trigger entrance animations after mount
useEffect(() => {
@@ -26,6 +38,32 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
return () => clearTimeout(timer);
}, []);
// Load archived projects on mount
useEffect(() => {
listArchivedProjects()
.then(setArchivedProjects)
.catch(() => setArchivedProjects([]))
.finally(() => setLoadingArchived(false));
}, []);
const handleRestore = async (id: string) => {
try {
await restoreProject(id);
setArchivedProjects((prev) => prev.filter((p) => p.id !== id));
} catch {
// best-effort
}
};
const handleDelete = async (id: string) => {
try {
await deleteProject(id);
setArchivedProjects((prev) => prev.filter((p) => p.id !== id));
} catch {
// best-effort
}
};
// Content transition on section change
// biome-ignore lint/correctness/useExhaustiveDependencies: activeSection triggers the transition effect intentionally
useEffect(() => {
@@ -60,95 +98,176 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
<div
className={cn(
"flex w-44 flex-col border-r bg-background-secondary/50 transition-all duration-700 ease-out",
isLoaded ? "opacity-100 translate-x-0" : "opacity-0 -translate-x-6",
isLoaded ? "opacity-100 translate-x-0" : "opacity-0 -translate-x-2",
)}
>
<h2 className="text-sm font-semibold">Settings</h2>
</div>
<nav className="flex flex-col gap-1 px-2">
{NAV_ITEMS.map((item, index) => (
<button
type="button"
key={item.id}
onClick={() => setActiveSection(item.id)}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition-all duration-600 ease-out",
activeSection === item.id
? "bg-background text-foreground shadow-sm"
: "text-foreground-secondary hover:bg-background-secondary/50 hover:text-foreground duration-300",
isLoaded
? "opacity-100 translate-x-0"
: "opacity-0 translate-x-4",
)}
style={{
transitionDelay: isLoaded ? "0ms" : `${index * 40 + 300}ms`,
}}
>
<item.icon className="h-4 w-4" />
{item.label}
</button>
))}
</nav>
</div>
{/* Content */}
<div className="relative flex-1 overflow-y-auto">
<button
type="button"
onClick={onClose}
className="absolute right-4 top-4 z-10 rounded-md p-1 text-foreground-secondary transition-colors hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
<div
className={cn(
"px-6 py-4 transition-all duration-400 ease-out",
isTransitioning
? "opacity-0 translate-y-2"
: "opacity-100 translate-y-0",
)}
>
<div
className={cn(
"px-4 py-4 transition-all duration-500 ease-out",
"transition-all duration-600 ease-out",
isLoaded
? "opacity-100 translate-x-0"
: "opacity-0 -translate-x-2",
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-4",
)}
style={{
transitionDelay: isLoaded ? "400ms" : "0ms",
}}
>
<h2 className="text-sm font-semibold">Settings</h2>
</div>
<nav className="flex flex-col gap-1 px-2">
{NAV_ITEMS.map((item, index) => (
<button
type="button"
key={item.id}
onClick={() => setActiveSection(item.id)}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition-all duration-600 ease-out",
activeSection === item.id
? "bg-background text-foreground shadow-sm"
: "text-foreground-secondary hover:bg-background-secondary/50 hover:text-foreground duration-300",
isLoaded
? "opacity-100 translate-x-0"
: "opacity-0 translate-x-4",
)}
style={{
transitionDelay: isLoaded ? "0ms" : `${index * 40 + 300}ms`,
}}
>
<item.icon className="h-4 w-4" />
{item.label}
</button>
))}
</nav>
</div>
{/* Content */}
<div className="relative flex-1 overflow-y-auto">
<button
type="button"
onClick={onClose}
className="absolute right-4 top-4 z-10 rounded-md p-1 text-foreground-secondary transition-colors hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
<div
className={cn(
"px-6 py-4 transition-all duration-400 ease-out",
isTransitioning
? "opacity-0 translate-y-2"
: "opacity-100 translate-y-0",
)}
>
<div
className={cn(
"transition-all duration-600 ease-out",
isLoaded
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-4",
)}
style={{
transitionDelay: isLoaded ? "400ms" : "0ms",
}}
>
{activeSection === "appearance" && <AppearanceSettings />}
{activeSection === "general" && (
{activeSection === "appearance" && <AppearanceSettings />}
{activeSection === "general" && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold">General</h3>
<p className="mt-1 text-sm text-foreground-secondary">
General settings will appear here.
</p>
</div>
)}
{activeSection === "about" && (
</div>
)}
{activeSection === "projects" && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold">About</h3>
<h3 className="text-lg font-semibold">Projects</h3>
<p className="mt-1 text-sm text-foreground-secondary">
About information will appear here.
Manage your projects.
</p>
</div>
)}
</div>
{/* Archived Projects */}
<div className="space-y-3">
<h3 className="text-sm font-semibold">Archived Projects</h3>
{!loadingArchived && archivedProjects.length === 0 && (
<p className="text-xs text-foreground-secondary">
No archived projects.
</p>
)}
{archivedProjects.map((project) => (
<div
key={project.id}
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 }}
/>
<span className="text-sm truncate">{project.name}</span>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<button
type="button"
onClick={() => handleRestore(project.id)}
className="px-2 py-1 text-xs font-medium rounded-md border border-border hover:bg-background-tertiary transition-colors"
>
Restore
</button>
<button
type="button"
onClick={() => setDeletingProject(project)}
className="px-2 py-1 text-xs font-medium rounded-md text-foreground-danger hover:bg-background-danger/10 transition-colors"
>
Delete
</button>
</div>
</div>
))}
</div>
</div>
)}
{activeSection === "about" && (
<div>
<h3 className="text-lg font-semibold">About</h3>
<p className="mt-1 text-sm text-foreground-secondary">
About information will appear here.
</p>
</div>
)}
</div>
</div>
</div>
{deletingProject && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setDeletingProject(null)}
aria-hidden="true"
/>
<div className="relative z-10 w-full max-w-sm rounded-xl border border-border bg-background p-6 shadow-xl space-y-4">
<h3 className="text-sm font-semibold">
Delete project permanently?
</h3>
<p className="text-sm text-foreground-secondary">
Are you sure you want to permanently delete &quot;
{deletingProject.name}&quot;? This cannot be undone.
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setDeletingProject(null)}
className="px-3 py-1.5 text-xs font-medium rounded-md hover:bg-background-secondary transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={() => {
handleDelete(deletingProject.id);
setDeletingProject(null);
}}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-background-danger text-foreground-inverse shadow-sm hover:bg-background-danger/90 transition-colors"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}
+82 -86
View File
@@ -1,8 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
BookOpen,
Bot,
MessageSquare,
PanelLeft,
PanelLeftClose,
Plus,
@@ -12,6 +11,8 @@ import {
import { cn } from "@/shared/lib/cn";
import { GooseIcon } from "@/shared/ui/icons/GooseIcon";
import type { AppView } from "@/app/AppShell";
import type { ProjectInfo } from "@/features/projects/api/projects";
import { SidebarProjectsSection } from "./SidebarProjectsSection";
interface SidebarProps {
collapsed: boolean;
@@ -20,9 +21,24 @@ interface SidebarProps {
onSettingsClick?: () => void;
onSearchClick?: () => void;
onNewChat?: () => void;
onNewChatInProject?: (projectId: string) => void;
onCreateProject?: () => void;
onEditProject?: (projectId: string) => void;
onArchiveProject?: (projectId: string) => void;
onArchiveChat?: (tabId: string) => void;
onNavigate?: (view: AppView) => void;
onSelectTab?: (tabId: string) => void;
activeView?: AppView;
activeTabId?: string | null;
className?: string;
// Project & tab data
projects: ProjectInfo[];
tabs: Array<{
id: string;
title: string;
sessionId: string;
projectId?: string;
}>;
}
const NAV_ITEMS: readonly { id: AppView; label: string; icon: typeof Bot }[] = [
@@ -30,13 +46,6 @@ const NAV_ITEMS: readonly { id: AppView; label: string; icon: typeof Bot }[] = [
{ id: "skills", label: "Skills", icon: BookOpen },
];
const RECENT_CHATS = [
{ id: "1", name: "Debug login flow", time: "2m" },
{ id: "2", name: "API refactor notes", time: "1h" },
{ id: "3", name: "Weekend deploy plan", time: "3h" },
{ id: "4", name: "Design review", time: "1d" },
] as const;
export function Sidebar({
collapsed,
width = 240,
@@ -44,12 +53,24 @@ export function Sidebar({
onSettingsClick,
onSearchClick,
onNewChat,
onNewChatInProject,
onCreateProject,
onEditProject,
onArchiveProject,
onArchiveChat,
onNavigate,
onSelectTab,
activeView,
activeTabId,
className,
projects,
tabs,
}: SidebarProps) {
const [expanded, setExpanded] = useState(!collapsed);
const prevCollapsed = useRef(collapsed);
const [expandedProjects, setExpandedProjects] = useState<
Record<string, boolean>
>({});
useEffect(() => {
if (collapsed) {
@@ -66,6 +87,40 @@ export function Sidebar({
const labelTransition = "transition-all duration-300 ease-out";
const labelVisible = expanded && !collapsed;
const projectTabs = useMemo(() => {
const byProject: Record<string, typeof tabs> = {};
const standalone: typeof tabs = [];
for (const tab of tabs) {
if (tab.projectId) {
if (!byProject[tab.projectId]) byProject[tab.projectId] = [];
byProject[tab.projectId].push(tab);
} else {
standalone.push(tab);
}
}
return { byProject, standalone };
}, [tabs]);
// Auto-expand the project containing the active tab
useEffect(() => {
if (!activeTabId) return;
const activeTab = tabs.find((t) => t.id === activeTabId);
const projectId = activeTab?.projectId;
if (projectId) {
setExpandedProjects((prev) => {
if (prev[projectId]) return prev;
return { ...prev, [projectId]: true };
});
}
}, [activeTabId, tabs]);
const toggleProject = (projectId: string) => {
setExpandedProjects((prev) => ({
...prev,
[projectId]: !prev[projectId],
}));
};
return (
<div
className={cn(
@@ -252,83 +307,24 @@ export function Sidebar({
)}
/>
{/* Recent section */}
<div
className={cn(
labelTransition,
labelVisible
? "opacity-100 max-h-[2000px]"
: collapsed
? "opacity-100 max-h-[2000px]"
: "opacity-0 max-h-0 overflow-hidden",
)}
>
{/* Section header (expanded only) */}
<div
className={cn(
"flex items-center transition-all duration-300",
collapsed ? "px-0 pt-0 pb-1 justify-center" : "px-3 pt-2 pb-1",
)}
>
<span
className={cn(
"text-[10px] font-semibold uppercase tracking-wider text-foreground-secondary/70",
labelTransition,
labelVisible
? "opacity-100 w-auto"
: "opacity-0 w-0 overflow-hidden",
)}
>
Recent
</span>
</div>
{/* Chat items */}
{collapsed ? (
<div className="flex flex-col items-center gap-1">
{RECENT_CHATS.map((chat) => (
<button
type="button"
key={chat.id}
title={chat.name}
className={cn(
"flex items-center justify-center w-7 h-7 rounded-lg transition-all duration-200",
"text-foreground-secondary hover:text-foreground hover:bg-background-tertiary/50",
)}
>
<MessageSquare className="w-4 h-4" />
</button>
))}
</div>
) : (
<div className="space-y-0.5">
{RECENT_CHATS.map((chat, index) => (
<button
key={chat.id}
type="button"
className={cn(
"group flex items-center gap-2 w-full py-1.5 rounded-md text-[13px]",
"transition-colors duration-150 px-2.5",
"text-foreground-secondary hover:text-foreground hover:bg-background-tertiary/50",
)}
style={{
transitionDelay:
!collapsed && expanded
? `${(NAV_ITEMS.length + index) * 30}ms`
: "0ms",
}}
>
<span className="flex-1 min-w-0 truncate text-left">
{chat.name}
</span>
<span className="text-[10px] text-foreground-secondary/60 flex-shrink-0">
{chat.time}
</span>
</button>
))}
</div>
)}
</div>
{/* Projects + Chats section */}
<SidebarProjectsSection
projects={projects}
projectTabs={projectTabs}
expandedProjects={expandedProjects}
toggleProject={toggleProject}
collapsed={collapsed}
labelTransition={labelTransition}
labelVisible={labelVisible}
activeTabId={activeTabId}
onNavigate={onNavigate}
onSelectTab={onSelectTab}
onNewChatInProject={onNewChatInProject}
onCreateProject={onCreateProject}
onEditProject={onEditProject}
onArchiveProject={onArchiveProject}
onArchiveChat={onArchiveChat}
/>
</nav>
{/* Footer */}
@@ -0,0 +1,427 @@
import { useState, useEffect, useRef } from "react";
import {
ChevronDown,
ChevronRight,
MessageSquare,
MoreHorizontal,
Plus,
} from "lucide-react";
import { cn } from "@/shared/lib/cn";
import type { AppView } from "@/app/AppShell";
import type { ProjectInfo } from "@/features/projects/api/projects";
const MAX_VISIBLE_CHATS = 3;
interface TabInfo {
id: string;
title: string;
sessionId: string;
projectId?: string;
}
interface SidebarProjectsSectionProps {
projects: ProjectInfo[];
projectTabs: {
byProject: Record<string, TabInfo[]>;
standalone: TabInfo[];
};
expandedProjects: Record<string, boolean>;
toggleProject: (projectId: string) => void;
collapsed: boolean;
labelTransition: string;
labelVisible: boolean;
activeTabId?: string | null;
onNavigate?: (view: AppView) => void;
onSelectTab?: (tabId: string) => void;
onNewChatInProject?: (projectId: string) => void;
onCreateProject?: () => void;
onEditProject?: (projectId: string) => void;
onArchiveProject?: (projectId: string) => void;
onArchiveChat?: (tabId: string) => void;
}
function ItemMenu({
label,
onEdit,
onArchive,
}: {
label: string;
onEdit?: () => void;
onArchive?: () => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
return (
<div ref={ref} className="relative shrink-0">
<button
type="button"
aria-label={`Options for ${label}`}
aria-haspopup="true"
aria-expanded={open}
onClick={(e) => {
e.stopPropagation();
setOpen((prev) => !prev);
}}
className={cn(
"flex items-center justify-center w-6 h-6 rounded-md",
"text-foreground-secondary/40 hover:text-foreground hover:bg-background-tertiary/50",
open
? "visible opacity-100"
: "invisible group-hover:visible opacity-0 group-hover:opacity-100",
)}
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
{open && (
<div
role="menu"
className="absolute right-0 top-full z-10 mt-1 w-28 rounded-lg border border-border bg-background py-1 shadow-lg"
>
{onEdit && (
<button
type="button"
role="menuitem"
onClick={() => {
setOpen(false);
onEdit();
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-background-secondary transition-colors"
>
Edit
</button>
)}
{onArchive && (
<button
type="button"
role="menuitem"
onClick={() => {
setOpen(false);
onArchive();
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-background-secondary transition-colors"
>
Archive
</button>
)}
</div>
)}
</div>
);
}
function ProjectSection({
project,
projectChats,
isExpanded,
toggleProject,
activeTabId,
onSelectTab,
onNewChatInProject,
onNavigate,
onEditProject,
onArchiveProject,
onArchiveChat,
}: {
project: ProjectInfo;
projectChats: TabInfo[];
isExpanded: boolean;
toggleProject: (projectId: string) => void;
activeTabId?: string | null;
onSelectTab?: (tabId: string) => void;
onNewChatInProject?: (projectId: string) => void;
onNavigate?: (view: AppView) => void;
onEditProject?: (projectId: string) => void;
onArchiveProject?: (projectId: string) => void;
onArchiveChat?: (tabId: string) => void;
}) {
const visibleChats = projectChats.slice(0, MAX_VISIBLE_CHATS);
const hasMore = projectChats.length > MAX_VISIBLE_CHATS;
return (
<div>
{/* Project row */}
<div className="flex items-center group">
<button
type="button"
onClick={() => toggleProject(project.id)}
className={cn(
"flex items-center flex-1 min-w-0 gap-2 py-1.5 px-2.5 rounded-md text-[13px]",
"transition-colors duration-150",
"text-foreground-secondary hover:text-foreground hover:bg-background-tertiary/50",
)}
>
{isExpanded ? (
<ChevronDown className="w-3 h-3 flex-shrink-0" />
) : (
<ChevronRight className="w-3 h-3 flex-shrink-0" />
)}
<span
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: project.color }}
/>
<span className="flex-1 min-w-0 truncate text-left">
{project.name}
</span>
</button>
<ItemMenu
label={project.name}
onEdit={() => onEditProject?.(project.id)}
onArchive={() => onArchiveProject?.(project.id)}
/>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onNewChatInProject?.(project.id);
}}
title="New chat in project"
className={cn(
"flex items-center justify-center w-6 h-6 rounded-md mr-1 flex-shrink-0",
"text-foreground-secondary/50 hover:text-foreground hover:bg-background-tertiary/50",
"invisible group-hover:visible opacity-0 group-hover:opacity-100",
)}
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
{/* Nested chats */}
{isExpanded && (
<div className="space-y-0.5">
{visibleChats.map((tab) => {
const isActive = activeTabId === tab.id;
return (
<div key={tab.id} className="flex items-center group">
<button
type="button"
onClick={() => onSelectTab?.(tab.id)}
className={cn(
"flex items-center gap-2 flex-1 min-w-0 py-1.5 pl-8 pr-1 rounded-md text-[13px]",
"transition-colors duration-150",
isActive
? "bg-background-tertiary/70 text-foreground"
: "text-foreground-secondary hover:text-foreground hover:bg-background-tertiary/50",
)}
>
<span className="flex-1 min-w-0 truncate text-left">
{tab.title}
</span>
</button>
<ItemMenu
label={tab.title}
onArchive={() => onArchiveChat?.(tab.id)}
/>
</div>
);
})}
{hasMore && (
<button
type="button"
onClick={() => onNavigate?.("projects")}
className={cn(
"flex items-center w-full py-1 pl-8 pr-2.5 rounded-md text-[11px]",
"text-foreground-secondary/60 hover:text-foreground-secondary transition-colors duration-150",
)}
>
View all {projectChats.length} chats
</button>
)}
</div>
)}
</div>
);
}
export function SidebarProjectsSection({
projects,
projectTabs,
expandedProjects,
toggleProject,
collapsed,
labelTransition,
labelVisible,
activeTabId,
onNavigate,
onSelectTab,
onNewChatInProject,
onCreateProject,
onEditProject,
onArchiveProject,
onArchiveChat,
}: SidebarProjectsSectionProps) {
return (
<div
className={cn(
labelTransition,
labelVisible
? "opacity-100 max-h-[2000px]"
: collapsed
? "opacity-100 max-h-[2000px]"
: "opacity-0 max-h-0 overflow-hidden",
)}
>
{/* --- PROJECTS (always visible) --- */}
{/* Section header with [+] button */}
<div
className={cn(
"flex items-center transition-all duration-300",
collapsed ? "px-0 pt-0 pb-1 justify-center" : "pt-2 pb-1",
)}
>
<span
className={cn(
"text-[10px] font-semibold uppercase tracking-wider text-foreground-secondary/70 flex-1 pl-1.5",
labelTransition,
labelVisible
? "opacity-100 w-auto"
: "opacity-0 w-0 overflow-hidden",
)}
>
Projects
</span>
{!collapsed && (
<button
type="button"
onClick={onCreateProject}
title="New project"
className={cn(
"flex items-center justify-center w-6 h-6 rounded-md flex-shrink-0 mr-1",
"text-foreground-secondary/50 hover:text-foreground hover:bg-background-tertiary/50",
"transition-opacity duration-150",
)}
>
<Plus className="w-3.5 h-3.5" />
</button>
)}
</div>
{collapsed ? (
<div className="flex flex-col items-center gap-1">
{projects.map((project) => (
<button
type="button"
key={project.id}
title={project.name}
onClick={() => onNavigate?.("projects")}
className={cn(
"flex items-center justify-center w-7 h-7 rounded-lg transition-all duration-200",
"text-foreground-secondary hover:text-foreground hover:bg-background-tertiary/50",
)}
>
<span
className="inline-block w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: project.color }}
/>
</button>
))}
</div>
) : (
<div className="space-y-0.5">
{projects.map((project) => (
<ProjectSection
key={project.id}
project={project}
projectChats={projectTabs.byProject[project.id] ?? []}
isExpanded={expandedProjects[project.id] ?? false}
toggleProject={toggleProject}
activeTabId={activeTabId}
onSelectTab={onSelectTab}
onNewChatInProject={onNewChatInProject}
onNavigate={onNavigate}
onEditProject={onEditProject}
onArchiveProject={onArchiveProject}
onArchiveChat={onArchiveChat}
/>
))}
</div>
)}
{/* --- STANDALONE CHATS --- */}
{projectTabs.standalone.length > 0 && (
<>
{/* Section header (expanded only) */}
<div
className={cn(
"flex items-center transition-all duration-300",
collapsed ? "px-0 pt-0 pb-1 justify-center" : "pt-2 pb-1",
)}
>
<span
className={cn(
"text-[10px] font-semibold uppercase tracking-wider text-foreground-secondary/70 pl-1.5",
labelTransition,
labelVisible
? "opacity-100 w-auto"
: "opacity-0 w-0 overflow-hidden",
)}
>
Chats
</span>
</div>
{collapsed ? (
<div className="flex flex-col items-center gap-1">
{projectTabs.standalone.map((tab) => (
<button
type="button"
key={tab.id}
title={tab.title}
onClick={() => onSelectTab?.(tab.id)}
className={cn(
"flex items-center justify-center w-7 h-7 rounded-lg transition-all duration-200",
activeTabId === tab.id
? "bg-background-tertiary/70 text-foreground"
: "text-foreground-secondary hover:text-foreground hover:bg-background-tertiary/50",
)}
>
<MessageSquare className="w-4 h-4" />
</button>
))}
</div>
) : (
<div className="space-y-0.5">
{projectTabs.standalone.map((tab) => {
const isActive = activeTabId === tab.id;
return (
<div key={tab.id} className="flex items-center group">
<button
type="button"
onClick={() => onSelectTab?.(tab.id)}
className={cn(
"flex items-center gap-2 flex-1 min-w-0 py-1.5 rounded-md text-[13px]",
"transition-colors duration-150 px-2.5",
isActive
? "bg-background-tertiary/70 text-foreground"
: "text-foreground-secondary hover:text-foreground hover:bg-background-tertiary/50",
)}
>
<span className="flex-1 min-w-0 truncate text-left">
{tab.title}
</span>
</button>
<ItemMenu
label={tab.title}
onArchive={() => onArchiveChat?.(tab.id)}
/>
</div>
);
})}
</div>
)}
</>
)}
</div>
);
}
+1
View File
@@ -3,4 +3,5 @@ export interface Tab {
title: string;
sessionId: string;
agentId?: string;
projectId?: string;
}