From 7a738d2ca7a2efa511978d8b754291714286286c Mon Sep 17 00:00:00 2001 From: Frank Aloia Date: Fri, 7 Aug 2026 10:03:26 -0400 Subject: [PATCH 1/9] [front] feat: add conversation_side_panel MCP server to open frames Adds a new internal MCP server (`conversation_side_panel`) that lets the activation agent open/close frames in the conversation side panel instead of rendering them inline. - New server with two tools: `open_frame` (open a specific file frame by ID) and `set_files_side_panel` (show/hide the files panel) - Registers a `side_panel_control` progress-notification output schema and filters it from the public v1 API (UI-only signal) - Wires the server into `INTERNAL_MCP_SERVERS`, `getInternalMCPServer`, and the activation skill MCP server list - Updates the activation skill system prompt: frames are now delivered via `open_frame` rather than rendered inline - Adds BM25 search test cases for the new tools Co-Authored-By: Claude Sonnet 4.6 --- .../actions/mcp_internal_actions/constants.ts | 13 ++ .../mcp_internal_actions/output_schemas.ts | 17 +++ .../mcp_internal_actions/servers/index.ts | 3 + .../actions/servers/bm25_tool_search.test.ts | 18 +++ .../servers/conversation_side_panel/index.ts | 24 ++++ .../conversation_side_panel/metadata.ts | 69 ++++++++++ .../conversation_side_panel/tools/index.ts | 128 ++++++++++++++++++ front/lib/api/v1/backward_compatibility.ts | 16 ++- .../skill/code_defined/global/activation.ts | 29 +++- 9 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 front/lib/api/actions/servers/conversation_side_panel/index.ts create mode 100644 front/lib/api/actions/servers/conversation_side_panel/metadata.ts create mode 100644 front/lib/api/actions/servers/conversation_side_panel/tools/index.ts diff --git a/front/lib/actions/mcp_internal_actions/constants.ts b/front/lib/actions/mcp_internal_actions/constants.ts index 554835d51736..627cf8f3d639 100644 --- a/front/lib/actions/mcp_internal_actions/constants.ts +++ b/front/lib/actions/mcp_internal_actions/constants.ts @@ -20,6 +20,7 @@ import { CLARI_COPILOT_SERVER } from "@app/lib/api/actions/servers/clari_copilot import { COMMON_UTILITIES_SERVER } from "@app/lib/api/actions/servers/common_utilities/metadata"; import { CONFLUENCE_SERVER } from "@app/lib/api/actions/servers/confluence/metadata"; import { CONVERSATION_FILES_SERVER } from "@app/lib/api/actions/servers/conversation_files/metadata"; +import { CONVERSATION_SIDE_PANEL_SERVER } from "@app/lib/api/actions/servers/conversation_side_panel/metadata"; import { DATA_SOURCES_FILE_SYSTEM_SERVER } from "@app/lib/api/actions/servers/data_sources_file_system/metadata"; import { DATA_WAREHOUSES_SERVER } from "@app/lib/api/actions/servers/data_warehouses/metadata"; import { DATABRICKS_SERVER } from "@app/lib/api/actions/servers/databricks/metadata"; @@ -165,6 +166,7 @@ export const AVAILABLE_INTERNAL_MCP_SERVER_NAMES = [ "clari_copilot", "confluence", "conversation_files", + "conversation_side_panel", "files", "databricks", "data_sources_file_system", @@ -473,6 +475,17 @@ export const INTERNAL_MCP_SERVERS = ensureUniqueToolNames({ timeoutMs: undefined, metadata: CONVERSATION_FILES_SERVER, }, + conversation_side_panel: { + id: 1044, + availability: "auto_hidden_builder", + allowMultipleInstances: false, + isRestricted: undefined, + isPreview: false, + tools_arguments_requiring_approval: undefined, + tools_retry_policies: undefined, + timeoutMs: undefined, + metadata: CONVERSATION_SIDE_PANEL_SERVER, + }, slack: { id: 18, availability: "manual", diff --git a/front/lib/actions/mcp_internal_actions/output_schemas.ts b/front/lib/actions/mcp_internal_actions/output_schemas.ts index ca6038f312b8..469f166dadf7 100644 --- a/front/lib/actions/mcp_internal_actions/output_schemas.ts +++ b/front/lib/actions/mcp_internal_actions/output_schemas.ts @@ -807,6 +807,22 @@ export function isInteractiveContentFileContentOutput( return output !== undefined && output.type === "interactive_content_file"; } +const NotificationSidePanelControlSchema = z.object({ + type: z.literal("side_panel_control"), + panel: z.enum(["files"]), + action: z.enum(["open", "close"]), +}); + +type SidePanelControlProgressOutput = z.infer< + typeof NotificationSidePanelControlSchema +>; + +export function isSidePanelControlOutput( + output: ProgressNotificationOutput +): output is SidePanelControlProgressOutput { + return output !== undefined && output.type === "side_panel_control"; +} + const InternalAllowedIconSchema = z.enum([...INTERNAL_ALLOWED_ICONS]); const CustomResourceIconSchema = z.enum([...CUSTOM_RESOURCE_ALLOWED]); @@ -890,6 +906,7 @@ export const ProgressNotificationOutputSchema = z .union([ NotificationImageContentSchema, NotificationInteractiveContentFileContentSchema, + NotificationSidePanelControlSchema, NotificationRunAgentContentSchema, NotificationStoreResourceContentSchema, NotificationTextContentSchema, diff --git a/front/lib/actions/mcp_internal_actions/servers/index.ts b/front/lib/actions/mcp_internal_actions/servers/index.ts index e37731c973b3..b4e5f542b6a9 100644 --- a/front/lib/actions/mcp_internal_actions/servers/index.ts +++ b/front/lib/actions/mcp_internal_actions/servers/index.ts @@ -17,6 +17,7 @@ import { default as clariCopilotServer } from "@app/lib/api/actions/servers/clar import { default as commonUtilitiesServer } from "@app/lib/api/actions/servers/common_utilities"; import { default as confluenceServer } from "@app/lib/api/actions/servers/confluence"; import { default as conversationFilesServer } from "@app/lib/api/actions/servers/conversation_files"; +import { default as conversationSidePanelServer } from "@app/lib/api/actions/servers/conversation_side_panel"; import { default as dataSourcesFileSystemServer } from "@app/lib/api/actions/servers/data_sources_file_system"; import { default as dataWarehousesServer } from "@app/lib/api/actions/servers/data_warehouses"; import { default as databricksServer } from "@app/lib/api/actions/servers/databricks"; @@ -198,6 +199,8 @@ export async function getInternalMCPServer( return dataSourcesFileSystemServer(auth, toolContext); case "conversation_files": return conversationFilesServer(auth, toolContext); + case "conversation_side_panel": + return conversationSidePanelServer(auth, toolContext); case "files": return filesServer(auth, toolContext); case "databricks": diff --git a/front/lib/api/actions/servers/bm25_tool_search.test.ts b/front/lib/api/actions/servers/bm25_tool_search.test.ts index 0c876116288b..9dcdcc009d99 100644 --- a/front/lib/api/actions/servers/bm25_tool_search.test.ts +++ b/front/lib/api/actions/servers/bm25_tool_search.test.ts @@ -809,6 +809,14 @@ const QUERIES: LabeledQuery[] = [ query: "read back the content of my frame", expected: "interactive_content.retrieve_interactive_content_file", }, + { + query: "open an existing frame in the side panel without editing it", + expected: "conversation_side_panel.open_frame", + }, + { + query: "show the user a frame that was already created", + expected: "conversation_side_panel.open_frame", + }, { query: "revert my frame to the previous version", expected: "interactive_content.revert_interactive_content_file", @@ -1578,6 +1586,16 @@ const QUERIES: LabeledQuery[] = [ expected: "common_utilities.set_conversation_title", }, + // --- conversation_side_panel --- + { + query: "open the files side panel so the user can browse attachments", + expected: "conversation_side_panel.set_files_side_panel", + }, + { + query: "hide the conversation files explorer panel", + expected: "conversation_side_panel.set_files_side_panel", + }, + // --- exa_people_and_company --- { query: "find the LinkedIn profile of the CTO of Mistral AI", diff --git a/front/lib/api/actions/servers/conversation_side_panel/index.ts b/front/lib/api/actions/servers/conversation_side_panel/index.ts new file mode 100644 index 000000000000..c7e6eba368d9 --- /dev/null +++ b/front/lib/api/actions/servers/conversation_side_panel/index.ts @@ -0,0 +1,24 @@ +import { makeInternalMCPServer } from "@app/lib/actions/mcp_internal_actions/utils"; +import { registerTool } from "@app/lib/actions/mcp_internal_actions/wrappers"; +import type { ToolContext } from "@app/lib/actions/types"; +import { CONVERSATION_SIDE_PANEL_SERVER_NAME } from "@app/lib/api/actions/servers/conversation_side_panel/metadata"; +import { TOOLS } from "@app/lib/api/actions/servers/conversation_side_panel/tools"; +import type { Authenticator } from "@app/lib/auth"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +function createServer( + auth: Authenticator, + toolContext?: ToolContext +): McpServer { + const server = makeInternalMCPServer(CONVERSATION_SIDE_PANEL_SERVER_NAME); + + for (const tool of TOOLS) { + registerTool(auth, toolContext, server, tool, { + monitoringName: CONVERSATION_SIDE_PANEL_SERVER_NAME, + }); + } + + return server; +} + +export default createServer; diff --git a/front/lib/api/actions/servers/conversation_side_panel/metadata.ts b/front/lib/api/actions/servers/conversation_side_panel/metadata.ts new file mode 100644 index 000000000000..d0e1ffd213af --- /dev/null +++ b/front/lib/api/actions/servers/conversation_side_panel/metadata.ts @@ -0,0 +1,69 @@ +import type { ServerMetadata } from "@app/lib/actions/mcp_internal_actions/tool_definition"; +import { z } from "zod"; + +export const CONVERSATION_SIDE_PANEL_SERVER_NAME = + "conversation_side_panel" as const; + +export const OPEN_FRAME_TOOL_NAME = "open_frame" as const; +export const SET_FILES_SIDE_PANEL_TOOL_NAME = "set_files_side_panel" as const; + +export const CONVERSATION_SIDE_PANEL_TOOLS_METADATA = [ + { + name: OPEN_FRAME_TOOL_NAME, + description: + "Open an existing Frame in the conversation side panel without modifying it. " + + "Use this when a Frame already exists and you want the user to see it now " + + "(for example after referring back to a prior Frame). Creating, editing, reverting, " + + "renaming, or publishing a Frame already opens it automatically — call this only to " + + "re-open a Frame that is not currently shown.", + schema: { + file_id: z + .string() + .describe( + "The ID of the Interactive Content file to open (e.g., 'fil_abc123')" + ), + }, + stake: "never_ask", + displayLabels: { + running: "Opening Frame", + done: "Open Frame", + }, + toolCostCategory: "basic", + freeUsage: true, + }, + { + name: SET_FILES_SIDE_PANEL_TOOL_NAME, + description: + "Show or hide the conversation files side panel in the UI. Use `visible: true` to open " + + "the file explorer so the user can browse conversation files, or `visible: false` to close " + + "it when it is open. This does not open a Frame; use `open_frame` for that.", + schema: { + visible: z + .boolean() + .describe( + "Whether the files side panel should be visible. `true` opens it; `false` closes it " + + "when the files panel is currently open." + ), + }, + stake: "never_ask", + displayLabels: { + running: "Updating files panel", + done: "Update files panel", + }, + toolCostCategory: "basic", + freeUsage: true, + }, +] as const; + +export const CONVERSATION_SIDE_PANEL_SERVER = { + serverInfo: { + name: CONVERSATION_SIDE_PANEL_SERVER_NAME, + version: "1.0.0", + description: + "Control the conversation side panel: open a Frame, or show/hide the files explorer.", + icon: "ActionFrameIcon", + authorization: null, + documentationUrl: null, + }, + tools: CONVERSATION_SIDE_PANEL_TOOLS_METADATA, +} as const satisfies ServerMetadata; diff --git a/front/lib/api/actions/servers/conversation_side_panel/tools/index.ts b/front/lib/api/actions/servers/conversation_side_panel/tools/index.ts new file mode 100644 index 000000000000..8682eb4e009d --- /dev/null +++ b/front/lib/api/actions/servers/conversation_side_panel/tools/index.ts @@ -0,0 +1,128 @@ +import { MCPError } from "@app/lib/actions/mcp_errors"; +import type { MCPProgressNotificationType } from "@app/lib/actions/mcp_internal_actions/output_schemas"; +import type { ToolHandlers } from "@app/lib/actions/mcp_internal_actions/tool_definition"; +import { buildTools } from "@app/lib/actions/mcp_internal_actions/tool_definition"; +import { isAgentLoopRunContext } from "@app/lib/actions/types"; +import { + CONVERSATION_SIDE_PANEL_TOOLS_METADATA, + OPEN_FRAME_TOOL_NAME, + SET_FILES_SIDE_PANEL_TOOL_NAME, +} from "@app/lib/api/actions/servers/conversation_side_panel/metadata"; +import { buildInteractiveContentFileNotification } from "@app/lib/api/actions/servers/interactive_content/helpers"; +import { FileResource } from "@app/lib/resources/file_resource"; +import { isInteractiveContentType } from "@app/types/files"; +import { Err, Ok } from "@app/types/shared/result"; + +function buildFilesSidePanelControlNotification( + progressToken: string | number, + action: "open" | "close", + label: string +): MCPProgressNotificationType { + return { + method: "notifications/progress", + params: { + progress: 1, + total: 1, + progressToken, + _meta: { + data: { + label, + output: { + type: "side_panel_control", + panel: "files", + action, + }, + }, + }, + }, + }; +} + +const handlers: ToolHandlers = { + [OPEN_FRAME_TOOL_NAME]: async ( + { file_id }, + { auth, sendNotification, _meta, runContext } + ) => { + if (!isAgentLoopRunContext(runContext)) { + return new Err( + new MCPError( + "No conversation context available. This tool can only be used within a conversation." + ) + ); + } + + const fileResource = await FileResource.fetchById(auth, file_id); + if (!fileResource) { + return new Err( + new MCPError(`File not found: ${file_id}`, { tracked: false }) + ); + } + + if (!isInteractiveContentType(fileResource.contentType)) { + return new Err( + new MCPError( + `File '${file_id}' is not a Frame (content type: ${fileResource.contentType}).`, + { tracked: false } + ) + ); + } + + if (_meta?.progressToken) { + await sendNotification( + buildInteractiveContentFileNotification( + _meta.progressToken, + fileResource, + "Opening Frame..." + ) + ); + } + + return new Ok([ + { + type: "text", + text: + `Opened Frame '${fileResource.sId}' (${fileResource.fileName}) ` + + "in the side panel.", + }, + ]); + }, + + [SET_FILES_SIDE_PANEL_TOOL_NAME]: async ( + { visible }, + { sendNotification, _meta, runContext } + ) => { + if (!isAgentLoopRunContext(runContext)) { + return new Err( + new MCPError( + "No conversation context available. This tool can only be used within a conversation." + ) + ); + } + + const action = visible ? "open" : "close"; + + if (_meta?.progressToken) { + await sendNotification( + buildFilesSidePanelControlNotification( + _meta.progressToken, + action, + visible ? "Opening files panel..." : "Closing files panel..." + ) + ); + } + + return new Ok([ + { + type: "text", + text: visible + ? "Opened the files side panel." + : "Closed the files side panel.", + }, + ]); + }, +}; + +export const TOOLS = buildTools( + CONVERSATION_SIDE_PANEL_TOOLS_METADATA, + handlers +); diff --git a/front/lib/api/v1/backward_compatibility.ts b/front/lib/api/v1/backward_compatibility.ts index 5caecad2f169..e7ce0aee9f35 100644 --- a/front/lib/api/v1/backward_compatibility.ts +++ b/front/lib/api/v1/backward_compatibility.ts @@ -1,4 +1,7 @@ -import { isRunAgentQueryProgressOutput } from "@app/lib/actions/mcp_internal_actions/output_schemas"; +import { + isRunAgentQueryProgressOutput, + isSidePanelControlOutput, +} from "@app/lib/actions/mcp_internal_actions/output_schemas"; import type { MessageStreamEvent } from "@app/lib/api/assistant/pubsub"; import config from "@app/lib/api/config"; import type { Authenticator } from "@app/lib/auth"; @@ -196,7 +199,10 @@ export function toPublicAgentMessageEvent( event.data.notification._meta.data; let output; - if (isRunAgentQueryProgressOutput(originalOutput)) { + if (isSidePanelControlOutput(originalOutput)) { + // UI-only signal — not exposed on the public v1 API. + output = undefined; + } else if (isRunAgentQueryProgressOutput(originalOutput)) { const wId = auth.getNonNullableWorkspace().sId; const { conversationId, agentMessageId } = originalOutput; const childConversationUrl = `${config.getApiBaseUrl()}/api/v1/w/${wId}/assistant/conversations/${conversationId}`; @@ -211,6 +217,10 @@ export function toPublicAgentMessageEvent( output = originalOutput; } + // Cast: this is the internal→public boundary. The internal type carries + // output variants (e.g. side_panel_control) that post-date the installed + // SDK. Casting is safe here because the handler is the mapping layer. + // biome-ignore lint/suspicious/noExplicitAny: internal→public boundary cast return { eventId: event.eventId, data: { @@ -224,7 +234,7 @@ export function toPublicAgentMessageEvent( }, }, }, - }; + } as AgentMessageEventType; } return { diff --git a/front/lib/resources/skill/code_defined/global/activation.ts b/front/lib/resources/skill/code_defined/global/activation.ts index a47528c3bce2..ce35596d95f7 100644 --- a/front/lib/resources/skill/code_defined/global/activation.ts +++ b/front/lib/resources/skill/code_defined/global/activation.ts @@ -1,3 +1,9 @@ +import { getPrefixedToolName } from "@app/lib/actions/tool_name_utils"; +import { + CONVERSATION_SIDE_PANEL_SERVER_NAME, + OPEN_FRAME_TOOL_NAME, + SET_FILES_SIDE_PANEL_TOOL_NAME, +} from "@app/lib/api/actions/servers/conversation_side_panel/metadata"; import type { Authenticator } from "@app/lib/auth"; import { getFeatureFlags } from "@app/lib/auth"; import type { GlobalSkillDefinition } from "@app/lib/resources/skill/code_defined/shared"; @@ -9,6 +15,15 @@ import { isJobType, JOB_TYPE_LABELS } from "@app/types/job_type"; import { isStringArray } from "@app/types/shared/utils/general"; import { safeParseJSON } from "@app/types/shared/utils/json_utils"; +const OPEN_FRAME_TOOL = getPrefixedToolName( + CONVERSATION_SIDE_PANEL_SERVER_NAME, + OPEN_FRAME_TOOL_NAME +); +const SET_FILES_SIDE_PANEL_TOOL = getPrefixedToolName( + CONVERSATION_SIDE_PANEL_SERVER_NAME, + SET_FILES_SIDE_PANEL_TOOL_NAME +); + const ACTIVATION_BEHAVIOR = ` # Overview You are a Dust trainer for dormant / low-fluency users. In each conversation, you move the user one concrete step toward getting real work done in Dust. @@ -32,7 +47,7 @@ Every conversation runs the same loop. Each step below has its own section with 3. Build the Plan — 2–4 ordered rungs toward the Goal, recorded in \`session_plan.md\`. 4. Prepare the current rung — run every safe automatic read before anything user-visible. 5. Present the current rung — exactly one action card, recorded via \`create_recommendation\`. -6. Execute on accept — run the prepared work; deliver the result as an inline Frame. +6. Execute on accept — run the prepared work; deliver the result as a Frame opened in the side panel. 7. Collect feedback — then offer Skill or Trigger creation only when it is the next rung. 8. Complete and advance — recap the rung, update durable state, move to the next rung or close. @@ -44,6 +59,9 @@ A session succeeds when the user gets one timely, evidence-backed domain win (ar # Hard Rules - Never use plan mode. - Never describe the mechanics of this workflow as a system. For example, the user will have no idea what a session goal is. +- The user did not choose or write the Session Goal. It is something Dust set for them. Never imply + they asked for it, already agreed to it, or remember it ("as you wanted…", "per your goal…", "you said you wanted to…"). Introduce + it as a fresh suggestion and explain why it might help, grounded in evidence they can recognize (role, peers, their work). - Never block the user (skip / redirect / leave is always allowed). - The first user-visible response always includes an action card. Before it, never call \`ask_user_question\` or a blocking tool (a tool that requires approval, authentication, or user input). If information is missing, use the best evidence-backed Work Area @@ -245,6 +263,8 @@ At the start of EVERY session, give an extremely warm welcome to the user. Act a work, and explain that the recommendation is a concrete win within it. Ground this in evidence (role, peers, their work, etc). * Present exactly one action card at the start of the session. +* Call \`${SET_FILES_SIDE_PANEL_TOOL}\` with \`visible: false\` before finishing this first-turn response. + ## Presenting the Recommendation - ALWAYS surface a new recommendation as the first user-visible response and the final output of the agent. The result is rendered @@ -302,6 +322,12 @@ Once the user accepts, execute the current rung for real: - Ask at most one clarifying question, only when it is a genuinely blocking human gate; otherwise use sensible defaults and let the user correct the output. - Deliver the result as its own inline Frame in this conversation; never leave the user to find it in the file system. +## Deliver the Frame + +You MUST open every Frame for the user. After creating or finding the Frame, call \`${OPEN_FRAME_TOOL}\` with its \`file_id\`. +Do not merely mention a Frame in chat or expect the user to find it. +When referring to a Frame again later, call \`${OPEN_FRAME_TOOL}\` again first. + ## When a required source is missing user authentication Lead the user through the connection process: @@ -445,6 +471,7 @@ export const activationSkill = { { name: "files" }, { name: "activation_recommendations" }, { name: "pod_manager" }, + { name: "conversation_side_panel" }, ], version: 6, icon: "ActionRocketIcon", From 5ab4c2c2cb980a6b9a7261bad65ae0cb20882bae Mon Sep 17 00:00:00 2001 From: Frank Aloia Date: Fri, 7 Aug 2026 10:19:12 -0400 Subject: [PATCH 2/9] [sdks/js] Add side_panel_control to NotificationContentSchema Same pattern as tool_approval_bubble_up and other notification types. Avoids a type cast in backward_compatibility.ts. Co-Authored-By: Claude Sonnet 4.6 --- sdks/js/src/types.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sdks/js/src/types.ts b/sdks/js/src/types.ts index ea12acb5a462..a4debb71001c 100644 --- a/sdks/js/src/types.ts +++ b/sdks/js/src/types.ts @@ -1382,6 +1382,12 @@ const NotificationTextContentSchema = z.object({ text: z.string(), }); +const NotificationSidePanelControlSchema = z.object({ + type: z.literal("side_panel_control"), + panel: z.enum(["files"]), + action: z.enum(["open", "close"]), +}); + const NotificationToolApproveBubbleUpContentSchema = z.object({ type: z.literal("tool_approval_bubble_up"), configurationId: z.string(), @@ -1429,6 +1435,7 @@ const NotificationContentSchema = z.union([ NotificationRunAgentContentSchema, NotificationStoreResourceContentSchema, NotificationTextContentSchema, + NotificationSidePanelControlSchema, NotificationToolApproveBubbleUpContentSchema, ]); From 103904deb5e14ff9b411340a90e9ee7087cd6c8f Mon Sep 17 00:00:00 2001 From: Frank Aloia Date: Fri, 7 Aug 2026 10:21:13 -0400 Subject: [PATCH 3/9] Revert backward_compatibility.ts: no change needed side_panel_control is now in the SDK NotificationContentSchema union, so no cast or filtering is required here. Co-Authored-By: Claude Sonnet 4.6 --- front/lib/api/v1/backward_compatibility.ts | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/front/lib/api/v1/backward_compatibility.ts b/front/lib/api/v1/backward_compatibility.ts index e7ce0aee9f35..5caecad2f169 100644 --- a/front/lib/api/v1/backward_compatibility.ts +++ b/front/lib/api/v1/backward_compatibility.ts @@ -1,7 +1,4 @@ -import { - isRunAgentQueryProgressOutput, - isSidePanelControlOutput, -} from "@app/lib/actions/mcp_internal_actions/output_schemas"; +import { isRunAgentQueryProgressOutput } from "@app/lib/actions/mcp_internal_actions/output_schemas"; import type { MessageStreamEvent } from "@app/lib/api/assistant/pubsub"; import config from "@app/lib/api/config"; import type { Authenticator } from "@app/lib/auth"; @@ -199,10 +196,7 @@ export function toPublicAgentMessageEvent( event.data.notification._meta.data; let output; - if (isSidePanelControlOutput(originalOutput)) { - // UI-only signal — not exposed on the public v1 API. - output = undefined; - } else if (isRunAgentQueryProgressOutput(originalOutput)) { + if (isRunAgentQueryProgressOutput(originalOutput)) { const wId = auth.getNonNullableWorkspace().sId; const { conversationId, agentMessageId } = originalOutput; const childConversationUrl = `${config.getApiBaseUrl()}/api/v1/w/${wId}/assistant/conversations/${conversationId}`; @@ -217,10 +211,6 @@ export function toPublicAgentMessageEvent( output = originalOutput; } - // Cast: this is the internal→public boundary. The internal type carries - // output variants (e.g. side_panel_control) that post-date the installed - // SDK. Casting is safe here because the handler is the mapping layer. - // biome-ignore lint/suspicious/noExplicitAny: internal→public boundary cast return { eventId: event.eventId, data: { @@ -234,7 +224,7 @@ export function toPublicAgentMessageEvent( }, }, }, - } as AgentMessageEventType; + }; } return { From 807a7026bc5a3617739dc85aeab6631ae37ecc02 Mon Sep 17 00:00:00 2001 From: Frank Aloia Date: Fri, 7 Aug 2026 10:46:33 -0400 Subject: [PATCH 4/9] Fix BM25 tests: add conversation_side_panel to SERVER_SOURCES, improve open_frame description Co-Authored-By: Claude Sonnet 4.6 --- front/lib/api/actions/servers/bm25_tool_search_utils.test.ts | 5 +++++ .../api/actions/servers/conversation_side_panel/metadata.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/front/lib/api/actions/servers/bm25_tool_search_utils.test.ts b/front/lib/api/actions/servers/bm25_tool_search_utils.test.ts index 005305f573de..d406e100d131 100644 --- a/front/lib/api/actions/servers/bm25_tool_search_utils.test.ts +++ b/front/lib/api/actions/servers/bm25_tool_search_utils.test.ts @@ -6,6 +6,7 @@ import { CLARI_COPILOT_SERVER } from "@app/lib/api/actions/servers/clari_copilot import { COMMON_UTILITIES_SERVER } from "@app/lib/api/actions/servers/common_utilities/metadata"; import { CONFLUENCE_SERVER } from "@app/lib/api/actions/servers/confluence/metadata"; import { CONVERSATION_FILES_SERVER } from "@app/lib/api/actions/servers/conversation_files/metadata"; +import { CONVERSATION_SIDE_PANEL_SERVER } from "@app/lib/api/actions/servers/conversation_side_panel/metadata"; import { DATA_SOURCES_FILE_SYSTEM_SERVER } from "@app/lib/api/actions/servers/data_sources_file_system/metadata"; import { DATA_WAREHOUSES_SERVER } from "@app/lib/api/actions/servers/data_warehouses/metadata"; import { DATABRICKS_SERVER } from "@app/lib/api/actions/servers/databricks/metadata"; @@ -123,6 +124,10 @@ const SERVER_SOURCES: Array<{ }> = [ { name: "agent_memory", tools: AGENT_MEMORY_SERVER.tools }, { name: "conversation_files", tools: CONVERSATION_FILES_SERVER.tools }, + { + name: "conversation_side_panel", + tools: CONVERSATION_SIDE_PANEL_SERVER.tools, + }, { name: "google_drive", tools: GOOGLE_DRIVE_SERVER.tools }, { name: "google_sheets", tools: GOOGLE_SHEETS_SERVER.tools }, { name: "microsoft_drive", tools: MICROSOFT_DRIVE_SERVER.tools }, diff --git a/front/lib/api/actions/servers/conversation_side_panel/metadata.ts b/front/lib/api/actions/servers/conversation_side_panel/metadata.ts index d0e1ffd213af..efc54c695efb 100644 --- a/front/lib/api/actions/servers/conversation_side_panel/metadata.ts +++ b/front/lib/api/actions/servers/conversation_side_panel/metadata.ts @@ -11,8 +11,8 @@ export const CONVERSATION_SIDE_PANEL_TOOLS_METADATA = [ { name: OPEN_FRAME_TOOL_NAME, description: - "Open an existing Frame in the conversation side panel without modifying it. " + - "Use this when a Frame already exists and you want the user to see it now " + + "Open and show an existing Frame in the conversation side panel without modifying it. " + + "Use this when a Frame was already created and you want the user to see it now " + "(for example after referring back to a prior Frame). Creating, editing, reverting, " + "renaming, or publishing a Frame already opens it automatically — call this only to " + "re-open a Frame that is not currently shown.", From 3a599281651daed71df5d6754b285769380d124d Mon Sep 17 00:00:00 2001 From: Frank Aloia Date: Fri, 7 Aug 2026 11:05:22 -0400 Subject: [PATCH 5/9] Update internal MCP availability snapshot for conversation_side_panel Co-Authored-By: Claude Sonnet 4.6 --- .../internal_mcp_server_availability.snapshot.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/front/lib/actions/mcp_internal_actions/internal_mcp_server_availability.snapshot.json b/front/lib/actions/mcp_internal_actions/internal_mcp_server_availability.snapshot.json index 88a114af02e3..0b9f2df342d5 100644 --- a/front/lib/actions/mcp_internal_actions/internal_mcp_server_availability.snapshot.json +++ b/front/lib/actions/mcp_internal_actions/internal_mcp_server_availability.snapshot.json @@ -38,7 +38,8 @@ { "name": "user_analytics", "id": 1039 }, { "name": "activation_recommendations", "id": 1040 }, { "name": "agent_templates", "id": 1041 }, - { "name": "user_memory", "id": 1043 } + { "name": "user_memory", "id": 1043 }, + { "name": "conversation_side_panel", "id": 1044 } ], "manual": [ { "name": "github", "id": 1 }, From 47e27e4e5457a9c1b592e79aae94a2ebf199d426 Mon Sep 17 00:00:00 2001 From: Frank Aloia Date: Fri, 7 Aug 2026 11:22:50 -0400 Subject: [PATCH 6/9] Update MCP server metadata snapshots for conversation_side_panel Co-Authored-By: Claude Sonnet 4.6 --- .../mcp_servers_metadata.test.ts.snap | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/front/lib/actions/mcp_internal_actions/__snapshots__/mcp_servers_metadata.test.ts.snap b/front/lib/actions/mcp_internal_actions/__snapshots__/mcp_servers_metadata.test.ts.snap index 17a49a84e1bf..e7d3dc804df3 100644 --- a/front/lib/actions/mcp_internal_actions/__snapshots__/mcp_servers_metadata.test.ts.snap +++ b/front/lib/actions/mcp_internal_actions/__snapshots__/mcp_servers_metadata.test.ts.snap @@ -294,6 +294,16 @@ exports[`MCP Servers Metadata Snapshot > should have stable tool billing info ac "toolCostCategory": "advanced", }, }, + "conversation_side_panel": { + "open_frame": { + "freeUsage": true, + "toolCostCategory": "basic", + }, + "set_files_side_panel": { + "freeUsage": true, + "toolCostCategory": "basic", + }, + }, "data_sources_file_system": { "cat": { "freeUsage": false, @@ -2737,6 +2747,10 @@ exports[`MCP Servers Metadata Snapshot > should have stable tool stakes across a "list_content_nodes_and_tables": "never_ask", "semantic_search": "never_ask", }, + "conversation_side_panel": { + "open_frame": "never_ask", + "set_files_side_panel": "never_ask", + }, "data_sources_file_system": { "cat": "never_ask", "find": "never_ask", From 917636da636d26112347cd15be8ba0e42ba12951 Mon Sep 17 00:00:00 2001 From: Frank Aloia Date: Sun, 9 Aug 2026 22:36:30 -0400 Subject: [PATCH 7/9] Fix files panel visibility controls Honor the activation agent's explicit files panel preference so generated-file auto-open behavior cannot override it. Co-authored-by: Cursor --- .../conversation/useAutoOpenSidePanel.ts | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/front/components/assistant/conversation/useAutoOpenSidePanel.ts b/front/components/assistant/conversation/useAutoOpenSidePanel.ts index b0f76b88b549..7931bb9aaed0 100644 --- a/front/components/assistant/conversation/useAutoOpenSidePanel.ts +++ b/front/components/assistant/conversation/useAutoOpenSidePanel.ts @@ -1,7 +1,11 @@ import { useConversationSidePanelContext } from "@app/components/assistant/conversation/ConversationSidePanelContext"; import type { AgentMessageWithStreaming } from "@app/components/assistant/conversation/types"; -import { isInteractiveContentFileContentOutput } from "@app/lib/actions/mcp_internal_actions/output_schemas"; +import { + isInteractiveContentFileContentOutput, + isSidePanelControlOutput, +} from "@app/lib/actions/mcp_internal_actions/output_schemas"; import { useIsMobile } from "@app/lib/swr/useIsMobile"; +import { FILES_SIDE_PANEL_TYPE } from "@app/types/conversation_side_panel"; import { isInteractiveContentType } from "@app/types/files"; import { removeNulls } from "@app/types/shared/utils/general"; import React from "react"; @@ -25,7 +29,8 @@ export function useAutoOpenSidePanel({ isLastMessage, agentMessage, }: UseAutoOpenSidePanelProps) { - const { openPanel, currentPanel } = useConversationSidePanelContext(); + const { openPanel, closePanel, currentPanel } = + useConversationSidePanelContext(); const isMobile = useIsMobile(); // Track the last opened fileId to prevent double-opening glitch. @@ -45,6 +50,7 @@ export function useAutoOpenSidePanel({ // Track which message sId last triggered file-panel auto-open to open only once per message. const autoOpenedFilesForRef = React.useRef(null); + const handledFilesSidePanelControlRef = React.useRef(null); const interactiveFilesFromProgress = React.useMemo( () => @@ -62,6 +68,24 @@ export function useAutoOpenSidePanel({ [agentMessage.streaming.actionProgress] ); + const latestFilesSidePanelControl = React.useMemo(() => { + let latestControl: + | { + actionId: number; + action: "open" | "close"; + } + | undefined; + + for (const [actionId, progress] of agentMessage.streaming.actionProgress) { + const output = progress.progress?._meta.data.output; + if (isSidePanelControlOutput(output) && output.panel === "files") { + latestControl = { actionId, action: output.action }; + } + } + + return latestControl; + }, [agentMessage.streaming.actionProgress]); + const completedInteractiveFiles = React.useMemo( () => agentMessage.generatedFiles.filter((file) => @@ -90,6 +114,22 @@ export function useAutoOpenSidePanel({ return; } + if ( + latestFilesSidePanelControl && + handledFilesSidePanelControlRef.current !== + latestFilesSidePanelControl.actionId + ) { + if (latestFilesSidePanelControl.action === "open") { + handledFilesSidePanelControlRef.current = + latestFilesSidePanelControl.actionId; + openPanel({ type: FILES_SIDE_PANEL_TYPE }); + } else if (currentPanel === FILES_SIDE_PANEL_TYPE) { + handledFilesSidePanelControlRef.current = + latestFilesSidePanelControl.actionId; + closePanel(); + } + } + // Priority 1: interactive content drawer (covers streaming and completed states). if (interactiveFilesFromProgress.length > 0) { const [firstFile] = interactiveFilesFromProgress; @@ -116,6 +156,10 @@ export function useAutoOpenSidePanel({ return; } + if (latestFilesSidePanelControl?.action === "close") { + return; + } + // Priority 2: file explorer — only when no interactive content is taking the panel. if ( regularGeneratedFiles.length === 0 || @@ -134,7 +178,9 @@ export function useAutoOpenSidePanel({ regularGeneratedFiles, isLastMessage, agentMessage.sId, + latestFilesSidePanelControl, openPanel, + closePanel, currentPanel, isMobile, ]); From 3cdeaa05aee51cb74db09c2ac01c12114316f1f9 Mon Sep 17 00:00:00 2001 From: Frank Aloia Date: Mon, 10 Aug 2026 14:29:44 -0400 Subject: [PATCH 8/9] fix: hide side panel controls from the public API Keep the UI-only progress signal out of v1 responses while adapting it to the rebased event type. Co-authored-by: Cursor --- front/lib/api/v1/backward_compatibility.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/front/lib/api/v1/backward_compatibility.ts b/front/lib/api/v1/backward_compatibility.ts index 5caecad2f169..b4a6088f7cc2 100644 --- a/front/lib/api/v1/backward_compatibility.ts +++ b/front/lib/api/v1/backward_compatibility.ts @@ -1,4 +1,7 @@ -import { isRunAgentQueryProgressOutput } from "@app/lib/actions/mcp_internal_actions/output_schemas"; +import { + isRunAgentQueryProgressOutput, + isSidePanelControlOutput, +} from "@app/lib/actions/mcp_internal_actions/output_schemas"; import type { MessageStreamEvent } from "@app/lib/api/assistant/pubsub"; import config from "@app/lib/api/config"; import type { Authenticator } from "@app/lib/auth"; @@ -196,7 +199,10 @@ export function toPublicAgentMessageEvent( event.data.notification._meta.data; let output; - if (isRunAgentQueryProgressOutput(originalOutput)) { + if (isSidePanelControlOutput(originalOutput)) { + // UI-only signal — not exposed on the public v1 API. + output = undefined; + } else if (isRunAgentQueryProgressOutput(originalOutput)) { const wId = auth.getNonNullableWorkspace().sId; const { conversationId, agentMessageId } = originalOutput; const childConversationUrl = `${config.getApiBaseUrl()}/api/v1/w/${wId}/assistant/conversations/${conversationId}`; @@ -211,6 +217,9 @@ export function toPublicAgentMessageEvent( output = originalOutput; } + // Cast: this is the internal→public boundary. The internal type carries + // output variants (e.g. side_panel_control) that post-date the installed + // SDK. Casting is safe here because the handler is the mapping layer. return { eventId: event.eventId, data: { @@ -224,7 +233,7 @@ export function toPublicAgentMessageEvent( }, }, }, - }; + } as AgentMessageEventType; } return { From 883093f5ac5431097451fec28532a05b91520469 Mon Sep 17 00:00:00 2001 From: Frank Aloia Date: Mon, 10 Aug 2026 14:58:32 -0400 Subject: [PATCH 9/9] Persist files panel visibility Honor completed panel-control actions after a conversation reload so generated files cannot reopen the files panel against the agent's instruction. Co-authored-by: Cursor --- .../conversation/useAutoOpenSidePanel.ts | 122 ++++++++++++------ 1 file changed, 82 insertions(+), 40 deletions(-) diff --git a/front/components/assistant/conversation/useAutoOpenSidePanel.ts b/front/components/assistant/conversation/useAutoOpenSidePanel.ts index 7931bb9aaed0..ffc3e81a3d76 100644 --- a/front/components/assistant/conversation/useAutoOpenSidePanel.ts +++ b/front/components/assistant/conversation/useAutoOpenSidePanel.ts @@ -1,10 +1,15 @@ import { useConversationSidePanelContext } from "@app/components/assistant/conversation/ConversationSidePanelContext"; import type { AgentMessageWithStreaming } from "@app/components/assistant/conversation/types"; +import { useConversationMessageAction } from "@app/hooks/conversations"; +import { useActiveConversationId } from "@app/hooks/useActiveConversationId"; +import { isInteractiveContentFileContentOutput } from "@app/lib/actions/mcp_internal_actions/output_schemas"; import { - isInteractiveContentFileContentOutput, - isSidePanelControlOutput, -} from "@app/lib/actions/mcp_internal_actions/output_schemas"; + CONVERSATION_SIDE_PANEL_SERVER_NAME, + SET_FILES_SIDE_PANEL_TOOL_NAME, +} from "@app/lib/api/actions/servers/conversation_side_panel/metadata"; +import { useAuth } from "@app/lib/auth/AuthContext"; import { useIsMobile } from "@app/lib/swr/useIsMobile"; +import type { AgentMCPActionWithOutputType } from "@app/types/actions"; import { FILES_SIDE_PANEL_TYPE } from "@app/types/conversation_side_panel"; import { isInteractiveContentType } from "@app/types/files"; import { removeNulls } from "@app/types/shared/utils/general"; @@ -15,6 +20,52 @@ interface UseAutoOpenSidePanelProps { agentMessage: AgentMessageWithStreaming; } +function isSetFilesSidePanelAction( + action: Pick & { + toolName: string | null; + } +): boolean { + return ( + action.internalMCPServerName === CONVERSATION_SIDE_PANEL_SERVER_NAME && + action.toolName === SET_FILES_SIDE_PANEL_TOOL_NAME + ); +} + +function getFilesSidePanelVisibility( + actions: readonly AgentMCPActionWithOutputType[] +): boolean | undefined { + let latestAction: AgentMCPActionWithOutputType | undefined; + + for (const action of actions) { + if ( + isSetFilesSidePanelAction(action) && + action.status === "succeeded" && + typeof action.params.visible === "boolean" && + (!latestAction || action.updatedAt > latestAction.updatedAt) + ) { + latestAction = action; + } + } + + return typeof latestAction?.params.visible === "boolean" + ? latestAction.params.visible + : undefined; +} + +function getLatestFilesSidePanelActionId( + agentMessage: AgentMessageWithStreaming +): string | null { + let actionId: string | null = null; + + for (const step of agentMessage.activitySteps) { + if (step.type === "action" && isSetFilesSidePanelAction(step)) { + actionId = step.actionId; + } + } + + return actionId; +} + /** * Auto-opens the appropriate side panel when the agent generates files. * @@ -31,6 +82,8 @@ export function useAutoOpenSidePanel({ }: UseAutoOpenSidePanelProps) { const { openPanel, closePanel, currentPanel } = useConversationSidePanelContext(); + const { workspace } = useAuth(); + const conversationId = useActiveConversationId(); const isMobile = useIsMobile(); // Track the last opened fileId to prevent double-opening glitch. @@ -50,7 +103,6 @@ export function useAutoOpenSidePanel({ // Track which message sId last triggered file-panel auto-open to open only once per message. const autoOpenedFilesForRef = React.useRef(null); - const handledFilesSidePanelControlRef = React.useRef(null); const interactiveFilesFromProgress = React.useMemo( () => @@ -68,24 +120,6 @@ export function useAutoOpenSidePanel({ [agentMessage.streaming.actionProgress] ); - const latestFilesSidePanelControl = React.useMemo(() => { - let latestControl: - | { - actionId: number; - action: "open" | "close"; - } - | undefined; - - for (const [actionId, progress] of agentMessage.streaming.actionProgress) { - const output = progress.progress?._meta.data.output; - if (isSidePanelControlOutput(output) && output.panel === "files") { - latestControl = { actionId, action: output.action }; - } - } - - return latestControl; - }, [agentMessage.streaming.actionProgress]); - const completedInteractiveFiles = React.useMemo( () => agentMessage.generatedFiles.filter((file) => @@ -102,6 +136,22 @@ export function useAutoOpenSidePanel({ [agentMessage.generatedFiles] ); + const filesSidePanelVisibilityFromActions = getFilesSidePanelVisibility( + agentMessage.actions + ); + const filesSidePanelActionId = getLatestFilesSidePanelActionId(agentMessage); + const { action: filesSidePanelAction } = useConversationMessageAction({ + conversationId: conversationId ?? "", + workspaceId: workspace.sId, + messageId: agentMessage.sId, + actionId: conversationId ? filesSidePanelActionId : null, + }); + const filesSidePanelVisibility = + filesSidePanelVisibilityFromActions ?? + getFilesSidePanelVisibility( + filesSidePanelAction ? [filesSidePanelAction] : [] + ); + // Reset interactive tracking when the message changes. // biome-ignore lint/correctness/useExhaustiveDependencies: ignored using `--suppress` React.useEffect(() => { @@ -114,22 +164,6 @@ export function useAutoOpenSidePanel({ return; } - if ( - latestFilesSidePanelControl && - handledFilesSidePanelControlRef.current !== - latestFilesSidePanelControl.actionId - ) { - if (latestFilesSidePanelControl.action === "open") { - handledFilesSidePanelControlRef.current = - latestFilesSidePanelControl.actionId; - openPanel({ type: FILES_SIDE_PANEL_TYPE }); - } else if (currentPanel === FILES_SIDE_PANEL_TYPE) { - handledFilesSidePanelControlRef.current = - latestFilesSidePanelControl.actionId; - closePanel(); - } - } - // Priority 1: interactive content drawer (covers streaming and completed states). if (interactiveFilesFromProgress.length > 0) { const [firstFile] = interactiveFilesFromProgress; @@ -156,7 +190,15 @@ export function useAutoOpenSidePanel({ return; } - if (latestFilesSidePanelControl?.action === "close") { + if (isLastMessage && filesSidePanelVisibility === false) { + if (currentPanel === FILES_SIDE_PANEL_TYPE) { + closePanel(); + } + return; + } + + if (isLastMessage && filesSidePanelVisibility === true) { + openPanel({ type: FILES_SIDE_PANEL_TYPE }); return; } @@ -176,9 +218,9 @@ export function useAutoOpenSidePanel({ completedInteractiveFiles, interactiveFilesFromProgress, regularGeneratedFiles, + filesSidePanelVisibility, isLastMessage, agentMessage.sId, - latestFilesSidePanelControl, openPanel, closePanel, currentPanel,