Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d0b8e95
feat(canvas): local .atmos.tldr documents and document scripts
AruNi-01 Jul 18, 2026
d9bb0bc
fix(canvas): restore /api/canvas/default compat for stale clients
AruNi-01 Jul 18, 2026
e1cf934
fix(canvas): stop autosave remount and fix document menus/dialogs
AruNi-01 Jul 18, 2026
7180f6e
fix(canvas): advertise script/exec bridge capabilities for agents
AruNi-01 Jul 18, 2026
3bb47c5
fix(canvas): refuse Save As name conflicts; hide storage path UI
AruNi-01 Jul 18, 2026
c25337a
feat(canvas): auto Untitled docs, New-only UI, autosave without Save As
AruNi-01 Jul 18, 2026
c69acba
fix(canvas): remove toolbar Save/status control entirely
AruNi-01 Jul 18, 2026
68c43e7
fix(canvas): restore CanvasView and remove toolbar Save control
AruNi-01 Jul 18, 2026
d4baa99
fix(canvas): fuse document row with actions menu and cleaner hover
AruNi-01 Jul 18, 2026
12443de
fix(canvas): put Agent canvas control next to collapse button
AruNi-01 Jul 18, 2026
39dffb9
feat(canvas): claimInputScope for document-script keyboard isolation
AruNi-01 Jul 18, 2026
20219be
fix(canvas): address APP-037 review findings without legacy compat
AruNi-01 Jul 19, 2026
c749a22
fix(canvas): serialize document writes and harden script/rename edges
AruNi-01 Jul 19, 2026
f8717ff
docs(skill): require frame + claimInputScope for canvas games
AruNi-01 Jul 19, 2026
1271756
docs(skill): generalize interactive document-script rules
AruNi-01 Jul 19, 2026
f53052c
fix(canvas): serialize rename, gate script-get, tab-scope pin target
AruNi-01 Jul 19, 2026
5bd2139
fix(canvas): focus pulse pans at 100% zoom instead of fit-zoom
AruNi-01 Jul 19, 2026
4911b95
refactor(canvas): static import for centerCameraOnPageBounds
AruNi-01 Jul 19, 2026
4e44bc3
fix(canvas): rewrite literal backtick dynamic imports in document scr…
AruNi-01 Jul 19, 2026
529453b
fix(canvas): fit focus camera to all pulse shapes with max 100% zoom
AruNi-01 Jul 19, 2026
5f8e847
fix(web): escape CustomAgentDialog env placeholder for ICU
AruNi-01 Jul 19, 2026
252e92a
fix(canvas): pin only to this tab’s active document
AruNi-01 Jul 19, 2026
942a442
fix(canvas): isolate agent-chat widgets and persist session binding
AruNi-01 Jul 19, 2026
06074c7
feat(workspace): add note summary and isolate canvas chats
AruNi-01 Jul 19, 2026
e5b8ad0
fix(workspace): prevent stale note overwrites
AruNi-01 Jul 19, 2026
5cf7a69
fix(canvas): preserve pointer behavior in input scopes
AruNi-01 Jul 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions apps/api/src/api/canvas/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,9 +279,37 @@ pub async fn invoke(
/// this to figure out *why* a follow-up call would fail.
pub async fn status(State(state): State<AppState>) -> Json<Value> {
let snapshot = state.canvas_agent_relay.status();
let dir = state
.canvas_service
.canvas_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| String::new());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The status function now reads the canvas directory and lists all documents, but the dir field always serializes as an empty string on error (unwrap_or_else(|_| String::new())). If canvas_dir() fails (e.g. home dir missing), the response silently omits the error — callers may assume a valid empty directory instead of a misconfiguration.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/api/canvas/agent.rs, line 286:

<comment>The `status` function now reads the canvas directory and lists all documents, but the `dir` field always serializes as an empty string on error (`unwrap_or_else(|_| String::new())`). If `canvas_dir()` fails (e.g. home dir missing), the response silently omits the error — callers may assume a valid empty directory instead of a misconfiguration.</comment>

<file context>
@@ -279,9 +279,37 @@ pub async fn invoke(
+        .canvas_service
+        .canvas_dir()
+        .map(|p| p.display().to_string())
+        .unwrap_or_else(|_| String::new());
+    let items = state
+        .canvas_service
</file context>

let items = state
.canvas_service
.list_documents()
.unwrap_or_default()
.into_iter()
.map(|item| {
json!({
"file_name": item.file_name,
"title": item.title,
"modified_at": item.modified_at,
"size_bytes": item.size_bytes,
})
})
.collect::<Vec<_>>();
let active_document = snapshot
.clients
.iter()
.find_map(|c| c.active_document_file_name.clone());
Json(json!({
"ok": true,
"bridge": snapshot,
"documents": {
"dir": dir,
"items": items,
"active_document": active_document,
},
}))
}

Expand Down
190 changes: 164 additions & 26 deletions apps/api/src/api/canvas/handlers.rs
Original file line number Diff line number Diff line change
@@ -1,40 +1,178 @@
use axum::{extract::State, Json};
use axum::{
extract::{Path, Query, State},
Json,
};

use crate::{
api::dto::{ApiResponse, CanvasBoardResponse, UpdateCanvasBoardPayload},
api::dto::{
ApiResponse, AtmosCanvasFilePayload, CanvasDocumentFileResponse, CanvasDocumentListItemDto,
CanvasDocumentListResponse, CanvasDocumentWriteResponse,
},
app_state::AppState,
error::ApiResult,
};
use core_service::SaveCanvasBoardReq;
use core_service::{AtmosCanvasFile, AtmosCanvasScript};

fn item_dto(item: core_service::CanvasDocumentListItem) -> CanvasDocumentListItemDto {
CanvasDocumentListItemDto {
file_name: item.file_name,
title: item.title,
modified_at: item.modified_at,
size_bytes: item.size_bytes,
}
}

pub async fn get_default_board(
pub async fn list_documents(
State(state): State<AppState>,
) -> ApiResult<Json<ApiResponse<CanvasBoardResponse>>> {
let board = state.canvas_service.get_default_board().await?;
Ok(Json(ApiResponse::success(CanvasBoardResponse {
guid: board.guid,
slug: board.slug,
name: board.name,
document_json: board.document_json,
updated_at: board.updated_at,
) -> ApiResult<Json<ApiResponse<CanvasDocumentListResponse>>> {
let dir = state.canvas_service.canvas_dir()?;
let items = state.canvas_service.list_documents()?;
Ok(Json(ApiResponse::success(CanvasDocumentListResponse {
dir: dir.display().to_string(),
items: items.into_iter().map(item_dto).collect(),
})))
}
Comment on lines +25 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Blocking file I/O operations on the Tokio async reactor thread.

These handlers invoke synchronous file system methods from canvas_service (like list_documents, read_document, and write_document) directly within an async fn. This blocks the Tokio worker thread, violating the best practice against blocking calls on request threads and potentially stalling the API service under load. Wrap these synchronous operations in tokio::task::spawn_blocking to safely execute them off the async reactor.

  • apps/api/src/api/canvas/handlers.rs#L31-L40: wrap state.canvas_service.list_documents() in spawn_blocking.
  • apps/api/src/api/canvas/handlers.rs#L42-L70: wrap state.canvas_service.read_document() and absolute_path() in spawn_blocking.
  • apps/api/src/api/canvas/handlers.rs#L72-L92: wrap state.canvas_service.write_document() in spawn_blocking.
  • apps/api/src/api/canvas/handlers.rs#L94-L103: wrap state.canvas_service.delete_document() in spawn_blocking.
  • apps/api/src/api/canvas/handlers.rs#L105-L117: wrap state.canvas_service.rename_document() in spawn_blocking.
  • apps/api/src/api/canvas/handlers.rs#L119-L131: wrap state.canvas_service.duplicate_document() in spawn_blocking.
  • apps/api/src/api/canvas/agent.rs#L280-L314: wrap state.canvas_service.list_documents() in spawn_blocking.
📍 Affects 2 files
  • apps/api/src/api/canvas/handlers.rs#L31-L40 (this comment)
  • apps/api/src/api/canvas/handlers.rs#L42-L70
  • apps/api/src/api/canvas/handlers.rs#L72-L92
  • apps/api/src/api/canvas/handlers.rs#L94-L103
  • apps/api/src/api/canvas/handlers.rs#L105-L117
  • apps/api/src/api/canvas/handlers.rs#L119-L131
  • apps/api/src/api/canvas/agent.rs#L280-L314
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/api/canvas/handlers.rs` around lines 31 - 40, Move all
synchronous canvas file-system operations off the Tokio reactor using
tokio::task::spawn_blocking: in apps/api/src/api/canvas/handlers.rs lines 31-40
wrap list_documents; lines 42-70 wrap read_document and absolute_path; lines
72-92 wrap write_document; lines 94-103 wrap delete_document; lines 105-117 wrap
rename_document; lines 119-131 wrap duplicate_document. Also update
apps/api/src/api/canvas/agent.rs lines 280-314 to wrap list_documents. Preserve
existing error propagation and response behavior when joining each blocking
task.


pub async fn update_default_board(
pub async fn get_document(
State(state): State<AppState>,
Json(payload): Json<UpdateCanvasBoardPayload>,
) -> ApiResult<Json<ApiResponse<CanvasBoardResponse>>> {
let board = state
Path(file_name): Path<String>,
) -> ApiResult<Json<ApiResponse<CanvasDocumentFileResponse>>> {
// Axum Path is already percent-decoded — do not decode again.
let doc = state.canvas_service.read_document(&file_name)?;
let abs = state
.canvas_service
.save_default_board(SaveCanvasBoardReq {
document_json: payload.document_json,
})
.await?;
Ok(Json(ApiResponse::success(CanvasBoardResponse {
guid: board.guid,
slug: board.slug,
name: board.name,
document_json: board.document_json,
updated_at: board.updated_at,
.absolute_path(&file_name)
.map(|p| p.display().to_string())
.ok();
Ok(Json(ApiResponse::success(CanvasDocumentFileResponse {
file_name: doc.file_name,
title: doc.title,
modified_at: doc.modified_at,
size_bytes: doc.size_bytes,
absolute_path: abs,
body: AtmosCanvasFilePayload {
schema: doc.body.schema,
title: doc.body.title,
tldraw_document: doc.body.tldraw_document,
session: doc.body.session,
script: doc.body.script.map(|s| crate::api::dto::AtmosCanvasScriptPayload {
entry: s.entry,
files: s.files,
}),
},
})))
}

#[derive(Debug, Default, serde::Deserialize)]
pub struct PutDocumentQuery {
/// When true, allow replacing an existing file (normal Save of the open doc).
/// When false/omitted, refuse if the file already exists (Save As / create).
#[serde(default)]
pub overwrite: bool,
}

pub async fn put_document(
State(state): State<AppState>,
Path(file_name): Path<String>,
Query(query): Query<PutDocumentQuery>,
Json(payload): Json<AtmosCanvasFilePayload>,
) -> ApiResult<Json<ApiResponse<CanvasDocumentWriteResponse>>> {
let body = AtmosCanvasFile {
schema: payload.schema,
title: payload.title,
tldraw_document: payload.tldraw_document,
session: payload.session,
script: payload.script.map(|s| AtmosCanvasScript {
entry: s.entry,
files: s.files,
}),
};
let item = state
.canvas_service
.write_document(&file_name, &body, query.overwrite)?;
Ok(Json(ApiResponse::success(CanvasDocumentWriteResponse {
item: item_dto(item),
})))
}

pub async fn delete_document(
State(state): State<AppState>,
Path(file_name): Path<String>,
) -> ApiResult<Json<ApiResponse<DeleteDocumentResponse>>> {
state.canvas_service.delete_document(&file_name)?;
Ok(Json(ApiResponse::success(DeleteDocumentResponse {
deleted: file_name,
})))
}

pub async fn rename_document(
State(state): State<AppState>,
Path(file_name): Path<String>,
Json(payload): Json<RenameDocumentPayload>,
) -> ApiResult<Json<ApiResponse<CanvasDocumentWriteResponse>>> {
let item = state
.canvas_service
.rename_document(&file_name, &payload.name)?;
Ok(Json(ApiResponse::success(CanvasDocumentWriteResponse {
item: item_dto(item),
})))
}

pub async fn duplicate_document(
State(state): State<AppState>,
Path(file_name): Path<String>,
Json(payload): Json<DuplicateDocumentPayload>,
) -> ApiResult<Json<ApiResponse<CanvasDocumentWriteResponse>>> {
let item = state
.canvas_service
.duplicate_document(&file_name, payload.name.as_deref())?;
Ok(Json(ApiResponse::success(CanvasDocumentWriteResponse {
item: item_dto(item),
})))
}

pub async fn sanitize_name(
State(_state): State<AppState>,
Json(payload): Json<SanitizeNamePayload>,
) -> ApiResult<Json<ApiResponse<SanitizeNameResponse>>> {
let file_name = core_service::CanvasDocumentService::sanitize_file_name(&payload.name)?;
Ok(Json(ApiResponse::success(SanitizeNameResponse {
file_name,
})))
}

/// POST /api/canvas/documents/new — create Untitled / Untitled-1 / … and return it.
pub async fn create_new_document(
State(state): State<AppState>,
) -> ApiResult<Json<ApiResponse<CanvasDocumentWriteResponse>>> {
let item = state.canvas_service.create_untitled_document()?;
Ok(Json(ApiResponse::success(CanvasDocumentWriteResponse {
item: item_dto(item),
})))
}

#[derive(Debug, serde::Deserialize)]
pub struct SanitizeNamePayload {
pub name: String,
}

#[derive(Debug, serde::Serialize)]
pub struct SanitizeNameResponse {
pub file_name: String,
}

#[derive(Debug, serde::Deserialize)]
pub struct RenameDocumentPayload {
pub name: String,
}

#[derive(Debug, serde::Deserialize, Default)]
pub struct DuplicateDocumentPayload {
#[serde(default)]
pub name: Option<String>,
}

#[derive(Debug, serde::Serialize)]
pub struct DeleteDocumentResponse {
pub deleted: String,
}
18 changes: 16 additions & 2 deletions apps/api/src/api/canvas/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,23 @@ use crate::app_state::AppState;

pub fn routes() -> Router<AppState> {
Router::new()
// APP-037: file-backed documents only (no legacy /default board).
.route("/documents", get(handlers::list_documents))
.route("/documents/new", post(handlers::create_new_document))
.route("/documents/sanitize-name", post(handlers::sanitize_name))
.route(
"/default",
get(handlers::get_default_board).put(handlers::update_default_board),
"/documents/{file_name}",
get(handlers::get_document)
.put(handlers::put_document)
.delete(handlers::delete_document),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: With LAN-without-token enabled, a host on the private network can overwrite or delete any canvas document. Route document mutations through the destructive loopback-or-token guard (rename/duplicate should be covered too).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/api/canvas/mod.rs, line 19:

<comment>With LAN-without-token enabled, a host on the private network can overwrite or delete any canvas document. Route document mutations through the destructive loopback-or-token guard (rename/duplicate should be covered too).</comment>

<file context>
@@ -10,9 +10,21 @@ use crate::app_state::AppState;
+            "/documents/{file_name}",
+            get(handlers::get_document)
+                .put(handlers::put_document)
+                .delete(handlers::delete_document),
+        )
+        .route(
</file context>

)
.route(
"/documents/{file_name}/rename",
post(handlers::rename_document),
)
.route(
"/documents/{file_name}/duplicate",
post(handlers::duplicate_document),
)
.route("/agent/invoke", post(agent::invoke))
.route("/agent/status", get(agent::status))
Expand Down
59 changes: 50 additions & 9 deletions apps/api/src/api/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,57 @@ pub struct TerminalLayoutResponse {
pub maximized_terminal_id: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, Default, Clone)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Programmatic AtmosCanvasScriptPayload::default() produces an empty entry instead of the documented main.js, so a caller that adds files to the default payload receives a validation error on save. Use a manual Default implementation aligned with the serde default.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/api/dto.rs, line 42:

<comment>Programmatic `AtmosCanvasScriptPayload::default()` produces an empty entry instead of the documented `main.js`, so a caller that adds files to the default payload receives a validation error on save. Use a manual `Default` implementation aligned with the serde default.</comment>

<file context>
@@ -39,16 +39,57 @@ pub struct TerminalLayoutResponse {
     pub maximized_terminal_id: Option<String>,
 }
 
+#[derive(Debug, Serialize, Deserialize, Default, Clone)]
+pub struct AtmosCanvasScriptPayload {
+    #[serde(default = "default_script_entry")]
</file context>

pub struct AtmosCanvasScriptPayload {
#[serde(default = "default_script_entry")]
pub entry: String,
#[serde(default)]
pub files: std::collections::BTreeMap<String, String>,
}

fn default_script_entry() -> String {
"main.js".to_string()
}

#[derive(Debug, Serialize, Deserialize)]
pub struct AtmosCanvasFilePayload {
pub schema: String,
pub title: String,
#[serde(rename = "tldrawDocument")]
pub tldraw_document: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script: Option<AtmosCanvasScriptPayload>,
}

#[derive(Debug, Serialize)]
pub struct CanvasBoardResponse {
pub guid: String,
pub slug: String,
pub name: String,
pub document_json: String,
pub updated_at: String,
pub struct CanvasDocumentListItemDto {
pub file_name: String,
pub title: String,
pub modified_at: String,
pub size_bytes: u64,
}

#[derive(Debug, Deserialize)]
pub struct UpdateCanvasBoardPayload {
pub document_json: String,
#[derive(Debug, Serialize)]
pub struct CanvasDocumentListResponse {
/// Absolute canvas directory path (e.g. ~/.atmos/canvas).
pub dir: String,
pub items: Vec<CanvasDocumentListItemDto>,
}

#[derive(Debug, Serialize)]
pub struct CanvasDocumentFileResponse {
pub file_name: String,
pub title: String,
pub modified_at: String,
pub size_bytes: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub absolute_path: Option<String>,
pub body: AtmosCanvasFilePayload,
}

#[derive(Debug, Serialize)]
pub struct CanvasDocumentWriteResponse {
pub item: CanvasDocumentListItemDto,
}
4 changes: 0 additions & 4 deletions apps/api/src/api/ws/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,10 +211,6 @@ pub enum WsAction {
AppOpen,

// ===== Canvas 操作 =====
/// 获取默认 canvas board
CanvasGetDefaultBoard,
/// 更新默认 canvas board
CanvasUpdateDefaultBoard,
/// Register this browser tab as a terminal-agent bridge target (APP-015)
CanvasBridgeRegister,
/// Unregister this browser tab from the terminal-agent bridge (APP-015)
Expand Down
19 changes: 3 additions & 16 deletions apps/api/src/api/ws/message/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,6 @@ pub struct FsValidateGitPathRequest {
pub path: String,
}

/// 获取默认 canvas board 响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanvasBoardResponse {
pub guid: String,
pub slug: String,
pub name: String,
pub document_json: String,
pub updated_at: String,
}

/// 更新默认 canvas board 请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanvasUpdateDefaultBoardRequest {
pub document_json: String,
}

/// Register a browser tab as terminal-agent bridge target.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanvasBridgeRegisterRequest {
Expand All @@ -71,6 +55,9 @@ pub struct CanvasBridgeRegisterRequest {
/// Protocol capabilities advertised by the tab (forward-compatibility).
#[serde(default)]
pub capabilities: Vec<String>,
/// Active canvas document file name (APP-037), if saved.
#[serde(default)]
pub active_document_file_name: Option<String>,
}

fn default_accepts_commands() -> bool {
Expand Down
Loading
Loading