Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
999faf3
style(chat): 统一图片预览相关组件的 prettier 格式
yovinchen Aug 16, 2026
bd7d959
feat(ui): 新增 Session Workbench 布局内核
yovinchen Aug 16, 2026
394a9a5
test(chat): 覆盖 workbench pane 树与终端 surface 的布局命令语义
yovinchen Aug 16, 2026
d7fb93d
feat(ui): 实现 WorkbenchCanvas 画布与 pane 框架组件
yovinchen Aug 16, 2026
8fc0910
feat(settings): workbench 布局本机持久化(SQLite)
yovinchen Aug 16, 2026
d5a8643
refactor(chat): 工具审批改为按会话订阅快照
yovinchen Aug 16, 2026
0ba6c9a
refactor(chat): 会话态收敛为可订阅 store,脱离当前会话组件生命周期
yovinchen Aug 16, 2026
6e3fdcc
feat(chat): 引入 ConversationSurfaceController 驱动会话面板
yovinchen Aug 16, 2026
3bd4306
feat(chat): 终端 pane 绑定/租约与拖入提交链路
yovinchen Aug 16, 2026
25d045c
feat(chat): 窗口级工作台状态与布局恢复
yovinchen Aug 16, 2026
5139d36
feat(chat): 会话/终端 pane 宿主与后台会话 harness
yovinchen Aug 16, 2026
11d0625
feat(ui): 侧栏与右侧 dock 接入工作台拖拽入口
yovinchen Aug 16, 2026
838844b
fix(ui): composer 与转录空态适配分屏窄面板
yovinchen Aug 16, 2026
30eb5ae
feat(chat): ChatPage 接入 Session Workbench 多会话面板
yovinchen Aug 16, 2026
b5ac9bc
feat(i18n): 补充 Session Workbench 中英文案
yovinchen Aug 16, 2026
7b9fdbe
feat(ui): 布局内核支持分裂空间校验与父级 split 查找
yovinchen Aug 16, 2026
e85dcb2
fix(ui): 布局序列化改为显式 allow-list 投影
yovinchen Aug 16, 2026
cd37912
refactor(chat): 拖拽会话拆出纯状态机 workbenchDragMachine
yovinchen Aug 16, 2026
b404857
feat(i18n): 终端 pane 结束/重连、SSH 状态与区域标签文案
yovinchen Aug 16, 2026
12a614e
feat(ui): 新增 SshTerminalPaneSurface 与共享 SSH 状态推导
yovinchen Aug 16, 2026
677517c
feat(ui): 终端 pane 增加两态确认的结束会话入口
yovinchen Aug 16, 2026
ef67f46
feat(ui): SSH overlay 支持 pane 租约占位与 shell tab 拖出
yovinchen Aug 16, 2026
efb58de
feat(ui): 右侧 dock 空态"新建终端"支持拖出到画板
yovinchen Aug 16, 2026
4d2c053
feat(chat): TerminalPaneHost 接入 SSH surface 与 kill/重连链路
yovinchen Aug 16, 2026
b0bb89f
feat(chat): ChatPage 接入终端拖出入口、pane 快捷键与区域标签
yovinchen Aug 16, 2026
78d72cf
feat(settings): 损坏 workbench 布局时同步清理 SQLite 记录
yovinchen Aug 17, 2026
1146c04
fix(ui): 分隔条与拖拽手柄适配 forced-colors 模式
yovinchen Aug 17, 2026
03cb4eb
test(chat): 覆盖终端拖放提交与绑定/租约安全语义
yovinchen Aug 17, 2026
40ae006
test(chat): 覆盖多会话流式隔离与 stop 状态写入隔离
yovinchen Aug 17, 2026
faf1770
perf(ui): 终端输出流按 sessionId 分桶派发
yovinchen Aug 17, 2026
6f5eab4
perf(ui): XTermViewport 接入 WebGL 渲染并治理 resize 风暴
yovinchen Aug 17, 2026
67719b2
perf(ui): 右侧 dock 非活跃终端 tab 改为懒挂载
yovinchen Aug 17, 2026
6937d13
feat(chat): 重启后终端 Pane 恢复为休眠占位而非丢弃
yovinchen Aug 17, 2026
93d2084
feat(ui): workbench 内核支持按 surface kind 的最小尺寸
yovinchen Aug 17, 2026
0d802bb
feat(ui): 拖放预检与分隔条按 per-kind 最小尺寸约束
yovinchen Aug 17, 2026
92bee17
feat(ui): 极窄 Pane 渲染期自动切换紧凑 chrome
yovinchen Aug 17, 2026
52e726e
feat(ui): SSH Pane 状态行显示实时延迟
yovinchen Aug 17, 2026
2e017e8
feat(chat): TerminalPaneHost 接入延迟轮询、休眠占位与紧凑态
yovinchen Aug 17, 2026
314d619
test(chat): 覆盖终端租约转移时序与 auto-launch 授权语义
yovinchen Aug 17, 2026
f5120f3
fix(webui): 补充 @xterm/addon-webgl 依赖修复共享 XTermViewport 解析失败
yovinchen Aug 17, 2026
7204752
perf(ui): 终端 resize 相同尺寸去重避免重复下发 PTY
yovinchen Aug 17, 2026
e401a8c
fix(ui): 删除 compactChrome 死分支,紧凑态仅按 rect 宽度派生
yovinchen Aug 17, 2026
d32844f
feat(security): 终端 cwd 强制落在所属项目范围内
yovinchen Aug 17, 2026
5820364
feat(ui): Right Dock 终端租约标记、菜单入口与新建终端拖柄
yovinchen Aug 17, 2026
b41c16e
feat(chat): 布局命令统一注入几何 context 启用空间不足拒绝
yovinchen Aug 17, 2026
efbd260
test(chat): 覆盖 dock 焦点不变量与终端宿主启动语义
yovinchen Aug 17, 2026
f630220
docs(design): Session Workbench 设计文档同步实现现状
yovinchen Aug 17, 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 crates/agent-gateway/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@streamdown/mermaid": "^1.0.2",
"@tanstack/react-virtual": "^3.14.6",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand Down
7 changes: 7 additions & 0 deletions crates/agent-gui/src-tauri/src/commands/config/settings/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ pub(crate) fn initialize_schema(conn: &Connection) -> Result<(), String> {
payload_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS workbench_layout (
scope_id TEXT PRIMARY KEY NOT NULL,
schema_version INTEGER NOT NULL,
revision INTEGER NOT NULL,
payload_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS workspace_root_grants (
grant_id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,5 +165,6 @@ include!("memory_settings.rs");
include!("model_failover.rs");
include!("gateway_sync.rs");
include!("ssh/mod.rs");
include!("workbench_layout.rs");
include!("commands.rs");
include!("tests.rs");
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// 窗口级 Session Workbench 布局的本机持久化。布局只保存稳定身份与空间信息
// (PaneTree/ratio/focus),绝不包含消息、草稿、Secret 或 Session ID;
// 该表不参与 Gateway Settings Sync。

/// 单窗口布局 Payload 上限(与设计文档一致)。
const WORKBENCH_LAYOUT_PAYLOAD_MAX_BYTES: usize = 96 * 1024;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkbenchLayoutRecord {
pub scope_id: String,
pub schema_version: i64,
pub revision: i64,
pub payload_json: String,
pub updated_at: i64,
}

fn load_workbench_layout(
conn: &Connection,
scope_id: &str,
) -> Result<Option<WorkbenchLayoutRecord>, String> {
conn.query_row(
"SELECT scope_id, schema_version, revision, payload_json, updated_at
FROM workbench_layout WHERE scope_id = ?1",
params![scope_id],
|row| {
Ok(WorkbenchLayoutRecord {
scope_id: row.get(0)?,
schema_version: row.get(1)?,
revision: row.get(2)?,
payload_json: row.get(3)?,
updated_at: row.get(4)?,
})
},
)
.optional()
.map_err(|e| format!("读取工作台布局失败:{e}"))
}

fn save_workbench_layout(
conn: &Connection,
scope_id: &str,
schema_version: i64,
revision: i64,
payload_json: &str,
) -> Result<(), String> {
if scope_id.trim().is_empty() {
return Err("工作台布局 scope_id 不能为空".to_string());
}
if payload_json.len() > WORKBENCH_LAYOUT_PAYLOAD_MAX_BYTES {
return Err("工作台布局 Payload 超过 96 KiB 上限".to_string());
}
conn.execute(
"INSERT INTO workbench_layout (scope_id, schema_version, revision, payload_json, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(scope_id) DO UPDATE SET
schema_version = excluded.schema_version,
revision = excluded.revision,
payload_json = excluded.payload_json,
updated_at = excluded.updated_at",
params![scope_id, schema_version, revision, payload_json, now_ms()],
)
.map_err(|e| format!("保存工作台布局失败:{e}"))?;
Ok(())
}

fn delete_workbench_layout(conn: &Connection, scope_id: &str) -> Result<(), String> {
if scope_id.trim().is_empty() {
return Err("工作台布局 scope_id 不能为空".to_string());
}
conn.execute(
"DELETE FROM workbench_layout WHERE scope_id = ?1",
params![scope_id],
)
.map_err(|e| format!("删除工作台布局失败:{e}"))?;
Ok(())
}

#[tauri::command]
pub async fn workbench_layout_load(
scope_id: String,
) -> Result<Option<WorkbenchLayoutRecord>, String> {
tauri::async_runtime::spawn_blocking(move || {
let conn = open_db()?;
load_workbench_layout(&conn, &scope_id)
})
.await
.map_err(|e| format!("workbench_layout_load join 失败:{e}"))?
}

#[tauri::command]
pub async fn workbench_layout_save(
scope_id: String,
schema_version: i64,
revision: i64,
payload_json: String,
) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let conn = open_db()?;
save_workbench_layout(&conn, &scope_id, schema_version, revision, &payload_json)
})
.await
.map_err(|e| format!("workbench_layout_save join 失败:{e}"))?
}

