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
|
- **Shape Drawing** — Rectangles, ellipses, lines, arrows, text directly on canvas
|
||||||
- **Selection & Editing** — Select, move, resize shapes with corner handles
|
- **Selection & Editing** — Select, move, resize shapes with corner handles
|
||||||
- **Mermaid Diagrams** — Write Mermaid syntax, render as SVG on canvas
|
- **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
|
- **Pan/Zoom** — Smooth canvas navigation
|
||||||
|
|
||||||
### AI Agent Integration
|
### AI Agent Integration
|
||||||
@@ -179,7 +179,9 @@ Same MCP config format — add the `agcanvas` entry to your Codex MCP configurat
|
|||||||
|
|
||||||
| Tool | Description |
|
| 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) |
|
| `get_element_tree` | Get the full parsed SVG element tree (structured JSON) |
|
||||||
| `describe_canvas` | Get a human-readable description of the canvas |
|
| `describe_canvas` | Get a human-readable description of the canvas |
|
||||||
| `get_element_by_id` | Look up a specific element by ID |
|
| `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
|
```json
|
||||||
{"type": "ListSessions"}
|
{"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:
|
Response:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "Sessions",
|
"type": "Sessions",
|
||||||
"sessions": [
|
"sessions": [
|
||||||
{"id": "session-1", "name": "Tab 1", "has_svg": true, "element_count": 15},
|
{"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": "Tab 2", "has_svg": false, "element_count": null}
|
{"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"
|
"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
|
#### Get full element tree
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -354,8 +381,9 @@ crates/
|
|||||||
- [x] Selection, move, resize with handles
|
- [x] Selection, move, resize with handles
|
||||||
- [x] Mermaid diagram rendering
|
- [x] Mermaid diagram rendering
|
||||||
- [x] MCP server bridge for AI coding tools
|
- [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)
|
- [ ] Real code generation (not just stubs)
|
||||||
- [ ] Agent draw commands (modify canvas from agent)
|
|
||||||
- [ ] Export to file
|
- [ ] Export to file
|
||||||
- [ ] Diff view (before/after agent changes)
|
- [ ] Diff view (before/after agent changes)
|
||||||
- [ ] Plugin system for code generators
|
- [ ] Plugin system for code generators
|
||||||
|
|||||||
@@ -11,6 +11,36 @@ pub struct SessionIdParam {
|
|||||||
pub session_id: Option<String>,
|
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)]
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
pub struct GetElementParam {
|
pub struct GetElementParam {
|
||||||
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||||
@@ -155,10 +185,62 @@ impl AgCanvasServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tool(
|
#[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> {
|
async fn list_sessions(
|
||||||
let request = serde_json::json!({"type": "ListSessions"});
|
&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
|
self.call_agcanvas(&request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
mod protocol;
|
mod protocol;
|
||||||
mod server;
|
mod server;
|
||||||
|
|
||||||
pub use protocol::{DrawingCommand, GuiEvent};
|
pub use protocol::{DrawingCommand, GuiEvent, SessionCommand};
|
||||||
pub use server::AgentServer;
|
pub use server::AgentServer;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::drawing::{DrawingElement, Shape, ShapeStyle};
|
use crate::drawing::{DrawingElement, Shape, ShapeStyle};
|
||||||
use crate::element_tree::{ElementTree, TreeMetadata};
|
use crate::element_tree::{ElementTree, TreeMetadata};
|
||||||
use crate::session::SessionInfo;
|
use crate::session::{SessionCreator, SessionInfo, SessionSortField, SortOrder};
|
||||||
use egui::{Color32, Pos2, Vec2};
|
use egui::{Color32, Pos2, Vec2};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -56,7 +56,27 @@ pub enum GuiEvent {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum AgentRequest {
|
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 {
|
GetTree {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
session_id: Option<String>,
|
session_id: Option<String>,
|
||||||
@@ -192,6 +212,12 @@ pub enum AgentResponse {
|
|||||||
sessions: Vec<SessionInfo>,
|
sessions: Vec<SessionInfo>,
|
||||||
active_session: Option<String>,
|
active_session: Option<String>,
|
||||||
},
|
},
|
||||||
|
SessionCreated {
|
||||||
|
session: SessionInfo,
|
||||||
|
},
|
||||||
|
SessionUpdated {
|
||||||
|
session: SessionInfo,
|
||||||
|
},
|
||||||
Tree {
|
Tree {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
tree: ElementTree,
|
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
|
// Code generation targets
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use super::protocol::{
|
use super::protocol::{
|
||||||
build_shape, build_style, AgentRequest, AgentResponse, CodeGenTarget, DrawingCommand, GuiEvent,
|
build_shape, build_style, AgentRequest, AgentResponse, CodeGenTarget, DrawingCommand, GuiEvent,
|
||||||
|
SessionCommand,
|
||||||
};
|
};
|
||||||
use crate::drawing::DrawingElement;
|
use crate::drawing::DrawingElement;
|
||||||
use crate::session::SessionStore;
|
use crate::session::{SessionCreator, SessionStore};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -16,16 +17,22 @@ pub struct AgentServer {
|
|||||||
sessions: Arc<RwLock<SessionStore>>,
|
sessions: Arc<RwLock<SessionStore>>,
|
||||||
event_tx: broadcast::Sender<GuiEvent>,
|
event_tx: broadcast::Sender<GuiEvent>,
|
||||||
command_tx: mpsc::UnboundedSender<DrawingCommand>,
|
command_tx: mpsc::UnboundedSender<DrawingCommand>,
|
||||||
|
session_command_tx: mpsc::UnboundedSender<SessionCommand>,
|
||||||
port: u16,
|
port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgentServer {
|
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);
|
let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||||
Self {
|
Self {
|
||||||
sessions: Arc::new(RwLock::new(SessionStore::new())),
|
sessions: Arc::new(RwLock::new(SessionStore::new())),
|
||||||
event_tx,
|
event_tx,
|
||||||
command_tx,
|
command_tx,
|
||||||
|
session_command_tx,
|
||||||
port,
|
port,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,9 +56,17 @@ impl AgentServer {
|
|||||||
let event_tx = self.event_tx.clone();
|
let event_tx = self.event_tx.clone();
|
||||||
let event_rx = self.event_tx.subscribe();
|
let event_rx = self.event_tx.subscribe();
|
||||||
let command_tx = self.command_tx.clone();
|
let command_tx = self.command_tx.clone();
|
||||||
|
let session_command_tx = self.session_command_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) =
|
if let Err(e) = handle_connection(
|
||||||
handle_connection(stream, sessions, event_rx, event_tx, command_tx).await
|
stream,
|
||||||
|
sessions,
|
||||||
|
event_rx,
|
||||||
|
event_tx,
|
||||||
|
command_tx,
|
||||||
|
session_command_tx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
tracing::error!("Connection error: {}", e);
|
tracing::error!("Connection error: {}", e);
|
||||||
}
|
}
|
||||||
@@ -68,6 +83,7 @@ async fn handle_connection(
|
|||||||
mut event_rx: broadcast::Receiver<GuiEvent>,
|
mut event_rx: broadcast::Receiver<GuiEvent>,
|
||||||
event_tx: broadcast::Sender<GuiEvent>,
|
event_tx: broadcast::Sender<GuiEvent>,
|
||||||
command_tx: mpsc::UnboundedSender<DrawingCommand>,
|
command_tx: mpsc::UnboundedSender<DrawingCommand>,
|
||||||
|
session_command_tx: mpsc::UnboundedSender<SessionCommand>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let ws_stream = tokio_tungstenite::accept_async(stream).await?;
|
let ws_stream = tokio_tungstenite::accept_async(stream).await?;
|
||||||
let (mut write, mut read) = ws_stream.split();
|
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) {
|
let response = match serde_json::from_str::<AgentRequest>(&text) {
|
||||||
Ok(request) => {
|
Ok(request) => {
|
||||||
process_request(
|
process_request(
|
||||||
request, &sessions, &event_tx, &command_tx,
|
request, &sessions, &event_tx, &command_tx, &session_command_tx,
|
||||||
).await
|
).await
|
||||||
}
|
}
|
||||||
Err(e) => AgentResponse::Error {
|
Err(e) => AgentResponse::Error {
|
||||||
@@ -134,18 +150,98 @@ async fn process_request(
|
|||||||
sessions: &Arc<RwLock<SessionStore>>,
|
sessions: &Arc<RwLock<SessionStore>>,
|
||||||
event_tx: &broadcast::Sender<GuiEvent>,
|
event_tx: &broadcast::Sender<GuiEvent>,
|
||||||
command_tx: &mpsc::UnboundedSender<DrawingCommand>,
|
command_tx: &mpsc::UnboundedSender<DrawingCommand>,
|
||||||
|
session_command_tx: &mpsc::UnboundedSender<SessionCommand>,
|
||||||
) -> AgentResponse {
|
) -> AgentResponse {
|
||||||
match request {
|
match request {
|
||||||
AgentRequest::Ping => AgentResponse::Pong,
|
AgentRequest::Ping => AgentResponse::Pong,
|
||||||
|
|
||||||
AgentRequest::ListSessions => {
|
AgentRequest::ListSessions {
|
||||||
|
sort_by,
|
||||||
|
sort_order,
|
||||||
|
} => {
|
||||||
let store = sessions.read().await;
|
let store = sessions.read().await;
|
||||||
|
let sessions_list = store
|
||||||
|
.list_sessions_sorted(sort_by.unwrap_or_default(), sort_order.unwrap_or_default());
|
||||||
AgentResponse::Sessions {
|
AgentResponse::Sessions {
|
||||||
sessions: store.list_sessions(),
|
sessions: sessions_list,
|
||||||
active_session: store.active_session_id().map(|s| s.to_string()),
|
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 } => {
|
AgentRequest::GetTree { session_id } => {
|
||||||
let store = sessions.read().await;
|
let store = sessions.read().await;
|
||||||
match store.get_tree(session_id.as_deref()) {
|
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::canvas::{CanvasInteraction, CanvasState};
|
||||||
use crate::clipboard::ClipboardManager;
|
use crate::clipboard::ClipboardManager;
|
||||||
use crate::drawing::{
|
use crate::drawing::{
|
||||||
@@ -6,7 +6,7 @@ use crate::drawing::{
|
|||||||
screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, Tool,
|
screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, Tool,
|
||||||
};
|
};
|
||||||
use crate::mermaid::render_mermaid_to_svg;
|
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 crate::svg::{parse_svg, SvgRenderer};
|
||||||
use egui::{Color32, ColorImage, TextureOptions};
|
use egui::{Color32, ColorImage, TextureOptions};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -23,6 +23,7 @@ pub struct AgCanvasApp {
|
|||||||
sessions_handle: Arc<RwLock<SessionStore>>,
|
sessions_handle: Arc<RwLock<SessionStore>>,
|
||||||
event_tx: broadcast::Sender<GuiEvent>,
|
event_tx: broadcast::Sender<GuiEvent>,
|
||||||
command_rx: mpsc::UnboundedReceiver<DrawingCommand>,
|
command_rx: mpsc::UnboundedReceiver<DrawingCommand>,
|
||||||
|
session_command_rx: mpsc::UnboundedReceiver<SessionCommand>,
|
||||||
clipboard: Option<ClipboardManager>,
|
clipboard: Option<ClipboardManager>,
|
||||||
show_tree_panel: bool,
|
show_tree_panel: bool,
|
||||||
show_description: bool,
|
show_description: bool,
|
||||||
@@ -43,7 +44,8 @@ impl AgCanvasApp {
|
|||||||
|
|
||||||
let runtime = Runtime::new().expect("Failed to create tokio runtime");
|
let runtime = Runtime::new().expect("Failed to create tokio runtime");
|
||||||
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
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 sessions_handle = server.sessions_handle();
|
||||||
let event_tx = server.event_sender();
|
let event_tx = server.event_sender();
|
||||||
|
|
||||||
@@ -62,6 +64,7 @@ impl AgCanvasApp {
|
|||||||
sessions_handle,
|
sessions_handle,
|
||||||
event_tx,
|
event_tx,
|
||||||
command_rx,
|
command_rx,
|
||||||
|
session_command_rx,
|
||||||
clipboard,
|
clipboard,
|
||||||
show_tree_panel: false,
|
show_tree_panel: false,
|
||||||
show_description: false,
|
show_description: false,
|
||||||
@@ -83,7 +86,7 @@ impl AgCanvasApp {
|
|||||||
self.session_counter += 1;
|
self.session_counter += 1;
|
||||||
let id = format!("session-{}", self.session_counter);
|
let id = format!("session-{}", self.session_counter);
|
||||||
let name = format!("Tab {}", 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();
|
let info = session.info();
|
||||||
|
|
||||||
self.sessions.push(session);
|
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) {
|
fn sync_drawing_elements_to_store(&self) {
|
||||||
let sessions_handle = self.sessions_handle.clone();
|
let sessions_handle = self.sessions_handle.clone();
|
||||||
let elements_by_session: Vec<(String, Vec<DrawingElement>)> = self
|
let elements_by_session: Vec<(String, Vec<DrawingElement>)> = self
|
||||||
@@ -534,6 +577,7 @@ fn handle_shape_tool(
|
|||||||
impl eframe::App for AgCanvasApp {
|
impl eframe::App for AgCanvasApp {
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
self.drain_drawing_commands();
|
self.drain_drawing_commands();
|
||||||
|
self.drain_session_commands();
|
||||||
|
|
||||||
let mut paste = false;
|
let mut paste = false;
|
||||||
let mut new_tab = false;
|
let mut new_tab = false;
|
||||||
@@ -678,10 +722,15 @@ impl eframe::App for AgCanvasApp {
|
|||||||
let is_active = idx == self.active_session_idx;
|
let is_active = idx == self.active_session_idx;
|
||||||
let has_content =
|
let has_content =
|
||||||
session.element_tree.is_some() || !session.drawing_elements.is_empty();
|
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 {
|
let label = if has_content {
|
||||||
format!("{} *", session.name)
|
format!("{}{} *", creator_icon, session.name)
|
||||||
} else {
|
} else {
|
||||||
session.name.clone()
|
format!("{}{}", creator_icon, session.name)
|
||||||
};
|
};
|
||||||
|
|
||||||
let button = egui::Button::new(&label).fill(if is_active {
|
let button = egui::Button::new(&label).fill(if is_active {
|
||||||
@@ -695,6 +744,14 @@ impl eframe::App for AgCanvasApp {
|
|||||||
switch_idx = Some(idx);
|
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 {
|
if self.sessions.len() > 1 {
|
||||||
tab_response.context_menu(|ui| {
|
tab_response.context_menu(|ui| {
|
||||||
if ui.button("Close").clicked() {
|
if ui.button("Close").clicked() {
|
||||||
@@ -1003,6 +1060,16 @@ impl eframe::App for AgCanvasApp {
|
|||||||
if self.last_drawing_sync.elapsed().as_millis() > 500 {
|
if self.last_drawing_sync.elapsed().as_millis() > 500 {
|
||||||
self.last_drawing_sync = std::time::Instant::now();
|
self.last_drawing_sync = std::time::Instant::now();
|
||||||
self.sync_drawing_elements_to_store();
|
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();
|
ctx.request_repaint();
|
||||||
|
|||||||
@@ -5,6 +5,61 @@ use crate::svg::SvgRenderer;
|
|||||||
use egui::TextureHandle;
|
use egui::TextureHandle;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct SessionInfo {
|
pub struct SessionInfo {
|
||||||
@@ -13,6 +68,12 @@ pub struct SessionInfo {
|
|||||||
pub has_svg: bool,
|
pub has_svg: bool,
|
||||||
pub element_count: Option<usize>,
|
pub element_count: Option<usize>,
|
||||||
pub drawing_element_count: 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 {
|
pub struct Session {
|
||||||
@@ -28,10 +89,14 @@ pub struct Session {
|
|||||||
pub selected_element_id: Option<String>,
|
pub selected_element_id: Option<String>,
|
||||||
pub active_tool: Tool,
|
pub active_tool: Tool,
|
||||||
pub drag_state: DragState,
|
pub drag_state: DragState,
|
||||||
|
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub created_by: SessionCreator,
|
||||||
|
pub created_at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
pub fn new(id: String, name: String) -> Self {
|
pub fn new(id: String, name: String, created_by: SessionCreator) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@@ -44,9 +109,17 @@ impl Session {
|
|||||||
selected_element_id: None,
|
selected_element_id: None,
|
||||||
active_tool: Tool::default(),
|
active_tool: Tool::default(),
|
||||||
drag_state: DragState::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 {
|
pub fn info(&self) -> SessionInfo {
|
||||||
SessionInfo {
|
SessionInfo {
|
||||||
id: self.id.clone(),
|
id: self.id.clone(),
|
||||||
@@ -54,6 +127,9 @@ impl Session {
|
|||||||
has_svg: self.element_tree.is_some(),
|
has_svg: self.element_tree.is_some(),
|
||||||
element_count: self.element_tree.as_ref().map(|t| t.metadata.element_count),
|
element_count: self.element_tree.as_ref().map(|t| t.metadata.element_count),
|
||||||
drawing_element_count: self.drawing_elements.len(),
|
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 {
|
pub struct SessionStore {
|
||||||
sessions: HashMap<String, SessionData>,
|
sessions: HashMap<String, SessionData>,
|
||||||
active_session_id: Option<String>,
|
active_session_id: Option<String>,
|
||||||
|
session_counter: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionStore {
|
impl SessionStore {
|
||||||
@@ -234,6 +311,63 @@ impl SessionStore {
|
|||||||
self.sessions.values().map(|d| d.info.clone()).collect()
|
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> {
|
pub fn active_session_id(&self) -> Option<&str> {
|
||||||
self.active_session_id.as_deref()
|
self.active_session_id.as_deref()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user