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:
+26
-2
@@ -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`
|
||||
|
||||
@@ -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?
|
||||
>
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}...`);
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user