This commit is contained in:
Douwe Osinga
2026-02-28 12:06:35 -05:00
parent b43302a058
commit c438eb71af
18 changed files with 888 additions and 20 deletions
+1
View File
@@ -361,6 +361,7 @@ pub fn message_to_markdown(message: &Message, export_all_content: bool) -> Strin
.unwrap_or_else(|_| "{}".to_string())
));
}
},
MessageContent::Text(text) => {
md.push_str(&text.text);
+1
View File
@@ -467,6 +467,7 @@ derive_utoipa!(Icon as IconSchema);
goose::prompt_template::Template,
super::routes::action_required::ConfirmToolActionRequest,
super::routes::reply::ChatRequest,
super::routes::reply::ToolResultPayload,
super::routes::session::ImportSessionRequest,
super::routes::session::SessionListResponse,
super::routes::session::UpdateSessionNameRequest,
+33 -1
View File
@@ -13,6 +13,7 @@ use bytes::Bytes;
use futures::{stream::StreamExt, Stream};
use goose::agents::{AgentEvent, SessionConfig};
use goose::conversation::message::{Message, MessageContent, TokenState};
use rmcp::model::{CallToolResult, Content};
use goose::conversation::Conversation;
use goose::session::SessionManager;
use rmcp::model::ServerNotification;
@@ -76,6 +77,17 @@ fn track_tool_telemetry(content: &MessageContent, all_messages: &[Message]) {
}
}
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
pub struct ToolResultPayload {
/// The tool_call_id that this result corresponds to
pub tool_call_id: String,
/// The result content from the client-side tool execution
pub result: String,
/// Whether the tool execution was an error
#[serde(default)]
pub is_error: bool,
}
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
pub struct ChatRequest {
user_message: Message,
@@ -87,6 +99,11 @@ pub struct ChatRequest {
session_id: String,
recipe_name: Option<String>,
recipe_version: Option<String>,
/// When present, this reply contains a tool result from client-side execution
/// (e.g., browser commands). The server will prepend the tool response to the
/// conversation before continuing the agent loop.
#[serde(default)]
tool_result: Option<ToolResultPayload>,
}
pub struct SseResponse {
@@ -242,7 +259,21 @@ pub async fn reply(
let stream = ReceiverStream::new(rx);
let cancel_token = CancellationToken::new();
let user_message = request.user_message;
let user_message = if let Some(tr) = request.tool_result {
let content = vec![Content::text(&tr.result)];
let call_result = if tr.is_error {
Err(rmcp::model::ErrorData::new(
rmcp::model::ErrorCode::INTERNAL_ERROR,
tr.result,
None,
))
} else {
Ok(CallToolResult::success(content))
};
Message::user().with_tool_response(tr.tool_call_id, call_result)
} else {
request.user_message
};
let override_conversation = request.override_conversation;
let task_cancel = cancel_token.clone();
@@ -496,6 +527,7 @@ mod tests {
session_id: "test-session".to_string(),
recipe_name: None,
recipe_version: None,
tool_result: None,
})
.unwrap(),
))
@@ -96,4 +96,5 @@ impl ActionRequiredManager {
Ok(())
}
}
+44 -6
View File
@@ -1174,6 +1174,7 @@ impl Agent {
let mut messages_to_add = Conversation::default();
let mut tools_updated = false;
let mut did_recovery_compact_this_iteration = false;
let mut has_forward_to_client = false;
while let Some(next) = stream.next().await {
if is_token_cancelled(&cancel_token) {
@@ -1250,6 +1251,7 @@ impl Agent {
yield AgentEvent::Message(msg);
}
}
let mut forward_to_client_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
if goose_mode == GooseMode::Chat {
// Skip all remaining tool calls in chat mode
for request in remaining_requests.iter() {
@@ -1372,9 +1374,17 @@ impl Agent {
yield AgentEvent::McpNotification((request_id.clone(), server_notification));
}
}
if meta.0.get("forward_to_client").and_then(|v| v.as_bool()).unwrap_or(false) {
forward_to_client_ids.insert(request_id.clone());
}
}
}
if forward_to_client_ids.contains(&request_id) {
continue;
}
if enable_extension_request_ids.contains(&request_id)
&& output.is_err()
{
@@ -1438,6 +1448,8 @@ impl Agent {
for (idx, request) in frontend_requests.iter().chain(remaining_requests.iter()).enumerate() {
if request.tool_call.is_ok() {
let is_forwarded = forward_to_client_ids.contains(&request.id);
let mut request_msg = Message::assistant()
.with_id(format!("msg_{}", Uuid::new_v4()));
@@ -1455,11 +1467,37 @@ impl Agent {
request.metadata.as_ref(),
request.tool_meta.clone(),
);
messages_to_add.push(request_msg);
let final_response = tool_response_messages[idx]
.lock().await.clone();
yield AgentEvent::Message(final_response.clone());
messages_to_add.push(final_response);
if is_forwarded {
// For forwarded requests, annotate the tool request with
// forward_to_client so the client knows to execute it locally
// and call back with the result via /reply.
let mut forwarded_msg = Message::assistant()
.with_id(format!("msg_{}", Uuid::new_v4()));
for rc in &reasoning_content {
forwarded_msg = forwarded_msg.with_content(rc.clone());
}
let mut meta = request.tool_meta.clone()
.unwrap_or_else(|| serde_json::json!({}));
meta.as_object_mut().unwrap()
.insert("forward_to_client".to_string(), serde_json::json!(true));
forwarded_msg = forwarded_msg
.with_tool_request_with_metadata(
request.id.clone(),
request.tool_call.clone(),
request.metadata.as_ref(),
Some(meta),
);
yield AgentEvent::Message(forwarded_msg.clone());
messages_to_add.push(forwarded_msg);
has_forward_to_client = true;
} else {
messages_to_add.push(request_msg);
let final_response = tool_response_messages[idx]
.lock().await.clone();
yield AgentEvent::Message(final_response.clone());
messages_to_add.push(final_response);
}
} else {
let error_msg = format!(
"[system: Tool call could not be parsed: {}. The response may have been truncated. Try breaking the task into smaller steps.]",
@@ -1566,7 +1604,7 @@ impl Agent {
(tools, toolshim_tools, system_prompt) =
self.prepare_tools_and_prompt(&session_config.id, &session.working_dir).await?;
}
let mut exit_chat = false;
let mut exit_chat = has_forward_to_client;
if no_tools_called {
if let Some(final_output_tool) = self.final_output_tool.lock().await.as_ref() {
if final_output_tool.final_output.is_none() {
@@ -0,0 +1,260 @@
use crate::agents::extension::PlatformExtensionContext;
use crate::agents::mcp_client::{Error, McpClientTrait};
use async_trait::async_trait;
use rmcp::model::{
CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListResourcesResult,
ListToolsResult, Meta, ProtocolVersion, ReadResourceResult, ServerCapabilities,
Tool as McpTool, ToolsCapability,
};
use schemars::{schema_for, JsonSchema};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio_util::sync::CancellationToken;
pub static EXTENSION_NAME: &str = "browser";
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct BrowserOpenParams {
/// URL to open in the browser
url: String,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct BrowserNavigateParams {
/// URL to navigate to
url: String,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct BrowserClickParams {
/// CSS selector for the element to click
selector: String,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct BrowserTypeParams {
/// CSS selector for the input element
selector: String,
/// Text to type into the element
text: String,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct BrowserGetTextParams {
/// CSS selector (defaults to "body" if not provided)
selector: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct BrowserGetHtmlParams {
/// CSS selector (defaults to "body" if not provided)
selector: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct BrowserEvaluateParams {
/// JavaScript code to execute in the page context
script: String,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct BrowserWaitParams {
/// CSS selector to wait for
selector: String,
/// Timeout in milliseconds (default: 5000)
timeout_ms: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct BrowserScrollParams {
/// Direction to scroll: "up", "down", "top", or "bottom"
direction: String,
/// Number of pixels to scroll (optional, defaults to one viewport)
amount: Option<i32>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct BrowserScreenshotParams {}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct BrowserCloseParams {}
pub struct BrowserClient {
#[allow(dead_code)]
context: PlatformExtensionContext,
info: InitializeResult,
}
impl BrowserClient {
pub fn new(context: PlatformExtensionContext) -> Self {
let info = InitializeResult {
protocol_version: ProtocolVersion::V_2025_03_26,
capabilities: ServerCapabilities {
tools: Some(ToolsCapability {
list_changed: Some(false),
}),
tasks: None,
resources: None,
extensions: None,
prompts: None,
completions: None,
experimental: None,
logging: None,
},
server_info: Implementation {
name: EXTENSION_NAME.to_string(),
description: None,
title: Some("Browser".to_string()),
version: "1.0.0".to_string(),
icons: None,
website_url: None,
},
instructions: Some(
"Control an embedded browser window for web navigation and content extraction."
.to_string(),
),
};
Self { context, info }
}
fn schema<T: JsonSchema>() -> JsonObject {
let schema = schema_for!(T);
let schema_value =
serde_json::to_value(schema).expect("Failed to serialize schema");
schema_value.as_object().unwrap().clone()
}
fn forward_to_client(command: &str, arguments: Option<JsonObject>) -> CallToolResult {
let params = arguments
.map(serde_json::Value::Object)
.unwrap_or(json!({}));
let content = json!({
"command": command,
"params": params,
});
let mut meta_map = serde_json::Map::new();
meta_map.insert("forward_to_client".to_string(), json!(true));
CallToolResult {
content: vec![Content::text(content.to_string())],
structured_content: None,
is_error: Some(false),
meta: Some(Meta(meta_map)),
}
}
}
#[async_trait]
impl McpClientTrait for BrowserClient {
async fn list_tools(
&self,
_session_id: &str,
_next_cursor: Option<String>,
_cancel_token: CancellationToken,
) -> Result<ListToolsResult, Error> {
let tools = vec![
McpTool::new(
"browser_open".to_string(),
"Open a URL in the embedded browser. This will display a browser panel in the UI."
.to_string(),
Self::schema::<BrowserOpenParams>(),
),
McpTool::new(
"browser_close".to_string(),
"Close the embedded browser panel.".to_string(),
Self::schema::<BrowserCloseParams>(),
),
McpTool::new(
"browser_navigate".to_string(),
"Navigate to a different URL in the browser.".to_string(),
Self::schema::<BrowserNavigateParams>(),
),
McpTool::new(
"browser_screenshot".to_string(),
"Take a screenshot of the current browser view.".to_string(),
Self::schema::<BrowserScreenshotParams>(),
),
McpTool::new(
"browser_click".to_string(),
"Click an element in the page by CSS selector.".to_string(),
Self::schema::<BrowserClickParams>(),
),
McpTool::new(
"browser_type".to_string(),
"Type text into an input element.".to_string(),
Self::schema::<BrowserTypeParams>(),
),
McpTool::new(
"browser_get_text".to_string(),
"Get the text content of an element (default: body).".to_string(),
Self::schema::<BrowserGetTextParams>(),
),
McpTool::new(
"browser_get_html".to_string(),
"Get the HTML content of an element (default: body).".to_string(),
Self::schema::<BrowserGetHtmlParams>(),
),
McpTool::new(
"browser_evaluate".to_string(),
"Execute JavaScript in the page context and return the result.".to_string(),
Self::schema::<BrowserEvaluateParams>(),
),
McpTool::new(
"browser_wait".to_string(),
"Wait for an element to appear in the page.".to_string(),
Self::schema::<BrowserWaitParams>(),
),
McpTool::new(
"browser_scroll".to_string(),
"Scroll the page. Direction can be 'up', 'down', 'top', or 'bottom'.".to_string(),
Self::schema::<BrowserScrollParams>(),
),
];
Ok(ListToolsResult {
tools,
next_cursor: None,
meta: None,
})
}
async fn call_tool(
&self,
_session_id: &str,
name: &str,
arguments: Option<JsonObject>,
_working_dir: Option<&str>,
_cancel_token: CancellationToken,
) -> Result<CallToolResult, Error> {
let command = name.strip_prefix("browser_").unwrap_or(name);
Ok(Self::forward_to_client(command, arguments))
}
async fn list_resources(
&self,
_session_id: &str,
_next_cursor: Option<String>,
_cancel_token: CancellationToken,
) -> Result<ListResourcesResult, Error> {
Ok(ListResourcesResult {
resources: vec![],
next_cursor: None,
meta: None,
})
}
async fn read_resource(
&self,
_session_id: &str,
_uri: &str,
_cancel_token: CancellationToken,
) -> Result<ReadResourceResult, Error> {
Err(Error::TransportClosed)
}
fn get_info(&self) -> Option<&InitializeResult> {
Some(&self.info)
}
}
@@ -1,4 +1,5 @@
pub mod apps;
pub mod browser;
pub mod chatrecall;
#[cfg(feature = "code-mode")]
pub mod code_execution;
@@ -130,6 +131,19 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
},
);
map.insert(
browser::EXTENSION_NAME,
PlatformExtensionDef {
name: browser::EXTENSION_NAME,
display_name: "Browser",
description:
"Control an embedded browser window to navigate, interact with, and extract content from web pages",
default_enabled: false,
unprefixed_tools: false,
client_factory: |ctx| Box::new(browser::BrowserClient::new(ctx)),
},
);
map
},
);
+29
View File
@@ -3917,6 +3917,14 @@
"session_id": {
"type": "string"
},
"tool_result": {
"allOf": [
{
"$ref": "#/components/schemas/ToolResultPayload"
}
],
"nullable": true
},
"user_message": {
"$ref": "#/components/schemas/Message"
}
@@ -8050,6 +8058,27 @@
}
}
},
"ToolResultPayload": {
"type": "object",
"required": [
"tool_call_id",
"result"
],
"properties": {
"is_error": {
"type": "boolean",
"description": "Whether the tool execution was an error"
},
"result": {
"type": "string",
"description": "The result content from the client-side tool execution"
},
"tool_call_id": {
"type": "string",
"description": "The tool_call_id that this result corresponds to"
}
}
},
"TranscribeRequest": {
"type": "object",
"required": [
File diff suppressed because one or more lines are too long
+16
View File
@@ -69,6 +69,7 @@ export type ChatRequest = {
recipe_name?: string | null;
recipe_version?: string | null;
session_id: string;
tool_result?: ToolResultPayload | null;
user_message: Message;
};
@@ -1430,6 +1431,21 @@ export type ToolResponse = {
};
};
export type ToolResultPayload = {
/**
* Whether the tool execution was an error
*/
is_error?: boolean;
/**
* The result content from the client-side tool execution
*/
result: string;
/**
* The tool_call_id that this result corresponds to
*/
tool_call_id: string;
};
export type TranscribeRequest = {
/**
* Base64 encoded audio data
+4
View File
@@ -40,6 +40,7 @@ import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal
import { toastSuccess } from '../toasts';
import { Recipe } from '../recipe';
import { useAutoSubmit } from '../hooks/useAutoSubmit';
import { useForwardedToolHandler } from '../hooks/useForwardedToolHandler';
import { Goose } from './icons';
import EnvironmentBadge from './GooseSidebar/EnvironmentBadge';
@@ -94,6 +95,7 @@ export default function BaseChat({
setChatState,
handleSubmit,
submitElicitationResponse,
submitToolResult,
stopStreaming,
sessionLoadError,
setRecipeUserParams,
@@ -116,6 +118,8 @@ export default function BaseChat({
handleSubmit,
});
useForwardedToolHandler(messages, chatState, submitToolResult);
useEffect(() => {
let streamState: 'idle' | 'loading' | 'streaming' | 'error' = 'idle';
if (chatState === ChatState.LoadingConversation) {
@@ -0,0 +1,182 @@
import { createContext, useContext, useRef, useCallback, useState } from 'react';
interface BrowserState {
isOpen: boolean;
url: string;
}
interface BrowserContextType {
state: BrowserState;
webviewRef: React.RefObject<Electron.WebviewTag | null>;
openUrl: (url: string) => void;
close: () => void;
navigate: (url: string) => void;
executeCommand: (command: string, params: Record<string, unknown>) => Promise<{ success: boolean; data?: unknown }>;
}
const BrowserContext = createContext<BrowserContextType | null>(null);
export function useBrowser() {
const ctx = useContext(BrowserContext);
if (!ctx) {
throw new Error('useBrowser must be used within a BrowserProvider');
}
return ctx;
}
export function useBrowserOptional() {
return useContext(BrowserContext);
}
export function BrowserProvider({ children }: { children: React.ReactNode }) {
const webviewRef = useRef<Electron.WebviewTag | null>(null);
const [state, setState] = useState<BrowserState>({ isOpen: false, url: '' });
const openUrl = useCallback((url: string) => {
setState({ isOpen: true, url });
}, []);
const close = useCallback(() => {
setState({ isOpen: false, url: '' });
}, []);
const navigate = useCallback((url: string) => {
const wv = webviewRef.current;
if (wv) {
wv.loadURL(url).catch((err: Error) => {
// ERR_ABORTED happens on redirects — not a real error
if (err.message?.includes('ERR_ABORTED')) return;
console.error('Navigation failed:', err);
});
setState((prev) => ({ ...prev, url }));
}
}, []);
const executeCommand = useCallback(
async (command: string, params: Record<string, unknown>): Promise<{ success: boolean; data?: unknown }> => {
if (command === 'open') {
const url = params.url as string;
openUrl(url);
return { success: true };
}
if (command === 'close') {
close();
return { success: true };
}
if (command === 'navigate') {
const url = params.url as string;
navigate(url);
return { success: true };
}
const wv = webviewRef.current;
if (!wv) {
return { success: false, data: 'Browser not open' };
}
switch (command) {
case 'screenshot': {
// webview.capturePage returns a NativeImage
const image = await wv.capturePage();
return { success: true, data: image.toDataURL() };
}
case 'click': {
const selector = params.selector as string;
await wv.executeJavaScript(`
(function() {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) throw new Error('Element not found: ${selector}');
el.click();
return true;
})()
`);
return { success: true };
}
case 'type': {
const selector = params.selector as string;
const text = params.text as string;
await wv.executeJavaScript(`
(function() {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) throw new Error('Element not found: ${selector}');
el.focus();
el.value = ${JSON.stringify(text)};
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()
`);
return { success: true };
}
case 'get_text': {
const selector = params.selector as string;
const text = await wv.executeJavaScript(`
(function() {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) throw new Error('Element not found: ${selector}');
return el.innerText || el.textContent || '';
})()
`);
return { success: true, data: text };
}
case 'get_html': {
const selector = params.selector as string;
const html = await wv.executeJavaScript(`
(function() {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) throw new Error('Element not found: ${selector}');
return el.innerHTML;
})()
`);
return { success: true, data: html };
}
case 'evaluate': {
const script = params.script as string;
const result = await wv.executeJavaScript(script);
return { success: true, data: result };
}
case 'wait': {
const selector = params.selector as string;
const timeout = (params.timeout as number) || 5000;
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const found = await wv.executeJavaScript(
`!!document.querySelector(${JSON.stringify(selector)})`
);
if (found) {
return { success: true };
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
return { success: false, data: `Timeout waiting for element: ${selector}` };
}
case 'scroll': {
const x = (params.x as number) || 0;
const y = (params.y as number) || 0;
await wv.executeJavaScript(`window.scrollBy(${x}, ${y})`);
return { success: true };
}
default:
return { success: false, data: `Unknown command: ${command}` };
}
},
[openUrl, close, navigate]
);
return (
<BrowserContext.Provider value={{ state, webviewRef, openUrl, close, navigate, executeCommand }}>
{children}
</BrowserContext.Provider>
);
}
+155
View File
@@ -0,0 +1,155 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useBrowser } from './BrowserContext';
export default function BrowserPanel() {
const { state, webviewRef, close } = useBrowser();
const containerRef = useRef<HTMLDivElement>(null);
const [displayUrl, setDisplayUrl] = useState(state.url);
const [isLoading, setIsLoading] = useState(false);
const [panelWidth, setPanelWidth] = useState(500);
const isDragging = useRef(false);
useEffect(() => {
setDisplayUrl(state.url);
}, [state.url]);
// Attach webview event listeners once the element mounts
useEffect(() => {
if (!state.isOpen) return;
const container = containerRef.current;
if (!container) return;
// The webview element is rendered declaratively but we need to grab it
const wv = container.querySelector('webview') as Electron.WebviewTag | null;
if (!wv) return;
webviewRef.current = wv;
const onLoadStart = () => setIsLoading(true);
const onLoadStop = () => setIsLoading(false);
const onNavigate = (e: Electron.DidNavigateEvent) => setDisplayUrl(e.url);
const onFailLoad = (e: Electron.DidFailLoadEvent) => {
// ERR_ABORTED (-3) happens on redirects — not a real error
if (e.errorCode === -3) return;
console.error(`Webview load failed: ${e.errorDescription} (${e.errorCode})`);
setIsLoading(false);
};
wv.addEventListener('did-start-loading', onLoadStart);
wv.addEventListener('did-stop-loading', onLoadStop);
wv.addEventListener('did-navigate', onNavigate as EventListener);
wv.addEventListener('did-navigate-in-page', onNavigate as EventListener);
wv.addEventListener('did-fail-load', onFailLoad as EventListener);
return () => {
wv.removeEventListener('did-start-loading', onLoadStart);
wv.removeEventListener('did-stop-loading', onLoadStop);
wv.removeEventListener('did-navigate', onNavigate as EventListener);
wv.removeEventListener('did-navigate-in-page', onNavigate as EventListener);
wv.removeEventListener('did-fail-load', onFailLoad as EventListener);
if (webviewRef.current === wv) {
webviewRef.current = null;
}
};
}, [state.isOpen, state.url, webviewRef]);
// Resize drag handler
const onResizeMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
isDragging.current = true;
const startX = e.clientX;
const startWidth = panelWidth;
const onMouseMove = (moveEvent: MouseEvent) => {
if (!isDragging.current) return;
const delta = startX - moveEvent.clientX;
const newWidth = Math.max(300, Math.min(1200, startWidth + delta));
setPanelWidth(newWidth);
};
const onMouseUp = () => {
isDragging.current = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
},
[panelWidth]
);
const handleUrlSubmit = (e: React.FormEvent) => {
e.preventDefault();
const wv = webviewRef.current;
if (wv && displayUrl) {
let url = displayUrl;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
wv.loadURL(url).catch((err: Error) => {
if (err.message?.includes('ERR_ABORTED')) return;
console.error('URL load failed:', err);
});
}
};
if (!state.isOpen) return null;
return (
<div
ref={containerRef}
className="relative flex flex-col border-l border-border-subtle bg-surface-default"
style={{ width: panelWidth, minWidth: 300 }}
>
{/* Resize handle */}
<div
className="absolute left-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-accent-default/30 z-10"
onMouseDown={onResizeMouseDown}
/>
{/* Toolbar */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border-subtle bg-surface-raised">
<form onSubmit={handleUrlSubmit} className="flex-1 flex">
<input
type="text"
value={displayUrl}
onChange={(e) => setDisplayUrl(e.target.value)}
className="flex-1 text-sm px-2 py-1 rounded bg-surface-default border border-border-default text-text-default placeholder:text-text-muted focus:outline-none focus:border-accent-default"
placeholder="Enter URL..."
/>
</form>
{isLoading && (
<div className="w-4 h-4 border-2 border-accent-default border-t-transparent rounded-full animate-spin" />
)}
<button
onClick={close}
className="p-1 rounded hover:bg-surface-hover text-text-muted hover:text-text-default"
title="Close browser"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M4 4L12 12M12 4L4 12"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</div>
{/* Webview */}
<div className="flex-1">
{/* eslint-disable-next-line react/no-unknown-property */}
<webview
src={state.url}
style={{ width: '100%', height: '100%' }}
/* @ts-expect-error webview is an Electron-specific element */
allowpopups="true"
/>
</div>
</div>
);
}
@@ -1,5 +1,7 @@
import { useSearchParams } from 'react-router-dom';
import BaseChat from './BaseChat';
import BrowserPanel from './BrowserPanel';
import { BrowserProvider } from './BrowserContext';
import { ChatType } from '../types/chat';
import { UserInput } from '../types/message';
@@ -23,15 +25,12 @@ export default function ChatSessionsContainer({
const [searchParams] = useSearchParams();
const currentSessionId = searchParams.get('resumeSessionId') ?? undefined;
// Always render active sessions to keep SSE connections alive, even when not on /pair route
if (!currentSessionId && activeSessions.length === 0) {
return null;
}
// Build the list of sessions to render
let sessionsToRender = activeSessions;
// If we have a currentSessionId that's not in activeSessions, add it (handles page refresh)
if (currentSessionId && !activeSessions.some((s) => s.sessionId === currentSessionId)) {
sessionsToRender = [...activeSessions, { sessionId: currentSessionId }];
}
@@ -44,16 +43,21 @@ export default function ChatSessionsContainer({
return (
<div
key={session.sessionId}
className={`absolute inset-0 ${isVisible ? 'block' : 'hidden'}`}
className={`absolute inset-0 ${isVisible ? 'flex' : 'hidden'}`}
data-session-id={session.sessionId}
>
<BaseChat
setChat={setChat}
sessionId={session.sessionId}
initialMessage={session.initialMessage}
suppressEmptyState={false}
isActiveSession={isVisible}
/>
<BrowserProvider>
<div className="flex-1 min-w-0 relative">
<BaseChat
setChat={setChat}
sessionId={session.sessionId}
initialMessage={session.initialMessage}
suppressEmptyState={false}
isActiveSession={isVisible}
/>
</div>
<BrowserPanel />
</BrowserProvider>
</div>
);
})}
+46
View File
@@ -45,6 +45,7 @@ interface UseChatStreamReturn {
elicitationId: string,
userData: Record<string, unknown>
) => Promise<void>;
submitToolResult: (toolCallId: string, result: string, isError?: boolean) => Promise<void>;
setRecipeUserParams: (values: Record<string, string>) => Promise<void>;
stopStreaming: () => void;
sessionLoadError?: string;
@@ -648,6 +649,50 @@ export function useChatStream({
[sessionId, onFinish]
);
const submitToolResult = useCallback(
async (toolCallId: string, result: string, isError: boolean = false) => {
const currentState = stateRef.current;
if (!currentState.session || currentState.chatState === ChatState.LoadingConversation) {
return;
}
dispatch({ type: 'START_STREAMING' });
abortControllerRef.current = new AbortController();
try {
const { stream } = await reply({
body: {
session_id: sessionId,
user_message: createUserMessage(''),
tool_result: {
tool_call_id: toolCallId,
result,
is_error: isError,
},
},
throwOnError: true,
signal: abortControllerRef.current.signal,
});
await streamFromResponse(
stream,
currentState.messages,
dispatch,
onFinish,
sessionId
);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
// Silently handle abort
} else {
onFinish('Tool result submit error: ' + errorMessage(error));
}
}
},
[sessionId, onFinish]
);
const setRecipeUserParams = useCallback(
async (user_recipe_values: Record<string, string>) => {
const currentState = stateRef.current;
@@ -828,6 +873,7 @@ export function useChatStream({
setChatState,
handleSubmit,
submitElicitationResponse,
submitToolResult,
stopStreaming,
setRecipeUserParams,
tokenState: state.tokenState,
@@ -0,0 +1,83 @@
import { useEffect, useRef } from 'react';
import { Message } from '../api';
import { ChatState } from '../types/chatState';
import { getToolRequests } from '../types/message';
import { useBrowserOptional } from '../components/BrowserContext';
function toolNameToCommand(toolName: string): string | null {
const mapping: Record<string, string> = {
browser_open: 'open',
browser_close: 'close',
browser_navigate: 'navigate',
browser_screenshot: 'screenshot',
browser_click: 'click',
browser_type: 'type',
browser_get_text: 'get_text',
browser_get_html: 'get_html',
browser_evaluate: 'evaluate',
browser_wait: 'wait',
browser_scroll: 'scroll',
};
return mapping[toolName] ?? null;
}
/**
* Watches for forwarded tool requests (tool calls the agent delegated to the client).
* When the stream finishes and the last assistant message contains a tool request
* with _meta.forward_to_client, this hook executes it on the browser webview
* and submits the result back via submitToolResult.
*/
export function useForwardedToolHandler(
messages: Message[],
chatState: ChatState,
submitToolResult: (toolCallId: string, result: string, isError?: boolean) => Promise<void>
) {
const browser = useBrowserOptional();
const handledRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (chatState !== ChatState.Idle) return;
if (!browser) return;
const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
if (!lastAssistant) return;
const toolRequests = getToolRequests(lastAssistant);
if (!toolRequests.length) return;
for (const toolRequest of toolRequests) {
const meta = toolRequest._meta as Record<string, unknown> | undefined;
if (!meta?.forward_to_client) continue;
const toolCallId = toolRequest.id;
if (handledRef.current.has(toolCallId)) continue;
handledRef.current.add(toolCallId);
const toolName = toolRequest.toolCall?.name as string | undefined;
const args = toolRequest.toolCall?.arguments as Record<string, unknown> | undefined;
const command = toolName ? toolNameToCommand(toolName) : null;
if (!command || !args) {
submitToolResult(
toolCallId,
JSON.stringify({ error: `Unknown forwarded tool: ${toolName}` }),
true
);
continue;
}
(async () => {
try {
const result = await browser.executeCommand(command, args);
await submitToolResult(toolCallId, JSON.stringify(result));
} catch (err) {
await submitToolResult(
toolCallId,
JSON.stringify({ error: String(err) }),
true
);
}
})();
}
}, [chatState, messages, browser, submitToolResult]);
}
+2 -1
View File
@@ -521,7 +521,6 @@ let appConfig = {
const windowMap = new Map<number, BrowserWindow>();
const goosedClients = new Map<number, Client>();
const appWindows = new Map<string, BrowserWindow>();
const windowPowerSaveBlockers = new Map<number, number>(); // windowId -> blockerId
// Track pending initial messages per window
const pendingInitialMessages = new Map<number, string>(); // windowId -> initialMessage
@@ -598,6 +597,7 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
webSecurity: true,
nodeIntegration: false,
contextIsolation: true,
webviewTag: true,
additionalArguments: [
JSON.stringify({
...appConfig,
@@ -2436,6 +2436,7 @@ async function appMain() {
throw error;
}
});
}
app.whenReady().then(async () => {
+1
View File
@@ -170,6 +170,7 @@ type ElectronAPI = {
launchApp: (app: GooseApp) => Promise<void>;
refreshApp: (app: GooseApp) => Promise<void>;
closeApp: (appName: string) => Promise<void>;
addRecentDir: (dir: string) => Promise<boolean>;
};