Merge branch 'main' of github.com:block/goose into evict-sessions-from-cache

* 'main' of github.com:block/goose:
  fix: replace unwrap() with graceful error in scheduler execute_job (#7436)
  fix: Dictation API error message shows incorrect limit (#7423)
  fix(acp): Use ACP schema types for session/list (#7409)
  fix(desktop): make bundle and updater asset naming configurable (#7337)
  fix(openai): preserve order in Responses API history (#7500)
  Use the correct Goose emoji 🪿 instead of Swan in README.md (#7485)
  feat(ui): implement fullscreen and pip display modes for MCP Apps (#7312)
  Disable tool pair summarization (#7481)
This commit is contained in:
Zane Staggs
2026-02-26 12:24:36 -08:00
18 changed files with 866 additions and 141 deletions
+26 -2
View File
@@ -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`
+1 -1
View File
@@ -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?
>
+1 -1
View File
@@ -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"] }
-6
View File
@@ -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<serde_json::Value>,
}
/// Delete a session.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DeleteSessionRequest {
+22 -11
View File
@@ -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<SessionInfo> = sessions
.into_iter()
.map(|s| serde_json::to_value(&s))
.collect::<Result<Vec<_>, _>>()
.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)?,
+1 -1
View File
@@ -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"),
+11
View File
@@ -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<Option<(Message, String)>> {
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
{
@@ -277,59 +277,37 @@ pub enum ContentPart {
},
}
fn add_conversation_history(input_items: &mut Vec<Value>, messages: &[Message]) {
fn add_message_items(input_items: &mut Vec<Value>, 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<Value>, 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<Value>, 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<Value>, 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<String> = 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<String> = 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"]
);
}
}
+48 -3
View File
@@ -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"
);
}
}
+1 -1
View File
@@ -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"
+4 -4
View File
@@ -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",
+1 -1
View File
@@ -2969,7 +2969,7 @@ export type TranscribeDictationErrors = {
*/
412: unknown;
/**
* Audio file too large (max 25MB)
* Audio file too large (max 50MB)
*/
413: unknown;
/**
@@ -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<GooseDisplayMode, DimensionLayout> = {
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<HTMLDivElement>(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<HTMLDivElement>(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<number>(0);
const [containerHeight, setContainerHeight] = useState<number>(0);
const [apiHost, setApiHost] = useState<string | null>(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 (
<div className="no-drag absolute top-3 right-3 z-[60] flex gap-1">
{appSupportsPip && (
<button
onClick={() => changeDisplayMode('pip')}
className="cursor-pointer rounded-md bg-black/50 p-1.5 text-white backdrop-blur-sm transition-opacity hover:bg-black/70"
title="Picture-in-Picture"
aria-label="Picture-in-Picture"
>
<PictureInPicture2 size={16} />
</button>
)}
<button
ref={fullscreenCloseRef}
onClick={() => changeDisplayMode('inline')}
className="cursor-pointer rounded-md bg-black/50 p-1.5 text-white backdrop-blur-sm transition-opacity hover:bg-black/70"
title="Exit fullscreen (Esc)"
aria-label="Exit fullscreen"
>
<X size={16} />
</button>
</div>
);
}
if (activeDisplayMode === 'pip') {
return (
<>
{appSupportsFullscreen && (
<button
onClick={() => changeDisplayMode('fullscreen')}
className="cursor-pointer rounded-md bg-black/50 p-1 text-white backdrop-blur-sm transition-opacity hover:bg-black/70"
title="Fullscreen"
aria-label="Fullscreen"
>
<Maximize2 size={14} />
</button>
)}
<button
onClick={() => changeDisplayMode('inline')}
className="cursor-pointer rounded-md bg-black/50 p-1 text-white backdrop-blur-sm transition-opacity hover:bg-black/70"
title="Close"
aria-label="Close"
>
<X size={14} />
</button>
</>
);
}
// Inline mode — show controls on hover or keyboard focus
return (
<div className="absolute top-2 right-2 z-10 flex gap-1 opacity-0 transition-opacity group-hover/mcp-app:opacity-100 focus-within:opacity-100">
{appSupportsFullscreen && (
<button
onClick={() => changeDisplayMode('fullscreen')}
className="cursor-pointer rounded-md bg-black/40 p-1.5 text-white backdrop-blur-sm transition-opacity hover:bg-black/60"
title="Fullscreen"
aria-label="Fullscreen"
>
<Maximize2 size={14} />
</button>
)}
{appSupportsPip && (
<button
onClick={() => changeDisplayMode('pip')}
className="cursor-pointer rounded-md bg-black/40 p-1.5 text-white backdrop-blur-sm transition-opacity hover:bg-black/60"
title="Picture-in-Picture"
aria-label="Picture-in-Picture"
>
<PictureInPicture2 size={14} />
</button>
)}
</div>
);
};
// 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 (
<div ref={containerRef} className={containerClasses} style={containerStyle}>
{renderContent()}
</div>
<>
{/* Placeholder in chat flow when app is detached (fullscreen or pip) */}
{isFullscreen && (
<div
className="invisible mt-6 mb-2"
style={{ width: '100%', height: `${inlineHeight}px` }}
/>
)}
{isPip && (
<div
className="mt-6 mb-2 flex items-center justify-center rounded-lg border border-dashed border-border-primary bg-black/[0.02] dark:bg-white/[0.02]"
style={{ width: '100%', height: `${inlineHeight}px` }}
>
<button
onClick={() => changeDisplayMode('inline')}
className="cursor-pointer flex items-center gap-2 rounded-md px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-black/5 hover:text-text-primary dark:hover:bg-white/5"
>
<PictureInPicture2 size={14} />
<span>Playing in Picture-in-Picture</span>
</button>
</div>
)}
{/* Stable app container — never unmounted, only repositioned via CSS */}
<div
ref={containerRef}
className={cn(containerClasses, isPip && 'group/pip')}
style={containerStyle}
>
{isPip && (
<div className="pointer-events-none sticky top-1 z-20 flex h-0 items-start justify-between px-1 opacity-0 transition-opacity group-hover/pip:pointer-events-auto group-hover/pip:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100">
<div
role="button"
tabIndex={0}
aria-label="Move Picture-in-Picture window (use arrow keys)"
className="pointer-events-auto cursor-grab rounded-md bg-black/50 p-1 text-white backdrop-blur-sm hover:bg-black/70 active:cursor-grabbing"
onPointerDown={pipHandlers.onPointerDown}
onPointerMove={pipHandlers.onPointerMove}
onPointerUp={pipHandlers.onPointerUp}
onLostPointerCapture={pipHandlers.onLostPointerCapture}
onKeyDown={pipHandlers.onKeyDown}
>
<GripHorizontal size={14} />
</div>
<div className="flex gap-1">{renderDisplayModeControls()}</div>
</div>
)}
<div className={cn('relative w-full', !isPip && 'h-full')}>
{!isPip && renderDisplayModeControls()}
{renderContent()}
</div>
</div>
</>
);
}
@@ -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 };
@@ -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<HTMLDivElement | null>;
}
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<HTMLButtonElement | null>;
}
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<GooseDisplayMode>(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<string[] | null>(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<Set<Window>>(new Set());
const enterAnimRef = useRef<string | null>(null);
const fullscreenCloseRef = useRef<HTMLButtonElement>(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,
};
}
+49
View File
@@ -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;
}
+6 -5
View File
@@ -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<UpdateCheckResult> {
@@ -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}...`);
+1
View File
@@ -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'),
},
});