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:
Hibryda 2026-03-07 16:06:07 +01:00
parent 293bed6dc5
commit ab79dac4b3
20 changed files with 2296 additions and 65 deletions

225
v2/src-tauri/src/groups.rs Normal file
View 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));
}
}

View file

@ -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| {

View file

@ -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)]