Skip to content

Commit d82014e

Browse files
caseprinceclaude
andcommitted
spec(draft): add split display mode
Add a fourth McpUiDisplayMode, "split": the View is displayed in a persistent, non-overlapping region while the host's primary conversational interface remains visible and interactive. This keeps interactive Views (spreadsheets, maps, dashboards) referenceable as the conversation scrolls on, instead of losing them off-screen. - spec.types.ts + draft spec: define "split" semantics and non-goals - regenerate Zod/JSON schemas via `npm run generate:schemas` - debug-server: add a "Split" display-mode control - basic-host: advertise "split" and render the View in a resizable, docked region without remounting the iframe, so View state survives inline <-> split transitions - unit + E2E coverage for negotiation and transitions Prototype for #684; relates to #412 and #430. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 92f46a5 commit d82014e

12 files changed

Lines changed: 365 additions & 18 deletions

File tree

examples/basic-host/src/global.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,9 @@ html, body {
3838
code {
3939
font-size: 1em;
4040
}
41+
42+
/* While a View is in split display mode, reserve the right-hand region for it
43+
so the conversation column and the split View never overlap. */
44+
body.split-view-active {
45+
margin-right: var(--split-view-width, 40vw);
46+
}

examples/basic-host/src/implementation.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -262,15 +262,19 @@ function hookInitializedCallback(appBridge: AppBridge): Promise<void> {
262262
export type ModelContext = McpUiUpdateModelContextRequest["params"];
263263
export type AppMessage = McpUiMessageRequest["params"];
264264

265+
/** Display modes this host supports and advertises to apps. */
266+
export type HostDisplayMode = "inline" | "fullscreen" | "split";
267+
export const HOST_AVAILABLE_DISPLAY_MODES: HostDisplayMode[] = ["inline", "fullscreen", "split"];
268+
265269
export interface AppBridgeCallbacks {
266270
onContextUpdate?: (context: ModelContext | null) => void;
267271
onMessage?: (message: AppMessage) => void;
268-
onDisplayModeChange?: (mode: "inline" | "fullscreen") => void;
272+
onDisplayModeChange?: (mode: HostDisplayMode) => void;
269273
}
270274

271275
export interface AppBridgeOptions {
272276
containerDimensions?: { maxHeight?: number; width?: number } | { height: number; width?: number };
273-
displayMode?: "inline" | "fullscreen";
277+
displayMode?: HostDisplayMode;
274278
}
275279

276280
export function newAppBridge(
@@ -296,7 +300,7 @@ export function newAppBridge(
296300
},
297301
containerDimensions: options?.containerDimensions ?? { maxHeight: 6000 },
298302
displayMode: options?.displayMode ?? "inline",
299-
availableDisplayModes: ["inline", "fullscreen"],
303+
availableDisplayModes: HOST_AVAILABLE_DISPLAY_MODES,
300304
},
301305
});
302306

@@ -395,7 +399,9 @@ export function newAppBridge(
395399
// Handle display mode change requests from the app
396400
appBridge.onrequestdisplaymode = async (params) => {
397401
log.info("Display mode request from MCP App:", params);
398-
const newMode = params.mode === "fullscreen" ? "fullscreen" : "inline";
402+
const newMode = (HOST_AVAILABLE_DISPLAY_MODES as string[]).includes(params.mode)
403+
? (params.mode as HostDisplayMode)
404+
: "inline";
399405
// Update host context and notify the app
400406
appBridge.sendHostContextChange({
401407
displayMode: newMode,

examples/basic-host/src/index.module.css

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,54 @@
183183
border-radius: 0;
184184
}
185185
}
186+
187+
/* Persistent, non-overlapping region docked to the right; the host's
188+
conversation column shifts aside via body.split-view-active (global.css)
189+
and stays visible and interactive. */
190+
&.split {
191+
position: fixed;
192+
top: 0;
193+
right: 0;
194+
bottom: 0;
195+
width: var(--split-view-width, 40vw);
196+
z-index: 900;
197+
margin: 0;
198+
padding: 1rem;
199+
max-width: none;
200+
background: var(--color-bg);
201+
border: none;
202+
border-left: 1px solid var(--color-border);
203+
border-radius: 0;
204+
display: flex;
205+
flex-direction: column;
206+
207+
/* The split region is dedicated to the View */
208+
.collapsiblePanel {
209+
display: none;
210+
}
211+
212+
iframe {
213+
flex: 1;
214+
height: 100%;
215+
border: none;
216+
border-radius: 0;
217+
}
218+
}
219+
}
220+
221+
.splitResizeHandle {
222+
position: absolute;
223+
top: 0;
224+
left: -3px;
225+
width: 6px;
226+
height: 100%;
227+
cursor: col-resize;
228+
touch-action: none;
229+
user-select: none;
230+
231+
&:hover {
232+
background: var(--color-primary);
233+
}
186234
}
187235

188236
.appToolbar {

examples/basic-host/src/index.tsx

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { getToolUiResourceUri, McpUiToolMetaSchema } from "@modelcontextprotocol
22
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
33
import { Component, type ErrorInfo, type ReactNode, StrictMode, Suspense, use, useEffect, useMemo, useRef, useState } from "react";
44
import { createRoot } from "react-dom/client";
5-
import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo, type ModelContext, type AppMessage } from "./implementation";
5+
import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo, type ModelContext, type AppMessage, type HostDisplayMode } from "./implementation";
66
import { getTheme, toggleTheme, onThemeChange, type Theme } from "./theme";
77
import styles from "./index.module.css";
88

