mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
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:
@@ -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",
|
||||
|
||||
Generated
+10
@@ -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
|
||||
|
||||
Generated
+65
@@ -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"
|
||||
|
||||
@@ -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,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))
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 "{deletingProject.name}
|
||||
"? 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 "
|
||||
{deletingProject.name}"? 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,4 +3,5 @@ export interface Tab {
|
||||
title: string;
|
||||
sessionId: string;
|
||||
agentId?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user