From ab79dac4b30ac5001e7ea476cfe77ea42bb2f17a Mon Sep 17 00:00:00 2001 From: Hibryda Date: Sat, 7 Mar 2026 16:06:07 +0100 Subject: [PATCH] feat(v3): implement Mission Control MVP (Phases 1-5) Phase 1: Data model - groups.rs (Rust structs + load/save groups.json), groups.ts (TypeScript interfaces), groups-bridge.ts (IPC adapter), workspace.svelte.ts (replaces layout store), SQLite migrations (agent_messages, project_agent_state tables, project_id column), --group CLI argument. Phase 2: Project shell layout - GlobalTabBar, ProjectGrid, ProjectBox, ProjectHeader, CommandPalette, DocsTab, ContextTab, SettingsTab, App.svelte full rewrite (no sidebar/TilingGrid). Phase 3: ClaudeSession.svelte wrapping AgentPane per-project. Phase 4: TerminalTabs.svelte with shell/SSH/agent tab types. Phase 5: TeamAgentsPanel + AgentCard for compact subagent view. Also fixes AgentPane Svelte 5 event modifier (on:click -> onclick). --- v2/src-tauri/src/groups.rs | 225 ++++++++++++++ v2/src-tauri/src/lib.rs | 90 +++++- v2/src-tauri/src/session.rs | 177 +++++++++++ v2/src/App.svelte | 141 +++++---- v2/src/lib/adapters/groups-bridge.ts | 85 ++++++ v2/src/lib/components/Agent/AgentPane.svelte | 2 +- .../lib/components/Workspace/AgentCard.svelte | 100 +++++++ .../components/Workspace/ClaudeSession.svelte | 82 ++++++ .../Workspace/CommandPalette.svelte | 159 ++++++++++ .../components/Workspace/ContextTab.svelte | 14 + .../lib/components/Workspace/DocsTab.svelte | 159 ++++++++++ .../components/Workspace/GlobalTabBar.svelte | 56 ++++ .../components/Workspace/ProjectBox.svelte | 77 +++++ .../components/Workspace/ProjectGrid.svelte | 85 ++++++ .../components/Workspace/ProjectHeader.svelte | 74 +++++ .../components/Workspace/SettingsTab.svelte | 275 ++++++++++++++++++ .../Workspace/TeamAgentsPanel.svelte | 91 ++++++ .../components/Workspace/TerminalTabs.svelte | 217 ++++++++++++++ v2/src/lib/stores/workspace.svelte.ts | 216 ++++++++++++++ v2/src/lib/types/groups.ts | 36 +++ 20 files changed, 2296 insertions(+), 65 deletions(-) create mode 100644 v2/src-tauri/src/groups.rs create mode 100644 v2/src/lib/adapters/groups-bridge.ts create mode 100644 v2/src/lib/components/Workspace/AgentCard.svelte create mode 100644 v2/src/lib/components/Workspace/ClaudeSession.svelte create mode 100644 v2/src/lib/components/Workspace/CommandPalette.svelte create mode 100644 v2/src/lib/components/Workspace/ContextTab.svelte create mode 100644 v2/src/lib/components/Workspace/DocsTab.svelte create mode 100644 v2/src/lib/components/Workspace/GlobalTabBar.svelte create mode 100644 v2/src/lib/components/Workspace/ProjectBox.svelte create mode 100644 v2/src/lib/components/Workspace/ProjectGrid.svelte create mode 100644 v2/src/lib/components/Workspace/ProjectHeader.svelte create mode 100644 v2/src/lib/components/Workspace/SettingsTab.svelte create mode 100644 v2/src/lib/components/Workspace/TeamAgentsPanel.svelte create mode 100644 v2/src/lib/components/Workspace/TerminalTabs.svelte create mode 100644 v2/src/lib/stores/workspace.svelte.ts create mode 100644 v2/src/lib/types/groups.ts diff --git a/v2/src-tauri/src/groups.rs b/v2/src-tauri/src/groups.rs new file mode 100644 index 0000000..fc7aa5a --- /dev/null +++ b/v2/src-tauri/src/groups.rs @@ -0,0 +1,225 @@ +// Project group configuration +// Reads/writes ~/.config/bterminal/groups.json + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectConfig { + pub id: String, + pub name: String, + pub identifier: String, + pub description: String, + pub icon: String, + pub cwd: String, + pub profile: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroupConfig { + pub id: String, + pub name: String, + pub projects: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GroupsFile { + pub version: u32, + pub groups: Vec, + pub active_group_id: String, +} + +impl Default for GroupsFile { + fn default() -> Self { + Self { + version: 1, + groups: Vec::new(), + active_group_id: String::new(), + } + } +} + +fn config_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("bterminal") + .join("groups.json") +} + +pub fn load_groups() -> Result { + let path = config_path(); + if !path.exists() { + return Ok(GroupsFile::default()); + } + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read groups.json: {e}"))?; + serde_json::from_str(&content) + .map_err(|e| format!("Invalid groups.json: {e}")) +} + +pub fn save_groups(config: &GroupsFile) -> Result<(), String> { + let path = config_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create config dir: {e}"))?; + } + let json = serde_json::to_string_pretty(config) + .map_err(|e| format!("JSON serialize error: {e}"))?; + std::fs::write(&path, json) + .map_err(|e| format!("Failed to write groups.json: {e}")) +} + +/// Discover markdown files in a project directory for the Docs tab. +/// Returns paths relative to cwd, prioritized: CLAUDE.md, README.md, docs/*.md +pub fn discover_markdown_files(cwd: &str) -> Result, String> { + let root = PathBuf::from(cwd); + if !root.is_dir() { + return Err(format!("Directory not found: {cwd}")); + } + + let mut entries = Vec::new(); + + // Priority files at root + for name in &["CLAUDE.md", "README.md", "CHANGELOG.md", "TODO.md"] { + let path = root.join(name); + if path.is_file() { + entries.push(MdFileEntry { + name: name.to_string(), + path: path.to_string_lossy().to_string(), + priority: true, + }); + } + } + + // docs/ directory (max 20 entries, depth 2) + let docs_dir = root.join("docs"); + if docs_dir.is_dir() { + scan_md_dir(&docs_dir, &mut entries, 2, 20); + } + + Ok(entries) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MdFileEntry { + pub name: String, + pub path: String, + pub priority: bool, +} + +fn scan_md_dir(dir: &PathBuf, entries: &mut Vec, max_depth: u32, max_count: usize) { + if max_depth == 0 || entries.len() >= max_count { + return; + } + let Ok(read_dir) = std::fs::read_dir(dir) else { return }; + for entry in read_dir.flatten() { + if entries.len() >= max_count { + break; + } + let path = entry.path(); + if path.is_file() { + if let Some(ext) = path.extension() { + if ext == "md" || ext == "markdown" { + let name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); + entries.push(MdFileEntry { + name, + path: path.to_string_lossy().to_string(), + priority: false, + }); + } + } + } else if path.is_dir() { + // Skip common non-doc directories + let dir_name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); + if !matches!(dir_name.as_str(), "node_modules" | ".git" | "target" | "dist" | "build" | ".next" | "__pycache__") { + scan_md_dir(&path, entries, max_depth - 1, max_count); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_groups_file() { + let g = GroupsFile::default(); + assert_eq!(g.version, 1); + assert!(g.groups.is_empty()); + assert!(g.active_group_id.is_empty()); + } + + #[test] + fn test_groups_roundtrip() { + let config = GroupsFile { + version: 1, + groups: vec![GroupConfig { + id: "test".to_string(), + name: "Test Group".to_string(), + projects: vec![ProjectConfig { + id: "p1".to_string(), + name: "Project One".to_string(), + identifier: "project-one".to_string(), + description: "A test project".to_string(), + icon: "\u{f120}".to_string(), + cwd: "/tmp/test".to_string(), + profile: "default".to_string(), + enabled: true, + }], + }], + active_group_id: "test".to_string(), + }; + let json = serde_json::to_string(&config).unwrap(); + let parsed: GroupsFile = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.groups.len(), 1); + assert_eq!(parsed.groups[0].projects.len(), 1); + assert_eq!(parsed.groups[0].projects[0].identifier, "project-one"); + } + + #[test] + fn test_load_missing_file_returns_default() { + // config_path() will point to a non-existent file in test + // We test the default case directly + let g = GroupsFile::default(); + assert_eq!(g.version, 1); + } + + #[test] + fn test_discover_nonexistent_dir() { + let result = discover_markdown_files("/nonexistent/path/12345"); + assert!(result.is_err()); + } + + #[test] + fn test_discover_empty_dir() { + let dir = tempfile::tempdir().unwrap(); + let result = discover_markdown_files(dir.path().to_str().unwrap()).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_discover_finds_readme() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("README.md"), "# Hello").unwrap(); + let result = discover_markdown_files(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "README.md"); + assert!(result[0].priority); + } + + #[test] + fn test_discover_finds_docs() { + let dir = tempfile::tempdir().unwrap(); + let docs = dir.path().join("docs"); + std::fs::create_dir(&docs).unwrap(); + std::fs::write(docs.join("guide.md"), "# Guide").unwrap(); + std::fs::write(docs.join("api.md"), "# API").unwrap(); + let result = discover_markdown_files(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(result.len(), 2); + assert!(result.iter().all(|e| !e.priority)); + } +} diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index 029e648..1b8f8eb 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod ctx; mod event_sink; +mod groups; mod pty; mod remote; mod sidecar; @@ -8,9 +9,10 @@ mod watcher; use ctx::CtxDb; use event_sink::TauriEventSink; +use groups::{GroupsFile, MdFileEntry}; use pty::{PtyManager, PtyOptions}; use remote::{RemoteManager, RemoteMachineConfig}; -use session::{Session, SessionDb, LayoutState, SshSession}; +use session::{Session, SessionDb, LayoutState, SshSession, AgentMessageRecord, ProjectAgentState}; use sidecar::{AgentQueryOptions, SidecarConfig, SidecarManager}; use watcher::FileWatcherManager; use std::sync::Arc; @@ -360,6 +362,65 @@ fn claude_read_skill(path: String) -> Result { std::fs::read_to_string(&path).map_err(|e| format!("Failed to read skill: {e}")) } +// --- Group config commands (v3) --- + +#[tauri::command] +fn groups_load() -> Result { + groups::load_groups() +} + +#[tauri::command] +fn groups_save(config: GroupsFile) -> Result<(), String> { + groups::save_groups(&config) +} + +#[tauri::command] +fn discover_markdown_files(cwd: String) -> Result, String> { + groups::discover_markdown_files(&cwd) +} + +// --- Agent message persistence commands (v3) --- + +#[tauri::command] +fn agent_messages_save( + state: State<'_, AppState>, + session_id: String, + project_id: String, + sdk_session_id: Option, + messages: Vec, +) -> Result<(), String> { + state.session_db.save_agent_messages( + &session_id, + &project_id, + sdk_session_id.as_deref(), + &messages, + ) +} + +#[tauri::command] +fn agent_messages_load( + state: State<'_, AppState>, + project_id: String, +) -> Result, String> { + state.session_db.load_agent_messages(&project_id) +} + +#[tauri::command] +fn project_agent_state_save( + state: State<'_, AppState>, + agent_state: ProjectAgentState, +) -> Result<(), String> { + state.session_db.save_project_agent_state(&agent_state) +} + +#[tauri::command] +fn project_agent_state_load( + state: State<'_, AppState>, + project_id: String, +) -> Result, String> { + state.session_db.load_project_agent_state(&project_id) +} + // --- Directory picker command --- #[tauri::command] @@ -369,6 +430,25 @@ fn pick_directory() -> Option { None } +// --- CLI argument commands --- + +#[tauri::command] +fn cli_get_group() -> Option { + let args: Vec = std::env::args().collect(); + let mut i = 1; + while i < args.len() { + if args[i] == "--group" { + if i + 1 < args.len() { + return Some(args[i + 1].clone()); + } + } else if let Some(val) = args[i].strip_prefix("--group=") { + return Some(val.to_string()); + } + i += 1; + } + None +} + // --- Remote machine commands --- #[tauri::command] @@ -475,6 +555,14 @@ pub fn run() { claude_list_skills, claude_read_skill, pick_directory, + groups_load, + groups_save, + discover_markdown_files, + agent_messages_save, + agent_messages_load, + project_agent_state_save, + project_agent_state_load, + cli_get_group, ]) .plugin(tauri_plugin_updater::Builder::new().build()) .setup(move |app| { diff --git a/v2/src-tauri/src/session.rs b/v2/src-tauri/src/session.rs index d031360..303406c 100644 --- a/v2/src-tauri/src/session.rs +++ b/v2/src-tauri/src/session.rs @@ -116,6 +116,47 @@ impl SessionDb { .map_err(|e| format!("Migration (group_name) failed: {e}"))?; } + // v3 migration: project_id column on sessions + let has_project_id: i64 = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('sessions') WHERE name='project_id'", + [], + |row| row.get(0), + ).unwrap_or(0); + if has_project_id == 0 { + conn.execute("ALTER TABLE sessions ADD COLUMN project_id TEXT DEFAULT ''", []) + .map_err(|e| format!("Migration (project_id) failed: {e}"))?; + } + + // v3: agent message history for session continuity + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS agent_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + project_id TEXT NOT NULL, + sdk_session_id TEXT, + message_type TEXT NOT NULL, + content TEXT NOT NULL, + parent_id TEXT, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_agent_messages_session + ON agent_messages(session_id); + CREATE INDEX IF NOT EXISTS idx_agent_messages_project + ON agent_messages(project_id); + + CREATE TABLE IF NOT EXISTS project_agent_state ( + project_id TEXT PRIMARY KEY, + last_session_id TEXT NOT NULL, + sdk_session_id TEXT, + status TEXT NOT NULL, + cost_usd REAL DEFAULT 0, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + last_prompt TEXT, + updated_at INTEGER NOT NULL + );" + ).map_err(|e| format!("Migration (v3 tables) failed: {e}"))?; + Ok(()) } @@ -340,6 +381,142 @@ impl SessionDb { ).map_err(|e| format!("SSH update failed: {e}"))?; Ok(()) } + + // --- v3: Agent message persistence --- + + pub fn save_agent_messages( + &self, + session_id: &str, + project_id: &str, + sdk_session_id: Option<&str>, + messages: &[AgentMessageRecord], + ) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + // Clear previous messages for this session + conn.execute( + "DELETE FROM agent_messages WHERE session_id = ?1", + params![session_id], + ).map_err(|e| format!("Delete old messages failed: {e}"))?; + + let mut stmt = conn.prepare( + "INSERT INTO agent_messages (session_id, project_id, sdk_session_id, message_type, content, parent_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" + ).map_err(|e| format!("Prepare insert failed: {e}"))?; + + for msg in messages { + stmt.execute(params![ + session_id, + project_id, + sdk_session_id, + msg.message_type, + msg.content, + msg.parent_id, + msg.created_at, + ]).map_err(|e| format!("Insert message failed: {e}"))?; + } + Ok(()) + } + + pub fn load_agent_messages(&self, project_id: &str) -> Result, String> { + let conn = self.conn.lock().unwrap(); + // Load messages from the most recent session for this project + let mut stmt = conn.prepare( + "SELECT id, session_id, project_id, sdk_session_id, message_type, content, parent_id, created_at + FROM agent_messages + WHERE project_id = ?1 + ORDER BY created_at ASC" + ).map_err(|e| format!("Query prepare failed: {e}"))?; + + let messages = stmt.query_map(params![project_id], |row| { + Ok(AgentMessageRecord { + id: row.get(0)?, + session_id: row.get(1)?, + project_id: row.get(2)?, + sdk_session_id: row.get(3)?, + message_type: row.get(4)?, + content: row.get(5)?, + parent_id: row.get(6)?, + created_at: row.get(7)?, + }) + }).map_err(|e| format!("Query failed: {e}"))? + .collect::, _>>() + .map_err(|e| format!("Row read failed: {e}"))?; + + Ok(messages) + } + + // --- v3: Project agent state --- + + pub fn save_project_agent_state(&self, state: &ProjectAgentState) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT OR REPLACE INTO project_agent_state (project_id, last_session_id, sdk_session_id, status, cost_usd, input_tokens, output_tokens, last_prompt, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + state.project_id, + state.last_session_id, + state.sdk_session_id, + state.status, + state.cost_usd, + state.input_tokens, + state.output_tokens, + state.last_prompt, + state.updated_at, + ], + ).map_err(|e| format!("Save project agent state failed: {e}"))?; + Ok(()) + } + + pub fn load_project_agent_state(&self, project_id: &str) -> Result, String> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT project_id, last_session_id, sdk_session_id, status, cost_usd, input_tokens, output_tokens, last_prompt, updated_at FROM project_agent_state WHERE project_id = ?1" + ).map_err(|e| format!("Query prepare failed: {e}"))?; + + let result = stmt.query_row(params![project_id], |row| { + Ok(ProjectAgentState { + project_id: row.get(0)?, + last_session_id: row.get(1)?, + sdk_session_id: row.get(2)?, + status: row.get(3)?, + cost_usd: row.get(4)?, + input_tokens: row.get(5)?, + output_tokens: row.get(6)?, + last_prompt: row.get(7)?, + updated_at: row.get(8)?, + }) + }); + + match result { + Ok(state) => Ok(Some(state)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("Load project agent state failed: {e}")), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentMessageRecord { + #[serde(default)] + pub id: i64, + pub session_id: String, + pub project_id: String, + pub sdk_session_id: Option, + pub message_type: String, + pub content: String, + pub parent_id: Option, + pub created_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectAgentState { + pub project_id: String, + pub last_session_id: String, + pub sdk_session_id: Option, + pub status: String, + pub cost_usd: f64, + pub input_tokens: i64, + pub output_tokens: i64, + pub last_prompt: Option, + pub updated_at: i64, } #[cfg(test)] diff --git a/v2/src/App.svelte b/v2/src/App.svelte index 3d37e91..75c3149 100644 --- a/v2/src/App.svelte +++ b/v2/src/App.svelte @@ -1,80 +1,73 @@ + +
e.key === 'Enter' && onclick?.()}> +
+ + {session.status} + {#if session.costUsd > 0} + ${session.costUsd.toFixed(4)} + {/if} +
+
{truncatedPrompt}
+ {#if session.status === 'running'} +
+ {session.numTurns} turns +
+ {/if} +
+ + diff --git a/v2/src/lib/components/Workspace/ClaudeSession.svelte b/v2/src/lib/components/Workspace/ClaudeSession.svelte new file mode 100644 index 0000000..be83fb8 --- /dev/null +++ b/v2/src/lib/components/Workspace/ClaudeSession.svelte @@ -0,0 +1,82 @@ + + +
+ {#if loading} +
Loading session...
+ {:else} + + {/if} +
+ + diff --git a/v2/src/lib/components/Workspace/CommandPalette.svelte b/v2/src/lib/components/Workspace/CommandPalette.svelte new file mode 100644 index 0000000..a1db009 --- /dev/null +++ b/v2/src/lib/components/Workspace/CommandPalette.svelte @@ -0,0 +1,159 @@ + + +{#if open} + +
+ +
e.stopPropagation()} onkeydown={handleKeydown}> + +
    + {#each filtered as group} +
  • + +
  • + {/each} + {#if filtered.length === 0} +
  • No groups match "{query}"
  • + {/if} +
+
+
+{/if} + + diff --git a/v2/src/lib/components/Workspace/ContextTab.svelte b/v2/src/lib/components/Workspace/ContextTab.svelte new file mode 100644 index 0000000..26ebbc1 --- /dev/null +++ b/v2/src/lib/components/Workspace/ContextTab.svelte @@ -0,0 +1,14 @@ + + +
+ {}} /> +
+ + diff --git a/v2/src/lib/components/Workspace/DocsTab.svelte b/v2/src/lib/components/Workspace/DocsTab.svelte new file mode 100644 index 0000000..ca5fd31 --- /dev/null +++ b/v2/src/lib/components/Workspace/DocsTab.svelte @@ -0,0 +1,159 @@ + + +
+ + +
+ {#if selectedPath} + + {:else} +
Select a document from the sidebar
+ {/if} +
+
+ + diff --git a/v2/src/lib/components/Workspace/GlobalTabBar.svelte b/v2/src/lib/components/Workspace/GlobalTabBar.svelte new file mode 100644 index 0000000..7a59173 --- /dev/null +++ b/v2/src/lib/components/Workspace/GlobalTabBar.svelte @@ -0,0 +1,56 @@ + + + + + diff --git a/v2/src/lib/components/Workspace/ProjectBox.svelte b/v2/src/lib/components/Workspace/ProjectBox.svelte new file mode 100644 index 0000000..ade0cc4 --- /dev/null +++ b/v2/src/lib/components/Workspace/ProjectBox.svelte @@ -0,0 +1,77 @@ + + +
+ + +
+ mainSessionId = id} /> + {#if mainSessionId} + + {/if} +
+ +
+ +
+
+ + diff --git a/v2/src/lib/components/Workspace/ProjectGrid.svelte b/v2/src/lib/components/Workspace/ProjectGrid.svelte new file mode 100644 index 0000000..f1708db --- /dev/null +++ b/v2/src/lib/components/Workspace/ProjectGrid.svelte @@ -0,0 +1,85 @@ + + +
+ {#each projects as project, i (project.id)} +
+ setActiveProject(project.id)} + /> +
+ {/each} + + {#if projects.length === 0} +
+ No enabled projects in this group. Go to Settings to add projects. +
+ {/if} +
+ + diff --git a/v2/src/lib/components/Workspace/ProjectHeader.svelte b/v2/src/lib/components/Workspace/ProjectHeader.svelte new file mode 100644 index 0000000..9f3818d --- /dev/null +++ b/v2/src/lib/components/Workspace/ProjectHeader.svelte @@ -0,0 +1,74 @@ + + + + + diff --git a/v2/src/lib/components/Workspace/SettingsTab.svelte b/v2/src/lib/components/Workspace/SettingsTab.svelte new file mode 100644 index 0000000..819a26b --- /dev/null +++ b/v2/src/lib/components/Workspace/SettingsTab.svelte @@ -0,0 +1,275 @@ + + +
+
+

Groups

+
+ {#each groups as group} +
+ + {group.projects.length} projects + {#if groups.length > 1} + + {/if} +
+ {/each} +
+ +
+ + +
+
+ + {#if activeGroup} +
+

Projects in "{activeGroup.name}"

+ + {#each activeGroup.projects as project} +
+
+ + updateProject(activeGroupId, project.id, { name: (e.target as HTMLInputElement).value })} + /> +
+
+ + updateProject(activeGroupId, project.id, { cwd: (e.target as HTMLInputElement).value })} + /> +
+
+ + updateProject(activeGroupId, project.id, { icon: (e.target as HTMLInputElement).value })} + style="width: 60px" + /> +
+
+ + updateProject(activeGroupId, project.id, { enabled: (e.target as HTMLInputElement).checked })} + /> +
+ +
+ {/each} + + {#if activeGroup.projects.length < 5} +
+ + + +
+ {:else} +

Maximum 5 projects per group reached.

+ {/if} +
+ {/if} +
+ + diff --git a/v2/src/lib/components/Workspace/TeamAgentsPanel.svelte b/v2/src/lib/components/Workspace/TeamAgentsPanel.svelte new file mode 100644 index 0000000..5216330 --- /dev/null +++ b/v2/src/lib/components/Workspace/TeamAgentsPanel.svelte @@ -0,0 +1,91 @@ + + +{#if hasAgents} +
+ + + {#if expanded} +
+ {#each childSessions as child (child.id)} + + {/each} +
+ {/if} +
+{/if} + + diff --git a/v2/src/lib/components/Workspace/TerminalTabs.svelte b/v2/src/lib/components/Workspace/TerminalTabs.svelte new file mode 100644 index 0000000..d2dfeb9 --- /dev/null +++ b/v2/src/lib/components/Workspace/TerminalTabs.svelte @@ -0,0 +1,217 @@ + + +
+
+ {#each tabs as tab (tab.id)} + + {/each} + +
+ +
+ {#each tabs as tab (tab.id)} +
+ {#if activeTabId === tab.id} + handleTabExit(tab.id)} + /> + {/if} +
+ {/each} + + {#if tabs.length === 0} +
+ +
+ {/if} +
+
+ + diff --git a/v2/src/lib/stores/workspace.svelte.ts b/v2/src/lib/stores/workspace.svelte.ts new file mode 100644 index 0000000..ad1169a --- /dev/null +++ b/v2/src/lib/stores/workspace.svelte.ts @@ -0,0 +1,216 @@ +import { loadGroups, saveGroups, getCliGroup } from '../adapters/groups-bridge'; +import type { GroupsFile, GroupConfig, ProjectConfig } from '../types/groups'; + +export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings'; + +export interface TerminalTab { + id: string; + title: string; + type: 'shell' | 'ssh' | 'agent-terminal'; + /** SSH session ID if type === 'ssh' */ + sshSessionId?: string; +} + +// --- Core state --- + +let groupsConfig = $state(null); +let activeGroupId = $state(''); +let activeTab = $state('sessions'); +let activeProjectId = $state(null); + +/** Terminal tabs per project (keyed by project ID) */ +let projectTerminals = $state>(new Map()); + +// --- Getters --- + +export function getGroupsConfig(): GroupsFile | null { + return groupsConfig; +} + +export function getActiveGroupId(): string { + return activeGroupId; +} + +export function getActiveTab(): WorkspaceTab { + return activeTab; +} + +export function getActiveProjectId(): string | null { + return activeProjectId; +} + +export function getActiveGroup(): GroupConfig | undefined { + return groupsConfig?.groups.find(g => g.id === activeGroupId); +} + +export function getEnabledProjects(): ProjectConfig[] { + const group = getActiveGroup(); + if (!group) return []; + return group.projects.filter(p => p.enabled); +} + +export function getAllGroups(): GroupConfig[] { + return groupsConfig?.groups ?? []; +} + +// --- Setters --- + +export function setActiveTab(tab: WorkspaceTab): void { + activeTab = tab; +} + +export function setActiveProject(projectId: string | null): void { + activeProjectId = projectId; +} + +export async function switchGroup(groupId: string): Promise { + if (groupId === activeGroupId) return; + + // Clear terminal tabs for the old group + projectTerminals = new Map(); + + activeGroupId = groupId; + activeProjectId = null; + + // Auto-focus first enabled project + const projects = getEnabledProjects(); + if (projects.length > 0) { + activeProjectId = projects[0].id; + } + + // Persist active group + if (groupsConfig) { + groupsConfig.activeGroupId = groupId; + saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); + } +} + +// --- Terminal tab management per project --- + +export function getTerminalTabs(projectId: string): TerminalTab[] { + return projectTerminals.get(projectId) ?? []; +} + +export function addTerminalTab(projectId: string, tab: TerminalTab): void { + const tabs = projectTerminals.get(projectId) ?? []; + tabs.push(tab); + projectTerminals.set(projectId, [...tabs]); +} + +export function removeTerminalTab(projectId: string, tabId: string): void { + const tabs = projectTerminals.get(projectId) ?? []; + const filtered = tabs.filter(t => t.id !== tabId); + projectTerminals.set(projectId, filtered); +} + +// --- Persistence --- + +export async function loadWorkspace(initialGroupId?: string): Promise { + try { + const config = await loadGroups(); + groupsConfig = config; + projectTerminals = new Map(); + + // CLI --group flag takes priority, then explicit param, then persisted + let cliGroup: string | null = null; + if (!initialGroupId) { + cliGroup = await getCliGroup(); + } + const targetId = initialGroupId || cliGroup || config.activeGroupId; + // Match by ID or by name (CLI users may pass name) + const targetGroup = config.groups.find( + g => g.id === targetId || g.name === targetId, + ); + + if (targetGroup) { + activeGroupId = targetGroup.id; + } else if (config.groups.length > 0) { + activeGroupId = config.groups[0].id; + } + + // Auto-focus first enabled project + const projects = getEnabledProjects(); + if (projects.length > 0) { + activeProjectId = projects[0].id; + } + } catch (e) { + console.warn('Failed to load groups config:', e); + groupsConfig = { version: 1, groups: [], activeGroupId: '' }; + } +} + +export async function saveWorkspace(): Promise { + if (!groupsConfig) return; + await saveGroups(groupsConfig); +} + +// --- Group/project mutation --- + +export function addGroup(group: GroupConfig): void { + if (!groupsConfig) return; + groupsConfig = { + ...groupsConfig, + groups: [...groupsConfig.groups, group], + }; + saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); +} + +export function removeGroup(groupId: string): void { + if (!groupsConfig) return; + groupsConfig = { + ...groupsConfig, + groups: groupsConfig.groups.filter(g => g.id !== groupId), + }; + if (activeGroupId === groupId) { + activeGroupId = groupsConfig.groups[0]?.id ?? ''; + activeProjectId = null; + } + saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); +} + +export function updateProject(groupId: string, projectId: string, updates: Partial): void { + if (!groupsConfig) return; + groupsConfig = { + ...groupsConfig, + groups: groupsConfig.groups.map(g => { + if (g.id !== groupId) return g; + return { + ...g, + projects: g.projects.map(p => { + if (p.id !== projectId) return p; + return { ...p, ...updates }; + }), + }; + }), + }; + saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); +} + +export function addProject(groupId: string, project: ProjectConfig): void { + if (!groupsConfig) return; + const group = groupsConfig.groups.find(g => g.id === groupId); + if (!group || group.projects.length >= 5) return; + groupsConfig = { + ...groupsConfig, + groups: groupsConfig.groups.map(g => { + if (g.id !== groupId) return g; + return { ...g, projects: [...g.projects, project] }; + }), + }; + saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); +} + +export function removeProject(groupId: string, projectId: string): void { + if (!groupsConfig) return; + groupsConfig = { + ...groupsConfig, + groups: groupsConfig.groups.map(g => { + if (g.id !== groupId) return g; + return { ...g, projects: g.projects.filter(p => p.id !== projectId) }; + }), + }; + if (activeProjectId === projectId) { + activeProjectId = null; + } + saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); +} diff --git a/v2/src/lib/types/groups.ts b/v2/src/lib/types/groups.ts new file mode 100644 index 0000000..247d420 --- /dev/null +++ b/v2/src/lib/types/groups.ts @@ -0,0 +1,36 @@ +export interface ProjectConfig { + id: string; + name: string; + identifier: string; + description: string; + icon: string; + cwd: string; + profile: string; + enabled: boolean; +} + +export interface GroupConfig { + id: string; + name: string; + projects: ProjectConfig[]; +} + +export interface GroupsFile { + version: number; + groups: GroupConfig[]; + activeGroupId: string; +} + +/** Derive a project identifier from a name: lowercase, spaces to dashes */ +export function deriveIdentifier(name: string): string { + return name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); +} + +/** Project accent colors by slot index (0-4), Catppuccin Mocha */ +export const PROJECT_ACCENTS = [ + '--ctp-blue', + '--ctp-green', + '--ctp-mauve', + '--ctp-peach', + '--ctp-pink', +] as const;