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