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)]
|
||||
|
|
|
|||
|
|
@ -1,80 +1,73 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import SessionList from './lib/components/Sidebar/SessionList.svelte';
|
||||
import TilingGrid from './lib/components/Layout/TilingGrid.svelte';
|
||||
import StatusBar from './lib/components/StatusBar/StatusBar.svelte';
|
||||
import ToastContainer from './lib/components/Notifications/ToastContainer.svelte';
|
||||
import SettingsDialog from './lib/components/Settings/SettingsDialog.svelte';
|
||||
import { addPane, focusPaneByIndex, removePane, getPanes, restoreFromDb } from './lib/stores/layout.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { initTheme } from './lib/stores/theme.svelte';
|
||||
import { isDetachedMode, getDetachedConfig } from './lib/utils/detach';
|
||||
import { startAgentDispatcher, stopAgentDispatcher } from './lib/agent-dispatcher';
|
||||
import { loadWorkspace, getActiveTab, setActiveTab, setActiveProject, getEnabledProjects } from './lib/stores/workspace.svelte';
|
||||
|
||||
// Workspace components
|
||||
import GlobalTabBar from './lib/components/Workspace/GlobalTabBar.svelte';
|
||||
import ProjectGrid from './lib/components/Workspace/ProjectGrid.svelte';
|
||||
import DocsTab from './lib/components/Workspace/DocsTab.svelte';
|
||||
import ContextTab from './lib/components/Workspace/ContextTab.svelte';
|
||||
import SettingsTab from './lib/components/Workspace/SettingsTab.svelte';
|
||||
import CommandPalette from './lib/components/Workspace/CommandPalette.svelte';
|
||||
|
||||
// Shared
|
||||
import StatusBar from './lib/components/StatusBar/StatusBar.svelte';
|
||||
import ToastContainer from './lib/components/Notifications/ToastContainer.svelte';
|
||||
|
||||
// Detached mode (preserved from v2)
|
||||
import TerminalPane from './lib/components/Terminal/TerminalPane.svelte';
|
||||
import AgentPane from './lib/components/Agent/AgentPane.svelte';
|
||||
|
||||
let settingsOpen = $state(false);
|
||||
let detached = isDetachedMode();
|
||||
let detachedConfig = getDetachedConfig();
|
||||
import { startAgentDispatcher, stopAgentDispatcher } from './lib/agent-dispatcher';
|
||||
|
||||
function newTerminal() {
|
||||
const id = crypto.randomUUID();
|
||||
const num = getPanes().length + 1;
|
||||
addPane({
|
||||
id,
|
||||
type: 'terminal',
|
||||
title: `Terminal ${num}`,
|
||||
});
|
||||
}
|
||||
let paletteOpen = $state(false);
|
||||
let loaded = $state(false);
|
||||
|
||||
function newAgent() {
|
||||
const id = crypto.randomUUID();
|
||||
const num = getPanes().filter(p => p.type === 'agent').length + 1;
|
||||
addPane({
|
||||
id,
|
||||
type: 'agent',
|
||||
title: `Agent ${num}`,
|
||||
});
|
||||
}
|
||||
let activeTab = $derived(getActiveTab());
|
||||
|
||||
onMount(() => {
|
||||
initTheme();
|
||||
startAgentDispatcher();
|
||||
if (!detached) restoreFromDb();
|
||||
|
||||
if (!detached) {
|
||||
loadWorkspace().then(() => { loaded = true; });
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Ctrl+N — new terminal
|
||||
if (e.ctrlKey && !e.shiftKey && e.key === 'n') {
|
||||
// Ctrl+K — command palette
|
||||
if (e.ctrlKey && !e.shiftKey && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
newTerminal();
|
||||
paletteOpen = !paletteOpen;
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+N — new agent
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'N') {
|
||||
// Alt+1..4 — switch workspace tab
|
||||
if (e.altKey && !e.ctrlKey && e.key >= '1' && e.key <= '4') {
|
||||
e.preventDefault();
|
||||
newAgent();
|
||||
const tabs = ['sessions', 'docs', 'context', 'settings'] as const;
|
||||
setActiveTab(tabs[parseInt(e.key) - 1]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+1-4 — focus pane by index
|
||||
if (e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '4') {
|
||||
// Ctrl+1..5 — focus project by index
|
||||
if (e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '5') {
|
||||
e.preventDefault();
|
||||
focusPaneByIndex(parseInt(e.key) - 1);
|
||||
const projects = getEnabledProjects();
|
||||
const idx = parseInt(e.key) - 1;
|
||||
if (idx < projects.length) {
|
||||
setActiveProject(projects[idx].id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+, — settings
|
||||
// Ctrl+, — settings tab
|
||||
if (e.ctrlKey && e.key === ',') {
|
||||
e.preventDefault();
|
||||
settingsOpen = !settingsOpen;
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+W — close focused pane
|
||||
if (e.ctrlKey && !e.shiftKey && e.key === 'w') {
|
||||
e.preventDefault();
|
||||
const focused = getPanes().find(p => p.focused);
|
||||
if (focused) removePane(focused.id);
|
||||
setActiveTab('settings');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -104,15 +97,28 @@
|
|||
<TerminalPane />
|
||||
{/if}
|
||||
</div>
|
||||
{:else if loaded}
|
||||
<div class="app-shell">
|
||||
<GlobalTabBar />
|
||||
|
||||
<main class="tab-content">
|
||||
{#if activeTab === 'sessions'}
|
||||
<ProjectGrid />
|
||||
{:else if activeTab === 'docs'}
|
||||
<DocsTab />
|
||||
{:else if activeTab === 'context'}
|
||||
<ContextTab />
|
||||
{:else if activeTab === 'settings'}
|
||||
<SettingsTab />
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<StatusBar />
|
||||
</div>
|
||||
|
||||
<CommandPalette open={paletteOpen} onclose={() => paletteOpen = false} />
|
||||
{:else}
|
||||
<aside class="sidebar">
|
||||
<SessionList />
|
||||
</aside>
|
||||
<main class="workspace">
|
||||
<TilingGrid />
|
||||
</main>
|
||||
<StatusBar />
|
||||
<SettingsDialog open={settingsOpen} onClose={() => settingsOpen = false} />
|
||||
<div class="loading">Loading workspace...</div>
|
||||
{/if}
|
||||
<ToastContainer />
|
||||
|
||||
|
|
@ -120,20 +126,29 @@
|
|||
.detached-pane {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background: var(--bg-primary);
|
||||
background: var(--ctp-base);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: var(--ctp-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
background: var(--bg-primary);
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.9rem;
|
||||
background: var(--ctp-base);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
85
v2/src/lib/adapters/groups-bridge.ts
Normal file
85
v2/src/lib/adapters/groups-bridge.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { GroupsFile, ProjectConfig, GroupConfig } from '../types/groups';
|
||||
|
||||
export type { GroupsFile, ProjectConfig, GroupConfig };
|
||||
|
||||
export interface MdFileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
priority: boolean;
|
||||
}
|
||||
|
||||
export interface AgentMessageRecord {
|
||||
id: number;
|
||||
session_id: string;
|
||||
project_id: string;
|
||||
sdk_session_id: string | null;
|
||||
message_type: string;
|
||||
content: string;
|
||||
parent_id: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface ProjectAgentState {
|
||||
project_id: string;
|
||||
last_session_id: string;
|
||||
sdk_session_id: string | null;
|
||||
status: string;
|
||||
cost_usd: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
last_prompt: string | null;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
// --- Group config ---
|
||||
|
||||
export async function loadGroups(): Promise<GroupsFile> {
|
||||
return invoke('groups_load');
|
||||
}
|
||||
|
||||
export async function saveGroups(config: GroupsFile): Promise<void> {
|
||||
return invoke('groups_save', { config });
|
||||
}
|
||||
|
||||
// --- Markdown discovery ---
|
||||
|
||||
export async function discoverMarkdownFiles(cwd: string): Promise<MdFileEntry[]> {
|
||||
return invoke('discover_markdown_files', { cwd });
|
||||
}
|
||||
|
||||
// --- Agent message persistence ---
|
||||
|
||||
export async function saveAgentMessages(
|
||||
sessionId: string,
|
||||
projectId: string,
|
||||
sdkSessionId: string | undefined,
|
||||
messages: AgentMessageRecord[],
|
||||
): Promise<void> {
|
||||
return invoke('agent_messages_save', {
|
||||
sessionId,
|
||||
projectId,
|
||||
sdkSessionId: sdkSessionId ?? null,
|
||||
messages,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadAgentMessages(projectId: string): Promise<AgentMessageRecord[]> {
|
||||
return invoke('agent_messages_load', { projectId });
|
||||
}
|
||||
|
||||
// --- Project agent state ---
|
||||
|
||||
export async function saveProjectAgentState(state: ProjectAgentState): Promise<void> {
|
||||
return invoke('project_agent_state_save', { state });
|
||||
}
|
||||
|
||||
export async function loadProjectAgentState(projectId: string): Promise<ProjectAgentState | null> {
|
||||
return invoke('project_agent_state_load', { projectId });
|
||||
}
|
||||
|
||||
// --- CLI arguments ---
|
||||
|
||||
export async function getCliGroup(): Promise<string | null> {
|
||||
return invoke('cli_get_group');
|
||||
}
|
||||
|
|
@ -302,7 +302,7 @@
|
|||
<button
|
||||
class="skill-item"
|
||||
class:active={i === skillMenuIndex}
|
||||
onmousedown|preventDefault={() => handleSkillSelect(skill)}
|
||||
onmousedown={(e) => { e.preventDefault(); handleSkillSelect(skill); }}
|
||||
>
|
||||
<span class="skill-name">/{skill.name}</span>
|
||||
<span class="skill-desc">{skill.description}</span>
|
||||
|
|
|
|||
100
v2/src/lib/components/Workspace/AgentCard.svelte
Normal file
100
v2/src/lib/components/Workspace/AgentCard.svelte
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<script lang="ts">
|
||||
import type { AgentSession } from '../../stores/agents.svelte';
|
||||
|
||||
interface Props {
|
||||
session: AgentSession;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { session, onclick }: Props = $props();
|
||||
|
||||
let statusColor = $derived(
|
||||
session.status === 'running' ? 'var(--ctp-green)' :
|
||||
session.status === 'done' ? 'var(--ctp-blue)' :
|
||||
session.status === 'error' ? 'var(--ctp-red)' :
|
||||
'var(--ctp-overlay0)'
|
||||
);
|
||||
|
||||
let truncatedPrompt = $derived(
|
||||
session.prompt.length > 60
|
||||
? session.prompt.slice(0, 60) + '...'
|
||||
: session.prompt
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="agent-card" role="button" tabindex="0" {onclick} onkeydown={e => e.key === 'Enter' && onclick?.()}>
|
||||
<div class="card-header">
|
||||
<span class="status-dot" style="background: {statusColor}"></span>
|
||||
<span class="agent-status">{session.status}</span>
|
||||
{#if session.costUsd > 0}
|
||||
<span class="agent-cost">${session.costUsd.toFixed(4)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="card-prompt">{truncatedPrompt}</div>
|
||||
{#if session.status === 'running'}
|
||||
<div class="card-progress">
|
||||
<span class="turns">{session.numTurns} turns</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.agent-card {
|
||||
padding: 6px 8px;
|
||||
background: var(--ctp-surface0);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
.agent-card:hover {
|
||||
background: var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-status {
|
||||
font-size: 0.65rem;
|
||||
color: var(--ctp-overlay1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.agent-cost {
|
||||
margin-left: auto;
|
||||
font-size: 0.65rem;
|
||||
color: var(--ctp-overlay0);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.card-prompt {
|
||||
font-size: 0.72rem;
|
||||
color: var(--ctp-subtext0);
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-progress {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.turns {
|
||||
font-size: 0.65rem;
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
</style>
|
||||
82
v2/src/lib/components/Workspace/ClaudeSession.svelte
Normal file
82
v2/src/lib/components/Workspace/ClaudeSession.svelte
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { ProjectConfig } from '../../types/groups';
|
||||
import {
|
||||
loadProjectAgentState,
|
||||
saveProjectAgentState,
|
||||
loadAgentMessages,
|
||||
saveAgentMessages,
|
||||
type ProjectAgentState,
|
||||
type AgentMessageRecord,
|
||||
} from '../../adapters/groups-bridge';
|
||||
import AgentPane from '../Agent/AgentPane.svelte';
|
||||
|
||||
interface Props {
|
||||
project: ProjectConfig;
|
||||
onsessionid?: (id: string) => void;
|
||||
}
|
||||
|
||||
let { project, onsessionid }: Props = $props();
|
||||
|
||||
// Per-project session ID (stable across renders, changes with project)
|
||||
let sessionId = $state(crypto.randomUUID());
|
||||
let lastState = $state<ProjectAgentState | null>(null);
|
||||
let resumeSessionId = $state<string | undefined>(undefined);
|
||||
let loading = $state(true);
|
||||
|
||||
// Load previous session state when project changes
|
||||
$effect(() => {
|
||||
const pid = project.id;
|
||||
loadPreviousState(pid);
|
||||
});
|
||||
|
||||
async function loadPreviousState(projectId: string) {
|
||||
loading = true;
|
||||
try {
|
||||
const state = await loadProjectAgentState(projectId);
|
||||
lastState = state;
|
||||
if (state?.sdk_session_id) {
|
||||
resumeSessionId = state.sdk_session_id;
|
||||
sessionId = state.last_session_id;
|
||||
} else {
|
||||
resumeSessionId = undefined;
|
||||
sessionId = crypto.randomUUID();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load project agent state:', e);
|
||||
sessionId = crypto.randomUUID();
|
||||
} finally {
|
||||
loading = false;
|
||||
onsessionid?.(sessionId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="claude-session">
|
||||
{#if loading}
|
||||
<div class="loading-state">Loading session...</div>
|
||||
{:else}
|
||||
<AgentPane
|
||||
{sessionId}
|
||||
cwd={project.cwd}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.claude-session {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
159
v2/src/lib/components/Workspace/CommandPalette.svelte
Normal file
159
v2/src/lib/components/Workspace/CommandPalette.svelte
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<script lang="ts">
|
||||
import { getAllGroups, switchGroup, getActiveGroupId } from '../../stores/workspace.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { open, onclose }: Props = $props();
|
||||
|
||||
let query = $state('');
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
|
||||
let groups = $derived(getAllGroups());
|
||||
let filtered = $derived(
|
||||
groups.filter(g =>
|
||||
g.name.toLowerCase().includes(query.toLowerCase()),
|
||||
),
|
||||
);
|
||||
let activeGroupId = $derived(getActiveGroupId());
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
query = '';
|
||||
// Focus input after render
|
||||
requestAnimationFrame(() => inputEl?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
function selectGroup(groupId: string) {
|
||||
switchGroup(groupId);
|
||||
onclose();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onclose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="palette-backdrop" onclick={onclose} onkeydown={handleKeydown}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="palette" onclick={(e) => e.stopPropagation()} onkeydown={handleKeydown}>
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
class="palette-input"
|
||||
placeholder="Switch group..."
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
<ul class="palette-results">
|
||||
{#each filtered as group}
|
||||
<li>
|
||||
<button
|
||||
class="palette-item"
|
||||
class:active={group.id === activeGroupId}
|
||||
onclick={() => selectGroup(group.id)}
|
||||
>
|
||||
<span class="group-name">{group.name}</span>
|
||||
<span class="project-count">{group.projects.length} projects</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{#if filtered.length === 0}
|
||||
<li class="no-results">No groups match "{query}"</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.palette-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 15vh;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.palette {
|
||||
width: 460px;
|
||||
max-height: 360px;
|
||||
background: var(--ctp-mantle);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.palette-input {
|
||||
padding: 12px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.palette-input::placeholder {
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.palette-results {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.palette-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.palette-item:hover {
|
||||
background: var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.palette-item.active {
|
||||
background: var(--ctp-surface0);
|
||||
border-left: 3px solid var(--ctp-blue);
|
||||
}
|
||||
|
||||
.group-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.project-count {
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 12px;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
14
v2/src/lib/components/Workspace/ContextTab.svelte
Normal file
14
v2/src/lib/components/Workspace/ContextTab.svelte
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script lang="ts">
|
||||
import ContextPane from '../Context/ContextPane.svelte';
|
||||
</script>
|
||||
|
||||
<div class="context-tab">
|
||||
<ContextPane onExit={() => {}} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.context-tab {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
159
v2/src/lib/components/Workspace/DocsTab.svelte
Normal file
159
v2/src/lib/components/Workspace/DocsTab.svelte
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<script lang="ts">
|
||||
import { getActiveProjectId, getActiveGroup } from '../../stores/workspace.svelte';
|
||||
import { discoverMarkdownFiles, type MdFileEntry } from '../../adapters/groups-bridge';
|
||||
import MarkdownPane from '../Markdown/MarkdownPane.svelte';
|
||||
|
||||
let files = $state<MdFileEntry[]>([]);
|
||||
let selectedPath = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
|
||||
let activeProjectId = $derived(getActiveProjectId());
|
||||
let activeGroup = $derived(getActiveGroup());
|
||||
let activeProject = $derived(
|
||||
activeGroup?.projects.find(p => p.id === activeProjectId),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const project = activeProject;
|
||||
if (project) {
|
||||
loadFiles(project.cwd);
|
||||
} else {
|
||||
files = [];
|
||||
selectedPath = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadFiles(cwd: string) {
|
||||
loading = true;
|
||||
try {
|
||||
files = await discoverMarkdownFiles(cwd);
|
||||
// Auto-select first priority file
|
||||
const priority = files.find(f => f.priority);
|
||||
selectedPath = priority?.path ?? files[0]?.path ?? null;
|
||||
} catch (e) {
|
||||
console.warn('Failed to discover markdown files:', e);
|
||||
files = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="docs-tab">
|
||||
<aside class="file-picker">
|
||||
<h3 class="picker-title">
|
||||
{activeProject?.name ?? 'No project'} — Docs
|
||||
</h3>
|
||||
{#if loading}
|
||||
<div class="loading">Scanning...</div>
|
||||
{:else if files.length === 0}
|
||||
<div class="empty">No markdown files found</div>
|
||||
{:else}
|
||||
<ul class="file-list">
|
||||
{#each files as file}
|
||||
<li>
|
||||
<button
|
||||
class="file-btn"
|
||||
class:active={selectedPath === file.path}
|
||||
class:priority={file.priority}
|
||||
onclick={() => (selectedPath = file.path)}
|
||||
>
|
||||
{file.name}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<main class="doc-content">
|
||||
{#if selectedPath}
|
||||
<MarkdownPane paneId="docs-viewer" filePath={selectedPath} />
|
||||
{:else}
|
||||
<div class="no-selection">Select a document from the sidebar</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.docs-tab {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-picker {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
background: var(--ctp-mantle);
|
||||
border-right: 1px solid var(--ctp-surface0);
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.picker-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext0);
|
||||
padding: 4px 12px 8px;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.file-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 5px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: color 0.1s, background 0.1s;
|
||||
}
|
||||
|
||||
.file-btn:hover {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.file-btn.active {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.file-btn.priority {
|
||||
color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.file-btn.priority.active {
|
||||
color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.doc-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.loading, .empty, .no-selection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.85rem;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.no-selection {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
56
v2/src/lib/components/Workspace/GlobalTabBar.svelte
Normal file
56
v2/src/lib/components/Workspace/GlobalTabBar.svelte
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<script lang="ts">
|
||||
import { getActiveTab, setActiveTab, type WorkspaceTab } from '../../stores/workspace.svelte';
|
||||
|
||||
const tabs: { id: WorkspaceTab; label: string; shortcut: string }[] = [
|
||||
{ id: 'sessions', label: 'Sessions', shortcut: 'Alt+1' },
|
||||
{ id: 'docs', label: 'Docs', shortcut: 'Alt+2' },
|
||||
{ id: 'context', label: 'Context', shortcut: 'Alt+3' },
|
||||
{ id: 'settings', label: 'Settings', shortcut: 'Alt+4' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<nav class="global-tab-bar">
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
class="tab-btn"
|
||||
class:active={getActiveTab() === tab.id}
|
||||
onclick={() => setActiveTab(tab.id)}
|
||||
title={tab.shortcut}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.global-tab-bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
background: var(--ctp-mantle);
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 4px 14px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px 4px 0 0;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--ctp-text);
|
||||
background: var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--ctp-text);
|
||||
background: var(--ctp-base);
|
||||
border-bottom: 2px solid var(--ctp-blue);
|
||||
}
|
||||
</style>
|
||||
77
v2/src/lib/components/Workspace/ProjectBox.svelte
Normal file
77
v2/src/lib/components/Workspace/ProjectBox.svelte
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<script lang="ts">
|
||||
import type { ProjectConfig } from '../../types/groups';
|
||||
import { PROJECT_ACCENTS } from '../../types/groups';
|
||||
import ProjectHeader from './ProjectHeader.svelte';
|
||||
import ClaudeSession from './ClaudeSession.svelte';
|
||||
import TerminalTabs from './TerminalTabs.svelte';
|
||||
import TeamAgentsPanel from './TeamAgentsPanel.svelte';
|
||||
|
||||
interface Props {
|
||||
project: ProjectConfig;
|
||||
slotIndex: number;
|
||||
active: boolean;
|
||||
onactivate: () => void;
|
||||
}
|
||||
|
||||
let { project, slotIndex, active, onactivate }: Props = $props();
|
||||
|
||||
let accentVar = $derived(PROJECT_ACCENTS[slotIndex % PROJECT_ACCENTS.length]);
|
||||
let mainSessionId = $state<string | null>(null);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="project-box"
|
||||
class:active
|
||||
style="--accent: var({accentVar})"
|
||||
>
|
||||
<ProjectHeader
|
||||
{project}
|
||||
{slotIndex}
|
||||
{active}
|
||||
onclick={onactivate}
|
||||
/>
|
||||
|
||||
<div class="project-session-area">
|
||||
<ClaudeSession {project} onsessionid={(id) => mainSessionId = id} />
|
||||
{#if mainSessionId}
|
||||
<TeamAgentsPanel {mainSessionId} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="project-terminal-area">
|
||||
<TerminalTabs {project} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.project-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 480px;
|
||||
scroll-snap-align: start;
|
||||
background: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.project-box.active {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.project-session-area {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.project-terminal-area {
|
||||
flex-shrink: 0;
|
||||
min-height: 120px;
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
</style>
|
||||
85
v2/src/lib/components/Workspace/ProjectGrid.svelte
Normal file
85
v2/src/lib/components/Workspace/ProjectGrid.svelte
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getEnabledProjects, getActiveProjectId, setActiveProject } from '../../stores/workspace.svelte';
|
||||
import ProjectBox from './ProjectBox.svelte';
|
||||
|
||||
let containerEl: HTMLDivElement | undefined = $state();
|
||||
let containerWidth = $state(0);
|
||||
|
||||
let projects = $derived(getEnabledProjects());
|
||||
let activeProjectId = $derived(getActiveProjectId());
|
||||
let visibleCount = $derived(
|
||||
Math.min(projects.length, Math.max(1, Math.floor(containerWidth / 520))),
|
||||
);
|
||||
|
||||
let observer: ResizeObserver | undefined;
|
||||
|
||||
onMount(() => {
|
||||
if (containerEl) {
|
||||
containerWidth = containerEl.clientWidth;
|
||||
observer = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
containerWidth = entry.contentRect.width;
|
||||
}
|
||||
});
|
||||
observer.observe(containerEl);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
observer?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="project-grid"
|
||||
bind:this={containerEl}
|
||||
style="--visible-count: {visibleCount}"
|
||||
>
|
||||
{#each projects as project, i (project.id)}
|
||||
<div class="project-slot">
|
||||
<ProjectBox
|
||||
{project}
|
||||
slotIndex={i}
|
||||
active={activeProjectId === project.id}
|
||||
onactivate={() => setActiveProject(project.id)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if projects.length === 0}
|
||||
<div class="empty-state">
|
||||
No enabled projects in this group. Go to Settings to add projects.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.project-grid {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.project-slot {
|
||||
flex: 0 0 calc((100% - (var(--visible-count) - 1) * 4px) / var(--visible-count));
|
||||
min-width: 480px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.project-slot > :global(*) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
74
v2/src/lib/components/Workspace/ProjectHeader.svelte
Normal file
74
v2/src/lib/components/Workspace/ProjectHeader.svelte
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<script lang="ts">
|
||||
import type { ProjectConfig } from '../../types/groups';
|
||||
import { PROJECT_ACCENTS } from '../../types/groups';
|
||||
|
||||
interface Props {
|
||||
project: ProjectConfig;
|
||||
slotIndex: number;
|
||||
active: boolean;
|
||||
onclick: () => void;
|
||||
}
|
||||
|
||||
let { project, slotIndex, active, onclick }: Props = $props();
|
||||
|
||||
let accentVar = $derived(PROJECT_ACCENTS[slotIndex % PROJECT_ACCENTS.length]);
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="project-header"
|
||||
class:active
|
||||
style="--accent: var({accentVar})"
|
||||
{onclick}
|
||||
>
|
||||
<span class="project-icon">{project.icon || '\uf120'}</span>
|
||||
<span class="project-name">{project.name}</span>
|
||||
<span class="project-id">({project.identifier})</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.project-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
height: 28px;
|
||||
background: var(--ctp-mantle);
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.project-header:hover {
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.project-header.active {
|
||||
color: var(--ctp-text);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.project-icon {
|
||||
font-family: 'NerdFontsSymbols Nerd Font', 'Symbols Nerd Font Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.project-id {
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.7rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
275
v2/src/lib/components/Workspace/SettingsTab.svelte
Normal file
275
v2/src/lib/components/Workspace/SettingsTab.svelte
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
getActiveProjectId,
|
||||
getActiveGroup,
|
||||
getActiveGroupId,
|
||||
getAllGroups,
|
||||
updateProject,
|
||||
addProject,
|
||||
removeProject,
|
||||
addGroup,
|
||||
removeGroup,
|
||||
switchGroup,
|
||||
} from '../../stores/workspace.svelte';
|
||||
import { deriveIdentifier } from '../../types/groups';
|
||||
|
||||
let activeGroupId = $derived(getActiveGroupId());
|
||||
let activeGroup = $derived(getActiveGroup());
|
||||
let activeProjectId = $derived(getActiveProjectId());
|
||||
let groups = $derived(getAllGroups());
|
||||
|
||||
let editingProject = $derived(
|
||||
activeGroup?.projects.find(p => p.id === activeProjectId),
|
||||
);
|
||||
|
||||
// New project form
|
||||
let newName = $state('');
|
||||
let newCwd = $state('');
|
||||
|
||||
function handleAddProject() {
|
||||
if (!newName.trim() || !newCwd.trim() || !activeGroupId) return;
|
||||
const id = crypto.randomUUID();
|
||||
addProject(activeGroupId, {
|
||||
id,
|
||||
name: newName.trim(),
|
||||
identifier: deriveIdentifier(newName.trim()),
|
||||
description: '',
|
||||
icon: '\uf120',
|
||||
cwd: newCwd.trim(),
|
||||
profile: 'default',
|
||||
enabled: true,
|
||||
});
|
||||
newName = '';
|
||||
newCwd = '';
|
||||
}
|
||||
|
||||
// New group form
|
||||
let newGroupName = $state('');
|
||||
|
||||
function handleAddGroup() {
|
||||
if (!newGroupName.trim()) return;
|
||||
const id = crypto.randomUUID();
|
||||
addGroup({ id, name: newGroupName.trim(), projects: [] });
|
||||
newGroupName = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="settings-tab">
|
||||
<section class="settings-section">
|
||||
<h2>Groups</h2>
|
||||
<div class="group-list">
|
||||
{#each groups as group}
|
||||
<div class="group-row" class:active={group.id === activeGroupId}>
|
||||
<button class="group-name" onclick={() => switchGroup(group.id)}>
|
||||
{group.name}
|
||||
</button>
|
||||
<span class="group-count">{group.projects.length} projects</span>
|
||||
{#if groups.length > 1}
|
||||
<button class="btn-danger" onclick={() => removeGroup(group.id)}>Remove</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="add-form">
|
||||
<input bind:value={newGroupName} placeholder="New group name" />
|
||||
<button class="btn-primary" onclick={handleAddGroup} disabled={!newGroupName.trim()}>
|
||||
Add Group
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if activeGroup}
|
||||
<section class="settings-section">
|
||||
<h2>Projects in "{activeGroup.name}"</h2>
|
||||
|
||||
{#each activeGroup.projects as project}
|
||||
<div class="project-settings-row">
|
||||
<div class="project-field">
|
||||
<label>Name</label>
|
||||
<input
|
||||
value={project.name}
|
||||
onchange={e => updateProject(activeGroupId, project.id, { name: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</div>
|
||||
<div class="project-field">
|
||||
<label>CWD</label>
|
||||
<input
|
||||
value={project.cwd}
|
||||
onchange={e => updateProject(activeGroupId, project.id, { cwd: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</div>
|
||||
<div class="project-field">
|
||||
<label>Icon</label>
|
||||
<input
|
||||
value={project.icon}
|
||||
onchange={e => updateProject(activeGroupId, project.id, { icon: (e.target as HTMLInputElement).value })}
|
||||
style="width: 60px"
|
||||
/>
|
||||
</div>
|
||||
<div class="project-field">
|
||||
<label>Enabled</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={project.enabled}
|
||||
onchange={e => updateProject(activeGroupId, project.id, { enabled: (e.target as HTMLInputElement).checked })}
|
||||
/>
|
||||
</div>
|
||||
<button class="btn-danger" onclick={() => removeProject(activeGroupId, project.id)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if activeGroup.projects.length < 5}
|
||||
<div class="add-form">
|
||||
<input bind:value={newName} placeholder="Project name" />
|
||||
<input bind:value={newCwd} placeholder="/path/to/project" />
|
||||
<button class="btn-primary" onclick={handleAddProject} disabled={!newName.trim() || !newCwd.trim()}>
|
||||
Add Project
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="limit-notice">Maximum 5 projects per group reached.</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-tab {
|
||||
padding: 16px 24px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.group-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.group-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--ctp-surface0);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.group-row.active {
|
||||
border-left: 3px solid var(--ctp-blue);
|
||||
}
|
||||
|
||||
.group-name {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.project-settings-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--ctp-surface0);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.project-field label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--ctp-overlay0);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.project-field input[type="text"],
|
||||
.project-field input:not([type]) {
|
||||
padding: 4px 8px;
|
||||
background: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 3px;
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.add-form input {
|
||||
padding: 5px 10px;
|
||||
background: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 3px;
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.8rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 5px 14px;
|
||||
background: var(--ctp-blue);
|
||||
color: var(--ctp-base);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
padding: 4px 10px;
|
||||
background: transparent;
|
||||
color: var(--ctp-red);
|
||||
border: 1px solid var(--ctp-red);
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.limit-notice {
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
91
v2/src/lib/components/Workspace/TeamAgentsPanel.svelte
Normal file
91
v2/src/lib/components/Workspace/TeamAgentsPanel.svelte
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<script lang="ts">
|
||||
import { getAgentSessions, getChildSessions, type AgentSession } from '../../stores/agents.svelte';
|
||||
import AgentCard from './AgentCard.svelte';
|
||||
|
||||
interface Props {
|
||||
/** The main Claude session ID for this project */
|
||||
mainSessionId: string;
|
||||
}
|
||||
|
||||
let { mainSessionId }: Props = $props();
|
||||
|
||||
// Get subagent sessions spawned by the main session
|
||||
let childSessions = $derived(getChildSessions(mainSessionId));
|
||||
let hasAgents = $derived(childSessions.length > 0);
|
||||
let expanded = $state(true);
|
||||
</script>
|
||||
|
||||
{#if hasAgents}
|
||||
<div class="team-agents-panel">
|
||||
<button class="panel-header" onclick={() => expanded = !expanded}>
|
||||
<span class="header-icon">{expanded ? '▾' : '▸'}</span>
|
||||
<span class="header-title">Team Agents</span>
|
||||
<span class="agent-count">{childSessions.length}</span>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="agent-list">
|
||||
{#each childSessions as child (child.id)}
|
||||
<AgentCard session={child} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.team-agents-panel {
|
||||
border-left: 1px solid var(--ctp-surface0);
|
||||
background: var(--ctp-mantle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.panel-header:hover {
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.agent-count {
|
||||
margin-left: auto;
|
||||
background: var(--ctp-surface0);
|
||||
padding: 0 5px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.65rem;
|
||||
color: var(--ctp-overlay1);
|
||||
}
|
||||
|
||||
.agent-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 4px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
217
v2/src/lib/components/Workspace/TerminalTabs.svelte
Normal file
217
v2/src/lib/components/Workspace/TerminalTabs.svelte
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
<script lang="ts">
|
||||
import type { ProjectConfig } from '../../types/groups';
|
||||
import {
|
||||
getTerminalTabs,
|
||||
addTerminalTab,
|
||||
removeTerminalTab,
|
||||
type TerminalTab,
|
||||
} from '../../stores/workspace.svelte';
|
||||
import TerminalPane from '../Terminal/TerminalPane.svelte';
|
||||
|
||||
interface Props {
|
||||
project: ProjectConfig;
|
||||
}
|
||||
|
||||
let { project }: Props = $props();
|
||||
|
||||
let tabs = $derived(getTerminalTabs(project.id));
|
||||
let activeTabId = $state<string | null>(null);
|
||||
|
||||
// Auto-select first tab
|
||||
$effect(() => {
|
||||
if (tabs.length > 0 && (!activeTabId || !tabs.find(t => t.id === activeTabId))) {
|
||||
activeTabId = tabs[0].id;
|
||||
}
|
||||
if (tabs.length === 0) {
|
||||
activeTabId = null;
|
||||
}
|
||||
});
|
||||
|
||||
function addShellTab() {
|
||||
const id = crypto.randomUUID();
|
||||
const num = tabs.filter(t => t.type === 'shell').length + 1;
|
||||
addTerminalTab(project.id, {
|
||||
id,
|
||||
title: `Shell ${num}`,
|
||||
type: 'shell',
|
||||
});
|
||||
activeTabId = id;
|
||||
}
|
||||
|
||||
function closeTab(tabId: string) {
|
||||
removeTerminalTab(project.id, tabId);
|
||||
}
|
||||
|
||||
function handleTabExit(tabId: string) {
|
||||
closeTab(tabId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="terminal-tabs">
|
||||
<div class="tab-bar">
|
||||
{#each tabs as tab (tab.id)}
|
||||
<div
|
||||
class="tab"
|
||||
class:active={activeTabId === tab.id}
|
||||
role="tab"
|
||||
tabindex="0"
|
||||
onclick={() => (activeTabId = tab.id)}
|
||||
onkeydown={e => e.key === 'Enter' && (activeTabId = tab.id)}
|
||||
>
|
||||
<span class="tab-title">{tab.title}</span>
|
||||
<button
|
||||
class="tab-close"
|
||||
onclick={(e) => { e.stopPropagation(); closeTab(tab.id); }}
|
||||
title="Close"
|
||||
>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button class="tab-add" onclick={addShellTab} title="New shell (Ctrl+N)">+</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
{#each tabs as tab (tab.id)}
|
||||
<div class="tab-pane" class:visible={activeTabId === tab.id}>
|
||||
{#if activeTabId === tab.id}
|
||||
<TerminalPane
|
||||
cwd={project.cwd}
|
||||
shell={tab.type === 'ssh' ? '/usr/bin/ssh' : undefined}
|
||||
onExit={() => handleTabExit(tab.id)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if tabs.length === 0}
|
||||
<div class="empty-terminals">
|
||||
<button class="add-first" onclick={addShellTab}>
|
||||
+ Open terminal
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.terminal-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
height: 26px;
|
||||
padding: 0 4px;
|
||||
background: var(--ctp-mantle);
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
border-radius: 3px 3px 0 0;
|
||||
white-space: nowrap;
|
||||
transition: color 0.1s, background 0.1s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--ctp-text);
|
||||
background: var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--ctp-text);
|
||||
background: var(--ctp-base);
|
||||
border-bottom: 1px solid var(--ctp-blue);
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-family: 'NerdFontsSymbols Nerd Font', 'Symbols Nerd Font Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.tab-add {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.tab-add:hover {
|
||||
color: var(--ctp-text);
|
||||
background: var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-pane {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-pane.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty-terminals {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.add-first {
|
||||
padding: 6px 16px;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 4px;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-first:hover {
|
||||
background: var(--ctp-surface1);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
</style>
|
||||
216
v2/src/lib/stores/workspace.svelte.ts
Normal file
216
v2/src/lib/stores/workspace.svelte.ts
Normal file
|
|
@ -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<GroupsFile | null>(null);
|
||||
let activeGroupId = $state<string>('');
|
||||
let activeTab = $state<WorkspaceTab>('sessions');
|
||||
let activeProjectId = $state<string | null>(null);
|
||||
|
||||
/** Terminal tabs per project (keyed by project ID) */
|
||||
let projectTerminals = $state<Map<string, TerminalTab[]>>(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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<ProjectConfig>): 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));
|
||||
}
|
||||
36
v2/src/lib/types/groups.ts
Normal file
36
v2/src/lib/types/groups.ts
Normal file
|
|
@ -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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue