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).
This commit is contained in:
parent
293bed6dc5
commit
ab79dac4b3
20 changed files with 2296 additions and 65 deletions
225
v2/src-tauri/src/groups.rs
Normal file
225
v2/src-tauri/src/groups.rs
Normal file
|
|
@ -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<ProjectConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GroupsFile {
|
||||
pub version: u32,
|
||||
pub groups: Vec<GroupConfig>,
|
||||
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<GroupsFile, String> {
|
||||
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<Vec<MdFileEntry>, 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<MdFileEntry>, 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, String> {
|
|||
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<GroupsFile, String> {
|
||||
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<Vec<MdFileEntry>, 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<String>,
|
||||
messages: Vec<AgentMessageRecord>,
|
||||
) -> 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<Vec<AgentMessageRecord>, 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<Option<ProjectAgentState>, String> {
|
||||
state.session_db.load_project_agent_state(&project_id)
|
||||
}
|
||||
|
||||
// --- Directory picker command ---
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -369,6 +430,25 @@ fn pick_directory() -> Option<String> {
|
|||
None
|
||||
}
|
||||
|
||||
// --- CLI argument commands ---
|
||||
|
||||
#[tauri::command]
|
||||
fn cli_get_group() -> Option<String> {
|
||||
let args: Vec<String> = 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| {
|
||||
|
|
|
|||
|
|
@ -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<Vec<AgentMessageRecord>, 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::<Result<Vec<_>, _>>()
|
||||
.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<Option<ProjectAgentState>, 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<String>,
|
||||
pub message_type: String,
|
||||
pub content: String,
|
||||
pub parent_id: Option<String>,
|
||||
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<String>,
|
||||
pub status: String,
|
||||
pub cost_usd: f64,
|
||||
pub input_tokens: i64,
|
||||
pub output_tokens: i64,
|
||||
pub last_prompt: Option<String>,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue