diff --git a/CUSTOM_DISTROS.md b/CUSTOM_DISTROS.md index 1405a911..c9f08a22 100644 --- a/CUSTOM_DISTROS.md +++ b/CUSTOM_DISTROS.md @@ -46,7 +46,7 @@ goose's architecture is designed for extensibility. Organizations can create "re |---------------|---------------|------------| | Preconfigure a model/provider | `config.yaml`, `init-config.yaml`, environment variables | Low | | Add custom AI providers | `crates/goose/src/providers/declarative/` | Low | -| Bundle custom MCP extensions | `config.yaml` extensions section, `ui/desktop/src/built-in-extensions.json` | Medium | +| Bundle custom MCP extensions | `config.yaml` extensions section, `ui/desktop/src/built-in-extensions.json`, `ui/desktop/src/components/settings/extensions/bundled-extensions.json` | Medium | | Modify system prompts | `crates/goose/src/prompts/` | Low | | Customize desktop branding | `ui/desktop/` (icons, names, colors) | Medium | | Build a new UI (web, mobile) | Integrate with `goose-server` REST API | High | @@ -191,7 +191,11 @@ async def query_data_lake(query: str) -> str: return results ``` -2. **Bundle as a built-in extension** by adding to `ui/desktop/src/built-in-extensions.json`: +2. **Bundle as a built-in extension** by adding to either: + - `ui/desktop/src/built-in-extensions.json` (core built-ins surfaced in extension UI) + - `ui/desktop/src/components/settings/extensions/bundled-extensions.json` (bundled extension catalog in Settings) + +Example: ```json { @@ -268,6 +272,26 @@ You are an AI assistant called [YourName], created by [YourCompany]. - Component text and labels - Feature visibility +5. **Align packaging and updater names** when rebranding: + - Update static branding metadata in `ui/desktop/package.json` (`productName`, description) and Linux desktop templates (`ui/desktop/forge.deb.desktop`, `ui/desktop/forge.rpm.desktop`) + + - Set build/release environment variables consistently: + - `GITHUB_OWNER` and `GITHUB_REPO` for publisher + updater repository lookup + - `GOOSE_BUNDLE_NAME` for bundle/debug scripts and updater asset naming (defaults to `Goose`) + +Example: + +```bash +export GITHUB_OWNER="your-org" +export GITHUB_REPO="your-goose-fork" +export GOOSE_BUNDLE_NAME="InsightStream-goose" +``` + +6. **Use this branding consistency checklist** before release: + - Application metadata (`forge.config.ts`, `package.json`, `index.html`) uses your distro name + - Release artifact names and updater lookup names are consistent + - Desktop launchers (Linux `.desktop` templates) point to the same executable name produced by packaging + ### Technical Details - Electron config: `ui/desktop/forge.config.ts` diff --git a/README.md b/README.md index e5199fc1..f2a673bf 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Designed for maximum flexibility, goose works with any LLM and supports multi-mo - [Diagnostics & Reporting](https://block.github.io/goose/docs/troubleshooting/diagnostics-and-reporting) - [Known Issues](https://block.github.io/goose/docs/troubleshooting/known-issues) -# a little goose humor ðŸĶĒ +# a little goose humor ðŸŠŋ > Why did the developer choose goose as their AI agent? > diff --git a/crates/goose-acp/Cargo.toml b/crates/goose-acp/Cargo.toml index b0829fc0..25b9255c 100644 --- a/crates/goose-acp/Cargo.toml +++ b/crates/goose-acp/Cargo.toml @@ -23,7 +23,7 @@ goose = { path = "../goose" } goose-mcp = { path = "../goose-mcp" } rmcp = { workspace = true } sacp = "10.1.0" -agent-client-protocol-schema = { version = "0.10", features = ["unstable_session_model"] } +agent-client-protocol-schema = { version = "0.10", features = ["unstable_session_model", "unstable_session_list"] } anyhow = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true, features = ["compat", "rt"] } diff --git a/crates/goose-acp/src/custom_requests.rs b/crates/goose-acp/src/custom_requests.rs index 04025a43..816d7bac 100644 --- a/crates/goose-acp/src/custom_requests.rs +++ b/crates/goose-acp/src/custom_requests.rs @@ -82,12 +82,6 @@ pub struct GetSessionResponse { pub session: serde_json::Value, } -/// List all sessions. -#[derive(Debug, Serialize, JsonSchema)] -pub struct ListSessionsResponse { - pub sessions: Vec, -} - /// Delete a session. #[derive(Debug, Deserialize, JsonSchema)] pub struct DeleteSessionRequest { diff --git a/crates/goose-acp/src/server.rs b/crates/goose-acp/src/server.rs index 5b7fdf9f..9ebd0aba 100644 --- a/crates/goose-acp/src/server.rs +++ b/crates/goose-acp/src/server.rs @@ -24,10 +24,11 @@ use sacp::schema::{ AgentCapabilities, AuthMethod, AuthenticateRequest, AuthenticateResponse, BlobResourceContents, CancelNotification, Content, ContentBlock, ContentChunk, EmbeddedResource, EmbeddedResourceResource, ImageContent, InitializeRequest, InitializeResponse, - LoadSessionRequest, LoadSessionResponse, McpCapabilities, McpServer, ModelId, ModelInfo, - NewSessionRequest, NewSessionResponse, PermissionOption, PermissionOptionKind, - PromptCapabilities, PromptRequest, PromptResponse, RequestPermissionOutcome, - RequestPermissionRequest, ResourceLink, SessionId, SessionModelState, SessionNotification, + ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, McpCapabilities, McpServer, + ModelId, ModelInfo, NewSessionRequest, NewSessionResponse, PermissionOption, + PermissionOptionKind, PromptCapabilities, PromptRequest, PromptResponse, + RequestPermissionOutcome, RequestPermissionRequest, ResourceLink, SessionCapabilities, + SessionId, SessionInfo, SessionListCapabilities, SessionModelState, SessionNotification, SessionUpdate, SetSessionModelRequest, SetSessionModelResponse, StopReason, TextContent, TextResourceContents, ToolCall, ToolCallContent, ToolCallId, ToolCallLocation, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, @@ -664,6 +665,7 @@ impl GooseAcpAgent { let capabilities = AgentCapabilities::new() .load_session(true) + .session_capabilities(SessionCapabilities::new().list(SessionListCapabilities::new())) .prompt_capabilities( PromptCapabilities::new() .image(true) @@ -1097,14 +1099,15 @@ impl GooseAcpAgent { .list_sessions() .await .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; - let sessions_json = sessions + let session_infos: Vec = sessions .into_iter() - .map(|s| serde_json::to_value(&s)) - .collect::, _>>() - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; - Ok(ListSessionsResponse { - sessions: sessions_json, - }) + .map(|s| { + SessionInfo::new(SessionId::new(s.id), s.working_dir) + .title(s.name) + .updated_at(s.updated_at.to_rfc3339()) + }) + .collect(); + Ok(ListSessionsResponse::new(session_infos)) } #[custom_method("session/get")] @@ -1286,6 +1289,14 @@ impl JrMessageHandler for GooseAcpHandler { request_cx.respond(json)?; Ok(()) } + MessageCx::Request(req, request_cx) if req.method == "session/list" => { + let resp = agent.on_list_sessions().await?; + let json = serde_json::to_value(resp).map_err(|e| { + sacp::Error::internal_error().data(e.to_string()) + })?; + request_cx.respond(json)?; + Ok(()) + } MessageCx::Request(req, request_cx) if req.method.starts_with('_') => { match agent.handle_custom_request(&req.method, req.params).await { Ok(json) => request_cx.respond(json)?, diff --git a/crates/goose-server/src/routes/dictation.rs b/crates/goose-server/src/routes/dictation.rs index 8530d2b1..b4aaf8b7 100644 --- a/crates/goose-server/src/routes/dictation.rs +++ b/crates/goose-server/src/routes/dictation.rs @@ -127,7 +127,7 @@ fn convert_error(e: anyhow::Error) -> ErrorResponse { (status = 400, description = "Invalid request (bad base64 or unsupported format)"), (status = 401, description = "Invalid API key"), (status = 412, description = "Provider not configured"), - (status = 413, description = "Audio file too large (max 25MB)"), + (status = 413, description = "Audio file too large (max 50MB)"), (status = 429, description = "Rate limit exceeded"), (status = 500, description = "Internal server error"), (status = 502, description = "Provider API error"), diff --git a/crates/goose/src/context_mgmt/mod.rs b/crates/goose/src/context_mgmt/mod.rs index d8bd486e..0f278bac 100644 --- a/crates/goose/src/context_mgmt/mod.rs +++ b/crates/goose/src/context_mgmt/mod.rs @@ -18,6 +18,11 @@ use tracing::log::warn; pub const DEFAULT_COMPACTION_THRESHOLD: f64 = 0.8; +/// Feature flag to enable/disable tool pair summarization. +/// Set to `false` to disable summarizing old tool call/response pairs. +/// TODO: Re-enable once tool summarization stability issues are resolved. +const ENABLE_TOOL_PAIR_SUMMARIZATION: bool = false; + const CONVERSATION_CONTINUATION_TEXT: &str = "Your context was compacted. The previous message contains a summary of the conversation so far. Do not mention that you read a summary or that conversation summarization occurred. @@ -509,6 +514,12 @@ pub fn maybe_summarize_tool_pair( cutoff: usize, ) -> JoinHandle> { tokio::spawn(async move { + // Tool pair summarization is currently disabled via feature flag. + // See ENABLE_TOOL_PAIR_SUMMARIZATION constant above. + if !ENABLE_TOOL_PAIR_SUMMARIZATION { + return None; + } + if let Some(tool_id) = tool_id_to_summarize(&conversation, cutoff) { match summarize_tool_call(provider.as_ref(), &session_id, &conversation, &tool_id).await { diff --git a/crates/goose/src/providers/formats/openai_responses.rs b/crates/goose/src/providers/formats/openai_responses.rs index 82aab125..4ecee178 100644 --- a/crates/goose/src/providers/formats/openai_responses.rs +++ b/crates/goose/src/providers/formats/openai_responses.rs @@ -277,59 +277,37 @@ pub enum ContentPart { }, } -fn add_conversation_history(input_items: &mut Vec, messages: &[Message]) { +fn add_message_items(input_items: &mut Vec, messages: &[Message]) { for message in messages.iter().filter(|m| m.is_agent_visible()) { - let has_only_tool_content = message.content.iter().all(|c| { - matches!( - c, - MessageContent::ToolRequest(_) | MessageContent::ToolResponse(_) - ) - }); - - if has_only_tool_content { - continue; - } - - if message.role != Role::User && message.role != Role::Assistant { - continue; - } - let role = match message.role { Role::User => "user", Role::Assistant => "assistant", }; - let mut content_items = Vec::new(); + let mut text_items = Vec::new(); + for content in &message.content { - if let MessageContent::Text(text) = content { - if !text.text.is_empty() { + match content { + MessageContent::Text(text) if !text.text.is_empty() => { let content_type = if message.role == Role::Assistant { "output_text" } else { "input_text" }; - content_items.push(json!({ + text_items.push(json!({ "type": content_type, "text": text.text })); } - } - } + MessageContent::ToolRequest(request) if message.role == Role::Assistant => { + if !text_items.is_empty() { + input_items.push(json!({ + "role": role, + "content": text_items + })); + text_items = Vec::new(); + } - if !content_items.is_empty() { - input_items.push(json!({ - "role": role, - "content": content_items - })); - } - } -} - -fn add_function_calls(input_items: &mut Vec, messages: &[Message]) { - for message in messages.iter().filter(|m| m.is_agent_visible()) { - if message.role == Role::Assistant { - for content in &message.content { - if let MessageContent::ToolRequest(request) = content { if let Ok(tool_call) = &request.tool_call { let arguments_str = tool_call .arguments @@ -352,57 +330,64 @@ fn add_function_calls(input_items: &mut Vec, messages: &[Message]) { })); } } - } - } - } -} + MessageContent::ToolResponse(response) => { + if !text_items.is_empty() { + input_items.push(json!({ + "role": role, + "content": text_items + })); + text_items = Vec::new(); + } -fn add_function_call_outputs(input_items: &mut Vec, messages: &[Message]) { - for message in messages { - for content in &message.content { - if let MessageContent::ToolResponse(response) = content { - match &response.tool_result { - Ok(contents) => { - let text_content: Vec = contents - .content - .iter() - .filter_map(|c| { - if let RawContent::Text(t) = c.deref() { - Some(t.text.clone()) - } else { - None - } - }) - .collect(); + match &response.tool_result { + Ok(contents) => { + let text_content: Vec = contents + .content + .iter() + .filter_map(|c| { + if let RawContent::Text(t) = c.deref() { + Some(t.text.clone()) + } else { + None + } + }) + .collect(); - if !text_content.is_empty() { + if !text_content.is_empty() { + tracing::debug!( + "Sending function_call_output with call_id: {}", + response.id + ); + input_items.push(json!({ + "type": "function_call_output", + "call_id": response.id, + "output": text_content.join("\n") + })); + } + } + Err(error_data) => { tracing::debug!( - "Sending function_call_output with call_id: {}", + "Sending function_call_output error with call_id: {}", response.id ); input_items.push(json!({ "type": "function_call_output", "call_id": response.id, - "output": text_content.join("\n") + "output": format!("Error: {}", error_data.message) })); } } - Err(error_data) => { - // Handle error responses - must send them back to the API - // to avoid "No tool output found" errors - tracing::debug!( - "Sending function_call_output error with call_id: {}", - response.id - ); - input_items.push(json!({ - "type": "function_call_output", - "call_id": response.id, - "output": format!("Error: {}", error_data.message) - })); - } } + _ => {} } } + + if !text_items.is_empty() { + input_items.push(json!({ + "role": role, + "content": text_items + })); + } } } @@ -424,9 +409,7 @@ pub fn create_responses_request( })); } - add_conversation_history(&mut input_items, messages); - add_function_calls(&mut input_items, messages); - add_function_call_outputs(&mut input_items, messages); + add_message_items(&mut input_items, messages); let mut payload = json!({ "model": model_config.model_name, @@ -761,7 +744,10 @@ where mod tests { use super::*; use crate::conversation::message::MessageContent; + use crate::model::ModelConfig; use futures::StreamExt; + use rmcp::model::CallToolRequestParams; + use rmcp::object; #[tokio::test] async fn test_responses_stream_ignores_keepalive_event() -> anyhow::Result<()> { @@ -829,4 +815,60 @@ mod tests { Ok(()) } + + #[test] + fn test_history_preserves_chronological_order() { + let model_config = ModelConfig { + model_name: "gpt-5.2-codex".to_string(), + context_limit: None, + temperature: None, + max_tokens: None, + toolshim: false, + toolshim_model: None, + fast_model_config: None, + request_params: None, + }; + + let messages = vec![ + Message::assistant() + .with_text("I'll create that file.") + .with_tool_request( + "call_1", + Ok(CallToolRequestParams { + meta: None, + task: None, + name: "shell".into(), + arguments: Some(object!({"command": "echo hello"})), + }), + ), + Message::assistant() + .with_text("Now let me verify.") + .with_tool_request( + "call_2", + Ok(CallToolRequestParams { + meta: None, + task: None, + name: "shell".into(), + arguments: Some(object!({"command": "cat file.txt"})), + }), + ), + ]; + + let result = create_responses_request(&model_config, "", &messages, &[]).unwrap(); + let input = result["input"].as_array().unwrap(); + + let types: Vec<&str> = input + .iter() + .map(|item| { + item.get("type") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| item["role"].as_str().unwrap()) + }) + .collect(); + + assert_eq!( + types, + vec!["assistant", "function_call", "assistant", "function_call"] + ); + } } diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 192fa796..972534ee 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -780,9 +780,17 @@ async fn execute_job( let prompt_text = recipe .prompt - .as_ref() - .or(recipe.instructions.as_ref()) - .unwrap(); + .as_deref() + .filter(|s| !s.trim().is_empty()) + .or_else(|| { + recipe + .instructions + .as_deref() + .filter(|s| !s.trim().is_empty()) + }) + .ok_or_else(|| { + anyhow!("Recipe must specify at least one of `instructions` or `prompt`.") + })?; let user_message = Message::user().with_text(prompt_text); let mut conversation = Conversation::new_unvalidated(vec![user_message.clone()]); @@ -985,4 +993,41 @@ mod tests { let jobs = scheduler.list_scheduled_jobs().await; assert!(jobs[0].last_run.is_none(), "Paused job should not run"); } + + #[tokio::test] + async fn test_job_with_no_prompt_does_not_panic() { + let temp_dir = tempdir().unwrap(); + let recipe_path = temp_dir.path().join("no_prompt.yaml"); + fs::write( + &recipe_path, + "title: missing\ndescription: no prompt or instructions\n", + ) + .unwrap(); + + let storage_path = temp_dir.path().join("schedule.json"); + let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf())); + let scheduler = Scheduler::new(storage_path, session_manager).await.unwrap(); + + let job = ScheduledJob { + id: "no_prompt_job".to_string(), + source: recipe_path.to_string_lossy().to_string(), + cron: "* * * * * *".to_string(), + last_run: None, + currently_running: false, + paused: false, + current_session_id: None, + process_start_time: None, + }; + + // Schedule the job and let it run — should not panic + scheduler.add_scheduled_job(job, true).await.unwrap(); + sleep(Duration::from_millis(1500)).await; + + // The job should have attempted to run (last_run set) but not crashed the scheduler + let jobs = scheduler.list_scheduled_jobs().await; + assert!( + jobs[0].last_run.is_some(), + "Job should have attempted to run without panicking" + ); + } } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 832bd713..07924db2 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1839,7 +1839,7 @@ "description": "Provider not configured" }, "413": { - "description": "Audio file too large (max 25MB)" + "description": "Audio file too large (max 50MB)" }, "429": { "description": "Rate limit exceeded" diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 8364dccf..6253c7be 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -17,10 +17,10 @@ "start:test-error": "GOOSE_TEST_ERROR=true electron-forge start", "package": "electron-forge package", "make": "electron-forge make", - "bundle:default": "node scripts/prepare-platform-binaries.js && npm run make && (cd out/Goose-darwin-arm64 && ditto -c -k --sequesterRsrc --keepParent Goose.app Goose.zip) || echo 'out/Goose-darwin-arm64 not found; either the binary is not built or you are not on macOS'", - "bundle:alpha": "ALPHA=true node scripts/prepare-platform-binaries.js && ALPHA=true npm run make && (cd out/Goose-darwin-arm64 && ditto -c -k --sequesterRsrc --keepParent Goose.app Goose_alpha.zip) || echo 'out/Goose-darwin-arm64 not found; either the binary is not built or you are not on macOS'", - "bundle:intel": "node scripts/prepare-platform-binaries.js && npm run make -- --arch=x64 && cd out/Goose-darwin-x64 && ditto -c -k --sequesterRsrc --keepParent Goose.app Goose_intel_mac.zip", - "debug": "echo 'run --remote-debugging-port=8315' && lldb out/Goose-darwin-arm64/Goose.app", + "bundle:default": "node scripts/prepare-platform-binaries.js && npm run make && BUNDLE_NAME=\"${GOOSE_BUNDLE_NAME:-Goose}\" && APP_DIR=\"out/${BUNDLE_NAME}-darwin-arm64\" && APP_BUNDLE=\"${APP_DIR}/${BUNDLE_NAME}.app\" && (cd \"$APP_DIR\" && ditto -c -k --sequesterRsrc --keepParent \"${BUNDLE_NAME}.app\" \"${BUNDLE_NAME}.zip\") || echo \"${APP_BUNDLE} not found; either the binary is not built or you are not on macOS\"", + "bundle:alpha": "ALPHA=true node scripts/prepare-platform-binaries.js && ALPHA=true npm run make && BUNDLE_NAME=\"${GOOSE_BUNDLE_NAME:-Goose}\" && APP_DIR=\"out/${BUNDLE_NAME}-darwin-arm64\" && APP_BUNDLE=\"${APP_DIR}/${BUNDLE_NAME}.app\" && (cd \"$APP_DIR\" && ditto -c -k --sequesterRsrc --keepParent \"${BUNDLE_NAME}.app\" \"${BUNDLE_NAME}_alpha.zip\") || echo \"${APP_BUNDLE} not found; either the binary is not built or you are not on macOS\"", + "bundle:intel": "node scripts/prepare-platform-binaries.js && npm run make -- --arch=x64 && BUNDLE_NAME=\"${GOOSE_BUNDLE_NAME:-Goose}\" && APP_DIR=\"out/${BUNDLE_NAME}-darwin-x64\" && APP_BUNDLE=\"${APP_DIR}/${BUNDLE_NAME}.app\" && (cd \"$APP_DIR\" && ditto -c -k --sequesterRsrc --keepParent \"${BUNDLE_NAME}.app\" \"${BUNDLE_NAME}_intel_mac.zip\")", + "debug": "echo 'run --remote-debugging-port=8315' && BUNDLE_NAME=\"${GOOSE_BUNDLE_NAME:-Goose}\" && lldb \"out/${BUNDLE_NAME}-darwin-arm64/${BUNDLE_NAME}.app\"", "test-e2e": "npm run generate-api && playwright test", "test-e2e:dev": "npm run generate-api && playwright test --reporter=list --retries=0 --max-failures=1", "test-e2e:ui": "npm run generate-api && playwright test --ui", diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 93e16772..2ccd506d 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -2969,7 +2969,7 @@ export type TranscribeDictationErrors = { */ 412: unknown; /** - * Audio file too large (max 25MB) + * Audio file too large (max 50MB) */ 413: unknown; /** diff --git a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx index b53c380e..c2142df8 100644 --- a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx +++ b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx @@ -24,6 +24,7 @@ import type { McpUiSizeChangedNotification, } from '@modelcontextprotocol/ext-apps/app-bridge'; import type { CallToolResult, JSONRPCRequest } from '@modelcontextprotocol/sdk/types.js'; +import { GripHorizontal, Maximize2, PictureInPicture2, X } from 'lucide-react'; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; import { callTool, readResource } from '../../api'; import { AppEvents } from '../../constants/events'; @@ -40,14 +41,21 @@ import { McpAppToolInputPartial, McpAppToolResult, DimensionLayout, + OnDisplayModeChange, SamplingCreateMessageParams, SamplingCreateMessageResponse, } from './types'; +import { + useDisplayMode, + AVAILABLE_DISPLAY_MODES, + PIP_WIDTH, + PIP_HEIGHT, + PIP_MARGIN_RIGHT, + PIP_MARGIN_BOTTOM, +} from './useDisplayMode'; const DEFAULT_IFRAME_HEIGHT = 200; -const AVAILABLE_DISPLAY_MODES: McpUiDisplayMode[] = ['inline']; - const DISPLAY_MODE_LAYOUTS: Record = { inline: { width: 'fixed', height: 'unbounded' }, fullscreen: { width: 'fixed', height: 'fixed' }, @@ -139,6 +147,7 @@ interface McpAppRendererProps { append?: (text: string) => void; displayMode?: GooseDisplayMode; cachedHtml?: string; + onDisplayModeChange?: OnDisplayModeChange; } interface ResourceMeta { @@ -235,8 +244,27 @@ export default function McpAppRenderer({ append, displayMode = 'inline', cachedHtml, + onDisplayModeChange, }: McpAppRendererProps) { - const isExpandedView = displayMode === 'fullscreen' || displayMode === 'standalone'; + const containerRef = useRef(null); + + const dm = useDisplayMode({ displayMode, onDisplayModeChange, containerRef }); + const { + activeDisplayMode, + effectiveDisplayModes, + isStandalone, + isFullscreen, + isPip, + isFillsViewport, + isInline, + appSupportsFullscreen, + appSupportsPip, + changeDisplayMode, + inlineHeight, + pipPosition, + pipHandlers, + fullscreenCloseRef, + } = dm; const { resolvedTheme, mcpHostStyles } = useTheme(); @@ -264,7 +292,18 @@ export default function McpAppRenderer({ }); const [iframeHeight, setIframeHeight] = useState(DEFAULT_IFRAME_HEIGHT); - const containerRef = useRef(null); + // Restore iframeHeight from the saved snapshot when returning to inline. + // While in fullscreen/pip, handleSizeChanged ignores size notifications, so + // iframeHeight may be stale. This ensures the container starts at the correct + // height the moment the mode flips back to inline. + useEffect(() => { + if (isInline) { + setIframeHeight(inlineHeight); + } + }, [isInline, inlineHeight]); + + const effectiveInlineHeight = iframeHeight || DEFAULT_IFRAME_HEIGHT; + const [containerWidth, setContainerWidth] = useState(0); const [containerHeight, setContainerHeight] = useState(0); const [apiHost, setApiHost] = useState(null); @@ -512,11 +551,14 @@ export default function McpAppRenderer({ [] ); - const handleSizeChanged = useCallback(({ height }: McpUiSizeChangedNotification['params']) => { - if (height !== undefined && height > 0) { - setIframeHeight(height); - } - }, []); + const handleSizeChanged = useCallback( + ({ height }: McpUiSizeChangedNotification['params']) => { + if (height !== undefined && height > 0 && isInline) { + setIframeHeight(height); + } + }, + [isInline] + ); // Track the container's pixel dimensions so we can report them to apps via containerDimensions. useEffect(() => { @@ -605,12 +647,17 @@ export default function McpAppRenderer({ // todo: toolInfo: {} theme: resolvedTheme, styles: mcpHostStyles, - // 'standalone' is a Goose-specific display mode (dedicated Electron window) - // that maps to the spec's inline | fullscreen | pip modes. - displayMode: displayMode as McpUiDisplayMode, - availableDisplayModes: - displayMode === 'standalone' ? [displayMode as McpUiDisplayMode] : AVAILABLE_DISPLAY_MODES, - containerDimensions: getContainerDimensions(displayMode, containerWidth, containerHeight), + displayMode: activeDisplayMode as McpUiDisplayMode, + availableDisplayModes: isStandalone + ? [activeDisplayMode as McpUiDisplayMode] + : effectiveDisplayModes.length > 0 + ? effectiveDisplayModes + : AVAILABLE_DISPLAY_MODES, + containerDimensions: getContainerDimensions( + activeDisplayMode, + containerWidth, + containerHeight + ), locale: navigator.language, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, userAgent: navigator.userAgent, @@ -628,7 +675,15 @@ export default function McpAppRenderer({ }; return context; - }, [resolvedTheme, mcpHostStyles, displayMode, containerWidth, containerHeight]); + }, [ + resolvedTheme, + mcpHostStyles, + activeDisplayMode, + isStandalone, + containerWidth, + containerHeight, + effectiveDisplayModes, + ]); const appToolResult = useMemo((): CallToolResult | undefined => { if (!toolResult) return undefined; @@ -694,23 +749,171 @@ export default function McpAppRenderer({ ); }; + const showControls = !isStandalone && !isError && (appSupportsFullscreen || appSupportsPip); + + const renderDisplayModeControls = () => { + if (!showControls) return null; + + if (activeDisplayMode === 'fullscreen') { + return ( +
+ {appSupportsPip && ( + + )} + +
+ ); + } + + if (activeDisplayMode === 'pip') { + return ( + <> + {appSupportsFullscreen && ( + + )} + + + ); + } + + // Inline mode — show controls on hover or keyboard focus + return ( +
+ {appSupportsFullscreen && ( + + )} + {appSupportsPip && ( + + )} +
+ ); + }; + + // Single stable container — CSS switches between inline/fullscreen/pip positioning. + // The AppRenderer and its iframe are never unmounted, preserving app state across mode changes. const containerClasses = cn( - 'bg-background-primary overflow-hidden [&_iframe]:!w-full', - isError && 'border border-red-500 rounded-lg bg-red-50 dark:bg-red-900/20', - !isError && !isExpandedView && 'mt-6 mb-2', - !isError && !isExpandedView && meta.prefersBorder && 'border border-border-primary rounded-lg' + 'mcp-app-container bg-background-primary [&_iframe]:!w-full', + isFillsViewport && 'fixed inset-0 z-[1000] overflow-hidden [&_iframe]:!h-full', + isPip && + 'fixed z-[900] overflow-y-auto overflow-x-hidden rounded-xl border border-border-primary shadow-2xl', + isInline && 'group/mcp-app relative overflow-hidden', + isInline && !isError && 'mt-6 mb-2', + isInline && !isError && meta.prefersBorder && 'border border-border-primary rounded-lg', + isError && 'border border-red-500 rounded-lg bg-red-50 dark:bg-red-900/20' ); - const containerStyle = isExpandedView - ? { width: '100%', height: '100%' } - : { - width: '100%', - height: `${iframeHeight || DEFAULT_IFRAME_HEIGHT}px`, - }; + const containerStyle: React.CSSProperties = { + ...(isFillsViewport + ? {} + : isPip + ? { + width: `${PIP_WIDTH}px`, + height: `${PIP_HEIGHT}px`, + right: `${PIP_MARGIN_RIGHT - pipPosition.x}px`, + bottom: `${PIP_MARGIN_BOTTOM - pipPosition.y}px`, + } + : { + width: '100%', + height: `${effectiveInlineHeight}px`, + }), + }; return ( -
- {renderContent()} -
+ <> + {/* Placeholder in chat flow when app is detached (fullscreen or pip) */} + {isFullscreen && ( +
+ )} + {isPip && ( +
+ +
+ )} + + {/* Stable app container — never unmounted, only repositioned via CSS */} +
+ {isPip && ( +
+
+ +
+
{renderDisplayModeControls()}
+
+ )} +
+ {!isPip && renderDisplayModeControls()} + {renderContent()} +
+
+ ); } diff --git a/ui/desktop/src/components/McpApps/types.ts b/ui/desktop/src/components/McpApps/types.ts index c2efed80..144f2576 100644 --- a/ui/desktop/src/components/McpApps/types.ts +++ b/ui/desktop/src/components/McpApps/types.ts @@ -43,6 +43,12 @@ export type McpAppToolResult = { _meta?: { [key: string]: unknown }; }; +/** + * Callback fired when the display mode changes, either via user-initiated + * host-side controls or app-initiated `ui/request-display-mode` changes. + */ +export type OnDisplayModeChange = (mode: GooseDisplayMode) => void; + export type SamplingMessage = { role: 'user' | 'assistant'; content: { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }; diff --git a/ui/desktop/src/components/McpApps/useDisplayMode.ts b/ui/desktop/src/components/McpApps/useDisplayMode.ts new file mode 100644 index 00000000..9f644ffd --- /dev/null +++ b/ui/desktop/src/components/McpApps/useDisplayMode.ts @@ -0,0 +1,338 @@ +/** + * useDisplayMode — Manages display mode state for MCP App containers. + * + * Encapsulates the display mode state machine, capability negotiation, + * PiP drag handling, entrance animations, and postMessage interception + * for ui/initialize and ui/request-display-mode. + */ + +import type { McpUiDisplayMode } from '@modelcontextprotocol/ext-apps/app-bridge'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { GooseDisplayMode, OnDisplayModeChange } from './types'; + +const DEFAULT_IFRAME_HEIGHT = 200; + +const AVAILABLE_DISPLAY_MODES: McpUiDisplayMode[] = ['inline', 'fullscreen', 'pip']; + +const PIP_WIDTH = 400; +const PIP_HEIGHT = 300; +const PIP_MARGIN_RIGHT = 16; +// Keeps the PiP window above the chat input area (~120px) plus padding. +const PIP_MARGIN_BOTTOM = 140; + +interface UseDisplayModeOptions { + displayMode: GooseDisplayMode; + onDisplayModeChange?: OnDisplayModeChange; + containerRef: React.RefObject; +} + +export interface DisplayModeState { + activeDisplayMode: GooseDisplayMode; + effectiveDisplayModes: McpUiDisplayMode[]; + isStandalone: boolean; + isFullscreen: boolean; + isPip: boolean; + isFillsViewport: boolean; + isInline: boolean; + appSupportsFullscreen: boolean; + appSupportsPip: boolean; + + changeDisplayMode: (mode: GooseDisplayMode) => void; + + /** Remembered inline height for placeholders when detached. */ + inlineHeight: number; + + /** PiP position offset from the default bottom-right corner. */ + pipPosition: { x: number; y: number }; + + /** PiP drag handle event handlers. */ + pipHandlers: { + onPointerDown: (e: React.PointerEvent) => void; + onPointerMove: (e: React.PointerEvent) => void; + onPointerUp: (e: React.PointerEvent) => void; + onLostPointerCapture: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + }; + + /** Ref for the fullscreen close button (auto-focused on enter). */ + fullscreenCloseRef: React.RefObject; +} + +export { AVAILABLE_DISPLAY_MODES, PIP_WIDTH, PIP_HEIGHT, PIP_MARGIN_RIGHT, PIP_MARGIN_BOTTOM }; + +export function useDisplayMode({ + displayMode, + onDisplayModeChange, + containerRef, +}: UseDisplayModeOptions): DisplayModeState { + const [activeDisplayMode, setActiveDisplayMode] = useState(displayMode); + + useEffect(() => { + setActiveDisplayMode(displayMode); + }, [displayMode]); + + const isStandalone = displayMode === 'standalone'; + + // Display modes the app declared support for during ui/initialize. + // null = not yet known (controls stay hidden until initialize), empty = app didn't declare any. + const [appDeclaredModes, setAppDeclaredModes] = useState(null); + + const effectiveDisplayModes = useMemo((): McpUiDisplayMode[] => { + if (!appDeclaredModes) return []; + return AVAILABLE_DISPLAY_MODES.filter((m) => appDeclaredModes.includes(m)); + }, [appDeclaredModes]); + + // Snapshot of the container height captured when leaving inline mode. + // Stored as state (not a ref) so consumers re-render with the correct value + // for placeholders and for restoring the inline container on return. + const [savedInlineHeight, setSavedInlineHeight] = useState(DEFAULT_IFRAME_HEIGHT); + + // Cache iframe contentWindows for O(1) message source matching. + // eslint-disable-next-line no-undef + const iframeWindowsRef = useRef>(new Set()); + + const enterAnimRef = useRef(null); + const fullscreenCloseRef = useRef(null); + + // ── Mode transitions ────────────────────────────────────────────────── + + const changeDisplayMode = useCallback( + (mode: GooseDisplayMode) => { + const el = containerRef.current; + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + if (activeDisplayMode === 'inline' && el) { + setSavedInlineHeight(el.getBoundingClientRect().height || DEFAULT_IFRAME_HEIGHT); + } + + if (enterAnimRef.current && el) { + el.classList.remove(enterAnimRef.current); + enterAnimRef.current = null; + } + + setActiveDisplayMode(mode); + onDisplayModeChange?.(mode); + + if (el && !prefersReducedMotion && mode !== activeDisplayMode) { + const animClass = + mode === 'pip' + ? 'mcp-enter-pip' + : mode === 'fullscreen' + ? 'mcp-enter-fullscreen' + : 'mcp-enter-inline'; + + requestAnimationFrame(() => { + el.classList.add(animClass); + enterAnimRef.current = animClass; + + el.addEventListener( + 'animationend', + () => { + el.classList.remove(animClass); + if (enterAnimRef.current === animClass) { + enterAnimRef.current = null; + } + }, + { once: true } + ); + }); + } + }, + [onDisplayModeChange, activeDisplayMode, containerRef] + ); + + // ── PiP drag ────────────────────────────────────────────────────────── + + const [pipPosition, setPipPosition] = useState({ x: 0, y: 0 }); + const pipPositionRef = useRef(pipPosition); + const pipDragRef = useRef<{ + startX: number; + startY: number; + originX: number; + originY: number; + } | null>(null); + + useEffect(() => { + pipPositionRef.current = pipPosition; + }, [pipPosition]); + + const clampPipPosition = useCallback((pos: { x: number; y: number }) => { + const minX = PIP_WIDTH + PIP_MARGIN_RIGHT - window.innerWidth; + const maxX = PIP_MARGIN_RIGHT; + const minY = PIP_HEIGHT + PIP_MARGIN_BOTTOM - window.innerHeight; + const maxY = PIP_MARGIN_BOTTOM; + return { + x: minX > maxX ? 0 : Math.max(minX, Math.min(maxX, pos.x)), + y: minY > maxY ? 0 : Math.max(minY, Math.min(maxY, pos.y)), + }; + }, []); + + const handlePipPointerDown = useCallback((e: React.PointerEvent) => { + e.preventDefault(); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + const { x, y } = pipPositionRef.current; + pipDragRef.current = { startX: e.clientX, startY: e.clientY, originX: x, originY: y }; + }, []); + + const handlePipPointerMove = useCallback( + (e: React.PointerEvent) => { + if (!pipDragRef.current) return; + const dx = e.clientX - pipDragRef.current.startX; + const dy = e.clientY - pipDragRef.current.startY; + setPipPosition( + clampPipPosition({ + x: pipDragRef.current.originX + dx, + y: pipDragRef.current.originY + dy, + }) + ); + }, + [clampPipPosition] + ); + + const handlePipPointerUp = useCallback((e: React.PointerEvent) => { + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + pipDragRef.current = null; + }, []); + + const handlePipLostPointerCapture = useCallback(() => { + pipDragRef.current = null; + }, []); + + const handlePipKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const step = e.shiftKey ? 32 : 8; + let dx = 0; + let dy = 0; + switch (e.key) { + case 'ArrowUp': + dy = -step; + break; + case 'ArrowDown': + dy = step; + break; + case 'ArrowLeft': + dx = -step; + break; + case 'ArrowRight': + dx = step; + break; + default: + return; + } + e.preventDefault(); + setPipPosition((prev) => clampPipPosition({ x: prev.x + dx, y: prev.y + dy })); + }, + [clampPipPosition] + ); + + // ── Effects ─────────────────────────────────────────────────────────── + + // Cache iframe contentWindows for O(1) source matching via MutationObserver. + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const refreshCache = () => { + const windows = iframeWindowsRef.current; + windows.clear(); + container.querySelectorAll('iframe').forEach((iframe) => { + if (iframe.contentWindow) windows.add(iframe.contentWindow); + }); + }; + + refreshCache(); + const observer = new MutationObserver(refreshCache); + observer.observe(container, { childList: true, subtree: true }); + return () => observer.disconnect(); + }, [containerRef]); + + // Intercept app postMessages for: + // 1. ui/initialize — extract appCapabilities.availableDisplayModes + // 2. ui/request-display-mode — change display mode on behalf of the app + useEffect(() => { + if (isStandalone) return; + + const handleMessage = (e: MessageEvent) => { + const data = e.data; + if (!data || typeof data !== 'object') return; + // eslint-disable-next-line no-undef + if (!e.source || !iframeWindowsRef.current.has(e.source as Window)) return; + + if (data.method === 'ui/initialize' && data.params) { + const caps = data.params.appCapabilities || data.params.capabilities; + if (caps?.availableDisplayModes && Array.isArray(caps.availableDisplayModes)) { + setAppDeclaredModes(caps.availableDisplayModes); + } + } + + // After initialize, only allow modes both host and app agree on. + // Before initialize (effectiveDisplayModes empty), fall back to the full host list. + if (data.method === 'ui/request-display-mode' && data.params?.mode) { + const requested = data.params.mode as McpUiDisplayMode; + const allowed = + effectiveDisplayModes.length > 0 ? effectiveDisplayModes : AVAILABLE_DISPLAY_MODES; + if (allowed.includes(requested)) { + changeDisplayMode(requested); + } + } + }; + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [isStandalone, changeDisplayMode, effectiveDisplayModes]); + + // Escape key exits fullscreen. + useEffect(() => { + if (activeDisplayMode !== 'fullscreen') return; + fullscreenCloseRef.current?.focus(); + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') changeDisplayMode('inline'); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [activeDisplayMode, changeDisplayMode]); + + // Reset PiP position when entering PiP mode. + useEffect(() => { + if (activeDisplayMode === 'pip') { + setPipPosition({ x: 0, y: 0 }); + } + }, [activeDisplayMode]); + + // ── Derived state ───────────────────────────────────────────────────── + + const isFullscreen = activeDisplayMode === 'fullscreen'; + const isPip = activeDisplayMode === 'pip'; + const isFillsViewport = isFullscreen || isStandalone; + const isInline = !isFillsViewport && !isPip; + + const appSupportsFullscreen = effectiveDisplayModes.includes('fullscreen'); + const appSupportsPip = effectiveDisplayModes.includes('pip'); + + return { + activeDisplayMode, + effectiveDisplayModes, + isStandalone, + isFullscreen, + isPip, + isFillsViewport, + isInline, + appSupportsFullscreen, + appSupportsPip, + + changeDisplayMode, + + inlineHeight: savedInlineHeight, + pipPosition, + + pipHandlers: { + onPointerDown: handlePipPointerDown, + onPointerMove: handlePipPointerMove, + onPointerUp: handlePipPointerUp, + onLostPointerCapture: handlePipLostPointerCapture, + onKeyDown: handlePipKeyDown, + }, + + fullscreenCloseRef, + }; +} diff --git a/ui/desktop/src/styles/main.css b/ui/desktop/src/styles/main.css index 1d9e181c..f4f96837 100644 --- a/ui/desktop/src/styles/main.css +++ b/ui/desktop/src/styles/main.css @@ -878,3 +878,52 @@ p > code.bg-inline-code { max-height: var(--search-bar-height); } } + +/* ========================================================================== + MCP App Display Mode Entrance Animations + ========================================================================== */ + +@keyframes mcp-enter-pip { + from { + opacity: 0; + transform: scale(0.85) translate(12px, -12px); + } + to { + opacity: 1; + transform: none; + } +} + +@keyframes mcp-enter-fullscreen { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: none; + } +} + +@keyframes mcp-enter-inline { + from { + opacity: 0; + transform: scale(1.03); + } + to { + opacity: 1; + transform: none; + } +} + +.mcp-app-container.mcp-enter-pip { + animation: mcp-enter-pip 200ms cubic-bezier(0.2, 0, 0, 1) both; +} + +.mcp-app-container.mcp-enter-fullscreen { + animation: mcp-enter-fullscreen 200ms cubic-bezier(0.2, 0, 0, 1) both; +} + +.mcp-app-container.mcp-enter-inline { + animation: mcp-enter-inline 180ms cubic-bezier(0.2, 0, 0, 1) both; +} diff --git a/ui/desktop/src/utils/githubUpdater.ts b/ui/desktop/src/utils/githubUpdater.ts index 2282e51c..a5d2ce3f 100644 --- a/ui/desktop/src/utils/githubUpdater.ts +++ b/ui/desktop/src/utils/githubUpdater.ts @@ -29,6 +29,7 @@ interface UpdateCheckResult { export class GitHubUpdater { private readonly owner = process.env.GITHUB_OWNER || 'block'; private readonly repo = process.env.GITHUB_REPO || 'goose'; + private readonly bundleName = process.env.GOOSE_BUNDLE_NAME || 'Goose'; private readonly apiUrl = `https://api.github.com/repos/${this.owner}/${this.repo}/releases/latest`; async checkForUpdates(): Promise { @@ -103,16 +104,16 @@ export class GitHubUpdater { if (platform === 'darwin') { // macOS if (arch === 'arm64') { - assetName = 'Goose.zip'; + assetName = `${this.bundleName}.zip`; } else { - assetName = 'Goose_intel_mac.zip'; + assetName = `${this.bundleName}_intel_mac.zip`; } } else if (platform === 'win32') { // Windows - for future support - assetName = 'Goose-win32-x64.zip'; + assetName = `${this.bundleName}-win32-x64.zip`; } else { // Linux - for future support - assetName = `Goose-linux-${arch}.zip`; + assetName = `${this.bundleName}-linux-${arch}.zip`; } log.info(`GitHubUpdater: Looking for asset named: ${assetName}`); @@ -254,7 +255,7 @@ export class GitHubUpdater { // Save to Downloads directory const downloadsDir = path.join(os.homedir(), 'Downloads'); - const fileName = `goose-${latestVersion}.zip`; + const fileName = `${this.bundleName}-${latestVersion}.zip`; const downloadPath = path.join(downloadsDir, fileName); log.info(`GitHubUpdater: Writing file to ${downloadPath}...`); diff --git a/ui/desktop/vite.main.config.mts b/ui/desktop/vite.main.config.mts index 329f511c..7087ae83 100644 --- a/ui/desktop/vite.main.config.mts +++ b/ui/desktop/vite.main.config.mts @@ -5,5 +5,6 @@ export default defineConfig({ define: { 'process.env.GITHUB_OWNER': JSON.stringify(process.env.GITHUB_OWNER || 'block'), 'process.env.GITHUB_REPO': JSON.stringify(process.env.GITHUB_REPO || 'goose'), + 'process.env.GOOSE_BUNDLE_NAME': JSON.stringify(process.env.GOOSE_BUNDLE_NAME || 'Goose'), }, });