#[tauri::command]
pub async fn workbench_layout_delete(scope_id: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let conn = open_db()?;
delete_workbench_layout(&conn, &scope_id)
})
.await
.map_err(|e| format!("workbench_layout_delete join 失败:{e}"))?
}
3 changes: 3 additions & 0 deletions crates/agent-gui/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ macro_rules! app_invoke_handler {
commands::settings::settings_save_remote,
commands::settings::settings_save_memory,
commands::settings::settings_save_model_failover,
commands::settings::workbench_layout_load,
commands::settings::workbench_layout_save,
commands::settings::workbench_layout_delete,
commands::update::app_update_check,
commands::update::app_update_install,
commands::update::app_restart,
Expand Down
9 changes: 4 additions & 5 deletions crates/agent-gui/src-tauri/src/runtime/terminal/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,13 @@ impl TerminalSessionRegistry {
cols: Option<u16>,
rows: Option<u16>,
) -> Result<TerminalSnapshotResponse, String> {
let cwd = canonicalize_workdir(&cwd)?;
// The project key must come from the caller; deriving it from `cwd` would let
// any path mint its own "project" and defeat the containment check below.
let project_key = project_path_key
.map(|value| normalize_project_path_key(&value))
.filter(|value| !value.is_empty())
.unwrap_or_else(|| normalize_project_path_key(&cwd.display().to_string()));
if project_key.is_empty() {
return Err("project_path_key is required".to_string());
}
.ok_or_else(|| "project_path_key is required".to_string())?;
let cwd = canonicalize_workdir_within(&cwd, &project_key)?;

let shell_spec = resolve_shell(shell)?;
let size = TerminalSize {
Expand Down
28 changes: 28 additions & 0 deletions crates/agent-gui/src-tauri/src/runtime/terminal/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,34 @@ pub(crate) fn canonicalize_workdir(workdir: &str) -> Result<PathBuf, String> {
Ok(strip_windows_verbatim_prefix(canonical))
}

/// Canonicalize `workdir` and assert it stays inside `project_root`.
///
/// Both sides are canonicalized before comparison so symlinks, `..` segments and
/// platform path shapes (Windows casing/separators, macOS `/var` -> `/private/var`)
/// cannot be used to escape the project. Pane layout JSON is not an authorization
/// credential: a caller may name any cwd/project pair, so containment has to be
/// re-derived from the filesystem here rather than trusted from the request.
pub(crate) fn canonicalize_workdir_within(
workdir: &str,
project_root: &str,
) -> Result<PathBuf, String> {
let root_raw = project_root.trim();
if root_raw.is_empty() {
return Err("project_path_key is required".to_string());
}
let root = canonicalize_workdir(root_raw)
.map_err(|err| format!("project root is not a usable directory: {err}"))?;
let canonical = canonicalize_workdir(workdir)?;
if canonical == root || canonical.starts_with(&root) {
return Ok(canonical);
}
Err(format!(
"workdir is outside the current project: {} is not inside {}",
canonical.display(),
root.display()
))
}

pub(crate) fn is_program_on_path(program: &str) -> bool {
let path_var = std::env::var_os("PATH").unwrap_or_default();
for dir in std::env::split_paths(&path_var) {
Expand Down
11 changes: 6 additions & 5 deletions crates/agent-gui/src-tauri/src/runtime/terminal/ssh_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,15 @@ impl TerminalSessionRegistry {
rows: Option<u16>,
sftp_enabled: bool,
) -> Result<TerminalSshCreateResponse, String> {
let cwd = canonicalize_workdir(&cwd)?;
// `cwd` here is the local project anchor recorded on the session (used for
// project scoping and the SFTP local root), not a remote path — the remote
// working directory is chosen by the SSH server. So it is validated exactly
// like a local terminal: caller-supplied key, cwd proven to live inside it.
let project_key = project_path_key
.map(|value| normalize_project_path_key(&value))
.filter(|value| !value.is_empty())
.unwrap_or_else(|| normalize_project_path_key(&cwd.display().to_string()));
if project_key.is_empty() {
return Err("project_path_key is required".to_string());
}
.ok_or_else(|| "project_path_key is required".to_string())?;
let cwd = canonicalize_workdir_within(&cwd, &project_key)?;
let request = PendingSshConnectRequest {
cwd: cwd.display().to_string(),
project_path_key: project_key,
Expand Down
122 changes: 122 additions & 0 deletions crates/agent-gui/src-tauri/src/runtime/terminal/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1096,3 +1096,125 @@ fn private_key_decode_error_explains_missing_passphrase() {
"wrong-passphrase message should hint at the passphrase: {message}"
);
}

#[test]
fn canonicalize_workdir_within_accepts_paths_inside_the_project() {
let project = tempfile::tempdir().expect("project root");
let nested = project.path().join("packages").join("app");
std::fs::create_dir_all(&nested).expect("create nested workdir");
let root = project.path().display().to_string();

let resolved = canonicalize_workdir_within(&nested.display().to_string(), &root)
.expect("nested workdir is inside the project");
let canonical_root = canonicalize_workdir(&root).expect("canonical project root");
assert!(resolved.starts_with(&canonical_root));

let at_root =
canonicalize_workdir_within(&root, &root).expect("project root itself is in range");
assert_eq!(at_root, canonical_root);
}

#[test]
fn canonicalize_workdir_within_rejects_paths_outside_the_project() {
let project = tempfile::tempdir().expect("project root");
let outside = tempfile::tempdir().expect("outside root");
let root = project.path().display().to_string();

let sibling = canonicalize_workdir_within(&outside.path().display().to_string(), &root)
.expect_err("sibling directory is rejected");
assert!(
sibling.contains("outside the current project"),
"unexpected error: {sibling}"
);

let escaped = project.path().join("..");
let traversal = canonicalize_workdir_within(&escaped.display().to_string(), &root)
.expect_err("parent traversal is rejected");
assert!(
traversal.contains("outside the current project"),
"unexpected error: {traversal}"
);

let absolute = canonicalize_workdir_within("/etc", &root)
.expect_err("unrelated absolute path is rejected");
assert!(
absolute.contains("outside the current project"),
"unexpected error: {absolute}"
);

let home = canonicalize_workdir_within("~", &root).expect_err("home directory is rejected");
assert!(
home.contains("outside the current project") || home.contains("workdir"),
"unexpected error: {home}"
);
}

#[cfg(unix)]
#[test]
fn canonicalize_workdir_within_rejects_symlink_escapes() {
let project = tempfile::tempdir().expect("project root");
let outside = tempfile::tempdir().expect("outside root");
let link = project.path().join("escape");
std::os::unix::fs::symlink(outside.path(), &link).expect("create escaping symlink");

let error = canonicalize_workdir_within(
&link.display().to_string(),
&project.path().display().to_string(),
)
.expect_err("symlink pointing outside the project is rejected");
assert!(
error.contains("outside the current project"),
"unexpected error: {error}"
);
}

#[test]
fn canonicalize_workdir_within_requires_a_project_root() {
let project = tempfile::tempdir().expect("project root");

let error = canonicalize_workdir_within(&project.path().display().to_string(), " ")
.expect_err("blank project root is rejected");
assert_eq!(error, "project_path_key is required");
}

#[test]
fn registry_create_requires_project_path_key() {
let registry = Arc::new(TerminalSessionRegistry::default());
let tempdir = tempfile::tempdir().expect("tempdir");

let error = registry
.create(
tempdir.path().display().to_string(),
None,
None,
None,
Some(80),
Some(24),
)
.expect_err("create without a project key is rejected");
assert_eq!(error, "project_path_key is required");
assert_eq!(registry.running_session_count(), 0);
}

#[test]
fn registry_create_rejects_cwd_outside_the_project() {
let registry = Arc::new(TerminalSessionRegistry::default());
let project = tempfile::tempdir().expect("project root");
let outside = tempfile::tempdir().expect("outside root");

let error = registry
.create(
outside.path().display().to_string(),
Some(project.path().display().to_string()),
None,
None,
Some(80),
Some(24),
)
.expect_err("cwd outside the claimed project is rejected");
assert!(
error.contains("outside the current project"),
"unexpected error: {error}"
);
assert_eq!(registry.running_session_count(), 0);
}
Loading
Loading