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

View file

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

View 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');
}

View file

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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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));
}

View 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;