Add session metadata: creator tracking, descriptions, timestamps, and sorting
Sessions now track who created them (Human vs Agent with name), optional descriptions, and creation timestamps. Agents can create and update sessions via WebSocket and MCP. ListSessions supports sorting by name, created_at, created_by, or element_count. New MCP tools: create_session, update_session. Updated list_sessions with sort_by/sort_order params. Tab bar shows robot icon for agent-created sessions with hover tooltips.
This commit is contained in:
38
README.md
38
README.md
@@ -33,7 +33,7 @@ agcanvas bridges the gap between visual design and code generation. It's a **col
|
||||
- **Shape Drawing** — Rectangles, ellipses, lines, arrows, text directly on canvas
|
||||
- **Selection & Editing** — Select, move, resize shapes with corner handles
|
||||
- **Mermaid Diagrams** — Write Mermaid syntax, render as SVG on canvas
|
||||
- **Sessions/Tabs** — Multiple canvases in tabs, each with independent state
|
||||
- **Sessions/Tabs** — Multiple canvases in tabs, each with independent state, creator tracking (human vs agent), descriptions, and timestamps
|
||||
- **Pan/Zoom** — Smooth canvas navigation
|
||||
|
||||
### AI Agent Integration
|
||||
@@ -179,7 +179,9 @@ Same MCP config format — add the `agcanvas` entry to your Codex MCP configurat
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_sessions` | List all open tabs/sessions in agcanvas |
|
||||
| `list_sessions` | List all open tabs/sessions with creator info, descriptions, timestamps. Supports sorting by name, created_at, created_by, element_count |
|
||||
| `create_session` | Create a new session/tab from an agent, with name, description, and creator identity |
|
||||
| `update_session` | Update an existing session's name or description |
|
||||
| `get_element_tree` | Get the full parsed SVG element tree (structured JSON) |
|
||||
| `describe_canvas` | Get a human-readable description of the canvas |
|
||||
| `get_element_by_id` | Look up a specific element by ID |
|
||||
@@ -214,20 +216,45 @@ All requests support an optional `session_id` parameter. If omitted, the active
|
||||
|
||||
```json
|
||||
{"type": "ListSessions"}
|
||||
{"type": "ListSessions", "sort_by": "created_at", "sort_order": "desc"}
|
||||
```
|
||||
|
||||
Sort fields: `name`, `created_at` (default), `created_by`, `element_count`. Order: `asc` (default), `desc`.
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"type": "Sessions",
|
||||
"sessions": [
|
||||
{"id": "session-1", "name": "Tab 1", "has_svg": true, "element_count": 15},
|
||||
{"id": "session-2", "name": "Tab 2", "has_svg": false, "element_count": null}
|
||||
{"id": "session-1", "name": "Tab 1", "has_svg": true, "element_count": 15, "description": null, "created_by": {"type": "Human"}, "created_at": 1707500000},
|
||||
{"id": "session-2", "name": "Agent Work", "has_svg": false, "element_count": null, "description": "Architecture diagram", "created_by": {"type": "Agent", "name": "Claude"}, "created_at": 1707500100}
|
||||
],
|
||||
"active_session": "session-1"
|
||||
}
|
||||
```
|
||||
|
||||
#### Create session (agent)
|
||||
|
||||
```json
|
||||
{"type": "CreateSession", "name": "My Session", "description": "Working on auth flow", "created_by_name": "Claude"}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{"type": "SessionCreated", "session": {"id": "session-3", "name": "My Session", ...}}
|
||||
```
|
||||
|
||||
#### Update session
|
||||
|
||||
```json
|
||||
{"type": "UpdateSession", "session_id": "session-1", "name": "Renamed", "description": "Updated description"}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{"type": "SessionUpdated", "session": {"id": "session-1", "name": "Renamed", ...}}
|
||||
```
|
||||
|
||||
#### Get full element tree
|
||||
|
||||
```json
|
||||
@@ -354,8 +381,9 @@ crates/
|
||||
- [x] Selection, move, resize with handles
|
||||
- [x] Mermaid diagram rendering
|
||||
- [x] MCP server bridge for AI coding tools
|
||||
- [x] Agent draw commands (modify canvas from agent)
|
||||
- [x] Session metadata (creator tracking, descriptions, timestamps, sorting)
|
||||
- [ ] Real code generation (not just stubs)
|
||||
- [ ] Agent draw commands (modify canvas from agent)
|
||||
- [ ] Export to file
|
||||
- [ ] Diff view (before/after agent changes)
|
||||
- [ ] Plugin system for code generators
|
||||
|
||||
@@ -11,6 +11,36 @@ pub struct SessionIdParam {
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct ListSessionsParam {
|
||||
#[schemars(
|
||||
description = "Sort field: 'name', 'created_at' (default), 'created_by', or 'element_count'"
|
||||
)]
|
||||
pub sort_by: Option<String>,
|
||||
#[schemars(description = "Sort order: 'asc' (default) or 'desc'")]
|
||||
pub sort_order: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct CreateSessionParam {
|
||||
#[schemars(description = "Session name. If omitted, auto-generated.")]
|
||||
pub name: Option<String>,
|
||||
#[schemars(description = "Session description.")]
|
||||
pub description: Option<String>,
|
||||
#[schemars(description = "Name of the agent creating the session (identifies the creator).")]
|
||||
pub created_by_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct UpdateSessionParam {
|
||||
#[schemars(description = "ID of the session to update.")]
|
||||
pub session_id: String,
|
||||
#[schemars(description = "New session name.")]
|
||||
pub name: Option<String>,
|
||||
#[schemars(description = "New session description.")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct GetElementParam {
|
||||
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||
@@ -155,10 +185,62 @@ impl AgCanvasServer {
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "List all open sessions/tabs in agcanvas. Returns session IDs, names, and whether they have SVG or drawing content loaded."
|
||||
description = "List all open sessions/tabs in agcanvas. Returns session IDs, names, creator info, description, timestamps, and whether they have SVG or drawing content loaded. Supports sorting."
|
||||
)]
|
||||
async fn list_sessions(&self) -> Result<CallToolResult, McpError> {
|
||||
let request = serde_json::json!({"type": "ListSessions"});
|
||||
async fn list_sessions(
|
||||
&self,
|
||||
Parameters(params): Parameters<ListSessionsParam>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut request = serde_json::json!({"type": "ListSessions"});
|
||||
let obj = request.as_object_mut().unwrap();
|
||||
if let Some(v) = params.sort_by {
|
||||
obj.insert("sort_by".into(), v.into());
|
||||
}
|
||||
if let Some(v) = params.sort_order {
|
||||
obj.insert("sort_order".into(), v.into());
|
||||
}
|
||||
self.call_agcanvas(&request).await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Create a new session/tab in agcanvas. The session is created by an agent. Returns the created session with its ID and metadata."
|
||||
)]
|
||||
async fn create_session(
|
||||
&self,
|
||||
Parameters(params): Parameters<CreateSessionParam>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut request = serde_json::json!({"type": "CreateSession"});
|
||||
let obj = request.as_object_mut().unwrap();
|
||||
if let Some(v) = params.name {
|
||||
obj.insert("name".into(), v.into());
|
||||
}
|
||||
if let Some(v) = params.description {
|
||||
obj.insert("description".into(), v.into());
|
||||
}
|
||||
if let Some(v) = params.created_by_name {
|
||||
obj.insert("created_by_name".into(), v.into());
|
||||
}
|
||||
self.call_agcanvas(&request).await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Update an existing session's name or description. Only provided fields are changed."
|
||||
)]
|
||||
async fn update_session(
|
||||
&self,
|
||||
Parameters(params): Parameters<UpdateSessionParam>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut request = serde_json::json!({
|
||||
"type": "UpdateSession",
|
||||
"session_id": params.session_id,
|
||||
});
|
||||
let obj = request.as_object_mut().unwrap();
|
||||
if let Some(v) = params.name {
|
||||
obj.insert("name".into(), v.into());
|
||||
}
|
||||
if let Some(v) = params.description {
|
||||
obj.insert("description".into(), v.into());
|
||||
}
|
||||
self.call_agcanvas(&request).await
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
mod protocol;
|
||||
mod server;
|
||||
|
||||
pub use protocol::{DrawingCommand, GuiEvent};
|
||||
pub use protocol::{DrawingCommand, GuiEvent, SessionCommand};
|
||||
pub use server::AgentServer;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::drawing::{DrawingElement, Shape, ShapeStyle};
|
||||
use crate::element_tree::{ElementTree, TreeMetadata};
|
||||
use crate::session::SessionInfo;
|
||||
use crate::session::{SessionCreator, SessionInfo, SessionSortField, SortOrder};
|
||||
use egui::{Color32, Pos2, Vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -56,7 +56,27 @@ pub enum GuiEvent {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum AgentRequest {
|
||||
ListSessions,
|
||||
ListSessions {
|
||||
#[serde(default)]
|
||||
sort_by: Option<SessionSortField>,
|
||||
#[serde(default)]
|
||||
sort_order: Option<SortOrder>,
|
||||
},
|
||||
CreateSession {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
created_by_name: Option<String>,
|
||||
},
|
||||
UpdateSession {
|
||||
session_id: String,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
},
|
||||
GetTree {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
@@ -192,6 +212,12 @@ pub enum AgentResponse {
|
||||
sessions: Vec<SessionInfo>,
|
||||
active_session: Option<String>,
|
||||
},
|
||||
SessionCreated {
|
||||
session: SessionInfo,
|
||||
},
|
||||
SessionUpdated {
|
||||
session: SessionInfo,
|
||||
},
|
||||
Tree {
|
||||
session_id: String,
|
||||
tree: ElementTree,
|
||||
@@ -261,6 +287,25 @@ pub enum DrawingCommand {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent → GUI session commands (reverse sync channel for session management)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SessionCommand {
|
||||
Create {
|
||||
id: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
created_by: SessionCreator,
|
||||
},
|
||||
Update {
|
||||
session_id: String,
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Code generation targets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use super::protocol::{
|
||||
build_shape, build_style, AgentRequest, AgentResponse, CodeGenTarget, DrawingCommand, GuiEvent,
|
||||
SessionCommand,
|
||||
};
|
||||
use crate::drawing::DrawingElement;
|
||||
use crate::session::SessionStore;
|
||||
use crate::session::{SessionCreator, SessionStore};
|
||||
use anyhow::Result;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use std::sync::Arc;
|
||||
@@ -16,16 +17,22 @@ pub struct AgentServer {
|
||||
sessions: Arc<RwLock<SessionStore>>,
|
||||
event_tx: broadcast::Sender<GuiEvent>,
|
||||
command_tx: mpsc::UnboundedSender<DrawingCommand>,
|
||||
session_command_tx: mpsc::UnboundedSender<SessionCommand>,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl AgentServer {
|
||||
pub fn new(port: u16, command_tx: mpsc::UnboundedSender<DrawingCommand>) -> Self {
|
||||
pub fn new(
|
||||
port: u16,
|
||||
command_tx: mpsc::UnboundedSender<DrawingCommand>,
|
||||
session_command_tx: mpsc::UnboundedSender<SessionCommand>,
|
||||
) -> Self {
|
||||
let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(SessionStore::new())),
|
||||
event_tx,
|
||||
command_tx,
|
||||
session_command_tx,
|
||||
port,
|
||||
}
|
||||
}
|
||||
@@ -49,9 +56,17 @@ impl AgentServer {
|
||||
let event_tx = self.event_tx.clone();
|
||||
let event_rx = self.event_tx.subscribe();
|
||||
let command_tx = self.command_tx.clone();
|
||||
let session_command_tx = self.session_command_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) =
|
||||
handle_connection(stream, sessions, event_rx, event_tx, command_tx).await
|
||||
if let Err(e) = handle_connection(
|
||||
stream,
|
||||
sessions,
|
||||
event_rx,
|
||||
event_tx,
|
||||
command_tx,
|
||||
session_command_tx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Connection error: {}", e);
|
||||
}
|
||||
@@ -68,6 +83,7 @@ async fn handle_connection(
|
||||
mut event_rx: broadcast::Receiver<GuiEvent>,
|
||||
event_tx: broadcast::Sender<GuiEvent>,
|
||||
command_tx: mpsc::UnboundedSender<DrawingCommand>,
|
||||
session_command_tx: mpsc::UnboundedSender<SessionCommand>,
|
||||
) -> Result<()> {
|
||||
let ws_stream = tokio_tungstenite::accept_async(stream).await?;
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
@@ -91,7 +107,7 @@ async fn handle_connection(
|
||||
let response = match serde_json::from_str::<AgentRequest>(&text) {
|
||||
Ok(request) => {
|
||||
process_request(
|
||||
request, &sessions, &event_tx, &command_tx,
|
||||
request, &sessions, &event_tx, &command_tx, &session_command_tx,
|
||||
).await
|
||||
}
|
||||
Err(e) => AgentResponse::Error {
|
||||
@@ -134,18 +150,98 @@ async fn process_request(
|
||||
sessions: &Arc<RwLock<SessionStore>>,
|
||||
event_tx: &broadcast::Sender<GuiEvent>,
|
||||
command_tx: &mpsc::UnboundedSender<DrawingCommand>,
|
||||
session_command_tx: &mpsc::UnboundedSender<SessionCommand>,
|
||||
) -> AgentResponse {
|
||||
match request {
|
||||
AgentRequest::Ping => AgentResponse::Pong,
|
||||
|
||||
AgentRequest::ListSessions => {
|
||||
AgentRequest::ListSessions {
|
||||
sort_by,
|
||||
sort_order,
|
||||
} => {
|
||||
let store = sessions.read().await;
|
||||
let sessions_list = store
|
||||
.list_sessions_sorted(sort_by.unwrap_or_default(), sort_order.unwrap_or_default());
|
||||
AgentResponse::Sessions {
|
||||
sessions: store.list_sessions(),
|
||||
sessions: sessions_list,
|
||||
active_session: store.active_session_id().map(|s| s.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
AgentRequest::CreateSession {
|
||||
name,
|
||||
description,
|
||||
created_by_name,
|
||||
} => {
|
||||
let mut store = sessions.write().await;
|
||||
let id = store.next_session_id();
|
||||
let session_name = name.unwrap_or_else(|| format!("Agent Session {}", &id));
|
||||
let created_by = SessionCreator::Agent {
|
||||
name: created_by_name,
|
||||
};
|
||||
|
||||
let info = crate::session::SessionInfo {
|
||||
id: id.clone(),
|
||||
name: session_name.clone(),
|
||||
has_svg: false,
|
||||
element_count: None,
|
||||
drawing_element_count: 0,
|
||||
description: description.clone(),
|
||||
created_by: created_by.clone(),
|
||||
created_at: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0),
|
||||
};
|
||||
|
||||
store.add_session(info.clone(), None);
|
||||
drop(store);
|
||||
|
||||
let _ = session_command_tx.send(SessionCommand::Create {
|
||||
id: id.clone(),
|
||||
name: session_name,
|
||||
description,
|
||||
created_by,
|
||||
});
|
||||
let _ = event_tx.send(GuiEvent::SessionCreated {
|
||||
session: info.clone(),
|
||||
});
|
||||
|
||||
AgentResponse::SessionCreated { session: info }
|
||||
}
|
||||
|
||||
AgentRequest::UpdateSession {
|
||||
session_id,
|
||||
name,
|
||||
description,
|
||||
} => {
|
||||
let mut store = sessions.write().await;
|
||||
let desc_update = description.as_ref().map(|d| Some(d.clone()));
|
||||
if !store.update_session_meta(&session_id, name.clone(), desc_update) {
|
||||
return AgentResponse::Error {
|
||||
message: format!("Session '{}' not found", session_id),
|
||||
};
|
||||
}
|
||||
let info = store
|
||||
.list_sessions()
|
||||
.into_iter()
|
||||
.find(|s| s.id == session_id);
|
||||
drop(store);
|
||||
|
||||
let _ = session_command_tx.send(SessionCommand::Update {
|
||||
session_id: session_id.clone(),
|
||||
name,
|
||||
description,
|
||||
});
|
||||
|
||||
match info {
|
||||
Some(session) => AgentResponse::SessionUpdated { session },
|
||||
None => AgentResponse::Error {
|
||||
message: format!("Session '{}' not found after update", session_id),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
AgentRequest::GetTree { session_id } => {
|
||||
let store = sessions.read().await;
|
||||
match store.get_tree(session_id.as_deref()) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::agent::{AgentServer, DrawingCommand, GuiEvent};
|
||||
use crate::agent::{AgentServer, DrawingCommand, GuiEvent, SessionCommand};
|
||||
use crate::canvas::{CanvasInteraction, CanvasState};
|
||||
use crate::clipboard::ClipboardManager;
|
||||
use crate::drawing::{
|
||||
@@ -6,7 +6,7 @@ use crate::drawing::{
|
||||
screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, Tool,
|
||||
};
|
||||
use crate::mermaid::render_mermaid_to_svg;
|
||||
use crate::session::{Session, SessionStore};
|
||||
use crate::session::{Session, SessionCreator, SessionStore};
|
||||
use crate::svg::{parse_svg, SvgRenderer};
|
||||
use egui::{Color32, ColorImage, TextureOptions};
|
||||
use std::sync::Arc;
|
||||
@@ -23,6 +23,7 @@ pub struct AgCanvasApp {
|
||||
sessions_handle: Arc<RwLock<SessionStore>>,
|
||||
event_tx: broadcast::Sender<GuiEvent>,
|
||||
command_rx: mpsc::UnboundedReceiver<DrawingCommand>,
|
||||
session_command_rx: mpsc::UnboundedReceiver<SessionCommand>,
|
||||
clipboard: Option<ClipboardManager>,
|
||||
show_tree_panel: bool,
|
||||
show_description: bool,
|
||||
@@ -43,7 +44,8 @@ impl AgCanvasApp {
|
||||
|
||||
let runtime = Runtime::new().expect("Failed to create tokio runtime");
|
||||
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
||||
let server = AgentServer::new(AGENT_PORT, command_tx);
|
||||
let (session_command_tx, session_command_rx) = mpsc::unbounded_channel();
|
||||
let server = AgentServer::new(AGENT_PORT, command_tx, session_command_tx);
|
||||
let sessions_handle = server.sessions_handle();
|
||||
let event_tx = server.event_sender();
|
||||
|
||||
@@ -62,6 +64,7 @@ impl AgCanvasApp {
|
||||
sessions_handle,
|
||||
event_tx,
|
||||
command_rx,
|
||||
session_command_rx,
|
||||
clipboard,
|
||||
show_tree_panel: false,
|
||||
show_description: false,
|
||||
@@ -83,7 +86,7 @@ impl AgCanvasApp {
|
||||
self.session_counter += 1;
|
||||
let id = format!("session-{}", self.session_counter);
|
||||
let name = format!("Tab {}", self.session_counter);
|
||||
let session = Session::new(id.clone(), name);
|
||||
let session = Session::new(id.clone(), name, SessionCreator::Human);
|
||||
let info = session.info();
|
||||
|
||||
self.sessions.push(session);
|
||||
@@ -321,6 +324,46 @@ impl AgCanvasApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_session_commands(&mut self) {
|
||||
while let Ok(cmd) = self.session_command_rx.try_recv() {
|
||||
match cmd {
|
||||
SessionCommand::Create {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
created_by,
|
||||
} => {
|
||||
let session =
|
||||
Session::new(id.clone(), name, created_by).with_description(description);
|
||||
self.sessions.push(session);
|
||||
self.active_session_idx = self.sessions.len() - 1;
|
||||
|
||||
if let Some(num) = id.strip_prefix("session-") {
|
||||
if let Ok(n) = num.parse::<usize>() {
|
||||
if n >= self.session_counter {
|
||||
self.session_counter = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SessionCommand::Update {
|
||||
session_id,
|
||||
name,
|
||||
description,
|
||||
} => {
|
||||
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
|
||||
if let Some(n) = name {
|
||||
session.name = n;
|
||||
}
|
||||
if let Some(d) = description {
|
||||
session.description = Some(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_drawing_elements_to_store(&self) {
|
||||
let sessions_handle = self.sessions_handle.clone();
|
||||
let elements_by_session: Vec<(String, Vec<DrawingElement>)> = self
|
||||
@@ -534,6 +577,7 @@ fn handle_shape_tool(
|
||||
impl eframe::App for AgCanvasApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.drain_drawing_commands();
|
||||
self.drain_session_commands();
|
||||
|
||||
let mut paste = false;
|
||||
let mut new_tab = false;
|
||||
@@ -678,10 +722,15 @@ impl eframe::App for AgCanvasApp {
|
||||
let is_active = idx == self.active_session_idx;
|
||||
let has_content =
|
||||
session.element_tree.is_some() || !session.drawing_elements.is_empty();
|
||||
|
||||
let creator_icon = match &session.created_by {
|
||||
SessionCreator::Human => "",
|
||||
SessionCreator::Agent { .. } => "\u{1F916} ",
|
||||
};
|
||||
let label = if has_content {
|
||||
format!("{} *", session.name)
|
||||
format!("{}{} *", creator_icon, session.name)
|
||||
} else {
|
||||
session.name.clone()
|
||||
format!("{}{}", creator_icon, session.name)
|
||||
};
|
||||
|
||||
let button = egui::Button::new(&label).fill(if is_active {
|
||||
@@ -695,6 +744,14 @@ impl eframe::App for AgCanvasApp {
|
||||
switch_idx = Some(idx);
|
||||
}
|
||||
|
||||
let mut tooltip_parts = vec![format!("Created by: {}", session.created_by)];
|
||||
if let Some(desc) = &session.description {
|
||||
if !desc.is_empty() {
|
||||
tooltip_parts.push(desc.clone());
|
||||
}
|
||||
}
|
||||
tab_response.clone().on_hover_text(tooltip_parts.join("\n"));
|
||||
|
||||
if self.sessions.len() > 1 {
|
||||
tab_response.context_menu(|ui| {
|
||||
if ui.button("Close").clicked() {
|
||||
@@ -1003,6 +1060,16 @@ impl eframe::App for AgCanvasApp {
|
||||
if self.last_drawing_sync.elapsed().as_millis() > 500 {
|
||||
self.last_drawing_sync = std::time::Instant::now();
|
||||
self.sync_drawing_elements_to_store();
|
||||
|
||||
let sessions_handle = self.sessions_handle.clone();
|
||||
let counter = self.session_counter;
|
||||
std::thread::spawn(move || {
|
||||
let rt = Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let mut store = sessions_handle.write().await;
|
||||
store.set_counter_minimum(counter);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ctx.request_repaint();
|
||||
|
||||
@@ -5,6 +5,61 @@ use crate::svg::SvgRenderer;
|
||||
use egui::TextureHandle;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session metadata types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum SessionCreator {
|
||||
#[default]
|
||||
Human,
|
||||
Agent {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SessionCreator {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SessionCreator::Human => write!(f, "Human"),
|
||||
SessionCreator::Agent { name: Some(n) } => write!(f, "Agent: {}", n),
|
||||
SessionCreator::Agent { name: None } => write!(f, "Agent"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionSortField {
|
||||
Name,
|
||||
#[default]
|
||||
CreatedAt,
|
||||
CreatedBy,
|
||||
ElementCount,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SortOrder {
|
||||
#[default]
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
fn unix_now() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SessionInfo — lightweight summary sent over the wire
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionInfo {
|
||||
@@ -13,6 +68,12 @@ pub struct SessionInfo {
|
||||
pub has_svg: bool,
|
||||
pub element_count: Option<usize>,
|
||||
pub drawing_element_count: usize,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_by: SessionCreator,
|
||||
#[serde(default)]
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
@@ -28,10 +89,14 @@ pub struct Session {
|
||||
pub selected_element_id: Option<String>,
|
||||
pub active_tool: Tool,
|
||||
pub drag_state: DragState,
|
||||
|
||||
pub description: Option<String>,
|
||||
pub created_by: SessionCreator,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(id: String, name: String) -> Self {
|
||||
pub fn new(id: String, name: String, created_by: SessionCreator) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
@@ -44,9 +109,17 @@ impl Session {
|
||||
selected_element_id: None,
|
||||
active_tool: Tool::default(),
|
||||
drag_state: DragState::default(),
|
||||
description: None,
|
||||
created_by,
|
||||
created_at: unix_now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_description(mut self, description: Option<String>) -> Self {
|
||||
self.description = description;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn info(&self) -> SessionInfo {
|
||||
SessionInfo {
|
||||
id: self.id.clone(),
|
||||
@@ -54,6 +127,9 @@ impl Session {
|
||||
has_svg: self.element_tree.is_some(),
|
||||
element_count: self.element_tree.as_ref().map(|t| t.metadata.element_count),
|
||||
drawing_element_count: self.drawing_elements.len(),
|
||||
description: self.description.clone(),
|
||||
created_by: self.created_by.clone(),
|
||||
created_at: self.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +174,7 @@ pub struct SessionData {
|
||||
pub struct SessionStore {
|
||||
sessions: HashMap<String, SessionData>,
|
||||
active_session_id: Option<String>,
|
||||
session_counter: usize,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
@@ -234,6 +311,63 @@ impl SessionStore {
|
||||
self.sessions.values().map(|d| d.info.clone()).collect()
|
||||
}
|
||||
|
||||
pub fn list_sessions_sorted(
|
||||
&self,
|
||||
sort_by: SessionSortField,
|
||||
order: SortOrder,
|
||||
) -> Vec<SessionInfo> {
|
||||
let mut sessions: Vec<SessionInfo> = self.list_sessions();
|
||||
sessions.sort_by(|a, b| {
|
||||
let cmp = match sort_by {
|
||||
SessionSortField::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
SessionSortField::CreatedAt => a.created_at.cmp(&b.created_at),
|
||||
SessionSortField::CreatedBy => {
|
||||
a.created_by.to_string().cmp(&b.created_by.to_string())
|
||||
}
|
||||
SessionSortField::ElementCount => {
|
||||
let ac = a.element_count.unwrap_or(0) + a.drawing_element_count;
|
||||
let bc = b.element_count.unwrap_or(0) + b.drawing_element_count;
|
||||
ac.cmp(&bc)
|
||||
}
|
||||
};
|
||||
match order {
|
||||
SortOrder::Asc => cmp,
|
||||
SortOrder::Desc => cmp.reverse(),
|
||||
}
|
||||
});
|
||||
sessions
|
||||
}
|
||||
|
||||
pub fn next_session_id(&mut self) -> String {
|
||||
self.session_counter += 1;
|
||||
format!("session-{}", self.session_counter)
|
||||
}
|
||||
|
||||
pub fn set_counter_minimum(&mut self, min: usize) {
|
||||
if min > self.session_counter {
|
||||
self.session_counter = min;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_session_meta(
|
||||
&mut self,
|
||||
session_id: &str,
|
||||
name: Option<String>,
|
||||
description: Option<Option<String>>,
|
||||
) -> bool {
|
||||
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||
if let Some(n) = name {
|
||||
data.info.name = n;
|
||||
}
|
||||
if let Some(d) = description {
|
||||
data.info.description = d;
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_session_id(&self) -> Option<&str> {
|
||||
self.active_session_id.as_deref()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user