Merge pull request #2864 from ljm3790865/feat/tab-core-narrow
feat(tui): add multi-tab system core (manager + persistence)
This commit is contained in:
@@ -271,7 +271,7 @@ impl WindowsJob {
|
||||
)
|
||||
.map_err(windows_io_error)?;
|
||||
|
||||
let process_handle = HANDLE(child.as_raw_handle() as *mut core::ffi::c_void);
|
||||
let process_handle = HANDLE(child.as_raw_handle());
|
||||
AssignProcessToJobObject(job.handle, process_handle).map_err(windows_io_error)?;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ pub mod slash_menu;
|
||||
pub mod streaming;
|
||||
pub mod streaming_thinking;
|
||||
mod subagent_routing;
|
||||
pub mod tab;
|
||||
pub mod theme_picker;
|
||||
mod tool_routing;
|
||||
pub mod transcript;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Performance benchmarks for the tab system.
|
||||
//!
|
||||
//! These tests are not assertions — they print timing info to stderr and
|
||||
//! return success. Run with `--nocapture` to see the numbers.
|
||||
//!
|
||||
//! Run with: `cargo test tui::tab::benches -- --nocapture --test-threads=1`
|
||||
//!
|
||||
//! These benchmarks guard against performance regressions in the
|
||||
//! critical-path operations of the multi-tab system:
|
||||
//!
|
||||
//! - TabManager creation and tab creation (startup overhead)
|
||||
//! - Tab switching (interactive latency)
|
||||
//! - Delegation queue operations (background processing)
|
||||
//! - Persistence save/load (startup + shutdown overhead)
|
||||
//! - Group color rendering (per-frame work in tab bar)
|
||||
|
||||
#![allow(unused_imports)]
|
||||
|
||||
#[cfg(test)]
|
||||
mod benches {
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::tui::tab::{
|
||||
Priority, TabId, TabManager, TabType, group::GroupColor, persistence::PersistedTab,
|
||||
persistence::PersistedTabState,
|
||||
};
|
||||
|
||||
/// Helper: print a timing result
|
||||
fn report(label: &str, dur: std::time::Duration, ops: usize) {
|
||||
let per_op_ns = if ops > 0 {
|
||||
dur.as_nanos() / ops as u128
|
||||
} else {
|
||||
0
|
||||
};
|
||||
eprintln!(
|
||||
"[bench] {:50} total={:>8.2?} ops={:>6} per_op={:>7} ns",
|
||||
label, dur, ops, per_op_ns
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bench_create_tabs() {
|
||||
let start = Instant::now();
|
||||
let mut manager = TabManager::new();
|
||||
for i in 0..9 {
|
||||
manager
|
||||
.create_tab(format!("Bench Tab {}", i), TabType::Chat)
|
||||
.expect("create_tab should succeed");
|
||||
}
|
||||
report("create 9 tabs", start.elapsed(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bench_switch_tabs() {
|
||||
let mut manager = TabManager::new();
|
||||
for i in 0..9 {
|
||||
manager
|
||||
.create_tab(format!("Tab {}", i), TabType::Chat)
|
||||
.expect("create_tab");
|
||||
}
|
||||
let start = Instant::now();
|
||||
for _ in 0..1000 {
|
||||
manager.switch_to_next();
|
||||
}
|
||||
report("1000 tab switches (next)", start.elapsed(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bench_delegate_many_tasks() {
|
||||
let mut manager = TabManager::new();
|
||||
let from = manager
|
||||
.create_tab("Source".to_string(), TabType::Chat)
|
||||
.expect("create");
|
||||
let to = manager
|
||||
.create_tab("Target".to_string(), TabType::Chat)
|
||||
.expect("create");
|
||||
|
||||
let start = Instant::now();
|
||||
for i in 0..1000 {
|
||||
manager.delegate_task(from, to, format!("Task {}", i), Priority::Normal);
|
||||
}
|
||||
report("1000 delegations", start.elapsed(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bench_take_pending_priority() {
|
||||
let mut manager = TabManager::new();
|
||||
let from = manager.create_tab("S".to_string(), TabType::Chat).unwrap();
|
||||
let to = manager.create_tab("T".to_string(), TabType::Chat).unwrap();
|
||||
|
||||
// Create 100 tasks with mixed priorities
|
||||
let priorities = [
|
||||
Priority::Low,
|
||||
Priority::Normal,
|
||||
Priority::High,
|
||||
Priority::Urgent,
|
||||
];
|
||||
for i in 0..100 {
|
||||
manager.delegate_task(from, to, format!("Task {}", i), priorities[i % 4]);
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
let mut count = 0;
|
||||
while manager.take_next_delegation(to).is_some() {
|
||||
count += 1;
|
||||
}
|
||||
report(
|
||||
&format!("drain {} priority-sorted tasks", count),
|
||||
start.elapsed(),
|
||||
count,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bench_persistence_roundtrip() {
|
||||
let mut manager = TabManager::new();
|
||||
for i in 0..9 {
|
||||
manager
|
||||
.create_tab(format!("Tab {}", i), TabType::Chat)
|
||||
.expect("create");
|
||||
}
|
||||
|
||||
// Add some delegations
|
||||
let from = manager.active_id().unwrap();
|
||||
let to = manager.all_tabs()[1].id;
|
||||
for i in 0..20 {
|
||||
manager.delegate_task(from, to, format!("Task {}", i), Priority::Normal);
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
let state = manager.snapshot();
|
||||
let snap_dur = start.elapsed();
|
||||
|
||||
let json = serde_json::to_string(&state).unwrap();
|
||||
let ser_dur = start.elapsed() - snap_dur;
|
||||
|
||||
let start2 = Instant::now();
|
||||
let _loaded: PersistedTabState = serde_json::from_str(&json).unwrap();
|
||||
let de_dur = start2.elapsed();
|
||||
|
||||
eprintln!(
|
||||
"[bench] {:50} snap={:>8.2?} ser={:>8.2?} de={:>8.2?} json_size={} bytes",
|
||||
"9 tabs + 20 delegations persistence",
|
||||
snap_dur,
|
||||
ser_dur,
|
||||
de_dur,
|
||||
json.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bench_group_operations() {
|
||||
let mut manager = TabManager::new();
|
||||
for i in 0..9 {
|
||||
manager
|
||||
.create_tab(format!("Tab {}", i), TabType::Chat)
|
||||
.expect("create");
|
||||
}
|
||||
let tabs: Vec<TabId> = manager.all_tabs().iter().map(|t| t.id).collect();
|
||||
|
||||
// Create 3 groups
|
||||
let start = Instant::now();
|
||||
let g1 = manager.create_group("Frontend".to_string(), GroupColor::Blue);
|
||||
let g2 = manager.create_group("Backend".to_string(), GroupColor::Red);
|
||||
let g3 = manager.create_group("Misc".to_string(), GroupColor::Green);
|
||||
report("create 3 groups", start.elapsed(), 3);
|
||||
|
||||
// Assign all 9 tabs to groups
|
||||
let start = Instant::now();
|
||||
for (i, tab) in tabs.iter().enumerate() {
|
||||
let group = if i % 3 == 0 {
|
||||
&g1
|
||||
} else if i % 3 == 1 {
|
||||
&g2
|
||||
} else {
|
||||
&g3
|
||||
};
|
||||
manager.assign_tab_to_group(*tab, group);
|
||||
}
|
||||
report("assign 9 tabs to groups", start.elapsed(), 9);
|
||||
|
||||
// Lookup
|
||||
let start = Instant::now();
|
||||
for tab in &tabs {
|
||||
let _ = manager.tab_group(*tab);
|
||||
}
|
||||
report("9 group lookups", start.elapsed(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bench_render_at_widths() {
|
||||
// Smoke test: ensure rendering at various widths completes quickly
|
||||
for width in [20, 40, 80, 120, 200] {
|
||||
let mut manager = TabManager::new();
|
||||
for i in 0..9 {
|
||||
manager
|
||||
.create_tab(format!("Tab {}", i), TabType::Chat)
|
||||
.expect("create");
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
// Simulate the per-frame work the tab bar does: iterate all tabs
|
||||
// and gather metadata for display. Real rendering also iterates.
|
||||
let mut count = 0;
|
||||
for tab in manager.all_tabs() {
|
||||
// Touch a few fields to simulate display work
|
||||
let _ = tab.title.len();
|
||||
let _ = tab.id.0;
|
||||
count += 1;
|
||||
}
|
||||
report(
|
||||
&format!("iterate {} tabs at width {}", count, width),
|
||||
start.elapsed(),
|
||||
count,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Cross-tab collaboration events
|
||||
|
||||
// WIP collaboration surface — narrow harvest. See `tab/mod.rs` for the
|
||||
// PR #2753 context.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use super::{Priority, TabId};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A link between two tabs for collaboration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CrossTabLink {
|
||||
pub from: TabId,
|
||||
pub to: TabId,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Cross-tab event types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum CrossTabEvent {
|
||||
/// Task delegation request
|
||||
TaskDelegation {
|
||||
task_id: String,
|
||||
from_tab: TabId,
|
||||
to_tab: TabId,
|
||||
description: String,
|
||||
priority: Priority,
|
||||
created_at: DateTime<Utc>,
|
||||
},
|
||||
/// Review request
|
||||
ReviewRequest {
|
||||
request_id: String,
|
||||
from_tab: TabId,
|
||||
to_tab: TabId,
|
||||
content_ref: String,
|
||||
criteria: Vec<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
},
|
||||
/// Meeting invitation
|
||||
MeetingInvite {
|
||||
meeting_id: String,
|
||||
from_tab: TabId,
|
||||
participants: Vec<TabId>,
|
||||
topic: String,
|
||||
created_at: DateTime<Utc>,
|
||||
},
|
||||
/// Result returned from delegation
|
||||
ResultReturn {
|
||||
task_id: String,
|
||||
from_tab: TabId,
|
||||
to_tab: TabId,
|
||||
result: String,
|
||||
created_at: DateTime<Utc>,
|
||||
},
|
||||
/// Context sync between tabs
|
||||
ContextSync {
|
||||
tab_ids: Vec<TabId>,
|
||||
changes: Vec<ContextChange>,
|
||||
created_at: DateTime<Utc>,
|
||||
},
|
||||
}
|
||||
|
||||
impl CrossTabEvent {
|
||||
/// Get the sender tab ID
|
||||
#[allow(clippy::wrong_self_convention)] // getter, not a constructor
|
||||
pub fn from_tab(&self) -> TabId {
|
||||
match self {
|
||||
Self::TaskDelegation { from_tab, .. } => *from_tab,
|
||||
Self::ReviewRequest { from_tab, .. } => *from_tab,
|
||||
Self::MeetingInvite { from_tab, .. } => *from_tab,
|
||||
Self::ResultReturn { from_tab, .. } => *from_tab,
|
||||
// ContextSync has no single sender; return the first participant
|
||||
// if any, otherwise a sentinel `TabId(0)`. Callers that need to
|
||||
// distinguish "no sender" should match on the variant directly.
|
||||
Self::ContextSync { tab_ids, .. } => tab_ids.first().copied().unwrap_or(TabId(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the target tab ID (if applicable)
|
||||
pub fn to_tab(&self) -> Option<TabId> {
|
||||
match self {
|
||||
Self::TaskDelegation { to_tab, .. } => Some(*to_tab),
|
||||
Self::ReviewRequest { to_tab, .. } => Some(*to_tab),
|
||||
Self::MeetingInvite { participants, .. } => participants.first().copied(),
|
||||
Self::ResultReturn { to_tab, .. } => Some(*to_tab),
|
||||
Self::ContextSync { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get creation timestamp
|
||||
pub fn created_at(&self) -> DateTime<Utc> {
|
||||
match self {
|
||||
Self::TaskDelegation { created_at, .. } => *created_at,
|
||||
Self::ReviewRequest { created_at, .. } => *created_at,
|
||||
Self::MeetingInvite { created_at, .. } => *created_at,
|
||||
Self::ResultReturn { created_at, .. } => *created_at,
|
||||
Self::ContextSync { created_at, .. } => *created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Type of context change
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ContextChangeType {
|
||||
VariableSet(String),
|
||||
VariableRemoved(String),
|
||||
MessageAdded,
|
||||
FileModified(String),
|
||||
StateUpdate,
|
||||
}
|
||||
|
||||
/// A change in shared context
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContextChange {
|
||||
pub change_type: ContextChangeType,
|
||||
pub value: Option<String>,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl ContextChange {
|
||||
pub fn variable_set(name: &str, value: &str) -> Self {
|
||||
Self {
|
||||
change_type: ContextChangeType::VariableSet(name.to_string()),
|
||||
value: Some(value.to_string()),
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn file_modified(path: &str) -> Self {
|
||||
Self {
|
||||
change_type: ContextChangeType::FileModified(path.to_string()),
|
||||
value: None,
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared context between linked tabs
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SharedContext {
|
||||
pub participants: Vec<TabId>,
|
||||
pub shared_variables: HashMap<String, String>,
|
||||
pub shared_messages: Vec<SharedMessage>,
|
||||
pub meeting_notes: Vec<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl SharedContext {
|
||||
pub fn new(participants: Vec<TabId>) -> Self {
|
||||
Self {
|
||||
participants,
|
||||
shared_variables: HashMap::new(),
|
||||
shared_messages: Vec::new(),
|
||||
meeting_notes: Vec::new(),
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_variable(&mut self, name: &str, value: &str) {
|
||||
self.shared_variables
|
||||
.insert(name.to_string(), value.to_string());
|
||||
}
|
||||
|
||||
pub fn get_variable(&self, name: &str) -> Option<&String> {
|
||||
self.shared_variables.get(name)
|
||||
}
|
||||
|
||||
pub fn add_message(&mut self, sender: TabId, content: &str) {
|
||||
self.shared_messages.push(SharedMessage {
|
||||
sender,
|
||||
content: content.to_string(),
|
||||
timestamp: Utc::now(),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn add_meeting_note(&mut self, note: &str) {
|
||||
self.meeting_notes.push(note.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// A message in shared context
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SharedMessage {
|
||||
pub sender: TabId,
|
||||
pub content: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
//! Task delegation system
|
||||
|
||||
// WIP collaboration surface — narrow harvest. See `tab/mod.rs` for the
|
||||
// PR #2753 context.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use super::{Priority, TabId};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Status of a delegation task
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DelegationStatus {
|
||||
Pending,
|
||||
InProgress,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// A delegation task
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DelegationTask {
|
||||
pub task_id: String,
|
||||
pub from_tab: TabId,
|
||||
pub to_tab: TabId,
|
||||
pub description: String,
|
||||
pub priority: Priority,
|
||||
pub status: DelegationStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub deadline: Option<DateTime<Utc>>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
pub result: Option<String>,
|
||||
}
|
||||
|
||||
impl DelegationTask {
|
||||
/// Create a new pending delegation task.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `task_id` - Unique identifier (e.g. "delegation_42")
|
||||
/// * `from` - Tab that originated the task
|
||||
/// * `to` - Tab that should execute the task
|
||||
/// * `description` - Human-readable description of what to do
|
||||
/// * `priority` - Priority level (Low/Normal/High/Urgent)
|
||||
pub fn new(
|
||||
task_id: String,
|
||||
from: TabId,
|
||||
to: TabId,
|
||||
description: String,
|
||||
priority: Priority,
|
||||
) -> Self {
|
||||
Self {
|
||||
task_id,
|
||||
from_tab: from,
|
||||
to_tab: to,
|
||||
description,
|
||||
priority,
|
||||
status: DelegationStatus::Pending,
|
||||
created_at: Utc::now(),
|
||||
deadline: None,
|
||||
completed_at: None,
|
||||
result: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder method: set the deadline for this task. Returns self for chaining.
|
||||
pub fn with_deadline(mut self, deadline: DateTime<Utc>) -> Self {
|
||||
self.deadline = Some(deadline);
|
||||
self
|
||||
}
|
||||
|
||||
/// Transition status to InProgress. Idempotent.
|
||||
pub fn start(&mut self) {
|
||||
self.status = DelegationStatus::InProgress;
|
||||
}
|
||||
|
||||
/// Mark as completed with the given result string.
|
||||
/// Records completion timestamp.
|
||||
pub fn complete(&mut self, result: String) {
|
||||
self.status = DelegationStatus::Completed;
|
||||
self.result = Some(result);
|
||||
self.completed_at = Some(Utc::now());
|
||||
}
|
||||
|
||||
/// Mark as failed (no result). Records completion timestamp.
|
||||
pub fn fail(&mut self) {
|
||||
self.status = DelegationStatus::Failed;
|
||||
self.completed_at = Some(Utc::now());
|
||||
}
|
||||
|
||||
/// Cancel the task. Records completion timestamp.
|
||||
pub fn cancel(&mut self) {
|
||||
self.status = DelegationStatus::Cancelled;
|
||||
self.completed_at = Some(Utc::now());
|
||||
}
|
||||
|
||||
/// Returns true if the task is still pending (not yet started).
|
||||
pub fn is_pending(&self) -> bool {
|
||||
self.status == DelegationStatus::Pending
|
||||
}
|
||||
|
||||
/// Returns true if the task completed successfully.
|
||||
pub fn is_completed(&self) -> bool {
|
||||
self.status == DelegationStatus::Completed
|
||||
}
|
||||
|
||||
/// Returns true if the task is pending or in progress (i.e., not terminal).
|
||||
pub fn is_active(&self) -> bool {
|
||||
matches!(
|
||||
self.status,
|
||||
DelegationStatus::Pending | DelegationStatus::InProgress
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a completed delegation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DelegationResult {
|
||||
pub task_id: String,
|
||||
pub from_tab: TabId,
|
||||
pub to_tab: TabId,
|
||||
pub result: String,
|
||||
pub completed_at: DateTime<Utc>,
|
||||
pub was_successful: bool,
|
||||
}
|
||||
|
||||
/// Task delegator managing cross-tab task distribution
|
||||
pub struct TaskDelegator {
|
||||
/// Active tasks (pending + in-progress). Terminal-state tasks
|
||||
/// (completed / failed / cancelled) are removed from this vec and
|
||||
/// recorded in `completed_results`. `pub(crate)` so the persistence
|
||||
/// layer can restore from snapshot.
|
||||
pub(crate) pending_tasks: Vec<DelegationTask>,
|
||||
/// Bounded ring buffer of completed results.
|
||||
/// Using VecDeque so O(1) front removal when pruning old entries.
|
||||
/// Bounded to MAX_COMPLETED_RESULTS to prevent unbounded growth.
|
||||
completed_results: VecDeque<DelegationResult>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
/// Maximum number of completed results to keep in memory.
|
||||
/// At this size, prune_results is a no-op for the common case.
|
||||
const MAX_COMPLETED_RESULTS: usize = 256;
|
||||
|
||||
impl TaskDelegator {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pending_tasks: Vec::new(),
|
||||
completed_results: VecDeque::new(),
|
||||
next_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new delegation
|
||||
pub fn create_delegation(
|
||||
&mut self,
|
||||
from: TabId,
|
||||
to: TabId,
|
||||
description: String,
|
||||
priority: Priority,
|
||||
) -> Option<String> {
|
||||
let task_id = self.generate_task_id();
|
||||
let task = DelegationTask::new(task_id.clone(), from, to, description, priority);
|
||||
self.pending_tasks.push(task);
|
||||
Some(task_id)
|
||||
}
|
||||
|
||||
/// Create a delegation with a deadline
|
||||
pub fn create_delegation_with_deadline(
|
||||
&mut self,
|
||||
from: TabId,
|
||||
to: TabId,
|
||||
description: String,
|
||||
priority: Priority,
|
||||
deadline: DateTime<Utc>,
|
||||
) -> Option<String> {
|
||||
let task_id = self.generate_task_id();
|
||||
let task = DelegationTask::new(task_id.clone(), from, to, description, priority)
|
||||
.with_deadline(deadline);
|
||||
self.pending_tasks.push(task);
|
||||
Some(task_id)
|
||||
}
|
||||
|
||||
/// Get pending tasks for a tab
|
||||
pub fn pending_for_tab(&self, tab_id: TabId) -> Vec<&DelegationTask> {
|
||||
self.pending_tasks
|
||||
.iter()
|
||||
.filter(|t| t.to_tab == tab_id && t.is_pending())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get active tasks for a tab (pending or in progress)
|
||||
pub fn active_for_tab(&self, tab_id: TabId) -> Vec<&DelegationTask> {
|
||||
self.pending_tasks
|
||||
.iter()
|
||||
.filter(|t| t.to_tab == tab_id && t.is_active())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all pending tasks
|
||||
pub fn all_pending(&self) -> &[DelegationTask] {
|
||||
&self.pending_tasks
|
||||
}
|
||||
|
||||
/// Get pending tasks sorted by priority (highest first)
|
||||
pub fn pending_sorted_by_priority(&self) -> Vec<&DelegationTask> {
|
||||
let mut tasks: Vec<&DelegationTask> = self
|
||||
.pending_tasks
|
||||
.iter()
|
||||
.filter(|t| t.is_pending())
|
||||
.collect();
|
||||
tasks.sort_by_key(|t| std::cmp::Reverse(t.priority));
|
||||
tasks
|
||||
}
|
||||
|
||||
/// Start working on a task
|
||||
pub fn start_task(&mut self, task_id: &str) -> bool {
|
||||
if let Some(task) = self.pending_tasks.iter_mut().find(|t| t.task_id == task_id) {
|
||||
task.start();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the highest-priority pending task for a tab.
|
||||
/// Marks the task as `InProgress` in place and returns a clone; the task
|
||||
/// is only removed from the queue when it reaches a terminal state via
|
||||
/// `complete` / `fail_task` / `cancel_task`. Higher priority wins; on tie,
|
||||
/// earlier `created_at` wins.
|
||||
pub fn take_pending_for_tab(&mut self, tab_id: TabId) -> Option<DelegationTask> {
|
||||
// Find the highest priority pending task for this tab
|
||||
let mut best_idx: Option<usize> = None;
|
||||
for (i, task) in self.pending_tasks.iter().enumerate() {
|
||||
if task.to_tab != tab_id || !task.is_pending() {
|
||||
continue;
|
||||
}
|
||||
match best_idx {
|
||||
None => best_idx = Some(i),
|
||||
Some(b) => {
|
||||
let best = &self.pending_tasks[b];
|
||||
// Higher priority wins; if equal, earlier created_at wins
|
||||
if task.priority > best.priority
|
||||
|| (task.priority == best.priority && task.created_at < best.created_at)
|
||||
{
|
||||
best_idx = Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as in-progress in place and return a clone; do NOT remove.
|
||||
best_idx.map(|i| {
|
||||
self.pending_tasks[i].start();
|
||||
self.pending_tasks[i].clone()
|
||||
})
|
||||
}
|
||||
|
||||
/// Peek at the next pending task for a tab without removing it
|
||||
pub fn peek_pending_for_tab(&self, tab_id: TabId) -> Option<&DelegationTask> {
|
||||
self.pending_tasks
|
||||
.iter()
|
||||
.filter(|t| t.to_tab == tab_id && t.is_pending())
|
||||
.max_by(|a, b| {
|
||||
// Higher priority first; on tie, earlier created_at first
|
||||
a.priority
|
||||
.cmp(&b.priority)
|
||||
.then_with(|| b.created_at.cmp(&a.created_at))
|
||||
})
|
||||
}
|
||||
|
||||
/// Complete a task
|
||||
pub fn complete(&mut self, task_id: &str, result: String) {
|
||||
let pos = self.pending_tasks.iter().position(|t| t.task_id == task_id);
|
||||
let Some(pos) = pos else { return };
|
||||
let mut task = self.pending_tasks.swap_remove(pos);
|
||||
let from = task.from_tab;
|
||||
let to = task.to_tab;
|
||||
task.complete(result.clone());
|
||||
|
||||
self.completed_results.push_back(DelegationResult {
|
||||
task_id: task_id.to_string(),
|
||||
from_tab: from,
|
||||
to_tab: to,
|
||||
result,
|
||||
completed_at: Utc::now(),
|
||||
was_successful: true,
|
||||
});
|
||||
// Auto-prune to bound memory
|
||||
if self.completed_results.len() > MAX_COMPLETED_RESULTS {
|
||||
self.completed_results.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fail a task
|
||||
pub fn fail_task(&mut self, task_id: &str) {
|
||||
let pos = self.pending_tasks.iter().position(|t| t.task_id == task_id);
|
||||
let Some(pos) = pos else { return };
|
||||
let mut task = self.pending_tasks.swap_remove(pos);
|
||||
let from = task.from_tab;
|
||||
let to = task.to_tab;
|
||||
task.fail();
|
||||
|
||||
self.completed_results.push_back(DelegationResult {
|
||||
task_id: task_id.to_string(),
|
||||
from_tab: from,
|
||||
to_tab: to,
|
||||
result: String::new(),
|
||||
completed_at: Utc::now(),
|
||||
was_successful: false,
|
||||
});
|
||||
// Auto-prune to bound memory
|
||||
if self.completed_results.len() > MAX_COMPLETED_RESULTS {
|
||||
self.completed_results.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel a task
|
||||
pub fn cancel_task(&mut self, task_id: &str) -> bool {
|
||||
let Some(pos) = self.pending_tasks.iter().position(|t| t.task_id == task_id) else {
|
||||
return false;
|
||||
};
|
||||
let mut task = self.pending_tasks.swap_remove(pos);
|
||||
task.cancel();
|
||||
true
|
||||
}
|
||||
|
||||
/// Get results for a tab
|
||||
pub fn results_for_tab(&self, tab_id: TabId) -> Vec<&DelegationResult> {
|
||||
self.completed_results
|
||||
.iter()
|
||||
.filter(|r| r.to_tab == tab_id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get pending count for a tab
|
||||
pub fn pending_count(&self, tab_id: TabId) -> usize {
|
||||
self.pending_tasks
|
||||
.iter()
|
||||
.filter(|t| t.to_tab == tab_id && t.is_pending())
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Clean up old completed results (keep last N)
|
||||
/// O(N) where N is the number of items to remove, but much faster than
|
||||
/// the previous drain() implementation because VecDeque supports
|
||||
/// O(1) front removal.
|
||||
pub fn prune_results(&mut self, keep_last: usize) {
|
||||
while self.completed_results.len() > keep_last {
|
||||
self.completed_results.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get completed results sorted by completion time (most recent first)
|
||||
pub fn recent_results(&self, limit: usize) -> Vec<&DelegationResult> {
|
||||
let mut results: Vec<&DelegationResult> = self.completed_results.iter().collect();
|
||||
results.sort_by_key(|r| std::cmp::Reverse(r.completed_at));
|
||||
results.into_iter().take(limit).collect()
|
||||
}
|
||||
|
||||
fn generate_task_id(&mut self) -> String {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
format!("delegation_{}", id)
|
||||
}
|
||||
|
||||
pub(crate) fn advance_next_id_past_existing_tasks(&mut self) {
|
||||
let max_seen = self
|
||||
.pending_tasks
|
||||
.iter()
|
||||
.filter_map(|task| task.task_id.strip_prefix("delegation_"))
|
||||
.filter_map(|suffix| suffix.parse::<u64>().ok())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
self.next_id = self.next_id.max(max_seen + 1);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TaskDelegator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_create_and_complete_delegation() {
|
||||
let mut delegator = TaskDelegator::new();
|
||||
let from = TabId::new(1);
|
||||
let to = TabId::new(2);
|
||||
|
||||
let task_id = delegator
|
||||
.create_delegation(from, to, "Fix the bug".to_string(), Priority::High)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(delegator.pending_count(to), 1);
|
||||
|
||||
delegator.complete(&task_id, "Fixed successfully".to_string());
|
||||
|
||||
let results = delegator.results_for_tab(from);
|
||||
assert!(results.is_empty());
|
||||
|
||||
let results_to = delegator.results_for_tab(to);
|
||||
assert_eq!(results_to.len(), 1);
|
||||
assert_eq!(results_to[0].was_successful, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_priority_ordering() {
|
||||
let mut delegator = TaskDelegator::new();
|
||||
let from = TabId::new(1);
|
||||
let to = TabId::new(2);
|
||||
|
||||
delegator.create_delegation(from, to, "Low priority".to_string(), Priority::Low);
|
||||
delegator.create_delegation(from, to, "Urgent".to_string(), Priority::Urgent);
|
||||
delegator.create_delegation(from, to, "Normal".to_string(), Priority::Normal);
|
||||
delegator.create_delegation(from, to, "High".to_string(), Priority::High);
|
||||
|
||||
let sorted = delegator.pending_sorted_by_priority();
|
||||
assert_eq!(sorted[0].description, "Urgent");
|
||||
assert_eq!(sorted[1].description, "High");
|
||||
assert_eq!(sorted[2].description, "Normal");
|
||||
assert_eq!(sorted[3].description, "Low priority");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_take_pending_priority_order() {
|
||||
let mut delegator = TaskDelegator::new();
|
||||
let from = TabId::new(1);
|
||||
let to = TabId::new(2);
|
||||
|
||||
delegator.create_delegation(from, to, "Low task".to_string(), Priority::Low);
|
||||
delegator.create_delegation(from, to, "Urgent task".to_string(), Priority::Urgent);
|
||||
delegator.create_delegation(from, to, "Normal task".to_string(), Priority::Normal);
|
||||
|
||||
// Should return Urgent first
|
||||
let task = delegator.take_pending_for_tab(to).unwrap();
|
||||
assert_eq!(task.description, "Urgent task");
|
||||
assert_eq!(task.priority, Priority::Urgent);
|
||||
|
||||
// Then Normal
|
||||
let task = delegator.take_pending_for_tab(to).unwrap();
|
||||
assert_eq!(task.description, "Normal task");
|
||||
assert_eq!(task.priority, Priority::Normal);
|
||||
|
||||
// Then Low
|
||||
let task = delegator.take_pending_for_tab(to).unwrap();
|
||||
assert_eq!(task.description, "Low task");
|
||||
assert_eq!(task.priority, Priority::Low);
|
||||
|
||||
// Then nothing
|
||||
assert!(delegator.take_pending_for_tab(to).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_take_pending_filters_by_tab() {
|
||||
let mut delegator = TaskDelegator::new();
|
||||
let from = TabId::new(1);
|
||||
let to_a = TabId::new(2);
|
||||
let to_b = TabId::new(3);
|
||||
|
||||
delegator.create_delegation(from, to_a, "For A".to_string(), Priority::High);
|
||||
delegator.create_delegation(from, to_b, "For B".to_string(), Priority::High);
|
||||
|
||||
let task = delegator.take_pending_for_tab(to_a).unwrap();
|
||||
assert_eq!(task.description, "For A");
|
||||
|
||||
let task = delegator.take_pending_for_tab(to_b).unwrap();
|
||||
assert_eq!(task.description, "For B");
|
||||
|
||||
// Both should be drained now
|
||||
assert!(delegator.take_pending_for_tab(to_a).is_none());
|
||||
assert!(delegator.take_pending_for_tab(to_b).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_peek_pending_does_not_remove() {
|
||||
let mut delegator = TaskDelegator::new();
|
||||
let from = TabId::new(1);
|
||||
let to = TabId::new(2);
|
||||
|
||||
delegator.create_delegation(from, to, "Task".to_string(), Priority::High);
|
||||
|
||||
// Peek multiple times
|
||||
assert!(delegator.peek_pending_for_tab(to).is_some());
|
||||
assert!(delegator.peek_pending_for_tab(to).is_some());
|
||||
assert_eq!(delegator.pending_count(to), 1);
|
||||
|
||||
// Take should still work
|
||||
let task = delegator.take_pending_for_tab(to).unwrap();
|
||||
assert_eq!(task.description, "Task");
|
||||
|
||||
// Now should be empty
|
||||
assert!(delegator.peek_pending_for_tab(to).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auto_prune_bounded_results() {
|
||||
// Verify auto-prune keeps the queue bounded under heavy load.
|
||||
let mut delegator = TaskDelegator::new();
|
||||
let from = TabId::new(1);
|
||||
let to = TabId::new(2);
|
||||
|
||||
// Create and complete 1000 tasks (more than MAX_COMPLETED_RESULTS=256)
|
||||
for i in 0..1000 {
|
||||
let task_id = delegator
|
||||
.create_delegation(from, to, format!("Task {}", i), Priority::Normal)
|
||||
.unwrap();
|
||||
delegator.complete(&task_id, format!("Result {}", i));
|
||||
}
|
||||
|
||||
// Should be bounded at MAX_COMPLETED_RESULTS
|
||||
let results = delegator.results_for_tab(to);
|
||||
assert!(
|
||||
results.len() <= 256,
|
||||
"Results should be bounded, got {}",
|
||||
results.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prune_results_o1() {
|
||||
let mut delegator = TaskDelegator::new();
|
||||
let from = TabId::new(1);
|
||||
let to = TabId::new(2);
|
||||
|
||||
// Complete many tasks
|
||||
for i in 0..100 {
|
||||
let task_id = delegator
|
||||
.create_delegation(from, to, format!("Task {}", i), Priority::Normal)
|
||||
.unwrap();
|
||||
delegator.complete(&task_id, format!("Result {}", i));
|
||||
}
|
||||
|
||||
assert_eq!(delegator.results_for_tab(to).len(), 100);
|
||||
|
||||
// Prune to keep only 5
|
||||
delegator.prune_results(5);
|
||||
assert_eq!(delegator.results_for_tab(to).len(), 5);
|
||||
|
||||
// Pruning further works
|
||||
delegator.prune_results(3);
|
||||
assert_eq!(delegator.results_for_tab(to).len(), 3);
|
||||
|
||||
// Pruning to a larger count is a no-op
|
||||
delegator.prune_results(10);
|
||||
assert_eq!(delegator.results_for_tab(to).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_advance_next_id_after_restore() {
|
||||
let mut delegator = TaskDelegator::new();
|
||||
delegator.pending_tasks.push(DelegationTask::new(
|
||||
"delegation_42".to_string(),
|
||||
TabId::new(1),
|
||||
TabId::new(2),
|
||||
"restored".to_string(),
|
||||
Priority::Normal,
|
||||
));
|
||||
|
||||
delegator.advance_next_id_past_existing_tasks();
|
||||
let new_id = delegator
|
||||
.create_delegation(
|
||||
TabId::new(1),
|
||||
TabId::new(2),
|
||||
"fresh".to_string(),
|
||||
Priority::Normal,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(new_id, "delegation_43");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
//! Tab groups for organizing related tabs
|
||||
//!
|
||||
//! A TabGroup is a named collection of tabs (e.g. "Frontend Refactor",
|
||||
//! "Backend Bug Hunt"). Groups help users manage 9 tabs by clustering
|
||||
//! them by project/topic.
|
||||
//!
|
||||
//! Groups are purely organizational - they don't change delegation,
|
||||
//! meeting, or any other tab behavior. They just provide:
|
||||
//! - Visual separation in the tab bar (color/icon)
|
||||
//! - Filtering in the tab switcher
|
||||
//! - Quick group switching (next/prev group)
|
||||
|
||||
// WIP collaboration surface — narrow harvest. See `tab/mod.rs` for the
|
||||
// PR #2753 context.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::TabId;
|
||||
|
||||
/// Visual style/color identifier for a group
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
pub enum GroupColor {
|
||||
Red,
|
||||
Orange,
|
||||
Yellow,
|
||||
Green,
|
||||
Cyan,
|
||||
#[default]
|
||||
Blue,
|
||||
Magenta,
|
||||
Gray,
|
||||
}
|
||||
|
||||
impl GroupColor {
|
||||
/// Short name for the color (1-3 chars)
|
||||
pub fn short(&self) -> &'static str {
|
||||
match self {
|
||||
GroupColor::Red => "Rd",
|
||||
GroupColor::Orange => "Or",
|
||||
GroupColor::Yellow => "Yl",
|
||||
GroupColor::Green => "Gn",
|
||||
GroupColor::Cyan => "Cy",
|
||||
GroupColor::Blue => "Bl",
|
||||
GroupColor::Magenta => "Mg",
|
||||
GroupColor::Gray => "Gy",
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycle to the next color (used by the group cycle command)
|
||||
pub fn next(&self) -> Self {
|
||||
match self {
|
||||
GroupColor::Red => GroupColor::Orange,
|
||||
GroupColor::Orange => GroupColor::Yellow,
|
||||
GroupColor::Yellow => GroupColor::Green,
|
||||
GroupColor::Green => GroupColor::Cyan,
|
||||
GroupColor::Cyan => GroupColor::Blue,
|
||||
GroupColor::Blue => GroupColor::Magenta,
|
||||
GroupColor::Magenta => GroupColor::Gray,
|
||||
GroupColor::Gray => GroupColor::Red,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named collection of tabs
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TabGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub color: GroupColor,
|
||||
pub tab_ids: Vec<TabId>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TabGroup {
|
||||
pub fn new(name: String, color: GroupColor) -> Self {
|
||||
let id = format!("group_{}", chrono::Utc::now().timestamp_millis());
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
color,
|
||||
tab_ids: Vec::new(),
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a tab to this group
|
||||
pub fn add_tab(&mut self, tab_id: TabId) {
|
||||
if !self.tab_ids.contains(&tab_id) {
|
||||
self.tab_ids.push(tab_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a tab from this group
|
||||
pub fn remove_tab(&mut self, tab_id: TabId) -> bool {
|
||||
if let Some(pos) = self.tab_ids.iter().position(|t| *t == tab_id) {
|
||||
self.tab_ids.swap_remove(pos);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of tabs in this group
|
||||
pub fn len(&self) -> usize {
|
||||
self.tab_ids.len()
|
||||
}
|
||||
|
||||
/// Check if the group is empty
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.tab_ids.is_empty()
|
||||
}
|
||||
|
||||
/// Check if this group contains a tab
|
||||
pub fn contains(&self, tab_id: TabId) -> bool {
|
||||
self.tab_ids.contains(&tab_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Manager for tab groups
|
||||
pub struct TabGroupManager {
|
||||
pub(crate) groups: HashMap<String, TabGroup>,
|
||||
/// Maps tab_id -> group_id for quick lookup
|
||||
pub(crate) tab_to_group: HashMap<TabId, String>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl TabGroupManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
groups: HashMap::new(),
|
||||
tab_to_group: HashMap::new(),
|
||||
next_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new group
|
||||
pub fn create_group(&mut self, name: String, color: GroupColor) -> String {
|
||||
let id = self.generate_group_id();
|
||||
let group = TabGroup {
|
||||
id: id.clone(),
|
||||
name,
|
||||
color,
|
||||
tab_ids: Vec::new(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
self.groups.insert(id.clone(), group);
|
||||
id
|
||||
}
|
||||
|
||||
/// Delete a group (tabs themselves are not deleted)
|
||||
pub fn delete_group(&mut self, group_id: &str) -> bool {
|
||||
if let Some(group) = self.groups.remove(group_id) {
|
||||
for tab_id in &group.tab_ids {
|
||||
self.tab_to_group.remove(tab_id);
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Assign a tab to a group
|
||||
pub fn assign_tab(&mut self, tab_id: TabId, group_id: &str) -> bool {
|
||||
// Remove from any previous group first
|
||||
self.unassign_tab(tab_id);
|
||||
|
||||
if let Some(group) = self.groups.get_mut(group_id) {
|
||||
group.add_tab(tab_id);
|
||||
self.tab_to_group.insert(tab_id, group_id.to_string());
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a tab from its group (if any)
|
||||
pub fn unassign_tab(&mut self, tab_id: TabId) {
|
||||
if let Some(prev_group_id) = self.tab_to_group.remove(&tab_id)
|
||||
&& let Some(group) = self.groups.get_mut(&prev_group_id)
|
||||
{
|
||||
group.remove_tab(tab_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the group a tab is assigned to
|
||||
pub fn group_of(&self, tab_id: TabId) -> Option<&TabGroup> {
|
||||
self.tab_to_group
|
||||
.get(&tab_id)
|
||||
.and_then(|id| self.groups.get(id))
|
||||
}
|
||||
|
||||
/// Get all groups
|
||||
pub fn all_groups(&self) -> Vec<&TabGroup> {
|
||||
let mut groups: Vec<&TabGroup> = self.groups.values().collect();
|
||||
// Sort by name for stable display
|
||||
groups.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
groups
|
||||
}
|
||||
|
||||
/// Get tabs in a specific group
|
||||
pub fn tabs_in_group(&self, group_id: &str) -> Option<&Vec<TabId>> {
|
||||
self.groups.get(group_id).map(|g| &g.tab_ids)
|
||||
}
|
||||
|
||||
/// Get the number of groups
|
||||
pub fn group_count(&self) -> usize {
|
||||
self.groups.len()
|
||||
}
|
||||
|
||||
/// Iterate over groups
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&String, &TabGroup)> {
|
||||
self.groups.iter()
|
||||
}
|
||||
|
||||
/// Cycle a tab to the next group (or unassign if at the end)
|
||||
pub fn cycle_tab_group(&mut self, tab_id: TabId) {
|
||||
let group_ids: Vec<String> = self.all_groups().iter().map(|g| g.id.clone()).collect();
|
||||
|
||||
if let Some(current_group_id) = self.tab_to_group.get(&tab_id).cloned() {
|
||||
if let Some(pos) = group_ids.iter().position(|id| id == ¤t_group_id) {
|
||||
if pos + 1 < group_ids.len() {
|
||||
let next = group_ids[pos + 1].clone();
|
||||
self.assign_tab(tab_id, &next);
|
||||
} else {
|
||||
self.unassign_tab(tab_id);
|
||||
}
|
||||
}
|
||||
} else if !group_ids.is_empty() {
|
||||
let first = group_ids[0].clone();
|
||||
self.assign_tab(tab_id, &first);
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_group_id(&mut self) -> String {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
format!("group_{}", id)
|
||||
}
|
||||
|
||||
pub(crate) fn advance_next_id_past_existing_groups(&mut self) {
|
||||
let max_seen = self
|
||||
.groups
|
||||
.keys()
|
||||
.filter_map(|id| id.strip_prefix("group_"))
|
||||
.filter_map(|suffix| suffix.parse::<u64>().ok())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
self.next_id = self.next_id.max(max_seen + 1);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TabGroupManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_create_and_delete_group() {
|
||||
let mut mgr = TabGroupManager::new();
|
||||
let id = mgr.create_group("Frontend".to_string(), GroupColor::Blue);
|
||||
assert_eq!(mgr.group_count(), 1);
|
||||
assert!(mgr.delete_group(&id));
|
||||
assert_eq!(mgr.group_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assign_tab() {
|
||||
let mut mgr = TabGroupManager::new();
|
||||
let group_id = mgr.create_group("Backend".to_string(), GroupColor::Green);
|
||||
let tab1 = TabId::new(1);
|
||||
let tab2 = TabId::new(2);
|
||||
|
||||
assert!(mgr.assign_tab(tab1, &group_id));
|
||||
assert!(mgr.assign_tab(tab2, &group_id));
|
||||
|
||||
let group = mgr.group_of(tab1).unwrap();
|
||||
assert_eq!(group.len(), 2);
|
||||
assert!(group.contains(tab1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unassign_tab() {
|
||||
let mut mgr = TabGroupManager::new();
|
||||
let group_id = mgr.create_group("Test".to_string(), GroupColor::Red);
|
||||
let tab1 = TabId::new(1);
|
||||
mgr.assign_tab(tab1, &group_id);
|
||||
|
||||
mgr.unassign_tab(tab1);
|
||||
assert!(mgr.group_of(tab1).is_none());
|
||||
|
||||
let group = mgr.groups.get(&group_id).unwrap();
|
||||
assert_eq!(group.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reassign_tab() {
|
||||
let mut mgr = TabGroupManager::new();
|
||||
let g1 = mgr.create_group("G1".to_string(), GroupColor::Blue);
|
||||
let g2 = mgr.create_group("G2".to_string(), GroupColor::Red);
|
||||
let tab1 = TabId::new(1);
|
||||
|
||||
mgr.assign_tab(tab1, &g1);
|
||||
assert_eq!(mgr.group_of(tab1).unwrap().id, g1);
|
||||
|
||||
mgr.assign_tab(tab1, &g2);
|
||||
assert_eq!(mgr.group_of(tab1).unwrap().id, g2);
|
||||
// G1 should now be empty
|
||||
let g1_ref = mgr.groups.get(&g1).unwrap();
|
||||
assert_eq!(g1_ref.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_color_cycle() {
|
||||
let c = GroupColor::Red;
|
||||
assert_eq!(c.next(), GroupColor::Orange);
|
||||
assert_eq!(c.next().next(), GroupColor::Yellow);
|
||||
// Cycle back to Red after Gray
|
||||
let gray = GroupColor::Gray;
|
||||
assert_eq!(gray.next(), GroupColor::Red);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cycle_tab_group() {
|
||||
let mut mgr = TabGroupManager::new();
|
||||
let g1 = mgr.create_group("G1".to_string(), GroupColor::Blue);
|
||||
let g2 = mgr.create_group("G2".to_string(), GroupColor::Red);
|
||||
let tab1 = TabId::new(1);
|
||||
|
||||
// Not assigned yet -> assign to first
|
||||
mgr.cycle_tab_group(tab1);
|
||||
assert_eq!(mgr.group_of(tab1).unwrap().id, g1);
|
||||
|
||||
// Cycle to g2
|
||||
mgr.cycle_tab_group(tab1);
|
||||
assert_eq!(mgr.group_of(tab1).unwrap().id, g2);
|
||||
|
||||
// Cycle past end -> unassign
|
||||
mgr.cycle_tab_group(tab1);
|
||||
assert!(mgr.group_of(tab1).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_group_clears_assignments() {
|
||||
let mut mgr = TabGroupManager::new();
|
||||
let g1 = mgr.create_group("G1".to_string(), GroupColor::Blue);
|
||||
let tab1 = TabId::new(1);
|
||||
mgr.assign_tab(tab1, &g1);
|
||||
|
||||
mgr.delete_group(&g1);
|
||||
assert!(mgr.group_of(tab1).is_none());
|
||||
assert!(mgr.tab_to_group.get(&tab1).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_advance_next_id_after_restore() {
|
||||
let mut mgr = TabGroupManager::new();
|
||||
mgr.groups.insert(
|
||||
"group_7".to_string(),
|
||||
TabGroup {
|
||||
id: "group_7".to_string(),
|
||||
name: "Restored".to_string(),
|
||||
color: GroupColor::Blue,
|
||||
tab_ids: Vec::new(),
|
||||
created_at: Utc::now(),
|
||||
},
|
||||
);
|
||||
|
||||
mgr.advance_next_id_past_existing_groups();
|
||||
let new_id = mgr.create_group("Fresh".to_string(), GroupColor::Green);
|
||||
|
||||
assert_eq!(new_id, "group_8");
|
||||
assert!(mgr.groups.contains_key("group_7"));
|
||||
assert!(mgr.groups.contains_key("group_8"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! End-to-end keyboard event tests for tab system
|
||||
//!
|
||||
//! These tests simulate the keyboard event handling that happens in
|
||||
//! `ui.rs` when the user presses tab-related shortcuts. They verify that
|
||||
//! the TabManager state transitions correctly in response to key events.
|
||||
//!
|
||||
//! The actual key event dispatch lives in `ui.rs` (which is hard to
|
||||
//! test in isolation due to the engine/App dependencies), so these
|
||||
//! tests exercise the underlying state transitions that the key handlers
|
||||
//! would trigger.
|
||||
//!
|
||||
//! Run with: `cargo test tui::tab::key_e2e -- --nocapture`
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tui::tab::{Priority, TabId, TabManager, TabType};
|
||||
|
||||
/// Simulate the sequence of key events the user would press
|
||||
/// to: create a new tab, switch to it, type a message, and submit.
|
||||
fn simulate_create_and_switch(manager: &mut TabManager) {
|
||||
// Ctrl+Shift+N: create new tab
|
||||
manager
|
||||
.create_tab(format!("Tab {}", manager.len() + 1), TabType::Chat)
|
||||
.expect("Ctrl+Shift+N should create tab");
|
||||
|
||||
// The new tab is automatically active after creation,
|
||||
// simulating the key handler that updates active_tab.
|
||||
}
|
||||
|
||||
/// Simulate Ctrl+1..9 key press
|
||||
fn simulate_ctrl_number(manager: &mut TabManager, n: u8) {
|
||||
if n == 0 || n as usize > manager.len() {
|
||||
return;
|
||||
}
|
||||
manager.switch_to((n - 1) as usize);
|
||||
}
|
||||
|
||||
/// Simulate Ctrl+Tab / Ctrl+Shift+Tab
|
||||
fn simulate_ctrl_tab(manager: &mut TabManager, forward: bool) {
|
||||
if forward {
|
||||
manager.switch_to_next();
|
||||
} else {
|
||||
manager.switch_to_prev();
|
||||
}
|
||||
}
|
||||
|
||||
/// Simulate Ctrl+` to open switcher (we just verify the manager
|
||||
/// can list its tabs as the switcher would)
|
||||
fn simulate_switcher_list(manager: &TabManager) -> Vec<(usize, String)> {
|
||||
manager
|
||||
.all_tabs()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| (i, t.title.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Simulate Ctrl+Shift+D: process pending delegations
|
||||
fn simulate_process_delegation(manager: &mut TabManager) -> Option<String> {
|
||||
let tab_id = manager.active_id()?;
|
||||
manager.take_next_delegation(tab_id).map(|t| t.task_id)
|
||||
}
|
||||
|
||||
// === Tab creation tests ===
|
||||
|
||||
#[test]
|
||||
fn test_e2e_create_first_tab() {
|
||||
let mut manager = TabManager::new();
|
||||
assert!(manager.is_empty());
|
||||
|
||||
simulate_create_and_switch(&mut manager);
|
||||
assert_eq!(manager.len(), 1);
|
||||
assert_eq!(manager.active_index(), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_create_max_tabs() {
|
||||
let mut manager = TabManager::new();
|
||||
for _ in 0..9 {
|
||||
simulate_create_and_switch(&mut manager);
|
||||
}
|
||||
assert_eq!(manager.len(), 9);
|
||||
|
||||
// 10th should fail
|
||||
let result = manager.create_tab("10th".to_string(), TabType::Chat);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// === Tab switching tests ===
|
||||
|
||||
#[test]
|
||||
fn test_e2e_ctrl_number_switches() {
|
||||
let mut manager = TabManager::new();
|
||||
for i in 1..=5 {
|
||||
manager
|
||||
.create_tab(format!("Tab {}", i), TabType::Chat)
|
||||
.expect("create");
|
||||
}
|
||||
|
||||
simulate_ctrl_number(&mut manager, 3);
|
||||
assert_eq!(manager.active_index(), Some(2));
|
||||
|
||||
simulate_ctrl_number(&mut manager, 1);
|
||||
assert_eq!(manager.active_index(), Some(0));
|
||||
|
||||
simulate_ctrl_number(&mut manager, 5);
|
||||
assert_eq!(manager.active_index(), Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_ctrl_number_out_of_range() {
|
||||
let mut manager = TabManager::new();
|
||||
for i in 1..=3 {
|
||||
manager
|
||||
.create_tab(format!("Tab {}", i), TabType::Chat)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Out-of-range should be a no-op
|
||||
simulate_ctrl_number(&mut manager, 9);
|
||||
assert_eq!(manager.active_index(), Some(2)); // last created
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_ctrl_tab_cycles() {
|
||||
let mut manager = TabManager::new();
|
||||
for i in 1..=3 {
|
||||
manager
|
||||
.create_tab(format!("Tab {}", i), TabType::Chat)
|
||||
.unwrap();
|
||||
}
|
||||
// Initially at last (2)
|
||||
assert_eq!(manager.active_index(), Some(2));
|
||||
|
||||
simulate_ctrl_tab(&mut manager, true);
|
||||
assert_eq!(manager.active_index(), Some(0)); // wrap
|
||||
|
||||
simulate_ctrl_tab(&mut manager, true);
|
||||
assert_eq!(manager.active_index(), Some(1));
|
||||
|
||||
simulate_ctrl_tab(&mut manager, false);
|
||||
assert_eq!(manager.active_index(), Some(0));
|
||||
}
|
||||
|
||||
// === Switcher listing tests ===
|
||||
|
||||
#[test]
|
||||
fn test_e2e_switcher_lists_all_tabs() {
|
||||
let mut manager = TabManager::new();
|
||||
manager.create_tab("A".to_string(), TabType::Chat).unwrap();
|
||||
manager
|
||||
.create_tab("B".to_string(), TabType::Review)
|
||||
.unwrap();
|
||||
manager
|
||||
.create_tab("C".to_string(), TabType::Meeting)
|
||||
.unwrap();
|
||||
|
||||
let listed = simulate_switcher_list(&manager);
|
||||
assert_eq!(listed.len(), 3);
|
||||
assert_eq!(listed[0].1, "A");
|
||||
assert_eq!(listed[1].1, "B");
|
||||
assert_eq!(listed[2].1, "C");
|
||||
}
|
||||
|
||||
// === Delegation tests ===
|
||||
|
||||
#[test]
|
||||
fn test_e2e_delegate_and_process() {
|
||||
let mut manager = TabManager::new();
|
||||
let from = manager
|
||||
.create_tab("Source".to_string(), TabType::Chat)
|
||||
.unwrap();
|
||||
let to = manager
|
||||
.create_tab("Target".to_string(), TabType::Chat)
|
||||
.unwrap();
|
||||
|
||||
// User delegates a task
|
||||
let task_id = manager
|
||||
.delegate_task(from, to, "Review PR".to_string(), Priority::High)
|
||||
.expect("delegate");
|
||||
|
||||
// User switches to target tab
|
||||
manager.switch_to_by_id(to);
|
||||
|
||||
// User presses Ctrl+Shift+D
|
||||
let processed = simulate_process_delegation(&mut manager);
|
||||
assert_eq!(processed, Some(task_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_no_pending_delegation_returns_none() {
|
||||
let mut manager = TabManager::new();
|
||||
let _ = manager
|
||||
.create_tab("Solo".to_string(), TabType::Chat)
|
||||
.unwrap();
|
||||
|
||||
let processed = simulate_process_delegation(&mut manager);
|
||||
assert_eq!(processed, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_delegation_priority_drain() {
|
||||
let mut manager = TabManager::new();
|
||||
let from = manager.create_tab("S".to_string(), TabType::Chat).unwrap();
|
||||
let to = manager.create_tab("T".to_string(), TabType::Chat).unwrap();
|
||||
|
||||
manager.delegate_task(from, to, "Low".to_string(), Priority::Low);
|
||||
manager.delegate_task(from, to, "Urgent".to_string(), Priority::Urgent);
|
||||
manager.delegate_task(from, to, "High".to_string(), Priority::High);
|
||||
manager.delegate_task(from, to, "Normal".to_string(), Priority::Normal);
|
||||
|
||||
manager.switch_to_by_id(to);
|
||||
|
||||
// Press Ctrl+Shift+D 4 times
|
||||
assert_eq!(simulate_process_delegation(&mut manager).is_some(), true);
|
||||
assert_eq!(simulate_process_delegation(&mut manager).is_some(), true);
|
||||
assert_eq!(simulate_process_delegation(&mut manager).is_some(), true);
|
||||
// 4th should be the last task
|
||||
assert_eq!(simulate_process_delegation(&mut manager).is_some(), true);
|
||||
// 5th should be none
|
||||
assert_eq!(simulate_process_delegation(&mut manager), None);
|
||||
}
|
||||
|
||||
// === Tab close tests ===
|
||||
|
||||
#[test]
|
||||
fn test_e2e_close_active_tab() {
|
||||
let mut manager = TabManager::new();
|
||||
let id_a = manager.create_tab("A".to_string(), TabType::Chat).unwrap();
|
||||
let _id_b = manager.create_tab("B".to_string(), TabType::Chat).unwrap();
|
||||
let id_c = manager.create_tab("C".to_string(), TabType::Chat).unwrap();
|
||||
|
||||
// Switch to B (index 1)
|
||||
manager.switch_to(1);
|
||||
|
||||
// Ctrl+Shift+W: close current
|
||||
manager.close_tab(1);
|
||||
assert_eq!(manager.len(), 2);
|
||||
// Active should now be C (index 1) since B was removed.
|
||||
// C is the previously-created 3rd tab.
|
||||
assert_eq!(manager.active_id().unwrap(), id_c);
|
||||
assert!(id_a != id_c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_close_only_tab_clears_active() {
|
||||
let mut manager = TabManager::new();
|
||||
manager
|
||||
.create_tab("Solo".to_string(), TabType::Chat)
|
||||
.unwrap();
|
||||
manager.close_tab(0);
|
||||
assert!(manager.is_empty());
|
||||
assert_eq!(manager.active_index(), None);
|
||||
}
|
||||
|
||||
// === Group management tests ===
|
||||
|
||||
#[test]
|
||||
fn test_e2e_cycle_through_groups() {
|
||||
use crate::tui::tab::group::GroupColor;
|
||||
|
||||
let mut manager = TabManager::new();
|
||||
let tab1 = manager.create_tab("T1".to_string(), TabType::Chat).unwrap();
|
||||
let _g1 = manager.create_group("A".to_string(), GroupColor::Blue);
|
||||
let _g2 = manager.create_group("B".to_string(), GroupColor::Red);
|
||||
|
||||
// Cycle: not assigned -> first group (A)
|
||||
manager.cycle_tab_group(tab1);
|
||||
assert!(manager.tab_group(tab1).is_some());
|
||||
|
||||
// Cycle: A -> B
|
||||
manager.cycle_tab_group(tab1);
|
||||
let group = manager.tab_group(tab1).unwrap();
|
||||
assert_eq!(group.name, "B");
|
||||
|
||||
// Cycle: B -> unassigned
|
||||
manager.cycle_tab_group(tab1);
|
||||
assert!(manager.tab_group(tab1).is_none());
|
||||
}
|
||||
|
||||
// === Persistence e2e ===
|
||||
|
||||
#[test]
|
||||
fn test_e2e_full_workflow_save_load() {
|
||||
use std::path::Path;
|
||||
|
||||
let dir = std::env::temp_dir().join("codewhale_e2e_persist");
|
||||
let path = dir.join("tabs.json");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
// Session 1: User creates tabs and groups
|
||||
let mut manager = TabManager::new();
|
||||
manager
|
||||
.create_tab("Work".to_string(), TabType::Chat)
|
||||
.unwrap();
|
||||
manager
|
||||
.create_tab("Personal".to_string(), TabType::Chat)
|
||||
.unwrap();
|
||||
let group_id = manager.create_group(
|
||||
"Default".to_string(),
|
||||
crate::tui::tab::group::GroupColor::Blue,
|
||||
);
|
||||
manager.assign_tab_to_group(manager.all_tabs()[0].id, &group_id);
|
||||
manager.switch_to(1);
|
||||
|
||||
// App saves on shutdown
|
||||
manager.save_to_file(&path).unwrap();
|
||||
|
||||
// Session 2: Restore
|
||||
let mut restored = TabManager::new();
|
||||
restored.restore_from_file(&path).unwrap();
|
||||
assert_eq!(restored.len(), 2);
|
||||
assert_eq!(restored.active_index(), Some(1));
|
||||
assert!(restored.tab_group(restored.all_tabs()[0].id).is_some());
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
// === Edge case tests ===
|
||||
|
||||
#[test]
|
||||
fn test_e2e_rapid_create_close() {
|
||||
let mut manager = TabManager::new();
|
||||
for i in 0..9 {
|
||||
manager
|
||||
.create_tab(format!("T{}", i), TabType::Chat)
|
||||
.expect("create");
|
||||
}
|
||||
// Close all
|
||||
for _ in 0..9 {
|
||||
manager.close_tab(manager.active_index().unwrap());
|
||||
}
|
||||
assert!(manager.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_switch_empty_manager() {
|
||||
let mut manager = TabManager::new();
|
||||
simulate_ctrl_tab(&mut manager, true);
|
||||
// Should be a no-op
|
||||
assert!(manager.is_empty());
|
||||
simulate_ctrl_number(&mut manager, 1);
|
||||
assert!(manager.is_empty());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,377 @@
|
||||
//! Meeting manager for multi-agent discussions
|
||||
|
||||
// WIP collaboration surface — narrow harvest. See `tab/mod.rs` for the
|
||||
// PR #2753 context.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use super::TabId;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Status of a meeting
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum MeetingStatus {
|
||||
Active,
|
||||
Paused,
|
||||
Ended,
|
||||
}
|
||||
|
||||
/// Type of meeting message
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum MeetingMessageType {
|
||||
Regular,
|
||||
Question,
|
||||
Answer,
|
||||
Proposal,
|
||||
Agreement,
|
||||
Objection,
|
||||
Summary,
|
||||
}
|
||||
|
||||
/// A message in a meeting
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeetingMessage {
|
||||
pub id: u64,
|
||||
pub sender: TabId,
|
||||
pub content: String,
|
||||
pub message_type: MeetingMessageType,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl MeetingMessage {
|
||||
pub fn new(id: u64, sender: TabId, content: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
sender,
|
||||
content,
|
||||
message_type: MeetingMessageType::Regular,
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn question(id: u64, sender: TabId, content: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
sender,
|
||||
content,
|
||||
message_type: MeetingMessageType::Question,
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn proposal(id: u64, sender: TabId, content: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
sender,
|
||||
content,
|
||||
message_type: MeetingMessageType::Proposal,
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A decision made in a meeting
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeetingDecision {
|
||||
pub id: u64,
|
||||
pub description: String,
|
||||
pub proposer: TabId,
|
||||
pub supporters: Vec<TabId>,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl MeetingDecision {
|
||||
pub fn new(id: u64, description: String, proposer: TabId) -> Self {
|
||||
Self {
|
||||
id,
|
||||
description,
|
||||
proposer,
|
||||
supporters: vec![proposer],
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_supporter(&mut self, tab_id: TabId) {
|
||||
if !self.supporters.contains(&tab_id) {
|
||||
self.supporters.push(tab_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn support_count(&self) -> usize {
|
||||
self.supporters.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// A meeting session
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Meeting {
|
||||
pub id: String,
|
||||
pub topic: String,
|
||||
pub participants: Vec<TabId>,
|
||||
pub messages: Vec<MeetingMessage>,
|
||||
pub decisions: Vec<MeetingDecision>,
|
||||
pub status: MeetingStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub ended_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Meeting {
|
||||
pub fn new(id: String, topic: String, participants: Vec<TabId>) -> Self {
|
||||
Self {
|
||||
id,
|
||||
topic,
|
||||
participants,
|
||||
messages: Vec::new(),
|
||||
decisions: Vec::new(),
|
||||
status: MeetingStatus::Active,
|
||||
created_at: Utc::now(),
|
||||
ended_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_message(&mut self, msg: MeetingMessage) {
|
||||
self.messages.push(msg);
|
||||
}
|
||||
|
||||
pub fn add_decision(&mut self, decision: MeetingDecision) {
|
||||
self.decisions.push(decision);
|
||||
}
|
||||
|
||||
pub fn end(&mut self) {
|
||||
self.status = MeetingStatus::Ended;
|
||||
self.ended_at = Some(Utc::now());
|
||||
}
|
||||
|
||||
pub fn duration(&self) -> chrono::Duration {
|
||||
let end = self.ended_at.unwrap_or_else(Utc::now);
|
||||
end - self.created_at
|
||||
}
|
||||
|
||||
pub fn message_count(&self) -> usize {
|
||||
self.messages.len()
|
||||
}
|
||||
|
||||
pub fn decision_count(&self) -> usize {
|
||||
self.decisions.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary of a completed meeting
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeetingSummary {
|
||||
pub id: String,
|
||||
pub topic: String,
|
||||
pub participant_count: usize,
|
||||
pub message_count: usize,
|
||||
pub decision_count: usize,
|
||||
pub duration_seconds: i64,
|
||||
pub decisions: Vec<String>,
|
||||
}
|
||||
|
||||
/// Meeting history entry
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeetingHistory {
|
||||
pub summary: MeetingSummary,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Manager for meeting sessions
|
||||
pub struct MeetingManager {
|
||||
active_meetings: HashMap<String, Meeting>,
|
||||
history: Vec<MeetingHistory>,
|
||||
next_meeting_id: u64,
|
||||
next_message_id: u64,
|
||||
next_decision_id: u64,
|
||||
}
|
||||
|
||||
impl MeetingManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active_meetings: HashMap::new(),
|
||||
history: Vec::new(),
|
||||
next_meeting_id: 1,
|
||||
next_message_id: 1,
|
||||
next_decision_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new meeting
|
||||
pub fn start_meeting(&mut self, topic: String, participants: Vec<TabId>) -> Option<String> {
|
||||
if participants.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let meeting_id = self.generate_meeting_id();
|
||||
let meeting = Meeting::new(meeting_id.clone(), topic, participants);
|
||||
self.active_meetings.insert(meeting_id.clone(), meeting);
|
||||
Some(meeting_id)
|
||||
}
|
||||
|
||||
/// Get an active meeting by ID
|
||||
pub fn get_meeting(&self, meeting_id: &str) -> Option<&Meeting> {
|
||||
self.active_meetings.get(meeting_id)
|
||||
}
|
||||
|
||||
/// Get a mutable meeting by ID
|
||||
pub fn get_meeting_mut(&mut self, meeting_id: &str) -> Option<&mut Meeting> {
|
||||
self.active_meetings.get_mut(meeting_id)
|
||||
}
|
||||
|
||||
/// Add a message to a meeting
|
||||
pub fn add_message(&mut self, meeting_id: &str, msg: MeetingMessage) {
|
||||
if let Some(meeting) = self.active_meetings.get_mut(meeting_id) {
|
||||
meeting.add_message(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Create and add a new message
|
||||
pub fn create_message(
|
||||
&mut self,
|
||||
meeting_id: &str,
|
||||
sender: TabId,
|
||||
content: String,
|
||||
) -> Option<u64> {
|
||||
let msg_id = self.next_message_id;
|
||||
self.next_message_id += 1;
|
||||
|
||||
if let Some(meeting) = self.active_meetings.get_mut(meeting_id) {
|
||||
let msg = MeetingMessage::new(msg_id, sender, content);
|
||||
meeting.add_message(msg);
|
||||
Some(msg_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a decision to a meeting
|
||||
pub fn add_decision(&mut self, meeting_id: &str, decision: MeetingDecision) {
|
||||
if let Some(meeting) = self.active_meetings.get_mut(meeting_id) {
|
||||
meeting.add_decision(decision);
|
||||
}
|
||||
}
|
||||
|
||||
/// Create and add a new decision
|
||||
pub fn create_decision(
|
||||
&mut self,
|
||||
meeting_id: &str,
|
||||
description: String,
|
||||
proposer: TabId,
|
||||
) -> Option<u64> {
|
||||
let decision_id = self.next_decision_id;
|
||||
self.next_decision_id += 1;
|
||||
|
||||
if let Some(meeting) = self.active_meetings.get_mut(meeting_id) {
|
||||
let decision = MeetingDecision::new(decision_id, description, proposer);
|
||||
meeting.add_decision(decision);
|
||||
Some(decision_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// End a meeting
|
||||
pub fn end_meeting(&mut self, meeting_id: &str) -> Option<MeetingSummary> {
|
||||
if let Some(mut meeting) = self.active_meetings.remove(meeting_id) {
|
||||
meeting.end();
|
||||
let duration = meeting.duration();
|
||||
let summary = MeetingSummary {
|
||||
id: meeting.id.clone(),
|
||||
topic: meeting.topic.clone(),
|
||||
participant_count: meeting.participants.len(),
|
||||
message_count: meeting.message_count(),
|
||||
decision_count: meeting.decision_count(),
|
||||
duration_seconds: duration.num_seconds(),
|
||||
decisions: meeting
|
||||
.decisions
|
||||
.iter()
|
||||
.map(|d| d.description.clone())
|
||||
.collect(),
|
||||
};
|
||||
self.history.push(MeetingHistory {
|
||||
summary: summary.clone(),
|
||||
created_at: meeting.created_at,
|
||||
});
|
||||
Some(summary)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Get active meeting for a specific tab
|
||||
pub fn active_meeting_for(&self, tab_id: TabId) -> Option<&Meeting> {
|
||||
self.active_meetings
|
||||
.values()
|
||||
.find(|m| m.participants.contains(&tab_id))
|
||||
}
|
||||
|
||||
/// Get all active meetings
|
||||
pub fn active_meetings(&self) -> Vec<&Meeting> {
|
||||
self.active_meetings.values().collect()
|
||||
}
|
||||
|
||||
/// Get meeting history
|
||||
pub fn history(&self) -> &[MeetingHistory] {
|
||||
&self.history
|
||||
}
|
||||
|
||||
/// Get recent meetings
|
||||
pub fn recent_meetings(&self, limit: usize) -> Vec<&MeetingHistory> {
|
||||
let mut history: Vec<&MeetingHistory> = self.history.iter().collect();
|
||||
history.sort_by_key(|m| std::cmp::Reverse(m.created_at));
|
||||
history.into_iter().take(limit).collect()
|
||||
}
|
||||
|
||||
/// Check if a tab is in any active meeting
|
||||
pub fn is_in_meeting(&self, tab_id: TabId) -> bool {
|
||||
self.active_meetings
|
||||
.values()
|
||||
.any(|m| m.participants.contains(&tab_id))
|
||||
}
|
||||
|
||||
fn generate_meeting_id(&mut self) -> String {
|
||||
let id = self.next_meeting_id;
|
||||
self.next_meeting_id += 1;
|
||||
format!("meeting_{}", id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MeetingManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_meeting_lifecycle() {
|
||||
let mut manager = MeetingManager::new();
|
||||
let tab1 = TabId::new(1);
|
||||
let tab2 = TabId::new(2);
|
||||
|
||||
let meeting_id = manager
|
||||
.start_meeting("Discuss design".to_string(), vec![tab1, tab2])
|
||||
.unwrap();
|
||||
|
||||
assert!(manager.is_in_meeting(tab1));
|
||||
assert!(manager.is_in_meeting(tab2));
|
||||
assert!(!manager.is_in_meeting(TabId::new(3)));
|
||||
|
||||
manager.create_message(&meeting_id, tab1, "Let's start".to_string());
|
||||
manager.create_message(&meeting_id, tab2, "Agreed".to_string());
|
||||
|
||||
let meeting = manager.get_meeting(&meeting_id).unwrap();
|
||||
assert_eq!(meeting.message_count(), 2);
|
||||
|
||||
manager.create_decision(&meeting_id, "Use component pattern".to_string(), tab1);
|
||||
|
||||
let summary = manager.end_meeting(&meeting_id).unwrap();
|
||||
assert_eq!(summary.topic, "Discuss design");
|
||||
assert_eq!(summary.participant_count, 2);
|
||||
assert_eq!(summary.message_count, 2);
|
||||
assert_eq!(summary.decision_count, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Smart @-mention parsing for cross-tab references
|
||||
//!
|
||||
//! When a user writes a message like "Hey @Tab2 can you review this?",
|
||||
//! the mention parser extracts the referenced tab and suggests
|
||||
//! automatically switching to it (or routing the message).
|
||||
//!
|
||||
//! Supported mention forms:
|
||||
//! - `@Tab2` - by tab number (1-indexed)
|
||||
//! - `@2` - shorthand for tab number
|
||||
//! - `@tab2` - case-insensitive
|
||||
//!
|
||||
//! Examples:
|
||||
//! ```
|
||||
//! use crate::tui::tab::mention::extract_tab_mention;
|
||||
//! assert_eq!(extract_tab_mention("Hello @Tab2!"), Some(2));
|
||||
//! assert_eq!(extract_tab_mention("see @3"), Some(3));
|
||||
//! assert_eq!(extract_tab_mention("no mention here"), None);
|
||||
//! ```
|
||||
|
||||
// WIP collaboration surface — narrow harvest. See `tab/mod.rs` for the
|
||||
// PR #2753 context.
|
||||
#![allow(dead_code)]
|
||||
|
||||
/// Parse a message and extract the first tab mention (1-indexed number).
|
||||
/// Returns `None` if no valid mention is found.
|
||||
///
|
||||
/// Recognized patterns:
|
||||
/// - `@Tab<number>` (e.g. `@Tab2`, `@tab3`)
|
||||
/// - `@<number>` at the start of a word (e.g. `@2`, `@3`)
|
||||
///
|
||||
/// The mention must be a single token (preceded by start-of-string or
|
||||
/// whitespace, followed by whitespace, punctuation, or end-of-string).
|
||||
pub fn extract_tab_mention(message: &str) -> Option<usize> {
|
||||
let bytes = message.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'@' {
|
||||
// Must be at start or after whitespace
|
||||
if i > 0 && !is_mention_boundary(bytes[i - 1]) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Try `@Tab<n>`
|
||||
if i + 4 <= bytes.len()
|
||||
&& bytes[i..i + 4].eq_ignore_ascii_case(b"@tab")
|
||||
&& i + 4 < bytes.len()
|
||||
&& bytes[i + 4].is_ascii_digit()
|
||||
{
|
||||
let num_start = i + 4;
|
||||
let num_end = scan_digits(bytes, num_start);
|
||||
if let Some(num) = parse_usize(&bytes[num_start..num_end])
|
||||
&& num > 0
|
||||
&& is_mention_terminator(bytes, num_end)
|
||||
{
|
||||
return Some(num);
|
||||
}
|
||||
}
|
||||
// Try `@<n>` shorthand
|
||||
if i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
|
||||
let num_start = i + 1;
|
||||
let num_end = scan_digits(bytes, num_start);
|
||||
if let Some(num) = parse_usize(&bytes[num_start..num_end])
|
||||
&& num > 0
|
||||
&& is_mention_terminator(bytes, num_end)
|
||||
{
|
||||
return Some(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract all tab mentions in order
|
||||
pub fn extract_all_tab_mentions(message: &str) -> Vec<usize> {
|
||||
let mut mentions = Vec::new();
|
||||
let bytes = message.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'@' && (i == 0 || is_mention_boundary(bytes[i - 1])) {
|
||||
// Determine the mention token length
|
||||
let token_start = i;
|
||||
// @Tab<n> or @<n>
|
||||
let mut j = i + 1;
|
||||
if j + 3 <= bytes.len() && bytes[j..j + 3].eq_ignore_ascii_case(b"tab") {
|
||||
j += 3;
|
||||
}
|
||||
let num_start = j;
|
||||
while j < bytes.len() && bytes[j].is_ascii_digit() {
|
||||
j += 1;
|
||||
}
|
||||
let num_end = j;
|
||||
if num_start < num_end
|
||||
&& is_mention_terminator(bytes, num_end)
|
||||
&& let Some(num) = parse_usize(&bytes[num_start..num_end])
|
||||
&& num > 0
|
||||
{
|
||||
mentions.push(num);
|
||||
// Also skip past the terminator
|
||||
i = num_end;
|
||||
continue;
|
||||
}
|
||||
let _ = token_start; // suppress unused
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
mentions
|
||||
}
|
||||
|
||||
fn is_mention_boundary(b: u8) -> bool {
|
||||
b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b',' || b == b';'
|
||||
}
|
||||
|
||||
fn is_mention_terminator(bytes: &[u8], pos: usize) -> bool {
|
||||
pos >= bytes.len()
|
||||
|| bytes[pos] == b' '
|
||||
|| bytes[pos] == b'\t'
|
||||
|| bytes[pos] == b'\n'
|
||||
|| bytes[pos] == b'\r'
|
||||
|| bytes[pos] == b','
|
||||
|| bytes[pos] == b'.'
|
||||
|| bytes[pos] == b'!'
|
||||
|| bytes[pos] == b'?'
|
||||
|| bytes[pos] == b';'
|
||||
|| bytes[pos] == b':'
|
||||
}
|
||||
|
||||
fn scan_digits(bytes: &[u8], start: usize) -> usize {
|
||||
let mut end = start;
|
||||
while end < bytes.len() && bytes[end].is_ascii_digit() {
|
||||
end += 1;
|
||||
}
|
||||
end
|
||||
}
|
||||
|
||||
fn parse_usize(s: &[u8]) -> Option<usize> {
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut result: usize = 0;
|
||||
for &b in s {
|
||||
if !b.is_ascii_digit() {
|
||||
return None;
|
||||
}
|
||||
result = result.checked_mul(10)?;
|
||||
result = result.checked_add((b - b'0') as usize)?;
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// Given a tab number (1-indexed) and the list of tab IDs, return the
|
||||
/// matching TabId. Returns `None` if the index is out of range.
|
||||
///
|
||||
/// The caller is expected to pass the IDs in **visual order** (i.e. the
|
||||
/// order they appear in the tab bar). We deliberately do not sort the
|
||||
/// list here — tab mentions like `@Tab2` should map to the second tab the
|
||||
/// user sees, not the second-smallest ID.
|
||||
pub fn resolve_tab_mention<'a, I>(tab_number: usize, tab_ids: I) -> Option<u64>
|
||||
where
|
||||
I: IntoIterator<Item = &'a u64>,
|
||||
{
|
||||
let ids: Vec<u64> = tab_ids.into_iter().copied().collect();
|
||||
if tab_number == 0 || tab_number > ids.len() {
|
||||
return None;
|
||||
}
|
||||
Some(ids[tab_number - 1])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_tab_mention() {
|
||||
assert_eq!(extract_tab_mention("Hello @Tab2!"), Some(2));
|
||||
assert_eq!(extract_tab_mention("see @3 please"), Some(3));
|
||||
assert_eq!(extract_tab_mention("@Tab1 help"), Some(1));
|
||||
assert_eq!(extract_tab_mention("no mention here"), None);
|
||||
assert_eq!(extract_tab_mention("email@2 is wrong"), None); // not at boundary
|
||||
assert_eq!(extract_tab_mention("@0 invalid"), None); // 0 not allowed
|
||||
assert_eq!(extract_tab_mention(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_case_insensitive() {
|
||||
assert_eq!(extract_tab_mention("Hello @TAB2!"), Some(2));
|
||||
assert_eq!(extract_tab_mention("see @tab3 please"), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_with_punctuation() {
|
||||
assert_eq!(extract_tab_mention("Please @Tab2, review"), Some(2));
|
||||
assert_eq!(extract_tab_mention("Ask @Tab2."), Some(2));
|
||||
assert_eq!(extract_tab_mention("Hey @Tab2!"), Some(2));
|
||||
assert_eq!(extract_tab_mention("What about @Tab2?"), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_all_mentions() {
|
||||
let mentions = extract_all_tab_mentions("Hey @Tab2 and @3, also @Tab1");
|
||||
assert_eq!(mentions, vec![2, 3, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_mention_at_start() {
|
||||
assert_eq!(extract_tab_mention("@Tab1 hi"), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_tab_mention() {
|
||||
// Tab IDs in the visual order they appear in the tab bar.
|
||||
let tab_ids = vec![100, 50, 200];
|
||||
// Tab 1 = first in visual order (100)
|
||||
assert_eq!(resolve_tab_mention(1, tab_ids.iter()), Some(100));
|
||||
// Tab 2 = second in visual order (50)
|
||||
assert_eq!(resolve_tab_mention(2, tab_ids.iter()), Some(50));
|
||||
// Tab 3 = third in visual order (200)
|
||||
assert_eq!(resolve_tab_mention(3, tab_ids.iter()), Some(200));
|
||||
// Out of range
|
||||
assert_eq!(resolve_tab_mention(4, tab_ids.iter()), None);
|
||||
assert_eq!(resolve_tab_mention(0, tab_ids.iter()), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Multi-tab system for CodeWhale TUI
|
||||
//!
|
||||
//! This module provides support for multiple concurrent agent sessions
|
||||
//! in a tabbed interface, similar to Claude Code Windows.
|
||||
|
||||
// Cross-tab collaboration APIs (delegator, meeting, cross_tab, group,
|
||||
// mention) are intentionally exposed here as a public surface for the
|
||||
// narrow tab-core harvest. They are not yet wired into the TUI host
|
||||
// (that lands in a follow-up UI pass) and therefore trip `dead_code`
|
||||
// inside the binary crate. The `pub use manager::TabManager` re-export
|
||||
// is the public entry point for that follow-up wiring, so it is also
|
||||
// marked `unused_imports`-tolerated in the meantime.
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
#[cfg(test)]
|
||||
mod benches;
|
||||
mod cross_tab;
|
||||
mod delegator;
|
||||
pub mod group;
|
||||
#[cfg(test)]
|
||||
mod key_e2e;
|
||||
mod manager;
|
||||
pub mod meeting;
|
||||
pub mod mention;
|
||||
pub mod persistence;
|
||||
|
||||
pub use manager::TabManager;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Unique identifier for a tab
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct TabId(pub u64);
|
||||
|
||||
impl TabId {
|
||||
pub fn new(id: u64) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tab type determining the session mode
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum TabType {
|
||||
/// Regular conversation session
|
||||
#[default]
|
||||
Chat,
|
||||
/// Task delegation session
|
||||
Delegation,
|
||||
/// Code review session
|
||||
Review,
|
||||
/// Multi-agent meeting session
|
||||
Meeting,
|
||||
}
|
||||
|
||||
impl TabType {
|
||||
/// Short single-character icon for display in tight UI (tab bar, picker)
|
||||
pub fn icon(&self) -> &'static str {
|
||||
match self {
|
||||
TabType::Chat => "💬",
|
||||
TabType::Delegation => "📤",
|
||||
TabType::Review => "🔍",
|
||||
TabType::Meeting => "👥",
|
||||
}
|
||||
}
|
||||
|
||||
/// ASCII fallback icon (for terminals without emoji support)
|
||||
pub fn ascii_icon(&self) -> &'static str {
|
||||
match self {
|
||||
TabType::Chat => "[C]",
|
||||
TabType::Delegation => "[D]",
|
||||
TabType::Review => "[R]",
|
||||
TabType::Meeting => "[M]",
|
||||
}
|
||||
}
|
||||
|
||||
/// Display name for the tab type
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
TabType::Chat => "Chat",
|
||||
TabType::Delegation => "Delegation",
|
||||
TabType::Review => "Review",
|
||||
TabType::Meeting => "Meeting",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata for a tab
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TabMetadata {
|
||||
pub id: TabId,
|
||||
pub title: String,
|
||||
pub tab_type: TabType,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_active: DateTime<Utc>,
|
||||
pub unread_count: usize,
|
||||
pub agent_name: Option<String>,
|
||||
pub session_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl TabMetadata {
|
||||
pub fn new(id: TabId, title: String, tab_type: TabType) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id,
|
||||
title,
|
||||
tab_type,
|
||||
created_at: now,
|
||||
last_active: now,
|
||||
unread_count: 0,
|
||||
agent_name: None,
|
||||
session_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn touch(&mut self) {
|
||||
self.last_active = Utc::now();
|
||||
}
|
||||
|
||||
pub fn increment_unread(&mut self) {
|
||||
self.unread_count += 1;
|
||||
}
|
||||
|
||||
pub fn clear_unread(&mut self) {
|
||||
self.unread_count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Priority levels for task delegation
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
|
||||
pub enum Priority {
|
||||
Low = 0,
|
||||
#[default]
|
||||
Normal = 1,
|
||||
High = 2,
|
||||
Urgent = 3,
|
||||
}
|
||||
|
||||
/// Status of a tab
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum TabStatus {
|
||||
Active,
|
||||
#[default]
|
||||
Idle,
|
||||
Loading,
|
||||
Error,
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
//! Tab state persistence
|
||||
//!
|
||||
//! Saves the TabManager's tab list (titles, types, IDs, creation time) to a
|
||||
//! JSON file in the user's data directory. On startup, the file is loaded
|
||||
//! to restore the previous session's tabs.
|
||||
//!
|
||||
//! Note: messages and conversation history are NOT persisted here - those
|
||||
//! live in the session_manager. This file only stores the tab metadata.
|
||||
|
||||
// WIP collaboration surface — narrow harvest. See `tab/mod.rs` for the
|
||||
// PR #2753 context.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::delegator::DelegationStatus;
|
||||
use super::{Priority, TabId, TabMetadata, TabType};
|
||||
|
||||
/// Current schema version. Bump when making breaking changes to the
|
||||
/// on-disk format. Older versions are detected on load so we can
|
||||
/// give a useful error message rather than silently dropping data.
|
||||
pub const CURRENT_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// Maximum size of the tab state file we'll attempt to load (1 MB).
|
||||
/// Past this, the file is treated as corrupted and ignored. This
|
||||
/// prevents a malicious or accidental huge file from OOM-ing the TUI
|
||||
/// on startup.
|
||||
pub const MAX_FILE_SIZE: u64 = 1024 * 1024;
|
||||
|
||||
/// On-disk representation of a single tab
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersistedTab {
|
||||
pub id: u64,
|
||||
pub title: String,
|
||||
pub tab_type: TabType,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_active: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// On-disk representation of a tab group
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersistedGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub color: super::group::GroupColor,
|
||||
pub tab_ids: Vec<TabId>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// On-disk representation of a single delegation task
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersistedDelegation {
|
||||
pub task_id: String,
|
||||
pub from_tab: u64,
|
||||
pub to_tab: u64,
|
||||
pub description: String,
|
||||
pub priority: Priority,
|
||||
/// Status of the delegation when it was snapshotted. Without this field,
|
||||
/// an in-flight `InProgress` task is silently demoted to `Pending` on
|
||||
/// restart, losing work-in-progress state.
|
||||
pub status: DelegationStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
pub result: Option<String>,
|
||||
pub was_successful: Option<bool>,
|
||||
}
|
||||
|
||||
/// On-disk representation of the tab manager state
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersistedTabState {
|
||||
pub version: u32,
|
||||
pub saved_at: DateTime<Utc>,
|
||||
pub active_tab_index: Option<usize>,
|
||||
pub tabs: Vec<PersistedTab>,
|
||||
pub delegations: Vec<PersistedDelegation>,
|
||||
#[serde(default)]
|
||||
pub groups: Vec<PersistedGroup>,
|
||||
}
|
||||
|
||||
impl Default for PersistedTabState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: 1,
|
||||
saved_at: Utc::now(),
|
||||
active_tab_index: None,
|
||||
tabs: Vec::new(),
|
||||
delegations: Vec::new(),
|
||||
groups: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the default path for the tab state file
|
||||
/// `~/.codewhale/tabs.json`
|
||||
pub fn default_tab_state_path() -> Option<PathBuf> {
|
||||
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
|
||||
Some(PathBuf::from(home).join(".codewhale").join("tabs.json"))
|
||||
}
|
||||
|
||||
/// Save the tab state to a file.
|
||||
/// Atomically writes via temp file + rename to prevent corruption
|
||||
/// from interrupted writes.
|
||||
pub fn save_to_file(state: &PersistedTabState, path: &Path) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(state)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
// Write to temp file first, then rename for atomicity
|
||||
let tmp_path = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp_path, json)?;
|
||||
std::fs::rename(&tmp_path, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load the tab state from a file. Returns default state if file doesn't exist.
|
||||
/// Refuses to load files larger than MAX_FILE_SIZE to prevent OOM.
|
||||
/// Detects schema version mismatches and returns a specific error.
|
||||
pub fn load_from_file(path: &Path) -> std::io::Result<PersistedTabState> {
|
||||
if !path.exists() {
|
||||
return Ok(PersistedTabState::default());
|
||||
}
|
||||
|
||||
// Size check
|
||||
let metadata = std::fs::metadata(path)?;
|
||||
if metadata.len() > MAX_FILE_SIZE {
|
||||
// Silently returning `default()` would let the next save overwrite
|
||||
// the oversized file and destroy the user's data. Surface the error
|
||||
// so the application can refuse to save and preserve the file.
|
||||
tracing::error!(
|
||||
size = metadata.len(),
|
||||
max = MAX_FILE_SIZE,
|
||||
path = %path.display(),
|
||||
"Tab state file too large, refusing to load"
|
||||
);
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Tab state file size {} exceeds maximum allowed size {}",
|
||||
metadata.len(),
|
||||
MAX_FILE_SIZE
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let state: PersistedTabState = serde_json::from_str(&content).map_err(|e| {
|
||||
tracing::error!(
|
||||
?e,
|
||||
path = %path.display(),
|
||||
"Failed to parse tab state file"
|
||||
);
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
|
||||
})?;
|
||||
|
||||
// Schema version check
|
||||
if state.version > CURRENT_SCHEMA_VERSION {
|
||||
tracing::warn!(
|
||||
file_version = state.version,
|
||||
current = CURRENT_SCHEMA_VERSION,
|
||||
"Tab state file is from a newer version; some data may be ignored"
|
||||
);
|
||||
} else if state.version < CURRENT_SCHEMA_VERSION {
|
||||
tracing::info!(
|
||||
file_version = state.version,
|
||||
current = CURRENT_SCHEMA_VERSION,
|
||||
"Migrating tab state from older schema"
|
||||
);
|
||||
// Future: implement migration logic here
|
||||
}
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Convert a TabMetadata to its persisted form
|
||||
pub fn from_metadata(meta: &TabMetadata) -> PersistedTab {
|
||||
PersistedTab {
|
||||
id: meta.id.0,
|
||||
title: meta.title.clone(),
|
||||
tab_type: meta.tab_type,
|
||||
created_at: meta.created_at,
|
||||
last_active: meta.last_active,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a persisted tab to a TabMetadata
|
||||
pub fn to_metadata(persisted: &PersistedTab) -> TabMetadata {
|
||||
let mut meta = TabMetadata::new(
|
||||
TabId::new(persisted.id),
|
||||
persisted.title.clone(),
|
||||
persisted.tab_type,
|
||||
);
|
||||
meta.created_at = persisted.created_at;
|
||||
meta.last_active = persisted.last_active;
|
||||
meta
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip() {
|
||||
let state = PersistedTabState {
|
||||
version: 1,
|
||||
saved_at: Utc::now(),
|
||||
active_tab_index: Some(0),
|
||||
tabs: vec![
|
||||
PersistedTab {
|
||||
id: 1,
|
||||
title: "Tab 1".to_string(),
|
||||
tab_type: TabType::Chat,
|
||||
created_at: Utc::now(),
|
||||
last_active: Utc::now(),
|
||||
},
|
||||
PersistedTab {
|
||||
id: 2,
|
||||
title: "Tab 2".to_string(),
|
||||
tab_type: TabType::Meeting,
|
||||
created_at: Utc::now(),
|
||||
last_active: Utc::now(),
|
||||
},
|
||||
],
|
||||
delegations: vec![PersistedDelegation {
|
||||
task_id: "delegation_1".to_string(),
|
||||
from_tab: 1,
|
||||
to_tab: 2,
|
||||
description: "Review code".to_string(),
|
||||
priority: Priority::High,
|
||||
status: DelegationStatus::Pending,
|
||||
created_at: Utc::now(),
|
||||
completed_at: None,
|
||||
result: None,
|
||||
was_successful: None,
|
||||
}],
|
||||
groups: vec![],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&state).unwrap();
|
||||
let parsed: PersistedTabState = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(parsed.tabs.len(), 2);
|
||||
assert_eq!(parsed.tabs[0].title, "Tab 1");
|
||||
assert_eq!(parsed.tabs[1].tab_type, TabType::Meeting);
|
||||
assert_eq!(parsed.delegations.len(), 1);
|
||||
assert_eq!(parsed.delegations[0].priority, Priority::High);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metadata_conversion() {
|
||||
let meta = TabMetadata::new(TabId::new(42), "Test".to_string(), TabType::Review);
|
||||
let persisted = from_metadata(&meta);
|
||||
assert_eq!(persisted.id, 42);
|
||||
assert_eq!(persisted.title, "Test");
|
||||
assert_eq!(persisted.tab_type, TabType::Review);
|
||||
|
||||
let restored = to_metadata(&persisted);
|
||||
assert_eq!(restored.id, TabId::new(42));
|
||||
assert_eq!(restored.title, "Test");
|
||||
assert_eq!(restored.tab_type, TabType::Review);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_missing_file() {
|
||||
let result = load_from_file(Path::new("/nonexistent/path/tabs.json"));
|
||||
assert!(result.is_ok());
|
||||
let state = result.unwrap();
|
||||
assert!(state.tabs.is_empty());
|
||||
assert!(state.delegations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load() {
|
||||
let dir = std::env::temp_dir().join("codewhale_tab_test");
|
||||
let path = dir.join("tabs.json");
|
||||
|
||||
// Clean up any leftover
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let state = PersistedTabState {
|
||||
version: 1,
|
||||
saved_at: Utc::now(),
|
||||
active_tab_index: Some(1),
|
||||
tabs: vec![PersistedTab {
|
||||
id: 1,
|
||||
title: "Test".to_string(),
|
||||
tab_type: TabType::Delegation,
|
||||
created_at: Utc::now(),
|
||||
last_active: Utc::now(),
|
||||
}],
|
||||
delegations: vec![],
|
||||
groups: vec![],
|
||||
};
|
||||
|
||||
save_to_file(&state, &path).unwrap();
|
||||
let loaded = load_from_file(&path).unwrap();
|
||||
assert_eq!(loaded.active_tab_index, Some(1));
|
||||
assert_eq!(loaded.tabs.len(), 1);
|
||||
assert_eq!(loaded.tabs[0].tab_type, TabType::Delegation);
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_oversized_file_rejected() {
|
||||
// Create a file that exceeds MAX_FILE_SIZE
|
||||
let dir = std::env::temp_dir().join("codewhale_tab_oversize");
|
||||
let path = dir.join("tabs.json");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
// Write a small header followed by enough junk to exceed 1MB
|
||||
let mut content = String::from(
|
||||
r#"{"version":1,"saved_at":"2026-01-01T00:00:00Z","active_tab_index":null,"tabs":[],"delegations":[]}"#,
|
||||
);
|
||||
while content.len() < (MAX_FILE_SIZE as usize) + 100 {
|
||||
content.push(' ');
|
||||
}
|
||||
std::fs::write(&path, content).unwrap();
|
||||
|
||||
// Should return an error rather than silently overwriting the file
|
||||
// on next save. Silently returning a default would destroy the
|
||||
// user's data.
|
||||
let result = load_from_file(&path);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_corrupted_file() {
|
||||
let dir = std::env::temp_dir().join("codewhale_tab_corrupt");
|
||||
let path = dir.join("tabs.json");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
// Write invalid JSON
|
||||
std::fs::write(&path, "{ not valid json :::").unwrap();
|
||||
|
||||
// Should return error
|
||||
let result = load_from_file(&path);
|
||||
assert!(result.is_err());
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_is_atomic() {
|
||||
// Verify that save_to_file uses a temp + rename pattern.
|
||||
// The test ensures the final file exists and no .tmp file remains.
|
||||
let dir = std::env::temp_dir().join("codewhale_tab_atomic");
|
||||
let path = dir.join("tabs.json");
|
||||
let tmp_path = path.with_extension("json.tmp");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
|
||||
let state = PersistedTabState::default();
|
||||
save_to_file(&state, &path).unwrap();
|
||||
|
||||
assert!(path.exists(), "Final file should exist");
|
||||
assert!(!tmp_path.exists(), "Temp file should be cleaned up");
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_newer_schema_logs_warning_but_loads() {
|
||||
// Simulate a file from a future version
|
||||
let dir = std::env::temp_dir().join("codewhale_tab_newer");
|
||||
let path = dir.join("tabs.json");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let json = r#"{"version":99,"saved_at":"2027-01-01T00:00:00Z","active_tab_index":null,"tabs":[],"delegations":[]}"#;
|
||||
std::fs::write(&path, json).unwrap();
|
||||
|
||||
// Should still load successfully (graceful degradation)
|
||||
let loaded = load_from_file(&path).unwrap();
|
||||
assert_eq!(loaded.version, 99);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user