From ccb326b55e72dcc0666b93081b5bc48a397d5a3a Mon Sep 17 00:00:00 2001 From: AlphaCat Date: Mon, 2 Nov 2026 15:43:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20K-brain=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E7=9A=84=E6=96=87=E4=BB=B6=E9=93=BE=E6=8E=A5=E8=83=BD=E6=89=93?= =?UTF-8?q?=E5=BC=80=EF=BC=8C=E5=8F=B3=E9=94=AE=E5=8F=AF=E6=89=93=E5=BC=80?= =?UTF-8?q?=E6=89=80=E5=9C=A8=E7=9B=AE=E5=BD=95=E6=88=96=E7=94=A8=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=BA=94=E7=94=A8=E6=89=93=E5=BC=80=EF=BC=8CImage=20?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E8=83=BD=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 打开聊天文件链接时按会话查工作目录:K-brain 会话不在本地历史库,原来报 "The conversation is unavailable on this device."。桌面窗口用当前会话的工作 目录;WebUI 经网关由桌面前端按 K-brain 会话记录查询,不采信请求里的目录; 迁移来的旧会话也以当前历史为准,本地库只作回退 - 文件链接右键:打开文件、打开所在目录;桌面端另有"用默认应用打开"(网页文件 为"用默认浏览器打开"),仅放行文档/媒体/网页类,拒绝脚本、可执行、带宏 Office 与含逗号的路径;Windows 打开时路径一律加引号 - 网址链接右键:显示目标地址,用默认浏览器打开或复制链接 - 没有内置预览的 Office 文件(pptx、odt 等)左键改为在文件管理器中定位 - K-brain 模式下成功的 Image 调用原来什么都不显示:实时与历史结果都转换成 display_image,data URL 只保留一次前缀,远程图片走代理 Co-Authored-By: Claude Opus 5.5 --- .../web/src/components/GatewayTranscript.tsx | 2 + .../src/commands/workspace/chat_file_links.rs | 359 +++++++++++++++-- .../src-tauri/src/commands/workspace/fs.rs | 18 +- .../src/services/gateway/envelope_handler.rs | 9 +- .../src/services/gateway/kbrain_history.rs | 34 ++ .../src-tauri/src/services/gateway_bridge.rs | 15 +- crates/agent-gui/src/lib/kbrain/history.ts | 18 +- crates/agent-gui/src/lib/kbrain/toolImages.ts | 71 ++++ crates/agent-gui/src/lib/kbrain/turn.ts | 17 +- .../pages/chat/transcript/ChatTranscript.tsx | 3 + .../chat/turns/runKBrainConversationTurn.ts | 8 +- .../chat/chat-file-link-context-menu.test.mjs | 133 +++++++ .../chat/chat-file-link-menu-intent.test.mjs | 361 ++++++++++++++++++ .../chat-file-link-menu-pane-switch.test.mjs | 225 +++++++++++ .../test/chat/chat-file-links.test.mjs | 6 +- .../chat/kbrain-image-tool-results.test.mjs | 70 ++++ .../test/chat/markdown-image-policy.test.mjs | 51 +++ ...-chat-file-link-default-app-comma.test.mjs | 146 +++++++ ...review-chat-url-menu-confirmation.test.mjs | 126 ++++++ .../review-kbrain-image-webui-live.test.mjs | 176 +++++++++ .../test/providers/kbrain-turn.test.mjs | 3 +- crates/agent-ui/src/components/Markdown.tsx | 11 +- .../chat/ChatFileLinkContextMenu.tsx | 112 ++++++ .../chat/assistant-bubble/ToolCallItem.tsx | 7 +- .../src/i18n/translations/enUSCommon.ts | 5 + .../src/i18n/translations/zhCNCommon.ts | 5 + .../lib/chat/chatFileLinkContextMenuStore.ts | 114 ++++++ crates/agent-ui/src/lib/chat/chatFileLinks.ts | 6 + .../agent-ui/src/lib/chat/openChatFileLink.ts | 1 + 29 files changed, 2061 insertions(+), 51 deletions(-) create mode 100644 crates/agent-gui/src/lib/kbrain/toolImages.ts create mode 100644 crates/agent-gui/test/chat/chat-file-link-context-menu.test.mjs create mode 100644 crates/agent-gui/test/chat/chat-file-link-menu-intent.test.mjs create mode 100644 crates/agent-gui/test/chat/chat-file-link-menu-pane-switch.test.mjs create mode 100644 crates/agent-gui/test/chat/kbrain-image-tool-results.test.mjs create mode 100644 crates/agent-gui/test/chat/review-chat-file-link-default-app-comma.test.mjs create mode 100644 crates/agent-gui/test/chat/review-chat-url-menu-confirmation.test.mjs create mode 100644 crates/agent-gui/test/chat/review-kbrain-image-webui-live.test.mjs create mode 100644 crates/agent-ui/src/components/chat/ChatFileLinkContextMenu.tsx create mode 100644 crates/agent-ui/src/lib/chat/chatFileLinkContextMenuStore.ts diff --git a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx index 8967a072c..32990eee4 100644 --- a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx +++ b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx @@ -3,6 +3,7 @@ import { LiveAssistantStatus, } from "@liveagent/ui/components/chat/AssistantBubble"; import { ChatEmptyState } from "@liveagent/ui/components/chat/ChatEmptyState"; +import { ChatFileLinkContextMenuHost } from "@liveagent/ui/components/chat/ChatFileLinkContextMenu"; import { ContextCheckpointCard } from "@liveagent/ui/components/chat/ContextCheckpointCard"; import { EditableUserMessageBubble } from "@liveagent/ui/components/chat/EditableUserMessageBubble"; import { RetryDetailsBlock } from "@liveagent/ui/components/chat/RetryDetailsBlock"; @@ -1204,6 +1205,7 @@ export function GatewayTranscript({ ) : null}
+
); } diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/chat_file_links.rs b/crates/agent-gui/src-tauri/src/commands/workspace/chat_file_links.rs index 684b7a30c..0a220f508 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/chat_file_links.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/chat_file_links.rs @@ -1,5 +1,6 @@ use serde::Serialize; use std::fs; +use std::future::Future; use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::{Arc, LazyLock}; @@ -167,6 +168,23 @@ const EXECUTABLE_EXTENSIONS: &[&str] = &[ "xpi", ]; +// Professional-app documents without an in-app preview. A plain click reveals +// them; the link menu can hand them to the default app. +const OFFICE_EXTENSIONS: &[&str] = &[ + "epub", "key", "mpp", "numbers", "odg", "odp", "odt", "one", "pages", "pps", "ppsx", "ppt", + "pptx", "pub", "vsd", "vsdx", +]; +// Types the link menu may hand to the default app or browser. Scripts, +// executables and the macro-enabled OOXML formats (docm, xlsm, pptm...) are +// not on it; legacy doc/xls/ppt stay, as Office asks before running macros. +const DEFAULT_APP_EXTENSIONS: &[&str] = &[ + "avif", "bmp", "csv", "doc", "docx", "epub", "flac", "gif", "htm", "html", "ico", "jpeg", + "jpg", "json", "key", "log", "m4a", "m4v", "md", "mov", "mp3", "mp4", "mpp", "numbers", "odg", + "odp", "ods", "odt", "oga", "ogg", "ogv", "one", "pages", "pdf", "png", "pps", "ppsx", "ppt", + "pptx", "pub", "rtf", "svg", "tsv", "txt", "vsd", "vsdx", "wav", "webm", "webp", "xhtml", + "xls", "xlsx", "xltx", "xml", "yaml", "yml", +]; + // Build artifacts the chat frequently links to (release archives, exports). // They are neither editable text nor previewable, so reveal them in the host // file manager instead of failing closed. @@ -348,6 +366,7 @@ fn preview_target( )) } +#[cfg(test)] fn build_chat_file_link_plan( conversation_id: &str, workdir: &str, @@ -357,6 +376,48 @@ fn build_chat_file_link_plan( end_line: Option, column: Option, open_in_file_manager: bool, +) -> Result { + build_chat_file_link_plan_with( + conversation_id, + workdir, + path, + source, + line, + end_line, + column, + open_in_file_manager, + false, + ) +} + +fn opened_plan(target: PathBuf, kind: &str, outside_workspace: bool) -> ChatFileLinkPlan { + ChatFileLinkPlan { + response: ChatFileLinkOpenResponse { + action: "opened".to_string(), + kind: kind.to_string(), + workdir: None, + path: None, + line: None, + end_line: None, + column: None, + outside_workspace, + }, + target, + system_mode: Some("open"), + } +} + +#[allow(clippy::too_many_arguments)] +fn build_chat_file_link_plan_with( + conversation_id: &str, + workdir: &str, + path: &str, + source: &str, + line: Option, + end_line: Option, + column: Option, + open_in_file_manager: bool, + open_with_default_app: bool, ) -> Result { if conversation_id.trim().is_empty() || conversation_id.len() > 256 { return Err(ChatFileLinkError::new( @@ -445,6 +506,15 @@ fn build_chat_file_link_plan( } let inside_workspace = target.starts_with(&conversation_workdir) && target != conversation_workdir; + if open_with_default_app { + if target.to_string_lossy().contains(',') { + return Err(ChatFileLinkError::new( + ChatFileLinkErrorCode::UnsupportedTarget, + "This folder cannot be opened with the default app safely.", + )); + } + return Ok(opened_plan(target, "directory", !inside_workspace)); + } if inside_workspace && !open_in_file_manager { let (response_workdir, response_path, outside_workspace) = preview_target(&conversation_workdir, &target)?; @@ -484,6 +554,41 @@ fn build_chat_file_link_plan( "The linked path is not a regular file or directory.", )); } + let outside_workspace = !target.starts_with(&conversation_workdir); + if open_with_default_app { + // The menu's "open with the default app/browser" launches the file's + // handler, so only document, media and web types qualify. + // Explorer splits a command line at commas, so a comma in the path + // could make it open a different file than the one checked here. + if !has_extension(&target, DEFAULT_APP_EXTENSIONS) + || has_shebang(&target) + || target.to_string_lossy().contains(',') + { + return Err(ChatFileLinkError::new( + ChatFileLinkErrorCode::UnsupportedTarget, + "This file type cannot be opened with the default app safely.", + )); + } + return Ok(opened_plan(target, "file", outside_workspace)); + } + // "Open containing folder": revealing selects the file in the file manager + // and never launches it, so every file type may be revealed. + if open_in_file_manager { + return Ok(ChatFileLinkPlan { + response: ChatFileLinkOpenResponse { + action: "revealed".to_string(), + kind: "file".to_string(), + workdir: None, + path: None, + line: None, + end_line: None, + column: None, + outside_workspace: !target.starts_with(&conversation_workdir), + }, + target, + system_mode: Some("reveal"), + }); + } let script = has_extension(&target, SCRIPT_EXTENSIONS) || has_shebang(&target); let executable = has_extension(&target, EXECUTABLE_EXTENSIONS) @@ -492,7 +597,10 @@ fn build_chat_file_link_plan( "editor" } else if has_extension(&target, PREVIEW_EXTENSIONS) { "preview" - } else if executable || has_extension(&target, ARCHIVE_EXTENSIONS) { + } else if executable + || has_extension(&target, ARCHIVE_EXTENSIONS) + || has_extension(&target, OFFICE_EXTENSIONS) + { "revealed" } else if is_probably_text(&target) { "editor" @@ -538,6 +646,7 @@ fn build_chat_file_link_plan( }) } +#[allow(clippy::too_many_arguments)] pub(crate) fn open_chat_file_link_sync( conversation_id: String, workdir: String, @@ -547,8 +656,9 @@ pub(crate) fn open_chat_file_link_sync( end_line: Option, column: Option, open_in_file_manager: bool, + open_with_default_app: bool, ) -> Result { - let plan = build_chat_file_link_plan( + let plan = build_chat_file_link_plan_with( &conversation_id, &workdir, &path, @@ -557,6 +667,7 @@ pub(crate) fn open_chat_file_link_sync( end_line, column, open_in_file_manager, + open_with_default_app, )?; if let Some(mode) = plan.system_mode { spawn_workspace_open_command(&plan.target, mode).map_err(|_| { @@ -569,38 +680,66 @@ pub(crate) fn open_chat_file_link_sync( Ok(plan.response) } -pub(crate) async fn open_chat_file_link_for_conversation( +/// Picks the conversation working directory. `current_workdir` is the +/// authoritative answer (K-brain owns history in K-brain mode, and migrated +/// conversations keep a stale local row); the desktop's own history database +/// is the fallback, and an id neither knows stays rejected. +async fn resolve_conversation_workdir( + current_workdir: F, + local_summary_cwd: L, +) -> Result +where + F: FnOnce() -> Fut, + Fut: Future>, + L: FnOnce() -> LFut, + LFut: Future, String>>, +{ + let non_empty = |value: &String| !value.trim().is_empty(); + if let Some(cwd) = current_workdir().await.filter(non_empty) { + return Ok(cwd); + } + let cwd = local_summary_cwd().await.map_err(|_| { + ChatFileLinkError::new( + ChatFileLinkErrorCode::InvalidRequest, + "The conversation is unavailable on this device.", + ) + })?; + cwd.filter(non_empty).ok_or_else(|| { + ChatFileLinkError::new( + ChatFileLinkErrorCode::InvalidWorkdir, + "The conversation working directory is unavailable.", + ) + }) +} + +/// `current_workdir` resolves the conversation's working directory from the +/// history that currently owns it. Gateway callers must answer it from the +/// desktop's own session record, never from the request. +pub(crate) async fn open_chat_file_link_for_conversation( conversation_id: String, - requested_workdir: String, path: String, source: String, line: Option, end_line: Option, column: Option, open_in_file_manager: bool, -) -> Result { + open_with_default_app: bool, + current_workdir: F, +) -> Result +where + F: FnOnce() -> Fut, + Fut: Future>, +{ tokio::time::timeout(CHAT_FILE_OPEN_TIMEOUT, async move { - // Finding the id in the target agent's own history database is the - // conversation/worker ownership check. The request-supplied workdir is - // never used as the base for a relative path. - let summary = chat_history::chat_history_get_summary_inner(conversation_id.clone()) - .await - .map_err(|_| { - ChatFileLinkError::new( - ChatFileLinkErrorCode::InvalidRequest, - "The conversation is unavailable on this device.", - ) - })?; - let workdir = summary - .cwd - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| { - ChatFileLinkError::new( - ChatFileLinkErrorCode::InvalidWorkdir, - "The conversation working directory is unavailable.", - ) - })?; - let _ = requested_workdir; + // Finding the id in the owning history (K-brain session or the + // desktop's own database) is the conversation/worker ownership check. + let local_id = conversation_id.clone(); + let workdir = resolve_conversation_workdir(current_workdir, || async move { + chat_history::chat_history_get_summary_inner(local_id) + .await + .map(|summary| summary.cwd) + }) + .await?; let permit = Arc::clone(&CHAT_FILE_OPEN_SEMAPHORE) .acquire_owned() .await @@ -622,6 +761,7 @@ pub(crate) async fn open_chat_file_link_for_conversation( end_line, column, open_in_file_manager, + open_with_default_app, ) }) .await @@ -651,16 +791,21 @@ pub async fn open_chat_file_link( end_line: Option, column: Option, open_in_file_manager: Option, + open_with_default_app: Option, ) -> Result { + // The desktop webview owns this device and already reads any workdir + // through the fs commands, so the workdir it shows for the conversation + // is trusted. open_chat_file_link_for_conversation( conversation_id, - workdir, path, source, line, end_line, column, open_in_file_manager.unwrap_or(false), + open_with_default_app.unwrap_or(false), + || async move { Some(workdir) }, ) .await } @@ -701,6 +846,166 @@ mod tests { .expect("build plan") } + fn resolve( + current: Option<&str>, + local: Result, String>, + ) -> Result { + let current = current.map(str::to_string); + tauri::async_runtime::block_on(resolve_conversation_workdir( + || async move { current }, + || async move { local }, + )) + } + + #[test] + fn conversation_workdir_prefers_the_owning_history_over_a_stale_local_row() { + let workdir = + resolve(Some("/kbrain"), Ok(Some("/migrated-stale".into()))).expect("current cwd"); + assert_eq!(workdir, "/kbrain"); + } + + #[test] + fn kbrain_conversations_missing_from_local_history_use_the_owning_workdir() { + let workdir = resolve(Some("/kbrain"), Err("not found".into())).expect("current cwd"); + assert_eq!(workdir, "/kbrain"); + } + + #[test] + fn local_history_answers_when_the_owning_history_has_no_workdir() { + let workdir = resolve(None, Ok(Some("/local".into()))).expect("local cwd"); + assert_eq!(workdir, "/local"); + let workdir = resolve(Some(" "), Ok(Some("/local".into()))).expect("blank current"); + assert_eq!(workdir, "/local"); + } + + #[test] + fn unknown_conversations_stay_unavailable() { + let error = resolve(None, Err("not found".into())).expect_err("unknown id"); + assert_eq!(error.code, ChatFileLinkErrorCode::InvalidRequest); + let error = resolve(None, Ok(Some(" ".into()))).expect_err("empty cwd"); + assert_eq!(error.code, ChatFileLinkErrorCode::InvalidWorkdir); + } + + #[test] + fn kbrain_temp_artifacts_outside_the_workspace_resolve_to_a_preview_root() { + let root = temp_workspace(); + let outside = std::env::temp_dir().join(format!( + "liveagent-chat-artifact-{}-{}.png", + std::process::id(), + NEXT_TEMP_WORKSPACE_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::write(&outside, b"\x89PNG").expect("write temp artifact"); + let workdir = + resolve(Some(&root.to_string_lossy()), Err("not found".into())).expect("current cwd"); + let planned = build_chat_file_link_plan( + "kbrain-conversation", + &workdir, + &outside.to_string_lossy(), + "absolute", + None, + None, + None, + false, + ) + .expect("plan temp artifact"); + assert_eq!(planned.response.action, "preview"); + assert!(planned.response.outside_workspace); + fs::remove_file(outside).expect("remove temp artifact"); + fs::remove_dir_all(root).expect("remove temp workspace"); + } + + fn default_app_plan(root: &Path, path: &str) -> Result { + build_chat_file_link_plan_with( + "conversation-test", + &root.to_string_lossy(), + path, + "relative", + None, + None, + None, + false, + true, + ) + } + + #[test] + fn default_app_opens_documents_and_web_pages_but_never_scripts_or_macros() { + let root = temp_workspace(); + for name in ["deck.pptx", "report.docx", "sheet.xlsx", "page.html", "notes.md"] { + fs::write(root.join(name), b"content").expect("write document"); + let planned = default_app_plan(&root, name).expect("default app plan"); + assert_eq!(planned.response.action, "opened", "{name}"); + assert_eq!(planned.system_mode, Some("open"), "{name}"); + } + for name in [ + "run.ps1", + "setup.exe", + "macro.docm", + "macro.xlsm", + "start.bat", + "opaque.custom", + ] { + fs::write(root.join(name), b"content").expect("write unsafe file"); + let error = match default_app_plan(&root, name) { + Ok(planned) => panic!("{name} would launch: {:?}", planned.response), + Err(error) => error, + }; + assert_eq!(error.code, ChatFileLinkErrorCode::UnsupportedTarget, "{name}"); + } + fs::write(root.join("notes"), b"#!/bin/sh\necho hi\n").expect("write shebang"); + fs::rename(root.join("notes"), root.join("notes.txt")).expect("rename shebang"); + assert!(default_app_plan(&root, "notes.txt").is_err()); + let directory = default_app_plan(&root, "src").expect("directory plan"); + assert_eq!(directory.response.action, "opened"); + fs::remove_dir_all(root).expect("remove temp workspace"); + } + + #[test] + fn default_app_refuses_paths_explorer_would_split_on_a_comma() { + let root = temp_workspace(); + fs::write(root.join("run.bat"), b"@echo off\r\n").expect("write bat"); + fs::write(root.join("run.bat,report.pdf"), b"%PDF-1.4").expect("write decoy"); + assert!(default_app_plan(&root, "run.bat,report.pdf").is_err()); + fs::create_dir(root.join("run.bat,docs")).expect("comma dir"); + assert!(default_app_plan(&root, "run.bat,docs").is_err()); + fs::remove_dir_all(root).expect("remove temp workspace"); + } + + #[test] + fn office_documents_without_a_preview_reveal_on_a_plain_click() { + let root = temp_workspace(); + fs::write(root.join("deck.pptx"), [0x50_u8, 0x4b, 0x03, 0x04]).expect("write pptx"); + let planned = plan(&root, "deck.pptx", "relative"); + assert_eq!(planned.response.action, "revealed"); + assert_eq!(planned.system_mode, Some("reveal")); + fs::remove_dir_all(root).expect("remove temp workspace"); + } + + #[test] + fn open_in_file_manager_reveals_files_of_every_type_without_launching() { + let root = temp_workspace(); + fs::write(root.join("src/a.ts"), "const value = 1;\n").expect("write source"); + fs::write(root.join("setup.exe"), [0_u8, 1, 2, 3]).expect("write exe"); + fs::write(root.join("opaque.custom"), [0_u8, 1, 2, 3]).expect("write opaque"); + for name in ["src/a.ts", "setup.exe", "opaque.custom"] { + let planned = build_chat_file_link_plan( + "conversation-test", + &root.to_string_lossy(), + name, + "relative", + Some(3), + None, + None, + true, + ) + .expect("reveal plan"); + assert_eq!(planned.response.action, "revealed", "{name}"); + assert_eq!(planned.system_mode, Some("reveal"), "{name}"); + assert!(!planned.response.outside_workspace, "{name}"); + } + fs::remove_dir_all(root).expect("remove temp workspace"); + } + #[test] fn classifies_text_and_preview_files_but_rejects_unknown_binary_types() { let root = temp_workspace(); diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs index 15c1f171d..49fd5f549 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs @@ -3448,7 +3448,9 @@ fn workspace_open_command(target: &Path, mode: &str) -> Command { // 绕过默认引号,只给路径部分加引号:`/select,""`。 command.raw_arg(format!("/select,\"{}\"", target.display())); } else { - command.arg(target); + // 同一个解析器:未加引号的逗号也会把路径截断,`run.bat,a.pdf` + // 实际打开的是 run.bat。这里同样只给路径加引号。 + command.raw_arg(format!("\"{}\"", target.display())); } command } @@ -4852,6 +4854,20 @@ mod tests { assert_eq!(args, vec![r#"/select,"C:\work\Dangerous.bundle""#]); } + #[cfg(target_os = "windows")] + #[test] + fn windows_open_quotes_paths_so_commas_cannot_split_them() { + let target = Path::new(r"C:\work\run.bat,report.pdf"); + let command = workspace_open_command(target, "open"); + let args = command + .get_args() + .map(|value| value.to_string_lossy().into_owned()) + .collect::>(); + + assert_eq!(command.get_program(), std::ffi::OsStr::new("explorer.exe")); + assert_eq!(args, vec![r#""C:\work\run.bat,report.pdf""#]); + } + // 路径含空格时引号必须保住整个路径,否则 explorer 在空格处截断参数, // 回退打开默认的 "文档" 目录(文件树"打开所在目录"空格路径 bug)。 #[cfg(target_os = "windows")] diff --git a/crates/agent-gui/src-tauri/src/services/gateway/envelope_handler.rs b/crates/agent-gui/src-tauri/src/services/gateway/envelope_handler.rs index bbc5c484f..06b878051 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/envelope_handler.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/envelope_handler.rs @@ -1123,8 +1123,15 @@ impl GatewayController { } Some(proto::gateway_envelope::Payload::ChatFileOpen(request)) => { let sender = self.current_outbound_sender()?; + let controller = Arc::clone(self); tauri::async_runtime::spawn(async move { - let envelope = match gateway_bridge::handle_chat_file_open(request).await { + let conversation_id = request.conversation_id.clone(); + let lookup_id = format!("{request_id}:chat-file-workdir"); + let current_workdir = + || controller.kbrain_conversation_workdir(lookup_id, conversation_id); + let result = + gateway_bridge::handle_chat_file_open(request, current_workdir).await; + let envelope = match result { Ok(response) => proto::AgentEnvelope { request_id, timestamp: now_unix_seconds(), diff --git a/crates/agent-gui/src-tauri/src/services/gateway/kbrain_history.rs b/crates/agent-gui/src-tauri/src/services/gateway/kbrain_history.rs index e3408842c..ace4daf73 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/kbrain_history.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/kbrain_history.rs @@ -30,7 +30,41 @@ fn summary(value: &Value) -> proto::ConversationSummary { } } +fn summary_cwd(value: &Value) -> Option { + value["conversation"]["cwd"] + .as_str() + .map(str::trim) + .filter(|cwd| !cwd.is_empty()) + .map(str::to_string) +} + impl GatewayController { + /// Working directory of a conversation as the desktop frontend's history + /// (K-brain sessions or the local database) records it; `None` when the + /// frontend cannot find it. + pub(crate) async fn kbrain_conversation_workdir( + self: &std::sync::Arc, + request_id: String, + conversation_id: String, + ) -> Option { + let value = self + .run_composer_action(GatewayComposerActionEvent { + request_id, + kind: "history".into(), + conversation_id, + action: "get".into(), + cron: String::new(), + interval: String::new(), + prompt: String::new(), + reasoning: String::new(), + loop_id: String::new(), + data: Some(json!({"maxMessages":1})), + }) + .await + .ok()?; + summary_cwd(&value) + } + pub(crate) async fn handle_kbrain_history_request( self: &std::sync::Arc, request_id: String, diff --git a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs index ddd440bbe..c8f18a5cd 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs @@ -1329,18 +1329,27 @@ pub async fn handle_fs_read_workspace_image( }) } -pub async fn handle_chat_file_open( +/// `current_workdir` looks the conversation up in the history that owns it; +/// `request.workdir` is never trusted as the link base. +pub async fn handle_chat_file_open( request: proto::ChatFileOpenRequest, -) -> Result { + current_workdir: F, +) -> Result +where + F: FnOnce() -> Fut, + Fut: std::future::Future>, +{ open_chat_file_link_for_conversation( request.conversation_id, - request.workdir, request.path, request.source, request.line, request.end_line, request.column, request.open_in_file_manager, + // Launching apps on the host is a desktop-window choice only. + false, + current_workdir, ) .await .map_err(|error| error.message) diff --git a/crates/agent-gui/src/lib/kbrain/history.ts b/crates/agent-gui/src/lib/kbrain/history.ts index 2399a6f99..be11ca094 100644 --- a/crates/agent-gui/src/lib/kbrain/history.ts +++ b/crates/agent-gui/src/lib/kbrain/history.ts @@ -38,6 +38,7 @@ import { import { questionResultDetails } from "./questions"; import { getConfiguredKBrainConnection } from "./runtimeConnection"; import { confirmKBrainMutation, hasKBrainMutationSync } from "./sessionSyncState"; +import { displayImageResultDetails, kbrainToolImages } from "./toolImages"; import { contextToKBrainMessages } from "./turn"; import type { KBrainHistoryResponse, @@ -164,8 +165,16 @@ function toMessage( } if (message.role === "tool") { const converted = userContent(message); - const content = - typeof converted === "string" ? [{ type: "text" as const, text: converted }] : converted; + const textAndFiles = ( + typeof converted === "string" ? [{ type: "text" as const, text: converted }] : converted + ).filter((part) => part.type !== "image"); + const output = textAndFiles + .filter((part) => part.type === "text") + .map((part) => part.text) + .join(""); + const isError = message.stop_reason === "error" || message.stop_reason === "cancelled"; + const images = kbrainToolImages(message.content, output); + const content = [...textAndFiles, ...images.content]; return { role: "toolResult", toolCallId: message.tool_call_id || `kbrain-tool-${index}`, @@ -191,8 +200,9 @@ function toMessage( }, taskRunId ?? "history", index, - ), - isError: message.stop_reason === "error" || message.stop_reason === "cancelled", + ) ?? + displayImageResultDetails(message.name, images.images, isError), + isError, timestamp, }; } diff --git a/crates/agent-gui/src/lib/kbrain/toolImages.ts b/crates/agent-gui/src/lib/kbrain/toolImages.ts new file mode 100644 index 000000000..64db2bf01 --- /dev/null +++ b/crates/agent-gui/src/lib/kbrain/toolImages.ts @@ -0,0 +1,71 @@ +import type { ImageContent } from "@liveagent/app/lib/agentTypes"; +import type { + DisplayImageItemDetails, + DisplayImageResultDetails, +} from "@liveagent/ui/contracts/builtinTools"; +import type { KBrainContentBlock } from "./types"; + +const DATA_URL_PREFIX = /^data:([^;,]+);base64,/i; +const REMOTE_URL = /^https?:\/\//i; +const DISPLAY_LINE_PREFIX = "Display image: "; + +export type KBrainToolImages = { + content: ImageContent[]; + images: DisplayImageItemDetails[]; +}; + +/** + * K-brain sends tool images as `image_url` (a data URL, or the remote URL an + * Image call displays). The transcript stores raw base64 plus the MIME type and + * renders remote images through the proxy, so split them here. + */ +export function kbrainToolImages( + blocks: readonly KBrainContentBlock[] | undefined, + output = "", +): KBrainToolImages { + const labels = output + .split("\n") + .filter((line) => line.startsWith(DISPLAY_LINE_PREFIX)) + .map((line) => line.slice(DISPLAY_LINE_PREFIX.length).trim()); + const content: ImageContent[] = []; + const images: DisplayImageItemDetails[] = []; + for (const block of blocks ?? []) { + if (block.type !== "image" || !block.image_url) continue; + const label = labels[images.length] || `inline-image-${images.length + 1}`; + if (REMOTE_URL.test(block.image_url)) { + images.push({ + path: label, + sourceType: "url", + renderMode: "proxy", + sourceUrl: block.image_url, + }); + continue; + } + const match = DATA_URL_PREFIX.exec(block.image_url); + const mimeType = block.mime_type || match?.[1] || "image/png"; + const data = match ? block.image_url.slice(match[0].length) : block.image_url; + content.push({ type: "image", data, mimeType }); + images.push({ + path: label, + renderMode: "inline", + mimeType, + sizeBytes: Math.ceil((data.length * 3) / 4), + }); + } + return { content, images }; +} + +/** The native `display_image` details the transcript renders an Image call from. */ +export function displayImageResultDetails( + toolName: string | undefined, + images: readonly DisplayImageItemDetails[], + isError: boolean, +): DisplayImageResultDetails | undefined { + if (toolName !== "Image" || isError || images.length === 0) return undefined; + const proxied = images.filter((image) => image.renderMode === "proxy").length; + return { + kind: "display_image", + images: [...images], + loadMode: proxied === 0 ? "inline" : proxied === images.length ? "proxy" : "mixed", + }; +} diff --git a/crates/agent-gui/src/lib/kbrain/turn.ts b/crates/agent-gui/src/lib/kbrain/turn.ts index 8eec2e178..5a12f41b0 100644 --- a/crates/agent-gui/src/lib/kbrain/turn.ts +++ b/crates/agent-gui/src/lib/kbrain/turn.ts @@ -23,6 +23,7 @@ import { createKBrainClient } from "./client"; import { getKBrainSessionId, setKBrainSessionId } from "./mapping"; import { questionResultDetails } from "./questions"; import { aiReviewTitle, localizeAIReviewReason } from "./reviewNotice"; +import { displayImageResultDetails, kbrainToolImages } from "./toolImages"; import type { KBrainClientToolRequest, KBrainClientToolResult, @@ -287,22 +288,18 @@ function toolResult( runId: string, revision: number, ): ToolResultMessage { + const isError = result.failed === true || result.cancelled === true; + const images = kbrainToolImages(result.content, result.output); return { role: "toolResult", toolCallId: result.id, toolName: result.name ?? "tool", - content: [ - { type: "text", text: result.output }, - ...(result.content ?? []).flatMap((block): ToolResultMessage["content"] => - block.type === "image" && block.image_url - ? [{ type: "image", data: block.image_url, mimeType: block.mime_type || "image/png" }] - : [], - ), - ], + content: [{ type: "text", text: result.output }, ...images.content], details: questionResultDetails(result.name, result.output) ?? - taskResultDetails(result.name, call?.arguments, result, runId, revision), - isError: result.failed === true || result.cancelled === true, + taskResultDetails(result.name, call?.arguments, result, runId, revision) ?? + displayImageResultDetails(result.name, images.images, isError), + isError, timestamp: Date.now(), }; } diff --git a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx index 7d454eb96..5c341f34e 100644 --- a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx @@ -1,4 +1,5 @@ import { ChatEmptyState } from "@liveagent/ui/components/chat/ChatEmptyState"; +import { ChatFileLinkContextMenuHost } from "@liveagent/ui/components/chat/ChatFileLinkContextMenu"; import { ChevronDown, Copy } from "@liveagent/ui/components/IconSet"; import { ContextMenuItem, ContextMenuPopup } from "@liveagent/ui/components/ui/context-menu"; import { useLocale } from "@liveagent/ui/i18n/index"; @@ -25,6 +26,7 @@ import { useState, useSyncExternalStore, } from "react"; +import { isTauriHost } from "../../../lib/host"; import { RowInteractionProvider, useRowInteractionStore } from "./rowInteraction"; import { TranscriptList, type TranscriptNavHandle } from "./TranscriptList"; import { HistorySwitchLoadingOverlay } from "./TranscriptLoadingStates"; @@ -391,6 +393,7 @@ export const ChatTranscript = memo(function ChatTranscript(props: ChatTranscript ) : null} ) : null} + {isTranscriptBusy ? : null} ); diff --git a/crates/agent-gui/src/pages/chat/turns/runKBrainConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runKBrainConversationTurn.ts index f44f9b83f..e0ae62673 100644 --- a/crates/agent-gui/src/pages/chat/turns/runKBrainConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runKBrainConversationTurn.ts @@ -198,13 +198,19 @@ export async function runKBrainConversationTurn(params: Params): Promise { ...attachToolResultToRound(target, call, enriched), runningToolCallIds: target.runningToolCallIds.filter((id) => id !== call.id), })); - // WebUI reads `content`/`isError` and attaches the result in the call's round. + // WebUI reads `content`/`isError` and attaches the result in the call's round; + // an Image call also needs its display_image details to render at all. + const displayImage = + (enriched.details as { kind?: unknown } | undefined)?.kind === "display_image" + ? { details: enriched.details } + : {}; gatewayBridgeEvents.queueEvent({ type: "tool_result", id: call.id, name: call.name, content: result.content, isError: result.isError === true, + ...displayImage, conversation_id: params.conversationId, round: callRounds.get(call.id) ?? round, }); diff --git a/crates/agent-gui/test/chat/chat-file-link-context-menu.test.mjs b/crates/agent-gui/test/chat/chat-file-link-context-menu.test.mjs new file mode 100644 index 000000000..32632d342 --- /dev/null +++ b/crates/agent-gui/test/chat/chat-file-link-context-menu.test.mjs @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createDomTestEnv } from "../helpers/dom-test-env.mjs"; + +// Chat file link context menu in a split Session Workbench. +// Each pane's transcript mounts a ChatFileLinkContextMenuHost, but only the +// first mounted host renders the popup. The popup is portaled to , yet +// React still bubbles its synthetic events through the *host's* React tree, so +// the press on a menu item reaches the first pane's PaneFrame +// onPointerDownCapture and focuses that pane — even when the link that opened +// the menu lives in another pane. + +const noopIcons = new Proxy( + {}, + { + get: (_target, key) => + key === "__esModule" ? true : String(key).endsWith("_SVG_MARKUP") ? "" : () => null, + }, +); +const env = await createDomTestEnv({ + mocks: { + "@liveagent/ui/components/IconSet": noopIcons, + "@liveagent/ui/i18n/index": { useLocale: () => ({ locale: "en-US", t: (key) => key }) }, + "@liveagent/app/shims/tauriOpener": { openUrl() {} }, + "@streamdown/cjk": { cjk: {} }, + "@streamdown/code": { code: {} }, + "@streamdown/math": { math: {} }, + "@streamdown/mermaid": { mermaid: {} }, + streamdown: { + Streamdown: () => null, + defaultRemarkPlugins: {}, + defaultRehypePlugins: { raw: () => {}, sanitize: [() => {}, {}], harden: () => {} }, + }, + }, +}); +globalThis.DOMRect = window.DOMRect; +const { React, act, createRoot } = env; +const { PaneFrame } = env.loadModule("@liveagent/ui/components/workbench/PaneFrame.tsx"); +const { ChatFileLinkContextMenuHost } = env.loadModule( + "@liveagent/ui/components/chat/ChatFileLinkContextMenu.tsx", +); +const markdown = env.loadModule("@liveagent/ui/components/Markdown.tsx"); + +function fileLinkNode(href) { + const node = { type: "element", tagName: "a", properties: { href }, children: [] }; + markdown.rewriteChatFileLinks()({ type: "root", children: [node] }); + return node; +} + +function pointer(type, target, button) { + target.dispatchEvent( + new MouseEvent(type, { bubbles: true, cancelable: true, button, clientX: 40, clientY: 30 }), + ); +} + +for (const { name, linkPane, otherPane, hostOutsidePanes = false } of [ + // Control: the link lives in the pane whose host renders the menu. + { name: "using the link menu from the first workbench pane keeps it focused", linkPane: "pane-1", otherPane: "pane-2" }, + // Control: a single host outside every PaneFrame does not steal focus, + // so the cause is the popup's React bubbling through its host's pane. + { name: "a page-level host outside the panes keeps the second pane focused", linkPane: "pane-2", otherPane: "pane-1", hostOutsidePanes: true }, + { name: "using the link menu from a second workbench pane must not focus the first pane", linkPane: "pane-2", otherPane: "pane-1" }, +]) test(name, async () => { + const focusRequests = []; + const opened = []; + const rect = (left) => ({ left, top: 0, width: 400, height: 600 }); + function Pane({ paneId, left }) { + const node = fileLinkNode(`C:/work/${paneId}/report.ts`); + return React.createElement( + PaneFrame, + { + paneId, + rect: rect(left), + isFocused: false, + regionLabel: paneId, + onFocusRequest: () => focusRequests.push(paneId), + }, + React.createElement(markdown.MarkdownFileLink, { + href: node.properties.href, + node, + onOpenFileLink: (link) => opened.push({ paneId, path: link.path }), + children: `${paneId} link`, + }), + // Same placement as ChatTranscript / GatewayTranscript: one host per transcript. + hostOutsidePanes ? null : React.createElement(ChatFileLinkContextMenuHost), + ); + } + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + try { + await act(async () => + root.render( + React.createElement( + "div", + null, + React.createElement(Pane, { paneId: "pane-1", left: 0 }), + React.createElement(Pane, { paneId: "pane-2", left: 400 }), + hostOutsidePanes ? React.createElement(ChatFileLinkContextMenuHost) : null, + ), + ), + ); + const link = container.querySelector(`[data-workbench-pane="${linkPane}"] [data-liveagent-file-link="true"]`); + assert.ok(link, `${linkPane} renders its file link`); + + // Right-click the link: pointerdown focuses its pane, contextmenu opens the menu. + await act(async () => { + pointer("pointerdown", link, 2); + pointer("contextmenu", link, 2); + }); + assert.deepEqual(focusRequests, [linkPane]); + const item = [...document.querySelectorAll('[role="menuitem"]')].find( + (element) => element.textContent === "chat.fileLink.open", + ); + assert.ok(item, "the link menu is open"); + + // Press "Open file". + focusRequests.length = 0; + await act(async () => { + pointer("pointerdown", item, 0); + item.click(); + }); + assert.deepEqual(opened, [{ paneId: linkPane, path: `C:/work/${linkPane}/report.ts` }]); + assert.deepEqual( + focusRequests.filter((paneId) => paneId === otherPane), + [], + "pressing the menu item focused another pane", + ); + } finally { + await act(async () => root.unmount()); + container.remove(); + } +}); diff --git a/crates/agent-gui/test/chat/chat-file-link-menu-intent.test.mjs b/crates/agent-gui/test/chat/chat-file-link-menu-intent.test.mjs new file mode 100644 index 000000000..446630bae --- /dev/null +++ b/crates/agent-gui/test/chat/chat-file-link-menu-intent.test.mjs @@ -0,0 +1,361 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createDomTestEnv } from "../helpers/dom-test-env.mjs"; + +// Menu items re-click the right-clicked link button and arm a one-shot intent +// ("Open containing folder", "Open with default app") that the button's own +// onClick consumes through withChatFileLinkMenuIntent. These cases check that +// the re-click reaches React synchronously, that the intent never survives +// into the next ordinary click, the ToolCallItem row, and the web link menu. +// Assertions compare plain values only (a failing assert on a jsdom node would +// try to print the whole tree). + +const openedUrls = []; +const copiedTexts = []; +const noopIcons = new Proxy( + {}, + { + get: (_target, key) => + key === "__esModule" ? true : String(key).endsWith("_SVG_MARKUP") ? "" : () => null, + }, +); +const env = await createDomTestEnv({ + mocks: { + "@liveagent/ui/components/IconSet": noopIcons, + "@liveagent/ui/components/chat/fileTypeIcons": noopIcons, + // ToolCallItem imports ChevronRight relatively. + "../../IconSet": noopIcons, + "@liveagent/ui/i18n/index": { useLocale: () => ({ locale: "en-US", t: (key) => key }) }, + "@liveagent/app/shims/tauriOpener": { + openUrl: async (url) => { + openedUrls.push(url); + }, + }, + "@liveagent/ui/lib/shared/clipboard": { + copyTextToClipboard: async (text) => { + copiedTexts.push(text); + return true; + }, + }, + "@streamdown/cjk": { cjk: {} }, + "@streamdown/code": { code: {} }, + "@streamdown/math": { math: {} }, + "@streamdown/mermaid": { mermaid: {} }, + streamdown: { + Streamdown: () => null, + defaultRemarkPlugins: {}, + defaultRehypePlugins: { raw: () => {}, sanitize: [() => {}, {}], harden: () => {} }, + }, + }, +}); +globalThis.DOMRect = window.DOMRect; +const { React, act, createRoot } = env; +const { ChatFileLinkContextMenuHost } = env.loadModule( + "@liveagent/ui/components/chat/ChatFileLinkContextMenu.tsx", +); +const markdown = env.loadModule("@liveagent/ui/components/Markdown.tsx"); + +function fileLinkNode(href) { + const node = { type: "element", tagName: "a", properties: { href }, children: [] }; + markdown.rewriteChatFileLinks()({ type: "root", children: [node] }); + return node; +} + +function mouse(type, target, button) { + target.dispatchEvent( + new MouseEvent(type, { bubbles: true, cancelable: true, button, clientX: 40, clientY: 30 }), + ); +} + +const menuItem = (label) => + [...document.querySelectorAll('[role="menuitem"]')].find((element) => element.textContent === label); + +async function mount(element) { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + await act(async () => root.render(element)); + return { + container, + unmount: async () => { + await act(async () => root.unmount()); + container.remove(); + }, + }; +} + +async function chooseFromMenu(target, label) { + await act(async () => mouse("contextmenu", target, 2)); + const item = menuItem(label); + assert.equal(Boolean(item), true, `${label} is in the menu`); + await act(async () => item.click()); +} + +const summary = (calls) => calls.map((link) => `${link.path}|${link.openInFileManager === true}`); + +test("markdown link: reveal reaches the re-clicked handler and the next left click is a plain open", async () => { + const calls = []; + const node = fileLinkNode("C:/work/project/src/report.ts"); + const view = await mount( + React.createElement( + "div", + null, + React.createElement(markdown.MarkdownFileLink, { + href: node.properties.href, + node, + onOpenFileLink: (link) => calls.push(link), + children: "report.ts", + }), + React.createElement(ChatFileLinkContextMenuHost), + ), + ); + try { + const link = view.container.querySelector('[data-liveagent-file-link="true"]'); + await chooseFromMenu(link, "chat.fileLink.openContainingDirectory"); + await act(async () => link.click()); + await chooseFromMenu(link, "chat.fileLink.open"); + assert.deepEqual(summary(calls), [ + "C:/work/project/src/report.ts|true", + "C:/work/project/src/report.ts|false", + "C:/work/project/src/report.ts|false", + ]); + } finally { + await view.unmount(); + } +}); + +test("markdown link: a handler that throws during a reveal leaves no armed flag behind", async () => { + const calls = []; + let throwOnce = true; + const node = fileLinkNode("C:/work/project/src/report.ts"); + const errors = []; + const onError = (event) => { + errors.push(String(event.error?.message ?? event.message)); + event.preventDefault(); + }; + window.addEventListener("error", onError); + const view = await mount( + React.createElement( + "div", + null, + React.createElement(markdown.MarkdownFileLink, { + href: node.properties.href, + node, + onOpenFileLink: (link) => { + calls.push(link); + if (throwOnce) { + throwOnce = false; + throw new Error("open failed"); + } + }, + children: "report.ts", + }), + React.createElement(ChatFileLinkContextMenuHost), + ), + ); + try { + const link = view.container.querySelector('[data-liveagent-file-link="true"]'); + await chooseFromMenu(link, "chat.fileLink.openContainingDirectory").catch(() => {}); + await act(async () => link.click()); + assert.deepEqual(summary(calls), [ + "C:/work/project/src/report.ts|true", + "C:/work/project/src/report.ts|false", + ]); + } finally { + window.removeEventListener("error", onError); + await view.unmount(); + } +}); + +test("tool file operation row: reveal reaches the re-clicked handler and the next click is plain", async () => { + const { MemoToolCallItem } = env.loadModule( + "@liveagent/ui/components/chat/assistant-bubble/ToolCallItem.tsx", + ); + const calls = []; + const item = { + toolCall: { type: "toolCall", id: "call-1", name: "Read", arguments: { path: "src/report.ts" } }, + toolResult: { + role: "toolResult", + toolCallId: "call-1", + toolName: "Read", + isError: false, + content: [{ type: "text", text: "ok" }], + details: { absolutePath: "C:/work/project/src/report.ts" }, + }, + }; + const view = await mount( + React.createElement( + "div", + null, + React.createElement(MemoToolCallItem, { item, onOpenFileLink: (link) => calls.push(link) }), + React.createElement(ChatFileLinkContextMenuHost), + ), + ); + try { + const target = view.container.querySelector("[data-chat-file-link]"); + assert.equal(Boolean(target), true, "the Read row renders its file link"); + await chooseFromMenu(target, "chat.fileLink.openContainingDirectory"); + await act(async () => target.click()); + assert.deepEqual(summary(calls), [ + "C:/work/project/src/report.ts|true", + "C:/work/project/src/report.ts|false", + ]); + } finally { + await view.unmount(); + } +}); + +test("markdown link: a native listener that swallows the re-click leaves no armed flag behind", async () => { + const calls = []; + const node = fileLinkNode("C:/work/project/src/report.ts"); + let swallow = true; + const attach = (element) => { + element?.addEventListener("click", (event) => { + if (!swallow) return; + swallow = false; + event.stopPropagation(); + }); + }; + const view = await mount( + React.createElement( + "div", + null, + React.createElement( + "div", + { ref: attach }, + React.createElement(markdown.MarkdownFileLink, { + href: node.properties.href, + node, + onOpenFileLink: (link) => calls.push(link), + children: "report.ts", + }), + ), + React.createElement(ChatFileLinkContextMenuHost), + ), + ); + try { + const link = view.container.querySelector('[data-liveagent-file-link="true"]'); + await chooseFromMenu(link, "chat.fileLink.openContainingDirectory"); + assert.deepEqual(summary(calls), [], "the swallowed re-click never reached React"); + await act(async () => link.click()); + assert.deepEqual(summary(calls), ["C:/work/project/src/report.ts|false"]); + } finally { + await view.unmount(); + } +}); + +// If the right-clicked row remounts while the menu is open, its old button has +// left the DOM; the menu must not fall back to the handler frozen at right-click. +test("remounted link: the menu never opens it with the handler frozen at right-click", async () => { + const calls = []; + let switchPane = null; + function Pane() { + const [current, setCurrent] = React.useState("conv-1"); + switchPane = () => setCurrent("conv-2"); + const node = fileLinkNode("src/report.ts"); + return React.createElement( + "div", + null, + React.createElement(markdown.MarkdownFileLink, { + // The row remounts when the conversation commits. + key: current, + href: node.properties.href, + node, + onOpenFileLink: (link) => calls.push(`${current}:${link.path}`), + children: "src/report.ts", + }), + React.createElement(ChatFileLinkContextMenuHost), + ); + } + const view = await mount(React.createElement(Pane)); + try { + const link = view.container.querySelector('[data-liveagent-file-link="true"]'); + await act(async () => mouse("contextmenu", link, 2)); + await act(async () => switchPane()); + await act(async () => menuItem("chat.fileLink.open").click()); + assert.deepEqual(calls, []); + } finally { + await view.unmount(); + } +}); + +test("desktop host offers the default app, and the browser for web pages; the WebUI host does not", async () => { + const calls = []; + const renderLink = (path, canOpenWithDefaultApp) => { + const node = fileLinkNode(path); + return mount( + React.createElement( + "div", + null, + React.createElement(markdown.MarkdownFileLink, { + href: node.properties.href, + node, + onOpenFileLink: (link) => calls.push(link), + children: path, + }), + React.createElement(ChatFileLinkContextMenuHost, { canOpenWithDefaultApp }), + ), + ); + }; + let view = await renderLink("C:/work/out/deck.pptx", true); + try { + const link = view.container.querySelector('[data-liveagent-file-link="true"]'); + await chooseFromMenu(link, "chat.fileLink.openWithDefaultApp"); + await act(async () => link.click()); + assert.deepEqual( + calls.map((call) => `${call.path}|${call.openWithDefaultApp === true}`), + ["C:/work/out/deck.pptx|true", "C:/work/out/deck.pptx|false"], + ); + } finally { + await view.unmount(); + } + view = await renderLink("C:/work/out/index.html", true); + try { + const link = view.container.querySelector('[data-liveagent-file-link="true"]'); + await act(async () => mouse("contextmenu", link, 2)); + assert.equal(Boolean(menuItem("chat.fileLink.openInBrowser")), true); + assert.equal(Boolean(menuItem("chat.fileLink.openWithDefaultApp")), false); + await act(async () => menuItem("chat.fileLink.openInBrowser").click()); + assert.equal(calls.at(-1).openWithDefaultApp, true); + } finally { + await view.unmount(); + } + view = await renderLink("C:/work/out/deck.pptx", false); + try { + const link = view.container.querySelector('[data-liveagent-file-link="true"]'); + await act(async () => mouse("contextmenu", link, 2)); + assert.equal(Boolean(menuItem("chat.fileLink.openContainingDirectory")), true); + assert.equal(Boolean(menuItem("chat.fileLink.openWithDefaultApp")), false); + await act(async () => menuItem("chat.fileLink.open").click()); + } finally { + await view.unmount(); + } +}); + +test("web links open in the default browser or copy from the right-click menu", async () => { + openedUrls.length = 0; + copiedTexts.length = 0; + const view = await mount( + React.createElement( + "div", + null, + React.createElement(markdown.MarkdownLink, { + href: "https://example.com/docs", + children: "docs", + }), + React.createElement(ChatFileLinkContextMenuHost), + ), + ); + try { + const link = view.container.querySelector('[data-streamdown="link"]'); + await act(async () => mouse("contextmenu", link, 2)); + assert.equal(Boolean(menuItem("chat.fileLink.open")), false); + await act(async () => menuItem("chat.fileLink.openInBrowser").click()); + await act(async () => mouse("contextmenu", link, 2)); + await act(async () => menuItem("chat.fileLink.copyLink").click()); + assert.deepEqual(openedUrls, ["https://example.com/docs"]); + assert.deepEqual(copiedTexts, ["https://example.com/docs"]); + } finally { + await view.unmount(); + } +}); diff --git a/crates/agent-gui/test/chat/chat-file-link-menu-pane-switch.test.mjs b/crates/agent-gui/test/chat/chat-file-link-menu-pane-switch.test.mjs new file mode 100644 index 000000000..fac2299ff --- /dev/null +++ b/crates/agent-gui/test/chat/chat-file-link-menu-pane-switch.test.mjs @@ -0,0 +1,225 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createDomTestEnv } from "../helpers/dom-test-env.mjs"; + +// Both hosts give every workbench pane the same page-level file link handler, +// bound to the page's current conversation, and focusing a background pane +// commits its conversation only after the history window loads. The menu must +// run the link's handler as of the item press, not as of the right-click. + +const noopIcons = new Proxy( + {}, + { + get: (_target, key) => + key === "__esModule" ? true : String(key).endsWith("_SVG_MARKUP") ? "" : () => null, + }, +); +const env = await createDomTestEnv({ + mocks: { + "@liveagent/ui/components/IconSet": noopIcons, + "@liveagent/ui/i18n/index": { useLocale: () => ({ locale: "en-US", t: (key) => key }) }, + "@liveagent/app/shims/tauriOpener": { openUrl() {} }, + "@streamdown/cjk": { cjk: {} }, + "@streamdown/code": { code: {} }, + "@streamdown/math": { math: {} }, + "@streamdown/mermaid": { mermaid: {} }, + streamdown: { + Streamdown: () => null, + defaultRemarkPlugins: {}, + defaultRehypePlugins: { raw: () => {}, sanitize: [() => {}, {}], harden: () => {} }, + }, + }, +}); +globalThis.DOMRect = window.DOMRect; +const { React, act, createRoot } = env; +const { PaneFrame } = env.loadModule("@liveagent/ui/components/workbench/PaneFrame.tsx"); +const { ContextMenuPopup, ContextMenuItem } = env.loadModule( + "@liveagent/ui/components/ui/context-menu.tsx", +); +const { ChatFileLinkContextMenuHost } = env.loadModule( + "@liveagent/ui/components/chat/ChatFileLinkContextMenu.tsx", +); +const markdown = env.loadModule("@liveagent/ui/components/Markdown.tsx"); +const { useChatFileLinkNavigation } = env.loadModule( + "@liveagent/ui/lib/chat/useChatFileLinkNavigation.ts", +); + +const PANES = [ + { paneId: "pane-1", conversationId: "conv-1", workdir: "C:/work/project-1" }, + { paneId: "pane-2", conversationId: "conv-2", workdir: "C:/work/project-2" }, +]; + +function relativeLinkNode() { + const node = { type: "element", tagName: "a", properties: { href: "src/report.ts" }, children: [] }; + markdown.rewriteChatFileLinks()({ type: "root", children: [node] }); + return node; +} + +function mouse(type, target, button) { + target.dispatchEvent( + new MouseEvent(type, { bubbles: true, cancelable: true, button, clientX: 40, clientY: 30 }), + ); +} + +async function mountWorkbench() { + const requests = []; + let pendingCommit = null; + // Page model: focus is immediate, the current conversation commits later. + function Workbench() { + const [focused, setFocused] = React.useState("pane-1"); + const [current, setCurrent] = React.useState(PANES[0]); + const handleOpenChatFileLink = useChatFileLinkNavigation({ + conversationId: current.conversationId, + conversationWorkdir: current.workdir, + terminalProjectPathKey: "", + notifyError: () => {}, + onRevealInFileTree: () => {}, + openWorkspaceEditorFile: () => {}, + openWorkspaceFilePreview: () => {}, + openLink: async (request) => { + requests.push({ conversationId: request.conversationId, workdir: request.workdir }); + return { action: "revealed", kind: "file", outsideWorkspace: false }; + }, + }); + return React.createElement( + "div", + null, + PANES.map((pane, index) => { + const node = relativeLinkNode(); + return React.createElement( + PaneFrame, + { + key: pane.paneId, + paneId: pane.paneId, + rect: { left: index * 400, top: 0, width: 400, height: 600 }, + isFocused: focused === pane.paneId, + regionLabel: pane.paneId, + onFocusRequest: + focused === pane.paneId + ? undefined + : () => { + setFocused(pane.paneId); + pendingCommit = () => setCurrent(pane); + }, + }, + React.createElement(markdown.MarkdownFileLink, { + href: node.properties.href, + node, + workdir: current.workdir, + onOpenFileLink: handleOpenChatFileLink, + children: `${pane.paneId} src/report.ts`, + }), + React.createElement(ChatFileLinkContextMenuHost), + ); + }), + ); + } + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + await act(async () => root.render(React.createElement(Workbench))); + return { + requests, + link: () => + container.querySelector('[data-workbench-pane="pane-2"] [data-liveagent-file-link="true"]'), + commit: async () => { + assert.ok(pendingCommit, "focusing pane-2 scheduled a conversation commit"); + await act(async () => pendingCommit()); + }, + unmount: async () => { + await act(async () => root.unmount()); + container.remove(); + }, + }; +} + +test("control: an action that reads the handler after pane-2 commits opens it against conv-2", async () => { + const view = await mountWorkbench(); + try { + await act(async () => mouse("pointerdown", view.link(), 0)); + await view.commit(); + await act(async () => view.link().click()); + assert.deepEqual(view.requests, [{ conversationId: "conv-2", workdir: "C:/work/project-2" }]); + } finally { + await view.unmount(); + } +}); + +test("the link menu opens a pane-2 link against conv-2 once pane-2's conversation commits", async () => { + const view = await mountWorkbench(); + try { + await act(async () => { + mouse("pointerdown", view.link(), 2); + mouse("contextmenu", view.link(), 2); + }); + // The user moves to the menu; meanwhile the history window loads and commits. + await view.commit(); + const item = [...document.querySelectorAll('[role="menuitem"]')].find( + (element) => element.textContent === "chat.fileLink.open", + ); + assert.ok(item, "the link menu is open"); + await act(async () => item.click()); + assert.deepEqual(view.requests, [{ conversationId: "conv-2", workdir: "C:/work/project-2" }]); + } finally { + await view.unmount(); + } +}); + +test("the menu popup carries the workbench focus guard and still closes on an outside press", async () => { + const view = await mountWorkbench(); + try { + await act(async () => { + mouse("pointerdown", view.link(), 2); + mouse("contextmenu", view.link(), 2); + }); + const menu = document.querySelector('[role="menu"]'); + assert.equal(Boolean(menu), true, "the link menu is open"); + assert.equal( + Boolean(menu.closest("[data-workbench-preserve-focus]")), + true, + "data-workbench-preserve-focus reaches the rendered menu", + ); + // Base UI ignores outside presses for a short window after opening (a + // plain ContextMenuPopup without the attribute behaves the same), so wait. + await act(async () => new Promise((resolve) => setTimeout(resolve, 600))); + await act(async () => { + mouse("pointerdown", document.body, 0); + mouse("mousedown", document.body, 0); + }); + assert.equal(Boolean(document.querySelector('[role="menu"]')), false, "an outside press closes the menu"); + } finally { + await view.unmount(); + } +}); + +test("control: a plain ContextMenuPopup without the attribute needs the same wait before an outside press closes it", async () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + function Harness() { + const [open, setOpen] = React.useState(true); + return open + ? React.createElement( + ContextMenuPopup, + { point: { x: 5, y: 5 }, onClose: () => setOpen(false) }, + React.createElement(ContextMenuItem, null, "Copy"), + ) + : null; + } + const outsidePress = async () => + act(async () => { + mouse("pointerdown", document.body, 0); + mouse("mousedown", document.body, 0); + }); + try { + await act(async () => root.render(React.createElement(Harness))); + await outsidePress(); + assert.equal(Boolean(document.querySelector('[role="menu"]')), true, "an immediate press is ignored"); + await act(async () => new Promise((resolve) => setTimeout(resolve, 600))); + await outsidePress(); + assert.equal(Boolean(document.querySelector('[role="menu"]')), false, "a later press closes it"); + } finally { + await act(async () => root.unmount()); + host.remove(); + } +}); diff --git a/crates/agent-gui/test/chat/chat-file-links.test.mjs b/crates/agent-gui/test/chat/chat-file-links.test.mjs index b5aa0860e..84d62e2d2 100644 --- a/crates/agent-gui/test/chat/chat-file-links.test.mjs +++ b/crates/agent-gui/test/chat/chat-file-links.test.mjs @@ -118,8 +118,12 @@ test("Gateway chat file opens run off-loop with bounded host concurrency", () => assert.match(branch, /send_agent_envelope_to\(sender, envelope\)/); assert.ok( branch.indexOf("tauri::async_runtime::spawn") < - branch.indexOf("handle_chat_file_open(request).await"), + branch.indexOf("handle_chat_file_open(request, current_workdir).await"), ); + // K-brain conversations resolve their workdir through the desktop's own + // session record; the WebUI-supplied workdir is never the link base. + assert.match(branch, /kbrain_conversation_workdir\(lookup_id, conversation_id\)/); + assert.doesNotMatch(branch, /request\.workdir/); assert.match(chatFileLinks, /tokio::time::timeout\(CHAT_FILE_OPEN_TIMEOUT/); assert.match(chatFileLinks, /CHAT_FILE_OPEN_SEMAPHORE/); assert.match(chatFileLinks, /let _permit = permit/); diff --git a/crates/agent-gui/test/chat/kbrain-image-tool-results.test.mjs b/crates/agent-gui/test/chat/kbrain-image-tool-results.test.mjs new file mode 100644 index 000000000..1619aa807 --- /dev/null +++ b/crates/agent-gui/test/chat/kbrain-image-tool-results.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const { displayImageResultDetails, kbrainToolImages } = loader.loadModule( + "src/lib/kbrain/toolImages.ts", +); + +const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6WQAAAABJRU5ErkJggg=="; + +test("K-brain Image results become display_image details with raw base64 content", () => { + const output = [ + "Display image: C:/Users/me/AppData/Local/Temp/shot.png", + "Display image: https://example.com/a.png", + ].join("\n"); + const images = kbrainToolImages( + [ + { type: "text", text: output }, + { type: "image", image_url: `data:image/png;base64,${PNG}` }, + { type: "image", image_url: "https://example.com/a.png" }, + ], + output, + ); + // The data URL prefix is stripped once; the renderer adds it back. + assert.deepEqual(images.content, [{ type: "image", data: PNG, mimeType: "image/png" }]); + assert.equal(images.images[0].path, "C:/Users/me/AppData/Local/Temp/shot.png"); + assert.equal(images.images[0].renderMode, "inline"); + assert.deepEqual(images.images[1], { + path: "https://example.com/a.png", + sourceType: "url", + renderMode: "proxy", + sourceUrl: "https://example.com/a.png", + }); + const details = displayImageResultDetails("Image", images.images, false); + assert.equal(details.kind, "display_image"); + assert.equal(details.loadMode, "mixed"); + assert.equal(details.images.length, 2); +}); + +test("only successful Image calls with images get display_image details", () => { + const { images } = kbrainToolImages([{ type: "image", image_url: `data:image/png;base64,${PNG}` }]); + assert.equal(images[0].path, "inline-image-1"); + assert.equal(displayImageResultDetails("Image", images, true), undefined); + assert.equal(displayImageResultDetails("ComputerUse", images, false), undefined); + assert.equal(displayImageResultDetails("Image", [], false), undefined); + assert.equal(displayImageResultDetails("Image", images, false).loadMode, "inline"); +}); + +test("a mime type sent by K-brain wins over the data URL prefix", () => { + const { content } = kbrainToolImages([ + { type: "image", image_url: `data:image/png;base64,${PNG}`, mime_type: "image/webp" }, + { type: "image", image_url: PNG }, + ]); + assert.deepEqual( + content.map((image) => `${image.mimeType}:${image.data === PNG}`), + ["image/webp:true", "image/png:true"], + ); +}); + +test("live tool results and reloaded history both build Image details from the helper", () => { + for (const relativePath of ["../../src/lib/kbrain/turn.ts", "../../src/lib/kbrain/history.ts"]) { + const source = fs.readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); + assert.match(source, /kbrainToolImages\(/, relativePath); + assert.match(source, /displayImageResultDetails\(/, relativePath); + } +}); diff --git a/crates/agent-gui/test/chat/markdown-image-policy.test.mjs b/crates/agent-gui/test/chat/markdown-image-policy.test.mjs index b3e18a014..d7c88880f 100644 --- a/crates/agent-gui/test/chat/markdown-image-policy.test.mjs +++ b/crates/agent-gui/test/chat/markdown-image-policy.test.mjs @@ -270,6 +270,57 @@ test("the reported ps1 link renders as one accessible click target and never exe ]); }); +test("file links keep the browser menu when no transcript hosts the link menu", () => { + const fileNode = { + type: "element", + tagName: "a", + properties: { href: "C:/Users/AlphaCat/AppData/Local/Temp/report.png" }, + children: [], + }; + markdownModule.rewriteChatFileLinks()({ type: "root", children: [fileNode] }); + const button = markdownModule.MarkdownFileLink({ + children: "report.png", + href: fileNode.properties.href, + node: fileNode, + onOpenFileLink() {}, + }); + let prevented = false; + button.props.onContextMenu({ + clientX: 4, + clientY: 8, + preventDefault() { + prevented = true; + }, + stopPropagation() {}, + }); + assert.equal(prevented, false); +}); + +test("both transcripts host the file link menu and its reveal never enters the link codec", () => { + for (const relativePath of [ + "../../src/pages/chat/transcript/ChatTranscript.tsx", + "../../../agent-gateway/web/src/components/GatewayTranscript.tsx", + ]) { + const source = fs.readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); + assert.match(source, / { const files = [ "../../src/pages/chat/transcript/ChatTranscript.tsx", diff --git a/crates/agent-gui/test/chat/review-chat-file-link-default-app-comma.test.mjs b/crates/agent-gui/test/chat/review-chat-file-link-default-app-comma.test.mjs new file mode 100644 index 000000000..7dea10e5a --- /dev/null +++ b/crates/agent-gui/test/chat/review-chat-file-link-default-app-comma.test.mjs @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createDomTestEnv } from "../helpers/dom-test-env.mjs"; + +// Review round 4 — reachability for the Windows comma split. +// open_with_default_app passes the canonical target to `explorer.exe ` +// through Command::arg, which only quotes arguments containing whitespace. +// explorer.exe treats an unquoted comma as a separator and opens the first +// token (verified on this machine, see the review report). So a path like +// `/run.bat,report.pdf` passes the DEFAULT_APP_EXTENSIONS check +// (extension "pdf") while explorer launches `/run.bat`. +// These tests show the agent-written path reaches the Tauri command intact, +// with open_with_default_app set, from the real Write tool row and from a +// markdown link. The fix belongs in Rust; these are reachability evidence. + +const invokes = []; +const noopIcons = new Proxy( + {}, + { + get: (_target, key) => + key === "__esModule" ? true : String(key).endsWith("_SVG_MARKUP") ? "" : () => null, + }, +); +const env = await createDomTestEnv({ + mocks: { + "@liveagent/ui/components/IconSet": noopIcons, + "@liveagent/ui/components/chat/fileTypeIcons": noopIcons, + "../../IconSet": noopIcons, + "@liveagent/ui/i18n/index": { useLocale: () => ({ locale: "en-US", t: (key) => key }) }, + "@liveagent/app/shims/tauriOpener": { openUrl: async () => {} }, + "@liveagent/app/shims/tauriCore": { + invoke: async (command, args) => { + invokes.push({ command, args }); + return { action: "opened", kind: "file", outsideWorkspace: false }; + }, + }, + "@streamdown/cjk": { cjk: {} }, + "@streamdown/code": { code: {} }, + "@streamdown/math": { math: {} }, + "@streamdown/mermaid": { mermaid: {} }, + streamdown: { + Streamdown: () => null, + defaultRemarkPlugins: {}, + defaultRehypePlugins: { raw: () => {}, sanitize: [() => {}, {}], harden: () => {} }, + }, + }, +}); +globalThis.DOMRect = window.DOMRect; +const { React, act, createRoot } = env; +const { ChatFileLinkContextMenuHost } = env.loadModule( + "@liveagent/ui/components/chat/ChatFileLinkContextMenu.tsx", +); +const { openChatFileLink } = env.loadModule("@liveagent/ui/lib/chat/openChatFileLink.ts"); +const markdown = env.loadModule("@liveagent/ui/components/Markdown.tsx"); +const { MemoToolCallItem } = env.loadModule( + "@liveagent/ui/components/chat/assistant-bubble/ToolCallItem.tsx", +); + +const WORKDIR = "D:/work/demo"; +const SPLIT_PATH = `${WORKDIR}/run.bat,report.pdf`; +const openLink = (link) => openChatFileLink({ ...link, conversationId: "conv-1", workdir: WORKDIR }); + +async function mount(child) { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + await act(async () => + root.render( + React.createElement( + "div", + null, + child, + React.createElement(ChatFileLinkContextMenuHost, { canOpenWithDefaultApp: true }), + ), + ), + ); + return { + container, + unmount: async () => { + await act(async () => root.unmount()); + container.remove(); + }, + }; +} + +async function openWithDefaultApp(target) { + await act(async () => + target.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true, button: 2, clientX: 4, clientY: 4 }), + ), + ); + const item = [...document.querySelectorAll('[role="menuitem"]')].find( + (element) => element.textContent === "chat.fileLink.openWithDefaultApp", + ); + assert.equal(Boolean(item), true, "the desktop menu offers the default app"); + await act(async () => item.click()); +} + +const sent = () => + invokes.map(({ command, args }) => `${command}|${args.path}|${args.open_with_default_app === true}`); + +test("Write tool row: a comma path reaches open_chat_file_link with open_with_default_app", async () => { + invokes.length = 0; + const item = { + toolCall: { type: "toolCall", id: "call-w", name: "Write", arguments: { path: "run.bat,report.pdf" } }, + toolResult: { + role: "toolResult", + toolCallId: "call-w", + toolName: "Write", + isError: false, + content: [{ type: "text", text: "ok" }], + details: { absolutePath: SPLIT_PATH }, + }, + }; + const view = await mount(React.createElement(MemoToolCallItem, { item, onOpenFileLink: openLink })); + try { + const target = view.container.querySelector("[data-chat-file-link]"); + assert.equal(Boolean(target), true, "the Write row renders its file link"); + await openWithDefaultApp(target); + assert.deepEqual(sent(), [`open_chat_file_link|${SPLIT_PATH}|true`]); + } finally { + await view.unmount(); + } +}); + +test("markdown link: a comma path reaches open_chat_file_link with open_with_default_app", async () => { + invokes.length = 0; + const node = { type: "element", tagName: "a", properties: { href: SPLIT_PATH }, children: [] }; + markdown.rewriteChatFileLinks()({ type: "root", children: [node] }); + const view = await mount( + React.createElement(markdown.MarkdownFileLink, { + href: node.properties.href, + node, + onOpenFileLink: openLink, + children: "report.pdf", + }), + ); + try { + const target = view.container.querySelector('[data-liveagent-file-link="true"]'); + assert.equal(Boolean(target), true, "the comma path is rewritten into a file link"); + await openWithDefaultApp(target); + assert.deepEqual(sent(), [`open_chat_file_link|${SPLIT_PATH}|true`]); + } finally { + await view.unmount(); + } +}); diff --git a/crates/agent-gui/test/chat/review-chat-url-menu-confirmation.test.mjs b/crates/agent-gui/test/chat/review-chat-url-menu-confirmation.test.mjs new file mode 100644 index 000000000..961211647 --- /dev/null +++ b/crates/agent-gui/test/chat/review-chat-url-menu-confirmation.test.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createDomTestEnv } from "../helpers/dom-test-env.mjs"; + +// Review round 4: chat web links require the "Open external link" dialog, +// which shows the real destination, until the user ticks "don't remind" +// (externalLinkPreference.ts). The right-click menu's "Open in default +// browser" calls openUrl directly and its menu never shows the URL, so a link +// whose text disguises its target opens without the user ever seeing where it +// goes — even though the left click on the same link still asks. +// Assertions compare plain values only. + +const openedUrls = []; +const noopIcons = new Proxy( + {}, + { + get: (_target, key) => + key === "__esModule" ? true : String(key).endsWith("_SVG_MARKUP") ? "" : () => null, + }, +); +const env = await createDomTestEnv({ + mocks: { + "@liveagent/ui/components/IconSet": noopIcons, + "@liveagent/ui/components/chat/fileTypeIcons": noopIcons, + "@liveagent/ui/i18n/index": { useLocale: () => ({ locale: "en-US", t: (key) => key }) }, + "@liveagent/app/shims/tauriOpener": { + openUrl: async (url) => { + openedUrls.push(url); + }, + }, + "@streamdown/cjk": { cjk: {} }, + "@streamdown/code": { code: {} }, + "@streamdown/math": { math: {} }, + "@streamdown/mermaid": { mermaid: {} }, + streamdown: { + Streamdown: () => null, + defaultRemarkPlugins: {}, + defaultRehypePlugins: { raw: () => {}, sanitize: [() => {}, {}], harden: () => {} }, + }, + }, +}); +globalThis.DOMRect = window.DOMRect; +window.open = () => null; +const { React, act, createRoot } = env; +const { ChatFileLinkContextMenuHost } = env.loadModule( + "@liveagent/ui/components/chat/ChatFileLinkContextMenu.tsx", +); +const markdown = env.loadModule("@liveagent/ui/components/Markdown.tsx"); +const { shouldSkipExternalLinkConfirmation } = env.loadModule( + "@liveagent/ui/lib/externalLinkPreference.ts", +); + +const DESTINATION = "https://login.example-attacker.test/session"; + +async function mountDisguisedLink() { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + await act(async () => + root.render( + React.createElement( + "div", + null, + React.createElement(markdown.MarkdownLink, { + href: DESTINATION, + children: "docs.python.org", + }), + React.createElement(ChatFileLinkContextMenuHost, { canOpenWithDefaultApp: true }), + ), + ), + ); + return { + link: () => container.querySelector('[data-streamdown="link"]'), + destinationShown: () => document.body.textContent.includes(DESTINATION), + unmount: async () => { + await act(async () => root.unmount()); + container.remove(); + }, + }; +} + +test("control: a left click on a disguised web link asks first and shows the destination", async () => { + openedUrls.length = 0; + assert.equal(shouldSkipExternalLinkConfirmation(), false); + const view = await mountDisguisedLink(); + try { + await act(async () => view.link().click()); + assert.deepEqual(openedUrls, []); + assert.equal(view.destinationShown(), true, "the dialog shows the real URL"); + } finally { + await view.unmount(); + } +}); + +test("the menu's open-in-browser does not open a disguised link the user has not seen", async () => { + openedUrls.length = 0; + assert.equal(shouldSkipExternalLinkConfirmation(), false); + const view = await mountDisguisedLink(); + try { + await act(async () => + view + .link() + .dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true, button: 2, clientX: 4, clientY: 4 }), + ), + ); + const menuShowsDestination = view.destinationShown(); + const item = [...document.querySelectorAll('[role="menuitem"]')].find( + (element) => element.textContent === "chat.fileLink.openInBrowser", + ); + assert.equal(Boolean(item), true, "the menu offers open in browser"); + await act(async () => item.click()); + // Either fix is fine: ask first (nothing opened yet) or show the URL in + // the menu before opening it. Opening a destination never shown is not. + assert.equal( + openedUrls.length === 0 || menuShowsDestination, + true, + `opened ${JSON.stringify(openedUrls)} without ever showing it (menu shows URL: ${menuShowsDestination})`, + ); + if (openedUrls.length === 0) { + assert.equal(view.destinationShown(), true, "the confirmation shows the real URL"); + } + } finally { + await view.unmount(); + } +}); diff --git a/crates/agent-gui/test/chat/review-kbrain-image-webui-live.test.mjs b/crates/agent-gui/test/chat/review-kbrain-image-webui-live.test.mjs new file mode 100644 index 000000000..cce90c6e1 --- /dev/null +++ b/crates/agent-gui/test/chat/review-kbrain-image-webui-live.test.mjs @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import http from "node:http"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { createWebModuleLoader } from "../../../agent-gateway/test/helpers/load-web-module.mjs"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +// Review round 5 (playbook A: the second consumer of a contract). +// K-brain Image results now render through `display_image` details built in +// turn.ts toolResult(). The desktop keeps them, but runKBrainConversationTurn +// projectToolResult() mirrors the result to WebUI as a `tool_result` event with +// only content/isError, although the WebUI reducer reads `details`. A WebUI +// viewer of a live K-brain turn therefore gets no display_image payload, so +// the Image row renders nothing until a history reload rebuilds it. +// Real K-brain turn over a real HTTP/SSE fixture -> real WebUI reducer/rows -> +// the shared getNativeDisplayImagePayload render gate. Plain-value asserts only. + +const loader = createTsModuleLoader(); +const { runAgentConversationTurn } = loader.loadModule("src/pages/chat/turns/runAgentConversationTurn.ts"); +const { createConversationStateFromContext } = loader.loadModule("src/lib/chat/conversation/conversationState.ts"); +const { setKBrainRuntimeConnection, clearKBrainRuntimeConnection } = loader.loadModule("src/lib/kbrain/runtimeConnection.ts"); + +const webLoader = createWebModuleLoader({ + rootDir: fileURLToPath(new URL("../../../agent-gateway/web", import.meta.url)), +}); +const { createTurn, applyEventToTurn } = webLoader.loadModule("src/lib/chat/transcript/turnReducer.ts"); +const { buildTurnRows } = webLoader.loadModule("src/lib/chat/transcript/rows.ts"); +const { getNativeDisplayImagePayload } = webLoader.loadModule( + "@liveagent/ui/components/chat/assistant-bubble/ToolImages.tsx", +); + +const PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; + +async function runImageTurn(t) { + const conversationId = `conv-${Math.random().toString(36).slice(2)}`; + let stream; + let sequence = 0; + const event = (type, payload) => stream.write(`data: ${JSON.stringify({ + version: "kbrain.agent.v1", seq: ++sequence, conversation_id: "session", run_id: "run", type, + created_at: new Date().toISOString(), payload, + })}\n\n`); + const server = http.createServer(async (request, response) => { + for await (const _chunk of request) { /* drain */ } + const json = (value, code = 200) => { + response.writeHead(code, { "Content-Type": "application/json" }); + response.end(JSON.stringify(value)); + }; + if (request.url === "/v1/sessions") return json({ id: "session" }, 201); + if (request.url === "/v1/sessions/session/runs") return json({ version: "kbrain.agent.v1", conversation_id: "session", run_id: "run", accepted_seq: 1 }, 202); + if (request.url === "/v1/sessions/session" || request.url.startsWith("/v1/sessions/session/history")) { + const session = { id: "session", revision: "r", title: "t", model: { provider: "fixture", model: "model" }, created_at: "2026-11-01T00:00:00Z", updated_at: "2026-11-01T00:00:01Z", message_count: 0, last_seq: sequence, messages: [] }; + return json(request.url.includes("/history") ? { session, revision: "r", oldest_offset: 0, has_more_before: false, total_message_count: 0, active_messages: [] } : session); + } + if (request.url.startsWith("/v1/sessions/session/events")) { + stream = response; + response.writeHead(200, { "Content-Type": "text/event-stream" }); + sequence = Number(new URL(request.url, "http://localhost").searchParams.get("after_seq") ?? 0); + event("tool.call", { tool_call: { id: "img-1", name: "Image", arguments: { path: "chart.png" } } }); + event("tool.result", { + tool_result: { + id: "img-1", + name: "Image", + output: "Display image: chart.png", + content: [{ type: "image", image_url: `data:image/png;base64,${PNG_BASE64}`, mime_type: "image/png" }], + }, + }); + event("assistant.message.created", { role: "assistant", content: [{ type: "text", text: "done" }] }); + event("run.completed", { state: "completed" }); + stream.end(); + return; + } + json({ error: `Unexpected path ${request.url}` }, 404); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const origin = `http://127.0.0.1:${server.address().port}`; + const originalStorage = globalThis.localStorage; + const storage = new Map(); + globalThis.localStorage = { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, String(value)), + removeItem: (key) => storage.delete(key), + key: (index) => Array.from(storage.keys())[index] ?? null, + get length() { return storage.size; }, + }; + setKBrainRuntimeConnection({ baseUrl: origin, token: "fixture-token", protocolVersion: "kbrain.agent.v1" }); + t.after(() => { + clearKBrainRuntimeConnection(); + if (originalStorage === undefined) delete globalThis.localStorage; + else globalThis.localStorage = originalStorage; + server.closeAllConnections(); + server.close(); + }); + const context = { systemPrompt: "system", messages: [{ role: "user", content: [{ type: "text", text: "show it" }], timestamp: 1 }] }; + let state = createConversationStateFromContext(context); + let rounds = []; + const gatewayEvents = []; + const errors = []; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + t.after(() => clearTimeout(timeout)); + await runAgentConversationTurn({ + providerId: "codex", model: "model", selectedModel: { customProviderId: "fixture", model: "model" }, + runtime: { backend: "kbrain", baseUrl: "https://must-not-be-called.invalid", apiKey: "unused" }, sessionId: "frontend-session", conversationId, + trajectoryMessageId: "message", conversationCwd: "/workspace", effectiveWorkdir: "/workspace", createdAt: 1, fallbackTitle: "t", titlePromise: null, + transcriptStore: {}, cancellation: { userStop: controller }, + gatewayBridgeEvents: { queueToken() {}, queueEvent(value) { gatewayEvents.push(value); }, queueToolStatus() {}, emitError(message) { errors.push(String(message)); } }, + hookLifecycle: Object.fromEntries(["startAgent", "startTurn", "ensureMessageEnded", "endTurn", "endAgent"].map((key) => [key, () => {}])), + buildPreparedContext: () => context, getNextConversationState: () => state, + applyConversationState: (value) => { state = value; }, + batchLiveRoundsUpdate: (update) => { rounds = update(rounds); }, + updateToolStatus() {}, updateGatewayBridgeToolStatus() {}, freezeGatewayFinalProjection() {}, settleLiveTranscript() {}, + persistConversationWithHistorySync: async () => true, + }); + return { gatewayEvents, errors, rounds }; +} + +function findDetailsKind(value, toolCallId) { + // Walk the desktop live rounds for the toolResult of this call. + const seen = new Set(); + const stack = [value]; + while (stack.length) { + const current = stack.pop(); + if (!current || typeof current !== "object" || seen.has(current)) continue; + seen.add(current); + if (current.role === "toolResult" && current.toolCallId === toolCallId) return current.details?.kind ?? ""; + for (const child of Object.values(current)) stack.push(child); + } + return "missing"; +} + +test("K-brain Image: desktop live rounds carry display_image details (control)", async (t) => { + const { rounds, errors } = await runImageTurn(t); + assert.equal(errors.length, 0, `turn failed: ${errors.join(" | ")}`); + assert.equal(findDetailsKind(rounds, "img-1"), "display_image"); +}); + +test("K-brain Image: the WebUI mirror of a live turn can render the image", async (t) => { + const { gatewayEvents, errors } = await runImageTurn(t); + assert.equal(errors.length, 0, `turn failed: ${errors.join(" | ")}`); + let turn = createTurn({ key: "req:image", runId: "run", phase: "streaming" }); + for (const value of gatewayEvents) { + if (value.type === "tool_call" || value.type === "tool_result") turn = applyEventToTurn(turn, value); + } + const assistant = buildTurnRows(turn).find((row) => row.kind === "assistant"); + const block = assistant?.rounds.flatMap((round) => round.blocks).find((item) => item.kind === "tool"); + assert.equal(Boolean(block?.item?.toolResult), true, "WebUI attached the tool result"); + assert.equal( + block.item.toolResult.details?.kind ?? "none", + "display_image", + "the mirrored tool_result carries no display_image details", + ); + assert.equal(Boolean(getNativeDisplayImagePayload(block.item)), true, "WebUI render gate finds the image"); +}); + +test("control: the same mirrored event plus the desktop's details renders in WebUI", async (t) => { + const { gatewayEvents, rounds, errors } = await runImageTurn(t); + assert.equal(errors.length, 0, `turn failed: ${errors.join(" | ")}`); + let desktopDetails; + const stack = [rounds]; + const seen = new Set(); + while (stack.length && !desktopDetails) { + const current = stack.pop(); + if (!current || typeof current !== "object" || seen.has(current)) continue; + seen.add(current); + if (current.role === "toolResult" && current.toolCallId === "img-1") desktopDetails = current.details; + for (const child of Object.values(current)) stack.push(child); + } + let turn = createTurn({ key: "req:image", runId: "run", phase: "streaming" }); + for (const value of gatewayEvents) { + if (value.type === "tool_call") turn = applyEventToTurn(turn, value); + if (value.type === "tool_result") turn = applyEventToTurn(turn, { ...value, details: desktopDetails }); + } + const assistant = buildTurnRows(turn).find((row) => row.kind === "assistant"); + const block = assistant?.rounds.flatMap((round) => round.blocks).find((item) => item.kind === "tool"); + assert.equal(Boolean(getNativeDisplayImagePayload(block.item)), true); +}); diff --git a/crates/agent-gui/test/providers/kbrain-turn.test.mjs b/crates/agent-gui/test/providers/kbrain-turn.test.mjs index dcdbe3c3f..8863bd870 100644 --- a/crates/agent-gui/test/providers/kbrain-turn.test.mjs +++ b/crates/agent-gui/test/providers/kbrain-turn.test.mjs @@ -37,7 +37,8 @@ test("backend computer screenshots survive live tool result events", async () => event(4, "run.completed"), )] }); await runKBrainTurn({ ...baseParams, fetch, onToolResult: (_call, result) => results.push(result) }); - assert.equal(results[0].content[1].data, image); + // Transcript images hold raw base64; the renderer adds the data URL prefix. + assert.equal(results[0].content[1].data, "aW1hZ2U="); assert.equal(results[0].content[1].mimeType, "image/png"); }); diff --git a/crates/agent-ui/src/components/Markdown.tsx b/crates/agent-ui/src/components/Markdown.tsx index 764eb2ffd..6824b9103 100644 --- a/crates/agent-ui/src/components/Markdown.tsx +++ b/crates/agent-ui/src/components/Markdown.tsx @@ -1,6 +1,11 @@ import { openUrl } from "@liveagent/app/shims/tauriOpener"; import { ChevronDown, ChevronUp, Copy, ExternalLink } from "@liveagent/ui/components/IconSet"; import { useLocale } from "@liveagent/ui/i18n/index"; +import { + showChatFileLinkContextMenu, + showChatUrlContextMenu, + withChatFileLinkMenuIntent, +} from "@liveagent/ui/lib/chat/chatFileLinkContextMenuStore"; import { math } from "@streamdown/math"; import { type ComponentProps, @@ -345,7 +350,8 @@ export function MarkdownFileLink(props: MarkdownFileLinkProps) { )} data-liveagent-file-link="true" title={label} - onClick={() => onOpenFileLink(parsed)} + onClick={() => onOpenFileLink(withChatFileLinkMenuIntent(parsed))} + onContextMenu={(event) => showChatFileLinkContextMenu(event, parsed)} > {children} @@ -418,6 +424,9 @@ function MarkdownExternalLink(props: MarkdownAnchorFallbackProps) { data-incomplete={incomplete} data-streamdown="link" title={title} + onContextMenu={(event) => { + if (!incomplete) showChatUrlContextMenu(event, href); + }} onClick={() => { if (incomplete) return; if (shouldSkipExternalLinkConfirmation()) { diff --git a/crates/agent-ui/src/components/chat/ChatFileLinkContextMenu.tsx b/crates/agent-ui/src/components/chat/ChatFileLinkContextMenu.tsx new file mode 100644 index 000000000..0558f50f7 --- /dev/null +++ b/crates/agent-ui/src/components/chat/ChatFileLinkContextMenu.tsx @@ -0,0 +1,112 @@ +import { openUrl } from "@liveagent/app/shims/tauriOpener"; +import { AppWindow, Copy, FileText, FolderOpen, Globe } from "@liveagent/ui/components/IconSet"; +import { + ContextMenuItem, + ContextMenuLabel, + ContextMenuPopup, + ContextMenuSeparator, +} from "@liveagent/ui/components/ui/context-menu"; +import { useLocale } from "@liveagent/ui/i18n/index"; +import { + closeChatFileLinkMenu, + getChatFileLinkMenuSnapshot, + openFromChatFileLinkMenu, + registerChatFileLinkMenuHost, + subscribeChatFileLinkMenu, +} from "@liveagent/ui/lib/chat/chatFileLinkContextMenuStore"; +import { copyTextToClipboard } from "@liveagent/ui/lib/shared/clipboard"; +import { useEffect, useState, useSyncExternalStore } from "react"; + +const WEB_PAGE_PATTERN = /\.(?:html?|xhtml|svg)$/i; + +type ChatFileLinkContextMenuHostProps = { + /** Desktop only: launching the host's default app means nothing to a + * WebUI viewer on another device. */ + canOpenWithDefaultApp?: boolean; +}; + +/** Renders the menu opened by `showChatFileLinkContextMenu` or + * `showChatUrlContextMenu`; mount one per transcript. */ +export function ChatFileLinkContextMenuHost({ + canOpenWithDefaultApp = false, +}: ChatFileLinkContextMenuHostProps) { + const { t } = useLocale(); + const [hostId] = useState(() => Symbol("chat-file-link-menu-host")); + const { menu: state, primaryHost } = useSyncExternalStore( + subscribeChatFileLinkMenu, + getChatFileLinkMenuSnapshot, + getChatFileLinkMenuSnapshot, + ); + useEffect(() => registerChatFileLinkMenuHost(hostId), [hostId]); + if (!state || primaryHost !== hostId || typeof document === "undefined") return null; + return ( + // The portal still bubbles React events through the primary host's pane; + // keep a press here from focusing that pane instead of the link's own. + + {state.kind === "file" ? ( + <> + openFromChatFileLinkMenu(state, "open")}> + + {t("chat.fileLink.open")} + + openFromChatFileLinkMenu(state, "reveal")}> + + {t("chat.fileLink.openContainingDirectory")} + + {canOpenWithDefaultApp ? ( + openFromChatFileLinkMenu(state, "default-app")}> + {WEB_PAGE_PATTERN.test(state.link.path) ? ( + <> + + {t("chat.fileLink.openInBrowser")} + + ) : ( + <> + + {t("chat.fileLink.openWithDefaultApp")} + + )} + + ) : null} + + ) : ( + <> + {/* The link text may differ from its target; the left click shows + the URL in a confirmation, so the menu shows it up front. */} + + {state.url} + + + { + closeChatFileLinkMenu(); + void openUrl(state.url).catch((error: unknown) => { + console.error("Failed to open link in the default browser", error); + }); + }} + > + + {t("chat.fileLink.openInBrowser")} + + { + closeChatFileLinkMenu(); + void copyTextToClipboard(state.url); + }} + > + + {t("chat.fileLink.copyLink")} + + + )} + + ); +} diff --git a/crates/agent-ui/src/components/chat/assistant-bubble/ToolCallItem.tsx b/crates/agent-ui/src/components/chat/assistant-bubble/ToolCallItem.tsx index 70079a5d9..24b79ddd0 100644 --- a/crates/agent-ui/src/components/chat/assistant-bubble/ToolCallItem.tsx +++ b/crates/agent-ui/src/components/chat/assistant-bubble/ToolCallItem.tsx @@ -29,6 +29,10 @@ import { type ToolTraceItem, toolResultMessageToText, } from "@liveagent/ui/lib/chat/assistantBubbleAdapter"; +import { + showChatFileLinkContextMenu, + withChatFileLinkMenuIntent, +} from "@liveagent/ui/lib/chat/chatFileLinkContextMenuStore"; import type { ChatFileLink } from "@liveagent/ui/lib/chat/chatFileLinks"; import { EXIT_PLAN_MODE_TOOL_NAME, @@ -98,7 +102,8 @@ function FileOperationTarget({ )} title={operation.path} aria-label={`${actionLabel} ${operation.path}`} - onClick={() => onOpenFileLink(fileLink)} + onClick={() => onOpenFileLink(withChatFileLinkMenuIntent(fileLink))} + onContextMenu={(event) => showChatFileLinkContextMenu(event, fileLink)} > {operation.fileName} diff --git a/crates/agent-ui/src/i18n/translations/enUSCommon.ts b/crates/agent-ui/src/i18n/translations/enUSCommon.ts index 9e3d67089..0667562ce 100644 --- a/crates/agent-ui/src/i18n/translations/enUSCommon.ts +++ b/crates/agent-ui/src/i18n/translations/enUSCommon.ts @@ -602,6 +602,11 @@ export const EN_US_COMMON_TRANSLATIONS = { "chat.markdown.expandCode": "Expand {count} lines", "chat.markdown.expandAllCode": "Show all", "chat.markdown.collapseCode": "Collapse code", + "chat.fileLink.open": "Open file", + "chat.fileLink.openContainingDirectory": "Open containing folder", + "chat.fileLink.openWithDefaultApp": "Open with default app", + "chat.fileLink.openInBrowser": "Open in default browser", + "chat.fileLink.copyLink": "Copy link", "chat.composer.noMatchingFiles": "No matching files", "chat.composer.noMatchingFilesOrApps": "No matching files or apps", "chat.composer.noMatchingApps": "No matching apps", diff --git a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts index 6a706cf52..14371db27 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts @@ -557,6 +557,11 @@ export const ZH_CN_COMMON_TRANSLATIONS = { "chat.markdown.expandCode": "展开 {count} 行", "chat.markdown.expandAllCode": "展开全部", "chat.markdown.collapseCode": "收起代码", + "chat.fileLink.open": "打开文件", + "chat.fileLink.openContainingDirectory": "打开所在目录", + "chat.fileLink.openWithDefaultApp": "用默认应用打开", + "chat.fileLink.openInBrowser": "用默认浏览器打开", + "chat.fileLink.copyLink": "复制链接", "chat.composer.noMatchingFiles": "未找到匹配的文件", "chat.composer.noMatchingFilesOrApps": "未找到匹配的文件或应用", "chat.composer.noMatchingApps": "未找到匹配的应用", diff --git a/crates/agent-ui/src/lib/chat/chatFileLinkContextMenuStore.ts b/crates/agent-ui/src/lib/chat/chatFileLinkContextMenuStore.ts new file mode 100644 index 000000000..04829e209 --- /dev/null +++ b/crates/agent-ui/src/lib/chat/chatFileLinkContextMenuStore.ts @@ -0,0 +1,114 @@ +import type { ChatFileLink } from "./chatFileLinks"; + +type MenuPoint = { x: number; y: number }; + +export type ChatFileLinkMenuState = + | (MenuPoint & { + kind: "file"; + link: ChatFileLink; + /** The right-clicked link; menu items click it again so they run its + * current handler, not the one rendered when the menu opened. */ + target: { isConnected: boolean; click(): void }; + }) + | (MenuPoint & { kind: "url"; url: string }); + +export type ChatFileLinkMenuIntent = "open" | "reveal" | "default-app"; + +export type ChatFileLinkMenuSnapshot = { + menu: ChatFileLinkMenuState | null; + primaryHost: symbol | undefined; +}; + +type ContextMenuEvent = { + clientX: number; + clientY: number; + currentTarget?: unknown; + preventDefault(): void; + stopPropagation(): void; +}; + +// Workbench panes each mount a transcript; only the first mounted host renders. +const hosts: symbol[] = []; +let snapshot: ChatFileLinkMenuSnapshot = { menu: null, primaryHost: undefined }; +let pendingIntent: ChatFileLinkMenuIntent = "open"; +const listeners = new Set<() => void>(); + +function publish(menu: ChatFileLinkMenuState | null) { + snapshot = { menu, primaryHost: hosts[0] }; + for (const listener of listeners) listener(); +} + +function openMenu(event: ContextMenuEvent, menu: ChatFileLinkMenuState) { + event.preventDefault(); + event.stopPropagation(); + publish(menu); +} + +export function subscribeChatFileLinkMenu(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export const getChatFileLinkMenuSnapshot = () => snapshot; + +export function registerChatFileLinkMenuHost(hostId: symbol) { + hosts.push(hostId); + publish(snapshot.menu); + return () => { + const index = hosts.indexOf(hostId); + if (index >= 0) hosts.splice(index, 1); + publish(hosts.length === 0 ? null : snapshot.menu); + }; +} + +export function closeChatFileLinkMenu() { + publish(null); +} + +/** Link click handlers pass their link through this so a menu item can turn + * the click into "reveal in the file manager" or "open with the default app". */ +export function withChatFileLinkMenuIntent(link: ChatFileLink): ChatFileLink { + const intent = pendingIntent; + pendingIntent = "open"; + if (intent === "reveal") return { ...link, openInFileManager: true }; + if (intent === "default-app") return { ...link, openWithDefaultApp: true }; + return link; +} + +/** Runs a menu item against the link's current handler: the pane that owns + * the link may switch conversations between the right-click and the choice. + * A link that left the DOM meanwhile is not opened with a stale handler. */ +export function openFromChatFileLinkMenu( + menu: Extract, + intent: ChatFileLinkMenuIntent, +) { + closeChatFileLinkMenu(); + if (!menu.target.isConnected) return; + pendingIntent = intent; + try { + menu.target.click(); + } finally { + pendingIntent = "open"; + } +} + +/** Right-click on a chat file link. Without a mounted host the browser menu stays. */ +export function showChatFileLinkContextMenu(event: ContextMenuEvent, link: ChatFileLink) { + const target = event.currentTarget as { isConnected?: unknown; click?: unknown } | undefined; + if (hosts.length === 0 || !target || typeof target.click !== "function") return; + openMenu(event, { + kind: "file", + x: event.clientX, + y: event.clientY, + link, + target: target as { isConnected: boolean; click(): void }, + }); +} + +/** Right-click on a web link in a chat message. */ +export function showChatUrlContextMenu(event: ContextMenuEvent, url: string) { + if (hosts.length === 0) return; + openMenu(event, { kind: "url", x: event.clientX, y: event.clientY, url }); +} diff --git a/crates/agent-ui/src/lib/chat/chatFileLinks.ts b/crates/agent-ui/src/lib/chat/chatFileLinks.ts index ce46a77de..66398d30e 100644 --- a/crates/agent-ui/src/lib/chat/chatFileLinks.ts +++ b/crates/agent-ui/src/lib/chat/chatFileLinks.ts @@ -4,6 +4,12 @@ export type ChatFileLink = { endLine?: number; column?: number; source: "absolute" | "relative" | "file-url"; + /** Set by the link's context menu to reveal the target in the host file + * manager; never parsed from or encoded into message text. */ + openInFileManager?: boolean; + /** Set by the link's context menu (desktop only) to hand the file to the + * host's default app or browser; never parsed from message text. */ + openWithDefaultApp?: boolean; }; const WINDOWS_DRIVE_PATH_PATTERN = /^[a-zA-Z]:[\\/]/; diff --git a/crates/agent-ui/src/lib/chat/openChatFileLink.ts b/crates/agent-ui/src/lib/chat/openChatFileLink.ts index a180c1102..1b519c1f5 100644 --- a/crates/agent-ui/src/lib/chat/openChatFileLink.ts +++ b/crates/agent-ui/src/lib/chat/openChatFileLink.ts @@ -28,5 +28,6 @@ export function openChatFileLink(params: OpenChatFileLinkParams) { end_line: params.endLine, column: params.column, open_in_file_manager: params.openInFileManager, + open_with_default_app: params.openWithDefaultApp, }); }