@@ -417,6 +417,50 @@ function CollapsiblePanel({ icon, label, content, badge, defaultExpanded = false
417417
}
418418

419419

420+
// Reserve the split region on <body> while at least one View is in split
421+
// mode, so the host's conversation column shifts aside instead of being
422+
// overlapped. Counted so multiple panels (and Strict Mode re-runs) compose.
423+
let splitViewCount = 0;
424+
function acquireSplitRegion(): () => void {
425+
if (++splitViewCount === 1) {
426+
document.body.classList.add("split-view-active");
427+
}
428+
return () => {
429+
if (--splitViewCount === 0) {
430+
document.body.classList.remove("split-view-active");
431+
document.documentElement.style.removeProperty("--split-view-width");
432+
}
433+
};
434+
}
435+
436+
// Keep the split region within sensible bounds: wide enough to be useful,
437+
// narrow enough that the conversation column stays usable.
438+
function setSplitViewWidth(clientX: number) {
439+
const width = Math.min(
440+
Math.max(window.innerWidth - clientX, 280),
441+
window.innerWidth - 320,
442+
);
443+
document.documentElement.style.setProperty("--split-view-width", `${width}px`);
444+
}
445+
446+
function SplitResizeHandle() {
447+
return (
448+
<div
449+
className={styles.splitResizeHandle}
450+
title="Drag to resize"
451+
onPointerDown={(e) => {
452+
e.preventDefault();
453+
e.currentTarget.setPointerCapture(e.pointerId);
454+
}}
455+
onPointerMove={(e) => {
456+
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
457+
setSplitViewWidth(e.clientX);
458+
}
459+
}}
460+
/>
461+
);
462+
}
463+
420464
interface AppIFramePanelProps {
421465
toolCallInfo: Required<ToolCallInfo>;
422466
isDestroying?: boolean;
@@ -427,7 +471,14 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
427471
const appBridgeRef = useRef<ReturnType<typeof newAppBridge> | null>(null);
428472
const [modelContext, setModelContext] = useState<ModelContext | null>(null);
429473
const [messages, setMessages] = useState<AppMessage[]>([]);
430-
const [displayMode, setDisplayMode] = useState<"inline" | "fullscreen">("inline");
474+
const [displayMode, setDisplayMode] = useState<HostDisplayMode>("inline");
475+
476+
// Reserve the split region while active. Only the panel's className
477+
// changes, so the iframe is never remounted and View state survives.
478+
useEffect(() => {
479+
if (displayMode !== "split") return;
480+
return acquireSplitRegion();
481+
}, [displayMode]);
431482

432483
useEffect(() => {
433484
const iframe = iframeRef.current!;
@@ -510,12 +561,17 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
510561
};
511562
const messagesText = messages.map(formatMessage).join("\n\n");
512563

513-
const panelClassName = displayMode === "fullscreen"
514-
? `${styles.appIframePanel} ${styles.fullscreen}`
515-
: styles.appIframePanel;
564+
const panelClassName = [
565+
styles.appIframePanel,
566+
displayMode === "fullscreen" && styles.fullscreen,
567+
displayMode === "split" && styles.split,
568+
]
569+
.filter(Boolean)
570+
.join(" ");
516571

517572
return (
518573
<div className={panelClassName}>
574+
{displayMode === "split" && <SplitResizeHandle />}
519575
<iframe ref={iframeRef} />
520576
{messages.length > 0 && (
521577
<CollapsiblePanel

examples/debug-server/mcp-app.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ <h3>Display Mode</h3>
134134
<button id="display-inline-btn" class="btn-small">Inline</button>
135135
<button id="display-fullscreen-btn" class="btn-small">Fullscreen</button>
136136
<button id="display-pip-btn" class="btn-small">PiP</button>
137+
<button id="display-split-btn" class="btn-small">Split</button>
137138
</div>
138139
</div>
139140

examples/debug-server/src/mcp-app.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ const updateContextImageBtn = document.getElementById(
9999
const displayInlineBtn = document.getElementById("display-inline-btn")!;
100100
const displayFullscreenBtn = document.getElementById("display-fullscreen-btn")!;
101101
const displayPipBtn = document.getElementById("display-pip-btn")!;
102+
const displaySplitBtn = document.getElementById("display-split-btn")!;
102103

103104
const linkUrlEl = document.getElementById("link-url") as HTMLInputElement;
104105
const openLinkBtn = document.getElementById("open-link-btn")!;
@@ -347,7 +348,10 @@ function handleHostContextChanged(ctx: McpUiHostContext): void {
347348

348349
const app = new App(
349350
{ name: "Debug App", version: "1.0.0" },
350-
{ tools: { listChanged: true } }, // Declare tools capability for oncalltool/onlisttools
351+
{
352+
tools: { listChanged: true }, // Declare tools capability for oncalltool/onlisttools
353+
availableDisplayModes: ["inline", "fullscreen", "pip", "split"],
354+
},
351355
{ autoResize: false }, // We'll manage auto-resize ourselves for toggle demo
352356
);
353357

@@ -514,7 +518,7 @@ updateContextImageBtn.addEventListener("click", async () => {
514518
// ============================================================================
515519

516520
async function requestDisplayMode(
517-
mode: "inline" | "fullscreen" | "pip",
521+
mode: "inline" | "fullscreen" | "pip" | "split",
518522
): Promise<void> {
519523
try {
520524
const result = await app.requestDisplayMode({ mode });
@@ -529,6 +533,7 @@ displayFullscreenBtn.addEventListener("click", () =>
529533
requestDisplayMode("fullscreen"),
530534
);
531535
displayPipBtn.addEventListener("click", () => requestDisplayMode("pip"));
536+
displaySplitBtn.addEventListener("click", () => requestDisplayMode("split"));
532537

533538
// ============================================================================
534539
// Link Action

specification/draft/apps.mdx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,7 @@ interface McpUiAppCapabilities {
559559
* Display modes the app supports. See Display Modes section for details.
560560
* @example ["inline", "fullscreen"]
561561
*/
562-
availableDisplayModes?: Array<"inline" | "fullscreen" | "pip">;
562+
availableDisplayModes?: Array<"inline" | "fullscreen" | "pip" | "split">;
563563
}
564564
```
565565

@@ -589,7 +589,7 @@ interface HostContext {
589589
};
590590
};
591591
/** How the View is currently displayed */
592-
displayMode?: "inline" | "fullscreen" | "pip";
592+
displayMode?: "inline" | "fullscreen" | "pip" | "split";
593593
/** Display modes the host supports */
594594
availableDisplayModes?: string[];
595595
/** Container dimensions for the iframe. Specify either width or maxWidth, and either height or maxHeight. */
@@ -805,12 +805,23 @@ Views using the SDK automatically send size-changed notifications via ResizeObse
805805
Views can be displayed in different modes depending on the host's capabilities and the view's declared support.
806806

807807
```typescript
808-
type McpUiDisplayMode = "inline" | "fullscreen" | "pip";
808+
type McpUiDisplayMode = "inline" | "fullscreen" | "pip" | "split";
809809
```
810810

811811
- **inline**: Default mode, embedded within the host's content flow
812812
- **fullscreen**: View takes over the full screen/window
813813
- **pip**: Picture-in-picture, floating overlay
814+
- **split**: View is displayed in a persistent, non-overlapping region while the host's primary conversational interface remains visible and interactive
815+
816+
#### Split Mode
817+
818+
In `split` mode, the View occupies a dedicated region of the host UI (e.g., a side panel) that does not overlap the host's primary conversational interface. Both remain visible and interactive at the same time, allowing users to keep referencing the View as the conversation continues.
819+
820+
- Host controls the orientation, placement, and dimensions of the split region, how (and whether) it can be resized, and how many split Views it permits at once.
821+
- Entering or leaving `split` mode SHOULD NOT inherently recreate the current View: it is a presentation change of the already-rendered View, and its state SHOULD be preserved across the transition.
822+
- This mode does not define reusing Views across separate tool calls; each tool call still renders a new View instance.
823+
824+
`split` is additive: existing hosts and apps are unaffected.
814825

815826
#### Declaring Support
816827

@@ -1177,7 +1188,7 @@ Host behavior:
11771188
id: 3,
11781189
method: "ui/request-display-mode",
11791190
params: {
1180-
mode: "inline" | "fullscreen" | "pip" // Requested display mode
1191+
mode: "inline" | "fullscreen" | "pip" | "split" // Requested display mode
11811192
}
11821193
}
11831194

@@ -1186,7 +1197,7 @@ Host behavior:
11861197
jsonrpc: "2.0",
11871198
id: 3,
11881199
result: {
1189-
mode: "inline" | "fullscreen" | "pip" // Actual display mode set
1200+
mode: "inline" | "fullscreen" | "pip" | "split" // Actual display mode set
11901201
}
11911202
}
11921203
```

src/app-bridge.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,78 @@ describe("App <-> AppBridge integration", () => {
624624
});
625625
});
626626

627+
describe("display mode negotiation", () => {
628+
it("app.requestDisplayMode round-trips the granted mode through bridge.onrequestdisplaymode", async () => {
629+
const requestedModes: string[] = [];
630+
bridge.onrequestdisplaymode = async ({ mode }) => {
631+
requestedModes.push(mode);
632+
return { mode };
633+
};
634+
635+
await bridge.connect(bridgeTransport);
636+
await app.connect(appTransport);
637+
const result = await app.requestDisplayMode({ mode: "split" });
638+
639+
expect(requestedModes).toEqual(["split"]);
640+
expect(result.mode).toBe("split");
641+
});
642+
643+
it("default handler returns the current mode from host context", async () => {
644+
const [newAppTransport, newBridgeTransport] =
645+
InMemoryTransport.createLinkedPair();
646+
const bridgeWithContext = new AppBridge(
647+
createMockClient() as Client,
648+
testHostInfo,
649+
testHostCapabilities,
650+
{ hostContext: { displayMode: "fullscreen" } },
651+
);
652+
653+
await bridgeWithContext.connect(newBridgeTransport);
654+
await app.connect(newAppTransport);
655+
const result = await app.requestDisplayMode({ mode: "split" });
656+
657+
expect(result.mode).toBe("fullscreen");
658+
659+
await newAppTransport.close();
660+
await newBridgeTransport.close();
661+
});
662+
663+
it("app and host can declare split in availableDisplayModes", async () => {
664+
const [newAppTransport, newBridgeTransport] =
665+
InMemoryTransport.createLinkedPair();
666+
const splitApp = new App(
667+
testAppInfo,
668+
{ availableDisplayModes: ["inline", "split"] },
669+
{ autoResize: false },
670+
);
671+
const bridgeWithContext = new AppBridge(
672+
createMockClient() as Client,
673+
testHostInfo,
674+
testHostCapabilities,
675+
{
676+
hostContext: {
677+
availableDisplayModes: ["inline", "fullscreen", "split"],
678+
},
679+
},
680+
);
681+
682+
await bridgeWithContext.connect(newBridgeTransport);
683+
await splitApp.connect(newAppTransport);
684+
685+
expect(
686+
bridgeWithContext.getAppCapabilities()?.availableDisplayModes,
687+
).toEqual(["inline", "split"]);
688+
expect(splitApp.getHostContext()?.availableDisplayModes).toEqual([
689+
"inline",
690+
"fullscreen",
691+
"split",
692+
]);
693+
694+
await newAppTransport.close();
695+
await newBridgeTransport.close();
696+
});
697+
});
698+
627699
describe("deprecated method aliases", () => {
628700
beforeEach(async () => {
629701
await bridge.connect(bridgeTransport);

0 commit comments

Comments
 (0)