feat(basic-host): URL params, fullscreen mode, unified layout (#266)
* basic-host: handle containerDimensions * feat(basic-host): Unified tool call layout with shared collapsible panels * snapshots * format
|
|
@ -228,12 +228,19 @@ export type AppMessage = McpUiMessageRequest["params"];
|
|||
export interface AppBridgeCallbacks {
|
||||
onContextUpdate?: (context: ModelContext | null) => void;
|
||||
onMessage?: (message: AppMessage) => void;
|
||||
onDisplayModeChange?: (mode: "inline" | "fullscreen") => void;
|
||||
}
|
||||
|
||||
export interface AppBridgeOptions {
|
||||
containerDimensions?: { maxHeight?: number; width?: number } | { height: number; width?: number };
|
||||
displayMode?: "inline" | "fullscreen";
|
||||
}
|
||||
|
||||
export function newAppBridge(
|
||||
serverInfo: ServerInfo,
|
||||
iframe: HTMLIFrameElement,
|
||||
callbacks?: AppBridgeCallbacks,
|
||||
options?: AppBridgeOptions,
|
||||
): AppBridge {
|
||||
const serverCapabilities = serverInfo.client.getServerCapabilities();
|
||||
const appBridge = new AppBridge(serverInfo.client, IMPLEMENTATION, {
|
||||
|
|
@ -242,6 +249,12 @@ export function newAppBridge(
|
|||
serverResources: serverCapabilities?.resources,
|
||||
// Declare support for model context updates
|
||||
updateModelContext: { text: {} },
|
||||
}, {
|
||||
hostContext: {
|
||||
containerDimensions: options?.containerDimensions ?? { maxHeight: 600 },
|
||||
displayMode: options?.displayMode ?? "inline",
|
||||
availableDisplayModes: ["inline", "fullscreen"],
|
||||
},
|
||||
});
|
||||
|
||||
// Register all handlers before calling connect(). The Guest UI can start
|
||||
|
|
@ -308,5 +321,18 @@ export function newAppBridge(
|
|||
iframe.animate([from, to], { duration: 300, easing: "ease-out" });
|
||||
};
|
||||
|
||||
// Handle display mode change requests from the app
|
||||
appBridge.onrequestdisplaymode = async (params) => {
|
||||
log.info("Display mode request from MCP App:", params);
|
||||
const newMode = params.mode === "fullscreen" ? "fullscreen" : "inline";
|
||||
// Update host context and notify the app
|
||||
appBridge.sendHostContextChange({
|
||||
displayMode: newMode,
|
||||
});
|
||||
// Notify the host UI (via callback)
|
||||
callbacks?.onDisplayModeChange?.(newMode);
|
||||
return { mode: newMode };
|
||||
};
|
||||
|
||||
return appBridge;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@
|
|||
|
||||
.toolCallInfoPanel {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
|
|
@ -80,57 +81,6 @@
|
|||
from { opacity: 0; transform: translateY(-12px); }
|
||||
}
|
||||
|
||||
.inputInfoPanel {
|
||||
display: flex;
|
||||
flex: 3;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
position: relative;
|
||||
|
||||
.toolName {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: #e0e0e0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #d0d0d0;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.outputInfoPanel {
|
||||
flex: 4;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.appOutputPanel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.appHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -166,16 +116,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.jsonBlock {
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
background-color: #f5f5f5;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.appIframePanel {
|
||||
min-height: 200px;
|
||||
|
||||
|
|
@ -186,6 +126,62 @@
|
|||
border: 3px dashed #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&.fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
max-width: none;
|
||||
background: white;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* Hide collapsible panels in fullscreen */
|
||||
.collapsiblePanel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
iframe {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.appToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.collapsiblePanel {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fullscreenButton {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #f5f5f5;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
}
|
||||
|
||||
.collapsiblePanel {
|
||||
|
|
|
|||
|
|
@ -54,9 +54,20 @@ interface HostProps {
|
|||
type ToolCallEntry = ToolCallInfo & { id: number };
|
||||
let nextToolCallId = 0;
|
||||
|
||||
// Parse URL query params for debugging: ?server=name&tool=name&call=true
|
||||
function getQueryParams() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return {
|
||||
server: params.get("server"),
|
||||
tool: params.get("tool"),
|
||||
call: params.get("call") === "true",
|
||||
};
|
||||
}
|
||||
|
||||
function Host({ serversPromise }: HostProps) {
|
||||
const [toolCalls, setToolCalls] = useState<ToolCallEntry[]>([]);
|
||||
const [destroyingIds, setDestroyingIds] = useState<Set<number>>(new Set());
|
||||
const queryParams = useMemo(() => getQueryParams(), []);
|
||||
|
||||
const requestClose = (id: number) => {
|
||||
setDestroyingIds((s) => new Set(s).add(id));
|
||||
|
|
@ -85,6 +96,9 @@ function Host({ serversPromise }: HostProps) {
|
|||
<CallToolPanel
|
||||
serversPromise={serversPromise}
|
||||
addToolCall={(info) => setToolCalls([...toolCalls, { ...info, id: nextToolCallId++ }])}
|
||||
initialServer={queryParams.server}
|
||||
initialTool={queryParams.tool}
|
||||
autoCall={queryParams.call}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
@ -95,11 +109,15 @@ function Host({ serversPromise }: HostProps) {
|
|||
interface CallToolPanelProps {
|
||||
serversPromise: Promise<ServerInfo[]>;
|
||||
addToolCall: (info: ToolCallInfo) => void;
|
||||
initialServer?: string | null;
|
||||
initialTool?: string | null;
|
||||
autoCall?: boolean;
|
||||
}
|
||||
function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
|
||||
function CallToolPanel({ serversPromise, addToolCall, initialServer, initialTool, autoCall }: CallToolPanelProps) {
|
||||
const [selectedServer, setSelectedServer] = useState<ServerInfo | null>(null);
|
||||
const [selectedTool, setSelectedTool] = useState("");
|
||||
const [inputJson, setInputJson] = useState("{}");
|
||||
const [hasAutoCalledRef] = useState({ called: false });
|
||||
|
||||
// Filter out app-only tools, prioritize tools with UIs
|
||||
const toolNames = selectedServer
|
||||
|
|
@ -118,16 +136,21 @@ function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
|
|||
}
|
||||
}, [inputJson]);
|
||||
|
||||
const handleServerSelect = (server: ServerInfo) => {
|
||||
const handleServerSelect = (server: ServerInfo, preferredTool?: string) => {
|
||||
setSelectedServer(server);
|
||||
// Filter out app-only tools, prioritize tools with UIs
|
||||
const visibleTools = Array.from(server.tools.values())
|
||||
.filter((tool) => isToolVisibleToModel(tool))
|
||||
.sort(compareTools);
|
||||
const firstTool = visibleTools[0]?.name ?? "";
|
||||
setSelectedTool(firstTool);
|
||||
|
||||
// Use preferred tool if it exists and is visible, otherwise first visible tool
|
||||
const targetTool = preferredTool && visibleTools.some(t => t.name === preferredTool)
|
||||
? preferredTool
|
||||
: visibleTools[0]?.name ?? "";
|
||||
|
||||
setSelectedTool(targetTool);
|
||||
// Set input JSON to tool defaults (if any)
|
||||
setInputJson(getToolDefaults(server.tools.get(firstTool)));
|
||||
setInputJson(getToolDefaults(server.tools.get(targetTool)));
|
||||
};
|
||||
|
||||
const handleToolSelect = (toolName: string) => {
|
||||
|
|
@ -136,10 +159,21 @@ function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
|
|||
setInputJson(getToolDefaults(selectedServer?.tools.get(toolName)));
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedServer) return;
|
||||
const toolCallInfo = callTool(selectedServer, selectedTool, JSON.parse(inputJson));
|
||||
// Submit with optional override for server/tool (used by auto-call)
|
||||
const handleSubmit = (overrideServer?: ServerInfo, overrideTool?: string) => {
|
||||
const server = overrideServer ?? selectedServer;
|
||||
const tool = overrideTool ?? selectedTool;
|
||||
if (!server) return;
|
||||
|
||||
const toolCallInfo = callTool(server, tool, JSON.parse(inputJson));
|
||||
addToolCall(toolCallInfo);
|
||||
|
||||
// Update URL for easy refresh/sharing (without triggering navigation)
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("server", server.name);
|
||||
url.searchParams.set("tool", tool);
|
||||
url.searchParams.set("call", "true"); // Auto-call on refresh
|
||||
history.replaceState(null, "", url.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -148,7 +182,17 @@ function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
|
|||
<label>
|
||||
Server
|
||||
<Suspense fallback={<select disabled><option>Loading...</option></select>}>
|
||||
<ServerSelect serversPromise={serversPromise} onSelect={handleServerSelect} />
|
||||
<ServerSelect
|
||||
serversPromise={serversPromise}
|
||||
onSelect={handleServerSelect}
|
||||
initialServer={initialServer}
|
||||
initialTool={initialTool}
|
||||
autoCall={autoCall && !hasAutoCalledRef.called}
|
||||
onAutoCall={(server, tool) => {
|
||||
hasAutoCalledRef.called = true;
|
||||
handleSubmit(server, tool);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</label>
|
||||
<label>
|
||||
|
|
@ -184,17 +228,47 @@ function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
|
|||
// ServerSelect calls use() and renders the server <select>
|
||||
interface ServerSelectProps {
|
||||
serversPromise: Promise<ServerInfo[]>;
|
||||
onSelect: (server: ServerInfo) => void;
|
||||
onSelect: (server: ServerInfo, toolName?: string) => void;
|
||||
initialServer?: string | null;
|
||||
initialTool?: string | null;
|
||||
autoCall?: boolean;
|
||||
onAutoCall?: (server: ServerInfo, tool: string) => void;
|
||||
}
|
||||
function ServerSelect({ serversPromise, onSelect }: ServerSelectProps) {
|
||||
function ServerSelect({ serversPromise, onSelect, initialServer, initialTool, autoCall, onAutoCall }: ServerSelectProps) {
|
||||
const servers = use(serversPromise);
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// Initialize with the correct server/tool when servers are loaded
|
||||
useEffect(() => {
|
||||
if (servers.length > selectedIndex) {
|
||||
onSelect(servers[selectedIndex]);
|
||||
if (hasInitialized || servers.length === 0) return;
|
||||
|
||||
// Find initial server index if specified
|
||||
let idx = 0;
|
||||
if (initialServer) {
|
||||
const foundIdx = servers.findIndex(s => s.name === initialServer);
|
||||
if (foundIdx >= 0) idx = foundIdx;
|
||||
}
|
||||
}, [servers]);
|
||||
|
||||
const server = servers[idx];
|
||||
setSelectedIndex(idx);
|
||||
|
||||
// Find the tool to use
|
||||
const visibleTools = Array.from(server.tools.values())
|
||||
.filter((tool) => isToolVisibleToModel(tool))
|
||||
.sort(compareTools);
|
||||
const targetTool = initialTool && visibleTools.some(t => t.name === initialTool)
|
||||
? initialTool
|
||||
: visibleTools[0]?.name ?? "";
|
||||
|
||||
onSelect(server, targetTool);
|
||||
setHasInitialized(true);
|
||||
|
||||
// Auto-call after initial selection if requested
|
||||
if (autoCall && targetTool) {
|
||||
onAutoCall?.(server, targetTool);
|
||||
}
|
||||
}, [servers, hasInitialized, initialServer, initialTool, autoCall, onSelect, onAutoCall]);
|
||||
|
||||
if (servers.length === 0) {
|
||||
return <select disabled><option>No servers configured</option></select>;
|
||||
|
|
@ -233,74 +307,54 @@ function ToolCallInfoPanel({ toolCallInfo, isDestroying, onRequestClose, onClose
|
|||
}
|
||||
}, [isDestroying, isApp, onCloseComplete]);
|
||||
|
||||
const inputJson = JSON.stringify(toolCallInfo.input, null, 2);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.toolCallInfoPanel}
|
||||
style={isDestroying ? { opacity: 0.5, pointerEvents: "none" } : undefined}
|
||||
>
|
||||
{/* For non-app tools, show input/output side by side */}
|
||||
{!isApp && (
|
||||
<div className={styles.inputInfoPanel}>
|
||||
<h2>
|
||||
<span>{toolCallInfo.serverInfo.name}</span>
|
||||
<span className={styles.toolName}>{toolCallInfo.tool.name}</span>
|
||||
{onRequestClose && !isDestroying && (
|
||||
<button
|
||||
className={styles.closeButton}
|
||||
onClick={onRequestClose}
|
||||
title="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</h2>
|
||||
<JsonBlock value={toolCallInfo.input} />
|
||||
</div>
|
||||
)}
|
||||
<div className={isApp ? styles.appOutputPanel : styles.outputInfoPanel}>
|
||||
{/* For apps, show header above the app: ServerName:tool_name */}
|
||||
{isApp && (
|
||||
<div className={styles.appHeader}>
|
||||
<span>{toolCallInfo.serverInfo.name}:<span className={styles.toolName}>{toolCallInfo.tool.name}</span></span>
|
||||
{onRequestClose && !isDestroying && (
|
||||
<button
|
||||
className={styles.closeButton}
|
||||
onClick={onRequestClose}
|
||||
title="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Row 1: Header with server:tool name and close button */}
|
||||
<div className={styles.appHeader}>
|
||||
<span>{toolCallInfo.serverInfo.name}:<span className={styles.toolName}>{toolCallInfo.tool.name}</span></span>
|
||||
{onRequestClose && !isDestroying && (
|
||||
<button
|
||||
className={styles.closeButton}
|
||||
onClick={onRequestClose}
|
||||
title="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Tool Input */}
|
||||
<CollapsiblePanel icon="📥" label="Tool Input" content={inputJson} />
|
||||
|
||||
{/* Row 3: App iframe (if app) */}
|
||||
{isApp && (
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback="Loading...">
|
||||
{
|
||||
isApp
|
||||
? <AppIFramePanel
|
||||
toolCallInfo={toolCallInfo}
|
||||
isDestroying={isDestroying}
|
||||
onTeardownComplete={onCloseComplete}
|
||||
/>
|
||||
: <ToolResultPanel toolCallInfo={toolCallInfo} />
|
||||
}
|
||||
<AppIFramePanel
|
||||
toolCallInfo={toolCallInfo}
|
||||
isDestroying={isDestroying}
|
||||
onTeardownComplete={onCloseComplete}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 4: Tool Result */}
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback="Loading result...">
|
||||
<ToolResultPanel toolCallInfo={toolCallInfo} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function JsonBlock({ value }: { value: object }) {
|
||||
return (
|
||||
<pre className={styles.jsonBlock}>
|
||||
<code>{JSON.stringify(value, null, 2)}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface CollapsiblePanelProps {
|
||||
icon: string;
|
||||
label: string;
|
||||
|
|
@ -347,8 +401,8 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
|
|||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const appBridgeRef = useRef<ReturnType<typeof newAppBridge> | null>(null);
|
||||
const [modelContext, setModelContext] = useState<ModelContext | null>(null);
|
||||
const [toolResult, setToolResult] = useState<object | null>(null);
|
||||
const [messages, setMessages] = useState<AppMessage[]>([]);
|
||||
const [displayMode, setDisplayMode] = useState<"inline" | "fullscreen">("inline");
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current!;
|
||||
|
|
@ -365,6 +419,11 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
|
|||
const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe, {
|
||||
onContextUpdate: setModelContext,
|
||||
onMessage: (msg) => setMessages((prev) => [...prev, msg]),
|
||||
onDisplayModeChange: setDisplayMode,
|
||||
}, {
|
||||
// Provide container dimensions - maxHeight for flexible sizing
|
||||
containerDimensions: { maxHeight: 600 },
|
||||
displayMode: "inline",
|
||||
});
|
||||
appBridgeRef.current = appBridge;
|
||||
initializeApp(iframe, appBridge, toolCallInfo);
|
||||
|
|
@ -372,8 +431,6 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
|
|||
});
|
||||
});
|
||||
|
||||
// Track tool result for display
|
||||
toolCallInfo.resultPromise.then(setToolResult).catch(() => {});
|
||||
}, [toolCallInfo]);
|
||||
|
||||
// Graceful teardown: wait for guest to respond before unmounting
|
||||
|
|
@ -421,9 +478,6 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
|
|||
: "";
|
||||
const fullContext = [contextText, contextJson].filter(Boolean).join("\n\n");
|
||||
|
||||
const inputJson = JSON.stringify(toolCallInfo.input, null, 2);
|
||||
const resultJson = toolResult ? JSON.stringify(toolResult, null, 2) : null;
|
||||
|
||||
// Format messages
|
||||
const formatMessage = (m: AppMessage) => {
|
||||
const content = m.content.map(formatContentBlock).join("\n");
|
||||
|
|
@ -431,13 +485,13 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
|
|||
};
|
||||
const messagesText = messages.map(formatMessage).join("\n\n");
|
||||
|
||||
const panelClassName = displayMode === "fullscreen"
|
||||
? `${styles.appIframePanel} ${styles.fullscreen}`
|
||||
: styles.appIframePanel;
|
||||
|
||||
return (
|
||||
<div className={styles.appIframePanel}>
|
||||
<CollapsiblePanel icon="📥" label="Tool Input" content={inputJson} />
|
||||
<div className={panelClassName}>
|
||||
<iframe ref={iframeRef} />
|
||||
{resultJson && (
|
||||
<CollapsiblePanel icon="📤" label="Tool Result" content={resultJson} />
|
||||
)}
|
||||
{messages.length > 0 && (
|
||||
<CollapsiblePanel
|
||||
icon="💬"
|
||||
|
|
@ -459,7 +513,8 @@ interface ToolResultPanelProps {
|
|||
}
|
||||
function ToolResultPanel({ toolCallInfo }: ToolResultPanelProps) {
|
||||
const result = use(toolCallInfo.resultPromise);
|
||||
return <JsonBlock value={result} />;
|
||||
const resultJson = JSON.stringify(result, null, 2);
|
||||
return <CollapsiblePanel icon="📤" label="Tool Result" content={resultJson} />;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 6 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |